@@ -40,16 +40,21 @@ use crate::visibility::{visibility_check, Decision};
4040/// caller passes. For each iterated row we gate against that row's OWN rules
4141/// (`visibility_check` at `"/"`), never re-resolving via `authorize_repo_read`
4242/// — `get_repo`'s fuzzy match could otherwise authorize a different physical
43- /// row than the one read (KTD2a). We check object existence via
44- /// `store::object_type` *before* the expensive reachability walk so random-CID
45- /// spray cannot trigger full-history git walks on repos that don't carry the
46- /// object. When the row carries path-scoped rules (KTD4) the served object
47- /// must be either a non-blob (trees/commits are structural; KTD3) OR a blob
48- /// in the caller's *reachable* allowed-set (`allowed_blob_set_for_caller`).
49- /// The reachable allowed-set excludes dangling blobs — a blob written via
50- /// `git hash-object -w` and never committed has no path to gate, so it is
51- /// fail-closed 404'd under path-scoped rules (#126). Denial and genuine
52- /// not-found both fall through to an opaque 404.
43+ /// row than the one read (KTD2a). Quarantine is fail-closed in two layers:
44+ /// `list_all_repos` drops rows with `quarantined = TRUE`, and a logical-repo
45+ /// fold drops any surviving twin that shares owner+name with a quarantined
46+ /// *canonical* (UUID) row — so a public mirror cannot serve while its
47+ /// canonical twin is quarantined, without letting a quarantined mirror
48+ /// suppress a healthy canonical (`get_repo` prefers canonical). We check object
49+ /// existence via `store::object_type` *before* the expensive reachability walk
50+ /// so random-CID spray cannot trigger full-history git walks on repos that
51+ /// don't carry the object. When the row carries path-scoped rules (KTD4) the
52+ /// served object must be either a non-blob (trees/commits are structural;
53+ /// KTD3) OR a blob in the caller's *reachable* allowed-set
54+ /// (`allowed_blob_set_for_caller`). The reachable allowed-set excludes dangling
55+ /// blobs — a blob written via `git hash-object -w` and never committed has no
56+ /// path to gate, so it is fail-closed 404'd under path-scoped rules (#126).
57+ /// Denial and genuine not-found both fall through to an opaque 404.
5358///
5459/// Scan completeness (F2): the 404 above is returned ONLY when every candidate
5560/// repo reached a VERDICT — visibility deny, probe-says-absent, walk-gate deny,
@@ -86,6 +91,17 @@ use crate::visibility::{visibility_check, Decision};
8691/// case. A stale-public mirror row still serves withheld content (tracked
8792/// separately, #124).
8893///
94+ /// Normalize owner DID for quarantine logical-repo keys: matches [`did_matches`]
95+ /// for `did:key:` vs bare-key pairs without scanning every quarantined row per
96+ /// candidate repo.
97+ fn quarantine_owner_key ( owner_did : & str ) -> String {
98+ match owner_did. strip_prefix ( "did:key:" ) {
99+ Some ( k) if !k. contains ( ':' ) => k. to_string ( ) ,
100+ _ if !owner_did. contains ( ':' ) => owner_did. to_string ( ) ,
101+ _ => owner_did. to_string ( ) ,
102+ }
103+ }
104+
89105/// One `/ipfs` request's walk admission: the global pool permit plus the
90106/// optional per-source sub-permit, both RAII (#174 U1).
91107///
@@ -216,6 +232,36 @@ pub async fn get_by_cid(
216232 ) ) ) ;
217233 }
218234 } ;
235+ // Logical-repo quarantine fold: when a *canonical* UUID row is quarantined
236+ // but a public mirror twin (`owner/name`) survives `list_all_repos`, slug
237+ // routes 404 via `authorize_repo_read` (prefers canonical) while CID serve
238+ // would otherwise still hit the mirror. Drop candidates that share
239+ // owner+name with a quarantined *canonical* only — a quarantined mirror
240+ // must not suppress a healthy canonical (same preference as get_repo).
241+ let quarantined = match tokio:: time:: timeout (
242+ request_deadline. saturating_duration_since ( std:: time:: Instant :: now ( ) ) ,
243+ state. db . list_quarantined_repos ( ) ,
244+ )
245+ . await
246+ {
247+ Ok ( Ok ( rows) ) => rows,
248+ Ok ( Err ( e) ) => return Err ( AppError :: Internal ( e) ) ,
249+ Err ( _elapsed) => {
250+ tracing:: warn!(
251+ budget_secs = state. config. ipfs_request_budget_secs,
252+ "/ipfs list_quarantined_repos exceeded the request budget \
253+ (GITLAWB_IPFS_REQUEST_BUDGET_SECS); shedding a retryable 503 and freeing the walk permit"
254+ ) ;
255+ return Err ( AppError :: Overloaded ( format ! (
256+ "ipfs scan incomplete (budget) for CID {cid_str}; retry shortly"
257+ ) ) ) ;
258+ }
259+ } ;
260+ let quarantined_canonical_keys: HashSet < ( String , String ) > = quarantined
261+ . iter ( )
262+ . filter ( |q| !q. id . contains ( '/' ) )
263+ . map ( |q| ( quarantine_owner_key ( & q. owner_did ) , q. name . clone ( ) ) )
264+ . collect ( ) ;
219265
220266 // Fetch every repo's visibility rules in one query rather than one per row
221267 // (the gate runs each row against its OWN rules — KTD2a). A row absent from
@@ -320,6 +366,12 @@ pub async fn get_by_cid(
320366 let mut repos_visited: usize = 0 ;
321367
322368 for repo in & repos {
369+ if quarantined_canonical_keys
370+ . contains ( & ( quarantine_owner_key ( & repo. owner_did ) , repo. name . clone ( ) ) )
371+ {
372+ continue ;
373+ }
374+
323375 // Repo-level read gate against THIS row's own rules (KTD2a). Deny is a
324376 // VERDICT: this repo would never serve the caller, so skipping it cannot
325377 // hide content from them.
0 commit comments