1use 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
17pub fn config_dir() -> Result<PathBuf, AgdError> {
21 base_dir("XDG_CONFIG_HOME", ".config", dirs::config_dir).map(|base| base.join("agd"))
22}
23
24pub 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 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#[derive(Clone, Debug, Default)]
62pub struct OrgFiles {
63 pub files: Vec<PathBuf>,
65 pub skipped: BTreeMap<PathBuf, SkipReason>,
67}
68
69#[derive(Clone, Debug, Eq, PartialEq)]
71pub enum SkipReason {
72 OutsideRoots,
74 Unreadable(String),
76}
77
78impl SkipReason {
79 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 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 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 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 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 pub fn agenda_files(&self) -> Result<OrgFiles, AgdError> {
293 self.org_files_matching(&self.agenda_patterns())
294 }
295
296 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 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 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
449pub(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
468fn 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
495fn has_hidden_component(relative: &Path) -> bool {
497 relative
498 .components()
499 .any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
500}
501
502fn 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
523fn 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}