Skip to main content

sqlite_graphrag/
paths.rs

1//! XDG 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//!
6//! Precedence (G-T-XDG-04): CLI flag `--db` / `db_override` → XDG setting
7//! `db.path` → XDG data dir default `graphrag.sqlite` → cwd fallback.
8//! Product `SQLITE_GRAPHRAG_*` env vars are **not** read.
9
10use crate::config;
11use crate::errors::AppError;
12use crate::i18n::validation;
13use directories::ProjectDirs;
14use std::path::{Component, Path, PathBuf};
15
16/// Resolved filesystem paths used by the CLI at runtime.
17#[derive(Debug, Clone)]
18pub struct AppPaths {
19    /// Absolute path to the SQLite database file.
20    pub db: PathBuf,
21    /// Directory where embedding model files are cached.
22    pub models: PathBuf,
23}
24
25impl AppPaths {
26    pub fn resolve(db_override: Option<&str>) -> Result<Self, AppError> {
27        let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
28            AppError::Io(std::io::Error::other("could not determine home directory"))
29        })?;
30
31        // Cache always under XDG cache (or ProjectDirs); optional XDG setting cache.dir
32        let cache_root = if let Ok(Some(override_dir)) = config::get_setting("cache.dir") {
33            if !override_dir.is_empty() {
34                validate_path(&override_dir)?;
35                PathBuf::from(override_dir)
36            } else {
37                proj.cache_dir().to_path_buf()
38            }
39        } else {
40            proj.cache_dir().to_path_buf()
41        };
42
43        let db = if let Some(p) = db_override {
44            validate_path(p)?;
45            PathBuf::from(p)
46        } else if let Ok(Some(cfg_path)) = config::get_setting("db.path") {
47            if !cfg_path.is_empty() {
48                validate_path(&cfg_path)?;
49                PathBuf::from(cfg_path)
50            } else {
51                default_db_path(&proj)?
52            }
53        } else {
54            default_db_path(&proj)?
55        };
56
57        Ok(Self {
58            db,
59            models: cache_root.join("models"),
60        })
61    }
62
63    pub fn ensure_dirs(&self) -> Result<(), AppError> {
64        for dir in [parent_or_err(&self.db)?, self.models.as_path()] {
65            std::fs::create_dir_all(dir)?;
66        }
67        Ok(())
68    }
69}
70
71fn default_db_path(proj: &ProjectDirs) -> Result<PathBuf, AppError> {
72    // Prefer XDG data dir; fall back to cwd for bare-metal one-shot without home.
73    let data = proj.data_dir();
74    if data.as_os_str().is_empty() {
75        return Ok(std::env::current_dir()
76            .map_err(AppError::Io)?
77            .join("graphrag.sqlite"));
78    }
79    Ok(data.join("graphrag.sqlite"))
80}
81
82fn validate_path(p: &str) -> Result<(), AppError> {
83    if Path::new(p).components().any(|c| c == Component::ParentDir) {
84        return Err(AppError::Validation(validation::path_traversal(p)));
85    }
86    Ok(())
87}
88
89/// Returns the XDG config directory for the application.
90pub fn config_dir() -> Result<PathBuf, AppError> {
91    let proj = ProjectDirs::from("", "", "sqlite-graphrag").ok_or_else(|| {
92        AppError::Io(std::io::Error::other(
93            "could not determine home directory for config",
94        ))
95    })?;
96    Ok(proj.config_dir().to_path_buf())
97}
98
99pub(crate) fn parent_or_err(path: &Path) -> Result<&Path, AppError> {
100    path.parent().ok_or_else(|| {
101        AppError::Validation(format!(
102            "path '{}' has no valid parent component",
103            path.display()
104        ))
105    })
106}
107
108/// Derives a sidecar file path next to the database (e.g. the enrich/ingest
109/// queue), so worklist files follow `--db` instead of the process CWD. Falls
110/// back to the bare filename (CWD) when `db_path` has no parent — preserving the
111/// legacy default-DB layout.
112pub fn sidecar_path(db_path: &Path, filename: &str) -> PathBuf {
113    db_path
114        .parent()
115        .filter(|p| !p.as_os_str().is_empty())
116        .map(|p| p.join(filename))
117        .unwrap_or_else(|| PathBuf::from(filename))
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use tempfile::TempDir;
124
125    #[test]
126    fn flag_overrides_default() {
127        let tmp = TempDir::new().expect("tempdir");
128        let db_flag = tmp.path().join("via-flag.sqlite");
129        let paths = AppPaths::resolve(Some(db_flag.to_str().expect("utf8")))
130            .expect("resolve with flag");
131        assert_eq!(paths.db, db_flag);
132    }
133
134    #[test]
135    fn traversal_in_flag_rejected() {
136        let result = AppPaths::resolve(Some("/tmp/../etc/passwd"));
137        assert!(
138            matches!(result, Err(AppError::Validation(_))),
139            "traversal must fail as Validation, got {result:?}"
140        );
141    }
142
143    #[test]
144    fn default_resolve_ok() {
145        let paths = AppPaths::resolve(None).expect("default resolve");
146        assert!(!paths.db.as_os_str().is_empty());
147        assert!(paths.models.ends_with("models"));
148    }
149
150    #[test]
151    fn sidecar_path_joins_parent() {
152        let p = sidecar_path(Path::new("/data/db/graphrag.sqlite"), "enrich.queue");
153        assert_eq!(p, PathBuf::from("/data/db/enrich.queue"));
154    }
155}