forjar 1.29.0

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! FJ-002: YAML parsing and validation.
//!
//! Parses forjar.yaml and validates structural constraints:
//! - Version must be "1.0"
//! - Machine references in resources must exist
//! - depends_on references must exist
//! - Required fields per resource type

mod backup_sync_validate;
mod disk_budget_validate;
mod expansion;
mod format_validation;
mod nas_archive_validate;
mod overlay_interface_validate;
mod unknown_policy;
pub use unknown_policy::rejects_unknown;
mod includes;
mod known_fields;
mod policy;
mod recipes;
mod resource_types;
pub(crate) mod unknown_fields;
mod validation;

#[cfg(test)]
mod tests_arch;
#[cfg(test)]
mod tests_core;
#[cfg(test)]
mod tests_expansion;
#[cfg(test)]
mod tests_format_validation;
#[cfg(test)]
mod tests_includes;
#[cfg(test)]
mod tests_known_fields_completeness;
#[cfg(test)]
mod tests_misc;
#[cfg(test)]
mod tests_misc_2;
#[cfg(test)]
mod tests_misc_2b;
#[cfg(test)]
mod tests_misc_3;
#[cfg(test)]
mod tests_misc_4;
#[cfg(test)]
mod tests_policy;
#[cfg(test)]
mod tests_policy_b;
#[cfg(test)]
mod tests_regressions;
#[cfg(test)]
mod tests_resource_types_cov;
#[cfg(test)]
mod tests_sarif;
#[cfg(test)]
mod tests_sudo_inference;
#[cfg(test)]
mod tests_triggers;
#[cfg(test)]
mod tests_unknown_fields;
#[cfg(test)]
mod tests_validation;

use super::recipe;
use super::types::*;
use std::path::Path;

// Re-export public API
pub use expansion::expand_resources;
pub use policy::{
    evaluate_policies, evaluate_policies_full, policy_check_to_json, policy_check_to_sarif,
    resource_field_value, violating_pairs,
};
// Crate-internal: `core::policy_coverage` reports coverage with the SAME
// predicate the evaluator enforces with. Not `pub` — a second public scope
// matcher is how the two drifted apart in the first place.
pub(crate) use policy::matches_scope;
pub use recipes::expand_recipes;

/// Recognized CPU architectures for the `arch` field.
const KNOWN_ARCHITECTURES: &[&str] =
    &["x86_64", "aarch64", "armv7l", "riscv64", "s390x", "ppc64le"];

/// Validation error.
#[derive(Debug, Clone)]
pub struct ValidationError {
    /// Human-readable error description.
    pub message: String,
}

impl std::fmt::Display for ValidationError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.message)
    }
}

/// Parse a forjar.yaml file from disk.
pub fn parse_config_file(path: &Path) -> Result<ForjarConfig, String> {
    let content = std::fs::read_to_string(path)
        .map_err(|e| format!("failed to read {}: {}", path.display(), e))?;
    parse_config(&content)
}

/// Parse a forjar.yaml from a string.
///
/// # Two failures, deliberately distinguishable (forjar#354)
///
/// `serde_yaml_ng` reports both of these through the same error type, and this
/// function used to flatten both into `"YAML parse error: {e}"`:
///
///   * the document is not valid YAML — a SYNTAX failure;
///   * the document is valid YAML but not a forjar config (`missing field
///     `version``) — a SCHEMA failure.
///
/// So `forjar validate` said `error: YAML parse error: missing field
/// `version`` for a file whose YAML parsed perfectly, and exited 3 for both.
///
/// That matters to any caller wanting to use forjar as a YAML syntax checker,
/// which paiml/infra does: it is removing `uvx yamllint` from CI, and the
/// sovereign answer to "a guard needs to parse YAML" is that forjar already
/// does. With both failures collapsed, a fleet-wide lint would have flagged all
/// 1095 of that repo's YAML files, 1090 of which are workflows and contracts
/// that were never meant to be forjar configs.
///
/// The discriminator is a SECOND PARSE, not the error itself. `location()` was
/// the obvious candidate and it is wrong: `serde_yaml_ng` attaches a location to
/// a `missing field` error too, so it cannot separate the two.
///
/// What does separate them is asking the question directly — deserialise into
/// `serde_yaml_ng::Value`, which imposes no schema. If THAT succeeds, the
/// document is well-formed YAML and the failure was ours; if it fails, the
/// document is genuinely malformed. Two parses of a config file is a cost worth
/// paying on an error path that already ends the process.
///
/// # Examples
///
/// ```
/// use forjar::core::parser::parse_config;
///
/// let yaml = r#"
/// version: "1.0"
/// name: my-stack
/// resources:
///   pkg-curl:
///     type: package
///     packages: [curl]
/// "#;
/// let config = parse_config(yaml).expect("valid");
/// assert_eq!(config.name, "my-stack");
/// assert!(config.resources.contains_key("pkg-curl"));
/// ```
pub fn parse_config(yaml: &str) -> Result<ForjarConfig, String> {
    serde_yaml_ng::from_str(yaml).map_err(|e| {
        if serde_yaml_ng::from_str::<serde_yaml_ng::Value>(yaml).is_ok() {
            format!("not a forjar config: {e}")
        } else {
            format!("YAML parse error: {e}")
        }
    })
}

