Skip to main content

unifier/
scope.rs

1//! Path validation: chroot names and subtree confinement.
2
3use std::path::{Component, Path, PathBuf};
4
5use crate::error::{Error, Result};
6
7/// A single path segment safe for use as a chroot name.
8pub fn validate_chroot_name(name: &str) -> Result<()> {
9    validate_segment(name, "chroot name")
10}
11
12/// Resolve `relative` under `root`, rejecting `..` and paths that escape `root`.
13pub fn resolve_under_root(root: &Path, relative: &str) -> Result<PathBuf> {
14    if relative.is_empty() {
15        return Err(Error::msg("path must not be empty"));
16    }
17    for component in Path::new(relative).components() {
18        match component {
19            Component::Normal(_) | Component::CurDir => {}
20            Component::ParentDir => {
21                return Err(Error::msg("path must not contain '..'"));
22            }
23            Component::RootDir | Component::Prefix(_) => {
24                return Err(Error::msg("path must be relative to the store root"));
25            }
26        }
27    }
28
29    let mut resolved = root_canonical(root)?;
30    for component in Path::new(relative).components() {
31        if let Component::Normal(part) = component {
32            resolved.push(part);
33        }
34    }
35    Ok(resolved)
36}
37
38/// True when `path` is the same as or nested under `root`.
39pub fn path_within_root(root: &Path, path: &Path) -> Result<bool> {
40    let root = root_canonical(root)?;
41    let path = if path.exists() {
42        path.canonicalize().map_err(crate::Error::from)?
43    } else {
44        normalize_under_root(&root, path)?
45    };
46    Ok(path.starts_with(&root))
47}
48
49fn root_canonical(root: &Path) -> Result<PathBuf> {
50    match root.canonicalize() {
51        Ok(p) => Ok(p),
52        Err(_) => Ok(root.to_path_buf()),
53    }
54}
55
56fn normalize_under_root(root: &Path, path: &Path) -> Result<PathBuf> {
57    if path.is_absolute() {
58        return Ok(path.to_path_buf());
59    }
60    let mut resolved = root.to_path_buf();
61    for component in path.components() {
62        match component {
63            Component::Normal(part) => resolved.push(part),
64            Component::CurDir => {}
65            Component::ParentDir => return Err(Error::msg("path escapes store root")),
66            Component::RootDir | Component::Prefix(_) => {}
67        }
68    }
69    Ok(resolved)
70}
71
72fn validate_segment(segment: &str, label: &str) -> Result<()> {
73    if segment.is_empty() || segment.contains('/') || segment.contains("..") {
74        return Err(Error::msg(format!("invalid {label}: {segment}")));
75    }
76    Ok(())
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn rejects_parent_dir_in_relative() {
85        let root = PathBuf::from("/tmp/unifier");
86        assert!(resolve_under_root(&root, "../etc/passwd").is_err());
87    }
88}