use std::fmt;
use std::path::Path;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum LocalPathError {
#[error("a configured path must not be empty")]
Empty,
#[error(
"a configured path must be absolute; {got:?} is relative, and what it \
resolves to depends on the process working directory"
)]
NotAbsolute { got: String },
#[error(
"a configured path must name a directory below a filesystem root; {got:?} \
is a root itself"
)]
RootPath { got: String },
#[error(
"a configured path must not contain a `..` component; {got:?} does, and \
resolving it without the filesystem would be wrong across a symlink"
)]
Traversal { got: String },
#[error(
"a configured path must be local; {got:?} is a UNC path, and runner \
correctness and recovery may not depend on a remote filesystem (D10)"
)]
Unc { got: String },
#[error(
"a configured path must use ordinary filesystem syntax; {got:?} is in the \
Windows device namespace, which bypasses the rules validated here"
)]
DeviceNamespace { got: String },
#[error(
"the path component {component:?} contains a character that cannot be \
stored: {found:?}"
)]
UnrepresentableCharacter { component: String, found: char },
#[error(
"the path component {component:?} ends with a space or a dot, which \
Windows silently strips, so the stored path would not name the \
directory it appears to"
)]
TrailingDotOrSpace { component: String },
#[error("the path component {component:?} is a reserved Windows device name")]
ReservedName { component: String },
#[error("{got:?} is not a single path component")]
NotASingleComponent { got: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PathPlatform {
Windows,
Unix,
}
impl PathPlatform {
pub const NATIVE: Self = if cfg!(windows) {
PathPlatform::Windows
} else {
PathPlatform::Unix
};
#[must_use]
pub const fn separator(self) -> char {
match self {
PathPlatform::Windows => '\\',
PathPlatform::Unix => '/',
}
}
#[must_use]
pub const fn is_separator(self, c: char) -> bool {
match self {
PathPlatform::Windows => c == '\\' || c == '/',
PathPlatform::Unix => c == '/',
}
}
}
impl fmt::Display for PathPlatform {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
PathPlatform::Windows => "windows",
PathPlatform::Unix => "unix",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct LocalAbsolutePath {
text: String,
platform: PathPlatform,
}
impl LocalAbsolutePath {
pub fn new(raw: impl AsRef<str>) -> Result<Self, LocalPathError> {
Self::parse_for(raw, PathPlatform::NATIVE)
}
pub fn parse_for(raw: impl AsRef<str>, platform: PathPlatform) -> Result<Self, LocalPathError> {
let raw = raw.as_ref();
if raw.trim().is_empty() {
return Err(LocalPathError::Empty);
}
if raw.contains('\0') {
return Err(LocalPathError::UnrepresentableCharacter {
component: raw.to_string(),
found: '\0',
});
}
let (prefix, rest) = match platform {
PathPlatform::Windows => windows_prefix(raw)?,
PathPlatform::Unix => unix_prefix(raw)?,
};
let components = normalise_components(raw, rest, platform)?;
if components.is_empty() {
return Err(LocalPathError::RootPath {
got: raw.to_string(),
});
}
let separator = platform.separator();
let mut text = prefix;
for (index, component) in components.iter().enumerate() {
if index > 0 {
text.push(separator);
}
text.push_str(component);
}
Ok(Self { text, platform })
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
#[must_use]
pub fn as_path(&self) -> &Path {
Path::new(&self.text)
}
#[must_use]
pub const fn platform(&self) -> PathPlatform {
self.platform
}
pub fn join_child(&self, name: impl AsRef<str>) -> Result<Self, LocalPathError> {
let name = name.as_ref();
if name.is_empty()
|| name == "."
|| name == ".."
|| name.chars().any(|c| self.platform.is_separator(c))
{
return Err(LocalPathError::NotASingleComponent {
got: name.to_string(),
});
}
validate_component(name, self.platform)?;
let mut text = self.text.clone();
text.push(self.platform.separator());
text.push_str(name);
Ok(Self {
text,
platform: self.platform,
})
}
}
impl fmt::Display for LocalAbsolutePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text)
}
}
impl TryFrom<String> for LocalAbsolutePath {
type Error = LocalPathError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<LocalAbsolutePath> for String {
fn from(value: LocalAbsolutePath) -> Self {
value.text
}
}
impl FromStr for LocalAbsolutePath {
type Err = LocalPathError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
fn unix_prefix(raw: &str) -> Result<(String, &str), LocalPathError> {
match raw.strip_prefix('/') {
Some(rest) => Ok(("/".to_string(), rest)),
None => Err(LocalPathError::NotAbsolute {
got: raw.to_string(),
}),
}
}
fn windows_prefix(raw: &str) -> Result<(String, &str), LocalPathError> {
let mut chars = raw.chars();
let first = chars.next();
let second = chars.next();
if first.is_some_and(|c| PathPlatform::Windows.is_separator(c))
&& second.is_some_and(|c| PathPlatform::Windows.is_separator(c))
{
let rest = &raw[2..];
let mut rest_chars = rest.chars();
let marker = rest_chars.next();
let after = rest_chars.next();
if matches!(marker, Some('?' | '.'))
&& after.is_some_and(|c| PathPlatform::Windows.is_separator(c))
{
return Err(LocalPathError::DeviceNamespace {
got: raw.to_string(),
});
}
return Err(LocalPathError::Unc {
got: raw.to_string(),
});
}
let (Some(drive), Some(':')) = (first.filter(char::is_ascii_alphabetic), second) else {
return Err(LocalPathError::NotAbsolute {
got: raw.to_string(),
});
};
let rest = &raw[drive.len_utf8() + 1..];
match rest.chars().next() {
Some(c) if PathPlatform::Windows.is_separator(c) => {
let prefix = format!("{}:{}", drive.to_ascii_uppercase(), '\\');
Ok((prefix, &rest[c.len_utf8()..]))
}
_ => Err(LocalPathError::NotAbsolute {
got: raw.to_string(),
}),
}
}
fn normalise_components<'a>(
raw: &str,
rest: &'a str,
platform: PathPlatform,
) -> Result<Vec<&'a str>, LocalPathError> {
let mut components = Vec::new();
for component in rest.split(|c| platform.is_separator(c)) {
match component {
"" | "." => continue,
".." => {
return Err(LocalPathError::Traversal {
got: raw.to_string(),
});
}
component => {
validate_component(component, platform)?;
components.push(component);
}
}
}
Ok(components)
}
const WINDOWS_RESERVED_CHARACTERS: [char; 7] = ['<', '>', ':', '"', '|', '?', '*'];
const WINDOWS_RESERVED_NAMES: [&str; 22] = [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
fn validate_component(component: &str, platform: PathPlatform) -> Result<(), LocalPathError> {
if let Some(found) = component.chars().find(|c| *c == '\0') {
return Err(LocalPathError::UnrepresentableCharacter {
component: component.to_string(),
found,
});
}
if platform == PathPlatform::Unix {
return Ok(());
}
if let Some(found) = component
.chars()
.find(|c| WINDOWS_RESERVED_CHARACTERS.contains(c) || c.is_control())
{
return Err(LocalPathError::UnrepresentableCharacter {
component: component.to_string(),
found,
});
}
if component.ends_with(' ') || component.ends_with('.') {
return Err(LocalPathError::TrailingDotOrSpace {
component: component.to_string(),
});
}
let (stem, _) = component.split_once('.').unwrap_or((component, ""));
let stem = stem.trim_end_matches(' ');
if WINDOWS_RESERVED_NAMES
.iter()
.any(|name| stem.eq_ignore_ascii_case(name))
{
return Err(LocalPathError::ReservedName {
component: component.to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use PathPlatform::{Unix, Windows};
fn parse(raw: &str, platform: PathPlatform) -> Result<LocalAbsolutePath, LocalPathError> {
LocalAbsolutePath::parse_for(raw, platform)
}
fn text(raw: &str, platform: PathPlatform) -> String {
parse(raw, platform)
.expect("the fixture is a valid path")
.as_str()
.to_string()
}
#[test]
fn unix_absolute_paths_are_accepted_and_normalised() {
let cases = [
("/srv/rman", "/srv/rman"),
("/srv/rman/", "/srv/rman"),
("//srv///rman//", "/srv/rman"),
("/srv/./rman", "/srv/rman"),
("/srv/rman workspaces", "/srv/rman workspaces"),
("/srv/a\\b", "/srv/a\\b"),
("/rman", "/rman"),
];
for (raw, expected) in cases {
assert_eq!(text(raw, Unix), expected, "input {raw:?}");
}
}
#[test]
fn windows_drive_paths_are_accepted_and_normalised() {
let cases = [
("C:\\rman", "C:\\rman"),
("c:/rman", "C:\\rman"),
("c:\\rman\\", "C:\\rman"),
("D:\\rman\\\\workspaces//x", "D:\\rman\\workspaces\\x"),
("C:\\rman\\.\\slots", "C:\\rman\\slots"),
("Z:/builds/runner root", "Z:\\builds\\runner root"),
];
for (raw, expected) in cases {
assert_eq!(text(raw, Windows), expected, "input {raw:?}");
}
}
#[test]
fn the_windows_default_root_shape_is_representable() {
assert_eq!(text("C:\\rman", Windows), "C:\\rman");
assert_eq!(text("E:\\rman", Windows), "E:\\rman");
}
#[test]
fn relative_paths_are_rejected_on_both_platforms() {
for raw in ["rman", "./rman", "../rman", "srv/rman", ""] {
assert!(
parse(raw, Unix).is_err(),
"unix accepted the relative path {raw:?}"
);
assert!(
parse(raw, Windows).is_err(),
"windows accepted the relative path {raw:?}"
);
}
assert_eq!(
parse("rman", Unix),
Err(LocalPathError::NotAbsolute {
got: "rman".to_string()
})
);
assert_eq!(parse(" ", Unix), Err(LocalPathError::Empty));
}
#[test]
fn windows_rooted_and_drive_relative_paths_are_not_absolute() {
for raw in ["\\rman", "/rman", "C:rman", "C:"] {
assert_eq!(
parse(raw, Windows),
Err(LocalPathError::NotAbsolute {
got: raw.to_string()
}),
"input {raw:?}"
);
}
}
#[test]
fn filesystem_roots_are_rejected() {
for raw in ["/", "/.", "/./"] {
assert_eq!(
parse(raw, Unix),
Err(LocalPathError::RootPath {
got: raw.to_string()
}),
"input {raw:?}"
);
}
for raw in ["C:\\", "c:/", "C:\\.\\", "D:\\\\"] {
assert_eq!(
parse(raw, Windows),
Err(LocalPathError::RootPath {
got: raw.to_string()
}),
"input {raw:?}"
);
}
}
#[test]
fn traversal_is_rejected_rather_than_resolved() {
for raw in ["/srv/../etc", "/srv/rman/..", "/../srv"] {
assert_eq!(
parse(raw, Unix),
Err(LocalPathError::Traversal {
got: raw.to_string()
}),
"input {raw:?}"
);
}
for raw in ["C:\\rman\\..\\Windows", "C:\\..", "C:/rman/../x"] {
assert_eq!(
parse(raw, Windows),
Err(LocalPathError::Traversal {
got: raw.to_string()
}),
"input {raw:?}"
);
}
}
#[test]
fn unc_paths_are_rejected() {
for raw in [
"\\\\nas\\builds",
"//nas/builds",
"\\\\nas\\builds\\rman",
"\\\\127.0.0.1\\c$",
] {
assert_eq!(
parse(raw, Windows),
Err(LocalPathError::Unc {
got: raw.to_string()
}),
"input {raw:?}"
);
}
}
#[test]
fn device_namespace_paths_are_rejected() {
for raw in [
"\\\\?\\C:\\rman",
"\\\\.\\PhysicalDrive0",
"\\\\?\\UNC\\nas\\builds",
"//?/C:/rman",
] {
assert_eq!(
parse(raw, Windows),
Err(LocalPathError::DeviceNamespace {
got: raw.to_string()
}),
"input {raw:?}"
);
}
}
#[test]
fn windows_unrepresentable_components_are_rejected() {
let cases = [
(
"C:\\rman\\a<b",
LocalPathError::UnrepresentableCharacter {
component: "a<b".to_string(),
found: '<',
},
),
(
"C:\\rman\\a|b",
LocalPathError::UnrepresentableCharacter {
component: "a|b".to_string(),
found: '|',
},
),
(
"C:\\rman\\a:b",
LocalPathError::UnrepresentableCharacter {
component: "a:b".to_string(),
found: ':',
},
),
(
"C:\\rman\\slots.",
LocalPathError::TrailingDotOrSpace {
component: "slots.".to_string(),
},
),
(
"C:\\rman\\slots ",
LocalPathError::TrailingDotOrSpace {
component: "slots ".to_string(),
},
),
(
"C:\\rman\\NUL",
LocalPathError::ReservedName {
component: "NUL".to_string(),
},
),
(
"C:\\rman\\com1.txt",
LocalPathError::ReservedName {
component: "com1.txt".to_string(),
},
),
];
for (raw, expected) in cases {
assert_eq!(parse(raw, Windows), Err(expected), "input {raw:?}");
}
}
#[test]
fn an_interior_nul_is_rejected_on_every_platform() {
for platform in [Unix, Windows] {
assert_eq!(
parse("/srv/rm\0an", platform),
Err(LocalPathError::UnrepresentableCharacter {
component: "/srv/rm\0an".to_string(),
found: '\0',
}),
"platform {platform}"
);
}
}
#[test]
fn a_windows_path_is_not_a_unix_path_and_the_reverse() {
assert!(parse("C:\\rman", Unix).is_err());
assert!(parse("/srv/rman", Windows).is_err());
}
#[test]
fn join_child_appends_one_validated_component() {
let root = parse("/srv/rman", Unix).expect("valid root");
assert_eq!(
root.join_child("s1").expect("valid child").as_str(),
"/srv/rman/s1"
);
let root = parse("C:\\rman", Windows).expect("valid root");
assert_eq!(
root.join_child("s12").expect("valid child").as_str(),
"C:\\rman\\s12"
);
}
#[test]
fn join_child_refuses_anything_that_is_not_one_component() {
let root = parse("/srv/rman", Unix).expect("valid root");
for name in ["", ".", "..", "a/b", "/abs"] {
assert_eq!(
root.join_child(name),
Err(LocalPathError::NotASingleComponent {
got: name.to_string()
}),
"child {name:?}"
);
}
let root = parse("C:\\rman", Windows).expect("valid root");
for name in ["a\\b", "a/b", ".."] {
assert_eq!(
root.join_child(name),
Err(LocalPathError::NotASingleComponent {
got: name.to_string()
}),
"child {name:?}"
);
}
assert!(matches!(
root.join_child("NUL"),
Err(LocalPathError::ReservedName { .. })
));
}
fn native_fixture() -> &'static str {
if cfg!(windows) {
"C:\\rman"
} else {
"/srv/rman"
}
}
#[test]
fn the_native_entry_point_uses_the_native_platform() {
let value = LocalAbsolutePath::new(native_fixture()).expect("valid native path");
assert_eq!(value.platform(), PathPlatform::NATIVE);
assert_eq!(value.as_path(), Path::new(value.as_str()));
}
#[test]
fn serde_round_trips_through_the_normalised_string() {
let value = LocalAbsolutePath::new(native_fixture()).expect("valid native path");
let encoded = serde_json::to_string(&value).expect("serialisable");
assert_eq!(
encoded,
serde_json::to_string(value.as_str()).expect("serialisable")
);
let decoded: LocalAbsolutePath = serde_json::from_str(&encoded).expect("deserialisable");
assert_eq!(decoded, value);
}
#[test]
fn deserialising_an_illegal_shape_fails_closed() {
for encoded in ["\"\"", "\"rman\"", "\"\\\\\\\\nas\\\\builds\""] {
assert!(
serde_json::from_str::<LocalAbsolutePath>(encoded).is_err(),
"accepted {encoded}"
);
}
}
#[test]
fn display_and_from_str_agree_with_the_stored_text() {
let value: LocalAbsolutePath = native_fixture().parse().expect("valid native path");
assert_eq!(value.to_string(), value.as_str());
assert_eq!(String::from(value.clone()), value.as_str());
}
}