Skip to main content

max / makenotwork

87.4 KB · 2230 lines History Blame Raw
1 //! S3-compatible storage client for Hetzner Object Storage
2 //!
3 //! Provides presigned URL generation for client-direct uploads/downloads.
4 //! Delegates S3 operations to the shared `s3_storage` crate.
5 //!
6 //! See also: `/docs/guide/audio`, `/docs/guide/video`, `/docs/guide/software`
7
8 use std::str::FromStr;
9
10 use crate::config::StorageConfig;
11 use crate::db::{ItemId, ProjectId, SyncAppId, UserId, VersionId};
12 use crate::error::{AppError, Result};
13
14 /// A storage object key. There are exactly two ways to obtain one, and an
15 /// ad-hoc `format!("...")` is neither:
16 ///
17 /// 1. A `S3Client::generate_*` constructor, the single, reviewed home for key
18 /// *layout*. Multi-instance kinds (versions, gallery, media) take their
19 /// uniqueness segment (a table PK or a fresh uuid) as a required argument, so
20 /// a collidable key cannot be built; singleton kinds (audio/cover/video, OTA
21 /// artifacts) are one-per-parent and correctly overwrite-on-replace.
22 /// 2. [`S3Key::from_stored`], the named trust boundary for a key that already
23 /// exists in our storage (read back from a DB row). The caller asserts it was
24 /// minted by a generator at write time; this is how delete/download/re-presign
25 /// paths address objects without re-deriving their layout.
26 ///
27 /// Because every write/presign/delete on [`StorageBackend`] takes `&S3Key`, a
28 /// hand-built string can never reach S3, the OTA-style inline `format!` key
29 /// (which bypassed the generators) is now uncompilable.
30 #[derive(Debug, Clone, PartialEq, Eq, Hash, sqlx::Type)]
31 #[sqlx(transparent)]
32 pub struct S3Key(String);
33
34 impl S3Key {
35 /// Wrap a key read back from durable storage (a DB row). Names the trust
36 /// boundary: the caller asserts this key was minted by a `generate_*`
37 /// constructor when the object was written, not freshly invented here.
38 pub fn from_stored(key: impl AsRef<str>) -> Self {
39 S3Key(key.as_ref().to_string())
40 }
41
42 pub fn as_str(&self) -> &str {
43 &self.0
44 }
45
46 pub fn into_string(self) -> String {
47 self.0
48 }
49 }
50
51 impl std::ops::Deref for S3Key {
52 type Target = str;
53 fn deref(&self) -> &str {
54 &self.0
55 }
56 }
57
58 impl AsRef<str> for S3Key {
59 fn as_ref(&self) -> &str {
60 &self.0
61 }
62 }
63
64 impl std::fmt::Display for S3Key {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 f.write_str(&self.0)
67 }
68 }
69
70 impl PartialEq<&str> for S3Key {
71 fn eq(&self, other: &&str) -> bool {
72 self.0 == *other
73 }
74 }
75
76 /// Allowed audio file extensions and their MIME types
77 const ALLOWED_AUDIO_TYPES: &[(&str, &str)] = &[
78 ("mp3", "audio/mpeg"),
79 ("wav", "audio/wav"),
80 ("m4a", "audio/mp4"),
81 ("ogg", "audio/ogg"),
82 ("flac", "audio/flac"),
83 ("aac", "audio/aac"),
84 ];
85
86 /// Allowed image file extensions and their MIME types
87 const ALLOWED_IMAGE_TYPES: &[(&str, &str)] = &[
88 ("jpg", "image/jpeg"),
89 ("jpeg", "image/jpeg"),
90 ("png", "image/png"),
91 ("webp", "image/webp"),
92 ("gif", "image/gif"),
93 ];
94
95 /// Allowed video file extensions and their MIME types
96 const ALLOWED_VIDEO_TYPES: &[(&str, &str)] = &[
97 ("mp4", "video/mp4"),
98 ("webm", "video/webm"),
99 ("mov", "video/quicktime"),
100 ];
101
102 /// Allowed insertion-clip extensions and MIME types. A clip may be audio (the
103 /// original use: intros, sponsor reads) or video (pre/mid/post-roll on a video
104 /// item), so this is the union of the audio and video allow-lists. Kept as one
105 /// literal because `allowed_types()` returns a `&'static` slice.
106 const ALLOWED_INSERTION_TYPES: &[(&str, &str)] = &[
107 ("mp3", "audio/mpeg"),
108 ("wav", "audio/wav"),
109 ("m4a", "audio/mp4"),
110 ("ogg", "audio/ogg"),
111 ("flac", "audio/flac"),
112 ("aac", "audio/aac"),
113 ("mp4", "video/mp4"),
114 ("webm", "video/webm"),
115 ("mov", "video/quicktime"),
116 ];
117
118 /// MIME types accepted for video uploads
119 const ALLOWED_VIDEO_MIMES: &[&str] = &["video/mp4", "video/webm", "video/quicktime"];
120
121 /// Allowed download file extensions and their MIME types
122 /// Browsers are inconsistent about MIME types for binary downloads,
123 /// so we accept several common types for each extension.
124 const ALLOWED_DOWNLOAD_TYPES: &[(&str, &str)] = &[
125 ("zip", "application/zip"),
126 ("dmg", "application/x-apple-diskimage"),
127 ("exe", "application/octet-stream"),
128 ("appimage", "application/octet-stream"),
129 ("deb", "application/octet-stream"),
130 ("clap", "application/octet-stream"),
131 ("vst3", "application/octet-stream"),
132 ];
133
134 /// MIME types accepted for download uploads (browsers vary widely)
135 const ALLOWED_DOWNLOAD_MIMES: &[&str] = &[
136 "application/octet-stream",
137 "application/zip",
138 "application/x-zip-compressed",
139 "application/x-apple-diskimage",
140 "application/x-diskcopy",
141 "application/x-msi",
142 "application/x-ole-storage",
143 "application/gzip",
144 "application/x-tar",
145 "application/x-gtar",
146 "application/x-compressed",
147 "application/x-executable",
148 "application/x-deb",
149 "application/vnd.debian.binary-package",
150 ];
151
152 /// Allowed download file extensions (checked separately from MIME)
153 const ALLOWED_DOWNLOAD_EXTENSIONS: &[&str] = &[
154 "zip", "dmg", "exe", "msi", "appimage", "deb", "tar.gz", "clap", "vst3",
155 ];
156
157 /// Maximum file sizes in bytes
158 const MAX_AUDIO_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
159 const MAX_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
160 const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
161 const MAX_INSERTION_SIZE: u64 = 500 * 1024 * 1024; // 500 MB
162 const MAX_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB
163 const MAX_MEDIA_IMAGE_SIZE: u64 = 10 * 1024 * 1024; // 10 MB
164 const MAX_MEDIA_VIDEO_SIZE: u64 = 20 * 1024 * 1024 * 1024; // 20 GB
165
166 /// Default presigned URL expiration.
167 /// 1 hour balances usability (large uploads over slow connections) against
168 /// security (limiting the window for URL leakage). Overridable per-call.
169 const PRESIGN_EXPIRY_SECS: u64 = 3600;
170
171 /// File type categories for upload
172 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
173 pub enum FileType {
174 Audio,
175 Cover,
176 Download,
177 Insertion,
178 Video,
179 /// Media library image (user-scoped, for inline markdown content).
180 MediaImage,
181 /// Media library video (user-scoped, for inline markdown content).
182 MediaVideo,
183 }
184
185 /// How the generic `/api/upload/confirm` handler confirms a file type onto an
186 /// `items` row. Returned by [`FileType::generic_item_confirm`], whose `match`
187 /// is exhaustive, adding a `FileType` variant fails the build until its
188 /// posture is declared here, so a new type can't silently fall into the wrong
189 /// column set (the `Cover` branch that wrote `cover_s3_key` but never
190 /// `cover_image_url`, leaving an invisible cover, Run #13).
191 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
192 pub enum GenericItemConfirm {
193 /// Confirmable by the generic handler: write these two columns on the item.
194 /// Only types fully described by `(s3_key, size)` belong here, anything
195 /// that needs an extra column (e.g. a CDN render URL) must use a dedicated
196 /// route instead.
197 Columns {
198 s3_key: &'static str,
199 size: &'static str,
200 },
201 /// Not confirmable by the generic handler, use this dedicated route. The
202 /// handler rejects the request (after cleaning up the staged object) so a
203 /// misrouted confirm never half-writes the row.
204 UseRoute(&'static str),
205 }
206
207 impl FileType {
208 /// Declare, exhaustively, how `/api/upload/confirm` treats this type.
209 /// See [`GenericItemConfirm`] for why this is the single source of truth.
210 pub fn generic_item_confirm(self) -> GenericItemConfirm {
211 match self {
212 FileType::Audio => GenericItemConfirm::Columns {
213 s3_key: "audio_s3_key",
214 size: "audio_file_size_bytes",
215 },
216 FileType::Video => GenericItemConfirm::Columns {
217 s3_key: "video_s3_key",
218 size: "video_file_size_bytes",
219 },
220 // Cover IS confirmable, but it must also set `cover_image_url` (the
221 // CDN render source). The generic two-column writer can't, so covers
222 // go through the dedicated route that writes all three atomically.
223 FileType::Cover => GenericItemConfirm::UseRoute("/api/items/image/confirm"),
224 FileType::Download => {
225 GenericItemConfirm::UseRoute("/api/versions/{version_id}/upload/*")
226 }
227 FileType::Insertion => GenericItemConfirm::UseRoute("/api/users/me/insertions/*"),
228 FileType::MediaImage | FileType::MediaVideo => {
229 GenericItemConfirm::UseRoute("/api/media/*")
230 }
231 }
232 }
233
234 pub fn as_str(&self) -> &'static str {
235 match self {
236 FileType::Audio => "audio",
237 FileType::Cover => "cover",
238 FileType::Download => "download",
239 FileType::Insertion => "insertion",
240 FileType::Video => "video",
241 FileType::MediaImage => "media_image",
242 FileType::MediaVideo => "media_video",
243 }
244 }
245
246 pub fn max_size(&self) -> u64 {
247 match self {
248 FileType::Audio => MAX_AUDIO_SIZE,
249 FileType::Cover => MAX_IMAGE_SIZE,
250 FileType::Download => MAX_DOWNLOAD_SIZE,
251 FileType::Insertion => MAX_INSERTION_SIZE,
252 FileType::Video => MAX_VIDEO_SIZE,
253 FileType::MediaImage => MAX_MEDIA_IMAGE_SIZE,
254 FileType::MediaVideo => MAX_MEDIA_VIDEO_SIZE,
255 }
256 }
257
258 pub fn allowed_types(&self) -> &'static [(&'static str, &'static str)] {
259 match self {
260 FileType::Audio => ALLOWED_AUDIO_TYPES,
261 FileType::Insertion => ALLOWED_INSERTION_TYPES,
262 FileType::Cover | FileType::MediaImage => ALLOWED_IMAGE_TYPES,
263 FileType::Download => ALLOWED_DOWNLOAD_TYPES,
264 FileType::Video | FileType::MediaVideo => ALLOWED_VIDEO_TYPES,
265 }
266 }
267 }
268
269 impl FromStr for FileType {
270 type Err = String;
271
272 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
273 match s.to_lowercase().as_str() {
274 "audio" => Ok(FileType::Audio),
275 "cover" | "image" => Ok(FileType::Cover),
276 "download" => Ok(FileType::Download),
277 "insertion" => Ok(FileType::Insertion),
278 "video" => Ok(FileType::Video),
279 "media_image" => Ok(FileType::MediaImage),
280 "media_video" => Ok(FileType::MediaVideo),
281 _ => Err(format!("Invalid file type: {s}")),
282 }
283 }
284 }
285
286 /// Cache-Control value for immutable content (builds, audio, covers).
287 /// One year with immutable directive, Cloudflare and browsers cache indefinitely.
288 pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
289
290 /// Capability proof required by every `StorageBackend` delete method.
291 ///
292 /// Direct S3 deletion is sealed off from route handlers: the delete methods
293 /// take `&S3DeleteAuthority`, so the accidental `s3.delete_object(key)` from a
294 /// handler no longer compiles. Route code must instead enqueue through
295 /// `pending_s3_deletions` (e.g. `routes::storage::enqueue_s3_orphan`), whose
296 /// worker applies the `is_s3_key_live` guard before deleting, closing the
297 /// chronic where a handler blind-deleted a key a live row still referenced
298 /// (Run #18 CHRONIC B′).
299 ///
300 /// Minting is `pub(crate)` and confined by convention to the durable-deletion
301 /// paths, the scheduler deletion worker + cleanup (`scheduler/cleanup.rs`) and
302 /// the malware-quarantine scan worker (`scanning/worker.rs`). The build-time
303 /// guard test `routes_never_delete_s3_directly` fails if any file under
304 /// `src/routes/` names a delete method or mints an authority, so the seal can't
305 /// silently erode.
306 pub struct S3DeleteAuthority(());
307
308 impl S3DeleteAuthority {
309 /// Mint a deletion authority. Restricted to the sanctioned durable-deletion
310 /// paths; see the type docs. Route handlers cannot reach a sanctioned path,
311 /// and the guard test enforces that they don't mint one anyway.
312 pub(crate) fn new() -> Self {
313 S3DeleteAuthority(())
314 }
315 }
316
317 /// Which configured S3 backend an object lives in.
318 ///
319 /// The delete *verb* is type-sealed by [`S3DeleteAuthority`]; this seals the
320 /// bucket *noun*. The `pending_s3_deletions` queue stores the bucket as text, and
321 /// the deletion worker dispatches between the main and SyncKit S3 clients on that
322 /// text. This enum is the single source of truth for the `"main"`/`"synckit"`
323 /// spellings so an orphan-enqueue can't silently mis-tag a SyncKit object as
324 /// `main` (where the worker would delete it against the wrong client and leak it
325 /// forever), `enqueue_s3_orphan` now requires an `S3Bucket`, not a bare string
326 /// (ultra-fuzz Run 11 Storage surprise).
327 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
328 pub enum S3Bucket {
329 Main,
330 Synckit,
331 /// Public, CDN-served bucket (`cdn.makenot.work`). Holds ONLY the
332 /// immutably-public image kinds after promote (covers, gallery, item/project
333 /// images, content insertions); a paid object can never enter it, so its
334 /// blanket public-read policy is safe by construction. Staging is never here
335 ///, unscanned bytes stay in `Main`; the content object lands here only via
336 /// the cross-bucket promote (see `scanning::promote_staging_to_content`).
337 Public,
338 }
339
340 impl S3Bucket {
341 /// The stored/text spelling for the deletion queue.
342 pub fn as_str(self) -> &'static str {
343 match self {
344 S3Bucket::Main => "main",
345 S3Bucket::Synckit => "synckit",
346 S3Bucket::Public => "public",
347 }
348 }
349
350 /// Parse a bucket tag read back from the queue. Unknown/legacy values map to
351 /// `Main` (the historical default), so a garbled row is still reaped against
352 /// a backend rather than wedging the queue.
353 pub fn from_db_str(s: &str) -> Self {
354 match s {
355 "synckit" => S3Bucket::Synckit,
356 "public" => S3Bucket::Public,
357 _ => S3Bucket::Main,
358 }
359 }
360 }
361
362 /// Deletion enqueue pair for a content-image key whose promote state is unknown.
363 ///
364 /// A CDN-image key is a private **staging** key (`staging/...`, in `Main`) until
365 /// [`crate::scanning::promote_staging_to_content`] repoints it to the public
366 /// **content** key (`{user}/c/{sha}.ext`, in `Public`). A given object is in
367 /// EXACTLY one bucket, but a delete/replace path can run in either state, so it
368 /// can't know which. Enqueue the key under BOTH buckets: the reaper deletes from
369 /// the bucket the object is in and no-ops the other (content keys are unique to
370 /// one bucket), and `is_s3_key_live` still guards each bucket against a live
371 /// reference. Only for the four CDN-served image surfaces; gated media
372 /// (audio/video/version/media) is always `Main` and insertions always `Main`.
373 pub fn both_bucket_delete(key: &str) -> [(String, String); 2] {
374 [
375 (key.to_string(), S3Bucket::Main.as_str().to_string()),
376 (key.to_string(), S3Bucket::Public.as_str().to_string()),
377 ]
378 }
379
380 /// Aggregate a `ByteStream` into memory, aborting once more than `max_bytes`
381 /// have been read. Backs [`StorageBackend::download_object_buf_capped`]; factored
382 /// out as a free function so the cap logic is unit-testable without a full
383 /// backend. `label` is only used in the error message (the object key).
384 pub(crate) async fn read_bytestream_capped(
385 mut stream: s3_storage::ByteStream,
386 label: &str,
387 max_bytes: u64,
388 ) -> Result<bytes::Bytes> {
389 let mut buf = bytes::BytesMut::new();
390 let mut read: u64 = 0;
391 loop {
392 match stream.try_next().await {
393 Ok(Some(chunk)) => {
394 read += chunk.len() as u64;
395 if read > max_bytes {
396 return Err(AppError::Storage(format!(
397 "object {label} exceeds scan in-memory cap ({read} > {max_bytes} bytes); \
398 recorded size under-reported the real object"
399 )));
400 }
401 buf.extend_from_slice(&chunk);
402 }
403 Ok(None) => break,
404 Err(e) => return Err(AppError::Storage(format!("read object from S3: {e}"))),
405 }
406 }
407 Ok(buf.freeze())
408 }
409
410 /// Abstract storage backend, implemented by `S3Client` (production) and
411 /// `InMemoryStorage` (tests). Routes access storage through this trait.
412 #[async_trait::async_trait]
413 pub trait StorageBackend: Send + Sync {
414 /// Generate a presigned upload URL. `max_bytes`, when set, is signed into
415 /// the URL as `Content-Length` so S3 itself enforces the size cap at the
416 /// protocol level (prevents oversized PUTs from burning bandwidth before
417 /// hitting the post-PUT delete-and-charge fallback).
418 async fn presign_upload(
419 &self,
420 s3_key: &S3Key,
421 content_type: &str,
422 expiry_secs: Option<u64>,
423 cache_control: Option<&str>,
424 max_bytes: Option<i64>,
425 ) -> Result<String>;
426 async fn presign_download(&self, s3_key: &S3Key, expiry_secs: Option<u64>) -> Result<String>;
427 async fn object_exists(&self, s3_key: &str) -> Result<bool>;
428 async fn object_size(&self, s3_key: &str) -> Result<Option<i64>>;
429 async fn download_object(&self, s3_key: &str) -> Result<Vec<u8>>;
430 /// Download as `bytes::Bytes`, no `to_vec` copy of the aggregated body.
431 /// Memory-sensitive callers (the scanner's buffered branch) use this so the
432 /// payload isn't transiently doubled.
433 async fn download_object_buf(&self, s3_key: &str) -> Result<bytes::Bytes>;
434 /// Stream the object body without buffering the whole payload. Callers
435 /// drive the stream to disk (scanner spool) or to a layer that consumes
436 /// chunks directly.
437 async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream>;
438 /// Download into memory like [`download_object_buf`], but abort if the body
439 /// exceeds `max_bytes`. The scanner routes files it *recorded* as small to an
440 /// in-memory branch, but `file_size_bytes` is asserted at upload time and can
441 /// under-report the real object; this bounds the aggregation so a mis-recorded
442 /// or abusive object can't pull an unbounded body into RAM, the independent
443 /// ceiling the spool path already enforces (Run 22 Perf). Streams via
444 /// `download_stream`, so no backend can hand back the whole body up front.
445 async fn download_object_buf_capped(
446 &self,
447 s3_key: &str,
448 max_bytes: u64,
449 ) -> Result<bytes::Bytes> {
450 let stream = self.download_stream(s3_key).await?;
451 read_bytestream_capped(stream, s3_key, max_bytes).await
452 }
453 /// Read up to the first `len` bytes of an object. Production overrides this
454 /// with a ranged `GetObject` so a content sniff transfers only the header,
455 /// not the whole object. The default streams and stops early, correct, but
456 /// it still initiates a full GET, which is fine for in-memory test backends.
457 async fn download_head(&self, s3_key: &str, len: usize) -> Result<Vec<u8>> {
458 let mut stream = self.download_stream(s3_key).await?;
459 let mut head = Vec::with_capacity(len.min(64 * 1024));
460 while head.len() < len {
461 match stream.try_next().await {
462 Ok(Some(chunk)) => head.extend_from_slice(&chunk),
463 Ok(None) => break,
464 Err(e) => return Err(AppError::Storage(format!("read object head from S3: {e}"))),
465 }
466 }
467 head.truncate(len);
468 Ok(head)
469 }
470 async fn upload_object(
471 &self,
472 s3_key: &S3Key,
473 content_type: &str,
474 data: Vec<u8>,
475 cache_control: Option<&str>,
476 ) -> Result<()>;
477 /// Delete an object. Requires an [`S3DeleteAuthority`], route handlers
478 /// cannot mint one, so they must enqueue through `pending_s3_deletions`
479 /// instead of deleting directly (Run #18 CHRONIC B′).
480 async fn delete_object(&self, auth: &S3DeleteAuthority, s3_key: &S3Key) -> Result<()>;
481 /// Delete a batch of objects in a single S3 `DeleteObjects` request
482 /// (up to 1000 keys/call). Default loops `delete_object` so test backends
483 /// don't have to implement it, but production should override.
484 async fn delete_objects(&self, auth: &S3DeleteAuthority, keys: &[S3Key]) -> Result<()> {
485 let mut failed = 0usize;
486 for k in keys {
487 if let Err(e) = self.delete_object(auth, k).await {
488 failed += 1;
489 tracing::warn!(key = %k, error = ?e, "delete_objects: per-key delete failed");
490 }
491 }
492 // Don't report success when every key failed, a total failure must
493 // surface so the caller can fall back (Run #2 Storage MINOR). Partial
494 // failures stay logged; callers pre-enqueue to pending_s3_deletions.
495 if !keys.is_empty() && failed == keys.len() {
496 return Err(AppError::Storage(format!(
497 "delete_objects: all {failed} keys failed"
498 )));
499 }
500 Ok(())
501 }
502 /// Delete all objects under a key prefix. Default logs a warning (no-op).
503 async fn delete_prefix(&self, _auth: &S3DeleteAuthority, _prefix: &str) -> Result<()> {
504 tracing::warn!("delete_prefix called on a storage backend that does not implement it");
505 Ok(())
506 }
507 /// Upload a file via S3 multipart upload. Required (not defaulted): a
508 /// default that `tokio::fs::read`s the whole file into RAM + single PUT
509 /// silently defeats streaming, so a future backend that forgot to override
510 /// it would quietly lose multipart. Every backend must declare its strategy.
511 async fn upload_multipart(
512 &self,
513 s3_key: &S3Key,
514 content_type: &str,
515 file_path: &std::path::Path,
516 ) -> Result<()>;
517 /// Server-side copy `src_key` to `dst_key` within this backend's bucket
518 /// (no bytes transit the process). The scan-then-promote primitive: a Clean
519 /// staging object is copied to the served key the client holds no presign
520 /// for, so served bytes are provably the scanned bytes (Run #24 Storage
521 /// HIGH). Required (not defaulted): a silent no-op default would make a
522 /// promote "succeed" while the served key stays empty.
523 async fn copy_object(&self, src_key: &S3Key, dst_key: &S3Key) -> Result<()>;
524 /// Server-side copy from `src_bucket` into THIS backend's bucket. The
525 /// cross-bucket half of scan-then-promote: a Clean staging object in the
526 /// private bucket is lifted into the public (CDN-served) bucket. Call on the
527 /// public backend with the private bucket name as `src_bucket`. Required
528 /// (not defaulted) for the same reason as `copy_object`.
529 async fn copy_object_from(
530 &self,
531 src_bucket: &str,
532 src_key: &S3Key,
533 dst_key: &S3Key,
534 ) -> Result<()>;
535 /// Server-side multipart copy (`UploadPartCopy`) for sources over the 5 GiB
536 /// single-part `CopyObject` limit, the >5 GiB half of scan-then-promote.
537 /// Always takes `src_bucket` explicitly, collapsing the
538 /// `copy_object`/`copy_object_from` pair into one method (pass this
539 /// backend's own bucket for a same-bucket promote). `content_type` sets the
540 /// destination's type, since a fresh multipart upload does not inherit the
541 /// source's metadata the way `CopyObject` does. Required (not defaulted) for
542 /// the same reason as `copy_object`.
543 async fn copy_object_multipart(
544 &self,
545 src_bucket: &str,
546 src_key: &S3Key,
547 dst_key: &S3Key,
548 content_type: &str,
549 src_size: u64,
550 part_size: Option<usize>,
551 ) -> Result<()>;
552
553 // Client-direct multipart sessions
554 //
555 // The counterpart to `upload_multipart`, which drives a whole transfer
556 // server-side from a local file. Here the server only mints the session and
557 // the per-part presigned URLs; the client streams parts straight to S3, so
558 // no object bytes transit the server. This is the path large CLI/desktop
559 // uploads take (a browser stays on the single-PUT `presign_upload`).
560 //
561 // All four are required (not defaulted): a no-op default would mint a
562 // session no client could complete, or silently drop the cleanup that
563 // stops orphaned parts billing forever.
564
565 /// Begin a client-direct multipart upload, returning the `upload_id` the
566 /// part/complete/abort calls key on.
567 async fn create_multipart_upload(&self, s3_key: &S3Key, content_type: &str) -> Result<String>;
568 /// Presign an `UploadPart` request for one part (1-based `part_number`).
569 /// `max_bytes`, when set, is signed as `Content-Length`, the same
570 /// defense-in-depth as [`Self::presign_upload`], the authoritative size
571 /// check still happens at confirm time.
572 ///
573 /// `checksum_sha256` (base64 of the raw digest), when set, is signed as
574 /// `x-amz-checksum-sha256` and IS enforced: S3 rehashes the part and
575 /// rejects a mismatch before the bytes are durable.
576 async fn presign_upload_part(
577 &self,
578 s3_key: &S3Key,
579 upload_id: &str,
580 part_number: i32,
581 expiry_secs: Option<u64>,
582 max_bytes: Option<i64>,
583 checksum_sha256: Option<&str>,
584 ) -> Result<String>;
585 /// Complete a multipart upload from the collected `(part_number, etag)`
586 /// pairs. Parts may be passed in any order; the backend sorts them.
587 async fn complete_multipart_upload(
588 &self,
589 s3_key: &S3Key,
590 upload_id: &str,
591 parts: &[(i32, String)],
592 ) -> Result<()>;
593 /// Abort a multipart upload, releasing its uploaded parts. The
594 /// pending-upload reaper calls this on sessions that were never confirmed,
595 /// incomplete multipart uploads bill for their parts indefinitely.
596 async fn abort_multipart_upload(&self, s3_key: &S3Key, upload_id: &str) -> Result<()>;
597 /// Upload ids of the in-progress multipart sessions for exactly `s3_key`.
598 /// The reaper recovers them from S3 rather than the database, so a session
599 /// whose tracking row was lost is still cleaned up. Required (not defaulted):
600 /// an empty default would silently strand billed parts.
601 async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result<Vec<String>>;
602 async fn check_connectivity(&self) -> std::result::Result<(), String>;
603 fn bucket(&self) -> &str;
604 }
605
606 /// S3 client wrapper for presigned URL operations.
607 /// Delegates S3 operations to `s3_storage::S3Client`.
608 #[derive(Clone)]
609 pub struct S3Client {
610 inner: s3_storage::S3Client,
611 }
612
613 impl S3Client {
614 /// Create a new S3 client from storage configuration.
615 ///
616 /// Configures CORS on the bucket at startup so browser PUT uploads to
617 /// presigned URLs work without manual bucket configuration.
618 pub async fn new(config: &StorageConfig, host_url: &str) -> Result<Self> {
619 let s3_config = s3_storage::S3Config {
620 endpoint: config.endpoint.clone(),
621 bucket: config.bucket.clone(),
622 access_key: config.access_key.clone(),
623 secret_key: config.secret_key.clone(),
624 region: config.region.clone(),
625 };
626
627 let inner = s3_storage::S3Client::new(&s3_config)
628 .await
629 .map_err(AppError::Storage)?;
630
631 inner.configure_cors(host_url).await;
632
633 Ok(S3Client { inner })
634 }
635
636 /// Generate a consistent S3 key for an object
637 /// Format: {user_id}/{item_id}/{file_type}/{filename}
638 pub fn generate_key(
639 user_id: UserId,
640 item_id: ItemId,
641 file_type: FileType,
642 filename: &str,
643 ) -> S3Key {
644 let safe_filename = sanitize_filename(filename);
645 S3Key(format!(
646 "{}/{}/{}/{}",
647 user_id,
648 item_id,
649 file_type.as_str(),
650 safe_filename
651 ))
652 }
653
654 /// Staging key for scan-then-promote: browser uploads presign to this key,
655 /// which is NEVER served. After a Clean scan the worker copies the object to
656 /// its content-addressed [`content_key`](Self::content_key) and deletes the
657 /// staging object. The upload's extension is carried in the staging key so
658 /// the worker can build the content key without re-reading the entity row.
659 /// Format: `staging/{uuid}/{sanitized_filename}`. The random uuid segment
660 /// means a replayed presigned PUT can only re-write the (unserved,
661 /// post-scan-deleted) staging object, never the served content key (ultra-fuzz
662 /// Run #24 Storage HIGH), and two uploads of the same filename never collide.
663 /// The original filename is preserved after the uuid so a confirm can recover
664 /// it (e.g. a version download's suggested name), `sanitize_filename` strips
665 /// any `/`, so the name can't add path segments or escape the `staging/`
666 /// prefix. The extension still rides along for the content key.
667 pub fn generate_staging_key(filename: &str) -> S3Key {
668 S3Key(format!(
669 "staging/{}/{}",
670 uuid::Uuid::new_v4(),
671 sanitize_filename(filename)
672 ))
673 }
674
675 /// Content-addressed served key: `{user_id}/c/{sha256}.{ext}`. The object's
676 /// name *is* its content hash, so the served bytes are provably the bytes
677 /// that were scanned, a swapped object would hash to a different key. The
678 /// key is per-owner (`user_id`) namespaced, so identical bytes uploaded by
679 /// different creators do NOT collapse to one shared object (no cross-tenant
680 /// existence oracle). The `c` marker segment cannot collide with the legacy
681 /// `{user_id}/{item_id}/...` layout because `c` is not a UUID.
682 pub fn content_key(user_id: UserId, sha256: &str, ext: &str) -> S3Key {
683 S3Key(format!("{user_id}/c/{sha256}.{ext}"))
684 }
685
686 /// Generate an S3 key for a version download file. The version's own id is
687 /// woven into the path so two versions of the same item that share a
688 /// filename (e.g. a creator who ships every release as `plugin.zip`) never
689 /// resolve to the same object, mirrors the per-entity-uuid segment the
690 /// gallery keys use, except the version id is the table's primary key, so
691 /// uniqueness is guaranteed by construction rather than by a fresh uuid.
692 /// Format: {user_id}/{item_id}/download/{version_id}/{filename}
693 pub fn generate_version_key(
694 user_id: UserId,
695 item_id: ItemId,
696 version_id: VersionId,
697 filename: &str,
698 ) -> S3Key {
699 let safe_filename = sanitize_filename(filename);
700 S3Key(format!(
701 "{}/{}/{}/{}/{}",
702 user_id,
703 item_id,
704 FileType::Download.as_str(),
705 version_id,
706 safe_filename
707 ))
708 }
709
710 /// Generate an S3 key for a reusable insertion clip (not tied to any item).
711 /// Format: {user_id}/insertions/{filename}
712 pub fn generate_insertion_key(user_id: UserId, filename: &str) -> S3Key {
713 let safe_filename = sanitize_filename(filename);
714 S3Key(format!("{user_id}/insertions/{safe_filename}"))
715 }
716
717 /// Generate an S3 key for a media library file.
718 /// Format: `{user_id}/media/{folder}/{filename}` (or `{user_id}/media/{filename}` for root folder).
719 pub fn generate_media_key(user_id: UserId, folder: &str, filename: &str) -> S3Key {
720 let safe_filename = sanitize_filename(filename);
721 let safe_folder = sanitize_folder(folder);
722 if safe_folder.is_empty() {
723 S3Key(format!("{user_id}/media/{safe_filename}"))
724 } else {
725 S3Key(format!("{user_id}/media/{safe_folder}/{safe_filename}"))
726 }
727 }
728
729 /// Generate an S3 key for a project image (logo/avatar).
730 /// Format: projects/{project_id}/image/{sanitized_filename}
731 pub fn generate_project_image_key(project_id: ProjectId, filename: &str) -> S3Key {
732 let safe_filename = sanitize_filename(filename);
733 S3Key(format!("projects/{project_id}/image/{safe_filename}"))
734 }
735
736 /// Generate an S3 key for an OTA release artifact. Singleton per
737 /// (app, version, target, arch), the release row already enforces
738 /// `UNIQUE(app_id, version)` and the artifact row `UNIQUE(release_id, target,
739 /// arch)`, so re-uploading the same artifact correctly overwrites in place.
740 /// Centralized here so OTA keys are no longer hand-built at the call site.
741 /// Format: ota/{app_id}/{version}/{target}/{arch}/artifact
742 pub fn generate_ota_artifact_key(
743 app_id: SyncAppId,
744 version: &str,
745 target: &str,
746 arch: &str,
747 ) -> S3Key {
748 S3Key(format!("ota/{app_id}/{version}/{target}/{arch}/artifact"))
749 }
750
751 /// Generate an S3 key for a SyncKit content-addressed blob. The hash is the
752 /// uniqueness segment (and `UNIQUE(app_id, user_id, hash)` backs it), so two
753 /// uploads of identical bytes resolve to one object by design.
754 /// Format: {app_id}/{user_id}/{hash}
755 pub fn generate_synckit_blob_key(app_id: SyncAppId, user_id: UserId, hash: &str) -> S3Key {
756 S3Key(format!("{app_id}/{user_id}/{hash}"))
757 }
758
759 /// Generate an S3 key for a generated content-export archive. Ephemeral
760 /// (presigned, then reaped); the timestamp keeps repeat exports distinct.
761 /// Format: {user_id}/exports/content-{timestamp}.zip
762 pub fn generate_content_export_key(user_id: UserId, timestamp: &str) -> S3Key {
763 S3Key(format!("{user_id}/exports/content-{timestamp}.zip"))
764 }
765
766 /// Generate an S3 key for an item gallery image. A per-image uuid segment
767 /// keeps multiple gallery uploads from colliding (unlike the single cover,
768 /// which has a fixed `cover/` path).
769 /// Format: {user_id}/{item_id}/gallery/{image_uuid}/{sanitized_filename}
770 pub fn generate_item_gallery_key(
771 user_id: UserId,
772 item_id: ItemId,
773 image_uuid: uuid::Uuid,
774 filename: &str,
775 ) -> S3Key {
776 let safe_filename = sanitize_filename(filename);
777 S3Key(format!(
778 "{user_id}/{item_id}/gallery/{image_uuid}/{safe_filename}"
779 ))
780 }
781
782 /// Generate an S3 key for a project gallery image.
783 /// Format: projects/{project_id}/gallery/{image_uuid}/{sanitized_filename}
784 pub fn generate_project_gallery_key(
785 project_id: ProjectId,
786 image_uuid: uuid::Uuid,
787 filename: &str,
788 ) -> S3Key {
789 let safe_filename = sanitize_filename(filename);
790 S3Key(format!(
791 "projects/{project_id}/gallery/{image_uuid}/{safe_filename}"
792 ))
793 }
794
795 /// Validate content type for the given file type
796 pub fn validate_content_type(file_type: FileType, content_type: &str) -> Result<()> {
797 let is_valid = if file_type == FileType::Download {
798 ALLOWED_DOWNLOAD_MIMES.contains(&content_type)
799 } else if file_type == FileType::Video {
800 ALLOWED_VIDEO_MIMES.contains(&content_type)
801 } else {
802 let allowed = file_type.allowed_types();
803 allowed.iter().any(|(_, mime)| *mime == content_type)
804 };
805
806 if !is_valid {
807 let allowed_list = if file_type == FileType::Download {
808 ALLOWED_DOWNLOAD_MIMES.join(", ")
809 } else if file_type == FileType::Video {
810 ALLOWED_VIDEO_MIMES.join(", ")
811 } else {
812 let allowed = file_type.allowed_types();
813 allowed
814 .iter()
815 .map(|(_, m)| *m)
816 .collect::<Vec<_>>()
817 .join(", ")
818 };
819 return Err(AppError::InvalidFileType(format!(
820 "Content type '{content_type}' not allowed. Allowed types: {allowed_list}"
821 )));
822 }
823
824 Ok(())
825 }
826
827 /// Classify a validated MIME type as the `media_type` we persist for an
828 /// insertion clip: `"video"` for `video/*`, otherwise `"audio"`. The MIME is
829 /// expected to have already passed `validate_content_type`, so the audio
830 /// fallback is safe (the only non-audio family the insertion allow-list
831 /// admits is `video/*`).
832 pub fn insertion_media_type(mime_type: &str) -> &'static str {
833 if mime_type.starts_with("video/") {
834 "video"
835 } else {
836 "audio"
837 }
838 }
839
840 /// Validate file extension for the given file type
841 pub fn validate_extension(file_type: FileType, filename: &str) -> Result<()> {
842 if file_type == FileType::Download {
843 let lower = filename.to_lowercase();
844 let is_valid = ALLOWED_DOWNLOAD_EXTENSIONS
845 .iter()
846 .any(|ext| lower.ends_with(&format!(".{ext}")));
847 if !is_valid {
848 return Err(AppError::InvalidFileType(format!(
849 "File extension not allowed. Allowed extensions: {}",
850 ALLOWED_DOWNLOAD_EXTENSIONS.join(", ")
851 )));
852 }
853 return Ok(());
854 }
855
856 let extension = filename
857 .rsplit('.')
858 .next()
859 .map(str::to_lowercase)
860 .unwrap_or_default();
861
862 let allowed = file_type.allowed_types();
863 let is_valid = allowed.iter().any(|(ext, _)| *ext == extension);
864
865 if !is_valid {
866 let allowed_exts: Vec<&str> = allowed.iter().map(|(e, _)| *e).collect();
867 return Err(AppError::InvalidFileType(format!(
868 "File extension '.{}' not allowed. Allowed extensions: {}",
869 extension,
870 allowed_exts.join(", ")
871 )));
872 }
873
874 Ok(())
875 }
876
877 /// Generate a presigned URL for uploading a file. `max_bytes`, when set,
878 /// binds `Content-Length` into the signature, S3 will reject any PUT
879 /// whose actual body length differs from `max_bytes`.
880 pub async fn presign_upload(
881 &self,
882 s3_key: &S3Key,
883 content_type: &str,
884 expiry_secs: Option<u64>,
885 cache_control: Option<&str>,
886 max_bytes: Option<i64>,
887 ) -> Result<String> {
888 self.inner
889 .presign_upload(
890 s3_key.as_str(),
891 content_type,
892 expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS),
893 cache_control,
894 max_bytes,
895 )
896 .await
897 .map_err(AppError::Storage)
898 }
899
900 /// Generate a presigned URL for downloading/streaming a file
901 pub async fn presign_download(
902 &self,
903 s3_key: &S3Key,
904 expiry_secs: Option<u64>,
905 ) -> Result<String> {
906 self.inner
907 .presign_download(s3_key.as_str(), expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS))
908 .await
909 .map_err(AppError::Storage)
910 }
911
912 /// Check if an object exists in S3
913 pub async fn object_exists(&self, s3_key: &str) -> Result<bool> {
914 self.inner
915 .object_exists(s3_key)
916 .await
917 .map_err(AppError::Storage)
918 }
919
920 /// Get the size of an object in S3 (bytes), or None if not found.
921 pub async fn object_size(&self, s3_key: &str) -> Result<Option<i64>> {
922 self.inner
923 .object_size(s3_key)
924 .await
925 .map_err(AppError::Storage)
926 }
927
928 /// Download an object's bytes from S3
929 pub async fn download_object(&self, s3_key: &str) -> Result<Vec<u8>> {
930 self.inner
931 .download(s3_key)
932 .await
933 .map(|(bytes, _content_type)| bytes)
934 .map_err(AppError::Storage)
935 }
936
937 /// Download an object as `bytes::Bytes` without the `to_vec` copy. See trait docs.
938 pub async fn download_object_buf(&self, s3_key: &str) -> Result<bytes::Bytes> {
939 self.inner
940 .download_buf(s3_key)
941 .await
942 .map(|(bytes, _content_type)| bytes)
943 .map_err(AppError::Storage)
944 }
945
946 /// Stream an object's body from S3 without buffering. See trait docs.
947 pub async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream> {
948 self.inner
949 .download_stream(s3_key)
950 .await
951 .map_err(AppError::Storage)
952 }
953
954 /// Read the first `len` bytes via a ranged S3 GET (for content sniffing).
955 pub async fn download_head(&self, s3_key: &str, len: usize) -> Result<Vec<u8>> {
956 self.inner
957 .download_head(s3_key, len)
958 .await
959 .map_err(AppError::Storage)
960 }
961
962 /// Upload an object to S3 from bytes
963 pub async fn upload_object(
964 &self,
965 s3_key: &S3Key,
966 content_type: &str,
967 data: Vec<u8>,
968 cache_control: Option<&str>,
969 ) -> Result<()> {
970 self.inner
971 .upload(s3_key.as_str(), content_type, data, cache_control)
972 .await
973 .map_err(AppError::Storage)
974 }
975
976 /// Delete an object from S3
977 pub async fn delete_object(&self, s3_key: &S3Key) -> Result<()> {
978 self.inner
979 .delete(s3_key.as_str())
980 .await
981 .map_err(AppError::Storage)
982 }
983
984 /// Server-side copy within the bucket. See the [`StorageBackend::copy_object`]
985 /// trait method for the scan-then-promote rationale.
986 pub async fn copy_object(&self, src_key: &S3Key, dst_key: &S3Key) -> Result<()> {
987 self.inner
988 .copy_object(src_key.as_str(), dst_key.as_str())
989 .await
990 .map_err(AppError::Storage)
991 }
992
993 /// Server-side copy from `src_bucket` into this client's bucket. See the
994 /// [`StorageBackend::copy_object_from`] trait method for the cross-bucket
995 /// promote rationale.
996 pub async fn copy_object_from(
997 &self,
998 src_bucket: &str,
999 src_key: &S3Key,
1000 dst_key: &S3Key,
1001 ) -> Result<()> {
1002 self.inner
1003 .copy_object_from(src_bucket, src_key.as_str(), dst_key.as_str())
1004 .await
1005 .map_err(AppError::Storage)
1006 }
1007
1008 /// Batched S3 delete (`DeleteObjects`, up to 1000 keys per call).
1009 /// Chunks larger slices into 1000-key batches and logs per-key failures
1010 /// without bubbling, the pending_s3_deletions queue is the safety net.
1011 pub async fn delete_objects(&self, keys: &[S3Key]) -> Result<()> {
1012 if keys.is_empty() {
1013 return Ok(());
1014 }
1015 for chunk in keys.chunks(1000) {
1016 let chunk: Vec<String> = chunk.iter().map(|k| k.as_str().to_string()).collect();
1017 match self.inner.delete_objects(&chunk).await {
1018 Ok(failures) => {
1019 for (k, msg) in &failures {
1020 tracing::warn!(key = %k, error = %msg, "S3 delete_objects: key-level failure");
1021 }
1022 // A whole-batch failure must not read as success (Run #2
1023 // Storage MINOR); partial failures stay logged and the
1024 // pending_s3_deletions queue is the retry net.
1025 if !chunk.is_empty() && failures.len() == chunk.len() {
1026 return Err(AppError::Storage(format!(
1027 "S3 delete_objects: all {} keys in batch failed",
1028 chunk.len()
1029 )));
1030 }
1031 }
1032 Err(e) => return Err(AppError::Storage(e)),
1033 }
1034 }
1035 Ok(())
1036 }
1037
1038 /// Upload a file to S3 using multipart upload (10 MB parts).
1039 pub async fn upload_multipart(
1040 &self,
1041 s3_key: &S3Key,
1042 content_type: &str,
1043 file_path: &std::path::Path,
1044 ) -> Result<()> {
1045 self.inner
1046 .upload_multipart(s3_key.as_str(), content_type, file_path, None)
1047 .await
1048 .map_err(AppError::Storage)
1049 }
1050
1051 /// Server-side multipart copy for sources over the 5 GiB single-part
1052 /// `CopyObject` limit. See the [`StorageBackend::copy_object_multipart`]
1053 /// trait method for the promote rationale. `part_size` of `None` lets the
1054 /// storage layer auto-size parts for `src_size`.
1055 pub async fn copy_object_multipart(
1056 &self,
1057 src_bucket: &str,
1058 src_key: &S3Key,
1059 dst_key: &S3Key,
1060 content_type: &str,
1061 src_size: u64,
1062 part_size: Option<usize>,
1063 ) -> Result<()> {
1064 self.inner
1065 .copy_object_multipart(
1066 src_bucket,
1067 src_key.as_str(),
1068 dst_key.as_str(),
1069 content_type,
1070 src_size,
1071 part_size,
1072 )
1073 .await
1074 .map_err(AppError::Storage)
1075 }
1076
1077 /// Begin a client-direct multipart upload. See the
1078 /// [`StorageBackend::create_multipart_upload`] trait method.
1079 pub async fn create_multipart_upload(
1080 &self,
1081 s3_key: &S3Key,
1082 content_type: &str,
1083 ) -> Result<String> {
1084 self.inner
1085 .create_multipart_upload(s3_key.as_str(), content_type)
1086 .await
1087 .map_err(AppError::Storage)
1088 }
1089
1090 /// Presign one `UploadPart` request. See the
1091 /// [`StorageBackend::presign_upload_part`] trait method.
1092 pub async fn presign_upload_part(
1093 &self,
1094 s3_key: &S3Key,
1095 upload_id: &str,
1096 part_number: i32,
1097 expiry_secs: Option<u64>,
1098 max_bytes: Option<i64>,
1099 checksum_sha256: Option<&str>,
1100 ) -> Result<String> {
1101 self.inner
1102 .presign_upload_part(
1103 s3_key.as_str(),
1104 upload_id,
1105 part_number,
1106 expiry_secs.unwrap_or(PRESIGN_EXPIRY_SECS),
1107 max_bytes,
1108 checksum_sha256,
1109 )
1110 .await
1111 .map_err(AppError::Storage)
1112 }
1113
1114 /// Complete a multipart upload. See the
1115 /// [`StorageBackend::complete_multipart_upload`] trait method.
1116 pub async fn complete_multipart_upload(
1117 &self,
1118 s3_key: &S3Key,
1119 upload_id: &str,
1120 parts: &[(i32, String)],
1121 ) -> Result<()> {
1122 self.inner
1123 .complete_multipart_upload(s3_key.as_str(), upload_id, parts)
1124 .await
1125 .map_err(AppError::Storage)
1126 }
1127
1128 /// Abort a multipart upload. See the
1129 /// [`StorageBackend::abort_multipart_upload`] trait method.
1130 pub async fn abort_multipart_upload(&self, s3_key: &S3Key, upload_id: &str) -> Result<()> {
1131 self.inner
1132 .abort_multipart_upload(s3_key.as_str(), upload_id)
1133 .await
1134 .map_err(AppError::Storage)
1135 }
1136
1137 /// In-progress multipart sessions for a key. See the
1138 /// [`StorageBackend::list_multipart_uploads_for_key`] trait method.
1139 pub async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result<Vec<String>> {
1140 self.inner
1141 .list_multipart_uploads_for_key(s3_key)
1142 .await
1143 .map_err(AppError::Storage)
1144 }
1145
1146 /// Lightweight connectivity check, issues a list with max_keys(0).
1147 pub async fn check_connectivity(&self) -> std::result::Result<(), String> {
1148 self.inner.check_connectivity().await
1149 }
1150 }
1151
1152 /// Sanitize a filename: keep only alphanumeric, dots, dashes, and underscores.
1153 /// Prevents path traversal, shell injection, and S3 key encoding issues.
1154 /// Falls back to "file" if the sanitized result has no basename (only extension or empty).
1155 ///
1156 /// **By design**: the sanitizer keeps `.`/`-`/`_` and strips everything else,
1157 /// so e.g. `"../etc/passwd"` collapses to `"..etcpasswd"`, preserved as a
1158 /// literal filename, not as a directory traversal. The unit test pins this
1159 /// behavior: we don't reject names containing `..`, we just guarantee the
1160 /// output has no path separators. S3 keys are namespaced by user/item ID
1161 /// upstream, so a flat literal here can't escape the user's prefix.
1162 ///
1163 /// `pub(crate)` so confirm handlers store a filename that matches the tail of
1164 /// the key `generate_media_key` produced, rather than re-deriving a weaker
1165 /// filter that drops the empty-basename fallback (Run #18 Storage B9).
1166 /// The lowercased ASCII-alphanumeric file extension for a staging/content key,
1167 /// or `"bin"` when the filename has none. Bounded to 16 chars so a crafted
1168 /// filename can't bloat the key. Content keys carry an extension purely so
1169 /// CDN-served objects keep a sensible suffix (content-type sniffing, browser
1170 /// "save as"); the hash is the identity, the extension is cosmetic.
1171 // Retained as a tested key-extension utility; `generate_staging_key` now embeds
1172 // the full sanitized filename (which carries the extension) instead of calling
1173 // this, so it has no production caller today.
1174 #[allow(dead_code)]
1175 pub(crate) fn extension_for(filename: &str) -> String {
1176 let ext: String = std::path::Path::new(filename)
1177 .extension()
1178 .and_then(|s| s.to_str())
1179 .unwrap_or("")
1180 .chars()
1181 .filter(char::is_ascii_alphanumeric)
1182 .map(|c| c.to_ascii_lowercase())
1183 .take(16)
1184 .collect();
1185 if ext.is_empty() {
1186 "bin".to_string()
1187 } else {
1188 ext
1189 }
1190 }
1191
1192 /// The extension segment of a key's basename (the text after the last `.`), or
1193 /// `"bin"`. Lets the scan worker's promote step carry a staging object's
1194 /// extension onto its content key without re-reading the entity row.
1195 pub(crate) fn key_extension(key: &str) -> &str {
1196 key.rsplit('/')
1197 .next()
1198 .and_then(|base| base.rsplit_once('.').map(|(_, ext)| ext))
1199 .filter(|ext| !ext.is_empty())
1200 .unwrap_or("bin")
1201 }
1202
1203 /// The MIME type registered for `ext` under `file_type`, falling back to
1204 /// `application/octet-stream` for an extension outside the allow-list.
1205 ///
1206 /// Needed by the multipart promote path: a single `CopyObject` carries the
1207 /// source object's metadata across, but a multipart copy writes a *fresh*
1208 /// destination object whose content type comes from `CreateMultipartUpload`, so
1209 /// the promote has to name it explicitly or the served object would default to
1210 /// the wrong type.
1211 pub(crate) fn content_type_for(file_type: FileType, ext: &str) -> &'static str {
1212 file_type
1213 .allowed_types()
1214 .iter()
1215 .find(|(e, _)| e.eq_ignore_ascii_case(ext))
1216 .map_or("application/octet-stream", |(_, ct)| *ct)
1217 }
1218
1219 pub(crate) fn sanitize_filename(filename: &str) -> String {
1220 let sanitized: String = filename
1221 .chars()
1222 .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
1223 .collect();
1224 // Ensure the result has a non-empty basename (not just ".ext" or empty)
1225 let stem = std::path::Path::new(&sanitized)
1226 .file_stem()
1227 .and_then(|s| s.to_str())
1228 .unwrap_or("");
1229 if stem.is_empty() {
1230 let ext = std::path::Path::new(&sanitized)
1231 .extension()
1232 .and_then(|s| s.to_str())
1233 .unwrap_or("");
1234 if ext.is_empty() {
1235 "file".to_string()
1236 } else {
1237 format!("file.{ext}")
1238 }
1239 } else {
1240 sanitized
1241 }
1242 }
1243
1244 /// Sanitize a folder name: keep only alphanumeric, dashes, and underscores.
1245 /// Rejects path traversal (`..`) and slashes. Returns empty string for root folder.
1246 pub fn sanitize_folder(folder: &str) -> String {
1247 let trimmed = folder.trim();
1248 if trimmed.is_empty() {
1249 return String::new();
1250 }
1251 // Reject any path traversal
1252 if trimmed.contains("..") {
1253 return String::new();
1254 }
1255 trimmed
1256 .chars()
1257 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
1258 .collect()
1259 }
1260
1261 /// Extract the S3 key from a CDN or presigned URL.
1262 ///
1263 /// Accepts two URL shapes:
1264 /// - **CDN**: `https://cdn.example.com/{s3_key}`, caller supplies the
1265 /// CDN base; the function strips it verbatim.
1266 /// - **Path-style S3**: `https://{host}/{bucket}/{s3_key}?...`, caller
1267 /// supplies the bucket name; the function strips host + bucket prefix.
1268 ///
1269 /// Returns `None` if neither prefix matches. Query strings (presigned URL
1270 /// signatures) are stripped before returning.
1271 ///
1272 /// **Why explicit prefixes**: the prior implementation used
1273 /// `find("projects/")` as a heuristic, which would silently mis-key any URL
1274 /// whose path happened to contain the literal substring (e.g. a key with a
1275 /// `projects/` suffix inside a user folder). Passing the known CDN base and
1276 /// bucket eliminates the heuristic entirely.
1277 pub fn extract_s3_key_from_url(
1278 url: &str,
1279 cdn_base: &str,
1280 bucket: Option<&str>,
1281 s3_endpoint: Option<&str>,
1282 ) -> Option<String> {
1283 let no_query = url.split('?').next()?;
1284
1285 // Try CDN-base prefix first. An empty base matches nothing rather than
1286 // matching everything: `strip_prefix("")` succeeds on any input, so the
1287 // guard is what keeps a caller that passes "" from harvesting a key out of
1288 // an arbitrary host.
1289 if !cdn_base.is_empty() {
1290 let base = cdn_base.trim_end_matches('/');
1291 if let Some(rest) = no_query.strip_prefix(base)
1292 && let Some(key) = rest.strip_prefix('/')
1293 && !key.is_empty()
1294 {
1295 return Some(key.to_string());
1296 }
1297 }
1298
1299 // Path-style S3: must match the configured `{endpoint}/{bucket}/` exactly.
1300 // Without the endpoint pin, the prior implementation accepted any
1301 // `https://{any-host}/{bucket}/{key}`, so an attacker-controlled URL like
1302 // `https://attacker.example/my-bucket/poisoned` would extract a real-looking
1303 // key and direct downstream code at attacker-chosen storage paths.
1304 if let (Some(bucket), Some(endpoint)) = (bucket, s3_endpoint) {
1305 let endpoint = endpoint.trim_end_matches('/');
1306 let prefix = format!("{endpoint}/{bucket}/");
1307 if let Some(key) = no_query.strip_prefix(&prefix)
1308 && !key.is_empty()
1309 {
1310 return Some(key.to_string());
1311 }
1312 }
1313
1314 None
1315 }
1316
1317 /// Build a permanent URL for a project image.
1318 ///
1319 /// Permanent is the whole contract: callers persist the result into
1320 /// `projects.cover_image_url`, which is read forever. There is deliberately no
1321 /// presigned fallback — an expiring URL in a durable column is the bug this
1322 /// signature exists to make unrepresentable. `cdn_base` is required config
1323 /// (`Config::cdn_base_url`), so there is nothing to fall back to.
1324 pub fn build_project_image_url(cdn_base: &str, s3_key: &str) -> String {
1325 format!("{cdn_base}/{s3_key}")
1326 }
1327
1328 #[async_trait::async_trait]
1329 impl StorageBackend for S3Client {
1330 async fn presign_upload(
1331 &self,
1332 s3_key: &S3Key,
1333 content_type: &str,
1334 expiry_secs: Option<u64>,
1335 cache_control: Option<&str>,
1336 max_bytes: Option<i64>,
1337 ) -> Result<String> {
1338 self.presign_upload(s3_key, content_type, expiry_secs, cache_control, max_bytes)
1339 .await
1340 }
1341
1342 async fn presign_download(&self, s3_key: &S3Key, expiry_secs: Option<u64>) -> Result<String> {
1343 self.presign_download(s3_key, expiry_secs).await
1344 }
1345
1346 async fn object_exists(&self, s3_key: &str) -> Result<bool> {
1347 self.object_exists(s3_key).await
1348 }
1349
1350 async fn object_size(&self, s3_key: &str) -> Result<Option<i64>> {
1351 self.object_size(s3_key).await
1352 }
1353
1354 async fn download_object(&self, s3_key: &str) -> Result<Vec<u8>> {
1355 self.download_object(s3_key).await
1356 }
1357
1358 async fn download_object_buf(&self, s3_key: &str) -> Result<bytes::Bytes> {
1359 self.download_object_buf(s3_key).await
1360 }
1361
1362 async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream> {
1363 self.download_stream(s3_key).await
1364 }
1365
1366 async fn download_head(&self, s3_key: &str, len: usize) -> Result<Vec<u8>> {
1367 self.download_head(s3_key, len).await
1368 }
1369
1370 async fn upload_object(
1371 &self,
1372 s3_key: &S3Key,
1373 content_type: &str,
1374 data: Vec<u8>,
1375 cache_control: Option<&str>,
1376 ) -> Result<()> {
1377 self.upload_object(s3_key, content_type, data, cache_control)
1378 .await
1379 }
1380
1381 async fn delete_object(&self, _auth: &S3DeleteAuthority, s3_key: &S3Key) -> Result<()> {
1382 // Authority proven by the caller; delegate to the inherent impl.
1383 self.delete_object(s3_key).await
1384 }
1385
1386 async fn delete_objects(&self, _auth: &S3DeleteAuthority, keys: &[S3Key]) -> Result<()> {
1387 self.delete_objects(keys).await
1388 }
1389
1390 async fn copy_object_from(
1391 &self,
1392 src_bucket: &str,
1393 src_key: &S3Key,
1394 dst_key: &S3Key,
1395 ) -> Result<()> {
1396 // Inherent method; delegate.
1397 self.copy_object_from(src_bucket, src_key, dst_key).await
1398 }
1399
1400 async fn copy_object(&self, src_key: &S3Key, dst_key: &S3Key) -> Result<()> {
1401 // Inherent method; delegate.
1402 self.copy_object(src_key, dst_key).await
1403 }
1404
1405 async fn delete_prefix(&self, _auth: &S3DeleteAuthority, prefix: &str) -> Result<()> {
1406 self.inner
1407 .delete_prefix(prefix)
1408 .await
1409 .map_err(AppError::Storage)
1410 }
1411
1412 async fn upload_multipart(
1413 &self,
1414 s3_key: &S3Key,
1415 content_type: &str,
1416 file_path: &std::path::Path,
1417 ) -> Result<()> {
1418 self.upload_multipart(s3_key, content_type, file_path).await
1419 }
1420
1421 async fn copy_object_multipart(
1422 &self,
1423 src_bucket: &str,
1424 src_key: &S3Key,
1425 dst_key: &S3Key,
1426 content_type: &str,
1427 src_size: u64,
1428 part_size: Option<usize>,
1429 ) -> Result<()> {
1430 // Inherent method; delegate.
1431 self.copy_object_multipart(
1432 src_bucket,
1433 src_key,
1434 dst_key,
1435 content_type,
1436 src_size,
1437 part_size,
1438 )
1439 .await
1440 }
1441
1442 async fn create_multipart_upload(&self, s3_key: &S3Key, content_type: &str) -> Result<String> {
1443 self.create_multipart_upload(s3_key, content_type).await
1444 }
1445
1446 async fn presign_upload_part(
1447 &self,
1448 s3_key: &S3Key,
1449 upload_id: &str,
1450 part_number: i32,
1451 expiry_secs: Option<u64>,
1452 max_bytes: Option<i64>,
1453 checksum_sha256: Option<&str>,
1454 ) -> Result<String> {
1455 self.presign_upload_part(
1456 s3_key,
1457 upload_id,
1458 part_number,
1459 expiry_secs,
1460 max_bytes,
1461 checksum_sha256,
1462 )
1463 .await
1464 }
1465
1466 async fn complete_multipart_upload(
1467 &self,
1468 s3_key: &S3Key,
1469 upload_id: &str,
1470 parts: &[(i32, String)],
1471 ) -> Result<()> {
1472 self.complete_multipart_upload(s3_key, upload_id, parts)
1473 .await
1474 }
1475
1476 async fn abort_multipart_upload(&self, s3_key: &S3Key, upload_id: &str) -> Result<()> {
1477 self.abort_multipart_upload(s3_key, upload_id).await
1478 }
1479
1480 async fn list_multipart_uploads_for_key(&self, s3_key: &str) -> Result<Vec<String>> {
1481 self.list_multipart_uploads_for_key(s3_key).await
1482 }
1483
1484 async fn check_connectivity(&self) -> std::result::Result<(), String> {
1485 self.check_connectivity().await
1486 }
1487
1488 fn bucket(&self) -> &str {
1489 self.inner.bucket()
1490 }
1491 }
1492
1493 #[cfg(test)]
1494 mod tests {
1495 use super::*;
1496
1497 #[tokio::test]
1498 async fn capped_read_aborts_when_body_exceeds_cap() {
1499 // A recorded-small object whose real body is larger must not aggregate
1500 // past the cap (Run 22 Perf, the buffered scan-download ceiling).
1501 let stream = s3_storage::ByteStream::from(vec![0u8; 100]);
1502 let err = read_bytestream_capped(stream, "k", 50).await.unwrap_err();
1503 assert!(
1504 matches!(err, AppError::Storage(_)),
1505 "expected Storage error, got {err:?}"
1506 );
1507 }
1508
1509 #[tokio::test]
1510 async fn capped_read_allows_body_within_cap() {
1511 let stream = s3_storage::ByteStream::from(vec![7u8; 40]);
1512 let out = read_bytestream_capped(stream, "k", 50).await.unwrap();
1513 assert_eq!(out.len(), 40);
1514 assert!(out.iter().all(|&b| b == 7));
1515 }
1516
1517 #[tokio::test]
1518 async fn capped_read_allows_body_exactly_at_cap() {
1519 // The abort condition is strictly `>`, so a body equal to the cap passes.
1520 let stream = s3_storage::ByteStream::from(vec![1u8; 50]);
1521 let out = read_bytestream_capped(stream, "k", 50).await.unwrap();
1522 assert_eq!(out.len(), 50);
1523 }
1524
1525 #[test]
1526 fn extract_key_cdn_form() {
1527 let key = extract_s3_key_from_url(
1528 "https://cdn.makenot.work/projects/abc/image/cover.png",
1529 "https://cdn.makenot.work",
1530 None,
1531 None,
1532 );
1533 assert_eq!(key.as_deref(), Some("projects/abc/image/cover.png"));
1534 }
1535
1536 #[test]
1537 fn extract_key_cdn_with_trailing_slash_in_base() {
1538 let key = extract_s3_key_from_url(
1539 "https://cdn.makenot.work/foo/bar",
1540 "https://cdn.makenot.work/",
1541 None,
1542 None,
1543 );
1544 assert_eq!(key.as_deref(), Some("foo/bar"));
1545 }
1546
1547 #[test]
1548 fn extract_key_strips_query_string() {
1549 let key = extract_s3_key_from_url(
1550 "https://cdn.makenot.work/foo/bar?X-Amz-Signature=zzz",
1551 "https://cdn.makenot.work",
1552 None,
1553 None,
1554 );
1555 assert_eq!(key.as_deref(), Some("foo/bar"));
1556 }
1557
1558 #[test]
1559 fn extract_key_path_style_s3() {
1560 let key = extract_s3_key_from_url(
1561 "https://fsn1.your-objectstorage.com/my-bucket/u/123/image/cover.png?X-Amz=...",
1562 "",
1563 Some("my-bucket"),
1564 Some("https://fsn1.your-objectstorage.com"),
1565 );
1566 assert_eq!(key.as_deref(), Some("u/123/image/cover.png"));
1567 }
1568
1569 #[test]
1570 fn extract_key_path_style_rejects_attacker_host() {
1571 // Attacker-controlled host with the legitimate bucket name in the
1572 // path must NOT be accepted. The endpoint pin closes the gap.
1573 let key = extract_s3_key_from_url(
1574 "https://attacker.example/my-bucket/poisoned",
1575 "",
1576 Some("my-bucket"),
1577 Some("https://fsn1.your-objectstorage.com"),
1578 );
1579 assert_eq!(key, None);
1580 }
1581
1582 #[test]
1583 fn extract_key_path_style_requires_endpoint() {
1584 // Without the endpoint, the path-style branch must not fire, bucket
1585 // name alone is not enough to identify a trustworthy host.
1586 let key = extract_s3_key_from_url(
1587 "https://fsn1.your-objectstorage.com/my-bucket/u/123/key",
1588 "",
1589 Some("my-bucket"),
1590 None,
1591 );
1592 assert_eq!(key, None);
1593 }
1594
1595 #[test]
1596 fn extract_key_returns_none_when_no_prefix_matches() {
1597 // Neither the CDN base nor the bucket name is present in the URL.
1598 let key = extract_s3_key_from_url(
1599 "https://random.example.com/foo/bar",
1600 "https://cdn.makenot.work",
1601 Some("my-bucket"),
1602 Some("https://fsn1.your-objectstorage.com"),
1603 );
1604 assert_eq!(key, None);
1605 }
1606
1607 #[test]
1608 fn extract_key_does_not_misparse_keys_containing_projects_substring() {
1609 // Regression: the old heuristic would have returned just
1610 // "projects/x" from this URL, dropping the user-scoped prefix.
1611 let key = extract_s3_key_from_url(
1612 "https://cdn.makenot.work/u/me/projects/x",
1613 "https://cdn.makenot.work",
1614 None,
1615 None,
1616 );
1617 assert_eq!(key.as_deref(), Some("u/me/projects/x"));
1618 }
1619
1620 #[test]
1621 fn test_generate_key() {
1622 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1623 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
1624
1625 let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "episode.mp3");
1626 assert_eq!(
1627 key,
1628 "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/audio/episode.mp3"
1629 );
1630 }
1631
1632 #[test]
1633 fn test_generate_version_key_is_unique_per_version() {
1634 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1635 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
1636 let v1: VersionId = "33333333-3333-3333-3333-333333333333".parse().unwrap();
1637 let v2: VersionId = "44444444-4444-4444-4444-444444444444".parse().unwrap();
1638
1639 // Two versions of the SAME item sharing a filename must not collide.
1640 let k1 = S3Client::generate_version_key(user_id, item_id, v1, "plugin.zip");
1641 let k2 = S3Client::generate_version_key(user_id, item_id, v2, "plugin.zip");
1642 assert_ne!(k1, k2, "same-filename versions must produce distinct keys");
1643
1644 assert_eq!(
1645 k1,
1646 "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/download/33333333-3333-3333-3333-333333333333/plugin.zip"
1647 );
1648 // Confirm-handler prefix check is `{user}/{item}/`; the woven key still
1649 // satisfies it.
1650 assert!(k1.starts_with(
1651 "11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/"
1652 ));
1653 // Filename is still the last path segment (confirm extracts it via rsplit).
1654 assert_eq!(k1.rsplit('/').next(), Some("plugin.zip"));
1655 }
1656
1657 #[test]
1658 fn test_generate_version_key_sanitizes_filename() {
1659 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1660 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
1661 let v1: VersionId = "33333333-3333-3333-3333-333333333333".parse().unwrap();
1662
1663 let key = S3Client::generate_version_key(user_id, item_id, v1, "my release (1).zip");
1664 assert!(key.ends_with("/myrelease1.zip"));
1665 }
1666
1667 #[test]
1668 fn test_generate_key_sanitizes_filename() {
1669 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1670 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
1671
1672 let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "my file (1).mp3");
1673 assert!(key.ends_with("/myfile1.mp3"));
1674 }
1675
1676 #[test]
1677 fn test_validate_content_type() {
1678 assert!(S3Client::validate_content_type(FileType::Audio, "audio/mpeg").is_ok());
1679 assert!(S3Client::validate_content_type(FileType::Audio, "audio/wav").is_ok());
1680 assert!(S3Client::validate_content_type(FileType::Audio, "image/png").is_err());
1681
1682 assert!(S3Client::validate_content_type(FileType::Cover, "image/png").is_ok());
1683 assert!(S3Client::validate_content_type(FileType::Cover, "image/jpeg").is_ok());
1684 assert!(S3Client::validate_content_type(FileType::Cover, "audio/mpeg").is_err());
1685 }
1686
1687 #[test]
1688 fn content_type_for_maps_allowed_extensions() {
1689 // The multipart promote names the destination's type explicitly, so this
1690 // must agree with the allow-list the upload was validated against.
1691 assert_eq!(content_type_for(FileType::Video, "mp4"), "video/mp4");
1692 assert_eq!(content_type_for(FileType::Video, "webm"), "video/webm");
1693 assert_eq!(content_type_for(FileType::Cover, "png"), "image/png");
1694 // Extensions arrive from a key, which may carry any case.
1695 assert_eq!(content_type_for(FileType::Video, "MP4"), "video/mp4");
1696 }
1697
1698 #[test]
1699 fn content_type_for_falls_back_for_unknown_extension() {
1700 // `key_extension` yields "bin" when a key has no extension; an unknown
1701 // extension must degrade to a generic type, never panic or mis-label.
1702 assert_eq!(
1703 content_type_for(FileType::Video, "bin"),
1704 "application/octet-stream"
1705 );
1706 assert_eq!(
1707 content_type_for(FileType::Video, ""),
1708 "application/octet-stream"
1709 );
1710 }
1711
1712 #[test]
1713 fn single_copy_ceiling_matches_s3_and_is_distinct_from_the_browser_ceiling() {
1714 // Different limits, and since the browser cap dropped to 2 GiB, different
1715 // numbers too: the copy ceiling is what S3 enforces on a one-shot
1716 // `CopyObject`, the browser cap is a product call about resumability.
1717 // Pinned so a future change to one doesn't silently move the other.
1718 assert_eq!(
1719 crate::constants::S3_SINGLE_COPY_MAX_BYTES,
1720 5 * 1024 * 1024 * 1024
1721 );
1722 assert_eq!(
1723 crate::constants::BROWSER_UPLOAD_MAX_BYTES,
1724 2 * 1024 * 1024 * 1024
1725 );
1726 }
1727
1728 #[test]
1729 fn test_validate_extension() {
1730 assert!(S3Client::validate_extension(FileType::Audio, "episode.mp3").is_ok());
1731 assert!(S3Client::validate_extension(FileType::Audio, "episode.MP3").is_ok());
1732 assert!(S3Client::validate_extension(FileType::Audio, "episode.png").is_err());
1733
1734 assert!(S3Client::validate_extension(FileType::Cover, "cover.jpg").is_ok());
1735 assert!(S3Client::validate_extension(FileType::Cover, "cover.webp").is_ok());
1736 assert!(S3Client::validate_extension(FileType::Cover, "cover.mp3").is_err());
1737 }
1738
1739 #[test]
1740 fn test_file_type_from_str() {
1741 assert_eq!(FileType::from_str("audio"), Ok(FileType::Audio));
1742 assert_eq!(FileType::from_str("AUDIO"), Ok(FileType::Audio));
1743 assert_eq!(FileType::from_str("cover"), Ok(FileType::Cover));
1744 assert_eq!(FileType::from_str("image"), Ok(FileType::Cover));
1745 assert!(FileType::from_str("invalid").is_err());
1746 }
1747
1748 #[test]
1749 fn file_type_as_str() {
1750 assert_eq!(FileType::Audio.as_str(), "audio");
1751 assert_eq!(FileType::Cover.as_str(), "cover");
1752 }
1753
1754 #[test]
1755 fn file_type_max_size() {
1756 assert_eq!(FileType::Audio.max_size(), 500 * 1024 * 1024);
1757 assert_eq!(FileType::Cover.max_size(), 10 * 1024 * 1024);
1758 }
1759
1760 #[test]
1761 fn file_type_allowed_types_audio() {
1762 let types = FileType::Audio.allowed_types();
1763 let exts: Vec<&str> = types.iter().map(|(e, _)| *e).collect();
1764 assert!(exts.contains(&"mp3"));
1765 assert!(exts.contains(&"wav"));
1766 assert!(exts.contains(&"flac"));
1767 assert!(!exts.contains(&"png"));
1768 }
1769
1770 #[test]
1771 fn file_type_allowed_types_cover() {
1772 let types = FileType::Cover.allowed_types();
1773 let exts: Vec<&str> = types.iter().map(|(e, _)| *e).collect();
1774 assert!(exts.contains(&"jpg"));
1775 assert!(exts.contains(&"png"));
1776 assert!(exts.contains(&"webp"));
1777 assert!(!exts.contains(&"mp3"));
1778 }
1779
1780 #[test]
1781 fn generate_key_strips_path_traversal() {
1782 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1783 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
1784
1785 let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "../../etc/passwd");
1786 // Slashes are stripped, dots kept: "../../etc/passwd" -> "....etcpasswd"
1787 assert!(key.ends_with("/audio/....etcpasswd"));
1788 }
1789
1790 #[test]
1791 fn generate_key_empty_filename_gets_fallback() {
1792 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1793 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
1794
1795 let key = S3Client::generate_key(user_id, item_id, FileType::Cover, "");
1796 assert!(
1797 key.ends_with("/cover/file"),
1798 "expected fallback name 'file', got: {key}"
1799 );
1800 }
1801
1802 #[test]
1803 fn validate_extension_no_extension() {
1804 assert!(S3Client::validate_extension(FileType::Audio, "noext").is_err());
1805 }
1806
1807 #[test]
1808 fn validate_extension_double_dot() {
1809 assert!(S3Client::validate_extension(FileType::Audio, "file.backup.mp3").is_ok());
1810 }
1811
1812 #[test]
1813 fn validate_content_type_empty() {
1814 assert!(S3Client::validate_content_type(FileType::Audio, "").is_err());
1815 }
1816
1817 #[test]
1818 fn file_type_insertion_from_str() {
1819 assert_eq!(FileType::from_str("insertion"), Ok(FileType::Insertion));
1820 assert_eq!(FileType::from_str("INSERTION"), Ok(FileType::Insertion));
1821 }
1822
1823 #[test]
1824 fn file_type_insertion_as_str() {
1825 assert_eq!(FileType::Insertion.as_str(), "insertion");
1826 }
1827
1828 #[test]
1829 fn file_type_insertion_max_size() {
1830 assert_eq!(FileType::Insertion.max_size(), 500 * 1024 * 1024);
1831 }
1832
1833 #[test]
1834 fn validate_insertion_content_types() {
1835 // Audio clips (the original use).
1836 assert!(S3Client::validate_content_type(FileType::Insertion, "audio/mpeg").is_ok());
1837 assert!(S3Client::validate_content_type(FileType::Insertion, "audio/wav").is_ok());
1838 assert!(S3Client::validate_content_type(FileType::Insertion, "audio/flac").is_ok());
1839 // Video clips (pre/mid/post-roll on video items).
1840 assert!(S3Client::validate_content_type(FileType::Insertion, "video/mp4").is_ok());
1841 assert!(S3Client::validate_content_type(FileType::Insertion, "video/webm").is_ok());
1842 assert!(S3Client::validate_content_type(FileType::Insertion, "video/quicktime").is_ok());
1843 // Neither audio nor video is rejected.
1844 assert!(S3Client::validate_content_type(FileType::Insertion, "image/png").is_err());
1845 }
1846
1847 #[test]
1848 fn validate_insertion_extensions() {
1849 assert!(S3Client::validate_extension(FileType::Insertion, "intro.mp3").is_ok());
1850 assert!(S3Client::validate_extension(FileType::Insertion, "sponsor.wav").is_ok());
1851 assert!(S3Client::validate_extension(FileType::Insertion, "outro.flac").is_ok());
1852 assert!(S3Client::validate_extension(FileType::Insertion, "bumper.mp4").is_ok());
1853 assert!(S3Client::validate_extension(FileType::Insertion, "bumper.webm").is_ok());
1854 assert!(S3Client::validate_extension(FileType::Insertion, "clip.png").is_err());
1855 }
1856
1857 #[test]
1858 fn insertion_media_type_classifies_by_mime_family() {
1859 assert_eq!(S3Client::insertion_media_type("audio/mpeg"), "audio");
1860 assert_eq!(S3Client::insertion_media_type("audio/mp4"), "audio");
1861 assert_eq!(S3Client::insertion_media_type("video/mp4"), "video");
1862 assert_eq!(S3Client::insertion_media_type("video/webm"), "video");
1863 }
1864
1865 #[test]
1866 fn generate_insertion_key_format() {
1867 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1868 let key = S3Client::generate_insertion_key(user_id, "intro.mp3");
1869 assert_eq!(
1870 key,
1871 "11111111-1111-1111-1111-111111111111/insertions/intro.mp3"
1872 );
1873 }
1874
1875 #[test]
1876 fn generate_insertion_key_sanitizes() {
1877 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1878 let key = S3Client::generate_insertion_key(user_id, "my sponsor read (v2).mp3");
1879 assert_eq!(
1880 key,
1881 "11111111-1111-1111-1111-111111111111/insertions/mysponsorreadv2.mp3"
1882 );
1883 }
1884
1885 #[test]
1886 fn extension_for_cases() {
1887 assert_eq!(extension_for("plugin.zip"), "zip");
1888 assert_eq!(extension_for("LOUD.WAV"), "wav"); // lowercased
1889 assert_eq!(extension_for("archive.tar.gz"), "gz"); // last segment only
1890 assert_eq!(extension_for("noextension"), "bin"); // fallback
1891 assert_eq!(extension_for("trailing."), "bin"); // empty ext → fallback
1892 assert_eq!(extension_for("weird.z!p"), "zp"); // non-alnum stripped
1893 }
1894
1895 #[test]
1896 fn key_extension_cases() {
1897 assert_eq!(key_extension("staging/2f9a.zip"), "zip");
1898 assert_eq!(key_extension("uid/c/abcd1234.mp3"), "mp3");
1899 assert_eq!(key_extension("staging/no-dot-basename"), "bin");
1900 assert_eq!(key_extension("dir.with.dot/basename"), "bin"); // dot in dir, not basename
1901 }
1902
1903 #[test]
1904 fn staging_key_is_unserved_and_carries_extension() {
1905 let key = S3Client::generate_staging_key("release.zip");
1906 assert!(key.as_str().starts_with("staging/"), "staging key: {key}");
1907 assert_eq!(key_extension(key.as_str()), "zip");
1908 // Two calls never collide (random uuid), so a replayed PUT can't target
1909 // another upload's staging object.
1910 let key2 = S3Client::generate_staging_key("release.zip");
1911 assert_ne!(key, key2);
1912 }
1913
1914 #[test]
1915 fn content_key_is_hash_addressed_and_owner_namespaced() {
1916 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1917 let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1918 let key = S3Client::content_key(user_id, sha, "zip");
1919 assert_eq!(
1920 key,
1921 "11111111-1111-1111-1111-111111111111/c/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.zip"
1922 );
1923 // Same bytes, different owner → different key (no cross-tenant sharing).
1924 let other: UserId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
1925 assert_ne!(S3Client::content_key(other, sha, "zip"), key);
1926 }
1927
1928 #[test]
1929 fn generate_key_cover_type() {
1930 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1931 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
1932
1933 let key = S3Client::generate_key(user_id, item_id, FileType::Cover, "art.png");
1934 assert!(key.contains("/cover/"));
1935 assert!(key.ends_with("art.png"));
1936 }
1937
1938 // FileType::Download tests
1939
1940 #[test]
1941 fn file_type_download_from_str() {
1942 assert_eq!(FileType::from_str("download"), Ok(FileType::Download));
1943 assert_eq!(FileType::from_str("DOWNLOAD"), Ok(FileType::Download));
1944 }
1945
1946 #[test]
1947 fn file_type_download_as_str() {
1948 assert_eq!(FileType::Download.as_str(), "download");
1949 }
1950
1951 #[test]
1952 fn file_type_download_max_size() {
1953 assert_eq!(FileType::Download.max_size(), 500 * 1024 * 1024);
1954 }
1955
1956 #[test]
1957 fn validate_download_content_types() {
1958 assert!(
1959 S3Client::validate_content_type(FileType::Download, "application/octet-stream").is_ok()
1960 );
1961 assert!(S3Client::validate_content_type(FileType::Download, "application/zip").is_ok());
1962 assert!(
1963 S3Client::validate_content_type(FileType::Download, "application/x-apple-diskimage")
1964 .is_ok()
1965 );
1966 assert!(S3Client::validate_content_type(FileType::Download, "application/gzip").is_ok());
1967 assert!(S3Client::validate_content_type(FileType::Download, "application/x-tar").is_ok());
1968 // Reject clearly wrong types
1969 assert!(S3Client::validate_content_type(FileType::Download, "text/html").is_err());
1970 assert!(S3Client::validate_content_type(FileType::Download, "image/png").is_err());
1971 }
1972
1973 #[test]
1974 fn validate_download_extensions() {
1975 assert!(S3Client::validate_extension(FileType::Download, "app.zip").is_ok());
1976 assert!(S3Client::validate_extension(FileType::Download, "app.dmg").is_ok());
1977 assert!(S3Client::validate_extension(FileType::Download, "app.exe").is_ok());
1978 assert!(S3Client::validate_extension(FileType::Download, "app.appimage").is_ok());
1979 assert!(S3Client::validate_extension(FileType::Download, "app.deb").is_ok());
1980 assert!(S3Client::validate_extension(FileType::Download, "app.tar.gz").is_ok());
1981 assert!(S3Client::validate_extension(FileType::Download, "app.clap").is_ok());
1982 assert!(S3Client::validate_extension(FileType::Download, "app.vst3").is_ok());
1983 assert!(S3Client::validate_extension(FileType::Download, "App.ZIP").is_ok());
1984 // Reject invalid extensions
1985 assert!(S3Client::validate_extension(FileType::Download, "app.mp3").is_err());
1986 assert!(S3Client::validate_extension(FileType::Download, "app.txt").is_err());
1987 }
1988
1989 #[test]
1990 fn generate_key_download_type() {
1991 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1992 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
1993
1994 let key = S3Client::generate_key(user_id, item_id, FileType::Download, "plugin-v1.0.zip");
1995 assert!(key.contains("/download/"));
1996 assert!(key.ends_with("plugin-v1.0.zip"));
1997 }
1998
1999 // CDN tests
2000
2001 #[test]
2002 fn cache_control_immutable_format() {
2003 assert!(CACHE_CONTROL_IMMUTABLE.contains("public"));
2004 assert!(CACHE_CONTROL_IMMUTABLE.contains("max-age=31536000"));
2005 assert!(CACHE_CONTROL_IMMUTABLE.contains("immutable"));
2006 }
2007
2008 #[test]
2009 fn generate_project_image_key_format() {
2010 let project_id: ProjectId = "33333333-3333-3333-3333-333333333333".parse().unwrap();
2011 let key = S3Client::generate_project_image_key(project_id, "logo.png");
2012 assert_eq!(
2013 key,
2014 "projects/33333333-3333-3333-3333-333333333333/image/logo.png"
2015 );
2016 }
2017
2018 #[test]
2019 fn generate_project_image_key_sanitizes() {
2020 let project_id: ProjectId = "33333333-3333-3333-3333-333333333333".parse().unwrap();
2021 let key = S3Client::generate_project_image_key(project_id, "my logo (v2).png");
2022 assert_eq!(
2023 key,
2024 "projects/33333333-3333-3333-3333-333333333333/image/mylogov2.png"
2025 );
2026 }
2027
2028 #[test]
2029 fn cdn_url_from_s3_key() {
2030 let cdn_base = "https://cdn.makenot.work";
2031 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
2032 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
2033 let key = S3Client::generate_key(user_id, item_id, FileType::Audio, "episode.mp3");
2034 let cdn_url = format!("{cdn_base}/{key}");
2035 assert_eq!(
2036 cdn_url,
2037 "https://cdn.makenot.work/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/audio/episode.mp3"
2038 );
2039 }
2040
2041 // FileType::Video tests
2042
2043 #[test]
2044 fn file_type_video_from_str() {
2045 assert_eq!(FileType::from_str("video"), Ok(FileType::Video));
2046 assert_eq!(FileType::from_str("VIDEO"), Ok(FileType::Video));
2047 }
2048
2049 #[test]
2050 fn file_type_video_as_str() {
2051 assert_eq!(FileType::Video.as_str(), "video");
2052 }
2053
2054 #[test]
2055 fn file_type_video_max_size() {
2056 assert_eq!(FileType::Video.max_size(), 20 * 1024 * 1024 * 1024);
2057 }
2058
2059 #[test]
2060 fn validate_video_content_types() {
2061 assert!(S3Client::validate_content_type(FileType::Video, "video/mp4").is_ok());
2062 assert!(S3Client::validate_content_type(FileType::Video, "video/webm").is_ok());
2063 assert!(S3Client::validate_content_type(FileType::Video, "video/quicktime").is_ok());
2064 assert!(S3Client::validate_content_type(FileType::Video, "audio/mpeg").is_err());
2065 assert!(
2066 S3Client::validate_content_type(FileType::Video, "application/octet-stream").is_err()
2067 );
2068 assert!(S3Client::validate_content_type(FileType::Video, "text/html").is_err());
2069 }
2070
2071 #[test]
2072 fn validate_video_extensions() {
2073 assert!(S3Client::validate_extension(FileType::Video, "clip.mp4").is_ok());
2074 assert!(S3Client::validate_extension(FileType::Video, "clip.webm").is_ok());
2075 assert!(S3Client::validate_extension(FileType::Video, "clip.mov").is_ok());
2076 assert!(S3Client::validate_extension(FileType::Video, "Clip.MP4").is_ok());
2077 assert!(S3Client::validate_extension(FileType::Video, "clip.avi").is_err());
2078 assert!(S3Client::validate_extension(FileType::Video, "clip.mp3").is_err());
2079 }
2080
2081 #[test]
2082 fn generate_key_video_type() {
2083 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
2084 let item_id: ItemId = "22222222-2222-2222-2222-222222222222".parse().unwrap();
2085
2086 let key = S3Client::generate_key(user_id, item_id, FileType::Video, "tutorial.mp4");
2087 assert!(key.contains("/video/"));
2088 assert!(key.ends_with("tutorial.mp4"));
2089 }
2090 }
2091
2092 /// Build-time enforcement of CHRONIC B′ (Run #18): route handlers must never
2093 /// delete S3 objects directly, nor mint an [`S3DeleteAuthority`]. Direct
2094 /// deletion is for the sanctioned durable-deletion paths (`scheduler/cleanup.rs`,
2095 /// `scanning/worker.rs`) only; handlers enqueue through `pending_s3_deletions`.
2096 ///
2097 /// The type system already makes the accidental `s3.delete_object(key)`
2098 /// uncompilable (the delete methods require an authority handlers can't reach).
2099 /// This test closes the deliberate-circumvention gap: it fails the build if any
2100 /// file under `src/routes/` names a delete method or the authority type, so the
2101 /// seal cannot silently erode in a future handler.
2102 #[cfg(test)]
2103 mod delete_seal_guard {
2104 use std::path::Path;
2105
2106 #[test]
2107 fn routes_never_delete_s3_directly() {
2108 let routes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/routes");
2109 let mut offenders = Vec::new();
2110 walk(&routes_dir, &mut |path, contents| {
2111 for (i, line) in contents.lines().enumerate() {
2112 // Skip comment/doc lines (they legitimately mention the API).
2113 if line.trim_start().starts_with("//") {
2114 continue;
2115 }
2116 if line.contains(".delete_object(")
2117 || line.contains(".delete_objects(")
2118 || line.contains(".delete_prefix(")
2119 || line.contains("S3DeleteAuthority")
2120 {
2121 offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
2122 }
2123 }
2124 });
2125 assert!(
2126 offenders.is_empty(),
2127 "CHRONIC B' seal violated, route code must enqueue via \
2128 routes::storage::enqueue_s3_orphan, never delete S3 directly or mint an \
2129 S3DeleteAuthority. Offending lines:\n{}",
2130 offenders.join("\n")
2131 );
2132 }
2133
2134 fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
2135 let Ok(entries) = std::fs::read_dir(dir) else {
2136 return;
2137 };
2138 for entry in entries.flatten() {
2139 let path = entry.path();
2140 if path.is_dir() {
2141 walk(&path, f);
2142 } else if path.extension().is_some_and(|e| e == "rs")
2143 && let Ok(contents) = std::fs::read_to_string(&path)
2144 {
2145 f(&path, &contents);
2146 }
2147 }
2148 }
2149 }
2150
2151 /// C1 scan-then-promote seal: route handlers must never mint a *served* S3 key.
2152 ///
2153 /// A presigned client upload can only ever land at a `staging/{uuid}` key
2154 /// ([`S3Client::generate_staging_key`]); the served, content-addressed key
2155 /// ([`S3Client::content_key`]) is created in exactly one place, the scan
2156 /// worker's promote step, after a Clean verdict, so the bytes a buyer is served
2157 /// are provably the bytes that were scanned. The mutable-served-key class (a
2158 /// presign minting `{user}/{item}/type/filename`, then the owner re-PUTting to it
2159 /// after it goes Clean) is what this closes.
2160 ///
2161 /// This guard fails the build if any file under `src/routes/` names a served-key
2162 /// generator or `content_key`. It is stronger than `pub(crate)` visibility,
2163 /// route code lives in the same crate, so `pub(crate)` would not stop it from
2164 /// calling these, and it is the same grep-proof discipline as the delete seal
2165 /// above. (The build runner uploads OTA artifacts server-side to a deterministic
2166 /// key via `generate_ota_artifact_key`; it lives outside `src/routes/`, so it is
2167 /// legitimately unaffected.)
2168 #[cfg(test)]
2169 mod served_key_seal_guard {
2170 use std::path::Path;
2171
2172 #[test]
2173 fn routes_never_mint_served_keys() {
2174 let routes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/routes");
2175 // Every served-key generator, plus the content-key minter. `staging`
2176 // keys are the ONLY key a route may mint, so `generate_staging_key` is
2177 // deliberately absent from this list.
2178 // Anchored to the `S3Client::` call prefix so an unrelated `generate_key`
2179 // (e.g. `license_keys::generate_key`, `helpers::generate_key_code`) is not
2180 // a false positive, only the storage generators are S3Client methods.
2181 const FORBIDDEN: &[&str] = &[
2182 "S3Client::generate_key(",
2183 "S3Client::generate_version_key(",
2184 "S3Client::generate_insertion_key(",
2185 "S3Client::generate_media_key(",
2186 "S3Client::generate_project_image_key(",
2187 "S3Client::generate_ota_artifact_key(",
2188 "S3Client::generate_item_gallery_key(",
2189 "S3Client::generate_project_gallery_key(",
2190 "S3Client::content_key(",
2191 ];
2192 let mut offenders = Vec::new();
2193 walk(&routes_dir, &mut |path, contents| {
2194 for (i, line) in contents.lines().enumerate() {
2195 if line.trim_start().starts_with("//") {
2196 continue;
2197 }
2198 for needle in FORBIDDEN {
2199 if line.contains(needle) {
2200 offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
2201 }
2202 }
2203 }
2204 });
2205 assert!(
2206 offenders.is_empty(),
2207 "C1 seal violated, route handlers must presign only `generate_staging_key`; \
2208 the served/content key is minted solely by the scan worker's promote step. \
2209 Offending lines:\n{}",
2210 offenders.join("\n")
2211 );
2212 }
2213
2214 fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
2215 let Ok(entries) = std::fs::read_dir(dir) else {
2216 return;
2217 };
2218 for entry in entries.flatten() {
2219 let path = entry.path();
2220 if path.is_dir() {
2221 walk(&path, f);
2222 } else if path.extension().is_some_and(|e| e == "rs")
2223 && let Ok(contents) = std::fs::read_to_string(&path)
2224 {
2225 f(&path, &contents);
2226 }
2227 }
2228 }
2229 }
2230