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 all regular files in a directory.
184///
185/// Parses the timestamp via `parse_mod_timestamp`, then sets the mtime on
186/// every regular file in `dir`.
187pub fn apply_mod_timestamp(dir: &Path, raw: &str, log: &crate::log::StageLogger) -> Result<()> {
188    let mtime = parse_mod_timestamp(raw)?;
189
190    for entry in fs::read_dir(dir).with_context(|| format!("read staging dir {}", dir.display()))? {
191        let entry = entry?;
192        let ft = entry.file_type()?;
193        if ft.is_file() {
194            set_file_mtime(&entry.path(), mtime)?;
195        }
196    }
197
198    log.status(&format!("applied mod_timestamp={raw} to staging files"));
199    Ok(())
200}
201
202/// Set the modification time on a single file.
203pub fn set_file_mtime(path: &Path, mtime: SystemTime) -> Result<()> {
204    let file = std::fs::OpenOptions::new()
205        .write(true)
206        .open(path)
207        .with_context(|| format!("open {} for mtime update", path.display()))?;
208    file.set_times(
209        std::fs::FileTimes::new()
210            .set_accessed(mtime)
211            .set_modified(mtime),
212    )
213    .with_context(|| format!("set mtime on {}", path.display()))?;
214    Ok(())
215}
216
217/// Set the modification time on a single file from a Unix epoch (seconds).
218///
219/// Thin wrapper over `set_file_mtime` that accepts `SOURCE_DATE_EPOCH`-style
220/// `i64` seconds (signed to permit pre-1970 values per the spec).
221pub fn set_file_mtime_epoch(path: &Path, epoch_secs: i64) -> Result<()> {
222    let mtime = if epoch_secs >= 0 {
223        SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs as u64)
224    } else {
225        SystemTime::UNIX_EPOCH - Duration::from_secs((-epoch_secs) as u64)
226    };
227    set_file_mtime(path, mtime)
228}
229
230/// Recursively copy the directory tree rooted at `src` into `dst`, recreating
231/// subdirectories, copying regular files (with [`fs::copy`], which preserves
232/// the Unix mode bits — including the executable bit), and recreating symlinks
233/// as symlinks rather than dereferencing them.
234///
235/// Preserving symlinks matters for macOS app bundles, which embed framework
236/// version symlinks (`Versions/Current -> A`); a dereferencing copy would
237/// flatten them and bloat the bundle. `dst` (and any missing parents) is
238/// created if absent. On non-Unix hosts, where creating a symlink needs
239/// elevated rights, the link target's contents are copied instead so the tree
240/// stays complete.
241pub fn copy_dir_tree(src: &Path, dst: &Path) -> Result<()> {
242    fs::create_dir_all(dst).with_context(|| format!("create dir {}", dst.display()))?;
243    for entry in fs::read_dir(src).with_context(|| format!("read dir {}", src.display()))? {
244        let entry = entry.with_context(|| format!("read entry under {}", src.display()))?;
245        let from = entry.path();
246        let to = dst.join(entry.file_name());
247        // symlink_metadata (via DirEntry::file_type) so a symlink is recreated
248        // as a link rather than dereferenced.
249        let file_type = entry
250            .file_type()
251            .with_context(|| format!("stat {}", from.display()))?;
252        if file_type.is_symlink() {
253            #[cfg(unix)]
254            {
255                let target = fs::read_link(&from)
256                    .with_context(|| format!("read symlink {}", from.display()))?;
257                std::os::unix::fs::symlink(&target, &to).with_context(|| {
258                    format!("recreate symlink {} -> {}", to.display(), target.display())
259                })?;
260            }
261            #[cfg(not(unix))]
262            {
263                if from.is_dir() {
264                    copy_dir_tree(&from, &to)?;
265                } else {
266                    fs::copy(&from, &to)
267                        .with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
268                }
269            }
270        } else if file_type.is_dir() {
271            copy_dir_tree(&from, &to)?;
272        } else {
273            fs::copy(&from, &to)
274                .with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
275        }
276    }
277    Ok(())
278}
279
280// ---------------------------------------------------------------------------
281// collect_replace_archives
282// ---------------------------------------------------------------------------
283
284/// Collect archive artifact paths for a given crate + target, for removal by `replace` options.
285pub fn collect_replace_archives(
286    artifacts: &crate::artifact::ArtifactRegistry,
287    crate_name: &str,
288    target: Option<&str>,
289) -> Vec<std::path::PathBuf> {
290    artifacts
291        .by_kind_and_crate(crate::artifact::ArtifactKind::Archive, crate_name)
292        .iter()
293        .filter(|a| a.target.as_deref() == target)
294        .map(|a| a.path.clone())
295        .collect()
296}
297
298/// Gated variant of [`collect_replace_archives`]: returns the matching
299/// archive paths only when `replace` is `Some(true)`. Used by packaging
300/// stages (dmg, msi, flatpak, snapcraft, nsis, pkg, appbundle) to
301/// replace a source archive with the packaged output when the user
302/// opts in via `replace: true` on the config. Returns an empty vec
303/// when `replace` is unset or `false`.
304pub fn collect_if_replace(
305    replace: Option<bool>,
306    artifacts: &crate::artifact::ArtifactRegistry,
307    crate_name: &str,
308    target: Option<&str>,
309) -> Vec<std::path::PathBuf> {
310    if replace.unwrap_or(false) {
311        collect_replace_archives(artifacts, crate_name, target)
312    } else {
313        Vec::new()
314    }
315}
316
317/// Convert any Windows-style backslash separators in `s` to forward
318/// slashes. Cross-platform path string normalization for cases where the
319/// downstream consumer (artifact-manifest JSON, MSYS subprocess env var)
320/// is sensitive to separator drift between Linux/macOS and Windows hosts.
321pub fn normalize_path_separators(s: &str) -> String {
322    s.replace('\\', "/")
323}
324
325/// Apply a "minimal trusted" environment to a `Command` after `env_clear()`.
326///
327/// Stage subprocess invocations (sbom, source-archive, …) clear the env to
328/// stop accidental token leakage but still need a small set of platform-
329/// neutral keys so that `git`, `tar`, `syft`, etc. behave normally — HOME
330/// for tool config, USER for git author fallback, USERPROFILE/LOCALAPPDATA
331/// for the Windows equivalents, TMPDIR/TMP/TEMP so temp-file allocation
332/// doesn't land in a forbidden directory, and PATH so the tool itself can
333/// find its dependencies. Keeping this list in core means any new entry
334/// (e.g. SSL_CERT_DIR for syft pulling enrich data) is added once.
335pub fn apply_minimal_env(command: &mut std::process::Command) {
336    const PASSTHROUGH: &[&str] = &[
337        "HOME",
338        "USER",
339        "USERPROFILE",
340        "TMPDIR",
341        "TMP",
342        "TEMP",
343        "PATH",
344        "LOCALAPPDATA",
345    ];
346    for key in PASSTHROUGH {
347        if let Ok(val) = std::env::var(key) {
348            command.env(key, val);
349        }
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    // -----------------------------------------------------------------------
358    // topological_sort tests
359    // -----------------------------------------------------------------------
360
361    #[test]
362    fn test_topo_sort_simple_chain() {
363        let items = vec![
364            ("c".to_string(), vec!["b".to_string()]),
365            ("b".to_string(), vec!["a".to_string()]),
366            ("a".to_string(), vec![]),
367        ];
368        let sorted = topological_sort(&items);
369        assert_eq!(sorted, vec!["a", "b", "c"]);
370    }
371
372    #[test]
373    fn test_topo_sort_no_deps() {
374        let items = vec![("b".to_string(), vec![]), ("a".to_string(), vec![])];
375        // Deterministic: alphabetical
376        let sorted = topological_sort(&items);
377        assert_eq!(sorted, vec!["a", "b"]);
378    }
379
380    #[test]
381    fn test_topo_sort_ignores_external_deps() {
382        let items = vec![
383            (
384                "b".to_string(),
385                vec!["a".to_string(), "external".to_string()],
386            ),
387            ("a".to_string(), vec![]),
388        ];
389        let sorted = topological_sort(&items);
390        assert_eq!(sorted, vec!["a", "b"]);
391    }
392
393    #[test]
394    fn test_topo_sort_diamond() {
395        let items = vec![
396            ("d".to_string(), vec!["b".to_string(), "c".to_string()]),
397            ("b".to_string(), vec!["a".to_string()]),
398            ("c".to_string(), vec!["a".to_string()]),
399            ("a".to_string(), vec![]),
400        ];
401        let sorted = topological_sort(&items);
402        // a must come first, d must come last, b and c in between
403        assert_eq!(sorted[0], "a");
404        assert_eq!(sorted[3], "d");
405    }
406
407    #[test]
408    fn test_topo_sort_cycle_appends_remaining() {
409        let items = vec![
410            ("a".to_string(), vec!["b".to_string()]),
411            ("b".to_string(), vec!["a".to_string()]),
412            ("c".to_string(), vec![]),
413        ];
414        let sorted = topological_sort(&items);
415        assert_eq!(sorted.len(), 3);
416        // c has no deps, should come first; a and b are in a cycle
417        assert_eq!(sorted[0], "c");
418    }
419
420    #[test]
421    fn test_topo_sort_empty() {
422        let items: Vec<(String, Vec<String>)> = vec![];
423        let sorted = topological_sort(&items);
424        assert!(sorted.is_empty());
425    }
426
427    // -----------------------------------------------------------------------
428    // find_binary tests
429    // -----------------------------------------------------------------------
430
431    #[test]
432    fn test_find_binary_absolute_path_exists() {
433        if cfg!(windows) {
434            // cmd.exe exists on all Windows systems
435            assert!(find_binary("C:\\Windows\\System32\\cmd.exe"));
436        } else {
437            // /usr/bin/env exists on virtually all Unix systems
438            assert!(find_binary("/usr/bin/env"));
439        }
440    }
441
442    #[test]
443    fn test_find_binary_absolute_path_does_not_exist() {
444        if cfg!(windows) {
445            assert!(!find_binary("C:\\nonexistent\\binary\\path.exe"));
446        } else {
447            assert!(!find_binary("/nonexistent/binary/path"));
448        }
449    }
450
451    #[test]
452    fn test_find_binary_bare_name_on_path() {
453        if cfg!(windows) {
454            // "cmd.exe" should be findable on PATH on any Windows system
455            // (find_binary does exact name match, no implicit .exe appending)
456            assert!(find_binary("cmd.exe"));
457        } else {
458            // "env" should be findable on PATH on any Unix system
459            assert!(find_binary("env"));
460        }
461    }
462
463    #[test]
464    fn test_find_binary_bare_name_not_on_path() {
465        assert!(!find_binary("nonexistent-binary-xyz-12345"));
466    }
467
468    // -----------------------------------------------------------------------
469    // parse_mod_timestamp tests
470    // -----------------------------------------------------------------------
471
472    #[test]
473    fn test_parse_mod_timestamp_epoch_integer() {
474        let t = parse_mod_timestamp("1704067200").unwrap();
475        let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
476        assert_eq!(epoch, 1704067200);
477    }
478
479    #[test]
480    fn test_parse_mod_timestamp_rfc3339() {
481        let t = parse_mod_timestamp("2024-01-01T00:00:00Z").unwrap();
482        let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
483        assert_eq!(epoch, 1704067200);
484    }
485
486    #[test]
487    fn test_parse_mod_timestamp_rfc3339_with_offset() {
488        let t = parse_mod_timestamp("2024-01-01T01:00:00+01:00").unwrap();
489        let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
490        // 2024-01-01T01:00:00+01:00 is the same instant as 2024-01-01T00:00:00Z
491        assert_eq!(epoch, 1704067200);
492    }
493
494    #[test]
495    fn test_parse_mod_timestamp_naive_datetime() {
496        let t = parse_mod_timestamp("2024-01-01T00:00:00").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_invalid() {
503        let err = parse_mod_timestamp("not-a-timestamp").unwrap_err();
504        let msg = err.to_string();
505        assert!(
506            msg.contains("not a valid timestamp"),
507            "unexpected error: {msg}"
508        );
509        // The parse error must include
510        // the offending mtime value so misconfigurations are diagnosable.
511        assert!(
512            msg.contains("not-a-timestamp"),
513            "error must include the bad value, got: {msg}"
514        );
515    }
516
517    #[test]
518    fn test_parse_mod_timestamp_zero() {
519        let t = parse_mod_timestamp("0").unwrap();
520        assert_eq!(t, SystemTime::UNIX_EPOCH);
521    }
522
523    // -----------------------------------------------------------------------
524    // set_file_mtime tests
525    // -----------------------------------------------------------------------
526
527    #[test]
528    fn test_set_file_mtime_sets_both_atime_and_mtime() {
529        let dir = std::env::temp_dir().join("anodizer_test_set_file_mtime");
530        let _ = std::fs::remove_dir_all(&dir);
531        std::fs::create_dir_all(&dir).unwrap();
532
533        let file_path = dir.join("test.txt");
534        std::fs::write(&file_path, "hello").unwrap();
535
536        // Set mtime to a known epoch: 2024-01-01T00:00:00Z = 1704067200
537        let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
538        set_file_mtime(&file_path, target).unwrap();
539
540        let meta = std::fs::metadata(&file_path).unwrap();
541        let actual_mtime = meta.modified().unwrap();
542
543        // Allow 1-second tolerance for filesystem granularity
544        let diff = if actual_mtime > target {
545            actual_mtime.duration_since(target).unwrap()
546        } else {
547            target.duration_since(actual_mtime).unwrap()
548        };
549        assert!(
550            diff.as_secs() <= 1,
551            "mtime should be within 1s of target, diff={:?}",
552            diff
553        );
554
555        // Also verify atime was set (on Linux, accessed() is available)
556        let actual_atime = meta.accessed().unwrap();
557        let diff_a = if actual_atime > target {
558            actual_atime.duration_since(target).unwrap()
559        } else {
560            target.duration_since(actual_atime).unwrap()
561        };
562        assert!(
563            diff_a.as_secs() <= 1,
564            "atime should be within 1s of target, diff={:?}",
565            diff_a
566        );
567
568        let _ = std::fs::remove_dir_all(&dir);
569    }
570
571    #[test]
572    fn test_set_file_mtime_nonexistent_file() {
573        let result = set_file_mtime(Path::new("/nonexistent/file.txt"), SystemTime::UNIX_EPOCH);
574        assert!(result.is_err());
575    }
576
577    // -----------------------------------------------------------------------
578    // apply_mod_timestamp tests
579    // -----------------------------------------------------------------------
580
581    #[test]
582    fn test_apply_mod_timestamp_sets_mtime_on_regular_files() {
583        let dir = std::env::temp_dir().join("anodizer_test_apply_mod_timestamp");
584        let _ = std::fs::remove_dir_all(&dir);
585        std::fs::create_dir_all(&dir).unwrap();
586
587        // Create two regular files and a subdirectory (should be skipped)
588        std::fs::write(dir.join("a.txt"), "aaa").unwrap();
589        std::fs::write(dir.join("b.txt"), "bbb").unwrap();
590        std::fs::create_dir(dir.join("subdir")).unwrap();
591
592        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
593        apply_mod_timestamp(&dir, "1704067200", &log).unwrap();
594
595        let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
596        for name in &["a.txt", "b.txt"] {
597            let meta = std::fs::metadata(dir.join(name)).unwrap();
598            let mtime = meta.modified().unwrap();
599            let diff = if mtime > target {
600                mtime.duration_since(target).unwrap()
601            } else {
602                target.duration_since(mtime).unwrap()
603            };
604            assert!(
605                diff.as_secs() <= 1,
606                "{name}: mtime should be within 1s of target, diff={:?}",
607                diff
608            );
609        }
610
611        let _ = std::fs::remove_dir_all(&dir);
612    }
613
614    #[test]
615    fn test_apply_mod_timestamp_invalid_timestamp_errors() {
616        let dir = std::env::temp_dir().join("anodizer_test_apply_mod_timestamp_invalid");
617        let _ = std::fs::remove_dir_all(&dir);
618        std::fs::create_dir_all(&dir).unwrap();
619
620        let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
621        let result = apply_mod_timestamp(&dir, "not-valid", &log);
622        assert!(result.is_err());
623
624        let _ = std::fs::remove_dir_all(&dir);
625    }
626}