@@ -16,7 +16,7 @@ use openshell_core::proto::{CreateSshSessionRequest, GetSandboxRequest};
1616use owo_colors:: OwoColorize ;
1717use rustls:: pki_types:: ServerName ;
1818use std:: fs;
19- use std:: io:: IsTerminal ;
19+ use std:: io:: { IsTerminal , Write } ;
2020#[ cfg( unix) ]
2121use std:: os:: unix:: process:: CommandExt ;
2222use 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.
710741pub 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