Skip to content

Commit 20ffc72

Browse files
authored
fix(cli): preserve directory basename for filtered uploads (#1028)
Signed-off-by: John Myers <johntmyers@users.noreply.github.com> Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
1 parent d414e69 commit 20ffc72

4 files changed

Lines changed: 169 additions & 47 deletions

File tree

architecture/sandbox-connect.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@ openshell sandbox download <name> <sandbox-path> [<local-path>]
356356
```
357357

358358
- **Upload**: `sandbox_upload()` streams a tar archive of the local path to `ssh ... tar xf - -C <dest>` on the sandbox side. Default destination: `/sandbox`.
359+
Named directory uploads preserve the source directory basename at the destination, matching `scp -r` and `cp -r`; uploading `.` remains flat.
360+
`.gitignore` filtering only changes which files are included, not the destination layout.
359361
- **Download**: `sandbox_download()` runs `ssh ... tar cf - -C <dir> <path>` on the sandbox side and extracts the output locally via `tar::Archive`. Default destination: `.` (current directory).
360362
- No compression for v1 -- the SSH tunnel rides the already-TLS-encrypted gateway connection; compression adds CPU cost with marginal bandwidth savings.
361363

crates/openshell-cli/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2465,6 +2465,7 @@ async fn main() -> Result<()> {
24652465
&name,
24662466
&base_dir,
24672467
&files,
2468+
local,
24682469
sandbox_dest,
24692470
&tls,
24702471
)

crates/openshell-cli/src/run.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2417,6 +2417,7 @@ pub async fn sandbox_create(
24172417
&sandbox_name,
24182418
&base_dir,
24192419
&files,
2420+
local,
24202421
dest,
24212422
&effective_tls,
24222423
)

crates/openshell-cli/src/ssh.rs

Lines changed: 165 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use openshell_core::proto::{CreateSshSessionRequest, GetSandboxRequest};
1616
use owo_colors::OwoColorize;
1717
use rustls::pki_types::ServerName;
1818
use std::fs;
19-
use std::io::IsTerminal;
19+
use std::io::{IsTerminal, Write};
2020
#[cfg(unix)]
2121
use std::os::unix::process::CommandExt;
2222
use std::path::{Path, PathBuf};
@@ -476,9 +476,68 @@ enum UploadSource {
476476
FileList {
477477
base_dir: PathBuf,
478478
files: Vec<String>,
479+
archive_prefix: Option<PathBuf>,
479480
},
480481
}
481482

483+
fn write_upload_archive<W: Write>(writer: W, source: UploadSource) -> Result<()> {
484+
let mut archive = tar::Builder::new(writer);
485+
match source {
486+
UploadSource::SinglePath {
487+
local_path,
488+
tar_name,
489+
} => {
490+
if local_path.is_file() {
491+
archive
492+
.append_path_with_name(&local_path, &tar_name)
493+
.into_diagnostic()?;
494+
} else if local_path.is_dir() {
495+
archive
496+
.append_dir_all(&tar_name, &local_path)
497+
.into_diagnostic()?;
498+
} else {
499+
return Err(miette::miette!(
500+
"local path does not exist: {}",
501+
local_path.display()
502+
));
503+
}
504+
}
505+
UploadSource::FileList {
506+
base_dir,
507+
files,
508+
archive_prefix,
509+
} => {
510+
for file in &files {
511+
let full_path = base_dir.join(file);
512+
let archive_path = archive_prefix
513+
.as_ref()
514+
.map(|prefix| prefix.join(file))
515+
.unwrap_or_else(|| PathBuf::from(file));
516+
if full_path.is_file() {
517+
archive
518+
.append_path_with_name(&full_path, &archive_path)
519+
.into_diagnostic()
520+
.wrap_err_with(|| {
521+
format!("failed to add {} to tar archive", archive_path.display())
522+
})?;
523+
} else if full_path.is_dir() {
524+
archive
525+
.append_dir_all(&archive_path, &full_path)
526+
.into_diagnostic()
527+
.wrap_err_with(|| {
528+
format!(
529+
"failed to add directory {} to tar archive",
530+
archive_path.display()
531+
)
532+
})?;
533+
}
534+
}
535+
}
536+
}
537+
archive.finish().into_diagnostic()?;
538+
Ok(())
539+
}
540+
482541
/// Core tar-over-SSH upload: streams a tar archive into `dest_dir` on the
483542
/// sandbox. Callers are responsible for splitting the destination path so
484543
/// that `dest_dir` is always a directory.
@@ -521,52 +580,9 @@ async fn ssh_tar_upload(
521580
.ok_or_else(|| miette::miette!("failed to open stdin for ssh process"))?;
522581

523582
// Build the tar archive in a blocking task since the tar crate is synchronous.
524-
tokio::task::spawn_blocking(move || -> Result<()> {
525-
let mut archive = tar::Builder::new(stdin);
526-
match source {
527-
UploadSource::SinglePath {
528-
local_path,
529-
tar_name,
530-
} => {
531-
if local_path.is_file() {
532-
archive
533-
.append_path_with_name(&local_path, &tar_name)
534-
.into_diagnostic()?;
535-
} else if local_path.is_dir() {
536-
archive
537-
.append_dir_all(&tar_name, &local_path)
538-
.into_diagnostic()?;
539-
} else {
540-
return Err(miette::miette!(
541-
"local path does not exist: {}",
542-
local_path.display()
543-
));
544-
}
545-
}
546-
UploadSource::FileList { base_dir, files } => {
547-
for file in &files {
548-
let full_path = base_dir.join(file);
549-
if full_path.is_file() {
550-
archive
551-
.append_path_with_name(&full_path, file)
552-
.into_diagnostic()
553-
.wrap_err_with(|| format!("failed to add {file} to tar archive"))?;
554-
} else if full_path.is_dir() {
555-
archive
556-
.append_dir_all(file, &full_path)
557-
.into_diagnostic()
558-
.wrap_err_with(|| {
559-
format!("failed to add directory {file} to tar archive")
560-
})?;
561-
}
562-
}
563-
}
564-
}
565-
archive.finish().into_diagnostic()?;
566-
Ok(())
567-
})
568-
.await
569-
.into_diagnostic()??;
583+
tokio::task::spawn_blocking(move || -> Result<()> { write_upload_archive(stdin, source) })
584+
.await
585+
.into_diagnostic()??;
570586

571587
let status = tokio::task::spawn_blocking(move || child.wait())
572588
.await
@@ -606,6 +622,7 @@ pub async fn sandbox_sync_up_files(
606622
name: &str,
607623
base_dir: &Path,
608624
files: &[String],
625+
local_path: &Path,
609626
dest: Option<&str>,
610627
tls: &TlsOptions,
611628
) -> Result<()> {
@@ -619,6 +636,7 @@ pub async fn sandbox_sync_up_files(
619636
UploadSource::FileList {
620637
base_dir: base_dir.to_path_buf(),
621638
files: files.to_vec(),
639+
archive_prefix: file_list_archive_prefix(local_path),
622640
},
623641
tls,
624642
)
@@ -706,6 +724,19 @@ fn directory_upload_prefix(local_path: &Path) -> std::ffi::OsString {
706724
.unwrap_or_else(|| ".".into())
707725
}
708726

727+
fn file_list_archive_prefix(local_path: &Path) -> Option<PathBuf> {
728+
if !local_path.is_dir() {
729+
return None;
730+
}
731+
732+
let prefix = directory_upload_prefix(local_path);
733+
if prefix == "." {
734+
None
735+
} else {
736+
Some(PathBuf::from(prefix))
737+
}
738+
}
739+
709740
/// Pull a path from a sandbox to a local destination using tar-over-SSH.
710741
pub async fn sandbox_sync_down(
711742
server: &str,
@@ -1332,6 +1363,93 @@ mod tests {
13321363
);
13331364
}
13341365

1366+
#[test]
1367+
fn file_list_archive_prefix_uses_named_directory_basename() {
1368+
let tmpdir = tempfile::tempdir().expect("create tmpdir");
1369+
let source = tmpdir.path().join("source-dir");
1370+
let file = tmpdir.path().join("file.txt");
1371+
fs::create_dir_all(&source).expect("create source dir");
1372+
fs::write(&file, "file").expect("write file");
1373+
1374+
assert_eq!(
1375+
file_list_archive_prefix(&source),
1376+
Some(PathBuf::from("source-dir"))
1377+
);
1378+
assert_eq!(file_list_archive_prefix(Path::new(".")), None);
1379+
assert_eq!(file_list_archive_prefix(&file), None);
1380+
}
1381+
1382+
fn upload_archive_paths(source: UploadSource) -> Vec<String> {
1383+
let mut bytes = Vec::new();
1384+
write_upload_archive(&mut bytes, source).expect("write upload archive");
1385+
let mut archive = tar::Archive::new(std::io::Cursor::new(bytes));
1386+
let entries = archive.entries().expect("read archive entries");
1387+
let mut paths = entries
1388+
.map(|entry| {
1389+
entry
1390+
.expect("read archive entry")
1391+
.path()
1392+
.expect("read archive path")
1393+
.to_string_lossy()
1394+
.into_owned()
1395+
})
1396+
.collect::<Vec<_>>();
1397+
paths.sort();
1398+
paths
1399+
}
1400+
1401+
#[test]
1402+
fn file_list_archive_preserves_directory_prefix_when_requested() {
1403+
let tmpdir = tempfile::tempdir().expect("create tmpdir");
1404+
let base_dir = tmpdir.path().join("nested");
1405+
fs::create_dir_all(base_dir.join("inner")).expect("create dirs");
1406+
fs::write(base_dir.join("file.txt"), "file").expect("write file");
1407+
fs::write(base_dir.join("inner/child.txt"), "child").expect("write child");
1408+
1409+
let paths = upload_archive_paths(UploadSource::FileList {
1410+
base_dir,
1411+
files: vec!["file.txt".into(), "inner/child.txt".into()],
1412+
archive_prefix: Some(PathBuf::from("nested")),
1413+
});
1414+
1415+
assert_eq!(paths, vec!["nested/file.txt", "nested/inner/child.txt"]);
1416+
}
1417+
1418+
#[test]
1419+
fn file_list_archive_stays_flat_without_directory_prefix() {
1420+
let tmpdir = tempfile::tempdir().expect("create tmpdir");
1421+
let base_dir = tmpdir.path().join("nested");
1422+
fs::create_dir_all(base_dir.join("inner")).expect("create dirs");
1423+
fs::write(base_dir.join("file.txt"), "file").expect("write file");
1424+
fs::write(base_dir.join("inner/child.txt"), "child").expect("write child");
1425+
1426+
let paths = upload_archive_paths(UploadSource::FileList {
1427+
base_dir,
1428+
files: vec!["file.txt".into(), "inner/child.txt".into()],
1429+
archive_prefix: None,
1430+
});
1431+
1432+
assert_eq!(paths, vec!["file.txt", "inner/child.txt"]);
1433+
}
1434+
1435+
#[test]
1436+
fn single_directory_archive_preserves_directory_basename() {
1437+
let tmpdir = tempfile::tempdir().expect("create tmpdir");
1438+
let source = tmpdir.path().join("source-dir");
1439+
fs::create_dir_all(source.join("inner")).expect("create dirs");
1440+
fs::write(source.join("file.txt"), "file").expect("write file");
1441+
fs::write(source.join("inner/child.txt"), "child").expect("write child");
1442+
1443+
let paths = upload_archive_paths(UploadSource::SinglePath {
1444+
local_path: source,
1445+
tar_name: "source-dir".into(),
1446+
});
1447+
1448+
assert!(paths.contains(&"source-dir/file.txt".to_string()));
1449+
assert!(paths.contains(&"source-dir/inner/child.txt".to_string()));
1450+
assert!(paths.iter().all(|path| path.starts_with("source-dir/")));
1451+
}
1452+
13351453
#[test]
13361454
fn split_sandbox_path_handles_root_and_bare_names() {
13371455
// File directly under root

0 commit comments

Comments
 (0)