1use std::io::Cursor;
35use std::time::Duration;
36
37use bytes::Bytes;
38use sha2::{Digest, Sha256};
39
40use super::{Blob, BlobStore, BlobStoreError};
41
42#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
50#[non_exhaustive]
51pub enum Transform {
52 ResizeToLimit {
55 width: u32,
57 height: u32,
59 },
60 ResizeToFill {
62 width: u32,
64 height: u32,
66 },
67 Rotate {
70 degrees: u16,
72 },
73 StripMetadata,
79}
80
81impl Transform {
82 #[must_use]
84 pub const fn resize_to_limit(width: u32, height: u32) -> Self {
85 Self::ResizeToLimit { width, height }
86 }
87
88 #[must_use]
90 pub const fn resize_to_fill(width: u32, height: u32) -> Self {
91 Self::ResizeToFill { width, height }
92 }
93
94 #[must_use]
96 pub const fn rotate(degrees: u16) -> Self {
97 Self::Rotate { degrees }
98 }
99
100 #[must_use]
102 pub const fn strip_metadata() -> Self {
103 Self::StripMetadata
104 }
105}
106
107#[derive(Debug, Clone)]
113pub struct VariantBudget {
114 pub max_source_bytes: u64,
116 pub max_source_width: u32,
118 pub max_source_height: u32,
120}
121
122impl Default for VariantBudget {
123 fn default() -> Self {
124 Self {
125 max_source_bytes: 20 * 1024 * 1024, max_source_width: 10_000,
127 max_source_height: 10_000,
128 }
129 }
130}
131
132#[derive(Debug, thiserror::Error)]
134#[non_exhaustive]
135pub enum VariantError {
136 #[error("unsupported MIME type for image variant: {0}")]
139 UnsupportedMimeType(String),
140
141 #[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 #[error(
150 "source image dimensions too large: {width}×{height} px \
151 (budget: {max_width}×{max_height} px)"
152 )]
153 SourceDimensionsTooLarge {
154 width: u32,
156 height: u32,
158 max_width: u32,
160 max_height: u32,
162 },
163
164 #[error("image decode/encode error: {0}")]
166 DecodeError(String),
167
168 #[error(transparent)]
170 Storage(#[from] BlobStoreError),
171}
172
173impl VariantError {
174 #[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 #[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#[derive(Debug, Clone)]
218pub struct VariantHandle {
219 source: Blob,
220 name: String,
221 transforms: Vec<Transform>,
222 variant_key: String,
224}
225
226impl VariantHandle {
227 #[must_use]
229 pub fn key(&self) -> &str {
230 &self.variant_key
231 }
232
233 #[must_use]
235 pub fn name(&self) -> &str {
236 &self.name
237 }
238
239 #[must_use]
241 pub fn transforms(&self) -> &[Transform] {
242 &self.transforms
243 }
244
245 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 pub async fn ensure_generated(
280 &self,
281 store: &dyn BlobStore,
282 budget: &VariantBudget,
283 ) -> Result<Blob, VariantError> {
284 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 async fn generate(
299 &self,
300 store: &dyn BlobStore,
301 budget: &VariantBudget,
302 ) -> Result<Blob, VariantError> {
303 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 check_image_mime_type(&self.source.content_type)?;
314
315 let source_bytes = store.get(&self.source.key).await?;
316
317 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 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 let mut reader = image::ImageReader::new(Cursor::new(&source_bytes[..]))
339 .with_guessed_format()
340 .map_err(|e| VariantError::DecodeError(e.to_string()))?;
341 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 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
388impl Blob {
391 #[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
419pub(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
439pub(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
473fn 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
492fn 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 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 Transform::StripMetadata => img,
518 };
519 }
520 img
521}
522
523fn 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#[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 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 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 #[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 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 #[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 #[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 let budget = VariantBudget {
727 max_source_bytes: actual_size - 1,
728 ..Default::default()
729 };
730 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 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, max_source_height: 3,
756 max_source_bytes: jpeg.len() as u64 * 10, };
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 #[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 #[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 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 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 #[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 #[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 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 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 #[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 #[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 #[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 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 #[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}