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}