Skip to content

Commit 47aad1e

Browse files
committed
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
1 parent ed74a19 commit 47aad1e

File tree

1 file changed

+165
-83
lines changed
  • crates/openshell-cli/src

1 file changed

+165
-83
lines changed

crates/openshell-cli/src/ssh.rs

Lines changed: 165 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -447,22 +447,31 @@ 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.
451-
///
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(
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.
468+
async fn ssh_tar_upload(
455469
server: &str,
456470
name: &str,
457-
base_dir: &Path,
458-
files: &[String],
459-
dest: &str,
471+
dest_dir: &str,
472+
source: UploadSource,
460473
tls: &TlsOptions,
461474
) -> Result<()> {
462-
if files.is_empty() {
463-
return Ok(());
464-
}
465-
466475
let session = ssh_session_config(server, name, tls).await?;
467476

468477
let mut ssh = ssh_base_command(&session.proxy_command);
@@ -472,8 +481,8 @@ pub async fn sandbox_sync_up_files(
472481
.arg("sandbox")
473482
.arg(format!(
474483
"mkdir -p {} && cat | tar xf - -C {}",
475-
shell_escape(dest),
476-
shell_escape(dest)
484+
shell_escape(dest_dir),
485+
shell_escape(dest_dir)
477486
))
478487
.stdin(Stdio::piped())
479488
.stdout(Stdio::inherit())
@@ -486,22 +495,43 @@ pub async fn sandbox_sync_up_files(
486495
.ok_or_else(|| miette::miette!("failed to open stdin for ssh process"))?;
487496

488497
// 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();
491498
tokio::task::spawn_blocking(move || -> Result<()> {
492499
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"))?;
500+
match source {
501+
UploadSource::SinglePath {
502+
local_path,
503+
tar_name,
504+
} => {
505+
if local_path.is_file() {
506+
archive
507+
.append_path_with_name(&local_path, &tar_name)
508+
.into_diagnostic()?;
509+
} else if local_path.is_dir() {
510+
archive.append_dir_all(".", &local_path).into_diagnostic()?;
511+
} else {
512+
return Err(miette::miette!(
513+
"local path does not exist: {}",
514+
local_path.display()
515+
));
516+
}
517+
}
518+
UploadSource::FileList { base_dir, files } => {
519+
for file in &files {
520+
let full_path = base_dir.join(file);
521+
if full_path.is_file() {
522+
archive
523+
.append_path_with_name(&full_path, file)
524+
.into_diagnostic()
525+
.wrap_err_with(|| format!("failed to add {file} to tar archive"))?;
526+
} else if full_path.is_dir() {
527+
archive
528+
.append_dir_all(file, &full_path)
529+
.into_diagnostic()
530+
.wrap_err_with(|| {
531+
format!("failed to add directory {file} to tar archive")
532+
})?;
533+
}
534+
}
505535
}
506536
}
507537
archive.finish().into_diagnostic()?;
@@ -524,72 +554,103 @@ pub async fn sandbox_sync_up_files(
524554
Ok(())
525555
}
526556

557+
/// Split a sandbox path into (parent_directory, basename).
558+
///
559+
/// Examples:
560+
/// `"/sandbox/.bashrc"` -> `("/sandbox", ".bashrc")`
561+
/// `"/sandbox/sub/file"` -> `("/sandbox/sub", "file")`
562+
/// `"file.txt"` -> `(".", "file.txt")`
563+
fn split_sandbox_path(path: &str) -> (&str, &str) {
564+
match path.rfind('/') {
565+
Some(0) => ("/", &path[1..]),
566+
Some(pos) => (&path[..pos], &path[pos + 1..]),
567+
None => (".", path),
568+
}
569+
}
570+
571+
/// Push a list of files from a local directory into a sandbox using tar-over-SSH.
572+
///
573+
/// Files are streamed as a tar archive to `ssh ... tar xf - -C <dest>` on
574+
/// the sandbox side.
575+
pub async fn sandbox_sync_up_files(
576+
server: &str,
577+
name: &str,
578+
base_dir: &Path,
579+
files: &[String],
580+
dest: &str,
581+
tls: &TlsOptions,
582+
) -> Result<()> {
583+
if files.is_empty() {
584+
return Ok(());
585+
}
586+
ssh_tar_upload(
587+
server,
588+
name,
589+
dest,
590+
UploadSource::FileList {
591+
base_dir: base_dir.to_path_buf(),
592+
files: files.to_vec(),
593+
},
594+
tls,
595+
)
596+
.await
597+
}
598+
527599
/// Push a local path (file or directory) into a sandbox using tar-over-SSH.
600+
///
601+
/// When uploading a single file to a destination that does not end with `/`,
602+
/// the destination is treated as a file path: the parent directory is created
603+
/// and the file is written with the destination's basename. This matches
604+
/// `cp` / `scp` semantics and avoids creating a directory at the destination
605+
/// path.
528606
pub async fn sandbox_sync_up(
529607
server: &str,
530608
name: &str,
531609
local_path: &Path,
532610
sandbox_path: &str,
533611
tls: &TlsOptions,
534612
) -> 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());
613+
// When uploading a single file to a path that looks like a file (does not
614+
// end with '/'), split into parent directory + target basename so that
615+
// `mkdir -p` creates the parent and tar extracts the file with the right
616+
// name. For directories or paths ending in '/', keep the original
617+
// behavior where sandbox_path is the extraction directory.
618+
if local_path.is_file() && !sandbox_path.ends_with('/') {
619+
let (parent, target_name) = split_sandbox_path(sandbox_path);
620+
return ssh_tar_upload(
621+
server,
622+
name,
623+
parent,
624+
UploadSource::SinglePath {
625+
local_path: local_path.to_path_buf(),
626+
tar_name: target_name.into(),
627+
},
628+
tls,
629+
)
630+
.await;
631+
}
550632

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"))?;
633+
let tar_name = if local_path.is_file() {
634+
local_path
635+
.file_name()
636+
.ok_or_else(|| miette::miette!("path has no file name"))?
637+
.to_os_string()
638+
} else {
639+
// For directories the tar_name is unused — append_dir_all uses "."
640+
".".into()
641+
};
556642

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-
));
574-
}
575-
archive.finish().into_diagnostic()?;
576-
Ok(())
577-
})
643+
ssh_tar_upload(
644+
server,
645+
name,
646+
sandbox_path,
647+
UploadSource::SinglePath {
648+
local_path: local_path.to_path_buf(),
649+
tar_name,
650+
},
651+
tls,
652+
)
578653
.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-
));
590-
}
591-
592-
Ok(())
593654
}
594655

