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#[cfg(feature = "avif")]
9pub(crate) use avif::avif_clean_aperture;
10// Not gated with the decoder: `smaller_passthrough` is compiled in every build and reads
11// this, and the container walk needs no decoder anyway.
12pub(crate) use avif::avif_carries_metadata;
13#[cfg(feature = "avif")]
14pub(crate) use avif::{avif_metadata, avif_with_metadata};
15use avif::{avif_orientation, has_avif_brand, sniff_avif};
16
17// The shared failure vocabulary is only read by the adapters, so a build with none of them
18// (`--no-default-features`) leaves it out rather than carrying an unused table.
19/// The AVIF container walk, which is long enough to read on its own.
20mod avif;
21#[cfg(any(feature = "server", feature = "wasm"))]
22pub(crate) mod error_class;
23/// Gated with the `url` crate the address rules parse with, which the server feature brings
24/// in and which the two adapters that fetch are the only users of.
25#[cfg(feature = "server")]
26pub(crate) mod remote_policy;
27
28/// Maximum number of pixels in the output image (width × height).
29///
30/// This limit prevents resize operations from producing excessively large
31/// output buffers. The value matches the API specification in `docs/openapi.yaml`.
32///
33/// ```
34/// assert_eq!(truss::MAX_OUTPUT_PIXELS, 67_108_864);
35/// ```
36pub const MAX_OUTPUT_PIXELS: u64 = 67_108_864;
37
38/// Maximum number of decoded pixels allowed for an input image (width × height).
39///
40/// This limit prevents decompression bombs from consuming unbounded memory.
41/// The value matches the API specification in `docs/openapi.yaml`.
42pub(crate) const MAX_DECODED_PIXELS: u64 = 100_000_000;
43
44/// Maximum number of decoded pixels allowed for a watermark image.
45///
46/// This prevents a single watermark overlay from dominating memory during
47/// compositing. The value (4 MP) is generous for typical watermarks.
48pub(crate) const MAX_WATERMARK_PIXELS: u64 = 4_000_000;
49
50/// A (width, height) pair that prevents accidental transposition of dimensions.
51///
52/// Using a named struct instead of separate `u32` parameters ensures call sites
53/// cannot silently swap width and height.
54///
55/// ```
56/// use truss::Dimensions;
57/// let d = Dimensions::new(1920, 1080);
58/// assert_eq!(d.width, 1920);
59/// assert_eq!(d.height, 1080);
60/// assert_eq!(d.pixel_count(), 1920 * 1080);
61/// ```
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63#[must_use]
64pub struct Dimensions {
65    pub width: u32,
66    pub height: u32,
67}
68
69impl Dimensions {
70    /// Creates a new dimensions value.
71    pub const fn new(width: u32, height: u32) -> Self {
72        Self { width, height }
73    }
74
75    /// Returns the total pixel count (width × height) as `u64` to avoid overflow.
76    #[must_use]
77    pub const fn pixel_count(self) -> u64 {
78        self.width as u64 * self.height as u64
79    }
80}
81
82impl fmt::Display for Dimensions {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        write!(f, "{}x{}", self.width, self.height)
85    }
86}
87
88/// Raw input bytes before media-type detection has completed.
89///
90/// # Examples
91///
92/// ```
93/// use truss::{RawArtifact, MediaType};
94///
95/// let raw = RawArtifact::new(vec![0xFF, 0xD8, 0xFF], Some(MediaType::Jpeg));
96/// assert_eq!(raw.declared_media_type, Some(MediaType::Jpeg));
97///
98/// let unknown = RawArtifact::new(vec![1, 2, 3], None);
99/// assert!(unknown.declared_media_type.is_none());
100/// ```
101#[derive(Debug, Clone, PartialEq, Eq)]
102#[non_exhaustive]
103pub struct RawArtifact {
104    /// The raw input bytes.
105    pub bytes: Vec<u8>,
106    /// The media type declared by an adapter, if one is available.
107    pub declared_media_type: Option<MediaType>,
108}
109
110impl RawArtifact {
111    /// Creates a new raw artifact value.
112    pub fn new(bytes: Vec<u8>, declared_media_type: Option<MediaType>) -> Self {
113        Self {
114            bytes,
115            declared_media_type,
116        }
117    }
118}
119
120/// A decoded or otherwise classified artifact handled by the Core layer.
121///
122/// # Examples
123///
124/// ```
125/// use truss::{Artifact, ArtifactMetadata, MediaType};
126///
127/// let artifact = Artifact::new(
128///     vec![0x89, b'P', b'N', b'G'],
129///     MediaType::Png,
130///     ArtifactMetadata::default(),
131/// );
132/// assert_eq!(artifact.media_type, MediaType::Png);
133/// assert_eq!(artifact.metadata.frame_count, 1);
134/// ```
135#[derive(Debug, Clone, PartialEq, Eq)]
136#[must_use]
137#[non_exhaustive]
138pub struct Artifact {
139    /// The artifact bytes.
140    pub bytes: Vec<u8>,
141    /// The detected media type for the bytes.
142    pub media_type: MediaType,
143    /// Additional metadata extracted from the artifact.
144    pub metadata: ArtifactMetadata,
145}
146
147impl Artifact {
148    /// Creates a new artifact value.
149    pub fn new(bytes: Vec<u8>, media_type: MediaType, metadata: ArtifactMetadata) -> Self {
150        Self {
151            bytes,
152            media_type,
153            metadata,
154        }
155    }
156}
157
158/// Metadata that the Core layer can carry between decode and encode steps.
159///
160/// # Examples
161///
162/// ```
163/// use truss::{ArtifactMetadata, Dimensions};
164///
165/// // The struct is `#[non_exhaustive]`, so start from `default()` and assign; a field
166/// // truss adds later is then a minor change rather than a breaking one.
167/// let mut meta = ArtifactMetadata::default();
168/// meta.width = Some(1920);
169/// meta.height = Some(1080);
170/// assert_eq!(meta.dimensions(), Some(Dimensions::new(1920, 1080)));
171/// assert_eq!(meta.frame_count, 1);
172///
173/// // With no orientation tag, the oriented dimensions are the stored ones.
174/// assert_eq!(meta.oriented_dimensions(), Some(Dimensions::new(1920, 1080)));
175///
176/// // Orientation 6 is a quarter turn, so a transform swaps the axes.
177/// let mut rotated = meta.clone();
178/// rotated.orientation = Some(6);
179/// assert_eq!(rotated.oriented_dimensions(), Some(Dimensions::new(1080, 1920)));
180///
181/// // When either dimension is unknown, dimensions() returns None
182/// let mut partial = ArtifactMetadata::default();
183/// partial.width = Some(100);
184/// assert!(partial.dimensions().is_none());
185/// ```
186#[derive(Debug, Clone, PartialEq, Eq)]
187#[non_exhaustive]
188pub struct ArtifactMetadata {
189    /// The rendered width in pixels, when known.
190    pub width: Option<u32>,
191    /// The rendered height in pixels, when known.
192    pub height: Option<u32>,
193    /// The number of frames contained in the artifact.
194    pub frame_count: u32,
195    /// The total animation duration, when known.
196    pub duration: Option<Duration>,
197    /// Whether the artifact contains alpha, when known.
198    pub has_alpha: Option<bool>,
199    /// The EXIF orientation tag, when the artifact carries one.
200    ///
201    /// `width` and `height` are the dimensions as stored in the container. A transform
202    /// applies this tag by default, and values 5 to 8 transpose the two, so a caller that
203    /// records dimensions at upload time and serves derivatives later needs this to know
204    /// which way round the result will be. [`ArtifactMetadata::oriented_dimensions`] does
205    /// that arithmetic.
206    pub orientation: Option<u16>,
207}
208
209impl ArtifactMetadata {
210    /// Returns the dimensions as a [`Dimensions`] value, if both width and height are known.
211    pub fn dimensions(&self) -> Option<Dimensions> {
212        match (self.width, self.height) {
213            (Some(w), Some(h)) => Some(Dimensions::new(w, h)),
214            _ => None,
215        }
216    }
217
218    /// Returns the dimensions after the EXIF orientation is applied.
219    ///
220    /// These are the dimensions a transform produces with auto-orientation on, which is the
221    /// default. They equal [`ArtifactMetadata::dimensions`] whenever there is no orientation
222    /// tag or the tag does not transpose the axes, so a caller can read these unconditionally.
223    pub fn oriented_dimensions(&self) -> Option<Dimensions> {
224        let dimensions = self.dimensions()?;
225        Some(if orientation_transposes(self.orientation) {
226            Dimensions::new(dimensions.height, dimensions.width)
227        } else {
228            dimensions
229        })
230    }
231}
232
233/// Reports whether an EXIF orientation swaps the width and the height.
234///
235/// Values 5 to 8 include a quarter turn; 1 to 4 do not, and anything else is ignored the way
236/// the transform pipeline ignores it.
237pub(crate) const fn orientation_transposes(orientation: Option<u16>) -> bool {
238    matches!(orientation, Some(5..=8))
239}
240
241impl Default for ArtifactMetadata {
242    fn default() -> Self {
243        Self {
244            width: None,
245            height: None,
246            frame_count: 1,
247            duration: None,
248            has_alpha: None,
249            orientation: None,
250        }
251    }
252}
253
254/// Supported media types for the current implementation phase.
255///
256/// # Examples
257///
258/// ```
259/// use truss::MediaType;
260/// use std::str::FromStr;
261///
262/// let mt = MediaType::from_str("png").unwrap();
263/// assert_eq!(mt, MediaType::Png);
264/// assert_eq!(mt.as_name(), "png");
265/// assert_eq!(mt.as_mime(), "image/png");
266/// assert!(!mt.is_lossy());
267/// assert!(mt.is_raster());
268///
269/// assert!(MediaType::Jpeg.is_lossy());
270/// assert!(!MediaType::Svg.is_raster());
271/// ```
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273#[non_exhaustive]
274pub enum MediaType {
275    /// JPEG image data.
276    Jpeg,
277    /// PNG image data.
278    Png,
279    /// WebP image data.
280    Webp,
281    /// AVIF image data.
282    Avif,
283    /// SVG image data.
284    Svg,
285    /// BMP image data.
286    Bmp,
287    /// TIFF image data.
288    Tiff,
289    /// GIF image data.
290    ///
291    /// GIF is an input-only format: truss decodes it but never encodes it, the same way
292    /// it treats a raster input requesting SVG output. Requesting `gif` output returns
293    /// [`TransformError::UnsupportedOutputMediaType`].
294    Gif,
295}
296
297impl MediaType {
298    /// Returns the canonical media type name used by the API and CLI.
299    #[must_use]
300    pub const fn as_name(self) -> &'static str {
301        match self {
302            Self::Jpeg => "jpeg",
303            Self::Png => "png",
304            Self::Webp => "webp",
305            Self::Avif => "avif",
306            Self::Svg => "svg",
307            Self::Bmp => "bmp",
308            Self::Tiff => "tiff",
309            Self::Gif => "gif",
310        }
311    }
312
313    /// Returns the canonical MIME type string.
314    #[must_use]
315    pub const fn as_mime(self) -> &'static str {
316        match self {
317            Self::Jpeg => "image/jpeg",
318            Self::Png => "image/png",
319            Self::Webp => "image/webp",
320            Self::Avif => "image/avif",
321            Self::Svg => "image/svg+xml",
322            Self::Bmp => "image/bmp",
323            Self::Tiff => "image/tiff",
324            Self::Gif => "image/gif",
325        }
326    }
327
328    /// Reports whether the media type is typically encoded with lossy quality controls.
329    #[must_use]
330    pub const fn is_lossy(self) -> bool {
331        matches!(self, Self::Jpeg | Self::Webp | Self::Avif)
332    }
333
334    /// Returns `true` if the format participates in the optimization pipeline.
335    #[must_use]
336    pub const fn supports_optimization(self) -> bool {
337        matches!(self, Self::Jpeg | Self::Png | Self::Webp | Self::Avif)
338    }
339
340    /// Returns `true` if the format supports lossy optimization controls.
341    #[must_use]
342    pub const fn supports_lossy_optimization(self) -> bool {
343        matches!(self, Self::Jpeg | Self::Webp | Self::Avif)
344    }
345
346    /// The longest an axis of this format's output can be, when the format sets a limit.
347    ///
348    /// `MAX_OUTPUT_PIXELS` bounds the area and says nothing about the shape, so an output can
349    /// be tens of thousands of pixels on one axis as long as the other is small. Three of the
350    /// encoders refuse that, for reasons outside truss: a JPEG frame header stores each
351    /// dimension in sixteen bits, WebP caps an image at 16383 on an axis, and rav1e refuses an
352    /// axis outside 16 to 65535, which is narrower than AV1's own frame size fields. The
353    /// number is the smaller of what the format can hold and what the encoder truss reaches
354    /// will write, so WebP is 16383 rather than the 16384 the `image` crate's lossless encoder
355    /// alone would accept: the mode that selects the encoder is a separate option, and one
356    /// ceiling per format is the one a caller can predict.
357    ///
358    /// PNG, BMP and TIFF store dimensions in thirty-two bits, so nothing in the format bites
359    /// before `MAX_OUTPUT_PIXELS` does. GIF is not an output format, and an SVG output is the
360    /// sanitized document rather than a raster of a chosen size.
361    pub(crate) const fn max_output_dimension(self) -> Option<u32> {
362        match self {
363            Self::Jpeg | Self::Avif => Some(65_535),
364            Self::Webp => Some(16_383),
365            Self::Png | Self::Bmp | Self::Tiff | Self::Gif | Self::Svg => None,
366        }
367    }
368
369    /// Returns `true` if the encoded format can carry an embedded ICC profile.
370    ///
371    /// AVIF signals color through the container's `colr` box rather than a profile truss can
372    /// write, and BMP/TIFF/SVG output has no profile path in this pipeline.
373    #[must_use]
374    pub const fn supports_icc_profile(self) -> bool {
375        matches!(self, Self::Jpeg | Self::Png | Self::Webp)
376    }
377
378    /// Returns `true` if this is a raster (bitmap) format, `false` for vector formats.
379    #[must_use]
380    pub const fn is_raster(self) -> bool {
381        !matches!(self, Self::Svg)
382    }
383
384    /// Returns `true` if truss can encode this format, not merely decode it.
385    ///
386    /// GIF is decode-only: animation, palette quantization, and frame disposal are a
387    /// different problem from the single-frame pipeline, so truss reads GIF input and
388    /// writes one of the formats it fully supports. SVG is encodable only in the sense
389    /// that an SVG input can be sanitized back out as SVG, which
390    /// [`crate::codecs::transform`] handles on its own path.
391    #[must_use]
392    pub const fn is_encodable(self) -> bool {
393        !matches!(self, Self::Gif)
394    }
395
396    /// Why this format cannot be an output, or `None` when it can be one.
397    ///
398    /// A format that parses is not automatically a format truss writes, and the four
399    /// adapters used to each carry their own copy of that sentence. This is the one copy:
400    /// the CLI rejects the flag value with it, the Wasm package and the HTTP server refuse
401    /// the request with it, and all three do so before the input is read rather than after
402    /// the picture has been decoded.
403    pub(crate) fn unencodable_reason(self) -> Option<String> {
404        (!self.is_encodable()).then(|| {
405            format!(
406                "{} is an input-only format; choose an output format such as png, jpeg, webp, or avif",
407                self.as_name()
408            )
409        })
410    }
411
412    /// The output format to use when a request does not name one.
413    ///
414    /// Normally that is the input's own format, so a transform without an explicit
415    /// `format` does not silently re-encode into something else. A decode-only input has
416    /// no such option: GIF falls back to PNG, which is lossless and reproduces a palette
417    /// and a transparent color index exactly.
418    ///
419    /// Every adapter that has to resolve a missing format goes through here, so the rule
420    /// cannot drift between the CLI, the server, and the WASM build.
421    ///
422    /// # Examples
423    ///
424    /// ```
425    /// use truss::MediaType;
426    ///
427    /// assert_eq!(MediaType::Jpeg.default_output(), MediaType::Jpeg);
428    /// assert_eq!(MediaType::Gif.default_output(), MediaType::Png);
429    /// ```
430    #[must_use]
431    pub const fn default_output(self) -> Self {
432        if self.is_encodable() { self } else { Self::Png }
433    }
434}
435
436impl fmt::Display for MediaType {
437    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438        f.write_str(self.as_mime())
439    }
440}
441
442impl FromStr for MediaType {
443    type Err = String;
444
445    fn from_str(value: &str) -> Result<Self, Self::Err> {
446        match value {
447            "jpeg" | "jpg" => Ok(Self::Jpeg),
448            "png" => Ok(Self::Png),
449            "webp" => Ok(Self::Webp),
450            "avif" => Ok(Self::Avif),
451            "svg" => Ok(Self::Svg),
452            "bmp" => Ok(Self::Bmp),
453            "tiff" | "tif" => Ok(Self::Tiff),
454            "gif" => Ok(Self::Gif),
455            _ => Err(format!("unsupported media type `{value}`")),
456        }
457    }
458}
459
460/// Where a watermark sits when the caller does not say.
461pub(crate) const WATERMARK_DEFAULT_POSITION: Position = Position::BottomRight;
462/// How opaque a watermark is when the caller does not say.
463pub(crate) const WATERMARK_DEFAULT_OPACITY: u8 = 50;
464/// How far a watermark sits from its edge when the caller does not say.
465pub(crate) const WATERMARK_DEFAULT_MARGIN: u32 = 10;
466
467/// A watermark image to composite onto the output.
468///
469/// The watermark is alpha-composited onto the main image after all other
470/// transforms (resize, blur) and before encoding.
471///
472/// ```
473/// use truss::{Artifact, ArtifactMetadata, MediaType, Position, WatermarkInput};
474///
475/// let image = Artifact::new(vec![0], MediaType::Png, ArtifactMetadata::default());
476/// let mut wm = WatermarkInput::new(image);
477/// assert_eq!(wm.position, Position::BottomRight);
478/// assert_eq!(wm.opacity, 50);
479/// assert_eq!(wm.margin, 10);
480///
481/// wm.opacity = 80;
482/// assert_eq!(wm.opacity, 80);
483/// ```
484#[derive(Debug, Clone, PartialEq, Eq)]
485#[non_exhaustive]
486pub struct WatermarkInput {
487    /// The watermark image (already classified via [`sniff_artifact`]).
488    pub image: Artifact,
489    /// Where to place the watermark on the main image.
490    pub position: Position,
491    /// Opacity of the watermark (1–100). Default: 50.
492    pub opacity: u8,
493    /// Margin in pixels from the nearest edge. Default: 10.
494    pub margin: u32,
495}
496
497impl WatermarkInput {
498    /// Builds a watermark placed the way the vocabulary says it is placed when nothing else
499    /// is asked for.
500    ///
501    /// The three defaults are the ones the CLI, the HTTP server, and the Wasm package all
502    /// publish, and this is where they are written; each adapter used to spell them again at
503    /// its own call site. A caller who wants something else assigns the field afterwards,
504    /// which is also how a caller reaches the ones this constructor does not take: the
505    /// struct is `#[non_exhaustive]`, so a field truss adds later is a minor change.
506    #[must_use]
507    pub fn new(image: Artifact) -> Self {
508        Self {
509            image,
510            position: WATERMARK_DEFAULT_POSITION,
511            opacity: WATERMARK_DEFAULT_OPACITY,
512            margin: WATERMARK_DEFAULT_MARGIN,
513        }
514    }
515}
516
517/// A complete transform request for the Core layer.
518///
519/// # Examples
520///
521/// ```
522/// use truss::{Artifact, ArtifactMetadata, MediaType, TransformOptions, TransformRequest};
523///
524/// let input = Artifact::new(vec![0], MediaType::Png, ArtifactMetadata::default());
525/// let request = TransformRequest::new(input, TransformOptions::default());
526/// assert!(request.watermark.is_none());
527/// ```
528#[derive(Debug, Clone, PartialEq)]
529#[non_exhaustive]
530pub struct TransformRequest {
531    /// The already-resolved input artifact.
532    pub input: Artifact,
533    /// Raw transform options as provided by an adapter.
534    pub options: TransformOptions,
535    /// Optional watermark image to composite onto the output.
536    pub watermark: Option<WatermarkInput>,
537}
538
539impl TransformRequest {
540    /// Creates a new transform request.
541    pub fn new(input: Artifact, options: TransformOptions) -> Self {
542        Self {
543            input,
544            options,
545            watermark: None,
546        }
547    }
548
549    /// Creates a new transform request with a watermark.
550    pub fn with_watermark(
551        input: Artifact,
552        options: TransformOptions,
553        watermark: WatermarkInput,
554    ) -> Self {
555        Self {
556            input,
557            options,
558            watermark: Some(watermark),
559        }
560    }
561
562    /// Normalizes the request into a form that does not require adapter-specific defaults.
563    pub(crate) fn normalize(self) -> Result<NormalizedTransformRequest, TransformError> {
564        let options = self.options.normalize(self.input.media_type)?;
565
566        if let Some(ref wm) = self.watermark {
567            validate_watermark(wm)?;
568        }
569
570        Ok(NormalizedTransformRequest {
571            input: self.input,
572            options,
573            watermark: self.watermark,
574        })
575    }
576}
577
578/// A fully normalized transform request.
579#[derive(Debug, Clone, PartialEq)]
580#[non_exhaustive]
581pub(crate) struct NormalizedTransformRequest {
582    /// The normalized input artifact.
583    pub input: Artifact,
584    /// Fully normalized transform options.
585    pub options: NormalizedTransformOptions,
586    /// Optional watermark to composite onto the output.
587    pub watermark: Option<WatermarkInput>,
588}
589
590/// An explicit crop region applied before resize.
591///
592/// Crop extracts a rectangular sub-image at the given pixel coordinates.
593/// The origin `(x, y)` is the top-left corner and `(width, height)` define
594/// the size of the extracted region. Both dimensions must be non-zero.
595///
596/// # Examples
597///
598/// ```
599/// use truss::CropRegion;
600/// use std::str::FromStr;
601///
602/// let region = CropRegion::from_str("10,20,100,200").unwrap();
603/// assert_eq!(region.x, 10);
604/// assert_eq!(region.y, 20);
605/// assert_eq!(region.width, 100);
606/// assert_eq!(region.height, 200);
607/// assert_eq!(format!("{region}"), "10,20,100,200");
608///
609/// // Zero-size regions are rejected
610/// assert!(CropRegion::from_str("0,0,0,100").is_err());
611/// ```
612#[derive(Debug, Clone, Copy, PartialEq, Eq)]
613pub struct CropRegion {
614    /// Horizontal offset from the left edge.
615    pub x: u32,
616    /// Vertical offset from the top edge.
617    pub y: u32,
618    /// Width of the crop region.
619    pub width: u32,
620    /// Height of the crop region.
621    pub height: u32,
622}
623
624impl FromStr for CropRegion {
625    type Err = String;
626
627    fn from_str(s: &str) -> Result<Self, Self::Err> {
628        let parts: Vec<&str> = s.split(',').collect();
629        if parts.len() != 4 {
630            return Err(format!(
631                "crop must be x,y,w,h (four comma-separated integers), got '{s}'"
632            ));
633        }
634        let x = parts[0]
635            .parse::<u32>()
636            .map_err(|_| format!("crop x must be a non-negative integer, got '{}'", parts[0]))?;
637        let y = parts[1]
638            .parse::<u32>()
639            .map_err(|_| format!("crop y must be a non-negative integer, got '{}'", parts[1]))?;
640        let width = parts[2].parse::<u32>().map_err(|_| {
641            format!(
642                "crop width must be a non-negative integer, got '{}'",
643                parts[2]
644            )
645        })?;
646        let height = parts[3].parse::<u32>().map_err(|_| {
647            format!(
648                "crop height must be a non-negative integer, got '{}'",
649                parts[3]
650            )
651        })?;
652        if width == 0 || height == 0 {
653            return Err("crop width and height must be greater than zero".to_string());
654        }
655        Ok(CropRegion {
656            x,
657            y,
658            width,
659            height,
660        })
661    }
662}
663
664impl fmt::Display for CropRegion {
665    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
666        write!(f, "{},{},{},{}", self.x, self.y, self.width, self.height)
667    }
668}
669
670/// Optimization policy applied near the final encoding stage.
671#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
672#[non_exhaustive]
673pub enum OptimizeMode {
674    /// Keep the current encoding behavior with no extra optimization work.
675    #[default]
676    None,
677    /// Pick the most appropriate optimization strategy for the target format.
678    Auto,
679    /// Only use lossless size-reduction techniques.
680    Lossless,
681    /// Allow controlled quality loss for smaller output files.
682    Lossy,
683}
684
685impl OptimizeMode {
686    /// Returns the canonical option name used by the API, CLI, and WASM adapter.
687    #[must_use]
688    pub const fn as_name(self) -> &'static str {
689        match self {
690            Self::None => "none",
691            Self::Auto => "auto",
692            Self::Lossless => "lossless",
693            Self::Lossy => "lossy",
694        }
695    }
696}
697
698impl fmt::Display for OptimizeMode {
699    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
700        f.write_str(self.as_name())
701    }
702}
703
704impl FromStr for OptimizeMode {
705    type Err = String;
706
707    fn from_str(value: &str) -> Result<Self, Self::Err> {
708        match value {
709            "none" => Ok(Self::None),
710            "auto" => Ok(Self::Auto),
711            "lossless" => Ok(Self::Lossless),
712            "lossy" => Ok(Self::Lossy),
713            _ => Err(format!("unsupported optimize mode `{value}`")),
714        }
715    }
716}
717
718/// Perceptual metric used for lossy optimization quality targeting.
719#[derive(Debug, Clone, Copy, PartialEq, Eq)]
720#[non_exhaustive]
721pub enum QualityMetric {
722    /// Structural similarity index.
723    Ssim,
724    /// Peak signal-to-noise ratio.
725    Psnr,
726}
727
728impl QualityMetric {
729    /// Returns the canonical metric name used in textual forms such as `ssim:0.98`.
730    #[must_use]
731    pub const fn as_name(self) -> &'static str {
732        match self {
733            Self::Ssim => "ssim",
734            Self::Psnr => "psnr",
735        }
736    }
737}
738
739impl fmt::Display for QualityMetric {
740    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
741        f.write_str(self.as_name())
742    }
743}
744
745impl FromStr for QualityMetric {
746    type Err = String;
747
748    fn from_str(value: &str) -> Result<Self, Self::Err> {
749        match value {
750            "ssim" => Ok(Self::Ssim),
751            "psnr" => Ok(Self::Psnr),
752            _ => Err(format!("unsupported target quality metric `{value}`")),
753        }
754    }
755}
756
757/// A perceptual quality target used when binary-searching a lossy encode quality.
758///
759/// The search is a binary search over `1..=quality` when a `quality` is given and `1..=100`
760/// otherwise, and it assumes the score rises with the quality setting. Encoders do not
761/// promise that: rate control changes quantization as the setting moves, and a perceptual
762/// score against the original can fall a little on the way up. So the quality returned is
763/// one whose score meets `value` rather than necessarily the least one that would, and where
764/// no probed quality meets it, the top of the range is returned together with a
765/// [`TransformWarning::TargetQualityNotReached`] naming the score that encode reached.
766/// Guaranteeing the minimum would mean scanning the range at an encode, a decode, and a
767/// metric per step, against the handful the search makes.
768#[derive(Debug, Clone, Copy, PartialEq)]
769pub struct TargetQuality {
770    /// The requested quality metric.
771    pub metric: QualityMetric,
772    /// The threshold that the encoded output should meet or exceed.
773    pub value: f32,
774}
775
776impl fmt::Display for TargetQuality {
777    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
778        write!(f, "{}:{}", self.metric.as_name(), self.value)
779    }
780}
781
782impl FromStr for TargetQuality {
783    type Err = String;
784
785    fn from_str(value: &str) -> Result<Self, Self::Err> {
786        let (metric, raw_value) = value.split_once(':').ok_or_else(|| {
787            "targetQuality must be <metric>:<value>, for example ssim:0.98".to_string()
788        })?;
789        // Spelled the way every other named value in the vocabulary is: `fit`, `position`,
790        // `format`, and the optimize mode all match what the caller wrote, so a metric that
791        // accepted any case was the one flag whose lesson did not carry to the next.
792        let metric = QualityMetric::from_str(metric)?;
793        let value = raw_value
794            .parse::<f32>()
795            .map_err(|_| format!("target quality value must be a number, got `{raw_value}`"))?;
796
797        Ok(Self { metric, value })
798    }
799}
800
801pub(crate) fn default_lossy_target_quality(media_type: MediaType) -> Option<TargetQuality> {
802    let value = match media_type {
803        MediaType::Jpeg | MediaType::Webp => 0.985,
804        MediaType::Avif => 0.99,
805        _ => return None,
806    };
807
808    Some(TargetQuality {
809        metric: QualityMetric::Ssim,
810        value,
811    })
812}
813
814/// Raw transform options before defaulting and validation has completed.
815///
816/// Start from `TransformOptions::default()` and assign the fields you need. Validation and
817/// the resolution of the rest happen inside [`transform`](crate::transform), which reports
818/// what it refuses through [`TransformError`]. The struct is `#[non_exhaustive]`, so a field
819/// a later version of truss adds is a minor change rather than a breaking one, and a struct
820/// literal is not available from outside the crate.
821///
822/// # Examples
823///
824/// ```
825/// use truss::{TransformOptions, MediaType, Rotation};
826///
827/// let mut opts = TransformOptions::default();
828/// opts.width = Some(800);
829/// opts.height = Some(600);
830/// opts.format = Some(MediaType::Webp);
831/// opts.quality = Some(80);
832/// opts.rotate = Rotation::DEG_90;
833/// assert_eq!(opts.width, Some(800));
834/// assert_eq!(opts.quality, Some(80));
835/// assert_eq!(opts.rotate, Rotation::DEG_90);
836/// // strip_metadata defaults to true
837/// assert!(opts.strip_metadata);
838/// ```
839#[derive(Debug, Clone, PartialEq)]
840#[non_exhaustive]
841pub struct TransformOptions {
842    /// The desired output width in pixels.
843    pub width: Option<u32>,
844    /// The desired output height in pixels.
845    pub height: Option<u32>,
846    /// The requested resize fit mode.
847    pub fit: Option<Fit>,
848    /// The requested positioning mode.
849    pub position: Option<Position>,
850    /// The requested output format.
851    pub format: Option<MediaType>,
852    /// The requested lossy quality.
853    pub quality: Option<u8>,
854    /// The requested optimization mode.
855    pub optimize: OptimizeMode,
856    /// Optional perceptual target used by lossy optimization.
857    pub target_quality: Option<TargetQuality>,
858    /// The requested background color.
859    pub background: Option<Rgba8>,
860    /// The requested extra rotation.
861    pub rotate: Rotation,
862    /// Whether EXIF-based auto-orientation should run.
863    pub auto_orient: bool,
864    /// Whether metadata should be stripped from the output.
865    pub strip_metadata: bool,
866    /// Whether EXIF metadata should be preserved.
867    pub preserve_exif: bool,
868    /// Gaussian blur sigma.
869    ///
870    /// When set, a Gaussian blur with the given sigma is applied after resizing
871    /// and before encoding. Valid range is 0.1–100.0.
872    pub blur: Option<f32>,
873    /// Unsharp-mask (sharpen) sigma.
874    ///
875    /// When set, an unsharp mask with the given sigma is applied after resizing
876    /// and before encoding. Valid range is 0.1–100.0. The sharpening threshold
877    /// is fixed at 1.
878    pub sharpen: Option<f32>,
879    /// Whether the image should be desaturated to grayscale.
880    ///
881    /// When true, the image is converted to grayscale after resizing, blur, and
882    /// sharpening, and before any watermark is composited, so a watermark keeps
883    /// its own colors. Luminance is computed with the Rec. 601 weights the
884    /// `image` crate uses, and the alpha channel is preserved.
885    pub grayscale: bool,
886    /// Whether a source smaller than the requested size may be scaled up.
887    ///
888    /// When true, the resize never enlarges: an image already within the requested
889    /// bounds is left at its own size. This is a separate question from [`Fit`], which
890    /// decides how the image is arranged relative to the box, so the two combine freely.
891    /// `contain` still pads out to the full requested box; only the content inside it
892    /// stops growing.
893    pub without_enlargement: bool,
894    /// Optional explicit crop region applied before resize.
895    ///
896    /// When set, the image is cropped to the specified rectangle before any resize
897    /// operation. The crop region is validated at runtime against the decoded image
898    /// dimensions.
899    pub crop: Option<CropRegion>,
900    /// Optional wall-clock deadline for the transform pipeline.
901    ///
902    /// When set, the transform checks elapsed time at each pipeline stage and returns
903    /// [`TransformError::LimitExceeded`] if the deadline is exceeded. Adapters inject
904    /// this value based on their operational requirements — for example, the HTTP server
905    /// sets a 30-second deadline while the CLI leaves it as `None` (unlimited).
906    ///
907    /// The check happens between stages, not inside one. A stage that is already running
908    /// runs to its end, so a transform can return after the deadline rather than at it,
909    /// by however long its slowest single step takes. Encoding is the step where that
910    /// matters, since no encoder truss calls can be interrupted part way.
911    pub deadline: Option<Duration>,
912}
913
914impl Default for TransformOptions {
915    fn default() -> Self {
916        Self {
917            width: None,
918            height: None,
919            fit: None,
920            position: None,
921            format: None,
922            quality: None,
923            optimize: OptimizeMode::None,
924            target_quality: None,
925            background: None,
926            rotate: Rotation::DEG_0,
927            auto_orient: true,
928            strip_metadata: true,
929            preserve_exif: false,
930            blur: None,
931            sharpen: None,
932            grayscale: false,
933            without_enlargement: false,
934            crop: None,
935            deadline: None,
936        }
937    }
938}
939
940impl TransformOptions {
941    /// The first option set on this request that an SVG passthrough cannot honour.
942    ///
943    /// `fit`, `position`, and `withoutEnlargement` are absent from the list because the rules
944    /// above already require a width or a height alongside each of them, so naming the axis
945    /// covers those too and names the option the caller has to drop.
946    fn svg_passthrough_unsupported_option(&self) -> Option<&'static str> {
947        if self.width.is_some() {
948            return Some("width");
949        }
950        if self.height.is_some() {
951            return Some("height");
952        }
953        if !self.rotate.is_identity() {
954            return Some("rotate");
955        }
956        if self.grayscale {
957            return Some("grayscale");
958        }
959        if self.background.is_some() {
960            return Some("background");
961        }
962        None
963    }
964
965    /// Checks every rule that the options decide between themselves, with no input.
966    ///
967    /// [`Self::normalize`] runs this first and then goes on to the rules that need the
968    /// resolved output format, which for an absent `format` comes from the input. Callers
969    /// that hold no input run this on its own: the HTTP server refuses a request here
970    /// rather than after fetching the source, and `truss sign` refuses to put an option
971    /// set into a signed URL that no server would serve. Keeping the two apart is what
972    /// lets one list of rules answer for callers that have an image and callers that do
973    /// not.
974    pub(crate) fn validate_without_input(&self) -> Result<(), TransformError> {
975        validate_dimension("width", self.width)?;
976        validate_dimension("height", self.height)?;
977        validate_quality(self.quality)?;
978        validate_target_quality(self.target_quality)?;
979        validate_blur(self.blur)?;
980        validate_sharpen(self.sharpen)?;
981        if let Some(crop) = self.crop
982            && (crop.width == 0 || crop.height == 0)
983        {
984            return Err(TransformError::InvalidOptions(
985                "crop width and height must be greater than zero".to_string(),
986            ));
987        }
988
989        let has_bounded_resize = self.width.is_some() && self.height.is_some();
990
991        if self.fit.is_some() && !has_bounded_resize {
992            return Err(TransformError::InvalidOptions(
993                "fit requires both width and height".to_string(),
994            ));
995        }
996
997        if self.position.is_some() && !has_bounded_resize {
998            return Err(TransformError::InvalidOptions(
999                "position requires both width and height".to_string(),
1000            ));
1001        }
1002
1003        // Unlike fit and position, this is meaningful with a single axis too, so it only
1004        // needs some resize to act on. Rejecting it outright beats silently ignoring a flag
1005        // the caller clearly meant to have an effect.
1006        if self.without_enlargement && self.width.is_none() && self.height.is_none() {
1007            return Err(TransformError::InvalidOptions(
1008                "withoutEnlargement requires width or height".to_string(),
1009            ));
1010        }
1011
1012        if self.preserve_exif && self.strip_metadata {
1013            return Err(TransformError::InvalidOptions(
1014                "preserveExif requires stripMetadata to be false".to_string(),
1015            ));
1016        }
1017
1018        Ok(())
1019    }
1020
1021    /// Normalizes and validates the options against the input media type.
1022    ///
1023    /// # Errors
1024    ///
1025    /// Returns [`TransformError::InvalidOptions`] when the options contradict each other
1026    /// or the output format they resolve to.
1027    pub(crate) fn normalize(
1028        self,
1029        input_media_type: MediaType,
1030    ) -> Result<NormalizedTransformOptions, TransformError> {
1031        self.validate_without_input()?;
1032
1033        let has_bounded_resize = self.width.is_some() && self.height.is_some();
1034
1035        // An explicit `format: Some(Gif)` is a different request from an absent one, and is
1036        // rejected by `codecs::transform` rather than quietly rewritten here.
1037        let format = self
1038            .format
1039            .unwrap_or_else(|| input_media_type.default_output());
1040        let optimize = self.optimize;
1041
1042        if optimize != OptimizeMode::None && !format.supports_optimization() {
1043            return Err(TransformError::InvalidOptions(format!(
1044                "optimization is not supported for {} output",
1045                format.as_name()
1046            )));
1047        }
1048
1049        if optimize == OptimizeMode::Lossy && !format.supports_lossy_optimization() {
1050            return Err(TransformError::InvalidOptions(format!(
1051                "lossy optimization requires jpeg, webp, or avif output, got {}",
1052                format.as_name()
1053            )));
1054        }
1055
1056        if self.preserve_exif && format == MediaType::Svg {
1057            return Err(TransformError::InvalidOptions(
1058                "preserveExif is not supported with SVG output".to_string(),
1059            ));
1060        }
1061
1062        // SVG in and SVG out is a sanitize-only passthrough: the document is returned as its
1063        // author wrote it, so an option that asks for a different picture cannot be honoured.
1064        // Refusing it is what the rule above already does, and what `transform_svg` does for
1065        // blur, sharpen, crop, and watermark; the alternative is a caller who asked for a
1066        // 64-pixel icon getting the original back with exit code 0.
1067        if input_media_type == MediaType::Svg
1068            && format == MediaType::Svg
1069            && let Some(option) = self.svg_passthrough_unsupported_option()
1070        {
1071            return Err(TransformError::InvalidOptions(format!(
1072                "{option} is not supported with SVG output; choose a raster output format such as png"
1073            )));
1074        }
1075
1076        if self.quality.is_some() && !format.is_lossy() {
1077            return Err(TransformError::InvalidOptions(
1078                "quality requires a lossy output format".to_string(),
1079            ));
1080        }
1081
1082        if self.quality.is_some() && optimize == OptimizeMode::Lossless {
1083            return Err(TransformError::InvalidOptions(
1084                "quality cannot be combined with optimize=lossless".to_string(),
1085            ));
1086        }
1087
1088        if self.target_quality.is_some()
1089            && matches!(optimize, OptimizeMode::None | OptimizeMode::Lossless)
1090        {
1091            return Err(TransformError::InvalidOptions(
1092                "targetQuality requires optimize=auto or optimize=lossy".to_string(),
1093            ));
1094        }
1095
1096        if self.target_quality.is_some() && !format.supports_lossy_optimization() {
1097            return Err(TransformError::InvalidOptions(
1098                "targetQuality requires jpeg, webp, or avif output".to_string(),
1099            ));
1100        }
1101
1102        let fit = if has_bounded_resize {
1103            Some(self.fit.unwrap_or(Fit::Contain))
1104        } else {
1105            None
1106        };
1107
1108        Ok(NormalizedTransformOptions {
1109            width: self.width,
1110            height: self.height,
1111            fit,
1112            position: self.position.unwrap_or(Position::Center),
1113            format,
1114            quality: self.quality,
1115            optimize,
1116            target_quality: self.target_quality,
1117            background: self.background,
1118            rotate: self.rotate,
1119            auto_orient: self.auto_orient,
1120            metadata_policy: normalize_metadata_policy(
1121                self.strip_metadata,
1122                self.preserve_exif,
1123                optimize,
1124                format,
1125            ),
1126            blur: self.blur,
1127            sharpen: self.sharpen,
1128            grayscale: self.grayscale,
1129            without_enlargement: self.without_enlargement,
1130            crop: self.crop,
1131            deadline: self.deadline,
1132        })
1133    }
1134}
1135
1136/// Fully normalized transform options ready for a backend pipeline.
1137#[derive(Debug, Clone, PartialEq)]
1138#[non_exhaustive]
1139pub(crate) struct NormalizedTransformOptions {
1140    /// The desired output width in pixels.
1141    pub width: Option<u32>,
1142    /// The desired output height in pixels.
1143    pub height: Option<u32>,
1144    /// The normalized resize fit mode.
1145    pub fit: Option<Fit>,
1146    /// The normalized positioning mode.
1147    pub position: Position,
1148    /// The resolved output format.
1149    pub format: MediaType,
1150    /// The requested lossy quality.
1151    pub quality: Option<u8>,
1152    /// The normalized optimization mode.
1153    pub optimize: OptimizeMode,
1154    /// Optional perceptual target used by lossy optimization.
1155    pub target_quality: Option<TargetQuality>,
1156    /// The requested background color.
1157    pub background: Option<Rgba8>,
1158    /// The requested extra rotation.
1159    pub rotate: Rotation,
1160    /// Whether EXIF-based auto-orientation should run.
1161    pub auto_orient: bool,
1162    /// The normalized metadata handling strategy.
1163    pub metadata_policy: MetadataPolicy,
1164    /// Gaussian blur sigma, when requested.
1165    pub blur: Option<f32>,
1166    /// Unsharp-mask (sharpen) sigma, when requested.
1167    pub sharpen: Option<f32>,
1168    /// Whether the image should be desaturated to grayscale.
1169    pub grayscale: bool,
1170    /// Whether a source smaller than the requested size may be scaled up.
1171    pub without_enlargement: bool,
1172    /// Optional explicit crop region applied before resize.
1173    pub crop: Option<CropRegion>,
1174    /// Optional wall-clock deadline for the transform pipeline.
1175    pub deadline: Option<Duration>,
1176}
1177
1178/// Resize behavior for bounded transforms.
1179///
1180/// # Examples
1181///
1182/// ```
1183/// use truss::Fit;
1184/// use std::str::FromStr;
1185///
1186/// let fit = Fit::from_str("cover").unwrap();
1187/// assert_eq!(fit, Fit::Cover);
1188/// assert_eq!(fit.as_name(), "cover");
1189///
1190/// assert!(Fit::from_str("unknown").is_err());
1191/// ```
1192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1193#[non_exhaustive]
1194pub enum Fit {
1195    /// Scale to fit inside the box, preserving aspect ratio, then pad to the exact box.
1196    ///
1197    /// The output is always the requested width and height. When the aspect ratios differ,
1198    /// the remaining area is filled with the requested background.
1199    Contain,
1200    /// Scale to cover the box, preserving aspect ratio, then crop to the exact box.
1201    Cover,
1202    /// Stretch each axis to the box independently, without preserving aspect ratio.
1203    Fill,
1204    /// Scale to fit inside the box, preserving aspect ratio, and add no padding.
1205    ///
1206    /// The output is at most the requested width and height, and is usually smaller on one
1207    /// axis: a 640x427 source in a 200x200 box becomes 200x133. This is the difference from
1208    /// [`Fit::Contain`], which pads that same result out to 200x200.
1209    ///
1210    /// Whether a smaller source may be scaled up is not part of this mode. That is
1211    /// [`TransformOptions::without_enlargement`], which applies to every fit.
1212    Inside,
1213}
1214
1215impl Fit {
1216    /// Returns the canonical option name used by the API and CLI.
1217    #[must_use]
1218    pub const fn as_name(self) -> &'static str {
1219        match self {
1220            Self::Contain => "contain",
1221            Self::Cover => "cover",
1222            Self::Fill => "fill",
1223            Self::Inside => "inside",
1224        }
1225    }
1226}
1227
1228impl FromStr for Fit {
1229    type Err = String;
1230
1231    fn from_str(value: &str) -> Result<Self, Self::Err> {
1232        match value {
1233            "contain" => Ok(Self::Contain),
1234            "cover" => Ok(Self::Cover),
1235            "fill" => Ok(Self::Fill),
1236            "inside" => Ok(Self::Inside),
1237            _ => Err(format!("unsupported fit mode `{value}`")),
1238        }
1239    }
1240}
1241
1242/// Positioning behavior for bounded transforms.
1243///
1244/// # Examples
1245///
1246/// ```
1247/// use truss::Position;
1248/// use std::str::FromStr;
1249///
1250/// let pos = Position::from_str("bottom-right").unwrap();
1251/// assert_eq!(pos, Position::BottomRight);
1252/// assert_eq!(pos.as_name(), "bottom-right");
1253///
1254/// assert!(Position::from_str("middle").is_err());
1255/// ```
1256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1257#[non_exhaustive]
1258pub enum Position {
1259    /// Center alignment.
1260    Center,
1261    /// Top alignment.
1262    Top,
1263    /// Right alignment.
1264    Right,
1265    /// Bottom alignment.
1266    Bottom,
1267    /// Left alignment.
1268    Left,
1269    /// Top-left alignment.
1270    TopLeft,
1271    /// Top-right alignment.
1272    TopRight,
1273    /// Bottom-left alignment.
1274    BottomLeft,
1275    /// Bottom-right alignment.
1276    BottomRight,
1277}
1278
1279impl Position {
1280    /// Returns the canonical option name used by the API and CLI.
1281    #[must_use]
1282    pub const fn as_name(self) -> &'static str {
1283        match self {
1284            Self::Center => "center",
1285            Self::Top => "top",
1286            Self::Right => "right",
1287            Self::Bottom => "bottom",
1288            Self::Left => "left",
1289            Self::TopLeft => "top-left",
1290            Self::TopRight => "top-right",
1291            Self::BottomLeft => "bottom-left",
1292            Self::BottomRight => "bottom-right",
1293        }
1294    }
1295}
1296
1297impl FromStr for Position {
1298    type Err = String;
1299
1300    fn from_str(value: &str) -> Result<Self, Self::Err> {
1301        match value {
1302            "center" => Ok(Self::Center),
1303            "top" => Ok(Self::Top),
1304            "right" => Ok(Self::Right),
1305            "bottom" => Ok(Self::Bottom),
1306            "left" => Ok(Self::Left),
1307            "top-left" => Ok(Self::TopLeft),
1308            "top-right" => Ok(Self::TopRight),
1309            "bottom-left" => Ok(Self::BottomLeft),
1310            "bottom-right" => Ok(Self::BottomRight),
1311            _ => Err(format!("unsupported position `{value}`")),
1312        }
1313    }
1314}
1315
1316/// Clockwise rotation in whole degrees, applied after auto-orientation.
1317///
1318/// Any integer is accepted and normalized into `0..360`, so a negative angle turns
1319/// counter-clockwise and a value past a full turn wraps: `-90` and `270` are the same
1320/// rotation, and so are `370` and `10`.
1321///
1322/// Degrees are whole numbers on purpose. The value appears verbatim in the cache key and
1323/// in the signed-URL canonical string, and a fractional angle would have to round-trip
1324/// bit-identically through Rust's and JavaScript's float formatting for a signature to
1325/// verify. Whole degrees sidestep that entirely, and no real caller asks for a fraction of
1326/// one.
1327///
1328/// # Examples
1329///
1330/// ```
1331/// use truss::Rotation;
1332/// use std::str::FromStr;
1333///
1334/// let rot = Rotation::from_str("270").unwrap();
1335/// assert_eq!(rot.as_degrees(), 270);
1336///
1337/// // Negative turns counter-clockwise, and wraps to the same rotation.
1338/// assert_eq!(Rotation::from_str("-90").unwrap(), rot);
1339/// // Angles past a full turn wrap too.
1340/// assert_eq!(Rotation::from_str("630").unwrap(), rot);
1341///
1342/// // Any whole angle is allowed, not just quarter turns.
1343/// assert_eq!(Rotation::from_str("45").unwrap().as_degrees(), 45);
1344/// assert!(Rotation::from_str("45.5").is_err());
1345///
1346/// assert!(Rotation::DEG_0.is_identity());
1347/// assert_eq!(Rotation::DEG_90.quarter_turns(), Some(1));
1348/// assert_eq!(Rotation::from_degrees(45).quarter_turns(), None);
1349/// ```
1350#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1351pub struct Rotation(u16);
1352
1353impl Rotation {
1354    /// No rotation.
1355    pub const DEG_0: Self = Self(0);
1356    /// A quarter turn clockwise.
1357    pub const DEG_90: Self = Self(90);
1358    /// A half turn.
1359    pub const DEG_180: Self = Self(180);
1360    /// Three quarter turns clockwise.
1361    pub const DEG_270: Self = Self(270);
1362
1363    /// Builds a rotation from any whole number of degrees, normalizing into `0..360`.
1364    ///
1365    /// Positive turns clockwise and negative turns counter-clockwise, which is the
1366    /// convention `--rotate` has always used and the one image tools generally agree on.
1367    #[must_use]
1368    pub const fn from_degrees(degrees: i32) -> Self {
1369        let wrapped = degrees % 360;
1370        let normalized = if wrapped < 0 { wrapped + 360 } else { wrapped };
1371        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
1372        Self(normalized as u16)
1373    }
1374
1375    /// Returns the normalized degree value used by the API, the CLI, and the cache key.
1376    #[must_use]
1377    pub const fn as_degrees(self) -> u16 {
1378        self.0
1379    }
1380
1381    /// Returns `true` when the rotation leaves the image untouched.
1382    #[must_use]
1383    pub const fn is_identity(self) -> bool {
1384        self.0 == 0
1385    }
1386
1387    /// Returns the number of clockwise quarter turns when the angle is a multiple of 90.
1388    ///
1389    /// A quarter turn only permutes pixels, so the pipeline keeps that exact path instead
1390    /// of resampling. Anything else returns `None` and goes through the general rotation.
1391    #[must_use]
1392    pub const fn quarter_turns(self) -> Option<u8> {
1393        if self.0.is_multiple_of(90) {
1394            #[allow(clippy::cast_possible_truncation)]
1395            Some((self.0 / 90) as u8)
1396        } else {
1397            None
1398        }
1399    }
1400}
1401
1402impl fmt::Display for Rotation {
1403    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1404        write!(f, "{}", self.0)
1405    }
1406}
1407
1408impl FromStr for Rotation {
1409    type Err = String;
1410
1411    fn from_str(value: &str) -> Result<Self, Self::Err> {
1412        // Parsed wide and reduced before it is narrowed. An angle past a full turn wraps,
1413        // which is what this type documents, so how many turns it is past does not change
1414        // the answer; parsing straight into `i32` made a large multiple of 360 report that
1415        // it was not a whole number, which it is.
1416        match value.parse::<i64>() {
1417            Ok(degrees) => Ok(Self::from_degrees((degrees % 360) as i32)),
1418            Err(_) => Err(format!(
1419                "unsupported rotation `{value}`: expected a whole number of degrees"
1420            )),
1421        }
1422    }
1423}
1424
1425/// A simple 8-bit RGBA color.
1426///
1427/// # Examples
1428///
1429/// ```
1430/// use truss::Rgba8;
1431///
1432/// // Parse a 6-digit hex color (fully opaque)
1433/// let red = Rgba8::from_hex("ff0000").unwrap();
1434/// assert_eq!(red, Rgba8 { r: 255, g: 0, b: 0, a: 255 });
1435///
1436/// // Parse an 8-digit hex color with alpha
1437/// let semi = Rgba8::from_hex("00ff0080").unwrap();
1438/// assert_eq!(semi, Rgba8 { r: 0, g: 255, b: 0, a: 128 });
1439///
1440/// // Invalid input is rejected
1441/// assert!(Rgba8::from_hex("xyz").is_err());
1442/// ```
1443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1444pub struct Rgba8 {
1445    /// Red channel.
1446    pub r: u8,
1447    /// Green channel.
1448    pub g: u8,
1449    /// Blue channel.
1450    pub b: u8,
1451    /// Alpha channel.
1452    pub a: u8,
1453}
1454
1455impl Rgba8 {
1456    /// Parses a hexadecimal RGB or RGBA color string without a leading `#`.
1457    ///
1458    /// Every rejection reads the same sentence, which names the shape truss accepts rather
1459    /// than repeating the value back. `#ffffff` is the spelling a caller reaches for first,
1460    /// since CSS, HTML, and every colour picker use it, and `unsupported color \`#ffffff\``
1461    /// gave them nothing to correct. Naming the rule is what every other option here does.
1462    pub fn from_hex(value: &str) -> Result<Self, String> {
1463        fn rule(value: &str) -> String {
1464            format!(
1465                "unsupported color `{value}`: a color is six or eight hexadecimal digits with no leading `#`, as in ffffff or ffffffaa"
1466            )
1467        }
1468
1469        if !value.is_ascii() || (value.len() != 6 && value.len() != 8) {
1470            return Err(rule(value));
1471        }
1472
1473        let r = u8::from_str_radix(&value[0..2], 16).map_err(|_| rule(value))?;
1474        let g = u8::from_str_radix(&value[2..4], 16).map_err(|_| rule(value))?;
1475        let b = u8::from_str_radix(&value[4..6], 16).map_err(|_| rule(value))?;
1476        let a = if value.len() == 8 {
1477            u8::from_str_radix(&value[6..8], 16).map_err(|_| rule(value))?
1478        } else {
1479            u8::MAX
1480        };
1481
1482        Ok(Self { r, g, b, a })
1483    }
1484}
1485
1486/// Metadata handling after option normalization.
1487///
1488/// Crate-internal: it is the resolved form of `stripMetadata`, `keepMetadata`, and
1489/// `preserveExif`, which the adapters carry as those three names. What a caller sees is the
1490/// output, and a [`TransformWarning::MetadataDropped`] when the format could not carry
1491/// something. The resolution itself is covered by `metadata_policy_resolution` in this
1492/// module's tests.
1493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1494#[non_exhaustive]
1495pub(crate) enum MetadataPolicy {
1496    /// Drop metadata from the output.
1497    StripAll,
1498    /// Keep metadata unchanged when possible.
1499    KeepAll,
1500    /// Preserve ICC profiles while stripping EXIF and other metadata.
1501    PreserveIcc,
1502    /// Preserve EXIF while allowing other metadata policies later.
1503    PreserveExif,
1504}
1505
1506/// Resolves the three-way metadata flag semantics shared by all adapters.
1507///
1508/// Adapters accept different flag names (CLI: `--keep-metadata`/`--strip-metadata`/`--preserve-exif`,
1509/// WASM: `keepMetadata`/`preserveExif`, server: `stripMetadata`/`preserveExif`) but the
1510/// underlying semantics are identical. This function centralizes the resolution so that
1511/// every adapter produces the same `(strip_metadata, preserve_exif)` pair for the same
1512/// logical input.
1513///
1514/// # Arguments
1515///
1516/// * `strip` — Explicit "strip all metadata" flag, when provided.
1517/// * `keep` — Explicit "keep all metadata" flag, when provided.
1518/// * `preserve_exif` — Explicit "preserve EXIF only" flag, when provided.
1519///
1520/// # Errors
1521///
1522/// Returns [`TransformError::InvalidOptions`] when `keep` and `preserve_exif` are both
1523/// explicitly `true`, since those policies are mutually exclusive.
1524pub(crate) fn resolve_metadata_flags(
1525    strip: Option<bool>,
1526    keep: Option<bool>,
1527    preserve_exif: Option<bool>,
1528) -> Result<(bool, bool), TransformError> {
1529    let keep = keep.unwrap_or(false);
1530    let preserve_exif = preserve_exif.unwrap_or(false);
1531
1532    if keep && preserve_exif {
1533        return Err(TransformError::InvalidOptions(
1534            "keepMetadata and preserveExif cannot both be true".to_string(),
1535        ));
1536    }
1537
1538    let strip_metadata = if keep || preserve_exif {
1539        false
1540    } else {
1541        strip.unwrap_or(true)
1542    };
1543
1544    Ok((strip_metadata, preserve_exif))
1545}
1546
1547/// Errors returned by Core validation or backend execution.
1548///
1549/// # Examples
1550///
1551/// ```
1552/// use truss::TransformError;
1553///
1554/// let err = TransformError::InvalidOptions("quality must be between 1 and 100".into());
1555/// assert_eq!(
1556///     format!("{err}"),
1557///     "invalid transform options: quality must be between 1 and 100"
1558/// );
1559///
1560/// // TransformError implements std::error::Error
1561/// let _: &dyn std::error::Error = &err;
1562/// ```
1563#[derive(Debug, Clone, PartialEq, Eq)]
1564#[non_exhaustive]
1565pub enum TransformError {
1566    /// The input artifact is structurally invalid.
1567    InvalidInput(String),
1568    /// The provided options are contradictory or unsupported.
1569    InvalidOptions(String),
1570    /// The input media type cannot be processed.
1571    UnsupportedInputMediaType(String),
1572    /// The requested output media type cannot be produced.
1573    UnsupportedOutputMediaType(MediaType),
1574    /// Decoding the input artifact failed.
1575    DecodeFailed(String),
1576    /// Encoding the output artifact failed.
1577    EncodeFailed(String),
1578    /// The current runtime does not provide a required capability.
1579    CapabilityMissing(String),
1580    /// The image exceeds a processing limit such as maximum pixel count.
1581    LimitExceeded(String),
1582}
1583
1584/// Folds a message onto a single trimmed line.
1585///
1586/// Every adapter presents a failure as one line of text: the CLI writes
1587/// `error: <message> (<class>)`, the HTTP server puts the message in an RFC 9457 `detail`,
1588/// and `@nao1215/truss-wasm` hands it to a browser. Some of those messages come from a
1589/// decoder in a dependency, whose wording truss does not choose and which may end with a
1590/// newline or hold one in the middle, so the line break is taken out where the message is
1591/// rendered rather than at each of the thirty places one can enter from.
1592///
1593/// A message that is already one trimmed line is returned untouched. `server::cache` does
1594/// the same to a warning before it goes on an entry's header line, for the same reason.
1595///
1596/// A build with neither the server nor the Wasm adapter, which is the library on its own,
1597/// renders no message and does not compile this.
1598#[cfg(any(feature = "server", feature = "wasm"))]
1599pub(crate) fn single_line(message: &str) -> std::borrow::Cow<'_, str> {
1600    let trimmed = message.trim();
1601    if !trimmed.contains(breaks_a_line) {
1602        return std::borrow::Cow::Borrowed(trimmed);
1603    }
1604    std::borrow::Cow::Owned(trimmed.split_whitespace().collect::<Vec<_>>().join(" "))
1605}
1606
1607/// Reports whether a character would move a message off its line.
1608///
1609/// A space is what the message already reads as between two words, so only the ones that
1610/// end the line or move the cursor count.
1611#[cfg(any(feature = "server", feature = "wasm"))]
1612fn breaks_a_line(c: char) -> bool {
1613    (c.is_whitespace() && c != ' ') || c.is_control()
1614}
1615
1616impl fmt::Display for TransformError {
1617    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1618        match self {
1619            Self::InvalidInput(reason) => write!(f, "invalid input: {reason}"),
1620            Self::InvalidOptions(reason) => write!(f, "invalid transform options: {reason}"),
1621            Self::UnsupportedInputMediaType(reason) => {
1622                write!(f, "unsupported input media type: {reason}")
1623            }
1624            // Naming only the media type left the reader to guess why: `svg` is
1625            // refused for a raster input yet accepted for an SVG one, and `gif` is
1626            // refused for every input. Say which rule was hit and what to ask for
1627            // instead, so the CLI, the server, and the WASM build all explain it the
1628            // same way.
1629            Self::UnsupportedOutputMediaType(media_type) => match media_type {
1630                MediaType::Svg => write!(
1631                    f,
1632                    "svg output requires an svg input; choose a raster output format such as png, jpeg, webp, or avif"
1633                ),
1634                MediaType::Gif => write!(
1635                    f,
1636                    "gif is an input-only format; choose an output format such as png, jpeg, webp, or avif"
1637                ),
1638                other => write!(f, "unsupported output media type: {other}"),
1639            },
1640            Self::DecodeFailed(reason) => write!(f, "decode failed: {reason}"),
1641            Self::EncodeFailed(reason) => write!(f, "encode failed: {reason}"),
1642            Self::CapabilityMissing(reason) => write!(f, "missing capability: {reason}"),
1643            Self::LimitExceeded(reason) => write!(f, "limit exceeded: {reason}"),
1644        }
1645    }
1646}
1647
1648impl Error for TransformError {}
1649
1650/// Categories of image metadata that may be present in an artifact.
1651///
1652/// Used by [`TransformWarning::MetadataDropped`] to identify which metadata type
1653/// was silently dropped during a transform operation.
1654///
1655/// ```
1656/// use truss::MetadataKind;
1657///
1658/// assert_eq!(format!("{}", MetadataKind::Xmp), "XMP");
1659/// assert_eq!(format!("{}", MetadataKind::Iptc), "IPTC");
1660/// assert_eq!(format!("{}", MetadataKind::Exif), "EXIF");
1661/// assert_eq!(format!("{}", MetadataKind::Icc), "ICC profile");
1662/// ```
1663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1664#[non_exhaustive]
1665pub enum MetadataKind {
1666    /// XMP (Extensible Metadata Platform) metadata.
1667    Xmp,
1668    /// IPTC/IIM (International Press Telecommunications Council) metadata.
1669    Iptc,
1670    /// EXIF (Exchangeable Image File Format) metadata.
1671    Exif,
1672    /// ICC color profile.
1673    Icc,
1674}
1675
1676impl fmt::Display for MetadataKind {
1677    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1678        match self {
1679            Self::Xmp => f.write_str("XMP"),
1680            Self::Iptc => f.write_str("IPTC"),
1681            Self::Exif => f.write_str("EXIF"),
1682            Self::Icc => f.write_str("ICC profile"),
1683        }
1684    }
1685}
1686
1687/// A non-fatal warning emitted during a transform operation.
1688///
1689/// Warnings indicate that the transform completed successfully but some aspect of
1690/// the request could not be fully honored. Adapters should surface these to operators
1691/// (e.g. CLI prints to stderr, server logs to stderr).
1692///
1693/// ```
1694/// use truss::{MetadataKind, TransformWarning};
1695///
1696/// let warning = TransformWarning::MetadataDropped(MetadataKind::Xmp);
1697/// assert_eq!(
1698///     format!("{warning}"),
1699///     "XMP metadata was present in the input but could not be preserved by the output encoder"
1700/// );
1701/// ```
1702#[derive(Debug, Clone, PartialEq)]
1703#[non_exhaustive]
1704pub enum TransformWarning {
1705    /// Metadata of the given kind was present in the input but could not be preserved
1706    /// by the output encoder and was silently dropped.
1707    MetadataDropped(MetadataKind),
1708    /// The input carries an EXIF orientation that the output records neither in its pixels
1709    /// nor in its metadata, so the output displays rotated relative to the input.
1710    OrientationDropped {
1711        /// The EXIF orientation value the input carried.
1712        orientation: u16,
1713    },
1714    /// No quality the search probed reached the requested target, so the output scores below
1715    /// it. Raised only for a target the caller named, never for the one `auto` picks on its
1716    /// own, and never when the input's own bytes were handed back. The search samples the
1717    /// quality range rather than walking it, so this says no probed quality reached the
1718    /// target rather than that none would; see [`TargetQuality`].
1719    TargetQualityNotReached {
1720        /// The target that was asked for.
1721        target: TargetQuality,
1722        /// The score the returned encode reached, which is the one at `quality`.
1723        achieved: f32,
1724        /// The quality of the encode returned: the `quality` cap when one was given,
1725        /// otherwise 100.
1726        quality: u8,
1727    },
1728}
1729
1730impl fmt::Display for TransformWarning {
1731    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1732        match self {
1733            Self::MetadataDropped(kind) => write!(
1734                f,
1735                "{kind} metadata was present in the input but could not be preserved by the output encoder"
1736            ),
1737            Self::OrientationDropped { orientation } => write!(
1738                f,
1739                "the input carries EXIF orientation {orientation}; with autoOrient off and the metadata stripped the output records it neither in its pixels nor in its metadata, so it displays rotated. Keep the metadata to preserve the tag, or leave autoOrient on to apply it to the pixels"
1740            ),
1741            Self::TargetQualityNotReached {
1742                target,
1743                achieved,
1744                quality,
1745            } => {
1746                let metric = target.metric.as_name();
1747                if *quality < 100 {
1748                    write!(
1749                        f,
1750                        "the lossy encode did not reach {target} within the quality cap of {quality}: at that quality it reached {metric} {achieved:.3}. Raise the cap or lower the target"
1751                    )
1752                } else {
1753                    write!(
1754                        f,
1755                        "the lossy encode did not reach {target} at quality 100, where it reached {metric} {achieved:.3}. The quality range is sampled rather than scanned, so a setting the search did not try may still reach it; lower the target for one it will find"
1756                    )
1757                }
1758            }
1759        }
1760    }
1761}
1762
1763/// The result of a successful transform, containing the output artifact and any warnings.
1764///
1765/// Warnings indicate aspects of the request that could not be fully honored, such as
1766/// metadata types that were silently dropped because the output encoder does not support them.
1767#[derive(Debug)]
1768#[must_use]
1769#[non_exhaustive]
1770pub struct TransformResult {
1771    /// The transformed output artifact.
1772    pub artifact: Artifact,
1773    /// Non-fatal warnings emitted during the transform.
1774    pub warnings: Vec<TransformWarning>,
1775}
1776
1777/// Inspects raw bytes, detects the media type, and extracts best-effort metadata.
1778///
1779/// The caller is expected to pass bytes that have already been resolved by an adapter
1780/// such as the CLI or HTTP server runtime. If a declared media type is provided in the
1781/// [`RawArtifact`], this function verifies that the declared type matches the detected
1782/// signature before returning the classified [`Artifact`].
1783///
1784/// Detection currently supports JPEG, PNG, WebP, AVIF, and BMP recognition.
1785/// Width, height, and alpha extraction are best-effort and depend on the underlying format
1786/// and any container metadata the file exposes.
1787///
1788/// # Errors
1789///
1790/// Returns [`TransformError::UnsupportedInputMediaType`] when the byte signature does not
1791/// match a supported format, [`TransformError::InvalidInput`] when the declared media type
1792/// conflicts with the detected type, and [`TransformError::DecodeFailed`] when a supported
1793/// format has an invalid or truncated structure.
1794///
1795/// # Examples
1796///
1797/// ```
1798/// use truss::{sniff_artifact, MediaType, RawArtifact};
1799///
1800/// let png_bytes = vec![
1801///     0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1A, b'\n',
1802///     0, 0, 0, 13, b'I', b'H', b'D', b'R',
1803///     0, 0, 0, 4, 0, 0, 0, 3, 8, 6, 0, 0, 0,
1804///     0, 0, 0, 0,
1805/// ];
1806///
1807/// let artifact = sniff_artifact(RawArtifact::new(png_bytes, Some(MediaType::Png))).unwrap();
1808///
1809/// assert_eq!(artifact.media_type, MediaType::Png);
1810/// assert_eq!(artifact.metadata.width, Some(4));
1811/// assert_eq!(artifact.metadata.height, Some(3));
1812/// ```
1813///
1814/// ```ignore
1815/// use image::codecs::avif::AvifEncoder;
1816/// use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
1817/// use truss::{sniff_artifact, MediaType, RawArtifact};
1818///
1819/// let image = RgbaImage::from_pixel(3, 2, Rgba([10, 20, 30, 0]));
1820/// let mut bytes = Vec::new();
1821/// AvifEncoder::new(&mut bytes)
1822///     .write_image(&image, 3, 2, ColorType::Rgba8.into())
1823///     .unwrap();
1824///
1825/// let artifact = sniff_artifact(RawArtifact::new(bytes, Some(MediaType::Avif))).unwrap();
1826///
1827/// assert_eq!(artifact.media_type, MediaType::Avif);
1828/// assert_eq!(artifact.metadata.width, Some(3));
1829/// assert_eq!(artifact.metadata.height, Some(2));
1830/// assert_eq!(artifact.metadata.has_alpha, Some(true));
1831/// ```
1832#[must_use = "this function returns the detected artifact without side effects"]
1833pub fn sniff_artifact(input: RawArtifact) -> Result<Artifact, TransformError> {
1834    let (media_type, metadata) = detect_artifact(&input.bytes)?;
1835
1836    if let Some(declared_media_type) = input.declared_media_type
1837        && declared_media_type != media_type
1838    {
1839        return Err(TransformError::InvalidInput(
1840            "declared media type does not match detected media type".to_string(),
1841        ));
1842    }
1843
1844    Ok(Artifact::new(input.bytes, media_type, metadata))
1845}
1846
1847fn validate_dimension(name: &str, value: Option<u32>) -> Result<(), TransformError> {
1848    if matches!(value, Some(0)) {
1849        return Err(TransformError::InvalidOptions(format!(
1850            "{name} must be greater than zero"
1851        )));
1852    }
1853
1854    Ok(())
1855}
1856
1857fn validate_quality(value: Option<u8>) -> Result<(), TransformError> {
1858    match value {
1859        Some(value) => validate_quality_value(i64::from(value))
1860            .map(|_| ())
1861            .map_err(|message| TransformError::InvalidOptions(message.to_string())),
1862        None => Ok(()),
1863    }
1864}
1865
1866/// The range a quality has to be in, checked against a number of any width.
1867///
1868/// A caller types a number, not a `u8`, and refusing 256 with the span of the integer it
1869/// would be stored in tells them a limit that is not truss's: `--quality 255` was answered
1870/// `quality must be between 1 and 100` while `--quality 256` was answered
1871/// `256 is not in 0..=255`, and the server said `expected u8`. Every adapter parses wide and
1872/// asks this, so one option has one limit and one sentence.
1873pub(crate) fn validate_quality_value(value: i64) -> Result<u8, &'static str> {
1874    match value {
1875        1..=100 => Ok(value as u8),
1876        _ => Err("quality must be between 1 and 100"),
1877    }
1878}
1879
1880fn validate_target_quality(value: Option<TargetQuality>) -> Result<(), TransformError> {
1881    let Some(value) = value else {
1882        return Ok(());
1883    };
1884
1885    if !value.value.is_finite() {
1886        return Err(TransformError::InvalidOptions(
1887            "targetQuality must be finite".to_string(),
1888        ));
1889    }
1890
1891    match value.metric {
1892        QualityMetric::Ssim if !(0.0..=1.0).contains(&value.value) || value.value == 0.0 => {
1893            Err(TransformError::InvalidOptions(
1894                "ssim targetQuality must be greater than 0.0 and at most 1.0".to_string(),
1895            ))
1896        }
1897        QualityMetric::Psnr if value.value <= 0.0 => Err(TransformError::InvalidOptions(
1898            "psnr targetQuality must be greater than 0".to_string(),
1899        )),
1900        _ => Ok(()),
1901    }
1902}
1903
1904fn validate_blur(value: Option<f32>) -> Result<(), TransformError> {
1905    if let Some(sigma) = value
1906        && !(0.1..=100.0).contains(&sigma)
1907    {
1908        return Err(TransformError::InvalidOptions(
1909            "blur sigma must be between 0.1 and 100.0".to_string(),
1910        ));
1911    }
1912
1913    Ok(())
1914}
1915
1916fn validate_sharpen(value: Option<f32>) -> Result<(), TransformError> {
1917    if let Some(sigma) = value
1918        && !(0.1..=100.0).contains(&sigma)
1919    {
1920        return Err(TransformError::InvalidOptions(
1921            "sharpen sigma must be between 0.1 and 100.0".to_string(),
1922        ));
1923    }
1924
1925    Ok(())
1926}
1927
1928/// Reads a quality of any width and judges it by the range truss publishes.
1929///
1930/// Deserializing straight into the `u8` the field holds makes `serde` refuse 256 with
1931/// `invalid value: integer 256, expected u8`, which names a Rust type and a limit that is
1932/// not truss's. Both the HTTP payload and the Wasm options object read this, so the two
1933/// answer the same way.
1934pub(crate) fn deserialize_quality<'de, D>(deserializer: D) -> Result<Option<u8>, D::Error>
1935where
1936    D: serde::Deserializer<'de>,
1937{
1938    // A value the field can hold is handed on for `normalize` to judge, so 101 reads the
1939    // same sentence from the same place on every adapter. Only a value too large to be
1940    // held is refused here, with the sentence that check would have given.
1941    deserialize_ranged(deserializer, |value| match u8::try_from(value) {
1942        Ok(quality) => Ok(quality),
1943        Err(_) => {
1944            Err(validate_quality_value(value).expect_err("a value outside u8 is outside 1..=100"))
1945        }
1946    })
1947}
1948
1949/// Reads a width of any width and judges it by [`validate_width_value`].
1950pub(crate) fn deserialize_width<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
1951where
1952    D: serde::Deserializer<'de>,
1953{
1954    deserialize_ranged(deserializer, validate_width_value)
1955}
1956
1957/// Reads a height of any width and judges it by [`validate_height_value`].
1958pub(crate) fn deserialize_height<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
1959where
1960    D: serde::Deserializer<'de>,
1961{
1962    deserialize_ranged(deserializer, validate_height_value)
1963}
1964
1965fn deserialize_ranged<'de, D, T>(
1966    deserializer: D,
1967    validate: impl FnOnce(i64) -> Result<T, &'static str>,
1968) -> Result<Option<T>, D::Error>
1969where
1970    D: serde::Deserializer<'de>,
1971{
1972    use serde::Deserialize as _;
1973    use serde::de::Error as _;
1974    match Option::<i64>::deserialize(deserializer)? {
1975        None => Ok(None),
1976        Some(value) => validate(value).map(Some).map_err(D::Error::custom),
1977    }
1978}
1979
1980/// Reads a rotation of any width and reduces it to a single turn.
1981///
1982/// The option documents that an angle past a full turn wraps, and the CLI takes any whole
1983/// number of degrees; deserializing into `i32` made the two adapters that do it refuse what
1984/// the CLI accepts.
1985pub(crate) fn deserialize_rotation_degrees<'de, D>(deserializer: D) -> Result<Option<i32>, D::Error>
1986where
1987    D: serde::Deserializer<'de>,
1988{
1989    use serde::Deserialize as _;
1990    Ok(Option::<i64>::deserialize(deserializer)?.map(|degrees| (degrees % 360) as i32))
1991}
1992
1993/// The rules a width has to satisfy before any image has been read.
1994///
1995/// A number that fits is handed back so that the rules needing the image report it where
1996/// they always did, with the class they always had: zero is `width must be greater than
1997/// zero` from `normalize`, and a size past `MAX_OUTPUT_PIXELS` is the pixel count the
1998/// transform reports. Only a number that cannot be a count of pixels at all is refused
1999/// here, and it says which of the two things is wrong rather than naming the integer it
2000/// would have been stored in.
2001pub(crate) fn validate_width_value(value: i64) -> Result<u32, &'static str> {
2002    dimension_value(
2003        value,
2004        "width must be greater than zero",
2005        "width is too large to be a number of pixels",
2006    )
2007}
2008
2009/// The same for a height. See [`validate_width_value`].
2010pub(crate) fn validate_height_value(value: i64) -> Result<u32, &'static str> {
2011    dimension_value(
2012        value,
2013        "height must be greater than zero",
2014        "height is too large to be a number of pixels",
2015    )
2016}
2017
2018/// The rules a watermark margin has to satisfy before any image has been read.
2019///
2020/// Zero is a margin, so only a negative number and one too large to be a count of pixels are
2021/// refused here; a margin that leaves the watermark no room is reported by the pipeline,
2022/// which names the sizes involved.
2023#[cfg(any(feature = "cli", feature = "server"))]
2024pub(crate) fn validate_watermark_margin_value(value: i64) -> Result<u32, &'static str> {
2025    dimension_value(
2026        value,
2027        "watermark margin must not be negative",
2028        "watermark margin is too large to be a number of pixels",
2029    )
2030}
2031
2032fn dimension_value(
2033    value: i64,
2034    not_positive: &'static str,
2035    too_large: &'static str,
2036) -> Result<u32, &'static str> {
2037    match u32::try_from(value) {
2038        Ok(pixels) => Ok(pixels),
2039        Err(_) if value <= 0 => Err(not_positive),
2040        Err(_) => Err(too_large),
2041    }
2042}
2043
2044/// The range a watermark opacity has to be in, checked against a number of any width.
2045///
2046/// The sibling of [`validate_quality_value`], and there for the same reason: 256 is not a
2047/// `u8`, and saying so names an integer type rather than the range truss publishes. Only
2048/// the two adapters that parse a caller's text need it, so it is gated the way they are.
2049#[cfg(any(feature = "cli", feature = "server"))]
2050pub(crate) fn validate_watermark_opacity_value(value: i64) -> Result<u8, &'static str> {
2051    match value {
2052        1..=100 => Ok(value as u8),
2053        _ => Err("watermark opacity must be between 1 and 100"),
2054    }
2055}
2056
2057/// Checks the watermark opacity, which every adapter reads before it has an image.
2058///
2059/// The CLI checks it while parsing a flag, the HTTP server before fetching the watermark
2060/// URL, and the Wasm package while reading its options object, so the rule is needed in
2061/// four places and the message has to be the same in all of them.
2062///
2063/// Returns the message to report, so each adapter keeps its own error type and its own
2064/// failure class while the sentence the caller reads is written once.
2065pub(crate) fn validate_watermark_opacity(opacity: u8) -> Result<(), &'static str> {
2066    if opacity == 0 || opacity > 100 {
2067        return Err("watermark opacity must be between 1 and 100");
2068    }
2069
2070    Ok(())
2071}
2072
2073fn validate_watermark(wm: &WatermarkInput) -> Result<(), TransformError> {
2074    validate_watermark_opacity(wm.opacity)
2075        .map_err(|message| TransformError::InvalidOptions(message.to_string()))?;
2076
2077    if !wm.image.media_type.is_raster() {
2078        return Err(TransformError::InvalidOptions(
2079            "watermark image must be a raster format".to_string(),
2080        ));
2081    }
2082
2083    Ok(())
2084}
2085
2086/// Resolves the metadata flags into the policy the pipeline applies.
2087///
2088/// Re-encoding a profile-tagged image renders it in the wrong colors if the profile is
2089/// dropped, so a strip request is upgraded to "keep the ICC profile only" whenever an
2090/// optimization was asked for. The pixels surviving the encode does not change that: a
2091/// lossless optimization writes the same picture and the profile is what says how to read
2092/// it. `OptimizeMode::None` is left alone, since that is what a plain `truss convert` does
2093/// and stripping is what its flag says.
2094///
2095/// The upgrade is limited to formats that can actually carry a profile: turning it on for a
2096/// format that cannot made `--strip-metadata` fail with "cannot preserve metadata", which left
2097/// no flag combination that worked (<https://github.com/nao1215/truss/issues/279>).
2098fn normalize_metadata_policy(
2099    strip_metadata: bool,
2100    preserve_exif: bool,
2101    optimize: OptimizeMode,
2102    format: MediaType,
2103) -> MetadataPolicy {
2104    if preserve_exif {
2105        MetadataPolicy::PreserveExif
2106    } else if strip_metadata && optimize != OptimizeMode::None && format.supports_icc_profile() {
2107        MetadataPolicy::PreserveIcc
2108    } else if strip_metadata {
2109        MetadataPolicy::StripAll
2110    } else {
2111        MetadataPolicy::KeepAll
2112    }
2113}
2114
2115fn detect_artifact(bytes: &[u8]) -> Result<(MediaType, ArtifactMetadata), TransformError> {
2116    if is_png(bytes) {
2117        return Ok((MediaType::Png, sniff_png(bytes)?));
2118    }
2119
2120    if is_jpeg(bytes) {
2121        return Ok((MediaType::Jpeg, sniff_jpeg(bytes)?));
2122    }
2123
2124    if is_webp(bytes) {
2125        return Ok((MediaType::Webp, sniff_webp(bytes)?));
2126    }
2127
2128    if is_avif(bytes) {
2129        return Ok((MediaType::Avif, sniff_avif(bytes)?));
2130    }
2131
2132    if is_bmp(bytes) {
2133        return Ok((MediaType::Bmp, sniff_bmp(bytes)?));
2134    }
2135
2136    if is_tiff(bytes) {
2137        return Ok((MediaType::Tiff, sniff_tiff(bytes)?));
2138    }
2139
2140    if is_gif(bytes) {
2141        return Ok((MediaType::Gif, sniff_gif(bytes)?));
2142    }
2143
2144    // SVG check goes last: it relies on text scanning which is slower than binary
2145    // magic-number checks and could produce false positives on non-SVG XML.
2146    if is_svg(bytes) {
2147        return Ok((MediaType::Svg, sniff_svg(bytes)));
2148    }
2149
2150    // The length and nothing else. This message used to carry the first sixteen bytes in
2151    // hexadecimal, which is a useful thing to say about a file the operator named and a
2152    // disclosure about a URL fetched on somebody's behalf: the CLI printed it for
2153    // `--url`, and the server returned it to the caller in the `detail` of its problem
2154    // body. The core cannot tell the two apart, so it says neither.
2155    Err(TransformError::UnsupportedInputMediaType(format!(
2156        "unknown file signature ({} bytes)",
2157        bytes.len()
2158    )))
2159}
2160
2161fn is_png(bytes: &[u8]) -> bool {
2162    bytes.starts_with(b"\x89PNG\r\n\x1a\n")
2163}
2164
2165fn is_jpeg(bytes: &[u8]) -> bool {
2166    bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
2167}
2168
2169fn is_webp(bytes: &[u8]) -> bool {
2170    bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP"
2171}
2172
2173fn is_avif(bytes: &[u8]) -> bool {
2174    bytes.len() >= 16 && &bytes[4..8] == b"ftyp" && has_avif_brand(&bytes[8..])
2175}
2176
2177/// Detects SVG by consuming the XML prolog and checking that the root element is
2178/// `<svg`.
2179///
2180/// XML 1.0 defines the prolog as `XMLDecl? Misc* (doctypedecl Misc*)?` with
2181/// `Misc ::= Comment | PI | S`, so comments and processing instructions are legal
2182/// on either side of the doctype and in any number. Walking a fixed sequence
2183/// instead rejects documents real editors produce: Adobe Illustrator writes the
2184/// declaration, a generator comment, and then a doctype with an internal subset.
2185fn is_svg(bytes: &[u8]) -> bool {
2186    svg_root_element(bytes).is_some()
2187}
2188
2189/// Returns the document text from the root element onwards, when that root is `<svg`.
2190///
2191/// The prolog walk is shared with [`sniff_svg`], which needs the root element's attributes
2192/// and would otherwise repeat it. Splitting the two apart is what keeps detection and
2193/// measurement from disagreeing about where the root begins.
2194fn svg_root_element(bytes: &[u8]) -> Option<&str> {
2195    let text = std::str::from_utf8(bytes).ok()?;
2196
2197    // Skip UTF-8 BOM if present.
2198    let mut remaining = text.strip_prefix('\u{FEFF}').unwrap_or(text);
2199    let mut seen_doctype = false;
2200
2201    loop {
2202        remaining = remaining.trim_start();
2203
2204        if let Some(rest) = remaining.strip_prefix("<!--") {
2205            let end = rest.find("-->")?;
2206            remaining = &rest[end + 3..];
2207            continue;
2208        }
2209
2210        // Any processing instruction, including the XML declaration, which is
2211        // just the one whose target is `xml`.
2212        if let Some(rest) = remaining.strip_prefix("<?") {
2213            let end = rest.find("?>")?;
2214            remaining = &rest[end + 2..];
2215            continue;
2216        }
2217
2218        if !seen_doctype && let Some(rest) = remaining.strip_prefix("<!DOCTYPE") {
2219            let after = skip_doctype(rest)?;
2220            seen_doctype = true;
2221            remaining = after;
2222            continue;
2223        }
2224
2225        break;
2226    }
2227
2228    let is_root = remaining.starts_with("<svg")
2229        && remaining
2230            .as_bytes()
2231            .get(4)
2232            .is_some_and(|&b| b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>');
2233    is_root.then_some(remaining)
2234}
2235
2236/// Returns the text after a doctype declaration, or `None` when it is unterminated.
2237///
2238/// The terminating `>` is not simply the first one: an internal subset is
2239/// delimited by `[` and `]` and declares entities whose replacement text may
2240/// contain `>`, and a system identifier is a quoted string that may contain one
2241/// too.
2242fn skip_doctype(rest: &str) -> Option<&str> {
2243    let bytes = rest.as_bytes();
2244    let mut quote: Option<u8> = None;
2245    let mut in_subset = false;
2246
2247    for (index, &byte) in bytes.iter().enumerate() {
2248        match quote {
2249            Some(open) => {
2250                if byte == open {
2251                    quote = None;
2252                }
2253            }
2254            None => match byte {
2255                b'"' | b'\'' => quote = Some(byte),
2256                b'[' => in_subset = true,
2257                b']' => in_subset = false,
2258                b'>' if !in_subset => return Some(&rest[index + 1..]),
2259                _ => {}
2260            },
2261        }
2262    }
2263
2264    None
2265}
2266
2267/// Extracts SVG metadata. SVGs inherently support transparency.
2268///
2269/// The dimensions come from the root element's `width` and `height`, falling back to the
2270/// `viewBox` extent, which is what SVG defines the intrinsic size to be. They stay unknown
2271/// when the document gives no absolute answer — a percentage with no `viewBox` to resolve
2272/// it against, a font-relative unit, nothing declared at all — because a viewport is needed
2273/// to resolve those and a file on disk has none.
2274fn sniff_svg(bytes: &[u8]) -> ArtifactMetadata {
2275    let size = svg_root_element(bytes).and_then(svg_intrinsic_size);
2276    ArtifactMetadata {
2277        width: size.map(|(width, _)| width),
2278        height: size.map(|(_, height)| height),
2279        frame_count: 1,
2280        duration: None,
2281        has_alpha: Some(true),
2282        orientation: None,
2283    }
2284}
2285
2286/// The intrinsic size an SVG document declares, in pixels.
2287///
2288/// SVG resolves an intrinsic size from `width` and `height` when both are absolute lengths,
2289/// and from the `viewBox` extent otherwise; when one axis is absolute and the other is not,
2290/// the missing one follows from the `viewBox` aspect ratio. Anything that needs a viewport
2291/// or a font to resolve — a percentage, `em`, `ex` — has no answer here and returns `None`
2292/// rather than a guess, which is what the `Option` in `ArtifactMetadata` is for.
2293fn svg_intrinsic_size(root: &str) -> Option<(u32, u32)> {
2294    let tag = svg_root_tag(root)?;
2295    let width = root_attribute(tag, "width").and_then(svg_length_px);
2296    let height = root_attribute(tag, "height").and_then(svg_length_px);
2297    let view_box = root_attribute(tag, "viewBox").and_then(parse_view_box);
2298
2299    let (width, height) = match (width, height, view_box) {
2300        (Some(width), Some(height), _) => (width, height),
2301        (Some(width), None, Some((box_width, box_height))) => {
2302            (width, width * box_height / box_width)
2303        }
2304        (None, Some(height), Some((box_width, box_height))) => {
2305            (height * box_width / box_height, height)
2306        }
2307        (None, None, Some(size)) => size,
2308        _ => return None,
2309    };
2310
2311    Some((to_dimension(width)?, to_dimension(height)?))
2312}
2313
2314/// Returns the text between `<` and the `>` that closes the root start tag.
2315///
2316/// The terminator is not simply the first `>`: an attribute value is a quoted string and may
2317/// contain one, which is the same trap [`skip_doctype`] works around.
2318fn svg_root_tag(root: &str) -> Option<&str> {
2319    let mut quote: Option<u8> = None;
2320    for (index, &byte) in root.as_bytes().iter().enumerate() {
2321        match quote {
2322            Some(open) => {
2323                if byte == open {
2324                    quote = None;
2325                }
2326            }
2327            None => match byte {
2328                b'"' | b'\'' => quote = Some(byte),
2329                b'>' => return Some(&root[1..index]),
2330                _ => {}
2331            },
2332        }
2333    }
2334    None
2335}
2336
2337/// Returns the value of one attribute of a start tag, by exact name.
2338///
2339/// Matching on the whole name rather than searching for it as a substring is what keeps
2340/// `stroke-width` from answering for `width`.
2341fn root_attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
2342    let bytes = tag.as_bytes();
2343    let mut index = 0;
2344
2345    // Step over the element name; attributes start after the first run of whitespace.
2346    while index < bytes.len() && !bytes[index].is_ascii_whitespace() {
2347        index += 1;
2348    }
2349
2350    loop {
2351        while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2352            index += 1;
2353        }
2354        if index >= bytes.len() {
2355            return None;
2356        }
2357
2358        let name_start = index;
2359        while index < bytes.len()
2360            && bytes[index] != b'='
2361            && !bytes[index].is_ascii_whitespace()
2362            && bytes[index] != b'/'
2363        {
2364            index += 1;
2365        }
2366        let attribute = &tag[name_start..index];
2367        if attribute.is_empty() {
2368            // Nothing was consumed, so skip the character to guarantee progress.
2369            index += 1;
2370            continue;
2371        }
2372
2373        while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2374            index += 1;
2375        }
2376        if index >= bytes.len() || bytes[index] != b'=' {
2377            continue;
2378        }
2379        index += 1;
2380        while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2381            index += 1;
2382        }
2383
2384        let &quote = bytes.get(index)?;
2385        if quote != b'"' && quote != b'\'' {
2386            return None;
2387        }
2388        index += 1;
2389        let value_start = index;
2390        while index < bytes.len() && bytes[index] != quote {
2391            index += 1;
2392        }
2393        if index >= bytes.len() {
2394            return None;
2395        }
2396        let value = &tag[value_start..index];
2397        index += 1;
2398
2399        if attribute == name {
2400            return Some(value);
2401        }
2402    }
2403}
2404
2405/// Converts a CSS length to pixels, for the absolute units only.
2406///
2407/// The relative units — `%`, `em`, `ex`, `ch`, `rem`, `vw`, `vh` — need a viewport or a font
2408/// to resolve and have no answer for a file read off disk, so they return `None` and let the
2409/// `viewBox` answer instead.
2410fn svg_length_px(value: &str) -> Option<f64> {
2411    let value = value.trim();
2412    let split = value
2413        .find(|c: char| !matches!(c, '0'..='9' | '.' | '+' | '-' | 'e' | 'E'))
2414        .unwrap_or(value.len());
2415    let number: f64 = value[..split].parse().ok()?;
2416    let scale = match value[split..].trim().to_ascii_lowercase().as_str() {
2417        "" | "px" => 1.0,
2418        "pt" => 96.0 / 72.0,
2419        "pc" => 16.0,
2420        "in" => 96.0,
2421        "cm" => 96.0 / 2.54,
2422        "mm" => 96.0 / 25.4,
2423        "q" => 96.0 / 101.6,
2424        _ => return None,
2425    };
2426    let pixels = number * scale;
2427    (pixels.is_finite() && pixels > 0.0).then_some(pixels)
2428}
2429
2430/// Returns the width and height of a `viewBox`, which is its third and fourth numbers.
2431fn parse_view_box(value: &str) -> Option<(f64, f64)> {
2432    let numbers: Vec<f64> = value
2433        .split([' ', '\t', '\n', '\r', ','])
2434        .filter(|part| !part.is_empty())
2435        .map(str::parse)
2436        .collect::<Result<_, _>>()
2437        .ok()?;
2438    let [_, _, width, height] = numbers[..] else {
2439        return None;
2440    };
2441    (width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0)
2442        .then_some((width, height))
2443}
2444
2445/// Truncates a resolved length to the pixel count a caller can act on.
2446///
2447/// Truncation rather than rounding is deliberate: `usvg` truncates when it turns the same
2448/// document into a render size, and a reported dimension that disagrees with the one a
2449/// conversion produces is the drift issue #322 closed for EXIF orientation.
2450fn to_dimension(value: f64) -> Option<u32> {
2451    if !(value.is_finite() && value >= 1.0 && value < f64::from(u32::MAX)) {
2452        return None;
2453    }
2454    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2455    Some(value as u32)
2456}
2457
2458/// Detects BMP files by checking for the "BM" signature at offset 0.
2459fn is_bmp(bytes: &[u8]) -> bool {
2460    bytes.len() >= 26 && bytes[0] == 0x42 && bytes[1] == 0x4D
2461}
2462
2463/// Extracts BMP metadata from the DIB header.
2464///
2465/// The BITMAPINFOHEADER layout (and compatible V4/V5 headers) stores:
2466/// - width as a signed 32-bit integer at file offset 18
2467/// - height as a signed 32-bit integer at file offset 22 (negative = top-down)
2468/// - bits per pixel at file offset 28
2469fn sniff_bmp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
2470    if bytes.len() < 30 {
2471        return Err(TransformError::DecodeFailed(
2472            "bmp file is too short".to_string(),
2473        ));
2474    }
2475
2476    let width = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]);
2477    let raw_height = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]);
2478    let height = raw_height.unsigned_abs();
2479    let bits_per_pixel = u16::from_le_bytes([bytes[28], bytes[29]]);
2480
2481    let has_alpha = bits_per_pixel == 32;
2482
2483    Ok(ArtifactMetadata {
2484        width: Some(width),
2485        height: Some(height),
2486        frame_count: 1,
2487        duration: None,
2488        has_alpha: Some(has_alpha),
2489        orientation: None,
2490    })
2491}
2492
2493/// Detects TIFF files by checking the byte-order marker and magic number.
2494///
2495/// Little-endian: `II` + 0x002A, big-endian: `MM` + 0x002A.
2496fn is_tiff(bytes: &[u8]) -> bool {
2497    bytes.len() >= 4
2498        && ((bytes[0] == b'I' && bytes[1] == b'I' && bytes[2] == 0x2A && bytes[3] == 0x00)
2499            || (bytes[0] == b'M' && bytes[1] == b'M' && bytes[2] == 0x00 && bytes[3] == 0x2A))
2500}
2501
2502fn is_gif(bytes: &[u8]) -> bool {
2503    bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")
2504}
2505
2506/// Extracts GIF metadata by walking the block stream in the header.
2507///
2508/// The Logical Screen Descriptor carries the canvas size, which is also the size the
2509/// `image` crate reports for the decoded first frame, so a frame smaller than the canvas
2510/// still lines up. Frames and transparency need a walk over the block stream: `frame_count`
2511/// is how [`crate::inspect`] reports animation, and refusing to encode an animation depends
2512/// on getting the count right, so this deliberately walks the whole file rather than
2513/// stopping at the first image descriptor.
2514///
2515/// Transparency is read from the Graphic Control Extension's transparent-color flag. Like
2516/// [`sniff_png`], which reads the PNG color type, this reports what the container declares
2517/// rather than scanning pixels.
2518fn sniff_gif(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
2519    // 6-byte signature + 7-byte Logical Screen Descriptor.
2520    if bytes.len() < 13 {
2521        return Err(TransformError::DecodeFailed(
2522            "gif file is too short".to_string(),
2523        ));
2524    }
2525
2526    let width = u32::from(read_u16_le(&bytes[6..8])?);
2527    let height = u32::from(read_u16_le(&bytes[8..10])?);
2528    let packed = bytes[10];
2529
2530    let mut offset = 13usize;
2531    // Skip the Global Color Table when the flag in the packed field is set. Its entry
2532    // count is 2^(N+1), three bytes per entry.
2533    if packed & 0b1000_0000 != 0 {
2534        let entries = 1usize << ((packed & 0b0000_0111) + 1);
2535        offset = offset.saturating_add(entries * 3);
2536    }
2537
2538    let mut frame_count = 0u32;
2539    let mut has_alpha = false;
2540
2541    while offset < bytes.len() {
2542        match bytes[offset] {
2543            // Trailer.
2544            0x3B => break,
2545            // Extension introducer.
2546            0x21 => {
2547                if offset + 1 >= bytes.len() {
2548                    break;
2549                }
2550                let label = bytes[offset + 1];
2551                let mut cursor = offset + 2;
2552                // A Graphic Control Extension declares transparency in bit 0 of the
2553                // packed field, which is the first byte of its single data sub-block.
2554                if label == 0xF9
2555                    && cursor + 2 < bytes.len()
2556                    && bytes[cursor] >= 1
2557                    && bytes[cursor + 1] & 0b0000_0001 != 0
2558                {
2559                    has_alpha = true;
2560                }
2561                cursor = skip_gif_sub_blocks(bytes, cursor)?;
2562                offset = cursor;
2563            }
2564            // Image descriptor: 10 bytes, then an optional Local Color Table, then the
2565            // LZW minimum code size byte, then the image data sub-blocks.
2566            0x2C => {
2567                frame_count = frame_count.saturating_add(1);
2568                if offset + 10 > bytes.len() {
2569                    break;
2570                }
2571                let local_packed = bytes[offset + 9];
2572                let mut cursor = offset + 10;
2573                if local_packed & 0b1000_0000 != 0 {
2574                    let entries = 1usize << ((local_packed & 0b0000_0111) + 1);
2575                    cursor = cursor.saturating_add(entries * 3);
2576                }
2577                // LZW minimum code size.
2578                cursor = cursor.saturating_add(1);
2579                cursor = skip_gif_sub_blocks(bytes, cursor)?;
2580                offset = cursor;
2581            }
2582            other => {
2583                return Err(TransformError::DecodeFailed(format!(
2584                    "gif file has an unknown block introducer 0x{other:02x}"
2585                )));
2586            }
2587        }
2588    }
2589
2590    if frame_count == 0 {
2591        return Err(TransformError::DecodeFailed(
2592            "gif file contains no image data".to_string(),
2593        ));
2594    }
2595
2596    Ok(ArtifactMetadata {
2597        width: Some(width),
2598        height: Some(height),
2599        frame_count,
2600        duration: None,
2601        has_alpha: Some(has_alpha),
2602        orientation: None,
2603    })
2604}
2605
2606/// Advances past a GIF sub-block chain, returning the offset just after its terminator.
2607///
2608/// A chain is a run of `[length: u8][length bytes]` records ending in a zero-length record.
2609/// Running off the end means the file is truncated, which is a decode failure rather than a
2610/// silently short read.
2611fn skip_gif_sub_blocks(bytes: &[u8], mut offset: usize) -> Result<usize, TransformError> {
2612    loop {
2613        if offset >= bytes.len() {
2614            return Err(TransformError::DecodeFailed(
2615                "gif file ends inside a data block".to_string(),
2616            ));
2617        }
2618        let len = bytes[offset] as usize;
2619        offset += 1;
2620        if len == 0 {
2621            return Ok(offset);
2622        }
2623        offset = offset.saturating_add(len);
2624    }
2625}
2626
2627/// Extracts TIFF metadata by decoding the image header via the `image` crate.
2628fn sniff_tiff(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
2629    let cursor = std::io::Cursor::new(bytes);
2630    let decoder = image::codecs::tiff::TiffDecoder::new(cursor)
2631        .map_err(|e| TransformError::DecodeFailed(format!("tiff decode: {e}")))?;
2632    let (width, height) = image::ImageDecoder::dimensions(&decoder);
2633    let color = image::ImageDecoder::color_type(&decoder);
2634    let has_alpha = matches!(
2635        color,
2636        image::ColorType::La8
2637            | image::ColorType::Rgba8
2638            | image::ColorType::La16
2639            | image::ColorType::Rgba16
2640            | image::ColorType::Rgba32F
2641    );
2642    Ok(ArtifactMetadata {
2643        width: Some(width),
2644        height: Some(height),
2645        frame_count: 1,
2646        duration: None,
2647        has_alpha: Some(has_alpha),
2648        orientation: exif_orientation(MediaType::Tiff, bytes),
2649    })
2650}
2651
2652fn sniff_png(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
2653    if bytes.len() < 29 {
2654        return Err(TransformError::DecodeFailed(
2655            "png file is too short".to_string(),
2656        ));
2657    }
2658
2659    if &bytes[12..16] != b"IHDR" {
2660        return Err(TransformError::DecodeFailed(
2661            "png file is missing an IHDR chunk".to_string(),
2662        ));
2663    }
2664
2665    let width = read_u32_be(&bytes[16..20])?;
2666    let height = read_u32_be(&bytes[20..24])?;
2667    let color_type = bytes[25];
2668    let ancillary = png_ancillary_facts(bytes);
2669    let has_alpha = match color_type {
2670        // These two carry an alpha channel, and `tRNS` is not allowed alongside one.
2671        4 | 6 => Some(true),
2672        // These three have no alpha channel and may still be transparent: the
2673        // specification puts that transparency in a `tRNS` chunk, as a transparent grey
2674        // value, a transparent colour, or a per-entry palette alpha table. Reading only
2675        // IHDR calls a transparent palette PNG opaque while the same picture as a GIF,
2676        // whose sniffer walks the blocks, is called transparent.
2677        0 | 2 | 3 => Some(ancillary.has_trns),
2678        _ => None,
2679    };
2680
2681    Ok(ArtifactMetadata {
2682        width: Some(width),
2683        height: Some(height),
2684        frame_count: ancillary.frame_count,
2685        duration: None,
2686        has_alpha,
2687        orientation: exif_orientation(MediaType::Png, bytes),
2688    })
2689}
2690
2691/// What the chunks after IHDR say about transparency and animation.
2692struct PngAncillaryFacts {
2693    has_trns: bool,
2694    frame_count: u32,
2695}
2696
2697/// Walks the chunk list for the two facts IHDR does not carry.
2698///
2699/// Both chunks are required to precede the image data, so the walk stops at the first
2700/// `IDAT` and a file carrying neither pays for a few chunk headers. A length that runs off
2701/// the end ends the walk with whatever was read: this reports facts about a file the
2702/// decoder has not seen yet, and a malformed chunk list is the decoder's to refuse.
2703fn png_ancillary_facts(bytes: &[u8]) -> PngAncillaryFacts {
2704    let mut facts = PngAncillaryFacts {
2705        has_trns: false,
2706        frame_count: 1,
2707    };
2708
2709    // Past the 8-byte signature and the IHDR chunk, whose length is fixed at 13.
2710    let mut offset = 8 + 12 + 13;
2711    while offset + 8 <= bytes.len() {
2712        let Ok(length) = read_u32_be(&bytes[offset..offset + 4]) else {
2713            break;
2714        };
2715        let chunk_type = &bytes[offset + 4..offset + 8];
2716        match chunk_type {
2717            b"IDAT" | b"IEND" => break,
2718            b"tRNS" => facts.has_trns = true,
2719            // An APNG announces its frame count here, before the image data.
2720            b"acTL" if length >= 4 => {
2721                if let Ok(frames) = read_u32_be(&bytes[offset + 8..offset + 12]) {
2722                    facts.frame_count = frames.max(1);
2723                }
2724            }
2725            _ => {}
2726        }
2727        let Some(next) = offset
2728            .checked_add(12)
2729            .and_then(|next| next.checked_add(length as usize))
2730        else {
2731            break;
2732        };
2733        offset = next;
2734    }
2735
2736    facts
2737}
2738
2739/// Reads the EXIF Orientation tag out of any container that can carry one.
2740///
2741/// The tag says how the stored pixels are meant to be displayed, so a reader that honours
2742/// it in one container and not the next makes the container a photo happens to arrive in
2743/// decide whether the picture comes out upright. Browsers honour it in JPEG, PNG, WebP, and
2744/// TIFF alike, and honour the AVIF properties that mean the same, so truss reads all five
2745/// through here, and the sniffers and the transform pipeline both go through this function
2746/// so what `inspect` reports and what `convert` applies cannot drift.
2747///
2748/// A file with no EXIF block, no Orientation field, or an unreadable one reports `None`,
2749/// which means no transform. Each container is located by walking its headers rather than
2750/// by decoding it, which is what keeps the common file — the one carrying no metadata at
2751/// all — from paying for a container scan on every `sniff_artifact` call.
2752///
2753/// BMP and GIF have nowhere to put the tag. AVIF signals the same transform without an Exif
2754/// field, as `irot` and `imir` item properties, and [`avif_orientation`] folds those into
2755/// the same eight values, so a caller reads one number whatever the container.
2756pub(crate) fn exif_orientation(media_type: MediaType, bytes: &[u8]) -> Option<u16> {
2757    let payload = match media_type {
2758        MediaType::Jpeg => jpeg_exif_payload(bytes)?,
2759        MediaType::Png => png_exif_payload(bytes)?,
2760        MediaType::Webp => webp_exif_payload(bytes)?,
2761        // A TIFF file is an Exif block from byte zero, so it needs no locating.
2762        MediaType::Tiff => return tiff_orientation(bytes),
2763        MediaType::Avif => return avif_orientation(bytes),
2764        MediaType::Bmp | MediaType::Gif | MediaType::Svg => return None,
2765    };
2766    exif_orientation_from_payload(payload)
2767}
2768
2769/// Returns the contents of a PNG `eXIf` chunk.
2770///
2771/// Only the chunk headers are walked, so no compressed data is touched: `sniff_artifact`
2772/// runs on every server upload and this runs with it. The chunk holds the Exif block
2773/// directly, but writers that carry the JPEG APP1 prefix over into it are common enough to
2774/// be worth stripping.
2775fn png_exif_payload(bytes: &[u8]) -> Option<&[u8]> {
2776    // Past the 8-byte signature; a shorter input never sniffed as a PNG.
2777    let mut offset = 8usize;
2778    while offset + 8 <= bytes.len() {
2779        let length = usize::try_from(read_u32_be(bytes.get(offset..offset + 4)?).ok()?).ok()?;
2780        let chunk_type = bytes.get(offset + 4..offset + 8)?;
2781        let start = offset + 8;
2782        let end = start.checked_add(length)?;
2783        if end > bytes.len() {
2784            return None;
2785        }
2786        if chunk_type == b"eXIf" {
2787            return Some(strip_exif_prefix(bytes.get(start..end)?));
2788        }
2789        if chunk_type == b"IEND" {
2790            return None;
2791        }
2792        // Past the payload and its CRC.
2793        offset = end.checked_add(4)?;
2794    }
2795    None
2796}
2797
2798/// Returns the contents of a WebP `EXIF` chunk.
2799///
2800/// The chunk sits after the image data in an extended container, which is why this walks
2801/// the file rather than reusing the loop in [`sniff_webp`]: that one stops at the first
2802/// image chunk, which is what makes it cheap for the common file with no metadata at all.
2803fn webp_exif_payload(bytes: &[u8]) -> Option<&[u8]> {
2804    // Past "RIFF", the file size, and "WEBP".
2805    let mut offset = 12usize;
2806    while offset + 8 <= bytes.len() {
2807        let chunk_tag = bytes.get(offset..offset + 4)?;
2808        let size = usize::try_from(read_u32_le(bytes.get(offset + 4..offset + 8)?).ok()?).ok()?;
2809        let start = offset + 8;
2810        let end = start.checked_add(size)?;
2811        if end > bytes.len() {
2812            return None;
2813        }
2814        if chunk_tag == b"EXIF" {
2815            return Some(strip_exif_prefix(bytes.get(start..end)?));
2816        }
2817        // RIFF chunks are padded to an even length.
2818        offset = end.checked_add(size % 2)?;
2819    }
2820    None
2821}
2822
2823/// Drops the JPEG APP1 marker prefix when a writer has carried it into another container.
2824fn strip_exif_prefix(payload: &[u8]) -> &[u8] {
2825    payload
2826        .strip_prefix(b"Exif\0\0".as_slice())
2827        .unwrap_or(payload)
2828}
2829
2830/// Reads the Orientation tag out of a bare TIFF header.
2831///
2832/// The entries of the first IFD are walked rather than the file being handed to the exif
2833/// crate, which reads from an owned buffer and would copy the whole image to reach twelve
2834/// bytes of it.
2835fn tiff_orientation(bytes: &[u8]) -> Option<u16> {
2836    let little_endian = match bytes.get(0..2)? {
2837        b"II" => true,
2838        b"MM" => false,
2839        _ => return None,
2840    };
2841
2842    let read_u16 = |offset: usize| -> Option<u16> {
2843        let raw: [u8; 2] = bytes.get(offset..offset + 2)?.try_into().ok()?;
2844        Some(if little_endian {
2845            u16::from_le_bytes(raw)
2846        } else {
2847            u16::from_be_bytes(raw)
2848        })
2849    };
2850    let read_u32 = |offset: usize| -> Option<u32> {
2851        let raw: [u8; 4] = bytes.get(offset..offset + 4)?.try_into().ok()?;
2852        Some(if little_endian {
2853            u32::from_le_bytes(raw)
2854        } else {
2855            u32::from_be_bytes(raw)
2856        })
2857    };
2858
2859    const ORIENTATION_TAG: u16 = 0x0112;
2860    const TYPE_SHORT: u16 = 3;
2861    const TYPE_LONG: u16 = 4;
2862
2863    let ifd = usize::try_from(read_u32(4)?).ok()?;
2864    let entry_count = usize::from(read_u16(ifd)?);
2865    for index in 0..entry_count {
2866        let entry = ifd.checked_add(2)?.checked_add(index.checked_mul(12)?)?;
2867        if read_u16(entry)? != ORIENTATION_TAG {
2868            continue;
2869        }
2870        // A value short enough to fit is left-justified in the value field under either
2871        // byte order, so both widths are read from the same offset.
2872        return match read_u16(entry + 2)? {
2873            TYPE_SHORT => read_u16(entry + 8),
2874            TYPE_LONG => u16::try_from(read_u32(entry + 8)?).ok(),
2875            _ => None,
2876        };
2877    }
2878    None
2879}
2880
2881/// Reads the Orientation tag out of an already-located Exif TIFF block.
2882fn exif_orientation_from_payload(payload: &[u8]) -> Option<u16> {
2883    let exif = exif::Reader::new().read_raw(payload.to_vec()).ok()?;
2884    let field = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?;
2885    match &field.value {
2886        exif::Value::Short(values) => values.first().copied(),
2887        exif::Value::Long(values) => values.first().and_then(|value| u16::try_from(*value).ok()),
2888        _ => None,
2889    }
2890}
2891
2892/// Returns the TIFF block of a JPEG's Exif APP1 segment, reading only segment headers.
2893fn jpeg_exif_payload(bytes: &[u8]) -> Option<&[u8]> {
2894    const EXIF_PREFIX: &[u8] = b"Exif\0\0";
2895    const APP1: u8 = 0xE1;
2896
2897    let mut offset = 2;
2898    while offset + 1 < bytes.len() {
2899        if bytes[offset] != 0xFF {
2900            return None;
2901        }
2902        while offset < bytes.len() && bytes[offset] == 0xFF {
2903            offset += 1;
2904        }
2905
2906        let marker = *bytes.get(offset)?;
2907        offset += 1;
2908
2909        // Start of scan or end of image: no metadata segment follows.
2910        if marker == 0xD9 || marker == 0xDA {
2911            return None;
2912        }
2913        // Standalone markers carry no length field.
2914        if (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
2915            continue;
2916        }
2917
2918        let length = read_u16_be(bytes.get(offset..offset + 2)?).ok()? as usize;
2919        if length < 2 || offset + length > bytes.len() {
2920            return None;
2921        }
2922        if marker == APP1
2923            && let Some(payload) = bytes[offset + 2..offset + length].strip_prefix(EXIF_PREFIX)
2924        {
2925            return Some(payload);
2926        }
2927        offset += length;
2928    }
2929
2930    None
2931}
2932
2933fn sniff_jpeg(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
2934    let mut offset = 2;
2935    // Captured on the way past rather than by a second walk: the Exif APP1 segment always
2936    // precedes the SOF this loop is looking for.
2937    let mut exif_payload: Option<&[u8]> = None;
2938
2939    while offset + 1 < bytes.len() {
2940        if bytes[offset] != 0xFF {
2941            return Err(TransformError::DecodeFailed(
2942                "jpeg file has an invalid marker prefix".to_string(),
2943            ));
2944        }
2945
2946        while offset < bytes.len() && bytes[offset] == 0xFF {
2947            offset += 1;
2948        }
2949
2950        if offset >= bytes.len() {
2951            break;
2952        }
2953
2954        let marker = bytes[offset];
2955        offset += 1;
2956
2957        if marker == 0xD9 || marker == 0xDA {
2958            break;
2959        }
2960
2961        if (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
2962            continue;
2963        }
2964
2965        if offset + 2 > bytes.len() {
2966            return Err(TransformError::DecodeFailed(
2967                "jpeg segment is truncated".to_string(),
2968            ));
2969        }
2970
2971        let segment_length = read_u16_be(&bytes[offset..offset + 2])? as usize;
2972        if segment_length < 2 || offset + segment_length > bytes.len() {
2973            return Err(TransformError::DecodeFailed(
2974                "jpeg segment length is invalid".to_string(),
2975            ));
2976        }
2977
2978        if marker == 0xE1
2979            && exif_payload.is_none()
2980            && let Some(payload) =
2981                bytes[offset + 2..offset + segment_length].strip_prefix(b"Exif\0\0".as_slice())
2982        {
2983            exif_payload = Some(payload);
2984        }
2985
2986        if is_jpeg_sof_marker(marker) {
2987            if segment_length < 7 {
2988                return Err(TransformError::DecodeFailed(
2989                    "jpeg SOF segment is too short".to_string(),
2990                ));
2991            }
2992
2993            let height = read_u16_be(&bytes[offset + 3..offset + 5])? as u32;
2994            let width = read_u16_be(&bytes[offset + 5..offset + 7])? as u32;
2995
2996            return Ok(ArtifactMetadata {
2997                width: Some(width),
2998                height: Some(height),
2999                frame_count: 1,
3000                duration: None,
3001                has_alpha: Some(false),
3002                orientation: exif_payload.and_then(exif_orientation_from_payload),
3003            });
3004        }
3005
3006        offset += segment_length;
3007    }
3008
3009    Err(TransformError::DecodeFailed(
3010        "jpeg file is missing a SOF segment".to_string(),
3011    ))
3012}
3013
3014fn sniff_webp(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
3015    let mut offset = 12;
3016
3017    while offset + 8 <= bytes.len() {
3018        let chunk_tag = &bytes[offset..offset + 4];
3019        let chunk_size = read_u32_le(&bytes[offset + 4..offset + 8])? as usize;
3020        let chunk_start = offset + 8;
3021        let chunk_end = chunk_start
3022            .checked_add(chunk_size)
3023            .ok_or_else(|| TransformError::DecodeFailed("webp chunk is too large".to_string()))?;
3024
3025        if chunk_end > bytes.len() {
3026            return Err(TransformError::DecodeFailed(
3027                "webp chunk exceeds file length".to_string(),
3028            ));
3029        }
3030
3031        let chunk_data = &bytes[chunk_start..chunk_end];
3032
3033        let mut metadata = match chunk_tag {
3034            b"VP8X" => sniff_webp_vp8x(chunk_data)?,
3035            b"VP8 " => sniff_webp_vp8(chunk_data)?,
3036            b"VP8L" => sniff_webp_vp8l(chunk_data)?,
3037            _ => {
3038                offset = chunk_end + (chunk_size % 2);
3039                continue;
3040            }
3041        };
3042
3043        // The EXIF chunk follows the image data, so it is read from the whole file rather
3044        // than from the chunk this loop stopped at. The frames are counted the same way,
3045        // since `ANMF` chunks also follow the header this loop stopped at.
3046        metadata.orientation = exif_orientation(MediaType::Webp, bytes);
3047        if metadata.frame_count > 1 {
3048            metadata.frame_count = count_webp_frames(bytes).max(2);
3049        }
3050        return Ok(metadata);
3051    }
3052
3053    Err(TransformError::DecodeFailed(
3054        "webp file is missing an image chunk".to_string(),
3055    ))
3056}
3057
3058/// Counts the `ANMF` chunks of an animated WebP, each of which holds one frame.
3059fn count_webp_frames(bytes: &[u8]) -> u32 {
3060    let mut frames = 0_u32;
3061    let mut offset = 12;
3062    while offset + 8 <= bytes.len() {
3063        let Ok(size) = read_u32_le(&bytes[offset + 4..offset + 8]) else {
3064            break;
3065        };
3066        if &bytes[offset..offset + 4] == b"ANMF" {
3067            frames = frames.saturating_add(1);
3068        }
3069        let size = size as usize;
3070        let Some(next) = offset
3071            .checked_add(8)
3072            .and_then(|next| next.checked_add(size))
3073            .and_then(|next| next.checked_add(size % 2))
3074        else {
3075            break;
3076        };
3077        offset = next;
3078    }
3079    frames
3080}
3081
3082fn sniff_webp_vp8x(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
3083    if bytes.len() < 10 {
3084        return Err(TransformError::DecodeFailed(
3085            "webp VP8X chunk is too short".to_string(),
3086        ));
3087    }
3088
3089    let flags = bytes[0];
3090    let width = read_u24_le(&bytes[4..7])? + 1;
3091    let height = read_u24_le(&bytes[7..10])? + 1;
3092    let has_alpha = Some(flags & VP8X_ALPHA_FLAG != 0);
3093    // The frames themselves are counted by the caller, which has the whole file; this
3094    // records only that there is more than one of them, which is what the flag states.
3095    let frame_count = u32::from(flags & VP8X_ANIMATION_FLAG != 0) + 1;
3096
3097    Ok(ArtifactMetadata {
3098        width: Some(width),
3099        height: Some(height),
3100        frame_count,
3101        duration: None,
3102        has_alpha,
3103        orientation: None,
3104    })
3105}
3106
3107/// The VP8X feature flags this sniffer reads, in the bit positions the container gives them.
3108const VP8X_ALPHA_FLAG: u8 = 0b0001_0000;
3109const VP8X_ANIMATION_FLAG: u8 = 0b0000_0010;
3110
3111fn sniff_webp_vp8(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
3112    if bytes.len() < 10 {
3113        return Err(TransformError::DecodeFailed(
3114            "webp VP8 chunk is too short".to_string(),
3115        ));
3116    }
3117
3118    if bytes[3..6] != [0x9D, 0x01, 0x2A] {
3119        return Err(TransformError::DecodeFailed(
3120            "webp VP8 chunk has an invalid start code".to_string(),
3121        ));
3122    }
3123
3124    let width = (read_u16_le(&bytes[6..8])? & 0x3FFF) as u32;
3125    let height = (read_u16_le(&bytes[8..10])? & 0x3FFF) as u32;
3126
3127    Ok(ArtifactMetadata {
3128        width: Some(width),
3129        height: Some(height),
3130        frame_count: 1,
3131        duration: None,
3132        has_alpha: Some(false),
3133        orientation: None,
3134    })
3135}
3136
3137fn sniff_webp_vp8l(bytes: &[u8]) -> Result<ArtifactMetadata, TransformError> {
3138    if bytes.len() < 5 {
3139        return Err(TransformError::DecodeFailed(
3140            "webp VP8L chunk is too short".to_string(),
3141        ));
3142    }
3143
3144    if bytes[0] != 0x2F {
3145        return Err(TransformError::DecodeFailed(
3146            "webp VP8L chunk has an invalid signature".to_string(),
3147        ));
3148    }
3149
3150    // VP8L header bits, LSB first: 14 bits width-1, 14 bits height-1, 1 bit alpha_is_used,
3151    // 3 bits version.
3152    let bits = read_u32_le(&bytes[1..5])?;
3153    let width = (bits & 0x3FFF) + 1;
3154    let height = ((bits >> 14) & 0x3FFF) + 1;
3155    let has_alpha = (bits >> 28) & 1 != 0;
3156
3157    Ok(ArtifactMetadata {
3158        width: Some(width),
3159        height: Some(height),
3160        frame_count: 1,
3161        duration: None,
3162        has_alpha: Some(has_alpha),
3163        orientation: None,
3164    })
3165}
3166
3167fn is_jpeg_sof_marker(marker: u8) -> bool {
3168    matches!(
3169        marker,
3170        0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF
3171    )
3172}
3173
3174fn read_u16_be(bytes: &[u8]) -> Result<u16, TransformError> {
3175    let array: [u8; 2] = bytes
3176        .try_into()
3177        .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
3178    Ok(u16::from_be_bytes(array))
3179}
3180
3181fn read_u16_le(bytes: &[u8]) -> Result<u16, TransformError> {
3182    let array: [u8; 2] = bytes
3183        .try_into()
3184        .map_err(|_| TransformError::DecodeFailed("expected 2 bytes".to_string()))?;
3185    Ok(u16::from_le_bytes(array))
3186}
3187
3188fn read_u24_le(bytes: &[u8]) -> Result<u32, TransformError> {
3189    if bytes.len() != 3 {
3190        return Err(TransformError::DecodeFailed("expected 3 bytes".to_string()));
3191    }
3192
3193    Ok(u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16))
3194}
3195
3196fn read_u32_be(bytes: &[u8]) -> Result<u32, TransformError> {
3197    let array: [u8; 4] = bytes
3198        .try_into()
3199        .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
3200    Ok(u32::from_be_bytes(array))
3201}
3202
3203fn read_u32_le(bytes: &[u8]) -> Result<u32, TransformError> {
3204    let array: [u8; 4] = bytes
3205        .try_into()
3206        .map_err(|_| TransformError::DecodeFailed("expected 4 bytes".to_string()))?;
3207    Ok(u32::from_le_bytes(array))
3208}
3209
3210fn read_u64_be(bytes: &[u8]) -> Result<u64, TransformError> {
3211    let array: [u8; 8] = bytes
3212        .try_into()
3213        .map_err(|_| TransformError::DecodeFailed("expected 8 bytes".to_string()))?;
3214    Ok(u64::from_be_bytes(array))
3215}
3216
3217#[cfg(test)]
3218mod tests {
3219    /// The two input caps are the numbers `docs/openapi.yaml` publishes, so a change to
3220    /// either is a change to the document. These were doctests on the constants before the
3221    /// pair stopped being public; the assertions are the same.
3222    #[test]
3223    fn the_input_pixel_caps_are_the_documented_numbers() {
3224        assert_eq!(super::MAX_DECODED_PIXELS, 100_000_000);
3225        assert_eq!(super::MAX_WATERMARK_PIXELS, 4_000_000);
3226    }
3227
3228    /// The three flag names resolve to one `(strip_metadata, preserve_exif)` pair, which is
3229    /// what makes the four adapters agree. This was a doctest on `resolve_metadata_flags`
3230    /// before that function stopped being public; the assertions are the same.
3231    #[test]
3232    fn metadata_flag_resolution() {
3233        use super::resolve_metadata_flags;
3234
3235        // Default: strip all metadata.
3236        let (strip, exif) = resolve_metadata_flags(None, None, None).unwrap();
3237        assert!(strip);
3238        assert!(!exif);
3239
3240        // Explicit keep.
3241        let (strip, exif) = resolve_metadata_flags(None, Some(true), None).unwrap();
3242        assert!(!strip);
3243        assert!(!exif);
3244
3245        // Preserve EXIF only.
3246        let (strip, exif) = resolve_metadata_flags(None, None, Some(true)).unwrap();
3247        assert!(!strip);
3248        assert!(exif);
3249
3250        // keep + preserve_exif conflict.
3251        assert!(resolve_metadata_flags(None, Some(true), Some(true)).is_err());
3252    }
3253
3254    /// The three metadata names the adapters carry resolve to one policy, which is what the
3255    /// pipeline reads. This was a doctest on `MetadataPolicy` before that enum stopped being
3256    /// public; the assertions are the same.
3257    ///
3258    /// The struct literal is available here because `#[non_exhaustive]` binds other crates
3259    /// and not the defining one, which is why the mutation form appears in the examples a
3260    /// caller reads and not in the tests beside the definition.
3261    #[test]
3262    fn metadata_policy_resolution() {
3263        let options = TransformOptions::default();
3264        assert!(options.strip_metadata);
3265        assert_eq!(
3266            options.normalize(MediaType::Png).unwrap().metadata_policy,
3267            MetadataPolicy::StripAll
3268        );
3269
3270        let options = TransformOptions {
3271            strip_metadata: false,
3272            ..TransformOptions::default()
3273        };
3274        assert_eq!(
3275            options.normalize(MediaType::Png).unwrap().metadata_policy,
3276            MetadataPolicy::KeepAll
3277        );
3278
3279        let options = TransformOptions {
3280            strip_metadata: false,
3281            preserve_exif: true,
3282            ..TransformOptions::default()
3283        };
3284        assert_eq!(
3285            options.normalize(MediaType::Jpeg).unwrap().metadata_policy,
3286            MetadataPolicy::PreserveExif
3287        );
3288    }
3289
3290    #[cfg(any(feature = "server", feature = "wasm"))]
3291    use super::single_line;
3292    use super::{
3293        Artifact, ArtifactMetadata, Dimensions, Fit, MediaType, MetadataPolicy, OptimizeMode,
3294        Position, QualityMetric, RawArtifact, Rgba8, Rotation, TargetQuality, TransformError,
3295        TransformOptions, TransformRequest, exif_orientation, sniff_artifact,
3296        validate_height_value, validate_quality_value, validate_watermark_opacity_value,
3297        validate_width_value,
3298    };
3299    #[cfg(feature = "avif")]
3300    use image::codecs::avif::AvifEncoder;
3301    use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
3302    use rstest::rstest;
3303
3304    /// One spelling per named value, across every parser that takes one.
3305    ///
3306    /// The vocabulary is what a caller learns once and reuses: `--fit cover` teaches that
3307    /// these values are written as the documentation writes them, and a metric that also
3308    /// took `SSIM` was the one place that lesson did not hold.
3309    #[test]
3310    fn a_named_value_has_one_spelling() {
3311        use std::str::FromStr;
3312
3313        assert!(MediaType::from_str("jpeg").is_ok());
3314        assert!(MediaType::from_str("JPEG").is_err());
3315        assert!(Fit::from_str("cover").is_ok());
3316        assert!(Fit::from_str("COVER").is_err());
3317        assert!(Position::from_str("center").is_ok());
3318        assert!(Position::from_str("CENTER").is_err());
3319        assert!(OptimizeMode::from_str("lossless").is_ok());
3320        assert!(OptimizeMode::from_str("LOSSLESS").is_err());
3321        assert!(TargetQuality::from_str("ssim:0.98").is_ok());
3322        assert_eq!(
3323            TargetQuality::from_str("SSIM:0.98"),
3324            Err("unsupported target quality metric `SSIM`".to_string())
3325        );
3326        assert_eq!(
3327            TargetQuality::from_str("Psnr:42"),
3328            Err("unsupported target quality metric `Psnr`".to_string())
3329        );
3330    }
3331
3332    /// A message that is already one trimmed line is what it was.
3333    #[cfg(any(feature = "server", feature = "wasm"))]
3334    #[test]
3335    fn single_line_leaves_a_line_alone() {
3336        assert_eq!(
3337            single_line("quality must be between 1 and 100"),
3338            "quality must be between 1 and 100"
3339        );
3340        assert_eq!(single_line(""), "");
3341    }
3342
3343    /// The wording a decoder in a dependency produced, which ends with a newline.
3344    #[cfg(any(feature = "server", feature = "wasm"))]
3345    #[test]
3346    fn single_line_folds_a_message_that_leaves_its_line() {
3347        assert_eq!(
3348            single_line("Format error decoding Jpeg: Not enough bytes\n"),
3349            "Format error decoding Jpeg: Not enough bytes"
3350        );
3351        assert_eq!(single_line("first\nsecond"), "first second");
3352        assert_eq!(single_line("first\r\n\tsecond"), "first second");
3353        assert_eq!(single_line("  padded  "), "padded");
3354    }
3355
3356    fn jpeg_artifact() -> Artifact {
3357        Artifact::new(vec![1, 2, 3], MediaType::Jpeg, ArtifactMetadata::default())
3358    }
3359
3360    /// A PNG header and nothing else: eight signature bytes and an IHDR, with no image
3361    /// data. It is what the sniffer reads, and it is deliberately not a decodable file.
3362    fn png_ihdr_bytes(width: u32, height: u32, color_type: u8) -> Vec<u8> {
3363        let mut bytes = Vec::new();
3364        bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
3365        bytes.extend_from_slice(&13_u32.to_be_bytes());
3366        bytes.extend_from_slice(b"IHDR");
3367        bytes.extend_from_slice(&width.to_be_bytes());
3368        bytes.extend_from_slice(&height.to_be_bytes());
3369        bytes.push(8);
3370        bytes.push(color_type);
3371        bytes.push(0);
3372        bytes.push(0);
3373        bytes.push(0);
3374        bytes.extend_from_slice(&0_u32.to_be_bytes());
3375        bytes
3376    }
3377
3378    /// A PNG signature, an IHDR, and whatever chunks the caller wants after it.
3379    ///
3380    /// The sniffers read headers rather than pixels, so a file with no IDAT is enough to
3381    /// describe every fact they report.
3382    fn png_bytes_with_chunks(color_type: u8, chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
3383        let mut bytes = png_ihdr_bytes(8, 8, color_type);
3384        for (chunk_type, data) in chunks {
3385            bytes.extend_from_slice(&(data.len() as u32).to_be_bytes());
3386            bytes.extend_from_slice(*chunk_type);
3387            bytes.extend_from_slice(data);
3388            bytes.extend_from_slice(&0_u32.to_be_bytes());
3389        }
3390        bytes.extend_from_slice(&0_u32.to_be_bytes());
3391        bytes.extend_from_slice(b"IEND");
3392        bytes.extend_from_slice(&0_u32.to_be_bytes());
3393        bytes
3394    }
3395
3396    fn jpeg_bytes(width: u16, height: u16) -> Vec<u8> {
3397        let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
3398        bytes.extend_from_slice(&[0; 14]);
3399        bytes.extend_from_slice(&[
3400            0xFF,
3401            0xC0,
3402            0x00,
3403            0x11,
3404            0x08,
3405            (height >> 8) as u8,
3406            height as u8,
3407            (width >> 8) as u8,
3408            width as u8,
3409            0x03,
3410            0x01,
3411            0x11,
3412            0x00,
3413            0x02,
3414            0x11,
3415            0x00,
3416            0x03,
3417            0x11,
3418            0x00,
3419        ]);
3420        bytes.extend_from_slice(&[0xFF, 0xD9]);
3421        bytes
3422    }
3423
3424    /// Builds a GIF whose block structure is valid, which is all `sniff_gif` walks.
3425    ///
3426    /// The LZW payload is deliberately opaque bytes: the sniffer never decodes image data,
3427    /// so a real compressed stream would only obscure what each test is pinning.
3428    fn gif_bytes(
3429        version: &[u8; 3],
3430        width: u16,
3431        height: u16,
3432        frames: usize,
3433        transparent: bool,
3434    ) -> Vec<u8> {
3435        let mut bytes = Vec::new();
3436        bytes.extend_from_slice(b"GIF");
3437        bytes.extend_from_slice(version);
3438        bytes.extend_from_slice(&width.to_le_bytes());
3439        bytes.extend_from_slice(&height.to_le_bytes());
3440        // Global Color Table present, 2 entries (2^(0+1)).
3441        bytes.push(0b1000_0000);
3442        bytes.push(0); // background color index
3443        bytes.push(0); // pixel aspect ratio
3444        bytes.extend_from_slice(&[0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF]);
3445
3446        for _ in 0..frames {
3447            if transparent {
3448                // Graphic Control Extension with the transparent-color flag set.
3449                bytes.extend_from_slice(&[0x21, 0xF9, 0x04, 0b0000_0001, 0x00, 0x00, 0x00, 0x00]);
3450            }
3451            // Image descriptor: position, size, no local color table.
3452            bytes.push(0x2C);
3453            bytes.extend_from_slice(&0u16.to_le_bytes());
3454            bytes.extend_from_slice(&0u16.to_le_bytes());
3455            bytes.extend_from_slice(&width.to_le_bytes());
3456            bytes.extend_from_slice(&height.to_le_bytes());
3457            bytes.push(0);
3458            // LZW minimum code size, then one data sub-block and the terminator.
3459            bytes.push(0x02);
3460            bytes.extend_from_slice(&[0x02, 0x44, 0x01, 0x00]);
3461        }
3462
3463        bytes.push(0x3B);
3464        bytes
3465    }
3466
3467    fn webp_vp8x_bytes(width: u32, height: u32, flags: u8) -> Vec<u8> {
3468        let width_minus_one = width - 1;
3469        let height_minus_one = height - 1;
3470        let mut bytes = Vec::new();
3471        bytes.extend_from_slice(b"RIFF");
3472        bytes.extend_from_slice(&30_u32.to_le_bytes());
3473        bytes.extend_from_slice(b"WEBP");
3474        bytes.extend_from_slice(b"VP8X");
3475        bytes.extend_from_slice(&10_u32.to_le_bytes());
3476        bytes.push(flags);
3477        bytes.extend_from_slice(&[0, 0, 0]);
3478        bytes.extend_from_slice(&[
3479            (width_minus_one & 0xFF) as u8,
3480            ((width_minus_one >> 8) & 0xFF) as u8,
3481            ((width_minus_one >> 16) & 0xFF) as u8,
3482        ]);
3483        bytes.extend_from_slice(&[
3484            (height_minus_one & 0xFF) as u8,
3485            ((height_minus_one >> 8) & 0xFF) as u8,
3486            ((height_minus_one >> 16) & 0xFF) as u8,
3487        ]);
3488        bytes
3489    }
3490
3491    fn webp_vp8l_bytes(width: u32, height: u32) -> Vec<u8> {
3492        webp_vp8l_bytes_with_alpha(width, height, false)
3493    }
3494
3495    fn webp_vp8l_bytes_with_alpha(width: u32, height: u32, alpha_is_used: bool) -> Vec<u8> {
3496        let packed = (width - 1) | ((height - 1) << 14) | (u32::from(alpha_is_used) << 28);
3497        let mut bytes = Vec::new();
3498        bytes.extend_from_slice(b"RIFF");
3499        bytes.extend_from_slice(&17_u32.to_le_bytes());
3500        bytes.extend_from_slice(b"WEBP");
3501        bytes.extend_from_slice(b"VP8L");
3502        bytes.extend_from_slice(&5_u32.to_le_bytes());
3503        bytes.push(0x2F);
3504        bytes.extend_from_slice(&packed.to_le_bytes());
3505        bytes.push(0);
3506        bytes
3507    }
3508
3509    fn avif_bytes() -> Vec<u8> {
3510        let mut bytes = Vec::new();
3511        bytes.extend_from_slice(&24_u32.to_be_bytes());
3512        bytes.extend_from_slice(b"ftyp");
3513        bytes.extend_from_slice(b"avif");
3514        bytes.extend_from_slice(&0_u32.to_be_bytes());
3515        bytes.extend_from_slice(b"mif1");
3516        bytes.extend_from_slice(b"avif");
3517        bytes
3518    }
3519
3520    #[cfg(feature = "avif")]
3521    fn encoded_avif_bytes(width: u32, height: u32, fill: Rgba<u8>) -> Vec<u8> {
3522        let image = RgbaImage::from_pixel(width, height, fill);
3523        let mut bytes = Vec::new();
3524        AvifEncoder::new(&mut bytes)
3525            .write_image(&image, width, height, ColorType::Rgba8.into())
3526            .expect("encode avif");
3527        bytes
3528    }
3529
3530    #[test]
3531    fn default_transform_options_match_documented_defaults() {
3532        let options = TransformOptions::default();
3533
3534        assert_eq!(options.width, None);
3535        assert_eq!(options.height, None);
3536        assert_eq!(options.fit, None);
3537        assert_eq!(options.position, None);
3538        assert_eq!(options.format, None);
3539        assert_eq!(options.quality, None);
3540        assert_eq!(options.rotate, Rotation::DEG_0);
3541        assert!(options.auto_orient);
3542        assert!(options.strip_metadata);
3543        assert!(!options.preserve_exif);
3544    }
3545
3546    #[test]
3547    fn media_type_helpers_report_expected_values() {
3548        assert_eq!(MediaType::Jpeg.as_name(), "jpeg");
3549        assert_eq!(MediaType::Jpeg.as_mime(), "image/jpeg");
3550        assert!(MediaType::Webp.is_lossy());
3551        assert!(!MediaType::Png.is_lossy());
3552    }
3553
3554    #[test]
3555    fn media_type_parsing_accepts_documented_names() {
3556        assert_eq!("jpeg".parse::<MediaType>(), Ok(MediaType::Jpeg));
3557        assert_eq!("jpg".parse::<MediaType>(), Ok(MediaType::Jpeg));
3558        assert_eq!("png".parse::<MediaType>(), Ok(MediaType::Png));
3559        // `gif` parses: it is a supported input. Whether it may be used as an output is a
3560        // separate question, answered by `is_encodable`.
3561        assert_eq!("gif".parse::<MediaType>(), Ok(MediaType::Gif));
3562        assert!("heic".parse::<MediaType>().is_err());
3563    }
3564
3565    #[test]
3566    fn fit_position_rotation_and_color_parsing_work() {
3567        assert_eq!("cover".parse::<Fit>(), Ok(Fit::Cover));
3568        assert_eq!(
3569            "bottom-right".parse::<Position>(),
3570            Ok(Position::BottomRight)
3571        );
3572        assert_eq!("270".parse::<Rotation>(), Ok(Rotation::DEG_270));
3573        assert_eq!(
3574            Rgba8::from_hex("AABBCCDD"),
3575            Ok(Rgba8 {
3576                r: 0xAA,
3577                g: 0xBB,
3578                b: 0xCC,
3579                a: 0xDD
3580            })
3581        );
3582        assert!(Rgba8::from_hex("AABB").is_err());
3583
3584        // Non-ASCII input must not panic (even if byte length happens to be 6 or 8).
3585        assert!(Rgba8::from_hex("\u{00e9}\u{00e9}\u{00e9}").is_err());
3586        assert!(Rgba8::from_hex("\u{1f600}\u{1f600}").is_err());
3587    }
3588
3589    #[test]
3590    fn normalize_defaults_fit_and_position_for_bounded_resize() {
3591        let normalized = TransformOptions {
3592            width: Some(1200),
3593            height: Some(630),
3594            ..TransformOptions::default()
3595        }
3596        .normalize(MediaType::Jpeg)
3597        .expect("normalize bounded resize");
3598
3599        assert_eq!(normalized.fit, Some(Fit::Contain));
3600        assert_eq!(normalized.position, Position::Center);
3601        assert_eq!(normalized.format, MediaType::Jpeg);
3602        assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
3603    }
3604
3605    #[test]
3606    fn normalize_uses_requested_fit_and_output_format() {
3607        let normalized = TransformOptions {
3608            width: Some(320),
3609            height: Some(320),
3610            fit: Some(Fit::Cover),
3611            position: Some(Position::BottomRight),
3612            format: Some(MediaType::Webp),
3613            quality: Some(70),
3614            strip_metadata: false,
3615            preserve_exif: true,
3616            ..TransformOptions::default()
3617        }
3618        .normalize(MediaType::Jpeg)
3619        .expect("normalize explicit values");
3620
3621        assert_eq!(normalized.fit, Some(Fit::Cover));
3622        assert_eq!(normalized.position, Position::BottomRight);
3623        assert_eq!(normalized.format, MediaType::Webp);
3624        assert_eq!(normalized.quality, Some(70));
3625        assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveExif);
3626    }
3627
3628    #[test]
3629    fn normalize_can_keep_all_metadata() {
3630        let normalized = TransformOptions {
3631            strip_metadata: false,
3632            ..TransformOptions::default()
3633        }
3634        .normalize(MediaType::Jpeg)
3635        .expect("normalize keep metadata");
3636
3637        assert_eq!(normalized.metadata_policy, MetadataPolicy::KeepAll);
3638    }
3639
3640    #[test]
3641    fn normalize_lossy_optimize_preserves_icc_by_default() {
3642        let normalized = TransformOptions {
3643            optimize: OptimizeMode::Lossy,
3644            format: Some(MediaType::Jpeg),
3645            ..TransformOptions::default()
3646        }
3647        .normalize(MediaType::Jpeg)
3648        .expect("normalize lossy optimize metadata policy");
3649
3650        assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveIcc);
3651    }
3652
3653    /// Every optimization mode re-encodes, and a profile dropped by any of them renders
3654    /// the picture in the wrong colors. Only `none`, which is what `truss convert` does
3655    /// when nobody asks for an optimization, strips the way the flag says.
3656    #[rstest]
3657    #[case::none(OptimizeMode::None, MetadataPolicy::StripAll)]
3658    #[case::auto(OptimizeMode::Auto, MetadataPolicy::PreserveIcc)]
3659    #[case::lossless(OptimizeMode::Lossless, MetadataPolicy::PreserveIcc)]
3660    #[case::lossy(OptimizeMode::Lossy, MetadataPolicy::PreserveIcc)]
3661    fn an_optimization_keeps_the_profile_a_plain_encode_strips(
3662        #[case] optimize: OptimizeMode,
3663        #[case] expected: MetadataPolicy,
3664    ) {
3665        let normalized = TransformOptions {
3666            optimize,
3667            strip_metadata: true,
3668            format: Some(MediaType::Jpeg),
3669            ..TransformOptions::default()
3670        }
3671        .normalize(MediaType::Jpeg)
3672        .expect("normalize the metadata policy");
3673
3674        assert_eq!(normalized.metadata_policy, expected);
3675    }
3676
3677    /// A format that cannot carry a profile has nothing to preserve, and asking for it
3678    /// there is what made `--strip-metadata` fail outright before the upgrade was limited
3679    /// to formats that can take one. AVIF is the only such format an optimization mode
3680    /// reaches: TIFF and BMP refuse the mode itself.
3681    #[test]
3682    fn an_optimization_strips_for_a_format_that_carries_no_profile() {
3683        let normalized = TransformOptions {
3684            optimize: OptimizeMode::Auto,
3685            strip_metadata: true,
3686            format: Some(MediaType::Avif),
3687            ..TransformOptions::default()
3688        }
3689        .normalize(MediaType::Jpeg)
3690        .expect("normalize the metadata policy");
3691
3692        assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
3693    }
3694
3695    #[test]
3696    fn normalize_lossy_optimize_preserves_icc_for_webp_output() {
3697        let normalized = TransformOptions {
3698            optimize: OptimizeMode::Lossy,
3699            format: Some(MediaType::Webp),
3700            strip_metadata: true,
3701            ..TransformOptions::default()
3702        }
3703        .normalize(MediaType::Jpeg)
3704        .expect("normalize lossy webp metadata policy");
3705
3706        assert_eq!(normalized.metadata_policy, MetadataPolicy::PreserveIcc);
3707    }
3708
3709    // Regression test for https://github.com/nao1215/truss/issues/279: the ICC upgrade must
3710    // not apply to a format that cannot carry a profile, or `--strip-metadata` puts the
3711    // pipeline into a state the encoder rejects.
3712    #[test]
3713    fn normalize_lossy_optimize_strips_all_for_a_format_without_icc_support() {
3714        let normalized = TransformOptions {
3715            optimize: OptimizeMode::Lossy,
3716            format: Some(MediaType::Avif),
3717            strip_metadata: true,
3718            ..TransformOptions::default()
3719        }
3720        .normalize(MediaType::Jpeg)
3721        .expect("normalize lossy avif metadata policy");
3722
3723        assert_eq!(normalized.metadata_policy, MetadataPolicy::StripAll);
3724    }
3725
3726    #[test]
3727    fn normalize_keeps_fit_none_when_resize_is_not_bounded() {
3728        let normalized = TransformOptions {
3729            width: Some(500),
3730            ..TransformOptions::default()
3731        }
3732        .normalize(MediaType::Jpeg)
3733        .expect("normalize unbounded resize");
3734
3735        assert_eq!(normalized.fit, None);
3736        assert_eq!(normalized.position, Position::Center);
3737    }
3738
3739    #[test]
3740    fn normalize_rejects_zero_dimensions() {
3741        let err = TransformOptions {
3742            width: Some(0),
3743            ..TransformOptions::default()
3744        }
3745        .normalize(MediaType::Jpeg)
3746        .expect_err("zero width should fail");
3747
3748        assert_eq!(
3749            err,
3750            TransformError::InvalidOptions("width must be greater than zero".to_string())
3751        );
3752    }
3753
3754    #[test]
3755    fn normalize_rejects_fit_without_both_dimensions() {
3756        let err = TransformOptions {
3757            width: Some(300),
3758            fit: Some(Fit::Contain),
3759            ..TransformOptions::default()
3760        }
3761        .normalize(MediaType::Jpeg)
3762        .expect_err("fit without bounded resize should fail");
3763
3764        assert_eq!(
3765            err,
3766            TransformError::InvalidOptions("fit requires both width and height".to_string())
3767        );
3768    }
3769
3770    /// Every rule the options settle between themselves gives the same message whether
3771    /// it is reached through `normalize`, which has an input, or through
3772    /// `validate_without_input`, which is what the HTTP server and `truss sign` call.
3773    /// Two lists would drift; one list read twice cannot.
3774    #[rstest]
3775    #[case(
3776        TransformOptions { width: Some(300), fit: Some(Fit::Contain), ..TransformOptions::default() },
3777        "fit requires both width and height"
3778    )]
3779    #[case(
3780        TransformOptions { height: Some(300), position: Some(Position::Top), ..TransformOptions::default() },
3781        "position requires both width and height"
3782    )]
3783    #[case(
3784        TransformOptions { without_enlargement: true, ..TransformOptions::default() },
3785        "withoutEnlargement requires width or height"
3786    )]
3787    #[case(
3788        TransformOptions { width: Some(0), ..TransformOptions::default() },
3789        "width must be greater than zero"
3790    )]
3791    #[case(
3792        TransformOptions { height: Some(0), ..TransformOptions::default() },
3793        "height must be greater than zero"
3794    )]
3795    #[case(
3796        TransformOptions { quality: Some(101), format: Some(MediaType::Jpeg), ..TransformOptions::default() },
3797        "quality must be between 1 and 100"
3798    )]
3799    #[case(
3800        TransformOptions { blur: Some(200.0), ..TransformOptions::default() },
3801        "blur sigma must be between 0.1 and 100.0"
3802    )]
3803    #[case(
3804        TransformOptions { sharpen: Some(500.0), ..TransformOptions::default() },
3805        "sharpen sigma must be between 0.1 and 100.0"
3806    )]
3807    #[case(
3808        TransformOptions {
3809            crop: Some(crate::CropRegion { x: 0, y: 0, width: 0, height: 0 }),
3810            ..TransformOptions::default()
3811        },
3812        "crop width and height must be greater than zero"
3813    )]
3814    fn the_input_independent_rules_answer_the_same_through_either_door(
3815        #[case] options: TransformOptions,
3816        #[case] message: &str,
3817    ) {
3818        let expected = TransformError::InvalidOptions(message.to_string());
3819
3820        assert_eq!(
3821            options
3822                .validate_without_input()
3823                .expect_err("the options contradict each other whatever the input is"),
3824            expected
3825        );
3826        assert_eq!(
3827            options
3828                .normalize(MediaType::Jpeg)
3829                .expect_err("normalize runs the same list first"),
3830            expected
3831        );
3832    }
3833
3834    #[test]
3835    fn normalize_rejects_position_without_both_dimensions() {
3836        let err = TransformOptions {
3837            height: Some(300),
3838            position: Some(Position::Top),
3839            ..TransformOptions::default()
3840        }
3841        .normalize(MediaType::Jpeg)
3842        .expect_err("position without bounded resize should fail");
3843
3844        assert_eq!(
3845            err,
3846            TransformError::InvalidOptions("position requires both width and height".to_string())
3847        );
3848    }
3849
3850    #[test]
3851    fn normalize_rejects_quality_for_lossless_output() {
3852        let err = TransformOptions {
3853            format: Some(MediaType::Png),
3854            quality: Some(80),
3855            ..TransformOptions::default()
3856        }
3857        .normalize(MediaType::Jpeg)
3858        .expect_err("quality for png should fail");
3859
3860        assert_eq!(
3861            err,
3862            TransformError::InvalidOptions("quality requires a lossy output format".to_string())
3863        );
3864    }
3865
3866    #[test]
3867    fn normalize_rejects_zero_quality() {
3868        let err = TransformOptions {
3869            quality: Some(0),
3870            ..TransformOptions::default()
3871        }
3872        .normalize(MediaType::Jpeg)
3873        .expect_err("zero quality should fail");
3874
3875        assert_eq!(
3876            err,
3877            TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
3878        );
3879    }
3880
3881    #[test]
3882    fn normalize_rejects_quality_above_one_hundred() {
3883        let err = TransformOptions {
3884            quality: Some(101),
3885            ..TransformOptions::default()
3886        }
3887        .normalize(MediaType::Jpeg)
3888        .expect_err("quality above one hundred should fail");
3889
3890        assert_eq!(
3891            err,
3892            TransformError::InvalidOptions("quality must be between 1 and 100".to_string())
3893        );
3894    }
3895
3896    #[test]
3897    fn normalize_rejects_preserve_exif_when_metadata_is_stripped() {
3898        let err = TransformOptions {
3899            preserve_exif: true,
3900            ..TransformOptions::default()
3901        }
3902        .normalize(MediaType::Jpeg)
3903        .expect_err("preserve_exif should require metadata retention");
3904
3905        assert_eq!(
3906            err,
3907            TransformError::InvalidOptions(
3908                "preserveExif requires stripMetadata to be false".to_string()
3909            )
3910        );
3911    }
3912
3913    #[test]
3914    fn normalize_validates_optimize_and_target_quality_matrix() {
3915        struct Case {
3916            name: &'static str,
3917            input_media_type: MediaType,
3918            options: TransformOptions,
3919            expected_error: Option<&'static str>,
3920        }
3921
3922        let cases = [
3923            Case {
3924                name: "target quality requires optimize auto or lossy",
3925                input_media_type: MediaType::Jpeg,
3926                options: TransformOptions {
3927                    format: Some(MediaType::Jpeg),
3928                    target_quality: Some(TargetQuality {
3929                        metric: QualityMetric::Ssim,
3930                        value: 0.98,
3931                    }),
3932                    ..TransformOptions::default()
3933                },
3934                expected_error: Some("targetQuality requires optimize=auto or optimize=lossy"),
3935            },
3936            Case {
3937                name: "target quality not allowed with lossless optimize",
3938                input_media_type: MediaType::Webp,
3939                options: TransformOptions {
3940                    format: Some(MediaType::Webp),
3941                    optimize: OptimizeMode::Lossless,
3942                    target_quality: Some(TargetQuality {
3943                        metric: QualityMetric::Ssim,
3944                        value: 0.98,
3945                    }),
3946                    ..TransformOptions::default()
3947                },
3948                expected_error: Some("targetQuality requires optimize=auto or optimize=lossy"),
3949            },
3950            Case {
3951                name: "target quality requires lossy optimizable output",
3952                input_media_type: MediaType::Png,
3953                options: TransformOptions {
3954                    format: Some(MediaType::Png),
3955                    optimize: OptimizeMode::Auto,
3956                    target_quality: Some(TargetQuality {
3957                        metric: QualityMetric::Ssim,
3958                        value: 0.98,
3959                    }),
3960                    ..TransformOptions::default()
3961                },
3962                expected_error: Some("targetQuality requires jpeg, webp, or avif output"),
3963            },
3964            Case {
3965                name: "quality cannot combine with lossless optimize",
3966                input_media_type: MediaType::Jpeg,
3967                options: TransformOptions {
3968                    format: Some(MediaType::Jpeg),
3969                    optimize: OptimizeMode::Lossless,
3970                    quality: Some(80),
3971                    ..TransformOptions::default()
3972                },
3973                expected_error: Some("quality cannot be combined with optimize=lossless"),
3974            },
3975            Case {
3976                name: "lossy optimize requires lossy capable format",
3977                input_media_type: MediaType::Png,
3978                options: TransformOptions {
3979                    format: Some(MediaType::Png),
3980                    optimize: OptimizeMode::Lossy,
3981                    ..TransformOptions::default()
3982                },
3983                expected_error: Some(
3984                    "lossy optimization requires jpeg, webp, or avif output, got png",
3985                ),
3986            },
3987            Case {
3988                name: "optimize unsupported for svg output",
3989                input_media_type: MediaType::Svg,
3990                options: TransformOptions {
3991                    format: Some(MediaType::Svg),
3992                    optimize: OptimizeMode::Auto,
3993                    ..TransformOptions::default()
3994                },
3995                expected_error: Some("optimization is not supported for svg output"),
3996            },
3997            Case {
3998                name: "preserve exif unsupported for svg output",
3999                input_media_type: MediaType::Svg,
4000                options: TransformOptions {
4001                    format: Some(MediaType::Svg),
4002                    preserve_exif: true,
4003                    strip_metadata: false,
4004                    ..TransformOptions::default()
4005                },
4006                expected_error: Some("preserveExif is not supported with SVG output"),
4007            },
4008            // SVG output is a sanitize-only passthrough: the document comes back as written,
4009            // so an option asking for a different picture cannot be honoured. Refusing it is
4010            // what the rules above already do for the options they cover.
4011            Case {
4012                name: "width unsupported for svg output",
4013                input_media_type: MediaType::Svg,
4014                options: TransformOptions {
4015                    format: Some(MediaType::Svg),
4016                    width: Some(100),
4017                    ..TransformOptions::default()
4018                },
4019                expected_error: Some(
4020                    "width is not supported with SVG output; choose a raster output format such as png",
4021                ),
4022            },
4023            Case {
4024                name: "height unsupported for svg output",
4025                input_media_type: MediaType::Svg,
4026                options: TransformOptions {
4027                    format: Some(MediaType::Svg),
4028                    height: Some(100),
4029                    ..TransformOptions::default()
4030                },
4031                expected_error: Some(
4032                    "height is not supported with SVG output; choose a raster output format such as png",
4033                ),
4034            },
4035            Case {
4036                name: "rotate unsupported for svg output",
4037                input_media_type: MediaType::Svg,
4038                options: TransformOptions {
4039                    format: Some(MediaType::Svg),
4040                    rotate: Rotation::DEG_90,
4041                    ..TransformOptions::default()
4042                },
4043                expected_error: Some(
4044                    "rotate is not supported with SVG output; choose a raster output format such as png",
4045                ),
4046            },
4047            Case {
4048                name: "grayscale unsupported for svg output",
4049                input_media_type: MediaType::Svg,
4050                options: TransformOptions {
4051                    format: Some(MediaType::Svg),
4052                    grayscale: true,
4053                    ..TransformOptions::default()
4054                },
4055                expected_error: Some(
4056                    "grayscale is not supported with SVG output; choose a raster output format such as png",
4057                ),
4058            },
4059            Case {
4060                name: "background unsupported for svg output",
4061                input_media_type: MediaType::Svg,
4062                options: TransformOptions {
4063                    format: Some(MediaType::Svg),
4064                    background: Some(Rgba8 {
4065                        r: 255,
4066                        g: 0,
4067                        b: 0,
4068                        a: 255,
4069                    }),
4070                    ..TransformOptions::default()
4071                },
4072                expected_error: Some(
4073                    "background is not supported with SVG output; choose a raster output format such as png",
4074                ),
4075            },
4076            Case {
4077                name: "svg passthrough with no transform options is accepted",
4078                input_media_type: MediaType::Svg,
4079                options: TransformOptions {
4080                    format: Some(MediaType::Svg),
4081                    rotate: Rotation::DEG_0,
4082                    ..TransformOptions::default()
4083                },
4084                expected_error: None,
4085            },
4086            Case {
4087                name: "svg input rasterized to png accepts the same options",
4088                input_media_type: MediaType::Svg,
4089                options: TransformOptions {
4090                    format: Some(MediaType::Png),
4091                    width: Some(100),
4092                    height: Some(100),
4093                    rotate: Rotation::DEG_90,
4094                    grayscale: true,
4095                    ..TransformOptions::default()
4096                },
4097                expected_error: None,
4098            },
4099            Case {
4100                name: "auto optimize accepts lossy target quality",
4101                input_media_type: MediaType::Jpeg,
4102                options: TransformOptions {
4103                    format: Some(MediaType::Jpeg),
4104                    optimize: OptimizeMode::Auto,
4105                    target_quality: Some(TargetQuality {
4106                        metric: QualityMetric::Ssim,
4107                        value: 0.98,
4108                    }),
4109                    ..TransformOptions::default()
4110                },
4111                expected_error: None,
4112            },
4113            Case {
4114                name: "lossless optimize accepts png without quality",
4115                input_media_type: MediaType::Png,
4116                options: TransformOptions {
4117                    format: Some(MediaType::Png),
4118                    optimize: OptimizeMode::Lossless,
4119                    ..TransformOptions::default()
4120                },
4121                expected_error: None,
4122            },
4123        ];
4124
4125        for case in cases {
4126            let result = case.options.normalize(case.input_media_type);
4127            match case.expected_error {
4128                Some(message) => {
4129                    let error = result.expect_err(case.name);
4130                    assert_eq!(
4131                        error,
4132                        TransformError::InvalidOptions(message.to_string()),
4133                        "{}",
4134                        case.name
4135                    );
4136                }
4137                None => {
4138                    result.expect(case.name);
4139                }
4140            }
4141        }
4142    }
4143
4144    #[test]
4145    fn transform_request_normalize_uses_input_media_type_as_default_output() {
4146        let request = TransformRequest::new(jpeg_artifact(), TransformOptions::default());
4147        let normalized = request.normalize().expect("normalize request");
4148
4149        assert_eq!(normalized.input.media_type, MediaType::Jpeg);
4150        assert_eq!(normalized.options.format, MediaType::Jpeg);
4151        assert_eq!(normalized.options.metadata_policy, MetadataPolicy::StripAll);
4152    }
4153
4154    #[test]
4155    fn sniff_artifact_detects_png_dimensions_and_alpha() {
4156        let artifact =
4157            sniff_artifact(RawArtifact::new(png_ihdr_bytes(64, 32, 6), None)).expect("sniff png");
4158
4159        assert_eq!(artifact.media_type, MediaType::Png);
4160        assert_eq!(artifact.metadata.width, Some(64));
4161        assert_eq!(artifact.metadata.height, Some(32));
4162        assert_eq!(artifact.metadata.has_alpha, Some(true));
4163    }
4164
4165    #[test]
4166    fn sniff_artifact_detects_jpeg_dimensions() {
4167        let artifact =
4168            sniff_artifact(RawArtifact::new(jpeg_bytes(320, 240), None)).expect("sniff jpeg");
4169
4170        assert_eq!(artifact.media_type, MediaType::Jpeg);
4171        assert_eq!(artifact.metadata.width, Some(320));
4172        assert_eq!(artifact.metadata.height, Some(240));
4173        assert_eq!(artifact.metadata.has_alpha, Some(false));
4174    }
4175
4176    #[test]
4177    fn normalize_defaults_gif_input_to_png_output() {
4178        // "Keep the input format" cannot mean GIF, because truss has no GIF encoder.
4179        let options = TransformOptions::default()
4180            .normalize(MediaType::Gif)
4181            .expect("gif input should normalize");
4182
4183        assert_eq!(options.format, MediaType::Png);
4184    }
4185
4186    #[test]
4187    fn normalize_keeps_an_explicit_format_for_gif_input() {
4188        let options = TransformOptions {
4189            format: Some(MediaType::Webp),
4190            ..TransformOptions::default()
4191        }
4192        .normalize(MediaType::Gif)
4193        .expect("gif input with an explicit format should normalize");
4194
4195        assert_eq!(options.format, MediaType::Webp);
4196    }
4197
4198    #[test]
4199    fn gif_is_not_encodable() {
4200        assert!(!MediaType::Gif.is_encodable());
4201        for media_type in [
4202            MediaType::Jpeg,
4203            MediaType::Png,
4204            MediaType::Webp,
4205            MediaType::Avif,
4206            MediaType::Svg,
4207            MediaType::Bmp,
4208            MediaType::Tiff,
4209        ] {
4210            assert!(
4211                media_type.is_encodable(),
4212                "{} should be encodable",
4213                media_type.as_name()
4214            );
4215        }
4216    }
4217
4218    #[test]
4219    fn sniff_artifact_detects_static_gif87a() {
4220        let artifact = sniff_artifact(RawArtifact::new(
4221            gif_bytes(b"87a", 640, 480, 1, false),
4222            None,
4223        ))
4224        .expect("sniff gif87a");
4225
4226        assert_eq!(artifact.media_type, MediaType::Gif);
4227        assert_eq!(artifact.metadata.width, Some(640));
4228        assert_eq!(artifact.metadata.height, Some(480));
4229        assert_eq!(artifact.metadata.frame_count, 1);
4230        assert_eq!(artifact.metadata.has_alpha, Some(false));
4231    }
4232
4233    #[test]
4234    fn sniff_artifact_detects_gif89a_transparency() {
4235        let artifact = sniff_artifact(RawArtifact::new(gif_bytes(b"89a", 4, 4, 1, true), None))
4236            .expect("sniff transparent gif");
4237
4238        assert_eq!(artifact.media_type, MediaType::Gif);
4239        assert_eq!(
4240            artifact.metadata.has_alpha,
4241            Some(true),
4242            "a Graphic Control Extension with the transparent-color flag means alpha"
4243        );
4244    }
4245
4246    #[test]
4247    fn sniff_artifact_counts_gif_frames() {
4248        // Frame count is what `inspect` turns into `isAnimated` and what the transform
4249        // pipeline refuses on, so the walk has to reach every image descriptor rather than
4250        // stopping at the first one.
4251        let artifact = sniff_artifact(RawArtifact::new(gif_bytes(b"89a", 8, 8, 5, true), None))
4252            .expect("sniff animated gif");
4253
4254        assert_eq!(artifact.metadata.frame_count, 5);
4255    }
4256
4257    /// A rejected colour is told what a colour looks like.
4258    ///
4259    /// Every other option in truss names its own rule in the failure, and `--background`
4260    /// answered every wrong spelling with the value repeated back. The assertion is the
4261    /// property rather than the sentence: the message says how many digits and says that no
4262    /// `#` is used, whatever wording carries it.
4263    #[test]
4264    fn a_rejected_color_is_told_what_a_color_looks_like() {
4265        for value in [
4266            "#ffffff", "fff", "white", "0xffffff", "FFFFFFF", "", "gggggg",
4267        ] {
4268            let message = Rgba8::from_hex(value).expect_err("not a color");
4269            assert!(
4270                message.contains("six or eight") && message.contains("hexadecimal"),
4271                "{value:?} was not told the digit count: {message}"
4272            );
4273            assert!(
4274                message.contains('#'),
4275                "{value:?} was not told that no `#` is used: {message}"
4276            );
4277        }
4278
4279        for value in ["ffffff", "FFFFFF", "ffffffaa", "000000"] {
4280            assert!(
4281                Rgba8::from_hex(value).is_ok(),
4282                "{value:?} should be a color"
4283            );
4284        }
4285    }
4286
4287    #[test]
4288    fn sniff_artifact_detects_an_animated_avif() {
4289        // An animated AVIF is a moving-image sequence, and the container says so in its
4290        // brands: `avis` is the sequence brand, which `is_avif_brand` already accepts as a
4291        // reason to call the file an AVIF at all.
4292        let mut bytes = Vec::new();
4293        bytes.extend_from_slice(&24_u32.to_be_bytes());
4294        bytes.extend_from_slice(b"ftyp");
4295        bytes.extend_from_slice(b"avis");
4296        bytes.extend_from_slice(&0_u32.to_be_bytes());
4297        bytes.extend_from_slice(b"avis");
4298        bytes.extend_from_slice(b"avif");
4299        let artifact =
4300            sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff an animated avif");
4301
4302        assert!(
4303            artifact.metadata.frame_count > 1,
4304            "an animated avif reported {} frames",
4305            artifact.metadata.frame_count
4306        );
4307    }
4308
4309    #[test]
4310    fn sniff_artifact_counts_the_frames_of_an_animated_avif() {
4311        // The frames are samples of a `moov` track, and the count is in `stsz`. The refusal
4312        // prints the number, so a placeholder there would state a count nothing measured.
4313        fn mp4_box(box_type: &[u8; 4], payload: &[u8]) -> Vec<u8> {
4314            let mut out = ((payload.len() + 8) as u32).to_be_bytes().to_vec();
4315            out.extend_from_slice(box_type);
4316            out.extend_from_slice(payload);
4317            out
4318        }
4319
4320        let mut stsz = vec![0_u8; 4];
4321        stsz.extend_from_slice(&0_u32.to_be_bytes());
4322        stsz.extend_from_slice(&7_u32.to_be_bytes());
4323        let stbl = mp4_box(b"stbl", &mp4_box(b"stsz", &stsz));
4324        let minf = mp4_box(b"minf", &stbl);
4325        let mdia = mp4_box(b"mdia", &minf);
4326        let trak = mp4_box(b"trak", &mdia);
4327        let moov = mp4_box(b"moov", &trak);
4328
4329        let mut bytes = Vec::new();
4330        bytes.extend_from_slice(&24_u32.to_be_bytes());
4331        bytes.extend_from_slice(b"ftyp");
4332        bytes.extend_from_slice(b"avis");
4333        bytes.extend_from_slice(&0_u32.to_be_bytes());
4334        bytes.extend_from_slice(b"avis");
4335        bytes.extend_from_slice(b"avif");
4336        bytes.extend_from_slice(&moov);
4337
4338        let artifact =
4339            sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff an animated avif");
4340
4341        assert_eq!(artifact.metadata.frame_count, 7);
4342    }
4343
4344    #[test]
4345    fn sniff_artifact_counts_the_frames_of_an_animated_png() {
4346        // An APNG announces its frame count in an `acTL` chunk before the image data. The
4347        // IHDR says nothing about it, so a sniffer that stops there calls the file static.
4348        let mut actl = Vec::new();
4349        actl.extend_from_slice(&4_u32.to_be_bytes());
4350        actl.extend_from_slice(&0_u32.to_be_bytes());
4351        let artifact = sniff_artifact(RawArtifact::new(
4352            png_bytes_with_chunks(2, &[(b"acTL", actl)]),
4353            None,
4354        ))
4355        .expect("sniff an animated png");
4356
4357        assert_eq!(artifact.metadata.frame_count, 4);
4358    }
4359
4360    #[test]
4361    fn sniff_artifact_counts_the_frames_of_an_animated_webp() {
4362        // Bit 1 of the VP8X flags is the animation flag, beside the alpha flag at bit 4 that
4363        // the sniffer already reads, and the frames follow in `ANMF` chunks.
4364        const ANIMATION: u8 = 0b0000_0010;
4365        let mut bytes = webp_vp8x_bytes(8, 8, ANIMATION);
4366        for _ in 0..3 {
4367            bytes.extend_from_slice(b"ANMF");
4368            bytes.extend_from_slice(&0_u32.to_le_bytes());
4369        }
4370        let riff_len = (bytes.len() - 8) as u32;
4371        bytes[4..8].copy_from_slice(&riff_len.to_le_bytes());
4372        let artifact =
4373            sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff an animated webp");
4374
4375        assert!(
4376            artifact.metadata.frame_count > 1,
4377            "an animated webp reported {} frames",
4378            artifact.metadata.frame_count
4379        );
4380    }
4381
4382    #[test]
4383    fn sniff_artifact_reads_png_transparency_from_a_trns_chunk() {
4384        // Colour types 0, 2, and 3 have no alpha channel and may still be transparent: the
4385        // PNG specification puts that transparency in a `tRNS` chunk. Reading only IHDR
4386        // calls a transparent palette PNG opaque while the same picture as a GIF is not.
4387        let cases: &[(u8, Vec<u8>, bool)] = &[
4388            (0, vec![0x00, 0x01], true),
4389            (2, vec![0x00, 0x01, 0x00, 0x02, 0x00, 0x03], true),
4390            (3, vec![0x00, 0xFF], true),
4391            (0, Vec::new(), false),
4392            (2, Vec::new(), false),
4393            (3, Vec::new(), false),
4394        ];
4395
4396        for (color_type, trns, expected) in cases {
4397            let chunks: Vec<(&[u8; 4], Vec<u8>)> = if trns.is_empty() {
4398                Vec::new()
4399            } else {
4400                vec![(b"tRNS", trns.clone())]
4401            };
4402            let artifact = sniff_artifact(RawArtifact::new(
4403                png_bytes_with_chunks(*color_type, &chunks),
4404                None,
4405            ))
4406            .expect("sniff png");
4407
4408            assert_eq!(
4409                artifact.metadata.has_alpha,
4410                Some(*expected),
4411                "color type {color_type} with {} bytes of tRNS",
4412                trns.len()
4413            );
4414        }
4415
4416        // A colour type that carries its own alpha channel is unaffected.
4417        for color_type in [4_u8, 6] {
4418            let artifact = sniff_artifact(RawArtifact::new(
4419                png_bytes_with_chunks(color_type, &[]),
4420                None,
4421            ))
4422            .expect("sniff png");
4423            assert_eq!(artifact.metadata.has_alpha, Some(true));
4424        }
4425    }
4426
4427    #[test]
4428    fn sniff_gif_rejects_a_header_shorter_than_the_screen_descriptor() {
4429        let err = sniff_artifact(RawArtifact::new(b"GIF89a\x04\x00".to_vec(), None))
4430            .expect_err("a 9-byte gif should be rejected");
4431
4432        assert!(
4433            matches!(err, TransformError::DecodeFailed(ref msg) if msg.contains("too short")),
4434            "expected a too-short decode error, got: {err}"
4435        );
4436    }
4437
4438    #[test]
4439    fn sniff_gif_rejects_a_file_truncated_inside_a_data_block() {
4440        let mut bytes = gif_bytes(b"89a", 4, 4, 1, false);
4441        // Drop the trailer and the sub-block terminator so the walk runs off the end.
4442        bytes.truncate(bytes.len() - 2);
4443        let err = sniff_artifact(RawArtifact::new(bytes, None))
4444            .expect_err("a truncated gif should be rejected");
4445
4446        assert!(
4447            matches!(err, TransformError::DecodeFailed(ref msg) if msg.contains("ends inside a data block")),
4448            "expected a truncated-block decode error, got: {err}"
4449        );
4450    }
4451
4452    #[test]
4453    fn sniff_gif_rejects_a_file_with_no_image_data() {
4454        let err = sniff_artifact(RawArtifact::new(gif_bytes(b"89a", 4, 4, 0, false), None))
4455            .expect_err("a gif with no frames should be rejected");
4456
4457        assert!(
4458            matches!(err, TransformError::DecodeFailed(ref msg) if msg.contains("no image data")),
4459            "expected a no-image-data decode error, got: {err}"
4460        );
4461    }
4462
4463    #[test]
4464    fn sniff_gif_rejects_an_unknown_block_introducer() {
4465        let mut bytes = gif_bytes(b"89a", 4, 4, 1, false);
4466        // Replace the trailer with a byte that is neither an extension, an image
4467        // descriptor, nor a trailer.
4468        let last = bytes.len() - 1;
4469        bytes[last] = 0x99;
4470        let err = sniff_artifact(RawArtifact::new(bytes, None))
4471            .expect_err("an unknown block introducer should be rejected");
4472
4473        assert!(
4474            matches!(err, TransformError::DecodeFailed(ref msg) if msg.contains("unknown block introducer")),
4475            "expected an unknown-introducer decode error, got: {err}"
4476        );
4477    }
4478
4479    #[test]
4480    fn sniff_gif_skips_a_local_color_table() {
4481        // A frame carrying its own palette shifts every later offset. Getting the skip
4482        // wrong would land the walk mid-palette and report a bogus block introducer.
4483        let mut bytes = Vec::new();
4484        bytes.extend_from_slice(b"GIF89a");
4485        bytes.extend_from_slice(&4u16.to_le_bytes());
4486        bytes.extend_from_slice(&4u16.to_le_bytes());
4487        bytes.extend_from_slice(&[0x00, 0x00, 0x00]); // no global color table
4488        bytes.push(0x2C);
4489        bytes.extend_from_slice(&0u16.to_le_bytes());
4490        bytes.extend_from_slice(&0u16.to_le_bytes());
4491        bytes.extend_from_slice(&4u16.to_le_bytes());
4492        bytes.extend_from_slice(&4u16.to_le_bytes());
4493        bytes.push(0b1000_0001); // local color table, 4 entries (2^(1+1))
4494        bytes.extend_from_slice(&[0u8; 12]);
4495        bytes.push(0x02);
4496        bytes.extend_from_slice(&[0x02, 0x44, 0x01, 0x00]);
4497        bytes.push(0x3B);
4498
4499        let artifact =
4500            sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff gif with local palette");
4501        assert_eq!(artifact.metadata.frame_count, 1);
4502        assert_eq!(artifact.metadata.width, Some(4));
4503    }
4504
4505    #[test]
4506    fn sniff_artifact_detects_webp_vp8x_dimensions() {
4507        let artifact = sniff_artifact(RawArtifact::new(
4508            webp_vp8x_bytes(800, 600, 0b0001_0000),
4509            None,
4510        ))
4511        .expect("sniff webp vp8x");
4512
4513        assert_eq!(artifact.media_type, MediaType::Webp);
4514        assert_eq!(artifact.metadata.width, Some(800));
4515        assert_eq!(artifact.metadata.height, Some(600));
4516        assert_eq!(artifact.metadata.has_alpha, Some(true));
4517    }
4518
4519    #[test]
4520    fn sniff_artifact_detects_webp_vp8l_dimensions() {
4521        let artifact = sniff_artifact(RawArtifact::new(webp_vp8l_bytes(123, 77), None))
4522            .expect("sniff webp vp8l");
4523
4524        assert_eq!(artifact.media_type, MediaType::Webp);
4525        assert_eq!(artifact.metadata.width, Some(123));
4526        assert_eq!(artifact.metadata.height, Some(77));
4527        assert_eq!(artifact.metadata.has_alpha, Some(false));
4528    }
4529
4530    #[test]
4531    fn sniff_artifact_reads_the_webp_vp8l_alpha_bit() {
4532        let artifact = sniff_artifact(RawArtifact::new(
4533            webp_vp8l_bytes_with_alpha(123, 77, true),
4534            None,
4535        ))
4536        .expect("sniff webp vp8l");
4537
4538        assert_eq!(artifact.metadata.has_alpha, Some(true));
4539    }
4540
4541    #[test]
4542    fn sniff_artifact_detects_avif_brand() {
4543        let artifact = sniff_artifact(RawArtifact::new(avif_bytes(), None)).expect("sniff avif");
4544
4545        assert_eq!(artifact.media_type, MediaType::Avif);
4546        assert_eq!(artifact.metadata, ArtifactMetadata::default());
4547    }
4548
4549    #[cfg(feature = "avif")]
4550    #[test]
4551    fn sniff_artifact_detects_avif_dimensions_and_alpha() {
4552        let artifact = sniff_artifact(RawArtifact::new(
4553            encoded_avif_bytes(7, 5, Rgba([10, 20, 30, 0])),
4554            None,
4555        ))
4556        .expect("sniff avif with alpha");
4557
4558        assert_eq!(artifact.media_type, MediaType::Avif);
4559        assert_eq!(artifact.metadata.width, Some(7));
4560        assert_eq!(artifact.metadata.height, Some(5));
4561        assert_eq!(artifact.metadata.has_alpha, Some(true));
4562    }
4563
4564    #[cfg(feature = "avif")]
4565    #[test]
4566    fn sniff_artifact_detects_opaque_avif_without_alpha_item() {
4567        let artifact = sniff_artifact(RawArtifact::new(
4568            encoded_avif_bytes(9, 4, Rgba([10, 20, 30, 255])),
4569            None,
4570        ))
4571        .expect("sniff opaque avif");
4572
4573        assert_eq!(artifact.media_type, MediaType::Avif);
4574        assert_eq!(artifact.metadata.width, Some(9));
4575        assert_eq!(artifact.metadata.height, Some(4));
4576        assert_eq!(artifact.metadata.has_alpha, Some(false));
4577    }
4578
4579    fn mp4_box(box_type: &[u8; 4], payload: &[u8]) -> Vec<u8> {
4580        let mut bytes = Vec::new();
4581        bytes.extend_from_slice(
4582            &u32::try_from(payload.len() + 8)
4583                .expect("box size")
4584                .to_be_bytes(),
4585        );
4586        bytes.extend_from_slice(box_type);
4587        bytes.extend_from_slice(payload);
4588        bytes
4589    }
4590
4591    fn mp4_full_box(box_type: &[u8; 4], version: u8, flags: u32, payload: &[u8]) -> Vec<u8> {
4592        let mut body = vec![version];
4593        body.extend_from_slice(&flags.to_be_bytes()[1..]);
4594        body.extend_from_slice(payload);
4595        mp4_box(box_type, &body)
4596    }
4597
4598    fn avif_ispe(width: u32, height: u32) -> Vec<u8> {
4599        let mut payload = width.to_be_bytes().to_vec();
4600        payload.extend_from_slice(&height.to_be_bytes());
4601        mp4_full_box(b"ispe", 0, 0, &payload)
4602    }
4603
4604    /// An `ipma` box in the given encoding: version 1 widens item ids to 32 bits, and flag
4605    /// bit 0 widens property positions to 15 bits.
4606    fn avif_ipma(version: u8, flags: u32, associations: &[(u32, &[u16])]) -> Vec<u8> {
4607        let mut payload = u32::try_from(associations.len())
4608            .expect("entry count")
4609            .to_be_bytes()
4610            .to_vec();
4611        for (item, positions) in associations {
4612            if version == 0 {
4613                payload.extend_from_slice(&u16::try_from(*item).expect("item id").to_be_bytes());
4614            } else {
4615                payload.extend_from_slice(&item.to_be_bytes());
4616            }
4617            payload.push(u8::try_from(positions.len()).expect("association count"));
4618            for position in *positions {
4619                if flags & 1 == 1 {
4620                    payload.extend_from_slice(&position.to_be_bytes());
4621                } else {
4622                    payload.push(u8::try_from(*position).expect("narrow position"));
4623                }
4624            }
4625        }
4626        mp4_full_box(b"ipma", version, flags, &payload)
4627    }
4628
4629    /// A structurally complete AVIF with no coded picture: the sniffer reads the item
4630    /// properties and never the payload, so none is needed to ask it about orientation.
4631    fn avif_bytes_with_properties(
4632        primary_item: u32,
4633        properties: &[Vec<u8>],
4634        ipma: Vec<u8>,
4635    ) -> Vec<u8> {
4636        let pitm = mp4_full_box(
4637            b"pitm",
4638            0,
4639            0,
4640            &u16::try_from(primary_item).expect("item id").to_be_bytes(),
4641        );
4642        let ipco = mp4_box(b"ipco", &properties.concat());
4643        let iprp = mp4_box(b"iprp", &[ipco, ipma].concat());
4644        let meta = mp4_full_box(b"meta", 0, 0, &[pitm, iprp].concat());
4645        let mut bytes = avif_bytes();
4646        bytes.extend_from_slice(&meta);
4647        bytes
4648    }
4649
4650    fn avif_bytes_with_transforms(rotation: Option<u8>, mirror: Option<u8>) -> Vec<u8> {
4651        let mut properties = vec![avif_ispe(40, 20)];
4652        let mut positions = vec![1_u16];
4653        if let Some(angle) = rotation {
4654            properties.push(mp4_box(b"irot", &[angle]));
4655            positions.push(u16::try_from(properties.len()).expect("position"));
4656        }
4657        if let Some(mode) = mirror {
4658            properties.push(mp4_box(b"imir", &[mode]));
4659            positions.push(u16::try_from(properties.len()).expect("position"));
4660        }
4661        avif_bytes_with_properties(1, &properties, avif_ipma(0, 0, &[(1, &positions)]))
4662    }
4663
4664    /// Every combination of the two properties, against the table Chrome and Firefox use.
4665    /// The rotation is applied before the mirror, which is what tells 5 from 7.
4666    #[rstest]
4667    #[case(None, None, None)]
4668    #[case(Some(0), None, Some(1))]
4669    #[case(Some(1), None, Some(8))]
4670    #[case(Some(2), None, Some(3))]
4671    #[case(Some(3), None, Some(6))]
4672    #[case(None, Some(0), Some(4))]
4673    #[case(None, Some(1), Some(2))]
4674    #[case(Some(1), Some(0), Some(5))]
4675    #[case(Some(1), Some(1), Some(7))]
4676    #[case(Some(2), Some(0), Some(2))]
4677    #[case(Some(2), Some(1), Some(4))]
4678    #[case(Some(3), Some(0), Some(7))]
4679    #[case(Some(3), Some(1), Some(5))]
4680    fn sniff_artifact_folds_avif_irot_and_imir_into_an_orientation(
4681        #[case] rotation: Option<u8>,
4682        #[case] mirror: Option<u8>,
4683        #[case] expected: Option<u16>,
4684    ) {
4685        let bytes = avif_bytes_with_transforms(rotation, mirror);
4686        let artifact = sniff_artifact(RawArtifact::new(bytes.clone(), None)).expect("sniff avif");
4687
4688        assert_eq!(
4689            artifact.metadata.orientation, expected,
4690            "irot {rotation:?}, imir {mirror:?}"
4691        );
4692        assert_eq!(
4693            (artifact.metadata.width, artifact.metadata.height),
4694            (Some(40), Some(20)),
4695            "the dimensions are still read from the same property container"
4696        );
4697        assert_eq!(
4698            exif_orientation(MediaType::Avif, &bytes),
4699            expected,
4700            "the pipeline reads what the sniffer reports"
4701        );
4702    }
4703
4704    /// The properties of another item — an alpha plane with its own `irot` — say nothing
4705    /// about the primary picture.
4706    #[test]
4707    fn sniff_artifact_ignores_avif_transforms_on_other_items() {
4708        let properties = vec![avif_ispe(40, 20), mp4_box(b"irot", &[3])];
4709        let bytes =
4710            avif_bytes_with_properties(1, &properties, avif_ipma(0, 0, &[(1, &[1]), (2, &[1, 2])]));
4711
4712        let artifact = sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff avif");
4713
4714        assert_eq!(artifact.metadata.orientation, None);
4715    }
4716
4717    /// `ipma` has two encodings for ids and two for positions, and encoders use both.
4718    #[test]
4719    fn sniff_artifact_reads_avif_associations_in_the_wide_ipma_encoding() {
4720        let properties = vec![avif_ispe(40, 20), mp4_box(b"irot", &[3])];
4721        let bytes = avif_bytes_with_properties(1, &properties, avif_ipma(1, 1, &[(1, &[1, 2])]));
4722
4723        let artifact = sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff avif");
4724
4725        assert_eq!(artifact.metadata.orientation, Some(6));
4726    }
4727
4728    /// The order the file lists the two properties in does not change the answer: MIAF
4729    /// fixes the rotation before the mirror.
4730    #[test]
4731    fn sniff_artifact_applies_avif_rotation_before_mirror_whatever_the_listed_order() {
4732        let properties = vec![
4733            avif_ispe(40, 20),
4734            mp4_box(b"imir", &[1]),
4735            mp4_box(b"irot", &[3]),
4736        ];
4737        let bytes = avif_bytes_with_properties(1, &properties, avif_ipma(0, 0, &[(1, &[3, 2, 1])]));
4738
4739        let artifact = sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff avif");
4740
4741        assert_eq!(artifact.metadata.orientation, Some(5));
4742    }
4743
4744    /// An `ipma` that promises more entries than it holds is refused, not read past.
4745    #[test]
4746    fn sniff_artifact_rejects_a_truncated_avif_ipma() {
4747        let ipma = mp4_full_box(b"ipma", 0, 0, &5_u32.to_be_bytes());
4748        let bytes = avif_bytes_with_properties(1, &[avif_ispe(4, 4)], ipma);
4749
4750        let error = sniff_artifact(RawArtifact::new(bytes, None)).expect_err("truncated ipma");
4751
4752        assert!(
4753            error.to_string().contains("ipma box is too short"),
4754            "{error}"
4755        );
4756    }
4757
4758    fn avif_clap(width: u32, height: u32, horizontal: i32, vertical: i32) -> Vec<u8> {
4759        let mut payload = Vec::new();
4760        for value in [width, 1, height, 1] {
4761            payload.extend_from_slice(&value.to_be_bytes());
4762        }
4763        for offset in [horizontal, vertical] {
4764            payload.extend_from_slice(&offset.to_be_bytes());
4765            payload.extend_from_slice(&1_u32.to_be_bytes());
4766        }
4767        mp4_box(b"clap", &payload)
4768    }
4769
4770    /// A clean aperture whose denominator is large enough to overflow the arithmetic that
4771    /// places it is answered rather than aborting the process that read the file.
4772    ///
4773    /// The product of the picture size and the denominator reaches about 1.8e19 with both read
4774    /// from the file, which is past what an `i64` holds. Whether such a file is accepted or
4775    /// refused is a property of the fraction: a maximum denominator against a maximum picture
4776    /// centres a one-pixel aperture exactly, and one pixel less does not divide evenly. What
4777    /// this pins is that both answers come back at all.
4778    #[rstest]
4779    #[case::a_fraction_that_does_not_divide(4_000_000_000, 4_000_000_000, None)]
4780    #[case::a_fraction_that_does(u32::MAX, u32::MAX, Some((1, 1)))]
4781    fn sniff_artifact_places_a_clean_aperture_without_overflowing(
4782        #[case] picture: u32,
4783        #[case] denominator: u32,
4784        #[case] expected: Option<(u32, u32)>,
4785    ) {
4786        let mut clap = Vec::new();
4787        // An aperture of one pixel: the numerator over the denominator, so the aperture is
4788        // small and the picture it is cut from is enormous, which is what makes the product
4789        // large.
4790        for value in [denominator, denominator, denominator, denominator] {
4791            clap.extend_from_slice(&value.to_be_bytes());
4792        }
4793        for _ in 0..2 {
4794            clap.extend_from_slice(&0_i32.to_be_bytes());
4795            clap.extend_from_slice(&denominator.to_be_bytes());
4796        }
4797        let bytes = avif_bytes_with_properties(
4798            1,
4799            &[avif_ispe(picture, picture), mp4_box(b"clap", &clap)],
4800            avif_ipma(0, 0, &[(1, &[1, 2])]),
4801        );
4802
4803        match (sniff_artifact(RawArtifact::new(bytes, None)), expected) {
4804            (Ok(artifact), Some((width, height))) => {
4805                assert_eq!(
4806                    (artifact.metadata.width, artifact.metadata.height),
4807                    (Some(width), Some(height))
4808                );
4809            }
4810            (Err(TransformError::DecodeFailed(_)), None) => {}
4811            (actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
4812        }
4813    }
4814
4815    /// The clean aperture is the picture, so the sniffer reports its size, and it is cut
4816    /// before the orientation turns it, so the oriented size follows from the cut.
4817    #[rstest]
4818    #[case::centred(30, 20, 0, 0, None, (30, 20), (30, 20))]
4819    #[case::offset_to_the_left(30, 20, -5, 0, None, (30, 20), (30, 20))]
4820    #[case::then_rotated(30, 20, 0, 0, Some(3), (30, 20), (20, 30))]
4821    #[case::whole_picture(40, 20, 0, 0, None, (40, 20), (40, 20))]
4822    fn sniff_artifact_reports_the_avif_clean_aperture_as_the_picture(
4823        #[case] width: u32,
4824        #[case] height: u32,
4825        #[case] horizontal: i32,
4826        #[case] vertical: i32,
4827        #[case] rotation: Option<u8>,
4828        #[case] expected: (u32, u32),
4829        #[case] expected_oriented: (u32, u32),
4830    ) {
4831        let mut properties = vec![
4832            avif_ispe(40, 20),
4833            avif_clap(width, height, horizontal, vertical),
4834        ];
4835        let mut positions = vec![1_u16, 2];
4836        if let Some(angle) = rotation {
4837            properties.push(mp4_box(b"irot", &[angle]));
4838            positions.push(3);
4839        }
4840        let bytes = avif_bytes_with_properties(1, &properties, avif_ipma(0, 0, &[(1, &positions)]));
4841
4842        let artifact = sniff_artifact(RawArtifact::new(bytes, None)).expect("sniff avif");
4843
4844        assert_eq!(
4845            (artifact.metadata.width, artifact.metadata.height),
4846            (Some(expected.0), Some(expected.1))
4847        );
4848        assert_eq!(
4849            artifact.metadata.oriented_dimensions(),
4850            Some(Dimensions::new(expected_oriented.0, expected_oriented.1))
4851        );
4852    }
4853
4854    /// An aperture that does not land on whole pixels or does not fit is refused, not
4855    /// rounded: MIAF requires whole pixels for an AV1 image, and a viewer that rounds shows
4856    /// a different picture from one that does not.
4857    #[rstest]
4858    #[case::off_the_pixel_grid(31, 20, 0, 0, "does not land on a whole pixel")]
4859    #[case::wider_than_the_picture(50, 20, 0, 0, "larger than the 40-pixel picture")]
4860    #[case::pushed_out_of_the_picture(30, 20, 6, 0, "leaves the picture")]
4861    fn sniff_artifact_refuses_an_avif_clean_aperture_that_is_not_a_pixel_rectangle(
4862        #[case] width: u32,
4863        #[case] height: u32,
4864        #[case] horizontal: i32,
4865        #[case] vertical: i32,
4866        #[case] reason: &str,
4867    ) {
4868        let properties = vec![
4869            avif_ispe(40, 20),
4870            avif_clap(width, height, horizontal, vertical),
4871        ];
4872        let bytes = avif_bytes_with_properties(1, &properties, avif_ipma(0, 0, &[(1, &[1, 2])]));
4873
4874        let error = sniff_artifact(RawArtifact::new(bytes, None)).expect_err("refused");
4875
4876        assert!(error.to_string().contains(reason), "{error}");
4877    }
4878
4879    /// Two files patched from what libheif wrote, since no encoder here writes the box: a
4880    /// 40x20 picture with a centred 30x20 aperture, and the same aperture on a rotated one.
4881    #[test]
4882    fn sniff_artifact_reads_the_clean_aperture_of_a_patched_avif() {
4883        let cropped = include_bytes!("../integration/fixtures/clap-cropped.avif");
4884        let rotated = include_bytes!("../integration/fixtures/clap-rotated.avif");
4885
4886        let cropped = sniff_artifact(RawArtifact::new(cropped.to_vec(), None)).expect("sniff");
4887        assert_eq!(
4888            (cropped.metadata.width, cropped.metadata.height),
4889            (Some(30), Some(20))
4890        );
4891        assert_eq!(cropped.metadata.orientation, None);
4892
4893        let rotated = sniff_artifact(RawArtifact::new(rotated.to_vec(), None)).expect("sniff");
4894        assert_eq!(
4895            (rotated.metadata.width, rotated.metadata.height),
4896            (Some(30), Some(20))
4897        );
4898        assert_eq!(rotated.metadata.orientation, Some(6));
4899        assert_eq!(
4900            rotated.metadata.oriented_dimensions(),
4901            Some(Dimensions::new(20, 30))
4902        );
4903    }
4904
4905    /// Two files ImageMagick wrote through libheif, which is the encoder behind the phones
4906    /// and the CMSes that produce AVIF: the transform is in the properties and there is no
4907    /// Exif block at all.
4908    #[test]
4909    fn sniff_artifact_reads_the_orientation_libheif_writes() {
4910        let rotated = include_bytes!("../integration/fixtures/irot-rotated.avif");
4911        let transposed = include_bytes!("../integration/fixtures/imir-transposed-5.avif");
4912
4913        let rotated = sniff_artifact(RawArtifact::new(rotated.to_vec(), None)).expect("sniff");
4914        assert_eq!(rotated.metadata.orientation, Some(6));
4915        assert_eq!(
4916            (rotated.metadata.width, rotated.metadata.height),
4917            (Some(40), Some(20))
4918        );
4919        assert_eq!(
4920            rotated.metadata.oriented_dimensions(),
4921            Some(Dimensions::new(20, 40)),
4922            "the oriented dimensions are what convert will produce"
4923        );
4924
4925        let transposed =
4926            sniff_artifact(RawArtifact::new(transposed.to_vec(), None)).expect("sniff");
4927        assert_eq!(transposed.metadata.orientation, Some(5));
4928    }
4929
4930    #[test]
4931    fn sniff_artifact_rejects_declared_media_type_mismatch() {
4932        let err = sniff_artifact(RawArtifact::new(
4933            png_ihdr_bytes(8, 8, 2),
4934            Some(MediaType::Jpeg),
4935        ))
4936        .expect_err("declared mismatch should fail");
4937
4938        assert_eq!(
4939            err,
4940            TransformError::InvalidInput(
4941                "declared media type does not match detected media type".to_string()
4942            )
4943        );
4944    }
4945
4946    /// A number a caller typed is not yet a `u8`, so the range they are told has to be the
4947    /// range truss documents rather than the span of the integer it would be stored in.
4948    ///
4949    /// `--quality 255` was answered `quality must be between 1 and 100` and `--quality 256`
4950    /// was answered `256 is not in 0..=255`, which is two limits for one option.
4951    #[test]
4952    fn a_quality_outside_the_documented_range_reports_that_range_at_any_width() {
4953        for value in [0_i64, 101, 255, 256, 999_999, -1, i64::MAX, i64::MIN] {
4954            assert_eq!(
4955                validate_quality_value(value),
4956                Err("quality must be between 1 and 100"),
4957                "{value}"
4958            );
4959        }
4960        for value in [1_i64, 50, 100] {
4961            assert_eq!(validate_quality_value(value), Ok(value as u8), "{value}");
4962        }
4963    }
4964
4965    /// A dimension that cannot be a number of pixels says which of the two things is wrong,
4966    /// and never names the integer it would be stored in.
4967    ///
4968    /// `--width 4294967296` was answered `4294967296 is not in 0..=4294967295`, the span of
4969    /// a `u32`, while `--quality 256` had been given the documented range in v0.20.0.
4970    #[test]
4971    fn a_dimension_that_cannot_be_a_pixel_count_says_which_half_is_wrong() {
4972        for value in [1_i64, 2, 100, u32::MAX as i64] {
4973            assert_eq!(validate_width_value(value), Ok(value as u32), "{value}");
4974            assert_eq!(validate_height_value(value), Ok(value as u32), "{value}");
4975        }
4976        // Zero fits, and is reported where it always was, with the class it always had.
4977        assert_eq!(validate_width_value(0), Ok(0));
4978        assert_eq!(validate_height_value(0), Ok(0));
4979
4980        for value in [-1_i64, i64::MIN] {
4981            assert_eq!(
4982                validate_width_value(value),
4983                Err("width must be greater than zero"),
4984                "{value}"
4985            );
4986            assert_eq!(
4987                validate_height_value(value),
4988                Err("height must be greater than zero"),
4989                "{value}"
4990            );
4991        }
4992        for value in [u32::MAX as i64 + 1, i64::MAX] {
4993            assert_eq!(
4994                validate_width_value(value),
4995                Err("width is too large to be a number of pixels"),
4996                "{value}"
4997            );
4998            assert_eq!(
4999                validate_height_value(value),
5000                Err("height is too large to be a number of pixels"),
5001                "{value}"
5002            );
5003        }
5004    }
5005
5006    /// The same for the watermark opacity, which is the other option of this shape.
5007    #[test]
5008    fn a_watermark_opacity_outside_the_documented_range_reports_that_range_at_any_width() {
5009        for value in [0_i64, 101, 255, 256, -1, i64::MAX] {
5010            assert_eq!(
5011                validate_watermark_opacity_value(value),
5012                Err("watermark opacity must be between 1 and 100"),
5013                "{value}"
5014            );
5015        }
5016        assert_eq!(validate_watermark_opacity_value(50), Ok(50));
5017    }
5018
5019    /// An angle past a full turn wraps, which is what the flag documents, and a big one is
5020    /// no different from a small one.
5021    ///
5022    /// `--rotate 9999999999` was refused with `expected a whole number of degrees`, which
5023    /// it is; the real limit was that the value had to fit an `i32`, and nothing said so.
5024    #[test]
5025    fn a_rotation_past_a_full_turn_wraps_however_large_it_is() {
5026        use std::str::FromStr;
5027        assert_eq!(
5028            Rotation::from_str("9999999999").expect("a whole number of degrees"),
5029            Rotation::from_str("279").expect("279")
5030        );
5031        assert_eq!(
5032            Rotation::from_str("-9999999999").expect("a whole number of degrees"),
5033            Rotation::from_str("81").expect("81")
5034        );
5035        assert_eq!(
5036            Rotation::from_str("2147483648").expect("one past i32"),
5037            Rotation::from_str(&(2147483648_i64 % 360).to_string()).expect("wrapped")
5038        );
5039        // A value that is not a whole number is still refused, and says so.
5040        let error = Rotation::from_str("1.5").expect_err("not a whole number");
5041        assert!(error.contains("whole number of degrees"), "{error}");
5042    }
5043
5044    #[test]
5045    fn sniff_artifact_rejects_unknown_signatures() {
5046        let err =
5047            sniff_artifact(RawArtifact::new(vec![1, 2, 3, 4], None)).expect_err("unknown bytes");
5048
5049        assert!(
5050            matches!(err, TransformError::UnsupportedInputMediaType(ref msg) if msg.contains("unknown file signature")),
5051            "expected unknown file signature error, got: {err}"
5052        );
5053        let msg = err.to_string();
5054        assert!(msg.contains("4 bytes"), "should include file size: {msg}");
5055        assert!(
5056            !msg.contains("01 02 03 04"),
5057            "the bytes themselves are content, and this message reaches whoever named a URL: {msg}"
5058        );
5059    }
5060
5061    /// The XML prolog is `XMLDecl? Misc* (doctypedecl Misc*)?` with
5062    /// `Misc ::= Comment | PI | S`, so comments and processing instructions are
5063    /// legal on both sides of the DOCTYPE and in any number, and a DOCTYPE may
5064    /// carry an internal subset whose `>` characters are not its terminator.
5065    /// Every shape below is a valid SVG document.
5066    #[rstest]
5067    #[case::no_prolog(r#"<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>"#)]
5068    #[case::declaration(
5069        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5070    )]
5071    #[case::declaration_and_doctype(
5072        "<?xml version=\"1.0\"?>\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5073    )]
5074    #[case::comment_before_doctype(
5075        "<?xml version=\"1.0\"?>\n<!-- Generator: Adobe Illustrator 27.0.0 -->\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5076    )]
5077    #[case::doctype_with_internal_subset(
5078        "<?xml version=\"1.0\"?>\n<!DOCTYPE svg [<!ENTITY a \"b\">]>\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5079    )]
5080    #[case::illustrator_export(
5081        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Generator: Adobe Illustrator 27.0.0, SVG Export Plug-In -->\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\" [\n\t<!ENTITY ns_extend \"http://ns.adobe.com/Extensibility/1.0/\">\n]>\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5082    )]
5083    #[case::internal_subset_with_angle_bracket_in_a_string(
5084        "<?xml version=\"1.0\"?>\n<!DOCTYPE svg [<!ENTITY gt \"a > b\">]>\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5085    )]
5086    #[case::stylesheet_processing_instruction(
5087        "<?xml version=\"1.0\"?>\n<?xml-stylesheet type=\"text/css\" href=\"a.css\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5088    )]
5089    #[case::processing_instruction_between_comments(
5090        "<?xml version=\"1.0\"?>\n<!-- one -->\n<?foo bar?>\n<!-- two -->\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5091    )]
5092    #[case::comment_on_both_sides_of_the_doctype(
5093        "<!-- before -->\n<!DOCTYPE svg>\n<!-- after -->\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5094    )]
5095    #[case::bom_then_declaration(
5096        "\u{FEFF}<?xml version=\"1.0\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>"
5097    )]
5098    fn sniff_artifact_accepts_every_legal_svg_prolog(#[case] document: &str) {
5099        let artifact = sniff_artifact(RawArtifact::new(document.as_bytes().to_vec(), None))
5100            .unwrap_or_else(|err| panic!("prolog should be recognized as SVG, got: {err}"));
5101        assert_eq!(artifact.media_type, MediaType::Svg);
5102    }
5103
5104    /// Every other format reports the dimensions its container stores, and an SVG stores
5105    /// them on the root element. The unit table is what stops the next spelling from
5106    /// silently becoming `None`: a length with no unit and one in `px` are the same number,
5107    /// and the absolute units are fixed ratios of it.
5108    #[rstest]
5109    #[case::bare_numbers(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"/>"#, Some((100, 50)))]
5110    #[case::px(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100px" height="50px"/>"#, Some((100, 50)))]
5111    #[case::decimal(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100.6" height="50.2"/>"#, Some((100, 50)))]
5112    #[case::inches(r#"<svg xmlns="http://www.w3.org/2000/svg" width="1in" height="2in"/>"#, Some((96, 192)))]
5113    #[case::points(r#"<svg xmlns="http://www.w3.org/2000/svg" width="72pt" height="36pt"/>"#, Some((96, 48)))]
5114    #[case::picas(r#"<svg xmlns="http://www.w3.org/2000/svg" width="1pc" height="2pc"/>"#, Some((16, 32)))]
5115    #[case::whitespace_around_the_value(r#"<svg xmlns="http://www.w3.org/2000/svg" width=" 100 " height=" 50 "/>"#, Some((100, 50)))]
5116    #[case::single_quoted(r"<svg xmlns='http://www.w3.org/2000/svg' width='100' height='50'/>", Some((100, 50)))]
5117    #[case::view_box_only(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60"/>"#, Some((120, 60)))]
5118    #[case::view_box_with_commas(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0,0,120,60"/>"#, Some((120, 60)))]
5119    #[case::percentages_fall_back_to_the_view_box(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 30 20"/>"#, Some((30, 20)))]
5120    #[case::one_axis_takes_its_aspect_ratio_from_the_view_box(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" viewBox="0 0 30 20"/>"#, Some((100, 66)))]
5121    #[case::font_relative_units_are_unresolvable(
5122        r#"<svg xmlns="http://www.w3.org/2000/svg" width="10em" height="4em"/>"#,
5123        None
5124    )]
5125    #[case::percentages_with_no_view_box(
5126        r#"<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"/>"#,
5127        None
5128    )]
5129    #[case::nothing_declared(r#"<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>"#, None)]
5130    #[case::zero_is_not_a_size(
5131        r#"<svg xmlns="http://www.w3.org/2000/svg" width="0" height="50"/>"#,
5132        None
5133    )]
5134    #[case::negative_is_not_a_size(
5135        r#"<svg xmlns="http://www.w3.org/2000/svg" width="-100" height="50"/>"#,
5136        None
5137    )]
5138    #[case::malformed_view_box(
5139        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120"/>"#,
5140        None
5141    )]
5142    #[case::illustrator_prolog(
5143        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Generator: Adobe Illustrator 27.0.0 -->\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\" [\n\t<!ENTITY ns_extend \"http://ns.adobe.com/Extensibility/1.0/\">\n]>\n<svg xmlns=\"http://www.w3.org/2000/svg\" x=\"0px\" y=\"0px\" width=\"64px\" height=\"32px\" viewBox=\"0 0 64 32\"><rect/></svg>",
5144        Some((64, 32))
5145    )]
5146    fn sniff_artifact_reads_svg_dimensions_from_the_root_element(
5147        #[case] document: &str,
5148        #[case] expected: Option<(u32, u32)>,
5149    ) {
5150        let artifact = sniff_artifact(RawArtifact::new(document.as_bytes().to_vec(), None))
5151            .expect("document should be recognized as SVG");
5152
5153        assert_eq!(artifact.media_type, MediaType::Svg);
5154        assert_eq!(
5155            artifact.metadata.width.zip(artifact.metadata.height),
5156            expected
5157        );
5158        assert_eq!(
5159            artifact
5160                .metadata
5161                .oriented_dimensions()
5162                .map(|d| (d.width, d.height)),
5163            expected
5164        );
5165    }
5166
5167    /// Reading the prolog must not turn the sniffer into something that claims
5168    /// any XML document, or any document that merely starts with `<svg`.
5169    #[rstest]
5170    #[case::xhtml_root(
5171        "<?xml version=\"1.0\"?>\n<html xmlns=\"http://www.w3.org/1999/xhtml\"><body/></html>"
5172    )]
5173    #[case::element_with_an_svg_prefix("<?xml version=\"1.0\"?>\n<svgfoo/>")]
5174    #[case::prolog_with_no_root("<?xml version=\"1.0\"?>\n<!-- only a comment -->")]
5175    #[case::unterminated_declaration("<?xml version=\"1.0\"\n<svg/>")]
5176    #[case::unterminated_comment("<!-- never closed\n<svg/>")]
5177    #[case::unterminated_internal_subset("<!DOCTYPE svg [<!ENTITY a \"b\">\n<svg/>")]
5178    fn sniff_artifact_does_not_claim_non_svg_documents(#[case] document: &str) {
5179        let result = sniff_artifact(RawArtifact::new(document.as_bytes().to_vec(), None));
5180        assert!(
5181            result.is_err(),
5182            "should not be claimed as SVG: {document:?} produced {result:?}"
5183        );
5184    }
5185
5186    #[test]
5187    fn sniff_artifact_rejects_invalid_png_structure() {
5188        let err = sniff_artifact(RawArtifact::new(b"\x89PNG\r\n\x1a\nbroken".to_vec(), None))
5189            .expect_err("broken png should fail");
5190
5191        assert_eq!(
5192            err,
5193            TransformError::DecodeFailed("png file is too short".to_string())
5194        );
5195    }
5196
5197    #[test]
5198    fn sniff_artifact_detects_bmp_dimensions() {
5199        // Build a minimal BMP with BITMAPINFOHEADER (40 bytes DIB header).
5200        // File header: 14 bytes, DIB header: 40 bytes minimum.
5201        let mut bmp = Vec::new();
5202        // BM signature
5203        bmp.extend_from_slice(b"BM");
5204        // File size (placeholder)
5205        bmp.extend_from_slice(&0u32.to_le_bytes());
5206        // Reserved
5207        bmp.extend_from_slice(&0u32.to_le_bytes());
5208        // Pixel data offset (14 + 40 = 54)
5209        bmp.extend_from_slice(&54u32.to_le_bytes());
5210        // DIB header size (BITMAPINFOHEADER = 40)
5211        bmp.extend_from_slice(&40u32.to_le_bytes());
5212        // Width = 8
5213        bmp.extend_from_slice(&8u32.to_le_bytes());
5214        // Height = 6
5215        bmp.extend_from_slice(&6i32.to_le_bytes());
5216        // Planes = 1
5217        bmp.extend_from_slice(&1u16.to_le_bytes());
5218        // Bits per pixel = 24
5219        bmp.extend_from_slice(&24u16.to_le_bytes());
5220        // Padding to reach minimum sniff length
5221        bmp.resize(54, 0);
5222
5223        let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
5224        assert_eq!(artifact.media_type, MediaType::Bmp);
5225        assert_eq!(artifact.metadata.width, Some(8));
5226        assert_eq!(artifact.metadata.height, Some(6));
5227        assert_eq!(artifact.metadata.has_alpha, Some(false));
5228    }
5229
5230    #[test]
5231    fn sniff_artifact_detects_bmp_32bit_alpha() {
5232        let mut bmp = Vec::new();
5233        bmp.extend_from_slice(b"BM");
5234        bmp.extend_from_slice(&0u32.to_le_bytes());
5235        bmp.extend_from_slice(&0u32.to_le_bytes());
5236        bmp.extend_from_slice(&54u32.to_le_bytes());
5237        bmp.extend_from_slice(&40u32.to_le_bytes());
5238        // Width = 4
5239        bmp.extend_from_slice(&4u32.to_le_bytes());
5240        // Height = 4
5241        bmp.extend_from_slice(&4i32.to_le_bytes());
5242        // Planes = 1
5243        bmp.extend_from_slice(&1u16.to_le_bytes());
5244        // Bits per pixel = 32 (has alpha)
5245        bmp.extend_from_slice(&32u16.to_le_bytes());
5246        bmp.resize(54, 0);
5247
5248        let artifact = sniff_artifact(RawArtifact::new(bmp, None)).unwrap();
5249        assert_eq!(artifact.media_type, MediaType::Bmp);
5250        assert_eq!(artifact.metadata.has_alpha, Some(true));
5251    }
5252
5253    #[test]
5254    fn sniff_artifact_rejects_too_short_bmp() {
5255        // "BM" + enough padding to pass is_bmp (>= 26 bytes) but not sniff_bmp (>= 30)
5256        let mut data = b"BM".to_vec();
5257        data.resize(27, 0);
5258        let err =
5259            sniff_artifact(RawArtifact::new(data, None)).expect_err("too-short BMP should fail");
5260
5261        assert_eq!(
5262            err,
5263            TransformError::DecodeFailed("bmp file is too short".to_string())
5264        );
5265    }
5266
5267    #[test]
5268    fn normalize_rejects_blur_sigma_below_minimum() {
5269        let err = TransformOptions {
5270            blur: Some(0.0),
5271            ..TransformOptions::default()
5272        }
5273        .normalize(MediaType::Jpeg)
5274        .expect_err("blur sigma 0.0 should be rejected");
5275
5276        assert_eq!(
5277            err,
5278            TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
5279        );
5280    }
5281
5282    #[test]
5283    fn normalize_rejects_blur_sigma_above_maximum() {
5284        let err = TransformOptions {
5285            blur: Some(100.1),
5286            ..TransformOptions::default()
5287        }
5288        .normalize(MediaType::Jpeg)
5289        .expect_err("blur sigma 100.1 should be rejected");
5290
5291        assert_eq!(
5292            err,
5293            TransformError::InvalidOptions("blur sigma must be between 0.1 and 100.0".to_string())
5294        );
5295    }
5296
5297    #[test]
5298    fn normalize_accepts_blur_sigma_at_boundaries() {
5299        let opts_min = TransformOptions {
5300            blur: Some(0.1),
5301            ..TransformOptions::default()
5302        }
5303        .normalize(MediaType::Jpeg)
5304        .expect("blur sigma 0.1 should be accepted");
5305        assert_eq!(opts_min.blur, Some(0.1));
5306
5307        let opts_max = TransformOptions {
5308            blur: Some(100.0),
5309            ..TransformOptions::default()
5310        }
5311        .normalize(MediaType::Jpeg)
5312        .expect("blur sigma 100.0 should be accepted");
5313        assert_eq!(opts_max.blur, Some(100.0));
5314    }
5315
5316    #[test]
5317    fn normalize_rejects_sharpen_sigma_below_minimum() {
5318        let err = TransformOptions {
5319            sharpen: Some(0.0),
5320            ..TransformOptions::default()
5321        }
5322        .normalize(MediaType::Jpeg)
5323        .expect_err("sharpen sigma 0.0 should be rejected");
5324
5325        assert_eq!(
5326            err,
5327            TransformError::InvalidOptions(
5328                "sharpen sigma must be between 0.1 and 100.0".to_string()
5329            )
5330        );
5331    }
5332
5333    #[test]
5334    fn normalize_rejects_sharpen_sigma_above_maximum() {
5335        let err = TransformOptions {
5336            sharpen: Some(100.1),
5337            ..TransformOptions::default()
5338        }
5339        .normalize(MediaType::Jpeg)
5340        .expect_err("sharpen sigma 100.1 should be rejected");
5341
5342        assert_eq!(
5343            err,
5344            TransformError::InvalidOptions(
5345                "sharpen sigma must be between 0.1 and 100.0".to_string()
5346            )
5347        );
5348    }
5349
5350    #[test]
5351    fn normalize_accepts_sharpen_sigma_at_boundaries() {
5352        let opts_min = TransformOptions {
5353            sharpen: Some(0.1),
5354            ..TransformOptions::default()
5355        }
5356        .normalize(MediaType::Jpeg)
5357        .expect("sharpen sigma 0.1 should be accepted");
5358        assert_eq!(opts_min.sharpen, Some(0.1));
5359
5360        let opts_max = TransformOptions {
5361            sharpen: Some(100.0),
5362            ..TransformOptions::default()
5363        }
5364        .normalize(MediaType::Jpeg)
5365        .expect("sharpen sigma 100.0 should be accepted");
5366        assert_eq!(opts_max.sharpen, Some(100.0));
5367    }
5368
5369    #[test]
5370    fn validate_watermark_rejects_zero_opacity() {
5371        let wm = super::WatermarkInput {
5372            image: jpeg_artifact(),
5373            position: Position::BottomRight,
5374            opacity: 0,
5375            margin: 10,
5376        };
5377        let err = super::validate_watermark(&wm).expect_err("opacity 0 should be rejected");
5378        assert_eq!(
5379            err,
5380            TransformError::InvalidOptions(
5381                "watermark opacity must be between 1 and 100".to_string()
5382            )
5383        );
5384    }
5385
5386    #[test]
5387    fn validate_watermark_rejects_opacity_above_100() {
5388        let wm = super::WatermarkInput {
5389            image: jpeg_artifact(),
5390            position: Position::BottomRight,
5391            opacity: 101,
5392            margin: 10,
5393        };
5394        let err = super::validate_watermark(&wm).expect_err("opacity 101 should be rejected");
5395        assert_eq!(
5396            err,
5397            TransformError::InvalidOptions(
5398                "watermark opacity must be between 1 and 100".to_string()
5399            )
5400        );
5401    }
5402
5403    #[test]
5404    fn validate_watermark_rejects_svg_image() {
5405        let wm = super::WatermarkInput {
5406            image: Artifact::new(vec![1], MediaType::Svg, ArtifactMetadata::default()),
5407            position: Position::BottomRight,
5408            opacity: 50,
5409            margin: 10,
5410        };
5411        let err = super::validate_watermark(&wm).expect_err("SVG watermark should be rejected");
5412        assert_eq!(
5413            err,
5414            TransformError::InvalidOptions("watermark image must be a raster format".to_string())
5415        );
5416    }
5417
5418    #[test]
5419    fn validate_watermark_accepts_valid_input() {
5420        let wm = super::WatermarkInput {
5421            image: jpeg_artifact(),
5422            position: Position::BottomRight,
5423            opacity: 50,
5424            margin: 10,
5425        };
5426        super::validate_watermark(&wm).expect("valid watermark should be accepted");
5427    }
5428
5429    #[test]
5430    fn crop_region_from_str_valid() {
5431        use super::CropRegion;
5432        let crop: CropRegion = "10,20,100,200".parse().expect("valid crop");
5433        assert_eq!(crop.x, 10);
5434        assert_eq!(crop.y, 20);
5435        assert_eq!(crop.width, 100);
5436        assert_eq!(crop.height, 200);
5437    }
5438
5439    #[test]
5440    fn crop_region_from_str_zero_width() {
5441        use super::CropRegion;
5442        let err = "10,20,0,200"
5443            .parse::<CropRegion>()
5444            .expect_err("zero width should fail");
5445        assert!(err.contains("greater than zero"), "unexpected error: {err}");
5446    }
5447
5448    #[test]
5449    fn crop_region_from_str_wrong_parts() {
5450        use super::CropRegion;
5451        let err = "10,20,100"
5452            .parse::<CropRegion>()
5453            .expect_err("three parts should fail");
5454        assert!(
5455            err.contains("four comma-separated"),
5456            "unexpected error: {err}"
5457        );
5458    }
5459
5460    #[test]
5461    fn crop_region_display() {
5462        use super::CropRegion;
5463        let crop = CropRegion {
5464            x: 1,
5465            y: 2,
5466            width: 3,
5467            height: 4,
5468        };
5469        assert_eq!(crop.to_string(), "1,2,3,4");
5470    }
5471
5472    #[test]
5473    fn normalize_rejects_zero_dimension_crop() {
5474        use super::{CropRegion, MediaType, TransformOptions};
5475        let opts = TransformOptions {
5476            crop: Some(CropRegion {
5477                x: 0,
5478                y: 0,
5479                width: 0,
5480                height: 100,
5481            }),
5482            ..TransformOptions::default()
5483        };
5484        let err = opts
5485            .normalize(MediaType::Jpeg)
5486            .expect_err("zero-width crop should fail");
5487        assert!(
5488            matches!(err, super::TransformError::InvalidOptions(_)),
5489            "unexpected error: {err:?}"
5490        );
5491    }
5492}