Skip to main content

max / makenotwork

25.3 KB · 654 lines History Blame Raw
1 //! Storage API routes for S3 file uploads and streaming
2
3 mod downloads;
4 mod gallery;
5 mod images;
6 pub(crate) mod media;
7 mod uploads;
8 mod versions;
9
10 use std::sync::Arc;
11
12 use axum::routing::get;
13 use serde::Serialize;
14 use sqlx::PgPool;
15 use tower_governor::GovernorLayer;
16 use uuid::Uuid;
17
18 use crate::{
19 AppState, AppStorage,
20 config::Config,
21 constants,
22 csrf::{CsrfRouter, delete_csrf, post_csrf},
23 db,
24 db::scan_jobs::ScanTargetKind,
25 error::{AppError, Result},
26 scanning::ScanPipeline,
27 storage::FileType,
28 };
29
30 /// Validate a client-declared upload size before it is signed into the
31 /// presigned URL's `Content-Length`. Checks the static per-type cap and the
32 /// tier-effective cap. Centralized so every presign handler (item, version,
33 /// media, gallery) enforces the same bounds instead of re-deriving them, the
34 /// item path used to be the only one that bound the cap, leaving the largest
35 /// file classes (version downloads, media video) unenforced at the protocol
36 /// level. `None` size = the client didn't declare one; the confirm-time HEAD
37 /// still bounds it.
38 pub(crate) fn validate_declared_upload_size(
39 size: Option<i64>,
40 file_type: FileType,
41 max_file_bytes: Option<u64>,
42 ) -> Result<()> {
43 validate_declared_upload_size_limits(size, file_type, max_file_bytes)?;
44 let Some(size) = size else {
45 return Ok(());
46 };
47 // Browser transport ceiling: every caller of THIS function issues a single
48 // presigned PUT, which a tab cannot resume. The tier cap can be much higher
49 // (BigFiles/Everything allow 20 GB), those files upload through the
50 // CLI/desktop clients, which chunk and resume, so point a too-big browser
51 // upload there instead of handing out a presigned URL for a transfer that
52 // will strand the person if it drops.
53 // Deliberately NOT in `validate_declared_upload_size_limits`: it is a
54 // property of the one-shot transport, not of the file, and the multipart
55 // path is exactly what this message points people toward.
56 if size as u64 > constants::BROWSER_UPLOAD_MAX_BYTES {
57 let limit_gb = constants::BROWSER_UPLOAD_MAX_BYTES / (1024 * 1024 * 1024);
58 return Err(AppError::FileTooLarge(format!(
59 "Files larger than {limit_gb} GB must be uploaded with the makenot.work CLI or desktop app."
60 )));
61 }
62 Ok(())
63 }
64
65 /// The size limits that hold for *any* transport: positive, the per-file-type
66 /// cap, and the tier's per-file cap. The single-PUT ceiling is excluded, so the
67 /// multipart (CLI/desktop) path can accept files above it while still enforcing
68 /// every limit that describes the file rather than how it is carried.
69 pub(crate) fn validate_declared_upload_size_limits(
70 size: Option<i64>,
71 file_type: FileType,
72 max_file_bytes: Option<u64>,
73 ) -> Result<()> {
74 let Some(size) = size else {
75 return Ok(());
76 };
77 if size <= 0 {
78 return Err(AppError::BadRequest(
79 "file_size_bytes must be positive".to_string(),
80 ));
81 }
82 if size as u64 > file_type.max_size() {
83 let limit_mb = file_type.max_size() / (1024 * 1024);
84 let file_mb = size as u64 / (1024 * 1024);
85 return Err(AppError::FileTooLarge(format!(
86 "File is {} MB but the maximum for {} files is {} MB.",
87 file_mb,
88 file_type.as_str(),
89 limit_mb
90 )));
91 }
92 if let Some(tier_cap) = max_file_bytes
93 && (size as u64) > tier_cap
94 {
95 let limit_mb = tier_cap / (1024 * 1024);
96 return Err(AppError::FileTooLarge(format!(
97 "File exceeds your tier's per-file limit of {limit_mb} MB."
98 )));
99 }
100 Ok(())
101 }
102
103 /// Enqueue an orphaned S3 key for the pending-deletion worker.
104 ///
105 /// This is the ONLY way a route handler may cause an S3 object to be deleted.
106 /// Direct deletion is sealed off, the `StorageBackend` delete methods require
107 /// an [`S3DeleteAuthority`](crate::storage::S3DeleteAuthority) that handlers
108 /// cannot mint (Run #18 CHRONIC B′), so every handler-side delete, whether a
109 /// post-credit failure (storage credited / row inserted / old object being
110 /// replaced) or a pre-credit rejection (size cap, type-mismatch, tier check),
111 /// routes through here. The queue worker applies the `is_s3_key_live` guard
112 /// before deleting, so enqueuing a key a live row still references is safe (it
113 /// is skipped), and a transient failure is retried rather than leaking.
114 pub(crate) async fn enqueue_s3_orphan(
115 pool: &sqlx::PgPool,
116 s3_key: &str,
117 bucket: crate::storage::S3Bucket,
118 source: &'static str,
119 ) {
120 if let Err(e) = db::pending_s3_deletions::enqueue_deletions(
121 pool,
122 &[(s3_key.to_string(), bucket.as_str().to_string())],
123 source,
124 )
125 .await
126 {
127 tracing::warn!(error = ?e, key = %s3_key, bucket = %bucket.as_str(), source = %source, "failed to enqueue orphan S3 key");
128 }
129 }
130
131 /// Register S3 upload and streaming routes.
132 ///
133 /// Upload routes (presign + confirm) are rate limited per IP (see `constants::UPLOAD_RATE_LIMIT_*`).
134 /// Stream/download endpoints are unlimited (presigned URLs already expire in 1 hour).
135 pub fn storage_routes() -> CsrfRouter<AppState> {
136 let upload_rate_limit = crate::helpers::rate_limiter_ms(
137 constants::UPLOAD_RATE_LIMIT_MS,
138 constants::UPLOAD_RATE_LIMIT_BURST,
139 );
140
141 let upload_routes = CsrfRouter::new()
142 .route("/api/upload/presign", post_csrf(uploads::presign_upload))
143 .route("/api/upload/confirm", post_csrf(uploads::confirm_upload))
144 .route(
145 "/api/versions/{version_id}/upload/presign",
146 post_csrf(versions::version_presign_upload),
147 )
148 .route(
149 "/api/versions/{version_id}/upload/confirm",
150 post_csrf(versions::version_confirm_upload),
151 )
152 .route(
153 "/api/projects/image/presign",
154 post_csrf(images::project_image_presign),
155 )
156 .route(
157 "/api/projects/image/confirm",
158 post_csrf(images::project_image_confirm),
159 )
160 .route(
161 "/api/items/image/presign",
162 post_csrf(images::item_image_presign),
163 )
164 .route(
165 "/api/items/image/confirm",
166 post_csrf(images::item_image_confirm),
167 )
168 .route("/api/gallery/presign", post_csrf(gallery::gallery_presign))
169 .route("/api/gallery/confirm", post_csrf(gallery::gallery_confirm))
170 .route("/api/gallery/reorder", post_csrf(gallery::gallery_reorder))
171 .route_get(
172 "/api/gallery/list/{target_type}/{target_id}",
173 get(gallery::gallery_list),
174 )
175 .route(
176 "/api/gallery/image/{target_type}/{image_id}",
177 delete_csrf(gallery::gallery_delete),
178 )
179 .route("/api/media/presign", post_csrf(media::media_presign))
180 .route("/api/media/confirm", post_csrf(media::media_confirm))
181 .route_get("/api/media", get(media::media_list))
182 .route_get("/api/media/folders", get(media::media_folders))
183 .route("/api/media/{id}", delete_csrf(media::media_delete))
184 .route_layer(GovernorLayer::new(upload_rate_limit));
185
186 let stream_rate_limit = crate::helpers::rate_limiter_ms(
187 constants::STREAM_RATE_LIMIT_MS,
188 constants::STREAM_RATE_LIMIT_BURST,
189 );
190
191 let stream_routes = CsrfRouter::new()
192 .route_get("/api/stream/{item_id}", get(downloads::stream_url))
193 .route_get(
194 "/api/versions/{version_id}/download",
195 get(downloads::version_download),
196 )
197 .route_layer(GovernorLayer::new(stream_rate_limit));
198
199 upload_routes.merge(stream_routes)
200 }
201
202 // Shared Request/Response Types
203
204 /// JSON response containing the presigned upload URL and S3 key.
205 #[derive(Debug, Serialize)]
206 pub struct PresignUploadResponse {
207 pub upload_url: String,
208 pub s3_key: String,
209 pub expires_in: u64,
210 /// Cache-Control header the client must send with the S3 PUT (part of the presigned signature).
211 #[serde(skip_serializing_if = "Option::is_none")]
212 pub cache_control: Option<String>,
213 /// Maximum file size in bytes for this upload (for client-side pre-validation).
214 #[serde(skip_serializing_if = "Option::is_none")]
215 pub max_file_bytes: Option<u64>,
216 }
217
218 /// JSON response confirming a successful upload.
219 #[derive(Debug, Serialize)]
220 pub struct ConfirmUploadResponse {
221 pub success: bool,
222 /// When true, the file was uploaded but is pending manual review before
223 /// it becomes available to fans. The creator should see a "pending review"
224 /// indicator instead of assuming the file is live.
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub pending_review: Option<bool>,
227 }
228
229 // Helpers
230
231 /// Discriminates which entity an upload commit applies to, and carries the
232 /// per-target ID and the corresponding scan_status update.
233 ///
234 /// Construct one of these in your handler AFTER the entity's DB write has
235 /// committed, then pass it to [`commit_upload`]. Order matters, see
236 /// [`commit_upload`] docs.
237 pub(crate) enum CommitTarget {
238 /// An item (Audio/Cover/Video s3_key column on `items`).
239 Item(db::ItemId),
240 /// A version (`versions` table).
241 Version(db::VersionId),
242 /// A media library file (`media_files` table).
243 Media(db::MediaFileId),
244 /// A project cover image (`projects.cover_image_url`/`cover_s3_key`). Gated
245 /// by the CDN-image `cover_scan_status` column (migration 162): the worker
246 /// stamps it clean/held keyed on s3_key, and on quarantine purges the row.
247 ProjectImage(db::ProjectId),
248 /// An item cover image (`items.cover_s3_key`/`cover_image_url`), CDN-served
249 /// with no per-request gate. It is gated by the separate `cover_scan_status`
250 /// column (migration 162), NOT `items.scan_status` (which gates the
251 /// audio/video track, flipping that would take the published track offline).
252 /// The worker stamps `cover_scan_status` clean/held and, on quarantine, NULLs
253 /// the cover columns.
254 ItemImage(db::ItemId),
255 /// A gallery image row (`item_images`/`project_images`), gated by the row's
256 /// `scan_status` column (migration 162). The worker stamps it clean/held by
257 /// s3_key and purges on quarantine. Carries the gallery row's own id (for log
258 /// correlation only; unused by the worker, which acts on the s3_key).
259 GalleryImage(Uuid),
260 /// A content insertion clip (`content_insertions`), gated by its
261 /// `scan_status` column (migration 162); worker stamps clean/held by key.
262 ContentInsertion(db::ContentInsertionId),
263 }
264
265 impl CommitTarget {
266 fn kind(&self) -> ScanTargetKind {
267 match self {
268 CommitTarget::Item(_) => ScanTargetKind::Item,
269 CommitTarget::ItemImage(_) => ScanTargetKind::ItemImage,
270 CommitTarget::Version(_) => ScanTargetKind::Version,
271 CommitTarget::Media(_) => ScanTargetKind::Media,
272 CommitTarget::ProjectImage(_) => ScanTargetKind::ProjectImage,
273 CommitTarget::GalleryImage(_) => ScanTargetKind::GalleryImage,
274 CommitTarget::ContentInsertion(_) => ScanTargetKind::ContentInsertion,
275 }
276 }
277
278 fn target_uuid(&self) -> Uuid {
279 match self {
280 CommitTarget::Item(id) | CommitTarget::ItemImage(id) => (*id).into(),
281 CommitTarget::Version(id) => (*id).into(),
282 CommitTarget::Media(id) => (*id).into(),
283 CommitTarget::ProjectImage(id) => (*id).into(),
284 CommitTarget::GalleryImage(id) => *id,
285 CommitTarget::ContentInsertion(id) => (*id).into(),
286 }
287 }
288 }
289
290 /// Enqueue a scan job and write the resulting status onto the target entity.
291 ///
292 /// **Call this AFTER the DB write that commits the upload has succeeded.**
293 /// Calling it earlier produces three known bug shapes, chronic across four
294 /// audit runs, which is why the lower-level pieces (`enqueue_scan_for`,
295 /// `update_*_scan_status`) are gated behind this single entry point:
296 ///
297 /// 1. A handler that early-returns (idempotent re-confirm, route mismatch,
298 /// quota rejection) leaks a `scan_jobs` row and flips a Clean status back
299 /// to Pending, blocking every fan's download until a rescan.
300 /// 2. A failed DB write leaves a dangling scan_jobs row pointing at an S3
301 /// key that's about to be deleted.
302 /// 3. The worker can race the still-uncommitted entity row.
303 ///
304 /// Use [`CommitTarget`] to bind the target id + per-target status updater.
305 /// The function returns the `FileScanStatus` that was written (callers use
306 /// this to populate the `pending_review` field of `ConfirmUploadResponse`).
307 #[tracing::instrument(
308 skip_all,
309 name = "storage::commit_upload",
310 fields(kind = ?target.kind(), target_id = %target.target_uuid(), %user_id, file_size_bytes, scan_status = tracing::field::Empty)
311 )]
312 pub(crate) async fn commit_upload(
313 db: &PgPool,
314 scanner: Option<&Arc<ScanPipeline>>,
315 target: CommitTarget,
316 s3_key: &str,
317 file_type: FileType,
318 user_id: db::UserId,
319 file_size_bytes: i64,
320 ) -> Result<db::FileScanStatus> {
321 let scan_status = enqueue_scan_for(
322 db,
323 scanner,
324 target.kind(),
325 target.target_uuid(),
326 s3_key,
327 file_type,
328 user_id,
329 file_size_bytes,
330 )
331 .await?;
332 match target {
333 CommitTarget::Item(id) => {
334 db::scanning::update_item_scan_status(db, id, scan_status).await?;
335 }
336 CommitTarget::Version(id) => {
337 db::scanning::update_version_scan_status(db, id, scan_status).await?;
338 }
339 CommitTarget::Media(id) => {
340 db::scanning::update_media_file_scan_status(db, id, scan_status).await?;
341 }
342 CommitTarget::ItemImage(_)
343 | CommitTarget::ProjectImage(_)
344 | CommitTarget::GalleryImage(_)
345 | CommitTarget::ContentInsertion(_) => {
346 // Nothing to flip here at commit time: these CDN-served image kinds
347 // carry their own per-row scan gate (`cover_scan_status` on
348 // items/projects; `scan_status` on item_images/project_images/
349 // content_insertions, migration 162), which defaults to 'pending' so
350 // the row stays hidden until scanned. The worker stamps it clean/held
351 // keyed on s3_key when the scan completes (fail-closed held gate), and
352 // on quarantine purges the row / NULLs the cover columns. We must NOT
353 // flip `items.scan_status` here, that gates the audio/video track, and
354 // a cover re-scan would take the published track offline (Run #20).
355 }
356 }
357 tracing::Span::current().record("scan_status", tracing::field::debug(&scan_status));
358 Ok(scan_status)
359 }
360
361 /// Admin-rescan entry point. The entity already exists; we just need to
362 /// re-run the scan pipeline against its existing `s3_key`. Enqueues the
363 /// scan job then flips the per-row `scan_status` to Pending in the same
364 /// order `commit_upload` uses for first-scan, so admin handlers can't
365 /// invert it (the chronic disease the seal was built to prevent).
366 #[tracing::instrument(
367 skip_all,
368 name = "storage::commit_rescan",
369 fields(kind = ?target.kind(), target_id = %target.target_uuid(), %user_id, file_size_bytes)
370 )]
371 pub(crate) async fn commit_rescan(
372 db: &PgPool,
373 scanner: Option<&Arc<ScanPipeline>>,
374 target: CommitTarget,
375 s3_key: &str,
376 file_type: FileType,
377 user_id: db::UserId,
378 file_size_bytes: i64,
379 ) -> Result<db::FileScanStatus> {
380 enqueue_scan_for(
381 db,
382 scanner,
383 target.kind(),
384 target.target_uuid(),
385 s3_key,
386 file_type,
387 user_id,
388 file_size_bytes,
389 )
390 .await?;
391 let pending = db::FileScanStatus::Pending;
392 match target {
393 CommitTarget::Item(id) => {
394 db::scanning::update_item_scan_status(db, id, pending).await?;
395 }
396 CommitTarget::Version(id) => {
397 db::scanning::update_version_scan_status(db, id, pending).await?;
398 }
399 CommitTarget::Media(id) => {
400 db::scanning::update_media_file_scan_status(db, id, pending).await?;
401 }
402 CommitTarget::ItemImage(_)
403 | CommitTarget::ProjectImage(_)
404 | CommitTarget::GalleryImage(_)
405 | CommitTarget::ContentInsertion(_) => {
406 // No per-row scan_status column. An `ItemImage` rescan must not flip
407 // `items.scan_status`, that gates the audio/video, not the cover.
408 }
409 }
410 Ok(pending)
411 }
412
413 /// Admin approve-held: promote an item's held gated files (audio/video) to their
414 /// content keys, then mark the item Clean. A held file sits at its unserved
415 /// staging key (the scan didn't promote it), so approving it must run the SAME
416 /// copy-then-repoint the scan worker's Clean path runs, otherwise the item is
417 /// marked Clean while still pointing at a staging object that is about to be
418 /// reaped. Factored here (beside `commit_rescan`) so the worker and admin promote
419 /// can't diverge (the anti-drift discipline the scan-ordering seal uses).
420 ///
421 /// Idempotent: a file already at a `{owner}/c/...` content key is skipped, so a
422 /// bulk approve over a mixed set (some already promoted) is safe.
423 #[tracing::instrument(skip_all, name = "storage::commit_promote_item", fields(%item_id))]
424 pub(crate) async fn commit_promote_item(
425 db: &PgPool,
426 storage: &AppStorage,
427 config: &Config,
428 item_id: db::ItemId,
429 ) -> Result<()> {
430 let item = db::items::get_item_by_id(db, item_id)
431 .await?
432 .ok_or(AppError::NotFound)?;
433 let owner = db::items::get_item_owner(db, item_id)
434 .await?
435 .ok_or(AppError::NotFound)?;
436 // Only the files still at a staging key need a copy; skip a file that is
437 // absent or already content-keyed (bulk approve over a mixed set). Scan
438 // results are recorded against the staging key, so a hash lookup on a content
439 // key would miss, hence the staging check gates everything, including the
440 // `require_s3` below (a held row with nothing to promote, e.g. no S3 backend
441 // configured, still marks Clean without needing storage).
442 let staged: Vec<(String, FileType)> = [
443 (item.audio_s3_key.clone(), FileType::Audio),
444 (item.video_s3_key.clone(), FileType::Video),
445 ]
446 .into_iter()
447 .filter_map(|(key, ft)| match key {
448 Some(k) if k.starts_with("staging/") => Some((k, ft)),
449 _ => None,
450 })
451 .collect();
452 if !staged.is_empty() {
453 let s3 = storage.require_s3()?;
454 for (staging_key, file_type) in staged {
455 let sha256 = db::scanning::latest_sha256_by_key(db, &staging_key)
456 .await?
457 .ok_or_else(|| {
458 AppError::Storage(format!(
459 "cannot approve item {item_id}: no scan hash recorded for {staging_key}"
460 ))
461 })?;
462 crate::scanning::promote_staging_to_content(
463 db,
464 s3.as_ref(),
465 // Item audio is a gated Main-bucket kind; the public backend is
466 // unused here but passed for signature consistency.
467 storage.public_s3.as_deref(),
468 &config.cdn_base_url,
469 ScanTargetKind::Item,
470 file_type,
471 item_id.into(),
472 owner,
473 &staging_key,
474 &sha256,
475 crate::storage::S3Bucket::Main,
476 )
477 .await?;
478 }
479 }
480 // `promote_gated` already set `scan_status = 'clean'` per promoted file; this
481 // also covers the no-staged-file case (all already content-keyed) so the
482 // admin action still lands the item Clean.
483 db::scanning::update_item_scan_status(db, item_id, db::FileScanStatus::Clean).await?;
484 Ok(())
485 }
486
487 /// Admin approve-held: promote a version's held download file to its content key,
488 /// then mark the version Clean. See [`commit_promote_item`] for why the copy must
489 /// happen at approve time and why it is factored here.
490 #[tracing::instrument(skip_all, name = "storage::commit_promote_version", fields(%version_id))]
491 pub(crate) async fn commit_promote_version(
492 db: &PgPool,
493 storage: &AppStorage,
494 config: &Config,
495 version_id: db::VersionId,
496 ) -> Result<()> {
497 let version = db::versions::get_version_by_id(db, version_id)
498 .await?
499 .ok_or(AppError::NotFound)?;
500 let owner = db::items::get_item_owner(db, version.item_id)
501 .await?
502 .ok_or(AppError::NotFound)?;
503 // `require_s3` only when there is a staging file to copy, a held row with no
504 // staging download (or an already content-keyed one) still marks Clean with no
505 // storage backend needed.
506 if let Some(staging_key) = version.s3_key.clone()
507 && staging_key.starts_with("staging/")
508 {
509 let s3 = storage.require_s3()?;
510 let sha256 = db::scanning::latest_sha256_by_key(db, &staging_key)
511 .await?
512 .ok_or_else(|| {
513 AppError::Storage(format!(
514 "cannot approve version {version_id}: no scan hash recorded for {staging_key}"
515 ))
516 })?;
517 crate::scanning::promote_staging_to_content(
518 db,
519 s3.as_ref(),
520 // Version downloads are a gated Main-bucket kind; public backend unused.
521 storage.public_s3.as_deref(),
522 &config.cdn_base_url,
523 ScanTargetKind::Version,
524 FileType::Download,
525 version_id.into(),
526 owner,
527 &staging_key,
528 &sha256,
529 crate::storage::S3Bucket::Main,
530 )
531 .await?;
532 }
533 db::scanning::update_version_scan_status(db, version_id, db::FileScanStatus::Clean).await?;
534 Ok(())
535 }
536
537 /// Enqueue an async scan job for an uploaded file and return the initial
538 /// `scan_status` to write onto the target entity.
539 ///
540 /// **Storage handlers should not call this directly**, use [`commit_upload`]
541 /// so the ordering invariant (scan-after-DB-commit) cannot be inverted by a
542 /// future sibling handler. This function remains `pub(super)`-equivalent for
543 /// the `commit_upload` implementation and for the worker / admin tooling
544 /// that legitimately needs the lower-level op.
545 #[allow(clippy::too_many_arguments)]
546 #[tracing::instrument(
547 skip_all,
548 name = "storage::enqueue_scan_for",
549 fields(kind = ?target_kind, %target_id, %user_id, file_size_bytes)
550 )]
551 async fn enqueue_scan_for(
552 db: &PgPool,
553 scanner: Option<&Arc<ScanPipeline>>,
554 target_kind: ScanTargetKind,
555 target_id: Uuid,
556 s3_key: &str,
557 file_type: FileType,
558 user_id: db::UserId,
559 file_size_bytes: i64,
560 ) -> Result<db::FileScanStatus> {
561 if scanner.is_none() {
562 let is_trusted = db::users::is_upload_trusted(db, user_id).await?;
563 let status = if is_trusted {
564 db::FileScanStatus::Clean
565 } else {
566 db::FileScanStatus::HeldForReview
567 };
568 tracing::info!(
569 scanner = "disabled",
570 is_trusted,
571 ?status,
572 "scanner unavailable; status assigned without enqueue"
573 );
574 return Ok(status);
575 }
576
577 db::scan_jobs::enqueue(
578 db,
579 target_kind,
580 target_id,
581 s3_key,
582 file_type,
583 user_id,
584 file_size_bytes,
585 )
586 .await?;
587
588 Ok(db::FileScanStatus::Pending)
589 }
590
591 #[cfg(test)]
592 mod tests {
593 use super::*;
594
595 const GIB: i64 = 1024 * 1024 * 1024;
596
597 #[test]
598 fn browser_validator_refuses_above_the_browser_ceiling() {
599 // 10 GiB video: within the 20 GB per-type cap, but a browser issues one
600 // unresumable presigned PUT, so it is refused above 2 GiB.
601 let err = validate_declared_upload_size(Some(10 * GIB), FileType::Video, None)
602 .expect_err("browser upload above the browser ceiling must be refused");
603 assert!(
604 matches!(err, AppError::FileTooLarge(ref m) if m.contains("CLI or desktop app")),
605 "expected a pointer to the CLI, got: {err:?}"
606 );
607 }
608
609 #[test]
610 fn multipart_validator_allows_above_the_browser_ceiling() {
611 // The same file over the chunked CLI path is fine, exceeding the
612 // one-shot ceiling is the entire point of multipart. If this ever starts
613 // failing, the 2 GiB-to-20 GB tier band is unreachable again.
614 validate_declared_upload_size_limits(Some(10 * GIB), FileType::Video, None)
615 .expect("multipart upload above the browser ceiling must be allowed");
616 }
617
618 #[test]
619 fn multipart_validator_still_enforces_the_per_type_cap() {
620 // Skipping the transport ceiling must not skip the limits that describe
621 // the file itself: 25 GiB is past FileType::Video's 20 GB cap.
622 assert!(
623 validate_declared_upload_size_limits(Some(25 * GIB), FileType::Video, None).is_err()
624 );
625 }
626
627 #[test]
628 fn multipart_validator_still_enforces_the_tier_cap() {
629 // A 10 GiB file under a 6 GiB tier cap is refused before it stages parts.
630 assert!(
631 validate_declared_upload_size_limits(
632 Some(10 * GIB),
633 FileType::Video,
634 Some(6 * GIB as u64)
635 )
636 .is_err()
637 );
638 }
639
640 #[test]
641 fn both_validators_reject_non_positive_sizes() {
642 assert!(validate_declared_upload_size(Some(0), FileType::Video, None).is_err());
643 assert!(validate_declared_upload_size_limits(Some(0), FileType::Video, None).is_err());
644 assert!(validate_declared_upload_size_limits(Some(-1), FileType::Video, None).is_err());
645 }
646
647 #[test]
648 fn absent_size_is_accepted_by_both() {
649 // No declared size: the confirm-time HEAD is the bound.
650 validate_declared_upload_size(None, FileType::Video, None).unwrap();
651 validate_declared_upload_size_limits(None, FileType::Video, None).unwrap();
652 }
653 }
654