Skip to main content

org_core/
paths.rs

1use std::collections::BTreeSet;
2use std::path::{Component, Path, PathBuf};
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use ignore::WalkBuilder;
6
7use crate::AgdError;
8
9/// agd's configuration directory: `$XDG_CONFIG_HOME/agd`, else
10/// `~/.config/agd`, on every Unix including macOS; the platform
11/// configuration directory on Windows.
12pub fn config_dir() -> Result<PathBuf, AgdError> {
13    base_dir("XDG_CONFIG_HOME", ".config", dirs::config_dir).map(|base| base.join("agd"))
14}
15
16/// agd's data directory, which holds the journal: `$XDG_DATA_HOME/agd`, else
17/// `~/.local/share/agd`, on every Unix including macOS; the platform local
18/// data directory on Windows.
19pub fn data_dir() -> Result<PathBuf, AgdError> {
20    base_dir("XDG_DATA_HOME", ".local/share", dirs::data_local_dir).map(|base| base.join("agd"))
21}
22
23fn base_dir(
24    variable: &str,
25    home_relative: &str,
26    windows: fn() -> Option<PathBuf>,
27) -> Result<PathBuf, AgdError> {
28    if cfg!(windows) {
29        return windows().ok_or_else(|| {
30            AgdError::new(
31                "config",
32                format!("cannot determine the Windows directory for {variable}"),
33            )
34        });
35    }
36    // The XDG Base Directory spec says a relative value is invalid and must be ignored.
37    if let Some(value) = std::env::var_os(variable) {
38        let path = PathBuf::from(value);
39        if path.is_absolute() {
40            return Ok(path);
41        }
42    }
43    let home = dirs::home_dir().ok_or_else(|| {
44        AgdError::new(
45            "config",
46            format!("cannot determine the home directory for ~/{home_relative}; set {variable}"),
47        )
48    })?;
49    Ok(home.join(home_relative))
50}
51
52#[derive(Clone, Debug)]
53pub struct RootSet {
54    roots: Vec<PathBuf>,
55    agenda: Vec<String>,
56}
57
58impl RootSet {
59    /// `roots` are the only directories agd may read or write; `agenda`
60    /// holds the agenda file patterns (files, directories or globs).
61    pub fn new(roots: &[String], agenda: &[String]) -> Result<Self, AgdError> {
62        let configured_roots = roots;
63        let mut roots = Vec::with_capacity(configured_roots.len());
64        for configured in configured_roots {
65            let expanded = shellexpand::tilde(configured).into_owned();
66            let canonical = PathBuf::from(&expanded).canonicalize().map_err(|error| {
67                AgdError::with_source(
68                    "invalid_root",
69                    format!("cannot resolve approved root '{configured}': {error}"),
70                    error,
71                )
72            })?;
73            if !canonical.is_dir() {
74                return Err(AgdError::new(
75                    "invalid_root",
76                    format!("approved root is not a directory: {configured}"),
77                ));
78            }
79            roots.push(canonical);
80        }
81        roots.sort();
82        roots.dedup();
83        if roots.is_empty() {
84            return Err(AgdError::new(
85                "invalid_root",
86                "at least one root is required",
87            ));
88        }
89        Ok(Self {
90            roots,
91            agenda: agenda.to_vec(),
92        })
93    }
94
95    pub fn roots(&self) -> &[PathBuf] {
96        &self.roots
97    }
98
99    pub fn contains(&self, path: &Path) -> bool {
100        self.roots.iter().any(|root| path.starts_with(root))
101    }
102
103    pub fn resolve_read(&self, requested: &Path) -> Result<PathBuf, AgdError> {
104        reject_parent_segments(requested)?;
105        if requested.is_absolute() {
106            return self.validate_existing_read(requested);
107        }
108
109        let mut matches = BTreeSet::new();
110        for root in &self.roots {
111            let candidate = root.join(requested);
112            if candidate.exists() {
113                matches.insert(self.validate_existing_read(&candidate)?);
114            }
115        }
116        match matches.len() {
117            0 => Err(AgdError::new(
118                "not_found",
119                format!(
120                    "file is not present under an approved root: {}",
121                    requested.display()
122                ),
123            )),
124            1 => Ok(matches.into_iter().next().expect("one path")),
125            _ => Err(AgdError::new(
126                "ambiguous_path",
127                format!(
128                    "relative path matches multiple approved roots: {}",
129                    requested.display()
130                ),
131            )),
132        }
133    }
134
135    pub fn resolve_write(&self, requested: &Path, create: bool) -> Result<PathBuf, AgdError> {
136        reject_parent_segments(requested)?;
137        let lexical = if requested.is_absolute() {
138            requested.to_path_buf()
139        } else if self.roots.len() == 1 {
140            self.roots[0].join(requested)
141        } else {
142            let existing: Vec<PathBuf> = self
143                .roots
144                .iter()
145                .map(|root| root.join(requested))
146                .filter(|path| path.exists())
147                .collect();
148            match existing.as_slice() {
149                [path] => path.clone(),
150                [] if create => {
151                    return Err(AgdError::new(
152                        "ambiguous_path",
153                        "capture into multiple roots requires an absolute target",
154                    ));
155                }
156                [] => {
157                    return Err(AgdError::new(
158                        "not_found",
159                        format!(
160                            "file is not present under an approved root: {}",
161                            requested.display()
162                        ),
163                    ));
164                }
165                _ => {
166                    return Err(AgdError::new(
167                        "ambiguous_path",
168                        format!(
169                            "relative path matches multiple approved roots: {}",
170                            requested.display()
171                        ),
172                    ));
173                }
174            }
175        };
176
177        if let Ok(metadata) = std::fs::symlink_metadata(&lexical)
178            && metadata.file_type().is_symlink()
179        {
180            return Err(AgdError::new(
181                "symlink_write_refused",
182                format!("writes to symlinks are refused: {}", lexical.display()),
183            ));
184        }
185
186        let parent = lexical.parent().ok_or_else(|| {
187            AgdError::new(
188                "invalid_path",
189                format!("path has no parent: {}", lexical.display()),
190            )
191        })?;
192        let canonical_parent = nearest_existing(parent)?.canonicalize().map_err(|error| {
193            AgdError::with_source(
194                "invalid_path",
195                format!("cannot resolve parent of {}: {error}", lexical.display()),
196                error,
197            )
198        })?;
199        if !self.contains(&canonical_parent) {
200            return Err(AgdError::new(
201                "outside_roots",
202                format!("path escapes approved roots: {}", lexical.display()),
203            ));
204        }
205        if lexical.exists() && !lexical.is_file() {
206            return Err(AgdError::new(
207                "invalid_path",
208                format!("write target is not a regular file: {}", lexical.display()),
209            ));
210        }
211        Ok(lexical)
212    }
213
214    pub fn agenda_files(&self) -> Result<Vec<PathBuf>, AgdError> {
215        let patterns = if self.agenda.is_empty() {
216            vec!["**/*.org".to_string()]
217        } else {
218            self.agenda.clone()
219        };
220        self.org_files_matching(&patterns)
221    }
222
223    pub fn all_org_files(&self) -> Result<Vec<PathBuf>, AgdError> {
224        self.org_files_matching(&[String::from("**/*.org")])
225    }
226
227    fn org_files_matching(&self, patterns: &[String]) -> Result<Vec<PathBuf>, AgdError> {
228        let mut relative_patterns = Vec::new();
229        let mut files = BTreeSet::new();
230        for pattern in patterns {
231            let path = Path::new(pattern);
232            reject_parent_segments(path)?;
233            if path.is_absolute() {
234                if pattern.contains(['*', '?', '[', ']']) {
235                    return Err(AgdError::new(
236                        "invalid_agenda_pattern",
237                        format!("absolute agenda entries must be exact files: {pattern}"),
238                    ));
239                }
240                let resolved = self.validate_existing_read(path)?;
241                if resolved
242                    .extension()
243                    .is_none_or(|extension| extension != "org")
244                {
245                    return Err(AgdError::new(
246                        "invalid_agenda_file",
247                        format!("agenda file does not have an .org extension: {pattern}"),
248                    ));
249                }
250                files.insert(resolved);
251            } else {
252                relative_patterns.push(pattern.clone());
253            }
254        }
255        if relative_patterns.is_empty() {
256            return Ok(files.into_iter().collect());
257        }
258
259        let matcher = build_globset(&relative_patterns)?;
260        for root in &self.roots {
261            for entry in WalkBuilder::new(root).follow_links(true).build() {
262                let entry = match entry {
263                    Ok(entry) => entry,
264                    Err(error) if is_emacs_lock(&error) => continue,
265                    Err(error) => {
266                        return Err(AgdError::new(
267                            "walk_error",
268                            format!("cannot walk {}: {error}", root.display()),
269                        ));
270                    }
271                };
272                if !entry.file_type().is_some_and(|kind| kind.is_file()) {
273                    continue;
274                }
275                let path = entry.path();
276                let relative = path.strip_prefix(root).unwrap_or(path);
277                if path.extension().is_some_and(|extension| extension == "org")
278                    && matcher.is_match(relative)
279                {
280                    files.insert(self.validate_existing_read(path)?);
281                }
282            }
283        }
284        Ok(files.into_iter().collect())
285    }
286
287    fn validate_existing_read(&self, path: &Path) -> Result<PathBuf, AgdError> {
288        let canonical = path.canonicalize().map_err(|error| {
289            AgdError::with_source(
290                "not_found",
291                format!("cannot resolve file {}: {error}", path.display()),
292                error,
293            )
294        })?;
295        if !self.contains(&canonical) {
296            return Err(AgdError::new(
297                "outside_roots",
298                format!("resolved path escapes approved roots: {}", path.display()),
299            ));
300        }
301        if !canonical.is_file() {
302            return Err(AgdError::new(
303                "invalid_path",
304                format!("path is not a regular file: {}", path.display()),
305            ));
306        }
307        Ok(canonical)
308    }
309}
310
311fn reject_parent_segments(path: &Path) -> Result<(), AgdError> {
312    if path
313        .components()
314        .any(|component| component == Component::ParentDir)
315    {
316        return Err(AgdError::new(
317            "path_traversal",
318            format!("'..' is not allowed in paths: {}", path.display()),
319        ));
320    }
321    Ok(())
322}
323
324fn nearest_existing(path: &Path) -> Result<&Path, AgdError> {
325    let mut current = path;
326    loop {
327        if current.exists() {
328            return Ok(current);
329        }
330        current = current.parent().ok_or_else(|| {
331            AgdError::new(
332                "invalid_path",
333                format!("no existing ancestor for {}", path.display()),
334            )
335        })?;
336    }
337}
338
339fn build_globset(patterns: &[String]) -> Result<GlobSet, AgdError> {
340    let mut builder = GlobSetBuilder::new();
341    for pattern in patterns {
342        reject_parent_segments(Path::new(pattern))?;
343        if Path::new(pattern).is_absolute() {
344            return Err(AgdError::new(
345                "invalid_agenda_pattern",
346                format!("agenda pattern must be relative to an approved root: {pattern}"),
347            ));
348        }
349        builder.add(Glob::new(pattern).map_err(|error| {
350            AgdError::new(
351                "invalid_agenda_pattern",
352                format!("invalid agenda pattern '{pattern}': {error}"),
353            )
354        })?);
355    }
356    builder.build().map_err(|error| {
357        AgdError::new(
358            "invalid_agenda_pattern",
359            format!("cannot build agenda patterns: {error}"),
360        )
361    })
362}
363
364/// Emacs marks a buffer with unsaved changes by a dangling `.#name`
365/// symlink; following it fails, but it is never an agenda file.
366fn is_emacs_lock(error: &ignore::Error) -> bool {
367    match error {
368        ignore::Error::WithPath { path, err } => {
369            path.file_name()
370                .is_some_and(|name| name.to_string_lossy().starts_with(".#"))
371                || is_emacs_lock(err)
372        }
373        ignore::Error::WithDepth { err, .. } | ignore::Error::WithLineNumber { err, .. } => {
374            is_emacs_lock(err)
375        }
376        _ => false,
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    fn roots(temp: &tempfile::TempDir) -> RootSet {
385        RootSet::new(&[temp.path().to_string_lossy().into_owned()], &[]).unwrap()
386    }
387
388    #[test]
389    fn rejects_parent_traversal() {
390        let temp = tempfile::tempdir().unwrap();
391        let error = roots(&temp)
392            .resolve_read(Path::new("../secret.org"))
393            .unwrap_err();
394        assert_eq!(error.code(), "path_traversal");
395    }
396
397    #[test]
398    fn accepts_exact_absolute_agenda_files_inside_roots() {
399        let temp = tempfile::tempdir().unwrap();
400        let file = temp.path().join("tasks.org");
401        std::fs::write(&file, "* TODO Safe\n").unwrap();
402        let roots = RootSet::new(
403            &[temp.path().to_string_lossy().into_owned()],
404            &[file.to_string_lossy().into_owned()],
405        )
406        .unwrap();
407        assert_eq!(
408            roots.agenda_files().unwrap(),
409            vec![std::fs::canonicalize(file).unwrap()]
410        );
411    }
412
413    #[cfg(unix)]
414    #[test]
415    fn emacs_lock_symlinks_do_not_break_discovery() {
416        use std::os::unix::fs::symlink;
417
418        let temp = tempfile::tempdir().unwrap();
419        let file = temp.path().join("tasks.org");
420        std::fs::write(&file, "* TODO Edited in Emacs\n").unwrap();
421        symlink("user@host.1234:1700000000", temp.path().join(".#tasks.org")).unwrap();
422        assert_eq!(
423            roots(&temp).agenda_files().unwrap(),
424            vec![std::fs::canonicalize(file).unwrap()]
425        );
426    }
427
428    #[cfg(unix)]
429    #[test]
430    fn follows_only_root_contained_read_symlinks_and_refuses_symlink_writes() {
431        use std::os::unix::fs::symlink;
432
433        let temp = tempfile::tempdir().unwrap();
434        let target = temp.path().join("target.org");
435        std::fs::write(&target, "* TODO Safe\n").unwrap();
436        symlink(&target, temp.path().join("link.org")).unwrap();
437        let roots = roots(&temp);
438        assert_eq!(
439            roots.resolve_read(Path::new("link.org")).unwrap(),
440            std::fs::canonicalize(target).unwrap()
441        );
442        let error = roots
443            .resolve_write(Path::new("link.org"), false)
444            .unwrap_err();
445        assert_eq!(error.code(), "symlink_write_refused");
446
447        let outside = tempfile::tempdir().unwrap();
448        let outside_file = outside.path().join("outside.org");
449        std::fs::write(&outside_file, "* TODO Secret\n").unwrap();
450        symlink(&outside_file, temp.path().join("escape.org")).unwrap();
451        let error = roots.resolve_read(Path::new("escape.org")).unwrap_err();
452        assert_eq!(error.code(), "outside_roots");
453    }
454}