llman-core 0.0.77

Foundation utility layer for llman (fs/path/managed-block/env-safety/git/schema plumbing).
Documentation
//! Path validation and utility functions

use anyhow::{Result as AnyhowResult, bail};
use std::fs;
use std::path::{Path, PathBuf};

/// Compute a relative path from `from_dir` to `to`.
///
/// Returns `None` when either path is not absolute, or when a relative path
/// cannot be expressed (for example, differing Windows drive letters).
pub fn relative_path_from_dir(from_dir: &Path, to: &Path) -> Option<PathBuf> {
    if !from_dir.is_absolute() || !to.is_absolute() {
        return None;
    }

    let from_components: Vec<_> = from_dir.components().collect();
    let to_components: Vec<_> = to.components().collect();

    #[cfg(windows)]
    {
        use std::path::Component;

        let from_prefix = match from_components.first() {
            Some(Component::Prefix(prefix)) => Some(prefix.kind()),
            _ => None,
        };
        let to_prefix = match to_components.first() {
            Some(Component::Prefix(prefix)) => Some(prefix.kind()),
            _ => None,
        };

        if from_prefix != to_prefix {
            return None;
        }
    }

    let mut common_len = 0usize;
    while common_len < from_components.len()
        && common_len < to_components.len()
        && from_components[common_len] == to_components[common_len]
    {
        common_len += 1;
    }

    let mut out = PathBuf::new();

    // For each remaining segment in `from_dir`, go up one level.
    for _ in common_len..from_components.len() {
        out.push("..");
    }

    // Then descend into the remaining segments of `to`.
    for comp in &to_components[common_len..] {
        match comp {
            std::path::Component::Prefix(_) | std::path::Component::RootDir => return None,
            _ => out.push(comp.as_os_str()),
        }
    }

    if out.as_os_str().is_empty() {
        out.push(".");
    }

    Some(out)
}

/// Validates that a path string is non-empty and does not contain unsafe components.
///
/// This is for multi-segment filesystem paths (config dirs, output dirs). For a single
/// id/file stem, use [`validate_path_segment`] instead.
pub fn validate_path_str(path_str: &str) -> Result<(), String> {
    let trimmed = path_str.trim();
    if trimmed.is_empty() {
        return Err("Path cannot be empty or contain only whitespace".to_string());
    }
    if trimmed.contains('\0') {
        return Err("Path must not contain NUL".to_string());
    }
    for component in Path::new(trimmed).components() {
        if matches!(component, std::path::Component::ParentDir) {
            return Err("Path must not contain '..'".to_string());
        }
    }
    Ok(())
}

/// Creates a PathBuf from a string after [`validate_path_str`].
pub fn create_validated_pathbuf(path_str: &str) -> Result<PathBuf, String> {
    validate_path_str(path_str)?;
    Ok(PathBuf::from(path_str.trim()))
}

/// Safely gets the parent directory for creating directories.
/// Returns None for paths that don't need directory creation (like "config.yaml" in current dir)
pub fn safe_parent_for_creation(path: &Path) -> Option<&Path> {
    path.parent().filter(|p| !p.as_os_str().is_empty())
}

/// True when the path itself is a symlink (callers use it on symlinked dirs).
pub fn is_symlink_dir(path: &Path) -> bool {
    fs::symlink_metadata(path)
        .map(|meta| meta.file_type().is_symlink())
        .unwrap_or(false)
}

/// Checks if a path looks like a filename (no directory components)
pub fn is_just_filename(path: &Path) -> bool {
    path.parent().is_some_and(|p| p.as_os_str().is_empty())
}

