The Rust Engineering Handbook / Chapter 33
Zero-Copy Parsing and Borrowed Data Models
Design parsers that validate bounded input once, return safe borrowed views, and make every unavoidable copy and lifetime constraint explicit.
Read this frame without inventing an owner
Consider a wireview frame already resident in a network receive buffer:
57 56 01 01 00 00 00 2a 00 06 00 00 00 0e sensor temperature=24
|magic|v|fl| record id |name | payload len | name | payload |
The parser has three jobs. It must reject an incomplete or hostile length before indexing, decode fixed-width integers with the protocol’s byte order, and expose the name and payload only after their invariants hold. It does not need to allocate new strings or byte vectors to accomplish those jobs.
That last sentence is narrower than “the parser performs zero work.” Bounds checks, integer decoding, UTF-8 validation, branches, and cache traffic remain. The useful claim is precise:
On the successful path,
PacketView<'a>borrows its name and payload bytes from the caller’s&'a [u8]; it does not copy those fields into new owned buffers.
This closes Part V’s progression from representation to storage policy. Layout rules told us which bytes may be interpreted; slices made length metadata explicit; pinning ruled out accidental self-reference as an ownership solution; allocation policy made retention visible. A borrowed parser combines those constraints at a trust boundary.
A lifetime in the output is an API consequence
The smallest useful signature states the ownership result:
pub fn parse(input: &[u8]) -> Result<PacketView<'_>, ParseError>;
pub struct PacketView<'a> {
version: u8,
flags: u8,
record_id: u32,
name: &'a str,
payload: &'a [u8],
}
The elided output lifetime is related to input. The parser does not choose how long the view lives; the caller’s storage does. Safe Rust therefore rejects returning the view after the input owner is destroyed, resizing a Vec<u8> while slices into it remain usable, or packaging an owned buffer beside references into itself through an ordinary movable struct.
Scalar fields are decoded by value. Copying a u32 out of four wire bytes is not the kind of payload copy that zero-copy designs try to avoid. Keeping a four-byte borrowed slice would spread endian interpretation through callers and weaken the validated abstraction.
The lifetime also changes ergonomics. A caller that wants to enqueue PacketView<'_> beyond the receive buffer’s reuse boundary must change something: retain a stable owner, copy selected fields into an owned domain model, or move processing inside the borrow. Adding 'static cannot lengthen the underlying data. Box::leak can manufacture process-long storage, but it converts a lifetime mismatch into unbounded retention.
Validation must dominate construction
Figure 33-1 shows the decisive ordering. A view exists only on the success side of the validation boundary, and every borrowed range remains inside the same input buffer.

