unifier-cli 0.5.0

Filesystem postbox for inter-process communication via a Unix tree
Documentation
//! Path validation: chroot names and subtree confinement.

use std::path::{Component, Path, PathBuf};

use crate::error::{Error, Result};

/// A single path segment safe for use as a chroot name.
pub fn validate_chroot_name(name: &str) -> Result<()> {
    validate_segment(name, "chroot name")
}

/// Resolve `relative` under `root`, rejecting `..` and paths that escape `root`.
pub fn resolve_under_root(root: &Path, relative: &str) -> Result<PathBuf> {
    if relative.is_empty() {
        return Err(Error::msg("path must not be empty"));
    }
    for component in Path::new(relative).components() {
        match component {
            Component::Normal(_) | Component::CurDir => {}
            Component::ParentDir => {
                return Err(Error::msg("path must not contain '..'"));
            }
            Component::RootDir | Component::Prefix(_) => {
                return Err(Error::msg("path must be relative to the store root"));
            }
        }
    }

    let mut resolved = root_canonical(root)?;
    for component in Path::new(relative).components() {
        if let Component::Normal(part) = component {
            resolved.push(part);
        }
    }
    Ok(resolved)
}

/// True when `path` is the same as or nested under `root`.
pub fn path_within_root(root: &Path, path: &Path) -> Result<bool> {
    let root = root_canonical(root)?;
    let path = if path.exists() {
        path.canonicalize().map_err(crate::Error::from)?
    } else {
        normalize_under_root(&root, path)?
    };
    Ok(path.starts_with(&root))
}

fn root_canonical(root: &Path) -> Result<PathBuf> {
    match root.canonicalize() {
        Ok(p) => Ok(p),
        Err(_) => Ok(root.to_path_buf()),
    }
}

fn normalize_under_root(root: &Path, path: &Path) -> Result<PathBuf> {
    if path.is_absolute() {
        return Ok(path.to_path_buf());
    }
    let mut resolved = root.to_path_buf();
    for component in path.components() {
        match component {
            Component::Normal(part) => resolved.push(part),
            Component::CurDir => {}
            Component::ParentDir => return Err(Error::msg("path escapes store root")),
            Component::RootDir | Component::Prefix(_) => {}
        }
    }
    Ok(resolved)
}

fn validate_segment(segment: &str, label: &str) -> Result<()> {
    if segment.is_empty() || segment.contains('/') || segment.contains("..") {
        return Err(Error::msg(format!("invalid {label}: {segment}")));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_parent_dir_in_relative() {
        let root = PathBuf::from("/tmp/unifier");
        assert!(resolve_under_root(&root, "../etc/passwd").is_err());
    }
}