kernex-agent 0.4.4

CLI dev assistant powered by Kernex runtime
use std::io::Write as _;
use std::path::Path;

use super::types::TrustLevel;

pub enum AuditEvent<'a> {
    Installed {
        name: &'a str,
        source: &'a str,
        sha256: &'a str,
        trust: &'a TrustLevel,
    },
    Removed {
        name: &'a str,
    },
    Verified {
        name: &'a str,
        result: &'a str,
    },
    Loaded {
        name: &'a str,
        trust: &'a TrustLevel,
    },
    /// SHA-256 mismatch detected at load time. The skill was rejected.
    Modified {
        name: &'a str,
        expected: &'a str,
        actual: &'a str,
    },
}

fn escape_json_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            _ => out.push(c),
        }
    }
    out
}

fn current_timestamp() -> String {
    crate::utils::iso_timestamp()
}

fn format_event(event: &AuditEvent<'_>) -> String {
    let ts = current_timestamp();
    match event {
        AuditEvent::Installed {
            name,
            source,
            sha256,
            trust,
        } => {
            format!(
                r#"{{"timestamp":"{}","event":"installed","name":"{}","source":"{}","sha256":"{}","trust":"{}"}}"#,
                escape_json_string(&ts),
                escape_json_string(name),
                escape_json_string(source),
                escape_json_string(sha256),
                trust,
            )
        }
        AuditEvent::Removed { name } => {
            format!(
                r#"{{"timestamp":"{}","event":"removed","name":"{}"}}"#,
                escape_json_string(&ts),
                escape_json_string(name),
            )
        }
        AuditEvent::Verified { name, result } => {
            format!(
                r#"{{"timestamp":"{}","event":"verified","name":"{}","result":"{}"}}"#,
                escape_json_string(&ts),
                escape_json_string(name),
                escape_json_string(result),
            )
        }
        AuditEvent::Loaded { name, trust } => {
            format!(
                r#"{{"timestamp":"{}","event":"loaded","name":"{}","trust":"{}"}}"#,
                escape_json_string(&ts),
                escape_json_string(name),
                trust,
            )
        }
        AuditEvent::Modified {
            name,
            expected,
            actual,
        } => {
            format!(
                r#"{{"timestamp":"{}","event":"modified","name":"{}","expected":"{}","actual":"{}"}}"#,
                escape_json_string(&ts),
                escape_json_string(name),
                escape_json_string(expected),
                escape_json_string(actual),
            )
        }
    }
}

pub fn log_event(data_dir: &Path, event: &AuditEvent<'_>) {
    let log_path = data_dir.join("skills-audit.log");
    let line = format!("{}\n", format_event(event));

    // Open with mode 0o600 on Unix at create time so we never go through a
    // window where the file is world-readable. The log records every skill
    // install (source URL, sha256, trust level); on shared hosts the
    // default 0o644 would let any local user enumerate what's installed.
    let file = open_append_private(&log_path);

    match file {
        Ok(mut f) => {
            if let Err(e) = f.write_all(line.as_bytes()) {
                eprintln!("warning: failed to write audit log: {e}");
            }
        }
        Err(e) => {
            eprintln!("warning: failed to open audit log: {e}");
        }
    }
}

#[cfg(unix)]
fn open_append_private(path: &Path) -> std::io::Result<std::fs::File> {
    use std::os::unix::fs::OpenOptionsExt;
    std::fs::OpenOptions::new()
        .append(true)
        .create(true)
        .mode(0o600)
        .open(path)
}

