Skip to content

Commit 92dbdfa

Browse files
committed
test(node): guard that every repo-scoped handler is visibility-gated
Mirrors the existing every_in_scope_mutation_has_its_gate source-string CI test on the egress side: scrapes every repo-scoped handler (any pub/pub(crate) async fn taking Path<(String,String..)>) and asserts it carries an authz marker, either a read gate (authorize_repo_read / visibility_check) or a write/owner/self gate (require_repo_owner / require_owner / did_matches / &auth.0), or is listed in KNOWN_UNGATED with its tracking issue. Verb-agnostic on purpose: a repo-scoped read named outside list_/get_ (git_info_refs, replicate_encrypted_blobs, withheld_paths) is still checked, so a new ungated egress of any name fails CI instead of shipping as the next leak. A completeness scan over src/api/ asserts every module declaring a repo-scoped handler is wired into the guard, so a whole new file cannot add an ungated handler the scrape never looks at. KNOWN_UNGATED is the live gap list (certs/issues/labels/ bounties/stars per #120, plus events/webhooks/replicas/protected pending PR #113); the staleness assert forces each entry out the moment its gate lands. A count floor trips if the scrape silently breaks, and unit tests pin handler_names and is_repo_scoped. Verified by execution: removing an allowlist entry trips the missing-gate assert; listing a gated handler trips the staleness assert; dropping a source file trips the completeness scan; breaking the parser trips the count floor; and a non-list/get handler (git_info_refs) is confirmed scraped and gate-checked.
1 parent 6ae316c commit 92dbdfa

1 file changed

Lines changed: 209 additions & 0 deletions

File tree

  • crates/gitlawb-node/src/api

‎crates/gitlawb-node/src/api/mod.rs‎

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)