diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index c67d239..9e30741 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -1212,13 +1212,17 @@ cannot name a place on your machine — it can only ask for a dialog — and not 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 +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`. +The *file's own name* is a different matter, and dotfiles are fine: `.env`, `.gitignore` and the +rest save normally, since you chose the name in the save dialog yourself. Only the folders on the +way are judged. + If you already keep the project in a folder mounted into the container, the simplest answer is usually none of the above: edit the file on your host and it is already inside. diff --git a/README.md b/README.md index 5dae25b..a4c17f5 100644 --- a/README.md +++ b/README.md @@ -451,10 +451,16 @@ 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. + `uploadFiles` takes no argument at all. The web UI can ask for a dialog, and that is the whole of + its influence over where a file comes from or goes — it cannot name a host path as an *input*. This is a boundary rather than a convention: a dialog the page itself opens is only as trustworthy - as the page. + as the page. Be precise about the limit, though — host paths still travel *outward* in error text, + canonical ones included, so this closes the inbound direction and not both. +- **The dialog's pre-filled name is sanitized, because a container authored it.** On Windows the + save dialog parses its name box as a path, and a container can name a file + `..\..\Users\you\…\Word\STARTUP\x.dotm` — one POSIX segment, so nothing upstream objects. + `suggested_save_name` replaces every separator and every character NTFS refuses, so the string + cannot be a path on any platform this ships to. - **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 diff --git a/app/src-tauri/src/commands/file_commands.rs b/app/src-tauri/src/commands/file_commands.rs index d9d8353..779c3cf 100644 --- a/app/src-tauri/src/commands/file_commands.rs +++ b/app/src-tauri/src/commands/file_commands.rs @@ -484,14 +484,39 @@ fn is_under_root(path: &str, root: &str) -> bool { path == root || path.strip_prefix(root).is_some_and(|rest| rest.starts_with('/')) } -/// What a host path is about to be used for. The two directions differ over -/// hidden names — see [`validate_host_path`]. +/// What a host path is about to be used for. The three modes differ over which +/// components may be hidden — see [`validate_host_path`] and the variants. #[derive(Clone, Copy, Debug, PartialEq)] enum HostPathUse { /// Host bytes are about to be read *into* the container. Read, - /// Container bytes are about to be written *onto* the host. + /// Container bytes are about to be written *onto* the host, at a path that + /// arrived **over IPC as a string** — `download_container_backup`. + /// + /// The strictest of the three, and the leaf is judged along with every + /// directory above it, because creating `~/.bashrc` is escape all by + /// itself and nothing here can tell a path a person picked from one a + /// compromised webview invented. Write, + /// Container bytes are about to be written onto the host, at a name a + /// person typed or accepted in an **OS save dialog opened by Rust** — + /// `download_container_file`. + /// + /// Identical to [`HostPathUse::Write`] except that the final component is + /// not judged for hiddenness, which is the difference between a rule and a + /// bug. Under `Write`, saving `/workspace/.env` was refused *after* the + /// modal and the overwrite prompt, with the message "\".env\" is a hidden + /// file — Triple-C will not save there" — and the app had pre-filled that + /// exact name itself. `.gitignore`, `.dockerignore`, `.eslintrc.json`, + /// `.nvmrc` and the rest of an ordinary workspace were all unsavable, while + /// uploading them worked, so a dotfile could go in and never come out. + /// + /// What justifies dropping it *here* and nowhere else is that the dialog is + /// a real boundary for this caller and only this caller: the name is on + /// screen, the user chose the directory, and the OS asked before + /// overwriting anything. Every *directory* rule still applies, so `~/.ssh` + /// and `~/.config` are as refused as they ever were. + WriteChosenName, } /// Host directories nothing in this app has any business reading a file out of @@ -583,7 +608,35 @@ fn normalize_host_path(path: &str) -> String { break; } } - s + // Collapse runs of separators, keeping any leading pair (a UNC root is + // `//server/share` and means something). + // + // Without this the *lexical* system-root rule was quietly absent for + // Windows paths: `C:\\Windows\System32\x.dll` normalises to + // `c://windows/...`, which `is_under_root` does not match, while the + // single-separator form is refused. Nothing was exploitable — `resolve_host_path` + // runs the same policy again over the canonical form and `canonicalize` + // collapses the run — but a documented layer that silently does nothing is + // a trap for the next caller who reaches for it without the resolved pass. + let lead = if s.starts_with("//") { "//" } else { "" }; + let body: String = { + let rest = &s[lead.len()..]; + let mut out = String::with_capacity(rest.len()); + let mut prev_sep = false; + for c in rest.chars() { + if c == '/' { + if !prev_sep { + out.push(c); + } + prev_sep = true; + } else { + out.push(c); + prev_sep = false; + } + } + out + }; + format!("{}{}", lead, body) } /// The named components of a host path, with the drive letter, the separators @@ -763,7 +816,9 @@ fn validate_host_path(path: &str, use_for: HostPathUse) -> Result names.len(), - HostPathUse::Read => names.len().saturating_sub(1), + // The leaf is the user's own choice in both of these — dropped from a + // file manager, or typed into a save dialog. See the enum. + HostPathUse::Read | HostPathUse::WriteChosenName => names.len().saturating_sub(1), }; if let Some(hidden) = names[..hidden_limit].iter().find(|n| n.starts_with('.')) { let verb = if use_for == HostPathUse::Write { "save" } else { "read" }; @@ -861,7 +916,10 @@ async fn resolve_host_path(path: &str, use_for: HostPathUse) -> Result tokio::fs::canonicalize(&candidate) .await .map_err(|e| format!("Cannot access {}: {}", candidate.display(), e))?, - HostPathUse::Write => { + // Both write modes resolve the *parent* and keep the caller's leaf; they + // differ only in whether that leaf may be hidden, which + // `validate_host_path` has already decided by this point. + HostPathUse::Write | HostPathUse::WriteChosenName => { let parent = candidate .parent() .ok_or_else(|| format!("{} does not name a file", candidate.display()))?; @@ -878,13 +936,15 @@ async fn resolve_host_path(path: &str, use_for: HostPathUse) -> Result