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}