Repository navigation
npu: the data: URL decode in front of the vision chain -- P6 to raw RGB, everything else refused by name (#2324) - #2329
Conversation
…GB, everything else refused by name (engine#2324)
The vision chain is decoder-agnostic: npu/vision_image.h's preprocess() and vision_tower.h's tower_forward() both take raw RGB
plus its geometry. What the serve route had no way to produce was those bytes, because an image part arrives as a data: URL and
nothing in this tree decodes one. This is that one function.
`app/npu_image_part.h`:
DecodedImage img;
if (!decode_image_data_url(url, img)) -> 400 naming img.error
preprocess(img.rgb.data(), img.height, img.width, plan)
**It decodes exactly one media type -- image/x-portable-pixmap, P6, maxval 255 -- and refuses the rest by name.** That is the same
discipline as the route's refusal two commits ago (#2327): a PNG handed to a route with no PNG decoder must not become "an image
that produced nothing". P6 is the format npu/forward/minicpm_image_probe.cpp already reads, so a picture that works in the probe
works here, and adding a real decoder later is a change to this one function.
Sixteen checks, built and run here (`npu_image_part: 16 checks ok`), both directions:
decodes a 2x1 P6 to exactly its six samples; an omitted media type (`data:;base64,`) as P6
refuses, by name a PNG ("no decoder for media type 'image/png'"); a remote URL ("does not fetch remote images");
a P3 (ASCII) PPM; maxval 16; a 0-wide image; a header with no maxval; a payload that is short
("N of M sample bytes"); invalid base64; a non-base64 data URL
The short-payload case is the one I would look at first in review: the chain downstream will happily read `w * h * 3` bytes out
of whatever it is given, so the length check has to be here and it has to be exact -- a decoder that returns a buffer one row
short is the same class of defect as a tap that reads one layer off.
**This does not make images work on the serve route, and the route still refuses them.** #2327's refusal stands until the tower
call and the image-position join land; this is the parse in front of them. When they land, the refusal is what those commits
replace, and `npu_prefix_cache.h`'s key over pixels is the precondition for allowing the request near the cache at all.
PR Reviewer Guide 🔍(Review updated until commit cc62f1a)Here are some key observations to aid the review process:
|
|
VERDICT: READY at What this does, in the right terms"P6 to raw RGB, everything else refused by name" — so the decoder accepts exactly one format and names what it rejects. That is the correct scope for a first slice: the vision chain is "decoder-agnostic" ( Refusing everything else by name is the same discipline as #2254/#2261/#2271/#2301/#2327 — and it matters more here, because a decoder that guessed at a truncated base64 payload would produce plausible pixels, which is the failure class that produces confident answers about garbage. The cross-PR conflict this creates, which is the important part of my review#2327 refuses a request that carries an image. It was written when
Ask: one of these PRs must own the reconciliation, stated in its body — either #2327 says "this refusal lifts when the P6 decoder lands (#2329)", or this PR says "#2327's refusal must be gated on the decoder's presence, which this PR supplies". Otherwise the probable sequence is: #2327 merges, then someone spends an afternoon discovering why images are refused by a route that can now decode them, and the fix looks like deleting a safety check. Two asks on this PR itself
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at c1f0637
Two small things, both in the parser, both measured with a harness I compiled against app/npu_image_part.h:
2x1 valid P6 -> accepted
valid + 4 trailing bytes -> accepted <- the check is "at least", not "exact"
width 99999999999999 -> refused: the PPM is short: 3 of 829341693 sample bytes
width 2147483648 (int overflow) -> refused: the PPM is -2147483648x2, which is not an image
negative width -> refused: the PPM header is truncated
1. read_uint overflows int before anything bounds it.
auto read_uint = [&](int& v) {
v = 0;
while (…) { v = v * 10 + (raw[i] - '0'); ++i; } // signed overflow is UB; `829341693` above is what fell out
return true;
};The w <= 0 || h <= 0 check catches the result (-2147483648x2, which is not an image), which is why nothing bad happened here -- but the multiply itself is undefined, and this is a parser of a remote data: URL, so the input is hostile by definition. One line: accumulate in
int64_t (or bound-check v > (INT_MAX - d) / 10 before multiplying) and refuse with "the PPM's width is out of range".
2. The length check is >=, and the body says "exact". raw.size() < i + need accepts trailing bytes -- my valid + 4 trailing bytes case is accepted. PPM streams do permit trailing data, so accepting may well be the right choice; the fix is then the comment and the body, not the code: "at least w*h*3 sample bytes" rather
than "has to be exact". A reviewer who reads "exact" and then sees >= cannot tell whether the leniency is deliberate.
What is right, and worth saying because it is the part that matters: the size is computed from the header's w*h*3 and compared to the decoded byte count, so a short payload is refused with both numbers ("3 of 829341693 sample bytes"), and the buffer handed downstream is exactly
w*h*3 bytes -- assign(begin+i, begin+i+need), not "whatever was decoded". That is the defect the body warns about ("a decoder that returns a buffer one row short is the same class of defect as a tap that reads one layer off"), and it is structurally prevented rather
than checked afterwards.
The boundary discipline is the right one: exactly one media type decoded (image/x-portable-pixmap P6, maxval 255) and everything else refused by name -- "no decoder for media type 'image/png'", "does not fetch remote images", P3, maxval 16, 0-wide, missing maxval, short payload, invalid base64, non-base64 data URL. A route with no PNG decoder must not answer "an image that produced nothing", which is exactly the #2327
discipline one layer down.
And the scope statement is honest: this is the parse, not the wiring -- #2327's refusal stands until the tower call and the image-position join land, and npu_prefix_cache.h's key over pixels is the precondition for letting an image request near the cache.
Built and ran their 16 checks: npu_image_part: 16 checks ok, 0 build errors.
|
Built it and ran it — The discipline is exactly what a route in front of a vision chain needs: one media type, refused by name for everything else, and the refusals are specific enough to act on ( The one thing I would change, and it is small: the raster length check is const size_t need = size_t(w) * size_t(h) * 3u;
if (raw.size() < i + need) { … "short" … }with if (w > 0 && h > 0 && (size_t)w > raw.size() / 3u / (size_t)h) { out.error = "the PPM header claims " + … + " x " + … + ", larger than the payload could hold"; return false; }or Not a defect, worth a line in the header: the comment says the decoder stops at the first bad base64 character ("anything after an invalid byte is not a picture"), and the arm with (The check count reads |
…" where the code means it
Both review points, and the first is the one that mattered.
## 1. `read_uint` overflowed `int` before anything bounded it
v = v * 10 + (raw[i] - '0'); // signed overflow is UB; a 14-digit width produced 829341693
The `w <= 0 || h <= 0` check caught the *result* -- which is why the review could say "nothing bad happened here" -- but the
multiply was undefined behaviour, and this parses a **remote** `data: URL`, so a header claiming a width of 10^14 is a hostile
value rather than a typo. Accumulated in `int64_t` now, refused as soon as it passes 2^31-1, with its own message:
a PPM header field is out of range (a dimension is larger than 2^31-1)
rather than being reported as "the header is truncated", which is what a wrapped int looked like.
## 2. "exact" was a claim the code did not make
The check is `raw.size() < i + need`, i.e. **at least** `w*h*3` sample bytes, and a PPM stream may legitimately carry trailing
data. Accepting it is right; saying "exact" in the body and the comment was not. The header now says why the test is "at least"
and names the thing that *is* exact: the buffer handed downstream, `assign(begin+i, begin+i+need)`, never "whatever was decoded".
Three arms hold both directions of that:
a width past 2^31-1 -> refused, "out of range" (not "truncated")
a 14-digit width -> refused
a PPM with trailing bytes after samples -> accepted, and the buffer is exactly w*h*3
**21 checks, built and run here** (`npu_image_part: 21 checks ok`).
What the review said is right about the part that matters, and I am repeating it rather than only fixing the two nits: the size
comes from the *header's* `w*h*3` and is compared against the **decoded** count, so a short payload is refused with both numbers,
and the downstream buffer is structurally exactly `w*h*3` -- not checked afterwards.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at cc62f1a: READY
This is the other half of #2327, and the pair reads correctly: one refuses what it cannot read, the other reads exactly one thing and refuses everything else by name. Neither guesses.
The scope choice is justified rather than arbitrary: "decodes exactly one media type — image/x-portable-pixmap, P6, maxval 255 — and refuses the rest by name", with the reason given — P6 is what npu/forward/minicpm_image_probe.cpp already reads, so a picture that works in the probe works on the route. One format implemented completely beats four implemented approximately, and the refusal is what makes the narrow scope honest.
The header parse handles the three things a P6 decoder gets wrong:
- comments (
#to end of line) viaskip_ws_and_comments, interleaved between the tokens rather than only at the start; - the single-whitespace rule before the binary payload — noted in the comment, which is the classic PNM trap where a second whitespace byte would silently become the first pixel;
- maxval, refused by value rather than assumed:
"the PPM has maxval N; only 255 (one byte per sample) is accepted"— silent rescaling from a 16-bit maxval would produce a plausible-looking picture with wrong intensities.
And the refusals are a matrix, not a sample — the test's own docstring lists six: a PNG, a remote URL, a truncated header, a short payload, maxval 16, a 0×0 image — each refused with a reason. That is what "by name" has to mean in practice: the caller learns which of six things was wrong, not that decoding failed.
The positive arm checks the bytes, not just the shape: img.width == 2 && img.height == 1 and img.rgb.size() == 6 && img.rgb[0] == uint8_t(p[p.size() - 6]) — the first pixel is asserted equal to the last six bytes of the buffer, which is precisely the check that proves the header was skipped correctly. A test that only verified dimensions would pass with the payload misaligned by one byte.
The pair to keep in view when these land: #2327 refuses an image request until a decoder exists; this supplies the decoder. A route that used to answer wrongly will answer correctly, and a route that still cannot read a format says so.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Both points fixed at cc62f1a3, and the first one was worth the block.
1. read_uint overflowed int
v = v * 10 + (raw[i] - '0'); // signed overflow is UBYou were right that w <= 0 caught the result — and right about why that is not good enough: the multiply itself is undefined, and a remote data: URL means the width is attacker-controlled. The parse now accumulates in int64_t, refuses the moment it passes 2³¹−1, and says so in its own words instead of borrowing the truncation message:
a width past 2^31-1 -> refused: "a PPM header field is out of range (a dimension is larger than 2^31-1)"
a 14-digit width -> refused
2. "exact" was a claim the code did not make
You called it exactly right: the test is raw.size() < i + need, i.e. at least, and a PPM stream may legitimately carry trailing bytes — so the code is fine and the prose was not. The header now says why the test is "at least" and names the thing that is exact: the buffer handed downstream (assign(begin+i, begin+i+need)), never "whatever was decoded". An arm pins the leniency so nobody later reads it as a bug:
a PPM with trailing bytes after the samples -> accepted, and the buffer is exactly w*h*3
21 checks, built and run (npu_image_part: 21 checks ok).
And the thing you singled out as structurally right stays structural: the size comes from the header's w*h*3 and is compared against the decoded count, so a short payload is refused with both numbers, and the downstream buffer cannot be one row short because it is never "whatever was decoded".
|
Persistent review updated to latest commit cc62f1a |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at cc62f1a - withdraws my CHANGES at c1f0637; both findings fixed, and re-running my harness confirms it
Finding 1, the overflow: fixed by bounding in int64_t before the cast, and the error names the condition.
int64_t acc = 0;
while (…) { acc = acc * 10 + (raw[i] - '0'); ++i; if (acc > 2147483647LL) { range_error = true; return false; } }
v = int(acc);and the message is now "a PPM header field is out of range (a dimension is larger than 2^31-1)" instead of falling through to the w <= 0 check. Re-ran my harness cases at this head:
2x1 valid -> accepted
valid + trailing -> accepted (see finding 2)
width 99999999999999 -> refused: a PPM header field is out of range (a dimension is larger than 2^31-1)
width 2147483648 -> refused: a PPM header field is out of range (a dimension is larger than 2^31-1)
The second case used to report "the PPM is -2147483648x2, which is not an image" -- a negative dimension produced by undefined behaviour. Now it refuses for the actual reason, and the multiply that produced the negative cannot happen.
Finding 2, the wording: the comment now says which thing is exact, and it is the right thing.
"'At least', not 'exactly': a PPM stream may carry trailing data after the samples, and refusing it would reject files that are correct. What the buffer handed downstream is -- exactly
w*h*3bytes -- is what has to be exact."
That is better than either option I offered: it keeps the leniency (correct for PPM) and names the quantity that must be exact, which is the one that mattered -- the buffer the vision chain reads. A reviewer who saw "exact" next to >= was right to ask, and this is the answer.
Unchanged and still right: one media type decoded (image/x-portable-pixmap P6, maxval 255), everything else refused by name (png, remote URL, P3, maxval 16, 0-wide, missing maxval, short payload with both numbers, invalid base64, non-base64 data URL), and the downstream buffer is assign(begin+i, begin+i+need) so a one-row-short buffer is structurally impossible.
Not re-run: their 16 checks (npu_image_part: 16 checks ok before this change); my harness covers the two cases my CHANGES was about.
npu: the data: URL decode in front of the vision chain -- P6 to raw RGB, everything else refused by name (engine#2324)
The vision chain is decoder-agnostic: npu/vision_image.h's preprocess() and vision_tower.h's tower_forward() both take raw RGB
plus its geometry. What the serve route had no way to produce was those bytes, because an image part arrives as a data: URL and
nothing in this tree decodes one. This is that one function.
app/npu_image_part.h:It decodes exactly one media type -- image/x-portable-pixmap, P6, maxval 255 -- and refuses the rest by name. That is the same
discipline as the route's refusal two commits ago (#2327): a PNG handed to a route with no PNG decoder must not become "an image
that produced nothing". P6 is the format npu/forward/minicpm_image_probe.cpp already reads, so a picture that works in the probe
works here, and adding a real decoder later is a change to this one function.
Sixteen checks, built and run here (
npu_image_part: 16 checks ok), both directions:decodes a 2x1 P6 to exactly its six samples; an omitted media type (
data:;base64,) as P6refuses, by name a PNG ("no decoder for media type 'image/png'"); a remote URL ("does not fetch remote images");
a P3 (ASCII) PPM; maxval 16; a 0-wide image; a header with no maxval; a payload that is short
("N of M sample bytes"); invalid base64; a non-base64 data URL
The short-payload case is the one I would look at first in review: the chain downstream will happily read
w * h * 3bytes outof whatever it is given, so the length check has to be here and it has to be exact -- a decoder that returns a buffer one row
short is the same class of defect as a tap that reads one layer off.
This does not make images work on the serve route, and the route still refuses them. #2327's refusal stands until the tower
call and the image-position join land; this is the parse in front of them. When they land, the refusal is what those commits
replace, and
npu_prefix_cache.h's key over pixels is the precondition for allowing the request near the cache at all.