@@ -239,4 +239,213 @@ mod authz_guard {
239239 "a marker present only in a comment must not count as an enforced gate"
240240 ) ;
241241 }
242+
243+ /// All `pub async fn` / `pub(crate) async fn` handler names declared in `src`.
244+ /// Verb-agnostic on purpose: a repo-scoped read of any name is in scope, so a
245+ /// handler named `fetch_*` / `replicate_*` / `info_refs` cannot escape the gate
246+ /// check by not being called `list_*` / `get_*`.
247+ fn handler_names ( src : & str ) -> Vec < String > {
248+ [ "pub async fn " , "pub(crate) async fn " ]
249+ . iter ( )
250+ . flat_map ( |decl| {
251+ src. match_indices ( decl) . map ( move |( i, _) | {
252+ src[ i + decl. len ( ) ..]
253+ . chars ( )
254+ . take_while ( |c| c. is_alphanumeric ( ) || * c == '_' )
255+ . collect :: < String > ( )
256+ } )
257+ } )
258+ . collect ( )
259+ }
260+
261+ /// True when the handler's signature takes an owner+repo path
262+ /// (`Path<(String, String...)>`), i.e. it is a repo-scoped read rather than a
263+ /// node-wide aggregate (`/stats`, `/ipfs/pins`, the global feeds).
264+ fn is_repo_scoped ( body : & str ) -> bool {
265+ let header = & body[ ..body. find ( '{' ) . unwrap_or ( body. len ( ) ) ] ;
266+ header. contains ( "Path<(String, String" )
267+ }
268+
269+ /// Egress gate guard: every repo-scoped handler (`Path<(String, String..)>`)
270+ /// must carry an authz marker — a read gate (`authorize_repo_read` /
271+ /// `visibility_check`), or a write gate (`require_repo_owner` / `require_owner`
272+ /// / `did_matches` / a `&auth.0` self-binding) — or be listed in KNOWN_UNGATED
273+ /// (the tracked, ungated reads). A new ungated handler of ANY verb trips this,
274+ /// and a completeness scan over `src/api/` trips it for a whole new module that
275+ /// adds a repo-scoped handler without being wired into `sources`. Mutations are
276+ /// also checked precisely, per handler, by the mutation guard above; here they
277+ /// only need *some* binding so the net stays verb-agnostic.
278+ ///
279+ /// Scope and limits (this is a source scrape; the runtime route guard covers
280+ /// behaviour):
281+ /// - It proves a gate is CALLED, not that it runs on the requested path: a
282+ /// content handler gating `"/"` instead of the subtree still passes here.
283+ /// - It sees handlers taking the owner+repo tuple `Path<(String, String..)>`; a
284+ /// repo identified by a struct/custom extractor would be missed (the helper
285+ /// unit tests pin these boundaries).
286+ /// - KNOWN_UNGATED entries need a real open issue and review; the staleness
287+ /// assert removes one only once its gate lands.
288+ #[ test]
289+ fn every_repo_scoped_handler_is_gated ( ) {
290+ let sources: & [ ( & str , & str ) ] = & [
291+ ( include_str ! ( "bounties.rs" ) , "bounties.rs" ) ,
292+ ( include_str ! ( "certs.rs" ) , "certs.rs" ) ,
293+ ( include_str ! ( "changelog.rs" ) , "changelog.rs" ) ,
294+ ( include_str ! ( "encrypted.rs" ) , "encrypted.rs" ) ,
295+ ( include_str ! ( "events.rs" ) , "events.rs" ) ,
296+ ( include_str ! ( "issues.rs" ) , "issues.rs" ) ,
297+ ( include_str ! ( "labels.rs" ) , "labels.rs" ) ,
298+ ( include_str ! ( "protect.rs" ) , "protect.rs" ) ,
299+ ( include_str ! ( "pulls.rs" ) , "pulls.rs" ) ,
300+ ( include_str ! ( "replicas.rs" ) , "replicas.rs" ) ,
301+ ( include_str ! ( "repos.rs" ) , "repos.rs" ) ,
302+ ( include_str ! ( "stars.rs" ) , "stars.rs" ) ,
303+ ( include_str ! ( "visibility.rs" ) , "visibility.rs" ) ,
304+ ( include_str ! ( "webhooks.rs" ) , "webhooks.rs" ) ,
305+ ] ;
306+ let listed: std:: collections:: HashSet < & str > = sources. iter ( ) . map ( |( _, f) | * f) . collect ( ) ;
307+ assert ! ( !listed. is_empty( ) , "read-guard `sources` is empty" ) ;
308+
309+ // Completeness: every api/*.rs NOT already in `sources` must declare no
310+ // repo-scoped handler, so a brand-new module cannot add an ungated handler
311+ // the scrape never looks at. Reads the directory at test time (so the list
312+ // cannot silently drift from the filesystem) and only inspects unlisted
313+ // files — listed files are covered by the per-handler loop below.
314+ let api_dir = concat ! ( env!( "CARGO_MANIFEST_DIR" ) , "/src/api" ) ;
315+ for ( _, f) in sources {
316+ assert ! (
317+ std:: path:: Path :: new( api_dir) . join( f) . exists( ) ,
318+ "read-guard `sources` lists {f} but the file does not exist"
319+ ) ;
320+ }
321+ for entry in std:: fs:: read_dir ( api_dir) . expect ( "read src/api" ) {
322+ let path = entry. expect ( "dir entry" ) . path ( ) ;
323+ let fname = path. file_name ( ) . unwrap ( ) . to_string_lossy ( ) . into_owned ( ) ;
324+ if !fname. ends_with ( ".rs" ) || fname == "mod.rs" || listed. contains ( fname. as_str ( ) ) {
325+ continue ;
326+ }
327+ let src = std:: fs:: read_to_string ( & path) . expect ( "read api file" ) ;
328+ let has_repo_handler = handler_names ( & src)
329+ . iter ( )
330+ . any ( |n| is_repo_scoped ( & fn_body ( & src, n) ) ) ;
331+ assert ! (
332+ !has_repo_handler,
333+ "api/{fname} declares a repo-scoped handler but is not in the egress \
334+ guard `sources` list — add it so its handlers are gate-checked"
335+ ) ;
336+ }
337+
338+ // Repo-scoped reads known to be ungated today, each tracked by an issue.
339+ // Remove an entry the moment its gate lands (the staleness assert enforces it).
340+ let known_ungated: & [ ( & str , & str ) ] = & [
341+ ( "list_certs" , "#120" ) ,
342+ ( "get_cert" , "#120" ) ,
343+ ( "list_issues" , "#120" ) ,
344+ ( "get_issue" , "#120" ) ,
345+ ( "list_issue_comments" , "#120" ) ,
346+ ( "list_labels" , "#120" ) ,
347+ ( "list_repo_bounties" , "#120" ) ,
348+ ( "get_star_status" , "#120" ) ,
349+ ( "list_repo_events" , "#94 (PR #113)" ) ,
350+ ( "list_webhooks" , "#94 (PR #113)" ) ,
351+ ( "list_replicas" , "PR #113" ) ,
352+ ( "list_protected_branches" , "PR #113" ) ,
353+ ] ;
354+ let is_known = |n : & str | known_ungated. iter ( ) . any ( |( k, _) | * k == n) ;
355+ // Any one of these = the handler binds the caller to an authz decision: the
356+ // first two are read gates, the rest are the write/owner/self-binding forms.
357+ // A repo-scoped mutation passes here on its write gate; the mutation guard
358+ // above additionally verifies the exact gate type per handler. NOTE: a local
359+ // rename of `caller`/`replica_did` must be paired with a marker update here.
360+ let markers = [
361+ "authorize_repo_read(" ,
362+ "visibility_check(" ,
363+ "require_repo_owner(" ,
364+ "require_owner(" ,
365+ "did_matches(" ,
366+ "caller = &auth.0" ,
367+ "replica_did = &auth.0" ,
368+ ] ;
369+
370+ // Every KNOWN_UNGATED name must be a real handler (catch typos / renames).
371+ let all: std:: collections:: HashSet < String > =
372+ sources. iter ( ) . flat_map ( |( s, _) | handler_names ( s) ) . collect ( ) ;
373+ for ( n, _) in known_ungated {
374+ assert ! (
375+ all. contains( * n) ,
376+ "KNOWN_UNGATED lists `{n}`, which is not a real handler (renamed or removed?)"
377+ ) ;
378+ }
379+
380+ let mut checked = 0usize ;
381+ for ( src, file) in sources {
382+ for name in handler_names ( src) {
383+ let body = fn_body ( src, & name) ;
384+ if !is_repo_scoped ( & body) {
385+ continue ; // node-wide aggregate, not a repo-scoped surface
386+ }
387+ checked += 1 ;
388+ let gated = markers. iter ( ) . any ( |m| body. contains ( m) ) ;
389+ assert ! (
390+ gated || is_known( & name) ,
391+ "repo-scoped handler `{name}` ({file}) has no authz gate and is \
392+ not in KNOWN_UNGATED — add the visibility/owner gate with the \
393+ caller, or track it there"
394+ ) ;
395+ assert ! (
396+ !( gated && is_known( & name) ) ,
397+ "handler `{name}` ({file}) is now gated — remove it from \
398+ KNOWN_UNGATED so the allowlist stays an accurate gap list"
399+ ) ;
400+ }
401+ }
402+ // Tripwire: if the scrape silently stopped finding handlers (e.g. a parser
403+ // regression in handler_names/is_repo_scoped), this floor fails loudly
404+ // instead of the guard passing vacuously. Current count is ~54 repo-scoped
405+ // handlers; 20 is a deliberate floor that trips only on a gross regression.
406+ assert ! (
407+ checked >= 20 ,
408+ "egress guard only checked {checked} repo-scoped handlers — the scrape likely broke"
409+ ) ;
410+ }
411+
412+ /// Pins the `handler_names` boundary: it collects every `pub`/`pub(crate)
413+ /// async fn` regardless of verb (so a `fetch_*` read cannot escape), and skips
414+ /// private `async fn` and sync `fn` helpers.
415+ #[ test]
416+ fn handler_names_finds_all_pub_async_and_skips_others ( ) {
417+ let src = "pub async fn list_things() {}\n \
418+ pub async fn fetch_thing() {}\n \
419+ pub(crate) async fn get_crate_thing() {}\n \
420+ async fn private_helper() {}\n \
421+ fn sync_helper() {}\n ";
422+ let names = handler_names ( src) ;
423+ // Verb-agnostic: a non-list/get read verb is still seen.
424+ assert ! ( names. contains( & "list_things" . to_string( ) ) ) ;
425+ assert ! ( names. contains( & "fetch_thing" . to_string( ) ) ) ;
426+ // pub(crate) routed handlers are in scope too.
427+ assert ! ( names. contains( & "get_crate_thing" . to_string( ) ) ) ;
428+ // Private/sync helpers are not routed handlers and are skipped.
429+ assert ! ( !names
430+ . iter( )
431+ . any( |n| n == "private_helper" || n == "sync_helper" ) ) ;
432+ }
433+
434+ /// Pins the `is_repo_scoped` boundary: an owner+repo tuple Path is repo-scoped;
435+ /// a single-segment or absent Path is a node-wide aggregate.
436+ #[ test]
437+ fn is_repo_scoped_requires_owner_repo_path ( ) {
438+ let repo =
439+ "fn get_x(s: State, Path((owner, name)): Path<(String, String)>) {\n body();\n }" ;
440+ let three = "fn get_y(Path((o, n, id)): Path<(String, String, String)>) {\n body();\n }" ;
441+ let node_wide = "fn list_z(s: State<AppState>) {\n body();\n }" ;
442+ let single = "fn get_w(Path(cid): Path<String>) {\n body();\n }" ;
443+ assert ! ( is_repo_scoped( repo) , "owner+repo tuple is repo-scoped" ) ;
444+ assert ! ( is_repo_scoped( three) , "owner+repo+id tuple is repo-scoped" ) ;
445+ assert ! ( !is_repo_scoped( node_wide) , "no Path is node-wide" ) ;
446+ assert ! (
447+ !is_repo_scoped( single) ,
448+ "single-segment Path is not repo-scoped"
449+ ) ;
450+ }
242451}
0 commit comments