Skip to main content

anodizer_core/
util.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::fs;
3use std::path::Path;
4use std::time::{Duration, SystemTime};
5
6use anyhow::{Context as _, Result};
7
8/// Compile a regex, panicking with a diagnostic if the pattern is invalid.
9/// Intended for `LazyLock::new(…)` initializers where the pattern is a
10/// hardcoded literal (or built from `format!` over known-safe fragments).
11/// A compile failure means a programmer bug surfaced at first use, not a
12/// runtime-path user-input error. Exists because the anti-pattern hook
13/// forbids bare panicking error helpers in lib code, and `Regex::new` on
14/// a trusted literal is inherently infallible.
15pub fn static_regex(pattern: &str) -> regex::Regex {
16    regex::Regex::new(pattern)
17        .unwrap_or_else(|e| panic!("invalid static regex literal `{}`: {}", pattern, e))
18}
19
20// ---------------------------------------------------------------------------
21// Topological sort (Kahn's algorithm)
22// ---------------------------------------------------------------------------
23
24/// Topologically sort items by their dependency lists.
25///
26/// Input: slice of `(name, depends_on)` pairs.
27/// Output: names in dependency order (dependencies before dependents).
28///
29/// - Dependencies that are not in the input set are silently ignored.
30/// - Deterministic: zero-in-degree nodes are sorted alphabetically.
31/// - On cycles: sorted nodes are returned followed by remaining nodes in
32///   their original order.
33pub fn topological_sort(items: &[(impl AsRef<str>, impl AsRef<[String]>)]) -> Vec<String> {
34    let names: HashSet<&str> = items.iter().map(|(n, _)| n.as_ref()).collect();
35
36    let mut in_degree: HashMap<&str, usize> = items
37        .iter()
38        .map(|(n, deps)| {
39            let deg = deps
40                .as_ref()
41                .iter()
42                .filter(|d| names.contains(d.as_str()))
43                .count();
44            (n.as_ref(), deg)
45        })
46        .collect();
47
48    // edges: dep → list of dependents
49    let mut edges: HashMap<&str, Vec<&str>> = HashMap::new();
50    for (n, deps) in items {
51        for dep in deps.as_ref() {
52            if names.contains(dep.as_str()) {
53                edges.entry(dep.as_str()).or_default().push(n.as_ref());
54            }
55        }
56    }
57
58    // Kahn's algorithm with deterministic seed ordering
59    let mut queue: VecDeque<&str> = {
60        let mut v: Vec<&str> = in_degree
61            .iter()
62            .filter(|(_, d)| **d == 0)
63            .map(|(&n, _)| n)
64            .collect();
65        v.sort_unstable();
66        VecDeque::from(v)
67    };
68
69    let mut result = Vec::with_capacity(items.len());
70    while let Some(node) = queue.pop_front() {
71        result.push(node.to_string());
72        if let Some(dependents) = edges.get(node) {
73            let mut next: Vec<&str> = dependents
74                .iter()
75                .filter_map(|&dep| {
76                    let deg = in_degree.get_mut(dep)?;
77                    *deg -= 1;
78                    if *deg == 0 { Some(dep) } else { None }
79                })
80                .collect();
81            next.sort_unstable();
82            for n in next {
83                queue.push_back(n);
84            }
85        }
86    }
87
88    // Append remaining (cycle case) in original order.
89    if result.len() < items.len() {
90        let in_result: HashSet<String> = result.iter().cloned().collect();
91        for (n, _) in items {
92            if !in_result.contains(n.as_ref()) {
93                result.push(n.as_ref().to_string());
94            }
95        }
96    }
97
98    result
99}
100
101// ---------------------------------------------------------------------------
102// find_binary
103// ---------------------------------------------------------------------------
104
105/// Check whether a binary can be found on the system.
106///
107/// For absolute or relative paths (containing `/`), checks if the file exists.
108/// For bare names, searches each directory in the `PATH` environment variable
109/// for an executable with the given name. This is a pure-Rust implementation
110/// that avoids shelling out to `which` or `command -v`, making it portable
111/// across all platforms.
112pub fn find_binary(name: &str) -> bool {
113    if name.contains('/') || name.contains('\\') {
114        return Path::new(name).exists();
115    }
116
117    // On Windows, PATHEXT lists extensions to try (e.g., .COM;.EXE;.BAT;.CMD).
118    // When the caller asks for "upx", we also check for "upx.exe", etc.
119    let extensions: Vec<String> = if cfg!(windows) {
120        std::env::var("PATHEXT")
121            .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
122            .split(';')
123            .filter(|e| !e.is_empty())
124            .map(|e| e.to_string())
125            .collect()
126    } else {
127        Vec::new()
128    };
129
130    if let Ok(path_var) = std::env::var("PATH") {
131        for dir in std::env::split_paths(&path_var) {
132            let candidate = dir.join(name);
133            if candidate.is_file() {
134                return true;
135            }
136            for ext in &extensions {
137                let with_ext = dir.join(format!("{}{}", name, ext));
138                if with_ext.is_file() {
139                    return true;
140                }
141            }
142        }
143    }
144
145    false
146}
147
148// ---------------------------------------------------------------------------
149// apply_mod_timestamp
150// ---------------------------------------------------------------------------
151
152// ---------------------------------------------------------------------------
153// mod_timestamp helpers
154// ---------------------------------------------------------------------------
155
156/// Parse a `mod_timestamp` string into a `SystemTime`.
157///
158/// Accepts:
159///   - Unix epoch seconds as an integer (e.g. `"1704067200"`)
160///   - RFC 3339 / ISO 8601 datetime (e.g. `"2024-01-01T00:00:00Z"`)
161pub fn parse_mod_timestamp(raw: &str) -> Result<SystemTime> {
162    // Try Unix epoch integer first (most common in CI)
163    if let Ok(epoch_secs) = raw.parse::<u64>() {
164        return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs));
165    }
166    // Try RFC 3339 / ISO 8601 via chrono
167    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
168        let epoch_secs = dt.timestamp() as u64;
169        return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs));
170    }
171    // Try chrono's more lenient parsing for formats like "2024-01-01T00:00:00"
172    if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") {
173        let epoch_secs = dt.and_utc().timestamp() as u64;
174        return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs));
175    }
176    anyhow::bail!(
177        "mod_timestamp value '{raw}' is not a valid timestamp. \
178         Accepted formats: Unix epoch seconds (e.g. \"1704067200\") or \
179         RFC 3339 datetime (e.g. \"2024-01-01T00:00:00Z\")"
180    )
181}
182
183/// Apply `mod_timestamp` to every regular file in a directory tree.
184///
185/// Parses the timestamp via `parse_mod_timestamp`, then recurses into
186/// subdirectories, setting the mtime on every regular file. Symlinks are not
187/// followed and directory mtimes are left untouched (files-only semantics,
188/// matching [`pin_dir_mtimes_epoch`], the SDE reproducibility floor this
189/// override is layered on top of). A nested staged file — e.g. a
190/// `templated_extra_files` entry whose dst is `docs/README.txt` — must receive
191/// the user's `mod_timestamp`, not the SDE epoch left by the floor.
192pub fn apply_mod_timestamp(dir: &Path, raw: &str, log: &crate::log::StageLogger) -> Result<()> {
193    let mtime = parse_mod_timestamp(raw)?;
194
195    let mut stack: Vec<std::path::PathBuf> = vec![dir.to_path_buf()];
196    while let Some(p) = stack.pop() {
197        for entry in
198            fs::read_dir(&p).with_context(|| format!("read staging dir {}", p.display()))?
199        {
200            let entry = entry?;
201            let path = entry.path();
202            let ft = entry.file_type()?;
203            if ft.is_dir() {
204                stack.push(path);
205            } else if ft.is_file() {
206                set_file_mtime(&path, mtime)?;
207            }
208        }
209    }
210
211    log.status(&format!("applied mod_timestamp={raw} to staging files"));
212    Ok(())
213}
214
215/// Set the modification time on a single file.
216pub fn set_file_mtime(path: &Path, mtime: SystemTime) -> Result<()> {
217    let file = std::fs::OpenOptions::new()
218        .write(true)
219        .open(path)
220        .with_context(|| format!("open {} for mtime update", path.display()))?;
221    file.set_times(
222        std::fs::FileTimes::new()
223            .set_accessed(mtime)
224            .set_modified(mtime),
225    )
226    .with_context(|| format!("set mtime on {}", path.display()))?;
227    Ok(())
228}
229
230/// Set the modification time on a single file from a Unix epoch (seconds).
231///
232/// Thin wrapper over `set_file_mtime` that accepts `SOURCE_DATE_EPOCH`-style
233/// `i64` seconds (signed to permit pre-1970 values per the spec).
234pub fn set_file_mtime_epoch(path: &Path, epoch_secs: i64) -> Result<()> {
235    let mtime = if epoch_secs >= 0 {
236        SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs as u64)
237    } else {
238        SystemTime::UNIX_EPOCH - Duration::from_secs((-epoch_secs) as u64)
239    };
240    set_file_mtime(path, mtime)
241}
242
243/// Recursively pin every regular file's mtime under `dir` to `epoch_secs`
244/// (SOURCE_DATE_EPOCH seconds). Packaging tools (makeself's tar, NSIS's `File`)
245/// embed each input file's on-disk mtime; `fs::copy` stamps the wall clock, so
246/// two harness runs with identical contents drift the packed bytes. Pinning to
247/// the build epoch removes that variance.
248///
249/// Subdirectories are walked; only regular files have their mtime set (mirrors
250/// the mtime semantics relevant to the archive headers these tools emit).
251pub fn pin_dir_mtimes_epoch(dir: &Path, epoch_secs: i64) -> Result<()> {
252    let mut stack: Vec<std::path::PathBuf> = vec![dir.to_path_buf()];
253    while let Some(p) = stack.pop() {
254        for entry in
255            fs::read_dir(&p).with_context(|| format!("read_dir {} for mtime pin", p.display()))?
256        {
257            let entry = entry?;
258            let path = entry.path();
259            let ft = entry.file_type()?;
260            if ft.is_dir() {
261                stack.push(path);
262            } else if ft.is_file() {
263                set_file_mtime_epoch(&path, epoch_secs)
264                    .with_context(|| format!("pin mtime on {}", path.display()))?;
265            }
266        }
267    }
268    Ok(())
269}
270
271/// Recursively copy the directory tree rooted at `src` into `dst`, recreating
272/// subdirectories, copying regular files (with [`fs::copy`], which preserves
273/// the Unix mode bits — including the executable bit), and recreating symlinks
274/// as symlinks rather than dereferencing them.
275///
276/// Preserving symlinks matters for macOS app bundles, which embed framework
277/// version symlinks (`Versions/Current -> A`); a dereferencing copy would
278/// flatten them and bloat the bundle. `dst` (and any missing parents) is
279/// created if absent. On non-Unix hosts, where creating a symlink needs
280/// elevated rights, the link target's contents are copied instead so the tree
281/// stays complete.
282pub fn copy_dir_tree(src: &Path, dst: &Path) -> Result<()> {
283    fs::create_dir_all(dst).with_context(|| format!("create dir {}", dst.display()))?;
284    for entry in fs::read_dir(src).with_context(|| format!("read dir {}", src.display()))? {
285        let entry = entry.with_context(|| format!("read entry under {}", src.display()))?;
286        let from = entry.path();
287        let to = dst.join(entry.file_name());
288        // symlink_metadata (via DirEntry::file_type) so a symlink is recreated
289        // as a link rather than dereferenced.
290        let file_type = entry
291            .file_type()
292            .with_context(|| format!("stat {}", from.display()))?;
293        if file_type.is_symlink() {
294            #[cfg(unix)]
295            {
296                let target = fs::read_link(&from)
297                    .with_context(|| format!("read symlink {}", from.display()))?;
298                std::os::unix::fs::symlink(&target, &to).with_context(|| {
299                    format!("recreate symlink {} -> {}", to.display(), target.display())
300                })?;
301            }
302            #[cfg(not(unix))]
303            {
304                if from.is_dir() {
305                    copy_dir_tree(&from, &to)?;
306                } else {
307                    fs::copy(&from, &to)
308                        .with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
309                }
310            }
311        } else if file_type.is_dir() {
312            copy_dir_tree(&from, &to)?;
313        } else {
314            fs::copy(&from, &to)
315                .with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
316        }
317    }
318    Ok(())
319}
320
321// ---------------------------------------------------------------------------
322// collect_replace_archives
323// ---------------------------------------------------------------------------
324
325/// Collect archive artifact paths for a given crate + target, for removal by `replace` options.
326pub fn collect_replace_archives(
327    artifacts: &crate::artifact::ArtifactRegistry,
328    crate_name: &str,
329    target: Option<&str>,
330) -> Vec<std::path::PathBuf> {
331    artifacts
332        .by_kind_and_crate(crate::artifact::ArtifactKind::Archive, crate_name)
333        .iter()
334        .filter(|a| a.target.as_deref() == target)
335        .map(|a| a.path.clone())
336        .collect()
337}
338
339/// Gated variant of [`collect_replace_archives`]: returns the matching
340/// archive paths only when `replace` is `Some(true)`. Used by packaging
341/// stages (dmg, msi, flatpak, snapcraft, nsis, pkg, appbundle) to
342/// replace a source archive with the packaged output when the user
343/// opts in via `replace: true` on the config. Returns an empty vec
344/// when `replace` is unset or `false`.
345pub fn collect_if_replace(
346    replace: Option<bool>,
347    artifacts: &crate::artifact::ArtifactRegistry,
348    crate_name: &str,
349    target: Option<&str>,
350) -> Vec<std::path::PathBuf> {
351    if replace.unwrap_or(false) {
352        collect_replace_archives(artifacts, crate_name, target)
353    } else {
354        Vec::new()
355    }
356}
357
358/// Convert any Windows-style backslash separators in `s` to forward
359/// slashes. Cross-platform path string normalization for cases where the
360/// downstream consumer (artifact-manifest JSON, MSYS subprocess env var)
361/// is sensitive to separator drift between Linux/macOS and Windows hosts.
362pub fn normalize_path_separators(s: &str) -> String {
363    s.replace('\\', "/")
364}
365
366/// Apply a "minimal trusted" environment to a `Command` after `env_clear()`.
367///
368/// Stage subprocess invocations (sbom, source-archive, …) clear the env to
369/// stop accidental token leakage but still need a small set of platform-
370/// neutral keys so that `git`, `tar`, `syft`, etc. behave normally — HOME
371/// for tool config, USER for git author fallback, USERPROFILE/LOCALAPPDATA
372/// for the Windows equivalents, TMPDIR/TMP/TEMP so temp-file allocation
373/// doesn't land in a forbidden directory, and PATH so the tool itself can
374/// find its dependencies. Keeping this list in core means any new entry
375/// (e.g. SSL_CERT_DIR for syft pulling enrich data) is added once.
376pub fn apply_minimal_env(command: &mut std::process::Command) {
377    const PASSTHROUGH: &[&str] = &[
378        "HOME",
379        "USER",
380        "USERPROFILE",
381        "TMPDIR",
382        "TMP",
383        "TEMP",
384        "PATH",
385        "LOCALAPPDATA",
386    ];
387    for key in PASSTHROUGH {
388        if let Ok(val) = std::env::var(key) {
389            command.env(key, val);
390        }
391    }
392}
393
394/// Cargo build-intermediate subdirectories that sit under a profile dir
395/// (`target/<triple>/release/`) and hold no shippable or hashed artifact.
396///
397/// The final binary and any sibling files live directly under the profile
398/// dir; everything reproducibility cares about (the produced binary, the
399/// `dist/` archives/installers built from it) is downstream of these. These
400/// four are pure cargo scratch — object files, build-script outputs,
401/// incremental-compilation state, and fingerprints — that cargo regenerates
402/// on demand if a later build touches the same triple.
403const CARGO_BUILD_INTERMEDIATE_DIRS: &[&str] = &["deps", "build", "incremental", ".fingerprint"];
404
405/// Free cargo build intermediates under a profile directory
406/// (`target/<triple>/release/`) once its binary has been produced, lowering
407/// peak disk for multi-target builds that share one `target/` tree.
408///
409/// Removes only [`CARGO_BUILD_INTERMEDIATE_DIRS`] (`deps`, `build`,
410/// `incremental`, `.fingerprint`). The final binary and every other file
411/// directly under `profile_dir` are left untouched, so neither a shipped
412/// artifact nor a determinism-hashed binary can change.
413///
414/// Best-effort: a missing subdir is the normal case (not every triple has
415/// `incremental/`), and a failed remove must never fail the build — both are
416/// reported at verbose and swallowed. Returns the list of subdir names
417/// actually removed so callers can log a precise per-triple line.
418///
419/// Guard: this only operates when `profile_dir`'s basename is a real cargo
420/// profile (`release` / `debug`). Handed anything else (a workspace root,
421/// `target/` itself, an empty/root path), it is a hard no-op — the scratch
422/// names are generic enough that a future miswire pointing at the wrong dir
423/// would otherwise delete a real `build`/`deps` tree.
424pub fn free_cargo_build_intermediates(
425    profile_dir: &Path,
426    log: &crate::log::StageLogger,
427) -> Vec<&'static str> {
428    let is_cargo_profile_dir = profile_dir
429        .file_name()
430        .and_then(|n| n.to_str())
431        .is_some_and(|n| n == "release" || n == "debug");
432    if !is_cargo_profile_dir {
433        log.verbose(&format!(
434            "refusing to free build intermediates under non-profile dir {}",
435            profile_dir.display()
436        ));
437        return Vec::new();
438    }
439    let mut freed = Vec::new();
440    for sub in CARGO_BUILD_INTERMEDIATE_DIRS {
441        let path = profile_dir.join(sub);
442        if !path.exists() {
443            continue;
444        }
445        match fs::remove_dir_all(&path) {
446            Ok(()) => freed.push(*sub),
447            Err(err) => log.verbose(&format!(
448                "could not free build intermediate {}: {err}",
449                path.display()
450            )),
451        }
452    }
453    freed
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    // -----------------------------------------------------------------------
461    // topological_sort tests
462    // -----------------------------------------------------------------------
463
464    #[test]
465    fn test_topo_sort_simple_chain() {
466        let items = vec![
467            ("c".to_string(), vec!["b".to_string()]),
468            ("b".to_string(), vec!["a".to_string()]),
469            ("a".to_string(), vec![]),
470        ];
471        let sorted = topological_sort(&items);
472        assert_eq!(sorted, vec!["a", "b", "c"]);
473    }
474
475    #[test]
476    fn test_topo_sort_no_deps() {
477        let items = vec![("b".to_string(), vec![]), ("a".to_string(), vec![])];
478        // Deterministic: alphabetical
479        let sorted = topological_sort(&items);
480        assert_eq!(sorted, vec!["a", "b"]);
481    }
482
483    #[test]
484    fn test_topo_sort_ignores_external_deps() {
485        let items = vec![
486            (
487                "b".to_string(),
488                vec!["a".to_string(), "external".to_string()],
489            ),
490            ("a".to_string(), vec![]),
491        ];
492        let sorted = topological_sort(&items);
493        assert_eq!(sorted, vec!["a", "b"]);
494    }
495
496    #[test]
497    fn test_topo_sort_diamond() {
498        let items = vec![
499            ("d".to_string(), vec!["b".to_string(), "c".to_string()]),
500            ("b".to_string(), vec!["a".to_string()]),
501            ("c".to_string(), vec!["a".to_string()]),
502            ("a".to_string(), vec![]),
503        ];
504        let sorted = topological_sort(&items);
505        // a must come first, d must come last, b and c in between
506        assert_eq!(sorted[0], "a");
507        assert_eq!(sorted[3], "d");
508    }
509
510    #[test]
511    fn test_topo_sort_cycle_appends_remaining() {
512        let items = vec![
513            ("a".to_string(), vec!["b".to_string()]),
514            ("b".to_string(), vec!["a".to_string()]),
515            ("c".to_string(), vec![]),
516        ];
517        let sorted = topological_sort(&items);
518        assert_eq!(sorted.len(), 3);
519        // c has no deps, should come first; a and b are in a cycle
520        assert_eq!(sorted[0], "c");
521    }
522
523    #[test]
524    fn test_topo_sort_empty() {
525        let items: Vec<(String, Vec<String>)> = vec![];
526        let sorted = topological_sort(&items);
527        assert!(sorted.is_empty());
528    }
529
530    // -----------------------------------------------------------------------
531    // find_binary tests
532    // -----------------------------------------------------------------------
533
534    #[test]
535    fn test_find_binary_absolute_path_exists() {
536        if cfg!(windows) {
537            // cmd.exe exists on all Windows systems
538            assert!(find_binary("C:\\Windows\\System32\\cmd.exe"));
539        } else {
540            // /usr/bin/env exists on virtually all Unix systems
541            assert!(find_binary("/usr/bin/env"));
542        }
543    }
544
545    #[test]
546    fn test_find_binary_absolute_path_does_not_exist() {
547        if cfg!(windows) {
548            assert!(!find_binary("C:\\nonexistent\\binary\\path.exe"));
549        } else {
550            assert!(!find_binary("/nonexistent/binary/path"));
551        }
552    }
553
554    #[test]
555    fn test_find_binary_bare_name_on_path() {
556        if cfg!(windows) {
557            // "cmd.exe" should be findable on PATH on any Windows system
558            // (find_binary does exact name match, no implicit .exe appending)
559            assert!(find_binary("cmd.exe"));
560        } else {
561            // "env" should be findable on PATH on any Unix system
562            assert!(find_binary("env"));
563        }
564    }
565
566    #[test]
567    fn test_find_binary_bare_name_not_on_path() {
568        assert!(!find_binary("nonexistent-binary-xyz-12345"));
569    }
570
571    // -----------------------------------------------------------------------
572    // parse_mod_timestamp tests
573    // -----------------------------------------------------------------------
574
575    #[test]
576    fn test_parse_mod_timestamp_epoch_integer() {
577        let t = parse_mod_timestamp("1704067200").unwrap();
578        let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
579        assert_eq!(epoch, 1704067200);
580    }
581
582    #[test]
583    fn test_parse_mod_timestamp_rfc3339() {
584        let t = parse_mod_timestamp("2024-01-01T00:00:00Z").unwrap();
585        let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
586        assert_eq!(epoch, 1704067200);
587    }
588
589    #[test]
590    fn test_parse_mod_timestamp_rfc3339_with_offset() {
591        let t = parse_mod_timestamp("2024-01-01T01:00:00+01:00").unwrap();
592        let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
593        // 2024-01-01T01:00:00+01:00 is the same instant as 2024-01-01T00:00:00Z
594        assert_eq!(epoch, 1704067200);
595    }
596
597    #[test]
598    fn test_parse_mod_timestamp_naive_datetime() {
599        let t = parse_mod_timestamp("2024-01-01T00:00:00").unwrap();
600        let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
601        assert_eq!(epoch, 1704067200);
602    }
603
604    #[test]
605    fn test_parse_mod_timestamp_invalid() {
606        let err = parse_mod_timestamp("not-a-timestamp").unwrap_err();
607        let msg = err.to_string();
608        assert!(
609            msg.contains("not a valid timestamp"),
610            "unexpected error: {msg}"
611        );
612        // The parse error must include
613        // the offending mtime value so misconfigurations are diagnosable.
614        assert!(
615            msg.contains("not-a-timestamp"),
616            "error must include the bad value, got: {msg}"
617        );
618    }
619
620    #[test]
621    fn test_parse_mod_timestamp_zero() {
622        let t = parse_mod_timestamp("0").unwrap();
623        assert_eq!(t, SystemTime::UNIX_EPOCH);
624    }
625
626    // -----------------------------------------------------------------------
627    // set_file_mtime tests
628    // -----------------------------------------------------------------------
629
630    #[test]
631    fn test_set_file_mtime_sets_both_atime_and_mtime() {
632        let dir = tempfile::tempdir().unwrap();
633        let dir = dir.path();
634
635        let file_path = dir.join("test.txt");
636        std::fs::write(&file_path, "hello").unwrap();
637
638        // Set mtime to a known epoch: 2024-01-01T00:00:00Z = 1704067200
639        let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
640        set_file_mtime(&file_path, target).unwrap();
641
642        let meta = std::fs::metadata(&file_path).unwrap();
643        let actual_mtime = meta.modified().unwrap();
644
645        // Allow 1-second tolerance for filesystem granularity
646        let diff = if actual_mtime > target {
647            actual_mtime.duration_since(target).unwrap()
648        } else {
649            target.duration_since(actual_mtime).unwrap()
650        };
651        assert!(
652            diff.as_secs() <= 1,
653            "mtime should be within 1s of target, diff={:?}",
654            diff
655        );
656
657        // Also verify atime was set (on Linux, accessed() is available)
658        let actual_atime = meta.accessed().unwrap();
659        let diff_a = if actual_atime > target {
660            actual_atime.duration_since(target).unwrap()
661        } else {
662            target.duration_since(actual_atime).unwrap()
663        };
664        assert!(
665            diff_a.as_secs() <= 1,
666            "atime should be within 1s of target, diff={:?}",
667            diff_a
668        );
669    }
670
671    #[test]
672    fn test_pin_dir_mtimes_epoch_recurses_into_subdirs() {
673        let dir = tempfile::tempdir().unwrap();
674        let dir = dir.path();
675        let sub = dir.join("nested");
676        std::fs::create_dir_all(&sub).unwrap();
677
678        let top = dir.join("top.txt");
679        let nested = sub.join("nested.txt");
680        std::fs::write(&top, "top").unwrap();
681        std::fs::write(&nested, "nested").unwrap();
682
683        let epoch: i64 = 1704067200;
684        pin_dir_mtimes_epoch(dir, epoch).unwrap();
685
686        let target = SystemTime::UNIX_EPOCH + Duration::from_secs(epoch as u64);
687        for path in [&top, &nested] {
688            let mtime = std::fs::metadata(path).unwrap().modified().unwrap();
689            assert_eq!(
690                mtime,
691                target,
692                "{}: mtime must equal the pinned epoch exactly",
693                path.display()
694            );
695        }
696    }
697
698    #[test]
699    fn test_set_file_mtime_nonexistent_file() {
700        let result = set_file_mtime(Path::new("/nonexistent/file.txt"), SystemTime::UNIX_EPOCH);
701        assert!(result.is_err());
702    }
703
704    // -----------------------------------------------------------------------
705    // apply_mod_timestamp tests
706    // -----------------------------------------------------------------------
707
708    #[test]
709    fn test_apply_mod_timestamp_sets_mtime_on_regular_files() {
710        let dir = tempfile::tempdir().unwrap();
711        let dir = dir.path();
712
713        // Create two regular files and a subdirectory (the dir itself is not stamped)
714        std::fs::write(dir.join("a.txt"), "aaa").unwrap();
715        std::fs::write(dir.join("b.txt"), "bbb").unwrap();
716        std::fs::create_dir(dir.join("subdir")).unwrap();
717
718        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
719        apply_mod_timestamp(dir, "1704067200", &log).unwrap();
720
721        let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
722        for name in &["a.txt", "b.txt"] {
723            let meta = std::fs::metadata(dir.join(name)).unwrap();
724            let mtime = meta.modified().unwrap();
725            let diff = if mtime > target {
726                mtime.duration_since(target).unwrap()
727            } else {
728                target.duration_since(mtime).unwrap()
729            };
730            assert!(
731                diff.as_secs() <= 1,
732                "{name}: mtime should be within 1s of target, diff={:?}",
733                diff
734            );
735        }
736    }
737
738    #[test]
739    fn test_apply_mod_timestamp_recurses_into_subdirs() {
740        let dir = tempfile::tempdir().unwrap();
741        let dir = dir.path();
742        let sub = dir.join("docs");
743        std::fs::create_dir_all(&sub).unwrap();
744
745        let top = dir.join("top.txt");
746        let nested = sub.join("README.txt");
747        std::fs::write(&top, "top").unwrap();
748        std::fs::write(&nested, "nested").unwrap();
749
750        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
751        apply_mod_timestamp(dir, "1704067200", &log).unwrap();
752
753        let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
754        for path in [&top, &nested] {
755            let mtime = std::fs::metadata(path).unwrap().modified().unwrap();
756            let diff = if mtime > target {
757                mtime.duration_since(target).unwrap()
758            } else {
759                target.duration_since(mtime).unwrap()
760            };
761            assert!(
762                diff.as_secs() <= 1,
763                "{}: nested file must receive mod_timestamp, diff={:?}",
764                path.display(),
765                diff
766            );
767        }
768    }
769
770    #[test]
771    fn test_apply_mod_timestamp_invalid_timestamp_errors() {
772        let dir = tempfile::tempdir().unwrap();
773
774        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
775        let result = apply_mod_timestamp(dir.path(), "not-valid", &log);
776        assert!(result.is_err());
777    }
778
779    // -----------------------------------------------------------------------
780    // free_cargo_build_intermediates tests
781    // -----------------------------------------------------------------------
782
783    /// Build a `target/<triple>/release/` profile dir under `root` so the
784    /// helper's profile-dir guard (basename must be `release`/`debug`) is
785    /// satisfied, mirroring cargo's real layout.
786    fn mk_release_dir(root: &Path) -> std::path::PathBuf {
787        let profile = root
788            .join("target")
789            .join("x86_64-unknown-linux-gnu")
790            .join("release");
791        std::fs::create_dir_all(&profile).unwrap();
792        profile
793    }
794
795    #[test]
796    fn test_free_cargo_build_intermediates_removes_transient_keeps_binary() {
797        let tmp = tempfile::tempdir().unwrap();
798        let profile = mk_release_dir(tmp.path());
799
800        // Scaffold the four transient subdirs (each with a file inside so the
801        // remove is non-trivial), the final binary, and a sibling regular file
802        // directly under the profile dir.
803        for sub in ["deps", "build", "incremental", ".fingerprint"] {
804            let d = profile.join(sub);
805            std::fs::create_dir_all(&d).unwrap();
806            std::fs::write(d.join("scratch.o"), "obj").unwrap();
807        }
808        std::fs::write(profile.join("myapp"), b"\x7fELF binary").unwrap();
809        std::fs::write(profile.join("myapp.d"), "depinfo").unwrap();
810
811        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
812        let mut freed = free_cargo_build_intermediates(&profile, &log);
813        freed.sort_unstable();
814        assert_eq!(freed, vec![".fingerprint", "build", "deps", "incremental"]);
815
816        for sub in ["deps", "build", "incremental", ".fingerprint"] {
817            assert!(
818                !profile.join(sub).exists(),
819                "transient subdir {sub} should be removed"
820            );
821        }
822        assert!(profile.join("myapp").exists(), "binary must be retained");
823        assert_eq!(
824            std::fs::read(profile.join("myapp")).unwrap(),
825            b"\x7fELF binary"
826        );
827        assert!(
828            profile.join("myapp.d").exists(),
829            "sibling regular file must be retained"
830        );
831    }
832
833    #[test]
834    fn test_free_cargo_build_intermediates_missing_dirs_is_noop() {
835        let tmp = tempfile::tempdir().unwrap();
836        let profile = mk_release_dir(tmp.path());
837        // Only a binary present — no transient subdirs at all.
838        std::fs::write(profile.join("myapp"), "bin").unwrap();
839
840        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
841        let freed = free_cargo_build_intermediates(&profile, &log);
842        assert!(freed.is_empty(), "nothing to free when no subdirs exist");
843        assert!(profile.join("myapp").exists());
844    }
845
846    #[test]
847    fn test_free_cargo_build_intermediates_partial_subset() {
848        let tmp = tempfile::tempdir().unwrap();
849        let profile = mk_release_dir(tmp.path());
850        // Only `deps/` and `incremental/` present — the helper frees exactly
851        // those and leaves the absent ones as no-ops.
852        std::fs::create_dir_all(profile.join("deps")).unwrap();
853        std::fs::create_dir_all(profile.join("incremental")).unwrap();
854        std::fs::write(profile.join("myapp"), "bin").unwrap();
855
856        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
857        let mut freed = free_cargo_build_intermediates(&profile, &log);
858        freed.sort_unstable();
859        assert_eq!(freed, vec!["deps", "incremental"]);
860        assert!(profile.join("myapp").exists());
861    }
862
863    /// Guard: handed a NON-profile dir (basename not `release`/`debug`), the
864    /// helper is a hard no-op even if scratch-named subdirs are present, so a
865    /// future miswire can't delete a real `build`/`deps` tree elsewhere.
866    #[test]
867    fn test_free_cargo_build_intermediates_non_profile_dir_is_noop() {
868        let tmp = tempfile::tempdir().unwrap();
869        // A workspace-root-shaped dir whose basename is `target`, not a profile.
870        let not_profile = tmp.path().join("target");
871        std::fs::create_dir_all(not_profile.join("deps")).unwrap();
872        std::fs::create_dir_all(not_profile.join("build")).unwrap();
873
874        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
875        let freed = free_cargo_build_intermediates(&not_profile, &log);
876        assert!(
877            freed.is_empty(),
878            "non-profile dir must free nothing (guard)"
879        );
880        assert!(
881            not_profile.join("deps").exists() && not_profile.join("build").exists(),
882            "guard must leave a non-profile dir's contents untouched"
883        );
884    }
885}