michal/tit
Browse tree · Show commit · Download archive
Diff
6962618a1764 → 3705d21c774e
Cargo.lock
Mode 100644 → 100644; object 0ecdf91f609b → 37a8f7dfbc6f
@@ -521,6 +521,20 @@ ] [[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -772,10 +786,26 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" @@ -942,6 +972,9 @@ checksum = "ae54ae0ebd1a5a3c3f8d95dd3b5ca6e63f4fed9bfd585e13801a97d7bde8f9ce" dependencies = [ "gix-actor", + "gix-attributes", + "gix-blame", + "gix-command", "gix-commitgraph", "gix-config", "gix-date", @@ -949,15 +982,19 @@ "gix-discover", "gix-error", "gix-features", + "gix-filter", "gix-fs", "gix-glob", "gix-hash", "gix-hashtable", + "gix-ignore", + "gix-index", "gix-lock", "gix-object", "gix-odb", "gix-pack", "gix-path", + "gix-pathspec", "gix-protocol", "gix-ref", "gix-refspec", @@ -965,12 +1002,14 @@ "gix-revwalk", "gix-sec", "gix-shallow", + "gix-submodule", "gix-tempfile", "gix-trace", "gix-traverse", "gix-url", "gix-utils", "gix-validate", + "gix-worktree", "gix-worktree-stream", "nonempty", "smallvec", @@ -1003,6 +1042,35 @@ "smallvec", "thiserror", "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-blame" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d39a0c14af94c2edaa5eefe06d5ef2cdea55316ae9a9321314288e3f55fa4c0" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-diff", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "gix-trace", + "gix-traverse", + "gix-worktree", + "smallvec", + "thiserror", ] [[package]] @@ -1091,8 +1159,17 @@ checksum = "3b6d9528f32d94cef2edf39a1ac01fe5a0fc44ddbb18d9e44099936047c3302b" dependencies = [ "bstr", + "gix-command", + "gix-filter", + "gix-fs", "gix-hash", + "gix-imara-diff", "gix-object", + "gix-path", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", "thiserror", ] @@ -1213,6 +1290,57 @@ ] [[package]] +name = "gix-ignore" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d491bab9bf2c9f341dc754f425c31d5d3f63aca615312167b82e1deeaca97d8d" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19753d40da53d0ec41604750eeb969097a90fb2d7f7992730d904541c04e2c19" +dependencies = [ + "bstr", + "hashbrown 0.16.1", +] + +[[package]] +name = "gix-index" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6b28cc592dc753adb58302bb14a64e412ee591a3bec77aa4df87bff74fa80d" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror", +] + +[[package]] name = "gix-lock" version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1307,6 +1435,21 @@ "bstr", "gix-trace", "gix-validate", + "thiserror", +] + +[[package]] +name = "gix-pathspec" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3050783b41ee11511e1e8fb35623df81806194f4030395f14f48ea37c2798c9f" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", "thiserror", ] @@ -1434,11 +1577,27 @@ ] [[package]] +name = "gix-submodule" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3059890ef054066c22a94bfc6a3eaba0d806aedcd630a0bc9e5783fd88884781" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror", +] + +[[package]] name = "gix-tempfile" version = "23.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f" dependencies = [ + "dashmap", "gix-fs", "libc", "parking_lot", @@ -1517,6 +1676,24 @@ ] [[package]] +name = "gix-worktree" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef414ed275e8407cd5d53d301e83be19700b0dd3f859d2434417b58f454a2d1" +dependencies = [ + "bstr", + "gix-attributes", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] name = "gix-worktree-stream" version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1556,6 +1733,12 @@ [[package]] name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" @@ -1570,6 +1753,11 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heapless"
Cargo.toml
Mode 100644 → 100644; object 12af51e0a56e → fa8df52d4574
@@ -13,7 +13,7 @@
[dependencies]
axum = { version = "0.8", default-features = false, features = ["http1", "query", "tokio"] }
clap = { version = "4.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
-gix = { version = "0.84", default-features = false, features = ["parallel", "sha1", "sha256"] }
+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"] }
rand = "0.10"
rusqlite = { version = "0.40", default-features = false, features = ["backup", "bundled"] }
scripts/check-m2-3
Mode → 100755; object → a692cff9b2fb
@@ -1,0 +1,5 @@ +#!/bin/sh +set -eu + +./scripts/check +cargo test --locked --release --test git_reads --test git_repository
src/git/mod.rs
Mode 100644 → 100644; object d3e4cb4664a6 → db96461ed5b4
@@ -1,5 +1,10 @@ pub(crate) mod http; pub(crate) mod packetline; +#[allow( + dead_code, + reason = "M2.3 establishes repository reads before the HTTP handlers call them" +)] +pub(crate) mod read; pub(crate) mod receive_pack; pub(crate) mod repository; pub(crate) mod transport;
src/git/read.rs
Mode → 100644; object → 83bb27f4e256
@@ -1,0 +1,1043 @@
+use std::collections::{BTreeMap, HashSet, VecDeque};
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::time::{Duration, Instant};
+
+use gix::bstr::ByteSlice;
+use gix::hash::ObjectId;
+use gix::objs::Kind as ObjectKind;
+use gix::objs::tree::{EntryKind, EntryMode};
+use gix::prelude::HeaderExt;
+use thiserror::Error;
+
+#[derive(Clone, Debug)]
+pub(crate) struct ReadLimits {
+ pub(crate) max_refs: usize,
+ pub(crate) max_history_commits: usize,
+ pub(crate) max_path_bytes: usize,
+ pub(crate) max_tree_bytes: usize,
+ pub(crate) max_tree_entries: usize,
+ pub(crate) max_commit_bytes: usize,
+ pub(crate) max_blob_bytes: usize,
+ pub(crate) max_diff_bytes: usize,
+ pub(crate) max_archive_entries: usize,
+ pub(crate) max_archive_bytes: usize,
+ pub(crate) max_duration: Duration,
+}
+
+impl Default for ReadLimits {
+ fn default() -> Self {
+ Self {
+ max_refs: 10_000,
+ max_history_commits: 10_000,
+ max_path_bytes: 4096,
+ max_tree_bytes: 16 * 1024 * 1024,
+ max_tree_entries: 100_000,
+ max_commit_bytes: 1024 * 1024,
+ max_blob_bytes: 16 * 1024 * 1024,
+ max_diff_bytes: 64 * 1024 * 1024,
+ max_archive_entries: 100_000,
+ max_archive_bytes: 256 * 1024 * 1024,
+ max_duration: Duration::from_secs(30),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Default)]
+pub(crate) struct ReadCancellation {
+ cancelled: Arc<AtomicBool>,
+}
+
+impl ReadCancellation {
+ pub(crate) fn cancel(&self) {
+ self.cancelled.store(true, Ordering::Relaxed);
+ }
+}
+
+pub(crate) struct RepositoryReadService {
+ repository: gix::Repository,
+ limits: ReadLimits,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct RefInfo {
+ pub(crate) name: Vec<u8>,
+ pub(crate) target: ObjectId,
+ pub(crate) peeled: Option<ObjectId>,
+ pub(crate) symbolic_target: Option<Vec<u8>>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct CommitInfo {
+ pub(crate) id: ObjectId,
+ pub(crate) tree: ObjectId,
+ pub(crate) parents: Vec<ObjectId>,
+ pub(crate) author_name: Vec<u8>,
+ pub(crate) author_email: Vec<u8>,
+ pub(crate) committed_at: i64,
+ pub(crate) message: Vec<u8>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct TreeEntryInfo {
+ pub(crate) name: Vec<u8>,
+ pub(crate) id: ObjectId,
+ pub(crate) mode: u16,
+ pub(crate) kind: EntryKind,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct BlobInfo {
+ pub(crate) id: ObjectId,
+ pub(crate) mode: u16,
+ pub(crate) data: Vec<u8>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct ReadmeInfo {
+ pub(crate) path: Vec<u8>,
+ pub(crate) blob: BlobInfo,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct DiffFile {
+ pub(crate) path: Vec<u8>,
+ pub(crate) old_id: Option<ObjectId>,
+ pub(crate) new_id: Option<ObjectId>,
+ pub(crate) old_mode: Option<u16>,
+ pub(crate) new_mode: Option<u16>,
+ pub(crate) binary: bool,
+ pub(crate) hunks: Vec<u8>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct BlameHunk {
+ pub(crate) start_line: u32,
+ pub(crate) source_start_line: u32,
+ pub(crate) line_count: u32,
+ pub(crate) commit_id: ObjectId,
+ pub(crate) source_path: Option<Vec<u8>>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub(crate) struct ArchiveStats {
+ pub(crate) entries: usize,
+ pub(crate) bytes: usize,
+}
+
+impl RepositoryReadService {
+ pub(crate) fn open(path: &Path, limits: ReadLimits) -> Result<Self, ReadError> {
+ let repository = gix::open(path).map_err(|error| ReadError::Open {
+ path: path.to_owned(),
+ reason: error.to_string(),
+ })?;
+ if !repository.is_bare() {
+ return Err(ReadError::NotBare(path.to_owned()));
+ }
+ validate_limits(&limits)?;
+ Ok(Self { repository, limits })
+ }
+
+ pub(crate) fn references(
+ &self,
+ cancellation: &ReadCancellation,
+ ) -> Result<Vec<RefInfo>, ReadError> {
+ let budget = self.budget(cancellation);
+ let platform = self
+ .repository
+ .references()
+ .map_err(|error| ReadError::Git(error.to_string()))?;
+ let iterator = platform
+ .all()
+ .map_err(|error| ReadError::Git(error.to_string()))?;
+ let mut references = Vec::new();
+ for reference in iterator {
+ budget.check()?;
+ if references.len() >= self.limits.max_refs {
+ return Err(ReadError::Limit("refs"));
+ }
+ let reference = reference.map_err(|error| ReadError::Git(error.to_string()))?;
+ if reference.name().as_bstr().len() > self.limits.max_path_bytes {
+ return Err(ReadError::Limit("ref name bytes"));
+ }
+ let Some(target) = reference.try_id().map(gix::Id::detach) else {
+ continue;
+ };
+ let name = reference.name().as_bstr().to_vec();
+ let peeled = if name.starts_with(b"refs/tags/") {
+ let mut candidate = reference.clone();
+ let candidate = candidate
+ .peel_to_id()
+ .map_err(|error| ReadError::Git(error.to_string()))?
+ .detach();
+ (candidate != target).then_some(candidate)
+ } else {
+ None
+ };
+ references.push(RefInfo {
+ name,
+ target,
+ peeled,
+ symbolic_target: None,
+ });
+ }
+ references.sort_by(|left, right| left.name.cmp(&right.name));
+ if let Some(head) = self
+ .repository
+ .head_ref()
+ .map_err(|error| ReadError::Git(error.to_string()))?
+ {
+ if references.len() >= self.limits.max_refs {
+ return Err(ReadError::Limit("refs"));
+ }
+ references.insert(
+ 0,
+ RefInfo {
+ name: b"HEAD".to_vec(),
+ target: head.id().detach(),
+ peeled: None,
+ symbolic_target: Some(head.name().as_bstr().to_vec()),
+ },
+ );
+ }
+ Ok(references)
+ }
+
+ pub(crate) fn commit(
+ &self,
+ id: ObjectId,
+ cancellation: &ReadCancellation,
+ ) -> Result<CommitInfo, ReadError> {
+ let budget = self.budget(cancellation);
+ self.read_commit(id, &budget)
+ }
+
+ pub(crate) fn history(
+ &self,
+ start: ObjectId,
+ cancellation: &ReadCancellation,
+ ) -> Result<Vec<CommitInfo>, ReadError> {
+ let budget = self.budget(cancellation);
+ self.history_with_budget(start, &budget)
+ }
+
+ pub(crate) fn tree(
+ &self,
+ commit: ObjectId,
+ path: &[u8],
+ cancellation: &ReadCancellation,
+ ) -> Result<Vec<TreeEntryInfo>, ReadError> {
+ validate_path(path, true, self.limits.max_path_bytes)?;
+ let budget = self.budget(cancellation);
+ let commit = self.read_commit(commit, &budget)?;
+ let tree_id = self.resolve_tree(commit.tree, path, &budget)?;
+ let tree = self.read_tree(tree_id, &budget)?;
+ if tree.entries.len() > self.limits.max_tree_entries {
+ return Err(ReadError::Limit("tree entries"));
+ }
+ tree.entries
+ .into_iter()
+ .map(|entry| {
+ budget.check()?;
+ Ok(TreeEntryInfo {
+ name: entry.filename.to_vec(),
+ id: entry.oid.to_owned(),
+ mode: entry.mode.value(),
+ kind: entry.mode.kind(),
+ })
+ })
+ .collect()
+ }
+
+ pub(crate) fn blob(
+ &self,
+ commit: ObjectId,
+ path: &[u8],
+ cancellation: &ReadCancellation,
+ ) -> Result<BlobInfo, ReadError> {
+ validate_path(path, false, self.limits.max_path_bytes)?;
+ let budget = self.budget(cancellation);
+ let commit = self.read_commit(commit, &budget)?;
+ let (id, mode) = self.resolve_blob(commit.tree, path, &budget)?;
+ self.read_blob(id, mode, &budget)
+ }
+
+ pub(crate) fn raw(
+ &self,
+ commit: ObjectId,
+ path: &[u8],
+ cancellation: &ReadCancellation,
+ output: &mut impl Write,
+ ) -> Result<usize, ReadError> {
+ validate_path(path, false, self.limits.max_path_bytes)?;
+ let budget = self.budget(cancellation);
+ let commit = self.read_commit(commit, &budget)?;
+ let (id, mode) = self.resolve_blob(commit.tree, path, &budget)?;
+ let blob = self.read_blob(id, mode, &budget)?;
+ budget.check()?;
+ output.write_all(&blob.data).map_err(ReadError::Output)?;
+ Ok(blob.data.len())
+ }
+
+ pub(crate) fn readme(
+ &self,
+ commit: ObjectId,
+ cancellation: &ReadCancellation,
+ ) -> Result<Option<ReadmeInfo>, ReadError> {
+ let budget = self.budget(cancellation);
+ let commit = self.read_commit(commit, &budget)?;
+ let tree = self.read_tree(commit.tree, &budget)?;
+ let mut candidates = Vec::new();
+ for entry in tree.entries {
+ budget.check()?;
+ if !entry.mode.is_blob() {
+ continue;
+ }
+ if let Some(priority) = readme_priority(&entry.filename) {
+ candidates.push((
+ priority,
+ entry.filename.to_vec(),
+ entry.oid.to_owned(),
+ entry.mode,
+ ));
+ }
+ }
+ candidates.sort_by(|left, right| (left.0, &left.1).cmp(&(right.0, &right.1)));
+ let Some((_, path, id, mode)) = candidates.into_iter().next() else {
+ return Ok(None);
+ };
+ Ok(Some(ReadmeInfo {
+ path,
+ blob: self.read_blob(id, mode, &budget)?,
+ }))
+ }
+
+ pub(crate) fn diff(
+ &self,
+ old_commit: ObjectId,
+ new_commit: ObjectId,
+ cancellation: &ReadCancellation,
+ ) -> Result<Vec<DiffFile>, ReadError> {
+ let budget = self.budget(cancellation);
+ let old = self.read_commit(old_commit, &budget)?;
+ let new = self.read_commit(new_commit, &budget)?;
+ let old_files = self.flatten_tree(old.tree, &budget)?;
+ let new_files = self.flatten_tree(new.tree, &budget)?;
+ let paths: BTreeMap<&[u8], ()> = old_files
+ .keys()
+ .chain(new_files.keys())
+ .map(|path| (path.as_slice(), ()))
+ .collect();
+ let mut files = Vec::new();
+ let mut bytes = 0_usize;
+ for path in paths.keys() {
+ budget.check()?;
+ let old = old_files.get(*path);
+ let new = new_files.get(*path);
+ if old == new {
+ continue;
+ }
+ let old_data = self.diff_blob(old, &budget)?;
+ let new_data = self.diff_blob(new, &budget)?;
+ bytes = checked_add(
+ bytes,
+ old_data.len(),
+ self.limits.max_diff_bytes,
+ "diff bytes",
+ )?;
+ bytes = checked_add(
+ bytes,
+ new_data.len(),
+ self.limits.max_diff_bytes,
+ "diff bytes",
+ )?;
+ let binary = old.is_some_and(|file| file.mode.is_commit())
+ || new.is_some_and(|file| file.mode.is_commit())
+ || old_data.contains(&0)
+ || new_data.contains(&0);
+ let hunks = if binary {
+ Vec::new()
+ } else {
+ unified_diff(&old_data, &new_data)?
+ };
+ bytes = checked_add(bytes, hunks.len(), self.limits.max_diff_bytes, "diff bytes")?;
+ files.push(DiffFile {
+ path: path.to_vec(),
+ old_id: old.map(|file| file.id),
+ new_id: new.map(|file| file.id),
+ old_mode: old.map(|file| file.mode.value()),
+ new_mode: new.map(|file| file.mode.value()),
+ binary,
+ hunks,
+ });
+ }
+ Ok(files)
+ }
+
+ pub(crate) fn blame(
+ &self,
+ commit: ObjectId,
+ path: &[u8],
+ cancellation: &ReadCancellation,
+ ) -> Result<Vec<BlameHunk>, ReadError> {
+ validate_path(path, false, self.limits.max_path_bytes)?;
+ let budget = self.budget(cancellation);
+ let history = self.history_with_budget(commit, &budget)?;
+ let mut candidate_bytes = 0_usize;
+ for candidate in &history {
+ budget.check()?;
+ if let Ok((id, _)) = self.resolve_blob(candidate.tree, path, &budget) {
+ let header = self
+ .repository
+ .objects
+ .header(id)
+ .map_err(|error| ReadError::Git(error.to_string()))?;
+ candidate_bytes = checked_add(
+ candidate_bytes,
+ usize::try_from(header.size()).map_err(|_| ReadError::Limit("blame bytes"))?,
+ self.limits.max_diff_bytes,
+ "blame bytes",
+ )?;
+ }
+ }
+ budget.check()?;
+ let outcome = self
+ .repository
+ .blame_file(path.as_bstr(), commit, Default::default())
+ .map_err(|error| ReadError::Git(error.to_string()))?;
+ budget.check()?;
+ if outcome.statistics.commits_traversed > self.limits.max_history_commits
+ || outcome.blob.len() > self.limits.max_blob_bytes
+ {
+ return Err(ReadError::Limit("blame"));
+ }
+ outcome
+ .entries
+ .into_iter()
+ .map(|entry| {
+ Ok(BlameHunk {
+ start_line: entry.start_in_blamed_file + 1,
+ source_start_line: entry.start_in_source_file + 1,
+ line_count: entry.len.get(),
+ commit_id: entry.commit_id,
+ source_path: entry.source_file_name.map(|path| path.to_vec()),
+ })
+ })
+ .collect()
+ }
+
+ pub(crate) fn archive(
+ &self,
+ commit: ObjectId,
+ cancellation: &ReadCancellation,
+ output: &mut impl Write,
+ ) -> Result<ArchiveStats, ReadError> {
+ let budget = self.budget(cancellation);
+ let commit = self.read_commit(commit, &budget)?;
+ let mut writer = BoundedWriter {
+ output,
+ budget: &budget,
+ written: 0,
+ maximum: self.limits.max_archive_bytes,
+ failure: None,
+ };
+ let mut entries = 0_usize;
+ let result = self.archive_tree(
+ commit.tree,
+ &[],
+ commit.committed_at.max(0) as u64,
+ &budget,
+ &mut writer,
+ &mut entries,
+ );
+ if let Err(error) = result {
+ return Err(writer.failure.take().map_or(error, ReadFailure::into_error));
+ }
+ if let Err(error) = writer.write_all(&[0_u8; 1024]) {
+ return Err(writer
+ .failure
+ .take()
+ .map_or(ReadError::Output(error), ReadFailure::into_error));
+ }
+ Ok(ArchiveStats {
+ entries,
+ bytes: writer.written,
+ })
+ }
+
+ fn budget<'a>(&self, cancellation: &'a ReadCancellation) -> ReadBudget<'a> {
+ ReadBudget {
+ cancellation,
+ deadline: Instant::now() + self.limits.max_duration,
+ }
+ }
+
+ fn read_commit(&self, id: ObjectId, budget: &ReadBudget<'_>) -> Result<CommitInfo, ReadError> {
+ budget.check()?;
+ let object = self
+ .repository
+ .find_object(id)
+ .map_err(|error| ReadError::Git(error.to_string()))?;
+ if object.kind != ObjectKind::Commit {
+ return Err(ReadError::WrongObjectKind {
+ expected: "commit",
+ actual: object.kind,
+ });
+ }
+ if object.data.len() > self.limits.max_commit_bytes {
+ return Err(ReadError::Limit("commit bytes"));
+ }
+ let commit = object
+ .try_to_commit_ref()
+ .map_err(|error| ReadError::Git(error.to_string()))?;
+ let tree = self.parse_id(commit.tree, id)?;
+ let parents = commit
+ .parents
+ .iter()
+ .map(|parent| self.parse_id(parent, id))
+ .collect::<Result<Vec<_>, _>>()?;
+ let author = commit
+ .author()
+ .map_err(|error| ReadError::Git(error.to_string()))?;
+ let committed_at = commit
+ .time()
+ .map_err(|error| ReadError::Git(error.to_string()))?
+ .seconds;
+ Ok(CommitInfo {
+ id,
+ tree,
+ parents,
+ author_name: author.name.to_vec(),
+ author_email: author.email.to_vec(),
+ committed_at,
+ message: commit.message.to_vec(),
+ })
+ }
+
+ fn history_with_budget(
+ &self,
+ start: ObjectId,
+ budget: &ReadBudget<'_>,
+ ) -> Result<Vec<CommitInfo>, ReadError> {
+ let mut pending = VecDeque::from([start]);
+ let mut seen = HashSet::new();
+ let mut history = Vec::new();
+ while let Some(id) = pending.pop_front() {
+ budget.check()?;
+ if !seen.insert(id) {
+ continue;
+ }
+ if history.len() >= self.limits.max_history_commits {
+ return Err(ReadError::Limit("history commits"));
+ }
+ let commit = self.read_commit(id, budget)?;
+ pending.extend(commit.parents.iter().copied());
+ history.push(commit);
+ }
+ Ok(history)
+ }
+
+ fn read_tree(
+ &self,
+ id: ObjectId,
+ budget: &ReadBudget<'_>,
+ ) -> Result<gix::objs::Tree, ReadError> {
+ budget.check()?;
+ let object = self
+ .repository
+ .find_object(id)
+ .map_err(|error| ReadError::Git(error.to_string()))?;
+ if object.kind != ObjectKind::Tree {
+ return Err(ReadError::WrongObjectKind {
+ expected: "tree",
+ actual: object.kind,
+ });
+ }
+ if object.data.len() > self.limits.max_tree_bytes {
+ return Err(ReadError::Limit("tree bytes"));
+ }
+ gix::objs::Tree::try_from(object.into_tree())
+ .map_err(|error| ReadError::Git(error.to_string()))
+ }
+
+ fn resolve_tree(
+ &self,
+ root: ObjectId,
+ path: &[u8],
+ budget: &ReadBudget<'_>,
+ ) -> Result<ObjectId, ReadError> {
+ let mut tree_id = root;
+ if path.is_empty() {
+ return Ok(tree_id);
+ }
+ for component in path.split(|byte| *byte == b'/') {
+ budget.check()?;
+ let tree = self.read_tree(tree_id, budget)?;
+ let entry = tree
+ .entries
+ .iter()
+ .find(|entry| entry.filename.as_bytes() == component)
+ .ok_or_else(|| ReadError::PathNotFound(path.to_vec()))?;
+ if !entry.mode.is_tree() {
+ return Err(ReadError::NotTree(path.to_vec()));
+ }
+ tree_id = entry.oid.to_owned();
+ }
+ Ok(tree_id)
+ }
+
+ fn resolve_blob(
+ &self,
+ root: ObjectId,
+ path: &[u8],
+ budget: &ReadBudget<'_>,
+ ) -> Result<(ObjectId, EntryMode), ReadError> {
+ let mut components = path.split(|byte| *byte == b'/').peekable();
+ let mut tree_id = root;
+ while let Some(component) = components.next() {
+ budget.check()?;
+ let tree = self.read_tree(tree_id, budget)?;
+ let entry = tree
+ .entries
+ .iter()
+ .find(|entry| entry.filename.as_bytes() == component)
+ .ok_or_else(|| ReadError::PathNotFound(path.to_vec()))?;
+ if components.peek().is_some() {
+ if !entry.mode.is_tree() {
+ return Err(ReadError::NotTree(path.to_vec()));
+ }
+ tree_id = entry.oid.to_owned();
+ } else if entry.mode.is_blob_or_symlink() {
+ return Ok((entry.oid.to_owned(), entry.mode));
+ } else {
+ return Err(ReadError::NotBlob(path.to_vec()));
+ }
+ }
+ Err(ReadError::NotBlob(path.to_vec()))
+ }
+
+ fn read_blob(
+ &self,
+ id: ObjectId,
+ mode: EntryMode,
+ budget: &ReadBudget<'_>,
+ ) -> Result<BlobInfo, ReadError> {
+ budget.check()?;
+ let object = self
+ .repository
+ .find_object(id)
+ .map_err(|error| ReadError::Git(error.to_string()))?;
+ if object.kind != ObjectKind::Blob {
+ return Err(ReadError::WrongObjectKind {
+ expected: "blob",
+ actual: object.kind,
+ });
+ }
+ if object.data.len() > self.limits.max_blob_bytes {
+ return Err(ReadError::Limit("blob bytes"));
+ }
+ Ok(BlobInfo {
+ id,
+ mode: mode.value(),
+ data: object.data.to_vec(),
+ })
+ }
+
+ fn flatten_tree(
+ &self,
+ root: ObjectId,
+ budget: &ReadBudget<'_>,
+ ) -> Result<BTreeMap<Vec<u8>, TreeFile>, ReadError> {
+ let mut files = BTreeMap::new();
+ let mut pending = vec![(Vec::new(), root)];
+ let mut entries = 0_usize;
+ while let Some((prefix, tree_id)) = pending.pop() {
+ budget.check()?;
+ let tree = self.read_tree(tree_id, budget)?;
+ for entry in tree.entries {
+ budget.check()?;
+ entries += 1;
+ if entries > self.limits.max_tree_entries {
+ return Err(ReadError::Limit("tree entries"));
+ }
+ let path = join_git_path(&prefix, &entry.filename, self.limits.max_path_bytes)?;
+ if entry.mode.is_tree() {
+ pending.push((path, entry.oid.to_owned()));
+ } else if entry.mode.is_blob_or_symlink() || entry.mode.is_commit() {
+ files.insert(
+ path,
+ TreeFile {
+ id: entry.oid.to_owned(),
+ mode: entry.mode,
+ },
+ );
+ }
+ }
+ }
+ Ok(files)
+ }
+
+ fn diff_blob(
+ &self,
+ file: Option<&TreeFile>,
+ budget: &ReadBudget<'_>,
+ ) -> Result<Vec<u8>, ReadError> {
+ match file {
+ Some(file) if file.mode.is_commit() => Ok(Vec::new()),
+ Some(file) => Ok(self.read_blob(file.id, file.mode, budget)?.data),
+ None => Ok(Vec::new()),
+ }
+ }
+
+ fn archive_tree(
+ &self,
+ tree_id: ObjectId,
+ prefix: &[u8],
+ modified_at: u64,
+ budget: &ReadBudget<'_>,
+ writer: &mut BoundedWriter<'_, impl Write>,
+ entries: &mut usize,
+ ) -> Result<(), ReadError> {
+ let tree = self.read_tree(tree_id, budget)?;
+ for entry in tree.entries {
+ budget.check()?;
+ *entries += 1;
+ if *entries > self.limits.max_archive_entries {
+ return Err(ReadError::Limit("archive entries"));
+ }
+ let path = join_git_path(prefix, &entry.filename, self.limits.max_path_bytes)?;
+ if entry.mode.is_tree() {
+ let mut directory = path.clone();
+ directory.push(b'/');
+ write_tar_header(writer, &directory, 0o755, 0, modified_at, b'5')?;
+ self.archive_tree(
+ entry.oid.to_owned(),
+ &path,
+ modified_at,
+ budget,
+ writer,
+ entries,
+ )?;
+ } else if entry.mode.is_blob_or_symlink() {
+ let blob = self.read_blob(entry.oid.to_owned(), entry.mode, budget)?;
+ // Store symbolic-link content as a regular file. Extraction cannot create a link outside its destination.
+ let mode = if entry.mode.is_executable() {
+ 0o755
+ } else {
+ 0o644
+ };
+ write_tar_header(
+ writer,
+ &path,
+ mode,
+ u64::try_from(blob.data.len())
+ .map_err(|_| ReadError::Limit("archive bytes"))?,
+ modified_at,
+ b'0',
+ )?;
+ writer.write_all(&blob.data).map_err(ReadError::Output)?;
+ let padding = (512 - blob.data.len() % 512) % 512;
+ if padding != 0 {
+ writer
+ .write_all(&[0_u8; 512][..padding])
+ .map_err(ReadError::Output)?;
+ }
+ } else if entry.mode.is_commit() {
+ let mut directory = path;
+ directory.push(b'/');
+ write_tar_header(writer, &directory, 0o755, 0, modified_at, b'5')?;
+ }
+ }
+ Ok(())
+ }
+
+ fn parse_id(&self, input: &[u8], owner: ObjectId) -> Result<ObjectId, ReadError> {
+ let id = ObjectId::from_hex(input).map_err(|error| ReadError::DamagedObject {
+ id: owner,
+ reason: error.to_string(),
+ })?;
+ if id.kind() != self.repository.object_hash() {
+ return Err(ReadError::DamagedObject {
+ id: owner,
+ reason: "object ID uses the wrong hash format".to_owned(),
+ });
+ }
+ Ok(id)
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct TreeFile {
+ id: ObjectId,
+ mode: EntryMode,
+}
+
+struct ReadBudget<'a> {
+ cancellation: &'a ReadCancellation,
+ deadline: Instant,
+}
+
+impl ReadBudget<'_> {
+ fn check(&self) -> Result<(), ReadError> {
+ if self.cancellation.cancelled.load(Ordering::Relaxed) {
+ return Err(ReadError::Cancelled);
+ }
+ if Instant::now() >= self.deadline {
+ return Err(ReadError::Deadline);
+ }
+ Ok(())
+ }
+}
+
+struct BoundedWriter<'a, W> {
+ output: &'a mut W,
+ budget: &'a ReadBudget<'a>,
+ written: usize,
+ maximum: usize,
+ failure: Option<ReadFailure>,
+}
+
+impl<W: Write> Write for BoundedWriter<'_, W> {
+ fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
+ if let Err(error) = self.budget.check() {
+ self.failure = Some(match error {
+ ReadError::Cancelled => ReadFailure::Cancelled,
+ ReadError::Deadline => ReadFailure::Deadline,
+ _ => unreachable!("a budget check has only cancellation and deadline errors"),
+ });
+ return Err(std::io::Error::other("repository read stopped"));
+ }
+ let remaining = self.maximum.saturating_sub(self.written);
+ if buffer.len() > remaining {
+ self.failure = Some(ReadFailure::Limit);
+ return Err(std::io::Error::other(
+ "repository archive reached its byte limit",
+ ));
+ }
+ let written = self.output.write(buffer)?;
+ self.written += written;
+ Ok(written)
+ }
+
+ fn flush(&mut self) -> std::io::Result<()> {
+ self.output.flush()
+ }
+}
+
+#[derive(Clone, Copy)]
+enum ReadFailure {
+ Cancelled,
+ Deadline,
+ Limit,
+}
+
+impl ReadFailure {
+ fn into_error(self) -> ReadError {
+ match self {
+ Self::Cancelled => ReadError::Cancelled,
+ Self::Deadline => ReadError::Deadline,
+ Self::Limit => ReadError::Limit("archive bytes"),
+ }
+ }
+}
+
+fn validate_limits(limits: &ReadLimits) -> Result<(), ReadError> {
+ if limits.max_refs == 0
+ || limits.max_history_commits == 0
+ || limits.max_path_bytes == 0
+ || limits.max_tree_bytes == 0
+ || limits.max_tree_entries == 0
+ || limits.max_commit_bytes == 0
+ || limits.max_blob_bytes == 0
+ || limits.max_diff_bytes == 0
+ || limits.max_archive_entries == 0
+ || limits.max_archive_bytes < 1024
+ || limits.max_duration.is_zero()
+ {
+ return Err(ReadError::InvalidLimits);
+ }
+ Ok(())
+}
+
+fn validate_path(path: &[u8], permit_empty: bool, maximum: usize) -> Result<(), ReadError> {
+ if path.is_empty() {
+ return if permit_empty {
+ Ok(())
+ } else {
+ Err(ReadError::InvalidPath)
+ };
+ }
+ if path.len() > maximum {
+ return Err(ReadError::Limit("path bytes"));
+ }
+ if path.starts_with(b"/")
+ || path.ends_with(b"/")
+ || path.contains(&0)
+ || path
+ .split(|byte| *byte == b'/')
+ .any(|component| component.is_empty() || component == b"." || component == b"..")
+ {
+ return Err(ReadError::InvalidPath);
+ }
+ Ok(())
+}
+
+fn join_git_path(prefix: &[u8], name: &[u8], maximum: usize) -> Result<Vec<u8>, ReadError> {
+ if name.is_empty() || name == b"." || name == b".." || name.contains(&b'/') || name.contains(&0)
+ {
+ return Err(ReadError::UnsafeTreeEntry(name.to_vec()));
+ }
+ let mut path = Vec::with_capacity(prefix.len() + usize::from(!prefix.is_empty()) + name.len());
+ path.extend_from_slice(prefix);
+ if !prefix.is_empty() {
+ path.push(b'/');
+ }
+ path.extend_from_slice(name);
+ if path.len() > maximum {
+ return Err(ReadError::Limit("path bytes"));
+ }
+ Ok(path)
+}
+
+fn readme_priority(name: &[u8]) -> Option<usize> {
+ const NAMES: [&[u8]; 6] = [
+ b"README",
+ b"README.md",
+ b"README.markdown",
+ b"README.txt",
+ b"README.rst",
+ b"README.adoc",
+ ];
+ NAMES
+ .iter()
+ .position(|candidate| name.eq_ignore_ascii_case(candidate))
+}
+
+fn checked_add(
+ current: usize,
+ additional: usize,
+ maximum: usize,
+ limit: &'static str,
+) -> Result<usize, ReadError> {
+ let total = current
+ .checked_add(additional)
+ .ok_or(ReadError::Limit(limit))?;
+ if total > maximum {
+ return Err(ReadError::Limit(limit));
+ }
+ Ok(total)
+}
+
+fn unified_diff(old: &[u8], new: &[u8]) -> Result<Vec<u8>, ReadError> {
+ use gix::diff::blob::unified_diff::{ConsumeBinaryHunk, ContextSize};
+ use gix::diff::blob::{Algorithm, Diff, InternedInput, UnifiedDiff};
+
+ let input = InternedInput::new(old, new);
+ let diff = Diff::compute(Algorithm::Histogram, &input);
+ UnifiedDiff::new(
+ &diff,
+ &input,
+ ConsumeBinaryHunk::new(Vec::new(), "\n"),
+ ContextSize::default(),
+ )
+ .consume()
+ .map_err(ReadError::Output)
+}
+
+fn write_tar_header(
+ writer: &mut impl Write,
+ path: &[u8],
+ mode: u64,
+ size: u64,
+ modified_at: u64,
+ kind: u8,
+) -> Result<(), ReadError> {
+ let (name, prefix) = split_tar_path(path)?;
+ let mut header = [0_u8; 512];
+ header[..name.len()].copy_from_slice(name);
+ header[345..345 + prefix.len()].copy_from_slice(prefix);
+ write_tar_octal(&mut header[100..108], mode)?;
+ write_tar_octal(&mut header[108..116], 0)?;
+ write_tar_octal(&mut header[116..124], 0)?;
+ write_tar_octal(&mut header[124..136], size)?;
+ write_tar_octal(&mut header[136..148], modified_at)?;
+ header[148..156].fill(b' ');
+ header[156] = kind;
+ header[257..263].copy_from_slice(b"ustar\0");
+ header[263..265].copy_from_slice(b"00");
+ let checksum: u64 = header.iter().map(|byte| u64::from(*byte)).sum();
+ let checksum_text = format!("{checksum:06o}\0 ");
+ header[148..156].copy_from_slice(checksum_text.as_bytes());
+ writer.write_all(&header).map_err(ReadError::Output)
+}
+
+fn split_tar_path(path: &[u8]) -> Result<(&[u8], &[u8]), ReadError> {
+ if path.len() <= 100 {
+ return Ok((path, &[]));
+ }
+ for split in (1..path.len()).rev() {
+ if path[split] == b'/'
+ && split + 1 < path.len()
+ && path.len() - split - 1 <= 100
+ && split <= 155
+ {
+ return Ok((&path[split + 1..], &path[..split]));
+ }
+ }
+ Err(ReadError::ArchivePath(path.to_vec()))
+}
+
+fn write_tar_octal(field: &mut [u8], value: u64) -> Result<(), ReadError> {
+ let text = format!("{value:o}");
+ if text.len() + 1 > field.len() {
+ return Err(ReadError::ArchiveMetadata);
+ }
+ field.fill(b'0');
+ let start = field.len() - text.len() - 1;
+ field[start..start + text.len()].copy_from_slice(text.as_bytes());
+ field[field.len() - 1] = 0;
+ Ok(())
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum ReadError {
+ #[error("cannot open Git repository {path}: {reason}")]
+ Open { path: PathBuf, reason: String },
+ #[error("Git repository is not bare: {0}")]
+ NotBare(PathBuf),
+ #[error("Git read failed: {0}")]
+ Git(String),
+ #[error("expected a {expected} object, found {actual}")]
+ WrongObjectKind {
+ expected: &'static str,
+ actual: ObjectKind,
+ },
+ #[error("Git object {id} is damaged: {reason}")]
+ DamagedObject { id: ObjectId, reason: String },
+ #[error("repository read reached the {0} limit")]
+ Limit(&'static str),
+ #[error("repository read was cancelled")]
+ Cancelled,
+ #[error("repository read reached its time limit")]
+ Deadline,
+ #[error("repository read limits are not valid")]
+ InvalidLimits,
+ #[error("repository path is not valid")]
+ InvalidPath,
+ #[error("repository path does not exist: {0:?}")]
+ PathNotFound(Vec<u8>),
+ #[error("repository path is not a tree: {0:?}")]
+ NotTree(Vec<u8>),
+ #[error("repository path is not a blob: {0:?}")]
+ NotBlob(Vec<u8>),
+ #[error("Git tree entry is not safe: {0:?}")]
+ UnsafeTreeEntry(Vec<u8>),
+ #[error("repository archive path is too long: {0:?}")]
+ ArchivePath(Vec<u8>),
+ #[error("repository archive metadata is too large")]
+ ArchiveMetadata,
+ #[error("cannot write repository content: {0}")]
+ Output(std::io::Error),
+}
tests/git_reads.rs
Mode → 100644; object → 351670cf7770
@@ -1,0 +1,428 @@
+#[allow(
+ dead_code,
+ reason = "the test uses each public read contract selectively"
+)]
+#[path = "../src/git/read.rs"]
+mod read;
+
+use std::collections::BTreeSet;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::time::Duration;
+
+use gix::hash::ObjectId;
+use read::{ReadCancellation, ReadError, ReadLimits, RepositoryReadService};
+use tempfile::TempDir;
+
+struct Fixture {
+ _directory: TempDir,
+ bare: PathBuf,
+ first: ObjectId,
+ second: ObjectId,
+}
+
+#[test]
+fn reads_repository_content_for_both_object_formats() {
+ for format in ["sha1", "sha256"] {
+ let fixture = fixture(format);
+ let service = RepositoryReadService::open(&fixture.bare, ReadLimits::default())
+ .expect("open the read service");
+ let cancellation = ReadCancellation::default();
+
+ let references = service.references(&cancellation).expect("read refs");
+ assert_eq!(references[0].name, b"HEAD");
+ assert_eq!(
+ references[0].symbolic_target.as_deref(),
+ Some(b"refs/heads/main".as_slice())
+ );
+ assert!(references.iter().any(|reference| {
+ reference.name == b"refs/heads/main" && reference.target == fixture.second
+ }));
+ assert!(references.iter().any(|reference| {
+ reference.name == b"refs/tags/v1" && reference.peeled == Some(fixture.first)
+ }));
+
+ let commit = service
+ .commit(fixture.second, &cancellation)
+ .expect("read a commit");
+ assert_eq!(commit.id, fixture.second);
+ assert_eq!(commit.parents, [fixture.first]);
+ assert_eq!(commit.author_name, b"Tit Test");
+ assert_eq!(commit.author_email, b"tit@example.test");
+ assert_eq!(commit.message, b"second\n");
+
+ let history = service
+ .history(fixture.second, &cancellation)
+ .expect("read history");
+ assert_eq!(
+ history.iter().map(|commit| commit.id).collect::<Vec<_>>(),
+ [fixture.second, fixture.first]
+ );
+
+ let root = service
+ .tree(fixture.second, b"", &cancellation)
+ .expect("read the root tree");
+ assert_eq!(
+ root.iter()
+ .map(|entry| entry.name.as_slice())
+ .collect::<Vec<_>>(),
+ [
+ b"README.md".as_slice(),
+ b"binary".as_slice(),
+ b"docs".as_slice(),
+ b"src".as_slice()
+ ]
+ );
+ let source = service
+ .tree(fixture.second, b"src", &cancellation)
+ .expect("read a nested tree");
+ assert_eq!(source.len(), 1);
+ assert_eq!(source[0].name, b"lib.rs");
+
+ let blob = service
+ .blob(fixture.second, b"src/lib.rs", &cancellation)
+ .expect("read a blob");
+ assert_eq!(blob.data, b"one\nchanged\nthree\n");
+ assert_eq!(blob.mode, 0o100644);
+ let mut raw = Vec::new();
+ assert_eq!(
+ service
+ .raw(fixture.second, b"src/lib.rs", &cancellation, &mut raw)
+ .expect("stream raw content"),
+ raw.len()
+ );
+ assert_eq!(raw, blob.data);
+
+ let readme = service
+ .readme(fixture.second, &cancellation)
+ .expect("select a README")
+ .expect("find a README");
+ assert_eq!(readme.path, b"README.md");
+ assert_eq!(readme.blob.data, b"Tit read service\n");
+ }
+}
+
+#[test]
+fn produces_bounded_diffs_blame_and_streaming_archives() {
+ let fixture = fixture("sha1");
+ let service = RepositoryReadService::open(&fixture.bare, ReadLimits::default())
+ .expect("open the read service");
+ let cancellation = ReadCancellation::default();
+
+ let diff = service
+ .diff(fixture.first, fixture.second, &cancellation)
+ .expect("read a diff");
+ let source = diff
+ .iter()
+ .find(|file| file.path == b"src/lib.rs")
+ .expect("find the source diff");
+ assert!(!source.binary);
+ assert!(source.hunks.windows(5).any(|window| window == b"-two\n"));
+ assert!(
+ source
+ .hunks
+ .windows(9)
+ .any(|window| window == b"+changed\n")
+ );
+ assert!(diff.iter().any(|file| file.path == b"docs/note.txt"));
+ let binary = diff
+ .iter()
+ .find(|file| file.path == b"binary")
+ .expect("find the binary diff");
+ assert!(binary.binary);
+ assert!(binary.hunks.is_empty());
+
+ let blame = service
+ .blame(fixture.second, b"src/lib.rs", &cancellation)
+ .expect("blame a file");
+ assert!(blame.iter().any(|hunk| hunk.commit_id == fixture.first));
+ assert!(blame.iter().any(|hunk| hunk.commit_id == fixture.second));
+ assert_eq!(
+ blame
+ .iter()
+ .map(|hunk| usize::try_from(hunk.line_count).expect("count blame lines"))
+ .sum::<usize>(),
+ 3
+ );
+
+ let mut archive = Vec::new();
+ let stats = service
+ .archive(fixture.second, &cancellation, &mut archive)
+ .expect("stream an archive");
+ assert_eq!(stats.bytes, archive.len());
+ assert!(archive.ends_with(&[0_u8; 1024]));
+ let names = tar_names(&archive);
+ assert!(names.contains(b"README.md".as_slice()));
+ assert!(names.contains(b"src/".as_slice()));
+ assert!(names.contains(b"src/lib.rs".as_slice()));
+ assert!(names.contains(b"docs/note.txt".as_slice()));
+ let long_path = format!("docs/{}/{}.txt", "a".repeat(60), "b".repeat(60));
+ assert!(names.contains(long_path.as_bytes()));
+ let archive_path = fixture
+ .bare
+ .parent()
+ .expect("a fixture parent")
+ .join("repository.tar");
+ fs::write(&archive_path, &archive).expect("write the streamed archive");
+ let tar = Command::new("tar")
+ .args(["-tf"])
+ .arg(&archive_path)
+ .output()
+ .expect("run the system tar reader");
+ assert!(
+ tar.status.success(),
+ "tar rejected the archive: {}",
+ String::from_utf8_lossy(&tar.stderr)
+ );
+}
+
+#[test]
+fn enforces_each_read_boundary_and_rejects_unsafe_paths() {
+ let fixture = fixture("sha1");
+ let cancellation = ReadCancellation::default();
+
+ let limits = ReadLimits {
+ max_refs: 1,
+ ..ReadLimits::default()
+ };
+ let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+ assert!(matches!(
+ service.references(&cancellation),
+ Err(ReadError::Limit("refs"))
+ ));
+
+ let limits = ReadLimits {
+ max_history_commits: 1,
+ ..ReadLimits::default()
+ };
+ let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+ assert!(matches!(
+ service.history(fixture.second, &cancellation),
+ Err(ReadError::Limit("history commits"))
+ ));
+ assert!(matches!(
+ service.blame(fixture.second, b"src/lib.rs", &cancellation),
+ Err(ReadError::Limit("history commits"))
+ ));
+
+ let limits = ReadLimits {
+ max_blob_bytes: 4,
+ ..ReadLimits::default()
+ };
+ let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+ assert!(matches!(
+ service.blob(fixture.second, b"src/lib.rs", &cancellation),
+ Err(ReadError::Limit("blob bytes"))
+ ));
+
+ let limits = ReadLimits {
+ max_commit_bytes: 1,
+ ..ReadLimits::default()
+ };
+ let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+ assert!(matches!(
+ service.commit(fixture.second, &cancellation),
+ Err(ReadError::Limit("commit bytes"))
+ ));
+
+ let limits = ReadLimits {
+ max_path_bytes: 4,
+ ..ReadLimits::default()
+ };
+ let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+ assert!(matches!(
+ service.blob(fixture.second, b"src/lib.rs", &cancellation),
+ Err(ReadError::Limit("path bytes"))
+ ));
+
+ let limits = ReadLimits {
+ max_tree_entries: 1,
+ ..ReadLimits::default()
+ };
+ let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+ assert!(matches!(
+ service.tree(fixture.second, b"", &cancellation),
+ Err(ReadError::Limit("tree entries"))
+ ));
+
+ let limits = ReadLimits {
+ max_diff_bytes: 8,
+ ..ReadLimits::default()
+ };
+ let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+ assert!(matches!(
+ service.diff(fixture.first, fixture.second, &cancellation),
+ Err(ReadError::Limit("diff bytes"))
+ ));
+
+ let limits = ReadLimits {
+ max_archive_entries: 1,
+ ..ReadLimits::default()
+ };
+ let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+ assert!(matches!(
+ service.archive(fixture.second, &cancellation, &mut Vec::new()),
+ Err(ReadError::Limit("archive entries"))
+ ));
+
+ let limits = ReadLimits {
+ max_archive_bytes: 1024,
+ ..ReadLimits::default()
+ };
+ let service = RepositoryReadService::open(&fixture.bare, limits).expect("open the service");
+ assert!(matches!(
+ service.archive(fixture.second, &cancellation, &mut Vec::new()),
+ Err(ReadError::Limit("archive bytes"))
+ ));
+
+ let service = RepositoryReadService::open(&fixture.bare, ReadLimits::default())
+ .expect("open the service");
+ for path in [b"".as_slice(), b"/src", b"src/", b"src//lib.rs", b"../HEAD"] {
+ assert!(matches!(
+ service.blob(fixture.second, path, &cancellation),
+ Err(ReadError::InvalidPath)
+ ));
+ }
+ let cancelled = ReadCancellation::default();
+ cancelled.cancel();
+ assert!(matches!(
+ service.history(fixture.second, &cancelled),
+ Err(ReadError::Cancelled)
+ ));
+
+ let limits = ReadLimits {
+ max_duration: Duration::ZERO,
+ ..ReadLimits::default()
+ };
+ assert!(matches!(
+ RepositoryReadService::open(&fixture.bare, limits),
+ Err(ReadError::InvalidLimits)
+ ));
+}
+
+fn fixture(object_format: &str) -> Fixture {
+ let directory = TempDir::new().expect("create a fixture directory");
+ let worktree = directory.path().join("worktree");
+ run(Command::new("git")
+ .args(["init", "-q", "-b", "main", "--object-format", object_format])
+ .arg(&worktree));
+ run(Command::new("git")
+ .args(["config", "user.name", "Tit Test"])
+ .current_dir(&worktree));
+ run(Command::new("git")
+ .args(["config", "user.email", "tit@example.test"])
+ .current_dir(&worktree));
+ run(Command::new("git")
+ .args(["config", "commit.gpgsign", "false"])
+ .current_dir(&worktree));
+ run(Command::new("git")
+ .args(["config", "tag.gpgsign", "false"])
+ .current_dir(&worktree));
+ fs::create_dir(worktree.join("src")).expect("create a source directory");
+ fs::write(worktree.join("README.md"), b"Tit read service\n").expect("write a README");
+ fs::write(worktree.join("src/lib.rs"), b"one\ntwo\n").expect("write source");
+ fs::write(worktree.join("binary"), b"old\0binary").expect("write binary content");
+ run(Command::new("git")
+ .args(["add", "."])
+ .current_dir(&worktree));
+ run(Command::new("git")
+ .args(["commit", "-q", "-m", "first"])
+ .env("GIT_AUTHOR_DATE", "1700000000 +0000")
+ .env("GIT_COMMITTER_DATE", "1700000000 +0000")
+ .current_dir(&worktree));
+ let first = revision(&worktree, "HEAD");
+ run(Command::new("git")
+ .args(["tag", "-a", "v1", "-m", "version one"])
+ .current_dir(&worktree));
+
+ fs::create_dir(worktree.join("docs")).expect("create a documentation directory");
+ fs::write(worktree.join("src/lib.rs"), b"one\nchanged\nthree\n").expect("change source");
+ fs::write(worktree.join("binary"), b"new\0binary").expect("change binary content");
+ fs::write(worktree.join("docs/note.txt"), b"note\n").expect("write documentation");
+ let long_directory = worktree.join("docs").join("a".repeat(60));
+ fs::create_dir(&long_directory).expect("create a long archive directory");
+ fs::write(
+ long_directory.join(format!("{}.txt", "b".repeat(60))),
+ b"long archive path\n",
+ )
+ .expect("write a long archive path");
+ run(Command::new("git")
+ .args(["add", "."])
+ .current_dir(&worktree));
+ run(Command::new("git")
+ .args(["commit", "-q", "-m", "second"])
+ .env("GIT_AUTHOR_DATE", "1700000100 +0000")
+ .env("GIT_COMMITTER_DATE", "1700000100 +0000")
+ .current_dir(&worktree));
+ let second = revision(&worktree, "HEAD");
+ let bare = directory.path().join("repository.git");
+ run(Command::new("git")
+ .args(["clone", "-q", "--bare"])
+ .arg(&worktree)
+ .arg(&bare));
+ Fixture {
+ _directory: directory,
+ bare,
+ first,
+ second,
+ }
+}
+
+fn revision(repository: &Path, name: &str) -> ObjectId {
+ let output = Command::new("git")
+ .args(["rev-parse", name])
+ .current_dir(repository)
+ .output()
+ .expect("run git rev-parse");
+ assert!(output.status.success());
+ ObjectId::from_hex(output.stdout.trim_ascii()).expect("parse a fixture revision")
+}
+
+fn run(command: &mut Command) {
+ let output = command.output().expect("run the stock Git client");
+ assert!(
+ output.status.success(),
+ "Git failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+}
+
+fn tar_names(archive: &[u8]) -> BTreeSet<Vec<u8>> {
+ let mut names = BTreeSet::new();
+ let mut offset = 0_usize;
+ while offset + 512 <= archive.len() {
+ let header = &archive[offset..offset + 512];
+ if header.iter().all(|byte| *byte == 0) {
+ break;
+ }
+ let name_end = header[..100]
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(100);
+ let prefix_end = header[345..500]
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(155);
+ let mut path = header[345..345 + prefix_end].to_vec();
+ if !path.is_empty() {
+ path.push(b'/');
+ }
+ path.extend_from_slice(&header[..name_end]);
+ names.insert(path);
+ let size_end = header[124..136]
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(12);
+ let size = std::str::from_utf8(&header[124..124 + size_end])
+ .expect("read a tar size")
+ .trim_start_matches('0');
+ let size = if size.is_empty() {
+ 0
+ } else {
+ usize::from_str_radix(size, 8).expect("parse a tar size")
+ };
+ offset += 512 + size.div_ceil(512) * 512;
+ }
+ names
+}