tsafe-attest 1.1.0

Attestation pipeline for tsafe — secret scanner + env-injection contract + run-evidence harness (algol-merged)
Documentation
//! Append-only audit-event log + lifecycle event constructors.
//!
//! # Provenance
//!
//! Lifted from `algol/src/event_log.rs` @ commit `6956cfd347cd8ce492231ba5aaa4952227d72689`
//! (`6956cfd`, branch `master`, repo `0ryant/algol`). Re-licensed
//! `AGPL-3.0-or-later` per ec `draft-algol-into-tsafe-merge.md` +
//! Phase 4 plan (`portfolio-algol-tsafe-phase4-attest-run-2026-05-21.md`)
//! + operator decision 2026-05-21.
//!
//! # Phase 4 changes
//!
//! - Schema constants and CloudEvent type strings flip to the `tsafe.*`
//!   namespace via [`crate::events`].
//! - Artifact-ref hashes and id/correlation/idempotency/fingerprint
//!   derivations use BLAKE3 (`crate::hash::blake3_hash`) per
//!   ec ADR-0003. Legacy reads keep working through the v1.x compat
//!   window since validators accept both prefixes.
//! - Artifact schema strings for scanner / contract events are the new
//!   `tsafe.scan.v1` / `tsafe.contract.v1` names (legacy `algol.*`
//!   schemas are still accepted on parse).

use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use chrono::Utc;
use tsafe_core::run_evidence::RunEvidence;

use crate::events::{
    project_enforce_completed_cloudevent, project_lifecycle_cloudevent, AuditActor,
    AuditArtifactRef, AuditOutcome, AuditResource, AuditTouchedResource, CloudEvent,
    EnforceCompletedInput, LifecycleEventInput, DEFAULT_CLOUDEVENT_SOURCE,
    DEFAULT_PROVENANCE_KIND_OTHER, EVENT_AUDIT_FAILED, EVENT_AUDIT_RENDERED,
    EVENT_CONTRACT_CREATED, EVENT_CONTRACT_REJECTED, EVENT_ENFORCE_COMPLETED,
    EVENT_ENFORCE_REJECTED, EVENT_SCAN_COMPLETED, EVENT_SCAN_FAILED,
};
use crate::hash::blake3_hash;
use crate::model::SCAN_SCHEMA;
use tsafe_core::attest_contract::ATTEST_CONTRACT_SCHEMA;

/// Crate version embedded in lifecycle audit events (Phase 4 rename from
/// algol's `ALGOL_VERSION`).
pub const TSAFE_ATTEST_VERSION: &str = env!("CARGO_PKG_VERSION");

#[derive(Debug, Clone)]
pub struct EventLog {
    path: PathBuf,
}

impl EventLog {
    pub fn new(path: impl Into<PathBuf>) -> Self {
        Self { path: path.into() }
    }

    pub fn append(&self, event: &CloudEvent) -> Result<()> {
        event
            .ensure_valid()
            .map_err(|errors| anyhow::anyhow!("invalid audit event: {errors}"))?;
        ensure_parent_dir(&self.path)?;
        let mut file = OpenOptions::new()
            .create(true)
            .append(true)
            .open(&self.path)
            .with_context(|| format!("open audit events: {}", self.path.display()))?;
        let mut line = serde_json::to_string(event)
            .with_context(|| format!("serialize audit event: {}", self.path.display()))?;
        line.push('\n');
        file.write_all(line.as_bytes())
            .with_context(|| format!("write audit event: {}", self.path.display()))?;
        Ok(())
    }

    pub fn path(&self) -> &Path {
        &self.path
    }
}

pub fn enforce_completed_event(evidence: &RunEvidence, run_output: &Path) -> Result<CloudEvent> {
    let run_bytes = fs::read(run_output)
        .with_context(|| format!("read run evidence: {}", run_output.display()))?;
    let run_hash = blake3_hash(&run_bytes);
    let event = project_enforce_completed_cloudevent(&EnforceCompletedInput {
        event_id: stable_hash("event", EVENT_ENFORCE_COMPLETED, &run_hash),
        source: String::new(),
        time: evidence.finished_at,
        subject: run_hash.clone(),
        actor: local_actor(Some(evidence.machine.username_hash.clone())),
        resource: AuditResource {
            kind: "run".to_string(),
            id: run_hash.clone(),
        },
        correlation_id: run_hash.clone(),
        idempotency_key: stable_hash("idempotency", EVENT_ENFORCE_COMPLETED, &run_hash),
        fingerprint: Some(stable_hash(
            "fingerprint",
            EVENT_ENFORCE_COMPLETED,
            &format!("{}:{}", evidence.contract.hash, evidence.command.join("\0")),
        )),
        artifact_refs: vec![AuditArtifactRef {
            schema: evidence.schema.clone(),
            path: run_output.display().to_string(),
            hash: run_hash.clone(),
        }],
        touched_resources: touched_env_refs(evidence),
        artifact_hash: Some(run_hash),
        tsafe_attest_version: evidence.tsafe_attest_version.clone(),
    });
    Ok(event)
}

