arcthis 0.5.0

An agent-native CLI for accessing and manipulating compressed files
use std::path::PathBuf;
use std::time::Duration;

use crate::error::{ArcthisError, Result};
use crate::model::EntryKind;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExtractionLimits {
    pub max_entries: u64,
    pub max_total_size: u64,
    pub max_entry_size: u64,
    pub max_path_bytes: usize,
    pub max_components: usize,
    pub max_compression_ratio: Option<u64>,
    pub max_entry_duration: Option<Duration>,
}

impl Default for ExtractionLimits {
    fn default() -> Self {
        Self {
            max_entries: 100_000,
            max_total_size: 16 * 1024 * 1024 * 1024,
            max_entry_size: 4 * 1024 * 1024 * 1024,
            max_path_bytes: 4_096,
            max_components: 256,
            max_compression_ratio: None,
            max_entry_duration: None,
        }
    }
}

pub(crate) fn validate_entry_path(
    path: &str,
    kind: EntryKind,
    limits: &ExtractionLimits,
) -> Result<PathBuf> {
    if path.is_empty() {
        return unsafe_path(path, "path is empty");
    }
    if path.contains('\0') {
        return unsafe_path(path, "path contains a NUL byte");
    }
    if path.len() > limits.max_path_bytes {
        return Err(ArcthisError::ResourceLimit {
            message: format!(
                "entry path uses {} bytes, above the {} byte limit",
                path.len(),
                limits.max_path_bytes
            ),
        });
    }
    if path.starts_with('/') || path.starts_with("//") {
        return unsafe_path(path, "absolute or UNC paths are not allowed");
    }
    if path.contains('\\') {
        return unsafe_path(path, "backslash separators are not allowed");
    }
    let bytes = path.as_bytes();
    if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
        return unsafe_path(path, "Windows drive prefixes are not allowed");
    }

    let trimmed = if kind == EntryKind::Directory {
        path.trim_end_matches('/')
    } else {
        path
    };
    if trimmed.is_empty() {
        return unsafe_path(path, "path has no usable component");
    }
    let components = trimmed.split('/').collect::<Vec<_>>();
    if components.len() > limits.max_components {
        return Err(ArcthisError::ResourceLimit {
            message: format!(
                "entry path has {} components, above the {} component limit",
                components.len(),
                limits.max_components
            ),
        });
    }

    let mut result = PathBuf::new();
    for component in components {
        if component.is_empty() {
            return unsafe_path(path, "empty path components are not allowed");
        }
        if component == "." || component == ".." {
            return unsafe_path(path, "dot path components are not allowed");
        }
        if component.len() > 255 {
            return Err(ArcthisError::ResourceLimit {
                message: format!("entry path component exceeds 255 bytes: `{component}`"),
            });
        }
        result.push(component);
    }
    Ok(result)
}

fn unsafe_path<T>(path: &str, reason: &str) -> Result<T> {
    Err(ArcthisError::UnsafePath {
        path: path.to_owned(),
        reason: reason.to_owned(),
    })
}

#[cfg(test)]
mod tests {
    use super::{ExtractionLimits, validate_entry_path};
    use crate::model::EntryKind;

    #[test]
    fn accepts_normal_relative_paths() {
        let path = validate_entry_path(
            "docs/资料/readme.md",
            EntryKind::File,
            &ExtractionLimits::default(),
        )
        .expect("safe path");
        assert_eq!(path.to_string_lossy(), "docs/资料/readme.md");
    }

    #[test]
    fn rejects_parent_absolute_drive_and_backslash_paths() {
        let limits = ExtractionLimits::default();
        for path in ["../escape", "/etc/passwd", "C:/escape", r"..\escape"] {
            assert!(validate_entry_path(path, EntryKind::File, &limits).is_err());
        }
    }

    #[test]
    fn permits_one_trailing_slash_only_for_directories() {
        let limits = ExtractionLimits::default();
        assert!(validate_entry_path("empty/", EntryKind::Directory, &limits).is_ok());
        assert!(validate_entry_path("file/", EntryKind::File, &limits).is_err());
    }
}