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