#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod error;
pub mod traits;
pub mod types;
pub(crate) mod jpeg_transcoder;
pub(crate) mod protected;
pub(crate) mod util;
#[cfg(feature = "async")]
pub mod async_api;
pub use error::{Error, Result};
pub use types::{
DmiValue, ImageOutputFormat, LegalMetadata, ProtectionConfig, ProtectionContext,
ProtectionLevel, ProtectionWarning, VerificationResult, VerificationStatus,
DEFAULT_OUTPUT_FORMAT,
};
pub use traits::Protector;
pub use protected::metadata_trap::MetadataTrapProtector;
pub use protected::passthrough::PassthroughProtector;
pub use protected::steganography::{SteganographyProtector, StegoPayload};
pub use jpeg_transcoder::is_progressive_jpeg;
#[cfg(feature = "fuzz")]
pub fn parse_jpeg_for_fuzz(
data: &[u8],
) -> std::result::Result<
(jpeg_transcoder::JpegHeader, jpeg_transcoder::Coefficients),
jpeg_transcoder::TranscoderError,
> {
jpeg_transcoder::JpegTranscoder::decode_coefficients(data)
}
pub use util::image::{
compute_image_hash, detect_image_format, encode_image, encode_image_with_options,
load_image_from_bytes,
};
pub use util::iscc::{
compute_iscc, compute_iscc_from_bytes, compute_iscc_from_bytes_with_metadata,
compute_iscc_with_metadata, Iscc,
};
pub use util::seed::generate_random_seed;
#[cfg(feature = "async")]
pub use async_api::{
process_image_async, process_image_bytes_async, process_image_bytes_with_warnings_async,
process_images_bytes_parallel_async, process_images_parallel_async, verify_image_bytes_async,
};
use image::DynamicImage;
use image::GenericImageView;
use std::borrow::Cow;
use std::sync::Arc;
use std::sync::LazyLock;
static DEFAULT_PIPELINE: LazyLock<ProtectionPipeline> = LazyLock::new(ProtectionPipeline::new);
#[non_exhaustive]
pub struct ProtectionPipeline {
passthrough: Arc<PassthroughProtector>,
metadata_trap: Arc<MetadataTrapProtector>,
steganography: Arc<SteganographyProtector>,
}
impl ProtectionPipeline {
pub fn new() -> Self {
Self {
passthrough: Arc::new(PassthroughProtector::new()),
metadata_trap: Arc::new(MetadataTrapProtector::new()),
steganography: Arc::new(SteganographyProtector::new()),
}
}
fn validate_dimensions(img: &DynamicImage, max_dim: Option<u32>) -> Result<()> {
if let Some(max) = max_dim {
let (width, height) = img.dimensions();
if width > max || height > max {
return Err(Error::ImageDecode(format!(
"Image dimensions {}x{} exceed maximum allowed {}",
width, height, max
)));
}
}
Ok(())
}
pub fn process<'a>(
&'a self,
img: &'a DynamicImage,
level: ProtectionLevel,
ctx: &ProtectionContext,
) -> Result<Cow<'a, DynamicImage>> {
Self::validate_dimensions(img, ctx.max_dimension())?;
let mut ctx_with_level = ctx.clone();
ctx_with_level.set_protection_level(level);
let ctx = &ctx_with_level;
match level {
ProtectionLevel::Disabled => self.passthrough.apply(img, ctx),
ProtectionLevel::Light => self.apply_light_bytes(img, ctx).map(Cow::Owned),
ProtectionLevel::Standard => self.apply_standard_pipeline(img, ctx).map(Cow::Owned),
}
}
fn apply_standard_pipeline(
&self,
img: &DynamicImage,
ctx: &ProtectionContext,
) -> Result<DynamicImage> {
let output_format = ctx
.output_format()
.or(ctx.input_format())
.unwrap_or(crate::types::DEFAULT_OUTPUT_FORMAT);
let final_bytes = self.apply_pipeline_bytes(img, ctx, output_format)?;
Ok(image::load_from_memory(&final_bytes)?)
}
fn apply_pipeline_bytes(
&self,
img: &DynamicImage,
ctx: &ProtectionContext,
output_format: crate::types::ImageOutputFormat,
) -> Result<Vec<u8>> {
if output_format == crate::types::ImageOutputFormat::Jpeg {
let jpeg_bytes = crate::util::image::encode_image_with_options(
img,
Some(output_format),
ctx.progressive_jpeg(),
ctx.jpeg_quality(),
)?;
let with_stego = self.steganography.apply_dct_stego_bytes(&jpeg_bytes, ctx)?;
return self.metadata_trap.inject_bytes(&with_stego, ctx);
}
let with_stego = self.steganography.apply(img, ctx)?;
let encoded = crate::util::image::encode_image_with_options(
&with_stego,
Some(output_format),
ctx.progressive_jpeg(),
ctx.jpeg_quality(),
)?;
self.metadata_trap.inject_bytes(&encoded, ctx)
}
fn apply_light_bytes(
&self,
img: &DynamicImage,
ctx: &ProtectionContext,
) -> Result<DynamicImage> {
let output_format = ctx
.output_format()
.or(ctx.input_format())
.unwrap_or(crate::types::DEFAULT_OUTPUT_FORMAT);
match output_format {
crate::types::ImageOutputFormat::Jpeg => {
let encoded =
crate::util::image::encode_image(img, output_format.to_image_format())?;
let with_metadata = self.metadata_trap.inject_bytes(&encoded, ctx)?;
let with_stego = self
.steganography
.apply_qtable_seed_bytes(&with_metadata, ctx.seed())?;
Ok(image::load_from_memory(&with_stego)?)
}
_ => {
let mut minimal_ctx = ctx.clone();
minimal_ctx.set_protection_level(crate::types::ProtectionLevel::Light);
let stego_img = self.steganography.embed_lsb_minimal(img, &minimal_ctx);
let encoded =
crate::util::image::encode_image(&stego_img, output_format.to_image_format())?;
let with_metadata = self.metadata_trap.inject_bytes(&encoded, ctx)?;
Ok(image::load_from_memory(&with_metadata)?)
}
}
}
pub fn process_bytes(
&self,
img_bytes: &[u8],
level: ProtectionLevel,
ctx: &ProtectionContext,
) -> Result<Vec<u8>> {
let mut ctx_with_level = ctx.clone();
ctx_with_level.set_protection_level(level);
match level {
ProtectionLevel::Disabled => Ok(img_bytes.to_vec()),
ProtectionLevel::Light => {
let format = ctx_with_level
.output_format()
.or(ctx_with_level.input_format())
.unwrap_or_else(|| {
crate::types::ImageOutputFormat::from_magic_bytes(img_bytes)
.unwrap_or(crate::types::DEFAULT_OUTPUT_FORMAT)
});
match format {
crate::types::ImageOutputFormat::Jpeg => {
let with_metadata =
self.metadata_trap.apply_bytes(img_bytes, &ctx_with_level)?;
self.steganography
.apply_qtable_seed_bytes(&with_metadata, ctx_with_level.seed())
}
_ => {
let mut minimal_ctx = ctx_with_level.clone();
minimal_ctx.set_protection_level(crate::types::ProtectionLevel::Light);
let img = image::load_from_memory(img_bytes)?;
let stego_img = self.steganography.embed_lsb_minimal(&img, &minimal_ctx);
let encoded =
crate::util::image::encode_image(&stego_img, format.to_image_format())?;
self.metadata_trap.apply_bytes(&encoded, &ctx_with_level)
}
}
}
ProtectionLevel::Standard => self.apply_bytes_pipeline(img_bytes, &ctx_with_level),
}
}
fn validate_jpeg_dimensions_from_bytes(img_bytes: &[u8], max_dim: Option<u32>) -> Result<()> {
if let Some(max) = max_dim {
let header = jpeg_transcoder::header::JpegHeader::parse(img_bytes)?;
if header.width as u32 > max || header.height as u32 > max {
return Err(Error::ImageDecode(format!(
"Image dimensions {}x{} exceed maximum allowed {}",
header.width, header.height, max
)));
}
}
Ok(())
}
fn apply_bytes_pipeline(&self, img_bytes: &[u8], ctx: &ProtectionContext) -> Result<Vec<u8>> {
let input_format = ctx
.input_format()
.or_else(|| crate::types::ImageOutputFormat::from_magic_bytes(img_bytes))
.ok_or_else(|| Error::InvalidFormat("Unrecognized image format".to_string()))?;
let output_format = ctx
.output_format()
.or(ctx.input_format())
.unwrap_or(crate::types::DEFAULT_OUTPUT_FORMAT);
if input_format == crate::types::ImageOutputFormat::Jpeg
&& output_format == crate::types::ImageOutputFormat::Jpeg
{
Self::validate_jpeg_dimensions_from_bytes(img_bytes, ctx.max_dimension())?;
let with_stego = self.steganography.apply_dct_stego_bytes(img_bytes, ctx)?;
return self.metadata_trap.inject_bytes(&with_stego, ctx);
}
let img = load_image_from_bytes(img_bytes)?;
Self::validate_dimensions(&img, ctx.max_dimension())?;
self.apply_pipeline_bytes(&img, ctx, output_format)
}
}
impl Default for ProtectionPipeline {
fn default() -> Self {
Self::new()
}
}
#[must_use = "the protected image should be saved or used"]
pub fn process_image(
img: DynamicImage,
level: ProtectionLevel,
ctx: &ProtectionContext,
) -> Result<DynamicImage> {
DEFAULT_PIPELINE
.process(&img, level, ctx)
.map(|c| c.into_owned())
}
#[must_use = "the protected images should be saved or used"]
pub fn process_images_parallel(
images: &[DynamicImage],
level: ProtectionLevel,
ctx: &ProtectionContext,
) -> Result<Vec<DynamicImage>> {
use rayon::prelude::*;
images
.par_iter()
.map(|img| {
DEFAULT_PIPELINE
.process(img, level, ctx)
.map(|c| c.into_owned())
})
.collect()
}
#[must_use = "the protected image bytes should be saved or used"]
pub fn process_images_bytes_parallel(
images: &[Vec<u8>],
level: ProtectionLevel,
ctx: &ProtectionContext,
) -> Result<Vec<Vec<u8>>> {
use rayon::prelude::*;
images
.par_iter()
.map(|img_bytes| DEFAULT_PIPELINE.process_bytes(img_bytes, level, ctx))
.collect()
}
#[must_use = "the protected image bytes should be saved or used"]
pub fn process_image_bytes(
img_bytes: &[u8],
level: ProtectionLevel,
ctx: &ProtectionContext,
) -> Result<Vec<u8>> {
let format = ImageOutputFormat::from_magic_bytes(img_bytes)
.ok_or_else(|| Error::InvalidFormat("Unrecognized image format".to_string()))?;
let ctx_with_format = {
let mut ctx = ctx.clone();
if ctx.input_format().is_none() {
ctx.set_input_format(format);
}
ctx
};
DEFAULT_PIPELINE.process_bytes(img_bytes, level, &ctx_with_format)
}
pub fn process_image_bytes_with_info(
img_bytes: &[u8],
level: ProtectionLevel,
ctx: &ProtectionContext,
) -> Result<(Vec<u8>, Option<ProtectionWarning>)> {
let (bytes, warnings) = process_image_bytes_with_warnings(img_bytes, level, ctx)?;
let warning = warnings
.into_iter()
.find(|w| matches!(w, ProtectionWarning::ProgressiveJpegFallback));
Ok((bytes, warning))
}
pub fn process_image_bytes_with_warnings(
img_bytes: &[u8],
level: ProtectionLevel,
ctx: &ProtectionContext,
) -> Result<(Vec<u8>, Vec<ProtectionWarning>)> {
let format = ImageOutputFormat::from_magic_bytes(img_bytes)
.ok_or_else(|| Error::InvalidFormat("Unrecognized image format".to_string()))?;
let ctx_with_format = {
let mut ctx = ctx.clone();
if ctx.input_format().is_none() {
ctx.set_input_format(format);
}
ctx
};
let mut warnings = Vec::new();
if level != ProtectionLevel::Disabled && ctx_with_format.mac_key().is_none() {
warnings.push(ProtectionWarning::MissingMacKey);
}
if matches!(ctx_with_format.inject_metadata(), Some(false)) {
warnings.push(ProtectionWarning::MetadataInjectionDisabled);
}
let output_format = ctx_with_format
.output_format()
.or(ctx_with_format.input_format())
.unwrap_or(DEFAULT_OUTPUT_FORMAT);
if level == ProtectionLevel::Standard
&& ctx_with_format.progressive_jpeg()
&& output_format == ImageOutputFormat::Jpeg
{
warnings.push(ProtectionWarning::ProgressiveJpegFallback);
}
if level == ProtectionLevel::Standard
&& format == ImageOutputFormat::Jpeg
&& is_progressive_jpeg(img_bytes)
{
warnings.push(ProtectionWarning::ProgressiveJpegFallback);
}
if level != ProtectionLevel::Disabled && output_format == ImageOutputFormat::Jpeg {
warnings.push(ProtectionWarning::JpegReencodeFragile);
}
if level != ProtectionLevel::Disabled && output_format == ImageOutputFormat::WebP {
warnings.push(ProtectionWarning::WebpLossyReencodeDestructive);
}
if level == ProtectionLevel::Standard
&& matches!(
output_format,
ImageOutputFormat::Png | ImageOutputFormat::WebP
)
{
if let Ok(img) = image::load_from_memory(img_bytes) {
let (w, h) = img.dimensions();
let total_pixels = (w as usize) * (h as usize);
let payload_bits: usize = 800;
let pixels_needed =
payload_bits.div_ceil(3) * crate::protected::constants::STEGO_SPREAD_FACTOR;
if total_pixels < pixels_needed {
warnings.push(ProtectionWarning::LsbCapacitySkipped);
}
}
}
let result = DEFAULT_PIPELINE.process_bytes(img_bytes, level, &ctx_with_format)?;
if level == ProtectionLevel::Standard && format == ImageOutputFormat::Jpeg {
let size_delta = (result.len() as i64 - img_bytes.len() as i64).unsigned_abs();
if size_delta * 10 < img_bytes.len() as u64 / 20 {
if !is_progressive_jpeg(img_bytes) {
warnings.push(ProtectionWarning::DctCapacityInsufficient);
}
}
}
Ok((result, warnings))
}
pub fn verify_image_bytes(img_bytes: &[u8], mac_key: &[u8]) -> VerificationStatus {
let stego = SteganographyProtector::new();
stego.verify_payload_from_bytes_with_key(img_bytes, mac_key)
}
pub fn verify_image_bytes_detailed(img_bytes: &[u8], mac_key: &[u8]) -> VerificationResult {
let stego = SteganographyProtector::new();
match stego.verify_payload_from_bytes_with_key(img_bytes, mac_key) {
VerificationStatus::Verified => {
if let Some(payload) = stego.extract_payload_from_bytes_with_key(img_bytes, mac_key) {
return VerificationResult::Verified { payload };
}
return VerificationResult::NotFound;
}
VerificationStatus::Invalid => {
if let Some(payload) = stego.extract_payload_from_bytes_with_key(img_bytes, mac_key) {
return VerificationResult::Corrupted { payload };
}
return VerificationResult::NotFound;
}
VerificationStatus::NotFound => {}
}
if let Some(seed) = MetadataTrapProtector::extract_seed_from_image(img_bytes) {
return VerificationResult::MetadataOnly { seed };
}
VerificationResult::NotFound
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pipeline_disabled() {
let pipeline = ProtectionPipeline::new();
let img = DynamicImage::new_rgb8(10, 10);
let result = pipeline.process(
&img,
ProtectionLevel::Disabled,
&ProtectionContext::default(),
);
assert!(result.is_ok());
}
#[test]
fn test_pipeline_all_levels() {
let pipeline = ProtectionPipeline::new();
let img = DynamicImage::new_rgb8(10, 10);
for level in &[
ProtectionLevel::Disabled,
ProtectionLevel::Light,
ProtectionLevel::Standard,
] {
let result = pipeline.process(&img, *level, &ProtectionContext::default());
assert!(result.is_ok(), "Failed for level: {:?}", level);
}
}
#[test]
fn test_process_image_bytes() {
use image::ImageEncoder;
let img = DynamicImage::new_rgb8(10, 10);
let mut buffer = Vec::new();
{
let encoder = image::codecs::png::PngEncoder::new(&mut buffer);
let rgb = img.to_rgb8();
encoder
.write_image(&rgb, 10, 10, image::ExtendedColorType::Rgb8)
.unwrap();
}
let result = process_image_bytes(
&buffer,
ProtectionLevel::Standard,
&ProtectionContext::default(),
);
assert!(result.is_ok());
}
#[test]
fn test_end_to_end_protection_verification() {
let pipeline = ProtectionPipeline::new();
let img = DynamicImage::new_rgb8(64, 64);
let ctx = ProtectionContext::default()
.with_seed(42)
.with_intensity(0.5);
let protected = pipeline
.process(&img, ProtectionLevel::Standard, &ctx)
.unwrap();
let stego = SteganographyProtector::new();
let verified = stego.verify_payload(&protected);
assert!(verified, "Payload should be verified after protection");
}
#[test]
fn test_process_bytes_and_verify() {
use image::ImageEncoder;
let img = DynamicImage::new_rgb8(32, 32);
let mut input_bytes = Vec::new();
{
let encoder = image::codecs::png::PngEncoder::new(&mut input_bytes);
let rgb = img.to_rgb8();
encoder
.write_image(&rgb, 32, 32, image::ExtendedColorType::Rgb8)
.unwrap();
}
let ctx = ProtectionContext::default()
.with_seed(12345)
.with_intensity(0.7);
let protected_bytes =
process_image_bytes(&input_bytes, ProtectionLevel::Standard, &ctx).unwrap();
assert!(!protected_bytes.is_empty());
assert!(
protected_bytes.len() != input_bytes.len() || ctx.intensity() == 0.0,
"Protected bytes should differ from input at intensity {}",
ctx.intensity()
);
}
#[test]
fn test_different_seeds_different_output() {
let pipeline = ProtectionPipeline::new();
let img = DynamicImage::new_rgb8(32, 32);
let ctx1 = ProtectionContext::default().with_seed(42);
let ctx2 = ProtectionContext::default().with_seed(99);
let result1 = pipeline
.process(&img, ProtectionLevel::Standard, &ctx1)
.unwrap();
let result2 = pipeline
.process(&img, ProtectionLevel::Standard, &ctx2)
.unwrap();
let rgba1 = result1.to_rgba8();
let rgba2 = result2.to_rgba8();
assert_ne!(
rgba1.as_raw(),
rgba2.as_raw(),
"Different seeds should produce different output"
);
}
#[test]
fn test_parallel_processing() {
let images: Vec<DynamicImage> = (0..4).map(|_| DynamicImage::new_rgb8(16, 16)).collect();
let ctx = ProtectionContext::default().with_seed(42);
let results = process_images_parallel(&images, ProtectionLevel::Standard, &ctx).unwrap();
assert_eq!(results.len(), 4);
}
#[test]
fn test_metadata_extraction() {
let img = DynamicImage::new_rgb8(32, 32);
let ctx = ProtectionContext::default()
.with_seed(42)
.with_format(ImageOutputFormat::Png);
let metadata_protector = MetadataTrapProtector::new();
let encoded = crate::util::image::encode_image(&img, image::ImageFormat::Png).unwrap();
let protected_bytes = metadata_protector.apply_bytes(&encoded, &ctx).unwrap();
let seed = MetadataTrapProtector::extract_seed_from_image(&protected_bytes);
assert!(
seed.is_some(),
"Seed should be extractable from protected image"
);
}
#[test]
fn test_intensity_zero_no_change() {
let pipeline = ProtectionPipeline::new();
let img = DynamicImage::new_rgb8(16, 16);
let ctx = ProtectionContext::default()
.with_seed(42)
.with_intensity(0.0);
let result = pipeline
.process(&img, ProtectionLevel::Disabled, &ctx)
.unwrap();
let original_bytes = img.to_rgba8();
let result_bytes = result.to_rgba8();
assert_eq!(original_bytes.as_raw(), result_bytes.as_raw());
}
#[test]
fn test_max_dimension_validation() {
let pipeline = ProtectionPipeline::new();
let img = DynamicImage::new_rgb8(1000, 1000);
let ctx = ProtectionContext::default().with_max_dimension(512);
let result = pipeline.process(&img, ProtectionLevel::Standard, &ctx);
assert!(
result.is_err(),
"Should fail when image exceeds max dimension"
);
}
#[test]
fn test_max_dimension_validation_process_bytes() {
use image::ImageEncoder;
let img = DynamicImage::new_rgb8(1000, 1000);
let mut buffer = Vec::new();
{
let encoder = image::codecs::png::PngEncoder::new(&mut buffer);
let rgb = img.to_rgb8();
encoder
.write_image(&rgb, 1000, 1000, image::ExtendedColorType::Rgb8)
.unwrap();
}
let ctx = ProtectionContext::default().with_max_dimension(512);
let result = process_image_bytes(&buffer, ProtectionLevel::Standard, &ctx);
assert!(
result.is_err(),
"Should fail when image exceeds max dimension via process_bytes"
);
}
}