Skip to main content

stenoxide_core/stego/stc/
native.rs

1//! Syndrome-Trellis Codes, implemented in safe Rust.
2//!
3//! This module replaces the former FFI wrapper around libsdc++. It solves the
4//! same problem the foreign library did, with the same public surface, and
5//! without a single `unsafe` block or a C++ toolchain.
6//!
7//! # The problem being solved
8//!
9//! Given a cover vector `x`, a per-position cost vector `rho` and a message `m`,
10//! find the stego vector `y` that minimises `sum(rho_i * |y_i - x_i|)` subject to
11//! `H * y = m (mod 2)`, where `H` is the parity-check matrix of a convolutional
12//! code. Minimising a sum of costs under a linear constraint is what makes the
13//! embedder *adaptive*: the changes land where the image can hide them, not
14//! wherever the payload happened to fall.
15//!
16//! The reference is Filler, Judas and Fridrich, "Minimizing Additive Distortion
17//! in Steganography using Syndrome-Trellis Codes", IEEE TIFS 2011.
18//!
19//! # How the matrix is represented
20//!
21//! `H` is never materialised. It is defined implicitly by a running `h`-bit
22//! register — the trellis state — and one `h`-bit column per cover position:
23//!
24//! ```text
25//! register = 0
26//! for each block of positions:
27//!     for each position j in the block:
28//!         if y_j == 1 { register ^= column_j }
29//!     message_bit = register & 1
30//!     register >>= 1
31//! ```
32//!
33//! That loop *is* the syndrome computation, and it is literally what
34//! [`stc_decode_safe`] runs. Encoding is the same loop read backwards: find the
35//! cheapest assignment of `y` that makes the register produce the message.
36//!
37//! The columns are drawn from a ChaCha20 keystream keyed by `stc_seed`, so
38//! sender and receiver build the same matrix from the same secret without any of
39//! it travelling in the container. Two bits of every column are forced on:
40//!
41//! * **Bit 0.** A block whose columns were all even could not change the parity
42//!   the register is about to emit, and the trellis would have no path at all for
43//!   one of the two message bits. Forcing the low bit makes every block solvable
44//!   by construction rather than with overwhelming probability.
45//! * **Bit `h - 1`.** Without it a change would not reach the top of the register
46//!   and the effective constraint height would be shorter than the one asked for.
47//!
48//! Both are properties of the hand-optimised submatrices published with the
49//! reference implementation, arrived at there for the same reasons.
50//!
51//! # Two bounds that are not in the paper
52//!
53//! The published algorithm is `O(n * 2^h)` in time and stores one survivor bit
54//! per position and state, i.e. `n * 2^h` bits. On the four-megapixel containers
55//! this crate insists on, at `h = 10`, that is half a gigabyte of survivor
56//! decisions. Two limits keep the coder inside a working set that a desktop
57//! actually has:
58//!
59//! * `MAX_BLOCK_WIDTH` caps how many cover positions one message bit may be
60//!   spread over. A payload at the [`MAX_BPP`] ceiling needs about fifty-nine
61//!   positions per bit, so the cap never binds there and the whole container is
62//!   used exactly as the design intends; it binds only for payloads well under
63//!   the ceiling, where the surplus positions buy a coding gain far below one
64//!   change per container. The positions that go unused are simply left alone,
65//!   and both sides compute the same count from the cover length and the message
66//!   length, so nothing has to be transmitted.
67//! * `MAX_SEGMENT_COLUMNS` caps how much of the trellis one forward pass keeps
68//!   survivors for. Longer runs are cut into segments, each solved as its own
69//!   trellis with the register reset to zero at the boundary. The decoder resets
70//!   at the same places, so the two agree; the only price is the `h` bits of
71//!   memory the register would otherwise have carried across, once every
72//!   sixty-five thousand positions.
73//!
74//! # What a cover position holds
75//!
76//! One image sample per position — the carrier byte of a pixel, not a bit. The
77//! coder reads its least significant bit as the cover symbol and, when the
78//! trellis asks for a change, writes back `value ± 1` rather than overwriting the
79//! bit. The two are indistinguishable in the payload they carry and very
80//! different in what they leave behind: LSB overwriting pairs each even value
81//! with the odd one above it and never the other way round, which is the
82//! asymmetry RS Analysis and Sample Pair Analysis are built to measure. Adding or
83//! subtracting one, with the direction drawn from the keystream, leaves the
84//! histogram symmetric and those detectors with nothing to read.
85
86use std::fmt;
87
88use chacha20::cipher::{KeyIvInit, StreamCipher};
89use chacha20::ChaCha20;
90use zeroize::{ZeroizeOnDrop, Zeroizing};
91
92/// Bits per pixel the embedder may never exceed.
93///
94/// A hard limit of the design, not a tunable: detection accuracy against the
95/// modern rich-model detectors climbs steeply with payload rate, and everything
96/// this crate does — the HILL costs, the trellis, the permutation — buys
97/// invisibility only in the low-rate regime. Exposing this as a runtime
98/// parameter would let a caller trade away, in one argument, the property the
99/// whole system exists to provide.
100pub const MAX_BPP: f32 = 0.02;
101
102/// Constraint height of the trellis used unless a caller overrides it.
103///
104/// Embedding efficiency grows with the height and so does the cost of the
105/// Viterbi pass, which is `O(2^h)` per position; ten is the value the published
106/// measurements use to sit near the rate-distortion bound without becoming
107/// impractical.
108pub const DEFAULT_TRELLIS_HEIGHT: u32 = 10;
109
110/// Shortest constraint height the coder will run.
111///
112/// At `h = 1` the register has no memory beyond the block it is in and the code
113/// degenerates into plain parity embedding, which is not what any caller asking
114/// for a trellis wants.
115const MIN_TRELLIS_HEIGHT: u32 = 2;
116
117/// Tallest constraint height the coder will run.
118///
119/// The state space doubles with every unit, so twenty already means a million
120/// states per position. The limit is what keeps a mis-set height a clean error
121/// instead of an allocation nobody can serve.
122const MAX_TRELLIS_HEIGHT: u32 = 20;
123
124/// Nonce of the keystream that generates the parity-check columns.
125///
126/// Distinct from the zero nonce [`crate::stego::permute`] uses. Both keystreams
127/// run under the same seed, and drawing the matrix from the same stream as the
128/// permutation would tie the two together for anyone who recovered either.
129const H_MATRIX_NONCE: [u8; 12] = [1u8; 12];
130
131/// Nonce of the keystream that chooses the direction of each change.
132///
133/// A third stream, for the same reason the second one exists.
134const SIGN_NONCE: [u8; 12] = [2u8; 12];
135
136/// Cover positions the trellis will spend on one message bit, at most.
137///
138/// See the module documentation: the cap is above the width a full-capacity
139/// payload asks for, so it costs nothing at the rate the system is designed
140/// around and bounds the work for everything below it.
141const MAX_BLOCK_WIDTH: usize = 64;
142
143/// Cover positions one forward pass keeps survivor decisions for, at most.
144///
145/// At `h = 10` this is eight mebibytes of survivors per segment, which is the
146/// figure the segmentation exists to hold down.
147const MAX_SEGMENT_COLUMNS: usize = 1 << 16;
148
149/// Bytes of ChaCha20 keystream produced per refill.
150const KEYSTREAM_BUFFER_BYTES: usize = 512;
151
152/// Bits in a byte, named where the conversion happens.
153const BITS_PER_BYTE: usize = 8;
154
155/// Every way the Syndrome-Trellis Codes layer can refuse to run.
156#[derive(Debug)]
157pub enum StcError {
158    /// The cover vector and the cost vector describe different position counts.
159    LengthMismatch {
160        /// Number of cover positions supplied.
161        pixels: usize,
162        /// Number of costs supplied.
163        costs: usize,
164    },
165    /// The payload asks for more bits than `max_bpp` allows over this cover.
166    PayloadExceedsCapacity {
167        /// Bits the payload needs.
168        payload_bits: usize,
169        /// Bits the cover may carry under the `max_bpp` hard limit.
170        capacity_bits: usize,
171    },
172    /// A cost was negative, infinite or not a number.
173    InvalidCostMap,
174    /// The trellis could not be built or could not be solved.
175    EncodingError(String),
176    /// The syndrome could not be computed for the parameters given.
177    DecodingError(String),
178}
179
180impl fmt::Display for StcError {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        match self {
183            StcError::LengthMismatch { pixels, costs } => write!(
184                f,
185                "cover and cost vectors disagree: {pixels} positions against {costs} costs"
186            ),
187            StcError::PayloadExceedsCapacity {
188                payload_bits,
189                capacity_bits,
190            } => write!(
191                f,
192                "payload of {payload_bits} bits exceeds the {capacity_bits} bits this cover may \
193                 carry"
194            ),
195            StcError::InvalidCostMap => write!(
196                f,
197                "the cost map contains a negative or non-finite value and cannot be used"
198            ),
199            StcError::EncodingError(message) => write!(f, "the stc coder failed: {message}"),
200            StcError::DecodingError(message) => write!(f, "the stc decoder failed: {message}"),
201        }
202    }
203}
204
205impl std::error::Error for StcError {}
206
207/// Parameters of one Syndrome-Trellis Codes operation.
208///
209/// Holds key material — the permutation seed — and is therefore wiped on drop.
210/// Encode and decode must be given identical configurations: the trellis height
211/// is part of the code definition, so a decode at a different height reads a
212/// different syndrome and recovers nothing.
213#[derive(ZeroizeOnDrop)]
214pub struct StcConfig {
215    /// Seed of the embedding permutation, derived by HKDF from the master key.
216    pub(crate) stc_seed: [u8; 32],
217    /// Constraint height of the trellis.
218    pub(crate) trellis_height: u32,
219    /// Bits per pixel ceiling. Always [`MAX_BPP`]; see the constant.
220    pub(crate) max_bpp: f32,
221}
222
223impl StcConfig {
224    /// Builds a configuration around `stc_seed`, with the defaults of this
225    /// crate.
226    ///
227    /// There is no constructor that takes a `max_bpp`. The field exists so the
228    /// limit can be read, not chosen.
229    pub fn new(stc_seed: [u8; 32]) -> Self {
230        Self {
231            stc_seed,
232            trellis_height: DEFAULT_TRELLIS_HEIGHT,
233            max_bpp: MAX_BPP,
234        }
235    }
236
237    /// Borrows the permutation seed.
238    pub fn stc_seed(&self) -> &[u8; 32] {
239        &self.stc_seed
240    }
241
242    /// Constraint height of the trellis.
243    pub fn trellis_height(&self) -> u32 {
244        self.trellis_height
245    }
246
247    /// The bits-per-pixel ceiling in force. Always [`MAX_BPP`].
248    pub fn max_bpp(&self) -> f32 {
249        self.max_bpp
250    }
251
252    /// Bits `positions` cover elements may carry under the ceiling.
253    ///
254    /// Truncating towards zero, so the limit is never rounded up into a payload
255    /// that the ceiling does not actually allow. The arithmetic is done in
256    /// `f32` deliberately: [`crate::stego::sizer`] advertises capacity with the
257    /// same expression, and a wider intermediate type here would let a payload
258    /// pass the sizer and then fail this check.
259    pub fn capacity_bits(&self, positions: usize) -> usize {
260        (positions as f32 * self.max_bpp) as usize
261    }
262}
263
264impl fmt::Debug for StcConfig {
265    /// Prints the parameters of the configuration, never the seed.
266    ///
267    /// Written by hand rather than derived: a derived implementation would put
268    /// key material into every log line and error report that formats a config.
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        f.debug_struct("StcConfig")
271            .field("stc_seed", &"[redacted]")
272            .field("trellis_height", &self.trellis_height)
273            .field("max_bpp", &self.max_bpp)
274            .finish()
275    }
276}
277
278/// Embeds `payload` into the carrier bits of `pixels`, in place.
279///
280/// `pixels` holds one image sample per embedding position — the carrier byte of
281/// each selected pixel — and `cost` holds the price of changing each of them, in
282/// the same order. On success the samples the trellis chose have been moved by
283/// exactly one level, up or down, so that their least significant bits spell the
284/// stego vector; the return value is how many of them moved.
285///
286/// # Errors
287///
288/// Returns [`StcError::LengthMismatch`] when the two vectors disagree in length,
289/// [`StcError::PayloadExceedsCapacity`] when the payload needs more bits than
290/// [`MAX_BPP`] allows, [`StcError::InvalidCostMap`] when a cost is negative or
291/// not finite, and [`StcError::EncodingError`] when the configured trellis height
292/// is out of range or the cover is too short to carry the message. Every check
293/// runs before a single sample is touched, so a rejected input leaves `pixels`
294/// exactly as it was.
295pub fn stc_encode_safe(
296    pixels: &mut [u8],
297    cost: &[f32],
298    payload: &[u8],
299    config: &StcConfig,
300) -> Result<usize, StcError> {
301    // Pre-condition (a) — the two vectors must describe the same positions.
302    if pixels.len() != cost.len() {
303        return Err(StcError::LengthMismatch {
304            pixels: pixels.len(),
305            costs: cost.len(),
306        });
307    }
308
309    // Pre-condition (b) — the payload must fit under the bits-per-pixel ceiling.
310    let payload_bits = payload.len().saturating_mul(BITS_PER_BYTE);
311    let capacity_bits = config.capacity_bits(pixels.len());
312    if payload_bits > capacity_bits {
313        return Err(StcError::PayloadExceedsCapacity {
314            payload_bits,
315            capacity_bits,
316        });
317    }
318
319    // Pre-condition (c) — a NaN would poison the path metric and make every
320    // comparison in the Viterbi pass false, so the trellis would pick a path
321    // arbitrarily rather than cheaply.
322    if !cost.iter().all(|&value| value.is_finite() && value >= 0.0) {
323        return Err(StcError::InvalidCostMap);
324    }
325
326    // Nothing to embed. Returned before the layout is planned so that the
327    // division by the message length below never sees a zero.
328    if payload_bits == 0 {
329        return Ok(0);
330    }
331
332    let Some(height) = validated_height(config.trellis_height) else {
333        return Err(StcError::EncodingError(format!(
334            "a constraint height of {} is outside the supported range {MIN_TRELLIS_HEIGHT}..={MAX_TRELLIS_HEIGHT}",
335            config.trellis_height
336        )));
337    };
338
339    let Some(layout) = TrellisLayout::plan(pixels.len(), payload_bits) else {
340        return Err(StcError::EncodingError(format!(
341            "a cover of {} positions cannot carry {payload_bits} message bits",
342            pixels.len()
343        )));
344    };
345
346    // Wiped on drop: the unpacked message is the payload, one bit per byte.
347    let message = unpack_bits(payload);
348    let columns = parity_columns(&config.stc_seed, height, layout.used_positions);
349
350    let stego_bits = solve_trellis(pixels, cost, &message, &columns, &layout, height)?;
351
352    Ok(apply_changes(pixels, &stego_bits, &config.stc_seed))
353}
354
355/// Recovers `payload_len_bits` payload bits from the stego samples in `pixels`.
356///
357/// The extraction path needs no cost map: the syndrome is a function of the
358/// stego vector alone, which is exactly what makes it recoverable by a receiver
359/// who never saw the cover. Only the least significant bit of each sample is
360/// read, so a container whose other bits were touched decodes just the same.
361///
362/// The returned vector is the repacked payload, `ceil(payload_len_bits / 8)`
363/// bytes long, with any padding bits of the final byte left zero. It is
364/// plaintext-adjacent material and **must** be wrapped in
365/// [`zeroize::Zeroizing`] by the caller — this function cannot do it on the
366/// caller's behalf without dictating the return type of the whole pipeline.
367///
368/// # Errors
369///
370/// Returns [`StcError::PayloadExceedsCapacity`] when more bits are requested
371/// than [`MAX_BPP`] allows over this cover, and [`StcError::DecodingError`] when
372/// the configured trellis height is out of range or the cover is too short for
373/// the bit count requested.
374pub fn stc_decode_safe(
375    pixels: &[u8],
376    payload_len_bits: usize,
377    config: &StcConfig,
378) -> Result<Vec<u8>, StcError> {
379    let capacity_bits = config.capacity_bits(pixels.len());
380    if payload_len_bits > capacity_bits {
381        return Err(StcError::PayloadExceedsCapacity {
382            payload_bits: payload_len_bits,
383            capacity_bits,
384        });
385    }
386
387    // No bits requested, nothing to read.
388    if payload_len_bits == 0 {
389        return Ok(Vec::new());
390    }
391
392    let Some(height) = validated_height(config.trellis_height) else {
393        return Err(StcError::DecodingError(format!(
394            "a constraint height of {} is outside the supported range {MIN_TRELLIS_HEIGHT}..={MAX_TRELLIS_HEIGHT}",
395            config.trellis_height
396        )));
397    };
398
399    let Some(layout) = TrellisLayout::plan(pixels.len(), payload_len_bits) else {
400        return Err(StcError::DecodingError(format!(
401            "a cover of {} positions cannot hold {payload_len_bits} message bits",
402            pixels.len()
403        )));
404    };
405
406    let columns = parity_columns(&config.stc_seed, height, layout.used_positions);
407
408    // Wiped on drop: these are the payload bits themselves.
409    let mut message = Zeroizing::new(vec![0u8; payload_len_bits]);
410
411    let mut register = 0u32;
412    let mut position = 0usize;
413
414    for block in 0..layout.message_bits {
415        // The same reset the encoder performs; see [`TrellisLayout`].
416        if block % layout.blocks_per_segment == 0 {
417            register = 0;
418        }
419
420        let block_end = layout.block_start(block + 1);
421        while position < block_end {
422            if pixels.get(position).is_some_and(|sample| sample & 1 == 1) {
423                register ^= columns.get(position).copied().unwrap_or(0);
424            }
425            position += 1;
426        }
427
428        if let Some(slot) = message.get_mut(block) {
429            *slot = (register & 1) as u8;
430        }
431        register >>= 1;
432    }
433
434    Ok(pack_bits(&message))
435}
436
437/// How one trellis run cuts the cover into blocks and segments.
438///
439/// Every field is a function of the cover length and the message length alone,
440/// which is what lets the decoder rebuild the same layout without being told
441/// anything.
442#[derive(Debug, Clone, Copy)]
443struct TrellisLayout {
444    /// Bits of message, i.e. blocks in the trellis.
445    message_bits: usize,
446    /// Cover positions the trellis will actually visit, counted from the start.
447    ///
448    /// Positions beyond this are left untouched by the encoder and ignored by
449    /// the decoder. See [`MAX_BLOCK_WIDTH`] for why there can be any.
450    used_positions: usize,
451    /// Blocks solved as one trellis before the register is reset to zero.
452    blocks_per_segment: usize,
453}
454
455impl TrellisLayout {
456    /// Plans a run of `message_bits` bits over `positions` cover elements.
457    ///
458    /// Returns `None` when there is no message to carry or the cover is shorter
459    /// than the message, which are the two cases where no block layout exists.
460    fn plan(positions: usize, message_bits: usize) -> Option<Self> {
461        if message_bits == 0 {
462            return None;
463        }
464
465        let used_positions = positions.min(message_bits.saturating_mul(MAX_BLOCK_WIDTH));
466        if used_positions < message_bits {
467            return None;
468        }
469
470        // At least one, by the check above. Every block is therefore at least one
471        // position wide, which is what keeps the syndrome of each block settable.
472        let average_width = (used_positions / message_bits).max(1);
473        let blocks_per_segment = (MAX_SEGMENT_COLUMNS / average_width).max(1);
474
475        Some(Self {
476            message_bits,
477            used_positions,
478            blocks_per_segment,
479        })
480    }
481
482    /// First cover position of block `index`, for `index` in `0..=message_bits`.
483    ///
484    /// Spreading the remainder of `used_positions / message_bits` across the
485    /// blocks rather than dropping it means every position between zero and
486    /// `used_positions` belongs to exactly one block, and the widths differ by at
487    /// most one. Computed in `u128` so that the product cannot wrap on a cover
488    /// large enough to matter.
489    fn block_start(&self, index: usize) -> usize {
490        let numerator = index as u128 * self.used_positions as u128;
491
492        (numerator / self.message_bits as u128) as usize
493    }
494}
495
496/// Narrows a configured height to one the coder will run.
497fn validated_height(height: u32) -> Option<u32> {
498    (MIN_TRELLIS_HEIGHT..=MAX_TRELLIS_HEIGHT)
499        .contains(&height)
500        .then_some(height)
501}
502
503/// Draws `count` parity-check columns from the keystream of `seed`.
504///
505/// Each column is `height` bits wide with bit 0 and bit `height - 1` forced on;
506/// see the module documentation for what each of the two guarantees. A raw draw
507/// of zero is discarded and redrawn, so the rejection the specification asks for
508/// happens on the value the keystream actually produced rather than on the
509/// value after the mask has already made it non-zero.
510fn parity_columns(seed: &[u8; 32], height: u32, count: usize) -> Vec<u32> {
511    let mask = (1u32 << height) - 1;
512    let forced = 1u32 | (1u32 << (height - 1));
513
514    let mut keystream = Keystream::new(seed, &H_MATRIX_NONCE);
515    let mut columns = Vec::with_capacity(count);
516
517    for _ in 0..count {
518        let draw = loop {
519            let value = keystream.next_u32() & mask;
520            if value != 0 {
521                break value;
522            }
523        };
524
525        columns.push(draw | forced);
526    }
527
528    columns
529}
530
531/// Finds the cheapest stego vector whose syndrome is `message`.
532///
533/// Returns one stego bit per used cover position, in cover order. The vector is
534/// wiped on drop: read against the cover it names exactly which positions the
535/// trellis wants changed, which is the position list an attacker would otherwise
536/// have to solve for.
537///
538/// # Errors
539///
540/// Returns [`StcError::EncodingError`] if a segment of the trellis admits no
541/// path at all. Every column carries bit 0, so every block can flip the parity it
542/// is about to emit and this cannot happen; the arm is what keeps a future change
543/// to the column generator from silently producing garbage.
544fn solve_trellis(
545    pixels: &[u8],
546    cost: &[f32],
547    message: &[u8],
548    columns: &[u32],
549    layout: &TrellisLayout,
550    height: u32,
551) -> Result<Zeroizing<Vec<u8>>, StcError> {
552    let states = 1usize << height;
553    let half_states = states / 2;
554
555    let mut stego_bits = Zeroizing::new(vec![0u8; layout.used_positions]);
556
557    // The path metric, double-buffered: the transition at one position reads
558    // every state of the previous column while writing every state of this one,
559    // so the two cannot be the same allocation.
560    let mut current = vec![f32::INFINITY; states];
561    let mut next = vec![f32::INFINITY; states];
562
563    let mut first_block = 0usize;
564    while first_block < layout.message_bits {
565        let last_block = (first_block + layout.blocks_per_segment).min(layout.message_bits);
566        let segment_start = layout.block_start(first_block);
567        let segment_end = layout.block_start(last_block);
568
569        // One survivor bit per (position, state) of this segment: whether the
570        // cheapest way to reach that state at that position arrived by setting
571        // the stego bit. This is the only thing the backward pass needs, and the
572        // reason the segmentation exists at all.
573        let mut survivors = Zeroizing::new(vec![
574            0u8;
575            (segment_end - segment_start)
576                .saturating_mul(states)
577                .div_ceil(BITS_PER_BYTE)
578        ]);
579
580        // The register starts every segment at zero, which is where the decoder
581        // will start reading it.
582        current
583            .iter_mut()
584            .for_each(|weight| *weight = f32::INFINITY);
585        if let Some(origin) = current.get_mut(0) {
586            *origin = 0.0;
587        }
588
589        // Forward pass, left to right.
590        let mut position = segment_start;
591        for block in first_block..last_block {
592            let block_end = layout.block_start(block + 1);
593
594            while position < block_end {
595                let column = columns.get(position).copied().unwrap_or(1) as usize;
596                let cover_bit = pixels.get(position).copied().unwrap_or(0) & 1;
597                let rho = cost.get(position).copied().unwrap_or(0.0);
598
599                // What each choice of stego bit costs at this position: nothing
600                // if it agrees with the cover, the price of the change if not.
601                let keep = if cover_bit == 0 { 0.0 } else { rho };
602                let flip = if cover_bit == 0 { rho } else { 0.0 };
603
604                let base = (position - segment_start) * states;
605
606                for (state, slot) in next.iter_mut().enumerate() {
607                    // Reaching `state` with a stego bit of zero leaves the
608                    // register alone; with a one it came from `state ^ column`.
609                    let stay = current.get(state).copied().unwrap_or(f32::INFINITY) + keep;
610                    let cross = current
611                        .get(state ^ column)
612                        .copied()
613                        .unwrap_or(f32::INFINITY)
614                        + flip;
615
616                    if cross < stay {
617                        set_survivor(&mut survivors, base + state);
618                        *slot = cross;
619                    } else {
620                        *slot = stay;
621                    }
622                }
623
624                std::mem::swap(&mut current, &mut next);
625                position += 1;
626            }
627
628            // The block is closed: only the states whose low bit is the message
629            // bit survive, and the register shifts that bit out. Ascending order
630            // is safe because `folded` is never greater than `2 * folded + bit`.
631            let bit = usize::from(message.get(block).copied().unwrap_or(0) & 1);
632            for folded in 0..half_states {
633                let survivor = current
634                    .get(2 * folded + bit)
635                    .copied()
636                    .unwrap_or(f32::INFINITY);
637
638                if let Some(slot) = current.get_mut(folded) {
639                    *slot = survivor;
640                }
641            }
642            for slot in current.iter_mut().skip(half_states) {
643                *slot = f32::INFINITY;
644            }
645        }
646
647        // The trellis is not terminated: the register is free to end anywhere,
648        // because the decoder discards whatever is left in it. The cheapest end
649        // state is therefore simply the cheapest path.
650        let (mut state, best) = current.iter().enumerate().fold(
651            (0usize, f32::INFINITY),
652            |(best_state, best_cost), (state, &weight)| {
653                if weight < best_cost {
654                    (state, weight)
655                } else {
656                    (best_state, best_cost)
657                }
658            },
659        );
660
661        if !best.is_finite() {
662            return Err(StcError::EncodingError(
663                "the trellis admits no path that satisfies the requested syndrome".to_owned(),
664            ));
665        }
666
667        // Backward pass, right to left, replaying the survivors.
668        let mut position = segment_end;
669        for block in (first_block..last_block).rev() {
670            // Undo the shift the block's closure performed.
671            let bit = usize::from(message.get(block).copied().unwrap_or(0) & 1);
672            state = state * 2 + bit;
673
674            let block_start = layout.block_start(block);
675            while position > block_start {
676                position -= 1;
677
678                let column = columns.get(position).copied().unwrap_or(1) as usize;
679                let base = (position - segment_start) * states;
680
681                if survivor(&survivors, base + state) {
682                    if let Some(slot) = stego_bits.get_mut(position) {
683                        *slot = 1;
684                    }
685                    state ^= column;
686                }
687            }
688        }
689
690        first_block = last_block;
691    }
692
693    Ok(stego_bits)
694}
695
696/// Moves every sample whose carrier bit disagrees with `stego_bits` by one
697/// level, and reports how many moved.
698///
699/// The direction is the `±1` operator of the module documentation: forced
700/// upwards at zero and downwards at the maximum, and otherwise drawn from the
701/// keystream. Either direction flips the least significant bit, so the choice is
702/// free to be made on grounds of detectability alone.
703fn apply_changes(pixels: &mut [u8], stego_bits: &[u8], seed: &[u8; 32]) -> usize {
704    let mut signs = Keystream::new(seed, &SIGN_NONCE);
705    let mut changed = 0usize;
706
707    for (position, &target) in stego_bits.iter().enumerate() {
708        let Some(sample) = pixels.get_mut(position) else {
709            break;
710        };
711
712        if *sample & 1 == target & 1 {
713            continue;
714        }
715
716        // Saturating only in name: the two boundary values are handled by their
717        // own arms, so neither operation below can reach the end of the range.
718        *sample = match *sample {
719            0 => 1,
720            u8::MAX => u8::MAX - 1,
721            value if signs.next_bit() == 1 => value.saturating_add(1),
722            value => value.saturating_sub(1),
723        };
724
725        changed += 1;
726    }
727
728    changed
729}
730
731/// Records that the cheapest path into a state set the stego bit.
732fn set_survivor(survivors: &mut [u8], index: usize) {
733    if let Some(byte) = survivors.get_mut(index / BITS_PER_BYTE) {
734        *byte |= 1 << (index % BITS_PER_BYTE);
735    }
736}
737
738/// Reads back what [`set_survivor`] recorded.
739fn survivor(survivors: &[u8], index: usize) -> bool {
740    survivors
741        .get(index / BITS_PER_BYTE)
742        .is_some_and(|byte| byte & (1 << (index % BITS_PER_BYTE)) != 0)
743}
744
745/// Expands packed bytes into one binary symbol per byte, most significant bit
746/// first.
747///
748/// Wiped on drop: the expansion is a copy of the payload.
749fn unpack_bits(packed: &[u8]) -> Zeroizing<Vec<u8>> {
750    let mut bits = Zeroizing::new(Vec::with_capacity(
751        packed.len().saturating_mul(BITS_PER_BYTE),
752    ));
753
754    for byte in packed {
755        for shift in (0..BITS_PER_BYTE).rev() {
756            bits.push((byte >> shift) & 1);
757        }
758    }
759
760    bits
761}
762
763/// Packs binary symbols back into bytes, most significant bit first.
764///
765/// The inverse of [`unpack_bits`] for whole bytes. A count that is not a
766/// multiple of eight leaves the unused low bits of the final byte at zero,
767/// which is why the bit count travels with the payload rather than being
768/// inferred from its length.
769fn pack_bits(bits: &[u8]) -> Vec<u8> {
770    let mut packed = vec![0u8; bits.len().div_ceil(BITS_PER_BYTE)];
771
772    for (index, bit) in bits.iter().enumerate() {
773        if bit & 1 == 1 {
774            if let Some(byte) = packed.get_mut(index / BITS_PER_BYTE) {
775                *byte |= 1 << (BITS_PER_BYTE - 1 - index % BITS_PER_BYTE);
776            }
777        }
778    }
779
780    packed
781}
782
783/// Buffered reader over the ChaCha20 keystream of one coder run.
784///
785/// Wiped on drop: the keystream is a direct function of the STC seed, so a copy
786/// of it left in freed memory is as good as a copy of the seed for anyone who
787/// wants to rebuild the parity-check matrix.
788#[derive(ZeroizeOnDrop)]
789struct Keystream {
790    /// The cipher itself. Skipped by the derive because it does not implement
791    /// `Zeroize`; the `zeroize` feature of the `chacha20` crate — enabled in the
792    /// workspace manifest — already makes it wipe its own state on drop.
793    #[zeroize(skip)]
794    cipher: ChaCha20,
795    /// Keystream bytes produced by the last refill.
796    buffer: [u8; KEYSTREAM_BUFFER_BYTES],
797    /// Offset of the next unread byte in [`Keystream::buffer`].
798    cursor: usize,
799    /// Byte currently being handed out one bit at a time.
800    reservoir: u8,
801    /// Bits already taken out of [`Keystream::reservoir`].
802    taken: u8,
803}
804
805impl Keystream {
806    /// Starts a keystream under `seed` and `nonce`.
807    ///
808    /// Both cursors start spent, so the first read of either kind refills rather
809    /// than returning the zeros the reader was constructed with.
810    fn new(seed: &[u8; 32], nonce: &[u8; 12]) -> Self {
811        Self {
812            cipher: ChaCha20::new(seed.into(), nonce.into()),
813            buffer: [0u8; KEYSTREAM_BUFFER_BYTES],
814            cursor: KEYSTREAM_BUFFER_BYTES,
815            reservoir: 0,
816            taken: u8::try_from(BITS_PER_BYTE).unwrap_or(8),
817        }
818    }
819
820    /// The next keystream byte.
821    fn next_byte(&mut self) -> u8 {
822        if self.cursor >= self.buffer.len() {
823            self.refill();
824        }
825
826        let byte = self.buffer.get(self.cursor).copied().unwrap_or(0);
827        self.cursor += 1;
828
829        byte
830    }
831
832    /// The next four keystream bytes, interpreted as a little-endian `u32`.
833    fn next_u32(&mut self) -> u32 {
834        u32::from_le_bytes([
835            self.next_byte(),
836            self.next_byte(),
837            self.next_byte(),
838            self.next_byte(),
839        ])
840    }
841
842    /// The next keystream bit, least significant bit of each byte first.
843    ///
844    /// Kept on a separate reservoir from [`Keystream::next_byte`] so that a
845    /// reader used for both would still consume whole bytes in order; no reader
846    /// in this module mixes the two.
847    fn next_bit(&mut self) -> u8 {
848        if usize::from(self.taken) >= BITS_PER_BYTE {
849            self.reservoir = self.next_byte();
850            self.taken = 0;
851        }
852
853        let bit = (self.reservoir >> self.taken) & 1;
854        self.taken += 1;
855
856        bit
857    }
858
859    /// Advances the cipher by one buffer's worth of keystream.
860    ///
861    /// The buffer is cleared first because `apply_keystream` XORs into its
862    /// argument: XOR against zero is the keystream itself, XOR against the
863    /// previous contents would be noise.
864    fn refill(&mut self) {
865        self.buffer = [0u8; KEYSTREAM_BUFFER_BYTES];
866        self.cipher.apply_keystream(&mut self.buffer);
867        self.cursor = 0;
868    }
869}
870
871#[cfg(test)]
872mod tests {
873    // The crate-wide `deny(clippy::panic)` reaches into `cfg(test)` code as
874    // well. A test that cannot panic cannot fail, so the ban is lifted here and
875    // only here — every `panic!` below reports a `Result` this module produced
876    // and the specification requires to be `Ok`, which is the one thing an
877    // `assert!` cannot express without an `unwrap`.
878    #![allow(clippy::panic)]
879
880    use super::*;
881
882    use rand::rngs::StdRng;
883    use rand::{RngExt, SeedableRng};
884
885    /// The seed every test derives its configuration from.
886    const SEED: [u8; 32] = [0x5Au8; 32];
887
888    /// Builds a deterministic cover of `len` samples with the full byte range
889    /// represented, so the boundary arms of the `±1` operator are exercised.
890    fn cover(len: usize, seed: u64) -> Vec<u8> {
891        let mut rng = StdRng::seed_from_u64(seed);
892
893        (0..len).map(|_| rng.random()).collect()
894    }
895
896    /// TEST 1 — a payload survives the trellis and comes back out.
897    #[test]
898    fn round_trip_recovers_the_payload() {
899        let mut pixels = cover(10_000, 1);
900        let costs = vec![1.0f32; pixels.len()];
901        let payload = b"test";
902        let config = StcConfig::new(SEED);
903
904        let changed = match stc_encode_safe(&mut pixels, &costs, payload, &config) {
905            Ok(changed) => changed,
906            Err(error) => panic!("embedding into a uniform cover must succeed: {error}"),
907        };
908
909        assert!(
910            changed > 0,
911            "a four-byte payload cannot be carried by a cover nothing was changed in"
912        );
913
914        let recovered = match stc_decode_safe(&pixels, payload.len() * 8, &config) {
915            Ok(recovered) => recovered,
916            Err(error) => panic!("decoding what was just embedded must succeed: {error}"),
917        };
918
919        assert_eq!(recovered.as_slice(), payload.as_slice());
920    }
921
922    /// TEST 1b — the same, over a payload long enough to span several blocks per
923    /// segment and to land at the capacity ceiling rather than well under it.
924    ///
925    /// The short round trip above never makes [`MAX_BLOCK_WIDTH`] bind from the
926    /// other side; this one runs at the width a full container actually uses.
927    #[test]
928    fn round_trip_recovers_a_payload_at_the_capacity_ceiling() {
929        let mut pixels = cover(40_000, 2);
930        let costs = vec![1.0f32; pixels.len()];
931        let payload: Vec<u8> = (0..90u16).map(|value| (value % 251) as u8).collect();
932        let config = StcConfig::new(SEED);
933
934        if let Err(error) = stc_encode_safe(&mut pixels, &costs, &payload, &config) {
935            panic!("a payload at the ceiling must still embed: {error}");
936        }
937
938        let recovered = match stc_decode_safe(&pixels, payload.len() * 8, &config) {
939            Ok(recovered) => recovered,
940            Err(error) => panic!("decoding what was just embedded must succeed: {error}"),
941        };
942
943        assert_eq!(recovered, payload);
944    }
945
946    /// TEST 2 — the same seed, cover and payload always give the same stego
947    /// image.
948    ///
949    /// Not a convenience. The receiver rebuilds the parity-check matrix from the
950    /// seed alone, so a coder that drew anything from a non-reproducible source
951    /// would embed a payload nobody could read back.
952    #[test]
953    fn encoding_is_deterministic() {
954        let original = cover(10_000, 3);
955        let costs = vec![1.0f32; original.len()];
956        let payload = b"determinism";
957
958        let mut first = original.clone();
959        let mut second = original.clone();
960
961        let changes_first = stc_encode_safe(&mut first, &costs, payload, &StcConfig::new(SEED));
962        let changes_second = stc_encode_safe(&mut second, &costs, payload, &StcConfig::new(SEED));
963
964        match (changes_first, changes_second) {
965            (Ok(first_count), Ok(second_count)) => assert_eq!(first_count, second_count),
966            (first_result, second_result) => {
967                panic!("both runs must succeed: {first_result:?} and {second_result:?}")
968            }
969        }
970
971        assert_eq!(first, second);
972        assert_ne!(first, original, "something must have been embedded");
973    }
974
975    /// TEST 3 — the Viterbi pass spends the cheap positions and spares the
976    /// expensive ones.
977    ///
978    /// The cost pattern alternates position by position rather than splitting the
979    /// cover into an expensive half and a cheap one. That is not a softening of
980    /// the test but a property of the code being tested: the parity-check matrix
981    /// is banded, so each message bit is satisfied by changes inside its own
982    /// block of positions and cannot be paid for a million positions away. A
983    /// half-and-half map would measure the block layout, not the cost model. An
984    /// alternating map puts both price levels inside every block, which is
985    /// exactly where the trellis is free to choose — and where a Viterbi pass
986    /// that ignored `rho` would show up immediately.
987    #[test]
988    fn changes_follow_the_cheap_positions() {
989        let mut pixels = cover(20_000, 4);
990        let original = pixels.clone();
991
992        let costs: Vec<f32> = (0..pixels.len())
993            .map(|index| if index % 2 == 0 { 1000.0 } else { 0.001 })
994            .collect();
995
996        let payload: Vec<u8> = (0..40u8).collect();
997        let config = StcConfig::new(SEED);
998
999        if let Err(error) = stc_encode_safe(&mut pixels, &costs, &payload, &config) {
1000            panic!("embedding must succeed before its choices can be judged: {error}");
1001        }
1002
1003        let (expensive, cheap) = original
1004            .iter()
1005            .zip(pixels.iter())
1006            .enumerate()
1007            .filter(|(_, (before, after))| before != after)
1008            .fold((0usize, 0usize), |(expensive, cheap), (index, _)| {
1009                if index % 2 == 0 {
1010                    (expensive + 1, cheap)
1011                } else {
1012                    (expensive, cheap + 1)
1013                }
1014            });
1015
1016        assert!(cheap > 0, "the payload must have cost something to embed");
1017        assert!(
1018            expensive * 10 <= cheap,
1019            "the trellis ignored the cost map: {expensive} changes at cost 1000.0 against {cheap} \
1020             at cost 0.001"
1021        );
1022    }
1023
1024    /// TEST 3b — the `±1` operator never overwrites, and never leaves the range.
1025    ///
1026    /// Every changed sample must differ from its cover value by exactly one, in
1027    /// either direction, and both directions must actually occur: a coder that
1028    /// always added would reintroduce the very pairing the operator exists to
1029    /// break.
1030    #[test]
1031    fn changes_move_samples_by_exactly_one_level_in_both_directions() {
1032        let mut pixels = cover(20_000, 5);
1033        let original = pixels.clone();
1034        let costs = vec![1.0f32; pixels.len()];
1035        let payload: Vec<u8> = (0..40u8).map(|value| value.wrapping_mul(37)).collect();
1036
1037        if let Err(error) = stc_encode_safe(&mut pixels, &costs, &payload, &StcConfig::new(SEED)) {
1038            panic!("embedding must succeed: {error}");
1039        }
1040
1041        let mut up = 0usize;
1042        let mut down = 0usize;
1043
1044        for (before, after) in original.iter().zip(pixels.iter()) {
1045            match (i16::from(*after)) - (i16::from(*before)) {
1046                0 => {}
1047                1 => up += 1,
1048                -1 => down += 1,
1049                other => panic!("a sample moved by {other} levels, which is not a ±1 change"),
1050            }
1051        }
1052
1053        assert!(
1054            up > 0 && down > 0,
1055            "{up} increments against {down} decrements"
1056        );
1057    }
1058
1059    /// TEST 3c — samples sitting at the ends of the range move inwards.
1060    ///
1061    /// The one place the `±1` operator has no choice: a sample at `0` can only
1062    /// go up and one at `255` can only go down, whatever the keystream says.
1063    /// Getting it wrong would wrap the sample to the other end of the range — a
1064    /// change of 255 levels in a scheme whose whole premise is changes of one —
1065    /// so the cover here is built entirely out of those two values.
1066    #[test]
1067    fn samples_at_the_ends_of_the_range_move_inwards() {
1068        let original: Vec<u8> = (0..20_000)
1069            .map(|index| if index % 2 == 0 { 0 } else { u8::MAX })
1070            .collect();
1071        let mut pixels = original.clone();
1072        let costs = vec![1.0f32; pixels.len()];
1073        let payload: Vec<u8> = (0..40u8).map(|value| value.wrapping_mul(53)).collect();
1074        let config = StcConfig::new(SEED);
1075
1076        if let Err(error) = stc_encode_safe(&mut pixels, &costs, &payload, &config) {
1077            panic!("a cover at the ends of the range must still embed: {error}");
1078        }
1079
1080        let mut raised = 0usize;
1081        let mut lowered = 0usize;
1082
1083        for (before, after) in original.iter().zip(pixels.iter()) {
1084            match (*before, *after) {
1085                (0, 0) | (u8::MAX, u8::MAX) => {}
1086                (0, 1) => raised += 1,
1087                (u8::MAX, 254) => lowered += 1,
1088                (before, after) => {
1089                    panic!("a sample moved from {before} to {after}, which is not an inward ±1")
1090                }
1091            }
1092        }
1093
1094        assert!(
1095            raised > 0 && lowered > 0,
1096            "{raised} raised from zero against {lowered} lowered from the maximum"
1097        );
1098
1099        match stc_decode_safe(&pixels, payload.len() * 8, &config) {
1100            Ok(recovered) => assert_eq!(recovered, payload),
1101            Err(error) => panic!("the payload must still come back out: {error}"),
1102        }
1103    }
1104
1105    /// The configuration exposes its parameters and hides its key material.
1106    #[test]
1107    fn the_configuration_reads_but_never_prints_its_seed() {
1108        let config = StcConfig::new(SEED);
1109
1110        assert_eq!(config.stc_seed(), &SEED);
1111        assert_eq!(config.trellis_height(), DEFAULT_TRELLIS_HEIGHT);
1112        assert_eq!(config.max_bpp(), MAX_BPP);
1113
1114        // Truncating towards zero: a capacity rounded up would be a payload the
1115        // ceiling does not actually allow.
1116        assert_eq!(config.capacity_bits(1_000), 20);
1117        assert_eq!(config.capacity_bits(49), 0);
1118
1119        let rendered = format!("{config:?}");
1120        assert!(rendered.contains("redacted"), "got: {rendered}");
1121        assert!(
1122            !rendered.contains("90"),
1123            "the seed must not appear: {rendered}"
1124        );
1125    }
1126
1127    /// TEST 4a — a cover and a cost map of different lengths are refused.
1128    #[test]
1129    fn mismatched_lengths_are_refused() {
1130        let mut pixels = vec![0u8; 10_000];
1131        let costs = vec![1.0f32; 9_999];
1132
1133        let error = stc_encode_safe(&mut pixels, &costs, b"test", &StcConfig::new(SEED));
1134
1135        assert!(
1136            matches!(
1137                error,
1138                Err(StcError::LengthMismatch {
1139                    pixels: 10_000,
1140                    costs: 9_999
1141                })
1142            ),
1143            "expected a length mismatch, got: {error:?}"
1144        );
1145    }
1146
1147    /// TEST 4b — a payload over the `max_bpp` ceiling is refused, on both paths.
1148    #[test]
1149    fn oversized_payloads_are_refused() {
1150        // Twenty bytes is 160 bits against the 20 the ceiling allows here.
1151        let mut pixels = vec![0u8; 1_000];
1152        let costs = vec![1.0f32; pixels.len()];
1153        let config = StcConfig::new(SEED);
1154
1155        let encoding = stc_encode_safe(&mut pixels, &costs, &[0u8; 20], &config);
1156        assert!(
1157            matches!(
1158                encoding,
1159                Err(StcError::PayloadExceedsCapacity {
1160                    payload_bits: 160,
1161                    capacity_bits: 20
1162                })
1163            ),
1164            "expected the ceiling to refuse the payload, got: {encoding:?}"
1165        );
1166
1167        let decoding = stc_decode_safe(&pixels, 160, &config);
1168        assert!(
1169            matches!(decoding, Err(StcError::PayloadExceedsCapacity { .. })),
1170            "expected the ceiling to refuse the request, got: {decoding:?}"
1171        );
1172
1173        assert!(
1174            pixels.iter().all(|&sample| sample == 0),
1175            "a refused payload must leave the cover untouched"
1176        );
1177    }
1178
1179    /// TEST 4c — a cost map with a negative or non-finite entry is refused.
1180    #[test]
1181    fn unusable_cost_maps_are_refused() {
1182        let config = StcConfig::new(SEED);
1183
1184        for poison in [f32::NAN, f32::INFINITY, -1.0] {
1185            let mut pixels = vec![0u8; 10_000];
1186            let mut costs = vec![1.0f32; pixels.len()];
1187            if let Some(slot) = costs.get_mut(4_242) {
1188                *slot = poison;
1189            }
1190
1191            let error = stc_encode_safe(&mut pixels, &costs, b"test", &config);
1192
1193            assert!(
1194                matches!(error, Err(StcError::InvalidCostMap)),
1195                "expected a cost of {poison} to be refused, got: {error:?}"
1196            );
1197        }
1198    }
1199
1200    /// TEST 4d — a trellis height outside the supported range is refused rather
1201    /// than turned into an allocation nobody can serve.
1202    #[test]
1203    fn unsupported_trellis_heights_are_refused() {
1204        let mut pixels = vec![0u8; 10_000];
1205        let costs = vec![1.0f32; pixels.len()];
1206
1207        let mut config = StcConfig::new(SEED);
1208        config.trellis_height = MAX_TRELLIS_HEIGHT + 1;
1209
1210        let encoding = stc_encode_safe(&mut pixels, &costs, b"test", &config);
1211        assert!(
1212            matches!(encoding, Err(StcError::EncodingError(_))),
1213            "expected an oversized height to be refused, got: {encoding:?}"
1214        );
1215
1216        let decoding = stc_decode_safe(&pixels, 32, &config);
1217        assert!(
1218            matches!(decoding, Err(StcError::DecodingError(_))),
1219            "expected an oversized height to be refused, got: {decoding:?}"
1220        );
1221    }
1222
1223    /// An empty payload is a no-op on both paths rather than an error.
1224    #[test]
1225    fn an_empty_payload_changes_nothing() {
1226        let mut pixels = cover(10_000, 6);
1227        let original = pixels.clone();
1228        let costs = vec![1.0f32; pixels.len()];
1229        let config = StcConfig::new(SEED);
1230
1231        assert!(matches!(
1232            stc_encode_safe(&mut pixels, &costs, &[], &config),
1233            Ok(0)
1234        ));
1235        assert_eq!(pixels, original);
1236
1237        match stc_decode_safe(&pixels, 0, &config) {
1238            Ok(recovered) => assert!(recovered.is_empty()),
1239            Err(error) => panic!("decoding nothing must succeed: {error}"),
1240        }
1241    }
1242
1243    /// A different seed builds a different matrix, so the payload does not come
1244    /// back out.
1245    ///
1246    /// The property the whole extraction path rests on: without `stc_seed` there
1247    /// is no matrix, and without the matrix the syndrome of the container says
1248    /// nothing.
1249    #[test]
1250    fn the_wrong_seed_recovers_nothing() {
1251        let mut pixels = cover(10_000, 7);
1252        let costs = vec![1.0f32; pixels.len()];
1253        let payload = b"secret!!";
1254
1255        if let Err(error) = stc_encode_safe(&mut pixels, &costs, payload, &StcConfig::new(SEED)) {
1256            panic!("embedding must succeed: {error}");
1257        }
1258
1259        let recovered = stc_decode_safe(&pixels, payload.len() * 8, &StcConfig::new([0xA5u8; 32]));
1260
1261        match recovered {
1262            Ok(bytes) => assert_ne!(bytes.as_slice(), payload.as_slice()),
1263            Err(error) => panic!("a wrong seed must decode to noise, not fail: {error}"),
1264        }
1265    }
1266
1267    /// Only the carrier bit of a sample is read back.
1268    ///
1269    /// The decoder is handed the stego samples with every bit above the first
1270    /// scrambled; the payload must still come out, because the syndrome is a
1271    /// function of the least significant bits alone.
1272    #[test]
1273    fn decoding_reads_nothing_but_the_carrier_bit() {
1274        let mut pixels = cover(10_000, 8);
1275        let costs = vec![1.0f32; pixels.len()];
1276        let payload = b"carrier";
1277        let config = StcConfig::new(SEED);
1278
1279        if let Err(error) = stc_encode_safe(&mut pixels, &costs, payload, &config) {
1280            panic!("embedding must succeed: {error}");
1281        }
1282
1283        let scrambled: Vec<u8> = pixels
1284            .iter()
1285            .map(|sample| (sample & 1) | (sample.rotate_left(3) & !1))
1286            .collect();
1287
1288        match stc_decode_safe(&scrambled, payload.len() * 8, &config) {
1289            Ok(recovered) => assert_eq!(recovered.as_slice(), payload.as_slice()),
1290            Err(error) => panic!("decoding must ignore the upper bits: {error}"),
1291        }
1292    }
1293}