greentic_component/
path_safety.rs

1use std::io::ErrorKind;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5
6/// Normalize a user-supplied path and ensure it stays within an allowed root.
7/// Reject absolute paths and any that escape via `..`.
8pub fn normalize_under_root(root: &Path, candidate: &Path) -> Result<PathBuf> {
9    if candidate.is_absolute() {
10        anyhow::bail!("absolute paths are not allowed: {}", candidate.display());
11    }
12
13    let root = root
14        .canonicalize()
15        .with_context(|| format!("failed to canonicalize root {}", root.display()))?;
16
17    // Join and canonicalize. If the full path does not exist yet (e.g., we are
18    // about to create it), canonicalize the nearest existing ancestor instead.
19    let joined = root.join(candidate);
20    let canon =
21        match joined.canonicalize() {
22            Ok(path) => path,
23            Err(err) if err.kind() == ErrorKind::NotFound => {
24                let mut missing: Vec<PathBuf> = Vec::new();
25                let mut ancestor = joined.as_path();
26                loop {
27                    if ancestor.try_exists().unwrap_or(false) {
28                        break;
29                    }
30                    let parent = ancestor
31                        .parent()
32                        .with_context(|| format!("{} has no parent", ancestor.display()))?;
33                    missing.push(ancestor.file_name().map(PathBuf::from).with_context(|| {
34                        format!("{} missing final component", ancestor.display())
35                    })?);
36                    ancestor = parent;
37                }
38
39                let mut rebuilt = ancestor
40                    .canonicalize()
41                    .with_context(|| format!("failed to canonicalize {}", ancestor.display()))?;
42                while let Some(component) = missing.pop() {
43                    rebuilt.push(component);
44                }
45                rebuilt
46            }
47            Err(err) => {
48                return Err(err)
49                    .with_context(|| format!("failed to canonicalize {}", joined.display()));
50            }
51        };
52
53    // Ensure the canonical path is still under root
54    if !canon.starts_with(&root) {
55        anyhow::bail!(
56            "path escapes root ({}): {}",
57            root.display(),
58            canon.display()
59        );
60    }
61
62    Ok(canon)
63}