use crate::codec::jpeg::dct::DctGrid;
use crate::codec::jpeg::JpegImage;
use crate::stego::armor::ecc;
use crate::stego::armor::repetition;
use crate::stego::crypto;
use crate::stego::error::StegoError;
use crate::stego::frame;
use rand::Rng;
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
#[cfg(feature = "parallel")]
use rayon::prelude::*;
const HEADER_QIM_STEP: f64 = 12.0;
const FORTRESS_MIN_R: usize = 15;
const FORTRESS_MAGIC: u8 = 0xF6;
const FORTRESS_HEADER_BLOCKS: usize = 56;
const FORTRESS_HEADER_COPIES: usize = 7;
struct AdaptiveParams {
base_step: f64,
watson_lo: f64,
watson_hi: f64,
}
fn adaptive_params(r: usize) -> AdaptiveParams {
const R_MIN: f64 = 15.0;
const R_MAX: f64 = 61.0;
let r_clamped = (r as f64).clamp(R_MIN, R_MAX);
let t = (r_clamped - R_MIN) / (R_MAX - R_MIN);
AdaptiveParams {
base_step: 12.0 + t * (6.5 - 12.0), watson_lo: 0.9 + t * (0.62 - 0.9), watson_hi: 1.1 + t * (1.26 - 1.1), }
}
fn watson_base_factor(ratio: f64) -> f64 {
const R0: f64 = 0.01;
const R1: f64 = 0.35;
const R2: f64 = 2.0;
const R3: f64 = 4.0;
const F0: f64 = 0.3;
const F1: f64 = 0.7;
const F2: f64 = 1.0;
const F3: f64 = 1.5;
if ratio <= R0 {
F0
} else if ratio <= R1 {
F0 + (ratio - R0) / (R1 - R0) * (F1 - F0)
} else if ratio <= R2 {
F1 + (ratio - R1) / (R2 - R1) * (F2 - F1)
} else if ratio <= R3 {
F2 + (ratio - R2) / (R3 - R2) * (F3 - F2)
} else {
F3
}
}
fn remap_watson(base_factor: f64, lo: f64, hi: f64) -> f64 {
let t = (base_factor - 0.3) / (1.5 - 0.3);
lo + t * (hi - lo)
}
struct EnergyRatios {
ratios: Vec<f64>,
}
fn compute_energy_ratios(grid: &DctGrid) -> EnergyRatios {
let blocks_wide = grid.blocks_wide();
let blocks_tall = grid.blocks_tall();
let total_blocks = blocks_wide * blocks_tall;
let block_indices: Vec<usize> = (0..total_blocks).collect();
let compute_energy = |&block_idx: &usize| -> f64 {
let br = block_idx / blocks_wide;
let bc = block_idx % blocks_wide;
let block = grid.block(br, bc);
let mut energy: f64 = 0.0;
for k in 1..64 {
let c = block[k];
if c.abs() >= 2 {
energy += (c as f64) * (c as f64);
}
}
energy
};
#[cfg(feature = "parallel")]
let ac_energies: Vec<f64> = block_indices.par_iter().map(compute_energy).collect();
#[cfg(not(feature = "parallel"))]
let ac_energies: Vec<f64> = block_indices.iter().map(compute_energy).collect();
let mut sorted = ac_energies.clone();
#[cfg(feature = "parallel")]
sorted.par_sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
#[cfg(not(feature = "parallel"))]
sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = if total_blocks == 0 {
1.0
} else {
sorted[total_blocks / 2].max(1.0)
};
let ratios = ac_energies.iter().map(|&e| e / median).collect();
EnergyRatios { ratios }
}
fn watson_factors(ratios: &EnergyRatios, watson_lo: f64, watson_hi: f64) -> Vec<f64> {
ratios
.ratios
.iter()
.map(|&r| remap_watson(watson_base_factor(r), watson_lo, watson_hi))
.collect()
}
fn dc_to_avg(dc: i16, qt_dc: u16) -> f64 {
dc as f64 * qt_dc as f64 / 8.0
}
fn avg_to_dc(avg: f64, qt_dc: u16) -> i16 {
(avg * 8.0 / qt_dc as f64).round() as i16
}
fn qim_embed_avg(avg: f64, step: f64, bit: u8) -> f64 {
let offset = if bit == 0 { 0.0 } else { step / 2.0 };
let shifted = avg - offset;
let quantized = (shifted / step).round() * step;
quantized + offset
}
fn qim_extract_soft(avg: f64, step: f64) -> f64 {
let half = step / 2.0;
let d0 = (avg / step).round() * step;
let dist0 = (avg - d0).abs();
let d1 = ((avg - half) / step).round() * step + half;
let dist1 = (avg - d1).abs();
dist1 - dist0
}
fn permute_blocks(total_blocks: usize, seed: &[u8; 32]) -> Vec<usize> {
let mut indices: Vec<usize> = (0..total_blocks).collect();
let mut rng = ChaCha20Rng::from_seed(*seed);
let n = indices.len();
for i in (1..n).rev() {
let j = rng.gen_range(0..=(i as u32)) as usize;
indices.swap(i, j);
}
indices
}
pub fn fortress_max_frame_bytes(img: &JpegImage) -> Result<usize, StegoError> {
fortress_max_frame_bytes_ext(img, false)
}
pub fn fortress_max_frame_bytes_ext(img: &JpegImage, _compact: bool) -> Result<usize, StegoError> {
let grid = img.dct_grid(0);
let total_blocks = grid.blocks_wide() * grid.blocks_tall();
if total_blocks <= FORTRESS_HEADER_BLOCKS {
return Ok(0);
}
let payload_blocks = total_blocks - FORTRESS_HEADER_BLOCKS;
let mut best_frame_bytes = 0usize;
for &parity in &ecc::PARITY_TIERS {
let mut lo = 0usize;
let mut hi = frame::MAX_FRAME_BYTES;
while lo < hi {
let mid = (lo + hi).div_ceil(2);
let rs_encoded_len = ecc::rs_encoded_len_with_parity(mid, parity);
let rs_bits = rs_encoded_len * 8;
if rs_bits == 0 || rs_bits > payload_blocks {
hi = mid - 1;
continue;
}
let r = repetition::compute_r(rs_bits, payload_blocks);
if r >= FORTRESS_MIN_R && rs_bits <= payload_blocks {
lo = mid;
} else {
hi = mid - 1;
}
}
if lo > best_frame_bytes {
let rs_encoded_len = ecc::rs_encoded_len_with_parity(lo, parity);
let rs_bits = rs_encoded_len * 8;
if rs_bits <= payload_blocks {
let r = repetition::compute_r(rs_bits, payload_blocks);
if r >= FORTRESS_MIN_R {
best_frame_bytes = lo;
}
}
}
}
Ok(best_frame_bytes)
}
pub fn fortress_capacity(img: &JpegImage) -> Result<usize, StegoError> {
let max_frame = fortress_max_frame_bytes(img)?;
if max_frame <= frame::FRAME_OVERHEAD {
return Ok(0);
}
Ok(max_frame - frame::FRAME_OVERHEAD)
}
pub fn fortress_capacity_compact(img: &JpegImage) -> Result<usize, StegoError> {
let max_frame = fortress_max_frame_bytes_ext(img, true)?;
if max_frame <= frame::FORTRESS_COMPACT_FRAME_OVERHEAD {
return Ok(0);
}
Ok(max_frame - frame::FORTRESS_COMPACT_FRAME_OVERHEAD)
}
pub struct FortressEncodeResult {
pub repetition_factor: usize,
pub parity_symbols: usize,
}
pub fn fortress_encode(
img: &mut JpegImage,
frame_bytes: &[u8],
passphrase: &str,
) -> Result<FortressEncodeResult, StegoError> {
let qt_id = img.frame_info().components[0].quant_table_id as usize;
let qt_dc = img
.quant_table(qt_id)
.ok_or(StegoError::NoLuminanceChannel)?
.values[0];
let grid = img.dct_grid(0);
let blocks_wide = grid.blocks_wide();
let blocks_tall = grid.blocks_tall();
let total_blocks = blocks_wide * blocks_tall;
if total_blocks <= FORTRESS_HEADER_BLOCKS {
return Err(StegoError::ImageTooSmall);
}
let fort_key = crypto::derive_fortress_structural_key(passphrase)?;
let perm = permute_blocks(total_blocks, &fort_key);
let header_perm = &perm[..FORTRESS_HEADER_BLOCKS];
let remaining_perm = &perm[FORTRESS_HEADER_BLOCKS..];
let payload_blocks = remaining_perm.len();
let mut chosen_parity = ecc::PARITY_TIERS[0];
for &parity in &ecc::PARITY_TIERS {
let rs_encoded_len = ecc::rs_encoded_len_with_parity(frame_bytes.len(), parity);
let rs_bits_len = rs_encoded_len * 8;
if rs_bits_len <= payload_blocks {
let r = repetition::compute_r(rs_bits_len, payload_blocks);
if r >= FORTRESS_MIN_R {
chosen_parity = parity;
break;
}
}
}
let rs_encoded = ecc::rs_encode_blocks_with_parity(frame_bytes, chosen_parity);
let rs_bits = frame::bytes_to_bits(&rs_encoded);
let r = repetition::compute_r(rs_bits.len(), payload_blocks);
if r < FORTRESS_MIN_R {
return Err(StegoError::MessageTooLarge);
}
let params = adaptive_params(r);
let energy = compute_energy_ratios(grid);
let factors = watson_factors(&energy, params.watson_lo, params.watson_hi);
let rs_bit_count_aligned = payload_blocks / r;
let mut rs_bits_padded = rs_bits;
rs_bits_padded.resize(rs_bit_count_aligned, 0);
let (rep_bits, _) = repetition::repetition_encode(&rs_bits_padded, payload_blocks);
let mut header_bits = Vec::with_capacity(FORTRESS_HEADER_BLOCKS);
for _ in 0..FORTRESS_HEADER_COPIES {
for bp in (0..8).rev() {
header_bits.push((FORTRESS_MAGIC >> bp) & 1);
}
}
let grid_mut = img.dct_grid_mut(0);
for (bit_idx, &block_idx) in header_perm.iter().enumerate() {
let br = block_idx / blocks_wide;
let bc = block_idx % blocks_wide;
let dc = grid_mut.get(br, bc, 0, 0);
let avg = dc_to_avg(dc, qt_dc);
let new_avg = qim_embed_avg(avg, HEADER_QIM_STEP, header_bits[bit_idx]);
let new_dc = avg_to_dc(new_avg, qt_dc);
grid_mut.set(br, bc, 0, 0, new_dc);
}
for (payload_idx, &block_idx) in remaining_perm.iter().enumerate() {
if payload_idx >= rep_bits.len() {
break;
}
let step = params.base_step * factors[block_idx];
let br = block_idx / blocks_wide;
let bc = block_idx % blocks_wide;
let dc = grid_mut.get(br, bc, 0, 0);
let avg = dc_to_avg(dc, qt_dc);
let new_avg = qim_embed_avg(avg, step, rep_bits[payload_idx]);
let new_dc = avg_to_dc(new_avg, qt_dc);
grid_mut.set(br, bc, 0, 0, new_dc);
}
Ok(FortressEncodeResult {
repetition_factor: r,
parity_symbols: chosen_parity,
})
}
pub fn fortress_decode(
img: &JpegImage,
passphrase: &str,
) -> Result<(crate::stego::payload::PayloadData, super::pipeline::DecodeQuality), StegoError> {
let qt_id = img.frame_info().components[0].quant_table_id as usize;
let qt_dc = img
.quant_table(qt_id)
.ok_or(StegoError::NoLuminanceChannel)?
.values[0];
let grid = img.dct_grid(0);
let blocks_wide = grid.blocks_wide();
let blocks_tall = grid.blocks_tall();
let total_blocks = blocks_wide * blocks_tall;
if total_blocks <= FORTRESS_HEADER_BLOCKS {
return Err(StegoError::FrameCorrupted);
}
let fort_key = crypto::derive_fortress_structural_key(passphrase)?;
let perm = permute_blocks(total_blocks, &fort_key);
let mut header_llrs = Vec::with_capacity(FORTRESS_HEADER_BLOCKS);
for &block_idx in &perm[..FORTRESS_HEADER_BLOCKS] {
let br = block_idx / blocks_wide;
let bc = block_idx % blocks_wide;
let dc = grid.get(br, bc, 0, 0);
let avg = dc_to_avg(dc, qt_dc);
header_llrs.push(qim_extract_soft(avg, HEADER_QIM_STEP));
}
let magic = extract_magic_byte(&header_llrs);
if magic != FORTRESS_MAGIC {
return Err(StegoError::FrameCorrupted);
}
let energy = compute_energy_ratios(grid);
let remaining_perm = &perm[FORTRESS_HEADER_BLOCKS..];
let payload_blocks = remaining_perm.len();
let block_avgs: Vec<f64> = remaining_perm
.iter()
.map(|&block_idx| {
let br = block_idx / blocks_wide;
let bc = block_idx % blocks_wide;
dc_to_avg(grid.get(br, bc, 0, 0), qt_dc)
})
.collect();
let use_compact = passphrase.is_empty();
let mut candidates: Vec<(usize, usize)> = Vec::new();
for &parity in &ecc::PARITY_TIERS {
let candidate_rs = if use_compact {
super::pipeline::compute_candidate_rs_compact(payload_blocks, parity)
} else {
super::pipeline::compute_candidate_rs(payload_blocks, parity)
};
for r in candidate_rs {
candidates.push((parity, r));
}
}
let try_candidate = |&(parity, r): &(usize, usize)| -> Option<(crate::stego::payload::PayloadData, super::pipeline::DecodeQuality)> {
let params = adaptive_params(r);
let factors = watson_factors(&energy, params.watson_lo, params.watson_hi);
let reference_llr = params.base_step / 2.0;
let payload_llrs: Vec<f64> = remaining_perm
.iter()
.enumerate()
.map(|(i, &block_idx)| {
let step = params.base_step * factors[block_idx];
qim_extract_soft(block_avgs[i], step)
})
.collect();
let rs_bit_count = payload_blocks / r;
if rs_bit_count == 0 {
return None;
}
let used_llrs = rs_bit_count * r;
if used_llrs > payload_llrs.len() {
return None;
}
let (voted_bits, rep_quality) =
repetition::repetition_decode_soft_with_quality(&payload_llrs[..used_llrs], rs_bit_count);
let voted_bytes = frame::bits_to_bytes(&voted_bits);
let (decoded_frame, rs_stats) = if use_compact {
super::pipeline::try_rs_decode_compact_frame_with_parity(&voted_bytes, parity)?
} else {
super::pipeline::try_rs_decode_frame_with_parity(&voted_bytes, parity)?
};
let parsed = if use_compact {
frame::parse_fortress_compact_frame(&decoded_frame).ok()?
} else {
frame::parse_frame(&decoded_frame).ok()?
};
let plaintext = crypto::decrypt(
&parsed.ciphertext, passphrase, &parsed.salt, &parsed.nonce,
).ok()?;
let len = parsed.plaintext_len as usize;
if len > plaintext.len() {
return None;
}
let payload_data = crate::stego::payload::decode_payload(&plaintext[..len]).ok()?;
let mut q = super::pipeline::DecodeQuality::from_rs_stats_with_signal(
&rs_stats, r as u8, parity as u16,
rep_quality.avg_abs_llr_per_copy, reference_llr,
);
q.fortress_used = true;
Some((payload_data, q))
};
#[cfg(feature = "parallel")]
let result = candidates.par_iter().find_map_first(try_candidate);
#[cfg(not(feature = "parallel"))]
let result = candidates.iter().find_map(try_candidate);
match result {
Some(result) => Ok(result),
None => Err(StegoError::FrameCorrupted),
}
}
fn extract_magic_byte(header_llrs: &[f64]) -> u8 {
let mut byte = 0u8;
for bit_pos in 0..8 {
let mut total = 0.0;
for copy in 0..FORTRESS_HEADER_COPIES {
let idx = copy * 8 + bit_pos;
if idx < header_llrs.len() {
total += header_llrs[idx];
}
}
if total < 0.0 {
byte |= 1 << (7 - bit_pos);
}
}
byte
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::jpeg::dct::DctGrid;
#[test]
fn qim_embed_extract_roundtrip() {
for avg in [0.0, 50.0, 100.0, 200.0, -50.0, 127.5] {
for bit in [0u8, 1] {
let embedded = qim_embed_avg(avg, HEADER_QIM_STEP, bit);
let llr = qim_extract_soft(embedded, HEADER_QIM_STEP);
let extracted_bit = if llr >= 0.0 { 0u8 } else { 1u8 };
assert_eq!(
extracted_bit, bit,
"Failed for avg={avg}, bit={bit}: embedded={embedded}, llr={llr}"
);
}
}
}
#[test]
fn qim_soft_llr_sign_matches_hard() {
for avg in [10.0, 50.0, 128.0, 250.0] {
for bit in [0u8, 1] {
let embedded = qim_embed_avg(avg, HEADER_QIM_STEP, bit);
let llr = qim_extract_soft(embedded, HEADER_QIM_STEP);
if bit == 0 {
assert!(llr >= 0.0, "Expected positive LLR for bit 0, got {llr}");
} else {
assert!(llr <= 0.0, "Expected negative LLR for bit 1, got {llr}");
}
}
}
}
#[test]
fn dc_avg_roundtrip() {
for qt_dc in [1u16, 2, 4, 8, 16, 32] {
for dc in [-100i16, -1, 0, 1, 50, 100] {
let avg = dc_to_avg(dc, qt_dc);
let recovered = avg_to_dc(avg, qt_dc);
assert_eq!(
recovered, dc,
"Roundtrip failed for dc={dc}, qt_dc={qt_dc}: avg={avg}"
);
}
}
}
#[test]
fn magic_byte_majority_voting() {
let mut llrs = Vec::with_capacity(56);
for _ in 0..FORTRESS_HEADER_COPIES {
for bp in (0..8).rev() {
let bit = (FORTRESS_MAGIC >> bp) & 1;
llrs.push(if bit == 0 { 5.0 } else { -5.0 });
}
}
for i in 0..8 {
llrs[i] = -llrs[i]; llrs[8 + i] = -llrs[8 + i]; }
let extracted = extract_magic_byte(&llrs);
assert_eq!(extracted, FORTRESS_MAGIC, "Majority vote should recover magic byte");
}
#[test]
fn block_permutation_deterministic() {
let seed = [42u8; 32];
let a = permute_blocks(100, &seed);
let b = permute_blocks(100, &seed);
assert_eq!(a, b);
}
#[test]
fn block_permutation_different_seeds() {
let a = permute_blocks(100, &[1u8; 32]);
let b = permute_blocks(100, &[2u8; 32]);
assert_ne!(a, b);
}
#[test]
fn block_permutation_is_permutation() {
let perm = permute_blocks(100, &[7u8; 32]);
let mut sorted = perm;
sorted.sort();
let expected: Vec<usize> = (0..100).collect();
assert_eq!(sorted, expected);
}
#[test]
fn fortress_capacity_reasonable() {
let total_blocks = 14400usize;
let payload_blocks = total_blocks - FORTRESS_HEADER_BLOCKS;
assert!(payload_blocks > 1000, "Should have many payload blocks");
}
fn make_grid(blocks_wide: usize, blocks_tall: usize, blocks: &[[i16; 64]]) -> DctGrid {
let mut grid = DctGrid::new(blocks_wide, blocks_tall);
for (idx, block_data) in blocks.iter().enumerate() {
let br = idx / blocks_wide;
let bc = idx % blocks_wide;
for k in 0..64 {
let i = k / 8;
let j = k % 8;
grid.set(br, bc, i, j, block_data[k]);
}
}
grid
}
fn smooth_block() -> [i16; 64] {
let mut b = [0i16; 64];
b[0] = 100; b
}
fn textured_block() -> [i16; 64] {
let mut b = [0i16; 64];
b[0] = 100; for k in 1..64 {
b[k] = 20; }
b
}
fn medium_block() -> [i16; 64] {
let mut b = [0i16; 64];
b[0] = 100; for k in 1..20 {
b[k] = 5;
}
b
}
#[test]
fn watson_energy_smooth_vs_textured() {
let blocks = vec![
smooth_block(),
smooth_block(),
medium_block(),
textured_block(),
];
let grid = make_grid(4, 1, &blocks);
let energy = compute_energy_ratios(&grid);
assert_eq!(energy.ratios.len(), 4);
assert_eq!(energy.ratios[0], 0.0, "Smooth block should have zero ratio");
assert_eq!(energy.ratios[1], 0.0, "Smooth block should have zero ratio");
assert!(
energy.ratios[3] > energy.ratios[2],
"Textured block should have higher ratio than medium"
);
}
#[test]
fn watson_energy_deterministic() {
let blocks: Vec<[i16; 64]> = (0..16)
.map(|i| {
let mut b = [0i16; 64];
b[0] = 100;
for k in 1..(1 + (i % 10) + 1).min(64) {
b[k] = (i as i16 + 1) * 3;
}
b
})
.collect();
let grid = make_grid(4, 4, &blocks);
let a = compute_energy_ratios(&grid);
let b = compute_energy_ratios(&grid);
assert_eq!(a.ratios, b.ratios, "Energy ratios must be deterministic");
}
#[test]
fn watson_ac_energy_invariant() {
let mut block = textured_block();
let original_ac_energy: f64 = block[1..64]
.iter()
.filter(|&&c| c.abs() >= 2)
.map(|&c| (c as f64) * (c as f64))
.sum();
block[0] = 42; let modified_ac_energy: f64 = block[1..64]
.iter()
.filter(|&&c| c.abs() >= 2)
.map(|&c| (c as f64) * (c as f64))
.sum();
assert_eq!(
original_ac_energy, modified_ac_energy,
"AC energy must be unchanged after DC modification"
);
}
#[test]
fn adaptive_fortress_encode_decode_roundtrip() {
use crate::stego::armor::pipeline::{armor_encode, armor_decode};
let test_jpeg = if let Ok(d) = std::fs::read("test-vectors/image/progressive_whatsapp_1200x1600.jpg") { d } else { eprintln!("skipped: test vector not found"); return; };
let passphrase = "test-adaptive-pass";
let img = crate::codec::jpeg::JpegImage::from_bytes(&test_jpeg).unwrap();
let fort_cap = fortress_capacity(&img).unwrap();
assert!(fort_cap >= 1, "Fortress capacity ({fort_cap}) must be >= 1");
let message = if fort_cap >= 4 { "Hi!!" } else { "Hi" };
let stego_bytes = armor_encode(&test_jpeg, message, passphrase)
.expect("Adaptive fortress encode should succeed");
let (decoded_msg, quality) = armor_decode(&stego_bytes, passphrase)
.expect("Adaptive fortress decode should succeed");
assert_eq!(decoded_msg.text, message, "Decoded message must match original");
assert!(quality.fortress_used, "Should use fortress mode");
}
#[test]
fn watson_magic_extraction() {
let mut header_llrs = Vec::with_capacity(56);
for _ in 0..FORTRESS_HEADER_COPIES {
for bp in (0..8).rev() {
let bit = (FORTRESS_MAGIC >> bp) & 1;
header_llrs.push(if bit == 0 { 5.0 } else { -5.0 });
}
}
let extracted = extract_magic_byte(&header_llrs);
assert_eq!(extracted, FORTRESS_MAGIC, "Should extract magic 0xF6");
}
#[test]
fn watson_all_smooth_base_factor() {
let blocks: Vec<[i16; 64]> = (0..9).map(|_| smooth_block()).collect();
let grid = make_grid(3, 3, &blocks);
let energy = compute_energy_ratios(&grid);
for &r in &energy.ratios {
assert_eq!(watson_base_factor(r), 0.3, "Smooth block base factor should be 0.3");
}
}
#[test]
fn watson_uniform_texture_base_factor() {
let blocks: Vec<[i16; 64]> = (0..9).map(|_| textured_block()).collect();
let grid = make_grid(3, 3, &blocks);
let energy = compute_energy_ratios(&grid);
for &r in &energy.ratios {
let f = watson_base_factor(r);
assert!(
(0.7..=1.0).contains(&f),
"Uniform texture ratio={r} should give factor in [0.7, 1.0], got {f}"
);
}
}
#[test]
fn watson_base_factor_anchor_points() {
assert_eq!(watson_base_factor(0.0), 0.3);
assert_eq!(watson_base_factor(0.01), 0.3);
assert!((watson_base_factor(0.35) - 0.7).abs() < 1e-12);
assert!((watson_base_factor(2.0) - 1.0).abs() < 1e-12);
assert!((watson_base_factor(4.0) - 1.5).abs() < 1e-12);
assert_eq!(watson_base_factor(10.0), 1.5);
}
#[test]
fn watson_base_factor_monotonic() {
let mut prev = watson_base_factor(0.0);
let mut r = 0.001;
while r <= 10.0 {
let f = watson_base_factor(r);
assert!(f >= prev - 1e-12, "Not monotonic at ratio={r}: {f} < {prev}");
prev = f;
r += 0.001;
}
}
#[test]
fn watson_base_factor_midpoints() {
let mid1 = watson_base_factor((0.01 + 0.35) / 2.0);
assert!((mid1 - 0.5).abs() < 1e-10, "Midpoint segment 1: expected ~0.5, got {mid1}");
let mid2 = watson_base_factor((0.35 + 2.0) / 2.0);
assert!((mid2 - 0.85).abs() < 1e-10, "Midpoint segment 2: expected ~0.85, got {mid2}");
let mid3 = watson_base_factor((2.0 + 4.0) / 2.0);
assert!((mid3 - 1.25).abs() < 1e-10, "Midpoint segment 3: expected ~1.25, got {mid3}");
}
#[test]
fn watson_base_factor_deterministic() {
for ratio in [0.0, 0.005, 0.18, 1.0, 3.0, 5.0, 100.0] {
let a = watson_base_factor(ratio);
let b = watson_base_factor(ratio);
assert_eq!(a.to_bits(), b.to_bits(), "Not bit-identical at ratio={ratio}");
}
}
#[test]
fn remap_watson_full_range() {
assert!((remap_watson(0.3, 0.3, 1.5) - 0.3).abs() < 1e-12);
assert!((remap_watson(1.5, 0.3, 1.5) - 1.5).abs() < 1e-12);
assert!((remap_watson(0.9, 0.3, 1.5) - 0.9).abs() < 1e-12);
}
#[test]
fn remap_watson_narrow_range() {
assert!((remap_watson(0.3, 0.9, 1.1) - 0.9).abs() < 1e-12);
assert!((remap_watson(1.5, 0.9, 1.1) - 1.1).abs() < 1e-12);
let mid = remap_watson(0.9, 0.9, 1.1);
assert!((mid - 1.0).abs() < 1e-12, "Midpoint should remap to 1.0, got {mid}");
}
#[test]
fn adaptive_params_continuous() {
let p = adaptive_params(15);
assert_eq!(p.base_step, 12.0);
assert_eq!(p.watson_lo, 0.9);
assert_eq!(p.watson_hi, 1.1);
let p = adaptive_params(61);
assert_eq!(p.base_step, 6.5);
assert_eq!(p.watson_lo, 0.62);
assert_eq!(p.watson_hi, 1.26);
let p = adaptive_params(38);
assert!(p.base_step > 6.5 && p.base_step < 12.0);
assert!(p.watson_lo > 0.62 && p.watson_lo < 0.9);
assert!(p.watson_hi > 1.1 && p.watson_hi < 1.26);
let p15 = adaptive_params(15);
let p38 = adaptive_params(38);
let p61 = adaptive_params(61);
assert!(p15.base_step > p38.base_step);
assert!(p38.base_step > p61.base_step);
let p200 = adaptive_params(200);
assert_eq!(p200.base_step, 6.5);
}
#[test]
fn watson_factors_all_smooth() {
let blocks: Vec<[i16; 64]> = (0..9).map(|_| smooth_block()).collect();
let grid = make_grid(3, 3, &blocks);
let energy = compute_energy_ratios(&grid);
let factors = watson_factors(&energy, 0.9, 1.1);
assert!(
factors.iter().all(|&f| (f - 0.9).abs() < 1e-12),
"All smooth blocks should have factor 0.9 (watson_lo)"
);
}
#[test]
fn watson_factors_range_respected() {
let blocks = vec![
smooth_block(),
smooth_block(),
medium_block(),
textured_block(),
];
let grid = make_grid(4, 1, &blocks);
let energy = compute_energy_ratios(&grid);
for (lo, hi) in [(0.8, 1.2), (0.9, 1.1)] {
let factors = watson_factors(&energy, lo, hi);
for (i, &f) in factors.iter().enumerate() {
assert!(
f >= lo - 1e-12 && f <= hi + 1e-12,
"Factor {f} for block {i} out of [{lo}, {hi}]"
);
}
}
}
#[test]
fn adaptive_encode_decode_roundtrip() {
use crate::stego::armor::pipeline::{armor_encode, armor_decode};
let test_jpeg = if let Ok(d) = std::fs::read("test-vectors/image/progressive_whatsapp_1200x1600.jpg") { d } else { eprintln!("skipped: test vector not found"); return; };
let passphrase = "adaptive-watson-test";
let img = crate::codec::jpeg::JpegImage::from_bytes(&test_jpeg).unwrap();
let fort_cap = fortress_capacity(&img).unwrap();
assert!(fort_cap >= 1, "Fortress capacity must be >= 1");
let message = if fort_cap >= 4 { "Hi!!" } else { "Hi" };
let stego_bytes = armor_encode(&test_jpeg, message, passphrase)
.expect("Adaptive encode should succeed");
let (decoded_msg, quality) = armor_decode(&stego_bytes, passphrase)
.expect("Adaptive decode should succeed");
assert_eq!(decoded_msg.text, message, "Decoded message must match");
assert!(quality.fortress_used, "Should use fortress mode");
}
#[test]
fn fortress_compact_encode_decode_roundtrip() {
use crate::stego::armor::pipeline::{armor_encode, armor_decode};
let test_jpeg = if let Ok(d) = std::fs::read("test-vectors/image/progressive_whatsapp_1200x1600.jpg") { d } else { eprintln!("skipped: test vector not found"); return; };
let passphrase = ""; let img = crate::codec::jpeg::JpegImage::from_bytes(&test_jpeg).unwrap();
let fort_cap = fortress_capacity_compact(&img).unwrap();
assert!(fort_cap >= 1, "Fortress compact capacity ({fort_cap}) must be >= 1");
let message = if fort_cap >= 4 { "Hi!!" } else { "Hi" };
let stego_bytes = armor_encode(&test_jpeg, message, passphrase)
.expect("Compact fortress encode should succeed");
let (decoded_msg, quality) = armor_decode(&stego_bytes, passphrase)
.expect("Compact fortress decode should succeed");
assert_eq!(decoded_msg.text, message, "Decoded message must match");
assert!(quality.fortress_used, "Should use fortress mode");
}
#[test]
fn fortress_compact_capacity_larger_than_full() {
let test_jpeg = if let Ok(d) = std::fs::read("test-vectors/image/progressive_whatsapp_1200x1600.jpg") { d } else { eprintln!("skipped: test vector not found"); return; };
let img = crate::codec::jpeg::JpegImage::from_bytes(&test_jpeg).unwrap();
let full_cap = fortress_capacity(&img).unwrap();
let compact_cap = fortress_capacity_compact(&img).unwrap();
assert!(
compact_cap >= full_cap,
"Compact capacity ({compact_cap}) should be >= full capacity ({full_cap})"
);
if full_cap > 0 {
assert!(
compact_cap - full_cap >= 28,
"Compact frame saves 28 bytes overhead, so capacity should increase by >= 28: \
compact={compact_cap}, full={full_cap}, diff={}",
compact_cap - full_cap
);
}
}
#[test]
fn fortress_nonempty_passphrase_still_uses_full_frame() {
use crate::stego::armor::pipeline::{armor_encode, armor_decode};
let test_jpeg = if let Ok(d) = std::fs::read("test-vectors/image/progressive_whatsapp_1200x1600.jpg") { d } else { eprintln!("skipped: test vector not found"); return; };
let passphrase = "some-secret";
let stego_bytes = armor_encode(&test_jpeg, "Hi", passphrase)
.expect("Non-empty passphrase fortress encode should succeed");
let (decoded_msg, quality) = armor_decode(&stego_bytes, passphrase)
.expect("Non-empty passphrase fortress decode should succeed");
assert_eq!(decoded_msg.text, "Hi");
assert!(quality.fortress_used, "Should use fortress mode");
}
#[test]
fn fortress_compact_wrong_passphrase_fails() {
use crate::stego::armor::pipeline::{armor_encode, armor_decode};
let test_jpeg = if let Ok(d) = std::fs::read("test-vectors/image/progressive_whatsapp_1200x1600.jpg") { d } else { eprintln!("skipped: test vector not found"); return; };
let stego_bytes = armor_encode(&test_jpeg, "Hi", "")
.expect("Compact fortress encode should succeed");
let result = armor_decode(&stego_bytes, "wrong");
assert!(result.is_err(), "Decoding compact frame with wrong passphrase should fail");
}
}