tsafe-cli 1.1.0

Local-first secrets runtime for developers — inject credentials via exec, never shell history or .env files
Documentation
//! Phase 5 e2e test: `tsafe attest verify` covers the full chain.
//!
//! Strategy:
//!
//! - Build a signed `RunEvidence` artifact in-process via
//!   `tsafe_core::sign::sign_evidence` (so the test does not depend on
//!   the OS credential store, which may be unavailable in CI sandboxes).
//! - Write it to a tempdir.
//! - Invoke `tsafe attest verify <path>` via `assert_cmd`.
//! - Verify exit codes for the three documented outcomes:
//!   - `0` — valid signature
//!   - `5` — signature absent
//!   - `6` — verification failed (tampered)
//!
//! Provenance: Phase 5 of the algol→tsafe merge per
//! `ecosystem-catalog/portfolio-algol-tsafe-roadmap-to-code-complete-2026-05-21.md`
//! §5.1.

use assert_cmd::Command;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::Utc;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::tempdir;
use tsafe_core::run_evidence::{
    blake3_hash, ContractRef, DeniedSensitiveEnvEvidence, EnforcementResult, EnvironmentEvidence,
    InjectedSecretEvidence, MachineEvidence, ProcessEvidence, RiskDelta, RunEvidence,
    RUN_EVIDENCE_VERSION, RUN_SCHEMA,
};
use tsafe_core::sign::sign_evidence;

const VERIFY_EXIT_SIGNATURE_ABSENT: i32 = 5;
const VERIFY_EXIT_SIGNATURE_FAILED: i32 = 6;

fn tsafe() -> Command {
    Command::cargo_bin("tsafe").expect("tsafe binary built by cargo")
}

fn well_formed() -> RunEvidence {
    let now = Utc::now();
    RunEvidence {
        schema: RUN_SCHEMA.to_string(),
        tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
        started_at: now,
        finished_at: now,
        repo_path: "/tmp/phase5-cli-test".to_string(),
        repo_commit: None,
        command: vec!["true".to_string()],
        contract: ContractRef {
            path: "tsafe.contract.json".to_string(),
            hash: blake3_hash(b"contract"),
        },
        environment: EnvironmentEvidence {
            parent_env_count: 2,
            child_env_count: 1,
            removed_env_count: 1,
            safe_baseline_injected: vec!["PATH".to_string()],
            secrets_injected: vec![InjectedSecretEvidence {
                name: "DATABASE_URL".to_string(),
                source: "literal://demo/DATABASE_URL".to_string(),
                hash: blake3_hash(b"db"),
                redacted_value: "p***".to_string(),
                required: true,
            }],
            sensitive_env_denied: vec![DeniedSensitiveEnvEvidence {
                name: "AWS_SECRET_ACCESS_KEY".to_string(),
                hash: blake3_hash(b"aws"),
                reason: "test".to_string(),
            }],
        },
        process: ProcessEvidence {
            pid: 1234,
            exit_code: 0,
            duration_ms: 42,
            cwd: "/tmp/phase5-cli-test".to_string(),
        },
        machine: MachineEvidence {
            hostname_hash: blake3_hash(b"host"),
            username_hash: blake3_hash(b"user"),
            os: "linux".to_string(),
            arch: "x86_64".to_string(),
        },
        result: EnforcementResult {
            contract_enforced: true,
            violations: Vec::new(),
            risk_delta: RiskDelta {
                before_score: 10,
                after_score: 0,
            },
        },
        signature: None,
    }
}

fn write_evidence(path: &Path, evidence: &RunEvidence) {
    fs::write(path, serde_json::to_vec_pretty(evidence).unwrap())
        .expect("write evidence to tempdir");
}

fn signed_artifact_path(dir: &Path) -> (PathBuf, SigningKey) {
    let key = SigningKey::generate(&mut OsRng);
    let signed = sign_evidence(&well_formed(), &key).expect("sign");
    let path = dir.join("signed-evidence.json");
    write_evidence(&path, &signed.evidence);
    (path, key)
}

fn unsigned_artifact_path(dir: &Path) -> PathBuf {
    let evidence = well_formed();
    assert!(evidence.signature.is_none());
    let path = dir.join("unsigned-evidence.json");
    write_evidence(&path, &evidence);
    path
}

#[test]
fn verify_succeeds_with_a_correctly_signed_artifact_tofu_path() {
    let dir = tempdir().expect("tempdir");
    let (path, _key) = signed_artifact_path(dir.path());

    tsafe()
        .args(["attest", "verify"])
        .arg(&path)
        .assert()
        .success();
}

#[test]
fn verify_succeeds_with_explicit_operator_pubkey() {
    let dir = tempdir().expect("tempdir");
    let (path, key) = signed_artifact_path(dir.path());
    let pubkey = URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes());

    tsafe()
        .args(["attest", "verify"])
        .arg(&path)
        .args(["--pubkey", &pubkey])
        .assert()
        .success();
}

#[test]
fn verify_returns_exit_code_5_when_signature_is_absent() {
    let dir = tempdir().expect("tempdir");
    let path = unsigned_artifact_path(dir.path());

    let result = tsafe()
        .args(["attest", "verify"])
        .arg(&path)
        .assert()
        .failure();
    let code = result.get_output().status.code();
    assert_eq!(
        code,
        Some(VERIFY_EXIT_SIGNATURE_ABSENT),
        "expected exit code 5 for unsigned artifact, got {code:?}"
    );
}

#[test]
fn verify_returns_exit_code_6_when_artifact_is_tampered() {
    let dir = tempdir().expect("tempdir");
    let (path, _key) = signed_artifact_path(dir.path());

    // Mutate the artifact post-sign by rewriting the process exit code.
    let bytes = fs::read(&path).unwrap();
    let mut evidence: RunEvidence = serde_json::from_slice(&bytes).unwrap();
    evidence.process.exit_code = 99;
    write_evidence(&path, &evidence);

    let result = tsafe()
        .args(["attest", "verify"])
        .arg(&path)
        .assert()
        .failure();
    let code = result.get_output().status.code();
    assert_eq!(
        code,
        Some(VERIFY_EXIT_SIGNATURE_FAILED),
        "expected exit code 6 for tampered artifact, got {code:?}"
    );
}

#[test]
fn verify_returns_exit_code_6_when_operator_pubkey_is_wrong() {
    let dir = tempdir().expect("tempdir");
    let (path, _key) = signed_artifact_path(dir.path());
    let wrong_key = SigningKey::generate(&mut OsRng);
    let wrong_pubkey = URL_SAFE_NO_PAD.encode(wrong_key.verifying_key().as_bytes());

    let result = tsafe()
        .args(["attest", "verify"])
        .arg(&path)
        .args(["--pubkey", &wrong_pubkey])
        .assert()
        .failure();
    let code = result.get_output().status.code();
    assert_eq!(
        code,
        Some(VERIFY_EXIT_SIGNATURE_FAILED),
        "expected exit code 6 for wrong operator pubkey, got {code:?}"
    );
}

#[test]
fn verify_help_documents_phase5_disclosure() {
    let assert = tsafe()
        .args(["attest", "verify", "--help"])
        .assert()
        .success();
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    assert!(
        stdout.contains("TOFU") || stdout.contains("out of band"),
        "tsafe attest verify --help must include the Phase 5 honest-disclosure copy: {stdout}"
    );
}