Skip to main content

sqlite_graphrag/
paths.rs

1//! XDG/cwd path resolution and traversal-safe overrides.
2//!
3//! Resolves data directories via [`directories::ProjectDirs`] and validates
4//! that user-supplied paths cannot escape the project root.
5
6use crate::errors::AppError;
7use crate::i18n::validation;
8use directories::ProjectDirs;
9use std::path::{Component, Path, PathBuf};
10
11#[derive(Debug)]
12pub struct AppPaths {
13    pub db: PathBuf,
14    pub models: PathBuf,
15}
16
17impl AppPaths {
18    pub fn resolve(db_override: Option<&str>) -> Result<Self, AppError> {
19        let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
20            AppError::Io(std::io::Error::other("could not determine home directory"))
21        })?;
22
23        let cache_root = if let Some(override_dir) = std::env::var_os("SQLITE_GRAPHRAG_CACHE_DIR") {
24            PathBuf::from(override_dir)
25        } else {
26            proj.cache_dir().to_path_buf()
27        };
28
29        let db = if let Some(p) = db_override {
30            validate_path(p)?;
31            PathBuf::from(p)
32        } else if let Ok(env_path) = std::env::var("SQLITE_GRAPHRAG_DB_PATH") {
33            validate_path(&env_path)?;
34            PathBuf::from(env_path)
35        } else if let Some(home_dir) = home_env_dir()? {
36            home_dir.join("graphrag.sqlite")
37        } else {
38            std::env::current_dir()
39                .map_err(AppError::Io)?
40                .join("graphrag.sqlite")
41        };
42
43        Ok(Self {
44            db,
45            models: cache_root.join("models"),
46        })
47    }
48
49    pub fn ensure_dirs(&self) -> Result<(), AppError> {
50        for dir in [parent_or_err(&self.db)?, self.models.as_path()] {
51            std::fs::create_dir_all(dir)?;
52        }
53        Ok(())
54    }
55}
56
57fn validate_path(p: &str) -> Result<(), AppError> {
58    if Path::new(p).components().any(|c| c == Component::ParentDir) {
59        return Err(AppError::Validation(validation::path_traversal(p)));
60    }
61    Ok(())
62}
63
64/// Resolves `SQLITE_GRAPHRAG_HOME` as the root directory for the default database.
65///
66/// Returns `Ok(Some(dir))` when the env var is set and valid,
67/// `Ok(None)` when absent or empty (falls back to `current_dir`),
68/// and `Err(...)` when the value contains traversal components.
69fn home_env_dir() -> Result<Option<PathBuf>, AppError> {
70    let raw = match std::env::var("SQLITE_GRAPHRAG_HOME") {
71        Ok(v) => v,
72        Err(_) => return Ok(None),
73    };
74    if raw.is_empty() {
75        return Ok(None);
76    }
77    validate_path(&raw)?;
78    Ok(Some(PathBuf::from(raw)))
79}
80
81pub(crate) fn parent_or_err(path: &Path) -> Result<&Path, AppError> {
82    path.parent().ok_or_else(|| {
83        AppError::Validation(format!(
84            "path '{}' has no valid parent component",
85            path.display()
86        ))
87    })
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use serial_test::serial;
94    use tempfile::TempDir;
95
96    /// Clears all variables that affect `AppPaths::resolve` to isolate the
97    /// test from the developer/CI environment.
98    fn clean_env_paths() {
99        // SAFETY: tests are annotated with #[serial], guaranteeing single-threaded execution.
100        unsafe {
101            std::env::remove_var("SQLITE_GRAPHRAG_HOME");
102            std::env::remove_var("SQLITE_GRAPHRAG_DB_PATH");
103            std::env::remove_var("SQLITE_GRAPHRAG_CACHE_DIR");
104        }
105    }
106
107    #[test]
108    #[serial]
109    fn home_env_resolves_db_in_subdir() {
110        clean_env_paths();
111        let tmp = TempDir::new().expect("tempdir");
112        // SAFETY: tests are annotated with #[serial], guaranteeing single-threaded execution.
113        unsafe {
114            std::env::set_var("SQLITE_GRAPHRAG_HOME", tmp.path());
115        }
116
117        let paths = AppPaths::resolve(None).expect("resolve with valid HOME");
118        assert_eq!(paths.db, tmp.path().join("graphrag.sqlite"));
119
120        clean_env_paths();
121    }
122
123    #[test]
124    #[serial]
125    fn home_env_traversal_rejected() {
126        clean_env_paths();
127        // SAFETY: tests are annotated with #[serial], guaranteeing single-threaded execution.
128        unsafe {
129            std::env::set_var("SQLITE_GRAPHRAG_HOME", "/tmp/../etc");
130        }
131
132        let result = AppPaths::resolve(None);
133        assert!(
134            matches!(result, Err(AppError::Validation(_))),
135            "traversal in SQLITE_GRAPHRAG_HOME must fail as Validation, got {result:?}"
136        );
137
138        clean_env_paths();
139    }
140
141    #[test]
142    #[serial]
143    fn db_path_overrides_home() {
144        clean_env_paths();
145        let tmp_home = TempDir::new().expect("tempdir home");
146        let tmp_db = TempDir::new().expect("tempdir db");
147        let explicit_db = tmp_db.path().join("explicit.sqlite");
148        // SAFETY: tests are annotated with #[serial], guaranteeing single-threaded execution.
149        unsafe {
150            std::env::set_var("SQLITE_GRAPHRAG_HOME", tmp_home.path());
151            std::env::set_var("SQLITE_GRAPHRAG_DB_PATH", &explicit_db);
152        }
153
154        let paths = AppPaths::resolve(None).expect("resolve with DB_PATH and HOME");
155        assert_eq!(paths.db, explicit_db);
156
157        clean_env_paths();
158    }
159
160    #[test]
161    #[serial]
162    fn flag_overrides_home() {
163        clean_env_paths();
164        let tmp_home = TempDir::new().expect("tempdir home");
165        let tmp_flag = TempDir::new().expect("tempdir flag");
166        let db_flag = tmp_flag.path().join("via-flag.sqlite");
167        // SAFETY: tests are annotated with #[serial], guaranteeing single-threaded execution.
168        unsafe {
169            std::env::set_var("SQLITE_GRAPHRAG_HOME", tmp_home.path());
170        }
171
172        let paths = AppPaths::resolve(Some(db_flag.to_str().expect("utf8")))
173            .expect("resolve with flag and HOME");
174        assert_eq!(paths.db, db_flag);
175
176        clean_env_paths();
177    }
178
179    #[test]
180    #[serial]
181    fn home_env_empty_falls_back_to_cwd() {
182        clean_env_paths();
183        // SAFETY: tests are annotated with #[serial], guaranteeing single-threaded execution.
184        unsafe {
185            std::env::set_var("SQLITE_GRAPHRAG_HOME", "");
186        }
187
188        let paths = AppPaths::resolve(None).expect("resolve with empty HOME");
189        let expected = std::env::current_dir()
190            .expect("cwd")
191            .join("graphrag.sqlite");
192        assert_eq!(paths.db, expected);
193
194        clean_env_paths();
195    }
196
197    #[test]
198    fn parent_or_err_accepts_normal_path() {
199        let p = PathBuf::from("/home/user/db.sqlite");
200        let parent = parent_or_err(&p).expect("valid parent");
201        assert_eq!(parent, Path::new("/home/user"));
202    }
203
204    #[test]
205    fn parent_or_err_accepts_relative_path() {
206        let p = PathBuf::from("subdir/file.sqlite");
207        let parent = parent_or_err(&p).expect("relative parent");
208        assert_eq!(parent, Path::new("subdir"));
209    }
210
211    #[test]
212    fn parent_or_err_rejects_unix_root() {
213        let p = PathBuf::from("/");
214        let result = parent_or_err(&p);
215        assert!(matches!(result, Err(AppError::Validation(_))));
216    }
217
218    #[test]
219    fn parent_or_err_rejects_empty_path() {
220        let p = PathBuf::from("");
221        let result = parent_or_err(&p);
222        assert!(matches!(result, Err(AppError::Validation(_))));
223    }
224}