Skip to main content

mbx_cache_core/
path_mapping.rs

1use std::fmt;
2use std::path::{Component, Path, PathBuf};
3
4/// Mapping from a host-specific absolute root to a stable key placeholder.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct PathMapping {
7    /// Absolute host path to replace.
8    pub root: PathBuf,
9    /// Placeholder name without the surrounding `${...}` syntax.
10    pub placeholder: String,
11}
12
13impl PathMapping {
14    /// Map an absolute host path to a stable cache-key placeholder.
15    pub fn new(root: impl Into<PathBuf>, placeholder: impl Into<String>) -> Self {
16        Self {
17            root: root.into(),
18            placeholder: placeholder.into(),
19        }
20    }
21
22    /// Order mappings deepest root first, which is what normalization needs.
23    pub fn ordered(mappings: &[PathMapping]) -> Vec<PathMapping> {
24        let mut ordered = mappings.to_vec();
25        ordered.sort_by_key(|mapping| std::cmp::Reverse(mapping.root.components().count()));
26        ordered
27    }
28}
29
30/// Why a path could not be represented with the configured mappings.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum PathNormalizationError {
33    /// An absolute path has no matching stable placeholder.
34    UnmappedAbsolutePath(PathBuf),
35    /// A path component cannot be represented in the UTF-8 cache key.
36    NonUtf8Path(PathBuf),
37}
38
39impl fmt::Display for PathNormalizationError {
40    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::UnmappedAbsolutePath(path) => {
43                write!(
44                    formatter,
45                    "absolute path has no stable cache mapping: {}",
46                    path.display()
47                )
48            }
49            Self::NonUtf8Path(path) => {
50                write!(
51                    formatter,
52                    "cache key paths must be valid UTF-8: {}",
53                    path.display()
54                )
55            }
56        }
57    }
58}
59
60impl std::error::Error for PathNormalizationError {}
61
62/// Map an absolute path to its cache-key placeholder form.
63///
64/// `mappings` must already be ordered by [`PathMapping::ordered`]. Existing
65/// path aliases are resolved while a not-yet-created suffix is preserved.
66pub fn normalize_mapped_path(
67    path: &Path,
68    working_dir: &Path,
69    mappings: &[PathMapping],
70) -> Result<String, PathNormalizationError> {
71    let mappings = resolve_path_mappings(mappings);
72    normalize_resolved_mapped_path(path, working_dir, &mappings)
73}
74
75/// Resolve filesystem aliases in mapping roots once for repeated lookups.
76///
77/// The returned mappings retain the caller's order and can be passed to
78/// [`normalize_resolved_mapped_path`].
79pub fn resolve_path_mappings(mappings: &[PathMapping]) -> Vec<PathMapping> {
80    mappings
81        .iter()
82        .map(|mapping| PathMapping {
83            root: resolve_mapping_root(&mapping.root),
84            placeholder: mapping.placeholder.clone(),
85        })
86        .collect()
87}
88
89/// Normalize a path against mapping roots already resolved by
90/// [`resolve_path_mappings`].
91pub fn normalize_resolved_mapped_path(
92    path: &Path,
93    working_dir: &Path,
94    mappings: &[PathMapping],
95) -> Result<String, PathNormalizationError> {
96    normalize_resolved_mapped_path_with(&PathAliases::default(), path, working_dir, mappings)
97}
98
99/// Directories whose aliases a caller has already resolved.
100///
101/// One compilation normalizes hundreds of input paths that share a handful of
102/// parent directories, and `realpath` walks every component of every one of
103/// them. Resolving a parent once and then only the last component of each
104/// path costs one `lstat` per path instead of one per component. The memo
105/// belongs to the caller and lives as long as the caller wants it to: one
106/// action's worth of paths, in practice, which is no longer than a link is
107/// assumed to hold between two `realpath` calls today.
108#[derive(Debug, Default)]
109pub struct PathAliases {
110    #[cfg(unix)]
111    directories:
112        std::cell::RefCell<std::collections::HashMap<PathBuf, std::rc::Rc<ResolvedDirectory>>>,
113}
114
115/// A directory as `realpath` would name it, and the names it holds, so a
116/// child can be checked against the listing rather than resolved again.
117#[cfg(unix)]
118#[derive(Debug)]
119struct ResolvedDirectory {
120    path: PathBuf,
121    entries: std::collections::HashSet<std::ffi::OsString>,
122}
123
124/// [`normalize_resolved_mapped_path`], resolving directory aliases through
125/// `aliases` rather than from scratch.
126pub fn normalize_resolved_mapped_path_with(
127    aliases: &PathAliases,
128    path: &Path,
129    working_dir: &Path,
130    mappings: &[PathMapping],
131) -> Result<String, PathNormalizationError> {
132    let absolute = if path.is_absolute() {
133        normalize_components(path)
134    } else {
135        normalize_components(&working_dir.join(path))
136    };
137    let resolved = if absolute.is_absolute() {
138        resolve_path_aliases_with(aliases, &absolute)
139    } else {
140        absolute.clone()
141    };
142    for mapping in mappings {
143        if let Ok(relative) = resolved.strip_prefix(&mapping.root) {
144            let suffix = slash_path(relative)?;
145            return Ok(if suffix.is_empty() {
146                format!("${{{}}}", mapping.placeholder)
147            } else {
148                format!("${{{}}}/{suffix}", mapping.placeholder)
149            });
150        }
151    }
152    Err(PathNormalizationError::UnmappedAbsolutePath(absolute))
153}
154
155#[cfg(any(unix, windows))]
156fn resolve_path_aliases(path: &Path) -> PathBuf {
157    resolve_path_aliases_with(&PathAliases::default(), path)
158}
159
160#[cfg(any(unix, windows))]
161fn resolve_path_aliases_with(aliases: &PathAliases, path: &Path) -> PathBuf {
162    let mut existing = path;
163    let mut missing = Vec::new();
164    loop {
165        match aliases.canonicalize(existing) {
166            Ok(mut resolved) => {
167                for component in missing.iter().rev() {
168                    resolved.push(component);
169                }
170                return normalize_components(&resolved);
171            }
172            Err(_) => {
173                let Some(name) = existing.file_name() else {
174                    return path.to_path_buf();
175                };
176                missing.push(name.to_os_string());
177                let Some(parent) = existing.parent() else {
178                    return path.to_path_buf();
179                };
180                existing = parent;
181            }
182        }
183    }
184}
185
186#[cfg(not(any(unix, windows)))]
187fn resolve_path_aliases(path: &Path) -> PathBuf {
188    path.to_path_buf()
189}
190
191#[cfg(not(any(unix, windows)))]
192fn resolve_path_aliases_with(_aliases: &PathAliases, path: &Path) -> PathBuf {
193    path.to_path_buf()
194}
195
196impl PathAliases {
197    /// `std::fs::canonicalize`, through the directories already resolved.
198    #[cfg(unix)]
199    fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
200        let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
201            return std::fs::canonicalize(path);
202        };
203        if parent.as_os_str().is_empty() {
204            return std::fs::canonicalize(path);
205        }
206        // Looked up, then released: resolving the parent below borrows the
207        // memo again, and a `Ref` that lived through the match would panic.
208        let known = self.directories.borrow().get(parent).cloned();
209        let resolved_parent = match known {
210            Some(resolved) => resolved,
211            None => {
212                let resolved = self.canonicalize(parent)?;
213                // A listing that cannot be read is an empty one: every child
214                // then takes the full call, which is where it started.
215                let entries = std::fs::read_dir(&resolved)
216                    .map(|listing| listing.flatten().map(|entry| entry.file_name()).collect())
217                    .unwrap_or_default();
218                let resolved = std::rc::Rc::new(ResolvedDirectory {
219                    path: resolved,
220                    entries,
221                });
222                self.directories
223                    .borrow_mut()
224                    .insert(parent.to_path_buf(), resolved.clone());
225                resolved
226            }
227        };
228        let candidate = resolved_parent.path.join(name);
229        // The name has to be in the listing as written: on a volume that
230        // folds case, `realpath` would answer with the name on disk, and a
231        // spelling the listing does not contain takes the full call so that
232        // it still does. So does a link, and anything the listing predates.
233        if resolved_parent.entries.contains(name)
234            && !std::fs::symlink_metadata(&candidate)?
235                .file_type()
236                .is_symlink()
237        {
238            Ok(candidate)
239        } else {
240            std::fs::canonicalize(&candidate)
241        }
242    }
243
244    /// Windows keeps the plain call: its canonical paths are verbatim, and a
245    /// junction is not what `is_symlink` reports.
246    #[cfg(windows)]
247    fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
248        std::fs::canonicalize(path)
249    }
250}
251
252fn resolve_mapping_root(root: &Path) -> PathBuf {
253    let root = normalize_components(root);
254    if root.is_absolute() {
255        resolve_path_aliases(&root)
256    } else {
257        root
258    }
259}
260
261fn normalize_components(path: &Path) -> PathBuf {
262    let mut normalized = PathBuf::new();
263    for component in path.components() {
264        match component {
265            Component::CurDir => {}
266            Component::ParentDir => {
267                normalized.pop();
268            }
269            component => normalized.push(component.as_os_str()),
270        }
271    }
272    normalized
273}
274
275fn slash_path(path: &Path) -> Result<String, PathNormalizationError> {
276    path.components()
277        .filter_map(|component| match component {
278            Component::Normal(value) => Some(
279                value
280                    .to_str()
281                    .map(ToOwned::to_owned)
282                    .ok_or_else(|| PathNormalizationError::NonUtf8Path(path.to_path_buf())),
283            ),
284            _ => None,
285        })
286        .collect::<Result<Vec<_>, _>>()
287        .map(|components| components.join("/"))
288}
289
290#[cfg(all(test, windows))]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn maps_verbatim_windows_paths_to_non_verbatim_roots() {
296        let directory = tempfile::tempdir().unwrap();
297        let input = directory.path().join("clippy.toml");
298        std::fs::write(&input, "disallowed-methods = []").unwrap();
299        let verbatim = std::fs::canonicalize(&input).unwrap();
300        assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
301
302        let normalized = normalize_mapped_path(
303            &verbatim,
304            directory.path(),
305            &[PathMapping::new(directory.path(), "workspace")],
306        )
307        .unwrap();
308
309        assert_eq!(normalized, "${workspace}/clippy.toml");
310    }
311}
312
313#[cfg(all(test, unix))]
314mod unix_tests {
315    use super::*;
316
317    #[test]
318    fn resolves_aliases_through_remembered_parents() {
319        let directory = tempfile::tempdir().unwrap();
320        let aliases = PathAliases::default();
321        let root = std::fs::canonicalize(directory.path()).unwrap();
322        let physical = root.join("physical");
323        std::fs::create_dir_all(physical.join("src")).unwrap();
324        std::fs::write(physical.join("src").join("lib.rs"), "").unwrap();
325        std::fs::write(physical.join("real.rs"), "").unwrap();
326        std::os::unix::fs::symlink(&physical, root.join("alias")).unwrap();
327        std::os::unix::fs::symlink(physical.join("real.rs"), physical.join("link.rs")).unwrap();
328        let mappings = [PathMapping::new(&physical, "workspace")];
329
330        // A directory alias, then a sibling that reuses its resolved parent.
331        let through_alias = root.join("alias").join("src").join("lib.rs");
332        assert_eq!(
333            normalize_resolved_mapped_path_with(&aliases, &through_alias, &root, &mappings)
334                .unwrap(),
335            "${workspace}/src/lib.rs"
336        );
337        let sibling = root.join("alias").join("src").join("missing.rs");
338        assert_eq!(
339            normalize_resolved_mapped_path_with(&aliases, &sibling, &root, &mappings).unwrap(),
340            "${workspace}/src/missing.rs"
341        );
342        // A file that is itself a link resolves to what it names.
343        assert_eq!(
344            normalize_resolved_mapped_path_with(
345                &aliases,
346                &physical.join("link.rs"),
347                &root,
348                &mappings
349            )
350            .unwrap(),
351            "${workspace}/real.rs"
352        );
353        // And a suffix that does not exist yet is kept as written.
354        let unborn = root
355            .join("alias")
356            .join("target")
357            .join("deps")
358            .join("out.rlib");
359        assert_eq!(
360            normalize_resolved_mapped_path_with(&aliases, &unborn, &root, &mappings).unwrap(),
361            "${workspace}/target/deps/out.rlib"
362        );
363        // A file created after its directory was listed is resolved all the
364        // same, by the full call the listing cannot vouch for.
365        std::fs::write(physical.join("src").join("late.rs"), "").unwrap();
366        assert_eq!(
367            normalize_resolved_mapped_path_with(
368                &aliases,
369                &root.join("alias").join("src").join("late.rs"),
370                &root,
371                &mappings
372            )
373            .unwrap(),
374            "${workspace}/src/late.rs"
375        );
376    }
377
378    #[test]
379    fn a_retargeted_alias_is_seen_by_the_next_caller() {
380        let directory = tempfile::tempdir().unwrap();
381        let root = std::fs::canonicalize(directory.path()).unwrap();
382        for name in ["one", "two"] {
383            std::fs::create_dir_all(root.join(name).join("src")).unwrap();
384            std::fs::write(root.join(name).join("src").join("lib.rs"), "").unwrap();
385        }
386        let alias = root.join("alias");
387        std::os::unix::fs::symlink(root.join("one"), &alias).unwrap();
388        let mappings = [
389            PathMapping::new(root.join("one"), "one"),
390            PathMapping::new(root.join("two"), "two"),
391        ];
392        let input = alias.join("src").join("lib.rs");
393
394        let first = PathAliases::default();
395        assert_eq!(
396            normalize_resolved_mapped_path_with(&first, &input, &root, &mappings).unwrap(),
397            "${one}/src/lib.rs"
398        );
399        std::fs::remove_file(&alias).unwrap();
400        std::os::unix::fs::symlink(root.join("two"), &alias).unwrap();
401        // The memo that resolved it is the caller's, and a new caller starts
402        // from the filesystem.
403        assert_eq!(
404            normalize_resolved_mapped_path_with(&PathAliases::default(), &input, &root, &mappings)
405                .unwrap(),
406            "${two}/src/lib.rs"
407        );
408    }
409}