Skip to main content

max / makenotwork

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