Skip to content

fix(cli): protect batch artifacts and skipped inputs during preflight - #254

Merged
johnny4young merged 66 commits into
mainfrom
fix/batch-report-preflight
Oct 8, 2026
Merged

johnny4young merged 66 commits into
mainfrom
fix/batch-report-preflight

Conversation

@johnny4young

@johnny4young johnny4young commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Review follow-up to #251

This is stacked on fix/app-review-2026-10 so the focused safety delta can be reviewed independently of the existing full-app review. Merge order, if approved: #251 first, then retarget this PR to main. No merge or release was performed.

Findings

  1. --manifest or --skipped-report could name an image or sidecar produced by the same run. JSON was written after rendering and silently replaced that artifact, while the summary still reported success.
  2. Both reports could name the same file, so the second write replaced the first.
  3. --no-overwrite checked images and sidecars but not reports.
  4. The source-protection set contained only successfully loaded text files. A skipped input could still be overwritten by another input's sidecar.
  5. Batch input preflight canonicalized every source once per output, introducing quadratic filesystem work.
  6. A report could replace a background/watermark input resource, including through a symlink alias.
  7. Named reports are intentionally excluded from discovery, so an existing source named by --manifest/--skipped-report could be overwritten. Existing destinations inside the input tree now require bounded recognition as the matching prior report structure.

Changes

  • Preflight report/image/sidecar collisions with canonical, case-folded filesystem keys
  • Protect existing reports under --no-overwrite, including dry runs that still emit reports
  • Validate before creating output directories or entering the render loop
  • Protect every discovered input, including files that the loader skipped
  • Canonicalize the source set once for the complete batch plan; preserve the single-render API
  • Add regression tests for both flags, every sidecar type, case aliases, shared reports, report preservation and skipped-input preservation
  • Include image-resource identities in report collision checks, with direct/symlink regression coverage
  • Recognize existing in-input reports through the shared stable regular-file reader (maximum 5 MiB), strict matching keys/types and valid manifest status/dimensions. Reject unknown, malformed, wrong-kind and oversized targets before output; retain empty/nonempty report reruns and recursive/nested inputs
  • Document that recognized structure is not cryptographic proof of origin

Review follow-ups (2026-10-06)

  • Verified:
    • The guards run before any directory is created.
    • The report-structure validator matches the encoder: planned entries omit width/height, format is limited to png/pdf/heic/avif, and language is a raw value.
    • Symlink aliases to resources are caught.
    • Empty and matching non-empty reports can be re-used.
  • Applied:
    • cca7677 merges fix: full app review — editor, menu bar, settings, domain, web/CLI, docs and screenshots #251's head, which carries the runtime outputConflict error and case-folded claim keys. The conflict in CLIOutputWriter is resolved by applying both to the batch (array) form of guardOutputsDoNotOverwriteInputs. The CHANGELOG keeps both entries.
    • 0c05fdc fix(cli): report batch output conflicts deterministically:
      • planned outputs are checked in source-key order instead of dictionary order, so the first reported conflict is the same on every run;
      • a batch report that collides with a resource or output now raises outputConflict (exit 1, message only), like the other filesystem conflicts.
  • Validation: swiftc -parse and swift format lint --strict on the changed files (Swift 6.0.3, Linux) and the repository hygiene script pass. AppKit targets cannot be compiled on Linux; native build and tests rely on macOS CI for this head.

Earlier verification (head 2d3132e)

  • CI run 37363266467: all five required jobs successful (Linux static, Sequoia/Tahoe build and tests, both native UI suites with strict visual tours). Tahoe logs confirmed 268 CLI, 158 domain, 53 rendering, 133 repository and 1,919 app tests.
  • Critical changed-line coverage 101/103 (98.06%); Tahoe production coverage 59.28% (floor 47.93%).
  • Website npm test passed: Astro check/build and 58/58 Node tests.

Scope

No dependency, entitlement, rendering-style or test-lane changes. The existing rerun behavior for separately named manifest/report files is retained.

