What?

The gates a Rust build needs once a model writes most of the code, beyond the ones that keep it readable. Second of three: for Readers covers the code, for Operators the running system.

CategoryToolOwns
Type checkerClippy’s unwrap_used, and .expect()Where a missing value becomes a panic
Unsafeforbid, or // SAFETY: lints + MiriWhere the compiler’s guarantees stop
Mutation testingmutasticWhether the tests would notice a wrong line
Integration testingtestcontainersThe seams, tested against the real thing
Test runner#[ignore] + --include-ignoredSkipped is not passed
Securitycargo-denyKnown vulnerabilities, licences and sources
Supply chainCargo.lock + --lockedDependencies pinned as tightly as the tools
Supply chaincargo-cyclonedx, cargo-auditableWhat went into the artifact
GateCIThe checks running on every change, not when you remember

Why?

  • Agents pull in dependencies by reflex. Limit the blast radius in Cargo.toml and deny.toml.
  • A rule in the build is only enforced if something runs the build.
  • The compiler checks a lot, but not everything. There’s no null and no data race, but unwrap() still panics, unsafe is still on you, and a test that compiles can still check nothing.
  • Security and supply-chain gates are never waived.

How?

Type checker: There’s no null; unwrap() is where it went

Cargo.toml
[workspace.lints.clippy]
unwrap_used = "warn" # handle the absent value, or say with .expect() why it can't be absent
clippy.toml
allow-unwrap-in-tests = true # a panic in a test is a failed test, which is what you want
let port = config.port.expect("validated non-empty when the config was loaded");
  • Option is the null check, built into the type system. The compiler won’t let you use a value that might be absent. What it allows is unwrap(), which turns “might be absent” into a panic.
  • Generated code uses unwrap() on the unhappy path. It compiles, it passes the happy-path tests, and it panics in production.
  • Ban unwrap(), keep .expect(). A panic is how Rust reports a bug, and .expect("why this can't fail") is how the community writes down an invariant: the reason is in the code, and in the panic message if it’s ever wrong. unwrap_used is in restriction, so turn it on. Leave expect_used off.
  • The message states the invariant, not the failure. .expect("validated when loaded") tells a reader why; .expect("no port") only says what happened. That one’s for AGENTS.md: no lint can tell them apart.

Unsafe: Forbid it where you can, and make every block justify itself where you can’t

  • Unsafe code is where Rust’s guarantees stop. The worst a bug in safe Rust can do is panic, loop or give a wrong answer, and a test can catch all three. A bug in unsafe code can be undefined behaviour, which a test may not show at all.
  • Whether you need it depends on what you’re building. A service, a CLI or a library of business logic doesn’t need it, so forbid it. A device driver, a memory mapper, an allocator or an FFI binding does, so the gate is different.

Application code: forbid it.

Cargo.toml
[workspace.lints.rust]
unsafe_code = "forbid" # not "deny": an #[allow] further down can't switch it back on
  • Use forbid, not deny. deny can be overridden by an #[allow] anywhere below it, and an agent working around a build failure will add one. forbid can’t be overridden.

Systems code: keep it small, and make every block say why it’s sound.

crates/mmio/Cargo.toml
[lints.rust]
unsafe_op_in_unsafe_fn = "deny" # an unsafe fn's body is not one big unsafe block
 
[lints.clippy]
undocumented_unsafe_blocks = "deny"     # every unsafe block has a // SAFETY: comment
multiple_unsafe_ops_per_block = "deny"  # …and the comment covers one operation, not five
missing_safety_doc = "deny"             # every pub unsafe fn has a # Safety section for its callers
crates/mmio/src/lib.rs
/// Reads the status register.
pub fn status(&self) -> u32 {
    // SAFETY: `base` is the device's register block, mapped for the lifetime of `self`,
    // and STATUS is a 4-byte-aligned offset inside it.
    unsafe { self.base.add(STATUS).read_volatile() }
}
  • Put the unsafe code in its own crate, behind a safe API. Forbid unsafe_code everywhere else. The crate boundary is the audit boundary: a reviewer, or an agent, knows where to look.
  • A // SAFETY: comment is the proof, written down. Generated unsafe code is the worst case: plausible, compiling, and unsound in a way no test shows. One operation per block means each comment justifies exactly one thing.
  • Miri checks the parts it can run. cargo +nightly miri test runs the tests in an interpreter that detects undefined behaviour: out-of-bounds pointers, use after free, misaligned reads, aliasing violations. It can’t call into C or touch real hardware, so put the logic behind a trait and test it against a fake device in memory. Leave the real register access to tests on the hardware.
  • For what Miri can’t run, use the sanitizers. RUSTFLAGS=-Zsanitizer=address on nightly catches memory errors in real runs, FFI included.
  • Mutation testing covers less here. mutastic leaves unsafe code alone by default, because a mutant that might cause undefined behaviour proves nothing either way. --unsafe=enclosing mutates the safe functions around the unsafe blocks, which is where a driver’s logic usually lives. Another reason to keep the blocks small.

