Skip to content

Commit 6f2e6b0

Browse files
authored
Merge pull request #10534 from Extra-Chill/fix-10290-unify-tree-hashers
fix(core): unify deploy and harvest tree walks behind one scanner
2 parents 9482e16 + bdae997 commit 6f2e6b0

4 files changed

Lines changed: 391 additions & 101 deletions

File tree

crates/homeboy-core/src/content_diff.rs

Lines changed: 276 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
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
312
use std::collections::{BTreeMap, BTreeSet};
413
use 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+
132277
pub 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
}

crates/homeboy-core/src/harvest.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,10 @@ fn materialize_remote(
297297
Some("materialize harvest snapshot".to_string()),
298298
)
299299
})?;
300-
if content_diff::excluded(relative, excludes) {
300+
// Runtime scratch files are created by deploy's own transport, so they
301+
// are never remote content worth recovering. Skipping them here as well
302+
// as in the comparison avoids downloading bytes only to discard them.
303+
if content_diff::excluded(relative, excludes) || content_diff::runtime_artifact(relative) {
301304
continue;
302305
}
303306
let local = crate::resolve_contained_local_path(destination, relative, "remote path")?;

0 commit comments

Comments
 (0)