#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod conformance;
pub mod error;
pub mod payload_v3;
pub mod provenance;
pub mod resource_limits;
pub mod traits;
#[allow(deprecated)]
pub mod types;
pub mod verification;
pub(crate) mod jpeg_transcoder;
pub(crate) mod protected;
pub(crate) mod util;
#[cfg(feature = "async")]
pub mod async_api;
#[cfg(feature = "signatures")]
#[cfg_attr(docsrs, doc(cfg(feature = "signatures")))]
pub mod signing;
#[cfg(feature = "detached-manifest")]
#[cfg_attr(docsrs, doc(cfg(feature = "detached-manifest")))]
pub mod detached;
pub use error::{Error, Result};
pub use resource_limits::{ResourceLimits, ResourceUsage};
pub use types::{AuthenticationMode, HiddenMarkerMode, ProcessingOptions};
#[allow(deprecated)]
pub use types::{
DmiValue, EvidenceChannel, EvidenceProfile, EvidenceStrength, ExecutionReport,
ImageOutputFormat, LegalMetadata, LocalizedText, MetadataUpdatePolicy, NoticeVerification,
NoticeVerificationBuilder, ProtectionChannels, ProtectionConfig, ProtectionContext,
ProtectionLevel, ProtectionPreset, ProtectionRequest, ProtectionWarning,
ResolvedProtectionPlan, RightsNotice, RightsPolicy, RightsSignalKind, VerificationResult,
VerificationStatus, WarningCategory, WarningSeverity, DEFAULT_OUTPUT_FORMAT,
PLUS_DATA_MINING_PROPERTY, PLUS_NAMESPACE,
};
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,
};
#[allow(deprecated)]
pub use util::iscc::{
compute_content_identifiers, compute_content_identifiers_from_bytes,
compute_content_identifiers_from_bytes_with_metadata,
compute_content_identifiers_with_metadata, compute_iscc, compute_iscc_from_bytes,
compute_iscc_from_bytes_with_metadata, compute_iscc_with_metadata, ContentIdentifiers, 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 Clone for ProtectionPipeline {
fn clone(&self) -> Self {
Self {
passthrough: Arc::clone(&self.passthrough),
metadata_trap: Arc::clone(&self.metadata_trap),
steganography: Arc::clone(&self.steganography),
}
}
}
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 = resolved_output_format(ctx);
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.output(), 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 = resolved_output_format(ctx);
match output_format {
crate::types::ImageOutputFormat::Jpeg => {
let encoded = crate::util::image::encode_image_with_options(
img,
Some(output_format),
ctx.progressive_jpeg(),
ctx.jpeg_quality(),
)?;
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_with_options(
&stego_img,
Some(output_format),
ctx.progressive_jpeg(),
ctx.jpeg_quality(),
)?;
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>> {
if level == ProtectionLevel::Disabled {
return Ok(img_bytes.to_vec());
}
ctx.resource_limits().check_input_size(img_bytes.len())?;
let limits = ctx.resource_limits();
if img_bytes.starts_with(&[0xFF, 0xD8]) {
let header = jpeg_transcoder::header::JpegHeader::parse(img_bytes)?;
limits.check_dimensions(header.width as u32, header.height as u32)?;
} else if let Ok(img) = load_image_from_bytes(img_bytes) {
let (width, height) = img.dimensions();
limits.check_dimensions(width, height)?;
}
let (ctx_with_level, input_format, output_format) =
Self::context_for_bytes(img_bytes, level, ctx)?;
match level {
ProtectionLevel::Disabled => unreachable!("disabled level returned above"),
ProtectionLevel::Light => {
self.validate_input_dimensions_for_bytes(
img_bytes,
input_format,
ctx_with_level.max_dimension(),
&ctx.resource_limits(),
)?;
self.apply_light_bytes_pipeline(
img_bytes,
input_format,
output_format,
&ctx_with_level,
)
}
ProtectionLevel::Standard => self.apply_bytes_pipeline_resolved(
img_bytes,
input_format,
output_format,
&ctx_with_level,
),
}
}
fn input_format_from_bytes(
img_bytes: &[u8],
ctx: &ProtectionContext,
) -> Result<crate::types::ImageOutputFormat> {
ctx.input_format()
.or_else(|| crate::types::ImageOutputFormat::from_magic_bytes(img_bytes))
.ok_or_else(|| Error::InvalidFormat("Unrecognized image format".to_string()))
}
fn output_format_for_bytes(
ctx: &ProtectionContext,
input_format: crate::types::ImageOutputFormat,
) -> crate::types::ImageOutputFormat {
ctx.output_format().unwrap_or(input_format)
}
fn context_for_bytes(
img_bytes: &[u8],
level: ProtectionLevel,
ctx: &ProtectionContext,
) -> Result<(
ProtectionContext,
crate::types::ImageOutputFormat,
crate::types::ImageOutputFormat,
)> {
let mut ctx_with_level = ctx.clone();
ctx_with_level.set_protection_level(level);
let input_format = Self::input_format_from_bytes(img_bytes, &ctx_with_level)?;
if ctx_with_level.input_format().is_none() {
ctx_with_level.set_input_format(input_format);
}
let output_format = Self::output_format_for_bytes(&ctx_with_level, input_format);
Ok((ctx_with_level, input_format, output_format))
}
fn validate_jpeg_dimensions_from_bytes(
img_bytes: &[u8],
max_dim: Option<u32>,
limits: &ResourceLimits,
) -> Result<()> {
if let Some(max) = max_dim {
let header = jpeg_transcoder::header::JpegHeader::parse_with_limits(img_bytes, limits)?;
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 validate_input_dimensions_for_bytes(
&self,
img_bytes: &[u8],
input_format: crate::types::ImageOutputFormat,
max_dim: Option<u32>,
limits: &ResourceLimits,
) -> Result<()> {
if max_dim.is_none() {
return Ok(());
}
if input_format == crate::types::ImageOutputFormat::Jpeg {
Self::validate_jpeg_dimensions_from_bytes(img_bytes, max_dim, limits)
} else {
let img = load_image_from_bytes(img_bytes)?;
Self::validate_dimensions(&img, max_dim)
}
}
fn apply_light_bytes_pipeline(
&self,
img_bytes: &[u8],
input_format: crate::types::ImageOutputFormat,
output_format: crate::types::ImageOutputFormat,
ctx: &ProtectionContext,
) -> Result<Vec<u8>> {
if output_format == crate::types::ImageOutputFormat::Jpeg {
let encoded = if input_format == crate::types::ImageOutputFormat::Jpeg {
img_bytes.to_vec()
} else {
let img = load_image_from_bytes(img_bytes)?;
crate::util::image::encode_image_with_options(
&img,
Some(output_format),
ctx.progressive_jpeg(),
ctx.jpeg_quality(),
)?
};
let with_metadata = self.metadata_trap.apply_bytes(&encoded, ctx)?;
return self
.steganography
.apply_qtable_seed_bytes(&with_metadata, ctx.seed());
}
let mut minimal_ctx = ctx.clone();
minimal_ctx.set_protection_level(crate::types::ProtectionLevel::Light);
let img = load_image_from_bytes(img_bytes)?;
let stego_img = self.steganography.embed_lsb_minimal(&img, &minimal_ctx);
let encoded = crate::util::image::encode_image_with_options(
&stego_img,
Some(output_format),
ctx.progressive_jpeg(),
ctx.jpeg_quality(),
)?;
self.metadata_trap.apply_bytes(&encoded, ctx)
}
fn apply_bytes_pipeline_resolved(
&self,
img_bytes: &[u8],
input_format: crate::types::ImageOutputFormat,
output_format: crate::types::ImageOutputFormat,
ctx: &ProtectionContext,
) -> Result<Vec<u8>> {
if input_format == crate::types::ImageOutputFormat::Jpeg
&& output_format == crate::types::ImageOutputFormat::Jpeg
{
Self::validate_jpeg_dimensions_from_bytes(
img_bytes,
ctx.max_dimension(),
&ctx.resource_limits(),
)?;
let with_stego = self.steganography.apply_dct_stego_bytes(img_bytes, ctx)?;
return self.metadata_trap.inject_bytes(with_stego.output(), ctx);
}
let img = load_image_from_bytes(img_bytes)?;
Self::validate_dimensions(&img, ctx.max_dimension())?;
self.apply_pipeline_bytes(&img, ctx, output_format)
}
fn process_metadata_only(
&self,
img_bytes: &[u8],
plan: &ResolvedProtectionPlan,
) -> Result<Vec<u8>> {
let input_format = plan.input_format();
let output_format = plan.output_format();
if input_format != output_format {
let img = load_image_from_bytes(img_bytes)?;
let ctx = plan_to_context(plan);
return self.metadata_trap.inject_bytes(
&crate::util::image::encode_image_with_options(
&img,
Some(output_format),
plan.processing().progressive_jpeg,
plan.processing().jpeg_quality,
)?,
&ctx,
);
}
let ctx = plan_to_context(plan);
self.metadata_trap.inject_bytes(img_bytes, &ctx)
}
}
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()
}
pub fn resolve_request(
request: &ProtectionRequest,
input_format: ImageOutputFormat,
) -> Result<ResolvedProtectionPlan> {
protected::resolve::resolve_request(request, input_format)
}
#[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>> {
if level != ProtectionLevel::Disabled {
if let Some(meta) = ctx.legal_metadata() {
meta.validate()?;
}
}
DEFAULT_PIPELINE.process_bytes(img_bytes, level, ctx)
}
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().next();
Ok((bytes, warning))
}
#[allow(deprecated)]
pub fn process_image_bytes_with_warnings(
img_bytes: &[u8],
level: ProtectionLevel,
ctx: &ProtectionContext,
) -> Result<(Vec<u8>, Vec<ProtectionWarning>)> {
if level == ProtectionLevel::Disabled {
let result = DEFAULT_PIPELINE.process_bytes(img_bytes, level, ctx)?;
return Ok((result, Vec::new()));
}
if let Some(meta) = ctx.legal_metadata() {
meta.validate()?;
}
let (ctx_with_format, format) = context_with_detected_format(img_bytes, ctx)?;
let mut warnings = Vec::new();
if level != ProtectionLevel::Disabled
&& ctx_with_format.mac_key().is_none()
&& matches!(
ctx_with_format.evidence_profile(),
EvidenceProfile::AuthenticatedProvenance | EvidenceProfile::Maximal
)
{
warnings.push(ProtectionWarning::MissingMacKey);
}
if matches!(ctx_with_format.inject_metadata(), Some(false)) {
warnings.push(ProtectionWarning::MetadataInjectionDisabled);
}
if matches!(ctx_with_format.inject_legal_claims(), Some(false))
&& ctx_with_format
.legal_metadata()
.is_some_and(|m| m.has_content())
{
warnings.push(ProtectionWarning::ContradictoryLegalClaims);
}
let output_format = resolved_output_format(&ctx_with_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::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 pixels_needed = SteganographyProtector::lsb_pixels_needed(&ctx_with_format);
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
&& output_format == ImageOutputFormat::Jpeg
&& !is_progressive_jpeg(img_bytes)
{
let required_bits = dct_required_bits_for_context(&ctx_with_format);
if dct_capacity_bits_from_jpeg(&result) < required_bits {
warnings.push(ProtectionWarning::DctCapacityInsufficient);
}
}
Ok((result, warnings))
}
pub fn process_request_bytes(img_bytes: &[u8], request: &ProtectionRequest) -> Result<Vec<u8>> {
let input_format = ImageOutputFormat::from_magic_bytes(img_bytes)
.ok_or_else(|| Error::InvalidFormat("Unrecognized image format".to_string()))?;
let plan = resolve_request(request, input_format)?;
if let Some(meta) = plan.legal_metadata() {
meta.validate()?;
}
process_plan_bytes(img_bytes, &plan)
}
pub fn process_request_bytes_with_warnings(
img_bytes: &[u8],
request: &ProtectionRequest,
) -> Result<(Vec<u8>, Vec<ProtectionWarning>)> {
let input_format = ImageOutputFormat::from_magic_bytes(img_bytes)
.ok_or_else(|| Error::InvalidFormat("Unrecognized image format".to_string()))?;
let plan = resolve_request(request, input_format)?;
if let Some(meta) = plan.legal_metadata() {
meta.validate()?;
}
let all_warnings = plan.warnings().to_vec();
let result = process_plan_bytes(img_bytes, &plan)?;
Ok((result, all_warnings))
}
pub fn process_request_bytes_with_report(
img_bytes: &[u8],
request: &ProtectionRequest,
) -> Result<(Vec<u8>, ExecutionReport)> {
let input_format = ImageOutputFormat::from_magic_bytes(img_bytes)
.ok_or_else(|| Error::InvalidFormat("Unrecognized image format".to_string()))?;
let plan = resolve_request(request, input_format)?;
if let Some(meta) = plan.legal_metadata() {
meta.validate()?;
}
let warnings = plan.warnings().to_vec();
let output_format = plan.output_format();
let format_transcoded = input_format != output_format;
let result = process_plan_bytes(img_bytes, &plan)?;
let stego_attempted = plan.channels().has_stego();
let stego_succeeded = if stego_attempted {
let had_capacity_warning = warnings.iter().any(|w| {
matches!(
w,
ProtectionWarning::LsbCapacitySkipped | ProtectionWarning::DctCapacityInsufficient
)
});
if had_capacity_warning {
false
} else {
let stego = protected::steganography::SteganographyProtector::new();
let mac_key = plan.mac_key().unwrap_or(&[]);
stego.verify_payload_from_bytes_with_key(&result, mac_key)
== VerificationStatus::Verified
}
} else {
false
};
let metadata_injected = if plan.channels().rights_metadata {
protected::metadata_trap::MetadataTrapProtector::new()
.has_stego_owned_metadata(&result, output_format)
} else {
false
};
let mut resource_usage = crate::resource_limits::ResourceUsage::begin(img_bytes.len());
resource_usage.track_allocation(result.len());
match input_format {
ImageOutputFormat::Png => {
resource_usage.record_png_chunks(count_png_chunks(img_bytes));
}
ImageOutputFormat::Jpeg => {
resource_usage.record_jpeg_segments(count_jpeg_segments(img_bytes));
}
ImageOutputFormat::WebP => {
resource_usage.record_webp_riff_chunks(count_webp_riff_chunks(img_bytes));
}
}
let report = ExecutionReport {
effective_policy: plan.effective_policy(),
effective_dmi: plan.effective_dmi(),
metadata_injected,
stego_attempted,
stego_succeeded,
format_transcoded,
warnings,
resource_usage: Some(resource_usage),
};
Ok((result, report))
}
fn process_plan_bytes(img_bytes: &[u8], plan: &ResolvedProtectionPlan) -> Result<Vec<u8>> {
let pipeline = DEFAULT_PIPELINE.clone();
let limits = plan.resource_limits();
limits.check_input_size(img_bytes.len())?;
if plan.input_format() == ImageOutputFormat::Jpeg {
let header = jpeg_transcoder::header::JpegHeader::parse(img_bytes)?;
limits.check_dimensions(header.width as u32, header.height as u32)?;
} else if let Ok(img) = load_image_from_bytes(img_bytes) {
let (width, height) = img.dimensions();
limits.check_dimensions(width, height)?;
}
if plan.is_metadata_only() {
return pipeline.process_metadata_only(img_bytes, plan);
}
match plan.channels().hidden_marker {
HiddenMarkerMode::Disabled => pipeline.process_metadata_only(img_bytes, plan),
HiddenMarkerMode::BestEffort => {
let level = ProtectionLevel::Standard;
let ctx = plan_to_context(plan);
pipeline.process_bytes(img_bytes, level, &ctx)
}
HiddenMarkerMode::Tiled { tile_size } => {
let mut ctx = plan_to_context(plan);
ctx.set_tile_size(tile_size);
pipeline.process_bytes(img_bytes, ProtectionLevel::Standard, &ctx)
}
}
}
#[allow(deprecated)]
fn plan_to_context(plan: &ResolvedProtectionPlan) -> ProtectionContext {
let mut ctx =
ProtectionContext::new(plan.intensity(), plan.seed()).with_format(plan.output_format());
ctx.set_input_format(plan.input_format());
if let Some(key) = plan.mac_key() {
ctx = ctx.with_mac_key(key.to_vec());
}
ctx = ctx
.with_jpeg_quality(plan.processing().jpeg_quality)
.with_metadata_update_policy(plan.processing().metadata_update_policy);
if plan.processing().progressive_jpeg {
ctx = ctx.with_progressive_jpeg(true);
}
if let Some(max_dim) = plan.processing().max_dimension {
ctx = ctx.with_max_dimension(max_dim);
}
if let Some(meta) = plan.legal_metadata() {
ctx = ctx.with_legal_metadata(meta.clone());
}
if plan.channels().rights_metadata {
ctx = ctx.with_metadata_injection(true);
} else {
ctx = ctx.with_metadata_injection(false);
}
if let Some(dmi) = plan.effective_dmi() {
ctx = ctx.with_dmi(dmi);
}
ctx = ctx.with_resource_limits(plan.resource_limits().clone());
ctx
}
fn context_with_detected_format(
img_bytes: &[u8],
ctx: &ProtectionContext,
) -> Result<(ProtectionContext, ImageOutputFormat)> {
let format = ImageOutputFormat::from_magic_bytes(img_bytes)
.ok_or_else(|| Error::InvalidFormat("Unrecognized image format".to_string()))?;
let mut ctx_with_format = ctx.clone();
if ctx_with_format.input_format().is_none() {
ctx_with_format.set_input_format(format);
}
Ok((ctx_with_format, format))
}
fn resolved_output_format(ctx: &ProtectionContext) -> ImageOutputFormat {
ctx.output_format()
.or(ctx.input_format())
.unwrap_or(DEFAULT_OUTPUT_FORMAT)
}
fn dct_required_bits_for_context(ctx: &ProtectionContext) -> usize {
let payload_bits = if ctx.mac_key().is_some() {
protected::steganography::V3_HMAC_PAYLOAD_BITS
} else {
protected::steganography::V3_CRC_PAYLOAD_BITS
};
payload_bits * ctx.effective_redundancy()
}
fn dct_capacity_bits_from_jpeg(jpeg_bytes: &[u8]) -> usize {
match jpeg_transcoder::JpegTranscoder::decode_coefficients(jpeg_bytes) {
Ok((_, coefficients)) => {
protected::steganography::SteganographyProtector::dct_payload_capacity(&coefficients)
}
Err(_) => 0,
}
}
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_with_limits(
img_bytes: &[u8],
mac_key: &[u8],
limits: &ResourceLimits,
) -> VerificationStatus {
let stego = SteganographyProtector::with_resource_limits(limits.clone());
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 };
}
}
VerificationStatus::NotFound => {}
}
if let Some(seed) = MetadataTrapProtector::extract_seed_from_image(img_bytes) {
return VerificationResult::MetadataOnly { seed };
}
VerificationResult::NotFound
}
pub fn verify_image_bytes_detailed_with_limits(
img_bytes: &[u8],
mac_key: &[u8],
limits: &ResourceLimits,
) -> VerificationResult {
let stego = SteganographyProtector::with_resource_limits(limits.clone());
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 };
}
}
VerificationStatus::NotFound => {}
}
if let Some(seed) = MetadataTrapProtector::extract_seed_from_image(img_bytes) {
return VerificationResult::MetadataOnly { seed };
}
VerificationResult::NotFound
}
pub fn verify_legal_notice(img_bytes: &[u8], mac_key: &[u8]) -> NoticeVerification {
protected::notice_verification::verify_notice_metadata(img_bytes, mac_key)
}
pub fn verify_legal_notice_with_limits(
img_bytes: &[u8],
mac_key: &[u8],
limits: &ResourceLimits,
) -> NoticeVerification {
protected::notice_verification::verify_notice_metadata_with_limits(img_bytes, mac_key, limits)
}
fn count_png_chunks(data: &[u8]) -> usize {
if data.len() < 8 || data[..8] != [137, 80, 78, 71, 13, 10, 26, 10] {
return 0;
}
let mut count = 0;
let mut pos = 8;
while pos + 8 <= data.len() {
let _length = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
count += 1;
pos += 12;
if pos > data.len() {
break;
}
}
count
}
fn count_jpeg_segments(data: &[u8]) -> usize {
if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 {
return 0;
}
let mut count = 0;
let mut pos = 2;
while pos + 1 < data.len() {
if data[pos] != 0xFF {
break;
}
let marker = data[pos + 1];
if marker == 0xD9 || marker == 0xDA {
count += 1;
break;
}
count += 1;
if pos + 3 >= data.len() {
break;
}
let seg_len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize;
if seg_len < 2 {
break;
}
pos += 2 + seg_len;
}
count
}
fn count_webp_riff_chunks(data: &[u8]) -> usize {
if data.len() < 12 || &data[0..4] != b"RIFF" || &data[8..12] != b"WEBP" {
return 0;
}
let mut count = 0;
let mut pos = 12;
while pos + 8 <= data.len() {
count += 1;
let chunk_size =
u32::from_le_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
as usize;
pos += 8 + chunk_size;
if !chunk_size.is_multiple_of(2) {
pos += 1;
}
}
count
}
#[cfg(test)]
#[allow(deprecated)]
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 disabled_process_image_bytes_is_byte_for_byte_noop() {
let input = b"not an image";
let ctx = ProtectionContext::default();
let result = process_image_bytes(input, ProtectionLevel::Disabled, &ctx).unwrap();
assert_eq!(result, input);
}
#[test]
fn disabled_process_image_bytes_with_warnings_is_byte_for_byte_noop() {
let input = b"not an image";
let ctx = ProtectionContext::default().with_metadata_injection(false);
let (result, warnings) =
process_image_bytes_with_warnings(input, ProtectionLevel::Disabled, &ctx).unwrap();
assert_eq!(result, input);
assert!(warnings.is_empty());
}
#[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"
);
}
#[test]
fn pipeline_process_bytes_preserves_detected_jpeg_format() {
let pipeline = ProtectionPipeline::new();
let img = DynamicImage::new_rgb8(64, 64);
let jpeg_bytes = crate::util::image::encode_image(&img, image::ImageFormat::Jpeg).unwrap();
let ctx = ProtectionContext::new(0.5, 42);
let protected = pipeline
.process_bytes(&jpeg_bytes, ProtectionLevel::Standard, &ctx)
.unwrap();
assert!(
protected.starts_with(&[0xFF, 0xD8, 0xFF]),
"direct byte pipeline should preserve detected JPEG output format"
);
}
#[test]
fn light_process_bytes_validates_max_dimension() {
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::Light, &ctx);
assert!(
result.is_err(),
"Light byte processing should enforce max_dimension"
);
}
#[test]
fn light_process_bytes_can_convert_png_to_jpeg() {
use image::ImageEncoder;
let img = DynamicImage::new_rgb8(64, 64);
let mut png_bytes = Vec::new();
{
let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes);
let rgb = img.to_rgb8();
encoder
.write_image(&rgb, 64, 64, image::ExtendedColorType::Rgb8)
.unwrap();
}
let ctx = ProtectionContext::new(0.5, 42).with_format(ImageOutputFormat::Jpeg);
let protected = process_image_bytes(&png_bytes, ProtectionLevel::Light, &ctx).unwrap();
assert!(
protected.starts_with(&[0xFF, 0xD8, 0xFF]),
"Light byte processing should convert PNG input to requested JPEG output"
);
}
#[test]
fn process_request_metadata_only_png() {
use image::ImageEncoder;
let img = DynamicImage::new_rgb8(64, 64);
let mut png_bytes = Vec::new();
{
let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes);
let rgb = img.to_rgb8();
encoder
.write_image(&rgb, 64, 64, image::ExtendedColorType::Rgb8)
.unwrap();
}
let notice = RightsNotice::default();
let request = ProtectionRequest::metadata_only(notice, RightsPolicy::Unspecified);
let result = process_request_bytes(&png_bytes, &request);
assert!(
result.is_ok(),
"Metadata-only PNG processing should succeed"
);
let output = result.unwrap();
assert!(
output.starts_with(&[0x89, 0x50, 0x4E, 0x47]),
"Output should be PNG"
);
}
#[test]
fn process_request_metadata_only_jpeg() {
use image::ImageEncoder;
let img = DynamicImage::new_rgb8(64, 64);
let mut jpeg_bytes = Vec::new();
{
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg_bytes, 90);
let rgb = img.to_rgb8();
encoder
.write_image(&rgb, 64, 64, image::ExtendedColorType::Rgb8)
.unwrap();
}
let notice = RightsNotice::default();
let request = ProtectionRequest::metadata_only(notice, RightsPolicy::Unspecified);
let result = process_request_bytes(&jpeg_bytes, &request);
assert!(
result.is_ok(),
"Metadata-only JPEG processing should succeed"
);
let output = result.unwrap();
assert!(
output.starts_with(&[0xFF, 0xD8, 0xFF]),
"Output should be JPEG"
);
}
#[test]
fn process_request_rejects_hmac_without_key() {
let notice = RightsNotice::default();
let request = ProtectionRequest::new(
notice,
RightsPolicy::ProhibitedAiMlTraining,
ProtectionChannels::authenticated(),
);
let input_format = ImageOutputFormat::Png;
let result = resolve_request(&request, input_format);
assert!(result.is_err(), "HMAC without key should fail resolution");
}
#[test]
fn process_request_rejects_hmac_with_disabled_marker() {
let notice = RightsNotice::default();
let request = ProtectionRequest::new(
notice,
RightsPolicy::ProhibitedAiMlTraining,
ProtectionChannels {
rights_metadata: true,
hidden_marker: HiddenMarkerMode::Disabled,
authentication: AuthenticationMode::Hmac,
},
)
.with_mac_key(b"test-key".to_vec());
let input_format = ImageOutputFormat::Png;
let result = resolve_request(&request, input_format);
assert!(result.is_err(), "HMAC with disabled marker should fail");
}
#[test]
fn process_request_preserves_png_format() {
use image::ImageEncoder;
let img = DynamicImage::new_rgb8(32, 32);
let mut png_bytes = Vec::new();
{
let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes);
let rgb = img.to_rgb8();
encoder
.write_image(&rgb, 32, 32, image::ExtendedColorType::Rgb8)
.unwrap();
}
let notice = RightsNotice::default();
let request = ProtectionRequest::metadata_only(notice, RightsPolicy::Unspecified);
let output = process_request_bytes(&png_bytes, &request).unwrap();
assert!(output.starts_with(&[0x89, 0x50, 0x4E, 0x47]));
assert!(output.len() >= png_bytes.len());
}
#[test]
fn preset_legal_notice_is_metadata_only() {
let preset = ProtectionPreset::LegalNotice;
let channels = preset.to_channels();
assert!(!channels.has_stego());
assert!(channels.rights_metadata);
assert!(!preset.requires_mac_key());
}
#[test]
fn preset_legal_notice_with_stego_has_marker() {
let preset = ProtectionPreset::LegalNoticeWithStego;
let channels = preset.to_channels();
assert!(channels.has_stego());
assert!(channels.rights_metadata);
assert!(!preset.requires_mac_key());
}
#[test]
fn preset_authenticated_requires_mac() {
let preset = ProtectionPreset::AuthenticatedProvenance;
assert!(preset.requires_mac_key());
let channels = preset.to_channels();
assert!(channels.has_stego());
assert!(matches!(channels.authentication, AuthenticationMode::Hmac));
}
#[test]
fn preset_maximal_requires_mac() {
let preset = ProtectionPreset::Maximal;
assert!(preset.requires_mac_key());
let channels = preset.to_channels();
assert!(channels.has_stego());
assert!(matches!(channels.authentication, AuthenticationMode::Hmac));
}
#[test]
fn from_preset_sets_channels() {
let notice = RightsNotice::default();
let req = ProtectionRequest::from_preset(
ProtectionPreset::LegalNotice,
notice,
RightsPolicy::ProhibitedAiMlTraining,
);
assert!(!req.channels().has_stego());
assert!(req.channels().rights_metadata);
}
#[test]
fn process_request_with_report_metadata_only() {
use image::ImageEncoder;
let img = DynamicImage::new_rgb8(32, 32);
let mut png_bytes = Vec::new();
{
let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes);
let rgb = img.to_rgb8();
encoder
.write_image(&rgb, 32, 32, image::ExtendedColorType::Rgb8)
.unwrap();
}
let notice = RightsNotice::default();
let request =
ProtectionRequest::metadata_only(notice, RightsPolicy::ProhibitedAiMlTraining);
let (output, report) = process_request_bytes_with_report(&png_bytes, &request).unwrap();
assert!(report.metadata_injected);
assert!(!report.stego_attempted);
assert!(!report.format_transcoded);
assert!(report.effective_dmi.is_some());
assert!(output.starts_with(&[0x89, 0x50, 0x4E, 0x47]));
}
#[test]
fn rights_policy_dmi_roundtrip() {
let policies = [
RightsPolicy::Unspecified,
RightsPolicy::Allowed,
RightsPolicy::ProhibitedAiMlTraining,
RightsPolicy::ProhibitedGenerativeAiTraining,
RightsPolicy::ProhibitedExceptSearchIndexing,
RightsPolicy::ProhibitedAllDataMining,
RightsPolicy::ProhibitedSeeConstraints,
];
for policy in &policies {
let dmi = DmiValue::from(*policy);
let back = RightsPolicy::from_dmi_value(dmi);
assert_eq!(&back, policy, "Roundtrip failed for {:?}", policy);
}
}
#[test]
fn rights_policy_to_dmi_unspecified_is_none() {
assert!(RightsPolicy::Unspecified.to_dmi_value().is_none());
}
#[test]
fn rights_policy_to_dmi_allowed_is_some() {
assert_eq!(
RightsPolicy::Allowed.to_dmi_value(),
Some(DmiValue::Allowed)
);
}
#[test]
fn protection_level_to_request_disabled() {
let notice = RightsNotice::default();
let req = ProtectionLevel::Disabled.to_request(notice, RightsPolicy::Unspecified);
assert!(!req.channels().rights_metadata);
assert!(!req.channels().has_stego());
}
#[test]
fn protection_level_to_request_light() {
let notice = RightsNotice::default();
let req = ProtectionLevel::Light.to_request(notice, RightsPolicy::ProhibitedAiMlTraining);
assert!(req.channels().rights_metadata);
assert!(req.channels().has_stego());
}
#[test]
fn protection_level_to_request_standard() {
let notice = RightsNotice::default();
let req = ProtectionLevel::Standard.to_request(notice, RightsPolicy::Allowed);
assert!(req.channels().rights_metadata);
assert!(req.channels().has_stego());
}
#[test]
fn preset_to_channels_metadata_only() {
let channels = ProtectionPreset::LegalNotice.to_channels();
assert!(!channels.has_stego());
assert!(channels.rights_metadata);
}
#[test]
fn preset_to_channels_with_stego() {
let channels = ProtectionPreset::LegalNoticeWithStego.to_channels();
assert!(channels.has_stego());
}
#[test]
fn preset_to_channels_authenticated() {
let channels = ProtectionPreset::AuthenticatedProvenance.to_channels();
assert!(channels.has_stego());
assert!(matches!(channels.authentication, AuthenticationMode::Hmac));
}
}