Mutation testing: Measure whether the tests would notice

Don’t just measure whether you have tests; measure whether those tests detect the failures you care about.

curl -fsSL https://mutastic.io/install.sh | MUTASTIC_NATIVE_VERSION=1.0.0 sh # pinned, like any tool
cargo mutastic run --threshold=80 # a score below it fails; set it where the score already is
  • Mutation testing breaks the code on purpose. mutastic makes small changes, such as >= to >, and runs the tests against each one. A change no test notices is missed: a line the tests run without checking.
  • Every mutant goes into one copy of the crate, behind a switch. One build holds them all, and each test run turns one on. Your source, Cargo.toml and target/ are left alone. The copy’s build is kept warm in target/mutastic.
  • A missed mutant comes with its evidence. You get the inputs the tests used, what a test would need to check, and the mutastic verify <id> command that passes once a test does. An agent can close that loop on its own.
  • The score is caught ÷ mutants run. Report it next to coverage, never instead of it: coverage says what ran, the mutation score says what would be caught.
  • Too slow to run on everything for every build. --since=origin/main runs only the mutants on lines changed since main, which is where a miss is worth acting on. Run the whole project nightly.
  • Not every miss is a gap. mutastic ignore <id> --reason="…" records why in .mutastic/ignores.yaml, a file you commit and review. An ignore lapses once its line changes. An agent can’t ignore a mutant itself. It uses --propose, and a person accepts the proposal.
  • An existing project starts with misses. mutastic baseline records today’s in .mutastic/baseline.yaml, to commit. From then on a miss that isn’t in it is new, and that’s what next puts first and the agent gate holds a task to.
  • Honourable mention: cargo-mutants. The established Rust mutation tester. It’s the same idea, and it builds and tests each mutant on its own.

Integration testing: Test the seams against the real thing

Cargo.toml
[dev-dependencies]
testcontainers = "0.28.0"
tests/oven_integration.rs
let oven = GenericImage::new("ollama/ollama", "0.31.2")
    .with_exposed_port(11434.tcp())
    .with_wait_for(WaitFor::message_on_stderr("Listening on"))
    .start()
    .await
    .expect("no oven, no bread: is Docker running?");
let port = oven.get_host_port_ipv4(11434).await.expect("the oven has a port");
  • The bugs live at the seams.
  • One contract test per port, run against every adapter. In Rust, the port is a trait, and the contract is a function generic over it: fn honours_the_oven_contract(oven: &impl Oven). Each adapter’s test calls it with the real technology: a real SQLite file, a real Ollama in a container.
  • Mock only what you own. Fake your own traits in unit tests. Test third-party behaviour in integration tests, because a mock of someone else’s API only checks what you think it does.
  • Pin the image like any other dependency. A tag at least, and a digest for anything you measure.

Test runner: Skipped is not passed

  • Rust’s test harness can’t skip a test while it runs. A test that returns early because Ollama isn’t there is reported as passed. A skip at least says it was skipped; this says nothing.
  • So decide before the test runs, with #[ignore]. An ignored test shows up as ignored in the summary. The everyday cargo test leaves it out, so a first-time cloner doesn’t need a 40 GB model.
  • CI runs cargo test -- --include-ignored, and the test panics if its input is missing. It’s the same test run two ways, and the Rust version needs no helper.
tests/oven_integration.rs
#[tokio::test]
#[ignore = "needs the oven: docker compose up oven, then cargo test -- --include-ignored"]
async fn sourdough_rises_overnight() {
    let oven_url = std::env::var("OVEN_URL")
        .expect("no oven, no bread: set OVEN_URL, or start it with docker compose up oven");
    // …
}
  • Never return early from a test because an input is missing. Panic, and say what’s missing and how to get it.

Security: Scan what you depend on

deny.toml
[advisories]
yanked = "deny"
ignore = [
  # { id = "RUSTSEC-0000-0000", reason = "only reachable through a feature we don't enable" },
]
 
[licenses]
allow = ["Apache-2.0", "MIT", "Unicode-3.0"]
 
[bans]
wildcards = "deny" # no `*` versions
 
[sources]
unknown-registry = "deny"
unknown-git = "deny"
  • cargo deny check covers four things in one file: advisories from the RustSec database, licences, banned crates, and where crates come from. There’s no API key to get, because the advisory database is a git repository.
  • Run it weekly as well as on every change. A new advisory can make the build fail without any change on your side.
  • Ignore an advisory in deny.toml, with a reason. Don’t remove the check.
  • cargo-audit checks the same advisories on their own, if that’s all you want.

Supply chain: Pin dependencies the way you pin tools

