smriti/services/
path_util.rs1use std::path::{Component, Path, PathBuf};
9
10pub fn relative_path_for_storage(p: &Path) -> String {
17 p.to_string_lossy().replace("\\", "/")
18}
19
20pub fn safe_join_relative(root: &Path, stored_relative: &str) -> Result<PathBuf, String> {
25 if stored_relative.is_empty() {
26 return Err("path is empty".to_string());
27 }
28 if stored_relative.contains('\\') {
29 return Err("path must use normalized forward slashes".to_string());
30 }
31
32 let rel = Path::new(stored_relative);
33 let mut clean = PathBuf::new();
34 for component in rel.components() {
35 match component {
36 Component::Normal(part) => clean.push(part),
37 Component::CurDir => {}
38 Component::ParentDir => return Err("path contains parent traversal".to_string()),
39 Component::RootDir | Component::Prefix(_) => {
40 return Err("path must be relative".to_string())
41 }
42 }
43 }
44
45 if clean.as_os_str().is_empty() {
46 return Err("path is empty".to_string());
47 }
48 Ok(root.join(clean))
49}
50
51pub fn safe_existing_path_under_root(
54 root: &Path,
55 stored_relative: &str,
56) -> Result<PathBuf, String> {
57 let joined = safe_join_relative(root, stored_relative)?;
58 let root = root
59 .canonicalize()
60 .map_err(|e| format!("failed to canonicalize root: {e}"))?;
61 let resolved = joined
62 .canonicalize()
63 .map_err(|e| format!("failed to canonicalize path: {e}"))?;
64 if !resolved.starts_with(&root) {
65 return Err("path resolves outside library root".to_string());
66 }
67 Ok(resolved)
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73 use std::path::PathBuf;
74
75 #[test]
76 fn forward_slashes_pass_through() {
77 let s = relative_path_for_storage(&PathBuf::from("subdir/photo.jpg"));
78 assert_eq!(s, "subdir/photo.jpg");
79 }
80
81 #[test]
82 fn backslashes_become_forward_slashes() {
83 let s = relative_path_for_storage(Path::new(r"subdir\photo.jpg"));
86 assert_eq!(s, "subdir/photo.jpg");
87 }
88
89 #[test]
90 fn mixed_separators_normalize() {
91 let s = relative_path_for_storage(Path::new(r"a\b/c\d.jpg"));
92 assert_eq!(s, "a/b/c/d.jpg");
93 }
94
95 #[test]
96 fn safe_join_accepts_normal_relative_paths() {
97 let joined = safe_join_relative(Path::new("/photos"), "2026/IMG_001.jpg").unwrap();
98 assert_eq!(joined, PathBuf::from("/photos/2026/IMG_001.jpg"));
99 }
100
101 #[test]
102 fn safe_join_rejects_escape_paths() {
103 assert!(safe_join_relative(Path::new("/photos"), "../secret").is_err());
104 assert!(safe_join_relative(Path::new("/photos"), "/etc/passwd").is_err());
105 assert!(safe_join_relative(Path::new("/photos"), r"..\secret").is_err());
106 }
107
108 #[test]
109 fn safe_existing_path_rejects_symlink_escape() {
110 let root = tempfile::tempdir().unwrap();
111 let outside = tempfile::tempdir().unwrap();
112 let outside_file = outside.path().join("secret.jpg");
113 std::fs::write(&outside_file, b"secret").unwrap();
114
115 #[cfg(unix)]
116 {
117 std::os::unix::fs::symlink(outside.path(), root.path().join("link")).unwrap();
118 assert!(safe_existing_path_under_root(root.path(), "link/secret.jpg").is_err());
119 }
120
121 #[cfg(windows)]
122 {
123 if std::os::windows::fs::symlink_dir(outside.path(), root.path().join("link")).is_ok() {
124 assert!(safe_existing_path_under_root(root.path(), "link/secret.jpg").is_err());
125 }
126 }
127 }
128}