use std::fmt;
use crate::cost::CostMap;
use crate::stego::stc::MAX_BPP;
const STC_EFFICIENCY: f32 = 0.85;
const MAC_OVERHEAD_BYTES: usize = 16;
#[cfg(feature = "pqc")]
const ML_KEM_1024_CIPHERTEXT_BYTES: usize = 1568;
const BITS_PER_BYTE: usize = 8;
#[derive(Debug)]
pub enum SizerError {
PayloadTooLarge {
payload: usize,
available: usize,
deficit: usize,
},
}
impl fmt::Display for SizerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SizerError::PayloadTooLarge { .. } => write!(
f,
"the message does not fit in this image; shorten the message or use an image of \
higher resolution"
),
}
}
}
impl std::error::Error for SizerError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EmbeddingMode {
#[default]
Symmetric,
#[cfg(feature = "pqc")]
AsymmetricPqc,
}
impl EmbeddingMode {
pub fn key_transport_overhead_bytes(self) -> usize {
match self {
EmbeddingMode::Symmetric => 0,
#[cfg(feature = "pqc")]
EmbeddingMode::AsymmetricPqc => ML_KEM_1024_CIPHERTEXT_BYTES,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct CapacityReport {
pub(crate) total_pixels: usize,
pub(crate) textured_pixels: usize,
pub(crate) gross_capacity_bits: usize,
pub(crate) net_capacity_bits: usize,
pub(crate) mac_overhead_bytes: usize,
pub(crate) available_bytes: usize,
}
impl CapacityReport {
pub fn total_pixels(&self) -> usize {
self.total_pixels
}
pub fn textured_pixels(&self) -> usize {
self.textured_pixels
}
pub fn gross_capacity_bits(&self) -> usize {
self.gross_capacity_bits
}
pub fn net_capacity_bits(&self) -> usize {
self.net_capacity_bits
}
pub fn mac_overhead_bytes(&self) -> usize {
self.mac_overhead_bytes
}
pub fn available_bytes(&self) -> usize {
self.available_bytes
}
}
pub fn compute_capacity(cost_map: &CostMap<'_>, mode: EmbeddingMode) -> CapacityReport {
let total_pixels = cost_map.pixel_count();
let textured_pixels = cost_map.costs().iter().filter(|&&cost| cost > 0.0).count();
let gross_capacity_bits = (textured_pixels as f32 * MAX_BPP) as usize;
let net_capacity_bits = (gross_capacity_bits as f32 * STC_EFFICIENCY) as usize;
let available_bytes = (net_capacity_bits / BITS_PER_BYTE)
.saturating_sub(MAC_OVERHEAD_BYTES)
.saturating_sub(mode.key_transport_overhead_bytes());
CapacityReport {
total_pixels,
textured_pixels,
gross_capacity_bits,
net_capacity_bits,
mac_overhead_bytes: MAC_OVERHEAD_BYTES,
available_bytes,
}
}
pub fn validate_payload_fits(
payload_len: usize,
report: &CapacityReport,
) -> Result<(), SizerError> {
if payload_len > report.available_bytes {
return Err(SizerError::PayloadTooLarge {
payload: payload_len,
available: report.available_bytes,
deficit: payload_len - report.available_bytes,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
#![allow(clippy::panic)]
use super::*;
use crate::image_io::buffer::{ColorSpace, CoverSource, ImageBuffer};
const SIDE: u32 = 200;
fn image() -> ImageBuffer {
ImageBuffer::new(
vec![0u8; (SIDE * SIDE) as usize],
SIDE,
SIDE,
ColorSpace::Luma8,
)
}
fn map(image: &ImageBuffer, usable: usize) -> CostMap<'_> {
let costs = (0..image.pixel_count())
.map(|index| if index < usable { 1.0 } else { 0.0 })
.collect();
CostMap::new(image, costs)
}
#[test]
fn capacity_is_reported_step_by_step() {
let image = image();
let map = map(&image, image.pixel_count());
let report = compute_capacity(&map, EmbeddingMode::Symmetric);
assert_eq!(report.total_pixels(), image.pixel_count());
assert_eq!(report.textured_pixels(), image.pixel_count());
assert_eq!(
report.gross_capacity_bits(),
(image.pixel_count() as f32 * MAX_BPP) as usize
);
assert_eq!(
report.net_capacity_bits(),
(report.gross_capacity_bits() as f32 * STC_EFFICIENCY) as usize
);
assert_eq!(report.mac_overhead_bytes(), MAC_OVERHEAD_BYTES);
assert_eq!(
report.available_bytes(),
report.net_capacity_bits() / BITS_PER_BYTE - MAC_OVERHEAD_BYTES
);
}
#[test]
fn positions_of_zero_cost_carry_nothing() {
let image = image();
let half = image.pixel_count() / 2;
let full = compute_capacity(&map(&image, image.pixel_count()), EmbeddingMode::Symmetric);
let halved = compute_capacity(&map(&image, half), EmbeddingMode::Symmetric);
assert_eq!(halved.textured_pixels(), half);
assert_eq!(halved.total_pixels(), full.total_pixels());
assert!(halved.available_bytes() < full.available_bytes());
}
#[test]
fn a_container_that_cannot_pay_the_tag_admits_nothing() {
let tiny = ImageBuffer::new(vec![0u8; 64], 8, 8, ColorSpace::Luma8);
let report = compute_capacity(&map(&tiny, tiny.pixel_count()), EmbeddingMode::Symmetric);
assert_eq!(report.available_bytes(), 0);
}
#[test]
fn the_symmetric_mode_transports_no_key() {
assert_eq!(EmbeddingMode::default(), EmbeddingMode::Symmetric);
assert_eq!(EmbeddingMode::Symmetric.key_transport_overhead_bytes(), 0);
}
#[test]
fn the_last_byte_that_fits_is_accepted_and_the_next_is_not() {
let image = image();
let report = compute_capacity(&map(&image, image.pixel_count()), EmbeddingMode::Symmetric);
let available = report.available_bytes();
assert!(available > 0, "the fixture must have room to measure");
assert!(validate_payload_fits(available, &report).is_ok());
assert!(validate_payload_fits(available - 1, &report).is_ok());
match validate_payload_fits(available + 1, &report) {
Err(SizerError::PayloadTooLarge {
payload,
available: reported,
deficit,
}) => {
assert_eq!(payload, available + 1);
assert_eq!(reported, available);
assert_eq!(deficit, 1);
}
Ok(()) => panic!("one byte over the limit must be refused"),
}
}
#[test]
fn the_refusal_leaks_no_parameter() {
let message = SizerError::PayloadTooLarge {
payload: 4_242,
available: 1_337,
deficit: 2_905,
}
.to_string();
assert!(message.contains("shorten the message"));
for leak in [
"4242", "1337", "2905", "bpp", "0.02", "byte", "capacity", "pixel",
] {
assert!(
!message.contains(leak),
"the message must not expose {leak:?}: {message}"
);
}
}
}