@@ -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