Give the Files tab back its uploads and downloads
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
This commit is contained in:
2026-08-25 10:00:21 -07:00
co-authored by Claude Opus 5
parent 88ffb4744a
commit 2c9482a67d
18 changed files with 1467 additions and 122 deletions
+34 -19
View File
@@ -79,23 +79,34 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
modals — see "UI conventions" below.
- **Files is container-side only. It does no host filesystem I/O, and must not grow any.**
The tab lists, opens (text and image viewer), renames and creates folders *inside* the
container: `list_container_files`, `read_container_file`, `rename_container_path`,
`create_container_directory`. There is no upload button, no "Save to host…", and no
drop-into-the-pane. Four successive audits found that host filesystem paths crossing IPC
were where the criticals lived — a caller-named host destination for container-controlled
bytes, an arbitrary host source read into the container, a `link(2)` upload reservation
that succeeded against a directory and failed forever on any filesystem without hard
links — so the feature was narrowed rather than fixed a fifth time. If a host path ever
needs to reach this pane again, the honest shape is for the *backend* to drive
`tauri-plugin-dialog`, so no host path arrives over IPC at all.
- **A file gets *in* by being dropped on the Terminal, and *out* through "Back up
container".** Those two are the whole host↔container story, they predate the Files work,
and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent` is Tauri's
native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for *whose*
drop it is, a document-wide `dropIsBlocked` for whether the app should accept one at all;
keep both halves and keep `PaneVisibility`). Backup is
- **The Files pane's host transfers open their dialog from Rust, and that is the whole
design — do not move it back into the webview.** The tab browses, views (text and image),
renames and creates folders inside the container (`list_container_files`,
`read_container_file`, `rename_container_path`, `create_container_directory`), and it
copies single files in and out (`upload_files_to_container`, `download_container_file`).
The second pair call `pick_files_to_upload` / `pick_save_path`, which drive
`tauri-plugin-dialog` from the *backend*: the webview can ask for a picker and that is the
entirety of its influence — it cannot name a host path as an *input*. The claim stops
there and should not be widened: host paths still travel outward in error text, canonical
ones included. What is closed is the direction that produced the criticals.
That shape is not decoration. Four successive audits found that host filesystem paths
crossing IPC were where the criticals lived — a caller-named host destination for
container-controlled bytes, an arbitrary host source read into the container, a `link(2)`
upload reservation that succeeded against a directory and failed forever on any filesystem
without hard links. The feature was removed rather than fixed a fifth time, and it came
back only in the shape that removes the class: a frontend-driven dialog handing Rust a
string is the exact thing that failed, so re-introducing `open()`/`save()` in `FilesTab`
would undo the whole point while looking like a simplification.
None of the reservation machinery came back with it. There is no destination reservation,
no placeholder rollback and no collision marker — the OS save dialog already asks about
overwriting, and Docker's archive extractor overwrites on upload the way `cp` does.
- **Drag-and-drop is still not it.** There is no drop-into-the-Files-pane and no OS
drag-out; the buttons are the gesture. A file also gets *in* by being dropped on the
Terminal, and a whole tree comes *out* through "Back up container" — those two predate the
Files work and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent`
is Tauri's native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for
*whose* drop it is, a document-wide `dropIsBlocked` for whether the app should accept one
at all; keep both halves and keep `PaneVisibility`). Backup is
`file_commands::download_container_backup`.
- **`resolve_host_path` applies the full lexical predicate twice — as written, and again
after canonicalisation.** That includes the general hidden-component rule, which
@@ -103,8 +114,12 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
`~/.local/share` is refused. Do not narrow it back to a list of "credential" directories.
That was tried, and allow-by-omission let `~/.local/bin` (write there and you own the
user's next shell command), `~/.password-store`, browser profiles and `~/.pki/nssdb`
through a planted symlink with a perfectly visible name. The two callers left are
occasional, so over-refusing is the cheaper mistake.
through a planted symlink with a perfectly visible name. Over-refusing is the cheaper
mistake. Note the cost is real and has grown: of the four callers, the Files pane's two
are routine, and their path comes from a dialog — so an over-catch refuses a destination a
person actually chose (`~/.config` is the common one). Accepted, and not a reason to
narrow the rule, because the terminal drop and `download_container_backup` still take
their host path over IPC and this predicate is their only boundary.
- **OS drag-out is not here.** `tauri-plugin-drag`, `stage_container_file_for_drag` and its
host staging directory were held back for separate hardening and live on
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command