Skip to content

Commit 7eb1df6

Browse files
authored
fix(cli): sandbox upload overwrites files instead of creating directories (#694)
* fix(cli): sandbox upload overwrites files instead of creating directories When uploading a single file to an existing file path, sandbox_sync_up unconditionally ran mkdir -p on the destination, turning it into a directory. Split the destination into parent + basename for single-file uploads so tar extracts with the correct filename, matching cp/scp semantics. Consolidates duplicated SSH/tar boilerplate from sandbox_sync_up and sandbox_sync_up_files into a shared ssh_tar_upload helper. Closes #667 * better path defaults
1 parent c6f3087 commit 7eb1df6

File tree

3 files changed

+199
-94
lines changed

3 files changed

+199
-94
lines changed

crates/openshell-cli/src/main.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,8 +1091,8 @@ enum SandboxCommands {
10911091
/// Upload local files into the sandbox before running.
10921092
///
10931093
/// Format: `<LOCAL_PATH>[:<SANDBOX_PATH>]`.
1094-
/// When `SANDBOX_PATH` is omitted, files are uploaded to the container
1095-
/// working directory (`/sandbox`).
1094+
/// When `SANDBOX_PATH` is omitted, files are uploaded to the container's
1095+
/// working directory.
10961096
/// `.gitignore` rules are applied by default; use `--no-git-ignore` to
10971097
/// upload everything.
10981098
#[arg(long, value_hint = ValueHint::AnyPath, help_heading = "UPLOAD FLAGS")]
@@ -1255,7 +1255,7 @@ enum SandboxCommands {
12551255
#[arg(value_hint = ValueHint::AnyPath)]
12561256
local_path: String,
12571257

1258-
/// Destination path in the sandbox (defaults to `/sandbox`).
1258+
/// Destination path in the sandbox (defaults to the container's working directory).
12591259
dest: Option<String>,
12601260

12611261
/// Disable `.gitignore` filtering (uploads everything).
@@ -2224,15 +2224,16 @@ async fn main() -> Result<()> {
22242224
let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
22252225
let mut tls = tls.with_gateway_name(&ctx.name);
22262226
apply_edge_auth(&mut tls, &ctx.name);
2227-
let sandbox_dest = dest.as_deref().unwrap_or("/sandbox");
2227+
let sandbox_dest = dest.as_deref();
22282228
let local = std::path::Path::new(&local_path);
22292229
if !local.exists() {
22302230
return Err(miette::miette!(
22312231
"local path does not exist: {}",
22322232
local.display()
22332233
));
22342234
}
2235-
eprintln!("Uploading {} -> sandbox:{}", local.display(), sandbox_dest);
2235+
let dest_display = sandbox_dest.unwrap_or("~");
2236+
eprintln!("Uploading {} -> sandbox:{}", local.display(), dest_display);
22362237
if !no_git_ignore && let Ok((base_dir, files)) = run::git_sync_files(local) {
22372238
run::sandbox_sync_up_files(
22382239
&ctx.endpoint,

crates/openshell-cli/src/run.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2309,8 +2309,12 @@ pub async fn sandbox_create(
23092309
drop(client);
23102310

23112311
if let Some((local_path, sandbox_path, git_ignore)) = upload {
2312-
let dest = sandbox_path.as_deref().unwrap_or("/sandbox");
2313-
eprintln!(" {} Uploading files to {dest}...", "\u{2022}".dimmed(),);
2312+
let dest = sandbox_path.as_deref();
2313+
let dest_display = dest.unwrap_or("~");
2314+
eprintln!(
2315+
" {} Uploading files to {dest_display}...",
2316+
"\u{2022}".dimmed(),
2317+
);
23142318
let local = Path::new(local_path);
23152319
if *git_ignore && let Ok((base_dir, files)) = git_sync_files(local) {
23162320
sandbox_sync_up_files(
@@ -2628,16 +2632,16 @@ pub async fn sandbox_sync_command(
26282632
) -> Result<()> {
26292633
match (up, down) {
26302634
(Some(local_path), None) => {
2631-
let sandbox_dest = dest.unwrap_or("/sandbox");
26322635
let local = Path::new(local_path);
26332636
if !local.exists() {
26342637
return Err(miette::miette!(
26352638
"local path does not exist: {}",
26362639
local.display()
26372640
));
26382641
}
2639-
eprintln!("Syncing {} -> sandbox:{}", local.display(), sandbox_dest);
2640-
sandbox_sync_up(server, name, local, sandbox_dest, tls).await?;
2642+
let dest_display = dest.unwrap_or("~");
2643+
eprintln!("Syncing {} -> sandbox:{}", local.display(), dest_display);
2644+
sandbox_sync_up(server, name, local, dest, tls).await?;
26412645
eprintln!("{} Sync complete", "✓".green().bold());
26422646
}
26432647
(None, Some(sandbox_path)) => {

crates/openshell-cli/src/ssh.rs

Lines changed: 184 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -447,33 +447,51 @@ pub(crate) async fn sandbox_exec_without_exec(
447447
sandbox_exec_with_mode(server, name, command, tty, tls, false).await
448448
}
449449

450-
/// Push a list of files from a local directory into a sandbox using tar-over-SSH.
450+
/// What to pack into the tar archive streamed to the sandbox.
451+
enum UploadSource {
452+
/// A single local file or directory. `tar_name` controls the entry name
453+
/// inside the archive (e.g. the target basename for file-to-file uploads).
454+
SinglePath {
455+
local_path: PathBuf,
456+
tar_name: std::ffi::OsString,
457+
},
458+
/// A set of files relative to a base directory (git-filtered uploads).
459+
FileList {
460+
base_dir: PathBuf,
461+
files: Vec<String>,
462+
},
463+
}
464+
465+
/// Core tar-over-SSH upload: streams a tar archive into `dest_dir` on the
466+
/// sandbox. Callers are responsible for splitting the destination path so
467+
/// that `dest_dir` is always a directory.
451468
///
452-
/// This replaces the old rsync-based sync. Files are streamed as a tar archive
453-
/// to `ssh ... tar xf - -C <dest>` on the sandbox side.
454-
pub async fn sandbox_sync_up_files(
469+
/// When `dest_dir` is `None`, the sandbox user's home directory (`$HOME`) is
470+
/// used as the extraction target. This avoids hard-coding any particular
471+
/// path and works for custom container images with non-default `WORKDIR`.
472+
async fn ssh_tar_upload(
455473
server: &str,
456474
name: &str,
457-
base_dir: &Path,
458-
files: &[String],
459-
dest: &str,
475+
dest_dir: Option<&str>,
476+
source: UploadSource,
460477
tls: &TlsOptions,
461478
) -> Result<()> {
462-
if files.is_empty() {
463-
return Ok(());
464-
}
465-
466479
let session = ssh_session_config(server, name, tls).await?;
467480

481+
// When no explicit destination is given, use the unescaped `$HOME` shell
482+
// variable so the remote shell resolves it at runtime.
483+
let escaped_dest = match dest_dir {
484+
Some(d) => shell_escape(d),
485+
None => "$HOME".to_string(),
486+
};
487+
468488
let mut ssh = ssh_base_command(&session.proxy_command);
469489
ssh.arg("-T")
470490
.arg("-o")
471491
.arg("RequestTTY=no")
472492
.arg("sandbox")
473493
.arg(format!(
474-
"mkdir -p {} && cat | tar xf - -C {}",
475-
shell_escape(dest),
476-
shell_escape(dest)
494+
"mkdir -p {escaped_dest} && cat | tar xf - -C {escaped_dest}",
477495
))
478496
.stdin(Stdio::piped())
479497
.stdout(Stdio::inherit())
@@ -486,22 +504,43 @@ pub async fn sandbox_sync_up_files(
486504
.ok_or_else(|| miette::miette!("failed to open stdin for ssh process"))?;
487505

488506
// Build the tar archive in a blocking task since the tar crate is synchronous.
489-
let base_dir = base_dir.to_path_buf();
490-
let files = files.to_vec();
491507
tokio::task::spawn_blocking(move || -> Result<()> {
492508
let mut archive = tar::Builder::new(stdin);
493-
for file in &files {
494-
let full_path = base_dir.join(file);
495-
if full_path.is_file() {
496-
archive
497-
.append_path_with_name(&full_path, file)
498-
.into_diagnostic()
499-
.wrap_err_with(|| format!("failed to add {file} to tar archive"))?;
500-
} else if full_path.is_dir() {
501-
archive
502-
.append_dir_all(file, &full_path)
503-
.into_diagnostic()
504-
.wrap_err_with(|| format!("failed to add directory {file} to tar archive"))?;
509+
match source {
510+
UploadSource::SinglePath {
511+
local_path,
512+
tar_name,
513+
} => {
514+
if local_path.is_file() {
515+
archive
516+
.append_path_with_name(&local_path, &tar_name)
517+
.into_diagnostic()?;
518+
} else if local_path.is_dir() {
519+
archive.append_dir_all(".", &local_path).into_diagnostic()?;
520+
} else {
521+
return Err(miette::miette!(
522+
"local path does not exist: {}",
523+
local_path.display()
524+
));
525+
}
526+
}
527+
UploadSource::FileList { base_dir, files } => {
528+
for file in &files {
529+
let full_path = base_dir.join(file);
530+
if full_path.is_file() {
531+
archive
532+
.append_path_with_name(&full_path, file)
533+
.into_diagnostic()
534+
.wrap_err_with(|| format!("failed to add {file} to tar archive"))?;
535+
} else if full_path.is_dir() {
536+
archive
537+
.append_dir_all(file, &full_path)
538+
.into_diagnostic()
539+
.wrap_err_with(|| {
540+
format!("failed to add directory {file} to tar archive")
541+
})?;
542+
}
543+
}
505544
}
506545
}
507546
archive.finish().into_diagnostic()?;
@@ -524,72 +563,112 @@ pub async fn sandbox_sync_up_files(
524563
Ok(())
525564
}
526565

566+
/// Split a sandbox path into (parent_directory, basename).
567+
///
568+
/// Examples:
569+
/// `"/sandbox/.bashrc"` -> `("/sandbox", ".bashrc")`
570+
/// `"/sandbox/sub/file"` -> `("/sandbox/sub", "file")`
571+
/// `"file.txt"` -> `(".", "file.txt")`
572+
fn split_sandbox_path(path: &str) -> (&str, &str) {
573+
match path.rfind('/') {
574+
Some(0) => ("/", &path[1..]),
575+
Some(pos) => (&path[..pos], &path[pos + 1..]),
576+
None => (".", path),
577+
}
578+
}
579+
580+
/// Push a list of files from a local directory into a sandbox using tar-over-SSH.
581+
///
582+
/// Files are streamed as a tar archive to `ssh ... tar xf - -C <dest>` on
583+
/// the sandbox side. When `dest` is `None`, files are uploaded to the
584+
/// sandbox user's home directory.
585+
pub async fn sandbox_sync_up_files(
586+
server: &str,
587+
name: &str,
588+
base_dir: &Path,
589+
files: &[String],
590+
dest: Option<&str>,
591+
tls: &TlsOptions,
592+
) -> Result<()> {
593+
if files.is_empty() {
594+
return Ok(());
595+
}
596+
ssh_tar_upload(
597+
server,
598+
name,
599+
dest,
600+
UploadSource::FileList {
601+
base_dir: base_dir.to_path_buf(),
602+
files: files.to_vec(),
603+
},
604+
tls,
605+
)
606+
.await
607+
}
608+
527609
/// Push a local path (file or directory) into a sandbox using tar-over-SSH.
610+
///
611+
/// When `sandbox_path` is `None`, files are uploaded to the sandbox user's
612+
/// home directory. When uploading a single file to an explicit destination
613+
/// that does not end with `/`, the destination is treated as a file path:
614+
/// the parent directory is created and the file is written with the
615+
/// destination's basename. This matches `cp` / `scp` semantics.
528616
pub async fn sandbox_sync_up(
529617
server: &str,
530618
name: &str,
531619
local_path: &Path,
532-
sandbox_path: &str,
620+
sandbox_path: Option<&str>,
533621
tls: &TlsOptions,
534622
) -> Result<()> {
535-
let session = ssh_session_config(server, name, tls).await?;
536-
537-
let mut ssh = ssh_base_command(&session.proxy_command);
538-
ssh.arg("-T")
539-
.arg("-o")
540-
.arg("RequestTTY=no")
541-
.arg("sandbox")
542-
.arg(format!(
543-
"mkdir -p {} && cat | tar xf - -C {}",
544-
shell_escape(sandbox_path),
545-
shell_escape(sandbox_path)
546-
))
547-
.stdin(Stdio::piped())
548-
.stdout(Stdio::inherit())
549-
.stderr(Stdio::inherit());
550-
551-
let mut child = ssh.spawn().into_diagnostic()?;
552-
let stdin = child
553-
.stdin
554-
.take()
555-
.ok_or_else(|| miette::miette!("failed to open stdin for ssh process"))?;
556-
557-
let local_path = local_path.to_path_buf();
558-
tokio::task::spawn_blocking(move || -> Result<()> {
559-
let mut archive = tar::Builder::new(stdin);
560-
if local_path.is_file() {
561-
let file_name = local_path
562-
.file_name()
563-
.ok_or_else(|| miette::miette!("path has no file name"))?;
564-
archive
565-
.append_path_with_name(&local_path, file_name)
566-
.into_diagnostic()?;
567-
} else if local_path.is_dir() {
568-
archive.append_dir_all(".", &local_path).into_diagnostic()?;
569-
} else {
570-
return Err(miette::miette!(
571-
"local path does not exist: {}",
572-
local_path.display()
573-
));
623+
// When an explicit destination is given and looks like a file path (does
624+
// not end with '/'), split into parent directory + target basename so that
625+
// `mkdir -p` creates the parent and tar extracts the file with the right
626+
// name.
627+
//
628+
// Exception: if splitting would yield "/" as the parent (e.g. the user
629+
// passed "/sandbox"), fall through to directory semantics instead. The
630+
// sandbox user cannot write to "/" and the intent is almost certainly
631+
// "put the file inside /sandbox", not "create a file named sandbox in /".
632+
if let Some(path) = sandbox_path {
633+
if local_path.is_file() && !path.ends_with('/') {
634+
let (parent, target_name) = split_sandbox_path(path);
635+
if parent != "/" {
636+
return ssh_tar_upload(
637+
server,
638+
name,
639+
Some(parent),
640+
UploadSource::SinglePath {
641+
local_path: local_path.to_path_buf(),
642+
tar_name: target_name.into(),
643+
},
644+
tls,
645+
)
646+
.await;
647+
}
574648
}
575-
archive.finish().into_diagnostic()?;
576-
Ok(())
577-
})
578-
.await
579-
.into_diagnostic()??;
580-
581-
let status = tokio::task::spawn_blocking(move || child.wait())
582-
.await
583-
.into_diagnostic()?
584-
.into_diagnostic()?;
585-
586-
if !status.success() {
587-
return Err(miette::miette!(
588-
"ssh tar extract exited with status {status}"
589-
));
590649
}
591650

592-
Ok(())
651+
let tar_name = if local_path.is_file() {
652+
local_path
653+
.file_name()
654+
.ok_or_else(|| miette::miette!("path has no file name"))?
655+
.to_os_string()
656+
} else {
657+
// For directories the tar_name is unused — append_dir_all uses "."
658+
".".into()
659+
};
660+
661+
ssh_tar_upload(
662+
server,
663+
name,
664+
sandbox_path,
665+
UploadSource::SinglePath {
666+
local_path: local_path.to_path_buf(),
667+
tar_name,
668+
},
669+
tls,
670+
)
671+
.await
593672
}
594673

595674
/// Pull a path from a sandbox to a local destination using tar-over-SSH.
@@ -1149,4 +1228,25 @@ mod tests {
11491228
assert!(message.contains("Forwarding port 3000 to sandbox demo"));
11501229
assert!(message.contains("Access at: http://localhost:3000/"));
11511230
}
1231+
1232+
#[test]
1233+
fn split_sandbox_path_separates_parent_and_basename() {
1234+
assert_eq!(
1235+
split_sandbox_path("/sandbox/.bashrc"),
1236+
("/sandbox", ".bashrc")
1237+
);
1238+
assert_eq!(
1239+
split_sandbox_path("/sandbox/sub/file"),
1240+
("/sandbox/sub", "file")
1241+
);
1242+
assert_eq!(split_sandbox_path("/a/b/c/d.txt"), ("/a/b/c", "d.txt"));
1243+
}
1244+
1245+
#[test]
1246+
fn split_sandbox_path_handles_root_and_bare_names() {
1247+
// File directly under root
1248+
assert_eq!(split_sandbox_path("/.bashrc"), ("/", ".bashrc"));
1249+
// No directory component at all
1250+
assert_eq!(split_sandbox_path("file.txt"), (".", "file.txt"));
1251+
}
11521252
}

0 commit comments

Comments
 (0)