#[cfg(not(unix))]
fn open_append_private(path: &Path) -> std::io::Result<std::fs::File> {
    std::fs::OpenOptions::new()
        .append(true)
        .create(true)
        .open(path)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn escape_json_string_plain() {
        assert_eq!(escape_json_string("hello"), "hello");
    }

    #[test]
    fn escape_json_string_quotes() {
        assert_eq!(escape_json_string(r#"say "hello""#), r#"say \"hello\""#);
    }

    #[test]
    fn escape_json_string_backslash() {
        assert_eq!(escape_json_string(r"path\to\file"), r"path\\to\\file");
    }

    #[test]
    fn escape_json_string_newlines() {
        assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
        assert_eq!(escape_json_string("line1\rline2"), "line1\\rline2");
    }

    #[test]
    fn escape_json_string_tab() {
        assert_eq!(escape_json_string("col1\tcol2"), "col1\\tcol2");
    }

    #[test]
    fn escape_json_string_mixed() {
        assert_eq!(
            escape_json_string("\"hello\"\n\tworld\\"),
            "\\\"hello\\\"\\n\\tworld\\\\"
        );
    }

    #[test]
    fn format_event_installed() {
        let event = AuditEvent::Installed {
            name: "my-skill",
            source: "acme/my-skill",
            sha256: "abc123",
            trust: &TrustLevel::Sandboxed,
        };
        let json = format_event(&event);
        assert!(json.contains(r#""event":"installed""#));
        assert!(json.contains(r#""name":"my-skill""#));
        assert!(json.contains(r#""source":"acme/my-skill""#));
        assert!(json.contains(r#""sha256":"abc123""#));
        assert!(json.contains(r#""trust":"sandboxed""#));
        assert!(json.contains(r#""timestamp":""#));
    }

    #[test]
    fn format_event_removed() {
        let event = AuditEvent::Removed { name: "old-skill" };
        let json = format_event(&event);
        assert!(json.contains(r#""event":"removed""#));
        assert!(json.contains(r#""name":"old-skill""#));
    }

    #[test]
    fn format_event_verified() {
        let event = AuditEvent::Verified {
            name: "test-skill",
            result: "ok",
        };
        let json = format_event(&event);
        assert!(json.contains(r#""event":"verified""#));
        assert!(json.contains(r#""name":"test-skill""#));
        assert!(json.contains(r#""result":"ok""#));
    }

    #[test]
    fn format_event_loaded() {
        let event = AuditEvent::Loaded {
            name: "active-skill",
            trust: &TrustLevel::Trusted,
        };
        let json = format_event(&event);
        assert!(json.contains(r#""event":"loaded""#));
        assert!(json.contains(r#""name":"active-skill""#));
        assert!(json.contains(r#""trust":"trusted""#));
    }

    #[test]
    fn format_event_escapes_special_chars() {
        let event = AuditEvent::Installed {
            name: "skill-with-\"quotes\"",
            source: "path\\with\\backslash",
            sha256: "hash",
            trust: &TrustLevel::Standard,
        };
        let json = format_event(&event);
        assert!(json.contains(r#"skill-with-\"quotes\""#));
        assert!(json.contains(r#"path\\with\\backslash"#));
    }

    #[test]
    fn log_event_creates_file() {
        let dir = tempfile::tempdir().unwrap();
        let tmp = dir.path();

        let event = AuditEvent::Installed {
            name: "test",
            source: "acme/test",
            sha256: "abc",
            trust: &TrustLevel::Sandboxed,
        };
        log_event(tmp, &event);

        let log_path = tmp.join("skills-audit.log");
        assert!(log_path.exists());

        let content = std::fs::read_to_string(&log_path).unwrap();
        assert!(content.contains("installed"));
        assert!(content.contains("test"));
    }

    #[test]
    fn log_event_appends() {
        let dir = tempfile::tempdir().unwrap();
        let tmp = dir.path();

        log_event(
            tmp,
            &AuditEvent::Installed {
                name: "first",
                source: "a/b",
                sha256: "x",
                trust: &TrustLevel::Sandboxed,
            },
        );
        log_event(tmp, &AuditEvent::Removed { name: "second" });

        let content = std::fs::read_to_string(tmp.join("skills-audit.log")).unwrap();
        let lines: Vec<&str> = content.lines().collect();
        assert_eq!(lines.len(), 2);
        assert!(lines[0].contains("first"));
        assert!(lines[1].contains("second"));
    }
}