Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .cargo/mutants.toml
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
**/*.onc
# …but checked-in test fixtures are deliberate
!**/tests/fixtures/*.onc
!**/e2e/fixtures/*.onc

# Generated by Cargo
/target
Expand Down
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 10 additions & 8 deletions HACKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
20 changes: 19 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)"
'';
};

Expand Down Expand Up @@ -377,6 +394,7 @@
ci = ci-all;
ci-browser = ci-browser;
ci-e2e = ci-e2e;
ci-mutants = ci-mutants;
inherit demo;
});

Expand Down
1 change: 1 addition & 0 deletions onomancy_automerge/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ thiserror = { workspace = true }
tracing = { workspace = true }

[dev-dependencies]
bolero = { workspace = true, features = ["arbitrary"] }
testresult = { workspace = true }

[lints]
Expand Down
79 changes: 78 additions & 1 deletion onomancy_automerge/src/certificates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<u8>>::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(())
}

Expand Down
14 changes: 10 additions & 4 deletions onomancy_automerge/src/change_hash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
}
}
Loading
Loading