rust
Rust reference pack (editions 2021/2024, 2026 stable toolchains): ownership and lifetimes, traits and generics, async/tokio and structured concurrency, error handling, unsafe/FFI, axum and the data layer, embedded no_std, testing, build tooling, performance, security, and observability. Use when writing, reviewing, debugging, or architecting non-trivial Rust: borrow-checker fights, async cancellation, unsound or unaudited unsafe, C/pyo3/wasm interop, web services with axum or actix, sqlx/diesel schemas and migrations, cargo workspaces and feature flags, cross-compilation and supply chain, profiling and benchmarking, or firmware on embedded-hal and embassy.
Rust
Reference pack for senior Rust work: language core, type system, async, errors, unsafe/FFI, web/data, embedded, testing, build, and performance/security/observability. Depth lives in references/; this file is the map.
Baseline (2026)
- Editions 2021 and 2024 are the supported targets; new crates should start on edition 2024 unless a dependency forces otherwise. Verify upstream for exact toolchain behavior.
- Useful floors: async fn in traits / RPITIT (1.75+), async closures and edition 2024 (1.85+),
LazyLock(1.80+),pin!(1.68+), workspace lints (1.74+),&rawpointers (1.82+), MSRV-aware resolver v3 with edition 2024. - Pin the toolchain in
rust-toolchain.toml, declarerust-version, and test the MSRV in CI. - The ecosystem moves; every version range in this pack is a floor or "verify upstream", never a promise.
Non-Negotiable Core Rules
- No
unwrap()/expect()on external input or I/O in production paths. Libraries expose typed errors; applications useanyhowinternally and convert at boundaries. See./references/04-error-handling.md. - Every
unsafeblock has a// SAFETY:invariant, a safe wrapper, and Miri coverage. Neverunsafeto appease the borrow checker. See./references/05-unsafe-ffi.md. - No blocking calls or held lock guards across
.await. Cancellation of everyselect!/timeoutbranch is understood and documented. See./references/03-async-concurrency.md. - Every spawned task has an owner, a join path, and error handling; shutdown is token-driven and drains with a deadline.
- Cargo features are additive; mutually exclusive features are a design bug. Public APIs never expose
anyhow::Error. - Measurements precede optimization: profile a release-like build, record a baseline, guard with benchmarks.
- CI runs
cargo fmt --check,cargo clippy --all-targets --all-features -D warnings, tests, and dependency/license/advisory checks; releases build--locked. - Secrets are never logged, never committed, and never embedded in
Debugoutput; TLS/crypto/RNG come from maintained crates with tracked advisories.
Decision Tables
Error Type by Position
| Position | Use | Do not use |
|---|---|---|
| Public library API | thiserror enum / hand-written Error | anyhow, String |
| Binary internals | anyhow::Result + .context() | custom enum per function |
| Boundary conversion | From/map_err preserving source | map_err(|_| …) |
| Erased trait object boundary | Box<dyn Error + Send + Sync> | generic error params everywhere |
Async Concurrency Primitive
| Need | Primitive |
|---|---|
| Background work with result | JoinSet / spawned task with owned handle |
| Request/response between tasks | oneshot |
| Ordered work queue with backpressure | bounded mpsc |
| Latest state broadcast | watch |
| Every event to every subscriber | broadcast (handle lag explicitly) |
| Concurrency limit | Semaphore, tower concurrency layer |
| Cancellation/shutdown | CancellationToken + TaskTracker |
Unsafe Justification
| Situation | Verdict |
|---|---|
| FFI to C/pyo3/wasm | justified; isolate and document |
| Performance-proven hot loop | justified with benchmark and scalar fallback |
| Defeating the borrow checker | redesign; use interior mutability or ownership transfer |
| "Compiler is annoying" | never |
| Crypto/SIMD intrinsics | use vetted crates or std::arch with runtime dispatch |
Web and Data Defaults
| Choice | Default | Switch when | |---|---|---| | HTTP framework | axum (tower/hyper ecosystem) | team/legacy is actix-web | | Database | sqlx (compile-time checked SQL) | entity modeling -> sea-orm; typed query builder -> diesel + diesel-async | | Config | typed struct + figment/config layered env | single source -> plain env | | Auth | middleware authenticates, services authorize | gateway terminates auth |
Reference Index
| File | Scope | Load when |
|---|---|---|
| ./references/01-language-core.md | Ownership/borrowing/lifetime model, smart pointers, enums, pattern matching, closures, iterators, borrow-checker fixes | Any ownership or lifetime error; choosing Rc/Arc/RefCell/locks; designing data flow |
| ./references/02-type-system-traits.md | Traits, associated types, generics vs dyn, blanket impls, marker traits, newtypes, conversions, operator traits, Error integration | Designing APIs/traits, resolving coherence/object-safety issues, adding conversions |
| ./references/03-async-concurrency.md | Tokio runtime tuning, tasks, cancellation safety, structured concurrency, channels, select!, backpressure, Send/Sync, async traits | Writing async code, debugging hangs/stalls, cancellation or shutdown work, async trait design |
| ./references/04-error-handling.md | Result/Option idioms, thiserror vs anyhow, context chains, panic policy, unwrap discipline, boundary conversion | Defining errors, mapping HTTP/DB/task errors, eliminating panics or unwraps |
| ./references/05-unsafe-ffi.md | Soundness rules, raw pointers/provenance, Pin, safe abstractions, Miri, C ABI/bindgen, pyo3/wasm-bindgen, unsafe audit | Writing/reviewing unsafe, FFI or interop, pinned/self-referential types, Miri CI |
| ./references/06-web-data.md | axum vs actix, tower middleware, serde, sqlx/diesel/sea-orm, migrations, config, auth, graceful shutdown | Building or reviewing HTTP services, DB schemas/queries/migrations, config and auth |
| ./references/07-embedded.md | no_std, embedded-hal, embassy async, defmt/probe-rs, memory budgets, target config | Firmware, HAL drivers, bare-metal targets, embedded CI and debugging |
| ./references/08-testing.md | Unit/integration/doc tests, proptest, criterion, cargo-nextest, Miri, loom, snapshots, fuzzing, coverage, mocks | Adding test infrastructure, flaky/racy bugs, unsafe test strategy, benchmark setup |
| ./references/09-tooling-build.md | Workspaces, feature flags/unification, profiles, task runners, rustfmt/clippy, cross-compilation, MSRV, release and supply chain | Setting up or fixing builds, features, CI, cross targets, publishing, dependency policy |
| ./references/10-performance-security-observability.md | Profiling, allocation reduction, rayon, SIMD, crypto/secrets, tracing, metrics | Optimizing hot paths, memory/latency budgets, adding instrumentation or hardening |
Load Order
- Language or design question: start at
./references/01-language-core.mdor./references/02-type-system-traits.md. - Async service work:
./references/03-async-concurrency.mdthen./references/06-web-data.md. - Correctness/robustness pass:
./references/04-error-handling.mdthen./references/08-testing.md. - Native interop or firmware:
./references/05-unsafe-ffi.mdor./references/07-embedded.md. - Root-causing build/CI or perf:
./references/09-tooling-build.mdthen./references/10-performance-security-observability.md. - Load only what the task needs; do not inject the whole pack into context.
Review Workflow
For a non-trivial Rust change, this is the default pass order:
- Model the data and ownership first; most "Rust is hard" issues are design issues (see
./references/01-language-core.md). - Define errors and boundaries before implementation (
./references/04-error-handling.md,./references/02-type-system-traits.md). - Decide sync vs async and the concurrency primitives (
./references/03-async-concurrency.md). - Keep unsafe/FFI isolated and covered by Miri (
./references/05-unsafe-ffi.md). - Wire the framework/data layer with explicit timeouts and shutdown (
./references/06-web-data.md). - Add tests at the same time as the code (
./references/08-testing.md). - Run fmt, clippy, tests, and dependency checks before review (
./references/09-tooling-build.md). - Instrument from day one and profile before optimizing (
./references/10-performance-security-observability.md).
Port
- Port id —
skill://senior/rust(version inmetadata.port-version, currently2.0.0). - Kind — read-only reference pack; no side effects, no tools required.
- Entrypoint — this
SKILL.md; depth lives inreferences/. - Load modes
- Installed agent:
skill({ name: "rust" })in OpenCode; Claude Code reads<skills-dir>/rust/SKILL.md. - Orchestrator: read
SKILL.md, then load only the references the task needs. - Not installed: consumers MUST degrade gracefully using their own guidance and report the degraded mode. Never block on the pack.
- Installed agent:
- Consumer contract —
metadata.consumerslists the agents that may load it; consumers reference it asload skill rust (optional). - Stability —
stable; breaking changes bumpport-versionmajor.
Contract
- References are numbered
01-10; keep them mutually consistent and cross-linked with relative paths. - Version claims are floors or qualified with "verify upstream"; never present invented exact versions as fact.
- Anti-patterns and checklists close every reference; treat unchecked boxes as review findings.
References (10)
01-language-core
Language Core: Ownership, Borrowing, Lifetimes, Data Modeling
Scope: the ownership/borrowing/lifetime mental model, smart pointers, enums, pattern matching, closures, iterators, and concrete fixes for borrow-checker fights.
Ownership Mental Model
- Every value has exactly one owner. When the owner goes out of scope, the value is dropped.
- Assignment, passing by value, and returning move the value unless the type is
Copy. Copyis a property of the type (no heap, no destructor semantics enforced);Cloneis an explicit deep or shallow copy.- Drop order: locals drop in reverse declaration order; struct fields drop in declaration order; tuple elements drop in order. Temporaries drop at the end of the enclosing statement.
- Edition 2024 changed the scope of temporaries in tail expressions and
if let; code that relies on temporary drop timing should be tested on both editions. Verify upstream for exact rules. - Use
std::mem::drop(x)to end a borrow early; usestd::mem::take/replaceto move a value out through&mut.
struct Conn { name: String }
impl Drop for Conn { fn drop(&mut self) { /* close */ } }
fn take_ownership(c: Conn) -> String { c.name } // c dropped at end of fn
fn borrow(c: &Conn) -> &str { &c.name } // no move, no drop
Copy vs Clone decision table
| Situation | Use |
|---|---|
| Small scalar/identifier (u64, f64, [u8; 16], enums of scalars) | derive Copy |
| Owns heap or resource (String, Vec, file handle) | Clone only, explicit |
| Type will be serialized/shared and is cheap | derive both |
| Contains Drop behavior | never Copy |
Borrowing Rules
- At any time: any number of shared borrows (
&T) OR exactly one mutable borrow (&mut T) — never both. - Borrow ends at its last use under non-lexical lifetimes (NLL). Holding a
&mutthrough an unrelated call still conflicts. - Reborrowing (
&mut *x) is implicit when passing&mutto functions; the original can be reused after the call returns. - Returning a reference from a function requires the lifetime to come from an input (see elision below).
- Interior mutability (
Cell,RefCell,Mutex, atomics) is the escape hatch when aliasing plus mutation is genuinely required.
Lifetime elision rules
- Each elided input lifetime becomes a distinct parameter lifetime.
- If there is exactly one input lifetime, it is assigned to all elided outputs.
- If there is
&selfor&mut self, its lifetime is assigned to all elided outputs.
fn first_word(s: &str) -> &str { /* ties output to s */ }
fn get<'a>(&'a self) -> &'a str { &self.name }
'staticas a bound means "may live for the whole program", not "must".&'static stris a reference to static storage;T: 'staticon a generic means "contains no non-static borrows", which includes owned types likeString.- Structs holding borrows need explicit lifetimes:
struct Parser<'a> { src: &'a str }. If that becomes viral, own the data (String,Arc<str>) at API boundaries. - Lifetime elision does not apply inside closures or across
asyncblocks; keep borrows short before.await. - Higher-ranked trait bounds (
for<'a> Fn(&'a str)) are occasionally needed; preferFn(&str) -> ...sugar.
Smart Pointers
| Type | Sharing | Mutation | Thread-safe | Use when |
|---|---|---|---|---|
| Box<T> | single | via &mut | if T: Send | heap indirection, recursive types, trait objects |
| Rc<T> | shared | no (or with RefCell) | no | single-thread shared ownership, cheap clones |
| Arc<T> | shared | no (or with Mutex) | yes (atomics) | cross-thread shared ownership |
| Weak<T> | shared | no | matches parent | break reference cycles, caches, parent pointers |
| Cell<T> | single | copy-in/copy-out | no | small Copy state behind &self |
| RefCell<T> | single | runtime-checked | no | complex mutation behind &self |
| Mutex<T>/RwLock<T> | shared | lock | yes | shared mutable state across threads |
| OnceLock<T> | shared | set once | yes | lazily initialized global/config; LazyLock for init expressions (Rust 1.80+, verify upstream) |
| Cow<'a, T> | clone-on-write | via to_mut | varies | accept borrowed or owned, avoid allocations |
RefCellpanics on conflicting borrows at runtime. Usetry_borrow/try_borrow_mutwhere a panic is unacceptable, and keep borrows short.Mutexpoisoning:.lock()returnsResult;unwrap()is defensible only when a panic while holding the lock already means the process should die. Otherwise recover or useparking_lot::Mutex(no poisoning).- Never hold a
std::sync::MutexGuardacross.await. Usetokio::sync::Mutexonly if the critical section must await; prefer restructuring to avoid async locks entirely. See./03-async-concurrency.md. Rccycles leak. StoreWeakfor back/observer links and upgrade with.upgrade().- Prefer
Arc<str>/Arc<[T]>overArc<String>/Arc<Vec<T>>for immutable shared data (one indirection, immutable). - Atomics (
AtomicUsize,AtomicBool,AtomicPtr) belong in the standard library; useOrdering::Relaxedonly for counters where no synchronization is implied. Verify the necessary ordering against the C++ memory model.
Interior mutability hierarchy
&mut T for free, else
AtomicBool/AtomicUsize (Copy, lock-free, cross-thread)
Cell<T: Copy> (no borrow checking, single thread)
RefCell<T> (runtime borrow checking, single thread)
Mutex<T>/RwLock<T> (blocking, cross-thread)
tokio::sync::Mutex (async-aware, cross-thread)
channels (ownership transfer instead of sharing)
Rule of thumb: if you reach for Rc<RefCell<...>> frequently, redesign around message passing or explicit state structs.
Enums and Pattern Matching
- Prefer enums over boolean flags and over "nullable fields"; model the domain so invalid states do not compile.
matchmust be exhaustive. Use#[non_exhaustive]on public enums so downstream matches can add a wildcard.let ... elsefor early exit without deep nesting.matches!(x, Pat if guard)for boolean tests.if letchains (if let Some(x) = a && let Some(y) = b) stabilize with edition 2024; verify upstream before relying on them in older editions.
#[derive(Debug)]
enum Job { Queued, Running { pid: u32 }, Done(ExitStatus), Failed(String) }
fn label(job: &Job) -> &'static str {
match job {
Job::Queued => "queued",
Job::Running { .. } => "running",
Job::Done(_) => "done",
Job::Failed(_) => "failed",
}
}
let Some(cfg) = load_config() else { return Err(Error::NoConfig) };
Option/Result combinator quick table
| Need | Use |
|---|---|
| default | unwrap_or, unwrap_or_else, unwrap_or_default |
| convert to Result | ok_or, ok_or_else |
| chain fallible | and_then, Result::and_then |
| branch on both | match, map_or, map_or_else |
| swap Result<Option<_>> | .transpose() |
| replace with owned | take() |
| merge Option<Result<_>> | and_then(|r| r.ok()) |
Closures
- Closure traits:
Fn(callable via&),FnMut(via&mut),FnOnce(consumes captured values). Functions implement all three. - Capture modes follow usage;
moveforces ownership, which is what'staticspawns need. - Edition 2021+ captures disjoint fields of a struct instead of the whole struct (closures capture precision). This can change drop timing; be explicit when it matters.
- Pass closures as
impl Fn(...), generic parameters, orBox<dyn Fn(...)>when naming/routing is needed. - A closure that mutates captured state cannot be called through
&self. If a struct stores a callback, decide up front whether it isFnMutand expose&mut self.
fn retry<F: FnMut() -> Result<(), E>, E>(attempts: usize, mut f: F) -> Result<(), E> {
for _ in 0..attempts {
match f() { Err(e) => last = Some(e), Ok(()) => return Ok(()) }
}
Err(last.expect("attempts > 0"))
}
Iterators
- Iterators are lazy; adapters (
map,filter) do nothing until a consumer (collect,sum,for_each,count,fold) runs. collect::<Result<Vec<_>, _>>()short-circuits on first error — the idiomatic way to parse a sequence.iter()yields&T;iter_mut()yields&mut T;into_iter()yieldsT. On a&Vec<T>,into_iter()already yields&T.- Prefer iterator chains over index loops: bounds checks usually elide and intent is clearer.
- Watch for: intermediate
collect()followed by another iteration,Vecgrowth withoutwith_capacity, andclone()inside adapters. Vec<T>specifics:drain(..).for_each(...)removes and consumes;retainmutates in place;split_at_mutyields two disjoint mutable slices (a standard borrow-checker fix).
let nums: Vec<i32> = lines.iter().map(|l| l.parse::<i32>()).collect::<Result<_, _>>()?;
let total: i32 = nums.iter().filter(|n| **n > 0).sum();
Common Borrow-Checker Fights and Fixes
| Error | Typical cause | Fix |
|---|---|---|
| E0382 use of moved value | value used after pass/return | borrow it, clone() deliberately, or restructure ownership |
| E0499 two mutable borrows | two &mut into same container | split via destructuring, split_at_mut, iterate over fields |
| E0502 mutable + shared borrow | read while mutating | compute the read into a local first; scope the borrow |
| E0505 moved while borrowed | value dropped/moved while borrowed | clone, reorder drop, mem::take |
| E0515 returning local reference | returned temporary/local | return owned (String, Vec) or Cow |
| E0597 borrowed value does not live long enough | reference outlives source | own the data or shorten the borrow |
| E0716 temporary dropped while borrowed | binding a reference to a temporary | bind the temporary first with let |
| E0507 cannot move out of borrowed content | moving a field out of &self | mem::take, Option::take, clone, or match by reference |
| E0596 cannot borrow as mutable | binding not mut, or shared &self | add mut, take &mut, or use interior mutability |
| E0277 Send/Sync not satisfied | non-thread-safe value crosses threads | see ./03-async-concurrency.md |
| E0106 missing lifetime | struct/fn returns borrow without source | add lifetime parameter tied to input |
// E0499 fix: split one &mut Vec into two disjoint slices
let (left, right) = data.split_at_mut(mid);
left[i] += right[j];
// E0507 fix: move a field out of &mut self
let task = self.queue.pop_front().take(); // no clone
Diagnosis strategy: read the compiler's "first borrow occurs here / second borrow occurs here" span pair; the fix is usually to shorten one borrow, not to fight the compiler. Add clone() only after you can justify why sharing is wrong.
Anti-Patterns
Rc<RefCell<T>>graphs everywhere instead of ownership boundaries or message passing.clone()to silence E0382 without understanding sharing.unwrap()on lock/parse results in library code (see./04-error-handling.md).- Public structs with
pubfields that encode invariants impossible to maintain; use constructors and accessors. - Returning
&str/&Tfrom a function backed by an internal cache without a lifetime story. - Overusing
'staticbounds on async traits to dodge lifetime errors. - Index loops where iterators are clearer and safer.
Checklist
- [ ] Types encode valid states; invalid states do not compile where practical.
- [ ] Borrows are short and scoped; no
clone()without a stated reason. - [ ]
Rc/Arcused only for genuine shared ownership; cycles useWeak. - [ ] No
std::synclock guard held across.await. - [ ] Public enums are
#[non_exhaustive]when extension is expected. - [ ] Iterators replace manual index loops; no accidental intermediate collections.
- [ ] Every borrow-checker workaround is explained in code or review notes.
02-type-system-traits
Type System and Traits
Scope: traits, associated types, generics versus dyn, blanket impls, marker traits, newtypes, conversions, operator traits, and std::error::Error integration.
Trait Design Fundamentals
- A trait is a contract plus optional default methods. Keep required methods minimal; implement defaults in terms of them.
- Supertraits (
trait Conn: Send + Sync) express requirements, not inheritance. Prefer composing small traits over deep supertrait chains. - Methods taking
&selfvs&mut selfvsselfdefine the object-safety and usability surface. ReturningSelfor generic methods makes a trait non-object-safe. - Coherence: a downstream crate cannot implement a foreign trait for a foreign type. Design extension points with local traits or newtypes when downstream impls are expected.
pub trait Store {
type Error;
fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Self::Error>;
fn put(&mut self, key: &str, value: &[u8]) -> Result<(), Self::Error>;
fn contains(&self, key: &str) -> Result<bool, Self::Error> {
Ok(self.get(key)?.is_some()) // default method
}
}
Associated Types vs Generic Parameters
| Question | Associated type (trait Trait { type Out; }) | Generic parameter (trait Trait<T>) |
|---|---|---|
| One impl per type? | yes, by construction | multiple impls possible |
| Caller names the type? | no; inferred from impl | yes, turbofish |
| Object safety | usually keeps dyn possible | rarely dyn-compatible |
| Typical use | Iterator::Item, Store::Error | From<T>, PartialEq<Rhs> |
| Ergonomics | no type annotation at call sites | annotation/noise at call sites |
Rule: if the trait conceptually has exactly one output per implementor, use an associated type. If a type may implement the trait for several parameter types (Add<Meters> and Add<Feet>), use generics.
Generics vs dyn (Static vs Dynamic Dispatch)
| Criterion | Generics (impl Trait/<T: Trait>) | dyn Trait |
|---|---|---|
| Dispatch | static, inlinable | vtable, indirect |
| Binary size | monomorphized per type | one copy |
| Heterogeneous collections | no | yes (Vec<Box<dyn Trait>>) |
| Compile time | worse as combinations grow | better |
| Object safety required | no | yes |
| Plugin/runtime selection | no | yes |
- Prefer generics in hot paths and library APIs; prefer
dynat process boundaries (plugins, heterogeneous lists, avoiding combinatorial explosion). impl Traitin argument position is sugar for a generic parameter; in return position it hides a concrete (possibly unnameable) type.- RPITIT (
-> impl Traitin trait methods) is stable on modern Rust (1.75+, verify upstream). It makes traits non-object-safe unlessdynsupport is designed. - Boxed trait objects allocate:
Box<dyn Error + Send + Sync>costs one allocation. Accept it at boundaries, avoid in tight loops.
fn total<I: Iterator<Item = u64>>(it: I) -> u64 { it.sum() } // static
fn total_boxed(it: Box<dyn Iterator<Item = u64>>) -> u64 { it.sum() } // dynamic
Object safety quick check
A trait can be used as dyn Trait if it has no generic methods, no Self-returning methods (except where Self: Sized), no associated constants, and Self: Sized is not required. Add where Self: Sized on non-object-safe methods to keep the rest dyn-compatible.
Blanket Impls and Coherence
- Blanket impl:
impl<T: Display> Loggable for T. Powerful, but any downstream type automatically gets it and overlap becomes likely. - The orphan rule allows
impl LocalTrait for ForeignTypeandimpl ForeignTrait for LocalType. - Overlap checks are conservative; two blanket impls that "obviously" do not overlap still conflict. Use newtypes or sealed traits to disambiguate.
- Common std blanket impls to know:
From<T> for T,From<T> for Option<T>,Into<T> for T,Borrow<T> for T,AsRef<T> for T,Anyfor'statictypes.
pub trait Encode { fn encode(&self, out: &mut Vec<u8>); }
impl<T: serde::Serialize> Encode for T { /* blanket */ }
// Now a manual impl for a local type would conflict; use a newtype instead.
Marker Traits and Auto Traits
| Trait | Meaning | Opt-out |
|---|---|---|
| Send | can move across threads | PhantomData<*const ()> or Rc field |
| Sync | can be shared by reference across threads | Cell/RefCell field |
| Unpin | safe to move after pinning | PhantomPinned |
| Sized | known size at compile time | ?Sized bound |
| Copy | bitwise duplicate, no drop | manual Clone |
| UnwindSafe | safe to observe after panic | AssertUnwindSafe wrapper |
Send/Syncare auto traits: composed structurally from fields.unsafe impl Send for MyType {}is an invariant you now own; document why.- Negative impls (
impl !Send) remain unstable for user types; use marker fields to remove auto traits. PhantomData<T>communicates ownership, variance, and auto-trait behavior for types with no field of that type.
Newtypes and Type-State
- Newtype:
struct UserId(u64);avoids primitive obsession, enables trait impls on foreign types, and centralizes validation. - Add
#[repr(transparent)]when the wrapper must have the same ABI as the inner type (FFI). - Implement
From<Inner>only when the conversion is infallible and identity-preserving; use atry_newconstructor for validation. - Type-state: encode lifecycle in a type parameter (
Request<Draft>,Request<Sent>) so invalid transitions fail to compile. Cost: more generics and sometimes harderdynintegration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Email(String);
impl Email {
pub fn parse(raw: &str) -> Result<Self, EmailError> {
let raw = raw.trim();
if raw.contains('@') && !raw.contains(char::is_whitespace) {
Ok(Self(raw.to_ascii_lowercase()))
} else {
Err(EmailError::Invalid)
}
}
}
impl std::fmt::Display for Email { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } }
Deref anti-pattern
Do not implement Deref to gain method forwarding on non-pointer types. It leaks the inner API, confuses inference, and breaks dyn bounds. Use explicit methods or AsRef. Deref is for smart pointers and wrapper types whose entire purpose is delegation.
Conversions
| Trait | Direction | Failure | Convention |
|---|---|---|---|
| From<T> | infallible | no | also gives Into |
| TryFrom<T> | fallible | Error assoc type | also gives TryInto |
| AsRef<T> | borrowed view | no | cheap, non-consuming |
| Borrow<T> | borrowed view with semantic equality | no | map keys/lookups |
| FromStr | parse from &str | Err | "x".parse::<T>() |
| Serialize/Deserialize | data mapping | varies | serde |
- Implement
From, getIntofree. Never implement both. - Use
AsRef<Path>for path-like parameters andimpl Into<String>for owned string parameters at API boundaries. Borrowdiffers fromAsRef:String: Borrow<str>promisesHash/Eqagree withstr, which is what makesHashMap<String, _>::get("key")work.- Keep conversions lossless. If a conversion truncates (
u64 -> u32), make itTryFromor name ittruncate_to_u32.
impl From<io::Error> for AppError { fn from(e: io::Error) -> Self { Self::Io(e) } }
impl TryFrom<&str> for Port { type Error = PortError; fn try_from(s: &str) -> Result<Self, _> { /* ... */ } }
Operator Traits and Core Contracts
| Trait | Operator | Notes |
|---|---|---|
| Add, Sub, Mul, Div | + - * / | take self/&self; define AddAssign for mutation |
| Neg, Not | - ! | Not on booleans is logical |
| Index, IndexMut | [] | may panic; document bounds behavior |
| PartialEq/Eq | == != | Eq requires reflexivity; floats are only PartialEq |
| PartialOrd/Ord | < <= > >= | implement Ord with a total, consistent order |
| Hash | hashing | must agree with Eq; derive both together |
| Display/Debug | {}/{:?} | Display is user-facing, Debug is diagnostics |
| Deref | * | see anti-pattern above |
| Drop | scope exit | cannot be called directly; use mem::drop |
- Derive
Debugon every public type; include it indeny(missing_debug_implementations)policies. - Manual
PartialEqwithoutHashconsistency silently breaksHashMap. - For newtypes used as keys, derive
PartialEq, Eq, Hashor implement all three with the same field set.
Error Trait Integration
- A custom error type should implement
std::error::Error(viathiserroror manually),Display, andDebug. Error::source()exposes the causal chain;anyhowand tracing walk it. Providesourcevia#[from]/#[source]attributes.Box<dyn std::error::Error + Send + Sync + 'static>is the universal erased error; use it at API boundaries, not internally.- Never implement
Errorfor a type whoseDisplayis empty or whose message duplicates the source without context.
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("key not found: {key}")]
NotFound { key: String },
#[error("backend unavailable")]
Backend(#[from] tokio::io::Error),
}
See ./04-error-handling.md for the full policy on thiserror vs anyhow and boundary conversion.
Advanced Patterns
- Sealed traits:
pub trait Foo: private::Sealedprevents downstream impls while keeping the API public. Use for extension points you intend to grow. - Extension traits: define a local trait with methods for a foreign type to add behavior (
trait VecExt { ... } impl<T> VecExt for Vec<T>). - GATs (generic associated types,
type Item<'a>) are stable on modern Rust; use for lending iterators and view types. They add complexity — verify need first. #[non_exhaustive]on public structs/enums reserves the right to add fields/variants.- Hygiene of derive:
#[derive(Clone)]requires all fieldsClone; aderivefailure names the field, so fix the field rather than writing a manual impl that panics.
Anti-Patterns
- Trait-per-type instead of functions/structs (
UserServiceTraitfor a single impl, never mocked): only introduce traits at seams you substitute or mock. Dereffor code reuse.- Generic explosion: 4+ type parameters on public types instead of boxing or enum dispatch.
impl Traitin return position hiding a type callers must name for traits (e.g., returningimpl Iteratorwhen callers needExactSizeIterator).- Blanket impls over foreign traits that create coherence conflicts downstream.
Clonederived on handle/resource types that should be moved, not duplicated.anyhow::Errorin a public library API (see./04-error-handling.md).
Checklist
- [ ] Traits exist at real seams; no speculative abstraction.
- [ ] Associated types used for one-output contracts; generics for multi-impl contracts.
- [ ]
dynused only where heterogeneity or runtime selection is required. - [ ] Newtypes validate in constructors; no public field that breaks invariants.
- [ ]
From/TryFromused instead of ad-hocto_*conversion methods. - [ ] Error types implement
Error,Display,Debugwith a usefulsourcechain. - [ ] Every
unsafe impl Send/Synchas a written justification. - [ ] Derived
PartialEq/Hash/Ordare mutually consistent.
03-async-concurrency
Async and Concurrency
Scope: tokio runtime tuning, tasks, cancellation and cancellation safety, structured concurrency, channels, select!, backpressure, Send/Sync for futures, and async traits.
Runtime Selection and Tuning
| Runtime | Use when | Notes |
|---|---|---|
| #[tokio::main] multi-thread | servers, CPU+IO mix | default worker threads = logical CPUs; good default |
| #[tokio::main(flavor = "current_thread")] | CLI tools, tests, single-threaded embedders | lower memory, deterministic polling order |
| Runtime::new manual | library owns runtime, custom config | build once, never per request |
| spawn_blocking pool | filesystem, CPU-bound, FFI, DNS via blocking libs | bounded; default cap 512 (verify upstream) |
| LocalSet | non-Send futures | single-thread only |
| Other (smol, async-std, embassy) | niche/embedded | see ./07-embedded.md |
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.max_blocking_threads(64)
.thread_name("app-worker")
.enable_all()
.build()?;
- Never call
block_oninside async context. Usespawn_blockingor an async equivalent. - Blocking syscalls on worker threads starve timers and IO. Audit
std::fs,std::net,std::process, and synchronous DB drivers. tokio::time::pause()in tests makes time deterministic; requirestest-util.- Enable only needed features (
rt-multi-thread,net,time,sync,macros) to reduce build time and binary size. - Tokio Console (
console-subscriber) is the tool for diagnosing task stalls and waker storms; keep it behind a dev feature. - Graceful shutdown belongs to the application: a
CancellationTokenplus drainedJoinSet/TaskTracker. See./06-web-data.mdfor HTTP wiring.
Tasks
tokio::spawnrequiresF: Future + Send + 'staticand returnsJoinHandle<T>. The future must own everything it uses.JoinHandlereturnsResult<T, JoinError>;JoinError::is_panic()distinguishes panic from cancellation.JoinSetmanages a dynamic group: spawn,join_next().await, and abort remaining tasks on drop (verify exact behavior for your toolchain upstream).TaskTracker(tokio-util) plusCancellationTokenis the canonical structured-concurrency pair.AbortHandle/JoinHandle::abortstops at the next await point; it does not run cleanup. Design for drop-based cleanup and cancellation tokens.- Task-local state (
tokio::task_local!) is per-task context that follows spawned work only if re-scoped insidespawn. Prefer passing anArc<Context>explicitly.
let mut set = JoinSet::new();
for shard in shards {
set.spawn(async move { process(shard).await });
}
while let Some(res) = set.join_next().await {
match res { Ok(Ok(())) => {}, Ok(Err(e)) => error!(?e, "shard failed"), Err(je) => warn!(?je, "join failed") }
}
Cancellation and Cancellation Safety
- Dropping a future cancels it. Cancellation happens at
.awaitpoints; code between awaits runs to completion or not at all. - A future is cancellation-safe if dropping it cannot lose data that was already committed from its perspective.
tokio::io::AsyncReadExt::readis not safe to cancel midway if you must not lose bytes;read_exactis not;AsyncWriteExt::write_allis not. - Cancellation-safety table (common ops):
| Operation | Safe to cancel? | Guidance |
|---|---|---|
| TcpStream::connect | yes | retry is free |
| read/read_buf | no | bytes may be lost; keep buffer/state |
| read_exact/read_to_end | no | wrap in a task or buffer |
| write/write_all/flush | no | partial writes |
| sleep/timeout | yes | no data |
| mpsc::Sender::send | no (may be cancelled after reserve) | use reserve explicitly if needed |
| Semaphore::acquire | no | permit may be acquired and dropped |
| JoinHandle | yes | abort semantics |
| Receiver::recv | yes (for mpsc), but messages may be unreceived | design idempotent handling |
select!polls branches in random order by default; each unselected branch is dropped, so all branches must be cancellation-safe. Usebiased;for priority, and always add a default path orelsebranch when needed.- Wrap whole critical sequences in
tokio::spawnif they must survive aselect!loser drop. CancellationToken(tokio-util) composes:child_token,cancelled().await,run_until_cancelled. Cancel is idempotent and wakes all waiters.
tokio::select! {
_ = token.cancelled() => return Ok(()),
res = do_work(&mut state) => res?,
}
timeout(d, fut)dropsfuton expiry: only use around cancellation-safe futures; otherwise spawn it and await the handle with a timeout.- Graceful shutdown sequence: stop accepting (drop listener) -> cancel token -> await tasks with a deadline -> run cleanup -> exit. Never
process::exitbefore drains unless aborts are acceptable.
Structured Concurrency
- Own every task: store handles, join them, and propagate errors. Fire-and-forget tasks are a leak and an observability hole.
- A supervision pattern: one coordinator owns a
JoinSet, restarts or records failed children, and exposes a single completion future to the caller. - Backpressure must be end-to-end: bounded channels +
Semaphore+ connection limits. Unbounded queues convert overload into OOM. - Prefer scoped APIs (
JoinSet,TaskTracker,tokio::task::scopewhen available/futuresscope) so no task outlives its data.
Channels
| Channel | Producers | Consumers | Semantics | Use |
|---|---|---|---|---|
| mpsc | many | one | ordered queue, bounded/unbounded | work queues, actor inboxes |
| oneshot | one | one | single value | request/response, task results |
| broadcast | many | many | every receiver sees every message (lagging drops oldest) | events, shutdown fan-out |
| watch | one | many | latest value only | config updates, state snapshots |
| Notify | many | many | wake-up signal, no payload | condition-style coordination |
| Semaphore | n/a | n/a | permit pool | concurrency limits |
- Always prefer bounded
mpscfor work queues.send().awaitapplies backpressure;try_sendsurfacesFullfor load shedding. broadcast::Receivertreats lag asRecvError::Lagged(n); decide whether to resync or fail. Default capacity must be sized for burst rate, not steady state.watchrequiresT: Cloneand stores one value; good for "current config" and shutdown flags, bad for event streams.- Cross-process coordination needs an external broker (NATS, Redis streams, Kafka); in-process channels do not survive restart.
- Avoid sharing
Arc<Mutex<HashMap>>as a "channel"; a task owning the map plus an mpsc inbox removes lock contention and makes ownership obvious.
Send/Sync for Futures and Tasks
- A future is
Sendiff all values held across.awaitpoints areSend. The compiler points at the offending binding. - Typical offenders:
Rc,RefCell,MutexGuard, non-Sendtrait objects,*const T, and&TwhereT: !Sync. - Holding a
std::sync::MutexGuardacross.awaitproduces a non-Sendfuture and potential deadlocks. Scope the guard before awaiting, or usetokio::sync::Mutexwhen the section genuinely awaits. - Spawning non-
Sendwork requiresLocalSeton a current-thread runtime. Sendbounds propagate: anasync fngeneric overTmay only beSendifT: Send; add bounds at the public boundary, not everywhere.- Beware
asyncblocks capturing&mut selfacross awaits; split the method or move owned state in.
// BAD: guard held across await (non-Send, deadlock risk)
let guard = self.inner.lock().unwrap();
some_async_call().await;
drop(guard);
// GOOD
{ let mut g = self.inner.lock().unwrap(); g.update(); }
some_async_call().await;
Blocking, Timeouts, and Retries
spawn_blockingfor anything that can take >100us of CPU or uses blocking IO. Its pool is bounded; a saturated pool queues.- CPU-bound parallelism should be a rayon pool, not many blocking tasks (see
./10-performance-security-observability.md). - Timeouts everywhere at boundaries: connect, request handling, DB queries, shutdown. Track remaining deadline (
tokio::time::Instant) through retries. - Retries: exponential backoff with jitter (
backoff,tower::retry, or hand-rolled). Only retry idempotent operations; propagate the original error after N attempts. - Circuit breakers (
towermiddleware or a custom state machine) prevent retry storms.
Async Traits (AFIT and dyn)
- AFIT:
async fnin traits is stable (Rust 1.75+, verify upstream) and lowers to RPITIT. It is notdyn-compatible by default. - Making async traits
dyn-compatible:#[async_trait](crateasync-trait) boxes every method future — allocation per call, works on all targets, supportsdyn.- Return-position
impl Futureplus manualPin<Box<dyn Future>>signatures. trait-variantgenerates aSend-bounded twin via a macro.
async fnin a public trait is fine for static dispatch; addSendbounds at the trait or impl level when tasks spawn the returned future.- Async closures (
async || {},AsyncFn/AsyncFnMut/AsyncFnOnce) are stable in edition 2024 (Rust 1.85+, verify upstream); they simplify callback APIs that spawn. - Cancellation safety of returned futures is part of the contract; document it for each trait method.
#[async_trait::async_trait]
pub trait Fetcher: Send + Sync {
async fn fetch(&self, id: u64) -> Result<Vec<u8>, FetchError>;
}
Anti-Patterns
- Blocking calls in async context (
std::fs,std::thread::sleep, synchronous HTTP/DB clients). tokio::spawnwith a detached handle and no error logging; failures vanish.- Unbounded channels hiding overload.
select!over cancellation-unsafe branches (partial reads/writes).- Locking an async mutex for short CPU-only sections (use
stdmutex, no await). - Runtime per request (
Runtime::newin a handler). - Using
tokio::time::sleepto "fix" races instead of proper synchronization. - Spawning inside loops without a semaphore or join tracking.
Checklist
- [ ] Every spawned task has an owner, a join path, and error handling.
- [ ] Cancellation behavior is understood for each
select!/timeoutbranch. - [ ] Shutdown is token-driven and drains with a deadline.
- [ ] All queues are bounded; overload behavior (block vs shed) is explicit.
- [ ] No blocking calls or lock guards across
.await. - [ ]
Send/Syncbounds appear where tasks require them; no gratuitous'staticboxing. - [ ] Timeouts and retries with jitter are applied at every external boundary.
04-error-handling
Error Handling
Scope: Result/Option idioms, thiserror versus anyhow policy, error context and chains, panic and unwrap/expect discipline, and conversion at architectural boundaries.
The Error Decision Table
| Situation | Representation |
|---|---|
| Library public API | custom enum, thiserror or hand-written Error impl |
| Binary/application internals | anyhow::Result<T> (context-rich, type-erased) |
| Binary top level (main) | anyhow::Result<()> or ExitCode |
| Boundary between layers | convert library error into app error (From/map_err) |
| Recoverable, expected absence | Option<T> |
| Infallible by construction | plain T, not Result |
| Erased trait-object boundary | Box<dyn Error + Send + Sync + 'static> |
| FFI/panic boundary | no error type; status code / catch_unwind (see ./05-unsafe-ffi.md) |
Policy in one line: libraries use typed errors; applications use anyhow; every conversion at a boundary preserves the cause.
Result and Option Idioms
?propagates and converts viaFromwhen the function's error type can be built from the inner error.ok_or_else(|| ...)mapsOptiontoResultwithout allocating on the happy path;ok_oris fine for non-allocating errors.transpose()convertsResult<Option<T>, E>and back; useful with fallible fetch-or-none APIs.map_erradds context or maps to a domain error; avoidmap_err(|_| MyError::Unknown)which drops the cause.Result::and_thenchains fallible steps without nesting;inspect/inspect_err(Rust 1.76+, verify upstream) for logging without altering the value.tryblocks remain unstable on stable Rust; do not design APIs that need them.maincan returnResult<(), E: Debug>; for custom exit codes returnExitCodefromstd::process.
fn load(path: &Path) -> anyhow::Result<Config> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading config {}", path.display()))?;
let cfg = toml::from_str(&text)
.with_context(|| format!("parsing config {}", path.display()))?;
Ok(cfg)
}
thiserror vs anyhow
| Aspect | thiserror | anyhow |
|---|---|---|
| Purpose | define error types | consume/erase errors |
| Location | library crates | binaries, app internals, tests |
| Downcastable | yes, pattern-match variants | yes via chain/downcast, but discouraged for control flow |
| Context | fields + #[source] | .context() / .with_context() |
| Public API | recommended | forbidden: leaks opaque type, breaks versioning and matching |
| Conversion boilerplate | #[from] generates | not applicable |
- Never expose
anyhow::Errorfrom a published crate. It changes type identity with versions and provides no stable contract. - Library error enums: one variant per actionable failure mode, plus a catch-all
#[error(transparent)] Other(#[from] ...)only where genuinely opaque. - Derive
Debug; implementDisplayviathiserrormessages that are lowercase, no trailing period, and name the operation and subject. - Mark constructors and source fields with
#[from],#[source], or#[error(transparent)]correctly:transparentexcludes the source from being printed twice. anyhow!("...")/bail!(...)for ad-hoc failures inside binaries; still attach chains to boundary errors.
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("invalid credentials for {user}")]
Invalid { user: String },
#[error("token expired")]
Expired,
#[error(transparent)]
Store(#[from] StoreError),
}
Error Context and Chains
- Context answers "what was the program doing" at the error, not just "what failed". Include the operation and the resource id/path, never secrets.
- Build context lazily (
with_context(|| ...)) so the allocation is skipped on success. - Print full chains:
{:#}for anyhow prints the whole chain on one line;{:?}prints source chain plus backtrace when enabled. std::backtrace::Backtraceis stable (1.65+);anyhowcaptures a backtrace whenRUST_BACKTRACE=1(orfull) is set at compile/run time. Verify behavior for your toolchain.- Do not log and return the same error at every level; log once at the boundary where it is handled, with the chain intact.
- Preserve typed errors for programmatic handling (e.g., HTTP 404 vs 500); use
downcast_refonly at the edge.
Panic Policy
| Context | Policy |
|---|---|
| Library code | never panic on user input; return Result |
| main/startup (bad config) | panic acceptable: fail fast, no partial service |
| Tests | panics are assertions |
| FFI boundary | catch (catch_unwind) or document unwind across extern "C" as forbidden |
| Task/thread worker | catch at task boundary, log, keep supervisor alive |
| Holding a lock | avoid panics; a panic poisons std::sync::Mutex |
| panic = "abort" builds | process dies; use only if crash-only design is intentional |
panic!,assert!,assert_eq!,unreachable!,todo!,unimplemented!, slicing/indexing, integer overflow (debug),.unwrap(),.expect(), division by zero, andRefCelldouble-borrow all panic.- Do not use
catch_unwindfor control flow or expected errors; it is for quarantine/supervision, and requires the boundary to beUnwindSafeor wrapped inAssertUnwindSafe. - Edition 2024/
extern "C-unwind"and"C"unwind behavior differ; pick the ABI deliberately when panics may propagate. Verify upstream for your targets. - Log panics: install a panic hook that records the message, location, and backtrace (tracing or
human-panicfor CLIs).
unwrap/expect Discipline
expect("reason")is allowed when the invariant is local and provable: literals parsed at startup, states just checked,Mutex::lockwhen a poisoned lock should crash anyway..unwrap()without a message is banned by policy in production code. Enforce with clippy lints:
# clippy.toml / Cargo lints
[workspace.lints.clippy]
unwrap_used = "deny"
expect_used = "warn"
panic = "warn"
indexing_slicing = "warn"
- Allow locally where justified:
#[allow(clippy::unwrap_used, reason = "compile-time regex")](lint reason attributes supported on modern Rust; verify upstream). - Never
unwrap()on: parsing external input, environment variables, file/network operations,JoinHandlein production without logging,Mutexin libraries, orSystemTimearithmetic. - Prefer
let else,?,unwrap_or_default, or explicit match overis_some()+unwrap()(a fragile double lookup). - Treat
Option::unwrapin tests as acceptable; tests should fail loudly.
Conversion at Boundaries
- Convert at the edge: each layer owns its error type; adapt with
Fromimplementations or a singlemap_errat the call site. - HTTP boundary: map domain errors to
IntoResponse/StatusCodecentrally (oneimpl IntoResponse for AppError), not per handler. - DB boundary: distinguish constraint violation, not-found, and connectivity at the repository layer; expose domain variants upward.
- gRPC/IPC boundary: map to
tonic::Statuscodes with messages safe for clients; log the full chain internally. - Task boundary:
JoinError-> application error; include the task name. - Serialization boundary:
serde_json::Errorwith line/column goes into context; do not expose raw parser messages to end users.
impl axum::response::IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let status = match &self {
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
if status.is_server_error() { tracing::error!(error = ?self, "request failed"); }
(status, status.canonical_reason().unwrap_or("error")).into_response()
}
}
Anti-Patterns
- String-only errors:
Result<T, String>loses structure, source, and downcasting. map_err(|_| ...)discarding the cause.- Logging an error and also returning it, causing duplicate logs (or none at the top).
unwrap()in request paths; one malformed input crashes a worker.- Catch-all enum variant that swallows every error type (
Other(String)). - Using panics for validation; returning a
Resultis the contract. expect("this should never happen")without naming the invariant.- Treating
OptionandResultinterchangeably by smugglingNoneas an error with no context.
Checklist
- [ ] Libraries expose typed errors; applications use
anyhowinternally. - [ ] Every
?conversion has aFromimpl or explicitmap_errwith context. - [ ] Error messages name the operation and subject, contain no secrets, and are lowercase.
- [ ] No
unwrap()in production paths;expectmessages state invariants. - [ ] Panics are impossible on parsed user input; FFI boundaries never unwind.
- [ ] One place per boundary maps errors to wire formats and logs once.
- [ ]
RUST_BACKTRACE/chain printing is available in staging and production logs. - [ ] Clippy
unwrap_used/paniclints enabled with documented allowances.
05-unsafe-ffi
Unsafe, Soundness, and FFI
Scope: when unsafe is justified, raw pointers and aliasing rules, Pin, building sound safe abstractions, Miri, C ABI and bindgen, pyo3/wasm-bindgen interop, and auditing unsafe code.
The Soundness Contract
unsafedoes not disable the borrow checker; it grants five extra powers:- Dereference raw pointers.
- Call
unsafefunctions (including foreign functions). - Access fields of a
union. - Mutate a mutable
static(edition 2024 requires&raw mutfor references; verify upstream). - Implement an
unsafetrait (unsafe impl Send/Sync).
- Soundness means: no safe code using this API can trigger undefined behavior, for all inputs and all interleavings, including other threads.
- UB examples: data races, dangling/unaligned pointers, out-of-bounds reads/writes, aliasing violations (two
&mutto the same memory), invalid values for a type, calling a function with the wrong ABI. - Rules: keep
unsafeblocks minimal, document each with a// SAFETY:comment stating the invariant, and expose a safe wrapper whose type system prevents misuse. - Enable
unsafe_op_in_unsafe_fn(warn by default, deny in edition 2024): every unsafe operation gets its own explicitunsafe {}block insideunsafe fn. #[unsafe(no_mangle)]/#[unsafe(export_name = "...")]in edition 2024 make the risky part explicit; older editions accept bare attributes with a lint.
/// # Safety
/// `ptr` must be valid for reads of `len` initialized bytes and aligned.
unsafe fn checksum(ptr: *const u8, len: usize) -> u32 {
assert!(!ptr.is_null());
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
bytes.iter().fold(0u32, |acc, &b| acc.wrapping_add(b as u32))
}
Raw Pointers and Provenance
*const T/*mut Tcarry provenance in the abstract machine (Stacked Borrows/Tree Borrows); casting through integers (ptr as usize) strips it and is only sound if provenance is restored correctly. Preferptr::with_exposed_provenance/expose_provenance(1.84+, verify upstream) over raw integer casts.- Create with
&raw const x/&raw mut x(stable since 1.82, verify upstream) orstd::ptr::addr_of!/addr_of_mut!to avoid creating intermediate references. NonNull<T>is covariant, null-optimized, and documents non-null invariants; use it for internal pointers..as_ref()/.as_mut()on pointers areunsafeand require validity;&*ptrneeds alignment, non-null, dereferenceable, and initialized (unless the type permits uninit, e.g.,MaybeUninit).- Never derive references to uninitialized memory. Use
MaybeUninit<T>and onlyassume_initafter all fields are written. - Prefer safe alternatives first: slices with
get,split_at,Vec::spare_capacity_mut,bytemuck/zerocopyfor casts,bytemuck::Podfor plain-old-data.
Aliasing quick rules
| Reference | Rule |
|---|---|
| &T | no writes anywhere in the referent for its lifetime; many may coexist |
| &mut T | exclusive; no other &/&mut alias for its lifetime |
| UnsafeCell<T> | the only legal path to interior mutation; raw access must be synchronized |
| &Cell<T> | fine to mutate, but T: Copy and no reentrancy through the same &Cell |
| reborrow &mut *p | creates a fresh exclusive borrow; the old one is invalid until it ends |
Miri detects most violations. Aliasing rules are not a finalized spec; write code that satisfies both Stacked Borrows and Tree Borrows where possible.
Pin and Self-Referential Types
Pin<P>is a guarantee that the pointee will not move again, until it is dropped.Unpintypes ignore the guarantee.Pin<&mut T>: safe&mut Taccess viaget_mut()only ifT: Unpin; otherwiseunsafe { get_unchecked_mut() }with the structural-pinning invariant.- Never move a
!Unpinvalue after pinning.std::pin::pin!andBox::pinare safe constructors;Pin::newrequiresT: Unpin. - Async blocks are
!Unpinwhen they hold references across await; executors pin them in place. - Structural pinning: if a wrapper exposes a field through
Pin<&mut Field>and relies on field stability, it cannot implementDropthat moves the field, and unsafe projection code must uphold "never move" for that field. Usepin-project-lite/pin-projectinstead of hand-rolling projections. PhantomPinnedmakes a type!Unpin; it costs nothing.
use std::pin::{pin, Pin};
async fn demo() { let fut = pin!(some_async()); fut.await; }
Building Sound Safe Abstractions
- Pattern: a private owned buffer (
Vec,Box<[MaybeUninit<T>]>) plus a safe API that maintains initialization and aliasing invariants; unsafe is confined to a few methods. - Encapsulate:
struct TinyVec<T> { buf: Vec<T>, len: usize }internals are private; public methods take&self/&mut selfand uphold invariants. - Use
debug_assert!for invariants that safe callers cannot violate but bugs might. - Drop correctly: partially initialized values require dropping only initialized elements (
drop_in_placeon slices), and panics during drop need guards (DropGuard/ManuallyDroppatterns). unsafe impl Send/Syncneeds a proof: describe which fields, which synchronization makes cross-thread access safe, and why no aliasing rule is broken. If the proof needs more than three sentences, redesign.- Panics must not cross an unsafe abstraction boundary in a half-updated state; restore invariants before propagating panics.
Miri
cargo +nightly miri testinterprets MIR and checks UB, leaks, data races, and invalid values. Most useful flags:-Zmiri-strict-provenance,-Zmiri-symbolic-alignment-check,-Zmiri-many-seeds, and Tree Borrows (MIRIFLAGS=-Zmiri-tree-borrows; verify defaults as they change).- Limitations: no FFI (unless shims exist), only supported targets (host-like), much slower than native, and non-deterministic timing does not exist (races are modeled). Unsupported code produces "unsupported operation" errors rather than proof of soundness.
- Run Miri on the unsafe-heavy crate or module, not necessarily the whole workspace; use
#[cfg(miri)]shims for syscalls and time. - A clean Miri run is necessary but not sufficient: concurrency bugs under real schedulers still need loom (see
./08-testing.md) and stress tests. - CI: nightly toolchain plus
rustup component add miri,cargo miri setup, cache the sysroot.
C ABI and bindgen
extern "C"is the stable interchange ABI. Userepr(C)for structs crossing the boundary andrepr(transparent)for newtypes. Rust's defaultrepr(Rust)layout is unspecified.- Edition 2024:
externblocks requireunsafe extern "C" { ... }; choose"C-unwind"when panics or exceptions may propagate across the boundary and that behavior is intended. - Generation:
bindgen(C headers to Rust) in abuild.rs;cbindgen(Rust to C headers); keep generated files checked in or cached for reproducible builds. - Ownership rules must be explicit in the header docs: who allocates, who frees, which allocator. Freeing a Rust allocation with
free(3)(or vice versa) is UB with different allocators. - Strings:
CStr::from_ptris unsafe and requires NUL-termination;CString::newrejects interior NUL. Usestd::ffi::{c_char, c_int}rather thanlibctypes where possible. - Callbacks: never let unwinding cross
extern "C"(abort on panic or catch and translate); document threading expectations; pass an opaquevoid*context back to Rust. - Optional/error conventions: return status codes, use out-parameters for values, and translate errors into a stable C error enum. Never return Rust references that Rust may free later.
- Structs returned by value must be
#[repr(C)]and contain only C-compatible fields; avoidbool(useu8) and Rust enums (usec_uintconstants). - Cargo:
[lib] crate-type = ["cdylib", "staticlib"];panic = "abort"in the FFI profile if unwinding cannot be contained.
#[repr(C)]
pub struct Span { pub ptr: *const u8, pub len: usize }
#[unsafe(no_mangle)]
pub extern "C" fn span_free(s: Span) {
if !s.ptr.is_null() {
// SAFETY: ptr/len came from Box<[u8]> with the same allocator in this library.
drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(s.ptr as *mut u8, s.len)) });
}
}
Python and WebAssembly Interop
- pyo3 (0.2x line, verify upstream):
- Prefer the
Bound<'py, T>API;#[pyfunction]/#[pymodule];Python::attach/with_gilhas evolved — verify current naming upstream. - The GIL protects Python objects but not Rust data; do not block holding the GIL, release it for long Rust work (
py.allow_threadsor equivalent). - Map
Result<T, E>toPyErrwithFrom<E> for PyErr; keep error translation at the boundary. - Build with
maturin; useabi3wheels for forward compatibility with a version floor; profile release withlto = "thin". - Panics in
#[pyfunction]are converted to Python exceptions by the runtime, but prefer returningResult.
- Prefer the
- wasm-bindgen:
#[wasm_bindgen]for exported functions/structs;JsValuefor JS interop,web_sys/js_sysbindings.- Wasm linear memory is not shared: JS strings are copied; use typed arrays (
Uint8Array) for bulk data to avoid conversion overhead. wasm32-unknown-unknown+wasm-bindgen-cli(version must match the crate; verify) orwasm-packfor bundling.- Panics abort the wasm instance by default; use
console_error_panic_hookin dev for stack traces. - Test in Node/browsers with
wasm-bindgen-test; keep JS glue generated, not hand-edited.
Auditing Unsafe Code
- Inventory:
rg "unsafe",cargo geigerfor dependencies; track unsafe surface per crate and require review for increases. - Documentation: each public
unsafe fnmust have a# Safetydoc section; every unsafe block a// SAFETY:comment referencing the invariant it upholds. - Encapsulation audit: verify every public safe function keeps invariants (check constructor paths,
Default,Clone,Deserializederived impls — derive can smuggle invalid states past constructors). - Fuzzing:
cargo-fuzztargets for parsers and unsafe-heavy code paths; run under sanitizers (ASan/TSan) where Miri cannot (FFI). - Sanitizers:
-Zsanitizer=address/threadon nightly for FFI and cross-thread code; not a substitute for Miri's aliasing checks. - Keep unsafe code in a dedicated module or crate with tests that exercise edge cases, alignment, zero-length slices, and null handling.
- Dependencies:
cargo-deny/cargo-auditin CI; prefer well-maintained crates with documented safety posture for crypto and parsing.
Anti-Patterns
unsafeto defeat the borrow checker for convenience.- "I tested it once" as a soundness argument; no Miri, no fuzzing, no review.
- Returning raw pointers to stack data or references derived from temporaries.
mem::transmutefor type punning; preferbytemuck,zerocopy, or explicit conversion.unsafe impl Send/Syncon a type containingRc,RefCell, raw pointers, or interior mutability without synchronization.- Freeing memory across allocator or language boundaries.
- Letting panics unwind across FFI boundaries.
- Multiple
&mutderived from the sameUnsafeCellwithout synchronization. - Ignoring
MaybeUninitinitialization beforeassume_init.
Checklist
- [ ] Every
unsafeblock has a// SAFETY:comment and a test that would fail on violation. - [ ]
unsafeis minimized and wrapped in a safe API whose misuse cannot compile. - [ ] Miri passes for unsafe modules; tree-borrows mode checked.
- [ ] FFI types are
#[repr(C)]/#[repr(transparent)], ABI (including unwind) is explicit. - [ ] Ownership and allocator rules documented for both sides of FFI.
- [ ]
unsafe impl Send/Synchas a written proof and required synchronization. - [ ] Pinned types never move after pinning; projections use a vetted crate.
- [ ] CI tracks unsafe surface (
cargo geiger) and denies increases without review.
06-web-data
Web Services and Data Layer
Scope: axum versus actix-web, tower middleware, serde, database access (sqlx/diesel/sea-orm), migrations, configuration, auth patterns, and graceful shutdown.
Framework Decision Table
| Criterion | axum | actix-web |
|---|---|---|
| Ecosystem | tokio + tower + hyper, broad middleware reuse | custom runtime (actix-rt), mature plugin set |
| Extractors | trait-based, composable, typed | custom extractors via FromRequest |
| Middleware | tower::Layer (tower-http, tower limits, tracing) | Transform/Service, actix-specific |
| Learning curve | moderate, explicit types | moderate, macros (#[get("/")]) |
| WebSocket/gRPC | axum + tokio-tungstenite, tonic in the same server | actix actors, actix-ws; tonic separate |
| Performance | high (hyper-based) | high; benchmarks are workload-dependent — measure, do not trust microbenchmarks |
| Best fit | services already on tokio/tower; library-friendly | teams already on actix; heavy actor-style state |
Default for new projects in 2026: axum, because middleware, HTTP clients, gRPC (tonic), and tracing all share the tower/hyper stack. Choose actix-web when the team knows it and the application fits its model; do not mix both in one service.
axum Essentials
Routercomposition:Router::new().route("/x", get(h).post(h2)).nest("/api", api).layer(TraceLayer::new_for_http()).- State:
Router::with_state(state); extract withState<T>whereT: Clone + Send + Sync + 'static. Wrap inArcwhen non-trivial;Arcthe whole state once rather than per-field. - Extractors run in order: body-consuming extractors (
Json,Form) must be last. Extractors that fail return non-2xx by default viaIntoResponse. - Handlers return
impl IntoResponse; useResult<Json<T>, AppError>and oneIntoResponse for AppErrormapping. - Path/query:
Path<(u64, String)>,Path<Struct>,Query<Pagination>with serdeDeserialize. - Custom extractor: implement
FromRequestParts(no body) orFromRequest(consumes body) for auth/api-key. - Sharing HTTP clients:
reqwest::ClientisCloneand pools connections; build once in state with timeouts. axum0.7/0.8 era changed path syntax (/{id}instead of/:id) and removedasync_traitrequirements; verify exact API upstream.
#[derive(Clone)]
struct AppState { db: PgPool, http: reqwest::Client }
async fn get_user(State(s): State<AppState>, Path(id): Path<i64>) -> Result<Json<User>, AppError> {
let user = queries::user_by_id(&s.db, id).await?.ok_or(AppError::NotFound)?;
Ok(Json(user))
}
tower Middleware
- Layers wrap services; order matters: outermost runs first on request, last on response.
ServiceBuilder::new().layer(A).layer(B).service(svc)results inA(B(svc)). - Recommended stack order (outer to inner): request id -> tracing -> timeout -> concurrency limit -> compression -> catch panic.
- tower-http:
TraceLayer,TimeoutLayer,CompressionLayer,CorsLayer,RequestIdLayer,SetRequestIdLayer,RequestBodyLimitLayer,ServeDir/ServeFile. tower::limit::ConcurrencyLimitLayerandtower::load_shed::LoadShedLayerprotect the service; combine withtower::buffer::Buffercarefully (buffer adds a task and queue).- Rate limiting:
tower-governoror custom keyed limiter; token buckets need shared state (governor). ServiceBuildervsRouter::layer:Router::layerapplies to routes added before it;route_layeronly runs on matched routes (so 404s skip auth). Useroute_layerfor auth.- Middleware errors must map to responses; a layer returning a body must set content type and length consistently.
- Test layers by calling
router.oneshot(request)(towerServiceExt).
Serde
#[derive(Serialize, Deserialize)];#[serde(rename_all = "camelCase")],#[serde(deny_unknown_fields)]for strict external input,#[serde(default)]for optional fields.skip_serializing_if = "Option::is_none"for sparse payloads;flattensparingly (breaksdeny_unknown_fieldsand can hide errors).- Use
serde_withfor base64, hex, timestamps, and duration formats;chrono/timeserializers must be explicit. - Custom types: implement
Serialize/Deserializefor newtypes to validate at parse time (TryFrominsideDeserialize). - JSON:
serde_jsonis the baseline.simd-json/sonic-rsaccelerate hot paths with compatibility caveats (verify upstream); profile before switching. - Enum representations:
#[serde(tag = "type", rename_all = "snake_case")]internally tagged is common; untagged is last resort (poor errors, quadratic in variants). - Do not use
serde_json::Valuefor domain data; parse into typed structs at the boundary. - Version payloads explicitly (
#[serde(rename = "...")]legacy aliases) instead of relying on field-order.
Database Access
| Criterion | sqlx | diesel (+ diesel-async) | sea-orm |
|---|---|---|---|
| Style | async, SQL-first, compile-time checked macros | typed query builder + async in diesel-async | async ORM over sea-query |
| Compile-time SQL check | yes (query! against live DB or offline .sqlx cache) | yes via schema types | no (runtime), but strong typing |
| Migrations | sqlx migrate | diesel migration | sea-orm-cli migrate |
| Best fit | existing SQL, precise queries, Postgres/MySQL/SQLite | teams wanting ORM-like safety with Rust types | entity modeling, CRUD-heavy apps |
| Async | native | requires diesel-async | native |
- Pooling:
sqlx::postgres::PgPoolOptions— setmax_connectionsfrom DB limits and concurrency budget,acquire_timeout,max_lifetime,idle_timeout. Nevermax_connections= number of tasks. - Transactions:
let mut tx = pool.begin().await?; ...; tx.commit().await?;— dropping rolls back. Hold transactions short; never across external HTTP calls. - sqlx compile-time macros need
DATABASE_URLat build time orcargo sqlx prepare(SQLX_OFFLINE=truein CI). Commit.sqlx/for reproducible builds. - Use
query_as!/FromRowfor row mapping; add#[sqlx(rename = "...")]for column mismatches; prefer explicit SELECT columns overSELECT *. - Migrations: forward-only, immutable once applied; additive changes (new nullable column, backfill, then constraint) to allow rolling deploys. Never edit an applied migration.
- N+1 detection: log query counts in dev; batch with
WHERE id = ANY($1)orINSERT ... ON CONFLICT. - SQLite for embedded/small services with WAL mode and a single writer or a connection-pool bound of 1 for writes; libsql/turso for remote SQLite if needed.
- SeaORM entities generated from schema drift; re-run generation in CI to catch drift.
- Connection health: set
test_before_acquire/before_acquirehooks; handle transient failures with retries at the repository layer.
let rows = sqlx::query_as!(User,
"SELECT id, email, created_at FROM users WHERE id = $1", id)
.fetch_optional(&pool).await?;
Configuration
- Precedence: defaults < config file < environment < CLI flags. Document it once and test each layer.
figment(layered providers: TOML + env) or the simplerconfigcrate;envyfor env-to-struct only.dotenvyin dev only — never load.envin production.- Parse config into a typed struct at startup with
serde; fail fast with context (anyhowwith file/key names). Nostd::env::varscattered through the codebase. - Secrets: inject via environment or a secret manager (Vault, AWS Secrets Manager, SOPS); never commit them, never log them. Use
secrecy::SecretString/zeroizefor sensitive fields. - Validate config ranges at startup (port, pool size > 0, URL schemes); reject unknown keys in config files.
- Feature flags/tenancy config that changes at runtime belongs in a
watchchannel or the DB, notOnceLockreads.
Authentication and Authorization Patterns
- Password hashing: Argon2id (
argon2crate) with sane parameters (memory >= 19 MiB, iterations >= 2, parallelism 1 as OWASP baseline; verify current recommendations upstream). Never SHA/MD5, never unsalted. - Sessions: opaque random tokens stored server-side (hashed) with expiry and rotation; or signed cookies. Cookies:
HttpOnly,Secure,SameSite=Lax/Strict,__Host-prefix. - JWT (
jsonwebtoken): short-lived access tokens (5-15 min), refresh tokens server-side revocable, validatealgexplicitly (nevernone/unknown),aud,iss,exp,nbf. Public key set rotation via JWKS; cache keys. - API keys: store a hash, show once, prefix for identification, per-key scopes and rate limits.
- Authorization: enforce in the service layer (
fn can(user, action, resource) -> bool), not only in middleware; middleware handles authentication, handlers enforce per-resource access. Never trust client-supplied tenant/user ids. - OAuth/OIDC: use
openidconnectcrate or a gateway; implement PKCE; validatestateand nonce. - Rate-limit auth endpoints and return uniform errors/timings to avoid user enumeration.
- mTLS/service auth inside the mesh or with signed service tokens; do not rely on network location.
Graceful Shutdown
- Sequence: receive signal (SIGTERM/SIGINT) -> stop accepting -> drain in-flight requests with deadline -> close DB pools -> flush telemetry -> exit non-zero on timeout.
- axum:
axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()).shutdown_signalawaitstokio::signal::ctrl_c()and unixSIGTERM. - Background tasks:
CancellationToken+TaskTracker; cancel after HTTP drain or in parallel depending on semantics (e.g., stop consumers after in-flight writes complete). - Readiness: flip to not-ready before drain so load balancers stop routing; keep liveness green until exit.
- Pools:
pool.close().awaitafter handlers stop. - Timeouts: hard deadline (e.g., 30s) then abort; log remaining tasks. Kubernetes
terminationGracePeriodSecondsmust exceed the deadline. - Deserialization/queue workers: finish the current message, then cancel; make handlers idempotent for redelivery.
let token = CancellationToken::new();
let mut app = app.with_state(state);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal(token.clone()))
.await?;
token.cancel();
tracker.close().await;
Anti-Patterns
- Business logic in handlers; handlers should parse, call a service, map the result.
- Connection pool per request or unbounded pool size.
- Letting
serde_json::Valueleak into domain code. SELECT *plus struct field order assumptions.- Middleware ordering that runs auth after body parsing or skips 404s unintentionally.
- JWT
algaccepted from the token header; missing audience/issuer validation. - Editing applied migrations; mixing schema DDL into request handlers.
- Blocking shutdown: dropping the future before drain; no deadline.
- Storing secrets in
config.tomlcommitted to the repo.
Checklist
- [ ] Framework chosen deliberately; one HTTP stack in the service.
- [ ] State is
Clone + Send + Sync; no per-request clients. - [ ] tower layers ordered (trace -> timeout -> limits -> body) and tested.
- [ ] All external input parsed into typed structs with
deny_unknown_fieldswhere strictness helps. - [ ] Pool sizes and acquire timeouts configured from DB limits; queries prepared/offline-checked.
- [ ] Migrations forward-only, applied in CI before deploy; rollback plan documented as a new migration.
- [ ] Authn at middleware, authz in services; tokens short-lived and revocable.
- [ ] Graceful shutdown drains with a deadline and closes pools; readiness flips first.
- [ ] Config validated at startup; secrets from environment/secret manager only.
07-embedded
Embedded and no_std
Scope: no_std fundamentals, embedded-hal traits, the embassy async ecosystem, defmt/probe-rs debugging, memory constraints, and target/build configuration.
no_std Fundamentals
#![no_std]linkscore(and optionallyalloc); you losestdtypes,std::io, panics-with-unwind, and the default global allocator.#![no_main]plus a runtime crate's entry macro (#[cortex_m_rt::entry],#[esp_hal::main],#[embassy_executor::main]) replaces the standard start code.- Every binary needs a panic handler and (on bare metal) a memory layout. Pick:
panic-halt: stop execution; smallest.panic-probe(defmt): prints the panic over RTT; best for debugging.panic-reset/ custom#[panic_handler].
default-features = falseeverywhere in the dependency tree; verify each crate supportsno_std(many do behind astdfeature).allocneeds a#[global_allocator]; on MCUs preferembedded-allocorlinked_list_allocator, but treat dynamic allocation as exceptional.core::fmtis available but pulls formatting code (~10s of KiB); avoidformat!in hot or size-critical paths.- Replace
stdtooling withcoreequivalents:core::fmt::Write,core::cell::RefCell/Cell,core::sync::atomic,spin::Mutexorcritical-sectionfor locks.
#![no_std]
#![no_main]
use panic_probe as _;
use cortex_m_rt::entry;
#[entry]
fn main() -> ! {
loop { core::hint::spin_loop(); }
}
Target and Build Configuration
- Common targets:
thumbv6m-none-eabi(Cortex-M0),thumbv7em-none-eabihf(M4F/M7),thumbv8m.main-none-eabihf(M33+),riscv32imac-unknown-none-elf,riscv32imc-unknown-none-elf(ESP32-C3),xtensa-esp32-none-elf. - Pin the toolchain in
rust-toolchain.tomlwith the target and components; commit it. build-std(rebuildingcore/alloc) is still nightly-only in practice; use it for custom targets or when a dependency requires it. Verify upstream for stable support.
# .cargo/config.toml
[build]
target = "thumbv7em-none-eabihf"
rustflags = ["-C", "link-arg=-Tlink.x"]
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F411CEUx"
[env]
DEFMT_LOG = "info"
memory.xdeclares FLASH/RAM origins and lengths forcortex-m-rt; a wrong value silently corrupts links. Custom memory layouts need matching linker scripts and stack placement.build.rscopiesmemory.xintoOUT_DIR(typicalcortex-m-rtboilerplate) or sets link search paths.- Probe/runner setup:
probe-rs run, OpenOCD, or vendor tools;cargo embedwraps flashing + RTT. - For RP2040/ESP, use the vendor HAL's build flow (
embassy-rp,esp-hal+espflash); bootloader and partition concerns differ from Cortex-M. - Size checks in CI:
cargo size --release -- -A,cargo bloat,cargo nmfor symbol sizes; fail on regressions.
embedded-hal (1.0 line)
- Traits are the portability layer between drivers and HALs. Implement HAL traits; write drivers against traits.
- Core traits (verify exact signatures upstream for the 1.0 line):
OutputPin(set_high,set_low),InputPin(is_high).SpiDevice/SpiBus(transaction-based),I2c(write,read,write_read).DelayNs(delay_ns/delay_ms/delay_us),Pwm,Adc,Serial.
- Async twins in
embedded-hal-async(SpiDevice,I2c,DelayNsasFuture) enable interrupt-driven drivers without blocking. embedded-io/embedded-io-asyncfor byte-stream abstractions;embedded-can,embedded-graphics,embedded-nalfor other domains.- Driver crates should be generic over traits (
pub struct Mpu6050<I: I2c> { i2c: I }) and returnResult<_, I::Error>or a driver-defined error. - Do not implement
embedded-haltraits for types whose errors are unrepresentable; useInfalliblewhere no failure exists.
use embedded_hal::digital::OutputPin;
use embedded_hal::delay::DelayNs;
fn blink<P: OutputPin, D: DelayNs>(pin: &mut P, delay: &mut D) {
loop {
let _ = pin.set_high();
delay.delay_ms(500);
let _ = pin.set_low();
delay.delay_ms(500);
}
}
Async Embedded (embassy and friends)
embassy-executoris the de-facto async executor: cooperative, interrupt-driven wakers, static task allocation via#[embassy_executor::task]andSpawner.embassy-timeprovides timers (Timer::after,Ticker); configure a time driver from the HAL.embassy-syncmirrors async sync primitives forno_std:Mutex,Signal,Channel,Watch,PubSubChannel.embassy-stm32/embassy-nrf/embassy-rpcombine HAL + interrupt integration;esp-halhas its own async executor support.- Tasks are statically allocated with pool sizes; the executor is a future scheduler, not a preemptive RTOS. Reentrancy safety comes from interrupt priorities and critical sections.
- Interrupt handlers must be cooperative: no blocking waits, minimal work, wake a task instead.
- Cancellation and drop semantics match
stdasync: dropping a future cancels at await points. See./03-async-concurrency.mdfor the mental model. - Priorities: hardware interrupts preempt tasks; use
critical-sectionto protect shared state shared with ISRs.
#[embassy_executor::task]
async fn heartbeat(mut led: Output<'static>) {
loop {
led.toggle();
embassy_time::Timer::after_millis(500).await;
}
}
Memory Constraints
- RAM is measured in tens/hundreds of KiB; stack overflow is silent corruption, not a clean panic. Estimate the deepest call chain and async state sizes (
core::mem::size_of_valon futures at compile time via tests). - Static allocation first:
staticbuffers,heapless::{Vec, String, Deque, Map}with fixed capacities. - If using
alloc: single-threaded allocator, set aside a fixed arena, and either make allocation failure fatal or avoid it in steady state. - Watch sizes:
size_ofon futures,cargo bloat,nm --size-sort; a singleformat!can add kilobytes. defmtstrings live in flash, not RAM; still avoid formatting in hot loops.- Use
#[inline]selectively; flash is finite too.opt-level = "s"or"z",lto = true,codegen-units = 1,panic = "abort"for release. - Stack placement and interrupt stack: on some targets interrupts use the main stack; on others (e.g., RP2040) there are per-core stacks. Know your chip.
- Data in flash:
staticimmutable data,#[link_section = ".rodata"]; DMA buffers must be in RAM and often aligned. - Avoid recursion; use explicit state machines or
heapless::Vec-based stacks.
Debugging and Logging (defmt + probe-rs)
defmtcompresses log strings into flash and sends binary frames over RTT/SWO; far cheaper thanlog+UART.- Setup:
defmt = "0.3"(verify current line),defmt-rtttransport,panic-probewith thedefmtfeature, andDEFMT_LOG=infoin.cargo/config.toml. defmt::info!,warn!,error!,debug!,trace!,assert!; derivedefmt::Formatfor custom types (or use{=[u8]:a}hints for slices).- Global logger: depend on
defmt-rtt; only one global logger per firmware. probe-rsreads RTT:probe-rs run --chip <CHIP>,probe-rs attach,probe-rs gdb;defmt-printdecodes captured streams.- Timestamps/sequences:
defmtsupports timestamping (elapsed time) when the HAL provides a timer; verify integration for your HAL. - Alternatives:
rtt-targetfor plain RTT, UART logging when a probe is unavailable (costly),tracingon host-emulated targets. - Debugging:
probe-rs gdborarm-none-eabi-gdbplusopenocd; hardware breakpoints are limited (typically 2-6); flash breakpoints can be used. embedded-testruns unit tests on hardware; keep a#[cfg(test)]harness that doesn't requirestd::test.
One Professional Embedded Crate
firmware/
Cargo.toml
build.rs # memory.x
memory.x
.cargo/config.toml # target, runner, defmt env
rust-toolchain.toml # pinned nightly/channel + target
src/main.rs # entry, tasks
src/net.rs # ...
crates/
sensor-driver/ # no_std driver over embedded-hal traits
- Split hardware-independent logic (state machines, protocols) into portable crates tested on the host; keep HAL coupling in the firmware crate.
- Run host tests on the portable crates; hardware tests stay minimal and manual or on a bench rig.
Anti-Patterns
std-only crates pulled in by accident (often via default features).- Overflowing the stack silently by recursion or deep async chains.
- Blocking
Delayinside async tasks (embassy-timeorembedded-hal-asyncinstead). - Using a heap in interrupt context or from multiple cores without synchronization.
- Ignoring
critical-sectionaround shared state accessed by ISRs. - Shipping with
panic-haltand no boot-time watchdog when field resets are required. - Building for the wrong target/features and only noticing a crash in the field.
- Logging through a blocking UART in a timing-critical loop.
Checklist
- [ ]
no_stdgate verified (cargo check --target <t>) in CI; no accidentalstddependency. - [ ] Panic handler chosen and documented; watchdog strategy defined for field devices.
- [ ] Drivers written against
embedded-haltraits; async twins where appropriate. - [ ] Memory budget measured (
.bss/.data/.text, stack high-water) and enforced in CI. - [ ]
defmtused with RTT; log level configurable per build. - [ ] Shared state with ISRs protected (
critical-sectionor atomics with correct ordering). - [ ] Toolchain, target, and runner pinned in-repo.
- [ ] Portable logic host-tested; hardware tests documented.
08-testing
Testing
Scope: unit/integration/doc tests, property testing, criterion benchmarks, cargo-nextest, Miri, loom, snapshot testing, fuzzing, coverage, and test doubles.
Test Taxonomy
| Kind | Location | Compiles with crate? | Access | Use for |
|---|---|---|---|---|
| Unit | src/**/tests.rs under #[cfg(test)] mod tests | yes | private items | internal invariants, edge cases |
| Integration | tests/*.rs | links public API | public only | public contract, end-to-end of a crate |
| Doc test | ```rust in doc comments | compiled as separate crate | public, with extern crate | API examples that must keep compiling |
| Example | examples/*.rs | built by cargo test/cargo build --examples | public | usage demos, smoke binaries |
| Benchmark | benches/*.rs (criterion) | yes | varies | perf regression detection |
| Fuzz | fuzz/fuzz_targets/*.rs | separate, cargo-fuzz | varies | parsers, untrusted input, unsafe |
| Property | wherever | yes | varies | invariants over generated input |
- Unit tests may use private APIs; integration tests must not. If an integration test needs internals, extract a testable module or add a
#[doc(hidden)] pubseam. #[cfg(test)]code is not compiled for doc tests or downstream users — do not depend on it frompubgenerics.- Keep tests deterministic: no real clock/network/random; inject
Clock,Rng, and clients.
Writing Useful Tests
- Name tests after behavior:
rejects_expired_token, nottest_validate_2. - One logical assertion per test where practical; use table tests for many cases.
- Test the contract, not the implementation: public API behavior, invariants, error variants, and boundary values (empty, max, off-by-one, unicode, zero-length).
- Assert on error types/variants and messages that are part of the contract; use
matches!orassert_matchesto avoid brittle formatting. - Use
#[should_panic(expected = "...")]only for genuinely panicking contracts (or remove the panic and returnResultinstead). #[ignore]marks tests that need external resources; run them explicitly in CI with--ignored.#[cfg_attr(miri, ignore)]for tests Miri cannot execute (FFI, filesystem unsupported ops).
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_empty_input_as_none() {
assert_eq!(parse(""), None);
}
#[test]
fn rejects_oversized_frame() {
assert!(matches!(decode(&[0xFF; 64]), Err(DecodeError::TooLarge { .. })));
}
}
Doc Tests
- Doc tests are executed by
cargo test; they are the best regression net for README-level examples. - Attributes:
no_run(compile but do not run),ignore(skip; avoid),compile_fail(must not compile),should_panic,text. - Hidden setup lines start with
#and are compiled but not rendered — use forfn main, imports, or unwraps. - Mark non-runnable examples (
no_run) when they need a server/DB; still compile them in CI. cargo test --docisolates runtime;rustdoclinkcheck is not a thing — verify intra-doc links withRUSTDOCFLAGS="-D warnings"andcargo doc.- Set
[lib] doctest = falseonly when doctests are impossible (e.g.,no_stdcrates); otherwise keep them on. - Add
#![doc = include_str!("../README.md")]to test README examples.
Property Testing
proptest(orquickcheck): generate inputs, shrink failures to minimal counterexamples.- Define strategies near the type:
fn arb_email() -> impl Strategy<Value = Email>; compose withprop_map,prop_flat_map. - Assert properties, not examples: round-trip (
decode(encode(x)) == x), idempotence, commutativity, invariants (len() <= capacity), and no-panic. - Persist regressions: proptest writes failures to
proptest-regressions/; commit those files. - Bound the search space (
prop_filtersparingly,prop_assumefor preconditions) and setPROPTEST_CASESin CI for longer runs. - Combine with fuzzing: proptest for structured inputs, cargo-fuzz for raw bytes and long campaigns.
use proptest::prelude::*;
proptest! {
#[test]
fn roundtrip(input in any::<Vec<u8>>()) {
let frame = encode(&input);
prop_assert_eq!(decode(&frame).unwrap(), input);
}
}
Continuous Benchmarking (criterion)
criterionmeasures wall time with statistical analysis: warmup, sample size, outliers, and change detection against a baseline.- Always
black_boxinputs and outputs so the optimizer cannot elide work. - Benchmark at the right granularity: micro (function), meso (request handling), and end-to-end (process) — only micro-benchmarks in-repo by default.
cargo benchcompares to the previous saved baseline; use--save-baseline/--baselineand storetarget/criterionin CI artifacts.Throughput::Bytes/Elementsreports throughput instead of raw time for IO/parsing benchmarks.- For instruction-count benchmarks (immune to CI noise), use
iai-callgrind(Valgrind-based) — verify the current crate/function names upstream. - Do not gate PRs on wall-time microbenchmarks without noise controls (dedicated runner, instruction counts, or median of many runs).
cargo-flamegraphandperffor finding what to benchmark (see./10-performance-security-observability.md).
cargo-nextest
- Nextest runs each test in its own process, defaulting to parallelism with per-test timeouts; output is clearer and faster than
cargo testfor large suites. - Config
nextest.toml: profiles (default,ci), retries for known-flaky tests (retries = { backoff = "exponential", count = 2 }), test groups for serial tests. --partition count:1/4splits suites across CI shards;--profile ciadds retries and strict output.cargo nextest run --all-features --workspace; doc tests still run viacargo test --doc(nextest does not run them).cargo nextest listfor auditing;--no-fail-fastto see all failures.- JUnit output:
--profile ci --message-format libtest-jsonpiped throughcargo-nextesttooling ornextest-rsconverters; many CIs accept nextest's native JUnit.
Miri and Loom
- Miri catches UB in unsafe code and some logic errors (invalid values, leaks, races) by interpreting MIR. See
./05-unsafe-ffi.mdfor flags and setup. - Run Miri on unsafe modules with strict provenance; expect it to be 10-100x slower — keep the Miri test set focused.
- Loom model-checks concurrent code by exploring interleavings under a configurable bound (
LOOM_MAX_PREEMPTIONS).- Gate code on
#[cfg(loom)] use loom::sync::{Mutex, Arc};and mirror the types in a smallsyncmodule. - Test:
loom::model(|| { ... }); exhaustively explores schedules for small thread counts. - Loom substitutes atomics and Arc;
loom::thread::spawninstead ofstd::thread. - Loom does not model tokio task scheduling; test the synchronous primitives (locks, queues) under loom, then test task orchestration separately.
- Gate code on
- Sanitizers (ASan/TSan on nightly or
-Zsanitizer) complement Miri where FFI/unsupported ops block it.
Snapshots
instafor snapshot assertions:assert_snapshot!,assert_debug_snapshot!,assert_json_snapshot!.- Workflow: run tests -> pending
.snap.newfiles ->cargo insta revieworcargo insta accept; commit.snapfiles. - Use inline snapshots (
assert_snapshot!(value, @"...")) for small, local expectations; file snapshots for larger output. - Redact nondeterminism (timestamps, ids, paths) with
insta::with_settings!({filters => ...})rather than post-processing. - Snapshot tests are excellent for API responses, CLI output, error messages, and generated code; they are poor for behavior assertions (they fail on any formatting change).
expect-testis the lighter alternative for inline expected strings.
Fuzzing
cargo-fuzz(libFuzzer) is the baseline:cargo fuzz init,cargo fuzz add target,cargo fuzz run target.- Feed
&[u8]througharbitraryto structured types where a grammar helps; keep a raw-bytes target for parser robustness. - Corpus management: commit seed corpus, download OSS-Fuzz corpus in CI; crashes are written to
artifacts/— turn each into a regression test. - Run short fuzz jobs on PRs (time-boxed) and long campaigns nightly; use
-max_total_time=...,-rss_limit_mb,-jobs/-workers. - For integration with Rust test harnesses,
cargo afl/afl.rsis an alternative; verify maintenance status upstream. - Fuzz unsafe code under ASan (
cargo fuzz runuses sanitizers by default on supported targets).
Coverage
cargo-llvm-covis the current standard:cargo llvm-cov --workspace --all-features --html, producing source-based coverage.- Branch coverage:
cargo llvm-cov --branchon modern LLVM; line-only coverage can hide untested match arms. - CI: upload
lcov/coberturaartifacts; gate on coverage of changed lines rather than a global percentage. grcovis an older alternative; both are acceptable — pick one and keep it stable.- Coverage of tests themselves is not useful; exclude test modules and generated files via
--ignore-filename-regex. - Coverage cannot prove correctness; combine with property tests and Miri for high-risk code.
Test Doubles and Integration Fixtures
| Need | Tool |
|---|---|
| Mock traits | mockall (auto-mock with #[automock]), hand-written fakes |
| HTTP clients | wiremock, httpmock, mockito |
| HTTP servers | axum router + tower::ServiceExt::oneshot (no port needed) |
| Databases | testcontainers, sqlx::test (per-test DB), pg_embed/libsql for SQLite |
| Time | tokio::time::pause, injected Clock trait, time fakes |
| Randomness | seeded rand::rngs::StdRng, injected Rng |
| Filesystem | tempfile::TempDir |
| Env vars | temp-env or a config struct built in tests (never mutate process env in parallel tests) |
- Prefer hand-written fakes over generated mocks; they exercise behavior and stay readable.
sqlx::testcreates an isolated database per test when given a database URL; migrations applied automatically (verify behavior upstream).- Testcontainers require Docker in CI; keep a fast unit suite that runs without Docker and a slower integration suite that can be skipped.
- Avoid global mutable state between tests; nextest's process-per-test makes leaks less likely but not harmless.
Anti-Patterns
- Tests coupled to private implementation details; refactors break tests without behavior changes.
sleepto synchronize: use channels, barriers, or paused time.- Testing only happy paths and no error variants.
- Snapshot tests accepted blindly via
cargo insta acceptwithout review. - Gating on flaky wall-clock microbenchmarks.
- Ignoring Miri/loom results because "it's probably fine".
- A single giant integration test that exercises everything and fails with no localization.
- Tests that require network access or a shared database instance.
Checklist
- [ ] Public APIs have unit and integration coverage; error variants asserted.
- [ ] Doc examples compile and run (or are
no_runwith a reason). - [ ] Properties tested with proptest for parsers/codecs/state machines.
- [ ] Benchmarks run in a stable environment and track regressions.
- [ ] cargo-nextest in CI with timeouts; flaky tests quarantined with retries, then fixed.
- [ ] Miri on unsafe modules; loom on lock/queue implementations.
- [ ] Fuzz targets for every untrusted-input parser; corpora committed.
- [ ] Coverage tracked per changed lines, not as a vanity metric.
- [ ] Integration fixtures are hermetic (containers/temp dirs), no shared external state.
09-tooling-build
Tooling and Build
Scope: cargo workspaces, feature flags and unification, profiles, task runners, lints, cross-compilation, MSRV, release automation, and supply-chain hygiene.
Workspaces
- One workspace for related crates: a root
Cargo.tomlwith[workspace] members = ["crates/*"], one sharedCargo.lock, onetarget/. - Resolver: edition 2024 defaults to resolver v3 (MSRV-aware feature/dependency resolution); edition 2021 defaults to v2. Set
[workspace] resolver = "3"explicitly if members differ. Verify upstream for resolver behavior changes. [workspace.dependencies]centralizes versions; members usedep = { workspace = true }plus local features.[workspace.package]centralizesversion,edition,license,repository,rust-version.[workspace.lints](stabilized 1.74) with[lints] workspace = trueper crate is the standard lint policy mechanism.- Keep
Cargo.lockcommitted for binaries and applications; libraries generally commit it too for reproducible CI (policy choice — document it). cargo metadata --format-version 1for tooling;cargo tree -dto find duplicate versions;cargo tree -e features -i serdeto see who enables features.- Publishing order matters;
cargo-release,release-plz, orcargo-workspacesautomate version bumps and ordered publishes.
[workspace]
members = ["crates/*"]
resolver = "3"
[workspace.package]
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", default-features = false }
thiserror = "2"
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
[workspace.lints.clippy]
unwrap_used = "deny"
Feature Flags
- Features are additive and must never be mutually exclusive; enabling a feature must not disable another. This rule is non-negotiable for published crates.
- Feature unification: with a shared lockfile, features enabled by any member are visible to all members using that dependency. Resolver v2 avoids unifying build-dependencies/proc-macros/target-specific deps; v3 additionally prefers MSRV-compatible versions.
- Use
dep:syntax to create features that enable optional dependencies without exposing implicit features (serde = { version = "1", optional = true }plusderive = ["dep:serde"]).cargono longer creates implicit features for optional deps whendep:is used (verify upstream). - Default features should be minimal and safe.
default-features = falsein libraries that want to opt in. - Feature design table:
| Kind | Naming | Example |
|---|---|---|
| Additive capability | noun | tls, http2, json |
| Backend selection | backend-<name> | backend-postgres |
| Runtime selection | rt-<name> | rt-tokio |
| Unstable/experimental | unstable-<x> | unstable-gats |
| Test-only helpers | _test-util (underscore = internal) | _test-util |
- Test feature combinations with
cargo hack --feature-powerset --depth 2 test(feature powerset can explode; limit depth) or a targetedcargo-hackjob per combination. cargo hack check --each-feature --workspacecatches features that do not compile alone.- docs.rs:
[package.metadata.docs.rs] all-features = trueplusrustdoc-args = ["--cfg", "docsrs"]; crates conditionally document features with#[cfg_attr(docsrs, doc(cfg(feature = "...")))]. - Optional dependencies that create feature unification hazards (e.g., a runtime) should be kept small and never enabled transitively.
Profiles
| Profile | Defaults | Common overrides |
|---|---|---|
| dev | opt-level=0, debug info, overflow-checks=true | add [profile.dev.package."*"] opt-level = 2 to speed deps |
| release | opt-level=3, no debug info, lto=false, codegen-units=16 | lto="thin", codegen-units=1, debug=1, strip="debuginfo" |
| test | dev-like; cargo test --release uses bench? no, use --profile | [profile.test] opt-level=1 for faster integration tests |
| bench | release-like | debug=true, lto=true for accurate flamegraphs |
[profile.release]
lto = "thin"
codegen-units = 1
panic = "abort" # only if no unwind boundary is required
strip = "debuginfo" # keep symbols for profiling builds via a custom profile
overflow-checks = true
[profile.profiling]
inherits = "release"
debug = true
strip = "none"
- Custom profiles inherit from
devorrelease; use them for profiling, fuzzing, and sanitizer builds. panic = "abort"reduces size and removes unwinding; it also makescatch_unwindineffective and changes FFI panic behavior. Decide per binary.build-overridefor build scripts and proc-macros:[profile.release.build-override] opt-level = 3.- Overriding dependency optimization (
[profile.release.package."*"]) trades build time for runtime. -Zbuild-stdfor custom targets stays nightly-gated in practice; verify upstream.- Debug symbols in release:
debug = "line-tables-only"(or1) balances symbol size and actionable profiles;strip = "symbols"for shipping binaries.
Build Acceleration and Caching
- sccache:
RUSTC_WRAPPER=sccache; configure a local or remote (S3/GCS) backend in CI. - Linkers:
moldorlldcut link time substantially on Linux:RUSTFLAGS="-C link-arg=-fuse-ld=mold"(orrustflagsin.cargo/config.toml). - cranelift backend (
-Zcodegen-backend=cranelift) speeds debug builds on nightly; not for release artifacts. cargo build --timingsandcargo nextestreports for build hotspots.- Docker:
cargo-chefto cache dependency compilation in a separate layer; build with--locked. - Separate
targetdirs per profile/target in CI caches; cache keyed byCargo.lockhash and toolchain. cargo build --workspace --all-targetsonce before parallel test jobs avoids recompilation across jobs.cargo udeps(nightly) andcargo macheteto remove unused dependencies; both reduce compile time.
Task Runners
| Approach | Pros | Cons |
|---|---|---|
| just | simple recipes, shell-friendly, widely installed | no Rust integration |
| cargo-make | TOML tasks, cross-platform, conditionals | verbose; plugin surface |
| xtask | plain Rust, typed, debuggable, no extra config | requires a workspace crate |
xtaskis the preferred approach for complex automation (codegen, bundling, release steps): acrates/xtaskbinary invoked ascargo run -p xtask -- <cmd>or aliased in.cargo/config.toml.- Keep a thin
justfile/Makefileas the discoverable entry point callingcargo/cargo xtask. - CI should call the same commands developers run; no divergent CI-only scripts where avoidable.
# .cargo/config.toml
[alias]
xtask = "run --package xtask --"
ci = "clippy --workspace --all-targets --all-features -- -D warnings"
Lints: rustfmt and clippy
rustfmt.toml:edition,max_width,use_small_heuristics,newline_style. Note:imports_granularity,group_imports, andstyle_editionare nightly-only options; if you want them, runcargo +nightly fmtor avoid them.clippy.toml:msrv,cognitive-complexity-threshold,too-many-arguments-threshold,disallowed-types,disallowed-methods,avoid-breaking-msg(verify current option names upstream).- Gate CI:
cargo clippy --workspace --all-targets --all-features -- -D warningsandcargo fmt --all --check. - Set baseline lints in the crate root (
#![deny(missing_docs, missing_debug_implementations, rust_2018_idioms)]) and policy lints in[workspace.lints]. - Common library lint set:
clippy::all,clippy::pedantic(allow noisy ones explicitly),unwrap_used,expect_used(warn),panic,indexing_slicing(warn),arithmetic_side_effects(selective). clippy::correctnessis deny-by-default; never blanket-allow it.- Allow lints at the narrowest scope with a reason:
#[expect(clippy::cast_possible_truncation, reason = "checked above")](#[expect]supported on modern Rust; verify upstream). - Test code may relax policy lints via
[lints]on the test target or#![cfg_attr(test, allow(...))].
Cross-Compilation
- Install targets:
rustup target add aarch64-unknown-linux-musl x86_64-pc-windows-gnu wasm32-wasip2. - Static Linux binaries:
*-unknown-linux-musl+-C target-feature=+crt-static(default for musl on most targets). - Linker configuration in
.cargo/config.tomlper target;cross(Docker images with toolchains) orcargo-zigbuild(zig as cross linker) removes most sysroot pain. - Apple targets require the Xcode SDK and license acceptance; aarch64 macOS cross from Linux is not officially supported without an SDK — use a macOS runner.
- Windows:
x86_64-pc-windows-gnu(MinGW) cross-compiles from Linux; MSVC should run on Windows. - WASM:
wasm32-unknown-unknown+ wasm-bindgen, orwasm32-wasip1/wasip2for the component model;wasm-pack/cargo-componentfor bundling. Verify target names and component-model status upstream. - Test cross outputs with QEMU (
cross test --target ...,qemu-<arch>binfmt) or run on matching runners. - Check
cargo tree --target <triple>because target-specific dependencies change feature graphs.
MSRV (Minimum Supported Rust Version)
- Declare
rust-versionin[package]/[workspace.package]; cargo uses it for resolver v3 compatibility hints and toolchain checks. - Test the MSRV in CI with a pinned toolchain (
actions-rs/toolchain-style matrix orrustup toolchain install 1.85.0). cargo-msrvcan bisect the minimal version; use it when bumping dependencies floods the MSRV.- MSRV policy: bumping requires a semver-minor release of your library (or is a breaking change under some policies) — document the policy in the README.
cargo update -p <dep> --precise <version>in CI keeps the MSRV lockfile coherent; or maintain aCargo.lock.msrv.
Release and Supply Chain
| Tool | Purpose | When |
|---|---|---|
| cargo-deny | advisories, bans, licenses, source allowlist | every CI run |
| cargo-audit | RustSec advisory DB | every CI run (or rely on cargo-deny) |
| cargo-vet | audit trail for dependency reviews | org policy for high-assurance |
| cargo-udeps | unused dependencies (nightly) | periodic |
| cargo-machete | unused dependencies without nightly | every CI run |
| cargo-semver-checks | API break detection | before release |
| cargo-sbom / cyclonedx | SBOM generation | release artifacts |
# deny.toml (abridged)
[advisories]
version = 2
yanked = "warn"
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "Unicode-3.0"]
confidence-threshold = 0.93
[bans]
multiple-versions = "warn"
wildcards = "deny"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
- Lockfile:
cargo build --lockedin CI and release;cargo updateis a reviewed PR, never silent. - Vendoring for hermetic builds:
cargo vendorand[source.crates-io] replace-with = "vendored-sources". cargo publish --dry-runbefore release; publish with--locked(supported by modern cargo; verify upstream).- crates.io trusted publishing (OIDC-based CI publishing) exists on the modern registry; verify upstream before adopting, and prefer it to long-lived tokens.
- Tag releases, attach checksums/SBOM, and sign when the org requires it (Sigstore/cosign is common).
- Reproducibility: pin toolchain (
rust-toolchain.toml), commit lockfile, use--locked, setSOURCE_DATE_EPOCHwhere applicable, and avoid path-dependent build scripts. - Security response: watch RustSec advisories, have an update path for transitive deps, and CI-fail on new advisories (
cargo-deny check advisories).
Anti-Patterns
- Always-on
[features] default = ["full"]that pulls TLS, DB drivers, and runtimes into every consumer. - Mutually exclusive features ("enable exactly one backend") that only work by convention.
- Cargo.lock ignored in a binary repository.
-D warningson rustc but not clippy, or clamping the toolchain without pinning it.- Cross-compiling without testing the artifact.
- Committing
cargo vendortrees without an update process. - Long-lived crates.io tokens in CI.
- Relying on
cargo updateto silently fix advisories in production releases.
Checklist
- [ ] Workspace resolver matches the edition; shared deps/lints centralized.
- [ ] Features are additive and documented; powerset check runs in CI.
- [ ] Release/profile settings deliberate (
lto,codegen-units,panic,strip,debug). - [ ] Lint policy in
[workspace.lints]; CI runs fmt + clippy with-D warnings. - [ ] Cross targets built and smoke-tested where shipped.
- [ ] MSRV declared, pinned, and tested.
- [ ]
cargo-deny/cargo-auditandcargo-semver-checksin CI; release uses--locked. - [ ] Build cache configured; clean-checkout build time measured.
- [ ] Release process automated (version bump, changelog, publish order, artifacts).
10-performance-security-observability
Performance, Security, Observability
Scope: profiling tools, allocation and CPU optimization, rayon, SIMD basics, crypto and secrets hygiene, and tracing/metrics instrumentation policy.
Performance Method
- Define the metric and budget (p99 latency, throughput, RSS, binary size, energy).
- Measure with a representative workload; record a baseline in version control or CI.
- Profile to find the hotspot; fix the largest cost first.
- Verify with the same measurement; add a regression guard (benchmark, size check).
- Repeat. Never optimize from intuition.
| Symptom | First tool | Then |
|---|---|---|
| CPU-bound slowness | perf record --call-graph dwarf | cargo-flamegraph, samply |
| Allocation pressure | DHAT/heaptrack | cargo-bloat, massif |
| Long compile times | cargo build --timings | cargo-llvm-lines |
| Large binary | cargo bloat | cargo size, nm --size-sort |
| Lock contention | perf lock? use samply/perf with thread views | loom, redesigned ownership |
| High syscall overhead | strace -c, perf trace | buffering, batching |
cargo-flamegraph(CARGO_PROFILE_RELEASE_DEBUG=trueor the profiling profile) gives a quick live view;samplyprovides a nicer timeline and is widely used in 2026 — verify current project status upstream.perfneeds symbols and frame pointers for good stacks: build withdebug = trueand prefer--call-graph dwarfwhen frame pointers are unavailable.- DHAT (
dhat-rs),heaptrack, andbytehoundfind allocation hot spots;dhatintegrates as an allocator behind a feature flag and writes a JSON report. cargo-bloat --releaseranks functions by size;cargo-bloat --cratessplits by crate.- For I/O-bound services, measure queue times and connection pools, not CPU.
- Profile release-like builds: optimizations change hotspots. Use a custom
profilingprofile withdebugsymbols (see./09-tooling-build.md).
Allocation Reduction
Vec::with_capacity,String::with_capacitywhen sizes are known;reservebefore loop growth.- Reuse buffers across requests (pool them) instead of allocating per request;
bytes::Bytes/BytesMutfor zero-copy slicing of shared buffers. - Replace small
Vec/StringwithSmallVec/ArrayVec/smallstrto avoid heap for typical sizes; measure the inline size tradeoff. Cow<'_, str>(orCow<'_, [T]>) in parsing pipelines to avoid copying unchanged input.- Write into buffers with
std::fmt::Write(write!(&mut String, ...)) instead offormat!when the destination is reused. - Avoid
collect()in hot paths; chain iterators or usefold/for_eachwith preallocated output. boxedtrait objects allocate; hoist to setup, or use enums for a fixed set of variants.- Arena allocation (
bumpalo) for batch processing with a single lifetime; reset the arena per batch. - Global allocator choice:
jemalloc/tikv-jemallocatorormimalloccan help multi-threaded alloc-heavy workloads; measure RSS and latency — they are not automatic wins, especially for short-lived processes. Vec::shrink_to_fit/into_boxed_sliceafter final growth when memory is tight.- Beware hidden allocations:
format!inDisplayimpls,to_string()in logs,Path::to_path_buf, and closures capturing ownedStrings.
let mut out = String::with_capacity(input.len() + 16);
write!(out, "{prefix}:{input}")?;
CPU and Codegen
| Technique | When | Caveat |
|---|---|---|
| Iterator chains instead of indexing | loops over slices | usually enables bounds-check elision |
| #[inline] | tiny, cross-crate hot functions | can bloat; rely on LTO first |
| #[inline(always)] | proven hotspots only | hurts icache and compile time |
| lto = "thin"/"fat" | release binaries | build-time cost; fat maximizes cross-crate inlining |
| codegen-units = 1 | release | slower builds |
| target-cpu=native | controlled deployment | illegal-instruction risk on heterogeneous fleets; prefer runtime dispatch |
| PGO/BOLT | high-value binaries | tooling-heavy; nightly -Cprofile-generate / BOLT on ELF |
| #[cold] and #[inline(never)] | error paths | keep hot path small |
| Branch hints | rarely needed | std::hint::likely is unstable; do not use on stable |
- Bounds checks: use
iter(),windows(),chunks(),split_at, andgetwith explicit handling; keep-C debug-assertionsoff in release. - Prefer
&[T]parameters over&Vec<T>to accept more callers without conversion. - Avoid
dynin inner loops; hoist dynamic dispatch to the outer layer. - Use
#[repr(C)]/#[repr(align(N))]only with a concrete reason (ABI, cache-line separation for atomics). parking_lotlocks are faster thanstdand have no poisoning; measure contention before switching.
Rayon and Data Parallelism
rayonprovides work-stealingpar_iter,join, andscope; use it for CPU-bound data processing, not for I/O.data.par_iter().map(f).collect::<Vec<_>>()preserves order;for_each/reducefor unordered.- Custom tasks:
rayon::join(|| a(), || b())for divide-and-conquer;scopefor borrowing local data. - Do not block worker threads on I/O or locks; rayon's pool is sized to CPUs. Blocking a rayon thread stalls the whole pool.
- Nested parallelism with tokio: never call rayon inside async on every item; either
spawn_blockinga rayon batch, or usetokio::task::spawn_blockingper batch, or bridge withpollster/block_in_placedeliberately. - Global pool configuration:
rayon::ThreadPoolBuilder::new().num_threads(n); build a dedicated pool when the library must not hijack the global one. - False sharing: pad per-thread accumulators (
CachePaddedfrom crossbeam) for high-contention counters. - Determinism: reduce with an associative operation; avoid floating-point nondeterminism when a stable result is required.
use rayon::prelude::*;
let sum: u64 = data.par_chunks(4096).map(|c| c.iter().map(|&x| x as u64).sum::<u64>()).sum();
SIMD Basics
- Start with autovectorization: contiguous slices, simple loops, no data-dependent branches per element, and
-C target-featureortarget-cpuwhere safe. std::simd(portable SIMD) remains nightly-only in practice; do not put it in a stable crate without a gate. Verify upstream.- Stable options:
std::archintrinsics behind#[target_feature(enable = "avx2")] unsafe fn, plus runtime detection withis_x86_feature_detected!/is_aarch64_feature_detected!. - Cross-platform wrappers:
wide(portable SIMD types) andmultiversion(compiles feature-specialized versions and dispatches at runtime). - Keep SIMD kernels isolated in a module with scalar fallbacks, tests comparing scalar vs SIMD outputs, and Miri-friendly scalar paths (Miri does not support many intrinsics).
- Alignment: unaligned loads (
loadu) are fine on modern x86 but cost on some ARM; align when easy. - Crypto and compression libraries already ship tuned SIMD; use them rather than hand-rolling.
- Measure with
perf stat(IPC, cache misses) and check that vectorization actually happened (cargo asm,llvm-mca).
Crypto and Secrets
| Need | Recommended | Notes |
|---|---|---|
| TLS | rustls (+ aws-lc-rs or ring provider) | pure-Rust, no OpenSSL linkage; native-tls when OS trust store integration is required |
| Symmetric AEAD | aes-gcm, chacha20poly1305 (RustCrypto) | use well-reviewed, actively maintained crates; verify audit status upstream |
| Hashing | sha2, blake3 | blake3 for speed, sha2 for compatibility |
| Password hashing | argon2, password-hash | Argon2id; store parameters with the hash |
| KDF | hkdf, pbkdf2 | derive distinct keys per purpose |
| Randomness | getrandom (OS CSPRNG), rand for non-crypto | never seed crypto from rand::thread_rng state you control; use OsRng |
| Constant-time compare | subtle | never compare secrets with == |
| Secret storage | secrecy, zeroize | SecretString, zeroize on drop, avoid Debug leakage |
| JWT | jsonwebtoken | validate alg/aud/iss explicitly (see ./06-web-data.md) |
- Do not roll your own crypto, padding, or protocol; compose audited primitives.
- RustCrypto crates are widely used but many carry "no formal audit" disclaimers; check the current audit status of each crate before high-assurance use and verify upstream.
- Constant-time behavior is not guaranteed by the type system:
subtle::ConstantTimeEq, avoid branching on secrets, avoid secret-dependent indexing (table lookups), and avoid secrets in error messages/timing. - Use
zeroize/Zeroizingfor keys and passwords; note that Rust may copy values — zeroize best-effort and do not rely on it for safety guarantees. - Secrets in process: environment variables are visible in
/procfor the same user and in crash dumps; prefer a secret manager fetched at startup, or workload identity. Never bake into images. - Logging policy: never log tokens, keys, passwords, or PII; run a lint/review check for
Debugon types containing secrets (derive a redactingDebugmanually). - Dependencies: pin crypto crates tightly, vendor or lock, and track advisories (see
./09-tooling-build.md). - Platform RNG failures: handle
getrandomerrors (early boot, restricted sandboxes) by failing closed, not by falling back to weak randomness.
Observability with tracing
tracingreplaceslogfor structured, async-aware diagnostics;tracing-subscriberis the runtime side.- Create spans at request/job boundaries:
#[instrument(skip(state), fields(user_id = %id))]; keep span fields high-cardinality limited (ids are fine; unbounded values are not). - Events carry the detail:
info!,debug!,warn!,error!with structured fields (error = ?err,latency_ms = elapsed.as_millis()), not string interpolation. - Subscriber stack:
registry().with(EnvFilter::from_default_env()).with(fmt::layer().json()); addconsole-subscriberin dev for task stalls. - Log levels:
error= page-worthy;warn= degraded;info= lifecycle;debug/trace= development. Never shiptracein production defaults. - JSON logging in production, pretty in development; include service name, version, trace/span ids.
- OpenTelemetry:
tracing-opentelemetry+ OTLP exporter; propagate context through HTTP/gRPC withopentelemetrypropagators. Sample at the collector when possible; head sampling in-process for cost control. - Avoid per-request
format!in hot paths;tracingis cheap when the level is disabled, but field formatting still costs when enabled. - Libraries depend on
tracing(orlogwith a feature) and emit spans; applications configure filtering and exporters. Never install a global subscriber in a library. - Flush telemetry on shutdown:
subscriberguards +opentelemetry::global::shutdown_tracer_provider(verify current API). - Correlation: inject/propagate
traceparent(W3C) through clients and servers; include request id in error responses without leaking internals.
Metrics
- Use the
metricsfacade plus an exporter (metrics-exporter-prometheus) or the OpenTelemetry metrics API; keep metric names stable, and use units in the name (http_request_duration_seconds). - Counters for totals, gauges for current values, histograms for latency/size; avoid high-cardinality labels (user ids) on histograms.
- RED (rate, errors, duration) for services; USE (utilization, saturation, errors) for resources.
- Export scrape-friendly endpoints on a separate port or internal router, not the public listener.
- Instrument queues with depth and age; queued age is the leading indicator of overload.
- Define SLOs and alert on symptoms (latency, error rate), not causes (CPU).
Checklist
- [ ] A release-like build is profiled before optimizing; baseline recorded.
- [ ] Perf budget (latency/throughput/memory/size) exists and regressions are caught.
- [ ] Allocations in hot paths measured; buffers reused; hidden
format!/to_string()removed. - [ ] Rayon used only for CPU work; no blocking of its pool; no nested oversubscription with tokio.
- [ ] SIMD kernels have scalar fallbacks, dispatch, and equivalence tests; Miri path exists.
- [ ] TLS, hashing, password hashing, and RNG come from maintained crates with tracked advisories.
- [ ] Secrets never logged, zeroized where practical, delivered by a secret manager.
- [ ] Tracing spans at boundaries; metrics for RED/USE; logs structured with levels.
- [ ] Telemetry flushed on graceful shutdown; no global subscriber installed by libraries.