michal/tit
Browse tree · Show commit · Download archive
Diff
c7d4b27868e7 → bc058d78daeb
Cargo.lock
Mode 100644 → 100644; object 3d38e38317ba → abedf5827148
@@ -40,6 +40,15 @@ ] [[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -365,6 +374,7 @@ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -853,6 +863,23 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "feed-rs" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "369995dae0733f1fe5ab0e3f345f6503a5f384179df5d8da333702031a131cf9" +dependencies = [ + "chrono", + "mediatype", + "quick-xml", + "regex", + "serde", + "serde_json", + "siphasher", + "url", + "uuid", +] [[package]] name = "ff" @@ -2335,6 +2362,15 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] +name = "mediatype" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" +dependencies = [ + "serde", +] + +[[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2773,6 +2809,16 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "encoding_rs", + "memchr", +] + +[[package]] name = "quote" version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2814,10 +2860,33 @@ ] [[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] name = "regex-automata" version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rfc6979" @@ -3474,8 +3543,11 @@ "askama", "axum", "clap", + "feed-rs", "gix", "gix-pack", + "httpdate", + "jiff", "pulldown-cmark", "rand", "rusqlite", @@ -3661,6 +3733,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] [[package]] name = "vcpkg"
Cargo.toml
Mode 100644 → 100644; object 9b8feff6154c → 6e66bc45730b
@@ -17,6 +17,8 @@
clap = { version = "4.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
gix = { version = "0.84", default-features = false, features = ["blame", "parallel", "sha1", "sha256"] }
gix-pack = { version = "0.71", default-features = false, features = ["generate", "sha1", "sha256", "streaming-input"] }
+httpdate = "1.0.3"
+jiff = { version = "0.2.34", default-features = false, features = ["std"] }
pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
rand = "0.10"
rusqlite = { version = "0.40", default-features = false, features = ["backup", "bundled"] }
@@ -31,6 +33,7 @@
url = { version = "2.5", features = ["serde"] }
[dev-dependencies]
+feed-rs = { version = "2.4.0", default-features = false }
tempfile = "3.27"
[lints.rust]
docs/adr/0007-public-repository-feeds.md
Mode → 100644; object → 1f4909617b11
@@ -1,0 +1,60 @@
+# Architectural decision record 0007: public repository feeds
+
+Status: Accepted
+
+Date: 2026-07-23
+
+## Context
+
+`tit` must publish public repository events as Atom and RSS. Feed entries must
+not describe a push before its Git refs are reachable. Feed pages need stable
+entry IDs, bounded pagination, and HTTP cache validators.
+
+## Decision
+
+Add an append-only `repository_event` table. Insert the repository creation or
+import event in the transaction that inserts the repository. For an import,
+also insert one event for each initial branch and tag.
+
+Insert a push event and one event for each changed branch or tag in the
+transaction that completes a Git operation intent. Do not insert these events
+for a pending, promoted, or abandoned intent. Use the immutable repository ID
+to connect an event to a repository. Use the event row ID in the public entry
+ID.
+
+Migration 006 adds the event table. It also adds one creation event for each
+repository that already exists. It cannot determine whether an old repository
+was created or imported, so it uses the creation event type.
+
+Publish `/{owner}/{repository}/atom.xml` and
+`/{owner}/{repository}/rss.xml`. Return at most 20 entries. Accept a positive
+`before` event ID and return older events. Add a `next` link when more events
+exist. Do not publish feeds for private or archived repositories.
+
+Use Atom entry IDs in this form:
+
+```text
+urn:tit:event:REPOSITORY_ID:EVENT_ID
+```
+
+Use SHA-256 over the response body for the ETag. Also return Last-Modified.
+Honor If-None-Match before If-Modified-Since. Return 304 without a response
+body when a validator matches.
+
+Generate XML with a small escaping function. Use `feed-rs` version 2.4.0 as an
+independent test parser for both output formats. Use `jiff` version 0.2.34 for
+Atom dates and `httpdate` version 1.0.3 for RSS and HTTP dates.
+
+## Evidence
+
+Storage tests cover imports, initial branches and tags, completed pushes,
+branch creation, and migration from schema version 5. The public-route test
+parses Atom and RSS, checks stable entry IDs, follows pagination, checks GET and
+HEAD, checks conditional requests, and hides feeds for private and archived
+repositories.
+
+## Consequences
+
+A completed event is immutable and has a stable ID. A repository rename changes
+the feed URL but does not change an entry ID. A feed reader can use the next
+link to read old events without an unbounded database query.
scripts/check-m2-7
Mode → 100755; object → b905f0dbacf2
@@ -1,0 +1,7 @@ +#!/bin/sh +set -eu + +./scripts/check +cargo test --locked --release --test sqlite creates_renames_archives_and_reads_owned_repositories +cargo test --locked --release --test sqlite backfills_repository_events_when_version_five_is_migrated +cargo test --locked --release --test public_routes browses_and_clones_public_repositories_for_both_hash_formats
src/admin.rs
Mode 100644 → 100644; object b97e46935980 → 4268bac3cf24
@@ -10,7 +10,9 @@
use crate::domain::repository::{RepositoryNameError, validate_slug};
use crate::git::repository::{GitRepository, GitRepositoryError};
use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
-use crate::store::{NewRepository, RepositoryRecord, Store, StoreError};
+use crate::store::{
+ NewRepository, NewRepositoryReference, RepositoryOrigin, RepositoryRecord, Store, StoreError,
+};
pub(crate) fn create_repository(
instance_dir: &Path,
@@ -19,10 +21,16 @@
object_format: Kind,
) -> Result<RepositoryRecord, AdminError> {
validate_names(owner, slug)?;
- administer_repository(instance_dir, owner, slug, |path| {
- GitRepository::create_bare(path, object_format)?;
- Ok(object_format)
- })
+ administer_repository(
+ instance_dir,
+ owner,
+ slug,
+ RepositoryOrigin::Created,
+ |path| {
+ GitRepository::create_bare(path, object_format)?;
+ Ok(object_format)
+ },
+ )
}
pub(crate) fn import_repository(
@@ -36,12 +44,18 @@
path: source.to_owned(),
source: source_error,
})?;
- administer_repository(instance_dir, owner, slug, |path| {
- if source.starts_with(path.parent().expect("a managed repository has a parent")) {
- return Err(AdminError::ManagedImport(source));
- }
- GitRepository::copy_bare(&source, path).map_err(Into::into)
- })
+ administer_repository(
+ instance_dir,
+ owner,
+ slug,
+ RepositoryOrigin::Imported,
+ |path| {
+ if source.starts_with(path.parent().expect("a managed repository has a parent")) {
+ return Err(AdminError::ManagedImport(source));
+ }
+ GitRepository::copy_bare(&source, path).map_err(Into::into)
+ },
+ )
}
pub(crate) fn rename_repository(
@@ -97,6 +111,7 @@
instance_dir: &Path,
owner: &str,
slug: &str,
+ origin: RepositoryOrigin,
prepare: impl FnOnce(&Path) -> Result<Kind, AdminError>,
) -> Result<RepositoryRecord, AdminError> {
let _lock = InstanceLock::acquire(instance_dir)?;
@@ -133,12 +148,26 @@
let created_at = timestamp()?;
let object_format = object_format_name(object_format)?;
+ let git = GitRepository::open(&canonical_path)?;
+ let initial_references = git
+ .references()?
+ .into_iter()
+ .filter(|reference| {
+ reference.name.starts_with(b"refs/heads/") || reference.name.starts_with(b"refs/tags/")
+ })
+ .map(|reference| NewRepositoryReference {
+ name: reference.name,
+ target: reference.target.to_string(),
+ })
+ .collect::<Vec<_>>();
if let Err(error) = store.create_repository(&NewRepository {
id: &id,
owner,
slug,
object_format,
created_at,
+ origin,
+ initial_references: &initial_references,
}) {
remove_created_repository(&canonical_path)?;
return Err(error.into());
src/feed.rs
Mode → 100644; object → ca31a497f93a
@@ -1,0 +1,234 @@
+use std::fmt::Write;
+use std::time::{Duration, UNIX_EPOCH};
+
+use thiserror::Error;
+
+use crate::store::{RepositoryEventRecord, RepositoryRecord};
+
+pub(crate) const PAGE_SIZE: usize = 20;
+
+pub(crate) enum FeedFormat {
+ Atom,
+ Rss,
+}
+
+pub(crate) struct FeedPage<'a> {
+ pub(crate) repository: &'a RepositoryRecord,
+ pub(crate) base_url: &'a str,
+ pub(crate) feed_url: &'a str,
+ pub(crate) self_url: &'a str,
+ pub(crate) events: &'a [RepositoryEventRecord],
+ pub(crate) next_before: Option<i64>,
+}
+
+impl FeedPage<'_> {
+ pub(crate) fn render(&self, format: FeedFormat) -> Result<String, FeedError> {
+ match format {
+ FeedFormat::Atom => self.atom(),
+ FeedFormat::Rss => self.rss(),
+ }
+ }
+
+ fn atom(&self) -> Result<String, FeedError> {
+ let repository_url = self.repository_url();
+ let updated = self
+ .events
+ .iter()
+ .map(|event| event.created_at)
+ .max()
+ .unwrap_or(self.repository.created_at);
+ let mut output = String::from(
+ "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n",
+ );
+ element(&mut output, "id", &self.feed_id())?;
+ element(&mut output, "title", &self.feed_title())?;
+ element(&mut output, "updated", &atom_date(updated)?)?;
+ empty_link(&mut output, "self", self.self_url)?;
+ empty_link(&mut output, "alternate", &repository_url)?;
+ if let Some(next) = self.next_url() {
+ empty_link(&mut output, "next", &next)?;
+ }
+ for event in self.events {
+ output.push_str("<entry>\n");
+ element(&mut output, "id", &event_id(&self.repository.id, event.id))?;
+ element(&mut output, "title", &event_title(event))?;
+ element(&mut output, "updated", &atom_date(event.created_at)?)?;
+ empty_link(&mut output, "alternate", &repository_url)?;
+ output.push_str("<author>");
+ element(&mut output, "name", &event.actor)?;
+ output.push_str("</author>\n<content type=\"text\">");
+ escape_xml(&event_description(event), &mut output)?;
+ output.push_str("</content>\n</entry>\n");
+ }
+ output.push_str("</feed>\n");
+ Ok(output)
+ }
+
+ fn rss(&self) -> Result<String, FeedError> {
+ let repository_url = self.repository_url();
+ let updated = self
+ .events
+ .iter()
+ .map(|event| event.created_at)
+ .max()
+ .unwrap_or(self.repository.created_at);
+ let mut output = String::from(
+ "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n<channel>\n",
+ );
+ element(&mut output, "title", &self.feed_title())?;
+ element(&mut output, "link", &repository_url)?;
+ element(
+ &mut output,
+ "description",
+ &format!("Public events for {}", self.feed_title()),
+ )?;
+ element(&mut output, "lastBuildDate", &rss_date(updated)?)?;
+ atom_link(&mut output, "self", self.self_url)?;
+ if let Some(next) = self.next_url() {
+ atom_link(&mut output, "next", &next)?;
+ }
+ for event in self.events {
+ output.push_str("<item>\n");
+ element(&mut output, "title", &event_title(event))?;
+ element(&mut output, "link", &repository_url)?;
+ write!(output, "<guid isPermaLink=\"false\">")?;
+ escape_xml(&event_id(&self.repository.id, event.id), &mut output)?;
+ output.push_str("</guid>\n");
+ element(&mut output, "pubDate", &rss_date(event.created_at)?)?;
+ element(&mut output, "description", &event_description(event))?;
+ output.push_str("</item>\n");
+ }
+ output.push_str("</channel>\n</rss>\n");
+ Ok(output)
+ }
+
+ fn feed_id(&self) -> String {
+ format!("urn:tit:repository:{}:events", self.repository.id)
+ }
+
+ fn feed_title(&self) -> String {
+ format!("{}/{} events", self.repository.owner, self.repository.slug)
+ }
+
+ fn repository_url(&self) -> String {
+ format!(
+ "{}/{}/{}",
+ self.base_url, self.repository.owner, self.repository.slug
+ )
+ }
+
+ fn next_url(&self) -> Option<String> {
+ self.next_before
+ .map(|before| format!("{}?before={before}", self.feed_url))
+ }
+}
+
+fn event_id(repository_id: &str, event_id: i64) -> String {
+ format!("urn:tit:event:{repository_id}:{event_id}")
+}
+
+fn event_title(event: &RepositoryEventRecord) -> String {
+ let reference = event
+ .ref_name
+ .as_deref()
+ .map(display_reference)
+ .unwrap_or_default();
+ match event.kind.as_str() {
+ "repository-created" => "Repository created".to_owned(),
+ "repository-imported" => "Repository imported".to_owned(),
+ "push" => format!("{} pushed", event.actor),
+ "ref-created" => format!("Branch {reference} created"),
+ "ref-updated" => format!("Branch {reference} updated"),
+ "ref-deleted" => format!("Branch {reference} deleted"),
+ "tag-created" => format!("Tag {reference} created"),
+ "tag-updated" => format!("Tag {reference} updated"),
+ "tag-deleted" => format!("Tag {reference} deleted"),
+ _ => "Repository event".to_owned(),
+ }
+}
+
+fn event_description(event: &RepositoryEventRecord) -> String {
+ let mut description = event_title(event);
+ if let Some(old) = &event.old_target {
+ write!(description, " from {old}").expect("a string write cannot fail");
+ }
+ if let Some(new) = &event.new_target {
+ write!(description, " to {new}").expect("a string write cannot fail");
+ }
+ description
+}
+
+fn display_reference(name: &[u8]) -> String {
+ let short = name
+ .strip_prefix(b"refs/heads/")
+ .or_else(|| name.strip_prefix(b"refs/tags/"))
+ .unwrap_or(name);
+ String::from_utf8_lossy(short).into_owned()
+}
+
+fn atom_date(timestamp: i64) -> Result<String, FeedError> {
+ Ok(jiff::Timestamp::from_second(timestamp)
+ .map_err(|_| FeedError::Timestamp)?
+ .to_string())
+}
+
+fn rss_date(timestamp: i64) -> Result<String, FeedError> {
+ let seconds = u64::try_from(timestamp).map_err(|_| FeedError::Timestamp)?;
+ let time = UNIX_EPOCH
+ .checked_add(Duration::from_secs(seconds))
+ .ok_or(FeedError::Timestamp)?;
+ Ok(httpdate::fmt_http_date(time))
+}
+
+fn element(output: &mut String, name: &str, value: &str) -> Result<(), FeedError> {
+ write!(output, "<{name}>")?;
+ escape_xml(value, output)?;
+ writeln!(output, "</{name}>")?;
+ Ok(())
+}
+
+fn empty_link(output: &mut String, relation: &str, href: &str) -> Result<(), FeedError> {
+ write!(output, "<link rel=\"")?;
+ escape_xml(relation, output)?;
+ output.push_str("\" href=\"");
+ escape_xml(href, output)?;
+ output.push_str("\" />\n");
+ Ok(())
+}
+
+fn atom_link(output: &mut String, relation: &str, href: &str) -> Result<(), FeedError> {
+ write!(output, "<atom:link rel=\"")?;
+ escape_xml(relation, output)?;
+ output.push_str("\" href=\"");
+ escape_xml(href, output)?;
+ output.push_str("\" />\n");
+ Ok(())
+}
+
+fn escape_xml(value: &str, output: &mut String) -> Result<(), FeedError> {
+ for character in value.chars() {
+ match character {
+ '&' => output.push_str("&"),
+ '<' => output.push_str("<"),
+ '>' => output.push_str(">"),
+ '"' => output.push_str("""),
+ '\'' => output.push_str("'"),
+ character
+ if (character.is_control() && !matches!(character, '\t' | '\n' | '\r'))
+ || matches!(character, '\u{fffe}' | '\u{ffff}') =>
+ {
+ output.push('\u{fffd}');
+ }
+ character => output.push(character),
+ }
+ }
+ Ok(())
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum FeedError {
+ #[error("event timestamp is outside the supported range")]
+ Timestamp,
+ #[error("cannot render the feed")]
+ Format(#[from] std::fmt::Error),
+}
src/http/public.rs
Mode 100644 → 100644; object 1d61929a4777 → 7e249a3517bb
@@ -2,11 +2,12 @@
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
+use std::time::{Duration, UNIX_EPOCH};
use askama::Template;
use axum::Router;
use axum::body::{Body, Bytes};
-use axum::extract::{DefaultBodyLimit, Extension, OriginalUri, Path as AxumPath, State};
+use axum::extract::{DefaultBodyLimit, Extension, OriginalUri, Path as AxumPath, Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::Response;
use axum::routing::{get, post};
@@ -14,12 +15,14 @@
use gix::hash::ObjectId;
use gix::objs::tree::EntryKind;
use serde::Deserialize;
+use sha2::{Digest, Sha256};
use thiserror::Error;
use tokio::sync::{Semaphore, mpsc};
use tokio_stream::wrappers::ReceiverStream;
use crate::auth::validate_username;
use crate::domain::repository::validate_slug;
+use crate::feed::{FeedFormat, FeedPage, PAGE_SIZE};
use crate::git::packetline::MAX_REQUEST_BYTES;
use crate::git::read::{
BlameHunk, CommitInfo, DiffFile, ReadCancellation, ReadError, ReadLimits,
@@ -94,6 +97,31 @@
};
let service = RepositoryReadService::open(&path, limits)?;
operation(repository, service)
+ })
+ .await
+ .map_err(|_| RouteError::Internal)?
+ }
+
+ async fn event_page(
+ &self,
+ owner: String,
+ repository: String,
+ before: Option<i64>,
+ ) -> Result<(RepositoryRecord, Vec<crate::store::RepositoryEventRecord>), RouteError> {
+ validate_username(&owner).map_err(|_| RouteError::NotFound)?;
+ validate_slug(&repository).map_err(|_| RouteError::NotFound)?;
+ let permit = self
+ .jobs
+ .clone()
+ .acquire_owned()
+ .await
+ .map_err(|_| RouteError::Unavailable)?;
+ let database = self.database.clone();
+ tokio::task::spawn_blocking(move || {
+ let _permit = permit;
+ Store::open(&database)?
+ .public_repository_events(&owner, &repository, before, PAGE_SIZE + 1)
+ .map_err(Into::into)
})
.await
.map_err(|_| RouteError::Internal)?
@@ -197,6 +225,8 @@
post(git_upload_pack).layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)),
)
.route("/{owner}/{repository}/refs", get(refs))
+ .route("/{owner}/{repository}/atom.xml", get(atom_feed))
+ .route("/{owner}/{repository}/rss.xml", get(rss_feed))
.route("/{owner}/{repository}/commit/{commit}", get(commit))
.route("/{owner}/{repository}/diff/{old}/{new}", get(diff))
.route("/{owner}/{repository}/tree/{commit}", get(tree_root))
@@ -206,6 +236,84 @@
.route("/{owner}/{repository}/blame/{commit}/{*path}", get(blame))
.route("/{owner}/{repository}/archive/{archive}", get(archive))
.route("/{owner}/{repository}", get(summary))
+}
+
+async fn atom_feed(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ AxumPath(path): AxumPath<RepositoryPath>,
+ Query(query): Query<FeedQuery>,
+ headers: HeaderMap,
+) -> Response {
+ feed_response(state, request_id, path, query, headers, FeedFormat::Atom).await
+}
+
+async fn rss_feed(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ AxumPath(path): AxumPath<RepositoryPath>,
+ Query(query): Query<FeedQuery>,
+ headers: HeaderMap,
+) -> Response {
+ feed_response(state, request_id, path, query, headers, FeedFormat::Rss).await
+}
+
+async fn feed_response(
+ state: WebState,
+ request_id: RequestId,
+ path: RepositoryPath,
+ query: FeedQuery,
+ headers: HeaderMap,
+ format: FeedFormat,
+) -> Response {
+ if matches!(query.before, Some(before) if before <= 0) {
+ return route_error(RouteError::InvalidRequest, &request_id.0);
+ }
+ let Some(web) = state.public else {
+ return route_error(RouteError::NotFound, &request_id.0);
+ };
+ let owner = path.owner;
+ let repository = path.repository;
+ let (record, mut events) = match web
+ .event_page(owner.clone(), repository.clone(), query.before)
+ .await
+ {
+ Ok(page) => page,
+ Err(error) => return route_error(error, &request_id.0),
+ };
+ let has_next = events.len() > PAGE_SIZE;
+ events.truncate(PAGE_SIZE);
+ let next_before = has_next
+ .then(|| events.last().map(|event| event.id))
+ .flatten();
+ let name = match format {
+ FeedFormat::Atom => "atom.xml",
+ FeedFormat::Rss => "rss.xml",
+ };
+ let feed_url = format!("{}/{owner}/{repository}/{name}", web.http_clone_base);
+ let self_url = query.before.map_or_else(
+ || feed_url.clone(),
+ |before| format!("{feed_url}?before={before}"),
+ );
+ let newest = events
+ .iter()
+ .map(|event| event.created_at)
+ .max()
+ .unwrap_or(record.created_at);
+ let body = match (FeedPage {
+ repository: &record,
+ base_url: &web.http_clone_base,
+ feed_url: &feed_url,
+ self_url: &self_url,
+ events: &events,
+ next_before,
+ })
+ .render(format)
+ {
+ Ok(body) => body,
+ Err(_) => return route_error(RouteError::Internal, &request_id.0),
+ };
+ conditional_feed(&headers, name, body, newest)
}
async fn summary(
@@ -583,6 +691,63 @@
}
}
+fn conditional_feed(headers: &HeaderMap, name: &str, body: String, timestamp: i64) -> Response {
+ let digest = Sha256::digest(body.as_bytes());
+ let etag = format!("\"{}\"", encode_hex(&digest));
+ let modified = u64::try_from(timestamp)
+ .ok()
+ .and_then(|seconds| UNIX_EPOCH.checked_add(Duration::from_secs(seconds)))
+ .unwrap_or(UNIX_EPOCH);
+ let not_modified = headers
+ .get(header::IF_NONE_MATCH)
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|value| etag_matches(value, &etag))
+ || (!headers.contains_key(header::IF_NONE_MATCH)
+ && headers
+ .get(header::IF_MODIFIED_SINCE)
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| httpdate::parse_http_date(value).ok())
+ .is_some_and(|value| modified <= value));
+ let status = if not_modified {
+ StatusCode::NOT_MODIFIED
+ } else {
+ StatusCode::OK
+ };
+ let content_type = if name == "atom.xml" {
+ "application/atom+xml; charset=utf-8"
+ } else {
+ "application/rss+xml; charset=utf-8"
+ };
+ Response::builder()
+ .status(status)
+ .header(header::CONTENT_TYPE, content_type)
+ .header(header::CACHE_CONTROL, "public, max-age=60")
+ .header(header::ETAG, etag)
+ .header(header::LAST_MODIFIED, httpdate::fmt_http_date(modified))
+ .body(if not_modified {
+ Body::empty()
+ } else {
+ Body::from(body)
+ })
+ .expect("the feed response is valid")
+}
+
+fn etag_matches(value: &str, etag: &str) -> bool {
+ value.split(',').any(|candidate| {
+ let candidate = candidate.trim();
+ candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag
+ })
+}
+
+fn encode_hex(bytes: &[u8]) -> String {
+ let mut output = String::with_capacity(bytes.len() * 2);
+ for byte in bytes {
+ output.push(char::from(b"0123456789abcdef"[usize::from(byte >> 4)]));
+ output.push(char::from(b"0123456789abcdef"[usize::from(byte & 0x0f)]));
+ }
+ output
+}
+
fn route_error(error: RouteError, request_id: &str) -> Response {
match error {
RouteError::NotFound => render_error(
@@ -788,6 +953,12 @@
struct RepositoryPath {
owner: String,
repository: String,
+}
+
+#[derive(Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct FeedQuery {
+ before: Option<i64>,
}
#[derive(Debug, Deserialize, Eq, PartialEq)]
src/main.rs
Mode 100644 → 100644; object b7137c4ba927 → 04feabf673b1
@@ -8,6 +8,7 @@ mod cli; mod config; mod domain; +mod feed; #[allow(dead_code, reason = "M1C proves Git reads before the CLI serves them")] mod git; #[allow(
src/store/migrations/006_repository_events.sql
Mode → 100644; object → 0c7b9eff1da7
@@ -1,0 +1,41 @@
+CREATE TABLE repository_event (
+ id INTEGER PRIMARY KEY,
+ repository_id TEXT NOT NULL
+ REFERENCES repository (id) ON DELETE RESTRICT,
+ source_intent_id TEXT
+ REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+ source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
+ kind TEXT NOT NULL
+ CHECK (kind IN (
+ 'repository-created', 'repository-imported', 'push',
+ 'ref-created', 'ref-updated', 'ref-deleted',
+ 'tag-created', 'tag-updated', 'tag-deleted'
+ )),
+ actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+ ref_name BLOB,
+ old_target TEXT,
+ new_target TEXT,
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ UNIQUE (source_intent_id, source_ordinal),
+ CHECK (
+ (source_intent_id IS NULL AND source_ordinal IS NULL)
+ OR (source_intent_id IS NOT NULL AND source_ordinal IS NOT NULL)
+ ),
+ CHECK (
+ (kind IN ('repository-created', 'repository-imported', 'push')
+ AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+ OR
+ (kind LIKE 'ref-%' OR kind LIKE 'tag-%')
+ AND ref_name IS NOT NULL
+ AND (old_target IS NOT NULL OR new_target IS NOT NULL)
+ )
+) STRICT;
+
+INSERT INTO repository_event (repository_id, kind, actor, created_at)
+SELECT repository.id, 'repository-created', account.username, repository.created_at
+FROM repository
+JOIN account ON account.id = repository.owner_account_id
+ORDER BY repository.created_at, repository.id;
+
+CREATE INDEX repository_event_feed
+ON repository_event (repository_id, id DESC);
src/store/mod.rs
Mode 100644 → 100644; object 31509e1ba88e → 7a572023ebd5
@@ -4,12 +4,12 @@
use rusqlite::OpenFlags;
use rusqlite::backup::Backup;
-use rusqlite::{Connection, TransactionBehavior};
+use rusqlite::{Connection, OptionalExtension, TransactionBehavior};
use thiserror::Error;
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 5;
+const SCHEMA_VERSION: i64 = 6;
#[allow(
dead_code,
reason = "the integration test imports this module without the CLI operation"
@@ -19,12 +19,13 @@
dead_code,
reason = "M1A proves migrations before the M2 server calls them"
)]
-const MIGRATIONS: [&str; 5] = [
+const MIGRATIONS: [&str; 6] = [
include_str!("migrations/001_initial.sql"),
include_str!("migrations/002_state.sql"),
include_str!("migrations/003_git_intents.sql"),
include_str!("migrations/004_identity.sql"),
include_str!("migrations/005_repository.sql"),
+ include_str!("migrations/006_repository_events.sql"),
];
#[derive(Debug, Error)]
@@ -65,6 +66,14 @@
RepositoryIdentifierCollision,
#[error("repository is already archived: {0}/{1}")]
RepositoryArchived(String, String),
+ #[allow(
+ dead_code,
+ reason = "some integration tests import storage without public event pages"
+ )]
+ #[error("repository event page limit is too large")]
+ EventLimit,
+ #[error("stored Git reference event is malformed")]
+ EventPayload,
}
pub(crate) struct Store {
@@ -241,11 +250,28 @@
let transaction = self
.connection
.transaction_with_behavior(TransactionBehavior::Immediate)?;
- let payload: Vec<u8> = transaction.query_row(
- "SELECT event_payload FROM git_operation_intent
+ let (payload, repository_path, actor, initial_refs, proposed_refs, created_at): (
+ Vec<u8>,
+ String,
+ String,
+ Vec<u8>,
+ Vec<u8>,
+ i64,
+ ) = transaction.query_row(
+ "SELECT event_payload, repository_path, actor, initial_refs, proposed_refs, created_at
+ FROM git_operation_intent
WHERE id = ?1 AND state = 'promoted'",
[id],
- |row| row.get(0),
+ |row| {
+ Ok((
+ row.get(0)?,
+ row.get(1)?,
+ row.get(2)?,
+ row.get(3)?,
+ row.get(4)?,
+ row.get(5)?,
+ ))
+ },
)?;
let changed = transaction.execute(
"UPDATE git_operation_intent SET state = 'completed'
@@ -256,6 +282,15 @@
transaction.execute(
"INSERT INTO git_operation_event (intent_id, payload) VALUES (?1, ?2)",
rusqlite::params![id, payload],
+ )?;
+ insert_push_events(
+ &transaction,
+ id,
+ &repository_path,
+ &actor,
+ &initial_refs,
+ &proposed_refs,
+ created_at,
)?;
transaction.commit()?;
Ok(())
@@ -359,7 +394,42 @@
],
);
match result {
- Ok(1) => transaction.commit()?,
+ Ok(1) => {
+ transaction.execute(
+ "INSERT INTO repository_event
+ (repository_id, kind, actor, created_at)
+ VALUES (?1, ?2, ?3, ?4)",
+ rusqlite::params![
+ repository.id,
+ repository.origin.event_kind(),
+ repository.owner,
+ repository.created_at,
+ ],
+ )?;
+ for reference in repository.initial_references {
+ let kind = if reference.name.starts_with(b"refs/tags/") {
+ "tag-created"
+ } else if reference.name.starts_with(b"refs/heads/") {
+ "ref-created"
+ } else {
+ return Err(StoreError::EventPayload);
+ };
+ transaction.execute(
+ "INSERT INTO repository_event
+ (repository_id, kind, actor, ref_name, new_target, created_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+ rusqlite::params![
+ repository.id,
+ kind,
+ repository.owner,
+ reference.name,
+ reference.target,
+ repository.created_at,
+ ],
+ )?;
+ }
+ transaction.commit()?;
+ }
Ok(_) => unreachable!("an INSERT changes one row"),
Err(error) if is_unique_constraint(&error) => {
let duplicate_id: bool = transaction.query_row(
@@ -494,6 +564,42 @@
Err(error) => Err(error.into()),
}
}
+
+ #[allow(
+ dead_code,
+ reason = "some integration tests import storage without public event pages"
+ )]
+ pub(crate) fn public_repository_events(
+ &self,
+ owner: &str,
+ slug: &str,
+ before: Option<i64>,
+ limit: usize,
+ ) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
+ let repository = self.public_repository(owner, slug)?;
+ let limit = i64::try_from(limit).map_err(|_| StoreError::EventLimit)?;
+ let mut statement = self.connection.prepare(
+ "SELECT id, kind, actor, ref_name, old_target, new_target, created_at
+ FROM repository_event
+ WHERE repository_id = ?1 AND (?2 IS NULL OR id < ?2)
+ ORDER BY id DESC
+ LIMIT ?3",
+ )?;
+ let events = statement
+ .query_map(rusqlite::params![repository.id, before, limit], |row| {
+ Ok(RepositoryEventRecord {
+ id: row.get(0)?,
+ kind: row.get(1)?,
+ actor: row.get(2)?,
+ ref_name: row.get(3)?,
+ old_target: row.get(4)?,
+ new_target: row.get(5)?,
+ created_at: row.get(6)?,
+ })
+ })?
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok((repository, events))
+ }
}
pub(crate) struct InitialAdministrator<'a> {
@@ -510,6 +616,32 @@
pub(crate) slug: &'a str,
pub(crate) object_format: &'a str,
pub(crate) created_at: i64,
+ pub(crate) origin: RepositoryOrigin,
+ pub(crate) initial_references: &'a [NewRepositoryReference],
+}
+
+#[derive(Clone, Copy)]
+#[allow(
+ dead_code,
+ reason = "some integration tests create repositories without the import operation"
+)]
+pub(crate) enum RepositoryOrigin {
+ Created,
+ Imported,
+}
+
+impl RepositoryOrigin {
+ fn event_kind(self) -> &'static str {
+ match self {
+ Self::Created => "repository-created",
+ Self::Imported => "repository-imported",
+ }
+ }
+}
+
+pub(crate) struct NewRepositoryReference {
+ pub(crate) name: Vec<u8>,
+ pub(crate) target: String,
}
#[derive(Debug, Eq, PartialEq)]
@@ -522,6 +654,20 @@
pub(crate) object_format: String,
pub(crate) created_at: i64,
pub(crate) archived_at: Option<i64>,
+}
+
+#[allow(
+ dead_code,
+ reason = "some integration tests import storage without public event pages"
+)]
+pub(crate) struct RepositoryEventRecord {
+ pub(crate) id: i64,
+ pub(crate) kind: String,
+ pub(crate) actor: String,
+ pub(crate) ref_name: Option<Vec<u8>>,
+ pub(crate) old_target: Option<String>,
+ pub(crate) new_target: Option<String>,
+ pub(crate) created_at: i64,
}
pub(crate) struct GitOperationIntent<'a> {
@@ -543,6 +689,116 @@
pub(crate) quarantine_path: String,
pub(crate) state: String,
pub(crate) pack_name: Option<String>,
+}
+
+fn insert_push_events(
+ transaction: &rusqlite::Transaction<'_>,
+ intent_id: &str,
+ repository_path: &str,
+ actor: &str,
+ initial_bytes: &[u8],
+ proposed_bytes: &[u8],
+ created_at: i64,
+) -> Result<(), StoreError> {
+ let Some(repository_id) = managed_repository_id(repository_path) else {
+ return Ok(());
+ };
+ let exists = transaction
+ .query_row(
+ "SELECT id FROM repository WHERE id = ?1",
+ [repository_id],
+ |row| row.get::<_, String>(0),
+ )
+ .optional()?;
+ if exists.is_none() {
+ return Ok(());
+ }
+
+ let initial = parse_event_refs(initial_bytes)?;
+ let proposed = parse_event_refs(proposed_bytes)?;
+ if initial.len() != proposed.len() {
+ return Err(StoreError::EventPayload);
+ }
+ transaction.execute(
+ "INSERT INTO repository_event
+ (repository_id, source_intent_id, source_ordinal, kind, actor, created_at)
+ VALUES (?1, ?2, 0, 'push', ?3, ?4)",
+ rusqlite::params![repository_id, intent_id, actor, created_at],
+ )?;
+ for (index, ((old, old_name), (new, new_name))) in initial.into_iter().zip(proposed).enumerate()
+ {
+ if old_name != new_name || (is_null_id(&old) && is_null_id(&new)) {
+ return Err(StoreError::EventPayload);
+ }
+ let prefix = if old_name.starts_with(b"refs/tags/") {
+ "tag"
+ } else if old_name.starts_with(b"refs/heads/") {
+ "ref"
+ } else {
+ return Err(StoreError::EventPayload);
+ };
+ let action = if is_null_id(&old) {
+ "created"
+ } else if is_null_id(&new) {
+ "deleted"
+ } else {
+ "updated"
+ };
+ let kind = format!("{prefix}-{action}");
+ let old_target = (!is_null_id(&old)).then_some(old);
+ let new_target = (!is_null_id(&new)).then_some(new);
+ let ordinal = i64::try_from(index + 1).map_err(|_| StoreError::EventPayload)?;
+ transaction.execute(
+ "INSERT INTO repository_event
+ (repository_id, source_intent_id, source_ordinal, kind, actor,
+ ref_name, old_target, new_target, created_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
+ rusqlite::params![
+ repository_id,
+ intent_id,
+ ordinal,
+ kind,
+ actor,
+ old_name,
+ old_target,
+ new_target,
+ created_at,
+ ],
+ )?;
+ }
+ Ok(())
+}
+
+fn managed_repository_id(repository_path: &str) -> Option<&str> {
+ let name = Path::new(repository_path).file_name()?.to_str()?;
+ let id = name.strip_suffix(".git")?;
+ (id.len() == 32 && id.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(id)
+}
+
+fn parse_event_refs(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, StoreError> {
+ let mut references = Vec::new();
+ for line in bytes.split(|byte| *byte == b'\n') {
+ if line.is_empty() {
+ continue;
+ }
+ let Some(space) = line.iter().position(|byte| *byte == b' ') else {
+ return Err(StoreError::EventPayload);
+ };
+ let id = std::str::from_utf8(&line[..space]).map_err(|_| StoreError::EventPayload)?;
+ if !matches!(id.len(), 40 | 64) || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ return Err(StoreError::EventPayload);
+ }
+ let name = line[space + 1..].to_vec();
+ if name.is_empty() {
+ return Err(StoreError::EventPayload);
+ }
+ references.push((id.to_owned(), name));
+ }
+ Ok(references)
+}
+
+fn is_null_id(id: &str) -> bool {
+ id.bytes().all(|byte| byte == b'0')
}
fn require_one_intent(id: &str, changed: usize) -> Result<(), StoreError> {
templates/repository.html
Mode 100644 → 100644; object 02a5bb605c73 → a236daaf5ebf
@@ -6,6 +6,8 @@
<nav aria-label="Repository">
<a href="/{{ owner }}/{{ repository }}">Summary</a>
<a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
+ <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
+ <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
{% if has_head %}
<a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">Tree</a>
<a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}">Commit</a>
tests/cli.rs
Mode 100644 → 100644; object 1e9b2ef02902 → 91b9aaa1e01d
@@ -12,7 +12,11 @@
};
use tempfile::TempDir;
-const V5_DATABASE: &str = include_str!("fixtures/sqlite/v5.sql");
+const V6_DATABASE: &str = concat!(
+ include_str!("fixtures/sqlite/v5.sql"),
+ include_str!("../src/store/migrations/006_repository_events.sql"),
+ "PRAGMA user_version = 6;\n",
+);
#[test]
fn help_and_version_use_standard_output() {
@@ -67,7 +71,7 @@
let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
.expect("open the instance database");
database
- .execute_batch(V5_DATABASE)
+ .execute_batch(V6_DATABASE)
.expect("create the current database");
drop(database);
@@ -119,7 +123,7 @@
let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
.expect("open the instance database");
database
- .execute_batch(V5_DATABASE)
+ .execute_batch(V6_DATABASE)
.expect("create the current database");
database
.pragma_update(None, "foreign_keys", false)
tests/public_routes.rs
Mode 100644 → 100644; object 342a3023b837 → f8a6474db2c4
@@ -6,6 +6,8 @@
mod auth;
#[path = "../src/domain/mod.rs"]
mod domain;
+#[path = "../src/feed.rs"]
+mod feed;
#[allow(
dead_code,
reason = "the public-route test does not use each shared Git API"
@@ -41,7 +43,7 @@
use std::process::Command;
use http::{PublicWebConfig, RunningWebServer};
-use store::{InitialAdministrator, NewRepository, Store};
+use store::{InitialAdministrator, NewRepository, RepositoryOrigin, Store};
use tempfile::TempDir;
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -77,6 +79,106 @@
assert!(!summary_text.contains("<img"));
assert!(!summary_text.contains("tracker.example"));
assert!(summary_text.contains("<script>alert(3)</script>"));
+ assert!(summary_text.contains("/alice/example/atom.xml"));
+ assert!(summary_text.contains("/alice/example/rss.xml"));
+
+ let mut feed_entry_ids = Vec::new();
+ for (path, content_type) in [
+ (
+ "/alice/example/atom.xml",
+ "application/atom+xml; charset=utf-8",
+ ),
+ (
+ "/alice/example/rss.xml",
+ "application/rss+xml; charset=utf-8",
+ ),
+ ] {
+ let feed = request(server.address(), "GET", path, &[], &[]);
+ assert_eq!(feed.status, 200);
+ assert_eq!(feed.header("content-type"), content_type);
+ assert_eq!(feed.header("cache-control"), "public, max-age=60");
+ assert!(!feed.header("etag").is_empty());
+ assert!(!feed.header("last-modified").is_empty());
+ let parsed = feed_rs::parser::parse(feed.body.as_slice()).expect("parse the feed");
+ assert_eq!(parsed.entries.len(), 1);
+ assert!(parsed.entries[0].id.starts_with("urn:tit:event:"));
+ feed_entry_ids.push(parsed.entries[0].id.clone());
+ assert!(feed.text().contains("Repository imported"));
+
+ let etag = feed.header("etag");
+ let conditional = request(
+ server.address(),
+ "GET",
+ path,
+ &[("If-None-Match", etag)],
+ &[],
+ );
+ assert_eq!(conditional.status, 304);
+ assert!(conditional.body.is_empty());
+
+ let modified = request(
+ server.address(),
+ "GET",
+ path,
+ &[("If-Modified-Since", feed.header("last-modified"))],
+ &[],
+ );
+ assert_eq!(modified.status, 304);
+ assert!(modified.body.is_empty());
+
+ let head = request(server.address(), "HEAD", path, &[], &[]);
+ assert_eq!(head.status, 200);
+ assert!(head.body.is_empty());
+ assert_eq!(head.header("etag"), feed.header("etag"));
+ }
+ assert_eq!(feed_entry_ids[0], feed_entry_ids[1]);
+
+ let invalid_page = request(
+ server.address(),
+ "GET",
+ "/alice/example/atom.xml?before=0",
+ &[],
+ &[],
+ );
+ assert_eq!(invalid_page.status, 400);
+
+ let database = fixture.instance.path().join("tit.sqlite3");
+ let feed_store = Store::open(&database).expect("open the feed database");
+ for timestamp in 10..35 {
+ let actor = if timestamp == 34 {
+ "x</title><script>alert(5)</script>"
+ } else {
+ "alice"
+ };
+ feed_store
+ .connection()
+ .execute(
+ "INSERT INTO repository_event
+ (repository_id, kind, actor, created_at)
+ VALUES (?1, 'push', ?2, ?3)",
+ rusqlite::params![fixture.repository_id, actor, timestamp],
+ )
+ .expect("insert a page fixture event");
+ }
+ drop(feed_store);
+ let first_page = request(server.address(), "GET", "/alice/example/atom.xml", &[], &[]);
+ let first_feed =
+ feed_rs::parser::parse(first_page.body.as_slice()).expect("parse the first page");
+ assert_eq!(first_feed.entries.len(), 20);
+ assert!(!first_page.text().contains("<script>"));
+ assert!(first_page.text().contains("x</title><script>"));
+ let next = first_page
+ .text()
+ .split("rel=\"next\" href=\"")
+ .nth(1)
+ .and_then(|value| value.split('\"').next())
+ .expect("find the next-page URL")
+ .replace("https://tit.example", "");
+ let second_page = request(server.address(), "GET", &next, &[], &[]);
+ let second_feed =
+ feed_rs::parser::parse(second_page.body.as_slice()).expect("parse the second page");
+ assert_eq!(second_feed.entries.len(), 6);
+ assert!(!second_page.text().contains("rel=\"next\""));
let empty = request(server.address(), "GET", "/alice/empty", &[], &[]);
assert_eq!(empty.status, 200);
@@ -359,6 +461,8 @@
for route in [
"/alice/example".to_owned(),
format!("/alice/example/raw/{head}/README.md"),
+ "/alice/example/atom.xml".to_owned(),
+ "/alice/example/rss.xml".to_owned(),
"/alice/example/info/refs?service=git-upload-pack".to_owned(),
] {
let response = request(address, "GET", &route, &[], &[]);
@@ -368,6 +472,7 @@
struct Fixture {
instance: TempDir,
+ repository_id: String,
head: String,
parent: String,
}
@@ -389,6 +494,11 @@
run(Command::new("git")
.args(["init", "-q", "-b", "main", "--object-format", format])
.arg(&worktree));
+ run(Command::new("git").arg("-C").arg(&worktree).args([
+ "config",
+ "commit.gpgsign",
+ "false",
+ ]));
fs::write(
worktree.join("README.md"),
b"# tit fixture\n\n**safe** and `<safe>`\n\n[guide](docs/guide.md) [bad](javascript:alert(1))\n\n\n\n<script>alert(2)</script>\n",
@@ -450,6 +560,8 @@
slug: "example",
object_format: format,
created_at: 2,
+ origin: RepositoryOrigin::Imported,
+ initial_references: &[],
})
.expect("create the repository record");
store
@@ -459,12 +571,15 @@
slug: "empty",
object_format: format,
created_at: 2,
+ origin: RepositoryOrigin::Created,
+ initial_references: &[],
})
.expect("create the empty repository record");
drop(store);
Self {
instance,
+ repository_id: id.to_owned(),
head,
parent,
}
tests/sqlite.rs
Mode 100644 → 100644; object 353eb3086014 → ec95cda5c1c7
@@ -9,13 +9,17 @@
use std::{env, ffi::OsString, fs};
use rusqlite::{Connection, ErrorCode, TransactionBehavior, params};
-use store::{GitOperationIntent, InitialAdministrator, NewRepository, Store, StoreError};
+use store::{
+ GitOperationIntent, InitialAdministrator, NewRepository, NewRepositoryReference,
+ RepositoryOrigin, Store, StoreError,
+};
use tempfile::TempDir;
const V1_FIXTURE: &str = include_str!("fixtures/sqlite/v1.sql");
const V2_FIXTURE: &str = include_str!("fixtures/sqlite/v2.sql");
const V3_FIXTURE: &str = include_str!("fixtures/sqlite/v3.sql");
const V4_FIXTURE: &str = include_str!("fixtures/sqlite/v4.sql");
+const V5_FIXTURE: &str = include_str!("fixtures/sqlite/v5.sql");
fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
directory.path().join(name)
@@ -133,7 +137,7 @@
let directory = TempDir::new().expect("create a temporary directory");
let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
- assert_eq!(store.schema_version().expect("read the schema version"), 5);
+ assert_eq!(store.schema_version().expect("read the schema version"), 6);
assert_eq!(
store
.connection()
@@ -304,12 +308,24 @@
created_at: 10,
})
.expect("create an account");
+ let initial_references = [
+ NewRepositoryReference {
+ name: b"refs/heads/main".to_vec(),
+ target: "1".repeat(64),
+ },
+ NewRepositoryReference {
+ name: b"refs/tags/v1".to_vec(),
+ target: "2".repeat(64),
+ },
+ ];
let repository = NewRepository {
id: "00112233445566778899aabbccddeeff",
owner: "alice",
slug: "project",
object_format: "sha256",
created_at: 20,
+ origin: RepositoryOrigin::Imported,
+ initial_references: &initial_references,
};
store
.create_repository(&repository)
@@ -325,6 +341,51 @@
assert_eq!(created.object_format, "sha256");
assert_eq!(created.created_at, 20);
assert_eq!(created.archived_at, None);
+ let (_, imported_events) = store
+ .public_repository_events("alice", "project", None, 10)
+ .expect("read imported repository events");
+ assert_eq!(imported_events.len(), 3);
+ assert_eq!(imported_events[0].kind, "tag-created");
+ assert_eq!(imported_events[1].kind, "ref-created");
+ assert_eq!(imported_events[2].kind, "repository-imported");
+
+ let initial = format!("{} refs/heads/main\n", "0".repeat(64));
+ let proposed = format!("{} refs/heads/main\n", "a".repeat(64));
+ let push = GitOperationIntent {
+ id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ repository_path: "/srv/tit/repositories/00112233445566778899aabbccddeeff.git",
+ actor: "alice",
+ initial_refs: initial.as_bytes(),
+ proposed_refs: proposed.as_bytes(),
+ event_payload: proposed.as_bytes(),
+ quarantine_path: "/srv/tit/quarantine/push",
+ created_at: 21,
+ };
+ store.begin_git_intent(&push).expect("begin a managed push");
+ let (_, pending_events) = store
+ .public_repository_events("alice", "project", None, 10)
+ .expect("read events while a push is pending");
+ assert_eq!(pending_events.len(), 3);
+ store
+ .mark_git_objects_promoted(push.id, None)
+ .expect("promote a managed push");
+ let (_, promoted_events) = store
+ .public_repository_events("alice", "project", None, 10)
+ .expect("read events while a push is promoted");
+ assert_eq!(promoted_events.len(), 3);
+ store
+ .complete_git_intent(push.id)
+ .expect("complete a managed push");
+ let (_, pushed_events) = store
+ .public_repository_events("alice", "project", None, 10)
+ .expect("read pushed repository events");
+ assert_eq!(pushed_events.len(), 5);
+ assert_eq!(pushed_events[0].kind, "ref-created");
+ assert_eq!(
+ pushed_events[0].new_target.as_deref(),
+ Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+ );
+ assert_eq!(pushed_events[1].kind, "push");
assert!(matches!(
store.create_repository(&repository),
@@ -345,6 +406,8 @@
slug: "project",
object_format: "sha1",
created_at: 20,
+ origin: RepositoryOrigin::Created,
+ initial_references: &[],
};
assert!(matches!(
store.create_repository(&missing_owner),
@@ -708,13 +771,14 @@
(V2_FIXTURE, 2),
(V3_FIXTURE, 3),
(V4_FIXTURE, 4),
+ (V5_FIXTURE, 5),
] {
let directory = TempDir::new().expect("create a temporary directory");
let path = database(&directory, "tit.sqlite3");
create_fixture(&path, fixture);
let store = Store::open(&path).expect("migrate the fixture");
- assert_eq!(store.schema_version().expect("read the schema version"), 5);
+ assert_eq!(store.schema_version().expect("read the schema version"), 6);
store.integrity_check().expect("check migrated integrity");
let state: String = store
.connection()
@@ -742,8 +806,45 @@
}
#[test]
+fn backfills_repository_events_when_version_five_is_migrated() {
+ let directory = TempDir::new().expect("create a temporary directory");
+ let path = database(&directory, "tit.sqlite3");
+ create_fixture(&path, V5_FIXTURE);
+ let connection = Connection::open(&path).expect("open the version-five fixture");
+ connection
+ .execute(
+ "INSERT INTO account
+ (id, username, is_administrator, state, created_at)
+ VALUES (1, 'alice', 1, 'active', 1)",
+ [],
+ )
+ .expect("insert a historical account");
+ connection
+ .execute(
+ "INSERT INTO repository
+ (id, owner_account_id, slug, visibility, state, object_format, created_at)
+ VALUES ('00112233445566778899aabbccddeeff', 1, 'project', 'public',
+ 'active', 'sha1', 2)",
+ [],
+ )
+ .expect("insert a historical repository");
+ connection
+ .pragma_update(None, "user_version", 5)
+ .expect("set the historical schema version");
+ drop(connection);
+
+ let store = Store::open(&path).expect("migrate the version-five fixture");
+ let (_, events) = store
+ .public_repository_events("alice", "project", None, 10)
+ .expect("read the backfilled event");
+ assert_eq!(events.len(), 1);
+ assert_eq!(events[0].kind, "repository-created");
+ assert_eq!(events[0].created_at, 2);
+}
+
+#[test]
fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
- for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 5)] {
+ for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 6)] {
let directory = TempDir::new().expect("create a temporary directory");
let path = database(&directory, "fixture.sqlite");
create_fixture(&path, V1_FIXTURE);
tests/web_shell.rs
Mode 100644 → 100644; object 103c842f0b8a → abf37bf22e15
@@ -7,6 +7,8 @@ )] #[path = "../src/domain/mod.rs"] mod domain; +#[path = "../src/feed.rs"] +mod feed; #[allow(dead_code, reason = "the shell test does not use each shared Git API")] #[path = "../src/git/mod.rs"] mod git;