buzz upload file never strips image metadata — agents cannot publish screenshots or charts (422)
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## Summary
`buzz upload file` never strips image metadata, so the CLI cannot upload any image a real producer emits. Every macOS screenshot and every matplotlib figure fails with:
```
422 {"error":"media contains metadata or a non-canonical metadata channel"}
```
Practically: **agents and scripts can read images but cannot publish them** — no charts, no plots, no diagrams, no screenshots. Desktop is unaffected; it strips metadata before hashing.
## Reproduction
Isolating metadata as the only variable — same pixels, one chunk added:
| Payload | Result |
|---|---|
| PNG, `IHDR`+`IDAT`+`IEND` only | uploads |
| same + `pHYs` | 422 |
| same + `iCCP` | 422 |
Real producers:
```bash
python -c "import matplotlib;matplotlib.use('Agg');import matplotlib.pyplot as plt;f,a=plt.subplots();a.plot([0,1],[0,1]);f.savefig('c.png')"
buzz upload file --file c.png
# -> 422 media contains metadata or a non-canonical metadata channel
```
matplotlib writes `tEXt` (`Software: Matplotlib version…`) **and** `pHYs` by default. PIL with `dpi=` writes `pHYs`. macOS screenshots carry `iCCP` + `eXIf` + `iTXt` — verified across 7 real screenshots, all rejected raw.
A plain `Image.save()` with no ancillary chunks uploads fine, which confirms metadata is the whole cause.
## Cause
`crates/buzz-media/src/validation.rs::validate_png_metadata_free` rejects these chunks. **The policy is correct** — metadata is a covert channel and a privacy leak; no complaint about it.
The bug is that only one client implements the other half. `sanitize_image_for_upload` lives in `desktop/src-tauri/src/commands/media.rs`, inside the Tauri crate, so nothing else can call it. `crates/buzz-cli/src/client.rs::upload_file` posts raw bytes.
The relay cannot fix this server-side: Blossom is content-addressed, so the kind-24242 auth event commits to the SHA-256 of the bytes actually sent. Stripping has to happen client-side, before the hash.
## Proposed approach
Extract the sanitizer and the metadata validator into a small leaf crate shared by the relay, the CLI, and Desktop. They are two halves of one contract — every client must produce what the relay accepts — and they drifted precisely because they lived in different crates with no shared test. A round-trip test (`sanitize(x)` must satisfy `validate_content(…)`) pins them together.
A leaf crate rather than putting it in `buzz-media`: `buzz-cli` does not depend on `buzz-media` today, and `buzz-media` pulls `axum` and `rust-s3`. Hoisting there would link a web framework and a storage client into the shipped CLI binary.
Happy to take direction here — if maintainers would rather this live somewhere else, or be solved without a new crate, that is much cheaper to hear now.
## Related
**#5415** is the same shape in a different client: mobile's own video transcode produces MP4s the relay rejects, same 422. It is a **sibling, not a duplicate** — Dart, and MP4 validated by `validate_mp4_metadata_free`, so a Rust crate cannot help it. Worth noting its repro uses `buzz upload file` as the reference tool for "does the relay accept this," while that tool was itself broken for every image.
#5414 (mobile hardcodes media caps, relay does not advertise its limits) is adjacent.
Three clients now re-derive one relay policy in two languages, none able to share code with the others. The durable answer is probably the relay publishing its media policy so clients read it instead of guessing — out of scope here, but it is the pattern these three issues have in common.
## Environment
macOS 26.5 (arm64), relay `communities.buzz.xyz`, `buzz` CLI bundled in Buzz.app 0.5.8.
Contributor guide
Assessment
This issue has not been assessed yet.