Skip to main content

smriti/services/
exclusions.rs

1//! Folder exclusion matching and path normalization.
2
3use std::path::{Component, Path};
4
5use rusqlite::Connection;
6
7use crate::db::ExcludedFolderRepo;
8use crate::services::path_util::relative_path_for_storage;
9
10#[derive(Debug, Clone, Default)]
11pub struct ExclusionMatcher {
12    relative_paths: Vec<String>,
13}
14
15impl ExclusionMatcher {
16    pub fn empty() -> Self {
17        Self::default()
18    }
19
20    pub fn from_db(conn: &Connection) -> rusqlite::Result<Self> {
21        let paths = ExcludedFolderRepo::new(conn).relative_paths()?;
22        Ok(Self::new(paths))
23    }
24
25    pub fn new(relative_paths: Vec<String>) -> Self {
26        let mut relative_paths: Vec<String> = relative_paths
27            .into_iter()
28            .filter_map(|p| normalize_stored_relative(&p).ok())
29            .collect();
30        relative_paths.sort();
31        relative_paths.dedup();
32        Self { relative_paths }
33    }
34
35    pub fn is_excluded(&self, stored_relative_path: &str) -> bool {
36        self.relative_paths
37            .iter()
38            .any(|excluded| path_is_self_or_descendant(stored_relative_path, excluded))
39    }
40
41    pub fn should_skip_path(&self, root: &Path, path: &Path) -> bool {
42        let Ok(relative) = path.strip_prefix(root) else {
43            return false;
44        };
45        let stored = relative_path_for_storage(relative);
46        !stored.is_empty() && self.is_excluded(&stored)
47    }
48}
49
50pub fn normalize_stored_relative(path: &str) -> Result<String, String> {
51    let path = path.trim().replace('\\', "/");
52    if path.is_empty() {
53        return Err("path is empty".into());
54    }
55
56    let mut parts = Vec::new();
57    for component in Path::new(&path).components() {
58        match component {
59            Component::Normal(part) => {
60                let s = part
61                    .to_str()
62                    .ok_or_else(|| "path must be valid UTF-8".to_string())?;
63                if !s.is_empty() {
64                    parts.push(s.to_string());
65                }
66            }
67            Component::CurDir => {}
68            Component::ParentDir => return Err("path contains parent traversal".into()),
69            Component::RootDir | Component::Prefix(_) => return Err("path must be relative".into()),
70        }
71    }
72
73    if parts.is_empty() {
74        return Err("path is empty".into());
75    }
76    Ok(parts.join("/"))
77}
78
79pub fn path_is_self_or_descendant(path: &str, folder: &str) -> bool {
80    path == folder
81        || path
82            .strip_prefix(folder)
83            .is_some_and(|suffix| suffix.starts_with('/'))
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn matcher_skips_descendants_not_similar_prefixes() {
92        let matcher = ExclusionMatcher::new(vec!["Trips/Goa".into()]);
93
94        assert!(matcher.is_excluded("Trips/Goa"));
95        assert!(matcher.is_excluded("Trips/Goa/a.jpg"));
96        assert!(matcher.is_excluded("Trips/Goa/Sub/b.jpg"));
97        assert!(!matcher.is_excluded("Trips/Goa2/c.jpg"));
98        assert!(!matcher.is_excluded("Other/Goa/d.jpg"));
99    }
100
101    #[test]
102    fn normalization_rejects_unsafe_paths() {
103        assert_eq!(
104            normalize_stored_relative(r"Trips\Goa/./A").unwrap(),
105            "Trips/Goa/A"
106        );
107        assert!(normalize_stored_relative("../Goa").is_err());
108        assert!(normalize_stored_relative("").is_err());
109    }
110}