Skip to main content

agd_core/
path.rs

1//! Which paths agd may touch. [`RootSet`] confines reads and writes to the
2//! configured roots and lists the org and agenda files under them
3//! ([`OrgFiles`]); [`config_dir`] and [`data_dir`] are agd's own directories.
4//!
5//! `..` segments are refused, a symlink is followed only while it stays inside
6//! a root and is never written through, and an entry discovery cannot read is
7//! skipped with a [`SkipReason`] instead of failing the walk.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::path::{Component, Path, PathBuf};
11
12use globset::{Glob, GlobSet, GlobSetBuilder};
13use ignore::WalkBuilder;
14
15use crate::{AgdError, ErrorCode};
16
17/// agd's configuration directory: `$XDG_CONFIG_HOME/agd` when it is set,
18/// else `~/.config/agd` on every Unix including macOS and the platform
19/// configuration directory on Windows.
20pub fn config_dir() -> Result<PathBuf, AgdError> {
21    base_dir("XDG_CONFIG_HOME", ".config", dirs::config_dir).map(|base| base.join("agd"))
22}
23
24/// agd's data directory, which holds the journal: `$XDG_DATA_HOME/agd` when
25/// it is set, else `~/.local/share/agd` on every Unix including macOS and
26/// the platform local data directory on Windows.
27pub fn data_dir() -> Result<PathBuf, AgdError> {
28    base_dir("XDG_DATA_HOME", ".local/share", dirs::data_local_dir).map(|base| base.join("agd"))
29}
30
31fn base_dir(
32    variable: &str,
33    home_relative: &str,
34    windows: fn() -> Option<PathBuf>,
35) -> Result<PathBuf, AgdError> {
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    if cfg!(windows) {
44        return windows().ok_or_else(|| {
45            AgdError::new(
46                ErrorCode::InvalidConfig,
47                format!("cannot determine the Windows directory for {variable}"),
48            )
49        });
50    }
51    let home = dirs::home_dir().ok_or_else(|| {
52        AgdError::new(
53            ErrorCode::InvalidConfig,
54            format!("cannot determine the home directory for ~/{home_relative}; set {variable}"),
55        )
56    })?;
57    Ok(home.join(home_relative))
58}
59
60/// Org files discovered under the roots, plus the paths discovery left out.
61#[derive(Clone, Debug, Default)]
62pub struct OrgFiles {
63    /// Canonical paths, sorted.
64    pub files: Vec<PathBuf>,
65    /// Path as discovered -> why it was left out.
66    pub skipped: BTreeMap<PathBuf, SkipReason>,
67}
68
69/// Why discovery left a path out instead of failing.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub enum SkipReason {
72    /// A symlinked file or directory that resolves outside every root.
73    OutsideRoots,
74    /// An entry the walk could not read, such as a dangling symlink.
75    Unreadable(String),
76}
77
78impl SkipReason {
79    /// The `agd setup check` problem code for this reason.
80    pub fn code(&self) -> &'static str {
81        match self {
82            Self::OutsideRoots => ErrorCode::InvalidPath.as_str(),
83            Self::Unreadable(_) => ErrorCode::WalkFailed.as_str(),
84        }
85    }
86
87    /// The reason alone, for a report that names the path separately. A
88    /// warning says `skipped` before it, so the reason does not.
89    pub fn message(&self) -> String {
90        match self {
91            Self::OutsideRoots => "symlink resolves outside the roots".to_string(),
92            Self::Unreadable(error) => format!("cannot read: {error}"),
93        }
94    }
95
96    /// One line naming the path and the reason, for warnings: `warning:
97    /// skipped <this>`.
98    pub fn describe(&self, path: &Path) -> String {
99        format!("{}: {}", path.display(), self.message())
100    }
101}
102
103#[derive(Clone, Debug)]
104pub struct RootSet {
105    roots: Vec<PathBuf>,
106    agenda: Vec<String>,
107}
108
109impl RootSet {
110    /// `roots` are the only directories agd may read or write; `agenda`
111    /// holds the agenda file patterns (files, directories or globs).
112    pub fn new(roots: &[String], agenda: &[String]) -> Result<Self, AgdError> {
113        let configured_roots = roots;
114        let mut roots = Vec::with_capacity(configured_roots.len());
115        for configured in configured_roots {
116            let expanded = shellexpand::tilde(configured).into_owned();
117            let canonical = PathBuf::from(&expanded).canonicalize().map_err(|error| {
118                AgdError::with_source(
119                    ErrorCode::InvalidRoot,
120                    format!("cannot resolve root '{configured}': {error}"),
121                    error,
122                )
123            })?;
124            if !canonical.is_dir() {
125                return Err(AgdError::new(
126                    ErrorCode::InvalidRoot,
127                    format!("root '{configured}' is not a directory"),
128                ));
129            }
130            roots.push(canonical);
131        }
132        roots.sort();
133        roots.dedup();
134        if roots.is_empty() {
135            return Err(AgdError::new(
136                ErrorCode::InvalidRoot,
137                "roots must contain at least one directory",
138            ));
139        }
140        Ok(Self {
141            roots,
142            agenda: agenda.to_vec(),
143        })
144    }
145
146    pub fn roots(&self) -> &[PathBuf] {
147        &self.roots
148    }
149
150    pub fn contains(&self, path: &Path) -> bool {
151        self.roots.iter().any(|root| path.starts_with(root))
152    }
153
154    /// Whether discovery would list this canonical `.org` path as an agenda
155    /// file: an exact absolute entry, or a pattern matching it relative to
156    /// its root outside hidden directories (which the walk skips). Lets a
157    /// file created after the index loaded join it.
158    pub fn is_agenda_file(&self, canonical: &Path) -> Result<bool, AgdError> {
159        if canonical
160            .extension()
161            .is_none_or(|extension| extension != "org")
162        {
163            return Ok(false);
164        }
165        let mut relative_patterns = Vec::new();
166        for pattern in self.agenda_patterns() {
167            let path = Path::new(&pattern);
168            if !path.is_absolute() {
169                relative_patterns.push(pattern);
170            } else if path.canonicalize().is_ok_and(|exact| exact == canonical) {
171                return Ok(true);
172            }
173        }
174        if relative_patterns.is_empty() {
175            return Ok(false);
176        }
177        let matcher = build_globset(&relative_patterns)?;
178        Ok(self.roots.iter().any(|root| {
179            canonical
180                .strip_prefix(root)
181                .is_ok_and(|relative| !has_hidden_component(relative) && matcher.is_match(relative))
182        }))
183    }
184
185    pub fn resolve_read(&self, requested: &Path) -> Result<PathBuf, AgdError> {
186        reject_parent_segments(requested)?;
187        if requested.is_absolute() {
188            return self.validate_existing_read(requested);
189        }
190
191        let mut matches = BTreeSet::new();
192        for root in &self.roots {
193            let candidate = root.join(requested);
194            if candidate.exists() {
195                matches.insert(self.validate_existing_read(&candidate)?);
196            }
197        }
198        match matches.len() {
199            0 => Err(AgdError::new(
200                ErrorCode::FileNotFound,
201                format!("file is not under a root: {}", requested.display()),
202            )),
203            1 => Ok(matches.into_iter().next().expect("one path")),
204            _ => Err(AgdError::new(
205                ErrorCode::PathConflict,
206                format!(
207                    "relative path matches more than one root: {}",
208                    requested.display()
209                ),
210            )),
211        }
212    }
213
214    pub fn resolve_write(&self, requested: &Path, create: bool) -> Result<PathBuf, AgdError> {
215        reject_parent_segments(requested)?;
216        let lexical = if requested.is_absolute() {
217            requested.to_path_buf()
218        } else if self.roots.len() == 1 {
219            self.roots[0].join(requested)
220        } else {
221            let existing: Vec<PathBuf> = self
222                .roots
223                .iter()
224                .map(|root| root.join(requested))
225                .filter(|path| path.exists())
226                .collect();
227            match existing.as_slice() {
228                [path] => path.clone(),
229                [] if create => {
230                    return Err(AgdError::new(
231                        ErrorCode::PathConflict,
232                        "capture into multiple roots requires an absolute target",
233                    ));
234                }
235                [] => {
236                    return Err(AgdError::new(
237                        ErrorCode::FileNotFound,
238                        format!("file is not under a root: {}", requested.display()),
239                    ));
240                }
241                _ => {
242                    return Err(AgdError::new(
243                        ErrorCode::PathConflict,
244                        format!(
245                            "relative path matches more than one root: {}",
246                            requested.display()
247                        ),
248                    ));
249                }
250            }
251        };
252
253        if let Ok(metadata) = std::fs::symlink_metadata(&lexical)
254            && metadata.file_type().is_symlink()
255        {
256            return Err(AgdError::new(
257                ErrorCode::InvalidPath,
258                format!("writes to symlinks are refused: {}", lexical.display()),
259            ));
260        }
261
262        let parent = lexical.parent().ok_or_else(|| {
263            AgdError::new(
264                ErrorCode::InvalidPath,
265                format!("path has no parent: {}", lexical.display()),
266            )
267        })?;
268        let canonical_parent = nearest_existing(parent)?.canonicalize().map_err(|error| {
269            AgdError::with_source(
270                ErrorCode::InvalidPath,
271                format!("cannot resolve parent of {}: {error}", lexical.display()),
272                error,
273            )
274        })?;
275        if !self.contains(&canonical_parent) {
276            return Err(AgdError::new(
277                ErrorCode::InvalidPath,
278                format!("path is outside the roots: {}", lexical.display()),
279            ));
280        }
281        if lexical.exists() && !lexical.is_file() {
282            return Err(AgdError::new(
283                ErrorCode::InvalidPath,
284                format!("write target is not a regular file: {}", lexical.display()),
285            ));
286        }
287        Ok(lexical)
288    }
289
290    /// The agenda files: every `.org` file under the roots that matches an
291    /// agenda pattern.
292    pub fn agenda_files(&self) -> Result<OrgFiles, AgdError> {
293        self.org_files_matching(&self.agenda_patterns())
294    }
295
296    /// Every `.org` file under the roots, agenda file or not.
297    pub fn all_org_files(&self) -> Result<OrgFiles, AgdError> {
298        self.org_files_matching(&[String::from("**/*.org")])
299    }
300
301    fn agenda_patterns(&self) -> Vec<String> {
302        if self.agenda.is_empty() {
303            vec!["**/*.org".to_string()]
304        } else {
305            self.agenda.clone()
306        }
307    }
308
309    fn org_files_matching(&self, patterns: &[String]) -> Result<OrgFiles, AgdError> {
310        let mut relative_patterns = Vec::new();
311        let mut files = BTreeSet::new();
312        let mut skipped = BTreeMap::new();
313        for pattern in patterns {
314            let path = Path::new(pattern);
315            reject_parent_segments(path)?;
316            if path.is_absolute() {
317                if pattern.contains(['*', '?', '[', ']']) {
318                    return Err(AgdError::new(
319                        ErrorCode::InvalidAgendaPattern,
320                        format!("absolute agenda entries must be exact files: {pattern}"),
321                    ));
322                }
323                let resolved = self.validate_existing_read(path)?;
324                if resolved
325                    .extension()
326                    .is_none_or(|extension| extension != "org")
327                {
328                    return Err(AgdError::new(
329                        ErrorCode::InvalidAgendaFile,
330                        format!("agenda file does not have an .org extension: {pattern}"),
331                    ));
332                }
333                files.insert(resolved);
334            } else {
335                relative_patterns.push(pattern.clone());
336            }
337        }
338        if relative_patterns.is_empty() {
339            return Ok(OrgFiles {
340                files: files.into_iter().collect(),
341                skipped,
342            });
343        }
344
345        let matcher = build_globset(&relative_patterns)?;
346        for root in &self.roots {
347            // Symlinked directories that resolve outside the roots; nothing
348            // below them is read.
349            let mut escaped_directories: Vec<PathBuf> = Vec::new();
350            for entry in WalkBuilder::new(root).follow_links(true).build() {
351                let entry = match entry {
352                    Ok(entry) => entry,
353                    Err(error) if is_emacs_lock(&error) => continue,
354                    Err(error) => match walk_error_path(&error) {
355                        Some(path) => {
356                            skipped.insert(path, SkipReason::Unreadable(error.to_string()));
357                            continue;
358                        }
359                        None => {
360                            return Err(AgdError::new(
361                                ErrorCode::WalkFailed,
362                                format!("cannot walk {}: {error}", root.display()),
363                            ));
364                        }
365                    },
366                };
367                let path = entry.path();
368                if escaped_directories
369                    .iter()
370                    .any(|directory| path.starts_with(directory))
371                {
372                    continue;
373                }
374                let is_directory = entry.file_type().is_some_and(|kind| kind.is_dir());
375                let is_org_file = entry.file_type().is_some_and(|kind| kind.is_file())
376                    && path.extension().is_some_and(|extension| extension == "org");
377                if entry.path_is_symlink()
378                    && (is_directory || is_org_file)
379                    && !self.symlink_target_inside(path)
380                {
381                    skipped.insert(path.to_path_buf(), SkipReason::OutsideRoots);
382                    if is_directory {
383                        escaped_directories.push(path.to_path_buf());
384                    }
385                    continue;
386                }
387                let relative = path.strip_prefix(root).unwrap_or(path);
388                if is_org_file && matcher.is_match(relative) {
389                    files.insert(self.validate_existing_read(path)?);
390                }
391            }
392        }
393        Ok(OrgFiles {
394            files: files.into_iter().collect(),
395            skipped,
396        })
397    }
398
399    fn symlink_target_inside(&self, path: &Path) -> bool {
400        path.canonicalize()
401            .is_ok_and(|canonical| self.contains(&canonical))
402    }
403
404    /// Containment is checked before the file itself is resolved, so a path
405    /// outside the roots is `invalid_path` whether or not it exists.
406    fn validate_existing_read(&self, path: &Path) -> Result<PathBuf, AgdError> {
407        if !self.contains(&resolve_existing_prefix(path)?) {
408            return Err(AgdError::new(
409                ErrorCode::InvalidPath,
410                format!("resolved path is outside the roots: {}", path.display()),
411            ));
412        }
413        let canonical = path.canonicalize().map_err(|error| {
414            AgdError::with_source(
415                ErrorCode::FileNotFound,
416                format!("cannot resolve file {}: {error}", path.display()),
417                error,
418            )
419        })?;
420        if !self.contains(&canonical) {
421            return Err(AgdError::new(
422                ErrorCode::InvalidPath,
423                format!("resolved path is outside the roots: {}", path.display()),
424            ));
425        }
426        if !canonical.is_file() {
427            return Err(AgdError::new(
428                ErrorCode::InvalidPath,
429                format!("path is not a regular file: {}", path.display()),
430            ));
431        }
432        Ok(canonical)
433    }
434}
435
436fn reject_parent_segments(path: &Path) -> Result<(), AgdError> {
437    if path
438        .components()
439        .any(|component| component == Component::ParentDir)
440    {
441        return Err(AgdError::new(
442            ErrorCode::InvalidPath,
443            format!("'..' is not allowed in paths: {}", path.display()),
444        ));
445    }
446    Ok(())
447}
448
449/// `path` with its longest existing prefix canonicalized and the missing
450/// rest appended as written; for an existing path, its canonical form.
451pub(crate) fn resolve_existing_prefix(path: &Path) -> Result<PathBuf, AgdError> {
452    let existing = nearest_existing(path)?;
453    let canonical = existing.canonicalize().map_err(|error| {
454        AgdError::with_source(
455            ErrorCode::InvalidPath,
456            format!("cannot resolve {}: {error}", existing.display()),
457            error,
458        )
459    })?;
460    let rest = path.strip_prefix(existing).unwrap_or(Path::new(""));
461    Ok(if rest.as_os_str().is_empty() {
462        canonical
463    } else {
464        canonical.join(rest)
465    })
466}
467
468/// The entry a walk error is about, when it names one.
469fn walk_error_path(error: &ignore::Error) -> Option<PathBuf> {
470    match error {
471        ignore::Error::WithPath { path, .. } => Some(path.clone()),
472        ignore::Error::WithDepth { err, .. } | ignore::Error::WithLineNumber { err, .. } => {
473            walk_error_path(err)
474        }
475        ignore::Error::Loop { child, .. } => Some(child.clone()),
476        _ => None,
477    }
478}
479
480fn nearest_existing(path: &Path) -> Result<&Path, AgdError> {
481    let mut current = path;
482    loop {
483        if current.exists() {
484            return Ok(current);
485        }
486        current = current.parent().ok_or_else(|| {
487            AgdError::new(
488                ErrorCode::InvalidPath,
489                format!("no existing ancestor for {}", path.display()),
490            )
491        })?;
492    }
493}
494
495/// A component starting with `.`, which the discovery walk never enters.
496fn has_hidden_component(relative: &Path) -> bool {
497    relative
498        .components()
499        .any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
500}
501
502/// The matcher for agenda patterns relative to a root; both callers set
503/// absolute entries aside first.
504fn build_globset(patterns: &[String]) -> Result<GlobSet, AgdError> {
505    let mut builder = GlobSetBuilder::new();
506    for pattern in patterns {
507        reject_parent_segments(Path::new(pattern))?;
508        builder.add(Glob::new(pattern).map_err(|error| {
509            AgdError::new(
510                ErrorCode::InvalidAgendaPattern,
511                format!("invalid agenda pattern '{pattern}': {error}"),
512            )
513        })?);
514    }
515    builder.build().map_err(|error| {
516        AgdError::new(
517            ErrorCode::InvalidAgendaPattern,
518            format!("cannot build agenda patterns: {error}"),
519        )
520    })
521}
522
523/// Emacs marks a buffer with unsaved changes by a dangling `.#name`
524/// symlink; following it fails, but it is never an agenda file.
525fn is_emacs_lock(error: &ignore::Error) -> bool {
526    match error {
527        ignore::Error::WithPath { path, err } => {
528            path.file_name()
529                .is_some_and(|name| name.to_string_lossy().starts_with(".#"))
530                || is_emacs_lock(err)
531        }
532        ignore::Error::WithDepth { err, .. } | ignore::Error::WithLineNumber { err, .. } => {
533            is_emacs_lock(err)
534        }
535        _ => false,
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    fn roots(temp: &tempfile::TempDir) -> RootSet {
544        RootSet::new(&[temp.path().to_string_lossy().into_owned()], &[]).unwrap()
545    }
546
547    #[test]
548    fn rejects_parent_traversal() {
549        let temp = tempfile::tempdir().unwrap();
550        let error = roots(&temp)
551            .resolve_read(Path::new("../secret.org"))
552            .unwrap_err();
553        assert_eq!(error.code().as_str(), "invalid_path");
554    }
555
556    #[test]
557    fn accepts_exact_absolute_agenda_files_inside_roots() {
558        let temp = tempfile::tempdir().unwrap();
559        let file = temp.path().join("tasks.org");
560        std::fs::write(&file, "* TODO Safe\n").unwrap();
561        let roots = RootSet::new(
562            &[temp.path().to_string_lossy().into_owned()],
563            &[file.to_string_lossy().into_owned()],
564        )
565        .unwrap();
566        assert_eq!(
567            roots.agenda_files().unwrap().files,
568            vec![std::fs::canonicalize(file).unwrap()]
569        );
570    }
571
572    #[cfg(unix)]
573    #[test]
574    fn emacs_lock_symlinks_do_not_break_discovery() {
575        use std::os::unix::fs::symlink;
576
577        let temp = tempfile::tempdir().unwrap();
578        let file = temp.path().join("tasks.org");
579        std::fs::write(&file, "* TODO Edited in Emacs\n").unwrap();
580        symlink("user@host.1234:1700000000", temp.path().join(".#tasks.org")).unwrap();
581        assert_eq!(
582            roots(&temp).agenda_files().unwrap().files,
583            vec![std::fs::canonicalize(file).unwrap()]
584        );
585    }
586
587    #[cfg(unix)]
588    #[test]
589    fn follows_only_root_contained_read_symlinks_and_refuses_symlink_writes() {
590        use std::os::unix::fs::symlink;
591
592        let temp = tempfile::tempdir().unwrap();
593        let target = temp.path().join("target.org");
594        std::fs::write(&target, "* TODO Safe\n").unwrap();
595        symlink(&target, temp.path().join("link.org")).unwrap();
596        let roots = roots(&temp);
597        assert_eq!(
598            roots.resolve_read(Path::new("link.org")).unwrap(),
599            std::fs::canonicalize(target).unwrap()
600        );
601        let error = roots
602            .resolve_write(Path::new("link.org"), false)
603            .unwrap_err();
604        assert_eq!(error.code().as_str(), "invalid_path");
605
606        let outside = tempfile::tempdir().unwrap();
607        let outside_file = outside.path().join("outside.org");
608        std::fs::write(&outside_file, "* TODO Secret\n").unwrap();
609        symlink(&outside_file, temp.path().join("escape.org")).unwrap();
610        let error = roots.resolve_read(Path::new("escape.org")).unwrap_err();
611        assert_eq!(error.code().as_str(), "invalid_path");
612    }
613
614    #[cfg(unix)]
615    #[test]
616    fn discovery_skips_and_reports_symlinks_that_leave_the_roots() {
617        use std::os::unix::fs::symlink;
618
619        let temp = tempfile::tempdir().unwrap();
620        let kept = temp.path().join("tasks.org");
621        std::fs::write(&kept, "* TODO Kept\n").unwrap();
622        let outside = tempfile::tempdir().unwrap();
623        std::fs::write(outside.path().join("secret.org"), "* TODO Secret\n").unwrap();
624        symlink(
625            outside.path().join("secret.org"),
626            temp.path().join("escape.org"),
627        )
628        .unwrap();
629        symlink(outside.path(), temp.path().join("elsewhere")).unwrap();
630        symlink(
631            outside.path().join("missing.org"),
632            temp.path().join("dangling.org"),
633        )
634        .unwrap();
635
636        let root = std::fs::canonicalize(temp.path()).unwrap();
637        for found in [
638            roots(&temp).agenda_files().unwrap(),
639            roots(&temp).all_org_files().unwrap(),
640        ] {
641            assert_eq!(found.files, vec![std::fs::canonicalize(&kept).unwrap()]);
642            let skipped: Vec<(&Path, &str)> = found
643                .skipped
644                .iter()
645                .map(|(path, reason)| (path.strip_prefix(&root).unwrap(), reason.code()))
646                .collect();
647            assert_eq!(
648                skipped,
649                [
650                    (Path::new("dangling.org"), "walk_failed"),
651                    (Path::new("elsewhere"), "invalid_path"),
652                    (Path::new("escape.org"), "invalid_path"),
653                ]
654            );
655        }
656        assert_eq!(
657            SkipReason::OutsideRoots.describe(Path::new("escape.org")),
658            "escape.org: symlink resolves outside the roots"
659        );
660    }
661
662    #[test]
663    fn outside_paths_are_outside_roots_whether_or_not_they_exist() {
664        let temp = tempfile::tempdir().unwrap();
665        let outside = tempfile::tempdir().unwrap();
666        std::fs::write(outside.path().join("real.org"), "* TODO Secret\n").unwrap();
667        let roots = roots(&temp);
668        for path in [
669            outside.path().join("real.org"),
670            outside.path().join("missing.org"),
671            outside.path().join("no/such/file.org"),
672        ] {
673            let error = roots.resolve_read(&path).unwrap_err();
674            assert_eq!(error.code().as_str(), "invalid_path", "{}", path.display());
675        }
676        let error = roots
677            .resolve_read(&temp.path().join("missing.org"))
678            .unwrap_err();
679        assert_eq!(error.code().as_str(), "file_not_found");
680    }
681}