Summary
withheld_blob_recipients_bounded (crates/gitlawb-node/src/git/visibility_pack.rs:717) accepts a timeout and applies it to only one of its two phases. blob_paths at :726 is deadline-bounded. The recipient loop that follows at :738-749 is not, and it is the phase whose cost the repo owner controls.
Present unchanged on main at visibility_pack.rs:368, where it runs from a bare spawn_blocking (api/repos.rs:1147) with no permit and no timeout at all. This is not a #174 follow-up; #174 only adds the concurrency cap around it.
Cost
O(withheld_pairs x candidate_dids x rules), quadratic in rule count and near-quadratic in reader-DID count, because candidates is the union of reader_dids across all rules while visibility_check is itself O(rules).
visibility_check is not cheap per iteration. Per rule per call, glob_matches (visibility.rs:48) does two nfc() collects into fresh Strings plus a format!, and specificity (visibility.rs:62) does a third, then the winning rule linearly scans reader_dids (visibility.rs:100). Roughly 1 microsecond per rule per call.
Measured on a release build, the loop extracted verbatim (pairs / rules / readers-per-rule):
| shape |
time |
| 1,000 / 10 / 20 |
1.95 s |
| 10,000 / 10 / 20 |
19.7 s |
| 20,000 / 5 / 30 |
16.1 s |
| 1,000 / 1 / 3,200 |
38.7 s |
| 1,000 / 320 / 5 |
496 s |
Scaling confirmed by execution: linear in pairs (500/1k/2k/4k gives 0.96/1.95/3.94/7.96 s), quadratic in rules (4x rules gives 14.6x to 16.5x), near-quadratic in readers. The 19.7 s row is an ordinary private monorepo with a 200-person org, no hostility required. Extrapolating the quadratic fit, 1,000 rules with 5 readers on a 10,000-pair repo is on the order of hours; that one is extrapolation, not measurement.
Nothing caps the inputs
validate_path_glob (api/visibility.rs:43) checks glob shape only, never count or DID-list length. The schema is reader_dids TEXT NOT NULL (db/mod.rs:754), unbounded, with UNIQUE(repo_id, path_glob) so rule count is bounded only by distinct globs. The visibility route (server.rs:174-177) is not in a group that calls DefaultBodyLimit::disable(), so it keeps axum's 2 MB default, which still admits roughly 35,000 did:key entries in a single PUT, and rule count accumulates across repeated PUTs.
What it costs the node
On this branch the permit lives inside the spawn_blocking closure (api/repos.rs:838), deliberately, so the call pins one of 32 node-wide git_encrypt_semaphore slots (main.rs:402, default from max_concurrent_git_pushes) and one blocking thread at full CPU for the duration. That pool is disjoint from the read and write pools and defers rather than sheds, so this does not directly block pushes or reads; the cross-tenant cost is the burned core and the starved background walks of other repos. On main there is no permit, so the only bound is the thread pool itself.
Reachability
Owner-only. The single production writer of visibility rules is api/visibility.rs:114, behind require_owner (:28); every other set_visibility_rule call site is test code. No sync, announce, p2p, admin, or migration path writes the table. Worth stating plainly: "owner" here means any authenticated DID that created a repo, not a privileged operator, so this is cross-tenant CPU starvation available to any account, not an anonymous DoS.
Suggested direction
A deadline over the tail is the obvious fix but probably the wrong one alone, since abandoning the loop partway yields an incomplete recipient map. Capping the inputs is likely the real answer: a maximum rule count per repo and a maximum reader_dids length per rule, validated in set_visibility. Memoizing visibility_check per (path, did) would also collapse the quadratic term.
Out of scope, noted so it is on record
withheld_from_pairs (visibility_pack.rs:548) is O(pairs x rules), linear in both, measured two to three orders below the tail. replicable_objects_fail_closed is one hash probe per candidate. Both are non-findings.
Separately, the same O(pairs x rules) shape appears on the anonymously reachable /ipfs/{cid} path via allowed_blob_set_for_caller_bounded (api/ipfs.rs:521) at lower magnitude, overlapping #164. Not this issue.
Summary
withheld_blob_recipients_bounded(crates/gitlawb-node/src/git/visibility_pack.rs:717) accepts atimeoutand applies it to only one of its two phases.blob_pathsat:726is deadline-bounded. The recipient loop that follows at:738-749is not, and it is the phase whose cost the repo owner controls.Present unchanged on
mainatvisibility_pack.rs:368, where it runs from a barespawn_blocking(api/repos.rs:1147) with no permit and no timeout at all. This is not a #174 follow-up; #174 only adds the concurrency cap around it.Cost
O(withheld_pairs x candidate_dids x rules), quadratic in rule count and near-quadratic in reader-DID count, because
candidatesis the union ofreader_didsacross all rules whilevisibility_checkis itself O(rules).visibility_checkis not cheap per iteration. Per rule per call,glob_matches(visibility.rs:48) does twonfc()collects into freshStrings plus aformat!, andspecificity(visibility.rs:62) does a third, then the winning rule linearly scansreader_dids(visibility.rs:100). Roughly 1 microsecond per rule per call.Measured on a release build, the loop extracted verbatim (pairs / rules / readers-per-rule):
Scaling confirmed by execution: linear in pairs (500/1k/2k/4k gives 0.96/1.95/3.94/7.96 s), quadratic in rules (4x rules gives 14.6x to 16.5x), near-quadratic in readers. The 19.7 s row is an ordinary private monorepo with a 200-person org, no hostility required. Extrapolating the quadratic fit, 1,000 rules with 5 readers on a 10,000-pair repo is on the order of hours; that one is extrapolation, not measurement.
Nothing caps the inputs
validate_path_glob(api/visibility.rs:43) checks glob shape only, never count or DID-list length. The schema isreader_dids TEXT NOT NULL(db/mod.rs:754), unbounded, withUNIQUE(repo_id, path_glob)so rule count is bounded only by distinct globs. The visibility route (server.rs:174-177) is not in a group that callsDefaultBodyLimit::disable(), so it keeps axum's 2 MB default, which still admits roughly 35,000did:keyentries in a single PUT, and rule count accumulates across repeated PUTs.What it costs the node
On this branch the permit lives inside the
spawn_blockingclosure (api/repos.rs:838), deliberately, so the call pins one of 32 node-widegit_encrypt_semaphoreslots (main.rs:402, default frommax_concurrent_git_pushes) and one blocking thread at full CPU for the duration. That pool is disjoint from the read and write pools and defers rather than sheds, so this does not directly block pushes or reads; the cross-tenant cost is the burned core and the starved background walks of other repos. Onmainthere is no permit, so the only bound is the thread pool itself.Reachability
Owner-only. The single production writer of visibility rules is
api/visibility.rs:114, behindrequire_owner(:28); every otherset_visibility_rulecall site is test code. No sync, announce, p2p, admin, or migration path writes the table. Worth stating plainly: "owner" here means any authenticated DID that created a repo, not a privileged operator, so this is cross-tenant CPU starvation available to any account, not an anonymous DoS.Suggested direction
A deadline over the tail is the obvious fix but probably the wrong one alone, since abandoning the loop partway yields an incomplete recipient map. Capping the inputs is likely the real answer: a maximum rule count per repo and a maximum
reader_didslength per rule, validated inset_visibility. Memoizingvisibility_checkper(path, did)would also collapse the quadratic term.Out of scope, noted so it is on record
withheld_from_pairs(visibility_pack.rs:548) is O(pairs x rules), linear in both, measured two to three orders below the tail.replicable_objects_fail_closedis one hash probe per candidate. Both are non-findings.Separately, the same O(pairs x rules) shape appears on the anonymously reachable
/ipfs/{cid}path viaallowed_blob_set_for_caller_bounded(api/ipfs.rs:521) at lower magnitude, overlapping #164. Not this issue.