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/// Maximum names remembered per directory. Large stores must not make a
116/// single path lookup proportional to their entire listing. Unlisted names
117/// fall back to `canonicalize`, preserving its case and symlink handling.
118#[cfg(unix)]
119const MAX_DIRECTORY_ENTRIES: usize = 256;
120
121/// A directory as `realpath` would name it, and some names it holds, so a
122/// child can be checked against the listing rather than resolved again.
123#[cfg(unix)]
124#[derive(Debug)]
125struct ResolvedDirectory {
126    path: PathBuf,
127    entries: std::collections::HashSet<std::ffi::OsString>,
128}
129
130/// [`normalize_resolved_mapped_path`], resolving directory aliases through
131/// `aliases` rather than from scratch.
132pub fn normalize_resolved_mapped_path_with(
133    aliases: &PathAliases,
134    path: &Path,
135    working_dir: &Path,
136    mappings: &[PathMapping],
137) -> Result<String, PathNormalizationError> {
138    let absolute = if path.is_absolute() {
139        normalize_components(path)
140    } else {
141        normalize_components(&working_dir.join(path))
142    };
143    let resolved = if absolute.is_absolute() {
144        resolve_path_aliases_with(aliases, &absolute)
145    } else {
146        absolute.clone()
147    };
148    for mapping in mappings {
149        if let Ok(relative) = resolved.strip_prefix(&mapping.root) {
150            let suffix = slash_path(relative)?;
151            return Ok(if suffix.is_empty() {
152                format!("${{{}}}", mapping.placeholder)
153            } else {
154                format!("${{{}}}/{suffix}", mapping.placeholder)
155            });
156        }
157    }
158    Err(PathNormalizationError::UnmappedAbsolutePath(absolute))
159}
160
161#[cfg(any(unix, windows))]
162fn resolve_path_aliases(path: &Path) -> PathBuf {
163    resolve_path_aliases_with(&PathAliases::default(), path)
164}
165
166#[cfg(any(unix, windows))]
167fn resolve_path_aliases_with(aliases: &PathAliases, path: &Path) -> PathBuf {
168    let mut existing = path;
169    let mut missing = Vec::new();
170    loop {
171        match aliases.canonicalize(existing) {
172            Ok(mut resolved) => {
173                for component in missing.iter().rev() {
174                    resolved.push(component);
175                }
176                return normalize_components(&resolved);
177            }
178            Err(_) => {
179                let Some(name) = existing.file_name() else {
180                    return path.to_path_buf();
181                };
182                missing.push(name.to_os_string());
183                let Some(parent) = existing.parent() else {
184                    return path.to_path_buf();
185                };
186                existing = parent;
187            }
188        }
189    }
190}
191
192#[cfg(not(any(unix, windows)))]
193fn resolve_path_aliases(path: &Path) -> PathBuf {
194    path.to_path_buf()
195}
196
197#[cfg(not(any(unix, windows)))]
198fn resolve_path_aliases_with(_aliases: &PathAliases, path: &Path) -> PathBuf {
199    path.to_path_buf()
200}
201
202impl PathAliases {
203    /// `std::fs::canonicalize`, through the directories already resolved.
204    #[cfg(unix)]
205    fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
206        let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
207            return std::fs::canonicalize(path);
208        };
209        if parent.as_os_str().is_empty() {
210            return std::fs::canonicalize(path);
211        }
212        // Release the lookup before inserting a newly resolved parent.
213        let known = self.directories.borrow().get(parent).cloned();
214        let resolved_parent = match known {
215            Some(resolved) => resolved,
216            None => {
217                // Resolve ancestors with realpath, which looks up their names
218                // without enumerating them. Recursing through this memo would
219                // list /nix/store just to reach a header inside one package.
220                let resolved = std::fs::canonicalize(parent)?;
221                // A listing that cannot be read is an empty one: every child
222                // then takes the full call, which is where it started.
223                let entries = std::fs::read_dir(&resolved)
224                    .map(|listing| {
225                        listing
226                            .take(MAX_DIRECTORY_ENTRIES)
227                            .filter_map(Result::ok)
228                            .map(|entry| entry.file_name())
229                            .collect()
230                    })
231                    .unwrap_or_default();
232                let resolved = std::rc::Rc::new(ResolvedDirectory {
233                    path: resolved,
234                    entries,
235                });
236                self.directories
237                    .borrow_mut()
238                    .insert(parent.to_path_buf(), resolved.clone());
239                resolved
240            }
241        };
242        let candidate = resolved_parent.path.join(name);
243        // The name has to be in the listing as written: on a volume that
244        // folds case, `realpath` would answer with the name on disk, and a
245        // spelling the listing does not contain takes the full call so that
246        // it still does. So does a link, anything beyond the listing budget,
247        // and anything the listing predates.
248        if resolved_parent.entries.contains(name)
249            && !std::fs::symlink_metadata(&candidate)?
250                .file_type()
251                .is_symlink()
252        {
253            Ok(candidate)
254        } else {
255            std::fs::canonicalize(&candidate)
256        }
257    }
258
259    /// Windows keeps the plain call: its canonical paths are verbatim, and a
260    /// junction is not what `is_symlink` reports.
261    #[cfg(windows)]
262    fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
263        std::fs::canonicalize(path)
264    }
265}
266
267fn resolve_mapping_root(root: &Path) -> PathBuf {
268    let root = normalize_components(root);
269    if root.is_absolute() {
270        resolve_path_aliases(&root)
271    } else {
272        root
273    }
274}
275
276fn normalize_components(path: &Path) -> PathBuf {
277    let mut normalized = PathBuf::new();
278    for component in path.components() {
279        match component {
280            Component::CurDir => {}
281            Component::ParentDir => {
282                normalized.pop();
283            }
284            component => normalized.push(component.as_os_str()),
285        }
286    }
287    normalized
288}
289
290fn slash_path(path: &Path) -> Result<String, PathNormalizationError> {
291    path.components()
292        .filter_map(|component| match component {
293            Component::Normal(value) => Some(
294                value
295                    .to_str()
296                    .map(ToOwned::to_owned)
297                    .ok_or_else(|| PathNormalizationError::NonUtf8Path(path.to_path_buf())),
298            ),
299            _ => None,
300        })
301        .collect::<Result<Vec<_>, _>>()
302        .map(|components| components.join("/"))
303}
304
305#[cfg(all(test, windows))]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn maps_verbatim_windows_paths_to_non_verbatim_roots() {
311        let directory = tempfile::tempdir().unwrap();
312        let input = directory.path().join("clippy.toml");
313        std::fs::write(&input, "disallowed-methods = []").unwrap();
314        let verbatim = std::fs::canonicalize(&input).unwrap();
315        assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
316
317        let normalized = normalize_mapped_path(
318            &verbatim,
319            directory.path(),
320            &[PathMapping::new(directory.path(), "workspace")],
321        )
322        .unwrap();
323
324        assert_eq!(normalized, "${workspace}/clippy.toml");
325    }
326}
327
328#[cfg(all(test, unix))]
329mod unix_tests {
330    use super::*;
331
332    #[test]
333    fn resolves_aliases_through_remembered_parents() {
334        let directory = tempfile::tempdir().unwrap();
335        let aliases = PathAliases::default();
336        let root = std::fs::canonicalize(directory.path()).unwrap();
337        let physical = root.join("physical");
338        std::fs::create_dir_all(physical.join("src")).unwrap();
339        std::fs::write(physical.join("src").join("lib.rs"), "").unwrap();
340        std::fs::write(physical.join("real.rs"), "").unwrap();
341        std::os::unix::fs::symlink(&physical, root.join("alias")).unwrap();
342        std::os::unix::fs::symlink(physical.join("real.rs"), physical.join("link.rs")).unwrap();
343        let mappings = [PathMapping::new(&physical, "workspace")];
344
345        // A directory alias, then a sibling that reuses its resolved parent.
346        let through_alias = root.join("alias").join("src").join("lib.rs");
347        assert_eq!(
348            normalize_resolved_mapped_path_with(&aliases, &through_alias, &root, &mappings)
349                .unwrap(),
350            "${workspace}/src/lib.rs"
351        );
352        let sibling = root.join("alias").join("src").join("missing.rs");
353        assert_eq!(
354            normalize_resolved_mapped_path_with(&aliases, &sibling, &root, &mappings).unwrap(),
355            "${workspace}/src/missing.rs"
356        );
357        // A file that is itself a link resolves to what it names.
358        assert_eq!(
359            normalize_resolved_mapped_path_with(
360                &aliases,
361                &physical.join("link.rs"),
362                &root,
363                &mappings
364            )
365            .unwrap(),
366            "${workspace}/real.rs"
367        );
368        // And a suffix that does not exist yet is kept as written.
369        let unborn = root
370            .join("alias")
371            .join("target")
372            .join("deps")
373            .join("out.rlib");
374        assert_eq!(
375            normalize_resolved_mapped_path_with(&aliases, &unborn, &root, &mappings).unwrap(),
376            "${workspace}/target/deps/out.rlib"
377        );
378        // A file created after its directory was listed is resolved all the
379        // same, by the full call the listing cannot vouch for.
380        std::fs::write(physical.join("src").join("late.rs"), "").unwrap();
381        assert_eq!(
382            normalize_resolved_mapped_path_with(
383                &aliases,
384                &root.join("alias").join("src").join("late.rs"),
385                &root,
386                &mappings
387            )
388            .unwrap(),
389            "${workspace}/src/late.rs"
390        );
391    }
392
393    #[test]
394    fn large_directory_listings_are_bounded_without_losing_paths() {
395        let directory = tempfile::tempdir().unwrap();
396        let root = std::fs::canonicalize(directory.path()).unwrap();
397        let store = root.join("store");
398        std::fs::create_dir(&store).unwrap();
399        for index in 0..MAX_DIRECTORY_ENTRIES * 2 {
400            std::fs::write(store.join(format!("header-{index}.h")), "").unwrap();
401        }
402        std::os::unix::fs::symlink(store.join("header-0.h"), store.join("link.h")).unwrap();
403        let aliases = PathAliases::default();
404        let mappings = [PathMapping::new(&store, "store")];
405        for index in 0..MAX_DIRECTORY_ENTRIES * 2 {
406            let name = format!("header-{index}.h");
407            assert_eq!(
408                normalize_resolved_mapped_path_with(&aliases, &store.join(&name), &root, &mappings)
409                    .unwrap(),
410                format!("${{store}}/{name}")
411            );
412        }
413        // Only the immediate parent is listed, never its ancestors.
414        assert_eq!(aliases.directories.borrow().len(), 1);
415        assert_eq!(
416            aliases.directories.borrow()[&store].entries.len(),
417            MAX_DIRECTORY_ENTRIES
418        );
419        assert_eq!(
420            normalize_resolved_mapped_path_with(&aliases, &store.join("link.h"), &root, &mappings)
421                .unwrap(),
422            "${store}/header-0.h"
423        );
424        assert_eq!(
425            normalize_resolved_mapped_path_with(
426                &aliases,
427                &store.join("missing/subdir/header.h"),
428                &root,
429                &mappings
430            )
431            .unwrap(),
432            "${store}/missing/subdir/header.h"
433        );
434    }
435
436    #[test]
437    fn a_retargeted_alias_is_seen_by_the_next_caller() {
438        let directory = tempfile::tempdir().unwrap();
439        let root = std::fs::canonicalize(directory.path()).unwrap();
440        for name in ["one", "two"] {
441            std::fs::create_dir_all(root.join(name).join("src")).unwrap();
442            std::fs::write(root.join(name).join("src").join("lib.rs"), "").unwrap();
443        }
444        let alias = root.join("alias");
445        std::os::unix::fs::symlink(root.join("one"), &alias).unwrap();
446        let mappings = [
447            PathMapping::new(root.join("one"), "one"),
448            PathMapping::new(root.join("two"), "two"),
449        ];
450        let input = alias.join("src").join("lib.rs");
451
452        let first = PathAliases::default();
453        assert_eq!(
454            normalize_resolved_mapped_path_with(&first, &input, &root, &mappings).unwrap(),
455            "${one}/src/lib.rs"
456        );
457        std::fs::remove_file(&alias).unwrap();
458        std::os::unix::fs::symlink(root.join("two"), &alias).unwrap();
459        // The memo that resolved it is the caller's, and a new caller starts
460        // from the filesystem.
461        assert_eq!(
462            normalize_resolved_mapped_path_with(&PathAliases::default(), &input, &root, &mappings)
463                .unwrap(),
464            "${two}/src/lib.rs"
465        );
466    }
467}