1use std::collections::BTreeSet;
2use std::path::{Component, Path, PathBuf};
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use ignore::WalkBuilder;
6
7use crate::AgdError;
8
9pub fn config_dir() -> Result<PathBuf, AgdError> {
13 base_dir("XDG_CONFIG_HOME", ".config", dirs::config_dir).map(|base| base.join("agd"))
14}
15
16pub 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 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 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
364fn 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}