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}