assay-cli 3.7.0

CLI for Assay
use super::suggest::PolicySuggestion;
use std::fs::File;
use std::io::Write;
use std::path::Path;

/// Write policy suggestion to YAML string (deterministic).
pub fn write_yaml(s: &PolicySuggestion) -> String {
    let mut out = String::new();
    out.push_str("# Generated by `assay sandbox --profile`\n");
    out.push_str(&format!("api_version: {}\n", s.api_version));

    out.push_str("extends:\n");
    for e in &s.extends {
        out.push_str(&format!("  - {}\n", e));
    }

    out.push_str("fs:\n");
    out.push_str("  allow:\n");
    for p in &s.fs.allow {
        out.push_str(&format!("    - \"{}\"\n", escape(p)));
    }
    if s.fs.deny.is_empty() {
        out.push_str("  deny: []\n");
    } else {
        out.push_str("  deny:\n");
        for p in &s.fs.deny {
            out.push_str(&format!("    - \"{}\"\n", escape(p)));
        }
    }

    out.push_str("net:\n");
    if s.net.allow.is_empty() {
        out.push_str("  allow: []\n");
    } else {
        out.push_str("  allow:\n");
        for p in &s.net.allow {
            out.push_str(&format!("    - \"{}\"\n", escape(p)));
        }
    }
    out.push_str("  deny:\n");
    for p in &s.net.deny {
        out.push_str(&format!("    - \"{}\"\n", escape(p)));
    }

    out.push_str("env:\n");
    out.push_str("  allow:\n");
    for k in &s.env.allow {
        out.push_str(&format!("    - \"{}\"\n", escape(k)));
    }

    out.push_str("processes:\n");
    out.push_str("  allow:\n");
    for x in &s.processes.allow {
        out.push_str(&format!("    - \"{}\"\n", escape(x)));
    }

    out.push_str("meta:\n");
    out.push_str("  counters:\n");
    for (k, v) in &s.meta.counters {
        out.push_str(&format!("    \"{}\": {}\n", escape(k), v));
    }

    if !s.meta.notes.is_empty() {
        out.push_str("  notes:\n");
        for n in &s.meta.notes {
            out.push_str(&format!("    - \"{}\"\n", escape(n)));
        }
    }

    out
}

fn escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

/// Write policy suggestion to JSON string (deterministic).
pub fn write_json(s: &PolicySuggestion) -> anyhow::Result<String> {
    // We use serde_json for SOTA reliability
    Ok(serde_json::to_string_pretty(s)?)
}

/// Save content to disk atomically and safely.
pub fn save_atomic(path: &Path, content: &str) -> anyhow::Result<()> {
    // 1. Check if target is a symlink (avoid TOCTOU/symlink attacks if possible)
    if path.exists() {
        let meta = std::fs::symlink_metadata(path)?;
        if meta.file_type().is_symlink() {
            anyhow::bail!("Refusing to write to symlink: {}", path.display());
        }
    }

    // Unique temp path to avoid race conditions
    let pid = std::process::id();
    let temp_path = path.with_extension(format!("tmp.{}", pid));

    // RAII Cleanup handler
    struct Cleanup<'a>(&'a Path);
    impl Drop for Cleanup<'_> {
        fn drop(&mut self) {
            let _ = std::fs::remove_file(self.0);
        }
    }
    let _cleanup = Cleanup(&temp_path);

    // 2. Create temp file
    let mut file = File::create(&temp_path)?;

    // 3. Set permissions 0600 (Unix)
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = file.metadata()?.permissions();
        perms.set_mode(0o600);
        file.set_permissions(perms)?;
    }

    // 4. Write content
    file.write_all(content.as_bytes())?;

    // 5. Fsync
    file.sync_all()?;
    drop(file); // Ensure closed

    // 6. Rename (Atomic)
    std::fs::rename(&temp_path, path)?;

    // 7. Fsync Directory (SOTA durability: ensure rename is persisted)
    if let Some(parent) = path.parent() {
        if let Ok(dir) = File::open(parent) {
            let _ = dir.sync_all();
        }
    }

    // Defuse cleanup: path is successfully moved
    std::mem::forget(_cleanup);

    Ok(())
}