Merge order

#251 first, then retarget this PR to main and re-run CI.

…ocks

The grid emulator now fills erased, inserted and scrolled-in cells with the current SGR background (bce), so TUI color bars and theme backgrounds reach the right margin. The routing scan no longer stops at a CSI interrupted by a control byte.

Line mode keeps a line after a carriage return until something is drawn or erased there, so script's \r\r\n output survives. Cursor up/down moves between emitted lines, so log-update style redraws keep only the final frame. Erases keep SGR and OSC escapes, and backspace removes a glyph instead of escape bytes.

TerminalScreen.plainText() had no production caller and moves to the tests; the routing table in ARCHITECTURE.md now documents the repeated ED 2 trigger.
Decoded images now take their point size from the file's DPI, so a 144-DPI Retina screenshot lays out at half its pixel size and exports 1:1 at @2x instead of being upsampled.

Image reads go through BoundedFileReader (non-blocking open, descriptor type and stability checks) instead of a separate FileHandle loop. The pixel budget is a single ImageDecodePolicy function that the import tests now exercise directly; the unused decodedByteCost helper and the duplicate validateImageDimensions are removed.
Scanner accepted a 0x prefix and leading whitespace that the length check counted, so 0x1E1E1E decoded as a nearly transparent color. RGBAColor and HexColor now use the same ASCII-only parser.
Recipe import decodes the style strictly, so a string font size or an unknown background kind fails with its JSON path instead of silently falling back to a default. Presets and preferences keep the tolerant decoder.
- Read --stdin in bounded chunks and report oversized stdin or files as
  inputTooLarge instead of "could not read".
- Refuse an --out image (not only sidecars) that would overwrite the
  source, a background image, or a watermark logo.
- Batch discovery runs before the output folder is created and skips the
  output folder, manifest, and skipped report, so re-runs do not render
  earlier artifacts; render failures keep their typed reason.
- --edit rejects theme and output options the handoff cannot carry,
  carries --terminal-width as a columns hint, and fails on empty sources.
- --copy with --out writes the file first and copies that raster;
  clipboard-only copies reject a non-PNG --format and report png.
- Locate fonts and the enclosing app through the dyld executable path,
  not argv[0].
- Usage errors exit 2 and print usage; runtime errors exit 1 with only
  their message. A second positional is an unexpected argument.
- --alt-text requires a Markdown or HTML sidecar; help text for
  --profile and terminal-capture matches the parser.
…cribed

- Network quiet now counts in-flight fetch/XHR from document start and
  waits for an idle window with no new resources, instead of returning
  about 0.4 s after load.
- The sign-in window hosts popups in their own windows, answers HTTP
  Basic/Digest/NTLM challenges with a credential sheet, and shows the
  page host and whether it is fully secure.
- Production URL capture uses the tested URLRenderer.configured mapping.
- HTML mode hides capture options that HTML rendering ignores.
- The capture caption no longer claims nothing is sent to a server.
- Image-backed social cards export with the same opaque matte as
  snapshots, in raster and PDF.
- Raster-backed PDFs (Web Snapshot, comparison boards) use point-sized
  pages.
- Export all sizes encodes off the main actor, honors the export format,
  names files from the real capture size, and never overwrites.
- Toolbar tooltips and labels are localized; "%lld slides" has an
  English plural.
- The unreachable disabled-disclosure branch is removed.
Menu bar
- Return focus to the previous app after a panel action or the history
  consent alert, so the next paste lands there.
- Relaunch to Apply passes the old process id, so the single-instance guard
  no longer quits both instances; terminated processes are ignored.
- Refresh the panel's hotkey chip on every open and name what the hotkey runs.
- Announce capture HUD results to VoiceOver (failures with high priority).
- File > Copy Image closes the editor under "Close the editor after copying".
- Services renders with the Export scale, color profile, and destination size.
- Close the panel before running a capture so a modal alert keeps Escape.
- Anchor the helper's panel on the icon; label the in-process status item.
- Show the newest captures in the panel; say when history is off; use the
  pinned-capture warning for Clear All.
