Skip to main content

rite_model/
safe_path.rs

1//! Path-safety helpers for confining ceremony-derived names and paths.
2//!
3//! Ceremony files are authored by third parties and may be downloaded and run
4//! by an operator who never inspected them. Any string from a ceremony that
5//! ends up on the filesystem — an artifact id used as an output filename, a
6//! material's `path:` value — is therefore untrusted and must not be allowed to
7//! escape the directory it is meant to live in.
8//!
9//! Two primitives cover the cases that occur in practice:
10//!
11//! - [`validate_component`] / [`safe_join`] for a value that must be a single
12//!   path component (a bare file or directory name). This is the right tool for
13//!   identifiers that become filenames. Because these names are *created* by
14//!   the runtime and a ceremony is portable across operating systems, the rules
15//!   are the intersection of what every platform accepts (see
16//!   [`validate_component`] for the full list).
17//! - [`confine`] for a value that may legitimately be a multi-segment relative
18//!   path but must stay within a known root (e.g. `keys/root.pem` under the
19//!   ceremony directory). This is used for *reading* files that already exist
20//!   on the host, so it only enforces containment, not name portability.
21//!
22//! Both are purely lexical: they never touch the filesystem, so they are safe to
23//! call on paths that do not exist yet and cannot be confused by the current
24//! working directory. Lexical checks do **not** defend against symlinks placed
25//! at the destination — call sites that create files should additionally open
26//! with `create_new` (no-follow, no-clobber) so a pre-planted symlink cannot
27//! redirect the write.
28
29use std::fmt;
30use std::path::{Component, Path, PathBuf};
31
32/// Characters rejected in created filenames beyond the separators.
33///
34/// This is the set Windows disallows in file and directory names, per
35/// Microsoft's "Naming Files, Paths, and Namespaces" documentation; it matches
36/// `DISALLOWED_FILENAME_CHARS` in the `typed-path` crate (v0.12, MIT OR
37/// Apache-2.0) minus the separators and NUL, which are rejected separately.
38/// `:` is the security-relevant one — on NTFS, `name:stream` writes to an
39/// alternate data stream and `C:name` is drive-relative — the rest fail or
40/// misbehave in shells and UI. Rejected on every platform because the name may
41/// be created on one OS and the run directory copied to another.
42const ILLEGAL_FILENAME_CHARS: &[char] = &[':', '?', '*', '"', '>', '<', '|'];
43
44/// Names Windows reserves for devices, matched case-insensitively against the
45/// portion of the name before the first `.` (`NUL.txt` is reserved too).
46///
47/// List adapted from `RESERVED_DEVICE_NAMES` in the `typed-path` crate (v0.12,
48/// MIT OR Apache-2.0), which extends Microsoft's documented set with `COM0` /
49/// `LPT0`. The match-with-extension semantics follow the `sanitize-filename`
50/// crate (v0.6, MIT).
51const WINDOWS_RESERVED_NAMES: &[&str] = &[
52    "CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
53    "COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
54];
55
56/// Maximum filename length in bytes (`NAME_MAX` on Linux, the common floor
57/// across filesystems).
58const MAX_COMPONENT_BYTES: usize = 255;
59
60/// Why a ceremony-supplied name or path was rejected as unsafe.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum PathSafetyError {
63    /// The value was empty.
64    Empty,
65    /// The value was absolute, or carried a root / drive prefix, where a
66    /// relative path was required.
67    NotRelative,
68    /// The value escaped its intended root via one or more `..` components.
69    Traversal,
70    /// A value that had to be a single path component contained a directory
71    /// separator (`/` or `\`).
72    Separator,
73    /// The name contained a character that is not portable in filenames
74    /// (one of `: ? * " > < |`, or a control character).
75    IllegalCharacter(char),
76    /// The name is reserved by Windows for a device (`CON`, `NUL`, `COM1`, …),
77    /// with or without an extension.
78    ReservedName,
79    /// The name ends with a `.` or a space, which Windows strips on creation —
80    /// `key.` and `key` would silently alias the same file.
81    TrailingDotOrSpace,
82    /// The name exceeds 255 bytes, the common filesystem limit for a single
83    /// path component.
84    TooLong,
85}
86
87impl fmt::Display for PathSafetyError {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            PathSafetyError::Empty => write!(f, "path is empty"),
91            PathSafetyError::NotRelative => {
92                write!(f, "path must be relative (no leading '/' or drive prefix)")
93            }
94            PathSafetyError::Traversal => {
95                write!(f, "path escapes its directory via '..'")
96            }
97            PathSafetyError::Separator => {
98                write!(f, "name must not contain a path separator ('/' or '\\')")
99            }
100            PathSafetyError::IllegalCharacter(c) => {
101                write!(f, "name contains {c:?}, which is not portable in filenames")
102            }
103            PathSafetyError::ReservedName => {
104                write!(f, "name is reserved for a device on Windows")
105            }
106            PathSafetyError::TrailingDotOrSpace => {
107                write!(f, "name must not end with '.' or a space")
108            }
109            PathSafetyError::TooLong => {
110                write!(f, "name exceeds {MAX_COMPONENT_BYTES} bytes")
111            }
112        }
113    }
114}
115
116impl std::error::Error for PathSafetyError {}
117
118/// Validate that `name` is a safe, portable single path component: a non-empty
119/// plain file or directory name that every supported platform accepts and that
120/// cannot leave its directory.
121///
122/// Rejected, in order of checking:
123///
124/// - the empty string
125/// - separators `/` and `\`
126/// - `.` and `..`
127/// - characters Windows disallows in names (`: ? * " > < |`) and control
128///   characters (C0, C1, DEL — these also enable terminal-escape spoofing when
129///   a path is echoed)
130/// - Windows reserved device names (`CON`, `NUL`, `COM1`, …), with or without
131///   an extension
132/// - trailing `.` or space (Windows strips them, silently aliasing names)
133/// - names longer than 255 bytes (`NAME_MAX`)
134/// - anything `Path::components` does not parse as exactly one normal
135///   component on the host platform (e.g. a bare drive prefix)
136///
137/// # Errors
138/// Returns a [`PathSafetyError`] describing the first rule the name violates.
139pub fn validate_component(name: &str) -> Result<(), PathSafetyError> {
140    if name.is_empty() {
141        return Err(PathSafetyError::Empty);
142    }
143    if name.contains('/') || name.contains('\\') {
144        return Err(PathSafetyError::Separator);
145    }
146    if name == "." || name == ".." {
147        return Err(PathSafetyError::Traversal);
148    }
149    // `is_control` covers C0 (0x00-0x1F), DEL, and C1 (0x80-0x9F).
150    if let Some(c) = name
151        .chars()
152        .find(|c| c.is_control() || ILLEGAL_FILENAME_CHARS.contains(c))
153    {
154        return Err(PathSafetyError::IllegalCharacter(c));
155    }
156    let stem = name.split('.').next().unwrap_or(name);
157    if WINDOWS_RESERVED_NAMES
158        .iter()
159        .any(|r| r.eq_ignore_ascii_case(stem))
160    {
161        return Err(PathSafetyError::ReservedName);
162    }
163    if name.ends_with(['.', ' ']) {
164        return Err(PathSafetyError::TrailingDotOrSpace);
165    }
166    if name.len() > MAX_COMPONENT_BYTES {
167        return Err(PathSafetyError::TooLong);
168    }
169    // Anything that does not resolve to exactly one "normal" component (e.g. a
170    // bare drive prefix like `C:` on Windows) is not a plain name.
171    let mut components = Path::new(name).components();
172    match (components.next(), components.next()) {
173        (Some(Component::Normal(_)), None) => Ok(()),
174        _ => Err(PathSafetyError::NotRelative),
175    }
176}
177
178/// Returns `true` if `name` is a safe single path component.
179///
180/// Convenience wrapper over [`validate_component`] for boolean checks.
181#[must_use]
182pub fn is_safe_component(name: &str) -> bool {
183    validate_component(name).is_ok()
184}
185
186/// Join a single untrusted `name` onto `base`, guaranteeing the result is a
187/// direct child of `base`.
188///
189/// # Errors
190/// Returns a [`PathSafetyError`] if `name` is not a safe single component (see
191/// [`validate_component`]).
192pub fn safe_join(base: &Path, name: &str) -> Result<PathBuf, PathSafetyError> {
193    validate_component(name)?;
194    Ok(base.join(name))
195}
196
197/// Resolve an untrusted relative `candidate` against `root`, guaranteeing the
198/// result stays within `root`.
199///
200/// Interior `.` and `..` components are normalized lexically; `candidate` is
201/// rejected if it is empty, absolute, or if its `..` components would climb
202/// above `root`. The returned path is `root` with the normalized, confined
203/// remainder appended.
204///
205/// Unlike [`validate_component`], this does not enforce name portability —
206/// it is meant for paths to existing files on the host, where the host's own
207/// rules already applied when the file was created.
208///
209/// # Errors
210/// Returns [`PathSafetyError::Empty`] if `candidate` is empty,
211/// [`PathSafetyError::NotRelative`] if it is absolute or carries a
212/// root/prefix, or [`PathSafetyError::Traversal`] if it escapes `root`.
213pub fn confine(root: &Path, candidate: &Path) -> Result<PathBuf, PathSafetyError> {
214    if candidate.as_os_str().is_empty() {
215        return Err(PathSafetyError::Empty);
216    }
217    if candidate.is_absolute() {
218        return Err(PathSafetyError::NotRelative);
219    }
220
221    let mut relative = PathBuf::new();
222    let mut depth: usize = 0;
223    for component in candidate.components() {
224        match component {
225            Component::Normal(segment) => {
226                relative.push(segment);
227                depth = depth.saturating_add(1);
228            }
229            Component::CurDir => {}
230            Component::ParentDir => {
231                if depth == 0 {
232                    return Err(PathSafetyError::Traversal);
233                }
234                relative.pop();
235                depth = depth.saturating_sub(1);
236            }
237            Component::RootDir | Component::Prefix(_) => {
238                return Err(PathSafetyError::NotRelative);
239            }
240        }
241    }
242
243    Ok(root.join(relative))
244}
245
246#[cfg(test)]
247mod tests {
248    // Several accept/reject vectors below are adapted from the test corpus of
249    // the `sanitize-filename` crate (v0.6, MIT), which in turn derives from
250    // node-sanitize-filename (ISC). The `confine` normalization cases are
251    // adapted from the test suite of the `path-clean` crate (v1.0, MIT OR
252    // Apache-2.0).
253    use super::*;
254
255    #[test]
256    fn accepts_plain_names() {
257        assert!(is_safe_component("wrapped_key"));
258        assert!(is_safe_component("root_ca_cert.pem"));
259        assert!(is_safe_component("share-1"));
260        // Unusual but legal everywhere.
261        assert!(is_safe_component("résumé"));
262        assert!(is_safe_component("semi;colon.js"));
263        assert!(is_safe_component("singlequote'.js"));
264        assert!(is_safe_component("plus+.js"));
265        assert!(is_safe_component(" space at front"));
266        assert!(is_safe_component(".period"));
267    }
268
269    #[test]
270    fn rejects_separators_and_traversal_in_components() {
271        assert_eq!(
272            validate_component("../etc/passwd"),
273            Err(PathSafetyError::Separator)
274        );
275        assert_eq!(validate_component("a/b"), Err(PathSafetyError::Separator));
276        assert_eq!(validate_component("a\\b"), Err(PathSafetyError::Separator));
277        assert_eq!(validate_component(".."), Err(PathSafetyError::Traversal));
278        assert_eq!(validate_component("."), Err(PathSafetyError::Traversal));
279        assert_eq!(validate_component(""), Err(PathSafetyError::Empty));
280    }
281
282    #[test]
283    fn rejects_absolute_component() {
284        // A leading separator makes this contain a separator first.
285        assert_eq!(validate_component("/abs"), Err(PathSafetyError::Separator));
286    }
287
288    #[test]
289    fn rejects_windows_illegal_characters() {
290        // `:` is the dangerous one: NTFS alternate data stream / drive-relative.
291        assert_eq!(
292            validate_component("key.pem:hidden"),
293            Err(PathSafetyError::IllegalCharacter(':'))
294        );
295        assert_eq!(
296            validate_component("C:"),
297            Err(PathSafetyError::IllegalCharacter(':'))
298        );
299        for name in [
300            "col:on.js",
301            "star*.js",
302            "question?.js",
303            "quote\".js",
304            "brack<e>ts.js",
305            "p|pes.js",
306        ] {
307            assert!(
308                matches!(
309                    validate_component(name),
310                    Err(PathSafetyError::IllegalCharacter(_))
311                ),
312                "{name:?} should be rejected"
313            );
314        }
315    }
316
317    #[test]
318    fn rejects_control_characters() {
319        assert_eq!(
320            validate_component("hello\u{0000}world"),
321            Err(PathSafetyError::IllegalCharacter('\u{0000}'))
322        );
323        assert_eq!(
324            validate_component("hello\nworld"),
325            Err(PathSafetyError::IllegalCharacter('\n'))
326        );
327        // ANSI escape, the terminal-spoofing vector.
328        assert_eq!(
329            validate_component("key\u{001b}[2K.pem"),
330            Err(PathSafetyError::IllegalCharacter('\u{001b}'))
331        );
332        // C1 range.
333        assert_eq!(
334            validate_component("a\u{0085}b"),
335            Err(PathSafetyError::IllegalCharacter('\u{0085}'))
336        );
337    }
338
339    #[test]
340    fn rejects_windows_reserved_device_names() {
341        for name in ["CON", "con", "NUL", "nul.txt", "LPT9.asdf", "COM1.tar.gz"] {
342            assert_eq!(
343                validate_component(name),
344                Err(PathSafetyError::ReservedName),
345                "{name:?} should be rejected"
346            );
347        }
348        // Longer names and different stems are fine.
349        assert!(is_safe_component("CONSOLE"));
350        assert!(is_safe_component("null.txt"));
351        assert!(is_safe_component("com.example"));
352        assert!(is_safe_component("communication"));
353    }
354
355    #[test]
356    fn rejects_trailing_dot_or_space() {
357        assert_eq!(
358            validate_component("period."),
359            Err(PathSafetyError::TrailingDotOrSpace)
360        );
361        assert_eq!(
362            validate_component("space at end "),
363            Err(PathSafetyError::TrailingDotOrSpace)
364        );
365        assert_eq!(
366            validate_component("foobar..."),
367            Err(PathSafetyError::TrailingDotOrSpace)
368        );
369    }
370
371    #[test]
372    fn rejects_overlong_names() {
373        let long = "a".repeat(300);
374        assert_eq!(validate_component(&long), Err(PathSafetyError::TooLong));
375        let max = "a".repeat(255);
376        assert!(is_safe_component(&max));
377    }
378
379    #[test]
380    fn safe_join_confines_to_base() {
381        let base = Path::new("/runs/c/artifacts");
382        assert_eq!(
383            safe_join(base, "key.pem").unwrap(),
384            PathBuf::from("/runs/c/artifacts/key.pem")
385        );
386        assert!(safe_join(base, "../../etc/passwd").is_err());
387        assert!(safe_join(base, "/etc/passwd").is_err());
388    }
389
390    #[test]
391    fn confine_allows_subpaths() {
392        let root = Path::new("/ceremony");
393        assert_eq!(
394            confine(root, Path::new("keys/root.pem")).unwrap(),
395            PathBuf::from("/ceremony/keys/root.pem")
396        );
397        // Interior `.`/`..` that stays within root is normalized, not rejected.
398        assert_eq!(
399            confine(root, Path::new("keys/../keys/root.pem")).unwrap(),
400            PathBuf::from("/ceremony/keys/root.pem")
401        );
402        assert_eq!(
403            confine(root, Path::new("./root.pem")).unwrap(),
404            PathBuf::from("/ceremony/root.pem")
405        );
406    }
407
408    #[test]
409    fn confine_normalizes_like_path_clean() {
410        let root = Path::new("/ceremony");
411        // Repeated separators collapse.
412        assert_eq!(
413            confine(root, Path::new("path//to///thing")).unwrap(),
414            PathBuf::from("/ceremony/path/to/thing")
415        );
416        // `.` segments vanish wherever they appear.
417        assert_eq!(
418            confine(root, Path::new("./test/./path")).unwrap(),
419            PathBuf::from("/ceremony/test/path")
420        );
421        assert_eq!(
422            confine(root, Path::new("test/path/.")).unwrap(),
423            PathBuf::from("/ceremony/test/path")
424        );
425        // Trailing separators are ignored.
426        assert_eq!(
427            confine(root, Path::new("test/path/")).unwrap(),
428            PathBuf::from("/ceremony/test/path")
429        );
430        // A path that fully cancels out resolves to the root itself.
431        assert_eq!(
432            confine(root, Path::new("test/path/../../")).unwrap(),
433            PathBuf::from("/ceremony")
434        );
435        assert_eq!(
436            confine(root, Path::new(".")).unwrap(),
437            PathBuf::from("/ceremony")
438        );
439    }
440
441    #[test]
442    fn confine_rejects_escapes_absolute_and_empty() {
443        let root = Path::new("/ceremony");
444        assert_eq!(
445            confine(root, Path::new("../secret")),
446            Err(PathSafetyError::Traversal)
447        );
448        assert_eq!(
449            confine(root, Path::new("keys/../../secret")),
450            Err(PathSafetyError::Traversal)
451        );
452        // Escapes are rejected even when later segments would "come back".
453        assert_eq!(
454            confine(root, Path::new("../ceremony/keys")),
455            Err(PathSafetyError::Traversal)
456        );
457        assert_eq!(
458            confine(root, Path::new("/etc/passwd")),
459            Err(PathSafetyError::NotRelative)
460        );
461        assert_eq!(confine(root, Path::new("")), Err(PathSafetyError::Empty));
462    }
463}