@@ -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.
528606pub 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