Skip to main content

grex_core/pack/validate/
dup_symlink.rs

1//! Duplicate-`dst` detector for a single pack's `symlink` actions.
2//!
3//! Per pack-spec patch 3, two or more `symlink` actions within the same
4//! pack whose `dst` literals collide are a **plan-phase error**. The
5//! check is pre-expansion: we compare the authored string verbatim, not
6//! the post-variable-expansion filesystem path. Any environment-variable
7//! mismatch (e.g. `$HOME/a` vs `/home/user/a`) is a separate slice.
8//!
9//! # Emission policy (all-pairs)
10//!
11//! For `n` symlinks sharing the same `dst`, the validator emits
12//! `n * (n - 1) / 2` errors — one per unordered pair. This surfaces the
13//! full conflict graph to the CLI so an author renaming one entry can see
14//! exactly which others it was colliding against, rather than replaying
15//! after each fix. Indices are the flattened action-walk positions
16//! produced by [`PackManifest::iter_all_symlinks`].
17//!
18//! # Platform-aware collision key
19//!
20//! The bucket key is platform-folded before comparison:
21//!
22//! * **Windows** and **macOS** — ASCII-lowercased so `FOO` and `foo`
23//!   collide (NTFS is case-insensitive by default; APFS and HFS+ on
24//!   macOS default to case-insensitive too).
25//! * **Other Unix** — byte-exact, matching typical case-sensitive
26//!   filesystems.
27//!
28//! Full Unicode case-folding is not applied; the overhead is not
29//! justified for the rare pack `dst` that relies on non-ASCII casing.
30//! APFS can be reformatted case-sensitive; this validator stays
31//! pessimistic in that rare configuration (it may flag a non-collision
32//! the filesystem would actually accept). Probing filesystem
33//! case-sensitivity is a future enhancement. The error message carries
34//! the **original** authored `dst`, not the folded form.
35
36use std::collections::BTreeMap;
37
38use super::{PackValidationError, Validator};
39use crate::pack::PackManifest;
40
41/// Flags duplicate literal `dst` strings across all symlink actions in a
42/// pack (including those nested inside `when` blocks).
43///
44/// See the module docs for the all-pairs emission rationale.
45pub struct DuplicateSymlinkValidator;
46
47impl Validator for DuplicateSymlinkValidator {
48    fn name(&self) -> &'static str {
49        "duplicate_symlink_dst"
50    }
51
52    fn check(&self, pack: &PackManifest) -> Vec<PackValidationError> {
53        // Bucket (canonicalised_key, first_original_dst, indices) by the
54        // platform-folded key. BTreeMap keeps emission order deterministic
55        // on the folded key, which matters for snapshot tests and
56        // reproducible CLI output. The stored original dst comes from the
57        // first symlink in walk order so error messages echo what the
58        // author actually wrote.
59        let mut by_dst: BTreeMap<String, (&str, Vec<usize>)> = BTreeMap::new();
60        for (idx, sym) in pack.iter_all_symlinks() {
61            let key = canonical_dst(sym.dst.as_str());
62            by_dst.entry(key).or_insert_with(|| (sym.dst.as_str(), Vec::new())).1.push(idx);
63        }
64
65        let mut errs = Vec::new();
66        for (_key, (original_dst, indices)) in by_dst {
67            if indices.len() < 2 {
68                continue;
69            }
70            // All unordered pairs (i, j) with i < j — indices are already
71            // in walk order so the pair is naturally ordered.
72            for i in 0..indices.len() {
73                for j in (i + 1)..indices.len() {
74                    errs.push(PackValidationError::DuplicateSymlinkDst {
75                        dst: original_dst.to_string(),
76                        first: indices[i],
77                        second: indices[j],
78                    });
79                }
80            }
81        }
82        errs
83    }
84}
85
86/// Canonicalise a `dst` literal for collision bucketing.
87///
88/// Case-folds on Windows and macOS (whose default filesystems are
89/// case-insensitive), and passes bytes through unchanged elsewhere. See
90/// the module docs for the full rationale and caveats.
91fn canonical_dst(dst: &str) -> String {
92    #[cfg(any(windows, target_os = "macos"))]
93    {
94        dst.to_ascii_lowercase()
95    }
96    #[cfg(not(any(windows, target_os = "macos")))]
97    {
98        dst.to_string()
99    }
100}