From 40bf0acbb71139c61c1a2d1742d4cf2d2f2b9490 Mon Sep 17 00:00:00 2001 From: hmirin Date: Wed, 14 Oct 2026 14:54:30 +0900 Subject: [PATCH] Refuse to push from a shallow repository A shallow source produced a pack whose oldest commits point at parents that were never sent, so the encrypted remote accepted incomplete history. Check `git rev-parse --is-shallow-repository` before building the pack and fail before anything is published; `git fetch --unshallow` fixes the source. The test that published from a shallow clone to check the verified frontier is replaced by one that checks the refusal and the unshallow recovery. Salvaged from the unmerged fix/carrier-object-cache branch (71150dc). --- docs/guide.md | 6 ++- src/git.rs | 11 +++++ tests/end_to_end.rs | 98 +++++++++++++++++++++++---------------------- 3 files changed, 65 insertions(+), 50 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index dec6457..3448cd4 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -244,8 +244,10 @@ your local remote-tracking refs. - Recovery discovery has fixed limits of 2,048 carrier commits, 256 distinct heads, 64 MiB of manifest data, and 1,000,000 estimated cryptographic operations. It fails closed if any limit is exhausted. -- Shallow and partial clones are not supported. A fresh clone downloads and - verifies the complete history. +- Shallow and partial clones are not supported. Pushing from a shallow + repository is refused before anything is published; run + `git fetch --unshallow` first. A fresh clone downloads and verifies the + complete history. ## Storage contract diff --git a/src/git.rs b/src/git.rs index 0219910..336f459 100644 --- a/src/git.rs +++ b/src/git.rs @@ -163,6 +163,17 @@ pub fn start_incremental_pack( new_refs: &BTreeMap, old_refs: &BTreeMap, ) -> Result { + let shallow = Command::new("git") + .arg("-C") + .arg(repo) + .args(["rev-parse", "--is-shallow-repository"]) + .output()?; + if !shallow.status.success() { + bail!("could not determine whether the source repository is shallow") + } + if String::from_utf8(shallow.stdout)?.trim() == "true" { + bail!("cannot publish from a shallow repository; fetch --unshallow first") + } let timer = trace::Span::new("git_pack_objects_stream_lifetime"); let stderr = tempfile::tempfile().context("create pack-objects stderr file")?; let mut child = Command::new("git") diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index 894d179..291a6b6 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -142,6 +142,56 @@ fn copy_tree(source: &Path, destination: &Path) { } } +#[test] +fn shallow_push_is_rejected_before_publication_and_unshallow_recovers() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("source"); + let shallow = temporary.path().join("shallow"); + let destination = temporary.path().join("destination"); + let storage = temporary.path().join("encrypted"); + initialize_git(&source); + let first = commit(&source, "first\n", "first"); + let second = commit(&source, "second\n", "second"); + git( + temporary.path(), + &[ + "clone", + "--quiet", + "--depth=1", + &format!("file://{}", source.display()), + shallow.to_str().unwrap(), + ], + ); + assert_eq!( + git(&shallow, &["rev-parse", "--is-shallow-repository"]), + "true" + ); + let key = KeyFile::generate(); + let encrypted = EncryptedRepository::new(FilesystemStorage::new(&storage), key); + encrypted.initialize().unwrap(); + let head_before = fs::read(storage.join("HEAD")).unwrap(); + let error = encrypted + .push_ref(&shallow, "refs/heads/main", false) + .unwrap_err(); + assert!(error.to_string().contains("shallow"), "{error:#}"); + assert_eq!(fs::read(storage.join("HEAD")).unwrap(), head_before); + assert!(encrypted.verify().unwrap().refs.is_empty()); + git(&shallow, &["fetch", "--unshallow"]); + encrypted + .push_ref(&shallow, "refs/heads/main", false) + .unwrap(); + initialize_git(&destination); + encrypted.fetch_into(&destination, "encrypted").unwrap(); + assert_eq!( + git(&destination, &["rev-parse", "refs/remotes/encrypted/main"]), + second + ); + assert_eq!( + git(&destination, &["rev-parse", "refs/remotes/encrypted/main^"]), + first + ); +} + #[test] fn pushes_incrementally_and_fetches_into_another_repository() { let temporary = tempfile::tempdir().unwrap(); @@ -504,54 +554,6 @@ fn local_publication_advances_verified_frontier_before_observation() { ); } -#[test] -fn shallow_publication_does_not_advance_verified_frontier() { - let temporary = tempfile::tempdir().unwrap(); - let full_source = temporary.path().join("full-source"); - let shallow_source = temporary.path().join("shallow-source"); - let remote = temporary.path().join("remote"); - initialize_git(&full_source); - commit(&full_source, "first\n", "first"); - commit(&full_source, "second\n", "second"); - - let source_url = format!("file://{}", full_source.display()); - let clone = Command::new("git") - .args(["clone", "--quiet", "--depth=1", "--no-tags"]) - .arg(&source_url) - .arg(&shallow_source) - .output() - .unwrap(); - assert!( - clone.status.success(), - "git clone: {}", - String::from_utf8_lossy(&clone.stderr) - ); - assert_eq!( - git(&shallow_source, &["rev-parse", "--is-shallow-repository"]), - "true" - ); - - let key = KeyFile::generate(); - let repository = EncryptedRepository::new(FilesystemStorage::new(&remote), key); - repository.initialize().unwrap(); - repository - .push_update_for_remote( - &shallow_source, - "origin", - "refs/heads/main", - "refs/heads/main", - false, - ) - .unwrap(); - - assert!( - client_state(&shallow_source, "origin")["verified_refs"] - .as_object() - .unwrap() - .is_empty() - ); -} - fn read_stored_object(root: &Path, kind: &str, id: &str) -> Vec { fs::read(root.join(kind).join(&id[..2]).join(id)).unwrap() }