Skip to main content

autumn_web/storage/
variant.rs

1//! On-demand image variants for stored blobs.
2//!
3//! Variants are lazily generated on first request, stored content-addressably
4//! in the same [`BlobStore`] as the source, and served with a strong `ETag` and
5//! far-future `Cache-Control: immutable` header from that point on.
6//!
7//! ## Quick start
8//!
9//! ```rust,ignore
10//! use autumn_web::storage::{BlobStoreState, variant::{Transform, VariantBudget}};
11//!
12//! // In your route handler:
13//! let store = blobs.store();
14//! let budget = VariantBudget::default();
15//! let handle = user.avatar.variant("thumb", &[Transform::resize_to_limit(200, 200)]);
16//! let url = handle.url(&**store, &budget, Duration::from_secs(3600)).await?;
17//! ```
18//!
19//! ## Content addressing
20//!
21//! The variant key is
22//! `SHA-256(source_key + NUL + etag + NUL + content_type + NUL + JSON(transforms))`
23//! encoded under `_variants/{h0}{h1}/{h2}{h3}/{hash}`.  The same
24//! `(source content, transforms)` pair always maps to the same key; re-uploading
25//! to the same storage key (new `ETag`) or correcting a mis-tagged MIME type
26//! both produce a fresh variant key so stale thumbnails are never served.
27//!
28//! ## Budget
29//!
30//! Configurable via `[storage.variants]` in `autumn.toml`; the [`VariantBudget`]
31//! default caps source blobs at 20 MiB and 10 000 × 10 000 px to prevent
32//! runaway memory allocation.
33
34use std::io::Cursor;
35use std::time::Duration;
36
37use bytes::Bytes;
38use sha2::{Digest, Sha256};
39
40use super::{Blob, BlobStore, BlobStoreError};
41
42// ── Public types ─────────────────────────────────────────────────────────────
43
44/// A single transform applied to the source image.
45///
46/// Transforms are applied in order and serialised to JSON for content
47/// addressing — the order matters for both the visual output and the
48/// cache key.
49#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
50#[non_exhaustive]
51pub enum Transform {
52    /// Resize to fit within `width × height`, preserving aspect ratio.
53    /// Never upscales: an image smaller than the limit is stored as-is.
54    ResizeToLimit {
55        /// Maximum output width in pixels.
56        width: u32,
57        /// Maximum output height in pixels.
58        height: u32,
59    },
60    /// Resize and crop to exactly `width × height`, centred.
61    ResizeToFill {
62        /// Exact output width in pixels.
63        width: u32,
64        /// Exact output height in pixels.
65        height: u32,
66    },
67    /// Rotate clockwise by the given number of degrees.
68    /// Values outside {90, 180, 270} are treated as no-op rotations.
69    Rotate {
70        /// Clockwise rotation in degrees (0, 90, 180, or 270).
71        degrees: u16,
72    },
73    /// Strip all embedded metadata (EXIF, GPS, ICC profiles, comment chunks).
74    ///
75    /// Re-encoding through the `image` crate already drops EXIF data; this
76    /// variant is a no-op on pixel data but explicitly documents the intent so
77    /// the transform spec is unambiguous.
78    StripMetadata,
79}
80
81impl Transform {
82    /// Shorthand constructor for [`Transform::ResizeToLimit`].
83    #[must_use]
84    pub const fn resize_to_limit(width: u32, height: u32) -> Self {
85        Self::ResizeToLimit { width, height }
86    }
87
88    /// Shorthand constructor for [`Transform::ResizeToFill`].
89    #[must_use]
90    pub const fn resize_to_fill(width: u32, height: u32) -> Self {
91        Self::ResizeToFill { width, height }
92    }
93
94    /// Shorthand constructor for [`Transform::Rotate`].
95    #[must_use]
96    pub const fn rotate(degrees: u16) -> Self {
97        Self::Rotate { degrees }
98    }
99
100    /// Shorthand constructor for [`Transform::StripMetadata`].
101    #[must_use]
102    pub const fn strip_metadata() -> Self {
103        Self::StripMetadata
104    }
105}
106
107/// Resource limits for variant generation.
108///
109/// Set via `[storage.variants]` in `autumn.toml`; the defaults are
110/// deliberately conservative to prevent runaway memory allocation from
111/// pathologically large source images.
112#[derive(Debug, Clone)]
113pub struct VariantBudget {
114    /// Maximum byte size of the source blob. Default: 20 MiB.
115    pub max_source_bytes: u64,
116    /// Maximum pixel width of the source image. Default: 10 000.
117    pub max_source_width: u32,
118    /// Maximum pixel height of the source image. Default: 10 000.
119    pub max_source_height: u32,
120}
121
122impl Default for VariantBudget {
123    fn default() -> Self {
124        Self {
125            max_source_bytes: 20 * 1024 * 1024, // 20 MiB
126            max_source_width: 10_000,
127            max_source_height: 10_000,
128        }
129    }
130}
131
132/// Errors returned by variant operations.
133#[derive(Debug, thiserror::Error)]
134#[non_exhaustive]
135pub enum VariantError {
136    /// The source blob's content type is not a supported image format.
137    /// Only `image/jpeg`, `image/png`, and `image/webp` are accepted.
138    #[error("unsupported MIME type for image variant: {0}")]
139    UnsupportedMimeType(String),
140
141    /// The source blob exceeds [`VariantBudget::max_source_bytes`].
142    #[error(
143        "source blob too large for variant generation: {byte_size} bytes \
144         (budget: {max_bytes} bytes)"
145    )]
146    SourceTooLarge { byte_size: u64, max_bytes: u64 },
147
148    /// The decoded source image exceeds the pixel budget.
149    #[error(
150        "source image dimensions too large: {width}×{height} px \
151         (budget: {max_width}×{max_height} px)"
152    )]
153    SourceDimensionsTooLarge {
154        /// Actual source width.
155        width: u32,
156        /// Actual source height.
157        height: u32,
158        /// Configured maximum width.
159        max_width: u32,
160        /// Configured maximum height.
161        max_height: u32,
162    },
163
164    /// The source bytes could not be decoded as the claimed image format.
165    #[error("image decode/encode error: {0}")]
166    DecodeError(String),
167
168    /// A storage operation failed while checking for or writing the variant.
169    #[error(transparent)]
170    Storage(#[from] BlobStoreError),
171}
172
173impl VariantError {
174    /// HTTP status code that best represents this error.
175    ///
176    /// Routes using `?` on [`VariantHandle::url`] or
177    /// [`VariantHandle::ensure_generated`] get 500 by default via the blanket
178    /// `impl From<E: Error> for AutumnError`; call
179    /// [`VariantError::into_autumn_error`] instead to preserve the precise
180    /// status.
181    #[must_use]
182    pub const fn status(&self) -> http::StatusCode {
183        match self {
184            Self::UnsupportedMimeType(_) | Self::DecodeError(_) => {
185                http::StatusCode::UNPROCESSABLE_ENTITY
186            }
187            Self::SourceTooLarge { .. } | Self::SourceDimensionsTooLarge { .. } => {
188                http::StatusCode::PAYLOAD_TOO_LARGE
189            }
190            Self::Storage(e) => e.status(),
191        }
192    }
193
194    /// Promote into an [`AutumnError`](crate::AutumnError) carrying the
195    /// status from [`VariantError::status`].
196    ///
197    /// Use this instead of `?` when the route needs accurate HTTP status codes
198    /// for variant failures (e.g. 422 for a corrupt image, 413 for an
199    /// oversized source):
200    ///
201    /// ```rust,ignore
202    /// let url = handle.url(&*store, &budget, expires)
203    ///     .await
204    ///     .map_err(VariantError::into_autumn_error)?;
205    /// ```
206    #[must_use]
207    pub fn into_autumn_error(self) -> crate::AutumnError {
208        let status = self.status();
209        crate::AutumnError::internal_server_error(self).with_status(status)
210    }
211}
212
213/// Handle for a named, lazily-generated image variant.
214///
215/// Created by [`Blob::variant`]; call [`VariantHandle::url`] to generate
216/// the variant on first access and obtain a presigned serving URL.
217#[derive(Debug, Clone)]
218pub struct VariantHandle {
219    source: Blob,
220    name: String,
221    transforms: Vec<Transform>,
222    /// Content-addressed storage key for this variant.
223    variant_key: String,
224}
225
226impl VariantHandle {
227    /// The content-addressed key under which this variant is stored.
228    #[must_use]
229    pub fn key(&self) -> &str {
230        &self.variant_key
231    }
232
233    /// The human-readable label for this variant (does not affect caching).
234    #[must_use]
235    pub fn name(&self) -> &str {
236        &self.name
237    }
238
239    /// The transforms applied to the source image.
240    #[must_use]
241    pub fn transforms(&self) -> &[Transform] {
242        &self.transforms
243    }
244
245    /// Ensure the variant is generated, then return a presigned serving URL.
246    ///
247    /// On the first call the source image is fetched, transformed, and
248    /// persisted under the content-addressed key.  Subsequent calls skip
249    /// generation (`head` cache hit) and call [`BlobStore::presigned_url`]
250    /// directly.
251    ///
252    /// The returned URL is identical in form to any other presigned URL from
253    /// the backend: a route-served HMAC-signed URL for the Local backend and
254    /// a real S3 presigned URL for S3 backends.
255    ///
256    /// # Errors
257    ///
258    /// Returns [`VariantError`] when the source MIME type is unsupported,
259    /// the source exceeds the budget, decoding fails, or a storage operation
260    /// fails.
261    pub async fn url(
262        &self,
263        store: &dyn BlobStore,
264        budget: &VariantBudget,
265        expires_in: Duration,
266    ) -> Result<String, VariantError> {
267        self.ensure_generated(store, budget).await?;
268        Ok(store.presigned_url(&self.variant_key, expires_in).await?)
269    }
270
271    /// Ensure the variant blob exists in the store, returning its handle.
272    ///
273    /// Idempotent: if the variant is already cached this is a single `head`
274    /// call with no image processing.
275    ///
276    /// # Errors
277    ///
278    /// Returns [`VariantError`] on generation or storage failure.
279    pub async fn ensure_generated(
280        &self,
281        store: &dyn BlobStore,
282        budget: &VariantBudget,
283    ) -> Result<Blob, VariantError> {
284        // Fast path: variant already in store.
285        if let Some(meta) = store.head(&self.variant_key).await? {
286            return Ok(Blob {
287                provider_id: store.provider_id().to_owned(),
288                key: self.variant_key.clone(),
289                content_type: meta.content_type,
290                byte_size: meta.byte_size,
291                etag: meta.etag,
292            });
293        }
294        self.generate(store, budget).await
295    }
296
297    /// Fetch the source, decode, transform, encode, and persist.
298    async fn generate(
299        &self,
300        store: &dyn BlobStore,
301        budget: &VariantBudget,
302    ) -> Result<Blob, VariantError> {
303        // Guard byte size before we even fetch — avoids streaming a huge blob
304        // into memory just to reject it.
305        if self.source.byte_size > budget.max_source_bytes {
306            return Err(VariantError::SourceTooLarge {
307                byte_size: self.source.byte_size,
308                max_bytes: budget.max_source_bytes,
309            });
310        }
311
312        // Reject non-image MIME types before touching the store.
313        check_image_mime_type(&self.source.content_type)?;
314
315        let source_bytes = store.get(&self.source.key).await?;
316
317        // Secondary guard on the real byte count — the Blob metadata could be
318        // stale or crafted; check the actual payload length too.
319        if source_bytes.len() as u64 > budget.max_source_bytes {
320            return Err(VariantError::SourceTooLarge {
321                byte_size: source_bytes.len() as u64,
322                max_bytes: budget.max_source_bytes,
323            });
324        }
325
326        // Offload CPU-bound decode/transform/encode to a blocking thread so
327        // Tokio worker threads are not stalled during image processing.
328        let transforms = self.transforms.clone();
329        let content_type = self.source.content_type.clone();
330        let max_width = budget.max_source_width;
331        let max_height = budget.max_source_height;
332
333        let (output_bytes, output_content_type) = tokio::task::spawn_blocking(
334            move || -> Result<(Vec<u8>, &'static str), VariantError> {
335                // Use decoder limits to guard against image bombs: a compressed
336                // file under `max_source_bytes` can still expand to gigabytes of
337                // pixel data without this guard.
338                let mut reader = image::ImageReader::new(Cursor::new(&source_bytes[..]))
339                    .with_guessed_format()
340                    .map_err(|e| VariantError::DecodeError(e.to_string()))?;
341                // image::Limits is #[non_exhaustive]; build via Default + field mutation.
342                let mut limits = image::Limits::default();
343                limits.max_image_width = Some(max_width);
344                limits.max_image_height = Some(max_height);
345                reader.limits(limits);
346                let img = reader.decode().map_err(|e| match &e {
347                    image::ImageError::Limits(_) => VariantError::SourceDimensionsTooLarge {
348                        width: 0,
349                        height: 0,
350                        max_width,
351                        max_height,
352                    },
353                    _ => VariantError::DecodeError(e.to_string()),
354                })?;
355
356                // Belt-and-suspenders: if the decoder didn't enforce limits,
357                // catch oversized images here where we know the actual size.
358                if img.width() > max_width || img.height() > max_height {
359                    return Err(VariantError::SourceDimensionsTooLarge {
360                        width: img.width(),
361                        height: img.height(),
362                        max_width,
363                        max_height,
364                    });
365                }
366
367                let transformed = apply_transforms(img, &transforms);
368                let (output_format, output_content_type) = output_format_and_mime(&content_type);
369                let output_bytes = encode_image(&transformed, output_format)?;
370                Ok((output_bytes, output_content_type))
371            },
372        )
373        .await
374        .map_err(|e| VariantError::DecodeError(format!("variant worker panicked: {e}")))??;
375
376        let blob = store
377            .put(
378                &self.variant_key,
379                output_content_type,
380                Bytes::from(output_bytes),
381            )
382            .await?;
383
384        Ok(blob)
385    }
386}
387
388// ── `Blob` extension ─────────────────────────────────────────────────────────
389
390impl Blob {
391    /// Create a [`VariantHandle`] for the given `name` and `transforms`.
392    ///
393    /// The content-addressed storage key is derived from
394    /// `SHA-256(source_key || NUL || JSON(transforms))` so identical
395    /// transform specs always map to the same cached artifact — the
396    /// human-readable `name` is stored on the handle but does not affect
397    /// the cache key.
398    ///
399    /// # Example
400    ///
401    /// ```rust,ignore
402    /// use autumn_web::storage::variant::{Transform, VariantBudget};
403    ///
404    /// let handle = user.avatar.variant("thumb", &[Transform::resize_to_limit(200, 200)]);
405    /// let url = handle.url(&*store, &budget, Duration::from_secs(3600)).await?;
406    /// ```
407    #[must_use]
408    pub fn variant(&self, name: &str, transforms: &[Transform]) -> VariantHandle {
409        let key = content_addressed_key(self, transforms);
410        VariantHandle {
411            source: self.clone(),
412            name: name.to_owned(),
413            transforms: transforms.to_vec(),
414            variant_key: key,
415        }
416    }
417}
418
419// ── Internal helpers ──────────────────────────────────────────────────────────
420
421/// Return an error when the content type is not a supported image format.
422pub(crate) fn check_image_mime_type(content_type: &str) -> Result<(), VariantError> {
423    let base = content_type
424        .split(';')
425        .next()
426        .unwrap_or(content_type)
427        .trim();
428    if base.eq_ignore_ascii_case("image/jpeg")
429        || base.eq_ignore_ascii_case("image/jpg")
430        || base.eq_ignore_ascii_case("image/png")
431        || base.eq_ignore_ascii_case("image/webp")
432    {
433        Ok(())
434    } else {
435        Err(VariantError::UnsupportedMimeType(base.to_owned()))
436    }
437}
438
439/// Derive the content-addressed storage key for a given source blob and
440/// transform spec.
441///
442/// Format: `_variants/{h[0..2]}/{h[2..4]}/{h}` where `h` is the lowercase
443/// hex SHA-256 of
444/// `source_key + NUL + etag + NUL + content_type + NUL + JSON(transforms)`.
445///
446/// Including `etag` ensures re-uploading to the same key invalidates cached
447/// variants.  Including `content_type` ensures that correcting a mis-tagged
448/// MIME type (PNG stored as `image/webp`) also produces a fresh variant key
449/// — the two produce different output formats, so the same bytes should not
450/// share a cached result.
451pub(crate) fn content_addressed_key(source_blob: &Blob, transforms: &[Transform]) -> String {
452    let spec = serde_json::to_string(transforms).expect("Transform is always serialisable");
453    let mut hasher = Sha256::new();
454    hasher.update(source_blob.key.as_bytes());
455    hasher.update(b"\0");
456    if let Some(etag) = &source_blob.etag {
457        hasher.update(etag.as_bytes());
458    }
459    hasher.update(b"\0");
460    hasher.update(source_blob.content_type.as_bytes());
461    hasher.update(b"\0");
462    hasher.update(spec.as_bytes());
463    let hash = hasher.finalize();
464    let hash_hex = hex::encode(hash);
465    format!(
466        "_variants/{}/{}/{}",
467        &hash_hex[..2],
468        &hash_hex[2..4],
469        hash_hex
470    )
471}
472
473/// Determine the output image format and its MIME type from the source MIME.
474///
475/// - JPEG sources → JPEG output (`image/jpeg`)
476/// - PNG sources  → PNG output  (`image/png`)
477/// - WebP sources → JPEG output (`image/jpeg`) — WebP encode path is kept
478///   simple by transcoding; lossless round-trip is not a requirement here
479fn output_format_and_mime(content_type: &str) -> (image::ImageFormat, &'static str) {
480    let base = content_type
481        .split(';')
482        .next()
483        .unwrap_or(content_type)
484        .trim();
485    if base.eq_ignore_ascii_case("image/png") {
486        (image::ImageFormat::Png, "image/png")
487    } else {
488        (image::ImageFormat::Jpeg, "image/jpeg")
489    }
490}
491
492/// Apply each transform in order to the image.
493fn apply_transforms(mut img: image::DynamicImage, transforms: &[Transform]) -> image::DynamicImage {
494    use image::imageops::FilterType;
495
496    for transform in transforms {
497        img = match transform {
498            Transform::ResizeToLimit { width, height } => {
499                // Never upscale: if the image already fits, return as-is.
500                if img.width() <= *width && img.height() <= *height {
501                    img
502                } else {
503                    img.resize(*width, *height, FilterType::Lanczos3)
504                }
505            }
506            Transform::ResizeToFill { width, height } => {
507                img.resize_to_fill(*width, *height, FilterType::Lanczos3)
508            }
509            Transform::Rotate { degrees } => match degrees % 360 {
510                90 => img.rotate90(),
511                180 => img.rotate180(),
512                270 => img.rotate270(),
513                _ => img,
514            },
515            // Re-encoding through the `image` crate already drops EXIF; this
516            // variant documents intent without additional pixel mutation.
517            Transform::StripMetadata => img,
518        };
519    }
520    img
521}
522
523/// Encode `img` to bytes using `format`.
524fn encode_image(
525    img: &image::DynamicImage,
526    format: image::ImageFormat,
527) -> Result<Vec<u8>, VariantError> {
528    let mut buf = Cursor::new(Vec::new());
529    img.write_to(&mut buf, format)
530        .map_err(|e| VariantError::DecodeError(e.to_string()))?;
531    Ok(buf.into_inner())
532}
533
534// ── Tests ─────────────────────────────────────────────────────────────────────
535//
536// TDD phases:
537//   RED   – test bodies written first; without the implementation above they
538//           would not compile / would panic.
539//   GREEN – the implementation above makes all tests pass.
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use crate::storage::local::{LocalBlobStore, SigningKey};
545    use std::path::Path;
546    use std::time::Duration;
547
548    // ── test helpers ────────────────────────────────────────────────────
549
550    fn test_store(root: &Path) -> LocalBlobStore {
551        LocalBlobStore::new(
552            "test",
553            root.to_path_buf(),
554            "/_blobs",
555            Duration::from_secs(60),
556            SigningKey::new(b"test-variant-key".to_vec()),
557            vec![],
558        )
559        .unwrap()
560    }
561
562    /// Generate a solid-colour test image in the given format.
563    fn make_test_image(width: u32, height: u32, format: image::ImageFormat) -> Vec<u8> {
564        let img = image::DynamicImage::ImageRgb8(image::RgbImage::new(width, height));
565        let mut buf = Cursor::new(Vec::new());
566        img.write_to(&mut buf, format).unwrap();
567        buf.into_inner()
568    }
569
570    // ── RED: content addressing ──────────────────────────────────────────────
571
572    #[test]
573    fn transform_serialisation_is_stable() {
574        let t = Transform::resize_to_limit(200, 200);
575        let j1 = serde_json::to_string(&t).unwrap();
576        let j2 = serde_json::to_string(&t).unwrap();
577        assert_eq!(j1, j2, "serialisation must be deterministic");
578    }
579
580    #[test]
581    fn same_spec_produces_same_key() {
582        let blob = Blob::new("local", "avatars/1.png", "image/png", 1024);
583        let h1 = blob.variant("thumb", &[Transform::resize_to_limit(200, 200)]);
584        let h2 = blob.variant("thumbnail", &[Transform::resize_to_limit(200, 200)]);
585        assert_eq!(
586            h1.key(),
587            h2.key(),
588            "different names but same spec → same content-addressed key"
589        );
590    }
591
592    #[test]
593    fn different_specs_produce_different_keys() {
594        let blob = Blob::new("local", "avatars/1.png", "image/png", 1024);
595        let h1 = blob.variant("thumb", &[Transform::resize_to_limit(200, 200)]);
596        let h2 = blob.variant("large", &[Transform::resize_to_limit(400, 400)]);
597        assert_ne!(h1.key(), h2.key());
598    }
599
600    #[test]
601    fn different_sources_produce_different_keys() {
602        let b1 = Blob::new("local", "a/1.png", "image/png", 1024);
603        let b2 = Blob::new("local", "a/2.png", "image/png", 1024);
604        let spec = [Transform::resize_to_limit(200, 200)];
605        assert_ne!(b1.variant("t", &spec).key(), b2.variant("t", &spec).key());
606    }
607
608    #[test]
609    fn same_source_key_different_etag_produces_different_keys() {
610        // Simulates a re-upload to the same key (e.g. avatars/{id}.bin):
611        // the new ETag must produce a different variant key so the old
612        // cached thumbnail is not served for the new content.
613        let mut b1 = Blob::new("local", "avatars/1.png", "image/png", 1024);
614        b1.etag = Some("sha256-aabbcc".to_owned());
615        let mut b2 = Blob::new("local", "avatars/1.png", "image/png", 2048);
616        b2.etag = Some("sha256-ddeeff".to_owned());
617        let spec = [Transform::resize_to_limit(100, 100)];
618        assert_ne!(
619            b1.variant("thumb", &spec).key(),
620            b2.variant("thumb", &spec).key(),
621            "different ETags on the same source key must yield different variant keys"
622        );
623    }
624
625    #[test]
626    fn variant_key_passes_blob_validation() {
627        use crate::storage::validate_key;
628        let blob = Blob::new("local", "avatars/1.png", "image/png", 1024);
629        let key = blob
630            .variant("t", &[Transform::resize_to_limit(200, 200)])
631            .key()
632            .to_owned();
633        validate_key(&key).unwrap_or_else(|e| panic!("variant key {key:?} failed validation: {e}"));
634    }
635
636    #[test]
637    fn variant_key_starts_with_variants_prefix() {
638        let blob = Blob::new("local", "avatars/1.png", "image/png", 1024);
639        let key = blob
640            .variant("t", &[Transform::resize_to_limit(200, 200)])
641            .key()
642            .to_owned();
643        assert!(key.starts_with("_variants/"), "key: {key}");
644    }
645
646    // ── RED: MIME-type enforcement ───────────────────────────────────────────
647
648    #[test]
649    fn check_image_mime_type_accepts_jpeg() {
650        check_image_mime_type("image/jpeg").unwrap();
651        check_image_mime_type("image/jpg").unwrap();
652        check_image_mime_type("image/jpeg; charset=utf-8").unwrap();
653    }
654
655    #[test]
656    fn check_image_mime_type_accepts_png() {
657        check_image_mime_type("image/png").unwrap();
658    }
659
660    #[test]
661    fn check_image_mime_type_accepts_webp() {
662        check_image_mime_type("image/webp").unwrap();
663    }
664
665    #[test]
666    fn check_image_mime_type_rejects_pdf() {
667        let err = check_image_mime_type("application/pdf").unwrap_err();
668        assert!(matches!(err, VariantError::UnsupportedMimeType(_)));
669    }
670
671    #[test]
672    fn check_image_mime_type_rejects_video() {
673        let err = check_image_mime_type("video/mp4").unwrap_err();
674        assert!(matches!(err, VariantError::UnsupportedMimeType(_)));
675    }
676
677    #[test]
678    fn check_image_mime_type_rejects_plaintext() {
679        let err = check_image_mime_type("text/plain").unwrap_err();
680        assert!(matches!(err, VariantError::UnsupportedMimeType(_)));
681    }
682
683    #[test]
684    fn check_image_mime_type_rejects_octet_stream() {
685        let err = check_image_mime_type("application/octet-stream").unwrap_err();
686        assert!(matches!(err, VariantError::UnsupportedMimeType(_)));
687    }
688
689    // ── RED: budget enforcement ──────────────────────────────────────────────
690
691    #[tokio::test]
692    async fn variant_rejects_non_image_blob() {
693        let dir = tempfile::tempdir().unwrap();
694        let store = test_store(dir.path());
695        store
696            .put(
697                "doc.pdf",
698                "application/pdf",
699                Bytes::from_static(b"%PDF-1.4"),
700            )
701            .await
702            .unwrap();
703        let blob = Blob::new("test", "doc.pdf", "application/pdf", 8);
704        let handle = blob.variant("thumb", &[Transform::resize_to_limit(200, 200)]);
705        let err = handle
706            .ensure_generated(&store, &VariantBudget::default())
707            .await
708            .unwrap_err();
709        assert!(
710            matches!(err, VariantError::UnsupportedMimeType(_)),
711            "expected UnsupportedMimeType, got {err:?}"
712        );
713    }
714
715    #[tokio::test]
716    async fn variant_rejects_oversized_source_bytes() {
717        let dir = tempfile::tempdir().unwrap();
718        let store = test_store(dir.path());
719        let jpeg = make_test_image(4, 4, image::ImageFormat::Jpeg);
720        let actual_size = jpeg.len() as u64;
721        store
722            .put("img.jpg", "image/jpeg", Bytes::from(jpeg))
723            .await
724            .unwrap();
725        // Budget smaller than the actual stored blob.
726        let budget = VariantBudget {
727            max_source_bytes: actual_size - 1,
728            ..Default::default()
729        };
730        // Lie about byte_size in the Blob so the pre-fetch check fires.
731        let blob = Blob::new("test", "img.jpg", "image/jpeg", actual_size);
732        let err = blob
733            .variant("t", &[Transform::resize_to_limit(2, 2)])
734            .ensure_generated(&store, &budget)
735            .await
736            .unwrap_err();
737        assert!(
738            matches!(err, VariantError::SourceTooLarge { .. }),
739            "expected SourceTooLarge, got {err:?}"
740        );
741    }
742
743    #[tokio::test]
744    async fn variant_rejects_oversized_source_dimensions() {
745        let dir = tempfile::tempdir().unwrap();
746        let store = test_store(dir.path());
747        // 4×4 image.
748        let jpeg = make_test_image(4, 4, image::ImageFormat::Jpeg);
749        store
750            .put("big.jpg", "image/jpeg", Bytes::from(jpeg.clone()))
751            .await
752            .unwrap();
753        let budget = VariantBudget {
754            max_source_width: 3, // less than 4
755            max_source_height: 3,
756            max_source_bytes: jpeg.len() as u64 * 10, // byte budget is fine
757        };
758        let blob = Blob::new("test", "big.jpg", "image/jpeg", jpeg.len() as u64);
759        let err = blob
760            .variant("t", &[Transform::resize_to_limit(2, 2)])
761            .ensure_generated(&store, &budget)
762            .await
763            .unwrap_err();
764        assert!(
765            matches!(err, VariantError::SourceDimensionsTooLarge { .. }),
766            "expected SourceDimensionsTooLarge, got {err:?}"
767        );
768    }
769
770    // ── RED: generation and caching ──────────────────────────────────────────
771
772    #[tokio::test]
773    async fn variant_generates_on_first_call() {
774        let dir = tempfile::tempdir().unwrap();
775        let store = test_store(dir.path());
776        let jpeg = make_test_image(100, 100, image::ImageFormat::Jpeg);
777        store
778            .put("photo.jpg", "image/jpeg", Bytes::from(jpeg.clone()))
779            .await
780            .unwrap();
781        let blob = Blob::new("test", "photo.jpg", "image/jpeg", jpeg.len() as u64);
782        let handle = blob.variant("thumb", &[Transform::resize_to_limit(50, 50)]);
783
784        let variant_blob = handle
785            .ensure_generated(&store, &VariantBudget::default())
786            .await
787            .unwrap();
788        assert_eq!(variant_blob.key, handle.key());
789        assert!(variant_blob.byte_size > 0);
790        assert_eq!(variant_blob.content_type, "image/jpeg");
791    }
792
793    #[tokio::test]
794    async fn variant_is_idempotent_on_second_call() {
795        let dir = tempfile::tempdir().unwrap();
796        let store = test_store(dir.path());
797        let jpeg = make_test_image(60, 60, image::ImageFormat::Jpeg);
798        store
799            .put("avatar.jpg", "image/jpeg", Bytes::from(jpeg.clone()))
800            .await
801            .unwrap();
802        let blob = Blob::new("test", "avatar.jpg", "image/jpeg", jpeg.len() as u64);
803        let handle = blob.variant("thumb", &[Transform::resize_to_limit(30, 30)]);
804        let budget = VariantBudget::default();
805
806        let first = handle.ensure_generated(&store, &budget).await.unwrap();
807        let second = handle.ensure_generated(&store, &budget).await.unwrap();
808        assert_eq!(first.key, second.key);
809        assert_eq!(first.byte_size, second.byte_size);
810    }
811
812    // ── RED: transforms ──────────────────────────────────────────────────────
813
814    #[tokio::test]
815    async fn resize_to_limit_respects_max_dimensions() {
816        let dir = tempfile::tempdir().unwrap();
817        let store = test_store(dir.path());
818        let jpeg = make_test_image(100, 80, image::ImageFormat::Jpeg);
819        store
820            .put("img.jpg", "image/jpeg", Bytes::from(jpeg.clone()))
821            .await
822            .unwrap();
823        let blob = Blob::new("test", "img.jpg", "image/jpeg", jpeg.len() as u64);
824        blob.variant("t", &[Transform::resize_to_limit(50, 50)])
825            .ensure_generated(&store, &VariantBudget::default())
826            .await
827            .unwrap();
828
829        let out_bytes = store
830            .get(
831                blob.variant("t", &[Transform::resize_to_limit(50, 50)])
832                    .key(),
833            )
834            .await
835            .unwrap();
836        let out_img = image::load_from_memory(&out_bytes).unwrap();
837        assert!(
838            out_img.width() <= 50 && out_img.height() <= 50,
839            "expected ≤50×50, got {}×{}",
840            out_img.width(),
841            out_img.height()
842        );
843        assert!(out_img.width() > 0 && out_img.height() > 0);
844    }
845
846    #[tokio::test]
847    async fn resize_to_limit_does_not_upscale() {
848        let dir = tempfile::tempdir().unwrap();
849        let store = test_store(dir.path());
850        // Source is already smaller than the limit.
851        let jpeg = make_test_image(20, 20, image::ImageFormat::Jpeg);
852        store
853            .put("small.jpg", "image/jpeg", Bytes::from(jpeg.clone()))
854            .await
855            .unwrap();
856        let blob = Blob::new("test", "small.jpg", "image/jpeg", jpeg.len() as u64);
857        blob.variant("large", &[Transform::resize_to_limit(200, 200)])
858            .ensure_generated(&store, &VariantBudget::default())
859            .await
860            .unwrap();
861
862        let out_bytes = store
863            .get(
864                blob.variant("large", &[Transform::resize_to_limit(200, 200)])
865                    .key(),
866            )
867            .await
868            .unwrap();
869        let out_img = image::load_from_memory(&out_bytes).unwrap();
870        assert!(
871            out_img.width() <= 20 && out_img.height() <= 20,
872            "must not upscale: got {}×{}",
873            out_img.width(),
874            out_img.height()
875        );
876    }
877
878    #[tokio::test]
879    async fn resize_to_fill_produces_exact_dimensions() {
880        let dir = tempfile::tempdir().unwrap();
881        let store = test_store(dir.path());
882        let jpeg = make_test_image(100, 150, image::ImageFormat::Jpeg);
883        store
884            .put("portrait.jpg", "image/jpeg", Bytes::from(jpeg.clone()))
885            .await
886            .unwrap();
887        let blob = Blob::new("test", "portrait.jpg", "image/jpeg", jpeg.len() as u64);
888        blob.variant("square", &[Transform::resize_to_fill(50, 50)])
889            .ensure_generated(&store, &VariantBudget::default())
890            .await
891            .unwrap();
892
893        let out_bytes = store
894            .get(
895                blob.variant("square", &[Transform::resize_to_fill(50, 50)])
896                    .key(),
897            )
898            .await
899            .unwrap();
900        let out_img = image::load_from_memory(&out_bytes).unwrap();
901        assert_eq!(
902            (out_img.width(), out_img.height()),
903            (50, 50),
904            "resize_to_fill must produce exact dimensions"
905        );
906    }
907
908    #[tokio::test]
909    async fn rotate_90_swaps_dimensions() {
910        let dir = tempfile::tempdir().unwrap();
911        let store = test_store(dir.path());
912        // 100×50 landscape image.
913        let jpeg = make_test_image(100, 50, image::ImageFormat::Jpeg);
914        store
915            .put("land.jpg", "image/jpeg", Bytes::from(jpeg.clone()))
916            .await
917            .unwrap();
918        let blob = Blob::new("test", "land.jpg", "image/jpeg", jpeg.len() as u64);
919        blob.variant("rotated", &[Transform::rotate(90)])
920            .ensure_generated(&store, &VariantBudget::default())
921            .await
922            .unwrap();
923
924        let out_bytes = store
925            .get(blob.variant("rotated", &[Transform::rotate(90)]).key())
926            .await
927            .unwrap();
928        let out_img = image::load_from_memory(&out_bytes).unwrap();
929        assert_eq!(
930            (out_img.width(), out_img.height()),
931            (50, 100),
932            "90° rotation must swap width and height"
933        );
934    }
935
936    #[tokio::test]
937    async fn rotate_180_preserves_dimensions() {
938        let dir = tempfile::tempdir().unwrap();
939        let store = test_store(dir.path());
940        let jpeg = make_test_image(100, 60, image::ImageFormat::Jpeg);
941        store
942            .put("img.jpg", "image/jpeg", Bytes::from(jpeg.clone()))
943            .await
944            .unwrap();
945        let blob = Blob::new("test", "img.jpg", "image/jpeg", jpeg.len() as u64);
946        blob.variant("r180", &[Transform::rotate(180)])
947            .ensure_generated(&store, &VariantBudget::default())
948            .await
949            .unwrap();
950
951        let out_bytes = store
952            .get(blob.variant("r180", &[Transform::rotate(180)]).key())
953            .await
954            .unwrap();
955        let out_img = image::load_from_memory(&out_bytes).unwrap();
956        assert_eq!(
957            (out_img.width(), out_img.height()),
958            (100, 60),
959            "180° rotation must preserve dimensions"
960        );
961    }
962
963    #[tokio::test]
964    async fn strip_metadata_produces_valid_image() {
965        let dir = tempfile::tempdir().unwrap();
966        let store = test_store(dir.path());
967        let jpeg = make_test_image(10, 10, image::ImageFormat::Jpeg);
968        store
969            .put("meta.jpg", "image/jpeg", Bytes::from(jpeg.clone()))
970            .await
971            .unwrap();
972        let blob = Blob::new("test", "meta.jpg", "image/jpeg", jpeg.len() as u64);
973        blob.variant("stripped", &[Transform::strip_metadata()])
974            .ensure_generated(&store, &VariantBudget::default())
975            .await
976            .unwrap();
977
978        let out_bytes = store
979            .get(
980                blob.variant("stripped", &[Transform::strip_metadata()])
981                    .key(),
982            )
983            .await
984            .unwrap();
985        assert!(
986            image::load_from_memory(&out_bytes).is_ok(),
987            "StripMetadata output must be a valid image"
988        );
989    }
990
991    // ── RED: PNG source ──────────────────────────────────────────────────────
992
993    #[tokio::test]
994    async fn png_source_is_processed_and_cached() {
995        let dir = tempfile::tempdir().unwrap();
996        let store = test_store(dir.path());
997        let png = make_test_image(40, 40, image::ImageFormat::Png);
998        store
999            .put("icon.png", "image/png", Bytes::from(png.clone()))
1000            .await
1001            .unwrap();
1002        let blob = Blob::new("test", "icon.png", "image/png", png.len() as u64);
1003        let variant_blob = blob
1004            .variant("small", &[Transform::resize_to_limit(20, 20)])
1005            .ensure_generated(&store, &VariantBudget::default())
1006            .await
1007            .unwrap();
1008        assert_eq!(variant_blob.content_type, "image/png");
1009
1010        let out_bytes = store.get(&variant_blob.key).await.unwrap();
1011        let out_img = image::load_from_memory(&out_bytes).unwrap();
1012        assert!(
1013            out_img.width() <= 20 && out_img.height() <= 20,
1014            "PNG variant dimensions: {}×{}",
1015            out_img.width(),
1016            out_img.height()
1017        );
1018    }
1019
1020    // ── RED: WebP source ─────────────────────────────────────────────────────
1021
1022    #[tokio::test]
1023    async fn webp_source_is_processed_to_jpeg() {
1024        let dir = tempfile::tempdir().unwrap();
1025        let store = test_store(dir.path());
1026        // Encode the source as WebP.
1027        let webp = make_test_image(40, 40, image::ImageFormat::WebP);
1028        store
1029            .put("photo.webp", "image/webp", Bytes::from(webp.clone()))
1030            .await
1031            .unwrap();
1032        let blob = Blob::new("test", "photo.webp", "image/webp", webp.len() as u64);
1033        let variant_blob = blob
1034            .variant("thumb", &[Transform::resize_to_limit(20, 20)])
1035            .ensure_generated(&store, &VariantBudget::default())
1036            .await
1037            .unwrap();
1038        // WebP sources are transcoded to JPEG.
1039        assert_eq!(
1040            variant_blob.content_type, "image/jpeg",
1041            "WebP source must produce JPEG variant"
1042        );
1043        let out_bytes = store.get(&variant_blob.key).await.unwrap();
1044        assert!(image::load_from_memory(&out_bytes).is_ok());
1045    }
1046
1047    // ── RED: URL helper ──────────────────────────────────────────────────────
1048
1049    #[tokio::test]
1050    async fn variant_url_returns_presigned_url() {
1051        let dir = tempfile::tempdir().unwrap();
1052        let store = test_store(dir.path());
1053        let jpeg = make_test_image(100, 100, image::ImageFormat::Jpeg);
1054        store
1055            .put("photo.jpg", "image/jpeg", Bytes::from(jpeg.clone()))
1056            .await
1057            .unwrap();
1058        let blob = Blob::new("test", "photo.jpg", "image/jpeg", jpeg.len() as u64);
1059        let url = blob
1060            .variant("thumb", &[Transform::resize_to_limit(50, 50)])
1061            .url(&store, &VariantBudget::default(), Duration::from_secs(300))
1062            .await
1063            .unwrap();
1064        assert!(
1065            url.contains("_variants/"),
1066            "URL must contain variant key: {url}"
1067        );
1068        assert!(url.contains("exp="), "URL must contain expiry param: {url}");
1069        assert!(url.contains("sig="), "URL must contain signature: {url}");
1070    }
1071
1072    // ── RED: handle accessors ────────────────────────────────────────────────
1073
1074    #[test]
1075    fn handle_name_accessor() {
1076        let blob = Blob::new("local", "a.png", "image/png", 1);
1077        let h = blob.variant("thumbnail", &[Transform::resize_to_limit(200, 200)]);
1078        assert_eq!(h.name(), "thumbnail");
1079    }
1080
1081    #[test]
1082    fn handle_transforms_accessor() {
1083        let transforms = vec![
1084            Transform::resize_to_limit(200, 200),
1085            Transform::strip_metadata(),
1086        ];
1087        let blob = Blob::new("local", "a.png", "image/png", 1);
1088        let h = blob.variant("t", &transforms);
1089        assert_eq!(h.transforms(), &transforms);
1090    }
1091
1092    // ── RED: content_addressed_key internals ─────────────────────────────────
1093
1094    #[test]
1095    fn content_addressed_key_hex_is_64_chars() {
1096        let blob = Blob::new("local", "avatars/1.png", "image/png", 1024);
1097        let key = content_addressed_key(&blob, &[Transform::resize_to_limit(200, 200)]);
1098        // format: _variants/xx/xx/<64-hex-chars>
1099        let hash_part = key.rsplit('/').next().unwrap();
1100        assert_eq!(hash_part.len(), 64, "hash part must be 64 hex chars");
1101    }
1102
1103    #[test]
1104    fn order_of_transforms_matters_for_content_addressing() {
1105        let blob = Blob::new("local", "a.png", "image/png", 1);
1106        let t1 = [Transform::resize_to_limit(200, 200), Transform::rotate(90)];
1107        let t2 = [Transform::rotate(90), Transform::resize_to_limit(200, 200)];
1108        assert_ne!(
1109            blob.variant("x", &t1).key(),
1110            blob.variant("x", &t2).key(),
1111            "transform order must affect the content-addressed key"
1112        );
1113    }
1114
1115    // ── status() and into_autumn_error() ────────────────────────────────────
1116
1117    #[test]
1118    fn variant_error_status_unsupported_mime_is_422() {
1119        assert_eq!(
1120            VariantError::UnsupportedMimeType("image/bmp".into()).status(),
1121            http::StatusCode::UNPROCESSABLE_ENTITY
1122        );
1123    }
1124
1125    #[test]
1126    fn variant_error_status_decode_error_is_422() {
1127        assert_eq!(
1128            VariantError::DecodeError("bad pixels".into()).status(),
1129            http::StatusCode::UNPROCESSABLE_ENTITY
1130        );
1131    }
1132
1133    #[test]
1134    fn variant_error_status_source_too_large_is_413() {
1135        assert_eq!(
1136            VariantError::SourceTooLarge {
1137                byte_size: 999,
1138                max_bytes: 100
1139            }
1140            .status(),
1141            http::StatusCode::PAYLOAD_TOO_LARGE
1142        );
1143    }
1144
1145    #[test]
1146    fn variant_error_status_dimensions_too_large_is_413() {
1147        assert_eq!(
1148            VariantError::SourceDimensionsTooLarge {
1149                width: 200,
1150                height: 200,
1151                max_width: 100,
1152                max_height: 100
1153            }
1154            .status(),
1155            http::StatusCode::PAYLOAD_TOO_LARGE
1156        );
1157    }
1158
1159    #[test]
1160    fn variant_error_status_storage_delegates_to_blob_store_error() {
1161        use crate::storage::BlobStoreError;
1162        assert_eq!(
1163            VariantError::Storage(BlobStoreError::NotFound("k".into())).status(),
1164            http::StatusCode::NOT_FOUND
1165        );
1166    }
1167
1168    #[test]
1169    fn variant_error_into_autumn_error_carries_correct_status() {
1170        let err = VariantError::SourceTooLarge {
1171            byte_size: 999,
1172            max_bytes: 100,
1173        }
1174        .into_autumn_error();
1175        assert_eq!(err.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
1176    }
1177}