Skip to content

fix(desktop): sniff attachment types from content - #4442

Merged
Astro-Han merged 4 commits into
apache:mainfrom
liuxiaocs7:fix/attachment-mime-sniffing
Sep 1, 2026
Merged

fix(desktop): sniff attachment types from content#4442
Astro-Han merged 4 commits into
apache:mainfrom
liuxiaocs7:fix/attachment-mime-sniffing

Conversation

@liuxiaocs7

@liuxiaocs7 liuxiaocs7 commented Sep 1, 2026

Copy link
Copy Markdown
Member

Summary

Sniff attachment magic bytes before deciding whether content is an image, PDF, or ordinary file. The detected content type now takes precedence over file extensions and renderer-supplied MIME metadata.

This prevents PDFs or unknown files renamed as images from reaching image resizing and preview decoders, preserves correct handling for real images with misleading names, and updates MIME after image re-encoding. Supported signatures are PNG, JPEG, GIF, WebP, and PDF. A PDF header behind a short preamble is found within a bounded window, so it is cleanly refused rather than decoded as text.

The content type is also resolved at pick/drop time, not just on send, so the composer stages each attachment under its true kind. A real image named report.pdf now renders its thumbnail and triggers the vision-capability notice, and a disguised file does neither — the earlier content-sniffing preview was previously unreachable because staging still keyed off the extension.

The magic-byte signatures live in one place: @maka/core's sniffAttachmentMimeType, shared by the send/ingest path, the runtime image reader, and the artifact-store binary reader (storage keeps only its own SVG text-scan). BMP is intentionally not sniffed: no downstream reader (the send path or Read) accepts it, and its 2-byte BM marker matches arbitrary files, so routing those bytes to an image decoder only produced unsupported_mime later.

Fixes #4441

Verification

  • Builds: @maka/core, @maka/runtime, @maka/storage, npm --workspace @maka/desktop run build:main
  • Typecheck: @maka/core, @maka/runtime, @maka/storage, and the desktop main / preload / renderer projects (the storybook project has a pre-existing @maka/ui × @astryxdesign/core prop drift unrelated to this change)
  • Tests: the attachment suites across core, runtime, storage, and desktop pass, including new coverage for the drop/attach draft race (fails without the fix), the pick-dialog owner, content-based staging, and the read-failure downgrade
  • npx biome check clean on the changed files
  • git diff --check origin/main...HEAD

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: OpenAI Codex diagnosed the attachment routing failure, implemented MIME sniffing and conflict handling, added regression tests, and prepared this issue and pull request. A follow-up review pass removed BMP (unsupported downstream), shared the content-first MIME resolver across the ingest and pick/drop paths, and made the content-detected kind reach the composer so the preview/notice fix is exercised end to end.

A second, adversarial review pass (Claude Code) then addressed reviewer feedback: fixed the pick/drop draft-ownership race exposed by the now-async sniff, stopped both prefix-read catches from failing open, taught the PDF sniff to find a header behind a preamble (bounded), folded the third magic-byte table into the shared core sniffer, and added the missing coverage for the pick-dialog owner and the read-failure path.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@github-actions github-actions Bot added the effort/M Under 500 readable lines label Sep 1, 2026

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — reviewed d75b242f. Approving; one P3 below, not blocking.

I went looking for a parser differential, since that is how content-sniffing fixes usually leak: the guard and the router disagree about what counts as a PDF, and a crafted suppliedMimeType slips between them. It isn't there. The downgrade guard (attachments.ts:171) and the routing (:228) use the same comparison shape — startsWith('image/') for images, exact equality for PDF — so "application/pdf " or "application/pdf;x" fails both and reaches neither path. Case differences only make the guard wider than the router, which errs safe. Base64 is not involved; the renderer-supplied value crosses IPC into resolveAttachmentMimeType and is treated as a claim throughout.

The rest of the design is the right shape: content beats extension beats claimed MIME, a claimed image/PDF with unverifiable bytes is downgraded to application/octet-stream rather than trusted, sniffing is capped at a fixed 16-byte prefix so it cannot become another unbounded read, and pick-time resolution reads only that prefix. The WebP check has RIFF at 0 and WEBP at 8, which is correct and easy to get wrong. Not sniffing BMP is justified in the description rather than just omitted.

P3 — %PDF- is required at offset 0. The PDF spec permits leading bytes before the header, and readers conventionally scan the first 1024 bytes for it, so a legitimate PDF with a preamble now sniffs as nothing, has its application/pdf claim downgraded, and is staged as an ordinary file. It fails closed, which is the right direction, but it is a behaviour regression against main, where the extension decided. Worth either widening the PDF search within the prefix you already read, or noting the limit next to the signature so the next person doesn't read %PDF- as spec-complete.

Evidence boundary: I read the sniffing and resolution functions, the main-process ingest path, and the downstream image/PDF routing checks on this head. I did not run the suites or exercise the composer.


AI-assisted review: drafted with Maka; I verified the guard/router comparison shapes, the IPC provenance of the supplied MIME, and the WebP offsets against the branch source myself.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up on my approval — I took a second, deliberately adversarial pass over d75b242f and found more than the one P3 I filed. My approval predates this, so please treat the P2 as something to land before merge. Findings are inline.