- Localize the Recents title, diagnostics panel, Services and automation
  errors; report a failed diagnostics export.
- View > Theme shows a checkmark and custom themes; Check for Updates is
  validated against Sparkle and also offered in Settings > About.
- Remove the unreachable Web Snapshot recovery action, the unused editor
  shortcut name, and the production-only test wrapper; compile memory-journey
  hooks in Debug only.

Settings
- Route captions, tooltips, panels, and import errors through the String
  Catalog, including domain summaries and messages.
- Report why a license activation failed with a specific message.
- Show the real launch-at-login status after a change, with an approval link.
- Add corner radius, shadow depth, and window title to the Style pane with
  value readouts and the persisted slider ranges; share one background
  control; fix the Brand Kit, Library, Help, and Reset copy.
- Disable URL-only Input controls on builds without URL capture; confirm
  before clearing web data.
- Offer the sudo fallback only for permission failures; pick the shell from
  the chosen startup file.
- Keep a seat release running when the user leaves the About pane, with
  progress; show restore and pending-purchase results on the App Store build.
- Make the free-placement handle adjustable from the keyboard and VoiceOver;
  announce disclosure state; label the Style sections.
- Follow the system accent for focus rings and selected pills; drop stale and
  unused catalog entries; use consistent Spanish terms.

Architecture
- Add WindowPlacement for every window controller and fit the Comparison
  Board window to small displays.
- Add one save-panel writer with a single atomic write policy.
- Share one image-share route and inject feedback into ShareManager.
- Add one image-copy helper that always reads clipboard privacy app-wide.
- Remove `.shared` default arguments; tests build isolated settings.
- Resolve the editor's window from viewDidMoveToWindow instead of polling.
- Route activation through AppActivation using cooperative activation.
- Use the batch-export folder picker for the documentation export.
…026-10

# Conflicts:
#	Vitrine/State/WindowState.swift
#	docs/ARCHITECTURE.md
…026-10

# Conflicts:
#	Vitrine/App/EditorWindowController.swift
#	Vitrine/Resources/Localizable.xcstrings
…tings

Auto-copy, save-to-file, close-after-copy, and clipboard concealment move
out of ExportSettings into a new OutputBehavior store. The app-wide
AppSettings creates the only instance; editor sessions reference it instead
of seeding their own, so a window can no longer read a stale default. Make
Default and EditorPreferencesSnapshot keep carrying only the per-capture
output (scale, format, color profile, rich clipboard, text sidecar).

All four image-copy paths (toolbar, File > Copy Image, menu-bar recents,
recents gallery presets) go through RenderedImageCopy, which takes the
config, the per-capture output, and the app-wide behavior. Persisted keys
and values are unchanged, and a regression test covers an editor session
copying with the app-wide conceal and close-after-copy preferences.
@johnny4young
johnny4young marked this pull request as ready for review October 6, 2026 03:50

@johnny4young johnny4young left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review: protect batch artifacts and skipped inputs during preflight

This is stacked on #251, and I reviewed only the delta against fix/app-review-2026-10.

What it does: before any directory is created, the batch now checks that --manifest and --skipped-report don't collide with each other, with any planned image or sidecar, or with the background or watermark resources. It uses canonical, case-folded keys. --no-overwrite now also protects existing reports. Every discovered input, including skipped ones, is protected from sidecars, and the input set is canonicalized once per batch instead of once per output. An existing file inside the input tree may only be replaced if it is a recognized prior report of the same kind (≤ 5 MiB, strict keys and types).

Verdict: ready to merge after #251, then retarget to main and re-run CI.

