Skip to content

Spec: image preview in the Code Editor file pane (GH9729)#10024

Open
NorfeldtKnowit wants to merge 10 commits intowarpdotdev:masterfrom
NorfeldtKnowit:spec/GH9729-image-preview
Open

Spec: image preview in the Code Editor file pane (GH9729)#10024
NorfeldtKnowit wants to merge 10 commits intowarpdotdev:masterfrom
NorfeldtKnowit:spec/GH9729-image-preview

Conversation

@NorfeldtKnowit
Copy link
Copy Markdown

Description

Spec PR for #9729 (Image preview when clicking image files in the File Tree), which is labeled ready-to-spec. Per the maintainer's guidance in the issue thread (@peicodes: "we think this should be implemented using the Lightbox component, which would open up a preview overlay over your pane with the image"), the spec routes File Tree image clicks through the existing Lightbox overlay rather than introducing a new tab variant.

This PR adds:

  • specs/GH9729/product.md: product spec covering opening, navigation, dismiss/focus restoration, error handling, decoder limits, accessibility, multi-window behavior, and edge cases (hidden files, identity click, file-system mutation during preview).
  • specs/GH9729/tech.md: tech spec grounded in real file:line references, structured per the house convention (Context, Relevant code, Proposed changes, End-to-end flow, Risks and mitigations, Testing and validation, Follow-ups).

Scope of the proposal

  1. New FileTarget::ImagePreview variant, plus a step-0 short-circuit in resolve_file_target_with_editor_choice (app/src/util/openable_file_type.rs) so PNG/JPEG/JPEG/GIF/WebP/SVG are captured ahead of both the markdown/code probe (so SVG no longer opens as raw XML) and the binary fall-through to SystemGeneric (so raster formats no longer hand off to the OS).
  2. A new Workspace::open_file_with_target arm that scans sibling images via a new list_sibling_images_natural_sorted helper (capped at MAX_SIBLING_IMAGES = 1024, hidden-file filter mirrors the clicked file), builds a Vec<LightboxImage> of Resolved { LocalFile { path } } entries, and dispatches WorkspaceAction::OpenLightbox. The existing handler at app/src/workspace/view.rs:21710-21737 already handles "reuse the open LightboxView via update_params," so no handler change is needed.
  3. Decoder safety in crates/warpui_core/src/image_cache.rs: introduce image::Limits (max dimension, max alloc) plus an explicit MAX_DECODED_PIXELS cap and an MAX_SVG_BYTES input cap, so a malformed or maliciously oversize image cannot OOM the process via into_rgba8() or animated-frame collection.
  4. New LightboxImageSource::Error { message } variant in crates/ui_components/src/lightbox.rs so per-image read/decode failures render the filename inline rather than spinning forever (the artifacts call site at app/src/ai/artifacts/mod.rs:362-365 works around this today by stuffing "Failed to load" into the description; that workaround drops once the variant exists).

What this spec deliberately does not propose

These are enumerated as Follow-ups in tech.md:

  • Zoom (Cmd+=/-/0), trackpad pinch-zoom, click-drag pan.
  • Status footer (filename, dimensions, file size, format string).
  • Animation play/pause control.
  • EXIF orientation rotation and ICC color-profile honoring.
  • Visible thumbnail strip (the sibling list exists internally for arrow navigation; no UI strip is rendered in v1).
  • Format support beyond PNG/JPEG/GIF/WebP/SVG (HEIC, HEIF, AVIF, BMP, TIFF, ICO).
  • Magic-byte content sniffing when extension and content disagree.
  • Right-click context menu and drag-out to Agent input.
  • Disk-backed thumbnail cache.
  • Image diff across git revisions, remote URL preview, RAW formats, slideshow mode.
  • Terminal-grid inline images (already tracked in Support displaying inline images #1693, Image protocols #5286).

Adjacent issues

Linked Issue

Closes #9729 (spec-only; implementation will follow on a subsequent PR per the house spec-driven flow).

  • The linked issue is labeled ready-to-spec or ready-to-implement.
  • Where appropriate, screenshots or a short video of the implementation are included below (especially for user-visible or UI changes).

Spec-only PR; no implementation yet, so no screenshots. Visual verification is enumerated in tech.md under Manual validation and will be attached on the implementation PR.

Screenshots / Videos

N/A; spec-only PR.

Testing

No code changes in this PR. The tech spec's Testing and validation section enumerates:

  • Unit tests in app/src/util/openable_file_type.rs for the new resolver branch (each supported extension; precedence over markdown; non-image binaries still SystemGeneric), the natural-sort helper, the hidden-file filter, and the MAX_SIBLING_IMAGES cap.
  • Unit tests in crates/warpui_core/src/image_cache.rs for the decoder-limit guard (huge dimensions rejected, normal photos accepted, garbage returns Unrecognized, oversize SVG rejected).
  • Manual validation against fixtures including a 10000×10000 PNG (decoder cap), corrupt PNG (per-entry error), animated GIF/WebP, SVG, broken symlink, large directory smoke test, and split-pane / multi-window scrim coverage.
  • Standard cargo fmt --check, cargo clippy --workspace --all-targets --tests -- -D warnings, and cargo nextest run --no-fail-fast --workspace --exclude command-signatures-v2.

Agent Mode

  • Warp Agent Mode; This PR was created via Warp's AI Agent Mode

Adds product and tech specs under specs/GH9729/ for an in-Warp
image preview surface launched from the File Tree, reusing the
existing Lightbox overlay component per maintainer guidance in
the issue thread.

Co-Authored-By: Warp <[email protected]>
@cla-bot
Copy link
Copy Markdown

cla-bot Bot commented May 4, 2026

Thank you for your pull request and welcome to our community. We require contributors to sign our Contributor License Agreement, and we don't seem to have the users @NorfeldtKnowit on file. In order for us to review and merge your code, each contributor must visit https://cla.warp.dev to read and agree to our CLA. Once you have done so, please comment @cla-bot check to trigger another check.

@github-actions github-actions Bot added the external-contributor Indicates that a PR has been opened by someone outside the Warp team. label May 4, 2026
@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 4, 2026

@NorfeldtKnowit

I'm starting a first review of this spec-only pull request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

@NorfeldtKnowit
Copy link
Copy Markdown
Author

@cla-bot check

@cla-bot cla-bot Bot added the cla-signed label May 4, 2026
@cla-bot
Copy link
Copy Markdown

cla-bot Bot commented May 4, 2026

The cla-bot has been summoned, and re-checked this pull request!

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

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

Overview

This spec PR defines a Lightbox-based image preview flow for the Code Editor file pane, including product behavior, target resolution changes, sibling navigation, decoder limits, error rendering, and validation coverage.

Concerns

  • The sibling-list cap algorithm can drop the clicked image after sorting, contradicting the required behavior and the proposed tests.
  • The re-open/replace behavior assumes users can click files behind a Lightbox even though the same spec says the scrim blocks underlying input.

Security

  • Eagerly loading and decoding up to 1024 sibling images on open leaves a local-project denial-of-service path despite per-image decode caps; the spec should require lazy loading or explicit decode/read concurrency limits.

Verdict

Found: 1 critical, 2 important, 0 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Comment thread specs/GH9729/product.md Outdated
Comment thread specs/GH9729/tech.md Outdated
siblings.push(path.to_path_buf());
}
siblings.sort_by(|a, b| natural_cmp(file_name_lossy(a), file_name_lossy(b)));
siblings.truncate(MAX_SIBLING_IMAGES);
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.

🚨 [CRITICAL] Truncating after sorting can drop the clicked image when its natural-sort position is beyond the first 1024 entries, so the Lightbox may open on the wrong file; specify and implement the partition/window-around-clicked algorithm before truncation.

