Skip to main content

truss/
core.rs

1//! Shared Core types for transformations, validation, and media inspection.
2
3use std::error::Error;
4use std::fmt;
5use std::str::FromStr;
6use std::time::Duration;
7
8/// Maximum number of pixels in the output image (width × height).
9///
10/// This limit prevents resize operations from producing excessively large
11/// output buffers. The value matches the API specification in `doc/api.md`.
12///
13/// ```
14/// assert_eq!(truss::MAX_OUTPUT_PIXELS, 67_108_864);
15/// ```
16pub const MAX_OUTPUT_PIXELS: u64 = 67_108_864;
17
18/// Maximum number of decoded pixels allowed for an input image (width × height).
19///
20/// This limit prevents decompression bombs from consuming unbounded memory.
21/// The value matches the API specification in `doc/api.md`.
22///
23/// ```
24/// assert_eq!(truss::MAX_DECODED_PIXELS, 100_000_000);
25/// ```
26pub const MAX_DECODED_PIXELS: u64 = 100_000_000;
27
28/// Raw input bytes before media-type detection has completed.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RawArtifact {
31    /// The raw input bytes.
32    pub bytes: Vec<u8>,
33    /// The media type declared by an adapter, if one is available.
34    pub declared_media_type: Option<MediaType>,
35}
36
37impl RawArtifact {
38    /// Creates a new raw artifact value.
39    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/// A decoded or otherwise classified artifact handled by the Core layer.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Artifact {
50    /// The artifact bytes.
51    pub bytes: Vec<u8>,
52    /// The detected media type for the bytes.
53    pub media_type: MediaType,
54    /// Additional metadata extracted from the artifact.
55    pub metadata: ArtifactMetadata,
56}
57
58impl Artifact {
59    /// Creates a new artifact value.
60    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/// Metadata that the Core layer can carry between decode and encode steps.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ArtifactMetadata {
72    /// The rendered width in pixels, when known.
73    pub width: Option<u32>,
74    /// The rendered height in pixels, when known.
75    pub height: Option<u32>,
76    /// The number of frames contained in the artifact.
77    pub frame_count: u32,
78    /// The total animation duration, when known.
79    pub duration: Option<Duration>,
80    /// Whether the artifact contains alpha, when known.
81    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/// Supported media types for the current implementation phase.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum MediaType {
99    /// JPEG image data.
100    Jpeg,
101    /// PNG image data.
102    Png,
103    /// WebP image data.
104    Webp,
105    /// AVIF image data.
106    Avif,
107    /// SVG image data.
108    Svg,
109    /// BMP image data.
110    Bmp,
111}
112
113impl MediaType {
114    /// Returns the canonical media type name used by the API and CLI.
115    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    /// Returns the canonical MIME type string.
127    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    /// Reports whether the media type is typically encoded with lossy quality controls.
139    pub const fn is_lossy(self) -> bool {
140        matches!(self, Self::Jpeg | Self::Webp | Self::Avif)
141    }
142
143    /// Returns `true` if this is a raster (bitmap) format, `false` for vector formats.
144    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/// A complete transform request for the Core layer.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct TransformRequest {
174    /// The already-resolved input artifact.
175    pub input: Artifact,
176    /// Raw transform options as provided by an adapter.
177    pub options: TransformOptions,
178}
179
180impl TransformRequest {
181    /// Creates a new transform request.
182    pub fn new(input: Artifact, options: TransformOptions) -> Self {
183        Self { input, options }
184    }
185
186    /// Normalizes the request into a form that does not require adapter-specific defaults.
187    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/// A fully normalized transform request.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct NormalizedTransformRequest {
200    /// The normalized input artifact.
201    pub input: Artifact,
202    /// Fully normalized transform options.
203    pub options: NormalizedTransformOptions,
204}
205
206/// Raw transform options before defaulting and validation has completed.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct TransformOptions {
209    /// The desired output width in pixels.
210    pub width: Option<u32>,
211    /// The desired output height in pixels.
212    pub height: Option<u32>,
213    /// The requested resize fit mode.
214    pub fit: Option<Fit>,
215    /// The requested positioning mode.
216    pub position: Option<Position>,
217    /// The requested output format.
218    pub format: Option<MediaType>,
219    /// The requested lossy quality.
220    pub quality: Option<u8>,
221    /// The requested background color.
222    pub background: Option<Rgba8>,
223    /// The requested extra rotation.
224    pub rotate: Rotation,
225    /// Whether EXIF-based auto-orientation should run.
226    pub auto_orient: bool,
227    /// Whether metadata should be stripped from the output.
228    pub strip_metadata: bool,
229    /// Whether EXIF metadata should be preserved.
230    pub preserve_exif: bool,
231    /// Optional wall-clock deadline for the transform pipeline.
232    ///
233    /// When set, the transform checks elapsed time at each pipeline stage and returns
234    /// [`TransformError::LimitExceeded`] if the deadline is exceeded. Adapters inject
235    /// this value based on their operational requirements — for example, the HTTP server
236    /// sets a 30-second deadline while the CLI leaves it as `None` (unlimited).
237    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    /// Normalizes and validates the options against the input media type.
261    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/// Fully normalized transform options ready for a backend pipeline.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct NormalizedTransformOptions {
328    /// The desired output width in pixels.
329    pub width: Option<u32>,
330    /// The desired output height in pixels.
331    pub height: Option<u32>,
332    /// The normalized resize fit mode.
333    pub fit: Option<Fit>,
334    /// The normalized positioning mode.
335    pub position: Position,
336    /// The resolved output format.
337    pub format: MediaType,
338    /// The requested lossy quality.
339    pub quality: Option<u8>,
340    /// The requested background color.
341    pub background: Option<Rgba8>,
342    /// The requested extra rotation.
343    pub rotate: Rotation,
344    /// Whether EXIF-based auto-orientation should run.
345    pub auto_orient: bool,
346    /// The normalized metadata handling strategy.
347    pub metadata_policy: MetadataPolicy,
348    /// Optional wall-clock deadline for the transform pipeline.
349    pub deadline: Option<Duration>,
350}
351
352/// Resize behavior for bounded transforms.
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum Fit {
355    /// Scale to fit within the box while preserving aspect ratio.
356    Contain,
357    /// Scale to cover the box while preserving aspect ratio.
358    Cover,
359    /// Stretch to fill the box.
360    Fill,
361    /// Scale down only when the input is larger than the box.
362    Inside,
363}
364
365impl Fit {
366    /// Returns the canonical option name used by the API and CLI.
367    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/// Positioning behavior for bounded transforms.
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393pub enum Position {
394    /// Center alignment.
395    Center,
396    /// Top alignment.
397    Top,
398    /// Right alignment.
399    Right,
400    /// Bottom alignment.
401    Bottom,
402    /// Left alignment.
403    Left,
404    /// Top-left alignment.
405    TopLeft,
406    /// Top-right alignment.
407    TopRight,
408    /// Bottom-left alignment.
409    BottomLeft,
410    /// Bottom-right alignment.
411    BottomRight,
412}
413
414impl Position {
415    /// Returns the canonical option name used by the API and CLI.
416    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/// Rotation that is applied after auto-orientation.
451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
452pub enum Rotation {
453    /// No extra rotation.
454    Deg0,
455    /// Rotate 90 degrees clockwise.
456    Deg90,
457    /// Rotate 180 degrees.
458    Deg180,
459    /// Rotate 270 degrees clockwise.
460    Deg270,
461}
462
463impl Rotation {
464    /// Returns the canonical degree value used by the API and CLI.
465    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/// A simple 8-bit RGBA color.
490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
491pub struct Rgba8 {
492    /// Red channel.
493    pub r: u8,
494    /// Green channel.
495    pub g: u8,
496    /// Blue channel.
497    pub b: u8,
498    /// Alpha channel.
499    pub a: u8,
500}
501
502impl Rgba8 {
503    /// Parses a hexadecimal RGB or RGBA color string without a leading `#`.
504    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/// Metadata handling after option normalization.
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528pub enum MetadataPolicy {
529    /// Drop metadata from the output.
530    StripAll,
531    /// Keep metadata unchanged when possible.
532    KeepAll,
533    /// Preserve EXIF while allowing other metadata policies later.
534    PreserveExif,
535}
536
537/// Resolves the three-way metadata flag semantics shared by all adapters.
538///
539/// Adapters accept different flag names (CLI: `--keep-metadata`/`--strip-metadata`/`--preserve-exif`,
540/// WASM: `keepMetadata`/`preserveExif`, server: `stripMetadata`/`preserveExif`) but the
541/// underlying semantics are identical. This function centralizes the resolution so that
542/// every adapter produces the same `(strip_metadata, preserve_exif)` pair for the same
543/// logical input.
544///
545/// # Arguments
546///
547/// * `strip` — Explicit "strip all metadata" flag, when provided.
548/// * `keep` — Explicit "keep all metadata" flag, when provided.
549/// * `preserve_exif` — Explicit "preserve EXIF only" flag, when provided.
550///
551/// # Errors
552///
553/// Returns [`TransformError::InvalidOptions`] when `keep` and `preserve_exif` are both
554/// explicitly `true`, since those policies are mutually exclusive.
555///
556/// # Examples
557///
558/// ```
559/// use truss::resolve_metadata_flags;
560///
561/// // Default: strip all metadata
562/// let (strip, exif) = resolve_metadata_flags(None, None, None).unwrap();
563/// assert!(strip);
564/// assert!(!exif);
565///
566/// // Explicit keep
567/// let (strip, exif) = resolve_metadata_flags(None, Some(true), None).unwrap();
568/// assert!(!strip);
569/// assert!(!exif);
570///
571/// // Preserve EXIF only
572/// let (strip, exif) = resolve_metadata_flags(None, None, Some(true)).unwrap();
573/// assert!(!strip);
574/// assert!(exif);
575///
576/// // keep + preserve_exif conflict
577/// assert!(resolve_metadata_flags(None, Some(true), Some(true)).is_err());
578/// ```
579pub 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/// Errors returned by Core validation or backend execution.
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub enum TransformError {
605    /// The input artifact is structurally invalid.
606    InvalidInput(String),
607    /// The provided options are contradictory or unsupported.
608    InvalidOptions(String),
609    /// The input media type cannot be processed.
610    UnsupportedInputMediaType(String),
611    /// The requested output media type cannot be produced.
612    UnsupportedOutputMediaType(MediaType),
613    /// Decoding the input artifact failed.
614    DecodeFailed(String),
615    /// Encoding the output artifact failed.
616    EncodeFailed(String),
617    /// The current runtime does not provide a required capability.
618    CapabilityMissing(String),
619    /// The image exceeds a processing limit such as maximum pixel count.
620    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/// Categories of image metadata that may be present in an artifact.
645///
646/// Used by [`TransformWarning::MetadataDropped`] to identify which metadata type
647/// was silently dropped during a transform operation.
648///
649/// ```
650/// use truss::MetadataKind;
651///
652/// assert_eq!(format!("{}", MetadataKind::Xmp), "XMP");
653/// assert_eq!(format!("{}", MetadataKind::Iptc), "IPTC");
654/// assert_eq!(format!("{}", MetadataKind::Exif), "EXIF");
655/// assert_eq!(format!("{}", MetadataKind::Icc), "ICC profile");
656/// ```
657#[derive(Debug, Clone, Copy, PartialEq, Eq)]
658pub enum MetadataKind {
659    /// XMP (Extensible Metadata Platform) metadata.
660    Xmp,
661    /// IPTC/IIM (International Press Telecommunications Council) metadata.
662    Iptc,
663    /// EXIF (Exchangeable Image File Format) metadata.
664    Exif,
665    /// ICC color profile.
666    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/// A non-fatal warning emitted during a transform operation.
681///
682/// Warnings indicate that the transform completed successfully but some aspect of
683/// the request could not be fully honored. Adapters should surface these to operators
684/// (e.g. CLI prints to stderr, server logs to stderr).
685///
686/// ```
687/// use truss::{MetadataKind, TransformWarning};
688///
689/// let warning = TransformWarning::MetadataDropped(MetadataKind::Xmp);
690/// assert_eq!(
691///     format!("{warning}"),
692///     "XMP metadata was present in the input but could not be preserved by the output encoder"
693/// );
694/// ```
695#[derive(Debug, Clone, PartialEq, Eq)]
696pub enum TransformWarning {
697    /// Metadata of the given kind was present in the input but could not be preserved
698    /// by the output encoder and was silently dropped.
699    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/// The result of a successful transform, containing the output artifact and any warnings.
714///
715/// Warnings indicate aspects of the request that could not be fully honored, such as
716/// metadata types that were silently dropped because the output encoder does not support them.
717#[derive(Debug)]
718pub struct TransformResult {
719    /// The transformed output artifact.
720    pub artifact: Artifact,
721    /// Non-fatal warnings emitted during the transform.
722    pub warnings: Vec<TransformWarning>,
723}
724
725/// Inspects raw bytes, detects the media type, and extracts best-effort metadata.
726///
727/// The caller is expected to pass bytes that have already been resolved by an adapter
728/// such as the CLI or HTTP server runtime. If a declared media type is provided in the
729/// [`RawArtifact`], this function verifies that the declared type matches the detected
730/// signature before returning the classified [`Artifact`].
731///
732/// Detection currently supports JPEG, PNG, WebP, AVIF, and BMP recognition.
733/// Width, height, and alpha extraction are best-effort and depend on the underlying format
734/// and any container metadata the file exposes.
735///
736/// # Errors
737///
738/// Returns [`TransformError::UnsupportedInputMediaType`] when the byte signature does not
739/// match a supported format, [`TransformError::InvalidInput`] when the declared media type
740/// conflicts with the detected type, and [`TransformError::DecodeFailed`] when a supported
741/// format has an invalid or truncated structure.
742///
743/// # Examples
744///
745/// ```
746/// use truss::{sniff_artifact, MediaType, RawArtifact};
747///
748/// let png_bytes = vec![
749///     0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1A, b'\n',
750///     0, 0, 0, 13, b'I', b'H', b'D', b'R',
751///     0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 0,
752///     0, 0, 0, 0,
753/// ];
754///
755/// let artifact = sniff_artifact(RawArtifact::new(png_bytes, Some(MediaType::Png))).unwrap();
756///
757/// assert_eq!(artifact.media_type, MediaType::Png);
758/// assert_eq!(artifact.metadata.width, Some(4));
759/// assert_eq!(artifact.metadata.height, Some(3));
760/// ```
761///
762/// ```
763/// use image::codecs::avif::AvifEncoder;
764/// use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
765/// use truss::{sniff_artifact, MediaType, RawArtifact};
766///
767/// let image = RgbaImage::from_pixel(3, 2, Rgba([10, 20, 30, 0]));
768/// let mut bytes = Vec::new();
769/// AvifEncoder::new(&mut bytes)
770///     .write_image(&image, 3, 2, ColorType::Rgba8.into())
771///     .unwrap();
772///
773/// let artifact = sniff_artifact(RawArtifact::new(bytes, Some(MediaType::Avif))).unwrap();
774///
775/// assert_eq!(artifact.media_type, MediaType::Avif);
776/// assert_eq!(artifact.metadata.width, Some(3));
777/// assert_eq!(artifact.metadata.height, Some(2));
778/// assert_eq!(artifact.metadata.has_alpha, Some(true));
779/// ```
780pub 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    // SVG check goes last: it relies on text scanning which is slower than binary
846    // magic-number checks and could produce false positives on non-SVG XML.
847    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
872/// Detects SVG by scanning for a `<svg` root element, skipping XML declarations,
873/// doctypes, comments, and whitespace.
874fn 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    // Skip UTF-8 BOM if present.
883    remaining = remaining.strip_prefix('\u{FEFF}').unwrap_or(remaining);
884    remaining = remaining.trim_start();
885
886    // Skip XML declaration: <?xml ... ?>
887    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    // Skip DOCTYPE: <!DOCTYPE ... >
896    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    // Skip comments: <!-- ... -->
905    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
920/// Extracts basic SVG metadata. SVGs inherently support transparency.
921/// Width and height are left unknown because SVGs may define dimensions via
922/// `viewBox`, percentage-based attributes, or not at all.
923fn 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
933/// Detects BMP files by checking for the "BM" signature at offset 0.
934fn is_bmp(bytes: &[u8]) -> bool {
935    bytes.len() >= 26 && bytes[0] == 0x42 && bytes[1] == 0x4D
936}
937
938/// Extracts BMP metadata from the DIB header.
939///
940/// The BITMAPINFOHEADER layout (and compatible V4/V5 headers) stores:
941/// - width as a signed 32-bit integer at file offset 18
942/// - height as a signed 32-bit integer at file offset 22 (negative = top-down)
943/// - bits per pixel at file offset 28
944fn 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        // Build a minimal BMP with BITMAPINFOHEADER (40 bytes DIB header).
1890        // File header: 14 bytes, DIB header: 40 bytes minimum.
1891        let mut bmp = Vec::new();
1892        // BM signature
1893        bmp.extend_from_slice(b"BM");
1894        // File size (placeholder)
1895        bmp.extend_from_slice(&0u32.to_le_bytes());
1896        // Reserved
1897        bmp.extend_from_slice(&0u32.to_le_bytes());
1898        // Pixel data offset (14 + 40 = 54)
1899        bmp.extend_from_slice(&54u32.to_le_bytes());
1900        // DIB header size (BITMAPINFOHEADER = 40)
1901        bmp.extend_from_slice(&40u32.to_le_bytes());
1902        // Width = 8
1903        bmp.extend_from_slice(&8u32.to_le_bytes());
1904        // Height = 6
1905        bmp.extend_from_slice(&6i32.to_le_bytes());
1906        // Planes = 1
1907        bmp.extend_from_slice(&1u16.to_le_bytes());
1908        // Bits per pixel = 24
1909        bmp.extend_from_slice(&24u16.to_le_bytes());
1910        // Padding to reach minimum sniff length
1911        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        // Width = 4
1929        bmp.extend_from_slice(&4u32.to_le_bytes());
1930        // Height = 4
1931        bmp.extend_from_slice(&4i32.to_le_bytes());
1932        // Planes = 1
1933        bmp.extend_from_slice(&1u16.to_le_bytes());
1934        // Bits per pixel = 32 (has alpha)
1935        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        // "BM" + enough padding to pass is_bmp (>= 26 bytes) but not sniff_bmp (>= 30)
1946        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}