Skip to content

fix(node): gate GET /ipfs/{cid} tree objects so a withheld subtree's structure can't leak (#135)#173

Open
beardthelion wants to merge 43 commits into
mainfrom
fix/issue-135-ipfs-cid-tree-gate
Open

fix(node): gate GET /ipfs/{cid} tree objects so a withheld subtree's structure can't leak (#135)#173
beardthelion wants to merge 43 commits into
mainfrom
fix/issue-135-ipfs-cid-tree-gate

Conversation

@beardthelion

@beardthelion beardthelion commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

What

GET /ipfs/{cid} served tree objects 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 what get_tree enforces 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:

  • A shared object_paths walk (git ls-tree -rzt per reachable commit) that blob_paths and the new tree_paths both filter, so the two gates cannot drift. blob_paths output is byte-identical, so its callers are unaffected.
  • tree_paths is the kind == "tree" slice plus each reachable commit's root tree (resolved in one git log --format=%T pass, since ls-tree never emits a commit's own root).
  • get_by_cid gates a blob against the allowed-blob-set and a tree against 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 with get_tree holds.

Reachability and scope

get_by_cid resolves a CID by treating its sha256 digest as a git oid, which only matches in sha256 repos; production repos currently init --object-format=sha1, so the endpoint is largely dormant until a sha256 migration. This lands the gate ahead of that. The replication/pin path exports the same withheld-tree structure to IPFS independently of the object format and is the more urgent sibling, tracked separately in #172.

Tests

Deny paths driven through the real handler:

  • Withheld subtree tree CID returns 404 for anon and non-readers; listed reader and owner still read it (200).
  • Root and ancestor trees, commits, and tags stay served; a dangling tree fails closed for anon and owner; a tree with no path-scoped rule is served.
  • The withheld directory's own path denies (glob parity with get_tree).
  • Content-dedup: a tree reachable at both an allowed and a withheld path is served (allowed wins).
  • blob_paths output is asserted byte-identical after the walk refactor.

Closes #135.

Summary by CodeRabbit

  • New Features

    • Added improved per-path IPFS visibility controls that gate directory trees and file blobs.
    • Enhanced /ipfs/{cid} to resolve via pinned CID → git-object OID mapping with visibility-aware tree handling.
    • Introduced a per-client rate limiter for IPFS full-history walk requests.
  • Bug Fixes

    • Strengthened fail-closed behavior so unverifiable access and dangling objects are withheld (404).
    • Hardened object enumeration/parsing to prevent visibility leaks from malformed/unreachable data.
  • Tests

    • Expanded CID gating coverage with raw-byte tree verification, updated fixtures, and added denied-directory and dangling-tree fail-closed tests.
  • Chores

    • Added a database index and CID→OID lookup helper to speed up resolution.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Path-scoped IPFS visibility now applies to blob and tree objects using caller-specific reachable allow-sets. CID resolution uses pinned_cids, traversal fails closed, full-history walks are rate-limited, and tests cover withheld, allowed, root, shared, and dangling objects.

Changes

Path-scoped object visibility

Layer / File(s) Summary
Pinned CID resolution
crates/gitlawb-node/src/db/mod.rs, crates/gitlawb-node/src/api/ipfs.rs
CID pins are indexed and resolved to Git OIDs through pinned_cids before repository scanning.
Reachability and allow-set computation
crates/gitlawb-node/src/git/visibility_pack.rs, crates/gitlawb-node/src/visibility.rs
Shared fail-closed traversal enumerates reachable commits, blob paths, tree paths, and root trees for caller-specific allow sets, including subtree own-path rules.
IPFS blob and tree gating
crates/gitlawb-node/src/api/ipfs.rs
The handler memoizes separate blob and tree allow sets and skips repositories when computation fails or panics.
Rate limiting and regression coverage
crates/gitlawb-node/src/main.rs, crates/gitlawb-node/src/state.rs, crates/gitlawb-node/src/test_support.rs, crates/gitlawb-node/src/git/visibility_pack.rs
IPFS full-history walks use per-source limits, while tests cover pinned CIDs, withheld and dangling trees, raw tree contents, metadata objects, and rate-limit behavior.

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
Loading

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

  • Gitlawb/node#128 — Established per-caller path-scoped gating in the IPFS handler.
  • Gitlawb/node#133 — Introduced reachable caller-aware allow-set gating extended here to trees.
  • Gitlawb/node#90 — Directly relates to the pinned_cids data flow used for CID resolution.

Suggested labels: sev:high, kind:security, subsystem:api

Suggested reviewers: jatmn

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main change: gating GET /ipfs/{cid} tree objects to prevent subtree structure leaks.
Description check ✅ Passed It covers the problem, approach, scope, tests, and issue closure, so the template's key content is present.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-135-ipfs-cid-tree-gate

Comment @coderabbitai help to get the list of available commands.

@beardthelion beardthelion added crate:node gitlawb-node — the serving node and REST API kind:bug Defect fix — wrong or unsafe behavior subsystem:storage Blob/object store, Arweave, IPFS, archives subsystem:visibility Path-scoped visibility and content withholding labels Jul 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
crates/gitlawb-node/src/api/ipfs.rs (1)

143-209: 🚀 Performance & Scalability | 🔵 Trivial

The 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 (one git ls-tree -rzt per 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 win

Avoid ARG_MAX in root_tree_pairs.

Passing every reachable commit to git log on argv scales 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

📥 Commits

Reviewing files that changed from the base of the PR and between 2109d08 and 89d4928.

📒 Files selected for processing (4)
  • crates/gitlawb-node/src/api/ipfs.rs
  • crates/gitlawb-node/src/git/visibility_pack.rs
  • crates/gitlawb-node/src/test_support.rs
  • crates/gitlawb-node/src/visibility.rs

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 request cid_for_oid(...), whose multihash is a Git object ID. The real pin path instead stores CID(sha256(raw object content)), and gl ipfs get sends 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_cid therefore 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 log arguments
    crates/gitlawb-node/src/git/visibility_pack.rs:395
    root_tree_pairs adds every reachable commit to the process argv. On a long history this exceeds ARG_MAX (about 32k SHA-256 OIDs on a 2 MiB limit), so spawning git log fails; 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 through git log --stdin or by batching/root-resolving them during the existing per-commit traversal.

@beardthelion
beardthelion force-pushed the fix/issue-135-ipfs-cid-tree-gate branch from 89d4928 to 7dec45c Compare July 10, 2026 15:42
beardthelion pushed a commit that referenced this pull request Jul 10, 2026
#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.
beardthelion pushed a commit that referenced this pull request Jul 10, 2026
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.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Both addressed as of 52b81fd.

P1 (CID → object mapping). You're right that the lookup and the tests diverged from the pin path. get_by_cid was treating the CID's sha2-256 digest as the git oid, but the pin path stores CID(sha256(raw content)), which never equals the oid (git frames the object as "<type> <len>\0" + content before hashing), so a real pinned CID 404'd before the tree gate ever ran. get_by_cid now resolves the incoming CID to its oid through the pinned_cids table (oid_for_cid, backed by an indexed column added as a versioned migration) and gates 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 (read the object's raw bytes, Cid::from_git_object_bytes, record the pin) rather than encoding the oid, so they exercise the gate on a real production CID. They fail against the old digest-as-oid handler and pass after the fix.

P2 (git log argv). root_tree_pairs now feeds the commit oids to git log --no-walk=unsorted --format=%T --stdin on stdin instead of argv, so a long history can no longer overflow ARG_MAX and fail-close an authorized reader of a reachable/root tree. The oids are written from a separate thread while the main thread drains stdout, so a large input and output can't deadlock on the pipe buffers; a 2500-commit test covers it.

Rebased onto main.

@beardthelion
beardthelion requested a review from jatmn July 10, 2026 16:14
t added 18 commits July 10, 2026 12:10
PR3 of the #62 served-git hardening stack (timeout #165 and teardown
wiring #150 are merged). A bounded semaphore caps how many upload-pack /
receive-pack / info-refs operations run at once; past the cap a request
is shed with a clean 503 + Retry-After before spawning another git
subprocess, instead of exhausting the PID/thread table. A permit is
acquired at the top of each of the three handlers and held for the whole
op, releasing on return.

The cap is a portable backstop: the compose pids_limit is absent on Fly,
whose 500-connection cap is a different axis. Size --max-concurrent-git-ops
(GITLAWB_MAX_CONCURRENT_GIT_OPS, default 128) below the process budget.
Range 1..=1_048_576 so 0 (shed everything) and an oversized value that
would panic tokio's Semaphore at boot are clean CLI errors.

Known gap, tracked separately: info/refs and the withheld-blob
(upload_pack_excluding) path are not duration-bounded and do not reap
their git child on client disconnect, so a hung git on those two paths
holds its slot until it exits and live git can briefly exceed the cap.
The main pack path (run_git_service) tears its group down on drop.

Tests: Overloaded maps to 503 + Retry-After; the config knob defaults
and rejects out-of-range; git_permit sheds at capacity and releases; and
each of the three endpoints sheds with 503 when the semaphore is
exhausted (load-bearing: drop the permit line and the endpoint test goes
red).
Add max_concurrent_git_pushes (default 32) and max_concurrent_reads_per_caller (default 16), both clap range(1..=1_048_576) so an oversized value is a clean CLI error, not a Semaphore::new boot panic. The per-caller knob documents that per-source-IP keying is only as granular as GITLAWB_TRUSTED_PROXY. Wiring lands in the following commits; these are the config surface for the #174 concurrency-fairness fix.

Resolves jatmn P1a/P1b groundwork on #174.
git-receive-pack now draws from a separate git_write_semaphore (max_concurrent_git_pushes) instead of the shared pool, so a flood of anonymous reads can no longer shed an authenticated push at admission (jatmn P1a). The shared field is renamed git_read_semaphore and continues to gate upload-pack and both info/refs advertisements. The write permit stays above acquire_write so it precedes the Tigris fresh-acquire (INV-10).

Handler-layer tests: write-pool shed (503), and a cross-boundary proof that an exhausted read pool does NOT shed a push; both mutation-checked (routing receive-pack back to the read pool flips each RED). 497 tests pass.

Part of #174.
Adds PerCallerConcurrency, a bounded-keyed in-flight limiter (distinct from the request-rate RateLimiter) so no single caller monopolizes the served-git read pool. Each caller (per-DID when signed via optional_signature, else per-source-IP via client_key) may hold at most max_concurrent_reads_per_caller concurrent reads; over that it sheds 503. The key map is self-bounding (a key is dropped when its in-flight count hits zero) with a reject-before-insert max_keys backstop so a key farm can't grow it (INV-15). Applied in git_upload_pack and both info/refs advertisements, acquired after the visibility gate so a denied request never consumes a slot (KTD7).

Primitive unit-tested (cap + self-bounding + reject-before-insert) and mutation-checked. Handler-layer SC2: same-caller sheds while a different caller passes, proven on BOTH git_info_refs and git_upload_pack with independent mutation probes; plus a None-key bypass test. Per-source-IP keying is trust-config dependent, documented on the config knob. 502 tests pass.

Part of #174.
info_refs ran a bare Command::output() with no timeout and no process-group teardown, so a hung git pinned its concurrency slot indefinitely and a client disconnect orphaned the child (jatmn P1b). Extract the timeout + process_group(0) + KillGroupOnDrop core from run_git_service into a shared drive_git_child, and route info_refs through it with an injectable git_bin. A hung advertisement now aborts with GitServiceTimeout (mapped to 504); disconnect reaps the group.

run_git_service's teardown tests all pass through the shared core (proving the group teardown info_refs inherits), the real-git filter tests cover the advertisement happy path, and a new watchdog-bounded test proves a hung advertisement times out. 503 tests pass.

Part of #174.
The filtered-pack path ran the whole rev-list + pack-objects build inside a spawn_blocking, so an outer tokio timeout could not cancel the blocking thread and a client disconnect orphaned the git child while the permit freed (jatmn P1b, the second gap path). Split it: rev-list enumeration stays blocking off the runtime (rev_list_keep), but the streaming pack-objects stage now runs under the shared drive_git_child on the async side, so it is duration-bounded (GitServiceTimeout -> 504) and its process group is reaped on disconnect. build_filtered_pack becomes async and takes a git_bin seam + timeout; upload_pack_excluding threads the git_service_timeout through.

A watchdog-bounded test proves a hung pack-objects times out (rev-list fast, pack-objects hangs). The refactor's happy path is covered by the existing filtered-pack correctness and real-git partial-clone/fetch tests, all still green; disconnect/group-teardown is the shared drive_git_child code proven by the run_git_service tests. 504 tests pass.

Part of #174.
#62)

The max_concurrent_git_ops and git_service_timeout_secs doc-comments (and .env.example) described the info/refs and withheld-blob paths as unbounded follow-up gaps. Both are now closed (#174): the comments reflect the read/write pool split, the per-caller sub-cap, and that every capped path is duration-bounded with process-group teardown. Verified the pattern-doc pre-ship checklist: every git_permit / write-permit / per-caller site holds only a timeout+teardown git path, and all three size knobs are range(1..=1_048_576).

Closes the #174 work. No behavior change.
Review of the served-git concurrency cap found no P0/P1; these are the
verified P2 follow-ups.

config: the max_concurrent_git_ops doc overclaimed that "every capped path
is duration-bounded." The rev-list object enumeration in the withheld-blob
path still runs in an uncancellable spawn_blocking, so a stuck rev-list can
hold its slot until git exits. Scope the guarantee to the streaming stages
and name the residual. Also tighten the fairness claim: the receive-pack
advertisement shares the read pool (a shed advertisement is a cheap retryable
GET); only the push POST is on the isolated write pool.

api/repos: add info_refs_per_caller_cap_keys_on_did_not_ip, the missing
handler proof that a signed caller is keyed by its DID, not its source IP.
Filling the DID slot sheds a request from a free IP; collapsing read_caller_key
to its IP arm turns the assertion green-not-503 (mutation-verified RED).

api/repos: extract acquire_read_caller_permit so both read handlers share one
shed path instead of a duplicated match block.

rate_limit: recover from a poisoned PerCallerConcurrency mutex instead of
panicking. The critical section is pure counter arithmetic and cannot poison
the lock, but a panic there would brick the limiter for every caller.

505 tests pass; clippy -D warnings and fmt clean.

Part of #174.
The read-pool knob was referenced by the push and per-caller entries'
comments but had no example line of its own, so operators couldn't
discover it from the template. Add it with the config default (128).
…ID (#174)

read_caller_key returned the authenticated DID when a caller signed, dropping the
source-IP key. Public read routes accept any valid did:key via optional_signature
with no admission step, so one host could mint N disposable DIDs and hold
max_concurrent_reads_per_caller slots under each, multiplying its budget N-fold and
filling the global read pool, while the same host unsigned was capped on its IP.

Key the read sub-cap on the resolved source IP for every caller, signed or not,
mirroring the push path's IpRateLimiter which already throttles on source IP for
this exact DID-farm reason. Drops the now-unused caller_did parameter at both call
sites (git_info_refs, git_upload_pack).

Inverts info_refs_per_caller_cap_keys_on_did_not_ip into
info_refs_per_caller_cap_keys_on_ip_not_did: fill one source IP's slot, then two
requests signed under different DIDs from that same IP both shed 503 (farm
defeated), while a signed request from a different IP keeps its own budget. RED on
the DID-keyed tree, GREEN after.
)

git_info_refs acquired git_read_semaphore for BOTH services, so the push handshake
(GET /info/refs?service=git-receive-pack) competed in the global read pool. An
anonymous clone flood could exhaust that pool and shed a legitimate push with 503
during its required advertisement phase, before it ever reached git_write_semaphore
on the POST. The write pool exists precisely so anonymous reads cannot shed an
authenticated push, but only the POST drew from it.

Select the pool by service: the receive-pack advertisement (phase one of a push)
now draws from git_write_semaphore, like the git-receive-pack POST, so a saturated
read pool cannot starve it. The per-IP push_rate_limiter that already brakes the
advertisement stays as the anti-flood control, and the advertisement stays
reader-visible with no new auth requirement. Because the receive-pack branch is now
a write-path op, it no longer consumes a read per-caller slot.

Handler-layer proofs: with the read pool at zero the receive-pack advertisement
survives while the upload-pack advertisement sheds; with the write pool at zero the
receive-pack advertisement sheds while upload-pack is unaffected; and a receive-pack
advertisement from an IP whose read per-caller budget is full still gets through
(mutation-checked, RED when the skip is neutralized).
…#174)

The withheld-blob classification walk (blob_paths) fanned out blocking git children
with no deadline and no process-group teardown: git for-each-ref, git cat-file, git
rev-list, a git ls-tree per commit, and an uncounted git rev-parse (via
store::head_commit). A hung or pathologically slow child pinned the caller's
served-git permit for the whole hang, and on client disconnect the spawn_blocking
task and its git children ran on, orphaned. blob_paths is the shared core of five
callers: the upload-pack serve path (holds a read permit) AND, inside
git_receive_pack, the post-push replication and encrypt-then-pin walks (hold the
write permit U2 reserves for pushes). So the same unbounded walk could pin either
pool, and leaving the write-side twin unbounded would have made U2's reservation a
claim that does not match behavior.

Bound every git child at the blob_paths spawn seam on the blocking side: each child
runs in its own process group with a watchdog thread that SIGTERMs (then SIGKILLs)
the group on one shared deadline spanning the whole walk, and retains admission
until the group is reaped. This is the blocking-side counterpart of
smart_http::drive_git_child (spawn_blocking cannot be cancelled by an async
timeout). blob_paths stays sync, so all five callers keep their signatures and the
32 classification tests are unchanged; because every caller funnels through
blob_paths, one seam bounds both the serve and replication paths. The previously
unbounded store::head_commit child becomes a bounded git rev-parse inside the walk.
A walk that hits its deadline carries GitServiceTimeout, which the serve handler now
maps to 504 rather than a generic 500.

Proof: a fake git that hangs on rev-list makes blob_paths return GitServiceTimeout
within the watchdog budget (not block on the child) and the recorded process-group
leader is reaped, not orphaned; neutralizing the watchdog kill makes it hang past
the budget (RED). The 32 real-git classification tests stay green through the
refactor, including detached-HEAD, non-standard-ref, and deleted-in-history cases.
GITLAWB_GIT_SERVICE_TIMEOUT_SECS bounds the info/refs advertisement too:
smart_http::info_refs drives it through drive_git_child under this timeout, with a
passing test proving the 504. The old note claimed it does not. It also claimed the
withheld-blob path is unbounded; after the blob_paths seam bound (this PR) the walk
is bounded and reaped, by a fixed internal deadline rather than this env var, so the
line now states that precisely instead of overclaiming this setting covers it.
Code review found run_bounded_git's watchdog could return a spurious 504 and
signal a recycled process group. The watchdog runs off a wall clock on its own
thread; done_tx.send() only fires after child.wait() reaps the leader, so a walk
that finished within microseconds of the deadline took the watchdog's Timeout
branch, discarded a fully-captured successful result, and returned GitServiceTimeout
(a 504 for a walk that actually completed). Worse, the Timeout branch SIGTERMed
-pgid unconditionally after the leader was reaped, so a recycled pgid could be
signalled, the exact hazard smart_http guards via disarm-after-wait.

Set a reaped AtomicBool the instant the main thread reaps the child; the watchdog
checks it before every kill and stands down if the leader is already reaped. Gate
the timeout verdict on !status.success(), so a child that exited on its own is never
reported as a timeout even if the watchdog fired late. Add the survived-SIGKILL warn
smart_http's reap already emits, for operator visibility on a wedged (D-state) git.

The hung-walk test stays green (a killed child exits by signal, not success, so it
still surfaces GitServiceTimeout and reaps the group) and the 32 real-git
classification tests stay green (a fast walk is never spuriously killed).
…nnot starve the write pool (#174)

U2 moved the receive-pack info/refs advertisement onto git_write_semaphore to keep
an anonymous read flood from starving the push handshake. But the advertisement is
anon-reachable on public repos and holds its write permit across the slow
acquire_fresh Tigris download, and the only per-source brake on it was the push
RATE limiter, not a concurrency cap. So a multi-source flood of receive-pack
advertisements could hold the write pool's slots across those downloads and shed
authenticated pushes (both the advertisement and the owner-gated git-receive-pack
POST draw from the same pool). U2 thus introduced the first anonymous consumer of
the write pool the state doc promised anon could never reach; the plan's residual
note (no worse than the POST) was wrong, because the POST is owner-gated and the
advertisement is not.

Add git_push_advert_per_caller, a per-source concurrency sub-cap on the receive-pack
advertisement keyed on the resolved source IP (the same PerCallerConcurrency
mechanism U1 uses for reads), sized to an eighth of the write pool so a single
source holds at most that share and saturating the pool takes many distinct source
IPs, each also braked by the per-IP push rate limiter. The upload-pack advertisement
keeps its read-pool per-caller cap; the owner-gated POST is unchanged. Correct the
state doc for git_write_semaphore accordingly.

Handler-layer proof: a source at its receive-pack advertisement cap sheds 503 (RED
before the acquisition, 500-not-503), while a different source and the upload-pack
advertisement are unaffected. Full suite 510 green.
…d timeout (#174)

Close the reasoned-not-run gaps from the code review by making the walk's git
binary and timeout injectable, then driving the missing branches with a real
handler and a fake git instead of reasoning about them.

- Add state.git_bin and *_bounded variants of the walk entry points taking
  (git_bin, timeout); the served handlers (upload-pack serve, receive-pack
  replication and full-scan and encrypt-pin, and the ipfs gate) now pass the
  operator-configured GITLAWB_GIT_SERVICE_TIMEOUT_SECS, so the whole walk is bounded
  by the same budget as the other served-git ops rather than a fixed constant. The
  git_bin-less wrappers stay for the real-git classification tests.

Newly vetted by execution (not reasoning):
- receive-pack replication path is bounded: replication_withheld_set with an injected
  hung git returns within the budget and fails closed, so it cannot pin the write
  permit git_receive_pack holds across it.
- a hung withheld-blob walk on the upload-pack POST returns 504 (real handler, real
  repo on disk, injected hung git), proving the GitServiceTimeout -> git_service_app_error
  wiring end to end.
- the watchdog status-gate: a child that exits successfully is not reported as a
  timeout even when the watchdog fired (mutation-checked: drop the guard -> RED).
- SIGKILL escalation: a SIGTERM-ignoring child is still reaped via SIGKILL and the
  group is gone; a truly uninterruptible D-state child (unreapable by any signal) is
  the documented residual, matching the async teardown.
- the advertisement per-source cap sizing never derives 0.

Full gitlawb-node suite 515 green.
…a fixed const (#174)

Follow-up to threading the configured timeout into the walk: the walk now honors
GITLAWB_GIT_SERVICE_TIMEOUT_SECS on both the serve and replication paths, so the
README no longer says a fixed internal deadline.
t added 7 commits July 14, 2026 20:28
…ion pool (#174)

After a successful path-scoped push the handler released its write permit and
then ran a DETACHED tokio task whose spawn_blocking(withheld_blob_recipients_bounded)
performed another full-history git walk under no admission. N fast completed
pushes spawned N concurrent full-history walks past GITLAWB_MAX_CONCURRENT_GIT_PUSHES
(which bounds only the in-handler phase), and the per-child bounding from 71389e6
caps each walk's duration but not their count (P1-e).

Add git_encrypt_semaphore, a pool of its own (sized from max_concurrent_git_pushes,
no new knob — Q1) so a long background walk never holds a foreground write slot and
a handler holding a write permit can't self-deadlock. Route the walk through a new
withheld_recipients_gated helper that acquires the pool before the walk and DEFERS
(blocks) when full rather than shedding — dropping the walk would lose the
withheld-blob recovery copy, so durability stays fail-closed.

Regression: encrypt_walk_defers_when_pool_exhausted drives the gated helper with the
pool exhausted and asserts the walk blocks and does not run its rev-list, then runs
once a permit frees. RED before (the walk runs regardless of the pool), GREEN after.
The detached push task calls this exact helper.
…surface (#174)

A source-scan tripwire that fails when a new site reintroduces the concurrency-cap
class PR #174 fixed, or when one of the five gates (U1-U5) is removed. INV-22: a
permit held per op recovers only if every path is duration-bounded and reaps the
group before releasing admission, and every detached git task carries admission.

Lives in tests/ (a separate crate) on purpose — a guard scanning the file it lives
in would match its own identifier literals and pass vacuously; scanning src/ from
here keeps every check load-bearing. Checks: run_bounded_git confirms child exit via
child_terminated_without_reaping (U1); KillGroupOnDrop launches the reaper via
Handle::try_current (U2); git_receive_pack acquires state.git_write_per_caller (U4);
the encryption walk runs through withheld_recipients_gated over git_encrypt_semaphore
(U5); and the bounded recipients walk is invoked exactly once — only inside the gated
helper — so a new spawn_blocking bypass (count > 1) fails (P1-e non-bypass).

Proven load-bearing: a synthetic second call site turns the count tripwire RED, and
each named gate maps to a fix whose own regression already goes RED on revert.
…d on the plain spawn paths (#174)

On the plain (non-path-scoped) info_refs / upload-pack / receive-pack paths the
global + per-source admission permits were handler-locals that dropped the
instant the handler future dropped on a client disconnect, while KillGroupOnDrop
reaps the git process group in a DETACHED task (SIGTERM -> grace -> SIGKILL ->
reap). So a disconnect-spammer admitted replacements while prior groups were
still alive, defeating the process cap and the per-source cap during teardown.
be0cdd6 already fixed this for the path-scoped upload-pack walk; this closes the
residual plain paths (P1-a).

Introduce AdmissionGuard, a move-only holder of the permits, threaded through
info_refs/upload_pack/receive_pack/run_git_service into drive_git_child's
KillGroupOnDrop. On disconnect the guard moves into the detached reaper and drops
only after the group is ESRCH-confirmed reaped; on success/timeout disarm()
returns it for the earliest provably-free drop. The rev-list/pack-objects
visibility-walk callers hold no admission and pass None.

Thread git_bin through upload_pack/receive_pack (they hardcoded "git") so the
plain POST path is drivable by an injected fake git, matching the existing
injectable seam on info_refs/run_git_service/upload_pack_excluding.

Handler-layer regression upload_pack_plain_permit_held_through_group_reap_after_disconnect:
isolates the global read pool (size 1, per-source + rate limiter permissive), a
SIGTERM-ignoring descendant keeps the group alive across the reap window, and
asserts available_permits()==0 held on disconnect + a cross-source replacement
sheds 503, then frees after ESRCH. Reverting the guard-into-reaper move (release
immediately) turns it RED at the held-through-reap assertion. Plus a None-key arm.
…ase-on-expiry deadline (#174)

The served-git admission permit is acquired, then RepoStore::acquire*/advisory-lock
runs before the bounded git runner starts — with no local deadline. acquire/acquire_fresh
await Tigris HEAD/GET, and acquire_write's advisory-lock loop awaits a per-iteration
pg_try_advisory_lock that can block indefinitely on a hung Postgres pool. Since
GITLAWB_GIT_SERVICE_TIMEOUT_SECS only starts once git spawns, a stalled backend pins
the permit and drains the pool until every later request 503s (P1-2).

Wrap each git-path acquire (git_info_refs read/advert, git_upload_pack, git_receive_pack)
and the /ipfs per-repo acquire loop in tokio::time::timeout(git_acquire_timeout_secs, ..).
On expiry the git paths return AppError::Overloaded (503 + Retry-After); the permit is a
handler-local at that point (moved into the AdmissionGuard only after acquire) so the early
return frees the slot. The /ipfs per-repo acquire keeps its fail-closed `continue` on
timeout (never serves an un-acquired repo). No repo_store.rs signature change: the outer
timeout cancels a mid-sleep/mid-fetch_one future, so no in-loop deadline is needed.

New knob GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS (default 30, range 1..), modeled on
git_service_timeout_secs, kept separate because acquisition and git execution are distinct
cost centers.

Handler-layer regression receive_pack_acquire_deadline_sheds_and_releases_permit: holds the
same pg advisory lock acquire_write derives on a second connection so the real loop must
retry, drives git-receive-pack with the deadline at 2s and write pool size 1, and asserts
503 at ~2s + the permit recovers + a follow-up admits once the lock frees. Removing the
timeout wrapper turns it RED (blocks ~31s to the test ceiling on the ~59s advisory loop).
…er-source cap + route rate limit (#174)

GET /ipfs/{cid} (get_by_cid, publicly reachable via optional_signature) ran
allowed_blob_set_for_caller_bounded in spawn_blocking inside a per-repo loop with
NO global/per-source admission and NO route rate limit — the surface had zero
concurrency or rate control. A permissionless caller could fan out concurrent
full-history git walks (each up to the walk timeout) exhausting blocking-pool
threads and PIDs outside every served-git pool (P1-3).

Acquire a global git_ipfs_walk permit (try_acquire_owned, shed 503) plus a
per-source sub-cap (with_default_max_keys, reject-before-insert, keyed via
client_key/TrustedProxy so XFF can't spoof it) ONCE after CID validation, held as
handler locals across the whole request — including every spawn_blocking walk, so
the slot reflects real blocking-thread occupancy (a tokio timeout cannot cancel
spawn_blocking). None key -> global pool only. Cap repos walked per request so one
request can't serialize N full-history walks. Attach rate_limit_by_ip +
IpRateLimiter to the /ipfs route (mirrors the git/create/peer routers; the
extension is required or the middleware is a silent no-op). Visibility/authz
semantics unchanged.

New knobs: GITLAWB_MAX_CONCURRENT_IPFS_WALKS (32), GITLAWB_IPFS_WALK_PER_SOURCE
(4), GITLAWB_IPFS_MAX_REPOS_WALKED (64), GITLAWB_IPFS_RATE_LIMIT (600/hr, 0
disables) — all range-validated and enforced on-path.

Handler-layer regressions (all mutation-verified RED->GREEN): shed-at-capacity
503 (delete acquire -> falls through), per-source cap sheds same source / admits
another, None-key arm sheds on the global pool, map self-bounds reject-before-insert,
the walk permit is held while the spawn_blocking walk runs (available_permits()==0;
dropping it before the loop -> RED), repos-walked cap (remove break -> 2 walks),
and the route IP rate limit actually fires 429 (drop the Extension -> RED no-op).
…cing without dropping recovery copies (#174)

After a successful path-scoped push the handler spawns a DETACHED task that runs
pin_new_objects then withheld_recipients_gated, which acquire_owned().await's on
git_encrypt_semaphore — it DEFERS (blocks) when the pool is full. The semaphore
caps active walks but nothing capped how many detached tasks were spawned and
PARKED on that await: N rapid path-scoped pushes spawned N tasks, each holding
cloned object lists/rules/paths/keys — an unbounded parked-waiter set (P2-2).

2a54c15 deliberately chose defer-not-shed because dropping the walk loses the
withheld-blob recovery copy and there is no reconciliation sweep to rebuild it.
So bound the OUTSTANDING-TASK set by per-repo coalescing rather than shedding:
EncryptInflight (a bounded Arc<Mutex<HashSet<repo_id>>>) + try_begin — before the
spawn, if a task for the repo is already in-flight skip the duplicate (the pending
walk covers the newer objects); otherwise spawn and move an RAII
EncryptInflightGuard into the task that removes the key on drop (completion, error,
or panic-unwind). This bounds the set to <=1 pending task per repo and never drops
work; withheld_recipients_gated's defer is unchanged.

The second detached post-push spawn (Pinata replication, also runs pin_new_objects)
is scoped out with rationale: it parks on no semaphore (no unbounded waiter set)
and does per-push per-ref work (branch->CID, gossip, subscription, Arweave,
peer-notify) keyed to this push's ref_updates — coalescing it would DROP a later
push's announcements, a correctness regression.

Regressions (mutation-verified RED->GREEN): the outstanding set is bounded to 1
per repo under saturation (never-coalesce -> 32 tasks RED); a coalesced repo's key
is released when its task ends so it is reprocessed, never permanently skipped
(guard-drop no-op -> repo locked out, recovery copy lost forever, RED — the exact
durability regression the fix prevents); distinct repos each admit; cold-set first
push admits; plus an inv22_gates structural tripwire that fails if the handler gate
is removed.
…ent the new admission knobs (#174)

P2-1: git_info_refs treated every service other than git-receive-pack as a read op
and did the DB/visibility/Tigris work under a read permit, validating the service
string only downstream in smart_http. So an unauthenticated `?service=anything` to
a public repo consumed a read slot + storage work before rejection. Validate the
service is exactly git-upload-pack or git-receive-pack immediately after parsing,
returning 400 before the pre-DB shed and the permit. Handler-layer regression:
with the read pool exhausted, `?service=git-explode` returns 400 (validated first),
not 503 — removing the validation makes it 503 (RED).

U5/INV-24 docs: document the new knobs from U2/U3 in .env.example and the README
env-var table — GITLAWB_GIT_ACQUIRE_TIMEOUT_SECS, GITLAWB_MAX_CONCURRENT_IPFS_WALKS,
GITLAWB_IPFS_WALK_PER_SOURCE, GITLAWB_IPFS_MAX_REPOS_WALKED, GITLAWB_IPFS_RATE_LIMIT.
Each is already enforced on the path it names (U2/U3 tests), so the docs match the
implemented routing.
beardthelion pushed a commit that referenced this pull request Jul 16, 2026
…-alias gaps (#173)

- gl ipfs get percent-encodes the CID as one path segment so a base64 CID
  containing '/' matches the single-segment route and signs the right target
- legacy NULL-provenance pins now backfill their source repo on re-pin
  (COALESCE on conflict plus an explicit backfill on the already-pinned path),
  so pre-v12 CIDs resolve to their local source instead of 503ing past the cap
- the legacy-scan probe budget is charged to the source IP from the first
  probe, closing the per-request free-budget reset that let repeated
  NULL-provenance requests amplify acquire+cat-file work un-limited
- Pinata pins store the locally computed raw CID as the resolver key and keep
  the provider dag-pb CID in pinata_cid, so a wrapped provider CID can never
  alias raw git bytes that do not hash to it
- the annotated-tag reachability walk runs one git cat-file --batch per round
  (bounded by chain depth, not tag count) and fails closed past an object
  bound, so one CID request cannot fan out unbounded subprocesses
@beardthelion

Copy link
Copy Markdown
Collaborator Author

All five addressed on a5fe189, each fix RED-verified by reverting the exact production line and watching its test fail before restoring.

Encode the CID as one path segment (P1).

a valid Base64 CID can contain /. Interpolating it directly makes the client request /ipfs/<prefix>/<suffix>, which does not match the single-segment Axum route; authenticated calls also sign that wrong target.

gl ipfs get now builds the path through a pure encode_cid_segment helper that percent-encodes the CID (via urlencoding, already a gl dependency) so /, +, and = from the base64 alphabet become one path segment. get_authed signs and sends the same encoded string, and the node verifies the RFC 9421 @path over the raw encoded target it received (auth/mod.rs) while Axum's Path extractor decodes back to the original CID for the handler, so client and server stay byte-consistent. Regression uses a base64 CID containing /; reverting to the raw format! leaves the / unescaped and fails it.

Backfill legacy pin provenance (P1).

old pins can never acquire provenance despite the comment saying re-pins backfill it [...] known historical CIDs can keep returning 503 search_incomplete

Migration-time backfill is infeasible (v12 has no record of each legacy OID's source repo), so this is a runtime backfill on the re-pin path: the already-pinned branch of pin_new_objects now reads provenance and, when NULL, calls backfill_pin_provenance (UPDATE ... WHERE repo_id IS NULL), never re-pinning the bytes. record_pinned_cid also COALESCEs repo_id on conflict as a secondary guard. RED: removing the backfill call leaves provenance None and fails the e2e test. In the interest of not overclaiming, the record_pinned_cid COALESCE on its own is not independently load-bearing (a DO NOTHING mutation there does not fail a test); the already-pinned-branch helper is the real fix.

Charge legacy probes from the first probe (P1).

LEGACY_SCAN_FREE_PROBES is applied anew to every request, so a caller can repeatedly request a known NULL-provenance CID and force eight repo_store.acquire operations [...] without ever touching the IP limiter.

The per-request free-probe budget is gone. Every legacy-scan probe now consults the per-IP ipfs_rate_limiter before acquire+cat-file, from the first probe, so repeated NULL-provenance requests from one source accumulate and get shed; the per-request cap stays as the second bound, and the no-resolvable-key skip is unchanged. Since a legacy candidate that also walks would otherwise be double-charged against the shared limiter, the walk brake now debits only on the provenance path. RED: a new cross-request test drives the same NULL-provenance CID twice from one IP and asserts the second request is 429; restoring the free carve-out flips it back to an un-braked 404. ipfs_walk_rate_limited_per_source stays green (its cheap public copy is a provenance pin).

Keep the Pinata provider CID out of the resolver index (P1).

a DAG-wrapped (or erroneous) provider CID can become an alias for bytes that do not hash to the requested CID.

The Pinata path now computes the raw content CID locally with Cid::from_git_object_bytes (the same primitive the local IPFS pin already uses) and stores THAT as the resolver key pinned_cids.cid; the provider's dag-pb CID stays in pinata_cid for gateway retrieval, and cid is never overwritten on conflict. RED: a new test pins a Pinata object whose provider CID differs from the raw CID and asserts the provider CID does not resolve while the raw CID does; binding cid = pinata_cid again makes the raw CID resolve to nothing and the provider CID alias the bytes.

Bound the annotated-tag fan-out (P1).

it spawns a separate git cat-file tag process for every annotated tag [...] lets one enumerable pinned-CID request create an unbounded number of subprocesses

The per-tag loop is replaced by walk_tag_chain, which runs one git cat-file --batch per depth round with the tag OIDs fed on stdin (not argv, so no ARG_MAX overflow): the process count is bounded by tag-chain depth, not tag count. A MAX_TAG_OBJECTS bound (8192) fails closed with an error past the limit, matching this function's fail-closed-on-any-error contract rather than silently under-withholding. RED: disabling the bound makes the over-limit repo return Ok instead of the expected error; behavior preservation is covered by the nested tag-of-a-tag reachability test.

Full run: 538 gitlawb-node tests plus 9 gl ipfs tests green, fmt and clippy clean.

@beardthelion
beardthelion requested a review from jatmn July 16, 2026 02:48

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Retain every source repository for a pinned object
    crates/gitlawb-node/src/db/mod.rs:2250
    pinned_cids retains one, first-pinner repo_id, and ipfs_pin::pin_new_objects skips the same OID on every later push. The new provenance branch then checks only that one repository and deliberately never falls back. Shared blobs, commits, and trees are common across forks and mirrors: if a path-scoped source pins a tree first and denies an anonymous caller, then a later public repository with the identical tree is never consulted and its advertised CID returns 404. The same happens after the first source is deleted or quarantined. Store/try all source repository associations (or use an equivalently bounded fallback) and cover the private-or-denied-first, public-second case through the production pin path.

  • [P1] Migrate existing Pinata resolver keys away from provider CIDs
    crates/gitlawb-node/src/db/mod.rs:889
    This fixes future Pinata writes to keep the raw-content CID in pinned_cids.cid, but v12 only adds repo_id; it does not repair rows written by the previous code, where cid was Pinata's dag-pb/UnixFS CID. get_by_cid now resolves that old provider CID and serves raw Git bytes, even though those bytes do not hash to the requested CID. The current Pinata skip path and conflict clause also leave those old keys intact. Backfill/rewrite the legacy rows where possible, or reject them until repaired, and add an upgrade-path regression.

  • [P1] Apply the legacy-scan rate limit before loading every repository
    crates/gitlawb-node/src/api/ipfs.rs:243
    A NULL-provenance CID eagerly fetches every repository, every repository's visibility rules, and every quarantined row before gate_and_serve reaches the IP limiter. Consequently, even an already-throttled source can repeatedly trigger O(repositories) database work simply by requesting a known legacy CID; the new limiter only prevents the later acquire/cat-file probes. Put a source-scoped admission check ahead of scan_ctx construction (or otherwise bound/page that preload), then test repeated throttled legacy requests on a large repository set.

  • [P1] Drain git cat-file --batch while writing the annotated-tag batch
    crates/gitlawb-node/src/git/visibility_pack.rs:339
    walk_tag_chain writes the entire round to the child's stdin before it starts draining stdout. With enough annotated tags or sufficiently large tag messages, cat-file can block on a full stdout pipe while the parent blocks writing the remaining stdin, leaving the full-history CID walk hung indefinitely. A path-scoped request can trigger this on demand and strand blocking workers despite the tag-count bound. Feed stdin concurrently with draining/reaping stdout, as the nearby root-tree code does, and add a large-output multi-tag regression.

  • [P1] Bound concurrent full-history visibility walks
    crates/gitlawb-node/src/api/ipfs.rs:477
    The hourly IP counter allows 600 requests by default, but it does not limit how many are in flight. A single source can send hundreds of simultaneous requests for a known path-scoped CID; all pass check() and each starts an independent spawn_blocking rev-list/ls-tree walk. The per-request cap only limits fan-out within one request, so this can exhaust the blocking pool, CPU, and process slots. Add global and per-source concurrent-walk admission held for the lifetime of the walk, with a simultaneous-request regression.

  • [P1] Do not run unbounded object reads on the async request worker
    crates/gitlawb-node/src/api/ipfs.rs:543
    The new CID lookup makes normal pinned objects retrievable, but the serve path calls read_object_content directly on the Axum worker. That helper executes blocking git cat-file and buffers its entire stdout with no object-size, streaming, concurrency, or route-level limit. Public CIDs are enumerable from the pins endpoint, so concurrent requests for large public blobs can block runtime workers and exhaust memory. Move the read off the async worker and use a bounded/streamed response with appropriate admission control.

beardthelion pushed a commit that referenced this pull request Jul 16, 2026
… source (#173 F1)

pinned_cids keeps only the first pinner's repo_id, so a shared object first
pinned from a private or quarantined repo 404s by CID even when a later public
repo also pinned it. Add a bounded pin_repo_sources table (new migration v13,
cap 16 per object via an atomic count-guarded insert) recorded on both the
fresh-pin and the already-pinned skip branches of the local-IPFS and Pinata pin
paths. The resolver now tries every recorded source through the same gate; the
first that authorizes serves, with no scan fan-out and no ordering-dependent
404. Legacy NULL-provenance pins still fall back to the bounded repo scan.
beardthelion pushed a commit that referenced this pull request Jul 16, 2026
…ng (#173 F2)

Legacy pinned_cids rows written by every release before this branch keyed on
the provider CID (Pinata/Kubo dag-pb), which does not hash the raw git object,
so get_by_cid could serve raw git bytes that do not match the requested content
address. Recompute the CID over the object bytes at the serve point and withhold
on mismatch, before any byte egresses. Compared against the canonical CID (not
the raw client string) so a non-canonical multibase request for a correctly-keyed
row is not false-rejected. Source-agnostic: covers both Pinata and Kubo legacy
rows. Updates the collision test to a genuine content collision, since an
arbitrary mismatched CID is now correctly withheld.
beardthelion pushed a commit that referenced this pull request Jul 16, 2026
…preload (#173 F3)

A NULL-provenance CID's legacy scan built its O(repos) preload (all repos,
their visibility rules, quarantined ids) before the per-probe brake inside
gate_and_serve could bite, so an already-throttled source could still force
O(repos) DB work on every replay. Add a non-consuming is_throttled peek on the
rate limiter and shed the request at the top of the legacy arm, before scan_ctx
is built. The consuming per-probe charge is left unchanged, so both fanout
bounds hold: the quota-1 first request still serves (no double-charge) and the
across-request replay is still braked per probe (no under-charge). A thread-local
preload-query counter guards it both ways (0 while throttled, 1 unthrottled).
beardthelion pushed a commit that referenced this pull request Jul 16, 2026
…#173 F4)

walk_tag_chain wrote an entire cat-file --batch round to the child's stdin
before draining stdout, so a repo with enough annotated tags (~2030+, tag count
not message size) filled both pipes at once and the walk hung indefinitely,
stranding a blocking-pool thread — reachable on demand by any anonymous request
for a commit/tag CID under a path-scoped rule. Feed stdin on a writer thread and
drain stdout via wait_with_output concurrently, joining the writer after the
drain, mirroring assert_all_refs_are_commits and root_tree_pairs. A ~3000-tag
regression completes near-instantly with the fix and deadlocks (times out) on
the old order.
beardthelion pushed a commit that referenced this pull request Jul 16, 2026
The serve path read the object with a blocking git cat-file directly on the
Axum worker and buffered the whole stdout with no size bound, so a large public
blob (enumerable from the pins index) could block a runtime thread or exhaust
memory. Precheck the object size (cat-file -s), then run size + read + CID
verify inside spawn_blocking, withholding anything over a configurable cap
(ipfs_max_served_object_bytes, default 32 MiB). A content-addressed serve cannot
verify a streamed body, so there is no streaming arm: buffer-verify-then-serve
up to the cap, reject larger with zero body bytes. The F2 integrity check moves
into the blocking closure so no unverified bytes are ever assembled. A cost
counter guards the size cap both ways.
beardthelion pushed a commit that referenced this pull request Jul 16, 2026
…fra errors (#173)

Two code-review follow-ups on the F1/F6 work:

- pin_sources_for_oid now LIMITs to MAX_PIN_SOURCES, making the resolver's
  per-source work a true O(MAX_PIN_SOURCES) ceiling (INV-10) regardless of an
  insert-side overshoot or the uncounted first-pinner union row. The
  record_pin_source doc no longer overclaims a hard ceiling: under Postgres READ
  COMMITTED concurrent inserts of distinct repos for one object can overshoot the
  count guard by a small bounded amount, which the read-side LIMIT now absorbs.
- The bounded serve read distinguishes a git spawn/IO failure (ReadErr, logged
  and skipped) from a genuine not-found (Gone), restoring the object-read warn
  log the spawn_blocking refactor dropped, so an infra failure is not silently
  rendered as a 404.
beardthelion pushed a commit that referenced this pull request Jul 16, 2026
…ors as 503 (#173)

Two findings from an independent adversarial pass (Grok 4.5):

- The MAX_PIN_SOURCES LIMIT added in the prior review fix sat on the whole
  first-pinner-plus-sources UNION with a lexicographic ORDER BY, so for a legacy
  pin (source in pinned_cids.repo_id but not yet in pin_repo_sources) an attacker
  could push the same object from MAX_PIN_SOURCES repos whose grindable ids sort
  before the public source and evict it from the window, turning a public CID
  that served 200 into a 404. Always include the first-pinner (a single row by
  PK) and apply the LIMIT only to the additional pin_repo_sources rows, so the
  original source can never be evicted. Bound stays O(MAX_PIN_SOURCES + 1). A new
  test drives a legacy public first-pinner plus MAX_PIN_SOURCES lower-sorting
  attacker sources and asserts the public object still serves (RED on the old
  whole-union LIMIT, GREEN after).
- The F6 ReadErr split logged an infra read failure but still returned an opaque
  404, indistinguishable from a genuine not-found. Mark the search truncated on a
  git spawn/IO failure so a wholly-unserved request tails to a retryable 503,
  making the infra-vs-not-found distinction client-visible.
@beardthelion

Copy link
Copy Markdown
Collaborator Author

Addressed on ce02f70, each fix verified by execution (revert the production line, the guard goes red; restore, green). Full gitlawb-node suite is 546 green; workspace clippy -D warnings and fmt clean.

F1, retain every source repository. A new pin_repo_sources table (migration v13) records every pin-path source, on the already-pinned skip branch as well as the fresh-pin branch (db/mod.rs, ipfs_pin.rs, pinata.rs). get_by_cid reads the union of pinned_cids.repo_id and pin_repo_sources and tries each through the same gate, so a shared object first pinned from a private or quarantined repo still serves from a later public source. The per-object cap keeps the first-pinner unconditionally and bounds only the additional sources, so filling the cap cannot evict the original source. Tests: ipfs_cid_multi_source_serves_from_later_public_pinner (private-first, then a public push through pin_new_objects; 404 before, 200 after), ipfs_cid_first_pinner_never_evicted_by_lower_sorting_sources, ipfs_cid_pin_sources_capped_at_max, pin_repo_sources_upgrade_path. The three existing provenance tests stay green.

F2, legacy provider-CID rows. get_by_cid recomputes the CID over the bytes it is about to serve and compares it against the canonical requested CID, withholding on mismatch (api/ipfs.rs). This rejects the mis-keyed rows until they are repaired, source-agnostically: it covers both the Pinata dag-pb rows and the local-IPFS/Kubo dag-pb roots for large objects, with no DDL. ipfs_cid_legacy_provider_cid_row_not_served seeds an old-shape row by raw INSERT (the helpers now store the raw CID, so a helper seed would be vacuous) and asserts the provider CID does not serve.

F3, rate limit before the preload. A non-consuming is_throttled peek (rate_limit.rs) runs at the top of the legacy arm, before scan_ctx is built, so an already-throttled source returns without the O(repos) preload. The per-probe charge is unchanged, so both the free-budget and across-request fanout bounds still hold. ipfs_cid_f3_throttled_source_skips_preload asserts zero preload queries while throttled, and goes red if the peek is removed.

F4, drain cat-file while writing. walk_tag_chain now feeds stdin on a writer thread and drains stdout via wait_with_output, matching root_tree_pairs and assert_all_refs_are_commits in the same file (git/visibility_pack.rs). One correction to the finding: the trigger is tag count, not message size. A single large tag message does not deadlock under the old order; enough tag refs to fill both pipes at once does. walk_tag_chain_large_batch_does_not_deadlock times out on the old order and completes on the new.

F6, bounded off-worker read. The read moves into spawn_blocking behind a cat-file -s size precheck, buffers and verifies up to ipfs_max_served_object_bytes (default 32 MiB), and rejects anything larger with no body bytes (api/ipfs.rs, git/store.rs). A content-addressed serve cannot verify a streamed body, so there is no streaming path. A git spawn or IO failure is distinguished from a genuine not-found and surfaces as a retryable 503 rather than an opaque 404. ipfs_cid_f6_oversized_object_withheld withholds the oversized object, with the size precheck as the load-bearing guard.

F5, bound concurrent walks. This is the ipfs-walk concurrency cap already in #174 (git_ipfs_walk_semaphore plus a per-caller sub-cap). I am resolving it by rebasing this branch onto #174 and reusing that cap rather than adding a second one, so it is not in this push; the other five findings are independent of it.

@beardthelion
beardthelion requested a review from jatmn July 16, 2026 22:44
t added 6 commits July 17, 2026 10:15
…concurrency base

Rebased onto #174 (fix/served-git-concurrency-cap). #173's incremental history
cannot replay commit-by-commit because #174 rewrote the same handler files in
parallel; this single commit carries the fully-integrated tree (identical to the
verified merge result), with the follow-up fixes as separate commits on top.

Brings in #173: GET /ipfs/{cid} CID->oid resolution via pinned_cids, per-caller
path-scoped blob/tree/commit-tag gating (#135/#173 F1-F6), and the pin-source
provenance table. Adapts #173's tree/commit-tag walks to #174's run_bounded_git
so the held /ipfs walk-concurrency permit is duration-safe (F5).
P1-A: gate_and_serve held the /ipfs walk permit across a bare repo_store.acquire;
wrap it in git_acquire_timeout_secs (mirroring #174's own handler) so a cold/hung
Tigris acquire cannot pin the global walk slot. On expiry skip the repo and mark
the search truncated (retryable 503, not a false 404).

P1-B: the local-IPFS pin path recorded Kubo's provider Hash as pinned_cids.cid;
for objects above the block size that is a dag-pb root that does not hash the raw
content, so GET /ipfs/{cid} listed then 404'd them under the F2 integrity check.
Record the locally-computed raw-content CID, mirroring the pinata twin.
…kfill pinata provenance

P2-E: the object-type probe and the F6 size+read ran bare git cat-file inside
spawn_blocking while the /ipfs walk permit was held, so a wedged cat-file could
pin the global walk slot for the request's life. Bound both under
git_service_timeout_secs; on timeout free the slot (truncated -> retryable 503)
and skip the repo.

P2-D: the pinata already-pinned branch now backfills NULL first-pinner
provenance in lockstep with the ipfs_pin skip branch (consistency; the object
was already resolvable via the pin_repo_sources union).
A provenanced object has a bounded source set (first-pinner + up to
MAX_PIN_SOURCES additional). With the walk ceiling at 16 == MAX_PIN_SOURCES, an
authorizing public source sorting after 16 path-scoped denials was never reached:
the ceiling truncated the search and returned a false 503 for a readable object.
Raise the ceiling to MAX_PIN_SOURCES + 1 so the whole bounded provenance set is
always tried; the legacy scan stays bounded by MAX_LEGACY_PROBES_PER_REQUEST.
…chIncomplete

- ipfs_pin::pin_new_objects returns the provider Hash (not the raw resolver key),
  matching the pinata twin's contract; the DB cid stays the raw content CID. The
  return is logging-only here, so this is drift-avoidance, not a functional fix.
- AppError::SearchIncomplete now carries Retry-After: 1 like Overloaded, so both
  retryable 503s from the /ipfs handler advertise the retry hint consistently.
…n fallback

An attacker who pins a public object from MAX_PIN_SOURCES repos before the
legitimate public source registers fills pin_repo_sources (record_pin_source is
first-N-wins and drops later sources silently); the buried public source is then
never reached because a non-empty provenance set suppressed the legacy scan, so
anon GET /ipfs/{cid} 404s forever for a public object (re-breaks F1).

U1: db::pin_sources_at_cap reports whether pin_repo_sources is at MAX_PIN_SOURCES
for an oid (the only observable signal that a servable source may have been
dropped, since the write cap never overshoots).

U2: get_by_cid falls back to the bounded legacy scan on a provenance miss when the
set is empty OR at_cap. The scan gates every repo through the real per-caller gate,
so it finds the buried public copy. A complete (non-full) set still fast-404s, so
ordinary denials never fan out to O(repos) (INV-10/F3); the fallback honors the
is_throttled peek.

Regression ipfs_cid_buried_public_source_still_serves_via_scan_fallback: 404 before,
200 after; pin_sources_at_cap_flips_at_max covers the boundary.
@beardthelion
beardthelion force-pushed the fix/issue-135-ipfs-cid-tree-gate branch from ce02f70 to d26cf6d Compare July 17, 2026 15:25
@beardthelion

beardthelion commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto #174 (fix/served-git-concurrency-cap) to resolve the last open finding, F5 (bound concurrent full-history walks). The /ipfs/{cid} walk now takes #174's walk-concurrency admission (global pool + per-source sub-cap) once at handler entry and holds it across every spawn_blocking walk in the request, and #173's tree/commit-tag walks are converted to run_bounded_git, so a hung git child can no longer pin that permit past the deadline. F1-F4 and F6 carry through unchanged.

Because the branch history couldn't replay commit-by-commit onto #174 (the same handler files were rewritten in parallel), this is a clean linear reconstruction: one integration commit carrying the fully-integrated tree, plus the follow-up fixes as separate commits. The diff against #174 is what a straight replay would have produced.

Integrating the two paths surfaced a few more issues, fixed here:

  • The gate held the walk permit across a bare repo_store.acquire. Wrapped it in git_acquire_timeout_secs, matching feat(node,git): cap concurrent served git ops with a 503 load-shed (#62) #174's own handler, so a cold or hung Tigris acquire can't pin the global walk slot.
  • The local-IPFS pin path recorded Kubo's provider Hash as the resolver key; for objects above the block size that dag-pb root doesn't hash the raw content, so those CIDs listed and then 404'd under the F2 integrity check. It now records the raw-content CID, matching the pinata twin.
  • The object-type probe and the bounded object read ran bare git cat-file under the held permit; both are bounded now, so a wedged cat-file frees the slot instead of pinning it.
  • MAX_HISTORY_WALKS_PER_REQUEST is tied to MAX_PIN_SOURCES + 1 so the walk ceiling can't truncate a request before its whole bounded provenance source set has been tried.

One security fix in the F1 pin-source area, worth a close look: record_pin_source is first-N-wins, so an attacker who pins a public object from MAX_PIN_SOURCES repos before the legitimate public source registers buries that source (it's dropped, and a non-empty provenance set suppressed the scan, so it's never reached and anon retrieval 404s permanently). Fixed by falling back to the bounded legacy scan on a provenance miss when the source set is at the cap; the scan gates every repo through the real per-caller gate, so it finds the buried public copy. ipfs_cid_buried_public_source_still_serves_via_scan_fallback is 404 before the fix and 200 after, mutation-verified load-bearing.

A few design calls I've settled, with the reasoning inline so they're on the record:

  • Walk and probe infra errors fail closed to 404 (readability unproven), while infra timeouts and the post-authorization read error return a retryable 503. The walk-error 404 is the tested fail-closed behavior, and only a timeout is unambiguously transient, so the split is intentional.
  • An oversize object returns an opaque 404 rather than 413, to avoid revealing that a withheld object exists and its size.
  • The griefing fallback keys on "source set at the cap," which over-triggers for an object that legitimately has exactly MAX_PIN_SOURCES sources and a denied caller; that case pays one bounded, throttled scan. The write-side alternatives (public-preferring eviction, unbounded write) each traded worse.
  • Legacy pre-provenance rows whose stored CID is a provider/dag-pb CID stay rejected by the F2 check rather than being repaired on re-pin. New pins are correct; the old-row backfill is out of scope here.

Full gitlawb-node suite green (604), clippy -D warnings and fmt clean.

@jatmn re-requesting review — the design notes above are settled with the reasoning inline, so the most useful pass is on correctness: the scan-fallback path and the walk-permit bounding are the two spots worth trying to break.

@beardthelion
beardthelion requested review from jatmn and removed request for jatmn July 17, 2026 15:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crate:node gitlawb-node — the serving node and REST API kind:bug Defect fix — wrong or unsafe behavior subsystem:storage Blob/object store, Arweave, IPFS, archives subsystem:visibility Path-scoped visibility and content withholding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GET /ipfs/{cid} serves tree/commit objects of withheld subtrees, leaking structure get_tree protects (KTD3)

2 participants