Rust best practices are the conventions that let you get the most out of the compiler: consistent tooling, borrowing over cloning, Result-based error handling, types that make invalid states impossible, disciplined unsafe, and the security checks Rust does not do for you.
Updated for 2026. This guide started life as a Rust security checklist. It has been expanded to cover the full set of Rust best practices that teams ask about in code review, with security as the connecting thread. Every recommendation targets current stable Rust and the 2024 edition.
Rust has a well-earned reputation for safety. Its ownership model prevents whole classes of memory bugs at compile time, which drastically shrinks the attack surface of anything written in it. But “memory safe” is not the same as “secure,” and “compiles” is not the same as “idiomatic.” Rust code can still have logic mistakes, integer wraparound, injection bugs, resource exhaustion, and vulnerable dependencies. This guide walks through what good Rust looks like in 2026, section by section, so that the safety you get for free from the compiler is matched by the safety you build in yourself. Tools like Corgea AI SAST cover the application-logic layer that type safety alone cannot guarantee, and we point to where that fits along the way.
Project and tooling hygiene
Most Rust best practices are cheap to enforce mechanically. If you set up the tooling once, the rest of this guide becomes something the CI pipeline nags you about rather than something you have to remember.
Format and lint on every change
rustfmt and Clippy are the baseline. Run them locally and fail the build on drift:
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
Rather than passing lint flags on the command line, declare them in Cargo.toml so every contributor and every CI job sees the same policy. The [lints] table has been stable since Rust 1.74:
[lints.rust]
unsafe_code = "forbid" # remove in crates that genuinely need unsafe
missing_docs = "warn"
[lints.clippy]
unwrap_used = "warn"
expect_used = "warn"
undocumented_unsafe_blocks = "deny"
cast_possible_truncation = "warn"
In a workspace, define these once under [workspace.lints] and opt each crate in with lints.workspace = true.
Pin a minimum supported Rust version
Set rust-version in Cargo.toml. Cargo refuses to build with an older toolchain, and with the MSRV-aware resolver (resolver = "3", the default for the 2024 edition) it will also avoid pulling in dependency versions that need a newer compiler than you support:
[package]
name = "payments"
edition = "2024"
rust-version = "1.85"
Test against both the MSRV and the current stable in CI. Skipping the MSRV job is how “supports 1.85” quietly becomes a lie six months later.
Audit dependencies with cargo-audit and cargo-deny
cargo audit checks Cargo.lock against the RustSec advisory database. cargo deny goes further: a deny.toml file lets you fail the build on advisories, disallowed licenses, duplicate versions of the same crate, and crates pulled from anywhere other than crates.io.
# deny.toml
[advisories]
yanked = "deny"
unmaintained = "workspace"
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC", "Unicode-3.0"]
[bans]
multiple-versions = "warn"
wildcards = "deny"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
Run both in CI:
cargo install cargo-audit cargo-deny --locked
cargo audit
cargo deny check
Commit Cargo.lock for binaries and build with cargo build --locked so the lockfile is honored rather than silently re-resolved. For higher-assurance codebases, cargo vet records that a human actually reviewed each crate version you depend on. If you want the same signal across every language in your organization, a dependency scanning tool that understands Cargo.lock gives you one policy instead of one per ecosystem; our best SCA tools roundup compares the options.
Workspace layout
Split anything beyond a small binary into a workspace. A common and effective shape is one library crate per bounded concern plus thin binaries that wire them together:
.
├── Cargo.toml # [workspace] members, shared deps, shared lints
├── crates/
│ ├── core/ # domain types, no I/O
│ ├── storage/ # database access
│ └── api/ # HTTP handlers
└── bin/
└── server/ # main.rs: config + composition only
Declare versions once in [workspace.dependencies] and reference them with dep = { workspace = true }. This keeps every crate on the same version of serde or tokio, which is both a build-time win and a security win: one place to bump when an advisory lands.
A minimal CI pipeline
A Rust CI job should at least run cargo fmt --check, cargo clippy -- -D warnings, cargo test --locked, cargo audit, and cargo deny check, plus a Miri job if you have unsafe. Cache the target directory and keep the whole thing fast enough that nobody is tempted to skip it.
Ownership and borrowing idioms
The borrow checker is the feature people fight with first and appreciate most later. The idioms below are how experienced Rust developers write code that satisfies it without contortions.
Prefer borrowing in function signatures
Accept the most general borrowed form and let the caller decide about ownership. &str instead of &String, &[T] instead of &Vec<T>, &Path instead of &PathBuf:
// Accepts String, &String, &str, and string literals.
fn normalize_email(raw: &str) -> String {
raw.trim().to_ascii_lowercase()
}
// Accepts Vec<u8>, arrays, slices.
fn checksum(bytes: &[u8]) -> u32 {
bytes.iter().fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(*b as u32))
}
Take ownership only when the function actually needs to keep or consume the value, such as pushing into a collection or sending it to another thread.
Treat clone as a design decision
.clone() is a legitimate tool, but a clone added purely to silence the borrow checker usually means the ownership story is unclear. Before cloning, ask whether the function should borrow, whether the data should be Rc/Arc-shared, or whether the struct should hold a reference. Clippy’s redundant_clone and needless_pass_by_value lints catch the mechanical cases.
Use Cow when ownership is conditional
Cow<'_, str> lets you return a borrowed value in the common case and an owned one only when you had to change something:
use std::borrow::Cow;
fn strip_bom(input: &str) -> Cow<'_, str> {
match input.strip_prefix('\u{FEFF}') {
Some(rest) => Cow::Borrowed(rest),
None => Cow::Borrowed(input),
}
}
fn escape_html(input: &str) -> Cow<'_, str> {
if !input.contains(['<', '>', '&', '"']) {
return Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len() + 8);
for c in input.chars() {
match c {
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'&' => out.push_str("&"),
'"' => out.push_str("""),
_ => out.push(c),
}
}
Cow::Owned(out)
}
This pattern is everywhere in parsers, sanitizers, and path normalizers, which are exactly the places where security-relevant code lives.
Let lifetimes be inferred, but understand them
Lifetime elision handles most functions. When you do need explicit lifetimes, the rule of thumb is that structs holding references are for short-lived views (iterators, parsers over a buffer), and long-lived state should own its data. A struct with a lifetime parameter that ends up stored in an Arc is usually a sign that it should own its fields instead.
Error handling
Rust’s error story is one of its best features, and also where a lot of otherwise-good code goes wrong.
Return Result and propagate with ?
Any function that can fail should return Result<T, E>. The ? operator propagates errors upward with automatic From conversion, so most function bodies stay flat:
use std::fs;
use std::path::Path;
fn load_config(path: &Path) -> Result<Config, ConfigError> {
let raw = fs::read_to_string(path)?; // io::Error -> ConfigError
let cfg: Config = toml::from_str(&raw)?; // toml::de::Error -> ConfigError
cfg.validate()?; // ConfigError
Ok(cfg)
}
thiserror for libraries, anyhow for applications
The two crates solve different problems. thiserror derives std::error::Error for an enum you define, giving callers concrete variants to match on. anyhow gives you a single opaque anyhow::Error type that any error converts into, plus .context() for adding human-readable breadcrumbs.
// In a library crate: typed, matchable errors.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("could not read config file")]
Io(#[from] std::io::Error),
#[error("config is not valid TOML")]
Parse(#[from] toml::de::Error),
#[error("field `{field}` is invalid: {reason}")]
Invalid { field: &'static str, reason: String },
}
// In a binary: add context, bubble up, report once at the top.
use anyhow::{Context, Result};
fn main() -> Result<()> {
let cfg = payments::load_config("config.toml".as_ref())
.context("loading configuration")?;
run(cfg).context("running server")
}
The mistake to avoid is exposing anyhow::Error from a library’s public API. Callers then cannot distinguish “file not found” from “permission denied” without string matching, which is both fragile and a common source of security bugs (fail-open on an unexpected error).
No unwrap in libraries or on untrusted paths
.unwrap() and .expect() turn a recoverable error into a panic. In a CLI tool run by a human, that is annoying. In a network service, it is a denial-of-service primitive: any input that reaches the unwrap crashes the worker.
// Bad: crashes the process if the header is missing or not UTF-8.
let host = req.headers().get("host").unwrap().to_str().unwrap();
// Good: turn missing or malformed input into a 400, not a crash.
let host = req
.headers()
.get("host")
.and_then(|v| v.to_str().ok())
.ok_or(ApiError::BadRequest("missing host header"))?;
Enforce this with unwrap_used and expect_used in your [lints.clippy] table. Reserve expect("reason") for invariants that genuinely cannot fail by construction, and say why in the message. Tests and examples can unwrap freely; add #![allow(clippy::unwrap_used)] at the top of test modules.
Have a panics policy
Decide, per binary, what a panic means. For a server, the usual answer is that panics are bugs and should be isolated: Tokio’s task model already contains a panic to the task, and JoinHandle returns Err so you can log it. For embedded or safety-critical builds, panic = "abort" is common because unwinding costs binary size. Whatever you pick, document it, and do not use std::panic::catch_unwind as a general-purpose exception handler; it belongs at FFI boundaries and in test harnesses.
Type-driven design
The type system is your first and cheapest security control. Every invalid state you make unrepresentable is a class of bug you never have to test for.
Newtypes for domain identifiers
Wrap primitives so that the compiler tells the difference between a user ID and an order ID:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OrderId(u64);
fn get_order(user: UserId, order: OrderId) -> Result<Order, StoreError> {
// ...
}
let uid = UserId(42);
let oid = OrderId(99);
// get_order(oid, uid); // compile error: mismatched types
get_order(uid, oid)?;
This is not academic. Swapped-argument bugs in authorization checks are one of the most common ways an insecure direct object reference (IDOR) gets into a codebase, and a newtype makes them a compile error.
Parse, don’t validate
Instead of checking a String every time you use it, parse it once into a type that can only hold valid values:
pub struct Email(String);
impl Email {
pub fn parse(raw: &str) -> Result<Self, ValidationError> {
let trimmed = raw.trim();
if trimmed.len() > 254 || !trimmed.contains('@') {
return Err(ValidationError::Email);
}
Ok(Email(trimmed.to_ascii_lowercase()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
Downstream code that takes an Email never has to re-check it, and there is no way to construct one without going through parse. Keep the inner field private for exactly that reason.
Enums instead of booleans
A function with two bool parameters has four combinations and no compiler help telling them apart. Enums are self-documenting and exhaustively matched:
pub enum Visibility { Public, Private, Unlisted }
pub enum Overwrite { Allow, Refuse }
fn publish(doc: &Doc, vis: Visibility, overwrite: Overwrite) { /* ... */ }
When you add a fourth Visibility variant, every match in the codebase fails to compile until it handles it. That is the point.
Builders and typestate for complex construction
For structs with many optional fields, a builder keeps the call site readable and lets you validate at build(). For construction that must happen in a specific order, the typestate pattern encodes the state machine in generics so an out-of-order call is a compile error:
pub struct Request<State> { inner: RequestParts, _state: std::marker::PhantomData<State> }
pub struct NoAuth;
pub struct Authed;
impl Request<NoAuth> {
pub fn authenticate(self, token: &Token) -> Result<Request<Authed>, AuthError> { /* ... */ }
}
impl Request<Authed> {
pub fn send(self) -> Result<Response, SendError> { /* ... */ }
}
// Request<NoAuth> has no send() method, so an unauthenticated send cannot compile.
Use must_use and non_exhaustive
Mark functions whose return value carries meaning with #[must_use], so ignoring the result is a warning. Result is already must_use; add it to builders, guards, and anything that returns a handle. Mark public enums and structs that will grow with #[non_exhaustive] so adding a variant later is not a breaking change for downstream crates.
Unsafe hygiene
unsafe is not a code smell in itself; the standard library is full of it. It is a promise that you, rather than the compiler, have checked the invariants. The best practices are about keeping that promise small and visible.
Minimize the surface
Keep each unsafe block as short as possible, ideally one operation. Put #![forbid(unsafe_code)] at the top of any crate that does not need unsafe so it cannot creep in through a well-meaning PR. In a workspace, most crates should have it; only the FFI and low-level crates should not.
Document the invariant with a SAFETY comment
Every unsafe block should have a comment starting with // SAFETY: that names the specific invariant making the operation sound. Clippy’s undocumented_unsafe_blocks lint enforces this. The comment is not boilerplate; it is what a reviewer will check.
pub fn first_byte(buf: &[u8]) -> Option<u8> {
if buf.is_empty() {
return None;
}
// SAFETY: we checked above that `buf` has at least one element,
// so index 0 is in bounds.
Some(unsafe { *buf.get_unchecked(0) })
}
Encapsulate unsafe behind a safe API
The public function should be safe to call with any arguments. All the checking that makes the inner unsafe sound happens inside the wrapper, so callers cannot get it wrong. If a function is only sound when the caller upholds something, mark the function itself unsafe fn and document the contract in a # Safety section of its doc comment. Since the 2024 edition, unsafe_op_in_unsafe_fn warns by default, so operations inside an unsafe fn still need their own unsafe block and SAFETY comment.
The original version of this guide showed the classic failure mode, and it is still worth seeing:
use std::ptr;
let p: *const i32 = ptr::null();
unsafe {
// Undefined behavior: dereferencing a null raw pointer.
println!("{}", *p);
}
Nothing about this compiles differently from correct code. The only defense is review, tooling, and keeping the amount of unsafe small enough to review.
Run Miri
Miri is an interpreter that detects undefined behavior in Rust tests: out-of-bounds access, use-after-free, invalid aliasing, uninitialized reads, and data races in single-threaded code. It is slow, so run it on the crates that contain unsafe rather than the whole workspace:
rustup +nightly component add miri
cargo +nightly miri test -p lowlevel-crate
cargo geiger counts unsafe usage across your dependency tree, which is useful for deciding which dependencies deserve a closer look.
Concurrency and async
Rust’s “fearless concurrency” claim is real for data races: safe Rust will not let two threads mutate the same memory without synchronization. It does not prevent deadlocks, starvation, or logic races, and async introduces a new set of pitfalls.
Send and Sync are your friends
Send means a value can move to another thread; Sync means it can be shared by reference. Both are auto traits, so most types get them for free. When the compiler says a future is not Send, you are almost always holding a non-Send value (a MutexGuard, an Rc, a RefCell borrow) across an .await. Fix the ownership; do not reach for unsafe impl Send.
Channels versus shared state
The two idiomatic ways to coordinate threads are Arc<Mutex<T>> (shared state, exclusive access) and channels (transfer ownership of messages). The original guide’s counter example still applies:
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0u64));
let handles: Vec<_> = (0..5)
.map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || {
let mut n = counter.lock().expect("counter mutex poisoned");
*n += 1;
// guard dropped here, lock released
})
})
.collect();
for h in handles {
h.join().expect("worker thread panicked");
}
println!("final = {}", *counter.lock().expect("counter mutex poisoned"));
The rule of thumb: use a lock for small, short-lived critical sections on state that several parties genuinely need to read and write. Use channels (std::sync::mpsc, crossbeam, or tokio::sync::mpsc) when one party produces work and another consumes it. Channels make ownership transfer explicit and are much harder to deadlock. For scoped parallelism over a slice, std::thread::scope lets threads borrow from the stack safely, and rayon gives you data parallelism with almost no ceremony.
Tokio pitfalls
Async Rust is a cooperative scheduler, and most production bugs come from forgetting that:
- Do not hold
std::sync::Mutexacross an.await. The guard is notSend, and even where it compiles, it blocks every other task on the same worker thread. Scope the lock so it drops before the.await, or usetokio::sync::Mutexif you genuinely must hold it across a yield point (rarely). - Do not block the runtime. CPU-heavy work, synchronous file I/O, and blocking clients belong in
tokio::task::spawn_blocking. A single blocking call in a handler stalls every task sharing that worker. - Dropping a
JoinHandledoes not cancel the task. It detaches. If you want structured concurrency where the child dies with the parent, useJoinSet,abort()explicitly, or a cancellation token. - Put a timeout on everything that talks to the network.
tokio::time::timeout(dur, fut)is one line and turns an unbounded hang into a handled error.
Cancellation safety
A future is cancellation-safe if dropping it partway through does not lose data or leave state inconsistent. This matters inside tokio::select!: every branch that loses the race is dropped. tokio::sync::mpsc::Receiver::recv is cancellation-safe; AsyncReadExt::read_exact is not, because the bytes read before cancellation are gone. Check the docs for each method you put in a select! arm, and prefer designs where the cancellable operation is the only side-effect-free one.
Rust security best practices
Everything above makes Rust code better. This section is about the vulnerabilities that memory safety does not touch. If you have read our Python security best practices or JavaScript security best practices guides, many of these will look familiar. That is the point: application-layer security is language-independent.
Validate and sanitize all input
Treat every byte from a user, a file, the network, or an environment variable as hostile until you have parsed it into a typed value. Validate lengths, ranges, character sets, and structure at the boundary, and reject rather than “clean” wherever you can. Stripping dangerous characters, as the original version of this guide showed, is better than nothing but is a denylist, and denylists are always incomplete:
// Weak: a denylist misses encodings, unicode lookalikes, and whatever you forgot.
fn strip_shell_metachars(data: &str) -> String {
data.replace(|c: char| matches!(c, ';' | '|' | '"' | '&' | '$' | '`'), "")
}
// Strong: an allowlist that says exactly what is acceptable.
fn parse_username(raw: &str) -> Result<Username, ValidationError> {
let ok = (3..=32).contains(&raw.len())
&& raw.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
ok.then(|| Username(raw.to_owned())).ok_or(ValidationError::Username)
}
Prefer parameterized queries for SQL (sqlx, diesel, and rusqlite all support them), typed builders for HTML, and structured APIs for anything else, so the question of escaping never arises.
Integer overflow: release mode wraps
This surprises people coming from managed languages. Rust panics on integer overflow in debug builds, but in release builds arithmetic wraps silently by default because the checks cost a few percent of throughput. An attacker who controls a length or a count can push a calculation past its maximum and wrap it to a tiny number, bypassing a bounds check. The standard library itself had an overflow bug of this shape, CVE-2018-1000810 in str::repeat.
Two defenses, and you should use both:
# Cargo.toml: keep overflow checks in release builds.
[profile.release]
overflow-checks = true
// Be explicit about what overflow should do on untrusted arithmetic.
fn total_size(count: usize, item_len: usize) -> Option<usize> {
count.checked_mul(item_len)?.checked_add(HEADER_LEN)
}
let capped = requested.saturating_add(padding); // clamps at usize::MAX
let hash = seed.wrapping_mul(0x9E37_79B9); // wrapping is the intent here
Use try_from when narrowing (u64 to u32, usize to i32) instead of as, which truncates silently. Clippy’s cast_possible_truncation lint flags the risky ones.
Denial of service and resource limits
Memory safety does not stop an attacker from making you allocate 4 GB. Any place where an untrusted number drives an allocation, a loop, or a recursion depth needs a bound:
const MAX_ITEMS: usize = 10_000;
// Bad: `len` comes straight from the wire.
let mut items = Vec::with_capacity(len);
// Good: cap it before it touches the allocator.
if len > MAX_ITEMS {
return Err(ProtocolError::TooManyItems(len));
}
let mut items = Vec::with_capacity(len);
Set request body limits and per-connection timeouts in your HTTP framework (axum, actix-web, and hyper all expose them). Cap decompression output, because a 1 MB gzip bomb can expand to gigabytes. Use the regex crate rather than a backtracking engine; it guarantees linear-time matching, which removes an entire class of ReDoS. For serde_json, note that the default recursion limit protects you from deeply nested documents, but a hostile 100 MB array of small objects is still your problem, so read into a bounded buffer first.
Dependency supply chain
A Rust binary is typically 90 percent other people’s code. Beyond cargo audit and cargo deny, keep these in mind:
- Build scripts and proc macros run at compile time. A malicious
build.rsowns your developer laptop and your CI runner before you ship anything. This is whycargo vetand reviewing new dependencies matter even for “just a dev dependency.” - Watch for typosquats. Check the crate name, download count, repository link, and maintainer before adding anything. Enterprises often mirror crates.io and allowlist crates.
- Prefer well-maintained crates with few transitive dependencies.
cargo treeshows what you are really pulling in. - Lock and verify. Commit
Cargo.lock, build with--locked, and considercargo auditableto embed the dependency list in the binary so you can answer “is this deployed artifact affected?” later.
Corgea’s dependency scanning, secret scanning, and code scanning added Rust support in the August 20, 2026 changelog, so Cargo.lock audits can sit in the same pipeline as your other languages.
Secrets and sensitive data
Rust does not stop you from logging a password. Three habits close most of the gap:
use secrecy::{ExposeSecret, SecretString};
use zeroize::Zeroizing;
pub struct DbConfig {
pub url: String,
pub password: SecretString, // Debug prints "[REDACTED]", never the value
}
fn derive_key(passphrase: &str) -> Zeroizing<[u8; 32]> {
let mut key = Zeroizing::new([0u8; 32]);
// ... fill `key` ...
key // zeroed on drop, even on early return
}
First, wrap secrets in a type whose Debug impl redacts them (secrecy does this) and never derive Debug on a struct that holds raw secret strings. Second, use zeroize for key material so it is wiped from memory on drop rather than lingering in a freed heap page. Third, compare secrets with a constant-time function such as subtle::ConstantTimeEq rather than ==, which returns early on the first mismatched byte and leaks timing.
Use vetted cryptography
Do not write your own. The Rust ecosystem has audited implementations for everything you need: the RustCrypto family (aes-gcm, chacha20poly1305, sha2, hmac, argon2), ring for a batteries-included primitive set, and rustls for TLS. Use OS-backed randomness (rand::rngs::OsRng or getrandom) for anything security-relevant, and a memory-hard password hash (argon2) rather than a plain hash for stored credentials.
use sha2::{Digest, Sha256};
let digest = Sha256::digest(b"content to fingerprint");
println!("sha256 = {digest:x}");
For passwords specifically, Sha256 is the wrong tool; use argon2::Argon2::default().hash_password(...) with a random salt via the password-hash API.
Serde deserialization limits
serde will happily deserialize whatever it is given. Tighten the contract on types that come from the outside:
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateUser {
#[serde(deserialize_with = "bounded_string::<64, _>")]
pub username: String,
pub email: String,
#[serde(default)]
pub roles: Vec<Role>, // an enum, so unknown roles are rejected
}
deny_unknown_fields blocks mass-assignment style attacks where a client sneaks in "is_admin": true. Bounding string and collection sizes in a deserialize_with function, or validating right after deserialization, keeps a single request from becoming a memory bomb. Use enums for anything with a fixed set of values so that invalid variants fail at parse time.
Path traversal
Path::join has a sharp edge: joining an absolute path replaces the base entirely, so base.join(user_input) with user_input = "/etc/passwd" returns /etc/passwd. Normalize and check after resolving:
use std::path::{Component, Path, PathBuf};
fn safe_join(base: &Path, untrusted: &str) -> Result<PathBuf, FsError> {
let rel = Path::new(untrusted);
if rel.is_absolute()
|| rel.components().any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
{
return Err(FsError::Traversal);
}
let full = base.join(rel).canonicalize()?;
let base = base.canonicalize()?;
full.starts_with(&base).then_some(full).ok_or(FsError::Traversal)
}
Rejecting .. components before joining, then confirming with canonicalize and starts_with, handles symlinks and platform-specific prefixes. Remember that canonicalize requires the file to exist; for paths you are about to create, check the parent instead.
Command injection
std::process::Command does not go through a shell, so arguments passed with .arg() are never interpreted for metacharacters. The injection risk appears only when you build a string and hand it to sh -c:
use std::process::Command;
// Bad: user input becomes shell syntax.
Command::new("sh").arg("-c").arg(format!("convert {input} out.png")).status()?;
// Good: each argument is passed to the program directly.
Command::new("convert").arg(&input).arg("out.png").status()?;
Even with .arg(), validate the input against what the target program expects. A filename beginning with - can still be parsed as a flag by the child process, so prefix with -- or reject leading dashes.
FFI boundaries
Calling into C, or exposing Rust to C, is where most of a typical project’s unsafe lives. Treat the boundary like a network boundary:
- Validate every pointer for null and every length against the buffer it describes before touching memory.
- Use
CStr/CStringfor strings and never assume a C string is UTF-8;CStr::to_str()returns aResultfor a reason. - Do not let a panic unwind into C. Since Rust 1.81, a panic escaping an
extern "C"function aborts the process; if the C side can handle unwinding, declareextern "C-unwind", otherwise wrap the body instd::panic::catch_unwindand return an error code. - Prefer
bindgenfor generating declarations andcxxfor C++ so signatures are derived from headers rather than typed by hand. - Document who owns each allocation and which side frees it; mismatched allocators are a classic FFI crash.
Where static analysis fits
The compiler catches memory errors. Clippy catches idiom errors. Neither one knows that get_order should have checked that the user owns the order, or that the value flowing into Command::arg came from an HTTP query string. That data-flow and authorization reasoning is what SAST does, and it is the layer that closes the gap between “memory safe” and “secure.” An AI-native SAST that understands your application’s logic can flag the IDOR, the missing bound, and the secret in a log line on the pull request where they were introduced. Corgea’s open-source Sighthound scanner is itself written in Rust, which is a decent sign of how well the language suits this kind of tooling.
Testing
Rust’s built-in test harness is good enough that there is no excuse for an untested crate. The best practices are about which kinds of tests to write.
Unit, integration, and doc tests
Put unit tests in a #[cfg(test)] mod tests block next to the code they exercise. Put integration tests in tests/, where they see only the public API, so they double as a check that the API is usable. Write doc examples for public functions; cargo test compiles and runs them, so they never rot. Tests that exercise security behavior (that the sanitizer rejects the payloads you care about, that a traversal attempt fails, that an overflow is caught) deserve their own module so a reviewer can find them.
Property-based testing with proptest
Example-based tests check the cases you thought of. Property tests generate thousands of inputs and check an invariant, then shrink any failure to a minimal reproduction:
use proptest::prelude::*;
proptest! {
#[test]
fn safe_join_never_escapes_base(input in "\\PC{0,64}") {
let base = std::env::temp_dir();
if let Ok(p) = safe_join(&base, &input) {
prop_assert!(p.starts_with(base.canonicalize().unwrap()));
}
}
#[test]
fn parse_then_display_roundtrips(email in "[a-z]{1,10}@[a-z]{1,10}\\.com") {
let parsed = Email::parse(&email).unwrap();
prop_assert_eq!(parsed.as_str(), email.to_ascii_lowercase());
}
}
Parsers, serializers, sanitizers, and anything with a “roundtrip” or “never escapes” invariant are the natural targets.
Fuzzing with cargo-fuzz
Fuzzing feeds random and mutated bytes into a function for hours and reports crashes, hangs, and (under sanitizers) memory errors. cargo fuzz wraps libFuzzer and is the standard tool:
cargo install cargo-fuzz
cargo fuzz init
cargo +nightly fuzz run parse_packet
// fuzz/fuzz_targets/parse_packet.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let _ = mypacket::parse(data); // must never panic or hang
});
Anything that parses bytes from the outside world (file formats, protocol frames, config) should have a fuzz target, and a CI job that runs it for a few minutes on every merge plus a longer nightly run. If your project has unsafe, fuzzing under Miri or AddressSanitizer is the most reliable way to find the soundness bug you did not know about.
Supporting tools
cargo nextest for a faster, clearer test runner, cargo llvm-cov for coverage, insta for snapshot tests, and loom for exhaustively exploring thread interleavings in small concurrent data structures.
Performance and release settings
Rust is fast by default, and premature optimization is still the root of most unreadable code. Two things are worth doing on every project; everything else should wait for a profiler.
Release profile
The defaults are tuned for compile time as much as run time. For a shipping binary, turn on link-time optimization and single codegen unit; expect a noticeably smaller and faster artifact at the cost of a slower release build:
[profile.release]
lto = "fat" # or "thin" for a faster build with most of the benefit
codegen-units = 1
opt-level = 3
strip = "symbols" # smaller binary; keep debug = 1 instead if you profile in prod
overflow-checks = true # from the security section; the cost is small
Keep panic = "unwind" unless you have a specific reason to abort; unwinding is what lets Tokio isolate a panicked task.
Avoid allocation in hot loops
The most common Rust performance mistake is allocating inside a loop: building a String per iteration, collecting into a Vec you immediately iterate again, or cloning to satisfy the borrow checker. Reuse buffers with clear(), prefer lazy iterator chains over intermediate collections, slice &str instead of creating Strings, and pre-size with with_capacity. Measure with criterion and cargo flamegraph before changing anything else; if the profile says the time is in clone, go back to the ownership section.
Rust best practices checklist
- Run
cargo fmt --checkandcargo clippy -- -D warningsin CI, with lints declared in[lints]. - Set
rust-versioninCargo.tomland test against the MSRV. - Run
cargo auditandcargo deny checkon every build; commitCargo.lockand build with--locked. - Accept
&str,&[T], and&Pathin signatures; take ownership only when you need it. - Treat every
clone()as a design decision; useCowwhen ownership is conditional. - Return
Resultand propagate with?;thiserrorin libraries,anyhowin binaries. - Enable
clippy::unwrap_used; reserveexpectfor true invariants with a stated reason. - Use newtypes for identifiers, enums instead of booleans, and private fields so invalid values cannot be constructed.
- Add
#[must_use]to results that matter and#[non_exhaustive]to public types that will grow. - Keep
unsafeblocks tiny, write// SAFETY:comments, wrap them in safe APIs, and run Miri. #![forbid(unsafe_code)]in every crate that does not need it.- Never hold a
std::sync::Mutexguard across.await; usespawn_blockingfor blocking work; put timeouts on network calls. - Prefer channels for ownership transfer and short-lived locks for shared state.
- Parse untrusted input into typed values at the boundary with allowlists, not denylists.
- Set
overflow-checks = truein release and usechecked_/saturating_/wrapping_deliberately; usetry_frominstead ofaswhen narrowing. - Cap every untrusted length, body size, decompression output, and recursion depth.
- Use RustCrypto,
ring, orrustls;OsRngfor randomness;argon2for passwords. - Wrap secrets in
secrecy/zeroizetypes; never deriveDebugon them; compare withsubtle. - Add
#[serde(deny_unknown_fields)]and size bounds on externally sourced types. - Reject
..and absolute paths, then verify withcanonicalizeandstarts_with. - Pass arguments to
Commandwith.arg(); never build shell strings. - Validate pointers, lengths, and strings at FFI boundaries; never unwind a panic into C.
- Write property tests with
proptestand fuzz every parser withcargo fuzz. - Enable LTO and
codegen-units = 1for release; profile before optimizing anything else. - Run SAST on pull requests to catch the logic, authorization, and data-flow bugs the compiler cannot.
FAQ
What are the best practices for Rust?
Run cargo fmt and Clippy in CI, pin an MSRV, prefer borrowing over cloning, return Result and propagate with ?, model your domain with newtypes and enums, keep unsafe small and documented, use channels or tightly scoped locks for concurrency, audit dependencies with cargo audit and cargo deny, validate all input, handle integer overflow explicitly, and test with unit, property, and fuzz tests. The checklist above is the condensed version.
Is Rust secure by default?
Rust is memory-safe by default, which removes buffer overflows, use-after-free, and data races from safe code. It is not secure by default in the application sense: nothing in the language prevents injection, path traversal, broken authorization, silent integer wraparound in release builds, resource exhaustion, secret leakage, or a vulnerable dependency. Those need the same discipline, and the same static analysis, as any other language.
Should I use unwrap in Rust?
Not in library code or on any path that touches external input. unwrap converts a recoverable error into a panic, which in a service is a crash an attacker can trigger. Use ? to propagate, and use expect("reason") only for invariants that cannot fail by construction. Enforce it with the clippy::unwrap_used lint, and allow it freely in tests.
anyhow vs thiserror: which should I use?
thiserror for libraries, where callers need typed error variants they can match on. anyhow for applications, where you want to add context and report once at the top. Most real projects use both, with thiserror errors inside crates converting into anyhow::Error at the binary boundary. Do not expose anyhow::Error from a library’s public API.
How do I audit Rust dependencies?
cargo audit checks Cargo.lock against the RustSec advisory database. cargo deny enforces a policy for advisories, licenses, duplicate versions, and allowed sources. Commit Cargo.lock, build with --locked, and use cargo vet if you need a record of human review. Remember that build scripts and proc macros execute at compile time, so review new dependencies before they reach CI, not after.
How do I write safe unsafe Rust?
Make each unsafe block as small as possible, write a // SAFETY: comment naming the invariant that makes it sound, and wrap it in a safe function whose signature makes the invariant impossible to violate. Turn on clippy::undocumented_unsafe_blocks, run the crate’s tests under Miri, fuzz anything that parses bytes, and forbid unsafe_code in every crate that does not need it.