fix(node): gate GET /ipfs/{cid} tree objects so a withheld subtree's structure can't leak (#135) - #173
fix(node): gate GET /ipfs/{cid} tree objects so a withheld subtree's structure can't leak (#135)#173beardthelion wants to merge 84 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughPath-scoped IPFS visibility now applies to blob and tree objects using caller-specific reachable allow-sets. CID resolution uses ChangesPath-scoped object visibility
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant IPFSHandler
participant Database
participant VisibilityPack
participant GitRepo
Caller->>IPFSHandler: Request CID
IPFSHandler->>Database: Resolve CID through pinned_cids
Database-->>IPFSHandler: Candidate Git OIDs
IPFSHandler->>VisibilityPack: Compute caller blob or tree allow-set
VisibilityPack->>GitRepo: Enumerate reachable commits and paths
GitRepo-->>VisibilityPack: Reachable object paths
VisibilityPack-->>IPFSHandler: Allowed object OIDs
IPFSHandler-->>Caller: Serve object or return 404
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
crates/gitlawb-node/src/api/ipfs.rs (1)
143-209: 🚀 Performance & Scalability | 🔵 TrivialThe blob/tree gating, memo selection, and fail-closed arms look correct.
One operational note:
/ipfs/{cid}is reachable anonymously, and under path-scoped rules each request against an object that exists in a repo triggers a full-history reachability walk (onegit ls-tree -rztper reachable commit, plus the root-tree pass for trees). The memo is request-scoped only, so a spray of valid blob/tree CIDs against a large-history repo re-runs the walk on every request. Consider a bounded cross-request allow-set cache (keyed by repo id + head oid + caller) and/or rate limiting on this route to cap the cost.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/gitlawb-node/src/api/ipfs.rs` around lines 143 - 209, Mitigate repeated full-history walks in the /ipfs/{cid} path by adding a bounded cross-request cache for computed blob/tree allow-sets, keyed by repository ID, current head OID, object type, and caller identity; invalidate or naturally bypass entries when the head changes. Implement this around allowed_blob_set_for_caller, allowed_tree_set_for_caller, and the existing memo lookup, and consider adding rate limiting for anonymous requests to further cap abuse.crates/gitlawb-node/src/git/visibility_pack.rs (1)
391-416: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAvoid
ARG_MAXinroot_tree_pairs.Passing every reachable commit to
git logonargvscales with history size and can fail on very large repos. When that happens, the tree CID path for path-scoped rules skips the repo and the object falls through to a 404. Feed the commits over stdin instead (git log --stdin --no-walk=unsorted --format=%T).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/gitlawb-node/src/git/visibility_pack.rs` around lines 391 - 416, Update root_tree_pairs to avoid placing all commit IDs in argv: invoke git log with --stdin alongside --no-walk=unsorted and --format=%T, write the commits joined by newlines to the child process stdin, and handle stdin/command errors consistently with the existing context and status checks. Remove the argument expansion of commits while preserving the existing tree-pair parsing.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/gitlawb-node/src/api/ipfs.rs`:
- Around line 143-209: Mitigate repeated full-history walks in the /ipfs/{cid}
path by adding a bounded cross-request cache for computed blob/tree allow-sets,
keyed by repository ID, current head OID, object type, and caller identity;
invalidate or naturally bypass entries when the head changes. Implement this
around allowed_blob_set_for_caller, allowed_tree_set_for_caller, and the
existing memo lookup, and consider adding rate limiting for anonymous requests
to further cap abuse.
In `@crates/gitlawb-node/src/git/visibility_pack.rs`:
- Around line 391-416: Update root_tree_pairs to avoid placing all commit IDs in
argv: invoke git log with --stdin alongside --no-walk=unsorted and --format=%T,
write the commits joined by newlines to the child process stdin, and handle
stdin/command errors consistently with the existing context and status checks.
Remove the argument expansion of commits while preserving the existing tree-pair
parsing.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 335f1ebc-783c-4552-bfd6-ebc5894e4d9a
📒 Files selected for processing (4)
crates/gitlawb-node/src/api/ipfs.rscrates/gitlawb-node/src/git/visibility_pack.rscrates/gitlawb-node/src/test_support.rscrates/gitlawb-node/src/visibility.rs
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Make the CID tests and lookup use the identifier published by the pin path
crates/gitlawb-node/src/test_support.rs:2064
These new assertions requestcid_for_oid(...), whose multihash is a Git object ID. The real pin path instead storesCID(sha256(raw object content)), andgl ipfs getsends that stored CID back to this handler. Even with SHA-256 Git repositories those values differ because Git hashes"<type> <len>\\0" + content;get_by_cidtherefore treats a real pinned CID as a nonexistent OID and returns 404 before this new tree gate runs. Please make the serving lookup use the same CID-to-object mapping as pinning (or make the two identifiers deliberately identical), and cover a CID produced from the fixture object's raw bytes rather than encoding its OID. -
[P2] Do not pass the complete history as
git logarguments
crates/gitlawb-node/src/git/visibility_pack.rs:395
root_tree_pairsadds every reachable commit to the process argv. On a long history this exceedsARG_MAX(about 32k SHA-256 OIDs on a 2 MiB limit), so spawninggit logfails; the handler treats that walk error as a denial and returns 404 even to an authorized caller requesting a reachable/root tree. Please complete CodeRabbit's pending root-tree request by feeding commit IDs throughgit log --stdinor by batching/root-resolving them during the existing per-commit traversal.
89d4928 to
7dec45c
Compare
#135) get_by_cid treated the CID's sha2-256 digest as a git oid and cat-file'd it, but a real pin CID digests the raw object content (Cid::from_git_object_bytes), not the framed git object, so every pinned CID 404'd before the #135 tree gate could run. Resolve the incoming CID to its oid through the pinned_cids table (new Db::oid_for_cid + idx_pinned_cids_cid) and gate on that oid; a CID never pinned here is an opaque 404, uniform with a genuine not-found and a visibility denial. The tree-gate tests now build the request CID the way the pin path does (pin_cid_for: read raw bytes, Cid::from_git_object_bytes, record_pinned_cid) instead of from the oid, so they exercise the gate on a production CID rather than an identifier that never occurs. RED before the serve fix (the served-object assertions 404), GREEN after. Also feed root_tree_pairs' commit set to 'git log --stdin' on stdin instead of argv: a long history overflowed ARG_MAX, failing the walk, and the caller fail-closed 404s an authorized reader of a reachable/root tree. Oids are written from a separate thread while the main thread drains stdout, so large input and output cannot deadlock on the pipe buffers; a scale test over 2500 commits guards it. Resolves jatmn's P1 and P2 on #173.
The index was appended to the v1 bundle, which is recorded once in schema_migrations and then skipped, so a node already past v1 would never create it. Move it to a new v11 migration and add an upgrade-path test that drops the index plus its migration record and asserts run_migrations() recreates it. Follow-up to jatmn's P1/P2 on #173; addresses INV-7 caught in pre-push review.
|
Both addressed as of P1 (CID → object mapping). You're right that the lookup and the tests diverged from the pin path. P2 (git log argv). Rebased onto main. |
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Bound the new tree visibility walk on the public retrieval route
crates/gitlawb-node/src/api/ipfs.rs:173
Every request for a known tree CID in a repo with a path-scoped rule recomputes the complete allowed-tree set:rev-list, one recursivels-treeper reachable commit, and a root-tree pass. The memo only lasts for that one request, while/ipfs/{cid}is anonymous and the public pins index exposes valid CIDs. An unauthenticated caller can therefore repeat a tree-CID request and saturate the blocking pool/CPU with unbounded full-history work. Please add a bounded, revision-aware cross-request cache and/or a route-level work/rate limit before enabling this path. -
[P2] Try every object recorded for a content CID
crates/gitlawb-node/src/db/mod.rs:2188
The new index deliberately permits duplicate CIDs, but this unorderedLIMIT 1chooses only one mapped OID and the handler never tries another. CIDs here hash untyped raw object bytes, so a tree and a blob containing its raw tree bytes have different Git OIDs but the same CID; both can be pinned. If PostgreSQL chooses a withheld, stale, or absent object while another mapped object is readable, the endpoint returns 404 for a valid advertised CID. Return all matching OIDs and run each through the existing repository/visibility checks (with collision coverage) rather than selecting one arbitrarily. -
[P2] Canonicalize parsed CIDv1 values before the database lookup
crates/gitlawb-node/src/api/ipfs.rs:92
CidGeneric::from_straccepts valid CIDv1 multibase encodings, but this lookup uses the original request spelling. Pins are stored under the canonical base32 string produced byCid::from_git_object_bytes(...).to_string(), so an equivalent base58/base64 CID passes validation yet missespinned_cidsand returns 404. Look upcid.to_string()(or a binary canonical key) and add an alternate-encoding retrieval test.
|
Ready for re-review on
Ready for another look. |
…aps (#173) Resolve jatmn's three CHANGES_REQUESTED findings on GET /ipfs/{cid}, each verified by execution (revert -> RED, fix -> GREEN): - [P1] Rate-limit the full-history allowed-set walk per source IP, checked once right before the walk spawns (the resource sink), reusing the same RateLimiter and trusted-proxy key as the push brake. GITLAWB_IPFS_RATE_LIMIT (default 600/hr, 0 disables, bounded key map). A memo hit or a cheap non-path-scoped fetch is never braked; the key is the non-farmable client IP, not the DID. - [P2] Resolve a CID to every mapped oid (oids_for_cid) instead of LIMIT 1, and try each through the repo/visibility loop, so a withheld or absent duplicate no longer false-404s a CID that has a readable object. - [P2] Canonicalize the parsed CID (cid.to_string()) before the pinned_cids lookup so an equivalent base58/base64 spelling resolves to the canonical base32 key the pin path stores. Tests: ipfs_walk_rate_limited_per_source (shed, per-source isolation, and the must-not on a cheap non-walk fetch), oids_for_cid_returns_all_duplicates, ipfs_cid_collision_serves_readable_duplicate, ipfs_alt_encoding_cid_resolves. Full node suite green (502).
|
Addressed all three on [P1] Bound the tree-visibility walk. The full-history allowed-set walk is now rate-limited per source IP, checked once immediately before it spawns (the resource sink), reusing the same [P2] Try every object recorded for a content CID. [P2] Canonicalize CIDv1 before the lookup. The lookup keys on Full node suite green (502). |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/gitlawb-node/src/api/ipfs.rs (1)
143-159: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winStale invariant comment: "one request builds exactly one of the two sets."
The tree analog (
#135): a withheld subtree's tree object is gated the same way a withheld blob is, so its structure cannot leak by CID where get_tree protects it. The adjacent claim that Built lazily and only for a tree fetch (a request is one CID = one object type), so one request builds exactly one of the two sets — no double walk no longer holds: the new multi-candidate oid loop can resolve a single CID to both a blob and a tree oid in the same repo (the documented CID-collision case indb/mod.rs'soids_for_cid), which would populate bothallowed_blob_memoandallowed_tree_memowithin one request.Update the comment to reflect that both sets can now be built in the collision case.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/gitlawb-node/src/api/ipfs.rs` around lines 143 - 159, Update the comment above allowed_blob_memo and allowed_tree_memo to remove the claim that one request builds exactly one set; state that the sets are built lazily and that both may be populated when a CID resolves to both blob and tree candidates.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/gitlawb-node/src/api/ipfs.rs`:
- Around line 165-279: The walk_rate_checked guard in the per-object gating flow
only limits the first spawn_blocking walk, allowing subsequent repo/type walks
within one request. Replace the one-time check with per-request walk accounting
and enforce a cap before every allowed_blob_set_for_caller or
allowed_tree_set_for_caller invocation, rejecting or skipping once exhausted
while preserving the existing IP limiter check and fail-closed walk handling.
In `@crates/gitlawb-node/src/main.rs`:
- Around line 328-345: The periodic cleanup task must also invoke cleanup on
ipfs_rate_limiter. Update the cleanup loop to call its cleanup method alongside
the other rate limiters, ensuring expired source-IP entries are removed
regularly and the 200,000-key bound does not retain stale clients.
---
Outside diff comments:
In `@crates/gitlawb-node/src/api/ipfs.rs`:
- Around line 143-159: Update the comment above allowed_blob_memo and
allowed_tree_memo to remove the claim that one request builds exactly one set;
state that the sets are built lazily and that both may be populated when a CID
resolves to both blob and tree candidates.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 06879bbd-f899-4792-9943-5bc38b5533fa
📒 Files selected for processing (6)
crates/gitlawb-node/src/api/ipfs.rscrates/gitlawb-node/src/auth/mod.rscrates/gitlawb-node/src/db/mod.rscrates/gitlawb-node/src/main.rscrates/gitlawb-node/src/state.rscrates/gitlawb-node/src/test_support.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- crates/gitlawb-node/src/test_support.rs
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Bound every full-history walk, not just the first one in a request
crates/gitlawb-node/src/api/ipfs.rs:216
walk_rate_checkedconsumes one IP quota token and then remains true while the nested CID-candidate × repository loops continue. A shared pinned blob/tree CID can be present in any number of public path-scoped repositories (and a CID can intentionally have multiple OID candidates); when the caller is denied in each, the request starts oneallowed_*_set_for_callerfull-history walk per repository after paying for only the first. Thus 600 requests per IP can still cause 600 × Nrev-list/ls-treewalks and exhaust the blocking pool/CPU. Charge or cap every spawned walk (and add a multi-repository regression) rather than treating the first check as a request-wide authorization for unbounded work. -
[P1] Do not leave resolvable pinned CIDs on the unbounded all-repository probe path
crates/gitlawb-node/src/api/ipfs.rs:117
The newpinned_cidslookup makes the CIDs published by the unsigned pin index reach this loop, but the handler still acquires every readable repository and runs the synchronousgit cat-file -tprobe before it can determine which repository contains the OID. The IP limiter is only consulted later, after a path-scoped blob/tree is found, so a CID for an old, stale, or unscoped pin can repeatedly force O(repositories) local probes (and cold Tigris existence/download work) without consuming a quota token. This reactivates the availability problem tracked in #164 for the now-functional CID contract; associate pins with their owning repository/rows or apply a route-level bound before scanning, and move the blocking probe off the async runtime. -
[P2] Include the IPFS limiter in the periodic expiry sweep
crates/gitlawb-node/src/main.rs:428
The new limiter has the same client-controlled key space and one-hour window as the other bounded IP limiters, but the cleanup task clones and cleans only the older limiters.RateLimiter::checkperforms a global expiry sweep only when its map is already full, so a distributed request burst can retain up to 200,000 expired IP windows indefinitely during normal traffic. Clonestate.ipfs_rate_limiterhere and callcleanup()with the other limiter cleanup calls.
…er (#173) Two review findings on the /ipfs/{cid} retrieval path. Bound the full-history walk fan-out per request. The ipfs_rate_limiter check fires once per request, but within a single request the object can exist under path-scoped rules in many repos, and each distinct repo pays its own spawn_blocking allowed-set walk (the memo only dedups the same repo). One request could therefore fan out to O(repos) walks for a single rate-limiter token (INV-10). Add MAX_HISTORY_WALKS_PER_REQUEST (16): once that many walks have run, no further walk is spawned for the rest of the request. This also closes jatmn's open P1 ("bound every full-history walk, not just the first one"). The ceiling uses a plain break, not a whole-search break. The budget persists across the outer oid-candidate loop, so a later candidate servable WITHOUT a walk (a commit/tag, or a no-rule public copy) is still served, while any further walk it would need re-trips the guard and is skipped. Breaking the whole search would 404 that free candidate for no amplification benefit. Sweep the ipfs limiter in the periodic cleanup task. ipfs_rate_limiter was the one bounded limiter the 300s cleanup loop never called cleanup() on, so its map sat full of stale source-IP entries until an inline capacity sweep reclaimed them at the 200k cap. The six cleanup calls now live in AppState::sweep_rate_limiters, which the loop drives, so the set is testable and a new limiter has one place to be added. Tests (each RED->GREEN verified by execution): - ipfs_walk_fanout_capped_per_request: cap+1 deniers precede a readable copy; capped -> 404, neutralizing the break -> 200. - ipfs_walk_cap_still_serves_walk_free_candidate: a multi-oid CID whose blob candidate burns the budget still serves its walk-free commit candidate (200); a whole-search break -> 404. - sweep_rate_limiters_includes_ipfs_limiter: drives the sweep and asserts the ipfs limiter's expired entry is evicted; dropping its cleanup() -> entry survives.
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Keep quarantined repositories out of the CID search
crates/gitlawb-node/src/api/ipfs.rs:185
The new CID-to-OID lookup makes this route reach real pinned objects, but itslist_all_repos()scan includes quarantined mirror rows and the loop never applies the quarantine gate used by the normal serve/clone handlers. A root-readable quarantined mirror can therefore return an on-disk pinned object through/ipfs/{cid}, despite quarantine being explicitly defined as hidden from serve, clone, and listings. Filter the scan to non-quarantined rows (or select and skip the flag) and cover a quarantined CID with a 404 regression test. -
[P1] Do not serve unreachable commit and tag objects under path rules
crates/gitlawb-node/src/api/ipfs.rs:215
The new resolver also activates CIDs recorded by the full-scan pin path. That path deliberately includes dangling non-blob objects, while this handler only proves reachability for blobs and trees; commits and tags fall through after the root gate. Thus a dangling commit/tag in a path-scoped repository can be pinned and then served anonymously even though it has no authorized reachable path, exposing commit/tag messages and structural metadata. Apply a reachable authorization check to these types too (or exclude unreachable non-blobs from pinning) and add a dangling commit/tag denial test. -
[P1] Debit every full-history walk from the IPFS quota
crates/gitlawb-node/src/api/ipfs.rs:245
walk_rate_checkedcharges the source IP only before the first walk, yet the same request can launch sixteen full-historyspawn_blockingwalks. At the configured default of 600, one source can therefore induce up to 9,600 walks per hour, defeating the stated per-IP walk brake and allowing a known CID across path-scoped repositories to consume the blocking pool and CPU. Consume quota for each spawned walk (or explicitly account for the whole request's walk budget) instead of suppressing the later checks. -
[P2] Continue searching for a walk-free readable copy after the fan-out cap
crates/gitlawb-node/src/api/ipfs.rs:242
Reaching the 16-walk ceiling usesbreak, which exits the entire repository loop for the current OID. Becauselist_all_repos()is ordered by mutableupdated_at, newer path-scoped rows that deny a shared object can consume the budget before an older no-rule public copy is considered; the request then returns an opaque 404 for content that remains publicly readable. This is also the behavior asserted byipfs_walk_fanout_capped_per_request, despite the surrounding comment claiming a later walk-free public copy is still served. Skip only candidates that require another walk and continue scanning for cheap readable candidates. -
[P2] Sign
gl ipfs getrequests when an identity is available
crates/gitlawb-node/src/api/ipfs.rs:119
Resolving actual pin CIDs makes the endpoint usable for path-authorized objects, butgl ipfs getstill constructsNodeClientwithNone, exposes no identity directory, and always calls the unsignedget. Owners and listed readers therefore receive the opaque anonymous 404 for every private/path-scoped object that the CLI can now resolve publicly. GiveGetthe same identity loading and signed-read path asList(or explicitly restrict and document it as public-only).
The CID resolver loop gated only on visibility, and list_all_repos() does not
filter quarantined mirror rows, so a public quarantined mirror served its pinned
objects via GET /ipfs/{cid} despite quarantine being hidden from serve/clone/
listings. Prefetch the quarantined ids and skip them in the loop, before the
visibility check so the mirror's own owner also 404s.
RED->GREEN: ipfs_cid_quarantined_repo_withheld_from_anon_and_owner serves 200
(anon+owner) without the skip, 404 with it; baseline pre-quarantine 200 confirms
the object is otherwise servable.
…173, F3+F4) F3: the per-IP walk brake charged one token per REQUEST (a walk_rate_checked latch), while a request spawns up to MAX_HISTORY_WALKS_PER_REQUEST walks, so one IP could drive 16x its quota of full-history walks. Debit one token per spawned walk and drop the latch; a memo hit or walk-free candidate is still never charged. F4: hitting the walk cap used a plain break that exited the repo loop for the current oid, abandoning a walk-free readable copy (list_all_repos is ORDER BY updated_at DESC, so a newer path-scoped denier can precede an older no-rule public copy) and returning an opaque 404 for publicly-readable content. Use continue to skip only the walk-requiring candidate and keep scanning; walks is incremented only inside the walk block, so the amplification bound is unchanged. RED->GREEN: ipfs_walk_quota_debited_per_walk (one request, quota 1, 2 deniers -> 429 on the 2nd walk; was 404). ipfs_walk_fanout_capped_per_request flipped from 404 to 200 + x-git-hash == the blob served from the no-rule public copy. Existing ipfs_walk_rate_limited_per_source and ipfs_walk_cap_still_serves_walk_free_candidate stay green.
The /ipfs/{cid} resolver now serves path-scoped objects to authorized readers,
but `gl ipfs get` built NodeClient with None and always sent an unsigned request,
so an owner or listed reader received the opaque anonymous 404 for content they
can read. Add a --dir identity arg (like `list`), load the keypair best-effort,
and use get_authed (signs iff a keypair is present, unsigned fallback keeps public
reads working).
RED->GREEN: test_cmd_get_signs_when_identity_present drives a signature-matching
mock — 501 (unmatched) while unsigned, 200 once signed. test_cmd_get_anonymous_
denial_is_error guards the must-not: a 404 surfaces as an error, not masked success.
…, F2) The full-scan pin path deliberately pins dangling (unreachable) non-blob objects, but the CID resolver only proved reachability for blobs and trees; commit and tag objects fell through to serve. A dangling commit/tag in a path-scoped repo could therefore be served anonymously by CID, leaking its message and structural metadata. Add reachable_commit_tag_oids (reachable commits via rev-list --all UNION annotated-tag objects at refs) and gate commit/tag under a path-scoped rule against it, exactly as blob/tree are gated against their allowed-sets. The three walks are unified into one cap+per-walk-quota path, so commit/tag reachability walks share the fan-out ceiling and IP quota and cannot bypass them (R6). RED->GREEN: ipfs_cid_dangling_commit_and_tag_fail_closed_under_path_rules (a dangling commit AND annotated tag, sentinel messages, must 404 for anon+owner with no leak; served 200 + sentinel before the fix). Reachable commit/tag still serve: ipfs_cid_gate_withholds_blob_from_unauthorized stays green. ipfs_walk_commit_tag_ candidate_respects_the_walk_cap (was ipfs_walk_cap_still_serves_walk_free_candidate) proves commit/tag walks respect the cap. Full node (508) + gl (268) suites green.
…173) Addresses the code-review findings on the F2 reachability gate: - P2: reachable_commit_tag_oids routed through reachable_commits, which runs assert_all_refs_are_commits and bails when any ref peels to a non-commit (an annotated tag of a tree is pushable through receive-pack). That fail-closed the whole repo, 404ing every reachable commit/tag CID for a legitimate reader. Decouple: enumerate reachable commits with a bare rev-list --all (+ HEAD) and no ref-commit assertion. A dangling object is still absent from rev-list and the ref walk, so no dangling object is admitted (no leak) — only availability recovered. - P3: for-each-ref only lists ref tips, so a nested tag-of-a-tag's inner tag object (reachable via the outer ref tag, and pinnable) was omitted and its CID 404'd. Peel each tag's chain so every reachable tag object is included. - P3: spell out the commit|tag match arms (memo select + walk dispatch) with an unreachable! default so a future added gated type fails loud instead of silently routing to the reachable-commit/tag set. RED->GREEN: ipfs_cid_reachable_commit_served_despite_non_commit_ref (reachable commit 404'd by the guard bail, now 200). ipfs_cid_nested_tag_inner_object_served (inner tag 404'd without the peel loop, now 200; RED confirmed by neutering the peel). Dangling commit/tag still 404 (ipfs_cid_dangling_commit_and_tag...). Full node suite 510 green, fmt + clippy clean.
|
All five addressed on Quarantine (F1). The resolver loop gated only on visibility, and Dangling commit/tag (F2). The per-object gate only covered blob/tree; a dangling commit/tag under a path rule fell through to serve, leaking its message. Added Two design notes on this one. First, commit/tag are now walk-gated, so a shared reachable commit behind more than the cap of path-scoped deniers with the budget already spent will 404 rather than spawn an unbounded number of walks; that is the fan-out ceiling applying to commit/tag too. Second, the commit/tag reachability deliberately does not run the Per-walk quota (F3). The IP brake charged one token per request while a request spawns up to 16 walks. Now debits per spawned walk; a memo hit or walk-free candidate is still never charged. Fan-out (F4). The cap used a plain
Each fix is RED->GREEN with the guard reverted to confirm it is load-bearing. |
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Make the new reachability tests independent of the runner's Git identity
crates/gitlawb-node/src/test_support.rs:2814
Thecommit-treefixture and the annotatedgit tag -afixture both rely on Git's ambient author/committer configuration. The beta and stable CI jobs therefore fail at these two new tests withAuthor identity unknown/Committer identity unknownbefore exercising the handler (509 passed, 2 failed). Set the identity explicitly for these fixture commands (or in the isolated test repository) so the required suite is portable and green. -
[P1] Bound the CID resolver before it probes every repository
crates/gitlawb-node/src/api/ipfs.rs:204
Resolving a real pinned CID now scans every root-visible repository and callsrepo_store.acquire()plus the synchronousgit cat-file -tprobe before reaching the new limiter. A stale/absent CID from the public pins index, or a CID whose copies are in unscoped repositories, therefore bypasses the limiter and can repeatedly trigger O(all repos) blocking subprocesses; cold repositories additionally cause Tigris downloads and disk writes. Apply a route-level budget/rate limit before the scan (and move the blocking probe off the async worker), or retain repository provenance with the pin so this public endpoint does not fan out across the node. -
[P2] Preserve reachable annotated tags when
HEADis detached at one
crates/gitlawb-node/src/git/visibility_pack.rs:213
The new commit/tag set includesHEADinrev-list, which only yields the peeled commit, but seeds tag collection exclusively fromfor-each-ref. A bare repository can have detachedHEADpointing at an annotated tag with no ref at that tag; that tag is reachable and pinnable, yet never entersworklist, so its CID incorrectly 404s for an authorized reader under a path-scoped rule. Treat a tag-valuedHEADas an additional tag-chain seed and cover the detached-head case. -
[P2] Do not make a valid tag-of-tree disable all allowed tree reads
crates/gitlawb-node/src/git/visibility_pack.rs:142
allowed_tree_set_for_callerstill goes throughreachable_commits, whoseassert_all_refs_are_commitsrejects an annotated tag pointing at a tree. Such tags are valid (the new commit/tag path explicitly handles them), but the resulting walk error makes every tree CID in that repository—including the root and public subtrees—fall through to 404 for its owner and readers. Compute the tree reachability set without rejecting unrelated non-commit tag refs, while retaining a fail-closed policy for objects whose visibility cannot be established. -
[P2] Do not spend the walk ceiling before checking a later allowed scoped copy
crates/gitlawb-node/src/api/ipfs.rs:279
The global 16-walk budget is consumed by each newer path-scoped repository containing the same CID. After 16 deny paths, a later repository where the caller is actually allowed also needs an allowed-set walk, but this branch skips it and returns an opaque 404. Since repositories are ordered byupdated_at, a user can arrange the deny copies ahead of the authorized one; the current test only covers a later no-rule copy. Preserve the resource bound without turning an allowed path-scoped CID into a false not-found response. -
[P2] Continue scanning when an earlier scoped duplicate has exhausted the IP bucket
crates/gitlawb-node/src/api/ipfs.rs:294
Returning 429 immediately for the first walk that exceeds the per-IP quota prevents the resolver from reaching a later unscoped public copy of the same content, even though that copy needs no walk. A newer scoped duplicate can thus make an otherwise ordinary public CID retrieval fail solely due to repository order. Skip the walk-requiring candidate (or otherwise separate the quota decision from walk-free candidates) and retain the existing protection for expensive work. -
[P2] Do not silently discard an explicitly selected identity
crates/gl/src/ipfs_cmd.rs:98
gl ipfs get --dir <path>converts a missing, unreadable, or corruptidentity.pemintoNoneand sends an anonymous request. For a path-scoped object the authorized user then receives the endpoint's opaque 404 instead of the actionable key-load error, defeating the new signed-read behavior;gl ipfs listcorrectly propagates this same error. Keep unsigned fallback for intentionally anonymous use, but propagate failures for an explicit--dirand add coverage for it.
…lean The dependency allowlist caught this: sharing the predicate pulled url, and behind it idna and the icu crates, into the closure of the one crate that is supposed to stay embeddable. Both clients that need the predicate already parse URLs, so they opt in and nothing else pays for it. The test dependency is not optional, so the origin matrix still runs under a bare cargo test -p gitlawb-core. Gating the module on the feature alone would have left those tests silently unbuilt, which is the failure the module is there to prevent.
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Do not automatically follow a signed redirect without rebuilding its signature
crates/gl/src/http.rs:42
crates/git-remote-gitlawb/src/main.rs:355
The new policy deliberately follows same-origin normalization redirects, but reqwest resends the existing RFC 9421 headers. Those headers were made for the original request path (NodeClient::get_signedsigns itspathathttp.rs:118, and the remote helper signs the original URL); the server verifies@pathfrom the redirected request URI inauth/mod.rs:156. Thus a normal/api/v1/...to/api/v1/.../redirect, or a query normalization, makes authenticated IPFS reads, private fetches, and pushes fail signature verification at the target.The root cause is treating a redirect policy as a transport-only decision even though this signature scheme binds request-target semantics. A redirect callback can approve the destination, but it cannot replace the stale signature headers with headers over that destination. Do not fix this by weakening server verification or removing
@pathfrom the signature: that would re-open a request-target authorization boundary. Instead, make signed requests stop at redirects, or implement an explicit redirect loop that validates the same-origin target and rebuilds the complete request (including a new signature and, for 307/308, the original body) for each hop. Keep the automatic policy only for unsigned requests if desired. Add end-to-end coverage with the real verifier for a signed, path-changing same-origin redirect on bothgland the remote helper; the existing mock tests only prove that a target was reached. -
[P1] Size the IPFS work bucket for the combined provenance and legacy fallback path
crates/gitlawb-node/src/state.rs:451
ipfs_work_budgetreserveslegacy_probes + legacy_scan_pages, but the same limiter is debited for every provenance visibility walk inapi/ipfs.rs:1548. A source set at the cap can first spend all 17 path-scoped denying walks, then enter the legacy fallback because that capped set may have omitted a source. Under a supported lowGITLAWB_IPFS_RATE_LIMIT, the ensuing legacy probes/pages exhaust the bucket before their configured ceilings; the handler returns 429 without a continuation. After the bucket refills, a retry starts at the first row and repeats the same provenance charges, so a readable legacy holder beyond that point is permanently unreachable.The root cause is that one cross-request work bucket accounts for both phases, while the derived "one complete legacy search" floor accounts for only the second phase. The fix needs to establish a budget invariant for the entire request shape, not merely add a token to a single branch: reserve the worst permitted provenance-walk cost in the floor in addition to probe and page costs, accounting for the actual tighter walk cap, or separate the phase budgets so provenance work cannot consume the fallback's guaranteed reach budget. If a work brake can still terminate the fallback, it must return a continuation at the last durable pager cursor; a bare 429 is only safe when the caller already has a usable progress token. Add a test with a capped/incomplete source set of path-scoped deniers, a low rate limit, and a public legacy holder beyond the scan work that remains after those provenance debits.
-
[P2] Treat explicit default ports as equivalent to omitted ports in the redirect predicate
crates/gitlawb-core/src/redirect.rs:47
The predicate comparesUrl::port()directly, so an explicitly configuredhttp://node:80does not follow the documented HTTP-to-HTTPS upgrade tohttps://node/(and the corresponding explicit:443spelling fails in the other direction). These URLs are common proxy configurations and are precisely the same-host upgrade path the new policy says it permits, leaving signedgland remote-helper traffic at the 3xx response.The root cause is comparing URL spelling rather than the policy's intended endpoint identity:
Url::port()preserves whether a default port was explicitly written. Normalize a port equal to that URL's scheme default to the same representation as an omitted port before comparing host/port, while retaining the explicit HTTPS-to-HTTP downgrade rejection. Do not simply compareport_or_known_default()values, since an HTTP-to-HTTPS upgrade has different scheme defaults and this policy intentionally permits that upgrade. Add cases for explicit:80and:443on both sides of an allowed upgrade, as well as a non-default-port negative case.
may_follow compared host, port and scheme only, so a same-origin hop that normalized a trailing slash or a query was followed. The signature binds @path as the client sent it and the node rebuilds it from the URI it received, so that hop left the signature covering a target nobody asked for and the read 401d. Add the request-target clause and repoint the seven matrix rows that rode on a path change, so each keeps pinning the host or port property it exists for rather than going false on the path alone.
…fier The two same-origin follow tests drove exactly the path-changing hop the predicate now refuses, and their mocks only proved a target was reached, never that the signature verified there. Rewrite both into refusal tests and run the real gitlawb-core verification over the request the target actually received, so the recorded verdict is what the assertion speaks about. Each refusal is paired with a positive control that verifies, so an empty verdict slot is attributable to the refusal rather than to a dead harness.
The verifier already rebuilds @path from the URI it was sent, which is why the client-side redirect bug surfaced as a 401 rather than as a bypass. Nothing pinned that, so a change to the reconstruction could drop the query half or collapse it to a constant and only the clients would notice. Drive the production router fixture with a signature made over one path and a request on another, and again with a query mismatch, plus an identically signed control. No pre-fix RED is obtainable here by construction, so each case is proven load-bearing by injecting the defect it names.
…l search The floor reserved one complete legacy search per window, probes plus page tolls, but the provenance visibility walk debits the same bucket before the fallback runs and its cap is charged per phase. With a route limit set below the floor the provenance phase spent from the budget the search was promised, the fallback 429d short of its configured reach, and the retry re-paid the same walk charges, so a readable holder past that point stayed unreachable. Add the walk term, min(MAX_HISTORY_WALKS_PER_REQUEST, ipfs_max_repos_walked), which is textually the resolver's own walk_cap so the two move together. The ladder fixture pins repos-walked to 1 to keep the page toll, not the new term, as the thing binding it.
Code review found the doc comments carrying claims the code does not support and two coupling points documented from one side only. The floor and the resolver's walk_cap are not textual twins: the floor reads the constant, the resolver reads the AppState seam, and they agree only because every construction seeds one from the other. Say that, and give walk_cap the back-reference it lacked. The lifted node_verifies helper no longer named the middleware it mirrors, so an edit to require_signature would not find either copy. The request-target clause pins @path, not @method or content-digest: a 301, 302 or 303 still rewrites a signed POST to a bodyless GET while the signature headers ride along. Record that rather than implying the hop is safe. Add the query-removed and fragment-only matrix rows, and a mutant pinning that scan-phase walks are not charged to the work bucket, which nothing covered.
|
Head is now F1, the signed request-target. I confirmed the mechanism before fixing it: a mockito target matching only on the
On your point that the existing mocks only prove a target was reached: both clients now run the real verification over the request the target actually received and record a verdict, each paired with a control that verifies against a pinned DID. The node side is pinned separately, three cases through the production router (path mismatch, query mismatch, identical control), proven by mutating the One thing the fix does not cover, and I would rather name it than let the clause read as broader than it is. It pins F2, the work floor. The floor now carries I measured the ledger rather than deriving it. Instrumenting the fixture and draining the bucket at four floors: floor 5 gives 429 with 2 walks, floor 6 gives 200 with 3, floor 7 gives 200 with one token spare, and floor 100 still spends 6. So six debits, the 5 to 6 boundary pins the sixth as the holder's own probe, and floor 100 proves nothing else debits. The red is the work-path 429 specifically, asserted on the Only the provenance phase charges walks to this bucket; the legacy phase pays a probe instead. That is what makes one walk term correct rather than two, and it is now pinned: removing the guard so the scan phase also charges reddens the test. On the continuation point, correcting the floor restores the one-complete-search-per-window guarantee, which is the contract the floor exists to hold. It does not close the tokenless case for a caller whose first request in a window is truncated, since they have no prior token to resume from. I left that as its own change rather than folding it in here. F3, explicit default ports. Declined. Suites: gitlawb-core 92, gl 355, git-remote-gitlawb 52 plus 8, gitlawb-node 1064, all passing. fmt, clippy and |
…changes Reviewer R1-P1 (delayed-upload race) and R1-P2 (exhausted-budget interaction) for issue-218's reconciliation sweep: - policy-epoch fence (v28 repos.policy_epoch): every visibility-rule and quarantine mutation bumps the epoch; the sweep captures it at each pin dispatch boundary and the pin loops abort the moment it moves, so a narrow landing mid-batch wins over the pre-authorized snapshot (fail closed). - encrypted seal path fenced the same way per blob; sweep acquires the pin semaphore before both public batches and the encrypted seal so the sweep cannot stack unlimited blocking pool work. - encrypt_and_pin takes git_bin + batch_budget and runs each object read under spawn_blocking with a shared read deadline via the new read_object_bounded_spawn_blocking, so a hung git reaps within budget (recovered_pins budget test). - cursor reset: run_pass fetches REPOS_PER_PASS+1 (lookahead) so a full terminal page clears the cursor instead of rescanning it forever. - gaps_filled counts unique objects across both backends so it stays countable against the union gaps_found (R2-P3). - list_pins doc reconciled with the pinata_cid-under-cid fallback it actually emits (R2-P2). - migrations v18/v19 renumbered to v26/v27 to dodge open Gitlawb#173's 18-25 claim. New tests: pin_new_objects_stops_mid_batch_when_policy_moves, encrypt_and_pin_stops_sealing_when_reader_removed_mid_batch, encrypt_and_pin_returns_by_budget_with_a_hung_git, and sweep_clears_cursor_on_exact_page_boundary.
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Merge blockers
-
[Review gate] GitHub reports
mergeStateStatus: BLOCKEDwithreviewDecision: CHANGES_REQUESTED. Required CI is green on heada7e8e35030cae4e1126cda6c161bc516e3b8ffff, and the branch is mergeable with currentmain(50d3cbbe97f77b3cab9fab220add9f3a0d0dbc2f), but the PR cannot land until the open review items below are resolved. -
[Merge drift]
mainand this head diverged at96d8123on a parallel merge with#326. Currentmain(50d3cbb) carries3993fd1(distinct-signer threshold counting). This head still has the pre-#326 implementation. Please rebase onto50d3cbband take the#326side of anycert.rsconflict.
Overall guidance — why review keeps dripping, and how to close it
This PR has been open since July, carries 77 commits and ~31k added lines, and has seen many review rounds. The drip persists because of three structural forces, not because individual fixes are missing:
Integration surface. The title is #135 (tree gate), but the branch also integrated #174, pin provenance/repair, scan tokens, redirect policy, gl resume, and push coalescing. Each addition carries its own caller-visible contract and reopens the same 39-file diff. Freeze scope on this PR; defer follow-up work to linked issues so the next review is closure, not another chapter.
Continuation logic has multiple exit paths. The AEAD token system, page-fetch ceilings, and gl ipfs get ladder are solid for the paths they cover. Remaining holes come from taints recorded inside gate_and_serve or from pager.exhausted breaking before the mint arms — three sites kept in sync by comments, not one helper. A shared record_scan_truncation(row, reason, …) at every truncation site, plus tests that misalign page_rows and each ceiling (not page_rows == ceiling), is what stops the next ceiling from dripping through.
Merge hygiene after parallel merges. Semantic conflicts (e.g. #210 advisory key, #326 cert threshold) merge cleanly in git but regress in behavior. Rebase onto current main before each review request and diff unrelated files against main after rebase.
What "done" looks like: rebase onto 50d3cbb with cert.rs intact; fix or file a tracked issue for any remaining contract gap; add the misaligned ceiling test matrix; post a frozen-scope / deferred-work list in the PR body; no new features until that lands.
Findings
-
[P1] Restore distinct-signer counting in
RefUpdateCert::satisfies_threshold
crates/gitlawb-core/src/cert.rs:138What breaks:
satisfies_thresholdcounts signature entries, not distinct signers. One maintainer's valid signature duplicated twice satisfies a 2-of-3 threshold with a single real signer.Root cause: Rebase drift onto the
96d8123side of the#326fork.mainat50d3cbbhas theHashSetimplementation andsatisfies_threshold_rejects_duplicated_signature; this head does not (git diff 50d3cbb..HEADis a 64-line regression in this file).How to fix at the root: Rebase onto
50d3cbband keepmain's threshold implementation — do not hand-resolve the conflict:let distinct_signers: HashSet<&Did> = valid.iter().filter(|d| maintainers.contains(d)).collect(); Ok(distinct_signers.len() >= threshold)
Restore
satisfies_threshold_rejects_duplicated_signature. Copy-pasting one valid signature onto the cert must not satisfy threshold 2.
-
[P1] Mint a continuation when probe or visit budget exhausts on the final fetched page
crates/gitlawb-node/src/api/ipfs.rs:929
crates/gitlawb-node/src/api/ipfs.rs:1324
crates/gitlawb-node/src/api/ipfs.rs:1103What breaks: On the last DB page (
pager.exhausted == true), probe or visit ceiling taints insidegate_and_serve, but the loop breaks at ~930 before the continuation-mint arms (~960–988). The tail returnssearch_incompletewithcontinuation: null.gl ipfs gettreats no token as ladder-over and stops. A holder on that final page that was never probed is unreachable on the ladder.Concrete path: Final page has 3 repos.
ipfs_max_legacy_probes = 1. Repo 1 probed; repo 2 hits probe ceiling (~1324), taints, returnsSkip; repo 3 never probed. Loop hitspager.exhausted, breaks at 930 without minting. Retry reproduces the same dead end.Root cause:
pager.exhaustedfast-path skips the mint arms. README line 403 promises probe and visit ceilings mint a continuation ("Every per-request ceiling on this path (rows, probes, visits, retained rule bytes) mints one"). Existing ladder tests setpage_rows == ceiling, so this final-page branch is never exercised.How to fix at the root: Introduce one
record_scan_truncationhelper used at every taint site. For this case: beforeif pager.exhausted { break }, iftruncated_byis non-empty andscan_continuationis unset, mint from the first skipped row's(created_at_key, repo.id). Alternatively restructure so the mint arms at ~960 run even whenexhaustedis true (skip onlyfetch_next_page).Test to add:
ipfs_legacy_scan_page_rows = 4,ipfs_max_legacy_probes = 1, exactly 3 repos on the final page, holder in repo 3. Assert non-nullcontinuationon first503and200on?scan=resume.
…d-page The probe and visit ceilings taint inside gate_and_serve and returned Skip, so the row they refused and every row behind it were walked past without a verdict while the resume position was sealed from pager.cursor, the end of the fetched page. Two ways that stranded content: - On the final page, `pager.exhausted` breaks ahead of every mint arm, so the shed carried no continuation at all. A tokenless search_incomplete is the wrapped-scan answer, which tells `gl ipfs get` its ladder is over, so a holder on that page was unreachable on every retry. - Mid-page, the sealed cursor sat past the refused rows, so the resume skipped them. The shipped ladder tests all set page_rows == ceiling, which puts the break exactly on a page boundary and hides both. The ceiling now returns GateOutcome::CeilingStop rather than tainting on its way out, and the scan loop stops there and seals the row in front of the one that was refused. record_scan_truncation is the single site that records a truncation: it taints and seals together, and only ever moves the position forward, so a later oid candidate re-walking the same rows on a spent budget cannot hand back a token the caller already echoed. The wrapped-scan tail no longer clears a seal, since a ceiling can stop a resumed scan part way through the last page.
Carries #326 (distinct-signer certificate threshold) onto this head.
… docs Code review of the previous commit turned up three things worth fixing in place. record_scan_truncation centralized the taint and the seal but logged nothing, so centralizing actually made a truncation less visible than the scattered inline taints it replaced: only the visit ceiling logged, and it logs from inside the gate, before the caller decides whether a position gets sealed. Two identical log lines could therefore mean "the ladder continues" or "the caller is stranded". One debug line now carries the reason and whether a position was sealed. It logs only whether one exists, never its value, since the position names a withheld row's created_at and id. The doc comment claimed to be "the one site that records a scan truncation" while eight other taint sites bypass it. The distinction is real but it is not the one the comment drew: a ceiling stops the scan and owes the caller a position, while a transient skip refuses one row and the rows behind it are still walked. Says that now. The forward-only rule's stated justification was wrong. A later candidate's position is still ahead of the token the caller echoed, so letting it win would not move the ladder backwards; it would shrink each rung toward a single row. Also records that the comparison is Rust byte order while the pager's keyset runs under the database collation, which can disagree on a non-C collation, and why that costs a replay rather than a skipped row.
|
Head is now F2, the ceiling that sheds without a continuation. Confirmed, and the final page is only half of it. The probe and visit ceilings taint inside Your suggested position, the first skipped row's own key, would not have closed it.
Three tests, each named for the branch it binds: the misaligned mid-page ladder, the ceiling on an unresumed final page, and the ceiling part way through a resumed final page. Each was red on
The forward-only rule in That same run surfaced two residual gaps that are the same class as F2 but are not introduced by this round, so per your scope-freeze I'd rather file them than keep committing here. First, a CID that maps to more than one oid shares a single probe budget, pager, and seal across candidates, so a starved later candidate can be sealed past; I confirmed it strands a holder, and I confirmed the pre-fix code stranded the same row, with controls at a raised probe budget and at a single oid both serving 200. Second, Worth stating precisely because it looks like the same bug: a tokenless shed is still reachable when F1, the certificate threshold. Declining this one. The branch never touches Merge state. I merged On freezing scope: agreed, and nothing new goes in after this. The remaining follow-up work gets linked issues rather than commits here. |
…rder oids_for_cid ran a bare SELECT with no ORDER BY, so Postgres was free to return the candidates in physical heap order. get_by_cid walks those candidates under one shared probe budget, visit budget and pager, so whichever comes back first is the one that spends the request's budget: two nodes holding identical data, or one node before and after an unrelated write, could resolve the same CID differently and one could shed a 503 where the other serves. The instability is not hypothetical. An unpin and re-pin of a single object, which is an ordinary production sequence, moves that row to the end of the heap and rotates the list. The sibling pin_sources_for_oid already orders its union for exactly this reason, and the handler comment next to it leans on that determinism.
A CID can map to several git oids, and the ladder needs to name which one it is resuming. The sealed position gains the candidate's oid hex so a rung resumes that candidate rather than a position in a list: oids_for_cid is a sorted set, so an ordinal silently repoints at a different candidate when a pin that sorts earlier arrives between rungs, while an identity degrades safely to "not found, restart at the front". The field is length-prefixed and padded to 64, matching the framing the row fields already use, because production oids are 40 hex, not 64: repos are created with --object-format=sha1 and only the test fixtures are sha256. A fixed 64-byte field would fail every seal on a real deployment and shed a tokenless 503, which the client reads as the ladder being over. Both widths are exercised, and a zero-length candidate is rejected at decode so it cannot be confused with the front-of-table sentinel, which is empty row fields with a real candidate. VERSION goes to 3 and the plaintext to 527 bytes, so a token minted under the old layout opens to None and the caller restarts at the front. Nothing has minted one outside tests. The slot carrying the position is a struct rather than a widened tuple on purpose: a 3-tuple would have pulled the hex into the existing keep-the-maximum comparison, changing behavior this commit is meant to leave alone. Token length stays invariant across both oid widths, since length would otherwise be a side channel for the withheld row it names. That is asserted on a real seal in gitlawb-core, not on the gl fixtures: nothing in gl seals or opens a token, so its width constant cannot detect a wrong layout.
…t skipped A CID that maps to several oids shared one resume slot across every candidate, and the slot kept the maximum position. An earlier candidate could spend the probe budget walking past a repo that holds the object for a later one, seal a position beyond it, and the next rung would resume past a row that candidate never examined, wrap, and shed tokenless. The client reads an absent token as the ladder being over, so the object became permanently unretrievable, at stock config, deterministically on every retry. The token now names which candidate it is resuming, and the rules that keep that sound are narrower than they first look: Only one candidate per request may seal, and which one depends on where the REQUEST started. On a resumed request it is the resumed candidate alone, since the shared pager holds only the table suffix from the caller's cursor, so a later candidate walked a suffix and never saw the front. On a front-started request it is the first unfinished candidate, since there every candidate walks from the front and a later candidate's stop is honest coverage. Silencing later candidates unconditionally would remove the only thing that mints rung 1 when the first candidate wraps untruncated. A candidate is finished when its row loop walked every fetched row, or when it owed no scan at all. Both matter: a properly provenanced candidate never wraps, so without the second arm the ladder dies every rung. The wrap is witnessed per candidate at the row loop's own two exits, never by reading the shared pager flag at the tail, which any short page sets and which would let a candidate that truncated mid-page look finished and strand the rows it refused. Finishing a non-final candidate advances the seal to the next one at a front sentinel and taints, because the tail emits a continuation only when something tainted; sealing without tainting would suppress the taint and return a definitive 404 while discarding the token it had just minted. The keep-the-maximum comparison is gone. With one proposer per request the slot is written at most once, so an assertion states that directly instead. The pager stays shared per request. A per-candidate pager would restore the fan-out the paging exists to remove.
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Merge blockers
- [Review gate] GitHub still reports
mergeStateStatus: BLOCKEDwithreviewDecision: CHANGES_REQUESTEDon head1bdefb18c2bc1e6e3cd370ea61bc7586d09dc76c. Required CI is green and the branch is mergeable with currentmain(50d3cbbe97f77b3cab9fab220add9f3a0d0dbc2f). The PR cannot land until this review gate clears.
Findings
-
[P2] Arm the legacy scan when the visit ceiling truncates the provenance path before every source is tried
crates/gitlawb-node/src/api/ipfs.rs:831-833
crates/gitlawb-node/src/api/ipfs.rs:889-931
crates/gitlawb-node/src/api/ipfs.rs:1452-1459What breaks:
walk.visitsis shared across the provenance loop and the legacy scan. WhenGITLAWB_IPFS_MAX_REPO_VISITSis exhausted on the provenance path,gate_and_servereturnsCeilingStop("visit-ceiling"), and the provenance handler callsrecord_scan_truncation(..., None)andcontinues. Every subsequent provenance source immediately hits the same ceiling. After the loop,needs_scanis computed as:sources.is_empty() || at_cap || pin_sources_incompleteIf the object has a non-empty, below-cap, complete-looking provenance set,
needs_scanis false and the legacy fallback never runs — even though one or more provenance sources were never visited. The tail still seestruncated_bycontainingvisit-ceilingand returnssearch_incompletewithcontinuation: null.gl ipfs gettreats a tokenlesssearch_incompleteas ladder-over (ipfs_cmd.rs:354-357), so a holder in an unvisited provenance repo is permanently stranded on retry.Concrete path: Object pinned with provenance from repos A and B (
pin_sources_for_oidreturns both).ipfs_max_repo_visits = 1. Repo A is visited (visit counter → 1). Repo B hitsCeilingStop("visit-ceiling")immediately.needs_scanis false. Legacy scan skipped. Response:503 search_incompletewith nocontinuation.Default-config note: dormant at stock settings.
GITLAWB_IPFS_MAX_REPO_VISITSdefaults to 1024 while provenance is capped atMAX_PIN_SOURCES + 1(17), so you cannot exhaust the visit budget on provenance alone without lowering the knob. The existing visit-ceiling ladder tests (get_by_cid_visit_ceiling_ladders_to_a_holder_past_it,get_by_cid_visit_ceiling_stops_scan_with_503) exercise the legacy pager with NULL-provenance pins; none cover provenance +needs_scan.Root cause:
needs_scanencodes three signals for "the provenance set may be incomplete" (empty, at cap, incomplete marker) but not a fourth: the provenance loop was truncated by a per-request ceiling before every recorded source was visited. The provenance handler also passesNonetorecord_scan_truncationbecause there is no pager cursor on that path — but that is separate from whether the legacy fallback should still run.How to fix at the root: Do not treat a truncated provenance pass as a complete source set. One minimal seam:
let mut provenance_truncated = false; // provenance GateOutcome::CeilingStop => { provenance_truncated = true; ... } let needs_scan = sources.is_empty() || at_cap || incomplete || provenance_truncated;
Alternatively, fold
provenance_truncatedinto the same predicate that armspin_sources_incomplete, so any ceiling that stops the provenance loop beforeServedforces the bounded legacy scan rather than a tokenless 503.Test to add: Mirror
ipfs_cid_provenance_path_scoped_walk_gates_withheld_blobsetup but with two provenance sources viarecord_pin_source(or two repos pinning the same oid). Setipfs_max_repo_visits = 1. Place the readable copy only in the second source. Assert first response is503 search_incompletewith a non-nullcontinuationor that the legacy fallback runs and serves200without requiring a client ladder — either outcome is acceptable as long as the holder is not permanently stranded. Reverting theneeds_scanguard should redden the test.
-
[P3] Update the legacy-pin README to match
list_pinned_cidsfiltering
README.md:415
crates/gitlawb-node/src/db/mod.rs:3370-3391What breaks: The legacy-pin paragraph still says
GET /api/v1/ipfs/pinscan advertise an unrepaired legacy provider CID that 404s onGET /ipfs/{cid}.list_pinned_cidsfilters withis_raw_cidv1before returning rows, andlist_pinned_cids_omits_unrepaired_legacy_rowasserts unrepaired provider-CID rows are omitted. Operators following the README will look for CIDs in the pins API that the implementation deliberately withholds.Root cause: The repair sweep and listing filter landed in code, but the operator-facing paragraph was not updated when the advertise-then-404 window moved from the listing endpoint to the resolver-only path.
How to fix at the root: Rewrite
README.md:415to describe the current contract:- Unrepaired provider-CID rows are not listed by
GET /api/v1/ipfs/pins. GET /ipfs/{cid}refuses them via the F2 integrity check.- Repair happens via the periodic sweep (
run_sweep_rearmedinmain.rs), opportunistic re-pin, or push delta — not via the pins listing. - Rows whose object bytes are gone stay withheld.
Align the stale comment in
db/mod.rsonrepair_legacy_provider_cid("even thoughlist_pinned_cidsstill advertises it") with the same wording. - Unrepaired provider-CID rows are not listed by
-
[P3] Add a handler test that #135 tree denial runs on the provenance pin path
crates/gitlawb-node/src/test_support.rs:11777-11836
crates/gitlawb-node/src/test_support.rs:5591-5657What breaks: The primary #135 tree-deny assertions (
ipfs_cid_gate_withholds_blob_from_unauthorizedand friends) pin viapin_cid_for, which records NULL provenance and exercises the legacy scan.ipfs_cid_provenance_path_scoped_walk_gates_withheld_blobproves the blob walk gate on the provenance path (pin_cid_for_repo+/secret/**rule). There is no parallel test that pins a withheld subtree tree viapin_cid_for_repoand asserts anon 404 with the raw-byte leak witness used in the legacy suite.Root cause: Test coverage followed the legacy pin helper first; the provenance-path walk gate was proven for blobs but not extended to trees even though
gate_and_servegates both throughallowed_tree_set_for_caller_boundedatipfs.rs:1726-1734.How to fix at the root: Copy
ipfs_cid_provenance_path_scoped_walk_gates_withheld_bloband swap the oid:- Use
fx.secret_tree_oid(or the tree CID fromseed_cid_repos) instead offx.secret_oid. - Pin with
pin_cid_for_repo(&bare, &fx.secret_tree_oid, &state.db, &repo.id). - Assert anon →
404(status-only deny check is fine; optional: add the listed-reader200+ raw-byte witness from the legacy suite at11795-11816to prove the gate is withholding structure, not just returning any 404).
Production code already shares one
gate_and_serve; this is a coverage gap, not a confirmed leak. The test exists to prevent a provenance-only regression in the tree arm. - Use
On a resumed request whose visit budget was already spent by the provenance phase, the scan's top-of-loop visit arm sealed pager.cursor, which at that moment is the position the caller just sent. The node returned the caller's own token, verbatim, rung after rung. Three rungs were observed returning an identical position. A token looks like progress, so the client keeps going: gl retries to its resume cap, and every one of those requests re-runs the full provenance phase, up to seventeen repo acquires and cat-file subprocesses, advancing nothing before it errors. That is roughly nine anonymous requests worth of work for none, and it is worse than shedding nothing, because a caller who is told the ladder is over stops immediately. A seal now has to be strictly ahead of where the request itself started: a different candidate is ahead by construction, since only the gated advance can name one, and the same candidate needs a row past the start row. A request that started at the front is before everything, so its seals pass untouched. When the proposer settled at least one row this rung, the existing ceiling arm already seals that row, and it is strictly ahead because a resumed scan only walks rows past its cursor. Only a rung that settled nothing sheds without a token, and that is honest: the spender is the provenance phase, which runs the same way every rung, so no retry can do better. The filter sits at the single mint site, where a future call site cannot bypass it, and it logs the drop as a boolean. record_scan_truncation has already logged that a position was sealed by then, and a 503 carrying no token next to that line is the confusion that log exists to prevent.
|
Head is now The one that matters, reachable at stock config. A CID can map to several oids, and every candidate shared one resume slot that kept the maximum position. An earlier candidate spends the probe budget walking past a repo that holds the object for a later one, seals a position beyond it, and the next rung resumes past a row that candidate never examined, wraps, and sheds tokenless. An absent token is the "ladder is over" signal, so the object is unretrievable, permanently, on every retry. Observed at untouched defaults (probes 256, page rows 128, row ceiling 2048, visits 1024) with 259 repos and a two-oid CID: rung 1 a 503 with a token, rung 2 a 503 with none, holder never served. Controls: raise the probe budget, or use a single-oid CID, and both serve 200 at rung 1. The token now names which candidate it is resuming. Three rules make that sound, and each one is narrower than it first looks because the obvious version of it breaks something: Only one candidate per request may seal, and which one depends on where the request started. Resumed: the resumed candidate alone, because the shared pager holds only the suffix from the caller's cursor, so a later candidate walked a suffix and never saw the front. Front-started: the first unfinished candidate, because there every candidate walks from the front and a later candidate's stop is honest coverage. Silencing later candidates unconditionally removes the only thing that mints rung 1 when the first candidate wraps untruncated. A candidate is finished when its row loop walked every fetched row, or when it owed no scan at all. The second arm is load-bearing: a properly provenanced candidate never wraps, so a rule keyed only on wrapping kills the ladder every rung. And the wrap is witnessed per candidate at the row loop's own two exits, never by reading the shared pager flag at the tail, which any short page sets, and which would let a candidate that truncated mid-page look finished and strand the rows it refused. Both exits matter: instrumenting only the first ends the ladder three rungs early. Finishing a non-final candidate advances the seal and taints. The tail only emits a continuation when something tainted, so sealing without tainting would suppress the taint and return a definitive 404 while discarding the token it had just minted. The no-progress echo. On a resumed request whose visit budget was already spent by the provenance phase, the scan sealed Third defect, found while fixing the first. Two decisions I would rather you object to now than discover in review. The advance uses a new taint reason, Verification. Every guard across the four commits was proven by re-injecting the defect it names and confirming the specific test reddens for the named reason: twelve of twelve. One came back red for the wrong reason and I resolved it by running the stacked case and observing the black-box assertion fire, not by widening the expectation until it passed. One honest gap, flagged as direction rather than coverage: the guard that stops a non-proposer candidate from sealing has no response-level witness. Mutating it reddens exactly one test of seventy-three, through an internal single-write assertion. The guard is unconditional production code and the assertion does fire under Still deferred, and genuinely pre-existing. The visit budget is charged by both the provenance phase and the scan, so a |
jatmn
left a comment
There was a problem hiding this comment.
Thanks for the contribution. The #135 withheld-subtree tree gate looks correct; I do not see a merge-blocking code defect in this PR.
Recommended follow-up
These are doc-alignment items worth a small follow-up PR rather than holding this merge:
-
README “Legacy-pin window” paragraph contradicts current behavior
README.md(new production note);crates/gitlawb-node/src/db/mod.rs(list_pinned_cids~3375–3399);crates/gitlawb-node/src/main.rs(spawn_legacy_cid_sweep)
The note added here saysGET /api/v1/ipfs/pinscan still advertise unrepaired legacy provider CIDs that/ipfswill not serve, and describes “a deferred one-shot startup sweep” as what retires the window.list_pinned_cidsnow filters non-raw keys out of the listing, andspawn_legacy_cid_sweepruns a periodic re-arming task that never returns. Align the README with the implemented listing filter and periodic repair sweep so operators are not chasing advertise-then-404 behavior the pins API no longer exhibits. -
Operator docs still describe pushes as pinning the main query pool after this branch splits write locks onto a separate pool
crates/gitlawb-node/src/config.rs(validate,db_max_connectionsfield doc);docs/RUN-A-NODE.md(new troubleshooting entry);crates/gitlawb-node/src/main.rs(build_lock_pool/RepoStore::new)
Onmain,acquire_writeheld an advisory lock on the main query pool, soGITLAWB_DB_MAX_CONNECTIONS >= max_concurrent_git_pushes + 8was accurate. This PR routes write locks throughlock_poolinstead, butvalidate()and the new RUN-A-NODE troubleshooting text still say each concurrent push pins a main-pool connection for its whole receive-pack. Themax_concurrent_git_pushesfield doc already notes the lock pool; extend that story throughvalidate()’s error text, thedb_max_connectionsfield doc, and RUN-A-NODE so operators size total Postgres demand (db_max_connectionsplus the lock pool sized fromGITLAWB_MAX_CONCURRENT_GIT_PUSHES) without implying that raising the query pool is what isolates push concurrency.
|
Correcting two things I said in my last comment before this merges, because both are wrong and one of them would mislead whoever reads this thread next. I said the visit-ceiling gap "predates the branch". It does not. I also said it would be filed as a tracked issue. It is fixed instead, for the reason above: an issue against The fix splits the visit budget per phase, which is the argument the walk ceiling already settled a round earlier. It is not going into this PR. It lands as a follow-up immediately after this merges. Two reasons. It needs One consequence worth recording since it changes something you reviewed. Splitting the budgets makes the strictly-ahead filter in |
|
@kevincodex1 ready to go. jatmn approved, 18/18 green on 76f6300, threads all resolved. |
What
GET /ipfs/{cid}servedtreeobjects of a withheld subtree to callers denied that subtree. A git tree body is<mode> <name>\0<raw-oid>per entry, so fetching the tree CID of a withheld directory returned every child filename and child oid in cleartext, recursively. Blob content was already protected; this closes the structure leak so the CID surface matches whatget_treeenforces on the REST path.Approach
Tree objects are now gated against a caller-aware allowed-tree-set, the mirror of the existing
allowed_blob_set_for_caller:object_pathswalk (git ls-tree -rztper reachable commit) thatblob_pathsand the newtree_pathsboth filter, so the per-path classification is derived once rather than twice.blob_pathsoutput is byte-identical, so its callers are unaffected. The two gates are not identical, and deliberately so:blob_pathsrunsassert_all_refs_are_commitsand fail-closes a repo's whole walk when any ref peels to a non-commit, whiletree_pathsgoes throughreachable_commit_oids, which tolerates such a ref and simply excludes what is reachable only through it. On clean fixtures they agree, and a unit test pins that; on a repo carrying pushable non-commit refs they diverge, which is the availability tradeoff that keeps one annotated tag of a tree from 404ing every CID in the repo.tree_pathsis thekind == "tree"slice plus each reachable commit's root tree (resolved in onegit log --format=%Tpass, sincels-treenever emits a commit's own root).get_by_cidgates ablobagainst the allowed-blob-set and atreeagainst the allowed-tree-set (lazy per repo, off the async runtime, fail-closed on any walk error). Commits and tags stay served: they expose only root-level metadata the caller already cleared the/gate for.The withheld directory's own tree (path
/secret) is denied, not just its descendants, so parity withget_treeholds.Reachability and scope
get_by_cidresolves a CID to its git oid through thepinned_cidstable, so the route is live on any node that has pins, whatever its object format. An earlier revision of this branch treated the CID digest as an oid directly, which only matched in sha256 repos and made the endpoint close to dormant against--object-format=sha1production repos; that is no longer how it resolves, so treat this gate as covering a reachable surface rather than a pre-emptive one. The replication/pin path exports the same withheld-tree structure to IPFS independently of the object format and is tracked separately in #172.Tests
Deny paths driven through the real handler:
get_tree).blob_pathsoutput is asserted byte-identical after the walk refactor.Closes #135.
Summary by CodeRabbit
New Features
/ipfs/{cid}to resolve via pinned CID → git-object OID mapping with visibility-aware tree handling.Bug Fixes
Tests
Chores