diff --git a/.env.example b/.env.example index 81c60824..a57822ba 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 3f29b076..0eb085ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5709,12 +5709,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", ] @@ -7481,6 +7483,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 9b8b4684..c2cab694 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 } # URL parsing (what reqwest::Url re-exports, so the shared redirect predicate can # take a parsed URL without pulling reqwest into gitlawb-core) url = "2" diff --git a/README.md b/README.md index 3a092bf2..7be239bf 100644 --- a/README.md +++ b/README.md @@ -414,7 +414,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..1039f392 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,6 +33,16 @@ We will acknowledge receipt within 48 hours and aim to release a fix within 14 d - A supplied token's signature, audience, expiry, and proof-chain attenuation are validated. - Tokens use a signed JSON wire format with expiry. - Capability grants are not yet consulted by repository write authorization; see the limitations below. +**UCAN capability tokens** +- Issued at registration as a signed JSON envelope `{ "payload": {...}, "s": "" }` — not a JWT (#224 review: the policy must describe the actual wire format) +- 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 + +**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 @@ -57,6 +67,16 @@ These are documented limitations of the current live release. They should be pri - `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. - **Mitigation:** Set `GITLAWB_ENFORCE_OWNER_PUSH=true` on nodes where owner-only pushes are required. Confirm that every legitimate pusher uses the owner DID before enabling it. +### 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). + +### Owner-push enforcement defaults off +- `GITLAWB_ENFORCE_OWNER_PUSH` defaults to `false`: a valid did:key HTTP Signature is authentication, not authorization, so any registered agent can push to a repo until the operator enables owner-only writes. +- **Impact:** Anyone who can register an agent can push to any repo while the flag is off. +- **Mitigation:** Enable `GITLAWB_ENFORCE_OWNER_PUSH=true` in production; keep write endpoints signed in the meantime. ### UCAN delegation and revocation - The middleware validates a supplied UCAN's complete proof chain, but a root token is accepted without an independently trusted issuer anchor. `Ucan::can` is not yet used by write handlers, so a UCAN does not grant scoped repository access. @@ -85,6 +105,9 @@ These are documented limitations of the current live release. They should be pri ### GraphQL mutation coverage - Existing GraphQL mutations require an authenticated signer, but a mutation-specific source-level guardrail has not yet been added for future mutations. - **Impact:** A new mutation could accidentally omit its signer check without an explicit test fence. +- Per-repository private-read enforcement IS wired: `authorize_repo_read` and per-path visibility rules deny non-readers with an opaque 404, on reads and writes alike. +- **Impact:** The remaining risk is operational, not structural: a public node should still not be handed secrets, because read access is granted by the repo owner's visibility rules and any node operator can see everything stored on their own node. +- **Mitigation:** Keep secrets on isolated nodes and restrict network access at the reverse proxy or firewall layer. ### Peer route hardening rollout - Peer announce and sync notification routes accept signed requests and verify DID matches when a signature is present. @@ -112,6 +135,7 @@ These are documented limitations of the current live release. They should be pri | 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..0577c798 --- /dev/null +++ b/crates/gitlawb-node/src/ans104.rs @@ -0,0 +1,556 @@ +//! 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::{anyhow, bail, Result}; +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(signature))` where signature +/// is bytes 2..66 (the 64-byte Ed25519 signature) of the serialized item. +/// 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). +/// +/// Note: this differs from hashing the complete serialized item — the id is +/// derived solely from the signature bytes, per the ANS-104 specification. +pub fn data_item_id(item: &[u8]) -> String { + if item.len() < 2 + SIGNATURE_LEN { + // Invalid item: return an impossible ID so callers fail safe + return base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(Sha256::digest(b"invalid data item")); + } + let signature_region = &item[2..2 + SIGNATURE_LEN]; + let digest = Sha256::digest(signature_region); + 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(), + }) +} + +/// Walk the serialized header of a data item and return its data section. +/// +/// Structural only: the signature is NOT verified here. Callers use this to +/// unwrap the payload of an item whose identity they have already bound by +/// other means (`data_item_id` over the served bytes), e.g. a gateway response +/// for a requested transaction id. +pub fn data_item_data(item: &[u8]) -> Result<&[u8]> { + 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 mut p = 2 + SIGNATURE_LEN + OWNER_LEN; + // Optional target, then optional anchor: one presence byte each, plus 32 + // bytes of field when present. + for field in ["target", "anchor"] { + let present = *item + .get(p) + .ok_or_else(|| anyhow!("data item truncated in {field} presence"))?; + p += 1; + match present { + 0 => {} + 1 => { + p += OWNER_LEN; + if p > item.len() { + bail!("data item truncated in {field}"); + } + } + other => bail!("invalid {field} presence byte {other}"), + } + } + + // Skip tag count (8 bytes) + tag byte count (8 bytes), then the tags. + let counts_end = p + .checked_add(16) + .ok_or_else(|| anyhow!("tag byte count overflow"))?; + if counts_end > item.len() { + bail!("data item truncated in tag counts"); + } + let num_tag_bytes = u64::from_le_bytes(item[p + 8..counts_end].try_into()?) as usize; + let tags_end = counts_end + .checked_add(num_tag_bytes) + .ok_or_else(|| anyhow!("tag byte count overflow"))?; + if tags_end > item.len() { + bail!("data item truncated in tags"); + } + Ok(&item[tags_end..]) +} + +/// 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..f5e39183 100644 --- a/crates/gitlawb-node/src/api/arweave.rs +++ b/crates/gitlawb-node/src/api/arweave.rs @@ -1,14 +1,58 @@ //! 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; + // Return a clear error when no gateway is configured, rather than letting + // the downstream URL parse fail surface as a 500. + if gateway.trim().is_empty() { + return Err(AppError::BadRequest( + "no Arweave gateway configured: set GITLAWB_ARWEAVE_GATEWAY to verify anchors" + .to_string(), + )); + } + 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,14 +69,49 @@ pub async fn list_anchors( State(state): State, Query(q): Query, ) -> Result> { - let limit = q.limit.min(200); + let _limit = 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). + // 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) + }; + + // Visibility is enforced in the query itself (#136 follow-up from the #215 + // review): the SQL joins anchors to their repo group and only keeps groups + // that currently contain a non-quarantined public row, then applies LIMIT + // after the gate. An anchor for a repo that later became private (or + // disappeared) never reaches this layer — no post-read filter, no pre-gate + // limit. let anchors = state .db .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(), @@ -48,6 +127,32 @@ mod closed_pool_tests { use sqlx::PgPool; use tower::ServiceExt; + /// Seed a public repo row for `zAlice/{name}`: the SQL visibility gate + /// joins each anchor to its repo group, so an anchor without a matching + /// public repo row is fail-closed out of the listing (and a test asserting + /// URLs/counts would then fail for the wrong reason). Pairing every seeded + /// `alice/myrepo`-slugged anchor with this row is the #136 follow-up test + /// contract. + async fn seed_public_repo(state: &crate::state::AppState, name: &str) { + state + .db + .create_repo(&crate::db::RepoRecord { + id: format!("repo-{name}"), + name: name.to_string(), + owner_did: "did:key:zAlice".to_string(), + description: None, + is_public: true, + default_branch: "main".to_string(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + disk_path: format!("/tmp/{name}"), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + } + /// #251: a closed pool on /api/v1/arweave/anchors must be 503 db_unavailable. #[sqlx::test] async fn list_anchors_closed_pool_returns_503_db_unavailable(pool: PgPool) { @@ -83,4 +188,245 @@ 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", + ])); + + seed_public_repo(&state, "myrepo").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 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", + ])); + + seed_public_repo(&state, "myrepo").await; + 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; + + seed_public_repo(&state, "myrepo").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. + seed_public_repo(&state, "myrepo").await; + 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 4e327c42..ba5d4f32 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, @@ -942,6 +944,8 @@ pub(crate) async fn run_encrypt_pin_task_for_test( owner_did, repo_name, 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), @@ -1492,7 +1496,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 { @@ -2004,12 +2011,15 @@ 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, crate::rate_limit::PeerAddr(peer): crate::rate_limit::PeerAddr, headers: axum::http::HeaderMap, + Extension(pusher_sig): Extension, + Extension(pusher_proof): Extension, body: Bytes, ) -> Result { let name = smart_http_repo_name(&repo)?; @@ -2275,13 +2285,23 @@ 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 now fails the job + // (returns an error), so a cert outage causes the push to fail rather than + // silently dropping certificates. 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 @@ -2297,14 +2317,49 @@ 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(); + + // ── Durable post-receive job (spawned ABOVE release) ─────────────── + // The job is persisted and spawned BEFORE guard.release(): a disconnect + // during release drops the handler future but the spawned task lives on. if push_succeeded { - tokio::spawn(post_receive_replication_tail( - state.clone(), - record.clone(), - ref_updates.clone(), - disk_path.clone(), - auth.0.to_string(), - )); + let did = auth.0.as_str(); + 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" + ); + 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 @@ -2347,50 +2402,8 @@ 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 + let did = auth.0.as_str(); if !ref_updates.is_empty() { let base_url = state .config @@ -2431,18 +2444,702 @@ pub async fn git_receive_pack( Ok(result) } -/// 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 -/// task. Split out of `git_receive_pack` so the ordering the coalescing gate depends -/// on is directly testable; the handler spawns it and returns. +/// 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 decides the DURABLE anchor inputs synchronously and + // returns them: the announce/visibility decision (fail-closed) and the + // locally computed tip CIDs. The best-effort Pinata/announcement worker it + // spawns continues independently (#224 R5 review): the durable Arweave + // anchoring unit must NOT depend on a best-effort replication worker — a + // saturated, disabled, slow, or failing Pinata must never hold the job + // lease or block certificate/anchor completion behind a process restart. + // The anchor embeds the real, content-derived tip CID either way: a CID is + // a pure function of the object bytes, so it needs no provider round-trip. + let (announce, cid_map) = post_receive_replication_tail( + state.clone(), + record.clone(), + ref_updates.clone(), + disk_path, + did.to_string(), + ref_certs.clone(), + ) + .await + .map_err(|e| anyhow::anyhow!("replication tail visibility lookup failed: {e}"))?; + // 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 + )); + } + }; + // This worker's lease token (per-attempt). It is written into the row by + // the INSERT (fresh claim) or the lease hand-off CAS (recovery), and every + // later state-write is conditioned on it — so a worker that loses the + // hand-off cannot corrupt the winner's row. + 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 + ) + })?; + // (claim_id, expected lease token for the hand-off CAS, anchor timestamp). + // + // The anchor's CONTENT timestamp must be the one already on the row: a + // rebuilt item on a retry must serialize identically so its ANS-104 id + // is the same id that the gateway probe reconciles (#224 deterministic + // retry). Fresh claims use `claimed_at` (= the value the row was + // inserted with); recoveries use the row's persisted `claimed_at`. + let (claim_id, expected_token, timestamp_for_anchor) = 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 } => { + // Fresh claim: the row's claim_token IS ours (the INSERT set it), + // so the hand-off CAS with expected token = ours is an atomic + // guard against a concurrent worker that could only ever observe + // a row we already own. + (id, claim_token.clone(), claimed_at.clone()) + } + crate::db::AnchorClaim::Recover { + id, + state: recovered_state, + item_id, + claim_token: current_token, + claimed_at: claimed_at_row, + } => { + tracing::debug!( + repo = %repo_slug, + ref_name = %update.ref_name, + state = %recovered_state, + "recovering non-terminal arweave anchor claim" + ); + // The expected value of the hand-off CAS is the CURRENT holder's + // token; a token-less (pre-lease) row is matched by the CAS's + // `claim_token IS NULL` clause, so the empty string is a safe + // sentinel — it can never equal a real UUID token. + let expected = current_token.unwrap_or_default(); + // 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. + let ts = claimed_at_row.unwrap_or_else(|| claimed_at.clone()); + match item_id { + None => (id, expected, ts), + Some(persisted_item) => { + match crate::arweave::anchor_item_present( + &state.http_client, + &state.config.arweave_gateway, + &persisted_item, + ) + .await + { + Ok(true) => { + // The earlier upload landed: record it directly. + // This is the idempotent terminal write — it does + // not pay, so no lease hand-off is required; the + // guard uses the current holder's token if one + // exists, so a raced loser cannot corrupt a + // winner's recorded row; a 0-rows_here means the + // row is already recorded, which is correctness + // (progress), not corruption. + match state.db.recover_claimed_anchor(&id, &persisted_item).await { + Ok(_) => { + tracing::info!( + tx_id = %persisted_item, + repo = %repo_slug, + ref_name = %update.ref_name, + "recovered already-uploaded arweave anchor without re-uploading" + ); + } + Err(e) => { + return Err(anyhow::anyhow!( + "recovered arweave anchor {persisted_item} for \ + {}/{} but could not persist it: {e}", + repo_slug, + update.ref_name + )); + } + } + continue; + } + Ok(false) => (id, expected, ts), + 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: timestamp_for_anchor.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); + // Acquire exclusive ownership before paying. `expected_token` is the + // current lease holder (or a sentinel for the pre-lease row), so this + // CAS is a token-conditioned pending→uploading transition: of N + // concurrent workers covering this same transition, exactly one wins, + // hijacks the lease to our `claim_token`, and proceeds; every loser + // gets `rows_affected == 0` and must not call the bundler. + let rows_affected = state + .db + .set_anchor_uploading( + &claim_id, + &item_id, + expected_token.as_str(), + Some(claim_token.as_str()), + ) + .await + .map_err(|e| { + anyhow::anyhow!( + "cannot mark arweave anchor uploading for {}/{}: {e}", + repo_slug, + update.ref_name + ) + })?; + let claim_id_for_upload = claim_id; + if rows_affected == 0 { + // We did not win the lease hand-off. Two causes, one safe action: + // either a concurrent worker just took the lease and is moving the + // row itself, or the row is an `uploading` lease younger than the + // quiescence window and no takeover is allowed yet. In NEITHER case + // may this worker call the bundler, and neither case justifies + // marking the job `done` (that would orphan the anchor if the + // winning/holding worker crashes before recording). Fail the job + // with no upload — the startup drain retry policy re-attempts it, + // converging on the recorded row or eventually winning the lease. + return Err(anyhow::anyhow!( + "cannot acquire the arweave anchor lease for {}/{} — another worker holds \ + or recently claimed the transition; failing without uploading so the \ + drain retries", + repo_slug, + update.ref_name + )); + } + let outcome = crate::arweave::upload_ref_anchor_item( + &state.http_client, + bundler_url, + bundler_account, + bundler_token, + &item, + &item_id, + ) + .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 bundler definitively did not accept the item; an upstream + // drain will re-upload. The row remains reserved for that drain. + // Failure is conditioned on the lease token, so that a batched + // worker can't flip the row over to the winner (#224 review). + let _ = state + .db + .set_anchor_failed(&claim_id_for_upload, claim_token.as_str()) + .await; + return Err(anyhow::anyhow!( + "arweave anchor for {}/{} was rejected by the bundler: {message} — if the \ + bundler reports 'Not enough balance', charge to \ + GITLAWB_BUNDLER_ACCOUNT (the token of GITLAWB_BUNDLER_TOKEN); an \ + uncharged node will retry and lose its anchor forever", + repo_slug, + update.ref_name + )); + } + crate::arweave::UploadOutcome::Uncertain { message } => { + // The result is unknown (connection dropped, or a + // success-shaped response that doesn't match the expected + // identity): the item is possibly accepted. Leave the row as + // `uploading` (with our lease token + persisted item_id) and + // fail the job; upstream drain recovery probes the gateway and, + // if the item has landed, records it instead of re-uploading. + return Err(anyhow::anyhow!( + "arweave anchor for {}/{} has unknown upload result: {message} — \ + upfront drain will probe the gateway before deciding whether to re-upload", + repo_slug, + update.ref_name + )); + } + }; + // Upload accepted — persist the persistent terminal state. A failed + // UPDATE is a failure in the work unit: the row remains `uploading` + // with its item_id, and upstream drain recovery probes the gateway and + // records the landing item without paying for a second artifact. The + // record write is guarded by our lease token, so that no delayed + // loser can overwrite the winner's terminal state. If 0 rows are + // affected, we've already been replaced mid-approval — the job fails + // closed and upstream drain settles the already-landed item. + let recorded = state + .db + .record_claimed_anchor(&claim_id_for_upload, &tx_id, claim_token.as_str()) + .await + .map_err(|e| { + anyhow::anyhow!( + "uploaded arweave anchor {tx_id} for {}/{} but could not persist it: {e}", + repo_slug, + update.ref_name + ) + })?; + if recorded == 0 { + return Err(anyhow::anyhow!( + "uploaded arweave anchor {tx_id} for {}/{} but the lease is already over — \ + the row no longer matches our claim token. Leaving the job unmarked so the \ + upfront drain settles it from gateway state", + 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) +} + +/// Compute each push tip's content CID locally (#224 R5 review). A CID is a +/// pure function of the object bytes (`Cid::from_git_object_bytes`), so the +/// durable Arweave anchor never needs a Pinata round-trip to learn it: one +/// bounded, admitted `cat-file` read per non-deletion tip replaces the +/// provider-derived map the job body used to wait on. +/// +/// Enrichment, not obligation: a tip that cannot be read here simply anchors +/// without an embedded CID (the anchor payload treats it as absent), and the +/// failure is never propagated into the job result. Every read runs under a +/// scan-admission permit and the repo's own git timeout, matching the +/// withholding walk's discipline. +async fn local_tip_cids( + encrypt_sem: std::sync::Arc, + disk_path: std::path::PathBuf, + git_bin: String, + timeout: std::time::Duration, + ref_updates: &[RefUpdate], +) -> std::collections::HashMap { + let mut cid_map = std::collections::HashMap::new(); + for u in ref_updates { + if u.new_sha == ZERO_SHA { + continue; + } + let permit = + crate::state::acquire_scan_permit(encrypt_sem.clone(), &disk_path, "tip cid read") + .await; + let deadline = std::time::Instant::now() + timeout; + let read = tokio::task::spawn_blocking({ + let disk_path = disk_path.clone(); + let git_bin = git_bin.clone(); + let sha = u.new_sha.clone(); + move || crate::git::store::read_object_bounded(&git_bin, &disk_path, &sha, deadline) + }) + .await; + drop(permit); + match read { + Ok(Ok(Some((_kind, bytes)))) => { + cid_map.insert( + u.new_sha.clone(), + gitlawb_core::cid::Cid::from_git_object_bytes(&bytes).to_string(), + ); + } + Ok(Ok(None)) => { + tracing::warn!( + sha = %u.new_sha, + "tip object not found locally; anchoring without an embedded CID" + ); + } + Ok(Err(e)) => { + tracing::warn!( + sha = %u.new_sha, + err = %e, + "tip object unreadable locally; anchoring without an embedded CID" + ); + } + Err(e) => { + tracing::warn!( + sha = %u.new_sha, + err = %e, + "tip CID read task failed; anchoring without an embedded CID" + ); + } + } + } + cid_map +} + +/// The post-receive replication tail (#174 F2): the replication decision, the +/// per-repo-coalesced pin/encrypt task, and this push's own Pinata + announce +/// task. Split out of `git_receive_pack` so the ordering the coalescing gate +/// depends on is directly testable; the handler spawns it and returns. +/// +/// DURABILITY BOUNDARY (#224 R5 review): the tail is awaited by the durable +/// post-receive job and returns the anchor's required inputs SYNCHRONOUSLY — +/// the fail-closed announce decision and the locally computed tip CIDs (a CID +/// is a pure function of the object bytes, so it needs no provider). The +/// Pinata pin and the announcement fan-out it dispatches below are strictly +/// BEST EFFORT: a disabled, saturated, slow, or failing provider can never +/// hold the job lease or block certificate/anchor completion behind a restart. async fn post_receive_replication_tail( state: AppState, record: RepoRecord, ref_updates: Vec, disk_path: std::path::PathBuf, did: String, -) { + ref_certs: std::collections::HashMap, +) -> anyhow::Result<(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 // node. `withheld == None` means this push pins nothing (private / mode A / @@ -2450,7 +3147,17 @@ async fn post_receive_replication_tail( // objects (which withheld_blob_oids never lists) stay local. Fail closed: a // private or undetermined repo never leaks. The announce decision that gates // the network-facing sends is taken separately, below. - let rules_opt = state.db.list_visibility_rules(&record.id).await.ok(); + // + // A DB error here is propagated (not collapsed to None) so the durable job + // fails and remains retryable: an explicit non-public decision may complete + // without an anchor, but an unavailable visibility decision must not silently + // skip anchoring (#224 R3 review). + let rules = match state.db.list_visibility_rules(&record.id).await { + Ok(r) => r, + Err(e) => { + return Err(e); + } + }; // #174 F2a: take the per-repo coalescing key BEFORE the walk, not after it. // `replication_withheld_set` decides announceability from the rules snapshot @@ -2461,12 +3168,8 @@ async fn post_receive_replication_tail( // materialization before finding out they were going to coalesce; now a push // that will coalesce does none of that. Not announceable is the same as // before: nothing replicates, so no key is taken and no walk runs. - let announce_at_root = match &rules_opt { - Some(rules) => { - crate::visibility::listable_at_root(rules, record.is_public, &record.owner_did, None) - } - None => false, - }; + let announce_at_root = + crate::visibility::listable_at_root(&rules, record.is_public, &record.owner_did, None); let mut coalesced = false; let mut inflight = None; if announce_at_root { @@ -2501,7 +3204,7 @@ async fn post_receive_replication_tail( } else { replication_withheld_set( state.git_encrypt_semaphore.clone(), - rules_opt.clone(), + Some(rules.clone()), &record.owner_did, record.is_public, disk_path.clone(), @@ -2518,6 +3221,10 @@ async fn post_receive_replication_tail( // rules-only predicate there: it has no walk of its own, so the worker's // re-derivation is its only fail-closed source. let own_walk_failed = announce_at_root && !coalesced && withheld.is_none(); + // The durable anchor decision is taken from the walk OUTCOME, not the set + // itself (the set is consumed below to build the pin list), so capture the + // verdict before the move. + let walk_vetted = withheld.is_some(); // Resolve the per-push pin candidate set once, off the async worker, then // filter to what may actually replicate. Delta path: the reachable-only @@ -2551,7 +3258,7 @@ async fn post_receive_replication_tail( fail_closed_full_scan_objects( state.git_encrypt_semaphore.clone(), disk_path.clone(), - rules_opt.clone().unwrap_or_default(), + rules.clone(), record.is_public, record.owner_did.clone(), pin_set.candidates, @@ -2597,7 +3304,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), @@ -2610,7 +3319,7 @@ async fn post_receive_replication_tail( ctx, inflight_guard, object_list, - rules_opt.clone(), + Some(rules.clone()), record.is_public, )); } @@ -2620,11 +3329,15 @@ 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 R5 review): it is the durable, awaited unit in the job body, fed by + // THIS function's synchronous return value. Everything this spawn does is + // best-effort and outside the post-receive job's durability contract — a stalled + // or failing provider must not block the anchor. // // #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 @@ -2649,12 +3362,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`. @@ -2678,7 +3390,7 @@ async fn post_receive_replication_tail( // it from these once a pin slot frees. rules/owner/is_public drive the fresh // fail-closed withheld filter; encrypt_sem + git_bin + timeout keep the re-derive // git children under the same INV-22 bounded, group-reaped scan admission. - let pinata_rules_opt = rules_opt.clone(); + let pinata_rules_opt = Some(rules.clone()); let pinata_owner_did = record.owner_did.clone(); let pinata_is_public = record.is_public; let pinata_git_bin = state.git_bin.clone(); @@ -2757,6 +3469,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(), @@ -2766,7 +3481,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; @@ -2798,47 +3513,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 @@ -2874,6 +3548,51 @@ async fn post_receive_replication_tail( } }); } + + // DURABLE ANCHOR INPUTS (#224 R5 review): decided here, synchronously, from + // signals this function already owns — never from the best-effort Pinata + // worker above. The job body anchors on this return value alone, so a + // disabled, saturated, slow, or failing provider can no longer hold the job + // lease or block certificate/anchor completion behind a process restart. + // + // - Not listable at root: never anchor (unchanged, fail-closed). + // - Admitted push: the walk gate the receive-pack tail has always applied — + // an unvetted push (failed withheld walk) does not reach the public + // ledger. + // - Coalesced push (#174 F2a contract): the push deliberately ran no walk, + // and re-running one here would reintroduce exactly the scan-pool parking + // F2a removed. Its durable decision is therefore taken from the rules + // snapshot alone (`listable_at_root`); its PINS and announcements remain + // governed by the in-flight worker's fresh vetting. The divergence is + // honest and bounded: the anchor payload (repo/ref/shas/tip-commit CID) + // contains nothing the withheld-blob walk could have classified — only + // blob objects are withholdable, commit/tree tips are public ref + // metadata for any repo that is listable at all. + let durable_announce = if !announce_at_root { + false + } else if !coalesced { + walk_vetted + } else { + announce_at_root + }; + + // Locally derived tip CIDs: content-addressed from the objects themselves, + // so the anchor embeds the real CID with no provider dependency. Only an + // announceable push pays for these reads. + let cid_map = if durable_announce { + local_tip_cids( + state.git_encrypt_semaphore.clone(), + disk_path, + state.git_bin.clone(), + std::time::Duration::from_secs(state.config.git_service_timeout_secs), + &ref_updates, + ) + .await + } else { + std::collections::HashMap::new() + }; + + Ok((durable_announce, cid_map)) } /// GET /api/v1/repos/{owner}/{repo}/refs @@ -5942,6 +6661,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did.to_string())), crate::rate_limit::PeerAddr(Some(capped)), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await; @@ -5960,6 +6685,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did.to_string())), crate::rate_limit::PeerAddr(Some(other)), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await; @@ -6066,6 +6797,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did.to_string())), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await @@ -6153,6 +6890,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did.to_string())), crate::rate_limit::PeerAddr(Some("203.0.113.62:5000".parse().unwrap())), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await; @@ -6312,6 +7055,12 @@ mod tests { "203.0.113.81:5000".parse::().unwrap(), )), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), )); let mut found: Option = None; @@ -6466,6 +7215,12 @@ mod tests { "203.0.113.83:5000".parse::().unwrap(), )), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ), ) @@ -6554,6 +7309,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did.to_string())), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await; @@ -6572,6 +7333,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did.to_string())), crate::rate_limit::PeerAddr(Some("203.0.113.72:5000".parse().unwrap())), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await; @@ -7220,6 +7987,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))), crate::rate_limit::PeerAddr(Some(peer.parse::().unwrap())), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), ref_update_body(new_sha), ) .await @@ -7314,6 +8087,12 @@ mod tests { )), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ), ) @@ -7374,6 +8153,12 @@ mod tests { )), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), ref_update_body("2222222222222222222222222222222222222222"), ), ) @@ -7860,6 +8645,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), @@ -8599,6 +9386,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did.to_string())), crate::rate_limit::PeerAddr(Some("203.0.113.81:5000".parse::().unwrap())), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), )); let mut desc: Option = None; @@ -8629,6 +9422,12 @@ mod tests { "203.0.113.82:5000".parse::().unwrap(), )), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await @@ -8723,6 +9522,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did.to_string())), crate::rate_limit::PeerAddr(Some(peer.parse::().unwrap())), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ), ) @@ -8816,6 +9621,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did.to_string())), crate::rate_limit::PeerAddr(Some("203.0.113.71:5000".parse::().unwrap())), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), )); for _ in 0..1000 { @@ -8845,6 +9656,12 @@ mod tests { "203.0.113.72:5000".parse::().unwrap(), )), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await @@ -9027,6 +9844,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did)), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await @@ -9100,6 +9923,12 @@ mod tests { )), crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::().unwrap())), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), ref_update_body("1111111111111111111111111111111111111111"), ) .await @@ -9154,6 +9983,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did)), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await @@ -9259,6 +10094,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did)), crate::rate_limit::PeerAddr(Some(src)), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await @@ -9349,6 +10190,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))), crate::rate_limit::PeerAddr(Some(edge)), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await @@ -9442,6 +10289,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did.to_string())), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await @@ -9502,6 +10355,12 @@ mod tests { )), crate::rate_limit::PeerAddr(None), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) .await; @@ -9546,6 +10405,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(did.to_string())), crate::rate_limit::PeerAddr(Some(src)), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ), ) @@ -9638,6 +10503,22 @@ mod tests { }] } + /// Drive the replication tail and discard its durable anchor inputs (the + /// announce decision and local tip CIDs). Tests that do not exercise the + /// durable Arweave anchor unit have no use for them; the spawned Pinata + /// task keeps running independently either way. + #[allow(clippy::let_underscore_future)] + async fn f2a_tail( + state: AppState, + rec: crate::db::RepoRecord, + updates: Vec, + path: std::path::PathBuf, + did: String, + certs: std::collections::HashMap, + ) { + let _ = post_receive_replication_tail(state, rec, updates, path, did, certs).await; + } + const F2A_PUSHER: &str = "did:key:z6MkF2aPusherAAAAAAAAAAAAAAAAAAAAAAAAAA"; /// Scenario 1 (the finding). A second rapid push to the same repo coalesces @@ -9663,12 +10544,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); @@ -9683,12 +10565,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; @@ -9756,12 +10639,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; @@ -9843,6 +10727,7 @@ mod tests { f2a_update("refs/heads/main", &c2), repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), )); f2a_wait_for(|| started.exists(), "the admitted push's walk to start").await; @@ -9860,7 +10745,13 @@ mod tests { "a push arriving mid-walk must coalesce, not start a second task" ); std::fs::write(&go, b"").unwrap(); - tail.await.unwrap(); + // The failed walk suppresses the durable announce (fail closed) but is + // not an error: the tail still completes and reports it. + let (announce, _cid_map) = tail.await.unwrap().unwrap(); + assert!( + !announce, + "a push whose own withheld walk failed must not announce durably" + ); f2a_wait_for( || f2a_delta_scanned(&log, &c3), @@ -9946,7 +10837,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 { @@ -9956,6 +10847,7 @@ mod tests { }], repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), ) .await; @@ -10056,6 +10948,12 @@ mod tests { Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))), crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::().unwrap())), axum::http::HeaderMap::new(), + Extension(crate::auth::PusherSignature(String::new())), + Extension(crate::auth::PusherProof { + signature_input: String::new(), + content_digest: String::new(), + request_path: String::new(), + }), axum::body::Bytes::from_static(b"0000"), ) } @@ -10224,7 +11122,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 { @@ -10234,6 +11132,7 @@ mod tests { }], repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), ) .await; @@ -10283,12 +11182,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; @@ -10320,7 +11220,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 { @@ -10330,6 +11230,7 @@ mod tests { }], repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), ) .await; @@ -10417,7 +11318,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 { @@ -10427,6 +11328,7 @@ mod tests { }], repo.path().to_path_buf(), F2A_PUSHER.to_string(), + std::collections::HashMap::new(), ) .await; @@ -10464,4 +11366,1033 @@ 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. Accepted item bytes are stashed into `uploaded` so a paired + /// gateway mock can serve them verbatim for id-verified presence probes. + async fn f2a_bundler( + fail_first: usize, + uploaded: std::sync::Arc>>>, + ) -> (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(); + let uploaded_srv = uploaded.clone(); + axum::Router::new().route( + "/tx/{token}", + // The mock returns the uploaded item's OWN ANS-104 id: the node + // binds the provider acknowledgement to the exact request + // identity (a different id is classified Uncertain), and a real + // bundler echoes the item's id back — this mock must do the + // same or every job-level anchor test sees a forged identity. + axum::routing::post(move |body: axum::body::Bytes| { + let calls = calls_srv.clone(); + let failures = failures_srv.clone(); + let uploaded = uploaded_srv.clone(); + async move { + // Derive the id from the received bytes BEFORE counting + // the call so that a simulated outage also does not + // consume the response path. + let item_id = crate::ans104::data_item_id(&body); + 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 { + // Keep the exact bytes the node sent: a gateway + // probe must derive the same id from the body. + *uploaded.write().unwrap() = Some(body.to_vec()); + ( + StatusCode::OK, + axum::Json(serde_json::json!({"id": item_id})), + ) + .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, + // The item store shared with the paired `f2a_bundler`. A gateway that + // cannot bind its response to the probed id is refused, so "present" + // serves the exact uploaded ANS-104 item bytes verbatim — the only + // representation whose id the probe can verify. + uploaded: std::sync::Arc>>>, + ) -> (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(); + let uploaded_srv = uploaded.clone(); + axum::Router::new().route( + "/{item_id}", + axum::routing::get(move |_path: axum::extract::Path| { + let probes = probes_srv.clone(); + let uploaded = uploaded_srv.clone(); + async move { + probes.fetch_add(1, Ordering::SeqCst); + match mode { + "present" => { + // Serve the exact uploaded item bytes: the probe + // only accepts a 2xx whose body derives the + // probed item id. If nothing has been uploaded + // yet there is no item to be "present", so 404. + match uploaded.read().unwrap().clone() { + Some(bytes) => ( + axum::http::StatusCode::OK, + [("content-type", "application/octet-stream")], + bytes, + ) + .into_response(), + None => { + (axum::http::StatusCode::NOT_FOUND, "{}").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 uploaded = std::sync::Arc::new(std::sync::RwLock::new(None::>)); + let (bundler_url, calls) = f2a_bundler(0, uploaded.clone()).await; + let (gateway_url, probes) = f2a_gateway("present", uploaded).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 uploaded = std::sync::Arc::new(std::sync::RwLock::new(None::>)); + let (bundler_url, calls) = f2a_bundler(0, uploaded.clone()).await; + let (gateway_url, _probes) = f2a_gateway("absent", uploaded).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 uploaded = std::sync::Arc::new(std::sync::RwLock::new(None::>)); + let (bundler_url, calls) = f2a_bundler(0, uploaded.clone()).await; + let (gateway_url, probes) = f2a_gateway("error", uploaded).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", "claim-token", None) + .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 uploaded = std::sync::Arc::new(std::sync::RwLock::new(None::>)); + let (bundler_url, calls) = f2a_bundler(1, uploaded.clone()).await; + let (gateway_url, probes) = f2a_gateway("absent", uploaded).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 R5 review, P1: the durable Arweave anchor unit must complete + /// independently of the best-effort Pinata worker. The job used to wait for + /// a report that worker only sent AFTER acquiring a pin permit, re-deriving + /// its object list, and uploading to Pinata — so a disabled, saturated, + /// slow, or failing provider held the job lease hostage. Here EVERY pin + /// permit is held by the test for the whole job, so the spawned worker + /// parks before doing any git or network of its own; the job must still + /// reach `done` with the anchor recorded and exactly one paid upload. + /// (Pre-refactor this test hung on the oneshot await until the harness + /// timeout — the deletion of that await is what turns it green.) + #[cfg(unix)] + #[sqlx::test] + async fn post_receive_job_anchors_without_waiting_for_the_pinata_worker(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", "a3", 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 uploaded = std::sync::Arc::new(std::sync::RwLock::new(None::>)); + let (bundler_url, calls) = f2a_bundler(0, uploaded.clone()).await; + let (gateway_url, _probes) = f2a_gateway("absent", uploaded).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, + ])); + + // Starve the pin pool BEFORE the job runs: the detached Pinata worker's + // very first act is awaiting a permit from this semaphore. + let mut held_pin_permits = Vec::new(); + while let Ok(permit) = state.pin_semaphore.clone().try_acquire_owned() { + held_pin_permits.push(permit); + } + assert!( + !held_pin_permits.is_empty(), + "the test must actually hold pin permits for the isolation to be real" + ); + + 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(); + + // The job completes to its terminal anchor state while every pin permit + // is still held: anchoring never waited on the parked Pinata worker. + process_post_receive_job(state.clone(), job.clone()).await; + + drop(held_pin_permits); + + assert_eq!( + f2a_job_status(&pool, &job.id).await, + "done", + "the job must reach its terminal anchor state without the Pinata worker" + ); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "exactly one paid upload: the anchor's own" + ); + assert!( + state + .db + .arweave_anchor_exists(&f2a_slug(&rec), "refs/heads/main", ZERO_SHA, &c1) + .await + .unwrap(), + "the anchor must be recorded with a locally derived tip CID" + ); + // The tip CID embedded in gossip/branch mapping is content-derived + // locally, matching what a provider round-trip would have reported. + let stored_cid: Option = + sqlx::query_scalar("SELECT cid FROM arweave_anchors WHERE repo = $1 AND new_sha = $2") + .bind(f2a_slug(&rec)) + .bind(&c1) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + stored_cid.as_deref(), + Some( + gitlawb_core::cid::Cid::from_git_object_bytes(&{ + let out = std::process::Command::new("git") + .args(["-C", repo_path.to_str().unwrap(), "cat-file", "commit", &c1]) + .output() + .unwrap(); + out.stdout + }) + .to_string() + .as_str() + ), + "the anchor must embed the locally computed content CID" + ); + } + + /// #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 uploaded = std::sync::Arc::new(std::sync::RwLock::new(None::>)); + let (bundler_url, calls) = f2a_bundler(0, uploaded.clone()).await; + let (gateway_url, _probes) = f2a_gateway("absent", uploaded).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..de3e6b5f 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,181 @@ 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. +/// +/// `expected_id` binds the provider acknowledgement to the exact request +/// identity: a success response whose `id` differs from the id the node +/// derived locally (a misrouted, faulty, or compromised bundler substituting +/// a different well-formed item) is classified `Uncertain`, never `Accepted`. +/// The recorded identity must be the item the node signed and would re-upload +/// and recover by identity (R2 #224 review). +pub async fn upload_ref_anchor_item( + client: &reqwest::Client, + bundler_url: &str, + bundler_account: &str, + bundler_token: &str, + item: &[u8], + expected_id: &str, +) -> 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) + ), + }); + } + }; + // Bind the provider acknowledgement to the exact request identity. A + // well-formed id that is NOT the id the node signed and persisted means + // the bundler returned a different item than the one sent (misrouted, + // faulty, or compromised): accepting it would record an anchor whose + // signature/identity the node cannot reproduce, verify, or recover. + // Classify as Uncertain (nonterminal) so recovery probes the item the node + // actually sent and re-uploads it if absent, rather than recording a + // foreign id as the durable anchor (R2 #224 review). + if tx_id != expected_id { + return Ok(UploadOutcome::Uncertain { + message: format!( + "Bundler reported success for a transaction id that does not match the \ + item sent: expected {expected_id}, got {tx_id} — treating as unknown \ + outcome so the durable identity of the anchor is not replaced" + ), + }); + } + 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)?; + let expected_id = crate::ans104::data_item_id(&item); + match upload_ref_anchor_item( + client, + bundler_url, + bundler_account, + bundler_token, + &item, + &expected_id, + ) + .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 +303,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 +338,1085 @@ 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)?; + // Derive the expected item id from the signed data item BEFORE sending, so + // the bundler's acknowledgement can be bound to the exact item we signed + // (#224 R3 review). A mismatch means the bundler accepted a different item. + let expected_id = crate::ans104::data_item_id(&data_item); + // 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) + )); + } + }; + // Bind the acknowledgement to the signed item: a different valid id means + // the bundler accepted a foreign item, not the one we signed and paid for. + if tx_id != expected_id { + return Err(anyhow::anyhow!( + "Bundler returned a different valid id ({tx_id}) than the signed item ({expected_id})" + )); + } 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(",") +/// 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() } - -/// Arweave permanent URL for a given Irys transaction ID. -pub fn arweave_url(tx_id: &str) -> String { - format!("https://arweave.net/{tx_id}") -} - -/// 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() { + // A 2xx means the gateway found *something*, but it might be an error + // page or a different item. Verify the returned item's id matches what + // we asked for — a misconfigured gateway/proxy returning 200 for any + // id would cause us to record a phantom anchor. Fail closed by treating + // a non-matching 2xx as "cannot determine" rather than "present". + let body = resp + .bytes() + .await + .map_err(|e| anyhow::anyhow!("failed to read gateway probe response: {e}"))?; + // The only bindable representation is the raw ANS-104 data item: its id + // derives from the signature region (bytes 2..66 per ANS-104 spec) and + // must equal the probed id. A bare-JSON 200 — even one carrying our own + // `schema` field — cannot be bound to the probed id at all, so it is no + // proof of presence: a generic gitlawb-shaped JSON 200 served for every + // id would otherwise make crash recovery treat an uncertain upload as + // landed and skip a needed re-upload (#224 R5 review). Fail closed. + let body_id = crate::ans104::data_item_id(&body); + if body_id == item_id { + return Ok(true); + } + // 2xx but id mismatch: the gateway resolved a DIFFERENT item (or an + // error page). We cannot conclude the requested item landed, and the + // caller must fail closed rather than record a phantom anchor — the + // durable recovery path re-probes / fails the job instead. + return Err(anyhow::anyhow!( + "gateway probe returned 2xx but item id mismatch: expected {}, got {}", + item_id, + body_id + )); + } + 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); + } + // ── Bind the response to the REQUESTED transaction id before trusting it ── + // (#224 R5 review). Gateways serve the signed ANS-104 data item at /{id}; + // the id is derived from the signature region, so a 2xx whose bytes do not + // carry that id resolved something else, and a proxy returning one cached + // payload for every id must never yield `valid: true` for the wrong tx. + // + // A bare-JSON 200 (no ANS-104 envelope) cannot be bound to the requested + // id at all — the id hashes the signature region, which a bare payload + // does not carry — so it is refused regardless of its content. The same + // well-formed anchor JSON served for two different ids is exactly the + // confusion this refuses to adjudicate. + let served_id = crate::ans104::data_item_id(&body_bytes); + let anchor_json_bytes: &[u8] = if served_id == tx_id { + match crate::ans104::data_item_data(&body_bytes) { + Ok(data) => data, + Err(e) => { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![format!( + "gateway response carries the requested id but is not a parseable \ + ANS-104 data item: {e}" + )], + }); + } + } + } else if serde_json::from_slice::(&body_bytes).is_ok() { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![ + "gateway returned a bare-JSON payload for this tx id; only the signed ANS-104 \ + data item representation can be bound to the requested id, so this response \ + is refused" + .to_string(), + ], + }); + } else { + return Ok(VerifyResult { + valid: false, + anchor: serde_json::Value::Null, + certificate: None, + errors: vec![format!( + "gateway returned item {served_id} for requested tx {tx_id} — refusing to \ + verify another transaction's payload" + )], + }); + }; + // Parse the unwrapped payload; a non-JSON payload is an invalid result, + // not an error. + let anchor: serde_json::Value = match serde_json::from_slice(anchor_json_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}")], + }); + } + }; + // Schema gate: only our well-known ref-update schema is adjudicated here. + // Any other JSON — even under a matching id — is not an artifact this + // verifier can vouch for. + if anchor.get("schema").and_then(|s| s.as_str()) != Some("gitlawb/ref-update/v1") { + return Ok(VerifyResult { + valid: false, + anchor, + certificate: None, + errors: vec![ + "anchor payload is missing or has an unexpected 'schema' (expected \ + gitlawb/ref-update/v1)" + .to_string(), + ], + }); + } + 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. + // Version 2 certificates use a 14-field payload that includes an + // explicit `version` field. Post-PR but pre-version-2 certificates + // use a 13-field payload (no version). Pre-PR certificates used a + // 7-field payload (repo_id, ref, old, new, pusher, node, ts) with + // NULL proof fields. Try each in order; the version field is the + // authoritative discriminator — nullable-field inference is not. + 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 14-field payload first (version 2 — includes `version` field). + let payload_14 = 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, + "version": crate::cert::CERT_PAYLOAD_VERSION, + "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_14 = serde_json::to_vec(&payload_14)?; + let sig_valid_14 = + gitlawb_core::identity::verify(&verifying_key, &payload_bytes_14, &sig_array); + let mut legacy_7_field_verified = false; + if sig_valid_14.is_ok() { + // Version-2 signature verified — nothing more to try. + } else { + // Fall back to 13-field payload (post-PR, pre-version-2). + 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); + if sig_valid_13.is_ok() { + // 13-field signature verified. + } else if proof_fields_null { + // 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 { + errors.push( + "certificate signature verification failed: no recognized payload version matched" + .to_string(), + ); + } + } + // 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; + /// Serve `payload` the way a production gateway does: as the data section of + /// a real signed ANS-104 data item, at the item's own id. Returns that id — + /// the tx id `verify_anchor` must be called with to hit the mock. + async fn serve_signed_anchor( + server: &mut mockito::Server, + signer: &Keypair, + payload: &serde_json::Value, + ) -> String { + let body = serde_json::to_vec(payload).expect("anchor payload serializes"); + let item = crate::ans104::build_signed_data_item(signer, &[], &body) + .expect("signed data item builds"); + let id = crate::ans104::data_item_id(&item); + server + .mock("GET", format!("/{id}").as_str()) + .with_status(200) + .with_header("content-type", "application/octet-stream") + .with_body(item) + .create_async() + .await; + id + } + /// 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": }` where + /// `item_id` is the ANS-104 id derived from the received item's signature + /// region — exactly what a real bundler echoes back (#224). + 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, + ) -> 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(), + ); + } + // A real bundler echoes the ANS-104 data-item id of the + // exact item it received (base64url(sha256(signature))), + // not an arbitrary constant. Derive it from the request + // body so the response-binding contract in + // upload_ref_anchor_item is exercised end to end (#224). + let item_id = crate::ans104::data_item_id(&body); + (StatusCode::OK, format!(r#"{{"id":"{item_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 +1424,31 @@ 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", + ) + .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 +1456,183 @@ 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" + assert!( + is_valid_tx_id(&result.unwrap()), + "anchor must return the well-formed data-item id the bundler echoed" ); - _mock.assert_async().await; } + /// #224 review, P2 + R2 BIND: the client validates the bundler's success id + /// at the boundary AND binds it to the item actually sent. 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. And a well-formed id that does NOT match the id of the item the + /// node sent (a misrouted/faulty/compromised bundler substituting a + /// different item) must also be Uncertain, never Accepted — recording a + /// foreign id would destroy the durable identity the node can recover. + #[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: String, + ) -> (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(); + let body = body.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(); + // Must be long enough (>= 2 + 64 bytes) for data_item_id to derive a + // real id from the signature region rather than the short-item sentinel. + let mut item = vec![0u8; 70]; + item[0] = 0x02; + item[1] = 0x00; + item[2..66].copy_from_slice(&[0xAA; 64]); + let expected_id = crate::ans104::data_item_id(&item); + assert_eq!(expected_id.len(), 43, "derived id must be a valid tx id"); + + 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.to_string()).await; + let outcome = upload_ref_anchor_item( + &client, + &server, + "zBundlerAccount", + "matic", + &item, + &expected_id, + ) + .await + .unwrap(); + assert!( + matches!(outcome, UploadOutcome::Uncertain { .. }), + "{label} must classify as Uncertain: {outcome:?}" + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + // A well-formed id that MATCHES the item the node sent is Accepted. + let (server, _calls) = mock_bundler(format!(r#"{{"id":"{expected_id}"}}"#)).await; + let outcome = upload_ref_anchor_item( + &client, + &server, + "zBundlerAccount", + "matic", + &item, + &expected_id, + ) + .await + .unwrap(); + assert!( + matches!(&outcome, UploadOutcome::Accepted { tx_id } if tx_id == &expected_id), + "a matching well-formed id must be Accepted: {outcome:?}" + ); + // A well-formed id that does NOT match the item sent is Uncertain, never + // Accepted (R2 #224): a bundler that returns a different well-formed id + // must not be able to replace the durable anchor identity. + let (server, _calls) = + mock_bundler(r#"{"id":"7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe4bEaLnMuO9"}"#.to_string()) + .await; + let outcome = upload_ref_anchor_item( + &client, + &server, + "zBundlerAccount", + "matic", + &item, + &expected_id, + ) + .await + .unwrap(); + assert!( + matches!(outcome, UploadOutcome::Uncertain { .. }), + "a mismatched well-formed id must classify as Uncertain: {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, + ) + .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, + ) + .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 +1640,71 @@ 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; + // The bundler now echoes the item's own ANS-104 id; compare it against + // the id derived from the very item this call would sign. + let item = build_ref_anchor_item(&anchor, &kp).unwrap(); + assert_eq!(result.unwrap(), crate::ans104::data_item_id(&item)); + } + #[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, + ) + .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 +1714,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 +1734,29 @@ 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), + ) + .await; let client = reqwest::Client::new(); let blobs = vec![("oid1".to_string(), "cid1".to_string())]; let m = EncryptedManifest { @@ -390,11 +1766,189 @@ 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!( + is_valid_tx_id(&r.unwrap()), + "manifest anchor must return the well-formed data-item id the bundler echoed" + ); + } + /// 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, + ) + .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; + // The bundler echoes the item's own ANS-104 id; compare against the id + // derived from the item this exact anchor + keypair signs. + let item = build_ref_anchor_item( + &test_anchor( + "alice/myrepo", + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", + ), + &kp, + ) + .unwrap(); + assert_eq!(result.unwrap(), crate::ans104::data_item_id(&item)); + } + /// 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, + ) + .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; + let item = build_ref_anchor_item( + &test_anchor( + "alice/myrepo", + "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", + ), + &kp, + ) + .unwrap(); + assert_eq!(result.unwrap(), crate::ans104::data_item_id(&item)); + } + /// 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 +1959,1300 @@ 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!({ + "schema": "gitlawb/ref-update/v1", + "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", + }); + // Served as a real signed data item at its own id, the way a + // production gateway returns it. + let tx = serve_signed_anchor(&mut server, &Keypair::generate(), &bad_cert_json).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(), &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!({ + "schema": "gitlawb/ref-update/v1", + "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 tx = serve_signed_anchor(&mut server, &node_kp, &anchor_json).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(), &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 + ); + } + /// 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!({ + "schema": "gitlawb/ref-update/v1", + "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 tx = serve_signed_anchor(&mut server, &node_kp, &anchor_json).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(), &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 + ); + } + /// 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!({ + "schema": "gitlawb/ref-update/v1", + "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 tx = serve_signed_anchor(&mut server, &node_kp, &anchor_json).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(), &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 + ); + } + /// 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!({ + "schema": "gitlawb/ref-update/v1", + "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, + }); + // Served as a real signed data item at its own id — the production + // gateway representation, so the id binding is exercised positively. + let mut server = mockito::Server::new_async().await; + let tx = serve_signed_anchor(&mut server, &node_kp, &anchor_json).await; + let client = reqwest::Client::new(); + let result = verify_anchor(&client, &server.url(), &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 + ); + } + /// Version 2 certificate: the `version` field is included in the signed + /// payload. Verification must succeed on the 14-field first pass. + #[sqlx::test] + async fn test_verify_anchor_accepts_authentic_14_field_version2_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; + 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(); + // Build a version-2 (14-field) certificate. + 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, + "version": crate::cert::CERT_PAYLOAD_VERSION, + "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()); + let cert = crate::db::RefCertificate { + id: "cert-accept-v2-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()), + }; + let anchor_json = serde_json::json!({ + "schema": "gitlawb/ref-update/v1", + "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 tx = serve_signed_anchor(&mut server, &node_kp, &anchor_json).await; + let client = reqwest::Client::new(); + let result = verify_anchor(&client, &server.url(), &tx, &db, &node_did).await; + let r = result.expect("verify_anchor should return Ok for a served anchor"); + assert!( + r.valid, + "authentic version-2 (14-field) cert must verify, errors: {:?}", + r.errors + ); + assert!( + r.errors.is_empty(), + "expected no errors, got: {:?}", + r.errors + ); + } + /// The replay attack the id binding closes: a gateway (or proxy) that + /// returns the SAME valid anchor JSON for every requested id used to get + /// `valid: true` for the wrong tx id, because bare JSON carries nothing + /// that ties it to what was asked for. Both ids must now be refused: only + /// the signed ANS-104 representation is adjudicated. + #[sqlx::test] + async fn verify_anchor_refuses_identical_bare_json_served_for_two_ids(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 db = migrated_db(pool).await; + 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, + 1, + &"0".repeat(64), + ); + // A fully authentic anchor payload — everything but the representation + // is exactly what the accept test serves. + let anchor_json = serde_json::json!({ + "schema": "gitlawb/ref-update/v1", + "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 body = serde_json::to_string(&anchor_json).unwrap(); + let mut server = mockito::Server::new_async().await; + // The identical bare-JSON payload, served for BOTH ids. + for path in ["/right-tx", "/wrong-tx"] { + server + .mock("GET", path) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body.as_str()) + .create_async() + .await; + } + let client = reqwest::Client::new(); + for tx in ["right-tx", "wrong-tx"] { + let r = verify_anchor(&client, &server.url(), tx, &db, &node_did) + .await + .expect("verify_anchor should return Ok for a served anchor"); + assert!( + !r.valid, + "bare-JSON served for {tx} must not verify as valid" + ); + assert!( + r.errors.iter().any(|e| e.contains("bare-JSON")), + "the refusal must name the unbindable representation, got: {:?}", + r.errors + ); + } + } + /// A signed item whose bytes ARE a well-formed ANS-104 data item — just not + /// the one whose id was requested — must be refused on the id mismatch, not + /// verified by content alone. + #[sqlx::test] + async fn verify_anchor_rejects_a_valid_item_served_under_a_different_id(pool: sqlx::PgPool) { + let node_kp = gitlawb_core::identity::Keypair::generate(); + let node_did = node_kp.did().as_str().to_string(); + let db = migrated_db(pool).await; + let anchor_json = serde_json::json!({ + "schema": "gitlawb/ref-update/v1", + "repo_id": "repo-uuid", + "ref_name": "refs/heads/main", + "old_sha": "0".repeat(40), + "new_sha": "a".repeat(40), + "node_did": node_did, + }); + let item = crate::ans104::build_signed_data_item( + &node_kp, + &[], + &serde_json::to_vec(&anchor_json).unwrap(), + ) + .unwrap(); + let mut server = mockito::Server::new_async().await; + // The item's real bytes, served under a DIFFERENT transaction's id. + server + .mock("GET", "/requested-tx") + .with_status(200) + .with_header("content-type", "application/octet-stream") + .with_body(item.clone()) + .create_async() + .await; + let client = reqwest::Client::new(); + let r = verify_anchor(&client, &server.url(), "requested-tx", &db, &node_did) + .await + .expect("verify_anchor should return Ok for a served anchor"); + assert!( + !r.valid, + "another item's bytes must not verify for the requested id" + ); + assert!( + r.errors + .iter() + .any(|e| e.contains("refusing to verify another transaction's payload")), + "expected the id-mismatch refusal, got: {:?}", + r.errors + ); + } + /// The presence probe accepts ONLY the raw item served at its own id: the + /// id derives from the signature region, so this is the one 2xx shape it + /// can actually bind to the probed identity. + #[tokio::test] + async fn probe_accepts_the_raw_item_served_at_its_own_id() { + let mut server = mockito::Server::new_async().await; + let kp = Keypair::generate(); + let payload = serde_json::json!({ "schema": "gitlawb/ref-update/v1" }); + let item = + crate::ans104::build_signed_data_item(&kp, &[], &serde_json::to_vec(&payload).unwrap()) + .unwrap(); + let id = crate::ans104::data_item_id(&item); + let mock = server + .mock("GET", format!("/{id}").as_str()) + .with_status(200) + .with_body(item) + .create_async() + .await; + let client = reqwest::Client::new(); + let present = anchor_item_present(&client, &server.url(), &id) + .await + .expect("raw item at its own id is present"); + assert!(present); + mock.assert_async().await; + } + /// A generic gitlawb-shaped JSON 200 must NOT prove presence: crash + /// recovery would treat an uncertain upload as landed and skip a needed + /// re-upload. The probe fails closed instead (#224 R5 review). + #[tokio::test] + async fn probe_fails_closed_on_a_gitlawb_shaped_json_200() { + let mut server = mockito::Server::new_async().await; + let payload = serde_json::json!({ + "schema": "gitlawb/ref-update/v1", + "repo": "zAlice/myrepo", + "repo_id": "repo-uuid", + "owner_did": "did:key:z6MkOwner", + "ref_name": "refs/heads/main", + "old_sha": "0".repeat(40), + "new_sha": "a".repeat(40), + "node_did": "did:key:zNODE", + }); + // Served for EVERY id — the misbehaving gateway from the finding. + server + .mock("GET", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&payload).unwrap()) + .create_async() + .await; + let client = reqwest::Client::new(); + let result = anchor_item_present(&client, &server.url(), "some-item-id").await; + assert!( + result.is_err(), + "an unbindable JSON 200 must fail closed, got {result:?}" + ); + } + #[tokio::test] + async fn probe_treats_404_as_absent() { + let mut server = mockito::Server::new_async().await; + server + .mock("GET", "/missing-item") + .with_status(404) + .create_async() + .await; + let client = reqwest::Client::new(); + let present = anchor_item_present(&client, &server.url(), "missing-item") + .await + .expect("404 is a verdict, not an error"); + assert!(!present); + } + /// 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!({ + "schema": "gitlawb/ref-update/v1", + "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 tx = serve_signed_anchor(&mut server, &node_kp, &anchor_json).await; + let client = reqwest::Client::new(); + let result = verify_anchor(&client, &server.url(), &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 + ); + } + /// 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!({ + "schema": "gitlawb/ref-update/v1", + "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 tx = serve_signed_anchor(&mut server, &node_kp, &anchor_json).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(), &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 + ); + } + /// 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!({ + "schema": "gitlawb/ref-update/v1", + "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 tx = serve_signed_anchor(&mut server, &forged_kp, &anchor_json).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(), &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 + ); + } + /// 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 27b67786..fc0f43d2 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 } @@ -515,6 +564,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)), ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)), @@ -636,4 +686,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..c87d7e3d 100644 --- a/crates/gitlawb-node/src/cert.rs +++ b/crates/gitlawb-node/src/cert.rs @@ -1,58 +1,227 @@ -//! 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. +/// The current signed certificate payload version. The version is included in +/// the JSON bytes the node signs so that verifiers select the correct field set +/// rather than inferring it from nullable columns. +pub const CERT_PAYLOAD_VERSION: u64 = 2; + +/// Build the canonical signing payload for a certificate. /// -/// 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( +/// Version 2 adds an explicit `version` field to the signed bytes. Verifiers +/// use it to select the field set: version 2 =14 fields (this function), +/// absent version =13-field post-PR format, absent version + absent proof +/// fields = 7-field pre-PR format. +#[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, + "version": CERT_PAYLOAD_VERSION, + "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 1fbf4376..614a87e4 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -129,10 +129,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 = "")] @@ -183,6 +229,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. @@ -746,8 +799,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)] @@ -771,6 +894,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 @@ -1408,6 +1566,202 @@ mod tests { /// nothing about what this crate declares — it reports the operator's setting. /// Asserting the declaration is the env-independent form, and it is the one that /// actually fails if someone flips `default_value_t` back. + /// 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 enforce_owner_push_is_declared_true_independent_of_the_environment() { use clap::CommandFactory; diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index cc2cf0bd..44a64f3e 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -151,6 +151,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)] @@ -261,7 +330,7 @@ pub struct ProfileRecord { #[derive(Clone)] pub struct Db { - pool: PgPool, + pub(crate) pool: PgPool, } impl Db { @@ -468,6 +537,21 @@ 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 v31 (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 v32. +// New installs reach v31/v32 via sequential migration; existing installs with +// the columns already present are no-ops via IF NOT EXISTS. v27 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). +// v28 adds the durable post-receive job table, v29 turns the +// `arweave_anchors` row into a per-transition durable claim/outbox +// (state, item_id, claim_token) with a unique transition index, and +// v30 adds a `lease_since` column for the exclusive-lease clock. +// // 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 @@ -1123,6 +1207,166 @@ const MIGRATIONS: &[Migration] = &[ "ALTER TABLE pin_repair_sweep ADD COLUMN IF NOT EXISTS discovery_cursor_id TEXT NOT NULL DEFAULT ''", ], }, + // Durable post-receive jobs, anchor outbox, and lease clock (#224 review). + // Numbered 27–30: versions 12–16 are claimed by other in-flight branches, + // 17 is main's current max, and 18–26 are claimed by #173. + Migration { + version: 27, + 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 v26 would require + // re-creating `idx_ref_certs_repo_ref`, which a release built at v27+ + // cannot do (the migration that created it has been superseded). + // Operators must treat v27 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 (v32) requires. + stmts: &[ + // Remove the superseded (repo_id, ref_name) unique index (v10). v32 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 28: versions 12–16 are + // claimed by other in-flight branches, 17–19 are main's prior migrations, + // and 20–26 are claimed by #173. + Migration { + version: 28, + 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 + // Arweave anchoring (#26). Numbered 29–32: versions 18–26 are claimed by + // #173. v29 (rename + cert columns) must precede v30 (outbox) which + // references the renamed column, and v32 (append-only cert chain) must + // follow v27 (index drop) and v29 (seq column). + Migration { + version: 29, + 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", + ], + }, + // Durable anchor outbox (#224 review): per-transition claim/prepare/upload/record + // state machine. `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: 30, + 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)", + ], + }, + // Lease clock for the anchor outbox (#224 review, R2): a dedicated timestamptz + // column carrying WHEN the current lease started. `set_anchor_uploading`'s + // lease-hand-off CAS refreshes it to `now()` on every successful takeover and + // refuses to reclaim an `uploading` lease younger than + // ANCHOR_UPLOADING_LEASE_SECONDS. `claimed_at` cannot serve this purpose — it + // is the anchor's content timestamp and must stay stable for deterministic + // retries (same timestamp → same ANS-104 id). Rows backfilled as `recorded` + // and rows later set to `recorded` keep their anchored-at value; 'pre-lease' + // rows (claim_token IS NULL) are matched by the CAS's NULL branch regardless. + Migration { + version: 31, + name: "arweave_anchor_lease_since", + stmts: &[ + "ALTER TABLE arweave_anchors ADD COLUMN IF NOT EXISTS lease_since TIMESTAMPTZ", + ], + }, + Migration { + version: 32, + 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 v27 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", + ], + }, ]; /// Max distinct source repos recorded per pinned object (F1, #173 jatmn round 8). @@ -1760,8 +2004,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, @@ -1769,9 +2018,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) @@ -2026,6 +2276,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 { @@ -2327,31 +2719,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) @@ -2362,11 +2739,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, @@ -2376,8 +2810,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) @@ -2415,8 +2849,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) @@ -2428,7 +2862,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) @@ -2436,6 +2870,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 ───────────────────────────────────────────────────────────────────── @@ -3790,31 +4333,113 @@ 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 never prepared/sent). `claim_token` + /// is the lease token of the worker that last won the row (may be NULL for + /// pre-lease rows); the recoverer passes it as the expected value of the + /// atomic CAS in [`Db::set_anchor_uploading`] that hands the lease over, so + /// of N concurrent recoverers exactly one can win and pay. + /// `claimed_at` is the timestamp from the ORIGINAL claim row — kept stable + /// across lease rotations (rotations bump their own copy) so a rebuilt + /// anchor item keeps the same content-derived id (deterministic retry, + /// #224 review). + /// + /// Recovery workers must reconcile (probe the gateway) and then win the + /// lease CAS BEFORE paying for an upload. There is no time-based expiry: + /// the CAS requires the CURRENT holder's token, which every recoverer reads + /// from the row, so a dead holder is replaced as soon as anyone retries; + /// a live-but-slow holder is protected from a double pay by the same CAS + /// because a loser's expected token stops matching the instant the winner + /// rotates it. + Recover { + id: String, + state: String, + item_id: Option, + claim_token: Option, + claimed_at: 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. On the fresh-claim path it is the new + /// worker's own token; on the recover path it supplies the CURRENT holder's + /// token (read from the row) so the per-row CAS can hand the lease over. + pub claim_token: &'a str, + /// RFC 3339 timestamp of this claim. + pub claimed_at: &'a str, } +/// Quiescence window for reclaiming an `uploading` anchor outbox row. A +/// recoverer seeing an `uploading` row cannot tell a dead holder (crashed +/// between the paid request and the terminal write) from a live one (still in +/// flight — the item MAY yet be accepted). Re-uploading against a live holder +/// is a double payment for the same transition, so the lease hand-off CAS in +/// [`Db::set_anchor_uploading`] only reclaims an `uploading` row once the +/// row's `claimed_at` is older than this window; `pending`/`failed` rows +/// (where nothing is in flight) are reclaimable immediately. A worker whose +/// CAS gets blocked by the window fails its job and lets the job-level retry +/// policy (restart drain) come back after the window passes. +pub const ANCHOR_UPLOADING_LEASE_SECONDS: i64 = 300; + 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) @@ -3824,53 +4449,356 @@ 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(()) } - pub async fn list_arweave_anchors( - &self, - repo: Option<&str>, - limit: i64, - ) -> 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", - ) - .bind(repo) - .bind(limit) - .fetch_all(&self.pool) - .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", - ) - .bind(limit) - .fetch_all(&self.pool) - .await? - }; + /// 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, claim_token, claimed_at 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); + } + // Surface the CURRENT lease holder so the recoverer's CAS in + // set_anchor_uploading can be conditioned on the actual token: the CAS + // guarantees exclusive ownership of the upload obligation. + let claim_token: Option = row.get("claim_token"); + let claimed_at: Option = row.get("claimed_at"); + Ok(AnchorClaim::Recover { + id: row.get("id"), + state, + item_id: row.get("item_id"), + claim_token, + claimed_at, + }) + } - Ok(rows - .into_iter() - .map(|r| ArweaveAnchor { - id: r.get("id"), - repo: r.get("repo"), - owner_did: r.get("owner_did"), - ref_name: r.get("ref_name"), - old_sha: r.get("old_sha"), + /// 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). + /// + /// This IS the per-row LEASE HAND-OFF — the concurrency boundary for paid + /// uploads (the atomic INSERT only resolves the fresh-claim race; it grants + /// no ownership of an existing non-terminal row, so recovering workers must + /// win this CAS before paying). The update applies only when the row is + /// non-terminal AND its `claim_token` still equals `expected_claim_token` + /// (or is NULL, for pre-lease rows) AND, for an `uploading` row, the lease + /// is older than [`ANCHOR_UPLOADING_LEASE_SECONDS`] (see below): + /// + /// Hand the lease from the worker holding `expected_claim_token` to the + /// caller, whose `new_claim_token` replaces it in the SAME row write. Of + /// N concurrent recoverers that all read the same current token, exactly + /// ONE wins this write; every loser gets `rows_affected == 0` and its + /// expected token no longer matches the row, so its subsequent terminal + /// writes are no-ops. Passing the holder's OWN token (fresh-claim path) + /// is the same guard. + /// + /// - `Some(t)` ONLY: the hand-off never applies without an expected token — + /// there is no unconditional path. Pre-lease rows (claim_token IS NULL) + /// are captured by passing the empty string as `t`: it can never equal a + /// real UUID token, and the CAS's NULL clause accepts it. + /// + /// Quiescence for `uploading` rows: a recoverer seeing `uploading` cannot + /// tell a dead holder from a live one mid-request (the item may still be + /// accepted). The CAS therefore reclaims an `uploading` row only once its + /// `lease_since` is older than [`ANCHOR_UPLOADING_LEASE_SECONDS`] (a NULL + /// `lease_since` means the lease clock predates the column; those rows are + /// reclaimable immediately). Before the window passes the row is presumed + /// live and the takeover is suppressed. `pending` and `failed` rows are + /// reclaimed immediately — no holder can be in flight there. A worker + /// blocked by the window sees `rows_affected == 0` and must fail + /// closed, never upload. + /// + /// On success the row's `lease_since` is refreshed to `now()` — the lease + /// clock for the NEXT takeover. `claimed_at` is deliberately NOT touched: + /// it is the anchor timestamp the item embeds, and every probe/retry must + /// reconcile the same id the row carries (#224 deterministic retry). + /// + /// Returns the number of rows affected; the caller MUST check `> 0` before + /// the paid upload, and MUST NOT touch the row afterward if it is 0. + pub async fn set_anchor_uploading( + &self, + id: &str, + item_id: &str, + expected_claim_token: &str, + new_claim_token: Option<&str>, + ) -> Result { + let result = sqlx::query( + "UPDATE arweave_anchors + SET state = 'uploading', item_id = $1, + lease_since = now(), + claim_token = COALESCE($4, claim_token) + WHERE id = $2 + AND state != 'recorded' + AND (claim_token = $3 OR claim_token IS NULL) + AND (state != 'uploading' + OR lease_since IS NULL + OR lease_since < now() - make_interval(secs => $5))", + ) + .bind(item_id) + .bind(id) + .bind(expected_claim_token) + .bind(new_claim_token) + .bind(ANCHOR_UPLOADING_LEASE_SECONDS) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + + /// 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. + /// + /// Lease-conditioned on `expected_claim_token` (same rules as + /// [`Db::set_anchor_uploading`], minus the quiescence clause — a row whose + /// holder was rejected is not in flight): a worker whose lease was already + /// handed off, or a row that reached `recorded`, must NOT be able to flip + /// the winner's state. Returns the number of rows affected so the caller + /// can log a superseded write; a 0 here is not an error for the loser. + pub async fn set_anchor_failed(&self, id: &str, expected_claim_token: &str) -> Result { + let result = sqlx::query( + "UPDATE arweave_anchors SET state = 'failed' + WHERE id = $1 AND state != 'recorded' + AND (claim_token = $2 OR claim_token IS NULL)", + ) + .bind(id) + .bind(expected_claim_token) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + + /// 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. + /// + /// This is the post-PAID-upload terminal write and MUST be + /// lease-conditioned on `expected_claim_token` (same rules as + /// [`Db::set_anchor_uploading`]): a superseded worker (whose lease was + /// handed off mid-flight while an upload may or may not have landed) must + /// not overwrite the current holder's terminal state with its own + /// transaction id. Returns rows affected; 0 means the caller was + /// superseded and its write was suppressed. + pub async fn record_claimed_anchor( + &self, + id: &str, + tx_id: &str, + expected_claim_token: &str, + ) -> Result { + let now = Utc::now().to_rfc3339(); + let result = sqlx::query( + "UPDATE arweave_anchors + SET state = 'recorded', arweave_tx_id = $1, item_id = $1, anchored_at = $2 + WHERE id = $3 AND state != 'recorded' + AND (claim_token = $4 OR claim_token IS NULL)", + ) + .bind(tx_id) + .bind(&now) + .bind(id) + .bind(expected_claim_token) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + + /// Record an anchor the gateway PROBE confirmed already landed, without any + /// paid upload. Unlike [`Db::record_claimed_anchor`] this write is NOT + /// lease-conditioned: the transaction id being recorded is the row's own + /// Probed `item_id` — the exact request identity an upload attempt sent — + /// so no caller fabricates the identity; every worker converges on the + /// same value, and the `state != 'recorded'` guard keeps the write + /// idempotent. The recovery path uses this so a worker that did not (and + /// must not, without paying) hijack the lease can still persist the + /// verified landing. Returns rows affected. + pub async fn recover_claimed_anchor(&self, id: &str, tx_id: &str) -> Result { + let now = Utc::now().to_rfc3339(); + let result = sqlx::query( + "UPDATE arweave_anchors + SET state = 'recorded', arweave_tx_id = $1, item_id = $1, anchored_at = $2 + WHERE id = $3 AND state != 'recorded'", + ) + .bind(tx_id) + .bind(&now) + .bind(id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + + /// 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")) + } + + /// List anchors gated by current repo visibility, enforced in SQL. This is + /// the #136 listing gate lifted out of the handler's post-read loop: the + /// old filter ran one `authorize_repo_read` per anchor row (O(N) DB round- + /// trips per request), applied LIMIT before filtering (a page silently + /// shrank to whatever survived), and its raw slug-equality parse bypassed + /// the `did_matches` owner normalization the canonical gate applies. + /// + /// `arweave_anchors` is joined to its repo GROUP — the same (owner-key, + /// name) pair `dedup_cte` groups on — so a canonical `did:key:zX/name` + /// slug and a bare `zX/name` mirror sibling are one repo, and the gate + /// implements `visibility::listable_at_root`'s anonymous decision: a group + /// is visible iff it contains at least one non-quarantined `is_public` + /// row. Path-scoped rules never widen read access for anonymous callers, + /// so the `is_public` gate is complete for this unauthenticated surface. + /// + /// Fail-closed edges: + /// - Anchors whose repo resolves to no repo group (repo deleted, never + /// mirrored, or unparseable slug) match nothing and are filtered. The + /// old "include unparseable slugs for safety" fallback leaked past the + /// gate and is deliberately gone. + /// - A quarantined canonical row hides the whole group even when a mirror + /// sibling is public (the 404-as-404 paradigm), and quarantined rows + /// never contribute `is_public` to a group. + /// - `LIMIT` applies AFTER the join, so filtered anchors cannot silently + /// shorten a page. + /// + /// `repo`, when given as `owner/name`, filters on the normalized owner + /// key and the name — the same normalization the group key uses, so a + /// full `did:key:` filter matches bare-owner groups and vice versa. + pub async fn list_arweave_anchors( + &self, + repo: Option<&str>, + limit: i64, + ) -> Result> { + // A supplied but malformed `repo` filter must not silently become an + // unfiltered listing: only an omitted filter selects all visible anchors. + // A malformed value (missing owner or name half) cannot match any + // repo-group key, so reject it explicitly rather than falling through + // to the NULL-safe predicates that treat NULL as "no filter" (#224 R3). + let (owner_key, name) = match repo { + None => (None, None), + Some(r) => match r.split_once('/') { + Some((owner, name)) if !owner.is_empty() && !name.is_empty() => { + (Some(normalize_owner_key(owner)), Some(name)) + } + _ => { + return Err(anyhow::anyhow!( + "malformed repo filter: expected owner/name, got {r:?}" + )); + } + }, + }; + let sql = format!( + "WITH repo_groups AS ( + SELECT {rkey} AS okey, name AS repo_name, + bool_or(is_public AND NOT quarantined) AS any_public, + bool_or(quarantined AND position('/' in id) = 0) + AS any_quarantined_canonical + FROM repos + GROUP BY {rkey}, name + ) + SELECT a.id, a.repo, a.owner_did, a.ref_name, a.old_sha, a.new_sha, + a.cid, a.arweave_tx_id, a.node_did, a.anchored_at, a.cert_id + FROM arweave_anchors a + JOIN repo_groups g + ON ({akey}) = g.okey + AND substr(a.repo, strpos(a.repo, '/') + 1) = g.repo_name + WHERE a.state = 'recorded' + AND g.any_public + AND NOT g.any_quarantined_canonical + AND ($1::text IS NULL OR g.okey = $1) + AND ($2::text IS NULL OR g.repo_name = $2) + ORDER BY a.anchored_at DESC + LIMIT $3", + rkey = OWNER_KEY_CASE_SQL, + akey = OWNER_KEY_CASE_SQL.replace("owner_did", "a.owner_did") + ); + let rows = sqlx::query(&sql) + .bind(owner_key) + .bind(name) + .bind(limit) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| ArweaveAnchor { + id: r.get("id"), + repo: r.get("repo"), + owner_did: r.get("owner_did"), + ref_name: r.get("ref_name"), + 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()) } @@ -3945,6 +4873,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), } } @@ -4873,7 +5807,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 ──────────────────────────────────────────────────── @@ -6549,6 +7483,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); @@ -6574,6 +7515,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, } } @@ -6625,20 +7572,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, }) @@ -6647,7 +7594,7 @@ mod ref_certificate_tests { // First insert db.insert_ref_certificate(&make_cert( - "cert-original", + "cert-first", &repo_id, "refs/heads/main", "0000", @@ -6657,9 +7604,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", @@ -6669,49 +7616,11 @@ mod ref_certificate_tests { .await .unwrap(); - // Only one row exists for this ref - 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(); + // 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, "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] @@ -6927,11 +7836,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 { @@ -7257,12 +8172,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 @@ -7422,33 +8342,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( @@ -7472,6 +8385,515 @@ mod ref_certificate_tests { "raw duplicate INSERT must be rejected by the unique index" ); } + + /// INV-7: upgrade-path test for migration v32 — seed a database at v28 + /// with multiple same-repo/different-ref certificates (all at seq=1), + /// then let run_migrations() apply v32 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 v28: drop the (repo_id, seq) index and the + // schema_migrations record for v32 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 = 32") + .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 — v32 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; + + // The listing gate joins anchors to their repo group: only anchors for a + // repo with a current non-quarantined public row are visible. + db.create_repo(&crate::db::RepoRecord { + id: "repo-mine".into(), + name: "myrepo".into(), + owner_did: "did:key:zAlice".into(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + disk_path: "/tmp/myrepo".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + + let input = RecordAnchorInputV2 { + repo: "did:key:zAlice/myrepo", + owner_did: "did:key:zAlice", + 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(); + + // Filter: full did:key owner form matches the bare-owner group key. + let anchors = db + .list_arweave_anchors(Some("did:key:zAlice/myrepo"), 10) + .await + .unwrap(); + assert_eq!(anchors.len(), 1, "one anchor recorded"); + assert_eq!(anchors[0].arweave_tx_id, "test-tx-id-123"); + + // Unfiltered list also sees it. + let all = db.list_arweave_anchors(None, 10).await.unwrap(); + assert_eq!(all.len(), 1); + + // A different group key must not match. + let other = db + .list_arweave_anchors(Some("zBob/myrepo"), 10) + .await + .unwrap(); + assert!(other.is_empty(), "other owner must not see the anchor"); + } + + /// Private repos never surface in the anchor listing — neither the + /// canonical private row nor a quarantined row may make the repo public. + #[sqlx::test] + async fn list_anchors_hides_private_and_quarantined_repos(pool: PgPool) { + let db = db(pool.clone()).await; + + // Private canonical repo with an anchor: logged but never listed. + db.create_repo(&crate::db::RepoRecord { + id: "repo-private".into(), + name: "secret".into(), + owner_did: "did:key:zAlice".into(), + description: None, + is_public: false, + default_branch: "main".into(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + disk_path: "/tmp/secret".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + db.record_arweave_anchor(&RecordAnchorInputV2 { + repo: "did:key:zAlice/secret", + owner_did: "did:key:zAlice", + ref_name: "refs/heads/main", + old_sha: &"0".repeat(40), + new_sha: &"1".repeat(40), + cid: None, + arweave_tx_id: "tx-private", + node_did: "did:key:zNODE", + cert_id: None, + }) + .await + .unwrap(); + + assert!( + db.list_arweave_anchors(None, 50).await.unwrap().is_empty(), + "private repo anchors must not be listed" + ); + + // Repo becomes public: the existing anchor surfaces. + sqlx::query("UPDATE repos SET is_public = TRUE WHERE id = 'repo-private'") + .execute(&pool) + .await + .unwrap(); + let anchors = db.list_arweave_anchors(None, 50).await.unwrap(); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].arweave_tx_id, "tx-private"); + + // Back to private: hidden again. + sqlx::query("UPDATE repos SET is_public = FALSE WHERE id = 'repo-private'") + .execute(&pool) + .await + .unwrap(); + assert!(db.list_arweave_anchors(None, 50).await.unwrap().is_empty()); + + // Quarantined canonical row in the group hides the anchor even though + // is_public was flipped back on above (404-as-404). + sqlx::query( + "UPDATE repos SET is_public = TRUE, quarantined = TRUE WHERE id = 'repo-private'", + ) + .execute(&pool) + .await + .unwrap(); + assert!( + db.list_arweave_anchors(None, 50).await.unwrap().is_empty(), + "quarantined canonical repo must not list anchors" + ); + } + + /// An anchor whose repo rows are gone (repo deleted, or the slug never + /// matched a repo group, e.g. an unparseable slug) fails closed: the join + /// finds no group and the row is filtered — matching the handler's old + /// "skip unknown repo" contract without the O(N) per-repo read. + #[sqlx::test] + async fn list_anchors_without_repo_group_fails_closed(pool: PgPool) { + let db = db(pool).await; + + // No repo rows at all for this slug. + db.record_arweave_anchor(&RecordAnchorInputV2 { + repo: "ghost/repo", + owner_did: "did:key:zGhost", + ref_name: "refs/heads/main", + old_sha: &"0".repeat(40), + new_sha: &"1".repeat(40), + cid: None, + arweave_tx_id: "tx-orphan", + node_did: "did:key:zNODE", + cert_id: None, + }) + .await + .unwrap(); + // Unparseable slug (no '/'): the substr join can never match a group. + db.record_arweave_anchor(&RecordAnchorInputV2 { + repo: "unparseable-slug", + owner_did: "did:key:zGhost2", + ref_name: "refs/heads/main", + old_sha: &"0".repeat(40), + new_sha: &"1".repeat(40), + cid: None, + arweave_tx_id: "tx-unparseable", + node_did: "did:key:zNODE", + cert_id: None, + }) + .await + .unwrap(); + + assert!( + db.list_arweave_anchors(None, 50).await.unwrap().is_empty(), + "anchors with no current repo group must not be listed" + ); + } + + /// Group semantics across canonical + bare mirror siblings (#97-style): + /// the anchor is visible only when at least one non-quarantined public row + /// exists in the group, and the bare slug is reachable through the + /// `did:key:` filter key just like the full one. + #[sqlx::test] + async fn list_anchors_canonical_and_mirror_slug_share_one_group(pool: PgPool) { + use chrono::Utc; + let db = db(pool.clone()).await; + + // Canonical private row + bare public mirror row, same (key, name). + db.create_repo(&crate::db::RepoRecord { + id: "repo-canonical".into(), + name: "dup".into(), + owner_did: "did:key:zAlice".into(), + description: None, + is_public: false, + default_branch: "main".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + disk_path: "/tmp/dup".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + db.upsert_mirror_repo("zAlice", "dup", "/tmp/dup-mirror", None, false) + .await + .unwrap(); + + // Anchor recorded under the canonical (full did) slug. + db.record_arweave_anchor(&RecordAnchorInputV2 { + repo: "did:key:zAlice/dup", + owner_did: "did:key:zAlice", + ref_name: "refs/heads/main", + old_sha: &"0".repeat(40), + new_sha: &"1".repeat(40), + cid: None, + arweave_tx_id: "tx-dup", + node_did: "did:key:zNODE", + cert_id: None, + }) + .await + .unwrap(); + + // Mirror row is public -> group is visible && the anchor is listed + // even though the canonical row it was recorded under is private. + let anchors = db.list_arweave_anchors(None, 50).await.unwrap(); + assert_eq!(anchors.len(), 1, "group visibility sees the anchored repo"); + assert_eq!(anchors[0].repo, "did:key:zAlice/dup"); + + // Filter forms: full, bare, and bare-owner-of-the-same-key all match. + for filter in ["did:key:zAlice/dup", "zAlice/dup"] { + assert_eq!( + db.list_arweave_anchors(Some(filter), 50) + .await + .unwrap() + .len(), + 1, + "filter {filter} must resolve the group" + ); + } + + // Hide the mirror too: group loses visibility. + sqlx::query("UPDATE repos SET is_public = FALSE WHERE id = 'zAlice/dup'") + .execute(&pool) + .await + .unwrap(); + assert!(db.list_arweave_anchors(None, 50).await.unwrap().is_empty()); + } + + /// #224 R5 review, P2: the per-transition outbox guard must be pinned at the + /// ANCHOR-CLAIM layer. `two_concurrent_workers_claim_the_job_once` (api + /// tests) races two job executors, but the job-level claim serializes them + /// before either reaches `claim_anchor_claim`, so gutting the + /// `ON CONFLICT (repo, ref_name, old_sha, new_sha) DO NOTHING` (backed by + /// the unique transition index) left that suite green while both workers + /// could win the upload obligation. Here two tasks hit + /// `claim_anchor_claim` CONCURRENTLY with no job gate in front: exactly one + /// may receive `Claimed`; the loser must observe the winner's row as + /// `Recover` carrying the winner's lease token, and exactly one row may + /// exist for the transition. + #[sqlx::test] + async fn concurrent_anchor_claims_converge_on_one_row_and_one_owner(pool: PgPool) { + let db = super::Db::for_testing(pool); + db.run_migrations().await.unwrap(); + + let new_sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let old_sha = "0".repeat(40); + + let race = |db: super::Db, old_sha: String| async move { + let claim_token = uuid::Uuid::new_v4().to_string(); + db.claim_anchor_claim(&super::ClaimAnchorInput { + repo: "zAlice/myrepo", + owner_did: "did:key:zAlice", + ref_name: "refs/heads/main", + old_sha: &old_sha, + new_sha, + cid: None, + node_did: "did:key:zNODE", + cert_id: Some("cert-1"), + claim_token: &claim_token, + claimed_at: "2026-07-22T00:00:00+00:00", + }) + .await + .expect("claim must not error") + }; + let (r1, r2) = tokio::join!( + race(db.clone(), old_sha.clone()), + race(db.clone(), old_sha.clone()) + ); + + // Exactly one worker wins the INSERT; the other observes a Recover of + // the winner's pending row — never a second Claimed. + let claimed_ids: Vec<&str> = [&r1, &r2] + .into_iter() + .filter_map(|r| match r { + super::AnchorClaim::Claimed { id } => Some(id.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + claimed_ids.len(), + 1, + "exactly one worker may win the anchor claim; got {r1:?} and {r2:?}" + ); + let winner_token = match (&r1, &r2) { + ( + _, + super::AnchorClaim::Recover { + claim_token: Some(t), + .. + }, + ) + | ( + super::AnchorClaim::Recover { + claim_token: Some(t), + .. + }, + _, + ) => t.clone(), + other => panic!( + "the losing claim must surface a Recover carrying the winner's lease token; \ + got {other:?}" + ), + }; + let (winner_id, state, item_id) = match (&r1, &r2) { + (super::AnchorClaim::Claimed { id }, _) | (_, super::AnchorClaim::Claimed { id }) => { + (id.clone(), "pending", None::) + } + other => panic!("one side must be Claimed; got {other:?}"), + }; + + // The row exists exactly once for the transition. + let rows: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM arweave_anchors + WHERE repo = 'zAlice/myrepo' AND ref_name = 'refs/heads/main' + AND old_sha = $1 AND new_sha = $2", + ) + .bind(&old_sha) + .bind(new_sha) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(rows, 1, "the unique transition index must yield one row"); + + // The surviving row is pending, un-uploaded, leased by the winner. + let (stored_state, stored_item, stored_token): (String, Option, Option) = + sqlx::query_as("SELECT state, item_id, claim_token FROM arweave_anchors WHERE id = $1") + .bind(&winner_id) + .fetch_one(&db.pool) + .await + .unwrap(); + assert_eq!(stored_state, state); + assert_eq!(stored_item, item_id, "no upload may have been attempted"); + assert_eq!( + stored_token.as_deref(), + Some(winner_token.as_str()), + "the row's lease token must be the winning worker's" + ); + } } #[cfg(test)] mod ref_update_db_tests { @@ -8552,3 +9974,166 @@ mod cid_candidate_order_tests { ); } } + +/// 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:")); + + // A repo row for the anchor's group: the listing gate joins anchors to + // their repo group, so the slug must resolve to a public, non-quarantined + // repo or the anchor would not be listable (fail closed). + db.create_repo(&crate::db::RepoRecord { + id: "repo-anchor-alice".into(), + name: "myrepo".into(), + owner_did: "did:key:zAlice".into(), + description: None, + is_public: true, + default_branch: "main".into(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + disk_path: "/tmp/myrepo".into(), + forked_from: None, + machine_id: None, + }) + .await + .unwrap(); + db.record_arweave_anchor(&RecordAnchorInputV2 { + repo: "did:key:zAlice/myrepo", + owner_did: "did:key:zAlice", + 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("did:key:zAlice/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 45820746..1ee2a75e 100644 --- a/crates/gitlawb-node/src/git/repo_store.rs +++ b/crates/gitlawb-node/src/git/repo_store.rs @@ -407,7 +407,7 @@ 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)> { + pub(crate) fn local_path(&self, owner_did: &str, repo_name: &str) -> Result<(String, PathBuf)> { let owner_slug = owner_did.replace([':', '/'], "_"); let local_path = validated_repo_disk_path(&self.repos_dir, owner_did, repo_name)?; Ok((owner_slug, local_path)) diff --git a/crates/gitlawb-node/src/main.rs b/crates/gitlawb-node/src/main.rs index 66bfa096..4e20b74b 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; @@ -97,6 +98,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); @@ -108,6 +152,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" @@ -331,6 +386,20 @@ async fn main() -> Result<()> { ); let repo_store = git::repo_store::RepoStore::new(config.repos_dir.clone(), tigris, lock_pool); + // 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 = @@ -421,6 +490,7 @@ async fn main() -> Result<()> { machine_id, repo_store, rate_limiter, + arweave_rate_limiter, create_ip_rate_limiter, push_rate_limiter, ipfs_max_history_walks: crate::api::ipfs::MAX_HISTORY_WALKS_PER_REQUEST, @@ -528,6 +598,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". { @@ -1191,6 +1286,7 @@ mod rate_limiter_sweep_tests { state.peer_write_rate_limiter = RateLimiter::new(10, window); state.ipfs_rate_limiter = RateLimiter::new(10, window); state.ipfs_work_rate_limiter = RateLimiter::new(10, window); + state.arweave_rate_limiter = RateLimiter::new(10, window); let limiters = |s: &crate::state::AppState| { [ @@ -1201,6 +1297,7 @@ mod rate_limiter_sweep_tests { s.peer_write_rate_limiter.clone(), s.ipfs_rate_limiter.clone(), s.ipfs_work_rate_limiter.clone(), + s.arweave_rate_limiter.clone(), ] }; for l in limiters(&state) { 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 24607e5a..16b2cada 100644 --- a/crates/gitlawb-node/src/state.rs +++ b/crates/gitlawb-node/src/state.rs @@ -65,6 +65,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 @@ -346,6 +350,7 @@ impl AppState { self.ipfs_work_rate_limiter.cleanup().await; self.sync_trigger_rate_limiter.cleanup().await; self.peer_write_rate_limiter.cleanup().await; + self.arweave_rate_limiter.cleanup().await; } /// Trigger graceful shutdown. Idempotent — calling more than once diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 430c0600..25938a12 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -101,6 +101,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)), ipfs_rate_limiter: RateLimiter::new(600, Duration::from_secs(3600)), @@ -158,6 +159,12 @@ pub(crate) fn signed_request_as(did: &str, method: Method, uri: &str, body: Body .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") } @@ -1751,6 +1758,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"); @@ -13943,6 +13956,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(); @@ -13978,6 +13997,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(); @@ -14487,6 +14512,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(); @@ -15327,6 +15358,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, @@ -15343,6 +15381,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 48381e3d..f25b6762 100644 --- a/crates/gitlawb-node/tests/inv22_gates.rs +++ b/crates/gitlawb-node/tests/inv22_gates.rs @@ -494,31 +494,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"); @@ -540,9 +549,18 @@ 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(".release(push_succeeded)") .expect("U5 gate stale: release must consume the same success flag as the tail gate"); @@ -554,20 +572,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..e14eb26e 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,12 @@ 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`. +/// +/// Version 2 certificates include an explicit `version` field (14 fields). +/// Post-PR but pre-version-2 certificates used a 13-field payload (no version). +/// Pre-PR certificates used a 7-field payload (repo_id, ref, old, new, pusher, +/// node, ts) with NULL proof columns. Try each in order; the version field is +/// the authoritative discriminator — nullable-field inference is not. #[allow(clippy::too_many_arguments)] fn verify_signature( repo_id: &str, @@ -251,22 +277,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 +306,81 @@ 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 14-field payload first (version 2 — includes `version` field). + let payload_14 = serde_json::json!({ + "repo_id": repo_id, + "ref": ref_name, + "old": old_sha, + "new": new_sha, + "pusher": pusher, + "node": node_did, + "ts": issued_at, + "version": 2_u64, + "seq": seq, + "prev": prev, + "pusher_sig": pusher_sig, + "signature_input": signature_input, + "content_digest": content_digest, + "request_path": request_path, + }); + let payload_bytes_14 = + serde_json::to_vec(&payload_14).map_err(|e| format!("could not serialize payload: {e}"))?; + + let sig_valid_14 = + gitlawb_core::identity::verify(&verifying_key, &payload_bytes_14, &sig_bytes); + + if sig_valid_14.is_ok() { + return Ok(()); + } + + // Fall back to 13-field payload (post-PR, pre-version-2). + 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 sig_valid_13.is_ok() { + return Ok(()); + } + + if proof_fields_null { + // 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 { + Err( + "Ed25519 signature does not match any recognized payload version (14/13/7-field)" + .to_string(), + ) + } } async fn resolve_cert_id(client: &NodeClient, owner: &str, name: &str, id: &str) -> Result { @@ -334,11 +432,20 @@ mod tests { "pusher": "did:key:z6MkPusher", "node": "did:key:z6MkNode", "ts": "2026-07-22T00:00:00+00:00", + "version": 2, + "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","version":2}"#, ); assert_eq!(serde_json::to_string(&payload).unwrap(), frozen); } @@ -349,6 +456,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 +466,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 +483,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 +501,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 +519,220 @@ 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" + ); + } + + /// Version 2 (14-field) certificate: the `version` field is part of the + /// signed bytes. Verification must succeed on the first try. + #[test] + fn verify_signature_version2_14_field() { + 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", + "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", + "version": 2, + "seq": 1, + "prev": prev, + "pusher_sig": "sig-123", + "signature_input": "sig-input-123", + "content_digest": "sha256-123", + "request_path": "/repo.git/git-receive-pack", + }); + 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("sig-123"), + Some("sig-input-123"), + Some("sha256-123"), + Some("/repo.git/git-receive-pack"), + &sig, + ); + assert!( + ok.is_ok(), + "version-2 (14-field) certificate must verify, got: {ok:?}" + ); + } + + /// A 13-field certificate (no `version` field) signed before the version + /// bump must still verify via the13-field fallback path. + #[test] + fn verify_signature_legacy_13_field_fallback() { + let kp = gitlawb_core::identity::Keypair::generate(); + let node_did = kp.did().as_str().to_string(); + let prev = "0000000000000000000000000000000000000000000000000000000000000000"; + + // 13-field payload: no `version` key. + 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": "sig-123", + "signature_input": "sig-input-123", + "content_digest": "sha256-123", + "request_path": "/repo.git/git-receive-pack", + }); + 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("sig-123"), + Some("sig-input-123"), + Some("sha256-123"), + Some("/repo.git/git-receive-pack"), + &sig, + ); + assert!( + ok.is_ok(), + "legacy 13-field certificate must verify via fallback, got: {ok:?}" + ); + } }