Comment thread specs/GH9729/tech.md Outdated
- tech.md: rewrite list_sibling_images_natural_sorted to window
  around the clicked image before truncation so the clicked image
  is never dropped in directories larger than MAX_SIBLING_IMAGES.
- tech.md: bound parallel sibling decodes to current ± PRELOAD_RADIUS
  by building only the preload window as Resolved and the rest as
  Loading; lazy-promote on Left/Right via UpdateLightboxImage.
  Closes the local-project DoS surface where 1024 parallel decodes
  could allocate ~262 GB transiently before cache eviction reacted.
- product.md: replace the unreachable "replace overlay in place"
  flow on file-tree clicks with the actual dismiss-then-cold-open
  behavior driven by LightboxViewEvent::FocusLost. The handler-reuse
  path stays in tech.md but is correctly scoped to non-focus-stealing
  dispatch sites (artifacts/screenshots), not file-tree.
- tech.md: update Risks and unit-test bullets to match.

Co-Authored-By: Warp <[email protected]>
@NorfeldtKnowit
Copy link
Copy Markdown
Author

Thanks @oz-for-oss. Pushed faa4860 addressing all three findings:

🚨 CRITICAL — tech.md sibling-list windowing. Rewrote list_sibling_images_natural_sorted to window around the clicked image (up to 512 entries before, 512 after, clamped at the directory's first/last entries) before any truncation. The clicked image is now guaranteed to be inside the kept slice for any directory size. Updated the unit-test list to cover left-edge, right-edge, and centered cases against a 2000-image fixture; dropped the misleading prose paragraph in the Notes section that described the algorithm without implementing it.

⚠️ IMPORTANT — product.md replace-in-place flow. The "click another image to replace overlay in place" behavior was internally inconsistent with the scrim-blocks-input invariant. Rewrote the section as "Opening a second image while one is already open": the file-tree click steals focus, fires LightboxViewEvent::FocusLost, the workspace handler tears down the open Lightbox, and the second click goes through cold open. The update_params reuse path in the OpenLightbox handler still exists and is preserved for non-focus-stealing dispatch sites (artifact / screenshot Lightboxes from agent block panes), but the file-tree path no longer relies on it. Manual-validation step renamed and reworded accordingly.

⚠️ IMPORTANT [SECURITY] — tech.md eager 1024-sibling decodes. Restructured change 3 to build only current ± PRELOAD_RADIUS (default PRELOAD_RADIUS = 2, so 5 entries) as LightboxImageSource::Resolved; the rest are LightboxImageSource::Loading. LightboxView::start_asset_loads only loads Resolved entries, so on initial open at most 5 parallel reads/decodes are queued regardless of directory size. On Left/Right, a small navigation hook in LightboxView::handle_action walks the new ±PRELOAD_RADIUS window and dispatches UpdateLightboxImage to promote each newly-visible neighbour from Loading to Resolved, which routes through the existing update_image_atstart_asset_load path. Peak transient allocation is now bounded at ~5 * MAX_DECODED_PIXELS * 4 bytes ≈ 1.2 GB (combined with change 4's per-decode cap), versus the pre-fix ~262 GB worst case. The Risks section is updated to call this out as the chosen mitigation rather than relying on cache eviction reacting to allocation pressure.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 4, 2026

@NorfeldtKnowit

I'm re-reviewing this spec-only pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

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

Overview

This spec proposes routing File Tree image clicks through the existing Lightbox overlay with sibling navigation, decoder limits, and per-entry error states. The direction is aligned with the issue, but several parts of the product and tech specs are internally inconsistent or underspecified enough that implementation could miss stated success criteria.

Concerns

  • Animated GIF/WebP playback is promised, but the proposed Lightbox rendering path does not specify the animation start-time change needed for repainting.
  • Lazy preloading requires a concrete path-bearing data model; reconstructing local paths from description is not reliable with the current Lightbox parameters.
  • Error handling only covers AssetState::FailedToLoad, leaving ImageType::Unrecognized files in a perpetual loading state despite the spec's error-state requirement.
  • The end-to-end flow still describes eager resolution/loading of every sibling, contradicting the bounded-preload mitigation.

Security

  • Animated GIF/WebP limits must be enforced while iterating frames, not after collect_frames(), or malicious animated files can allocate all frames before the cap is checked.

Verdict

Found: 0 critical, 5 important, 0 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Comment thread specs/GH9729/product.md Outdated
Comment thread specs/GH9729/tech.md Outdated
Comment thread specs/GH9729/tech.md Outdated
Comment thread specs/GH9729/tech.md Outdated
Comment thread specs/GH9729/tech.md Outdated
- product.md: scope animated GIF/WebP to first-frame static preview
  for v1 (continuous playback requires
  Image::enable_animation_with_start_time wiring + per-frame redraw,
  pulled out as a follow-up).
- tech.md change 3: extend LightboxImage with path: Option<PathBuf>
  so lazy promotion on Left/Right has a concrete data model;
  existing call sites (artifacts/screenshots/agent blocks) pass None.
- tech.md change 4: replace decoder.into_frames().collect_frames()
  with a streaming collect_animated_frames_with_limits iterator that
  bails on running pixel/dimension cap before allocating the next
  frame, preventing animated-bomb files from materializing every
  frame before the cap fires.
- tech.md change 4: convert Ok(ImageType::Unrecognized) to Err so
  unrecognized-bytes-but-supported-extension files surface as
  per-entry Error in the Lightbox instead of spinning forever as
  Loaded-with-no-size.
- tech.md end-to-end flow: rewrite step 6 to describe only the
  preload window being resolved/queued, with lazy promotion on
  navigation, matching change 3 (was contradicting the bounded
  preload by saying every sibling was resolved/queued).
- tech.md follow-ups: replace "play/pause control" with explicit
  "continuous playback via enable_animation_with_start_time" entry.

Co-Authored-By: Warp <[email protected]>
@NorfeldtKnowit
Copy link
Copy Markdown
Author

Thanks for the second pass @oz-for-oss. Pushed 10e1835 addressing all five findings.

1. Animated GIF/WebP needs enable_animation_with_start_time to repaint (product.md:56). v1 is now scoped to first-frame static preview for animated formats. Continuous playback would require wiring Image::enable_animation_with_start_time(Instant) (crates/warpui_core/src/elements/image.rs:128) into the Lightbox image element plus a per-frame redraw loop, which is enough surface area (animation timing, navigation reset semantics, eventual pause/play interaction) to belong in a follow-up. Files still load successfully and the user sees a static, unambiguous preview. Behavior 9/10, the Success Criteria bullet, and the manual-validation step are all updated; the follow-up entry is now spelled out as "continuous playback via enable_animation_with_start_time" with play/pause as the next layer.

2. Lazy promotion data model is underspecified (tech.md:154). Picked one: extend LightboxImage in crates/ui_components/src/lightbox.rs:38-43 with path: Option<PathBuf>. Every entry on the file-tree path carries Some(p); existing call sites (artifacts, screenshots, agent block paths) pass None and continue working. The navigation hook in LightboxView::handle_action reads image.path directly, so there's no filename-reconstruction or parent-dir-stashing needed; the data model is the source of truth.

3. collect_frames() allocates everything before the cap (tech.md:191). Replaced with a streaming collect_animated_frames_with_limits helper that iterates decoder.into_frames() directly, accumulates width * height per frame, and bail!s as soon as either the per-frame dimension cap or the running total-pixel cap is exceeded. The remaining iterator items are dropped along with the function's stack frame; peak allocation is bounded by MAX_DECODED_PIXELS * 4 bytes ≈ 256 MB plus one in-flight frame, instead of the entire animation. Both the WebP-animated and GIF arms are updated.

4. ImageType::Unrecognized leaves the Lightbox spinning (tech.md:222). try_from_bytes now returns Err for any unknown format (Ok(other) => bail!("unsupported image format: {other:?}") and Err(_) => bail!("could not detect image format from header bytes")) instead of Ok(Unrecognized). The Unrecognized variant is removed entirely (single-call-site cleanup in size_in_bytes). The asset cache then surfaces AssetState::FailedToLoad, which LightboxView maps to the new LightboxImageSource::Error { message } variant from change 5; the user sees a per-entry inline error with the filename. This also catches the extension/content-mismatch case (a .png containing tarball bytes) that was previously silent. New unit-test bullet covers the new Err behavior.

5. End-to-end flow contradicts the bounded preload (tech.md:368). Rewrote step 5 to describe building both Resolved (preload window) and Loading (rest of siblings) entries with path: Some(p) carried on every entry. Rewrote step 6 to say start_asset_loads queues loads only for Resolved entries and noted the update_params reuse path is not exercised on the file-tree path. Rewrote step 8 to describe lazy promotion via UpdateLightboxImage after each Left/Right and to thread the new Unrecognized → Err → AssetState::FailedToLoad → LightboxImageSource::Error failure flow.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 4, 2026

@NorfeldtKnowit

I'm re-reviewing this spec-only pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

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

Overview

This spec routes supported image file clicks from the Code Editor file pane into the existing Lightbox overlay, with sibling navigation, decode limits, error states, and validation coverage.

Concerns

  • The animated GIF/WebP decode plan still materializes every frame even though v1 explicitly renders only the first frame, which leaves avoidable memory and DoS exposure in the implementation plan.
  • The bounded preload plan still permits roughly 1.2 GB of concurrent transient decode allocation from a local directory click; the spec should require serialization, a stricter global memory budget, or lower caps before implementation.
  • The product accessibility requirement is not carried into the technical rendering changes, so the implementation could satisfy the visual behavior while missing the screen-reader label.

Security

  • Local project files are attacker-controlled inputs for many workflows; the remaining animated-frame and parallel-decode memory envelopes should be tightened in the design, not left to implementation judgment.

Verdict

Found: 0 critical, 2 important, 1 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Comment thread specs/GH9729/tech.md Outdated
Ok(ImageFormat::WebP) => {
let decoder = WebPDecoder::new(std::io::Cursor::new(data))?;
if decoder.has_animation() {
let frames = collect_animated_frames_with_limits(decoder.into_frames())?;
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.

⚠️ [IMPORTANT] [SECURITY] v1 says animated GIF/WebP render only the first frame, but this still decodes and stores every frame up to the pixel cap. Specify first-frame-only decoding for v1, or add a separate frame-count/byte cap, so a crafted animation cannot consume hundreds of MB for frames the Lightbox will not animate.

Comment thread specs/GH9729/tech.md Outdated
Comment thread specs/GH9729/tech.md Outdated
}
```

In `Lightbox::render` (`crates/ui_components/src/lightbox.rs`, the per-image render branch around lines 158-176), add an `Error` arm that renders a non-blocking error panel showing the entry's `description` (filename) and the `message`. Do not throw; do not block prev/next.
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.

💡 [SUGGESTION] The product spec requires the active filename to be exposed as the image's accessible label, but the tech spec only describes visual error rendering. Add the concrete Lightbox/Image accessibility change and validation so the a11y requirement is implemented.

- Animated decode wastes memory: collapse WebP/GIF animated arms to
  decode-first-frame-only since v1 doesn't render animations. The
  AnimatedBitmap variant becomes unconstructed; left in enum with a
  comment referencing the continuous-playback follow-up.
- Tighten preload envelope: PRELOAD_RADIUS 2->1, MAX_DECODED_PIXELS
  64M->32M. Worst-case transient decode allocation drops from
  ~1.2 GB to ~366 MB per file-tree click. Risks math, validation
  fixture sizes, lazy-preload validation step all updated to match.
- A11y label is now wired in tech: change 5 specifies the rendered
  Image gets accessible_label = description.unwrap_or("image"),
  with parallel labels on Loading and Error variants. New manual
  validation step covers VoiceOver announcement.

Co-Authored-By: Warp <[email protected]>
@NorfeldtKnowit
Copy link
Copy Markdown
Author

Pushed 2aff764 addressing all three notes from the third pass.

1. Animated decode still materializes every frame up to the cap (tech.md:255). Real bug; the previous round scoped product.md to first-frame-only display but left the decoder in change 4 happily collecting up to MAX_DECODED_PIXELS worth of frames per animated input. Collapsed both the WebP-animated and GIF-animated arms to first-frame-only decode, returning StaticBitmap directly:

  • WebP: always go through decode_with_limits(data, image::ImageFormat::WebP) (same path as static WebP). The WebPDecoder yields the first frame whether or not the file is animated.
  • GIF: read dimensions, cap-check, then DynamicImage::from_decoder(decoder).into_rgba8() for one frame; never call into_frames().collect_frames() or iterate frames at all.

ImageType::AnimatedBitmap is unconstructed in v1 (no in-tree caller actually drives animation today, since Image::enable_animation_with_start_time is not wired anywhere consuming it); the variant stays in the enum for the continuous-playback follow-up. The collect_animated_frames_with_limits helper from the previous round is dropped from the spec since it is no longer needed. Per-decode envelope is now MAX_DECODED_PIXELS * 4 bytes regardless of input being animated or static.

2. 1.2 GB transient is too generous (tech.md:177). Tightened both knobs:

  • PRELOAD_RADIUS = 2 → 1 (window of 2 * 1 + 1 = 3 entries: prev, current, next).
  • MAX_DECODED_PIXELS = 64M → 32M (~122 MB RGBA per decode).

Worst-case transient envelope is now ~3 * 122 MB ≈ 366 MB per file-tree click, down from ~1.2 GB. For typical photo directories (4000×3000 = 12M pixels, well under cap) actual allocation is a small fraction of that. Risks-section math is updated; reference "below cap" fixture for manual validation moves from 7000×7000 to 5000×5000 (25M pixels, just under the new cap); the rejection-path fixture stays at 10000×10000. Tighter envelopes (radius 0, smaller cap, or serializing decodes via a semaphore) are enumerated as a follow-up if real-world telemetry shows 366 MB is still too generous, but I want to leave one neighbour preloaded by default so Right-arrow is responsive in normal use.

3. A11y label requirement not implemented in tech (tech.md:311). Added a sub-section under change 5 spelling out the implementation:

  • Lightbox::render per-image render branch sets the rendered Image element's accessible label to current_image.description.as_deref().unwrap_or("image").
  • Loading entries get an accessible label of "loading {description}".
  • Error entries get an accessible label of "image preview failed: {description}: {message}".
  • Implementer should verify the GPUI element accessor name against crates/warpui_core/src/elements/image.rs and use the same accessor that other in-tree labeled elements (e.g. lightbox.rs:128-143 close button) use; the spec mandates the what not the exact API name.

Added a manual validation step: enable VoiceOver, click screenshot.png, confirm the announcement includes the filename instead of a generic "image" label.

This is the third /oz-review so the auto-retrigger budget is exhausted. Leaving the PR up for human review at this point; happy to iterate on anything reviewers raise.

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 4, 2026

@NorfeldtKnowit

I'm re-reviewing this spec-only pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

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

Overview

This spec defines a focused Lightbox-based image preview flow for File Tree image clicks and covers many product and implementation concerns. Several parts still conflict with the existing Lightbox input model, AssetCache/image decode behavior, and telemetry mapping.

Concerns

  • The spec requires underlying panes to be inert while also requiring a click on the File Tree to open another file while the Lightbox is active.
  • The responsiveness guarantee is not supported by the proposed AssetCache path because image conversion currently happens on the foreground executor.
  • Decoder limits do not cover the encoded local-file read that happens before try_from_bytes.
  • The proposed global GIF/WebP first-frame decode would regress an existing animated-image surface.
  • Telemetry mapping and preload-count prose need to be made explicit and internally consistent.

Security

  • Local files with supported image extensions can still be arbitrarily large encoded inputs; the design needs a pre-read file-size cap, streaming metadata check, or equivalent mitigation before full-file read/decode.

Verdict

Found: 1 critical, 4 important, 1 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Comment thread specs/GH9729/product.md Outdated
Comment thread specs/GH9729/product.md Outdated
- The image renders centered, fit to the viewport via `Image::new(...).contain()`: aspect ratio preserved, never upscaled past the image's native pixel dimensions.
- The Lightbox renders as a child of the workspace's main render `Stack`, so the scrim covers the whole workspace (all panes are dimmed, not just the active pane). This matches today's screenshot/artifact Lightbox behavior.
- Underlying panes remain visible behind the scrim and do not receive input while the Lightbox is open.
- While bytes are still being read or decoded, the existing loading indicator is shown. Arrow-key navigation remains responsive; an in-flight decode never blocks navigation.
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.

⚠️ [IMPORTANT] This responsiveness guarantee is not supported by the proposed AssetCache path: local-file bytes are fetched in the background, but ImageType::try_from_bytes runs on the foreground executor after fetch. Add a design change to decode off the UI/foreground executor or weaken this guarantee.

Comment thread specs/GH9729/tech.md Outdated
Comment thread specs/GH9729/tech.md Outdated
Comment thread specs/GH9729/tech.md Outdated
Comment thread specs/GH9729/tech.md Outdated
Cuts sibling navigation, the bounded preload window, and the click-to-swap
flow that drove the modality contradiction Oz flagged. Adds a unified
pre-read size cap and a bounded asset-cache read to close pre-decode DoS
surfaces (symlink-to-/dev/zero, TOCTOU). Hardens the global decoder with
image::Limits, an explicit animated frame-count and total-pixel budget
enforced during iteration, and an SVG intrinsic-dimension cap; frame
collection for AnimatedBitmap is unchanged so the changelog continues to
animate. Descopes the rendered-image a11y label since GPUI's Image
element does not expose an accessibility API today; tracks it as the
first follow-up.
@NorfeldtKnowit
Copy link
Copy Markdown
Author

Thanks for the fourth-pass review @oz-for-oss. Pushed 112e581, which is a substantive narrowing rather than a touch-up; quick summary so you have context if you pick it up.

Critical (modality contradiction). Cut sibling navigation, the bounded-preload window, and the click-to-swap flow entirely. v1 is now a single, fully modal, single-image Lightbox: while open, the workspace (file tree, terminals, code editor, tab bar) is inert; opening another file means dismiss-first. That removes the inert-vs-click-through contradiction at its source rather than papering over it.

Important (pre-read DoS surface). Added a unified pre-read file-size cap and a bounded AssetCache read, so a multi-gigabyte file or a symlink to /dev/zero is rejected at the metadata stat before any bytes are read, and a TOCTOU grow-after-stat is bounded by the read cap. Files above the cap surface as the per-entry error state without ever allocating the bytes.

Important (animated frame materialization). The previous round's first-frame-only collapse would have regressed the changelog's animated rendering, which I missed. Reverted that: AnimatedBitmap frame collection is unchanged so the changelog still animates. Hardened it instead with image::Limits plus an explicit frame-count and total-pixel budget enforced during iteration (bail! mid-stream, drop the iterator), so a pathological animated WebP/GIF cannot allocate every frame before the cap is checked.

Important (SVG decompression bomb). Added an SVG intrinsic-dimension cap at parse time, so a 200-byte SVG declaring <svg width=\"200000\" height=\"200000\"> is rejected before rasterization.

Suggestion (a11y label honesty). Verified against crates/warpui_core/src/elements/image.rs: GPUI's Image element does not currently expose an accessibility-label API. Descoped the rendered-image screen-reader label from v1 and listed it as the first follow-up rather than mandating an API the codebase does not have.

Quick procedural question: my read of the prompt is that the "up to 3 times on the same pull request" budget is aimed at re-review spam where nothing has changed between requests. Does it apply identically when the retrigger follows a substantive code push, or only to re-reviews against the same revision? Happy to leave this for human review either way; mostly want to make sure I am reading the rule correctly.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 6, 2026

@NorfeldtKnowit

I'm re-reviewing this spec-only pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

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

Overview

This spec PR defines a Lightbox-based image preview path for supported file-tree image extensions, including product behavior, implementation integration points, decoder limits, error rendering, telemetry, and validation.

Concerns

  • The product spec says there are no global decoder behavior changes, but the tech spec explicitly hardens the shared ImageType::try_from_bytes path with new global failure modes for over-cap animated and SVG inputs.
  • The bounded LocalFile read limits bytes but does not require re-validating that the opened handle is a regular file after the metadata/read TOCTOU window, leaving a local DoS gap for FIFO or special-file replacement.

Security

  • The asset-cache read plan should require post-open regular-file validation before reading so a path replacement cannot block indefinitely or bypass the stated special-file rejection contract.

Verdict

Found: 0 critical, 2 important, 0 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Comment thread specs/GH9729/product.md Outdated
- While bytes are still being read or decoded, the existing loading indicator is shown.
- If the file fails the pre-read size cap, fails to read, fails to decode, or exceeds the decoded-pixel cap, the Lightbox shows a non-blocking error state that includes the filename and a short reason. The Lightbox never crashes the workspace.
- SVG renders via the existing `usvg` + `resvg` pipeline used by `ImageType`.
- Animated GIF and animated WebP files render their **first frame only**. The Lightbox today never calls `Image::enable_animation_with_start_time`, so animated raster formats already render as a static first frame in this surface; v1 reuses that behavior unchanged. Continuous playback in the Lightbox is tracked as a follow-up. No global decoder behavior changes; the changelog section's animated-image rendering is untouched.
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.

⚠️ [IMPORTANT] This contradicts the tech spec, which adds global decoder limits and new over-cap failure modes in the shared ImageType::try_from_bytes path. Reword this to say legitimate animated playback is unchanged, while pathological inputs can now fail globally.

Comment thread specs/GH9729/tech.md Outdated
const MAX_ASSET_LOCAL_FILE_BYTES: u64 = 64 * 1024 * 1024; // matches MAX_PREVIEW_FILE_BYTES

async {
let file = async_fs::File::open(&path).await?;
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.

⚠️ [IMPORTANT] [SECURITY] Opening and then take()-reading the path still trusts whatever the path resolves to at read time; after the metadata check, a local process can replace it with a FIFO or special file that blocks indefinitely. Require the asset cache to verify the opened handle is a regular file, such as with post-open file-type validation/no-follow behavior where available, before reading.

- Reword product.md to accurately state that the global ImageType
  decoder is hardened (frame-count and total-pixel budgets for
  animated formats, SVG intrinsic-dimension cap) without regressing
  any in-tree surface; the workspace changelog's animated rendering
  is unchanged.

- Add post-open is_file() validation on the opened handle in the
  bounded AssetCache read, closing the FIFO/special-file TOCTOU
  window between the pre-read metadata stat and the open syscall.
  Add a corresponding race-replace validation case.
@NorfeldtKnowit
Copy link
Copy Markdown
Author

Thanks for the fifth-pass review @oz-for-oss. Pushed b6349ff addressing both findings.

Important (product/tech consistency on global decoder). The honest answer is that change 4 does harden the shared ImageType::try_from_bytes path globally, and centralizing the caps in the decoder is the right place for them rather than a bug. The product.md line claiming "no global decoder behavior changes" was the inconsistency. Reworded the Inside-the-Lightbox bullet to state plainly that v1 hardens the shared decoder against pathological inputs (oversized dimensions, animated frame-count and total-pixel budgets, SVG intrinsic-dimension cap), that the budgets are sized well above any in-tree consumer's legitimate inputs, and that the only user-visible global effect is pathological/malicious files surfacing as decode errors at every consumer of ImageType::try_from_bytes instead of silently allocating gigabytes of memory. The changelog's animated rendering continues unchanged. Existing Success Criterion 8 and the changelog regression-check in Validation step 9 are already aligned with this wording.

Important / Security (post-open regular-file revalidation). Real TOCTOU gap. Updated change 5 in tech.md to require a post-open is_file() check on the opened descriptor (fstat against the open file, not a fresh path-based stat) before any byte is read. This closes the swap-between-stat-and-open window: even if the path is replaced with a FIFO, character device, or directory between change 2's pre-read path stat and change 5's open syscall, the check runs against the already-open descriptor and rejects before read_to_end can hang on a FIFO or stream until OOM on /dev/zero. Spelled out in the change-5 prose, the code block, the Risks > Pre-read size envelope section, a new unit test local_file_read_rejects_post_open_non_regular_file, and a new manual validation step 7a (race-replace the path with a FIFO between stat and open). The post-open check is in addition to, not a replacement for, the pre-read size cap and the bounded MAX + 1 read; together they cover oversize regular files, file-grows-after-stat, file-type-swaps-after-stat, and FIFO-was-the-original-target.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 6, 2026

@NorfeldtKnowit

I'm re-reviewing this spec-only pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

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

Overview

The specs cover the intended Lightbox-based image preview flow and include useful product, implementation, validation, and follow-up detail. The main remaining issues are in the threat model for local image reads and SVG decoding.

Concerns

  • The proposed post-open regular-file check does not prevent open itself from blocking on a FIFO or special-file path introduced by the TOCTOU swap it is meant to defend against.
  • The SVG plan relies on a 64 MB global byte cap plus an intrinsic-dimension check after usvg parsing, but does not bound SVG parser CPU/memory before that parse runs on the foreground executor.
  • One product-spec performance sentence says over-cap files are rejected at pre-read, which conflicts with the tech spec's decode-time dimension and pixel checks.

Security

  • A malicious repository file can still cause a denial-of-service if a regular image path is swapped to a FIFO before asset-cache open, because the blocking point happens before the opened-handle metadata check.
  • A malicious but under-64 MB SVG can consume parser CPU/memory before the intrinsic-dimension guard runs; the spec should add a content-sniffed SVG byte/complexity limit or otherwise bound parsing.

Verdict

Found: 0 critical, 2 important, 1 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Comment thread specs/GH9729/tech.md Outdated
const MAX_ASSET_LOCAL_FILE_BYTES: u64 = 64 * 1024 * 1024; // matches MAX_PREVIEW_FILE_BYTES

async {
let file = async_fs::File::open(&path).await?;
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.

⚠️ [IMPORTANT] [SECURITY] The TOCTOU mitigation still opens the swapped path before checking it, and opening a FIFO for read can block indefinitely before file.metadata() runs. Specify a nonblocking/platform-specific open strategy, or an atomic regular-file-only open, that cannot hang on FIFOs or special files before the fstat check.

Comment thread specs/GH9729/tech.md

Notes on this shape:

- **One unified pre-read cap.** Earlier drafts split the cap by file extension (`.svg` got a smaller cap). That is bypassable: `try_from_bytes` classifies SVG by content (first byte `<`, see `image_cache.rs:275`), not by extension, so a `.png` whose contents start with `<` would be routed through `usvg` while passing the looser raster cap. v1 uses one cap for everything pre-read; the SVG-specific allocation surface is bounded by the SVG intrinsic-dimension cap added in change 4 instead.
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.

⚠️ [IMPORTANT] [SECURITY] The unified 64 MB cap only bounds input size and rasterization dimensions; it does not bound SVG parser CPU/memory before usvg::Tree::from_data runs on the foreground executor. Keep a content-sniffed SVG byte/complexity limit or move/bound SVG parsing separately.

Comment thread specs/GH9729/product.md Outdated

### Performance posture

- Image bytes are read on the background executor; the bytes-to-RGBA decode itself runs on the foreground executor (`AssetCache::load_asynchronously` invokes `try_from_bytes` on the foreground executor before publishing the loaded asset). v1 does not change this. The decoder caps below bound the worst-case decode time, and the loading indicator is shown until decode completes; a file that would exceed the caps is rejected at the pre-read step before any decode runs.
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.

💡 [SUGGESTION] This says any file exceeding decoder caps is rejected at the pre-read step, but dimension and pixel caps are only discovered during decode. Reword this so the product expectation matches the tech spec's pre-read byte cap versus decode-time dimension/pixel checks.

- Add O_NONBLOCK to the asset-cache open in change 5 so open() of
  a FIFO does not block before the round-5 post-open is_file() check
  can run. Document the POSIX semantics that O_NONBLOCK is harmless
  for regular-file reads, add a Unix-only mkfifo unit test, and note
  Windows-side handling.

- Add an SVG-specific pre-parse byte cap (MAX_SVG_BYTES = 4 MB) and
  content-sanity prefix check applied before usvg::Tree::from_data
  is invoked, so a malicious deeply-nested SVG cannot consume parser
  CPU/memory under the 64 MB raster cap. Add corresponding unit
  test and manual validation case.

- Reword product.md's performance/limits text to distinguish the
  pre-read size cap (file size, rejects before read) from the
  decode-time dimension and pixel caps (decoded resolution, rejects
  during decode). Reconcile Success criteria 5-7 if needed.
@NorfeldtKnowit
Copy link
Copy Markdown
Author

Thanks for the sixth-pass review @oz-for-oss. Pushed 9a043b5 addressing all three findings.

Important / Security (open() blocks on FIFO before is_file check). Real gap; the round-5 post-open check only runs if open() itself returns. On Linux/macOS, open(O_RDONLY) of a FIFO with no writer blocks indefinitely. Updated change 5 in tech.md to apply O_NONBLOCK via OpenOptions::custom_flags(libc::O_NONBLOCK) gated #[cfg(unix)]. POSIX read(2) on a regular file completes fully regardless of the flag, so no fcntl-clear step is needed before the bounded take(). Documented why on Windows the post-open is_file() is sufficient on its own (named pipes do not present as regular files there). Added a #[cfg(unix)] unit test local_file_read_does_not_block_on_fifo that creates a writer-less FIFO via mkfifo, passes the path to the asset-cache load future, and asserts the future returns Err quickly. Added manual validation step 11b for an explicit FIFO race-replace and updated the Risks > Pre-read size envelope section to enumerate the three coordinated mitigations (pre-read stat in change 2, O_NONBLOCK + post-open is_file() in change 5, bounded MAX + 1 read).

Important / Security (SVG parser CPU/memory under 64 MB cap). Layered defense in tech.md. (a) Introduced MAX_SVG_BYTES = 4 * 1024 * 1024 (4 MB) in change 5; the bounded read picks MAX_SVG_BYTES + 1 for .svg paths and MAX_ASSET_LOCAL_FILE_BYTES + 1 for raster. 4 MB is comfortable for any legitimate icon, diagram, or illustration SVG (real-world is usually a few hundred KB). (b) Added a looks_like_svg_xml content-sanity prefix check in change 4 that, before usvg::Tree::from_data is invoked, peeks the first 1 KB of bytes and confirms a UTF-8-BOM-tolerant whitespace-tolerant <?xml or <svg prefix. This catches the "binary blob renamed to .svg" case and is bounded-time / no-allocation. (c) Added unit tests local_file_read_caps_svg_at_smaller_limit, decode_svg_rejects_non_xml_prefix, and decode_svg_accepts_xml_prelude_with_bom_and_whitespace. (d) Added manual validation step 11a for a 3.5 MB SVG containing 50000 nested <g> elements (rejected by the byte cap before parse) and a binary-blob renamed .svg (rejected by content-sanity). Tracked a usvg/resvg parse-budget API as a follow-up; if it ever lands, it becomes the right primary defense and the 4 MB cap can relax.

Suggestion (product spec conflates pre-read with decode-time caps). Reworded the Performance posture and Limits sections in product.md to call out the two distinct caps. Pre-read size cap rejects oversize files before any read; decode-time dimension and pixel caps reject oversize decoded images during decode. Added concrete examples covering the matrix: a 3 MB JPEG with normal pixel content passes both; a 100 MB PNG with normal pixel content fails pre-read; a 3 MB JPEG header decoding to 100000×100000 passes pre-read but fails decode; a 100 MB PNG decoding to 100000×100000 fails pre-read (decode never runs). Updated Success criteria 5, 6, 7, and 10 with explicit cap attribution. Extended the Validation fixtures list with FIFO, deeply-nested SVG, and binary-blob .svg cases.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 6, 2026

@NorfeldtKnowit

I'm re-reviewing this spec-only pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

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

Overview

This spec proposes routing supported image-file clicks from the Code Editor file tree into the existing Lightbox overlay, with product behavior, decoder limits, bounded local-file reads, and validation coverage.

Concerns

  • The SVG parser DoS mitigation is internally inconsistent: the spec says extension-based caps are bypassable because SVG decode is content-sniffed, but the final bounded-read design keys the tighter SVG cap on .svg extension.
  • The global AssetSource::LocalFile read cap is under-surveyed; existing local markdown/blocklist image rendering and custom theme background images also use LocalFile, so the spec needs to scope or validate the behavior before implementation.

Security

  • The extension-keyed SVG byte cap can be bypassed by XML/SVG content under a non-.svg supported image extension, allowing up to the raster cap to reach usvg despite the stated parser-DoS mitigation.

Verdict

Found: 0 critical, 2 important, 0 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Comment thread specs/GH9729/tech.md Outdated
// intrinsic-dimension check is possible. 4 MB comfortably covers any
// legitimate icon, diagram, or illustration SVG (real-world SVGs are
// typically a few hundred KB at most).
let cap = if path_has_svg_extension(&path) {
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.

⚠️ [IMPORTANT] [SECURITY] This reintroduces the bypass called out earlier: try_from_bytes classifies SVG by content (<), so XML/SVG bytes in payload.png get the 64 MB raster cap and can still reach usvg; make the tight cap match the parser selection, or use a unified safe cap before SVG sniffing.

Comment thread specs/GH9729/tech.md Outdated

The post-open `is_file()` check is **in addition to**, not a replacement for, `O_NONBLOCK`, the pre-read size cap from change 2, and the `MAX + 1` bounded read. `O_NONBLOCK` ensures `open()` of a FIFO returns immediately so the post-open check actually runs; the post-open check rejects the FIFO before any read is attempted. Change 2 still rejects oversize regular files synchronously before dispatch (avoiding even an open syscall on the obvious-bad cases), and the bounded read still defends against a regular file that grows past the cap between the pre-read stat and the open. The dual-cap design (64 MB for raster, 4 MB for SVG) bounds the bytes fed to `usvg::Tree::from_data` more tightly because XML parsers have super-linear cost in input size.

`path_has_svg_extension` is a small case-insensitive helper that returns true if the path ends in `.svg`. This intentionally keys on extension, not content sniffing: change 2's resolver already gates the file-tree path on `is_supported_image_file`, so by the time bytes reach this load future the caller has committed to SVG-vs-raster classification. Other `LocalFile` consumers (none today) would also benefit from extension-driven cap selection because the SVG parser cost is the same regardless of caller.
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.

⚠️ [IMPORTANT] This global LocalFile change is not fully surveyed: local markdown/blocklist images and custom theme backgrounds also create AssetSource::LocalFile, so the spec should either scope the cap to file-tree previews or add acceptance criteria/tests for those existing surfaces.

- Switch the SVG byte cap from extension-keyed to content-keyed in
  change 5: a 1 KB peek + looks_like_svg_xml at the asset-cache read
  step picks MAX_SVG_BYTES (4 MB) for any file whose first bytes
  look XML-shaped, regardless of extension. A 4.5 MB SVG-content
  file with a .png extension is now capped at 4 MB instead of
  flowing through the 64 MB raster cap. Reconcile change 4's SVG
  text and the Pre-read size envelope risk to describe the cap as
  content-keyed and explain why extension-keying was discarded.
  Add unit tests local_file_read_caps_svg_content_under_png_extension
  and local_file_read_uses_raster_cap_for_non_svg_content, and add a
  manual-validation fixture for the same case.

- Survey existing AssetSource::LocalFile consumers (image preview,
  markdown image embeds in editor/notebooks, custom theme background
  images, theme deletion preview, settings profile image, blocklist
  images) and document each consumer's legitimate input envelope
  against the 64 MB raster / 4 MB SVG content-keyed caps. Keep a
  single global cap rather than parameterizing per call: no current
  consumer routinely exceeds those caps. Add a follow-up entry for
  parameterizing AssetSource::LocalFile with optional max_bytes if a
  future consumer ever needs higher caps.
@NorfeldtKnowit
Copy link
Copy Markdown
Author

Thanks for the seventh-pass review @oz-for-oss. Pushed 1b79a5d addressing both findings.

Important / Security (extension-keyed SVG cap is bypassable). Real bypass; the round-6 design picked the cap from path extension, so an evil.png containing 50 MB of SVG XML would have read at the 64 MB raster cap. Switched the cap to content-keyed at the asset-cache layer in change 5: after open + post-open is_file(), peek the first 1 KB, run looks_like_svg_xml on the peek, and pick MAX_SVG_BYTES + 1 (4 MB) if the prefix looks XML/SVG-shaped, otherwise MAX_ASSET_LOCAL_FILE_BYTES + 1 (64 MB). Extension is no longer used to choose the cap. The reused helper (looks_like_svg_xml) is the same one change 4 uses to gate usvg::Tree::from_data, so the asset-cache cap and the parser gate share one signal by construction. Reconciled the spec text in change 4's SVG section and Risks > Pre-read size envelope to describe the cap as content-keyed and to spell out why extension-keying was discarded. The parser route in try_from_bytes remains extension-keyed (only .svg reaches usvg) AND content-checked (looks_like_svg_xml must pass), so evil.png with SVG content still cannot reach usvg; the content-keyed read cap is defense-in-depth even if try_from_bytes is ever changed to content-sniff. Added unit tests local_file_read_caps_svg_content_under_png_extension and local_file_read_uses_raster_cap_for_non_svg_content, plus a manual-validation fixture for the same case.

Important (global LocalFile cap under-surveyed). Surveyed every current AssetSource::LocalFile consumer in the codebase and documented each in a new "Other LocalFile consumers and rollout impact" subsection in change 5: image preview (this PR), markdown image embeds in the editor and notebooks, custom theme background images, theme-deletion preview, settings profile image, blocklist images. For each consumer the typical legitimate input envelope (icons / avatars / screenshots / theme backgrounds in the 0.5–5 MB range) sits at least an order of magnitude below the 64 MB raster cap and well below the 4 MB SVG cap, so a single global cap does not regress any current consumer. Decision: keep the global cap as the simpler design. The parameterized AssetSource::LocalFile { path, max_bytes: Option<u64> } shape is documented as a follow-up for if/when a video-preview or large-export consumer ever lands and legitimately needs higher caps.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 6, 2026

@NorfeldtKnowit

I'm re-reviewing this spec-only pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

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

Overview

This spec proposes routing supported image files from the Code Editor file tree into the existing Lightbox and adds decoder/read limits for local image assets. The overall shape is aligned with the linked issue, but there are blocking internal inconsistencies around where the SVG byte cap is applied and how the validation fixtures prove the parser-DoS mitigation.

Concerns

  • The product spec says the SVG-specific cap fires at the same point as the pre-read metadata cap, while the tech spec later relies on a content-keyed bounded read after opening the asset.
  • The end-to-end flow describes extension-keyed SVG cap selection, contradicting the content-keyed design that is required to prevent SVG content hidden under non-SVG extensions from bypassing the tighter cap.
  • The manual validation fixture for the SVG parser-DoS byte cap is below the declared 4 MB cap, so it would not verify the intended pre-parse rejection.

Security

The SVG parser-DoS mitigation is security-relevant. The spec needs one consistent, testable contract for content-keyed SVG byte limiting before implementation.

Verdict

Found: 0 critical, 3 important, 0 suggestions

Request changes

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Comment thread specs/GH9729/product.md Outdated
- **Bounded read.** The actual file read in the asset cache is bounded by the same cap, so a file that grows after the pre-read metadata check, or a path swapped to a special device or FIFO between the metadata check and the open, cannot bypass the limit. Fires during the read step.
- **Decode-time dimension and pixel caps.** Rejects files whose declared image **dimensions** or `width × height` **pixel count** exceed fixed maximums. Fires during decode, after the bytes have been read; the file size may be small (and within the pre-read cap) while the decoded image would still exceed the pixel budget. Example: a 3 MB JPEG header that decodes to 100000×100000 pixels passes the pre-read cap but fails this cap.
- **Animated frame budget.** Rejects animated GIF and animated WebP files whose total frame count or whose summed pixels-across-frames exceed fixed maximums. Fires mid-iteration during decoding so pathological inputs do not first materialize every frame into memory.
- **SVG-specific pre-read cap.** Rejects SVG files whose on-disk size exceeds a tighter SVG-only cap (smaller than the raster cap), because XML parsers have super-linear cost in input size and a large SVG can consume parser memory disproportionate to its file size. Fires at the same point as the pre-read file-size cap, with a tighter ceiling for `.svg`.
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.

⚠️ [IMPORTANT] [SECURITY] This contradicts the tech spec's content-keyed asset-cache read cap: the 4 MB SVG cap does not fire at the same point as the workspace metadata pre-read cap. Update this to distinguish the 64 MB regular-file metadata check from the later SVG content-keyed bounded read.

Comment thread specs/GH9729/tech.md Outdated
4. `FileTreeEvent::OpenFile { path, target: ImagePreview, line_col: None }` is emitted, re-emitted as `LeftPanelEvent::OpenFileWithTarget` by `left_panel.rs:758-768`, and handled by `Workspace::open_file_with_target` at `view.rs:5715-5815`.
5. The new `ImagePreview` arm (change 2) calls `std::fs::metadata` for the pre-read check (regular-file + size cap). On success it builds a single-element `Vec<LightboxImage>` with `source: Resolved { LocalFile { path } }` and dispatches `WorkspaceAction::OpenLightbox` via `ctx.dispatch_typed_action`. On metadata error, non-regular-file, or oversize file it builds the same single-element vec with `source: Error { message }` (a sanitized constant) and dispatches.
6. The `OpenLightbox` handler (`view.rs:21710-21737`) creates `lightbox_view` and focuses it. The scrim covers the workspace; pointer input is intercepted by the scrim; the file tree, terminal panes, code editor panes, and tab bar are all inert. `LightboxView::start_asset_loads` queues `AssetCache::load_asset` for the single `Resolved` entry (no-op for `Error`).
7. The asset cache opens the file with `O_NONBLOCK` on Unix (change 5) so that `open()` of a FIFO swapped in via TOCTOU returns immediately rather than hanging waiting for a writer; the post-open `fstat`-based `is_file()` then rejects any non-regular-file descriptor before any byte is read. For regular files, the load future reads up to `cap + 1` bytes via `take()` where `cap` is `MAX_SVG_BYTES` (4 MB) for `.svg` paths and `MAX_ASSET_LOCAL_FILE_BYTES` (64 MB) otherwise, and bails if the cap was exceeded. The bytes are sent through the asset-cache channel and `try_from_bytes` runs on the foreground executor with the static-decode limits, the animated frame budget, the SVG content-sanity prefix check, and the SVG intrinsic-dimension cap from change 4. The Lightbox renders as a child of the workspace's main `Stack` (`view.rs:22739-22740`); the image fits via `Image::new(asset_source).contain()`. Until the bytes have been decoded, the loading indicator is shown. Animated GIF and animated WebP files render their first frame statically because the Lightbox does not call `enable_animation_with_start_time` (no v1 change to this).
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.

⚠️ [IMPORTANT] [SECURITY] This reintroduces extension-keyed cap selection (.svg paths), contradicting change 5's content-keyed design. The normative flow should say the cap is selected by the first-1 KB looks_like_svg_xml peek so SVG content under non-.svg extensions cannot bypass the 4 MB parser limit.

Comment thread specs/GH9729/tech.md Outdated
9. **Animated formats**: open an animated GIF and an animated WebP from the file tree. Confirm each renders its first frame statically (no continuous playback in the Lightbox). Then navigate to the changelog page in the Resource Center and confirm changelog GIFs/WebPs continue to animate (regression check on change 4).
10. **Pathological animated input**: open an animated GIF with 500 frames (above `MAX_ANIMATED_FRAMES`). Confirm the per-entry error state. Open an animated WebP whose frames sum above `MAX_ANIMATED_TOTAL_PIXELS` and confirm the same; observe via memory inspection that the error fires before the full frame set is materialized.
11. **SVG**: open a small well-formed SVG and confirm it renders (not raw XML). Open `<svg width="200000" height="200000">...</svg>` and confirm the per-entry error state ("svg dimensions exceed render budget") rather than a multi-GB pixmap allocation.
11a. **SVG byte-cap and parser DoS**: generate a `deeply_nested.svg` containing roughly 50000 opened `<g>` elements (file size around 3.5 MB); open it from the file tree and confirm the per-entry error fires from the SVG byte cap before `usvg::Tree::from_data` is ever invoked. With Activity Monitor / `time` / RSS observation, confirm the workspace foreground thread does not spike for hundreds of milliseconds, and total resident memory does not climb to multi-hundred-MB during the dispatch. Then generate a 5 MB binary blob renamed `data.svg` (e.g. `dd if=/dev/urandom of=data.svg bs=1M count=5`) and open it; confirm the per-entry error fires either from the SVG byte cap (if over 4 MB) or from the content-sanity prefix check (if under 4 MB) before the parser runs.
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.

⚠️ [IMPORTANT] [SECURITY] A 3.5 MB SVG is below MAX_SVG_BYTES = 4 MB, so this fixture will not fail from the SVG byte cap before usvg::Tree::from_data. Make the fixture exceed 4 MB or change the expected failure mode, otherwise the parser-DoS mitigation is not actually validated.

- Reword product.md to describe the SVG cap as firing at the
  asset-cache content-keyed bounded read, not at the pre-read
  metadata stage. The pre-read cap is uniform at 64 MB; the SVG
  cap (4 MB) is content-keyed and applies post-open after a
  1 KB peek.

- Update tech.md end-to-end flow and surrounding prose to describe
  the SVG cap selection as content-keyed (peek + looks_like_svg_xml)
  instead of extension-keyed. Reconciles round-7's content-keyed
  switch through the full spec narrative.

- Bump the SVG parser-DoS manual validation fixture from 3.5 MB
  (below cap, would not verify rejection) to 4.5 MB (above cap,
  exercises the bounded-read rejection at the asset-cache step
  before any parse runs). Add an under-cap counter-fixture and
  align the existing unit test to use a >4 MB fixture.
@NorfeldtKnowit
Copy link
Copy Markdown
Author

Thanks for the eighth-pass review @oz-for-oss. Pushed 759a5bd addressing all three consistency findings from the round-7 content-keyed switch.

Important (product.md describes SVG cap as pre-read). The cap is post-open, after the first-1-KB content peek; the pre-read metadata cap is uniform at 64 MB across formats because no bytes are read at that point. Reworded the relevant Limits and Performance-posture bullets in product.md to state plainly that the SVG-specific cap (MAX_SVG_BYTES = 4 MB) fires at the asset-cache bounded read step, not at the pre-read metadata stage, and that the two caps are complementary defense-in-depth (a 50 MB file is rejected by the pre-read cap whether or not it is SVG; a 5 MB SVG is rejected by the post-open content-keyed cap; a 5 MB JPEG is read normally and proceeds to decode). Adjusted Success criterion 10 to match.

Important (end-to-end flow describes extension-keyed cap). End-to-end-flow step 7 in tech.md previously read "cap is MAX_SVG_BYTES for .svg paths and MAX_ASSET_LOCAL_FILE_BYTES otherwise" — pure extension-keyed wording carried over from the round-6 design. Replaced with content-keyed wording: after open + post-open is_file(), peek the first 1 KB of the file, run looks_like_svg_xml on the peek, and select the cap from content regardless of extension. The asset-cache code block in change 5 was already content-keyed since round 7; only the narrative around it needed reconciling. Audit grep confirms no stale design-as-current statements survive; remaining extension-keyword hits are anti-pattern contrast wording ("content-keyed, not extension-keyed"), historical context for the round-6→7 switch, or unrelated supported-format-list strings.

Important (manual fixture below the 4 MB cap). Bumped the SVG parser-DoS fixture in Manual validation step 11a from "around 3.5 MB" (which would have parsed successfully and not exercised the cap) to "4.5 MB above MAX_SVG_BYTES", with the explicit expected outcome "rejected by the content-keyed asset-cache cap, no parse attempted, per-entry error state shown". Added an evil.png-renamed parallel (same 4.5 MB SVG payload under a .png extension) so the manual run also exercises the content-keyed-not-extension-keyed contract end-to-end, and an under-cap counter-fixture (100 KB legitimate SVG, expected to render normally). Verified the existing unit test local_file_read_caps_svg_content_under_png_extension already uses a 4.5 MB fixture so it actually exercises the cap.

/oz-review

@oz-for-oss
Copy link
Copy Markdown
Contributor

oz-for-oss Bot commented May 6, 2026

@NorfeldtKnowit

I'm re-reviewing this spec-only pull request in response to a review request.

You can view the conversation on Warp.

I completed the review and no human review was requested for this pull request.

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Copy link
Copy Markdown
Contributor

@oz-for-oss oz-for-oss Bot left a comment

Choose a reason for hiding this comment

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

Overview

The spec defines a scoped v1 for previewing supported image files from the Code Editor File Tree in the existing Lightbox overlay, with clear product behavior, integration points, decoder/read limits, validation coverage, and follow-up boundaries. The supplemental security review found the local-file and decoder DoS surfaces are explicitly addressed with pre-read caps, bounded reads, sanitized errors, and decode-time limits.

Concerns

  • One non-blocking clarity issue remains: the SVG prefix predicate should either broaden support or explicitly document the supported SVG prelude subset so valid SVG files with comments, DOCTYPE, or other XML prelude forms do not unexpectedly fail the advertised SVG-preview contract.

Verdict

Found: 0 critical, 0 important, 1 suggestions

Approve with nits

Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).

Powered by Oz

Comment thread specs/GH9729/tech.md Outdated

1. **SVG-specific pre-parse byte cap (`MAX_SVG_BYTES = 4 * 1024 * 1024`).** Applied in change 5's bounded read on the asset-cache side: bytes whose first 1 KB peek matches `looks_like_svg_xml` are read with a 4 MB ceiling (`MAX_SVG_BYTES + 1`) instead of the 64 MB raster ceiling. The cap is keyed on the **content** of the file, not its extension, so a non-`.svg` file whose contents are SVG XML is also tightened to 4 MB; an over-cap SVG returns `Err` from the asset-cache read step before bytes reach `try_from_bytes`. 4 MB is roomy for any legitimate icon, diagram, or illustration SVG (real-world SVGs are typically a few hundred KB; even maximalist illustration SVGs rarely exceed 1-2 MB) while preventing a 64 MB SVG from being handed to `usvg`.

2. **Content-sanity prefix check on the bytes that reach `try_from_bytes`.** Before `usvg::Tree::from_data` runs, the same `looks_like_svg_xml` helper used by change 5 to pick the byte cap is invoked one more time on the buffer that reached the decoder, and the buffer is rejected if it fails. The check confirms a UTF-8 prefix consistent with XML/SVG: optional UTF-8 BOM, optional whitespace, then `<?xml` or `<svg`. This is the second of two uses of the helper: change 5 calls it on the file-side peek to **pick the cap**, and change 4 calls it on the in-memory buffer to **gate the parser**. Reusing the same predicate keeps the cap signal and the parser-gate signal aligned by construction. The check catches the "binary blob renamed to `.svg`" case (which would otherwise either parse-fail expensively or waste cycles in `usvg`'s error paths) and is cheap (a bounded byte scan, no allocation):
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.

💡 [SUGGESTION] Broaden or explicitly scope this SVG-prefix predicate. As written it accepts only BOM/whitespace followed by <?xml or <svg, so valid SVGs that begin with comments, DOCTYPE, or another XML prelude could fail despite the product contract that .svg files render.

Broaden looks_like_svg_xml to accept XML comment and DOCTYPE
preludes in addition to <?xml and <svg. Document the supported
prelude subset explicitly so legitimate SVG files starting with
authoring-tool comments or DOCTYPE declarations are not rejected
by the content-sanity gate. Add corresponding unit tests for both
new prelude forms.
@NorfeldtKnowit
Copy link
Copy Markdown
Author

Thanks for the approve-with-nits @oz-for-oss. Pushed 6bc517b addressing the one suggestion.

Suggestion (SVG prefix predicate broadening). Real concern: legitimate SVGs in the wild often start with authoring-tool comments (<!-- Generated by Inkscape -->) or DOCTYPE declarations (<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ...>), and the round-6 predicate only accepted <?xml or <svg after optional BOM and whitespace, so those files would have hit the per-entry error path instead of rendering. Broadened looks_like_svg_xml to accept four prelude tokens after the optional UTF-8 BOM and bounded ASCII whitespace scan: <?xml, <svg, <!--, and <!DOCTYPE. Added an explicit "Supported prelude subset" subsection in tech.md enumerating the four accepted forms, noting that the predicate is a coarse content sniff (not a full XML lexer) and that usvg::Tree::from_data remains the actual XML/SVG validator. Out-of-scope forms (lowercase <!doctype, non-<?xml processing instructions, leading-data attributes, etc.) are documented as falling through to the per-entry decode error rather than being rejected at the content-sanity gate. Same helper is used at both layers (asset-cache cap selection in change 5, parser gate in change 4) so broadening covers both. Added unit tests decode_svg_accepts_doctype_prelude and decode_svg_accepts_xml_comment_prelude; the BOM-and-whitespace and binary-prefix tests are retained.

Leaving the PR for human review at this point. The retrigger budget across these passes has been generous and substantive — thanks for the careful read on the threat model in particular (the FIFO open-blocks-before-fstat finding and the content-keyed-vs-extension-keyed SVG cap bypass were both real bugs in the spec that I would not have caught on my own).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed external-contributor Indicates that a PR has been opened by someone outside the Warp team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Image preview in the Code Editor file pane, with directory thumbnail strip

1 participant