/// Validate a parsed config. Returns a list of errors (empty = valid).
pub fn validate_config(config: &ForjarConfig) -> Vec<ValidationError> {
    let mut errors = Vec::new();

    if config.version != "1.0" {
        errors.push(ValidationError {
            message: format!("version must be \"1.0\", got \"{}\"", config.version),
        });
    }

    if config.name.is_empty() {
        errors.push(ValidationError {
            message: "name must not be empty".to_string(),
        });
    }

    for (id, resource) in &config.resources {
        validation::validate_resource_refs(config, id, resource, &mut errors);
        validation::validate_lifecycle(id, resource, &mut errors);
        resource_types::validate_resource_type(id, resource, &mut errors);
        check_sudo_inference(id, resource, config, &mut errors);
    }

    // FJ-035 QUORUM MUST-1: overlay addresses must be injective across all
    // present overlay_interface resources (plan-time duplicate-IP rejection).
    overlay_interface_validate::validate_overlay_ip_injective(config, &mut errors);

    for (key, machine) in &config.machines {
        validation::validate_machine(key, machine, &mut errors);
    }

    // FJ-2501: Format validation (mode, port, path, owner/group, addr)
    errors.extend(format_validation::validate_formats(config));

    // FJ-154 (#23): Reject colliding / chained moved blocks before they can
    // silently overwrite lock state during planning.
    crate::core::planner::moved::validate_moved_blocks(config, &mut errors);

    errors
}

/// Render validation errors as one message.
///
/// Shared by the pre- and post-expansion passes in `load_config` so the two
/// cannot drift in how they report the same `ValidationError`.
fn render_validation_errors(errors: &[ValidationError], headline: &str) -> String {
    format!(
        "{headline}:\n{}",
        errors
            .iter()
            .map(|e| format!("  - {e}"))
            .collect::<Vec<_>>()
            .join("\n")
    )
}

/// Paths that require root/sudo for writes.
const PRIVILEGED_PREFIXES: &[&str] = &[
    "/etc/",
    "/usr/lib/systemd/",
    "/boot/",
    "/var/lib/",
    "/opt/",
    "/usr/local/bin/",
    "/usr/local/sbin/",
];

/// Warn when a file resource writes to a privileged path or has owner:root without sudo.
fn check_sudo_inference(
    id: &str,
    resource: &Resource,
    config: &ForjarConfig,
    errors: &mut Vec<ValidationError>,
) {
    if resource.sudo {
        return; // Already has sudo
    }
    if resource.resource_type != ResourceType::File {
        return; // Only applies to file resources
    }
    // Check if the machine user is root — sudo not needed
    let mut any_machine_resolved = false;
    for machine_name in resource.machine.iter() {
        if let Some(machine) = config.machines.get(machine_name) {
            any_machine_resolved = true;
            if machine.user == "root" {
                return; // Running as root, sudo not needed
            }
        }
    }
    // If no machine could be resolved from config, skip the check
    if !any_machine_resolved {
        return;
    }
    let needs_sudo = resource.owner.as_deref() == Some("root")
        || resource
            .path
            .as_deref()
            .is_some_and(|p| PRIVILEGED_PREFIXES.iter().any(|pfx| p.starts_with(pfx)));
    if needs_sudo {
        let reason = if resource.owner.as_deref() == Some("root") {
            "owner: root"
        } else {
            "privileged path"
        };
        errors.push(ValidationError {
            message: format!(
                "resource '{id}' has {reason} but no sudo: true — add sudo: true or the write will fail with permission denied"
            ),
        });
    }
}

