structured_zstd/skippable.rs
1//! Typed Rust API for zstd skippable frames (RFC 8878 §3.1).
2//!
3//! Skippable frames carry an arbitrary application payload alongside
4//! a zstd data stream. Spec layout, byte-compatible with upstream zstd
5//! `ZSTD_writeSkippableFrame`
6//! (`lib/compress/zstd_compress.c:4751-4763` in zstd v1.5.7):
7//!
8//! ```text
9//! +----------+-----------+----------------+
10//! | 4 bytes | 4 bytes | payload bytes |
11//! | magic LE | length LE | (size = length)|
12//! +----------+-----------+----------------+
13//! ```
14//!
15//! - `magic = 0x184D2A50 + magic_variant`, with `magic_variant` in
16//! `0..=15` — 16 application-claimed magic numbers in the
17//! skippable-magic range `0x184D2A50..=0x184D2A5F`.
18//! - `length` is the payload byte count as a little-endian `u32`,
19//! so payloads above `u32::MAX` are not representable on the wire
20//! (the validation in [`SkippableFrame::new`] / [`write_skippable_frame`]
21//! surfaces this as [`SkippableFrameError::PayloadTooLarge`]).
22//!
23//! # Primary use case
24//!
25//! Embedded metadata sidecars in storage formats. The first canonical
26//! consumer is the lsm-tree v1 encrypted wire format
27//! (<https://github.com/structured-world/coordinode-lsm-tree>), which
28//! stacks `MetadataFrame` / `BodyFrame` / `EccFrame` skippable frames
29//! around an inner zstd frame. Any storage-format author needing to
30//! interleave metadata with zstd data can use the same shape — the
31//! API takes a generic `magic_variant: u8` and leaves the per-variant
32//! semantics to the application.
33//!
34//! # Magic variant allocation policy
35//!
36//! Magic variants `0x184D2A50..=0x184D2A5F` are an **application-protocol**
37//! concern, NOT a structured-zstd concern. This crate accepts
38//! `magic_variant: u8` in `0..=15` and validates only that bound. No
39//! per-variant constants are baked into the source — applications are
40//! responsible for documenting which variants they claim and
41//! coordinating with other ecosystem consumers to avoid collisions.
42//!
43//! The ecosystem registry of known allocations and the policy for
44//! claiming new ones lives in
45//! [`docs/SKIPPABLE_MAGIC_ALLOCATIONS.md`](https://github.com/structured-world/structured-zstd/blob/main/docs/SKIPPABLE_MAGIC_ALLOCATIONS.md).
46
47extern crate alloc;
48
49use alloc::vec::Vec;
50
51use crate::io::{Error, Read, Write};
52
53/// First magic number in the skippable-frame range (RFC 8878 §3.1.2).
54/// Variants 0..=15 correspond to magics in `[0x184D2A50, 0x184D2A5F]`.
55pub const SKIPPABLE_MAGIC_START: u32 = 0x184D_2A50;
56
57/// Number of bytes the skippable-frame header occupies on the wire:
58/// 4 bytes magic + 4 bytes length.
59pub const SKIPPABLE_HEADER_SIZE: usize = 8;
60
61/// Upper bound on the variant nibble. Variants are constrained to the
62/// low 4 bits of the magic number so [`SKIPPABLE_MAGIC_START`] +
63/// `variant` stays inside the spec's `0x184D2A50..=0x184D2A5F` band.
64pub const SKIPPABLE_MAGIC_MAX_VARIANT: u8 = 15;
65
66/// A typed skippable-frame value.
67///
68/// Construct via [`SkippableFrame::new`] (validates the variant bound
69/// and payload size up front) or [`SkippableFrame::decode_from`].
70/// Round-trip a frame via [`SkippableFrame::encode_into`].
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct SkippableFrame {
73 magic_variant: u8,
74 payload: Vec<u8>,
75}
76
77impl SkippableFrame {
78 /// Build a `SkippableFrame` from its components. Validates:
79 /// - `magic_variant <= 15`
80 /// ([`SkippableFrameError::InvalidMagicVariant`]).
81 /// - `payload.len() <= u32::MAX as usize`
82 /// ([`SkippableFrameError::PayloadTooLarge`]) — unreachable on
83 /// 32-bit and smaller targets but enforced uniformly so 64-bit
84 /// callers cannot smuggle through an overlong payload.
85 pub fn new(magic_variant: u8, payload: Vec<u8>) -> Result<Self, SkippableFrameError> {
86 validate_magic_variant(magic_variant)?;
87 validate_payload_size(payload.len())?;
88 Ok(Self {
89 magic_variant,
90 payload,
91 })
92 }
93
94 /// The 4-bit variant nibble. Combined with [`SKIPPABLE_MAGIC_START`]
95 /// to form the on-wire magic number (`magic = START + variant`).
96 pub fn magic_variant(&self) -> u8 {
97 self.magic_variant
98 }
99
100 /// Full 32-bit magic number this frame serialises with.
101 pub fn magic_number(&self) -> u32 {
102 SKIPPABLE_MAGIC_START + u32::from(self.magic_variant)
103 }
104
105 /// Payload bytes carried by the frame (without the 8-byte header).
106 pub fn payload(&self) -> &[u8] {
107 &self.payload
108 }
109
110 /// Move the payload out, consuming the frame.
111 pub fn into_payload(self) -> Vec<u8> {
112 self.payload
113 }
114
115 /// Total serialised size of this frame on the wire:
116 /// `payload.len() + 8` (8 = 4-byte magic + 4-byte length).
117 pub fn serialized_size(&self) -> usize {
118 self.payload.len() + SKIPPABLE_HEADER_SIZE
119 }
120
121 /// Serialise this frame into `writer`. Writes
122 /// `serialized_size()` bytes total: 4-byte magic LE,
123 /// 4-byte length LE, payload bytes.
124 pub fn encode_into<W: Write>(&self, writer: &mut W) -> Result<(), Error> {
125 write_skippable_frame_to(self.magic_variant, &self.payload, writer).map(|_| ())
126 }
127
128 /// Read one skippable frame from `reader`. Consumes
129 /// 4-byte magic + 4-byte length + `length` payload bytes. The
130 /// caller is responsible for positioning the reader at a frame
131 /// boundary; this method does not scan past unknown content.
132 ///
133 /// Three layers of protection against crafted-`length` DoS:
134 ///
135 /// 1. Validates that `length` is representable on the target
136 /// pointer width (`length + SKIPPABLE_HEADER_SIZE` must not
137 /// overflow `usize`). On 32-bit targets a wire `length` near
138 /// `u32::MAX` would otherwise overflow `serialized_size()` and
139 /// `write_skippable_frame_to`. Returns
140 /// [`DecodeSkippableFrameError::PayloadTooLarge`] up front.
141 ///
142 /// 2. Reserves the address space via [`Vec::try_reserve_exact`],
143 /// converting alloc-failure into typed
144 /// [`DecodeSkippableFrameError::AllocationFailed`] instead of
145 /// process abort.
146 ///
147 /// 3. Reads the payload in fixed-size chunks via a stack scratch
148 /// buffer, so the OS only commits pages for bytes the reader
149 /// actually delivers. A crafted `length` near `u32::MAX` on a
150 /// reader that terminates early surfaces as
151 /// `DecodeSkippableFrameError::Payload` without ever
152 /// committing the full allocation — on OSes with memory
153 /// overcommit (Linux default) where step 2 would otherwise
154 /// succeed for any nominal size, this is what makes the
155 /// "no abort on huge length" guarantee actually reliable.
156 ///
157 /// Callers handling untrusted streams should additionally cap
158 /// the acceptable payload size at the application layer; this
159 /// method itself imposes no upper bound beyond the wire-format
160 /// `u32::MAX` plus target-representability.
161 pub fn decode_from<R: Read>(reader: &mut R) -> Result<Self, DecodeSkippableFrameError> {
162 let mut magic_buf = [0u8; 4];
163 reader
164 .read_exact(&mut magic_buf)
165 .map_err(DecodeSkippableFrameError::Magic)?;
166 let magic_number = u32::from_le_bytes(magic_buf);
167
168 let variant = magic_number.wrapping_sub(SKIPPABLE_MAGIC_START);
169 if !(0..=u32::from(SKIPPABLE_MAGIC_MAX_VARIANT)).contains(&variant) {
170 return Err(DecodeSkippableFrameError::BadMagicNumber(magic_number));
171 }
172
173 let mut len_buf = [0u8; 4];
174 reader
175 .read_exact(&mut len_buf)
176 .map_err(DecodeSkippableFrameError::Length)?;
177 let length_u32 = u32::from_le_bytes(len_buf);
178
179 // Convert the wire-format u32 length to `usize` via
180 // `TryFrom` (NOT `as usize`). On 16-bit pointer-width
181 // targets (e.g. MSP430) the bare `as usize` would silently
182 // truncate any value above `u16::MAX`, leaving the
183 // subsequent allocation + `read_exact` to consume far fewer
184 // bytes than the wire declared and leaving the reader
185 // mis-aligned at a junk position in the stream. Surface
186 // unrepresentable lengths as `PayloadTooLarge` BEFORE any
187 // allocation. The error variant carries the raw wire-format
188 // `u32` so the diagnostic reports the declared value
189 // verbatim — no narrowing cast where it would matter most
190 // (the 16-bit target).
191 let length = usize::try_from(length_u32)
192 .map_err(|_| DecodeSkippableFrameError::PayloadTooLarge { length: length_u32 })?;
193
194 // Reject lengths that the `new()` / `write_skippable_frame()`
195 // path would also reject up front. On 32-bit targets this
196 // catches `length + SKIPPABLE_HEADER_SIZE` overflowing
197 // `usize` when the declared length sits near `u32::MAX`.
198 // On 64-bit the check is a no-op (every u32 length is
199 // representable). On 16-bit the upstream `try_from` already
200 // rejected everything above `u16::MAX`, so this is also
201 // a no-op there.
202 if length.checked_add(SKIPPABLE_HEADER_SIZE).is_none() {
203 return Err(DecodeSkippableFrameError::PayloadTooLarge { length: length_u32 });
204 }
205
206 let mut payload: Vec<u8> = Vec::new();
207 payload
208 .try_reserve_exact(length)
209 .map_err(|_| DecodeSkippableFrameError::AllocationFailed { requested: length })?;
210
211 // Read in chunks via a stack scratch buffer instead of
212 // `resize(length, 0) + read_exact(&mut payload)`. The
213 // resize-then-read path eagerly zero-fills the entire
214 // address range up front, which on overcommit OSes
215 // (Linux default) triggers the OOM killer the moment the
216 // crafted-`length` worth of pages get committed — even
217 // though `try_reserve_exact` succeeded earlier. Chunked
218 // reads commit pages only as the reader delivers bytes,
219 // so a 4 GiB-declared payload on a 12-byte stream commits
220 // ~one page, surfaces `Payload`, and exits.
221 // 1 KiB scratch — small enough to live comfortably on a
222 // Cortex-M0 4 KiB default stack while still amortising the
223 // per-read overhead vs byte-by-byte reads. Larger sizes
224 // (16 KiB) realistically overflow small-stack embedded
225 // targets that this crate explicitly supports via the
226 // no-std + alloc build.
227 const CHUNK: usize = 1024;
228 let mut scratch = [0u8; CHUNK];
229 let mut remaining = length;
230 while remaining > 0 {
231 let take = remaining.min(CHUNK);
232 reader
233 .read_exact(&mut scratch[..take])
234 .map_err(DecodeSkippableFrameError::Payload)?;
235 payload.extend_from_slice(&scratch[..take]);
236 remaining -= take;
237 }
238
239 Ok(Self {
240 magic_variant: variant as u8,
241 payload,
242 })
243 }
244}
245
246/// Free function for callers that want to write a skippable frame
247/// directly into a sink without constructing a temporary
248/// [`SkippableFrame`]. Shape mirrors upstream zstd
249/// `ZSTD_writeSkippableFrame(dst, dstCapacity, src, srcSize,
250/// magicVariant)` — same validation, same byte-level output.
251///
252/// On success returns the number of bytes written
253/// (`payload.len() + 8`).
254pub fn write_skippable_frame<W: Write>(
255 magic_variant: u8,
256 payload: &[u8],
257 writer: &mut W,
258) -> Result<usize, SkippableFrameError> {
259 validate_magic_variant(magic_variant)?;
260 validate_payload_size(payload.len())?;
261 write_skippable_frame_to(magic_variant, payload, writer).map_err(SkippableFrameError::Io)
262}
263
264/// Internal raw writer. Skips validation (caller must have validated
265/// `magic_variant` and `payload.len()` first) and propagates raw I/O
266/// errors. Used by both the typed [`SkippableFrame::encode_into`] and
267/// the free [`write_skippable_frame`].
268fn write_skippable_frame_to<W: Write>(
269 magic_variant: u8,
270 payload: &[u8],
271 writer: &mut W,
272) -> Result<usize, Error> {
273 let magic = SKIPPABLE_MAGIC_START + u32::from(magic_variant);
274 let length = payload.len() as u32;
275
276 writer.write_all(&magic.to_le_bytes())?;
277 writer.write_all(&length.to_le_bytes())?;
278 writer.write_all(payload)?;
279 Ok(payload.len() + SKIPPABLE_HEADER_SIZE)
280}
281
282#[inline]
283fn validate_magic_variant(magic_variant: u8) -> Result<(), SkippableFrameError> {
284 if magic_variant > SKIPPABLE_MAGIC_MAX_VARIANT {
285 Err(SkippableFrameError::InvalidMagicVariant(magic_variant))
286 } else {
287 Ok(())
288 }
289}
290
291#[inline]
292fn validate_payload_size(len: usize) -> Result<(), SkippableFrameError> {
293 // The on-wire length field is u32; payloads beyond u32::MAX are
294 // not representable. The `as u64` cast is needed to compare on
295 // 32-bit targets where `u32::MAX as usize == usize::MAX` and the
296 // condition trivially folds away (correct: no payload on 32-bit
297 // can exceed the limit).
298 if (len as u64) > u64::from(u32::MAX) {
299 return Err(SkippableFrameError::PayloadTooLarge(len));
300 }
301 // On 32-bit targets `usize` IS `u32` so the wire-format limit
302 // (`u32::MAX`) is identical to `usize::MAX`. Computing the total
303 // serialised size as `len + SKIPPABLE_HEADER_SIZE` would then
304 // overflow `usize` when `len` sits at the wire-format ceiling.
305 // Reject those borderline-sized payloads up front so
306 // `serialized_size()` and `write_skippable_frame_to` stay
307 // unconditionally panic-free across target widths.
308 if len.checked_add(SKIPPABLE_HEADER_SIZE).is_none() {
309 return Err(SkippableFrameError::PayloadTooLarge(len));
310 }
311 Ok(())
312}
313
314/// Errors surfaced when constructing or writing a [`SkippableFrame`].
315#[derive(Debug)]
316#[non_exhaustive]
317pub enum SkippableFrameError {
318 /// `magic_variant` outside the spec's `0..=15` range.
319 InvalidMagicVariant(u8),
320 /// `payload.len()` exceeds `u32::MAX`, the on-wire length field
321 /// width, OR would overflow `usize` when combined with the
322 /// 8-byte skippable-frame header (32-bit targets).
323 PayloadTooLarge(usize),
324 /// Underlying I/O error from the writer.
325 Io(Error),
326}
327
328/// Errors surfaced when reading a [`SkippableFrame`] from a stream.
329#[derive(Debug)]
330#[non_exhaustive]
331pub enum DecodeSkippableFrameError {
332 /// I/O error while reading the 4-byte magic prefix.
333 Magic(Error),
334 /// First 4 bytes are not a skippable-frame magic in the
335 /// `0x184D2A50..=0x184D2A5F` range.
336 BadMagicNumber(u32),
337 /// I/O error while reading the 4-byte length field.
338 Length(Error),
339 /// I/O error while reading the payload bytes.
340 Payload(Error),
341 /// Allocation of the payload buffer failed (e.g. a crafted
342 /// length field requested more memory than is available).
343 /// `requested` is the byte count the on-wire length field
344 /// asked for.
345 AllocationFailed { requested: usize },
346 /// Wire-format `length` field is not representable on this
347 /// target's `usize` width: either `usize::try_from(length)`
348 /// fails outright (16-bit targets where the declared length
349 /// exceeds `u16::MAX`) or `length + SKIPPABLE_HEADER_SIZE`
350 /// would overflow `usize` (32-bit targets where the declared
351 /// length sits near `u32::MAX`). On 64-bit every u32 length
352 /// is representable and this variant is unreachable.
353 ///
354 /// `length` is the raw wire-format `u32` value from the
355 /// length field — preserved exactly so callers can diagnose
356 /// what the stream declared, without any narrowing cast.
357 PayloadTooLarge { length: u32 },
358}
359
360impl core::fmt::Display for SkippableFrameError {
361 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
362 match self {
363 Self::InvalidMagicVariant(v) => {
364 write!(
365 f,
366 "skippable frame magic_variant {v} out of range 0..={}",
367 SKIPPABLE_MAGIC_MAX_VARIANT
368 )
369 }
370 Self::PayloadTooLarge(n) => write!(
371 f,
372 "skippable frame payload size {n} not representable: either exceeds u32::MAX (wire-format length-field ceiling) or overflows usize when combined with the 8-byte header (32-bit targets)"
373 ),
374 Self::Io(e) => write!(f, "skippable frame I/O error: {e}"),
375 }
376 }
377}
378
379#[cfg(feature = "std")]
380impl std::error::Error for SkippableFrameError {
381 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
382 match self {
383 Self::Io(e) => Some(e),
384 Self::InvalidMagicVariant(_) | Self::PayloadTooLarge(_) => None,
385 }
386 }
387}
388
389impl From<Error> for SkippableFrameError {
390 fn from(value: Error) -> Self {
391 Self::Io(value)
392 }
393}
394
395impl core::fmt::Display for DecodeSkippableFrameError {
396 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
397 match self {
398 Self::Magic(e) => write!(f, "skippable frame: error reading magic number: {e}"),
399 Self::BadMagicNumber(m) => write!(
400 f,
401 "skippable frame: magic 0x{m:08X} is not in the skippable range 0x184D2A50..=0x184D2A5F"
402 ),
403 Self::Length(e) => write!(f, "skippable frame: error reading length field: {e}"),
404 Self::Payload(e) => write!(f, "skippable frame: error reading payload bytes: {e}"),
405 Self::AllocationFailed { requested } => write!(
406 f,
407 "skippable frame: failed to allocate {requested} bytes for payload"
408 ),
409 Self::PayloadTooLarge { length } => write!(
410 f,
411 "skippable frame: declared length {length} not representable on this target (length > usize::MAX on 16-bit, or length + 8 byte header overflows usize on 32-bit)"
412 ),
413 }
414 }
415}
416
417#[cfg(feature = "std")]
418impl std::error::Error for DecodeSkippableFrameError {
419 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
420 match self {
421 Self::Magic(e) | Self::Length(e) | Self::Payload(e) => Some(e),
422 Self::BadMagicNumber(_)
423 | Self::AllocationFailed { .. }
424 | Self::PayloadTooLarge { .. } => None,
425 }
426 }
427}
428
429#[cfg(all(test, feature = "std"))]
430mod tests;