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}