@@ -186,6 +186,29 @@ pub enum Source {
186186 Local ( PathBuf ) ,
187187}
188188
189+ /// Paths passed to an installation fixup hook.
190+ ///
191+ /// The staging directory is the mutable, freshly extracted archive directory.
192+ /// The final directory is the stable path where the staging directory will be
193+ /// atomically published after the hook returns successfully.
194+ #[ derive( Clone , Copy ) ]
195+ pub struct FixupPaths < ' a > {
196+ staging_dir : & ' a Path ,
197+ final_dir : & ' a Path ,
198+ }
199+
200+ impl < ' a > FixupPaths < ' a > {
201+ /// The mutable directory containing freshly extracted archive contents.
202+ pub fn staging_dir ( & self ) -> & ' a Path {
203+ self . staging_dir
204+ }
205+
206+ /// The stable installation path that will exist after publication.
207+ pub fn final_dir ( & self ) -> & ' a Path {
208+ self . final_dir
209+ }
210+ }
211+
189212impl Config {
190213 /// Resolves the dependency directory, failing if it doesn't exist.
191214 pub fn resolve_installation_dir ( & self , location : Location ) -> IoResult < PathBuf > {
@@ -199,13 +222,29 @@ impl Config {
199222 & self ,
200223 location : Location ,
201224 source : Source ,
225+ ) -> IoResult < PathBuf > {
226+ self . resolve_installation_dir_or_install_with_fixup ( location, source, |_| Ok ( ( ) ) )
227+ }
228+
229+ /// Resolves the dependency directory, installing and fixing it up if needed.
230+ ///
231+ /// The `fixup` hook runs once after a fresh archive extraction, but before
232+ /// the managed directory is published. Mutate files through
233+ /// [`FixupPaths::staging_dir`]; use [`FixupPaths::final_dir`] only for
234+ /// fixups that need to write stable install-root references into those
235+ /// staged files.
236+ pub fn resolve_installation_dir_or_install_with_fixup (
237+ & self ,
238+ location : Location ,
239+ source : Source ,
240+ fixup : impl FnOnce ( FixupPaths < ' _ > ) -> IoResult < ( ) > ,
202241 ) -> IoResult < PathBuf > {
203242 let dir_path = self . dir_path ( location) ;
204243 if ManagedDirName :: new ( & dir_path) . check_exists ( ) . is_ok ( ) {
205244 return Ok ( dir_path) ;
206245 }
207246 let ( reader, expected_sha) = self . open_source ( source) ?;
208- install ( reader, & dir_path, expected_sha) ?;
247+ install_with_fixup ( reader, & dir_path, expected_sha, fixup ) ?;
209248 Ok ( dir_path)
210249 }
211250
@@ -268,7 +307,17 @@ impl Config {
268307
269308/// Extracts the `.tar.zst` from `reader` and installs it at `dst`, optionally
270309/// validating its hash.
310+ #[ cfg( test) ]
271311fn install ( mut reader : impl Read , dst : & Path , expected_sha256 : Option < [ u8 ; 32 ] > ) -> IoResult < ( ) > {
312+ install_with_fixup ( & mut reader, dst, expected_sha256, |_| Ok ( ( ) ) )
313+ }
314+
315+ fn install_with_fixup (
316+ mut reader : impl Read ,
317+ dst : & Path ,
318+ expected_sha256 : Option < [ u8 ; 32 ] > ,
319+ fixup : impl FnOnce ( FixupPaths < ' _ > ) -> IoResult < ( ) > ,
320+ ) -> IoResult < ( ) > {
272321 struct HashingReader < R > {
273322 reader : R ,
274323 hasher : sha2:: Sha256 ,
@@ -282,14 +331,20 @@ fn install(mut reader: impl Read, dst: &Path, expected_sha256: Option<[u8; 32]>)
282331 }
283332 }
284333
285- sync:: ManagedDirName :: new ( dst)
286- . check_exists_or_create ( |target_dir| {
334+ fn unpack_tar_zst ( reader : impl Read , target_dir : & Path ) -> IoResult < ( ) > {
335+ let decoder = zstd:: stream:: read:: Decoder :: new ( reader) ?;
336+ let mut archive = tar:: Archive :: new ( decoder) ;
337+ archive. set_mask ( 0 ) ;
338+ archive. set_preserve_permissions ( true ) ;
339+ archive. unpack ( target_dir)
340+ }
341+
342+ let ( _dir, _created) =
343+ sync:: ManagedDirName :: new ( dst) . check_exists_or_create_with_status ( |target_dir| {
287344 if let Some ( expected) = expected_sha256 {
288345 let mut hash_reader = HashingReader { reader, hasher : sha2:: Sha256 :: new ( ) } ;
289346 {
290- let decoder = zstd:: stream:: read:: Decoder :: new ( & mut hash_reader) ?;
291- let mut archive = tar:: Archive :: new ( decoder) ;
292- archive. unpack ( target_dir) ?;
347+ unpack_tar_zst ( & mut hash_reader, target_dir) ?;
293348 }
294349
295350 // Ensure any remaining trailing bytes in the stream are read
@@ -308,13 +363,13 @@ fn install(mut reader: impl Read, dst: &Path, expected_sha256: Option<[u8; 32]>)
308363 ) ) ;
309364 }
310365 } else {
311- let decoder = zstd:: stream:: read:: Decoder :: new ( & mut reader) ?;
312- let mut archive = tar:: Archive :: new ( decoder) ;
313- archive. unpack ( target_dir) ?;
366+ unpack_tar_zst ( & mut reader, target_dir) ?;
314367 }
368+ fixup ( FixupPaths { staging_dir : target_dir, final_dir : dst } ) ?;
315369 Ok ( ( ) )
316- } )
317- . map ( |_| ( ) )
370+ } ) ?;
371+
372+ Ok ( ( ) )
318373}
319374
320375/// Parses a [`RemoteArchive`] from the `Cargo.toml` at `$cargo_toml_path`.
@@ -545,13 +600,19 @@ mod tests {
545600 use super :: * ;
546601
547602 fn create_dummy_tar_zst ( files : & [ ( & str , & [ u8 ] ) ] ) -> Vec < u8 > {
603+ let files =
604+ files. iter ( ) . map ( |( name, content) | ( * name, * content, 0o644 ) ) . collect :: < Vec < _ > > ( ) ;
605+ create_dummy_tar_zst_with_modes ( & files)
606+ }
607+
608+ fn create_dummy_tar_zst_with_modes ( files : & [ ( & str , & [ u8 ] , u32 ) ] ) -> Vec < u8 > {
548609 let mut zstd_enc = zstd:: stream:: write:: Encoder :: new ( Vec :: new ( ) , 0 ) . unwrap ( ) ;
549610 {
550611 let mut tar_builder = tar:: Builder :: new ( & mut zstd_enc) ;
551- for ( name, content) in files {
612+ for ( name, content, mode ) in files {
552613 let mut header = tar:: Header :: new_gnu ( ) ;
553614 header. set_size ( content. len ( ) as u64 ) ;
554- header. set_mode ( 0o644 ) ;
615+ header. set_mode ( * mode ) ;
555616 header. set_cksum ( ) ;
556617 tar_builder. append_data ( & mut header, name, * content) . unwrap ( ) ;
557618 }
@@ -595,6 +656,82 @@ mod tests {
595656 assert_eq ! ( fs:: read( dst. join( "nested/dir/data.bin" ) ) . unwrap( ) , b"\x01 \x02 \x03 " ) ;
596657 }
597658
659+ #[ test]
660+ fn test_install_with_fixup ( ) {
661+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
662+ let dst = temp. path ( ) . join ( "install_target" ) ;
663+ let tar_zst = create_dummy_tar_zst ( & [ ( "hello.txt" , b"hello world" ) ] ) ;
664+
665+ install_with_fixup ( tar_zst. as_slice ( ) , & dst, None , |paths| {
666+ assert_ne ! ( paths. staging_dir( ) , paths. final_dir( ) ) ;
667+ assert ! (
668+ !paths. final_dir( ) . exists( ) ,
669+ "final directory should not be visible until fixup completes"
670+ ) ;
671+ fs:: write ( paths. staging_dir ( ) . join ( "fixed.txt" ) , "fixed" )
672+ } )
673+ . unwrap ( ) ;
674+
675+ assert_eq ! ( fs:: read_to_string( dst. join( "hello.txt" ) ) . unwrap( ) , "hello world" ) ;
676+ assert_eq ! ( fs:: read_to_string( dst. join( "fixed.txt" ) ) . unwrap( ) , "fixed" ) ;
677+ }
678+
679+ #[ test]
680+ fn test_install_fixup_not_rerun_for_existing_dir ( ) {
681+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
682+ let dst = temp. path ( ) . join ( "install_target" ) ;
683+ fs:: create_dir_all ( & dst) . unwrap ( ) ;
684+ fs:: write ( dst. join ( "existing.txt" ) , "existing content" ) . unwrap ( ) ;
685+
686+ let bad_data = b"invalid archive data" ;
687+ install_with_fixup ( & bad_data[ ..] , & dst, None , |_| {
688+ panic ! ( "fixup should not run for an existing installation" ) ;
689+ } )
690+ . unwrap ( ) ;
691+
692+ assert_eq ! ( fs:: read_to_string( dst. join( "existing.txt" ) ) . unwrap( ) , "existing content" ) ;
693+ }
694+
695+ #[ test]
696+ fn test_install_fixup_failure_removes_installation ( ) {
697+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
698+ let dst = temp. path ( ) . join ( "install_target" ) ;
699+ let tar_zst = create_dummy_tar_zst ( & [ ( "hello.txt" , b"hello world" ) ] ) ;
700+
701+ let result = install_with_fixup ( tar_zst. as_slice ( ) , & dst, None , |_| {
702+ Err ( std:: io:: Error :: new ( std:: io:: ErrorKind :: Other , "fixup failed" ) )
703+ } ) ;
704+
705+ assert ! ( result. is_err( ) ) ;
706+ assert ! ( !dst. exists( ) ) ;
707+ let entries: Vec < _ > = fs:: read_dir ( temp. path ( ) )
708+ . unwrap ( )
709+ . map ( |e| e. unwrap ( ) . file_name ( ) )
710+ . filter ( |n| n != "install_target.lock" )
711+ . collect ( ) ;
712+ assert ! ( entries. is_empty( ) ) ;
713+ }
714+
715+ #[ cfg( unix) ]
716+ #[ test]
717+ fn test_install_preserves_read_only_permissions ( ) {
718+ use std:: os:: unix:: fs:: PermissionsExt as _;
719+
720+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
721+ let dst = temp. path ( ) . join ( "install_target" ) ;
722+ let tar_zst = create_dummy_tar_zst_with_modes ( & [
723+ ( "readonly.txt" , b"locked" , 0o444 ) ,
724+ ( "bin/tool" , b"tool" , 0o555 ) ,
725+ ] ) ;
726+
727+ install ( tar_zst. as_slice ( ) , & dst, None ) . unwrap ( ) ;
728+
729+ let readonly_mode = fs:: metadata ( dst. join ( "readonly.txt" ) ) . unwrap ( ) . permissions ( ) . mode ( ) ;
730+ let tool_mode = fs:: metadata ( dst. join ( "bin/tool" ) ) . unwrap ( ) . permissions ( ) . mode ( ) ;
731+ assert_eq ! ( readonly_mode & 0o777 , 0o444 ) ;
732+ assert_eq ! ( tool_mode & 0o777 , 0o555 ) ;
733+ }
734+
598735 #[ test]
599736 fn test_install_without_hash_validation ( ) {
600737 let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
0 commit comments