sota-rust
State-of-the-art Rust engineering (2026) for writing and auditing Rust code. Covers idiomatic ownership and API design, error handling and panic policy, unsafe discipline with Miri, async/tokio (cancellation safety, structured concurrency, graceful shutdown), security and supply
Install
npx skills add https://github.com/martinholovsky/SOTA-skills/tree/main/skills/sota-rust
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install martinholovsky-sota-skills@llmmart
git clone https://github.com/martinholovsky/SOTA-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole martinholovsky/sota-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
SOTA Rust (2026)
Purpose
This skill encodes the 2026 state of the art for production Rust: the idioms,
security posture, performance discipline, and CI baseline expected of an
expert Rust codebase. Baseline as of mid-2026: a recent stable Rust toolchain (verify the current
release at blog.rust-lang.org),
edition 2024 (next edition expected ~2027), tokio still 1.x. It serves two modes — BUILD (write new code to this
standard) and AUDIT (find where existing code falls short, with severity
and evidence). The detailed rules live in rules/*.md; load only the files
relevant to the task (see index below). Every rules file ends with an "Audit
checklist" of grep/clippy patterns — use those verbatim in AUDIT mode.
BUILD mode
When writing or modifying Rust code:
- Scope the work, load the rules. Pick the relevant
rules/files from the index. Touching async code? Load 04. Adding a dependency, parsing network input, or spawning an external program? Load 05. Writing anyunsafe? Load 03 — no exceptions. - Design types first. Newtypes for domain primitives, errors per
subsystem (thiserror for libs, anyhow for apps), ownership tree before
Arc<Mutex<_>>, public API minimal and borrowed (&str/&[T]params). Parse, don't validate: constructors enforce invariants. - Write to the non-negotiables (bottom of this file) without being asked.
They are defaults, not suggestions; deviations carry a written
justification at the site (e.g.
expectwith invariant message,#[allow(lint, reason = "...")]). - Wire the scaffolding with the code, not after: lints in
[lints],deny.toml+cargo denyin CI for anything deployed, Miri job if unsafe exists, nextest, MSRV declared and tested, benches for claimed-hot paths. See rules/07 §9 for the CI shape to copy. - Verify before claiming done:
cargo fmt --check,cargo clippy --all-targets --all-features -- -D warnings,cargo nextest run+cargo test --doc, andcargo docwarning-free for libraries. If you wrote unsafe:cargo +nightly miri testover it. If you claimed performance: show the benchmark. - Comment intent at decision points the next reader will question:
justified clones, cancel-safety of
select!arms, SAFETY comments, channel-capacity choices, poisoning policy.
AUDIT mode
When reviewing or auditing existing Rust:
- Recon first:
cargo metadata/workspace layout,Cargo.tomlprofiles and features, CI config,rg 'unsafe' --count-matches, dependency tree (cargo tree -d). This decides which rules files to load and where risk concentrates (network input? unsafe? async service?). - Run the audit checklists at the end of each loaded rules file — they are ordered grep/clippy hunts with pre-calibrated severities.
- Validate every finding: read the surrounding code; a grep hit is a lead, not a finding. Confirm reachability (is the unwrap on an attacker-influenced path?) before assigning severity.
- Report with the finding format below. Prefer few, true, prioritized findings over volume. Note positive observations where the code is already SOTA (prevents "fixes" that regress good decisions).
Severity conventions
| Severity | Meaning | Examples |
|---|---|---|
| Critical | Exploitable now, or UB | reachable UB, unsound safe API, SQLi/path traversal, authn bypass, unwinding across FFI, secrets in logs+repo |
| High | Exploitable under realistic conditions, or correctness loss | attacker-reachable panic/OOM (DoS), wrapped arithmetic on untrusted lengths, cancellation data loss, deadlock (block_on in async, lock across await), unbounded channels fed by network, missing dep-audit in deployed-service CI |
| Medium | Latent defect or eroded defense | missing SAFETY comments, no Miri CI on unsafe crate, swallowed errors (.ok(), filter_map(Result::ok)) uncommented, untested MSRV, non-additive features, orphaned spawned tasks |
| Low | Hygiene, idiom, maintainability | clone-to-satisfy-borrowck, index loops, missing #[non_exhaustive], missing # Errors docs, blanket #[allow] without reason |
Severity scales with reachability (attacker-controlled > user > operator > build-time) and blast radius (process death > request failure > slow).
Finding format
[SEVERITY] short title
Where: path/to/file.rs:123 (fn name / module)
What: the defect, in one or two sentences
Why: concrete consequence (exploit path, failure mode, cost)
Fix: specific change — code sketch or named pattern from rules/NN
Effort: trivial | small | medium | large
Refs: rules/NN §M; clippy lint or RUSTSEC id if applicable
Group findings by severity, Critical first. End with: checklist coverage (which rules files were applied), what was not reviewed, and quick wins (one-line fixes with outsized value).
Rules index
| File | Read this when... |
|---|---|
| rules/01-ownership-and-api-design.md | Designing structs/traits/modules/workspaces; fighting the borrow checker; deciding clone vs borrow vs Rc/Arc; newtype, typestate, builder patterns; sealed traits, coherence; comparison-trait (Eq/Ord) invariants; exhaustive matching; iterator-chain idioms |
| rules/02-errors-and-panics.md | Choosing thiserror vs anyhow/eyre; designing error enums; unwrap/expect policy and invariant messages; context discipline; panic policy for servers, FFI, and Drop (no panic in destructors); Option/Result combinator flow; not unwrapping an Option back into a sentinel (unwrap_or(-1), serde(default) on numbers) |
| rules/03-unsafe-discipline.md | Writing or reviewing ANY unsafe; SAFETY comment standards; UB catalog (aliasing, uninit, transmute, FFI lifetimes); Miri/sanitizers/loom in CI; cargo-geiger; soundness review protocol |
| rules/04-async-tokio.md | Anything async: tokio, spawn vs spawn_blocking, Send/Sync bound errors, select! and cancellation safety, JoinSet/TaskTracker, channel selection, locks across await, async traits, graceful shutdown |
| rules/05-security-supply-chain.md | Network-facing or deployed code; adding dependencies; cargo audit/deny/vet; integer overflow on untrusted input; panic-DoS; zeroize/constant-time for secrets; serde hardening (untagged enums, size limits); service-edge defaults; spawning external programs (std::process::Command — argv vs shell, the Windows .bat CVE, a dropped Child that keeps running, deadlines that don't kill, §9) |
| rules/06-performance.md | Performance work or claims: profiling (samply/perf/flamegraph, criterion/divan), allocation reduction (Cow/SmallVec/buffer reuse), accidental clones, iterator fusion, release profile (LTO, codegen-units, panic=abort), PGO |
| rules/07-tooling-ci.md | Setting up or auditing repo scaffolding: clippy policy and pedantic triage, rustfmt, nextest, MSRV declaration+testing, additive feature flags, docs.rs discipline, edition 2024 migration, CI baseline. Test strategy — suite shape, TDD, doubles, test data, flake policy — lives in sota-testing; load it for any build that writes logic. This file owns Rust runner mechanics only. |
Top-10 non-negotiables
- No
unwrap()/bareexpect()on production paths. Propagate with?+ context;expect("...")only with a message proving the invariant. An attacker-reachable panic is a DoS. (rules/02) - Every
unsafeblock has a// SAFETY:comment discharging the called API's documented preconditions, and lives behind a sound safe abstraction. Unsafe code without Miri in CI is unaudited code. (rules/03) - Libraries: thiserror enums with
#[source]chains. Applications: anyhow with.context(). Neveranyhow::Errorin a public lib API; never silently swallowed errors. (rules/02) - Never block the async runtime: no sync I/O,
std::thread::sleep, or sustained CPU insideasync fn;spawn_blockingor a compute pool. Nostd::sync::MutexGuardheld across.await. (rules/04) - Every
select!/timeout/abort path is cancellation-reviewed: futures dropped at any.await; cancel-unsafe ops don't go inselect!arms; invariants spanning awaits get drop guards. Spawned tasks are owned (JoinSet/TaskTracker), never orphaned. (rules/04) - Untrusted input gets checked arithmetic, size limits, and depth limits:
checked_*/try_intoon lengths (release mode wraps silently), body-size caps before parsing, no#[serde(untagged)]or uncappedwith_capacityon hostile data. (rules/05) - Supply chain is CI-enforced:
cargo deny/cargo auditon PRs + scheduled,Cargo.lockcommitted, new deps vetted (cargo vet or documented review), git deps pinned by rev. (rules/05) - Secrets are typed (
SecretString/Zeroizing), redacted from Debug/logs, and compared in constant time (ct_eq). Key material only from OS randomness. (rules/05) - Don't clone to satisfy the borrow checker; don't take owned params you
only read.
&str/&[T]in signatures, split borrows,mem::take; newtypes over primitive obsession; exhaustive matches (no lazy_ =>on owned enums). (rules/01) - CI gate: fmt + clippy
-D warnings(triaged pedantic) + nextest + doctests + MSRV job + feature-matrix check. Performance claims require benchmarks; release profile (LTO/codegen-units/panic strategy) is a deliberate, documented choice. (rules/06, 07)
Files (sota-skills)
-
rules
-
01-ownership-and-api-design.md 14 KB
# 01 — Ownership, Borrowing & API Design Idiomatic ownership is the difference between Rust that fights you and Rust that documents itself. These rules cover borrow patterns, smart-pointer selection, type-driven design (newtype, typestate, builder), trait design, and code layout. ## 1. Borrowing & clone discipline **Never clone to satisfy the borrow checker.** A `.clone()` whose only purpose is to silence E0502/E0505 is a design smell: restructure scopes, split borrows, or take ownership earlier. ```rust // BAD: clone because `self.items` is borrowed while mutating let names: Vec<String> = self.items.iter().map(|i| i.name.clone()).collect(); for name in names { self.register(&name); } // GOOD: split the struct so disjoint fields borrow independently let (items, registry) = (&self.items, &mut self.registry); for item in items { registry.register(&item.name); } ``` - Split-borrow via destructuring or accessor methods returning `(&A, &mut B)`. - For "clone before loop" patterns over small data, `Copy` types or indices are cheaper than cloning `String`/`Vec`. - `std::mem::take` / `mem::replace` extract owned values from `&mut self` without cloning (leave a cheap default behind). - Legitimate clones exist: crossing thread/task boundaries, caching, fan-out. Comment intent when a clone looks gratuitous: `// clone: sent to spawned task`. **Accept the most general borrowed form in APIs:** ```rust // BAD: forces callers to own/allocate fn process(s: String, items: Vec<u32>) -> usize { ... } // GOOD: borrow; caller keeps ownership fn process(s: &str, items: &[u32]) -> usize { ... } ``` - Parameters: `&str` over `&String`, `&[T]` over `&Vec<T>`, `&Path` over `&PathBuf`, `impl AsRef<Path>` / `impl Into<String>` when ergonomics matter (but not on hot generic-bloat-sensitive paths — use inner non-generic fn). - Take ownership (`String`, `Vec<T>`) only when you store the value. Taking `&str` then immediately `.to_owned()` forces a copy the caller may have been able to move; prefer `impl Into<String>` there. - Return `&str`/`&[T]` borrowed from `self` where lifetimes allow; `Cow<'_, str>` when sometimes-owned (see rules/06 for perf framing). ## 2. Rc/Arc/Box/RefCell: when shared ownership is right Reach for smart pointers in this order; each step is a justified escalation: | Need | Tool | |---|---| | Heap allocation / unsized / recursion | `Box<T>` | | Shared ownership, single-threaded | `Rc<T>` | | Shared ownership across threads | `Arc<T>` | | Shared + interior mutation, single-threaded | `Rc<RefCell<T>>` | | Shared + mutation, multi-threaded | `Arc<Mutex<T>>` / `Arc<RwLock<T>>` | | Shared, replace-on-write, read-mostly | `Arc<T>` + swap (`arc-swap`) | - `Arc<Mutex<T>>` everywhere is Java-in-Rust. Prefer **ownership trees** with message passing (channels) for cross-task state; share only what is genuinely shared (config, caches, connection pools). - `Rc::clone(&x)` / `Arc::clone(&x)` (associated form) — makes "refcount bump, not deep copy" greppable. - Break `Rc`/`Arc` cycles with `Weak` (parent links, observer lists). Audit any graph-shaped `Rc<RefCell<...>>` for leaks. - `RefCell` panics at runtime on double-borrow; confine it to module-private state with documented borrow discipline. Never let `RefCell` borrows escape across a callback into user code. - Immutable shared config: `Arc<Config>` (no lock). Read-mostly: `RwLock` only if writes are rare AND readers are many; otherwise `Mutex` (simpler, no writer starvation surprises). ## 3. Newtype pattern Wrap primitives that have semantics. `u64` is not a `UserId`. ```rust // BAD fn transfer(from: u64, to: u64, amount: u64) -> Result<(), Error> // GOOD #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct AccountId(u64); #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct Cents(u64); fn transfer(from: AccountId, to: AccountId, amount: Cents) -> Result<(), Error> ``` - Validate at construction: `impl TryFrom<&str> for Email` returning a typed error; keep the field private so the only way in is the validated path ("parse, don't validate"). - Derive the full expected set (`Debug, Clone, Copy, PartialEq, Eq, Hash`, plus `serde` derives behind a feature) — missing derives are friction that pushes callers back to raw primitives. - Implement `Deref` to the inner type **only** for true smart-pointer-like wrappers; for domain newtypes expose explicit `as_str()`/`get()` instead (Deref leaks the abstraction). - Newtypes are also the coherence escape hatch: wrap a foreign type to impl a foreign trait (orphan rule). ## 4. Typestate pattern Encode protocol/state machines in the type system so invalid transitions don't compile. Use when misuse is plausible and states are few. ```rust pub struct Connection<S> { stream: TcpStream, _state: PhantomData<S> } pub struct Unauthenticated; pub struct Authenticated; impl Connection<Unauthenticated> { pub fn authenticate(self, creds: &Creds) -> Result<Connection<Authenticated>, AuthError> { ... } } impl Connection<Authenticated> { pub fn query(&mut self, q: &str) -> Result<Rows, QueryError> { ... } // only here } ``` - Transitions consume `self` and return the next state — the old state is unusable afterward, enforced at compile time. - Don't typestate everything: runtime enums are right when states are dynamic, numerous, or serialized. Typestate shines for build-time-known protocols (request builders, handshakes, init-before-use). ## 5. Builder pattern Use a builder when a constructor would exceed ~4 params or has many optionals. ```rust let server = Server::builder() .bind("0.0.0.0:8080") .worker_threads(8) .tls(tls_config) // optional .build()?; // validation lives here, returns Result ``` - `build()` returns `Result<T, BuildError>` if any combination can be invalid; infallible builders may return `T`. - Owned `self` methods (move-chain) for one-shot builders; `&mut self` methods when the builder is configured conditionally across branches. Pick one style per builder. - Required params: take them in `T::builder(required)` rather than failing at `build()` — or use typestate builders for compile-time required-field checks (what `typed-builder`/`bon` generate). Consider `bon` (2024+) before hand-rolling large builders. - `#[non_exhaustive]` on config structs + builder is the semver-safe way to add options later. ## 6. Trait design, sealed traits, coherence - Keep traits **small and capability-shaped** (`Read`, `Write`, not `RepositoryManagerService`). Compose with supertraits sparingly. - **Sealed traits** prevent downstream impls so you can add methods without a breaking change: ```rust mod sealed { pub trait Sealed {} } pub trait Backend: sealed::Sealed { fn run(&self) -> Output; // adding a defaulted method later is non-breaking: no foreign impls exist } impl sealed::Sealed for Postgres {} impl Backend for Postgres { ... } ``` - Orphan rule: you may impl a trait only if you own the trait or the type. Workarounds: newtype wrapper, or define a local conversion trait. Never paper over it with a blanket `impl<T> From<T>` that will collide later. - Prefer generics (`impl Trait` / `<T: Trait>`) for hot paths (monomorphized, inlinable); `dyn Trait` for heterogeneous collections, plugin registries, and to cut compile time/bloat on cold paths. `Box<dyn Error + Send + Sync>` at app boundaries is fine. - Return-position `impl Trait` (and RPITIT in traits, stable since 1.75) over boxed returns where the concrete type is single. For public traits needing dyn-compatibility with async, use `async-trait` or hand-rolled `Box<Pin<...>>` returns (see rules/04). - Implement standard traits eagerly: `Debug` on everything public (or a manual redacting impl for secrets — rules/05), `Default` where a zero-config value exists, `Display` + `std::error::Error` on errors, `From` for infallible conversions, `TryFrom` for fallible ones. Never impl `Into` directly. - **`Eq`/`Ord`/`PartialEq`/`PartialOrd`: `#[derive]` them; hand-write only with care.** The std library *assumes* their invariants — `PartialEq` symmetry and transitivity, `Eq` reflexivity, `Ord` a total order consistent with `Eq`, and `PartialOrd`/`Ord` agreement. A manual impl that breaks them is not a compile error but silently corrupts `sort`, `binary_search`, `BinaryHeap`, `BTreeMap`/`Set`, and dedup — wrong results, lost entries, or a panic (`sort_unstable` may panic on a non-total `Ord`). If you must hand-roll, define the minimal method (`Ord::cmp`, then `PartialOrd` via `Some(self.cmp)`) so the others stay consistent, and never derive `Ord` on a type whose `f32`/ `f64` field makes the order partial. (ANSSI `LANG-CMP-INV`/`-DERIVE`) ## 7. Exhaustive matching & `#[non_exhaustive]` - Match exhaustively on **your own** enums; avoid `_ =>` arms so the compiler flags every new variant at each match site: ```rust // BAD: silently swallows future variants match event { Event::Open => ..., _ => {} } // GOOD: compiler forces a decision when Event grows match event { Event::Open => ..., Event::Close => ..., Event::Ping => {} } ``` - Mark public enums/structs `#[non_exhaustive]` when variants/fields will grow; downstream is then required to write `_` arms and your additions are non-breaking. Don't mark closed sets (e.g. `Ordering`-like) — it destroys downstream exhaustiveness checking for no gain. - `let ... else` for refutable bindings with early return; `matches!()` for boolean checks; `if let` chains (stable since 1.88) over nested `if let`; `if let` guards in `match` arms (`Some(x) if let Ok(y) = f(x) =>`, stable since 1.95) over guard-then-rematch patterns. ## 8. Iterators & combinators - Iterator chains over index loops: no bounds-check noise, no off-by-one, fuses and optimizes as well or better than manual indexing. ```rust // BAD let mut out = Vec::new(); for i in 0..xs.len() { if xs[i].active { out.push(xs[i].score * 2); } } // GOOD let out: Vec<_> = xs.iter().filter(|x| x.active).map(|x| x.score * 2).collect(); ``` - Know the collect targets: `Vec<_>`, `String`, `HashMap<_,_>`, `Result<Vec<T>, E>` (fail-fast), `itertools::process_results` for streaming. - Option/Result combinators over match ladders: `map`, `and_then`, `ok_or_else`, `unwrap_or_else`, `?` with `From` conversions. But: a 5-combinator chain that needs a comment should be a `match` — clarity wins. - Use lazy variants on hot/fallible paths: `unwrap_or_else(|| expensive())`, `ok_or_else`, not `unwrap_or(expensive())` (argument always evaluated). - Need the index? `.enumerate()`. Need pairs? `.zip()` / `.windows(2)` / `.chunks(n)` — or `array_windows::<N>()` (stable 1.94) when a const-size `&[T; N]` lets you destructure without bounds checks. Early exit with side results: `try_fold` / `find_map`. ## 9. Module & workspace organization - Module = privacy boundary, not a filing cabinet. Keep invariant-carrying fields private; expose constructors that enforce them. `pub(crate)` over `pub` until something external needs it. - Re-export the public API at the crate root (`pub use module::Type;`) so the internal tree can move without breaking users; keep the public surface flat and small. - Prefer `foo.rs` + `foo/` subdir over `foo/mod.rs` (one convention per repo). - Workspace structure for anything beyond one crate: ```toml # /Cargo.toml [workspace] members = ["crates/*"] resolver = "3" # edition 2024 default [workspace.package] edition = "2024" rust-version = "1.85" license = "MIT OR Apache-2.0" [workspace.dependencies] # single source of version truth serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } ``` Member crates use `serde = { workspace = true }`. Split crates along compile and dependency boundaries (`core` no-heavy-deps, `cli`, `server`), not by "layer" for its own sake — each crate is a compile unit and a semver unit. - Binary crates: keep `main.rs` thin (arg parsing + call into `lib.rs`) so the logic is testable. ## Audit checklist - [ ] `rg '\.clone\(\)' -t rust` — review each hit near a `for`/borrow error fix; flag clones of `String`/`Vec`/large structs that exist only to appease borrowck. `clippy::redundant_clone` (note: known false negatives, still run it). - [ ] `rg 'fn \w+\((&self, )?\w+: (String|Vec<|PathBuf)' -t rust` — owned params that are only read → should borrow. - [ ] `rg '&String|&Vec<|&PathBuf|&Box<' -t rust` — double-indirection params (`clippy::ptr_arg` catches most). - [ ] `rg 'Arc<Mutex<' -t rust` — each one: is shared mutable state actually required, or would a channel/owner task work? Check for `Weak` where graphs/cycles appear: `rg 'Rc<RefCell<'`. - [ ] `rg '_ =>' -t rust` — wildcard arms on local enums lose exhaustiveness; `clippy::wildcard_enum_match_arm` (pedantic) to enforce. - [ ] Public enums likely to grow without `#[non_exhaustive]`: `rg -B2 'pub enum' | rg -v non_exhaustive`. - [ ] `rg 'for i in 0\.\.' -t rust` — index loops; check `clippy::needless_range_loop`. - [ ] `rg 'unwrap_or\([a-zA-Z_]+\(' -t rust` — eager argument evaluation (`clippy::or_fun_call`). - [ ] Raw primitive IDs in public signatures: `rg 'fn .*\b(id|user|key)\w*: (u32|u64|i64|String)'`. - [ ] Public trait intended to be closed but unsealed — can a downstream crate impl it? If yes and that's unintended, seal it. - [ ] Hand-written comparison impls: `rg 'impl (PartialEq|Eq|PartialOrd|Ord)' -t rust` — verify invariants (total order, consistency with `Eq`, symmetry); a broken `Ord` corrupts `sort`/`BinaryHeap`/`BTreeMap` or panics. Prefer `#[derive]`. `LANG-CMP-INV`. `clippy::derive_ord_xor_partial_ord`, `clippy::non_canonical_partial_ord_impl`. - [ ] Multi-crate repo without `[workspace.dependencies]` → version drift; `cargo tree -d` to find duplicate dependency versions. - [ ] Clippy gates for this file's concerns: `clippy::needless_pass_by_value`, `clippy::trivially_copy_pass_by_ref`, `clippy::large_enum_variant`, `clippy::rc_buffer`, `clippy::mutable_key_type`. -
02-errors-and-panics.md 13.5 KB
# 02 — Error Handling & Panic Policy Errors are API. Panics are program-integrity violations, not control flow. These rules define the library/application split, the unwrap policy, and how panics interact with servers and FFI. ## 1. The library/application split - **Libraries**: concrete, structured error enums via `thiserror`. Callers must be able to match on failure modes without string-parsing. - **Applications** (binaries, services, CLIs): `anyhow` (or `eyre` if you want custom report handlers/spantrace) — context-rich, cheap to propagate, rendered once at the top. - Never expose `anyhow::Error` in a library's public API; never build elaborate error enums in app code that only ever get printed. ```rust // LIBRARY (thiserror) #[derive(Debug, thiserror::Error)] pub enum StoreError { #[error("key not found: {0}")] NotFound(String), #[error("serialization failed")] Serde(#[from] serde_json::Error), #[error("io error accessing {path}")] Io { path: PathBuf, #[source] source: std::io::Error }, } // APPLICATION (anyhow) use anyhow::{Context, Result}; fn load_config(path: &Path) -> Result<Config> { let raw = fs::read_to_string(path) .with_context(|| format!("reading config {}", path.display()))?; toml::from_str(&raw).context("parsing config TOML") } ``` ## 2. Designing library error types - One error enum **per fallible subsystem**, not one giant crate-wide enum where every function claims it can fail in 14 ways it can't. If a function has two failure modes, its error type should show two. - Preserve sources: `#[source]` (or `#[from]`) so `Error::source()` chains work and `anyhow` callers get full causality. Don't flatten causes into strings. - `#[from]` only when the conversion is unambiguous; if two variants wrap `io::Error`, use explicit constructors with context (path, operation) instead. - Mark public error enums `#[non_exhaustive]` so adding failure modes is non-breaking. - Errors must be `Debug + Display + Send + Sync + 'static` (and implement `std::error::Error`) — otherwise they won't flow through `anyhow`/`Box<dyn>`. - Don't `impl From<MyError> for String` or stringly-type errors. Display is for humans; variants are for machines. - Big errors slow every `Result` return: keep error types ≤ ~3 words or box the payload (`clippy::result_large_err` flags >128 bytes). ## 3. Context discipline - Add context **at each boundary where information exists that the callee lacked**: file paths, IDs, request params. `?` alone up a 6-frame stack yields "No such file or directory" with no idea which file. - `with_context(|| ...)` (lazy) over `context(format!(...))` (eager allocation on success path). - In services, attach machine context to spans (`tracing` fields), human context to the error. Log errors **once**, at the handling site, with `{:#}`/error-chain rendering — not at every propagation hop (double-logging audit smell). ## 4. unwrap/expect policy **Production code paths: no `unwrap()`. `expect()` only with an invariant message — a message stating why this cannot fail, not what failed.** ```rust // BAD let port = env::var("PORT").unwrap(); let re = Regex::new(pattern).unwrap(); // user-supplied pattern! // ACCEPTABLE: invariant expect — message states the reason it can't fail let re = Regex::new(r"^[a-z]+$").expect("static regex is valid"); let ts = UNIX_EPOCH.elapsed().expect("system clock before 1970"); // GOOD: fallible inputs propagate let port: u16 = env::var("PORT") .context("PORT not set")? .parse() .context("PORT is not a valid u16")?; ``` - Convention: messages read as "this holds because…" — `expect("mutex not poisoned: no panicking critical sections")`. If you can't write the invariant, you don't have one; return an error. - `unwrap()` is fine in: tests, benches, examples, doc-tests, build scripts, and `const`/static init where failure is a compile-mistake. Gate the rest: `#![warn(clippy::unwrap_used)]` (and `clippy::expect_used` in the strictest crates), with `#[allow]` + justification at the call site when truly needed. - Slicing/indexing (`xs[i]`, `&s[a..b]`) and integer `as` casts are implicit unwraps — use `.get(i)`, `s.get(a..b)`, `try_into()` on untrusted values (string slicing also panics on non-char-boundary). - `unreachable!()` must carry the proof: `unreachable!("len checked > 0 above")`. ## 5. Panics: when, and what they cost Panic only for **bugs** — violated invariants, impossible states — never for bad input, missing files, network failure, or anything an attacker controls. - Servers: a panic in a handler must not kill the process, and a reachable panic is a DoS primitive (rules/05 §4). Catch at the task boundary — `tokio::spawn` already isolates panics into `JoinError`; check `JoinError::is_panic()` and log. Axum/tower: `CatchPanicLayer`. But treat every caught panic as a bug to fix, not a handled case. - `panic = "abort"` in release profiles (rules/06) means no unwinding: no `catch_unwind`, panics kill the process. Decide deliberately for servers — abort+supervisor-restart is a valid stance, but then panic-freedom of handlers is load-bearing. - **FFI**: unwinding across `extern "C"` is UB. Wrap Rust callbacks invoked from C in `std::panic::catch_unwind` (or use `extern "C-unwind"` only when both sides genuinely support it). - **`Drop` impls must not panic.** `drop()` itself runs during unwinding from another panic; a panic there is a *double panic* that — per the std `Drop` docs — "will likely abort the program." So a panicking destructor turns one recoverable failure into process death (DoS). Destructors clean up and return normally; if a Drop genuinely must signal misuse, gate it on `std::thread::panicking()` first. This applies to RAII guards, buffer flushers, and `Zeroize`-on-drop types alike. (ANSSI `LANG-DROP-NO-PANIC`) - Poisoned mutexes (`std::sync::Mutex`): a panic while holding the lock poisons it. Decide policy once: propagate (`lock().expect("not poisoned: …")`) or recover (`unwrap_or_else(PoisonError::into_inner)`) — document which. - Allocation failure aborts; for memory-bound parsing of untrusted sizes use `try_reserve` (rules/05 §6). ## 6. Option/Result flow patterns - `?` everywhere it applies, including `Option` in functions returning `Option`. - Convert at the edges: `ok_or_else(|| StoreError::NotFound(key.into()))` turns `Option` into `Result` exactly where "absence is an error" becomes true — not earlier. - `let Some(x) = ... else { return ... };` for guard clauses; keeps the happy path unindented. - Fallible iteration: `collect::<Result<Vec<_>, _>>()` fail-fast; or `partition_result` / inspect-and-log per item when partial success is the semantics. Choose explicitly — silent `filter_map(Result::ok)` **discards errors** and is an audit finding unless commented. - Don't `match` on a `Result` just to re-wrap (`Ok(v) => Ok(f(v)), Err(e) => Err(e)`) — that's `.map(f)`. Clippy: `manual_map`, `question_mark`, `needless_match`. ### 6a. Don't unwrap an Option back into a sentinel Rust makes the in-band sentinel (`sota-architecture` rules/02 §8a) hard to write by accident: `str::find` and `Iterator::position` return `Option<usize>`, not `-1` (verified, rustc 1.97.1). The way it gets reintroduced is at the *seam*: - `opt.unwrap_or(-1)`, `unwrap_or_default()` on a numeric `Option`, or `unwrap_or(0)` — each discards the type system's absence encoding and hands a domain value downstream (verified: `None::<i32>.unwrap_or(-1)` is `-1`). Push the `Option` outward instead; collapse it only where the value is *consumed*, and then with `match`/`ok_or`, not a magic number. - FFI and wire boundaries are where it enters: a C ABI returning `-1`/`errno`, a protobuf `int32` with no `optional`, `serde` deserializing a field whose absence the schema encodes as a value. Convert **at the boundary** into `Option`/`Result` — the anti-corruption-layer rule, applied to a scalar. - `#[serde(default)]` on a numeric field silently substitutes `0` for absent. That is an in-band sentinel chosen by an attribute; use `Option<T>` unless `0` is genuinely the right value. - Audit: `grep -rnE 'unwrap_or\(-?[0-9]+\)|unwrap_or_default\(\)' --include='*.rs' .` and `serde(default)` on numeric fields. ## 7. Retryability & error classification Services need errors classified for *behavior*, not just display. Encode the decision the caller must make: ```rust #[derive(Debug, thiserror::Error)] pub enum FetchError { #[error("transient: {0}")] Transient(#[source] anyhow::Error), // timeouts, 503, conn reset #[error("permanent: {0}")] Permanent(#[source] anyhow::Error), // 4xx, validation, auth } impl FetchError { pub fn is_retryable(&self) -> bool { matches!(self, Self::Transient(_)) } } ``` - Classify at the site that knows (the HTTP client wrapper knows 503 vs 400); upper layers consume `is_retryable()` instead of re-inspecting causes. - Retries always carry backoff + jitter + a cap (`backon`/`tokio-retry` style); retrying permanent errors is load amplification, retrying without jitter is a thundering herd. - Map the same classification to HTTP/gRPC status at the boundary in ONE place (an `IntoResponse`/`From<Error> for Status` impl), not per-handler. - Idempotency: only auto-retry operations that are idempotent or carry an idempotency key — a retried non-idempotent POST is a correctness bug, not resilience. ## 8. Testing error paths Error paths are code; untested error paths are where prod incidents live. - Unit-test that fallible constructors reject bad input with the *right variant*: `assert!(matches!(parse(""), Err(ParseError::Empty)))` — not just `.is_err()` (a panic-turned-error or wrong variant passes `.is_err()`). `assert_matches!`/`debug_assert_matches!` (stable since 1.96) do the same and panic with the actual value on mismatch — prefer them in new tests. - Test `Display` output of user-facing errors (snapshot with `insta`) — error messages are UI and regress silently. - Fault injection at trait boundaries: a mock `Storage` whose `get` returns `Err(Io(...))` proves the caller's context/retry/cleanup logic. If errors can't be injected, the seam is missing (concrete deps where traits/generics belong). - For panics that are part of a documented contract: `#[should_panic(expected = "...")]` pins the message; for `Result`-returning tests use `fn test() -> anyhow::Result<()>` and `?` freely. ## 9. Error reporting at the top ```rust fn main() -> anyhow::Result<()> { ... } // Debug-renders the chain on error // or for custom exit codes / human rendering: fn main() -> ExitCode { if let Err(e) = run() { eprintln!("error: {e:#}"); // {:#} renders the full context chain return ExitCode::FAILURE; } ExitCode::SUCCESS } ``` - Services: render error chain into structured logs (`tracing::error!(error = ?err)` or the `err` field shorthand), map to a client-safe status — never leak internal error text to HTTP responses (info disclosure, rules/05). - Install a panic hook that logs through your structured logger (`std::panic::set_hook`) so panics in production aren't lost to stderr. ## Audit checklist - [ ] Sentinel re-entry (§6a): `rg 'unwrap_or\(-?[0-9]+\)|unwrap_or_default\(\)' -t rust` on numeric `Option`s, and `#[serde(default)]` on numeric fields — each discards the absence the type system was carrying. - [ ] `rg '\.unwrap\(\)' -t rust -g '!*test*' -g '!benches/*' -g '!examples/*'` — every hit in production paths is a finding; severity scales with input reachability (attacker-reachable unwrap = High). - [ ] `rg '\.expect\("' -t rust` — messages must state invariants ("valid static regex"), not restate the failure ("failed to parse"). - [ ] `rg '\.unwrap_or_default\(\)|filter_map\(Result::ok\)|\.ok\(\)[;)]' -t rust` — silently swallowed errors; require a comment justifying each. - [ ] `rg 'panic!|unreachable!|todo!|unimplemented!' -t rust -g '!*test*'` — `todo!`/`unimplemented!` in shipped code = High; `panic!` on input-derived conditions = High (DoS). - [ ] Indexing on untrusted data: `rg '\[[a-z_]+\]' -t rust` near parse/decode code; string slicing `&s[`, plus `clippy::indexing_slicing` (restriction lint) on parser crates. - [ ] Library crates exporting `anyhow::Error`/`Box<dyn Error>` in public signatures: `rg 'pub fn .*-> .*(anyhow|Box<dyn Error)' -t rust`. - [ ] thiserror enums: variants missing `#[source]`/`#[from]` on wrapped errors (broken causality chains); public error enums missing `#[non_exhaustive]`. - [ ] Double-logging: error logged at propagation site AND handler. - [ ] `?` chains with no `.context(...)` anywhere between syscall and `main` — undebuggable errors. - [ ] FFI: `extern "C"` functions whose bodies can panic without `catch_unwind` → UB, Critical. - [ ] `Drop` impls that can panic: `rg -A15 'impl Drop' -t rust` then scan for `unwrap`/`expect`/`panic!`/indexing/`?` inside `fn drop` — double-panic aborts the process (DoS), High. `LANG-DROP-NO-PANIC`. - [ ] Retry loops: `rg 'retry|backoff' -t rust -i` — retries without classification (retrying 4xx), without jitter/cap, or around non-idempotent operations. - [ ] Error tests assert variants (`matches!`), not just `.is_err()`; error seams injectable (trait boundaries) for fault testing. - [ ] Lints to enforce in CI: `clippy::unwrap_used`, `clippy::panic` (servers), `clippy::result_large_err`, `clippy::or_fun_call`, `clippy::manual_let_else`; consider `#![deny(clippy::unwrap_used)]` at crate roots of network-facing crates. -
03-unsafe-discipline.md 11.9 KB
# 03 — Unsafe Discipline `unsafe` does not turn off the rules; it makes **you** the checker. The standard is: minimal surface, sound encapsulation, documented invariants, and tooling (Miri, sanitizers) that re-checks what the compiler can't. ## 1. Minimize, then isolate - First question for any `unsafe`: **is there a safe equivalent?** Most are: `split_at_mut`, `MaybeUninit` + `Vec::spare_capacity_mut`, `bytemuck`/`zerocopy` for transmutes, `OnceLock`/`LazyLock` for lazy statics, `Cell`/`RefCell` for interior mutability, `Pin` APIs instead of raw self-references. The safe surface keeps growing — 1.93 stabilized `MaybeUninit` slice APIs (`assume_init_ref`/`assume_init_mut`/`write_copy_of_slice`), retiring many hand-rolled init loops. - Performance claims require receipts: an `unsafe` "optimization" (`get_unchecked`, skipped UTF-8 checks) without a benchmark showing the safe version is the bottleneck is a finding. Bounds checks usually vanish under iterators or a single up-front `assert!(len <= buf.len())` hoisting. - Isolate unsafe in **small modules/crates with a safe public API** whose soundness can be argued locally. The privacy boundary is the soundness boundary: if a `pub` field or safe method can break the invariant your unsafe code relies on, the abstraction is unsound *even if no caller does it today*. ```rust // Sound abstraction: invariant (`init <= N`) is privately owned, // every safe method maintains it, unsafe is locally justifiable. pub struct FixedVec<T, const N: usize> { buf: [MaybeUninit<T>; N], init: usize, // INVARIANT: buf[..init] is initialized } impl<T, const N: usize> FixedVec<T, N> { pub fn push(&mut self, v: T) -> Result<(), T> { if self.init == N { return Err(v); } self.buf[self.init].write(v); self.init += 1; Ok(()) } pub fn as_slice(&self) -> &[T] { // SAFETY: buf[..init] is initialized (struct invariant, maintained // by push/pop; init never exceeds N). unsafe { slice::from_raw_parts(self.buf.as_ptr().cast(), self.init) } } } ``` ## 2. SAFETY comments — non-negotiable **Every `unsafe` block carries a `// SAFETY:` comment proving each obligation of the called API is met.** Every `unsafe fn` and unsafe trait impl carries a `/// # Safety` doc section stating what callers/implementors must uphold. ```rust // BAD let x = unsafe { *ptr }; // GOOD // SAFETY: `ptr` comes from Box::into_raw in `Self::new`, is non-null and // aligned, and is not freed until Drop; no &mut alias exists because we // hold &self and the field is not otherwise exposed. let x = unsafe { *ptr }; ``` - The comment addresses the **specific preconditions** in the unsafe API's docs (non-null, aligned, initialized, valid-for-reads, no aliasing, lifetime bounds) — not vibes like "this is fine". - Enforce mechanically: `#![deny(clippy::undocumented_unsafe_blocks)]` (and `clippy::missing_safety_doc`, which is warn-by-default). Edition 2024: `unsafe_op_in_unsafe_fn` is warn-by-default — write explicit `unsafe {}` blocks inside `unsafe fn` so each obligation site is visible and commented. - An `unsafe fn` whose safety contract can't be written in one paragraph has the wrong API shape — split it. ## 3. The UB catalog — what actually bites Audit unsafe code against these, in observed-frequency order: 1. **Aliasing violations**: constructing two `&mut` to the same data, or a `&mut` while a `&` lives — *creating* the reference is UB even if unused. Classic source: `&mut *ptr` twice, casting `&T` → `&mut T` (always UB — `clippy::cast_ref_to_mut`/compiler `invalid_reference_casting` lint), `Vec`/self-referential pointer invalidated by reallocation. 2. **Uninitialized memory**: `mem::uninitialized()` (deprecated, instant UB for most types) and `MaybeUninit::assume_init` before full init. Reading uninit bytes is UB even for `u8`. Use `MaybeUninit`, `Vec::spare_capacity_mut`, `ptr::write` (not `*ptr = v`, which drops the uninit "old value"). 3. **Transmute abuse**: size/alignment mismatch, invalid bit patterns (`bool` not 0/1, invalid enum discriminant, null fn pointer, uninhabited types), transmuting `&T` lifetimes, transmuting between repr(Rust) types whose layout is unspecified. Prefer `bytemuck::{cast, Pod}` / `zerocopy` (derive-checked), `f32::from_bits`, `ptr::cast`. Transmuting to extend a lifetime is a soundness hole, full stop. 4. **Invalid values & ranges**: producing a `str` with invalid UTF-8 via `from_utf8_unchecked` on unvalidated input; out-of-range `char`; `NonZero*`/`NonNull` holding zero/null. 5. **FFI lifetimes & ownership**: returning a pointer into a Rust object the C side outlives; freeing with the wrong allocator (must round-trip `Box::into_raw`/`Box::from_raw`, or expose `mylib_free`); double-free when C calls a destructor twice; `CString::new(s).unwrap().as_ptr()` — temporary dropped at end of statement, dangling pointer (`temporary_cstring_as_ptr` lint). Struct layout across FFI requires `#[repr(C)]`. 6. **Unwinding across FFI** — see rules/02 §5; UB pre-"C-unwind", abort after. 7. **Data races**: `unsafe impl Send/Sync` on types containing raw pointers or `Cell`-like internals without an argument; `static mut` (deprecated pattern; edition 2024 denies `static_mut_refs`) — use `AtomicX`, `OnceLock`, `Mutex`, or `SyncUnsafeCell` with justification. ## 3a. Layout, provenance, and Pin — the subtler contracts **Layout:** `repr(Rust)` layout is unspecified and may differ between compilations — any unsafe code assuming field order/offsets needs `#[repr(C)]` (FFI, byte-casting) or `#[repr(transparent)]` (newtype with identical ABI to its single field — required for soundly casting `&Wrapper<T>` ↔ `&T`). Enum-discriminant tricks need explicit `#[repr(u8)]`-style declarations. `bytemuck::Pod`/`zerocopy::FromBytes` derives verify these statically — prefer them over manual offset math; for unavoidable offsets use `core::mem::offset_of!` (stable), never hand-computed constants. **Pointer provenance:** a pointer is more than an address. Casting ptr→int→ptr strips provenance and is UB-adjacent under strict provenance; round-trip with `ptr.with_addr(...)`/`ptr.map_addr(...)` (strict provenance APIs, stable) or keep it as a pointer. Pointers derived from a `&T` may only access that `T`'s bytes for that borrow's lifetime — offsetting into a sibling field via a field reference is UB even if the address is "right". Run Miri with `-Zmiri-strict-provenance` to catch the class. **Pin:** `Pin<&mut T>` promises T won't move again until drop. Unsafe code relying on pinning must uphold the drop guarantee (pinned memory must be dropped before reuse, can't be deallocated without drop) and never hand out `&mut T` from `Pin<&mut T>` for `!Unpin` types except via `map_unchecked_mut` with a SAFETY argument that the projection is structural. Hand-rolled self-referential types: use `pin-project` (safe projections, checks the rules) instead of manual `unsafe` projections — hand-rolled pin projections are a recurring soundness-bug source even in expert crates. **Drop interaction:** `ManuallyDrop` + `ptr::read` patterns (taking ownership out of `&mut self` in `Drop`) must guarantee no double-drop on every path including panics; `mem::forget` is safe but leaks — unsafe code may NOT rely on Drop running for soundness (leakpocalypse rule: `Rc` cycles + `mem::forget` make "Drop always runs" a false invariant). ## 4. Miri, sanitizers, fuzzing — CI for the unchecked Any crate with non-trivial `unsafe` runs **Miri in CI**: ```yaml # .github/workflows/miri.yml (core job) - run: rustup toolchain install nightly --component miri - run: cargo +nightly miri test env: # many-seeds for nondeterminism; strict provenance catches ptr-int abuse MIRIFLAGS: "-Zmiri-strict-provenance" ``` - Miri checks the (Tree Borrows / Stacked Borrows) aliasing model, init, alignment, leaks — but **only on executed paths**: unsafe code without tests is unaudited code. Write tests that exercise every unsafe branch. - Miri can't run FFI/syscall-heavy paths; for those use sanitizers: `RUSTFLAGS="-Zsanitizer=address" cargo +nightly test` (ASan), TSan for concurrency claims, and `loom` for testing lock-free/atomic algorithms exhaustively. - Parsers and any unsafe-touching decoder: fuzz with `cargo fuzz` (libFuzzer) — fuzzing + Miri/ASan is the practical soundness net (see rules/05 §6). ## 5. Supply-chain visibility of unsafe - `cargo geiger` reports unsafe usage across the dependency tree — use it to *direct review attention*, not as a verdict (unsafe ≠ unsound; zero-unsafe ≠ sound). Heavy-unsafe deps doing things std could do = replace. - Prefer audited foundations: `bytemuck`/`zerocopy` over hand transmutes, well-known FFI `-sys` crates over bespoke bindings. - `#![forbid(unsafe_code)]` in crates that need none — it's a semver-visible promise and makes regressions un-mergeable. Workspace-wide: `[lints.rust] unsafe_code = "forbid"` with per-crate opt-out. - Record unsafe review in `cargo vet` audits (criteria `safe-to-deploy` + unsafe review) — rules/05 §2. ## 6. Soundness review protocol (for AUDIT mode) For each `unsafe` block, in order: 1. Identify the exact unsafe operations (deref, call, transmute, impl). 2. List each documented precondition of those operations. 3. Check the SAFETY comment discharges **all** of them (missing comment = automatic finding; wrong comment = worse). 4. Hunt invariant escapes: can safe code (pub fields, safe methods, trait impls, `Deref`, `Drop`, panics mid-modification, reentrancy via callbacks) break the invariant the unsafe block assumes? Panic-safety: if user code (closures, `T: Clone`, comparators) can panic while your invariant is temporarily broken, Drop/unwinding observes broken state → need guard objects or `catch_unwind` reasoning. 5. Check `Send`/`Sync`: any manual `unsafe impl` needs a written argument per field; raw pointers suppress auto-derive for a reason. 6. Confirm Miri runs over this code path in CI; if not, that's a finding regardless of how correct the code looks. ## Audit checklist - [ ] `rg 'unsafe' -t rust --count-matches` — map the surface first; unsafe outside dedicated modules/crates is a structure finding. - [ ] Undocumented blocks: `rg -B2 'unsafe \{' -t rust | rg -v 'SAFETY'` (then verify by eye); enforce `clippy::undocumented_unsafe_blocks`. - [ ] `rg 'transmute' -t rust` — each one: why not `bytemuck`/`zerocopy`/ `from_bits`/`cast`? Lifetime-extending transmute = Critical. - [ ] `rg 'from_utf8_unchecked|get_unchecked|assume_init|set_len' -t rust` — verify the stated invariant actually holds on all paths incl. panics. - [ ] `rg 'static mut|&mut \*\(|as \*mut' -t rust`; compiler lints `static_mut_refs`, `invalid_reference_casting` must be deny. - [ ] `rg 'unsafe impl (Send|Sync)' -t rust` — require per-field justification comment; absence = High. - [ ] FFI: `rg 'extern "C"' -t rust` — check `#[repr(C)]` on crossing types, panic containment, allocator pairing (`into_raw`/`from_raw` symmetry), `as_ptr()` on temporaries. - [ ] Layout assumptions: `rg 'repr\(' -t rust` — byte-casting/FFI types have `repr(C)`/`repr(transparent)`; `rg 'as usize as \*|usize as \*' -t rust` — int→ptr casts (provenance loss). - [ ] `rg 'map_unchecked_mut|Pin::new_unchecked|get_unchecked_mut' -t rust` — hand-rolled pin projections; prefer `pin-project`. Drop impls using `ptr::read`/`ManuallyDrop` checked for panic-path double-drop. - [ ] CI: Miri job exists and isn't `continue-on-error: true`; fuzz targets exist for unsafe parsers; `cargo geiger` output reviewed for the tree. - [ ] Crates with zero unsafe missing `#![forbid(unsafe_code)]` — Low, but free hardening. - [ ] Severity calibration: reachable UB = Critical; unsound public API (safe code can trigger UB) = Critical even if unexercised; missing SAFETY comment = Medium; missing Miri CI on unsafe crate = Medium. -
04-async-tokio.md 14.6 KB
# 04 — Async Rust & Tokio Async Rust's failure modes are quiet: a blocked worker thread, a future dropped mid-write, a lock held across `.await`. These rules cover tokio idioms, cancellation safety, structured concurrency, channels, and shutdown. ## 1. Never block the runtime A tokio worker thread running blocking code stalls **every task scheduled on it**. Blocking = sync I/O, heavy CPU (>~100µs per poll), `std::thread::sleep`, sync DB drivers, `reqwest::blocking`, sync `zip`/compression, big serde on huge payloads, `std::sync::Mutex` under contention. ```rust // BAD: stalls the worker thread async fn load(path: PathBuf) -> Result<Config> { let raw = std::fs::read_to_string(&path)?; // sync I/O in async let parsed = heavy_parse(&raw); // 50ms CPU in async Ok(parsed) } // GOOD async fn load(path: PathBuf) -> Result<Config> { let raw = tokio::fs::read_to_string(&path).await?; // async I/O let parsed = tokio::task::spawn_blocking(move || heavy_parse(&raw)) .await?; // CPU off-runtime Ok(parsed) } ``` - `spawn` for async work; `spawn_blocking` for blocking-but-bounded work (file ops via std, sync clients, password hashing); a dedicated **rayon pool or separate runtime** for sustained CPU-parallel work — `spawn_blocking`'s pool (default cap 512 threads) is sized for blocking I/O, not compute. - `tokio::time::sleep`, never `std::thread::sleep`, in async fns. - Detection: `tokio-console` (task poll times), `RUSTFLAGS` + `tokio_unstable` task dumps; in review, grep for sync APIs inside `async fn` (checklist below). A p99 latency cliff under load with idle CPU is the classic blocked-worker signature. - `block_on` inside an async context panics or deadlocks (`Handle::block_on` on the current runtime, nested `Runtime::block_on`). Bridging sync→async from a blocking thread: `Handle::current().block_on` from `spawn_blocking` is legal; document why. ## 2. Decoding Send/Sync bounds errors "future cannot be sent between threads safely" means a non-`Send` value is **held across an `.await`** in a future passed to `tokio::spawn` (multi-threaded runtime requires `Send + 'static`). Fix in priority order: 1. **Shrink the hold**: drop/scope the non-Send value before the await. ```rust // BAD: MutexGuard (often non-Send) and Rc held across await let guard = state.lock().unwrap(); let data = guard.compute(); remote.push(data).await?; // guard still alive here // GOOD: scope ends before await let data = { state.lock().unwrap().compute() }; remote.push(data).await?; ``` 2. Replace the type: `Rc`→`Arc`, `RefCell`→`Mutex`/atomics, thread-local / `dyn Trait` without `+ Send` → bounded version. 3. For genuinely thread-bound libs (e.g. some FFI/GUI handles), as a last resort: `tokio::runtime::LocalRuntime` (stabilized in tokio 1.51, 2026 — a whole runtime whose tasks may be `!Send`) or the older `tokio::task::LocalSet` + `spawn_local`. - The compiler note "this value is used across an await" points at the exact hold — read it before refactoring. - `'static` errors on `spawn`: the future can't borrow from the caller. Move owned/`Arc` data in (`async move`), or restructure so the parent awaits the child directly (no spawn = borrows fine), or use `JoinSet`/scoped patterns. - Library code: add `Send` bounds tests (`fn assert_send<T: Send>(t: T)`) so you don't break downstream spawnability silently. ## 3. Cancellation safety **Every `.await` is a possible end of your function.** Futures are cancelled by being dropped — by `select!`, timeouts, dropped `JoinHandle`s being aborted, or a client disconnect dropping the request future (hyper/axum do this). - `select!` pitfall: the non-winning branches' futures are **dropped** each iteration. Recreating a future in a loop loses partial progress: ```rust // BAD: on every tick, read_line future is dropped — buffered partial line lost loop { tokio::select! { line = read_line(&mut reader) => handle(line?), _ = interval.tick() => flush().await, } } // GOOD: keep the future alive across iterations (pin it once) let read_fut = read_line(&mut reader); tokio::pin!(read_fut); loop { tokio::select! { line = &mut read_fut => { handle(line?); read_fut.set(read_line(&mut reader)); } _ = interval.tick() => flush().await, } } ``` - **Know your cancel-safe primitives — copy the list, do not reason about it.** Tokio classifies each operation itself, and two of the ones people most often assume are safe are *not*. Transcribed from the `select!` docs ([cancellation safety](https://docs.rs/tokio/latest/tokio/macro.select.html#cancellation-safety), read 2026-09-16; it is a per-version list, so re-read it for your pinned tokio): | | operation | |---|---| | **cancel-safe** | `mpsc::Receiver::recv`, `mpsc::UnboundedReceiver::recv`, `broadcast::Receiver::recv`, **`watch::Receiver::changed`**, `TcpListener::accept`, `UnixListener::accept`, `signal::unix::Signal::recv`, `AsyncReadExt::read` / `read_buf`, `AsyncWriteExt::write` / `write_buf`, `StreamExt::next` (tokio-stream or futures) | | **NOT safe — partial I/O, data is lost** | `AsyncReadExt::read_exact`, `read_to_end`, `read_to_string`, `AsyncWriteExt::write_all` | | **NOT safe — you lose your place in a fairness queue** | **`Mutex::lock`**, `RwLock::read`, `RwLock::write`, `Semaphore::acquire`, **`Notify::notified`** | **The two unsafe rows fail differently and the difference decides the fix.** The first row loses *bytes*: half a frame is gone and the stream is desynchronised, so the repair is to keep the future alive across iterations (`tokio::pin!`, above) or to read into a buffer you own. The second row loses *progress*: nothing is corrupted and no memory is unsound — the docs' wording is that these *"use a queue for fairness and cancellation makes you lose your place in the queue"* — so the symptom is starvation of a task that keeps getting cancelled and re-queued, not a torn value. Do not report the second row as data loss. On `watch`: the cancel-safe method is **`changed()`**, not `recv()` — a detail worth stating because the sibling channels *do* use `recv()`. - State mutations spanning an await are torn by cancellation. Either make the critical section await-free, or use a **drop guard** to restore/complete invariants: ```rust struct InFlightGuard<'a>(&'a Counter); impl Drop for InFlightGuard<'_> { fn drop(&mut self) { self.0.dec(); } } // guard decrements even if the request future is dropped mid-await ``` - Cooperative cancellation: `CancellationToken` (tokio-util) + `token.cancelled()` in `select!`, or `JoinHandle::abort()` (abort only stops at await points; CPU loops need explicit checks). - Spawned tasks are **not** cancelled when their `JoinHandle` drops — they leak unless tracked (see §4) or aborted. Dropping a `JoinSet` *does* abort its tasks. **Cancelling a `timeout` around a child process does not kill the process.** The future is dropped, the OS process is not — rules/05 §9 (R9.5) has the measured behaviour and `.kill_on_drop(true)`. A spawned process is owned state exactly like a spawned task, and cancellation is where that ownership is usually dropped. ## 4. Structured concurrency Unsupervised `tokio::spawn` is a goto: errors vanish, panics vanish, shutdown can't find it. - **`JoinSet`** for dynamic groups of homogeneous tasks: collects results, propagates panics as `JoinError`, aborts all on drop. ```rust let mut set = tokio::task::JoinSet::new(); for url in urls { set.spawn(fetch(url)); } while let Some(res) = set.join_next().await { let body = res??; // JoinError (panic/abort) then app error process(body); } ``` - **`TaskTracker` + `CancellationToken`** (tokio-util) for service-lifetime tasks: `tracker.spawn(...)`, then `tracker.close(); tracker.wait().await` on shutdown. - Concurrency without spawning (no `'static` needed, same task): `join!`/`try_join!` for fixed sets; `futures::stream::iter(items).map(work).buffer_unordered(N)` for bounded fan-out — **always bound N**; unbounded fan-out over request-derived collections is a self-DoS. - Every `tokio::spawn` must have an owner that observes its `JoinHandle` (or a comment justifying fire-and-forget + its own error logging). Panics in spawned tasks are silent until joined. ## 5. Locks across `.await` - `std::sync::MutexGuard` is non-Send (compile error on spawn) — but on single-future paths it *can* compile and then **deadlock**: task A holds the lock, awaits; task B on the same thread polls and blocks on the lock. - Decision rule: **short, await-free critical sections → `std::sync::Mutex` (or `parking_lot`)**, scoped to drop before any await. Need to hold a lock across an await (e.g. exclusive access to a connection through a protocol exchange) → `tokio::sync::Mutex` — accept that it's slower and serializes tasks. - Often the real fix is neither: move owned state into a dedicated task and communicate via channels (actor pattern), or use `RwLock`/`arc-swap` for read-mostly config. - Clippy: `await_holding_lock`, `await_holding_refcell_ref` — deny in CI. ## 6. Channel selection | Channel | Shape | Use | |---|---|---| | `mpsc` | many→one, bounded | work queues, actor inboxes — **default choice** | | `oneshot` | one value | request/response, completion signal | | `broadcast` | many→many, each gets all | events, pub/sub; lagging receivers get `RecvError::Lagged` — handle it | | `watch` | latest-value only | config updates, status, shutdown flag | | `mpsc::unbounded` | many→one, unbounded | almost never — unbounded = memory DoS under backpressure | - **Bounded `mpsc` everywhere by default**; choose capacity deliberately — `send().await` backpressure is the feature. `try_send` + explicit drop/shed policy on latency-critical producers. - Request/response over an actor: send `(payload, oneshot::Sender<Reply>)`. - `watch` for shutdown signals predates `CancellationToken`; prefer the token in new code. - Crossing sync→async: tokio `mpsc::Sender::blocking_send` from sync threads; never `block_on(tx.send(...))` inside the runtime. ## 7. Async traits & API design - Native `async fn` in traits (stable 1.75): fine for internal/sealed traits; **not dyn-compatible** and leaves `Send` of the returned future unnameable for generic callers. Public traits used as `dyn` or spawned generically: use `#[async_trait]` (boxes, adds `Send` bound by default) or return `impl Future + Send` explicitly / `BoxFuture`. The 2026-era alternative: `trait-variant` to generate `Send` variants. - Don't make functions `async` that never await — sync fn returning a value is simpler and callable anywhere (`clippy::unused_async`). - Don't expose tokio types in library public APIs unless the crate is tokio-specific by design; abstract over `AsyncRead`/`AsyncWrite` (tokio or futures versions) where feasible. - Tokio remains 1.x (1.52 as of mid-2026; no 2.0) and designates LTS minors with ≥1 year of backported fixes (1.47.x until Sep 2026, 1.51.x until Mar 2027). Stability-critical services can pin an LTS line with tilde syntax: `tokio = { version = "~1.51", features = [...] }`. ## 8. Graceful shutdown The canonical service shape: ```rust let token = CancellationToken::new(); let tracker = TaskTracker::new(); // signal handling let t = token.clone(); tokio::spawn(async move { tokio::signal::ctrl_c().await.expect("ctrl_c handler installed"); t.cancel(); }); // accept loop loop { tokio::select! { _ = token.cancelled() => break, conn = listener.accept() => { let (stream, _) = conn?; tracker.spawn(handle(stream, token.clone())); } } } // drain: stop accepting, let in-flight finish (with deadline), then exit tracker.close(); tokio::select! { _ = tracker.wait() => {} _ = tokio::time::sleep(DRAIN_TIMEOUT) => warn!("shutdown deadline hit"), } ``` - Order: stop intake → signal cancellation → drain with deadline → flush (logs, metrics, WAL) → exit. Dropping the `Runtime` mid-flight cancels everything abruptly — drain first. - Handlers must observe the token at long awaits (`select!` with `token.cancelled()`) or be cancel-safe end-to-end. - Test shutdown: a service that can't exit cleanly under load hides task leaks. ## Audit checklist - [ ] Blocking in async: `rg -t rust 'async fn' -A30 | rg 'std::fs::|std::thread::sleep|reqwest::blocking|\.lock\(\)\s*$'` — more reliably, grep each: `rg 'thread::sleep|std::fs::(read|write|File)|blocking::' -t rust` and check enclosing fn for `async`. High severity in request paths. - [ ] `rg 'block_on' -t rust` — any call reachable from async context = Critical (deadlock/panic). - [ ] `rg 'unbounded_channel|UnboundedSender' -t rust` — each needs a written backpressure argument; attacker-fed unbounded channel = High (DoS). - [ ] `rg 'tokio::spawn' -t rust` — orphaned handles (result never joined, no JoinSet/TaskTracker, no error logging in task) = Medium; panic observability gap. - [ ] `select!` loops: any branch future recreated per-iteration that buffers internally (reads, `write_all`, custom combinators) → cancellation data loss = High. Check each `select!` arm against the cancel-safety table above — and **rate the two unsafe rows differently**: partial-I/O (`read_exact`, `write_all`) loses bytes and desynchronises a stream = High; a fairness-queue operation (`Mutex::lock`, `Semaphore::acquire`, `Notify::notified`) loses only its place in the queue = starvation risk, **not** data loss, so reporting it as corruption is a false finding. `Notified` and `Mutex::lock` are the two most often assumed safe; both are on Tokio's unsafe list. - [ ] Locks: clippy `await_holding_lock`, `await_holding_refcell_ref`; `rg 'tokio::sync::Mutex' -t rust` — verify each actually needs hold-across-await, else downgrade to std/parking_lot. - [ ] `rg '\.abort\(\)' -t rust` — aborted tasks: is every shared invariant abort-safe (drop guards present)? - [ ] Fan-out: `rg 'buffer_unordered|buffered\(' -t rust` — bound derived from config, not unbounded or request-controlled; loops spawning per item of untrusted-size collections. - [ ] Shutdown path exists: signal handler, drain deadline, `tracker.close()` before `wait()` (close-after-wait hangs forever). - [ ] `rg 'async fn' -t rust` + `clippy::unused_async`; public async traits: dyn-compat and Send bounds checked for downstream spawnability. - [ ] CI lints: `clippy::await_holding_lock`, `clippy::unused_async`, `clippy::large_futures` (oversized futures → stack/box them). -
05-security-supply-chain.md 21.2 KB
# 05 — Security & Supply Chain Memory safety is the floor, not the ceiling. Rust services still fall to logic bugs, integer wrapping, panic-DoS, malicious dependencies, secret leakage, and hostile input. These rules are written for network-facing code; relax consciously for offline tools. ## 1. Dependency auditing in CI — cargo audit & cargo deny Non-negotiable for anything deployed: advisory + policy checks on every PR and on a schedule (new advisories land against old lockfiles). ```toml # deny.toml (core) [advisories] yanked = "deny" # RUSTSEC advisories: deny by default; ignore list requires expiry + reason ignore = [ # { id = "RUSTSEC-2026-0001", reason = "not reachable: feature off", expire = "2026-09-01" } ] [licenses] allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "Unicode-3.0"] [bans] multiple-versions = "warn" wildcards = "deny" # no `foo = "*"` [[bans.deny]] name = "openssl" # example policy: rustls-only stack [sources] unknown-registry = "deny" unknown-git = "deny" # git deps pinned by rev only, allowlisted ``` - `cargo deny check` (advisories, licenses, bans, sources) in PR CI; `cargo audit` nightly via cron so existing `Cargo.lock` gets re-checked. - Commit `Cargo.lock` for binaries **and** (current guidance) for libraries — reproducible CI; `cargo update` is a reviewed PR, not a side effect. - Dependabot/Renovate for bumps; review changelogs of security-sensitive deps (crypto, TLS, parsing) instead of auto-merging. - Minimize the tree first: every dep is attack surface and build time. `cargo tree -d` for duplicates; question deps that pull in 50 transitive crates for one function. ## 2. Vetting dependencies — cargo vet / supply-chain hygiene - `cargo vet`: record audits (`safe-to-deploy`) for each dep version; import trusted audit sets (Mozilla, Google, Bytecode Alliance) so you only audit the residue. `cargo vet` in CI fails on unaudited new deps — turns "someone added a crate" into a reviewed event. - New-dep review minimum: maintenance signal, repo matches crates.io package (`cargo crev`/inspect tarball — typosquats and repo/package divergence are the common attacks), `cargo geiger` unsafe density, build.rs and proc-macros (these run code **at build time** — highest-trust tier). Check the crate's Security tab on crates.io (since Jan 2026 it surfaces RustSec advisories, CVE aliases, and affected ranges at the point of discovery). - This is not theoretical: Feb–Mar 2026 saw a coordinated campaign of five fake "time utility" crates (`time-sync`, `dnp3times`, `chrono_anchor`, … — RUSTSEC-2026-0030/0031/0032/0036) that typosquatted/brandjacked real crates and exfiltrated `.env` files from developer and CI machines. Mitigations are exactly the above plus secret hygiene: no long-lived credentials in `.env` on build machines; rotate anything exposed to an unvetted build. - Pin GitHub Actions by SHA, not tag; CI tokens least-privilege (`permissions: contents: read` default). - **Declared but not reached**: an unused crate is still install/build-time trust granted (`build.rs`, proc-macros) for zero function. `cargo machete` runs on stable but is deliberately imprecise — false positives for crates used only from `build.rs`-generated code and for import names that differ from package names (`--with-metadata` fixes the latter); `cargo +nightly udeps` needs nightly and documents false *negatives*. Neither settles it alone: prove a candidate by removing it in a scratch copy and running the real build, clippy, and full suite — `sota-devsecops` rules/10. ## 3. Integer overflow — release mode wraps Debug builds panic on overflow; **release builds wrap silently** (unless `overflow-checks = true`). Wrapping on attacker-influenced arithmetic is how length checks, allocations, and billing go wrong. ```rust // BAD: attacker sends len = u32::MAX; len + 4 wraps to 3, check passes if header.len + 4 <= buf.len() as u32 { read(&buf[..header.len as usize]) } // GOOD: checked arithmetic on untrusted input, fail closed let total = header.len.checked_add(4).ok_or(Error::BadLength)?; if (total as usize) <= buf.len() { ... } ``` - Policy: **untrusted input → `checked_*` / `try_into`**; counters/metrics → `saturating_*`; intentional modular arithmetic (hashes, crypto, ring buffers) → `wrapping_*` / `Wrapping<T>` so intent is greppable. Bare `+ - *` is for values you've already bounded. - Set `overflow-checks = true` in the **release profile** for security-critical services — the cost is usually <2% and it converts silent corruption into a caught panic. (Then see §4: that panic must be contained.) - `as` casts truncate silently (`u64 as u32`, `i64 as usize`): use `try_from` on untrusted values; clippy `cast_possible_truncation`, `cast_sign_loss` (pedantic) on parser/protocol crates. ## 4. Panics as DoS Any attacker-reachable panic is a denial-of-service primitive — one request kills a worker or (with `panic=abort`) the whole process. - Hunt the implicit panic surface in request paths: `unwrap`/`expect` (rules/02), slice indexing, `&s[a..b]` on non-char boundaries, integer division by zero, `with_capacity(attacker_len)` (capacity overflow / OOM-abort), recursion depth on nested input (stack overflow — **abort, not catchable**; see §6 on serde recursion). - Containment at the boundary: per-connection/per-request `tokio::spawn` isolates unwinding panics (`JoinError::is_panic`); `CatchPanicLayer` for tower stacks. With `panic = "abort"`, containment is gone — pair abort with a supervisor (systemd `Restart=always`, k8s) and treat reachable panics as Critical, plus rate-limit restarts to blunt crash-loop DoS. - Resource-exhaustion siblings of panic-DoS: unbounded channels (rules/04), missing request body limits, decompression bombs (cap decompressed size), unbounded `read_to_end` on sockets — set explicit limits at every ingest. ## 5. Secrets in memory — zeroize & constant time - Wrap key material in `zeroize`/`secrecy`: ```rust use secrecy::{SecretString, ExposeSecret}; struct DbConfig { url: String, password: SecretString } // Debug prints REDACTED; memory zeroized on drop; .expose_secret() is greppable ``` Zeroize is best-effort (moves/reallocations copy bytes — avoid resizing buffers holding secrets; `Box::pin` long-lived keys), but it shrinks the window and kills the "secret in a core dump / Debug log" class. - **Don't rely *only* on `Drop` for security erasure.** `Drop` is skipped entirely by `mem::forget`, `Box::leak`, reference cycles (`Rc`/`Arc`), a panic mid-drop, and `panic = "abort"` / process exit — so Drop-based zeroization is a window-shrinker, not a guarantee. Don't structure a security argument ("the key is erased after use") on the destructor running; minimize the secret's lifetime, avoid leaking/forgetting secret-bearing values, and keep Drop impls panic-free (rules/02 §5) so the erasure path isn't skipped by an abort. (ANSSI `LANG-DROP-SEC`; soundness corollary in rules/03.) - **No `Debug`/`Display`/`Serialize` leaking secrets**: manual `Debug` impls redacting sensitive fields; never `#[derive(Debug)]` on a struct holding a raw key. Audit `tracing` events for token/password fields. - Comparisons of MACs/tokens/password hashes: constant-time only — `subtle::ConstantTimeEq` (`a.ct_eq(&b)`), or the comparison built into the crypto crate (e.g. `hmac`'s `verify_slice`). `==` on secret bytes is a timing oracle. - Don't hand-roll crypto: RustCrypto crates, `ring`, `aws-lc-rs`, or libsodium bindings; password hashing via `argon2`; randomness via `rand::rngs::OsRng` / `getrandom` only (never `SmallRng`/`thread_rng` for key material — thread_rng is a CSPRNG but OsRng removes the argument). - Env/config: secrets via files or secret managers over env vars where possible (`/proc/<pid>/environ` leaks); never in `Cargo.toml`, never compiled into the binary (`strings target/release/app | rg -i secret`). ## 6. Parsing untrusted input — serde hardening Deserialization is the front door. Rules for any `serde` boundary fed by the network: - **Size-limit before parse**: enforce body/frame limits at the transport (axum `DefaultBodyLimit`, manual `Content-Length` + streaming cap) — parsing a 2GB JSON body allocates before serde can object. - **`deny_unknown_fields`** on security-relevant configs and requests (prevents smuggling fields through proxies/validators that the backend interprets) — but note it breaks `#[serde(flatten)]` and forward-compat; choose per-type. - **Untagged enum DoS**: `#[serde(untagged)]` tries each variant in order — on deep/nested input this multiplies parse work and produces useless errors; worst case is exponential blowup with nested untagged enums. Prefer tagged (`#[serde(tag = "type")]`) or manual discriminator dispatch on hostile input. Adjacent risk: recursion depth — `serde_json` has a default 128-level limit, but `serde_yaml`-style formats and custom `Deserialize` impls may not; use `serde_stacker`/explicit depth caps for deeply-nested formats (stack overflow = abort = DoS). - Validate after parse: serde checks shape, not semantics. Lengths, ranges, string charsets via `TryFrom` newtypes (rules/01 §3) or `validator`/`garde` — the deserialized type should already be the validated type ("parse, don't validate"). - `Vec` preallocation from attacker-controlled length prefixes (`Vec::with_capacity(hdr.count)`): cap or `try_reserve`. Binary formats (`bincode` etc.): configure size limits explicitly. - Don't deserialize to `Box<dyn Trait>`/arbitrary types via `typetag`-style registries from untrusted sources without an allowlist. - Fuzz every parser of untrusted bytes: `cargo fuzz` target per format, in scheduled CI. Combine with rules/03 sanitizers when the parser has unsafe. ## 7. Service-edge defaults - TLS: `rustls` stack by default (memory-safe, modern defaults). - Timeouts on **everything**: connect, read, write, total-request, idle (`TimeoutLayer`, `tower` middleware). Missing timeouts = slowloris. - Error responses: generic client text, full chain only into logs (rules/02 §9); no `Debug`-formatted internals in HTTP bodies. - Path handling on user input: reject `..` traversal — canonicalize then verify prefix (`path.canonicalize()?.starts_with(root)`), never just join. - SQL via parameterized queries (`sqlx` compile-checked, `diesel`); any `format!` into a query string is a finding regardless of current inputs. ## 8. Release provenance & logging hygiene - **`cargo auditable`**: embeds the dependency list in the binary so deployed artifacts can be scanned against future advisories (`cargo audit bin app`). Pair with SBOM generation (`cargo cyclonedx`/`cargo sbom`) where compliance requires it. Reproducible-ish builds: pinned toolchain + locked deps + `--locked` in release CI (`cargo build --release --locked` — fails instead of silently updating the lockfile). - Release binaries built in CI from tags, not laptops; artifacts checksummed and (where distribution warrants) signed; `--locked` and the pinned toolchain make the build attributable to the lockfile that was audited. - **Log injection**: user-controlled strings logged raw can forge log lines (embedded `\n`) or poison downstream parsers — log via `tracing` structured fields (`tracing::info!(user = %name)` escapes on JSON output) rather than interpolating into the message; never log full request bodies or headers carrying credentials (`Authorization`, `Cookie`) — redact at the middleware layer once. - Don't log at error level on client mistakes (4xx) — that's an alert-fatigue vector that buries real errors; reserve `error!` for operator-actionable events. ## 9. Running external programs — `std::process::Command` `Command` takes a program plus an argument vector and **there is no shell**: the docs are explicit that "shell syntax like quotes, escaped characters, word splitting, glob patterns, variable substitution, etc. have no effect". That makes the classic injection hard to write by accident and moves the risk somewhere else. Behaviour below was **measured on rustc 1.97.1 / tokio 1.53, macOS** unless a doc is quoted; re-check the platform-specific items on your target. **R9.1 — Arguments are never split, by either API.** `.arg("-l -a")` passes the single argument `-l -a` (measured), and `.args([..])` merely iterates — neither splits on whitespace. The Rust mistake is therefore the *inverse* of the shell one: a string assembled as though it were a command line arrives as one nonsense argument, and `Command::new("ls -l")` looks for a program literally named `ls -l`. That failure is loud and harmless. The dangerous spelling is the one that puts a shell back: ```rust // BAD — a shell, and therefore injection, is back Command::new("sh").arg("-c").arg(format!("convert {path} out.png")) // GOOD — absolute program, argv, end-of-options, no shell Command::new("/usr/bin/convert").arg("--").arg(&path).arg("out.png") ``` **R9.2 — Argument injection survives argv.** A value beginning with `-` becomes a flag. Pin your own flags first, then `--`, then validated operands; prefer a library binding to a CLI wrapper for attacker-influenced parameters. `sota-sandboxing` rules/04 §5 lists the exec-capable argument gadgets (`find -exec`, `tar --checkpoint-action`, `ssh -o ProxyCommand`, …) to check for. **R9.3 — Windows `.bat`/`.cmd` is a documented exception (CVE-2024-24576).** `cmd.exe` and batch files decode their command line non-standardly, so argv-safety does not hold there: before **Rust 1.77.2**, passing untrusted arguments to a batch file could run arbitrary shell commands. The fix did not make escaping safe — the standard library now **returns an `InvalidInput` error when it cannot safely escape an argument**, and the current `Command` docs still carry the warning that for `cmd.exe` "a malicious argument can potentially run arbitrary shell commands". So: MSRV **≥ 1.77.2** for anything that may run on Windows, propagate that `InvalidInput` rather than unwrapping it, and treat `CommandExt::raw_arg` as trusted-input-only. **R9.4 — Dropping a `Child` neither kills nor reaps it.** "There is no implementation of `Drop` for child processes, so if you do not ensure the `Child` has exited then it will continue to run, even after the `Child` handle to the child process has gone out of scope" — measured: still alive 600 ms after the handle was dropped. The same docs warn that a terminated-but-unwaited process "is still around as a *zombie*" and that too many "may exhaust global resources (for example process IDs)". So an error path that returns early while holding a `Child` leaks a *running process*, not just a handle — and `?` makes that the easy path to write. Own the child on every exit route, including the error and cancellation ones. **R9.5 — `std` has no timeout, and a tokio timeout does not kill the child.** Neither `wait()` nor `wait_with_output()` takes a deadline, and `wait_with_output()` waits for **EOF on the pipes**, which a grandchild that inherited them can hold open indefinitely. Measured, and this is where Rust differs from Go (whose `Wait` blocks past context cancellation unless `cmd.WaitDelay` is set): `tokio::time::timeout(2s, child.wait_with_output())` **does** fire at 2.0 s under exactly that pipe-holding grandchild, so Rust needs no `WaitDelay` equivalent. But firing only cancels *your future* — the child and its grandchild keep running. Pair the deadline with `.kill_on_drop(true)` (measured: the child is gone 600 ms after the handle drops) or kill and `wait()` explicitly. In sync code the long-standing option is the `wait-timeout` crate — mature rather than active (last release 2025-02, still 0.2.x, ~50M recent downloads), so check its upstream health before adopting it (`sota-devsecops` rules/10 §5); otherwise use a supervisor thread you actually join. Never abandon a thread parked in `wait()`. Killing the direct child does not signal its *group*: for that, spawn it into its own group with `process_group(0)` (Unix, stable since **1.64**) and signal the group. **R9.6 — Output is buffered without a cap.** `output()`/`wait_with_output()` collect the child's stdout into a `Vec<u8>` with no limit — measured, 5 MB of `/dev/zero` buffered without complaint, and a hostile child can make that unbounded. For anything attacker-influenced, take `Stdio::piped()` and read with an explicit `.take(MAX)`, or send output to a file or `Stdio::null()`. **R9.7 — Environment and program resolution both have sharp edges.** The child inherits the parent's environment by default. `env_clear()` gives it **zero** variables (measured) — but a *bare* program name still resolves, because with `PATH` removed `execvp` falls back to an OS-defined default (the docs say typically `/bin:/usr/bin`, "not the parent's `PATH`"): measured, bare `uname` still ran while a binary present only in the process's working directory did **not**, so that fallback did not include `.` here. Do not rely on either half — **pass an absolute path**. Relative program paths are worse: the docs call the interpretation relative to the parent's cwd versus `current_dir` "platform specific and unstable" and recommend `canonicalize`; measured on macOS, `Command::new("./p").current_dir(d)` ran `d/p`. Use `uid`/`gid` to drop privilege where relevant (both trigger `setgroups(0, NULL)` unless groups are set explicitly, dropping supplementary groups). `CommandExt::pre_exec` is `unsafe` for a real reason — the closure runs after `fork` in the child, where "normal operations like `malloc`, accessing environment variables through `std::env` or acquiring a mutex are not guaranteed to work"; keep it async-signal-safe or use a purpose-built crate. For the isolation the child itself needs — seccomp/Landlock, fd-only interfaces, memory budgets — see `sota-sandboxing` rules/04 §5 and rules/02 R7.2a. ## Audit checklist - [ ] CI has `cargo deny check` (or `cargo audit`) on PRs **and** a scheduled run; `deny.toml` ignore entries have reasons + expiry. Missing = High for deployed services. - [ ] `Cargo.lock` committed; `rg 'git = "' Cargo.toml */Cargo.toml` — git deps without `rev =` pin = Medium; wildcard versions = Medium. - [ ] `cargo vet` (or documented dep-review process) for new dependencies; build.rs / proc-macro deps enumerated and reviewed. - [ ] Arithmetic on input: `rg '(len|size|count|offset|idx)\s*[+*-]' -t rust` near parsing code — wrapped math on untrusted values = High; `rg 'as u(8|16|32)|as usize' -t rust` in protocol code for truncating casts; release profile `overflow-checks` decision documented. - [ ] Panic surface in handlers: run rules/02 checklist scoped to request-reachable code; `rg 'with_capacity\(' -t rust` where the arg derives from input = High. - [ ] `rg '#\[derive\(.*Debug' -t rust` on structs with `password|secret|key| token` fields; `rg '==' -t rust` comparing MACs/tokens (want `ct_eq`); secrets not in `SecretString`/`Zeroizing` = Medium-High. - [ ] `rg 'thread_rng|SmallRng|StdRng::seed' -t rust` in key/nonce/token generation paths → require OsRng/getrandom. - [ ] `rg 'untagged' -t rust` on network-facing types = review for DoS; `rg 'deny_unknown_fields'` absent on auth/config types = Low-Medium; body-size limits present at every ingest (axum `DefaultBodyLimit`, manual caps) — absent = High. - [ ] `rg 'format!\(.*(SELECT|INSERT|UPDATE|DELETE|WHERE)' -t rust -i` = High; `rg '\.join\(' -t rust` on user-supplied path segments without canonicalize+prefix check = High. - [ ] Fuzz targets exist for each untrusted-input parser; absent on a network-facing parser = Medium. - [ ] Release CI uses `--locked`; binaries built from tags in CI; `cargo auditable` (or SBOM) for deployed artifacts = recommended. - [ ] Logs: `rg 'info!|warn!|error!|debug!' -t rust` near auth/headers — no `Authorization`/`Cookie`/body logging; user strings as structured fields, not message interpolation. - [ ] Subprocess: `rg 'Command::new\("(sh|bash|cmd|powershell)"' -t rust` and `rg '\.arg\("-c"\)' -t rust` — a shell with any interpolated value = Critical. `rg 'Command::new\(' -t rust` for non-absolute program names on attacker-reachable paths = Medium (R9.1, R9.7). - [ ] Every spawned `Child` is killed **and** waited on every exit path, including `?` early-returns and cancellation — `rg 'spawn\(\)' -t rust` and read the error paths; a dropped `Child` keeps running and then becomes a zombie (R9.4). Deployed code targeting Windows declares MSRV **≥ 1.77.2** (R9.3). - [ ] Every subprocess wait has a deadline **and** a kill: a bare `wait()`/`wait_with_output()` = High on any attacker-influenced child, and a `tokio::time::timeout` without `.kill_on_drop(true)` (or an explicit kill) leaves the child running when it fires (R9.5). - [ ] `rg 'output\(\)|wait_with_output' -t rust` where the child's output size is not bounded by the caller = Medium (R9.6); `rg 'pre_exec' -t rust` — the closure must be async-signal-safe (R9.7). - [ ] Severity calibration: RCE/memory corruption = Critical; authn/authz bypass, SQLi, traversal = Critical/High; attacker-reachable panic or unbounded allocation = High; missing CI audit gates = Medium-High; hygiene (locks, lints) = Low-Medium. -
06-performance.md 12.1 KB
# 06 — Performance Rust is fast by default; most "slow Rust" is accidental allocation, debug builds, or architecture — not missing micro-tricks. The discipline: measure, fix the proven bottleneck, re-measure. Never trade soundness or clarity for an unmeasured win (see rules/03 §1 on unsafe "optimizations"). ## 1. Measure first — profiling toolbox - **Always profile release builds** with debug symbols: ```toml [profile.release] debug = true # or: [profile.profiling] inherits = "release", debug = true ``` The #1 perf bug report is "Rust is slow" on a debug build (10–100x off). - **CPU profiles**: `samply record ./target/release/app` (works macOS+Linux, Firefox Profiler UI) or `perf record -g --call-graph dwarf` + `perf report` on Linux; `cargo flamegraph` for one-command flamegraphs. Wide flat flames = death-by-allocation/memcpy; deep narrow = algorithmic hotspot. - **Microbenchmarks**: `criterion` (statistical, regression-detecting) or `divan` (lighter, allocation counting). Use `std::hint::black_box` to stop the optimizer deleting your benchmark. Bench realistic input sizes — O(n²) hides at n=10. - **Allocation profiling**: `dhat-rs` (heap profiling as a test harness), `heaptrack` (Linux); divan's `AllocProfiler` for per-bench alloc counts. - **Async**: tokio-console for task-level stalls (rules/04); `tracing` spans + `tracing-timing`/OTel histograms for production latency attribution. - Lock contention: `perf lock` / mutex wrappers with metrics; high sys-time + low throughput on many cores is the contention signature. ## 2. Allocation reduction — the usual 80% Allocations (and the copies feeding them) dominate most non-numeric Rust hotspots. - **Borrow in signatures** (`&str`, `&[T]` — rules/01 §1) so callers don't allocate to call you. - **Reuse buffers in loops**: ```rust // BAD: fresh String/Vec per iteration for record in records { let mut line = String::new(); render(record, &mut line); out.write_all(line.as_bytes())?; } // GOOD: clear-and-reuse keeps capacity let mut line = String::new(); for record in records { line.clear(); render(record, &mut line); out.write_all(line.as_bytes())?; } ``` - **`with_capacity`** when the size is known (`Vec`, `String`, `HashMap`); growth is amortized but reallocation+memcpy of large buffers still hurts. (Untrusted sizes: cap it — rules/05 §6.) - **`Cow<'_, str>`** for transform-sometimes functions: borrow the common case, allocate only when modified. Don't `Cow` everything — it infects signatures; use where the borrow-rate is meaningfully high. - **`SmallVec`/`ArrayVec`/inline strings (`compact_str`)** only with profile evidence that small-collection allocs dominate; `SmallVec` adds branch + size costs and is a pessimization when it spills or gets moved a lot. - **Avoid intermediate collections**: `collect()` then re-iterate is two passes + an allocation; keep it lazy until the terminal op. `format!` into `write!(buf, ...)`; `Vec<String>` + `join` into a single fold/`itertools::join` when hot; `to_string()` in comparisons (`x.to_string() == y`) never. - String building: `push_str`/`write!` over repeated `+`/`format!`. - Interning/arenas (`bumpalo`, `typed-arena`, `lasso`) for parser/compiler workloads with many small same-lifetime objects — wholesale drop, zero per-object free. ## 3. Accidental clones & copies - Hot-path `.clone()` of `String`/`Vec`/maps found in a profile: restructure ownership (rules/01 §1) — pass borrows down, return owned up, `Arc<str>`/ `Arc<[T]>` for shared immutable data cloned often (refcount bump vs deep copy; also half the size of `Arc<String>`'s double indirection). - Large types moved by value are memcpys: 1KB struct passed through 5 call frames = 5KB of copying. `clippy::large_types_passed_by_value`, `clippy::large_enum_variant` (box the big variant), `clippy::large_stack_arrays`. Async: oversized futures (big locals held across await) — `clippy::large_futures`, box the future or shrink the locals. - Hidden copies: `*slice.to_vec()` where a borrow works, `as_bytes().to_vec()`, `iter().cloned()` where `iter()` + borrows suffice (`clippy::cloned_instead_of_copied` for Copy types — `copied()` is explicit and free). - Derive `Copy` for small (≤ ~16-byte) plain-data types — clone noise gone, and the compiler stops you when it grows? It won't — so re-check `Copy` types' sizes when fields are added (`static_assertions::assert_eq_size!`). ## 4. Iterator fusion & loop shape - Iterator adapter chains compile to single fused loops — no intermediate materialization, bounds checks elided. Trust the chain; verify with a benchmark when in doubt, not by rewriting to indices. - Keep bounds-check elision intact: iterate (`for x in &xs`, `zip`) instead of `xs[i]` under a manually-checked index; when indexing is unavoidable, hoist one `assert!(n <= xs.len())` so LLVM elides the per-element checks. - `extend` over push-in-loop (`vec.extend(iter)` can use size hints and specialized memcpy paths); `collect::<Vec<_>>()` from a sized iterator preallocates exactly. - `chunks_exact`/`array_chunks` over `chunks` in SIMD-able inner loops (the exact variant lets LLVM vectorize without remainder branches). - Data layout beats instruction tweaks: struct-of-arrays for scanned columns, `Vec<T>` over `Vec<Box<T>>` (pointer-chasing kills cache), sort+binary-search or `HashMap` with `FxHash`/`ahash` (default SipHash is DoS-resistant but slow — switch only for non-attacker-controlled keys, see rules/05). - Parallelize embarrassingly-parallel CPU work with `rayon` (`par_iter`) — after confirming single-thread is actually optimized; parallel O(n²) is still O(n²). ## 5. Release profile settings ```toml [profile.release] lto = "thin" # near-fat-LTO wins, fraction of the compile cost; "fat" for final binaries if it measures better codegen-units = 1 # better codegen, slower builds — for release artifacts # opt-level = 3 is default; try "s"/"z" for size-bound targets (embedded/wasm) strip = "symbols" # smaller binaries (keep debug=true in a separate profiling profile) # panic = "abort" # smaller/faster, no unwinding — decide per rules/02 §5 # (kills catch_unwind containment; not for libs that embed elsewhere) ``` - `panic = "abort"`: ~smaller binary, removes landing pads; cost = no panic containment (rules/02/05). Choose deliberately for servers; fine for CLIs. - `overflow-checks = true` in release for security-sensitive services (rules/05 §3) — measure, it's typically <2%. - Target-specific codegen for owned deployments: `RUSTFLAGS="-C target-cpu=native"` (or a fixed `target-cpu=x86-64-v3`) — unlocks AVX2+ vectorization; never for distributed portable binaries. - **PGO** for the last 5–15% on hot services: `cargo pgo` (or manual `-Cprofile-generate` → run representative load → `-Cprofile-use`); BOLT on top for large binaries. Only worth wiring once the easy wins are done. - Build-time hygiene: keep one `profiling` profile; don't ship `debug = true` symbols accidentally in size-sensitive contexts (use `strip` + split-debuginfo). ## 6. Allocators & zero-copy I/O - **Global allocator swap** is the cheapest multithreaded-throughput win in alloc-heavy services: `mimalloc` or `tikv-jemallocator` as `#[global_allocator]` often yields 5–30% on multithreaded alloc-heavy loads vs system malloc (glibc malloc contends; macOS/musl mallocs are slow). Measure with your workload; jemalloc additionally gives heap profiling (`jeprof`) in prod. musl-target deployments almost always want this (musl malloc is a known multithreaded bottleneck). - **`bytes::Bytes`** for network payloads: cheaply cloneable, sliceable, refcounted views — one recv buffer shared across framing/parsing/handlers without copies. Pair with `tokio_util::codec` framing. - **Zero-copy parsing**: borrow from the input (`&'a str` fields, `#[serde(borrow)]` with `serde_json::from_slice`) instead of owning `String` fields — turns deserialization allocations into pointer arithmetic; `zerocopy`/`bytemuck` for fixed-layout binary views (rules/03 for the safety side). - **Writes**: unbuffered `write!` to a raw `File`/`TcpStream` syscalls per call — wrap in `BufWriter` (and remember to flush; dropped `BufWriter` errors are swallowed). `vectored writes` (`write_vectored`) for header+body patterns. - mmap (`memmap2`) for large read-mostly files beats read-into-Vec; the safety caveat (file truncated under you = UB) is real — confine to files you control or accept advisory locking. ## 7. Build-time performance (developer loop) Slow builds are a perf problem too — they tax every iteration. - `cargo check`/rust-analyzer for the inner loop, not `cargo build`. - Linker: `rust-lld` is already the default on `x86_64-unknown-linux-gnu` since Rust 1.90 (opt-out: `-C linker-features=-lld`); `mold` is the further Linux upgrade, and manual `-C link-arg=-fuse-ld=lld` pays only on targets where lld isn't the default yet. - Split heavy generics: generic shells delegating to non-generic inner fns (`fn run(p: impl AsRef<Path>) { fn inner(p: &Path) {...} inner(p.as_ref()) }`) cut monomorphization bloat (binary size AND compile time). - `cargo build --timings` to find the long pole; feature-trim heavy deps (`tokio` full vs needed features, `syn` full); move optional integrations behind features (rules/07 §5). - Workspace split so hot-edit crates are leaves, not roots (rules/01 §9); proc-macro and build.rs crates dominate cold builds — audit their cost. ## 8. Don'ts - Don't sprinkle `#[inline(always)]` — it defeats the inliner's cost model and bloats icache; `#[inline]` only on small cross-crate hot functions (and it's unnecessary for generics, which are already monomorphized downstream). - Don't micro-optimize before architecture: batching, caching, removing a network round-trip, or a better algorithm beats any amount of `SmallVec`. - Don't benchmark on laptops with thermal throttling / background noise and publish 3% wins; criterion's noise floor on shared CI is ~5%+ — gate regressions with thresholds, not single runs. - Don't unsafe-away bounds checks without a flamegraph showing them (§1, rules/03). ## Audit checklist - [ ] Benchmarks exist for claimed-hot code (criterion/divan in `benches/`); perf-sensitive PRs include before/after numbers. Claims without measurements = Low finding, ask for receipts. - [ ] `rg '\.clone\(\)|to_vec\(\)|to_string\(\)|to_owned\(\)' -t rust` scoped to loop bodies / per-request paths — review each; clippy `redundant_clone`, `cloned_instead_of_copied`, `unnecessary_to_owned`. - [ ] `rg 'format!' -t rust` in hot loops (want `write!` into reused buffer); `rg 'String::new\(\)|Vec::new\(\)' -t rust` inside loops (want hoisted clear-and-reuse or `with_capacity`). - [ ] `rg 'collect::<Vec' -t rust` immediately followed by `.iter()`/`into_iter()` — needless materialization (`clippy::needless_collect`). - [ ] `[profile.release]` reviewed: LTO set, codegen-units decision, strip, panic-strategy documented; profiling profile with `debug = true` exists. - [ ] `rg 'inline\(always\)' -t rust` — each needs benchmark justification. - [ ] Hash maps on hot non-adversarial keys still on SipHash (perf left on table) or, inversely, `FxHash/ahash` on attacker-controlled keys (HashDoS — High, see rules/05). - [ ] `clippy::large_enum_variant`, `large_types_passed_by_value`, `large_futures`, `needless_range_loop`, `or_fun_call`, `manual_memcpy` enabled; `Vec<Box<T>>`-style pointer-chasing layouts in scanned data. - [ ] Alloc-heavy multithreaded service on default allocator — try mimalloc/jemalloc with a benchmark; musl deployments especially. - [ ] `rg 'File::create|TcpStream' -t rust` write paths without `BufWriter`; `rg 'BufWriter' -A20` missing explicit `flush()` before drop. - [ ] Network parsing copying into owned `String`/`Vec` where `Bytes`/`#[serde(borrow)]` would zero-copy. - [ ] CI bench regression gate (criterion + `critcmp`/`cargo bench` artifacts, or codspeed/iai-callgrind instruction counting for noise-free CI). -
07-tooling-ci.md 17.6 KB
# 07 — Tooling, CI & Crate Hygiene A SOTA Rust repo is recognizable from its CI config alone: lints are deny, tests run under nextest, MSRV is tested not guessed, features are additive, and docs build clean. This file defines that baseline. ## 1. Clippy — policy, not vibes Configure lints **in Cargo.toml** (`[lints]`, inherited workspace-wide), not scattered `#![allow]`s: ```toml # workspace Cargo.toml [workspace.lints.rust] unsafe_code = "warn" # forbid in crates that can (rules/03) missing_docs = "warn" # libraries: consider deny unused_must_use = "deny" [workspace.lints.clippy] all = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 } # triaged pedantic opt-outs — each with a reason, reviewed yearly: module_name_repetitions = "allow" # naming convention conflicts must_use_candidate = "allow" # too noisy for app crates # hard floors: unwrap_used = "deny" # production crates (rules/02) dbg_macro = "deny" todo = "deny" undocumented_unsafe_blocks = "deny" await_holding_lock = "deny" # member crates: [lints] workspace = true ``` - **Pedantic triage**, not pedantic-blanket: enable the group, then `allow` specific lints with a comment — this catches new pedantic lints on toolchain updates instead of opting out of the future. - **Deny-warnings in CI only**: `cargo clippy --all-targets --all-features -- -D warnings` in CI; locally keep warn so WIP compiles. For build warnings, Cargo's `build.warnings = "allow"/"warn"/"deny"` config (stable since Rust 1.97) is the first-class knob — set `CARGO_BUILD_WARNINGS=deny` in the CI environment, not in committed config, so local builds stay on warn. Hardcoding `#![deny(warnings)]` in source breaks builds on every new rustc lint — don't. - Per-site `#[allow(clippy::xyz, reason = "...")]` (lint reasons are stable) over module-level allows; an allow without a reason is a finding. - Run clippy on the same pinned toolchain as the build (lint sets drift across versions). ### 1a. On a constrained target, a style lint's *premise* may be false Clippy's advice is near-universally correct because it is written against the assumptions of a hosted target: an 8 MB stack, an allocator, a real `std`. In an **eBPF program, an embedded or `no_std` crate, a WASM module, a kernel module or an interrupt handler**, one of those assumptions is gone, and a lint that encodes it silently spends a resource the compiler will not warn you about. Worked case. `clippy::needless_borrows_for_generic_args` (style, warn-by-default — *"taking a reference that is going to be automatically dereferenced"*, verified in clippy 0.1.97) fired on a map `insert(&key, &now, 0)` inside a BPF program and suggested the owned form. `insert` takes `impl Borrow<K>`, so the owned call monomorphises to `Borrow<K> for K` and copies the 52-byte key onto the stack. A BPF program gets **512 bytes for the whole call chain** — `MAX_BPF_STACK` in the kernel's `include/linux/filter.h` — of which a helper already held 344. The program stopped loading: ```text combined stack size of 2 calls is 544. Too large ``` (that string is emitted by the kernel verifier, `kernel/bpf/verifier.c`.) Rules: - **Before taking a lint suggestion in such a crate, name the resource it spends.** `Copy` means "cheap to copy" *on an 8 MB stack*; it says nothing about a 512-byte one. The same applies to lints that suggest an owned value, an iterator adaptor, a `format!`, or anything that inlines a larger frame. - **Do not let the word "mechanical" stand in for the analysis.** *"Mechanical", "trivial", "just a rename", "style only"* are classifications that license skipping evaluation, and they are applied **before** the evaluation that would justify them. Treat them as a prompt to check, not as a conclusion — this is the linguistic tell that a decision was made without being made. - **Prefer removing the lint's premise over silencing it.** Dropping `Copy` from an oversized key type stops the lint firing *and* stops the next contributor reintroducing the copy — better than `#[allow]`, which only silences this site. But do not then claim the type *enforces* what it merely discourages. - **Set the policy at the crate, not the call site.** A constrained crate's `[lints.clippy]` should `allow` the specific hosted-assumption lints with a reason naming the constraint, so the rest of the group keeps working. - **Only a gate that loads or runs the artifact can catch this** — `fmt`, `clippy` and lint passes all stop at the compiled object and were green on the broken one. `sota-devsecops` rules/09 §2a. ### 1b. Source shape is not a proxy for compiled behaviour §1a is a *lint's* premise being false on a constrained target. This is **your own reasoning's** premise being false, and it is the harder one because nothing fires. Rust makes source structure feel like it maps onto machine behaviour — ownership, scopes and struct layout usually do. At `opt-level=3` with LTO they need not, and on a target where the limit is checked at **load** time there is no compile error to correct you. Measured on rustc 1.97.1: a plain helper called once from a function disappears entirely at `opt-level=3` — the symbol is absent from the emitted assembly, while an `#[inline(never)]` neighbour in the same file still shows three call sites (the control that proves the absence is real, not a search artefact). ```console $ rustc -C opt-level=0 --emit asm t.rs && grep -c fill t.s # 2 — the boundary exists $ rustc -C opt-level=3 --emit asm t.rs && grep -c fill t.s # 0 — it does not ``` Field-reported consequence: a helper was extracted from an eBPF program specifically so each transport would own one large stack local instead of two, and that reasoning was written into a commit message as fact. The verifier's own numbers before and after were **identical** — `stack depth 136+0+344+0` both times. The refactor was sound; the justification was fiction. - **A claim about memory layout cites a measurement from the toolchain that enforces the limit** — the verifier's report, `-Zprint-type-sizes`, a linker map, `--emit asm`. Write it as *"measured X on Y"* or do not write it. - **Extracting a function does not create a scope the optimiser must honour.** If you need two lifetimes not to overlap, you need something the compiler cannot inline through — a separate program, an explicit `#[inline(never)]`, or a different data flow. - **The numbers to compare are before and after on the same toolchain**, not one reading and an argument. `sota-code-security` rules/15 §2a: a *pass* is not a number. ## 2. rustfmt — zero-config by default - `cargo fmt --check` in CI. Default style; a `rustfmt.toml` should contain only deliberate deviations (e.g. `imports_granularity = "Crate"`, `group_imports = "StdExternalCrate"` — nightly-only options mean fmt runs on nightly toolchain in CI if used). - Never hand-format against rustfmt; never argue style in review — that's the tool's job. ## 3. Tests & cargo-nextest - `cargo nextest run` over `cargo test`: process-per-test isolation (one test's panic/env pollution can't poison others), better parallelism, flaky retries with detection (`--retries N` + reporting), per-test timeouts (`slow-timeout` + `terminate-after` — hangs fail instead of stalling CI), JUnit output. Note: nextest doesn't run doctests — keep a separate `cargo test --doc` step. - Test taxonomy: unit tests in-module (`#[cfg(test)]`), integration tests in `tests/` (compile as separate crates — each file is a binary; group to keep link time sane), doctests on every public API example (they're the only examples guaranteed to compile). - Property tests (`proptest`) for parsers/serializers/invariant-heavy code; snapshot tests (`insta`) for rendered output; `loom` for atomics (rules/03); fuzz for untrusted-input parsers (rules/05). - Coverage: `cargo llvm-cov nextest` — track trend, don't worship the number. ## 4. MSRV policy - Declare it: `rust-version = "1.85"` in `[package]`/`[workspace.package]` — cargo refuses to build on older toolchains with a clear error instead of cryptic syntax failures. - **Test it**: a CI job building with the pinned MSRV toolchain (`cargo +1.85 check --all-features`); `cargo msrv verify` / `cargo msrv find` to maintain. An untested MSRV claim is false within two dependency bumps — note deps' MSRV bumps in *minor* versions can break you; this is what the MSRV-aware resolver (Rust 1.84+, `resolver.incompatible-rust-versions = "fallback"`) mitigates. - Policy in README/CONTRIBUTING: which versions you support and whether an MSRV bump is a semver-minor (common convention) — pick one and say it. - Applications: pin the toolchain exactly with `rust-toolchain.toml` (reproducible builds, same clippy everywhere). Libraries: MSRV floor + stable-latest CI matrix. ## 5. Feature flag hygiene Features must be **additive**: enabling a feature may only add API/behavior, never remove or change it. Cargo unifies features across the graph — if crate A needs `foo` without feature X and crate B enables X, A gets X too; mutually exclusive features break the ecosystem. ```toml [features] default = ["std"] std = [] serde = ["dep:serde", "uuid?/serde"] # dep: = no implicit feature for optional deps # ?/ = enable serde on uuid only if uuid is on full = ["serde", "metrics"] # convenience aggregate, still additive ``` - `dep:` syntax for optional dependencies (no accidental public `features = ["serde"]` exposure of dep names); `pkg?/feat` for weak (conditional) feature forwarding. - No `no-std`-style **negative features** (`no_std = []` that removes things); model it as positive `std` in `default`, with `default-features = false` consumers opting in. - **Test the matrix**: `cargo hack check --feature-powerset --depth 2` (or at minimum: `--no-default-features`, default, `--all-features`) in CI — feature combinations nobody compiles are broken combinations. - Don't gate public API breaking-ly (a feature that changes a type's layout or a function's signature = non-additive = ecosystem breakage). - Keep default features lean; heavyweight integrations (TLS stacks, runtimes) always optional and documented. - cfg dispatch: prefer built-in `cfg_select!` (stable since Rust 1.95, compile-time match on cfgs) over adding the `cfg-if` dependency in new code. ## 6. Documentation discipline (docs.rs) - Every public item documented; `#![warn(missing_docs)]` on libraries. First line = one-sentence summary (it's the item's listing line); then details, then `# Examples` (doctested), `# Errors` (when `Result`), `# Panics` (every documented panic path — clippy `missing_panics_doc`), `# Safety` (every `unsafe fn` — rules/03). - Crate root (`lib.rs`) gets the long-form intro: what/why/quickstart — `#![doc = include_str!("../README.md")]` keeps README and docs in sync (and doctests the README's examples). - Feature-gated items: build docs.rs with all features and label them: ```toml [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] ``` ```rust #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] // renders "Available on feature serde" ``` - Intra-doc links (`[`Vec`]`, `[`crate::Config`]`) over bare URLs — checked at build (`rustdoc::broken_intra_doc_links` is deny-worthy). - CI: `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features` so doc rot fails the build. ## 7. Edition 2024 notes Current edition as of mid-2026; new code starts here (`edition = "2024"`, Rust ≥1.85; verify latest stable at releases.rs). The next edition is expected ~2027 on the usual three-year cadence — nothing to migrate toward yet. Migration: `cargo fix --edition` then review. Key changes that affect rules in this skill: - `unsafe_op_in_unsafe_fn`: unsafe fns need explicit inner `unsafe {}` blocks (rules/03 §2 — good: per-obligation SAFETY comments). - `static_mut_refs` denied: references to `static mut` rejected — migrate to atomics/`OnceLock`/`Mutex` (rules/03 §3). - **RPIT lifetime capture**: return-position `impl Trait` now captures *all* in-scope lifetimes by default (was: only named ones) — use `+ use<'a, T>` precise-capture syntax to opt out; check existing APIs whose returned opaques suddenly borrow more. - `unsafe extern` blocks and unsafe attributes (`#[unsafe(no_mangle)]`) — FFI declarations are now explicitly trust-me-marked. - Temporaries in `if let`/match scrutinees drop sooner (tail-expression temporary scope) — re-check `RefCell`/lock guards in conditions. - `Future`/`IntoFuture` in prelude; `gen` keyword reserved. - Resolver v3 (MSRV-aware) default with edition 2024 (§4). ## 8. Semver enforcement & release automation - **`cargo semver-checks`** in CI for published libraries: diffs the public API against the last release and fails on undeclared breaking changes (removed items, changed signatures, new non-defaulted trait methods, auto trait leaks like a type silently becoming `!Send`). Run on release PRs at minimum; it catches the breakage humans reliably miss. - Semver hazards it can't fully see — review manually: blanket impl additions, `#[non_exhaustive]` removal (breaking to *remove*), MSRV bumps (declare policy, §4), feature removals/renames (features are public API), and doc-promised behavior changes. - **Release automation**: `release-plz` (or `cargo-release`) — version bump from conventional commits, changelog generation, tag, `cargo publish` with `--locked` from CI with a scoped registry token. Manual `cargo publish` from laptops drifts from the audited lockfile and skips the gates. - Changelog discipline: keep a human-readable CHANGELOG.md (generated or curated); "see git log" is not a changelog. Yanked releases (`cargo yank`) for published-broken versions — yank doesn't delete, it stops *new* resolution. - Binaries: `cargo dist` (or equivalent) for multi-target release artifacts, checksums, and installers from one config; cross-compilation via `cross` or toolchain targets exercised in CI *before* the release tag, not during. ## 9. The CI baseline (copy this shape) ```yaml jobs: check: # fmt + clippy + doc, pinned stable toolchain - cargo fmt --check - cargo clippy --workspace --all-targets --all-features -- -D warnings - RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features test: - cargo nextest run --workspace --all-features - cargo test --doc --workspace msrv: - cargo +${MSRV} check --workspace --all-features features: - cargo hack check --workspace --feature-powerset --depth 2 supply-chain: # rules/05 - cargo deny check unsafe-crates-only: # rules/03 - cargo +nightly miri test -p crate-with-unsafe ``` Plus scheduled: `cargo audit` (new advisories), fuzz jobs, bench regression gate (rules/06). Cache with `Swatinem/rust-cache`; pin action SHAs (rules/05). ## Audit checklist - [ ] `[lints]` table present and workspace-inherited; `rg '#!\[allow' -t rust` — blanket crate-level allows without reasons = Low each, pattern = Medium. - [ ] CI runs clippy with `-D warnings` on `--all-targets --all-features`; source does NOT hardcode `#![deny(warnings)]`. - [ ] **Constrained crates (eBPF, `no_std`, embedded, WASM, kernel) — is any lint suggestion taken on a false premise?** (§1a) For each accepted style fix in such a crate, name the resource it spends (stack frame, allocation, code size); hosted- assumption lints are `allow`ed at the crate with a reason naming the constraint. A change described as "mechanical" in one of these crates is unreviewed, not safe. - [ ] **Any claim about stack, size or layout that cites the SOURCE rather than a measurement?** (§1b) At `opt-level=3` with LTO a plain helper's boundary does not exist in the artefact — measured, a single-call helper vanishes entirely — so "I extracted it so the locals would not overlap" is not evidence. Cite the enforcing toolchain's own number, before and after. - [ ] **Does any gate load or run the artifact?** (§1a) `fmt`, `clippy` and lint passes all stop at the compiled object; a crate whose failures appear at load or verify time is ungated until one gate executes it on the real target. - [ ] `cargo fmt --check` green and in CI; `rustfmt.toml` deviations are deliberate and few. - [ ] Tests: nextest in CI + separate doctest step; per-test timeout configured; `rg '#\[ignore\]' -t rust` — ignored tests have reasons. - [ ] `rust-version` declared AND exercised by a CI job; binaries have `rust-toolchain.toml`. `cargo msrv verify` passes. - [ ] Features: `rg 'no[-_](std|default)' Cargo.toml` style negative features = Medium (non-additive); optional deps using `dep:`; feature matrix job (`cargo hack`) present; `--no-default-features` builds. - [ ] Docs: `missing_docs` on lib crates; `# Errors`/`# Panics`/`# Safety` sections present (`clippy::missing_errors_doc`, `missing_panics_doc`, `missing_safety_doc`); docs.rs metadata for feature-gated crates; doc job with `-D warnings`. - [ ] Edition: new crates on 2024; pre-2024 crates have a migration note or reason; post-migration, re-audit `static mut` and RPIT capture changes. - [ ] Published libraries: `cargo semver-checks` in release CI; publishing automated (`release-plz`/`cargo-release`) with `--locked`, not from laptops; CHANGELOG maintained. - [ ] Reproducibility: `Cargo.lock` committed, toolchain pinned, CI action SHAs pinned, `rust-cache` keyed correctly (not caching stale clippy). - [ ] Quick greps: `rg 'dbg!|println!' -t rust -g '!*test*' -g '!*/bin/*'` (debug leftovers in libs); `rg 'FIXME|HACK|XXX' -t rust` triaged.
-
-
SKILL.md 10.4 KB
--- name: sota-rust description: >- State-of-the-art Rust engineering (2026) for writing and auditing Rust code. Covers idiomatic ownership and API design, error handling and panic policy, unsafe discipline with Miri, async/tokio (cancellation safety, structured concurrency, graceful shutdown), security and supply chain (cargo audit/deny/vet, integer overflow, serde hardening, zeroize), performance (profiling, allocation reduction, release profiles), and tooling/CI (clippy policy, nextest, MSRV, feature hygiene, edition 2024). Use when writing new Rust code, reviewing or auditing existing Rust, designing crate APIs, debugging borrow checker or Send/Sync errors, or hardening Rust services. Triggers: Rust, cargo, crate, tokio, unsafe, lifetime, borrow checker, clippy, async Rust, Cargo.toml, thiserror, anyhow, serde, Miri, MSRV, std::process::Command, subprocess, spawn a process. --- # SOTA Rust (2026) ## Purpose This skill encodes the 2026 state of the art for production Rust: the idioms, security posture, performance discipline, and CI baseline expected of an expert Rust codebase. Baseline as of mid-2026: a recent stable Rust toolchain (verify the current release at blog.rust-lang.org), edition 2024 (next edition expected ~2027), tokio still 1.x. It serves two modes — **BUILD** (write new code to this standard) and **AUDIT** (find where existing code falls short, with severity and evidence). The detailed rules live in `rules/*.md`; load only the files relevant to the task (see index below). Every rules file ends with an "Audit checklist" of grep/clippy patterns — use those verbatim in AUDIT mode. ## BUILD mode When writing or modifying Rust code: 1. **Scope the work, load the rules.** Pick the relevant `rules/` files from the index. Touching async code? Load 04. Adding a dependency, parsing network input, or spawning an external program? Load 05. Writing any `unsafe`? Load 03 — no exceptions. 2. **Design types first.** Newtypes for domain primitives, errors per subsystem (thiserror for libs, anyhow for apps), ownership tree before `Arc<Mutex<_>>`, public API minimal and borrowed (`&str`/`&[T]` params). Parse, don't validate: constructors enforce invariants. 3. **Write to the non-negotiables** (bottom of this file) without being asked. They are defaults, not suggestions; deviations carry a written justification at the site (e.g. `expect` with invariant message, `#[allow(lint, reason = "...")]`). 4. **Wire the scaffolding with the code**, not after: lints in `[lints]`, `deny.toml` + `cargo deny` in CI for anything deployed, Miri job if unsafe exists, nextest, MSRV declared and tested, benches for claimed-hot paths. See rules/07 §9 for the CI shape to copy. 5. **Verify before claiming done:** `cargo fmt --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo nextest run` + `cargo test --doc`, and `cargo doc` warning-free for libraries. If you wrote unsafe: `cargo +nightly miri test` over it. If you claimed performance: show the benchmark. 6. **Comment intent at decision points** the next reader will question: justified clones, cancel-safety of `select!` arms, SAFETY comments, channel-capacity choices, poisoning policy. ## AUDIT mode When reviewing or auditing existing Rust: 1. **Recon first:** `cargo metadata`/workspace layout, `Cargo.toml` profiles and features, CI config, `rg 'unsafe' --count-matches`, dependency tree (`cargo tree -d`). This decides which rules files to load and where risk concentrates (network input? unsafe? async service?). 2. **Run the audit checklists** at the end of each loaded rules file — they are ordered grep/clippy hunts with pre-calibrated severities. 3. **Validate every finding**: read the surrounding code; a grep hit is a lead, not a finding. Confirm reachability (is the unwrap on an attacker-influenced path?) before assigning severity. 4. **Report with the finding format below.** Prefer few, true, prioritized findings over volume. Note positive observations where the code is already SOTA (prevents "fixes" that regress good decisions). ### Severity conventions | Severity | Meaning | Examples | |---|---|---| | **Critical** | Exploitable now, or UB | reachable UB, unsound safe API, SQLi/path traversal, authn bypass, unwinding across FFI, secrets in logs+repo | | **High** | Exploitable under realistic conditions, or correctness loss | attacker-reachable panic/OOM (DoS), wrapped arithmetic on untrusted lengths, cancellation data loss, deadlock (`block_on` in async, lock across await), unbounded channels fed by network, missing dep-audit in deployed-service CI | | **Medium** | Latent defect or eroded defense | missing SAFETY comments, no Miri CI on unsafe crate, swallowed errors (`.ok()`, `filter_map(Result::ok)`) uncommented, untested MSRV, non-additive features, orphaned spawned tasks | | **Low** | Hygiene, idiom, maintainability | clone-to-satisfy-borrowck, index loops, missing `#[non_exhaustive]`, missing `# Errors` docs, blanket `#[allow]` without reason | Severity scales with **reachability** (attacker-controlled > user > operator > build-time) and **blast radius** (process death > request failure > slow). ### Finding format ``` [SEVERITY] short title Where: path/to/file.rs:123 (fn name / module) What: the defect, in one or two sentences Why: concrete consequence (exploit path, failure mode, cost) Fix: specific change — code sketch or named pattern from rules/NN Effort: trivial | small | medium | large Refs: rules/NN §M; clippy lint or RUSTSEC id if applicable ``` Group findings by severity, Critical first. End with: checklist coverage (which rules files were applied), what was *not* reviewed, and quick wins (one-line fixes with outsized value). ## Rules index | File | Read this when... | |---|---| | [rules/01-ownership-and-api-design.md](rules/01-ownership-and-api-design.md) | Designing structs/traits/modules/workspaces; fighting the borrow checker; deciding clone vs borrow vs Rc/Arc; newtype, typestate, builder patterns; sealed traits, coherence; comparison-trait (`Eq`/`Ord`) invariants; exhaustive matching; iterator-chain idioms | | [rules/02-errors-and-panics.md](rules/02-errors-and-panics.md) | Choosing thiserror vs anyhow/eyre; designing error enums; unwrap/expect policy and invariant messages; context discipline; panic policy for servers, FFI, and `Drop` (no panic in destructors); Option/Result combinator flow; **not unwrapping an `Option` back into a sentinel** (`unwrap_or(-1)`, `serde(default)` on numbers) | | [rules/03-unsafe-discipline.md](rules/03-unsafe-discipline.md) | Writing or reviewing ANY `unsafe`; SAFETY comment standards; UB catalog (aliasing, uninit, transmute, FFI lifetimes); Miri/sanitizers/loom in CI; cargo-geiger; soundness review protocol | | [rules/04-async-tokio.md](rules/04-async-tokio.md) | Anything async: tokio, spawn vs spawn_blocking, Send/Sync bound errors, `select!` and cancellation safety, JoinSet/TaskTracker, channel selection, locks across await, async traits, graceful shutdown | | [rules/05-security-supply-chain.md](rules/05-security-supply-chain.md) | Network-facing or deployed code; adding dependencies; cargo audit/deny/vet; integer overflow on untrusted input; panic-DoS; zeroize/constant-time for secrets; serde hardening (untagged enums, size limits); service-edge defaults; **spawning external programs** (`std::process::Command` — argv vs shell, the Windows `.bat` CVE, a dropped `Child` that keeps running, deadlines that don't kill, §9) | | [rules/06-performance.md](rules/06-performance.md) | Performance work or claims: profiling (samply/perf/flamegraph, criterion/divan), allocation reduction (Cow/SmallVec/buffer reuse), accidental clones, iterator fusion, release profile (LTO, codegen-units, panic=abort), PGO | | [rules/07-tooling-ci.md](rules/07-tooling-ci.md) | Setting up or auditing repo scaffolding: clippy policy and pedantic triage, rustfmt, nextest, MSRV declaration+testing, additive feature flags, docs.rs discipline, edition 2024 migration, CI baseline. **Test *strategy* — suite shape, TDD, doubles, test data, flake policy — lives in `sota-testing`; load it for any build that writes logic. This file owns Rust runner mechanics only.** | ## Top-10 non-negotiables 1. **No `unwrap()`/bare `expect()` on production paths.** Propagate with `?` + context; `expect("...")` only with a message proving the invariant. An attacker-reachable panic is a DoS. (rules/02) 2. **Every `unsafe` block has a `// SAFETY:` comment** discharging the called API's documented preconditions, and lives behind a sound safe abstraction. Unsafe code without Miri in CI is unaudited code. (rules/03) 3. **Libraries: thiserror enums with `#[source]` chains. Applications: anyhow with `.context()`.** Never `anyhow::Error` in a public lib API; never silently swallowed errors. (rules/02) 4. **Never block the async runtime:** no sync I/O, `std::thread::sleep`, or sustained CPU inside `async fn`; `spawn_blocking` or a compute pool. No `std::sync::MutexGuard` held across `.await`. (rules/04) 5. **Every `select!`/timeout/abort path is cancellation-reviewed:** futures dropped at any `.await`; cancel-unsafe ops don't go in `select!` arms; invariants spanning awaits get drop guards. Spawned tasks are owned (JoinSet/TaskTracker), never orphaned. (rules/04) 6. **Untrusted input gets checked arithmetic, size limits, and depth limits:** `checked_*`/`try_into` on lengths (release mode wraps silently), body-size caps before parsing, no `#[serde(untagged)]` or uncapped `with_capacity` on hostile data. (rules/05) 7. **Supply chain is CI-enforced:** `cargo deny`/`cargo audit` on PRs + scheduled, `Cargo.lock` committed, new deps vetted (cargo vet or documented review), git deps pinned by rev. (rules/05) 8. **Secrets are typed (`SecretString`/`Zeroizing`), redacted from Debug/logs, and compared in constant time** (`ct_eq`). Key material only from OS randomness. (rules/05) 9. **Don't clone to satisfy the borrow checker; don't take owned params you only read.** `&str`/`&[T]` in signatures, split borrows, `mem::take`; newtypes over primitive obsession; exhaustive matches (no lazy `_ =>` on owned enums). (rules/01) 10. **CI gate: fmt + clippy `-D warnings` (triaged pedantic) + nextest + doctests + MSRV job + feature-matrix check.** Performance claims require benchmarks; release profile (LTO/codegen-units/panic strategy) is a deliberate, documented choice. (rules/06, 07)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.