The complete fixture uses a 14-byte fixed header:
const HEADER_LEN: usize = 14;
const MAX_FRAME_LEN: usize = 64 * 1024;
pub fn parse(input: &[u8]) -> Result<PacketView<'_>, ParseError> {
if input.len() < HEADER_LEN {
return Err(ParseError::TooShort {
needed: HEADER_LEN,
actual: input.len(),
});
}
if &input[0..2] != b"WV" {
return Err(ParseError::BadMagic);
}
if input[2] != 1 {
return Err(ParseError::UnsupportedVersion(input[2]));
}
let record_id = read_u32(input, 4).ok_or(ParseError::TooShort {
needed: HEADER_LEN,
actual: input.len(),
})?;
let name_len = usize::from(read_u16(input, 8).ok_or(ParseError::TooShort {
needed: HEADER_LEN,
actual: input.len(),
})?);
let payload_len = usize::try_from(read_u32(input, 10).ok_or(
ParseError::TooShort {
needed: HEADER_LEN,
actual: input.len(),
},
)?)
.map_err(|_| ParseError::LengthOverflow)?;
let name_end = HEADER_LEN
.checked_add(name_len)
.ok_or(ParseError::LengthOverflow)?;
let frame_end = name_end
.checked_add(payload_len)
.ok_or(ParseError::LengthOverflow)?;
// limit, truncation, trailing-data, and UTF-8 checks follow
# let _ = (record_id, name_end, frame_end);
# unreachable!()
}
Checking input.len() >= HEADER_LEN dominates every fixed-field slice. Checked addition dominates every variable-field slice. The configured frame limit is applied to the total announced size before the parser accepts it. Only then does from_utf8 establish the str invariant and slicing construct the views.
The fixture’s read_u16 and read_u32 helpers use get plus fixed-array conversion, so a short range stays ordinary error control flow. The header guard makes those local failures unreachable in the current format, but the helpers keep the indexing proof next to the read. The truly data-dependent failures remain announced lengths, supported values, complete arrival, and UTF-8 validity.
Malformed-input tests are part of the abstraction, not cleanup work:
let mut frame = encode_for_test(7, "ok", b"abc");
frame[10..14].copy_from_slice(&99_u32.to_be_bytes());
assert_eq!(
parse(&frame),
Err(ParseError::Truncated {
announced: 115,
actual: 19,
})
);
Tests also replace the single name byte with 0xff, reject trailing data, and confirm that both returned pointers fall within the original allocation. Pointer-range assertions are evidence about this implementation’s borrowing behavior; the public lifetime is the stronger language-level constraint.
Endian decoding is not typed memory reinterpretation
Wire formats define byte sequences. Rust values have validity, alignment, and representation constraints. Treating one as the other with a pointer cast is usually the wrong starting point.
The fixture copies four bytes into [u8; 4] and calls u32::from_be_bytes. This works at every byte alignment and states the wire endianness. Optimizers may compile a small fixed-width copy efficiently, but generated instructions are an implementation observation, not part of the safety proof.
This tempting alternative has several problems:
// Do not use this as a parser shortcut.
let id = unsafe { *(input.as_ptr().add(4).cast::<u32>()) };
The derived address may be misaligned for u32. A direct load uses native endianness rather than the protocol’s specified order. More complex target types can have invalid bit patterns or padding. Bounds must still be proved. Even where an unaligned read of an integer can be written correctly with unsafe primitives, it spends review budget without improving the public model. Measure before adding such a narrow optimization, and keep byte-order conversion explicit.
repr(C) does not turn a Rust struct into a universal packet decoder. It controls specified layout properties for FFI; it does not validate wire lengths, solve endianness, authorize unaligned references, or make arbitrary nested fields interoperable. Serialization is a protocol contract, not a memory-layout accident.
References and offsets preserve different freedoms
A parser can represent a validated field as &'a [u8] or as a range such as Range<usize> interpreted against a buffer. Both can avoid payload copies, but they create different APIs.
| Representation | Strongest property | Cost and constraint | Prefer when |
|---|---|---|---|
| borrowed reference | compiler ties view validity directly to input | output carries a lifetime and cannot outlive storage | processing is local and one stable buffer backs the view |
| checked offset/range | model can be moved or stored independently of a borrow | every access needs the correct buffer and may repeat a bounds lookup | records cross queues, indexes are serialized, or storage is reborrowed later |
| owned field | lifetime-independent, simple transfer | allocates/copies proportional to promoted data | data outlives receive storage or is small and frequently retained |
| shared immutable owner plus ranges | cheap owner cloning and transferable offsets | atomic counts, indirection, whole-buffer retention | many consumers share a bounded immutable frame |
Offsets are not automatically safer. A range valid for buffer A may be applied to buffer B unless the API binds them through a validated owner. A generation, identity token, or private constructor can prevent accidental mixing. Bounds can also become stale if mutable storage changes. Prefer immutable backing storage after validation.
References are not automatically faster. A view-rich model may increase lifetime coupling and make application architecture contort around receive-buffer ownership. If downstream work needs three tiny fields for hours, copying those fields can retain far less memory than keeping a multi-megabyte frame alive.
The default is therefore local borrowed views at the parsing edge, followed by an explicit promotion step at the lifecycle boundary. That step becomes the audit point for allocation, normalization, redaction, and ownership transfer.
Partial parsing has two meanings
“Partial” can mean the bytes have not all arrived, or that the caller wants only a subset of fields. Those require different contracts.
For incomplete arrival, the fixture exposes:
pub fn announced_frame_len(prefix: &[u8]) -> Result<Option<usize>, ParseError>;
Ok(None) means the fixed header is incomplete. Ok(Some(total)) means enough header bytes exist to compute a bounded total. Err means the announced size overflows or exceeds policy. A stream decoder can accumulate until total contiguous bytes exist and then call parse.
Do not conflate incomplete data with malformed data. A decoder that reports “bad packet” after every short network read cannot operate correctly over a stream. Conversely, an attacker-controlled length must not cause indefinite buffering. The state machine needs a maximum header size, maximum frame size, timeout or cancellation policy, and behavior for excess trailing bytes.
For selective fields, a parser may validate and expose only the routing header before deciding whether to parse the body. The early view must state what has and has not been validated. A type such as ValidatedHeader<'a> can prevent body consumers from assuming a whole-frame guarantee. Boolean flags like fully_validated are easier to ignore than distinct types.
Streaming exposes the storage boundary
A borrowed PacketView<'a> requires its referenced bytes to be stable for 'a. A rolling receive buffer may compact, grow, or overwrite storage as more data arrives. Holding views across the next read can therefore block reuse in safe code or become unsound in an unsafe implementation.
Credible designs include:
- Buffer one bounded complete frame, parse it, finish borrowed processing, then reuse storage.
- Freeze each completed frame behind an immutable shared owner and store offsets or views owned through a library abstraction.
- Copy only promoted fields into an owned command before returning the receive buffer to the pool.
- Use a stateful streaming parser that emits owned events or consumes each borrowed event before requesting more input.
The first design is the simplest when frames are bounded and per-frame work is synchronous. The second supports fan-out but can retain entire frames and adds reference-counting cost. The third makes copy volume explicit and often fits service boundaries. The fourth can keep memory bounded for large logical messages, but its state machine and cancellation behavior deserve tests.
A function cannot normally implement an iterator that both mutates its internal rolling buffer and yields arbitrary references into that buffer for callers to retain across the next iteration. Lending-style APIs can express shorter per-call borrows in some designs, but framework and trait ergonomics matter. Do not hide this limitation behind unsafe lifetime extension.
Owning the buffer beside its views is still self-reference
This shape is attractive and invalid as an ordinary safe design:
struct OwnedPacket {
bytes: Vec<u8>,
view: PacketView<'self>, // Rust has no such ordinary self lifetime
}
Moving the struct can move the Vec handle, though the heap allocation often remains in place; more importantly, safe Rust has no general field lifetime that says one field borrows another field of the same owner. Reallocation or mutation could also invalidate field references. Pinning the outer value does not establish every needed invariant, and it does not prevent the Vec allocation from being replaced or resized.
Prefer one of three repairs: keep owner and view in separate scopes, store offsets beside the owner and create temporary borrows on access, or use a specialized self-referential abstraction whose unsafe implementation and mutation restrictions have been independently audited. For a parser front end, offsets are usually the clearer repair.
Leaking, transmuting the lifetime to 'static, or storing raw pointers without an explicit safety case are not ownership strategies. They suppress the compiler’s evidence while leaving relocation, mutation, drop, and concurrency obligations with the program.
Cow marks the conditional-copy boundary
Some inputs can be viewed as-is while others require normalization, decompression, unescaping, transcoding, decryption, or canonicalization. Calling all of those paths “zero-copy” hides meaningful work.
Cow<'a, str> can represent a precise two-path API. The fixture’s normalized_name borrows an already lowercase name and allocates only when ASCII uppercase bytes must change:
pub fn normalized_name(&self) -> Cow<'a, str> {
if self.name.bytes().all(|byte| !byte.is_ascii_uppercase()) {
Cow::Borrowed(self.name)
} else {
Cow::Owned(self.name.to_ascii_lowercase())
}
}
This makes conditional ownership visible to callers. It does not guarantee that the check is cheaper than always normalizing, nor that downstream code avoids a later clone. Measure the input distribution and end-to-end ownership path. If nearly every value changes and all results are retained, returning String can be simpler and equally efficient.
For secret data, a borrowed view keeps the secret resident as long as the source buffer. An owned normalized value may create a second sensitive copy. Define which buffers are cleared, whether exceptional capacity is retained, and whether telemetry can format the value. Zero-copy can reduce copies while increasing retention; security review needs both facts.
Hand-written parser or deserialization framework?
A hand-written parser offers exact control over limits, byte order, partial states, borrowed fields, and error taxonomy. It also makes every offset calculation and protocol evolution rule your responsibility.
A serialization framework may derive borrowed models, but its data format, lifetime support, unknown-field behavior, allocation choices, recursion limits, and error surface are third-party contracts. Framework convenience is valuable when the format fits and maintenance cost dominates. It is a poor reason to claim a borrowing behavior that has not been verified for the chosen representation and crate version.
Choose with the boundary in mind:
- use a small hand-written front end for fixed binary framing, hostile length checks, and routing fields;
- use a framework behind that boundary for a well-supported structured body when its limits are configured;
- use owned deserialization when data must cross task or persistence lifetimes and copies are acceptable;
- use a schema-driven system when compatibility, multi-language tooling, and evolution matter more than bespoke byte control.
Avoid a crate catalogue in the architectural decision. Record the chosen format, version, features, MSRV, unknown-field policy, maximum nesting and allocation behavior, then validate those claims with the actual dependency.
Operating the parser as a boundary
Parser correctness includes resource and observability policy:
- Reject announced lengths before allocation and use checked arithmetic.
- Bound nesting, collection counts, decompressed size, and total work, not only frame bytes.
- Distinguish incomplete input, invalid input, unsupported version, and internal defects.
- Count error variants with bounded-cardinality labels; do not attach raw names or payloads.
- Sample carefully if retaining malformed bytes for diagnosis, and apply redaction and access controls.
- Fuzz the complete safe entry point and retain minimized regressions.
- Test all-zero, maximum legal, one-byte-short, length-overflow, invalid-UTF-8, unknown-version, and trailing-data cases.
- Benchmark successful and rejected inputs. An early rejection path can still be algorithmically expensive.
The safe front end should contain unsafe code only when evidence demands it. Later unsafe and fuzzing chapters can deepen the assurance case, but today’s API should already make malformed inputs unrepresentable as PacketView.
Review exercise: approve the wireview front end
Extend the fixture with an optional sequence of records. Each record contains a big-endian length, a UTF-8 key, and an opaque value. The whole frame remains capped at 64 KiB, at most 256 records are allowed, and routing must complete before the receive buffer is reused.
Produce:
- a format table with every byte range, endian rule, semantic limit, and validation owner;
- a
ValidatedRecords<'a>API or an offset-based alternative; - tests for truncation at every field boundary, count/length overflow, invalid UTF-8, unknown flags, maximum legal input, and trailing data;
- a streaming decision that states when storage may be reused;
- a promotion API for a record sent to a longer-lived worker;
- a copy budget naming which fields borrow, decode by value, normalize conditionally, or become owned;
- an argument for hand-written parsing or a named framework, including version, MSRV, limits, and error behavior.
Approve the design only if no view can outlive or be applied to the wrong storage, every attacker-controlled size is bounded before allocation or slicing, and the longer-lived path has explicit ownership.
Borrowed-parser review card
- Which exact byte ranges avoid copies, and which operations still decode or validate?
- Does every constructed reference point inside live, immutable-enough storage?
- Do fixed and variable bounds checks dominate every slice?
- Are length additions and conversions checked before use?
- Are endian and alignment handled through byte-oriented APIs?
- Does the parser distinguish incomplete, malformed, unsupported, and excessive input?
- Can offsets be mixed with the wrong buffer or survive mutation?
- What ends the borrow before a receive buffer is reused?
- Which fields are promoted into ownership, and what does that retain or copy?
- Does
Cowdescribe a real conditional path rather than obscure likely allocation? - Are parser errors safe for users and useful as bounded operator signals?
- Can fuzzing and regression tests reach the complete safe boundary?
The Part V result
Zero-copy parsing is not a pointer trick. It is an ownership design in which validated output borrows from a clearly governed input owner. The lifetime is an honest API constraint, offsets are an alternative with their own identity obligations, and conditional ownership belongs at a named promotion or Cow boundary.
The safe wireview front end uses slices, checked arithmetic, explicit endianness, UTF-8 validation, and typed errors. It avoids self-reference and unsafe loads. When streaming or longer-lived work changes the storage contract, the design must freeze, finish, or copy deliberately.
Part VI changes lenses. The parser can distinguish malformed, unsupported, excessive, and incomplete input, but a production system still needs to decide which failures callers can recover from, which deserve retry, which represent partial completion, and which reveal a programmer defect. Failure taxonomy must precede the context added for reporting.
Sources and version notes
- Rust Reference: type layout
- Standard library: primitive slice
- Standard library:
str::from_utf8 - Standard library: integer byte-order conversions
- Standard library:
Cow - Rust Reference: behavior considered undefined
- The dependency-free
wireview-parser-labuses Rust 2024, pinned Rust 1.97.0, and MSRV 1.85. Its “zero-copy” claim is limited to successfulnameandpayloadviews. It makes no claim about instruction selection, network-stack copies, or framework behavior.
Continue reading
Full table of contents