Build App (Preview) / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 1m35s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 5m51s
Build App (Preview) / build-linux (pull_request) Successful in 6m50s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
`upload_file_to_container` and `download_container_file` existed on main before
any of this work started. "Ship the Files tab container-side only" removed them
and called it narrowing scope; from a user's side it was a regression they
upgraded into. This restores the feature.
The reason for the removal was real — four consecutive audits found their
criticals in host paths crossing IPC — so the feature comes back only in the
shape that removes the class rather than patching it a fifth time. The dialogs
are opened by **Rust** (`pick_save_path`, `pick_files_to_upload`), not by the
webview. A frontend `open()`/`save()` handing the backend a path string is
exactly what failed, and the backend cannot tell such a string from one a
compromised webview invented. Now the webview can ask for a picker and that is
the whole of its influence: it cannot name a host path as an input. That is the
shape the previous round's own notes named as the honest one if this ever
returned.
None of the machinery the audits condemned returns. No `link(2)` destination
reservation, no placeholder rollback, no collision marker: the OS save dialog
already asks about overwriting and Docker's extractor overwrites on upload the
way `cp` does, so there was nothing left for it to do. Download reuses the
sequence `download_container_backup` has been using unchanged — resolve, stream
into a partial file beside the destination, rename last — so a failed transfer
never touches the file that was already there. Upload reuses the terminal
drop's hardened uploader, with the container's uid/gid resolved once per
selection rather than once per file.
Against a container that is actively hostile rather than merely surprising:
* the read is `dd iflag=nonblock`, not `cat`. `[ -f ]` and the `open` after it
are two syscalls and the container owns the filesystem in between; a loop
swapping the file for a FIFO wins that race, and `cat` then blocks forever
with no writer and no timeout anywhere on the path — the `invoke` never
settles and a partial is left in the user's directory for good. Verified in
a real container that `cat` hangs, that `iflag=nonblock` returns, and that
it is byte-identical on a regular file.
* the read is bracketed by a second `[ -f ]`, because non-blocking turns that
hang into an empty file that would otherwise be renamed over the
destination and reported as a successful save.
* an *undeterminable* exit code is a failure. Backup catches this class with
its `total == 0` check, which download cannot have because an empty file is
a legitimate save; without a replacement, a project restarted mid-download
renames a truncated partial over the user's file and reports the byte count
as if it were whole.
* container stderr is capped. Every other reader of container output in the
tree is capped for this reason; the two streaming commands were the
exception, and stdout was bounded by disk while stderr was bounded by
nothing.
* the script's refusals are framed rather than used verbatim, so a directory
named to look like one of our own sentences cannot become the toast
headline through `readableRefusal`.
* the partial name is capped at NAME_MAX. A bundler's 230-character content
hash is a name that fits its directory and produces a partial name that
does not.
Also: a non-UTF-8 dialog path is refused by name rather than silently mangled
into a different path by U+FFFD substitution; both actions carry in-flight
state, so a second click cannot open a second dialog and a slow save is not
indistinguishable from a dead button; and the upload's completion message names
the directory, since the picker is modal and the user can browse elsewhere
while it is open.
Not restored: drag-and-drop, in either direction. `drag:allow-start-drag` stays
ungranted and `hold/disk-and-dragout` still holds that work.
Two bugs the new tests caught while being written: a double-click on "Save to
host…" opened the file viewer on top of the save dialog, and an N-file upload
made N redundant execs to re-ask `id -u`.
Docs that asserted this feature did not and must not exist are corrected —
CLAUDE.md, README, HOW-TO-USE, TECHNICAL and the capability threat model. The
"no host path crosses IPC" claim is deliberately narrowed to the inbound
direction: paths do still travel outward inside error text, canonical ones
included, and the reviewed record should not overstate.
600 frontend tests, 473 Rust, no new clippy warnings. Every new test was
mutation-checked; four that survived their first mutation were rewritten,
including two whose mutations turned out to be unfaithful and one that was
blind to a dismissal leaving a row stuck on "Saving…".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
205 lines
8.7 KiB
TypeScript
205 lines
8.7 KiB
TypeScript
/**
|
|
* Routing for Tauri's *native* drag-drop event.
|
|
*
|
|
* The listener is window-wide — every pane that wants dropped file paths gets
|
|
* the same event — so the module answers two separate questions, and keeping
|
|
* them separate is the whole design:
|
|
*
|
|
* 1. **Which pane is this drop for?** Geometry, and nothing else: is the
|
|
* payload position inside my rect? A hidden pane is `display:none` and so
|
|
* has a zero-size rect, which is what stops two panes both claiming the
|
|
* same drop. `TerminalView` is the only pane that takes dropped files
|
|
* today — the Files pane copies files through buttons and a backend-opened
|
|
* dialog, not through a drop — but the routing is what keeps it honest when
|
|
* a second one appears.
|
|
* 2. **Should the app accept a drop at all right now?** `dropIsBlocked` —
|
|
* document-wide, no geometry, no z-order. While a modal or a blocking
|
|
* overlay is on screen anywhere, every drop is refused.
|
|
*
|
|
* ## Why there is no z-order test here, and must not be one
|
|
*
|
|
* A drop that lands underneath a dialog and silently uploads into the
|
|
* container behind it is the failure mode that matters: it is
|
|
* invisible, it writes to the container, and the user did not ask for it.
|
|
* Every attempt to be *precise* about which points a dialog covers has gone
|
|
* wrong, twice, in opposite directions:
|
|
*
|
|
* - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing
|
|
* painted here mine?" — refused drops onto anything painted *over* a pane
|
|
* that is not part of it: `TerminalView`'s always-rendered "▼ Following"
|
|
* toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack.
|
|
* Permanent dead zones no user action could clear.
|
|
* - Replacing that with "is a *blocking overlay* painted here?" removed the
|
|
* dead zones and opened a hole instead. `elementFromPoint` returns the
|
|
* topmost painted element, and plenty of things paint above a `z-50` modal
|
|
* backdrop in the same stacking context: `ToastHost` is `z-[60]`, so is
|
|
* `TerminalContextMenu`. A refused drop pushed a toast; the toast then sat
|
|
* over the dialog; the next drop released on that toast was reported as
|
|
* "clear" and landed in the covered directory. The gate armed its own hole.
|
|
*
|
|
* Both bugs are the same mistake: trusting a per-point answer to decide
|
|
* whether the app should be accepting work at all. The document-wide question
|
|
* has no z-index in it, so no future overlay can become a drop hole by being
|
|
* painted high enough, and no chrome can become a dead zone by being painted
|
|
* at all.
|
|
*
|
|
* What it costs: while any dialog is open, drops are refused *everywhere*,
|
|
* including on parts of a pane the dialog does not cover. That is a state the
|
|
* user put the app in deliberately and can leave in one keystroke, the
|
|
* refusal is announced, and nothing is written. It is strictly the better
|
|
* failure.
|
|
*
|
|
* ## jsdom
|
|
*
|
|
* Note for future changes: jsdom implements no layout and has no
|
|
* `elementFromPoint`, which is how the first of those two bugs shipped green
|
|
* through 81 drop tests — the branch was never entered in any of them. This
|
|
* module no longer calls it (`dropTarget.test.ts` asserts that it does not),
|
|
* so the gap can no longer hide a bug here. Anything that reintroduces a
|
|
* geometric z-order test reintroduces the gap as well.
|
|
*/
|
|
|
|
export interface DropPoint {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
/**
|
|
* Anything that swallows drops while it is on screen.
|
|
*
|
|
* `[aria-modal="true"]` is every dialog in the app for free — `ui/Modal` is
|
|
* the only way one is built, and it sets that attribute. `data-blocks-drop`
|
|
* is for full-window overlays that are not dialogs (the shutdown overlay), and
|
|
* `ui/Modal` puts it on its backdrop as well.
|
|
*/
|
|
const BLOCKING_SELECTOR = '[aria-modal="true"],[data-blocks-drop="true"]';
|
|
|
|
/**
|
|
* A blocker inside this is in the DOM but not on screen — `ui/Modal` marks
|
|
* itself `hidden` when the pane that owns it is not the visible one, so a
|
|
* dialog left open in project A stops refusing drops in project B the moment
|
|
* the tab changes.
|
|
*
|
|
* `[hidden]` only, deliberately. `aria-hidden="true"` used to count too, and
|
|
* it is not a visibility statement: it is routinely put on *visible*
|
|
* decorative content (`ui/Modal`'s own ✕ glyph, every `StatusIndicator`
|
|
* dot). An overlay that happened to sit inside such a wrapper would have
|
|
* silently stopped blocking — the exact class of hole this gate exists to
|
|
* close. `ui/Modal` sets `hidden`, the `hidden` attribute, and inline
|
|
* `display:none` together, so nothing in the app depended on the aria half.
|
|
*/
|
|
const OFFSCREEN_SELECTOR = "[hidden]";
|
|
|
|
/** A blocker that is actually painted, rather than merely mounted. */
|
|
function isOnScreen(el: Element): boolean {
|
|
return el.closest(OFFSCREEN_SELECTOR) === null;
|
|
}
|
|
|
|
/** True while a modal or a blocking overlay is on screen *anywhere*. */
|
|
export function dropIsBlocked(doc: Document = document): boolean {
|
|
return Array.from(doc.querySelectorAll(BLOCKING_SELECTOR)).some(isOnScreen);
|
|
}
|
|
|
|
/**
|
|
* What both listeners say when they refuse a drop.
|
|
*
|
|
* `kind: "info"`, so it times out on its own: a drop refused because the user
|
|
* has a dialog open is expected behaviour, not an error, and an error card
|
|
* would sit on screen until dismissed. `dedupeKey` means three refused drops
|
|
* leave one notice rather than a stack of three.
|
|
*/
|
|
export const DROP_BLOCKED_TOAST = {
|
|
kind: "info",
|
|
message: "File drop ignored",
|
|
detail:
|
|
"A dialog or full-window overlay is open, so nothing accepts dropped files. Close it and drop again.",
|
|
dedupeKey: "drop-blocked",
|
|
} as const;
|
|
|
|
export interface DropTargetOptions {
|
|
doc?: Document;
|
|
/** Override the ratio used to convert physical pixels to CSS pixels. */
|
|
devicePixelRatio?: number;
|
|
/**
|
|
* Override the platform question "does this payload arrive in physical
|
|
* pixels?". Tests use it; nothing in the app passes it.
|
|
*/
|
|
physicalPixelPayload?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Whether the drop payload's coordinates are physical device pixels.
|
|
*
|
|
* **Only Windows delivers physical pixels.** `wry`'s WebView2 drag-drop
|
|
* handler reads the point from the OS in device pixels and passes it through
|
|
* (`wry/src/webview2/drag_drop.rs`), while the macOS and GTK backends deliver
|
|
* logical points and `tauri-runtime-wry`'s forwarding does not rescale them.
|
|
* Dividing by `devicePixelRatio` unconditionally therefore halved every drop
|
|
* position on a HiDPI Mac or Linux box, aiming the hit test at a point the
|
|
* user never touched.
|
|
*
|
|
* Verified by reading the wry/tauri sources named above. **Not** verified on a
|
|
* real HiDPI macOS or GTK machine — neither is available here — which is why
|
|
* the divisor is platform-conditional rather than simply deleted: the Windows
|
|
* behaviour is the one covered by tests and by the shipped code path.
|
|
*/
|
|
function payloadIsPhysical(
|
|
view: (Window & typeof globalThis) | null,
|
|
options: DropTargetOptions,
|
|
): boolean {
|
|
if (options.physicalPixelPayload !== undefined) return options.physicalPixelPayload;
|
|
return /windows/i.test(view?.navigator?.userAgent ?? "");
|
|
}
|
|
|
|
/**
|
|
* Why a native drop at `pos` did or did not belong to `el`.
|
|
*
|
|
* - `accept` — it is ours, and the app is in a state to take it.
|
|
* - `blocked` — it was aimed at us, but a modal or a blocking overlay is on
|
|
* screen. Worth *saying* to the user: the drop visibly did nothing.
|
|
* - `elsewhere` — not our drop. Silence is the right response; some other
|
|
* pane's listener may be about to accept it.
|
|
*
|
|
* Geometry is asked **first**, so exactly one pane can ever answer `blocked`
|
|
* for a given drop and the refusal is announced once rather than once per
|
|
* listener.
|
|
*/
|
|
export type DropVerdict = "accept" | "blocked" | "elsewhere";
|
|
|
|
export function classifyDrop(
|
|
el: HTMLElement | null | undefined,
|
|
pos: DropPoint,
|
|
options: DropTargetOptions = {},
|
|
): DropVerdict {
|
|
const doc = options.doc ?? el?.ownerDocument ?? document;
|
|
|
|
const rect = el?.getBoundingClientRect();
|
|
if (!el || !rect || rect.width === 0 || rect.height === 0) return "elsewhere";
|
|
|
|
const view = doc.defaultView;
|
|
const dpr =
|
|
options.devicePixelRatio ??
|
|
(payloadIsPhysical(view, options) ? view?.devicePixelRatio || 1 : 1);
|
|
const x = pos.x / dpr;
|
|
const y = pos.y / dpr;
|
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
|
return "elsewhere";
|
|
}
|
|
|
|
// Whose drop it is has been settled. Whether the app should be taking drops
|
|
// at all is a separate, document-wide question — see the header.
|
|
return dropIsBlocked(doc) ? "blocked" : "accept";
|
|
}
|
|
|
|
/**
|
|
* Whether a native drop at `pos` (physical pixels on Windows, logical
|
|
* elsewhere) belongs to `el`.
|
|
*/
|
|
export function isDropTarget(
|
|
el: HTMLElement | null | undefined,
|
|
pos: DropPoint,
|
|
options: DropTargetOptions = {},
|
|
): boolean {
|
|
return classifyDrop(el, pos, options) === "accept";
|
|
}
|