A JPEG too large for the server is made smaller as it is stored, not refused (#1096) #1103

Merged
claude-bot merged 3 commits from feat/1096-shrink-large-pictures into main 2026-10-05 10:27:34 +00:00
Collaborator

Closes #1096 (D284, the owner's answer to Q156). Not to be merged before the independent review reports.

Summary

  • A JPEG over the picture bound is decoded at a reduced scale and stored at that size. libjpeg can decode at 1/2, 1/4 or 1/8 while reading (Image.draft), so the picture is never held at full size.
  • A picture at or under the bound is stored exactly as before.
  • Uploads and imports behave alike. An import leaves out and lists only what an upload would refuse.
  • PNG and WebP over the bound are still refused. Pillow has no scaled decode for either. The sentence now ends: "A JPEG of the same picture may be larger, up to 108 million pixels: it is made smaller as it is stored."
  • Maps are untouched.
  • No migration, bot, compose or frontend change. Nothing stores or returns a page picture's size, and no screen reports on a finished upload, so no sentence was added to the app.

Where this departs from the plan

There is no resize step, so a JPEG just over the bound comes out at a quarter of its pixels.

draft offers only 1/2, 1/4 and 1/8. An intermediate "at or above the target" is therefore over the bound by up to four times, and that intermediate is what does not fit. Measured on a 24 MP intermediate: the decode alone is +94 to +96 MB, and every way of resizing it costs more (+97 to +211 MB).

So the picture is decoded at the largest scale whose result is within the bound, and that is stored:

Upload Stored as
4080×3072 (12.5 MP, at the bound) whole
4160×3120 (13 MP, common on Android) 2080×1560
5712×4284 (24 MP) 2856×2142
8064×6048 (48 MP) 4032×3024

Before this, all but the first were refused.

Ceilings, from the header, before any decode

Kind Stored whole up to Shrunk up to
Baseline RGB or grey 12.53 MP 108 MP
Baseline CMYK 6.27 MP 108 MP
Progressive or multi-scan 6.27 MP 8.95 MP
Progressive CMYK 6.27 MP 6.96 MP
PNG, WebP, GIF, lossless or arithmetic JPEG, maps unchanged never
  • draft does not avoid the progressive coefficient buffer, which stays at full size. That is why a progressive JPEG gains so little.
  • Only SOF0, SOF1 and SOF2 are ever scaled. Pillow sizes its row buffer from the drafted size and does not check libjpeg's output width, and libjpeg-turbo decodes lossless JPEG without scaling it.
  • If draft does not take, the picture is refused. _decode_smaller checks the image's size and decoder configuration before any load; there is no path from there to a full-size decode.

A bug on main, found while measuring and fixed here

_jpeg_profile stopped at an escaped FF 00 between segments, which Pillow and libjpeg step over. Two bytes after the start marker made a 12.5 MP progressive JPEG read as "baseline, 0 scans": it was counted once, missed the scan cap, and decoded at +124 MB in a 512 MB container. The walk now steps over FF 00 and runs on the sanitised bytes the decoder receives. Its own fragment: changelog.d/1096-progressive-jpeg-escaped-ff.security.md.

Measured

docker run -m 512m, the real validator, peak over the state before the call:

File Stored as Whole Now Time
24 MP baseline, turned 2856×2142 +97 MB +32 MB 1.4 s
48 MP baseline noise 4:4:4, turned (worst file taken) 4032×3024 n/a +62 MB 1.2 s
108 MP baseline, turned 3000×2250 n/a +37 MB 0.8 s
Progressive 4:4:4 at its ceiling, turned 1728×1295 n/a +62 MB 1.3 s
CMYK 100 MP, turned 2887×2165 n/a +55 MB 1.1 s

The worst accepted file costs +62 MB, the same envelope as before. The slowest takes 1.4 s.

Through nginx on a warm 512 MB backend: the files above were stored at the sizes shown, upright, with no metadata; past-ceiling files answered 422 in under half a second. Anonymous memory peaked at 494 MB, when the worst file was sent three times straight after a batch that had left the backend at 429 MB. oom_kill 0, no restarts.

Known and unchanged

  • The margin is as thin as before. A backend resting past 430 MB has no room for the worst file.
  • A progressive CMYK JPEG stored whole at its existing bound costs +71 to +72 MB, over the +66 of the worst baseline. Weighting it more would halve files that are stored whole today.
  • A 4:2:0 progressive JPEG costs half what the ceiling assumes. Reading the sampling factors would admit about 15 MP of those; not built.

Changelog

  • changelog.d/1096-shrink-large-pictures.changed.md
  • changelog.d/1096-progressive-jpeg-escaped-ff.security.md

Test plan

  • Full backend suite at the head commit: 8052 passed, 14 skipped, 107 deselected.
  • tests/wiki, tests/export, the damaged-image and session-hygiene tests: 1790 passed.
  • New test_shrink_large_pictures.py and test_import_shrinks_pictures.py. Against main's image_service: 63 failed, 5 passed.
  • Four existing tests changed meaning, each deliberately: two refusal sentences gain the JPEG hint; an over-bound CMYK JPEG and a 3200×2400 progressive JPEG are now shrunk, with their refusals asserted at the new ceilings.
  • Lint clean; version and changelog checks OK.
  • Not tested: a real lossless (SOF3) JPEG, since nothing here writes one; its refusal is held by a header-only test, and the reason for refusing it is read from source, not observed.
  • Not run: frontend, bot, synthetic, journeys (untouched); CI.

🤖 Generated with Claude Code

Closes #1096 (D284, the owner's answer to Q156). Not to be merged before the independent review reports. ## Summary - **A JPEG over the picture bound is decoded at a reduced scale and stored at that size.** libjpeg can decode at 1/2, 1/4 or 1/8 while reading (`Image.draft`), so the picture is never held at full size. - **A picture at or under the bound is stored exactly as before.** - **Uploads and imports behave alike.** An import leaves out and lists only what an upload would refuse. - **PNG and WebP over the bound are still refused.** Pillow has no scaled decode for either. The sentence now ends: "A JPEG of the same picture may be larger, up to 108 million pixels: it is made smaller as it is stored." - **Maps are untouched.** - No migration, bot, compose or frontend change. Nothing stores or returns a page picture's size, and no screen reports on a finished upload, so no sentence was added to the app. ## Where this departs from the plan **There is no resize step, so a JPEG just over the bound comes out at a quarter of its pixels.** `draft` offers only 1/2, 1/4 and 1/8. An intermediate "at or above the target" is therefore over the bound by up to four times, and that intermediate is what does not fit. Measured on a 24 MP intermediate: the decode alone is +94 to +96 MB, and every way of resizing it costs more (+97 to +211 MB). So the picture is decoded at the largest scale whose result is within the bound, and that is stored: | Upload | Stored as | |---|---| | 4080×3072 (12.5 MP, at the bound) | whole | | 4160×3120 (13 MP, common on Android) | 2080×1560 | | 5712×4284 (24 MP) | 2856×2142 | | 8064×6048 (48 MP) | 4032×3024 | Before this, all but the first were refused. ## Ceilings, from the header, before any decode | Kind | Stored whole up to | Shrunk up to | |---|---|---| | Baseline RGB or grey | 12.53 MP | 108 MP | | Baseline CMYK | 6.27 MP | 108 MP | | Progressive or multi-scan | 6.27 MP | 8.95 MP | | Progressive CMYK | 6.27 MP | 6.96 MP | | PNG, WebP, GIF, lossless or arithmetic JPEG, maps | unchanged | never | - **`draft` does not avoid the progressive coefficient buffer**, which stays at full size. That is why a progressive JPEG gains so little. - **Only SOF0, SOF1 and SOF2 are ever scaled.** Pillow sizes its row buffer from the drafted size and does not check libjpeg's output width, and libjpeg-turbo decodes lossless JPEG without scaling it. - **If `draft` does not take, the picture is refused.** `_decode_smaller` checks the image's size and decoder configuration before any load; there is no path from there to a full-size decode. ## A bug on main, found while measuring and fixed here `_jpeg_profile` stopped at an escaped `FF 00` between segments, which Pillow and libjpeg step over. Two bytes after the start marker made a 12.5 MP progressive JPEG read as "baseline, 0 scans": it was counted once, missed the scan cap, and decoded at +124 MB in a 512 MB container. The walk now steps over `FF 00` and runs on the sanitised bytes the decoder receives. Its own fragment: `changelog.d/1096-progressive-jpeg-escaped-ff.security.md`. ## Measured `docker run -m 512m`, the real validator, peak over the state before the call: | File | Stored as | Whole | Now | Time | |---|---|---:|---:|---:| | 24 MP baseline, turned | 2856×2142 | +97 MB | +32 MB | 1.4 s | | 48 MP baseline noise 4:4:4, turned (worst file taken) | 4032×3024 | n/a | +62 MB | 1.2 s | | 108 MP baseline, turned | 3000×2250 | n/a | +37 MB | 0.8 s | | Progressive 4:4:4 at its ceiling, turned | 1728×1295 | n/a | +62 MB | 1.3 s | | CMYK 100 MP, turned | 2887×2165 | n/a | +55 MB | 1.1 s | The worst accepted file costs +62 MB, the same envelope as before. The slowest takes 1.4 s. **Through nginx on a warm 512 MB backend:** the files above were stored at the sizes shown, upright, with no metadata; past-ceiling files answered 422 in under half a second. Anonymous memory peaked at 494 MB, when the worst file was sent three times straight after a batch that had left the backend at 429 MB. `oom_kill 0`, no restarts. ## Known and unchanged - **The margin is as thin as before.** A backend resting past 430 MB has no room for the worst file. - **A progressive CMYK JPEG stored whole at its existing bound costs +71 to +72 MB**, over the +66 of the worst baseline. Weighting it more would halve files that are stored whole today. - **A 4:2:0 progressive JPEG costs half what the ceiling assumes.** Reading the sampling factors would admit about 15 MP of those; not built. ## Changelog - [x] `changelog.d/1096-shrink-large-pictures.changed.md` - [x] `changelog.d/1096-progressive-jpeg-escaped-ff.security.md` ## Test plan - [x] **Full backend suite** at the head commit: `8052 passed, 14 skipped, 107 deselected`. - [x] `tests/wiki`, `tests/export`, the damaged-image and session-hygiene tests: `1790 passed`. - [x] New `test_shrink_large_pictures.py` and `test_import_shrinks_pictures.py`. Against main's `image_service`: `63 failed, 5 passed`. - [x] Four existing tests changed meaning, each deliberately: two refusal sentences gain the JPEG hint; an over-bound CMYK JPEG and a 3200×2400 progressive JPEG are now shrunk, with their refusals asserted at the new ceilings. - [x] Lint clean; version and changelog checks OK. - [ ] **Not tested:** a real lossless (SOF3) JPEG, since nothing here writes one; its refusal is held by a header-only test, and the reason for refusing it is read from source, not observed. - [ ] Not run: frontend, bot, synthetic, journeys (untouched); CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(webapp): a JPEG too large for the server is made smaller on the way in (#1096)
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 38s
CI / Backend lint (ruff) (pull_request) Successful in 44s
CI / Does this change touch the recording path? (pull_request) Successful in 45s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Feedback site tests (pull_request) Successful in 1m2s
CI / Docker image build (pull_request) Successful in 1m11s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m16s
CI / Bot tests and audit (pull_request) Successful in 2m13s
CI / Frontend (shard 2/2) (pull_request) Successful in 9m31s
CI / Frontend (shard 1/2) (pull_request) Successful in 11m3s
CI / End-to-end journeys (phone) (pull_request) Successful in 11m39s
CI / End-to-end journeys (keyboard) (pull_request) Successful in 12m34s
CI / End-to-end journeys (desktop) (pull_request) Successful in 12m36s
CI / Backend migration, tests, and audit (pull_request) Successful in 30m27s
36236146d1
A page's picture or an image attachment over the picture bound (4080x3072)
was refused, which is what a phone's 24 or 48 MP photograph is. The owner
decided on 5 October 2026 to shrink it on the way in and store that.

libjpeg decodes a JPEG at a half, a quarter or an eighth of its size while
it reads it (Image.draft), so the picture is never held at full size. A
JPEG over the bound is decoded at the largest of those scales that is
within the bound and stored at that size, turned upright afterwards, with
its metadata stripped before it is opened as before. A picture within the
bound is decoded exactly as it was and stored byte for byte the same. An
import asks the same header check, so it restores such a picture made
smaller and no longer leaves it out. A map is never made smaller.

Set from measurements in a 512 MB container (docs/OPERATIONS.md):

- No resize step. An intermediate over the bound is what does not fit
  (24 MP: +94 MB before any resize, +208 with resize or thumbnail, +98
  resized in strips), so what is stored has between a quarter of the
  bound and the bound, and is never larger than it came.
- A ceiling in header pixels for each kind of JPEG, asked before anything
  is decoded: 108 million for a baseline JPEG, which costs only what it
  comes out as (48 MP noise, turned: +62 MB, against +66 for the worst
  picture already taken whole); 5/7 of the bound (8.95 million) for a
  progressive or multi-scan one, because draft does not avoid libjpeg's
  full-size coefficient buffer (12.5 MP: +83 MB at half size); 5/9 of the
  bound for a progressive CMYK one.
- Only SOF0, SOF1 and SOF2 are asked to scale. Pillow does not check
  libjpeg's rows against the size it expects, and libjpeg-turbo 3 decodes
  a lossless JPEG without scaling it. The frame is read from the bytes,
  and after draft the size and scale are checked before anything is
  loaded: a picture is refused, never decoded whole, if it did not take.
- PNG and WebP over the bound stay refused (no scaled decode in Pillow;
  unfiltering a 24 MP PNG's rows by hand took 130 s), and the sentence
  now says a JPEG of the same picture may be larger.

Also fixes a way round the progressive weight of #1078, found while
measuring: the marker walk stopped at an escaped FF 00 between segments,
which Pillow and libjpeg step over, so two bytes made a 12.5 MP
progressive JPEG count as a baseline one of no scans (+124 MB decoded).
The walk steps over it now and reads the stripped bytes the decoder gets.

No migration, no bot change, no frontend change: neither upload route
returns or stores a picture's dimensions, and nothing in the frontend
reports on a finished upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Collaborator

Independent review of 36236146: not to be merged yet. The new shrink path is sound. Two bypasses were found in the code it builds on, both on main today, and both are being fixed on this branch.

Findings

  1. High, already on main, confirmed by running: _strip_jpeg returns the file unstripped when it stops before a scan. Four bytes in front of the 66 KB MPF file from the tests took Image.open to 1,650 MB. This defeats #1088; #1087 is reopened.
  2. Medium, already on main, confirmed by running: the scan walk stops at an end marker the decoder passes. A progressive 12.5 MP JPEG with the same prefix reads as baseline and was decoded whole at +122 MB.
  3. Low, new: no test would fail if the decoderconfig check were removed.
  4. Low, new: the "could not be made smaller" refusal is rewrapped as "damaged or incomplete".
  5. Note, already on main: a progressive CMYK JPEG at its bound costs +71 to +72 MB, over the envelope. To be weighted by measurement.

Found sound

  • No path reaches a full-size decode of an over-bound JPEG.
  • draft picks exactly the planned scale (300,000 random sizes, 0 mismatches), and the draft is applied to the open that is loaded.
  • The scale arithmetic matches libjpeg's rounding; no size under a ceiling lacks a fitting scale.
  • Multi-scan and progressive files get the lower ceiling; lossless, arithmetic and differential frames are never scaled.
  • Imports leave out exactly what an upload refuses.
  • A picture at or under the bound takes the old path unchanged.
  • 40,000 header mutations gave no uncaught exception and no stored size that differed from the plan.

Not checked by the review: Pillow's C decoder and libjpeg's source (reasoned from memory); the pytest suites; memory at the real ceilings.

**Independent review of `36236146`: not to be merged yet.** The new shrink path is sound. Two bypasses were found in the code it builds on, both on main today, and both are being fixed on this branch. **Findings** 1. **High, already on main, confirmed by running: `_strip_jpeg` returns the file unstripped when it stops before a scan.** Four bytes in front of the 66 KB MPF file from the tests took `Image.open` to 1,650 MB. This defeats #1088; #1087 is reopened. 2. **Medium, already on main, confirmed by running: the scan walk stops at an end marker the decoder passes.** A progressive 12.5 MP JPEG with the same prefix reads as baseline and was decoded whole at +122 MB. 3. **Low, new: no test would fail if the `decoderconfig` check were removed.** 4. **Low, new: the "could not be made smaller" refusal is rewrapped** as "damaged or incomplete". 5. **Note, already on main: a progressive CMYK JPEG at its bound costs +71 to +72 MB**, over the envelope. To be weighted by measurement. **Found sound** - No path reaches a full-size decode of an over-bound JPEG. - `draft` picks exactly the planned scale (300,000 random sizes, 0 mismatches), and the draft is applied to the open that is loaded. - The scale arithmetic matches libjpeg's rounding; no size under a ceiling lacks a fitting scale. - Multi-scan and progressive files get the lower ceiling; lossless, arithmetic and differential frames are never scaled. - Imports leave out exactly what an upload refuses. - A picture at or under the bound takes the old path unchanged. - 40,000 header mutations gave no uncaught exception and no stored size that differed from the plan. **Not checked by the review:** Pillow's C decoder and libjpeg's source (reasoned from memory); the pytest suites; memory at the real ceilings.
fix(webapp): the JPEG strip ends at a scan or refuses; review of #1096
All checks were successful
CI / Docker image build (pull_request) Successful in 16s
CI / Bot/backend version sync (pull_request) Successful in 29s
CI / Backend lint (ruff) (pull_request) Successful in 34s
CI / Does this change touch the recording path? (pull_request) Successful in 41s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Feedback site tests (pull_request) Successful in 48s
CI / Bot tests and audit (pull_request) Successful in 1m52s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 2m30s
CI / End-to-end journeys (phone) (pull_request) Successful in 10m9s
CI / Frontend (shard 2/2) (pull_request) Successful in 15m53s
CI / Frontend (shard 1/2) (pull_request) Successful in 18m49s
CI / End-to-end journeys (keyboard) (pull_request) Successful in 20m17s
CI / End-to-end journeys (desktop) (pull_request) Successful in 20m20s
CI / Backend migration, tests, and audit (pull_request) Successful in 49m33s
a87aa774cc
The review of this branch found two ways round the checks it builds on,
both on main, and three smaller points.

1. The metadata strip of #1087 could be switched off. _strip_jpeg stopped
   at an end-of-image before the scan, at a segment whose length said 0 or
   1, and at a marker with no room for its length, and handed back what it
   had: the file as it came, if nothing had been removed yet. Pillow steps
   past all three, so the EXIF and MPF behind them reached it. FF D8 FF D9
   in front of the 66 kB MPF bomb did not finish a header read in 4 GB; the
   EXIF bomb went from 0.1 to 144 MB. Both are refused at 0.0 MB now.
   The header is stepped through by one function, _jpeg_header, exactly as
   JpegImageFile._open steps. It ends at a scan or raises: everything
   Pillow refuses, and everything Pillow steps past that no decodable JPEG
   has before its scan (an end-of-image, a second start-of-image, a JPG
   marker, a length under two).

2. _jpeg_walk stopped at an end-of-image before the scan too, so SOI EOI
   in front of a progressive JPEG read as a baseline one of no scans
   (+122 MB decoded whole). The walk takes its header steps from the same
   function as the strip, stepping over a stray EOI or SOI as Pillow does.

   The same pass over the other strips: the GIF strip stopped at a byte
   that is no block, where Pillow reads on to the comment behind it (the
   frame walk refused such a file first; the strip refuses it itself now).
   A PNG cut short in a text, profile or EXIF chunk has that chunk dropped,
   not handed on. The WebP strip clears the flags of the first VP8X chunk.

3. A test that fails if the decoderconfig check in _decode_smaller is
   removed (draft does its work, then the setting is spoiled).

4. The "could not be made smaller" refusal reaches the user: _reencode
   lets an ImageError through instead of rewording it as a damaged file.

5. A progressive CMYK JPEG counts three times, not twice: held whole at
   half the bound it took +71 MB; at a third, +48 (+56 with EXIF).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Collaborator

All five review findings fixed in a87aa774 (main merged in first). One more of the same shape was found and fixed in the GIF stripper.

Finding Fix
1. _strip_jpeg returned the file unstripped when it stopped early One generator, _jpeg_header, steps from the start marker to the first scan as Pillow's _open does. It ends at a scan or raises; there is no other exit. The strip's only return is in the scan branch
2. The scan walk stopped at an end marker The walk takes its header steps from the same generator, and refuses a file with no scan
3. The decoderconfig check was untested Four tests. With the check removed, all four fail
4. The "could not be made smaller" sentence was rewrapped It now reaches the user as written
5. Progressive CMYK over the envelope Counts three times in the backend. Its bound is 4.18 MP, at +48 to +56 MB

The same pass over the other strippers

  • GIF: a byte that is no block made the strip stop where Pillow steps over it. It was shielded by the frame walk, which runs first and refuses. Now the strip refuses too.
  • PNG: a chunk cut short at the end of the file is passed on only if it is an image chunk. A cut-short text, profile or EXIF chunk is dropped.
  • WebP: the first VP8X chunk's flags are cleared, not the last. Harmless either way, since the chunks are dropped.

Measured, at the header read

File Before Now
EXIF file with FF D8 FF D9 in front 144.2 MB 0.0 MB, refused
EXIF file behind a zero-length DRI or a one-length DQT 144.0 MB 0.0 MB, refused
MPF file (66 kB), all three forms over 1,650 MB 0.0 MB, refused
Progressive 4080×3072 with the prefix decoded whole, +122 MB refused

New tests

  • 10,000 spoiled headers from a fixed seed: each is refused, or opens with no metadata.
  • Both crafted files behind 22 early-stop shapes.
  • 98 placements of an EXIF segment after stray markers and short segments.
  • Against the earlier commit, the new tests gave 154 failed, 419 passed.

Ran at a87aa774

  • Full backend suite: 8330 passed, 14 skipped, 107 deselected.
  • tests/wiki, tests/export, the damaged-image and session-hygiene tests: 2066 passed.
  • Lint clean; version and changelog checks OK.
  • Through nginx on a warm backend: the prefixed files answered 422 in under a quarter of a second with memory flat; oom_kill 0, no restarts.
  • Re-run by me: the reviewer's t1 and t3 against this commit. Both prefixed files are refused at 0.0 MB, and the as-built ones open as the 40×30 picture inside them.

Not done

  • No further independent review of this commit.
  • A real lossless JPEG is still untested.
  • libwebp's own chunk handling was not read; the WebP conclusion rests on the strip walking every top-level chunk.

One figure corrected: the envelope's real top is +66 to +67 MB, not +62. The dearest file is one at the upload cap that also carries EXIF, because the strip holds a second copy during the decode. The progressive CMYK ceiling reaches +68 MB with such a file.

**All five review findings fixed in `a87aa774`** (main merged in first). One more of the same shape was found and fixed in the GIF stripper. | Finding | Fix | |---|---| | 1. `_strip_jpeg` returned the file unstripped when it stopped early | One generator, `_jpeg_header`, steps from the start marker to the first scan as Pillow's `_open` does. It ends at a scan or raises; there is no other exit. The strip's only return is in the scan branch | | 2. The scan walk stopped at an end marker | The walk takes its header steps from the same generator, and refuses a file with no scan | | 3. The `decoderconfig` check was untested | Four tests. With the check removed, all four fail | | 4. The "could not be made smaller" sentence was rewrapped | It now reaches the user as written | | 5. Progressive CMYK over the envelope | Counts three times in the backend. Its bound is 4.18 MP, at +48 to +56 MB | **The same pass over the other strippers** - **GIF:** a byte that is no block made the strip stop where Pillow steps over it. It was shielded by the frame walk, which runs first and refuses. Now the strip refuses too. - **PNG:** a chunk cut short at the end of the file is passed on only if it is an image chunk. A cut-short text, profile or EXIF chunk is dropped. - **WebP:** the first `VP8X` chunk's flags are cleared, not the last. Harmless either way, since the chunks are dropped. **Measured, at the header read** | File | Before | Now | |---|---|---| | EXIF file with `FF D8 FF D9` in front | 144.2 MB | 0.0 MB, refused | | EXIF file behind a zero-length `DRI` or a one-length `DQT` | 144.0 MB | 0.0 MB, refused | | MPF file (66 kB), all three forms | over 1,650 MB | 0.0 MB, refused | | Progressive 4080×3072 with the prefix | decoded whole, +122 MB | refused | **New tests** - 10,000 spoiled headers from a fixed seed: each is refused, or opens with no metadata. - Both crafted files behind 22 early-stop shapes. - 98 placements of an EXIF segment after stray markers and short segments. - Against the earlier commit, the new tests gave `154 failed, 419 passed`. **Ran at `a87aa774`** - **Full backend suite:** `8330 passed, 14 skipped, 107 deselected`. - `tests/wiki`, `tests/export`, the damaged-image and session-hygiene tests: `2066 passed`. - Lint clean; version and changelog checks OK. - **Through nginx on a warm backend:** the prefixed files answered 422 in under a quarter of a second with memory flat; `oom_kill 0`, no restarts. - **Re-run by me:** the reviewer's `t1` and `t3` against this commit. Both prefixed files are refused at 0.0 MB, and the as-built ones open as the 40×30 picture inside them. **Not done** - No further independent review of this commit. - A real lossless JPEG is still untested. - libwebp's own chunk handling was not read; the WebP conclusion rests on the strip walking every top-level chunk. **One figure corrected:** the envelope's real top is +66 to +67 MB, not +62. The dearest file is one at the upload cap that also carries EXIF, because the strip holds a second copy during the decode. The progressive CMYK ceiling reaches +68 MB with such a file.
claude-bot deleted branch feat/1096-shrink-large-pictures 2026-10-05 10:27:35 +00:00
rbrooks referenced this pull request from a commit 2026-10-05 10:27:56 +00:00
Sign in to join this conversation.
No description provided.