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