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::Deserialize;
17use serde::Serialize;
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(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
50#[serde(into = "String", try_from = "String")]
51pub struct RelativePath(String);
52
53impl RelativePath {
54    /// Returns the path as a `/`-separated string slice.
55    pub fn as_str(&self) -> &str {
56        &self.0
57    }
58
59    /// Returns the path as a [`Path`].
60    pub fn as_path(&self) -> &Path {
61        Path::new(&self.0)
62    }
63}
64
65impl AsRef<str> for RelativePath {
66    fn as_ref(&self) -> &str {
67        &self.0
68    }
69}
70
71impl AsRef<Path> for RelativePath {
72    fn as_ref(&self) -> &Path {
73        Path::new(&self.0)
74    }
75}
76
77impl std::fmt::Display for RelativePath {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        std::fmt::Display::fmt(&self.0, f)
80    }
81}
82
83impl From<RelativePath> for String {
84    fn from(path: RelativePath) -> Self {
85        path.0
86    }
87}
88
89impl From<RelativePath> for PathBuf {
90    fn from(path: RelativePath) -> Self {
91        path.0.into()
92    }
93}
94
95impl TryFrom<&Path> for RelativePath {
96    type Error = RelativePathError;
97
98    fn try_from(path: &Path) -> Result<Self, Self::Error> {
99        let s = path.to_str().ok_or(RelativePathError::NonUtf8)?;
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)
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<PathBuf> for RelativePath {
133    type Error = RelativePathError;
134
135    fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
136        Self::try_from(path.as_path())
137    }
138}
139
140impl TryFrom<String> for RelativePath {
141    type Error = RelativePathError;
142
143    fn try_from(s: String) -> Result<Self, Self::Error> {
144        Self::try_from(Path::new(&s))
145    }
146}
147
148impl FromStr for RelativePath {
149    type Err = RelativePathError;
150
151    fn from_str(s: &str) -> Result<Self, Self::Err> {
152        Self::try_from(Path::new(s))
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn accepts_simple_relative_path() {
162        let p = RelativePath::from_str("foo/bar.wdl").unwrap();
163        assert_eq!(p.as_str(), "foo/bar.wdl");
164    }
165
166    #[test]
167    fn cleans_dot_segments() {
168        let p = RelativePath::from_str("./foo/./bar.wdl").unwrap();
169        assert_eq!(p.as_str(), "foo/bar.wdl");
170    }
171
172    #[test]
173    fn cleans_inner_double_dot() {
174        let p = RelativePath::from_str("foo/../bar.wdl").unwrap();
175        assert_eq!(p.as_str(), "bar.wdl");
176    }
177
178    #[test]
179    fn accepts_names_that_start_with_two_dots() {
180        let file = RelativePath::from_str("..config").unwrap();
181        assert_eq!(file.as_str(), "..config");
182
183        let nested = RelativePath::from_str("..foo/bar.wdl").unwrap();
184        assert_eq!(nested.as_str(), "..foo/bar.wdl");
185    }
186
187    #[test]
188    fn collapses_duplicate_separators() {
189        let p = RelativePath::from_str("foo//bar.wdl").unwrap();
190        assert_eq!(p.as_str(), "foo/bar.wdl");
191    }
192
193    #[test]
194    fn nfc_normalizes_on_construction() {
195        let composed = RelativePath::from_str("caf\u{00E9}.wdl").unwrap();
196        let decomposed = RelativePath::from_str("cafe\u{0301}.wdl").unwrap();
197        assert_eq!(composed, decomposed);
198        assert_eq!(composed.as_str(), "caf\u{00E9}.wdl");
199    }
200
201    #[test]
202    fn rejects_per_path_violations() {
203        let err = RelativePath::from_str("").unwrap_err();
204        assert!(matches!(err, RelativePathError::Empty));
205
206        let err = RelativePath::from_str("has\0null").unwrap_err();
207        assert!(matches!(err, RelativePathError::NullByte(_)));
208
209        let err = RelativePath::from_str("a\\b").unwrap_err();
210        assert!(matches!(err, RelativePathError::Backslash(_)));
211
212        let err = RelativePath::from_str("/abs").unwrap_err();
213        assert!(matches!(err, RelativePathError::Absolute(_)));
214
215        let err = RelativePath::from_str("C:/win").unwrap_err();
216        assert!(matches!(err, RelativePathError::Absolute(_)));
217
218        let err = RelativePath::from_str("c:\\win").unwrap_err();
219        assert!(matches!(err, RelativePathError::Backslash(_)));
220
221        let err = RelativePath::from_str(".").unwrap_err();
222        assert!(matches!(err, RelativePathError::ResolvesToEmpty(_)));
223
224        let err = RelativePath::from_str("..").unwrap_err();
225        assert!(matches!(err, RelativePathError::EscapesRoot(_)));
226
227        let err = RelativePath::from_str("../escape").unwrap_err();
228        assert!(matches!(err, RelativePathError::EscapesRoot(_)));
229
230        let err = RelativePath::from_str("a/..").unwrap_err();
231        assert!(matches!(err, RelativePathError::ResolvesToEmpty(_)));
232    }
233
234    #[test]
235    fn error_includes_path() {
236        let err = RelativePath::from_str("/abs/path").unwrap_err();
237        assert!(
238            err.to_string().contains("/abs/path"),
239            "error should include the path: {err}"
240        );
241    }
242
243    #[test]
244    fn round_trips_via_serde() {
245        let p = RelativePath::from_str("foo/bar.wdl").unwrap();
246        let s = serde_json::to_string(&p).unwrap();
247        assert_eq!(s, "\"foo/bar.wdl\"");
248        let back: RelativePath = serde_json::from_str(&s).unwrap();
249        assert_eq!(back, p);
250    }
251
252    #[test]
253    fn deserialize_normalizes_input() {
254        let p: RelativePath = serde_json::from_str("\"./foo/./bar.wdl\"").unwrap();
255        assert_eq!(p.as_str(), "foo/bar.wdl");
256    }
257
258    #[test]
259    fn deserialize_rejects_invalid() {
260        assert!(serde_json::from_str::<RelativePath>("\"/abs\"").is_err());
261    }
262}