#[cfg(feature = "file_io")]
use std::path::PathBuf;
use std::{
collections::HashMap,
ffi::OsStr,
fmt, fs,
io::{Read, Seek},
path::Path,
sync::Arc,
};
use tempfile::{Builder, NamedTempFile};
pub use crate::read_seek::{ReadSeek, ReadWriteSeek};
use crate::{
error::Result,
utils::{
mime::normalize_format,
xmp_inmemory_utils::{add_provenance, extract_provenance, remove_provenance, MIN_XMP},
},
Error,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ObjectType {
C2pa,
Xmp,
Other,
OtherExclusion,
}
impl fmt::Display for ObjectType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{self:?}")
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ObjectLocations {
pub offset: u64,
pub length: u64,
pub htype: ObjectType,
}
pub trait C2paReader: Sync + Send {
fn read_c2pa(&self, input_stream: &mut dyn ReadSeek) -> Result<Vec<u8>>;
fn read_xmp(&self, input_stream: &mut dyn ReadSeek) -> Option<String>;
}
pub trait C2paWriter: Sync + Send {
fn write_c2pa(
&self,
input_stream: &mut dyn ReadSeek,
output_stream: &mut dyn ReadWriteSeek,
store_bytes: &[u8],
) -> Result<()>;
fn get_object_locations(&self, input_stream: &mut dyn ReadSeek)
-> Result<Vec<ObjectLocations>>;
fn remove_c2pa(
&self,
input_stream: &mut dyn ReadSeek,
output_stream: &mut dyn ReadWriteSeek,
) -> Result<()>;
}
pub trait AssetIO: Sync + Send {
fn new(asset_type: &str) -> Self
where
Self: Sized;
fn get_handler(&self, asset_type: &str) -> Box<dyn AssetIO>;
fn get_reader(&self) -> &dyn C2paReader;
fn get_writer(&self, _asset_type: &str) -> Option<Box<dyn C2paWriter>> {
None
}
#[cfg(test)] fn read_cai_store(&self, asset_path: &Path) -> Result<Vec<u8>> {
let mut input_stream = fs::OpenOptions::new()
.read(true)
.open(asset_path)
.map_err(Error::IoError)?;
self.get_reader().read_c2pa(&mut input_stream)
}
#[allow(dead_code)] fn save_c2pa_store(&self, asset_path: &Path, store_bytes: &[u8]) -> Result<()> {
let ext = asset_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
let writer = self.get_writer(ext).ok_or(Error::UnsupportedType)?;
let mut input_stream = fs::OpenOptions::new()
.read(true)
.open(asset_path)
.map_err(Error::IoError)?;
let mut temp_file = tempfile_builder("c2pa_temp")?;
writer.write_c2pa(&mut input_stream, &mut temp_file, store_bytes)?;
rename_or_move(temp_file, asset_path)
}
#[allow(dead_code)] fn remove_c2pa_store(&self, asset_path: &Path) -> Result<()> {
let ext = asset_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default();
let writer = self.get_writer(ext).ok_or(Error::UnsupportedType)?;
let mut input_stream = fs::OpenOptions::new()
.read(true)
.open(asset_path)
.map_err(Error::IoError)?;
let mut temp_file = tempfile_builder("c2pa_temp")?;
writer.remove_c2pa(&mut input_stream, &mut temp_file)?;
rename_or_move(temp_file, asset_path)
}
fn supported_types(&self) -> &[&str];
fn mime_type_map(&self) -> Vec<(String, String)> {
let types = self.supported_types();
let Some(mime) = types.iter().find(|t| t.contains('/')) else {
return Vec::new();
};
types
.iter()
.filter(|t| !t.contains('/'))
.map(|ext| (ext.to_string(), mime.to_string()))
.collect()
}
#[allow(dead_code)] fn asset_patch_ref(&self) -> Option<&dyn AssetPatch> {
None
}
fn remote_manifest_url_ref(&self) -> Option<&dyn RemoteManifestUrl> {
None
}
fn asset_box_hash_ref(&self) -> Option<&dyn AssetBoxHash> {
None
}
fn composed_data_ref(&self) -> Option<&dyn ComposedManifestRef> {
None
}
#[allow(unused)] fn write_xmp_ref(&self) -> Option<&dyn WriteXmp> {
None
}
}
pub trait AssetPatch {
#[allow(dead_code)] fn patch_c2pa_file(&self, asset_path: &Path, store_bytes: &[u8]) -> Result<()> {
let mut file = fs::OpenOptions::new()
.read(true)
.write(true)
.open(asset_path)
.map_err(Error::IoError)?;
self.patch_c2pa(&mut file, store_bytes)
}
fn patch_c2pa(&self, _stream: &mut dyn ReadWriteSeek, _store_bytes: &[u8]) -> Result<()> {
Err(Error::NotImplemented(
"patch_c2pa is not implemented by this handler".to_string(),
))
}
}
pub const C2PA_BOXHASH: &str = "C2PA";
pub const C2PA_AFTER: &str = "c2pa.after";
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct BoxMap {
pub names: Vec<String>,
pub excluded: Option<bool>,
pub range_start: u64,
pub range_len: u64,
pub allowed_exclusions: Vec<AllowedExclusion>,
}
impl BoxMap {
pub fn new(names: Vec<String>, range_start: u64, range_len: u64) -> Self {
BoxMap {
names,
excluded: None,
range_start,
range_len,
allowed_exclusions: vec![],
}
}
pub fn excluded(mut self) -> Self {
self.excluded = Some(true);
self
}
pub fn with_allowed_exclusions(mut self, allowed_exclusions: Vec<AllowedExclusion>) -> Self {
self.allowed_exclusions = allowed_exclusions;
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExclusionKind {
ManifestOrPadding,
AssetMetadata,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AllowedExclusion {
pub start: u64,
pub length: u64,
pub kind: ExclusionKind,
}
impl AllowedExclusion {
pub fn whole_box(len: u64) -> Self {
AllowedExclusion {
start: 0,
length: len,
kind: ExclusionKind::ManifestOrPadding,
}
}
pub fn after_header(header_len: u64, total_len: u64) -> Self {
AllowedExclusion {
start: header_len,
length: total_len.saturating_sub(header_len),
kind: ExclusionKind::AssetMetadata,
}
}
pub(crate) fn contains(&self, range_start: u64, range_end: u64) -> bool {
self.end()
.is_some_and(|end| range_start >= self.start && range_end <= end)
}
pub(crate) fn is_bounded_by(&self, box_len: u64) -> bool {
self.end().is_some_and(|end| end <= box_len)
}
fn end(&self) -> Option<u64> {
self.start.checked_add(self.length)
}
}
pub trait AssetBoxHash {
fn get_box_map(&self, input_stream: &mut dyn ReadSeek) -> Result<Vec<BoxMap>>;
}
pub trait RemoteManifestUrl {
fn write_remote_manifest_url(
&self,
input_stream: &mut dyn ReadSeek,
output_stream: &mut dyn ReadWriteSeek,
remote_manifest_url: &str,
) -> Result<()>;
#[allow(unused)] fn read_manifest_url(&self, _input_stream: &mut dyn ReadSeek) -> Option<String> {
None
}
#[allow(unused)] fn remove_remote_manifest_url(
&self,
_input_stream: &mut dyn ReadSeek,
_output_stream: &mut dyn ReadWriteSeek,
) -> Result<()> {
Err(Error::NotImplemented(
"remove_remote_manifest_url is not supported by this handler".to_string(),
))
}
}
pub trait WriteXmp {
fn write_xmp(
&self,
input_stream: &mut dyn ReadSeek,
output_stream: &mut dyn ReadWriteSeek,
xmp: &str,
) -> Result<()>;
}
impl<T: WriteXmp + C2paReader> RemoteManifestUrl for T {
fn write_remote_manifest_url(
&self,
input_stream: &mut dyn ReadSeek,
output_stream: &mut dyn ReadWriteSeek,
remote_manifest_url: &str,
) -> Result<()> {
let current_xmp = self
.read_xmp(input_stream)
.unwrap_or_else(|| MIN_XMP.to_string());
let updated_xmp = add_provenance(¤t_xmp, remote_manifest_url)?;
self.write_xmp(input_stream, output_stream, &updated_xmp)
}
fn read_manifest_url(&self, input_stream: &mut dyn ReadSeek) -> Option<String> {
self.read_xmp(input_stream)
.as_deref()
.and_then(extract_provenance)
}
#[allow(unused)] fn remove_remote_manifest_url(
&self,
input_stream: &mut dyn ReadSeek,
output_stream: &mut dyn ReadWriteSeek,
) -> Result<()> {
let current_xmp = self
.read_xmp(input_stream)
.unwrap_or_else(|| MIN_XMP.to_string());
let updated_xmp = remove_provenance(¤t_xmp)?;
self.write_xmp(input_stream, output_stream, &updated_xmp)
}
}
pub trait ComposedManifestRef {
fn compose_manifest(&self, manifest_data: &[u8], format: &str) -> Result<Vec<u8>>;
}
pub struct HandlerRegistry {
handlers: HashMap<String, Arc<dyn AssetIO>>,
containers: HashMap<String, String>,
mimes: HashMap<String, String>,
fallback: Option<Arc<HandlerRegistry>>,
}
impl Default for HandlerRegistry {
fn default() -> Self {
Self::new()
}
}
impl HandlerRegistry {
pub fn new() -> Self {
Self {
handlers: HashMap::new(),
containers: HashMap::new(),
mimes: HashMap::new(),
fallback: None,
}
}
pub fn with_fallback(fallback: Arc<HandlerRegistry>) -> Self {
Self {
handlers: HashMap::new(),
containers: HashMap::new(),
mimes: HashMap::new(),
fallback: Some(fallback),
}
}
#[allow(unused)] pub fn with_handler(mut self, handler: impl AssetIO + 'static) -> Self {
self.add_handler(handler);
self
}
#[allow(unused)] pub fn add_handler(&mut self, handler: impl AssetIO + 'static) {
self.add_boxed_handler(Box::new(handler));
}
pub(crate) fn add_boxed_handler(&mut self, handler: Box<dyn AssetIO>) {
let types = handler.supported_types();
let Some(canonical) = types.first() else {
return;
};
let canonical = normalize_format(canonical);
for (ext, mime) in handler.mime_type_map() {
let mime = normalize_format(&mime);
self.mimes.insert(normalize_format(&ext), mime.clone());
self.mimes.insert(mime.clone(), mime);
}
for t in types {
let handler: Arc<dyn AssetIO> = Arc::from(handler.get_handler(t));
let key = normalize_format(t);
self.containers.insert(key.clone(), canonical.clone());
self.handlers.insert(key, handler);
}
}
pub fn handler(&self, format: &str) -> Option<&dyn AssetIO> {
let key = normalize_format(format);
match self.handlers.get(&key) {
Some(h) => Some(h.as_ref()),
None => self.fallback.as_deref().and_then(|f| f.handler(format)),
}
}
pub fn reader(&self, format: &str) -> Option<&dyn C2paReader> {
self.handler(format).map(|h| h.get_reader())
}
pub fn writer(&self, format: &str) -> Option<Box<dyn C2paWriter>> {
self.handler(format).and_then(|h| h.get_writer(format))
}
#[cfg(feature = "file_io")]
pub fn handler_from_path(&self, asset_path: &Path) -> Option<&dyn AssetIO> {
let ext = asset_path.extension()?.to_str()?;
self.handler(ext)
}
#[allow(dead_code)] pub fn supported_extension(&self, path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?.to_lowercase();
self.handler(&ext).is_some().then_some(ext)
}
pub fn reader_mime_types(&self) -> Vec<String> {
let mut types: Vec<String> = self.handlers.keys().cloned().collect();
if let Some(f) = &self.fallback {
for t in f.reader_mime_types() {
if !types.contains(&t) {
types.push(t);
}
}
}
types
}
pub fn writer_mime_types(&self) -> Vec<String> {
let mut types: Vec<String> = self
.handlers
.iter()
.filter(|(format, handler)| handler.get_writer(format).is_some())
.map(|(format, _)| format.clone())
.collect();
if let Some(f) = &self.fallback {
for t in f.writer_mime_types() {
if !types.contains(&t) {
types.push(t);
}
}
}
types
}
pub fn container_for(&self, format: &str) -> Option<&str> {
let key = normalize_format(format);
match self.containers.get(&key) {
Some(c) => Some(c.as_str()),
None => self
.fallback
.as_deref()
.and_then(|f| f.container_for(format)),
}
}
pub fn is_bmff_format(&self, format: &str) -> bool {
self.container_for(format) == self.container_for("avif")
}
pub fn is_zip_format(&self, format: &str) -> bool {
self.container_for(format) == self.container_for("zip")
}
#[allow(dead_code)] pub fn mime_for(&self, format: &str) -> Option<&str> {
let key = normalize_format(format);
match self.mimes.get(&key) {
Some(m) => Some(m.as_str()),
None => self.fallback.as_deref().and_then(|f| f.mime_for(format)),
}
}
#[allow(dead_code)] pub fn format_to_mime(&self, format: &str) -> String {
match self.mime_for(format) {
Some(mime) => mime.to_string(),
None => normalize_format(format),
}
}
#[allow(dead_code)] pub fn format_from_path<P: AsRef<Path>>(&self, path: P) -> Option<String> {
let ext = path.as_ref().extension()?.to_string_lossy();
Some(self.format_to_mime(&ext))
}
pub fn format_from_stream<R: Read + Seek>(&self, hint: &str, stream: &mut R) -> String {
let detected = sniff_container_from_stream(stream);
let hinted = self.container_for(hint);
match (hinted, detected) {
(Some(h), Some(d)) if h == d => hint.to_string(),
(_, Some(d)) => d.to_string(),
(_, None) => hint.to_string(),
}
}
pub(crate) fn read_c2pa(&self, asset_type: &str, stream: &mut dyn ReadSeek) -> Result<Vec<u8>> {
let c2pa_block = match self.reader(asset_type) {
Some(r) => r.read_c2pa(stream)?,
None => return Err(Error::UnsupportedType),
};
if c2pa_block.is_empty() {
return Err(Error::JumbfNotFound);
}
Ok(c2pa_block)
}
pub(crate) fn write_c2pa(
&self,
asset_type: &str,
input_stream: &mut dyn ReadSeek,
output_stream: &mut dyn ReadWriteSeek,
store_bytes: &[u8],
) -> Result<()> {
match self.writer(asset_type) {
Some(w) => w.write_c2pa(input_stream, output_stream, store_bytes),
None => Err(Error::UnsupportedType),
}
}
#[cfg(feature = "file_io")]
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn read_c2pa_from_file<P: AsRef<Path>>(&self, in_path: P) -> Result<Vec<u8>> {
let ext = in_path
.as_ref()
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.ok_or(Error::UnsupportedType)?;
match self.handler(&ext) {
Some(asset_handler) => {
let mut file = fs::File::open(in_path.as_ref()).map_err(Error::IoError)?;
asset_handler.get_reader().read_c2pa(&mut file)
}
None => Err(Error::UnsupportedType),
}
}
#[cfg(feature = "file_io")]
pub(crate) fn write_c2pa_to_file<P1: AsRef<Path>, P2: AsRef<Path>>(
&self,
data: &[u8],
in_path: P1,
out_path: Option<P2>,
) -> Result<()> {
let ext = in_path
.as_ref()
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.ok_or(Error::UnsupportedType)?;
let asset_out_path: PathBuf = match out_path.as_ref() {
Some(p) => p.as_ref().to_owned(),
None => {
let filename_osstr = in_path.as_ref().file_stem().ok_or(Error::UnsupportedType)?;
let filename = filename_osstr.to_str().ok_or(Error::UnsupportedType)?;
let out_name = format!("{filename}-c2pa.{ext}");
in_path.as_ref().to_owned().with_file_name(out_name)
}
};
if in_path.as_ref() != asset_out_path {
fs::copy(in_path, &asset_out_path).map_err(Error::IoError)?;
}
match self.handler(&ext) {
Some(asset_handler) => {
if let Some(patch_handler) = asset_handler.asset_patch_ref() {
if patch_handler.patch_c2pa_file(&asset_out_path, data).is_ok() {
return Ok(());
}
}
asset_handler.save_c2pa_store(&asset_out_path, data)
}
None => Err(Error::UnsupportedType),
}
}
pub(crate) fn object_locations(
&self,
format: &str,
stream: &mut dyn ReadSeek,
) -> Result<Vec<ObjectLocations>> {
match self.writer(format) {
Some(w) => w.get_object_locations(stream),
None => Err(Error::UnsupportedType),
}
}
}
fn sniff_container_from_stream<R: Read + Seek>(stream: &mut R) -> Option<&'static str> {
use std::io::SeekFrom;
stream.rewind().ok()?;
let mut buf = [0u8; 16];
let n = stream.read(&mut buf).ok()?;
stream.rewind().ok()?;
if n < 2 {
return None;
}
if n >= 3 && buf[0] == 0xff && buf[1] == 0xd8 && buf[2] == 0xff {
return Some("jpg");
}
if n >= 8 && buf[0..8] == [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] {
return Some("png");
}
if n >= 6 && &buf[0..3] == b"GIF" && (&buf[3..6] == b"87a" || &buf[3..6] == b"89a") {
return Some("gif");
}
if n >= 4
&& (buf[0..4] == [0x49, 0x49, 0x2A, 0x00] || buf[0..4] == [0x4D, 0x4D, 0x00, 0x2A] || buf[0..4] == [0x49, 0x49, 0x2B, 0x00] || buf[0..4] == [0x4D, 0x4D, 0x00, 0x2B])
{
return Some("tif");
}
if n >= 12
&& buf[0..12]
== [
0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a,
]
{
return Some("jxl");
}
if n >= 4 && &buf[0..4] == b"RIFF" {
return Some("avi");
}
if n >= 8 && &buf[4..8] == b"ftyp" {
return Some("avif");
}
if n >= 4 && &buf[0..4] == b"fLaC" {
return Some("flac");
}
if n >= 10 && &buf[0..3] == b"ID3" {
let tag_size = ((buf[6] as u64 & 0x7f) << 21)
| ((buf[7] as u64 & 0x7f) << 14)
| ((buf[8] as u64 & 0x7f) << 7)
| (buf[9] as u64 & 0x7f);
let flac_offset = 10 + tag_size;
let mut marker = [0u8; 4];
let is_flac = stream
.seek(SeekFrom::Start(flac_offset))
.and_then(|_| stream.read_exact(&mut marker))
.map(|_| &marker == b"fLaC")
.unwrap_or(false);
let _ = stream.rewind();
return if is_flac { Some("flac") } else { Some("mp3") };
}
if n >= 2 && buf[0] == 0xff && (buf[1] & 0xe0) == 0xe0 {
return Some("mp3");
}
#[cfg(feature = "pdf")]
if n >= 4 && &buf[0..4] == b"%PDF" {
return Some("pdf");
}
None
}
#[allow(dead_code)] fn tempfile_builder<T: AsRef<OsStr> + Sized>(prefix: T) -> Result<NamedTempFile> {
#[cfg(all(target_os = "wasi", target_env = "p1"))]
return Err(Error::NotImplemented(
"tempfile_builder requires wasip2 or later".to_string(),
));
#[cfg(all(target_os = "wasi", not(target_env = "p1")))]
return Builder::new()
.prefix(&prefix)
.rand_bytes(5)
.tempfile_in("/")
.map_err(Error::IoError);
#[cfg(not(target_os = "wasi"))]
return Builder::new()
.prefix(&prefix)
.rand_bytes(5)
.tempfile()
.map_err(Error::IoError);
}
#[allow(dead_code)] pub fn rename_or_move<P>(temp_file: NamedTempFile, asset_path: P) -> Result<()>
where
P: AsRef<Path>,
{
let (_, path) = temp_file
.keep()
.map_err(|e| crate::Error::OtherError(Box::new(e)))?;
fs::rename(&path, asset_path.as_ref())
.or_else(|_| {
fs::copy(&path, asset_path).map(|_| ()).and_then(|_| {
fs::remove_file(path)
})
})
.map_err(crate::Error::IoError)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
#![allow(clippy::panic)]
use std::io::Cursor;
use super::*;
struct TestHandler {
types: Vec<&'static str>,
tag: &'static [u8],
mime_map: Vec<(String, String)>,
}
impl AssetIO for TestHandler {
fn new(_asset_type: &str) -> Self {
unimplemented!("not needed for these tests")
}
fn get_handler(&self, _asset_type: &str) -> Box<dyn AssetIO> {
Box::new(TestHandler {
types: self.types.clone(),
tag: self.tag,
mime_map: self.mime_map.clone(),
})
}
fn get_reader(&self) -> &dyn C2paReader {
self
}
fn supported_types(&self) -> &[&str] {
&self.types
}
fn mime_type_map(&self) -> Vec<(String, String)> {
if self.mime_map.is_empty() {
let types = self.types.clone();
let Some(mime) = types.iter().find(|t| t.contains('/')) else {
return Vec::new();
};
types
.iter()
.filter(|t| !t.contains('/'))
.map(|ext| (ext.to_string(), mime.to_string()))
.collect()
} else {
self.mime_map.clone()
}
}
}
impl C2paReader for TestHandler {
fn read_c2pa(&self, _: &mut dyn ReadSeek) -> Result<Vec<u8>> {
Ok(self.tag.to_vec())
}
fn read_xmp(&self, _: &mut dyn ReadSeek) -> Option<String> {
None
}
}
fn handler(types: &[&'static str], tag: &'static [u8]) -> TestHandler {
TestHandler {
types: types.to_vec(),
tag,
mime_map: Vec::new(),
}
}
fn handler_with_mime_map(
types: &[&'static str],
tag: &'static [u8],
mime_map: &[(&str, &str)],
) -> TestHandler {
TestHandler {
types: types.to_vec(),
tag,
mime_map: mime_map
.iter()
.map(|(ext, mime)| (ext.to_string(), mime.to_string()))
.collect(),
}
}
#[test]
fn test_last_registered_wins() {
let reg = HandlerRegistry::new()
.with_handler(handler(&["x-custom/test"], b"A"))
.with_handler(handler(&["x-custom/test"], b"B"));
let mut stream = Cursor::new(vec![]);
let reader = reg.reader("x-custom/test").unwrap();
assert_eq!(reader.read_c2pa(&mut stream).unwrap(), b"B");
}
#[test]
fn test_fallback_is_only_consulted_when_own_handlers_miss() {
let base =
Arc::new(HandlerRegistry::new().with_handler(handler(&["image/jpeg"], b"builtin")));
let overlay =
HandlerRegistry::with_fallback(base).with_handler(handler(&["image/jpeg"], b"custom"));
let mut stream = Cursor::new(vec![]);
assert_eq!(
overlay
.reader("image/jpeg")
.unwrap()
.read_c2pa(&mut stream)
.unwrap(),
b"custom"
);
}
#[test]
fn test_fallback_covers_formats_not_registered_locally() {
let base =
Arc::new(HandlerRegistry::new().with_handler(handler(&["image/png"], b"builtin-png")));
let overlay = HandlerRegistry::with_fallback(base)
.with_handler(handler(&["image/jpeg"], b"custom-jpeg"));
assert!(overlay.handler("image/png").is_some());
assert!(overlay.handler("image/jpeg").is_some());
assert!(overlay.handler("nonexistent/format").is_none());
}
#[test]
fn test_container_for_groups_by_first_supported_type() {
let reg = HandlerRegistry::new()
.with_handler(handler(&["tif", "dng", "image/tiff"], b"tiff"))
.with_handler(handler(&["avif", "mp4", "mov"], b"bmff"));
assert_eq!(reg.container_for("dng"), reg.container_for("tif"));
assert_eq!(reg.container_for("image/tiff"), reg.container_for("tif"));
assert_ne!(reg.container_for("mp4"), reg.container_for("tif"));
assert_eq!(reg.container_for("nonexistent/format"), None);
}
#[test]
fn test_is_bmff_format() {
let reg = HandlerRegistry::new()
.with_handler(handler(&["tif", "dng"], b"tiff"))
.with_handler(handler(&["avif", "mp4", "mov", "heic"], b"bmff"));
assert!(reg.is_bmff_format("mp4"));
assert!(reg.is_bmff_format("heic"));
assert!(!reg.is_bmff_format("tif"));
assert!(!reg.is_bmff_format("nonexistent/format"));
}
#[test]
fn test_is_zip_format() {
let reg = HandlerRegistry::new()
.with_handler(handler(&["tif", "dng"], b"tiff"))
.with_handler(handler(&["zip", "epub", "docx"], b"zip"));
assert!(reg.is_zip_format("epub"));
assert!(reg.is_zip_format("docx"));
assert!(!reg.is_zip_format("tif"));
assert!(!reg.is_zip_format("nonexistent/format"));
}
#[test]
fn test_format_from_stream_detects_jpeg_from_bytes() {
let reg =
HandlerRegistry::new().with_handler(handler(&["jpg", "jpeg", "image/jpeg"], b"jpeg"));
let mut stream = Cursor::new(vec![0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]);
assert_eq!(reg.format_from_stream("png", &mut stream), "jpg");
assert_eq!(reg.format_from_stream("jpeg", &mut stream), "jpeg");
}
#[test]
fn test_format_from_stream_falls_back_to_hint_when_undetectable() {
let reg = HandlerRegistry::new();
let mut stream = Cursor::new(vec![0u8; 4]);
assert_eq!(
reg.format_from_stream("custom/unknown", &mut stream),
"custom/unknown"
);
}
#[test]
fn test_trims_and_lowercases_format_strings() {
let reg = HandlerRegistry::new().with_handler(handler(&["image/jpeg"], b"jpeg"));
assert!(reg.handler(" IMAGE/JPEG ").is_some());
}
#[test]
fn test_mime_for_derives_default_from_supported_types() {
let reg =
HandlerRegistry::new().with_handler(handler(&["jpg", "jpeg", "image/jpeg"], b"jpeg"));
assert_eq!(reg.mime_for("jpg"), Some("image/jpeg"));
assert_eq!(reg.mime_for("jpeg"), Some("image/jpeg"));
assert_eq!(reg.mime_for("image/jpeg"), Some("image/jpeg"));
assert_eq!(reg.mime_for("nonexistent/format"), None);
}
#[test]
fn test_mime_for_uses_explicit_mime_type_map_for_multi_format_handler() {
let reg = HandlerRegistry::new().with_handler(handler_with_mime_map(
&[
"avif",
"heif",
"heic",
"mp4",
"application/mp4",
"image/avif",
"image/heic",
"image/heif",
"video/mp4",
],
b"bmff",
&[
("avif", "image/avif"),
("heif", "image/heif"),
("heic", "image/heic"),
("mp4", "video/mp4"),
],
));
assert_eq!(reg.mime_for("heic"), Some("image/heic"));
assert_eq!(reg.mime_for("heif"), Some("image/heif"));
assert_eq!(reg.mime_for("avif"), Some("image/avif"));
assert_eq!(reg.mime_for("mp4"), Some("video/mp4"));
}
#[test]
fn test_format_to_mime_echoes_input_when_no_handler_registered() {
let reg = HandlerRegistry::new();
assert_eq!(reg.format_to_mime(" PSD "), "psd");
}
#[test]
fn test_format_from_path_uses_registered_handler() {
let reg = HandlerRegistry::new().with_handler(handler_with_mime_map(
&["heic", "image/heic"],
b"bmff",
&[("heic", "image/heic")],
));
assert_eq!(
reg.format_from_path("photo.HEIC"),
Some("image/heic".to_string())
);
}
}