Skip to main content

runner_manager_domain/
path.rs

1// owner: a1-workspace-domain
2
3//! The stored shape of an operator-configured local filesystem path.
4//!
5//! `02-target-architecture.md`, "Path validation", splits validation into two
6//! layers "so opening the database never depends on current filesystem
7//! availability". This module is the **pure** layer and nothing else: it decides
8//! whether a string is a shape the product is willing to persist, and it does so
9//! with no syscall, no probe, and no ambient state. The operational layer —
10//! local filesystem identity, writability, canonical containment, overlap with
11//! `AppPaths` — belongs to `b1` in `crates/platform`, runs before a mutation is
12//! committed, and is explicitly *not* run by database load.
13//!
14//! Two properties follow from being pure, and both are load-bearing:
15//!
16//! 1. **A corrupt row is refused at load.** [`LocalAbsolutePath`] is the only way
17//!    to hold a configured path, so a hand-edited `\\nas\builds` in SQLite fails
18//!    closed in the domain rather than becoming a runner root on a network share
19//!    (D10).
20//! 2. **The rules are testable off their own platform.** Every decision here is
21//!    taken against an explicit [`PathPlatform`], so the Windows UNC, device,
22//!    drive-relative and reserved-name cases are covered by a Linux CI leg and
23//!    the Unix cases by a Windows one. [`LocalAbsolutePath::new`] is the
24//!    native-only entry point that database load, the CLI and the TUI use;
25//!    [`LocalAbsolutePath::parse_for`] is the seam the tests use.
26//!
27//! What this module deliberately does **not** do is resolve `..`. Lexically
28//! collapsing `a/../b` is wrong in the presence of a symlink, and this layer is
29//! forbidden from asking the filesystem which one it has, so a traversal
30//! component is rejected outright ([`LocalPathError::Traversal`]) rather than
31//! normalised away. A `.` component carries no such ambiguity and is dropped.
32
33use std::fmt;
34use std::path::Path;
35use std::str::FromStr;
36
37use serde::{Deserialize, Serialize};
38
39// ---------------------------------------------------------------------------
40// Errors
41// ---------------------------------------------------------------------------
42
43/// Why a string is not a storable local absolute path.
44///
45/// Every variant carries the offending text so the CLI and TUI can say which
46/// part of the operator's input failed. Paths are not credentials — no variant
47/// here may ever be given a token, a JIT configuration, or any other secret.
48#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
49pub enum LocalPathError {
50    #[error("a configured path must not be empty")]
51    Empty,
52
53    #[error(
54        "a configured path must be absolute; {got:?} is relative, and what it \
55         resolves to depends on the process working directory"
56    )]
57    NotAbsolute { got: String },
58
59    #[error(
60        "a configured path must name a directory below a filesystem root; {got:?} \
61         is a root itself"
62    )]
63    RootPath { got: String },
64
65    #[error(
66        "a configured path must not contain a `..` component; {got:?} does, and \
67         resolving it without the filesystem would be wrong across a symlink"
68    )]
69    Traversal { got: String },
70
71    #[error(
72        "a configured path must be local; {got:?} is a UNC path, and runner \
73         correctness and recovery may not depend on a remote filesystem (D10)"
74    )]
75    Unc { got: String },
76
77    #[error(
78        "a configured path must use ordinary filesystem syntax; {got:?} is in the \
79         Windows device namespace, which bypasses the rules validated here"
80    )]
81    DeviceNamespace { got: String },
82
83    #[error(
84        "the path component {component:?} contains a character that cannot be \
85         stored: {found:?}"
86    )]
87    UnrepresentableCharacter { component: String, found: char },
88
89    #[error(
90        "the path component {component:?} ends with a space or a dot, which \
91         Windows silently strips, so the stored path would not name the \
92         directory it appears to"
93    )]
94    TrailingDotOrSpace { component: String },
95
96    #[error("the path component {component:?} is a reserved Windows device name")]
97    ReservedName { component: String },
98
99    #[error("{got:?} is not a single path component")]
100    NotASingleComponent { got: String },
101}
102
103// ---------------------------------------------------------------------------
104// Platform seam
105// ---------------------------------------------------------------------------
106
107/// Which platform's path syntax a string is judged against.
108///
109/// This exists so the rules are decided by an argument rather than by
110/// `cfg!(windows)` at every branch. The Windows cases below — UNC, the device
111/// namespace, drive-relative paths, reserved names — are the ones that motivate
112/// this whole module, and a table that only ran its Windows half on Windows
113/// would not be one table, it would be two half-tested ones.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116pub enum PathPlatform {
117    Windows,
118    Unix,
119}
120
121impl PathPlatform {
122    /// The platform this build runs on, and the only one
123    /// [`LocalAbsolutePath::new`] accepts.
124    pub const NATIVE: Self = if cfg!(windows) {
125        PathPlatform::Windows
126    } else {
127        PathPlatform::Unix
128    };
129
130    /// The separator a normalised path is rendered with.
131    #[must_use]
132    pub const fn separator(self) -> char {
133        match self {
134            PathPlatform::Windows => '\\',
135            PathPlatform::Unix => '/',
136        }
137    }
138
139    /// Windows accepts both separators on input; Unix accepts only `/`, because
140    /// a backslash is an ordinary character in a Unix file name.
141    #[must_use]
142    pub const fn is_separator(self, c: char) -> bool {
143        match self {
144            PathPlatform::Windows => c == '\\' || c == '/',
145            PathPlatform::Unix => c == '/',
146        }
147    }
148}
149
150impl fmt::Display for PathPlatform {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        f.write_str(match self {
153            PathPlatform::Windows => "windows",
154            PathPlatform::Unix => "unix",
155        })
156    }
157}
158
159// ---------------------------------------------------------------------------
160// LocalAbsolutePath
161// ---------------------------------------------------------------------------
162
163/// An absolute, non-root, normalised local path, validated without touching the
164/// filesystem.
165///
166/// This is the type `Host.runner_root_override` and
167/// [`crate::workspace::WorkspacePolicy::Persistent`] are written in, so "the
168/// stored value has a legal shape" is a property of the type rather than a check
169/// a caller may forget — the same reasoning [`crate::model`] gives for
170/// `NonZeroU16` capacity.
171///
172/// The stored text is normalised: separators are the platform's own, repeated
173/// separators are collapsed, `.` components are dropped, a trailing separator is
174/// removed, and a Windows drive letter is upper-cased. Two operators who type
175/// `c:/rman/` and `C:\rman` therefore configure one value, which is what makes
176/// the overlap comparisons `b1` layers on top meaningful.
177#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
178#[serde(try_from = "String", into = "String")]
179pub struct LocalAbsolutePath {
180    text: String,
181    platform: PathPlatform,
182}
183
184impl LocalAbsolutePath {
185    /// Validate against the platform this build runs on.
186    ///
187    /// This is the entry point for database load, the CLI and the TUI:
188    /// `02-target-architecture.md` requires "an absolute path native to the
189    /// current host", so a Windows path in a database opened on Linux is corrupt
190    /// state and fails closed here.
191    ///
192    /// # Errors
193    /// Any [`LocalPathError`].
194    pub fn new(raw: impl AsRef<str>) -> Result<Self, LocalPathError> {
195        Self::parse_for(raw, PathPlatform::NATIVE)
196    }
197
198    /// Validate against an explicit platform.
199    ///
200    /// # Errors
201    /// Any [`LocalPathError`].
202    pub fn parse_for(raw: impl AsRef<str>, platform: PathPlatform) -> Result<Self, LocalPathError> {
203        let raw = raw.as_ref();
204        if raw.trim().is_empty() {
205            return Err(LocalPathError::Empty);
206        }
207        // A NUL terminates the string at every operating system API this value
208        // ever reaches, so a path containing one names a different directory
209        // than it reads as. It is checked before anything else because no later
210        // rule would see the truncated remainder.
211        if raw.contains('\0') {
212            return Err(LocalPathError::UnrepresentableCharacter {
213                component: raw.to_string(),
214                found: '\0',
215            });
216        }
217        let (prefix, rest) = match platform {
218            PathPlatform::Windows => windows_prefix(raw)?,
219            PathPlatform::Unix => unix_prefix(raw)?,
220        };
221        let components = normalise_components(raw, rest, platform)?;
222        if components.is_empty() {
223            return Err(LocalPathError::RootPath {
224                got: raw.to_string(),
225            });
226        }
227        let separator = platform.separator();
228        let mut text = prefix;
229        for (index, component) in components.iter().enumerate() {
230            if index > 0 {
231                text.push(separator);
232            }
233            text.push_str(component);
234        }
235        Ok(Self { text, platform })
236    }
237
238    /// The normalised text, exactly as it is persisted.
239    #[must_use]
240    pub fn as_str(&self) -> &str {
241        &self.text
242    }
243
244    /// The same value as a [`Path`], for callers that do filesystem work with it
245    /// *after* the operational preflight has passed.
246    #[must_use]
247    pub fn as_path(&self) -> &Path {
248        Path::new(&self.text)
249    }
250
251    /// Which platform's rules this value was accepted under.
252    #[must_use]
253    pub const fn platform(&self) -> PathPlatform {
254        self.platform
255    }
256
257    /// A validated child directory of this path, one component down.
258    ///
259    /// Containment is by construction rather than by comparison: the child name
260    /// is required to be a single component, so `<root>/sN` — the slot path
261    /// `02-target-architecture.md` describes — cannot escape the root it was
262    /// derived from however the caller spells `N`. The canonical, symlink-aware
263    /// half of that check is `b1`'s operational preflight; this is the lexical
264    /// half.
265    ///
266    /// # Errors
267    /// [`LocalPathError::NotASingleComponent`] for an empty name, a separator, a
268    /// `.` or a `..`, plus the platform's own component rules.
269    pub fn join_child(&self, name: impl AsRef<str>) -> Result<Self, LocalPathError> {
270        let name = name.as_ref();
271        if name.is_empty()
272            || name == "."
273            || name == ".."
274            || name.chars().any(|c| self.platform.is_separator(c))
275        {
276            return Err(LocalPathError::NotASingleComponent {
277                got: name.to_string(),
278            });
279        }
280        validate_component(name, self.platform)?;
281        let mut text = self.text.clone();
282        text.push(self.platform.separator());
283        text.push_str(name);
284        Ok(Self {
285            text,
286            platform: self.platform,
287        })
288    }
289}
290
291impl fmt::Display for LocalAbsolutePath {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        f.write_str(&self.text)
294    }
295}
296
297impl TryFrom<String> for LocalAbsolutePath {
298    type Error = LocalPathError;
299
300    fn try_from(value: String) -> Result<Self, Self::Error> {
301        Self::new(value)
302    }
303}
304
305impl From<LocalAbsolutePath> for String {
306    fn from(value: LocalAbsolutePath) -> Self {
307        value.text
308    }
309}
310
311impl FromStr for LocalAbsolutePath {
312    type Err = LocalPathError;
313
314    fn from_str(s: &str) -> Result<Self, Self::Err> {
315        Self::new(s)
316    }
317}
318
319// ---------------------------------------------------------------------------
320// Parsing
321// ---------------------------------------------------------------------------
322
323/// The rendered root of a Unix path, and the remainder to split into components.
324fn unix_prefix(raw: &str) -> Result<(String, &str), LocalPathError> {
325    match raw.strip_prefix('/') {
326        Some(rest) => Ok(("/".to_string(), rest)),
327        None => Err(LocalPathError::NotAbsolute {
328            got: raw.to_string(),
329        }),
330    }
331}
332
333/// The rendered root of a Windows path, and the remainder to split.
334///
335/// The two-separator prefixes are decided first and rejected outright. Both are
336/// "absolute" in the sense that they do not depend on the working directory, so
337/// a plain `is_absolute` test would accept them; D10 and the device-namespace
338/// rule are the reasons they are refused, not addressability.
339fn windows_prefix(raw: &str) -> Result<(String, &str), LocalPathError> {
340    let mut chars = raw.chars();
341    let first = chars.next();
342    let second = chars.next();
343    if first.is_some_and(|c| PathPlatform::Windows.is_separator(c))
344        && second.is_some_and(|c| PathPlatform::Windows.is_separator(c))
345    {
346        // `\\?\…` and `\\.\…` are the device namespace; `\\?\UNC\…` is reached
347        // through it and is refused by the same arm.
348        let rest = &raw[2..];
349        let mut rest_chars = rest.chars();
350        let marker = rest_chars.next();
351        let after = rest_chars.next();
352        if matches!(marker, Some('?' | '.'))
353            && after.is_some_and(|c| PathPlatform::Windows.is_separator(c))
354        {
355            return Err(LocalPathError::DeviceNamespace {
356                got: raw.to_string(),
357            });
358        }
359        return Err(LocalPathError::Unc {
360            got: raw.to_string(),
361        });
362    }
363
364    let (Some(drive), Some(':')) = (first.filter(char::is_ascii_alphabetic), second) else {
365        return Err(LocalPathError::NotAbsolute {
366            got: raw.to_string(),
367        });
368    };
369    // `C:work` is drive-*relative*: it resolves against the working directory
370    // recorded for that drive, which is exactly the ambiguity a stored path may
371    // not have.
372    let rest = &raw[drive.len_utf8() + 1..];
373    match rest.chars().next() {
374        Some(c) if PathPlatform::Windows.is_separator(c) => {
375            let prefix = format!("{}:{}", drive.to_ascii_uppercase(), '\\');
376            Ok((prefix, &rest[c.len_utf8()..]))
377        }
378        _ => Err(LocalPathError::NotAbsolute {
379            got: raw.to_string(),
380        }),
381    }
382}
383
384/// Split `rest` into validated components, dropping `.` and empty ones.
385fn normalise_components<'a>(
386    raw: &str,
387    rest: &'a str,
388    platform: PathPlatform,
389) -> Result<Vec<&'a str>, LocalPathError> {
390    let mut components = Vec::new();
391    for component in rest.split(|c| platform.is_separator(c)) {
392        match component {
393            "" | "." => continue,
394            ".." => {
395                return Err(LocalPathError::Traversal {
396                    got: raw.to_string(),
397                });
398            }
399            component => {
400                validate_component(component, platform)?;
401                components.push(component);
402            }
403        }
404    }
405    Ok(components)
406}
407
408/// The characters Windows refuses in a file name, minus the separators, which
409/// have already been consumed by the split.
410const WINDOWS_RESERVED_CHARACTERS: [char; 7] = ['<', '>', ':', '"', '|', '?', '*'];
411
412/// The device names Windows resolves before it looks at the directory tree, so a
413/// directory of this name cannot be created and a path through one does not name
414/// what it reads as.
415const WINDOWS_RESERVED_NAMES: [&str; 22] = [
416    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
417    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
418];
419
420fn validate_component(component: &str, platform: PathPlatform) -> Result<(), LocalPathError> {
421    if let Some(found) = component.chars().find(|c| *c == '\0') {
422        return Err(LocalPathError::UnrepresentableCharacter {
423            component: component.to_string(),
424            found,
425        });
426    }
427    if platform == PathPlatform::Unix {
428        // Every byte except NUL and `/` is a legal Unix file name, and inventing
429        // a stricter rule here would refuse directories an operator already has.
430        return Ok(());
431    }
432    if let Some(found) = component
433        .chars()
434        .find(|c| WINDOWS_RESERVED_CHARACTERS.contains(c) || c.is_control())
435    {
436        return Err(LocalPathError::UnrepresentableCharacter {
437            component: component.to_string(),
438            found,
439        });
440    }
441    if component.ends_with(' ') || component.ends_with('.') {
442        return Err(LocalPathError::TrailingDotOrSpace {
443            component: component.to_string(),
444        });
445    }
446    // Windows resolves a device name from the part before the first `.`, so
447    // `com1.txt` is `COM1`. A component with no `.` at all is its own stem.
448    let (stem, _) = component.split_once('.').unwrap_or((component, ""));
449    let stem = stem.trim_end_matches(' ');
450    if WINDOWS_RESERVED_NAMES
451        .iter()
452        .any(|name| stem.eq_ignore_ascii_case(name))
453    {
454        return Err(LocalPathError::ReservedName {
455            component: component.to_string(),
456        });
457    }
458    Ok(())
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    use PathPlatform::{Unix, Windows};
466
467    fn parse(raw: &str, platform: PathPlatform) -> Result<LocalAbsolutePath, LocalPathError> {
468        LocalAbsolutePath::parse_for(raw, platform)
469    }
470
471    fn text(raw: &str, platform: PathPlatform) -> String {
472        parse(raw, platform)
473            .expect("the fixture is a valid path")
474            .as_str()
475            .to_string()
476    }
477
478    // -- accepted shapes ----------------------------------------------------
479
480    #[test]
481    fn unix_absolute_paths_are_accepted_and_normalised() {
482        let cases = [
483            ("/srv/rman", "/srv/rman"),
484            ("/srv/rman/", "/srv/rman"),
485            ("//srv///rman//", "/srv/rman"),
486            ("/srv/./rman", "/srv/rman"),
487            ("/srv/rman workspaces", "/srv/rman workspaces"),
488            // A backslash is an ordinary character in a Unix file name, not a
489            // separator, so this is one component and not two.
490            ("/srv/a\\b", "/srv/a\\b"),
491            ("/rman", "/rman"),
492        ];
493        for (raw, expected) in cases {
494            assert_eq!(text(raw, Unix), expected, "input {raw:?}");
495        }
496    }
497
498    #[test]
499    fn windows_drive_paths_are_accepted_and_normalised() {
500        let cases = [
501            ("C:\\rman", "C:\\rman"),
502            ("c:/rman", "C:\\rman"),
503            ("c:\\rman\\", "C:\\rman"),
504            ("D:\\rman\\\\workspaces//x", "D:\\rman\\workspaces\\x"),
505            ("C:\\rman\\.\\slots", "C:\\rman\\slots"),
506            ("Z:/builds/runner root", "Z:\\builds\\runner root"),
507        ];
508        for (raw, expected) in cases {
509            assert_eq!(text(raw, Windows), expected, "input {raw:?}");
510        }
511    }
512
513    #[test]
514    fn the_windows_default_root_shape_is_representable() {
515        // D1: `%SystemDrive%\rman`, normally `C:\rman`. The drive letter is not
516        // assumed anywhere in this crate; both spellings must survive.
517        assert_eq!(text("C:\\rman", Windows), "C:\\rman");
518        assert_eq!(text("E:\\rman", Windows), "E:\\rman");
519    }
520
521    // -- rejected shapes ----------------------------------------------------
522
523    #[test]
524    fn relative_paths_are_rejected_on_both_platforms() {
525        for raw in ["rman", "./rman", "../rman", "srv/rman", ""] {
526            assert!(
527                parse(raw, Unix).is_err(),
528                "unix accepted the relative path {raw:?}"
529            );
530            assert!(
531                parse(raw, Windows).is_err(),
532                "windows accepted the relative path {raw:?}"
533            );
534        }
535        assert_eq!(
536            parse("rman", Unix),
537            Err(LocalPathError::NotAbsolute {
538                got: "rman".to_string()
539            })
540        );
541        assert_eq!(parse("   ", Unix), Err(LocalPathError::Empty));
542    }
543
544    #[test]
545    fn windows_rooted_and_drive_relative_paths_are_not_absolute() {
546        // `\rman` is rooted on the current drive and `C:rman` on the current
547        // directory of drive C: both depend on process state.
548        for raw in ["\\rman", "/rman", "C:rman", "C:"] {
549            assert_eq!(
550                parse(raw, Windows),
551                Err(LocalPathError::NotAbsolute {
552                    got: raw.to_string()
553                }),
554                "input {raw:?}"
555            );
556        }
557    }
558
559    #[test]
560    fn filesystem_roots_are_rejected() {
561        for raw in ["/", "/.", "/./"] {
562            assert_eq!(
563                parse(raw, Unix),
564                Err(LocalPathError::RootPath {
565                    got: raw.to_string()
566                }),
567                "input {raw:?}"
568            );
569        }
570        for raw in ["C:\\", "c:/", "C:\\.\\", "D:\\\\"] {
571            assert_eq!(
572                parse(raw, Windows),
573                Err(LocalPathError::RootPath {
574                    got: raw.to_string()
575                }),
576                "input {raw:?}"
577            );
578        }
579    }
580
581    #[test]
582    fn traversal_is_rejected_rather_than_resolved() {
583        for raw in ["/srv/../etc", "/srv/rman/..", "/../srv"] {
584            assert_eq!(
585                parse(raw, Unix),
586                Err(LocalPathError::Traversal {
587                    got: raw.to_string()
588                }),
589                "input {raw:?}"
590            );
591        }
592        for raw in ["C:\\rman\\..\\Windows", "C:\\..", "C:/rman/../x"] {
593            assert_eq!(
594                parse(raw, Windows),
595                Err(LocalPathError::Traversal {
596                    got: raw.to_string()
597                }),
598                "input {raw:?}"
599            );
600        }
601    }
602
603    #[test]
604    fn unc_paths_are_rejected() {
605        for raw in [
606            "\\\\nas\\builds",
607            "//nas/builds",
608            "\\\\nas\\builds\\rman",
609            "\\\\127.0.0.1\\c$",
610        ] {
611            assert_eq!(
612                parse(raw, Windows),
613                Err(LocalPathError::Unc {
614                    got: raw.to_string()
615                }),
616                "input {raw:?}"
617            );
618        }
619    }
620
621    #[test]
622    fn device_namespace_paths_are_rejected() {
623        for raw in [
624            "\\\\?\\C:\\rman",
625            "\\\\.\\PhysicalDrive0",
626            "\\\\?\\UNC\\nas\\builds",
627            "//?/C:/rman",
628        ] {
629            assert_eq!(
630                parse(raw, Windows),
631                Err(LocalPathError::DeviceNamespace {
632                    got: raw.to_string()
633                }),
634                "input {raw:?}"
635            );
636        }
637    }
638
639    #[test]
640    fn windows_unrepresentable_components_are_rejected() {
641        // The offending *component* is asserted alongside the variant: an error
642        // that reported the whole raw path would tell the operator to fix the
643        // wrong part of their input.
644        let cases = [
645            (
646                "C:\\rman\\a<b",
647                LocalPathError::UnrepresentableCharacter {
648                    component: "a<b".to_string(),
649                    found: '<',
650                },
651            ),
652            (
653                "C:\\rman\\a|b",
654                LocalPathError::UnrepresentableCharacter {
655                    component: "a|b".to_string(),
656                    found: '|',
657                },
658            ),
659            (
660                "C:\\rman\\a:b",
661                LocalPathError::UnrepresentableCharacter {
662                    component: "a:b".to_string(),
663                    found: ':',
664                },
665            ),
666            (
667                "C:\\rman\\slots.",
668                LocalPathError::TrailingDotOrSpace {
669                    component: "slots.".to_string(),
670                },
671            ),
672            (
673                "C:\\rman\\slots ",
674                LocalPathError::TrailingDotOrSpace {
675                    component: "slots ".to_string(),
676                },
677            ),
678            (
679                "C:\\rman\\NUL",
680                LocalPathError::ReservedName {
681                    component: "NUL".to_string(),
682                },
683            ),
684            (
685                "C:\\rman\\com1.txt",
686                LocalPathError::ReservedName {
687                    component: "com1.txt".to_string(),
688                },
689            ),
690        ];
691        for (raw, expected) in cases {
692            assert_eq!(parse(raw, Windows), Err(expected), "input {raw:?}");
693        }
694    }
695
696    #[test]
697    fn an_interior_nul_is_rejected_on_every_platform() {
698        for platform in [Unix, Windows] {
699            assert_eq!(
700                parse("/srv/rm\0an", platform),
701                Err(LocalPathError::UnrepresentableCharacter {
702                    component: "/srv/rm\0an".to_string(),
703                    found: '\0',
704                }),
705                "platform {platform}"
706            );
707        }
708    }
709
710    #[test]
711    fn a_windows_path_is_not_a_unix_path_and_the_reverse() {
712        // The property database load depends on: a row written by a Windows host
713        // is corrupt state on a Linux host rather than a silently relative path.
714        assert!(parse("C:\\rman", Unix).is_err());
715        assert!(parse("/srv/rman", Windows).is_err());
716    }
717
718    // -- derived paths ------------------------------------------------------
719
720    #[test]
721    fn join_child_appends_one_validated_component() {
722        let root = parse("/srv/rman", Unix).expect("valid root");
723        assert_eq!(
724            root.join_child("s1").expect("valid child").as_str(),
725            "/srv/rman/s1"
726        );
727
728        let root = parse("C:\\rman", Windows).expect("valid root");
729        assert_eq!(
730            root.join_child("s12").expect("valid child").as_str(),
731            "C:\\rman\\s12"
732        );
733    }
734
735    #[test]
736    fn join_child_refuses_anything_that_is_not_one_component() {
737        let root = parse("/srv/rman", Unix).expect("valid root");
738        for name in ["", ".", "..", "a/b", "/abs"] {
739            assert_eq!(
740                root.join_child(name),
741                Err(LocalPathError::NotASingleComponent {
742                    got: name.to_string()
743                }),
744                "child {name:?}"
745            );
746        }
747
748        let root = parse("C:\\rman", Windows).expect("valid root");
749        for name in ["a\\b", "a/b", ".."] {
750            assert_eq!(
751                root.join_child(name),
752                Err(LocalPathError::NotASingleComponent {
753                    got: name.to_string()
754                }),
755                "child {name:?}"
756            );
757        }
758        assert!(matches!(
759            root.join_child("NUL"),
760            Err(LocalPathError::ReservedName { .. })
761        ));
762    }
763
764    // -- representation -----------------------------------------------------
765
766    /// A path this build's own platform accepts, for the round-trip tests.
767    fn native_fixture() -> &'static str {
768        if cfg!(windows) {
769            "C:\\rman"
770        } else {
771            "/srv/rman"
772        }
773    }
774
775    #[test]
776    fn the_native_entry_point_uses_the_native_platform() {
777        let value = LocalAbsolutePath::new(native_fixture()).expect("valid native path");
778        assert_eq!(value.platform(), PathPlatform::NATIVE);
779        assert_eq!(value.as_path(), Path::new(value.as_str()));
780    }
781
782    #[test]
783    fn serde_round_trips_through_the_normalised_string() {
784        let value = LocalAbsolutePath::new(native_fixture()).expect("valid native path");
785        let encoded = serde_json::to_string(&value).expect("serialisable");
786        assert_eq!(
787            encoded,
788            serde_json::to_string(value.as_str()).expect("serialisable")
789        );
790        let decoded: LocalAbsolutePath = serde_json::from_str(&encoded).expect("deserialisable");
791        assert_eq!(decoded, value);
792    }
793
794    #[test]
795    fn deserialising_an_illegal_shape_fails_closed() {
796        for encoded in ["\"\"", "\"rman\"", "\"\\\\\\\\nas\\\\builds\""] {
797            assert!(
798                serde_json::from_str::<LocalAbsolutePath>(encoded).is_err(),
799                "accepted {encoded}"
800            );
801        }
802    }
803
804    #[test]
805    fn display_and_from_str_agree_with_the_stored_text() {
806        let value: LocalAbsolutePath = native_fixture().parse().expect("valid native path");
807        assert_eq!(value.to_string(), value.as_str());
808        assert_eq!(String::from(value.clone()), value.as_str());
809    }
810}