Skip to main content

wdl_modules/
relative_path.rs

1//! A validated, canonical, NFC-normalized relative path under a module
2//! root.
3//!
4//! [`RelativePath`] enforces the spec's per-path structural rules at
5//! construction time. Once constructed, the value is guaranteed to be a
6//! `/`-separated, lexically-clean path that is not absolute, not Windows
7//! drive-prefixed, free of `\` separators and null bytes, and does not
8//! escape the module root via `..`. Two inputs that name the same logical
9//! file (e.g. `foo/./bar.wdl` and `foo/bar.wdl`, or two distinct Unicode
10//! spellings of `café.wdl`) compare equal once normalized.
11
12use std::path::Path;
13use std::path::PathBuf;
14use std::str::FromStr;
15
16use serde_with::DeserializeFromStr;
17use serde_with::SerializeDisplay;
18use thiserror::Error;
19use unicode_normalization::UnicodeNormalization;
20
21/// An error constructing a [`RelativePath`].
22#[derive(Clone, Debug, Eq, Error, PartialEq)]
23pub enum RelativePathError {
24    /// The path is not encoded as UTF-8.
25    #[error("path is not encoded as UTF-8")]
26    NonUtf8,
27    /// The path is empty.
28    #[error("path is empty")]
29    Empty,
30    /// The path contains a null byte.
31    #[error("path `{0}` contains a null byte")]
32    NullByte(String),
33    /// The path contains Windows path separators.
34    #[error("path `{0}` contains Windows path separators (e.g. `\\`)")]
35    Backslash(String),
36    /// The path is absolute.
37    #[error("path `{0}` cannot be absolute")]
38    Absolute(String),
39    /// The path resolves to nothing after lexical cleanup.
40    #[error("path `{0}` resolves to empty")]
41    ResolvesToEmpty(String),
42    /// The path escapes the module root directory.
43    #[error("path `{0}` escapes the module root directory")]
44    EscapesRoot(String),
45}
46
47/// A validated, canonical, NFC-normalized relative path under a module
48/// root.
49#[derive(
50    Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, SerializeDisplay, DeserializeFromStr,
51)]
52pub struct RelativePath(String);
53
54impl RelativePath {
55    /// Returns the path as a `/`-separated string slice.
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59
60    /// Returns the path as a [`Path`].
61    pub fn as_path(&self) -> &Path {
62        Path::new(&self.0)
63    }
64}
65
66impl AsRef<str> for RelativePath {
67    fn as_ref(&self) -> &str {
68        &self.0
69    }
70}
71
72impl AsRef<Path> for RelativePath {
73    fn as_ref(&self) -> &Path {
74        Path::new(&self.0)
75    }
76}
77
78impl std::fmt::Display for RelativePath {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        self.0.fmt(f)
81    }
82}
83
84impl From<RelativePath> for String {
85    fn from(path: RelativePath) -> Self {
86        path.0
87    }
88}
89
90impl From<RelativePath> for PathBuf {
91    fn from(path: RelativePath) -> Self {
92        path.0.into()
93    }
94}
95
96impl FromStr for RelativePath {
97    type Err = RelativePathError;
98
99    fn from_str(s: &str) -> Result<Self, Self::Err> {
100        if s.is_empty() {
101            return Err(RelativePathError::Empty);
102        }
103        if s.contains('\0') {
104            return Err(RelativePathError::NullByte(s.replace('\0', "\\0")));
105        }
106        if s.contains('\\') {
107            return Err(RelativePathError::Backslash(s.to_string()));
108        }
109        if s.starts_with('/') || crate::starts_with_windows_drive(s) {
110            return Err(RelativePathError::Absolute(s.to_string()));
111        }
112        let cleaned = path_clean::clean(Path::new(s))
113            .into_os_string()
114            .into_string()
115            .map_err(|_| RelativePathError::NonUtf8)?;
116        // `path_clean` produces native separators; normalize back to `/` since
117        // the invariant of `RelativePath` is that the stored form is
118        // `/`-separated. The earlier no-`\` check rejects user-supplied
119        // backslashes, so any backslashes here came from `path_clean` on
120        // Windows.
121        let cleaned = cleaned.replace('\\', "/");
122        if cleaned.is_empty() || cleaned == "." {
123            return Err(RelativePathError::ResolvesToEmpty(s.to_string()));
124        }
125        if cleaned == ".." || cleaned.starts_with("../") {
126            return Err(RelativePathError::EscapesRoot(s.to_string()));
127        }
128        Ok(Self(cleaned.nfc().collect()))
129    }
130}
131
132impl TryFrom<&Path> for RelativePath {
133    type Error = RelativePathError;
134
135    fn try_from(path: &Path) -> Result<Self, Self::Error> {
136        path.to_str().ok_or(RelativePathError::NonUtf8)?.parse()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn accepts_simple_relative_path() {
146        let p = RelativePath::from_str("foo/bar.wdl").unwrap();
147        assert_eq!(p.as_str(), "foo/bar.wdl");
148    }
149
150    #[test]
151    fn cleans_dot_segments() {
152        let p = RelativePath::from_str("./foo/./bar.wdl").unwrap();
153        assert_eq!(p.as_str(), "foo/bar.wdl");
154    }
155
156    #[test]
157    fn cleans_inner_double_dot() {
158        let p = RelativePath::from_str("foo/../bar.wdl").unwrap();
159        assert_eq!(p.as_str(), "bar.wdl");
160    }
161
162    #[test]
163    fn accepts_names_that_start_with_two_dots() {
164        let file = RelativePath::from_str("..config").unwrap();
165        assert_eq!(file.as_str(), "..config");
166
167        let nested = RelativePath::from_str("..foo/bar.wdl").unwrap();
168        assert_eq!(nested.as_str(), "..foo/bar.wdl");
169    }
170
171    #[test]
172    fn collapses_duplicate_separators() {
173        let p = RelativePath::from_str("foo//bar.wdl").unwrap();
174        assert_eq!(p.as_str(), "foo/bar.wdl");
175    }
176
177    #[test]
178    fn nfc_normalizes_on_construction() {
179        let composed = RelativePath::from_str("caf\u{00E9}.wdl").unwrap();
180        let decomposed = RelativePath::from_str("cafe\u{0301}.wdl").unwrap();
181        assert_eq!(composed, decomposed);
182        assert_eq!(composed.as_str(), "caf\u{00E9}.wdl");
183    }
184
185    #[test]
186    fn rejects_per_path_violations() {
187        let err = RelativePath::from_str("").unwrap_err();
188        assert!(matches!(err, RelativePathError::Empty));
189
190        let err = RelativePath::from_str("has\0null").unwrap_err();
191        assert!(matches!(err, RelativePathError::NullByte(_)));
192
193        let err = RelativePath::from_str("a\\b").unwrap_err();
194        assert!(matches!(err, RelativePathError::Backslash(_)));
195
196        let err = RelativePath::from_str("/abs").unwrap_err();
197        assert!(matches!(err, RelativePathError::Absolute(_)));
198
199        let err = RelativePath::from_str("C:/win").unwrap_err();
200        assert!(matches!(err, RelativePathError::Absolute(_)));
201
202        let err = RelativePath::from_str("c:\\win").unwrap_err();
203        assert!(matches!(err, RelativePathError::Backslash(_)));
204
205        let err = RelativePath::from_str(".").unwrap_err();
206        assert!(matches!(err, RelativePathError::ResolvesToEmpty(_)));
207
208        let err = RelativePath::from_str("..").unwrap_err();
209        assert!(matches!(err, RelativePathError::EscapesRoot(_)));
210
211        let err = RelativePath::from_str("../escape").unwrap_err();
212        assert!(matches!(err, RelativePathError::EscapesRoot(_)));
213
214        let err = RelativePath::from_str("a/..").unwrap_err();
215        assert!(matches!(err, RelativePathError::ResolvesToEmpty(_)));
216    }
217
218    #[test]
219    fn error_includes_path() {
220        let err = RelativePath::from_str("/abs/path").unwrap_err();
221        assert!(
222            err.to_string().contains("/abs/path"),
223            "error should include the path: {err}"
224        );
225    }
226
227    #[test]
228    fn round_trips_via_serde() {
229        let p = RelativePath::from_str("foo/bar.wdl").unwrap();
230        let s = serde_json::to_string(&p).unwrap();
231        assert_eq!(s, "\"foo/bar.wdl\"");
232        let back: RelativePath = serde_json::from_str(&s).unwrap();
233        assert_eq!(back, p);
234    }
235
236    #[test]
237    fn deserialize_normalizes_input() {
238        let p: RelativePath = serde_json::from_str("\"./foo/./bar.wdl\"").unwrap();
239        assert_eq!(p.as_str(), "foo/bar.wdl");
240    }
241
242    #[test]
243    fn deserialize_rejects_invalid() {
244        assert!(serde_json::from_str::<RelativePath>("\"/abs\"").is_err());
245    }
246}