/// Validate YAML for unknown fields and return warnings.
/// This performs the second pass of two-pass parsing (FJ-2500).
pub fn check_unknown_fields(yaml: &str) -> Vec<ValidationError> {
    match unknown_fields::detect_unknown_fields(yaml) {
        Ok(unknowns) => unknown_fields::unknown_fields_to_errors(&unknowns),
        // A check that could not RUN is not a clean result. This pass parses
        // into a raw `Value` while the first parses into `ForjarConfig`, so the
        // first can succeed where this one fails — and swallowing the error
        // made every unknown field in the file invisible while `validate`
        // printed OK.
        Err(e) => vec![ValidationError {
            message: format!("could not check for unknown fields: {e}"),
        }],
    }
}

/// Validate recipe YAML for unknown fields and return warnings (FJ-2500).
pub fn check_unknown_recipe_fields(yaml: &str) -> Vec<ValidationError> {
    match unknown_fields::detect_unknown_recipe_fields(yaml) {
        Ok(unknowns) => unknown_fields::unknown_fields_to_errors(&unknowns),
        Err(e) => vec![ValidationError {
            message: format!("could not check for unknown recipe fields: {e}"),
        }],
    }
}

/// Parse, validate, and expand recipes in a config file.
/// This is the main entry point for loading a config for plan/apply.
///
/// Unknown fields are ERRORS here (GH-272); opt out with
/// `parse_and_validate_opts(path, false)`.
pub fn parse_and_validate(path: &Path) -> Result<ForjarConfig, String> {
    parse_and_validate_opts(path, true)
}

/// Parse, validate, expand — with strict mode for unknown fields (FJ-2500).
/// When `deny_unknown` is true, unknown YAML fields are hard errors.
/// When false, unknown fields are printed as warnings to stderr.
pub fn parse_and_validate_opts(path: &Path, deny_unknown: bool) -> Result<ForjarConfig, String> {
    let content = std::fs::read_to_string(path)
        .map_err(|e| format!("failed to read {}: {}", path.display(), e))?;
    let mut config = parse_config(&content)?;

    // FJ-2500: Detect unknown fields (two-pass parsing)
    let unknown_warnings = check_unknown_fields(&content);
    if !unknown_warnings.is_empty() {
        if rejects_unknown(deny_unknown, unknown_warnings.len()) {
            return Err(format!(
                "unknown field errors:\n{}",
                unknown_warnings
                    .iter()
                    .map(|e| format!("  - {e}"))
                    .collect::<Vec<_>>()
                    .join("\n")
            ));
        }
        for w in &unknown_warnings {
            eprintln!("warning: {w}");
        }
    }

    // FJ-254: Process includes before validation
    if !config.includes.is_empty() {
        let base_dir = path.parent().unwrap_or(Path::new("."));
        config = includes::merge_includes(config, base_dir)?;
    }

    let errors = validate_config(&config);
    if !errors.is_empty() {
        return Err(render_validation_errors(&errors, "validation errors"));
    }
    expand_recipes(&mut config, path.parent())?;
    expand_resources(&mut config);

    // #357: validate AGAIN, over the EXPANDED config.
    //
    // The pass above cannot see a recipe. `expand_recipes` runs after it, so
    // every resource a recipe supplied reached `plan` and `apply` having been
    // checked by nothing. `includes` do not have this hole — FJ-254 moved
    // `merge_includes` ABOVE the first pass for exactly this reason — and
    // recipes were simply never given the same treatment. Recipes are the
    // mechanism forjar promotes for fleet reuse, so the resources deployed most
    // widely were the ones validated least.
    //
    // Both passes are kept, because they answer different questions. The first
    // reports errors in the file the user is editing, in the ids the user typed.
    // This one reports what the machine would actually converge, in the expanded
    // ids (`recipe_id/foo`), so the id in the message is the id in the plan.
    let errors = validate_config(&config);
    if !errors.is_empty() {
        return Err(render_validation_errors(
            &errors,
            "validation errors after expansion (ids below are post-expansion)",
        ));
    }

    // #165: re-check moved.to collisions against the POST-expansion resource
    // set. validate_config ran before expansion, so a `to` that lands on a
    // recipe-expanded key (e.g. `recipe_id/foo`) would otherwise pass and then
    // silently clobber that expanded resource's converged lock.
    // #165's post-expansion `moved.to` collision re-check is gone with the
    // config-time check it duplicated: `to` naming a declared resource is the
    // required shape of a rename, not a collision. The genuinely destructive
    // case — the LOCK already holding `to` — is detected in `rename_lock`,
    // which has the state this function does not.

    Ok(config)
}