Skip to main content

sloc_core/
pathsafe.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4//! Filesystem-path traversal barrier.
5//!
6//! Filesystem paths in this workbench are frequently assembled from operator-supplied
7//! values — CLI output flags, the web output directory, run identifiers, and git repo
8//! locations. Before such a path reaches a filesystem operation it is passed through
9//! [`reject_traversal`], which refuses any path that contains a parent-directory (`..`)
10//! segment and returns a fresh owned path rebuilt from the validated string. Callers
11//! must use the returned value for the filesystem call so the check dominates the
12//! operation and no `..` sequence can walk outside the intended location.
13
14use std::path::{Path, PathBuf};
15
16/// Reject a path that contains a parent-directory (`..`) segment.
17///
18/// Returns a freshly-owned [`PathBuf`] rebuilt from the validated string when the path
19/// is free of `..` sequences. Callers must use the returned value (not the original) for
20/// the subsequent filesystem operation.
21///
22/// # Errors
23/// Returns an [`std::io::Error`] with kind [`InvalidInput`](std::io::ErrorKind::InvalidInput)
24/// when the path contains a `..` component.
25pub fn reject_traversal(path: &Path) -> std::io::Result<PathBuf> {
26    let text = path.to_string_lossy().into_owned();
27    if text.contains("..") {
28        return Err(std::io::Error::new(
29            std::io::ErrorKind::InvalidInput,
30            "refusing filesystem path with a parent-directory (`..`) segment",
31        ));
32    }
33    Ok(PathBuf::from(text))
34}
35
36#[cfg(test)]
37mod tests {
38    use super::*;
39
40    #[test]
41    fn accepts_plain_relative_path() {
42        let p = Path::new("out/web/report.html");
43        let got = reject_traversal(p).expect("plain path is accepted");
44        assert_eq!(got, PathBuf::from("out/web/report.html"));
45    }
46
47    #[test]
48    fn accepts_absolute_path() {
49        let p = Path::new("/var/lib/oxide-sloc/run.json");
50        let got = reject_traversal(p).expect("absolute path is accepted");
51        assert_eq!(got, PathBuf::from("/var/lib/oxide-sloc/run.json"));
52    }
53
54    #[test]
55    fn accepts_name_with_non_adjacent_dots() {
56        // Dots separated by other characters are not a traversal segment.
57        let p = Path::new("out/web/report.final.html");
58        assert!(reject_traversal(p).is_ok());
59    }
60
61    #[test]
62    fn rejects_unix_traversal() {
63        let p = Path::new("out/../../etc/passwd");
64        assert!(reject_traversal(p).is_err());
65    }
66
67    #[test]
68    fn rejects_windows_traversal() {
69        let p = Path::new(r"out\..\..\secret");
70        assert!(reject_traversal(p).is_err());
71    }
72
73    #[test]
74    fn rejects_leading_traversal() {
75        assert!(reject_traversal(Path::new("../escape")).is_err());
76    }
77
78    #[test]
79    fn error_kind_is_invalid_input() {
80        let err = reject_traversal(Path::new("a/../b")).unwrap_err();
81        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
82    }
83}