Crates, Cargo, and privacy — promises that travel
Lesson 20's proof that MyVec is sound rests on one sentence: only MyVec's own code writes len. Mark that field pub and a program with no unsafe in it compiles and reads memory nobody wrote. This lesson derives the rules that keep the sentence true. Privacy bounds the code a proof must read; modules and crates are the units of that bound; Cargo, semver and editions carry the promises to teams who never read your source, including one, Send, that a private field can quietly withdraw and a test can catch. The payoff: invariants that survive being depended on.
unsafe block's proof holds only while every line that can touch its invariant is known; the Rustonomicon's conclusion is that unsafe code's assumptions reach its whole module, and the module boundary, enforced by privacy, is the one reliable limit on them. Crates, Cargo, semver, editions and tests scale that boundary up to people who see only your signatures.New idea: privacy is the soundness boundary: an item's visibility names a finite region of code (its module, a parent's subtree, the crate), and only that region can break the invariant the item guards. The crate is the unit compiled, shared and versioned; semver and editions say what may change across it.
Forces next: We now have every mechanism. What does the whole language look like assembled in one program, and what does the bargain honestly cost — where is it the wrong tool, and what should a careful engineer still weigh?
len public and derive visibility from what the proof loses. (2) Name the units: module, crate, package. (3) Compute who can see an item. (4) Cargo: version ranges, frozen in a lock file. (5) Semver as a promise about types, including one no signature shows. (6) Editions: change the language without breaking a crate. (7) Tests and tools: promises the compiler runs.1 · The invariant anyone can write
Lesson 20's MyVec is three words, a pointer, a capacity and a length, and its unsafe blocks lean on what they say: len ≤ cap, and the first len slots are initialized (I1 and I2 there). The clearest case is the block that lends out len elements (Deref there, as_slice in this i32 cut-down). Now declare len with pub, as one of Lesson 20's mutants did:
mod myvec {
use std::{alloc::{alloc, Layout}, ptr::NonNull};
pub struct MyVec {
ptr: NonNull<i32>,
cap: usize,
pub len: usize, // the mistake: the invariant's state is public
}
impl MyVec {
pub fn with_capacity(cap: usize) -> MyVec { // push, pop, Drop: as in Lesson 20
let p = unsafe { alloc(Layout::array::<i32>(cap).unwrap()) }.cast::<i32>();
MyVec { ptr: NonNull::new(p).unwrap(), cap, len: 0 }
}
pub fn as_slice(&self) -> &[i32] {
// SAFETY: len <= cap, and the first len slots are initialized (the invariant)
unsafe { std::slice::from_raw_parts(self.ptr.as_ptr(), self.len) }
}
}
}
fn main() {
let mut v = myvec::MyVec::with_capacity(4);
v.len = 1000; // safe code: no `unsafe` in main
println!("{}", v.as_slice()[999]); // reads far past a four-slot allocation
}
It compiles, with no unsafe in main, and was not run: reading slot 999 of a four-slot allocation is undefined behavior, so safe code has broken the promise. The fault is in the proof: its SAFETY comment holds only if every function that writes len keeps the invariant, and with pub that is every function, in every crate, that can name a MyVec, including crates not yet written. A proof over code nobody can list is not a proof: the code that can write len must be a finite, known set.
Three roads lead there. A check of len on every access costs a compare per call, and checks against what? cap is just as writable. A comment saying "never write len" is a promise, C++'s answer, and C++'s private hides the name, not the bytes:
class MyVec {
int* ptr = nullptr; std::size_t cap = 0; std::size_t len = 0; // private
};
void hostile(MyVec& v) {
// v.len = 1000; // rejected: len is private
reinterpret_cast<std::size_t*>(&v)[2] = 1000; // compiles: the bytes are reachable anyway
}
That compiles (Apple clang 21, -std=c++20): a cast reaches what the name protected, Lesson 01's type-punning family. The third road is free at run time: make the field unnameable outside code the author controls. In safe Rust that is a wall, because safe code has no such cast; it may build a raw pointer, but following one takes unsafe. Delete pub:
mod myvec {
pub struct MyVec {
len: usize, // private again (ptr and cap as before)
}
impl MyVec {
pub fn new() -> MyVec { MyVec { len: 0 } }
pub fn len(&self) -> usize { self.len }
}
}
fn main() {
let mut v = myvec::MyVec::new();
v.len = 1000; // the write from above
println!("{}", v.len); // and a read
}
error[E0616]: field `len` of struct `MyVec` is private
--> src/main.rs:12:7
|
12 | v.len = 1000; // the write from above
| ^^^ private field
error[E0616]: field `len` of struct `MyVec` is private
--> src/main.rs:13:22
help: a method `len` also exists, call it with parentheses
The message says what is private and where, and for the read it found a public method of that name. It cannot know why nothing public writes len: the missing setter is the design. rustc --explain E0616 offers two fixes, make the field public or add a getter; for MyVec the first is Lesson 20's mutant. A privacy error asks which public method keeps the invariant (push? truncate?), not which field to widen.The need decides three design questions. The default? If items were public until marked, one forgotten keyword would reopen a proof; private by default makes forgetting safe. The unit? Not the type: an invariant often spans types that must all see the fields, a vector and its iterator, and unsafe code's assumptions reach the whole module. So an item is private to its module, nested modules included: a child sees everything in its ancestors, a parent nothing private in its children, which is why a tests module inside myvec may check len. Widening? In steps, each naming a region a proof would have to read:
| Written | Who may name the item | What a proof about it must read |
|---|---|---|
| nothing (private) | its module, and the modules inside it | one module |
pub(super) | the parent module, and inside it | the parent's subtree |
pub(in crate::a) | the enclosing module crate::a, and inside it | a's subtree |
pub(crate) | every module of this crate; no other crate | the crate |
pub | any code that can reach every module on its path, in any crate | everyone, from now on |
Fields, methods and associated functions carry their own visibility: pub struct opens the name, not the fields. That shuts the second door, a struct literal that skips the constructor making the invariant true:
mod myvec {
pub struct MyVec { cap: usize, len: usize }
impl MyVec {
pub fn with_capacity(cap: usize) -> MyVec { MyVec { cap, len: 0 } } // makes the invariant true
}
}
fn main() {
let honest = myvec::MyVec::with_capacity(4);
let forged = myvec::MyVec { cap: 4, len: 1000 }; // the same lie as `v.len = 1000`
}
So every MyVec starts life through a constructor. The remaining ways to fail, in one program:
mod collections {
pub mod myvec {
pub struct MyVec { len: usize }
impl MyVec {
pub fn new() -> MyVec { MyVec { len: 0 } }
fn grow(&mut self) {} // private
pub(super) fn audit(&self) -> usize { self.len } // `collections` and below
}
mod raw { pub fn layout() {} } // `pub` inside a private module
}
}
fn main() {
let mut v = collections::myvec::MyVec::new();
v.grow(); // a private method
v.audit(); // main is in the root, not in `collections`
collections::myvec::raw::layout(); // a private module on the path
}
E0603 names the first private step of a path and stops: layout is pub, but its module is not. E0624 is a private method; pub(super) on audit reaches collections, not the root. Each rule shrinks a proof back to a region, and regions are drawn in modules and bounded by the crate, two units we have used without defining.
2 · Modules, crates, packages
A module is a named scope for items: mod myvec { … } inline, or mod myvec; to read myvec.rs (or the older myvec/mod.rs). Modules form a tree rooted at crate, the file compilation starts from, and a path names an item through it: absolute from crate::, relative with self:: or super::. A crate is the smallest unit the compiler takes in at a time, a library or a binary with a main, so it is the unit privacy is enforced across: pub(crate) stops at its edge, and another crate can use an item only if it is pub at every step from the root. A package is what Cargo builds: a Cargo.toml, at most one library crate and any number of binaries (src/lib.rs, src/main.rs).
use, which Lesson 02 gave as a recipe, imports no code: it binds a shorter name to an item that already exists, only in the module where it is written, and compiles to nothing. The root's use does not reach a child:
use std::collections::HashMap; // a name for std's HashMap, in this module only
mod report {
pub fn tally(words: &[&str]) -> HashMap<String, u32> { // not a name in `report`
let mut counts = HashMap::new();
for w in words { *counts.entry(w.to_string()).or_insert(0) += 1; }
counts
}
}
use crate::reports::tally; // no module `reports`
fn main() {
let t = report::tally(&["a", "b", "a"]);
println!("{}", t.len() + totl); // no value `totl`
}
Lesson 02 counted four name-resolution codes among the ten most common errors. Three are here, one lookup failing in three positions: a name used as a type or a value (E0425), a path's first segment (E0433), a use (E0432). The fourth, E0412, is gone: on rustc 1.98.1 a missing type is E0425. The reverse of hiding is re-exporting: pub use storage::disk::flush; gives an item a second, public path, so the module holding it can stay private and free to change.
Go chooses otherwise (the Go track's Lesson 12): the package is the unit of compilation and of encapsulation, capitalization the whole access rule, internal/ a hidden subtree. Rust separates the two: modules draw privacy as finely as an invariant needs, the crate bounds it. Whether an access compiles is then a walk down a path, one visibility per step, which is mechanical enough to compute.
3 · Who can see this item?
The widget runs §1's rule: an access from module F compiles when each module on the item's path, then the item, is visible from F (F lies inside the region its visibility names); otherwise it fails with the code rustc reports, stopping where rustc stops (a literal on a private path never reaches its fields). Checked against rustc 1.98.1 on 320 random module trees and 3,853 accesses, inside the crate and from another crate: no disagreement.
What to try. On the first example drag the slider from 0 to 4: writing v.len fails with E0616 from another crate, crate and collections, and compiles from myvec and its tests: 2 of 5 modules. Set its visibility to pub(super): 3 of 5; pub(crate): 5 of 5, another crate still no; pub: everywhere, Lesson 20's mutant. Choose the access MyVec { ptr, cap, len }: E0451 outside myvec, while MyVec::with_capacity(…) compiles from all 5 modules and other crates. In the restaurant the root gets E0603 for module hosting, and front_of_house gets it for add_to_waitlist: a parent cannot see into a child's private items. In the source, pub mod hosting alone leaves 1 of 3; pub on the function too makes 3 of 3. The widget's "another crate" is a stand-in: which crates those are, and at which versions, is still open.
4 · Cargo, minimal
Privacy says what another crate may touch; Cargo says which crate, at which version:
[package]
name = "app"
version = "0.1.0"
edition = "2024" # this crate's edition (§6)
[dependencies]
geom = "1.4" # a requirement, not a version
cache = { path = "../cache", version = "1" } # a local crate, held to the same rule
cargo new writes the [package] table (edition = "2024" on cargo 1.98.1); cargo build, run and test (§7) build, run and test the package with its dependencies. A requirement is a range: "1.4" is a caret requirement, >=1.4.0, <2.0.0; in a 0.x crate the first non-zero digit counts as major, so "0.2.3" means >=0.2.3, <0.3.0. The resolver picks the highest published version in range and records every choice in Cargo.lock. Later builds reuse the locked versions while they satisfy the manifest (Cargo.toml), so an upstream release changes nothing until cargo update (and --locked makes Cargo fail rather than touch the file). That makes an application's build reproducible: the Cargo guide says, when in doubt, commit the lock file (and let CI also try the newest versions).
Two requirements on one crate meet in the resolver: if semver-compatible ("1.2", "1.4"), Cargo unifies them to one version, built once, whose types both users share; if not ("0.6", "0.7"), it builds both. Go's minimal version selection answers the other way: modules state minimum versions, the build takes the highest minimum anyone requires and never a newer release, so the answer cannot drift and needs no lock file; an incompatible major changes the import path (/v2), making two majors two modules by name. Out of scope: workspaces, and features and publishing beyond one fact each (Cargo builds a crate once with the union of its users' features, so a feature may only add; a published version is never overwritten, and yanking only keeps new resolutions from choosing it). All of it assumes that a version number promises what may have changed.
5 · Semver is a promise about types
A version is MAJOR.MINOR.PATCH, and Cargo reads it with one rule: versions are compatible when their first non-zero component is equal, so 1.4.0 → 1.9.2 must break no one and 1.9 → 2.0 may. What counts as breaking is listed in Cargo's SemVer chapter, whose guidelines mostly ask: would code that compiled against the old version stop compiling?
| Change in a library | Class | Why |
|---|---|---|
| rename, move or remove a public item | major | downstream paths stop resolving |
| add a public item | minor | nothing named it (glob imports aside) |
| add a private field to a struct whose fields are all public | major | downstream literals stop compiling (below) |
| add or remove private fields when one already exists | minor | no one outside could write a literal anyway |
| add a trait method without a default | major | downstream impls lack it (E0046, Lesson 13) |
add an enum variant without #[non_exhaustive] | major | downstream matches stop being exhaustive (E0004); #[non_exhaustive] makes other crates write a wildcard arm, so the variant is free |
| tighten a generic bound; loosen one | major; minor | a caller meeting the old bound may miss the new |
The third row, compiled: the library added a private field, and the downstream line did not change.
mod geom { // version 1.1.0: one private field added
pub struct Point { pub x: f64, pub y: f64, cached_norm: f64 }
}
fn main() {
let p = geom::Point { x: 3.0, y: 4.0 }; // written against 1.0.0, unchanged
println!("{}", p.x);
}
error: cannot construct `Point` with struct literal syntax due to private fields
= note: ...and other private field `cached_norm` that was not provided
(No code this time; E0451 is for a literal that names a private field.) A struct marked #[non_exhaustive] from its first release, with a constructor, can grow fields in a minor version. Now the change no row lists. Lesson 16 showed that Send and Sync are auto traits, derived from every field, private ones included, so a private field is part of the public contract. Version 1.0.0 of a cache crate holds one private u64; an application moves a Cache into a thread. Version 1.1.0 adds a private field, minor by the rows above, and changes no signature:
$ cargo --version
cargo 1.98.1 (797e8a9bc 2026-08-05)
$ cat src/main.rs
use std::thread;
fn main() {
let mut c = cache::Cache::new();
let worker = thread::spawn(move || c.hit()); // needs Cache: Send
println!("{}", worker.join().unwrap());
}
$ cargo run -q # cache 1.0.0: struct Cache { hits: u64 }
1
$ cargo build -q # cache 1.1.0 adds: last: Option<Rc<String>>, private
error[E0277]: `Rc<String>` cannot be sent between threads safely
--> src/main.rs:4:32
note: required because it appears within the type `Option<Rc<String>>`
note: required because it appears within the type `Cache`
--> …/cache/src/lib.rs:2:12
note: required by a bound in `spawn`
Neither the application nor anything it can name changed; its build did. Cargo's SemVer chapter has no entry for this, but the Rust lang team's design note on auto traits calls each auto trait a semver hazard for libraries, for exactly this reason. The flip is mechanical: the series' trait solver (Lesson 16) predicts it from field types alone, and on 120 random private field types checked for Send and Sync against rustc 1.98.1 (240 checks, 117 yes-to-no flips) it never disagreed. The defense: a test that stops compiling when the promise is withdrawn (§7).
One more consequence. In the Rust Book's quiz data, "semver dependency deduplication" went from 18% to 70% correct after one added paragraph, the largest gain of twelve interventions. When the graph needs two semver-incompatible versions of one crate, Cargo builds both (§4), and to the compiler they are two crates: same name, same definition, different types:
$ grep geom Cargo.toml
geom = { path = "../geom1", version = "1.4" }
geom2 = { path = "../geom2", version = "2.0", package = "geom" }
$ cargo build -q
error[E0308]: mismatched types
--> src/main.rs:3:32
|
3 | println!("{}", geom2::norm(&p)); // expects geom 2.0.0's Point
| ----------- ^^ expected `geom::Point`, found a different `geom::Point`
note: there are multiple different versions of crate `geom` in the dependency graph
Neither value is wrong; they are different types (cargo tree -d lists such duplicates): a major version is a new set of types. Semver governs changes to your crate; the language changes too, under crates nobody can edit all at once.
6 · Editions
Rust promises that a stable feature keeps working in every later release. Some improvements cannot keep that promise: making async a keyword breaks every variable named async. Such changes go into an edition, a per-crate opt-in set by the edition key (2015, 2018, 2021 or 2024; no key means 2015, with a warning). One function, two editions, one compiler:
use std::cell::RefCell;
fn len_of_borrow() -> usize {
let c = RefCell::new(String::from("abc"));
c.borrow().len() // a temporary `Ref` in the tail expression
}
fn main() { println!("{}", len_of_borrow()); }
use std::cell::RefCell;
fn len_of_borrow() -> usize {
let c = RefCell::new(String::from("abc"));
c.borrow().len() // a temporary `Ref` in the tail expression
}
fn main() { println!("{}", len_of_borrow()); }
3
Under 2021 (the first badge) the tail expression's temporary Ref outlives c; under 2024 it is dropped first. Editions do not fork the language: crates of different editions must interoperate, and all compile to the same internal representation, so migrating is each crate's private decision. A 2015 crate that named a function async, called from a 2024 crate through a raw identifier:
$ grep edition Cargo.toml ../oldlib/Cargo.toml
Cargo.toml:edition = "2024"
../oldlib/Cargo.toml:edition = "2015"
$ cat ../oldlib/src/lib.rs src/main.rs
pub fn async() -> &'static str { "written in 2015, when async was a plain name" }
fn main() {
println!("{}", oldlib::r#async()); // a 2024 crate calling a 2015 crate
}
$ cargo run -q
written in 2015, when async was a plain name
Edition 2024 changes five things this track teaches, and cargo fix --edition applies what it can of a migration mechanically, printing what it cannot.
| In edition 2024 | Where the track covers it |
|---|---|
| temporaries in a block's tail expression drop before its locals (above) | only here |
an if let's scrutinee temporaries drop before its else block | 17 |
a return-position impl Trait captures every lifetime in scope | 12 |
a reference to a static mut is denied | 17, 19 |
an unsafe fn's body needs its own unsafe { } blocks (a warning) | 19 |
An edition protects old crates from the language's changes. A test protects your users from yours.
7 · Tests, docs, tooling — promises the compiler runs
cargo test builds and runs three kinds of test. Unit tests sit in a #[cfg(test)] mod tests inside the module they test, compiled only for testing; as a child module they see its private items (§1). Integration tests in tests/ are separate crates and see only the public API. Doc tests are the code blocks in /// comments: rustdoc compiles and runs each one, so a stale example fails cargo test, and a compile_fail block must be rejected (a verdict the rustdoc book warns may change between releases). This track works the same way: every badge here is rustc 1.98.1's verdict, recomputed by a script that compiles each block.
/// Counts lookups that hit.
///
/// ```
/// let mut c = cache::Cache::new();
/// assert_eq!(c.hit(), 1);
/// ```
pub struct Cache {
hits: u64,
}
impl Cache {
pub fn new() -> Cache { Cache { hits: 0 } }
pub fn hit(&mut self) -> u64 { self.hits += 1; self.hits }
}
#[cfg(test)]
mod tests {
use super::*; // a child module sees its parent's private items
#[test]
fn starts_at_zero() { assert_eq!(Cache::new().hits, 0); }
#[test]
fn stays_send() { fn is_send<T: Send>() {} is_send::<Cache>(); }
}
$ cargo test # tests/api.rs holds counts_hits, a test of the public API
Running unittests src/lib.rs (target/debug/deps/cache-0e7f0c23e947499a)
test tests::starts_at_zero ... ok
test tests::stays_send ... ok
Running tests/api.rs (target/debug/deps/api-bae8d392ed329275)
test counts_hits ... ok
Doc-tests cache
test src/lib.rs - Cache (line 3) ... ok
stays_send answers §5: add the Rc field and this crate's own cargo test fails to compile, with the same E0277, before any user sees it. Around the tests the default toolchain ships cargo fmt (layout, never meaning), cargo clippy (lints for likely mistakes; its correctness group is an error by default), cargo fix (applies suggestions with a clear fix) and rustc --explain. With privacy in place, Lesson 02's two kinds of error become three:
E0425 (types too, now), E0433, E0432, E0308, E0599, E0277: one name, one line.E0382, E0499, E0502, E0505, E0506, E0597, E0515: several program points (Lessons 03–08).E0603, E0616, E0451, E0624): ask its author. Stability (E0658, below): the toolchain. Edition (gen is reserved in 2024): the manifest.fn main() {
let segs = ["crate", "collections", "myvec"];
let path: String = segs.iter().copied().intersperse("::").collect(); // unstable as of 2026-09
println!("{path}");
}
error[E0658]: use of unstable library feature `iter_intersperse`
Common mistakes / failure modes
unsafe proof must cover every line that can touch its invariant, and privacy makes that set finite. pub len let safe code cause undefined behavior (§1).pub(crate) is basically private"Cargo.lock freezes the choice, so without it a fresh checkout of an application may build different code (§4).Rc removes Send and Sync with no signature change; a private field added to an all-public struct breaks literals (§5).async function (§6).use imports code"use (§2).Checkpoint exercise
v.len = …), predict the narrowest visibility for len that lets crate::collections write it while keeping crate::app out, and how many modules it shades; then set it and check. Two spellings give the same region: say why. Next, in a scratch file, give collections a pub fn reset(v: &mut myvec::MyVec) that writes v.len = 0, compile it with that visibility, then move reset into app and read which code rustc prints. Finally, list the code a reviewer must now read to trust as_slice.Where this points next
Every mechanism is on the table, from an owner and a law to the unsafe boundary and now the privacy, crates and versions that let proofs travel between teams. Each lesson priced its own piece; none has run them together or totalled the bill. Lesson 22 asks the closing question: built into one program, what do they look like, what does the whole bargain cost, and when is that the wrong price to pay?
unsafe proof holds only while the code that can break its invariant is known, and privacy makes that set finite: an item is private to its module and the modules inside it unless widened (pub(super), pub(in path), pub(crate), pub), and safe code has no cast around it. A private field forbids outside writes (E0616) and literals (E0451). The crate is the unit compiled, shared and versioned. Cargo resolves version ranges to the newest compatible release and freezes the answer in Cargo.lock. Semver promises what may change, auto traits included: a private Rc field removes Send. Two incompatible versions of a crate are two crates. Editions change the language per crate and interoperate. Tests, doc tests and a Send assertion are promises the compiler runs.Interview prompts
- Why is privacy a soundness mechanism in Rust but only access control in C++? (§1 — an
unsafeproof must cover all code that can touch its invariant; privacy bounds that to a module and safe Rust has no cast around it, while C++'sprivatehides the name, not the bytes.) - What do
pub(crate),pub(super)andpub(in path)mean, and what reaches another crate? (§1, §3 — the whole crate, the parent's subtree, an enclosing module's subtree; only plainpubon every step from the root reaches another crate.) - Module, crate, package: what is each? (§2 — a module is a scope in the crate's tree, the unit of privacy; a crate is what the compiler compiles, the edge
pub(crate)stops at; a package is what Cargo builds, at most one library plus binaries.) - What does
Cargo.lockrecord, and should an application commit it? (§4 — the version chosen for every crate in the graph; builds reuse it untilcargo update, so they are reproducible: yes.) - Name changes that break semver in Rust, including one that changes no signature. (§5 — removing a public item, a private field added to an all-public struct, a trait method without a default, a variant on an exhaustive enum, a tighter bound; and a private
Rcfield, which removesSend.) - What is an edition, and can a 2024 crate use a 2015 crate? (§6 — a per-crate opt-in for changes that would otherwise break code; editions interoperate:
r#asynccalls a 2015 function namedasync.) - How do doc tests work, and what can a unit test see that an integration test cannot? (§7 — rustdoc compiles and runs the code blocks in doc comments; a unit test is a child module and sees private items; an integration test is another crate.)
Companion reads: Go · 12 Packages and modules (capitalization, internal/, MVS), Go · 15 Testing and tooling, C++ · 11 Resource-safe class design (invariants kept by discipline), C++ · 16 const and constexpr (types as a tool).