1use std::error::Error;
4use std::fmt;
5use std::str::FromStr;
6use std::time::Duration;
7
8pub const MAX_OUTPUT_PIXELS: u64 = 67_108_864;
17
18pub const MAX_DECODED_PIXELS: u64 = 100_000_000;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RawArtifact {
31 pub bytes: Vec<u8>,
33 pub declared_media_type: Option<MediaType>,
35}
36
37impl RawArtifact {
38 pub fn new(bytes: Vec<u8>, declared_media_type: Option<MediaType>) -> Self {
40 Self {
41 bytes,
42 declared_media_type,
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Artifact {
50 pub bytes: Vec<u8>,
52 pub media_type: MediaType,
54 pub metadata: ArtifactMetadata,
56}
57
58impl Artifact {
59 pub fn new(bytes: Vec<u8>, media_type: MediaType, metadata: ArtifactMetadata) -> Self {
61 Self {
62 bytes,
63 media_type,
64 metadata,
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ArtifactMetadata {
72 pub width: Option<u32>,
74 pub height: Option<u32>,
76 pub frame_count: u32,
78 pub duration: Option<Duration>,
80 pub has_alpha: Option<bool>,
82}
83
84impl Default for ArtifactMetadata {
85 fn default() -> Self {
86 Self {
87 width: None,
88 height: None,
89 frame_count: 1,
90 duration: None,
91 has_alpha: None,
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum MediaType {
99 Jpeg,
101 Png,
103 Webp,
105 Avif,
107 Svg,
109 Bmp,
111}
112
113impl MediaType {
114 pub const fn as_name(self) -> &'static str {
116 match self {
117 Self::Jpeg => "jpeg",
118 Self::Png => "png",
119 Self::Webp => "webp",
120 Self::Avif => "avif",
121 Self::Svg => "svg",
122 Self::Bmp => "bmp",
123 }
124 }
125
126 pub const fn as_mime(self) -> &'static str {
128 match self {
129 Self::Jpeg => "image/jpeg",
130 Self::Png => "image/png",
131 Self::Webp => "image/webp",
132 Self::Avif => "image/avif",
133 Self::Svg => "image/svg+xml",
134 Self::Bmp => "image/bmp",
135 }
136 }
137
138 pub const fn is_lossy(self) -> bool {
140 matches!(self, Self::Jpeg | Self::Webp | Self::Avif)
141 }
142
143 pub const fn is_raster(self) -> bool {
145 !matches!(self, Self::Svg)
146 }
147}
148
149impl fmt::Display for MediaType {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 f.write_str(self.as_mime())
152 }
153}
154
155impl FromStr for MediaType {
156 type Err = String;
157
158 fn from_str(value: &str) -> Result<Self, Self::Err> {
159 match value {
160 "jpeg" | "jpg" => Ok(Self::Jpeg),
161 "png" => Ok(Self::Png),
162 "webp" => Ok(Self::Webp),
163 "avif" => Ok(Self::Avif),
164 "svg" => Ok(Self::Svg),
165 "bmp" => Ok(Self::Bmp),
166 _ => Err(format!("unsupported media type `{value}`")),
167 }
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct TransformRequest {
174 pub input: Artifact,
176 pub options: TransformOptions,
178}
179
180impl TransformRequest {
181 pub fn new(input: Artifact, options: TransformOptions) -> Self {
183 Self { input, options }
184 }
185
186 pub fn normalize(self) -> Result<NormalizedTransformRequest, TransformError> {
188 let options = self.options.normalize(self.input.media_type)?;
189
190 Ok(NormalizedTransformRequest {
191 input: self.input,
192 options,
193 })
194 }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct NormalizedTransformRequest {
200 pub input: Artifact,
202 pub options: NormalizedTransformOptions,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct TransformOptions {
209 pub width: Option<u32>,
211 pub height: Option<u32>,
213 pub fit: Option<Fit>,
215 pub position: Option<Position>,
217 pub format: Option<MediaType>,
219 pub quality: Option<u8>,
221 pub background: Option<Rgba8>,
223 pub rotate: Rotation,
225 pub auto_orient: bool,
227 pub strip_metadata: bool,
229 pub preserve_exif: bool,
231 pub deadline: Option<Duration>,
238}
239
240impl Default for TransformOptions {
241 fn default() -> Self {
242 Self {
243 width: None,
244 height: None,
245 fit: None,
246 position: None,
247 format: None,
248 quality: None,
249 background: None,
250 rotate: Rotation::Deg0,
251 auto_orient: true,
252 strip_metadata: true,
253 preserve_exif: false,
254 deadline: None,
255 }
256 }
257}
258
259impl TransformOptions {
260 pub fn normalize(
262 self,
263 input_media_type: MediaType,
264 ) -> Result<NormalizedTransformOptions, TransformError> {
265 validate_dimension("width", self.width)?;
266 validate_dimension("height", self.height)?;
267 validate_quality(self.quality)?;
268
269 let has_bounded_resize = self.width.is_some() && self.height.is_some();
270
271 if self.fit.is_some() && !has_bounded_resize {
272 return Err(TransformError::InvalidOptions(
273 "fit requires both width and height".to_string(),
274 ));
275 }
276
277 if self.position.is_some() && !has_bounded_resize {
278 return Err(TransformError::InvalidOptions(
279 "position requires both width and height".to_string(),
280 ));
281 }
282
283 if self.preserve_exif && self.strip_metadata {
284 return Err(TransformError::InvalidOptions(
285 "preserve_exif requires strip_metadata to be false".to_string(),
286 ));
287 }
288
289 let format = self.format.unwrap_or(input_media_type);
290
291 if self.preserve_exif && format == MediaType::Svg {
292 return Err(TransformError::InvalidOptions(
293 "preserveExif is not supported with SVG output".to_string(),
294 ));
295 }
296
297 if self.quality.is_some() && !format.is_lossy() {
298 return Err(TransformError::InvalidOptions(
299 "quality requires a lossy output format".to_string(),
300 ));
301 }
302
303 let fit = if has_bounded_resize {
304 Some(self.fit.unwrap_or(Fit::Contain))
305 } else {
306 None
307 };
308
309 Ok(NormalizedTransformOptions {
310 width: self.width,
311 height: self.height,
312 fit,
313 position: self.position.unwrap_or(Position::Center),
314 format,
315 quality: self.quality,
316 background: self.background,
317 rotate: self.rotate,
318 auto_orient: self.auto_orient,
319 metadata_policy: normalize_metadata_policy(self.strip_metadata, self.preserve_exif),
320 deadline: self.deadline,
321 })
322 }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct NormalizedTransformOptions {
328 pub width: Option<u32>,
330 pub height: Option<u32>,
332 pub fit: Option<Fit>,
334 pub position: Position,
336 pub format: MediaType,
338 pub quality: Option<u8>,
340 pub background: Option<Rgba8>,
342 pub rotate: Rotation,
344 pub auto_orient: bool,
346 pub metadata_policy: MetadataPolicy,
348 pub deadline: Option<Duration>,
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum Fit {
355 Contain,
357 Cover,
359 Fill,
361 Inside,
363}
364
365impl Fit {
366 pub const fn as_name(self) -> &'static str {
368 match self {
369 Self::Contain => "contain",
370 Self::Cover => "cover",
371 Self::Fill => "fill",
372 Self::Inside => "inside",
373 }
374 }
375}
376
377impl FromStr for Fit {
378 type Err = String;
379
380 fn from_str(value: &str) -> Result<Self, Self::Err> {
381 match value {
382 "contain" => Ok(Self::Contain),
383 "cover" => Ok(Self::Cover),
384 "fill" => Ok(Self::Fill),
385 "inside" => Ok(Self::Inside),
386 _ => Err(format!("unsupported fit mode `{value}`")),
387 }
388 }
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393pub enum Position {
394 Center,
396 Top,
398 Right,
400 Bottom,
402 Left,
404 TopLeft,
406 TopRight,
408 BottomLeft,
410 BottomRight,
412}
413
414impl Position {
415 pub const fn as_name(self) -> &'static str {
417 match self {
418 Self::Center => "center",
419 Self::Top => "top",
420 Self::Right => "right",
421 Self::Bottom => "bottom",
422 Self::Left => "left",
423 Self::TopLeft => "top-left",
424 Self::TopRight => "top-right",
425 Self::BottomLeft => "bottom-left",
426 Self::BottomRight => "bottom-right",
427 }
428 }
429}
430
431impl FromStr for Position {
432 type Err = String;
433
434 fn from_str(value: &str) -> Result<Self, Self::Err> {
435 match value {
436 "center" => Ok(Self::Center),
437 "top" => Ok(Self::Top),
438 "right" => Ok(Self::Right),
439 "bottom" => Ok(Self::Bottom),
440 "left" => Ok(Self::Left),
441 "top-left" => Ok(Self::TopLeft),
442 "top-right" => Ok(Self::TopRight),
443 "bottom-left" => Ok(Self::BottomLeft),
444 "bottom-right" => Ok(Self::BottomRight),
445 _ => Err(format!("unsupported position `{value}`")),
446 }
447 }
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
452pub enum Rotation {
453 Deg0,
455 Deg90,
457 Deg180,
459 Deg270,
461}
462
463impl Rotation {
464 pub const fn as_degrees(self) -> u16 {
466 match self {
467 Self::Deg0 => 0,
468 Self::Deg90 => 90,
469 Self::Deg180 => 180,
470 Self::Deg270 => 270,
471 }
472 }
473}
474
475impl FromStr for Rotation {
476 type Err = String;
477
478 fn from_str(value: &str) -> Result<Self, Self::Err> {
479 match value {
480 "0" => Ok(Self::Deg0),
481 "90" => Ok(Self::Deg90),
482 "180" => Ok(Self::Deg180),
483 "270" => Ok(Self::Deg270),
484 _ => Err(format!("unsupported rotation `{value}`")),
485 }
486 }
487}
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
491pub struct Rgba8 {
492 pub r: u8,
494 pub g: u8,
496 pub b: u8,
498 pub a: u8,
500}
501
502impl Rgba8 {
503 pub fn from_hex(value: &str) -> Result<Self, String> {
505 if value.len() != 6 && value.len() != 8 {
506 return Err(format!("unsupported color `{value}`"));
507 }
508
509 let r = u8::from_str_radix(&value[0..2], 16)
510 .map_err(|_| format!("unsupported color `{value}`"))?;
511 let g = u8::from_str_radix(&value[2..4], 16)
512 .map_err(|_| format!("unsupported color `{value}`"))?;
513 let b = u8::from_str_radix(&value[4..6], 16)
514 .map_err(|_| format!("unsupported color `{value}`"))?;
515 let a = if value.len() == 8 {
516 u8::from_str_radix(&value[6..8], 16)
517 .map_err(|_| format!("unsupported color `{value}`"))?
518 } else {
519 u8::MAX
520 };
521
522 Ok(Self { r, g, b, a })
523 }
524}
525
526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528pub enum MetadataPolicy {
529 StripAll,
531 KeepAll,
533 PreserveExif,
535}
536
537pub fn resolve_metadata_flags(
580 strip: Option<bool>,
581 keep: Option<bool>,
582 preserve_exif: Option<bool>,
583) -> Result<(bool, bool), TransformError> {
584 let keep = keep.unwrap_or(false);
585 let preserve_exif = preserve_exif.unwrap_or(false);
586
587 if keep && preserve_exif {
588 return Err(TransformError::InvalidOptions(
589 "keepMetadata and preserveExif cannot both be true".to_string(),
590 ));
591 }
592
593 let strip_metadata = if keep || preserve_exif {
594 false
595 } else {
596 strip.unwrap_or(true)
597 };
598
599 Ok((strip_metadata, preserve_exif))
600}
601
602#[derive(Debug, Clone, PartialEq, Eq)]
604pub enum TransformError {
605 InvalidInput(String),
607 InvalidOptions(String),
609 UnsupportedInputMediaType(String),
611 UnsupportedOutputMediaType(MediaType),
613 DecodeFailed(String),
615 EncodeFailed(String),
617 CapabilityMissing(String),
619 LimitExceeded(String),
621}
622
623impl fmt::Display for TransformError {
624 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
625 match self {
626 Self::InvalidInput(reason) => write!(f, "invalid input: {reason}"),
627 Self::InvalidOptions(reason) => write!(f, "invalid transform options: {reason}"),
628 Self::UnsupportedInputMediaType(reason) => {
629 write!(f, "unsupported input media type: {reason}")
630 }
631 Self::UnsupportedOutputMediaType(media_type) => {
632 write!(f, "unsupported output media type: {media_type}")
633 }
634 Self::DecodeFailed(reason) => write!(f, "decode failed: {reason}"),
635 Self::EncodeFailed(reason) => write!(f, "encode failed: {reason}"),
636 Self::CapabilityMissing(reason) => write!(f, "missing capability: {reason}"),
637 Self::LimitExceeded(reason) => write!(f, "limit exceeded: {reason}"),
638 }
639 }
640}
641
642impl Error for TransformError {}
643
644#[derive(Debug, Clone, Copy, PartialEq, Eq)]
658pub enum MetadataKind {
659 Xmp,
661 Iptc,
663 Exif,
665 Icc,
667}
668
669impl fmt::Display for MetadataKind {
670 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
671 match self {
672 Self::Xmp => f.write_str("XMP"),
673 Self::Iptc => f.write_str("IPTC"),
674 Self::Exif => f.write_str("EXIF"),
675 Self::Icc => f.write_str("ICC profile"),
676 }
677 }
678}
679
680#[derive(Debug, Clone, PartialEq, Eq)]
696pub enum TransformWarning {
697 MetadataDropped(MetadataKind),
700}
701
702impl fmt::Display for TransformWarning {
703 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
704 match self {
705 Self::MetadataDropped(kind) => write!(
706 f,
707 "{kind} metadata was present in the input but could not be preserved by the output encoder"
708 ),
709 }
710 }
711}
712
713#[derive(Debug)]
718pub struct TransformResult {
719 pub artifact: Artifact,
721 pub warnings: Vec<TransformWarning>,
723}
724
725pub fn sniff_artifact(input: RawArtifact) -> Result<Artifact, TransformError> {
781 let (media_type, metadata) = detect_artifact(&input.bytes)?;
782
783 if let Some(declared_media_type) = input.declared_media_type
784 && declared_media_type != media_type
785 {
786 return Err(TransformError::InvalidInput(
787 "declared media type does not match detected media type".to_string(),
788 ));
789 }
790
791 Ok(Artifact::new(input.bytes, media_type, metadata))
792}
793
794fn validate_dimension(name: &str, value: Option<u32>) -> Result<(), TransformError> {
795 if matches!(value, Some(0)) {
796 return Err(TransformError::InvalidOptions(format!(
797 "{name} must be greater than zero"
798 )));
799 }
800
801 Ok(())
802}
803
804fn validate_quality(value: Option<u8>) -> Result<(), TransformError> {
805 if matches!(value, Some(0) | Some(101..=u8::MAX)) {
806 return Err(TransformError::InvalidOptions(
807 "quality must be between 1 and 100".to_string(),
808 ));
809 }
810
811 Ok(())
812}
813
814fn normalize_metadata_policy(strip_metadata: bool, preserve_exif: bool) -> MetadataPolicy {
815 if preserve_exif {
816 MetadataPolicy::PreserveExif
817 } else if strip_metadata {
818 MetadataPolicy::StripAll
819 } else {
820 MetadataPolicy::KeepAll
821 }
822}
823
824fn detect_artifact(bytes: &[u8]) -> Result<(MediaType, ArtifactMetadata), TransformError> {
825 if is_png(bytes) {
826 return Ok((MediaType::Png, sniff_png(bytes)?));
827 }
828
829 if is_jpeg(bytes) {
830 return Ok((MediaType::Jpeg, sniff_jpeg(bytes)?));
831 }
832
833 if is_webp(bytes) {
834 return Ok((MediaType::Webp, sniff_webp(bytes)?));
835 }
836
837 if is_avif(bytes) {
838 return Ok((MediaType::Avif, sniff_avif(bytes)?));
839 }
840
841 if is_bmp(bytes) {
842 return Ok((MediaType::Bmp, sniff_bmp(bytes)?));
843 }
844
845 if is_svg(bytes) {
848 return Ok((MediaType::Svg, sniff_svg(bytes)));
849 }
850
851 Err(TransformError::UnsupportedInputMediaType(
852 "unknown file signature".to_string(),
853 ))
854}
855
856fn is_png(bytes: &[u8]) -> bool {
857 bytes.starts_with(b"\x89PNG\r\n\x1a\n")
858}
859
860fn is_jpeg(bytes: &[u8]) -> bool {
861 bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
862}
863
864fn is_webp(bytes: &[u8]) -> bool {
865 bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP"
866}
867
868fn is_avif(bytes: &[u8]) -> bool {
869 bytes.len() >= 16 && &bytes[4..8] == b"ftyp" && has_avif_brand(&bytes[8..])
870}
871
872fn is_svg(bytes: &[u8]) -> bool {
875 let text = match std::str::from_utf8(bytes) {
876 Ok(s) => s,
877 Err(_) => return false,
878 };
879
880 let mut remaining = text.trim_start();
881
882 remaining = remaining.strip_prefix('\u{FEFF}').unwrap_or(remaining);
884 remaining = remaining.trim_start();
885
886 if let Some(rest) = remaining.strip_prefix("<?xml") {
888 if let Some(end) = rest.find("?>") {
889 remaining = rest[end + 2..].trim_start();
890 } else {
891 return false;
892 }
893 }
894
895 if let Some(rest) = remaining.strip_prefix("<!DOCTYPE") {
897 if let Some(end) = rest.find('>') {
898 remaining = rest[end + 1..].trim_start();
899 } else {
900 return false;
901 }
902 }
903
904 while let Some(rest) = remaining.strip_prefix("<!--") {
906 if let Some(end) = rest.find("-->") {
907 remaining = rest[end + 3..].trim_start();
908 } else {
909 return false;
910 }
911 }
912
913 remaining.starts_with("<svg")
914 && remaining
915 .as_bytes()
916 .get(4)
917 .is_some_and(|&b| b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>')
918}
919
920fn sniff_svg(_bytes: &[u8]) -> ArtifactMetadata {
924 ArtifactMetadata {
925 width: None,
926 height: None,
927 frame_count: 1,
928 duration: None,
929 has_alpha: Some(true),
930 }
931}
932
933fn is_bmp(bytes: &[u8]) -> bool {
935 bytes.len() >= 26 && bytes[0] == 0x42 && bytes[1] == 0x4D
936}
937
938fn sniff_bmp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
945 if bytes.len() < 30 {
946 return Err(TransformError::DecodeFailed(
947 "bmp file is too short".to_string(),
948 ));
949 }
950
951 let width = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]);
952 let raw_height = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
953 let height = raw_height.unsigned_abs();
954 let bits_per_pixel = u16::from_le_bytes([bytes[28], bytes[29]]);
955
956 let has_alpha = bits_per_pixel == 32;
957
958 Ok(ArtifactMetadata {
959 width: Some(width),
960 height: Some(height),
961 frame_count: 1,
962 duration: None,
963 has_alpha: Some(has_alpha),
964 })
965}
966
967fn sniff_png(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
968 if bytes.len() < 29 {
969 return Err(TransformError::DecodeFailed(
970 "png file is too short".to_string(),
971 ));
972 }
973
974 if &bytes[12..16] != b"IHDR" {
975 return Err(TransformError::DecodeFailed(
976 "png file is missing an IHDR chunk".to_string(),
977 ));
978 }
979
980 let width = read_u32_be(&bytes[16..20])?;
981 let height = read_u32_be(&bytes[20..24])?;
982 let color_type = bytes[25];
983 let has_alpha = match color_type {
984 4 | 6 => Some(true),
985 0 | 2 | 3 => Some(false),
986 _ => None,
987 };
988
989 Ok(ArtifactMetadata {
990 width: Some(width),
991 height: Some(height),
992 frame_count: 1,
993 duration: None,
994 has_alpha,
995 })
996}
997
998fn sniff_jpeg(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
999 let mut offset = 2;
1000
1001 while offset + 1 < bytes.len() {
1002 if bytes[offset] != 0xFF {
1003 return Err(TransformError::DecodeFailed(
1004 "jpeg file has an invalid marker prefix".to_string(),
1005 ));
1006 }
1007
1008 while offset < bytes.len() && bytes[offset] == 0xFF {
1009 offset += 1;
1010 }
1011
1012 if offset >= bytes.len() {
1013 break;
1014 }
1015
1016 let marker = bytes[offset];
1017 offset += 1;
1018
1019 if marker == 0xD9 || marker == 0xDA {
1020 break;
1021 }
1022
1023 if (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
1024 continue;
1025 }
1026
1027 if offset + 2 > bytes.len() {
1028 return Err(TransformError::DecodeFailed(
1029 "jpeg segment is truncated".to_string(),
1030 ));
1031 }
1032
1033 let segment_length = read_u16_be(&bytes[offset..offset + 2])? as usize;
1034 if segment_length < 2 || offset + segment_length > bytes.len() {
1035 return Err(TransformError::DecodeFailed(
1036 "jpeg segment length is invalid".to_string(),
1037 ));
1038 }
1039
1040 if is_jpeg_sof_marker(marker) {
1041 if segment_length < 7 {
1042 return Err(TransformError::DecodeFailed(
1043 "jpeg SOF segment is too short".to_string(),
1044 ));
1045 }
1046
1047 let height = read_u16_be(&bytes[offset + 3..offset + 5])? as u32;
1048 let width = read_u16_be(&bytes[offset + 5..offset + 7])? as u32;
1049
1050 return Ok(ArtifactMetadata {
1051 width: Some(width),
1052 height: Some(height),
1053 frame_count: 1,
1054 duration: None,
1055 has_alpha: Some(false),
1056 });
1057 }
1058
1059 offset += segment_length;
1060 }
1061
1062 Err(TransformError::DecodeFailed(
1063 "jpeg file is missing a SOF segment".to_string(),
1064 ))
1065}
1066
1067fn sniff_webp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1068 let mut offset = 12;
1069
1070 while offset + 8 <= bytes.len() {
1071 let chunk_tag = &bytes[offset..offset + 4];
1072 let chunk_size = read_u32_le(&bytes[offset + 4..offset + 8])? as usize;
1073 let chunk_start = offset + 8;
1074 let chunk_end = chunk_start
1075 .checked_add(chunk_size)
1076 .ok_or_else(|| TransformError::DecodeFailed("webp chunk is too large".to_string()))?;
1077
1078 if chunk_end > bytes.len() {
1079 return Err(TransformError::DecodeFailed(
1080 "webp chunk exceeds file length".to_string(),
1081 ));
1082 }
1083
1084 let chunk_data = &bytes[chunk_start..chunk_end];
1085
1086 match chunk_tag {
1087 b"VP8X" => return sniff_webp_vp8x(chunk_data),
1088 b"VP8 " => return sniff_webp_vp8(chunk_data),
1089 b"VP8L" => return sniff_webp_vp8l(chunk_data),
1090 _ => {}
1091 }
1092
1093 offset = chunk_end + (chunk_size % 2);
1094 }
1095
1096 Err(TransformError::DecodeFailed(
1097 "webp file is missing an image chunk".to_string(),
1098 ))
1099}
1100
1101fn sniff_webp_vp8x(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1102 if bytes.len() < 10 {
1103 return Err(TransformError::DecodeFailed(
1104 "webp VP8X chunk is too short".to_string(),
1105 ));
1106 }
1107
1108 let flags = bytes[0];
1109 let width = read_u24_le(&bytes[4..7])? + 1;
1110 let height = read_u24_le(&bytes[7..10])? + 1;
1111 let has_alpha = Some(flags & 0b0001_0000 != 0);
1112
1113 Ok(ArtifactMetadata {
1114 width: Some(width),
1115 height: Some(height),
1116 frame_count: 1,
1117 duration: None,
1118 has_alpha,
1119 })
1120}
1121
1122fn sniff_webp_vp8(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1123 if bytes.len() < 10 {
1124 return Err(TransformError::DecodeFailed(
1125 "webp VP8 chunk is too short".to_string(),
1126 ));
1127 }
1128
1129 if bytes[3..6] != [0x9D, 0x01, 0x2A] {
1130 return Err(TransformError::DecodeFailed(
1131 "webp VP8 chunk has an invalid start code".to_string(),
1132 ));
1133 }
1134
1135 let width = (read_u16_le(&bytes[6..8])? & 0x3FFF) as u32;
1136 let height = (read_u16_le(&bytes[8..10])? & 0x3FFF) as u32;
1137
1138 Ok(ArtifactMetadata {
1139 width: Some(width),
1140 height: Some(height),
1141 frame_count: 1,
1142 duration: None,
1143 has_alpha: Some(false),
1144 })
1145}
1146
1147fn sniff_webp_vp8l(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1148 if bytes.len() < 5 {
1149 return Err(TransformError::DecodeFailed(
1150 "webp VP8L chunk is too short".to_string(),
1151 ));
1152 }
1153
1154 if bytes[0] != 0x2F {
1155 return Err(TransformError::DecodeFailed(
1156 "webp VP8L chunk has an invalid signature".to_string(),
1157 ));
1158 }
1159
1160 let bits = read_u32_le(&bytes[1..5])?;
1161 let width = (bits & 0x3FFF) + 1;
1162 let height = ((bits >> 14) & 0x3FFF) + 1;
1163
1164 Ok(ArtifactMetadata {
1165 width: Some(width),
1166 height: Some(height),
1167 frame_count: 1,
1168 duration: None,
1169 has_alpha: None,
1170 })
1171}
1172
1173fn sniff_avif(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
1174 if bytes.len() < 16 {
1175 return Err(TransformError::DecodeFailed(
1176 "avif file is too short".to_string(),
1177 ));
1178 }
1179
1180 if !has_avif_brand(&bytes[8..]) {
1181 return Err(TransformError::DecodeFailed(
1182 "avif file is missing a compatible AVIF brand".to_string(),
1183 ));
1184 }
1185
1186 let inspection = inspect_avif_container(bytes)?;
1187
1188 Ok(ArtifactMetadata {
1189 width: inspection.dimensions.map(|(width, _)| width),
1190 height: inspection.dimensions.map(|(_, height)| height),
1191 frame_count: 1,
1192 duration: None,
1193 has_alpha: inspection.has_alpha(),
1194 })
1195}
1196
1197fn has_avif_brand(bytes: &[u8]) -> bool {
1198 if bytes.len() < 8 {
1199 return false;
1200 }
1201
1202 if is_avif_brand(&bytes[0..4]) {
1203 return true;
1204 }
1205
1206 let mut offset = 8;
1207 while offset + 4 <= bytes.len() {
1208 if is_avif_brand(&bytes[offset..offset + 4]) {
1209 return true;
1210 }
1211 offset += 4;
1212 }
1213
1214 false
1215}
1216
1217fn is_avif_brand(bytes: &[u8]) -> bool {
1218 matches!(bytes, b"avif" | b"avis")
1219}
1220
1221const AVIF_ALPHA_AUX_TYPE: &[u8] = b"urn:mpeg:mpegB:cicp:systems:auxiliary:alpha";
1222
1223#[derive(Debug, Default)]
1224struct AvifInspection {
1225 dimensions: Option<(u32, u32)>,
1226 saw_structured_meta: bool,
1227 found_alpha_item: bool,
1228}
1229
1230impl AvifInspection {
1231 fn has_alpha(&self) -> Option<bool> {
1232 if self.saw_structured_meta {
1233 Some(self.found_alpha_item)
1234 } else {
1235 None
1236 }
1237 }
1238}
1239
1240fn inspect_avif_container(bytes: &[u8]) -> Result<AvifInspection, TransformError> {
1241 let mut inspection = AvifInspection::default();
1242 inspect_avif_boxes(bytes, &mut inspection)?;
1243 Ok(inspection)
1244}
1245
1246fn inspect_avif_boxes(bytes: &[u8], inspection: &mut AvifInspection) -> Result<(), TransformError> {
1247 let mut offset = 0;
1248
1249 while offset + 8 <= bytes.len() {
1250 let (box_type, payload, next_offset) = parse_mp4_box(bytes, offset)?;
1251
1252 match box_type {
1253 b"meta" | b"iref" => {
1254 inspection.saw_structured_meta = true;
1255 if payload.len() < 4 {
1256 return Err(TransformError::DecodeFailed(format!(
1257 "{} box is too short",
1258 String::from_utf8_lossy(box_type)
1259 )));
1260 }
1261 inspect_avif_boxes(&payload[4..], inspection)?;
1262 }
1263 b"iprp" | b"ipco" => {
1264 inspection.saw_structured_meta = true;
1265 inspect_avif_boxes(payload, inspection)?;
1266 }
1267 b"ispe" => {
1268 inspection.saw_structured_meta = true;
1269 if inspection.dimensions.is_none() {
1270 inspection.dimensions = Some(parse_avif_ispe(payload)?);
1271 }
1272 }
1273 b"auxC" => {
1274 inspection.saw_structured_meta = true;
1275 if avif_auxc_declares_alpha(payload)? {
1276 inspection.found_alpha_item = true;
1277 }
1278 }
1279 b"auxl" => {
1280 inspection.saw_structured_meta = true;
1281 inspection.found_alpha_item = true;
1282 }
1283 _ => {}
1284 }
1285
1286 offset = next_offset;
1287 }
1288
1289 if offset != bytes.len() {
1290 return Err(TransformError::DecodeFailed(
1291 "avif box payload has trailing bytes".to_string(),
1292 ));
1293 }
1294
1295 Ok(())
1296}
1297
1298fn parse_mp4_box(bytes: &[u8], offset: usize) -> Result<(&[u8; 4], &[u8], usize), TransformError> {
1299 if offset + 8 > bytes.len() {
1300 return Err(TransformError::DecodeFailed(
1301 "mp4 box header is truncated".to_string(),
1302 ));
1303 }
1304
1305 let size = read_u32_be(&bytes[offset..offset + 4])?;
1306 let box_type = bytes[offset + 4..offset + 8]
1307 .try_into()
1308 .map_err(|_| TransformError::DecodeFailed("expected 4-byte box type".to_string()))?;
1309 let mut header_len = 8_usize;
1310 let end = match size {
1311 0 => bytes.len(),
1312 1 => {
1313 if offset + 16 > bytes.len() {
1314 return Err(TransformError::DecodeFailed(
1315 "extended mp4 box header is truncated".to_string(),
1316 ));
1317 }
1318 header_len = 16;
1319 let extended_size = read_u64_be(&bytes[offset + 8..offset + 16])?;
1320 usize::try_from(extended_size)
1321 .map_err(|_| TransformError::DecodeFailed("mp4 box is too large".to_string()))?
1322 }
1323 _ => size as usize,
1324 };
1325
1326 if end < header_len {
1327 return Err(TransformError::DecodeFailed(
1328 "mp4 box size is smaller than its header".to_string(),
1329 ));
1330 }
1331
1332 let box_end = offset
1333 .checked_add(end)
1334 .ok_or_else(|| TransformError::DecodeFailed("mp4 box is too large".to_string()))?;
1335 if box_end > bytes.len() {
1336 return Err(TransformError::DecodeFailed(
1337 "mp4 box exceeds file length".to_string(),
1338 ));
1339 }
1340
1341 Ok((box_type, &bytes[offset + header_len..box_end], box_end))
1342}
1343
1344fn parse_avif_ispe(bytes: &[u8]) -> Result<(u32, u32), TransformError> {
1345 if bytes.len() < 12 {
1346 return Err(TransformError::DecodeFailed(
1347 "avif ispe box is too short".to_string(),
1348 ));
1349 }
1350
1351 let width = read_u32_be(&bytes[4..8])?;
1352 let height = read_u32_be(&bytes[8..12])?;
1353 Ok((width, height))
1354}
1355
1356fn avif_auxc_declares_alpha(bytes: &[u8]) -> Result<bool, TransformError> {
1357 if bytes.len() < 5 {
1358 return Err(TransformError::DecodeFailed(
1359 "avif auxC box is too short".to_string(),
1360 ));
1361 }
1362
1363 let urn = &bytes[4..];
1364 Ok(urn
1365 .strip_suffix(&[0])
1366 .is_some_and(|urn| urn == AVIF_ALPHA_AUX_TYPE))
1367}
1368
1369fn is_jpeg_sof_marker(marker: u8) -> bool {
1370 matches!(
1371 marker,
1372 0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF
1373 )
1374}
1375
1376fn read_u16_be(bytes: &[u8]) -> Result<u16, TransformError> {
1377 let array: [u8; 2] = bytes
1378 .try_into()
1379 .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
1380 Ok(u16::from_be_bytes(array))
1381}
1382
1383fn read_u16_le(bytes: &[u8]) -> Result<u16, TransformError> {
1384 let array: [u8; 2] = bytes
1385 .try_into()
1386 .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
1387 Ok(u16::from_le_bytes(array))
1388}
1389
1390fn read_u24_le(bytes: &[u8]) -> Result<u32, TransformError> {
1391 if bytes.len() != 3 {
1392 return Err(TransformError::DecodeFailed("expected 3 bytes".to_string()));
1393 }
1394
1395 Ok(u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16))
1396}
1397
1398fn read_u32_be(bytes: &[u8]) -> Result<u32, TransformError> {
1399 let array: [u8; 4] = bytes
1400 .try_into()
1401 .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
1402 Ok(u32::from_be_bytes(array))
1403}
1404
1405fn read_u32_le(bytes: &[u8]) -> Result<u32, TransformError> {
1406 let array: [u8; 4] = bytes
1407 .try_into()
1408 .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
1409 Ok(u32::from_le_bytes(array))
1410}
1411
1412fn read_u64_be(bytes: &[u8]) -> Result<u64, TransformError> {
1413 let array: [u8; 8] = bytes
1414 .try_into()
1415 .map_err(|_| TransformError::DecodeFailed("expected 8 bytes".to_string()))?;
1416 Ok(u64::from_be_bytes(array))
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421 use super::{
1422 Artifact, ArtifactMetadata, Fit, MediaType, MetadataPolicy, Position, RawArtifact, Rgba8,
1423 Rotation, TransformError, TransformOptions, TransformRequest, sniff_artifact,
1424 };
1425 use image::codecs::avif::AvifEncoder;
1426 use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
1427
1428 fn jpeg_artifact() -> Artifact {
1429 Artifact::new(vec![1, 2, 3], MediaType::Jpeg, ArtifactMetadata::default())
1430 }
1431
1432 fn png_bytes(width: u32, height: u32, color_type: u8) -> Vec<u8> {
1433 let mut bytes = Vec::new();
1434 bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
1435 bytes.extend_from_slice(&13_u32.to_be_bytes());
1436 bytes.extend_from_slice(b"IHDR");
1437 bytes.extend_from_slice(&width.to_be_bytes());
1438 bytes.extend_from_slice(&height.to_be_bytes());
1439 bytes.push(8);
1440 bytes.push(color_type);
1441 bytes.push(0);
1442 bytes.push(0);
1443 bytes.push(0);
1444 bytes.extend_from_slice(&0_u32.to_be_bytes());
1445 bytes
1446 }
1447
1448 fn jpeg_bytes(width: u16, height: u16) -> Vec<u8> {
1449 let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
1450 bytes.extend_from_slice(&[0; 14]);
1451 bytes.extend_from_slice(&[
1452 0xFF,
1453 0xC0,
1454 0x00,
1455 0x11,
1456 0x08,
1457 (height >> 8) as u8,
1458 height as u8,
1459 (width >> 8) as u8,
1460 width as u8,
1461 0x03,
1462 0x01,
1463 0x11,
1464 0x00,
1465 0x02,
1466 0x11,
1467 0x00,
1468 0x03,
1469 0x11,
1470 0x00,
1471 ]);
1472 bytes.extend_from_slice(&[0xFF, 0xD9]);
1473 bytes
1474 }
1475
1476 fn webp_vp8x_bytes(width: u32, height: u32, flags: u8) -> Vec<u8> {
1477 let width_minus_one = width - 1;
1478 let height_minus_one = height - 1;
1479 let mut bytes = Vec::new();
1480 bytes.extend_from_slice(b"RIFF");
1481 bytes.extend_from_slice(&30_u32.to_le_bytes());
1482 bytes.extend_from_slice(b"WEBP");
1483 bytes.extend_from_slice(b"VP8X");
1484 bytes.extend_from_slice(&10_u32.to_le_bytes());
1485 bytes.push(flags);
1486 bytes.extend_from_slice(&[0, 0, 0]);
1487 bytes.extend_from_slice(&[
1488 (width_minus_one & 0xFF) as u8,
1489 ((width_minus_one >> 8) & 0xFF) as u8,
1490 ((width_minus_one >> 16) & 0xFF) as u8,
1491 ]);
1492 bytes.extend_from_slice(&[
1493 (height_minus_one & 0xFF) as u8,
1494 ((height_minus_one >> 8) & 0xFF) as u8,
1495 ((height_minus_one >> 16) & 0xFF) as u8,
1496 ]);
1497 bytes
1498 }
1499
1500 fn webp_vp8l_bytes(width: u32, height: u32) -> Vec<u8> {
1501 let packed = (width - 1) | ((height - 1) << 14);
1502 let mut bytes = Vec::new();
1503 bytes.extend_from_slice(b"RIFF");
1504 bytes.extend_from_slice(&17_u32.to_le_bytes());
1505 bytes.extend_from_slice(b"WEBP");
1506 bytes.extend_from_slice(b"VP8L");
1507 bytes.extend_from_slice(&5_u32.to_le_bytes());
1508 bytes.push(0x2F);
1509 bytes.extend_from_slice(&packed.to_le_bytes());
1510 bytes.push(0);
1511 bytes
1512 }
1513
1514 fn avif_bytes() -> Vec<u8> {
1515 let mut bytes = Vec::new();
1516 bytes.extend_from_slice(&24_u32.to_be_bytes());
1517 bytes.extend_from_slice(b"ftyp");
1518 bytes.extend_from_slice(b"avif");
1519 bytes.extend_from_slice(&0_u32.to_be_bytes());
1520 bytes.extend_from_slice(b"mif1");
1521 bytes.extend_from_slice(b"avif");
1522 bytes
1523 }
1524
1525 fn encoded_avif_bytes(width: u32, height: u32, fill: Rgba<u8>) -> Vec<u8> {
1526 let image = RgbaImage::from_pixel(width, height, fill);
1527 let mut bytes = Vec::new();
1528 AvifEncoder::new(&mut bytes)
1529 .write_image(&image, width, height, ColorType::Rgba8.into())
1530 .expect("encode avif");
1531 bytes
1532 }
1533
1534 #[test]
1535 fn default_transform_options_match_documented_defaults() {
1536 let options = TransformOptions::default();
1537
1538 assert_eq!(options.width, None);
1539 assert_eq!(options.height, None);
1540 assert_eq!(options.fit, None);
1541 assert_eq!(options.position, None);
1542 assert_eq!(options.format, None);
1543 assert_eq!(options.quality, None);
1544 assert_eq!(options.rotate, Rotation::Deg0);
1545 assert!(options.auto_orient);
1546 assert!(options.strip_metadata);
1547 assert!(!options.preserve_exif);
1548 }
1549
1550 #[test]
1551 fn media_type_helpers_report_expected_values() {
1552 assert_eq!(MediaType::Jpeg.as_name(), "jpeg");
1553 assert_eq!(MediaType::Jpeg.as_mime(), "image/jpeg");
1554 assert!(MediaType::Webp.is_lossy());
1555 assert!(!MediaType::Png.is_lossy());
1556 }
1557
1558 #[test]
1559 fn media_type_parsing_accepts_documented_names() {
1560 assert_eq!("jpeg".parse::<MediaType>(), Ok(MediaType::Jpeg));
1561 assert_eq!("jpg".parse::<MediaType>(), Ok(MediaType::Jpeg));
1562 assert_eq!("png".parse::<MediaType>(), Ok(MediaType::Png));
1563 assert!("gif".parse::<MediaType>().is_err());
1564 }
1565
1566 #[test]
1567 fn fit_position_rotation_and_color_parsing_work() {
1568 assert_eq!("cover".parse::<Fit>(), Ok(Fit::Cover));
1569 assert_eq!(
1570 "bottom-right".parse::<Position>(),
1571 Ok(Position::BottomRight)
1572 );
1573 assert_eq!("270".parse::<Rotation>(), Ok(Rotation::Deg270));
1574 assert_eq!(
1575 Rgba8::from_hex("AABBCCDD"),
1576 Ok(Rgba8 {
1577 r: 0xAA,
1578 g: 0xBB,
1579 b: 0xCC,
1580 a: 0xDD
1581 })
1582 );
1583 assert!(Rgba8::from_hex("AABB").is_err());
1584 }
1585
1586 #[test]
1587 fn normalize_defaults_fit_and_position_for_bounded_resize() {
1588 let normalized = TransformOptions {
1589 width: Some(1200),
1590 height: Some(630),
1591 ..TransformOptions::default()
1592 }
1593 .normalize(MediaType::Jpeg)
1594 .expect("normalize bounded resize");
1595
1596 assert_eq!(normalized.fit, Some(Fit::Contain));
1597 assert_eq!(normalized.position, Position::Center);
1598 assert_eq!(normalized.format, MediaType::Jpeg);
1599 assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
1600 }
1601
1602 #[test]
1603 fn normalize_uses_requested_fit_and_output_format() {
1604 let normalized = TransformOptions {
1605 width: Some(320),
1606 height: Some(320),
1607 fit: Some(Fit::Cover),
1608 position: Some(Position::BottomRight),
1609 format: Some(MediaType::Webp),
1610 quality: Some(70),
1611 strip_metadata: false,
1612 preserve_exif: true,
1613 ..TransformOptions::default()
1614 }
1615 .normalize(MediaType::Jpeg)
1616 .expect("normalize explicit values");
1617
1618 assert_eq!(normalized.fit, Some(Fit::Cover));
1619 assert_eq!(normalized.position, Position::BottomRight);
1620 assert_eq!(normalized.format, MediaType::Webp);
1621 assert_eq!(normalized.quality, Some(70));
1622 assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveExif);
1623 }
1624
1625 #[test]
1626 fn normalize_can_keep_all_metadata() {
1627 let normalized = TransformOptions {
1628 strip_metadata: false,
1629 ..TransformOptions::default()
1630 }
1631 .normalize(MediaType::Jpeg)
1632 .expect("normalize keep metadata");
1633
1634 assert_eq!(normalized.metadata_policy, MetadataPolicy::KeepAll);
1635 }
1636
1637 #[test]
1638 fn normalize_keeps_fit_none_when_resize_is_not_bounded() {
1639 let normalized = TransformOptions {
1640 width: Some(500),
1641 ..TransformOptions::default()
1642 }
1643 .normalize(MediaType::Jpeg)
1644 .expect("normalize unbounded resize");
1645
1646 assert_eq!(normalized.fit, None);
1647 assert_eq!(normalized.position, Position::Center);
1648 }
1649
1650 #[test]
1651 fn normalize_rejects_zero_dimensions() {
1652 let err = TransformOptions {
1653 width: Some(0),
1654 ..TransformOptions::default()
1655 }
1656 .normalize(MediaType::Jpeg)
1657 .expect_err("zero width should fail");
1658
1659 assert_eq!(
1660 err,
1661 TransformError::InvalidOptions("width must be greater than zero".to_string())
1662 );
1663 }
1664
1665 #[test]
1666 fn normalize_rejects_fit_without_both_dimensions() {
1667 let err = TransformOptions {
1668 width: Some(300),
1669 fit: Some(Fit::Contain),
1670 ..TransformOptions::default()
1671 }
1672 .normalize(MediaType::Jpeg)
1673 .expect_err("fit without bounded resize should fail");
1674
1675 assert_eq!(
1676 err,
1677 TransformError::InvalidOptions("fit requires both width and height".to_string())
1678 );
1679 }
1680
1681 #[test]
1682 fn normalize_rejects_position_without_both_dimensions() {
1683 let err = TransformOptions {
1684 height: Some(300),
1685 position: Some(Position::Top),
1686 ..TransformOptions::default()
1687 }
1688 .normalize(MediaType::Jpeg)
1689 .expect_err("position without bounded resize should fail");
1690
1691 assert_eq!(
1692 err,
1693 TransformError::InvalidOptions("position requires both width and height".to_string())
1694 );
1695 }
1696
1697 #[test]
1698 fn normalize_rejects_quality_for_lossless_output() {
1699 let err = TransformOptions {
1700 format: Some(MediaType::Png),
1701 quality: Some(80),
1702 ..TransformOptions::default()
1703 }
1704 .normalize(MediaType::Jpeg)
1705 .expect_err("quality for png should fail");
1706
1707 assert_eq!(
1708 err,
1709 TransformError::InvalidOptions("quality requires a lossy output format".to_string())
1710 );
1711 }
1712
1713 #[test]
1714 fn normalize_rejects_zero_quality() {
1715 let err = TransformOptions {
1716 quality: Some(0),
1717 ..TransformOptions::default()
1718 }
1719 .normalize(MediaType::Jpeg)
1720 .expect_err("zero quality should fail");
1721
1722 assert_eq!(
1723 err,
1724 TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
1725 );
1726 }
1727
1728 #[test]
1729 fn normalize_rejects_quality_above_one_hundred() {
1730 let err = TransformOptions {
1731 quality: Some(101),
1732 ..TransformOptions::default()
1733 }
1734 .normalize(MediaType::Jpeg)
1735 .expect_err("quality above one hundred should fail");
1736
1737 assert_eq!(
1738 err,
1739 TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
1740 );
1741 }
1742
1743 #[test]
1744 fn normalize_rejects_preserve_exif_when_metadata_is_stripped() {
1745 let err = TransformOptions {
1746 preserve_exif: true,
1747 ..TransformOptions::default()
1748 }
1749 .normalize(MediaType::Jpeg)
1750 .expect_err("preserve_exif should require metadata retention");
1751
1752 assert_eq!(
1753 err,
1754 TransformError::InvalidOptions(
1755 "preserve_exif requires strip_metadata to be false".to_string()
1756 )
1757 );
1758 }
1759
1760 #[test]
1761 fn transform_request_normalize_uses_input_media_type_as_default_output() {
1762 let request = TransformRequest::new(jpeg_artifact(), TransformOptions::default());
1763 let normalized = request.normalize().expect("normalize request");
1764
1765 assert_eq!(normalized.input.media_type, MediaType::Jpeg);
1766 assert_eq!(normalized.options.format, MediaType::Jpeg);
1767 assert_eq!(normalized.options.metadata_policy, MetadataPolicy::StripAll);
1768 }
1769
1770 #[test]
1771 fn sniff_artifact_detects_png_dimensions_and_alpha() {
1772 let artifact =
1773 sniff_artifact(RawArtifact::new(png_bytes(64, 32, 6), None)).expect("sniff png");
1774
1775 assert_eq!(artifact.media_type, MediaType::Png);
1776 assert_eq!(artifact.metadata.width, Some(64));
1777 assert_eq!(artifact.metadata.height, Some(32));
1778 assert_eq!(artifact.metadata.has_alpha, Some(true));
1779 }
1780
1781 #[test]
1782 fn sniff_artifact_detects_jpeg_dimensions() {
1783 let artifact =
1784 sniff_artifact(RawArtifact::new(jpeg_bytes(320, 240), None)).expect("sniff jpeg");
1785
1786 assert_eq!(artifact.media_type, MediaType::Jpeg);
1787 assert_eq!(artifact.metadata.width, Some(320));
1788 assert_eq!(artifact.metadata.height, Some(240));
1789 assert_eq!(artifact.metadata.has_alpha, Some(false));
1790 }
1791
1792 #[test]
1793 fn sniff_artifact_detects_webp_vp8x_dimensions() {
1794 let artifact = sniff_artifact(RawArtifact::new(
1795 webp_vp8x_bytes(800, 600, 0b0001_0000),
1796 None,
1797 ))
1798 .expect("sniff webp vp8x");
1799
1800 assert_eq!(artifact.media_type, MediaType::Webp);
1801 assert_eq!(artifact.metadata.width, Some(800));
1802 assert_eq!(artifact.metadata.height, Some(600));
1803 assert_eq!(artifact.metadata.has_alpha, Some(true));
1804 }
1805
1806 #[test]
1807 fn sniff_artifact_detects_webp_vp8l_dimensions() {
1808 let artifact = sniff_artifact(RawArtifact::new(webp_vp8l_bytes(123, 77), None))
1809 .expect("sniff webp vp8l");
1810
1811 assert_eq!(artifact.media_type, MediaType::Webp);
1812 assert_eq!(artifact.metadata.width, Some(123));
1813 assert_eq!(artifact.metadata.height, Some(77));
1814 }
1815
1816 #[test]
1817 fn sniff_artifact_detects_avif_brand() {
1818 let artifact = sniff_artifact(RawArtifact::new(avif_bytes(), None)).expect("sniff avif");
1819
1820 assert_eq!(artifact.media_type, MediaType::Avif);
1821 assert_eq!(artifact.metadata, ArtifactMetadata::default());
1822 }
1823
1824 #[test]
1825 fn sniff_artifact_detects_avif_dimensions_and_alpha() {
1826 let artifact = sniff_artifact(RawArtifact::new(
1827 encoded_avif_bytes(7, 5, Rgba([10, 20, 30, 0])),
1828 None,
1829 ))
1830 .expect("sniff avif with alpha");
1831
1832 assert_eq!(artifact.media_type, MediaType::Avif);
1833 assert_eq!(artifact.metadata.width, Some(7));
1834 assert_eq!(artifact.metadata.height, Some(5));
1835 assert_eq!(artifact.metadata.has_alpha, Some(true));
1836 }
1837
1838 #[test]
1839 fn sniff_artifact_detects_opaque_avif_without_alpha_item() {
1840 let artifact = sniff_artifact(RawArtifact::new(
1841 encoded_avif_bytes(9, 4, Rgba([10, 20, 30, 255])),
1842 None,
1843 ))
1844 .expect("sniff opaque avif");
1845
1846 assert_eq!(artifact.media_type, MediaType::Avif);
1847 assert_eq!(artifact.metadata.width, Some(9));
1848 assert_eq!(artifact.metadata.height, Some(4));
1849 assert_eq!(artifact.metadata.has_alpha, Some(false));
1850 }
1851
1852 #[test]
1853 fn sniff_artifact_rejects_declared_media_type_mismatch() {
1854 let err = sniff_artifact(RawArtifact::new(png_bytes(8, 8, 2), Some(MediaType::Jpeg)))
1855 .expect_err("declared mismatch should fail");
1856
1857 assert_eq!(
1858 err,
1859 TransformError::InvalidInput(
1860 "declared media type does not match detected media type".to_string()
1861 )
1862 );
1863 }
1864
1865 #[test]
1866 fn sniff_artifact_rejects_unknown_signatures() {
1867 let err =
1868 sniff_artifact(RawArtifact::new(vec![1, 2, 3, 4], None)).expect_err("unknown bytes");
1869
1870 assert_eq!(
1871 err,
1872 TransformError::UnsupportedInputMediaType("unknown file signature".to_string())
1873 );
1874 }
1875
1876 #[test]
1877 fn sniff_artifact_rejects_invalid_png_structure() {
1878 let err = sniff_artifact(RawArtifact::new(b"\x89PNG\r\n\x1a\nbroken".to_vec(), None))
1879 .expect_err("broken png should fail");
1880
1881 assert_eq!(
1882 err,
1883 TransformError::DecodeFailed("png file is too short".to_string())
1884 );
1885 }
1886
1887 #[test]
1888 fn sniff_artifact_detects_bmp_dimensions() {
1889 let mut bmp = Vec::new();
1892 bmp.extend_from_slice(b"BM");
1894 bmp.extend_from_slice(&0u32.to_le_bytes());
1896 bmp.extend_from_slice(&0u32.to_le_bytes());
1898 bmp.extend_from_slice(&54u32.to_le_bytes());
1900 bmp.extend_from_slice(&40u32.to_le_bytes());
1902 bmp.extend_from_slice(&8u32.to_le_bytes());
1904 bmp.extend_from_slice(&6i32.to_le_bytes());
1906 bmp.extend_from_slice(&1u16.to_le_bytes());
1908 bmp.extend_from_slice(&24u16.to_le_bytes());
1910 bmp.resize(54, 0);
1912
1913 let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
1914 assert_eq!(artifact.media_type, MediaType::Bmp);
1915 assert_eq!(artifact.metadata.width, Some(8));
1916 assert_eq!(artifact.metadata.height, Some(6));
1917 assert_eq!(artifact.metadata.has_alpha, Some(false));
1918 }
1919
1920 #[test]
1921 fn sniff_artifact_detects_bmp_32bit_alpha() {
1922 let mut bmp = Vec::new();
1923 bmp.extend_from_slice(b"BM");
1924 bmp.extend_from_slice(&0u32.to_le_bytes());
1925 bmp.extend_from_slice(&0u32.to_le_bytes());
1926 bmp.extend_from_slice(&54u32.to_le_bytes());
1927 bmp.extend_from_slice(&40u32.to_le_bytes());
1928 bmp.extend_from_slice(&4u32.to_le_bytes());
1930 bmp.extend_from_slice(&4i32.to_le_bytes());
1932 bmp.extend_from_slice(&1u16.to_le_bytes());
1934 bmp.extend_from_slice(&32u16.to_le_bytes());
1936 bmp.resize(54, 0);
1937
1938 let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
1939 assert_eq!(artifact.media_type, MediaType::Bmp);
1940 assert_eq!(artifact.metadata.has_alpha, Some(true));
1941 }
1942
1943 #[test]
1944 fn sniff_artifact_rejects_too_short_bmp() {
1945 let mut data = b"BM".to_vec();
1947 data.resize(27, 0);
1948 let err =
1949 sniff_artifact(RawArtifact::new(data, None)).expect_err("too-short BMP should fail");
1950
1951 assert_eq!(
1952 err,
1953 TransformError::DecodeFailed("bmp file is too short".to_string())
1954 );
1955 }
1956}