595656
/// Pull a path from a sandbox to a local destination using tar-over-SSH.
@@ -1149,4 +1210,25 @@ mod tests {
11491210
assert!(message.contains("Forwarding port 3000 to sandbox demo"));
11501211
assert!(message.contains("Access at: http://localhost:3000/"));
11511212
}
1213+
1214+
#[test]
1215+
fn split_sandbox_path_separates_parent_and_basename() {
1216+
assert_eq!(
1217+
split_sandbox_path("/sandbox/.bashrc"),
1218+
("/sandbox", ".bashrc")
1219+
);
1220+
assert_eq!(
1221+
split_sandbox_path("/sandbox/sub/file"),
1222+
("/sandbox/sub", "file")
1223+
);
1224+
assert_eq!(split_sandbox_path("/a/b/c/d.txt"), ("/a/b/c", "d.txt"));
1225+
}
1226+
1227+
#[test]
1228+
fn split_sandbox_path_handles_root_and_bare_names() {
1229+
// File directly under root
1230+
assert_eq!(split_sandbox_path("/.bashrc"), ("/", ".bashrc"));
1231+
// No directory component at all
1232+
assert_eq!(split_sandbox_path("file.txt"), (".", "file.txt"));
1233+
}
11521234
}

0 commit comments

Comments
 (0)