Two things I suspected and could not substantiate, recorded so nobody re-runs them. Dropping bmp from MIME_BY_EXTENSION is not a regression: ImageMimeType never included it, and on main a resized BMP reached the provider as image/bmp carrying PNG bytes, so neither the small nor the large case worked. And reading before the kind check in loadApprovalPreview adds no amplification a large approved image did not already allow.

Coverage gap worth closing alongside the P2: attachFilePaths and the attachments:pickFiles IPC handler are the actual owners of this PR's headline claim — stage by content, so report.pdf gets a thumbnail and the vision notice — and both are uncovered; the three sniffPickedAttachmentMimeType cases exercise the helper directly. That is why the race below had nothing to catch it.

Evidence boundary: I read the eight changed files on this head plus the attachment paths through ai-sdk-backend, artifact-store, artifact-attachments, image-file, and the composer's drop/paste wiring, and grepped the new symbols across the tree. I did not run the suites, the E2E, or the desktop app; the Chromium File.type and nativeImage decode behaviour I reasoned about is from platform docs, not a device.


AI-assisted review: drafted with Maka; I verified the pickAttachments precedent, the three sniffers' byte-level equivalence, the SVG divergence, and the PDF consumption path against the branch source myself.

Comment thread apps/desktop/src/renderer/use-composer-attachments.ts
Comment thread apps/desktop/src/main/attachment-ingest.ts Outdated
Comment thread apps/desktop/src/renderer/use-composer-attachments.ts Outdated
Comment thread packages/core/src/attachments.ts
Comment thread packages/core/src/attachments.ts Outdated
@liuxiaocs7
liuxiaocs7 force-pushed the fix/attachment-mime-sniffing branch from d75b242 to 187d2c2 Compare September 1, 2026 14:07
@liuxiaocs7

Copy link
Copy Markdown
Member Author

Thanks for the second, adversarial pass — that's a much better PR for it. Pushed 187d2c21, rebased onto the current main. Every finding is addressed, with replies inline; summary here:

  • P2 (draft bound before the await)attachFilePaths now binds the owner after the sniff reads resolve and reads liveOptionsRef.current.draftKey, matching pickAttachments.
  • P3 ×2 (catches failing open) — both prefix-read catches route an empty prefix through resolveAttachmentMimeType instead of reinstating the name/renderer image-PDF claim.
  • P3 (%PDF- at offset 0) — the header is now searched across a bounded 1024-byte window; images stay offset-0. A preamble PDF resolves to pdf on the send path, so it's cleanly refused rather than decoded as text.
  • P3 (third magic-byte table)sniffImageMime and sniffAllowedBinaryMime now call core's sniffAttachmentMimeType (SVG stays local to storage); net −15 lines in each.

Coverage gap you flagged: attachFilePaths is now covered by the drop/attach race test (it fails without the P2 fix) and the content-staging test (report.pdf image vs disguised .png). The attachments:pickFiles IPC handler is a thin wrapper over sniffPickedAttachmentMimeType, whose downgrade-on-read-failure path is now covered directly.

The two items you recorded as suspected-but-unsubstantiated (dropping bmp, the read-before-kind-check in loadApprovalPreview) — I agree, left as-is.

Verification: core / runtime / storage / desktop-main build; typecheck clean for core, runtime, storage, and the desktop main/preload/renderer projects; the attachment suites across core, runtime, storage, and desktop pass; biome clean on the changed files.

Address review feedback on attachment content sniffing:

- Remove BMP from the sniffer, its MIME union, and the extension map. No
  downstream reader (the send path's sniffAllowedBinaryMime or Read's
  sniffImageMime) accepts BMP, so a sniffed image/bmp only surfaced later as
  unsupported_mime; the 2-byte BM marker also matched arbitrary files.

- Share one content-first resolver: move resolveAttachmentMimeType into
  @maka/core/attachments so ingest, the picker, and drag/drop apply the same
  precedence (sniffed bytes win; an unverified image/PDF claim downgrades to
  octet-stream) instead of drifting copies.

- Resolve the content type at pick/drop time (main sniffs a short prefix of
  each picked path; the renderer sniffs dropped/pasted blobs) so the composer
  stages each attachment under its true kind. A real image named report.pdf
  now previews and fires the vision notice; previously staging keyed off the
  extension and the new content-sniffing preview was unreachable.

Adds coverage for the shared resolver and the pick-time content decision.

Generated-by: Claude Code
Second review pass on the content-first attachment sniffing:

- P2: bind the dropped/pasted file's owner AFTER the leading-byte sniff
  resolves, not before. fileToPending became async in this PR, so the
  zero-width window before it turned into real disk I/O (seconds on a network
  volume or spun-down drive); switching sessions during it appended files to
  the previous draft — invisible in the composer on screen yet still sendable.
  Now mirrors pickAttachments and reads liveOptionsRef at resolve time.

