//! S3-compatible storage client for Hetzner Object Storage //! //! Provides presigned URL generation for client-direct uploads/downloads. //! Delegates S3 operations to the shared `s3_storage` crate. //! //! See also: `/docs/guide/audio`, `/docs/guide/video`, `/docs/guide/software` use std::str::FromStr; use crate::config::StorageConfig; use crate::constants; use crate::db::{ItemId, ProjectId, SyncAppId, UserId, VersionId}; use crate::error::{AppError, Result}; /// A storage object key. There are exactly two ways to obtain one, and an /// ad-hoc `format!("...")` is neither: /// /// 1. A `S3Client::generate_*` constructor, the single, reviewed home for key /// *layout*. Multi-instance kinds (versions, gallery, media) take their /// uniqueness segment (a table PK or a fresh uuid) as a required argument, so /// a collidable key cannot be built; singleton kinds (audio/cover/video, OTA /// artifacts) are one-per-parent and correctly overwrite-on-replace. /// 2. [`S3Key::from_stored`], the named trust boundary for a key that already /// exists in our storage (read back from a DB row). The caller asserts it was /// minted by a generator at write time; this is how delete/download/re-presign /// paths address objects without re-deriving their layout. /// /// Because every write/presign/delete on [`StorageBackend`] takes `&S3Key`, a /// hand-built string can never reach S3, the OTA-style inline `format!` key /// (which bypassed the generators) is now uncompilable. #[derive(Debug, Clone, PartialEq, Eq, Hash, sqlx::Type)] #[sqlx(transparent)] pub struct S3Key(String); impl S3Key { /// Wrap a key read back from durable storage (a DB row). Names the trust /// boundary: the caller asserts this key was minted by a `generate_*` /// constructor when the object was written, not freshly invented here. pub fn from_stored(key: impl AsRef) -> Self { S3Key(key.as_ref().to_string()) } pub fn as_str(&self) -> &str { &self.0 } pub fn into_string(self) -> String { self.0 } } impl std::ops::Deref for S3Key { type Target = str; fn deref(&self) -> &str { &self.0 } } impl AsRef for S3Key { fn as_ref(&self) -> &str { &self.0 } } impl std::fmt::Display for S3Key { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } impl PartialEq<&str> for S3Key { fn eq(&self, other: &&str) -> bool { self.0 == *other } } /// Allowed audio file extensions and their MIME types const ALLOWED_AUDIO_TYPES: &[(&str, &str)] = &[ ("mp3", "audio/mpeg"), ("wav", "audio/wav"), ("m4a", "audio/mp4"), ("ogg", "audio/ogg"), ("flac", "audio/flac"), ("aac", "audio/aac"), ]; /// Allowed image file extensions and their MIME types const ALLOWED_IMAGE_TYPES: &[(&str, &str)] = &[ ("jpg", "image/jpeg"), ("jpeg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp"), ("gif", "image/gif"), ]; /// Allowed video file extensions and their MIME types const ALLOWED_VIDEO_TYPES: &[(&str, &str)] = &[ ("mp4", "video/mp4"), ("webm", "video/webm"), ("mov", "video/quicktime"), ]; /// Allowed insertion-clip extensions and MIME types. A clip may be audio (the /// original use: intros, sponsor reads) or video (pre/mid/post-roll on a video /// item), so this is the union of the audio and video allow-lists. Kept as one /// literal because `allowed_types()` returns a `&'static` slice. const ALLOWED_INSERTION_TYPES: &[(&str, &str)] = &[ ("mp3", "audio/mpeg"), ("wav", "audio/wav"), ("m4a", "audio/mp4"), ("ogg", "audio/ogg"), ("flac", "audio/flac"), ("aac", "audio/aac"), ("mp4", "video/mp4"), ("webm", "video/webm"), ("mov", "video/quicktime"), ]; /// MIME types accepted for video uploads const ALLOWED_VIDEO_MIMES: &[&str] = &["video/mp4", "video/webm", "video/quicktime"]; /// Allowed download file extensions and their MIME types /// Browsers are inconsistent about MIME types for binary downloads, /// so we accept several common types for each extension. const ALLOWED_DOWNLOAD_TYPES: &[(&str, &str)] = &[ ("zip", "application/zip"), ("dmg", "application/x-apple-diskimage"), ("exe", "application/octet-stream"), ("appimage", "application/octet-stream"), ("deb", "application/octet-stream"), ("clap", "application/octet-stream"), ("vst3", "application/octet-stream"), ]; /// MIME types accepted for download uploads (browsers vary widely) const ALLOWED_DOWNLOAD_MIMES: &[&str] = &[ "application/octet-stream", "application/zip", "application/x-zip-compressed", "application/x-apple-diskimage", "application/x-diskcopy", "application/x-msi", "application/x-ole-storage", "application/gzip", "application/x-tar", "application/x-gtar", "application/x-compressed", "application/x-executable", "application/x-deb", "application/vnd.debian.binary-package", ]; /// Allowed download file extensions (checked separately from MIME) const ALLOWED_DOWNLOAD_EXTENSIONS: &[&str] = &[ "zip", "dmg", "exe", "msi", "appimage", "deb", "tar.gz", "clap", "vst3", ]; /// Maximum file sizes in bytes const MAX_AUDIO_SIZE: u64 = 500 * 1024 * 1024; // 500 MB const MAX_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; // 500 MB const MAX_INSERTION_SIZE: u64 = 500 * 1024 * 1024; // 500 MB const MAX_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB const MAX_MEDIA_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB const MAX_MEDIA_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB /// Extensions an Alloy hotfix RPM repository serves. The package itself, the /// `createrepo_c` metadata under `repodata/` (XML, in whatever compression the /// generator chose, or the sqlite variants), and the detached signature and /// public key that go beside `repomd.xml`. Anything else is a publish mistake: /// nothing in `dnf`'s fetch path asks for it, so serving it is pure surface. const RPM_REPO_EXTENSIONS: &[&str] = &[ "rpm", "xml", "zst", "gz", "xz", "bz2", "sqlite", "asc", "key", "sig", "yaml", ]; /// Default presigned URL expiration. /// 1 hour balances usability (large uploads over slow connections) against /// security (limiting the window for URL leakage). Overridable per-call. const PRESIGN_EXPIRY_SECS: u64 = 3600; /// File type categories for upload #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FileType { Audio, Cover, Download, Insertion, Video, /// Media library image (user-scoped, for inline markdown content). MediaImage, /// Media library video (user-scoped, for inline markdown content). MediaVideo, } /// How the generic `/api/upload/confirm` handler confirms a file type onto an /// `items` row. Returned by [`FileType::generic_item_confirm`], whose `match` /// is exhaustive, adding a `FileType` variant fails the build until its /// posture is declared here, so a new type can't silently fall into the wrong /// column set (a `Cover` branch that writes `cover_s3_key` but never /// `cover_image_url` leaves an invisible cover). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GenericItemConfirm { /// Confirmable by the generic handler: write these two columns on the item. /// Only types fully described by `(s3_key, size)` belong here, anything /// that needs an extra column (e.g. a CDN render URL) must use a dedicated /// route instead. Columns { s3_key: &'static str, size: &'static str, }, /// Not confirmable by the generic handler, use this dedicated route. The /// handler rejects the request (after cleaning up the staged object) so a /// misrouted confirm never half-writes the row. UseRoute(&'static str), } impl FileType { /// Declare, exhaustively, how `/api/upload/confirm` treats this type. /// See [`GenericItemConfirm`] for why this is the single source of truth. pub fn generic_item_confirm(self) -> GenericItemConfirm { match self { FileType::Audio => GenericItemConfirm::Columns { s3_key: "audio_s3_key", size: "audio_file_size_bytes", }, FileType::Video => GenericItemConfirm::Columns { s3_key: "video_s3_key", size: "video_file_size_bytes", }, // Cover IS confirmable, but it must also set `cover_image_url` (the // CDN render source). The generic two-column writer can't, so covers // go through the dedicated route that writes all three atomically. FileType::Cover => GenericItemConfirm::UseRoute("/api/items/image/confirm"), FileType::Download => { GenericItemConfirm::UseRoute("/api/versions/{version_id}/upload/*") } FileType::Insertion => GenericItemConfirm::UseRoute("/api/users/me/insertions/*"), FileType::MediaImage | FileType::MediaVideo => { GenericItemConfirm::UseRoute("/api/media/*") } } } pub fn as_str(&self) -> &'static str { match self { FileType::Audio => "audio", FileType::Cover => "cover", FileType::Download => "download", FileType::Insertion => "insertion", FileType::Video => "video", FileType::MediaImage => "media_image", FileType::MediaVideo => "media_video", } } pub fn max_size(&self) -> u64 { match self { FileType::Audio => MAX_AUDIO_SIZE, FileType::Cover => MAX_IMAGE_SIZE, FileType::Download => MAX_DOWNLOAD_SIZE, FileType::Insertion => MAX_INSERTION_SIZE, FileType::Video => MAX_VIDEO_SIZE, FileType::MediaImage => MAX_MEDIA_IMAGE_SIZE, FileType::MediaVideo => MAX_MEDIA_VIDEO_SIZE, } } pub fn allowed_types(&self) -> &'static [(&'static str, &'static str)] { match self { FileType::Audio => ALLOWED_AUDIO_TYPES, FileType::Insertion => ALLOWED_INSERTION_TYPES, FileType::Cover | FileType::MediaImage => ALLOWED_IMAGE_TYPES, FileType::Download => ALLOWED_DOWNLOAD_TYPES, FileType::Video | FileType::MediaVideo => ALLOWED_VIDEO_TYPES, } } } impl FromStr for FileType { type Err = String; fn from_str(s: &str) -> std::result::Result { match s.to_lowercase().as_str() { "audio" => Ok(FileType::Audio), "cover" | "image" => Ok(FileType::Cover), "download" => Ok(FileType::Download), "insertion" => Ok(FileType::Insertion), "video" => Ok(FileType::Video), "media_image" => Ok(FileType::MediaImage), "media_video" => Ok(FileType::MediaVideo), _ => Err(format!("Invalid file type: {s}")), } } } /// Cache-Control value for immutable content (builds, audio, covers). /// One year with immutable directive, Cloudflare and browsers cache indefinitely. pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable"; /// Capability proof required by every `StorageBackend` delete method. /// /// Direct S3 deletion is sealed off from route handlers: the delete methods /// take `&S3DeleteAuthority`, so an accidental `s3.delete_object(key)` from a /// handler does not compile. Route code must instead enqueue through /// `pending_s3_deletions` (e.g. `routes::storage::enqueue_s3_orphan`), whose /// worker applies the `is_s3_key_live` guard before deleting, so a handler /// cannot blind-delete a key a live row still references. /// /// Minting is `pub(crate)` and confined by convention to the durable-deletion /// paths, the scheduler deletion worker + cleanup (`scheduler/cleanup.rs`) and /// the malware-quarantine scan worker (`scanning/worker.rs`). The build-time /// guard test `routes_never_delete_s3_directly` fails if any file under /// `src/routes/` names a delete method or mints an authority, so the seal can't /// silently erode. pub struct S3DeleteAuthority(()); impl S3DeleteAuthority { /// Mint a deletion authority. Restricted to the sanctioned durable-deletion /// paths; see the type docs. Route handlers cannot reach a sanctioned path, /// and the guard test enforces that they don't mint one anyway. pub(crate) fn new() -> Self { S3DeleteAuthority(()) } } /// Which configured S3 backend an object lives in. /// /// The delete *verb* is type-sealed by [`S3DeleteAuthority`]; this seals the /// bucket *noun*. The `pending_s3_deletions` queue stores the bucket as text, and /// the deletion worker dispatches between the main and SyncKit S3 clients on that /// text. This enum is the single source of truth for the `"main"`/`"synckit"` /// spellings so an orphan-enqueue can't silently mis-tag a SyncKit object as /// `main` (where the worker would delete it against the wrong client and leak it /// forever), `enqueue_s3_orphan` requires an `S3Bucket`, not a bare string. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum S3Bucket { Main, Synckit, /// Public, CDN-served bucket (`cdn.makenot.work`). Holds ONLY the /// immutably-public image kinds after promote (covers, gallery, item/project /// images, content insertions); a paid object can never enter it, so its /// blanket public-read policy is safe by construction. Staging is never here ///, unscanned bytes stay in `Main`; the content object lands here only via /// the cross-bucket promote (see `scanning::promote_staging_to_content`). Public, } impl S3Bucket { /// The stored/text spelling for the deletion queue. pub fn as_str(self) -> &'static str { match self { S3Bucket::Main => "main", S3Bucket::Synckit => "synckit", S3Bucket::Public => "public", } } /// Parse a bucket tag read back from the queue. Unknown/legacy values map to /// `Main` (the historical default), so a garbled row is still reaped against /// a backend rather than wedging the queue. pub fn from_db_str(s: &str) -> Self { match s { "synckit" => S3Bucket::Synckit, "public" => S3Bucket::Public, _ => S3Bucket::Main, } } } /// Deletion enqueue pair for a content-image key whose promote state is unknown. /// /// A CDN-image key is a private **staging** key (`staging/...`, in `Main`) until /// [`crate::scanning::promote_staging_to_content`] repoints it to the public /// **content** key (`{user}/c/{sha}.ext`, in `Public`). A given object is in /// EXACTLY one bucket, but a delete/replace path can run in either state, so it /// can't know which. Enqueue the key under BOTH buckets: the reaper deletes from /// the bucket the object is in and no-ops the other (content keys are unique to /// one bucket), and `is_s3_key_live` still guards each bucket against a live /// reference. Only for the four CDN-served image surfaces; gated media /// (audio/video/version/media) is always `Main` and insertions always `Main`. pub fn both_bucket_delete(key: &str) -> [(String, String); 2] { [ (key.to_string(), S3Bucket::Main.as_str().to_string()), (key.to_string(), S3Bucket::Public.as_str().to_string()), ] } /// Aggregate a `ByteStream` into memory, aborting once more than `max_bytes` /// have been read. Backs [`StorageBackend::download_object_buf_capped`]; factored /// out as a free function so the cap logic is unit-testable without a full /// backend. `label` is only used in the error message (the object key). pub(crate) async fn read_bytestream_capped( mut stream: s3_storage::ByteStream, label: &str, max_bytes: u64, ) -> Result { let mut buf = bytes::BytesMut::new(); let mut read: u64 = 0; loop { match stream.try_next().await { Ok(Some(chunk)) => { read += chunk.len() as u64; if read > max_bytes { return Err(AppError::Storage(format!( "object {label} exceeds scan in-memory cap ({read} > {max_bytes} bytes); \ recorded size under-reported the real object" ))); } buf.extend_from_slice(&chunk); } Ok(None) => break, Err(e) => return Err(AppError::Storage(format!("read object from S3: {e}"))), } } Ok(buf.freeze()) } /// Abstract storage backend, implemented by `S3Client` (production) and /// `InMemoryStorage` (tests). Routes access storage through this trait. #[async_trait::async_trait] pub trait StorageBackend: Send + Sync { /// Generate a presigned upload URL. `max_bytes`, when set, is signed into /// the URL as `Content-Length` so S3 itself enforces the size cap at the /// protocol level (prevents oversized PUTs from burning bandwidth before /// hitting the post-PUT delete-and-charge fallback). async fn presign_upload( &self, s3_key: &S3Key, content_type: &str, expiry_secs: Option, cache_control: Option<&str>, max_bytes: Option, ) -> Result; async fn presign_download(&self, s3_key: &S3Key, expiry_secs: Option) -> Result; async fn object_exists(&self, s3_key: &str) -> Result; async fn object_size(&self, s3_key: &str) -> Result>; async fn download_object(&self, s3_key: &str) -> Result>; /// Download as `bytes::Bytes`, no `to_vec` copy of the aggregated body. /// Memory-sensitive callers (the scanner's buffered branch) use this so the /// payload isn't transiently doubled. async fn download_object_buf(&self, s3_key: &str) -> Result; /// Stream the object body without buffering the whole payload. Callers /// drive the stream to disk (scanner spool) or to a layer that consumes /// chunks directly. async fn download_stream(&self, s3_key: &str) -> Result; /// Download into memory like [`download_object_buf`], but abort if the body /// exceeds `max_bytes`. The scanner routes files it *recorded* as small to an /// in-memory branch, but `file_size_bytes` is asserted at upload time and can /// under-report the real object; this bounds the aggregation so a mis-recorded /// or abusive object can't pull an unbounded body into RAM, the independent /// ceiling the spool path already enforces. Streams via /// `download_stream`, so no backend can hand back the whole body up front. async fn download_object_buf_capped( &self, s3_key: &str, max_bytes: u64, ) -> Result { let stream = self.download_stream(s3_key).await?; read_bytestream_capped(stream, s3_key, max_bytes).await } /// Read up to the first `len` bytes of an object. Production overrides this /// with a ranged `GetObject` so a content sniff transfers only the header, /// not the whole object. The default streams and stops early, correct, but /// it still initiates a full GET, which is fine for in-memory test backends. async fn download_head(&self, s3_key: &str, len: usize) -> Result> { let mut stream = self.download_stream(s3_key).await?; let mut head = Vec::with_capacity(len.min(64 * 1024)); while head.len() < len { match stream.try_next().await { Ok(Some(chunk)) => head.extend_from_slice(&chunk), Ok(None) => break, Err(e) => return Err(AppError::Storage(format!("read object head from S3: {e}"))), } } head.truncate(len); Ok(head) } async fn upload_object( &self, s3_key: &S3Key, content_type: &str, data: Vec, cache_control: Option<&str>, ) -> Result<()>; /// Delete an object. Requires an [`S3DeleteAuthority`], route handlers /// cannot mint one, so they must enqueue through `pending_s3_deletions` /// instead of deleting directly. async fn delete_object(&self, auth: &S3DeleteAuthority, s3_key: &S3Key) -> Result<()>; /// Delete a batch of objects in a single S3 `DeleteObjects` request /// (up to 1000 keys/call). Default loops `delete_object` so test backends /// don't have to implement it, but production should override. async fn delete_objects(&self, auth: &S3DeleteAuthority, keys: &[S3Key]) -> Result<()> { let mut failed = 0usize; for k in keys { if let Err(e) = self.delete_object(auth, k).await { failed += 1; tracing::warn!(key = %k, error = ?e, "delete_objects: per-key delete failed"); } } // Don't report success when every key failed, a total failure must // surface so the caller can fall back (Run #2 Storage MINOR). Partial // failures stay logged; callers pre-enqueue to pending_s3_deletions. if !keys.is_empty() && failed == keys.len() { return Err(AppError::Storage(format!( "delete_objects: all {failed} keys failed" ))); } Ok(()) } /// Delete all objects under a key prefix. Default logs a warning (no-op). async fn delete_prefix(&self, _auth: &S3DeleteAuthority, _prefix: &str) -> Result<()> { tracing::warn!("delete_prefix called on a storage backend that does not implement it"); Ok(()) } /// Upload a file via S3 multipart upload. Required (not defaulted): a /// default that `tokio::fs::read`s the whole file into RAM + single PUT /// silently defeats streaming, so a future backend that forgot to override /// it would quietly lose multipart. Every backend must declare its strategy. async fn upload_multipart( &self, s3_key: &S3Key, content_type: &str, file_path: &std::path::Path, ) -> Result<()>; /// Server-side copy `src_key` to `dst_key` within this backend's bucket /// (no bytes transit the process). The scan-then-promote primitive: a Clean /// staging object is copied to the served key the client holds no presign /// for, so served bytes are provably the scanned bytes. Required (not /// defaulted): a silent no-op default would make a /// promote "succeed" while the served key stays empty. async fn copy_object(&self, src_key: &S3Key, dst_key: &S3Key) -> Result<()>; /// Server-side copy from `src_bucket` into THIS backend's bucket. The /// cross-bucket half of scan-then-promote: a Clean staging object in the /// private bucket is lifted into the public (CDN-served) bucket. Call on the /// public backend with the private bucket name as `src_bucket`. Required /// (not defaulted) for the same reason as `copy_object`. async fn copy_object_from( &self, src_bucket: &str, src_key: &S3Key, dst_key: &S3Key, ) -> Result<()>; /// Server-side multipart copy (`UploadPartCopy`) for sources over the 5 GiB /// single-part `CopyObject` limit, the >5 GiB half of scan-then-promote. /// Always takes `src_bucket` explicitly, collapsing the /// `copy_object`/`copy_object_from` pair into one method (pass this /// backend's own bucket for a same-bucket promote). `content_type` sets the /// destination's type, since a fresh multipart upload does not inherit the /// source's metadata the way `CopyObject` does. Required (not defaulted) for /// the same reason as `copy_object`. async fn copy_object_multipart( &self, src_bucket: &str, src_key: &S3Key, dst_key: &S3Key, content_type: &str, src_size: u64, part_size: Option, ) -> Result<()>; // Client-direct multipart sessions // // The counterpart to `upload_multipart`, which drives a whole transfer // server-side from a local file. Here the server only mints the session and // the per-part presigned URLs; the client streams parts straight to S3, so // no object bytes transit the server. This is the path large CLI/desktop // uploads take (a browser stays on the single-PUT `presign_upload`). // // All four are required (not defaulted): a no-op default would mint a // session no client could complete, or silently drop the cleanup that // stops orphaned parts billing forever. /// Begin a client-direct multipart upload, returning the `upload_id` the /// part/complete/abort calls key on. async fn create_multipart_upload(&self, s3_key: &S3Key, content_type: &str) -> Result; /// Presign an `UploadPart` request for one part (1-based `part_number`). /// `max_bytes`, when set, is signed as `Content-Length`, the same /// defense-in-depth as [`Self::presign_upload`], the authoritative size /// check still happens at confirm time. /// /// `checksum_sha256` (base64 of the raw digest), when set, is signed as /// `x-amz-checksum-sha256` and IS enforced: S3 rehashes the part and /// rejects a mismatch before the bytes are durable. async fn presign_upload_part( &self, s3_key: &S3Key, upload_id: &str, part_number: i32, expiry_secs: Option, max_bytes: Option, checksum_sha256: Option<&str>, ) -> Result; /// Complete a multipart upload from the collected `(part_number, etag)` /// pairs. Parts may be passed in any order; the backend sorts them. async fn complete_multipart_upload( &self, s3_key: &S3Key, upload_id: &str, parts: &[(i32, String)], ) -> Result<()>; /// Abort a multipart upload, releasing its uploaded parts. The /// pending-upload reaper calls this on sessions that were never confirmed, /// incomplete multipart uploads bill for their parts indefinitely. async fn abort_multipart_upload(&self, s3_key: &S3Key, upload_id: &str) -> Result<()>; /// Upload ids of the in-progress multipart sessions for exactly `s3_key`. /// The reaper recovers them from S3 rather than the database, so a session /// whose tracking row was lost is still cleaned up. Required (not defaulted): /// an empty default would silently strand billed parts. async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result>; async fn check_connectivity(&self) -> std::result::Result<(), String>; fn bucket(&self) -> &str; } /// S3 client wrapper for presigned URL operations. /// Delegates S3 operations to `s3_storage::S3Client`. #[derive(Clone)] pub struct S3Client { inner: s3_storage::S3Client, } impl S3Client { /// Create a new S3 client from storage configuration. /// /// Configures CORS on the bucket at startup so browser PUT uploads to /// presigned URLs work without manual bucket configuration. pub async fn new(config: &StorageConfig, host_url: &str) -> Result { let s3_config = s3_storage::S3Config { endpoint: config.endpoint.clone(), bucket: config.bucket.clone(), access_key: config.access_key.clone(), secret_key: config.secret_key.clone(), region: config.region.clone(), }; let inner = s3_storage::S3Client::new(&s3_config) .await .map_err(AppError::Storage)?; inner.configure_cors(host_url).await; Ok(S3Client { inner }) } /// Generate a consistent S3 key for an object /// Format: {user_id}/{item_id}/{file_type}/{filename} pub fn generate_key( user_id: UserId, item_id: ItemId, file_type: FileType, filename: &str, ) -> S3Key { let safe_filename = sanitize_filename(filename); S3Key(format!( "{}/{}/{}/{}", user_id, item_id, file_type.as_str(), safe_filename )) } /// Staging key for scan-then-promote: browser uploads presign to this key, /// which is NEVER served. After a Clean scan the worker copies the object to /// its content-addressed [`content_key`](Self::content_key) and deletes the /// staging object. The upload's extension is carried in the staging key so /// the worker can build the content key without re-reading the entity row. /// Format: `staging/{uuid}/{sanitized_filename}`. The random uuid segment /// means a replayed presigned PUT can only re-write the (unserved, /// post-scan-deleted) staging object, never the served content key, and two /// uploads of the same filename never collide. /// The original filename is preserved after the uuid so a confirm can recover /// it (e.g. a version download's suggested name), `sanitize_filename` strips /// any `/`, so the name can't add path segments or escape the `staging/` /// prefix. The extension still rides along for the content key. pub fn generate_staging_key(filename: &str) -> S3Key { S3Key(format!( "staging/{}/{}", uuid::Uuid::new_v4(), sanitize_filename(filename) )) } /// Key for an object in the Alloy hotfix RPM repository, from the relative /// path the publisher names (e.g. `alloy/f43/x86_64/repodata/repomd.xml`). /// /// The odd one out among the generators, and deliberately so: every other /// key layout here is derived from ids we hold, but a yum repository *is* a /// path layout that `createrepo_c` writes and `dnf` re-derives from /// `repomd.xml`. The server cannot invent it without reimplementing /// createrepo, so the caller supplies it. That makes this the one generator /// whose whole job is refusing bad input, and it returns `Result` for that /// reason. Which layout the repo actually uses is /// [`86cb87b9`](https://makenot.work)'s business, not this function's, hence /// no structure is imposed beyond a segment count. /// /// Refused: absolute paths, empty segments (so `//` and a trailing `/`), /// `.` and `..` in any position, a segment starting `.` or `-`, anything /// outside `[A-Za-z0-9._+~-]`, and a final segment whose extension is not /// one a yum repository serves. Together those make traversal /// unrepresentable rather than merely unlikely, and keep a presigned PUT /// from writing an object the Caddy block would then serve as something it /// is not. pub fn generate_rpm_key(path: &str) -> Result { let bad = |msg: &str| AppError::BadRequest(format!("invalid RPM object path: {msg}")); if path.is_empty() { return Err(bad("empty")); } if path.len() > constants::RPM_MAX_KEY_BYTES { return Err(bad(&format!( "longer than {} bytes", constants::RPM_MAX_KEY_BYTES ))); } if path.starts_with('/') { return Err(bad("must be relative, not absolute")); } let segments: Vec<&str> = path.split('/').collect(); if segments.len() > constants::RPM_MAX_KEY_SEGMENTS { return Err(bad(&format!( "more than {} path segments", constants::RPM_MAX_KEY_SEGMENTS ))); } for segment in &segments { if segment.is_empty() { return Err(bad("empty path segment")); } if *segment == "." || *segment == ".." { return Err(bad("`.` and `..` are not path segments")); } if segment.starts_with('.') || segment.starts_with('-') { return Err(bad("a path segment may not start with `.` or `-`")); } if !segment .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '~' | '-')) { return Err(bad( "a path segment may hold only letters, digits, and `.` `_` `+` `~` `-`", )); } } // Unwrap: `split` on a non-empty string always yields at least one // segment, and every segment was proven non-empty above. let filename = segments.last().copied().unwrap_or_default(); let ext = filename .rsplit_once('.') .map(|(_, ext)| ext.to_ascii_lowercase()) .ok_or_else(|| bad("the final path segment needs a file extension"))?; if !RPM_REPO_EXTENSIONS.contains(&ext.as_str()) { return Err(bad(&format!( "`.{ext}` is not served from an RPM repository. Allowed: {}", RPM_REPO_EXTENSIONS.join(", ") ))); } Ok(S3Key(path.to_string())) } /// Content-addressed served key: `{user_id}/c/{sha256}.{ext}`. The object's /// name *is* its content hash, so the served bytes are provably the bytes /// that were scanned, a swapped object would hash to a different key. The /// key is per-owner (`user_id`) namespaced, so identical bytes uploaded by /// different creators do NOT collapse to one shared object (no cross-tenant /// existence oracle). The `c` marker segment cannot collide with the legacy /// `{user_id}/{item_id}/...` layout because `c` is not a UUID. pub fn content_key(user_id: UserId, sha256: &str, ext: &str) -> S3Key { S3Key(format!("{user_id}/c/{sha256}.{ext}")) } /// Generate an S3 key for a version download file. The version's own id is /// woven into the path so two versions of the same item that share a /// filename (e.g. a creator who ships every release as `plugin.zip`) never /// resolve to the same object, mirrors the per-entity-uuid segment the /// gallery keys use, except the version id is the table's primary key, so /// uniqueness is guaranteed by construction rather than by a fresh uuid. /// Format: {user_id}/{item_id}/download/{version_id}/{filename} pub fn generate_version_key( user_id: UserId, item_id: ItemId, version_id: VersionId, filename: &str, ) -> S3Key { let safe_filename = sanitize_filename(filename); S3Key(format!( "{}/{}/{}/{}/{}", user_id, item_id, FileType::Download.as_str(), version_id, safe_filename )) } /// Generate an S3 key for a reusable insertion clip (not tied to any item). /// Format: {user_id}/insertions/{filename} pub fn generate_insertion_key(user_id: UserId, filename: &str) -> S3Key { let safe_filename = sanitize_filename(filename); S3Key(format!("{user_id}/insertions/{safe_filename}")) } /// Generate an S3 key for a media library file. /// Format: `{user_id}/media/{folder}/{filename}` (or `{user_id}/media/{filename}` for root folder). pub fn generate_media_key(user_id: UserId, folder: &str, filename: &str) -> S3Key { let safe_filename = sanitize_filename(filename); let safe_folder = sanitize_folder(folder); if safe_folder.is_empty() { S3Key(format!("{user_id}/media/{safe_filename}")) } else { S3Key(format!("{user_id}/media/{safe_folder}/{safe_filename}")) } } /// Generate an S3 key for a project image (logo/avatar). /// Format: projects/{project_id}/image/{sanitized_filename} pub fn generate_project_image_key(project_id: ProjectId, filename: &str) -> S3Key { let safe_filename = sanitize_filename(filename); S3Key(format!("projects/{project_id}/image/{safe_filename}")) } /// Generate an S3 key for an OTA release artifact. Singleton per /// (app, version, target, arch), the release row already enforces /// `UNIQUE(app_id, version)` and the artifact row `UNIQUE(release_id, target, /// arch)`, so re-uploading the same artifact correctly overwrites in place. /// Centralized here so OTA keys are no longer hand-built at the call site. /// Format: ota/{app_id}/{version}/{target}/{arch}/artifact pub fn generate_ota_artifact_key( app_id: SyncAppId, version: &str, target: &str, arch: &str, ) -> S3Key { S3Key(format!("ota/{app_id}/{version}/{target}/{arch}/artifact")) } /// Generate an S3 key for a SyncKit content-addressed blob. The hash is the /// uniqueness segment (and `UNIQUE(app_id, user_id, hash)` backs it), so two /// uploads of identical bytes resolve to one object by design. /// Format: {app_id}/{user_id}/{hash} pub fn generate_synckit_blob_key(app_id: SyncAppId, user_id: UserId, hash: &str) -> S3Key { S3Key(format!("{app_id}/{user_id}/{hash}")) } /// Generate an S3 key for a generated content-export archive. Ephemeral /// (presigned, then reaped); the timestamp keeps repeat exports distinct. /// Format: {user_id}/exports/content-{timestamp}.zip pub fn generate_content_export_key(user_id: UserId, timestamp: &str) -> S3Key { S3Key(format!("{user_id}/exports/content-{timestamp}.zip")) } /// Generate an S3 key for an item gallery image. A per-image uuid segment /// keeps multiple gallery uploads from colliding (unlike the single cover, /// which has a fixed `cover/` path). /// Format: {user_id}/{item_id}/gallery/{image_uuid}/{sanitized_filename} pub fn generate_item_gallery_key( user_id: UserId, item_id: ItemId, image_uuid: uuid::Uuid, filename: &str, ) -> S3Key { let safe_filename = sanitize_filename(filename); S3Key(format!( "{user_id}/{item_id}/gallery/{image_uuid}/{safe_filename}" )) } /// Generate an S3 key for a project gallery image. /// Format: projects/{project_id}/gallery/{image_uuid}/{sanitized_filename} pub fn generate_project_gallery_key( project_id: ProjectId, image_uuid: uuid::Uuid, filename: &str, ) -> S3Key { let safe_filename = sanitize_filename(filename); S3Key(format!( "projects/{project_id}/gallery/{image_uuid}/{safe_filename}" )) } /// Validate content type for the given file type pub fn validate_content_type(file_type: FileType, content_type: &str) -> Result<()> { let is_valid = if file_type == FileType::Download { ALLOWED_DOWNLOAD_MIMES.contains(&content_type) } else if file_type == FileType::Video { ALLOWED_VIDEO_MIMES.contains(&content_type) } else { let allowed = file_type.allowed_types(); allowed.iter().any(|(_, mime)| *mime == content_type) }; if !is_valid { let allowed_list = if file_type == FileType::Download { ALLOWED_DOWNLOAD_MIMES.join(", ") } else if file_type == FileType::Video { ALLOWED_VIDEO_MIMES.join(", ") } else { let allowed = file_type.allowed_types(); allowed .iter() .map(|(_, m)| *m) .collect::>() .join(", ") }; return Err(AppError::InvalidFileType(format!( "Content type '{content_type}' not allowed. Allowed types: {allowed_list}" ))); } Ok(()) } /// Classify a validated MIME type as the `media_type` we persist for an /// insertion clip: `"video"` for `video/*`, otherwise `"audio"`. The MIME is /// expected to have already passed `validate_content_type`, so the audio /// fallback is safe (the only non-audio family the insertion allow-list /// admits is `video/*`). pub fn insertion_media_type(mime_type: &str) -> &'static str { if mime_type.starts_with("video/") { "video" } else { "audio" } } /// Validate file extension for the given file type pub fn validate_extension(file_type: FileType, filename: &str) -> Result<()> { if file_type == FileType::Download { let lower = filename.to_lowercase(); let is_valid = ALLOWED_DOWNLOAD_EXTENSIONS .iter() .any(|ext| lower.ends_with(&format!(".{ext}"))); if !is_valid { return Err(AppError::InvalidFileType(format!( "File extension not allowed. Allowed extensions: {}", ALLOWED_DOWNLOAD_EXTENSIONS.join(", ") ))); } return Ok(()); } let extension = filename .rsplit('.') .next() .map(str::to_lowercase) .unwrap_or_default(); let allowed = file_type.allowed_types(); let is_valid = allowed.iter().any(|(ext, _)| *ext == extension); if !is_valid { let allowed_exts: Vec<&str> = allowed.iter().map(|(e, _)| *e).collect(); return Err(AppError::InvalidFileType(format!( "File extension '.{}' not allowed. Allowed extensions: {}", extension, allowed_exts.join(", ") ))); } Ok(()) } /// Generate a presigned URL for uploading a file. `max_bytes`, when set, /// binds `Content-Length` into the signature, S3 will reject any PUT /// whose actual body length differs from `max_bytes`. pub async fn presign_upload( &self, s3_key: &S3Key, content_type: &str, expiry_secs: Option, cache_control: Option<&str>, max_bytes: Option, ) -> Result { self.inner .presign_upload( s3_key.as_str(), content_type, expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS), cache_control, max_bytes, ) .await .map_err(AppError::Storage) } /// Generate a presigned URL for downloading/streaming a file pub async fn presign_download( &self, s3_key: &S3Key, expiry_secs: Option, ) -> Result { self.inner .presign_download(s3_key.as_str(), expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS)) .await .map_err(AppError::Storage) } /// Check if an object exists in S3 pub async fn object_exists(&self, s3_key: &str) -> Result { self.inner .object_exists(s3_key) .await .map_err(AppError::Storage) } /// Get the size of an object in S3 (bytes), or None if not found. pub async fn object_size(&self, s3_key: &str) -> Result> { self.inner .object_size(s3_key) .await .map_err(AppError::Storage) } /// Download an object's bytes from S3 pub async fn download_object(&self, s3_key: &str) -> Result> { self.inner .download(s3_key) .await .map(|(bytes, _content_type)| bytes) .map_err(AppError::Storage) } /// Download an object as `bytes::Bytes` without the `to_vec` copy. See trait docs. pub async fn download_object_buf(&self, s3_key: &str) -> Result { self.inner .download_buf(s3_key) .await .map(|(bytes, _content_type)| bytes) .map_err(AppError::Storage) } /// Stream an object's body from S3 without buffering. See trait docs. pub async fn download_stream(&self, s3_key: &str) -> Result { self.inner .download_stream(s3_key) .await .map_err(AppError::Storage) } /// Read the first `len` bytes via a ranged S3 GET (for content sniffing). pub async fn download_head(&self, s3_key: &str, len: usize) -> Result> { self.inner .download_head(s3_key, len) .await .map_err(AppError::Storage) } /// Upload an object to S3 from bytes pub async fn upload_object( &self, s3_key: &S3Key, content_type: &str, data: Vec, cache_control: Option<&str>, ) -> Result<()> { self.inner .upload(s3_key.as_str(), content_type, data, cache_control) .await .map_err(AppError::Storage) } /// Delete an object from S3 pub async fn delete_object(&self, s3_key: &S3Key) -> Result<()> { self.inner .delete(s3_key.as_str()) .await .map_err(AppError::Storage) } /// Server-side copy within the bucket. See the [`StorageBackend::copy_object`] /// trait method for the scan-then-promote rationale. pub async fn copy_object(&self, src_key: &S3Key, dst_key: &S3Key) -> Result<()> { self.inner .copy_object(src_key.as_str(), dst_key.as_str()) .await .map_err(AppError::Storage) } /// Server-side copy from `src_bucket` into this client's bucket. See the /// [`StorageBackend::copy_object_from`] trait method for the cross-bucket /// promote rationale. pub async fn copy_object_from( &self, src_bucket: &str, src_key: &S3Key, dst_key: &S3Key, ) -> Result<()> { self.inner .copy_object_from(src_bucket, src_key.as_str(), dst_key.as_str()) .await .map_err(AppError::Storage) } /// Batched S3 delete (`DeleteObjects`, up to 1000 keys per call). /// Chunks larger slices into 1000-key batches and logs per-key failures /// without bubbling, the pending_s3_deletions queue is the safety net. pub async fn delete_objects(&self, keys: &[S3Key]) -> Result<()> { if keys.is_empty() { return Ok(()); } for chunk in keys.chunks(1000) { let chunk: Vec = chunk.iter().map(|k| k.as_str().to_string()).collect(); match self.inner.delete_objects(&chunk).await { Ok(failures) => { for (k, msg) in &failures { tracing::warn!(key = %k, error = %msg, "S3 delete_objects: key-level failure"); } // A whole-batch failure must not read as success (Run #2 // Storage MINOR); partial failures stay logged and the // pending_s3_deletions queue is the retry net. if !chunk.is_empty() && failures.len() == chunk.len() { return Err(AppError::Storage(format!( "S3 delete_objects: all {} keys in batch failed", chunk.len() ))); } } Err(e) => return Err(AppError::Storage(e)), } } Ok(()) } /// Upload a file to S3 using multipart upload (10 MB parts). pub async fn upload_multipart( &self, s3_key: &S3Key, content_type: &str, file_path: &std::path::Path, ) -> Result<()> { self.inner .upload_multipart(s3_key.as_str(), content_type, file_path, None) .await .map_err(AppError::Storage) } /// Server-side multipart copy for sources over the 5 GiB single-part /// `CopyObject` limit. See the [`StorageBackend::copy_object_multipart`] /// trait method for the promote rationale. `part_size` of `None` lets the /// storage layer auto-size parts for `src_size`. pub async fn copy_object_multipart( &self, src_bucket: &str, src_key: &S3Key, dst_key: &S3Key, content_type: &str, src_size: u64, part_size: Option, ) -> Result<()> { self.inner .copy_object_multipart( src_bucket, src_key.as_str(), dst_key.as_str(), content_type, src_size, part_size, ) .await .map_err(AppError::Storage) } /// Begin a client-direct multipart upload. See the /// [`StorageBackend::create_multipart_upload`] trait method. pub async fn create_multipart_upload( &self, s3_key: &S3Key, content_type: &str, ) -> Result { self.inner .create_multipart_upload(s3_key.as_str(), content_type) .await .map_err(AppError::Storage) } /// Presign one `UploadPart` request. See the /// [`StorageBackend::presign_upload_part`] trait method. pub async fn presign_upload_part( &self, s3_key: &S3Key, upload_id: &str, part_number: i32, expiry_secs: Option, max_bytes: Option, checksum_sha256: Option<&str>, ) -> Result { self.inner .presign_upload_part( s3_key.as_str(), upload_id, part_number, expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS), max_bytes, checksum_sha256, ) .await .map_err(AppError::Storage) } /// Complete a multipart upload. See the /// [`StorageBackend::complete_multipart_upload`] trait method. pub async fn complete_multipart_upload( &self, s3_key: &S3Key, upload_id: &str, parts: &[(i32, String)], ) -> Result<()> { self.inner .complete_multipart_upload(s3_key.as_str(), upload_id, parts) .await .map_err(AppError::Storage) } /// Abort a multipart upload. See the /// [`StorageBackend::abort_multipart_upload`] trait method. pub async fn abort_multipart_upload(&self, s3_key: &S3Key, upload_id: &str) -> Result<()> { self.inner .abort_multipart_upload(s3_key.as_str(), upload_id) .await .map_err(AppError::Storage) } /// In-progress multipart sessions for a key. See the /// [`StorageBackend::list_multipart_uploads_for_key`] trait method. pub async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result> { self.inner .list_multipart_uploads_for_key(s3_key) .await .map_err(AppError::Storage) } /// Lightweight connectivity check, issues a list with max_keys(0). pub async fn check_connectivity(&self) -> std::result::Result<(), String> { self.inner.check_connectivity().await } } /// Sanitize a filename: keep only alphanumeric, dots, dashes, and underscores. /// Prevents path traversal, shell injection, and S3 key encoding issues. /// Falls back to "file" if the sanitized result has no basename (only extension or empty). /// /// **By design**: the sanitizer keeps `.`/`-`/`_` and strips everything else, /// so e.g. `"../etc/passwd"` collapses to `"..etcpasswd"`, preserved as a /// literal filename, not as a directory traversal. The unit test pins this /// behavior: we don't reject names containing `..`, we just guarantee the /// output has no path separators. S3 keys are namespaced by user/item ID /// upstream, so a flat literal here can't escape the user's prefix. /// /// `pub(crate)` so confirm handlers store a filename that matches the tail of /// the key `generate_media_key` produced, rather than re-deriving a weaker /// filter that drops the empty-basename fallback. /// The lowercased ASCII-alphanumeric file extension for a staging/content key, /// or `"bin"` when the filename has none. Bounded to 16 chars so a crafted /// filename can't bloat the key. Content keys carry an extension purely so /// CDN-served objects keep a sensible suffix (content-type sniffing, browser /// "save as"); the hash is the identity, the extension is cosmetic. // Retained as a tested key-extension utility; `generate_staging_key` now embeds // the full sanitized filename (which carries the extension) instead of calling // this, so it has no production caller today. #[allow(dead_code)] pub(crate) fn extension_for(filename: &str) -> String { let ext: String = std::path::Path::new(filename) .extension() .and_then(|s| s.to_str()) .unwrap_or("") .chars() .filter(char::is_ascii_alphanumeric) .map(|c| c.to_ascii_lowercase()) .take(16) .collect(); if ext.is_empty() { "bin".to_string() } else { ext } } /// The extension segment of a key's basename (the text after the last `.`), or /// `"bin"`. Lets the scan worker's promote step carry a staging object's /// extension onto its content key without re-reading the entity row. pub(crate) fn key_extension(key: &str) -> &str { key.rsplit('/') .next() .and_then(|base| base.rsplit_once('.').map(|(_, ext)| ext)) .filter(|ext| !ext.is_empty()) .unwrap_or("bin") } /// The MIME type registered for `ext` under `file_type`, falling back to /// `application/octet-stream` for an extension outside the allow-list. /// /// Needed by the multipart promote path: a single `CopyObject` carries the /// source object's metadata across, but a multipart copy writes a *fresh* /// destination object whose content type comes from `CreateMultipartUpload`, so /// the promote has to name it explicitly or the served object would default to /// the wrong type. pub(crate) fn content_type_for(file_type: FileType, ext: &str) -> &'static str { file_type .allowed_types() .iter() .find(|(e, _)| e.eq_ignore_ascii_case(ext)) .map_or("application/octet-stream", |(_, ct)| *ct) } pub(crate) fn sanitize_filename(filename: &str) -> String { let sanitized: String = filename .chars() .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_') .collect(); // Ensure the result has a non-empty basename (not just ".ext" or empty) let stem = std::path::Path::new(&sanitized) .file_stem() .and_then(|s| s.to_str()) .unwrap_or(""); if stem.is_empty() { let ext = std::path::Path::new(&sanitized) .extension() .and_then(|s| s.to_str()) .unwrap_or(""); if ext.is_empty() { "file".to_string() } else { format!("file.{ext}") } } else { sanitized } } /// Sanitize a folder name: keep only alphanumeric, dashes, and underscores. /// Rejects path traversal (`..`) and slashes. Returns empty string for root folder. pub fn sanitize_folder(folder: &str) -> String { let trimmed = folder.trim(); if trimmed.is_empty() { return String::new(); } // Reject any path traversal if trimmed.contains("..") { return String::new(); } trimmed .chars() .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') .collect() } /// Extract the S3 key from a CDN or presigned URL. /// /// Accepts two URL shapes: /// - **CDN**: `https://cdn.example.com/{s3_key}`, caller supplies the /// CDN base; the function strips it verbatim. /// - **Path-style S3**: `https://{host}/{bucket}/{s3_key}?...`, caller /// supplies the bucket name; the function strips host + bucket prefix. /// /// Returns `None` if neither prefix matches. Query strings (presigned URL /// signatures) are stripped before returning. /// /// **Why explicit prefixes**: the prior implementation used /// `find("projects/")` as a heuristic, which would silently mis-key any URL /// whose path happened to contain the literal substring (e.g. a key with a /// `projects/` suffix inside a user folder). Passing the known CDN base and /// bucket eliminates the heuristic entirely. pub fn extract_s3_key_from_url( url: &str, cdn_base: &str, bucket: Option<&str>, s3_endpoint: Option<&str>, ) -> Option { let no_query = url.split('?').next()?; // Try CDN-base prefix first. An empty base matches nothing rather than // matching everything: `strip_prefix("")` succeeds on any input, so the // guard is what keeps a caller that passes "" from harvesting a key out of // an arbitrary host. if !cdn_base.is_empty() { let base = cdn_base.trim_end_matches('/'); if let Some(rest) = no_query.strip_prefix(base) && let Some(key) = rest.strip_prefix('/') && !key.is_empty() { return Some(key.to_string()); } } // Path-style S3: must match the configured `{endpoint}/{bucket}/` exactly. // Without the endpoint pin, the prior implementation accepted any // `https://{any-host}/{bucket}/{key}`, so an attacker-controlled URL like // `https://attacker.example/my-bucket/poisoned` would extract a real-looking // key and direct downstream code at attacker-chosen storage paths. if let (Some(bucket), Some(endpoint)) = (bucket, s3_endpoint) { let endpoint = endpoint.trim_end_matches('/'); let prefix = format!("{endpoint}/{bucket}/"); if let Some(key) = no_query.strip_prefix(&prefix) && !key.is_empty() { return Some(key.to_string()); } } None } /// Build a permanent URL for a project image. /// /// Permanent is the whole contract: callers persist the result into /// `projects.cover_image_url`, which is read forever. There is deliberately no /// presigned fallback — an expiring URL in a durable column is the bug this /// signature exists to make unrepresentable. `cdn_base` is required config /// (`Config::cdn_base_url`), so there is nothing to fall back to. pub fn build_project_image_url(cdn_base: &str, s3_key: &str) -> String { format!("{cdn_base}/{s3_key}") } #[async_trait::async_trait] impl StorageBackend for S3Client { async fn presign_upload( &self, s3_key: &S3Key, content_type: &str, expiry_secs: Option, cache_control: Option<&str>, max_bytes: Option, ) -> Result { self.presign_upload(s3_key, content_type, expiry_secs, cache_control, max_bytes) .await } async fn presign_download(&self, s3_key: &S3Key, expiry_secs: Option) -> Result { self.presign_download(s3_key, expiry_secs).await } async fn object_exists(&self, s3_key: &str) -> Result { self.object_exists(s3_key).await } async fn object_size(&self, s3_key: &str) -> Result> { self.object_size(s3_key).await } async fn download_object(&self, s3_key: &str) -> Result> { self.download_object(s3_key).await } async fn download_object_buf(&self, s3_key: &str) -> Result { self.download_object_buf(s3_key).await } async fn download_stream(&self, s3_key: &str) -> Result { self.download_stream(s3_key).await } async fn download_head(&self, s3_key: &str, len: usize) -> Result> { self.download_head(s3_key, len).await } async fn upload_object( &self, s3_key: &S3Key, content_type: &str, data: Vec, cache_control: Option<&str>, ) -> Result<()> { self.upload_object(s3_key, content_type, data, cache_control) .await } async fn delete_object(&self, _auth: &S3DeleteAuthority, s3_key: &S3Key) -> Result<()> { // Authority proven by the caller; delegate to the inherent impl. self.delete_object(s3_key).await } async fn delete_objects(&self, _auth: &S3DeleteAuthority, keys: &[S3Key]) -> Result<()> { self.delete_objects(keys).await } async fn copy_object_from( &self, src_bucket: &str, src_key: &S3Key, dst_key: &S3Key, ) -> Result<()> { // Inherent method; delegate. self.copy_object_from(src_bucket, src_key, dst_key).await } async fn copy_object(&self, src_key: &S3Key, dst_key: &S3Key) -> Result<()> { // Inherent method; delegate. self.copy_object(src_key, dst_key).await } async fn delete_prefix(&self, _auth: &S3DeleteAuthority, prefix: &str) -> Result<()> { self.inner .delete_prefix(prefix) .await .map_err(AppError::Storage) } async fn upload_multipart( &self, s3_key: &S3Key, content_type: &str, file_path: &std::path::Path, ) -> Result<()> { self.upload_multipart(s3_key, content_type, file_path).await } async fn copy_object_multipart( &self, src_bucket: &str, src_key: &S3Key, dst_key: &S3Key, content_type: &str, src_size: u64, part_size: Option, ) -> Result<()> { // Inherent method; delegate. self.copy_object_multipart( src_bucket, src_key, dst_key, content_type, src_size, part_size, ) .await } async fn create_multipart_upload(&self, s3_key: &S3Key, content_type: &str) -> Result { self.create_multipart_upload(s3_key, content_type).await } async fn presign_upload_part( &self, s3_key: &S3Key, upload_id: &str, part_number: i32, expiry_secs: Option, max_bytes: Option, checksum_sha256: Option<&str>, ) -> Result { self.presign_upload_part( s3_key, upload_id, part_number, expiry_secs, max_bytes, checksum_sha256, ) .await } async fn complete_multipart_upload( &self, s3_key: &S3Key, upload_id: &str, parts: &[(i32, String)], ) -> Result<()> { self.complete_multipart_upload(s3_key, upload_id, parts) .await } async fn abort_multipart_upload(&self, s3_key: &S3Key, upload_id: &str) -> Result<()> { self.abort_multipart_upload(s3_key, upload_id).await } async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result> { self.list_multipart_uploads_for_key(s3_key).await } async fn check_connectivity(&self) -> std::result::Result<(), String> { self.check_connectivity().await } fn bucket(&self) -> &str { self.inner.bucket() } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn capped_read_aborts_when_body_exceeds_cap() { // A recorded-small object whose real body is larger must not aggregate // past the cap (Run 22 Perf, the buffered scan-download ceiling). let stream = s3_storage::ByteStream::from(vec![0u8; 100]); let err = read_bytestream_capped(stream, "k", 50).await.unwrap_err(); assert!( matches!(err, AppError::Storage(_)), "expected Storage error, got {err:?}" ); } #[tokio::test] async fn capped_read_allows_body_within_cap() { let stream = s3_storage::ByteStream::from(vec![7u8; 40]); let out = read_bytestream_capped(stream, "k", 50).await.unwrap(); assert_eq!(out.len(), 40); assert!(out.iter().all(|&b| b == 7)); } #[tokio::test] async fn capped_read_allows_body_exactly_at_cap() { // The abort condition is strictly `>`, so a body equal to the cap passes. let stream = s3_storage::ByteStream::from(vec![1u8; 50]); let out = read_bytestream_capped(stream, "k", 50).await.unwrap(); assert_eq!(out.len(), 50); } #[test] fn extract_key_cdn_form() { let key = extract_s3_key_from_url( "https://cdn.makenot.work/projects/abc/image/cover.png", "https://cdn.makenot.work", None, None, ); assert_eq!(key.as_deref(), Some("projects/abc/image/cover.png")); } #[test] fn extract_key_cdn_with_trailing_slash_in_base() { let key = extract_s3_key_from_url( "https://cdn.makenot.work/foo/bar", "https://cdn.makenot.work/", None, None, ); assert_eq!(key.as_deref(), Some("foo/bar")); } #[test] fn extract_key_strips_query_string() { let key = extract_s3_key_from_url( "https://cdn.makenot.work/foo/bar?X-Amz-Signature=zzz", "https://cdn.makenot.work", None, None, ); assert_eq!(key.as_deref(), Some("foo/bar")); } #[test] fn extract_key_path_style_s3() { let key = extract_s3_key_from_url( "https://fsn1.your-objectstorage.com/my-bucket/u/123/image/cover.png?X-Amz=...", "", Some("my-bucket"), Some("https://fsn1.your-objectstorage.com"), ); assert_eq!(key.as_deref(), Some("u/123/image/cover.png")); } #[test] fn extract_key_path_style_rejects_attacker_host() { // Attacker-controlled host with the legitimate bucket name in the // path must NOT be accepted. The endpoint pin closes the gap. let key = extract_s3_key_from_url( "https://attacker.example/my-bucket/poisoned", "", Some("my-bucket"), Some("https://fsn1.your-objectstorage.com"), ); assert_eq!(key, None); } #[test] fn extract_key_path_style_requires_endpoint() { // Without the endpoint, the path-style branch must not fire, bucket // name alone is not enough to identify a trustworthy host. let key = extract_s3_key_from_url( "https://fsn1.your-objectstorage.com/my-bucket/u/123/key", "", Some("my-bucket"), None, ); assert_eq!(key, None); } #[test] fn extract_key_returns_none_when_no_prefix_matches() { // Neither the CDN base nor the bucket name is present in the URL. let key = extract_s3_key_from_url( "https://random.example.com/foo/bar", "https://cdn.makenot.work", Some("my-bucket"), Some("https://fsn1.your-objectstorage.com"), ); assert_eq!(key, None); } #[test] fn extract_key_does_not_misparse_keys_containing_projects_substring() { // Regression: the old heuristic would have returned just // "projects/x" from this URL, dropping the user-scoped prefix. let key = extract_s3_key_from_url( "https://cdn.makenot.work/u/me/projects/x", "https://cdn.makenot.work", None, None, ); assert_eq!(key.as_deref(), Some("u/me/projects/x")); } #[test] fn test_generate_key() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "episode.mp3"); assert_eq!( key, "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/audio/episode.mp3" ); } #[test] fn test_generate_version_key_is_unique_per_version() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let v1: VersionId = "33333333-3333-3333-3333-333333333333".parse().unwrap(); let v2: VersionId = "44444444-4444-4444-4444-444444444444".parse().unwrap(); // Two versions of the SAME item sharing a filename must not collide. let k1 = S3Client::generate_version_key(user_id, item_id, v1, "plugin.zip"); let k2 = S3Client::generate_version_key(user_id, item_id, v2, "plugin.zip"); assert_ne!(k1, k2, "same-filename versions must produce distinct keys"); assert_eq!( k1, "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/download/33333333-3333-3333-3333-333333333333/plugin.zip" ); // Confirm-handler prefix check is `{user}/{item}/`; the woven key still // satisfies it. assert!(k1.starts_with( "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/" )); // Filename is still the last path segment (confirm extracts it via rsplit). assert_eq!(k1.rsplit('/').next(), Some("plugin.zip")); } #[test] fn test_generate_version_key_sanitizes_filename() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let v1: VersionId = "33333333-3333-3333-3333-333333333333".parse().unwrap(); let key = S3Client::generate_version_key(user_id, item_id, v1, "my release (1).zip"); assert!(key.ends_with("/myrelease1.zip")); } #[test] fn test_generate_key_sanitizes_filename() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "my file (1).mp3"); assert!(key.ends_with("/myfile1.mp3")); } #[test] fn test_validate_content_type() { assert!(S3Client::validate_content_type(FileType::Audio, "audio/mpeg").is_ok()); assert!(S3Client::validate_content_type(FileType::Audio, "audio/wav").is_ok()); assert!(S3Client::validate_content_type(FileType::Audio, "image/png").is_err()); assert!(S3Client::validate_content_type(FileType::Cover, "image/png").is_ok()); assert!(S3Client::validate_content_type(FileType::Cover, "image/jpeg").is_ok()); assert!(S3Client::validate_content_type(FileType::Cover, "audio/mpeg").is_err()); } #[test] fn content_type_for_maps_allowed_extensions() { // The multipart promote names the destination's type explicitly, so this // must agree with the allow-list the upload was validated against. assert_eq!(content_type_for(FileType::Video, "mp4"), "video/mp4"); assert_eq!(content_type_for(FileType::Video, "webm"), "video/webm"); assert_eq!(content_type_for(FileType::Cover, "png"), "image/png"); // Extensions arrive from a key, which may carry any case. assert_eq!(content_type_for(FileType::Video, "MP4"), "video/mp4"); } #[test] fn content_type_for_falls_back_for_unknown_extension() { // `key_extension` yields "bin" when a key has no extension; an unknown // extension must degrade to a generic type, never panic or mis-label. assert_eq!( content_type_for(FileType::Video, "bin"), "application/octet-stream" ); assert_eq!( content_type_for(FileType::Video, ""), "application/octet-stream" ); } #[test] fn single_copy_ceiling_matches_s3_and_is_distinct_from_the_browser_ceiling() { // Different limits, and since the browser cap dropped to 2 GiB, different // numbers too: the copy ceiling is what S3 enforces on a one-shot // `CopyObject`, the browser cap is a product call about resumability. // Pinned so a future change to one doesn't silently move the other. assert_eq!( crate::constants::S3_SINGLE_COPY_MAX_BYTES, 5 * 1024 * 1024 * 1024 ); assert_eq!( crate::constants::BROWSER_UPLOAD_MAX_BYTES, 2 * 1024 * 1024 * 1024 ); } #[test] fn test_validate_extension() { assert!(S3Client::validate_extension(FileType::Audio, "episode.mp3").is_ok()); assert!(S3Client::validate_extension(FileType::Audio, "episode.MP3").is_ok()); assert!(S3Client::validate_extension(FileType::Audio, "episode.png").is_err()); assert!(S3Client::validate_extension(FileType::Cover, "cover.jpg").is_ok()); assert!(S3Client::validate_extension(FileType::Cover, "cover.webp").is_ok()); assert!(S3Client::validate_extension(FileType::Cover, "cover.mp3").is_err()); } #[test] fn test_file_type_from_str() { assert_eq!(FileType::from_str("audio"), Ok(FileType::Audio)); assert_eq!(FileType::from_str("AUDIO"), Ok(FileType::Audio)); assert_eq!(FileType::from_str("cover"), Ok(FileType::Cover)); assert_eq!(FileType::from_str("image"), Ok(FileType::Cover)); assert!(FileType::from_str("invalid").is_err()); } #[test] fn file_type_as_str() { assert_eq!(FileType::Audio.as_str(), "audio"); assert_eq!(FileType::Cover.as_str(), "cover"); } #[test] fn file_type_max_size() { assert_eq!(FileType::Audio.max_size(), 500 * 1024 * 1024); assert_eq!(FileType::Cover.max_size(), 10 * 1024 * 1024); } #[test] fn file_type_allowed_types_audio() { let types = FileType::Audio.allowed_types(); let exts: Vec<&str> = types.iter().map(|(e, _)| *e).collect(); assert!(exts.contains(&"mp3")); assert!(exts.contains(&"wav")); assert!(exts.contains(&"flac")); assert!(!exts.contains(&"png")); } #[test] fn file_type_allowed_types_cover() { let types = FileType::Cover.allowed_types(); let exts: Vec<&str> = types.iter().map(|(e, _)| *e).collect(); assert!(exts.contains(&"jpg")); assert!(exts.contains(&"png")); assert!(exts.contains(&"webp")); assert!(!exts.contains(&"mp3")); } #[test] fn generate_key_strips_path_traversal() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "../../etc/passwd"); // Slashes are stripped, dots kept: "../../etc/passwd" -> "....etcpasswd" assert!(key.ends_with("/audio/....etcpasswd")); } #[test] fn generate_key_empty_filename_gets_fallback() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Cover, ""); assert!( key.ends_with("/cover/file"), "expected fallback name 'file', got: {key}" ); } #[test] fn validate_extension_no_extension() { assert!(S3Client::validate_extension(FileType::Audio, "noext").is_err()); } #[test] fn validate_extension_double_dot() { assert!(S3Client::validate_extension(FileType::Audio, "file.backup.mp3").is_ok()); } #[test] fn validate_content_type_empty() { assert!(S3Client::validate_content_type(FileType::Audio, "").is_err()); } #[test] fn file_type_insertion_from_str() { assert_eq!(FileType::from_str("insertion"), Ok(FileType::Insertion)); assert_eq!(FileType::from_str("INSERTION"), Ok(FileType::Insertion)); } #[test] fn file_type_insertion_as_str() { assert_eq!(FileType::Insertion.as_str(), "insertion"); } #[test] fn file_type_insertion_max_size() { assert_eq!(FileType::Insertion.max_size(), 500 * 1024 * 1024); } #[test] fn validate_insertion_content_types() { // Audio clips (the original use). assert!(S3Client::validate_content_type(FileType::Insertion, "audio/mpeg").is_ok()); assert!(S3Client::validate_content_type(FileType::Insertion, "audio/wav").is_ok()); assert!(S3Client::validate_content_type(FileType::Insertion, "audio/flac").is_ok()); // Video clips (pre/mid/post-roll on video items). assert!(S3Client::validate_content_type(FileType::Insertion, "video/mp4").is_ok()); assert!(S3Client::validate_content_type(FileType::Insertion, "video/webm").is_ok()); assert!(S3Client::validate_content_type(FileType::Insertion, "video/quicktime").is_ok()); // Neither audio nor video is rejected. assert!(S3Client::validate_content_type(FileType::Insertion, "image/png").is_err()); } #[test] fn validate_insertion_extensions() { assert!(S3Client::validate_extension(FileType::Insertion, "intro.mp3").is_ok()); assert!(S3Client::validate_extension(FileType::Insertion, "sponsor.wav").is_ok()); assert!(S3Client::validate_extension(FileType::Insertion, "outro.flac").is_ok()); assert!(S3Client::validate_extension(FileType::Insertion, "bumper.mp4").is_ok()); assert!(S3Client::validate_extension(FileType::Insertion, "bumper.webm").is_ok()); assert!(S3Client::validate_extension(FileType::Insertion, "clip.png").is_err()); } #[test] fn insertion_media_type_classifies_by_mime_family() { assert_eq!(S3Client::insertion_media_type("audio/mpeg"), "audio"); assert_eq!(S3Client::insertion_media_type("audio/mp4"), "audio"); assert_eq!(S3Client::insertion_media_type("video/mp4"), "video"); assert_eq!(S3Client::insertion_media_type("video/webm"), "video"); } #[test] fn generate_insertion_key_format() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let key = S3Client::generate_insertion_key(user_id, "intro.mp3"); assert_eq!( key, "11111111-1111-1111-1111-111111111111/insertions/intro.mp3" ); } #[test] fn generate_insertion_key_sanitizes() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let key = S3Client::generate_insertion_key(user_id, "my sponsor read (v2).mp3"); assert_eq!( key, "11111111-1111-1111-1111-111111111111/insertions/mysponsorreadv2.mp3" ); } #[test] fn extension_for_cases() { assert_eq!(extension_for("plugin.zip"), "zip"); assert_eq!(extension_for("LOUD.WAV"), "wav"); // lowercased assert_eq!(extension_for("archive.tar.gz"), "gz"); // last segment only assert_eq!(extension_for("noextension"), "bin"); // fallback assert_eq!(extension_for("trailing."), "bin"); // empty ext → fallback assert_eq!(extension_for("weird.z!p"), "zp"); // non-alnum stripped } #[test] fn key_extension_cases() { assert_eq!(key_extension("staging/2f9a.zip"), "zip"); assert_eq!(key_extension("uid/c/abcd1234.mp3"), "mp3"); assert_eq!(key_extension("staging/no-dot-basename"), "bin"); assert_eq!(key_extension("dir.with.dot/basename"), "bin"); // dot in dir, not basename } #[test] fn staging_key_is_unserved_and_carries_extension() { let key = S3Client::generate_staging_key("release.zip"); assert!(key.as_str().starts_with("staging/"), "staging key: {key}"); assert_eq!(key_extension(key.as_str()), "zip"); // Two calls never collide (random uuid), so a replayed PUT can't target // another upload's staging object. let key2 = S3Client::generate_staging_key("release.zip"); assert_ne!(key, key2); } #[test] fn content_key_is_hash_addressed_and_owner_namespaced() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; let key = S3Client::content_key(user_id, sha, "zip"); assert_eq!( key, "11111111-1111-1111-1111-111111111111/c/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.zip" ); // Same bytes, different owner → different key (no cross-tenant sharing). let other: UserId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); assert_ne!(S3Client::content_key(other, sha, "zip"), key); } #[test] fn generate_key_cover_type() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Cover, "art.png"); assert!(key.contains("/cover/")); assert!(key.ends_with("art.png")); } // FileType::Download tests #[test] fn file_type_download_from_str() { assert_eq!(FileType::from_str("download"), Ok(FileType::Download)); assert_eq!(FileType::from_str("DOWNLOAD"), Ok(FileType::Download)); } #[test] fn file_type_download_as_str() { assert_eq!(FileType::Download.as_str(), "download"); } #[test] fn file_type_download_max_size() { assert_eq!(FileType::Download.max_size(), 500 * 1024 * 1024); } #[test] fn validate_download_content_types() { assert!( S3Client::validate_content_type(FileType::Download, "application/octet-stream").is_ok() ); assert!(S3Client::validate_content_type(FileType::Download, "application/zip").is_ok()); assert!( S3Client::validate_content_type(FileType::Download, "application/x-apple-diskimage") .is_ok() ); assert!(S3Client::validate_content_type(FileType::Download, "application/gzip").is_ok()); assert!(S3Client::validate_content_type(FileType::Download, "application/x-tar").is_ok()); // Reject clearly wrong types assert!(S3Client::validate_content_type(FileType::Download, "text/html").is_err()); assert!(S3Client::validate_content_type(FileType::Download, "image/png").is_err()); } #[test] fn validate_download_extensions() { assert!(S3Client::validate_extension(FileType::Download, "app.zip").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.dmg").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.exe").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.appimage").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.deb").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.tar.gz").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.clap").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "app.vst3").is_ok()); assert!(S3Client::validate_extension(FileType::Download, "App.ZIP").is_ok()); // Reject invalid extensions assert!(S3Client::validate_extension(FileType::Download, "app.mp3").is_err()); assert!(S3Client::validate_extension(FileType::Download, "app.txt").is_err()); } #[test] fn generate_key_download_type() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Download, "plugin-v1.0.zip"); assert!(key.contains("/download/")); assert!(key.ends_with("plugin-v1.0.zip")); } // CDN tests #[test] fn cache_control_immutable_format() { assert!(CACHE_CONTROL_IMMUTABLE.contains("public")); assert!(CACHE_CONTROL_IMMUTABLE.contains("max-age=31536000")); assert!(CACHE_CONTROL_IMMUTABLE.contains("immutable")); } #[test] fn generate_project_image_key_format() { let project_id: ProjectId = "33333333-3333-3333-3333-333333333333".parse().unwrap(); let key = S3Client::generate_project_image_key(project_id, "logo.png"); assert_eq!( key, "projects/33333333-3333-3333-3333-333333333333/image/logo.png" ); } #[test] fn generate_project_image_key_sanitizes() { let project_id: ProjectId = "33333333-3333-3333-3333-333333333333".parse().unwrap(); let key = S3Client::generate_project_image_key(project_id, "my logo (v2).png"); assert_eq!( key, "projects/33333333-3333-3333-3333-333333333333/image/mylogov2.png" ); } #[test] fn cdn_url_from_s3_key() { let cdn_base = "https://cdn.makenot.work"; let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "episode.mp3"); let cdn_url = format!("{cdn_base}/{key}"); assert_eq!( cdn_url, "https://cdn.makenot.work/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/audio/episode.mp3" ); } // FileType::Video tests #[test] fn file_type_video_from_str() { assert_eq!(FileType::from_str("video"), Ok(FileType::Video)); assert_eq!(FileType::from_str("VIDEO"), Ok(FileType::Video)); } #[test] fn file_type_video_as_str() { assert_eq!(FileType::Video.as_str(), "video"); } #[test] fn file_type_video_max_size() { assert_eq!(FileType::Video.max_size(), 20 * 1024 * 1024 * 1024); } #[test] fn validate_video_content_types() { assert!(S3Client::validate_content_type(FileType::Video, "video/mp4").is_ok()); assert!(S3Client::validate_content_type(FileType::Video, "video/webm").is_ok()); assert!(S3Client::validate_content_type(FileType::Video, "video/quicktime").is_ok()); assert!(S3Client::validate_content_type(FileType::Video, "audio/mpeg").is_err()); assert!( S3Client::validate_content_type(FileType::Video, "application/octet-stream").is_err() ); assert!(S3Client::validate_content_type(FileType::Video, "text/html").is_err()); } #[test] fn validate_video_extensions() { assert!(S3Client::validate_extension(FileType::Video, "clip.mp4").is_ok()); assert!(S3Client::validate_extension(FileType::Video, "clip.webm").is_ok()); assert!(S3Client::validate_extension(FileType::Video, "clip.mov").is_ok()); assert!(S3Client::validate_extension(FileType::Video, "Clip.MP4").is_ok()); assert!(S3Client::validate_extension(FileType::Video, "clip.avi").is_err()); assert!(S3Client::validate_extension(FileType::Video, "clip.mp3").is_err()); } #[test] fn generate_key_video_type() { let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap(); let key = S3Client::generate_key(user_id, item_id, FileType::Video, "tutorial.mp4"); assert!(key.contains("/video/")); assert!(key.ends_with("tutorial.mp4")); } } /// Build-time enforcement: route handlers must never /// delete S3 objects directly, nor mint an [`S3DeleteAuthority`]. Direct /// deletion is for the sanctioned durable-deletion paths (`scheduler/cleanup.rs`, /// `scanning/worker.rs`) only; handlers enqueue through `pending_s3_deletions`. /// /// The type system already makes the accidental `s3.delete_object(key)` /// uncompilable (the delete methods require an authority handlers can't reach). /// This test closes the deliberate-circumvention gap: it fails the build if any /// file under `src/routes/` names a delete method or the authority type, so the /// seal cannot silently erode in a future handler. #[cfg(test)] mod delete_seal_guard { use std::path::Path; #[test] fn routes_never_delete_s3_directly() { let routes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/routes"); let mut offenders = Vec::new(); walk(&routes_dir, &mut |path, contents| { for (i, line) in contents.lines().enumerate() { // Skip comment/doc lines (they legitimately mention the API). if line.trim_start().starts_with("//") { continue; } if line.contains(".delete_object(") || line.contains(".delete_objects(") || line.contains(".delete_prefix(") || line.contains("S3DeleteAuthority") { offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); } } }); assert!( offenders.is_empty(), "CHRONIC B' seal violated, route code must enqueue via \ routes::storage::enqueue_s3_orphan, never delete S3 directly or mint an \ S3DeleteAuthority. Offending lines:\n{}", offenders.join("\n") ); } fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { walk(&path, f); } else if path.extension().is_some_and(|e| e == "rs") && let Ok(contents) = std::fs::read_to_string(&path) { f(&path, &contents); } } } } /// C1 scan-then-promote seal: route handlers must never mint a *served* S3 key. /// /// A presigned client upload can only ever land at a `staging/{uuid}` key /// ([`S3Client::generate_staging_key`]); the served, content-addressed key /// ([`S3Client::content_key`]) is created in exactly one place, the scan /// worker's promote step, after a Clean verdict, so the bytes a buyer is served /// are provably the bytes that were scanned. The mutable-served-key class (a /// presign minting `{user}/{item}/type/filename`, then the owner re-PUTting to it /// after it goes Clean) is what this closes. /// /// This guard fails the build if any file under `src/routes/` names a served-key /// generator or `content_key`. It is stronger than `pub(crate)` visibility, /// route code lives in the same crate, so `pub(crate)` would not stop it from /// calling these, and it is the same grep-proof discipline as the delete seal /// above. (The build runner uploads OTA artifacts server-side to a deterministic /// key via `generate_ota_artifact_key`; it lives outside `src/routes/`, so it is /// legitimately unaffected.) #[cfg(test)] mod served_key_seal_guard { use std::path::Path; #[test] fn routes_never_mint_served_keys() { let routes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/routes"); // Every served-key generator, plus the content-key minter. `staging` // keys are the ONLY key a route may mint, so `generate_staging_key` is // deliberately absent from this list. // Anchored to the `S3Client::` call prefix so an unrelated `generate_key` // (e.g. `license_keys::generate_key`, `helpers::generate_key_code`) is not // a false positive, only the storage generators are S3Client methods. const FORBIDDEN: &[&str] = &[ "S3Client::generate_key(", "S3Client::generate_version_key(", "S3Client::generate_insertion_key(", "S3Client::generate_media_key(", "S3Client::generate_project_image_key(", "S3Client::generate_ota_artifact_key(", "S3Client::generate_item_gallery_key(", "S3Client::generate_project_gallery_key(", "S3Client::content_key(", ]; let mut offenders = Vec::new(); walk(&routes_dir, &mut |path, contents| { for (i, line) in contents.lines().enumerate() { if line.trim_start().starts_with("//") { continue; } for needle in FORBIDDEN { if line.contains(needle) { offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); } } } }); assert!( offenders.is_empty(), "C1 seal violated, route handlers must presign only `generate_staging_key`; \ the served/content key is minted solely by the scan worker's promote step. \ Offending lines:\n{}", offenders.join("\n") ); } fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { walk(&path, f); } else if path.extension().is_some_and(|e| e == "rs") && let Ok(contents) = std::fs::read_to_string(&path) { f(&path, &contents); } } } }