Skip to main content

heatshrink/
lib.rs

1#![crate_type = "rlib"]
2#![no_std]
3#![deny(warnings)]
4#![forbid(unsafe_code)]
5#![deny(missing_docs)]
6
7//! Minimal compression & decompression library for embedded use.
8//!
9//! Implements the Heatshrink compression algorithm (LZSS-based).
10//! See <https://github.com/atomicobject/heatshrink> for the original C library.
11//!
12//! # Parameters
13//!
14//! Both the encoder and decoder are parameterised by const generics:
15//!
16//! - `W`   : base-2 log of the LZSS sliding window size (4–14).
17//! - `L`   : number of bits for back-reference lengths (3 ≤ L < W).
18//! - `I`   : (decoder) streaming input buffer size in bytes (≥ 1).
19//! - `BUF` : (encoder) total input buffer = `2 << W` bytes. **Must equal `2 << W`.**
20//! - `WIN` : (decoder) window buffer = `1 << W` bytes. **Must equal `1 << W`.**
21//!
22//! The `BUF` and `WIN` parameters are a workaround for the current Rust stable
23//! limitation that prevents arithmetic expressions on const generics in array
24//! sizes. They are always derived from `W` and are hidden behind type aliases.
25//!
26//! # Convenience aliases
27//!
28//! [`DefaultEncoder`] and [`DefaultDecoder`] use W=8, L=4, I=32, matching the
29//! original C library. Prefer these unless you need custom parameters.
30//!
31//! # Custom parameters example
32//!
33//! ```rust
34//! use heatshrink::encoder::HeatshrinkEncoder;
35//! use heatshrink::decoder::HeatshrinkDecoder;
36//!
37//! // W=10, L=5: BUF = 2<<10 = 2048, WIN = 1<<10 = 1024
38//! type MyEncoder = HeatshrinkEncoder<10, 5, 2048>;
39//! type MyDecoder = HeatshrinkDecoder<10, 5, 64, 1024>;
40//! ```
41
42/// Module to decompress compressed data.
43pub mod decoder;
44/// Module to compress data.
45pub mod encoder;
46
47/// [`embedded_io`](::embedded_io) adapters for the encoder and decoder.
48///
49/// Enabled by the `embedded-io` Cargo feature.
50#[cfg(feature = "embedded-io")]
51pub mod io;
52
53/// Default window size in bits — matches the original C library.
54pub const DEFAULT_WINDOW_BITS: usize = 8;
55/// Default lookahead size in bits — matches the original C library.
56pub const DEFAULT_LOOKAHEAD_BITS: usize = 4;
57/// Default decoder input buffer size in bytes — matches the original C library.
58pub const DEFAULT_INPUT_BUFFER_SIZE: usize = 32;
59
60/// Encoder using the original C library parameters (W=8, L=4).
61///
62/// `BUF = 2 << 8 = 512`.
63pub type DefaultEncoder = encoder::HeatshrinkEncoder<
64    DEFAULT_WINDOW_BITS,
65    DEFAULT_LOOKAHEAD_BITS,
66    512, // 2 << DEFAULT_WINDOW_BITS
67>;
68
69/// Decoder using the original C library parameters (W=8, L=4, I=32).
70///
71/// `WIN = 1 << 8 = 256`.
72pub type DefaultDecoder = decoder::HeatshrinkDecoder<
73    DEFAULT_WINDOW_BITS,
74    DEFAULT_LOOKAHEAD_BITS,
75    DEFAULT_INPUT_BUFFER_SIZE,
76    256, // 1 << DEFAULT_WINDOW_BITS
77>;
78
79/// Error returned by [`encoder::HeatshrinkEncoder::sink`] and
80/// [`decoder::HeatshrinkDecoder::sink`].
81#[derive(Debug, PartialEq, Eq)]
82pub enum SinkError {
83    /// Internal buffer is full; no data was consumed. Drain with `poll()` first.
84    Full,
85    /// API misuse: `sink()` called in the wrong state (e.g. after `finish()`).
86    Misuse,
87}
88
89/// Error returned by [`encoder::HeatshrinkEncoder::poll`] and
90/// [`decoder::HeatshrinkDecoder::poll`].
91///
92/// Only one variant exists: passing an empty output buffer is always a
93/// programming error.
94#[derive(Debug, PartialEq, Eq)]
95pub enum PollError {
96    /// API misuse: `poll()` was called with an empty output buffer.
97    Misuse,
98}
99
100/// Outcome of a successful [`encoder::HeatshrinkEncoder::poll`] or
101/// [`decoder::HeatshrinkDecoder::poll`] call.
102#[derive(Debug, PartialEq, Eq)]
103pub enum Poll {
104    /// Output buffer is full; more compressed/decompressed data is available.
105    /// Value is the number of bytes written into the output buffer.
106    More(usize),
107    /// Internal state is fully drained for now.
108    /// Value is the number of bytes written into the output buffer.
109    Empty(usize),
110}
111
112impl Poll {
113    /// Number of bytes written into the output buffer, regardless of variant.
114    #[inline]
115    pub fn bytes_written(&self) -> usize {
116        match self {
117            Poll::More(n) | Poll::Empty(n) => *n,
118        }
119    }
120}
121
122/// Outcome of a [`encoder::HeatshrinkEncoder::finish`] or
123/// [`decoder::HeatshrinkDecoder::finish`] call.
124#[derive(Debug, PartialEq, Eq)]
125pub enum Finish {
126    /// Stream is complete; no further `poll()` calls are needed.
127    Done,
128    /// More output remains; call `poll()` until it returns [`Poll::Empty`],
129    /// then call `finish()` again.
130    More,
131}
132
133/// Error returned by the convenience functions [`encoder::encode`] and
134/// [`decoder::decode`].
135#[derive(Debug)]
136pub enum CodecError {
137    /// Output buffer was too small to hold the result.
138    OutputFull,
139    /// Internal error (should not occur in normal use).
140    Internal,
141}
142
143// ─── Tests ───────────────────────────────────────────────────────────────────
144//
145// Three layers of coverage:
146//
147//  1. `test`          — regression fixtures for the default parameters (W=8,
148//                       L=4) plus a clib compatibility vector.
149//
150//  2. `test_streaming` — a generic streaming round-trip harness used for every
151//                        (W, L) pair exercised below.  Uses a small I (16) to
152//                        force many sink/poll cycles and stress buffer-refill
153//                        paths, plus I=32 which matches heatshrink-bin.
154//
155//  3. `test_params`   — spot-checks a representative set of (W, L) pairs,
156//                       including the boundary cases that triggered some bugs.
157
158#[cfg(test)]
159mod test {
160    use super::{decoder, encoder};
161
162    /// Round-trip via the convenience `encode` / `decode` free functions
163    /// (DefaultEncoder / DefaultDecoder, W=8 L=4).
164    fn compare(src: &[u8]) {
165        let mut compressed_buffer: [u8; 512] = [0; 512];
166        let mut uncompressed_buffer: [u8; 1024] = [0; 1024];
167        let out1 = encoder::encode(src, &mut compressed_buffer).unwrap();
168        let out2 = decoder::decode(out1, &mut uncompressed_buffer).unwrap();
169        assert_eq!(src, out2);
170    }
171
172    #[test]
173    fn alpha() {
174        let src = [
175            33, 82, 149, 84, 52, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
176            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 2, 0, 0, 0, 0, 0, 0, 242, 2, 241, 2, 240,
177            2, 0, 0, 0, 0, 0, 0, 47, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
178            0, 0,
179        ];
180        compare(&src);
181    }
182
183    #[test]
184    fn alpha2() {
185        let src = [
186            33, 82, 149, 84, 52, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
187            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 147, 2, 0, 0, 0, 0, 0, 0, 242, 2, 241, 2, 240,
188            2, 0, 0, 0, 0, 0, 0, 47, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
189            12, 17,
190        ];
191        compare(&src);
192    }
193
194    #[test]
195    fn beta() {
196        let src = [
197            189, 160, 51, 163, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
198            0, 199, 0, 0, 0, 0, 0, 0, 0, 166, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 154, 0,
199            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
200            0,
201        ];
202        compare(&src);
203    }
204
205    #[test]
206    fn beta2() {
207        // Two full 0..=255 ramps concatenated.
208        let src: [u8; 512] = core::array::from_fn(|i| i as u8);
209        compare(&src);
210    }
211
212    #[test]
213    fn clib_compatibility() {
214        use hex_literal::hex;
215        let src = hex!("90D4B2B549A4082BE00F000E4C46DF2817C605F005B4BE0825F00280");
216        let expected = hex!(
217            "21529554340200000000000000000000000000000000000000000000000000000000000000000 0009302000000000000F202F102F0020000000000002F0400000000000000000000000000000000000000000000"
218        );
219        let mut dst = [0u8; 100];
220        let out = decoder::decode(&src, &mut dst).unwrap();
221        assert_eq!(expected, out);
222    }
223}
224
225// ─── Generic streaming harness ────────────────────────────────────────────────
226
227#[cfg(test)]
228mod test_streaming {
229    use super::*;
230    use super::{Poll, SinkError};
231    use decoder::HeatshrinkDecoder;
232    use encoder::HeatshrinkEncoder;
233
234    /// Encode `src` with `HeatshrinkEncoder<W,L,BUF>` using the streaming API.
235    /// Returns the number of compressed bytes written into `dst`.
236    pub(super) fn stream_encode<const W: usize, const L: usize, const BUF: usize>(
237        src: &[u8],
238        dst: &mut [u8],
239    ) -> usize {
240        let mut enc = HeatshrinkEncoder::<W, L, BUF>::new();
241        let mut total_in = 0;
242        let mut total_out = 0;
243        loop {
244            if total_in < src.len() {
245                match enc.sink(&src[total_in..]) {
246                    Ok(n) => total_in += n,
247                    Err(SinkError::Full) => {}
248                    Err(SinkError::Misuse) => panic!("encoder sink misuse"),
249                }
250            }
251            if total_in == src.len() {
252                enc.finish();
253            }
254            match enc.poll(&mut dst[total_out..]) {
255                Ok(Poll::More(n)) => total_out += n,
256                Ok(Poll::Empty(n)) => {
257                    total_out += n;
258                    if total_in == src.len() {
259                        break;
260                    }
261                }
262                Err(_) => panic!("encoder poll misuse"),
263            }
264        }
265        total_out
266    }
267
268    /// Decode `encoded` with `HeatshrinkDecoder<W,L,I,WIN>` using the streaming
269    /// API.  `I` is deliberately kept small (caller's choice) to maximise the
270    /// number of sink/poll cycles and stress buffer-refill edge cases.
271    ///
272    /// Returns the number of decompressed bytes written into `dst`.
273    /// Panics with a descriptive message if the loop runs more than 1 000 000
274    /// iterations (infinite-loop guard).
275    pub(super) fn stream_decode<
276        const W: usize,
277        const L: usize,
278        const I: usize,
279        const WIN: usize,
280    >(
281        encoded: &[u8],
282        dst: &mut [u8],
283    ) -> usize {
284        let mut dec = HeatshrinkDecoder::<W, L, I, WIN>::new();
285        let mut total_in = 0;
286        let mut total_out = 0;
287        let mut iters = 0usize;
288        loop {
289            iters += 1;
290            assert!(
291                iters < 1_000_000,
292                "stream_decode infinite loop (W={W} L={L} I={I}): \
293                 iter={iters} total_in={total_in}/{} total_out={total_out}",
294                encoded.len()
295            );
296            if total_in < encoded.len() {
297                match dec.sink(&encoded[total_in..]) {
298                    Ok(n) => total_in += n,
299                    Err(SinkError::Full) => {}
300                    Err(SinkError::Misuse) => panic!("decoder sink misuse"),
301                }
302            }
303            match dec.poll(&mut dst[total_out..]) {
304                Ok(Poll::More(n)) => total_out += n,
305                Ok(Poll::Empty(n)) => {
306                    total_out += n;
307                    if total_in == encoded.len() {
308                        break;
309                    }
310                }
311                Err(_) => panic!("decoder poll misuse"),
312            }
313        }
314        total_out
315    }
316
317    /// Full round-trip: encode then decode with two different values of I
318    /// (16 and 32) to cover both tight and relaxed buffer-refill paths.
319    pub(super) fn roundtrip<const W: usize, const L: usize, const BUF: usize, const WIN: usize>(
320        src: &[u8],
321    ) {
322        let mut compressed = [0u8; 32768];
323        let mut decompressed = [0u8; 32768];
324
325        let n_enc = stream_encode::<W, L, BUF>(src, &mut compressed);
326        let encoded = &compressed[..n_enc];
327
328        // I=16 — many short sink() calls, stresses buffer-boundary handling.
329        let n_dec16 = stream_decode::<W, L, 16, WIN>(encoded, &mut decompressed);
330        assert_eq!(
331            src,
332            &decompressed[..n_dec16],
333            "W={W} L={L} I=16 roundtrip failed"
334        );
335
336        // I=32 — matches the value used by heatshrink-bin.
337        decompressed = [0u8; 32768];
338        let n_dec32 = stream_decode::<W, L, 32, WIN>(encoded, &mut decompressed);
339        assert_eq!(
340            src,
341            &decompressed[..n_dec32],
342            "W={W} L={L} I=32 roundtrip failed"
343        );
344    }
345}
346
347// ─── Parametric round-trip tests ─────────────────────────────────────────────
348//
349// Each test exercises one (W, L) pair with three payloads:
350//   • a short human-readable string  (< 32 bytes, fits in one sink)
351//   • an 8 KB repetitive sequence    (many back-references)
352//   • an 8 KB pseudo-random sequence (mostly literals, stresses get_bits)
353//
354// The pairs are chosen to cover:
355//   • the default configuration         (W=8,  L=4)
356//   • the minimum configuration         (W=4,  L=3)
357//   • the maximum configuration         (W=15, L=14)
358//   • W≤8 one-state index path          (W=6,  L=3)
359//   • L>8 multi-byte count path         (W=10, L=9)
360//   • large W and large L               (W=11, L=10)
361//   • a mid-range pair                  (W=12, L=7)
362
363#[cfg(test)]
364mod test_params {
365    use super::test_streaming::roundtrip;
366
367    fn payloads() -> (&'static [u8], [u8; 8192], [u8; 8192]) {
368        let short = b"hello heatshrink - parametric test" as &[u8];
369        let repetitive: [u8; 8192] = core::array::from_fn(|i| (i % 251) as u8);
370        let pseudo_random: [u8; 8192] = core::array::from_fn(|i| {
371            (i.wrapping_mul(6364136223846793005usize)
372                .wrapping_add(1442695040888963407)
373                >> 56) as u8
374        });
375        (short, repetitive, pseudo_random)
376    }
377
378    macro_rules! param_test {
379        ($name:ident, W=$w:literal, L=$l:literal) => {
380            #[test]
381            fn $name() {
382                const BUF: usize = 2 << $w;
383                const WIN: usize = 1 << $w;
384                let (short, rep, rnd) = payloads();
385                roundtrip::<$w, $l, BUF, WIN>(short);
386                roundtrip::<$w, $l, BUF, WIN>(&rep);
387                roundtrip::<$w, $l, BUF, WIN>(&rnd);
388            }
389        };
390    }
391
392    param_test!(default_w8_l4, W = 8, L = 4);
393    param_test!(minimum_w4_l3, W = 4, L = 3);
394    param_test!(maximum_w15_l14, W = 15, L = 14);
395    param_test!(bug_b5_w6_l3, W = 6, L = 3);
396    param_test!(bug_b6_w10_l9, W = 10, L = 9);
397    param_test!(bug_b8_w11_l10, W = 11, L = 10);
398    param_test!(midrange_w12_l7, W = 12, L = 7);
399}