phasm_core/stego/permute.rs
1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! Media-agnostic coefficient position permutation.
6//!
7//! Provides the [`CoeffPos`] type and a portable Fisher-Yates shuffle using
8//! a ChaCha20 PRNG seeded from the passphrase. Both the JPEG image pipeline
9//! and (future) video pipeline use this to establish a shared pseudo-random
10//! embedding order between encoder and decoder.
11//!
12//! # Cross-platform portability
13//!
14//! The Fisher-Yates shuffle uses `u32` for `gen_range` (not `usize`) to ensure
15//! identical permutations on all platforms. `usize` is 32-bit on WASM but
16//! 64-bit on native, which causes `rand::Rng::gen_range` to consume different
17//! amounts of PRNG entropy per step — producing completely different shuffles.
18
19use rand::Rng;
20use rand_chacha::ChaCha20Rng;
21use rand::SeedableRng;
22
23// Re-export JPEG-specific select_and_permute at the original path so that
24// existing code (`crate::stego::permute::select_and_permute`) continues to work.
25pub use crate::stego::ghost::permute::select_and_permute;
26
27/// A coefficient position: (flat_index, cost).
28///
29/// Compact representation: `u32` flat index (supports up to ~268M blocks = 4 Gpx)
30/// and `f32` cost (sufficient for cost ranking). Total: 8 bytes per position
31/// (down from 16 bytes with `usize` + `f64`).
32#[derive(Clone)]
33pub struct CoeffPos {
34 /// Flat index into the coefficient grid.
35 /// For JPEG: block_index * 64 + row * 8 + col.
36 /// For HEVC: TU-relative index (defined by video pipeline).
37 pub flat_idx: u32,
38 /// Embedding cost at this position (f32 — sufficient for ranking).
39 pub cost: f32,
40}
41
42/// Apply Fisher-Yates shuffle using `u32` for portable cross-platform behavior.
43fn shuffle_portable(positions: &mut [CoeffPos], seed: &[u8; 32]) {
44 let mut rng = ChaCha20Rng::from_seed(*seed);
45 let n = positions.len();
46 for i in (1..n).rev() {
47 let j = rng.gen_range(0..=(i as u32)) as usize;
48 positions.swap(i, j);
49 }
50}
51
52/// Apply a portable Fisher-Yates permutation to an externally collected
53/// position vector.
54///
55/// Used by the streaming UNIWARD path (image) where positions are collected
56/// during strip-based cost computation, and by the video pipeline where
57/// positions are collected from HEVC TUs.
58pub fn permute_positions(positions: &mut [CoeffPos], seed: &[u8; 32]) {
59 shuffle_portable(positions, seed);
60}
61
62
63#[cfg(test)]
64mod tests {
65 use super::*;
66
67 #[test]
68 fn shuffle_deterministic() {
69 let mut a = vec![
70 CoeffPos { flat_idx: 0, cost: 1.0 },
71 CoeffPos { flat_idx: 1, cost: 2.0 },
72 CoeffPos { flat_idx: 2, cost: 3.0 },
73 CoeffPos { flat_idx: 3, cost: 4.0 },
74 ];
75 let mut b = a.clone();
76 let seed = [42u8; 32];
77 permute_positions(&mut a, &seed);
78 permute_positions(&mut b, &seed);
79 let a_idx: Vec<_> = a.iter().map(|p| p.flat_idx).collect();
80 let b_idx: Vec<_> = b.iter().map(|p| p.flat_idx).collect();
81 assert_eq!(a_idx, b_idx);
82 }
83
84 #[test]
85 fn different_seeds_produce_different_order() {
86 let mut a = (0..20).map(|i| CoeffPos { flat_idx: i, cost: 1.0 }).collect::<Vec<_>>();
87 let mut b = a.clone();
88 permute_positions(&mut a, &[1u8; 32]);
89 permute_positions(&mut b, &[2u8; 32]);
90 let a_idx: Vec<_> = a.iter().map(|p| p.flat_idx).collect();
91 let b_idx: Vec<_> = b.iter().map(|p| p.flat_idx).collect();
92 assert_ne!(a_idx, b_idx);
93 }
94}