550b258c59
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
345 lines
19 KiB
Markdown
345 lines
19 KiB
Markdown
# GIF — maximum byte size of a file
|
||
|
||
Research note for Gitea issue #71 (backend `maxBodyBytes` = 4 MiB rejects the
|
||
8,571,192-byte animated cover GIF at
|
||
`https://cdn.asurascans.com/asura-images/covers/a-dragonslayers-peerless-regression.gif`).
|
||
|
||
All facts fetched live on **2026-08-17**: the GIF89a spec at
|
||
`https://www.w3.org/Graphics/GIF/spec-gif89a.txt`, Go stdlib `image/gif`
|
||
sources at `/usr/local/go/src/image/gif/reader.go` (Go 1.26.5), Chromium
|
||
`blink/renderer/platform/image-decoders/` sources via
|
||
`chromium.googlesource.com`, Firefox `image/decoders/nsGIFDecoder2.cpp` via
|
||
`hg.mozilla.org`, and cover bytes probed with plain `curl` (desktop Chrome UA;
|
||
`HEAD`/ranged `GET`). **No Cloudflare challenge was encountered on any CDN
|
||
probe** — every request returned real headers, consistent with the AGENTS.md
|
||
note of 2026-07-26 that plain `curl` works against both scan sites from the
|
||
dev machine and the VPS.
|
||
|
||
Every claim carries the URL it came from, or a reproducible command.
|
||
Interpretation rather than observation is marked `[INFERENCE]`.
|
||
|
||
---
|
||
|
||
## 1. Summary answer table
|
||
|
||
| Question | Answer | Evidence |
|
||
|---|---|---|
|
||
| Does the GIF89a spec define a maximum file size? | **No.** There is no file-size field anywhere in the format; the only numeric ceilings are per-field (16-bit screen/image dimensions, 255-byte sub-blocks, 12-bit LZW codes). | §2 |
|
||
| Maximum logical screen | 65535 × 65535 pixels (unsigned 16-bit width/height). | §2.1 |
|
||
| Number of frames / image descriptors | Unbounded — "An unlimited number of images may be present per Data Stream." | §2.2 |
|
||
| Formal max byte size of any single GIF | None. Single-frame worst case ≈ **6.44 GB** (12-bit LZW, max canvas); animated GIFs are **unbounded** because frames are unbounded. | §3 |
|
||
| Does the backend's decoder (Go `image/gif`) bound size? | **No.** It reads 16-bit dimensions and allocates `width×height` bytes per frame; a 65535² frame forces a ~4 GiB allocation. No total-size or dimension guard. | §4.1 |
|
||
| Do browsers bound on-disk GIF size? | Chromium and Firefox: no on-wire size cap in their GIF readers; Chromium caps *decoded* memory at min(4 B × pixels, platform budget). | §4.3, §4.4 |
|
||
| Real cover sizes (asurascans, n=25) | min 190,410 B · median 1,275,082 B · p90 4,524,788 B · max 8,571,192 B · **3/25 > 4 MiB** (two JPEGs and the animated GIF) | §5 |
|
||
| Real cover sizes (demonicscans/readermc, n=78) | min 13,298 B · median 63,061 B · max 801,200 B · 0/78 > 4 MiB | §5 |
|
||
| Comparable service caps | GitHub: 10 MB for images/GIFs. Discord API: default 10 MiB per file. Wikimedia: 100 MiB upload / 5 GiB host. | §6 |
|
||
| Recommended cover cap for #71 | **10 MiB** (separate from the 4 MiB series-page cap). Covers 100% of the 103 observed covers; matches GitHub/Discord calibration; ≤ 20 MiB worst-case transient per concurrent fetch+serve on a 1974 MiB swapless VPS. | §7 |
|
||
|
||
---
|
||
|
||
## 2. What the GIF89a specification actually bounds
|
||
|
||
Source: `https://www.w3.org/Graphics/GIF/spec-gif89a.txt` (fetched 2026-08-17).
|
||
|
||
### 2.1 Fixed-width fields — the only hard ceilings
|
||
|
||
The format is a stream of fixed-width blocks; the numeric fields that *do*
|
||
have a ceiling are all 16-bit unsigned, little-endian ("multi-byte numeric
|
||
fields are ordered Least Significant Byte first", §4 of the spec):
|
||
|
||
- **Logical Screen Width / Height** — "Unsigned" 2-byte fields (§18, Logical
|
||
Screen Descriptor) → maximum **65535 × 65535** pixels.
|
||
- **Image Left / Top Position, Image Width / Height** — "Unsigned" 2-byte
|
||
fields (§20, Image Descriptor). Each image "must fit within the boundaries
|
||
of the Logical Screen" (§20a), so an image cannot exceed the 65535² canvas
|
||
even though its own fields would allow it.
|
||
- **Data sub-blocks** — "A data sub-block may contain from 0 to 255 data
|
||
bytes" (§15); each sub-block is preceded by a 1-byte size field and the
|
||
stream is terminated by a 0x00 Block Terminator (§16). This bounds a
|
||
*chunk*, not the stream.
|
||
- **Global/Local Color Tables** — optional, "3 x 2^(Size of Global Color
|
||
Table+1)" bytes with a 3-bit size field → at most 3 × 2⁸ = **768 bytes**
|
||
each (§19, §21).
|
||
- **LZW codes** — "The output codes are of variable length, starting at
|
||
<code size>+1 bits per code, **up to 12 bits per code**. This defines a
|
||
maximum code value of 4095 (0xFFF)" (Appendix F, COMPRESSION, rule 4).
|
||
- **Trailer** — a single byte, fixed value 0x3B, "indicating the end of the
|
||
GIF Data Stream" (§27).
|
||
|
||
### 2.2 What is unbounded
|
||
|
||
- **Number of images (frames).** §20a, verbatim: "This block is REQUIRED for
|
||
an image. Exactly one Image Descriptor must be present per image in the
|
||
Data Stream. **An unlimited number of images may be present per Data
|
||
Stream.**"
|
||
- **The Data Stream itself.** The grammar in Appendix B is
|
||
`<GIF Data Stream> ::= Header <Logical Screen> <Data>* Trailer`, and the
|
||
spec states "the entity Data … may be repeated any number of times,
|
||
including 0 times." There is **no field anywhere that carries a file size,
|
||
byte count, frame count, or total-length value**. §13 (Block Sizes) only
|
||
defines sizes *within* blocks.
|
||
|
||
### 2.3 Verdict
|
||
|
||
**The GIF89a specification defines no maximum file size.** The only hard
|
||
bounds are per-field: 65535×65535 pixels per screen/image, 255 bytes per
|
||
sub-block, 12 bits per LZW code, and one trailer byte. A compliant decoder
|
||
must process whatever stream the blocks describe. Any byte ceiling a
|
||
particular GIF actually hits is therefore *implicit* — 16-bit dimensions,
|
||
LZW code width, decoder memory, or an external policy — never something the
|
||
format itself enforces. `[INFERENCE]` This is why real-world GIFs cap out at
|
||
"a few GB at most" and every service that wants a bound has to impose one
|
||
itself (see §6; Wikimedia explicitly documents that a 4 GiB host limit was a
|
||
storage-representation artifact of 32-bit integers, `phab:T191805`, not a
|
||
format limit).
|
||
|
||
---
|
||
|
||
## 3. Theoretical worst case
|
||
|
||
### 3.1 Single frame, maximal canvas, 8-bit pixels
|
||
|
||
| Quantity | Value | Derivation |
|
||
|---|---|---|
|
||
| Max pixels | 4,294,836,225 | 65535 × 65535 |
|
||
| Raw 8-bit palette-index raster | 4,294,836,225 B ≈ **4.29 GB / 4.00 GiB** | 1 byte per pixel (Table Based Image Data, §22; Go's `image.Paletted` uses exactly 1 byte/pixel) |
|
||
| LZW worst case | ≈ **6.44 GB / 6.00 GiB** | codes ≤ 12 bits each (Appendix F), at most ~1 code per pixel for incompressible data → ≤ 12 bits/px = 1.5 B/px → 4,294,836,225 × 1.5 B |
|
||
| Sub-block overhead | ≈ +25.3 MB | every ≤255-byte chunk carries a 1-byte size field (§15): ⌈6,442,254,338 / 255⌉ ≈ 25,263,743 size bytes, + 1 block terminator |
|
||
| Fixed overhead | ≈ +1.6 KB | header 6 B (§17) + logical screen descriptor 7 B (§18) + global color table ≤ 768 B (§19) + image descriptor 10 B (§20) + local color table ≤ 768 B (§21) + LZW minimum code size 1 B (§22) |
|
||
|
||
So a **single maximal-frame GIF cannot exceed ≈ 6.47 GB on the wire**
|
||
(12-bit LZW bound), and LZW being lossless means the real byte count depends
|
||
entirely on image content — the same canvas can be a few KB (flat color) or
|
||
~6 GB (noise).
|
||
|
||
Two caveats, both marked `[INFERENCE]`:
|
||
|
||
- The "1.5 B/px" figure assumes ~one emitted code per pixel. An encoder is
|
||
permitted to emit a Clear code at any point (Appendix F: "The Clear code
|
||
can appear at any point in the image data stream"), so a
|
||
pathological-but-compliant encoder emitting clear+pixel per pixel reaches
|
||
~24 bits/px ≈ 12.9 GB for the max canvas. Real encoders do not do this;
|
||
12-bit/px is the practical bound.
|
||
- The spec's deferred-clear note (cover sheet) explicitly allows an encoder
|
||
to keep using a full table at 12-bit codes without clearing, so the 12-bit
|
||
cap holds for the whole stream, it cannot "grow" past 12 bits.
|
||
|
||
### 3.2 Animated GIFs: unbounded
|
||
|
||
Every frame is one Image Descriptor, each bounded by the 65535² canvas, but
|
||
the *count* of frames is unbounded (§2.2). Total bytes = sum over frames —
|
||
therefore **there is no finite maximum byte size for an animated GIF** in
|
||
the format. The only thing that stops a real one is decoder memory, a
|
||
service cap, or disk space. `[INFERENCE]` This is the category the issue #71
|
||
cover falls into: it is an animated GIF (NETSCAPE2.0 loop extension found at
|
||
offset 0x310 of the file, verified 2026-08-17 by a ranged GET), and its
|
||
8,571,192 bytes are ~2.04× the current 4 MiB backend cap.
|
||
|
||
---
|
||
|
||
## 4. Decoder-side real limits
|
||
|
||
### 4.1 Go `image/gif` (the backend's decoder path, stdlib)
|
||
|
||
Source: `/usr/local/go/src/image/gif/reader.go`, Go 1.26.5.
|
||
|
||
- Dimensions are read as little-endian uint16 — `left/top/width/height :=
|
||
int(d.tmp[N]) + int(d.tmp[N+1])<<8` (reader.go:490-493) — so the format
|
||
ceiling 65535 applies, and nothing smaller is enforced.
|
||
- The only geometric check is that each frame fits inside the logical
|
||
screen: `if left+width > d.width || top+height > d.height` →
|
||
`errors.New("gif: frame bounds larger than image bounds")` (reader.go:512-513).
|
||
- **There is no file-size, byte-count, frame-count, or pixel-count guard.**
|
||
Each frame allocates `image.NewPaletted(...)` (reader.go:515) — a
|
||
`[]byte` of width×height — so decoding one legal 65535² frame attempts a
|
||
**~4.29 GB allocation**. `DecodeAll` (reader.go:603-605) additionally
|
||
retains every frame's `Pix` slice for the lifetime of the returned `*GIF`.
|
||
- `[INFERENCE]` On the 1974 MiB swapless VPS (root AGENTS.md), decoding such
|
||
a file would OOM rather than error cleanly; nothing in stdlib protects
|
||
the process. This matters for §7: the backend stores cover bytes without
|
||
decoding them (see §5.3), so the fetch path never triggers this — but any
|
||
future "validate/re-encode server-side" scheme would.
|
||
- Grep for `MaxInt|limit|too large|bounds` in reader.go: the only hits are
|
||
the frame-bounds check above and the `tmp [1024]byte` scratch buffer
|
||
(reader.go:109); no size caps exist.
|
||
|
||
### 4.2 giflib / libgif
|
||
|
||
**Not verified from source.** On 2026-08-17 the giflib sources were not
|
||
reachable from this network: `github.com/giflib/giflib` returns 404 (repo
|
||
gone/moved), `gitlab.com/giflib/giflib/-/raw/...` answers a Cloudflare
|
||
"Just a moment…" challenge, and the SourceForge project download path
|
||
404s. No limit claim about giflib is made here. `[INFERENCE]` giflib is
|
||
widely known to be allocation-driven with no dimension cap, but that is not
|
||
checked against source and is not needed for issue #71 (the backend uses Go
|
||
stdlib, not giflib).
|
||
|
||
### 4.3 Chromium (browser behaviour, first-party source)
|
||
|
||
- `third_party/blink/renderer/platform/image-decoders/gif/gif_image_reader.cc`
|
||
(via `chromium.googlesource.com/chromium/src/+/main/...`, fetched
|
||
2026-08-17): **no GIF byte-size or dimension cap found** — grep for
|
||
`max|limit|too large|dimension|65535|overflow` matches only license text.
|
||
- The base `ImageDecoder` caps *decoded memory*, not transfer size:
|
||
`CalculateMaxDecodedBytes` computes `min(4 * num_pixels, platform_max_decoded_bytes)`
|
||
(8 bytes/pixel for high-bit-depth), and the header comment says "Ignoring
|
||
this limit can cause excessive memory use or even crashes on low-memory
|
||
devices"
|
||
(`image_decoder.cc:94-117`, `image_decoder.h:545-549`). The GIF reader
|
||
itself is untouched by this — it is a decoded-buffer budget.
|
||
- Practical consequence `[INFERENCE]`: a browser will happily download and
|
||
store a multi-GB GIF from its own cache perspective; Chromium only limits
|
||
what it *decodes* into pixels.
|
||
|
||
### 4.4 Firefox
|
||
|
||
`image/decoders/nsGIFDecoder2.cpp` (via `hg.mozilla.org/mozilla-central/
|
||
raw-file/tip/...`, fetched 2026-08-17): **no dimension or size limit**; the
|
||
only guards are on LZW code width (`MAX_BITS` = 12, "maximum codeword size
|
||
of 12 bits") and the decode stack. Nothing bounds the on-disk byte size.
|
||
|
||
### 4.5 Summary
|
||
|
||
No mainstream decoder enforces a byte-size ceiling; they stop at the 16-bit
|
||
dimension ceiling (Go, by construction) or at decoded-memory budgets
|
||
(Chromium) or nowhere (Firefox). A GIF's byte size is policed only by
|
||
*storage* policies — which is what §6 calibrates and §7 sets.
|
||
|
||
---
|
||
|
||
## 5. Practical distribution — what real manga covers weigh
|
||
|
||
Probed **2026-08-17** with `curl -sI` (HEAD) and ranged GETs, desktop Chrome
|
||
UA. No Cloudflare block on any request. Sample = covers *as the backend
|
||
would fetch them* (the `og:image`/page-listed cover URL), not thumbnails we
|
||
chose by hand.
|
||
|
||
### 5.1 Exact commands
|
||
|
||
```sh
|
||
# asurascans.com — harvest cover URLs from the homepage, then HEAD each
|
||
curl -s -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0" https://asurascans.com/ -o home.html
|
||
grep -oE 'https://cdn\.asurascans\.com/asura-images/covers/[^"&\\< ]+\.(webp|gif|jpg|jpeg|png)' home.html \
|
||
| sort -u | grep -v '\-400\.' | head -25 > sample.txt # one full-res cover per series, no -400 thumbs
|
||
while read -r u; do curl -s -A "…Chrome/126.0" -I "$u" | tr -d '\r' \
|
||
| grep -iE '^content-length:'; done < sample.txt
|
||
|
||
# demonicscans.org — covers live on readermc.org (ADR-0007), URLs contain spaces/UTF-8
|
||
curl -s -A "…Chrome/126.0" https://demonicscans.org/ -o demonic.html
|
||
grep -oE 'src="https://readermc\.org/images/thumbnails/[^"]+"' demonic.html | tr -d 'src="' > demonic.txt
|
||
# …plus og:image from 5 manga pages (Catastrophic-Necromancer, Magic-Emperor, …)
|
||
# each URL percent-encoded per path segment (urllib.parse.quote, safe=':/') before HEAD
|
||
```
|
||
|
||
### 5.2 asurascans — 25 full-res covers (mixed formats)
|
||
|
||
Homepage fetched 200 (664,700 B). All 25 returned `200` with a real
|
||
`Content-Length`. Distribution:
|
||
|
||
| Statistic | Bytes |
|
||
|---|---|
|
||
| n | 25 |
|
||
| min | 190,410 |
|
||
| median | 1,275,082 |
|
||
| p90 | 4,524,788 |
|
||
| max | 8,571,192 |
|
||
| mean | 1,943,651 |
|
||
| **> 4 MiB (4,194,304)** | **3 (12%)** — `a-dragonslayers-peerless-regression.gif` 8,571,192 (the issue #71 cover, animated: NETSCAPE2.0 at 0x310, 550×733, 256 colors); `bad-born-blood.3008f6.webp` 4,524,788 `image/jpeg`; `ending-maker.cfbf53.webp` 4,619,303 `image/jpeg` |
|
||
|
||
Notes: the CDN serves `Content-Type` by stored bytes, not by URL extension
|
||
(the `.webp` URLs return `image/png`, `image/jpeg`, or `image/webp` — the
|
||
sample spans all four of `png/jpeg/webp/gif`). Two of the three over-cap
|
||
files are **not GIFs**, so the current 4 MiB cap already silently drops 12%
|
||
of asura covers of any format. p90 itself (4.52 MB) exceeds the cap.
|
||
|
||
### 5.3 demonicscans — 78 covers on readermc.org
|
||
|
||
78 unique cover URLs (73 from the homepage's `/images/thumbnails/` plus 5
|
||
`og:image` values from manga pages — demonicscans publishes the thumbnail
|
||
file as the full cover, so that is exactly what the backend would fetch).
|
||
**78/78 returned 200 with a real Content-Length** (spaces and UTF-8 in the
|
||
filenames were percent-encoded per path segment; the homepage's raw HTML
|
||
carries `’`-style mojibake for curly quotes, which was repaired by
|
||
latin-1→utf-8 re-encoding before probing).
|
||
|
||
| Statistic | Bytes |
|
||
|---|---|
|
||
| n | 78 |
|
||
| min | 13,298 |
|
||
| median | 63,061 |
|
||
| p90 | 206,994 |
|
||
| max | 801,200 |
|
||
| mean | 110,038 |
|
||
| > 4 MiB | 0 |
|
||
|
||
### 5.4 Reading
|
||
|
||
`[INFERENCE]` asurascans covers are the heavy tail (median 1.3 MB, top
|
||
decile > 4 MiB, occasional ~5–9 MB), demonicscans covers are tiny (all
|
||
< 0.8 MB). A cover cap must be chosen against the *asura* distribution —
|
||
the 8.57 MB animated GIF is not a freak one-off outlier; the 90th
|
||
percentile already crosses 4 MiB and two JPEGs sit between 4.5–4.7 MB.
|
||
|
||
---
|
||
|
||
## 6. Comparable documented byte caps (first-party docs only)
|
||
|
||
| Service | Cap | Source (fetched 2026-08-17) |
|
||
|---|---|---|
|
||
| GitHub (issues/PR comments) | **10 MB for images and gifs**; 25 MB other files; 10/100 MB video | `https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/attaching-files` — "The maximum file size is: 10MB for images and gifs … 25MB for all other files" |
|
||
| Discord (API uploads) | default **10 MiB per file**, higher with Nitro / boost tier | `https://discord.com/developers/docs/reference#uploading-files` — "The file upload size limit applies to each file in a request. The default limit is `10 MiB` for all users" (help-center article `support.discord.com/hc/en-us/articles/115002935588` exists but answered 403 from this network on the probe date, so its figures were not verified here) |
|
||
| Wikimedia Commons | **100 MiB** upload limit; hosting up to **5 GiB**; GIF thumbnails limited to **100 megapixels**; prior 4 GiB host cap was a 32-bit storage artifact (phab:T191805) | `https://commons.wikimedia.org/wiki/Commons:Maximum_file_size` |
|
||
| MDN | nothing — MDN documents no byte-size limit for images; browsers impose none (see §4.3–4.4) | `[INFERENCE]` from absence in the platform docs read in §4 |
|
||
|
||
Calibration takeaway: two major platforms independently land on **~10 MB**
|
||
as the ceiling for an uploadable image/GIF (GitHub exactly 10 MB, Discord
|
||
exactly 10 MiB), with Wikimedia the outlier at 100 MiB/5 GiB because it is a
|
||
media *archive*. A 10 MiB cover cap is therefore squarely inside industry
|
||
normal.
|
||
|
||
---
|
||
|
||
## 7. Recommendation for issue #71
|
||
|
||
**Raise the cover cap to 10 MiB (10,485,760 B) — as a separate constant, not
|
||
by moving the shared one.**
|
||
|
||
Why:
|
||
|
||
- **Fits the measured reality.** The largest observed cover is 8,571,192 B
|
||
(the issue's animated GIF) = 82% of 10 MiB; 10 MiB covers **100% of the
|
||
103 sampled covers** and the *entire* asura distribution, including its
|
||
heavy tail. 4 MiB rejects 12% of asura covers (two of them plain JPEGs).
|
||
- **Matches industry calibration** (§6): GitHub 10 MB images/GIFs, Discord
|
||
10 MiB default. A 10 MiB cap is a number every engineer recognizes, and
|
||
it leaves ~18% headroom over the current worst observed file.
|
||
- **Costs little on the target hardware.** The backend buffers cover bytes
|
||
whole during fetch (`backend/internal/latest/cover.go`: `ContentLength >
|
||
maxBodyBytes` rejection at :155, then `io.ReadAll(io.LimitReader(…,
|
||
maxBodyBytes+1))` at :158) and loads the full body per `GET /covers/…`
|
||
(`backend/internal/api/handlers.go`, `Cover` → `w.Write(body)`). Worst
|
||
case per concurrent fetch **+** serve is therefore 2 × cap = 20 MiB; even
|
||
ten of each concurrently is ~200 MiB of a 1974 MiB swapless VPS (~10%),
|
||
and the browser unit (471 MiB, root AGENTS.md) is no longer on that box.
|
||
The 4 MiB series-page cap is *not* the issue — measured pages run
|
||
100 KB–1.2 MB (`backend/internal/latest/fetch.go` comment) — so keep it.
|
||
- **The cap is a separate knob.** Today one `const maxBodyBytes = 4 << 20`
|
||
(`backend/internal/latest/fetch.go:17`) gates *both* series pages and
|
||
covers (`cover.go` references it). Raising it wholesale would loosen the
|
||
page-side memory guard for no benefit; a cover-specific constant (e.g.
|
||
`maxCoverBytes = 10 << 20`) keeps the two policies independent. The fetch
|
||
already double-checks `ContentLength` and the post-`LimitReader` length,
|
||
so a larger constant changes nothing else.
|
||
|
||
Alternatives and their costs:
|
||
|
||
| Option | Cost |
|
||
|---|---|
|
||
| Keep 4 MiB | 12% of asura covers (incl. non-GIF JPEGs) never stored — current bug, silent missing covers. |
|
||
| 16 MiB cap | 2× headroom over the observed max for future GIFs; +60% worst-case transient memory vs 10 MiB; diverges from the GitHub/Discord 10 MB calibration. |
|
||
| Server-side re-encode / downscale covers | Requires decoding → Go `image/gif` allocates width×height per frame with **no guard** (§4.1); a legal 65535² GIF forces a ~4.29 GB allocation on a 1974 MiB swapless box — OOM, not an error. Also mutates bytes, which the store treats as immutable/content-addressed (ADR-0007). Highest risk, no upside at this scale. |
|
||
| No cap | Unbounded transient memory and disk; rejected outright. |
|
||
|
||
Decision is the user's; on the evidence, **10 MiB for covers, 4 MiB for
|
||
pages** is the defensible middle.
|