use std::path::Path;
use image::{DynamicImage, ImageFormat, Luma, Rgb, Rgba};
use crate::image_io::buffer::{ColorSpace, CoverSource, ImageBuffer};
use crate::pipeline::error::OutputError;
pub(crate) const LENGTH_HEADER_BYTES: usize = 4;
pub(crate) const LENGTH_HEADER_BITS: usize = LENGTH_HEADER_BYTES * 8;
const POSITIONS_PER_HEADER_BIT: usize = 64;
pub(crate) const HEADER_POSITIONS: usize = LENGTH_HEADER_BITS * POSITIONS_PER_HEADER_BIT;
pub(crate) const FRAME_OVERHEAD_BYTES: usize = LENGTH_HEADER_BYTES + 4;
pub(crate) const MIN_CIPHERTEXT_BYTES: usize = 16;
pub(crate) fn split_regions<T>(positions: &[T]) -> (&[T], &[T]) {
positions.split_at(HEADER_POSITIONS.min(positions.len()))
}
pub(crate) fn split_regions_mut<T>(positions: &mut [T]) -> (&mut [T], &mut [T]) {
let boundary = HEADER_POSITIONS.min(positions.len());
positions.split_at_mut(boundary)
}
fn carrier_offset(index: usize, color_space: ColorSpace) -> usize {
index * color_space.bytes_per_pixel()
}
pub(crate) fn gather_cover_symbols(image: &ImageBuffer, permutation: &[usize]) -> Vec<u8> {
let color_space = image.color_space();
let pixels = image.pixels();
permutation
.iter()
.map(|&index| {
pixels
.get(carrier_offset(index, color_space))
.copied()
.unwrap_or(0)
})
.collect()
}
pub(crate) fn apply_cover_symbols(image: &mut ImageBuffer, permutation: &[usize], symbols: &[u8]) {
let color_space = image.color_space();
let pixels = image.pixels_mut();
for (&index, &symbol) in permutation.iter().zip(symbols.iter()) {
if let Some(sample) = pixels.get_mut(carrier_offset(index, color_space)) {
*sample = symbol;
}
}
}
pub(crate) fn reorder_costs(costs: &[f32], permutation: &[usize]) -> Vec<f32> {
permutation
.iter()
.map(|&index| costs.get(index).copied().unwrap_or(0.0))
.collect()
}
pub(crate) fn encode_length_header(ciphertext_len: usize) -> Option<[u8; LENGTH_HEADER_BYTES]> {
u32::try_from(ciphertext_len).ok().map(u32::to_be_bytes)
}
pub(crate) fn decode_length_header(header: &[u8]) -> Option<usize> {
let bytes: [u8; LENGTH_HEADER_BYTES] = header.get(..LENGTH_HEADER_BYTES)?.try_into().ok()?;
Some(u32::from_be_bytes(bytes) as usize)
}
pub(crate) fn write_png(image: &ImageBuffer, path: &Path) -> Result<(), OutputError> {
let (width, height) = image.dimensions();
let pixels = image.pixels();
let encoded = match image.color_space() {
ColorSpace::Rgb8 => {
image::ImageBuffer::<Rgb<u8>, Vec<u8>>::from_raw(width, height, pixels.to_vec())
.map(DynamicImage::ImageRgb8)
}
ColorSpace::Rgba8 => {
image::ImageBuffer::<Rgba<u8>, Vec<u8>>::from_raw(width, height, pixels.to_vec())
.map(DynamicImage::ImageRgba8)
}
ColorSpace::Luma8 => {
image::ImageBuffer::<Luma<u8>, Vec<u8>>::from_raw(width, height, pixels.to_vec())
.map(DynamicImage::ImageLuma8)
}
ColorSpace::Rgb16 => {
let samples: Vec<u16> = pixels
.chunks_exact(2)
.map(|pair| match pair {
[low, high] => u16::from_le_bytes([*low, *high]),
_ => 0,
})
.collect();
image::ImageBuffer::<Rgb<u16>, Vec<u16>>::from_raw(width, height, samples)
.map(DynamicImage::ImageRgb16)
}
};
let Some(encoded) = encoded else {
return Err(OutputError::MalformedBuffer);
};
encoded
.save_with_format(path, ImageFormat::Png)
.map_err(|err| OutputError::EncodingFailed(err.to_string()))
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
#![allow(clippy::panic)]
use super::*;
use tempfile::NamedTempFile;
const WIDTH: u32 = 5;
const HEIGHT: u32 = 4;
fn container(color_space: ColorSpace) -> ImageBuffer {
let stride = color_space.bytes_per_pixel();
let pixel_count = (WIDTH * HEIGHT) as usize;
let pixels = (0..pixel_count * stride)
.map(|offset| {
if offset % stride == 0 {
(offset / stride) as u8
} else {
0xEE
}
})
.collect();
ImageBuffer::new(pixels, WIDTH, HEIGHT, color_space)
}
#[test]
fn the_frame_is_cut_at_a_constant_boundary() {
let long: Vec<u8> = vec![0; HEADER_POSITIONS + 17];
let (header, payload) = split_regions(&long);
assert_eq!(header.len(), HEADER_POSITIONS);
assert_eq!(payload.len(), 17);
let mut short = vec![0u8; 9];
let (header, payload) = split_regions_mut(&mut short);
assert_eq!(header.len(), 9);
assert!(payload.is_empty());
}
#[test]
fn cover_symbols_round_trip_through_the_permutation() {
for color_space in [
ColorSpace::Rgb8,
ColorSpace::Rgba8,
ColorSpace::Luma8,
ColorSpace::Rgb16,
] {
let mut image = container(color_space);
let permutation = vec![3usize, 0, 7, 1];
let gathered = gather_cover_symbols(&image, &permutation);
assert_eq!(gathered, vec![3u8, 0, 7, 1], "layout {color_space:?}");
let written: Vec<u8> = gathered.iter().map(|symbol| symbol ^ 1).collect();
apply_cover_symbols(&mut image, &permutation, &written);
assert_eq!(
gather_cover_symbols(&image, &permutation),
written,
"layout {color_space:?}"
);
let stride = color_space.bytes_per_pixel();
assert!(image
.pixels()
.iter()
.enumerate()
.filter(|(offset, _)| offset % stride != 0)
.all(|(_, &sample)| sample == 0xEE));
}
}
#[test]
fn positions_outside_the_container_are_inert() {
let mut image = container(ColorSpace::Rgb8);
let permutation = vec![0usize, 9_999];
assert_eq!(gather_cover_symbols(&image, &permutation), vec![0u8, 0]);
let before = image.pixels().to_vec();
apply_cover_symbols(&mut image, &permutation, &[0, 42]);
assert_eq!(image.pixels(), before.as_slice());
}
#[test]
fn costs_follow_the_permutation() {
let costs = [0.5f32, 1.5, 2.5, 3.5];
assert_eq!(
reorder_costs(&costs, &[2, 0, 3, 1]),
vec![2.5f32, 0.5, 3.5, 1.5]
);
assert_eq!(reorder_costs(&costs, &[9]), vec![0.0f32]);
}
#[test]
fn the_length_header_round_trips() {
let header = encode_length_header(0x0102_0304).expect("a small length must encode");
assert_eq!(header, [0x01, 0x02, 0x03, 0x04]);
assert_eq!(decode_length_header(&header), Some(0x0102_0304));
assert_eq!(decode_length_header(&[0, 0, 0, 7, 9, 9]), Some(7));
assert_eq!(decode_length_header(&[0, 0, 7]), None);
}
#[test]
fn an_unrepresentable_length_does_not_encode() {
assert!(encode_length_header(u32::MAX as usize).is_some());
assert!(encode_length_header(u32::MAX as usize + 1).is_none());
}
#[test]
fn every_layout_survives_a_write_and_a_read() {
for color_space in [
ColorSpace::Rgb8,
ColorSpace::Rgba8,
ColorSpace::Luma8,
ColorSpace::Rgb16,
] {
let image = container(color_space);
let file = NamedTempFile::new().expect("temporary stego file");
if let Err(error) = write_png(&image, file.path()) {
panic!("a {color_space:?} container must be writable: {error}");
}
let bytes = std::fs::read(file.path()).expect("the written file must be readable");
assert_eq!(&bytes[..4], &[0x89, b'P', b'N', b'G']);
let decoded = image::load_from_memory_with_format(&bytes, ImageFormat::Png)
.expect("the written png must decode");
assert_eq!(decoded.width(), WIDTH);
assert_eq!(decoded.height(), HEIGHT);
}
}
#[test]
fn a_buffer_that_belies_its_geometry_is_refused() {
let image = ImageBuffer::new(vec![0u8; 5], WIDTH, HEIGHT, ColorSpace::Rgb8);
let file = NamedTempFile::new().expect("temporary stego file");
let error = write_png(&image, file.path()).expect_err("a short buffer must not be encoded");
assert!(
matches!(error, OutputError::MalformedBuffer),
"got: {error:?}"
);
}
#[test]
fn an_unwritable_path_is_reported() {
let image = container(ColorSpace::Rgb8);
let directory = tempfile::tempdir().expect("temporary directory");
let error = write_png(
&image,
&directory.path().join("no-such-dir").join("out.png"),
)
.expect_err("a path under a missing directory must fail");
assert!(
matches!(error, OutputError::EncodingFailed(_)),
"got: {error:?}"
);
}
}