Bug: multipart parser truncates binary uploads ending in a whitespace byte → bricked flashes (Image hash failed)
Summary
The custom multipart/form-data parser in pi/portal.py calls part.strip() on each raw
binary part. bytes.strip() removes trailing ASCII‑whitespace bytes (0x09 0x0a 0x0b 0x0c 0x0d 0x20), so any uploaded binary whose final byte is one of those is silently truncated. The
truncated image is flashed faithfully (esptool even reports Hash of data verified), but the
device then boot‑loops with:
E (xxx) esp_image: Image hash failed - image is corrupt
E (xxx) boot: Factory app partition is not bootable
because the image's appended SHA‑256 no longer matches. It affects roughly 2–3 % of builds at
random (whatever the final byte happens to be) and is independent of chip, board, baud, and
esptool version — which makes it extremely confusing to diagnose (it looks board/chip‑specific).
Affected code
pi/portal.py, in both multipart handlers:
/api/flash — line 2715
/api/firmware/upload — line 2634
parts_raw = body.split(b"--" + boundary_bytes)
...
for part in parts_raw:
part = part.strip() # <-- BUG: strips whitespace bytes from BINARY content
if not part or part == b"--":
continue
if b"\r\n\r\n" in part:
header_section, content = part.split(b"\r\n\r\n", 1)
...
if content.endswith(b"\r\n"):
content = content[:-2] # this already removes the framing CRLF correctly
...
files[filename] = content
The part.strip() is both redundant (the framing CRLF is already removed by
if content.endswith(b"\r\n")) and destructive (it eats real trailing bytes of the payload).
(Note: the part = part.strip() calls at lines 2617 and 2695 are inside
for part in content_type.split(";") — those parse the Content‑Type header text and are fine.)
Reproduction
Flash any app image whose last byte is 0x09/0a/0b/0c/0d/20. Clean A/B on one ESP32, same
board/portal, only the app's trailing byte differs:
| App image |
last byte |
via /api/flash |
original app.bin |
0x20 |
❌ boot‑loops, Image hash failed |
app.bin + one trailing 0xFF (ignored by the ROM, after the SHA‑256) |
0xFF |
✅ boots |
Direct esptool on the Pi against the same /dev/ttyUSB0 (using the original file) flashes and
boots fine at any baud, stub or --no-stub — confirming the corruption is introduced by the
parser, not esptool/serial.
Fix
Strip only the leading CRLF framing; never .strip() the part body. The existing
content.endswith(b"\r\n") check already removes the trailing framing CRLF.
for part in parts_raw:
- part = part.strip()
- if not part or part == b"--":
+ # strip only the leading CRLF framing; do NOT .strip() the part,
+ # which silently drops trailing whitespace bytes (09 0a 0b 0c 0d 20)
+ # from binary uploads -> truncated image -> bad SHA-256 -> bricked flash
+ if part.startswith(b"\r\n"):
+ part = part[2:]
+ elif part.startswith(b"\n"):
+ part = part[1:]
+ if not part or part in (b"--", b"--\r\n", b"--\n"):
continue
Apply the same change at both line 2715 and line 2634.
(A more robust alternative would be to replace the hand‑rolled parser with
email.parser/multipart, but the one‑line‑equivalent fix above is sufficient and minimal.)
Verified
Applied this patch to a running workbench (/usr/local/bin/rfc2217-portal) and re‑tested: the
exact image that previously bricked on every /api/flash (final byte 0x20) now boots, with no
other changes. Environment: Raspberry Pi workbench, esptool v5.2.0, classic ESP32‑D0WD over a
CP2102 (/dev/ttyUSB0).
Bug: multipart parser truncates binary uploads ending in a whitespace byte → bricked flashes (
Image hash failed)Summary
The custom multipart/form-data parser in
pi/portal.pycallspart.strip()on each rawbinary part.
bytes.strip()removes trailing ASCII‑whitespace bytes (0x09 0x0a 0x0b 0x0c 0x0d 0x20), so any uploaded binary whose final byte is one of those is silently truncated. Thetruncated image is flashed faithfully (esptool even reports
Hash of data verified), but thedevice then boot‑loops with:
because the image's appended SHA‑256 no longer matches. It affects roughly 2–3 % of builds at
random (whatever the final byte happens to be) and is independent of chip, board, baud, and
esptool version — which makes it extremely confusing to diagnose (it looks board/chip‑specific).
Affected code
pi/portal.py, in both multipart handlers:/api/flash— line 2715/api/firmware/upload— line 2634The
part.strip()is both redundant (the framing CRLF is already removed byif content.endswith(b"\r\n")) and destructive (it eats real trailing bytes of the payload).(Note: the
part = part.strip()calls at lines 2617 and 2695 are insidefor part in content_type.split(";")— those parse the Content‑Type header text and are fine.)Reproduction
Flash any app image whose last byte is
0x09/0a/0b/0c/0d/20. Clean A/B on one ESP32, sameboard/portal, only the app's trailing byte differs:
/api/flashapp.bin0x20Image hash failedapp.bin+ one trailing0xFF(ignored by the ROM, after the SHA‑256)0xFFDirect esptool on the Pi against the same
/dev/ttyUSB0(using the original file) flashes andboots fine at any baud, stub or
--no-stub— confirming the corruption is introduced by theparser, not esptool/serial.
Fix
Strip only the leading CRLF framing; never
.strip()the part body. The existingcontent.endswith(b"\r\n")check already removes the trailing framing CRLF.Apply the same change at both line 2715 and line 2634.
(A more robust alternative would be to replace the hand‑rolled parser with
email.parser/multipart, but the one‑line‑equivalent fix above is sufficient and minimal.)Verified
Applied this patch to a running workbench (
/usr/local/bin/rfc2217-portal) and re‑tested: theexact image that previously bricked on every
/api/flash(final byte0x20) now boots, with noother changes. Environment: Raspberry Pi workbench, esptool v5.2.0, classic ESP32‑D0WD over a
CP2102 (
/dev/ttyUSB0).