Skip to main content

smriti/services/
path_util.rs

1//! Path utilities used by storage layer.
2//!
3//! Stored relative paths are normalized to forward slashes regardless
4//! of the host OS. This lets the same drive be opened on Linux,
5//! Windows, and macOS without the reindexer flagging every photo as
6//! "new" because the stored path string differs by separator.
7
8use std::path::{Component, Path, PathBuf};
9
10/// Convert a relative path into the canonical string form used in the
11/// `photos.file_path` and related columns: forward slashes only.
12///
13/// `Path::join(s)` accepts forward slashes on every platform, so the
14/// read side stays unchanged — only the storage side needs to settle
15/// on one form.
16pub fn relative_path_for_storage(p: &Path) -> String {
17    p.to_string_lossy().replace("\\", "/")
18}
19
20/// Join a DB-stored relative path to a trusted root.
21///
22/// Database rows live on a user-writable drive, so callers must not
23/// blindly `root.join(file_path)` before opening or deleting files.
24pub 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
51/// Resolve an existing DB-stored path and prove it still lives under
52/// the trusted root after following symlinks.
53pub 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        // Use a string with backslashes directly — PathBuf parsing on
84        // Linux preserves them as part of the file name component.
85        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}