Merge branch 'r4/narrow' into ship/core

This commit is contained in:
2026-08-23 17:08:04 -07:00
19 changed files with 761 additions and 2928 deletions
File diff suppressed because it is too large Load Diff
+14 -16
View File
@@ -197,18 +197,17 @@ pub async fn upload_host_file_to_terminal(
state: State<'_, AppState>,
) -> Result<String, String> {
// The drop target is a host path chosen by the webview, not by the OS drag
// itself, so it gets the same host-read policy as the Files pane's upload:
// absolute, no traversal, and nothing out of a hidden directory
// (`~/.ssh`, `~/.aws`) or a system location — applied to the path with its
// symlinks already resolved, so a visible directory that *leads* to `~/.ssh`
// is refused too. What comes back is that resolved path, and it is what
// gets opened.
// itself, so it goes through `file_commands`' host-read policy: absolute,
// no traversal, and nothing whose path passes through a hidden directory
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
// the path with its symlinks already resolved, so a visible directory that
// *leads* to one of those is refused too. What comes back is that resolved
// path, and it is what gets opened. This is now one of only two commands
// that touch a host path at all; the other is `download_container_backup`.
// The name is taken from the path the user actually dropped, *before*
// resolution. Deriving it from the resolved path renames the file behind
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
// a symlink, would land it in the container as `2026-08-23.log`. The Files
// pane's upload had the same bug and fixes it the same way — one helper, so
// the two drop targets cannot drift.
// a symlink, would land it in the container as `2026-08-23.log`.
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
@@ -229,7 +228,7 @@ pub async fn upload_host_file_to_terminal(
use crate::docker::exec::MAX_DROP_BYTES;
if meta.len() > MAX_DROP_BYTES {
return Err(format!(
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
meta.len() as f64 / (1024.0 * 1024.0),
MAX_DROP_BYTES / (1024 * 1024)
));
@@ -301,19 +300,18 @@ pub async fn stop_audio_bridge(
#[cfg(test)]
mod tests {
/// Both drop targets must name a dropped file the way the *user* named it.
/// A dropped file must be named the way the *user* named it.
///
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
/// name from the path *after* symlink resolution, so dropping
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
/// `2026-08-23.log` — silently landed the file in the container under the
/// target's name. Nothing errored; the user just got a name they never
/// typed. The Files pane had the identical bug.
/// typed.
///
/// What actually keeps the two from drifting is that they now call one
/// helper, so this asserts that helper's contract from the terminal side:
/// the answer comes from the spelling, and a path that does not name a file
/// is refused rather than silently substituted (it used to fall back to
/// This asserts the shared helper's contract from the terminal side: the
/// answer comes from the spelling, and a path that does not name a file is
/// refused rather than silently substituted (it used to fall back to
/// `"dropped-file"`).
#[test]
fn a_dropped_file_keeps_the_name_the_user_dropped() {
+2 -40
View File
@@ -351,8 +351,8 @@ pub async fn upload_host_file_to_container(
// The caller resolved this path (`resolve_host_read_path`); opening it
// is a second trip through the same directories, so the descriptor is
// checked against the path that was validated before its bytes are
// packed into anything. Same policy as the Files pane's upload — this
// is the terminal's drop target, and the two must not differ.
// packed into anything. This is the terminal's drop target, and it is
// the only path by which host bytes enter a container.
let file = std::fs::File::open(&host_path)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
crate::commands::file_commands::verify_opened_path(
@@ -601,44 +601,6 @@ pub async fn exec_oneshot_as(
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
}
/// [`exec_oneshot_as`] with a wall-clock ceiling on the whole call.
///
/// H8. Nothing in this module bounds how long a container command may take,
/// which is right for the callers that need it — a base-image migration replays
/// `apt-get` and takes minutes — and wrong for a short command that can be made
/// to block forever by a *file* the caller does not control. The upload
/// reservation is the one that bit: a shell redirect onto a FIFO blocks in
/// `open(2)` until a reader appears, so a single `mkfifo` in a project
/// directory left the Files pane on "Uploading…" for the rest of the session
/// with the rest of the batch abandoned.
///
/// So the ceiling is opt-in per call site rather than global. Note what it can
/// and cannot do: dropping the future closes our end of the stream, but Docker
/// has no "kill an exec" API, so a process that is genuinely wedged stays
/// wedged in the container's process table. That is why the primitive matters
/// more than the timeout — this turns "the app never comes back" into "that
/// upload failed", and it is the caller's job not to run something that blocks.
pub async fn exec_oneshot_as_within(
container_id: &str,
user: &str,
cmd: Vec<String>,
env: Vec<String>,
limit: std::time::Duration,
) -> Result<(String, i64), String> {
match tokio::time::timeout(
limit,
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT),
)
.await
{
Ok(result) => result,
Err(_) => Err(format!(
"The container did not answer within {}s — the command may still be running inside it.",
limit.as_secs()
)),
}
}
/// What a one-shot exec printed, with the two streams still tellable apart.
///
/// `combined` is stdout and stderr interleaved in arrival order — the shape
-2
View File
@@ -497,9 +497,7 @@ pub fn run() {
commands::terminal_commands::stop_audio_bridge,
// Files
commands::file_commands::list_container_files,
commands::file_commands::download_container_file,
commands::file_commands::download_container_backup,
commands::file_commands::upload_file_to_container,
commands::file_commands::read_container_file,
commands::file_commands::rename_container_path,
commands::file_commands::create_container_directory,