We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Unsafe and Soundness step 12 of 24
Validity invariants vs safety invariants
struct Rec { kind: u8, flag: bool, ch: char }
pub fn parse_records(bytes: Vec<u8>) -> Vec<i64>
Decode a byte stream into records. Each record is six bytes: a u8 kind, a
byte that must be 0 or 1 to become a bool, and a little-endian u32
that must be a valid Unicode scalar value to become a char.
For each accepted record, push [kind, flag as 0/1, ch as its scalar value].
For each rejected record, push [-1, -1, -1]. A trailing partial record is
dropped.
The rule of the exercise: validate before constructing. Building a Rec
out of the raw bytes and checking it afterwards is already undefined
behaviour, and this page is about why.
Two different invariants that everyone conflates
A type’s validity invariant is what the compiler and the language assume unconditionally about every value of that type. It is not negotiable, it is not documented per-crate, and violating it is instant undefined behaviour the moment the value is PRODUCED — assigned to a place, read from one, passed to a function, returned from one — even if you never look at it.
A type’s safety invariant is what the library author additionally
promises. Vec‘s len <= capacity. String‘s bytes are UTF-8. Rc‘s
refcount equals the number of live handles. Violating one of these is not
instantly UB; it makes the module’s unsafe code unsound, which is a
different and slower kind of catastrophe.
The practical consequence is sharp:
Inside a module’s own unsafe code you may temporarily break the safety invariant — that is exactly what
Vec::set_lenis for — but you may never break the validity invariant, not even for an instant.
“I only briefly had an invalid bool“ is not a defence, because ‘briefly’ is
not a thing the model has.
::: question Vec::with_capacity(8) then set_len(8) before writing anything.
Which invariant does that break, and how badly?
It breaks Vec‘s safety invariant — “the first len elements are
initialised” — and for Vec<u8> that is unsound but not yet UB.
No Vec<u8> value is invalid: the struct’s fields are a pointer, a length and
a capacity, and all three hold perfectly ordinary bit patterns. So nothing is
instantly undefined. What is broken is the promise every consumer of that
Vec relies on. v[0] now reads uninitialised memory, which is UB, and
v.iter() hands out references to values that do not exist.
Change the element type to Vec<bool> and it gets worse in a different way:
now producing a bool from an uninitialised byte violates bool‘s validity
invariant, and it is UB at the read rather than at the use.
Clippy has a deny-by-default lint for exactly this shape, uninit_vec:
error: calling `set_len()` immediately after reserving a buffer creates
uninitialized values
:::
The validity table, in full
This is a lookup table, not a memorisation exercise. Come back to it.
| type | valid values |
|---|---|
bool |
exactly 0 or 1 |
char |
<= char::MAX (0x10FFFF) and not a surrogate (0xD800–0xDFFF) |
! |
none — a value of type ! must never exist |
| integers, floats, raw pointers | any bit pattern, but must be initialised |
| enums | must hold a valid discriminant |
&T, &mut T, Box<T> |
aligned, non-null, non-dangling, pointing at a valid T |
dyn Trait |
the metadata must be a real vtable pointer for that trait |
| slices |
the length metadata must not imply a size over isize::MAX |
NonNull<T>, NonZero<T> |
custom valid ranges — never null / never zero |
Two clarifications that resolve most confusion:
“Dangling” means not all the bytes are inside one live allocation. A zero-sized pointer is never dangling, no matter what its address is.
“Based on a misaligned pointer” only causes UB on a load or store. That is
why &raw const on a misaligned place is fine while & on it is not — item
17.6.
The two things that make char a real trap
A char is four bytes and holds a Unicode scalar value. Two ranges of
u32 are not scalar values:
-
anything above
0x10FFFF; -
the surrogate range
0xD800..=0xDFFF, which exists only as a UTF-16 encoding artefact.
char::from_u32(n) returns Option<char> and checks both. It compiles to two
comparisons. transmute::<u32, char>(n) checks neither and is undefined
behaviour for either range — and, since the char exists as soon as the
transmute returns, checking afterwards is too late.
::: question Both the surrogate 0xD800 and the value 0x110000 are rejected.
Why is a surrogate not just “a weird character”?
Because surrogates are not characters at all — they are half of a UTF-16
escape sequence — and char‘s validity invariant excludes them so that
char and str can be encoded and decoded without a validity check on every
step.
If char could hold 0xD800, then String::push(c) would have to produce
bytes that are not valid UTF-8, and str‘s safety invariant would be
unmaintainable. Excluding surrogates at the char level is what makes the
whole text stack cheap.
The practical lesson generalises: a validity invariant is almost always there
to make something else free. bool is 0-or-1 so if is a branch rather than
a comparison. NonNull is non-null so Option<NonNull<T>> is one word.
Breaking the invariant does not just risk a wrong value; it invalidates
reasoning the compiler has already baked into the code around it.
:::
Related facts, verified
mem::zeroed::<T>() is fine for u32 and instant UB for &T,
NonZero<T> or any struct containing a bool — because all-zeroes is not a
valid value of those types. rustc’s invalid_value lint (warn-by-default)
fires on mem::uninitialized::<T>() for both bool and u32.
str‘s UTF-8 property is a safety invariant, not a validity one.
str::from_utf8_unchecked with bad bytes is not instantly UB in the validity
sense — but it is unsound, it breaks every consumer, and rustc’s
invalid_from_utf8_unchecked is deny-by-default when it can see the literal.
Padding bytes never need to be initialised. That is why MaybeUninit
reasoning is per-field rather than per-byte, and it is what item 17.23 builds
on.
What this grader cannot check
If you build the Rec first and validate afterwards, every test here still
passes. The transmutes produce the bit patterns you expect, the comparisons
see the numbers you expect, and the program prints the right answers. It is
undefined behaviour anyway. The only reason the starter fails is that clippy
recognises the two specific transmutes by name — transmute_int_to_bool and
rustc’s own unnecessary_transmutes — not because anything observed the UB.
Remember the grade is compile + tests + clippy -D warnings.
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.