Terminal newlines, OAuth callback, Claude Code settings, and the Files tab #30
@@ -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
|
||||
|
||||
+34
-20
@@ -228,7 +228,7 @@ buttons. Below that are six tabs:
|
||||
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
|
||||
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
|
||||
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
|
||||
| **Files** | Browse, view and rename files inside the container — see [Files](#files) for how files get in and out |
|
||||
| **Files** | Browse, view and rename files inside the container, and move files between it and your own machine — see [Files](#files) |
|
||||
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
|
||||
|
||||
### Sessions
|
||||
@@ -351,7 +351,7 @@ it. The sidebar row carries only the two hover controls.
|
||||
| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck |
|
||||
| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab |
|
||||
| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container, upload files into it and save one back out |
|
||||
| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) |
|
||||
| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose |
|
||||
| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first |
|
||||
@@ -1170,8 +1170,8 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
|
||||
|
||||
### Files
|
||||
|
||||
The **Files** tab of Project Home browses inside a running container. It works entirely on the
|
||||
container side — it never reads or writes anything on your own machine. You can:
|
||||
The **Files** tab of Project Home browses inside a running container, and moves files between it
|
||||
and your own machine. You can:
|
||||
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
|
||||
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
|
||||
@@ -1181,32 +1181,46 @@ container side — it never reads or writes anything on your own machine. You ca
|
||||
- **Rename** an entry, from the row's Rename button or by pressing `F2`. A rename never moves a
|
||||
file between folders
|
||||
- **New folder** in the directory on screen
|
||||
- **Upload…**, from the toolbar, to copy files from your machine into the directory on screen
|
||||
- **Save to host…**, from a file's own row, to write that one file out to your machine
|
||||
- **Refresh** the directory listing at any time
|
||||
|
||||
The listing shows file names, sizes, and modification dates, and marks symbolic links.
|
||||
|
||||
#### Getting files in and out
|
||||
|
||||
The Files tab deliberately does **not** copy files between your computer and the container. There
|
||||
are two supported routes, and they are the ones to use:
|
||||
**Upload…** opens a file dialog on your machine, and whatever you choose is copied into the
|
||||
directory currently on screen. Uploaded files arrive owned by you inside the container, not by
|
||||
root. You can pick several files in one dialog; each is handled on its own, so if a folder or an
|
||||
over-sized file is among them, it is named in the message and the rest still arrive. Uploads are
|
||||
capped at **256 MB per file** — for anything larger, mount the folder into the project instead and
|
||||
skip the copying altogether.
|
||||
|
||||
- **To get a file in:** drag it from your desktop and **drop it onto the Terminal tab**. The file
|
||||
is copied into the container and its path is typed into the terminal for you, ready to hand to
|
||||
Claude Code. (You can also drop it onto the terminal's *Following* toggle — the whole pane is a
|
||||
drop target.)
|
||||
- **To get files out:** use **Back up container** in Project Home's **⋯** overflow menu. It writes
|
||||
a `.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose.
|
||||
For a single file, `cat` it in a terminal, or work in a project folder that is mounted from your
|
||||
host in the first place — those files are already on both sides.
|
||||
**Save to host…** does the reverse, for one file: a save dialog opens, you choose where the file
|
||||
goes, and it is written there. The button sits on the file's own row, and only on files. For a
|
||||
whole directory, use **Back up container** in Project Home's **⋯** overflow menu, which writes a
|
||||
`.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose — that
|
||||
is still the right tool for a tree.
|
||||
|
||||
Both routes refuse a destination whose path passes through a hidden folder — anything with a
|
||||
component beginning with `.`, such as `~/.ssh`, `~/.local/bin` or `~/.config`. That rule catches
|
||||
more than it strictly needs to (a path that happens to resolve through `~/.cache` or
|
||||
`node_modules/.pnpm` is refused as well), and the refusal says which folder tripped it. Choose a
|
||||
visible location such as `~/Documents` or `~/Downloads`.
|
||||
Dragging a file from your desktop and **dropping it onto the Terminal tab** works too, and is often
|
||||
the quickest way in when you are already typing: the file is copied into the container and its path
|
||||
is typed into the terminal for you, ready to hand to Claude Code. (The whole terminal pane is a
|
||||
drop target, including its *Following* toggle.) The Files pane itself is not a drop target.
|
||||
|
||||
Both dialogs are opened by Triple-C itself rather than by the page you are looking at. The page
|
||||
cannot name a place on your machine — it can only ask for a dialog — and nothing is read or written
|
||||
until you pick somewhere in it. Closing a dialog without choosing is not an error: nothing happens,
|
||||
and nothing is said about it.
|
||||
|
||||
Every one of these routes refuses a location whose path passes through a hidden folder — anything
|
||||
with a component beginning with `.`, such as `~/.ssh`, `~/.cache` or `~/.local/share` — or a system
|
||||
location, and it checks both the path as written and where it points after any symbolic links. That
|
||||
rule catches more than it strictly needs to, so now and then it will refuse a place you genuinely
|
||||
meant, `~/.config` among them. The refusal is a plain sentence saying so; choose a visible location
|
||||
such as `~/Documents` or `~/Downloads`.
|
||||
|
||||
If you already keep the project in a folder mounted into the container, the simplest answer is
|
||||
usually neither of the above: edit the file on your host and it is already inside.
|
||||
usually none of the above: edit the file on your host and it is already inside.
|
||||
|
||||
### Terminal Rendering
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ This file is the architectural tour: what each subsystem is and why it works the
|
||||
- [Permission Modes](#permission-modes)
|
||||
- [Containers](#containers) — lifecycle, base-image migration, mounts, CA certificates, sibling containers
|
||||
- [Models and Authentication](#models-and-authentication) — backends, model aliases, gateway, shared token
|
||||
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view
|
||||
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view, host file transfers
|
||||
- [Inside a Project](#inside-a-project) — capability tiles, Mission Control, web terminal, speech-to-text
|
||||
- [Key Files](#key-files) · [CSS / Styling Notes](#css--styling-notes) · [Container Image](#container-image)
|
||||
|
||||
@@ -105,7 +105,7 @@ configuration. Per-project configuration lives in the Config tab rather than in
|
||||
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
||||
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||
| **Files** | Browse, view and rename files inside the container. Container-side only: to get a file *in*, drop it on the Terminal tab; to get files *out*, use **Back up container** |
|
||||
| **Files** | Browse, view, rename and create folders inside the container, upload host files into the directory on screen, and save one file back out to the host — see [Host File Transfers](#host-file-transfers). A whole tree still comes out through **Back up container** |
|
||||
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
|
||||
|
||||
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
||||
@@ -442,6 +442,33 @@ per project.
|
||||
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
|
||||
once Playwright is present — not a setup route.
|
||||
|
||||
### Host File Transfers
|
||||
|
||||
Four routes move files across the boundary: **Upload…** and the per-row **Save to host…** in the
|
||||
Files tab, a file dropped onto the Terminal tab, and **Back up container**. All four share one path
|
||||
policy in `commands/file_commands.rs`.
|
||||
|
||||
- **The OS dialogs are opened by Rust, not by the webview.** `upload_files_to_container` and
|
||||
`download_container_file` drive `tauri-plugin-dialog` themselves and take nothing but a project
|
||||
id and a container-side path; `FilesTab.tsx` imports no dialog plugin and `useFileManager`'s
|
||||
`uploadFiles` takes no argument at all. No host path crosses IPC in either direction — the web UI
|
||||
can ask for a dialog, and that is the whole of its influence over where a file comes from or goes.
|
||||
This is a boundary rather than a convention: a dialog the page itself opens is only as trustworthy
|
||||
as the page.
|
||||
- **One policy for every host path.** A source or destination whose path passes through a hidden
|
||||
folder (`~/.ssh`, `~/.cache`, `~/.local/share`, anything dot-prefixed) or a system location is
|
||||
refused, and the check is applied both to the path as written and to what it resolves to after
|
||||
symlinks. It over-catches deliberately, so it will occasionally refuse somewhere a person
|
||||
genuinely meant — `~/.config`, say — and the refusal is a sentence naming the folder that tripped
|
||||
it, not an errno.
|
||||
- **Uploads are capped at 256 MB per file**; past that the answer is a mount, not a copy. One
|
||||
dialog's selection is handled file by file, so a folder or an oversized file among the selection
|
||||
is reported by name and does not stop the others. Uploaded files land owned by the container user,
|
||||
not root. A cancelled dialog is silent — `Ok(None)`, not an error.
|
||||
- **`download_container_file` is one file and files only** — no button on a folder row. A directory
|
||||
is what `download_container_backup` is for. There is no drop target on the Files pane; the
|
||||
Terminal tab keeps the one it has.
|
||||
|
||||
## Inside a Project
|
||||
|
||||
### Container Introspection (Capability Tiles)
|
||||
@@ -513,7 +540,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
||||
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
||||
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) plus **Upload…** and per-row **Save to host…**; imports no dialog plugin — the dialogs are Rust's |
|
||||
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
|
||||
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
|
||||
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
|
||||
@@ -536,7 +563,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
||||
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) |
|
||||
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) and the host transfers (upload, save one file out); never handles a host path |
|
||||
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||
@@ -561,7 +588,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir) plus `download_container_backup` |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir), the host transfers `upload_files_to_container` and `download_container_file` — each opening its own OS dialog here in Rust — plus `download_container_backup`, and the hidden-folder path policy all of them share |
|
||||
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
|
||||
|
||||
+3
-3
@@ -436,7 +436,7 @@ triple-c/
|
||||
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
|
||||
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
|
||||
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||
│ │ ├── useFileManager.ts # File browser operations
|
||||
│ │ ├── useFileManager.ts # File browser operations + host transfers
|
||||
│ │ ├── useInstallHelper.ts # Guided Docker installation
|
||||
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
|
||||
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
|
||||
@@ -464,7 +464,7 @@ triple-c/
|
||||
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
|
||||
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
|
||||
│ │ │ ├── ConfigTab.tsx # Config section host
|
||||
│ │ │ ├── FilesTab.tsx # In-container file browser
|
||||
│ │ │ ├── FilesTab.tsx # In-container file browser, upload / save to host
|
||||
│ │ │ ├── CapabilityTiles.tsx # Read-only capability counts
|
||||
│ │ │ ├── format.ts # Age / size / uptime formatting
|
||||
│ │ │ └── config/ # WorkspaceSection, ModelSection,
|
||||
@@ -504,7 +504,7 @@ triple-c/
|
||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||
│ ├── docker_commands.rs # Docker status, image ops
|
||||
│ ├── file_commands.rs # File browser (browse, view, rename)
|
||||
│ ├── file_commands.rs # File browser + host transfers (Rust-opened dialogs)
|
||||
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -660,13 +660,24 @@ fn is_autorun_dir(names: &[String]) -> bool {
|
||||
|
||||
/// Structural and policy checks on a host path, returning it as a [`PathBuf`].
|
||||
///
|
||||
/// Two callers are left, and both are occasional rather than routine: dropping
|
||||
/// a host file onto the terminal, and "Back up container". Neither is reached
|
||||
/// through a path the webview invented — a `save()`/`open()` dialog stands in
|
||||
/// front of both — but a dialog is a UI convention, not a boundary: every
|
||||
/// command is a single `invoke` away from any code running in the webview, with
|
||||
/// container-controlled bytes on one side of it. So the backend has its own
|
||||
/// policy:
|
||||
/// Four callers, and they no longer share a threat model — which is the thing
|
||||
/// to hold on to when changing any of this:
|
||||
///
|
||||
/// * [`download_container_file`] and [`upload_files_to_container`], the Files
|
||||
/// pane's own transfers, **open their dialog from Rust**. The webview can
|
||||
/// ask for a picker and that is the whole of its influence — it cannot name
|
||||
/// a host path as an input. (It still *sees* host paths in error text, and
|
||||
/// canonical ones at that; the inbound direction is what was closed.) For
|
||||
/// these two this policy is defence in depth.
|
||||
/// * `terminal_commands::upload_host_file_to_terminal` (the terminal drop)
|
||||
/// and [`download_container_backup`] still take a host path *from the
|
||||
/// webview* as a string. A `save()`/`open()` dialog stands in front of both
|
||||
/// in the UI, but a dialog is a UI convention, not a boundary: every
|
||||
/// command is a single `invoke` away from any code running in the webview,
|
||||
/// with container-controlled bytes on one side of it. For those two, this
|
||||
/// policy is the boundary itself.
|
||||
///
|
||||
/// So the backend has its own policy:
|
||||
///
|
||||
/// * absolute, no `..`, no NUL — judged on the path's own components, so a
|
||||
/// Windows path is judged as one wherever this runs;
|
||||
@@ -694,17 +705,29 @@ fn is_autorun_dir(names: &[String]) -> bool {
|
||||
/// denylist of "credential" directories, which is allow-by-omission for the
|
||||
/// whole of the rest of `$HOME`: `~/.local/bin` (a write there is the user's
|
||||
/// next shell command), `~/.password-store`, browser profiles, `~/.pki/nssdb`.
|
||||
/// For two occasional callers, over-refusing is the cheaper mistake, so the
|
||||
/// general rule stands and the refusal says plainly what tripped it.
|
||||
/// Over-refusing is the cheaper mistake, so the general rule stands and the
|
||||
/// refusal says plainly what tripped it. Note that the cost went **up** when
|
||||
/// the Files pane got its transfers back: those callers are routine rather than
|
||||
/// occasional, and because their path comes from a dialog the person is
|
||||
/// choosing a real destination when it is refused. That is a known, accepted
|
||||
/// wart — `~/.config` is not a place this pane will save to — and it is not a
|
||||
/// reason to narrow the rule, because the two IPC-fed callers above are still
|
||||
/// behind it.
|
||||
///
|
||||
/// **The rest of the policy is still a denylist, which is losing by
|
||||
/// construction.** `~/Library/LaunchAgents`, `%AppData%\…\Startup` and `/opt`
|
||||
/// are only refused because someone thought of them; the next persistence
|
||||
/// directory is not. The honest fix is for the *backend* to own the file dialog
|
||||
/// (`tauri-plugin-dialog` can be driven from Rust) so that the only host paths
|
||||
/// these commands accept are ones the user just pointed at, and no path arrives
|
||||
/// over IPC at all. Until then: these lists are defence in depth, and the
|
||||
/// dialog is the boundary.
|
||||
/// directory is not. The honest fix is for the *backend* to own the file
|
||||
/// dialog, so that the only host paths a command accepts are ones the user just
|
||||
/// pointed at and no path arrives over IPC at all — and that fix is **done for
|
||||
/// two of the four callers**: see [`pick_save_path`]. The lists are defence in
|
||||
/// depth there.
|
||||
///
|
||||
/// It is still outstanding for the terminal drop and for
|
||||
/// [`download_container_backup`], where these lists remain the only boundary
|
||||
/// and losing-by-construction is the live risk. Both are worth converting the
|
||||
/// same way; the drop is the harder of the two, because its path comes from an
|
||||
/// OS drag rather than from a dialog Rust could have opened.
|
||||
fn validate_host_path(path: &str, use_for: HostPathUse) -> Result<PathBuf, String> {
|
||||
if path.trim().is_empty() {
|
||||
return Err("No host path was given".to_string());
|
||||
@@ -795,8 +818,8 @@ fn validate_host_path(path: &str, use_for: HostPathUse) -> Result<PathBuf, Strin
|
||||
/// perfectly ordinary-looking name.
|
||||
///
|
||||
/// So the general rule is back, over-catch and all — see [`validate_host_path`]
|
||||
/// for what that costs and why it is the right way round for the two callers
|
||||
/// that are left. This is a thin wrapper rather than a second body so the two
|
||||
/// for what that costs, and for why it is still the right way round now that
|
||||
/// there are four callers rather than two. This is a thin wrapper rather than a second body so the two
|
||||
/// questions cannot drift apart again; the caller supplies the "resolves to"
|
||||
/// context, because on this side the offending component is usually one the
|
||||
/// user never wrote.
|
||||
@@ -932,10 +955,11 @@ pub(crate) fn verify_opened_path(file: &std::fs::File, expected: &Path) -> Resul
|
||||
/// [`resolve_host_path`] for a host file about to be read into a container,
|
||||
/// handed back as a `String`.
|
||||
///
|
||||
/// Public because its one caller lives elsewhere: the terminal's drag-and-drop
|
||||
/// drop target, `terminal_commands::upload_host_file_to_terminal`. That is the
|
||||
/// only way a host file gets into a container now — the Files pane is a
|
||||
/// container-side browser and does no host I/O at all.
|
||||
/// Public because one of its callers lives elsewhere: the terminal's
|
||||
/// drag-and-drop drop target, `terminal_commands::upload_host_file_to_terminal`.
|
||||
/// The other is [`upload_files_to_container`] just below, the Files pane's own
|
||||
/// upload. Both routes apply this same policy, so which one a file arrives by
|
||||
/// does not change what it is allowed to be.
|
||||
pub async fn resolve_host_read_path(path: &str) -> Result<String, String> {
|
||||
Ok(resolve_host_path(path, HostPathUse::Read)
|
||||
.await?
|
||||
@@ -968,8 +992,8 @@ pub(crate) fn host_upload_name(path: &str) -> Result<String, String> {
|
||||
|
||||
/// Where a host write is staged before it becomes the file the user asked for.
|
||||
///
|
||||
/// One caller left: [`download_container_backup`], which is the only command
|
||||
/// that still puts container bytes on the host.
|
||||
/// Two callers: [`download_container_backup`] and [`download_container_file`],
|
||||
/// the two commands that put container bytes on the host.
|
||||
///
|
||||
/// Same directory as the destination, so the last step is a rename within one
|
||||
/// filesystem: atomic, and the destination is not touched *at all* until the
|
||||
@@ -987,12 +1011,32 @@ fn partial_download_path(dest: &Path) -> Result<PathBuf, String> {
|
||||
let name = dest
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("{} does not name a file", dest.display()))?;
|
||||
let mut partial = name.to_os_string();
|
||||
partial.push(format!(
|
||||
let suffix = format!(
|
||||
".triple-c-part-{}",
|
||||
&uuid::Uuid::new_v4().simple().to_string()[..8]
|
||||
));
|
||||
Ok(dest.with_file_name(partial))
|
||||
);
|
||||
// The leaf has to be shortened to make room, or a destination name that fits
|
||||
// its directory perfectly well produces a partial name that does not.
|
||||
// `NAME_MAX` is 255 bytes on ext4, APFS and NTFS alike, and the suffix is
|
||||
// 23 of them.
|
||||
//
|
||||
// This did not matter while the only caller was Backup, whose name a person
|
||||
// types. It matters now that a name can come from the container: a bundler
|
||||
// writing `<230-char content hash>.js` would be unsavable, with the refusal
|
||||
// blaming a temporary name the user never saw. Lossy is fine for a partial
|
||||
// — it is thrown away by the rename, and the uuid is what makes it unique.
|
||||
const NAME_MAX: usize = 255;
|
||||
let budget = NAME_MAX.saturating_sub(suffix.len());
|
||||
let name = name.to_string_lossy();
|
||||
let mut base: &str = &name;
|
||||
if base.len() > budget {
|
||||
let mut end = budget;
|
||||
while end > 0 && !base.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
base = &base[..end];
|
||||
}
|
||||
Ok(dest.with_file_name(format!("{}{}", base, suffix)))
|
||||
}
|
||||
|
||||
/// Move a finished partial archive onto the destination the user chose.
|
||||
@@ -1037,6 +1081,42 @@ async fn finish_download(partial: &Path, dest: &Path) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of a container's stderr is worth keeping to explain a failure.
|
||||
///
|
||||
/// Every other reader of container output in this codebase is capped —
|
||||
/// `MAX_ONESHOT_OUTPUT`, `PROC_NET_OUTPUT_LIMIT` — for the reason named in
|
||||
/// their comments: the source is hostile. The two streaming commands were the
|
||||
/// exception, and their stdout is bounded by the disk it is being written to
|
||||
/// while their stderr was bounded by nothing at all. A container that puts a
|
||||
/// chattier `dd` earlier in `PATH` (it has passwordless sudo, so it can) could
|
||||
/// grow this `String` until the app was killed.
|
||||
///
|
||||
/// A few kilobytes is far more than any real diagnostic and far less than any
|
||||
/// pressure on the process.
|
||||
const MAX_EXEC_STDERR: usize = 8 * 1024;
|
||||
|
||||
/// Append a container's stderr frame, stopping at [`MAX_EXEC_STDERR`].
|
||||
///
|
||||
/// Silently, and deliberately so: this text exists to explain a failure to a
|
||||
/// person, and "(truncated)" in the middle of a `dd` diagnostic explains
|
||||
/// nothing that the first eight kilobytes did not.
|
||||
fn push_capped(buf: &mut String, frame: &[u8]) {
|
||||
if buf.len() >= MAX_EXEC_STDERR {
|
||||
return;
|
||||
}
|
||||
let room = MAX_EXEC_STDERR - buf.len();
|
||||
let text = String::from_utf8_lossy(frame);
|
||||
if text.len() <= room {
|
||||
buf.push_str(&text);
|
||||
return;
|
||||
}
|
||||
let mut end = room;
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
buf.push_str(&text[..end]);
|
||||
}
|
||||
|
||||
/// One regular file's bytes, pulled out of a container.
|
||||
struct FetchedFile {
|
||||
bytes: Vec<u8>,
|
||||
@@ -1308,6 +1388,559 @@ pub async fn create_container_directory(
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Refuse, in a sentence, before a Docker error has to speak for us.
|
||||
///
|
||||
/// Both file transfers and the backup run through `docker exec`, which needs a
|
||||
/// running container. Without this the failure surfaces as bollard's
|
||||
/// `is not running` wrapped in whatever the caller was doing, and for the
|
||||
/// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath`
|
||||
/// is the first thing to touch the container, so a stopped project fails inside
|
||||
/// path *validation* and reads like the path was the problem.
|
||||
async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
let running = docker
|
||||
.inspect_container(container_id, None)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|info| info.state)
|
||||
.and_then(|s| s.running)
|
||||
.unwrap_or(false);
|
||||
if running {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"Start the project before {} — it runs inside the running container.",
|
||||
action
|
||||
))
|
||||
}
|
||||
|
||||
/// Copy one regular file out of a container onto a host path the user chose in
|
||||
/// a save dialog.
|
||||
///
|
||||
/// ## Why this exists again
|
||||
///
|
||||
/// It was removed along with the rest of the Files pane's host I/O, on the
|
||||
/// reasoning that four audits had found their criticals in host paths crossing
|
||||
/// IPC. That reasoning was about the *reservation machinery* — the `link(2)`
|
||||
/// destination reservation, the placeholder rollback, the collision marker —
|
||||
/// and every one of those criticals lived there. Removing the machinery was
|
||||
/// right. Removing the feature with it took away something the app shipped
|
||||
/// before any of this work started, so the user upgraded into a regression.
|
||||
///
|
||||
/// None of the removed machinery comes back. The save dialog already asks about
|
||||
/// overwriting, so there is nothing to reserve and no collision to mark, and
|
||||
/// what is left is the sequence [`download_container_backup`] has been using
|
||||
/// unchanged: resolve the destination, stream into a partial file beside it,
|
||||
/// rename last. The destination the user already had is not touched until the
|
||||
/// transfer has completely succeeded.
|
||||
///
|
||||
/// ## Why `cat` rather than the archive endpoint
|
||||
///
|
||||
/// [`fetch_container_file`] buffers, which is why it takes a mandatory ceiling
|
||||
/// — it serves the viewer, where a cap is the correct behaviour. A download has
|
||||
/// no business refusing a 3 GB file, so this streams instead, and streaming
|
||||
/// means not reassembling a tar in host RAM. `exec_oneshot`'s reader is not
|
||||
/// usable here either: it runs chunks through `String::from_utf8_lossy` and
|
||||
/// merges stderr into stdout, so it would corrupt any non-UTF-8 file and let
|
||||
/// diagnostics splice themselves into content. Attaching to the exec directly
|
||||
/// gives pre-demuxed frames, and only `StdOut` frames are written.
|
||||
///
|
||||
/// The type check runs *inside* the same exec as the `cat`, not as a separate
|
||||
/// round trip, so there is no window between deciding the path is a regular
|
||||
/// file and reading it. `[ -f ]` follows symlinks — downloading through a
|
||||
/// symlink is ordinary and stays allowed — and is false for a directory, a
|
||||
/// device and, importantly, a FIFO: `cat` on one blocks forever with no writer
|
||||
/// and there is no timeout anywhere on this path.
|
||||
///
|
||||
/// Returns the number of bytes written. Zero is a success: an empty file is a
|
||||
/// file. (The backup's `total == 0` check is not copied down here — there it
|
||||
/// means the tar pipeline produced nothing, which is a failure.)
|
||||
#[tauri::command]
|
||||
pub async fn download_container_file(
|
||||
project_id: String,
|
||||
container_path: String,
|
||||
window: tauri::Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<u64>, String> {
|
||||
// Read-only source, so structure is what matters — not the write roots. A
|
||||
// download may legitimately reach image content the panel cannot modify.
|
||||
// Checked before the dialog: there is no point asking the user where to put
|
||||
// something that was never going to be read.
|
||||
validate_container_path("Download", &container_path)?;
|
||||
|
||||
// Before the dialog, matching `upload_files_to_container`: asking someone to
|
||||
// choose a destination and only then telling them the project is stopped is
|
||||
// the wrong order to find that out in. The window this opens — the project
|
||||
// being stopped *during* the dialog — costs nothing, because the exec then
|
||||
// fails with a Docker error and no host file has been touched.
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
require_running(container_id, "saving a file to the host").await?;
|
||||
|
||||
let Some(chosen) = pick_save_path(&window, suggested_save_name(&container_path)).await else {
|
||||
// Dismissed. Not an error, and deliberately distinguishable from one:
|
||||
// the frontend shows nothing at all rather than a "cancelled" toast.
|
||||
return Ok(None);
|
||||
};
|
||||
let chosen = host_path_string(chosen)?;
|
||||
// Still validated, even though the path came from an OS dialog rather than
|
||||
// over IPC. It is no longer the only thing standing between a compromised
|
||||
// webview and an arbitrary host write — that is what moving the dialog into
|
||||
// Rust bought — but the rule is cheap and the two host-path commands
|
||||
// agreeing about what is writable is worth more than the few refusals it
|
||||
// costs. See the note on `pick_save_path` about which ones those are.
|
||||
let dest = resolve_host_path(&chosen, HostPathUse::Write).await?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
|
||||
// `$TC_SRC`, never argv interpolation: the path reaches the shell as an
|
||||
// environment value, so a name containing a quote or a `$` is data.
|
||||
//
|
||||
// Three things here are load-bearing against a container that is actively
|
||||
// hostile rather than merely surprising:
|
||||
//
|
||||
// * `dd iflag=nonblock` rather than `cat`. `[ -f ]` and the `open` that
|
||||
// follows it are two syscalls, and the container owns the filesystem in
|
||||
// between — a loop replacing the file with a FIFO wins that race often
|
||||
// enough to matter. `cat` then blocks in `open(2)` forever with no
|
||||
// writer, and there is no timeout anywhere on this path: the `invoke`
|
||||
// never settles and a partial file is left in the user's directory for
|
||||
// good. `O_NONBLOCK` makes that open return instead of hang. On a
|
||||
// regular file the flag is ignored by the kernel, so this is
|
||||
// byte-for-byte what `cat` did — verified against a real container.
|
||||
// * the second `[ -f ]`, *after* the read. Non-blocking turns the hang
|
||||
// into an empty file that would otherwise be renamed over the user's
|
||||
// destination and reported as a successful save. Bracketing the read
|
||||
// means the adversarial case ends as a refusal, which deletes the
|
||||
// partial and leaves the destination alone.
|
||||
// * `dd` is not `exec`-ed, because a replaced shell cannot run the check
|
||||
// that follows it.
|
||||
//
|
||||
// The bracket can also fire honestly — a file deleted or replaced mid-read
|
||||
// — and reporting that as a failure is the right answer, since the bytes on
|
||||
// their way to disk are then a mix of two files.
|
||||
let script = r#"if [ -d "$TC_SRC" ]; then
|
||||
echo "$TC_SRC is a folder — save its files individually." >&2
|
||||
exit 3
|
||||
fi
|
||||
if [ ! -f "$TC_SRC" ]; then
|
||||
echo "$TC_SRC is not a regular file." >&2
|
||||
exit 4
|
||||
fi
|
||||
dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5
|
||||
if [ ! -f "$TC_SRC" ]; then
|
||||
echo "$TC_SRC changed while it was being read — nothing was saved." >&2
|
||||
exit 6
|
||||
fi"#;
|
||||
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
cmd: Some(vec!["sh".to_string(), "-c".to_string(), script.to_string()]),
|
||||
env: Some(vec![format!("TC_SRC={}", container_path)]),
|
||||
user: Some("claude".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start download: {}", e))?;
|
||||
|
||||
let result = docker
|
||||
.start_exec(&exec.id, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start download: {}", e))?;
|
||||
|
||||
let mut output = match result {
|
||||
StartExecResults::Attached { output, .. } => output,
|
||||
StartExecResults::Detached => return Err("Download exec started detached".to_string()),
|
||||
};
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let partial = partial_download_path(&dest)?;
|
||||
// Same open-and-confirm as the backup: `create_new` is `O_EXCL` so the
|
||||
// final component cannot be a symlink, and `verify_opened_path` catches a
|
||||
// directory on the way having been swapped since it was resolved. The
|
||||
// `created` flag separates "nothing was made" from "it is ours to remove".
|
||||
let open_at = partial.clone();
|
||||
let created = Arc::new(AtomicBool::new(false));
|
||||
let opened = {
|
||||
let created = Arc::clone(&created);
|
||||
tokio::task::spawn_blocking(move || -> Result<std::fs::File, String> {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&open_at)
|
||||
.map_err(|e| format!("Failed to create {}: {}", open_at.display(), e))?;
|
||||
created.store(true, Ordering::SeqCst);
|
||||
verify_opened_path(&file, &open_at)?;
|
||||
Ok(file)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Download task panicked: {}", e))?
|
||||
};
|
||||
let file = match opened {
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
if created.load(Ordering::SeqCst) {
|
||||
let _ = tokio::fs::remove_file(&partial).await;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let file = tokio::fs::File::from_std(file);
|
||||
let mut writer = tokio::io::BufWriter::new(file);
|
||||
let mut total: u64 = 0;
|
||||
let mut stderr_text = String::new();
|
||||
let mut stream_err: Option<String> = None;
|
||||
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
if let Err(e) = writer.write_all(&message).await {
|
||||
stream_err = Some(format!("Failed to write {}: {}", dest.display(), e));
|
||||
break;
|
||||
}
|
||||
total += message.len() as u64;
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
push_capped(&mut stderr_text, &message);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
stream_err = Some(format!("Download stream error: {}", e));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if stream_err.is_none() {
|
||||
if let Err(e) = writer.flush().await {
|
||||
stream_err = Some(format!("Failed to finalize {}: {}", dest.display(), e));
|
||||
}
|
||||
}
|
||||
drop(writer);
|
||||
|
||||
// The read can be killed part-way through and still have sent bytes, so the
|
||||
// exit code decides — not `total`.
|
||||
//
|
||||
// `!= Some(0)`, not `is_some_and(|c| c != 0)`. An *undeterminable* exit code
|
||||
// is a failure here, and the difference is not academic: the backup catches
|
||||
// this class with its `total == 0` check, which this command deliberately
|
||||
// does not have because an empty file is a legitimate save. Restart a
|
||||
// project mid-download and the exec is torn down — the attached stream ends
|
||||
// at EOF rather than in an `Err`, and the exec instance is purged, so
|
||||
// `inspect_exec` can no longer say how it ended. Reading that silence as
|
||||
// success renames a truncated partial over the file the user already had
|
||||
// and reports the byte count as if it were the whole thing. That is the one
|
||||
// way this command could destroy data, so silence is failure.
|
||||
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
|
||||
if stream_err.is_none() && exit_code != Some(0) {
|
||||
// Framed, never verbatim. The script's own refusals are finished
|
||||
// sentences, but they *quote the container's path* — and a directory
|
||||
// can be named anything. `readableRefusal` on the frontend promotes a
|
||||
// refusal that looks like one of ours to the toast headline, so an
|
||||
// unframed string is an invitation to write the app's own error message
|
||||
// from inside the container. The frame does not make the text
|
||||
// trustworthy; it makes it visibly quoted.
|
||||
let detail = stderr_text.trim();
|
||||
stream_err = Some(match exit_code {
|
||||
None => format!(
|
||||
"Could not confirm that {} was read completely — nothing was saved.",
|
||||
container_path
|
||||
),
|
||||
Some(code) if detail.is_empty() => {
|
||||
format!("Could not read {} (exit {})", container_path, code)
|
||||
}
|
||||
Some(code) => format!("Could not read {} (exit {}): {}", container_path, code, detail),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(err) = stream_err {
|
||||
// Only our own partial is removed. Whatever the user already had at
|
||||
// `dest` has not been touched at any point above.
|
||||
let _ = tokio::fs::remove_file(&partial).await;
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if let Err(e) = finish_download(&partial, &dest).await {
|
||||
let _ = tokio::fs::remove_file(&partial).await;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Saved {} from project {} to {} ({} bytes)",
|
||||
container_path,
|
||||
project_id,
|
||||
dest.display(),
|
||||
total
|
||||
);
|
||||
Ok(Some(total))
|
||||
}
|
||||
|
||||
/// What one upload dialog's worth of files did.
|
||||
///
|
||||
/// Both halves are needed because one dialog can select several files and they
|
||||
/// do not have to agree: a folder among the selection, or one file over the
|
||||
/// size ceiling, must not cost the user the files either side of it. The
|
||||
/// failures are finished sentences, ready to show.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct UploadOutcome {
|
||||
/// In-container paths, in the order they landed.
|
||||
pub uploaded: Vec<String>,
|
||||
/// One sentence per file that did not.
|
||||
pub failures: Vec<String>,
|
||||
}
|
||||
|
||||
/// Copy host files the user picked in an open dialog into the container
|
||||
/// directory the Files pane is showing.
|
||||
///
|
||||
/// `Ok(None)` means the picker was dismissed, which is not a failure and is
|
||||
/// deliberately distinguishable from one. Otherwise the [`UploadOutcome`]
|
||||
/// says what each selected file did — see there for why that is two lists.
|
||||
///
|
||||
/// The other half of the restoration described on [`download_container_file`],
|
||||
/// and the same rule: nothing that was removed comes back. In particular there
|
||||
/// is no destination reservation. The audit that ended this feature found the
|
||||
/// `link(2)` reservation returning success against a *directory* — linking into
|
||||
/// it, leaving permanent stray files, and through a symlinked directory writing
|
||||
/// outside the validated root — and failing permanently on any filesystem
|
||||
/// without hard links. What it was defending against was a name collision, and
|
||||
/// Docker's archive extractor overwrites on collision the same way `cp` does,
|
||||
/// which is what a file manager's upload is expected to do.
|
||||
///
|
||||
/// Every step here already existed and is already hardened; this command is the
|
||||
/// wiring, not new machinery:
|
||||
///
|
||||
/// * the name comes from the path **as the user gave it**, before resolution
|
||||
/// ([`host_upload_name`]) — `~/Downloads/latest.log` is routinely a symlink,
|
||||
/// and taking the leaf off the resolved path renames the file on its way in;
|
||||
/// * the bytes come from [`resolve_host_read_path`], i.e. the host-read policy
|
||||
/// applied to the path with its symlinks resolved, so a visible directory
|
||||
/// that *leads* to `~/.ssh` is refused;
|
||||
/// * the destination goes through [`resolve_container_dir`], so it is inside
|
||||
/// [`CONTAINER_WRITE_ROOTS`] both as written and as it resolves;
|
||||
/// * the read, the size ceiling and the descriptor check are
|
||||
/// `docker::exec::upload_host_file_with_ids`', unchanged — including
|
||||
/// landing the file owned by the container user rather than root.
|
||||
///
|
||||
/// The dialog itself is opened from Rust ([`pick_files_to_upload`]), which is
|
||||
/// the whole reason this shape is acceptable at all.
|
||||
#[tauri::command]
|
||||
pub async fn upload_files_to_container(
|
||||
project_id: String,
|
||||
container_dir: String,
|
||||
window: tauri::Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<UploadOutcome>, String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
// Both checks before the dialog, so a project that cannot receive files
|
||||
// says so instead of asking the user to choose some first.
|
||||
require_running(container_id, "uploading files").await?;
|
||||
resolve_container_dir(container_id, "Upload", &container_dir).await?;
|
||||
|
||||
let Some(picked) = pick_files_to_upload(&window).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Once for the whole selection, not once per file — see
|
||||
// `upload_host_file_with_ids`.
|
||||
let ids = crate::docker::exec::container_user_ids(container_id).await;
|
||||
|
||||
let mut outcome = UploadOutcome {
|
||||
uploaded: Vec::new(),
|
||||
failures: Vec::new(),
|
||||
};
|
||||
for path in picked {
|
||||
let path = match host_path_string(path) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
outcome.failures.push(e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match upload_one(container_id, &path, &container_dir, ids).await {
|
||||
Ok(dest) => {
|
||||
log::info!("Uploaded {} into project {} at {}", path, project_id, dest);
|
||||
outcome.uploaded.push(dest);
|
||||
}
|
||||
Err(e) => outcome.failures.push(e),
|
||||
}
|
||||
}
|
||||
Ok(Some(outcome))
|
||||
}
|
||||
|
||||
/// One file of an upload selection. Every refusal it returns is a sentence
|
||||
/// naming the file, because the caller may be reporting several at once and
|
||||
/// "is a folder" on its own does not say which one.
|
||||
async fn upload_one(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
container_dir: &str,
|
||||
ids: (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
let name = host_upload_name(host_path)?;
|
||||
let resolved = resolve_host_read_path(host_path).await?;
|
||||
|
||||
let meta = tokio::fs::metadata(&resolved)
|
||||
.await
|
||||
.map_err(|e| format!("Cannot access {}: {}", resolved, e))?;
|
||||
// `!is_file()`, not `!is_dir()` — the same reasoning as the terminal drop.
|
||||
// A FIFO is neither a directory nor a regular file, reports `len() == 0`,
|
||||
// and `File::open` on one blocks forever with no writer and no timeout.
|
||||
if !meta.is_file() {
|
||||
return Err(if meta.is_dir() {
|
||||
format!("{} is a folder — upload its files individually.", host_path)
|
||||
} else {
|
||||
format!("{} is not a regular file — only ordinary files can be uploaded.", host_path)
|
||||
});
|
||||
}
|
||||
// The ceiling is enforced against the open descriptor inside the uploader;
|
||||
// this copy of it exists so the refusal arrives as a sentence instead of
|
||||
// after a 300 MB read.
|
||||
use crate::docker::exec::MAX_DROP_BYTES;
|
||||
if meta.len() > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"{} is too large to upload ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
||||
host_path,
|
||||
meta.len() as f64 / (1024.0 * 1024.0),
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
|
||||
crate::docker::exec::upload_host_file_with_ids(
|
||||
container_id,
|
||||
&resolved,
|
||||
container_dir,
|
||||
&name,
|
||||
ids,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The path a dialog handed back, as a `String`, or a refusal.
|
||||
///
|
||||
/// Every host-path check in this module is `&str`-based, so a `PathBuf` has to
|
||||
/// become one somewhere. `to_string_lossy` is the wrong way to do it: invalid
|
||||
/// bytes become U+FFFD, which is a *different path*. For an upload that reads
|
||||
/// as "Cannot access …" on a file the user demonstrably just picked; for a save
|
||||
/// it is worse, because the write would go somewhere other than the file the
|
||||
/// dialog had just asked them to confirm overwriting.
|
||||
///
|
||||
/// Legacy Latin-1 filenames on Linux are the realistic way to meet this. Since
|
||||
/// nothing downstream can handle such a path honestly, it is refused by name
|
||||
/// rather than silently changed into another one.
|
||||
fn host_path_string(path: PathBuf) -> Result<String, String> {
|
||||
path.into_os_string().into_string().map_err(|bad| {
|
||||
format!(
|
||||
"{} is not valid Unicode — Triple-C cannot transfer a file whose path it cannot spell exactly. Rename it, or move it somewhere with an ASCII name.",
|
||||
PathBuf::from(bad).display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The name the save dialog opens with.
|
||||
///
|
||||
/// `unwrap_or` is not enough on its own: `rsplit` on a path with a trailing
|
||||
/// separator yields `Some("")`, so the fallback never fires and the dialog
|
||||
/// opens with a blank name for the user to fill in from nothing. `filter` is
|
||||
/// what makes the fallback reachable.
|
||||
fn suggested_save_name(container_path: &str) -> &str {
|
||||
container_path
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.filter(|leaf| !leaf.is_empty())
|
||||
.unwrap_or("download")
|
||||
}
|
||||
|
||||
/// Ask the user where to save, from Rust.
|
||||
///
|
||||
/// ## Why the dialog is here and not in the webview
|
||||
///
|
||||
/// This is the shape the previous round's threat model asked for by name. The
|
||||
/// frontend used to call `@tauri-apps/plugin-dialog` and hand the chosen path
|
||||
/// back over IPC as a string — at which point the backend has no way to tell a
|
||||
/// path a person picked from a path a compromised webview invented, and
|
||||
/// `resolve_host_path` is the *only* thing between `invoke` and an arbitrary
|
||||
/// host write. Four audits' worth of criticals lived in that gap.
|
||||
///
|
||||
/// Driving the dialog from Rust closes it structurally: the webview can ask for
|
||||
/// a save dialog, and that is the whole of its influence. It cannot name the
|
||||
/// destination, and it cannot proceed without a person choosing one.
|
||||
///
|
||||
/// ## What is still validated, and what that costs
|
||||
///
|
||||
/// The chosen path still goes through `resolve_host_path`, whose hidden-
|
||||
/// component rule deliberately over-catches (see [`validate_host_path`]). That
|
||||
/// rule was written for adversarial input, and against a *dialog* it will
|
||||
/// occasionally refuse something a person meant — saving into `~/.config`, or
|
||||
/// picking a file out of `~/.cache`. It is kept anyway: the refusal is a clear
|
||||
/// sentence, the destinations it costs are unusual ones for this pane, and
|
||||
/// having the two host-path commands disagree about what is writable is a worse
|
||||
/// failure than an occasional "choose somewhere else".
|
||||
///
|
||||
/// `None` means dismissed, which is not a failure. The `oneshot` is used rather
|
||||
/// than `blocking_save_file` because the blocking variants deadlock if they
|
||||
/// ever reach the main thread, and "which thread does this command run on" is
|
||||
/// not a property worth depending on.
|
||||
async fn pick_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
window
|
||||
.dialog()
|
||||
.file()
|
||||
.set_parent(window)
|
||||
.set_title("Save to host")
|
||||
.set_file_name(suggested)
|
||||
.save_file(move |picked| {
|
||||
let _ = tx.send(picked);
|
||||
});
|
||||
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||
}
|
||||
|
||||
/// Ask the user which host files to upload, from Rust. See [`pick_save_path`]
|
||||
/// for why the dialog lives on this side.
|
||||
async fn pick_files_to_upload(window: &tauri::Window) -> Option<Vec<PathBuf>> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
window
|
||||
.dialog()
|
||||
.file()
|
||||
.set_parent(window)
|
||||
.set_title("Upload to container")
|
||||
.pick_files(move |picked| {
|
||||
let _ = tx.send(picked);
|
||||
});
|
||||
let picked: Vec<PathBuf> = rx
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?
|
||||
.into_iter()
|
||||
.filter_map(|p| p.into_path().ok())
|
||||
.collect();
|
||||
// An empty selection is a dismissal as far as the caller is concerned —
|
||||
// there is nothing to report and nothing to refresh.
|
||||
(!picked.is_empty()).then_some(picked)
|
||||
}
|
||||
|
||||
/// Create a `.tar.gz` backup of the container and stream it to a host file.
|
||||
/// The archive contains:
|
||||
/// - the workspace (default /workspace), minus regenerable build artifacts
|
||||
@@ -1346,18 +1979,7 @@ pub async fn download_container_backup(
|
||||
|
||||
let docker = get_docker()?;
|
||||
|
||||
// The backup runs inside the container via `docker exec`, which requires it
|
||||
// to be running. Fail with a clear message rather than a raw Docker error.
|
||||
let running = docker
|
||||
.inspect_container(container_id, None)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|info| info.state)
|
||||
.and_then(|s| s.running)
|
||||
.unwrap_or(false);
|
||||
if !running {
|
||||
return Err("Start the project before backing up — the backup runs inside the running container.".to_string());
|
||||
}
|
||||
require_running(container_id, "backing up").await?;
|
||||
|
||||
let path = container_path.unwrap_or_else(|| "/workspace".to_string());
|
||||
// Read-only source: `tar -C` it, so absoluteness and `..` are what matter.
|
||||
@@ -1485,7 +2107,7 @@ tar czf - --ignore-failed-read \
|
||||
total += message.len() as u64;
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
stderr_text.push_str(&String::from_utf8_lossy(&message));
|
||||
push_capped(&mut stderr_text, &message);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
@@ -1555,6 +2177,109 @@ tar czf - --ignore-failed-read \
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A destination name that fits its directory must not become a partial
|
||||
/// name that does not. The leaf can come from the *container* now — a
|
||||
/// bundler's content-hashed chunk name is routinely 200+ characters — so
|
||||
/// this is reachable without anyone typing anything unusual.
|
||||
#[test]
|
||||
fn a_partial_name_stays_within_name_max() {
|
||||
let long = "x".repeat(250);
|
||||
let partial = partial_download_path(Path::new(&format!("/home/j/{}", long))).unwrap();
|
||||
let leaf = partial.file_name().unwrap().to_string_lossy().to_string();
|
||||
assert!(leaf.len() <= 255, "partial leaf was {} bytes", leaf.len());
|
||||
assert!(leaf.contains(".triple-c-part-"));
|
||||
// Same directory as the destination — the last step has to be a rename
|
||||
// within one filesystem.
|
||||
assert_eq!(partial.parent(), Some(Path::new("/home/j")));
|
||||
}
|
||||
|
||||
/// Truncation must not split a character in half, or the partial cannot be
|
||||
/// spelled back on a filesystem that validates encoding.
|
||||
#[test]
|
||||
fn a_partial_name_truncates_on_a_character_boundary() {
|
||||
// 3 bytes each, deliberately: the budget is 232, which 3 does not
|
||||
// divide, so a naive byte slice lands mid-character. A 2-byte character
|
||||
// would divide it evenly and the test would pass without the boundary
|
||||
// walk existing at all — which is exactly what it did on the first
|
||||
// attempt.
|
||||
let long = "日".repeat(250);
|
||||
let partial = partial_download_path(Path::new(&format!("/home/j/{}", long))).unwrap();
|
||||
let leaf = partial.file_name().unwrap().to_string_lossy().to_string();
|
||||
assert!(leaf.len() <= 255);
|
||||
// The kept part must be a genuine prefix of the name, whole characters
|
||||
// only — which rules out both a mid-character cut and the other way of
|
||||
// "not splitting a character", throwing the whole name away.
|
||||
let kept = leaf.split(".triple-c-part-").next().unwrap();
|
||||
assert!(!kept.is_empty(), "the whole name was discarded");
|
||||
assert!(long.starts_with(kept), "kept part is not a prefix of the name");
|
||||
assert_eq!(kept.len() % 3, 0, "cut landed mid-character");
|
||||
}
|
||||
|
||||
/// An ordinary name is left exactly as it is — the cap must not be paid by
|
||||
/// every download.
|
||||
#[test]
|
||||
fn an_ordinary_partial_name_keeps_the_whole_leaf() {
|
||||
let partial = partial_download_path(Path::new("/home/j/notes.txt")).unwrap();
|
||||
let leaf = partial.file_name().unwrap().to_string_lossy().to_string();
|
||||
assert!(leaf.starts_with("notes.txt.triple-c-part-"));
|
||||
}
|
||||
|
||||
/// The container's stderr is attacker-controlled and unbounded at the
|
||||
/// source; it must be bounded here.
|
||||
#[test]
|
||||
fn container_stderr_stops_growing_at_the_cap() {
|
||||
let mut buf = String::new();
|
||||
for _ in 0..1000 {
|
||||
push_capped(&mut buf, &b"x".repeat(1024));
|
||||
}
|
||||
assert!(buf.len() <= MAX_EXEC_STDERR, "grew to {}", buf.len());
|
||||
// …and it is not simply empty: the point is to explain a failure.
|
||||
assert!(!buf.is_empty());
|
||||
}
|
||||
|
||||
/// Capping must not split a character either — the text goes into an error
|
||||
/// message that is rendered.
|
||||
#[test]
|
||||
fn capping_stderr_truncates_on_a_character_boundary() {
|
||||
// 3 bytes each, and the cap is not a multiple of 3, so a naive byte
|
||||
// slice lands mid-character.
|
||||
let text = "日".repeat(MAX_EXEC_STDERR);
|
||||
let mut buf = String::new();
|
||||
push_capped(&mut buf, text.as_bytes());
|
||||
assert!(buf.len() <= MAX_EXEC_STDERR);
|
||||
assert!(!buf.is_empty(), "the whole diagnostic was discarded");
|
||||
assert!(text.starts_with(&buf), "kept part is not a prefix");
|
||||
assert_eq!(buf.len() % 3, 0, "cut landed mid-character");
|
||||
}
|
||||
|
||||
/// A path a dialog produced that cannot be spelled exactly is refused, not
|
||||
/// quietly turned into a different path by U+FFFD substitution.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_non_unicode_dialog_path_is_refused_rather_than_mangled() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let bad = PathBuf::from(std::ffi::OsString::from_vec(b"/home/j/caf\xe9.txt".to_vec()));
|
||||
let err = host_path_string(bad).unwrap_err();
|
||||
assert!(err.contains("not valid Unicode"), "{}", err);
|
||||
// An ordinary path still comes back untouched.
|
||||
assert_eq!(
|
||||
host_path_string(PathBuf::from("/home/j/café.txt")).unwrap(),
|
||||
"/home/j/café.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_dialog_always_opens_with_a_name() {
|
||||
assert_eq!(suggested_save_name("/workspace/notes.txt"), "notes.txt");
|
||||
assert_eq!(suggested_save_name("/notes.txt"), "notes.txt");
|
||||
// A trailing separator is the case `unwrap_or` alone gets wrong: the
|
||||
// leaf is `Some("")`, so the fallback is never consulted and the dialog
|
||||
// opens blank.
|
||||
assert_eq!(suggested_save_name("/workspace/logs/"), "download");
|
||||
assert_eq!(suggested_save_name("/"), "download");
|
||||
assert_eq!(suggested_save_name(""), "download");
|
||||
}
|
||||
|
||||
/// A record as `find -printf '%y\t%Y\t%s\t%T@\t%m\t%f\0'` emits it —
|
||||
/// fields first, name last, NUL-terminated.
|
||||
fn line(name: &str, own: &str, deref: &str, size: &str) -> String {
|
||||
@@ -2120,8 +2845,9 @@ mod tests {
|
||||
// denylist to buy those cases back, and the denylist let
|
||||
// `~/.local/bin` and `~/.password-store` straight through.
|
||||
//
|
||||
// These two callers — the terminal drop and Backup — are occasional
|
||||
// rather than routine, so paying the over-catch is the right way round.
|
||||
// Two of the four callers — the terminal drop and Backup — take their
|
||||
// host path from the webview, so this rule is their only boundary and
|
||||
// paying the over-catch is the right way round.
|
||||
// What the refusal must not be is mysterious: it says which component
|
||||
// is hidden and that the path *resolves* through it.
|
||||
let root = std::env::temp_dir()
|
||||
|
||||
@@ -202,8 +202,11 @@ pub async fn upload_host_file_to_terminal(
|
||||
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
|
||||
// the path with its symlinks already resolved, so a visible directory that
|
||||
// *leads* to one of those is refused too. What comes back is that resolved
|
||||
// path, and it is what gets opened. This is now one of only two commands
|
||||
// that touch a host path at all; the other is `download_container_backup`.
|
||||
// path, and it is what gets opened. Four commands touch a host path now,
|
||||
// but only two take it *over IPC*: this one and `download_container_backup`.
|
||||
// The Files pane's `download_container_file` and `upload_files_to_container`
|
||||
// open their dialog from Rust instead, so for them the policy above is
|
||||
// defence in depth and for these two it is the boundary itself.
|
||||
// The name is taken from the path the user actually dropped, *before*
|
||||
// resolution. Deriving it from the resolved path renames the file behind
|
||||
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
|
||||
@@ -222,8 +225,9 @@ pub async fn upload_host_file_to_terminal(
|
||||
// with no writer, with no timeout anywhere on this path. The upload then
|
||||
// never returns, the toast sticks on "Adding N files…" for the session and
|
||||
// the rest of the batch is abandoned. Sockets and device nodes are the same
|
||||
// shape. With the Files tab's upload removed, this is the only route for
|
||||
// getting a file into a container, so it is the wrong place to be clever.
|
||||
// shape. This is one of two routes for getting a host file into a
|
||||
// container (the Files pane's upload is the other), so it is the wrong
|
||||
// place to be clever.
|
||||
if !meta.is_file() {
|
||||
return Err(if meta.is_dir() {
|
||||
format!("{} is a directory — drop individual files", host_path)
|
||||
@@ -260,7 +264,13 @@ pub async fn upload_host_file_to_terminal(
|
||||
.await?;
|
||||
|
||||
let file_name = format!("triple-c-drops/{}", base);
|
||||
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
|
||||
crate::docker::exec::upload_host_file_to_container(
|
||||
&container_id,
|
||||
&host_path,
|
||||
"/tmp",
|
||||
&file_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -330,29 +330,58 @@ impl ExecSessionManager {
|
||||
/// meant a moment earlier.
|
||||
pub const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
|
||||
/// Upload a host file into `dest_dir` under `dest_name`. The file is
|
||||
/// read and packed into the tar inside a blocking task, so the synchronous IO
|
||||
/// runs off the async worker. The tar's declared entry size is taken from the
|
||||
/// bytes actually read (not a separate `stat`), so a file changing size between
|
||||
/// a size check and the read can't desync the header and corrupt the archive.
|
||||
/// Returns the in-container path (`/tmp/<dest_name>`).
|
||||
/// Returns the in-container path (`<dest_dir>/<dest_name>`).
|
||||
///
|
||||
/// `dest_dir` must already exist and must already have been checked by the
|
||||
/// caller — Docker's archive extractor writes wherever it is pointed. The two
|
||||
/// callers both do that first, by different routes because they are answering
|
||||
/// different questions: the terminal drop stages into a fixed `/tmp` path it
|
||||
/// creates itself, and the Files pane passes the directory the user is looking
|
||||
/// at, which `file_commands::resolve_container_dir` has already confirmed
|
||||
/// resolves inside `CONTAINER_WRITE_ROOTS`.
|
||||
pub async fn upload_host_file_to_container(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
) -> Result<String, String> {
|
||||
let ids = container_user_ids(container_id).await;
|
||||
upload_host_file_with_ids(container_id, host_path, dest_dir, dest_name, ids).await
|
||||
}
|
||||
|
||||
/// [`upload_host_file_to_container`] for a caller that already knows the
|
||||
/// container user's ids.
|
||||
///
|
||||
/// `container_user_ids` is a `docker exec`, and the Files pane's upload is a
|
||||
/// *selection* — one dialog can hand back twenty files. Resolving the ids per
|
||||
/// file made twenty extra round trips to answer the same `id -u` twenty times,
|
||||
/// which is seconds of latency for a fact that cannot change inside one
|
||||
/// container's lifetime. So the loop resolves once and passes the answer in.
|
||||
/// The wrapper above keeps the single-file callers unchanged.
|
||||
pub async fn upload_host_file_with_ids(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
(uid, gid): (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
let host_path = host_path.to_string();
|
||||
let dest_name = dest_name.to_string();
|
||||
let dest_for_blk = dest_name.clone();
|
||||
let (uid, gid) = container_user_ids(container_id).await;
|
||||
let mtime = now_epoch_secs();
|
||||
|
||||
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
// The caller resolved this path (`resolve_host_read_path`); opening it
|
||||
// is a second trip through the same directories, so the descriptor is
|
||||
// checked against the path that was validated before its bytes are
|
||||
// packed into anything. This is the terminal's drop target, and it is
|
||||
// the only path by which host bytes enter a container.
|
||||
// packed into anything. Two paths reach here: the terminal's drop
|
||||
// target, and the Files pane's upload via `upload_host_file_with_ids`.
|
||||
// Between them they are how host bytes enter a container.
|
||||
let file = std::fs::File::open(&host_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
crate::commands::file_commands::verify_opened_path(
|
||||
@@ -381,7 +410,7 @@ pub async fn upload_host_file_to_container(
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: "/tmp".to_string(),
|
||||
path: dest_dir.to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
@@ -389,7 +418,17 @@ pub async fn upload_host_file_to_container(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(format!("/tmp/{}", dest_name))
|
||||
Ok(container_join(dest_dir, &dest_name))
|
||||
}
|
||||
|
||||
/// Join a container directory to a name that may itself carry separators.
|
||||
///
|
||||
/// Only the *reported* path — the bytes have already landed by the time this is
|
||||
/// called — but that path is what the terminal echoes and what the Files pane
|
||||
/// puts in its toast, so `/tmp//x` reading back as a different file than `/tmp/x`
|
||||
/// is worth the four lines. `"/"` trims to `""` and yields `/x`.
|
||||
fn container_join(dir: &str, name: &str) -> String {
|
||||
format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
|
||||
@@ -892,4 +931,25 @@ mod tests {
|
||||
// …but still comfortably above a genuine /proc/net/tcp{,6} pair.
|
||||
assert!(PROC_NET_OUTPUT_LIMIT > 100 * 150);
|
||||
}
|
||||
|
||||
/// The reported path, which is what the terminal echoes back to Claude and
|
||||
/// what the Files pane puts in its log line. `/tmp//x` and `/tmp/x` are the
|
||||
/// same file to the kernel and different strings to a person reading either
|
||||
/// of those.
|
||||
#[test]
|
||||
fn container_join_produces_one_separator() {
|
||||
assert_eq!(container_join("/tmp", "a.txt"), "/tmp/a.txt");
|
||||
// The terminal's drop passes a nested name; it must not gain a second
|
||||
// slash at the seam.
|
||||
assert_eq!(
|
||||
container_join("/tmp", "triple-c-drops/a.txt"),
|
||||
"/tmp/triple-c-drops/a.txt"
|
||||
);
|
||||
// A directory the user navigated to can carry a trailing slash, and the
|
||||
// container root is the case where trimming it must not eat the only
|
||||
// separator there is.
|
||||
assert_eq!(container_join("/workspace/", "a.txt"), "/workspace/a.txt");
|
||||
assert_eq!(container_join("/", "a.txt"), "/a.txt");
|
||||
assert_eq!(container_join("/", "/a.txt"), "/a.txt");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,6 +497,8 @@ pub fn run() {
|
||||
// Files
|
||||
commands::file_commands::list_container_files,
|
||||
commands::file_commands::download_container_backup,
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::upload_files_to_container,
|
||||
commands::file_commands::read_container_file,
|
||||
commands::file_commands::rename_container_path,
|
||||
commands::file_commands::create_container_directory,
|
||||
|
||||
@@ -144,15 +144,16 @@ export default function FileViewerModal({ projectId, entry, onClose }: Props) {
|
||||
|
||||
{preview.kind === "too-large" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Open it
|
||||
from a terminal in the container, or take a backup and open it on the host.
|
||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Use
|
||||
“Save to host…” on its row to open it in a program that can, or read it from a
|
||||
terminal in the container.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "unsupported" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
There is no preview for this file type. Open it from a terminal in the container,
|
||||
or take a backup and open it on the host.
|
||||
There is no preview for this file type. Use “Save to host…” on its row to open it
|
||||
in a program that can, or read it from a terminal in the container.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
|
||||
import FilesTab from "./FilesTab";
|
||||
import type { FileContents, FileEntry, Project } from "../../../lib/types";
|
||||
|
||||
@@ -7,6 +7,8 @@ const listContainerFiles = vi.fn();
|
||||
const renameContainerPath = vi.fn(async () => "");
|
||||
const createContainerDirectory = vi.fn(async () => "");
|
||||
const readContainerFile = vi.fn();
|
||||
const uploadFilesToContainer = vi.fn();
|
||||
const downloadContainerFile = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
@@ -14,6 +16,8 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
|
||||
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
|
||||
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
|
||||
}));
|
||||
|
||||
/** Transient failures land in `ToastHost`, not in an inline string. */
|
||||
@@ -175,9 +179,15 @@ describe("FilesTab viewer", () => {
|
||||
});
|
||||
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
|
||||
expect(screen.queryByAltText("huge.png")).toBeNull();
|
||||
// The way out is named, and it is not a host path this pane could write:
|
||||
// a terminal inside the container, or a backup.
|
||||
expect(screen.getByText(/take a backup/)).toBeTruthy();
|
||||
// A refusal has to name the way out, and the way out is now the button on
|
||||
// the row rather than the `cat`-it-in-a-terminal workaround that existed
|
||||
// because the button did not.
|
||||
// Scoped to the modal: every file row also carries a "Save to host…"
|
||||
// button now, so an unscoped query matches the grid behind the overlay and
|
||||
// would pass with the refusal saying nothing at all.
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByText(/Save to host/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says so in words when only a prefix of a big text file came back", async () => {
|
||||
@@ -392,3 +402,73 @@ describe("FilesTab grid semantics", () => {
|
||||
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The pane's two host-transfer affordances.
|
||||
*
|
||||
* They are asserted at the *button* level and not only in the hook, because
|
||||
* this is the half that was actually lost: the commands behind them had been
|
||||
* deleted, but so had the controls, and a working command nobody can reach is
|
||||
* the same regression. Neither button names a host path — Rust opens the
|
||||
* dialog — so what a click is required to prove is that the container-side
|
||||
* argument reaching the backend is the one the user is looking at.
|
||||
*/
|
||||
describe("FilesTab host transfers", () => {
|
||||
beforeEach(() => {
|
||||
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
|
||||
downloadContainerFile.mockResolvedValue(4);
|
||||
});
|
||||
|
||||
it("uploads into the directory currently on screen", async () => {
|
||||
listContainerFiles.mockResolvedValue([entry("src", { is_directory: true })]);
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("src"));
|
||||
});
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/src/a.txt"],
|
||||
failures: [],
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upload…" }));
|
||||
});
|
||||
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/src");
|
||||
});
|
||||
|
||||
it("offers Save to host on a file and not on a folder", async () => {
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("notes.txt"),
|
||||
entry("src", { is_directory: true }),
|
||||
]);
|
||||
await renderTab();
|
||||
// The accessible name carries the row, per WCAG 2.5.3 — and it is how a
|
||||
// per-row action is told apart from every other row's copy of it.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save to host — notes.txt" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Save to host — src" }),
|
||||
).toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save to host — notes.txt" }));
|
||||
});
|
||||
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
|
||||
});
|
||||
|
||||
it("does not open the file viewer when Save to host is double-clicked", async () => {
|
||||
// Opening a file is a *double*-click on the row, and a double-click on a
|
||||
// button inside that row still bubbles — `onClick`'s `stopPropagation` does
|
||||
// nothing about it. So an impatient double-click on Save used to save the
|
||||
// file and drop the viewer modal over the pane at the same time, on top of
|
||||
// the save dialog the backend had just opened.
|
||||
listContainerFiles.mockResolvedValue([entry("notes.txt")]);
|
||||
readContainerFile.mockResolvedValue(contents("hello"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(
|
||||
screen.getByRole("button", { name: "Save to host — notes.txt" }),
|
||||
);
|
||||
});
|
||||
expect(readContainerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,13 +15,26 @@ const PARENT_ROW = "..";
|
||||
/**
|
||||
* The project's file browser.
|
||||
*
|
||||
* Container-side only: it lists, opens, renames and creates folders inside the
|
||||
* container, and it does no host filesystem I/O at all. A file gets *into* a
|
||||
* container by being dropped onto the Terminal tab, and a whole tree comes back
|
||||
* out through "Back up container" in the project's Workspace settings. Four
|
||||
* successive audits found that host paths crossing IPC were where the criticals
|
||||
* lived; those two paths are the ones that survived, and this pane is not one
|
||||
* of them.
|
||||
* It lists, opens, renames and creates folders inside the container, and it
|
||||
* copies single files across the boundary: "Upload…" in the toolbar, and a
|
||||
* per-row "Save to host…".
|
||||
*
|
||||
* **Neither of those names a host path, and this file must never learn how
|
||||
* to.** Four successive audits found that host paths crossing IPC were where
|
||||
* the criticals lived — a frontend `open()`/`save()` handing Rust a string is
|
||||
* exactly the shape that failed — so the picker is opened by the *backend*
|
||||
* (`pick_files_to_upload` / `pick_save_path` in `commands/file_commands.rs`).
|
||||
* What this file *sends* is a project id and a container path; the host side of
|
||||
* the transfer is chosen by a person in an OS dialog. That is why
|
||||
* `uploadFiles()` takes no argument and `saveToHost()` takes only the entry.
|
||||
* (A failed transfer does report a host path back, in the text of its error —
|
||||
* the inbound direction is the one that is closed, not both.)
|
||||
*
|
||||
* Drag-and-drop is deliberately still absent, in both directions. A file also
|
||||
* gets into a container by being dropped onto the Terminal tab, and a whole
|
||||
* tree comes back out through "Back up container" in the project's ⋯ menu —
|
||||
* which is still the right answer for a directory, since "Save to host…" is one
|
||||
* file at a time and is not offered on folders.
|
||||
*
|
||||
* Interaction model, chosen to match every desktop file manager rather than
|
||||
* the old half-and-half: **single click selects, double click opens**. That
|
||||
@@ -52,6 +65,10 @@ export default function FilesTab({ project }: Props) {
|
||||
refresh,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
uploadFiles,
|
||||
saveToHost,
|
||||
uploading,
|
||||
savingPath,
|
||||
} = useFileManager(project.id);
|
||||
|
||||
const running = project.status === "running";
|
||||
@@ -305,6 +322,16 @@ export default function FilesTab({ project }: Props) {
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
{/* The file picker this opens belongs to Rust, not to the webview — so
|
||||
this file imports no dialog plugin and never composes a host path.
|
||||
`uploadFiles` takes no argument for the same reason. */}
|
||||
<Button
|
||||
onClick={() => void uploadFiles()}
|
||||
disabled={uploading}
|
||||
className="ml-1"
|
||||
>
|
||||
{uploading ? "Uploading…" : "Upload…"}
|
||||
</Button>
|
||||
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||
Refresh
|
||||
</Button>
|
||||
@@ -501,6 +528,34 @@ export default function FilesTab({ project }: Props) {
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
{/* Folders have no single-file equivalent — a
|
||||
recursive download is what "Back up container" is
|
||||
for, and offering one here would mean rebuilding
|
||||
the tree-walking this pane deliberately does not
|
||||
do. */}
|
||||
{!entry.is_directory && (
|
||||
<Button
|
||||
aria-label={`Save to host — ${entry.name}`}
|
||||
className="ml-1"
|
||||
// Only this row: a large file can take a while,
|
||||
// and there is no reason the rest of the pane
|
||||
// should go dead while it is written.
|
||||
disabled={savingPath === entry.path}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void saveToHost(entry);
|
||||
}}
|
||||
// A double-click is its own event, and
|
||||
// `onClick`'s `stopPropagation` says nothing
|
||||
// about it — so an impatient double-click here
|
||||
// reached the row's `onDoubleClick` and dropped
|
||||
// the viewer modal over the pane, on top of the
|
||||
// save dialog the backend had just opened.
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{savingPath === entry.path ? "Saving…" : "Save to host…"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -6,12 +6,16 @@ import type { FileEntry } from "../lib/types";
|
||||
const listContainerFiles = vi.fn();
|
||||
const renameContainerPath = vi.fn();
|
||||
const createContainerDirectory = vi.fn();
|
||||
const uploadFilesToContainer = vi.fn();
|
||||
const downloadContainerFile = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
|
||||
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
|
||||
readContainerFile: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -45,6 +49,8 @@ const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listContainerFiles.mockResolvedValue([file("a.txt")]);
|
||||
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
|
||||
downloadContainerFile.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
describe("useFileManager navigation", () => {
|
||||
@@ -292,3 +298,226 @@ describe("useFileManager surfaces written refusals as prose", () => {
|
||||
expect(lastToast().detail).toBe("no space left on device");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Both of these actions are *dialog-driven from Rust* — the hook passes a
|
||||
* project and a directory and gets back an answer, and there is deliberately no
|
||||
* host path anywhere in this file. What is worth pinning is the vocabulary of
|
||||
* that answer, because two of its values look like failure and are not: `null`
|
||||
* means the user dismissed the picker, and `0` bytes means an empty file was
|
||||
* saved successfully.
|
||||
*/
|
||||
describe("useFileManager saving to the host", () => {
|
||||
it("treats a zero-byte save as a success", async () => {
|
||||
// The bug this exists for: `if (!bytes) return` reads a genuine
|
||||
// zero-length file — an empty `.gitkeep`, a truncated log — as a
|
||||
// dismissal, so the file lands on the host and the app says nothing at
|
||||
// all. The sentinel is `null`, and only `null`.
|
||||
downloadContainerFile.mockResolvedValueOnce(0);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("empty.txt"));
|
||||
});
|
||||
expect(result.current.completed).toContain("empty.txt");
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says nothing at all when the dialog is dismissed", async () => {
|
||||
downloadContainerFile.mockResolvedValueOnce(null);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("a.txt"));
|
||||
});
|
||||
expect(result.current.completed).toBeNull();
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the file in a refusal", async () => {
|
||||
downloadContainerFile.mockRejectedValueOnce("/etc/shadow is not readable");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("secret.txt"));
|
||||
});
|
||||
expect(toastText()).toContain("secret.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager uploading from the host", () => {
|
||||
it("uploads into the directory on screen and shows the result", async () => {
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/app/one.txt", "/workspace/app/two.txt"],
|
||||
failures: [],
|
||||
});
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/app");
|
||||
expect(result.current.completed).toContain("2 files");
|
||||
// The directory is named. `target` is captured at click time and the
|
||||
// picker is a modal dialog, so "Uploaded 2 files." on its own can be shown
|
||||
// in front of a grid those files are not in.
|
||||
expect(result.current.completed).toContain("/workspace/app");
|
||||
// The new files are only on screen if the listing was asked for again.
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/app");
|
||||
});
|
||||
|
||||
it("reports every file that failed, not just a count", async () => {
|
||||
// "3 of 5 uploaded" without naming the two is not a report — the user
|
||||
// cannot tell which ones to retry, or why.
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/ok.txt"],
|
||||
failures: [
|
||||
"/home/j/Pictures is a folder — upload its files individually.",
|
||||
"/home/j/vm.img is too large to upload (900 MB; limit 256 MB).",
|
||||
],
|
||||
});
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(pushToast).toHaveBeenCalledTimes(2);
|
||||
expect(toastText()).toContain("is a folder");
|
||||
expect(toastText()).toContain("too large");
|
||||
// A partial batch still succeeded partially, and the pane must show it.
|
||||
expect(result.current.completed).toContain("1 file");
|
||||
});
|
||||
|
||||
it("does not refresh when the picker was dismissed", async () => {
|
||||
uploadFilesToContainer.mockResolvedValueOnce(null);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
expect(result.current.completed).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a refusal that happened before the picker once, not per file", async () => {
|
||||
// No container, not running, or a directory this pane may not write to.
|
||||
// There is no selection yet, so there is nothing to enumerate.
|
||||
uploadFilesToContainer.mockRejectedValueOnce(
|
||||
"Start the project before uploading files — it runs inside the running container.",
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(pushToast).toHaveBeenCalledTimes(1);
|
||||
expect(toastText()).toContain("Start the project");
|
||||
});
|
||||
|
||||
it("does not drag the pane back when the user navigated during the upload", async () => {
|
||||
// The same rule rename and new-folder follow: a slow operation must not
|
||||
// relist a directory the user has already left.
|
||||
let release: (v: unknown) => void = () => {};
|
||||
uploadFilesToContainer.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
let uploading: Promise<void>;
|
||||
act(() => {
|
||||
uploading = result.current.uploadFiles();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/other");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
release({ uploaded: ["/workspace/app/one.txt"], failures: [] });
|
||||
await uploading;
|
||||
});
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
expect(result.current.currentPath).toBe("/workspace/other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager transfer state", () => {
|
||||
it("marks an upload in flight for as long as it runs", async () => {
|
||||
// Without this the button stays live: a second click opens a second OS
|
||||
// dialog and runs a second concurrent exec, and a slow transfer looks
|
||||
// exactly like a click that did nothing.
|
||||
let release: (v: unknown) => void = () => {};
|
||||
uploadFilesToContainer.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
expect(result.current.uploading).toBe(false);
|
||||
let uploading: Promise<void>;
|
||||
act(() => {
|
||||
uploading = result.current.uploadFiles();
|
||||
});
|
||||
expect(result.current.uploading).toBe(true);
|
||||
await act(async () => {
|
||||
release({ uploaded: [], failures: [] });
|
||||
await uploading;
|
||||
});
|
||||
expect(result.current.uploading).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the upload flag when the transfer fails", async () => {
|
||||
// The `catch` returns early, so without a `finally` the button is disabled
|
||||
// for the rest of the session — the failure mode is a pane that can never
|
||||
// upload again, with no error left on screen to explain it.
|
||||
uploadFilesToContainer.mockRejectedValueOnce("Start the project first");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(result.current.uploading).toBe(false);
|
||||
});
|
||||
|
||||
it("marks only the row being saved, and clears it on failure", async () => {
|
||||
let release: (v: unknown) => void = () => {};
|
||||
downloadContainerFile.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
expect(result.current.savingPath).toBeNull();
|
||||
let saving: Promise<void>;
|
||||
act(() => {
|
||||
saving = result.current.saveToHost(file("a.txt"));
|
||||
});
|
||||
// The path, not a boolean — the rest of the pane stays usable.
|
||||
expect(result.current.savingPath).toBe("/workspace/a.txt");
|
||||
await act(async () => {
|
||||
release(10);
|
||||
await saving;
|
||||
});
|
||||
expect(result.current.savingPath).toBeNull();
|
||||
|
||||
downloadContainerFile.mockRejectedValueOnce("Permission denied");
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("b.txt"));
|
||||
});
|
||||
expect(result.current.savingPath).toBeNull();
|
||||
|
||||
// And on dismissal, which is the path that actually needs the `finally`:
|
||||
// the dismissal check `return`s from inside the `try`, so a clear placed
|
||||
// after the block instead is skipped and the row reads "Saving…" for the
|
||||
// rest of the session with nothing running behind it.
|
||||
downloadContainerFile.mockResolvedValueOnce(null);
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("c.txt"));
|
||||
});
|
||||
expect(result.current.savingPath).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FileEntry } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { useAppState } from "../store/appState";
|
||||
import { errorText, readableRefusal } from "../lib/refusalText";
|
||||
import { formatBytes } from "../lib/formatBytes";
|
||||
|
||||
/**
|
||||
* ## Where failures are reported
|
||||
@@ -13,8 +14,8 @@ import { errorText, readableRefusal } from "../lib/refusalText";
|
||||
* (empty) grid. It is on screen, it is in context, it explains why there are
|
||||
* no rows, and it is not transient — it stands until the directory lists.
|
||||
*
|
||||
* Every **transient operation** failure — rename, create folder — goes to
|
||||
* `ToastHost` instead. Those used to land in the same inline `error` div, which
|
||||
* Every **transient operation** failure — rename, create folder, upload, save
|
||||
* to host — goes to `ToastHost` instead. Those used to land in the same inline `error` div, which
|
||||
* is the first child of the *scrolling* list: three hundred rows down, a
|
||||
* refused rename produced no visible change at all, just a rename box that
|
||||
* stayed open for no stated reason. The toast host is a persistent `aria-live`
|
||||
@@ -42,6 +43,20 @@ export function useFileManager(projectId: string) {
|
||||
* change a sighted user sees in the grid and a screen reader user does not.
|
||||
*/
|
||||
const [completed, setCompleted] = useState<string | null>(null);
|
||||
/**
|
||||
* Which host transfers are in flight.
|
||||
*
|
||||
* Both actions open an OS dialog and can then run for a long time on a large
|
||||
* file, with nothing on screen to say so. Without this the buttons stay live:
|
||||
* a second click opens a second dialog and runs a second concurrent exec
|
||||
* against the same file, and a multi-gigabyte save is indistinguishable from
|
||||
* a click that did nothing.
|
||||
*
|
||||
* `savingPath` rather than a boolean, so only the row being saved is
|
||||
* disabled — the pane stays usable while a big file is written.
|
||||
*/
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [savingPath, setSavingPath] = useState<string | null>(null);
|
||||
|
||||
const currentPathRef = useRef(currentPath);
|
||||
|
||||
@@ -154,6 +169,76 @@ export function useFileManager(projectId: string) {
|
||||
[projectId, navigate, report],
|
||||
);
|
||||
|
||||
/**
|
||||
* Copy host files into the directory on screen.
|
||||
*
|
||||
* The picker is opened by **Rust**, not here — `upload_files_to_container`
|
||||
* shows it, reads what the user chose and never lets a host path near IPC.
|
||||
* So this passes a directory and gets back an outcome; `null` means the user
|
||||
* dismissed the dialog, which is not a failure and says nothing.
|
||||
*
|
||||
* One dialog can select several files and they need not agree, hence two
|
||||
* lists. Every failure is reported, because "3 of 5 uploaded" without saying
|
||||
* which two is not a report. The listing is refreshed once, at the end, and
|
||||
* only if the user is still looking at the directory that was targeted.
|
||||
*/
|
||||
const uploadFiles = useCallback(async () => {
|
||||
const target = currentPathRef.current;
|
||||
let outcome;
|
||||
setUploading(true);
|
||||
try {
|
||||
outcome = await commands.uploadFilesToContainer(projectId, target);
|
||||
} catch (e) {
|
||||
// A failure *before* the picker: no container, not running, or a
|
||||
// directory this pane may not write to. One toast, not one per file.
|
||||
report("Could not upload", e);
|
||||
return;
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
if (!outcome) return;
|
||||
for (const failure of outcome.failures) {
|
||||
useAppState.getState().pushToast({ kind: "error", message: failure });
|
||||
}
|
||||
if (outcome.uploaded.length > 0) {
|
||||
// The directory is named, not implied. `target` is captured at click
|
||||
// time and the picker is a modal OS dialog — the user has all the time in
|
||||
// the world to browse somewhere else while it is open, and the files land
|
||||
// where they started. "Uploaded 2 files." in front of a grid that does not
|
||||
// contain them is a worse answer than no message at all.
|
||||
const count = outcome.uploaded.length;
|
||||
setCompleted(
|
||||
`Uploaded ${count === 1 ? "1 file" : `${count} files`} to ${target}.`,
|
||||
);
|
||||
if (currentPathRef.current === target) await navigate(target);
|
||||
}
|
||||
}, [projectId, navigate, report]);
|
||||
|
||||
/**
|
||||
* Save one file out to the host, with Rust opening the save dialog.
|
||||
*
|
||||
* No refresh: nothing in the container changed. The save dialog is also what
|
||||
* asks about overwriting an existing host file, which is why the backend has
|
||||
* no collision handling of its own to get wrong. `null` is a dismissal.
|
||||
*/
|
||||
const saveToHost = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
setSavingPath(entry.path);
|
||||
try {
|
||||
const bytes = await commands.downloadContainerFile(projectId, entry.path);
|
||||
// `0` is a real answer — an empty file saved is a success — so this
|
||||
// tests for the dismissal sentinel, not for falsiness.
|
||||
if (bytes === null) return;
|
||||
setCompleted(`Saved "${entry.name}" (${formatBytes(bytes)}).`);
|
||||
} catch (e) {
|
||||
report(`Could not save "${entry.name}"`, e);
|
||||
} finally {
|
||||
setSavingPath(null);
|
||||
}
|
||||
},
|
||||
[projectId, report],
|
||||
);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
entries,
|
||||
@@ -168,5 +253,10 @@ export function useFileManager(projectId: string) {
|
||||
refresh,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
uploadFiles,
|
||||
saveToHost,
|
||||
/** A host transfer is in flight — see the state declarations above. */
|
||||
uploading,
|
||||
savingPath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* 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.
|
||||
* 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -69,6 +69,25 @@ export const stopAudioBridge = (sessionId: string) =>
|
||||
// Files
|
||||
export const listContainerFiles = (projectId: string, path: string) =>
|
||||
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
||||
/**
|
||||
* Save one container file to the host.
|
||||
*
|
||||
* The **backend** opens the save dialog, so this call cannot name a place on
|
||||
* the host — that is the point (see `pick_save_path` in `file_commands.rs`).
|
||||
* Paths do come *back* inside error text; what is closed is the inbound
|
||||
* direction.
|
||||
* Resolves to the number of bytes written, or `null` if the user dismissed the
|
||||
* dialog. Zero bytes is a success: an empty file is a file.
|
||||
*/
|
||||
export const downloadContainerFile = (projectId: string, containerPath: string) =>
|
||||
invoke<number | null>("download_container_file", { projectId, containerPath });
|
||||
/**
|
||||
* Upload host files into `containerDir`, with the backend opening the file
|
||||
* picker. Resolves to `null` if the user dismissed it, otherwise to what
|
||||
* happened — one dialog can select several files and they need not all succeed.
|
||||
*/
|
||||
export const uploadFilesToContainer = (projectId: string, containerDir: string) =>
|
||||
invoke<UploadOutcome | null>("upload_files_to_container", { projectId, containerDir });
|
||||
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
|
||||
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
|
||||
export const readContainerFile = (projectId: string, path: string, maxBytes?: number) =>
|
||||
|
||||
@@ -365,6 +365,22 @@ export interface FileEntry {
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one upload dialog's worth of files did — mirrors `UploadOutcome` in
|
||||
* `commands/file_commands.rs`.
|
||||
*
|
||||
* Two lists rather than a count and a flag, because one dialog can select
|
||||
* several files and they do not have to agree: a folder among the selection, or
|
||||
* a file over the size ceiling, must not cost the user the ones either side of
|
||||
* it. Each `failures` entry is already a finished sentence naming its file.
|
||||
*/
|
||||
export interface UploadOutcome {
|
||||
/** In-container paths, in the order they landed. */
|
||||
uploaded: string[];
|
||||
/** One sentence per file that did not. */
|
||||
failures: string[];
|
||||
}
|
||||
|
||||
/** A file read out of the container for the in-app viewer. */
|
||||
export interface FileContents {
|
||||
/** Base64 — a byte array would cross IPC as JSON numbers. */
|
||||
|
||||
Reference in New Issue
Block a user