diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 0000000..7f81f1f --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,33 @@ +# cargo-mutants configuration (https://mutants.rs) +# +# Run locally with `nix run .#ci-mutants` (full workspace, slow) or let CI +# scope it to the PR diff with `--in-diff`. + +# Feature-gated tests (idna, test_utils, doh, …) must run, or their mutants +# read as missed. +additional_cargo_args = ["--all-features"] + +# Excluded from mutation, not from testing: +# - onomancy_wasm: its test suites are cfg(target_arch = "wasm32") and only +# execute under ci-browser; on the host every mutant would survive, which +# is pure noise, not signal. +# - onomancy_hickory: socket courier; its only test is a #[ignore]d live +# smoke, so host runs cannot catch its mutants either. +exclude_globs = [ + # CLI glue with no test suite (2026-09): every mutant survives, so a + # blocking diff gate would fail any PR touching it. Remove this line the + # day onomancer grows tests. + "onomancer/**", + "onomancy_hickory/**", + "onomancy_wasm/**", +] + +# Cross-crate tests are load-bearing here (onomancy_protocol conformance +# pins dnssec behavior; onomancy_chain and the validator share constants), +# so a mutant must survive the whole workspace's suite, not just its own +# crate's. +test_workspace = true + +# Property tests (bolero) make some suites slow-ish; give mutants headroom +# before they are declared timeouts. +timeout_multiplier = 2.5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4275846..de5ba16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,3 +50,34 @@ jobs: - name: ${{ matrix.check }} run: nix run .#${{ matrix.check }} + + # Mutation testing, scoped to the PR's changed code (full-workspace runs + # are hours; `nix run .#ci-mutants` does the deliberate full sweep + # locally). Blocking: a missed mutant in code you touched fails the job. + mutants: + name: ci-mutants (PR diff) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Full history so the merge-base with the target branch exists. + fetch-depth: 0 + + - uses: DeterminateSystems/nix-installer-action@v16 + + - name: Diff against the merge base + run: git diff "origin/${{ github.base_ref }}...HEAD" > pr.diff + + - name: ci-mutants + run: nix run .#ci-mutants -- --in-diff pr.diff + + # mutants.out holds the missed/caught/timeout lists and per-mutant + # logs — the thing to read when the job goes red. + - name: Upload mutants report + uses: actions/upload-artifact@v4 + if: always() + with: + name: mutants-report + path: mutants.out + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index f06f700..8f98848 100755 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ **/*.onc # …but checked-in test fixtures are deliberate !**/tests/fixtures/*.onc +!**/e2e/fixtures/*.onc # Generated by Cargo /target diff --git a/Cargo.lock b/Cargo.lock index 103678d..9876df6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1475,6 +1475,7 @@ name = "onomancy_automerge" version = "0.2.0" dependencies = [ "automerge", + "bolero", "ed25519-dalek", "onomancy_core", "onomancy_dnssec", @@ -1488,6 +1489,7 @@ dependencies = [ name = "onomancy_chain" version = "0.2.0" dependencies = [ + "bolero", "hickory-proto", "onomancy_core", "onomancy_dnssec", @@ -1508,6 +1510,7 @@ dependencies = [ "ed25519-dalek", "onomancy_core", "serde", + "serde_json", "testresult", "thiserror 2.0.20", "tracing", @@ -1551,6 +1554,7 @@ name = "onomancy_keyhive" version = "0.2.0" dependencies = [ "bincode", + "bolero", "ed25519-dalek", "future_form", "futures", @@ -1597,6 +1601,7 @@ name = "onomancy_wasm" version = "0.2.0" dependencies = [ "automerge", + "bolero", "console_error_panic_hook", "ed25519-dalek", "getrandom 0.2.17", diff --git a/HACKING.md b/HACKING.md index 3a83f5a..3036525 100644 --- a/HACKING.md +++ b/HACKING.md @@ -88,14 +88,15 @@ Related invariants: ## Testing Strategy -| Level | Tool | Notes | -|-------|------|-------| -| Property tests | `bolero` | Roundtrips, grammar disjointness, termination; live in `mod tests { mod props }` — `cargo test props::` selects them | -| Golden vectors | checked-in bytes | `onomancy_core/tests/vectors/` — byte drift is a wire-format break | -| Conformance | scenario replay | `verifier_state_conformance.rs` rows map to spec disposition tables; scenarios replay identically under memory fakes and the real validator | -| Never-rots fixtures | frozen production chains | `onomancy_dnssec/tests/fixtures/real_*` validate clock-free, forever | -| Browser | wasm-bindgen-test | `nix run .#ci-browser` (Chromium + Firefox); live DoH tests behind the `live` feature | -| Live smoke | `--ignored` tests | Query real resolvers; run deliberately | +| Level | Tool | Notes | +|---------------------|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------| +| Property tests | `bolero` | Roundtrips, grammar disjointness, termination; live in `mod tests { mod props }` — `cargo test props::` selects them | +| Golden vectors | checked-in bytes | `onomancy_core/tests/vectors/` — byte drift is a wire-format break | +| Conformance | scenario replay | `verifier_state_conformance.rs` rows map to spec disposition tables; scenarios replay identically under memory fakes and the real validator | +| Never-rots fixtures | frozen production chains | `onomancy_dnssec/tests/fixtures/real_*` validate clock-free, forever | +| Browser | wasm-bindgen-test | `nix run .#ci-browser` (Chromium + Firefox); live DoH tests behind the `live` feature | +| Live smoke | `--ignored` tests | Query real resolvers; run deliberately | +| Mutation | `cargo-mutants` | CI mutates only the PR diff (blocking); `nix run .#ci-mutants` for the full-workspace sweep. Config: `.cargo/mutants.toml` | Tests prefer `TestResult` returns and `matches!` on error variants; `expect` is confined to test modules under scoped allows. @@ -108,6 +109,7 @@ nix develop # dev shell: pinned rust 1.91 + nightly rustfmt wrapper nix run .#ci # the full gate: fmt, clippy, test, wasm, no-std, deny nix run .#ci-fmt # any single check: ci-{fmt,clippy,test,wasm,no-std,deny} nix run .#ci-browser # real-browser wasm tests (pulls whole browsers) +nix run .#ci-mutants # full-workspace mutation testing (slow; CI scopes it to the PR diff) ``` GitHub Actions is a thin matrix over the same apps, so local runs are CI runs. diff --git a/flake.nix b/flake.nix index 14dfb93..5e4a55e 100644 --- a/flake.nix +++ b/flake.nix @@ -191,6 +191,21 @@ ''; }; + # Mutation testing (cargo-mutants; config in .cargo/mutants.toml). + # Not in the `ci` aggregate: a full-workspace run is hours, not + # minutes. Hosted CI scopes it to the PR's changed code with + # `--in-diff`; run it bare for the deliberate full sweep. + ci-mutants = pkgs.writeShellApplication { + name = "onomancy-ci-mutants"; + runtimeInputs = [ rust-toolchain pkgs.cargo-mutants ]; + text = '' + set -x + # No args: full workspace (slow, deliberate). + # CI: onomancy-ci-mutants --in-diff pr.diff + cargo mutants "$@" + ''; + }; + # Playwright tests against the built npm package: real # Chromium + Firefox driving the wasm-bodge build. ci-e2e = pkgs.writeShellApplication { @@ -250,9 +265,11 @@ # green board without this notice reads as full coverage # while the wasm suites went unexecuted. echo - echo "NOT RUN (hosted CI runs both): ci-browser, ci-e2e" + echo "NOT RUN (hosted CI runs all three): ci-browser, ci-e2e, ci-mutants" echo " nix run .#ci-browser # executes the wasm test suites" echo " nix run .#ci-e2e # Playwright against the npm build" + echo " nix run .#ci-mutants # full-workspace mutation testing (slow;" + echo " # CI runs it scoped to the PR diff)" ''; }; @@ -377,6 +394,7 @@ ci = ci-all; ci-browser = ci-browser; ci-e2e = ci-e2e; + ci-mutants = ci-mutants; inherit demo; }); diff --git a/onomancy_automerge/Cargo.toml b/onomancy_automerge/Cargo.toml index bb37304..8ccfa5f 100644 --- a/onomancy_automerge/Cargo.toml +++ b/onomancy_automerge/Cargo.toml @@ -24,6 +24,7 @@ thiserror = { workspace = true } tracing = { workspace = true } [dev-dependencies] +bolero = { workspace = true, features = ["arbitrary"] } testresult = { workspace = true } [lints] diff --git a/onomancy_automerge/src/certificates.rs b/onomancy_automerge/src/certificates.rs index 372e2da..dd31555 100644 --- a/onomancy_automerge/src/certificates.rs +++ b/onomancy_automerge/src/certificates.rs @@ -346,7 +346,84 @@ mod tests { put(&mut doc, &one)?; put(&mut doc, &two)?; - assert_eq!(inline(&doc)?.len(), 2); + assert_eq!( + inline(&doc)?, + vec![one.encode(), two.encode()], + "both certificates, byte-exact, in insertion order" + ); + Ok(()) + } + + /// The write path's refusal mirror of `a_second_hop_is_refused`: + /// a document that delegates its list takes no writes — the + /// certificate belongs in the referenced document. This is the + /// variant a real ceremony hits first when a user's certificates + /// live elsewhere. + #[test] + fn putting_onto_a_reference_is_refused() { + let holder = anchor(2); + let mut doc = document_with(|tx, reserved| { + tx.put(reserved, CERTIFICATES_KEY, format!("automerge:{holder}")) + .expect("reference"); + }); + + assert!(matches!( + put(&mut doc, &certificate(1, "example.com")), + Err(WriteError::StoredElsewhere(target)) if *target == holder + )); + } + + /// `inline` reads ONE document: a reference is somebody else's + /// list, so it reads as empty here — never followed, never an + /// error. + #[test] + fn inline_never_follows_a_reference() -> TestResult { + let doc = document_with(|tx, reserved| { + tx.put( + reserved, + CERTIFICATES_KEY, + format!("automerge:{}", anchor(2)), + ) + .expect("reference"); + }); + + assert_eq!(inline(&doc)?, Vec::>::new()); + Ok(()) + } + + /// The replacement search skips entries that do not decode: + /// undecodable bytes are never "the same certificate", so a put + /// appends beside them and a refresh replaces only its own entry. + #[test] + fn undecodable_siblings_are_left_alone_by_replacement() -> TestResult { + let garbage = vec![0xFFu8; 16]; + let mut doc = document_with(|tx, reserved| { + let list = tx + .put_object(reserved, CERTIFICATES_KEY, ObjType::List) + .expect("list"); + tx.insert(&list, 0, ScalarValue::Bytes(vec![0xFFu8; 16])) + .expect("insert"); + }); + + let cert = certificate(1, "example.com"); + put(&mut doc, &cert)?; + assert_eq!( + inline(&doc)?, + vec![garbage.clone(), cert.encode()], + "the put appends beside the undecodable entry" + ); + + let refreshed = cert.with_attachments( + DelegationChain::default(), + Vec::new(), + DnssecChain::read_framed(&[1, 3, 0xAA, 0xBB, 0xCC])?, + )?; + put(&mut doc, &refreshed)?; + assert_eq!( + inline(&doc)?, + vec![garbage, refreshed.encode()], + "the refresh replaces its own entry, never the garbage" + ); Ok(()) } diff --git a/onomancy_automerge/src/change_hash.rs b/onomancy_automerge/src/change_hash.rs index 69f5f8d..404c544 100644 --- a/onomancy_automerge/src/change_hash.rs +++ b/onomancy_automerge/src/change_hash.rs @@ -25,12 +25,18 @@ pub fn from_change_hash(hash: &ChangeHash) -> Head { mod tests { use super::*; + /// Both directions, over every 32-byte value: the conversions + /// are byte-identity — no reordering, no truncation — which is + /// the whole seam contract. #[test] fn conversions_roundtrip() { - let head = Head::from([7u8; 32]); - assert_eq!(from_change_hash(&to_change_hash(&head)), head); + bolero::check!().with_type::<[u8; 32]>().for_each(|bytes| { + let head = Head::from(*bytes); + assert_eq!(from_change_hash(&to_change_hash(&head)), head); - let hash = ChangeHash([9u8; 32]); - assert_eq!(to_change_hash(&from_change_hash(&hash)), hash); + let hash = ChangeHash(*bytes); + assert_eq!(to_change_hash(&from_change_hash(&hash)), hash); + assert_eq!(to_change_hash(&head).0, *bytes, "byte-identity, verbatim"); + }); } } diff --git a/onomancy_automerge/src/decisions.rs b/onomancy_automerge/src/decisions.rs index 47a38a6..6c196a6 100644 --- a/onomancy_automerge/src/decisions.rs +++ b/onomancy_automerge/src/decisions.rs @@ -373,6 +373,94 @@ mod tests { assert_eq!(DecisionsView::new(&doc).decisions(), Decisions::default()); } + /// Every spelling of `v` beyond the canonical `Int(0)`: + /// `Uint(0)` is accepted (a JS writer plausibly produces it); + /// negative, non-scalar, and missing `v` all read nothing. Each + /// document plants a well-formed claim so acceptance is provable + /// and rejection is non-vacuous. + #[test] + fn version_shapes_gate_the_whole_read() -> TestResult { + let claim = |doc: &mut Automerge| push_claim(doc, "bob.example", &anchor_bytes(1), None); + + // Uint(0): accepted. + let mut doc = skeleton(0)?; + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + let (_, root) = tx.get(automerge::ROOT, DECISIONS_KEY)?.expect("reserved"); + tx.put(&root, "v", ScalarValue::Uint(0))?; + Ok(()) + }) + .map_err(|failure| failure.error)?; + claim(&mut doc)?; + assert_eq!( + DecisionsView::new(&doc).decisions().claims.len(), + 1, + "Uint(0) is version 0" + ); + + // Negative Int: refused (u64::try_from fails). + let mut doc = skeleton(-1)?; + claim(&mut doc)?; + assert_eq!(DecisionsView::new(&doc).decisions(), Decisions::default()); + + // Non-scalar v: refused. + let mut doc = skeleton(0)?; + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + let (_, root) = tx.get(automerge::ROOT, DECISIONS_KEY)?.expect("reserved"); + tx.put_object(&root, "v", ObjType::Map)?; + Ok(()) + }) + .map_err(|failure| failure.error)?; + claim(&mut doc)?; + assert_eq!(DecisionsView::new(&doc).decisions(), Decisions::default()); + + // Missing v: refused. + let mut doc = skeleton(0)?; + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + let (_, root) = tx.get(automerge::ROOT, DECISIONS_KEY)?.expect("reserved"); + tx.delete(&root, "v")?; + Ok(()) + }) + .map_err(|failure| failure.error)?; + claim(&mut doc)?; + assert_eq!(DecisionsView::new(&doc).decisions(), Decisions::default()); + Ok(()) + } + + /// A single malformed element voids the WHOLE reset entry — the + /// derivation never guesses which hashes the user meant. Same for + /// a reset filed under a non-canonical hostname. + #[test] + fn a_malformed_reset_element_voids_the_whole_entry() -> TestResult { + let mut doc = skeleton(0)?; + let resets = schema_object(&doc, "resets"); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + // One good hash + one 3-byte element: the entry is void. + let list = tx.put_object(&resets, "mallory.example", ObjType::List)?; + tx.insert(&list, 0, vec![9u8; 32])?; + tx.insert(&list, 1, vec![1u8, 2, 3])?; + // A well-formed list under a NON-canonical hostname key: + // the reader never normalizes. + let list = tx.put_object(&resets, "Eve.Example", ObjType::List)?; + tx.insert(&list, 0, vec![8u8; 32])?; + // And one good entry, so the read provably ran. + let list = tx.put_object(&resets, "good.example", ObjType::List)?; + tx.insert(&list, 0, vec![7u8; 32])?; + Ok(()) + }) + .map_err(|failure| failure.error)?; + + let decisions = DecisionsView::new(&doc).decisions(); + assert_eq!(decisions.resets.len(), 1, "only the well-formed entry"); + assert!( + decisions + .resets + .get(&hostname("good.example")) + .expect("good entry") + .contains(&Digest::from_bytes([7; 32])) + ); + Ok(()) + } + #[test] fn malformed_entries_contribute_nothing() -> TestResult { let mut doc = skeleton(0)?; @@ -390,11 +478,32 @@ mod tests { push_claim(&mut doc, "short.example", &[1, 2, 3], None)?; // Empty receipts: acceptances cite non-emptily by contract. put_acceptance(&mut doc, "bob.example", &anchor_bytes(1), &[])?; - // One good claim so the read provably ran. + // An acceptance VALUE that is not a map. + let acceptances = schema_object(&doc, "acceptances"); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + tx.put(&acceptances, "carol.example", "not a map")?; + Ok(()) + }) + .map_err(|failure| failure.error)?; + // One good claim so the read provably ran — with a NON-STRING + // note, which reads as no note rather than voiding the claim. push_claim(&mut doc, "bob.example", &anchor_bytes(1), None)?; + let claims = schema_object(&doc, "claims"); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + let (_, entry) = tx + .get(&claims, tx.length(&claims) - 1)? + .expect("just pushed"); + tx.put(&entry, "note", 7)?; + Ok(()) + }) + .map_err(|failure| failure.error)?; let decisions = DecisionsView::new(&doc).decisions(); assert_eq!(decisions.claims.len(), 1, "only the well-formed claim"); + assert_eq!( + decisions.claims[0].note, None, + "a non-string note reads as no note" + ); assert!(decisions.acceptances.is_empty(), "empty receipts are inert"); Ok(()) } @@ -423,4 +532,58 @@ mod tests { assert!(documents.contains(&anchor(2))); Ok(()) } + + mod props { + use super::*; + + /// Parse-don't-validate soundness of the reader, fuzzed: for + /// ANY shapes written into the schema, the read never panics, + /// and everything it returns already passed its gates — + /// canonical hostnames, curve-point documents, non-empty + /// receipts and resets. + #[test] + fn reads_are_total_and_everything_returned_passed_the_gates() { + bolero::check!() + .with_type::, u8)>>() + .for_each(|claim_shapes| { + let mut doc = skeleton(0).expect("skeleton"); + + for (host, document, note_shape) in claim_shapes { + let note = match note_shape % 3 { + 0 => None, + 1 => Some("note"), + _ => Some(host.as_str()), // arbitrary text + }; + push_claim(&mut doc, host, document, note).expect("write"); + // Reuse the raw host string as an acceptance + // key and a reset key too. + put_acceptance(&mut doc, host, document, &[[7; 32]]).expect("write"); + let resets = schema_object(&doc, "resets"); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + let list = tx.put_object(&resets, host.as_str(), ObjType::List)?; + tx.insert(&list, 0, document.clone())?; + Ok(()) + }) + .expect("write"); + } + + // Total: never panics, whatever was written. + let decisions = DecisionsView::new(&doc).decisions(); + + for claim in &decisions.claims { + assert_eq!( + DnsName::from_canonical(claim.hostname.as_str().as_bytes()).ok(), + Some(claim.hostname.clone()), + "returned hostnames are canonical" + ); + } + for entries in decisions.acceptances.values() { + assert!(entries.iter().all(|entry| !entry.cited.is_empty())); + } + for excluded in decisions.resets.values() { + assert!(!excluded.is_empty()); + } + }); + } + } } diff --git a/onomancy_automerge/src/namestore.rs b/onomancy_automerge/src/namestore.rs index f731d0a..b9e33b2 100644 --- a/onomancy_automerge/src/namestore.rs +++ b/onomancy_automerge/src/namestore.rs @@ -345,6 +345,11 @@ mod tests { "prefixes of a longer key are not entries; longest-match \ selection is the walk's job, not the store's" ); + assert_eq!( + DocumentNamestore::new(Automerge::new()).reference(&segments(&["anything"])), + None, + "an empty document has no names" + ); Ok(()) } @@ -382,12 +387,6 @@ mod tests { assert!(crate::certificates::CERTIFICATES_KEY.starts_with(RESERVED_PREFIX)); assert!(crate::decisions::DECISIONS_KEY.starts_with(RESERVED_PREFIX)); } - #[test] - fn an_empty_document_has_no_names() { - let store = DocumentNamestore::new(Automerge::new()); - assert_eq!(store.reference(&segments(&["anything"])), None); - } - /// The SHOULD half of E6/E7/E8: skipped entries are reported, /// not merely skipped. /// @@ -431,11 +430,23 @@ mod tests { #[test] fn held_documents_answer_only_what_they_hold() -> TestResult { let target = anchor(1); - let held = HeldDocuments::default().with(target, namestore_doc(&[])?); + let vouched = anchor(2); + let held = HeldDocuments::default() + .with(target, namestore_doc(&[])?) + .with_vouched(vouched, namestore_doc(&[])?, Authority::CarriageVerified); + + // The grade rides the replica: `with` is the dev bridge, + // `with_vouched` is the caller's claim — the walk folds the + // weakest grade crossed, so a wrong grade here misgrades + // every resolution through the document. + let (_, authority) = held.replica(&target).expect("held").into_parts(); + assert_eq!(authority, Authority::TrustedSubstrate); + + let (_, authority) = held.replica(&vouched).expect("held").into_parts(); + assert_eq!(authority, Authority::CarriageVerified); - assert!(held.replica(&target).is_some()); assert!( - held.replica(&anchor(2)).is_none(), + held.replica(&anchor(3)).is_none(), "not replicated here — never fetched" ); Ok(()) @@ -625,4 +636,104 @@ mod tests { ); Ok(()) } + + mod props { + use super::*; + + /// The parity contract as a property: a stored key is an edge + /// iff it is a well-formed path key AND its value parses as a + /// bare reference — and then `reference` returns exactly the + /// same target. Every key lands in exactly one bucket. + #[test] + fn every_key_lands_in_exactly_one_bucket_and_edges_match_reference() { + bolero::check!() + .with_type::>() + .for_each(|raw_entries| { + // Last write wins per key — dedupe so the + // expectation is unambiguous. + let mut entries: std::collections::BTreeMap<&str, String> = + std::collections::BTreeMap::new(); + for (key, seed, is_reference) in raw_entries { + let value = if *is_reference { + format!("automerge:{}", anchor(*seed)) + } else { + // Junk: the key itself — arbitrary text, + // never a bare reference. + format!("junk:{key}") + }; + entries.insert(key.as_str(), value); + } + + let pairs: Vec<(&str, &str)> = entries + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect(); + let store = + DocumentNamestore::new(namestore_doc(&pairs).expect("buildable doc")); + let read = store.read(); + + // Partition: single-actor writes conflict nowhere. + assert!(read.conflicts.is_empty()); + assert_eq!( + read.edges.len() + read.malformed_keys.len() + read.non_references.len(), + entries.len(), + "every key lands in exactly one bucket" + ); + + for (key, value) in &entries { + let well_formed = is_path_key(key); + let reference = value + .strip_prefix(doc::SCHEME_PREFIX) + .and_then(|body| DocAnchor::parse(body).ok()); + + match (well_formed, reference) { + (true, Some(target)) => { + let segments: Vec = key + .split('/') + .map(|raw| Segment::parse(raw).expect("path key segment")) + .collect(); + assert_eq!( + store.reference(&segments), + Some(target), + "an enumerated edge resolves to the same target" + ); + assert!(read.edges.iter().any(|(k, t)| k == key && *t == target)); + } + (true, None) => { + assert!(read.non_references.iter().any(|k| k == key)); + } + (false, _) => { + assert!(read.malformed_keys.iter().any(|k| k == key)); + } + } + } + }); + } + + /// `is_path_key` is the inverse of `path_key`: every key the + /// writer can produce, the reader admits. + #[test] + fn written_path_keys_are_always_readable() { + bolero::check!() + .with_type::>() + .for_each(|raw_segments| { + let path: Vec = raw_segments + .iter() + .filter_map(|raw| Segment::parse(raw).ok()) + .collect(); + + match path_key(&path) { + None => assert!(path.is_empty(), "only the empty path has no key"), + Some(key) => { + assert!(is_path_key(&key), "a written key is a readable key"); + // And the spelling is faithful: split + // recovers the segments. + let recovered: Vec<&str> = key.split('/').collect(); + let original: Vec<&str> = path.iter().map(Segment::as_str).collect(); + assert_eq!(recovered, original); + } + } + }); + } + } } diff --git a/onomancy_automerge/src/petname.rs b/onomancy_automerge/src/petname.rs index f06d525..acb5028 100644 --- a/onomancy_automerge/src/petname.rs +++ b/onomancy_automerge/src/petname.rs @@ -82,6 +82,14 @@ impl<'a> PetnameStore<'a> { /// detection, because the alleged-name claim lives in the /// decision document, not on the edge (P1). /// + /// A rename to the same label is a no-op: pin-then-unpin on one + /// label would delete the edge the pin just rewrote. + /// + /// Not atomic: the pin at `to` and the unpin at `from` are two + /// transactions. If the unpin fails after the pin succeeded, both + /// labels hold the edge — a harmless duplicate (both point at the + /// same target; the pins join dedups), never a lost pin. + /// /// # Errors /// /// Returns [`WriteError::MissingEdge`] when `from` holds no @@ -89,6 +97,11 @@ impl<'a> PetnameStore<'a> { /// substrate rejects the transaction. pub fn rename(&mut self, from: &[Segment], to: &[Segment]) -> Result<(), WriteError> { let target = self.existing_edge(from)?; + + if from == to { + return Ok(()); + } + self.pin(to, &target)?; self.unpin(from) } @@ -158,7 +171,7 @@ pub enum WriteError { } #[cfg(test)] -#[allow(clippy::expect_used)] +#[allow(clippy::expect_used, clippy::panic)] mod tests { use super::*; use ed25519_dalek::SigningKey; @@ -206,6 +219,33 @@ mod tests { Ok(()) } + /// A rename to the SAME label keeps the edge: naive + /// pin-then-unpin would delete what the pin just rewrote — and + /// with it the divergence join, if this was the target's only + /// edge (P1-adjacent). + #[test] + fn a_rename_to_itself_keeps_the_edge() -> TestResult { + let bob = anchor(1); + let label = segments(&["bob"]); + + let mut doc = Automerge::new(); + PetnameStore::new(&mut doc).pin(&label, &bob)?; + PetnameStore::new(&mut doc).rename(&label, &label)?; + + assert_eq!( + DocumentNamestore::new(doc.clone()).reference(&label), + Some(bob), + "rename-to-self is a no-op, never a delete" + ); + + // And on a missing edge it is still MissingEdge, not Ok. + assert!(matches!( + PetnameStore::new(&mut doc).rename(&segments(&["ghost"]), &segments(&["ghost"])), + Err(WriteError::MissingEdge) + )); + Ok(()) + } + #[test] fn writes_against_missing_edges_error() { let mut doc = Automerge::new(); @@ -253,6 +293,43 @@ mod tests { Ok(()) } + /// The function's whole purpose: two pinned targets for ONE + /// hostname surface together, sorted — the divergence the + /// derivation badges. And a target reachable through two claims + /// appears once (the dedup branch). + #[test] + fn divergent_pins_for_one_hostname_all_surface_sorted() -> TestResult { + let old = anchor(1); + let new = anchor(2); + + let mut doc = Automerge::new(); + PetnameStore::new(&mut doc).pin(&segments(&["bob"]), &old)?; + PetnameStore::new(&mut doc).pin(&segments(&["bob-again"]), &new)?; + + let claim = |document| Claim { + hostname: hostname("bob.example"), + document, + note: None, + }; + let decisions = Decisions { + // Two claims for the same hostname naming DIFFERENT + // documents, plus a duplicate claim for one of them. + claims: vec![claim(old), claim(new), claim(old)], + ..Decisions::default() + }; + + let pins = pins(&DocumentNamestore::new(doc), &decisions); + let mut expected = vec![old, new]; + expected.sort_unstable(); + + assert_eq!( + pins.get(&hostname("bob.example")), + Some(&expected), + "both targets surface, sorted, deduped" + ); + Ok(()) + } + #[test] fn renames_never_sever_the_divergence_join() -> TestResult { // The spec's P1 condition, end to end: the claim is frozen @@ -299,4 +376,144 @@ mod tests { assert!(read.edges.is_empty()); assert!(read.malformed_keys.is_empty()); } + + /// The empty-path refusal covers every write verb, not just + /// `pin`: there is no key for "no segments" anywhere. + #[test] + fn unpinning_and_renaming_empty_paths_are_refused() -> TestResult { + let bob = anchor(1); + let label = segments(&["bob"]); + let mut doc = Automerge::new(); + PetnameStore::new(&mut doc).pin(&label, &bob)?; + + let mut store = PetnameStore::new(&mut doc); + // `unpin(&[])` refuses at the key (no key for "no segments"); + // `rename(&[], …)` refuses at the edge lookup — the empty + // path holds nothing, by construction. + assert!(matches!(store.unpin(&[]), Err(WriteError::EmptyPath))); + assert!(matches!( + store.rename(&[], &segments(&["x"])), + Err(WriteError::MissingEdge) + )); + // Renaming TO the empty path is refused at the pin, before + // the unpin can run — the source edge survives. + assert!(matches!( + store.rename(&label, &[]), + Err(WriteError::EmptyPath) + )); + assert_eq!( + DocumentNamestore::new(doc.clone()).reference(&label), + Some(bob), + "a refused rename never loses the source edge" + ); + Ok(()) + } + + mod props { + use super::*; + + /// A valid, unique-ish label from a seed byte. + fn label(prefix: &str, seed: u8) -> Vec { + segments(&[&format!("{prefix}{seed}")]) + } + + /// P1 as a property: renames move labels, never the + /// divergence join — for ANY set of pins, claims, and + /// rename sequence. + #[test] + fn renames_never_change_the_pins_join() { + bolero::check!() + .with_type::<(Vec<(u8, u8)>, Vec<(u8, u8)>, Vec<(u8, u8)>)>() + .for_each(|(edges, claims, renames)| { + let mut doc = Automerge::new(); + for (label_seed, target_seed) in edges { + PetnameStore::new(&mut doc) + .pin(&label("p", *label_seed), &anchor(*target_seed)) + .expect("pin"); + } + + let decisions = Decisions { + claims: claims + .iter() + .map(|(host_seed, doc_seed)| Claim { + hostname: hostname(&format!("h{host_seed}.example")), + document: anchor(*doc_seed), + note: None, + }) + .collect(), + ..Decisions::default() + }; + + let before = pins(&DocumentNamestore::new(doc.clone()), &decisions); + + for (step, (from_seed, to_seed)) in renames.iter().enumerate() { + // A fresh, never-colliding destination label: + // renaming ONTO an existing edge overwrites + // it, which legitimately changes the join. + let to = segments(&[&format!("r{to_seed}-{step}")]); + match PetnameStore::new(&mut doc).rename(&label("p", *from_seed), &to) { + Ok(()) | Err(WriteError::MissingEdge) => {} + Err(other) => panic!("unexpected rename failure: {other}"), + } + } + + let after = pins(&DocumentNamestore::new(doc.clone()), &decisions); + assert_eq!(before, after, "renames never sever or grow the join"); + }); + } + + /// The join is order-insensitive under merge, and every value + /// list comes back sorted and deduped. + #[test] + fn pins_merge_commutes() { + bolero::check!() + .with_type::<(Vec<(u8, u8)>, Vec<(u8, u8)>, Vec)>() + .for_each(|(left_edges, right_edges, claim_seeds)| { + let mut left = Automerge::new(); + let mut right = left.fork(); + + for (label_seed, target_seed) in left_edges { + PetnameStore::new(&mut left) + .pin(&label("l", *label_seed), &anchor(*target_seed)) + .expect("pin"); + } + for (label_seed, target_seed) in right_edges { + PetnameStore::new(&mut right) + .pin(&label("r", *label_seed), &anchor(*target_seed)) + .expect("pin"); + } + + let decisions = Decisions { + claims: claim_seeds + .iter() + .map(|seed| Claim { + hostname: hostname("shared.example"), + document: anchor(*seed), + note: None, + }) + .collect(), + ..Decisions::default() + }; + + let mut left_first = left.clone(); + left_first.merge(&mut right.clone()).expect("merge"); + let mut right_first = right.clone(); + right_first.merge(&mut left.clone()).expect("merge"); + + let joined_left_first = pins(&DocumentNamestore::new(left_first), &decisions); + let joined_right_first = pins(&DocumentNamestore::new(right_first), &decisions); + + assert_eq!( + joined_left_first, joined_right_first, + "the join is merge-order-insensitive" + ); + for targets in joined_left_first.values() { + assert!(targets.is_sorted(), "values are sorted"); + let mut deduped = targets.clone(); + deduped.dedup(); + assert_eq!(&deduped, targets, "and deduped"); + } + }); + } + } } diff --git a/onomancy_automerge/tests/walk.rs b/onomancy_automerge/tests/walk.rs index cb0c47b..aed96f3 100644 --- a/onomancy_automerge/tests/walk.rs +++ b/onomancy_automerge/tests/walk.rs @@ -14,7 +14,7 @@ use ed25519_dalek::SigningKey; use onomancy_automerge::namestore::{DocumentNamestore, HeldDocuments}; use onomancy_core::{anchor::doc::DocAnchor, name::segment::Segment}; use onomancy_protocol::resolve::{ - namestore::{Authority, Vouched}, + namestore::{Authority, Namestore as _, Vouched}, resolution::{PartialReason, Resolution}, resolve, }; @@ -45,12 +45,15 @@ fn namestore_doc(entries: &[(&str, &DocAnchor)]) -> TestResult { fn the_walk_hops_across_held_documents() -> TestResult { let bob = anchor(1); let pics = anchor(2); + let marker = anchor(3); // root ── "bob" ──▶ bob ── "pics/best" ──▶ pics let root = DocumentNamestore::new(namestore_doc(&[("bob", &bob)])?); let held = HeldDocuments::default() .with(bob, namestore_doc(&[("pics/best", &pics)])?) - .with(pics, namestore_doc(&[])?); + // A marker edge only `pics` holds, so the landing document + // is checkable, not merely reached. + .with(pics, namestore_doc(&[("i-am-pics", &marker)])?); // Two hops: a single-segment edge, then a greedy multi-segment key. match resolve( @@ -58,8 +61,13 @@ fn the_walk_hops_across_held_documents() -> TestResult { &segments(&["bob", "pics", "best"])?, &held, ) { - Resolution::Resolved { authority, .. } => { + Resolution::Resolved { target, authority } => { assert_eq!(authority, Authority::TrustedSubstrate); + assert_eq!( + target.reference(&segments(&["i-am-pics"])?), + Some(marker), + "the walk landed in `pics`, not some other document" + ); Ok(()) } other @ Resolution::Partial { .. } => { @@ -81,20 +89,77 @@ fn greedy_matching_wins_across_documents_too() -> TestResult { let held = HeldDocuments::default() .with(shallow, namestore_doc(&[])?) .with(deep, namestore_doc(&[("baz", &marker)])?) - .with(marker, namestore_doc(&[])?); + .with(marker, namestore_doc(&[("i-am-marker", &shallow)])?); match resolve( Vouched::new(root, Authority::TrustedSubstrate), &segments(&["foo", "bar", "baz"])?, &held, ) { - Resolution::Resolved { .. } => Ok(()), + Resolution::Resolved { target, .. } => { + assert_eq!( + target.reference(&segments(&["i-am-marker"])?), + Some(shallow), + "the walk landed at `marker`, via the greedy edge" + ); + Ok(()) + } other @ Resolution::Partial { .. } => { panic!("expected the greedy edge to reach `deep`, got {other:?}") } } } +mod props { + use super::*; + + /// Longest-key matching through REAL documents, for arbitrary + /// labels: whenever one bound key is a proper segment-prefix of + /// another, the walk consumes the longer one — asserted by + /// making the short key's target a document where the residue + /// cannot resolve. + #[test] + fn greedy_matching_holds_for_arbitrary_labels() { + bolero::check!() + .with_type::<(u8, u8, u8)>() + .for_each(|(first, second, third)| { + let label = |prefix: &str, seed: u8| format!("{prefix}{seed}"); + let (x, y, z) = (label("x", *first), label("y", *second), label("z", *third)); + + let shallow = anchor(1); + let deep = anchor(2); + let marker = anchor(3); + + let build = || -> TestResult> { + let long_key = format!("{x}/{y}"); + let root = DocumentNamestore::new(namestore_doc(&[ + (x.as_str(), &shallow), + (long_key.as_str(), &deep), + ])?); + let held = HeldDocuments::default() + .with(shallow, namestore_doc(&[])?) + .with(deep, namestore_doc(&[(z.as_str(), &marker)])?) + .with(marker, namestore_doc(&[])?); + + Ok(resolve( + Vouched::new(root, Authority::TrustedSubstrate), + &segments(&[&x, &y, &z])?, + &held, + )) + }; + + assert!( + matches!( + build().expect("buildable fixture"), + Resolution::Resolved { .. } + ), + "only the greedy (longest) key reaches a store where \ + the residue resolves" + ); + }); + } +} + #[test] fn unsynced_targets_surface_as_partial() -> TestResult { let missing = anchor(9); diff --git a/onomancy_chain/Cargo.toml b/onomancy_chain/Cargo.toml index e3c61b1..9f41560 100644 --- a/onomancy_chain/Cargo.toml +++ b/onomancy_chain/Cargo.toml @@ -24,6 +24,7 @@ onomancy_dnssec = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +bolero = { workspace = true, features = ["arbitrary"] } onomancy_dnssec = { workspace = true, features = ["std"] } testresult = { workspace = true } diff --git a/onomancy_chain/src/builder.rs b/onomancy_chain/src/builder.rs index 6b1a50b..8d8e4a6 100644 --- a/onomancy_chain/src/builder.rs +++ b/onomancy_chain/src/builder.rs @@ -468,7 +468,7 @@ pub enum BuildError { } #[cfg(test)] -#[allow(clippy::expect_used, clippy::indexing_slicing)] +#[allow(clippy::expect_used, clippy::indexing_slicing, clippy::panic)] mod tests { use std::collections::HashMap; @@ -848,4 +848,143 @@ mod tests { fn hop_bound_matches_the_validator() { assert_eq!(MAX_CNAME_HOPS, VALIDATOR_MAX_CNAME_HOPS); } + + /// A leaf answer with `hops` legitimate in-zone CNAME hops ending + /// in a TXT. + fn hopping_transport(hops: usize) -> HashMap<(Name, RecordType), Vec> { + let names: Vec = std::iter::once(owner()) + .chain((0..hops).map(|index| { + Name::from_ascii(format!("hop{index}.example.com.")).expect("valid name") + })) + .collect(); + + let mut answers = Vec::new(); + for pair in names.windows(2) { + answers.push(cname_record(&pair[0], &pair[1])); + answers.push(rrsig_record(&pair[0], RecordType::CNAME)); + } + let last = names.last().expect("at least the owner"); + answers.push(txt_record(last)); + answers.push(rrsig_record(last, RecordType::TXT)); + + transport(vec![((owner(), RecordType::TXT), answers)]) + } + + /// The hop bound is exact: `MAX_CNAME_HOPS` legitimate hops frame, + /// one more does not. The loop test covers the pathological case; + /// this covers the off-by-one a `remaining` refactor would land. + #[test] + fn the_hop_bound_admits_exactly_max_hops() -> TestResult { + for hops in 0..=MAX_CNAME_HOPS { + let chain = drive(&hopping_transport(hops), &hostname()?)?; + assert_eq!( + chain.links().len(), + 1 + hops + 1, + "root keys, {hops} CNAME hops, one TXT leaf" + ); + } + + assert!(matches!( + drive(&hopping_transport(MAX_CNAME_HOPS + 1), &hostname()?), + Err(BuildError::TooManyCnames) + )); + Ok(()) + } + + /// The error-path twin of `missing_signatures_are_unframeable`, + /// through the full machine: a mid-descent DNSKEY answer without + /// its covering RRSIG is unframeable at that owner. + #[test] + fn an_unsigned_cut_answer_is_unframeable_mid_descent() -> TestResult { + let zone = Name::from_ascii("example.com.").expect("valid name"); + let map = transport(vec![ + ( + (zone.clone(), RecordType::DS), + vec![ds_record(&zone), rrsig_record(&zone, RecordType::DS)], + ), + // The DNSKEY answer arrives WITHOUT its RRSIG. + ( + (zone.clone(), RecordType::DNSKEY), + vec![dnskey_record(&zone)], + ), + ]); + + assert!(matches!( + drive(&map, &hostname()?), + Err(BuildError::MissingRrset { owner, rtype: RecordType::DNSKEY }) + if owner == zone.to_ascii() + )); + Ok(()) + } + + /// `UnrepresentableName` is live, not dead code: `DnsName` caps + /// hostnames at 253 octets, and `_onomancy.` adds ten more — a + /// near-cap hostname overflows the wire name and `start` refuses. + #[test] + fn near_cap_hostnames_are_unrepresentable_under_the_service_label() -> TestResult { + // Four 61-char labels + dots = 247 chars: a valid DnsName + // whose `_onomancy.` owner exceeds the 255-octet wire cap. + let long = ["a", "b", "c", "d"].map(|c| c.repeat(61)).join("."); + let hostname = DnsName::parse(&long)?; + + assert!(matches!( + ChainBuilder::start(&hostname), + Err(BuildError::UnrepresentableName(_)) + )); + Ok(()) + } + + /// The unit cap is exact: a chain totalling `MAX_UNIT_BYTES` + /// frames; one more byte does not. Pins `>` against an `>=` + /// regression. + #[test] + fn the_unit_cap_boundary_is_exact() { + let mut links = Links::default(); + assert!( + links + .push(ChainLink::from(vec![0u8; MAX_UNIT_BYTES])) + .is_ok(), + "exactly the cap frames" + ); + + let mut links = Links::default(); + links + .push(ChainLink::from(vec![0u8; MAX_UNIT_BYTES - 1])) + .expect("under the cap"); + assert!(matches!( + links.push(ChainLink::from(vec![0u8; 2])), + Err(BuildError::OversizeChain { bytes }) if bytes == MAX_UNIT_BYTES + 1 + )); + } + + mod props { + use super::*; + + /// The cap as a property: pushing errs exactly when the + /// running byte total exceeds `MAX_UNIT_BYTES`, never before. + #[test] + fn pushing_errs_exactly_past_the_cap() { + bolero::check!().with_type::>().for_each(|sizes| { + let mut links = Links::default(); + let mut total = 0usize; + + for size in sizes { + // Scale so random vectors actually reach the + // 1 MiB cap within a few links. + let bytes = usize::from(*size) * 64; + total += bytes; + + match links.push(ChainLink::from(vec![0u8; bytes])) { + Ok(()) => assert!(total <= MAX_UNIT_BYTES), + Err(BuildError::OversizeChain { bytes }) => { + assert!(total > MAX_UNIT_BYTES); + assert_eq!(bytes, total); + return; // the machine is spent + } + Err(other) => panic!("unexpected error: {other}"), + } + } + }); + } + } } diff --git a/onomancy_core/Cargo.toml b/onomancy_core/Cargo.toml index 576348f..2989674 100644 --- a/onomancy_core/Cargo.toml +++ b/onomancy_core/Cargo.toml @@ -30,6 +30,7 @@ tracing = { workspace = true } bolero = { workspace = true, features = ["arbitrary"] } ed25519-dalek = { workspace = true } onomancy_core = { path = ".", features = ["arbitrary"] } +serde_json = "1" testresult = { workspace = true } [features] diff --git a/onomancy_core/src/anchor/doc.rs b/onomancy_core/src/anchor/doc.rs index 720686f..a70c476 100644 --- a/onomancy_core/src/anchor/doc.rs +++ b/onomancy_core/src/anchor/doc.rs @@ -291,17 +291,88 @@ mod tests { ); } + #[test] + fn off_length_payloads_are_rejected_distinctly_from_legacy_ids() { + // Neither a legacy 16-byte ID nor a 32-byte key. + let odd = bs58::encode(&[9u8; 20]).with_check().into_string(); + assert_eq!( + DocAnchor::parse(&odd), + Err(ParseDocAnchorError::WrongLength) + ); + } + + /// 32 bytes that pass the checksum but decompress to no curve + /// point are their own failure, not a length or checksum error. + #[test] + fn non_curve_points_are_rejected_as_such() { + let non_point = non_curve_point_bytes(); + let encoded = bs58::encode(&non_point).with_check().into_string(); + assert_eq!( + DocAnchor::parse(&encoded), + Err(ParseDocAnchorError::NotACurvePoint) + ); + } + + #[test] + fn head_errors_distinguish_checksum_from_length() { + assert_eq!( + Head::parse("not-base58-0OIl"), + Err(ParseHeadError::MalformedBase58Check) + ); + + let short = bs58::encode(&[7u8; 16]).with_check().into_string(); + assert_eq!(Head::parse(&short), Err(ParseHeadError::WrongLength)); + } + + /// The manual `Ord` is byte order — a reversed or constant `cmp` + /// mutation dies here. + #[test] + fn anchors_order_by_key_bytes() { + let low = DocAnchor::from(vector_key()); + let high = DocAnchor::from(SigningKey::from_bytes(&[8u8; 32]).verifying_key()); + + assert_eq!( + low.cmp(&high), + low.verifying_key() + .as_bytes() + .cmp(high.verifying_key().as_bytes()) + ); + assert_eq!(low.cmp(&low), core::cmp::Ordering::Equal); + assert_eq!(low.cmp(&high), high.cmp(&low).reverse()); + } + + /// The first constant-fill 32-byte array that fails ed25519 point + /// decompression (about half the byte space does): a deterministic + /// non-point without hardcoding curve internals. + fn non_curve_point_bytes() -> [u8; 32] { + (0u8..=255) + .map(|b| [b; 32]) + .find(|bytes| VerifyingKey::from_bytes(bytes).is_err()) + .expect("some constant fill fails decompression") + } + mod props { use super::*; #[test] fn encode_decode_roundtrip_from_raw_bytes() { bolero::check!().with_type::<[u8; 32]>().for_each(|bytes| { - if let Ok(key) = VerifyingKey::from_bytes(bytes) { - let anchor = DocAnchor::from(key); - let reparsed = - DocAnchor::parse(&anchor.to_string()).expect("printed keys reparse"); - assert_eq!(anchor, reparsed); + let encoded = bs58::encode(bytes).with_check().into_string(); + + match VerifyingKey::from_bytes(bytes) { + Ok(key) => { + let anchor = DocAnchor::from(key); + let reparsed = + DocAnchor::parse(&anchor.to_string()).expect("printed keys reparse"); + assert_eq!(anchor, reparsed); + assert_eq!(anchor.to_string(), encoded); + } + // The negative arm: non-points fail as exactly + // NotACurvePoint, never some other shape. + Err(_) => assert_eq!( + DocAnchor::parse(&encoded), + Err(ParseDocAnchorError::NotACurvePoint) + ), } }); } diff --git a/onomancy_core/src/delegation_chain.rs b/onomancy_core/src/delegation_chain.rs index 5cf4266..9c8f408 100644 --- a/onomancy_core/src/delegation_chain.rs +++ b/onomancy_core/src/delegation_chain.rs @@ -136,6 +136,23 @@ impl From> for SignedDelegationBytes { mod tests { use super::*; + /// A count the input cannot back fails before any entry + /// allocation — the "trust the count" mutation the decode doc + /// comment warns about. + #[test] + fn hostile_counts_are_rejected_before_allocation() { + let mut bytes = Vec::new(); + wire::put_varint(&mut bytes, 5); // declares 5 entries, provides 0 + + assert!(matches!( + DelegationChain::read_framed(&bytes), + Err(WireError::LengthOverrun { + declared: 5, + have: 0 + }) + )); + } + mod props { use super::*; @@ -163,5 +180,31 @@ mod tests { assert_eq!(chain, decoded); }); } + + /// The framed (file-format) entry points roundtrip, and the + /// one behavior `read_framed` adds over `decode` — trailing + /// bytes are rejected — holds for every chain. + #[test] + fn framed_roundtrip_rejects_trailing_bytes() { + bolero::check!() + .with_type::>>() + .for_each(|blobs| { + let chain: DelegationChain = blobs + .iter() + .cloned() + .map(SignedDelegationBytes::from) + .collect(); + + let mut buf = Vec::new(); + chain.write_framed(&mut buf); + assert_eq!(DelegationChain::read_framed(&buf), Ok(chain)); + + buf.push(0); + assert_eq!( + DelegationChain::read_framed(&buf), + Err(WireError::TrailingBytes { extra: 1 }) + ); + }); + } } } diff --git a/onomancy_core/src/digest.rs b/onomancy_core/src/digest.rs index 082ed87..4c0d757 100644 --- a/onomancy_core/src/digest.rs +++ b/onomancy_core/src/digest.rs @@ -167,18 +167,40 @@ mod tests { struct UnitA; struct UnitB; + /// The manual `PartialEq` (phantom-bound workaround) actually + /// compares bytes: an `eq → true` mutation dies on the negative. #[test] fn digests_of_equal_bytes_agree_within_a_type() { - assert_eq!( - Digest::::hash(b"payload"), - Digest::::hash(b"payload") - ); assert_ne!( Digest::::hash(b"payload"), Digest::::hash(b"other") ); } + /// Known-answer pin: erased digests are PLAIN BLAKE3-256, no + /// keying, no derivation, no domain separation. Every store + /// address in the system silently changes if this moves — an + /// interop contract, pinned against the published vector rather + /// than against our own delegation one hop away. + #[test] + fn blake3_known_answer_pins_the_algorithm() { + // BLAKE3("") from the official test vectors. + const EMPTY: [u8; 32] = [ + 0xaf, 0x13, 0x49, 0xb9, 0xf5, 0xf9, 0xa1, 0xa6, 0xa0, 0x40, 0x4d, 0xea, 0x36, 0xdc, + 0xc9, 0x49, 0x9b, 0xcb, 0x25, 0xc9, 0xad, 0xc1, 0x12, 0xb7, 0xcc, 0x9a, 0x93, 0xca, + 0xe4, 0x1f, 0x32, 0x62, + ]; + + let digest = Digest::::hash(b""); + assert_eq!(*digest.as_bytes(), EMPTY); + + // Display is the lowercase hex of exactly those bytes. + assert_eq!( + alloc::string::ToString::to_string(&digest), + "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" + ); + } + #[test] fn erasure_preserves_the_bytes() { let typed_a = Digest::::hash(b"payload"); @@ -190,19 +212,65 @@ mod tests { } mod props { - use alloc::vec::Vec; + use core::{ + cmp::Ordering, + hash::{Hash, Hasher}, + }; use super::*; - /// Injective in practice: distinct bytes, distinct hashes - /// (collision = broken BLAKE3, not broken code). + /// Captures written bytes so `Hash` coverage is observable + /// without committing to any hasher's mixing function. + #[derive(Default)] + struct RecordingHasher { + bytes: Vec, + } + + impl Hasher for RecordingHasher { + fn finish(&self) -> u64 { + self.bytes.len() as u64 + } + + fn write(&mut self, bytes: &[u8]) { + self.bytes.extend_from_slice(bytes); + } + } + + /// The manual `Eq`/`Ord`/`Hash`/`from_bytes` impls agree with + /// each other and with raw byte order. #[test] - fn verbatim_bytes_hash_stably() { - bolero::check!().with_type::>().for_each(|bytes| { - let erased = Digest::::hash(bytes); - assert_eq!(erased, Digest::::hash(bytes)); - assert_eq!(*erased.as_bytes(), ::hash(bytes)); - }); + fn manual_impls_agree_with_byte_order() { + bolero::check!() + .with_type::<([u8; 32], [u8; 32])>() + .for_each(|(a, b)| { + let da = Digest::::from_bytes(*a); + let db = Digest::::from_bytes(*b); + + assert_eq!(da.as_bytes(), a, "from_bytes is verbatim"); + assert_eq!((da == db), (da.cmp(&db) == Ordering::Equal)); + assert_eq!(da.cmp(&db), a.cmp(b), "digest order is byte order"); + assert_eq!(da.cmp(&db), db.cmp(&da).reverse()); + assert_eq!( + da.partial_cmp(&db), + Some(da.cmp(&db)), + "partial order agrees with the total order" + ); + + assert!( + !format!("{da:?}").is_empty(), + "debug output renders something" + ); + + // The manual `Hash` must feed the bytes to the + // hasher: the recorded stream carries the digest + // bytes themselves. + let mut sink = RecordingHasher::default(); + da.hash(&mut sink); + assert!( + sink.bytes.ends_with(a), + "hash covers the digest bytes (modulo the slice length prefix)" + ); + }); } } } diff --git a/onomancy_core/src/name.rs b/onomancy_core/src/name.rs index dae295a..0c875c8 100644 --- a/onomancy_core/src/name.rs +++ b/onomancy_core/src/name.rs @@ -188,7 +188,12 @@ mod tests { Name::::parse(&alloc::format!("automerge:{DOC}#")), Err(doc::ParseDocNameError::ReservedFragment) ); - assert!(Name::::parse("~/bob#pin").is_err()); + assert_eq!( + Name::::parse("~/bob#pin"), + Err(local::ParseLocalNameError::Segments( + ParseSegmentsError::Segment(ParseSegmentError::ReservedHash) + )) + ); } #[test] @@ -229,13 +234,84 @@ mod tests { #[test] fn empty_and_dot_segments_are_rejected() { - for bad in ["//a", "/a/", "/./a", "/../a"] { - assert!(Name::::parse(&alloc::format!("~{bad}")).is_err()); + // Exact variants: rejecting for the WRONG reason (everything + // collapsing into one bucket) would be a different parser. + for (bad, expected) in [ + ("//a", ParseSegmentError::Empty), + ("/a/", ParseSegmentError::Empty), + ("/./a", ParseSegmentError::DotSegment), + ("/../a", ParseSegmentError::DotSegment), + ] { + assert_eq!( + Name::::parse(&alloc::format!("~{bad}")), + Err(local::ParseLocalNameError::Segments( + ParseSegmentsError::Segment(expected) + )), + "~{bad}" + ); + } + } + + #[test] + fn anchor_splitting_handles_slashless_and_empty_input() { + assert_eq!(split_anchor("anchor/a/b"), ("anchor", "/a/b")); + assert_eq!(split_anchor("anchor"), ("anchor", "")); + assert_eq!(split_anchor(""), ("", "")); + + assert_eq!(parse_segments(""), Ok(Vec::new())); + assert_eq!( + parse_segments("no-slash"), + Err(ParseSegmentsError::ExpectedSlashAfterAnchor) + ); + } + + /// The serde forms are the display string, exactly. + #[cfg(feature = "serde")] + mod serde_roundtrip { + use super::*; + + #[test] + fn json_roundtrips_and_rejects_malformed_names() { + let local = Name::::parse("~/bob/pics").expect("valid"); + let json = serde_json::to_string(&local).expect("serializes"); + assert_eq!(json, "\"~/bob/pics\""); + assert_eq!( + serde_json::from_str::>(&json).expect("deserializes"), + local + ); + + let doc = + Name::::parse(&alloc::format!("automerge:{DOC}/blog")).expect("valid"); + let json = serde_json::to_string(&doc).expect("serializes"); + assert_eq!( + serde_json::from_str::>(&json).expect("deserializes"), + doc + ); + + assert!(serde_json::from_str::>("\"~bob\"").is_err()); + assert!(serde_json::from_str::>("\"~/bob\"").is_err()); + } + + /// forall valid names: deserialize ∘ serialize = id. + #[test] + fn props_serde_roundtrip() { + bolero::check!() + .with_type::() + .for_each(|raw| { + if let Ok(name) = Name::::parse(raw) { + let json = serde_json::to_string(&name).expect("serializes"); + assert_eq!( + serde_json::from_str::>(&json).expect("deserializes"), + name + ); + } + }); } } mod props { use super::*; + use ed25519_dalek::{SigningKey, VerifyingKey}; #[test] fn parse_never_panics_and_roundtrips() { @@ -257,5 +333,45 @@ mod tests { } }); } + + /// Structured doc-anchor roundtrip: arbitrary strings almost + /// never pass bs58check, so the doc branch above is nearly + /// dead — this generator builds names from raw key bytes and + /// segment seeds, giving the roundtrip real traffic. + #[test] + fn doc_names_built_from_parts_roundtrip() { + bolero::check!() + .with_type::<([u8; 32], Vec)>() + .for_each(|(key_bytes, seg_seeds)| { + let Ok(key) = VerifyingKey::from_bytes(key_bytes) else { + return; + }; + + let segments: Vec = seg_seeds + .iter() + .filter_map(|s| Segment::parse(s).ok()) + .collect(); + + let name = Name::from_parts(DocAnchor::from(key), segments); + let reparsed = + Name::::parse(&name.to_string()).expect("printed names reparse"); + assert_eq!(name, reparsed); + }); + } + + /// Signing keys always yield parseable doc anchors — the + /// deterministic half of the structured generator. + #[test] + fn every_signing_keys_anchor_roundtrips() { + bolero::check!().with_type::<[u8; 32]>().for_each(|seed| { + let key = SigningKey::from_bytes(seed).verifying_key(); + let name = Name::from_parts(DocAnchor::from(key), Vec::new()); + assert_eq!( + Name::::parse(&name.to_string()), + Ok(name), + "anchors from real keys always roundtrip" + ); + }); + } } } diff --git a/onomancy_core/src/name/segment.rs b/onomancy_core/src/name/segment.rs index 2721918..7525d28 100644 --- a/onomancy_core/src/name/segment.rs +++ b/onomancy_core/src/name/segment.rs @@ -120,4 +120,31 @@ mod tests { assert!(Segment::parse("bmann.ca").is_ok()); assert!(Segment::parse("blog.old").is_ok()); } + + mod props { + use super::*; + + /// Acceptance is EXACTLY the documented predicate — no + /// narrower (weakened control-character check) and no wider + /// — and accepted segments are stored verbatim. + #[test] + fn acceptance_is_the_documented_predicate() { + bolero::check!().with_type::().for_each(|raw| { + let should_accept = !raw.is_empty() + && raw != "." + && raw != ".." + && !raw.contains('/') + && !raw.contains('#') + && !raw.chars().any(char::is_control); + + match Segment::parse(raw) { + Ok(segment) => { + assert!(should_accept, "accepted outside the predicate: {raw:?}"); + assert_eq!(segment.as_str(), raw, "segments are identity wrappers"); + } + Err(_) => assert!(!should_accept, "rejected inside the predicate: {raw:?}"), + } + }); + } + } } diff --git a/onomancy_core/src/wire.rs b/onomancy_core/src/wire.rs index 3d703a1..404c5a4 100644 --- a/onomancy_core/src/wire.rs +++ b/onomancy_core/src/wire.rs @@ -224,7 +224,7 @@ pub enum WireError { } #[cfg(test)] -#[allow(clippy::expect_used)] +#[allow(clippy::expect_used, clippy::panic)] mod tests { use super::*; use alloc::vec; @@ -260,6 +260,45 @@ mod tests { )); } + /// Both sides of the 1 MiB boundary, on both the decoder's gate + /// and the encoder-side contract every `sign` path leans on. + #[test] + fn the_unit_cap_boundary_is_exact() { + let at_cap = vec![0u8; MAX_UNIT_BYTES]; + assert!(Reader::new(&at_cap).is_ok(), "exactly the cap decodes"); + + assert_eq!(check_unit_len(MAX_UNIT_BYTES), Ok(())); + assert_eq!( + check_unit_len(MAX_UNIT_BYTES + 1), + Err(OversizeUnit { + len: MAX_UNIT_BYTES + 1 + }) + ); + } + + #[test] + fn short_reads_report_need_and_have() { + let unit = vec![0u8; 3]; + let mut reader = Reader::new(&unit).expect("under cap"); + assert_eq!( + reader.take(5), + Err(WireError::Truncated { need: 5, have: 3 }) + ); + assert!(matches!( + reader.take_array::<4>(), + Err(WireError::Truncated { need: 4, have: 3 }) + )); + + // Failed reads consume nothing. + assert_eq!(reader.take(3).expect("in bounds"), &[0, 0, 0]); + } + + #[test] + fn truncated_varints_are_rejected() { + let mut empty = Reader::new(&[]).expect("under cap"); + assert!(matches!(empty.varint(), Err(WireError::Varint(_)))); + } + mod props { use super::*; @@ -276,5 +315,66 @@ mod tests { assert_eq!(reader.finish(), Ok(())); }); } + + /// `bounded_len` admits a count iff `count × width` fits the + /// remaining input — including the `checked_mul` overflow arm + /// that `read_heads` (width 32) and `read_lineage` (width + /// 166) rely on. Never allocates, never panics. + #[test] + fn bounded_len_admits_exactly_what_the_input_backs() { + bolero::check!() + .with_type::<(u64, u8, u16)>() + .for_each(|(declared, width, have)| { + let width = usize::from(*width).max(1); + let mut unit = Vec::new(); + put_varint(&mut unit, *declared); + unit.extend(core::iter::repeat_n(0u8, usize::from(*have))); + + let mut reader = Reader::new(&unit).expect("under cap"); + let fits = usize::try_from(*declared) + .ok() + .and_then(|n| n.checked_mul(width)) + .is_some_and(|total| total <= usize::from(*have)); + + match reader.bounded_len(width) { + Ok(count) => { + assert!(fits, "admitted a count the input cannot back"); + assert_eq!(count as u64, *declared); + } + Err(WireError::LengthOverrun { declared: got, .. }) => { + assert!(!fits, "rejected a count the input backs"); + assert_eq!(got, *declared); + } + Err(other) => panic!("unexpected error: {other:?}"), + } + }); + } + + /// `take` succeeds iff enough bytes remain, and consumes + /// exactly what it returns. + #[test] + fn take_consumes_exactly_its_need() { + bolero::check!() + .with_type::<(Vec, u16)>() + .for_each(|(bytes, need)| { + let need = usize::from(*need); + let mut reader = Reader::new(bytes).expect("under cap"); + let before = reader.remaining(); + + match reader.take(need) { + Ok(taken) => { + assert!(need <= before); + assert_eq!(taken.len(), need); + assert_eq!(reader.remaining(), before - need); + } + Err(WireError::Truncated { need: n, have }) => { + assert!(need > before); + assert_eq!((n, have), (need, before)); + assert_eq!(reader.remaining(), before, "failed reads consume nothing"); + } + Err(other) => panic!("unexpected error: {other:?}"), + } + }); + } } } diff --git a/onomancy_dnssec/examples/generate_vectors.rs b/onomancy_dnssec/examples/generate_vectors.rs index 3a803b6..74c1bef 100644 --- a/onomancy_dnssec/examples/generate_vectors.rs +++ b/onomancy_dnssec/examples/generate_vectors.rs @@ -1,8 +1,9 @@ //! Regenerate the golden-vector files in `tests/vectors/` from the -//! catalog (`tests/vectors_catalog.rs` — the single source of truth). +//! catalog (`tests/support/vectors_catalog.rs` — the single source of +//! truth). //! //! ```sh -//! cargo run -p onomancy_core --example generate_vectors +//! cargo run -p onomancy_dnssec --example generate_vectors //! ``` //! //! Byte drift in the regenerated files is a wire-format break: the diff --git a/onomancy_dnssec/src/certificate.rs b/onomancy_dnssec/src/certificate.rs index 8c8fec2..c8caa45 100644 --- a/onomancy_dnssec/src/certificate.rs +++ b/onomancy_dnssec/src/certificate.rs @@ -822,6 +822,153 @@ mod tests { )); } + /// The lineage count is bounds-checked at the TRUE minimum entry + /// width (166 bytes), not at one byte: a declared count the input + /// cannot back at that width dies as `LengthOverrun` BEFORE any + /// entry decode runs. A weakened `MIN_LINEAGE_ENTRY` (say 1) + /// would wander into entry parsing and fail differently. + #[test] + fn lineage_counts_are_bounded_at_the_true_entry_width() { + let mut params = sample_params(); + params.lineage = vec![]; + params.chain = DnssecChain::default(); + params.delegation_chain = DelegationChain::default(); + let mut bytes = Certificate::sign(params, &SigningKey::from_bytes(&[2; 32])) + .expect("under the unit cap") + .encode(); + + // The attached region of this certificate is three zero + // varints: delegation count, lineage count, chain count. + let mut zero = Vec::new(); + wire::put_varint(&mut zero, 0); + assert_eq!( + bytes[bytes.len() - 3 * zero.len()..], + [zero.clone(), zero.clone(), zero.clone()].concat(), + "fixture sanity: empty attached region" + ); + + // Declare 2 lineage entries backed by 200 junk bytes: enough + // for 2 one-byte entries, nowhere near 2 × 166. + bytes.truncate(bytes.len() - 2 * zero.len()); + wire::put_varint(&mut bytes, 2); + bytes.extend_from_slice(&[0u8; 200]); + + assert!(matches!( + Certificate::decode(&bytes), + Err(DecodeCertificateError::Wire(WireError::LengthOverrun { + declared: 2, + have: 200 + })) + )); + } + + /// A non-point in the root-doc field is named as such — and the + /// grammar check fires before the signature check. + #[test] + fn non_curve_points_name_their_field() { + let non_point: [u8; 32] = (0u8..=255) + .map(|b| [b; 32]) + .find(|bytes| VerifyingKey::from_bytes(bytes).is_err()) + .expect("some constant fill fails decompression"); + + for (position, field) in [(0, FieldName::RootDoc), (1, FieldName::Signer)] { + let mut bytes = sample().encode(); + let at = 4 + position * 32; + bytes[at..at + 32].copy_from_slice(&non_point); + + assert!( + matches!( + Certificate::decode(&bytes), + Err(DecodeCertificateError::NotACurvePoint { field: got }) if got == field + ), + "field {field:?}" + ); + } + } + + /// A corrupt lineage entry surfaces with its zero-based index. + #[test] + fn corrupt_lineage_entries_carry_their_index() { + let cert = sample(); + let mut bytes = cert.encode(); + + // The one lineage entry is an embedded ONR unit in the + // attached region; break its tag. + let onr_at = bytes + .windows(4) + .rposition(|w| w == b"ONR\x00") + .expect("lineage unit present"); + bytes[onr_at..onr_at + 4].copy_from_slice(b"JUNK"); + + assert!(matches!( + Certificate::decode(&bytes), + Err(DecodeCertificateError::Lineage { index: 0, .. }) + )); + } + + /// Equal adjacent heads are as non-canonical as unsorted ones — + /// the `>=` in `read_heads`, both halves. + #[test] + fn duplicate_heads_are_not_canonical() { + let cert = sample(); + let mut bytes = cert.encode(); + + // Overwrite the second head with the first: equal adjacent. + let heads_at = bytes + .windows(32) + .position(|w| w == [3u8; 32]) + .expect("first head present"); + let first: [u8; 32] = bytes[heads_at..heads_at + 32].try_into().expect("32 bytes"); + bytes[heads_at + 32..heads_at + 64].copy_from_slice(&first); + + assert!(matches!( + Certificate::decode(&bytes), + Err(DecodeCertificateError::HeadsNotCanonical) + )); + } + + /// A non-canonical hostname on the wire is the certificate's own + /// `Hostname` error, before the signature check. + #[test] + fn non_canonical_hostnames_are_rejected_not_normalized() { + let mut bytes = sample().encode(); + + // The hostname appears twice (the embedded predecessor + // statement carries its own copy, later in the wire); the + // certificate's field 5 is the FIRST occurrence. + let needle = b"expede.wtf"; + let at = bytes + .windows(needle.len()) + .position(|w| w == needle) + .expect("hostname present"); + bytes[at] = b'E'; + + assert!(matches!( + Certificate::decode(&bytes), + Err(DecodeCertificateError::Hostname( + CanonicalDnsNameError::NotCanonical + )) + )); + } + + /// A corrupt embedded predecessor unit is the certificate's own + /// `Predecessor` error, carrying the nested failure. + #[test] + fn corrupt_predecessors_surface_as_their_own_error() { + let mut bytes = sample().encode(); + + let ons_at = bytes + .windows(4) + .position(|w| w == b"ONS\x00") + .expect("predecessor unit present"); + bytes[ons_at..ons_at + 4].copy_from_slice(b"JUNK"); + + assert!(matches!( + Certificate::decode(&bytes), + Err(DecodeCertificateError::Predecessor(_)) + )); + } + mod props { use super::*; @@ -859,6 +1006,34 @@ mod tests { }); } + /// The attached region's counterpart: a flip after the + /// signature either breaks framing (an error) or yields the + /// SAME certificate as a DIFFERENT store item — never a + /// different certificate. + #[test] + fn attached_region_tampering_never_changes_the_certificate() { + bolero::check!() + .with_type::<(usize, u8)>() + .for_each(|(at, mask)| { + let cert = sample(); + let mut bytes = cert.encode(); + let attached_start = cert.signed_bytes().len() + 64; + let attached_len = bytes.len() - attached_start; + let at = attached_start + (at % attached_len); + let mask = if *mask == 0 { 1 } else { *mask }; + + bytes[at] ^= mask; + + if let Ok(reread) = Certificate::decode(&bytes) { + assert!( + cert.same_certificate(&reread), + "attached flips never alter the signed unit" + ); + assert_ne!(cert.digest(), reread.digest(), "but the item changed"); + } + }); + } + /// Any bit flip inside the signed region kills the unit at /// decode: strictness catches it, or the signature does. #[test] diff --git a/onomancy_dnssec/src/chain.rs b/onomancy_dnssec/src/chain.rs index 4bab06f..40b5282 100644 --- a/onomancy_dnssec/src/chain.rs +++ b/onomancy_dnssec/src/chain.rs @@ -108,6 +108,22 @@ impl From> for ChainLink { mod tests { use super::*; + /// A count the input cannot back fails before any link + /// allocation. + #[test] + fn hostile_counts_are_rejected_before_allocation() { + let mut bytes = Vec::new(); + wire::put_varint(&mut bytes, 7); // declares 7 links, provides 0 + + assert!(matches!( + DnssecChain::read_framed(&bytes), + Err(WireError::LengthOverrun { + declared: 7, + have: 0 + }) + )); + } + mod props { use super::*; @@ -134,5 +150,33 @@ mod tests { assert_eq!(chain, decoded); }); } + + /// The framed (fixture/store-item) entry point roundtrips, + /// and the one behavior it adds over `read` — trailing bytes + /// are rejected — holds for every chain. + #[test] + fn framed_roundtrip_rejects_trailing_bytes() { + bolero::check!() + .with_type::>>() + .for_each(|blobs| { + let chain = DnssecChain::from( + blobs + .iter() + .cloned() + .map(ChainLink::from) + .collect::>(), + ); + + let mut buf = Vec::new(); + chain.write_framed(&mut buf); + assert_eq!(DnssecChain::read_framed(&buf), Ok(chain)); + + buf.push(0); + assert_eq!( + DnssecChain::read_framed(&buf), + Err(WireError::TrailingBytes { extra: 1 }) + ); + }); + } } } diff --git a/onomancy_dnssec/src/crypto.rs b/onomancy_dnssec/src/crypto.rs index 8c919a4..c042af9 100644 --- a/onomancy_dnssec/src/crypto.rs +++ b/onomancy_dnssec/src/crypto.rs @@ -226,6 +226,164 @@ pub enum VerifyError { #[allow(clippy::expect_used, clippy::indexing_slicing)] mod tests { use super::*; + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + + /// RFC 4035 §5.3.2 signed data for one A record, hand-assembled + /// for the RFC 5702/6605 worked examples (owner + /// `www.example.net.`, signer `example.net.`, TTL 3600). + fn rfc_signed_data( + algorithm: Algorithm, + expiration: u32, + inception: u32, + key_tag: u16, + address: [u8; 4], + ) -> Vec { + let mut message = Vec::new(); + // RRSIG preamble: covered A(1), algorithm, labels 3, orig TTL. + message.extend_from_slice(&1u16.to_be_bytes()); + message.push(algorithm.code()); + message.push(3); + message.extend_from_slice(&3600u32.to_be_bytes()); + message.extend_from_slice(&expiration.to_be_bytes()); + message.extend_from_slice(&inception.to_be_bytes()); + message.extend_from_slice(&key_tag.to_be_bytes()); + message.extend_from_slice(b"\x07example\x03net\x00"); + // The one A record in canonical form. + message.extend_from_slice(b"\x03www\x07example\x03net\x00"); + message.extend_from_slice(&1u16.to_be_bytes()); // type A + message.extend_from_slice(&1u16.to_be_bytes()); // class IN + message.extend_from_slice(&3600u32.to_be_bytes()); + message.extend_from_slice(&4u16.to_be_bytes()); + message.extend_from_slice(&address); + message + } + + /// RFC 5702 §6.1: a genuine RSA/SHA-256 signature made by another + /// implementation — the algorithm of the actual DNS root zone. + /// A wiring bug (wrong hash, wrong padding, exponent/modulus + /// swapped) fails closed and would brick every real-root + /// validation; this is the test that notices. + #[test] + fn the_rfc5702_rsa_sha256_vector_verifies() { + let key_blob = BASE64 + .decode( + "AwEAAcFcGsaxxdgiuuGmCkVImy4h99CqT7jwY3pexPGcnUFtR2Fh36Bponcwtk\ + Z4cAgtvd4Qs8PkxUdp6p/DlUmObdk=", + ) + .expect("published key decodes"); + let signature = BASE64 + .decode( + "kRCOH6u7l0QGy9qpC9l1sLncJcOKFLJ7GhiUOibu4teYp5VE9RncriShZNz85m\ + wlMgNEacFYK/lPtPiVYP4bwg==", + ) + .expect("published signature decodes"); + + // 20300101000000 / 20000101000000 as epoch seconds; tag 9033; + // www.example.net. A 192.0.2.91. + let message = rfc_signed_data( + Algorithm::RSA_SHA256, + 1_893_456_000, + 946_684_800, + 9033, + [192, 0, 2, 91], + ); + + assert_eq!( + verify_signature(Algorithm::RSA_SHA256, &key_blob, &message, &signature), + Ok(()) + ); + + // One flipped message byte fails as BadSignature, never panics. + let mut tampered = message; + tampered[0] ^= 0x01; + assert_eq!( + verify_signature(Algorithm::RSA_SHA256, &key_blob, &tampered, &signature), + Err(VerifyError::BadSignature) + ); + } + + /// RFC 6605 §6.1: a genuine ECDSA P-256 signature — pins the two + /// hand-rolled format details (the 0x04 SEC1 prefix prepend and + /// fixed-width `r ‖ s` parsing). + #[test] + fn the_rfc6605_p256_vector_verifies() { + let key_blob = BASE64 + .decode( + "GojIhhXUN/u4v54ZQqGSnyhWJwaubCvTmeexv7bR6edbkrSqQpF64cYbcB7wNc\ + P+e+MAnLr+Wi9xMWyQLc8NAA==", + ) + .expect("published key decodes"); + let signature = BASE64 + .decode( + "qx6wLYqmh+l9oCKTN6qIc+bw6ya+KJ8oMz0YP107epXAyGmt+3SNruPFKG7tZo\ + LBLlUzGGus7ZwmwWep666VCw==", + ) + .expect("published signature decodes"); + + // 20100909100439 / 20100812100439 as epoch seconds; tag 55648; + // www.example.net. A 192.0.2.1. + let message = rfc_signed_data( + Algorithm::ECDSA_P256_SHA256, + 1_284_026_679, + 1_281_607_479, + 55648, + [192, 0, 2, 1], + ); + + assert_eq!( + verify_signature( + Algorithm::ECDSA_P256_SHA256, + &key_blob, + &message, + &signature + ), + Ok(()) + ); + + // Bit flip → BadSignature. + let mut tampered = message.clone(); + tampered[0] ^= 0x01; + assert_eq!( + verify_signature( + Algorithm::ECDSA_P256_SHA256, + &key_blob, + &tampered, + &signature + ), + Err(VerifyError::BadSignature) + ); + + // A 65-byte key (0x04 already prepended) is malformed — the + // DNS form is exactly the 64-byte point. + let mut prefixed = alloc::vec![0x04]; + prefixed.extend_from_slice(&key_blob); + assert_eq!( + verify_signature( + Algorithm::ECDSA_P256_SHA256, + &prefixed, + &message, + &signature + ), + Err(VerifyError::MalformedKey) + ); + } + + /// The malformed-input arms of the Ed25519 path: a wrong-width + /// key is `MalformedKey`; a wrong-width signature is deliberately + /// indistinguishable from a failed one. + #[test] + fn ed25519_malformed_inputs_have_their_documented_shapes() { + let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]).verifying_key(); + + assert_eq!( + verify_signature(Algorithm::ED25519, &key.as_bytes()[..31], b"m", &[0; 64]), + Err(VerifyError::MalformedKey) + ); + assert_eq!( + verify_signature(Algorithm::ED25519, key.as_bytes(), b"m", &[0; 63]), + Err(VerifyError::BadSignature) + ); + } #[test] fn unsupported_algorithms_are_invalid_not_insecure() { @@ -235,6 +393,47 @@ mod tests { ); } + /// The two invalid-✗ policy arms of `ds_matches`: an unsupported + /// digest type and a key/DS algorithm disagreement are both their + /// own refusals, never insecure-but-ok. + #[test] + fn ds_matching_refuses_unsupported_digests_and_mismatched_algorithms() { + let mut key_rdata = Vec::new(); + key_rdata.extend_from_slice(&0x0100u16.to_be_bytes()); + key_rdata.push(3); + key_rdata.push(Algorithm::ED25519.code()); + key_rdata.extend_from_slice(&[7; 32]); + let key = Dnskey::parse(&key_rdata).expect("valid DNSKEY"); + let owner: Name = "expede.wtf".parse().expect("parses"); + + // SHA-384 (type 4): representable on the wire, uncomputable here. + let mut sha384_ds = Vec::new(); + sha384_ds.extend_from_slice(&key.key_tag().to_be_bytes()); + sha384_ds.push(Algorithm::ED25519.code()); + sha384_ds.push(4); + sha384_ds.extend_from_slice(&[0xAA; 48]); + let ds = Ds::parse(&sha384_ds).expect("parses"); + assert_eq!( + ds_matches(&owner, &key, &ds), + Err(VerifyError::UnsupportedDigest(DigestType::new(4))) + ); + + // A DS claiming a different key algorithm. + let mut rsa_ds = Vec::new(); + rsa_ds.extend_from_slice(&key.key_tag().to_be_bytes()); + rsa_ds.push(Algorithm::RSA_SHA256.code()); + rsa_ds.push(DigestType::SHA256.code()); + rsa_ds.extend_from_slice(ds_digest(&owner, &key).as_bytes()); + let ds = Ds::parse(&rsa_ds).expect("parses"); + assert_eq!( + ds_matches(&owner, &key, &ds), + Err(VerifyError::AlgorithmMismatch { + key: Algorithm::ED25519, + signature: Algorithm::RSA_SHA256 + }) + ); + } + #[test] fn ds_digest_commits_to_owner_and_key() { // DNSKEY: ZONE flag, protocol 3, ED25519, any 32-byte point. @@ -277,5 +476,36 @@ mod tests { assert!(split_rsa_key(&[]).is_err()); assert!(split_rsa_key(&[5, 1, 2]).is_err(), "exponent overrun"); + + // Zero-length exponents and empty moduli are refused too. + assert_eq!( + split_rsa_key(&[0, 0, 0, 9, 9]), + Err(VerifyError::MalformedKey), + "zero exponent length" + ); + assert_eq!( + split_rsa_key(&[2, 1, 1]), + Err(VerifyError::MalformedKey), + "empty modulus" + ); + } + + mod props { + use super::*; + + /// `split_rsa_key` is total, and on success the split is an + /// exact partition: header + exponent + modulus = blob. + #[test] + fn rsa_key_splitting_partitions_the_blob() { + bolero::check!().with_type::>().for_each(|blob| { + if let Ok((exponent, modulus)) = split_rsa_key(blob) { + assert!(!exponent.is_empty()); + assert!(!modulus.is_empty()); + + let header_len = if blob[0] == 0 { 3 } else { 1 }; + assert_eq!(header_len + exponent.len() + modulus.len(), blob.len()); + } + }); + } } } diff --git a/onomancy_dnssec/src/dns_name.rs b/onomancy_dnssec/src/dns_name.rs index ffb40b7..818b408 100644 --- a/onomancy_dnssec/src/dns_name.rs +++ b/onomancy_dnssec/src/dns_name.rs @@ -273,7 +273,10 @@ mod tests { /// the rule is about the whole label, not its first byte. #[test] fn accepts_a_tld_with_digits_and_letters() { - assert!(DnsName::parse("example.4d").is_ok()); + assert_eq!( + DnsName::parse("example.4d").expect("valid").as_str(), + "example.4d" + ); } #[test] @@ -286,7 +289,12 @@ mod tests { #[test] fn accepts_punycode_a_labels() { - assert!(DnsName::parse("xn--80ak6aa92e.com").is_ok()); + assert_eq!( + DnsName::parse("xn--80ak6aa92e.com") + .expect("valid") + .as_str(), + "xn--80ak6aa92e.com" + ); } #[test] @@ -297,6 +305,149 @@ mod tests { ); } + #[test] + fn hyphens_at_label_edges_are_rejected() { + assert_eq!( + DnsName::parse("-foo.com"), + Err(ParseDnsNameError::HyphenAtLabelEdge) + ); + assert_eq!( + DnsName::parse("foo-.com"), + Err(ParseDnsNameError::HyphenAtLabelEdge) + ); + assert_eq!( + DnsName::parse("foo.-com"), + Err(ParseDnsNameError::HyphenAtLabelEdge) + ); + + // Interior hyphens are ordinary LDH. + assert!(DnsName::parse("f-oo.com").is_ok()); + } + + #[test] + fn dotless_and_empty_names_do_not_exist() { + assert_eq!(DnsName::parse("localhost"), Err(ParseDnsNameError::Dotless)); + assert_eq!(DnsName::parse(""), Err(ParseDnsNameError::EmptyLabel)); + } + + /// A name of exactly `target` octets: fill labels ahead of `com`. + fn name_of_len(target: usize) -> String { + let mut labels = alloc::vec![String::from("com")]; + let mut remaining = target - 3; + + while remaining > 0 { + let label_len = MAX_LABEL_LEN.min(remaining - 1); + labels.insert(0, "a".repeat(label_len)); + remaining -= label_len + 1; + } + + let name = labels.join("."); + assert_eq!(name.len(), target, "helper builds exact lengths"); + name + } + + /// The 63/64 and 253/254 boundaries, both directions — classic + /// `>` vs `>=` territory. + #[test] + fn length_limits_are_exact() { + let max_label = alloc::format!("{}.com", "a".repeat(MAX_LABEL_LEN)); + assert!(DnsName::parse(&max_label).is_ok(), "63-octet label"); + assert_eq!( + DnsName::parse(&alloc::format!("{}.com", "a".repeat(MAX_LABEL_LEN + 1))), + Err(ParseDnsNameError::LabelTooLong) + ); + + assert!(DnsName::parse(&name_of_len(MAX_NAME_LEN)).is_ok(), "253"); + assert_eq!( + DnsName::parse(&name_of_len(MAX_NAME_LEN + 1)), + Err(ParseDnsNameError::NameTooLong) + ); + + // A 254-char INPUT whose trailing dot strips to 253 parses: + // the limit applies after normalization. + let dotted = alloc::format!("{}.", name_of_len(MAX_NAME_LEN)); + assert_eq!( + DnsName::parse(&dotted) + .expect("strips to the limit") + .as_str() + .len(), + MAX_NAME_LEN + ); + } + + /// `from_canonical` guards wire decode: it accepts exactly the + /// canonical spelling and refuses to normalize. + #[test] + fn from_canonical_accepts_only_the_canonical_spelling() { + let dns = DnsName::from_canonical(b"expede.wtf").expect("canonical"); + assert_eq!(dns.as_str(), "expede.wtf"); + + assert_eq!( + DnsName::from_canonical(b"ExPeDe.wtf"), + Err(CanonicalDnsNameError::NotCanonical) + ); + assert_eq!( + DnsName::from_canonical(b"expede.wtf."), + Err(CanonicalDnsNameError::NotCanonical) + ); + assert_eq!( + DnsName::from_canonical(&[0xFF, 0xFE]), + Err(CanonicalDnsNameError::NotUtf8) + ); + assert!(matches!( + DnsName::from_canonical(b"localhost"), + Err(CanonicalDnsNameError::Parse(ParseDnsNameError::Dotless)) + )); + } + + /// The `@` spelling family: `Name` parses, roundtrips, + /// and each `ParseDnsAnchoredNameError` variant has its input. + mod anchored { + use super::*; + use onomancy_core::name::{Name, ParseSegmentsError}; + + #[test] + fn dns_anchored_names_parse_and_roundtrip() { + let name = Name::::parse("@expede.wtf/pics/best").expect("valid"); + assert_eq!(name.anchor().as_str(), "expede.wtf"); + assert_eq!(name.segments().len(), 2); + assert_eq!(name.to_string(), "@expede.wtf/pics/best"); + + let bare = Name::::parse("@expede.wtf").expect("anchor-only"); + assert!(bare.segments().is_empty()); + } + + #[test] + fn the_sigil_is_mandatory() { + assert!(matches!( + Name::::parse("expede.wtf/pics"), + Err(ParseDnsAnchoredNameError::MissingSigil) + )); + } + + /// `@` means DNS and nothing else: a dotless `@` name is a + /// flat parse error, never a petname fallback. + #[test] + fn dotless_at_names_are_flat_errors() { + assert!(matches!( + Name::::parse("@bob"), + Err(ParseDnsAnchoredNameError::Anchor( + ParseDnsNameError::Dotless + )) + )); + } + + #[test] + fn malformed_paths_surface_as_segment_errors() { + assert!(matches!( + Name::::parse("@expede.wtf//x"), + Err(ParseDnsAnchoredNameError::Segments( + ParseSegmentsError::Segment(_) + )) + )); + } + } + #[cfg(feature = "idna")] mod display { use super::*; @@ -365,6 +516,65 @@ mod tests { } }); } + + /// Structured traffic for the accept branch: arbitrary + /// strings almost never form valid names, so this generator + /// builds LDH labels from seeds — every built name must parse + /// to itself. + #[test] + fn built_ldh_names_parse_verbatim() { + bolero::check!() + .with_type::>>() + .for_each(|label_seeds| { + let labels: Vec = label_seeds + .iter() + .take(4) + .map(|seed| { + seed.iter() + .take(8) + .map(|b| char::from(b'a' + (b % 26))) + .collect::() + }) + .filter(|l| !l.is_empty()) + .collect(); + + if labels.len() < 2 { + return; + } + + let raw = labels.join("."); + let parsed = DnsName::parse(&raw).expect("LDH names parse"); + assert_eq!(parsed.as_str(), raw, "already canonical"); + }); + } + + /// `from_canonical` accepts exactly the fixed points of + /// `parse`: the parser's output always re-enters, and inputs + /// the parser had to normalize are refused. + #[test] + fn from_canonical_accepts_exactly_parse_fixed_points() { + bolero::check!() + .with_type::() + .for_each(|raw| { + let Ok(parsed) = DnsName::parse(raw) else { + return; + }; + + assert_eq!( + DnsName::from_canonical(parsed.as_str().as_bytes()), + Ok(parsed.clone()), + "parse output is canonical" + ); + + if raw != parsed.as_str() { + assert_eq!( + DnsName::from_canonical(raw.as_bytes()), + Err(CanonicalDnsNameError::NotCanonical), + "normalized inputs are not canonical spellings" + ); + } + }); + } } } diff --git a/onomancy_dnssec/src/link.rs b/onomancy_dnssec/src/link.rs index be70517..cb5a2e6 100644 --- a/onomancy_dnssec/src/link.rs +++ b/onomancy_dnssec/src/link.rs @@ -280,4 +280,80 @@ mod tests { Err(ParseLinkError::MixedRrset) )); } + + /// The other arm of the one-RRset check: same type, two owners. + #[test] + fn mixed_owners_are_rejected_too() { + let mut elsewhere = txt_record(vec![1, b'x']); + elsewhere.owner = "_onomancy.example.com".parse().expect("parses"); + + assert!(matches!( + Link::parse(&frame(&[ + txt_record(vec![1, b'y']), + elsewhere, + rrsig_record(RrType::TXT, &owner()), + ])), + Err(ParseLinkError::MixedRrset) + )); + } + + /// Another name's signature record cannot be attached to this + /// `RRset` — the shape this layer exists to reject. + #[test] + fn signatures_at_another_owner_are_rejected() { + let elsewhere: Name = "_onomancy.example.com".parse().expect("parses"); + + assert!(matches!( + Link::parse(&frame(&[ + txt_record(vec![1, b'x']), + rrsig_record(RrType::TXT, &elsewhere), + ])), + Err(ParseLinkError::SignatureOwnerMismatch) + )); + } + + /// Onomancy records exist only in IN; a CH-class record is + /// rejected with the class it carried. + #[test] + fn non_in_classes_are_rejected() { + let mut chaos = txt_record(vec![1, b'x']); + chaos.class = 3; + + assert!(matches!( + Link::parse(&frame(&[chaos, rrsig_record(RrType::TXT, &owner())])), + Err(ParseLinkError::WrongClass { got: 3 }) + )); + } + + /// A record posing as an RRSIG with undecodable RDATA surfaces + /// through the `Rrsig` variant — the `#[from]` wiring, pinned. + #[test] + fn undecodable_rrsig_rdata_surfaces_as_rrsig_errors() { + let stub = Record { + owner: owner(), + rtype: RrType::RRSIG, + class: CLASS_IN, + ttl: 900, + rdata: vec![0x00, 0x10, 15], // truncated mid-header + }; + + assert!(matches!( + Link::parse(&frame(&[txt_record(vec![1, b'x']), stub])), + Err(ParseLinkError::Rrsig(_)) + )); + } + + /// Multiple covering RRSIGs are the normal key-rollover shape and + /// all of them are retained. + #[test] + fn rollover_links_keep_every_signature() { + let link = Link::parse(&frame(&[ + txt_record(vec![1, b'x']), + rrsig_record(RrType::TXT, &owner()), + rrsig_record(RrType::TXT, &owner()), + ])) + .expect("parses"); + + assert_eq!(link.signatures().len(), 2); + } } diff --git a/onomancy_dnssec/src/statement/rotation.rs b/onomancy_dnssec/src/statement/rotation.rs index 2c58a00..8694d7a 100644 --- a/onomancy_dnssec/src/statement/rotation.rs +++ b/onomancy_dnssec/src/statement/rotation.rs @@ -315,6 +315,55 @@ mod tests { )); } + /// Each of the three key positions surfaces a non-point as ITS + /// field — the grammar check precedes the signature check. + #[test] + fn non_curve_points_name_their_field() { + let non_point: [u8; 32] = (0u8..=255) + .map(|b| [b; 32]) + .find(|bytes| VerifyingKey::from_bytes(bytes).is_err()) + .expect("some constant fill fails decompression"); + + for (position, field) in [ + (0, FieldName::RootDoc), + (1, FieldName::Replaced), + (2, FieldName::Successor), + ] { + let mut bytes = sample().encode(); + let at = 4 + position * 32; + bytes[at..at + 32].copy_from_slice(&non_point); + + assert_eq!( + RotationStatement::decode(&bytes), + Err(DecodeRotationError::NotACurvePoint { field }), + "field {field:?}" + ); + } + } + + /// The attached/signed split: two signs over the same fields with + /// different carriages share their signed region byte-for-byte + /// (ed25519 is deterministic) but are different store items. + #[test] + fn different_carriages_share_the_signed_region_but_not_the_digest() { + let with_carriage = sample(); + let without = RotationStatement::sign( + &doc(1), + &gen_key(2), + &SigningKey::from_bytes(&[3; 32]), + DelegationChain::default(), + ) + .expect("under the unit cap"); + + let signed_and_signature = SIGNED_LEN + 64; + assert_eq!( + with_carriage.encode()[..signed_and_signature], + without.encode()[..signed_and_signature], + "same signed unit" + ); + assert_ne!(with_carriage.digest(), without.digest(), "different items"); + } + mod props { use super::*; diff --git a/onomancy_dnssec/src/statement/successor.rs b/onomancy_dnssec/src/statement/successor.rs index 85e8363..2d29237 100644 --- a/onomancy_dnssec/src/statement/successor.rs +++ b/onomancy_dnssec/src/statement/successor.rs @@ -343,6 +343,44 @@ mod tests { )); } + #[test] + fn trailing_bytes_are_rejected() { + let mut bytes = sample().encode(); + bytes.push(0); + assert!(matches!( + SuccessorStatement::decode(&bytes), + Err(DecodeSuccessorError::Wire(WireError::TrailingBytes { + extra: 1 + })) + )); + } + + /// Each of the three key positions surfaces a non-point as ITS + /// field — the grammar check precedes the signature check. + #[test] + fn non_curve_points_name_their_field() { + let non_point: [u8; 32] = (0u8..=255) + .map(|b| [b; 32]) + .find(|bytes| VerifyingKey::from_bytes(bytes).is_err()) + .expect("some constant fill fails decompression"); + + for (position, field) in [ + (0, FieldName::PredecessorDoc), + (1, FieldName::SuccessorDoc), + (2, FieldName::Signer), + ] { + let mut bytes = sample().encode(); + let at = 4 + position * 32; + bytes[at..at + 32].copy_from_slice(&non_point); + + assert_eq!( + SuccessorStatement::decode(&bytes), + Err(DecodeSuccessorError::NotACurvePoint { field }), + "field {field:?}" + ); + } + } + #[test] fn hostname_tampering_fails_the_signature() { // Same-length, still-canonical hostname swap must be caught by @@ -365,12 +403,24 @@ mod tests { mod props { use super::*; - /// Byte identity: sign → encode → decode → encode. + /// Byte identity: sign → encode → decode → encode. The + /// hostname varies in length so the `hostname_len` varint + /// framing is exercised, not just one fixed width. #[test] fn encode_decode_byte_identity() { bolero::check!() - .with_type::<(u8, u8, u8, Vec>)>() - .for_each(|(a, b, c, blobs)| { + .with_type::<(u8, u8, u8, u8, Vec>)>() + .for_each(|(a, b, c, host_pick, blobs)| { + let hostnames = [ + "a.io", + "expede.wtf", + "deep.sub.domain.example.com", + "xn--80ak6aa92e.com", + ]; + let hostname = + DnsName::parse(hostnames[usize::from(*host_pick) % hostnames.len()]) + .expect("fixture hostnames are valid"); + let authority: DelegationChain = blobs .iter() .cloned() @@ -380,7 +430,7 @@ mod tests { let statement = SuccessorStatement::sign( &doc(*a), &doc(*b), - &host(), + &hostname, &SigningKey::from_bytes(&[*c; 32]), authority.clone(), ) @@ -391,6 +441,7 @@ mod tests { assert_eq!(statement, decoded); assert_eq!(bytes, decoded.encode(), "byte identity"); assert_eq!(decoded.authority(), &authority); + assert_eq!(decoded.hostname(), &hostname); }); } diff --git a/onomancy_dnssec/src/test_utils.rs b/onomancy_dnssec/src/test_utils.rs index 75e41af..1b4aacb 100644 --- a/onomancy_dnssec/src/test_utils.rs +++ b/onomancy_dnssec/src/test_utils.rs @@ -139,8 +139,36 @@ impl Zone { /// /// Panics on empty `RRset`s or oversized RDATA — fixture bugs. #[must_use] - #[allow(clippy::expect_used, clippy::indexing_slicing)] pub fn rrsig_with_labels(&self, rrset: &[Record], window: (u32, u32), labels: usize) -> Record { + self.rrsig_full(rrset, window, labels, self.dnskey().key_tag()) + } + + /// [`rrsig`](Self::rrsig) with an explicit key-tag field — a tag + /// naming a DIFFERENT key produces a signature that is valid over + /// its own preamble (the tag is signed) yet fails under the + /// hinted key, forcing a verifier's all-keys fallback. Key tags + /// are hints, not commitments: they collide legally. + /// + /// # Panics + /// + /// Panics on empty `RRset`s or oversized RDATA — fixture bugs. + #[must_use] + pub fn rrsig_with_key_tag(&self, rrset: &[Record], window: (u32, u32), key_tag: u16) -> Record { + let labels = rrset + .first() + .map_or(0, |record| record.owner.labels().len()); + self.rrsig_full(rrset, window, labels, key_tag) + } + + /// The general RRSIG constructor behind the public variants. + #[allow(clippy::expect_used, clippy::indexing_slicing)] + fn rrsig_full( + &self, + rrset: &[Record], + window: (u32, u32), + labels: usize, + key_tag: u16, + ) -> Record { let owner = &rrset[0].owner; let mut preamble = Vec::new(); @@ -150,7 +178,7 @@ impl Zone { preamble.extend_from_slice(&rrset[0].ttl.to_be_bytes()); preamble.extend_from_slice(&window.1.to_be_bytes()); // expiration preamble.extend_from_slice(&window.0.to_be_bytes()); // inception - preamble.extend_from_slice(&self.dnskey().key_tag().to_be_bytes()); + preamble.extend_from_slice(&key_tag.to_be_bytes()); self.name.write(&mut preamble); let mut rdatas: Vec<&[u8]> = rrset.iter().map(|r| r.rdata.as_slice()).collect(); @@ -222,7 +250,8 @@ pub fn txt_record(hostname: &DnsName, text: &str) -> Record { rdata.extend_from_slice(text.as_bytes()); Record { - owner: Name::onomancy_owner(hostname), + owner: Name::onomancy_owner(hostname) + .expect("fixture hostname fits under the service label"), rtype: RrType::TXT, class: CLASS_IN, ttl: 900, diff --git a/onomancy_dnssec/src/test_utils/fixtures.rs b/onomancy_dnssec/src/test_utils/fixtures.rs index e2a5433..fe26d31 100644 --- a/onomancy_dnssec/src/test_utils/fixtures.rs +++ b/onomancy_dnssec/src/test_utils/fixtures.rs @@ -16,7 +16,9 @@ use onomancy_core::anchor::doc::DocAnchor; use super::{ChainWindows, Zone, binding_chain, link, txt_record, zone}; use crate::{ + crypto::VerifyError, trust_anchor::TrustAnchor, + validator::WalkError, wire::{ name::Name, record::{CLASS_IN, Record}, @@ -36,9 +38,13 @@ pub enum Expectation { /// Validates to a proof carrying [`FIXTURE_SERIAL`]. Binding, - /// MUST fail validation (mutation vectors — including denial-only - /// and wildcard chains, since negative proofs are out at v0). - Invalid, + /// MUST fail validation with EXACTLY this error (mutation vectors + /// — including denial-only and wildcard chains, since negative + /// proofs are out at v0). Pinning the variant keeps each + /// adversarial fixture tied to the rejection branch it was built + /// to exercise: a chain failing for the WRONG reason is a masked + /// dead branch, not a pass. + Invalid(WalkError), } /// The root zone every fixture chains from. @@ -88,18 +94,49 @@ pub fn fixture_txt_text() -> String { pub fn all_fixtures() -> Vec<(&'static str, DnssecChain, Expectation)> { vec![ ("valid_binding", valid_binding(), Expectation::Binding), - ("denial_only", denial_only(), Expectation::Invalid), - ("tampered_leaf", tampered_leaf(), Expectation::Invalid), - ("disjoint_windows", disjoint_windows(), Expectation::Invalid), - ("ds_mismatch", ds_mismatch(), Expectation::Invalid), - ("missing_leaf", missing_leaf(), Expectation::Invalid), - ("wildcard", wildcard(), Expectation::Invalid), + ( + "denial_only", + denial_only(), + Expectation::Invalid(WalkError::MissingLeaf), + ), + ( + "tampered_leaf", + tampered_leaf(), + Expectation::Invalid(WalkError::Verify(VerifyError::BadSignature)), + ), + ( + "disjoint_windows", + disjoint_windows(), + Expectation::Invalid(WalkError::EmptyWindow), + ), + ( + "ds_mismatch", + ds_mismatch(), + Expectation::Invalid(WalkError::DsMismatch), + ), + ( + "missing_leaf", + missing_leaf(), + Expectation::Invalid(WalkError::MissingLeaf), + ), + ( + "wildcard", + wildcard(), + Expectation::Invalid(WalkError::WildcardExpansion), + ), ( "wildcard_with_denial", wildcard_with_denial(), - Expectation::Invalid, + Expectation::Invalid(WalkError::WildcardExpansion), + ), + ( + "misordered_links", + misordered_links(), + Expectation::Invalid(WalkError::UnexpectedLink { + rtype: RrType::DNSKEY, + awaiting_child_keys: false, + }), ), - ("misordered_links", misordered_links(), Expectation::Invalid), ] } diff --git a/onomancy_dnssec/src/trust_anchor.rs b/onomancy_dnssec/src/trust_anchor.rs index 4a45479..cd08af8 100644 --- a/onomancy_dnssec/src/trust_anchor.rs +++ b/onomancy_dnssec/src/trust_anchor.rs @@ -102,10 +102,38 @@ const fn hex_to_bytes(hex: &[u8; 64]) -> [u8; 32] { } #[cfg(test)] -#[allow(clippy::expect_used)] +#[allow(clippy::expect_used, clippy::indexing_slicing)] mod tests { use super::*; - use crate::wire::digest_type::DigestType; + use crate::{crypto::ds_digest, wire::digest_type::DigestType}; + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + + /// The published root KSK public keys, from the root zone DNSKEY + /// `RRset` (flags 257, protocol 3, algorithm 8) — independent + /// data the shipped anchor constants must answer to. + const KSK_2017_KEY: &str = "AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq\ + 7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnI\ + DdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3rCj/EWgvIWgb\ + 9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTId\ + sIXxuOLYA4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qih\ + ylGa8subX2Nn6UwNR1AkUTV74bU="; + + const KSK_2024_KEY: &str = "AwEAAa96jeuknZlaeSrvyAJj6ZHv28hhOKkx3rLGXVaC6rXTsDc449/cidltpkyG\ + wCJNnOAlFNKF2jBosZBU5eeHspaQWOmOElZsjICMQMC3aeHbGiShvZsx4wMYSjH8\ + e7Vrhbu6irwCzVBApESjbUdpWWmEnhathWu1jo+siFUiRAAxm9qyJNg/wOZqqzL/\ + dL/q8PkcRU5oUKEpUge71M3ej2/7CPqpdVwuMoTvoB+ZOT4YeGyxMvHmbrxlFzGO\ + HOijtzN+u1TQNatX2XBuzZNQ1K+s2CXkPIZo7s6JgZyvaBevYtxPvYLw4z9mR7K2\ + vaF18UYH9Z9GNUUeayffKC73PYc="; + + /// The published KSK's DNSKEY RDATA: 257 3 8 . + fn ksk_rdata(key_base64: &str) -> Vec { + let mut rdata = Vec::new(); + rdata.extend_from_slice(&257u16.to_be_bytes()); + rdata.push(3); + rdata.push(Algorithm::RSA_SHA256.code()); + rdata.extend_from_slice(&BASE64.decode(key_base64).expect("published key decodes")); + rdata + } #[test] fn iana_anchors_are_well_formed() { @@ -122,12 +150,101 @@ mod tests { assert_eq!(tags, [20326, 38696]); } + /// The shipped constants answer to the PUBLISHED root keys: each + /// anchor's tag is what RFC 4034 Appendix B computes over the + /// real RDATA, and `matches` accepts the real key at the root. + /// This is the cross-implementation check the self-referential + /// constants cannot provide for themselves. #[test] - fn hex_decoding_roundtrips_known_bytes() { - let decoded = - hex_to_bytes(b"E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D"); - assert_eq!(decoded[0], 0xE0); - assert_eq!(decoded[1], 0x6D); - assert_eq!(decoded[31], 0x8D); + fn anchors_match_the_published_root_ksks() { + let root = Name::from_labels(Vec::new()); + let anchors = iana_root_anchors(); + + for (anchor, key_base64) in anchors.iter().zip([KSK_2017_KEY, KSK_2024_KEY]) { + let key = Dnskey::parse(&ksk_rdata(key_base64)).expect("published RDATA parses"); + assert_eq!(key.key_tag(), anchor.key_tag, "published tag agrees"); + assert!( + anchor.matches(&root, &key), + "anchor {} must commit to its published key", + anchor.key_tag + ); + } + + // And they are not interchangeable: each anchor commits to + // exactly its own key. + let ksk_2024 = Dnskey::parse(&ksk_rdata(KSK_2024_KEY)).expect("parses"); + assert!(!anchors[0].matches(&root, &ksk_2024)); + } + + /// Every conjunct of `matches` bites: right key at the right + /// owner passes; wrong owner, wrong algorithm, and a flipped + /// digest byte each fail on their own. + #[test] + fn matching_requires_zone_algorithm_and_digest_together() { + // An Ed25519 zone key at expede.wtf, anchored by its own DS + // digest (the pattern from crypto::ds_digest_commits…). + let mut key_rdata = Vec::new(); + key_rdata.extend_from_slice(&0x0100u16.to_be_bytes()); + key_rdata.push(3); + key_rdata.push(Algorithm::ED25519.code()); + key_rdata.extend_from_slice(&[7; 32]); + let key = Dnskey::parse(&key_rdata).expect("valid DNSKEY"); + + let owner: Name = "expede.wtf".parse().expect("parses"); + let anchor = TrustAnchor { + algorithm: Algorithm::ED25519, + digest: DsDigest::from(ds_digest(&owner, &key)), + key_tag: key.key_tag(), + zone: owner.clone(), + }; + + assert!(anchor.matches(&owner, &key)); + + // (a) A different owner fails (the digest recommits to it). + let other: Name = "attack.wtf".parse().expect("parses"); + assert!(!anchor.matches(&other, &key)); + + // (b) An algorithm disagreement fails on its own conjunct. + let mismatched = TrustAnchor { + algorithm: Algorithm::RSA_SHA256, + ..anchor.clone() + }; + assert!(!mismatched.matches(&owner, &key)); + + // (c) One flipped digest byte fails. + let mut flipped_bytes = *ds_digest(&owner, &key).as_bytes(); + flipped_bytes[0] ^= 0x01; + let flipped = TrustAnchor { + digest: DsDigest::Sha256(Digest::from_bytes(flipped_bytes)), + ..anchor + }; + assert!(!flipped.matches(&owner, &key)); + } + + mod props { + use super::*; + + /// Round trip through the const hex decoder: uppercase-hex + /// spellings of arbitrary bytes decode back to those bytes. + /// (The `_ => 0` nibble fallback is unreachable from valid + /// hex; the published-KSK cross-check above would catch a + /// corrupted constant that silently decoded through it.) + #[test] + fn hex_decoding_roundtrips_all_byte_values() { + bolero::check!().with_type::<[u8; 32]>().for_each(|bytes| { + // Both digit alphabets: the anchor constants happen to + // be uppercase, but the decoder accepts either case and + // the lowercase arm must not rot. + for digits in [b"0123456789ABCDEF", b"0123456789abcdef"] { + let mut hex = [0u8; 64]; + for (index, byte) in bytes.iter().enumerate() { + hex[index * 2] = digits[usize::from(byte >> 4)]; + hex[index * 2 + 1] = digits[usize::from(byte & 0x0F)]; + } + + assert_eq!(hex_to_bytes(&hex), *bytes); + } + }); + } } } diff --git a/onomancy_dnssec/src/txt/record.rs b/onomancy_dnssec/src/txt/record.rs index dfb9ded..47291a8 100644 --- a/onomancy_dnssec/src/txt/record.rs +++ b/onomancy_dnssec/src/txt/record.rs @@ -619,6 +619,70 @@ mod tests { Ok(()) } + /// 44 unpadded base64 chars decode to 33 bytes: right encoded + /// length, wrong key length — its own error, after base64. + #[test] + fn wrong_key_lengths_are_distinguished_from_bad_base64() { + let good = vector(3, 7, 9).to_string(); + let g_value = good + .split(';') + .nth(3) + .and_then(|f| f.strip_prefix("g=")) + .expect("well-formed vector") + .to_string(); + + let thirty_three_bytes = "A".repeat(44); + let swapped = good.replace(&g_value, &thirty_three_bytes); + assert_eq!( + TxtRecord::parse(&swapped), + Err(ParseTxtRecordError::WrongKeyLength { + field: FieldName::GenerationKey, + got: 33 + }) + ); + } + + /// Canonical base64 of 32 bytes that decompress to no curve + /// point: every earlier check passes, the point check names + /// its field. + #[test] + fn non_curve_points_name_their_field() { + let non_point: [u8; 32] = (0u8..=255) + .map(|b| [b; 32]) + .find(|bytes| VerifyingKey::from_bytes(bytes).is_err()) + .expect("some constant fill fails decompression"); + + let good = vector(3, 7, 9).to_string(); + let p_value = good + .split(';') + .nth(4) + .and_then(|f| f.strip_prefix("p=")) + .expect("well-formed vector") + .to_string(); + + let swapped = good.replace(&p_value, &BASE64.encode(non_point)); + assert_eq!( + TxtRecord::parse(&swapped), + Err(ParseTxtRecordError::NotACurvePoint { + field: FieldName::DocumentId + }) + ); + } + + /// The classify-vs-parse difference: dispositions become + /// errors under `parse`, where anything but a binding is one. + #[test] + fn parse_turns_dispositions_into_errors() { + assert_eq!( + TxtRecord::parse("v=spf1 include:_spf.example.com ~all"), + Err(ParseTxtRecordError::UnknownRecord) + ); + assert_eq!( + TxtRecord::parse("v=ONO1;k=ed25519;n=1;g=x;p=y"), + Err(ParseTxtRecordError::UnknownVersion) + ); + } + #[test] fn oversized_records_are_rejected_before_field_work() { let long = alloc::format!("v=ONO0;{}", "a".repeat(MAX_RECORD_LEN)); diff --git a/onomancy_dnssec/src/txt/serial.rs b/onomancy_dnssec/src/txt/serial.rs index 9b09e30..c7d7337 100644 --- a/onomancy_dnssec/src/txt/serial.rs +++ b/onomancy_dnssec/src/txt/serial.rs @@ -141,5 +141,22 @@ mod tests { assert_eq!(Serial::parse(&rendered), Ok(serial)); }); } + + /// The converse: anything accepted re-renders to exactly its + /// input — one serial, one spelling. + #[test] + fn accepted_spellings_are_canonical() { + bolero::check!() + .with_type::() + .for_each(|raw| { + if let Ok(serial) = Serial::parse(raw) { + assert_eq!( + &alloc::string::ToString::to_string(&serial), + raw, + "parse must only accept the canonical spelling" + ); + } + }); + } } } diff --git a/onomancy_dnssec/src/validator.rs b/onomancy_dnssec/src/validator.rs index 437792c..d4f17b5 100644 --- a/onomancy_dnssec/src/validator.rs +++ b/onomancy_dnssec/src/validator.rs @@ -42,7 +42,13 @@ use crate::{ link::{Link, ParseLinkError}, trust_anchor::TrustAnchor, wire::{ - cname::Cname, dnskey::Dnskey, ds::Ds, name::Name, rr_type::RrType, rrsig::Rrsig, txt::Txt, + cname::Cname, + dnskey::Dnskey, + ds::Ds, + name::{Name, ParseNameError}, + rr_type::RrType, + rrsig::Rrsig, + txt::Txt, }, }; @@ -97,7 +103,7 @@ impl Validator { // Link 0: a DNSKEY RRset, self-signed and anchor-matched. let mut walk = WalkState::enter_anchored(&self.anchors, &root_link)?; - let mut target = Name::onomancy_owner(hostname); + let mut target = Name::onomancy_owner(hostname).map_err(WalkError::UnrepresentableName)?; let mut pending_ds: Option<(Name, Vec)> = None; let mut cname_hops = 0usize; @@ -456,6 +462,12 @@ pub enum WalkError { awaiting_child_keys: bool, }, + /// The hostname cannot be spelled under the `_onomancy.` service + /// label within the 255-octet wire cap (hostnames past 243 + /// presentation octets). + #[error("hostname does not fit under the service label")] + UnrepresentableName(#[source] ParseNameError), + /// A signature-level failure (bad signature, unsupported /// algorithm, non-zone key, …). #[error(transparent)] diff --git a/onomancy_dnssec/src/wire/cname.rs b/onomancy_dnssec/src/wire/cname.rs index 97f7f32..2e02a2a 100644 --- a/onomancy_dnssec/src/wire/cname.rs +++ b/onomancy_dnssec/src/wire/cname.rs @@ -59,6 +59,27 @@ mod tests { let cname = Cname::parse(b"\x03txt\x06expede\x03wtf\x00").expect("parses"); assert_eq!(alloc::format!("{}", cname.target()), "txt.expede.wtf"); - assert!(Cname::parse(b"\x03txt\x06expede\x03wtf\x00X").is_err()); + assert!(matches!( + Cname::parse(b"\x03txt\x06expede\x03wtf\x00X"), + Err(ParseCnameError::Wire(WireError::TrailingBytes { extra: 1 })) + )); + } + + /// Name-level failures arrive through the `Target` variant — the + /// `#[from]` wiring, pinned. + #[test] + fn non_canonical_targets_surface_as_target_errors() { + assert!(matches!( + Cname::parse(b"\xC0\x0C"), + Err(ParseCnameError::Target(ParseNameError::NotCanonical { .. })) + )); + } + + /// A CNAME to the root parses (semantically odd, syntactically + /// legal DNS) — the walk judges targets, not this frame. + #[test] + fn a_root_target_is_frame_legal() { + let cname = Cname::parse(b"\x00").expect("parses"); + assert!(cname.target().is_root()); } } diff --git a/onomancy_dnssec/src/wire/dnskey.rs b/onomancy_dnssec/src/wire/dnskey.rs index d9c4a42..226b915 100644 --- a/onomancy_dnssec/src/wire/dnskey.rs +++ b/onomancy_dnssec/src/wire/dnskey.rs @@ -163,20 +163,73 @@ mod tests { } #[test] - fn key_tag_matches_the_rfc_reference_algorithm() { - // Independent reimplementation of Appendix B as the oracle. - let key = Dnskey::parse(&sample_rdata()).expect("parses"); - let rdata = key.rdata(); - - let mut oracle: u32 = 0; - for pair in rdata.chunks(2) { - oracle += u32::from(pair[0]) << 8; - if let Some(low) = pair.get(1) { - oracle += u32::from(*low); - } - } - oracle += (oracle >> 16) & 0xFFFF; + fn flags_are_independent_bits() { + let with_flags = |flags: u16| { + let mut rdata = sample_rdata(); + rdata[..2].copy_from_slice(&flags.to_be_bytes()); + Dnskey::parse(&rdata).expect("parses") + }; + + let neither = with_flags(0); + assert!(!neither.is_zone_key()); + assert!(!neither.is_sep()); + + let zone_only = with_flags(0x0100); + assert!(zone_only.is_zone_key()); + assert!(!zone_only.is_sep()); + + let sep_only = with_flags(0x0001); + assert!(!sep_only.is_zone_key()); + assert!(sep_only.is_sep()); + } + + #[test] + fn header_only_and_truncated_rdatas_have_their_shapes() { + // A header with no key material parses (an empty public key + // fails later, at verification). + let header_only = Dnskey::parse(&sample_rdata()[..4]).expect("parses"); + assert!(header_only.public_key().is_empty()); - assert_eq!(u32::from(key.key_tag()), oracle & 0xFFFF); + // Less than a header does not. + assert!(matches!( + Dnskey::parse(&sample_rdata()[..3]), + Err(ParseDnskeyError::Truncated(_)) + )); + } + + mod props { + use super::*; + use alloc::vec::Vec; + + /// The key tag matches an independently structured Appendix B + /// oracle over arbitrary key bodies — including odd lengths, + /// where the trailing byte takes the high position. (The + /// shipped IANA anchors pin the published-vector tags 20326 + /// and 38696 in `trust_anchor::tests`.) + #[test] + fn key_tag_matches_the_rfc_reference_algorithm() { + bolero::check!().with_type::>().for_each(|body| { + let mut rdata = Vec::new(); + rdata.extend_from_slice(&0x0100u16.to_be_bytes()); + rdata.push(3); + rdata.push(15); + rdata.extend_from_slice(body); + + let key = Dnskey::parse(&rdata).expect("valid header parses"); + + // Chunk-structured oracle vs the index-parity + // production loop. + let mut oracle: u32 = 0; + for pair in rdata.chunks(2) { + oracle += u32::from(pair[0]) << 8; + if let Some(low) = pair.get(1) { + oracle += u32::from(*low); + } + } + oracle += (oracle >> 16) & 0xFFFF; + + assert_eq!(u32::from(key.key_tag()), oracle & 0xFFFF); + }); + } } } diff --git a/onomancy_dnssec/src/wire/ds.rs b/onomancy_dnssec/src/wire/ds.rs index 19d5b4b..5136bfa 100644 --- a/onomancy_dnssec/src/wire/ds.rs +++ b/onomancy_dnssec/src/wire/ds.rs @@ -109,4 +109,36 @@ mod tests { let rdata = vec![0x00, 0x01, 8, 2]; assert!(matches!(Ds::parse(&rdata), Err(ParseDsError::EmptyDigest))); } + + #[test] + fn truncated_headers_are_rejected() { + assert!(matches!( + Ds::parse(&[0x00, 0x01, 8]), + Err(ParseDsError::Truncated(_)) + )); + } + + /// Parse-don't-validate layering: a SHA-256 DS with a wrong-width + /// digest PARSES here (the wire reflects the wire) and fails + /// downstream at `DsDigest::matches_wire`, which arbitrates. + #[test] + fn wrong_width_digests_parse_then_fail_to_match() { + use crate::crypto::ds_digest::DsDigest; + use onomancy_core::digest::Digest; + + let mut rdata = Vec::new(); + rdata.extend_from_slice(&38696u16.to_be_bytes()); + rdata.push(8); + rdata.push(2); + rdata.extend_from_slice(&[0xCD; 31]); // one byte short + + let ds = Ds::parse(&rdata).expect("the frame is legal DNS"); + assert_eq!(ds.digest().len(), 31); + + let computed = DsDigest::Sha256(Digest::from_bytes([0xCD; 32])); + assert!( + !computed.matches_wire(ds.digest_type(), ds.digest()), + "a 31-byte digest can never equal a 32-byte commitment" + ); + } } diff --git a/onomancy_dnssec/src/wire/name.rs b/onomancy_dnssec/src/wire/name.rs index ac04cef..4544984 100644 --- a/onomancy_dnssec/src/wire/name.rs +++ b/onomancy_dnssec/src/wire/name.rs @@ -90,13 +90,24 @@ impl Name { } /// The `_onomancy.` owner name a binding lives under. - #[must_use] - pub fn onomancy_owner(hostname: &DnsName) -> Self { + /// + /// # Errors + /// + /// Returns [`ParseNameError::NameTooLong`] when the composed name + /// exceeds the 255-octet wire cap: hostname grammar admits up to + /// 253 presentation octets, but the ten-octet service label + /// leaves room for only 243. + pub fn onomancy_owner(hostname: &DnsName) -> Result { let mut labels: Vec> = Vec::with_capacity(1); labels.push(SERVICE_LABEL.to_vec()); labels.extend(hostname.as_str().split('.').map(|l| l.as_bytes().to_vec())); - Self { labels } + let wire_len = 1 + labels.iter().map(|l| 1 + l.len()).sum::(); + if wire_len > MAX_WIRE_LEN { + return Err(ParseNameError::NameTooLong); + } + + Ok(Self { labels }) } /// Build a name from raw labels — crate-internal, for callers @@ -288,10 +299,33 @@ mod tests { #[test] fn onomancy_owner_prepends_the_service_label() { let hostname = DnsName::parse("expede.wtf").expect("valid"); - let owner = Name::onomancy_owner(&hostname); + let owner = Name::onomancy_owner(&hostname).expect("fits under the cap"); assert_eq!(format!("{owner}"), "_onomancy.expede.wtf"); } + /// `onomancy_owner`'s cap is exact: a 243-octet hostname wires + /// to precisely 255 with the ten-octet service label and + /// composes; one more octet is refused. + #[test] + fn onomancy_owner_cap_is_exact() { + let hostname_of = |last: usize| { + let a = "a".repeat(63); + DnsName::parse(&alloc::format!("{a}.{a}.{a}.{}", "a".repeat(last))).expect("valid") + }; + + let at_cap = hostname_of(51); + assert_eq!(at_cap.as_str().len(), 243); + let owner = Name::onomancy_owner(&at_cap).expect("exactly the cap composes"); + let mut wire = Vec::new(); + owner.write(&mut wire); + assert_eq!(wire.len(), MAX_WIRE_LEN); + + assert!(matches!( + Name::onomancy_owner(&hostname_of(52)), + Err(ParseNameError::NameTooLong) + )); + } + #[test] fn compression_pointers_are_rejected() { assert!(matches!( @@ -308,6 +342,72 @@ mod tests { )); } + /// A length byte of 64–127 (`0b01xx_xxxx`) is a reserved label + /// type and dies at the pointer-mask check — which makes + /// `LabelTooLong` UNREACHABLE from `read`: every length > 63 has + /// bit 6 or 7 set. This test documents the actual behavior so the + /// dead branch is a recorded fact, not a surprise. + #[test] + fn length_sixty_four_is_a_reserved_label_type_not_a_long_label() { + let mut bytes = alloc::vec![64u8]; + bytes.extend_from_slice(&[b'a'; 64]); + bytes.push(0); + + assert!(matches!( + read_all(&bytes), + Err(ParseNameError::NotCanonical { .. }) + )); + } + + /// The 255-octet wire cap, both directions: labels 63+63+63+61 + /// wire to exactly 255 and read; 63+63+63+62 wire to 256 and are + /// rejected mid-read. + #[test] + fn the_wire_length_cap_is_exact() { + let wire_of = |label_lens: &[u8]| { + let mut bytes = Vec::new(); + for &len in label_lens { + bytes.push(len); + bytes.extend(core::iter::repeat_n(b'a', usize::from(len))); + } + bytes.push(0); + bytes + }; + + let at_cap = wire_of(&[63, 63, 63, 61]); + assert_eq!(at_cap.len(), 255); + let name = read_all(&at_cap).expect("exactly the cap reads"); + assert_eq!(name.labels().len(), 4); + + let over = wire_of(&[63, 63, 63, 62]); + assert_eq!(over.len(), 256); + assert!(matches!(read_all(&over), Err(ParseNameError::NameTooLong))); + } + + #[test] + fn text_parsing_rejects_what_the_wire_would() { + assert!(matches!( + "a..b".parse::(), + Err(ParseNameError::LabelTooLong { len: 0 }) + )); + assert!(matches!( + "EXPEDE.wtf".parse::(), + Err(ParseNameError::NotCanonical { .. }) + )); + assert!(matches!( + alloc::format!("{}.com", "a".repeat(64)).parse::(), + Err(ParseNameError::LabelTooLong { len: 64 }) + )); + } + + /// Non-printable label bytes render as RFC 1035 `\DDD` escapes — + /// diagnostics never smuggle raw control bytes into logs. + #[test] + fn display_escapes_non_printable_bytes() { + let name = Name::from_labels(alloc::vec![alloc::vec![0x07, b'a', b'.']]); + assert_eq!(format!("{name}"), "\\007a\\046"); + } + #[test] fn root_is_a_lone_zero_byte() { let root = read_all(b"\x00").expect("root"); @@ -361,6 +461,51 @@ mod tests { }); } + /// `onomancy_owner` composes names that survive their own + /// wire trip whenever it composes at all; hostnames past 243 + /// octets (255-octet cap minus the ten-octet service label) + /// are refused at construction rather than written as bytes + /// `read` would reject. + #[test] + fn owner_names_roundtrip_for_hostnames_that_fit() { + bolero::check!() + .with_type::>>() + .for_each(|label_seeds| { + let labels: Vec = label_seeds + .iter() + .take(4) + .map(|seed| { + seed.iter() + .take(40) + .map(|b| char::from(b'a' + (b % 26))) + .collect::() + }) + .filter(|l| !l.is_empty()) + .collect(); + + if labels.len() < 2 { + return; + } + + let Ok(hostname) = DnsName::parse(&labels.join(".")) else { + return; + }; + + match Name::onomancy_owner(&hostname) { + Ok(owner) => { + let mut wire = Vec::new(); + owner.write(&mut wire); + assert!(wire.len() <= MAX_WIRE_LEN, "constructed over cap"); + assert_eq!(read_all(&wire).expect("own encoding"), owner); + } + Err(refusal) => { + assert_eq!(refusal, ParseNameError::NameTooLong); + assert!(hostname.as_str().len() > 243, "refused a fitting name"); + } + } + }); + } + /// Canonical ordering is a total order consistent with /// equality, and ancestry agrees with it (an ancestor never /// sorts after its descendant). diff --git a/onomancy_dnssec/src/wire/record.rs b/onomancy_dnssec/src/wire/record.rs index 5771fb0..870e3b6 100644 --- a/onomancy_dnssec/src/wire/record.rs +++ b/onomancy_dnssec/src/wire/record.rs @@ -145,10 +145,22 @@ mod tests { )); } + /// Oversized RDATA on write is a caller bug: the length clamps to + /// `u16::MAX` and the frame no longer roundtrips. Pinned so the + /// documented behavior is a recorded fact (unreachable via `read`, + /// which is u16-framed — the roundtrip property cannot see this). #[test] - fn type_display_names_the_seven() { - assert_eq!(alloc::format!("{}", RrType::DNSKEY), "DNSKEY"); - assert_eq!(alloc::format!("{}", RrType::new(65280)), "TYPE65280"); + fn oversized_rdata_writes_a_clamped_corrupt_frame() { + let mut record = sample(); + record.rdata = vec![0xAB; usize::from(u16::MAX) + 5]; + + let mut buf = Vec::new(); + record.write(&mut buf); + + let mut reader = Reader::new(&buf).expect("under cap"); + let reread = Record::read(&mut reader).expect("frame parses"); + assert_eq!(reread.rdata.len(), usize::from(u16::MAX), "clamped"); + assert_eq!(reader.remaining(), 5, "the tail is orphaned"); } mod props { diff --git a/onomancy_dnssec/src/wire/rr_type.rs b/onomancy_dnssec/src/wire/rr_type.rs index ffdf6e0..651de83 100644 --- a/onomancy_dnssec/src/wire/rr_type.rs +++ b/onomancy_dnssec/src/wire/rr_type.rs @@ -56,3 +56,26 @@ impl fmt::Display for RrType { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// All seven named types plus the RFC 3597 unknown-type fallback + /// — log-scrapers key on these strings. + #[test] + fn display_names_the_seven_and_numbers_the_rest() { + for (rtype, expected) in [ + (RrType::CNAME, "CNAME"), + (RrType::DNSKEY, "DNSKEY"), + (RrType::DS, "DS"), + (RrType::NSEC, "NSEC"), + (RrType::NSEC3, "NSEC3"), + (RrType::RRSIG, "RRSIG"), + (RrType::TXT, "TXT"), + (RrType::new(65280), "TYPE65280"), + ] { + assert_eq!(alloc::format!("{rtype}"), expected); + } + } +} diff --git a/onomancy_dnssec/src/wire/rrsig.rs b/onomancy_dnssec/src/wire/rrsig.rs index c9811eb..93c1bb9 100644 --- a/onomancy_dnssec/src/wire/rrsig.rs +++ b/onomancy_dnssec/src/wire/rrsig.rs @@ -340,10 +340,24 @@ mod tests { assert!(rrsig.window().is_err(), "never had joint validity"); } + /// Every truncation point inside the preamble is rejected, and + /// the preamble alone parses (an empty signature is a frame-level + /// fact; verification rejects it later). Exhaustive over the + /// whole boundary, not one hardcoded cut. #[test] - fn truncation_is_rejected() { + fn every_preamble_truncation_is_rejected() { let rdata = sample_rdata(); - assert!(Rrsig::parse(&rdata[..10]).is_err()); + let preamble_len = rdata.len() - 64; + + for cut in 0..preamble_len { + assert!( + Rrsig::parse(&rdata[..cut]).is_err(), + "cut at {cut} of {preamble_len} must not parse" + ); + } + + let headless = Rrsig::parse(&rdata[..preamble_len]).expect("preamble alone parses"); + assert!(headless.signature().is_empty()); } mod props { @@ -503,6 +517,54 @@ mod tests { )); } + /// A key claiming a different algorithm than the RRSIG is a + /// mismatch, checked before any crypto — the guard against + /// verifying an Ed25519 signature with an RSA key blob. + #[test] + fn mismatched_key_algorithms_are_rejected_before_crypto() { + let (link, key) = signed_link(&[b"\x04test"], "_onomancy.expede.wtf", 3); + + let mut rdata = key.rdata().to_vec(); + rdata[3] = Algorithm::ECDSA_P256_SHA256.code(); + let reflagged = Dnskey::parse(&rdata).expect("parses"); + + assert_eq!( + link.signatures()[0].verify(&link, &reflagged), + Err(VerifyError::AlgorithmMismatch { + key: Algorithm::ECDSA_P256_SHA256, + signature: Algorithm::ED25519 + }) + ); + } + + /// RFC 4034 §6.3: duplicates are dropped from the canonical + /// `RRset`. A link framing the same RDATA twice must verify against + /// a signature computed over the deduped set — deleting the + /// `dedup()` in `signed_data` fails this. + #[test] + fn duplicate_rdatas_are_dropped_from_the_signed_data() { + let (link, key) = signed_link( + &[b"\x04test", b"\x04test", b"\x02aa"], + "_onomancy.expede.wtf", + 3, + ); + assert_eq!(link.rrset().len(), 3, "the frame carries all three"); + + link.signatures()[0] + .verify(&link, &key) + .expect("duplicates drop before signing"); + } + + /// The wildcard reconstruction edge where zero labels were + /// signed: the source is `*` at the root. + #[test] + fn a_zero_label_signature_reconstructs_the_root_wildcard() { + let (link, key) = signed_link(&[b"\x04test"], "_onomancy.expede.wtf", 0); + link.signatures()[0] + .verify(&link, &key) + .expect("root wildcard reconstruction"); + } + #[test] fn non_zone_keys_never_verify() { let (link, key) = signed_link(&[b"\x04test"], "_onomancy.expede.wtf", 3); diff --git a/onomancy_dnssec/src/wire/txt.rs b/onomancy_dnssec/src/wire/txt.rs index a031890..d63a161 100644 --- a/onomancy_dnssec/src/wire/txt.rs +++ b/onomancy_dnssec/src/wire/txt.rs @@ -104,6 +104,15 @@ mod tests { assert!(matches!(Txt::parse(&[]), Err(ParseTxtError::Empty))); } + /// A single zero-length character string is a valid, empty TXT — + /// distinct from empty RDATA. + #[test] + fn a_zero_length_string_is_valid_and_empty() { + let txt = Txt::parse(&[0]).expect("parses"); + assert_eq!(txt.strings(), &[Vec::new()]); + assert!(txt.concatenated().is_empty()); + } + mod props { use super::*; diff --git a/onomancy_dnssec/src/zone_state_key.rs b/onomancy_dnssec/src/zone_state_key.rs index 5dc89ad..3777a8b 100644 --- a/onomancy_dnssec/src/zone_state_key.rs +++ b/onomancy_dnssec/src/zone_state_key.rs @@ -68,6 +68,17 @@ mod tests { assert!(key(1, 1, 2) > key(1, 1, 1)); } + /// The equivocation projection really excludes `issued_at`: two + /// keys differing only there are zone-vouched equals. + #[test] + fn zone_vouched_projects_away_the_signer_claimed_field() { + assert_eq!( + key(7, 3, 100).zone_vouched(), + (UnixSeconds::from(7), Serial::from(3)) + ); + assert_eq!(key(7, 3, 100).zone_vouched(), key(7, 3, 999).zone_vouched()); + } + mod props { use super::*; diff --git a/onomancy_dnssec/tests/fixtures.rs b/onomancy_dnssec/tests/fixtures.rs index b957711..6975135 100644 --- a/onomancy_dnssec/tests/fixtures.rs +++ b/onomancy_dnssec/tests/fixtures.rs @@ -1,10 +1,11 @@ //! The checked-in fixtures still mean what the catalog says they -#![allow(clippy::panic, clippy::indexing_slicing)] //! mean: every `tests/fixtures/*.chain` file is read back and //! validated, and its outcome must match its declared //! [`Expectation`]. Also pins byte-stability: regenerating the //! catalog in-process must reproduce the committed bytes exactly. +#![allow(clippy::panic, clippy::indexing_slicing)] + use std::{fs, path::PathBuf}; use onomancy_dnssec::{ @@ -24,6 +25,36 @@ fn fixture_path(name: &str) -> PathBuf { .join(format!("{name}.chain")) } +/// The catalog names every committed synthetic fixture. Without +/// this, an empty (or shrunken) catalog passes the other tests +/// vacuously — they iterate whatever `all_fixtures` returns. The +/// `real_*` captures are excluded: they are production snapshots +/// owned by `real_world.rs`, not catalog-generated. +#[test] +fn the_catalog_covers_every_committed_fixture() -> TestResult { + let fixtures_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let mut committed: Vec = fs::read_dir(fixtures_dir)? + .filter_map(|entry| { + let file_name = entry.ok()?.file_name().into_string().ok()?; + let stem = file_name.strip_suffix(".chain")?; + (!stem.starts_with("real_")).then(|| stem.to_owned()) + }) + .collect(); + committed.sort_unstable(); + + let mut cataloged: Vec = all_fixtures() + .iter() + .map(|(name, _, _)| (*name).to_owned()) + .collect(); + cataloged.sort_unstable(); + + assert_eq!( + cataloged, committed, + "the fixture catalog and tests/fixtures/*.chain drifted apart" + ); + Ok(()) +} + #[test] fn committed_bytes_match_the_catalog() -> TestResult { for (name, chain, _) in all_fixtures() { @@ -63,8 +94,15 @@ fn every_fixture_produces_its_declared_outcome() -> TestResult { ); } - Expectation::Invalid => { - assert!(outcome.is_err(), "{name}: mutation vector MUST fail"); + Expectation::Invalid(expected) => { + // The EXACT variant: an adversarial fixture failing + // for the wrong reason means the rejection branch it + // was built to exercise has gone dead. + assert_eq!( + outcome, + Err(expected), + "{name}: mutation vector MUST fail with its declared error" + ); } } } diff --git a/onomancy_dnssec/tests/golden_vectors.rs b/onomancy_dnssec/tests/golden_vectors.rs index a4fe005..7584153 100644 --- a/onomancy_dnssec/tests/golden_vectors.rs +++ b/onomancy_dnssec/tests/golden_vectors.rs @@ -6,7 +6,7 @@ //! deliberately with: //! //! ```sh -//! cargo run -p onomancy_core --example generate_vectors +//! cargo run -p onomancy_dnssec --example generate_vectors //! ``` #![allow(clippy::expect_used, clippy::indexing_slicing, clippy::panic)] @@ -22,7 +22,10 @@ use onomancy_core::{ }; use onomancy_dnssec::{ certificate::{Certificate, DecodeCertificateError}, - statement::{rotation::RotationStatement, successor::SuccessorStatement}, + statement::{ + rotation::{DecodeRotationError, RotationStatement}, + successor::{DecodeSuccessorError, SuccessorStatement}, + }, }; use testresult::TestResult; use vectors_catalog::{Expect, Vector, from_hex, to_hex, vectors}; @@ -119,7 +122,10 @@ fn reject_vectors_fail_at_decode() { "{}: expected a hostname canonicality error, got {result:?}", vector.name ), - other => assert!(result.is_err(), "{other}: reject vector decoded"), + // Force every future reject vector to pin its variant + // here — an unnamed vector asserting only is_err() could + // fail for the wrong reason without anyone noticing. + other => panic!("reject vector {other} has no variant assertion — add one"), } } } @@ -140,6 +146,8 @@ fn cross_tag_confusion_fails_on_the_tag() { let rotation = by_name("rotation_valid"); let successor = by_name("successor_valid"); + // All six pairings fail ON THE TAG — not on some incidental later + // field — so type confusion is caught at the front door. assert!(matches!( Certificate::decode(&rotation), Err(DecodeCertificateError::Malformed( @@ -152,20 +160,40 @@ fn cross_tag_confusion_fails_on_the_tag() { Malformed::WrongTag { .. } )) )); - assert!(RotationStatement::decode(&cert).is_err()); - assert!(RotationStatement::decode(&successor).is_err()); - assert!(SuccessorStatement::decode(&cert).is_err()); - assert!(SuccessorStatement::decode(&rotation).is_err()); + assert!(matches!( + RotationStatement::decode(&cert), + Err(DecodeRotationError::Malformed(Malformed::WrongTag { .. })) + )); + assert!(matches!( + RotationStatement::decode(&successor), + Err(DecodeRotationError::Malformed(Malformed::WrongTag { .. })) + )); + assert!(matches!( + SuccessorStatement::decode(&cert), + Err(DecodeSuccessorError::Malformed(Malformed::WrongTag { .. })) + )); + assert!(matches!( + SuccessorStatement::decode(&rotation), + Err(DecodeSuccessorError::Malformed(Malformed::WrongTag { .. })) + )); } /// The re-attach pair: one certificate identity (same signed region), -/// two content hashes (the attached region is hashed too). +/// two content hashes (the attached region is hashed too). Decoded +/// from the CHECKED-IN bytes, so the semantic claim is pinned to the +/// committed files, not just the in-process catalog. #[test] fn reattach_pair_shares_identity_but_not_hash() -> TestResult { - let (a, b) = vectors_catalog::reattach_pair(); + let by_name = |name: &str| -> Vec { + let vector = vectors() + .into_iter() + .find(|v| v.name == name) + .expect("vector in catalog"); + checked_in(&vector) + }; - let a = Certificate::decode(&a.encode())?; - let b = Certificate::decode(&b.encode())?; + let a = Certificate::decode(&by_name("cert_reattach_a"))?; + let b = Certificate::decode(&by_name("cert_reattach_b"))?; assert!(a.same_certificate(&b), "same signed region"); assert_ne!( diff --git a/onomancy_dnssec/tests/real_world.rs b/onomancy_dnssec/tests/real_world.rs index ae63aed..4303618 100644 --- a/onomancy_dnssec/tests/real_world.rs +++ b/onomancy_dnssec/tests/real_world.rs @@ -1,5 +1,5 @@ //! A real production chain, frozen: `_onomancy.brooklynzelenka.com` -//! as fetched from live DNS on 2026-08-20, captured with: +//! as fetched from live DNS on 2026-08-20 (the first Onomancy-bound //! name), captured with: //! //! ```sh diff --git a/onomancy_dnssec/tests/replay.rs b/onomancy_dnssec/tests/replay.rs index cbcf603..f10ed5c 100644 --- a/onomancy_dnssec/tests/replay.rs +++ b/onomancy_dnssec/tests/replay.rs @@ -54,6 +54,29 @@ fn real_binding( serial: u64, window: (u32, u32), issued_at: u64, +) -> TestResult { + real_binding_with_windows( + doc_seed, + gen_seed, + serial, + ChainWindows::uniform(window), + window, + issued_at, + ) +} + +/// [`real_binding`] with PER-LINK windows: `declared` must be the +/// hand-computed ∩ — which the real walk has to reproduce for the +/// fake/real derivations to agree, making the differential bite on +/// the window arithmetic rather than trivially echoing a uniform +/// window. +fn real_binding_with_windows( + doc_seed: u8, + gen_seed: u8, + serial: u64, + windows: ChainWindows, + declared: (u32, u32), + issued_at: u64, ) -> TestResult { let hostname = proto_utils::host(); let record = TxtRecord::new( @@ -66,7 +89,7 @@ fn real_binding( &fixtures::fixture_root(), &fixtures::fixture_child(), txt_record(&hostname, &record.to_string()), - ChainWindows::uniform(window), + windows, ); let cert = Certificate::sign( @@ -86,8 +109,8 @@ fn real_binding( let proof = ChainProof { records: vec![record], window: ValidityWindow::new( - UnixSeconds::from(u64::from(window.0)), - UnixSeconds::from(u64::from(window.1)), + UnixSeconds::from(u64::from(declared.0)), + UnixSeconds::from(u64::from(declared.1)), )?, }; @@ -165,6 +188,39 @@ fn fresh_binding_derives_identically() -> TestResult { Ok(()) } +#[test] +fn non_uniform_windows_derive_identically() -> TestResult { + // Distinct per-link windows: the declared proof carries the + // HAND-computed ∩ (max inception 1_754_900_000, min expiration + // 1_755_800_000, both from different links), so fake/real equality + // holds only if the real walk's window arithmetic reproduces it. + let binding = real_binding_with_windows( + 1, + 11, + 100, + ChainWindows { + root: (1_754_000_000, 1_756_000_000), + delegation: (1_754_500_000, 1_755_800_000), + child: (1_754_200_000, 1_756_500_000), + leaf: (1_754_900_000, 1_756_200_000), + }, + (1_754_900_000, 1_755_800_000), + 50, + )?; + + let derivation = derive_both_ways(&[&binding], &Decisions::default(), vec![]); + let state = derivation + .bindings + .get(&proto_utils::host()) + .cloned() + .unwrap_or_default(); + + let accepted = state.accepted.ok_or("expected an accepted binding")?; + assert_eq!(accepted.document, proto_utils::doc(1)); + assert_eq!(state.effective_serial, Some(Serial::from(100))); + Ok(()) +} + #[test] fn b1_pending_challenger_derives_identically() -> TestResult { // Acceptance-backed incumbent, stale unproven challenger with a diff --git a/onomancy_dnssec/tests/vectors/README.md b/onomancy_dnssec/tests/vectors/README.md index 2598608..cbae7c0 100644 --- a/onomancy_dnssec/tests/vectors/README.md +++ b/onomancy_dnssec/tests/vectors/README.md @@ -1,6 +1,6 @@ # Golden Vectors -Checked-in wire-format vectors mandated by [specs/serialization.md § Test Vectors](../../../specs/serialization.md#test-vectors). Each `{name}.hex` file holds one unit's canonical bytes (lowercase hex); `digests.txt` records the content hash of every unit that decodes. The catalog — the single source of truth — lives in [`tests/vectors_catalog.rs`](../vectors_catalog.rs), and [`tests/golden_vectors.rs`](../golden_vectors.rs) replays everything on every test run. +Checked-in wire-format vectors mandated by [specs/serialization.md § Test Vectors](../../../specs/serialization.md#test-vectors). Each `{name}.hex` file holds one unit's canonical bytes (lowercase hex); `digests.txt` records the content hash of every unit that decodes. The catalog — the single source of truth — lives in [`tests/support/vectors_catalog.rs`](../support/vectors_catalog.rs), and [`tests/golden_vectors.rs`](../golden_vectors.rs) replays everything on every test run. > [!WARNING] > Byte drift here is a wire-format break, not a refactor. Canonical @@ -11,7 +11,7 @@ Checked-in wire-format vectors mandated by [specs/serialization.md § Test Vecto Regenerate with: ```sh -cargo run -p onomancy_core --example generate_vectors +cargo run -p onomancy_dnssec --example generate_vectors ``` ## Status diff --git a/onomancy_dnssec/tests/walk.rs b/onomancy_dnssec/tests/walk.rs index 7b16759..a957539 100644 --- a/onomancy_dnssec/tests/walk.rs +++ b/onomancy_dnssec/tests/walk.rs @@ -2,12 +2,13 @@ //! (root → expede.wtf → `_onomancy` leaf), signed with real Ed25519 //! keys via `test_utils`. -#![allow(clippy::indexing_slicing, clippy::panic)] +#![allow(clippy::expect_used, clippy::indexing_slicing, clippy::panic)] use onomancy_core::time::UnixSeconds; use onomancy_dnssec::{ chain::{ChainLink, DnssecChain}, - chain_proof::{ChainProof, ChainValidator as _}, + chain_proof::{ChainProof, ChainValidator as _, InvalidChain}, + crypto::VerifyError, dns_name::DnsName, txt::serial::Serial, }; @@ -15,9 +16,9 @@ use testresult::TestResult; use onomancy_dnssec::{ test_utils::{ChainWindows, Zone, binding_chain, fixtures, link, txt_record, zone}, - validator::{Validator, WalkError}, + validator::{MAX_CNAME_HOPS, Validator, WalkError}, wire::{ - name::Name, + name::{Name, ParseNameError}, record::{CLASS_IN, Record}, rr_type::RrType, }, @@ -78,22 +79,21 @@ fn unanchored_roots_are_rejected() -> TestResult { } #[test] -fn a_tampered_leaf_fails_the_walk() -> TestResult { +fn hostnames_too_long_for_the_service_label_are_unrepresentable() -> TestResult { + // 63 + 63 + 63 + 52 octets and three dots: 244 presentation + // octets — legal hostname grammar (≤ 253), but `_onomancy.` + // leaves wire room for only 243. + let long = format!("{a}.{a}.{a}.{b}", a = "a".repeat(63), b = "b".repeat(52)); + let hostname = DnsName::parse(&long)?; + let root = zone(".", 1); let child = zone("expede.wtf", 2); - let chain = happy_chain(&root, &child)?; - - // Flip one byte inside the TXT link's RDATA region. - let mut links: Vec = chain.links().to_vec(); - let mut leaf_bytes = links[3].as_bytes().to_vec(); - let at = leaf_bytes.len() / 2; - leaf_bytes[at] ^= 0x01; - links[3] = ChainLink::from(leaf_bytes); - let validator = Validator::new(vec![root.anchor()]); - let outcome = validator.validate_detailed(&hostname()?, &DnssecChain::from(links)); - assert!(outcome.is_err(), "tampered leaf must not validate"); + assert_eq!( + validator.validate_detailed(&hostname, &happy_chain(&root, &child)?), + Err(WalkError::UnrepresentableName(ParseNameError::NameTooLong)) + ); Ok(()) } @@ -204,17 +204,34 @@ fn denial_only_chains_prove_nothing() -> TestResult { fn the_seam_collapses_detail_to_invalid_chain() -> TestResult { let root = zone(".", 1); let child = zone("expede.wtf", 2); - let validator = Validator::new(vec![root.anchor()]); - assert!( + + // The seam is pure delegation: the SAME proof on the Ok side (a + // seam impl returning a wrong-but-Ok proof would be a live bug), + // and detail collapsed to the unit error on the Err side. + let chain = happy_chain(&root, &child)?; + assert_eq!( + validator.validate(&hostname()?, &chain), validator - .validate(&hostname()?, &happy_chain(&root, &child)?) - .is_ok() + .validate_detailed(&hostname()?, &chain) + .map_err(|_| InvalidChain) ); - assert!( - validator - .validate(&hostname()?, &DnssecChain::default()) - .is_err() + + assert_eq!( + validator.validate(&hostname()?, &DnssecChain::default()), + Err(InvalidChain) + ); + Ok(()) +} + +#[test] +fn an_empty_chain_is_empty_not_merely_invalid() -> TestResult { + let root = zone(".", 1); + let validator = Validator::new(vec![root.anchor()]); + + assert_eq!( + validator.validate_detailed(&hostname()?, &DnssecChain::default()), + Err(WalkError::Empty) ); Ok(()) } @@ -244,7 +261,7 @@ fn a_cross_zone_cname_reroots_and_walks_to_the_target_branch() -> TestResult { let validator = Validator::new(vec![root.anchor()]); let target_owner: Name = "binding.example.net".parse()?; - let cname = cname_record(Name::onomancy_owner(&hostname()?), &target_owner); + let cname = cname_record(Name::onomancy_owner(&hostname()?)?, &target_owner); let txt = Record { owner: target_owner, ..txt_record(&hostname()?, &fixtures::fixture_txt_text()) @@ -252,31 +269,39 @@ fn a_cross_zone_cname_reroots_and_walks_to_the_target_branch() -> TestResult { let source_ds = Zone::ds_record_for(&source); let target_ds = Zone::ds_record_for(&target_zone); - let window = (1_000, 5_000); + // Distinct windows on both sides of the re-root: the ∩ must keep + // accumulating across branches (the doc comment's claim, tested). let chain = DnssecChain::from(vec![ link(&[ root.dnskey_record.clone(), - root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + root.rrsig(core::slice::from_ref(&root.dnskey_record), (1_000, 5_000)), ]), - link(&[source_ds.clone(), root.rrsig(&[source_ds], window)]), + link(&[source_ds.clone(), root.rrsig(&[source_ds], (1_100, 5_500))]), link(&[ source.dnskey_record.clone(), - source.rrsig(core::slice::from_ref(&source.dnskey_record), window), + source.rrsig(core::slice::from_ref(&source.dnskey_record), (1_050, 4_800)), ]), // The hop out of the subtree, signed by the source zone… - link(&[cname.clone(), source.rrsig(&[cname], window)]), + link(&[cname.clone(), source.rrsig(&[cname], (1_200, 6_000))]), // …then the target's branch, descended from the ROOT again. - link(&[target_ds.clone(), root.rrsig(&[target_ds], window)]), + link(&[target_ds.clone(), root.rrsig(&[target_ds], (1_500, 4_400))]), link(&[ target_zone.dnskey_record.clone(), - target_zone.rrsig(core::slice::from_ref(&target_zone.dnskey_record), window), + target_zone.rrsig( + core::slice::from_ref(&target_zone.dnskey_record), + (1_300, 4_200), + ), ]), - link(&[txt.clone(), target_zone.rrsig(&[txt], window)]), + link(&[txt.clone(), target_zone.rrsig(&[txt], (1_400, 4_300))]), ]); - let ChainProof { records, .. } = validator.validate_detailed(&hostname()?, &chain)?; + let ChainProof { records, window } = validator.validate_detailed(&hostname()?, &chain)?; assert_eq!(records.len(), 1); + assert_eq!(records[0].serial(), Serial::from(fixtures::FIXTURE_SERIAL)); + // ∩ across BOTH branches: max inception 1500, min expiration 4200. + assert_eq!(window.inception(), UnixSeconds::from(1_500)); + assert_eq!(window.expiration(), UnixSeconds::from(4_200)); Ok(()) } @@ -294,7 +319,7 @@ fn an_in_zone_cname_descends_a_deeper_cut_without_rerooting() -> TestResult { let validator = Validator::new(vec![root.anchor()]); let target_owner: Name = "binding.certs.expede.wtf".parse()?; - let cname = cname_record(Name::onomancy_owner(&hostname()?), &target_owner); + let cname = cname_record(Name::onomancy_owner(&hostname()?)?, &target_owner); let txt = Record { owner: target_owner, ..txt_record(&hostname()?, &fixtures::fixture_txt_text()) @@ -302,32 +327,40 @@ fn an_in_zone_cname_descends_a_deeper_cut_without_rerooting() -> TestResult { let source_ds = Zone::ds_record_for(&source); let deeper_ds = Zone::ds_record_for(&deeper); - let window = (1_000, 5_000); + // Distinct per-link windows: the ∩ must keep accumulating through + // the deeper cut. let chain = DnssecChain::from(vec![ link(&[ root.dnskey_record.clone(), - root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + root.rrsig(core::slice::from_ref(&root.dnskey_record), (1_000, 5_000)), ]), - link(&[source_ds.clone(), root.rrsig(&[source_ds], window)]), + link(&[source_ds.clone(), root.rrsig(&[source_ds], (1_100, 5_500))]), link(&[ source.dnskey_record.clone(), - source.rrsig(core::slice::from_ref(&source.dnskey_record), window), + source.rrsig(core::slice::from_ref(&source.dnskey_record), (1_050, 4_800)), ]), // The in-zone hop, signed by the source zone… - link(&[cname.clone(), source.rrsig(&[cname], window)]), + link(&[cname.clone(), source.rrsig(&[cname], (1_200, 6_000))]), // …then the deeper cut, its DS signed by the SOURCE zone (no // re-root: the source's links are not repeated). - link(&[deeper_ds.clone(), source.rrsig(&[deeper_ds], window)]), + link(&[ + deeper_ds.clone(), + source.rrsig(&[deeper_ds], (1_600, 4_600)), + ]), link(&[ deeper.dnskey_record.clone(), - deeper.rrsig(core::slice::from_ref(&deeper.dnskey_record), window), + deeper.rrsig(core::slice::from_ref(&deeper.dnskey_record), (1_300, 4_100)), ]), - link(&[txt.clone(), deeper.rrsig(&[txt], window)]), + link(&[txt.clone(), deeper.rrsig(&[txt], (1_400, 4_300))]), ]); - let ChainProof { records, .. } = validator.validate_detailed(&hostname()?, &chain)?; + let ChainProof { records, window } = validator.validate_detailed(&hostname()?, &chain)?; assert_eq!(records.len(), 1); + assert_eq!(records[0].serial(), Serial::from(fixtures::FIXTURE_SERIAL)); + // ∩ through the deeper cut: max inception 1600, min expiration 4100. + assert_eq!(window.inception(), UnixSeconds::from(1_600)); + assert_eq!(window.expiration(), UnixSeconds::from(4_100)); Ok(()) } @@ -341,7 +374,7 @@ fn an_in_zone_cname_to_a_deeper_cut_without_its_links_is_rejected() -> TestResul let validator = Validator::new(vec![root.anchor()]); let target_owner: Name = "binding.certs.expede.wtf".parse()?; - let cname = cname_record(Name::onomancy_owner(&hostname()?), &target_owner); + let cname = cname_record(Name::onomancy_owner(&hostname()?)?, &target_owner); let txt = Record { owner: target_owner, ..txt_record(&hostname()?, &fixtures::fixture_txt_text()) @@ -366,7 +399,13 @@ fn an_in_zone_cname_to_a_deeper_cut_without_its_links_is_rejected() -> TestResul link(&[txt.clone(), deeper.rrsig(&[txt], window)]), ]); - assert!(validator.validate_detailed(&hostname()?, &chain).is_err()); + // Exactly SignerMismatch: the walk still trusts expede.wtf's keys, + // and the RRSIG names certs.expede.wtf. A different error here + // would mean the un-descended-keys rejection went dead. + assert_eq!( + validator.validate_detailed(&hostname()?, &chain), + Err(WalkError::SignerMismatch) + ); Ok(()) } @@ -380,7 +419,7 @@ fn a_cross_zone_cname_without_target_cuts_is_rejected() -> TestResult { let validator = Validator::new(vec![root.anchor()]); let target_owner: Name = "binding.example.net".parse()?; - let cname = cname_record(Name::onomancy_owner(&hostname()?), &target_owner); + let cname = cname_record(Name::onomancy_owner(&hostname()?)?, &target_owner); let txt = Record { owner: target_owner, ..txt_record(&hostname()?, &fixtures::fixture_txt_text()) @@ -405,6 +444,777 @@ fn a_cross_zone_cname_without_target_cuts_is_rejected() -> TestResult { link(&[txt.clone(), target_zone.rrsig(&[txt], window)]), ]); - assert!(validator.validate_detailed(&hostname()?, &chain).is_err()); + // Exactly SignerMismatch: after the re-root the current zone is + // the ROOT, and the RRSIG names example.net. + assert_eq!( + validator.validate_detailed(&hostname()?, &chain), + Err(WalkError::SignerMismatch) + ); + Ok(()) +} + +/// A chain descending to `expede.wtf`, then `hops` in-zone CNAME +/// indirections on the `_onomancy` owner, ending at a TXT leaf. +fn chain_with_cname_hops(root: &Zone, child: &Zone, hops: usize) -> TestResult { + let window = (1_000, 5_000); + let ds = Zone::ds_record_for(child); + + let mut links = vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + link(&[ds.clone(), root.rrsig(&[ds], window)]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + ]; + + let mut owner = Name::onomancy_owner(&hostname()?)?; + for hop in 0..hops { + let target: Name = format!("hop{hop}.expede.wtf").parse()?; + let cname = cname_record(owner, &target); + links.push(link(&[cname.clone(), child.rrsig(&[cname], window)])); + owner = target; + } + + let txt = Record { + owner, + ..txt_record(&hostname()?, &fixtures::fixture_txt_text()) + }; + links.push(link(&[txt.clone(), child.rrsig(&[txt], window)])); + + Ok(DnssecChain::from(links)) +} + +/// The CNAME bound, both sides: exactly `MAX_CNAME_HOPS` hops still +/// validate; one more is rejected as `TooManyCnames`. The bound is +/// semi-exhaustively swept so an off-by-one cannot hide at any count. +#[test] +fn cname_hops_validate_up_to_the_bound_and_fail_past_it() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + + for hops in 0..=MAX_CNAME_HOPS { + let proof = + validator.validate_detailed(&hostname()?, &chain_with_cname_hops(&root, &child, hops)?); + assert!(proof.is_ok(), "{hops} hops must validate: {proof:?}"); + } + + assert_eq!( + validator.validate_detailed( + &hostname()?, + &chain_with_cname_hops(&root, &child, MAX_CNAME_HOPS + 1)? + ), + Err(WalkError::TooManyCnames) + ); + Ok(()) +} + +/// A CNAME loop (A→B→A→…) is terminated BY the hop bound — nothing +/// else in the walk stops it. +#[test] +fn cname_loops_are_terminated_by_the_hop_bound() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let window = (1_000, 5_000); + let ds = Zone::ds_record_for(&child); + + let a = Name::onomancy_owner(&hostname()?)?; + let b: Name = "b.expede.wtf".parse()?; + let a_to_b = cname_record(a.clone(), &b); + let b_to_a = cname_record(b, &a); + + let mut links = vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + link(&[ds.clone(), root.rrsig(&[ds], window)]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + ]; + for hop in 0..=MAX_CNAME_HOPS { + let cname = if hop % 2 == 0 { &a_to_b } else { &b_to_a }; + links.push(link(&[ + cname.clone(), + child.rrsig(core::slice::from_ref(cname), window), + ])); + } + + assert_eq!( + validator.validate_detailed(&hostname()?, &DnssecChain::from(links)), + Err(WalkError::TooManyCnames) + ); + Ok(()) +} + +/// Parent overreach across a cut: a leaf signed by the ROOT zone's +/// keys after the walk has descended to the child MUST be rejected as +/// a signer mismatch — a parent cannot speak for names below a cut it +/// has delegated away. +#[test] +fn a_parent_signed_leaf_below_the_cut_is_a_signer_mismatch() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let window = (1_000, 5_000); + let ds = Zone::ds_record_for(&child); + let txt = leaf()?; + + let chain = DnssecChain::from(vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + link(&[ds.clone(), root.rrsig(&[ds], window)]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + // The overreach: the ROOT signs the leaf below the cut. + link(&[txt.clone(), root.rrsig(&[txt], window)]), + ]); + + assert_eq!( + validator.validate_detailed(&hostname()?, &chain), + Err(WalkError::SignerMismatch) + ); + Ok(()) +} + +/// A DS introducing a SIBLING zone (not below the current one) breaks +/// the strict-descent rule. +#[test] +fn a_sideways_delegation_is_not_descending() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let sibling = zone("example.net", 3); + let validator = Validator::new(vec![root.anchor()]); + let window = (1_000, 5_000); + + let child_ds = Zone::ds_record_for(&child); + let sibling_ds = Zone::ds_record_for(&sibling); + let txt = leaf()?; + + let chain = DnssecChain::from(vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + link(&[child_ds.clone(), root.rrsig(&[child_ds], window)]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + // example.net is NOT below expede.wtf: sideways, not down. + link(&[sibling_ds.clone(), child.rrsig(&[sibling_ds], window)]), + link(&[ + sibling.dnskey_record.clone(), + sibling.rrsig(core::slice::from_ref(&sibling.dnskey_record), window), + ]), + link(&[txt.clone(), child.rrsig(&[txt], window)]), + ]); + + assert_eq!( + validator.validate_detailed(&hostname()?, &chain), + Err(WalkError::NotDescending) + ); + Ok(()) +} + +/// A DS at the CURRENT zone's own name (a self-delegation) also +/// violates strict descent: `zone == child` is not down either. +#[test] +fn a_self_delegation_is_not_descending() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let window = (1_000, 5_000); + + let ds = Zone::ds_record_for(&child); + let self_ds = Record { + owner: root.name.clone(), + ..Zone::ds_record_for(&child) + }; + let txt = leaf()?; + + let chain = DnssecChain::from(vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + // A DS at the root's own name while the current zone IS the + // root: not a descent. + link(&[self_ds.clone(), root.rrsig(&[self_ds], window)]), + link(&[ds.clone(), root.rrsig(&[ds], window)]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + link(&[txt.clone(), child.rrsig(&[txt], window)]), + ]); + + assert_eq!( + validator.validate_detailed(&hostname()?, &chain), + Err(WalkError::NotDescending) + ); + Ok(()) +} + +/// A validly-SIGNED link carrying garbage DS RDATA: signatures cover +/// bytes, not parseability, so this branch is reachable behind a good +/// signature and must fail as malformed RDATA — never verify-then- +/// crash, never pass. +#[test] +fn garbage_ds_rdata_behind_a_valid_signature_is_malformed() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let window = (1_000, 5_000); + + let garbage_ds = Record { + rdata: vec![0xFF, 0xEE, 0xDD], + ..Zone::ds_record_for(&child) + }; + let txt = leaf()?; + + let chain = DnssecChain::from(vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + // The root GENUINELY signs the garbage bytes. + link(&[garbage_ds.clone(), root.rrsig(&[garbage_ds], window)]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + link(&[txt.clone(), child.rrsig(&[txt], window)]), + ]); + + assert_eq!( + validator.validate_detailed(&hostname()?, &chain), + Err(WalkError::MalformedRdata { rtype: RrType::DS }) + ); + Ok(()) +} + +/// Garbage CNAME RDATA behind a valid signature: same rule at the +/// indirection hop. +#[test] +fn garbage_cname_rdata_behind_a_valid_signature_is_malformed() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let window = (1_000, 5_000); + let ds = Zone::ds_record_for(&child); + + let garbage_cname = Record { + owner: Name::onomancy_owner(&hostname()?)?, + rtype: RrType::CNAME, + class: CLASS_IN, + ttl: 900, + rdata: vec![0xFF, 0xFF], + }; + + let chain = DnssecChain::from(vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + link(&[ds.clone(), root.rrsig(&[ds], window)]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + link(&[garbage_cname.clone(), child.rrsig(&[garbage_cname], window)]), + ]); + + assert_eq!( + validator.validate_detailed(&hostname()?, &chain), + Err(WalkError::MalformedRdata { + rtype: RrType::CNAME + }) + ); + Ok(()) +} + +/// Garbage DNSKEY RDATA at a cut: rejected before any DS matching or +/// signature work. +#[test] +fn garbage_child_dnskey_rdata_is_malformed() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let window = (1_000, 5_000); + let ds = Zone::ds_record_for(&child); + + let garbage_dnskey = Record { + rdata: vec![0x01], + ..child.dnskey_record.clone() + }; + let txt = leaf()?; + + let chain = DnssecChain::from(vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + link(&[ds.clone(), root.rrsig(&[ds], window)]), + link(&[ + garbage_dnskey.clone(), + child.rrsig(&[garbage_dnskey], window), + ]), + link(&[txt.clone(), child.rrsig(&[txt], window)]), + ]); + + assert_eq!( + validator.validate_detailed(&hostname()?, &chain), + Err(WalkError::MalformedRdata { + rtype: RrType::DNSKEY + }) + ); + Ok(()) +} + +/// A leaf at an owner other than the query target — with NO CNAME +/// explaining the move — is a spliced answer for the wrong name. +#[test] +fn a_leaf_at_the_wrong_owner_without_a_cname_is_rejected() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let window = (1_000, 5_000); + let ds = Zone::ds_record_for(&child); + + let elsewhere: Name = "elsewhere.expede.wtf".parse()?; + let txt = Record { + owner: elsewhere, + ..txt_record(&hostname()?, &fixtures::fixture_txt_text()) + }; + + let chain = DnssecChain::from(vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + link(&[ds.clone(), root.rrsig(&[ds], window)]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + link(&[txt.clone(), child.rrsig(&[txt], window)]), + ]); + + assert_eq!( + validator.validate_detailed(&hostname()?, &chain), + Err(WalkError::WrongOwner) + ); Ok(()) } + +/// A link mixing record types fails at the link grammar — surfacing +/// as a Parse error, before any trust decisions. +#[test] +fn a_mixed_rrset_link_fails_at_parse() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let window = (1_000, 5_000); + + let ds = Zone::ds_record_for(&child); + let txt = leaf()?; + // One link framing a DS AND a TXT: not an RRset. + let mixed = link(&[ds.clone(), txt, root.rrsig(&[ds], window)]); + + let chain = DnssecChain::from(vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + mixed, + ]); + + assert!(matches!( + validator.validate_detailed(&hostname()?, &chain), + Err(WalkError::Parse(_)) + )); + Ok(()) +} + +/// An RRSIG claiming an unsupported algorithm in an otherwise-valid +/// chain: invalid ✗, never insecure-but-ok. (The zone key is Ed25519, +/// so the mismatch surfaces at the key/signature algorithm check; the +/// unsupported-dispatch arm itself is unit-pinned in `crypto.rs`.) +#[test] +fn an_unsupported_signature_algorithm_never_validates() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let window = (1_000, 5_000); + let ds = Zone::ds_record_for(&child); + + let txt = leaf()?; + let mut leaf_rrsig = child.rrsig(core::slice::from_ref(&txt), window); + // RRSIG RDATA layout: type covered (2), then the algorithm octet. + // 5 = RSA/SHA-1: unsupported at v0 (D13). + leaf_rrsig.rdata[2] = 5; + + let chain = DnssecChain::from(vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + link(&[ds.clone(), root.rrsig(&[ds], window)]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + link(&[txt, leaf_rrsig]), + ]); + + assert_eq!( + validator.validate_detailed(&hostname()?, &chain), + Err(WalkError::Verify(VerifyError::AlgorithmMismatch { + key: onomancy_dnssec::wire::algorithm::Algorithm::ED25519, + signature: onomancy_dnssec::wire::algorithm::Algorithm::new(5), + })) + ); + Ok(()) +} + +/// Key tags are hints, not commitments (they collide legally): an +/// RRSIG whose tag field names the WRONG key of a two-key zone must +/// still verify via the all-keys fallback. +#[test] +fn a_misleading_key_tag_hint_falls_back_to_every_zone_key() -> TestResult { + let anchored = zone(".", 1); + let cosigner = zone(".", 5); + let child = zone("expede.wtf", 2); + let window = (1_000, 5_000); + let ds = Zone::ds_record_for(&child); + let txt = leaf()?; + + // Two keys at the root; the hint must actually mislead. + assert_ne!(anchored.dnskey().key_tag(), cosigner.dnskey().key_tag()); + + // The root DNSKEY RRset carries BOTH keys; every root-signed link + // is signed by the CO-SIGNER but hints the ANCHORED key's tag. + let root_keys = [ + anchored.dnskey_record.clone(), + cosigner.dnskey_record.clone(), + ]; + let misleading = |rrset: &[Record]| -> Record { + cosigner.rrsig_with_key_tag(rrset, window, anchored.dnskey().key_tag()) + }; + + let chain = DnssecChain::from(vec![ + link(&[ + root_keys[0].clone(), + root_keys[1].clone(), + misleading(&root_keys), + ]), + link(&[ds.clone(), misleading(&[ds])]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + link(&[txt.clone(), child.rrsig(&[txt], window)]), + ]); + + // Anchor on the ANCHORED key: entry succeeds via the anchor match, + // and every verification succeeds only if the hinted-key miss + // falls back to the co-signer. + let validator = Validator::new(vec![anchored.anchor()]); + let ChainProof { records, .. } = validator.validate_detailed(&hostname()?, &chain)?; + assert_eq!(records[0].serial(), Serial::from(fixtures::FIXTURE_SERIAL)); + Ok(()) +} + +/// Denial links are skipped UNVERIFIED on the success path too: an +/// interposed NSEC whose RRSIG window would empty the ∩ must not +/// touch the proof window. +#[test] +fn a_skipped_denial_never_narrows_the_proof_window() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let window = (1_000, 5_000); + let ds = Zone::ds_record_for(&child); + let txt = leaf()?; + + let mut nsec_rdata = Vec::new(); + let next: Name = "zzz.expede.wtf".parse()?; + next.write(&mut nsec_rdata); + nsec_rdata.extend_from_slice(&[0, 1, 0x40]); // bitmap: A exists + let nsec = Record { + owner: child.name.clone(), + rtype: RrType::NSEC, + class: CLASS_IN, + ttl: 900, + rdata: nsec_rdata, + }; + + let chain = DnssecChain::from(vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + link(&[ds.clone(), root.rrsig(&[ds], window)]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + // The denial, signed under a window DISJOINT from every other + // link: intersecting it would empty the ∩ and fail the walk. + link(&[nsec.clone(), child.rrsig(&[nsec], (10, 20))]), + link(&[txt.clone(), child.rrsig(&[txt], window)]), + ]); + + let ChainProof { window: proof, .. } = validator.validate_detailed(&hostname()?, &chain)?; + assert_eq!(proof.inception(), UnixSeconds::from(1_000)); + assert_eq!(proof.expiration(), UnixSeconds::from(5_000)); + Ok(()) +} + +/// A TXT record at the leaf whose rdata is used verbatim (already +/// character-string framed or deliberately garbage). +const fn raw_txt(owner: Name, rdata: Vec) -> Record { + Record { + owner, + rtype: RrType::TXT, + class: CLASS_IN, + ttl: 900, + rdata, + } +} + +/// Grammar rejection is per-record, never RRset-wide: junk TXT +/// records co-resident with a valid ONO0 record are dispositioned +/// out, and exactly the valid record survives. +#[test] +fn leaf_grammar_rejection_is_per_record_never_rrset_wide() -> TestResult { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let window = (1_000, 5_000); + let ds = Zone::ds_record_for(&child); + let owner = Name::onomancy_owner(&hostname()?)?; + + let valid = leaf()?; + let rrset = vec![ + valid.clone(), + // An SPF-shaped foreigner (dispositioned, not an error). + raw_txt(owner.clone(), b"\x0Bv=spf1 -all".to_vec()), + // Invalid UTF-8 (dropped by the text decode). + raw_txt(owner.clone(), vec![0x02, 0xFF, 0xFE]), + // A future version (dispositioned as unknown). + raw_txt(owner.clone(), b"\x09v=ONO9;x=".to_vec()), + // Not even a TXT character string (fails the TXT wire parse). + raw_txt(owner.clone(), vec![0xFF]), + ]; + let mut records = rrset.clone(); + records.push(child.rrsig(&rrset, window)); + + let chain = DnssecChain::from(vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + link(&[ds.clone(), root.rrsig(&[ds], window)]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + link(&records), + ]); + + let ChainProof { records, .. } = validator.validate_detailed(&hostname()?, &chain)?; + assert_eq!(records.len(), 1, "exactly the valid record survives"); + assert_eq!(records[0].serial(), Serial::from(fixtures::FIXTURE_SERIAL)); + Ok(()) +} + +mod props { + use super::*; + + /// Any single bit flip anywhere in the chain either fails the + /// walk or leaves the proof IDENTICAL to the untampered one — + /// never a different accepted proof. (The weaker "always fails" + /// form is wrong by design: RFC 4035 substitutes the RRSIG's + /// original TTL into the signed message, so flips confined to + /// received-TTL bytes legitimately verify to the same proof.) + #[test] + fn a_bit_flip_never_yields_a_different_accepted_proof() { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let hostname = hostname().expect("valid literal"); + let chain = happy_chain(&root, &child).expect("chain builds"); + let baseline = validator + .validate_detailed(&hostname, &chain) + .expect("the untampered chain validates"); + + bolero::check!().with_type::<(u8, u16, u8)>().for_each( + |&(link_pick, byte_pick, bit_pick)| { + let mut links: Vec = chain.links().to_vec(); + let li = usize::from(link_pick) % links.len(); + let mut bytes = links[li].as_bytes().to_vec(); + let bi = usize::from(byte_pick) % bytes.len(); + let bit = 1u8 << (bit_pick % 8); + bytes[bi] ^= bit; + links[li] = ChainLink::from(bytes); + + match validator.validate_detailed(&hostname, &DnssecChain::from(links)) { + Err(_) => {} + Ok(proof) => assert_eq!( + proof, baseline, + "flip link {li} byte {bi} bit {bit:#04x} produced a DIFFERENT proof" + ), + } + }, + ); + } + + /// The proof window is exactly the ∩ of every link window; a + /// jointly-empty ∩ is `EmptyWindow` — invalid ✗, never stale. + #[test] + fn the_proof_window_is_the_intersection_of_every_link_window() { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let hostname = hostname().expect("valid literal"); + let txt = leaf().expect("leaf builds"); + + bolero::check!() + .with_type::<[(u32, u32); 4]>() + .for_each(|raw| { + let norm = |(a, b): (u32, u32)| (a.min(b), a.max(b)); + let [w_root, w_delegation, w_child, w_leaf] = raw.map(norm); + let chain = binding_chain( + &root, + &child, + txt.clone(), + ChainWindows { + root: w_root, + delegation: w_delegation, + child: w_child, + leaf: w_leaf, + }, + ); + + let inception = w_root.0.max(w_delegation.0).max(w_child.0).max(w_leaf.0); + let expiration = w_root.1.min(w_delegation.1).min(w_child.1).min(w_leaf.1); + + match validator.validate_detailed(&hostname, &chain) { + Ok(proof) => { + assert!(inception <= expiration); + assert_eq!( + proof.window.inception(), + UnixSeconds::from(u64::from(inception)) + ); + assert_eq!( + proof.window.expiration(), + UnixSeconds::from(u64::from(expiration)) + ); + } + Err(WalkError::EmptyWindow) => assert!(expiration < inception), + Err(other) => panic!("unexpected walk error: {other}"), + } + }); + } + + /// Anchoring is exactly root-key membership in the anchor set: + /// decoy anchors never admit the chain, and the genuine anchor + /// always does — regardless of the decoys around it. + #[test] + fn anchoring_is_exactly_root_key_membership() { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let hostname = hostname().expect("valid literal"); + let chain = happy_chain(&root, &child).expect("chain builds"); + + bolero::check!() + .with_type::<(Vec, bool)>() + .for_each(|(decoy_seeds, include_real)| { + // Seeds ORed past the fixture range: never the real key. + let mut anchors: Vec<_> = decoy_seeds + .iter() + .take(4) + .map(|seed| zone(".", seed | 0x80).anchor()) + .collect(); + if *include_real { + anchors.push(root.anchor()); + } + + let outcome = Validator::new(anchors).validate_detailed(&hostname, &chain); + if *include_real { + assert!(outcome.is_ok(), "the genuine anchor must admit the chain"); + } else { + assert_eq!(outcome, Err(WalkError::Unanchored)); + } + }); + } + + /// P5 generalized: arbitrary junk rdatas riding the leaf `RRset` + /// never break, never pollute, and never evict the valid record. + #[test] + fn junk_leaf_neighbors_never_survive_disposition() { + let root = zone(".", 1); + let child = zone("expede.wtf", 2); + let validator = Validator::new(vec![root.anchor()]); + let hostname = hostname().expect("valid literal"); + let window = (1_000, 5_000); + let ds = Zone::ds_record_for(&child); + let owner = Name::onomancy_owner(&hostname).expect("fits under the service label"); + let valid = leaf().expect("leaf builds"); + + let prefix = vec![ + link(&[ + root.dnskey_record.clone(), + root.rrsig(core::slice::from_ref(&root.dnskey_record), window), + ]), + link(&[ds.clone(), root.rrsig(&[ds], window)]), + link(&[ + child.dnskey_record.clone(), + child.rrsig(core::slice::from_ref(&child.dnskey_record), window), + ]), + ]; + + bolero::check!() + .with_type::>>() + .for_each(|garbage| { + let mut rrset = vec![valid.clone()]; + rrset.extend( + garbage + .iter() + .take(4) + .map(|bytes| bytes.iter().copied().take(64).collect::>()) + // A junk rdata equal to the valid record's + // would BE a second valid record, not junk. + .filter(|rdata| *rdata != valid.rdata) + .map(|rdata| raw_txt(owner.clone(), rdata)), + ); + let mut records = rrset.clone(); + records.push(child.rrsig(&rrset, window)); + + let mut links = prefix.clone(); + links.push(link(&records)); + + let proof = validator + .validate_detailed(&hostname, &DnssecChain::from(links)) + .expect("the valid record must survive its neighbors"); + assert_eq!(proof.records.len(), 1); + assert_eq!( + proof.records[0].serial(), + Serial::from(fixtures::FIXTURE_SERIAL) + ); + }); + } +} diff --git a/onomancy_hickory/src/provider.rs b/onomancy_hickory/src/provider.rs index d7ec44a..8e7700c 100644 --- a/onomancy_hickory/src/provider.rs +++ b/onomancy_hickory/src/provider.rs @@ -45,7 +45,17 @@ impl HickoryProvider { /// exists or none of its entries parse. #[must_use] pub fn system() -> Self { - let mut discovered = resolv_conf_upstreams().into_iter().map(StubResolver::new); + let text = fs::read_to_string("/etc/resolv.conf").unwrap_or_default(); + Self::with_fallback(resolv_conf(&text)) + } + + /// Discovered upstreams in order, with [`FALLBACK_UPSTREAM`] + /// appended as the last resort — or alone when discovery yields + /// nothing. The composition [`Self::system`] applies to what it + /// reads, split out so it is testable without a filesystem. + #[must_use] + pub fn with_fallback(discovered: Vec) -> Self { + let mut discovered = discovered.into_iter().map(StubResolver::new); let fallback = StubResolver::new(FALLBACK_UPSTREAM); match discovered.next() { @@ -67,6 +77,14 @@ impl HickoryProvider { self } + /// Every upstream, in the order tried. + #[must_use] + pub fn upstreams(&self) -> Vec { + core::iter::once(self.first.server()) + .chain(self.rest.iter().map(StubResolver::server)) + .collect() + } + /// A provider over one pre-configured stub. #[must_use] pub const fn from_stub(stub: StubResolver) -> Self { @@ -154,14 +172,14 @@ async fn drive( } } -/// The `nameserver` entries of `/etc/resolv.conf`, in file order. -/// Unreadable files and unparsable entries (e.g. scoped IPv6) yield -/// nothing — discovery degrades, never errors. -fn resolv_conf_upstreams() -> Vec { - let Ok(text) = fs::read_to_string("/etc/resolv.conf") else { - return Vec::new(); - }; - +/// The `nameserver` entries of resolv.conf text, in file order, each +/// on port 53. Unparsable entries (e.g. scoped IPv6) yield nothing — +/// discovery degrades, never errors. +/// +/// Pure over its input so the parsing is testable; the filesystem +/// read lives in [`HickoryProvider::system`]. +#[must_use] +pub fn resolv_conf(text: &str) -> Vec { text.lines() .filter_map(|line| line.strip_prefix("nameserver")) .filter(|rest| rest.starts_with(char::is_whitespace)) diff --git a/onomancy_hickory/src/stub.rs b/onomancy_hickory/src/stub.rs index 16ab9ae..d13e8af 100644 --- a/onomancy_hickory/src/stub.rs +++ b/onomancy_hickory/src/stub.rs @@ -54,6 +54,12 @@ impl StubResolver { Self { timeout, ..self } } + /// The upstream this stub queries. + #[must_use] + pub const fn server(&self) -> SocketAddr { + self.server + } + /// Answer one of the chain builder's questions, returning the /// answer-section records. /// diff --git a/onomancy_hickory/tests/offline.rs b/onomancy_hickory/tests/offline.rs new file mode 100644 index 0000000..0c4e9fa --- /dev/null +++ b/onomancy_hickory/tests/offline.rs @@ -0,0 +1,325 @@ +//! Offline transport tests: scripted local sockets, no network. +//! +//! The courier's contracts — failover order, whose error surfaces, +//! TCP fallback on truncation, ID-mismatch rejection, timeout mapping, +//! and resolv.conf parsing — are all observable against loopback +//! responders that misbehave on cue. The live test (`live.rs`) stays +//! the happy-path smoke; nothing here touches a real resolver. + +#![allow(clippy::expect_used, clippy::panic)] + +use std::{ + net::SocketAddr, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use hickory_proto::op::{Message, MessageType, OpCode, ResponseCode}; +use onomancy_chain::{builder::ChainBuilder, question::Question}; +use onomancy_dnssec::dns_name::DnsName; +use onomancy_hickory::{ + provider::{FALLBACK_UPSTREAM, FetchChainError, HickoryProvider, resolv_conf}, + stub::{QueryError, StubResolver}, +}; +use testresult::TestResult; +use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _}, + net::{TcpListener, UdpSocket}, +}; + +/// How a scripted upstream answers. +#[derive(Debug, Clone, Copy)] +enum Script { + /// Bytes that are not a DNS message. + Garbage, + /// A well-formed refusal. + ServFail, + /// A well-formed answer under the wrong ID. + WrongId, + /// A truncated answer (inviting the TCP retry). + Truncated, + /// A clean empty `NoError` answer. + Empty, +} + +/// The wire response a script produces for one request. +fn respond(script: Script, wire: &[u8]) -> Vec { + if matches!(script, Script::Garbage) { + return vec![0xFF; 8]; + } + + let request = Message::from_vec(wire).expect("requests are well-formed"); + let id = match script { + Script::WrongId => request.metadata.id.wrapping_add(1), + Script::Garbage | Script::ServFail | Script::Truncated | Script::Empty => { + request.metadata.id + } + }; + + let mut response = Message::new(id, MessageType::Response, OpCode::Query); + response.metadata.response_code = match script { + Script::ServFail => ResponseCode::ServFail, + Script::Garbage | Script::WrongId | Script::Truncated | Script::Empty => { + ResponseCode::NoError + } + }; + response.metadata.truncation = matches!(script, Script::Truncated); + + response.to_vec().expect("responses encode") +} + +/// A loopback UDP upstream running `script`, logging each hit as +/// `tag` into the shared sequence. +async fn udp_upstream( + script: Script, + tag: char, + log: Arc>>, +) -> TestResult { + let socket = UdpSocket::bind("127.0.0.1:0").await?; + let addr = socket.local_addr()?; + + tokio::spawn(async move { + let mut buffer = [0u8; 4096]; + while let Ok((received, peer)) = socket.recv_from(&mut buffer).await { + log.lock().expect("uncontended").push(tag); + let Some(datagram) = buffer.get(..received) else { + break; // recv_from never overruns its buffer + }; + let reply = respond(script, datagram); + socket.send_to(&reply, peer).await.ok(); + } + }); + + Ok(addr) +} + +/// The chain builder's first question (the root DNSKEY), which is all +/// a transport test needs: every scripted failure fires on query one. +fn first_question() -> Question { + let hostname = DnsName::parse("example.com").expect("valid hostname"); + let (_, question) = ChainBuilder::start(&hostname).expect("representable name"); + question +} + +/// Upstreams are tried strictly in order, and the LAST upstream's +/// error is what surfaces — the documented `fetch_chain` contract. +#[tokio::test(flavor = "current_thread")] +async fn failover_tries_upstreams_in_order_and_returns_the_last_error() -> TestResult { + let log = Arc::new(Mutex::new(Vec::new())); + let first = udp_upstream(Script::Garbage, 'a', Arc::clone(&log)).await?; + let second = udp_upstream(Script::ServFail, 'b', Arc::clone(&log)).await?; + + let provider = HickoryProvider::new(first).or(second); + let hostname = DnsName::parse("example.com")?; + + let outcome = provider.fetch_chain(&hostname).await; + + // The SECOND upstream's failure class, not the first's: garbage + // is `Malformed`, a refusal is `Refused`, and last-error-wins is + // only provable because they differ. + match outcome { + Err(FetchChainError::Transport(QueryError::Refused(refused))) => { + assert_eq!(refused.code, ResponseCode::ServFail); + } + other => panic!("expected the last upstream's refusal, got {other:?}"), + } + + assert_eq!( + *log.lock().expect("uncontended"), + vec!['a', 'b'], + "one query each, first upstream first" + ); + Ok(()) +} + +/// A single-upstream provider surfaces that upstream's error +/// unchanged — no fallback is invented. +#[tokio::test(flavor = "current_thread")] +async fn a_lone_upstreams_error_passes_through() -> TestResult { + let log = Arc::new(Mutex::new(Vec::new())); + let only = udp_upstream(Script::ServFail, 'x', Arc::clone(&log)).await?; + + let provider = HickoryProvider::new(only); + let hostname = DnsName::parse("example.com")?; + + assert!(matches!( + provider.fetch_chain(&hostname).await, + Err(FetchChainError::Transport(QueryError::Refused(_))) + )); + assert_eq!(*log.lock().expect("uncontended"), vec!['x']); + Ok(()) +} + +/// `system()`'s composition, testable without a filesystem: +/// discovered upstreams keep their order, the fallback rides last, +/// and an empty discovery uses the fallback alone. +#[test] +fn the_fallback_upstream_rides_last_or_alone() { + let a: SocketAddr = "192.0.2.1:53".parse().expect("addr"); + let b: SocketAddr = "192.0.2.2:53".parse().expect("addr"); + + assert_eq!( + HickoryProvider::with_fallback(vec![a, b]).upstreams(), + vec![a, b, FALLBACK_UPSTREAM] + ); + assert_eq!( + HickoryProvider::with_fallback(Vec::new()).upstreams(), + vec![FALLBACK_UPSTREAM] + ); +} + +/// `or()` appends after the existing upstreams. +#[test] +fn or_appends_upstreams_in_call_order() { + let a: SocketAddr = "192.0.2.1:53".parse().expect("addr"); + let b: SocketAddr = "192.0.2.2:53".parse().expect("addr"); + let c: SocketAddr = "192.0.2.3:53".parse().expect("addr"); + + assert_eq!( + HickoryProvider::new(a).or(b).or(c).upstreams(), + vec![a, b, c] + ); +} + +/// resolv.conf parsing: `nameserver` entries in file order, junk and +/// unparsable entries skipped, never an error. +#[test] +fn resolv_conf_keeps_parsable_nameservers_in_file_order() { + let text = "\ +# a comment +search example.com +nameserver 192.0.2.1 +nameserver\t2001:db8::1 +options edns0 +nameserver 192.0.2.2 +"; + + assert_eq!( + resolv_conf(text), + vec![ + "192.0.2.1:53".parse::().expect("addr"), + "[2001:db8::1]:53".parse::().expect("addr"), + "192.0.2.2:53".parse::().expect("addr"), + ] + ); +} + +/// The whitespace guard: `nameserverX` is not a `nameserver` entry, +/// and prefix-matching without it would invent an upstream from a +/// typo. +#[test] +fn resolv_conf_requires_whitespace_after_the_keyword() { + assert_eq!(resolv_conf("nameserver192.0.2.1"), Vec::new()); + assert_eq!(resolv_conf("nameserverX 192.0.2.1"), Vec::new()); +} + +/// Scoped IPv6 (`fe80::1%eth0`) does not parse as a bare `IpAddr`; +/// discovery degrades by skipping it rather than erroring. +#[test] +fn resolv_conf_skips_scoped_ipv6_and_garbage() { + let text = "\ +nameserver fe80::1%eth0 +nameserver not-an-address +nameserver 192.0.2.9 +"; + + assert_eq!( + resolv_conf(text), + vec!["192.0.2.9:53".parse::().expect("addr")] + ); +} + +/// Truncation over UDP retries the same query over TCP and takes the +/// TCP answer — asserted by counting the TCP hit and getting the +/// clean empty answer only TCP serves. +#[tokio::test(flavor = "current_thread")] +async fn truncated_udp_answers_fall_back_to_tcp() -> TestResult { + // Same port, both transports: bind TCP first (its port space is + // separate from UDP's, so the pairing needs a retry loop). + let (udp, tcp) = loop { + let tcp = TcpListener::bind("127.0.0.1:0").await?; + let port = tcp.local_addr()?.port(); + if let Ok(udp) = UdpSocket::bind(("127.0.0.1", port)).await { + break (udp, tcp); + } + }; + let addr = udp.local_addr()?; + + // UDP: always truncated. + tokio::spawn(async move { + let mut buffer = [0u8; 4096]; + while let Ok((received, peer)) = udp.recv_from(&mut buffer).await { + let Some(datagram) = buffer.get(..received) else { + break; // recv_from never overruns its buffer + }; + let reply = respond(Script::Truncated, datagram); + udp.send_to(&reply, peer).await.ok(); + } + }); + + // TCP: the real (empty NoError) answer, length-prefixed. + let tcp_hits = Arc::new(AtomicUsize::new(0)); + let hits = Arc::clone(&tcp_hits); + tokio::spawn(async move { + while let Ok((mut stream, _)) = tcp.accept().await { + hits.fetch_add(1, Ordering::SeqCst); + + let mut length = [0u8; 2]; + if stream.read_exact(&mut length).await.is_err() { + continue; + } + let mut wire = vec![0u8; usize::from(u16::from_be_bytes(length))]; + if stream.read_exact(&mut wire).await.is_err() { + continue; + } + + let reply = respond(Script::Empty, &wire); + #[allow(clippy::cast_possible_truncation)] // responses are tiny + let prefix = (reply.len() as u16).to_be_bytes(); + stream.write_all(&prefix).await.ok(); + stream.write_all(&reply).await.ok(); + } + }); + + let answers = StubResolver::new(addr).query(&first_question()).await?; + + assert!(answers.is_empty(), "the TCP answer is the empty NoError"); + assert_eq!(tcp_hits.load(Ordering::SeqCst), 1, "TCP was consulted"); + Ok(()) +} + +/// A response under the wrong ID is rejected, not accepted — weak IDs +/// are deliberate, but a mismatched one is still not this query's +/// answer. +#[tokio::test(flavor = "current_thread")] +async fn mismatched_response_ids_are_rejected() -> TestResult { + let log = Arc::new(Mutex::new(Vec::new())); + let addr = udp_upstream(Script::WrongId, 'w', log).await?; + + assert!(matches!( + StubResolver::new(addr).query(&first_question()).await, + Err(QueryError::IdMismatch) + )); + Ok(()) +} + +/// A silent upstream maps to `Timeout`, not a hang and not an `Io` +/// surprise. +#[tokio::test(flavor = "current_thread")] +async fn a_silent_upstream_times_out() -> TestResult { + // Bound and never read: the query is sent and nothing returns. + let silent = UdpSocket::bind("127.0.0.1:0").await?; + let addr = silent.local_addr()?; + + let stub = StubResolver::new(addr).with_timeout(Duration::from_millis(100)); + + assert!(matches!( + stub.query(&first_question()).await, + Err(QueryError::Timeout) + )); + drop(silent); + Ok(()) +} diff --git a/onomancy_keyhive/Cargo.toml b/onomancy_keyhive/Cargo.toml index 8368df0..f803c26 100644 --- a/onomancy_keyhive/Cargo.toml +++ b/onomancy_keyhive/Cargo.toml @@ -26,6 +26,7 @@ thiserror = { workspace = true } tracing = { workspace = true } [dev-dependencies] +bolero = { workspace = true, features = ["arbitrary"] } testresult = { workspace = true } [lints] diff --git a/onomancy_keyhive/src/carriage.rs b/onomancy_keyhive/src/carriage.rs index c0c40ea..9358b3d 100644 --- a/onomancy_keyhive/src/carriage.rs +++ b/onomancy_keyhive/src/carriage.rs @@ -119,6 +119,7 @@ pub enum ParseCarriageError { pub struct EncodeCarriageError(#[from] bincode::Error); #[cfg(test)] +#[allow(clippy::expect_used)] mod tests { use super::*; @@ -132,6 +133,25 @@ mod tests { )); } + /// The reported index is the ENTRY's position, not always zero: + /// a valid first entry followed by a bad second names index 1. + #[test] + fn rejection_indices_name_the_offending_entry() -> testresult::TestResult { + let minted = crate::mint::generation_carriage( + &ed25519_dalek::SigningKey::from_bytes(&[1; 32]), + &ed25519_dalek::SigningKey::from_bytes(&[2; 32]), + )?; + let mut entries = minted.entries().to_vec(); + entries.truncate(1); // one valid entry + entries.push(SignedDelegationBytes::from(b"zz9garbage".to_vec())); + + assert!(matches!( + Carriage::parse(&DelegationChain::from(entries)), + Err(ParseCarriageError::UnknownEnvelope { index: 1, .. }) + )); + Ok(()) + } + #[test] fn tagged_garbage_is_rejected_not_skipped() { let entry = SignedDelegationBytes::from(b"kh0garbage".to_vec()); @@ -142,11 +162,69 @@ mod tests { )); } + /// The envelope roundtrips through REAL events — tag prepended + /// and stripped, bincode both ways — not just the empty vector. #[test] - fn empty_carriage_roundtrips() -> testresult::TestResult { - let carriage = Carriage::new(Vec::new()); - let entries = carriage.to_delegation_bytes()?; - assert_eq!(Carriage::parse(&entries)?, carriage); + fn minted_carriages_roundtrip() -> testresult::TestResult { + let minted = crate::mint::generation_carriage( + &ed25519_dalek::SigningKey::from_bytes(&[1; 32]), + &ed25519_dalek::SigningKey::from_bytes(&[2; 32]), + )?; + + let parsed = Carriage::parse(&minted)?; + assert_eq!(parsed.events().len(), 2, "introduction + delegation"); + assert_eq!( + parsed.to_delegation_bytes()?, + minted, + "re-encoding reproduces the attached bytes verbatim" + ); + + // The empty carriage is the degenerate case of the same law. + let empty = Carriage::new(Vec::new()); + assert_eq!(Carriage::parse(&empty.to_delegation_bytes()?)?, empty); Ok(()) } + + mod props { + use super::*; + + /// `parse ∘ to_delegation_bytes = id` over minted carriages + /// for arbitrary key pairs — and byte-noise entries never + /// panic the parser, erring with the offending index instead. + #[test] + fn envelope_roundtrip_and_total_parse() { + bolero::check!() + .with_type::<([u8; 32], [u8; 32], Vec)>() + .for_each(|(doc_seed, generation_seed, noise)| { + if doc_seed == generation_seed { + return; // a document cannot delegate to itself + } + + let minted = crate::mint::generation_carriage( + &ed25519_dalek::SigningKey::from_bytes(doc_seed), + &ed25519_dalek::SigningKey::from_bytes(generation_seed), + ) + .expect("mintable"); + + let parsed = Carriage::parse(&minted).expect("own bytes parse"); + assert_eq!(parsed.to_delegation_bytes().expect("encodable"), minted); + + // Noise appended after valid entries: total, and + // the error names the noise entry's index. + let mut entries = minted.entries().to_vec(); + entries.push(SignedDelegationBytes::from(noise.clone())); + match Carriage::parse(&DelegationChain::from(entries)) { + Err( + ParseCarriageError::UnknownEnvelope { index, .. } + | ParseCarriageError::UndecodableEvent { index, .. }, + ) => assert_eq!(index, 2, "the error names the noise entry"), + Ok(_) => { + // Vanishingly unlikely (noise must spell + // `kh0` + a valid bincode event), but not + // impossible — and not a parser defect. + } + } + }); + } + } } diff --git a/onomancy_keyhive/tests/authority.rs b/onomancy_keyhive/tests/authority.rs index 022d24e..68ba3b7 100644 --- a/onomancy_keyhive/tests/authority.rs +++ b/onomancy_keyhive/tests/authority.rs @@ -179,6 +179,187 @@ fn the_root_key_speaks_for_itself() -> TestResult { ), "identity rule: the document root key needs no chain" ); + + // The identity rule short-circuits BEFORE replay — deliberate + // (authority.rs module docs): a signer that IS the root has + // nothing to prove, so even a garbage carriage cannot demote it. + let garbage = DelegationChain::from(vec![ + onomancy_core::delegation_chain::SignedDelegationBytes::from(b"zz9garbage".to_vec()), + ]); + assert!( + KeyhiveAuthority.authorizes(&f.anchor, f.anchor.verifying_key(), &garbage), + "the short-circuit precedes carriage parsing, by design" + ); + Ok(()) +} + +/// The signing bar's REJECTION direction (dns-anchor §Who Signs): a +/// signer who is a genuine DIRECT member of the root group, whose +/// delegating hop holds LESS than Admin, is refused. This is the +/// only test the access comparison itself protects: `sanctioned` +/// finds the signer among the root's members (no absence, no +/// depth-2 gap), reads the delegating hop's proof (`can = Edit`), +/// and the bar refuses it. Weakening the bar to `>= Access::Read` — +/// or deleting the access check — fails here and nowhere else. +#[test] +#[allow(clippy::too_many_lines)] // two instances must exchange a real graph +fn sub_admin_delegating_hops_fail_the_signing_bar() -> TestResult { + let built = block_on(async { + let owner = instance().await?; + let deputy = instance().await?; + let signer = instance().await?; + + let root = owner.generate_group(vec![]).await?; + let root_id = { root.lock().await.group_id() }; + let anchor = DocAnchor::from(Identifier::from(root_id).0); + + // root ──Edit──▶ deputy: the sub-Admin hop. + let deputy_card = deputy.contact_card().await?; + owner.receive_contact_card(&deputy_card).await?; + let deputy_agent = owner + .get_agent(Identifier::from(deputy_card.id())) + .await + .ok_or("just introduced")?; + owner + .add_member( + deputy_agent, + &Membered::Group(root_id, root.clone()), + Access::Edit, + &[], + ) + .await?; + + // The deputy materializes the group from the owner's export. + // Retried to a fixpoint: export order is dependency- + // compatible, not dependency-sorted — the same discipline the + // verifier's replay uses. + let mut remaining: Vec> = Vec::new(); + for ops in owner + .reachable_prekey_ops_for_all_agents() + .await + .ops + .values() + { + for op in ops { + remaining.push(prekey_event(op)); + } + } + for op_map in owner.membership_ops_for_all_agents().await.ops.values() { + for op in op_map.values() { + let event: Event<_, _, _, _> = op.clone().into(); + remaining.push(event.into()); + } + } + loop { + let mut deferred = Vec::with_capacity(remaining.len()); + for event in remaining.drain(..) { + if deputy.receive_static_event(event.clone()).await.is_err() { + deferred.push(event); + } + } + match (deferred.is_empty(), deferred.len() == deferred.capacity()) { + (true, _) => break, + (false, true) => return Err("deputy ingest stalled".into()), + (false, false) => remaining = deferred, + } + } + + // …and adds the signer DIRECTLY to the root group. The + // signer's delegation proof is the deputy's own Edit-access + // delegation — the sub-Admin delegating hop §Who Signs bars. + let signer_card = signer.contact_card().await?; + deputy.receive_contact_card(&signer_card).await?; + let signer_agent = deputy + .get_agent(Identifier::from(signer_card.id())) + .await + .ok_or("just introduced to the deputy")?; + let root_at_deputy = deputy + .get_group(root_id) + .await + .ok_or("the deputy holds the group")?; + deputy + .add_member( + signer_agent, + &Membered::Group(root_id, root_at_deputy), + Access::Read, + &[], + ) + .await?; + + let signer_key = Identifier::from(signer_card.id()).0; + + // The carriage is the DEPUTY's view: it holds the owner's + // ops plus its own delegation of the signer — and the + // delegates' introductions, which live in their contact-card + // ops and ride no all-agents export. + let mut events: Vec> = Vec::new(); + events.push(prekey_event(deputy_card.op())); + events.push(prekey_event(signer_card.op())); + for ops in deputy + .reachable_prekey_ops_for_all_agents() + .await + .ops + .values() + { + for op in ops { + events.push(prekey_event(op)); + } + } + for op_map in deputy.membership_ops_for_all_agents().await.ops.values() { + for op in op_map.values() { + let event: Event<_, _, _, _> = op.clone().into(); + events.push(event.into()); + } + } + + Ok::<_, testresult::TestError>(( + anchor, + signer_key, + Carriage::new(events).to_delegation_bytes()?, + )) + })?; + let (anchor, signer_key, carriage) = built; + + // Sanity: the signer IS in the graph — the refusal below is the + // access bar, never absence. + assert!( + KeyhiveAuthority.on_path(&carriage, &GenerationKey::from(signer_key)), + "the signer is a genuine (direct) member of the root group" + ); + + assert!( + !KeyhiveAuthority.authorizes(&anchor, &signer_key, &carriage), + "a delegating hop below Admin fails the signing bar" + ); + Ok(()) +} + +/// The replay's no-progress fixpoint, specifically: a carriage whose +/// delegation names a key the (dropped) prekey introduction would +/// have introduced can never fully ingest — the replay refuses, +/// rather than looping or accepting a partial graph. +#[test] +fn dangling_dependencies_refuse_at_the_fixpoint() -> TestResult { + let doc_key = SigningKey::from_bytes(&[11; 32]); + let generation_key = SigningKey::from_bytes(&[12; 32]); + let minted = onomancy_keyhive::mint::generation_carriage(&doc_key, &generation_key)?; + + // Drop the prekey introduction (entry 0), keeping the delegation + // that depends on it. Every entry still PARSES — the refusal is + // the ingest fixpoint, not the envelope. + let entries = minted.entries().to_vec(); + let dangling = DelegationChain::from(entries.get(1..).ok_or("two entries")?.to_vec()); + assert_eq!(dangling.len(), 1); + + let generation = GenerationKey::from(generation_key.verifying_key()); + assert!( + KeyhiveAuthority.on_path(&minted, &generation), + "the complete carriage proves the path" + ); + assert!( + !KeyhiveAuthority.on_path(&dangling, &generation), + "dropping the introduction dangles the delegation: refused" + ); Ok(()) } diff --git a/onomancy_protocol/src/ladder.rs b/onomancy_protocol/src/ladder.rs index 7570b32..b11ec76 100644 --- a/onomancy_protocol/src/ladder.rs +++ b/onomancy_protocol/src/ladder.rs @@ -288,6 +288,74 @@ mod tests { }); } + /// Rung 0 is absolute: a fresh contender beats a stale one + /// under EVERY continuity verdict — even a fork — because + /// freshness is DNSSEC-vouched and statements are not + /// consulted across freshness classes. Subsumes the + /// fresh-beats-stale and rung-precedence examples. + #[test] + fn fresh_always_beats_stale_under_any_continuity() { + bolero::check!() + .with_type::<((u8, u64, u64, u64), (u8, u64, u64, u64), u8)>() + .for_each(|(l, r, c)| { + let fresh = + contender(l.0 % 4, Freshness::Fresh, key(l.1 % 4, l.2 % 4, l.3 % 4)); + let stale = + contender(r.0 % 4, Freshness::Stale, key(r.1 % 4, r.2 % 4, r.3 % 4)); + + let continuity = match c % 4 { + 0 => Continuity::Silent, + 1 => Continuity::LeftNewer, + 2 => Continuity::RightNewer, + _ => Continuity::Fork, + }; + + assert_eq!(compare(&fresh, &stale, continuity), Verdict::Left); + assert_eq!(compare(&stale, &fresh, continuity), Verdict::Right); + }); + } + + /// `Equal` is exactly same-document, same-key, same-grade — + /// anything less is an order or an equivocation, never + /// silent equality. + #[test] + fn equal_is_exactly_same_document_same_key_same_grade() { + bolero::check!() + .with_type::<((u8, bool, u64, u64, u64), (u8, bool, u64, u64, u64))>() + .for_each(|(l, r)| { + let left = arb_contender(*l); + let right = arb_contender(*r); + + let expected = left.freshness == right.freshness + && left.document == right.document + && left.key == right.key; + + assert_eq!( + compare(&left, &right, Continuity::Silent) == Verdict::Equal, + expected + ); + }); + } + + /// Within one freshness class, a fork verdict from rung 1 is + /// always surfaced — never outvoted by rung 2's key. + #[test] + fn forks_within_a_freshness_class_always_surface() { + bolero::check!() + .with_type::<((u8, u64, u64, u64), (u8, u64, u64, u64), bool)>() + .for_each(|(l, r, fresh)| { + let grade = if *fresh { + Freshness::Fresh + } else { + Freshness::Stale + }; + let left = contender(l.0 % 4, grade, key(l.1 % 4, l.2 % 4, l.3 % 4)); + let right = contender(r.0 % 4, grade, key(r.1 % 4, r.2 % 4, r.3 % 4)); + + assert_eq!(compare(&left, &right, Continuity::Fork), Verdict::Fork); + }); + } + /// With continuity silent, rung 0+2 comparison is transitive /// in its strict wins — the no-cycles property the single /// lexicographic key exists to buy. diff --git a/onomancy_protocol/src/resolve.rs b/onomancy_protocol/src/resolve.rs index 0a527e6..1390198 100644 --- a/onomancy_protocol/src/resolve.rs +++ b/onomancy_protocol/src/resolve.rs @@ -298,7 +298,7 @@ mod tests { let replicas = MemoryReplicas::default() .with(alice, alice_store) - .with(bob, bob_store.clone()); + .with(bob, bob_store); // Three segments, three hops: root's `alice` edge, alice's // `bob` edge, bob's `alice` edge — landing back in alice's @@ -312,7 +312,6 @@ mod tests { authority: Authority::TrustedSubstrate } ); - drop(bob_store); } mod props { diff --git a/onomancy_protocol/src/test_utils.rs b/onomancy_protocol/src/test_utils.rs index ab06b09..0ac45ab 100644 --- a/onomancy_protocol/src/test_utils.rs +++ b/onomancy_protocol/src/test_utils.rs @@ -39,6 +39,18 @@ pub fn host() -> DnsName { DnsName::parse("expede.wtf").expect("valid hostname literal") } +/// A second, unrelated test hostname — the cross-hostname fixtures' +/// other name (B8 rotation-exclusion scope, per-host isolation). +/// +/// # Panics +/// +/// Never: the literal is valid. +#[must_use] +#[allow(clippy::expect_used)] +pub fn host2() -> DnsName { + DnsName::parse("example.org").expect("valid hostname literal") +} + /// A document anchor derived from seed bytes `[seed; 32]`. #[must_use] pub fn doc(seed: u8) -> DocAnchor { @@ -113,6 +125,34 @@ pub fn binding( ) } +/// A binding record for an arbitrary hostname — the multi-hostname +/// fixtures' factory. [`binding`] is this at [`host`]. +/// +/// # Errors +/// +/// Returns [`OversizeUnit`] when the unit would exceed the cap. +#[allow(clippy::too_many_arguments)] +pub fn binding_at( + hostname: DnsName, + doc_seed: u8, + gen_seed: u8, + chain_tag: u8, + serial: u64, + window_span: (u64, u64), + issued_at: u64, +) -> Result { + binding_carrying_at( + hostname, + doc_seed, + gen_seed, + chain_tag, + serial, + window_span, + issued_at, + vec![], + ) +} + /// A binding record whose certificate carries lineage statements — /// the extraction-closure input. /// @@ -127,13 +167,41 @@ pub fn binding_carrying( window_span: (u64, u64), issued_at: u64, lineage: Vec, +) -> Result { + binding_carrying_at( + host(), + doc_seed, + gen_seed, + chain_tag, + serial, + window_span, + issued_at, + lineage, + ) +} + +/// A statement-carrying binding record for an arbitrary hostname. +/// +/// # Errors +/// +/// Returns [`OversizeUnit`] when the unit would exceed the cap. +#[allow(clippy::too_many_arguments)] +pub fn binding_carrying_at( + hostname: DnsName, + doc_seed: u8, + gen_seed: u8, + chain_tag: u8, + serial: u64, + window_span: (u64, u64), + issued_at: u64, + lineage: Vec, ) -> Result { let chain = chain(chain_tag); let cert = Certificate::sign( CertificateParams { root_doc: doc(doc_seed), issued_at: UnixSeconds::from(issued_at), - hostname: host(), + hostname, heads: vec![], predecessor: None, delegation_chain: DelegationChain::default(), @@ -186,11 +254,27 @@ pub fn succession( predecessor_seed: u8, successor_seed: u8, signer_seed: u8, +) -> Result { + succession_at(&host(), predecessor_seed, successor_seed, signer_seed) +} + +/// A successor statement under an arbitrary hostname — the hostname +/// is inside the signature, so cross-hostname scenarios need their +/// own statements. +/// +/// # Errors +/// +/// Returns [`OversizeUnit`] when the unit would exceed the cap. +pub fn succession_at( + hostname: &DnsName, + predecessor_seed: u8, + successor_seed: u8, + signer_seed: u8, ) -> Result { SuccessorStatement::sign( &doc(predecessor_seed), &doc(successor_seed), - &host(), + hostname, &signer(signer_seed), DelegationChain::default(), ) diff --git a/onomancy_protocol/src/verifier/state/diff.rs b/onomancy_protocol/src/verifier/state/diff.rs index 33e2b91..fa09a1d 100644 --- a/onomancy_protocol/src/verifier/state/diff.rs +++ b/onomancy_protocol/src/verifier/state/diff.rs @@ -298,10 +298,134 @@ mod tests { ))); } + /// FIXME №6: entering contested-mask double-reports — the + /// prompt-class `BindingChanged { Some → None }` rides alongside + /// the badge-class `ContestedEntered`, though surfacing doctrine + /// says the mask transition should not prompt. This test pins + /// CURRENT behavior so the fix flips a visible assertion instead + /// of changing silent behavior. #[test] - fn identical_derivations_produce_no_events() { - let derivation = derivation(accepted(1, 100, BindingGrade::Provisional)); - assert!(derivation.diff(&derivation).is_empty()); + fn entering_contested_mask_double_reports_pinning_fixme_6() { + let before = derivation(accepted(1, 100, BindingGrade::Confirmed)); + let masked = derivation(BindingState { + contested: true, + ..BindingState::default() + }); + + let events = masked.diff(&before); + + assert!( + events + .iter() + .any(|event| event.kind == EventKind::ContestedEntered), + "the badge is the intended surfacing" + ); + // FIXME №6: should NOT be emitted on a pure mask transition. + assert!( + events.iter().any(|event| event.kind + == EventKind::BindingChanged { + from: Some(doc(1)), + to: None, + } + && event.kind.may_prompt()), + "FIXME №6 current behavior: the mask co-emits a \ + prompt-class binding change" + ); + } + + #[test] + fn serials_moving_up_or_across_documents_never_regress() { + // Upward move, same document: routine ratchet progress — + // silence. + let before = derivation(accepted(1, 100, BindingGrade::Confirmed)); + let up = derivation(accepted(1, 200, BindingGrade::Confirmed)); + assert!( + up.diff(&before).is_empty(), + "serials moving up are not events" + ); + + // Document move with a numerically lower serial: the ratchet + // is per-document, so this is a binding change and nothing + // else — a regression event across documents would misread + // unrelated serial spaces as a rollback. + let moved = derivation(accepted(2, 7, BindingGrade::Confirmed)); + let events = moved.diff(&before); + assert_eq!(events.len(), 1); + assert!(matches!(events[0].kind, EventKind::BindingChanged { .. })); + } + + #[test] + fn contested_clearing_is_a_badge() { + let contested = derivation(BindingState { + contested: true, + ..BindingState::default() + }); + let settled = derivation(BindingState::default()); + + let events = settled.diff(&contested); + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, EventKind::ContestedCleared); + assert!(!events[0].kind.may_prompt()); + } + + #[test] + fn pending_transitions_are_badges_both_ways() { + let quiet = derivation(BindingState::default()); + let pending = derivation(BindingState { + pending: vec![doc(3)], + ..BindingState::default() + }); + + let surfaced = pending.diff(&quiet); + assert_eq!(surfaced.len(), 1); + assert_eq!(surfaced[0].kind, EventKind::PendingSurfaced(doc(3))); + assert!(!surfaced[0].kind.may_prompt()); + + let cleared = quiet.diff(&pending); + assert_eq!(cleared.len(), 1); + assert_eq!(cleared[0].kind, EventKind::PendingCleared(doc(3))); + assert!(!cleared[0].kind.may_prompt()); + } + + #[test] + fn succession_forks_fire_once_like_lineage_forks() { + let fork = SuccessionFork { + predecessor: doc(1), + successors: vec![doc(2), doc(3)], + }; + let with_fork = derivation(BindingState { + succession_forks: vec![fork.clone()], + ..BindingState::default() + }); + + let events = with_fork.diff(&derivation(BindingState::default())); + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, EventKind::SuccessionForkSurfaced(fork)); + assert!(events[0].kind.may_prompt(), "forks are event-class"); + assert!(with_fork.diff(&with_fork).is_empty(), "old news"); + } + + #[test] + fn divergence_transitions_are_badges_both_ways() { + let divergence = Divergence { + alleged: doc(9), + source: super::super::binding_state::DivergenceSource::Pin, + }; + let quiet = derivation(accepted(1, 100, BindingGrade::Confirmed)); + let diverged = derivation(BindingState { + divergence: vec![divergence], + ..accepted(1, 100, BindingGrade::Confirmed) + }); + + let surfaced = diverged.diff(&quiet); + assert_eq!(surfaced.len(), 1); + assert_eq!(surfaced[0].kind, EventKind::DivergenceSurfaced(divergence)); + assert!(!surfaced[0].kind.may_prompt()); + + let cleared = quiet.diff(&diverged); + assert_eq!(cleared.len(), 1); + assert_eq!(cleared[0].kind, EventKind::DivergenceCleared(divergence)); + assert!(!cleared[0].kind.may_prompt()); } #[test] @@ -321,4 +445,158 @@ mod tests { ); assert!(with_fork.diff(&with_fork).is_empty(), "old news"); } + + mod props { + use super::*; + use crate::verifier::state::binding_state::DivergenceSource; + + /// The compact seed tuple behind [`arb_state`]: accepted + /// binding, contested flag, (pending, losing, divergence) + /// counts, fork seed. + type StateSeed = (Option<(u8, u64, bool)>, bool, (u8, u8, u8), u8); + + /// One arbitrary `BindingState` from compact seeds — every + /// field populated so the properties range over the whole + /// event vocabulary. + fn arb_state(seed: &StateSeed) -> BindingState { + let (accepted_seed, contested, (pending_n, losing_n, divergence_n), fork_seed) = seed; + + let accepted_binding = accepted_seed.map(|(doc_seed, serial, confirmed)| { + accepted( + doc_seed % 4, + serial % 64, + if confirmed { + BindingGrade::Confirmed + } else { + BindingGrade::Provisional + }, + ) + }); + + BindingState { + accepted: accepted_binding.as_ref().and_then(|s| s.accepted), + contested: *contested, + divergence: (0..divergence_n % 3) + .map(|i| Divergence { + alleged: doc(20 + i), + source: if i % 2 == 0 { + DivergenceSource::Claim + } else { + DivergenceSource::Pin + }, + }) + .collect(), + effective_serial: accepted_binding.and_then(|s| s.effective_serial), + forks: (0..fork_seed % 3) + .map(|i| Fork { + document: doc(i % 2), + at: generation(30 + i), + }) + .collect(), + losing_acceptances: (0..losing_n % 3).map(|i| doc(10 + i)).collect(), + pending: (0..pending_n % 3).map(|i| doc(5 + i)).collect(), + succession_forks: if fork_seed % 5 == 0 { + vec![SuccessionFork { + predecessor: doc(1), + successors: vec![doc(2), doc(3)], + }] + } else { + vec![] + }, + tenure: None, + } + } + + /// `diff(a, a) == []` for EVERY state, not one example: a + /// derivation compared with itself surfaces nothing. + #[test] + fn diff_of_identical_states_is_empty() { + bolero::check!().with_type::().for_each(|seed| { + let state = derivation(arb_state(seed)); + assert!(state.diff(&state).is_empty()); + }); + } + + /// Reversing a diff produces the duals: badges surface ⇄ + /// clear, binding changes and grade moves flip their + /// endpoints — while fork surfacing and serial regressions + /// are one-directional (fork removal and serial progress are + /// silent). + #[test] + fn diff_reversal_produces_event_duals() { + bolero::check!() + .with_type::<(StateSeed, StateSeed)>() + .for_each(|(before_seed, after_seed)| { + let before = derivation(arb_state(before_seed)); + let after = derivation(arb_state(after_seed)); + + let forward: Vec = + after.diff(&before).into_iter().map(|e| e.kind).collect(); + let backward: Vec = + before.diff(&after).into_iter().map(|e| e.kind).collect(); + + for kind in &forward { + match kind { + EventKind::BindingChanged { from, to } => assert!( + backward.contains(&EventKind::BindingChanged { + from: *to, + to: *from, + }), + "binding changes reverse" + ), + EventKind::GradeChanged { from, to } => assert!( + backward.contains(&EventKind::GradeChanged { + from: *to, + to: *from, + }), + "grade moves reverse" + ), + EventKind::ContestedEntered => { + assert!(backward.contains(&EventKind::ContestedCleared)); + } + EventKind::ContestedCleared => { + assert!(backward.contains(&EventKind::ContestedEntered)); + } + EventKind::PendingSurfaced(d) => { + assert!(backward.contains(&EventKind::PendingCleared(*d))); + } + EventKind::PendingCleared(d) => { + assert!(backward.contains(&EventKind::PendingSurfaced(*d))); + } + EventKind::LosingAcceptanceSurfaced(d) => { + assert!(backward.contains(&EventKind::LosingAcceptanceCleared(*d))); + } + EventKind::LosingAcceptanceCleared(d) => { + assert!( + backward.contains(&EventKind::LosingAcceptanceSurfaced(*d)) + ); + } + EventKind::DivergenceSurfaced(d) => { + assert!(backward.contains(&EventKind::DivergenceCleared(*d))); + } + EventKind::DivergenceCleared(d) => { + assert!(backward.contains(&EventKind::DivergenceSurfaced(*d))); + } + // One-directional kinds: their removal / + // reversal is silent, never an event. + EventKind::LineageForkSurfaced(fork) => assert!( + !backward.contains(&EventKind::LineageForkSurfaced(*fork)), + "fork removal is silent" + ), + EventKind::SuccessionForkSurfaced(fork) => assert!( + !backward + .contains(&EventKind::SuccessionForkSurfaced(fork.clone())), + "fork removal is silent" + ), + EventKind::SerialRegression { .. } => assert!( + !backward + .iter() + .any(|k| matches!(k, EventKind::SerialRegression { .. })), + "serial progress is silent" + ), + } + } + }); + } + } } diff --git a/onomancy_protocol/src/verifier/state/prune.rs b/onomancy_protocol/src/verifier/state/prune.rs index e27855f..8d5496e 100644 --- a/onomancy_protocol/src/verifier/state/prune.rs +++ b/onomancy_protocol/src/verifier/state/prune.rs @@ -238,6 +238,12 @@ mod tests { let (store, validator) = setup(&[&early, &late], vec![]); let pruned = assert_prune_invariant(&store, &validator, &Decisions::default()); + let kept: Set<_> = pruned.items().iter().map(Item::content_hash).collect(); + assert!( + kept.contains(&early.cert.digest().erase()) + && kept.contains(&late.cert.digest().erase()), + "BOTH incomparable records survive — by identity, not count" + ); assert_eq!(pruned.items().len(), 2); Ok(()) } @@ -263,12 +269,23 @@ mod tests { ..Decisions::default() }; - let (store, validator) = setup( - &[&weak, &strong], - vec![Item::Rotation(rotation(1, 40, 41)?)], - ); + let statement = rotation(1, 40, 41)?; + let (store, validator) = setup(&[&weak, &strong], vec![Item::Rotation(statement.clone())]); let pruned = assert_prune_invariant(&store, &validator, &decisions); + let kept: Set<_> = pruned.items().iter().map(Item::content_hash).collect(); + assert!( + kept.contains(&weak.cert.digest().erase()), + "the cited receipt survives" + ); + assert!( + kept.contains(&strong.cert.digest().erase()), + "the dominating record survives" + ); + assert!( + kept.contains(&Item::Rotation(statement).content_hash()), + "the statement survives" + ); assert_eq!( pruned.items().len(), 3, @@ -309,6 +326,92 @@ mod tests { Ok(()) } + #[test] + fn the_horizon_boundary_is_closed_at_exactly_one_year() -> TestResult { + // The `<=` vs `<` mutation: a serial at EXACTLY `now·1000 + + // horizon` is within bounds and survives beside a covering + // sibling; one past it drops (the previous test's case). + let at_horizon = binding( + 1, + 11, + 1, + NOW * 1000 + DEFERRAL_HORIZON_MS, + (NOW - 1_000, NOW + 1_000), + 10, + )?; + let anchor_record = binding(1, 11, 2, 100, (NOW - 5_000, NOW + 1_000), 20)?; + + let (store, validator) = setup(&[&at_horizon, &anchor_record], vec![]); + let pruned = assert_prune_invariant(&store, &validator, &Decisions::default()); + + let kept: Set<_> = pruned.items().iter().map(Item::content_hash).collect(); + assert!( + kept.contains(&at_horizon.cert.digest().erase()), + "exactly at the horizon is within bounds" + ); + Ok(()) + } + + #[test] + fn a_chain_refresh_never_prunes_a_certificate() -> TestResult { + // The `attestation_allows` guard: a bare refresh row + // component-wise better than the document's certificate — + // wider window, higher serial, and (with the certificate + // issued at 0) an equal issued_at — must NOT prune it: + // candidacy is certificate-attested, and a store holding only + // the zone's word cannot derive the binding at all. + let cert = binding(1, 11, 1, 50, (NOW - 4_000, NOW - 2_000), 0)?; + let refresh = binding(1, 11, 2, 100, (NOW - 5_000, NOW + 1_000), 0)?; + + let (mut store, mut validator) = setup(&[&cert], vec![]); + validator = validator.with(host(), &refresh.chain, refresh.proof.clone()); + store.insert(Item::ChainRefresh { + hostname: host(), + chain: refresh.chain.clone(), + }); + + let pruned = assert_prune_invariant(&store, &validator, &Decisions::default()); + + let kept: Set<_> = pruned.items().iter().map(Item::content_hash).collect(); + assert!( + kept.contains(&cert.cert.digest().erase()), + "the zone's word alone never evicts the document's own \ + direction" + ); + Ok(()) + } + + #[test] + fn a_certificate_may_prune_a_chain_refresh() -> TestResult { + // The reverse direction carries no such hazard: a certificate + // strictly better in every component drops the refresh row + // (refresh issued_at is 0 by construction, so any certificate + // issuance is at least as good). + let refresh = binding(1, 11, 2, 50, (NOW - 4_000, NOW - 2_000), 0)?; + let cert = binding(1, 11, 1, 100, (NOW - 5_000, NOW + 1_000), 20)?; + + let (mut store, mut validator) = setup(&[&cert], vec![]); + validator = validator.with(host(), &refresh.chain, refresh.proof.clone()); + store.insert(Item::ChainRefresh { + hostname: host(), + chain: refresh.chain.clone(), + }); + + let refresh_item = Item::ChainRefresh { + hostname: host(), + chain: refresh.chain.clone(), + }; + let pruned = assert_prune_invariant(&store, &validator, &Decisions::default()); + + let kept: Set<_> = pruned.items().iter().map(Item::content_hash).collect(); + assert!( + !kept.contains(&refresh_item.content_hash()), + "the dominated refresh row is reclaimed" + ); + assert!(kept.contains(&cert.cert.digest().erase())); + Ok(()) + } + #[test] fn equal_component_duplicates_are_kept_whole_and_survive_resets() -> TestResult { // Two certificates identical in every ladder component — @@ -363,4 +466,184 @@ mod tests { ); Ok(()) } + + mod props { + use super::*; + use crate::{ + test_utils::window, + verifier::state::{Attestation, store::item::Item}, + }; + use onomancy_dnssec::{txt::serial::Serial, zone_state_key::ZoneStateKey}; + + /// Build one `BindingEvidence` row from compact seeds — the + /// direct-domination-law fixture. + fn arb_record(tag: u8, seed: (u8, u64, u64, u64, u64)) -> BindingEvidence { + let (doc_seed, from, span, serial, issued_at) = seed; + let from = from % 1_000; + + BindingEvidence { + attestation: if tag.is_multiple_of(2) { + Attestation::Certificate + } else { + Attestation::ChainOnly + }, + document: crate::test_utils::doc(doc_seed % 2), + generation: crate::test_utils::generation((doc_seed % 2) + 10), + hash: Digest::hash(&[tag, doc_seed]), + hostname: host(), + key: ZoneStateKey { + window_end: UnixSeconds::from(from + (span % 1_000)), + serial: Serial::from(serial % 64), + issued_at: UnixSeconds::from(issued_at % 64), + }, + generation_on_path: true, + window: window(from, from + (span % 1_000)), + } + } + + /// `dominates` is irreflexive and antisymmetric — the + /// strictness clause, as a law rather than one example. + #[test] + fn dominates_is_irreflexive_and_antisymmetric() { + bolero::check!() + .with_type::<((u8, u64, u64, u64, u64), (u8, u64, u64, u64, u64))>() + .for_each(|(a_seed, b_seed)| { + let a = arb_record(0, *a_seed); + let b = arb_record(2, *b_seed); + + assert!(!dominates(&a, &a), "irreflexive"); + assert!( + !(dominates(&a, &b) && dominates(&b, &a)), + "antisymmetric: mutual domination would drop \ + both off the frontier" + ); + }); + } + + /// The generated-store pool the frontier and preservation + /// properties share: up to six records over two documents, + /// serials bounded far under the horizon. + fn build_store(specs: &[(bool, u64, u64, u64, u64)]) -> (Store, MemoryValidator) { + let mut store = Store::default(); + let mut validator = MemoryValidator::default(); + + for (i, (second_doc, serial, from, span, issued_at)) in specs.iter().take(6).enumerate() + { + #[allow(clippy::cast_possible_truncation)] + let tag = i as u8; + let doc_seed = u8::from(*second_doc) + 1; + let from = NOW - 10_000 + (from % 9_000); + + let Ok(b) = binding( + doc_seed, + doc_seed + 10, + tag, + serial % 1_000, + (from, from + (span % 2_000)), + issued_at % 100, + ) else { + continue; + }; + store.insert(Item::Record(b.cert.clone())); + validator = validator.with(host(), &b.chain, b.proof.clone()); + } + + (store, validator) + } + + /// The frontier, both directions: every dropped record is + /// dominated by a kept one, and a kept-but-dominated record + /// is only ever the tenure endpoint (its group's earliest + /// inception). + #[test] + fn prune_keeps_exactly_the_frontier() { + bolero::check!() + .with_type::>() + .for_each(|specs| { + let (store, validator) = build_store(specs); + let authority = MemoryAuthority::default(); + let now = UnixSeconds::from(NOW); + + let before = validate_and_extract(&store, &validator, &authority); + let pruned = prune(&store, now, &Decisions::default(), &validator, &authority); + let after = validate_and_extract(&pruned, &validator, &authority); + + let kept: Vec<&BindingEvidence> = after.records.iter().collect(); + + for record in &before.records { + let was_kept = kept.iter().any(|k| k.hash == record.hash); + + if was_kept { + // A kept record dominated by another KEPT + // record must be its group's tenure + // endpoint. + if kept.iter().any(|k| dominates(k, record)) { + let group_min = before + .records + .iter() + .filter(|r| r.document == record.document) + .map(|r| r.window.inception()) + .min(); + assert_eq!( + Some(record.window.inception()), + group_min, + "a dominated survivor must be the \ + tenure endpoint" + ); + } + } else { + // Every dropped record is dominated by a + // kept one (no horizon drops: serials are + // bounded small by construction). + assert!( + kept.iter().any(|k| dominates(k, record)), + "a dropped record must be dominated by \ + a kept one" + ); + } + } + }); + } + + /// The invariant, generatively: `derive(prune(s)) == + /// derive(s)` at `now` and later clocks within the horizon — + /// and pruning is idempotent. + #[test] + fn prune_never_changes_the_derivation_and_is_idempotent() { + bolero::check!() + .with_type::>() + .for_each(|specs| { + let (store, validator) = build_store(specs); + let authority = MemoryAuthority::default(); + let decisions = Decisions::default(); + let pins = Map::default(); + let now = UnixSeconds::from(NOW); + + let pruned = prune(&store, now, &decisions, &validator, &authority); + + // One hour and thirty days out — both far inside + // the one-year horizon. + for later in [NOW, NOW + 3_600, NOW + 30 * 24 * 3_600] { + let at = UnixSeconds::from(later); + assert_eq!( + VerifierState::compute( + &store, at, &decisions, &pins, &validator, &authority + ), + VerifierState::compute( + &pruned, at, &decisions, &pins, &validator, &authority + ), + "pruning changed the derivation at now + {}", + later - NOW + ); + } + + let twice = prune(&pruned, now, &decisions, &validator, &authority); + assert_eq!( + pruned.items().len(), + twice.items().len(), + "pruning is idempotent" + ); + }); + } + } } diff --git a/onomancy_protocol/src/verifier/verdict.rs b/onomancy_protocol/src/verifier/verdict.rs index 2f9d03d..6fdbe2b 100644 --- a/onomancy_protocol/src/verifier/verdict.rs +++ b/onomancy_protocol/src/verifier/verdict.rs @@ -262,6 +262,11 @@ mod tests { assert_eq!(verdict.serial, Serial::from(100)); assert_eq!(verdict.freshness, Freshness::Fresh); assert_eq!(verdict.generation_check, GenerationCheck::OnPath); + assert_eq!( + verdict.window, + crate::test_utils::window(NOW - 1_000, NOW + 1_000), + "the ∩-window rides the verdict for the caller's grading" + ); Ok(()) } @@ -280,6 +285,14 @@ mod tests { .expect("stale is a grade, not a rejection"); assert_eq!(verdict.freshness, Freshness::Stale); + assert_eq!( + verdict.document, + doc(1), + "the stale path extracts the same facts as the fresh one" + ); + assert_eq!(verdict.generation, generation(11)); + assert_eq!(verdict.serial, Serial::from(100)); + assert_eq!(verdict.generation_check, GenerationCheck::OnPath); Ok(()) } @@ -350,6 +363,57 @@ mod tests { Ok(()) } + #[test] + fn not_yet_begun_windows_defer() -> TestResult { + // The second deferral cause: the chain's window has not begun + // — evidence not yet in force, never malformed. + let b = binding(1, 11, 9, 100, (NOW + 1_000, NOW + 2_000), 50)?; + let validator = MemoryValidator::default().with(host(), &b.chain, b.proof.clone()); + + let rejection = verify( + &b.cert.encode(), + &host(), + UnixSeconds::from(NOW), + &validator, + &MemoryAuthority::default(), + ) + .expect_err("a not-yet-begun window defers"); + + let Rejection::Deferred(evidence) = rejection else { + panic!("expected deferral, got {rejection:?}"); + }; + + // The window is the cause this time, and it is readable. + assert!( + evidence.window.inception() > UnixSeconds::from(NOW), + "the not-yet-begun window must be readable from the deferral" + ); + assert_eq!(evidence.document, doc(1)); + Ok(()) + } + + #[test] + fn a_certificate_for_another_hostname_is_refused_by_name() -> TestResult { + // The one rejection that needs no validator at all: the + // certificate binds a different hostname, and the refusal + // carries WHICH one — a caller distinguishing "wrong name" + // from "bad zone" needs the found name, not prose. + let b = binding(1, 11, 1, 100, (NOW - 1_000, NOW + 1_000), 50)?; + let expected = onomancy_dnssec::dns_name::DnsName::parse("example.org")?; + + assert_eq!( + verify( + &b.cert.encode(), + &expected, + UnixSeconds::from(NOW), + &MemoryValidator::default(), + &MemoryAuthority::default(), + ), + Err(Rejection::HostnameMismatch { found: host() }) + ); + Ok(()) + } + #[test] fn unregistered_chains_are_rejected() -> TestResult { let b = binding(1, 11, 1, 100, (NOW - 1_000, NOW + 1_000), 50)?; @@ -426,24 +490,54 @@ mod tests { Ok(()) } + /// Any single bit flip anywhere in the unit is never a + /// DIFFERENT accepted verdict: it fails somewhere — decode for + /// signed-region flips, chain validation for attached-region + /// ones — or (defensively stated) verifies to the identical + /// verdict. Generalizes the old fixed-offset byte-40 flip, whose + /// magic offset was coupled to the wire layout. #[test] - fn tampered_bytes_fail_at_decode() -> TestResult { - let b = binding_carrying(1, 11, 7, 100, (NOW - 1_000, NOW + 1_000), 50, vec![])?; - let mut bytes = b.cert.encode(); - if let Some(byte) = bytes.get_mut(40) { - *byte ^= 0x01; - } - - assert!(matches!( - verify( - &bytes, - &host(), - UnixSeconds::from(NOW), - &MemoryValidator::default(), - &MemoryAuthority::default(), - ), - Err(Rejection::Decode(_)) - )); - Ok(()) + fn no_bit_flip_yields_a_different_verdict() { + let b = binding_carrying(1, 11, 7, 100, (NOW - 1_000, NOW + 1_000), 50, vec![]) + .expect("under the unit cap"); + let bytes = b.cert.encode(); + let validator = MemoryValidator::default().with(host(), &b.chain, b.proof.clone()); + let authority = MemoryAuthority::default(); + + let baseline = verify( + &bytes, + &host(), + UnixSeconds::from(NOW), + &validator, + &authority, + ) + .expect("the untampered unit verifies"); + + bolero::check!() + .with_type::<(usize, u8)>() + .for_each(|&(position, bit)| { + let mut flipped = bytes.clone(); + let at = position % flipped.len(); + if let Some(byte) = flipped.get_mut(at) { + *byte ^= 1 << (bit % 8); + } + + match verify( + &flipped, + &host(), + UnixSeconds::from(NOW), + &validator, + &authority, + ) { + Err(_) => (), + Ok(verdict) => assert_eq!( + verdict, + baseline, + "a flip at byte {at} bit {} produced a DIFFERENT \ + accepted verdict", + bit % 8 + ), + } + }); } } diff --git a/onomancy_protocol/tests/verifier_state_conformance.rs b/onomancy_protocol/tests/verifier_state_conformance.rs index b5f16ec..7b6b295 100644 --- a/onomancy_protocol/tests/verifier_state_conformance.rs +++ b/onomancy_protocol/tests/verifier_state_conformance.rs @@ -15,12 +15,14 @@ use testresult::TestResult; use onomancy_protocol::{ test_utils::{ - Binding, binding, binding_carrying, doc, generation, host, rotation, signer, succession, + Binding, binding, binding_at, binding_carrying, doc, generation, host, host2, rotation, + succession, }, verifier::state::{ VerifierState, - binding_state::{BindingGrade, BindingState, ContinuityGrade}, + binding_state::{BindingGrade, BindingState, ContinuityGrade, DivergenceSource}, decisions::{Acceptance, Claim, Decisions}, + diff::EventKind, memory::{authority::MemoryAuthority, validator::MemoryValidator}, store::{Store, item::Item}, }, @@ -28,32 +30,63 @@ use onomancy_protocol::{ const NOW: u64 = 1_755_000_000; +/// Serial deferral bound (5 minutes, ms convention) — mirrors the +/// derivation's private `SKEW_MS` for the boundary scenarios. +const SKEW_MS: u64 = 5 * 60 * 1000; + fn run(bindings: &[&Binding], decisions: &Decisions, extra: Vec) -> BindingState { + derive_full(bindings, decisions, extra) + .bindings + .get(&host()) + .cloned() + .unwrap_or_default() +} + +/// The full derivation, for scenarios that diff two states or read +/// more than one hostname. Bindings register under their own +/// certificate hostname, so multi-host fixtures need no extra setup. +fn derive_full(bindings: &[&Binding], decisions: &Decisions, extra: Vec) -> VerifierState { let mut validator = MemoryValidator::default(); let mut store = Store::default(); for b in bindings { - validator = validator.with(host(), &b.chain, b.proof.clone()); + validator = validator.with(b.cert.hostname().clone(), &b.chain, b.proof.clone()); store.insert(Item::Record(b.cert.clone())); } for item in extra { store.insert(item); } - let derivation = VerifierState::compute( + VerifierState::compute( &store, UnixSeconds::from(NOW), decisions, &Map::default(), &validator, &MemoryAuthority::default(), + ) +} + +/// Nothing survived: the FULL default state — rejection is +/// distinguishable from contested-masking (which also blanks +/// `accepted`, but sets `contested` and may leave forks standing). +fn assert_rejected(state: &BindingState) { + assert_eq!( + *state, + BindingState::default(), + "rejected evidence must leave no residue at all" ); +} - derivation - .bindings - .get(&host()) - .cloned() - .unwrap_or_default() +/// Contested-masked: the output is blanked BY the contest — the +/// other way `accepted` reads `None`. +fn assert_masked(state: &BindingState) { + assert!(state.contested, "masking requires a standing contest"); + assert!(state.accepted.is_none(), "contested output is empty"); + assert!( + state.effective_serial.is_none(), + "a masked output carries no serial" + ); } fn accept(document: DocAnchor, cited: &Binding) -> Decisions { @@ -96,7 +129,10 @@ fn sole_stale_first_contact_is_provisional_incumbent() -> TestResult { let state = run(&[&b], &Decisions::default(), vec![]); let accepted = state.accepted.expect("accepted"); + assert_eq!(accepted.document, doc(1), "the sole candidate is the one"); assert_eq!(accepted.grade, BindingGrade::Provisional); + assert_eq!(state.effective_serial, Some(Serial::from(100))); + assert!(!state.contested && state.pending.is_empty()); Ok(()) } @@ -116,6 +152,7 @@ fn stale_unproven_challenger_is_pending_never_displacing() -> TestResult { let accepted = state.accepted.expect("accepted"); assert_eq!(accepted.document, doc(1), "incumbent stands"); assert_eq!(state.pending, vec![doc(2)], "challenger quarantined"); + assert!(!state.contested, "never displaces — and never masks either"); Ok(()) } @@ -133,10 +170,41 @@ fn fresh_challenger_is_eligible_and_displaces() -> TestResult { let accepted = state.accepted.expect("accepted"); assert_eq!(accepted.document, doc(2), "fresh evidence is eligible"); assert_eq!(accepted.grade, BindingGrade::Confirmed); + assert_eq!( + accepted.continuity, + ContinuityGrade::Unproven, + "displacement without a proof path is surfaced as unproven — \ + the grade that gates the use-time MUST-prompt (B4)" + ); assert!(state.pending.is_empty()); Ok(()) } +#[test] +fn a_b2_displacement_emits_a_binding_change_in_the_diff() -> TestResult { + // B2's spec text: "output changes; binding-change event in the + // diff". Derived states, not handcrafted ones: the incumbent + // stands alone, then the fresh challenger arrives. + let incumbent = binding(1, 11, 1, 100, (NOW - 5000, NOW - 2000), 50)?; + let challenger = binding(2, 22, 2, 999, (NOW - 100, NOW + 1000), 60)?; + + let decisions = accept(doc(1), &incumbent); + let before = derive_full(&[&incumbent], &decisions, vec![]); + let after = derive_full(&[&incumbent, &challenger], &decisions, vec![]); + + let events = after.diff(&before); + assert!( + events.iter().any(|event| event.hostname == host() + && event.kind + == EventKind::BindingChanged { + from: Some(doc(1)), + to: Some(doc(2)), + }), + "the displacement is an event-class binding change: {events:?}" + ); + Ok(()) +} + #[test] fn succession_proof_makes_a_stale_challenger_eligible() -> TestResult { let incumbent = binding(1, 11, 1, 100, (NOW - 5000, NOW - 2000), 50)?; @@ -249,6 +317,50 @@ fn fresh_record_with_lower_serial_wins_and_resets_the_ratchet() -> TestResult { state.accepted.expect("accepted").grade, BindingGrade::Confirmed ); + assert_eq!( + state.tenure, + Some(onomancy_protocol::test_utils::window(NOW - 5000, NOW + 500)), + "tenure spans the document's evidence: earliest inception to \ + latest window end" + ); + Ok(()) +} + +#[test] +fn a_derived_ratchet_reset_surfaces_as_serial_regression() -> TestResult { + // The diff the test above's name promises, from DERIVED states: + // stale-high alone, then the fresh-low record arrives. The serial + // moves 999 → 7 (event-class), and the same transition carries + // the grade move provisional → confirmed (badge-class). + let stale_high = binding(1, 11, 1, 999, (NOW - 5000, NOW - 1000), 50)?; + let fresh_low = binding(1, 11, 2, 7, (NOW - 500, NOW + 500), 60)?; + + let before = derive_full(&[&stale_high], &Decisions::default(), vec![]); + let after = derive_full(&[&stale_high, &fresh_low], &Decisions::default(), vec![]); + + let events = after.diff(&before); + assert!( + events.iter().any(|event| { + event.kind + == EventKind::SerialRegression { + from: Serial::from(999), + to: Serial::from(7), + } + && event.kind.may_prompt() + }), + "the downward serial is the surfaced ratchet reset: {events:?}" + ); + assert!( + events.iter().any(|event| { + event.kind + == EventKind::GradeChanged { + from: BindingGrade::Provisional, + to: BindingGrade::Confirmed, + } + && !event.kind.may_prompt() + }), + "the grade move rides the same diff, badge-class: {events:?}" + ); Ok(()) } @@ -261,9 +373,7 @@ fn zone_equivocation_is_contested_with_empty_output() -> TestResult { let state = run(&[&a, &b], &Decisions::default(), vec![]); - assert!(state.contested); - assert!(state.accepted.is_none(), "contested output is empty"); - assert!(state.effective_serial.is_none()); + assert_masked(&state); Ok(()) } @@ -305,10 +415,7 @@ fn fresh_record_with_generation_off_path_is_rejected() -> TestResult { .get(&host()) .cloned() .unwrap_or_default(); - assert!( - state.accepted.is_none(), - "an off-path fresh record is rejected" - ); + assert_rejected(&state); Ok(()) } @@ -377,7 +484,7 @@ fn far_future_serials_are_deferred() -> TestResult { )?; let state = run(&[&poisoned], &Decisions::default(), vec![]); - assert!(state.accepted.is_none(), "deferred, not considered"); + assert_rejected(&state); Ok(()) } @@ -400,6 +507,11 @@ fn divergent_claims_badge_but_do_not_move_bindings() -> TestResult { assert_eq!(accepted.document, doc(1), "claims never move bindings"); assert_eq!(state.divergence.len(), 1); assert_eq!(state.divergence[0].alleged, doc(3)); + assert_eq!( + state.divergence[0].source, + DivergenceSource::Claim, + "the badge names its source — a claim, not a pin" + ); Ok(()) } @@ -674,9 +786,9 @@ mod succession_fork_isolation { assert_eq!(state.succession_forks.len(), 1); assert_eq!(state.succession_forks[0].predecessor, doc(1)); assert_eq!( - state.pending.len(), - 2, - "both unproven branches badge pending" + state.pending, + vec![doc(2), doc(3)], + "both unproven branches badge pending — these two, exactly" ); Ok(()) } @@ -685,17 +797,38 @@ mod succession_fork_isolation { mod props { use super::*; + /// Lehmer-code permutation: decode `code` into an arbitrary + /// reordering of `items` (Fisher–Yates driven by the code + /// digits). Reversal and rotation only ever explore `2n` of the + /// `n!` orders; this reaches all of them. + fn permute(items: &[T], code: &[u8]) -> Vec { + let mut pool: Vec = items.to_vec(); + let mut out = Vec::with_capacity(pool.len()); + + for (i, _) in items.iter().enumerate() { + let digit = usize::from(*code.get(i % code.len().max(1)).unwrap_or(&0)); + let pick = digit % pool.len(); + out.push(pool.remove(pick)); + } + + out + } + /// Verification target 7: the derivation is a function of the - /// evidence SET — any permutation (here: reversal and rotation) of - /// store insertion order yields identical output. The pool - /// includes carried statements, standalone statements, resets, and - /// acceptances — the inputs that made the first implementation - /// order-dependent. + /// evidence SET — any permutation (reversal, rotation, and an + /// arbitrary Lehmer-coded shuffle) of store insertion order + /// yields identical output, and the diff between two permuted + /// derivations is empty. The pool includes carried statements, + /// standalone statements, resets, acceptances, a bare chain + /// refresh, a second hostname, and pins — the inputs that made + /// the first implementation order-dependent, plus the cross-host + /// interference surfaces. #[test] + #[allow(clippy::too_many_lines)] // one property, one pool: splitting hides the scenario fn derivation_is_insertion_order_insensitive() { bolero::check!() - .with_type::<(Vec<(u8, u8, u64, u64, bool)>, usize)>() - .for_each(|(specs, rotate_by)| { + .with_type::<(Vec<(u8, u8, u64, u64, bool)>, usize, Vec)>() + .for_each(|(specs, rotate_by, lehmer)| { let bindings: Vec = specs .iter() .take(6) @@ -766,6 +899,36 @@ mod props { .with(host(), &carrier_x.chain, carrier_x.proof.clone()) .with(host(), &carrier_y.chain, carrier_y.proof.clone()); + // Cross-host interference surface: a binding at a + // second hostname, plus a bare refresh rider for the + // first one. + let elsewhere = binding_at( + host2(), + (specs[0].0 % 3) + 4, + (specs[0].0 % 3) + 14, + 102, + 9, + (NOW - 2000, NOW + 2000), + 3, + ) + .expect("under the unit cap"); + let refreshed = binding( + specs[0].0 % 3, + (specs[0].0 % 3) + 10, + 103, + 11, + (NOW - 1500, NOW + 1500), + 4, + ) + .expect("under the unit cap"); + validator = validator + .with(host2(), &elsewhere.chain, elsewhere.proof.clone()) + .with(host(), &refreshed.chain, refreshed.proof.clone()); + + let mut pins = Map::default(); + pins.insert(host(), vec![doc(7)]); + pins.insert(host2(), vec![doc((specs[0].0 % 3) + 4)]); + let mut items: Vec = bindings .iter() .map(|b| Item::Record(b.cert.clone())) @@ -773,6 +936,11 @@ mod props { items.extend([ Item::Record(carrier_x.cert.clone()), Item::Record(carrier_y.cert.clone()), + Item::Record(elsewhere.cert.clone()), + Item::ChainRefresh { + hostname: host(), + chain: refreshed.chain.clone(), + }, Item::Rotation(carried), Item::Rotation(rotation(specs[0].0 % 3, 41, 42).expect("under the unit cap")), Item::Successor( @@ -791,13 +959,14 @@ mod props { .cloned() .collect() }; + let shuffled: Store = permute(&items, lehmer).into_iter().collect(); let run = |store: &Store| { VerifierState::compute( store, UnixSeconds::from(NOW), &decisions, - &Map::default(), + &pins, &validator, &MemoryAuthority::default(), ) @@ -806,6 +975,16 @@ mod props { let baseline = run(&forward); assert_eq!(baseline, run(&reversed), "reversal changed the verdict"); assert_eq!(baseline, run(&rotated), "rotation changed the verdict"); + + let shuffled_state = run(&shuffled); + assert_eq!( + baseline, shuffled_state, + "an arbitrary permutation changed the verdict" + ); + assert!( + baseline.diff(&shuffled_state).is_empty(), + "permutations of one store must diff to nothing" + ); }); } } @@ -833,8 +1012,9 @@ mod certificate_signer_authority { &Decisions::default(), &Map::default(), &validator, - // The cert builder signs with `signer(200 ^ doc_seed)`. - &MemoryAuthority::default().deny(doc(1), &signer(200 ^ 1).verifying_key()), + // The certificate names its own signer — deny exactly that + // key, not a re-derivation of the factory's seed scheme. + &MemoryAuthority::default().deny(doc(1), b.cert.signer()), ); let state = derivation @@ -842,10 +1022,7 @@ mod certificate_signer_authority { .get(&host()) .cloned() .unwrap_or_default(); - assert!( - state.accepted.is_none(), - "an unauthorized signer's certificate must be inert" - ); + assert_rejected(&state); Ok(()) } } @@ -935,6 +1112,11 @@ mod refresh_corroboration { BindingGrade::Confirmed, "the refresh's fresh window is what confirms" ); + assert_eq!( + state.effective_serial, + Some(Serial::from(150)), + "the fresh refresh row is the document's ladder-best record" + ); Ok(()) } @@ -975,10 +1157,7 @@ mod refresh_corroboration { .cloned() .unwrap_or_default(); - assert_eq!( - state.accepted, None, - "a bare refresh must not confirm a binding whose certificate was rejected" - ); + assert_rejected(&state); Ok(()) } @@ -1022,10 +1201,7 @@ mod refresh_corroboration { .cloned() .unwrap_or_default(); - assert_eq!( - state.accepted, None, - "a bare refresh must not revive candidacy the rewind rule killed" - ); + assert_rejected(&state); Ok(()) } @@ -1328,8 +1504,7 @@ mod fresh_rewinds { vec![Item::Rotation(rotation(1, 2, 3)?)], ); - assert!(state.contested, "a 1-vs-1 equivocation is a contest"); - assert_eq!(state.accepted, None, "masked while the fork stands"); + assert_masked(&state); assert!( state.forks.iter().any(|f| f.at == generation(2)), "and surfaced" @@ -1337,3 +1512,675 @@ mod fresh_rewinds { Ok(()) } } + +/// The two deferral causes and the skew boundary (stage 2: exclude +/// and defer). +mod deferral { + use super::*; + + #[test] + fn not_yet_begun_windows_are_deferred() -> TestResult { + // The second `is_deferred` branch: a chain whose window has + // not begun is evidence not yet in force — not stale, not + // considered, nothing derived from it. + let early = binding(1, 11, 1, 100, (NOW + 1000, NOW + 2000), 50)?; + + let state = run(&[&early], &Decisions::default(), vec![]); + assert_rejected(&state); + Ok(()) + } + + #[test] + fn the_skew_boundary_is_closed_at_exactly_skew() -> TestResult { + // Serials are compared in the millisecond convention against + // `now·1000 + SKEW`: exactly at the bound is considered; + // one past it defers. The `>` vs `>=` mutation lives here. + let at_bound = binding(1, 11, 1, NOW * 1000 + SKEW_MS, (NOW - 1000, NOW + 1000), 50)?; + let state = run(&[&at_bound], &Decisions::default(), vec![]); + assert_eq!( + state + .accepted + .expect("exactly at the bound is in force") + .document, + doc(1) + ); + + let past_bound = binding( + 1, + 11, + 2, + NOW * 1000 + SKEW_MS + 1, + (NOW - 1000, NOW + 1000), + 50, + )?; + let state = run(&[&past_bound], &Decisions::default(), vec![]); + assert_rejected(&state); + Ok(()) + } +} + +/// Spec row B3: a pending candidate is refuted by fresh evidence for +/// the accepted binding — the badge clears without a prompt. +mod spec_row_b3 { + use super::*; + + // FIXME(spec B3): the pending badge does NOT clear when fresh + // evidence for the accepted binding refutes the challenger — the + // pending filter quarantines every stale, unproven, non-incumbent + // candidate unconditionally, so `PendingCleared` never fires on + // refutation. Spec row B3 says it must. This test pins CURRENT + // behavior so the eventual fix flips a visible assertion instead + // of changing silent behavior. + #[test] + fn pending_survives_refuting_fresh_evidence_pinning_the_b3_gap() -> TestResult { + let incumbent = binding(1, 11, 1, 100, (NOW - 5000, NOW - 2000), 50)?; + let challenger = binding(2, 22, 2, 999, (NOW - 1500, NOW - 100), 60)?; + let decisions = accept(doc(1), &incumbent); + + let before = derive_full(&[&incumbent, &challenger], &decisions, vec![]); + let pending_before = before + .bindings + .get(&host()) + .expect("derived") + .pending + .clone(); + assert_eq!( + pending_before, + vec![doc(2)], + "B1: the challenger quarantines" + ); + + // Fresh evidence for the ACCEPTED binding arrives: rung 0 now + // refutes the challenger's later-key claim outright. + let refuting = binding(1, 11, 3, 120, (NOW - 500, NOW + 500), 70)?; + let after = derive_full(&[&incumbent, &challenger, &refuting], &decisions, vec![]); + let state = after.bindings.get(&host()).expect("derived").clone(); + + assert_eq!( + state.accepted.expect("incumbent confirmed").grade, + BindingGrade::Confirmed + ); + + // The spec-conformant assertions, inverted to pin the gap: + assert_eq!( + state.pending, + vec![doc(2)], + "FIXME(spec B3): should be empty — refuted challengers must \ + leave quarantine" + ); + let events = after.diff(&before); + assert!( + !events + .iter() + .any(|e| matches!(e.kind, EventKind::PendingCleared(_))), + "FIXME(spec B3): should emit PendingCleared(doc 2), \ + badge-class" + ); + Ok(()) + } +} + +/// Spec row B6, the derivation-side half: claims are retained +/// provenance — a matching claim diverges nothing, and a claim keeps +/// its hostname in the universe. +mod spec_row_b6 { + use super::*; + + #[test] + fn a_claim_matching_the_accepted_binding_diverges_nothing() -> TestResult { + let b = binding(1, 11, 1, 100, (NOW - 1000, NOW + 1000), 50)?; + + let decisions = Decisions { + claims: vec![Claim { + hostname: host(), + document: doc(1), // matches the verified record + note: None, + }], + ..Decisions::default() + }; + + let state = run(&[&b], &decisions, vec![]); + assert_eq!(state.accepted.expect("accepted").document, doc(1)); + assert!( + state.divergence.is_empty(), + "the verified record takes display precedence; the \ + retained claim agrees, so no badge" + ); + Ok(()) + } + + #[test] + fn a_claim_alone_keeps_its_hostname_in_the_universe() { + // No records anywhere: the claimed hostname must still get a + // derived (empty) state — claims are a universe input, so a + // caller can observe "claimed but unverified" as a hostname + // with no accepted binding rather than as a missing key. + let decisions = Decisions { + claims: vec![Claim { + hostname: host(), + document: doc(3), + note: None, + }], + ..Decisions::default() + }; + + let derivation = derive_full(&[], &decisions, vec![]); + let state = derivation + .bindings + .get(&host()) + .expect("the claimed hostname is in the universe"); + assert_eq!(*state, BindingState::default()); + } + + #[test] + fn a_claim_keeps_contributing_after_a_verified_record_arrives() -> TestResult { + // The claim is retained provenance: when the verified record + // later disagrees, the OLD claim must still badge — it was + // never consumed by the earlier agreement. + let first = binding(3, 33, 1, 100, (NOW - 9000, NOW - 5000), 40)?; + let displacing = binding(1, 11, 2, 200, (NOW - 500, NOW + 500), 60)?; + + let decisions = Decisions { + claims: vec![Claim { + hostname: host(), + document: doc(3), + note: None, + }], + ..Decisions::default() + }; + + // While the record agrees with the claim: no divergence. + let state = run(&[&first], &decisions, vec![]); + assert!(state.divergence.is_empty()); + + // A fresh record for another document displaces: the retained + // claim now diverges from the new accepted binding. + let state = run(&[&first, &displacing], &decisions, vec![]); + assert_eq!(state.accepted.expect("accepted").document, doc(1)); + assert_eq!(state.divergence.len(), 1); + assert_eq!(state.divergence[0].alleged, doc(3)); + assert_eq!(state.divergence[0].source, DivergenceSource::Claim); + Ok(()) + } +} + +/// Spec row B8, the cross-hostname half: rotation exclusions are +/// document-scoped (a reset at ANY hostname excludes the statement +/// everywhere), while record exclusions stay hostname-scoped. +mod spec_row_b8_cross_hostname { + use super::*; + + #[test] + fn a_reset_at_another_hostname_excludes_a_rotation_everywhere() -> TestResult { + // Settled lineage G11→G12 makes G11 protected: the stale + // higher-key record attesting G11 is rewind-rejected and the + // G12 record wins. Excluding the rotation FROM A RESET AT A + // DIFFERENT HOSTNAME must lift that protection here too — + // rotation statements are document-scoped, and a user who + // reset the statement's evidence anywhere meant it everywhere. + let statement = rotation(1, 11, 12)?; + let old_gen = binding(1, 11, 1, 200, (NOW - 5000, NOW - 1000), 90)?; + let new_gen = binding(1, 12, 2, 50, (NOW - 6000, NOW - 2000), 10)?; + + let with_statement = run( + &[&old_gen, &new_gen], + &Decisions::default(), + vec![Item::Rotation(statement.clone())], + ); + assert_eq!( + with_statement.accepted.expect("accepted").generation, + generation(12), + "fixture sanity: with the rotation in force, the rewind is \ + rejected and descent orders G12 first" + ); + + // The reset lives at host2 — a hostname with no evidence at + // all — and names the rotation statement. + let mut resets = Map::default(); + let mut excluded = Set::default(); + excluded.insert(Item::Rotation(statement.clone()).content_hash()); + resets.insert(host2(), excluded); + let decisions = Decisions { + resets, + ..Decisions::default() + }; + + let state = run( + &[&old_gen, &new_gen], + &decisions, + vec![Item::Rotation(statement)], + ); + assert_eq!( + state.accepted.expect("accepted").generation, + generation(11), + "the exclusion crossed hostnames: no lineage, no rewind \ + rule, the higher key wins" + ); + Ok(()) + } + + #[test] + fn record_exclusions_never_leak_across_hostnames() -> TestResult { + // The inverse scope rule: a reset at host2 naming a RECORD + // held for host() must not exclude it there — record + // exclusions apply at their natural (hostname) scope. + let b = binding(1, 11, 1, 100, (NOW - 1000, NOW + 1000), 50)?; + + let mut resets = Map::default(); + let mut excluded = Set::default(); + excluded.insert(b.cert.digest().erase()); + resets.insert(host2(), excluded); + let decisions = Decisions { + resets, + ..Decisions::default() + }; + + let state = run(&[&b], &decisions, vec![]); + assert_eq!( + state.accepted.expect("accepted").document, + doc(1), + "a reset at another hostname does not touch this one's \ + records" + ); + Ok(()) + } +} + +/// Spec row B11: a fork implicating provisional support demotes and +/// surfaces. +mod spec_row_b11 { + use super::*; + + #[test] + fn a_fork_implicating_provisional_support_stays_demoted_and_surfaces() -> TestResult { + // Provisional (stale-supported) binding on G12 under settled + // G11→G12. A competing statement G11→G13 then implicates the + // accepted generation. The output must not firm up or move + // silently: it stays provisional (demoted), the record + // survives (fork territory is surfaced, never silently + // rejected), and the fork rides the diff as an event-class + // change. + let rec = binding(1, 12, 1, 100, (NOW - 5000, NOW - 2000), 50)?; + let settled = vec![Item::Rotation(rotation(1, 11, 12)?)]; + let forked = vec![ + Item::Rotation(rotation(1, 11, 12)?), + Item::Rotation(rotation(1, 11, 13)?), + ]; + + let before = derive_full(&[&rec], &Decisions::default(), settled); + let before_state = before.bindings.get(&host()).expect("derived"); + assert_eq!( + before_state.accepted.expect("accepted").grade, + BindingGrade::Provisional, + "fixture sanity: stale support is provisional" + ); + assert!(before_state.forks.is_empty()); + + let after = derive_full(&[&rec], &Decisions::default(), forked); + let state = after.bindings.get(&host()).expect("derived"); + + let accepted = state + .accepted + .expect("fork territory is surfaced, not blanked"); + assert_eq!(accepted.generation, generation(12)); + assert_eq!( + accepted.grade, + BindingGrade::Provisional, + "the output stays demoted while the fork stands" + ); + assert!( + state.forks.iter().any(|f| f.at == generation(11)), + "the implicating fork is surfaced" + ); + + let events = after.diff(&before); + assert!( + events.iter().any(|event| matches!( + event.kind, + EventKind::LineageForkSurfaced(fork) if fork.at == generation(11) + )), + "the fork's arrival is an event in the derived diff: {events:?}" + ); + Ok(()) + } +} + +/// Spec row B13, second half: acceptances with zone-state-equal +/// receipts for different documents are contested. +mod receipt_tie_contest { + use super::*; + + #[test] + fn zone_state_equal_receipts_for_different_documents_contest() -> TestResult { + // Two acceptances, each citing a record whose zone-state key + // is FULLY equal to the other's — the user's own records + // disagree, and no tiebreak may pick one silently. + let a = binding(1, 11, 1, 100, (NOW - 5000, NOW - 1000), 50)?; + let b = binding(2, 22, 2, 100, (NOW - 5000, NOW - 1000), 50)?; + + let mut decisions = accept(doc(1), &a); + let mut cited = Set::default(); + cited.insert(b.cert.digest().erase()); + decisions + .acceptances + .get_mut(&host()) + .expect("acceptance entry") + .push(Acceptance { + document: doc(2), + cited, + }); + + let state = run(&[&a, &b], &decisions, vec![]); + + assert_masked(&state); + assert!( + state.losing_acceptances.is_empty(), + "receipt ties are the contest itself, never losers" + ); + Ok(()) + } +} + +/// Acceptance evaluability: not-held receipts wait, excluded receipts +/// are inert, malformed receipts contribute nothing. +mod acceptance_evaluability { + use super::*; + + /// The fixture: an older acceptance-backed incumbent and a newer + /// stale challenger. When the acceptance is IN FORCE the incumbent + /// stands; whenever it is not, the ladder-maximal challenger wins. + fn contested_pair() -> Result<(Binding, Binding), onomancy_core::wire::OversizeUnit> { + Ok(( + binding(1, 11, 1, 100, (NOW - 9000, NOW - 5000), 10)?, + binding(2, 22, 2, 200, (NOW - 4000, NOW - 1000), 20)?, + )) + } + + fn acceptance_for( + document: DocAnchor, + cited: Set>, + ) -> Decisions { + let mut acceptances = Map::default(); + acceptances.insert(host(), vec![Acceptance { document, cited }]); + Decisions { + acceptances, + ..Decisions::default() + } + } + + #[test] + fn the_control_shape_holds_the_incumbent() -> TestResult { + // Sanity for the module's fixture: with a well-formed, + // fully-held receipt the incumbent stands and the challenger + // is pending. + let (incumbent, challenger) = contested_pair()?; + let state = run( + &[&incumbent, &challenger], + &accept(doc(1), &incumbent), + vec![], + ); + assert_eq!(state.accepted.expect("accepted").document, doc(1)); + assert_eq!(state.pending, vec![doc(2)]); + Ok(()) + } + + #[test] + fn an_acceptance_citing_a_not_held_item_is_not_yet_evaluable() -> TestResult { + // The receipt names an item this store has never seen: the + // acceptance waits (not-yet-evaluable), so no incumbency — + // the ladder-maximal challenger wins on its later key. + let (incumbent, challenger) = contested_pair()?; + let never_held = binding(1, 11, 9, 999, (NOW - 100, NOW + 100), 99)?; + + let mut cited = Set::default(); + cited.insert(never_held.cert.digest().erase()); + let state = run( + &[&incumbent, &challenger], + &acceptance_for(doc(1), cited), + vec![], + ); + + assert_eq!( + state.accepted.expect("accepted").document, + doc(2), + "an unevaluable acceptance confers no incumbency" + ); + Ok(()) + } + + #[test] + fn an_acceptance_citing_a_reset_excluded_item_is_inert() -> TestResult { + // The receipt is held but reset-excluded: the acceptance is + // inert (B8's closure includes the receipts that relied on + // the excluded evidence). + let (incumbent, challenger) = contested_pair()?; + + let mut decisions = accept(doc(1), &incumbent); + let mut excluded = Set::default(); + excluded.insert(incumbent.cert.digest().erase()); + decisions.resets.insert(host(), excluded); + + let state = run(&[&incumbent, &challenger], &decisions, vec![]); + assert_eq!( + state.accepted.expect("accepted").document, + doc(2), + "an inert acceptance confers no incumbency — and the \ + excluded record itself is gone too" + ); + Ok(()) + } + + #[test] + fn an_acceptance_citing_a_non_record_item_is_malformed() -> TestResult { + // The cited item is HELD — but it is a rotation statement, + // not a record. Receipt shape requires records; malformed + // receipts contribute nothing. + let (incumbent, challenger) = contested_pair()?; + let statement = rotation(7, 71, 72)?; + + let mut cited = Set::default(); + cited.insert(incumbent.cert.digest().erase()); + cited.insert(Item::Rotation(statement.clone()).content_hash()); + + let state = run( + &[&incumbent, &challenger], + &acceptance_for(doc(1), cited), + vec![Item::Rotation(statement)], + ); + + assert_eq!( + state.accepted.expect("accepted").document, + doc(2), + "a receipt citing a non-record item is void" + ); + Ok(()) + } + + #[test] + fn an_acceptance_citing_another_hostnames_record_is_malformed() -> TestResult { + // Every cited item must be a record for THIS hostname: a + // receipt reaching across hostnames is malformed, however + // genuine the foreign record. + let (incumbent, challenger) = contested_pair()?; + let foreign = binding_at(host2(), 1, 11, 9, 300, (NOW - 4000, NOW - 1000), 30)?; + + let mut cited = Set::default(); + cited.insert(incumbent.cert.digest().erase()); + cited.insert(foreign.cert.digest().erase()); + + let state = run( + &[&incumbent, &challenger, &foreign], + &acceptance_for(doc(1), cited), + vec![], + ); + + assert_eq!( + state.accepted.expect("accepted").document, + doc(2), + "a cross-hostname receipt is void" + ); + Ok(()) + } + + #[test] + fn an_acceptance_whose_receipts_never_attest_its_document_is_malformed() -> TestResult { + // At least one cited record must attest the acceptance's own + // document: accepting doc(5) on the strength of doc(1)'s + // records is a shape error, not a choice. + let (incumbent, challenger) = contested_pair()?; + + let mut cited = Set::default(); + cited.insert(incumbent.cert.digest().erase()); + let state = run( + &[&incumbent, &challenger], + &acceptance_for(doc(5), cited), + vec![], + ); + + assert_eq!( + state.accepted.expect("accepted").document, + doc(2), + "receipts that never attest the accepted document are void" + ); + Ok(()) + } +} + +/// Pins: divergence badges and hostname-universe inclusion (the +/// derivation's fourth input). +mod pins { + use super::*; + + fn derive_with_pins( + bindings: &[&Binding], + pins: &Map>, + ) -> VerifierState { + let mut validator = MemoryValidator::default(); + let mut store = Store::default(); + for b in bindings { + validator = validator.with(b.cert.hostname().clone(), &b.chain, b.proof.clone()); + store.insert(Item::Record(b.cert.clone())); + } + + VerifierState::compute( + &store, + UnixSeconds::from(NOW), + &Decisions::default(), + pins, + &validator, + &MemoryAuthority::default(), + ) + } + + #[test] + fn a_pin_disagreeing_with_the_accepted_binding_badges_pin_divergence() -> TestResult { + let b = binding(1, 11, 1, 100, (NOW - 1000, NOW + 1000), 50)?; + + let mut pins = Map::default(); + pins.insert(host(), vec![doc(9)]); + + let derivation = derive_with_pins(&[&b], &pins); + let state = derivation.bindings.get(&host()).expect("derived"); + + assert_eq!(state.accepted.expect("accepted").document, doc(1)); + assert_eq!(state.divergence.len(), 1); + assert_eq!(state.divergence[0].alleged, doc(9)); + assert_eq!( + state.divergence[0].source, + DivergenceSource::Pin, + "the badge names its source — a pin, not a claim" + ); + Ok(()) + } + + #[test] + fn a_matching_pin_diverges_nothing() -> TestResult { + let b = binding(1, 11, 1, 100, (NOW - 1000, NOW + 1000), 50)?; + + let mut pins = Map::default(); + pins.insert(host(), vec![doc(1)]); + + let derivation = derive_with_pins(&[&b], &pins); + let state = derivation.bindings.get(&host()).expect("derived"); + assert!(state.divergence.is_empty()); + Ok(()) + } + + #[test] + fn a_pin_alone_keeps_its_hostname_in_the_universe() { + // Pin-driven universe inclusion: a pinned hostname with no + // evidence at all still derives a (default) state. + let mut pins = Map::default(); + pins.insert(host2(), vec![doc(4)]); + + let derivation = derive_with_pins(&[], &pins); + let state = derivation + .bindings + .get(&host2()) + .expect("the pinned hostname is in the universe"); + assert_eq!(*state, BindingState::default()); + } +} + +/// Multi-hostname derivation: per-host isolation and deterministic +/// diff ordering — the suite's two-hostname fixtures. +mod multi_hostname { + use super::*; + + #[test] + fn hostnames_derive_in_isolation() -> TestResult { + // Different documents bound at different hostnames, one + // derivation: each hostname sees exactly its own evidence. + let at_one = binding(1, 11, 1, 100, (NOW - 1000, NOW + 1000), 50)?; + let at_two = binding_at(host2(), 2, 22, 2, 999, (NOW - 1000, NOW + 1000), 60)?; + + let derivation = derive_full(&[&at_one, &at_two], &Decisions::default(), vec![]); + + let one = derivation.bindings.get(&host()).expect("host 1 derived"); + assert_eq!(one.accepted.expect("accepted").document, doc(1)); + assert_eq!(one.effective_serial, Some(Serial::from(100))); + assert!(one.pending.is_empty(), "host2's evidence never leaks in"); + + let two = derivation.bindings.get(&host2()).expect("host 2 derived"); + assert_eq!(two.accepted.expect("accepted").document, doc(2)); + assert_eq!(two.effective_serial, Some(Serial::from(999))); + assert!(two.pending.is_empty(), "host1's evidence never leaks in"); + Ok(()) + } + + #[test] + fn a_contest_at_one_hostname_never_masks_another() -> TestResult { + // Zone equivocation at host(): masked there, untouched at + // host2(). + let a = binding(1, 11, 1, 100, (NOW - 5000, NOW - 1000), 50)?; + let b = binding(2, 22, 2, 100, (NOW - 5000, NOW - 1000), 99)?; + let elsewhere = binding_at(host2(), 3, 33, 3, 100, (NOW - 1000, NOW + 1000), 50)?; + + let derivation = derive_full(&[&a, &b, &elsewhere], &Decisions::default(), vec![]); + + assert_masked(derivation.bindings.get(&host()).expect("derived")); + let two = derivation.bindings.get(&host2()).expect("derived"); + assert!(!two.contested); + assert_eq!(two.accepted.expect("accepted").document, doc(3)); + Ok(()) + } + + #[test] + fn diff_events_arrive_in_sorted_hostname_order() -> TestResult { + // "example.org" < "expede.wtf": the diff's hostname ordering + // is deterministic regardless of map iteration order. + let at_one = binding(1, 11, 1, 100, (NOW - 1000, NOW + 1000), 50)?; + let at_two = binding_at(host2(), 2, 22, 2, 200, (NOW - 1000, NOW + 1000), 60)?; + + let before = derive_full(&[], &Decisions::default(), vec![]); + let after = derive_full(&[&at_one, &at_two], &Decisions::default(), vec![]); + + let events = after.diff(&before); + let hostnames: Vec<_> = events.iter().map(|e| e.hostname.clone()).collect(); + let mut sorted = hostnames.clone(); + sorted.sort_unstable(); + assert_eq!(hostnames, sorted, "events sort by hostname"); + assert!(hostnames.contains(&host()) && hostnames.contains(&host2())); + Ok(()) + } +} diff --git a/onomancy_publish/tests/ceremonies.rs b/onomancy_publish/tests/ceremonies.rs index b73fb8b..062ac50 100644 --- a/onomancy_publish/tests/ceremonies.rs +++ b/onomancy_publish/tests/ceremonies.rs @@ -54,11 +54,78 @@ fn bind_emits_a_verified_plan() -> TestResult { assert_eq!(plan.artifacts[0].kind, ArtifactKind::Certificate); assert!(plan.postconditions.iter().any(|p| matches!( p, - Postcondition::VerifiesFresh(fresh) if fresh.document == doc(1) + Postcondition::VerifiesFresh(fresh) + if fresh.document == doc(1) && fresh.generation == generation(2) ))); + assert!( + plan.postconditions.iter().any(|p| matches!( + p, + Postcondition::EffectiveSerialAtLeast { serial, .. } + if *serial == Serial::from(NOW_MS) + )), + "the ratchet floor is the plan's own serial" + ); Ok(()) } +/// The module's central claim, refused for real: a plan is a witness +/// only against the authority its verifiers will run, so a carriage +/// the authority rejects fails at PLAN time — not at the first +/// verifier. +#[test] +fn a_rejecting_authority_refuses_the_plan() { + let bind = Bind { + hostname: hostname(), + document: doc(1), + generation: generation(2), + heads: vec![], + lineage: vec![], + carriage: DelegationChain::default(), + }; + + // An off-path generation: the derivation D10-rejects the fresh + // record, so nothing derives — the same verdict every live + // verifier would reach. + let off_path = bind.clone().plan( + NOW_MS, + &signer(1), + &MemoryAuthority::default().off_path(&generation(2)), + ); + assert_eq!(off_path.unwrap_err(), CeremonyError::DerivesNothing); + + // A denied certificate signer: the certificate contributes no + // candidacy under seam parity, so nothing derives. + let denied = bind.plan( + NOW_MS, + &signer(1), + &MemoryAuthority::default().deny(doc(1), &SigningKey::from_bytes(&[1; 32]).verifying_key()), + ); + assert_eq!(denied.unwrap_err(), CeremonyError::DerivesNothing); +} + +/// The encoder cap surfaces as a ceremony error: a carriage too +/// large to ride the certificate's attached region refuses at +/// signing, before any simulation. +#[test] +fn oversize_carriages_refuse_at_plan_time() { + use onomancy_core::delegation_chain::SignedDelegationBytes; + + let oversize = Bind { + hostname: hostname(), + document: doc(1), + generation: generation(2), + heads: vec![], + lineage: vec![], + carriage: DelegationChain::from(vec![SignedDelegationBytes::from(vec![ + 0u8; + 2 * 1024 * 1024 + ])]), + } + .plan(NOW_MS, &signer(1), &MemoryAuthority::default()); + + assert!(matches!(oversize, Err(CeremonyError::Oversize(_)))); +} + #[test] fn rotate_emits_statement_and_certificate() -> TestResult { let plan = Rotate { @@ -176,7 +243,13 @@ fn migrate_dual_publishes_and_proves_continuity() -> TestResult { .any(|a| a.kind == ArtifactKind::SuccessorStatement) ); // The simulation already asserted the successor wins the - // dual-publish derivation via the proof (not zone-state luck). + // dual-publish derivation via the proof (not zone-state luck); + // the postcondition states the continuity claim explicitly. + assert!(plan.postconditions.iter().any(|p| matches!( + p, + Postcondition::VerifiesFresh(fresh) + if fresh.document == doc(5) && fresh.generation == generation(6) + ))); Ok(()) } @@ -209,6 +282,57 @@ fn refresh_is_keyless_and_zone_untouched() -> TestResult { assert!(refreshed.dns_ops.is_empty(), "refresh never touches DNS"); assert_eq!(refreshed.artifacts.len(), 1); + + // The refresh actually happened: the artifact decodes, is the + // SAME certificate (untouched signed region), and carries the + // new chain rather than the bind's empty one. + let original = onomancy_dnssec::certificate::Certificate::decode(&bound.artifacts[0].bytes)?; + let refreshed_cert = + onomancy_dnssec::certificate::Certificate::decode(&refreshed.artifacts[0].bytes)?; + assert!( + original.same_certificate(&refreshed_cert), + "same signed region: no key was involved" + ); + assert_eq!( + refreshed_cert.dnssec_chain().links().len(), + 1, + "the fresh chain rode along" + ); + assert!( + original.dnssec_chain().links().is_empty(), + "which the bind's certificate did not have" + ); + Ok(()) +} + +/// The refresh's own pre-simulation refusal: records that prove a +/// DIFFERENT document cannot refresh this certificate — the error a +/// user hits when refreshing against the wrong zone fetch. +#[test] +fn refreshing_against_another_documents_records_derives_nothing() -> TestResult { + let bound = Bind { + hostname: hostname(), + document: doc(1), + generation: generation(2), + heads: vec![], + lineage: vec![], + carriage: DelegationChain::default(), + } + .plan(NOW_MS, &signer(1), &MemoryAuthority::default())?; + let certificate = onomancy_dnssec::certificate::Certificate::decode(&bound.artifacts[0].bytes)?; + + // The zone fetch proves doc(9)'s record, not doc(1)'s. + let refused = Refresh { + certificate, + chain: onomancy_dnssec::chain::DnssecChain::from(vec![vec![0xAB; 8].into()]), + records: vec![TxtRecord::new(Serial::from(NOW_MS), generation(2), doc(9))], + } + .plan( + onomancy_core::time::UnixSeconds::from(NOW_MS / 1000), + &MemoryAuthority::default(), + ); + + assert_eq!(refused.unwrap_err(), CeremonyError::DerivesNothing); Ok(()) } diff --git a/onomancy_wasm/Cargo.toml b/onomancy_wasm/Cargo.toml index d5655e4..14808a0 100644 --- a/onomancy_wasm/Cargo.toml +++ b/onomancy_wasm/Cargo.toml @@ -56,21 +56,19 @@ web-sys = { workspace = true, optional = true, features = [ [dev-dependencies] # Exact-pinned lockstep with wasm-bindgen =0.2.121 (runner schema must match). wasm-bindgen-test = "=0.3.71" +# The Response synthesizer the offline DoH transport tests hand to a +# fake fetch; unifies with the crate's own web-sys feature set. +web-sys = { workspace = true, features = ["ResponseInit"] } + +# bolero's runtime does not compose with wasm-bindgen-test; the clock +# property runs on the host only. +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +bolero = { workspace = true } [features] default = ["doh", "names"] -names = [ - "dep:automerge", - "dep:getrandom", - "dep:onomancy_automerge", - "std", -] -doh = [ - "dep:hickory-proto", - "dep:onomancy_chain", - "dep:web-sys", - "std", -] +names = ["dep:automerge", "dep:getrandom", "dep:onomancy_automerge", "std"] +doh = ["dep:hickory-proto", "dep:onomancy_chain", "dep:web-sys", "std"] live = [] std = [ "js-sys/std", diff --git a/onomancy_wasm/e2e/fixtures/README.md b/onomancy_wasm/e2e/fixtures/README.md new file mode 100644 index 0000000..cf7f295 --- /dev/null +++ b/onomancy_wasm/e2e/fixtures/README.md @@ -0,0 +1,9 @@ +# e2e fixtures + +- `real_brooklynzelenka_carriage.onc` — a **byte copy** of the frozen + production capture at + `onomancy_dnssec/tests/fixtures/real_brooklynzelenka_carriage.onc` + (see that directory's README: do not regenerate). Copied because the + Playwright server's root is `onomancy_wasm/`, so the original is + unreachable from the browser. If the original ever changes (it must + not), this copy drifts loudly: the spec asserts exact field values. diff --git a/onomancy_wasm/e2e/fixtures/real_brooklynzelenka_carriage.onc b/onomancy_wasm/e2e/fixtures/real_brooklynzelenka_carriage.onc new file mode 100644 index 0000000..3e401dc Binary files /dev/null and b/onomancy_wasm/e2e/fixtures/real_brooklynzelenka_carriage.onc differ diff --git a/onomancy_wasm/e2e/tests/documents.spec.ts b/onomancy_wasm/e2e/tests/documents.spec.ts index e5424cf..4677f2d 100644 --- a/onomancy_wasm/e2e/tests/documents.spec.ts +++ b/onomancy_wasm/e2e/tests/documents.spec.ts @@ -19,21 +19,43 @@ test("mints documents the name grammar accepts", async ({ page }) => { expect(minted.anchors).toEqual([minted.anchor]); }); +// A saved Automerge document holding BOTH a reference edge and a +// plain value at another root key — generated once with the +// onomancy_automerge writer (`pics` → the anchor below; `note` → +// prose). The exclusion half cannot be staged through `bind`, which +// only writes references. +const MIXED_DOC_HEX = + "856f4a831715302600d0010110a5d81a65b1a4904d995a71172a95f9c101be041b5bf618" + + "51a89f5cca86482953621c63b9e4f15458c533f2e22c5e5fccc30601020302130223024" + + "002560208150b2102230334014202560557578001027f007f017f027f007f007f077e04" + + "6e6f7465047069637302007e027f0202017ec603b6076a7573742061206e6f74652c206" + + "e6f742061207265666572656e63656175746f6d657267653a5644546369784b4b397578" + + "72524545454e474a55504c4e4c714a6e783633685859444139674a3134676a56724c486" + + "f736a020000"; + +const MIXED_DOC_TARGET = + "automerge:VDTcixKK9uxrREEENGJUPLNLqJnx63hXYDA9gJ14gjVrLHosj"; + test("a document's own keys do not become names unless they are references", async ({ page, }) => { - const edges = await page.evaluate(() => { - const held = new window.onomancy.HeldDocuments(); - const anchor = held.createDocument(); - held.bind(anchor, "pics", anchor); - return held.edges(anchor); - }); + const edges = await page.evaluate( + ([hex, target]) => { + const bytes = new Uint8Array( + (hex.match(/../g) ?? []).map((pair) => parseInt(pair, 16)), + ); + const held = new window.onomancy.HeldDocuments(); + held.hold(target, bytes); + return held.edges(target); + }, + [MIXED_DOC_HEX, MIXED_DOC_TARGET] as const, + ); // A namestore is the document's own top-level map, so a name is a - // root key. Only reference-valued keys are edges. - expect(edges).toEqual([ - expect.objectContaining({ path: "pics" }), - ]); + // root key. Only reference-valued keys are edges: `pics` (a bare + // reference) appears with its target, and `note` (prose) does not + // appear at all. + expect(edges).toEqual([{ path: "pics", target: MIXED_DOC_TARGET }]); }); test("binds namestore edges and reads them back", async ({ page }) => { diff --git a/onomancy_wasm/e2e/tests/names.spec.ts b/onomancy_wasm/e2e/tests/names.spec.ts index ad252d1..bc7c3da 100644 --- a/onomancy_wasm/e2e/tests/names.spec.ts +++ b/onomancy_wasm/e2e/tests/names.spec.ts @@ -25,11 +25,24 @@ test("rejects a malformed name", async ({ page }) => { const outcome = await page.evaluate(() => { try { new window.onomancy.Name("no sigil, not a name"); - return "accepted"; - } catch { - return "rejected"; + return { threw: false, isError: false, message: "", hasReason: false }; + } catch (error) { + // A real Error with prose — not a wasm trap, and not a missing + // export's TypeError masquerading as a rejection — with no + // `reason`: a parse failure is a caller error, not a finding + // (reason-absence is the published discriminator). + return { + threw: true, + isError: error instanceof Error, + message: error instanceof Error ? error.message : "", + hasReason: + typeof error === "object" && error !== null && "reason" in error, + }; } }); - expect(outcome).toBe("rejected"); + expect(outcome.threw).toBe(true); + expect(outcome.isError).toBe(true); + expect(outcome.message).toMatch(/sigil|name/i); + expect(outcome.hasReason).toBe(false); }); diff --git a/onomancy_wasm/e2e/tests/verify.spec.ts b/onomancy_wasm/e2e/tests/verify.spec.ts new file mode 100644 index 0000000..ac54316 --- /dev/null +++ b/onomancy_wasm/e2e/tests/verify.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from "./harness"; + +// Certificate verification through the PUBLISHED npm build — the +// wasm-bodge pipeline has broken exports independently of the crate +// before (the -O4 closure-shim incident), and until this spec nothing +// exercised the verify surface through dist at all. + +/** A byte copy of the frozen production capture (see fixtures/README.md). */ +const CAPTURE = "/e2e/fixtures/real_brooklynzelenka_carriage.onc"; + +/** Well after the chain's RRSIG windows lapsed: stale, never invalid. */ +const YEARS_LATER = 1_788_100_000; + +test("the frozen capture verifies through the dist build", async ({ page }) => { + const verdict = await page.evaluate( + async ([capture, yearsLater]) => { + const bytes = new Uint8Array( + await (await fetch(capture)).arrayBuffer(), + ); + return window.onomancy.verifyCertificate( + bytes, + "brooklynzelenka.com", + yearsLater, + ); + }, + [CAPTURE, YEARS_LATER] as const, + ); + + // The same exact values the in-crate wasm test pins — through the + // packaged module instead. + expect(verdict.hostname).toBe("brooklynzelenka.com"); + expect(verdict.document).toBe( + "automerge:VDTcixKK9uxrREEENGJUPLNLqJnx63hXYDA9gJ14gjVrLHosj", + ); + expect(verdict.serial).toBe("1787291588428"); + expect(verdict.freshness).toBe("stale"); + expect(verdict.generation).toBe("on-path"); +}); + +test("a refusal carries its reason code through the dist build", async ({ + page, +}) => { + const outcome = await page.evaluate( + async ([capture, yearsLater]) => { + const bytes = new Uint8Array( + await (await fetch(capture)).arrayBuffer(), + ); + try { + await window.onomancy.verifyCertificate( + bytes, + "example.com", + yearsLater, + ); + return { threw: false, reason: "" }; + } catch (error) { + return { + threw: true, + reason: + typeof error === "object" && error !== null && "reason" in error + ? String((error as { reason: unknown }).reason) + : "", + }; + } + }, + [CAPTURE, YEARS_LATER] as const, + ); + + // The certificate binds one hostname and says so in its signature; + // the machine-readable code must survive the packaging pipeline. + expect(outcome.threw).toBe(true); + expect(outcome.reason).toBe("hostname-mismatch"); +}); diff --git a/onomancy_wasm/e2e/tests/walks.spec.ts b/onomancy_wasm/e2e/tests/walks.spec.ts index f465cbc..8b3e968 100644 --- a/onomancy_wasm/e2e/tests/walks.spec.ts +++ b/onomancy_wasm/e2e/tests/walks.spec.ts @@ -15,8 +15,16 @@ test("resolves a two-hop walk across held documents", async ({ page }) => { return { john, outcome }; }); - expect(verdict.outcome.status).toBe("resolved"); - expect(verdict.outcome.document).toBe(verdict.john); + // `toMatchObject` rather than field access: the published + // `WalkOutcome` is a union, and these assertions must typecheck + // without narrowing. Authority is the dev bridge's grade, and the + // warning says what the grade does not establish. + expect(verdict.outcome).toMatchObject({ + status: "resolved", + document: verdict.john, + authority: "trusted-substrate", + warning: expect.stringContaining(""), + }); }); test("reports a partial walk instead of failing", async ({ page }) => { @@ -26,6 +34,12 @@ test("reports a partial walk instead of failing", async ({ page }) => { return await held.resolve("~/nowhere", root); }); - expect(outcome.status).toBe("partial"); - expect(outcome.total).toBe(1); + // WHERE it stopped and WHY — the declared `WalkOutcome` fields, and + // the only e2e assertion of the dangling-vs-unsynced distinction. + expect(outcome).toMatchObject({ + status: "partial", + total: 1, + consumed: 0, + reason: "dangling segment", + }); }); diff --git a/onomancy_wasm/src/clock.rs b/onomancy_wasm/src/clock.rs index 20a28c2..03c7ad5 100644 --- a/onomancy_wasm/src/clock.rs +++ b/onomancy_wasm/src/clock.rs @@ -105,50 +105,65 @@ pub enum ClockError { mod tests { use super::*; - #[allow(clippy::expect_used)] - fn seconds(value: f64) -> u64 { - u64::from(resolve(Some(value)).expect("a plausible clock is accepted")) - } - - /// The property the original bug violated: seconds in, same - /// seconds out, with no scaling in either direction. - #[test] - fn a_supplied_clock_round_trips() { - assert_eq!(seconds(1_788_100_000.0), 1_788_100_000); - } - - /// Milliseconds are REFUSED, not accepted and not clamped. - /// - /// This is the security case. Taken as seconds, `Date.now()` - /// lands in the far future where every chain is expired, and a - /// stale chain downgrades an off-path generation from a refusal - /// to `provisional` — so the units slip turned a revoked key into - /// an accepted one. Observed before this guard existed. - #[test] - fn milliseconds_are_refused_rather_than_silently_accepted() { - assert!(resolve(Some(1_788_100_000_000.0)).is_err()); - } - /// The boundary, both sides, so the bound cannot drift untested. + /// Kept as an explicit example: generators do not reliably hit + /// exact constants. #[test] fn the_plausibility_bound_is_where_it_claims_to_be() { assert!(resolve(Some(IMPLAUSIBLE_SECONDS - 1.0)).is_ok()); assert!(resolve(Some(IMPLAUSIBLE_SECONDS)).is_err()); } - /// A negative clock is a caller bug and says so, rather than - /// becoming 1970 and grading everything `Deferred`. + /// A fractional in-range clock truncates — the sub-second part is + /// dropped, never rounded up into the future. #[test] - fn a_negative_clock_is_refused() { - assert!(resolve(Some(-1.0)).is_err()); + #[allow(clippy::expect_used)] + fn fractional_seconds_truncate() { + assert_eq!( + u64::from(resolve(Some(1.9)).expect("in range")), + 1, + "truncation, not rounding" + ); } - /// `f64` admits values no integer conversion can represent; the - /// cast would otherwise saturate and grade against nonsense. - #[test] - fn non_finite_clocks_are_refused() { - assert!(resolve(Some(f64::NAN)).is_err()); - assert!(resolve(Some(f64::INFINITY)).is_err()); - assert!(resolve(Some(f64::NEG_INFINITY)).is_err()); + #[cfg(not(target_arch = "wasm32"))] + mod props { + use super::*; + + /// The whole classification, as one law over every `f64`: + /// accepted exactly when finite, non-negative, and below the + /// bound — and then truncated, never scaled. The two refusals + /// partition the rest by which rule they broke. + /// + /// The millisecond arm is the security case: taken as + /// seconds, `Date.now()` lands in the far future where every + /// chain is stale, and a stale chain downgrades an off-path + /// generation from a refusal to `provisional` — the units + /// slip turned a revoked key into an accepted one. Observed + /// before this guard existed. + #[test] + fn resolve_classifies_exactly_by_the_bound() { + bolero::check!().with_type::().for_each(|&supplied| { + match resolve(Some(supplied)) { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded + Ok(instant) => { + assert!( + supplied.is_finite() && (0.0..IMPLAUSIBLE_SECONDS).contains(&supplied) + ); + assert_eq!( + u64::from(instant), + supplied as u64, + "truncation, no scaling" + ); + } + Err(ClockError::NotSeconds { .. }) => { + assert!(!supplied.is_finite() || supplied < 0.0); + } + Err(ClockError::LooksLikeMilliseconds { .. }) => { + assert!(supplied >= IMPLAUSIBLE_SECONDS); + } + } + }); + } } } diff --git a/onomancy_wasm/src/held.rs b/onomancy_wasm/src/held.rs index 370db3c..32d9537 100644 --- a/onomancy_wasm/src/held.rs +++ b/onomancy_wasm/src/held.rs @@ -30,9 +30,42 @@ use onomancy_protocol::resolve::{ resolution::{PartialReason, Resolution}, resolve, }; -use wasm_bindgen::{JsError, JsValue, prelude::wasm_bindgen}; +use wasm_bindgen::{JsCast as _, JsError, JsValue, prelude::wasm_bindgen}; -use crate::text::{self, Text}; +use crate::{ + shapes::JsWalkOutcome, + text::{self, Text}, +}; + +/// The `WalkOutcome` literals: one home, so the Rust that emits them +/// and the TypeScript that declares them (`shapes.d.ts`) can be held +/// together by the drift test below rather than by habit. +/// +/// These strings are API. A rewording here is a breaking change to +/// every `switch` downstream, whatever the `.d.ts` says. +pub mod walk { + /// Every segment consumed; `document` names where the walk landed. + pub const RESOLVED: &str = "resolved"; + + /// The walk stopped short — the designed norm under partition. + pub const PARTIAL: &str = "partial"; + + /// A segment named no edge in the document it reached. + pub const DANGLING_SEGMENT: &str = "dangling segment"; + + /// The next hop's document is not held; `target` names it. + pub const UNSYNCED_TARGET: &str = "unsynced target"; + + /// A resolved walk whose root is the zone's word alone — the + /// certificate direction was not checked. + pub const ZONE_ONLY: &str = "zone-only"; + + /// The published `WalkStatus` union, for the drift test. + pub const STATUSES: &[&str] = &[RESOLVED, PARTIAL]; + + /// The published `PartialWalkReason` union, for the drift test. + pub const PARTIAL_REASONS: &[&str] = &[DANGLING_SEGMENT, UNSYNCED_TARGET]; +} /// A browser-held document set: the anchoring and replication /// substrate a real agent would sync, reduced to in-memory documents @@ -197,7 +230,7 @@ impl JsHeldDocuments { name: &Text, root: Option, doh_url: Option, - ) -> Result { + ) -> Result { let name = text::read(name, "a name")?; let name = SupportedName::parse(&name).map_err(|error| JsError::new(&error.to_string()))?; @@ -223,21 +256,8 @@ impl JsHeldDocuments { held = held.with(*anchor, doc.clone()); } let Some(root_namestore) = held.replica(&root_anchor) else { - let verdict = js_sys::Object::new(); - set(&verdict, "status", &JsValue::from_str("partial")); - set(&verdict, "consumed", &JsValue::from_f64(0.0)); - set( - &verdict, - "total", - &JsValue::from_f64(cast_len(name.segments().len())), - ); - set(&verdict, "reason", &JsValue::from_str("unsynced target")); - set( - &verdict, - "target", - &JsValue::from_str(&anchor_string(&root_anchor)), - ); - return Ok(verdict.into()); + // An unheld ROOT is the same partial as any unsynced hop. + return Ok(unsynced_root(name.segments().len(), &root_anchor)); }; // Track hops so the outcome names where the walk landed. @@ -249,7 +269,7 @@ impl JsHeldDocuments { let verdict = js_sys::Object::new(); match resolve(root_namestore, name.segments(), &tracking) { Resolution::Resolved { authority, .. } => { - set(&verdict, "status", &JsValue::from_str("resolved")); + set(&verdict, "status", &JsValue::from_str(walk::RESOLVED)); set(&verdict, "authority", &JsValue::from_str(authority.label())); // The zone's word is one direction; the certificate // direction is verifyBinding's check. Said on the @@ -257,7 +277,11 @@ impl JsHeldDocuments { // `status: "resolved"` must be able to see that the // anchor itself is unauthenticated. if zone_anchored { - set(&verdict, "anchorAuthority", &JsValue::from_str("zone-only")); + set( + &verdict, + "anchorAuthority", + &JsValue::from_str(walk::ZONE_ONLY), + ); } // The browser has no keyhive route yet: every grade // here is the dev bridge's, and says so. @@ -280,7 +304,7 @@ impl JsHeldDocuments { } } Resolution::Partial { consumed, reason } => { - set(&verdict, "status", &JsValue::from_str("partial")); + set(&verdict, "status", &JsValue::from_str(walk::PARTIAL)); set(&verdict, "consumed", &JsValue::from_f64(cast_len(consumed))); set( &verdict, @@ -289,10 +313,18 @@ impl JsHeldDocuments { ); match reason { PartialReason::DanglingSegment => { - set(&verdict, "reason", &JsValue::from_str("dangling segment")); + set( + &verdict, + "reason", + &JsValue::from_str(walk::DANGLING_SEGMENT), + ); } PartialReason::UnsyncedTarget { target } => { - set(&verdict, "reason", &JsValue::from_str("unsynced target")); + set( + &verdict, + "reason", + &JsValue::from_str(walk::UNSYNCED_TARGET), + ); set( &verdict, "target", @@ -302,7 +334,7 @@ impl JsHeldDocuments { } } } - Ok(verdict.into()) + Ok(verdict.unchecked_into()) } } @@ -419,6 +451,26 @@ impl Replicas for Tracking<'_> { } } +/// The partial outcome for an unheld root: `consumed: 0`, the root +/// anchor as the unsynced target. +fn unsynced_root(total: usize, root_anchor: &DocAnchor) -> JsWalkOutcome { + let verdict = js_sys::Object::new(); + set(&verdict, "status", &JsValue::from_str(walk::PARTIAL)); + set(&verdict, "consumed", &JsValue::from_f64(0.0)); + set(&verdict, "total", &JsValue::from_f64(cast_len(total))); + set( + &verdict, + "reason", + &JsValue::from_str(walk::UNSYNCED_TARGET), + ); + set( + &verdict, + "target", + &JsValue::from_str(&anchor_string(root_anchor)), + ); + verdict.unchecked_into() +} + /// An anchor in its printed `automerge:…` form. fn anchor_string(anchor: &DocAnchor) -> String { format!("{}{anchor}", doc::SCHEME_PREFIX) @@ -458,3 +510,73 @@ const fn cast_len(len: usize) -> f64 { fn set(object: &js_sys::Object, key: &str, value: &JsValue) { drop(Reflect::set(object, &JsValue::from_str(key), value)); } + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic)] +mod tests { + use super::walk; + + /// Every literal Rust emits must be declared to TypeScript, and + /// every declared member must be one Rust emits — the same + /// bidirectional drift test the refusal codes have, because these + /// strings are the same kind of load-bearing API: `browser.rs` + /// and the Playwright specs both `switch` on them. + #[test] + fn the_declared_walk_unions_match_the_emitted_literals() { + for (name, emitted) in [ + ("WalkStatus", walk::STATUSES), + ("PartialWalkReason", walk::PARTIAL_REASONS), + ] { + let declared = declared_union(name); + + for literal in emitted { + assert!( + declared.contains(literal), + "`{literal}` is emitted by Rust but absent from {name}" + ); + } + + for member in &declared { + assert!( + emitted.contains(member), + "`{member}` is declared in {name} but no Rust path emits it" + ); + } + } + } + + /// The `zone-only` marker is declared where it is emitted: on the + /// resolved arm of `WalkOutcome`, verbatim. + #[test] + fn the_zone_only_marker_is_declared() { + assert!( + crate::shapes::TYPES.contains(&format!("\"{}\"", walk::ZONE_ONLY)), + "`{}` is emitted but not declared in shapes.d.ts", + walk::ZONE_ONLY + ); + } + + /// The members of one published union, by quoted string only — + /// same parser discipline as the refusal drift test: a substring + /// search over the whole file would match OTHER unions. + fn declared_union(name: &str) -> Vec<&'static str> { + let after = crate::shapes::TYPES + .split(&format!("export type {name} =")) + .nth(1) + .unwrap_or_else(|| panic!("{name} is declared")); + + after + .split(';') + .next() + .expect("the union terminates") + .split('|') + .filter_map(|part| { + let opening = part.find('"')?; + let rest = part.get(opening + 1..)?; + let closing = rest.find('"')?; + + rest.get(..closing) + }) + .collect() + } +} diff --git a/onomancy_wasm/src/refusal.rs b/onomancy_wasm/src/refusal.rs index 9fba98c..d370acc 100644 --- a/onomancy_wasm/src/refusal.rs +++ b/onomancy_wasm/src/refusal.rs @@ -295,6 +295,10 @@ pub const fn validation_reason(error: &onomancy_dnssec::validator::WalkError) -> // DNS answered; nothing was proven. WalkError::Empty | WalkError::MissingLeaf => RefusalReason::NoBinding, + // The hostname cannot be spelled under the service label: + // the caller's input, not a security signal. + WalkError::UnrepresentableName(_) => RefusalReason::InvalidHostname, + // Records arrived and failed to hold up: a security signal. WalkError::DsMismatch | WalkError::EmptyWindow @@ -440,11 +444,30 @@ mod tests { } } - /// A grade must stay out of the refusal union: publishing it - /// would make consumers handle a case only a bug could produce. + /// The two authority rejections keep their own codes — the zone + /// is fine in both, and merging either into `chain-rejected` + /// sends someone to debug DNSSEC over a key or document problem. + /// No end-to-end fixture can stage them (that needs a sound chain + /// plus a wrong signer or document), so the mapping is pinned + /// here, at the arm a refactor would wildcard. #[test] - fn a_grade_is_not_a_declared_refusal() { - assert!(!declared_union().contains(&RefusalReason::NotARefusal.as_str())); + fn authority_rejections_keep_their_own_codes() { + assert_eq!( + reason(&Rejection::SignerNotAuthorized), + RefusalReason::SignerNotAuthorized + ); + assert_eq!( + reason(&Rejection::DocumentNotAttested), + RefusalReason::DocumentNotAttested + ); + assert_ne!( + reason(&Rejection::SignerNotAuthorized), + RefusalReason::ChainRejected + ); + assert_ne!( + reason(&Rejection::DocumentNotAttested), + RefusalReason::ChainRejected + ); } /// The members of the published union, by quoted string only — diff --git a/onomancy_wasm/src/shapes.d.ts b/onomancy_wasm/src/shapes.d.ts index 0fd3351..10d02fe 100644 --- a/onomancy_wasm/src/shapes.d.ts +++ b/onomancy_wasm/src/shapes.d.ts @@ -70,6 +70,53 @@ export interface Resolution { checkedAt: UnixSeconds; } +/** How far a resolution walk got. */ +export type WalkStatus = "resolved" | "partial"; + +/** + * Why a walk stopped short. A partial walk is the designed norm + * under partition, not an error: + * + * - `"dangling segment"` — a segment named no edge in the document + * it reached. Retrying cannot help; the name goes nowhere from + * here. + * - `"unsynced target"` — the next hop's document is not held. + * `target` names it: hold a replica (`holdAt`) and retry. + */ +export type PartialWalkReason = "dangling segment" | "unsynced target"; + +/** + * The outcome of `HeldDocuments.resolve`. + * + * `anchorAuthority: "zone-only"` appears on resolved walks whose root + * came from a live DNS anchor: the DNSSEC walk proves what the zone + * published — one direction — and this walk roots on it without the + * certificate direction (`verifyBinding` is that check). A caller + * reading `status: "resolved"` must be able to see that the anchor + * itself is unauthenticated. + */ +export type WalkOutcome = + | { + status: "resolved"; + /** The terminal document, as an `automerge:` anchor. */ + document: string; + /** The weakest authority grade along the realized path. */ + authority: string; + /** What the grade does NOT establish, in prose. */ + warning: string; + anchorAuthority?: "zone-only"; + } + | { + status: "partial"; + /** Segments consumed before the walk stopped. */ + consumed: number; + /** Segments in the name. */ + total: number; + reason: PartialWalkReason; + /** The unheld document, when the reason is an unsynced target. */ + target?: string; + }; + /** * Why an operation was refused. * diff --git a/onomancy_wasm/src/shapes.rs b/onomancy_wasm/src/shapes.rs index 55ebd4f..ab80b8c 100644 --- a/onomancy_wasm/src/shapes.rs +++ b/onomancy_wasm/src/shapes.rs @@ -43,4 +43,8 @@ extern "C" { /// A live DNSSEC walk's result, as published to TypeScript. #[wasm_bindgen(typescript_type = "Resolution")] pub type JsResolution; + + /// A resolution walk's outcome, as published to TypeScript. + #[wasm_bindgen(typescript_type = "WalkOutcome")] + pub type JsWalkOutcome; } diff --git a/onomancy_wasm/src/verify.rs b/onomancy_wasm/src/verify.rs index 3cb804a..4796b53 100644 --- a/onomancy_wasm/src/verify.rs +++ b/onomancy_wasm/src/verify.rs @@ -35,7 +35,7 @@ use onomancy_keyhive::authority::KeyhiveAuthority; use onomancy_protocol::verifier::verdict::{ self, DeferredEvidence, GenerationCheck, Rejection, Verdict, }; -use wasm_bindgen::{JsCast as _, JsError, JsValue, prelude::wasm_bindgen}; +use wasm_bindgen::{JsCast as _, JsValue, prelude::wasm_bindgen}; // Reading a certificate OUT OF a document needs the document // substrate; verifying bytes does not. Only the former is gated. @@ -44,6 +44,7 @@ use { crate::held::JsHeldDocuments, onomancy_automerge::{certificates, namestore::HeldDocuments}, onomancy_core::anchor::doc::DocAnchor, + wasm_bindgen::JsError, }; /// Verify one certificate against `hostname` at `now_seconds` @@ -372,6 +373,7 @@ fn grade_object( /// instant (their serials and windows never line up), so the claim is /// untestable end-to-end. Here it is testable directly, by the code /// production actually calls. +#[cfg(any(feature = "names", test))] const fn freshness_rank(freshness: Option) -> u8 { match freshness { Some(Freshness::Fresh) => 2, diff --git a/onomancy_wasm/tests/browser.rs b/onomancy_wasm/tests/browser.rs index 58322b1..032e5b6 100644 --- a/onomancy_wasm/tests/browser.rs +++ b/onomancy_wasm/tests/browser.rs @@ -33,25 +33,50 @@ fn local_anchors_parse() -> JsTestResult { let name = JsName::new(&text("~/bob/pics"))?; assert_eq!(name.anchor_kind(), "local"); + assert_eq!(name.anchor(), "~"); + assert_eq!(name.segments(), vec!["bob".to_string(), "pics".to_string()]); + assert_eq!(name.value(), "~/bob/pics"); Ok(()) } +/// Names carry no version pins: heads are certificate state, so a +/// `#`-suffixed name is a parse error, not a doc anchor with extras. #[wasm_bindgen_test] -fn doc_anchors_parse_with_heads() -> JsTestResult { - let name = JsName::new(&text( - "automerge:VDTcixKK9uxrREEENGJUPLNLqJnx63hXYDA9gJ14gjVrLHosj/pics", - ))?; +fn doc_anchors_parse_and_version_pins_are_rejected() -> JsTestResult { + const ANCHOR: &str = "automerge:VDTcixKK9uxrREEENGJUPLNLqJnx63hXYDA9gJ14gjVrLHosj"; + + let name = JsName::new(&text(&format!("{ANCHOR}/pics")))?; assert_eq!(name.anchor_kind(), "doc"); + assert_eq!(name.anchor(), ANCHOR); assert_eq!(name.segments(), vec!["pics".to_string()]); + + assert!( + JsName::new(&text(&format!("{ANCHOR}/pics#head"))).is_err(), + "a version-pinned name must be a flat parse error" + ); Ok(()) } #[wasm_bindgen_test] -fn garbage_is_rejected() { - // A dotless `@` is a flat parse error, even in JS. - assert!(JsName::new(&text("@nodots/path")).is_err()); - assert!(JsName::new(&text("")).is_err()); +fn garbage_is_rejected_with_a_parse_message() { + // A dotless `@` is a flat parse error, even in JS — and a real + // `Error` with prose, not a wasm trap or a bare throw. + for garbage in ["@nodots/path", ""] { + let Err(value) = JsName::new(&text(garbage)) else { + panic!("{garbage:?} is not a name"); + }; + + let message = JsValue::from(value) + .unchecked_into::() + .message() + .as_string() + .unwrap_or_default(); + assert!( + !message.is_empty(), + "a parse refusal must say what is wrong" + ); + } } /// Documents naming documents, entirely in-tab: mint three docs, @@ -102,6 +127,14 @@ async fn unsynced_targets_walk_partially() -> JsTestResult { assert_eq!(status.as_string().as_deref(), Some("partial")); let reason = js_sys::Reflect::get(&verdict, &JsValue::from_str("reason"))?; assert_eq!(reason.as_string().as_deref(), Some("unsynced target")); + + // The declared shape's whole point: WHERE it stopped (one segment + // consumed by the hop to `elsewhere`) and WHAT is missing (the + // unheld document, so a caller can `holdAt` it and retry). + let consumed = js_sys::Reflect::get(&verdict, &JsValue::from_str("consumed"))?; + assert_eq!(consumed.as_f64(), Some(1.0)); + let target = js_sys::Reflect::get(&verdict, &JsValue::from_str("target"))?; + assert_eq!(target.as_string().as_deref(), Some(elsewhere.as_str())); Ok(()) } diff --git a/onomancy_wasm/tests/browser_live.rs b/onomancy_wasm/tests/browser_live.rs index 01e20b8..b2fd717 100644 --- a/onomancy_wasm/tests/browser_live.rs +++ b/onomancy_wasm/tests/browser_live.rs @@ -19,6 +19,24 @@ async fn the_first_bound_name_resolves_fresh_in_the_browser() -> Result<(), JsVa let freshness = Reflect::get(&verdict, &JsValue::from_str("freshness"))?; assert_eq!(freshness.as_string().as_deref(), Some("fresh")); + + // Not just the label: the proof carried records and links, and + // the chain bytes are present — the field browser minting + // actually depends on (`encodeCertificate` embeds them). + let records = Reflect::get(&verdict, &JsValue::from_str("records"))?; + assert!( + js_sys::Array::from(&records).length() > 0, + "a fresh verdict proves at least one record" + ); + + let links = Reflect::get(&verdict, &JsValue::from_str("links"))?; + assert!(links.as_f64().unwrap_or_default() > 0.0, "links counted"); + + let chain = Reflect::get(&verdict, &JsValue::from_str("chain"))?; + assert!( + js_sys::Uint8Array::new(&chain).length() > 0, + "the validated chain rides along for minting" + ); Ok(()) } diff --git a/onomancy_wasm/tests/doh.rs b/onomancy_wasm/tests/doh.rs new file mode 100644 index 0000000..d296a8c --- /dev/null +++ b/onomancy_wasm/tests/doh.rs @@ -0,0 +1,234 @@ +//! Offline transport tests for the `DoH` courier: a fake +//! `globalThis.fetch` returning synthesized `Response`s, so every +//! error path of `exchange`/`query` runs deterministically with no +//! network — under the same `ci-browser` job as the rest of the wasm +//! suite. Before this file, those paths ran only in the off-CI live +//! smoke, i.e. never. + +#![cfg(all(target_arch = "wasm32", feature = "doh"))] +// House pattern for test code: a failed `expect` here is the test +// failing, which is its job. +#![allow(clippy::expect_used, clippy::panic)] + +use std::{cell::RefCell, rc::Rc}; + +use hickory_proto::op::{Message, MessageType, OpCode}; +use js_sys::{Object, Promise, Reflect, Uint8Array}; +use onomancy_dnssec::{chain_provider::ChainProvider as _, dns_name::DnsName}; +use onomancy_wasm::{ + doh::{DohError, DohProvider, FetchChainError}, + text::Text, +}; +use wasm_bindgen::{JsCast as _, JsValue, closure::Closure}; +use wasm_bindgen_futures::JsFuture; +use wasm_bindgen_test::wasm_bindgen_test; +use web_sys::{Headers, Request, Response, ResponseInit}; + +/// Install a fake `globalThis.fetch` that answers every request with +/// `respond(request)`. Overwrites any previous fake; each test +/// installs its own. +fn install_fetch(respond: impl Fn(Request) -> Promise + 'static) { + let fetch = Closure::wrap(Box::new(respond) as Box Promise>); + Reflect::set( + &js_sys::global(), + &JsValue::from_str("fetch"), + fetch.as_ref().unchecked_ref(), + ) + .expect("global fetch is writable"); + fetch.forget(); +} + +/// A promise already resolved with `value` — typed through `JsValue` +/// so `Promise::resolve`'s generic has one answer. +fn resolved(value: JsValue) -> Promise { + Promise::resolve(&value) +} + +/// A synthesized `Response` with `status` and `body`. +fn response(status: u16, body: &[u8]) -> Response { + let init = ResponseInit::new(); + init.set_status(status); + + let mut bytes = body.to_vec(); + Response::new_with_opt_u8_array_and_init(Some(&mut bytes), &init) + .expect("a Response synthesizes") +} + +/// One drive of the courier: the fake fetch answers the FIRST +/// question (the root DNSKEY), which is as far as any of these +/// scripted failures lets the walk get. +async fn fetch_outcome() -> Result<(), FetchChainError> { + let hostname = DnsName::parse("example.com").expect("valid hostname"); + DohProvider::new("https://doh.test/dns-query") + .chain(&hostname) + .await + .map(|_| ()) +} + +/// A non-2xx status is `Status` with the code — transport, never a +/// validity verdict. +#[wasm_bindgen_test] +async fn a_non_2xx_status_is_a_transport_error() { + install_fetch(|_| resolved(response(500, &[]).into())); + + match fetch_outcome().await { + Err(FetchChainError::Transport(DohError::Status { code: 500 })) => {} + other => panic!("expected HTTP 500 to surface as Status, got {other:?}"), + } +} + +/// A declared `content-length` over the DNS wire maximum is refused +/// BEFORE buffering — the header, not the body, is what this arm +/// reads. +#[wasm_bindgen_test] +async fn an_oversized_declared_length_is_refused_before_buffering() { + install_fetch(|_| { + let headers = Headers::new().expect("headers"); + headers + .set("content-length", "70000") + .expect("content-length is settable on a synthesized response"); + + let init = ResponseInit::new(); + init.set_status(200); + init.set_headers(&headers.into()); + + let mut bytes = vec![0u8; 4]; + let response = Response::new_with_opt_u8_array_and_init(Some(&mut bytes), &init) + .expect("a Response synthesizes"); + resolved(response.into()) + }); + + match fetch_outcome().await { + Err(FetchChainError::Transport(DohError::Oversized { bytes: 70_000 })) => {} + other => panic!("expected the declared length to be refused, got {other:?}"), + } +} + +/// A body over the DNS wire maximum with no declared length is +/// refused after buffering — the chunked-response half of the guard. +#[wasm_bindgen_test] +async fn an_oversized_buffered_body_is_refused() { + install_fetch(|_| resolved(response(200, &vec![0u8; 70_000]).into())); + + match fetch_outcome().await { + Err(FetchChainError::Transport(DohError::Oversized { bytes: 70_000 })) => {} + other => panic!("expected the buffered length to be refused, got {other:?}"), + } +} + +/// Bytes that are not a DNS message are `Malformed` — a parse +/// failure, not a validity verdict and not a panic. +#[wasm_bindgen_test] +async fn garbage_bytes_are_malformed() { + install_fetch(|_| resolved(response(200, &[0xFF; 10]).into())); + + match fetch_outcome().await { + Err(FetchChainError::Transport(DohError::Malformed(_))) => {} + other => panic!("expected garbage to be Malformed, got {other:?}"), + } +} + +/// A well-formed response under any ID but the fixed `DoH` ID (0) is +/// rejected: RFC 8484 matches responses by the HTTP exchange, so a +/// nonzero ID is an upstream answering some other question. +#[wasm_bindgen_test] +async fn a_nonzero_response_id_is_rejected() { + install_fetch(|_| { + let wire = Message::new(7, MessageType::Response, OpCode::Query) + .to_vec() + .expect("responses encode"); + resolved(response(200, &wire).into()) + }); + + match fetch_outcome().await { + Err(FetchChainError::Transport(DohError::IdMismatch)) => {} + other => panic!("expected ID 7 to be rejected, got {other:?}"), + } +} + +/// `fetch` resolving to something that is not a `Response` is its own +/// error, not a trap inside the module. +#[wasm_bindgen_test] +async fn a_non_response_is_its_own_error() { + install_fetch(|_| resolved(Object::new().into())); + + match fetch_outcome().await { + Err(FetchChainError::Transport(DohError::NotAResponse)) => {} + other => panic!("expected a non-Response to be refused, got {other:?}"), + } +} + +/// The RFC 8484 request shape: POST, `application/dns-message` both +/// directions, and query ID 0 (cache friendliness) — asserted by +/// capturing what the courier actually sends. +#[wasm_bindgen_test] +async fn the_request_is_rfc_8484_shaped() { + #[derive(Debug, Default)] + struct Captured { + method: String, + content_type: Option, + accept: Option, + id: [u8; 2], + } + + let captured: Rc>> = Rc::new(RefCell::new(None)); + let sink = Rc::clone(&captured); + + install_fetch(move |request: Request| { + let sink = Rc::clone(&sink); + wasm_bindgen_futures::future_to_promise(async move { + let headers = request.headers(); + let body = JsFuture::from(request.array_buffer()?).await?; + let bytes = Uint8Array::new(&body).to_vec(); + + *sink.borrow_mut() = Some(Captured { + method: request.method(), + content_type: headers.get("content-type").unwrap_or(None), + accept: headers.get("accept").unwrap_or(None), + id: [ + bytes.first().copied().unwrap_or(0xFF), + bytes.get(1).copied().unwrap_or(0xFF), + ], + }); + + // Fail the exchange cheaply; the request was the point. + Ok(response(500, &[]).into()) + }) + }); + + let _outcome = fetch_outcome().await; + + let captured = captured.borrow_mut().take().expect("a request was sent"); + assert_eq!(captured.method, "POST"); + assert_eq!( + captured.content_type.as_deref(), + Some("application/dns-message") + ); + assert_eq!(captured.accept.as_deref(), Some("application/dns-message")); + assert_eq!(captured.id, [0, 0], "query ID 0 per RFC 8484"); +} + +/// A `dohUrl` that does not parse is `invalid-resolver-url`, decided +/// BEFORE any fetch — so this is offline-safe, and a regression to +/// `transport` (inviting a retry that can never succeed) fails here. +#[wasm_bindgen_test] +async fn a_malformed_resolver_url_is_the_callers_error_not_transport() { + let text = |raw: &str| -> Text { JsValue::from_str(raw).unchecked_into() }; + + let Err(refused) = onomancy_wasm::resolve::resolve_hostname( + &text("example.com"), + Some(text("not a url")), + None, + ) + .await + else { + panic!("a malformed resolver URL cannot resolve anything"); + }; + + let reason = Reflect::get(&refused, &JsValue::from_str("reason")) + .ok() + .and_then(|value| value.as_string()) + .expect("a substantive refusal carries a reason"); + + assert_eq!(reason, "invalid-resolver-url"); +} diff --git a/onomancy_wasm/tests/issue.rs b/onomancy_wasm/tests/issue.rs index be7f94f..d071604 100644 --- a/onomancy_wasm/tests/issue.rs +++ b/onomancy_wasm/tests/issue.rs @@ -92,14 +92,16 @@ fn an_externally_signed_certificate_assembles_and_decodes() { } /// A signature over the wrong bytes is refused at assembly rather -/// than producing a unit that fails later somewhere else. +/// than producing a unit that fails later somewhere else — and with +/// the code the contract promises: `invalid-signature`, because the +/// signature is well-formed and does not cover these fields. #[wasm_bindgen_test] fn a_signature_over_different_fields_is_refused() { let offered = signable_bytes(&anchor(), &signer_bytes(), ISSUED_AT, &host(HOST)) .expect("the fields are well formed"); // Signed for one hostname, assembled for another. - let assembled = encode_certificate( + let Err(refused) = encode_certificate( &anchor(), &signer_bytes(), ISSUED_AT, @@ -107,60 +109,11 @@ fn a_signature_over_different_fields_is_refused() { &sign_externally(&offered), vec![], &[0x00], - ); - - assert!( - assembled.is_err(), - "a signature that does not cover the assembled fields must be refused" - ); -} - -/// A different key's signature is refused, so the `signer` argument -/// cannot be a decoration the module ignores. -#[wasm_bindgen_test] -fn another_keys_signature_is_refused() { - let offered = signable_bytes(&anchor(), &signer_bytes(), ISSUED_AT, &host(HOST)) - .expect("the fields are well formed"); - - let impostor = SigningKey::from_bytes(&[9u8; 32]); - let signature = impostor.sign(&offered.to_vec()).to_bytes().to_vec(); - - assert!( - encode_certificate( - &anchor(), - &signer_bytes(), - ISSUED_AT, - &host(HOST), - &signature, - vec![], - &[0x00], - ) - .is_err(), - "a signature by a key other than `signer` must be refused" - ); -} - -/// `Date.now()` is milliseconds and is the obvious thing to reach -/// for. Accepted, it would date the certificate in year 58000 — on -/// the one field a verifier cannot cross-check against anything. -#[wasm_bindgen_test] -fn a_millisecond_timestamp_is_refused() { - assert!( - signable_bytes(&anchor(), &signer_bytes(), 1_788_100_000_000.0, &host(HOST)).is_err(), - "milliseconds must be refused rather than silently dating the unit" - ); -} + ) else { + panic!("a signature that does not cover the assembled fields must be refused"); + }; -/// `f64` admits values no clock produces; a silent cast would date -/// the certificate 1970 and grade everything against it. -#[wasm_bindgen_test] -fn a_nonsense_timestamp_is_refused() { - for bad in [f64::NAN, f64::INFINITY, -1.0] { - assert!( - signable_bytes(&anchor(), &signer_bytes(), bad, &host(HOST)).is_err(), - "{bad} must be refused as a timestamp" - ); - } + assert_eq!(reason_string(&refused), "invalid-signature"); } /// The seam this file stops at, pinned so the stopping point is a @@ -205,10 +158,7 @@ fn issuance_refusals_carry_reason_codes() { let Err(refusal) = result else { panic!("expected a refusal"); }; - js_sys::Reflect::get(&refusal, &JsValue::from_str("reason")) - .ok() - .and_then(|value| value.as_string()) - .expect("a substantive refusal carries a reason") + reason_string(&refusal) }; // End-user-visible inputs keep their specific codes… @@ -222,6 +172,20 @@ fn issuance_refusals_carry_reason_codes() { "invalid-timestamp" ); + // …including values no clock produces: a silent cast would date + // the certificate 1970 and grade everything against it. One case + // suffices to prove the boundary routes through `clock::seconds`, + // whose classification is property-tested on the host. + assert_eq!( + reason_of(signable_bytes( + &anchor(), + &signer_bytes(), + f64::NAN, + &host(HOST) + )), + "invalid-timestamp" + ); + // …developer wiring gets the one generic code, with the message // naming the argument. assert_eq!( @@ -235,7 +199,9 @@ fn issuance_refusals_carry_reason_codes() { ); // A signature that does not cover the fields is its own thing: - // not a malformed argument, a signature that fails. + // not a malformed argument, a signature that fails. The impostor + // key also pins that `signer` is not a decoration the module + // ignores. let offered = signable_bytes(&anchor(), &signer_bytes(), ISSUED_AT, &host(HOST)) .expect("the fields are well formed"); let impostor = SigningKey::from_bytes(&[9u8; 32]); @@ -254,3 +220,38 @@ fn issuance_refusals_carry_reason_codes() { "invalid-signature" ); } + +/// The 1 MiB cap bites at `encodeCertificate` — after the external +/// signature was spent, which is why the code matters: the caller +/// must learn it is `invalid-argument` (the carriage is too big), +/// not a signature problem worth re-signing over. +#[wasm_bindgen_test] +fn an_oversize_carriage_is_refused_as_an_argument_problem() { + let offered = signable_bytes(&anchor(), &signer_bytes(), ISSUED_AT, &host(HOST)) + .expect("the fields are well formed"); + + // Past the unit cap on its own: the fields and chain are tiny. + let fat = Uint8Array::new_with_length(1_100_000); + + let Err(refused) = encode_certificate( + &anchor(), + &signer_bytes(), + ISSUED_AT, + &host(HOST), + &sign_externally(&offered), + vec![fat], + &[0x00], + ) else { + panic!("a unit over the cap must be refused at assembly"); + }; + + assert_eq!(reason_string(&refused), "invalid-argument"); +} + +/// The machine-readable code on a refusal. +fn reason_string(refusal: &JsValue) -> String { + js_sys::Reflect::get(refusal, &JsValue::from_str("reason")) + .ok() + .and_then(|value| value.as_string()) + .expect("a substantive refusal carries a reason") +} diff --git a/onomancy_wasm/tests/verify.rs b/onomancy_wasm/tests/verify.rs index 485e0b0..3f8826b 100644 --- a/onomancy_wasm/tests/verify.rs +++ b/onomancy_wasm/tests/verify.rs @@ -184,6 +184,52 @@ fn garbage_is_refused_without_panicking() { assert_eq!(reason_of(&empty), "malformed"); } +/// A malformed-but-string hostname carries `invalid-hostname` through +/// the JS entry points — the same code `resolveHostname` gives for +/// the same input, previously pinned only at the mapping level. +#[wasm_bindgen_test] +fn a_malformed_hostname_is_refused_with_its_own_code() { + for bad in ["not a hostname!", ""] { + let Err(refused) = verify_certificate(CERT, &host(bad), Some(YEARS_LATER)) else { + panic!("{bad:?} is not a hostname"); + }; + assert_eq!(reason_of(&refused), "invalid-hostname"); + + let Err(via_binding) = verify_binding( + &JsHeldDocuments::new(), + &host("automerge:2nBeEMDjAzFa9Ev2pxwejYrgCRmSLx96SbA24uhdMMTUktJWvK"), + &host(bad), + Some(YEARS_LATER), + ) else { + panic!("{bad:?} is not a hostname via the binding path either"); + }; + assert_eq!(reason_of(&via_binding), "invalid-hostname"); + } +} + +/// One flipped signature byte in the frozen capture reads as +/// `invalid-signature` — someone altered this — never `malformed`, +/// which says "you sent the wrong buffer" and points a caller under +/// attack at their own wiring. +#[wasm_bindgen_test] +fn a_tampered_signature_is_a_security_signal_not_a_wiring_bug() { + let cert = + onomancy_dnssec::certificate::Certificate::decode(CERT).expect("the capture decodes"); + + // The signature is the 64 bytes after the signed region; flip + // the first of them, computed rather than hardcoded so a layout + // change moves the flip with it. + let mut tampered = CERT.to_vec(); + let at = cert.signed_bytes().len(); + tampered[at] ^= 0x01; + + let Err(refused) = verify_certificate(&tampered, &host("brooklynzelenka.com"), None) else { + panic!("a tampered signature cannot verify"); + }; + + assert_eq!(reason_of(&refused), "invalid-signature"); +} + #[wasm_bindgen_test] fn a_non_string_hostname_is_a_plain_error() { // Not `RuntimeError: memory access out of bounds`: the parameter @@ -193,7 +239,7 @@ fn a_non_string_hostname_is_a_plain_error() { }; let message = value - .unchecked_into::() + .unchecked_ref::() .message() .as_string() .unwrap_or_default(); @@ -202,6 +248,15 @@ fn a_non_string_hostname_is_a_plain_error() { message.contains("must be a string"), "expected a type error, got: {message}" ); + + // Reason-absence IS the published discriminator: a type error is + // "you passed the wrong kind of thing", not a finding. + assert!( + js_sys::Reflect::get(&value, &JsValue::from_str("reason")) + .expect("readable") + .is_undefined(), + "a type error must not carry a reason code" + ); } /// The third grade, as a value rather than a throw. @@ -362,6 +417,47 @@ fn an_empty_document_is_absence_with_its_own_code() { assert_eq!(reason_of(&unheld), "no-certificate-held"); } +/// A refused certificate FOR the requested hostname surfaces its own +/// refusal — never `no-certificate-held`. +/// +/// The distinction is revocation vs absence, and the wrong side of it +/// is the wrong-remedy failure this module's design exists to +/// prevent: a fresh chain with an off-path generation is the zone +/// saying the key was rotated away, and a caller told "no certificate +/// held" would go looking for replication problems while holding the +/// evidence of revocation. This is the only test that reaches the +/// `refusal_for_this_hostname` arm; dropping the `get_or_insert` +/// falls through to absence and fails here. +#[wasm_bindgen_test] +fn a_refused_certificate_for_this_hostname_is_not_absence() { + let cert = onomancy_dnssec::certificate::Certificate::decode(OFF_PATH_CERT) + .expect("the record-made capture decodes"); + let anchor = format!("automerge:{}", cert.root_doc()); + + let mut doc = automerge::Automerge::new(); + onomancy_automerge::certificates::put(&mut doc, &cert).expect("stored at the reserved key"); + + let mut held = JsHeldDocuments::new(); + held.hold(&host(&anchor), &doc.save()).expect("held"); + + // Inside the certificate's own window, where an off-path + // generation is refused outright rather than graded provisional. + let Err(refused) = verify_binding( + &held, + &host(&anchor), + &host("brooklynzelenka.com"), + Some(MID_OFF_PATH_WINDOW), + ) else { + panic!("a fresh off-path certificate must be refused through the binding path"); + }; + + assert_eq!( + reason_of(&refused), + "generation-off-path", + "revocation must not be mislabeled as absence" + ); +} + /// A document that binds OTHER hostnames is honest absence for this /// one: `hostname-mismatch` is a security signal about a unit, not /// the answer to "does this document bind that name".