tsafe-core 2.0.0

Core runtime engine for tsafe — encrypted credential storage, process injection contracts, audit log, RBAC
Documentation
//! `axiom.audit.v1` append-only, BLAKE3-chained audit trail for tsafe.
//!
//! Per ecosystem pattern 09 (audit-chain), a tsafe operation can append a
//! single JSONL row to `<dir>/audit-trail.jsonl`, chained
//! `row_hash = BLAKE3(JCS(row_without_row_hash))`, `prev_hash` = previous
//! `row_hash` (genesis = 64 zeros). The row type, append logic, and chain
//! verification live in the shared [`axiom_audit`] crate; this module is the
//! tsafe-shaped wrapper that stamps `tool = "tsafe"`.
//!
//! This is the cohort-standard cross-tool trail (the same shape tdep / tledger
//! / tflip emit). It is additive and orthogonal to the existing per-profile
//! [`crate::audit`] JSONL receipt log: it does not read, write, or alter the
//! vault, secret material, or the never-reveal path. It records only the
//! operation name, outcome, process exit code (pattern 11), and an optional
//! receipt linkage.

use std::path::Path;

use crate::errors::SafeError;

pub use axiom_audit::{
    AuditRow, ChainVerdict, ReceiptLink, AUDIT_SCHEMA, GENESIS_HASH, TRAIL_FILENAME,
};

/// Canonical tool name stamped on every tsafe audit row.
pub const TOOL_NAME: &str = "tsafe";

/// Tool version stamped on audit rows.
pub const TOOL_VERSION: &str = env!("CARGO_PKG_VERSION");

/// What one tsafe operation contributes to the trail.
#[derive(Debug, Clone)]
pub struct AuditEntry {
    /// `"<verb> <subverb?>"`, e.g. `"attest run"` or `"exec"`.
    pub operation: String,
    /// `"ok" | "failed" | "degraded"`.
    pub outcome: String,
    /// Process exit code for the operation (pattern 11; see
    /// [`axiom_exit::Exit::as_i32`]).
    pub exit_code: i32,
    /// Receipt linkage and its on-disk layout.
    pub receipt: ReceiptLink,
}

/// Read all rows of a trail. Returns an empty vec if the file is absent.
///
/// # Errors
/// [`SafeError::Audit`] if the trail cannot be read or a line is unparseable.
pub fn read_trail(path: &Path) -> Result<Vec<AuditRow>, SafeError> {
    Ok(axiom_audit::read_rows(path)?)
}

/// Append one row to `<dir>/audit-trail.jsonl`, deriving the chain bookkeeping
/// from the existing tail. Append-only.
///
/// # Errors
/// [`SafeError::Audit`] if the existing trail is unparseable or the append
/// fails.
pub fn append(dir: &Path, timestamp: &str, entry: &AuditEntry) -> Result<AuditRow, SafeError> {
    let trail = dir.join(TRAIL_FILENAME);
    let row = axiom_audit::append(
        &trail,
        &axiom_audit::AuditEntry {
            tool: TOOL_NAME.to_string(),
            tool_version: TOOL_VERSION.to_string(),
            operation: entry.operation.clone(),
            timestamp: timestamp.to_string(),
            outcome: entry.outcome.clone(),
            exit_code: entry.exit_code,
            receipt: entry.receipt.clone(),
        },
    )?;
    Ok(row)
}

/// Verify a `<dir>/audit-trail.jsonl` chain end to end (pattern 09).
///
/// # Errors
/// [`SafeError::Audit`] if the trail cannot be read or re-hashed.
pub fn verify_chain(path: &Path) -> Result<ChainVerdict, SafeError> {
    Ok(axiom_audit::verify_chain(path)?)
}

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

    fn entry(op: &str, exit: i32) -> AuditEntry {
        AuditEntry {
            operation: op.to_string(),
            outcome: if exit == 0 { "ok" } else { "failed" }.to_string(),
            exit_code: exit,
            receipt: ReceiptLink::Empty,
        }
    }

    #[test]
    fn append_chains_and_verifies() {
        let dir = tempfile::tempdir().unwrap();
        let r0 = append(dir.path(), "2026-06-22T00:00:00Z", &entry("attest run", 0)).unwrap();
        let r1 = append(dir.path(), "2026-06-22T00:00:01Z", &entry("attest run", 1)).unwrap();
        assert_eq!(r0.seq, 0);
        assert_eq!(r0.tool, TOOL_NAME);
        assert_eq!(r0.prev_hash, GENESIS_HASH);
        assert_eq!(r1.seq, 1);
        assert_eq!(r1.prev_hash, r0.row_hash);

        let trail = dir.path().join(TRAIL_FILENAME);
        match verify_chain(&trail).unwrap() {
            ChainVerdict::Valid { rows, head_hash } => {
                assert_eq!(rows, 2);
                assert_eq!(head_hash, r1.row_hash);
            }
            other @ ChainVerdict::Broken(_) => panic!("expected Valid, got {other:?}"),
        }
    }

    #[test]
    fn tampered_row_breaks_chain() {
        let dir = tempfile::tempdir().unwrap();
        append(dir.path(), "2026-06-22T00:00:00Z", &entry("exec", 0)).unwrap();
        let trail = dir.path().join(TRAIL_FILENAME);
        let mut rows = read_trail(&trail).unwrap();
        rows[0].exit_code = 99;
        let line = serde_json::to_string(&rows[0]).unwrap();
        std::fs::write(&trail, format!("{line}\n")).unwrap();
        assert!(matches!(
            verify_chain(&trail).unwrap(),
            ChainVerdict::Broken(_)
        ));
    }

    #[test]
    fn schema_is_axiom_audit_v1() {
        assert_eq!(AUDIT_SCHEMA, "axiom.audit.v1");
    }
}