feat(pages): raise per-file upload limit to 32 MiB for large wasm

An optimized 22 MiB bin_bg.wasm exceeded the 16 MiB per-file cap. Bump
max_file 16 -> 32 MiB (still < the 48 MiB total). max_uncompressed_total
(48 MiB) and max_compressed / MAX_UPLOAD_BYTES (24 MiB) are left
unchanged, so worst-case Worker memory (buffered body + decompressed
manifest) stays well under the ~128 MiB isolate ceiling.

Note: prefer zstd/gzip over xz for large archives — lzma-rs is slow in
wasm and can trip Cloudflare's per-request CPU limit (503 exceededCpu).

Bean: pages-yhxp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Elijah Voigt 1 day ago
parent f7fa11071d
commit 36d13cf266

@ -0,0 +1,22 @@
---
# pages-yhxp
title: Raise per-file upload limit to 32 MiB for large wasm binaries
status: completed
type: task
priority: high
created_at: 2026-07-21T17:17:56Z
updated_at: 2026-07-21T17:18:42Z
---
User's optimized bin_bg.wasm is 22 MiB, exceeding the 16 MiB per-file cap ("exceeds the maximum per-file size of 16777216 bytes"). A .tar.zst upload decoded fine within the Worker CPU budget (zstd is fast) and correctly hit the per-file limit; the earlier 503 was an xz attempt killed by exceededCpu (lzma-rs too slow in wasm) — zstd is the codec that works.
Change: bump ONLY max_file 16 -> 32 MiB. Keep max_uncompressed_total (48 MiB) and max_compressed / MAX_UPLOAD_BYTES (24 MiB) unchanged — they already fit a 22 MiB wasm + zst-compressed body, and keeping them bounds worst-case Worker memory (body + decompressed manifest) safely under the ~128 MiB isolate ceiling. 32 < 48 keeps per-file < total.
Spots: core/upload.rs UploadLimits::default (value + doc comment); design doc §2.2 number. No frontend/api.rs/body-cap change needed (per-file error is server-side only).
Known latent issue (out of scope, note for follow-up): the xz path can still 503 via exceededCpu on large files because lzma-rs is slow in wasm. Fine as long as users pick zstd/gzip.
Then: validate (all six), commit, push, deploy.
## Summary of Changes
Bumped max_file 16 to 32 MiB in UploadLimits::default (fits the user 22 MiB bin_bg.wasm with headroom). Left max_uncompressed_total (48 MiB) and max_compressed / MAX_UPLOAD_BYTES (24 MiB) unchanged so worst-case Worker memory (buffered body + decompressed manifest = 24+48) stays safely under the ~128 MiB isolate ceiling. Updated the default() doc comment and design doc §2.2. zstd is the working codec; xz still risks an exceededCpu 503 on large files (lzma-rs slow in wasm) — noted for follow-up. Validation green: 196 unit + 20 doctests.

@ -75,11 +75,12 @@ so it should be granted even more sparingly than a single-page grant.
`C:\x`), backslash separators, or `..` segments anywhere → reject upload.
- **Zip bombs:** enforce **before/while extracting**: max compressed upload
24 MiB (checked via `Content-Length` and while buffering), max total
uncompressed 48 MiB, max 16 MiB per file, max 300 entries, max path length
uncompressed 48 MiB, max 32 MiB per file, max 300 entries, max path length
180 bytes, max depth 10 (raised from 10/25/5 MiB to accommodate large wasm
binaries, still well under the Worker's ~128 MiB memory ceiling). Counting
is done on decompressed bytes as they stream out, aborting on breach —
never trust zip headers alone.
binaries — worst-case memory is compressed + total uncompressed, still well
under the Worker's ~128 MiB isolate ceiling). Counting is done on
decompressed bytes as they stream out, aborting on breach — never trust zip
headers alone.
- **Weird entries:** directories are skipped, symlinks don't exist for us
(we never touch a filesystem — entries go straight to R2), duplicate entry
names → reject, nested zips are stored as opaque files, never recursed.

@ -44,17 +44,18 @@ pub struct UploadLimits {
impl Default for UploadLimits {
/// Production limits (design §2.2, raised to accommodate large wasm
/// binaries): 24 MiB max compressed upload, 48 MiB max total uncompressed,
/// 16 MiB max per file, 300 max entries, 180-byte max path length,
/// depth 10. The sizes stay well under the Worker's ~128 MiB memory
/// ceiling — the body is buffered and decompressed in memory — and
/// `max_compressed` must stay in lockstep with
/// 32 MiB max per file, 300 max entries, 180-byte max path length,
/// depth 10. Worst-case memory is bounded by `max_compressed` (the
/// buffered body) plus `max_uncompressed_total` (the decompressed
/// manifest), which stays well under the Worker's ~128 MiB isolate
/// ceiling; `max_compressed` must stay in lockstep with
/// [`MAX_UPLOAD_BYTES`](crate::routes::api::MAX_UPLOAD_BYTES).
fn default() -> Self {
const MIB: u64 = 1024 * 1024;
Self {
max_compressed: (24 * MIB) as usize,
max_uncompressed_total: 48 * MIB,
max_file: 16 * MIB,
max_file: 32 * MIB,
max_entries: 300,
max_path_len: 180,
max_depth: 10,

Loading…
Cancel
Save