The Rust Engineering Handbook / Chapter 2
Rust's Contract: Safety, Control, and Cost
Separate Rust's documented guarantees from the correctness, liveness, portability, and cost obligations engineers still own.
A launch claim has to survive contact with the system
A launch review for a Rust service contains this sentence:
The component is written in safe Rust, so it cannot crash or corrupt data.
The sentence sounds reassuring because it compresses several real Rust guarantees into one total-system promise. It is also indefensible. Safe Rust can panic on an out-of-bounds index, terminate on allocation failure or stack exhaustion, deadlock, livelock, loop forever, consume unbounded memory, or produce the wrong business result. The process can be killed while state is half-written. A dependency may cross an unsafe or foreign-function boundary even when the calling module contains no unsafe token.
Do not answer that overstatement with the equally careless claim that Rust provides no safety. Make each promise narrow enough to reveal the evidence around it. Memory safety depends on sound unsafe foundations. Data-race freedom does not establish a correct protocol. Scope-driven destruction does not survive every form of process loss. Efficient abstraction is a design goal whose result belongs to a particular build and workload.
By the end of review, the launch sentence should describe both sides of the boundary: what the language prevents, and what the system must still prove. Safe Rust excludes specified classes of undefined behavior when the unsafe foundations beneath it are sound. Functional correctness, resource bounds, liveness, panic policy, external contracts, and manually upheld unsafe preconditions remain engineering obligations.
Memory safety is a bounded guarantee
Memory safety is freedom from classes of invalid memory access and related undefined behavior defined by Rust’s model: use after free, invalid references, data races, and invalid typed values are central examples. Safe Rust APIs are designed so callers following their types and documented contracts cannot trigger undefined behavior.
That statement has three important qualifications.
First, memory safety is not general correctness. A ledger that posts the same transaction twice can remain perfectly memory-safe while losing money. A parser can accept an unauthorized message. An access-control check can query the wrong tenant. Types help only when the design encodes the relevant invariant.
Second, safe code rests on unsafe foundations. The standard library, allocator, operating-system interfaces, and dependencies use unsafe operations internally. A sound safe abstraction hides those operations behind an interface that establishes and preserves their preconditions. An unsound dependency can allow safe callers to trigger undefined behavior.
Third, the guarantee assumes ordinary Rust validity rules are not violated from outside, for example through incorrect C code, corrupt hardware, a hostile kernel, or an invalid ABI declaration. Those are system-boundary concerns, not counterexamples to the language claim.
A review should therefore ask both “Is this module written in safe Rust?” and “Which unsafe foundations and external boundaries does it trust?” The first question measures local exposure. The second measures the actual safety case.
The boundary below is the chapter’s retrieval aid. “Deterministic Drop points” means ordinary scope-driven destruction follows language rules; it does not promise that destructors run after process abort, power loss, or every operating-system termination.
Data-race freedom is not race-condition freedom
A data race involves unsynchronized conflicting memory accesses, at least one write, under the language’s concurrency model. Safe Rust prevents data races by controlling which values can cross or be shared between threads and by requiring appropriate synchronization for shared mutation.
A race condition is broader: correctness depends on relative timing or ordering. It can occur even when every individual memory access is synchronized. Consider this check-then-act pattern:
if !*approved.lock().expect("mutex is not poisoned") {
*approved.lock().expect("mutex is not poisoned") = true;
perform_one_time_approval();
}
Each access is protected by a mutex, so the accesses do not create a data race. The invariant is still broken because the lock is released between the check and the update. Two threads may both observe false and both perform the supposedly one-time action.
The direct repair is to keep the decision and state transition within one critical section:
let should_approve = {
let mut approved = approved.lock().expect("mutex is not poisoned");
if *approved {
false
} else {
*approved = true;
true
}
};
if should_approve {
perform_one_time_approval();
}
Even this design needs review. If the external action fails after the state flips, is retry safe? If the action happens while holding the lock, can it block or call back? A state machine, idempotency key, channel owner, or transactional boundary may be stronger than a boolean protected by a lock. Rust enforces synchronized access; it does not invent the atomic business operation.
Destruction is deterministic within its boundary
Rust ordinarily drops initialized local values when their scopes end and drops fields according to defined destruction rules. This supports resource acquisition is initialization (RAII): a file, lock guard, temporary directory, or transaction guard can release or roll back in Drop.
Deterministic destruction improves reasoning about ordinary returns and unwinding, but it has boundaries:
panic = "abort"does not unwind the stack.- process termination, power loss, or
std::process::abortbypasses ordinary cleanup. mem::forgetcan intentionally leak a value in safe code.- reference cycles built with
RcorArccan leak. - a destructor can block, panic, or perform work too expensive for the shutdown path.
- external effects may already have committed before local cleanup runs.
Use Drop to preserve local resource invariants, not as the only home for critical distributed cleanup. A file replacement may need write-to-temporary, flush, rename, and recovery semantics. A service needs an explicit shutdown protocol. An external payment needs idempotency and reconciliation. RAII is a mechanism inside those designs, not a substitute for them.
Panic and error represent different contracts
Result<T, E> communicates that a caller may receive an expected failure and can decide how to recover, retry, translate, or report it. Panic communicates that normal typed continuation is not being offered at that point. A panic may unwind or abort depending on build and boundary policy.
Useful defaults are:
- invalid user input, missing files, unavailable dependencies, conflicts, and timeouts are errors;
- violated internal invariants and programming defects may panic when continuing would be misleading;
- library panic conditions must be documented, especially for indexing, callbacks, user-provided implementations, and capacity assumptions;
- panics must not unwind across an ABI boundary that forbids it;
- process boundaries decide whether an error becomes an exit code, retry, response, or operator event.
The distinction is not “panic bad, error good.” Converting every defect into an opaque error can allow corrupted internal state to travel farther. Conversely, panicking on ordinary network or input failure turns expected operating conditions into availability incidents.
In ledger-core, malformed text returns an error because the caller can reject the record and continue according to policy. A unit test uses expect for a fixture whose invalidity is itself a test defect. Same mechanism family, different boundary.
Safe and unsafe Rust share one language
An unsafe block permits a small set of operations whose preconditions the compiler cannot verify, such as dereferencing a raw pointer or calling an unsafe function. It does not disable type checking, ownership, drop behavior, or all borrow rules. It also does not make an operation incorrect by itself.
The right review unit is the safety contract. Start with the safe API claim: what may every safe caller rely on? Then identify the exact unchecked operation rather than treating the surrounding block as one mysterious hazard. Write down the alignment, bounds, initialization, aliasing, lifetime, thread, and ABI preconditions that apply. For each precondition, name who establishes it—a constructor, caller, type invariant, operating system, or foreign component—and follow it through mutation, panic, concurrency, and destruction. Focused tests, Miri, sanitizers, fuzzing, and specialist review can increase confidence where applicable, but they supplement that argument rather than replace it.
Safe code may call a sound safe abstraction with unsafe internals. That is Rust’s intended architecture: concentrate manually proved operations behind an API the compiler can then enforce for every caller. The safety budget is spent at the smallest reviewable boundary.
Undefined behavior is not “unpredictable output”
Undefined behavior means Rust provides no program semantics for the execution. It is not one more error result or a promise that the program will fail visibly. Optimization may assume undefined behavior does not occur; once it does, observations cannot support ordinary reasoning about later results.
This is why “it worked in a test” is not a safety proof. A test exercises particular inputs, schedules, targets, and optimizations. It can increase confidence and catch violations modeled by a tool, but the core argument must establish the unsafe operation’s preconditions for all safe callers.
Conversely, a panic is defined behavior. The language and standard library define how the panic mechanism begins; the build profile and boundary determine unwind versus abort behavior. A panic can be an availability failure, but it is categorically different from undefined behavior.
Zero-cost abstraction is a design goal, not a receipt
Rust is designed so high-level abstractions can often compile to code comparable to a direct implementation. “Zero-cost abstraction” does not mean zero execution time, zero allocation, zero code size, zero compile time, or automatic optimality.
Consider several mechanisms from Chapter 1:
- A generic iterator pipeline may be monomorphized and optimized without virtual dispatch, but monomorphization can increase code size and compile time.
- A trait object can stabilize a dynamic boundary, but usually adds indirection and may require allocation depending on ownership.
Stringmakes ownership straightforward, but allocation and copying at boundaries remain real.- Bounds-checked indexing adds a runtime condition unless optimization proves it redundant; iterators may express the same traversal more safely.
Arcprovides shared ownership across threads, but cloning increments an atomic reference count.RefCellpermits mutation through shared ownership, but checks borrow state at runtime and can panic on violation.
The senior question is not “Does Rust eliminate overhead?” It is “Which costs exist, which are semantically required, which can the compiler remove for this build, and which matter for this workload?” Answer with a target, profile, inputs, and measurement.
Portability and ABI require explicit boundaries
Rust’s default representation does not promise a stable cross-language ABI for arbitrary types. Field layout, enum representation, symbol names, unwinding, allocator ownership, and platform types all matter. repr(C) addresses only specified representation questions; it does not make String, trait objects, references, or panics universally safe to pass across C.
Platform behavior also varies around paths, process creation, signals, sockets, integer widths such as usize, and target capabilities. A program that compiles on Linux has not thereby proven Windows or embedded behavior. Portability is a declared support matrix backed by compilation, tests, and boundary documentation.
At an ABI boundary, record at least:
- representation and calling convention;
- ownership of every allocation and who frees it;
- buffer pointer, length, capacity, nullability, and validity rules;
- error translation;
- callback lifetime and thread affinity;
- unwind policy;
- library and protocol versioning.
Alternatives for enforcing an invariant
The same rule can live at different enforcement layers:
| Layer | Example | Strength | Failure mode and cost |
|---|---|---|---|
| Type construction | Non-empty AccountId newtype |
Invalid state cannot enter through safe constructor | More types and conversion points |
| Compile-time borrowing | One mutable reference | Conflicting access rejected | May require ownership redesign |
| Runtime check | Slice indexing or RefCell |
Flexible dynamic behavior | Panic or error on violation |
| Synchronization | Mutex<State> |
Coordinates thread access | Contention, poisoning policy, deadlock risk |
| Protocol | Idempotency key | Protects external side effect across retries | Storage and lifecycle complexity |
| Unsafe precondition | Raw slice construction | Enables low-level boundary | Undefined behavior if proof is wrong |
Choose the earliest layer that accurately expresses the invariant without making the interface unusable. Moving a dynamic business rule into types may explode state combinations. Leaving a stable local invariant to comments wastes compiler leverage. The boundary should follow the actual scope of the fact.
Rewrite the launch claim
If the corresponding records exist, the original sentence can now be replaced without either advocacy or apology:
The reviewed path contains no local unsafe operations; dependency and FFI unsafe boundaries are inventoried; expected input failures return
Result; the one-time approval transition occurs under one lock acquisition; and named tests and operating policy cover panic, allocation, and process-exit behavior.
Each clause creates a review question. “Sound dependencies” requires an unsafe and supply-chain boundary. The approval clause requires inspection of the whole state transition, not merely the presence of a mutex. The failure-policy clause requires named behavior for unwind, abort, retry, and recovery.
Other broad claims need the same treatment. A guard can promise to release a local lock on return and unwind while leaving abort and external-effect recovery to another mechanism. An abstraction can be reported as showing no material regression against a baseline for one target, release profile, and workload; it cannot be declared free. A repr(C) boundary becomes reviewable only when representation, ownership, nullability, errors, callbacks, and unwinding are specified and exercised across languages.
Bounded claims do more than reduce rhetoric. They expose the evidence a reviewer can demand and the obligations a design still carries.
Senior review checklist
- Which exact classes of failure does the design prevent at compile time?
- Which checks occur at runtime, and do they return, block, or panic?
- Which unsafe preconditions exist beneath safe APIs and dependencies?
- Can timing violate a business invariant even though there is no data race?
- Which cleanup paths run on return, unwind, abort, and process loss?
- Is each expected failure an error at the right boundary?
- Are undefined behavior and panic discussed as distinct categories?
- Do cost claims name a target, profile, workload, and measurement?
- Are ABI and platform assumptions documented beyond
repr(C)? - Does the launch claim describe residual obligations rather than implying total safety?
Review-board exercise: bound every promise
You receive a service review containing these statements: “Safe Rust means no crashes,” “the mutex makes approval race-free,” “iterators have no cost,” and “repr(C) makes the plugin portable.” Produce a correction memo with four sections. For each claim, state the strongest valid guarantee, construct one counterexample that remains within safe Rust where possible, identify the missing engineering evidence, and propose a bounded release criterion.
A strong memo distinguishes documented guarantees, current implementation observations, conventions, and recommendations. It must not weaken Rust’s real guarantees merely to avoid overstatement.
Durable takeaways
- Safe Rust provides powerful but bounded memory-safety and data-race guarantees; it does not prove the product correct or available.
- Race conditions, resource exhaustion, deadlock, panic policy, and external effects remain system-design obligations.
- RAII makes ordinary cleanup predictable, while abort and distributed recovery need explicit protocols.
- Unsafe code is a proof-obligation boundary inside the same language, not an escape from all rules.
- Cost and portability claims require defined builds, targets, workloads, and boundary contracts.
A bounded claim is still incomplete if its compiler, edition, target, and support window are ambiguous. The next engineering task is to make those version dimensions explicit and reproducible.
Sources and version notes
- Verified against Rust 1.97.0 and Rust 2024 Edition on 2026-07-11. The chapter states documented language and library contracts; implementation examples are labeled as such.
- The Rust Reference: behavior considered undefined
- The Rust Reference: destructors
- The Rust Reference: behavior not considered unsafe
- The Rustonomicon: races
- Standard library: panic
- Reproduction:
cargo run --example safety_contractin the Part I fixture.
Continue reading
Full table of contents