Skip to main content

mkit_core/
delta.rs

1//! Delta instruction stream — implements the versioned format required
2//! by `docs/specs/SPEC-DELTA.md`.
3//!
4//! Stream layout (SPEC-DELTA §2):
5//!
6//! ```text
7//! [u8  stream_version == 0x01]
8//! [u32 LE base_len]            length of the base object
9//! [u32 LE result_len]          expected length of the reconstruction
10//! [instructions ...]           sequence of opcodes
11//! ```
12//!
13//! Two opcodes (SPEC-DELTA §3):
14//!
15//! * `0x80` (COPY): `[opcode][u32 LE offset][u16 LE length]` = 7 bytes.
16//!   `length` MUST be `>= 1` and `offset + length <= base_len`. The
17//!   remaining 7 low bits of the opcode are reserved and MUST be 0 in v1.
18//! * `0x01..=0x7F` (INSERT): the opcode byte is the literal length (1..127),
19//!   followed by that many literal bytes. Long literals are split into
20//!   multiple INSERTs; there is no extended-length form in v1.
21//!
22//! `0x00` is **reserved** and MUST be rejected.
23
24use crate::object::MkitError;
25
26/// Version byte at offset 0 of every v1 delta stream.
27pub const STREAM_VERSION: u8 = 0x01;
28/// `COPY` opcode — top bit set, low seven bits reserved (must be zero).
29pub const OP_COPY: u8 = 0x80;
30/// Maximum bytes encodable in a single `INSERT` opcode.
31pub const MAX_INSERT_LEN: usize = 127;
32/// Fixed prefix size: 1 byte version + 4 byte `base_len` + 4 byte `result_len`.
33pub const HEADER_LEN: usize = 1 + 4 + 4;
34
35/// Block size for the writer's hash index. Power of two for cheap
36/// alignment math. Not part of the wire format — readers don't care.
37const BLOCK_SIZE: usize = 16;
38
39/// Multiplier applied to `stream.len()` when bounding the decoder's
40/// initial `Vec::with_capacity`. The worst-case expansion of a COPY op
41/// is 7 bytes of stream → `u16::MAX` output bytes (≈ 9363×), but in
42/// practice stream-sized * 256 dwarfs real deltas and still keeps the
43/// attacker's reach tiny: a 9-byte stream can only request ≤ 2304
44/// bytes of pre-allocation, independent of `base.len()` or the
45/// declared `result_len`. The final `result_len` self-consistency
46/// check still catches inflated payloads.
47pub(crate) const CAP_MULTIPLIER: usize = 256;
48
49/// Compute the decoder's initial capacity hint.
50///
51/// This is a small, explicitly attacker-resistant helper, exposed to
52/// the crate so that the regression test can pin down the bound
53/// without reaching into `decode`. The hint is the smaller of:
54///
55/// * the declared `result_len` (can't exceed declared output), and
56/// * `stream.len() * CAP_MULTIPLIER` (attacker bounded by on-wire size).
57///
58/// Crucially, `base.len()` does NOT appear here: letting the base size
59/// influence the cap would let an attacker pair a small delta stream with
60/// a large base to pre-reserve a huge output buffer (a ~1 GiB
61/// attacker-controlled allocation), so the cap is bounded only by the
62/// declared output length and the on-wire stream size.
63#[inline]
64pub(crate) fn compute_cap_hint(result_len: usize, _base_len: usize, stream_len: usize) -> usize {
65    result_len.min(stream_len.saturating_mul(CAP_MULTIPLIER))
66}
67
68/// Build a v1 delta stream that reconstructs `result` from `base`.
69///
70/// The writer is an FNV-1a-on-16-byte-blocks scan. Any conformant
71/// writer is acceptable; this one is greedy. Output is always at least
72/// [`HEADER_LEN`] bytes.
73///
74/// # Errors
75///
76/// Returns [`MkitError::DeltaLengthOverflow`] if either `base.len()`
77/// or `result.len()` exceeds `u32::MAX`. SPEC-PACKFILE caps individual
78/// payloads under this bound, so this is a programmer error rather
79/// than a normal runtime condition — but silently saturating (the
80/// old behaviour) produced a stream that `decode()` rejected with a
81/// confusing "length mismatch" far from the actual source.
82///
83/// # Panics
84///
85/// Panics only on invariant violations in the writer's bookkeeping
86/// (insert-buffer length > 127, match length > `u16::MAX`); both are
87/// guarded above and unreachable for any valid input.
88pub fn encode(base: &[u8], result: &[u8]) -> Result<Vec<u8>, MkitError> {
89    use std::collections::HashMap;
90
91    check_length_bounds(base.len(), result.len())?;
92
93    let mut out = Vec::with_capacity(HEADER_LEN + result.len());
94    write_header(&mut out, base.len(), result.len());
95
96    // Build hash table: hash(block) -> first-seen position. We cap
97    // `base.len()` at `u32::MAX` for COPY offsets — bases over 4 GiB
98    // are out of scope for v1 (SPEC-PACKFILE caps individual payloads).
99    let num_blocks = base.len() / BLOCK_SIZE;
100    let mut index: HashMap<u64, u32> = HashMap::with_capacity(num_blocks);
101    for i in 0..num_blocks {
102        let pos = i * BLOCK_SIZE;
103        if let Ok(pos_u32) = u32::try_from(pos) {
104            let block = &base[pos..pos + BLOCK_SIZE];
105            let h = block_hash(block);
106            index.entry(h).or_insert(pos_u32);
107        } else {
108            break; // base too large for u32 offsets; fall back to all-INSERT
109        }
110    }
111
112    let mut insert_buf: Vec<u8> = Vec::with_capacity(MAX_INSERT_LEN);
113    let mut ti = 0usize;
114    while ti < result.len() {
115        let mut matched = false;
116        if ti + BLOCK_SIZE <= result.len() {
117            let target_block = &result[ti..ti + BLOCK_SIZE];
118            let h = block_hash(target_block);
119            if let Some(&base_pos) = index.get(&h) {
120                let base_pos_usize = base_pos as usize;
121                if &base[base_pos_usize..base_pos_usize + BLOCK_SIZE] == target_block {
122                    flush_insert(&mut out, &mut insert_buf);
123
124                    // Greedy forward extension, capped at u16::MAX.
125                    let mut match_len = BLOCK_SIZE;
126                    while base_pos_usize + match_len < base.len()
127                        && ti + match_len < result.len()
128                        && base[base_pos_usize + match_len] == result[ti + match_len]
129                        && match_len < u16::MAX as usize
130                    {
131                        match_len += 1;
132                    }
133                    // match_len is bounded by u16::MAX above.
134                    emit_copy(
135                        &mut out,
136                        base_pos,
137                        u16::try_from(match_len).expect("<= u16::MAX"),
138                    );
139                    ti += match_len;
140                    matched = true;
141                }
142            }
143        }
144        if !matched {
145            insert_buf.push(result[ti]);
146            ti += 1;
147            if insert_buf.len() == MAX_INSERT_LEN {
148                flush_insert(&mut out, &mut insert_buf);
149            }
150        }
151    }
152    flush_insert(&mut out, &mut insert_buf);
153    Ok(out)
154}
155
156/// Validate that `base_len` and `result_len` both fit in the v1 wire
157/// format's `u32` cap. Extracted as a helper so tests can exercise
158/// the bound without actually allocating multi-gigabyte buffers.
159pub(crate) fn check_length_bounds(base_len: usize, result_len: usize) -> Result<(), MkitError> {
160    if u32::try_from(base_len).is_err() {
161        return Err(MkitError::DeltaLengthOverflow {
162            field: "base_len",
163            len: base_len,
164        });
165    }
166    if u32::try_from(result_len).is_err() {
167        return Err(MkitError::DeltaLengthOverflow {
168            field: "result_len",
169            len: result_len,
170        });
171    }
172    Ok(())
173}
174
175/// Apply a v1 delta stream to `base`, returning the reconstructed bytes.
176/// Verifies header version, base length, COPY bounds, and the final
177/// `result_len`.
178///
179/// # Errors
180///
181/// Returns [`MkitError::UnsupportedObjectVersion`] for stream version
182/// other than `0x01`, [`MkitError::UnexpectedEof`] for truncated input,
183/// and [`MkitError::TrailingData`] for any other corruption (zero
184/// opcode, COPY past base, length mismatch at end-of-stream, reserved
185/// bits set, etc.).
186///
187/// # Panics
188///
189/// Slice-to-fixed-array conversions in this function are guarded by
190/// the preceding bounds checks; the `expect` calls trip only if the
191/// compiler's slice-bounds elision is wrong.
192pub fn decode(base: &[u8], stream: &[u8]) -> Result<Vec<u8>, MkitError> {
193    if stream.len() < HEADER_LEN {
194        return Err(MkitError::UnexpectedEof);
195    }
196    if stream[0] != STREAM_VERSION {
197        return Err(MkitError::UnsupportedObjectVersion);
198    }
199    let base_len = u32::from_le_bytes(stream[1..5].try_into().expect("4 bytes")) as usize;
200    let result_len = u32::from_le_bytes(stream[5..9].try_into().expect("4 bytes")) as usize;
201    if base_len != base.len() {
202        return Err(MkitError::TrailingData);
203    }
204
205    // Bound the pre-allocation against attacker-controlled length fields.
206    // See [`compute_cap_hint`]: the hint is strictly a function of
207    // `stream.len()` (with `result_len` as an upper bound) — `base.len()`
208    // MUST NOT appear, because a 1 GiB base + 9-byte crafted stream
209    // otherwise triggers a ≈ 1 GiB allocation. The final `result_len`
210    // equality check below still enforces wire-level self-consistency.
211    let cap_hint = compute_cap_hint(result_len, base.len(), stream.len());
212    let mut out: Vec<u8> = Vec::with_capacity(cap_hint);
213    let mut pos = HEADER_LEN;
214    while pos < stream.len() {
215        let op = stream[pos];
216        pos += 1;
217        if op & 0x80 != 0 {
218            // COPY. Reserved low seven bits MUST be zero in v1.
219            if op & 0x7F != 0 {
220                return Err(MkitError::TrailingData);
221            }
222            if pos + 6 > stream.len() {
223                return Err(MkitError::UnexpectedEof);
224            }
225            let offset =
226                u32::from_le_bytes(stream[pos..pos + 4].try_into().expect("4 bytes")) as usize;
227            pos += 4;
228            let length =
229                u16::from_le_bytes(stream[pos..pos + 2].try_into().expect("2 bytes")) as usize;
230            pos += 2;
231            if length == 0 {
232                return Err(MkitError::TrailingData);
233            }
234            // Use checked math: an attacker-controlled offset could
235            // overflow `usize` on 32-bit targets when added to length, so
236            // reject the input rather than wrapping or clamping.
237            let end = offset.checked_add(length).ok_or(MkitError::TrailingData)?;
238            if end > base.len() {
239                return Err(MkitError::TrailingData);
240            }
241            // Don't overshoot the declared result_len.
242            if out.len().checked_add(length).is_none_or(|v| v > result_len) {
243                return Err(MkitError::TrailingData);
244            }
245            out.extend_from_slice(&base[offset..end]);
246        } else if op > 0 {
247            // INSERT. opcode IS the literal length (1..=127).
248            let length = op as usize;
249            if pos + length > stream.len() {
250                return Err(MkitError::UnexpectedEof);
251            }
252            if out.len().checked_add(length).is_none_or(|v| v > result_len) {
253                return Err(MkitError::TrailingData);
254            }
255            out.extend_from_slice(&stream[pos..pos + length]);
256            pos += length;
257        } else {
258            // 0x00 reserved.
259            return Err(MkitError::TrailingData);
260        }
261    }
262    if out.len() != result_len {
263        return Err(MkitError::TrailingData);
264    }
265    Ok(out)
266}
267
268// --- helpers ---
269
270fn write_header(out: &mut Vec<u8>, base_len: usize, result_len: usize) {
271    // `check_length_bounds` has already been called by `encode`, so
272    // both fit in u32. The `expect()`s are invariant-preserving
273    // rather than user-facing: reaching them means the caller
274    // bypassed `encode`.
275    let bl: u32 = u32::try_from(base_len).expect("base_len <= u32::MAX (checked)");
276    let rl: u32 = u32::try_from(result_len).expect("result_len <= u32::MAX (checked)");
277    out.push(STREAM_VERSION);
278    out.extend_from_slice(&bl.to_le_bytes());
279    out.extend_from_slice(&rl.to_le_bytes());
280}
281
282fn emit_copy(out: &mut Vec<u8>, offset: u32, length: u16) {
283    out.push(OP_COPY);
284    out.extend_from_slice(&offset.to_le_bytes());
285    out.extend_from_slice(&length.to_le_bytes());
286}
287
288fn flush_insert(out: &mut Vec<u8>, buf: &mut Vec<u8>) {
289    if buf.is_empty() {
290        return;
291    }
292    debug_assert!(buf.len() <= MAX_INSERT_LEN);
293    out.push(u8::try_from(buf.len()).expect("<= 127"));
294    out.extend_from_slice(buf);
295    buf.clear();
296}
297
298fn block_hash(block: &[u8]) -> u64 {
299    // FNV-1a 64-bit.
300    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
301    for &b in block {
302        h ^= u64::from(b);
303        h = h.wrapping_mul(0x0000_0001_0000_01b3);
304    }
305    h
306}
307
308// =========================================================================
309// Tests
310// =========================================================================
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    fn header(base_len: u32, result_len: u32) -> [u8; HEADER_LEN] {
317        let mut h = [0u8; HEADER_LEN];
318        h[0] = STREAM_VERSION;
319        h[1..5].copy_from_slice(&base_len.to_le_bytes());
320        h[5..9].copy_from_slice(&result_len.to_le_bytes());
321        h
322    }
323
324    #[test]
325    fn identity_roundtrip() {
326        let data = b"0123456789abcdef".repeat(4); // 64 bytes
327        let stream = encode(&data, &data).unwrap();
328        let restored = decode(&data, &stream).unwrap();
329        assert_eq!(restored, data);
330    }
331
332    #[test]
333    fn pure_insert_roundtrip() {
334        let base = b"aaa";
335        let target = b"zzz";
336        let stream = encode(base, target).unwrap();
337        // After the 9-byte header, the very next byte is the INSERT length.
338        assert_eq!(stream[HEADER_LEN] & 0x80, 0);
339        assert_eq!(stream[HEADER_LEN], 3);
340        let restored = decode(base, &stream).unwrap();
341        assert_eq!(restored, target);
342    }
343
344    #[test]
345    fn pure_copy_full_base() {
346        let base: Vec<u8> = (0..16u8).cycle().take(128).collect();
347        let target = &base[..64];
348        // Hand-build a stream that is exactly one COPY(0, 64).
349        let mut stream = header(
350            u32::try_from(base.len()).unwrap(),
351            u32::try_from(target.len()).unwrap(),
352        )
353        .to_vec();
354        stream.push(OP_COPY);
355        stream.extend_from_slice(&0u32.to_le_bytes());
356        stream.extend_from_slice(&64u16.to_le_bytes());
357        assert_eq!(stream.len(), HEADER_LEN + 7);
358        let restored = decode(&base, &stream).unwrap();
359        assert_eq!(restored, target);
360    }
361
362    #[test]
363    fn near_duplicate_yields_smaller_delta() {
364        let v1 = include_str!("delta.rs"); // any sizable text
365        let mut v2 = String::from(v1);
366        v2.push_str("\n// trailing edit\n");
367        let stream = encode(v1.as_bytes(), v2.as_bytes()).unwrap();
368        let restored = decode(v1.as_bytes(), &stream).unwrap();
369        assert_eq!(restored, v2.as_bytes());
370        assert!(stream.len() < v2.len(), "delta should be smaller than v2");
371    }
372
373    #[test]
374    fn rejects_zero_opcode() {
375        let mut stream = header(0, 0).to_vec();
376        stream.push(0x00);
377        let err = decode(&[], &stream).unwrap_err();
378        assert!(matches!(err, MkitError::TrailingData));
379    }
380
381    #[test]
382    fn rejects_unknown_version() {
383        let mut bytes = header(0, 0);
384        bytes[0] = 0x02;
385        let err = decode(&[], &bytes).unwrap_err();
386        assert!(matches!(err, MkitError::UnsupportedObjectVersion));
387    }
388
389    #[test]
390    fn rejects_truncated_header() {
391        let bytes = [0x01u8, 0x00, 0x00];
392        let err = decode(&[], &bytes).unwrap_err();
393        assert!(matches!(err, MkitError::UnexpectedEof));
394    }
395
396    #[test]
397    fn rejects_truncated_copy() {
398        let mut stream = header(16, 16).to_vec();
399        stream.push(OP_COPY);
400        stream.extend_from_slice(&0u32.to_le_bytes()); // missing 2-byte length
401        let err = decode(&[0u8; 16], &stream).unwrap_err();
402        assert!(matches!(err, MkitError::UnexpectedEof));
403    }
404
405    #[test]
406    fn rejects_truncated_insert() {
407        let mut stream = header(0, 10).to_vec();
408        stream.push(10); // claim 10 literal bytes
409        stream.extend_from_slice(b"abc"); // only 3 supplied
410        let err = decode(&[], &stream).unwrap_err();
411        assert!(matches!(err, MkitError::UnexpectedEof));
412    }
413
414    #[test]
415    fn rejects_copy_past_base_end() {
416        let base = b"short"; // 5 bytes
417        let mut stream = header(u32::try_from(base.len()).unwrap(), 100).to_vec();
418        stream.push(OP_COPY);
419        stream.extend_from_slice(&0u32.to_le_bytes());
420        stream.extend_from_slice(&100u16.to_le_bytes());
421        let err = decode(base, &stream).unwrap_err();
422        assert!(matches!(err, MkitError::TrailingData));
423    }
424
425    #[test]
426    fn rejects_copy_with_zero_length() {
427        let base = [0u8; 16];
428        let mut stream = header(16, 16).to_vec();
429        stream.push(OP_COPY);
430        stream.extend_from_slice(&0u32.to_le_bytes());
431        stream.extend_from_slice(&0u16.to_le_bytes());
432        let err = decode(&base, &stream).unwrap_err();
433        assert!(matches!(err, MkitError::TrailingData));
434    }
435
436    #[test]
437    fn rejects_base_len_mismatch() {
438        let stream = header(16, 0).to_vec();
439        let err = decode(&[0u8; 8], &stream).unwrap_err();
440        assert!(matches!(err, MkitError::TrailingData));
441    }
442
443    #[test]
444    fn rejects_result_len_mismatch_at_end() {
445        // INSERTs sum to 5 but header says result_len = 3.
446        let mut stream = header(0, 3).to_vec();
447        stream.push(5);
448        stream.extend_from_slice(b"hello");
449        let err = decode(&[], &stream).unwrap_err();
450        assert!(matches!(err, MkitError::TrailingData));
451    }
452
453    #[test]
454    fn rejects_huge_result_len_without_preallocating() {
455        // Regression: a 9-byte header claiming result_len = u32::MAX MUST NOT
456        // trigger a 4 GiB `Vec::with_capacity`. The pre-allocation is now
457        // capped against the stream+base size. The decoder still returns an
458        // error (TrailingData) because no ops follow — but the point is that
459        // it does so without first reserving 4 GiB of virtual memory.
460        let stream = header(0, u32::MAX);
461        let err = decode(&[], &stream).unwrap_err();
462        assert!(matches!(err, MkitError::TrailingData));
463    }
464
465    #[test]
466    fn rejects_copy_with_reserved_low_bits() {
467        let base = [0u8; 16];
468        let mut stream = header(16, 4).to_vec();
469        stream.push(OP_COPY | 0x01); // reserved bit set
470        stream.extend_from_slice(&0u32.to_le_bytes());
471        stream.extend_from_slice(&4u16.to_le_bytes());
472        let err = decode(&base, &stream).unwrap_err();
473        assert!(matches!(err, MkitError::TrailingData));
474    }
475
476    #[test]
477    fn empty_base_pure_insert() {
478        let target = b"all new content here!";
479        let stream = encode(b"", target).unwrap();
480        let restored = decode(b"", &stream).unwrap();
481        assert_eq!(restored, target);
482    }
483
484    #[test]
485    fn cap_hint_does_not_scale_with_base_len() {
486        // G5 regression: the decoder's pre-allocation must be bounded by
487        // `stream.len()`, not by `base.len()`. Previously a 9-byte stream
488        // with a 1 GiB base could drive `Vec::with_capacity` to ~1 GiB.
489        //
490        // Assert: for a huge base + tiny stream, cap_hint is tiny.
491        let huge_base = 1usize << 30; // 1 GiB
492        let tiny_stream = 9usize; // just the header
493        let declared_result = u32::MAX as usize;
494        let cap = super::compute_cap_hint(declared_result, huge_base, tiny_stream);
495        assert!(
496            cap <= tiny_stream.saturating_mul(CAP_MULTIPLIER),
497            "cap_hint {cap} must be bounded by stream.len() * CAP_MULTIPLIER, \
498             not by base.len()",
499        );
500        assert!(
501            cap < 1024 * 1024,
502            "cap_hint {cap} must stay well below 1 MiB for a 9-byte stream",
503        );
504    }
505
506    /// `encode()` used to saturate `base_len`/`result_len` to
507    /// `u32::MAX` for inputs over 4 GiB, silently producing a stream
508    /// that `decode()` would reject with a confusing "length mismatch".
509    /// Now `check_length_bounds` errors out explicitly with
510    /// `DeltaLengthOverflow` so misuse surfaces at the call site.
511    #[test]
512    fn check_length_bounds_rejects_over_u32() {
513        // base_len above u32::MAX.
514        let over = (u32::MAX as usize).saturating_add(1);
515        assert!(matches!(
516            check_length_bounds(over, 0),
517            Err(MkitError::DeltaLengthOverflow { .. })
518        ));
519        // result_len above u32::MAX.
520        assert!(matches!(
521            check_length_bounds(0, over),
522            Err(MkitError::DeltaLengthOverflow { .. })
523        ));
524        // Exactly at u32::MAX is fine.
525        assert!(check_length_bounds(u32::MAX as usize, u32::MAX as usize).is_ok());
526        // Small is fine.
527        assert!(check_length_bounds(1, 1).is_ok());
528    }
529}