Files
Triple-C/app/src/lib/dropTarget.ts
T
shadow-testandClaude Opus 5 06ccb4d818 Ship the Files tab container-side only
Four successive audits found the same thing: host filesystem paths crossing
IPC is where the criticals in this work live. The most recent one found the
`link(2)` upload reservation returning success against a *directory* (linking
into it, leaving permanent stray files, and via a symlink-to-directory writing
outside the validated write root), failing every upload permanently on any
filesystem without hard links, and the post-resolution credential check
weakened from a general rule to an eleven-name denylist.

Rather than fix that a fifth time, the Files tab ships as what it is good at:
a browser, viewer and renamer that never touches the host.

Removed: `upload_file_to_container`, `download_container_file`, and everything
that existed only for them — the whole reservation (`UPLOAD_RESERVATION_SCRIPT`,
`reserve_upload_destination`, the placeholder rollback, `exec_oneshot_as_within`
which had no other caller), `stream_container_file_to_host`, `ChannelReader`,
`save_to_host`, the download ceiling, and the collision marker with its
frontend contract. On the frontend: the upload button, the pane's
`onDragDropEvent` handler, both "Save to host…" affordances, `uploadPaths` /
`downloadFile` / the overwrite prompt, and `OverwriteConfirmModal`.
`lib/uploadErrors.ts` is now `lib/refusalText.ts` and keeps only the half that
turns any backend refusal into the sentence a person reads.

Kept, and not weakened: `upload_host_file_to_terminal` and
`download_container_backup`. They predate this work, their hardening is a real
improvement over main, and they are now the whole answer to "how do I get a
file in or out" — drop it on the Terminal, or Back up container. The drop gate
(`lib/dropTarget.ts`, `PaneVisibility`) is untouched.

`resolve_host_path` gets the general hidden-component rule back. Round 3
replaced it with `HOST_CREDENTIAL_DIRS`, which is allow-by-omission for the
rest of `$HOME`: `~/.local/bin` (write there and you own the user's next shell
command), `~/.password-store`, browser profiles and `~/.pki/nssdb` were all
reachable through a planted symlink with a visible name — verified against a
real home directory, and all five refused now. It over-catches `.pnpm` and
`~/.cache`; for two occasional callers that is the cheaper mistake, and the
refusal says which folder it resolved through.

Two defects fixed while in here:

  * A symlinked directory listed as empty. `find` defaults to `-P`, which does
    not follow a symlink even as the starting point, so `-mindepth 1` discarded
    the only match and a real directory rendered as "Empty directory" — a
    first-order defect now that browsing *is* the feature. `-H` follows the
    starting point and nothing else, so a loop is `ELOOP` rather than a walk
    that does not end; verified against a live container for a symlinked
    directory, a broken link and a loop. `find`'s errno for the loop case is
    now a sentence.
  * `finish_download`'s replace path fired on *any* rename failure with a
    destination present — a vanished partial, a permission error, a directory
    at the destination — and deleted the user's file to complete a move that
    could not complete. It is now fenced to Windows (where a rename onto an
    existing path genuinely fails) and to a partial that still exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 17:05:56 -07:00

204 lines
8.6 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 is container-side only — 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";
}