cargo build --locked # fail if Cargo.lock would have to change
  • Commit Cargo.lock, libraries included. Cargo’s guidance changed in 2023: the lockfile records what you tested against.
  • Use --locked on every CI command. Without it, Cargo quietly updates the lockfile when Cargo.toml and Cargo.lock disagree, and CI tests something nobody reviewed.
  • You get the checksums for free. Cargo.lock records a SHA-256 for every crates.io package, and Cargo checks it on download, with nothing to set up.
  • Restrict the sources. [sources] in deny.toml turns away any registry or git repository you didn’t name. That’s how an agent’s git = "…" dependency gets caught in review.
  • Next step: cargo-vet. It records who reviewed each dependency, and imports the audits other organisations have published.

Supply chain: Say what’s in the box

cargo cyclonedx --format json # writes <package>.cdx.json beside Cargo.toml
cargo auditable build --release # writes the dependency list into the binary itself
  • cargo-cyclonedx writes the SBOM: every component, its version and its licence. Publish one with every release.
  • cargo-auditable puts the SBOM in the binary. cargo audit bin ./target/release/app checks a deployed binary against new advisories, without the source or the release notes.

Gate: Nothing counts until CI runs it

.github/workflows/ci.yml
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with: { fetch-depth: 0 } # mutastic --since needs origin/main to compare against
      - uses: dtolnay/rust-toolchain@stable
        with: { components: "clippy, rustfmt" }
      - uses: Swatinem/rust-cache@v2
      - run: cargo fmt --all --check
      - run: cargo clippy --all-targets --locked -- -D warnings
      - run: cargo test --locked
      - run: |
          curl -fsSL https://mutastic.io/install.sh | MUTASTIC_NATIVE_VERSION=1.0.0 sh
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
      - run: cargo mutastic run --since=origin/main --threshold=80 # a PR's checkout has no local main
  deny:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: EmbarkStudios/cargo-deny-action@v2
  msrv: # the oldest toolchain rust-version in Cargo.toml promises
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@1.91
      - run: cargo build --all-targets --locked
  • dtolnay/rust-toolchain@stable gives you a floating toolchain on CI. That is the convention for a library, with the msrv job below for the oldest version it promises. An application pins its toolchain in rust-toolchain.toml, and Cargo uses that one instead (see for Readers).
  • Check the rust-version you declare. Cargo can’t tell you that rust-version = "1.91" is true. Only a build on 1.91 can.
  • Make it a required status check. A red build that can still be merged is only advice.
  • Pin the actions. A major tag at least. A commit SHA is safer, because tags can move.
  • Gate the agent before CI does. CI judges a change once it’s pushed. mutastic gate, run from the agent’s hooks by the Claude Code or Codex plugin, judges it before the agent may call the task done: every mutant on the lines it changed is caught or proposed for you to ignore, it added no ignore of its own, and it took no test away.

Too slow for every push, so run it nightly:

.github/workflows/nightly.yml
on:
  schedule: [{ cron: "0 3 * * *" }] # 03:00 UTC
  workflow_dispatch: # and on demand
jobs:
  mutation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - run: |
          curl -fsSL https://mutastic.io/install.sh | MUTASTIC_NATIVE_VERSION=1.0.0 sh
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
      - run: cargo mutastic run --threshold=80
      - uses: actions/upload-artifact@v7
        if: always() # the misses are the point
        with: { name: mutastic-report, path: target/mutastic/mutation.html }
  miri:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@nightly
        with: { components: miri }
      - run: cargo miri test -p mmio # only the crate that keeps the unsafe code
  integration:
    runs-on: [self-hosted, gpu] # the oven is a 40 GB model
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo test --locked -- --include-ignored # strict: a cold oven panics
  • Run the ignored tests, not just test. In an unattended run, an ignored test is easy to read as a passed one.
  • Container tests run on hosted runners. GitHub’s Linux runners have Docker, so testcontainers works there. Only tests that need a GPU-sized model need a self-hosted runner.
  • Upload the mutastic report even when the job passes. A score above the threshold can still hide the one miss that matters. mutation.html opens from disk, with no network. Add --report=html --report=sarif (naming one report drops the defaults) and upload target/mutastic/mutation.sarif with github/codeql-action/upload-sarif, and the misses show as code-scanning alerts on their lines.
  • Someone has to see a failure. GitHub notifies only the person who last edited the cron line. Scheduled runs use the default branch, and in a public repository they’re turned off after 60 days without activity.

The enforcement floor holds

  • When a gate breaks, fix the gate. If a new stable release adds a lint that fails -D warnings, fix the code or expect the lint with a reason. Don’t drop -D warnings.
  • Record every weakening. A lint you allow, a lower threshold, an ignored advisory or an ignored mutant gets its reason beside it, in the code, deny.toml or .mutastic/ignores.yaml.

Sources

BLUESKY — START THE THREAD KO-FI / RSS