1use std::fmt;
34use std::path::Path;
35use std::str::FromStr;
36
37use serde::{Deserialize, Serialize};
38
39#[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#[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 pub const NATIVE: Self = if cfg!(windows) {
125 PathPlatform::Windows
126 } else {
127 PathPlatform::Unix
128 };
129
130 #[must_use]
132 pub const fn separator(self) -> char {
133 match self {
134 PathPlatform::Windows => '\\',
135 PathPlatform::Unix => '/',
136 }
137 }
138
139 #[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#[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 pub fn new(raw: impl AsRef<str>) -> Result<Self, LocalPathError> {
195 Self::parse_for(raw, PathPlatform::NATIVE)
196 }
197
198 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 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 #[must_use]
240 pub fn as_str(&self) -> &str {
241 &self.text
242 }
243
244 #[must_use]
247 pub fn as_path(&self) -> &Path {
248 Path::new(&self.text)
249 }
250
251 #[must_use]
253 pub const fn platform(&self) -> PathPlatform {
254 self.platform
255 }
256
257 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
319fn 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
333fn 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 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 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
384fn 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
408const WINDOWS_RESERVED_CHARACTERS: [char; 7] = ['<', '>', ':', '"', '|', '?', '*'];
411
412const 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 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 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 #[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 ("/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 assert_eq!(text("C:\\rman", Windows), "C:\\rman");
518 assert_eq!(text("E:\\rman", Windows), "E:\\rman");
519 }
520
521 #[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 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 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 assert!(parse("C:\\rman", Unix).is_err());
715 assert!(parse("/srv/rman", Windows).is_err());
716 }
717
718 #[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 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}