forjar 1.29.0

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! Lock audit.

use super::helpers::*;
use crate::core::types;
use std::path::Path;

/// FJ-645: Show lock file change history
pub(crate) fn cmd_lock_history(state_dir: &Path, json: bool, limit: usize) -> Result<(), String> {
    let machines = discover_machines(state_dir);
    let mut entries = Vec::new();

    for m in &machines {
        let lock_path = state_dir.join(m).join("state.lock.yaml");
        if !lock_path.exists() {
            continue;
        }
        let content = std::fs::read_to_string(&lock_path).unwrap_or_default();
        let lock: crate::core::types::StateLock = match serde_yaml_ng::from_str(&content) {
            Ok(l) => l,
            Err(_) => continue,
        };

        for (rname, rlock) in &lock.resources {
            let applied = rlock.applied_at.clone().unwrap_or_default();
            let status_str = format!("{:?}", rlock.status);
            entries.push((
                m.clone(),
                rname.clone(),
                applied,
                status_str,
                rlock.hash.clone(),
            ));
        }
    }

    // Sort by applied_at descending (most recent first)
    entries.sort_by(|a, b| b.2.cmp(&a.2));
    let entries: Vec<_> = entries.into_iter().take(limit).collect();

    if json {
        print!("{{\"history\":[");
        for (i, (machine, resource, applied, status, hash)) in entries.iter().enumerate() {
            if i > 0 {
                print!(",");
            }
            print!(
                r#"{{"machine":"{}","resource":"{}","applied_at":"{}","status":"{}","hash":"{}"}}"#,
                machine,
                resource,
                applied,
                status,
                &hash[..hash.len().min(12)]
            );
        }
        println!("]}}");
    } else {
        println!("Lock history (last {} entries):", entries.len());
        for (machine, resource, applied, status, hash) in &entries {
            println!(
                "  {} {}/{} [{}] {}",
                if applied.is_empty() {
                    "unknown"
                } else {
                    applied
                },
                machine,
                resource,
                status,
                &hash[..hash.len().min(12)]
            );
        }
    }
    Ok(())
}

/// Audit a single parsed lock, returning (valid, reason).
fn audit_lock_integrity(lock: &crate::core::types::StateLock) -> (bool, String) {
    let mut valid = true;
    let mut reason = "ok".to_string();
    for (rname, rlock) in &lock.resources {
        let hash = &rlock.hash;
        let hex_part = hash.strip_prefix("blake3:").unwrap_or(hash);
        if hex_part.len() != 64 || !hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
            valid = false;
            reason = format!("invalid hash for resource {rname}");
            break;
        }
        // NO RECOMPUTATION HERE, ON PURPOSE. This used to recompute
        // `blake3("{rname}:{status:?}")`, compare it to the recorded hash, and
        // then do nothing with the answer — "flag it but don't fail". The
        // recorded hash is `planner::hash_desired_state` over the resolved
        // declaration, which this command does not have, so the comparison
        // could never match and never fired. A dead check that LOOKS like
        // tamper detection is the #405 defect shape (E03 quorum, agy lane).
        // `lock-audit` is a FORMAT audit; tamper evidence for a lock is the
        // `.b3` sidecar (`lock-verify`), and authentication is `lock-verify-sig`.
    }
    if !lock.generator.starts_with("forjar") {
        valid = false;
        reason = format!("unexpected generator: {}", lock.generator);
    }
    (valid, reason)
}

