Skip to main content

muster/adapter/
path.rs

1use std::{
2    ffi::OsStr,
3    fs,
4    path::{Component, MAIN_SEPARATOR, Path, PathBuf, is_separator},
5};
6
7use directories::BaseDirs;
8
9use crate::domain::{config::ConfigError, port::PathCompleter, project::Project};
10
11/// Leading path component expanded to the user's home directory.
12const HOME_PREFIX: &str = "~";
13/// Maximum directory suggestions offered at once.
14const MAX_COMPLETIONS: usize = 8;
15
16/// A [`PathCompleter`] that lists directories on the local filesystem.
17#[derive(Default)]
18pub struct FsPathCompleter;
19
20impl FsPathCompleter {
21    /// Returns directory completions for `partial` from the local filesystem.
22    fn complete(partial: &str) -> Vec<String> {
23        // When `partial` is itself an existing directory (and not already ending
24        // in a separator), browse inside it; otherwise complete the last segment
25        // within its parent. `is_separator` accepts both `/` and the platform
26        // separator, so Windows-style paths split correctly too.
27        let (typed_dir, prefix): (String, String) =
28            if !partial.ends_with(is_separator) && expand_home(Path::new(partial)).is_dir() {
29                (format!("{partial}{MAIN_SEPARATOR}"), String::new())
30            } else {
31                match partial.rfind(is_separator) {
32                    Some(index) => (
33                        partial[..=index].to_string(),
34                        partial[index + 1..].to_string(),
35                    ),
36                    None => (String::new(), partial.to_string()),
37                }
38            };
39        let read_dir = if typed_dir.is_empty() {
40            PathBuf::from(".")
41        } else {
42            expand_home(Path::new(&typed_dir))
43        };
44        let Ok(entries) = fs::read_dir(&read_dir) else {
45            return Vec::new();
46        };
47        let mut matches: Vec<String> = entries
48            .filter_map(Result::ok)
49            .filter(|entry| entry.path().is_dir())
50            .filter_map(|entry| Self::candidate(&entry.file_name(), &typed_dir, &prefix))
51            .collect();
52        matches.sort();
53        matches.truncate(MAX_COMPLETIONS);
54        matches
55    }
56
57    /// Converts one directory name to a completion, rejecting non-UTF-8 names
58    /// because a lossy string would identify a different filesystem path.
59    fn candidate(raw: &OsStr, typed_dir: &str, prefix: &str) -> Option<String> {
60        let name = raw.to_str()?;
61        let hidden = name.starts_with('.') && !prefix.starts_with('.');
62        (name.starts_with(prefix) && name != prefix && !hidden)
63            .then(|| format!("{typed_dir}{name}"))
64    }
65}
66
67impl PathCompleter for FsPathCompleter {
68    fn complete_dir(&self, partial: &str) -> Vec<String> {
69        Self::complete(partial)
70    }
71}
72
73/// Expands a leading `~` to the user's home directory; any other path is left
74/// unchanged.
75pub fn expand_home(path: &Path) -> PathBuf {
76    match path.strip_prefix(HOME_PREFIX) {
77        Ok(tail) => match BaseDirs::new() {
78            Some(dirs) => dirs.home_dir().join(tail),
79            None => path.to_path_buf(),
80        },
81        Err(_) => path.to_path_buf(),
82    }
83}
84
85/// Expands `~` and makes a path absolute, preserving the user-selected
86/// filesystem location. `.` and `..` components are normalized so equivalent
87/// addressings of one location compare and store identically; a `..` whose
88/// preceding component exists on disk follows that component's real nature
89/// (see [`parent_step`]).
90pub fn absolutize(path: &Path) -> PathBuf {
91    let expanded = expand_home(path);
92    let absolute = if expanded.is_absolute() {
93        expanded
94    } else {
95        match std::env::current_dir() {
96            Ok(cwd) => cwd.join(&expanded),
97            Err(_) => expanded,
98        }
99    };
100    normalize_lexically(&absolute)
101}
102
103/// Removes `.` and applies `..` against its preceding component, keeping the
104/// user's symlink aliases wherever `..` does not cross one. Each `..` follows
105/// [`parent_step`]: popped lexically over directories and missing components,
106/// resolved through the filesystem over directory symlinks, and preserved
107/// over existing non-directories the OS would reject. Leading `..` components
108/// of a relative path are kept; excess `..` at an absolute root is dropped,
109/// matching how the OS resolves it.
110fn normalize_lexically(path: &Path) -> PathBuf {
111    let mut normalized = PathBuf::new();
112    for component in path.components() {
113        match component {
114            Component::CurDir => {},
115            Component::ParentDir => {
116                let last_is_normal = normalized
117                    .components()
118                    .next_back()
119                    .is_some_and(|last| matches!(last, Component::Normal(_)));
120                let last_is_parent = normalized
121                    .components()
122                    .next_back()
123                    .is_some_and(|last| matches!(last, Component::ParentDir));
124                if last_is_normal {
125                    match parent_step(&normalized) {
126                        ParentStep::Lexical => {
127                            normalized.pop();
128                        },
129                        ParentStep::Resolved(parent) => normalized = parent,
130                        ParentStep::Preserved => normalized.push(component.as_os_str()),
131                    }
132                } else if last_is_parent || !normalized.has_root() {
133                    normalized.push(component.as_os_str());
134                }
135            },
136            other => normalized.push(other.as_os_str()),
137        }
138    }
139    normalized
140}
141
142/// How a `..` applies to the prefix before it.
143enum ParentStep {
144    /// A real directory: popping is exactly what the OS would do.
145    Lexical,
146    /// A symlink to a directory: `..` lands at the target's parent.
147    Resolved(PathBuf),
148    /// Anything else - missing, unreachable, or a non-directory: the OS
149    /// rejects the traversal, so it is kept intact rather than rewritten to a
150    /// valid but unrelated path.
151    Preserved,
152}
153
154/// Classifies `..` against `prefix`. Only a real, reachable directory may be
155/// traversed upward; a directory symlink resolves through the filesystem, and
156/// everything else - missing, denied, or a non-directory - preserves its
157/// traversal, because the OS would reject it (`ENOENT`, `EACCES`, `ENOTDIR`)
158/// and normalizing it away could silently select a different workspace.
159fn parent_step(prefix: &Path) -> ParentStep {
160    let Ok(meta) = fs::symlink_metadata(prefix) else {
161        return ParentStep::Preserved;
162    };
163    if meta.file_type().is_symlink() {
164        return match prefix.canonicalize() {
165            Ok(target) if target.is_dir() => match target.parent() {
166                Some(parent) => ParentStep::Resolved(parent.to_path_buf()),
167                // The target is the filesystem root, whose parent is itself.
168                None => ParentStep::Resolved(target),
169            },
170            _ => ParentStep::Preserved,
171        };
172    }
173    if meta.is_dir() {
174        ParentStep::Lexical
175    } else {
176        ParentStep::Preserved
177    }
178}
179
180/// Resolves a registered config only when its stored path is independent of the
181/// caller's directory.
182///
183/// # Errors
184/// Returns [`ConfigError::RelativeProjectConfig`] for ambiguous legacy entries.
185pub fn registered_config_path(project: &Project) -> Result<PathBuf, ConfigError> {
186    if expand_home(project.config()).is_relative() {
187        return Err(ConfigError::RelativeProjectConfig {
188            name: project.name().clone(),
189            path: project.config().clone(),
190        });
191    }
192    Ok(absolutize(project.config()))
193}
194
195/// Normalizes a config path for identity comparison: absolutizes it, then
196/// canonicalizes existing paths so aliases naming the same file compare equal.
197pub fn normalize(path: &Path) -> PathBuf {
198    let absolute = absolutize(path);
199    absolute.canonicalize().unwrap_or(absolute)
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn expand_home_rewrites_a_tilde_prefix() {
208        let home = BaseDirs::new().unwrap().home_dir().to_path_buf();
209        assert_eq!(
210            expand_home(Path::new("~/Projects/muster.yml")),
211            home.join("Projects/muster.yml")
212        );
213        assert_eq!(
214            expand_home(Path::new("/etc/muster.yml")),
215            PathBuf::from("/etc/muster.yml")
216        );
217    }
218
219    #[test]
220    fn relative_and_absolute_paths_to_one_file_normalize_equal() {
221        // `cargo test` runs with the crate root as the working directory, so the
222        // relative name and its absolute form resolve to the same file.
223        let absolute = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
224        assert_eq!(normalize(Path::new("Cargo.toml")), normalize(&absolute));
225    }
226
227    #[cfg(unix)]
228    /// An absolute location retains a symlink alias while identity normalization
229    /// resolves it to its target.
230    #[test]
231    fn absolutize_preserves_a_symlink_path() {
232        use std::os::unix::fs::symlink;
233
234        let dir = std::env::temp_dir().join(format!("muster-path-link-{}", std::process::id()));
235        let target = dir.join("target.yml");
236        let link = dir.join("muster.yml");
237        fs::create_dir_all(&dir).unwrap();
238        fs::write(&target, "").unwrap();
239        symlink(&target, &link).unwrap();
240
241        assert_eq!(absolutize(&link), link);
242        assert_eq!(normalize(&link), target.canonicalize().unwrap());
243
244        fs::remove_dir_all(dir).unwrap();
245    }
246
247    #[test]
248    fn complete_dir_suggests_matching_subdirectories() {
249        let dir = std::env::temp_dir().join(format!("muster-complete-{}", std::process::id()));
250        for sub in ["prism", "proto", "other"] {
251            fs::create_dir_all(dir.join(sub)).unwrap();
252        }
253        fs::write(dir.join("prfile"), "").unwrap();
254
255        let base = dir.display();
256        let got = FsPathCompleter::complete(&format!("{base}/pr"));
257
258        assert_eq!(
259            got,
260            vec![format!("{base}/prism"), format!("{base}/proto")],
261            "only matching directories, prefixed as typed, sorted"
262        );
263
264        let _ = fs::remove_dir_all(&dir);
265    }
266
267    #[test]
268    fn complete_dir_browses_inside_an_existing_directory() {
269        let dir = std::env::temp_dir().join(format!("muster-browse-{}", std::process::id()));
270        for sub in ["alpha", "beta"] {
271            fs::create_dir_all(dir.join(sub)).unwrap();
272        }
273
274        // The path is an existing directory with no trailing slash: list its
275        // children, not its siblings.
276        let base = dir.display();
277        let got = FsPathCompleter::complete(&base.to_string());
278
279        assert_eq!(got, vec![format!("{base}/alpha"), format!("{base}/beta")]);
280
281        let _ = fs::remove_dir_all(&dir);
282    }
283
284    #[cfg(unix)]
285    /// Non-UTF-8 names are rejected without asking the filesystem to create one.
286    #[test]
287    fn candidate_rejects_non_utf8_names() {
288        use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
289
290        let raw = OsStr::from_bytes(b"val\xffid");
291
292        assert_eq!(FsPathCompleter::candidate(raw, "/tmp/", "val"), None);
293    }
294
295    /// Dot components vanish; `..` over a real directory pops to its parent.
296    #[test]
297    fn absolutize_normalizes_dot_components() {
298        assert_eq!(
299            absolutize(Path::new("/tmp/./proj/./muster.yml")),
300            PathBuf::from("/tmp/proj/muster.yml")
301        );
302        assert_eq!(
303            absolutize(Path::new("/../etc/passwd")),
304            PathBuf::from("/etc/passwd"),
305            "excess parent components at the root are dropped"
306        );
307        let real = std::env::temp_dir().join(format!("muster-abs-dot-{}", std::process::id()));
308        let sub = real.join("sub");
309        fs::create_dir_all(&sub).unwrap();
310        assert_eq!(
311            absolutize(&sub.join("../muster.yml")),
312            real.join("muster.yml"),
313            "an existing directory pops"
314        );
315        fs::remove_dir_all(real).unwrap();
316    }
317
318    /// `..` over a missing component is preserved: the OS would fail the
319    /// resolution with ENOENT, so it must not be normalized into a valid,
320    /// unrelated path.
321    #[test]
322    fn missing_components_preserve_their_traversal() {
323        assert_eq!(
324            normalize_lexically(Path::new("/a/missing/../b")),
325            PathBuf::from("/a/missing/../b")
326        );
327        assert_eq!(
328            normalize_lexically(Path::new("../x/./y")),
329            PathBuf::from("../x/y"),
330            "leading parent components and dot removal are untouched"
331        );
332        assert_eq!(
333            normalize_lexically(Path::new("../../x")),
334            PathBuf::from("../../x")
335        );
336    }
337
338    /// A search-permission mode removing all access, for the denied case.
339    #[cfg(unix)]
340    const LOCKED_MODE: u32 = 0o000;
341    /// A normal directory mode, restored so cleanup can run.
342    #[cfg(unix)]
343    const OPEN_MODE: u32 = 0o755;
344
345    #[cfg(unix)]
346    /// `..` under an unsearchable directory is preserved instead of escaping
347    /// lexically toward a different, accessible workspace.
348    #[test]
349    fn traversal_in_an_unsearchable_directory_is_preserved() {
350        use std::os::unix::fs::PermissionsExt;
351
352        let base = std::env::temp_dir().join(format!("muster-denied-{}", std::process::id()));
353        let locked = base.join("locked");
354        let inner = locked.join("inner");
355        fs::create_dir_all(&inner).unwrap();
356        fs::set_permissions(&locked, fs::Permissions::from_mode(LOCKED_MODE)).unwrap();
357        let probe = inner.join("x/../muster.yml");
358        let normalized = normalize_lexically(&probe);
359
360        fs::set_permissions(&locked, fs::Permissions::from_mode(OPEN_MODE)).unwrap();
361        // Denied and missing prefixes both preserve, so this holds even for
362        // root, which ignores the permission bits and sees the path missing.
363        assert_eq!(normalized, probe, "the traversal survives untouched");
364        fs::remove_dir_all(&base).unwrap();
365    }
366
367    /// `..` after an existing regular file is preserved: the OS rejects the
368    /// traversal, so normalization must not rewrite it to a valid path.
369    #[test]
370    fn parent_of_a_regular_file_is_preserved() {
371        let base = std::env::temp_dir().join(format!("muster-file-dotdot-{}", std::process::id()));
372        let blocker = base.join("blocker");
373        fs::create_dir_all(&base).unwrap();
374        fs::write(&blocker, "").unwrap();
375
376        assert_eq!(
377            normalize_lexically(&blocker.join("../muster.yml")),
378            blocker.join("../muster.yml"),
379            "the invalid traversal survives for the OS to reject"
380        );
381        fs::remove_dir_all(base).unwrap();
382    }
383
384    /// `..` after an existing real directory still pops, matching the OS.
385    #[test]
386    fn parent_of_a_real_directory_pops() {
387        let base = std::env::temp_dir().join(format!("muster-dir-dotdot-{}", std::process::id()));
388        let sub = base.join("sub");
389        fs::create_dir_all(&sub).unwrap();
390
391        assert_eq!(
392            normalize_lexically(&sub.join("../muster.yml")),
393            base.join("muster.yml")
394        );
395        fs::remove_dir_all(base).unwrap();
396    }
397
398    #[cfg(unix)]
399    /// `..` across a real symlink resolves through the filesystem: the parent
400    /// of the link's target, not of the link itself.
401    #[test]
402    fn parent_of_a_symlink_resolves_through_the_filesystem() {
403        use std::os::unix::fs::symlink;
404
405        let base = std::env::temp_dir().join(format!("muster-link-dotdot-{}", std::process::id()));
406        let real_sub = base.join("real/sub");
407        let link = base.join("link");
408        fs::create_dir_all(&real_sub).unwrap();
409        symlink(&real_sub, &link).unwrap();
410
411        let resolved = normalize_lexically(&link.join("../muster.yml"));
412
413        assert_eq!(
414            resolved,
415            base.join("real").canonicalize().unwrap().join("muster.yml"),
416            "link/.. lands beside the link target, matching the OS"
417        );
418        fs::remove_dir_all(base).unwrap();
419    }
420}