Skip to content

Commit 6ea8a20

Browse files
test(git-remote): cover delegation_header end to end
`delegation_header` was the one piece of the delegated push path with no test. Its parts each had one — `split_pack_post_url`, `build_invocation`, `delegation_path` — but nothing checked they compose, and this is the function where a regression is silent by construction: every failure inside it returns `None` and the push goes out without `X-Ucan`, so a break surfaces as a 403 at the node with nothing locally to connect it to. Five cases, driven against a mockito node with the store seeded where `gl ucan import` would have left it: - the delegated push: a stored token becomes an invocation issued by the agent, addressed to the node's DID, rooted at the repo owner, with every link bounded - the owner's own push: no store read, no node round-trip. The comparison has to survive the form mismatch — the keypair holds `did:key:z…` while the URL carries the bare key — so the mock asserts zero hits - no usable delegation: an empty store, a token that does not decode, and a `git/fetch` capability that carries nothing to wrap. All three yield no header rather than an error - an unreadable node DID: a 500, a JSON body with no `did`, a proxy's HTML error page, and a `did` that does not parse. The push loses its header, never aborts - a path-prefixed node base: the DID probe must go to `/gitlawb`, not `/`. `split_pack_post_url` is unit-tested for the prefix, but nothing checked the probe followed it; the mock on `/` asserts zero hits Each case was watched failing before it was kept, against five separate mutations: the owner short-circuit removed, the prefix dropped from the node base, `build_invocation`'s push-class filter widened, the node-DID lookup given a fallback, and the invocation addressed to the agent instead of the node.
1 parent 479dd04 commit 6ea8a20

1 file changed

Lines changed: 297 additions & 0 deletions

File tree

  • crates/git-remote-gitlawb/src