/// Validates that a string is safe to use as a single path segment (e.g. an id or file stem).
///
/// Returns the trimmed segment on success.
pub fn validate_path_segment(segment: &str, what: &str) -> AnyhowResult<String> {
    let trimmed = segment.trim();
    if trimmed.is_empty() {
        bail!("{what} is required");
    }

    if trimmed.chars().count() > 128 {
        bail!("{what} is too long (max 128 characters)");
    }

    if trimmed == "." || trimmed == ".." {
        bail!("{what} must not be '.' or '..'");
    }

    if trimmed.contains('\0') {
        bail!("{what} must not contain NUL");
    }

    if trimmed.contains('/') || trimmed.contains('\\') {
        bail!("{what} must not contain path separators");
    }

    #[cfg(windows)]
    {
        if trimmed.ends_with('.') {
            bail!("{what} must not end with '.'");
        }

        const INVALID_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
        if trimmed.chars().any(|ch| INVALID_CHARS.contains(&ch)) {
            bail!("{what} contains invalid characters");
        }

        let stem = trimmed.split('.').next().unwrap_or(trimmed);
        let stem_upper = stem.to_ascii_uppercase();

        const RESERVED: &[&str] = &["CON", "PRN", "AUX", "NUL"];
        if RESERVED.contains(&stem_upper.as_str()) {
            bail!("{what} uses a reserved device name");
        }

        if let Some(num) = stem_upper.strip_prefix("COM") {
            if matches!(num, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") {
                bail!("{what} uses a reserved device name");
            }
        }

        if let Some(num) = stem_upper.strip_prefix("LPT") {
            if matches!(num, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") {
                bail!("{what} uses a reserved device name");
            }
        }
    }

    Ok(trimmed.to_string())
}

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

    #[test]
    fn test_validate_path_str() {
        assert!(validate_path_str("").is_err());
        assert!(validate_path_str("   ").is_err());
        assert!(validate_path_str("\t").is_err());
        assert!(validate_path_str("valid/path").is_ok());
        assert!(validate_path_str("config.yaml").is_ok());
        assert!(validate_path_str("a/../b").is_err());
        assert!(validate_path_str("../escape").is_err());
        assert!(validate_path_str("ok\0bad").is_err());
    }

    #[test]
    fn test_create_validated_pathbuf() {
        assert!(create_validated_pathbuf("").is_err());
        assert!(create_validated_pathbuf("   ").is_err());
        assert!(create_validated_pathbuf("valid/path").is_ok());
        assert!(create_validated_pathbuf("../escape").is_err());
        assert_eq!(
            create_validated_pathbuf("  foo/bar  ").unwrap(),
            PathBuf::from("foo/bar")
        );
    }

    #[test]
    fn test_safe_parent_for_creation() {
        use std::path::Path;

        // Should return None for just filename
        assert!(safe_parent_for_creation(Path::new("config.yaml")).is_none());

        // Should return Some for paths with directories
        assert!(safe_parent_for_creation(Path::new("dir/config.yaml")).is_some());

        // Should return Some for absolute paths
        assert!(safe_parent_for_creation(Path::new("/tmp/config.yaml")).is_some());
    }

    #[test]
    fn test_is_just_filename() {
        use std::path::Path;

        assert!(is_just_filename(Path::new("config.yaml")));
        assert!(!is_just_filename(Path::new("dir/config.yaml")));
        assert!(!is_just_filename(Path::new("/tmp/config.yaml")));
    }

    #[test]
    fn test_relative_path_from_dir_returns_none_for_relative_inputs() {
        assert!(relative_path_from_dir(Path::new("a/b"), Path::new("/tmp/x")).is_none());
        assert!(relative_path_from_dir(Path::new("/tmp/x"), Path::new("a/b")).is_none());
    }

    #[test]
    fn test_relative_path_from_dir_basic() {
        use tempfile::TempDir;

        let temp = TempDir::new().expect("temp dir");
        let root = temp.path();
        let from_dir = root.join("a/b/c");
        let to = root.join("a/d/e");
        std::fs::create_dir_all(&from_dir).expect("create from");
        std::fs::create_dir_all(&to).expect("create to");

        let rel = relative_path_from_dir(&from_dir, &to).expect("relative path");
        assert_eq!(rel, PathBuf::from("../../d/e"));

        let same = relative_path_from_dir(&from_dir, &from_dir).expect("relative path");
        assert_eq!(same, PathBuf::from("."));
    }

    #[test]
    fn test_validate_path_segment_basic() {
        assert!(validate_path_segment("", "name").is_err());
        assert!(validate_path_segment("   ", "name").is_err());
        assert!(validate_path_segment(".", "name").is_err());
        assert!(validate_path_segment("..", "name").is_err());
        assert!(validate_path_segment("a/b", "name").is_err());
        assert!(validate_path_segment("a\\b", "name").is_err());
        assert_eq!(validate_path_segment(" foo ", "name").unwrap(), "foo");
        assert_eq!(validate_path_segment("foo-bar", "name").unwrap(), "foo-bar");
        assert_eq!(validate_path_segment("draftpr", "name").unwrap(), "draftpr");
        assert_eq!(validate_path_segment("中文", "name").unwrap(), "中文");
    }

    #[cfg(windows)]
    #[test]
    fn test_validate_path_segment_windows_reserved_names() {
        assert!(validate_path_segment("con", "name").is_err());
        assert!(validate_path_segment("con.txt", "name").is_err());
        assert!(validate_path_segment("COM1", "name").is_err());
        assert!(validate_path_segment("LPT9.log", "name").is_err());
        assert!(validate_path_segment("bad:name", "name").is_err());
        assert!(validate_path_segment("trailing.", "name").is_err());
    }
}