smriti/services/
exclusions.rs1use 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}