Close what two reviews found in the Files tab transfers
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) Successful in 37s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / build-linux (pull_request) Successful in 5m11s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) Successful in 37s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / build-linux (pull_request) Successful in 5m11s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Two independent reviews of 2c9482a, one for correctness and one against the
threat model. Between them they found two ways to lose a file, one way for a
container to choose a Windows save destination, and a rule that made every
dotfile unsavable. Every finding below was demonstrated against a real
container before being fixed, and the fixes are demonstrated the same way.
## The download was accepting truncated reads and refusing whole ones
The `[ -f ]` bracket around the read was wrong in both directions.
It missed the case that loses data. Truncation *in place* — `> file`, log
rotation, `tar -x`, most build tools — leaves a regular file behind, so `dd`
stopped at the new EOF and exited 0. Measured: a 600 MB source truncated
mid-read delivered 34 MB, which was then renamed over the user's own earlier
copy and toasted as `Saved (34.1 MB)`. Truncate-to-zero did the same and needs
no adversary at all.
And it failed *good* downloads. `dd` already holds the fd, and neither `rm` nor
`mv` can touch an open one — the bytes are complete. But `rm` makes `[ -f ]`
false, so a finished 600 MB transfer of a file a bundler happened to unlink was
deleted, reporting "nothing was saved". The comment claiming the bracket caught
a "mix of two files" was simply wrong; an open fd cannot be a mix.
So the script now measures the file before reading it and puts the answer on
stderr, and Rust checks that at least that many bytes arrived. One rule,
subsuming everything the bracket was for. Verified against a real container:
truncation mid-read and the FIFO race are refused; deletion, rename-over, a
growing file, an empty file and an untouched 600 MB read all pass.
## On Windows the container, not the user, was naming the save destination
`suggested_save_name` split on `/` only, and it feeds the save dialog's
pre-filled name. Backslash is a legal Linux filename character and
`validate_container_path` has no reason to object — `..\..\Users\…` is one
POSIX segment. The Windows common file dialog parses its name box as a path on
Save, so a container-created file called
`..\..\..\Users\vic\AppData\Roaming\Microsoft\Word\STARTUP\x.dotm` put its own
bytes in an auto-loading Office directory on one un-read click. `resolve_host_path`
did not stop it: Word's `STARTUP` and Excel's `XLSTART` are not in the autorun
denylist, which this file's own docs already concede is "losing by
construction" and was never meant to be the boundary here.
The name is now sanitized of every separator, the drive colon and the rest of
what NTFS refuses, so it cannot be a path on any platform this ships to.
## No dotfile could be saved, and the app pre-filled the name that guaranteed it
The write policy judged the leaf for hiddenness, so `/workspace/.env` was
refused *after* the modal and the overwrite prompt — quoting the name the app
itself had suggested. `.gitignore`, `.dockerignore`, `.eslintrc.json`, `.nvmrc`:
all unsavable, while uploading them worked, so a dotfile could go in and never
come out.
`HostPathUse` gains a third mode. `WriteChosenName` drops the leaf check and
keeps every directory rule, and only `download_container_file` uses it — the
dialog is a real boundary for that caller and only that caller.
`download_container_backup` still takes its path over IPC as a string and keeps
the strict rule.
## Smaller, all found by the reviews
* A download had no ceiling. `dd` resolves through the container's `PATH`,
which its agent owns with passwordless sudo; a replacement writing forever
was measured at ~6 GB/s, so one click on a file listed as 2 KB filled the
host disk with no progress shown and no cancel. Bounded now by what the
file measured, with slack that is absolute for small files and
proportional for large ones, so an honest growing log is unaffected.
* "Framed rather than verbatim" did not stop container text reaching the
toast headline: `readableRefusal` matches with `includes`, and it has to,
because the app's own refusals carry those markers mid-sentence. Anchoring
would break them. The fix is at the injection point — container text is
clipped to one 200-character line with control characters stripped — plus a
`max-h-40` on the toast message, which the `detail` block always had and
this half did not. 8 KB of prose in a `z-[60]` card pushed its own dismiss
button off-screen.
* `savingPath` was a scalar while the design deliberately allows concurrent
saves. Starting a second freed the first's row mid-transfer, and whichever
finished first cleared both; dismissing the second dialog was enough. It is
a `Set` now.
* `setUploading(false)` fired when the command settled, not when the refresh
finished, so a second click landed mid-relisting.
* `upload_files_to_container` checked the container directory before the
picker and then used the unresolved path. A modal has no time limit. It
re-checks after, which is what `docker::exec`'s doc comment already claimed.
* `wait_for_exec_exit` flattened a missing exit code to `Some(0)`, which made
the new `!= Some(0)` check unreachable by construction.
* `normalize_host_path` did not collapse repeated separators, so the lexical
system-root rule was silently absent for `C:\\Windows\…`. Not exploitable —
the resolved pass catches it — but a documented layer that does nothing is
a trap for the next caller.
* README still carried the "no host path crosses IPC in either direction"
claim the previous commit narrowed everywhere else, and HOW-TO-USE
described the hidden rule without saying it applies to folders only.
480 Rust tests, 602 frontend, no new clippy warnings. Eleven mutations against
the new tests, all killed — two of the first round survived and were rewritten:
one because `split_whitespace` already handled the case I thought I was
testing, one because I had deleted a comment rather than the behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
This commit is contained in:
+5
-1
@@ -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,
|
until you pick somewhere in it. Closing a dialog without choosing is not an error: nothing happens,
|
||||||
and nothing is said about it.
|
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
|
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
|
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
|
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
|
meant, `~/.config` among them. The refusal is a plain sentence saying so; choose a visible location
|
||||||
such as `~/Documents` or `~/Downloads`.
|
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
|
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.
|
usually none of the above: edit the file on your host and it is already inside.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- **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
|
`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
|
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
|
`uploadFiles` takes no argument at all. The web UI can ask for a dialog, and that is the whole of
|
||||||
can ask for a dialog, and that is the whole of its influence over where a file comes from or goes.
|
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
|
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
|
- **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
|
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
|
refused, and the check is applied both to the path as written and to what it resolves to after
|
||||||
|
|||||||
@@ -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('/'))
|
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
|
/// What a host path is about to be used for. The three modes differ over which
|
||||||
/// hidden names — see [`validate_host_path`].
|
/// components may be hidden — see [`validate_host_path`] and the variants.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
enum HostPathUse {
|
enum HostPathUse {
|
||||||
/// Host bytes are about to be read *into* the container.
|
/// Host bytes are about to be read *into* the container.
|
||||||
Read,
|
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,
|
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
|
/// 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;
|
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
|
/// 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<PathBuf, Strin
|
|||||||
// directory the path passes *through*.
|
// directory the path passes *through*.
|
||||||
let hidden_limit = match use_for {
|
let hidden_limit = match use_for {
|
||||||
HostPathUse::Write => names.len(),
|
HostPathUse::Write => 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('.')) {
|
if let Some(hidden) = names[..hidden_limit].iter().find(|n| n.starts_with('.')) {
|
||||||
let verb = if use_for == HostPathUse::Write { "save" } else { "read" };
|
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<PathBuf,
|
|||||||
HostPathUse::Read => tokio::fs::canonicalize(&candidate)
|
HostPathUse::Read => tokio::fs::canonicalize(&candidate)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Cannot access {}: {}", candidate.display(), e))?,
|
.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
|
let parent = candidate
|
||||||
.parent()
|
.parent()
|
||||||
.ok_or_else(|| format!("{} does not name a file", candidate.display()))?;
|
.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<PathBuf,
|
|||||||
// as the one the caller typed. Only the *name* is taken from the
|
// as the one the caller typed. Only the *name* is taken from the
|
||||||
// canonical form: a destination that is a symlink gets replaced by
|
// canonical form: a destination that is a symlink gets replaced by
|
||||||
// the rename, never followed, so its target is not what is at risk.
|
// the rename, never followed, so its target is not what is at risk.
|
||||||
if let Ok(full) = tokio::fs::canonicalize(&joined).await {
|
if use_for == HostPathUse::Write {
|
||||||
let real = full.file_name().map(|n| n.to_string_lossy().to_string());
|
if let Ok(full) = tokio::fs::canonicalize(&joined).await {
|
||||||
if real.as_deref().is_some_and(|n| n.starts_with('.')) {
|
let real = full.file_name().map(|n| n.to_string_lossy().to_string());
|
||||||
return Err(format!(
|
if real.as_deref().is_some_and(|n| n.starts_with('.')) {
|
||||||
"\"{}\" is a hidden file — Triple-C will not save there. Choose a visible name.",
|
return Err(format!(
|
||||||
real.unwrap_or_default()
|
"\"{}\" is a hidden file — Triple-C will not save there. Choose a visible name.",
|
||||||
));
|
real.unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
joined
|
joined
|
||||||
@@ -1485,7 +1545,7 @@ pub async fn download_container_file(
|
|||||||
|
|
||||||
require_running(container_id, "saving a file to the host").await?;
|
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 {
|
let Some(chosen) = pick_save_path(&window, &suggested_save_name(&container_path)).await else {
|
||||||
// Dismissed. Not an error, and deliberately distinguishable from one:
|
// Dismissed. Not an error, and deliberately distinguishable from one:
|
||||||
// the frontend shows nothing at all rather than a "cancelled" toast.
|
// the frontend shows nothing at all rather than a "cancelled" toast.
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -1497,36 +1557,56 @@ pub async fn download_container_file(
|
|||||||
// Rust bought — but the rule is cheap and the two host-path commands
|
// 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
|
// 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.
|
// costs. See the note on `pick_save_path` about which ones those are.
|
||||||
let dest = resolve_host_path(&chosen, HostPathUse::Write).await?;
|
let dest = resolve_host_path(&chosen, HostPathUse::WriteChosenName).await?;
|
||||||
|
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
// `$TC_SRC`, never argv interpolation: the path reaches the shell as an
|
// `$TC_SRC`, never argv interpolation: the path reaches the shell as an
|
||||||
// environment value, so a name containing a quote or a `$` is data.
|
// environment value, so a name containing a quote or a `$` is data.
|
||||||
//
|
//
|
||||||
// Three things here are load-bearing against a container that is actively
|
// ## Why `dd iflag=nonblock` and not `cat`
|
||||||
// hostile rather than merely surprising:
|
|
||||||
//
|
//
|
||||||
// * `dd iflag=nonblock` rather than `cat`. `[ -f ]` and the `open` that
|
// `[ -f ]` and the `open` that follows it are two syscalls, and the
|
||||||
// follows it are two syscalls, and the container owns the filesystem in
|
// container owns the filesystem in between — a loop replacing the file with
|
||||||
// between — a loop replacing the file with a FIFO wins that race often
|
// a FIFO wins that race often enough to matter. `cat` then blocks in
|
||||||
// enough to matter. `cat` then blocks in `open(2)` forever with no
|
// `open(2)` forever with no writer, and there is no timeout anywhere on this
|
||||||
// writer, and there is no timeout anywhere on this path: the `invoke`
|
// path: the `invoke` never settles and a partial file is left in the user's
|
||||||
// never settles and a partial file is left in the user's directory for
|
// directory for good. `O_NONBLOCK` makes that open return instead of hang.
|
||||||
// good. `O_NONBLOCK` makes that open return instead of hang. On a
|
// On a regular file the kernel ignores the flag, so this is byte-for-byte
|
||||||
// regular file the flag is ignored by the kernel, so this is
|
// what `cat` did — verified against a real container, `cmp`-clean.
|
||||||
// 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
|
// ## Why the size, and not a second `[ -f ]`
|
||||||
// — 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.
|
// The first version of this bracketed the read with `[ -f ]` again, on the
|
||||||
|
// reasoning that a file which stopped being a regular file had changed
|
||||||
|
// underneath us. That check was wrong in **both** directions, and a review
|
||||||
|
// demonstrated both against a real container:
|
||||||
|
//
|
||||||
|
// * it did not catch the case that loses data. Truncation *in place* —
|
||||||
|
// `> file`, log rotation, `tar -x`, most build tools — leaves a regular
|
||||||
|
// file behind, so `dd` stopped at the new EOF, exited 0, and a 34 MB
|
||||||
|
// partial of a 600 MB file was renamed over the user's own copy and
|
||||||
|
// reported as `Saved (34.1 MB)`. Same for truncate-to-zero, which needs
|
||||||
|
// no adversary at all.
|
||||||
|
// * it failed *good* downloads. `dd` already holds the fd, and neither
|
||||||
|
// `rm` nor `mv` can affect an open one — the bytes are complete and
|
||||||
|
// correct. But `rm` makes `[ -f ]` false, so a finished 600 MB transfer
|
||||||
|
// of a file a bundler happened to unlink was deleted and reported as
|
||||||
|
// "nothing was saved".
|
||||||
|
//
|
||||||
|
// So the question asked is the one that actually matters: **did we get at
|
||||||
|
// least as many bytes as the file had when we started?** `TC_SIZE=` on
|
||||||
|
// stderr carries the answer out (stdout is the payload and must stay
|
||||||
|
// pristine), and Rust compares it against what it wrote.
|
||||||
|
//
|
||||||
|
// That single rule subsumes everything the bracket was for and gets the two
|
||||||
|
// cases above right: a deleted or renamed source still delivers its whole
|
||||||
|
// length and passes; a truncated one delivers less and fails; the FIFO race
|
||||||
|
// delivers zero against a non-zero size and fails. A file *growing* during
|
||||||
|
// the read delivers more than it started with, which passes — correct, and
|
||||||
|
// the reason the test is `>=`.
|
||||||
|
//
|
||||||
|
// `dd` is not `exec`-ed: a replaced shell cannot run what follows it.
|
||||||
let script = r#"if [ -d "$TC_SRC" ]; then
|
let script = r#"if [ -d "$TC_SRC" ]; then
|
||||||
echo "$TC_SRC is a folder — save its files individually." >&2
|
echo "$TC_SRC is a folder — save its files individually." >&2
|
||||||
exit 3
|
exit 3
|
||||||
@@ -1535,11 +1615,12 @@ if [ ! -f "$TC_SRC" ]; then
|
|||||||
echo "$TC_SRC is not a regular file." >&2
|
echo "$TC_SRC is not a regular file." >&2
|
||||||
exit 4
|
exit 4
|
||||||
fi
|
fi
|
||||||
dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5
|
SZ=$(stat -c %s -- "$TC_SRC" 2>/dev/null) || SZ=""
|
||||||
if [ ! -f "$TC_SRC" ]; then
|
case "$SZ" in
|
||||||
echo "$TC_SRC changed while it was being read — nothing was saved." >&2
|
''|*[!0-9]*) echo "Could not measure $TC_SRC before reading it." >&2; exit 7 ;;
|
||||||
exit 6
|
esac
|
||||||
fi"#;
|
echo "TC_SIZE=$SZ" >&2
|
||||||
|
dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||||
|
|
||||||
let exec = docker
|
let exec = docker
|
||||||
.create_exec(
|
.create_exec(
|
||||||
@@ -1603,6 +1684,10 @@ fi"#;
|
|||||||
let mut total: u64 = 0;
|
let mut total: u64 = 0;
|
||||||
let mut stderr_text = String::new();
|
let mut stderr_text = String::new();
|
||||||
let mut stream_err: Option<String> = None;
|
let mut stream_err: Option<String> = None;
|
||||||
|
// The size the container reported before the read, if it has arrived yet.
|
||||||
|
// It is the first thing the script writes, so in practice it is in hand
|
||||||
|
// before the first payload frame — but the loop does not depend on that.
|
||||||
|
let mut declared: Option<u64> = None;
|
||||||
|
|
||||||
while let Some(msg) = output.next().await {
|
while let Some(msg) = output.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
@@ -1612,9 +1697,36 @@ fi"#;
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
total += message.len() as u64;
|
total += message.len() as u64;
|
||||||
|
// A ceiling, derived from what the container itself said the
|
||||||
|
// file was. Without one, a single click on a file the panel
|
||||||
|
// lists as 2 KB can fill the host disk: `dd` is resolved
|
||||||
|
// through the container's `PATH`, which its agent owns with
|
||||||
|
// passwordless sudo, and a replacement that writes forever was
|
||||||
|
// measured at ~6 GB/s against a real container. The UI shows
|
||||||
|
// only "Saving…" while that happens and has no cancel.
|
||||||
|
//
|
||||||
|
// Generous, because a file can legitimately grow while it is
|
||||||
|
// being read — an active log is the ordinary case — and cutting
|
||||||
|
// one of those off would be a bug of our own. What this bounds
|
||||||
|
// is the *unbounded* case: the worst a single click can now
|
||||||
|
// cost is the slack, not the volume.
|
||||||
|
if let Some(limit) = declared.map(download_ceiling) {
|
||||||
|
if total > limit {
|
||||||
|
stream_err = Some(format!(
|
||||||
|
"{} kept producing data long past the {} it reported — stopped at {}. Nothing was saved.",
|
||||||
|
container_path,
|
||||||
|
human_bytes(declared.unwrap_or(0)),
|
||||||
|
human_bytes(total),
|
||||||
|
));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(LogOutput::StdErr { message }) => {
|
Ok(LogOutput::StdErr { message }) => {
|
||||||
push_capped(&mut stderr_text, &message);
|
push_capped(&mut stderr_text, &message);
|
||||||
|
if declared.is_none() {
|
||||||
|
declared = parse_declared_size(&stderr_text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1645,14 +1757,26 @@ fi"#;
|
|||||||
// way this command could destroy data, so silence is failure.
|
// way this command could destroy data, so silence is failure.
|
||||||
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
|
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
|
||||||
if stream_err.is_none() && exit_code != Some(0) {
|
if stream_err.is_none() && exit_code != Some(0) {
|
||||||
// Framed, never verbatim. The script's own refusals are finished
|
// Framed and clipped, never verbatim.
|
||||||
// sentences, but they *quote the container's path* — and a directory
|
//
|
||||||
// can be named anything. `readableRefusal` on the frontend promotes a
|
// Be precise about what the frame buys, because the first version of
|
||||||
// refusal that looks like one of ours to the toast headline, so an
|
// this comment overstated it. `readableRefusal` on the frontend matches
|
||||||
// unframed string is an invitation to write the app's own error message
|
// its markers with `includes`, not a prefix test — and it has to, since
|
||||||
// from inside the container. The frame does not make the text
|
// the app's own refusals carry those markers mid-sentence. So a frame
|
||||||
// trustworthy; it makes it visibly quoted.
|
// does **not** stop container text reaching the toast headline: text
|
||||||
let detail = stderr_text.trim();
|
// containing "Triple-C will not" is promoted wherever it sits.
|
||||||
|
//
|
||||||
|
// What the frame does buy is that the app's own words come first, so
|
||||||
|
// the quoted part is visibly quoted. What the *clip* buys is the part
|
||||||
|
// that actually mattered: `MAX_EXEC_STDERR` is 8 KiB, which is room for
|
||||||
|
// a convincing forged instruction, and a toast is rendered above every
|
||||||
|
// modal. A couple of hundred characters on one line is a diagnostic;
|
||||||
|
// eight kilobytes of prose is a phishing surface with a scrollbar.
|
||||||
|
//
|
||||||
|
// Newlines and control characters go too: they are what let quoted text
|
||||||
|
// fake the structure of a message the app wrote.
|
||||||
|
let detail = clip_container_text(&stderr_text);
|
||||||
|
let detail = detail.as_str();
|
||||||
stream_err = Some(match exit_code {
|
stream_err = Some(match exit_code {
|
||||||
None => format!(
|
None => format!(
|
||||||
"Could not confirm that {} was read completely — nothing was saved.",
|
"Could not confirm that {} was read completely — nothing was saved.",
|
||||||
@@ -1665,6 +1789,41 @@ fi"#;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Short of what the file measured is the data-loss case, and it is the one
|
||||||
|
// an exit code cannot see: `dd` reports success for a file truncated under
|
||||||
|
// it, because it faithfully read to the EOF it was given. Verified against
|
||||||
|
// a real container — a 600 MB source truncated mid-read delivered 34 MB at
|
||||||
|
// exit 0, and before this check that 34 MB was renamed over the user's own
|
||||||
|
// copy and toasted as `Saved (34.1 MB)`.
|
||||||
|
//
|
||||||
|
// `>=`, not `==`: a file being appended to during the read delivers more
|
||||||
|
// than it measured, and that is a complete read, not a failure. A file
|
||||||
|
// deleted or renamed mid-read also passes, correctly — `dd` holds the fd
|
||||||
|
// and neither operation can touch it, so the bytes are whole.
|
||||||
|
if stream_err.is_none() {
|
||||||
|
match declared {
|
||||||
|
Some(size) if total < size => {
|
||||||
|
stream_err = Some(format!(
|
||||||
|
"{} was {} when the copy started and only {} arrived — it changed while it was being read, so nothing was saved.",
|
||||||
|
container_path,
|
||||||
|
human_bytes(size),
|
||||||
|
human_bytes(total),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// The script exits 7 rather than staying quiet if it cannot measure
|
||||||
|
// the file, so a missing size here means the exec never got that
|
||||||
|
// far — and a zero exit with no measurement is not something to
|
||||||
|
// rename over the user's file on trust.
|
||||||
|
None => {
|
||||||
|
stream_err = Some(format!(
|
||||||
|
"Could not confirm how much of {} arrived — nothing was saved.",
|
||||||
|
container_path
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(err) = stream_err {
|
if let Some(err) = stream_err {
|
||||||
// Only our own partial is removed. Whatever the user already had at
|
// Only our own partial is removed. Whatever the user already had at
|
||||||
// `dest` has not been touched at any point above.
|
// `dest` has not been touched at any point above.
|
||||||
@@ -1761,6 +1920,16 @@ pub async fn upload_files_to_container(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Again, now that the picker has closed. The check above is there so a
|
||||||
|
// stopped or unwritable project says so *before* asking anyone to choose
|
||||||
|
// files; this one is the check that actually guards the extraction. A modal
|
||||||
|
// has no time limit, and `resolve_container_dir` answers a question about
|
||||||
|
// the container's filesystem — which the container is free to change while
|
||||||
|
// the dialog is open. Without this, `docker::exec`'s doc comment claim that
|
||||||
|
// the destination "has already been confirmed" would be true only of a
|
||||||
|
// moment that had passed.
|
||||||
|
resolve_container_dir(container_id, "Upload", &container_dir).await?;
|
||||||
|
|
||||||
// Once for the whole selection, not once per file — see
|
// Once for the whole selection, not once per file — see
|
||||||
// `upload_host_file_with_ids`.
|
// `upload_host_file_with_ids`.
|
||||||
let ids = crate::docker::exec::container_user_ids(container_id).await;
|
let ids = crate::docker::exec::container_user_ids(container_id).await;
|
||||||
@@ -1836,6 +2005,87 @@ async fn upload_one(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Container-authored diagnostic text, cut down to something safe to quote.
|
||||||
|
///
|
||||||
|
/// One line, a couple of hundred characters, no control characters. See the
|
||||||
|
/// call site for why each of those three matters; the short version is that
|
||||||
|
/// this text ends up inside a toast that renders above every modal, and its
|
||||||
|
/// author is the container.
|
||||||
|
fn clip_container_text(text: &str) -> String {
|
||||||
|
const MAX: usize = 200;
|
||||||
|
let flattened: String = text
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_control() { ' ' } else { c })
|
||||||
|
.collect();
|
||||||
|
let flattened = flattened.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
if flattened.chars().count() <= MAX {
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
let kept: String = flattened.chars().take(MAX).collect();
|
||||||
|
format!("{}…", kept.trim_end())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull `TC_SIZE=<n>` out of what the download script wrote to stderr.
|
||||||
|
///
|
||||||
|
/// A line rather than a second channel because there is no second channel: the
|
||||||
|
/// exec has exactly two streams and stdout is the payload, which has to stay
|
||||||
|
/// pristine — running it through anything is how a download corrupts a binary.
|
||||||
|
///
|
||||||
|
/// Deliberately strict. The container writes this, so it is parsed as one
|
||||||
|
/// well-formed line and nothing else: an unparseable value yields `None`, and
|
||||||
|
/// `None` fails the transfer rather than waving it through. The script has
|
||||||
|
/// already refused (exit 7) if `stat` could not answer, so a missing value here
|
||||||
|
/// means something further upstream went wrong.
|
||||||
|
fn parse_declared_size(stderr: &str) -> Option<u64> {
|
||||||
|
stderr
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.trim().strip_prefix("TC_SIZE="))
|
||||||
|
.and_then(|value| value.trim().parse::<u64>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much more than its measured size a file may deliver before the transfer
|
||||||
|
/// is treated as runaway.
|
||||||
|
///
|
||||||
|
/// Two shapes at once, because the two ends of the range need different things.
|
||||||
|
/// A small file needs *absolute* slack — a 2 KB file that grows to 40 MB is
|
||||||
|
/// unremarkable, and a multiplier would strangle it. A large one needs
|
||||||
|
/// *proportional* slack — doubling is plenty, and a fixed 256 MiB on top of
|
||||||
|
/// 3 GB is noise. So: whichever is larger.
|
||||||
|
///
|
||||||
|
/// This is a bound on the pathological case, not an attempt to predict the
|
||||||
|
/// honest one. The honest case is bounded by the file; this exists so that a
|
||||||
|
/// container answering a 2 KB read with an infinite stream costs the slack
|
||||||
|
/// rather than the disk.
|
||||||
|
fn download_ceiling(declared: u64) -> u64 {
|
||||||
|
const FLOOR: u64 = 256 * 1024 * 1024;
|
||||||
|
declared.saturating_mul(2).max(declared.saturating_add(FLOOR))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes as a person reads them, for a refusal.
|
||||||
|
///
|
||||||
|
/// The frontend has `formatBytes` and these strings are built in Rust, so they
|
||||||
|
/// cannot share it. Kept deliberately small — this is for error text, not for
|
||||||
|
/// the UI, which formats its own.
|
||||||
|
fn human_bytes(bytes: u64) -> String {
|
||||||
|
const UNITS: [(&str, u64); 4] = [
|
||||||
|
("GB", 1024 * 1024 * 1024),
|
||||||
|
("MB", 1024 * 1024),
|
||||||
|
("KB", 1024),
|
||||||
|
("bytes", 1),
|
||||||
|
];
|
||||||
|
for (unit, scale) in UNITS {
|
||||||
|
if bytes >= scale {
|
||||||
|
return if scale == 1 {
|
||||||
|
format!("{} {}", bytes, unit)
|
||||||
|
} else {
|
||||||
|
format!("{:.1} {}", bytes as f64 / scale as f64, unit)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"0 bytes".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// The path a dialog handed back, as a `String`, or a refusal.
|
/// 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
|
/// Every host-path check in this module is `&str`-based, so a `PathBuf` has to
|
||||||
@@ -1859,16 +2109,58 @@ fn host_path_string(path: PathBuf) -> Result<String, String> {
|
|||||||
|
|
||||||
/// The name the save dialog opens with.
|
/// The name the save dialog opens with.
|
||||||
///
|
///
|
||||||
/// `unwrap_or` is not enough on its own: `rsplit` on a path with a trailing
|
/// ## This string is container-authored, and it is a *name*, not a path
|
||||||
/// 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
|
/// It goes straight to `rfd`'s `set_file_name`, and on Windows the common file
|
||||||
/// what makes the fallback reachable.
|
/// dialog parses its File-name box as a **path** on Save. A container can name
|
||||||
fn suggested_save_name(container_path: &str) -> &str {
|
/// a file anything a Linux filesystem accepts, backslashes included, and
|
||||||
container_path
|
/// `validate_container_path` has no reason to object: it rejects a `..`
|
||||||
|
/// *segment*, and `..\..\Users\vic\…` is one POSIX segment.
|
||||||
|
///
|
||||||
|
/// So `rsplit('/')` alone — which is what this was — let the container choose
|
||||||
|
/// the destination on Windows, not just the file name:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// /workspace/proj/..\..\..\Users\vic\AppData\Roaming\Microsoft\Word\STARTUP\x.dotm
|
||||||
|
/// -> "..\..\..\Users\vic\AppData\Roaming\Microsoft\Word\STARTUP\x.dotm"
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// One un-read click on Save and that resolves. `resolve_host_path` still runs
|
||||||
|
/// on whatever the dialog produced, but Word's `STARTUP` and Excel's `XLSTART`
|
||||||
|
/// are auto-loading persistence directories that the denylist does not name —
|
||||||
|
/// and this file's own docs concede the denylist is "losing by construction".
|
||||||
|
/// It was never meant to be the boundary for this caller.
|
||||||
|
///
|
||||||
|
/// The fix is to make the string incapable of being a path on any platform this
|
||||||
|
/// ships to: every separator, the drive colon, and the rest of the characters
|
||||||
|
/// NTFS refuses are replaced rather than removed, so the name stays the same
|
||||||
|
/// length and stays recognisable. `?` and `*` are legal on Linux and go too —
|
||||||
|
/// this is a *suggestion* the user can edit, and a pre-filled name Windows
|
||||||
|
/// would reject is its own small bug.
|
||||||
|
///
|
||||||
|
/// The empty check is separate and also load-bearing: `rsplit` on a path with a
|
||||||
|
/// trailing separator yields `Some("")`, so without it the fallback never fires
|
||||||
|
/// and the dialog opens with a blank name.
|
||||||
|
fn suggested_save_name(container_path: &str) -> String {
|
||||||
|
let leaf = container_path
|
||||||
.rsplit('/')
|
.rsplit('/')
|
||||||
.next()
|
.next()
|
||||||
.filter(|leaf| !leaf.is_empty())
|
.filter(|leaf| !leaf.is_empty())
|
||||||
.unwrap_or("download")
|
.unwrap_or("download");
|
||||||
|
let cleaned: String = leaf
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*' => '_',
|
||||||
|
c if c.is_control() => '_',
|
||||||
|
c => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// `.` and `..` are names the dialog cannot use, and a name that sanitised
|
||||||
|
// down to nothing has nothing left to suggest.
|
||||||
|
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
|
||||||
|
return "download".to_string();
|
||||||
|
}
|
||||||
|
cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask the user where to save, from Rust.
|
/// Ask the user where to save, from Rust.
|
||||||
@@ -1889,13 +2181,18 @@ fn suggested_save_name(container_path: &str) -> &str {
|
|||||||
/// ## What is still validated, and what that costs
|
/// ## What is still validated, and what that costs
|
||||||
///
|
///
|
||||||
/// The chosen path still goes through `resolve_host_path`, whose hidden-
|
/// The chosen path still goes through `resolve_host_path`, whose hidden-
|
||||||
/// component rule deliberately over-catches (see [`validate_host_path`]). That
|
/// *directory* rule deliberately over-catches (see [`validate_host_path`]).
|
||||||
/// rule was written for adversarial input, and against a *dialog* it will
|
/// That rule was written for adversarial input, and against a dialog it will
|
||||||
/// occasionally refuse something a person meant — saving into `~/.config`, or
|
/// 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
|
/// 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
|
/// sentence and the destinations it costs are unusual ones for this pane.
|
||||||
/// having the two host-path commands disagree about what is writable is a worse
|
///
|
||||||
/// failure than an occasional "choose somewhere else".
|
/// The hidden *leaf* rule is not kept, and the distinction matters. Under it,
|
||||||
|
/// saving `/workspace/.env` was refused after the modal and after the overwrite
|
||||||
|
/// prompt, quoting a name the app had pre-filled itself — and every
|
||||||
|
/// `.gitignore`, `.eslintrc.json` and `.nvmrc` in an ordinary workspace with
|
||||||
|
/// it, while uploading the same files worked. That is what
|
||||||
|
/// [`HostPathUse::WriteChosenName`] exists for.
|
||||||
///
|
///
|
||||||
/// `None` means dismissed, which is not a failure. The `oneshot` is used rather
|
/// `None` means dismissed, which is not a failure. The `oneshot` is used rather
|
||||||
/// than `blocking_save_file` because the blocking variants deadlock if they
|
/// than `blocking_save_file` because the blocking variants deadlock if they
|
||||||
@@ -2268,6 +2565,121 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The dialog's pre-filled name is authored by the **container**, and on
|
||||||
|
/// Windows the save dialog parses its name box as a path. So the string
|
||||||
|
/// must be incapable of being one.
|
||||||
|
#[test]
|
||||||
|
fn a_suggested_name_can_never_be_a_path() {
|
||||||
|
// The finding this exists for, verbatim: `..` segments plus backslashes
|
||||||
|
// reach Word's auto-loading STARTUP directory, which the host-path
|
||||||
|
// denylist does not name.
|
||||||
|
let evil = r"/workspace/proj/..\..\..\Users\vic\AppData\Roaming\Microsoft\Word\STARTUP\x.dotm";
|
||||||
|
let got = suggested_save_name(evil);
|
||||||
|
assert!(!got.contains('\\'), "{}", got);
|
||||||
|
assert!(!got.contains('/'), "{}", got);
|
||||||
|
// A drive letter is a path on Windows too.
|
||||||
|
let drive = r"/workspace/proj/C:\Users\vic\Desktop\payload.exe";
|
||||||
|
let got = suggested_save_name(drive);
|
||||||
|
assert!(!got.contains(':'), "{}", got);
|
||||||
|
assert!(!got.contains('\\'), "{}", got);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitising must not damage the ordinary case — the whole point of a
|
||||||
|
/// suggestion is that it is the file's name.
|
||||||
|
#[test]
|
||||||
|
fn an_ordinary_suggested_name_is_untouched() {
|
||||||
|
assert_eq!(suggested_save_name("/workspace/notes.txt"), "notes.txt");
|
||||||
|
assert_eq!(suggested_save_name("/workspace/my report (v2).pdf"), "my report (v2).pdf");
|
||||||
|
// Dotfiles are the common case this pane browses, and they are saveable
|
||||||
|
// now — see `HostPathUse::WriteChosenName`.
|
||||||
|
assert_eq!(suggested_save_name("/workspace/.env"), ".env");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name that sanitises down to nothing usable still has to open the
|
||||||
|
/// dialog with something.
|
||||||
|
#[test]
|
||||||
|
fn a_suggested_name_always_has_something_in_it() {
|
||||||
|
assert_eq!(suggested_save_name("/workspace/logs/"), "download");
|
||||||
|
assert_eq!(suggested_save_name("/"), "download");
|
||||||
|
assert_eq!(suggested_save_name(""), "download");
|
||||||
|
// `/` alone sanitises to `_`, not to nothing — that is fine and still a
|
||||||
|
// name. But a leaf that *is* `..` is not usable as one.
|
||||||
|
assert_eq!(suggested_save_name("/workspace/.."), "download");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dotfile must be saveable to the host. Under the old rule the leaf was
|
||||||
|
/// judged for hiddenness on every write, so `.env` was refused *after* the
|
||||||
|
/// modal — with the name the app itself had pre-filled.
|
||||||
|
#[test]
|
||||||
|
fn a_dotfile_can_be_saved_when_the_user_named_it_in_a_dialog() {
|
||||||
|
let dest = "/home/j/Documents/.env";
|
||||||
|
assert!(
|
||||||
|
validate_host_path(dest, HostPathUse::WriteChosenName).is_ok(),
|
||||||
|
"a name chosen in a save dialog should be allowed to be hidden"
|
||||||
|
);
|
||||||
|
// But only the leaf. A hidden *directory* is refused exactly as before.
|
||||||
|
assert!(validate_host_path("/home/j/.ssh/authorized_keys", HostPathUse::WriteChosenName).is_err());
|
||||||
|
assert!(validate_host_path("/home/j/.config/x.txt", HostPathUse::WriteChosenName).is_err());
|
||||||
|
// And the IPC-fed writer keeps the strict rule, because for it the
|
||||||
|
// dialog is not a boundary.
|
||||||
|
assert!(validate_host_path(dest, HostPathUse::Write).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size line the download script emits is the only thing standing
|
||||||
|
/// between a truncated read and a rename over the user's own file.
|
||||||
|
#[test]
|
||||||
|
fn the_declared_size_is_read_out_of_stderr() {
|
||||||
|
assert_eq!(parse_declared_size("TC_SIZE=600000000\n"), Some(600000000));
|
||||||
|
// Real diagnostics can share the stream.
|
||||||
|
assert_eq!(
|
||||||
|
parse_declared_size("dd: warning: something\nTC_SIZE=42\n"),
|
||||||
|
Some(42)
|
||||||
|
);
|
||||||
|
assert_eq!(parse_declared_size("TC_SIZE=0"), Some(0));
|
||||||
|
// Container-authored, so anything unparseable is *no answer*, which the
|
||||||
|
// caller turns into a refusal rather than a pass.
|
||||||
|
assert_eq!(parse_declared_size("TC_SIZE=notanumber"), None);
|
||||||
|
assert_eq!(parse_declared_size("TC_SIZE=-1"), None);
|
||||||
|
assert_eq!(parse_declared_size("nothing here"), None);
|
||||||
|
assert_eq!(parse_declared_size(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The runaway ceiling has to be generous enough never to cut off an honest
|
||||||
|
/// read and tight enough that a 2 KB file cannot fill a disk.
|
||||||
|
#[test]
|
||||||
|
fn the_download_ceiling_bounds_the_pathological_case_only() {
|
||||||
|
// A small file gets absolute slack: an active log growing past its
|
||||||
|
// starting size is ordinary.
|
||||||
|
assert!(download_ceiling(2048) >= 256 * 1024 * 1024);
|
||||||
|
// A large one gets proportional slack; doubling is plenty.
|
||||||
|
let three_gb = 3 * 1024 * 1024 * 1024;
|
||||||
|
assert!(download_ceiling(three_gb) >= three_gb * 2);
|
||||||
|
// Never below the file itself, and no overflow at the top.
|
||||||
|
assert!(download_ceiling(0) > 0);
|
||||||
|
assert!(download_ceiling(u64::MAX) >= u64::MAX / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container-authored diagnostics end up in a toast that renders above
|
||||||
|
/// every modal, so they arrive as one short line.
|
||||||
|
#[test]
|
||||||
|
fn container_diagnostics_are_clipped_to_one_short_line() {
|
||||||
|
let hostile = format!("Triple-C will not save there.\n\n{}", "pad ".repeat(4000));
|
||||||
|
let out = clip_container_text(&hostile);
|
||||||
|
assert!(out.chars().count() <= 201, "{} chars", out.chars().count());
|
||||||
|
assert!(!out.contains('\n'));
|
||||||
|
assert!(!out.contains('\r'));
|
||||||
|
// Not just newlines — `split_whitespace` would have handled those on its
|
||||||
|
// own. The control-character map is here for the ones it would not:
|
||||||
|
// an ESC lets container text repaint or hide part of a terminal, and a
|
||||||
|
// backspace lets it overwrite the app's own framing.
|
||||||
|
let escapes = clip_container_text("dd: \u{1b}[2Jcannot\u{8}\u{8} open");
|
||||||
|
assert!(!escapes.contains('\u{1b}'), "{:?}", escapes);
|
||||||
|
assert!(!escapes.contains('\u{8}'), "{:?}", escapes);
|
||||||
|
// A real diagnostic still survives intact.
|
||||||
|
assert_eq!(clip_container_text(" dd: cannot open 'x' "), "dd: cannot open 'x'");
|
||||||
|
assert_eq!(clip_container_text(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_save_dialog_always_opens_with_a_name() {
|
fn a_save_dialog_always_opens_with_a_name() {
|
||||||
assert_eq!(suggested_save_name("/workspace/notes.txt"), "notes.txt");
|
assert_eq!(suggested_save_name("/workspace/notes.txt"), "notes.txt");
|
||||||
|
|||||||
@@ -818,8 +818,17 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
|||||||
match docker.inspect_exec(exec_id).await {
|
match docker.inspect_exec(exec_id).await {
|
||||||
Ok(info) => {
|
Ok(info) => {
|
||||||
if info.running != Some(true) {
|
if info.running != Some(true) {
|
||||||
// Finished: use the reported code (default 0 if somehow absent).
|
// Finished. `exit_code` rather than `unwrap_or(0)`: an exec
|
||||||
return Some(info.exit_code.unwrap_or(0));
|
// that has stopped without a reported code is a status
|
||||||
|
// nobody can vouch for, and flattening it to *success* is
|
||||||
|
// the wrong default when a caller is deciding whether to
|
||||||
|
// rename a downloaded file over the user's own.
|
||||||
|
// `download_container_file` treats `None` as a failure
|
||||||
|
// precisely because it cannot tell that silence from a
|
||||||
|
// clean exit; an `unwrap_or` here made that check
|
||||||
|
// unreachable. Callers that only care about "did it fail
|
||||||
|
// loudly" use `is_some_and`, which reads `None` as before.
|
||||||
|
return info.exit_code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => return None,
|
Err(_) => return None,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default function FilesTab({ project }: Props) {
|
|||||||
uploadFiles,
|
uploadFiles,
|
||||||
saveToHost,
|
saveToHost,
|
||||||
uploading,
|
uploading,
|
||||||
savingPath,
|
savingPaths,
|
||||||
} = useFileManager(project.id);
|
} = useFileManager(project.id);
|
||||||
|
|
||||||
const running = project.status === "running";
|
const running = project.status === "running";
|
||||||
@@ -540,7 +540,7 @@ export default function FilesTab({ project }: Props) {
|
|||||||
// Only this row: a large file can take a while,
|
// Only this row: a large file can take a while,
|
||||||
// and there is no reason the rest of the pane
|
// and there is no reason the rest of the pane
|
||||||
// should go dead while it is written.
|
// should go dead while it is written.
|
||||||
disabled={savingPath === entry.path}
|
disabled={savingPaths.has(entry.path)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void saveToHost(entry);
|
void saveToHost(entry);
|
||||||
@@ -553,7 +553,7 @@ export default function FilesTab({ project }: Props) {
|
|||||||
// save dialog the backend had just opened.
|
// save dialog the backend had just opened.
|
||||||
onDoubleClick={(e) => e.stopPropagation()}
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{savingPath === entry.path ? "Saving…" : "Save to host…"}
|
{savingPaths.has(entry.path) ? "Saving…" : "Save to host…"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -47,7 +47,16 @@ function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
|
|||||||
{tone.glyph}
|
{tone.glyph}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[var(--text-primary)] break-words">{toast.message}</div>
|
{/* Clamped. A toast message is normally a sentence, but some of them
|
||||||
|
quote text a *container* wrote — and this card is `z-[60]`, above
|
||||||
|
every modal, with its dismiss button at the top. An unclamped
|
||||||
|
message of a few kilobytes is a card taller than the viewport whose
|
||||||
|
✕ has been pushed off-screen, i.e. an unclosable overlay. The
|
||||||
|
`detail` block below has always had `max-h-40 overflow-auto`; this
|
||||||
|
half did not. */}
|
||||||
|
<div className="text-[var(--text-primary)] break-words max-h-40 overflow-y-auto">
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
{toast.detail && (
|
{toast.detail && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -471,6 +471,38 @@ describe("useFileManager transfer state", () => {
|
|||||||
expect(result.current.uploading).toBe(false);
|
expect(result.current.uploading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stays in flight through the refresh, not just the transfer", async () => {
|
||||||
|
// Clearing the flag the moment the command settled put the button back
|
||||||
|
// while the re-listing was still running, so a second click landed
|
||||||
|
// mid-refresh on a grid that was still showing the old contents.
|
||||||
|
uploadFilesToContainer.mockResolvedValueOnce({
|
||||||
|
uploaded: ["/workspace/a.txt"],
|
||||||
|
failures: [],
|
||||||
|
});
|
||||||
|
let finishListing: (v: unknown) => void = () => {};
|
||||||
|
listContainerFiles.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finishListing = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
let uploading: Promise<void>;
|
||||||
|
act(() => {
|
||||||
|
uploading = result.current.uploadFiles();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
// The transfer is done; the listing it triggered is not.
|
||||||
|
expect(result.current.uploading).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
finishListing([file("a.txt")]);
|
||||||
|
await uploading;
|
||||||
|
});
|
||||||
|
expect(result.current.uploading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("clears the upload flag when the transfer fails", async () => {
|
it("clears the upload flag when the transfer fails", async () => {
|
||||||
// The `catch` returns early, so without a `finally` the button is disabled
|
// 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
|
// for the rest of the session — the failure mode is a pane that can never
|
||||||
@@ -483,41 +515,47 @@ describe("useFileManager transfer state", () => {
|
|||||||
expect(result.current.uploading).toBe(false);
|
expect(result.current.uploading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks only the row being saved, and clears it on failure", async () => {
|
it("tracks each save separately, so one finishing does not free another", async () => {
|
||||||
let release: (v: unknown) => void = () => {};
|
// The bug this exists for: `savingPath` was a single string. Starting a
|
||||||
downloadContainerFile.mockReturnValueOnce(
|
// second save overwrote it, so the first row went live again mid-transfer,
|
||||||
new Promise((resolve) => {
|
// and whichever save settled first cleared the flag for both — dismissing
|
||||||
release = resolve;
|
// the second dialog was enough. A set is what the design needs, because
|
||||||
}),
|
// "only the row being saved is disabled" is exactly what makes a second
|
||||||
);
|
// save startable.
|
||||||
const { result } = renderHook(() => useFileManager("p1"));
|
let releaseBig: (v: unknown) => void = () => {};
|
||||||
expect(result.current.savingPath).toBeNull();
|
let releaseSmall: (v: unknown) => void = () => {};
|
||||||
let saving: Promise<void>;
|
downloadContainerFile
|
||||||
act(() => {
|
.mockReturnValueOnce(new Promise((r) => { releaseBig = r; }))
|
||||||
saving = result.current.saveToHost(file("a.txt"));
|
.mockReturnValueOnce(new Promise((r) => { releaseSmall = r; }));
|
||||||
});
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
let big: Promise<void>;
|
||||||
|
let small: Promise<void>;
|
||||||
|
act(() => { big = result.current.saveToHost(file("big.bin")); });
|
||||||
|
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
|
||||||
|
|
||||||
|
act(() => { small = result.current.saveToHost(file("notes.txt")); });
|
||||||
|
// Both, at once — a scalar could only hold the second.
|
||||||
|
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
|
||||||
|
expect(result.current.savingPaths.has("/workspace/notes.txt")).toBe(true);
|
||||||
|
|
||||||
|
// The second one finishing must not re-enable the first, which is still
|
||||||
|
// streaming. `null` is the dismissal path, which is how this was cheapest
|
||||||
|
// to trigger in practice.
|
||||||
|
await act(async () => { releaseSmall(null); await small; });
|
||||||
|
expect(result.current.savingPaths.has("/workspace/notes.txt")).toBe(false);
|
||||||
|
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => { releaseBig(10); await big; });
|
||||||
|
expect(result.current.savingPaths.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a row's saving flag when its save fails", async () => {
|
||||||
downloadContainerFile.mockRejectedValueOnce("Permission denied");
|
downloadContainerFile.mockRejectedValueOnce("Permission denied");
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.saveToHost(file("b.txt"));
|
await result.current.saveToHost(file("b.txt"));
|
||||||
});
|
});
|
||||||
expect(result.current.savingPath).toBeNull();
|
expect(result.current.savingPaths.size).toBe(0);
|
||||||
|
|
||||||
// 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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,11 +52,19 @@ export function useFileManager(projectId: string) {
|
|||||||
* against the same file, and a multi-gigabyte save is indistinguishable from
|
* against the same file, and a multi-gigabyte save is indistinguishable from
|
||||||
* a click that did nothing.
|
* a click that did nothing.
|
||||||
*
|
*
|
||||||
* `savingPath` rather than a boolean, so only the row being saved is
|
* `savingPaths` is a **set**, not one path. Keeping only the row being
|
||||||
* disabled — the pane stays usable while a big file is written.
|
* disabled is what makes the pane usable during a big transfer — and that is
|
||||||
|
* precisely what makes a *second* save startable, so the state has to be able
|
||||||
|
* to hold two. As a scalar it could not: starting a save on `notes.txt` while
|
||||||
|
* `big.bin` was still streaming overwrote it, so `big.bin`'s button went live
|
||||||
|
* again mid-transfer; and whichever save finished first cleared the flag for
|
||||||
|
* both. Dismissing the second dialog was enough to do it.
|
||||||
|
*
|
||||||
|
* Paths are unique within a listing, so a path is a usable key — `FilesTab`
|
||||||
|
* relies on the same fact for its row keys.
|
||||||
*/
|
*/
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [savingPath, setSavingPath] = useState<string | null>(null);
|
const [savingPaths, setSavingPaths] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
|
||||||
const currentPathRef = useRef(currentPath);
|
const currentPathRef = useRef(currentPath);
|
||||||
|
|
||||||
@@ -184,33 +192,37 @@ export function useFileManager(projectId: string) {
|
|||||||
*/
|
*/
|
||||||
const uploadFiles = useCallback(async () => {
|
const uploadFiles = useCallback(async () => {
|
||||||
const target = currentPathRef.current;
|
const target = currentPathRef.current;
|
||||||
let outcome;
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
outcome = await commands.uploadFilesToContainer(projectId, target);
|
let outcome;
|
||||||
} catch (e) {
|
try {
|
||||||
// A failure *before* the picker: no container, not running, or a
|
outcome = await commands.uploadFilesToContainer(projectId, target);
|
||||||
// directory this pane may not write to. One toast, not one per file.
|
} catch (e) {
|
||||||
report("Could not upload", e);
|
// A failure *before* the picker: no container, not running, or a
|
||||||
return;
|
// directory this pane may not write to. One toast, not one per file.
|
||||||
} finally {
|
report("Could not upload", e);
|
||||||
setUploading(false);
|
return;
|
||||||
}
|
}
|
||||||
if (!outcome) return;
|
if (!outcome) return;
|
||||||
for (const failure of outcome.failures) {
|
for (const failure of outcome.failures) {
|
||||||
useAppState.getState().pushToast({ kind: "error", message: failure });
|
useAppState.getState().pushToast({ kind: "error", message: failure });
|
||||||
}
|
}
|
||||||
if (outcome.uploaded.length > 0) {
|
if (outcome.uploaded.length === 0) return;
|
||||||
// The directory is named, not implied. `target` is captured at click
|
// The directory is named, not implied. `target` is captured at click time
|
||||||
// time and the picker is a modal OS dialog — the user has all the time in
|
// and the picker is a modal OS dialog — the user has all the time in the
|
||||||
// the world to browse somewhere else while it is open, and the files land
|
// 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
|
// where they started. "Uploaded 2 files." in front of a grid that does
|
||||||
// contain them is a worse answer than no message at all.
|
// not contain them is a worse answer than no message at all.
|
||||||
const count = outcome.uploaded.length;
|
const count = outcome.uploaded.length;
|
||||||
setCompleted(
|
setCompleted(
|
||||||
`Uploaded ${count === 1 ? "1 file" : `${count} files`} to ${target}.`,
|
`Uploaded ${count === 1 ? "1 file" : `${count} files`} to ${target}.`,
|
||||||
);
|
);
|
||||||
if (currentPathRef.current === target) await navigate(target);
|
if (currentPathRef.current === target) await navigate(target);
|
||||||
|
} finally {
|
||||||
|
// Around the *whole* body, refresh included. Clearing it the moment the
|
||||||
|
// command settled put the button back before the re-listing had run, so
|
||||||
|
// a second click landed mid-refresh on a grid that was still the old one.
|
||||||
|
setUploading(false);
|
||||||
}
|
}
|
||||||
}, [projectId, navigate, report]);
|
}, [projectId, navigate, report]);
|
||||||
|
|
||||||
@@ -223,7 +235,7 @@ export function useFileManager(projectId: string) {
|
|||||||
*/
|
*/
|
||||||
const saveToHost = useCallback(
|
const saveToHost = useCallback(
|
||||||
async (entry: FileEntry) => {
|
async (entry: FileEntry) => {
|
||||||
setSavingPath(entry.path);
|
setSavingPaths((live) => new Set(live).add(entry.path));
|
||||||
try {
|
try {
|
||||||
const bytes = await commands.downloadContainerFile(projectId, entry.path);
|
const bytes = await commands.downloadContainerFile(projectId, entry.path);
|
||||||
// `0` is a real answer — an empty file saved is a success — so this
|
// `0` is a real answer — an empty file saved is a success — so this
|
||||||
@@ -233,7 +245,13 @@ export function useFileManager(projectId: string) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
report(`Could not save "${entry.name}"`, e);
|
report(`Could not save "${entry.name}"`, e);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingPath(null);
|
// Remove only this one. A save that finishes while another is still
|
||||||
|
// streaming must not re-enable the other's row.
|
||||||
|
setSavingPaths((live) => {
|
||||||
|
const next = new Set(live);
|
||||||
|
next.delete(entry.path);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId, report],
|
[projectId, report],
|
||||||
@@ -257,6 +275,6 @@ export function useFileManager(projectId: string) {
|
|||||||
saveToHost,
|
saveToHost,
|
||||||
/** A host transfer is in flight — see the state declarations above. */
|
/** A host transfer is in flight — see the state declarations above. */
|
||||||
uploading,
|
uploading,
|
||||||
savingPath,
|
savingPaths,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user