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 is rejected on every platform so a manifest
49/// resolves identically everywhere, not only where `\` happens to be a
50/// separator.
51fn is_plain_name(segment: &str) -> bool {
52    if segment.contains('\\') {
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
180/// Resolve a portable, `/`-separated `src_path` (as written in a source file)
181/// relative to the directory containing `source_file`.
182///
183/// A convenience wrapper over [`resolve_relative`]: the base is `source_file`'s
184/// parent, or the current directory when `source_file` is a bare filename.
185/// Absolute or rooted `src_path` values pass through unchanged.
186///
187/// # Errors
188///
189/// Propagates [`PathError`] from [`resolve_relative`] when `src_path` has a
190/// segment that is not a single portable name.
191///
192/// # Examples
193///
194/// ```
195/// use std::path::Path;
196///
197/// use mos_core::resolve_source_path;
198///
199/// assert_eq!(
200///     resolve_source_path("img/a.png", Path::new("proj/main.mos")),
201///     Ok(Path::new("proj").join("img").join("a.png")),
202/// );
203/// ```
204pub fn resolve_source_path(src_path: &str, source_file: &Path) -> Result<PathBuf, PathError> {
205    // `Path::parent()` returns `Some("")` (not `None`) for a bare filename like
206    // `main.mos`, so the empty-component guard is required to fall back to the
207    // current directory rather than joining against an empty base.
208    match source_file.parent() {
209        Some(parent) if !parent.as_os_str().is_empty() => resolve_relative(parent, src_path),
210        _ => resolve_relative(Path::new(""), src_path),
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use std::path::Path;
217
218    use super::{PathError, resolve_relative, resolve_source_path};
219
220    #[test]
221    fn resolve_relative_normalizes_dot_dotdot_and_empty_segments() {
222        // `.`/empty are dropped and `..` is resolved lexically, so displayed
223        // paths stay clean (no `proj/sub/../file`).
224        assert_eq!(
225            resolve_relative(Path::new("proj"), "sub/../assets/./logo.png"),
226            Ok(Path::new("proj").join("assets").join("logo.png")),
227        );
228        assert_eq!(
229            resolve_relative(Path::new("proj"), "a//b"),
230            Ok(Path::new("proj").join("a").join("b")),
231        );
232        // `..` may ascend past the base lexically (sibling directory).
233        assert_eq!(
234            resolve_relative(Path::new("proj"), "../shared/x"),
235            Ok(Path::new("shared").join("x")),
236        );
237    }
238
239    #[test]
240    fn resolve_relative_passes_absolute_and_rooted_through() {
241        assert_eq!(
242            resolve_relative(Path::new("proj"), "/etc/refs.bib"),
243            Ok(Path::new("/etc/refs.bib").to_path_buf()),
244        );
245    }
246
247    #[test]
248    fn resolve_relative_preserves_excess_parents_past_a_relative_base() {
249        // More `..` than the base has components: the excess survives as leading
250        // `..` instead of being silently swallowed by `PathBuf::pop`.
251        assert_eq!(
252            resolve_relative(Path::new("proj/sub"), "../../../shared/x"),
253            Ok(Path::new("..").join("shared").join("x")),
254        );
255        // Exhausting the base with nothing below leaves bare `..`s.
256        assert_eq!(
257            resolve_relative(Path::new("proj"), "../../.."),
258            Ok(Path::new("..").join("..")),
259        );
260        // An empty base ascends from the current directory.
261        assert_eq!(
262            resolve_relative(Path::new(""), "../x"),
263            Ok(Path::new("..").join("x")),
264        );
265    }
266
267    #[test]
268    fn resolve_relative_clamps_parents_at_an_absolute_root() {
269        // You cannot ascend above `/`: extra `..` at the root are no-ops.
270        assert_eq!(
271            resolve_relative(Path::new("/a/b"), "../../../x"),
272            Ok(Path::new("/x").to_path_buf()),
273        );
274    }
275
276    #[test]
277    fn resolve_relative_rejects_segments_that_smuggle_separators() {
278        // A backslash is rejected on every platform: manifest paths are
279        // `/`-only, and on Windows `\` would re-split the segment past the
280        // lexical model. The first offending segment is reported verbatim.
281        assert_eq!(
282            resolve_relative(Path::new("proj"), "a\\b/c.png"),
283            Err(PathError::UnsafeSegment("a\\b".to_owned())),
284        );
285        // A bare absolute is not a segment, so it still passes through.
286        assert_eq!(
287            resolve_relative(Path::new("proj"), "/abs/x"),
288            Ok(Path::new("/abs/x").to_path_buf()),
289        );
290    }
291
292    #[test]
293    fn resolve_source_path_preserves_parent_past_an_empty_base() {
294        // Bare filename -> empty base; `../x` must keep its leading `..`.
295        assert_eq!(
296            resolve_source_path("../x", Path::new("main.mos")),
297            Ok(Path::new("..").join("x")),
298        );
299    }
300
301    #[test]
302    fn resolve_source_path_normalizes_against_source_dir_and_bare_filename() {
303        assert_eq!(
304            resolve_source_path("img/../logo.png", Path::new("proj/main.mos")),
305            Ok(Path::new("proj").join("logo.png")),
306        );
307        // Bare filename: parent is `Some("")`, so the base is the current dir.
308        assert_eq!(
309            resolve_source_path("a/./b.png", Path::new("main.mos")),
310            Ok(Path::new("a").join("b.png")),
311        );
312    }
313
314    #[test]
315    fn resolve_source_path_rejects_unsafe_segment() {
316        assert_eq!(
317            resolve_source_path("img\\scan.png", Path::new("proj/main.mos")),
318            Err(PathError::UnsafeSegment("img\\scan.png".to_owned())),
319        );
320    }
321}