diff --git a/.env.example b/.env.example index b70d1117..04e11c84 100644 --- a/.env.example +++ b/.env.example @@ -46,9 +46,37 @@ GITLAWB_DB_RETRY_MAX_SECS=60 GITLAWB_PINATA_JWT= GITLAWB_PINATA_UPLOAD_URL=https://uploads.pinata.cloud/v3/files -# ── Arweave permanent anchoring (Irys devnet) ───────────────────────────── -# Leave empty to disable Arweave anchoring. -GITLAWB_IRYS_URL=https://devnet.irys.xyz +# ── Arweave permanent anchoring (Bundler / Arweave gateway) ─────────────────── +# Bundler URL for permanent anchoring. Leave empty to disable anchoring. +# (Legacy name: GITLAWB_IRYS_URL) +# Anchoring is PAID, and the node refuses to start when a bundler URL is set +# without BOTH GITLAWB_BUNDLER_ACCOUNT (a funded account) and +# GITLAWB_BUNDLER_TOKEN (the token that account holds): Irys bills uploads at +# /tx/{token} via the x-irys-paid-by header, so a URL with no funded account and +# token would silently fail every anchor. Default (empty) disables anchoring. +GITLAWB_BUNDLER_URL= +# To enable, uncomment the devnet block below and fund the account via the +# bundler's devnet faucet (https://docs.irys.xyz/devnet/faucet), or use the +# production block with a funded wallet and https://node2.irys.xyz. +# Anchoring is PAID and needs the funded-account pair AND an explicit +# GITLAWB_ARWEAVE_GATEWAY for the SAME network: the node refuses to start with +# a bundler URL but no gateway, because an anchor is only resolvable through +# the gateway of the network that recorded it. +# +# Devnet: +#GITLAWB_BUNDLER_URL=https://devnet.irys.xyz +#GITLAWB_BUNDLER_ACCOUNT= +#GITLAWB_BUNDLER_TOKEN=matic +#GITLAWB_ARWEAVE_GATEWAY=https://devnet.irys.xyz +# +# Production (mainnet Irys + Arweave): +#GITLAWB_BUNDLER_URL=https://node2.irys.xyz +#GITLAWB_BUNDLER_ACCOUNT= +#GITLAWB_BUNDLER_TOKEN=ethereum +#GITLAWB_ARWEAVE_GATEWAY=https://arweave.net +# Per-client-IP rate limit for the unauthenticated /api/v1/arweave/verify/:tx_id +# endpoint, in requests per hour. 0 disables. Default 120. +GITLAWB_ARWEAVE_RATE_LIMIT=120 # ── Base L2 smart contracts ─────────────────────────────────────────────── GITLAWB_CHAIN_RPC_URL=https://sepolia.base.org diff --git a/Cargo.lock b/Cargo.lock index b7050bc6..57a890e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3542,9 +3542,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -5708,12 +5708,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.6", ] @@ -7480,6 +7482,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" diff --git a/Cargo.toml b/Cargo.toml index b2fd6c07..8a06e307 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ chrono = { version = "0.4", features = ["serde"] } # uuid uuid = { version = "1", features = ["v4"] } # http client -reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls"], default-features = false } +reqwest = { version = "0.12", features = ["blocking", "json", "multipart", "rustls-tls", "stream"], default-features = false } # HMAC hmac = "0.12" diff --git a/README.md b/README.md index 643992c2..2dabf339 100644 --- a/README.md +++ b/README.md @@ -363,7 +363,11 @@ Important node settings: | `GITLAWB_IPFS_RATE_LIMIT` | Max `/ipfs/{cid}` requests per client IP per hour (route flood brake). 0 disables. Default 600. | | `GITLAWB_TIGRIS_BUCKET` | Optional S3/Tigris shared repo storage bucket. | | `GITLAWB_PINATA_JWT` | Optional Pinata/IPFS warm-storage pinning. | -| `GITLAWB_IRYS_URL` | Optional Irys/Arweave permanent anchoring. | +| `GITLAWB_BUNDLER_URL` | Bundler URL for Arweave permanent anchoring (e.g., https://devnet.irys.xyz for devnet, https://node2.irys.xyz for mainnet Irys). Leave empty to disable. (Legacy name: `GITLAWB_IRYS_URL`). | +| `GITLAWB_BUNDLER_ACCOUNT` | Funded bundler account (public address/identity) that pays for uploads. The node's ANS-104 signature proves authorship, not payment — Irys only serves items backed by a funded account — so the node refuses to start when a bundler URL is set without this. It is sent as the `x-irys-paid-by` header on every upload. | +| `GITLAWB_BUNDLER_TOKEN` | Payment-token slug the funded account holds (e.g. `matic` on devnet, `ethereum` on mainnet). Irys bills uploads at `/tx/{token}`, so this names the token, not an API key, and is NOT sent as `x-irys-paid-by` (that header carries the account). The node refuses to start when a bundler URL is set without it. | +| `GITLAWB_ARWEAVE_GATEWAY` | Arweave gateway used to resolve anchors for `/verify` and the anchors listing. Has no default: the node refuses to start when a bundler is configured without an explicit gateway, because an anchor is only resolvable through the gateway of the network that recorded it (a devnet bundler pairs with the devnet gateway, mainnet Irys with `https://arweave.net`). | +| `GITLAWB_ARWEAVE_RATE_LIMIT` | Per-client-IP rate limit for the verify endpoint, requests per hour (defaults to 120; `0` disables). | Production note: change the default Postgres password before exposing a node publicly. diff --git a/SECURITY.md b/SECURITY.md index bbe97e7e..f948be06 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,12 +28,18 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d - Every git object is content-addressed via CIDv1 (SHA-256) - Tamper-evident by construction — a modified object changes its CID -**UCAN token validation** -- Bootstrap UCAN tokens are issued at registration. -- A supplied token's signature, audience, expiry, and proof-chain attenuation are validated. -- Tokens use a signed JSON wire format with expiry. +**UCAN capability tokens** +- Issued at registration as a signed JSON envelope `{ "payload": {...}, "s": "" }` — not a JWT +- Capability-scoped: `git/push`, `git/fetch`, `issue/create`, `pr/open` +- Expiry enforced on every verification +- The auth middleware (`require_ucan_chain`) verifies the full delegation chain when the `X-Ucan` header is present: the UCAN issuer must match the HTTP Signature identity, the audience must be this node's DID, and every proof in the chain must be cryptographically sound with no capability escalation - Capability grants are not yet consulted by repository write authorization; see the limitations below. +**Authorization** +- Every repo-scoped read and mutation binds the caller to an authorization decision before serving or mutating anything +- Per-repository read enforcement is wired: `authorize_repo_read` denies with the same 404 a missing repo returns, and content endpoints pass the specific path so a withheld subtree is denied even on an otherwise-public repo +- Owner-only mutations (visibility, webhooks, protected branches, merges) are gated to the repo owner; star/unstar, replica registration, and bounty actions have their own intended gates + **Smart contracts (Base Sepolia testnet)** - `GitlawbDIDRegistry` — on-chain DID → document registry - `GitlawbNameRegistry` — human name → DID registry @@ -53,6 +59,12 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d These are documented limitations of the current live release. They should be prioritized without breaking existing nodes during rolling upgrades. +### UCAN chain validation is optional per request +- The middleware verifies the full UCAN delegation chain only when the client presents an `X-Ucan` header. Requests without the header pass through unchanged, so agents that predate UCAN delegation are not forced off. +- **Impact:** A client can still authenticate with a bare RFC 9421 HTTP Signature and skip delegation-chain enforcement entirely; capability delegation is enforced only for clients that opt into presenting a UCAN. +- **Mitigation:** Keep write endpoints signed, treat public nodes as public infrastructure, and treat trust scores as soft rate-limiting signals rather than authorization. +- **Fix target:** make UCAN presentation mandatory for pushes (planned together with owner-push enforcement). + ### Repository write authorization defaults - `git-receive-pack` verifies HTTP Signatures, but `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false` for compatibility during rollout. - **Impact:** With the default setting, a valid signature authenticates the pusher but does not require that DID to be the repository owner. @@ -111,7 +123,7 @@ These are documented limitations of the current live release. They should be pri | Key storage | PKCS#8 PEM, 0600 permissions | | Content hashing | SHA-256 via CIDv1 | | HTTP Signatures | RFC 9421 (Ed25519 + SHA-256 Content-Digest) | -| UCAN tokens | Signed JSON object (Ed25519 signature) | +| UCAN tokens | Signed JSON envelope (Ed25519 over the payload JSON), not JWT | | On-chain | ECDSA secp256k1 (Base L2 / Ethereum) | --- diff --git a/crates/gitlawb-node/src/ans104.rs b/crates/gitlawb-node/src/ans104.rs new file mode 100644 index 00000000..c5bc580d --- /dev/null +++ b/crates/gitlawb-node/src/ans104.rs @@ -0,0 +1,496 @@ +//! ANS-104 signed data items for Arweave bundler uploads. +//! +//! Bundlers (Irys, Turbo, ...) accept a raw **Arweave data item** on their +//! upload endpoint and verify the embedded Ed25519 signature before accepting +//! the upload, so the item provably originates from this node's keypair. The +//! signature authenticates the item's authorship — it is NOT payment. The +//! bundler charges each upload against a funded account and rejects items whose +//! account is unfunded. The node therefore carries a funded account and payment +//! token in its config (`GITLAWB_BUNDLER_ACCOUNT`, `GITLAWB_BUNDLER_TOKEN`) and +//! sends them on every upload as the Irys `x-irys-paid-by` header to +//! `/tx/{token}`; `Config::validate()` refuses to start with a bundler URL but +//! no funded account. +//! +//! Binary layout (per the ANS-104 spec, ed25519 = signature type 2): +//! +//! ```text +//! 0 2 signature type (u16 LE) = 2 +//! 2 66 signature (64 bytes) +//! 66 98 owner public key (32 bytes) +//! 98 target presence byte (0 = absent) +//! 99 anchor presence byte (0 = absent) +//! 100 108 number of tags (u64 LE) +//! 108 116 number of tag bytes (u64 LE) +//! 116 ... serialized tags (Avro-style, see `serialize_tags`) +//! ... data (runs to end of buffer) +//! ``` +//! +//! The signature covers `deepHash(["dataitem", "1", type, owner, target, +//! anchor, tags, data])` using the bundler deepHash (recursive length-tagged +//! SHA-384, identical to the published `arbundles` package), so a bundler, +//! gateway, or the node itself can re-derive it from the item's own fields and +//! verify against the owner. The `tags` element is the FLAT serialized tag +//! stream (`item.rawTags` in `arbundles`' `getSignatureData`) — NOT a nested +//! list. The nested `[[name, value], ...]` form is what Arweave layer-one +//! transactions use; data items deep-hash the serialized tag blob. Zero tags is +//! an empty blob. + +use anyhow::Result; +#[cfg(test)] +use anyhow::{anyhow, bail}; +use base64::Engine as _; +use sha2::{Digest, Sha256, Sha384}; + +/// SignatureConfig value for Ed25519 data items (ANS-104). +pub const SIGNATURE_TYPE_ED25519: u16 = 2; +const SIGNATURE_LEN: usize = 64; +const OWNER_LEN: usize = 32; + +/// Parsed contents of a verified data item. Verification is exercised by the +/// enforcement tests (see `verify_data_item`), which is gated on `cfg(test)`. +#[cfg(test)] +#[derive(Debug, PartialEq, Eq)] +pub struct DataItem { + pub signature: [u8; 64], + pub owner: [u8; 32], + pub tags: Vec<(String, String)>, + pub data: Vec, +} + +/// Build and sign an ANS-104 data item carrying `data` plus the given tags. +/// +/// The tags are embedded *inside* the item (where the bundler verifies them +/// against the signature); nothing is passed out-of-band. +pub fn build_signed_data_item( + keypair: &gitlawb_core::identity::Keypair, + tags: &[(&str, &str)], + data: &[u8], +) -> Result> { + let owner = keypair.verifying_key().to_bytes(); + let serialized_tags = serialize_tags(tags)?; + + let mut item = Vec::with_capacity( + 2 + SIGNATURE_LEN + OWNER_LEN + 2 + 16 + serialized_tags.len() + data.len(), + ); + item.extend_from_slice(&SIGNATURE_TYPE_ED25519.to_le_bytes()); // 0..2 + item.extend_from_slice(&[0u8; SIGNATURE_LEN]); // 2..66, filled below + item.extend_from_slice(&owner); // 66..98 + item.push(0u8); // target presence: absent + item.push(0u8); // anchor presence: absent + item.extend_from_slice(&(tags.len() as u64).to_le_bytes()); // 100..108 + item.extend_from_slice(&(serialized_tags.len() as u64).to_le_bytes()); // 108..116 + item.extend_from_slice(&serialized_tags); + item.extend_from_slice(data); + + let signature_data = deep_hash(&[ + b"dataitem", + b"1", + SIGNATURE_TYPE_ED25519.to_string().as_bytes(), + &owner, + &[], + &[], + &serialized_tags, + data, + ]); + let signature = keypair.sign(&signature_data).to_bytes(); + item[2..2 + SIGNATURE_LEN].copy_from_slice(&signature); + Ok(item) +} + +/// The ANS-104 data-item id: `base64url(sha256(item bytes))`. This is the id +/// the bundler returns for a data item and the id gateways resolve +/// `{gateway}/{id}` under, so it is a stable, content-derived remote identity: +/// the durable job persists it BEFORE the upload request is sent, and a +/// recovery probes that id to decide whether a crashed upload actually landed +/// before ever issuing a second paid request (#224 review). +pub fn data_item_id(item: &[u8]) -> String { + let digest = Sha256::digest(item); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) +} + +/// Parse a data item and verify its Ed25519 signature against `verifying_key` +/// over the deepHash of its own fields. Returns the parsed item (tags + data) +/// on success. This is exactly what a bundler/gateway does on receipt, so a +/// test can use it to enforce the signed-upload contract. +#[cfg(test)] +pub fn verify_data_item( + verifying_key: &ed25519_dalek::VerifyingKey, + item: &[u8], +) -> Result { + if item.len() < 2 + SIGNATURE_LEN + OWNER_LEN + 2 + 16 { + bail!("data item too short"); + } + let signature_type = u16::from_le_bytes(item[0..2].try_into()?); + if signature_type != SIGNATURE_TYPE_ED25519 { + bail!("unsupported signature type {signature_type}"); + } + let signature: [u8; SIGNATURE_LEN] = item[2..2 + SIGNATURE_LEN].try_into()?; + let owner: [u8; OWNER_LEN] = + item[2 + SIGNATURE_LEN..2 + SIGNATURE_LEN + OWNER_LEN].try_into()?; + + let mut p = 2 + SIGNATURE_LEN + OWNER_LEN; + let target_present = item[p]; + p += 1; + let raw_target: &[u8] = match target_present { + 0 => &[], + 1 => { + let end = p + OWNER_LEN; + if end > item.len() { + bail!("data item truncated in target"); + } + let t = &item[p..end]; + p = end; + t + } + other => bail!("invalid target presence byte {other}"), + }; + let anchor_present = item[p]; + p += 1; + let raw_anchor: &[u8] = match anchor_present { + 0 => &[], + 1 => { + let end = p + OWNER_LEN; + if end > item.len() { + bail!("data item truncated in anchor"); + } + let a = &item[p..end]; + p = end; + a + } + other => bail!("invalid anchor presence byte {other}"), + }; + + let num_tags = u64::from_le_bytes(item[p..p + 8].try_into()?); + p += 8; + let num_tag_bytes = u64::from_le_bytes(item[p..p + 8].try_into()?); + p += 8; + let tags_end = p + .checked_add(num_tag_bytes as usize) + .ok_or_else(|| anyhow!("tag byte count overflow"))?; + if tags_end > item.len() { + bail!("data item truncated in tags"); + } + let raw_tags = &item[p..tags_end]; + let raw_data = &item[tags_end..]; + + let tags = deserialize_tags(raw_tags)?; + if tags.len() != num_tags as usize { + bail!( + "tag count {} disagrees with serialized length {}", + tags.len(), + num_tags + ); + } + + let signature_data = deep_hash(&[ + b"dataitem", + b"1", + signature_type.to_string().as_bytes(), + &owner, + raw_target, + raw_anchor, + raw_tags, + raw_data, + ]); + let sig = ed25519_dalek::Signature::from_bytes(&signature); + verifying_key + .verify_strict(&signature_data, &sig) + .map_err(|e| anyhow!("data item signature verification failed: {e}"))?; + + Ok(DataItem { + signature, + owner, + tags, + data: raw_data.to_vec(), + }) +} + +/// The bundler's `deepHash` over the data item's signature fields, +/// byte-for-byte identical to the published `arbundles` `deepHash` for the +/// all-blob preimage a data item uses: seeded by SHA-384("list") over the +/// element count, then each element chained as SHA-384(acc || blob-chunk) +/// where a blob-chunk is SHA-384(SHA-384("blob") || SHA-384(data)). The +/// bundler also recurses for nested list elements, but a data item's signature +/// fields are all blobs (tags included — see the module docs), so no nesting +/// is needed here. +pub fn deep_hash(elems: &[&[u8]]) -> [u8; 48] { + let mut acc = sha384(format!("list{}", elems.len()).as_bytes()); + for elem in elems { + let chunk = deep_hash_blob(elem); + let mut pair = [0u8; 96]; + pair[..48].copy_from_slice(&acc); + pair[48..].copy_from_slice(&chunk); + acc = sha384(&pair); + } + acc +} + +fn deep_hash_blob(data: &[u8]) -> [u8; 48] { + let mut tagged = [0u8; 96]; + tagged[..48].copy_from_slice(&sha384(format!("blob{}", data.len()).as_bytes())); + tagged[48..].copy_from_slice(&sha384(data)); + sha384(&tagged) +} + +fn sha384(data: &[u8]) -> [u8; 48] { + let mut h = Sha384::new(); + h.update(data); + h.finalize().into() +} + +/// Avro-style tag encoding matching the published `arbundles` `serializeTags`. +/// The serialized stream is the `tags` preimage element (`item.rawTags`), so a +/// bundler recomputes the signature from the exact bytes the item carries. +/// +/// For `n > 0` tags: zigzag-varint(n), then for each tag the zigzag-varint +/// length + UTF-8 bytes of name and value, then a terminating zigzag-varint(0). +/// Zero tags serializes to an empty buffer. +fn serialize_tags(tags: &[(&str, &str)]) -> Result> { + let mut out = Vec::new(); + if tags.is_empty() { + return Ok(out); + } + write_long(&mut out, tags.len() as i64)?; + for (name, value) in tags { + write_string(&mut out, name)?; + write_string(&mut out, value)?; + } + write_long(&mut out, 0)?; + Ok(out) +} + +#[cfg(test)] +fn deserialize_tags(buf: &[u8]) -> Result> { + let mut pos = 0usize; + let mut tags = Vec::new(); + loop { + let n = read_long(buf, &mut pos)?; + if n == 0 { + break; + } + let mut count = n; + if n < 0 { + // Negative array length: block count + a block byte-size to skip. + count = -n; + let _block_size = read_long(buf, &mut pos)?; + } + for _ in 0..count { + let name = read_string(buf, &mut pos)?; + let value = read_string(buf, &mut pos)?; + tags.push((name, value)); + } + } + Ok(tags) +} + +fn write_string(out: &mut Vec, s: &str) -> Result<()> { + let bytes = s.as_bytes(); + write_long(out, bytes.len() as i64)?; + out.extend_from_slice(bytes); + Ok(()) +} + +#[cfg(test)] +fn read_string(buf: &[u8], pos: &mut usize) -> Result { + let len = read_long(buf, pos)?; + if len < 0 { + bail!("negative string length"); + } + let len = len as usize; + let end = pos + .checked_add(len) + .ok_or_else(|| anyhow!("string length overflow"))?; + if end > buf.len() { + bail!("tag stream truncated in string"); + } + let s = std::str::from_utf8(&buf[*pos..end])?.to_string(); + *pos = end; + Ok(s) +} + +/// Zigzag + base-128 varint (Avro `writeLong`). +fn write_long(out: &mut Vec, n: i64) -> Result<()> { + let mut m = ((n as u64) << 1) ^ ((n >> 63) as u64); + loop { + let mut byte = (m & 0x7f) as u8; + m >>= 7; + if m != 0 { + byte |= 0x80; + } + out.push(byte); + if m == 0 { + break; + } + } + Ok(()) +} + +/// Zigzag + base-128 varint (Avro `readLong`). +#[cfg(test)] +fn read_long(buf: &[u8], pos: &mut usize) -> Result { + let mut value: u64 = 0; + let mut shift = 0u32; + loop { + if *pos >= buf.len() { + bail!("tag stream truncated in varint"); + } + let byte = buf[*pos]; + *pos += 1; + value |= ((byte & 0x7f) as u64) << shift; + if byte & 0x80 == 0 { + break; + } + shift += 7; + if shift >= 64 { + bail!("tag stream varint overlong"); + } + } + Ok(((value >> 1) as i64) ^ -((value & 1) as i64)) +} + +#[cfg(test)] +mod tests { + use super::*; + use gitlawb_core::identity::Keypair; + + /// Independent reference vector, generated with the published `arbundles` + /// package's `deepHash` (not the code under test) over the ANS-104 spec + /// preimage. Pins the deepHash wire format — decimal-ASCII length tags, + /// recursive list handling, chained SHA-384 — so an accidental divergence + /// in the length-tagging (e.g. reintroducing the old pairwise chaining) or + /// in the tags element turns this test red and every previously-signed + /// anchor would no longer verify. + #[test] + fn deep_hash_matches_independent_reference_vector() { + let owner = [0x41u8; 32]; + // Elements: "dataitem", "1", "2", owner, target, anchor, tags, data. + // 0 tags -> tags element is an EMPTY BLOB (deepHash([]) = SHA384("list0") + // would be a different value): data items hash the flat serialized tag + // stream, and an empty tag stream is zero bytes. + let hash = deep_hash(&[b"dataitem", b"1", b"2", &owner, &[], &[], &[], b"hi"]); + let expected = "98a0a3b931f9c5cc370e822ca06b6e9635f690f81979b70b6dfe92d0af3f601169b0d8dc72d518241e3caba7f9daad1d"; + assert_eq!(hex::encode(hash), expected); + } + + /// Full-serialization interoperability fixture produced by the published + /// `arbundles` package: `createData` + `sign` (its own `getSignatureData` + /// deepHash over the flat `item.rawTags`, plus its Ed25519 signer) with + /// NONEMPTY tags. Proves the flat-tags preimage and the binary layout + /// interop with the real bundler toolchain — a round trip through this + /// module alone is not enough, and the node's own signer must produce + /// items a bundler (and this verifier) accepts. + #[test] + fn verify_data_item_matches_independent_interop_fixture() { + let owner_hex = "d520b4cc5001a7ce12d1aaad57d6fd8e4b1c7b9926f699e6f778fb69f7e6f98b"; + let item_hex = "0200611e031059cf0395a990a1cd59e7c73f877cd36a065795630f9d1858a111d34e9db705dd01b6e2dbf0f5bbe9d6f8d5111d420512f60b80b7dfa7448a83c22e0bd520b4cc5001a7ce12d1aaad57d6fd8e4b1c7b9926f699e6f778fb69f7e6f98b00000300000000000000420000000000000006104170702d4e616d650e6769746c617762085265706f18616c6963652f6d797265706f0c536368656d612a6769746c6177622f7265662d7570646174652f7631007b22736368656d61223a226769746c6177622f7265662d7570646174652f7631222c227265706f223a22616c6963652f6d797265706f227d"; + let owner: [u8; 32] = hex::decode(owner_hex).unwrap().try_into().unwrap(); + let item = hex::decode(item_hex).unwrap(); + let key = ed25519_dalek::VerifyingKey::from_bytes(&owner).unwrap(); + + let parsed = verify_data_item(&key, &item).unwrap(); + assert_eq!( + parsed.tags, + vec![ + ("App-Name".to_string(), "gitlawb".to_string()), + ("Repo".to_string(), "alice/myrepo".to_string()), + ("Schema".to_string(), "gitlawb/ref-update/v1".to_string()), + ] + ); + assert_eq!( + parsed.data, + br#"{"schema":"gitlawb/ref-update/v1","repo":"alice/myrepo"}"# + ); + assert_eq!(parsed.owner, owner); + } + + #[test] + fn serialize_tags_matches_reference_layout() { + assert!(serialize_tags(&[]).unwrap().is_empty()); + // 1 tag: zigzag(1)=0x02, then name/value as varint-len + utf8, + // then terminating 0x00. + let one = serialize_tags(&[("App-Name", "gitlawb")]).unwrap(); + assert_eq!( + one, + [ + 0x02, // zigzag(1) = array count 1 + 0x10, // zigzag(8) = "App-Name".len() + b'A', b'p', b'p', b'-', b'N', b'a', b'm', b'e', + 0x0e, // zigzag(7) = "gitlawb".len() + b'g', b'i', b't', b'l', b'a', b'w', b'b', 0x00, // end of array + ] + ); + } + + #[test] + fn build_then_verify_round_trip() { + let kp = Keypair::generate(); + let data = br#"{"schema":"gitlawb/ref-update/v1","repo":"alice/myrepo"}"#; + let item = build_signed_data_item( + &kp, + &[("App-Name", "gitlawb"), ("Repo", "alice/myrepo")], + data, + ) + .unwrap(); + + // Layout sanity: sig type first, owner at its fixed offset. + assert_eq!(&item[0..2], &[0x02, 0x00]); + assert_eq!( + &item[2 + SIGNATURE_LEN..2 + SIGNATURE_LEN + OWNER_LEN], + &kp.verifying_key().to_bytes() + ); + + let parsed = verify_data_item(&kp.verifying_key(), &item).unwrap(); + assert_eq!( + parsed.tags, + vec![ + ("App-Name".to_string(), "gitlawb".to_string()), + ("Repo".to_string(), "alice/myrepo".to_string()), + ] + ); + assert_eq!(parsed.data, data); + assert_eq!(parsed.owner, kp.verifying_key().to_bytes()); + } + + #[test] + fn verify_rejects_tampered_signature() { + let kp = Keypair::generate(); + let item = build_signed_data_item(&kp, &[("App-Name", "gitlawb")], b"payload").unwrap(); + let mut forged = item.clone(); + forged[3] ^= 0x01; + assert!(verify_data_item(&kp.verifying_key(), &forged).is_err()); + } + + #[test] + fn verify_rejects_item_signed_by_other_key() { + let node = Keypair::generate(); + let attacker = Keypair::generate(); + let item = + build_signed_data_item(&attacker, &[("App-Name", "gitlawb")], b"payload").unwrap(); + assert!( + verify_data_item(&node.verifying_key(), &item).is_err(), + "item signed by a different key must not verify against the node key" + ); + } + + #[test] + fn verify_rejects_altered_data_or_tags() { + let kp = Keypair::generate(); + let item = build_signed_data_item(&kp, &[("Repo", "alice/real")], b"original").unwrap(); + let mut tampered_data = item.clone(); + let n = tampered_data.len(); + tampered_data[n - 1] ^= 0x01; + assert!(verify_data_item(&kp.verifying_key(), &tampered_data).is_err()); + // Tag value flipped inside the item. + let mut tampered_tag = item; + tampered_tag[120] = b'x'; + assert!(verify_data_item(&kp.verifying_key(), &tampered_tag).is_err()); + } + + #[test] + fn verify_rejects_truncated_and_garbage_items() { + let kp = Keypair::generate(); + let item = build_signed_data_item(&kp, &[("App-Name", "gitlawb")], b"payload").unwrap(); + assert!(verify_data_item(&kp.verifying_key(), &item[..item.len() - 1]).is_err()); + assert!(verify_data_item(&kp.verifying_key(), b"not a data item").is_err()); + } +} diff --git a/crates/gitlawb-node/src/api/arweave.rs b/crates/gitlawb-node/src/api/arweave.rs index ad8f45a7..935ffcb6 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -1,14 +1,50 @@ //! GET /api/v1/arweave/anchors — list Arweave ref-update anchors. use axum::{ - extract::{Query, State}, + extract::{Path, Query, State}, Json, }; use serde::Deserialize; -use crate::error::Result; +use crate::error::{AppError, Result}; use crate::state::AppState; +/// GET /api/v1/arweave/verify/:tx_id +/// +/// Fetch the anchor from Arweave via the configured gateway, extract the embedded +/// certificate, and verify: +/// 1. The node's Ed25519 signature on the certificate payload (with a +/// 7-field legacy fallback when the proof fields are absent) +/// 2. Chain continuity: `prev` hashes against the predecessor cert (seq > 1) +/// and, on the legacy path, the stored row is corroborated +/// 3. The RFC 9421 `pusher_sig` — REQUIRED (not optional) whenever the +/// signature context fields are present +/// +/// The verdict only ever covers fields the certificate actually signed; the +/// outer repo/owner_did are corroborated against the node's own record. +pub async fn verify_anchor_endpoint( + State(state): State, + Path(tx_id): Path, +) -> Result> { + if !crate::arweave::is_valid_tx_id(&tx_id) { + return Err(AppError::BadRequest( + "invalid transaction ID: expected 43-character base64url".to_string(), + )); + } + let gateway = &state.config.arweave_gateway; + let node_did = state.node_did.to_string(); + let result = + crate::arweave::verify_anchor(&state.http_client, gateway, &tx_id, &state.db, &node_did) + .await + .map_err(crate::error::AppError::Internal)?; + + Ok(Json(serde_json::json!({ + "valid": result.valid, + "errors": result.errors, + "certificate": result.certificate, + }))) +} + #[derive(Debug, Deserialize)] pub struct ListAnchorsQuery { pub repo: Option, @@ -25,7 +61,15 @@ pub async fn list_anchors( State(state): State, Query(q): Query, ) -> Result> { - let limit = q.limit.min(200); + // Clamp to a sane bound; a negative value would become LIMIT -1 in SQL, + // which Postgres rejects. A value below 1 means "unset" and uses the serde + // default, NOT the clamp floor: `?limit=0` must behave like the parameter + // being absent (default 50), not like `?limit=1`. + let limit = if q.limit < 1 { + default_limit() + } else { + q.limit.min(200) + }; // Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and // map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251). let anchors = state @@ -33,6 +77,25 @@ pub async fn list_anchors( .list_arweave_anchors(q.repo.as_deref(), limit) .await?; + // The gateway config may carry credentials (e.g. an Irys user:pass). Those + // must never leak into a public listing, so only the credential-free origin + // is embedded in each anchor's URL. A node with NO gateway configured emits + // no presentation URL at all: the recorded tx id stays durable and listable + // (it is the anchor's identity), but a `/tx_id`-shaped relative string would + // resolve against the node's own origin and mislead clients (#224 review). + let gateway = + crate::server::mask_credential_url(state.config.arweave_gateway.trim_end_matches('/')); + let anchors: Vec = anchors + .into_iter() + .map(|mut a| { + a.irys_tx_id = Some(a.arweave_tx_id.clone()); + if !gateway.is_empty() { + a.arweave_url = Some(format!("{}/{}", gateway, a.arweave_tx_id)); + } + a + }) + .collect(); + Ok(Json(serde_json::json!({ "anchors": anchors, "count": anchors.len(), @@ -83,4 +146,241 @@ mod closed_pool_tests { }) ); } + + /// A credentialed gateway (user:pass in the URL) must not leak into the + /// public anchors listing — every `arweave_url` is built from the masked + /// origin, never the raw config. + #[sqlx::test] + async fn list_anchors_does_not_leak_gateway_credentials(pool: PgPool) { + use clap::Parser as _; + + let mut state = crate::test_support::test_state(pool.clone()).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--arweave-gateway", + "https://user:supersecret@arweave.net", + ])); + + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { + repo: "alice/myrepo", + owner_did: "did:key:zAlice", + ref_name: "refs/heads/main", + old_sha: &"a".repeat(40), + new_sha: &"b".repeat(40), + cid: Some("bafy1test"), + arweave_tx_id: &"f".repeat(43), + node_did: "did:key:zNode", + cert_id: None, + }) + .await + .unwrap(); + + let resp = Router::new() + .route("/api/v1/arweave/anchors", axum::routing::get(list_anchors)) + .with_state(state) + .oneshot( + Request::builder() + .uri("/api/v1/arweave/anchors") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let body: String = String::from_utf8(bytes.to_vec()).expect("utf8 body"); + assert!( + !body.contains("supersecret"), + "anchors listing must not disclose gateway credentials" + ); + assert!( + body.contains("https://arweave.net/"), + "arweave_url should carry the credential-free origin" + ); + let v: Value = serde_json::from_str(&body).expect("json body"); + assert_eq!(v["count"], 1); + } + + /// Query and fragment credentials on a gateway with a path prefix must not + /// leak into the public listing, and the safe path prefix must survive so + /// the returned arweave_url still routes to the intended gateway. + #[sqlx::test] + async fn list_anchors_drops_query_and_fragment_credentials(pool: PgPool) { + use clap::Parser as _; + + let mut state = crate::test_support::test_state(pool.clone()).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--arweave-gateway", + "https://user:supersecret@gateway.example/data?token=SECRET#frag", + ])); + + let tx_id = "f".repeat(43); + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { + repo: "alice/myrepo", + owner_did: "did:key:zAlice", + ref_name: "refs/heads/main", + old_sha: &"a".repeat(40), + new_sha: &"b".repeat(40), + cid: Some("bafy1test"), + arweave_tx_id: &tx_id, + node_did: "did:key:zNode", + cert_id: None, + }) + .await + .unwrap(); + + let resp = Router::new() + .route("/api/v1/arweave/anchors", axum::routing::get(list_anchors)) + .with_state(state) + .oneshot( + Request::builder() + .uri("/api/v1/arweave/anchors") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let body: String = String::from_utf8(bytes.to_vec()).expect("utf8 body"); + for secret in ["supersecret", "SECRET"] { + assert!( + !body.contains(secret), + "anchors listing must not disclose {secret}" + ); + } + // Path prefix preserved, query/fragment gone, tx_id appended cleanly. + assert!( + body.contains(&format!("https://gateway.example/data/{tx_id}")), + "arweave_url should carry the safe origin plus path prefix, got: {body}" + ); + } + + /// #224 review, P2: a node with recorded anchors but NO gateway configured + /// must not emit a relative `/tx_id` string as `arweave_url` — it would + /// resolve against the node's own origin and mislead clients. The recorded + /// tx id stays durable and listable (it is the anchor's identity); the + /// presentation URL is simply omitted. + #[sqlx::test] + async fn list_anchors_without_gateway_omits_arweave_url(pool: PgPool) { + // test_state's default config has no gateway configured. + let state = crate::test_support::test_state(pool.clone()).await; + + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { + repo: "alice/myrepo", + owner_did: "did:key:zAlice", + ref_name: "refs/heads/main", + old_sha: &"a".repeat(40), + new_sha: &"b".repeat(40), + cid: Some("bafy1test"), + arweave_tx_id: &"f".repeat(43), + node_did: "did:key:zNode", + cert_id: None, + }) + .await + .unwrap(); + + let resp = Router::new() + .route("/api/v1/arweave/anchors", axum::routing::get(list_anchors)) + .with_state(state) + .oneshot( + Request::builder() + .uri("/api/v1/arweave/anchors") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let v: Value = serde_json::from_slice(&bytes).expect("json body"); + let anchor = v["anchors"][0].clone(); + assert_eq!( + anchor["arweave_tx_id"], + "f".repeat(43), + "the durable tx id must still be listable" + ); + assert!( + anchor["arweave_url"].is_null(), + "with no gateway the arweave_url must be omitted, got: {}", + anchor["arweave_url"] + ); + } + + /// #224 review: `?limit=0` must behave like the parameter being absent + /// (the serde default of 50), not like `?limit=1`. The old + /// `q.limit.clamp(1, 200)` collapsed 0 to 1, silently narrowing the + /// listing; the fix routes sub-1 values through `default_limit()`. + #[sqlx::test] + async fn list_anchors_limit_zero_uses_default_limit(pool: PgPool) { + use clap::Parser as _; + + let mut state = crate::test_support::test_state(pool.clone()).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--arweave-gateway", + "https://arweave.net", + ])); + + // Seed three distinct transitions. + for (ref_name, old_sha, new_sha) in [ + ("refs/heads/main", "a".repeat(40), "b".repeat(40)), + ("refs/heads/dev", "c".repeat(40), "d".repeat(40)), + ("refs/tags/v1", "e".repeat(40), "f".repeat(40)), + ] { + state + .db + .record_arweave_anchor(&crate::db::RecordAnchorInputV2 { + repo: "alice/myrepo", + owner_did: "did:key:zAlice", + ref_name, + old_sha: &old_sha, + new_sha: &new_sha, + cid: Some("bafy1test"), + arweave_tx_id: &"f".repeat(43), + node_did: "did:key:zNode", + cert_id: None, + }) + .await + .unwrap(); + } + + let resp = Router::new() + .route("/api/v1/arweave/anchors", axum::routing::get(list_anchors)) + .with_state(state) + .oneshot( + Request::builder() + .uri("/api/v1/arweave/anchors?limit=0") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body"); + let v: Value = serde_json::from_slice(&bytes).expect("json body"); + assert_eq!( + v["count"], 3, + "limit=0 must fall back to the default limit, not clamp to 1" + ); + } } diff --git a/crates/gitlawb-node/src/api/certs.rs b/crates/gitlawb-node/src/api/certs.rs index 0d954cb1..dbf60c67 100644 --- a/crates/gitlawb-node/src/api/certs.rs +++ b/crates/gitlawb-node/src/api/certs.rs @@ -52,6 +52,12 @@ pub async fn list_certs( "node_did": c.node_did, "signature": c.signature, "issued_at": c.issued_at, + "seq": c.seq, + "prev": c.prev, + "pusher_sig": c.pusher_sig, + "signature_input": c.signature_input, + "content_digest": c.content_digest, + "request_path": c.request_path, }) }) .collect(); @@ -92,5 +98,11 @@ pub async fn get_cert( "node_did": cert.node_did, "signature": cert.signature, "issued_at": cert.issued_at, + "seq": cert.seq, + "prev": cert.prev, + "pusher_sig": cert.pusher_sig, + "signature_input": cert.signature_input, + "content_digest": cert.content_digest, + "request_path": cert.request_path, }))) } diff --git a/crates/gitlawb-node/src/api/events.rs b/crates/gitlawb-node/src/api/events.rs index 1158f47e..07224e08 100644 --- a/crates/gitlawb-node/src/api/events.rs +++ b/crates/gitlawb-node/src/api/events.rs @@ -241,17 +241,23 @@ pub async fn list_repo_events( .iter() .map(|c| { serde_json::json!({ - "type": "local_cert", - "id": c.id, - "repo": repo_id_str, - "ref_name": c.ref_name, - "old_sha": c.old_sha, - "new_sha": c.new_sha, - "pusher_did": c.pusher_did, - "node_did": c.node_did, - "timestamp": c.issued_at, - "owner_did": record.owner_did, - "source": "local", + "type": "local_cert", + "id": c.id, + "repo": repo_id_str, + "ref_name": c.ref_name, + "old_sha": c.old_sha, + "new_sha": c.new_sha, + "pusher_did": c.pusher_did, + "node_did": c.node_did, + "timestamp": c.issued_at, + "seq": c.seq, + "prev": c.prev, + "pusher_sig": c.pusher_sig, + "signature_input": c.signature_input, + "content_digest": c.content_digest, + "request_path": c.request_path, + "owner_did": record.owner_did, + "source": "local", }) }) .collect(); @@ -426,6 +432,13 @@ mod ref_updates_feed_tests { .with_state(state) } + use std::sync::atomic::{AtomicI64, Ordering}; + static NEXT_FCERT_SEQ: AtomicI64 = AtomicI64::new(1); + + fn ref_cert_seq() -> i64 { + NEXT_FCERT_SEQ.fetch_add(1, Ordering::Relaxed) + } + fn ref_cert(id: &str, repo_id: &str) -> RefCertificate { RefCertificate { id: id.into(), @@ -437,6 +450,12 @@ mod ref_updates_feed_tests { node_did: "did:key:z6MkNode".into(), signature: "sig".into(), issued_at: Utc::now().to_rfc3339(), + seq: ref_cert_seq(), + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, } } diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index b09cb6da..263eb350 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -5,7 +5,7 @@ use axum::Json; use bytes::Bytes; use std::sync::Arc; -use crate::auth::{caller_authorized_to_push, AuthenticatedDid}; +use crate::auth::{caller_authorized_to_push, AuthenticatedDid, PusherProof, PusherSignature}; use crate::db::RepoRecord; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -858,6 +858,8 @@ struct EncryptTaskCtx { owner_did: String, repo_name: String, irys_url: String, + bundler_account: String, + bundler_token: String, http_client: Arc, node_did: String, node_keypair: Arc, @@ -1248,7 +1250,10 @@ async fn pin_and_encrypt_objects( match crate::arweave::anchor_encrypted_manifest( &ctx.http_client, &ctx.irys_url, + &ctx.bundler_account, + &ctx.bundler_token, &manifest, + &ctx.node_keypair, ) .await { @@ -1739,10 +1744,13 @@ async fn notify_peer_of_refs( } /// POST /:owner/:repo.git/git-receive-pack (AUTH REQUIRED — enforced by middleware) +#[allow(clippy::too_many_arguments)] pub async fn git_receive_pack( State(state): State, Path((owner, repo)): Path<(String, String)>, Extension(auth): Extension, + Extension(pusher_sig): Extension, + Extension(pusher_proof): Extension, crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr, headers: axum::http::HeaderMap, body: Bytes, @@ -1990,13 +1998,18 @@ pub async fn git_receive_pack( // The tail is read-only on `disk_path` (walk plus plumbing) and takes neither the // write lease nor the advisory lock, so running it concurrently with the upload // below waits on nothing this handler still holds. Everything after (touch_repo, - // metrics, trust score, certificates, webhooks) stays in the cancellable handler. + // metrics, webhooks) stays in the cancellable handler. // - // The tail also runs CONCURRENTLY with certificate issuance rather than after it, - // so a ref can be announced before its signed certificate exists. That window is - // accepted: cert issuance already fails open (errors are logged and skipped) and - // the gossip event carries `cert_id: None` regardless, so no announce consumer - // reads a certificate out of it. Each push owns its own tail, including its own + // The durable-success bookkeeping — record_push, trust score, and the per-ref + // signed certificates — also runs INSIDE the continuation, not here: it used to + // live in the cancellable handler between `receive_pack` returning Ok and the + // tail spawn, so a client/proxy disconnect during those DB awaits dropped a + // durable push with no certificates and no tail. Certificate issuance runs at + // the START of the continuation, so the tail always has the per-ref signed + // certificates in hand: the gossip event carries the real `cert_id`, and the + // Arweave anchor embeds the certificate itself. Issuance fails open (errors are + // logged and skipped), so a cert outage degrades to a cert-less announce rather + // than a dropped push. Each push owns its own tail, including its own // always-spawned announce, so per-push announcements are never coalesced away. // // ACCEPTED RESIDUAL, and it is the cost of this ordering: the tail also runs @@ -2012,14 +2025,69 @@ pub async fn git_receive_pack( // would return 200 to the pusher before the durable copy lands, which is a larger // change to the client contract than the window it closes. let push_succeeded = receive_result.is_ok(); + + // The route is behind `require_signature`, so the verified pusher identity is + // always present; use it directly rather than re-parsing the headers. + let did = auth.0.as_str(); if push_succeeded { - tokio::spawn(post_receive_replication_tail( - state.clone(), - record.clone(), - ref_updates.clone(), - disk_path.clone(), - auth.0.to_string(), - )); + // Everything a landed push owes after git accepted the pack — record_push, + // trust score, per-ref signed certificates, and the replication tail — runs + // inside a DURABLE POST-RECEIVE JOB, not a bare spawned task. The job is + // persisted BEFORE the response is returned: Tokio cancels spawned tasks on + // restart/shutdown, so a continuation spawned only in memory left the window + // open where a crash between the pack landing and the bookkeeping reaching + // record_push/cert dropped a durable push with no cert, accounting, anchor, + // or replication and no recovery record. Once the job row is durable the + // effects can be replayed idempotently after a crash (see + // `process_post_receive_job` and the startup drain in main). + // + // The enqueue itself is NOT skippable: if it fails, the pack is on disk but + // its post-receive work has no recovery record, so acknowledging the push + // would be a lie. Refuse the 200 (the client/operator can investigate) and + // release the lock exactly like the error path below does. The ordinary + // failure here is a DB write failing while other paths still work — rare, + // and returning 500 is the honest outcome; retrying the push will not + // re-derive the ref updates (git sees them as already applied), so the + // operator must treat a 500 as "the push landed but was not recorded". + let job = crate::db::PostReceiveJob { + id: Uuid::new_v4().to_string(), + pusher_did: did.to_string(), + owner_did: record.owner_did.clone(), + repo_name: record.name.clone(), + repo_id: record.id.clone(), + ref_updates: ref_updates + .iter() + .map(|u| crate::db::JobRefUpdate { + old_sha: u.old_sha.clone(), + new_sha: u.new_sha.clone(), + ref_name: u.ref_name.clone(), + }) + .collect(), + attestation: crate::db::PostReceiveAttestation { + sig: Some(pusher_sig.0.clone()), + signature_input: Some(pusher_proof.signature_input.clone()), + content_digest: Some(pusher_proof.content_digest.clone()), + request_path: Some(pusher_proof.request_path.clone()), + }, + status: "pending".to_string(), + enqueued_at: chrono::Utc::now().to_rfc3339(), + attempts: 0, + error: None, + }; + if let Err(e) = state.db.enqueue_post_receive_job(&job).await { + tracing::error!( + repo = %name, + err = %e, + "failed to persist post-receive job — the pack landed but its \ + bookkeeping has no recovery record; refusing the push success" + ); + guard.release(push_succeeded).await; + drop(lease); + return Err(AppError::Internal(anyhow::anyhow!( + "push landed but the node could not record its post-receive work" + ))); + } + tokio::spawn(process_post_receive_job(state.clone(), job)); } // Always release the advisory lock — even on error — to prevent stale locks @@ -2053,49 +2121,6 @@ pub async fn git_receive_pack( crate::metrics::record_push(&record.id); crate::metrics::observe_pack_size(body_len as f64); - // Record push event for trust score and issue a signed ref certificate. - // The route is behind `require_signature`, so the verified pusher identity is - // always present; use it directly rather than re-parsing the headers. - let did = auth.0.as_str(); - { - // Use the first new commit hash we parsed, fall back to timestamp - let commit_hash = ref_updates - .first() - .map(|u| u.new_sha.clone()) - .unwrap_or_else(|| Utc::now().timestamp().to_string()); - - let _ = state.db.record_push(did, &record.id, &commit_hash, 0).await; - if let Ok(push_count) = state.db.get_push_count(did).await { - // 0.05 base (from registration) + 0.05 per push, capped at 1.0 - // 1 push → 0.10, 5 pushes → 0.30, 19 pushes → 1.0 - let new_score = (push_count as f64 * 0.05 + 0.05).min(1.0); - let _ = state.db.update_trust_score(did, new_score).await; - } - - // Issue a signed certificate for every ref this push advanced, each - // carrying that ref's real old→new transition. A multi-ref push must - // not collapse to a single cert covering only the first ref. - for update in &ref_updates { - match cert::issue_ref_certificate( - &state, - &record.id, - &update.ref_name, - &update.old_sha, - &update.new_sha, - did, - ) - .await - { - Ok(c) => { - tracing::info!(cert_id = %c.id, repo = %record.name, ref_name = %update.ref_name, pusher = %did, "issued ref certificate") - } - Err(e) => { - tracing::warn!(err = %e, ref_name = %update.ref_name, "failed to issue ref certificate") - } - } - } - } - // Fire push webhooks — one per ref update if !ref_updates.is_empty() { let base_url = state @@ -2137,6 +2162,540 @@ pub async fn git_receive_pack( Ok(result) } +/// Deterministic certificate id for a (job, ref) pair (#224): the same job +/// replayed after a restart must mint the SAME id so `insert_ref_certificate_tx` +///'s `ON CONFLICT (id) DO NOTHING` turns the replay into a no-op instead of a +/// second certificate for the same transition. Any collision-resistant hash of +/// the job id + ref is sufficient; sha256 hex is used (the column is TEXT, not +/// a UUID type). +fn deterministic_cert_id(job_id: &str, ref_name: &str) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(b"post-receive/"); + h.update(job_id.as_bytes()); + h.update(b"/"); + h.update(ref_name.as_bytes()); + hex::encode(h.finalize()) +} + +/// Process a durable post-receive job (#224). Everything a landed push owes +/// after git accepted the pack — the trust-score `record_push`, the per-ref +/// signed certificates, and the replication tail — runs here, driven from the +/// `post_receive_jobs` row rather than from the request handler. The handler +/// enqueued the job BEFORE acking the push, so a crash or restart between the +/// pack landing and these effects is recovered by the startup drain in main, +/// which resets stale rows to `pending` and re-runs this function. Every effect +/// is idempotent, so a replay is safe: +/// +/// - `record_push_job` keys the `push_events` row on the job id with +/// `ON CONFLICT (id) DO NOTHING`, so a replay never double-counts the push. +/// - certificate ids are deterministic per (job, ref) (above), and +/// `insert_ref_certificate_tx` skips ids that already exist. +/// - the Arweave anchor upload is skipped when this exact transition already +/// has a recorded anchor (`arweave_anchor_exists`), so a replay does not mint +/// a second permanent on-chain artifact for the same transition. +/// - the replication tail re-announces, which is the same per-push per-ref +/// work the original run did — a replay is no worse than the original. +/// +/// The job's DB status marks progress (`processing` → `done`/`failed`); a +/// restart is the retry policy, matching the durable-queue pattern used +/// elsewhere. This task is spawned by the handler on success and by the startup +/// drain for every row a previous process left pending. +pub(crate) async fn process_post_receive_job(state: AppState, job: crate::db::PostReceiveJob) { + // Conditional claim: exactly one worker may run a job. A concurrent drainer + // (or a handler + drainer racing on the same row) loses the UPDATE and must + // not run the body — otherwise two workers would each attempt the paid + // anchor for the same transition (#224 review). + match state.db.claim_post_receive_job(&job.id).await { + Ok(true) => {} + Ok(false) => { + tracing::info!( + job_id = %job.id, + "post-receive job already claimed by another worker; skipping" + ); + return; + } + Err(e) => { + tracing::error!(job_id = %job.id, err = %e, "failed to claim post-receive job"); + return; + } + } + + match run_post_receive_job(&state, &job).await { + Ok(()) => { + if let Err(e) = state + .db + .update_post_receive_job(&job.id, "done", None) + .await + { + tracing::error!(job_id = %job.id, err = %e, "failed to mark post-receive job done"); + } + } + Err(e) => { + tracing::error!(job_id = %job.id, err = %e, "post-receive job failed"); + if let Err(mark_err) = state + .db + .update_post_receive_job(&job.id, "failed", Some(&e.to_string())) + .await + { + tracing::error!( + job_id = %job.id, + err = %mark_err, + "failed to mark post-receive job failed" + ); + } + } + } +} + +/// The durable job's body, factored out of `process_post_receive_job` so the +/// status transitions above stay visible next to the work they bookend. +async fn run_post_receive_job( + state: &AppState, + job: &crate::db::PostReceiveJob, +) -> anyhow::Result<()> { + // The RepoRecord is re-read from the DB rather than captured: the durable + // job may run after a restart, when the handler's in-memory record is gone. + let record = state + .db + .get_repo_by_id(&job.repo_id) + .await? + .ok_or_else(|| anyhow::anyhow!("repo {} vanished for post-receive job", job.repo_id))?; + // The local copy the original push wrote is exactly what a replay should + // read. `local_path` never touches Tigris or the network (unlike + // `acquire_fresh`, which would re-download), and the job's repo was written + // locally by that push moments earlier. + let (_, disk_path) = state + .repo_store + .local_path(&job.owner_did, &job.repo_name)?; + + let ref_updates: Vec = job + .ref_updates + .iter() + .map(|u| RefUpdate { + old_sha: u.old_sha.clone(), + new_sha: u.new_sha.clone(), + ref_name: u.ref_name.clone(), + }) + .collect(); + + let did = &job.pusher_did; + + // Use the first new commit hash we parsed, fall back to timestamp + let commit_hash = ref_updates + .first() + .map(|u| u.new_sha.clone()) + .unwrap_or_else(|| Utc::now().timestamp().to_string()); + + // Idempotent accounting: the push_events row is keyed on the job id, so a + // startup replay of this job is a no-op rather than a double-counted push + // that would inflate the pusher's trust score. + state + .db + .record_push_job(&job.id, did, &record.id, &commit_hash, 0) + .await?; + if let Ok(push_count) = state.db.get_push_count(did).await { + // 0.05 base (from registration) + 0.05 per push, capped at 1.0 + // 1 push → 0.10, 5 pushes → 0.30, 19 pushes → 1.0 + let new_score = (push_count as f64 * 0.05 + 0.05).min(1.0); + let _ = state.db.update_trust_score(did, new_score).await; + } + + // Issue a signed certificate for every ref this push advanced, each + // carrying that ref's real old→new transition. A multi-ref push must + // not collapse to a single cert covering only the first ref. + // + // A certificate is a REQUIRED durable output of a landed push: issuance + // failure (a transient insert/sequence/DB error) must fail the job so the + // startup drain retries it, never a warning followed by `done` that would + // lose the cert and its anchor permanently (#224 review). Retries re-issue + // the same deterministic cert id, which `insert_ref_certificate_tx`'s + // `ON CONFLICT (id) DO NOTHING` turns into a no-op. + let mut ref_certs: std::collections::HashMap = + std::collections::HashMap::new(); + for update in &ref_updates { + let cert_id = deterministic_cert_id(&job.id, &update.ref_name); + let cert = cert::issue_ref_certificate( + state, + &record.id, + &update.ref_name, + &update.old_sha, + &update.new_sha, + did, + &cert_id, + job.attestation.sig.clone(), + job.attestation.signature_input.clone(), + job.attestation.content_digest.clone(), + job.attestation.request_path.clone(), + ) + .await + .map_err(|e| { + anyhow::anyhow!( + "failed to issue ref certificate for {}/{}: {e} — the job stays retryable \ + so the startup drain re-issues it", + record.name, + update.ref_name + ) + })?; + tracing::info!(cert_id = %cert.id, repo = %record.name, ref_name = %update.ref_name, pusher = %did, "issued ref certificate"); + ref_certs.insert(update.ref_name.clone(), cert); + } + + // The replication tail's spawned task re-derives the announce decision and + // the pin's sha→CID map; the durable Arweave anchoring unit below needs + // both. The tail reports them over a oneshot once the pin has landed, so + // the job body only anchors after the pinned objects (and their CIDs) + // exist — the anchor embeds the real CID, and that is part of the job's + // durability scope. The tail itself stays best-effort: it also does the + // lower-priority gossip / GraphQL / peer-notify steps, none of which gate + // this job's completion. + let (anchor_cid_tx, anchor_cid_rx) = tokio::sync::oneshot::channel(); + post_receive_replication_tail( + state.clone(), + record.clone(), + ref_updates.clone(), + disk_path, + did.to_string(), + ref_certs.clone(), + anchor_cid_tx, + ) + .await; + + // Durable Arweave anchoring (#224 review): awaited so the job only reaches + // `done` after every anchor's upload AND DB row are on record. A tail that + // dies before reporting (panic) drops the sender; that is a job failure, + // not a silent skip, so the startup drain retries it. + let (announce, cid_map) = anchor_cid_rx + .await + .map_err(|_| anyhow::anyhow!("replication tail died before reporting announce/CID"))?; + // The anchor's issuer is the NODE, not the pusher: `verify_anchor` compares + // the anchor's outer node_did against the embedded certificate's issuer and + // rejects a mismatch, and the certificate is signed with `state.node_keypair`. + // `job.pusher_did` belongs only in the pusher/provenance fields (#224 review). + anchor_ref_updates( + state, + &record, + &ref_updates, + &ref_certs, + announce, + &cid_map, + &state.node_did.to_string(), + ) + .await?; + Ok(()) +} + +/// Durable Arweave anchoring for a post-receive job (#224 review): one awaited +/// unit of work per ref transition, so the job only reaches `done` after every +/// anchor's upload AND its DB row are on record. This is the part of the +/// replication tail that the durability contract covers — Pinata pins, gossip, +/// GraphQL broadcast, and peer notify are explicitly best-effort and outside it. +/// +/// The anchor row is a per-transition outbox/state machine, not a retry wrapper +/// around an HTTP call: +/// +/// - `claim` — an atomic `INSERT ... ON CONFLICT DO NOTHING` against the unique +/// (repo, ref_name, old_sha, new_sha) transition index creates the durable +/// claim BEFORE any paid upload is attempted. Competing workers converge: +/// exactly one INSERT wins, so exactly one worker can ever pay for a given +/// transition. A `recorded` claim is a replay of an already-anchored job and +/// skips the upload entirely. +/// - `prepare` — the signed item is built and its deterministic ANS-104 id +/// (`base64url(sha256(item))`) is persisted on the row BEFORE the request is +/// sent. That id is the durable request identity a crash-recovery probes. +/// - `upload` — the outcome is classified. A definitive provider rejection +/// marks the row `failed` (safe to re-upload later); a connection drop or a +/// malformed success marks nothing and leaves the row `uploading` because the +/// item MAY have been accepted. +/// - `record` — the accepted transaction id is persisted (`recorded`, the +/// terminal state) and `item_id` becomes the id the gateway resolves. +/// +/// Recovery of a non-terminal claim (`pending`/`uploading`/`failed`): if the row +/// carries an `item_id`, the gateway is probed for it BEFORE any re-upload. +/// Present → the earlier upload landed and is recorded as-is (no second paid +/// request); absent → it did not land, re-upload is safe; a probe that cannot +/// reach a verdict fails the job without uploading (fail-closed, no double-pay). +/// A row with no `item_id` was never prepared/sent, so a fresh upload is safe. +async fn anchor_ref_updates( + state: &AppState, + record: &crate::db::RepoRecord, + ref_updates: &[RefUpdate], + ref_certs: &std::collections::HashMap, + announce: bool, + cid_map: &std::collections::HashMap, + node_did: &str, +) -> anyhow::Result<()> { + // Arweave permanent anchoring — suppressed for repos the public cannot read + // (public permanent ledger). `announce` is the same fail-closed decision the + // replication tail produced (re-derived for coalesced pushes, false when the + // walk failed or the repo is not listable at root). + let bundler_url = &state.config.bundler_url; + if !announce || bundler_url.is_empty() { + return Ok(()); + } + let repo_slug = format!( + "{}/{}", + crate::db::normalize_owner_key(&record.owner_did), + record.name + ); + let bundler_account = &state.config.bundler_account; + let bundler_token = &state.config.bundler_token; + for update in ref_updates { + let cid = cid_map.get(&update.new_sha).cloned(); + // Use the per-update certificate issued above, not a repo-wide latest, + // so each anchor embeds the exact certificate for its own ref + // transition. Issuance failure already fails the job before this point; + // a missing cert here is a hard error, never a silent skip — anchoring + // without a cert would publish an artifact verify_anchor must reject. + let cert = match ref_certs.get(&update.ref_name) { + Some(c) => c.clone(), + None => { + return Err(anyhow::anyhow!( + "no certificate was issued for {}/{} — refusing to anchor an \ + unverifiable transition", + repo_slug, + update.ref_name + )); + } + }; + let claim_token = uuid::Uuid::new_v4().to_string(); + let claimed_at = chrono::Utc::now().to_rfc3339(); + // Atomic claim BEFORE any paid upload. This is the durable per-transition + // outbox state; the unique transition index makes concurrent workers + // converge on a single owner for the upload obligation. + let claim = state + .db + .claim_anchor_claim(&crate::db::ClaimAnchorInput { + repo: &repo_slug, + owner_did: &record.owner_did, + ref_name: &update.ref_name, + old_sha: &update.old_sha, + new_sha: &update.new_sha, + cid: cid.as_deref(), + node_did, + cert_id: Some(&cert.id), + claim_token: &claim_token, + claimed_at: &claimed_at, + }) + .await + .map_err(|e| { + anyhow::anyhow!( + "cannot claim arweave anchor for {}/{}: {e}", + repo_slug, + update.ref_name + ) + })?; + let claim_id = match claim { + crate::db::AnchorClaim::AlreadyRecorded => { + tracing::debug!( + repo = %repo_slug, + ref_name = %update.ref_name, + "skipping arweave anchor — transition already recorded" + ); + continue; + } + crate::db::AnchorClaim::Claimed { id } => id, + crate::db::AnchorClaim::Recover { + id, + state: recovered_state, + item_id, + } => { + tracing::debug!( + repo = %repo_slug, + ref_name = %update.ref_name, + state = %recovered_state, + "recovering non-terminal arweave anchor claim" + ); + // A previous attempt of this same transition did not reach + // `recorded`. Reconcile BEFORE any re-upload: an `item_id` that + // is already on the gateway means the earlier upload landed and + // we must not pay for a second artifact. + match item_id { + None => id, + Some(persisted_item) => { + match crate::arweave::anchor_item_present( + &state.http_client, + &state.config.arweave_gateway, + &persisted_item, + ) + .await + { + Ok(true) => { + state + .db + .record_claimed_anchor(&id, &persisted_item) + .await + .map_err(|e| { + anyhow::anyhow!( + "recovered arweave anchor {persisted_item} for \ + {}/{} but could not persist it: {e}", + repo_slug, + update.ref_name + ) + })?; + tracing::info!( + tx_id = %persisted_item, + repo = %repo_slug, + ref_name = %update.ref_name, + "recovered already-uploaded arweave anchor without re-uploading" + ); + continue; + } + Ok(false) => id, + Err(e) => { + // Fail closed: the gateway could not be queried, + // so we cannot know whether an upload happened. + // Failing the job (no upload) keeps the drain + // retrying until the probe can be answered. + return Err(anyhow::anyhow!( + "cannot reconcile possibly-uploaded arweave anchor for \ + {}/{} (item {persisted_item}): {e} — failing the job \ + without uploading so no second paid artifact is created", + repo_slug, + update.ref_name + )); + } + } + } + } + } + }; + let anchor = crate::arweave::RefAnchor { + repo: repo_slug.clone(), + repo_id: record.id.clone(), + owner_did: record.owner_did.clone(), + ref_name: update.ref_name.clone(), + old_sha: update.old_sha.clone(), + new_sha: update.new_sha.clone(), + cid: cid.clone(), + timestamp: claimed_at.clone(), + node_did: node_did.to_string(), + certificate: Some(cert.clone()), + }; + // Build the signed item, persist its deterministic id, then send. + let item = + crate::arweave::build_ref_anchor_item(&anchor, &state.node_keypair).map_err(|e| { + anyhow::anyhow!( + "failed to build arweave anchor for {}/{}: {e}", + repo_slug, + update.ref_name + ) + })?; + let item_id = crate::ans104::data_item_id(&item); + state + .db + .set_anchor_uploading(&claim_id, &item_id) + .await + .map_err(|e| { + anyhow::anyhow!( + "cannot mark arweave anchor uploading for {}/{}: {e}", + repo_slug, + update.ref_name + ) + })?; + let outcome = crate::arweave::upload_ref_anchor_item( + &state.http_client, + bundler_url, + bundler_account, + bundler_token, + &item, + ) + .await + .map_err(|e| { + anyhow::anyhow!( + "arweave anchor upload for {}/{} could not be classified: {e}", + repo_slug, + update.ref_name + ) + })?; + let tx_id = match outcome { + crate::arweave::UploadOutcome::Accepted { tx_id } => tx_id, + crate::arweave::UploadOutcome::Rejected { message } => { + // The provider definitively did not accept the item; a later + // drain re-uploads. The row stays reserved for that drain. + let _ = state.db.set_anchor_failed(&claim_id).await; + return Err(anyhow::anyhow!( + "arweave anchor for {}/{} rejected by the bundler: {message} — if the \ + bundler reports 'Not enough balance', fund GITLAWB_BUNDLER_ACCOUNT \ + (for the token in GITLAWB_BUNDLER_TOKEN); an unfunded node retries \ + and loses anchors forever", + repo_slug, + update.ref_name + )); + } + crate::arweave::UploadOutcome::Uncertain { message } => { + // The outcome is unknown (connection drop or malformed success): + // the item MAY have been accepted. Leave the row `uploading` and + // fail the job; the drain's recovery probes the gateway and + // records without re-uploading if the item landed. + return Err(anyhow::anyhow!( + "arweave anchor for {}/{} has an unknown upload outcome: {message} — \ + the startup drain will probe the gateway before deciding whether \ + to re-upload", + repo_slug, + update.ref_name + )); + } + }; + // Upload accepted — persist the durable terminal state. A failed UPDATE + // is a FAILED UNIT OF WORK: the row stays `uploading` with its item_id, + // so the drain's recovery probes the gateway and records the already- + // landed item without paying for a second artifact. + state + .db + .record_claimed_anchor(&claim_id, &tx_id) + .await + .map_err(|e| { + anyhow::anyhow!( + "uploaded arweave anchor {tx_id} for {}/{} but could not persist it: {e}", + repo_slug, + update.ref_name + ) + })?; + tracing::info!( + tx_id, + repo = %repo_slug, + ref_name = %update.ref_name, + "recorded arweave anchor" + ); + } + Ok(()) +} + +/// Startup recovery (#224): replay post-receive jobs a previous process left +/// mid-flight. Called once from main right after the AppState is built, before +/// the HTTP listener serves traffic. +/// +/// Rows a previous process left `processing` or `failed` are reset to +/// `pending` — a fresh process has no in-flight jobs, so resetting is safe — +/// and every pending row is spawned through the same processor the handler +/// uses. Each durable effect is idempotent (`record_push_job` keys on the job +/// id, certificate ids are deterministic per (job, ref), the Arweave anchor is +/// gated on an existence check), so a replay completes exactly the accounting, +/// certificate, and anchor work the original run owed without double-counting, +/// double-issuing, or paying for a duplicate on-chain artifact. The rest of the +/// replication tail (Pinata pins, gossip, GraphQL broadcast, peer notify) is +/// best-effort and NOT recovered here. A drain that errors out is logged; the +/// unprocessed rows stay `pending` and are retried on the next restart (the job +/// table IS the retry policy). +pub(crate) async fn drain_post_receive_jobs(state: AppState) -> anyhow::Result { + state.db.reset_stale_post_receive_jobs().await?; + let pending = state.db.list_pending_post_receive_jobs().await?; + let count = pending.len(); + for job in pending { + tracing::info!( + job_id = %job.id, + repo_id = %job.repo_id, + "replaying post-receive job left by the previous process" + ); + tokio::spawn(process_post_receive_job(state.clone(), job)); + } + if count > 0 { + tracing::info!(jobs = count, "startup post-receive job drain scheduled"); + } + Ok(count) +} + /// The detached post-receive replication tail (#174 F2): everything a landed push /// still owes after its git response has been returned: the replication decision, /// the per-repo-coalesced pin/encrypt task, and this push's own Pinata + announce @@ -2148,6 +2707,8 @@ async fn post_receive_replication_tail( ref_updates: Vec, disk_path: std::path::PathBuf, did: String, + ref_certs: std::collections::HashMap, + anchor_cid_tx: tokio::sync::oneshot::Sender<(bool, std::collections::HashMap)>, ) { // Replication enforcement (Phase 2): decide once per push whether the public // may read this repo at all and, if so, which blob OIDs must not leave the @@ -2303,7 +2864,9 @@ async fn post_receive_replication_tail( repo_id: record.id.clone(), owner_did: record.owner_did.clone(), repo_name: record.name.clone(), - irys_url: state.config.irys_url.clone(), + irys_url: state.config.bundler_url.clone(), + bundler_account: state.config.bundler_account.clone(), + bundler_token: state.config.bundler_token.clone(), http_client: std::sync::Arc::clone(&state.http_client), node_did: state.node_did.to_string(), node_keypair: std::sync::Arc::clone(&state.node_keypair), @@ -2326,11 +2889,14 @@ async fn post_receive_replication_tail( // #174 P2-2 scope note: this SECOND detached spawn is deliberately NOT brought // under the per-repo encryption coalescing above, because unlike the idempotent // recovery-copy walk it does PER-PUSH, PER-REF work — branch→CID upserts, gossip - // publish, GraphQL subscription broadcast, Arweave anchoring, and peer notify, each - // keyed to THIS push's ref_updates. Coalescing (or shedding) it against an in-flight - // task for the same repo would DROP a later push's ref-update announcements (a - // correctness regression), not merely delay a duplicate. So the task stays one per - // push and every push's effects fire exactly once. + // publish, GraphQL subscription broadcast, and peer notify, each keyed to THIS + // push's ref_updates. Coalescing (or shedding) it against an in-flight task for + // the same repo would DROP a later push's ref-update announcements (a correctness + // regression), not merely delay a duplicate. So the task stays one per push and + // every push's effects fire exactly once. Arweave anchoring is NOT part of this + // spawn (#224 review): it is the durable, awaited unit in the job body, which + // consumes this task's announce/CID report over a oneshot. Everything this spawn + // does is best-effort and outside the post-receive job's durability contract. // // #174 F2 / KTD-3: {bounded memory, no dropped effects, no handler latency} are // jointly unsatisfiable by coalesce/shed/block, so instead of retaining the full @@ -2354,12 +2920,11 @@ async fn post_receive_replication_tail( .iter() .map(|u| (u.ref_name.clone(), u.old_sha.clone(), u.new_sha.clone())) .collect::>(); + let ref_certs_clone = ref_certs.clone(); let p2p_handle = state.p2p.clone(); let pusher_did_clone = did.to_string(); let db_for_peers = state.db.clone(); let ref_update_tx = state.ref_update_tx.clone(); - let irys_url = state.config.irys_url.clone(); - let owner_did_for_arweave = record.owner_did.clone(); let self_public_url = state.config.public_url.clone(); let node_keypair = Arc::clone(&state.node_keypair); // #174 F2a: gated on the cheap announce predicate, not on `withheld`. @@ -2448,6 +3013,15 @@ async fn post_receive_replication_tail( // Build sha→cid map from pinned objects let cid_map: std::collections::HashMap = pinned.into_iter().collect(); + // Report the announce decision and pin CID map to the durable + // Arweave anchoring unit in the job body. Sent before the + // lower-priority, best-effort steps below (gossip, GraphQL + // broadcast, peer notify) so the job does not wait on them; they + // are outside the job's durability scope. A panic before this + // point drops the sender, and the job body treats that as a + // failure to be retried by the next startup drain. + let _ = anchor_cid_tx.send((announce, cid_map.clone())); + // Record branch→CID for each ref update and publish gossip for (ref_name, old_sha, new_sha) in &ref_updates_clone { let cid = cid_map.get(new_sha).map(|s| s.as_str()); @@ -2460,6 +3034,9 @@ async fn post_receive_replication_tail( if announce { if let Some(p2p) = &p2p_handle { + // Publish the exact cert issued for this ref transition so + // peers can resolve the anchored certificate by id. + let cert_id = ref_certs_clone.get(ref_name).map(|c| c.id.clone()); p2p.publish_ref_update(crate::p2p::RefUpdateEvent { node_did: node_did_str.clone(), pusher_did: pusher_did_clone.clone(), @@ -2469,7 +3046,7 @@ async fn post_receive_replication_tail( old_sha: old_sha.clone(), new_sha: new_sha.clone(), timestamp: chrono::Utc::now().to_rfc3339(), - cert_id: None, + cert_id, cid: cid.map(|s| s.to_string()), }) .await; @@ -2501,47 +3078,6 @@ async fn post_receive_replication_tail( } } - // Arweave permanent anchoring — fire for each ref update. - // Suppressed for repos the public cannot read (public permanent ledger). - if announce && !irys_url.is_empty() { - for (ref_name, old_sha, new_sha) in &ref_updates_clone { - let cid = cid_map.get(new_sha).cloned(); - let anchor = crate::arweave::RefAnchor { - repo: repo_slug.clone(), - owner_did: owner_did_for_arweave.clone(), - ref_name: ref_name.clone(), - old_sha: old_sha.clone(), - new_sha: new_sha.clone(), - cid: cid.clone(), - timestamp: now_ts.clone(), - node_did: node_did_str.clone(), - }; - match crate::arweave::anchor_ref_update(&http_client, &irys_url, &anchor).await - { - Ok(tx_id) if !tx_id.is_empty() => { - let arweave_url = crate::arweave::arweave_url(&tx_id); - let _ = db_clone - .record_arweave_anchor(&crate::db::RecordAnchorInput { - repo: &repo_slug, - owner_did: &owner_did_for_arweave, - ref_name, - old_sha, - new_sha, - cid: cid.as_deref(), - irys_tx_id: &tx_id, - arweave_url: &arweave_url, - node_did: &node_did_str, - }) - .await; - } - Ok(_) => {} - Err(e) => { - tracing::warn!(repo=%repo_slug, err=%e, "Arweave anchor failed") - } - } - } - } - // HTTP peer notification — notify all known peers to pull from us. // This is the reliable fallback when Gossipsub p2p is not yet connected. // Suppressed for repos the public cannot read. Runs last so a slow or @@ -5632,6 +6168,12 @@ mod tests { State(state.clone()), Path(("z6rp4wr".to_string(), "rp4".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(capped)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -5650,6 +6192,12 @@ mod tests { State(state.clone()), Path(("z6rp4wr".to_string(), "rp4".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(other)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -5753,6 +6301,12 @@ mod tests { State(state_for_task), Path((owner.to_string(), name.to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -5840,6 +6394,12 @@ mod tests { State(state.clone()), Path((owner.to_string(), name.to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some("203.0.113.62:5000".parse().unwrap())), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -6369,6 +6929,12 @@ mod tests { State(state), Path((owner.to_string(), name.to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(peer.parse::().unwrap())), axum::http::HeaderMap::new(), ref_update_body(new_sha), @@ -6463,6 +7029,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6MkF4FastPusherAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), )), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -6523,6 +7095,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6MkF4ParkPusherAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), )), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), ref_update_body("2222222222222222222222222222222222222222"), @@ -7011,6 +7589,8 @@ mod tests { owner_did: rec.owner_did.clone(), repo_name: rec.name.clone(), irys_url: String::new(), + bundler_account: String::new(), + bundler_token: String::new(), http_client: std::sync::Arc::clone(&state.http_client), node_did: state.node_did.to_string(), node_keypair: std::sync::Arc::clone(&state.node_keypair), @@ -7748,6 +8328,12 @@ mod tests { State(state.clone()), Path(("z6f3repo".to_string(), "r1".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some("203.0.113.81:5000".parse::().unwrap())), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -7776,6 +8362,12 @@ mod tests { State(state_b), Path(("z6f3repo".to_string(), "r1".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some( "203.0.113.82:5000".parse::().unwrap(), )), @@ -7872,6 +8464,12 @@ mod tests { State(st), Path(("z6f3clean".to_string(), "c1".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(peer.parse::().unwrap())), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -7965,6 +8563,12 @@ mod tests { State(state.clone()), Path(("z6f3dos".to_string(), "d1".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some("203.0.113.71:5000".parse::().unwrap())), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -7992,6 +8596,12 @@ mod tests { State(state_b), Path(("z6f3dos".to_string(), "d1".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some( "203.0.113.72:5000".parse::().unwrap(), )), @@ -8176,6 +8786,12 @@ mod tests { State(st), Path(("z6u2key".to_string(), "k1".to_string())), Extension(crate::auth::AuthenticatedDid(did)), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8249,6 +8865,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6MkOverflowPusherAAAAAAAAAAAAAAAAAAAAAA".to_string(), )), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::().unwrap())), axum::http::HeaderMap::new(), ref_update_body("1111111111111111111111111111111111111111"), @@ -8303,6 +8925,12 @@ mod tests { State(st), Path(("z6u1cap".to_string(), "c1".to_string())), Extension(crate::auth::AuthenticatedDid(did)), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8408,6 +9036,12 @@ mod tests { State(st), Path(("z6u1two".to_string(), repo.to_string())), Extension(crate::auth::AuthenticatedDid(did)), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(src)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8489,6 +9123,12 @@ mod tests { State(st), Path(("z6u1nat".to_string(), repo.to_string())), Extension(crate::auth::AuthenticatedDid(pusher.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(edge)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8587,6 +9227,12 @@ mod tests { State(st), Path(("z6f1key".to_string(), repo.to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8644,6 +9290,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid( "did:key:z6MkF1NoKeyPusherAAAAAAAAAAAAAAAAAAAAAAA".to_string(), )), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(None), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8683,6 +9335,12 @@ mod tests { State(state.clone()), Path(("z6f1seq".to_string(), "s1".to_string())), Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some(src)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8777,6 +9435,22 @@ mod tests { }] } + /// Drive the replication tail with a throwaway announce/CID channel. Tests + /// that do not exercise the durable Arweave anchor unit (they never await + /// the receiver) discard both ends; the spawned Pinata task's report is a + /// no-op either way. + async fn f2a_tail( + state: AppState, + rec: crate::db::RepoRecord, + updates: Vec, + path: std::path::PathBuf, + did: String, + certs: std::collections::HashMap, + ) { + let (_tx, _rx) = tokio::sync::oneshot::channel(); + post_receive_replication_tail(state, rec, updates, path, did, certs, _tx).await; + } + const F2A_PUSHER: &str = "did:key:z6MkF2aPusherAAAAAAAAAAAAAAAAAAAAAAAAAA"; /// Scenario 1 (the finding). A second rapid push to the same repo coalesces @@ -8802,12 +9476,13 @@ mod tests { state.pin_semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(1)); let _held = state.pin_semaphore.clone().acquire_owned().await.unwrap(); - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), f2a_update("refs/heads/main", &c2), repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), ) .await; let after_first = f2a_walks(&log); @@ -8822,12 +9497,13 @@ mod tests { "the admitted push's task holds the repo key while it is parked on the pin pool" ); - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), f2a_update("refs/heads/second", &c1), repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), ) .await; @@ -8895,12 +9571,13 @@ mod tests { let held = state.pin_semaphore.clone().acquire_owned().await.unwrap(); // Push A is admitted; its task then parks on the held pin pool, key retained. - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), f2a_update("refs/heads/main", &c2), repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), ) .await; @@ -8982,6 +9659,8 @@ mod tests { f2a_update("refs/heads/main", &c2), repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), + tokio::sync::oneshot::channel().0, )); f2a_wait_for(|| started.exists(), "the admitted push's walk to start").await; @@ -9085,7 +9764,7 @@ mod tests { crate::state::BeginOutcome::Coalesced => panic!("the first begin must admit"), }; - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), vec![RefUpdate { @@ -9095,6 +9774,7 @@ mod tests { }], repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), ) .await; @@ -9191,6 +9871,12 @@ mod tests { State(state.clone()), Path((owner.to_string(), name.to_string())), Extension(crate::auth::AuthenticatedDid(P2_PUSHER.to_string())), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::().unwrap())), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -9361,7 +10047,7 @@ mod tests { crate::state::BeginOutcome::Coalesced => panic!("the first begin must admit"), }; - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), vec![RefUpdate { @@ -9371,6 +10057,7 @@ mod tests { }], repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), ) .await; @@ -9420,12 +10107,13 @@ mod tests { let (state, mut rec) = f2a_state(pool, &git_bin, "z6f2apriv", "v1", false).await; rec.is_public = false; - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), f2a_update("refs/heads/main", &c1), repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), ) .await; tokio::time::sleep(std::time::Duration::from_millis(300)).await; @@ -9457,7 +10145,7 @@ mod tests { let (_server, cid) = f2a_pinata(&mut state).await; let mut updates = state.ref_update_tx.subscribe(); - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), vec![RefUpdate { @@ -9467,6 +10155,7 @@ mod tests { }], repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), ) .await; @@ -9554,7 +10243,7 @@ mod tests { // Nothing pre-takes the coalescing key, so this push is ADMITTED and runs its // own walk. - post_receive_replication_tail( + f2a_tail( state.clone(), rec.clone(), vec![RefUpdate { @@ -9564,6 +10253,7 @@ mod tests { }], repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), ) .await; @@ -9601,4 +10291,856 @@ mod tests { "and the unvetted push still maps no CID" ); } + + // ---- #224 review, P1: the durable post-receive job survives a crash ---- + + /// A post-receive job enqueued by the handler survives the handler being + /// aborted (a client/proxy disconnect — or, harder, a process crash) between + /// the pack landing and the job's bookkeeping running. + /// + /// Before the fix, `record_push`, the trust-score update, and the per-ref + /// certificate issuance ran in the CANCELLABLE handler between `receive_pack` + /// returning Ok and the tail spawn; a disconnect during those DB awaits + /// dropped a durable push with no certificates and no tail. The fix makes the + /// job DURABLE: the handler persists the job row BEFORE acking the push, and + /// the startup drain replays rows a previous process left pending. + /// + /// This test drives the hardest shape of that fix: the simulated handler + /// enqueues the job, then is aborted BEFORE it even spawns the processor — + /// the crash-between-enqueue-and-spawn window. The startup drain + /// (`reset_stale_post_receive_jobs` + replay each pending row) must recover + /// it completely: the push row, the trust score, the per-ref certificate, and + /// the tail's withheld walk all land. Running the drain a second time must + /// not double-count the push (idempotent replay). + #[cfg(unix)] + #[sqlx::test] + async fn post_receive_job_survives_handler_abort(pool: sqlx::PgPool) { + let bin = tempfile::TempDir::new().unwrap(); + let log = bin.path().join("git.log"); + let git_bin = f2a_logging_git(bin.path(), &log); + let (mut state, rec) = f2a_state(pool.clone(), &git_bin, "z6abort", "c1", true).await; + // Point the store at a per-run temp dir: the shared `for_testing` /tmp + // layout persists between test runs, and a stale repo dir makes the + // fixture's `git commit` a no-op ("nothing to commit"). + let repos_dir = tempfile::TempDir::new().unwrap(); + state.repo_store = + crate::git::repo_store::RepoStore::for_testing(repos_dir.path().to_path_buf(), pool); + // The trust-score update only mutates an existing agents row (never + // inserts); register the pusher so the update is observable. + state + .db + .register_agent(F2A_PUSHER, &["agent".to_string()]) + .await + .unwrap(); + + // The durable job re-locates the repo via repo_store.local_path, so the + // repo must exist exactly where the store will look for it. + let (_, repo_path) = state + .repo_store + .local_path(&rec.owner_did, &rec.name) + .unwrap(); + std::fs::create_dir_all(&repo_path).unwrap(); + u5_init_repo(&repo_path); + let c1 = u5_commit_file(&repo_path, "a.txt", "one\n"); + + let update = f2a_update("refs/heads/main", &c1); + let job = crate::db::PostReceiveJob { + id: uuid::Uuid::new_v4().to_string(), + pusher_did: F2A_PUSHER.to_string(), + owner_did: rec.owner_did.clone(), + repo_name: rec.name.clone(), + repo_id: rec.id.clone(), + ref_updates: update + .iter() + .map(|u| crate::db::JobRefUpdate { + old_sha: u.old_sha.clone(), + new_sha: u.new_sha.clone(), + ref_name: u.ref_name.clone(), + }) + .collect(), + attestation: crate::db::PostReceiveAttestation::default(), + status: "pending".to_string(), + enqueued_at: chrono::Utc::now().to_rfc3339(), + attempts: 0, + error: None, + }; + + // Simulated handler: after `receive_pack` returned Ok it persists the + // job (the durability boundary) — then, BEFORE spawning the processor, + // it is aborted: the crash-between-enqueue-and-spawn window. The startup + // drain is the only thing that can recover this job. + let (sent, received) = tokio::sync::oneshot::channel(); + let job_for_handler = job.clone(); + let handler_sim = tokio::spawn({ + let state = state.clone(); + async move { + state + .db + .enqueue_post_receive_job(&job_for_handler) + .await + .unwrap(); + let _ = sent.send(()); + std::future::pending::<()>().await + } + }); + received.await.expect("handler enqueued the job"); + + // Sever the client: the handler never spawns the processor. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + handler_sim.abort(); + let _ = handler_sim.await; + + assert_eq!( + state.db.get_push_count(F2A_PUSHER).await.unwrap(), + 0, + "nothing has run yet — the job is pending and unprocessed" + ); + + // Startup drain: reset stale rows, then replay every pending row. + state.db.reset_stale_post_receive_jobs().await.unwrap(); + let pending = state.db.list_pending_post_receive_jobs().await.unwrap(); + assert_eq!(pending.len(), 1, "the enqueued job must be drained"); + for job in pending { + process_post_receive_job(state.clone(), job).await; + } + + assert_eq!( + state.db.get_push_count(F2A_PUSHER).await.unwrap(), + 1, + "the push must still be recorded after the crash" + ); + assert!( + (state.db.get_trust_score(F2A_PUSHER).await.unwrap() - 0.10).abs() < 1e-9, + "the trust-score update (0.05 base + 0.05 per push) must still land" + ); + let certs = state.db.list_ref_certificates(&rec.id, 10).await.unwrap(); + assert_eq!( + certs.len(), + 1, + "the per-ref certificate must still be issued after the crash" + ); + assert_eq!(certs[0].ref_name, "refs/heads/main"); + assert_eq!(certs[0].new_sha, c1); + assert_eq!(certs[0].pusher_did, F2A_PUSHER); + assert!( + f2a_walks(&log) >= 1, + "the replication tail's withheld walk must still run after the crash; log:\n{}", + f2a_log(&log) + ); + + // Idempotent replay: the job is `done`, so a second drain finds nothing + // pending, and even a forced re-run of the processor does not double-count + // the push (push_events is keyed on the job id with ON CONFLICT DO NOTHING). + let pending = state.db.list_pending_post_receive_jobs().await.unwrap(); + assert!( + pending.is_empty(), + "a processed job must not be drained a second time" + ); + process_post_receive_job(state.clone(), job.clone()).await; + assert_eq!( + state.db.get_push_count(F2A_PUSHER).await.unwrap(), + 1, + "replaying the job must not double-count the push" + ); + assert_eq!( + state + .db + .list_ref_certificates(&rec.id, 10) + .await + .unwrap() + .len(), + 1, + "replaying the job must not mint a second certificate" + ); + } + + // ---- #224 review, P4/P5: the Arweave anchor is a durable unit ---- + + /// A mock Irys bundler that counts uploads and fails a fixed number of the + /// first ones with 500 before succeeding. Returns the base URL and a call + /// counter. + async fn f2a_bundler( + fail_first: usize, + ) -> (String, std::sync::Arc) { + use axum::http::StatusCode; + use axum::response::IntoResponse; + use std::sync::atomic::Ordering; + + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let failures_left = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(fail_first)); + let app = { + let calls_srv = calls.clone(); + let failures_srv = failures_left.clone(); + axum::Router::new().route( + "/tx/{token}", + axum::routing::post(move || { + let calls = calls_srv.clone(); + let failures = failures_srv.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + if failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| { + if n > 0 { + Some(n - 1) + } else { + None + } + }) + .is_ok() + { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "simulated bundler outage", + ) + .into_response() + } else { + ( + StatusCode::OK, + axum::Json(serde_json::json!({"id": "f".repeat(43)})), + ) + .into_response() + } + } + }), + ) + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), calls) + } + + /// A mock Arweave gateway that answers item-presence probes (`GET + /// /{item_id}`). Modes: `present` (200 — the earlier upload landed), + /// `absent` (404 — it did not), `error` (500 — no verdict). Returns the base + /// URL and a probe counter. + async fn f2a_gateway( + mode: &'static str, + ) -> (String, std::sync::Arc) { + use axum::response::IntoResponse; + use std::sync::atomic::Ordering; + + let probes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let app = { + let probes_srv = probes.clone(); + axum::Router::new().route( + "/{item_id}", + axum::routing::get(move |path: axum::extract::Path| { + let probes = probes_srv.clone(); + async move { + probes.fetch_add(1, Ordering::SeqCst); + match mode { + "present" => ( + axum::http::StatusCode::OK, + axum::Json(serde_json::json!({ "id": path.0 })), + ) + .into_response(), + "absent" => (axum::http::StatusCode::NOT_FOUND, "{}").into_response(), + _ => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "simulated gateway outage", + ) + .into_response(), + } + } + }), + ) + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), probes) + } + + async fn f2a_job_status(pool: &sqlx::PgPool, job_id: &str) -> String { + sqlx::query_scalar::<_, String>("SELECT status FROM post_receive_jobs WHERE id = $1") + .bind(job_id) + .fetch_one(pool) + .await + .unwrap() + } + + fn f2a_anchor_record() -> crate::db::RepoRecord { + let now = chrono::Utc::now(); + crate::db::RepoRecord { + id: "repo-anchor-1".to_string(), + name: "myrepo".to_string(), + owner_did: "did:key:zAlice".to_string(), + description: None, + is_public: true, + default_branch: "main".to_string(), + created_at: now, + updated_at: now, + disk_path: "/tmp/myrepo".to_string(), + forked_from: None, + machine_id: None, + } + } + + fn f2a_anchor_update() -> RefUpdate { + RefUpdate { + old_sha: "a".repeat(40), + new_sha: "b".repeat(40), + ref_name: "refs/heads/main".to_string(), + } + } + + fn f2a_anchor_cert(record: &crate::db::RepoRecord) -> crate::db::RefCertificate { + crate::db::RefCertificate { + id: "cert-anchor-1".to_string(), + repo_id: record.id.clone(), + ref_name: "refs/heads/main".to_string(), + old_sha: "a".repeat(40), + new_sha: "b".repeat(40), + pusher_did: "did:key:zAlice".to_string(), + node_did: "did:key:zNode".to_string(), + signature: "sig".to_string(), + issued_at: chrono::Utc::now().to_rfc3339(), + seq: 1, + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, + } + } + + /// #224 review, P1-4: an accepted upload whose `recorded` transition cannot + /// be persisted is a FAILED unit of work — the job body returns `Err`, the + /// row is left `uploading` with its item id — and the drain's recovery + /// probes the gateway, finds the item present, and records it WITHOUT paying + /// for a second upload. The CHECK constraint blocks only the + /// `UPDATE ... SET state = 'recorded'` (the claim INSERT and the `uploading` + /// transition both stay allowed), so the failure lands exactly where the + /// real crash does. + #[sqlx::test] + async fn anchor_record_failure_is_reconciled_without_double_pay(pool: sqlx::PgPool) { + use clap::Parser as _; + + let (bundler_url, calls) = f2a_bundler(0).await; + let (gateway_url, probes) = f2a_gateway("present").await; + let mut state = crate::test_support::test_state(pool.clone()).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--bundler-url", + &bundler_url, + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + &gateway_url, + ])); + + // Block only the transition to `recorded` for this repo's slug: the + // claim INSERT (`state='pending'`) and the `uploading` UPDATE must both + // succeed so the failure lands exactly where the real crash does. + sqlx::query( + "ALTER TABLE arweave_anchors ADD CONSTRAINT anchor_test_block \ + CHECK (NOT (state = 'recorded' AND repo = 'zAlice/myrepo'))", + ) + .execute(&pool) + .await + .unwrap(); + + let record = f2a_anchor_record(); + let update = f2a_anchor_update(); + let mut certs = std::collections::HashMap::new(); + certs.insert("refs/heads/main".to_string(), f2a_anchor_cert(&record)); + let empty_cid = std::collections::HashMap::new(); + + // Run 1: the upload is accepted but the row cannot be recorded → Err, so + // the job body fails the job and the startup drain retries. The row is + // left `uploading` with its item id — the durable trace of the request. + let err = anchor_ref_updates( + &state, + &record, + std::slice::from_ref(&update), + &certs, + true, + &empty_cid, + "did:key:zNode", + ) + .await + .expect_err("an unrecordable accepted upload must fail the job body"); + assert!( + err.to_string().contains("could not persist"), + "the error must name the unpersisted upload: {err}" + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + let row_state: String = + sqlx::query_scalar("SELECT state FROM arweave_anchors WHERE repo = 'zAlice/myrepo'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + row_state, "uploading", + "the accepted-but-unrecorded upload must leave the row uploading" + ); + let item_id: String = + sqlx::query_scalar("SELECT item_id FROM arweave_anchors WHERE repo = 'zAlice/myrepo'") + .fetch_one(&pool) + .await + .unwrap(); + assert!( + !item_id.is_empty(), + "the unrecorded upload must still carry its persisted item id" + ); + + // Unblock; the drain-style retry finds a non-terminal claim, probes the + // gateway, sees the item, and records it without uploading again. + sqlx::query("ALTER TABLE arweave_anchors DROP CONSTRAINT anchor_test_block") + .execute(&pool) + .await + .unwrap(); + anchor_ref_updates( + &state, + &record, + std::slice::from_ref(&update), + &certs, + true, + &empty_cid, + "did:key:zNode", + ) + .await + .expect("the reconciled retry must succeed once the row can be recorded"); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "an item the gateway already has must not be uploaded a second time" + ); + assert_eq!( + probes.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the recovery must probe the gateway exactly once" + ); + assert!( + state + .db + .arweave_anchor_exists( + "zAlice/myrepo", + "refs/heads/main", + &"a".repeat(40), + &"b".repeat(40), + ) + .await + .unwrap(), + "the reconciled retry must record the anchor" + ); + + // Replay with the row recorded: the claim itself says AlreadyRecorded, so + // the bundler is NOT called again (no second paid on-chain artifact). + anchor_ref_updates( + &state, + &record, + std::slice::from_ref(&update), + &certs, + true, + &empty_cid, + "did:key:zNode", + ) + .await + .expect("an already-recorded transition is a no-op"); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "an already-recorded transition must not spend bundler balance again" + ); + } + + /// #224 review, P1-4: a worker that cannot even make its atomic claim (DB + /// down) must fail closed — the job body returns `Err`, the bundler is never + /// called, and nothing is uploaded while the durable state cannot be + /// consulted. + #[sqlx::test] + async fn anchor_claim_db_failure_never_uploads(pool: sqlx::PgPool) { + use clap::Parser as _; + + let (bundler_url, calls) = f2a_bundler(0).await; + let (gateway_url, _probes) = f2a_gateway("absent").await; + let mut state = crate::test_support::test_state(pool.clone()).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--bundler-url", + &bundler_url, + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + &gateway_url, + ])); + + let record = f2a_anchor_record(); + let update = f2a_anchor_update(); + let mut certs = std::collections::HashMap::new(); + certs.insert("refs/heads/main".to_string(), f2a_anchor_cert(&record)); + let empty_cid = std::collections::HashMap::new(); + + // Take the DB away: the claim can no longer be answered. + pool.close().await; + + let err = anchor_ref_updates( + &state, + &record, + std::slice::from_ref(&update), + &certs, + true, + &empty_cid, + "did:key:zNode", + ) + .await + .expect_err("an unclaimable anchor must fail the job body"); + assert!( + err.to_string().contains("cannot claim"), + "the error must name the unclaimable anchor: {err}" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "an unknown durable state must never trigger a paid upload" + ); + } + + /// #224 review, P1-4: a recovery probe that cannot reach a verdict (gateway + /// 500) fails closed — the job body returns `Err`, the row stays + /// non-terminal, and the bundler is NOT called, because an upload MAY have + /// landed and a second one would be a duplicate paid artifact. + #[sqlx::test] + async fn anchor_probe_failure_never_uploads(pool: sqlx::PgPool) { + use clap::Parser as _; + + let (bundler_url, calls) = f2a_bundler(0).await; + let (gateway_url, probes) = f2a_gateway("error").await; + let mut state = crate::test_support::test_state(pool.clone()).await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--bundler-url", + &bundler_url, + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + &gateway_url, + ])); + + let record = f2a_anchor_record(); + let update = f2a_anchor_update(); + let mut certs = std::collections::HashMap::new(); + certs.insert("refs/heads/main".to_string(), f2a_anchor_cert(&record)); + let empty_cid = std::collections::HashMap::new(); + + // Simulate a prior crash between "upload accepted" and "recorded": a + // non-terminal claim with a persisted item id. + let claim = state + .db + .claim_anchor_claim(&crate::db::ClaimAnchorInput { + repo: "zAlice/myrepo", + owner_did: "did:key:zAlice", + ref_name: "refs/heads/main", + old_sha: &"a".repeat(40), + new_sha: &"b".repeat(40), + cid: None, + node_did: "did:key:zNode", + cert_id: Some("cert-anchor-1"), + claim_token: "claim-token", + claimed_at: &chrono::Utc::now().to_rfc3339(), + }) + .await + .unwrap(); + let claim_id = match claim { + crate::db::AnchorClaim::Claimed { id } => id, + other => panic!("expected a fresh claim, got {other:?}"), + }; + state + .db + .set_anchor_uploading(&claim_id, "item-probe-123") + .await + .unwrap(); + + let err = anchor_ref_updates( + &state, + &record, + std::slice::from_ref(&update), + &certs, + true, + &empty_cid, + "did:key:zNode", + ) + .await + .expect_err("a probe that cannot reach a verdict must fail the job body"); + assert!( + err.to_string().contains("cannot reconcile"), + "the error must name the unresolved reconciliation: {err}" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 0, + "an unknown upload outcome must never pay for a second artifact" + ); + assert_eq!( + probes.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the failed job must still have probed the gateway once" + ); + let row_state: String = + sqlx::query_scalar("SELECT state FROM arweave_anchors WHERE repo = 'zAlice/myrepo'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + row_state, "uploading", + "an unresolved reconciliation must leave the row non-terminal, not recorded" + ); + } + + /// #224 review, P1-4 end-to-end: a post-receive job whose Arweave anchor + /// upload fails (bundler returns 500) is NOT terminal — the startup drain + /// retries it. The retry's recovery probes the gateway, sees the rejected + /// item was never indexed, re-uploads, and records the anchor; once the row + /// is recorded, replaying the job never re-calls the bundler. Also asserts + /// the stored anchor names the NODE as issuer (state.node_did), not the + /// pusher (#224 review, P1-2). Drives the same crash fixture as + /// `post_receive_job_survives_handler_abort`, with counting mocks. + #[cfg(unix)] + #[sqlx::test] + async fn post_receive_job_anchor_failure_retries_and_replay_never_reuploads( + pool: sqlx::PgPool, + ) { + use clap::Parser as _; + + let bin = tempfile::TempDir::new().unwrap(); + let log = bin.path().join("git.log"); + let git_bin = f2a_logging_git(bin.path(), &log); + let (mut state, rec) = f2a_state(pool.clone(), &git_bin, "z6anchor", "a1", true).await; + let repos_dir = tempfile::TempDir::new().unwrap(); + state.repo_store = crate::git::repo_store::RepoStore::for_testing( + repos_dir.path().to_path_buf(), + pool.clone(), + ); + state + .db + .register_agent(F2A_PUSHER, &["agent".to_string()]) + .await + .unwrap(); + + // First upload fails (500), then the bundler behaves; the gateway says + // the rejected item was never indexed. + let (bundler_url, calls) = f2a_bundler(1).await; + let (gateway_url, probes) = f2a_gateway("absent").await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--bundler-url", + &bundler_url, + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + &gateway_url, + ])); + + let (_, repo_path) = state + .repo_store + .local_path(&rec.owner_did, &rec.name) + .unwrap(); + std::fs::create_dir_all(&repo_path).unwrap(); + u5_init_repo(&repo_path); + let c1 = u5_commit_file(&repo_path, "a.txt", "one\n"); + + let update = f2a_update("refs/heads/main", &c1); + let job = crate::db::PostReceiveJob { + id: uuid::Uuid::new_v4().to_string(), + pusher_did: F2A_PUSHER.to_string(), + owner_did: rec.owner_did.clone(), + repo_name: rec.name.clone(), + repo_id: rec.id.clone(), + ref_updates: update + .iter() + .map(|u| crate::db::JobRefUpdate { + old_sha: u.old_sha.clone(), + new_sha: u.new_sha.clone(), + ref_name: u.ref_name.clone(), + }) + .collect(), + attestation: crate::db::PostReceiveAttestation::default(), + status: "pending".to_string(), + enqueued_at: chrono::Utc::now().to_rfc3339(), + attempts: 0, + error: None, + }; + state.db.enqueue_post_receive_job(&job).await.unwrap(); + + // Run 1: the bundler rejects the upload, so the anchor unit fails and the + // job is NOT done — it stays `failed` for the startup drain to retry. + process_post_receive_job(state.clone(), job.clone()).await; + assert_eq!( + f2a_job_status(&pool, &job.id).await, + "failed", + "a job whose anchor upload was rejected must not be terminal" + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + let row_state: String = sqlx::query_scalar( + "SELECT state FROM arweave_anchors WHERE repo = $1 AND ref_name = 'refs/heads/main'", + ) + .bind(f2a_slug(&rec)) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + row_state, "failed", + "a definitively rejected upload must leave the row failed" + ); + + // Drain retry: recovery probes the gateway, sees the item absent, + // re-uploads (the bundler now behaves), records the anchor, `done`. + state.db.reset_stale_post_receive_jobs().await.unwrap(); + let pending = state.db.list_pending_post_receive_jobs().await.unwrap(); + assert_eq!(pending.len(), 1, "the failed job must be drained"); + for job in pending { + process_post_receive_job(state.clone(), job).await; + } + assert_eq!(f2a_job_status(&pool, &job.id).await, "done"); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + assert!( + probes.load(std::sync::atomic::Ordering::SeqCst) >= 1, + "the recovery must probe the gateway before deciding to re-upload" + ); + assert!( + state + .db + .arweave_anchor_exists(&f2a_slug(&rec), "refs/heads/main", ZERO_SHA, &c1) + .await + .unwrap(), + "the retried anchor must be recorded" + ); + + // The stored anchor names the NODE as issuer (state.node_did), not the + // pusher whose push triggered the job (#224 review, P1-2). + let stored_node: String = sqlx::query_scalar( + "SELECT node_did FROM arweave_anchors + WHERE repo = $1 AND ref_name = 'refs/heads/main' AND old_sha = $2 AND new_sha = $3", + ) + .bind(f2a_slug(&rec)) + .bind(ZERO_SHA) + .bind(&c1) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + stored_node, + state.node_did.to_string(), + "the anchor must be issued by the node's own DID" + ); + assert_ne!( + stored_node, F2A_PUSHER, + "the pusher must not be recorded as the anchor issuer" + ); + + // Replay with the row recorded: the claim says AlreadyRecorded, so the + // bundler is never called again. + process_post_receive_job(state.clone(), job.clone()).await; + assert_eq!(f2a_job_status(&pool, &job.id).await, "done"); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 2, + "replaying an anchored job must not pay for a second upload" + ); + } + + /// #224 review, P1-4: two workers processing the SAME job concurrently must + /// converge on a single executor. The atomic conditional claim lets exactly + /// one win; the loser's claim updates zero rows and it skips the body. The + /// bundler is called exactly once and the anchor is recorded exactly once. + #[sqlx::test] + async fn two_concurrent_workers_claim_the_job_once(pool: sqlx::PgPool) { + use clap::Parser as _; + + let bin = tempfile::TempDir::new().unwrap(); + let log = bin.path().join("git.log"); + let git_bin = f2a_logging_git(bin.path(), &log); + let (mut state, rec) = f2a_state(pool.clone(), &git_bin, "z6anchor", "a2", false).await; + let repos_dir = tempfile::TempDir::new().unwrap(); + state.repo_store = crate::git::repo_store::RepoStore::for_testing( + repos_dir.path().to_path_buf(), + pool.clone(), + ); + state + .db + .register_agent(F2A_PUSHER, &["agent".to_string()]) + .await + .unwrap(); + + let (bundler_url, calls) = f2a_bundler(0).await; + let (gateway_url, _probes) = f2a_gateway("absent").await; + state.config = std::sync::Arc::new(crate::config::Config::parse_from([ + "gitlawb-node", + "--bundler-url", + &bundler_url, + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + &gateway_url, + ])); + + let (_, repo_path) = state + .repo_store + .local_path(&rec.owner_did, &rec.name) + .unwrap(); + std::fs::create_dir_all(&repo_path).unwrap(); + u5_init_repo(&repo_path); + let c1 = u5_commit_file(&repo_path, "a.txt", "one\n"); + + let update = f2a_update("refs/heads/main", &c1); + let job = crate::db::PostReceiveJob { + id: uuid::Uuid::new_v4().to_string(), + pusher_did: F2A_PUSHER.to_string(), + owner_did: rec.owner_did.clone(), + repo_name: rec.name.clone(), + repo_id: rec.id.clone(), + ref_updates: update + .iter() + .map(|u| crate::db::JobRefUpdate { + old_sha: u.old_sha.clone(), + new_sha: u.new_sha.clone(), + ref_name: u.ref_name.clone(), + }) + .collect(), + attestation: crate::db::PostReceiveAttestation::default(), + status: "pending".to_string(), + enqueued_at: chrono::Utc::now().to_rfc3339(), + attempts: 0, + error: None, + }; + state.db.enqueue_post_receive_job(&job).await.unwrap(); + + // Two drainers race on the same job; the conditional claim lets only one + // run the body. + let ((), ()) = tokio::join!( + process_post_receive_job(state.clone(), job.clone()), + process_post_receive_job(state.clone(), job.clone()), + ); + + assert_eq!(f2a_job_status(&pool, &job.id).await, "done"); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "only the claiming worker may run the job body" + ); + assert!( + state + .db + .arweave_anchor_exists(&f2a_slug(&rec), "refs/heads/main", ZERO_SHA, &c1) + .await + .unwrap(), + "the anchor must be recorded exactly once" + ); + } } diff --git a/crates/gitlawb-node/src/arweave.rs b/crates/gitlawb-node/src/arweave.rs index 31d3d6d7..3c574724 100644 --- a/crates/gitlawb-node/src/arweave.rs +++ b/crates/gitlawb-node/src/arweave.rs @@ -1,29 +1,57 @@ -//! Arweave permanent anchoring via Irys. +//! Arweave permanent anchoring via Bundler (Irys). //! -//! Every ref-update event (push) is anchored to Arweave through the Irys +//! Every ref-update event (push) is anchored to Arweave through the Bundler //! network. The anchor payload is a small JSON object containing: //! //! { repo, owner_did, ref_name, old_sha, new_sha, cid, timestamp, node_did } //! -//! Irys allows free uploads for data < 100 KiB on both devnet and mainnet -//! (via Turbo). No wallet is required for payloads under the free threshold. +//! Uploads are signed ANS-104 data items (see [`crate::ans104`]): the node +//! signs the item with its own keypair and embeds the metadata as item tags, so +//! the item is verifiably authored by this node. That signature is NOT payment: +//! the bundler only serves items backed by a funded account, and refuses +//! under-funded uploads with "Not enough balance" — which the push path degrades +//! to a warning, so an unfunded node silently loses every anchor. Funding is +//! therefore mandatory configuration, not optional. Irys bills each upload +//! against a payment token at `/tx/{token}` and reads the funded address from +//! the `x-irys-paid-by` header (see the `@irys/upload` js-sdk, +//! `UploadHeaders.PAID_BY`), so the node sends: +//! - `GITLAWB_BUNDLER_ACCOUNT` — the funded address/identity, as `x-irys-paid-by` +//! - `GITLAWB_BUNDLER_TOKEN` — the payment-token slug (e.g. "matic") +//! - `GITLAWB_BUNDLER_URL` — the node base URL; uploads go to `{url}/tx/{token}` +//! - `Config::validate()` refuses to start with a bundler URL but no funded +//! account or payment token. //! -//! Set `GITLAWB_IRYS_URL` to override the default endpoint: -//! - devnet (free, no cost): https://devnet.irys.xyz -//! - mainnet: https://node2.irys.xyz +//! Set `GITLAWB_BUNDLER_URL` (deprecated name: `GITLAWB_IRYS_URL`) to override the default endpoint: +//! - devnet (faucet-funded): https://devnet.irys.xyz +//! - mainnet: https://node2.irys.xyz //! -//! Each anchor returns an Irys transaction ID (43-char base58 string). -//! The permanent Arweave URL is: https://arweave.net/ +//! `GITLAWB_ARWEAVE_GATEWAY` has NO default. An anchoring node MUST set it to a +//! gateway on the SAME network as the bundler (devnet → the devnet gateway, +//! mainnet → https://arweave.net): the old implicit arweave.net default paired +//! the gateway to the bundler URL and made /verify fail for devnet +//! transactions, which arweave.net cannot resolve. `Config::validate()` refuses +//! to start with a bundler configured but no explicit gateway; a node that +//! does not anchor may leave the gateway unset (existing recorded anchors stay +//! durable and listable, but carry no presentation URL). +//! +//! Each anchor returns a transaction ID (43-char base64url) that is the +//! content-derived id of the signed data item. The permanent Arweave URL is: +//! / //! //! Anchors are stored in the `arweave_anchors` table for auditability. - use anyhow::Result; +use base64::Engine as _; +use futures::StreamExt; +use serde::Serialize; use serde_json::json; - +use sha2::Digest; +use std::collections::HashMap; +use std::str::FromStr; /// Data describing a ref-update event to be anchored. #[derive(Debug, Clone)] pub struct RefAnchor { pub repo: String, + pub repo_id: String, pub owner_did: String, pub ref_name: String, pub old_sha: String, @@ -32,24 +60,54 @@ pub struct RefAnchor { pub cid: Option, pub timestamp: String, pub node_did: String, + /// The full signed [`crate::db::RefCertificate`] for this ref update, + /// serialized and embedded so a verifier can validate the chain. + pub certificate: Option, +} +/// Validate an Arweave transaction / data-item ID: 43-character base64url. +/// This is the expected wire format for both a bundler's `{"id": ...}` response +/// and the id under which a gateway resolves a data item. The durable job and +/// the public `/verify` endpoint share this boundary so a malformed id is +/// rejected the same way everywhere. +pub(crate) fn is_valid_tx_id(tx_id: &str) -> bool { + if tx_id.len() != 43 { + return false; + } + tx_id + .bytes() + .all(|b| matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_')) } -/// Anchor a ref-update to Arweave via Irys. +/// Classified outcome of a bundler upload, so the durable job can decide +/// whether a retry may safely pay for another upload (#224 review): /// -/// Returns the Irys/Arweave transaction ID on success. -/// Returns `Ok("")` if `irys_url` is empty (anchoring disabled). -pub async fn anchor_ref_update( - client: &reqwest::Client, - irys_url: &str, - anchor: &RefAnchor, -) -> Result { - if irys_url.is_empty() { - return Ok(String::new()); - } +/// - [`UploadOutcome::Accepted`] — the provider returned a well-formed +/// transaction id; the item is permanently accepted. +/// - [`UploadOutcome::Rejected`] — the provider returned a definitive +/// non-acceptance (HTTP error body). The item was NOT accepted, so a retry +/// may re-upload safely. +/// - [`UploadOutcome::Uncertain`] — the request failed before a verdict +/// (connection drop, or a success response that did not carry a valid id). +/// The item MAY have been accepted; a retry must reconcile via the gateway +/// probe before issuing another paid request, never re-upload blindly. +#[derive(Debug)] +pub enum UploadOutcome { + Accepted { tx_id: String }, + Rejected { message: String }, + Uncertain { message: String }, +} - let payload = json!({ +/// Build the signed ANS-104 data item for a ref-update anchor. The metadata is +/// embedded as tags inside the item (where the bundler verifies them against +/// the signature); nothing is passed out-of-band. +pub(crate) fn build_ref_anchor_item( + anchor: &RefAnchor, + node_keypair: &gitlawb_core::identity::Keypair, +) -> Result> { + let mut payload = json!({ "schema": "gitlawb/ref-update/v1", "repo": anchor.repo, + "repo_id": anchor.repo_id, "owner_did": anchor.owner_did, "ref_name": anchor.ref_name, "old_sha": anchor.old_sha, @@ -59,50 +117,147 @@ pub async fn anchor_ref_update( "node_did": anchor.node_did, "network": "alpha", }); - + // Embed the signed certificate so verifiers can validate the chain. + if let Some(cert) = &anchor.certificate { + payload["certificate"] = serde_json::to_value(cert)?; + } let body = serde_json::to_vec(&payload)?; + let tags: Vec<(String, String)> = [ + "App-Name:gitlawb".to_string(), + "Schema:gitlawb/ref-update/v1".to_string(), + format!("Repo:{}", sanitize_tag(&anchor.repo)), + format!("Ref:{}", sanitize_tag(&anchor.ref_name)), + format!("SHA:{}", &anchor.new_sha[..anchor.new_sha.len().min(16)]), + format!("Node-DID:{}", sanitize_tag(&anchor.node_did)), + ] + .iter() + .map(|pair| { + let (name, value) = pair.split_once(':').unwrap_or((pair.as_str(), "")); + (name.to_string(), value.to_string()) + }) + .collect(); + crate::ans104::build_signed_data_item(node_keypair, &tag_refs(&tags), &body) +} - // Irys upload endpoint - let url = format!("{}/upload", irys_url.trim_end_matches('/')); - - let resp = client +/// Upload a signed ANS-104 data item to the bundler and classify the outcome. +/// The caller supplies the already-signed item so it can persist the item's +/// deterministic id ([`crate::ans104::data_item_id`]) BEFORE the request is +/// sent — that is the durable request identity a crash-recovery probes. +pub async fn upload_ref_anchor_item( + client: &reqwest::Client, + bundler_url: &str, + bundler_account: &str, + bundler_token: &str, + item: &[u8], +) -> Result { + // Irys upload target: {bundler_url}/tx/{token}. Built structurally so a + // query on the base URL is preserved and a fragment is rejected outright. + let url = bundler_upload_url(bundler_url, bundler_token)?; + let display_url = crate::server::mask_credential_url(&url); + let resp = match client .post(&url) - .header("Content-Type", "application/json") - // Irys tags allow indexing on Arweave gateway - .header("x-irys-tags", build_tags_header(anchor)) - .body(body) + .header("Content-Type", "application/octet-stream") + .header("x-irys-paid-by", bundler_account) + .body(item.to_vec()) .send() .await - .map_err(|e| anyhow::anyhow!("Irys upload failed: {e}"))?; - + { + Ok(r) => r, + Err(e) => { + // The request did not reach a verdict: the item MAY have been + // accepted. The message is already redacted/masked. + return Ok(UploadOutcome::Uncertain { + message: remote_send_error("Bundler upload failed", &e, &url, &display_url) + .to_string(), + }); + } + }; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("Irys returned {status}: {body}")); - } - - let json: serde_json::Value = resp - .json() - .await - .map_err(|e| anyhow::anyhow!("failed to parse Irys response: {e}"))?; - - // Irys response: {"id": "", "timestamp": ..., "version": ...} - let tx_id = json["id"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("no 'id' in Irys response: {json}"))? + let message = remote_response_error( + "Bundler upload", + &status, + &body, + &url, + &display_url, + &[bundler_account, bundler_token], + ) .to_string(); - - tracing::info!( - repo = %anchor.repo, - ref_name = %anchor.ref_name, - new_sha = %anchor.new_sha, - tx_id = %tx_id, - "anchored ref update to Arweave" - ); - - Ok(tx_id) + return Ok(UploadOutcome::Rejected { message }); + } + let json: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(e) => { + // Success status but no parseable body: outcome unknown. Treating a + // malformed success as `Accepted` would let a misbehaving bundler + // turn a required anchor into a silent no-op; treating it as + // `Rejected` would risk a second paid artifact if the item landed. + return Ok(UploadOutcome::Uncertain { + message: format!("failed to parse Bundler response: {e}"), + }); + } + }; + // Bundler response: {"id": "", "timestamp": ..., "version": ...} + // The id must be a well-formed non-empty Arweave id; an empty/malformed + // success response is an Uncertain outcome, not success (#224 review). + let tx_id = match json["id"].as_str() { + Some(id) if is_valid_tx_id(id) => id.to_string(), + _ => { + return Ok(UploadOutcome::Uncertain { + message: format!( + "Bundler returned a malformed transaction id in its success response: {}", + truncate_for_error(&json.to_string(), 512) + ), + }); + } + }; + Ok(UploadOutcome::Accepted { tx_id }) } +/// Anchor a ref-update to Arweave via Irys. +/// +/// The payload is uploaded as a signed ANS-104 data item: `node_keypair` signs +/// the item and the indexing metadata (App-Name, Schema, Repo, Ref, SHA, +/// Node-DID) is embedded as data-item tags inside the signed item — never in a +/// request header. Returns the Irys/Arweave transaction ID on success. +/// Returns `Ok("")` if `bundler_url` is empty (anchoring disabled). +/// +/// The durable post-receive job does not call this directly: it drives +/// [`build_ref_anchor_item`] + [`upload_ref_anchor_item`] so it can persist the +/// item id before the request and classify the outcome. This thin wrapper keeps +/// the manifest/tail call sites and tests on a `Result` contract. +#[cfg(test)] +pub async fn anchor_ref_update( + client: &reqwest::Client, + bundler_url: &str, + bundler_account: &str, + bundler_token: &str, + anchor: &RefAnchor, + node_keypair: &gitlawb_core::identity::Keypair, +) -> Result { + if bundler_url.is_empty() { + return Ok(String::new()); + } + let item = build_ref_anchor_item(anchor, node_keypair)?; + match upload_ref_anchor_item(client, bundler_url, bundler_account, bundler_token, &item).await? + { + UploadOutcome::Accepted { tx_id } => { + tracing::info!( + repo = %anchor.repo, + ref_name = %anchor.ref_name, + new_sha = %anchor.new_sha, + tx_id = %tx_id, + bundler_account = %bundler_account, + bundler_token = %bundler_token, + "anchored ref update to Arweave via bundler" + ); + Ok(tx_id) + } + UploadOutcome::Rejected { message } => Err(anyhow::anyhow!(message)), + UploadOutcome::Uncertain { message } => Err(anyhow::anyhow!(message)), + } +} /// A per-push manifest of the blobs encrypted this push (Option B3). The /// `blobs` slice is `(oid, cid)` tuples. Anchored directly to Arweave as its JSON /// body so the discovery index survives total node loss. Recipient identities are @@ -114,30 +269,33 @@ pub struct EncryptedManifest<'a> { pub timestamp: &'a str, pub blobs: &'a [(String, String)], } - /// Anchor a per-push encrypted-blob manifest to Arweave via Irys. The manifest /// JSON body is the payload (not a CID pointer to IPFS), so the index is /// permanent and self-contained. Recipient identities are deliberately omitted: /// the anchor is permanent and public, and the v2 envelopes no longer expose /// recipients, so the reader set must not be written to Arweave either. /// -/// Returns the Irys/Arweave transaction ID, or `Ok("")` when `irys_url` is empty +/// The manifest is uploaded as a signed ANS-104 data item (same scheme as +/// `anchor_ref_update`); the discovery tags are embedded inside the item. +/// +/// Returns the Arweave transaction ID, or `Ok("")` when `bundler_url` is empty /// (anchoring disabled) or there are no blobs to anchor. pub async fn anchor_encrypted_manifest( client: &reqwest::Client, - irys_url: &str, + bundler_url: &str, + bundler_account: &str, + bundler_token: &str, manifest: &EncryptedManifest<'_>, + node_keypair: &gitlawb_core::identity::Keypair, ) -> Result { - if irys_url.is_empty() || manifest.blobs.is_empty() { + if bundler_url.is_empty() || manifest.blobs.is_empty() { return Ok(String::new()); } - let blobs_json: Vec = manifest .blobs .iter() .map(|(oid, cid)| manifest_blob_json(oid, cid)) .collect(); - let payload = json!({ "schema": "gitlawb/encrypted-manifest/v1", "repo": manifest.repo, @@ -146,101 +304,922 @@ pub async fn anchor_encrypted_manifest( "timestamp": manifest.timestamp, "blobs": blobs_json, }); - let body = serde_json::to_vec(&payload)?; - let url = format!("{}/upload", irys_url.trim_end_matches('/')); - + let tags: Vec<(String, String)> = [ + "App-Name:gitlawb".to_string(), + "Schema:gitlawb/encrypted-manifest/v1".to_string(), + format!("Repo:{}", sanitize_tag(manifest.repo)), + format!("Owner-DID:{}", sanitize_tag(manifest.owner_did)), + format!("Node-DID:{}", sanitize_tag(manifest.node_did)), + ] + .iter() + .map(|pair| { + let (name, value) = pair.split_once(':').unwrap_or((pair.as_str(), "")); + (name.to_string(), value.to_string()) + }) + .collect(); + let data_item = crate::ans104::build_signed_data_item(node_keypair, &tag_refs(&tags), &body)?; + // Irys upload target: {bundler_url}/tx/{token}. Built structurally so a + // query on the base URL is preserved and a fragment is rejected outright. + let url = bundler_upload_url(bundler_url, bundler_token)?; + let display_url = crate::server::mask_credential_url(&url); let resp = client .post(&url) - .header("Content-Type", "application/json") - .header("x-irys-tags", build_manifest_tags_header(manifest)) - .body(body) + .header("Content-Type", "application/octet-stream") + .header("x-irys-paid-by", bundler_account) + .body(data_item) .send() .await - .map_err(|e| anyhow::anyhow!("Irys upload failed: {e}"))?; - + .map_err(|e| remote_send_error("Bundler upload failed", &e, &url, &display_url))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("Irys returned {status}: {body}")); + return Err(remote_response_error( + "Bundler manifest upload", + &status, + &body, + &url, + &display_url, + &[bundler_account, bundler_token], + )); } - let json: serde_json::Value = resp .json() .await - .map_err(|e| anyhow::anyhow!("failed to parse Irys response: {e}"))?; - - let tx_id = json["id"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("no 'id' in Irys response: {json}"))? - .to_string(); - + .map_err(|e| anyhow::anyhow!("failed to parse Bundler response: {e}"))?; + // Bundler response: {"id": "", "timestamp": ..., "version": ...} + // A success without a well-formed, non-empty id must be an error (never a + // silent no-op), so a malformed success cannot fake an anchor (#224 review). + let tx_id = match json["id"].as_str() { + Some(id) if is_valid_tx_id(id) => id.to_string(), + _ => { + return Err(anyhow::anyhow!( + "Bundler returned a malformed transaction id in its success response: {}", + truncate_for_error(&json.to_string(), 512) + )); + } + }; tracing::info!( repo = %manifest.repo, tx_id = %tx_id, blobs = manifest.blobs.len(), - "anchored encrypted manifest to Arweave" + bundler_account = %bundler_account, + bundler_token = %bundler_token, + "anchored encrypted manifest to Arweave via bundler" ); - Ok(tx_id) } - /// Serialize one blob for the Arweave manifest. Recipient identities are /// intentionally absent so the permanent public anchor never records who can /// read a blob. fn manifest_blob_json(oid: &str, cid: &str) -> serde_json::Value { json!({ "oid": oid, "cid": cid }) } - -/// Build the Irys tag header for an encrypted-blob manifest. `Repo` and `Schema` -/// are the tags the `gl` recovery query filters on. -fn build_manifest_tags_header(manifest: &EncryptedManifest<'_>) -> String { - [ - "App-Name:gitlawb".to_string(), - "Schema:gitlawb/encrypted-manifest/v1".to_string(), - format!("Repo:{}", sanitize_tag(manifest.repo)), - format!("Owner-DID:{}", sanitize_tag(manifest.owner_did)), - format!("Node-DID:{}", sanitize_tag(manifest.node_did)), - ] - .join(",") -} - -/// Arweave permanent URL for a given Irys transaction ID. -pub fn arweave_url(tx_id: &str) -> String { - format!("https://arweave.net/{tx_id}") +/// Borrow `(name, value)` string slices from owned tag pairs for +/// [`crate::ans104::build_signed_data_item`]. +fn tag_refs(tags: &[(String, String)]) -> Vec<(&str, &str)> { + tags.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect() } - -/// Build the Irys tag header value for Arweave indexing. -/// Format: comma-separated "name:value" pairs. -fn build_tags_header(anchor: &RefAnchor) -> String { - [ - "App-Name:gitlawb".to_string(), - "Schema:gitlawb/ref-update/v1".to_string(), - format!("Repo:{}", sanitize_tag(&anchor.repo)), - format!("Ref:{}", sanitize_tag(&anchor.ref_name)), - format!("SHA:{}", &anchor.new_sha[..anchor.new_sha.len().min(16)]), - format!("Node-DID:{}", sanitize_tag(&anchor.node_did)), - ] - .join(",") -} - -/// Strip characters that are invalid in Irys/Arweave tag values. +/// Strip characters that are invalid in bundler/Arweave tag values. fn sanitize_tag(s: &str) -> String { s.chars() .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':')) .take(128) .collect() } - +/// Arweave URL for a given transaction ID, resolved through a configurable gateway. +#[allow(dead_code)] +pub fn arweave_url(gateway: &str, tx_id: &str) -> String { + format!("{}/{}", gateway.trim_end_matches('/'), tx_id) +} +/// Structurally join a base URL onto a path (`/tx/{token}` for uploads, a tx_id +/// for gateway reads), preserving the base's query string and rejecting +/// fragments. String concatenation would silently drop or garble a +/// query/fragment form and could smuggle credentials into the request target; +/// joining through `Url` keeps every part where it belongs. The returned string +/// is also the exact request target, so tests can assert it verbatim. +fn join_url_path(base: &str, segments: &[&str], what: &str) -> Result { + let mut url = reqwest::Url::parse(base).map_err(|e| anyhow::anyhow!("invalid {what}: {e}"))?; + if url.fragment().is_some() { + return Err(anyhow::anyhow!( + "{what} must not contain a URL fragment (a fragment is never sent to the \ + bundler/gateway and would silently change the request)" + )); + } + let query = url.query().map(str::to_string); + { + let mut segments_mut = url + .path_segments_mut() + .map_err(|_| anyhow::anyhow!("{what} must be a hierarchical URL"))?; + segments_mut.pop_if_empty(); + for seg in segments { + segments_mut.push(seg); + } + } + if let Some(q) = query { + url.set_query(Some(&q)); + } + Ok(url.to_string()) +} +/// Irys upload request target: `{bundler_url}/tx/{token}`, structurally joined. +fn bundler_upload_url(bundler_url: &str, token: &str) -> Result { + join_url_path(bundler_url, &["tx", token], "bundler URL") +} +/// Gateway request target for a transaction ID: `{gateway_url}/{tx_id}`. +fn gateway_tx_url(gateway_url: &str, tx_id: &str) -> Result { + join_url_path(gateway_url, &[tx_id], "gateway URL") +} +/// Whether a data item with the given id is resolvable at the configured +/// gateway (`GET {gateway}/{id}`). This is the reconciliation probe a durable +/// job uses to decide whether a crashed upload actually landed before issuing +/// a second paid request (#224 review): present → record the item id and skip +/// the upload; absent → the earlier upload did not land, re-upload is safe; +/// any other failure to reach a verdict → the caller must fail closed (no +/// upload). A 404/400/410 means the item is absent; a missing gateway means +/// the probe cannot run at all and is an error, never a silent "absent". +pub(crate) async fn anchor_item_present( + client: &reqwest::Client, + gateway_url: &str, + item_id: &str, +) -> Result { + if gateway_url.trim().is_empty() { + return Err(anyhow::anyhow!( + "no GITLAWB_ARWEAVE_GATEWAY configured to reconcile a possibly-uploaded anchor" + )); + } + let url = gateway_tx_url(gateway_url, item_id)?; + let display_url = crate::server::mask_credential_url(&url); + let resp = + client.get(&url).send().await.map_err(|e| { + remote_send_error("Arweave gateway probe failed", &e, &url, &display_url) + })?; + if resp.status().is_success() { + return Ok(true); + } + match resp.status() { + reqwest::StatusCode::NOT_FOUND + | reqwest::StatusCode::BAD_REQUEST + | reqwest::StatusCode::GONE => Ok(false), + other => Err(anyhow::anyhow!( + "Arweave gateway probe returned {other} for {display_url}" + )), + } +} +/// Cap a value for error messages/logs so a hostile or misbehaving endpoint +/// cannot drive unbounded allocations or output through an error string. +fn truncate_for_error(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut out = s.chars().take(max).collect::(); + out.push_str("…(truncated)"); + out +} +/// Central redaction boundary for every error that comes from a remote +/// endpoint the node talked to. reqwest embeds the request URL verbatim in its +/// error text, and a remote server can reflect anything the node sent — the +/// funded-account identity (`x-irys-paid-by`), the payment token riding in the +/// URL path, and any credentials in the base URL — back through an error or a +/// response body. Routing every such error through this module guarantees a raw +/// URL or a credential-bearing remote body never reaches a log (`err = %e`) or +/// a caller. +/// +/// `detail` is any string that may contain the raw URL or the secrets; the raw +/// URL is swapped for `display_url` (its credential-masked form) and each +/// non-empty secret is replaced with ``. +fn redact_remote_detail(detail: &str, url: &str, display_url: &str, secrets: &[&str]) -> String { + let mut out = detail.replace(url, display_url); + for secret in secrets { + if !secret.is_empty() { + out = out.replace(secret, ""); + } + } + out +} +/// Build the error for a remote request that failed before a response body was +/// available (connection refused, TLS failure, dropped stream). The reqwest +/// error text may embed the raw request URL, so it is masked and any secrets +/// scrubbed before the error is constructed. +fn remote_send_error( + prefix: &str, + err: &reqwest::Error, + url: &str, + display_url: &str, +) -> anyhow::Error { + let detail = redact_remote_detail(&err.to_string(), url, display_url, &[]); + anyhow::anyhow!("{prefix}: {detail}") +} +/// Build the error for a non-success response whose body the remote may have +/// populated by reflecting the request (including credential-bearing pieces). +/// The body is truncated, its raw URL swapped for the masked form, and the +/// secrets the node actually sent scrubbed — so a hostile bundler/gateway +/// cannot echo the operator's funded-account identity or payment token into +/// logs or an error surfaced to a caller. +fn remote_response_error( + prefix: &str, + status: &reqwest::StatusCode, + body: &str, + url: &str, + display_url: &str, + secrets: &[&str], +) -> anyhow::Error { + let body = truncate_for_error(&redact_remote_detail(body, url, display_url, secrets), 512); + anyhow::anyhow!("{prefix} returned {status}: {body}") +} +/// Result of verifying an Arweave anchor against the stored certificate chain. +#[derive(Debug, Clone, Serialize)] +pub struct VerifyResult { + pub valid: bool, + pub anchor: serde_json::Value, + pub certificate: Option, + pub errors: Vec, +} +/// Fetch an anchor from Arweave, extract the embedded certificate, and verify +/// the full chain: certificate signature, prev hash linkage, and pusher signature. +pub async fn verify_anchor( + client: &reqwest::Client, + gateway_url: &str, + tx_id: &str, + db: &crate::db::Db, + node_did: &str, +) -> Result { + // Fetch the data item from the Arweave gateway's data path. + // Gateways serve data at /{tx_id}, not /v1/tx/{id} (which is the bundler API). + // Built structurally: a query on the gateway config is preserved, and a + // fragment is rejected (it would never be sent to the gateway). + let url = match gateway_tx_url(gateway_url, tx_id) { + Ok(u) => u, + Err(e) => { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![e.to_string()], + }); + } + }; + // Public-facing display form of the same URL: reqwest's connection error + // embeds the request URL verbatim, so if the gateway config carries + // credentials the error text would otherwise leak them into VerifyResult. + let display_url = crate::server::mask_credential_url(&url); + let resp = match client.get(&url).send().await { + Ok(r) => r, + Err(e) => { + let safe_err = + remote_send_error("Arweave gateway connection failed", &e, &url, &display_url) + .to_string(); + tracing::warn!("{safe_err}"); + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![safe_err], + }); + } + }; + if !resp.status().is_success() { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![format!("Arweave gateway returned {}", resp.status())], + }); + } + // Stream the response body with a running 1 MiB cap so a chunked or + // header-omitting gateway cannot drive multi-hundred-MB allocations. + let mut body_bytes = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let data = match chunk { + Ok(d) => d, + Err(e) => { + // Mid-stream transport errors carry the same risk as connection + // errors: reqwest can embed the raw request URL in the error + // text, so it is masked through the same boundary as above. + let safe_err = + remote_send_error("failed to read response body", &e, &url, &display_url) + .to_string(); + tracing::warn!("{safe_err}"); + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![safe_err], + }); + } + }; + if body_bytes.len() + data.len() > 1_048_576 { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec!["response body exceeds 1 MiB limit".to_string()], + }); + } + body_bytes.extend_from_slice(&data); + } + // Parse the payload — could be JSON or raw bytes depending on gateway. + // Non-JSON responses are handled as an invalid result rather than an error. + let anchor: serde_json::Value = match serde_json::from_slice(&body_bytes) { + Ok(v) => v, + Err(e) => { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![format!("anchor payload is not valid JSON: {e}")], + }); + } + }; + let cert_value = anchor.get("certificate"); + let cert: Option = match cert_value { + Some(v) => serde_json::from_value(v.clone()).ok(), + None => None, + }; + let mut errors = Vec::new(); + if let Some(ref c) = cert { + // 0a. Verify the certificate was issued by this node. + if c.node_did != node_did { + errors.push(format!( + "certificate node_did ({}) does not match this node ({})", + c.node_did, node_did + )); + } + // 0b. Cross-check the outer anchor fields against the embedded certificate. + // A valid anchor must commit to the same identities and ref state. + // The outer repo_id (UUID) is compared against the cert's repo_id (UUID) + // to avoid comparing a human-readable slug against a UUID. + let outer_repo_id = anchor.get("repo_id").and_then(|v| v.as_str()); + let outer_ref = anchor.get("ref_name").and_then(|v| v.as_str()); + let outer_old = anchor.get("old_sha").and_then(|v| v.as_str()); + let outer_new = anchor.get("new_sha").and_then(|v| v.as_str()); + let outer_node = anchor.get("node_did").and_then(|v| v.as_str()); + if outer_repo_id.is_none() { + errors.push("anchor payload is missing top-level 'repo_id'".to_string()); + } else if outer_repo_id != Some(&c.repo_id) { + errors.push(format!( + "anchor outer repo_id ({}) does not match certificate repo_id ({})", + outer_repo_id.unwrap_or(""), + c.repo_id + )); + } + if outer_ref.is_none() { + errors.push("anchor payload is missing top-level 'ref_name'".to_string()); + } else if outer_ref != Some(&c.ref_name) { + errors.push(format!( + "anchor outer ref_name ({}) does not match certificate ref_name ({})", + outer_ref.unwrap_or(""), + c.ref_name + )); + } + // Fail closed: old_sha, new_sha, and node_did are mandatory in the + // outer anchor when a certificate is embedded. A forger who omits + // them must not pass verification. + if outer_old.is_none() { + errors.push("anchor payload is missing top-level 'old_sha'".to_string()); + } else if outer_old != Some(&c.old_sha) { + errors.push(format!( + "anchor outer old_sha ({}) does not match certificate old_sha ({})", + outer_old.unwrap_or(""), + c.old_sha + )); + } + if outer_new.is_none() { + errors.push("anchor payload is missing top-level 'new_sha'".to_string()); + } else if outer_new != Some(&c.new_sha) { + errors.push(format!( + "anchor outer new_sha ({}) does not match certificate new_sha ({})", + outer_new.unwrap_or(""), + c.new_sha + )); + } + if outer_node.is_none() { + errors.push("anchor payload is missing top-level 'node_did'".to_string()); + } else if outer_node != Some(&c.node_did) { + errors.push(format!( + "anchor outer node_did ({}) does not match certificate node_did ({})", + outer_node.unwrap_or(""), + c.node_did + )); + } + // 0c. Corroborate outer repo slug and owner_did against the node's own + // record for the certificate's repo_id. The certificate signs the + // repo_id UUID but not the human-readable slug or owner DID, so a + // forger could otherwise echo attacker-chosen identities next to a + // valid:true verdict. When the node hosts the repo, the outer + // identity fields must agree with what it recorded. + let outer_repo = anchor.get("repo").and_then(|v| v.as_str()); + let outer_owner = anchor.get("owner_did").and_then(|v| v.as_str()); + // Fail closed: when the outer identity fields are present, a lookup + // that cannot complete (repo missing or DB error) must not silently + // skip corroboration. Otherwise a forger could echo attacker-chosen + // identities next to a valid:true verdict simply because the node has + // no record — or the DB is down — to check them against. + let outer_identity_present = outer_repo.is_some() || outer_owner.is_some(); + match db.get_repo_by_id(&c.repo_id).await { + Ok(Some(record)) => { + let expected_repo = format!( + "{}/{}", + crate::db::normalize_owner_key(&record.owner_did), + record.name + ); + if let Some(outer_repo) = outer_repo { + if outer_repo != expected_repo { + errors.push(format!( + "anchor outer repo ({outer_repo}) does not match recorded repo ({expected_repo})" + )); + } + } + if let Some(outer_owner) = outer_owner { + if outer_owner != record.owner_did { + errors.push(format!( + "anchor outer owner_did ({outer_owner}) does not match recorded owner_did ({})", + record.owner_did + )); + } + } + } + Ok(None) => { + if outer_identity_present { + errors.push(format!( + "anchor outer repo/owner_did present but repo_id {} not found in node database — outer identity cannot be corroborated", + c.repo_id + )); + } else { + tracing::warn!( + repo_id = %c.repo_id, + "cannot corroborate anchor repo/owner_did — repo_id not found in node database" + ); + } + } + Err(e) => { + // The raw DB error never reaches the caller (it can embed + // connection details); it is logged server-side only, and the + // deny is stated without it, like the not-found branch above. + tracing::warn!("repo lookup failed for {}: {e}", c.repo_id); + if outer_identity_present { + errors.push(format!( + "repo lookup failed for {} — outer repo/owner_did cannot be corroborated", + c.repo_id + )); + } + } + } + // 1. Verify node signature on the certificate payload. + // Certificates produced after this PR use a 13-field payload + // that includes seq, prev, and proof fields. Pre-PR certificates + // used a 7-field payload (repo_id, ref, old, new, pusher, node, ts) + // with NULL proof fields. Try the 13-field check first; if it + // fails and all proof fields are NULL, fall back to 7-field. + let proof_fields_null = c.pusher_sig.is_none() + && c.signature_input.is_none() + && c.content_digest.is_none() + && c.request_path.is_none(); + // Resolve node DID to public key + let node_did = match gitlawb_core::did::Did::from_str(&c.node_did) { + Ok(did) => did, + Err(e) => { + errors.push(format!("invalid node DID: {e}")); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + }; + let verifying_key = match node_did.to_verifying_key() { + Ok(vk) => vk, + Err(e) => { + errors.push(format!("unresolvable node DID: {e}")); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + }; + let sig_array: [u8; 64] = + match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(&c.signature) { + Ok(bytes) => match bytes.as_slice().try_into() { + Ok(a) => a, + Err(_) => { + errors.push("certificate signature is not 64 bytes".to_string()); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + }, + Err(_) => { + errors.push("certificate signature is not valid base64".to_string()); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + }; + // Try 13-field payload first. + let payload_13 = serde_json::json!({ + "repo_id": c.repo_id, + "ref": c.ref_name, + "old": c.old_sha, + "new": c.new_sha, + "pusher": c.pusher_did, + "node": c.node_did, + "ts": c.issued_at, + "seq": c.seq, + "prev": c.prev, + "pusher_sig": c.pusher_sig, + "signature_input": c.signature_input, + "content_digest": c.content_digest, + "request_path": c.request_path, + }); + let payload_bytes_13 = serde_json::to_vec(&payload_13)?; + let sig_valid_13 = + gitlawb_core::identity::verify(&verifying_key, &payload_bytes_13, &sig_array); + let mut legacy_7_field_verified = false; + if proof_fields_null && sig_valid_13.is_err() { + // Fall back to 7-field payload for pre-PR certificates. + let payload_7 = serde_json::json!({ + "repo_id": c.repo_id, + "ref": c.ref_name, + "old": c.old_sha, + "new": c.new_sha, + "pusher": c.pusher_did, + "node": c.node_did, + "ts": c.issued_at, + }); + let payload_bytes_7 = serde_json::to_vec(&payload_7)?; + if gitlawb_core::identity::verify(&verifying_key, &payload_bytes_7, &sig_array).is_ok() + { + legacy_7_field_verified = true; + } else { + errors.push("certificate signature verification failed (7-field)".to_string()); + } + } else if let Err(e) = sig_valid_13 { + errors.push(format!("certificate signature verification failed: {e}")); + } + // 1b. Corroborate chain position for legacy certificates. + // The 7-field fallback covers only repo_id, ref, old, new, pusher, + // node, ts. seq and prev are NOT covered on that path, so a tampered + // legacy cert could otherwise pass with a blanket valid: true. Look + // up the node's own stored row by the FIELDS THE SIGNATURE COVERS + // (repo_id, ref_name, old_sha, new_sha, issued_at) — never by `id`, + // which appears in no signed payload and would let a forger choose + // which stored row their seq/prev claims are measured against — and + // require seq/prev agreement. + if legacy_7_field_verified { + match db + .get_cert_by_signed_tuple( + &c.repo_id, + &c.ref_name, + &c.old_sha, + &c.new_sha, + &c.issued_at, + ) + .await + { + Ok(Some(stored)) => { + if stored.seq != c.seq { + errors.push(format!( + "certificate seq {} disagrees with stored seq {}", + c.seq, stored.seq + )); + } + if stored.prev != c.prev { + errors.push(format!( + "certificate prev {} disagrees with stored prev {}", + c.prev, stored.prev + )); + } + } + Ok(None) => { + errors.push( + "no stored certificate matches the signed (repo_id, ref_name, old_sha, new_sha, ts) — cannot corroborate legacy chain position" + .to_string(), + ); + } + Err(e) => { + tracing::warn!("certificate lookup failed for {}: {e}", c.id); + errors.push(format!( + "error looking up certificate {} in node database", + c.id + )); + } + } + } + // 2. Verify prev hash linkage against the predecessor at seq - 1. + // The prev hash covers the 7-field payload (repo_id, ref, old, new, + // pusher, node, ts) — seq, prev, and proof fields are excluded so + // that the hash chain is stable across certificate versions. + // Fail closed: a missing declared predecessor is treated as invalid. + // + // Legacy certificates backfilled by the v13 migration have the + // default all-zeros prev even when seq > 1 because the migration + // only assigns sequence numbers without computing prev hashes. + // For these rows the chain link is unknown — skip the check and + // warn rather than reporting a valid signature as invalid. + if c.seq > 1 { + if c.prev == "0000000000000000000000000000000000000000000000000000000000000000" { + // Prevent legacy false-positives: the migration that assigned + // seq never backfilled prev, so every pre-upgrade cert after + // the first in a repo has default all-zeros. + tracing::warn!( + "legacy certificate seq {} has default prev — chain continuity not verifiable, skipping prev check", + c.seq + ); + } else { + match db.get_cert_by_seq(&c.repo_id, c.seq - 1).await { + Ok(Some(pred)) => { + let prev_payload = serde_json::json!({ + "repo_id": pred.repo_id, + "ref": pred.ref_name, + "old": pred.old_sha, + "new": pred.new_sha, + "pusher": pred.pusher_did, + "node": pred.node_did, + "ts": pred.issued_at, + }); + let prev_bytes = serde_json::to_vec(&prev_payload)?; + let expected_prev = hex::encode(sha2::Sha256::digest(&prev_bytes)); + if c.prev != expected_prev { + errors.push(format!( + "prev hash mismatch: claimed {} expected {}", + c.prev, expected_prev + )); + } + } + Ok(None) => { + errors.push(format!( + "predecessor cert seq {} not found for repo {}", + c.seq - 1, + c.repo_id + )); + } + Err(e) => { + tracing::warn!("predecessor lookup failed for seq {}: {e}", c.seq - 1); + errors.push(format!("error looking up predecessor seq {}", c.seq - 1)); + } + } + } + } + // 3. Verify the pusher authorization proof (RFC 9421 HTTP Signature). + // The context fields (signature_input, content_digest, request_path) + // are bound into the node signing payload, so a certificate whose + // node signature verified already commits to them. + // + // The ref transition is NOT directly signed by the pusher — the + // shipped pusher signs only @method, @path, and content-digest. + // Instead the binding works through the node certificate: the node + // verifies the pusher proof during push, then issues a certificate + // whose 13-field signed payload includes ref_name, old_sha, new_sha. + // A captured pusher proof for one ref transition cannot be reused + // to authorize a different transition because the node signature on + // the mismatch would fail verification in step 1 above. + // + // When proof fields are present, pusher_sig is REQUIRED; a missing + // pusher_sig is treated as invalid rather than silently skipped. + if !proof_fields_null && c.pusher_sig.is_none() { + errors.push("pusher signature is required when proof fields are present".to_string()); + } + if let Some(pusher_sig) = &c.pusher_sig { + match (&c.signature_input, &c.content_digest, &c.request_path) { + (Some(sig_input), Some(content_digest), Some(request_path)) => { + match gitlawb_core::http_sig::HttpSignature::parse( + sig_input, + &format!("sig1=:{pusher_sig}:"), + ) { + Ok(http_sig) => { + let mut request_values: HashMap = HashMap::new(); + request_values.insert("@method".to_string(), "POST".to_string()); + request_values.insert("@path".to_string(), request_path.clone()); + request_values + .insert("content-digest".to_string(), content_digest.clone()); + let sig_params_value = + sig_input.strip_prefix("sig1=").unwrap_or(sig_input); + let components_ref: Vec<&str> = + http_sig.components.iter().map(String::as_str).collect(); + match gitlawb_core::http_sig::build_signing_string( + &components_ref, + sig_params_value, + &request_values, + ) { + Ok(signing_string) => { + let pusher_did = + gitlawb_core::did::Did::from_str(&c.pusher_did); + let pusher_vk = pusher_did.and_then(|d| d.to_verifying_key()); + match pusher_vk { + Ok(vk) => { + let sig_bytes: [u8; 64] = + match base64::engine::general_purpose::STANDARD + .decode(pusher_sig) + { + Ok(bytes) => { + match bytes.as_slice().try_into() { + Ok(a) => a, + Err(_) => { + errors.push( + "pusher signature is not 64 bytes" + .to_string(), + ); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + } + } + Err(_) => { + errors.push( + "pusher signature is not valid base64" + .to_string(), + ); + return Ok(VerifyResult { + valid: false, + anchor, + certificate: cert, + errors, + }); + } + }; + if let Err(e) = gitlawb_core::identity::verify( + &vk, + signing_string.as_bytes(), + &sig_bytes, + ) { + errors.push(format!( + "pusher signature verification failed: {e}" + )); + } + } + Err(e) => { + errors.push(format!("unresolvable pusher DID: {e}")); + } + } + } + Err(e) => { + errors.push(format!("failed to build signing string: {e}")); + } + } + } + Err(e) => { + errors.push(format!("failed to parse pusher Signature-Input: {e}")); + } + } // inner match + } + (sig_input, content_digest, request_path) => { + errors.push(format!( + "pusher signature present but context fields incomplete \ + (signature_input={}, content_digest={}, request_path={})", + sig_input.is_some(), + content_digest.is_some(), + request_path.is_some(), + )); + } + } + } + } else { + errors.push("no embedded certificate found in anchor".to_string()); + } + Ok(VerifyResult { + valid: errors.is_empty(), + anchor, + certificate: cert, + errors, + }) +} #[cfg(test)] mod tests { use super::*; - + use axum::http::StatusCode; + use gitlawb_core::identity::Keypair; + /// Spin up an in-process bundler that *enforces* the signed data item + /// contract: it parses the posted bytes as an ANS-104 item, verifies the + /// Ed25519 signature against `kp`, checks that every `expected_tag` is + /// present inside the item, and requires the embedded JSON payload to pass + /// `validate`. It also asserts the Irys wire contract verbatim: the request + /// target must equal `expected_request_target` (i.e. `/tx/{token}`, possibly + /// with a path prefix or query) and the `x-irys-paid-by` header must carry + /// `expected_bundler_account`. Any failure returns 400 (surfacing as `Err` + /// from the anchor functions); success returns `{"id": }`. + async fn spawn_enforcing_bundler( + kp: &Keypair, + expected_bundler_account: &'static str, + expected_request_target: &'static str, + expected_tags: &[(&str, &str)], + validate: impl Fn(&serde_json::Value) -> bool + Send + Sync + Clone + 'static, + tx_id: &'static str, + ) -> String { + let vk = kp.verifying_key(); + let expected: Vec<(String, String)> = expected_tags + .iter() + .map(|(n, v)| (n.to_string(), v.to_string())) + .collect(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + // Serve the exact path the client must request (path portion of the + // expected request target), so prefixed or query-carrying bases are + // exercised structurally rather than special-cased. + let route_path = expected_request_target + .split('?') + .next() + .unwrap_or(expected_request_target); + let router = axum::Router::new().route( + route_path, + axum::routing::post( + move |uri: axum::http::Uri, + headers: axum::http::HeaderMap, + body: axum::body::Bytes| { + let vk = vk; + let expected = expected.clone(); + async move { + // The request target is the Irys contract: /tx/{token} + // with the base's query preserved. Assert it verbatim so + // the structural URL join cannot regress. + let target = uri.path_and_query().map(|q| q.as_str()).unwrap_or(""); + if target != expected_request_target { + return ( + StatusCode::BAD_REQUEST, + format!( + "wrong request target: got {target:?}, want \ + {expected_request_target:?}" + ), + ); + } + // The funded-account identity must be part of the request, + // not just the config: the item signature is authorship. + if !expected_bundler_account.is_empty() { + let got = headers + .get("x-irys-paid-by") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + if got != expected_bundler_account { + return ( + StatusCode::BAD_REQUEST, + format!( + "missing/wrong x-irys-paid-by: got {got:?}, want \ + {expected_bundler_account:?}" + ), + ); + } + } + let parsed = match crate::ans104::verify_data_item(&vk, &body) { + Ok(p) => p, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + format!("unsigned/invalid item: {e}"), + ); + } + }; + for (name, value) in &expected { + if !parsed.tags.iter().any(|(tn, tv)| tn == name && tv == value) { + return ( + StatusCode::BAD_REQUEST, + format!("missing signed tag {name}:{value}"), + ); + } + } + let json: serde_json::Value = match serde_json::from_slice(&parsed.data) { + Ok(j) => j, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + format!("item data is not JSON: {e}"), + ); + } + }; + if !validate(&json) { + return ( + StatusCode::BAD_REQUEST, + "payload validation failed".to_string(), + ); + } + (StatusCode::OK, format!(r#"{{"id":"{tx_id}"}}"#)) + } + }, + ), + ); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("http://{addr}") + } #[tokio::test] async fn test_anchor_noop_when_url_empty() { + let kp = Keypair::generate(); let client = reqwest::Client::new(); let anchor = RefAnchor { repo: "alice/myrepo".into(), + repo_id: "repo-uuid".into(), owner_did: "did:key:z6Mk...".into(), ref_name: "refs/heads/main".into(), old_sha: "0000000000000000000000000000000000000000".into(), @@ -248,26 +1227,32 @@ mod tests { cid: Some("bafyreib5...".into()), timestamp: "2026-03-14T00:00:00Z".into(), node_did: "did:key:z6MknndwexV9...".into(), + certificate: None, }; - let result = anchor_ref_update(&client, "", &anchor).await; + let result = anchor_ref_update(&client, "", "", "", &anchor, &kp).await; assert!(result.is_ok()); assert_eq!(result.unwrap(), ""); } - #[tokio::test] async fn test_anchor_success() { - let mut server = mockito::Server::new_async().await; - let _mock = server - .mock("POST", "/upload") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"id":"7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk","timestamp":1710000000000,"version":"1.0.0"}"#) - .create_async() - .await; - + let kp = Keypair::generate(); + let server = spawn_enforcing_bundler( + &kp, + "zBundlerAccount", + "/tx/matic", + &[ + ("App-Name", "gitlawb"), + ("Schema", "gitlawb/ref-update/v1"), + ("Repo", "alice/myrepo"), + ], + |j| j["repo"] == "alice/myrepo", + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk", + ) + .await; let client = reqwest::Client::new(); let anchor = RefAnchor { repo: "alice/myrepo".into(), + repo_id: "repo-uuid".into(), owner_did: "did:key:z6Mk...".into(), ref_name: "refs/heads/main".into(), old_sha: "0".repeat(40), @@ -275,40 +1260,141 @@ mod tests { cid: None, timestamp: "2026-03-14T00:00:00Z".into(), node_did: "did:key:z6Mknnd...".into(), + certificate: None, }; - - let result = anchor_ref_update(&client, &server.url(), &anchor).await; + let result = + anchor_ref_update(&client, &server, "zBundlerAccount", "matic", &anchor, &kp).await; assert!(result.is_ok(), "anchor should succeed: {result:?}"); assert_eq!( result.unwrap(), "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk" ); - _mock.assert_async().await; } + /// #224 review, P2: the client validates the bundler's success id at the + /// boundary. An empty, missing, or malformed transaction id in a 200 + /// response must NOT read as success — it is Uncertain (the item may or may + /// not have been accepted), so the durable job probes the gateway instead of + /// recording a fabricated anchor. Only a well-formed 43-char base64url id is + /// Accepted. + #[tokio::test] + async fn test_upload_rejects_empty_missing_and_malformed_success_ids() { + use axum::response::IntoResponse; + use std::sync::atomic::Ordering; + + async fn mock_bundler( + body: &'static str, + ) -> (String, std::sync::Arc) { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let app = { + let calls_srv = calls.clone(); + axum::Router::new().route( + "/tx/matic", + axum::routing::post(move || { + let calls = calls_srv.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + (axum::http::StatusCode::OK, body).into_response() + } + }), + ) + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), calls) + } + let client = reqwest::Client::new(); + let item = b"signed-data-item-bytes".to_vec(); + + for (label, body) in [ + ("empty id", r#"{"id":""}"#), + ("missing id", r#"{"foo":"bar"}"#), + ("malformed id", r#"{"id":"WAY_TOO_SHORT"}"#), + ] { + let (server, calls) = mock_bundler(body).await; + let outcome = + upload_ref_anchor_item(&client, &server, "zBundlerAccount", "matic", &item) + .await + .unwrap(); + assert!( + matches!(outcome, UploadOutcome::Uncertain { .. }), + "{label} must classify as Uncertain: {outcome:?}" + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + // The well-formed case still lands as Accepted. + let (server, _calls) = + mock_bundler(r#"{"id":"7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"}"#).await; + let outcome = upload_ref_anchor_item(&client, &server, "zBundlerAccount", "matic", &item) + .await + .unwrap(); + assert!( + matches!(&outcome, UploadOutcome::Accepted { tx_id } if tx_id == "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"), + "a well-formed id must be Accepted: {outcome:?}" + ); + } + /// The funded bundler account must ride on the upload request: the item + /// signature is authorship, not payment, so an upload that omits the + /// account must be refused — it would otherwise be billed to nobody. + #[tokio::test] + async fn test_anchor_ref_update_rejects_missing_bundler_account() { + let kp = Keypair::generate(); + let client = reqwest::Client::new(); + let anchor = RefAnchor { + repo: "alice/myrepo".into(), + repo_id: "repo-uuid".into(), + owner_did: "did:key:z6Mk...".into(), + ref_name: "refs/heads/main".into(), + old_sha: "0".repeat(40), + new_sha: "a1b2c3d4".repeat(8), + cid: None, + timestamp: "2026-03-14T00:00:00Z".into(), + node_did: "did:key:z6Mknnd...".into(), + certificate: None, + }; + let server = spawn_enforcing_bundler( + &kp, + "zBundlerAccount", + "/tx/matic", + &[("App-Name", "gitlawb"), ("Schema", "gitlawb/ref-update/v1")], + |_| true, + "NEVER_RETURNED", + ) + .await; + let result = anchor_ref_update(&client, &server, "", "matic", &anchor, &kp).await; + let err = result.expect_err("missing bundler account must fail the upload"); + assert!( + err.to_string().contains("x-irys-paid-by"), + "error should name the missing account header: {err}" + ); + } #[tokio::test] async fn test_anchor_body_carries_real_old_sha() { // The anchored body must serialize the real old→new transition the // node was handed, never a zero placeholder. Regression guard for the // push handler that used to hardcode `old_sha` to 64 zeros (#26). - let mut server = mockito::Server::new_async().await; + // The enforcing bundler rejects the upload unless the signed item's + // JSON data carries both real SHAs. let real_old = "1111111111111111111111111111111111111111"; let real_new = "2222222222222222222222222222222222222222"; - let _mock = server - .mock("POST", "/upload") - .match_body(mockito::Matcher::AllOf(vec![ - mockito::Matcher::PartialJsonString(format!(r#"{{"old_sha":"{real_old}"}}"#)), - mockito::Matcher::PartialJsonString(format!(r#"{{"new_sha":"{real_new}"}}"#)), - ])) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"id":"TX_REAL_OLD_SHA","timestamp":1710000000000,"version":"1.0.0"}"#) - .create_async() - .await; - + let kp = Keypair::generate(); + let server = spawn_enforcing_bundler( + &kp, + "zBundlerAccount", + "/tx/matic", + &[("App-Name", "gitlawb")], + move |j| j["old_sha"] == real_old && j["new_sha"] == real_new, + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO1", + ) + .await; let client = reqwest::Client::new(); let anchor = RefAnchor { repo: "alice/myrepo".into(), + repo_id: "repo-uuid".into(), owner_did: "did:key:z6Mk...".into(), ref_name: "refs/heads/main".into(), old_sha: real_old.into(), @@ -316,26 +1402,72 @@ mod tests { cid: None, timestamp: "2026-03-14T00:00:00Z".into(), node_did: "did:key:z6Mknnd...".into(), + certificate: None, }; - - let result = anchor_ref_update(&client, &server.url(), &anchor).await; - assert_eq!(result.unwrap(), "TX_REAL_OLD_SHA"); - // The mock only matches when the posted JSON carries both real SHAs. - _mock.assert_async().await; + let result = + anchor_ref_update(&client, &server, "zBundlerAccount", "matic", &anchor, &kp).await; + assert_eq!( + result.unwrap(), + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO1" + ); + } + #[tokio::test] + async fn test_anchor_rejected_when_signed_by_other_key() { + // The bundler enforces the node's public key; an item signed by a + // different credential must be denied end-to-end, not silently accepted. + let node_kp = Keypair::generate(); + let impostor_kp = Keypair::generate(); + let server = spawn_enforcing_bundler( + &node_kp, + "zBundlerAccount", + "/tx/matic", + &[("App-Name", "gitlawb")], + |_| true, + "NEVER_RETURNED", + ) + .await; + let client = reqwest::Client::new(); + let anchor = RefAnchor { + repo: "alice/myrepo".into(), + repo_id: "repo-uuid".into(), + owner_did: "did:key:z6Mk...".into(), + ref_name: "refs/heads/main".into(), + old_sha: "0".repeat(40), + new_sha: "a1b2c3d4".repeat(8), + cid: None, + timestamp: "2026-03-14T00:00:00Z".into(), + node_did: "did:key:z6Mknnd...".into(), + certificate: None, + }; + let result = anchor_ref_update( + &client, + &server, + "zBundlerAccount", + "matic", + &anchor, + &impostor_kp, + ) + .await; + assert!( + result.is_err(), + "upload signed by the wrong key must be denied by the bundler" + ); } - #[test] fn test_arweave_url() { - let url = arweave_url("7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk"); + let url = arweave_url( + "https://arweave.net", + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk", + ); assert_eq!( url, "https://arweave.net/7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuOk" ); } - #[tokio::test] async fn test_manifest_anchor_noop_when_url_empty() { let client = reqwest::Client::new(); + let kp = Keypair::generate(); let blobs = vec![("oid1".to_string(), "cid1".to_string())]; let m = EncryptedManifest { repo: "alice/r", @@ -345,14 +1477,16 @@ mod tests { blobs: &blobs, }; assert_eq!( - anchor_encrypted_manifest(&client, "", &m).await.unwrap(), + anchor_encrypted_manifest(&client, "", "", "", &m, &kp) + .await + .unwrap(), "" ); } - #[tokio::test] async fn test_manifest_anchor_noop_when_no_blobs() { let client = reqwest::Client::new(); + let kp = Keypair::generate(); let blobs: Vec<(String, String)> = vec![]; let m = EncryptedManifest { repo: "alice/r", @@ -363,24 +1497,30 @@ mod tests { }; // Non-empty URL, but no blobs: still a no-op. assert_eq!( - anchor_encrypted_manifest(&client, "https://example.invalid", &m) + anchor_encrypted_manifest(&client, "https://example.invalid", "", "", &m, &kp) .await .unwrap(), "" ); } - #[tokio::test] async fn test_manifest_anchor_success() { - let mut server = mockito::Server::new_async().await; - let _mock = server - .mock("POST", "/upload") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"id":"MANIFESTTX123","timestamp":1710000000000,"version":"1.0.0"}"#) - .create_async() - .await; - + let kp = Keypair::generate(); + let server = spawn_enforcing_bundler( + &kp, + "zBundlerAccount", + "/tx/matic", + &[ + ("App-Name", "gitlawb"), + ("Schema", "gitlawb/encrypted-manifest/v1"), + ("Repo", "alice/r"), + ("Owner-DID", "did:key:zO"), + ("Node-DID", "did:key:zN"), + ], + |j| j["repo"] == "alice/r" && j["blobs"].as_array().is_some_and(|b| b.len() == 1), + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO2", + ) + .await; let client = reqwest::Client::new(); let blobs = vec![("oid1".to_string(), "cid1".to_string())]; let m = EncryptedManifest { @@ -390,11 +1530,176 @@ mod tests { timestamp: "2026-06-11T00:00:00Z", blobs: &blobs, }; - let r = anchor_encrypted_manifest(&client, &server.url(), &m).await; - assert_eq!(r.unwrap(), "MANIFESTTX123"); - _mock.assert_async().await; + let r = + anchor_encrypted_manifest(&client, &server, "zBundlerAccount", "matic", &m, &kp).await; + assert_eq!(r.unwrap(), "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO2"); + } + /// A minimal ref-update anchor for the URL-join tests. + fn test_anchor(repo: &str, new_sha: &str) -> RefAnchor { + RefAnchor { + repo: repo.into(), + repo_id: "repo-uuid".into(), + owner_did: "did:key:z6Mk...".into(), + ref_name: "refs/heads/main".into(), + old_sha: "0".repeat(40), + new_sha: new_sha.into(), + cid: None, + timestamp: "2026-03-14T00:00:00Z".into(), + node_did: "did:key:z6Mknnd...".into(), + certificate: None, + } + } + /// The upload target must survive a path-prefixed bundler base: joining + /// `{url}/prefix` must produce `/prefix/tx/matic`, never a dropped prefix. + #[tokio::test] + async fn test_anchor_preserves_bundler_path_prefix() { + let kp = Keypair::generate(); + let server = spawn_enforcing_bundler( + &kp, + "zBundlerAccount", + "/prefix/tx/matic", + &[("App-Name", "gitlawb")], + |_| true, + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO3", + ) + .await; + let client = reqwest::Client::new(); + let base = format!("{server}/prefix"); + let result = anchor_ref_update( + &client, + &base, + "zBundlerAccount", + "matic", + &test_anchor( + "alice/myrepo", + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", + ), + &kp, + ) + .await; + assert_eq!( + result.unwrap(), + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO3" + ); + } + /// A query on the bundler base must ride along on the upload request target + /// (`/tx/matic?token=secret`) rather than being dropped by string concat. + #[tokio::test] + async fn test_anchor_preserves_bundler_query() { + let kp = Keypair::generate(); + let server = spawn_enforcing_bundler( + &kp, + "zBundlerAccount", + "/tx/matic?token=secret", + &[("App-Name", "gitlawb")], + |_| true, + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO4", + ) + .await; + let client = reqwest::Client::new(); + let base = format!("{server}?token=secret"); + let result = anchor_ref_update( + &client, + &base, + "zBundlerAccount", + "matic", + &test_anchor( + "alice/myrepo", + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", + ), + &kp, + ) + .await; + assert_eq!( + result.unwrap(), + "7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO4" + ); + } + /// A fragment in the bundler URL must be rejected outright for both upload + /// paths: it is never sent to the bundler, so sending it silently would + /// change the request target in a way the operator cannot see. + #[tokio::test] + async fn test_anchor_rejects_fragment_in_bundler_url() { + let kp = Keypair::generate(); + let client = reqwest::Client::new(); + let bad = "https://example.invalid/#fragment"; + let anchor = test_anchor( + "alice/myrepo", + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", + ); + let err = anchor_ref_update(&client, bad, "acct", "matic", &anchor, &kp) + .await + .expect_err("a fragment in the bundler URL must fail the upload"); + assert!( + err.to_string().contains("fragment"), + "error should name the fragment: {err}" + ); + let blobs = vec![("oid1".to_string(), "cid1".to_string())]; + let m = EncryptedManifest { + repo: "alice/r", + owner_did: "did:key:zO", + node_did: "did:key:zN", + timestamp: "2026-06-11T00:00:00Z", + blobs: &blobs, + }; + let err = anchor_encrypted_manifest(&client, bad, "acct", "matic", &m, &kp) + .await + .expect_err("a fragment in the bundler URL must fail the manifest upload"); + assert!( + err.to_string().contains("fragment"), + "error should name the fragment: {err}" + ); + } + /// The gateway read must preserve a query on the gateway config (structural + /// join), so the mock only answers a request whose target carries it. + #[tokio::test] + async fn test_verify_anchor_preserves_gateway_query() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/some-tx-id?token=secret") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"valid":false}"#) + .create_async() + .await; + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + let gateway = format!("{}?token=secret", server.url()); + let r = verify_anchor(&client, &gateway, "some-tx-id", &db, "did:key:zNODE") + .await + .expect("verify_anchor should return Ok"); + assert!(!r.valid, "non-certificate JSON should be invalid"); + mock.assert_async().await; + } + /// A fragment in the gateway URL must be rejected without ever issuing an + /// HTTP request: a fragment is never sent to the gateway, so a config that + /// carries one is a configuration error, surfaced as an invalid result. + #[tokio::test] + async fn test_verify_anchor_rejects_fragment_in_gateway_url() { + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + let r = verify_anchor( + &client, + "https://gateway.example/#fragment", + "some-tx-id", + &db, + "did:key:zNODE", + ) + .await + .expect("verify_anchor should return Ok"); + assert!(!r.valid, "fragment in gateway URL must be invalid"); + assert!( + r.errors.iter().any(|e| e.contains("fragment")), + "errors should name the fragment: {:?}", + r.errors + ); } - #[test] fn manifest_blob_json_omits_recipients() { let v = manifest_blob_json("oid1", "cidA"); @@ -405,10 +1710,1047 @@ mod tests { "Arweave manifest must not anchor recipient identities" ); } - #[test] fn test_sanitize_tag() { assert_eq!(sanitize_tag("alice/myrepo"), "alice/myrepo"); assert_eq!(sanitize_tag("hello world!"), "helloworld"); } + #[tokio::test] + async fn test_verify_anchor_uses_correct_gateway_url() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/does-not-exist") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"valid":false}"#) + .create_async() + .await; + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + let result = verify_anchor( + &client, + &server.url(), + "does-not-exist", + &db, + "did:key:zNODE", + ) + .await; + let r = result.expect("verify_anchor should return Ok for gateway errors"); + assert!(!r.valid, "non-certificate JSON should be invalid"); + mock.assert_async().await; + } + /// A gateway URL carrying a query token must never surface that token in + /// the public VerifyResult error text: reqwest embeds the request URL in + /// its connection error, so the error must be rebuilt from the masked URL. + #[tokio::test] + async fn test_verify_anchor_error_does_not_leak_gateway_query_credentials() { + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + // Port 1 on loopback refuses connections deterministically. + let result = verify_anchor( + &client, + "http://127.0.0.1:1/?token=SECRET", + "txid", + &db, + "did:key:zNODE", + ) + .await; + let r = result.expect("verify_anchor should return Ok for gateway connection errors"); + assert!(!r.valid); + let err_text = r.errors.join(" "); + assert!( + !err_text.contains("SECRET"), + "gateway query token leaked into VerifyResult: {err_text}" + ); + } + #[tokio::test] + async fn test_verify_anchor_malformed_node_did() { + let mut server = mockito::Server::new_async().await; + let bad_cert_json = serde_json::json!({ + "certificate": { + "id": "cert-1", + "repo_id": "repo-uuid", + "ref_name": "refs/heads/main", + "old_sha": "0000000000000000000000000000000000000000", + "new_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "pusher_did": "did:key:zPusher", + "node_did": "malformed-node-did", + "signature": "c2lnbmF0dXJl", + "issued_at": "2026-06-11T00:00:00Z", + "seq": 1, + "prev": "0000000000000000000000000000000000000000000000000000000000000000", + }, + "repo_id": "repo-uuid", + "ref_name": "refs/heads/main", + "old_sha": "0000000000000000000000000000000000000000", + "new_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "node_did": "malformed-node-did", + }); + let _mock = server + .mock("GET", "/test-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&bad_cert_json).unwrap()) + .create_async() + .await; + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + // Verify as "malformed-node-did" itself so the issuer check passes and + // the DID-parse guard is what must fire. This pins the `invalid node + // DID` error push: with the anchor claiming the node IS the malformed + // DID, only parsing the certificate's node_did can reject it. + let result = + verify_anchor(&client, &server.url(), "test-tx", &db, "malformed-node-did").await; + assert!( + result.is_ok(), + "Expected Ok response, got Err: {:?}", + result + ); + let verify_result = result.unwrap(); + assert!(!verify_result.valid, "VerifyResult should be invalid"); + assert!( + verify_result + .errors + .iter() + .any(|e| e.contains("invalid node DID")), + "Expected the DID-parse error, got: {:?}", + verify_result.errors + ); + } + /// Pins the issuer guard (`c.node_did != node_did`): a cert that is fully + /// authentic — real node signature over the real 13-field payload, real + /// pusher proof — but names a DIFFERENT node as its issuer must fail with + /// exactly the issuer-mismatch error. If the guard were removed, the cert + /// would verify clean (the signature resolves against its own node_did), + /// so this test turns that regression red. + #[tokio::test] + async fn test_verify_anchor_rejects_cert_issued_by_different_node() { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let other_kp = gitlawb_core::identity::Keypair::generate(); + let other_did = other_kp.did().as_str().to_string(); + let pusher_kp = gitlawb_core::identity::Keypair::generate(); + let pusher_did = pusher_kp.did().as_str().to_string(); + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + let seq = 1i64; + let prev = "0".repeat(64); + let request_path = "/repo-uuid.git/git-receive-pack"; + let signed = + gitlawb_core::http_sig::sign_request(&pusher_kp, "POST", request_path, b"push-body"); + let pusher_sig = signed + .signature + .strip_prefix("sig1=:") + .and_then(|s| s.strip_suffix(':')) + .unwrap() + .to_string(); + // Signed by `other_kp`, which the payload names as node_did — so the + // cert is internally self-consistent and its signature verifies. + let payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher_did, + "node": other_did, + "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, + "signature_input": signed.signature_input, + "content_digest": signed.content_digest, + "request_path": request_path, + }); + let signature = other_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + let cert = crate::db::RefCertificate { + id: "cert-other-node".to_string(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old_sha.clone(), + new_sha: new_sha.to_string(), + pusher_did, + node_did: other_did.clone(), + signature, + issued_at: issued_at.to_string(), + seq, + prev, + pusher_sig: Some(pusher_sig), + signature_input: Some(signed.signature_input), + content_digest: Some(signed.content_digest), + request_path: Some(request_path.to_string()), + }; + let anchor_json = serde_json::json!({ + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": old_sha, + "new_sha": new_sha, + "node_did": other_did, + "certificate": cert, + }); + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/other-node-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + let result = verify_anchor(&client, &server.url(), "other-node-tx", &db, &node_did).await; + let verify_result = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + !verify_result.valid, + "cert issued by a different node must not verify as valid" + ); + assert!( + verify_result + .errors + .iter() + .any(|e| e.contains("does not match this node")), + "expected the issuer-mismatch error, got: {:?}", + verify_result.errors + ); + _mock.assert_async().await; + } + /// Pins the 13-field signature-failure error push: an authentic cert whose + /// node signature was tampered must fail with the 13-field signature error. + /// If the push were removed, no other guard would catch it (the proof + /// fields are present, so no 7-field fallback runs and the tamper would be + /// silent). + #[tokio::test] + async fn test_verify_anchor_rejects_tampered_13_field_signature() { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let pusher_kp = gitlawb_core::identity::Keypair::generate(); + let pusher_did = pusher_kp.did().as_str().to_string(); + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + let seq = 1i64; + let prev = "0".repeat(64); + let request_path = "/repo-uuid.git/git-receive-pack"; + let signed = + gitlawb_core::http_sig::sign_request(&pusher_kp, "POST", request_path, b"push-body"); + let pusher_sig = signed + .signature + .strip_prefix("sig1=:") + .and_then(|s| s.strip_suffix(':')) + .unwrap() + .to_string(); + let payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher_did, + "node": node_did, + "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, + "signature_input": signed.signature_input, + "content_digest": signed.content_digest, + "request_path": request_path, + }); + let signature = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + // Tamper: decode the b64url signature, flip one byte (guaranteed to + // change the value — unlike prefix replacement, which is a 1-in-64 + // no-op), and re-encode. + let tampered_signature = { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let mut bytes = URL_SAFE_NO_PAD + .decode(&signature) + .expect("signature should decode"); + bytes[0] ^= 0x01; + let tampered = URL_SAFE_NO_PAD.encode(&bytes); + assert_ne!(tampered, signature, "tamper must change the signature"); + tampered + }; + let cert = crate::db::RefCertificate { + id: "cert-tampered-13".to_string(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old_sha.clone(), + new_sha: new_sha.to_string(), + pusher_did, + node_did: node_did.clone(), + signature: tampered_signature, + issued_at: issued_at.to_string(), + seq, + prev, + pusher_sig: Some(pusher_sig), + signature_input: Some(signed.signature_input), + content_digest: Some(signed.content_digest), + request_path: Some(request_path.to_string()), + }; + let anchor_json = serde_json::json!({ + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": old_sha, + "new_sha": new_sha, + "node_did": node_did, + "certificate": cert, + }); + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/tampered-13-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + let result = verify_anchor(&client, &server.url(), "tampered-13-tx", &db, &node_did).await; + let verify_result = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + !verify_result.valid, + "tampered 13-field cert must not verify as valid" + ); + assert!( + verify_result + .errors + .iter() + .any(|e| e.contains("certificate signature verification failed")), + "expected the 13-field signature error, got: {:?}", + verify_result.errors + ); + _mock.assert_async().await; + } + /// Pins the 7-field signature-failure error push: a legacy cert (proof + /// fields NULL) whose node signature was tampered must fail with the + /// 7-field signature error. + #[tokio::test] + async fn test_verify_anchor_rejects_tampered_7_field_signature() { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + let payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": "did:key:z6MkPusher", + "node": node_did, + "ts": issued_at, + }); + let signature = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + // Tamper: decode the b64url signature, flip one byte (guaranteed to + // change the value — unlike prefix replacement, which is a 1-in-64 + // no-op), and re-encode. + let tampered_signature = { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let mut bytes = URL_SAFE_NO_PAD + .decode(&signature) + .expect("signature should decode"); + bytes[0] ^= 0x01; + let tampered = URL_SAFE_NO_PAD.encode(&bytes); + assert_ne!(tampered, signature, "tamper must change the signature"); + tampered + }; + let cert = crate::db::RefCertificate { + id: "cert-tampered-7".to_string(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old_sha.clone(), + new_sha: new_sha.to_string(), + pusher_did: "did:key:z6MkPusher".to_string(), + node_did: node_did.clone(), + signature: tampered_signature, + issued_at: issued_at.to_string(), + seq: 1, + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, + }; + let anchor_json = serde_json::json!({ + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": old_sha, + "new_sha": new_sha, + "node_did": node_did, + "certificate": cert, + }); + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/tampered-7-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + let result = verify_anchor(&client, &server.url(), "tampered-7-tx", &db, &node_did).await; + let verify_result = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + !verify_result.valid, + "tampered 7-field cert must not verify as valid" + ); + assert!( + verify_result.errors.iter().any(|e| e.contains("(7-field)")), + "expected the 7-field signature error, got: {:?}", + verify_result.errors + ); + _mock.assert_async().await; + } + /// A true end-to-end accept: a cert signed by a real node keypair over a + /// real 13-field payload, with a real RFC 9421 pusher proof, served through + /// a mock gateway, must verify to `valid: true` with empty errors. + /// Build an authentic 13-field certificate signed by `node_kp` with a real + /// RFC 9421 pusher proof from `pusher_kp` — the exact shape a live node + /// issues. Shared by the accept and fail-closed corroboration tests. + #[allow(clippy::too_many_arguments)] + fn authentic_13_field_cert( + node_kp: &Keypair, + pusher_kp: &Keypair, + repo_id: &str, + ref_name: &str, + old_sha: &str, + new_sha: &str, + node_did: &str, + issued_at: &str, + seq: i64, + prev: &str, + ) -> crate::db::RefCertificate { + let request_path = "/repo-uuid.git/git-receive-pack"; + let signed = + gitlawb_core::http_sig::sign_request(pusher_kp, "POST", request_path, b"push-body"); + let pusher_sig = signed + .signature + .strip_prefix("sig1=:") + .and_then(|s| s.strip_suffix(':')) + .unwrap() + .to_string(); + let payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher_kp.did().as_str().to_string(), + "node": node_did, + "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, + "signature_input": signed.signature_input, + "content_digest": signed.content_digest, + "request_path": request_path, + }); + let signature = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + crate::db::RefCertificate { + id: "cert-accept-1".to_string(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old_sha.to_string(), + new_sha: new_sha.to_string(), + pusher_did: pusher_kp.did().as_str().to_string(), + node_did: node_did.to_string(), + signature, + issued_at: issued_at.to_string(), + seq, + prev: prev.to_string(), + pusher_sig: Some(pusher_sig), + signature_input: Some(signed.signature_input), + content_digest: Some(signed.content_digest), + request_path: Some(request_path.to_string()), + } + } + /// Run the current schema on a fresh `#[sqlx::test]` pool so DB-backed + /// anchor tests share one seeding path. + async fn migrated_db(pool: sqlx::PgPool) -> crate::db::Db { + let db = crate::db::Db::for_testing(pool); + db.run_migrations().await.expect("migrations should apply"); + db + } + #[sqlx::test] + async fn test_verify_anchor_accepts_authentic_13_field_certificate(pool: sqlx::PgPool) { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let pusher_kp = gitlawb_core::identity::Keypair::generate(); + let owner_did = "did:key:z6MkOwner"; + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + let seq = 1i64; + let prev = "0".repeat(64); + let db = migrated_db(pool).await; + // Seed the repo so the outer identity corroboration actually runs + // against a real row instead of being skipped by a lazy pool. + db.create_repo(&crate::db::RepoRecord { + id: repo_id.to_string(), + name: "myrepo".into(), + owner_did: owner_did.to_string(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + disk_path: "/tmp/anchor-test".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + let cert = authentic_13_field_cert( + &node_kp, &pusher_kp, repo_id, ref_name, &old_sha, new_sha, &node_did, issued_at, seq, + &prev, + ); + // The outer identity fields are present and must corroborate against + // the seeded repo row: expected_repo = normalize_owner_key(owner) / name. + let anchor_json = serde_json::json!({ + "repo": format!("{}/myrepo", crate::db::normalize_owner_key(owner_did)), + "owner_did": owner_did, + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": old_sha, + "new_sha": new_sha, + "node_did": node_did, + "certificate": cert, + }); + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/accept-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + let client = reqwest::Client::new(); + let result = verify_anchor(&client, &server.url(), "accept-tx", &db, &node_did).await; + let r = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + r.valid, + "authentic 13-field cert must verify, errors: {:?}", + r.errors + ); + assert!( + r.errors.is_empty(), + "expected no errors, got: {:?}", + r.errors + ); + _mock.assert_async().await; + } + /// Fail closed: when the anchor carries outer `repo`/`owner_did` claims but + /// the node has no record of the repo, corroboration cannot run — and the + /// verdict must not rest on the certificate signature alone. + #[sqlx::test] + async fn test_verify_anchor_fails_closed_when_outer_identity_cannot_be_corroborated( + pool: sqlx::PgPool, + ) { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let pusher_kp = gitlawb_core::identity::Keypair::generate(); + let owner_did = "did:key:zVictim"; + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + let db = migrated_db(pool).await; + // Deliberately do NOT seed the repo row: the lookup must come up empty. + let cert = authentic_13_field_cert( + &node_kp, + &pusher_kp, + repo_id, + ref_name, + &old_sha, + new_sha, + &node_did, + issued_at, + 1, + &"0".repeat(64), + ); + // Forged outer identity fields, no way to corroborate them. + let anchor_json = serde_json::json!({ + "repo": "victim-owner/victim-repo", + "owner_did": owner_did, + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": old_sha, + "new_sha": new_sha, + "node_did": node_did, + "certificate": cert, + }); + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/uncorroborated-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + let client = reqwest::Client::new(); + let result = + verify_anchor(&client, &server.url(), "uncorroborated-tx", &db, &node_did).await; + let r = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + !r.valid, + "uncorroborated outer identity must not verify as valid" + ); + assert!( + r.errors + .iter() + .any(|e| e.contains("cannot be corroborated")), + "expected the uncorroborated-identity error, got: {:?}", + r.errors + ); + _mock.assert_async().await; + } + /// A tampered seq on an authentic legacy 7-field cert must fail: the + /// 7-field signature does not cover seq/prev, so the node's stored row + /// must be corroborated rather than accepting a blanket valid: true. + #[tokio::test] + async fn test_verify_anchor_legacy_seq_tamper_fails_closed() { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let old_sha = "0".repeat(40); + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let issued_at = "2026-07-22T00:00:00+00:00"; + // Sign the 7-field payload exactly as pre-PR nodes did. + let payload_7 = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": "did:key:z6MkPusher", + "node": node_did, + "ts": issued_at, + }); + let signature = node_kp.sign_b64(&serde_json::to_vec(&payload_7).unwrap()); + let cert = crate::db::RefCertificate { + id: "cert-legacy-tamper".to_string(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old_sha.clone(), + new_sha: new_sha.to_string(), + pusher_did: "did:key:z6MkPusher".to_string(), + node_did: node_did.clone(), + signature, + issued_at: issued_at.to_string(), + seq: 1, + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, + }; + let anchor_json = serde_json::json!({ + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": old_sha, + "new_sha": new_sha, + "node_did": node_did, + "certificate": cert, + }); + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/legacy-tamper-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + // The cert is not present in the (lazy) node database — no stored row + // matches its signed (repo_id, ref_name, old_sha, new_sha, ts), so the + // legacy corroboration must fail closed instead of returning valid. + let result = + verify_anchor(&client, &server.url(), "legacy-tamper-tx", &db, &node_did).await; + let r = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + !r.valid, + "legacy cert not present in node DB must not verify as valid" + ); + assert!( + r.errors + .iter() + .any(|e| e.contains("no stored certificate matches the signed") + || e.contains("error looking up certificate")), + "expected a corroboration error, got: {:?}", + r.errors + ); + _mock.assert_async().await; + } + /// The legacy corroboration must key on the fields the 7-field signature + /// actually covers — never on `id`, which appears in no signed payload. + /// A forged cert that copies `id`/`seq`/`prev` from a stored row at seq 7 + /// while its signed tuple describes a DIFFERENT transition must fail: the + /// old `get_ref_certificate(id)` lookup measured the forger against the row + /// they chose, returning valid:true. + #[sqlx::test] + async fn test_verify_anchor_forged_legacy_cert_cannot_borrow_stored_chain_position( + pool: sqlx::PgPool, + ) { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let db = crate::db::Db::for_testing(pool.clone()); + db.run_migrations().await.expect("migrations should apply"); + // Build a full stored chain seq 1..7 for the repo so every chain check + // the forged cert must survive (prev-linkage against seq-1, predecessor + // lookups) has a real row to pass against. Each cert's `prev` is the + // sha256 of its predecessor's 7-field payload, as production issuance + // computes it. + let repo_id = "repo-uuid"; + let ref_name = "refs/heads/main"; + let mut prev = "0".repeat(64); + let mut stored_at_seq_7: Option = None; + for seq in 1..=7 { + let old = format!("{:040}", seq); + let new = format!("{:040}", seq + 1); + let ts = format!("2026-01-{:02}T00:00:00+00:00", seq); + let payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old, + "new": new, + "pusher": "did:key:z6MkStored", + "node": node_did, + "ts": ts, + }); + let signature = node_kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + let cert = crate::db::RefCertificate { + id: format!("stored-cert-{seq}"), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: old.clone(), + new_sha: new.clone(), + pusher_did: "did:key:z6MkStored".to_string(), + node_did: node_did.clone(), + signature, + issued_at: ts.clone(), + seq, + prev: prev.clone(), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, + }; + db.insert_ref_certificate(&cert) + .await + .expect("stored cert insert should succeed"); + prev = hex::encode(sha2::Sha256::digest(serde_json::to_vec(&payload).unwrap())); + if seq == 7 { + stored_at_seq_7 = Some(cert); + } + } + let stored_seq_7 = stored_at_seq_7.expect("seq-7 cert was inserted"); + // The forged anchor: signed tuple says the transition (repo, ref, + // forged_old, forged_new, forged_ts) — a DIFFERENT, never-recorded + // transition — but id/seq/prev are copied verbatim from the seq-7 + // stored row. The forger mints their own keypair (permissionless + // identities) and signs that payload as node_did. + let forged_kp = gitlawb_core::identity::Keypair::generate(); + let forged_did = forged_kp.did().as_str().to_string(); + let forged_old = "2222222222222222222222222222222222222222"; + let forged_new = "3333333333333333333333333333333333333333"; + let forged_ts = "2026-02-02T00:00:00+00:00"; + let forged_payload = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": forged_old, + "new": forged_new, + "pusher": "did:key:z6MkForged", + "node": forged_did, + "ts": forged_ts, + }); + let forged_signature = forged_kp.sign_b64(&serde_json::to_vec(&forged_payload).unwrap()); + let forged_cert = crate::db::RefCertificate { + id: stored_seq_7.id.clone(), + repo_id: repo_id.to_string(), + ref_name: ref_name.to_string(), + old_sha: forged_old.to_string(), + new_sha: forged_new.to_string(), + pusher_did: "did:key:z6MkForged".to_string(), + node_did: forged_did.clone(), + signature: forged_signature, + issued_at: forged_ts.to_string(), + seq: stored_seq_7.seq, + prev: stored_seq_7.prev.clone(), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, + }; + let anchor_json = serde_json::json!({ + "repo_id": repo_id, + "ref_name": ref_name, + "old_sha": forged_old, + "new_sha": forged_new, + "node_did": forged_did, + "certificate": forged_cert, + }); + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/forged-borrowed-position-tx") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&anchor_json).unwrap()) + .create_async() + .await; + let client = reqwest::Client::new(); + // Verify as the forger's own node: node_did, the issuer check, the + // outer-field cross-check, the signature, and the chain-position + // checks all line up. ONLY the signed-tuple corroboration can catch + // that this cert claims a chain position it never earned. + let result = verify_anchor( + &client, + &server.url(), + "forged-borrowed-position-tx", + &db, + &forged_did, + ) + .await; + let r = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + !r.valid, + "forged cert borrowing a stored chain position must not verify as valid: {:?}", + r.errors + ); + assert!( + r.errors + .iter() + .any(|e| e.contains("no stored certificate matches the signed")), + "expected the signed-tuple corroboration to reject the forged cert, got: {:?}", + r.errors + ); + _mock.assert_async().await; + } + /// A bundler that returns 500 with a body reflecting the request back — the + /// scenario a hostile or buggy endpoint uses to leak the credential-bearing + /// pieces (the `x-irys-paid-by` funded account and the payment token riding + /// in the path) through the error path. The error path must redact them. + async fn spawn_echoing_error_bundler() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let router = axum::Router::new().route( + "/tx/matic", + axum::routing::post( + move |uri: axum::http::Uri, headers: axum::http::HeaderMap| async move { + let paid_by = headers + .get("x-irys-paid-by") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + let target = uri.path_and_query().map(|q| q.as_str()).unwrap_or(""); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!(r#"{{"error":"rejected for {paid_by} at {target}"}}"#), + ) + }, + ), + ); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("http://{addr}") + } + /// A non-success bundler response must not let the remote reflect the + /// credential-bearing request back into the error text: the funded-account + /// identity and the payment token are sent by the node, so a bundler that + /// echoes them (hostile or buggy) must be defeated by the redaction + /// boundary, not surfaced verbatim in logs or a caller-visible error. + #[tokio::test] + async fn test_anchor_ref_update_redacts_credentials_in_error_body() { + let kp = Keypair::generate(); + let client = reqwest::Client::new(); + let server = spawn_echoing_error_bundler().await; + let account = "zSecretFundedAccount"; + let result = anchor_ref_update( + &client, + &server, + account, + "matic", + &test_anchor( + "alice/myrepo", + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", + ), + &kp, + ) + .await; + let err = result.expect_err("a 500 bundler response must fail the upload"); + let text = err.to_string(); + assert!( + text.contains("500"), + "error should carry the status: {text}" + ); + assert!( + !text.contains(account), + "funded account echoed by the bundler must be redacted: {text}" + ); + assert!( + !text.contains("matic"), + "payment token echoed by the bundler must be redacted: {text}" + ); + assert!( + text.contains(""), + "expected a redaction marker: {text}" + ); + } + /// The manifest upload path shares the same redaction boundary: a 500 body + /// that echoes the funded account and token must not reach the error text. + #[tokio::test] + async fn test_manifest_anchor_redacts_credentials_in_error_body() { + let kp = Keypair::generate(); + let client = reqwest::Client::new(); + let server = spawn_echoing_error_bundler().await; + let account = "zSecretFundedAccount"; + let blobs = vec![("oid1".to_string(), "cid1".to_string())]; + let m = EncryptedManifest { + repo: "alice/r", + owner_did: "did:key:zO", + node_did: "did:key:zN", + timestamp: "2026-06-11T00:00:00Z", + blobs: &blobs, + }; + let err = anchor_encrypted_manifest(&client, &server, account, "matic", &m, &kp) + .await + .expect_err("a 500 bundler response must fail the manifest upload"); + let text = err.to_string(); + assert!( + !text.contains(account), + "funded account must be redacted: {text}" + ); + assert!( + !text.contains("matic"), + "payment token must be redacted: {text}" + ); + assert!( + text.contains(""), + "expected a redaction marker: {text}" + ); + } + /// A gateway that announces a body it never delivers (headers promise + /// Content-Length, connection dropped mid-body) surfaces a mid-stream error. + /// That error must be rebuilt through the redaction boundary so a + /// credential-bearing gateway URL never leaks into the public VerifyResult. + #[tokio::test] + async fn test_verify_anchor_interrupted_stream_error_is_masked() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = [0u8; 2048]; + let _ = socket.read(&mut buf).await; + let _ = socket + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ + content-length: 1000\r\n\r\n{\"certificate\":", + ) + .await; + // Drop the connection mid-body: the promised length is never + // delivered, forcing a stream error on the client. + drop(socket); + }); + } + }); + let gateway = format!("http://{addr}/?token=SECRET"); + let client = reqwest::Client::new(); + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://localhost/gitlawb_test_placeholder") + .expect("lazy pool creation should not fail"); + let db = crate::db::Db::for_testing(pool); + let r = verify_anchor(&client, &gateway, "txid", &db, "did:key:zNODE") + .await + .expect("verify_anchor should return Ok for a stream error"); + assert!(!r.valid); + let err_text = r.errors.join(" "); + assert!( + err_text.contains("failed to read response body"), + "expected a masked stream error, got: {err_text}" + ); + assert!( + !err_text.contains("SECRET"), + "gateway query token leaked through the stream error: {err_text}" + ); + } + /// The redaction helpers must scrub a raw URL (userinfo, token in the path) + /// and every secret the node sent out of an error string, and the scrub + /// must apply to remote bodies that reflect the request. + #[test] + fn redaction_helpers_scrub_urls_and_secrets() { + let url = "https://user:pw@example.invalid/tx/matic"; + let display = "https://***@example.invalid/tx/matic"; + let body = format!(r#"{{"error":"rejected for zFundedAccount at {url}"}}"#); + let err = remote_response_error( + "Bundler upload", + &StatusCode::INTERNAL_SERVER_ERROR, + &body, + url, + display, + &["zFundedAccount", "matic"], + ); + let text = err.to_string(); + assert!( + text.contains("Bundler upload returned 500"), + "error should carry prefix and status: {text}" + ); + assert!( + !text.contains("zFundedAccount"), + "funded account leaked: {text}" + ); + assert!(!text.contains("matic"), "payment token leaked: {text}"); + assert!(!text.contains("user:pw"), "URL userinfo leaked: {text}"); + assert!( + !text.contains("example.invalid/tx/matic"), + "raw URL leaked: {text}" + ); + assert!( + text.contains(""), + "expected a redaction marker: {text}" + ); + + // A reqwest-style detail that embeds the raw URL is masked through the + // same boundary (used for connection and mid-stream errors). + let detail = format!("error sending request for url ({url})"); + let detail = redact_remote_detail(&detail, url, display, &["matic"]); + assert!(!detail.contains("user:pw"), "URL userinfo leaked: {detail}"); + assert!(!detail.contains("matic"), "payment token leaked: {detail}"); + assert!( + detail.contains(""), + "expected a redaction marker: {detail}" + ); + } } diff --git a/crates/gitlawb-node/src/auth/mod.rs b/crates/gitlawb-node/src/auth/mod.rs index eee8fd8b..6e90f975 100644 --- a/crates/gitlawb-node/src/auth/mod.rs +++ b/crates/gitlawb-node/src/auth/mod.rs @@ -17,6 +17,24 @@ use crate::state::AppState; #[derive(Clone, Debug)] pub struct AuthenticatedDid(pub String); +/// The raw RFC 9421 HTTP Signature value (the `Signature` header), injected into +/// request extensions by `require_signature`. Pushers sign the request, and the +/// node persists this signature so it can be presented as proof of authorization. +#[derive(Clone, Debug)] +pub struct PusherSignature(pub String); + +/// Full RFC 9421 HTTP Signature context, needed to reconstruct the signing +/// string when verifying the pusher authorization proof. +#[derive(Clone, Debug)] +pub struct PusherProof { + /// The `Signature-Input` header value (e.g. `sig1=("@method" "@path" "content-digest");keyid="...";alg="ed25519";created=1234`) + pub signature_input: String, + /// The `Content-Digest` header value (e.g. `sha-256=:base64:`) + pub content_digest: String, + /// The HTTP request path+query, e.g. /owner/repo.git/git-receive-pack + pub request_path: String, +} + /// Whether `caller` is authorized to push to `record`. /// /// Phase 1 (`GITLAWB_ENFORCE_OWNER_PUSH`): owner-only, via the canonical @@ -29,6 +47,7 @@ pub fn caller_authorized_to_push(record: &crate::db::RepoRecord, caller: &str) - crate::api::did_matches(caller, &record.owner_did) } +use base64::Engine as _; use gitlawb_core::http_sig::{ build_signing_string, compute_content_digest, HttpSignature, COVERED_COMPONENTS, }; @@ -162,17 +181,42 @@ pub async fn require_signature(request: Request, next: Next) -> Response { .unwrap_or("/") .to_string(); - let content_digest = parts - .headers - .get("content-digest") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); + // The signature always covers content-digest (see COVERED_COMPONENTS), so a + // request that claims a valid RFC 9421 signature but sends no Content-Digest + // header is not bound to any particular body. Accepting the empty-string + // substitute would let a signed receive-pack produce a certificate/anchor + // proof that commits to no pushed bytes, so a missing or unreadable header + // is rejected before any proof is issued or presented. + let content_digest = match parts.headers.get("content-digest") { + Some(v) => match v.to_str() { + Ok(s) => s.to_string(), + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "content_digest_invalid", + "message": "Content-Digest header is not a valid string", + })), + ) + .into_response() + } + }, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "content_digest_missing", + "message": "Content-Digest header is required when the signature covers content-digest", + })), + ) + .into_response() + } + }; let mut request_values: HashMap = HashMap::new(); - request_values.insert("@method".to_string(), method); - request_values.insert("@path".to_string(), path_and_query); - request_values.insert("content-digest".to_string(), content_digest); + request_values.insert("@method".to_string(), method.clone()); + request_values.insert("@path".to_string(), path_and_query.clone()); + request_values.insert("content-digest".to_string(), content_digest.to_string()); // The @signature-params value is the part of Signature-Input after "sig1=" let sig_params_value = sig_input.strip_prefix("sig1=").unwrap_or(&sig_input); @@ -217,23 +261,20 @@ pub async fn require_signature(request: Request, next: Next) -> Response { .into_response(); } - // Verify Content-Digest matches the actual request body - if let Some(claimed) = parts - .headers - .get("content-digest") - .and_then(|v| v.to_str().ok()) - { - let actual = compute_content_digest(&body_bytes); - if claimed != actual { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "content_digest_mismatch", - "message": "Content-Digest does not match request body", - })), - ) - .into_response(); - } + // Verify Content-Digest matches the actual request body. The header is + // mandatory above, so this comparison always runs: a signature over the + // empty-string substitute (or a forged digest) never reaches the body check + // with a clean pass, and a present-but-wrong digest is rejected here. + let actual = compute_content_digest(&body_bytes); + if content_digest != actual { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "content_digest_mismatch", + "message": "Content-Digest does not match request body", + })), + ) + .into_response(); } tracing::info!(did = %sig.key_id, "✓ authenticated request"); @@ -242,6 +283,14 @@ pub async fn require_signature(request: Request, next: Next) -> Response { request .extensions_mut() .insert(AuthenticatedDid(sig.key_id.to_string())); + request.extensions_mut().insert(PusherSignature( + base64::engine::general_purpose::STANDARD.encode(&sig.signature_bytes), + )); + request.extensions_mut().insert(PusherProof { + signature_input: sig_input, + content_digest, + request_path: path_and_query, + }); next.run(request).await } @@ -514,6 +563,7 @@ mod tests { machine_id: None, repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool), rate_limiter: RateLimiter::new(100, Duration::from_secs(60)), + arweave_rate_limiter: RateLimiter::new(120, Duration::from_secs(3600)), create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)), push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)), push_limiter_trust: crate::rate_limit::TrustedProxy::None, @@ -626,4 +676,34 @@ mod tests { let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); assert_eq!(body_json["error"], "invalid_ucan"); } + + #[tokio::test] + async fn require_signature_rejects_signed_request_without_content_digest() { + // A request whose Signature-Input covers content-digest but which omits + // the Content-Digest header must be rejected up front. Accepting it would + // let a signed receive-pack produce a certificate/anchor proof that + // commits to no pushed bytes. + let kp = Keypair::generate(); + let _state = make_test_state(kp.did()); + let app = Router::new() + .route("/", axum::routing::post(|| async { StatusCode::OK })) + .layer(middleware::from_fn(require_signature)); + + let signed = gitlawb_core::http_sig::sign_request(&kp, "POST", "/", b"push-body"); + let req = Request::builder() + .method("POST") + .uri("/") + // Content-Digest deliberately omitted + .header("Signature-Input", signed.signature_input) + .header("Signature", signed.signature) + .body(axum::body::Body::from("push-body")) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + let body_bytes = axum::body::to_bytes(resp.into_body(), 2048).await.unwrap(); + let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body_json["error"], "content_digest_missing"); + } } diff --git a/crates/gitlawb-node/src/cert.rs b/crates/gitlawb-node/src/cert.rs index 0ed50418..1d4e8933 100644 --- a/crates/gitlawb-node/src/cert.rs +++ b/crates/gitlawb-node/src/cert.rs @@ -1,58 +1,216 @@ -//! Certificate issuance for ref updates. -//! -//! When a push lands, the node signs a receipt proving the commit was -//! accepted. This receipt is a `RefCertificate` stored in the DB and -//! accessible via the API. +use std::ops::DerefMut; use anyhow::Result; use chrono::Utc; -use uuid::Uuid; +use sha2::{Digest, Sha256}; use crate::db::RefCertificate; use crate::state::AppState; -/// Issue a signed ref-update certificate for a successful push. -/// -/// Builds a canonical JSON payload, signs it with the node's Ed25519 key, -/// persists the certificate, and returns it. -pub async fn issue_ref_certificate( +/// Build the canonical signing payload for a certificate. +#[allow(clippy::too_many_arguments)] +fn cert_payload( + repo_id: &str, + ref_name: &str, + old_sha: &str, + new_sha: &str, + pusher_did: &str, + node_did: &str, + issued_at: &str, + seq: i64, + prev: &str, + pusher_sig: Option, + signature_input: Option, + content_digest: Option, + request_path: Option, +) -> serde_json::Value { + serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher_did, + "node": node_did, + "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, + "signature_input": signature_input, + "content_digest": content_digest, + "request_path": request_path, + }) +} + +/// Compute the SHA-256 prev hash from a predecessor certificate. +fn prev_hash(c: &RefCertificate) -> Result { + let prev_payload = serde_json::json!({ + "repo_id": c.repo_id, + "ref": c.ref_name, + "old": c.old_sha, + "new": c.new_sha, + "pusher": c.pusher_did, + "node": c.node_did, + "ts": c.issued_at, + }); + let prev_bytes = serde_json::to_vec(&prev_payload)?; + Ok(hex::encode(Sha256::digest(&prev_bytes))) +} + +/// Attempt a single cert-issuance within an active transaction. `cert_id` is +/// the certificate id: a deterministic per-(job, ref) value on the durable +/// post-receive job path so a startup replay is a no-op (see +/// [`Db::insert_ref_certificate_tx`]'s `ON CONFLICT (id) DO NOTHING`). +#[allow(clippy::too_many_arguments)] +async fn issue_once( state: &AppState, repo_id: &str, ref_name: &str, old_sha: &str, new_sha: &str, pusher_did: &str, + cert_id: &str, + pusher_sig: &Option, + signature_input: &Option, + content_digest: &Option, + request_path: &Option, + conn: &mut sqlx::postgres::PgConnection, ) -> Result { + // Look up the previous certificate to chain from it. + let prev_cert = state.db.get_most_recent_cert_tx(repo_id, conn).await?; + let seq = prev_cert.as_ref().map_or(1, |c| c.seq + 1); + let prev = match prev_cert.as_ref() { + Some(c) => prev_hash(c)?, + None => "0".repeat(64), + }; + let node_did = state.node_did.to_string(); let issued_at = Utc::now().to_rfc3339(); - // Build the canonical signing payload. - let payload = serde_json::json!({ - "repo_id": repo_id, - "ref": ref_name, - "old": old_sha, - "new": new_sha, - "pusher": pusher_did, - "node": node_did, - "ts": issued_at, - }); + let payload = cert_payload( + repo_id, + ref_name, + old_sha, + new_sha, + pusher_did, + &node_did, + &issued_at, + seq, + &prev, + pusher_sig.clone(), + signature_input.clone(), + content_digest.clone(), + request_path.clone(), + ); let payload_bytes = serde_json::to_vec(&payload)?; - let signature = state.node_keypair.sign_b64(&payload_bytes); let cert = RefCertificate { - id: Uuid::new_v4().to_string(), + id: cert_id.to_string(), repo_id: repo_id.to_string(), ref_name: ref_name.to_string(), old_sha: old_sha.to_string(), new_sha: new_sha.to_string(), pusher_did: pusher_did.to_string(), - node_did, + node_did: node_did.to_string(), signature, - issued_at, + issued_at: issued_at.to_string(), + seq, + prev, + pusher_sig: pusher_sig.clone(), + signature_input: signature_input.clone(), + content_digest: content_digest.clone(), + request_path: request_path.clone(), }; - // Persist and return the row as it exists in the database (on a - // conflict the existing row survives when it is newer). - state.db.insert_ref_certificate(&cert).await + state.db.insert_ref_certificate_tx(&cert, conn).await +} + +/// Issue a signed ref-update certificate for a successful push. +/// +/// `cert_id` is the certificate id to use. The durable post-receive job path +/// passes a deterministic per-(job, ref) value so a startup replay re-issues +/// the SAME id and `insert_ref_certificate_tx`'s `ON CONFLICT (id) DO NOTHING` +/// makes it a no-op — a replayed push must not mint a second certificate for +/// the same transition. +/// +/// Acquires a per-repo advisory lock to atomically allocate the chain +/// sequence number within a single database transaction, preventing race +/// conditions with concurrent pushes to the same repository. +#[allow(clippy::too_many_arguments)] +pub async fn issue_ref_certificate( + state: &AppState, + repo_id: &str, + ref_name: &str, + old_sha: &str, + new_sha: &str, + pusher_did: &str, + cert_id: &str, + pusher_sig: Option, + signature_input: Option, + content_digest: Option, + request_path: Option, +) -> Result { + let mut tx = state.db.pool().begin().await?; + + // Serialize cert issuance per repo within the transaction so the + // advisory lock is held for the entire lock → lookup → insert sequence. + state + .db + .lock_repo_cert_issuance_tx(repo_id, tx.deref_mut()) + .await?; + + let result = issue_once( + state, + repo_id, + ref_name, + old_sha, + new_sha, + pusher_did, + cert_id, + &pusher_sig, + &signature_input, + &content_digest, + &request_path, + &mut tx, + ) + .await; + + match result { + Ok(cert) => { + tx.commit().await?; + Ok(cert) + } + Err(e) => { + // Rollback the failed attempt before retrying + tx.rollback().await?; + let err_str = e.to_string(); + if err_str.contains("23505") || err_str.contains("unique") { + // Retry once with a fresh transaction + let mut tx = state.db.pool().begin().await?; + state + .db + .lock_repo_cert_issuance_tx(repo_id, tx.deref_mut()) + .await?; + let cert = issue_once( + state, + repo_id, + ref_name, + old_sha, + new_sha, + pusher_did, + cert_id, + &pusher_sig, + &signature_input, + &content_digest, + &request_path, + &mut tx, + ) + .await?; + tx.commit().await?; + Ok(cert) + } else { + Err(e) + } + } + } } diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index fefa063e..2dc199d5 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -113,10 +113,56 @@ pub struct Config { #[arg(long, env = "GITLAWB_AUTO_SYNC", default_value_t = false)] pub auto_sync: bool, - /// Irys URL for Arweave permanent anchoring. - /// Leave empty to disable. Use https://devnet.irys.xyz for free devnet. - #[arg(long, env = "GITLAWB_IRYS_URL", default_value = "")] - pub irys_url: String, + /// Bundler URL for Arweave permanent anchoring (Turbo/upload.ardrive.io). + /// Leave empty to disable anchoring. + /// Deprecated alias: --irys-url (renamed after the Irys→Bundler rebrand). + #[arg( + long, + env = "GITLAWB_BUNDLER_URL", + default_value = "", + alias = "irys-url" + )] + pub bundler_url: String, + + /// Funded bundler account (address/identity) that pays for anchoring. + /// The node signs ANS-104 data items with its own keypair, but that + /// signature is proof of authorship, NOT payment: the bundler only serves + /// items backed by a funded account. When `bundler_url` is set this must + /// name the funded account you created for the node (top up via the + /// bundler's devnet faucet for devnet hosts). `validate()` refuses to + /// start with a bundler URL but no funded account. + #[arg( + long, + env = "GITLAWB_BUNDLER_ACCOUNT", + default_value = "", + alias = "irys-account" + )] + pub bundler_account: String, + + /// Irys payment-token slug billed for uploads (e.g. "matic", "ethereum", + /// "solana", "usdc" — see the Irys devnet faucet). Irys serves uploads at + /// `/tx/{token}` and reads the funded address from the `x-irys-paid-by` + /// header, so when `bundler_url` is set this must name the token the + /// funded account holds. `validate()` refuses to start without it. + #[arg( + long, + env = "GITLAWB_BUNDLER_TOKEN", + default_value = "", + alias = "irys-token" + )] + pub bundler_token: String, + + /// Arweave gateway URL for resolving arweave_tx_id to data items. + /// Used by the verify endpoint and the anchors listing. + /// Required whenever `bundler_url` is set: anchors uploaded to a bundler + /// are only resolvable through the gateway of the SAME network, and the + /// inference that used to pair the two silently broke production verify + /// reads (a devnet bundler's txns are not resolvable via arweave.net, and + /// vice versa), so the operator must pick the network consciously. + /// No default: an unset gateway keeps the node's /verify and anchor + /// resolution inert, which is correct for a node that does not anchor. + #[arg(long, env = "GITLAWB_ARWEAVE_GATEWAY", default_value = "")] + pub arweave_gateway: String, /// Base L2 DID registry contract address (0x...) #[arg(long, env = "GITLAWB_CONTRACT_DID_REGISTRY", default_value = "")] @@ -167,6 +213,13 @@ pub struct Config { #[arg(long, env = "GITLAWB_MAX_PACK_BYTES", default_value_t = 2_147_483_648)] pub max_pack_bytes: usize, + /// Per-client-IP rate limit for the Arweave verify endpoint + /// (`GET /api/v1/arweave/verify/:tx_id`), in requests per hour. The route is + /// unauthenticated, so it is throttled by the resolved client IP. `0` + /// disables. Default: 120. + #[arg(long, env = "GITLAWB_ARWEAVE_RATE_LIMIT", default_value_t = 120)] + pub arweave_rate_limit: usize, + /// Per-client-IP rate limit for `POST /api/v1/sync/trigger`, in requests per /// hour. `/sync/trigger` requires a signature and drives an O(peers) outbound /// fan-out per call, so it gets a tight bucket. `0` disables. Default: 60. @@ -583,8 +636,78 @@ impl Config { floor )); } + // Anchoring writes real, permanent transactions: the node's ANS-104 + // signature on each data item is authorship, not payment, and the + // bundler rejects items its funded-account ledger does not back. + // Refusing to start keeps an operator from silently losing every + // anchor to "Not enough balance" (see api/repos.rs anchor call sites, + // which degrade the push to a warning rather than fail it). + if !self.bundler_url.trim().is_empty() && self.bundler_account.trim().is_empty() { + return Err( + "GITLAWB_BUNDLER_URL is set but GITLAWB_BUNDLER_ACCOUNT is not: the data item \ + signature is not bundler payment. Create a funded account for this node (top up \ + via the bundler's devnet faucet for devnet hosts) and set GITLAWB_BUNDLER_ACCOUNT to its \ + address/identity, or clear GITLAWB_BUNDLER_URL to disable anchoring." + .to_string(), + ); + } + // Irys uploads are billed against a payment token at /tx/{token}; the + // header the node sends is pointless if the operator has not said which + // token the funded account holds. + if !self.bundler_url.trim().is_empty() && self.bundler_token.trim().is_empty() { + return Err( + "GITLAWB_BUNDLER_URL is set but GITLAWB_BUNDLER_TOKEN is not: Irys bills uploads \ + against a payment token at /tx/{token} and reads x-irys-paid-by for the funded \ + address. Set GITLAWB_BUNDLER_TOKEN to the token the funded account holds (e.g. \ + 'matic' on the Irys devnet), or clear GITLAWB_BUNDLER_URL to disable anchoring." + .to_string(), + ); + } + // Anchoring is enabled, so the gateway must be chosen deliberately + // (#224 review). Anchors uploaded to a bundler are only resolvable + // through the gateway of the SAME network — an Irys devnet bundler's + // transactions are not resolvable via arweave.net, and mainnet Irys + // transactions are not resolvable via the devnet gateway — and the + // node refuses to start here rather than silently pair the two. The + // same fail-fast shape as the funded-account/token checks above. + if !self.bundler_url.trim().is_empty() && self.arweave_gateway.trim().is_empty() { + return Err(format!( + "GITLAWB_BUNDLER_URL is set to {} but GITLAWB_ARWEAVE_GATEWAY is not: an anchor \ + is only resolvable through the gateway of the network that recorded it. Set \ + GITLAWB_ARWEAVE_GATEWAY to the matching gateway for your bundler network \ + (devnet bundler https://devnet.irys.xyz pairs with the devnet gateway; \ + production bundler https://node2.irys.xyz pairs with https://arweave.net), or \ + clear GITLAWB_BUNDLER_URL to disable anchoring.", + crate::server::mask_credential_url(&self.bundler_url) + )); + } Ok(()) } + + /// Decide whether to adopt a legacy `GITLAWB_IRYS_URL` as the bundler URL. + /// + /// A bare URL no longer enables paid anchoring — uploads are billed to a + /// funded account via `x-irys-paid-by` at `/tx/{token}`, and `validate()` + /// refuses to start with a URL but no funded account/token. Adopting the + /// legacy value unconditionally would therefore break every deployment that + /// only ever set the URL. The legacy value is honored only when the operator + /// has opted into the new funded-account pair; otherwise `None` is returned + /// (anchoring stays disabled and the node starts, with a warning at the call + /// site). + pub fn legacy_bundler_url_fallback( + legacy_url: &str, + bundler_account: &str, + bundler_token: &str, + ) -> Option { + if legacy_url.is_empty() { + return None; + } + if !bundler_account.trim().is_empty() && !bundler_token.trim().is_empty() { + Some(legacy_url.to_string()) + } else { + None + } + } } #[cfg(test)] @@ -608,6 +731,41 @@ mod tests { ); } + #[test] + fn legacy_irys_url_is_adopted_only_with_funded_account_pair() { + // Full opt-in: legacy URL + the new funded-account pair -> adopted. + assert_eq!( + Config::legacy_bundler_url_fallback("https://devnet.irys.xyz", "0xabc", "matic"), + Some("https://devnet.irys.xyz".to_string()) + ); + // Legacy URL alone no longer enables anchoring: validate() would refuse + // to start, so the fallback stays disabled and the node boots. + assert_eq!( + Config::legacy_bundler_url_fallback("https://devnet.irys.xyz", "", ""), + None + ); + // Partial opt-in (account but no token, or vice versa) is also refused: + // both halves of the funded-account pair are required. + assert_eq!( + Config::legacy_bundler_url_fallback("https://devnet.irys.xyz", "0xabc", ""), + None + ); + assert_eq!( + Config::legacy_bundler_url_fallback("https://devnet.irys.xyz", "", "matic"), + None + ); + // Whitespace-only account/token are not an opt-in. + assert_eq!( + Config::legacy_bundler_url_fallback("https://devnet.irys.xyz", " ", " "), + None + ); + // Empty legacy value: nothing to adopt. + assert_eq!( + Config::legacy_bundler_url_fallback("", "0xabc", "matic"), + None + ); + } + /// #174 (RED-before/GREEN-after): the upper bound is what keeps every duration /// derived from this knob in range — the lease steal bound's `* 2 + 60` on the write /// path, and the `Instant::now() + Duration::from_secs(..)` deadlines in @@ -973,4 +1131,218 @@ mod tests { "db_max_connections at the floor (pushes + headroom) must validate" ); } + + /// Anchoring is paid, not free: the ANS-104 signature proves authorship, + /// and the bundler bills the funded account the upload names. A bundler URL + /// without a declared funded account and payment token must refuse to start, + /// or every anchor silently fails with "Not enough balance" behind a + /// push-time warning. + #[test] + fn bundler_url_requires_a_funded_account() { + // Defaults (no bundler) validate. + Config::parse_from(["gitlawb-node"]) + .validate() + .expect("default config must validate"); + + // Bundler URL alone must be rejected. + let no_account = + Config::parse_from(["gitlawb-node", "--bundler-url", "https://devnet.irys.xyz"]); + let err = no_account + .validate() + .expect_err("bundler URL without a funded account must be rejected"); + assert!( + err.contains("GITLAWB_BUNDLER_ACCOUNT"), + "error must name the missing account: {err}" + ); + + // Account without a payment token must still be rejected: Irys bills + // at /tx/{token}, so the header alone cannot be charged. + let no_token = Config::parse_from([ + "gitlawb-node", + "--bundler-url", + "https://devnet.irys.xyz", + "--bundler-account", + "zBundlerAccount", + ]); + let err = no_token + .validate() + .expect_err("bundler URL with an account but no token must be rejected"); + assert!( + err.contains("GITLAWB_BUNDLER_TOKEN"), + "error must name the missing token: {err}" + ); + + // URL plus account plus token validates (with an explicit gateway, as + // `bundler_url_requires_an_explicit_gateway` now requires). + Config::parse_from([ + "gitlawb-node", + "--bundler-url", + "https://devnet.irys.xyz", + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + "https://devnet.irys.xyz", + ]) + .validate() + .expect("bundler URL with a funded account and token must validate"); + } + + /// #224 review: anchoring is enabled, so the gateway must be chosen + /// deliberately — the old behavior silently paired the gateway to the + /// bundler URL, which broke /verify for production deployments (devnet + /// transactions are not resolvable via arweave.net). A bundler URL without + /// an explicit gateway must refuse to start, naming both URLs and which + /// network each must be on. + #[test] + fn bundler_url_requires_an_explicit_gateway() { + // Defaults (no bundler) validate with an unset gateway: a node that + // does not anchor has no need of gateway resolution. + Config::parse_from(["gitlawb-node"]) + .validate() + .expect("default config must validate"); + + // Bundler + account + token but no gateway must be rejected. + let no_gateway = Config::parse_from([ + "gitlawb-node", + "--bundler-url", + "https://devnet.irys.xyz", + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + ]); + let err = no_gateway + .validate() + .expect_err("bundler URL without an explicit gateway must be rejected"); + assert!( + err.contains("GITLAWB_ARWEAVE_GATEWAY"), + "error must name the missing gateway: {err}" + ); + assert!( + err.contains("https://devnet.irys.xyz"), + "error must name the bundler URL: {err}" + ); + assert!( + err.contains("https://arweave.net"), + "error must name the matching production gateway: {err}" + ); + + // Bundler + account + token + gateway validates. + Config::parse_from([ + "gitlawb-node", + "--bundler-url", + "https://devnet.irys.xyz", + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "matic", + "--arweave-gateway", + "https://devnet.irys.xyz", + ]) + .validate() + .expect("bundler URL with a funded account, token, and explicit gateway must validate"); + + // Production shape: mainnet bundler + arweave.net gateway validates. + Config::parse_from([ + "gitlawb-node", + "--bundler-url", + "https://node2.irys.xyz", + "--bundler-account", + "zBundlerAccount", + "--bundler-token", + "ethereum", + "--arweave-gateway", + "https://arweave.net", + ]) + .validate() + .expect("production bundler + arweave.net gateway must validate"); + } + + /// The shipped `.env.example` must stay startable. Anchoring is paid, and + /// `validate()` refuses a bundler URL without both a funded account, a + /// payment token, and an explicit gateway, so the example must never ship a + /// non-empty `GITLAWB_BUNDLER_URL` that the file itself does not also back + /// with `GITLAWB_BUNDLER_ACCOUNT`, `GITLAWB_BUNDLER_TOKEN`, and + /// `GITLAWB_ARWEAVE_GATEWAY`. The app has no dotenv loader, so this test + /// keys on the file's active (non-commented) lines the way a user + /// `source`-ing the example would. + #[test] + fn env_example_bundler_block_is_startable() { + let example_path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.env.example"); + let contents = std::fs::read_to_string(&example_path).unwrap_or_else(|e| { + panic!("cannot read shipped .env.example at {example_path:?}: {e}") + }); + + let active = |key: &str| -> String { + contents + .lines() + .map(str::trim) + .find(|l| l.starts_with(key) && !l.starts_with('#')) + .map(|l| l[key.len()..].trim().to_string()) + .unwrap_or_default() + }; + + let url = active("GITLAWB_BUNDLER_URL="); + let account = active("GITLAWB_BUNDLER_ACCOUNT="); + let token = active("GITLAWB_BUNDLER_TOKEN="); + let gateway = active("GITLAWB_ARWEAVE_GATEWAY="); + if !url.is_empty() { + assert!( + !account.is_empty(), + ".env.example sets GITLAWB_BUNDLER_URL but no active GITLAWB_BUNDLER_ACCOUNT" + ); + assert!( + !token.is_empty(), + ".env.example sets GITLAWB_BUNDLER_URL but no active GITLAWB_BUNDLER_TOKEN" + ); + assert!( + !gateway.is_empty(), + ".env.example sets GITLAWB_BUNDLER_URL but no active GITLAWB_ARWEAVE_GATEWAY" + ); + } + + // Whatever the example ships, it must be a shape `validate()` accepts, so a + // user who exports the example as-is can start the node. + let args = [ + "gitlawb-node", + "--bundler-url", + &url, + "--bundler-account", + &account, + "--bundler-token", + &token, + "--arweave-gateway", + &gateway, + ]; + Config::parse_from(args) + .validate() + .unwrap_or_else(|e| panic!("the shipped .env.example must be startable: {e}")); + } + + /// #224 review: the gateway-inference behavior is gone, so there is no + /// notion of an "explicit" gateway source to detect — `validate()` instead + /// requires a non-empty gateway whenever a bundler is configured (see + /// `bundler_url_requires_an_explicit_gateway`). The clap field carries no + /// default, so an unset gateway is simply empty and the pairing footgun + /// cannot silently select a network for the operator. + #[test] + fn arweave_gateway_has_no_default_network() { + // No flag, no env (in the test process) → empty, not a network URL. + assert_eq!( + Config::parse_from(["gitlawb-node"]).arweave_gateway, + "", + "arweave_gateway must have no default network so a missing gateway is a hard error" + ); + + // An operator-chosen gateway is preserved verbatim. + let cfg = Config::parse_from([ + "gitlawb-node", + "--arweave-gateway", + "https://custom.example.com", + ]); + assert_eq!(cfg.arweave_gateway, "https://custom.example.com"); + } } diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 50c3bdda..dc9187d7 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -136,6 +136,75 @@ pub struct RefCertificate { pub node_did: String, pub signature: String, pub issued_at: String, + /// Monotonic sequence number for chain continuity + pub seq: i64, + /// Hash of the previous certificate in the chain (first cert uses zeros) + pub prev: String, + /// RFC 9421 HTTP Signature from the pusher, proving they authorized this push + pub pusher_sig: Option, + /// RFC 9421 Signature-Input header value, needed to reconstruct the signing + /// string for pusher authorization verification. + pub signature_input: Option, + /// Content-Digest header value covering the request body (RFC 9421). + pub content_digest: Option, + /// The HTTP request path (e.g. /owner/repo.git/git-receive-pack) for RFC 9421 + /// signing-string reconstruction. + pub request_path: Option, +} + +/// One ref transition a durable post-receive job owes, in a serde-friendly form +/// so it can be persisted in the `post_receive_jobs` JSONB column and replayed +/// after a crash. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobRefUpdate { + pub old_sha: String, + pub new_sha: String, + pub ref_name: String, +} + +/// The pusher's RFC 9421 attestation, persisted with the post-receive job so +/// per-ref certificates can be issued during a replay with the same proof the +/// original push carried. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PostReceiveAttestation { + pub sig: Option, + pub signature_input: Option, + pub content_digest: Option, + pub request_path: Option, +} + +/// A durable post-receive job (#224 review): the post-ack work a landed push +/// owes that the durability contract covers — trust-score `record_push`, +/// per-ref signed certificates, and the Arweave anchor (upload + its DB row), +/// each awaited in the job body before the job reaches `done` — with its inputs +/// persisted BEFORE the push is acknowledged. Tokio cancels spawned tasks on +/// restart/shutdown, so a push whose continuation task died before reaching the +/// bookkeeping left a durable ref update with no certificate, accounting, or +/// anchor and no way to recover it. Persisting the job first makes that interval +/// recoverable: startup resets stale rows to `pending` and replays them, and +/// each effect is idempotent (`record_push` keys on the job id, certificates on +/// a deterministic per-(job, ref) id, the Arweave anchor on an existence +/// check), so a replay never double-counts, double-issues, or double-anchors. +/// The rest of the replication tail — Pinata pins, gossip publish, GraphQL +/// broadcast, peer notify — is explicitly best-effort and OUTSIDE this +/// contract: those steps are not recovered by a replay. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostReceiveJob { + pub id: String, + /// The DID that pushed — the signer of the RFC 9421 attestation, and the + /// subject of the trust-score `record_push`. Persisted because a startup + /// replay runs long after the handler that knew the caller is gone. + pub pusher_did: String, + pub owner_did: String, + pub repo_name: String, + pub repo_id: String, + pub ref_updates: Vec, + pub attestation: PostReceiveAttestation, + /// pending | processing | done | failed + pub status: String, + pub enqueued_at: String, + pub attempts: i64, + pub error: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -246,7 +315,7 @@ pub struct ProfileRecord { #[derive(Clone)] pub struct Db { - pool: PgPool, + pub(crate) pool: PgPool, } impl Db { @@ -453,6 +522,20 @@ impl Db { // appended to v1. Operators can read `schema_migrations` to confirm a node // is at the expected version. // +// NOTE: the released v1 schema has NO cert-chain columns: `ref_certificates` +// carries only (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, +// signature, issued_at). The chain fields seq, prev, and pusher_sig are added +// by migration v18 (alongside `arweave_anchors.cert_id` and the +// `irys_tx_id` → `arweave_tx_id` rename); the proof columns +// signature_input, content_digest, and request_path are added by v19. +// New installs reach v18/v19 via sequential migration; existing installs with +// the columns already present are no-ops via IF NOT EXISTS. v20 drops the +// superseded (repo_id, ref_name) unique index that v10 created; that drop is +// one-way and rollback-unsupported (see the migration's own comment). +// v21 adds the durable post-receive job table, and v22 turns the +// `arweave_anchors` row into a per-transition durable claim/outbox +// (state, item_id, claim_token) with a unique transition index. +// // Each migration runs in a single transaction, so statements that Postgres // forbids inside a transaction (notably `CREATE INDEX CONCURRENTLY`) cannot be // used here. Build such indexes the ordinary, transaction-safe way, or stage @@ -883,14 +966,6 @@ const MIGRATIONS: &[Migration] = &[ "ALTER TABLE received_ref_updates ADD COLUMN IF NOT EXISTS owner_did TEXT", ], }, - // Reservation: v17, deliberately not main's current_max + 1 (which is 12). - // The runner keys the applied set on the integer alone, so a version another - // in-flight branch also claims is skipped in full on whichever side merges - // second — no error, no warning, and schema_migrations still reads healthy - // while the column is simply absent. Two open branches already claim into - // this range: #135/#173 holds through 14 (15 once it rebases past v11), and - // #253 took 16. 17 clears both. Gaps are harmless: the runner iterates the - // array and never requires contiguity. Migration { version: 17, name: "sync_queue_attempted_at", @@ -901,6 +976,148 @@ const MIGRATIONS: &[Migration] = &[ "ALTER TABLE sync_queue ADD COLUMN IF NOT EXISTS attempted_at TEXT", ], }, + // Arweave anchoring (#26). Numbered 18/19: versions 12–16 are claimed by + // other in-flight branches, and main's current max is 17. The runner keys + // the applied set on the integer alone, so gaps are harmless. + Migration { + version: 18, + name: "arweave_anchor_v2_and_cert_chain", + stmts: &[ + "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS seq BIGINT NOT NULL DEFAULT 1", + "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS prev TEXT NOT NULL DEFAULT '0000000000000000000000000000000000000000000000000000000000000000'", + "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS pusher_sig TEXT", + "ALTER TABLE arweave_anchors ADD COLUMN IF NOT EXISTS cert_id TEXT", + // Rename irys_tx_id → arweave_tx_id only if the old column still exists + // (fresh databases created by v1 already use arweave_tx_id). + "DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='arweave_anchors' AND column_name='irys_tx_id') THEN ALTER TABLE arweave_anchors RENAME COLUMN irys_tx_id TO arweave_tx_id; END IF; END $$", + "ALTER TABLE arweave_anchors DROP COLUMN IF EXISTS arweave_url", + ], + }, + Migration { + version: 19, + name: "append_only_certs_and_pusher_proof", + stmts: &[ + // Backfill: assign sequential seq values to existing certificates + // before creating the unique index. Migrations v10/v11 may have left + // multiple rows per repo (from different refs) all at seq = 1. + // The prev column is intentionally NOT backfilled here: chain + // verification in verify_anchor computes expected_prev dynamically + // from the predecessor's 7 canonical fields (repo_id, ref, old, + // new, pusher, node, ts), never reading the DB's prev column. + // Existing prev values already match what was computed at issuance. + r#"UPDATE ref_certificates + SET seq = subq.new_seq + FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY repo_id ORDER BY issued_at ASC, id ASC + ) AS new_seq + FROM ref_certificates + ) subq + WHERE ref_certificates.id = subq.id"#, + // Make cert chain append-only: add a unique constraint on + // (repo_id, seq) so concurrent pushes cannot collide on the same + // sequence number. The superseded (repo_id, ref_name) unique index + // is dropped in v20 of this same release — it cannot be deferred + // any longer because append-only REQUIRES multiple rows per + // (repo_id, ref_name), which a unique index forbids; the two are + // mutually exclusive. Nodes share no database (each runs its own + // local Postgres), so the drop cannot strand a mixed-version + // writer mid-rollout. + "CREATE UNIQUE INDEX IF NOT EXISTS idx_ref_certs_repo_seq ON ref_certificates(repo_id, seq)", + // Store the full HTTP Signature context so a third party can verify + // the pusher authorization proof (RFC 9421). + "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS signature_input TEXT", + "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS content_digest TEXT", + "ALTER TABLE ref_certificates ADD COLUMN IF NOT EXISTS request_path TEXT", + ], + }, + Migration { + version: 20, + name: "drop_ref_certs_repo_ref_unique", + // ONE-WAY, ROLLBACK-UNSUPPORTED: this drops the unique index that v10 + // (ref_cert_unique_per_ref) created. Rolling back to v19 would require + // re-creating `idx_ref_certs_repo_ref`, which a release built at v20+ + // cannot do (the migration that created it has been superseded). + // Operators must treat v20 as terminal: there is no supported downgrade + // past it. The drop itself is the point of the migration — the old + // index would reject the second cert insert for a ref, which the + // append-only cert chain (v19) requires. + stmts: &[ + // Remove the superseded (repo_id, ref_name) unique index (v10). v19 makes + // the cert chain append-only, which requires multiple rows per + // (repo_id, ref_name); the unique index would reject the second + // insert for a ref. Deferring the drop is impossible for the same + // reason the old index could not survive this feature in any later + // release, and nodes each run their own local Postgres so there is + // no mixed-version shared database to strand a writer. + "DROP INDEX IF EXISTS idx_ref_certs_repo_ref", + ], + }, + // Durable post-receive jobs (#224 review). Numbered 21: versions 12–16 are + // claimed by other in-flight branches, and 17 is main's prior max (18–20 + // are this same branch's earlier migrations; #173 renumbers before merge). + // The runner keys the applied set on the integer alone, so gaps are + // harmless. + Migration { + version: 21, + name: "durable_post_receive_jobs", + stmts: &[ + r#"CREATE TABLE IF NOT EXISTS post_receive_jobs ( + id TEXT NOT NULL PRIMARY KEY, + pusher_did TEXT NOT NULL, + owner_did TEXT NOT NULL, + repo_name TEXT NOT NULL, + repo_id TEXT NOT NULL, + ref_updates JSONB NOT NULL, + attestation JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + enqueued_at TEXT NOT NULL, + attempted_at TEXT, + processed_at TEXT, + error TEXT + )"#, + "CREATE INDEX IF NOT EXISTS idx_post_receive_jobs_status ON post_receive_jobs(status, enqueued_at)", + ], + }, + // Per-transition Arweave anchor outbox (#224 review): the anchor row IS the + // durable claim. `anchor_ref_updates` atomically INSERTs the transition row + // in `pending` BEFORE any paid upload is attempted, then moves it through + // `uploading` → `recorded` (or `failed`). The unique (repo, ref_name, + // old_sha, new_sha) index makes competing workers converge: only one INSERT + // wins, so only one worker can ever pay for a given transition. `item_id` + // is the ANS-104 data-item id computed from the signed item BEFORE the + // upload request is sent; a recovery that finds the row in `pending`/ + // `uploading` with an `item_id` probes the gateway for that id to decide + // whether the crashed upload actually landed before ever issuing a second + // paid request. `claim_token`/`claimed_at` record who holds the lease. + Migration { + version: 22, + name: "arweave_anchor_outbox", + stmts: &[ + // Existing rows were all uploaded and recorded by earlier code, so + // backfill them as `recorded` (the durable terminal state). + "ALTER TABLE arweave_anchors ADD COLUMN IF NOT EXISTS state TEXT NOT NULL DEFAULT 'recorded'", + "ALTER TABLE arweave_anchors ADD COLUMN IF NOT EXISTS item_id TEXT", + "ALTER TABLE arweave_anchors ADD COLUMN IF NOT EXISTS claim_token TEXT", + "ALTER TABLE arweave_anchors ADD COLUMN IF NOT EXISTS claimed_at TEXT", + // A claimed (pending/uploading) outbox row has no transaction id yet; + // only the recorded row carries one. + "ALTER TABLE arweave_anchors ALTER COLUMN arweave_tx_id DROP NOT NULL", + // Dedup before the unique index: earlier releases had no uniqueness + // on a transition, so an existing database could carry two anchors + // for one (repo, ref, old→new). Keep the earliest recorded row (the + // original artifact) and drop the stragglers' LISTING rows — the + // permanent on-chain artifacts themselves cannot be un-published, + // but the audit table must not block the claim index. + r#"DELETE FROM arweave_anchors a + USING arweave_anchors b + WHERE a.repo = b.repo AND a.ref_name = b.ref_name + AND a.old_sha = b.old_sha AND a.new_sha = b.new_sha + AND (a.anchored_at, a.id) > (b.anchored_at, b.id)"#, + "CREATE UNIQUE INDEX IF NOT EXISTS idx_arweave_anchors_transition ON arweave_anchors(repo, ref_name, old_sha, new_sha)", + ], + }, ]; // ── Repos ───────────────────────────────────────────────────────────────────── @@ -1079,6 +1296,19 @@ impl Db { Ok(row.map(row_to_repo)) } + /// Fetch a repo by its UUID (the `repo_id` committed to by certificates). + pub async fn get_repo_by_id(&self, id: &str) -> Result> { + let row = sqlx::query( + "SELECT id, name, owner_did, description, is_public, default_branch, + created_at, updated_at, disk_path, forked_from, machine_id + FROM repos WHERE id = $1", + ) + .bind(id) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(row_to_repo)) + } + #[allow(dead_code)] pub async fn list_repos(&self, owner_did: &str) -> Result> { let rows = sqlx::query( @@ -1469,8 +1699,13 @@ impl Db { Ok(()) } - pub async fn record_push( + /// Idempotent `record_push` for the durable post-receive job path (#224): + /// the push event's `id` is the job id, so a replay of the same job is a + /// no-op (`ON CONFLICT (id) DO NOTHING`) instead of double-counting the + /// push — which would inflate the pusher's trust score. + pub async fn record_push_job( &self, + job_id: &str, agent_did: &str, repo_id: &str, commit_hash: &str, @@ -1478,9 +1713,10 @@ impl Db { ) -> Result<()> { sqlx::query( "INSERT INTO push_events (id, agent_did, repo_id, commit_hash, object_count, pushed_at) - VALUES ($1, $2, $3, $4, $5, $6)", + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (id) DO NOTHING", ) - .bind(Uuid::new_v4().to_string()) + .bind(job_id) .bind(agent_did) .bind(repo_id) .bind(commit_hash) @@ -1735,6 +1971,148 @@ impl Db { } } +// ── Durable post-receive jobs ───────────────────────────────────────────────── + +impl Db { + /// Persist a post-receive job BEFORE the push is acknowledged (#224): a + /// push whose detached continuation task is cancelled by a restart before + /// reaching record_push/cert/anchor would otherwise leave a durable ref + /// update with no bookkeeping and no recovery record. `ON CONFLICT (id) DO + /// NOTHING` makes a retried enqueue a no-op. + pub async fn enqueue_post_receive_job(&self, job: &PostReceiveJob) -> Result<()> { + sqlx::query( + "INSERT INTO post_receive_jobs + (id, pusher_did, owner_did, repo_name, repo_id, ref_updates, attestation, status, attempts, enqueued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', 0, $8) + ON CONFLICT (id) DO NOTHING", + ) + .bind(&job.id) + .bind(&job.pusher_did) + .bind(&job.owner_did) + .bind(&job.repo_name) + .bind(&job.repo_id) + .bind(serde_json::to_value(&job.ref_updates)?) + .bind(serde_json::to_value(&job.attestation)?) + .bind(&job.enqueued_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Atomically claim a post-receive job for processing. The conditional + /// `WHERE status IN ('pending','failed')` means only one worker wins the + /// claim; a concurrent drainer's claim updates zero rows and it must not + /// run the job body (#224 review: two simultaneous drainers must converge + /// on one executor per job). `done`/`failed` transitions are unconditional + /// because only the claiming worker runs the body. + pub async fn claim_post_receive_job(&self, id: &str) -> Result { + let now = Utc::now().to_rfc3339(); + let result = sqlx::query( + "UPDATE post_receive_jobs + SET status = 'processing', attempted_at = $1, attempts = attempts + 1, error = NULL + WHERE id = $2 AND status IN ('pending', 'failed')", + ) + .bind(&now) + .bind(id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() == 1) + } + + /// Advance a job's status. `done` stamps `processed_at`; `processing` + /// stamps `attempted_at` and increments `attempts`. `failed` records the + /// error so operators can see why a job never completed. + pub async fn update_post_receive_job( + &self, + id: &str, + status: &str, + error: Option<&str>, + ) -> Result<()> { + let now = Utc::now().to_rfc3339(); + let result = + match status { + "done" => sqlx::query( + "UPDATE post_receive_jobs SET status = 'done', processed_at = $1, error = NULL + WHERE id = $2", + ) + .bind(&now) + .bind(id) + .execute(&self.pool) + .await?, + "failed" => { + sqlx::query( + "UPDATE post_receive_jobs SET status = 'failed', error = $1 + WHERE id = $2", + ) + .bind(error) + .bind(id) + .execute(&self.pool) + .await? + } + _ => { + sqlx::query( + "UPDATE post_receive_jobs SET status = $1, attempted_at = $2, + attempts = attempts + 1, error = NULL + WHERE id = $3", + ) + .bind(status) + .bind(&now) + .bind(id) + .execute(&self.pool) + .await? + } + }; + if result.rows_affected() == 0 { + tracing::warn!(job_id = %id, status, "post-receive job not found for status update"); + } + Ok(()) + } + + /// Startup recovery (#224): every job that a previous process left + /// mid-flight (`processing`) or failed is reset to `pending` so the startup + /// drain replays it. A fresh process has no in-flight jobs, so resetting is + /// safe; a job that keeps failing stays `failed` between drains and its + /// error is preserved for operators until the next restart resets it. + pub async fn reset_stale_post_receive_jobs(&self) -> Result<()> { + sqlx::query( + "UPDATE post_receive_jobs SET status = 'pending', error = NULL + WHERE status IN ('processing', 'failed')", + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Pending jobs in enqueue order, for the startup drain. + pub async fn list_pending_post_receive_jobs(&self) -> Result> { + let rows = sqlx::query( + "SELECT id, pusher_did, owner_did, repo_name, repo_id, ref_updates, attestation, status, + attempts, enqueued_at, error + FROM post_receive_jobs + WHERE status = 'pending' + ORDER BY enqueued_at ASC, id ASC", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| PostReceiveJob { + id: r.get("id"), + pusher_did: r.get("pusher_did"), + owner_did: r.get("owner_did"), + repo_name: r.get("repo_name"), + repo_id: r.get("repo_id"), + ref_updates: serde_json::from_value(r.get("ref_updates")).unwrap_or_default(), + attestation: serde_json::from_value(r.get("attestation")).unwrap_or_default(), + status: r.get("status"), + enqueued_at: r.get("enqueued_at"), + attempts: r.get::("attempts") as i64, + error: r.get("error"), + }) + .collect()) + } +} + // ── Pull Requests ───────────────────────────────────────────────────────────── impl Db { @@ -2036,31 +2414,16 @@ impl Db { // ── Ref Certificates ────────────────────────────────────────────────────────── impl Db { - /// Insert a ref certificate, or update it if a row for `(repo_id, ref_name)` - /// already exists. The update only applies when the incoming row is newer - /// (compared by `issued_at`, which assumes a monotonic wall clock), so a - /// late-landing older cert cannot regress a ref's persisted state. Returns - /// the full row as it now exists in the database (the original row on a - /// rejected upsert; the passed row on insert). + /// Insert a ref certificate (append-only). The unique constraint on + /// `(repo_id, seq)` prevents duplicate sequence numbers; callers must + /// handle retry on collision. + #[allow(dead_code)] pub async fn insert_ref_certificate(&self, cert: &RefCertificate) -> Result { let row = sqlx::query( "INSERT INTO ref_certificates - (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT (repo_id, ref_name) DO UPDATE SET - old_sha = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.old_sha ELSE ref_certificates.old_sha END, - new_sha = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.new_sha ELSE ref_certificates.new_sha END, - pusher_did = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.pusher_did ELSE ref_certificates.pusher_did END, - node_did = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.node_did ELSE ref_certificates.node_did END, - signature = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.signature ELSE ref_certificates.signature END, - issued_at = CASE WHEN EXCLUDED.issued_at > ref_certificates.issued_at - THEN EXCLUDED.issued_at ELSE ref_certificates.issued_at END - RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at", + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path", ) .bind(&cert.id) .bind(&cert.repo_id) @@ -2071,11 +2434,68 @@ impl Db { .bind(&cert.node_did) .bind(&cert.signature) .bind(&cert.issued_at) + .bind(cert.seq) + .bind(&cert.prev) + .bind(&cert.pusher_sig) + .bind(&cert.signature_input) + .bind(&cert.content_digest) + .bind(&cert.request_path) .fetch_one(&self.pool) .await?; Ok(row_to_cert(row)) } + /// Transaction-scoped variant of [`insert_ref_certificate`]. + /// Uses the same advisory-lock hash for the repo_id so the lock key + /// stays consistent with [`lock_repo_cert_issuance`]. + pub async fn insert_ref_certificate_tx( + &self, + cert: &RefCertificate, + conn: &mut sqlx::postgres::PgConnection, + ) -> Result { + // Idempotent insert (#224): a durable post-receive job re-issues its + // certificates with a deterministic per-(job, ref) id during a replay, + // so a re-run must not duplicate the row. `ON CONFLICT (id) DO NOTHING` + // returns no row for the already-inserted case; the existing row is + // then read back so the caller gets the certificate that actually + // landed (which, for a deterministic id, is the same one it computed). + let row = sqlx::query( + "INSERT INTO ref_certificates + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ON CONFLICT (id) DO NOTHING + RETURNING id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path", + ) + .bind(&cert.id) + .bind(&cert.repo_id) + .bind(&cert.ref_name) + .bind(&cert.old_sha) + .bind(&cert.new_sha) + .bind(&cert.pusher_did) + .bind(&cert.node_did) + .bind(&cert.signature) + .bind(&cert.issued_at) + .bind(cert.seq) + .bind(&cert.prev) + .bind(&cert.pusher_sig) + .bind(&cert.signature_input) + .bind(&cert.content_digest) + .bind(&cert.request_path) + .fetch_optional(&mut *conn) + .await?; + if let Some(row) = row { + return Ok(row_to_cert(row)); + } + let existing = sqlx::query( + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path + FROM ref_certificates WHERE id = $1", + ) + .bind(&cert.id) + .fetch_one(&mut *conn) + .await?; + Ok(row_to_cert(existing)) + } + pub async fn list_ref_certificates( &self, repo_id: &str, @@ -2085,8 +2505,8 @@ impl Db { // bounded even if a raw/negative value slips through the handler layer. let limit = limit.max(1); let rows = sqlx::query( - "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at - FROM ref_certificates WHERE repo_id = $1 ORDER BY issued_at DESC LIMIT $2", + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path + FROM ref_certificates WHERE repo_id = $1 ORDER BY seq DESC, issued_at DESC LIMIT $2", ) .bind(repo_id) .bind(limit) @@ -2124,8 +2544,8 @@ impl Db { let pattern = format!("{}%", escaped_prefix); let rows = sqlx::query( - "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at - FROM ref_certificates WHERE repo_id = $1 AND id LIKE $2 ESCAPE '!' ORDER BY issued_at DESC LIMIT $3", + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path + FROM ref_certificates WHERE repo_id = $1 AND id LIKE $2 ESCAPE '!' ORDER BY seq DESC, issued_at DESC LIMIT $3", ) .bind(repo_id) .bind(&pattern) @@ -2137,7 +2557,7 @@ impl Db { pub async fn get_ref_certificate(&self, id: &str) -> Result> { let row = sqlx::query( - "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path FROM ref_certificates WHERE id = $1", ) .bind(id) @@ -2145,6 +2565,115 @@ impl Db { .await?; Ok(row.map(row_to_cert)) } + + /// Look up the node's own certificate row for a legacy cert by the fields the + /// 7-field signature actually covers: `(repo_id, ref_name, old_sha, new_sha, + /// issued_at)`. Corroboration must NOT key on `id` — that column is not part + /// of any signed payload, so a forger could otherwise pick which stored row + /// their chain-position claims are measured against. + pub async fn get_cert_by_signed_tuple( + &self, + repo_id: &str, + ref_name: &str, + old_sha: &str, + new_sha: &str, + issued_at: &str, + ) -> Result> { + let row = sqlx::query( + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path + FROM ref_certificates + WHERE repo_id = $1 AND ref_name = $2 AND old_sha = $3 AND new_sha = $4 AND issued_at = $5 + LIMIT 1", + ) + .bind(repo_id) + .bind(ref_name) + .bind(old_sha) + .bind(new_sha) + .bind(issued_at) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(row_to_cert)) + } + + /// Retrieve the most recent certificate for a repo (highest seq). + pub async fn get_cert_by_seq(&self, repo_id: &str, seq: i64) -> Result> { + let row = sqlx::query( + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path + FROM ref_certificates WHERE repo_id = $1 AND seq = $2", + ) + .bind(repo_id) + .bind(seq) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(row_to_cert)) + } + + #[allow(dead_code)] + pub async fn get_most_recent_cert(&self, repo_id: &str) -> Result> { + let row = sqlx::query( + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path + FROM ref_certificates WHERE repo_id = $1 ORDER BY seq DESC LIMIT 1", + ) + .bind(repo_id) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(row_to_cert)) + } + + /// Transaction-scoped variant of [`get_most_recent_cert`]. + pub async fn get_most_recent_cert_tx( + &self, + repo_id: &str, + conn: &mut sqlx::postgres::PgConnection, + ) -> Result> { + let row = sqlx::query( + "SELECT id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at, seq, prev, pusher_sig, signature_input, content_digest, request_path + FROM ref_certificates WHERE repo_id = $1 ORDER BY seq DESC LIMIT 1", + ) + .bind(repo_id) + .fetch_optional(&mut *conn) + .await?; + Ok(row.map(row_to_cert)) + } + + /// Acquire a per-repo advisory lock to serialize certificate issuance. + /// This prevents two concurrent pushes to the same repo from racing on + /// the sequence number allocation. + /// Uses a transaction-scoped lock (`pg_advisory_xact_lock`) so it MUST + /// be called within an active transaction to be effective. + #[allow(dead_code)] + pub async fn lock_repo_cert_issuance(&self, repo_id: &str) -> Result<()> { + let hash = repo_lock_hash(repo_id); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(hash) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Transaction-scoped variant of [`lock_repo_cert_issuance`]. + /// The lock is held until the enclosing transaction commits or rolls back. + pub async fn lock_repo_cert_issuance_tx( + &self, + repo_id: &str, + conn: &mut sqlx::postgres::PgConnection, + ) -> Result<()> { + let hash = repo_lock_hash(repo_id); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(hash) + .execute(&mut *conn) + .await?; + Ok(()) + } +} + +/// Deterministic 64-bit hash of a repo_id for advisory lock keys. +/// Uses the first 8 bytes of SHA-256 rather than DefaultHasher (which the +/// std docs do not guarantee stable across Rust versions or platforms). +fn repo_lock_hash(repo_id: &str) -> i64 { + use sha2::Digest; + let hash = sha2::Sha256::digest(repo_id.as_bytes()); + i64::from_be_bytes(hash[..8].try_into().expect("sha256 output >= 8 bytes")) } // ── Peers ───────────────────────────────────────────────────────────────────── @@ -2959,31 +3488,83 @@ pub struct ArweaveAnchor { pub old_sha: String, pub new_sha: String, pub cid: Option, - pub irys_tx_id: String, - pub arweave_url: String, + pub arweave_tx_id: String, pub node_did: String, pub anchored_at: String, + pub cert_id: Option, + /// Backward-compat alias for arweave_tx_id. v1 clients expect this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub irys_tx_id: Option, + /// Permanent Arweave URL derived from the gateway and tx_id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arweave_url: Option, } /// Input parameters for recording an Arweave anchor. -pub struct RecordAnchorInput<'a> { +#[cfg(test)] +pub struct RecordAnchorInputV2<'a> { + pub repo: &'a str, + pub owner_did: &'a str, + pub ref_name: &'a str, + pub old_sha: &'a str, + pub new_sha: &'a str, + pub cid: Option<&'a str>, + pub arweave_tx_id: &'a str, + pub node_did: &'a str, + /// ID of the [`RefCertificate`] embedded in this anchor, if any. + pub cert_id: Option, +} + +/// Outcome of atomically claiming a per-transition Arweave anchor outbox row +/// (#224 review). The claim row IS the durable per-transition state: it is +/// created BEFORE any paid upload is attempted, so a worker that wins the claim +/// is the only one that can pay for that transition. +#[derive(Debug)] +pub enum AnchorClaim { + /// This worker INSERTed the row (state `pending`); it owns the upload + /// obligation and must drive the row to `recorded`. + Claimed { id: String }, + /// A `recorded` row already exists for this exact transition — a replay of + /// an already-anchored job; nothing to do. + AlreadyRecorded, + /// A row exists in a non-terminal state (`pending`/`uploading`/`failed`). + /// `item_id` is the ANS-104 data-item id persisted before the last upload + /// attempt (`None` when no request was ever prepared/sent). The worker must + /// reconcile it (probe the gateway) before deciding whether another paid + /// upload is safe. + Recover { + id: String, + state: String, + item_id: Option, + }, +} + +/// Everything the claim of a per-transition anchor outbox row needs. Bundled +/// into a struct so the atomic-claim contract stays a single unit rather than +/// a ten-argument call. +pub struct ClaimAnchorInput<'a> { pub repo: &'a str, pub owner_did: &'a str, pub ref_name: &'a str, pub old_sha: &'a str, pub new_sha: &'a str, pub cid: Option<&'a str>, - pub irys_tx_id: &'a str, - pub arweave_url: &'a str, + /// The NODE's DID — the anchor issuer (never the pusher, #224 review). pub node_did: &'a str, + pub cert_id: Option<&'a str>, + /// Opaque per-claim lease token (for operator forensics on mid-flight rows). + pub claim_token: &'a str, + /// RFC 3339 timestamp of this claim. + pub claimed_at: &'a str, } impl Db { - pub async fn record_arweave_anchor(&self, input: &RecordAnchorInput<'_>) -> Result<()> { + #[cfg(test)] + pub async fn record_arweave_anchor(&self, input: &RecordAnchorInputV2<'_>) -> Result<()> { let id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); sqlx::query( - "INSERT INTO arweave_anchors (id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at) + "INSERT INTO arweave_anchors (id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, cert_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", ) .bind(&id) @@ -2993,15 +3574,138 @@ impl Db { .bind(input.old_sha) .bind(input.new_sha) .bind(input.cid) - .bind(input.irys_tx_id) - .bind(input.arweave_url) + .bind(input.arweave_tx_id) .bind(input.node_did) .bind(&now) + .bind(input.cert_id.clone()) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Atomically claim the per-transition anchor outbox row for + /// (repo, ref_name, old_sha, new_sha). The unique transition index makes + /// competing workers converge: exactly one INSERT wins, so exactly one + /// worker can pay for a given transition. A won claim leaves the row in + /// `pending` with a NULL item id — no upload has been attempted. + pub async fn claim_anchor_claim(&self, input: &ClaimAnchorInput<'_>) -> Result { + let id = Uuid::new_v4().to_string(); + let result = sqlx::query( + "INSERT INTO arweave_anchors + (id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, cert_id, state, item_id, claim_token, claimed_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,NULL,$8,$9,$10,'pending',NULL,$11,$12) + ON CONFLICT (repo, ref_name, old_sha, new_sha) DO NOTHING", + ) + .bind(&id) + .bind(input.repo) + .bind(input.owner_did) + .bind(input.ref_name) + .bind(input.old_sha) + .bind(input.new_sha) + .bind(input.cid) + .bind(input.node_did) + .bind(input.claimed_at) + .bind(input.cert_id) + .bind(input.claim_token) + .bind(input.claimed_at) + .execute(&self.pool) + .await?; + if result.rows_affected() == 1 { + return Ok(AnchorClaim::Claimed { id }); + } + let row = sqlx::query( + "SELECT id, state, item_id FROM arweave_anchors + WHERE repo = $1 AND ref_name = $2 AND old_sha = $3 AND new_sha = $4", + ) + .bind(input.repo) + .bind(input.ref_name) + .bind(input.old_sha) + .bind(input.new_sha) + .fetch_one(&self.pool) + .await?; + let state: String = row.get("state"); + if state == "recorded" { + return Ok(AnchorClaim::AlreadyRecorded); + } + Ok(AnchorClaim::Recover { + id: row.get("id"), + state, + item_id: row.get("item_id"), + }) + } + + /// Move a claimed outbox row to `uploading` and persist the ANS-104 + /// data-item id that the upload request is about to send. Persisting the id + /// BEFORE the request is what lets a crash-recovery probe that id to decide + /// whether the upload landed (#224 review). + pub async fn set_anchor_uploading(&self, id: &str, item_id: &str) -> Result<()> { + sqlx::query("UPDATE arweave_anchors SET state = 'uploading', item_id = $1 WHERE id = $2") + .bind(item_id) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Mark a claimed outbox row `failed` (the provider definitively rejected + /// the upload). The job row carries the error detail; the transition stays + /// reserved so a later drain owns it and re-uploads. + pub async fn set_anchor_failed(&self, id: &str) -> Result<()> { + sqlx::query("UPDATE arweave_anchors SET state = 'failed' WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Persist the accepted upload on a claimed outbox row: `recorded` state, + /// the transaction id the provider returned (also the id the gateway + /// resolves the item under, so it becomes the probe id for later replays), + /// and the anchor timestamp. This is the durable terminal state; a retry + /// that finds it skips the upload entirely. + pub async fn record_claimed_anchor(&self, id: &str, tx_id: &str) -> Result<()> { + let now = Utc::now().to_rfc3339(); + sqlx::query( + "UPDATE arweave_anchors + SET state = 'recorded', arweave_tx_id = $1, item_id = $1, anchored_at = $2 + WHERE id = $3", + ) + .bind(tx_id) + .bind(&now) + .bind(id) .execute(&self.pool) .await?; Ok(()) } + /// Whether this exact ref transition (same repo slug, ref, old→new SHAs) + /// already has a recorded Arweave anchor. The durable post-receive job + /// checks this BEFORE uploading, so a startup replay of an already-anchored + /// job skips the upload instead of writing a second permanent on-chain + /// artifact for the same transition (#224). + #[cfg(test)] + pub async fn arweave_anchor_exists( + &self, + repo: &str, + ref_name: &str, + old_sha: &str, + new_sha: &str, + ) -> Result { + let row = sqlx::query( + "SELECT EXISTS( + SELECT 1 FROM arweave_anchors + WHERE repo = $1 AND ref_name = $2 AND old_sha = $3 AND new_sha = $4 + ) AS present", + ) + .bind(repo) + .bind(ref_name) + .bind(old_sha) + .bind(new_sha) + .fetch_one(&self.pool) + .await?; + Ok(row.get::("present")) + } + pub async fn list_arweave_anchors( &self, repo: Option<&str>, @@ -3009,8 +3713,8 @@ impl Db { ) -> Result> { let rows = if let Some(repo) = repo { sqlx::query( - "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at - FROM arweave_anchors WHERE repo=$1 ORDER BY anchored_at DESC LIMIT $2", + "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, cert_id + FROM arweave_anchors WHERE repo=$1 AND state = 'recorded' ORDER BY anchored_at DESC LIMIT $2", ) .bind(repo) .bind(limit) @@ -3018,8 +3722,8 @@ impl Db { .await? } else { sqlx::query( - "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at - FROM arweave_anchors ORDER BY anchored_at DESC LIMIT $1", + "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, arweave_tx_id, node_did, anchored_at, cert_id + FROM arweave_anchors WHERE state = 'recorded' ORDER BY anchored_at DESC LIMIT $1", ) .bind(limit) .fetch_all(&self.pool) @@ -3036,10 +3740,12 @@ impl Db { old_sha: r.get("old_sha"), new_sha: r.get("new_sha"), cid: r.get("cid"), - irys_tx_id: r.get("irys_tx_id"), - arweave_url: r.get("arweave_url"), + arweave_tx_id: r.get("arweave_tx_id"), node_did: r.get("node_did"), anchored_at: r.get("anchored_at"), + cert_id: r.try_get("cert_id").unwrap_or(None), + irys_tx_id: None, + arweave_url: None, }) .collect()) } @@ -3114,6 +3820,12 @@ fn row_to_cert(r: sqlx::postgres::PgRow) -> RefCertificate { node_did: r.get("node_did"), signature: r.get("signature"), issued_at: r.get("issued_at"), + seq: r.try_get("seq").unwrap_or(0), + prev: r.try_get("prev").unwrap_or_default(), + pusher_sig: r.try_get("pusher_sig").unwrap_or(None), + signature_input: r.try_get("signature_input").unwrap_or(None), + content_digest: r.try_get("content_digest").unwrap_or(None), + request_path: r.try_get("request_path").unwrap_or(None), } } @@ -3935,7 +4647,7 @@ mod migration_tests { "pre-migration row must exist" ); - // ── Apply pending migrations (v10 ref_cert_unique_per_ref, v11 owner_did) ── + // ── Apply pending migrations (v10 ref_cert_unique_per_ref, v11 owner_did, v18 arweave) ── db.migrate().await.unwrap(); // ── Assertions ──────────────────────────────────────────────────── @@ -5611,6 +6323,13 @@ mod ref_certificate_tests { use chrono::Utc; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; + use std::sync::atomic::{AtomicI64, Ordering}; + + static NEXT_SEQ: AtomicI64 = AtomicI64::new(1); + + fn next_seq() -> i64 { + NEXT_SEQ.fetch_add(1, Ordering::Relaxed) + } async fn db(pool: PgPool) -> Db { let db = Db::for_testing(pool); @@ -5636,6 +6355,12 @@ mod ref_certificate_tests { node_did: "did:key:zNODE".to_string(), signature: "sig".to_string(), issued_at: issued_at.to_string(), + seq: next_seq(), + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, } } @@ -5687,20 +6412,20 @@ mod ref_certificate_tests { } #[sqlx::test] - async fn insert_ref_certificate_upserts_on_repo_ref(pool: PgPool) { + async fn insert_ref_certificate_append_only(pool: PgPool) { let db = db(pool).await; let repo_id = uuid::Uuid::new_v4().to_string(); db.create_repo(&RepoRecord { id: repo_id.clone(), - name: "upsert-test".into(), + name: "append-test".into(), owner_did: "did:key:zOWNER".into(), description: None, is_public: true, default_branch: "main".into(), created_at: Utc::now(), updated_at: Utc::now(), - disk_path: "/tmp/upsert-test".into(), + disk_path: "/tmp/append-test".into(), forked_from: None, machine_id: None, }) @@ -5709,7 +6434,7 @@ mod ref_certificate_tests { // First insert db.insert_ref_certificate(&make_cert( - "cert-original", + "cert-first", &repo_id, "refs/heads/main", "0000", @@ -5719,9 +6444,9 @@ mod ref_certificate_tests { .await .unwrap(); - // Upsert same ref with new values + // Second insert for the same ref — append-only means both rows exist db.insert_ref_certificate(&make_cert( - "cert-upserted", + "cert-second", &repo_id, "refs/heads/main", "aaaa", @@ -5731,49 +6456,11 @@ mod ref_certificate_tests { .await .unwrap(); - // Only one row exists for this ref + // Two rows now exist for this ref (append-only) let certs = db.list_ref_certificates(&repo_id, 10).await.unwrap(); - assert_eq!(certs.len(), 1, "upsert must not create a duplicate row"); - assert_eq!( - certs[0].id, "cert-original", - "upsert must preserve the original ID across re-pushes" - ); - assert_eq!(certs[0].old_sha, "aaaa", "old_sha updated"); - assert_eq!(certs[0].new_sha, "bbbb", "new_sha updated"); - assert_eq!( - certs[0].issued_at, "2026-07-03T21:00:00Z", - "newer issued_at overwrites older" - ); - - // Now try to overwrite with an OLDER cert — the guard must reject it. - db.insert_ref_certificate(&make_cert( - "stale-id", - &repo_id, - "refs/heads/main", - "stale", - "stale", - "2026-07-03T19:00:00Z", - )) - .await - .unwrap(); - let certs = db.list_ref_certificates(&repo_id, 10).await.unwrap(); - assert_eq!(certs.len(), 1, "no extra row from stale cert"); - assert_eq!( - certs[0].id, "cert-original", - "stale cert does not change the original id" - ); - assert_eq!( - certs[0].old_sha, "aaaa", - "stale cert does not regress old_sha" - ); - assert_eq!( - certs[0].new_sha, "bbbb", - "stale cert does not regress new_sha" - ); - assert_eq!( - certs[0].issued_at, "2026-07-03T21:00:00Z", - "stale cert does not regress issued_at" - ); + assert_eq!(certs.len(), 2, "append-only must keep both rows"); + assert_eq!(certs[0].id, "cert-second", "most recent first"); + assert_eq!(certs[1].id, "cert-first", "second most recent"); } #[sqlx::test] @@ -5989,11 +6676,17 @@ mod ref_certificate_tests { async fn v10_dedup_removes_old_duplicates(pool: PgPool) { let db = db(pool.clone()).await; - // Drop the unique index so we can simulate pre-v10 duplicate rows. + // Drop the unique indexes so we can simulate pre-v10 duplicate rows. + // v19's (repo_id, seq) index must also be removed because raw INSERTS + // without an explicit seq all get DEFAULT 1. sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_ref") .execute(&pool) .await .unwrap(); + sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_seq") + .execute(&pool) + .await + .unwrap(); let repo_id = uuid::Uuid::new_v4().to_string(); db.create_repo(&RepoRecord { @@ -6093,12 +6786,17 @@ mod ref_certificate_tests { let db = Db::for_testing(pool.clone()); db.run_migrations().await.unwrap(); - // 2. Roll back to v9: remove the v10-unique index and the + // 2. Roll back to v9: remove unique indexes and the // schema_migrations record so that run_migrations() re-applies v10. + // Also drop v19's (repo_id, seq) index so raw INSERTS below work. sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_ref") .execute(&pool) .await .unwrap(); + sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_seq") + .execute(&pool) + .await + .unwrap(); sqlx::query("DELETE FROM schema_migrations WHERE version = 10") .execute(&pool) .await @@ -6258,33 +6956,26 @@ mod ref_certificate_tests { "non-duplicate singleton untouched" ); - // 6. Verify the unique index exists: the upsert helper must succeed - // (exercises ON CONFLICT) and a direct duplicate INSERT must fail. + // 6. Verify the unique indexes exist: an append-only INSERT for + // a new (repo_id, ref_name) succeeds, and a raw INSERT for an + // existing (repo_id, ref_name) must fail (catches regressions). db.insert_ref_certificate(&make_cert( - "post-migration-upsert", + "post-migration-insert", &r1, - "refs/heads/main", + "refs/heads/new-ref", "1111", "2222", "2026-07-03T10:00:00Z", )) .await .unwrap(); - let after_upsert = db.list_ref_certificates(&r1, 10).await.unwrap(); - let r1_main_after: Vec<_> = after_upsert - .iter() - .filter(|c| c.ref_name == "refs/heads/main") - .collect(); - assert_eq!( - r1_main_after.len(), - 1, - "upsert keeps exactly one row for main" - ); - assert_eq!( - r1_main_after[0].id, "dup-a-new", - "upsert preserves original id" + let after_migration = db.list_ref_certificates(&r1, 10).await.unwrap(); + assert!( + after_migration + .iter() + .any(|c| c.id == "post-migration-insert"), + "append-only insert for new ref succeeds" ); - assert_eq!(r1_main_after[0].old_sha, "1111", "upsert updated old_sha"); // A raw INSERT for the same (repo_id, ref_name) must now fail. let err = sqlx::query( @@ -6308,6 +6999,190 @@ mod ref_certificate_tests { "raw duplicate INSERT must be rejected by the unique index" ); } + + /// INV-7: upgrade-path test for migration v19 — seed a database at v18 + /// with multiple same-repo/different-ref certificates (all at seq=1), + /// then let run_migrations() apply v19 and verify (a) seq values are + /// distinct per repo, (b) the (repo_id, seq) unique index exists and + /// rejects a raw INSERT with a colliding seq. + #[sqlx::test] + async fn v13_seq_backfill_via_migration(pool: PgPool) { + // 1. Bootstrap schema via the full migration chain. + let db = Db::for_testing(pool.clone()); + db.run_migrations().await.unwrap(); + + // 2. Roll back to v18: drop the (repo_id, seq) index and the + // schema_migrations record for v19 so run_migrations() re-applies it. + sqlx::query("DROP INDEX IF EXISTS idx_ref_certs_repo_seq") + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM schema_migrations WHERE version = 19") + .execute(&pool) + .await + .unwrap(); + + // 3. Seed repos and certs (all with seq=DEFAULT 1). + let r1 = uuid::Uuid::new_v4().to_string(); + db.create_repo(&RepoRecord { + id: r1.clone(), + name: "v13-upgrade-a".into(), + owner_did: "did:key:zOWNER".into(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + disk_path: "/tmp/v13-upgrade-a".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + + // Insert 3 certs for repo r1 on different refs — all with seq=1 (DEFAULT). + for (i, ref_name) in ["refs/heads/main", "refs/heads/feature", "refs/heads/dev"] + .iter() + .enumerate() + { + sqlx::query( + "INSERT INTO ref_certificates + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind(format!("v13-cert-{i}")) + .bind(&r1) + .bind(ref_name) + .bind("0000") + .bind("1111") + .bind("did:key:zPUSHER") + .bind("did:key:zNODE") + .bind("sig") + .bind(format!("2026-07-0{}T12:00:00Z", i + 1)) + .execute(&pool) + .await + .unwrap(); + } + + // 4. Re-run migrations — v19 backfills seq. + db.run_migrations().await.unwrap(); + + // 5. Assert distinct seq values per repo. + let certs = db.list_ref_certificates(&r1, 10).await.unwrap(); + assert_eq!(certs.len(), 3, "all three certs survive the migration"); + let mut seqs: Vec = certs.iter().map(|c| c.seq).collect(); + seqs.sort(); + assert_eq!(seqs, vec![1, 2, 3], "seq values are distinct and ascending"); + + // 6. Raw INSERT with colliding seq must be rejected by the unique index. + let err = sqlx::query( + "INSERT INTO ref_certificates + (id, repo_id, ref_name, old_sha, new_sha, pusher_did, node_did, signature, issued_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind("collide-seq") + .bind(&r1) + .bind("refs/heads/other") + .bind("xxxx") + .bind("yyyy") + .bind("did:key:zPUSHER") + .bind("did:key:zNODE") + .bind("sig-collide") + .bind("2026-07-10T12:00:00Z") + .execute(&pool) + .await; + assert!( + err.is_err(), + "raw INSERT with default seq=1 must be rejected by the unique index" + ); + } + + #[sqlx::test] + async fn get_most_recent_cert_returns_highest_seq(pool: PgPool) { + let db = db(pool).await; + let repo_id = uuid::Uuid::new_v4().to_string(); + db.create_repo(&RepoRecord { + id: repo_id.clone(), + name: "most-recent-test".into(), + owner_did: "did:key:zOWNER".into(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + disk_path: "/tmp/most-recent-test".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + + // Insert certs with increasing seq + for i in 1..=3 { + let mut cert = make_cert( + &format!("cert-seq-{i}"), + &repo_id, + "refs/heads/main", + "0000", + "1111", + &format!("2026-07-03T20:0{i}:00Z"), + ); + cert.seq = i; + db.insert_ref_certificate(&cert).await.unwrap(); + } + + let most_recent = db.get_most_recent_cert(&repo_id).await.unwrap(); + assert!(most_recent.is_some(), "should find a cert"); + assert_eq!(most_recent.unwrap().seq, 3, "highest seq returned"); + } + + #[sqlx::test] + async fn get_most_recent_cert_returns_none_for_empty_repo(pool: PgPool) { + let db = db(pool).await; + let result = db + .get_most_recent_cert("nonexistent-repo-id") + .await + .unwrap(); + assert!(result.is_none(), "empty repo returns None"); + } +} + +#[cfg(test)] +mod arweave_anchor_tests { + use super::{Db, RecordAnchorInputV2}; + use sqlx::PgPool; + + async fn db(pool: PgPool) -> Db { + let db = Db::for_testing(pool); + db.run_migrations().await.unwrap(); + db + } + + #[sqlx::test] + async fn record_and_list_arweave_anchors(pool: PgPool) { + let db = db(pool).await; + + let input = RecordAnchorInputV2 { + repo: "alice/myrepo", + owner_did: "did:key:zOWNER", + ref_name: "refs/heads/main", + old_sha: "0000000000000000000000000000000000000000", + new_sha: "1111111111111111111111111111111111111111", + cid: Some("bafyreib5..."), + arweave_tx_id: "test-tx-id-123", + node_did: "did:key:zNODE", + cert_id: None, + }; + + db.record_arweave_anchor(&input).await.unwrap(); + + let anchors = db + .list_arweave_anchors(Some("alice/myrepo"), 10) + .await + .unwrap(); + assert_eq!(anchors.len(), 1, "one anchor recorded"); + assert_eq!(anchors[0].arweave_tx_id, "test-tx-id-123"); + } } #[cfg(test)] mod ref_update_db_tests { @@ -7327,3 +8202,148 @@ mod peers_table_writer_guard { ); } } + +/// The released v1 migration is immutable: every deployment that already ran it +/// keeps the ORIGINAL column layout, and later migrations (v18+) do the column +/// adds and renames against that layout. This test replays that exact upgrade — +/// create the byte-identical released v1 schema, mark v1 applied, run the real +/// migration chain — and proves a certificate and an anchor written with the new +/// columns survive it. +#[cfg(test)] +mod upgrade_path_tests { + use super::{Db, RecordAnchorInputV2, RefCertificate, MIGRATIONS}; + use sqlx::{PgPool, Row}; + + #[sqlx::test] + async fn upgrading_released_v1_schema_lands_cert_and_anchor_columns(pool: PgPool) { + let v1 = &MIGRATIONS[0]; + assert_eq!(v1.version, 1, "test must target the released v1 migration"); + + // Bootstrap schema_migrations (the real migrate() creates it, but we + // replay v1 by hand to reproduce a deployed v1 database exactly). + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS schema_migrations ( + version BIGINT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL + )"#, + ) + .execute(&pool) + .await + .unwrap(); + + // Replay the released v1 schema, then record v1 as applied so the + // chain below picks up at v2 — exactly what a deployed node does. + for stmt in v1.stmts { + sqlx::query(stmt).execute(&pool).await.unwrap(); + } + sqlx::query( + "INSERT INTO schema_migrations (version, name, applied_at) VALUES (1, $1, now())", + ) + .bind(v1.name) + .execute(&pool) + .await + .unwrap(); + + // The released v1 layout must not yet carry the post-v1 columns; this + // assertion is what makes the test bite — it fails if v1 is ever edited + // to pre-add them, exactly the regression the immutability rule bans. + for (table, column) in [ + ("ref_certificates", "seq"), + ("ref_certificates", "pusher_sig"), + ("arweave_anchors", "arweave_tx_id"), + ("arweave_anchors", "cert_id"), + ] { + let exists: bool = sqlx::query( + "SELECT EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2 + ) AS present", + ) + .bind(table) + .bind(column) + .fetch_one(&pool) + .await + .unwrap() + .get("present"); + assert!(!exists, "released v1 must not contain {table}.{column}"); + } + + // Run the real migration chain v2..=v20 against the old layout. + let db = Db::for_testing(pool.clone()); + db.run_migrations().await.unwrap(); + + // The post-v1 columns must now exist... + for (table, column) in [ + ("ref_certificates", "seq"), + ("ref_certificates", "prev"), + ("ref_certificates", "pusher_sig"), + ("ref_certificates", "signature_input"), + ("ref_certificates", "content_digest"), + ("ref_certificates", "request_path"), + ("arweave_anchors", "arweave_tx_id"), + ("arweave_anchors", "cert_id"), + ] { + let exists: bool = sqlx::query( + "SELECT EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2 + ) AS present", + ) + .bind(table) + .bind(column) + .fetch_one(&pool) + .await + .unwrap() + .get("present"); + assert!(exists, "upgraded schema must contain {table}.{column}"); + } + + // ...and a full certificate (chain + pusher-proof columns) plus an + // anchor written through the code paths must round-trip. + let cert = RefCertificate { + id: "cert-upgrade-1".to_string(), + repo_id: "repo-uuid".to_string(), + ref_name: "refs/heads/main".to_string(), + old_sha: "0".repeat(40), + new_sha: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".into(), + pusher_did: "did:key:zPusher".to_string(), + node_did: "did:key:zNode".to_string(), + signature: "sig".to_string(), + issued_at: "2026-07-22T00:00:00+00:00".to_string(), + seq: 7, + prev: "0".repeat(64), + pusher_sig: Some("sig1=:abc:".to_string()), + signature_input: Some(r#"("content-digest" "http://example.com/repo.git/git-receive-pack"; created=…; keyid="did:key:zPusher")"#.to_string()), + content_digest: Some("sha-256=:abc:".to_string()), + request_path: Some("/repo-uuid.git/git-receive-pack".to_string()), + }; + db.insert_ref_certificate(&cert).await.unwrap(); + let got = db + .get_cert_by_seq("repo-uuid", 7) + .await + .unwrap() + .expect("cert readable"); + assert_eq!(got.pusher_sig.as_deref(), Some("sig1=:abc:")); + + db.record_arweave_anchor(&RecordAnchorInputV2 { + repo: "alice/myrepo", + owner_did: "did:key:zOWNER", + ref_name: "refs/heads/main", + old_sha: &"0".repeat(40), + new_sha: &"1".repeat(40), + cid: Some("bafyreib5..."), + arweave_tx_id: "upgrade-tx-id", + node_did: "did:key:zNODE", + cert_id: Some("cert-upgrade-1".to_string()), + }) + .await + .unwrap(); + let anchors = db + .list_arweave_anchors(Some("alice/myrepo"), 10) + .await + .unwrap(); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].arweave_tx_id, "upgrade-tx-id"); + } +} diff --git a/crates/gitlawb-node/src/git/repo_store.rs b/crates/gitlawb-node/src/git/repo_store.rs index 2aef6ff0..77c74795 100644 --- a/crates/gitlawb-node/src/git/repo_store.rs +++ b/crates/gitlawb-node/src/git/repo_store.rs @@ -328,7 +328,13 @@ impl RepoStore { /// (or the prefix/root from `repos_dir`); any `ParentDir`/`CurDir` /// segment is rejected. This is the CodeQL-recognised barrier /// pattern for `rust/path-injection`. - fn local_path(&self, owner_did: &str, repo_name: &str) -> Result<(String, PathBuf)> { + /// + /// Derive the local path for a repo without touching Tigris or the network. + /// Used by the durable post-receive job to re-locate a repo during a + /// startup replay, where the local copy written by the original push is + /// exactly what should be read. See also [`acquire`] / [`acquire_fresh`], + /// which download from Tigris first. + pub fn local_path(&self, owner_did: &str, repo_name: &str) -> Result<(String, PathBuf)> { validate_path_components(owner_did, repo_name)?; let owner_slug = owner_did.replace([':', '/'], "_"); diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 10aaca5b..486fe4e7 100644 --- a/crates/gitlawb-node/src/main.rs +++ b/crates/gitlawb-node/src/main.rs @@ -1,3 +1,4 @@ +mod ans104; mod api; mod arweave; mod auth; @@ -71,6 +72,49 @@ async fn main() -> Result<()> { let mut config = Config::parse(); + // Fallback to legacy GITLAWB_IRYS_URL for backward compatibility during rename. + // A bare URL no longer enables paid anchoring: uploads are billed to a funded + // account via x-irys-paid-by at /tx/{token}, which this release introduced, + // so validate() below refuses to start with a URL but no account/token. + // Config::legacy_bundler_url_fallback therefore honors the legacy value only + // when the operator has opted into the new funded-account pair; otherwise we + // warn that the legacy URL alone leaves anchoring disabled and start anyway. + if config.bundler_url.is_empty() { + if let Ok(legacy) = std::env::var("GITLAWB_IRYS_URL") { + match Config::legacy_bundler_url_fallback( + &legacy, + &config.bundler_account, + &config.bundler_token, + ) { + Some(url) => { + config.bundler_url = url; + tracing::warn!( + "GITLAWB_IRYS_URL is deprecated, use GITLAWB_BUNDLER_URL instead" + ); + } + None if !legacy.is_empty() => { + tracing::warn!( + "GITLAWB_IRYS_URL is set but GITLAWB_BUNDLER_ACCOUNT and \ + GITLAWB_BUNDLER_TOKEN are not: a bundler URL alone no longer \ + enables anchoring (uploads are billed to a funded account via \ + x-irys-paid-by). Set the funded-account pair to enable it, or \ + use GITLAWB_BUNDLER_URL. Starting with anchoring disabled." + ); + } + None => {} + } + } + } + + // The bundler gateway pairing is NOT inferred here (#224 review): silently + // setting the gateway to the bundler URL paired a devnet bundler with a + // devnet gateway behind the operator's back, which is exactly the shape of + // config surprise the old default had — and production deployments that + // anchor through a mainnet bundler would have resolve broken anchors via + // the devnet gateway. `Config::validate()` now fails fast at boot when a + // bundler is configured without an explicit GITLAWB_ARWEAVE_GATEWAY, + // forcing the operator to name the network on each side. + // Merge the embedded seed list of public network nodes into the runtime // bootstrap peers. Operators can opt out via GITLAWB_BOOTSTRAP_DISABLE_SEEDS. bootstrap::merge_seeds(&mut config); @@ -82,6 +126,17 @@ async fn main() -> Result<()> { .validate() .map_err(|e| anyhow::anyhow!("invalid configuration: {e}"))?; + if !config.bundler_url.is_empty() { + tracing::info!( + bundler_url = %crate::server::mask_credential_url(&config.bundler_url), + bundler_account = %config.bundler_account, + bundler_token = %config.bundler_token, + "arweave anchoring enabled; uploads billed to the funded bundler account \ + at /tx/{{token}} via x-irys-paid-by (the node's ANS-104 signature is \ + authorship, not payment)" + ); + } + if !config.public_read { warn!( "GITLAWB_PUBLIC_READ=false is reserved; per-repository private-read enforcement is not wired in alpha" @@ -290,6 +345,20 @@ async fn main() -> Result<()> { let repo_store = git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, db.pool().clone()); + // Per-client-IP limiter for the Arweave verify endpoint. The route is + // unauthenticated (anyone can check a tx_id) and the per-DID creation + // limiter is too restrictive (10/hr). 0 disables. Bounded key set — the + // key is a client-influenced IP. + let arweave_limit = config.arweave_rate_limit; + let arweave_rate_limiter = rate_limit::RateLimiter::new_bounded( + arweave_limit, + std::time::Duration::from_secs(3600), + 200_000, + ); + if arweave_limit == 0 { + tracing::warn!("GITLAWB_ARWEAVE_RATE_LIMIT=0 — arweave IP rate limiting disabled"); + } + // Per-DID limiter for the creation endpoints. Keyed on the authenticated // DID (attacker-varied), so bound its key set to cap memory. let rate_limiter = @@ -380,6 +449,7 @@ async fn main() -> Result<()> { machine_id, repo_store, rate_limiter, + arweave_rate_limiter, create_ip_rate_limiter, push_rate_limiter, push_limiter_trust, @@ -462,6 +532,31 @@ async fn main() -> Result<()> { tracing::warn!("GITLAWB_IPFS_RATE_LIMIT=0 — per-IP /ipfs rate limiting disabled"); } + // #224: replay durable post-receive jobs a previous process left mid-flight. + // The receive-pack handler persists each push's job BEFORE acknowledging it, + // so a crash between the pack landing and the job's bookkeeping (record_push, + // certificates, anchor, replication) is recovered here on the next start — + // and the drained jobs are spawned before traffic is served, so no push can + // be acknowledged against a queue this process has not yet replayed. Each + // effect is idempotent, so a replay completes exactly the work owed without + // double-counting or double-issuing. + { + let drain_state = state.clone(); + match crate::api::repos::drain_post_receive_jobs(drain_state).await { + Ok(0) => {} + Ok(count) => { + info!("startup post-receive job drain found {count} job(s) to replay") + } + Err(e) => { + tracing::error!( + err = %e, + "startup post-receive job drain failed; unprocessed jobs stay queued \ + and are retried on the next restart" + ); + } + } + } + // Periodic peer-count poll for the metrics gauge. If p2p is disabled // we still set the gauge to 0 so dashboards don't show "no data". { diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index de61fcbe..8a86027c 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -231,7 +231,24 @@ pub fn build_router(state: AppState) -> Router { .merge(Router::new().route("/api/v1/ipfs/pins", get(ipfs::list_pins))); // ── Arweave permanent anchors ────────────────────────────────────────── - let arweave_routes = Router::new().route("/api/v1/arweave/anchors", get(arweave::list_anchors)); + // Only the gateway-fetching /verify endpoint is rate-limited per-IP to + // prevent abuse as an open proxy or resource-exhaustion vector. + // The /anchors listing is cheap (DB read) and shares no quota. + let arweave_verify_limiter = rate_limit::IpRateLimiter { + limiter: state.arweave_rate_limiter.clone(), + trust: state.push_limiter_trust, + }; + let arweave_routes = Router::new() + .route("/api/v1/arweave/anchors", get(arweave::list_anchors)) + .merge( + Router::new() + .route( + "/api/v1/arweave/verify/{tx_id}", + get(arweave::verify_anchor_endpoint), + ) + .layer(middleware::from_fn(rate_limit::rate_limit_by_ip)) + .layer(axum::Extension(arweave_verify_limiter)), + ); // ── Bounty routes (write — require HTTP Signature) ───────────────── let bounty_write_routes = add_auth_layers( @@ -578,6 +595,68 @@ pub(crate) async fn stats(State(state): State) -> Json String { + match reqwest::Url::parse(url) { + Ok(parsed) if !parsed.cannot_be_a_base() => { + let needs_masking = !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some(); + if !needs_masking { + return url.to_string(); + } + let had_empty_path = !url.ends_with('/'); + let mut clean = parsed; + let _ = clean.set_username(""); + let _ = clean.set_password(None); + clean.set_query(None); + clean.set_fragment(None); + let mut masked = clean.to_string(); + // The url crate serializes an empty path with a trailing '/'; + // drop it so a bare-origin config masks to the same bare origin. + if had_empty_path && masked.ends_with('/') { + masked.pop(); + } + masked + } + _ => mask_credential_url_fallback(url), + } +} + +fn mask_credential_url_fallback(url: &str) -> String { + // Strip any query/fragment up front — the string may carry credentials + // even without a parseable scheme. + let end = url.find(['?', '#']).unwrap_or(url.len()); + let without_query = &url[..end]; + let scheme_end = match without_query.find("://") { + Some(pos) => pos + 3, + None => 0, + }; + let authority_end = without_query[scheme_end..] + .find('/') + .map(|p| scheme_end + p) + .unwrap_or(without_query.len()); + let authority = &without_query[scheme_end..authority_end]; + if let Some(at) = authority.rfind('@') { + format!( + "{}{}{}", + &without_query[..scheme_end], + &authority[at + 1..], + &without_query[authority_end..] + ) + } else { + without_query.to_string() + } +} + async fn contracts_info(State(state): State) -> Json { let did_registry = &state.config.contract_did_registry; let name_registry = &state.config.contract_name_registry; @@ -590,14 +669,15 @@ async fn contracts_info(State(state): State) -> Json) -> Json { None => Json(json!({ "enabled": false })), } } + +#[cfg(test)] +mod tests { + use super::mask_credential_url; + + #[test] + fn masks_userinfo_preserving_scheme_and_path() { + assert_eq!( + mask_credential_url("https://user:pass@arweave.net"), + "https://arweave.net" + ); + assert_eq!( + mask_credential_url("https://user:pass@arweave.net/"), + "https://arweave.net/" + ); + assert_eq!( + mask_credential_url("https://u:p@host:9443/gateway"), + "https://host:9443/gateway" + ); + } + + #[test] + fn drops_query_and_fragment_credentials() { + // Query tokens must not survive into public URLs, logs, or status + // responses — with or without userinfo and a path prefix. + assert_eq!( + mask_credential_url("https://gateway.example/data?token=SECRET"), + "https://gateway.example/data" + ); + assert_eq!( + mask_credential_url("https://gateway.example/data?token=SECRET#frag"), + "https://gateway.example/data" + ); + assert_eq!( + mask_credential_url("https://user:token@gateway.example/data?token=SECRET#frag"), + "https://gateway.example/data" + ); + assert_eq!( + mask_credential_url("https://u:p@host:9443/gateway?token=SECRET"), + "https://host:9443/gateway" + ); + assert_eq!( + mask_credential_url("https://host:9443/gateway#token=SECRET"), + "https://host:9443/gateway" + ); + // Scheme-less configs still get the query cut. + assert_eq!( + mask_credential_url("gateway.example/data?token=SECRET"), + "gateway.example/data" + ); + } + + #[test] + fn leaves_credential_free_urls_unchanged() { + assert_eq!( + mask_credential_url("https://arweave.net"), + "https://arweave.net" + ); + assert_eq!( + mask_credential_url("http://localhost:3000"), + "http://localhost:3000" + ); + assert_eq!(mask_credential_url("arweave.net"), "arweave.net"); + // '@' inside the path (not userinfo) must be preserved + assert_eq!( + mask_credential_url("https://arweave.net/a@b"), + "https://arweave.net/a@b" + ); + } +} diff --git a/crates/gitlawb-node/src/state.rs b/crates/gitlawb-node/src/state.rs index 9d23572b..5e97396a 100644 --- a/crates/gitlawb-node/src/state.rs +++ b/crates/gitlawb-node/src/state.rs @@ -52,6 +52,10 @@ pub struct AppState { pub repo_store: RepoStore, /// Per-DID rate limiter for creation endpoints (repos, issues, PRs) pub rate_limiter: RateLimiter, + /// Per-client-IP rate limiter for the Arweave verify endpoint. The verify + /// route is unauthenticated and the per-DID creation limiter is far too + /// restrictive (10/hr). Bounded key set — the key is a client-influenced IP. + pub arweave_rate_limiter: RateLimiter, /// Per-client-IP rate limiter for the same creation endpoints. The per-DID /// limiter above cannot brake a creation flood from a DID farm — one /// throwaway `did:key` per repo means each DID makes a single create call diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 2b5aef95..8523da44 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -76,6 +76,7 @@ fn build_state(db: Arc, pool: PgPool) -> AppState { machine_id: None, repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool), rate_limiter: RateLimiter::new(100, Duration::from_secs(60)), + arweave_rate_limiter: RateLimiter::new(120, Duration::from_secs(3600)), create_ip_rate_limiter: RateLimiter::new(1000, Duration::from_secs(3600)), push_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)), push_limiter_trust: crate::rate_limit::TrustedProxy::None, @@ -116,15 +117,23 @@ pub(crate) async fn app(pool: PgPool) -> Router { /// Build a request carrying an already-verified [`AuthenticatedDid`] extension, /// so a handler mounted without `require_signature` sees the caller identity. -/// Sets `Content-Type: application/json` — the API is JSON throughout, and -/// without it axum's `Json` extractor returns 415 before the handler runs -/// (which would make any JSON-body authz assertion a false pass). +/// Also carries the `PusherSignature` / `PusherProof` extensions production's +/// `require_signature` injects, since handlers that consume the signature data +/// require them. Sets `Content-Type: application/json` — the API is JSON +/// throughout, and without it axum's `Json` extractor returns 415 before the +/// handler runs (which would make any JSON-body authz assertion a false pass). pub(crate) fn signed_request_as(did: &str, method: Method, uri: &str, body: Body) -> Request { Request::builder() .method(method) .uri(uri) .header(axum::http::header::CONTENT_TYPE, "application/json") .extension(AuthenticatedDid(did.to_string())) + .extension(crate::auth::PusherSignature(String::new())) + .extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }) .body(body) .expect("request builder") } @@ -1718,6 +1727,12 @@ mod tests { node_did: owner.to_string(), signature: "sig".to_string(), issued_at: Utc::now().to_rfc3339(), + seq: next_cert_seq(), + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, }) .await .expect("seed private cert"); @@ -3801,6 +3816,12 @@ mod tests { node_did: "did:key:zNode".into(), signature: "sig".into(), issued_at: "2026-01-01T00:00:00Z".into(), + seq: 1, + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, }; state.db.insert_ref_certificate(&cert).await.unwrap(); @@ -3836,6 +3857,12 @@ mod tests { node_did: "did:key:zNode".into(), signature: "sig".into(), issued_at: "2026-01-01T00:00:00Z".into(), + seq: 1, + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, }; state.db.insert_ref_certificate(&cert).await.unwrap(); @@ -4345,6 +4372,12 @@ mod tests { node_did: "did:key:zNode".into(), signature: "sig".into(), issued_at: "2026-01-01T00:00:00Z".into(), + seq: 1, + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, }; state.db.insert_ref_certificate(&cert).await.unwrap(); @@ -5185,6 +5218,13 @@ mod tests { // ── #147: list_certs respects ?limit ────────────────────────────────────── + use std::sync::atomic::{AtomicI64, Ordering}; + static NEXT_CERT_SEQ: AtomicI64 = AtomicI64::new(1); + + fn next_cert_seq() -> i64 { + NEXT_CERT_SEQ.fetch_add(1, Ordering::Relaxed) + } + fn seed_cert( id: &str, repo_id: &str, @@ -5201,6 +5241,12 @@ mod tests { node_did: "did:key:zNODE".into(), signature: "sig".into(), issued_at: issued_at.to_string(), + seq: next_cert_seq(), + prev: "0".repeat(64), + pusher_sig: None, + signature_input: None, + content_digest: None, + request_path: None, } } diff --git a/crates/gitlawb-node/tests/inv22_gates.rs b/crates/gitlawb-node/tests/inv22_gates.rs index 32e23dd0..0834e8af 100644 --- a/crates/gitlawb-node/tests/inv22_gates.rs +++ b/crates/gitlawb-node/tests/inv22_gates.rs @@ -414,31 +414,40 @@ fn inv22_ipfs_walk_admission_reaches_every_blocking_site() { ); } -/// #174 U5: the post-receive replication tail is spawned at the DURABILITY BOUNDARY, -/// which is the moment receive-pack returns success, not the end of the handler and -/// not after `guard.release()`. +/// #174 U5, #224 review: the post-receive work is detached at the DURABILITY +/// BOUNDARY, which is the moment receive-pack returns success, not the end of the +/// handler and not after `guard.release()`. Since #224 the handler persists the +/// push's post-receive JOB (`enqueue_post_receive_job` — record_push, trust +/// score, certificates, and the replication tail all run inside the job) at +/// that boundary and spawns `process_post_receive_job` to run it; everything +/// below the enqueue stays in the cancellable request future, so anything the +/// enqueue is after is a window where a client disconnect drops that work while +/// the pack is already durable on disk. `guard.release()` is such a window: on +/// success it awaits the Tigris upload and then the advisory unlock. /// -/// The tail owes this push its pins, recovery copy, and announcements. Everything -/// below the spawn stays in the cancellable request future, so anything the tail is -/// spawned after is a window where a client disconnect drops that work while the pack -/// is already durable on disk. `guard.release()` is such a window: on success it -/// awaits the Tigris upload and then the advisory unlock. +/// The lower bound matters just as much as the upper one: `release` runs on +/// failure too, so an ungated enqueue would fire for a push git rejected, +/// pinning and announcing a half-applied repo. Above `release` the `?` on +/// `receive_result` can no longer be what gates it, so the success check is +/// explicit and this gate binds it: the enqueue AND the processor spawn must +/// sit inside `if push_succeeded`, the enqueue must come before the spawn (the +/// durable row is the job's recovery record, so the processor must never run +/// against an unpersisted job), and `release` must consume the same flag so the +/// gate and the release cannot drift apart. /// -/// The lower bound matters just as much as the upper one: `release` runs on failure -/// too, so an ungated spawn would fire for a push git rejected, pinning and announcing -/// a half-applied repo. Above `release` the `?` on `receive_result` can no longer be -/// what gates it, so the success check is explicit and this gate binds it: the spawn -/// must sit inside `if push_succeeded`, and `release` must consume the same flag so -/// the two cannot drift apart. +/// This is an ordering check rather than a cancellation-race test on purpose: +/// it is the companion to +/// `receive_pack_tail_survives_a_disconnect_during_release`, which drives the +/// actual disconnect through a parked `release`, and to +/// `post_receive_job_survives_handler_abort` (in `api/repos.rs`), which drives +/// the disconnect (and a crash-before-spawn) through the durable job the +/// handler persisted. Same instrument the F3 gate above uses. /// -/// This is an ordering check rather than a cancellation-race test on purpose: it is -/// the companion to `receive_pack_tail_survives_a_disconnect_during_release`, which -/// drives the actual disconnect through a parked `release`. Same instrument the F3 -/// gate above uses. -/// -/// MUTATION (RED): move the `tokio::spawn(post_receive_replication_tail` call below +/// MUTATION (RED): move the `enqueue_post_receive_job` call below /// `guard.release(` and the ordering assertion fails; take it out of the -/// `if push_succeeded` block and the failed-push assertion fails. +/// `if push_succeeded` block and the failed-push assertion fails; move the +/// `process_post_receive_job` spawn above the enqueue and the durability +/// assertion fails. #[test] fn inv22_replication_tail_spawns_at_the_durability_boundary() { let repos = src("api/repos.rs"); @@ -456,11 +465,20 @@ fn inv22_replication_tail_spawns_at_the_durability_boundary() { let gate_open = production .find("if push_succeeded {") .expect("U5 gate missing: the tail spawn must be gated on the push having succeeded"); + let enqueue = production + .find("state.db.enqueue_post_receive_job(&job)") + .expect( + "U5 gate missing: the post-receive job must be persisted by git_receive_pack \ + before the push is acknowledged", + ); let spawn = production - .find("tokio::spawn(post_receive_replication_tail(") - .expect("U5 gate missing: the replication tail must be spawned by git_receive_pack"); + .find("tokio::spawn(process_post_receive_job(state.clone(), job));") + .expect("U5 gate missing: the post-receive job must be spawned by git_receive_pack"); + // The success-path `release` is the LAST one in the handler (the enqueue + // error branch has its own, earlier); `rfind` picks it so the ordering + // assertions bind the normal success path. let release = production - .find("guard.release(push_succeeded)") + .rfind("guard.release(push_succeeded)") .expect("U5 gate stale: release must consume the same success flag as the tail gate"); let touch = production .find("state.db.touch_repo(") @@ -470,20 +488,33 @@ fn inv22_replication_tail_spawns_at_the_durability_boundary() { .expect("U5 gate stale: git_receive_pack no longer fires push webhooks"); assert!( - success_flag < gate_open && gate_open < spawn, - "U5 gate bypassed: the tail must be spawned inside `if push_succeeded`, or a \ - rejected push spawns a tail that pins and announces a half-applied repo" + success_flag < gate_open && gate_open < enqueue && enqueue < spawn, + "U5 gate bypassed: the post-receive job must be enqueued (the durability \ + boundary) then spawned inside `if push_succeeded`, or a rejected push spawns \ + a job that pins and announces a half-applied repo — or the processor runs \ + against a job that has no recovery record yet" ); - // Still inside that block: no `}` may close it between the gate and the spawn. + // Still inside that block: between the `if push_succeeded {` and the enqueue + // the brace balance must never go negative — the block's opening `{` is + // matched by the struct literals' own braces (job construction), but a `}` + // that closed the `if` block before the enqueue would unbalance it. The + // enqueue's own `if let Err` error branch closes a brace after it, which is + // fine — the spawn is asserted after the enqueue separately. + let prefix = &production[gate_open + "if push_succeeded {".len()..enqueue]; + let depth = prefix.chars().fold(1i64, |depth, c| match c { + '{' => depth + 1, + '}' => depth - 1, + _ => depth, + }); assert!( - !production[gate_open + "if push_succeeded {".len()..spawn].contains('}'), - "U5 gate bypassed: the tail spawn left the `if push_succeeded` block, so a \ - rejected push now spawns a tail" + depth >= 1, + "U5 gate bypassed: the enqueue left the `if push_succeeded` block, so a \ + rejected push now enqueues a job" ); assert!( spawn < release && spawn < touch && spawn < webhook, - "U5 gate bypassed: the tail must be spawned BEFORE guard.release, touch_repo \ - and the webhook fan-out, so a disconnect in any of those windows cannot drop \ - this push's pins, recovery copy, and announcements" + "U5 gate bypassed: the post-receive job must be enqueued and spawned BEFORE \ + guard.release, touch_repo and the webhook fan-out, so a disconnect in any of \ + those windows cannot drop this push's pins, recovery copy, and announcements" ); } diff --git a/crates/gl/src/cert.rs b/crates/gl/src/cert.rs index 87ad5aec..5f31e11f 100644 --- a/crates/gl/src/cert.rs +++ b/crates/gl/src/cert.rs @@ -156,6 +156,12 @@ async fn cmd_show( let node_did = cert["node_did"].as_str().unwrap_or("?"); let signature = cert["signature"].as_str().unwrap_or("?"); let issued_at = cert["issued_at"].as_str().unwrap_or("?"); + let seq = cert["seq"].as_i64().unwrap_or(0); + let prev = cert["prev"].as_str().unwrap_or("?"); + let pusher_sig = cert["pusher_sig"].as_str(); + let signature_input = cert["signature_input"].as_str(); + let content_digest = cert["content_digest"].as_str(); + let request_path = cert["request_path"].as_str(); println!("Ref Certificate: {cert_id}"); println!(" Ref: {ref_name}"); @@ -163,6 +169,7 @@ async fn cmd_show( println!(" New SHA: {new_sha}"); println!(" Pusher: {pusher}"); println!(" Node DID: {node_did}"); + println!(" Seq: {seq}"); println!(" Issued at: {issued_at}"); println!(" Signature: {signature}"); println!(); @@ -174,7 +181,20 @@ async fn cmd_show( // names; the node-DID comparison below covers *which* node that is. let repo_id = cert["repo_id"].as_str().unwrap_or(""); let verdict = verify_signature( - repo_id, ref_name, old_sha, new_sha, pusher, node_did, issued_at, signature, + repo_id, + ref_name, + old_sha, + new_sha, + pusher, + node_did, + issued_at, + seq, + prev, + pusher_sig, + signature_input, + content_digest, + request_path, + signature, ); println!("Signature verification:"); @@ -242,6 +262,11 @@ async fn cmd_show( /// Rebuild the node's canonical signing payload (field order must match /// gitlawb-node/src/cert.rs::issue_ref_certificate exactly) and verify the /// certificate's Ed25519 signature against the key embedded in `node_did`. +/// +/// Certificates after this PR use a 13-field payload. Pre-PR certificates +/// were signed over 7 fields (repo_id, ref, old, new, pusher, node, ts) with +/// NULL proof columns. Try 13-field first; if it fails and all proof fields +/// are None, retry with the 7-field payload. #[allow(clippy::too_many_arguments)] fn verify_signature( repo_id: &str, @@ -251,22 +276,21 @@ fn verify_signature( pusher: &str, node_did: &str, issued_at: &str, + seq: i64, + prev: &str, + pusher_sig: Option<&str>, + signature_input: Option<&str>, + content_digest: Option<&str>, + request_path: Option<&str>, signature_b64: &str, ) -> std::result::Result<(), String> { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use std::str::FromStr; - let payload = serde_json::json!({ - "repo_id": repo_id, - "ref": ref_name, - "old": old_sha, - "new": new_sha, - "pusher": pusher, - "node": node_did, - "ts": issued_at, - }); - let payload_bytes = - serde_json::to_vec(&payload).map_err(|e| format!("could not serialize payload: {e}"))?; + let proof_fields_null = pusher_sig.is_none() + && signature_input.is_none() + && content_digest.is_none() + && request_path.is_none(); let did = gitlawb_core::did::Did::from_str(node_did).map_err(|e| format!("bad node DID: {e}"))?; @@ -281,8 +305,47 @@ fn verify_signature( .try_into() .map_err(|_| "signature is not 64 bytes".to_string())?; - gitlawb_core::identity::verify(&verifying_key, &payload_bytes, &sig_bytes) - .map_err(|_| "Ed25519 signature does not match the signed payload".to_string()) + // Try 13-field payload first. + let payload_13 = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher, + "node": node_did, + "ts": issued_at, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, + "signature_input": signature_input, + "content_digest": content_digest, + "request_path": request_path, + }); + let payload_bytes_13 = + serde_json::to_vec(&payload_13).map_err(|e| format!("could not serialize payload: {e}"))?; + + let sig_valid_13 = + gitlawb_core::identity::verify(&verifying_key, &payload_bytes_13, &sig_bytes); + + if proof_fields_null && sig_valid_13.is_err() { + // Fall back to 7-field payload for pre-PR certificates. + let payload_7 = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher, + "node": node_did, + "ts": issued_at, + }); + let payload_bytes_7 = serde_json::to_vec(&payload_7) + .map_err(|e| format!("could not serialize payload: {e}"))?; + gitlawb_core::identity::verify(&verifying_key, &payload_bytes_7, &sig_bytes).map_err(|_| { + "Ed25519 signature does not match the signed payload (7-field)".to_string() + }) + } else { + sig_valid_13.map_err(|_| "Ed25519 signature does not match the signed payload".to_string()) + } } async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result { @@ -334,11 +397,19 @@ mod tests { "pusher": "did:key:z6MkPusher", "node": "did:key:z6MkNode", "ts": "2026-07-22T00:00:00+00:00", + "seq": 1, + "prev": "0000000000000000000000000000000000000000000000000000000000000000", + "pusher_sig": serde_json::Value::Null, + "signature_input": serde_json::Value::Null, + "content_digest": serde_json::Value::Null, + "request_path": serde_json::Value::Null, }); let frozen = concat!( - r#"{"new":"newsha","node":"did:key:z6MkNode","old":"oldsha","#, - r#""pusher":"did:key:z6MkPusher","ref":"refs/heads/main","#, - r#""repo_id":"repo-1","ts":"2026-07-22T00:00:00+00:00"}"#, + r#"{"content_digest":null,"new":"newsha","node":"did:key:z6MkNode","old":"oldsha","#, + r#""prev":"0000000000000000000000000000000000000000000000000000000000000000","#, + r#""pusher":"did:key:z6MkPusher","pusher_sig":null,"ref":"refs/heads/main","#, + r#""repo_id":"repo-1","request_path":null,"seq":1,"signature_input":null,"#, + r#""ts":"2026-07-22T00:00:00+00:00"}"#, ); assert_eq!(serde_json::to_string(&payload).unwrap(), frozen); } @@ -349,6 +420,7 @@ mod tests { fn verify_signature_round_trip_and_tamper() { let kp = gitlawb_core::identity::Keypair::generate(); let node_did = kp.did().as_str().to_string(); + let prev = "0000000000000000000000000000000000000000000000000000000000000000"; let payload = serde_json::json!({ "repo_id": "repo-1", @@ -358,6 +430,12 @@ mod tests { "pusher": "did:key:z6MkPusher", "node": node_did, "ts": "2026-07-22T00:00:00+00:00", + "seq": 1, + "prev": prev, + "pusher_sig": serde_json::Value::Null, + "signature_input": serde_json::Value::Null, + "content_digest": serde_json::Value::Null, + "request_path": serde_json::Value::Null, }); let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); @@ -369,6 +447,12 @@ mod tests { "did:key:z6MkPusher", &node_did, "2026-07-22T00:00:00+00:00", + 1, + prev, + None, + None, + None, + None, &sig, ); assert!(ok.is_ok(), "expected valid signature, got: {ok:?}"); @@ -381,6 +465,12 @@ mod tests { "did:key:z6MkPusher", &node_did, "2026-07-22T00:00:00+00:00", + 1, + prev, + None, + None, + None, + None, &sig, ); assert!(tampered.is_err(), "tampered payload must not verify"); @@ -393,8 +483,124 @@ mod tests { "did:key:z6MkPusher", &node_did, "2026-07-22T00:00:00+00:00", + 1, + prev, + None, + None, + None, + None, "not-base64url!!!", ); assert!(garbage.is_err(), "malformed signature must not verify"); } + + #[test] + fn verify_signature_all_fields_populated() { + let kp = gitlawb_core::identity::Keypair::generate(); + let node_did = kp.did().as_str().to_string(); + let prev = "0000000000000000000000000000000000000000000000000000000000000000"; + + let pusher_sig = "sig-123"; + let signature_input = "sig-input-123"; + let content_digest = "sha256-123"; + let request_path = "/repo.git/git-receive-pack"; + + let payload = serde_json::json!({ + "repo_id": "repo-1", + "ref": "refs/heads/main", + "old": "0".repeat(40), + "new": "a".repeat(40), + "pusher": "did:key:z6MkPusher", + "node": node_did, + "ts": "2026-07-22T00:00:00+00:00", + "seq": 1, + "prev": prev, + "pusher_sig": pusher_sig, + "signature_input": signature_input, + "content_digest": content_digest, + "request_path": request_path, + }); + let sig = kp.sign_b64(&serde_json::to_vec(&payload).unwrap()); + + let ok = verify_signature( + "repo-1", + "refs/heads/main", + &"0".repeat(40), + &"a".repeat(40), + "did:key:z6MkPusher", + &node_did, + "2026-07-22T00:00:00+00:00", + 1, + prev, + Some(pusher_sig), + Some(signature_input), + Some(content_digest), + Some(request_path), + &sig, + ); + assert!(ok.is_ok(), "expected valid signature, got: {ok:?}"); + } + + #[test] + fn verify_signature_7_field_legacy_fallback() { + // A true 7-field (pre-PR) payload — no seq, prev, or proof fields. + // The fallback must detect the 13-field mismatch and retry with 7. + let kp = gitlawb_core::identity::Keypair::generate(); + let node_did = kp.did().as_str().to_string(); + + let payload_7 = serde_json::json!({ + "repo_id": "repo-1", + "ref": "refs/heads/main", + "old": "0".repeat(40), + "new": "a".repeat(40), + "pusher": "did:key:z6MkPusher", + "node": node_did, + "ts": "2026-07-22T00:00:00+00:00", + }); + let sig = kp.sign_b64(&serde_json::to_vec(&payload_7).unwrap()); + + // All proof fields None → triggers 7-field fallback. + let ok = verify_signature( + "repo-1", + "refs/heads/main", + &"0".repeat(40), + &"a".repeat(40), + "did:key:z6MkPusher", + &node_did, + "2026-07-22T00:00:00+00:00", + 1, + "0000000000000000000000000000000000000000000000000000000000000000", + None, + None, + None, + None, + &sig, + ); + assert!( + ok.is_ok(), + "legacy 7-field certificate must verify via fallback, got: {ok:?}" + ); + + // Tampered new_sha must still fail. + let tampered = verify_signature( + "repo-1", + "refs/heads/main", + &"0".repeat(40), + &"b".repeat(40), + "did:key:z6MkPusher", + &node_did, + "2026-07-22T00:00:00+00:00", + 1, + "0000000000000000000000000000000000000000000000000000000000000000", + None, + None, + None, + None, + &sig, + ); + assert!( + tampered.is_err(), + "tampered 7-field payload must not verify" + ); + } }