@@ -32,6 +32,7 @@ use crate::error::{AppError, Result};
3232use crate :: git:: store;
3333use crate :: git:: visibility_pack:: {
3434 allowed_blob_set_for_caller, allowed_tree_set_for_caller, has_path_scoped_rule,
35+ reachable_commit_tag_oids,
3536} ;
3637use crate :: state:: AppState ;
3738use crate :: visibility:: { visibility_check, Decision } ;
@@ -64,15 +65,16 @@ pub(crate) const MAX_HISTORY_WALKS_PER_REQUEST: u32 = 16;
6465/// row than the one read (KTD2a). We check object existence via
6566/// `store::object_type` *before* the expensive reachability walk so random-CID
6667/// spray cannot trigger full-history git walks on repos that don't carry the
67- /// object. When the row carries path-scoped rules (KTD4) the served object must
68- /// be either a `commit`/`tag` (root-level metadata the caller already cleared the
69- /// `"/"` gate for) OR a `blob`/`tree` in the caller's *reachable* allowed-set
70- /// (`allowed_blob_set_for_caller` / `allowed_tree_set_for_caller`). A withheld
71- /// subtree's tree object is denied here exactly as `get_tree` denies its path, so
72- /// its child names and oids cannot leak by CID (#135). The reachable allowed-sets
73- /// exclude dangling objects — a blob or tree written via plumbing and never
74- /// committed has no path to gate, so it is fail-closed 404'd under path-scoped
75- /// rules (#126). Denial and genuine not-found both fall through to an opaque 404.
68+ /// object. When the row carries path-scoped rules (KTD4) the served object is
69+ /// gated by type: a `blob`/`tree` must be in the caller's *reachable* allowed-set
70+ /// (`allowed_blob_set_for_caller` / `allowed_tree_set_for_caller`), and a
71+ /// `commit`/`tag` must be in the repo's *reachable* commit/tag set
72+ /// (`reachable_commit_tag_oids`, #173). A withheld subtree's tree object is denied
73+ /// here exactly as `get_tree` denies its path, so its child names and oids cannot
74+ /// leak by CID (#135). All these sets exclude dangling objects — a blob, tree,
75+ /// commit, or tag written via plumbing and never referenced has no reachable path,
76+ /// so it is fail-closed 404'd under path-scoped rules (#126, #173). Denial and
77+ /// genuine not-found both fall through to an opaque 404.
7678///
7779/// Scope: this closes the direct unauthenticated scan, including the dangling
7880/// case. A stale-public mirror row still serves withheld content (tracked
@@ -175,6 +177,11 @@ pub async fn get_by_cid(
175177 // it. Built lazily and only for a tree fetch (a request is one CID = one object
176178 // type), so one request builds exactly one of the two sets — no double walk.
177179 let mut allowed_tree_memo: HashMap < String , HashSet < String > > = HashMap :: new ( ) ;
180+ // The reachable commit/tag set (#173, F2): a dangling commit/tag is in no
181+ // repo's set, so it fails closed under a path-scoped rule. Caller-independent
182+ // (reachability is objective), so keyed on repo.id alone. Built lazily on a
183+ // commit/tag fetch, sharing the walk cap + IP quota with the blob/tree walks.
184+ let mut reachable_ct_memo: HashMap < String , HashSet < String > > = HashMap :: new ( ) ;
178185
179186 // The `/ipfs/{cid}` route is anonymous and a valid tree CID makes each
180187 // path-scoped repo pay a fresh full-history allowed-set walk, memoized only
@@ -234,30 +241,37 @@ pub async fn get_by_cid(
234241
235242 // Per-object gating applies only when a path-scoped rule exists (KTD4);
236243 // without one, the "/" gate above is the whole story. Under a path-scoped
237- // rule a `blob` is gated against the caller's allowed-blob-set and a `tree`
238- // against the allowed-tree-set (#135 — a withheld subtree's tree structure
239- // must not leak by CID where get_tree protects it). A `commit`/`tag` exposes
240- // only "/"-level metadata the caller already cleared the "/" gate for, so it
241- // falls through to serve.
244+ // rule the fetched object is gated by type: a `blob` against the caller's
245+ // allowed-blob-set, a `tree` against the allowed-tree-set (#135 — a
246+ // withheld subtree's structure must not leak by CID), and a `commit`/`tag`
247+ // against the repo's REACHABLE commit/tag set (#173, F2 — a dangling
248+ // commit/tag has no authorized reachable path, so it must not serve its
249+ // message/metadata; a reachable one already cleared the "/" gate). Each
250+ // set is a full-history git walk, so all three share the per-request cap
251+ // and per-walk IP quota below.
242252 let path_scoped = has_path_scoped_rule ( rules) ;
243- if path_scoped && ( obj_type == "blob" || obj_type == "tree" ) {
244- let is_blob = obj_type == "blob" ;
245- let memo = if is_blob {
246- & mut allowed_blob_memo
247- } else {
248- & mut allowed_tree_memo
253+ let gated =
254+ path_scoped && matches ! ( obj_type. as_str( ) , "blob" | "tree" | "commit" | "tag" ) ;
255+ if gated {
256+ // One memo per kind: blob/tree gate on the caller's allowed-set,
257+ // commit/tag on reachability (caller-independent — reachability is
258+ // objective, so the set is not keyed on the caller).
259+ let memo = match obj_type. as_str ( ) {
260+ "blob" => & mut allowed_blob_memo,
261+ "tree" => & mut allowed_tree_memo,
262+ _ => & mut reachable_ct_memo,
249263 } ;
250264 if !memo. contains_key ( & repo. id ) {
251265 // Per-request fan-out ceiling (INV-10): once this request has
252266 // spawned MAX_HISTORY_WALKS_PER_REQUEST walks, skip THIS
253267 // walk-requiring candidate and keep scanning (#173, F4). A plain
254268 // `continue`, not `break`: a later candidate servable WITHOUT a
255- // walk (a `commit`/`tag` , or a no-rule public copy) must still be
256- // reached and served — breaking the loop would 404 that publicly
257- // readable content for no amplification benefit. `walks` is only
258- // incremented inside this walk block, so walk-free candidates
259- // never consume the budget and the ceiling still bounds spawned
260- // walks to MAX_HISTORY_WALKS_PER_REQUEST.
269+ // walk (a walk-free reachable copy , or a no-rule public copy) must
270+ // still be reached and served — breaking the loop would 404 that
271+ // publicly readable content for no amplification benefit. `walks`
272+ // is only incremented inside this walk block, so walk-free
273+ // candidates never consume the budget and the ceiling still bounds
274+ // spawned walks to MAX_HISTORY_WALKS_PER_REQUEST.
261275 if walks >= MAX_HISTORY_WALKS_PER_REQUEST {
262276 continue ;
263277 }
@@ -270,15 +284,13 @@ pub async fn get_by_cid(
270284 // charged. When no key resolves (e.g. a test `oneshot` with no
271285 // peer and no trusted header) the limiter is skipped, exactly as
272286 // the other IP brakes behave.
287+ if let Some ( key) =
288+ crate :: rate_limit:: client_key ( & headers, peer, state. push_limiter_trust )
273289 {
274- if let Some ( key) =
275- crate :: rate_limit:: client_key ( & headers, peer, state. push_limiter_trust )
276- {
277- if !state. ipfs_rate_limiter . check ( & key) . await {
278- return Err ( AppError :: TooManyRequests (
279- "ipfs retrieval rate limit exceeded — try again later" . into ( ) ,
280- ) ) ;
281- }
290+ if !state. ipfs_rate_limiter . check ( & key) . await {
291+ return Err ( AppError :: TooManyRequests (
292+ "ipfs retrieval rate limit exceeded — try again later" . into ( ) ,
293+ ) ) ;
282294 }
283295 }
284296
@@ -287,32 +299,32 @@ pub async fn get_by_cid(
287299 let is_public = repo. is_public ;
288300 let owner = repo. owner_did . clone ( ) ;
289301 let caller_for_walk = caller_owned. clone ( ) ;
290- // Full-history walk shells out to git — keep it off the async runtime.
291- // Only the fetched object's type is walked (blob XOR tree), so a tree
292- // fetch never pays the blob walk and vice-versa.
293- let walk = tokio:: task:: spawn_blocking ( move || {
294- if is_blob {
295- allowed_blob_set_for_caller (
296- & rp,
297- & r,
298- is_public,
299- & owner,
300- caller_for_walk. as_deref ( ) ,
301- )
302- } else {
303- allowed_tree_set_for_caller (
304- & rp,
305- & r,
306- is_public,
307- & owner,
308- caller_for_walk. as_deref ( ) ,
309- )
310- }
302+ let kind = obj_type. clone ( ) ;
303+ // Full-history walk shells out to git — keep it off the async
304+ // runtime. Only the fetched object's type is walked.
305+ let walk = tokio:: task:: spawn_blocking ( move || match kind. as_str ( ) {
306+ "blob" => allowed_blob_set_for_caller (
307+ & rp,
308+ & r,
309+ is_public,
310+ & owner,
311+ caller_for_walk. as_deref ( ) ,
312+ ) ,
313+ "tree" => allowed_tree_set_for_caller (
314+ & rp,
315+ & r,
316+ is_public,
317+ & owner,
318+ caller_for_walk. as_deref ( ) ,
319+ ) ,
320+ // commit | tag: caller-independent reachable set.
321+ _ => reachable_commit_tag_oids ( & rp) ,
311322 } )
312323 . await ;
313324 // Fail closed on EITHER a task panic (JoinError) or a walk error:
314- // we cannot prove the caller may read here, so skip this repo and
315- // let a public copy (if any) serve. Never serve on an unproven gate.
325+ // we cannot prove the object is readable here, so skip this repo
326+ // and let a public copy (if any) serve. Never serve on an unproven
327+ // gate.
316328 let set = match walk {
317329 Ok ( Ok ( set) ) => set,
318330 Ok ( Err ( e) ) => {
@@ -326,10 +338,10 @@ pub async fn get_by_cid(
326338 } ;
327339 memo. insert ( repo. id . clone ( ) , set) ;
328340 }
329- let in_allowed = memo
341+ let in_set = memo
330342 . get ( & repo. id )
331343 . is_some_and ( |set| set. contains ( sha256_hex) ) ;
332- if !in_allowed {
344+ if !in_set {
333345 continue ;
334346 }
335347 }
0 commit comments