@@ -484,20 +484,7 @@ fn write_upload_archive<W: Write>(writer: W, source: UploadSource) -> Result<()>
484484 local_path,
485485 tar_name,
486486 } => {
487- if local_path. is_file ( ) {
488- archive
489- . append_path_with_name ( & local_path, & tar_name)
490- . into_diagnostic ( ) ?;
491- } else if local_path. is_dir ( ) {
492- archive
493- . append_dir_all ( & tar_name, & local_path)
494- . into_diagnostic ( ) ?;
495- } else {
496- return Err ( miette:: miette!(
497- "local path does not exist: {}" ,
498- local_path. display( )
499- ) ) ;
500- }
487+ append_upload_path ( & mut archive, & local_path, Path :: new ( & tar_name) , false ) ?;
501488 }
502489 UploadSource :: FileList {
503490 base_dir,
@@ -509,31 +496,127 @@ fn write_upload_archive<W: Write>(writer: W, source: UploadSource) -> Result<()>
509496 let archive_path = archive_prefix
510497 . as_ref ( )
511498 . map_or_else ( || PathBuf :: from ( file) , |prefix| prefix. join ( file) ) ;
512- if full_path. is_file ( ) {
513- archive
514- . append_path_with_name ( & full_path, & archive_path)
515- . into_diagnostic ( )
516- . wrap_err_with ( || {
517- format ! ( "failed to add {} to tar archive" , archive_path. display( ) )
518- } ) ?;
519- } else if full_path. is_dir ( ) {
520- archive
521- . append_dir_all ( & archive_path, & full_path)
522- . into_diagnostic ( )
523- . wrap_err_with ( || {
524- format ! (
525- "failed to add directory {} to tar archive" ,
526- archive_path. display( )
527- )
528- } ) ?;
529- }
499+ append_upload_path ( & mut archive, & full_path, & archive_path, true ) ?;
530500 }
531501 }
532502 }
533503 archive. finish ( ) . into_diagnostic ( ) ?;
534504 Ok ( ( ) )
535505}
536506
507+ fn append_upload_path < W : Write > (
508+ archive : & mut tar:: Builder < W > ,
509+ local_path : & Path ,
510+ archive_path : & Path ,
511+ skip_missing : bool ,
512+ ) -> Result < ( ) > {
513+ let metadata = match fs:: symlink_metadata ( local_path) {
514+ Ok ( metadata) => metadata,
515+ Err ( err) if skip_missing && err. kind ( ) == std:: io:: ErrorKind :: NotFound => return Ok ( ( ) ) ,
516+ Err ( err) => {
517+ return Err ( err)
518+ . into_diagnostic ( )
519+ . wrap_err_with ( || format ! ( "failed to stat {}" , local_path. display( ) ) ) ;
520+ }
521+ } ;
522+ let file_type = metadata. file_type ( ) ;
523+
524+ if file_type. is_file ( ) {
525+ archive
526+ . append_path_with_name ( local_path, archive_path)
527+ . into_diagnostic ( )
528+ . wrap_err_with ( || format ! ( "failed to add {} to tar archive" , archive_path. display( ) ) ) ?;
529+ return Ok ( ( ) ) ;
530+ }
531+
532+ if file_type. is_dir ( ) {
533+ let dir_archive_path = upload_archive_dir_entry_path ( archive_path) ;
534+ archive
535+ . append_dir ( & dir_archive_path, local_path)
536+ . into_diagnostic ( )
537+ . wrap_err_with ( || {
538+ format ! (
539+ "failed to add directory {} to tar archive" ,
540+ archive_path. display( )
541+ )
542+ } ) ?;
543+ append_upload_dir_contents ( archive, local_path, archive_path) ?;
544+ return Ok ( ( ) ) ;
545+ }
546+
547+ if file_type. is_symlink ( ) {
548+ append_upload_symlink ( archive, local_path, archive_path, & metadata) ?;
549+ return Ok ( ( ) ) ;
550+ }
551+
552+ Err ( miette:: miette!(
553+ "unsupported file type for upload: {}" ,
554+ local_path. display( )
555+ ) )
556+ }
557+
558+ fn upload_archive_dir_entry_path ( archive_path : & Path ) -> PathBuf {
559+ let mut path = archive_path. as_os_str ( ) . to_os_string ( ) ;
560+ path. push ( "/" ) ;
561+ PathBuf :: from ( path)
562+ }
563+
564+ fn append_upload_dir_contents < W : Write > (
565+ archive : & mut tar:: Builder < W > ,
566+ local_path : & Path ,
567+ archive_path : & Path ,
568+ ) -> Result < ( ) > {
569+ let mut entries = fs:: read_dir ( local_path)
570+ . into_diagnostic ( )
571+ . wrap_err_with ( || format ! ( "failed to read directory {}" , local_path. display( ) ) ) ?
572+ . collect :: < std:: io:: Result < Vec < _ > > > ( )
573+ . into_diagnostic ( )
574+ . wrap_err_with ( || format ! ( "failed to read directory {}" , local_path. display( ) ) ) ?;
575+ entries. sort_by_key ( fs:: DirEntry :: file_name) ;
576+
577+ for entry in entries {
578+ let entry_name = entry. file_name ( ) ;
579+ let child_local_path = entry. path ( ) ;
580+ let child_archive_path = archive_path. join ( entry_name) ;
581+ append_upload_path ( archive, & child_local_path, & child_archive_path, false ) ?;
582+ }
583+
584+ Ok ( ( ) )
585+ }
586+
587+ fn append_upload_symlink < W : Write > (
588+ archive : & mut tar:: Builder < W > ,
589+ local_path : & Path ,
590+ archive_path : & Path ,
591+ metadata : & fs:: Metadata ,
592+ ) -> Result < ( ) > {
593+ let target = fs:: read_link ( local_path)
594+ . into_diagnostic ( )
595+ . wrap_err_with ( || format ! ( "failed to read symlink {}" , local_path. display( ) ) ) ?;
596+ let mut header = tar:: Header :: new_gnu ( ) ;
597+ header. set_metadata ( metadata) ;
598+ header. set_entry_type ( tar:: EntryType :: Symlink ) ;
599+ header. set_size ( 0 ) ;
600+ header. set_cksum ( ) ;
601+ archive
602+ . append_link ( & mut header, archive_path, target)
603+ . into_diagnostic ( )
604+ . wrap_err_with ( || {
605+ format ! (
606+ "failed to add symlink {} to tar archive" ,
607+ archive_path. display( )
608+ )
609+ } ) ?;
610+ Ok ( ( ) )
611+ }
612+
613+ fn local_upload_path_is_file_like ( path : & Path ) -> bool {
614+ fs:: symlink_metadata ( path) . is_ok_and ( |metadata| {
615+ let file_type = metadata. file_type ( ) ;
616+ file_type. is_file ( ) || file_type. is_symlink ( )
617+ } )
618+ }
619+
537620/// Core tar-over-SSH upload: streams a tar archive into `dest_dir` on the
538621/// sandbox. Callers are responsible for splitting the destination path so
539622/// that `dest_dir` is always a directory.
@@ -782,8 +865,9 @@ pub async fn sandbox_sync_up(
782865 // passed "/sandbox"), fall through to directory semantics instead. The
783866 // sandbox user cannot write to "/" and the intent is almost certainly
784867 // "put the file inside /sandbox", not "create a file named sandbox in /".
868+ let local_path_is_file_like = local_upload_path_is_file_like ( local_path) ;
785869 if let Some ( path) = sandbox_path
786- && local_path . is_file ( )
870+ && local_path_is_file_like
787871 && !path. ends_with ( '/' )
788872 {
789873 let ( parent, target_name) = split_sandbox_path ( path) ;
@@ -802,7 +886,7 @@ pub async fn sandbox_sync_up(
802886 }
803887 }
804888
805- let tar_name = if local_path . is_file ( ) {
889+ let tar_name = if local_path_is_file_like {
806890 local_path
807891 . file_name ( )
808892 . ok_or_else ( || miette:: miette!( "path has no file name" ) ) ?
@@ -1831,21 +1915,47 @@ mod tests {
18311915 assert_eq ! ( file_list_archive_prefix( & file) , None ) ;
18321916 }
18331917
1834- fn upload_archive_paths ( source : UploadSource ) -> Vec < String > {
1918+ #[ derive( Debug ) ]
1919+ struct UploadArchiveEntry {
1920+ path : String ,
1921+ entry_type : tar:: EntryType ,
1922+ link_name : Option < String > ,
1923+ }
1924+
1925+ fn upload_archive_entries ( source : UploadSource ) -> Vec < UploadArchiveEntry > {
18351926 let mut bytes = Vec :: new ( ) ;
18361927 write_upload_archive ( & mut bytes, source) . expect ( "write upload archive" ) ;
18371928 let mut archive = tar:: Archive :: new ( std:: io:: Cursor :: new ( bytes) ) ;
18381929 let entries = archive. entries ( ) . expect ( "read archive entries" ) ;
1839- let mut paths = entries
1930+ let mut entries = entries
18401931 . map ( |entry| {
1841- entry
1842- . expect ( "read archive entry" )
1932+ let entry = entry . expect ( "read archive entry" ) ;
1933+ let path = entry
18431934 . path ( )
18441935 . expect ( "read archive path" )
18451936 . to_string_lossy ( )
1846- . into_owned ( )
1937+ . into_owned ( ) ;
1938+ let entry_type = entry. header ( ) . entry_type ( ) ;
1939+ let link_name = entry
1940+ . link_name ( )
1941+ . expect ( "read archive link" )
1942+ . map ( |link| link. to_string_lossy ( ) . into_owned ( ) ) ;
1943+ UploadArchiveEntry {
1944+ path,
1945+ entry_type,
1946+ link_name,
1947+ }
18471948 } )
18481949 . collect :: < Vec < _ > > ( ) ;
1950+ entries. sort_by ( |left, right| left. path . cmp ( & right. path ) ) ;
1951+ entries
1952+ }
1953+
1954+ fn upload_archive_paths ( source : UploadSource ) -> Vec < String > {
1955+ let mut paths = upload_archive_entries ( source)
1956+ . into_iter ( )
1957+ . map ( |entry| entry. path )
1958+ . collect :: < Vec < _ > > ( ) ;
18491959 paths. sort ( ) ;
18501960 paths
18511961 }
@@ -1902,6 +2012,56 @@ mod tests {
19022012 assert ! ( paths. iter( ) . all( |path| path. starts_with( "source-dir/" ) ) ) ;
19032013 }
19042014
2015+ #[ cfg( unix) ]
2016+ #[ test]
2017+ fn single_directory_archive_preserves_symlink_entries ( ) {
2018+ let tmpdir = tempfile:: tempdir ( ) . expect ( "create tmpdir" ) ;
2019+ let source = tmpdir. path ( ) . join ( "source-dir" ) ;
2020+ fs:: create_dir_all ( source. join ( "real-dir" ) ) . expect ( "create dirs" ) ;
2021+ fs:: write ( source. join ( "real-dir/file.txt" ) , "file" ) . expect ( "write file" ) ;
2022+ std:: os:: unix:: fs:: symlink ( "real-dir" , source. join ( "link-dir" ) ) . expect ( "create symlink" ) ;
2023+
2024+ let entries = upload_archive_entries ( UploadSource :: SinglePath {
2025+ local_path : source,
2026+ tar_name : "source-dir" . into ( ) ,
2027+ } ) ;
2028+
2029+ let symlink = entries
2030+ . iter ( )
2031+ . find ( |entry| entry. path == "source-dir/link-dir" )
2032+ . expect ( "symlink archive entry" ) ;
2033+ assert_eq ! ( symlink. entry_type, tar:: EntryType :: Symlink ) ;
2034+ assert_eq ! ( symlink. link_name. as_deref( ) , Some ( "real-dir" ) ) ;
2035+ assert ! (
2036+ entries
2037+ . iter( )
2038+ . all( |entry| entry. path != "source-dir/link-dir/file.txt" ) ,
2039+ "symlink target should not be expanded into the archive: {entries:?}"
2040+ ) ;
2041+ }
2042+
2043+ #[ cfg( unix) ]
2044+ #[ test]
2045+ fn file_list_archive_preserves_symlink_entries ( ) {
2046+ let tmpdir = tempfile:: tempdir ( ) . expect ( "create tmpdir" ) ;
2047+ let base_dir = tmpdir. path ( ) . join ( "nested" ) ;
2048+ fs:: create_dir_all ( base_dir. join ( "real-dir" ) ) . expect ( "create dirs" ) ;
2049+ fs:: write ( base_dir. join ( "real-dir/file.txt" ) , "file" ) . expect ( "write file" ) ;
2050+ std:: os:: unix:: fs:: symlink ( "real-dir" , base_dir. join ( "link-dir" ) ) . expect ( "create symlink" ) ;
2051+
2052+ let entries = upload_archive_entries ( UploadSource :: FileList {
2053+ base_dir,
2054+ files : vec ! [ "link-dir" . into( ) ] ,
2055+ archive_prefix : Some ( PathBuf :: from ( "nested" ) ) ,
2056+ } ) ;
2057+
2058+ assert_eq ! ( entries. len( ) , 1 , "unexpected archive entries: {entries:?}" ) ;
2059+ let symlink = & entries[ 0 ] ;
2060+ assert_eq ! ( symlink. path, "nested/link-dir" ) ;
2061+ assert_eq ! ( symlink. entry_type, tar:: EntryType :: Symlink ) ;
2062+ assert_eq ! ( symlink. link_name. as_deref( ) , Some ( "real-dir" ) ) ;
2063+ }
2064+
19052065 #[ test]
19062066 fn split_sandbox_path_handles_root_and_bare_names ( ) {
19072067 // File directly under root
0 commit comments