Skip to main content

mos_core/
path.rs

1//! Portable path resolution and display helpers.
2//!
3//! Source documents and manifests spell relative paths with `/` (manifest
4//! convention). These helpers resolve such paths to the platform separator
5//! ([`resolve_relative`], [`resolve_source_path`]) and render any path back
6//! with forward slashes for user-facing output ([`display_path`]). Nothing
7//! here touches the filesystem; these are pure path-string operations.
8
9use std::ffi::OsStr;
10use std::path::{Component, Path, PathBuf};
11
12/// Why a portable, `/`-separated path could not be resolved.
13///
14/// Resolution is infallible for well-formed manifest paths; the sole failure
15/// mode is a segment that would smuggle platform path semantics past the
16/// `/`-only lexical model in [`resolve_relative`].
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum PathError {
19    /// A `/`-delimited segment was not a single portable name: it held a
20    /// platform separator (`\`), a drive prefix, or otherwise resolved to more
21    /// than one path component, so the OS would re-split it and bypass the
22    /// lexical `..` handling. The offending segment is reported verbatim.
23    UnsafeSegment(String),
24}
25
26impl core::fmt::Display for PathError {
27    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
28        match self {
29            Self::UnsafeSegment(segment) => write!(
30                f,
31                "path segment `{segment}` is not a portable name; manifest paths use `/` as the \
32                 only separator and a segment may not contain a `\\`, a drive prefix, or an \
33                 absolute root"
34            ),
35        }
36    }
37}
38
39impl core::error::Error for PathError {}
40
41/// Whether `segment` is a single portable filename component: it holds no
42/// platform separator and parses to exactly one [`Component::Normal`] equal to
43/// itself.
44///
45/// Manifest paths spell separators with `/` only, so a `\` (a separator on
46/// Windows), a drive prefix (`C:`), or any rooted form would let the OS
47/// re-split the segment and slip past the lexical `..` normalization in
48/// [`resolve_relative`]. Backslash and a leading drive letter are rejected on
49/// every platform so a manifest resolves identically everywhere, not only where
50/// `\` happens to be a separator or `C:` a prefix.
51fn is_plain_name(segment: &str) -> bool {
52    if segment.contains('\\') || has_drive_prefix(segment) {
53        return false;
54    }
55    let mut components = Path::new(segment).components();
56    matches!(components.next(), Some(Component::Normal(name)) if name == OsStr::new(segment))
57        && components.next().is_none()
58}
59
60/// Render a filesystem path for user-facing output with forward slashes on
61/// every platform.
62///
63/// On Windows, `Path::join` appends the native `\` separator, so a path built
64/// from a forward-slash input (such as a shell-glob argument like
65/// `examples/foo`) comes out mixed: `examples/foo\bar.pdf`. Normalizing to `/`
66/// keeps CLI output, diagnostics, and error messages consistent across
67/// platforms. On Unix the platform separator is already `/`, so this is a
68/// no-op.
69///
70/// # Examples
71///
72/// ```
73/// use std::path::Path;
74///
75/// use mos_core::display_path;
76///
77/// // Unchanged on Unix; on Windows any `\` separators become `/`.
78/// assert_eq!(display_path(Path::new("a/b/c.pdf")), "a/b/c.pdf");
79/// ```
80#[must_use]
81pub fn display_path(path: &Path) -> String {
82    path.to_string_lossy()
83        .replace(std::path::MAIN_SEPARATOR, "/")
84}
85
86/// Join a portable, `/`-separated `relative` path onto `base`, using the
87/// platform path separator throughout.
88///
89/// Source documents and manifests spell relative paths with `/` (manifest convention).
90/// Joining such a string with `Path::join` directly leaves the `/` embedded on Windows,
91/// producing mixed paths like `dir\sub/file`.
92/// This rebuilds the relative portion component-by-component so the result is
93/// uniformly native-separated. A `relative` value that is absolute *or rooted*
94/// passes through unchanged; on Windows a leading `/` is rooted but
95/// prefix-less, which `Path::is_absolute` alone misses, so `has_root` is
96/// checked too.
97///
98/// Normalization is lexical and stack-based: `.` and empty (`a//b`) segments are
99/// dropped; a `..` pops the last resolved *name*; a `..` that escapes a
100/// **relative** base is **preserved** as a leading `..` (`proj/sub` +
101/// `../../../shared/x` -> `../shared/x`), not silently swallowed; and a `..` at
102/// the root of an **absolute** base is clamped (`/a` + `../../x` -> `/x`),
103/// matching the OS. Nothing here touches the filesystem, so `..` is resolved
104/// textually and ignores symlinks; identity-grade canonicalization (NFC, escape
105/// rejection) lives in `mos_cache::ProjectPath`. Each `/`-delimited segment must
106/// be a single portable name -- manifest paths use `/` only, so a segment that
107/// smuggles in a platform separator (`\`), a drive prefix, or an absolute root
108/// is rejected (see Errors) rather than handed to `PathBuf::push`, which would
109/// let the OS re-split it past this lexical model.
110///
111/// # Errors
112///
113/// Returns [`PathError::UnsafeSegment`] when a `/`-delimited segment is not a
114/// single portable component. An absolute or rooted `relative` is *not* a
115/// segment and still passes through unchanged.
116///
117/// # Examples
118///
119/// ```
120/// use std::path::Path;
121///
122/// use mos_core::resolve_relative;
123///
124/// assert_eq!(
125///     resolve_relative(Path::new("proj"), "sub/file.txt"),
126///     Ok(Path::new("proj").join("sub").join("file.txt")),
127/// );
128/// // `.` and `..` segments are normalized away within the base:
129/// assert_eq!(
130///     resolve_relative(Path::new("proj"), "sub/../assets/./logo.png"),
131///     Ok(Path::new("proj").join("assets").join("logo.png")),
132/// );
133/// // `..` past a relative base is preserved, not eaten:
134/// assert_eq!(
135///     resolve_relative(Path::new("proj/sub"), "../../../shared/x"),
136///     Ok(Path::new("..").join("shared").join("x")),
137/// );
138/// ```
139pub fn resolve_relative(base: &Path, relative: &str) -> Result<PathBuf, PathError> {
140    let candidate = Path::new(relative);
141    if candidate.is_absolute() || candidate.has_root() {
142        return Ok(candidate.to_path_buf());
143    }
144    // `resolved` accumulates the path at or below `base`; `ascend` counts the
145    // leading `..` that escaped a relative base and must survive. `pop()` only
146    // removes a real component, so when it fails we ascend (relative base) or
147    // clamp at the root (absolute base) -- the `..` is never silently dropped.
148    let mut resolved = base.to_path_buf();
149    let mut ascend = 0usize;
150    for segment in relative.split('/') {
151        match segment {
152            "" | "." => {}
153            ".." => {
154                if !resolved.pop() && !resolved.has_root() {
155                    ascend += 1;
156                }
157            }
158            other => {
159                if !is_plain_name(other) {
160                    return Err(PathError::UnsafeSegment(other.to_owned()));
161                }
162                resolved.push(other);
163            }
164        }
165    }
166    if ascend == 0 {
167        return Ok(resolved);
168    }
169    let mut out = PathBuf::new();
170    for _ in 0..ascend {
171        out.push("..");
172    }
173    // `resolved` is the (relative) remainder below the escape point, if any.
174    if !resolved.as_os_str().is_empty() {
175        out.push(resolved);
176    }
177    Ok(out)
178}
179
180fn has_drive_prefix(segment: &str) -> bool {
181    let bytes = segment.as_bytes();
182    bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
183}
184
185/// Resolve a portable, `/`-separated `src_path` (as written in a source file)
186/// relative to the directory containing `source_file`.
187///
188/// A convenience wrapper over [`resolve_relative`]: the base is `source_file`'s
189/// parent, or the current directory when `source_file` is a bare filename.
190/// Absolute or rooted `src_path` values pass through unchanged.
191///
192/// # Errors
193///
194/// Propagates [`PathError`] from [`resolve_relative`] when `src_path` has a
195/// segment that is not a single portable name.
196///
197/// # Examples
198///
199/// ```
200/// use std::path::Path;
201///
202/// use mos_core::resolve_source_path;
203///
204/// assert_eq!(
205///     resolve_source_path("img/a.png", Path::new("proj/main.mos")),
206///     Ok(Path::new("proj").join("img").join("a.png")),
207/// );
208/// ```
209pub fn resolve_source_path(src_path: &str, source_file: &Path) -> Result<PathBuf, PathError> {
210    // `Path::parent()` returns `Some("")` (not `None`) for a bare filename like
211    // `main.mos`, so the empty-component guard is required to fall back to the
212    // current directory rather than joining against an empty base.
213    match source_file.parent() {
214        Some(parent) if !parent.as_os_str().is_empty() => resolve_relative(parent, src_path),
215        _ => resolve_relative(Path::new(""), src_path),
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use std::path::Path;
222
223    use super::{PathError, resolve_relative, resolve_source_path};
224
225    #[test]
226    fn resolve_relative_normalizes_dot_dotdot_and_empty_segments() {
227        // `.`/empty are dropped and `..` is resolved lexically, so displayed
228        // paths stay clean (no `proj/sub/../file`).
229        assert_eq!(
230            resolve_relative(Path::new("proj"), "sub/../assets/./logo.png"),
231            Ok(Path::new("proj").join("assets").join("logo.png")),
232        );
233        assert_eq!(
234            resolve_relative(Path::new("proj"), "a//b"),
235            Ok(Path::new("proj").join("a").join("b")),
236        );
237        // `..` may ascend past the base lexically (sibling directory).
238        assert_eq!(
239            resolve_relative(Path::new("proj"), "../shared/x"),
240            Ok(Path::new("shared").join("x")),
241        );
242    }
243
244    #[test]
245    fn resolve_relative_passes_absolute_and_rooted_through() {
246        assert_eq!(
247            resolve_relative(Path::new("proj"), "/etc/refs.bib"),
248            Ok(Path::new("/etc/refs.bib").to_path_buf()),
249        );
250    }
251
252    #[test]
253    fn resolve_relative_preserves_excess_parents_past_a_relative_base() {
254        // More `..` than the base has components: the excess survives as leading
255        // `..` instead of being silently swallowed by `PathBuf::pop`.
256        assert_eq!(
257            resolve_relative(Path::new("proj/sub"), "../../../shared/x"),
258            Ok(Path::new("..").join("shared").join("x")),
259        );
260        // Exhausting the base with nothing below leaves bare `..`s.
261        assert_eq!(
262            resolve_relative(Path::new("proj"), "../../.."),
263            Ok(Path::new("..").join("..")),
264        );
265        // An empty base ascends from the current directory.
266        assert_eq!(
267            resolve_relative(Path::new(""), "../x"),
268            Ok(Path::new("..").join("x")),
269        );
270    }
271
272    #[test]
273    fn resolve_relative_clamps_parents_at_an_absolute_root() {
274        // You cannot ascend above `/`: extra `..` at the root are no-ops.
275        assert_eq!(
276            resolve_relative(Path::new("/a/b"), "../../../x"),
277            Ok(Path::new("/x").to_path_buf()),
278        );
279    }
280
281    #[test]
282    fn resolve_relative_rejects_segments_that_smuggle_separators() {
283        // A backslash is rejected on every platform: manifest paths are
284        // `/`-only, and on Windows `\` would re-split the segment past the
285        // lexical model. The first offending segment is reported verbatim.
286        assert_eq!(
287            resolve_relative(Path::new("proj"), "a\\b/c.png"),
288            Err(PathError::UnsafeSegment("a\\b".to_owned())),
289        );
290        // A bare absolute is not a segment, so it still passes through.
291        assert_eq!(
292            resolve_relative(Path::new("proj"), "/abs/x"),
293            Ok(Path::new("/abs/x").to_path_buf()),
294        );
295    }
296
297    #[test]
298    fn resolve_relative_rejects_drive_prefixed_segments_on_every_platform() {
299        assert_eq!(
300            resolve_relative(Path::new("proj"), "C:foo/x.png"),
301            Err(PathError::UnsafeSegment("C:foo".to_owned())),
302        );
303        assert_eq!(
304            resolve_relative(Path::new("proj"), "a/c:/x.png"),
305            Err(PathError::UnsafeSegment("c:".to_owned())),
306        );
307        assert_eq!(
308            resolve_relative(Path::new("proj"), "notes:draft/x.png"),
309            Ok(Path::new("proj").join("notes:draft").join("x.png")),
310        );
311    }
312
313    #[test]
314    fn resolve_source_path_preserves_parent_past_an_empty_base() {
315        // Bare filename -> empty base; `../x` must keep its leading `..`.
316        assert_eq!(
317            resolve_source_path("../x", Path::new("main.mos")),
318            Ok(Path::new("..").join("x")),
319        );
320    }
321
322    #[test]
323    fn resolve_source_path_normalizes_against_source_dir_and_bare_filename() {
324        assert_eq!(
325            resolve_source_path("img/../logo.png", Path::new("proj/main.mos")),
326            Ok(Path::new("proj").join("logo.png")),
327        );
328        // Bare filename: parent is `Some("")`, so the base is the current dir.
329        assert_eq!(
330            resolve_source_path("a/./b.png", Path::new("main.mos")),
331            Ok(Path::new("a").join("b.png")),
332        );
333    }
334
335    #[test]
336    fn resolve_source_path_rejects_unsafe_segment() {
337        assert_eq!(
338            resolve_source_path("img\\scan.png", Path::new("proj/main.mos")),
339            Err(PathError::UnsafeSegment("img\\scan.png".to_owned())),
340        );
341    }
342}