diff --git a/CLAUDE.md b/CLAUDE.md index e21ecc6..910234d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,14 +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 takes drops *in*, and that path does not use HTML5 drag.** Dropping into the pane - is Tauri's native `onDragDropEvent`, which is window-wide and therefore routed by a - hit-test of the physical-pixel payload position against the pane's rect ÷ - `devicePixelRatio` — a hidden pane has a zero-size rect, which is what stops it and - `TerminalView`'s listener both firing. Keep `lib/dropTarget.ts` and both listeners. - - **Getting a file *out* is "Save to host…", and there is no other route.** OS drag-out — - `tauri-plugin-drag`, `stage_container_file_for_drag` and its host staging directory — was - removed from the ship branch and held back for separate hardening; it lives on + - **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 + `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 + deliberately over-catches: a path resolving through `node_modules/.pnpm`, `~/.cache` or + `~/.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. + - **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 without taking that work back whole: the plugin has no scope mechanism, so the grant lets a compromised webview start a drag on *any* host path the user can read, and the staging diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index d744049..058b763 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -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, download and upload files inside the container | +| **Files** | Browse, view and rename files inside the container — see [Files](#files) for how files get in and out | | **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, download and upload files | +| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container | | **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 | @@ -1161,16 +1161,43 @@ 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. You can: +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: -- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation -- **Save to host…** — copy any file out to a location you pick. This is the way to get a file out - of a container; there is one button per file entry, and the file viewer offers it too -- **Upload file** from your host into the current container directory — or **drop files straight - onto the pane** from your desktop, which uploads them into the directory on screen +- **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 + between rows and Enter opens the selected one +- **View** a file — double-click it, or press Enter. Text files and images render in a read-only + viewer +- **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 - **Refresh** the directory listing at any time -The listing shows file names, sizes, and modification dates. +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: + +- **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. + +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`. + +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. ### Terminal Rendering diff --git a/README.md b/README.md index 053fc91..2563218 100644 --- a/README.md +++ b/README.md @@ -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, download and upload files inside the container | +| **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** | | **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 @@ -513,7 +513,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` | File browser (browse, download, upload) | +| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) | | `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 +536,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 manager operations (list, download, upload) | +| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) | | `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 | @@ -547,7 +547,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi | File | Purpose | |---|---| | `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` | -| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar | +| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; one-shot execs and single-file tar building | | `app/src-tauri/src/docker/image.rs` | Image building/pulling | | `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine | | `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting | @@ -561,7 +561,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` | File manager Tauri commands (list, download, upload) | +| `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/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) | diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index 99c948b..c9fb12d 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -4,17 +4,14 @@ use std::sync::Arc; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; -use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions}; +use bollard::container::{DownloadFromContainerOptions, LogOutput}; use bollard::exec::{CreateExecOptions, StartExecResults}; use futures_util::StreamExt; use serde::Serialize; use tauri::State; use crate::docker::client::get_docker; -use crate::docker::exec::{ - build_single_file_tar, container_user_ids, exec_oneshot_as, exec_oneshot_as_within, - exec_oneshot_streams_as, now_epoch_secs, OUTPUT_LIMIT_MARKER, -}; +use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, OUTPUT_LIMIT_MARKER}; use crate::AppState; #[derive(Debug, PartialEq, Serialize)] @@ -51,10 +48,6 @@ pub struct FileContents { /// something to take on trust. const MAX_READ_BYTES: u64 = 8 * 1024 * 1024; -/// Ceiling on a single upload, mirroring the terminal drop path's guard. The -/// file is packed into an in-memory tar before it goes anywhere. -const MAX_UPLOAD_BYTES: u64 = 256 * 1024 * 1024; - #[tauri::command] pub async fn list_container_files( project_id: String, @@ -91,13 +84,14 @@ pub async fn list_container_files( let entries = parse_find_output(&path, &records); if code != 0 && entries.is_empty() { - // `find`'s own words — "Permission denied", "No such file or directory" - // — are the whole diagnosis. + // `find`'s own words — "Permission denied" — are usually the whole + // diagnosis; the two a symlinked starting point produces are not. + // See `describe_find_diagnostics`. let detail = diagnostics.trim(); return Err(if detail.is_empty() { format!("Could not list {} (exit {})", path, code) } else { - detail.to_string() + describe_find_diagnostics(&path, detail) }); } if code != 0 { @@ -115,6 +109,19 @@ pub async fn list_container_files( /// The argv `list_container_files` runs, in one place so the format and the /// parser can be pinned together. /// +/// `-H` is what makes a symlinked directory openable. `find` defaults to `-P`, +/// which does not follow a symlink *even when it is the starting point* — so +/// `find /workspace/link -mindepth 1` over a link to a directory matched the +/// link itself, `-mindepth 1` discarded it, and the panel showed a real +/// directory as "Empty directory". The row was navigable (see `%Y` below) and +/// navigating to it showed nothing. `-H` follows the starting point and only +/// the starting point, so nothing *inside* the directory is dereferenced during +/// the walk — which with `-maxdepth 1` is moot anyway, and is why this is not +/// `-L`: `-L` would have `find` chase links it enumerates, and a symlink loop +/// under a listed directory is then `find`'s problem rather than ours. +/// A loop *at* the starting point is resolved by the kernel, which answers +/// `ELOOP` immediately — a refusal, not a hang. +/// /// `%y` is the entry's own type, `%Y` the type it *dereferences* to. Both are /// printed: `%Y` is what decides navigability (a symlinked directory reports /// `l` under `%y`, which used to make it an unopenable row), while `%y` is the @@ -133,6 +140,8 @@ pub async fn list_container_files( fn list_argv(path: &str) -> Vec { vec![ "find".to_string(), + // Follow the starting point, and nothing else. See above. + "-H".to_string(), path.to_string(), "-mindepth".to_string(), "1".to_string(), @@ -161,6 +170,31 @@ fn describe_listing_failure(path: &str, error: String) -> String { error } +/// Turn `find`'s own stderr into a sentence about the folder. +/// +/// Only reached when `find` exited non-zero *and* printed no rows, i.e. when +/// its diagnostic is the whole diagnosis. Most of them already are one +/// ("Permission denied"), and those are passed through — but the two that +/// arrive now that `-H` follows the starting point are not: a link into nothing +/// and a link into itself both come back as raw `find:` text naming an errno, +/// about a row the user just double-clicked because it looked like a folder. +fn describe_find_diagnostics(path: &str, diagnostics: &str) -> String { + let lower = diagnostics.to_lowercase(); + if lower.contains("too many levels of symbolic links") || lower.contains("eloop") { + return format!("{} is a symbolic link that loops back on itself, so there is nothing to list.", path); + } + if lower.contains("no such file or directory") { + return format!( + "{} does not lead anywhere — it is either gone, or a symbolic link whose target is.", + path + ); + } + if diagnostics.is_empty() { + return format!("Could not list {}", path); + } + diagnostics.to_string() +} + /// Turn `find -printf '%y\t%Y\t%s\t%T@\t%m\t%f\0'` output into sorted entries. /// /// Split out from the command so it can be tested without a container: it is @@ -294,7 +328,7 @@ fn validate_entry_name(name: &str) -> Result<(), String> { /// cannot name a file in the container anyway. const MAX_CONTAINER_PATH_LEN: usize = 4096; -/// Container roots this panel may *create, rename or upload into*. +/// Container roots this panel may *create or rename into*. /// /// Reads are deliberately not restricted this way (see /// [`validate_container_path`]): the Files tab is a browser, `/etc/os-release` @@ -505,37 +539,6 @@ const HOST_AUTORUN_DIRS: &[&[&str]] = &[ &["start menu", "programs", "startup"], ]; -/// Directory *sequences* that hold credentials or authority, judged on the -/// path a symlink chain really leads to. -/// -/// This is the part of the hidden-component rule that has to survive -/// resolution. The rule itself cannot: see [`validate_resolved_host_path`] for -/// why "the real location passes through a dot directory" describes ordinary -/// software far more often than it describes an attack — `node_modules/.pnpm`, -/// `~/.local/share`, `~/.cache`, `~/.var/app`, `~/.nvm`, `~/.cargo`. What -/// *is* worth refusing after resolution is the small set of directories whose -/// contents are keys, tokens and startup entries, and those can be named. -/// -/// Matched as a contiguous run of components anywhere in the path, so -/// `~/.ssh/keys/id_rsa` is as refused as `~/.ssh/id_rsa`. Same defence-in-depth -/// footing as [`HOST_AUTORUN_DIRS`], and the same honest caveat: it is a list -/// of the places that are known, not of the ones that exist. The boundary is -/// the file dialog; this is what stops a *planted symlink* aiming an otherwise -/// ordinary-looking path at the one directory the attack wants. -const HOST_CREDENTIAL_DIRS: &[&[&str]] = &[ - &[".ssh"], - &[".gnupg"], - &[".aws"], - &[".azure"], - &[".kube"], - &[".docker"], - &[".claude"], - &[".config", "gcloud"], - &[".config", "autostart"], - &[".config", "systemd", "user"], - &[".local", "share", "keyrings"], -]; - /// Length of a `C:` drive prefix at the head of `path`, or 0. fn drive_prefix_len(path: &str) -> usize { let b = path.as_bytes(); @@ -655,63 +658,53 @@ fn is_autorun_dir(names: &[String]) -> bool { }) } -/// The [`HOST_CREDENTIAL_DIRS`] entry `names` passes through, spelled the way -/// the list spells it so the refusal can name it. +/// Structural and policy checks on a host path, returning it as a [`PathBuf`]. /// -/// Anywhere in the path rather than at the end: what matters is that the path -/// goes *through* `~/.ssh`, not how much further it goes. -fn credential_dir_in(names: &[String]) -> Option { - HOST_CREDENTIAL_DIRS.iter().find_map(|seq| { - (0..names.len().saturating_sub(seq.len() - 1)).find_map(|start| { - names[start..start + seq.len()] - .iter() - .zip(seq.iter()) - .all(|(have, want)| have.eq_ignore_ascii_case(want)) - .then(|| seq.join("/")) - }) - }) -} - -/// Structural and policy checks on a host path *as written*, returning it as a -/// [`PathBuf`]. -/// -/// The `save()`/`open()` dialog the Files pane puts in front of these commands -/// is a UI convention, not a boundary — every one of them is a single `invoke` -/// away from any code running in the webview, with a container-controlled -/// payload on one side. So the backend has its own policy: +/// 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: /// /// * absolute, no `..`, no NUL — judged on the path's own components, so a /// Windows path is judged as one wherever this runs; /// * nothing under [`HOST_SYSTEM_ROOTS`] or in a login-item directory; -/// * no *hidden* path components. The interesting targets for "write a -/// container-controlled file to an arbitrary host path" are mostly dot -/// directories — `~/.ssh/authorized_keys`, `~/.config/autostart/`, -/// `~/.claude/` — and the interesting targets for the reverse, reading a -/// host file into the container, are the same ones plus `~/.aws/credentials`. -/// A download is refused a hidden *name* too (creating `~/.bashrc` is escape -/// all by itself); an upload only cares about hidden *directories*, because -/// dragging a project's own `.env` into the container is an ordinary thing -/// to do and its parent is not hidden. +/// * no *hidden* path components. The interesting targets for "write +/// container-controlled bytes to a host path" are mostly dot directories — +/// `~/.ssh/authorized_keys`, `~/.config/autostart/`, `~/.local/bin/ls` — +/// and the interesting targets for the reverse, reading a host file into +/// the container, are the same ones plus `~/.aws/credentials`. A write is +/// refused a hidden *name* too (creating `~/.bashrc` is escape all by +/// itself); a read only cares about hidden *directories*, because dropping +/// a project's own `.env` onto the terminal is an ordinary thing to do and +/// its parent is not hidden. /// -/// **This is a lexical check on the string the user chose, and it judges only -/// that.** A path whose components are all visible can still *lead* somewhere -/// that deserves a second opinion, which is why [`resolve_host_path`] follows -/// this with [`validate_resolved_host_path`] over the canonical form. The two -/// ask different questions and must not be confused: this one is "did the user -/// point at a hidden place", that one is "where do these bytes actually land". -/// Re-running *this* function over a canonical path is the H6 regression — it -/// refuses `node_modules/pkg` under pnpm, and every visible directory that -/// symlinks into `~/.local/share`, `~/.cache`, `~/.var/app`, `~/.nvm` or -/// `~/.cargo`, none of which is anybody's attack. +/// **This is a lexical predicate over a string**, and [`resolve_host_path`] +/// runs it twice: once over the path as the user wrote it, and again over the +/// canonical form, because a path whose components are all visible can still +/// *lead* somewhere that is not. The second run is why a planted +/// `Downloads/pub → ~/.ssh` is refused. /// -/// **And the policy itself is a denylist, which is losing by construction.** -/// `~/Library/LaunchAgents`, `%AppData%\…\Startup`, `~/bin` and `/opt` are only -/// refused because someone thought of them; the next persistence directory is -/// not. The honest fix is not a longer list — it 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. That is a frontend change as well as this one. -/// Until then: this list is defence in depth, and the dialog is the boundary. +/// **The general hidden rule over-catches, and that is the deliberate trade.** +/// A path that resolves through `node_modules/.pnpm`, `~/.cache` or +/// `~/.local/share` is refused even though nothing about it is an attack. That +/// cost was once paid the other way — the rule was narrowed to an eleven-entry +/// 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. +/// +/// **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. fn validate_host_path(path: &str, use_for: HostPathUse) -> Result { if path.trim().is_empty() { return Err("No host path was given".to_string()); @@ -750,12 +743,22 @@ fn validate_host_path(path: &str, use_for: HostPathUse) -> Result names.len().saturating_sub(1), }; if let Some(hidden) = names[..hidden_limit].iter().find(|n| n.starts_with('.')) { - return Err(format!( - "\"{}\" is a hidden {} — Triple-C will not {} there. Choose a visible location.", - hidden, - if names.last() == Some(hidden) { "file" } else { "folder" }, - if use_for == HostPathUse::Write { "save" } else { "read" } - )); + let verb = if use_for == HostPathUse::Write { "save" } else { "read" }; + return Err(if names.last() == Some(hidden) { + format!( + "\"{}\" is a hidden file — Triple-C will not save there. Choose a visible name.", + hidden + ) + } else { + // Named as a *path* question rather than a name question, because + // this rule also runs over the canonical form: the component that + // trips it is frequently one the user never typed, and "the path + // goes through it" is the only wording that makes that make sense. + format!( + "the path goes through \"{}\", a hidden folder — Triple-C will not {} anything whose folders are not all visible. Choose a visible location.", + hidden, verb + ) + }); } let dirs = &names[..names.len().saturating_sub(1)]; @@ -777,70 +780,33 @@ fn validate_host_path(path: &str, use_for: HostPathUse) -> Result