scrollcase_consumer/
path.rs1use crate::error::{fail, Result};
11
12pub fn safe_relative_path(value: &str) -> Result<String> {
21 let normalized = value.replace('\\', "/");
22 if normalized.is_empty() || normalized.starts_with('/') || normalized.contains('\0') {
23 fail!("Unsafe relative path: {value}");
24 }
25 if has_drive_prefix(&normalized) {
26 fail!("Unsafe relative path: {value}");
27 }
28 if normalized
29 .split('/')
30 .any(|segment| segment == ".." || segment.is_empty())
31 {
32 fail!("Unsafe relative path: {value}");
33 }
34 Ok(normalized)
35}
36
37fn has_drive_prefix(value: &str) -> bool {
39 let mut characters = value.chars();
40 matches!(
41 (characters.next(), characters.next(), characters.next()),
42 (Some(letter), Some(':'), Some('/')) if letter.is_ascii_alphabetic()
43 )
44}
45
46#[must_use]
48pub fn join_relative(root: &std::path::Path, relative: &str) -> std::path::PathBuf {
49 let mut path = root.to_path_buf();
50 for segment in relative.split('/') {
51 path.push(segment);
52 }
53 path
54}
55
56#[cfg(test)]
57mod tests {
58 use super::safe_relative_path;
59
60 #[test]
61 fn accepts_ordinary_payload_paths() {
62 assert_eq!(safe_relative_path("box.json").unwrap(), "box.json");
63 assert_eq!(
64 safe_relative_path("venv/bin/python").unwrap(),
65 "venv/bin/python"
66 );
67 assert_eq!(
69 safe_relative_path("venv\\python.exe").unwrap(),
70 "venv/python.exe"
71 );
72 }
73
74 #[test]
75 fn refuses_every_way_out_of_the_root() {
76 for value in [
77 "",
78 "/etc/passwd",
79 "C:/windows",
80 "..",
81 "../escape",
82 "venv/../../escape",
83 "venv\\..\\escape",
84 "venv//python",
85 "venv/\0/python",
86 ] {
87 let error = safe_relative_path(value).unwrap_err();
88 assert!(
89 error.message().contains("Unsafe relative path"),
90 "{value} was accepted or misreported: {error}"
91 );
92 }
93 }
94}