Skip to content

Commit 89d4928

Browse files
author
t
committed
test(node): close #135 tree-gate coverage gaps from review
Add the handler- and helper-level cases the code review flagged: - ipfs_cid_dangling_tree_fails_closed_under_path_rules: a dangling tree via git mktree 404s through cid_router for anon AND owner (handler wiring of the fail-closed tree arm, not just the helper). - ipfs_cid_tree_served_when_no_path_scoped_rule: a tree CID serves 200 when no path-scoped rule exists (the skip-walk branch is not over-gating trees). - allowed_tree_set_includes_tree_shared_across_allowed_and_denied_paths: T2 content-dedup — a tree oid reachable at both an allowed and a withheld path is included (allowed-wins). - allowed_tree_set_includes_root_trees_of_all_reachable_commits: the batched root-tree pass returns every reachable commit's root, not just HEAD's.
1 parent 985e2af commit 89d4928

2 files changed

Lines changed: 217 additions & 2 deletions

File tree

‎crates/gitlawb-node/src/git/visibility_pack.rs‎

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -892,9 +892,9 @@ mod tests {
892892
.stdout(Stdio::piped())
893893
.spawn()
894894
.unwrap();
895-
write!(
895+
writeln!(
896896
child.stdin.as_mut().unwrap(),
897-
"100644 blob {secret_oid}\tdangling-only-unreferenced.txt\n"
897+
"100644 blob {secret_oid}\tdangling-only-unreferenced.txt"
898898
)
899899
.unwrap();
900900
let out = child.wait_with_output().unwrap();
@@ -911,6 +911,103 @@ mod tests {
911911
}
912912
}
913913

914+
#[test]
915+
fn allowed_tree_set_includes_tree_shared_across_allowed_and_denied_paths() {
916+
// T2 (content-dedup): the SAME tree oid reachable at both an allowed and a
917+
// withheld path is INCLUDED for anon (allowed-wins) — its structure is
918+
// visible to the caller at the allowed path. Mirrors the blob analog
919+
// `same_blob_at_allowed_and_denied_path_is_not_withheld`.
920+
let td = TempDir::new().unwrap();
921+
let work = td.path().join("work");
922+
std::fs::create_dir_all(work.join("pub/sub")).unwrap();
923+
std::fs::create_dir_all(work.join("sec/sub")).unwrap();
924+
std::fs::write(work.join("pub/sub/f.txt"), b"same bytes\n").unwrap();
925+
std::fs::write(work.join("sec/sub/f.txt"), b"same bytes\n").unwrap();
926+
let run = |args: &[&str]| {
927+
assert!(
928+
Command::new("git")
929+
.args(args)
930+
.current_dir(&work)
931+
.status()
932+
.unwrap()
933+
.success(),
934+
"git {args:?}"
935+
);
936+
};
937+
run(&["init", "-q"]);
938+
run(&["config", "user.email", "t@t"]);
939+
run(&["config", "user.name", "t"]);
940+
run(&["add", "."]);
941+
run(&["commit", "-qm", "seed"]);
942+
let oid = |rev: &str| {
943+
let out = Command::new("git")
944+
.args(["rev-parse", rev])
945+
.current_dir(&work)
946+
.output()
947+
.unwrap();
948+
String::from_utf8_lossy(&out.stdout).trim().to_string()
949+
};
950+
let pub_sub = oid("HEAD:pub/sub");
951+
let sec_sub = oid("HEAD:sec/sub");
952+
assert_eq!(pub_sub, sec_sub, "identical content dedups to one tree oid");
953+
954+
// Withhold /sec from anon; the shared oid is still reachable at /pub/sub.
955+
let rules = [rule("/sec/**", &[])];
956+
let anon = allowed_tree_set_for_caller(&work, &rules, true, OWNER, None).unwrap();
957+
assert!(
958+
anon.contains(&pub_sub),
959+
"a tree reachable at an allowed path is included even when also at a withheld path"
960+
);
961+
}
962+
963+
#[test]
964+
fn allowed_tree_set_includes_root_trees_of_all_reachable_commits() {
965+
// The batched root-tree pass (root_tree_pairs) must return EVERY reachable
966+
// commit's root tree, not just HEAD's — two commits with distinct root trees
967+
// both land in the set. Guards the git-log-over-N-commits root derivation.
968+
let td = TempDir::new().unwrap();
969+
let work = td.path().join("work");
970+
std::fs::create_dir_all(&work).unwrap();
971+
let run = |args: &[&str]| {
972+
assert!(
973+
Command::new("git")
974+
.args(args)
975+
.current_dir(&work)
976+
.status()
977+
.unwrap()
978+
.success(),
979+
"git {args:?}"
980+
);
981+
};
982+
run(&["init", "-q"]);
983+
run(&["config", "user.email", "t@t"]);
984+
run(&["config", "user.name", "t"]);
985+
let oid = |rev: &str| {
986+
let out = Command::new("git")
987+
.args(["rev-parse", rev])
988+
.current_dir(&work)
989+
.output()
990+
.unwrap();
991+
String::from_utf8_lossy(&out.stdout).trim().to_string()
992+
};
993+
std::fs::write(work.join("a.txt"), b"one\n").unwrap();
994+
run(&["add", "."]);
995+
run(&["commit", "-qm", "c1"]);
996+
let root1 = oid("HEAD^{tree}");
997+
std::fs::write(work.join("b.txt"), b"two\n").unwrap();
998+
run(&["add", "."]);
999+
run(&["commit", "-qm", "c2"]);
1000+
let root2 = oid("HEAD^{tree}");
1001+
assert_ne!(root1, root2, "the two commits have distinct root trees");
1002+
1003+
// Public repo, no rules: every reachable tree is allowed for anon.
1004+
let set = allowed_tree_set_for_caller(&work, &[], true, OWNER, None).unwrap();
1005+
assert!(
1006+
set.contains(&root1) && set.contains(&root2),
1007+
"root trees of BOTH reachable commits are in the set (batched root pass)"
1008+
);
1009+
}
1010+
9141011
#[test]
9151012
fn fail_closed_drops_dangling_private_blob() {
9161013
// #99: a private blob orphaned by a force-push/amend is unreachable but

‎crates/gitlawb-node/src/test_support.rs‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2487,6 +2487,124 @@ mod tests {
24872487
assert!(!body.contains("DANGLING SECRET"));
24882488
}
24892489

2490+
/// #135: a DANGLING tree (in the ODB, referenced by no commit) 404s under
2491+
/// path-scoped rules for anon AND owner — the reachable-only allowed-tree-set
2492+
/// never enumerates it. Handler-level companion to the helper test
2493+
/// `allowed_tree_set_excludes_dangling_tree`, proving the `get_by_cid` tree arm
2494+
/// (memo insert + `!in_allowed` continue) fails closed on the dangling case.
2495+
#[sqlx::test]
2496+
async fn ipfs_cid_dangling_tree_fails_closed_under_path_rules(pool: PgPool) {
2497+
use crate::db::VisibilityMode;
2498+
use gitlawb_core::identity::Keypair;
2499+
2500+
let owner = Keypair::generate();
2501+
let owner_did = owner.did().to_string();
2502+
let slug = owner_did.replace([':', '/'], "_");
2503+
let short = owner_did.split(':').next_back().unwrap().to_string();
2504+
let state = test_state(pool).await;
2505+
2506+
let fx = seed_cid_repos(&slug, &short, &["dangtree"]);
2507+
let bare = std::path::PathBuf::from("/tmp")
2508+
.join(&slug)
2509+
.join("dangtree.git");
2510+
2511+
// Dangling tree via `git mktree`: a UNIQUE entry name so its oid is
2512+
// content-distinct from every reachable tree (a content-identical tree would
2513+
// dedup to a reachable oid — that is T2, not danglingness).
2514+
let mut child = std::process::Command::new("git")
2515+
.args(["mktree"])
2516+
.current_dir(&bare)
2517+
.stdin(std::process::Stdio::piped())
2518+
.stdout(std::process::Stdio::piped())
2519+
.spawn()
2520+
.expect("spawn git mktree");
2521+
{
2522+
use std::io::Write;
2523+
writeln!(
2524+
child.stdin.as_mut().unwrap(),
2525+
"100644 blob {}\tdangling-only-unreferenced.txt",
2526+
fx.secret_oid
2527+
)
2528+
.unwrap();
2529+
}
2530+
let out = child.wait_with_output().expect("mktree output");
2531+
assert!(
2532+
out.status.success(),
2533+
"git mktree: {}",
2534+
String::from_utf8_lossy(&out.stderr)
2535+
);
2536+
let dangling_tree_oid = String::from_utf8_lossy(&out.stdout).trim().to_string();
2537+
assert_eq!(dangling_tree_oid.len(), 64, "expected sha256 oid");
2538+
let dangling_cid = cid_for_oid(&dangling_tree_oid);
2539+
2540+
state
2541+
.db
2542+
.create_repo(&seed_repo(&owner_did, "dangtree"))
2543+
.await
2544+
.expect("seed repo");
2545+
let rec = state
2546+
.db
2547+
.get_repo(&owner_did, "dangtree")
2548+
.await
2549+
.unwrap()
2550+
.unwrap();
2551+
state
2552+
.db
2553+
.set_visibility_rule(&rec.id, "/secret/**", VisibilityMode::B, &[], &owner_did)
2554+
.await
2555+
.expect("deny rule");
2556+
2557+
for req in [cid_anon(&dangling_cid), cid_signed(&owner, &dangling_cid)] {
2558+
let (st, _) = cid_parts(cid_router(&state).oneshot(req).await.unwrap()).await;
2559+
assert_eq!(
2560+
st,
2561+
StatusCode::NOT_FOUND,
2562+
"dangling tree must 404 under path-scoped rules (anon + owner)"
2563+
);
2564+
}
2565+
}
2566+
2567+
/// #135: with NO path-scoped rule the per-object gate is skipped, so a tree CID
2568+
/// is served (the `"/"` gate is the whole story). Guards against over-gating
2569+
/// trees — the tree analog of the blob skip-walk branch.
2570+
#[sqlx::test]
2571+
async fn ipfs_cid_tree_served_when_no_path_scoped_rule(pool: PgPool) {
2572+
use gitlawb_core::identity::Keypair;
2573+
2574+
let owner = Keypair::generate();
2575+
let owner_did = owner.did().to_string();
2576+
let slug = owner_did.replace([':', '/'], "_");
2577+
let short = owner_did.split(':').next_back().unwrap().to_string();
2578+
let state = test_state(pool).await;
2579+
2580+
let fx = seed_cid_repos(&slug, &short, &["nopathrule"]);
2581+
let tree_cid = cid_for_oid(&fx.secret_tree_oid);
2582+
2583+
// Public repo, no visibility rules → has_path_scoped_rule is false.
2584+
state
2585+
.db
2586+
.create_repo(&seed_repo(&owner_did, "nopathrule"))
2587+
.await
2588+
.expect("seed repo");
2589+
2590+
let (st, body) = cid_bytes(
2591+
cid_router(&state)
2592+
.oneshot(cid_anon(&tree_cid))
2593+
.await
2594+
.unwrap(),
2595+
)
2596+
.await;
2597+
assert_eq!(
2598+
st,
2599+
StatusCode::OK,
2600+
"tree served to anon when no path-scoped rule exists"
2601+
);
2602+
assert!(
2603+
bytes_contain(&body, b"b.txt"),
2604+
"served tree carries its child structure"
2605+
);
2606+
}
2607+
24902608
// ---------------------------------------------------------------------------
24912609
// Issue #120 — repo-scoped read surfaces visibility gate
24922610
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)