@@ -343,6 +343,29 @@ fn signed_publish_bytes(keypair: &Keypair, event: &RefUpdateEvent) -> serde_json
343343 serde_json:: to_vec ( & event)
344344}
345345
346+ /// Exactly the pair the swarm loop hands `gossipsub.publish` for one outbound
347+ /// ref-update: the topic, and the signed bytes.
348+ ///
349+ /// Extracted from the publish arm so a test can hold what the loop publishes.
350+ /// The arm itself sits inside a `select!` that no test drives, so before this
351+ /// existed the only thing standing between a regression and the mesh was that
352+ /// the arm happened to call `signed_publish_bytes`; an arm rewritten to
353+ /// `serde_json::to_vec(&event)` would have published unsigned bytes with the
354+ /// whole suite green. Now that regression has to be made HERE to stay silent.
355+ ///
356+ /// Be exact about what this does and does not close. It closes
357+ /// sign-before-publish and the topic the bytes go out on. It does NOT observe
358+ /// the `select!` arm dispatching to it, and it does not observe `require_signed`
359+ /// arriving from `main.rs`; both need a live swarm. Those remain uncovered
360+ /// seams, named rather than implied away.
361+ fn ref_update_publish_args (
362+ keypair : & Keypair ,
363+ event : & RefUpdateEvent ,
364+ ) -> serde_json:: Result < ( gossipsub:: IdentTopic , Vec < u8 > ) > {
365+ let bytes = signed_publish_bytes ( keypair, event) ?;
366+ Ok ( ( gossipsub:: IdentTopic :: new ( REF_UPDATES_TOPIC ) , bytes) )
367+ }
368+
346369/// Resolve the public key behind a claimed `node_did`, refusing anything that
347370/// is not a resolvable `did:key`.
348371///
@@ -1069,9 +1092,8 @@ pub async fn start(
10691092 Some ( cmd) = cmd_rx. recv( ) => {
10701093 match cmd {
10711094 P2pCommand :: PublishRefUpdate ( event) => {
1072- match signed_publish_bytes( & keypair, & event) {
1073- Ok ( bytes) => {
1074- let topic = gossipsub:: IdentTopic :: new( REF_UPDATES_TOPIC ) ;
1095+ match ref_update_publish_args( & keypair, & event) {
1096+ Ok ( ( topic, bytes) ) => {
10751097 match swarm. behaviour_mut( ) . gossipsub. publish( topic, bytes) {
10761098 Ok ( id) => info!( msg_id = %id, repo = %event. repo, "published ref-update" ) ,
10771099 Err ( e) => warn!( err = %e, "failed to publish ref-update" ) ,
@@ -1360,6 +1382,55 @@ mod tests {
13601382 ) ;
13611383 }
13621384
1385+ /// The same golden discipline, applied to the optional shape production
1386+ /// actually emits.
1387+ ///
1388+ /// `GOLDEN_SIGNING_BYTES` above pins an event with `owner_did`, `cert_id`,
1389+ /// and `cid` all populated, and no real publish looks like that. The sole
1390+ /// production publish site, `api::repos::post_receive_replication_tail`,
1391+ /// always passes `cert_id: None`, and `cid` is None on every push whose
1392+ /// pinning has not finished. So the encoding of a null-valued optional, the
1393+ /// one carried by essentially every live event, was pinned nowhere.
1394+ ///
1395+ /// What this catches that the all-`Some` constant structurally cannot:
1396+ /// adding `skip_serializing_if = "Option::is_none"` to any of those three
1397+ /// fields omits the key rather than writing `null`, which changes the
1398+ /// signing input for every event in flight while leaving the all-`Some`
1399+ /// golden byte-identical. That is not hypothetical; injecting exactly that
1400+ /// attribute on `cert_id` left the whole suite green, both goldens passing,
1401+ /// with the production signing input silently changed.
1402+ ///
1403+ /// Frozen for the same reason as the constant above: a failure here is a
1404+ /// wire-format change that needs a rollout plan, not a constant to re-pin.
1405+ const GOLDEN_SIGNING_BYTES_ALL_NONE : & str = concat ! (
1406+ r#"{"node_did":"did:key:zNode","pusher_did":"did:key:zPusher","# ,
1407+ r#""repo":"zOwner/myrepo","owner_did":null,"# ,
1408+ r#""ref_name":"refs/heads/main","# ,
1409+ r#""old_sha":"0000000000000000000000000000000000000000","# ,
1410+ r#""new_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","# ,
1411+ r#""timestamp":"2026-07-02T12:00:00Z","cert_id":null,"cid":null}"# ,
1412+ ) ;
1413+
1414+ /// The all-`None` optional shape: what the production publish site emits.
1415+ fn all_none_optionals_event ( ) -> RefUpdateEvent {
1416+ RefUpdateEvent {
1417+ owner_did : None ,
1418+ cert_id : None ,
1419+ cid : None ,
1420+ ..populated_event ( )
1421+ }
1422+ }
1423+
1424+ #[ test]
1425+ fn signing_bytes_of_the_all_none_shape_match_the_golden_constant ( ) {
1426+ let bytes = signing_bytes ( & all_none_optionals_event ( ) ) . unwrap ( ) ;
1427+ assert_eq ! (
1428+ String :: from_utf8( bytes) . unwrap( ) ,
1429+ GOLDEN_SIGNING_BYTES_ALL_NONE ,
1430+ "the wire signing input for null-valued optionals changed; see the comment on GOLDEN_SIGNING_BYTES_ALL_NONE"
1431+ ) ;
1432+ }
1433+
13631434 /// The signature must be excluded from its own input, so a signed event and
13641435 /// its unsigned original produce identical signing bytes.
13651436 #[ test]
@@ -1389,14 +1460,33 @@ mod tests {
13891460 /// silently stopped accepting every event already in flight. That is the
13901461 /// only failure this constant can see, and regenerating it is precisely
13911462 /// how you blind it. Same discipline as `GOLDEN_SIGNING_BYTES`, for the
1392- /// same reason: freeze it forever.
1463+ /// same reason: freeze it forever. `LEGACY_SIGNED_EVENT_V0_SHA256` below is
1464+ /// what makes "never regenerate it" a check rather than a request.
13931465 ///
13941466 /// Non-degenerate on purpose: `owner_did`, `cert_id`, and `cid` are all
13951467 /// populated, so the interaction between the optional fields' encoding and
13961468 /// the version field's is pinned rather than left unexercised by an
13971469 /// artifact that happened to carry none of them.
13981470 const LEGACY_SIGNED_EVENT_V0 : & str = r#"{"node_did":"did:key:z6MkiAJwX3dtfEY6KGeDDgxXB6ZZWCAxTSHDtJEyUVynqYtq","pusher_did":"did:key:zPusher","repo":"zOwner/myrepo","owner_did":"did:key:zOwner","ref_name":"refs/heads/main","old_sha":"0000000000000000000000000000000000000000","new_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","timestamp":"2026-07-02T12:00:00Z","cert_id":"cert-1","cid":"bafycid","sig":"-lH5aObROlqoTFjnjSXjbDgCVscLfVaKb1Y1gJL1tVsiBZlZnLKi55QgSo0ALTNtI_DyKo0ColzJMxL7w7ZODQ"}"# ;
13991471
1472+ /// SHA-256 of `LEGACY_SIGNED_EVENT_V0`, hex, lowercase. This is what makes
1473+ /// the capture claim above checkable instead of merely attested.
1474+ ///
1475+ /// The obvious guard does not work, which is why this one exists. Asserting
1476+ /// the artifact carries no `"v"` key proves nothing about its provenance: a
1477+ /// v0 event re-serializes with no version key under `skip_serializing_if`,
1478+ /// so an artifact regenerated from CURRENT code carries no `"v"` key either
1479+ /// and that assertion passes against precisely the regeneration it was meant
1480+ /// to refuse. A digest has no such blind spot. Any edit to those bytes,
1481+ /// regeneration included, moves it.
1482+ ///
1483+ /// Frozen alongside the artifact. If it fails, the constant was edited:
1484+ /// restore the original from commit e3dc6f07 rather than re-pinning the
1485+ /// digest, since re-pinning is exactly the act of blinding the test that the
1486+ /// "never regenerate it" paragraph above warns against.
1487+ const LEGACY_SIGNED_EVENT_V0_SHA256 : & str =
1488+ "2482e053c8ab1841d784f523f1ef5e3d0bd5f9d563565af8fca8dd34a1e264fc" ;
1489+
14001490 /// The compatibility test the rest of this module cannot substitute for:
14011491 /// the only one here that verifies an artifact it did not itself sign.
14021492 ///
@@ -1408,18 +1498,19 @@ mod tests {
14081498 /// observation that can tell those two worlds apart.
14091499 #[ test]
14101500 fn an_event_signed_before_the_version_field_existed_still_verifies ( ) {
1411- // The artifact's legacy shape is CHECKED, not attested in a comment.
1412- // If a later edit ever regenerates the constant from current code, a
1413- // `"v"` key could appear in it and this test would quietly decay into
1414- // the fresh-artifact round trip it exists to not be.
1415- let raw: serde_json:: Value = serde_json:: from_str ( LEGACY_SIGNED_EVENT_V0 )
1416- . expect ( "the frozen artifact must be valid JSON" ) ;
1417- assert ! (
1418- !raw. as_object( )
1419- . expect( "the frozen artifact must be a JSON object" )
1420- . contains_key( "v" ) ,
1421- "the frozen artifact must carry no version key; it predates the field and \
1422- re-capturing it from current code is what this assertion refuses"
1501+ // What this checks, exactly: that the constant still holds the bytes
1502+ // captured at e3dc6f07, byte for byte. It does not, and cannot, observe
1503+ // that those bytes predate the version field; that is established by the
1504+ // capture commit and by review, not by anything a test run can see. What
1505+ // the digest does buy is that a later edit which regenerates the artifact
1506+ // from current code fails HERE, loudly, instead of quietly decaying this
1507+ // test into the fresh-artifact round trip it exists to not be.
1508+ use sha2:: { Digest , Sha256 } ;
1509+ let digest = hex:: encode ( Sha256 :: digest ( LEGACY_SIGNED_EVENT_V0 . as_bytes ( ) ) ) ;
1510+ assert_eq ! (
1511+ digest, LEGACY_SIGNED_EVENT_V0_SHA256 ,
1512+ "the frozen legacy artifact was edited; restore it from commit e3dc6f07 rather than \
1513+ re-pinning this digest"
14231514 ) ;
14241515
14251516 // Exactly the bytes a peer would receive, straight off the wire.
@@ -2064,6 +2155,147 @@ mod tests {
20642155 assert_eq ! ( count( & pool, "received_ref_updates" ) . await , 1 ) ;
20652156 }
20662157
2158+ /// The publish arm's own output, driven end to end: whatever
2159+ /// `ref_update_publish_args` hands gossipsub must be signed, must go out on
2160+ /// the wire topic peers subscribe to, and must survive ingest with
2161+ /// enforcement ON.
2162+ ///
2163+ /// This is deliberately not a second copy of the round trip above. That test
2164+ /// calls `signed_publish_bytes`, one layer below the loop; this one calls
2165+ /// the function the `select!` arm calls, so an arm that stopped signing
2166+ /// would have to be rewritten rather than merely reordered to keep the suite
2167+ /// green. The topic is asserted against the literal string, not against
2168+ /// `REF_UPDATES_TOPIC`, because comparing the constant to itself proves
2169+ /// nothing: the topic name is a wire format shared with every deployed peer,
2170+ /// and renaming it silently partitions the mesh into two meshes that each
2171+ /// look healthy.
2172+ ///
2173+ /// What is still NOT observed, and is not implied to be: the `select!` arm
2174+ /// dispatching a `P2pCommand::PublishRefUpdate` into this function, and
2175+ /// `require_signed` reaching `ingest_ref_update` from `main.rs` the right
2176+ /// way round. Both live inside `p2p::start`, which needs a live swarm to
2177+ /// drive, so an inverted flag threaded from the config would still leave
2178+ /// this green. Uncovered seam, named.
2179+ #[ sqlx:: test]
2180+ async fn the_publish_arms_output_is_signed_and_survives_enforced_ingest ( pool : PgPool ) {
2181+ let db = ingest_db ( & pool) . await ;
2182+ let keypair = Keypair :: generate ( ) ;
2183+ // Unsigned on the way in, exactly as `publish_ref_update` hands it over.
2184+ let event = event_for ( & keypair) ;
2185+ assert_eq ! (
2186+ event. sig, None ,
2187+ "the publish path is what adds the signature"
2188+ ) ;
2189+ seed_peer ( & pool, & event. node_did ) . await ;
2190+
2191+ let ( topic, bytes) =
2192+ ref_update_publish_args ( & keypair, & event) . expect ( "the publish arm must produce args" ) ;
2193+
2194+ assert_eq ! (
2195+ topic. to_string( ) ,
2196+ "gitlawb/ref-updates/v1" ,
2197+ "the publish topic is a wire format; renaming it partitions the mesh"
2198+ ) ;
2199+
2200+ let published: RefUpdateEvent =
2201+ serde_json:: from_slice ( & bytes) . expect ( "published bytes must parse" ) ;
2202+ assert ! (
2203+ published. sig. is_some( ) ,
2204+ "the bytes the loop hands gossipsub must carry a signature"
2205+ ) ;
2206+
2207+ let outcome = ingest_with_fresh_limiter ( & db, true , true , & bytes, & PeerId :: random ( ) ) . await ;
2208+ assert ! (
2209+ matches!( outcome, IngestOutcome :: Accepted ) ,
2210+ "the publish arm's bytes must survive ingest with enforcement on, got {outcome:?}"
2211+ ) ;
2212+ assert_eq ! ( count( & pool, "received_ref_updates" ) . await , 1 ) ;
2213+ }
2214+
2215+ // ── Durable-write failure ─────────────────────────────────────────────
2216+ //
2217+ // `WriteFailed` is unreachable from every other test here: a rejection stops
2218+ // above both writes, and an acceptance has both succeed. So the variant that
2219+ // exists to stop `Accepted` from meaning "authenticated but never stored"
2220+ // was never once observed, and `rejection_reason` panics rather than
2221+ // distinguishes if it turns up.
2222+ //
2223+ // The failure is made REAL by dropping the target table on the live test
2224+ // database, so the error comes back from Postgres on the actual write rather
2225+ // than from a stub standing in for one. Both directions are driven, because
2226+ // the property is that the two writes are attempted INDEPENDENTLY: a test
2227+ // that only broke the first could not tell that from "the first failure
2228+ // aborts the rest", and each case therefore asserts what landed in the sink
2229+ // that was left intact.
2230+
2231+ /// The ref-update row fails, the queue entry still lands.
2232+ #[ sqlx:: test]
2233+ async fn a_failed_ref_update_insert_reports_write_failed_and_still_enqueues ( pool : PgPool ) {
2234+ let db = ingest_db ( & pool) . await ;
2235+ let keypair = Keypair :: generate ( ) ;
2236+ let mut event = event_for ( & keypair) ;
2237+ sign_ref_update ( & keypair, & mut event) . unwrap ( ) ;
2238+ seed_peer ( & pool, & event. node_did ) . await ;
2239+
2240+ sqlx:: query ( "DROP TABLE received_ref_updates" )
2241+ . execute ( & pool)
2242+ . await
2243+ . expect ( "drop the ref-update sink so its write genuinely fails" ) ;
2244+
2245+ let outcome =
2246+ ingest_with_fresh_limiter ( & db, true , true , & bytes_of ( & event) , & PeerId :: random ( ) ) . await ;
2247+
2248+ match outcome {
2249+ IngestOutcome :: WriteFailed ( reason) => assert ! (
2250+ reason. contains( "failed to store received ref-update" ) ,
2251+ "the outcome must name the write that failed, got: {reason}"
2252+ ) ,
2253+ other => panic ! (
2254+ "an event whose durable write failed must not be reported as accepted, got {other:?}"
2255+ ) ,
2256+ }
2257+
2258+ assert_eq ! (
2259+ count( & pool, "sync_queue" ) . await ,
2260+ 1 ,
2261+ "the queue entry is a separate write and must not be lost to the row's failure"
2262+ ) ;
2263+ }
2264+
2265+ /// The mirror: the queue entry fails, the ref-update row still lands.
2266+ #[ sqlx:: test]
2267+ async fn a_failed_enqueue_reports_write_failed_and_still_stores_the_row ( pool : PgPool ) {
2268+ let db = ingest_db ( & pool) . await ;
2269+ let keypair = Keypair :: generate ( ) ;
2270+ let mut event = event_for ( & keypair) ;
2271+ sign_ref_update ( & keypair, & mut event) . unwrap ( ) ;
2272+ seed_peer ( & pool, & event. node_did ) . await ;
2273+
2274+ sqlx:: query ( "DROP TABLE sync_queue" )
2275+ . execute ( & pool)
2276+ . await
2277+ . expect ( "drop the queue sink so its write genuinely fails" ) ;
2278+
2279+ let outcome =
2280+ ingest_with_fresh_limiter ( & db, true , true , & bytes_of ( & event) , & PeerId :: random ( ) ) . await ;
2281+
2282+ match outcome {
2283+ IngestOutcome :: WriteFailed ( reason) => assert ! (
2284+ reason. contains( "failed to enqueue sync" ) ,
2285+ "the outcome must name the write that failed, got: {reason}"
2286+ ) ,
2287+ other => panic ! (
2288+ "an event whose enqueue failed must not be reported as accepted, got {other:?}"
2289+ ) ,
2290+ }
2291+
2292+ assert_eq ! (
2293+ count( & pool, "received_ref_updates" ) . await ,
2294+ 1 ,
2295+ "the ref-update row is a separate write and must not be lost to the enqueue failure"
2296+ ) ;
2297+ }
2298+
20672299 // ── Ingest rate limits ────────────────────────────────────────────────
20682300
20692301 /// The documented numbers, and then the check that matters: each cap
0 commit comments