‎crates/git-remote-gitlawb/src/main.rs‎

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2642,4 +2642,301 @@ mod delegated_push_tests {
26422642
assert_eq!(delegation_path(base, "did:key:z6MkAbc", "myrepo"), expected);
26432643
assert_eq!(delegation_path(base, "z6MkAbc", "myrepo"), expected);
26442644
}
2645+
2646+
// ── delegation_header ─────────────────────────────────────────────────────
2647+
//
2648+
// Everything above tests one piece in isolation. `delegation_header` is where
2649+
// they compose — URL split, owner comparison, store lookup, node-DID probe,
2650+
// invocation build — and it is the piece with no safety net: every failure
2651+
// inside it is deliberately silent, so a regression does not fail loudly, it
2652+
// just stops attaching `X-Ucan` and the delegate starts getting 403s with no
2653+
// local explanation.
2654+
2655+
/// `delegation_header` resolves its store from `GITLAWB_KEY`, which is
2656+
/// process-global. Every case that sets it takes this lock.
2657+
///
2658+
/// Only these cases need it. `advertisement_and_pack_post_are_signed_…` also
2659+
/// reaches `delegation_header` (through `build_pack_post_request` on
2660+
/// `git-receive-pack`), but it asserts on signature headers alone and is
2661+
/// unaffected by whichever store is in scope.
2662+
static KEY_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2663+
2664+
/// Point `GITLAWB_KEY` at `dir/identity.pem` for the duration of `f`, so the
2665+
/// delegation store resolves to `dir/delegations`.
2666+
fn with_identity_dir<T>(dir: &std::path::Path, f: impl FnOnce() -> T) -> T {
2667+
let _guard = KEY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2668+
let restore = std::env::var_os("GITLAWB_KEY");
2669+
std::env::set_var("GITLAWB_KEY", dir.join("identity.pem"));
2670+
let out = f();
2671+
match restore {
2672+
Some(v) => std::env::set_var("GITLAWB_KEY", v),
2673+
None => std::env::remove_var("GITLAWB_KEY"),
2674+
}
2675+
out
2676+
}
2677+
2678+
/// Write a token where `gl ucan import` would have left it.
2679+
fn store_delegation(dir: &std::path::Path, owner: &str, repo: &str, raw: &str) {
2680+
let path = delegation_path(dir, owner, repo);
2681+
std::fs::create_dir_all(path.parent().expect("delegations dir")).expect("mkdir");
2682+
std::fs::write(path, raw).expect("write delegation");
2683+
}
2684+
2685+
fn bare(did: &gitlawb_core::did::Did) -> String {
2686+
did.to_string()
2687+
.strip_prefix("did:key:")
2688+
.expect("did:key")
2689+
.to_string()
2690+
}
2691+
2692+
fn push_delegation(owner: &Keypair, agent: &Keypair, resource: &str) -> Ucan {
2693+
Ucan::issue(
2694+
owner,
2695+
agent.did(),
2696+
vec![Capability::new(resource, caps::GIT_PUSH)],
2697+
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
2698+
)
2699+
.expect("issue")
2700+
}
2701+
2702+
fn did_body(node: &Keypair) -> String {
2703+
format!(r#"{{"did":"{}"}}"#, node.did())
2704+
}
2705+
2706+
#[test]
2707+
fn delegation_header_wraps_a_stored_delegation_and_targets_the_node() {
2708+
let owner = Keypair::generate();
2709+
let agent = Keypair::generate();
2710+
let node = Keypair::generate();
2711+
let owner_key = bare(&owner.did());
2712+
2713+
let delegation = push_delegation(&owner, &agent, &format!("gitlawb://repos/{owner_key}/r"));
2714+
2715+
let mut server = mockito::Server::new();
2716+
let did_probe = server
2717+
.mock("GET", "/")
2718+
.with_header("content-type", "application/json")
2719+
.with_body(did_body(&node))
2720+
.create();
2721+
2722+
let dir = tempfile::tempdir().expect("tempdir");
2723+
store_delegation(
2724+
dir.path(),
2725+
&owner_key,
2726+
"r",
2727+
&delegation.encode().expect("encode"),
2728+
);
2729+
2730+
let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url());
2731+
let client = reqwest::blocking::Client::new();
2732+
let header =
2733+
with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &agent))
2734+
.expect("a stored delegation must produce an X-Ucan");
2735+
2736+
did_probe.assert();
2737+
2738+
let invocation = Ucan::decode(&header).expect("the header must decode as a UCAN");
2739+
assert_eq!(invocation.payload.iss, agent.did(), "the agent invokes");
2740+
assert_eq!(
2741+
invocation.payload.aud,
2742+
node.did(),
2743+
"addressed to the node that will execute it"
2744+
);
2745+
assert_eq!(
2746+
invocation.verify_chain().expect("chain must verify"),
2747+
owner.did(),
2748+
"the chain must root at the repo owner"
2749+
);
2750+
assert!(
2751+
invocation.chain_lifetime_is_bounded(),
2752+
"the node refuses an unbounded push chain, so every link must carry an expiry"
2753+
);
2754+
}
2755+
2756+
/// The owner pushes on their own authority. The comparison has to survive the
2757+
/// form mismatch — the keypair holds `did:key:z…`, the URL carries the bare key
2758+
/// — or the owner takes the delegate path, finds nothing, and pays a node
2759+
/// round-trip on every push. `.expect(0)` is the assertion that matters here.
2760+
#[test]
2761+
fn delegation_header_is_skipped_when_the_pusher_is_the_owner() {
2762+
let owner = Keypair::generate();
2763+
let node = Keypair::generate();
2764+
let owner_key = bare(&owner.did());
2765+
2766+
let mut server = mockito::Server::new();
2767+
let did_probe = server
2768+
.mock("GET", "/")
2769+
.with_body(did_body(&node))
2770+
.expect(0)
2771+
.create();
2772+
2773+
// A delegation the owner does not need. Present so the assertion is about
2774+
// the owner check and not about an empty store.
2775+
let dir = tempfile::tempdir().expect("tempdir");
2776+
let delegate = Keypair::generate();
2777+
store_delegation(
2778+
dir.path(),
2779+
&owner_key,
2780+
"r",
2781+
&push_delegation(&owner, &delegate, &format!("gitlawb://repos/{owner_key}/r"))
2782+
.encode()
2783+
.expect("encode"),
2784+
);
2785+
2786+
let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url());
2787+
let client = reqwest::blocking::Client::new();
2788+
let header =
2789+
with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &owner));
2790+
2791+
assert!(header.is_none(), "the owner needs no delegation");
2792+
did_probe.assert();
2793+
}
2794+
2795+
/// Best-effort means best-effort: nothing here may panic or block the push. The
2796+
/// node decides whether a delegation was required, and its denial has to reach
2797+
/// the user instead of being pre-empted by a local guess.
2798+
#[test]
2799+
fn delegation_header_is_absent_without_a_usable_stored_delegation() {
2800+
let agent = Keypair::generate();
2801+
let node = Keypair::generate();
2802+
let owner_key = bare(&Keypair::generate().did());
2803+
2804+
let mut server = mockito::Server::new();
2805+
let _did = server.mock("GET", "/").with_body(did_body(&node)).create();
2806+
let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url());
2807+
let client = reqwest::blocking::Client::new();
2808+
2809+
// Nothing stored at all.
2810+
let empty = tempfile::tempdir().expect("tempdir");
2811+
assert!(
2812+
with_identity_dir(empty.path(), || delegation_header(
2813+
&client, &post_url, &agent
2814+
))
2815+
.is_none(),
2816+
"an empty store must yield no header, not an error"
2817+
);
2818+
2819+
// Stored, but not a UCAN — a truncated write or a hand-edited file.
2820+
let garbage = tempfile::tempdir().expect("tempdir");
2821+
store_delegation(garbage.path(), &owner_key, "r", "not a ucan");
2822+
assert!(
2823+
with_identity_dir(garbage.path(), || delegation_header(
2824+
&client, &post_url, &agent
2825+
))
2826+
.is_none(),
2827+
"an unreadable stored token must yield no header, not a panic"
2828+
);
2829+
2830+
// Stored and valid, but for a capability the push path cannot use. `gl ucan
2831+
// import` refuses these now; a store written by an older `gl` still holds them.
2832+
let owner = Keypair::generate();
2833+
let wrong_owner_key = bare(&owner.did());
2834+
let unusable = tempfile::tempdir().expect("tempdir");
2835+
let fetch_only = Ucan::issue(
2836+
&owner,
2837+
agent.did(),
2838+
vec![Capability::new(
2839+
format!("gitlawb://repos/{wrong_owner_key}/r"),
2840+
caps::GIT_FETCH,
2841+
)],
2842+
Some(chrono::Utc::now() + chrono::Duration::hours(1)),
2843+
)
2844+
.expect("issue");
2845+
store_delegation(
2846+
unusable.path(),
2847+
&wrong_owner_key,
2848+
"r",
2849+
&fetch_only.encode().expect("encode"),
2850+
);
2851+
let fetch_url = format!("{}/{wrong_owner_key}/r/git-receive-pack", server.url());
2852+
assert!(
2853+
with_identity_dir(unusable.path(), || delegation_header(
2854+
&client, &fetch_url, &agent
2855+
))
2856+
.is_none(),
2857+
"a git/fetch delegation carries no push capability to wrap"
2858+
);
2859+
}
2860+
2861+
/// The node DID addresses the invocation, so without it there is nothing to
2862+
/// build. A node that is down, slow, or serving something other than JSON must
2863+
/// cost the push a header, never an abort.
2864+
#[test]
2865+
fn delegation_header_is_absent_when_the_node_did_cannot_be_read() {
2866+
let owner = Keypair::generate();
2867+
let agent = Keypair::generate();
2868+
let owner_key = bare(&owner.did());
2869+
let encoded = push_delegation(&owner, &agent, &format!("gitlawb://repos/{owner_key}/r"))
2870+
.encode()
2871+
.expect("encode");
2872+
let client = reqwest::blocking::Client::new();
2873+
2874+
for (label, status, body) in [
2875+
("a 500 from the node", 500, "boom"),
2876+
("a JSON body with no did", 200, r#"{"name":"gitlawb"}"#),
2877+
("an HTML error page from a proxy", 200, "<html>502</html>"),
2878+
("a did that does not parse", 200, r#"{"did":"not-a-did"}"#),
2879+
] {
2880+
let mut server = mockito::Server::new();
2881+
let _did = server
2882+
.mock("GET", "/")
2883+
.with_status(status)
2884+
.with_body(body)
2885+
.create();
2886+
2887+
let dir = tempfile::tempdir().expect("tempdir");
2888+
store_delegation(dir.path(), &owner_key, "r", &encoded);
2889+
let post_url = format!("{}/{owner_key}/r/git-receive-pack", server.url());
2890+
2891+
assert!(
2892+
with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &agent))
2893+
.is_none(),
2894+
"{label} must drop the header, not fail the push"
2895+
);
2896+
}
2897+
}
2898+
2899+
/// A reverse-proxied `GITLAWB_NODE` carries a path prefix, and that prefix
2900+
/// survives into the pack URL. `split_pack_post_url` is unit-tested for it, but
2901+
/// nothing checked that the probe actually goes to the prefixed base — a
2902+
/// regression there would GET `/` on the proxy host, read whatever landing page
2903+
/// it serves, and silently drop the header. `.expect(0)` on `/` is the half that
2904+
/// catches it.
2905+
#[test]
2906+
fn delegation_header_probes_the_prefixed_node_base() {
2907+
let owner = Keypair::generate();
2908+
let agent = Keypair::generate();
2909+
let node = Keypair::generate();
2910+
let owner_key = bare(&owner.did());
2911+
2912+
let mut server = mockito::Server::new();
2913+
let prefixed = server
2914+
.mock("GET", "/gitlawb")
2915+
.with_body(did_body(&node))
2916+
.create();
2917+
let root = server.mock("GET", "/").expect(0).create();
2918+
2919+
let dir = tempfile::tempdir().expect("tempdir");
2920+
store_delegation(
2921+
dir.path(),
2922+
&owner_key,
2923+
"r",
2924+
&push_delegation(&owner, &agent, &format!("gitlawb://repos/{owner_key}/r"))
2925+
.encode()
2926+
.expect("encode"),
2927+
);
2928+
2929+
let post_url = format!("{}/gitlawb/{owner_key}/r/git-receive-pack", server.url());
2930+
let client = reqwest::blocking::Client::new();
2931+
let header =
2932+
with_identity_dir(dir.path(), || delegation_header(&client, &post_url, &agent))
2933+
.expect("a path-prefixed node base must still yield an X-Ucan");
2934+
2935+
prefixed.assert();
2936+
root.assert();
2937+
assert_eq!(
2938+
Ucan::decode(&header).expect("decode").payload.aud,
2939+
node.did()
2940+
);
2941+
}
26452942
}

0 commit comments

Comments
 (0)