What I checked

  • Ordering: createDirectory moved after both guards, so a rejected run leaves nothing behind, as the tests assert with !fileExists(output).
  • Report recognition (matchesReportStructure):
    • Manifest entries use synthesized Codable with encodeIfPresent, so planned entries omit width/height, exactly what the validator expects.
    • format comes from ExportFormat, which has only png/pdf/heic/avif.
    • language is Language.rawValue.
    • Empty arrays are accepted, so [] reruns work.
  • Size limit: maximumExistingReportBytes reuses FileInputLoader.maximumByteCount (5 MiB), which matches the README.
  • Symlinks: a symlink alias to a resource is caught through canonicalPath (resolvingSymlinksInPath).
  • CI was green on 2d3132e (all 5 required jobs).

Nits (not blocking)

  • 🟡 plannedOutputs = Array(outputURLs.values) iterates a dictionary, so when several outputs conflict, the path in the error message can differ between runs. Sorting by path would make CI logs reproducible.
  • 🟡 guardOutputsDoNotOverwriteInputs (in CLIOutputWriter) still compares canonicalPath without case folding, while guardReportTargets folds case. On the default case-insensitive APFS, render notes.MD --out notes.png --markdown-sidecar still slips past the input guard. That's pre-existing, but both guards could share filesystemKey.
  • 🟡 Report conflicts throw incompatibleOptions, which with #251 means exit code 2 and the full usage text, for what is really a filesystem conflict. A dedicated CLIError case (exit 1, no usage dump) would read better in CI logs.
  • 🟡 A legitimate manifest larger than 5 MiB stored inside the input tree can't be re-used. That's documented, but a very large recursive batch could hit it.

Generated by Claude Code

claude and others added 6 commits October 6, 2026 19:10
An output or sidecar that would replace an input or image resource is found
against the filesystem, not in the command line, so it now raises
CLIError.outputConflict (exit 1, message only) instead of
incompatibleOptions (exit 2 with the usage text). The claim check folds
case like the default macOS volume, so NOTES.md cannot replace notes.md;
on a case-sensitive volume this fails closed.
Subtitle KVO observations for sign-in popups lived in one controller-wide array
that was only cleared when the main sign-in window closed, so every closed popup
left its observations (and the closures retaining its web view) alive. Each
popup now owns its observations, invalidated when that popup closes.
Apply the case-folded claim key and runtime outputConflict to the batch form of
guardOutputsDoNotOverwriteInputs.
Plan outputs in source-key order instead of dictionary order, so the first
reported conflict is the same on every run, and report a batch report path
that collides with a resource or output as a runtime outputConflict (exit 1)
like the other filesystem conflicts.
…ecipe themes

- Compute the DPI-aware point size from the decoded, budget-bounded bitmap so a
  downsampled foreground image is never laid out larger than its pixels.
- Reuse an earlier re-keyed copy when a recipe theme collides with a different
  local theme, so reapplying the recipe no longer adds a new copy each time.
- Split asciinema casts only on CR, LF, and CRLF so events carrying a raw
  U+2028, U+2029, or NEL are not cut into invalid JSON fragments.
Base automatically changed from fix/app-review-2026-10 to main October 8, 2026 04:51
claude added 3 commits October 8, 2026 04:51
…light

# Conflicts:
#	CHANGELOG.md
#	Tests/CLI/CLIBatchArtifactTests.swift
#	Vitrine/CLI/CLIBatchRenderer.swift
#	Vitrine/CLI/CLIOutputWriter.swift
…sed inputs

- Raise outputConflict (exit 1, no usage text) when a report would replace an
  unrecognized input file, matching every other filesystem conflict.
- Compare report paths with every discovered input using the case-folded claim
  key, so a differently cased report path cannot overwrite a source.
- Refuse a report path that is the output folder, a folder the run creates, or
  a path below a planned image or sidecar before anything is rendered.
- Skip the report preflight when no report was requested, reuse the shared claim
  key, and recognize manifest formats from ExportFormat.
@johnny4young
johnny4young merged commit d97d4e7 into main Oct 8, 2026
6 checks passed
@johnny4young
johnny4young deleted the fix/batch-report-preflight branch October 8, 2026 06:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants