atomwrite 0.1.31

Atomic file operations CLI for LLM agents — read, write, edit, search, replace with NDJSON output
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Workspace path jail validation and symlink safety checks.

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

use anyhow::Result;

use crate::error::AtomwriteError;

/// Validate that a path is inside the workspace and is not a symlink.
///
/// # Errors
///
/// Returns `AtomwriteError::WorkspaceJail` if the path escapes the workspace.
/// Returns `AtomwriteError::InvalidInput` if the path contains a null byte.
/// Returns `AtomwriteError::SymlinkBlocked` if the path is a symbolic link.
/// Returns `AtomwriteError::FifoDetected` if the path is a FIFO.
/// Returns `AtomwriteError::DeviceFile` if the path is a device file.
pub fn validate_path(path: &Path, workspace: &Path) -> Result<PathBuf> {
    validate_path_with_symlink(path, workspace, false)
}

/// Validate a path with configurable symlink policy.
///
/// # Errors
///
/// Returns `AtomwriteError::WorkspaceJail` if the path escapes the workspace.
/// Returns `AtomwriteError::InvalidInput` if the path contains a null byte.
/// Returns `AtomwriteError::SymlinkBlocked` if the path is a symbolic link and symlinks are not followed.
/// Returns `AtomwriteError::FifoDetected` if the path is a FIFO.
/// Returns `AtomwriteError::DeviceFile` if the path is a device file.
pub fn validate_path_with_symlink(
    path: &Path,
    workspace: &Path,
    follow_symlinks: bool,
) -> Result<PathBuf> {
    let path_str = path.to_string_lossy();
    if path_str.contains('\0') {
        return Err(AtomwriteError::InvalidInput {
            reason: format!("path contains null byte: {}", path.display()),
        }
        .into());
    }

    if cfg!(windows) {
        check_reserved_windows(&path_str)?;
    }

    let effective_path = if path.is_relative() {
        workspace.join(path)
    } else {
        path.to_path_buf()
    };
    let resolved = normalize_path_nfc(&soft_canonicalize(&effective_path));
    let workspace_resolved = normalize_path_nfc(&soft_canonicalize(workspace));

    if !resolved.starts_with(&workspace_resolved) {
        return Err(AtomwriteError::WorkspaceJail {
            path: resolved.clone(),
            workspace: workspace_resolved.clone(),
        }
        .into());
    }

    if !follow_symlinks {
        let real_resolved = canonicalize_existing_prefix(&resolved);
        let real_workspace = workspace
            .canonicalize()
            .unwrap_or_else(|_| workspace_resolved.clone());
        let real_workspace = normalize_path_nfc(&real_workspace);
        if !real_resolved.starts_with(&real_workspace) {
            return Err(AtomwriteError::WorkspaceJail {
                path: real_resolved,
                workspace: real_workspace,
            }
            .into());
        }

        if resolved.exists() {
            check_symlink(&resolved)?;
        }
    }

    #[cfg(unix)]
    if resolved.exists() {
        check_special_file(&resolved)?;
    }

    Ok(resolved)
}

/// Return an error if the path is a symbolic link.
///
/// # Errors
///
/// Returns `AtomwriteError::SymlinkBlocked` if the path is a symbolic link.
pub fn check_symlink(path: &Path) -> Result<()> {
    let metadata = std::fs::symlink_metadata(path);
    if let Ok(meta) = metadata {
        if meta.file_type().is_symlink() {
            return Err(AtomwriteError::SymlinkBlocked {
                path: path.to_path_buf(),
            }
            .into());
        }
    }
    Ok(())
}

fn normalize_path_nfc(path: &Path) -> PathBuf {
    use unicode_normalization::UnicodeNormalization;
    let s = path.to_string_lossy();
    let normalized: String = s.nfc().collect();
    PathBuf::from(normalized)
}

/// Canonicalize the longest existing prefix of a path, resolving symlinks.
///
/// Walks from the full path upward until an existing ancestor is found,
/// canonicalizes it (resolving all symlinks), then re-appends the
/// non-existent tail. This catches symlink-directory escapes where an
/// intermediate component is a symlink pointing outside the workspace.
fn canonicalize_existing_prefix(path: &Path) -> PathBuf {
    let mut existing = path.to_path_buf();
    let mut tail: Vec<std::ffi::OsString> = Vec::new();

    loop {
        if existing.exists() {
            match existing.canonicalize() {
                Ok(canon) => {
                    let mut result = canon;
                    for component in tail.into_iter().rev() {
                        result.push(component);
                    }
                    return normalize_path_nfc(&result);
                }
                Err(_) => return normalize_path_nfc(path),
            }
        }
        match existing.file_name() {
            Some(name) => {
                tail.push(name.to_owned());
                existing.pop();
            }
            None => return normalize_path_nfc(path),
        }
    }
}

/// Resolve `.` and `..` components without touching the filesystem.
pub fn soft_canonicalize(path: &Path) -> PathBuf {
    let mut result = PathBuf::new();
    for component in path.components() {
        match component {
            Component::ParentDir => {
                result.pop();
            }
            Component::CurDir => {}
            other => result.push(other),
        }
    }
    if result.as_os_str().is_empty() {
        result.push(".");
    }
    result
}

/// Reject FIFOs and device files that would hang or corrupt the atomic pipeline.
#[cfg(unix)]
fn check_special_file(path: &Path) -> Result<()> {
    use std::os::unix::fs::FileTypeExt;
    let meta = std::fs::symlink_metadata(path);
    if let Ok(m) = meta {
        let ft = m.file_type();
        if ft.is_fifo() {
            return Err(AtomwriteError::FifoDetected {
                path: path.to_path_buf(),
            }
            .into());
        }
        if ft.is_block_device() || ft.is_char_device() {
            return Err(AtomwriteError::DeviceFile {
                path: path.to_path_buf(),
            }
            .into());
        }
    }
    Ok(())
}

fn check_reserved_windows(name: &str) -> Result<()> {
    let stem = Path::new(name)
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("")
        .to_uppercase();

    const RESERVED: &[&str] = &[
        "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
        "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
    ];

    if RESERVED.contains(&stem.as_str()) {
        return Err(AtomwriteError::InvalidInput {
            reason: format!("reserved Windows filename: {stem}"),
        }
        .into());
    }

    Ok(())
}

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

    #[test]
    fn soft_canonicalize_resolves_dotdot() {
        let result = soft_canonicalize(Path::new("/home/user/../other/./file.txt"));
        assert_eq!(result, PathBuf::from("/home/other/file.txt"));
    }

    #[test]
    fn soft_canonicalize_empty_becomes_dot() {
        let result = soft_canonicalize(Path::new(""));
        assert_eq!(result, PathBuf::from("."));
    }

    #[test]
    fn validate_rejects_null_byte() {
        let result = validate_path(Path::new("foo\0bar"), Path::new("/tmp"));
        assert!(result.is_err());
    }

    #[test]
    fn validate_rejects_outside_workspace() {
        let result = validate_path(Path::new("/etc/passwd"), Path::new("/home/user"));
        assert!(result.is_err());
    }

    #[test]
    fn validate_accepts_inside_workspace() {
        let result = validate_path(Path::new("/tmp/test.txt"), Path::new("/tmp"));
        assert!(result.is_ok());
    }
}