Skip to main content

max / makenotwork

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