11//! Generic byte-level directory comparison and safe source-to-destination application.
2+ //!
3+ //! This module owns the one directory walk every tree comparison in the
4+ //! codebase is built on. Recovery (`harvest`) and deploy drift detection used
5+ //! to each carry a privately written walker, and the two could return
6+ //! contradictory answers for the same directory pair because their divergences
7+ //! were accidents of two implementations rather than decisions (#10290). The
8+ //! walk now lives in [`scan_tree`] and every remaining divergence is a named
9+ //! field on [`TreeScanOptions`], so a behavioural difference between call sites
10+ //! is reviewable instead of invisible.
211
312use std:: collections:: { BTreeMap , BTreeSet } ;
413use std:: fs;
@@ -45,7 +54,7 @@ pub fn compare(
4554 kind : ContentChangeKind :: Deletion ,
4655 bytes : to. bytes ,
4756 } ) ,
48- ( Some ( from) , Some ( to) ) if from. digest != to . digest => Some ( ContentChange {
57+ ( Some ( from) , Some ( to) ) if ! from. matches ( to ) => Some ( ContentChange {
4958 path,
5059 kind : ContentChangeKind :: Modification ,
5160 bytes : from. bytes ,
@@ -76,13 +85,96 @@ pub fn apply(source: &Path, destination: &Path, changes: &[ContentChange]) -> cr
7685 Ok ( ( ) )
7786}
7887
79- #[ derive( Debug ) ]
80- struct Entry {
81- digest : String ,
82- bytes : u64 ,
88+ /// What a tree scan records, and what it leaves out.
89+ ///
90+ /// Recovery and deploy walk the same shape of directory but are answering
91+ /// different questions about it, so the facts each one needs differ. Every
92+ /// difference is a field here rather than a separate walker: two call sites can
93+ /// still behave differently, but only by declaring that they do.
94+ #[ derive( Debug , Clone , Default ) ]
95+ pub struct TreeScanOptions {
96+ /// Relative glob and directory patterns removed from the scan. `.git` is
97+ /// always removed regardless of this set.
98+ pub excludes : Vec < String > ,
99+ /// Record symlinks as entries carrying their link target. When false a
100+ /// symlink is neither followed nor recorded, so link-only drift is
101+ /// invisible to the caller.
102+ pub record_symlinks : bool ,
103+ /// Record the executable bit as part of each file's identity.
104+ pub record_executable_mode : bool ,
105+ /// Prune this product's own transport scratch files at any depth. They are
106+ /// created by the tooling performing the comparison, never by the content
107+ /// being compared, so no side of a comparison should report them as drift.
108+ pub prune_runtime_artifacts : bool ,
83109}
84110
85- fn collect ( root : & Path , excludes : & [ String ] ) -> crate :: Result < BTreeMap < String , Entry > > {
111+ /// Whether a scanned path is a regular file or a symlink.
112+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
113+ pub enum TreeEntryKind {
114+ File ,
115+ Symlink ,
116+ }
117+
118+ impl TreeEntryKind {
119+ /// Single-character tag used by manifest wire formats and digests.
120+ pub fn tag ( self ) -> char {
121+ match self {
122+ TreeEntryKind :: File => 'f' ,
123+ TreeEntryKind :: Symlink => 'l' ,
124+ }
125+ }
126+ }
127+
128+ /// One scanned path and the facts recorded about it.
129+ #[ derive( Debug , Clone ) ]
130+ pub struct TreeEntry {
131+ pub kind : TreeEntryKind ,
132+ /// Executable bits in octal (see [`executable_mode_tag`]), or `"0"` when
133+ /// the scan does not record mode, the entry is a symlink, or the platform
134+ /// has no mode concept.
135+ pub mode : String ,
136+ /// SHA-256 hex digest for files; the link target for symlinks.
137+ pub value : String ,
138+ /// Filesystem byte length, or `0` when the entry did not come from a
139+ /// filesystem walk. Deliberately excluded from [`TreeEntry::matches`]:
140+ /// manifests parsed from a remote probe or an archive carry content
141+ /// identity without a stat size, and comparing a missing size against a
142+ /// real one would report drift on identical content.
143+ pub bytes : u64 ,
144+ }
145+
146+ impl TreeEntry {
147+ /// Whether two entries describe the same content at the same path.
148+ pub fn matches ( & self , other : & Self ) -> bool {
149+ self . kind == other. kind && self . mode == other. mode && self . value == other. value
150+ }
151+ }
152+
153+ /// The mode semantic that survives a deploy.
154+ ///
155+ /// Deploy normalizes ownership and group-write/setgid bits on the target, so
156+ /// only executability is stable enough to compare. Rendering it here — rather
157+ /// than in each manifest producer — is what keeps a locally walked tree, a
158+ /// remotely probed tree, and an archive expressing the same bits identically.
159+ pub fn executable_mode_tag ( mode : u32 ) -> String {
160+ format ! ( "{:o}" , mode & 0o111 )
161+ }
162+
163+ /// Whether a relative path is one of this product's own transport scratch
164+ /// files, at any depth.
165+ pub fn runtime_artifact ( path : & str ) -> bool {
166+ let prefix = crate :: product_identity:: PRODUCT_IDENTITY . artifact_prefix ;
167+ path. split ( '/' ) . any ( |part| part. starts_with ( prefix) )
168+ }
169+
170+ /// Walk `root` and record one entry per path, honouring `options`.
171+ ///
172+ /// Entries are keyed by `/`-separated path relative to `root`, so the result is
173+ /// directly comparable with a manifest produced for a different root.
174+ pub fn scan_tree (
175+ root : & Path ,
176+ options : & TreeScanOptions ,
177+ ) -> crate :: Result < BTreeMap < String , TreeEntry > > {
86178 if !root. is_dir ( ) {
87179 return Err ( crate :: Error :: validation_invalid_argument (
88180 "path" ,
@@ -92,15 +184,26 @@ fn collect(root: &Path, excludes: &[String]) -> crate::Result<BTreeMap<String, E
92184 ) ) ;
93185 }
94186 let mut entries = BTreeMap :: new ( ) ;
95- visit ( root, root, excludes , & mut entries) ?;
187+ scan_directory ( root, root, options , & mut entries) ?;
96188 Ok ( entries)
97189}
98190
99- fn visit (
191+ fn collect ( root : & Path , excludes : & [ String ] ) -> crate :: Result < BTreeMap < String , TreeEntry > > {
192+ scan_tree (
193+ root,
194+ & TreeScanOptions {
195+ excludes : excludes. to_vec ( ) ,
196+ prune_runtime_artifacts : true ,
197+ ..TreeScanOptions :: default ( )
198+ } ,
199+ )
200+ }
201+
202+ fn scan_directory (
100203 root : & Path ,
101204 directory : & Path ,
102- excludes : & [ String ] ,
103- entries : & mut BTreeMap < String , Entry > ,
205+ options : & TreeScanOptions ,
206+ entries : & mut BTreeMap < String , TreeEntry > ,
104207) -> crate :: Result < ( ) > {
105208 for entry in fs:: read_dir ( directory) . map_err ( io_error) ? {
106209 let path = entry. map_err ( io_error) ?. path ( ) ;
@@ -109,26 +212,68 @@ fn visit(
109212 . map_err ( io_error) ?
110213 . to_string_lossy ( )
111214 . replace ( '\\' , "/" ) ;
112- if excluded ( & relative, excludes) {
215+ if excluded ( & relative, & options. excludes )
216+ || ( options. prune_runtime_artifacts && runtime_artifact ( & relative) )
217+ {
113218 continue ;
114219 }
115220 let metadata = fs:: symlink_metadata ( & path) . map_err ( io_error) ?;
116- if metadata. is_dir ( ) {
117- visit ( root, & path, excludes, entries) ?;
221+ if metadata. file_type ( ) . is_symlink ( ) {
222+ if !options. record_symlinks {
223+ continue ;
224+ }
225+ let target = fs:: read_link ( & path)
226+ . map_err ( io_error) ?
227+ . to_string_lossy ( )
228+ . to_string ( ) ;
229+ entries. insert (
230+ relative,
231+ TreeEntry {
232+ kind : TreeEntryKind :: Symlink ,
233+ mode : "0" . to_string ( ) ,
234+ value : target,
235+ bytes : 0 ,
236+ } ,
237+ ) ;
238+ } else if metadata. is_dir ( ) {
239+ scan_directory ( root, & path, options, entries) ?;
118240 } else if metadata. is_file ( ) {
119- let bytes = metadata. len ( ) ;
120241 entries. insert (
121242 relative,
122- Entry {
123- digest : digest ( & path) ?,
124- bytes,
243+ TreeEntry {
244+ kind : TreeEntryKind :: File ,
245+ mode : entry_mode ( & metadata, options) ,
246+ value : digest ( & path) ?,
247+ bytes : metadata. len ( ) ,
125248 } ,
126249 ) ;
127250 }
128251 }
129252 Ok ( ( ) )
130253}
131254
255+ fn entry_mode ( metadata : & fs:: Metadata , options : & TreeScanOptions ) -> String {
256+ if options. record_executable_mode {
257+ platform_executable_mode ( metadata)
258+ } else {
259+ "0" . to_string ( )
260+ }
261+ }
262+
263+ /// Executable bits as the platform reports them.
264+ #[ cfg( unix) ]
265+ fn platform_executable_mode ( metadata : & fs:: Metadata ) -> String {
266+ use std:: os:: unix:: fs:: PermissionsExt ;
267+ executable_mode_tag ( metadata. permissions ( ) . mode ( ) )
268+ }
269+
270+ /// Targets without a mode concept compare every entry as non-executable, which
271+ /// is the same answer the previous deploy walker gave there.
272+ #[ cfg( not( unix) ) ]
273+ fn platform_executable_mode ( _metadata : & fs:: Metadata ) -> String {
274+ "0" . to_string ( )
275+ }
276+
132277pub fn excluded ( path : & str , excludes : & [ String ] ) -> bool {
133278 path == ".git"
134279 || path. starts_with ( ".git/" )
@@ -212,4 +357,118 @@ mod tests {
212357 . expect( "compare" )
213358 . is_empty( ) ) ;
214359 }
360+
361+ /// #10290: deploy's manifest pruned this product's transport scratch files
362+ /// and recovery's comparison did not, so the two disagreed about the same
363+ /// directory pair. The scratch files belong to the tooling doing the
364+ /// comparing; neither answer should ever have included them.
365+ #[ test]
366+ fn recovery_comparison_prunes_this_products_transport_scratch_files ( ) {
367+ let temp = tempfile:: tempdir ( ) . expect ( "temp" ) ;
368+ let source = temp. path ( ) . join ( "source" ) ;
369+ let destination = temp. path ( ) . join ( "destination" ) ;
370+ fs:: create_dir_all ( source. join ( "nested" ) ) . expect ( "source" ) ;
371+ fs:: create_dir_all ( & destination) . expect ( "destination" ) ;
372+ let scratch = format ! (
373+ "{}upload.tmp" ,
374+ crate :: product_identity:: PRODUCT_IDENTITY . artifact_prefix
375+ ) ;
376+ fs:: write ( source. join ( & scratch) , "remote" ) . expect ( "scratch" ) ;
377+ fs:: write ( source. join ( "nested" ) . join ( & scratch) , "remote" ) . expect ( "nested scratch" ) ;
378+ fs:: write ( destination. join ( & scratch) , "local" ) . expect ( "scratch" ) ;
379+
380+ assert ! ( compare( & source, & destination, & [ ] )
381+ . expect( "compare" )
382+ . is_empty( ) ) ;
383+ assert ! ( runtime_artifact( & scratch) ) ;
384+ assert ! ( runtime_artifact( & format!( "nested/{scratch}" ) ) ) ;
385+ assert ! ( !runtime_artifact( "nested/payload.txt" ) ) ;
386+ }
387+
388+ /// The recorded facts are options, not accidents: the same tree scanned
389+ /// with recovery's options and with deploy's options must differ only in
390+ /// the ways those options declare.
391+ #[ test]
392+ fn scan_options_decide_which_facts_a_walk_records ( ) {
393+ let temp = tempfile:: tempdir ( ) . expect ( "temp" ) ;
394+ let root = temp. path ( ) . join ( "tree" ) ;
395+ fs:: create_dir_all ( & root) . expect ( "root" ) ;
396+ fs:: write ( root. join ( "file" ) , "bytes" ) . expect ( "file" ) ;
397+ #[ cfg( unix) ]
398+ {
399+ use std:: os:: unix:: fs:: PermissionsExt ;
400+ fs:: set_permissions ( root. join ( "file" ) , fs:: Permissions :: from_mode ( 0o755 ) )
401+ . expect ( "mode" ) ;
402+ std:: os:: unix:: fs:: symlink ( "file" , root. join ( "link" ) ) . expect ( "link" ) ;
403+ }
404+
405+ let recovery = scan_tree (
406+ & root,
407+ & TreeScanOptions {
408+ prune_runtime_artifacts : true ,
409+ ..TreeScanOptions :: default ( )
410+ } ,
411+ )
412+ . expect ( "recovery scan" ) ;
413+ let deployed = scan_tree (
414+ & root,
415+ & TreeScanOptions {
416+ record_symlinks : true ,
417+ record_executable_mode : true ,
418+ prune_runtime_artifacts : true ,
419+ ..TreeScanOptions :: default ( )
420+ } ,
421+ )
422+ . expect ( "deploy scan" ) ;
423+
424+ assert_eq ! ( recovery[ "file" ] . kind, TreeEntryKind :: File ) ;
425+ assert_eq ! ( recovery[ "file" ] . bytes, "bytes" . len( ) as u64 ) ;
426+ assert_eq ! ( recovery[ "file" ] . mode, "0" ) ;
427+ assert_eq ! ( recovery[ "file" ] . value, deployed[ "file" ] . value) ;
428+
429+ #[ cfg( unix) ]
430+ {
431+ assert_eq ! ( deployed[ "file" ] . mode, executable_mode_tag( 0o755 ) ) ;
432+ assert ! ( !recovery. contains_key( "link" ) ) ;
433+ assert_eq ! ( deployed[ "link" ] . kind, TreeEntryKind :: Symlink ) ;
434+ assert_eq ! ( deployed[ "link" ] . value, "file" ) ;
435+ assert_eq ! ( deployed[ "link" ] . mode, "0" ) ;
436+ }
437+ }
438+
439+ /// Byte length is provenance, not identity. A manifest parsed from a remote
440+ /// probe or an archive has no stat size, so folding size into the match
441+ /// predicate would report drift on byte-identical content.
442+ #[ test]
443+ fn entry_identity_excludes_filesystem_byte_length ( ) {
444+ let walked = TreeEntry {
445+ kind : TreeEntryKind :: File ,
446+ mode : executable_mode_tag ( 0o755 ) ,
447+ value : "a" . repeat ( 64 ) ,
448+ bytes : 42 ,
449+ } ;
450+ let parsed = TreeEntry {
451+ bytes : 0 ,
452+ ..walked. clone ( )
453+ } ;
454+ assert ! ( walked. matches( & parsed) ) ;
455+ assert ! ( !walked. matches( & TreeEntry {
456+ mode: "0" . to_string( ) ,
457+ ..walked. clone( )
458+ } ) ) ;
459+ assert ! ( !walked. matches( & TreeEntry {
460+ kind: TreeEntryKind :: Symlink ,
461+ ..walked. clone( )
462+ } ) ) ;
463+ }
464+
465+ #[ test]
466+ fn executable_mode_tag_keeps_only_bits_that_survive_a_deploy ( ) {
467+ // Deploy normalizes ownership and group-write bits, so 0644 and 0664
468+ // are the same deployed file while 0755 is not.
469+ assert_eq ! ( executable_mode_tag( 0o644 ) , "0" ) ;
470+ assert_eq ! ( executable_mode_tag( 0o664 ) , "0" ) ;
471+ assert_eq ! ( executable_mode_tag( 0o755 ) , "111" ) ;
472+ assert_eq ! ( executable_mode_tag( 0o775 ) , "111" ) ;
473+ }
215474}
0 commit comments