pub fn lifecycle_event(
    event_type: &str,
    operation: &str,
    outcome: AuditOutcome,
    resource_kind: &str,
    resource_id: impl AsRef<str>,
    reason_code: Option<String>,
    artifact: Option<AuditArtifactRef>,
) -> CloudEvent {
    let resource_id = resource_id.as_ref();
    let event_id = stable_hash("event", event_type, resource_id);
    let artifact_hash = artifact.as_ref().map(|item| item.hash.clone());
    let artifact_refs = artifact.into_iter().collect::<Vec<_>>();
    project_lifecycle_cloudevent(&LifecycleEventInput {
        event_type: event_type.to_string(),
        operation: operation.to_string(),
        outcome,
        reason_code,
        event_id: event_id.clone(),
        source: DEFAULT_CLOUDEVENT_SOURCE.to_string(),
        time: Utc::now(),
        subject: resource_id.to_string(),
        actor: local_actor(None),
        resource: AuditResource {
            kind: resource_kind.to_string(),
            id: resource_id.to_string(),
        },
        correlation_id: event_id.clone(),
        idempotency_key: stable_hash("idempotency", event_type, resource_id),
        fingerprint: Some(stable_hash("fingerprint", event_type, resource_id)),
        artifact_refs,
        touched_resources: Vec::new(),
        artifact_hash,
        tsafe_attest_version: TSAFE_ATTEST_VERSION.to_string(),
        provenancekind: DEFAULT_PROVENANCE_KIND_OTHER.to_string(),
    })
}

pub fn scan_completed_event(path: &Path) -> CloudEvent {
    lifecycle_event(
        EVENT_SCAN_COMPLETED,
        "scan.completed",
        AuditOutcome::Success,
        "scan",
        blake3_hash(path.display().to_string()),
        None,
        artifact_ref(SCAN_SCHEMA, path).ok(),
    )
}

pub fn scan_failed_event(repo: &Path, reason: &str) -> CloudEvent {
    lifecycle_event(
        EVENT_SCAN_FAILED,
        "scan.failed",
        AuditOutcome::Failure,
        "repo",
        blake3_hash(repo.display().to_string()),
        Some(reason_code(reason)),
        None,
    )
}

pub fn contract_created_event(path: &Path) -> CloudEvent {
    lifecycle_event(
        EVENT_CONTRACT_CREATED,
        "contract.created",
        AuditOutcome::Success,
        "contract",
        blake3_hash(path.display().to_string()),
        None,
        artifact_ref(ATTEST_CONTRACT_SCHEMA, path).ok(),
    )
}

pub fn contract_rejected_event(path: &Path, reason: &str) -> CloudEvent {
    lifecycle_event(
        EVENT_CONTRACT_REJECTED,
        "contract.rejected",
        AuditOutcome::Rejected,
        "contract",
        blake3_hash(path.display().to_string()),
        Some(reason_code(reason)),
        None,
    )
}

pub fn enforce_rejected_event(path: &Path, reason: &str) -> CloudEvent {
    lifecycle_event(
        EVENT_ENFORCE_REJECTED,
        "enforce.rejected",
        AuditOutcome::Rejected,
        "contract",
        blake3_hash(path.display().to_string()),
        Some(reason_code(reason)),
        None,
    )
}

pub fn audit_rendered_event(path: &Path) -> CloudEvent {
    lifecycle_event(
        EVENT_AUDIT_RENDERED,
        "audit.rendered",
        AuditOutcome::Success,
        "audit",
        blake3_hash(path.display().to_string()),
        None,
        None,
    )
}

pub fn audit_failed_event(path: &Path, reason: &str) -> CloudEvent {
    lifecycle_event(
        EVENT_AUDIT_FAILED,
        "audit.failed",
        AuditOutcome::Failure,
        "run",
        blake3_hash(path.display().to_string()),
        Some(reason_code(reason)),
        None,
    )
}

fn local_actor(id_hash: Option<String>) -> AuditActor {
    AuditActor {
        kind: "local-user".to_string(),
        id_hash,
    }
}

fn artifact_ref(schema: &str, path: &Path) -> Result<AuditArtifactRef> {
    let bytes = fs::read(path).with_context(|| format!("read artifact: {}", path.display()))?;
    Ok(AuditArtifactRef {
        schema: schema.to_string(),
        path: path.display().to_string(),
        hash: blake3_hash(bytes),
    })
}

fn touched_env_refs(evidence: &RunEvidence) -> Vec<AuditTouchedResource> {
    evidence
        .environment
        .secrets_injected
        .iter()
        .map(|item| &item.name)
        .chain(
            evidence
                .environment
                .sensitive_env_denied
                .iter()
                .map(|item| &item.name),
        )
        .map(|name| AuditTouchedResource {
            kind: "env".to_string(),
            name_ref: Some(blake3_hash(format!("env:{name}"))),
            id_ref: None,
        })
        .collect()
}

fn stable_hash(prefix: &str, event_type: &str, subject: &str) -> String {
    blake3_hash(format!("{prefix}:{event_type}:{subject}"))
}

fn reason_code(reason: &str) -> String {
    let raw = reason
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() {
                ch.to_ascii_lowercase()
            } else {
                '_'
            }
        })
        .collect::<String>()
        .trim_matches('_')
        .chars()
        .take(80)
        .collect::<String>();
    let mut collapsed = String::new();
    for ch in raw.chars() {
        if ch == '_' && collapsed.ends_with('_') {
            continue;
        }
        collapsed.push(ch);
    }
    collapsed
}

fn ensure_parent_dir(path: &Path) -> Result<()> {
    if let Some(parent) = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        fs::create_dir_all(parent)
            .with_context(|| format!("create output directory: {}", parent.display()))?;
    }
    Ok(())
}

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

    #[test]
    fn reason_code_removes_sensitive_shape() {
        assert_eq!(
            reason_code("Contract violation: missing API_TOKEN!"),
            "contract_violation_missing_api_token"
        );
    }
}