arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! Change-fragment file parsing — reads and parses fragment files from the
//! `changes/` directory.
//!
//! Security: fragment file names are validated to prevent path traversal.
//! Only simple names (ASCII alphanumeric, hyphens, underscores, dots) ending
//! in `.toml` are accepted — no `../`, no absolute paths, no subdirectories.
//! This is the boundary that protects the parser from hostile file names
//! (ADR-0005 Security section: path traversal in change fragments).

use std::path::Path;

use super::schema::{ChangeFragmentFile, FragmentDoc};

/// The result of loading fragment files: successfully parsed fragments and
/// per-file parse errors. A type alias keeps the public signature readable.
pub(crate) type FragmentLoadResult = (Vec<ChangeFragmentFile>, Vec<(String, String)>);

/// Validate that a fragment file name is safe (no path traversal, no
/// subdirectories). Returns the bare file name on success.
///
/// Security note: the check examines the full path (to reject `../` and
/// embedded separators) and not just `Path::file_name()`, which would
/// strip `../etc/` from `../etc/passwd.toml` and leave a clean `passwd.toml`.
fn validate_file_name(path: &Path) -> Result<String, String> {
    let path_str = path.to_string_lossy();

    // Reject any parent-directory traversal or absolute paths.
    if path_str.contains("..") || path.is_absolute() {
        return Err(format!(
            "fragment file path must not traverse or be absolute: {path_str}"
        ));
    }
    // Reject any path separator in the relative path (no subdirectories).
    if path_str.contains('/') || path_str.contains('\\') {
        return Err(format!(
            "fragment file path must not contain subdirectories: {path_str}"
        ));
    }

    let name = path
        .file_name()
        .ok_or_else(|| format!("path has no file name: {}", path.display()))?
        .to_string_lossy()
        .into_owned();

    // Only allow ASCII alphanumeric, hyphens, underscores, dots.
    if !name
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
    {
        return Err(format!(
            "fragment file name must be ASCII alphanumeric/hyphen/underscore/dot only: {name}"
        ));
    }
    if !name.ends_with(".toml") {
        return Err(format!("fragment file must end with .toml: {name}"));
    }
    Ok(name)
}

/// Parse a single fragment file from its raw TOML content.
///
/// Returns the parsed file with its validated file name and entries. A
/// parse failure is returned as an error (the caller decides whether to
/// collect or abort).
pub(crate) fn parse_fragment(path: &Path, content: &str) -> Result<ChangeFragmentFile, String> {
    let file_name = validate_file_name(path)?;
    let doc: FragmentDoc =
        toml::from_str(content).map_err(|e| format!("malformed fragment {file_name}: {e}"))?;
    Ok(ChangeFragmentFile {
        file_name,
        entries: doc.entries,
    })
}

/// Load and parse all `*.toml` fragment files from a directory.
///
/// Returns a sorted list of successfully parsed fragment files and a list
/// of (file_name, error) pairs for files that failed validation or parsing.
/// The sort is by file name for deterministic output. An empty or missing
/// directory is not an error — it means no changes are pending.
pub(crate) fn load_fragments_files(dir: &Path) -> std::io::Result<FragmentLoadResult> {
    let mut fragments = Vec::new();
    let mut errors = Vec::new();

    let entries = match std::fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return Ok((Vec::new(), Vec::new()));
        }
        Err(error) => return Err(error),
    };

    let mut paths: Vec<_> = entries
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| p.extension().is_some_and(|ext| ext == "toml"))
        .collect();
    paths.sort();

    for path in paths {
        let content = match std::fs::read_to_string(&path) {
            Ok(content) => content,
            Err(error) => {
                let name = path
                    .file_name()
                    .unwrap_or_default()
                    .to_string_lossy()
                    .into_owned();
                errors.push((name, format!("cannot read file: {error}")));
                continue;
            }
        };
        // Pass just the file name (not the full path) to validate_file_name,
        // which rejects any path separators. The full path is used only for
        // reading the file; the fragment's identity is its bare file name.
        let file_name_path = Path::new(path.file_name().unwrap_or_default().to_str().unwrap_or(""));
        match parse_fragment(file_name_path, &content) {
            Ok(fragment) => fragments.push(fragment),
            Err(error) => {
                let name = path
                    .file_name()
                    .unwrap_or_default()
                    .to_string_lossy()
                    .into_owned();
                errors.push((name, error));
            }
        }
    }

    Ok((fragments, errors))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::release::change::schema::ChangeKind;

    #[test]
    fn validates_simple_file_name() {
        let path = Path::new("71-add-refresh-session.toml");
        assert_eq!(
            validate_file_name(path).unwrap(),
            "71-add-refresh-session.toml"
        );
    }

    #[test]
    fn rejects_path_traversal() {
        assert!(validate_file_name(Path::new("../etc/passwd.toml")).is_err());
    }

    #[test]
    fn rejects_subdirectory() {
        assert!(validate_file_name(Path::new("sub/file.toml")).is_err());
        assert!(validate_file_name(Path::new("sub\\file.toml")).is_err());
    }

    #[test]
    fn rejects_non_toml_extension() {
        assert!(validate_file_name(Path::new("fragment.txt")).is_err());
    }

    #[test]
    fn rejects_non_ascii() {
        assert!(validate_file_name(Path::new("café.toml")).is_err());
    }

    #[test]
    fn parses_valid_fragment() {
        let path = Path::new("71-auth-fix.toml");
        let content = "\
[[change]]
unit = \"arcature-auth\"
kind = \"compatible\"
summary = \"Fix session refresh edge case\"
";
        let fragment = parse_fragment(path, content).expect("parses");
        assert_eq!(fragment.file_name, "71-auth-fix.toml");
        assert_eq!(fragment.entries.len(), 1);
        assert_eq!(fragment.entries[0].kind, ChangeKind::Compatible);
    }

    #[test]
    fn rejects_malformed_toml() {
        let path = Path::new("bad.toml");
        let content = "not valid toml {{{";
        assert!(parse_fragment(path, content).is_err());
    }

    #[test]
    fn rejects_empty_unit() {
        let path = Path::new("empty-unit.toml");
        let content = "\
[[change]]
unit = \"\"
kind = \"compatible\"
summary = \"x\"
";
        // Empty string is not rejected by deserialization, but the validator
        // catches it. Here we just confirm the fragment parses.
        let fragment = parse_fragment(path, content).expect("parses (validator catches empty)");
        assert_eq!(fragment.entries[0].unit, "");
    }
}