The Rust Engineering Handbook / Chapter 17
Numeric Semantics, Overflow, and Bit-Level Correctness
Make widths, conversions, arithmetic policy, endian boundaries, flags, shifts, and floating-point edge cases explicit.
Six bytes, five proofs
A relay receives this frame:
00 10 00 00 07 01 ...payload...
└─ length ─┘ │ │
│ └─ flags
└──── stream
The first four bytes declare a big-endian payload length. The next byte is a nonzero stream identifier, and the last contains two defined flags. Before the parser can return a payload, it must evaluate an innocent-looking expression:
frame_end = header_bytes + decoded_payload_length
That expression is the end of a proof, not the beginning. The parser must first establish what the bytes represent, whether that value fits the host’s indexing type, whether the addition fits, whether the input contains the claimed range, and whether product policy permits a frame of that size. None of those conclusions implies the next one.
Rust keeps a parser memory-safe when a slice index is wrong; it does not make the protocol correct. An unchecked narrowing conversion can change the declared length. Native byte order can give the same bytes different meanings on different hosts. An arithmetically valid length can still authorize an allocation large enough to exhaust the service.
For every numeric boundary, a reviewer should be able to name the unit, width, signedness, valid range, byte order, and overflow behavior. Those facts are as much a part of the domain model as the enum states in the preceding chapters.
Let the parser accumulate evidence
Here is the complete boundary for the frame above. It deliberately keeps each obligation visible:
use std::num::NonZeroU16;
const HEADER_LEN: usize = 6;
const MAX_PAYLOAD: usize = 1024 * 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct FrameFlags(u8);
impl FrameFlags {
const ACK: u8 = 0b0000_0001;
const COMPRESSED: u8 = 0b0000_0010;
const KNOWN: u8 = Self::ACK | Self::COMPRESSED;
fn new(bits: u8) -> Result<Self, ParseError> {
let unknown = bits & !Self::KNOWN;
if unknown == 0 {
Ok(Self(bits))
} else {
Err(ParseError::UnknownFlags(unknown))
}
}
}
#[derive(Debug, Eq, PartialEq)]
struct Frame<'a> {
stream: NonZeroU16,
flags: FrameFlags,
payload: &'a [u8],
}
#[derive(Debug, Eq, PartialEq)]
enum ParseError {
Truncated,
LengthUnrepresentable,
PayloadTooLarge(usize),
ZeroStream,
UnknownFlags(u8),
}
fn parse_frame(input: &[u8]) -> Result<Frame<'_>, ParseError> {
let header: &[u8; HEADER_LEN] = input
.get(..HEADER_LEN)
.ok_or(ParseError::Truncated)?
.try_into()
.map_err(|_| ParseError::Truncated)?;
let encoded_len = u32::from_be_bytes(header[..4].try_into().unwrap());
let payload_len =
usize::try_from(encoded_len).map_err(|_| ParseError::LengthUnrepresentable)?;
if payload_len > MAX_PAYLOAD {
return Err(ParseError::PayloadTooLarge(payload_len));
}
let frame_end = HEADER_LEN
.checked_add(payload_len)
.ok_or(ParseError::LengthUnrepresentable)?;
let payload = input
.get(HEADER_LEN..frame_end)
.ok_or(ParseError::Truncated)?;
let stream = NonZeroU16::new(u16::from(header[4])).ok_or(ParseError::ZeroStream)?;
let flags = FrameFlags::new(header[5])?;
Ok(Frame {
stream,
flags,
payload,
})
}
The first get proves that six header bytes exist. from_be_bytes interprets the protocol’s fixed-width representation. TryFrom asks whether that external value can become an index on this target. The product maximum is an independent admission rule. checked_add proves that the complete range is representable, and the second get proves that the bytes are present. Only then do the stream and flag fields become validated domain values.
Moving the maximum below the bounds check would not make the parser unsound, but it would obscure why the service rejects a frame. PayloadTooLarge is policy; Truncated is evidence about this input. Operations staff and callers may need to distinguish them.
Width belongs to the representation
The wire field is u32 because the protocol gives it 32 bits. The slice index is usize because indexing is address-sized. Keeping both types makes the boundary legible. usize and isize vary with the target, so placing either in a portable file or wire schema quietly makes host architecture part of the format.
Use fixed-width integers for wire fields, persisted schemas, hardware registers, and algorithms whose modulus is specified. Use pointer-width integers for indexes, lengths of in-memory Rust collections, and address-sized quantities. Convert at the point where the responsibility changes.
Inference is useful inside an expression and risky at a durable boundary. An unsuffixed integer literal is inferred from context and otherwise defaults to i32; let limit = 1_000_000 does not advertise whether the value is a protocol field, a collection length, or a count. A visible annotation helps. A newtype does more: PayloadBytes(u32) cannot be confused with RecordCount(u32) even though both occupy the same primitive representation.
Endianness belongs at the same boundary. u32::from_be_bytes and to_be_bytes state the protocol order directly. Native-endian conversion is correct only for a genuinely host-native representation. Reinterpreting a byte slice as integers also raises alignment, layout, validity, and possibly unsafe-code obligations; equal byte counts prove none of them.
Conversion should confess what it can lose
Rust defines numeric as casts, but definition is not preservation. An integer narrowed with as is truncated; a negative signed integer cast to an unsigned type is reduced to the destination representation; a float-to-integer cast saturates at the numeric bounds, with NaN becoming zero. Compact syntax can hide a substantial policy.
Use From when every source value is representable and TryFrom when it may not be. Use as when the specified cast behavior is itself the intended operation—for example, extracting a low byte in code whose modular representation is explicit. Put that intent in the operation’s name or next to a mask so review does not have to infer it.
Conversion proves only representation. In the parser, a successful u32-to-usize conversion says nothing about HEADER_LEN + payload_len, the bytes actually received, or the one-megabyte admission limit. Keeping these checks separate prevents one successful proof from becoming accidental authority for everything downstream.
Arithmetic policy comes from the domain
Primitive integers offer several addition families because there is no universal meaning for overflow.
For a parser offset, account balance, or capacity calculation, overflow invalidates the result. checked_add returns None, allowing the caller to reject the operation in every build profile.
For a telemetry counter whose documented state is “at least 255,” saturating_add can preserve that upper state. The same operation would be dishonest for a retry budget or invoice total: clamping would change the decision while making the number look valid.
For a protocol sequence number defined modulo 2^32, wrapping_add expresses the arithmetic domain. Comparisons across rollover still need a protocol rule; modular addition alone does not tell whether one sequence is newer.
Some algorithms need both the low bits and the carry. overflowing_add returns them together. At a domain boundary, give the carry a name rather than returning an unexplained (value, bool) that callers can casually discard.
Ordinary + is governed by overflow checking. With checks enabled, overflow panics; when they are disabled, integer operations use two’s-complement wrapping. Cargo’s usual development and release profiles differ here, and profiles can be configured. “Debug panics, release wraps” is therefore a description of common defaults, not an API contract. If behavior affects correctness, select the named operation rather than borrowing policy from the build.
The relay in Chapter 16 used attempts.saturating_add(1). That is honest only because the count was telemetry: 255 meant “255 or more.” If the value decided whether another connection attempt was permitted, saturation could create an endless retry. The type is unchanged; the meaning changes the correct operation.
Bits need a vocabulary, not cleverness
FrameFlags turns a raw byte into a closed vocabulary. Its constructor rejects reserved bits and reports the unknown subset, rather than accepting any u8 and hoping every caller remembers the mask. A protocol might instead preserve unknown bits for round-trip compatibility. It might ignore them when its versioning rules explicitly allow that. Rejecting, preserving, and masking are different compatibility policies; a bitwise operation cannot choose among them.
Combinable flags should not be modeled as an enum whose variants imply mutual exclusivity. A dedicated newtype or a well-chosen bitflag helper can provide composition and membership operations, but validation at the wire boundary remains part of the protocol.
Shift counts require the same precision. If a bit position must be within a u32, use checked_shl and reject positions of 32 or greater. If the specification reduces the count modulo the width, wrapping_shl says so. If bits leaving one end re-enter at the other, call rotate_left or rotate_right. A shift with an oversized count is not a disguised spelling of rotation.
Type the mask at the point of use—1_u32.checked_shl(position)—so width is not left to inference. Parenthesize expressions that mix masks, comparisons, and shifts even when precedence is known. Protocol review has harder work to do than reconstructing an operator table.
A narrow type proves a narrow fact
The parser widens its one-byte field into NonZeroU16 because the application uses that identifier type. Widening preserves every source value; the nonzero constructor then makes the local invariant durable. Rust documents that Option<NonZeroU16> has the same size and alignment as u16.
It proves nothing about whether the stream exists, belongs to this connection, or is authorized for the caller. Those are runtime facts. If the identifier travels through the application, a domain type such as StreamId(NonZeroU16) can prevent it from being confused with another nonzero number and can own the lookup policy at an appropriate boundary.
This is the same restraint typestate required in the preceding chapter. A useful type makes one consequential conclusion hard to counterfeit; it does not inflate that conclusion into remote or historical evidence.
Floating point needs an admission policy
Suppose the relay also reports a compression ratio. The ratio is not part of the frame index, but it is still an input to sorting, persistence, alerts, and dashboards. IEEE floating-point values include NaN and infinities. Comparisons with NaN are unordered, so f32 and f64 implement PartialOrd, not Ord; partial_cmp(...).unwrap() can panic when NaN enters.
Decide at admission whether non-finite values are invalid, retained as missing measurements, or meaningful sentinels. Decide how signed zero, rounding, and serialization behave. total_cmp gives floating-point values a total order suitable for deterministic sorting, including NaNs and signed zero, but it does not make every value meaningful to the product. It supplies mechanism after the domain has chosen a policy.
Do not use binary floating point for exact currency. Use a representation whose rounding and scale contract matches the financial domain, then check its arithmetic just as deliberately as the parser’s offsets.
Design the API around the permission
reserve(bytes: usize) accepts an in-memory length. It does not say who established that length or what maximum applies. An API such as PayloadLimit::new(encoded_len) can own the representability and product checks, then expose a usize only after they succeed. This is useful when many callers cross the same trust boundary.
Do not hide every arithmetic operation behind a type. Create a domain abstraction when it prevents a consequential confusion, centralizes policy, or survives across calls. Otherwise, a named local variable and a visible checked operation may be the clearer proof.
Likewise, name modular methods such as advance_wrapping when rollover is part of the public contract. Distinguish invalid input from capacity exhaustion if callers can respond differently. Numeric API design is not about replacing primitives everywhere; it is about making permission and loss visible where values acquire authority.
Audit the frame, then change the rules
Begin with a parser that uses as usize, HEADER_LEN + payload_len, a raw stream byte, and flags & KNOWN. Before changing it, annotate the source type and the conclusion claimed by each operation. Find the first line that uses one conclusion as if it proved another.
Then implement the boundary shown in this chapter. Test a truncated header, truncated payload, zero stream, unknown flag, maximum accepted payload, one byte beyond the product maximum, and arithmetic near the target’s indexing limit. Run the relevant tests with overflow checks both enabled and disabled; the parser’s result must not change.
Finally, change one requirement at a time. Make unknown flags round-trip instead of fail. Define a 32-bit sequence number that rolls over. Add a bit position supplied by an extension header. Add a compression ratio that can receive NaN. For each change, state the new policy before selecting a Rust operation. A solution that merely replaces every as and + has repaired syntax without completing the audit.
Numeric boundary review
- What unit, width, signedness, byte order, and valid range does each value have?
- Which values cross wire, storage, configuration, FFI, or plugin boundaries?
- Does each conversion preserve the value, reject loss, or deliberately transform it?
- Which arithmetic family expresses the domain, independent of build profile?
- Are representability, containment, capacity, and product permission proved separately?
- Are unknown flags rejected, preserved, or ignored by an explicit versioning rule?
- Are shift, rotation, and rollover distinguished?
- Can NaN, infinity, or signed zero affect ordering, persistence, or alerts?
- Does each numeric newtype claim only the fact its constructor established?
Durable takeaways
- A decoded number earns authority through separate representation, conversion, arithmetic, containment, and policy checks.
- Width, unit, signedness, byte order, and overflow behavior belong to the contract.
- Named arithmetic operations keep correctness independent of Cargo profile defaults.
- Flags, shifts, nonzero values, and floating-point order each require a domain decision beyond primitive syntax.
- Decode external fixed-width values before converting them into host-sized indexes, and apply resource limits even when the arithmetic fits.
Part III began by deciding which domain values could exist, then made decisions and transitions exhaustive. It ends at the point where those values meet their physical representation. A state can be structurally legal while its counter wraps, its length narrows, or its flags acquire an unintended meaning. Once those numeric boundaries are explicit, the next part can ask a different question: how reusable code expresses variation without hiding behavior, generated cost, or implementation ownership.
Sources and version notes
- Rust Reference: integer types, casts, and integer overflow
- Primitive integer methods,
TryFrom, andNonZeroU16 - Primitive floating-point methods and
total_cmp - The parser and named arithmetic examples were rechecked on the installed Rust 1.93.1 toolchain. The declared Rust 1.97.0 snapshot and Rust 1.85.0 MSRV were not available for this editorial pass.
Continue reading
Full table of contents