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}