- P3: stop the two prefix-read catches from failing open. A failed read no
  longer reinstates the name's or renderer's unverified image/PDF claim (the
  very claim resolveAttachmentMimeType exists to reject); both route an empty
  prefix through the same downgrade, so staging stays unblocked with one owner
  for the policy and the send path still re-reads.

- P3: sniff a PDF header behind a preamble. PDF permits bytes before %PDF- and
  readers scan the first ~1 KiB; matching that (bounded, images stay at their
  fixed offset) keeps a preamble PDF from being misrouted to `other` and then
  decoded as UTF-8 text downstream instead of cleanly refused.

- P3: fold the third magic-byte table into core. runtime's sniffImageMime and
  storage's sniffAllowedBinaryMime now call sniffAttachmentMimeType (SVG stays
  local to storage), removing byte-for-byte duplicates that had already drifted.

Adds the missing coverage the review flagged: the drop/attach race (fails
without the P2 fix), content-based staging of a real image named .pdf vs a
disguised .png, the read-failure downgrade, and the PDF preamble.

Generated-by: Claude Code
@liuxiaocs7
liuxiaocs7 force-pushed the fix/attachment-mime-sniffing branch from 187d2c2 to 0a65b31 Compare September 1, 2026 14:31
Follow-ups from the review round:

- Cover the actual pick-dialog owner. Extract resolvePickedAttachments from
  the attachments:pickFiles IPC handler (mirroring loadApprovalPreview /
  registerAttachmentPreviewIpc) so the by-content staging — a real image named
  report.pdf staged as an image, a disguised .png as an ordinary file, sizes
  from the injected main-side stat — is tested without a native dialog.

- Cover the sniffFileMimeType catch for real. The prior test read a disguised
  file successfully (the success-path downgrade); add one whose
  slice().arrayBuffer() rejects, proving a failed read downgrades the declared
  image claim to octet-stream and fires no vision notice.

- Fix a stale doc on resolveAttachmentMimeType: it still said the sniff only
  inspects the 16-byte prefix, but a PDF header behind a preamble is now
  searched across the first PDF_HEADER_SCAN_BYTES.

Generated-by: Claude Code
@liuxiaocs7

Copy link
Copy Markdown
Member Author

Follow-up in 3f2503ab, closing the two gaps from this pass:

  • Pick-dialog owner now covered. Extracted resolvePickedAttachments from the attachments:pickFiles handler (mirroring the loadApprovalPreview / registerAttachmentPreviewIpc split) and tested that it stages by content: report.pdf carrying image bytes → image, a disguised .png → ordinary file, sizes from the injected main-side stat. The IPC handler is now a thin call to it — so my earlier "every finding is addressed" is accurate rather than aspirational.
  • Real read-failure test for sniffFileMimeType's catch (see the renderer thread), plus a stale-doc fix: resolveAttachmentMimeType no longer claims the sniff only inspects the 16-byte prefix, since the PDF header is now searched across a bounded 1024-byte window.

I also corrected my SVG wording inline (the duplicate signature tables are gone; storage's SVG branch is a deliberate, retained difference), and the PR description now credits this review pass.

Note on CI: the earlier Windows package failure was an unrelated qualify-released-cli-state-root test that hardcoded a POSIX path — fixed on main in #4461. My first rebase landed one commit short of it; re-rebased onto the current main, so it's in the base now.

@Astro-Han
Astro-Han merged commit 2e37fea into apache:main Sep 1, 2026
3 checks passed
abhinav-phi pushed a commit to abhinav-phi/maka that referenced this pull request Sep 1, 2026
Attachments were routed by filename extension and renderer-supplied MIME, so a PDF renamed `photo.png` reached the image resizer and preview decoder, and a real image named `report.pdf` rendered no thumbnail and raised no vision-capability notice. Sniff the leading bytes instead and let the detected type win over both extension and metadata. PNG, JPEG, GIF, WebP and PDF are recognised; a PDF header behind a short preamble is found within a bounded window so it is refused rather than decoded as text.

The signatures live in one place. `@maka/core`'s `sniffAttachmentMimeType` is now the only magic-byte table, replacing the copies that had grown in `packages/runtime/src/image-file.ts` and `packages/storage/src/artifact-store.ts` — which had already drifted, since storage counted SVG as an image and core did not. Storage keeps only its own SVG text scan, which is not a magic-byte question. That consolidation is a net deletion in both files.

BMP is deliberately not sniffed: no downstream reader accepts it, and its 2-byte `BM` marker matches arbitrary files, so routing those bytes to an image decoder only produced `unsupported_mime` further along.

Resolution moved to pick/drop time rather than send time, which made the ownership of an in-flight staging batch load-bearing: `fileToPending` became async to read each file's leading bytes, and on a network volume or a spun-down drive that I/O takes seconds. Both staging paths now bind the draft owner after the reads resolve, from the live ref rather than the render-time value, so files land in the composer the user is looking at instead of one they have since left — where they would be invisible but still sendable.

No migration and no protocol change: sniffing happens before anything is persisted, and the stored shape is unchanged.

Fixes apache#4441

Generated-by: Claude Code
Generated-by: OpenAI Codex
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/M Under 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(desktop): attachment MIME conflicts can route non-images as images

2 participants