/// Output audit results in JSON or text.
fn output_audit_results(results: &[(String, bool, String)], json: bool) {
    if json {
        let items: Vec<String> = results
            .iter()
            .map(|(m, v, r)| format!(r#"{{"machine":"{m}","valid":{v},"reason":"{r}"}}"#))
            .collect();
        println!(
            r#"{{"audit":[{}],"total":{},"valid":{}}}"#,
            items.join(","),
            results.len(),
            results.iter().filter(|(_, v, _)| *v).count()
        );
    } else if results.is_empty() {
        println!("No lock files found to audit");
    } else {
        println!("Lock file audit ({} files):", results.len());
        for (m, valid, reason) in results {
            let icon = if *valid { "PASS" } else { "FAIL" };
            println!("  [{icon}] {m}{reason}");
        }
    }
}

/// FJ-555: Verify lock file integrity and show tampering evidence.
pub(crate) fn cmd_lock_audit(state_dir: &Path, json: bool) -> Result<(), String> {
    require_state_dir(state_dir)?;
    let machines = discover_machines(state_dir);
    let mut results: Vec<(String, bool, String)> = Vec::new();

    for m in &machines {
        let lock_path = state_dir.join(m).join("state.lock.yaml");
        if !lock_path.exists() {
            results.push((m.clone(), false, "lock file missing".to_string()));
            continue;
        }
        let content = std::fs::read_to_string(&lock_path).unwrap_or_default();
        if content.trim().is_empty() {
            results.push((m.clone(), false, "lock file empty".to_string()));
            continue;
        }
        match serde_yaml_ng::from_str::<crate::core::types::StateLock>(&content) {
            Ok(lock) => {
                let (valid, reason) = audit_lock_integrity(&lock);
                results.push((m.clone(), valid, reason));
            }
            Err(e) => {
                results.push((m.clone(), false, format!("YAML parse error: {e}")));
            }
        }
    }

    // CB-2010: "Verify lock file integrity and show tampering evidence" — while
    // never once reading the BLAKE3 sidecar that IS the tampering evidence. The
    // audit above only inspects fields the tamperer rewrote.
    for (machine, reason) in sidecar_failures(state_dir) {
        results.push((machine, false, reason));
    }

    output_audit_results(&results, json);

    // CB-2010: an audit that prints `[FAIL]` and then exits 0 cannot gate anything.
    let failed = results.iter().filter(|(_, valid, _)| !valid).count();
    if failed == 0 {
        Ok(())
    } else {
        Err(format!("{failed} lock audit failure(s)"))
    }
}

/// Resolve the most recent snapshot name from the snapshots directory.
fn resolve_latest_snapshot(snapshot_dir: &Path, json: bool) -> Result<Option<String>, String> {
    let mut entries: Vec<_> = std::fs::read_dir(snapshot_dir)
        .map_err(|e| format!("Failed to read snapshots: {e}"))?
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("yaml"))
        .collect();
    entries.sort_by_key(|e| e.file_name());
    match entries.last() {
        Some(e) => Ok(Some(
            e.path()
                .file_stem()
                .unwrap_or_default()
                .to_string_lossy()
                .to_string(),
        )),
        None => {
            if json {
                println!("{{\"restored\":false,\"reason\":\"no snapshots found\"}}");
            } else {
                println!("No snapshots found.");
            }
            Ok(None)
        }
    }
}

/// FJ-695: Restore lock state from a named snapshot
pub(crate) fn cmd_lock_restore(
    state_dir: &Path,
    name: Option<&str>,
    json: bool,
) -> Result<(), String> {
    let snapshot_dir = state_dir.join("snapshots");
    if !snapshot_dir.exists() {
        if json {
            println!("{{\"restored\":false,\"reason\":\"no snapshots directory\"}}");
        } else {
            println!("No snapshots directory found.");
        }
        return Ok(());
    }
    let snapshot_name = match name {
        Some(n) => n.to_string(),
        None => match resolve_latest_snapshot(&snapshot_dir, json)? {
            Some(n) => n,
            None => return Ok(()),
        },
    };
    let snap_path = snapshot_dir.join(format!("{snapshot_name}.yaml"));
    if !snap_path.exists() {
        return Err(format!("Snapshot not found: {snapshot_name}"));
    }
    let data =
        std::fs::read_to_string(&snap_path).map_err(|e| format!("Failed to read snapshot: {e}"))?;
    let machines = discover_machines(state_dir);
    let mut restored = 0;
    for m in &machines {
        let lock_path = state_dir.join(m).join("state.lock.yaml");
        std::fs::write(&lock_path, &data).map_err(|e| format!("Failed to restore {m}: {e}"))?;
        restored += 1;
    }
    if json {
        println!(
            "{{\"restored\":true,\"snapshot\":\"{snapshot_name}\",\"machines_restored\":{restored}}}"
        );
    } else {
        println!("Restored snapshot '{snapshot_name}' to {restored} machine(s).");
    }
    Ok(())
}

/// FJ-705: Verify lock file schema version compatibility
pub(crate) fn cmd_lock_verify_schema(state_dir: &Path, json: bool) -> Result<(), String> {
    let machines = discover_machines(state_dir);
    let expected_schema = "1.0";
    let mut results: Vec<(String, String, bool)> = Vec::new();
    for m in &machines {
        let lock_path = state_dir.join(m).join("state.lock.yaml");
        if let Ok(data) = std::fs::read_to_string(&lock_path) {
            if let Ok(lock) = serde_yaml_ng::from_str::<types::StateLock>(&data) {
                let matches = lock.schema == expected_schema;
                results.push((m.clone(), lock.schema.clone(), matches));
            }
        }
    }
    if json {
        let entries: Vec<String> = results
            .iter()
            .map(|(m, schema, ok)| {
                format!("{{\"machine\":\"{m}\",\"schema\":\"{schema}\",\"compatible\":{ok}}}")
            })
            .collect();
        println!(
            "{{\"expected_schema\":\"{}\",\"results\":[{}]}}",
            expected_schema,
            entries.join(",")
        );
    } else if results.is_empty() {
        println!("No lock files found.");
    } else {
        println!("Lock file schema verification (expected: {expected_schema}):");
        for (m, schema, ok) in &results {
            let status = if *ok { "OK" } else { "MISMATCH" };
            println!("  {m} — schema {schema} [{status}]");
        }
    }
    Ok(())
}

/// FJ-715: Add metadata tags to lock files
pub(crate) fn cmd_lock_tag(
    state_dir: &Path,
    tag_name: &str,
    tag_value: &str,
    json: bool,
) -> Result<(), String> {
    let machines = discover_machines(state_dir);
    let mut tagged = 0;
    for m in &machines {
        let lock_path = state_dir.join(m).join("state.lock.yaml");
        if let Ok(data) = std::fs::read_to_string(&lock_path) {
            // Prepend tag as YAML comment
            let tag_line = format!("# tag:{tag_name}: {tag_value}\n");
            let new_data = format!("{tag_line}{data}");
            std::fs::write(&lock_path, new_data)
                .map_err(|e| format!("Failed to write {}: {}", lock_path.display(), e))?;
            tagged += 1;
        }
    }
    if json {
        println!(
            "{{\"tagged\":{tagged},\"tag_name\":\"{tag_name}\",\"tag_value\":\"{tag_value}\"}}"
        );
    } else if tagged == 0 {
        println!("No lock files found to tag.");
    } else {
        println!("Tagged {tagged} lock file(s) with {tag_name}={tag_value}");
    }
    Ok(())
}

/// FJ-725: Migrate lock file schema between versions
pub(crate) fn cmd_lock_migrate(
    state_dir: &Path,
    from_version: &str,
    json: bool,
) -> Result<(), String> {
    let machines = discover_machines(state_dir);
    let target_version = "1.0";
    let mut migrated = 0;
    for m in &machines {
        let lock_path = state_dir.join(m).join("state.lock.yaml");
        if let Ok(data) = std::fs::read_to_string(&lock_path) {
            if let Ok(mut lock) = serde_yaml_ng::from_str::<types::StateLock>(&data) {
                if lock.schema == from_version && lock.schema != target_version {
                    lock.schema = target_version.to_string();
                    let new_data = serde_yaml_ng::to_string(&lock)
                        .map_err(|e| format!("Failed to serialize: {e}"))?;
                    std::fs::write(&lock_path, new_data)
                        .map_err(|e| format!("Failed to write: {e}"))?;
                    migrated += 1;
                }
            }
        }
    }
    if json {
        println!(
            "{{\"migrated\":{migrated},\"from_version\":\"{from_version}\",\"to_version\":\"{target_version}\"}}"
        );
    } else {
        println!(
            "Migrated {migrated} lock file(s) from schema {from_version} to {target_version}."
        );
    }
    Ok(())
}