tsafe-cli 1.2.1

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;
const VERIFY_EXIT_NOT_PINNED: i32 = 7;

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:?}"
    );
}

/// The trust store is co-located with `config.json`, which under a
/// `TSAFE_VAULT_DIR=<dir>/v` override lives at `<dir>/config.json` (the
/// override's parent). Pointing each test at its own tempdir keeps the
/// pinned-pubkey state hermetic and never touches the operator's real
/// store. Returns the value to pass as `TSAFE_VAULT_DIR`.
fn isolated_vault_dir(dir: &Path) -> PathBuf {
    dir.join("v")
}

fn pubkey_of(key: &SigningKey) -> String {
    URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes())
}

#[test]
fn require_pinned_fails_closed_exit_7_when_signer_is_not_pinned() {
    // A perfectly valid signature whose key was never pinned MUST fail
    // closed under --require-pinned. This is the core TOFU-gap closure:
    // "valid signature" is no longer sufficient — the signer must be a
    // pinned identity.
    let dir = tempdir().expect("tempdir");
    let (path, _key) = signed_artifact_path(dir.path());
    let vault = isolated_vault_dir(dir.path());

    let result = tsafe()
        .env("TSAFE_VAULT_DIR", &vault)
        .args(["attest", "verify"])
        .arg(&path)
        .arg("--require-pinned")
        .assert()
        .failure();
    let code = result.get_output().status.code();
    assert_eq!(
        code,
        Some(VERIFY_EXIT_NOT_PINNED),
        "valid-but-unpinned signer must exit 7 under --require-pinned, got {code:?}"
    );
}

#[test]
fn require_pinned_succeeds_when_signer_is_pinned() {
    // End-to-end: pin the signer's pubkey via `attest trust add`, then
    // --require-pinned verification of an artifact signed by that key
    // must succeed (exit 0). Both the crypto check AND the identity gate
    // pass.
    let dir = tempdir().expect("tempdir");
    let (path, key) = signed_artifact_path(dir.path());
    let vault = isolated_vault_dir(dir.path());
    let pubkey = pubkey_of(&key);

    // Pin the signer.
    tsafe()
        .env("TSAFE_VAULT_DIR", &vault)
        .args(["attest", "trust", "add", "ci-prod", &pubkey])
        .assert()
        .success();

    // Now verification under the gate passes.
    tsafe()
        .env("TSAFE_VAULT_DIR", &vault)
        .args(["attest", "verify"])
        .arg(&path)
        .arg("--require-pinned")
        .assert()
        .success();
}

#[test]
fn require_pinned_still_rejects_tampered_artifact_with_exit_6_not_7() {
    // If the artifact is tampered, the cryptographic check fails first
    // (exit 6) — the identity gate is only consulted on a valid
    // signature, so a tampered artifact never reaches exit 7 even though
    // its signer is pinned.
    let dir = tempdir().expect("tempdir");
    let (path, key) = signed_artifact_path(dir.path());
    let vault = isolated_vault_dir(dir.path());
    let pubkey = pubkey_of(&key);

    tsafe()
        .env("TSAFE_VAULT_DIR", &vault)
        .args(["attest", "trust", "add", "ci-prod", &pubkey])
        .assert()
        .success();

    // Tamper post-sign.
    let bytes = fs::read(&path).unwrap();
    let mut evidence: RunEvidence = serde_json::from_slice(&bytes).unwrap();
    evidence.process.exit_code = 77;
    write_evidence(&path, &evidence);

    let result = tsafe()
        .env("TSAFE_VAULT_DIR", &vault)
        .args(["attest", "verify"])
        .arg(&path)
        .arg("--require-pinned")
        .assert()
        .failure();
    let code = result.get_output().status.code();
    assert_eq!(
        code,
        Some(VERIFY_EXIT_SIGNATURE_FAILED),
        "tampered artifact must fail crypto first (exit 6), not the pin gate (7), got {code:?}"
    );
}

#[test]
fn trust_add_list_remove_round_trip() {
    let dir = tempdir().expect("tempdir");
    let vault = isolated_vault_dir(dir.path());
    let key = SigningKey::generate(&mut OsRng);
    let pubkey = pubkey_of(&key);

    tsafe()
        .env("TSAFE_VAULT_DIR", &vault)
        .args(["attest", "trust", "add", "ci-prod", &pubkey])
        .assert()
        .success();

    let listed = tsafe()
        .env("TSAFE_VAULT_DIR", &vault)
        .args(["attest", "trust", "list"])
        .assert()
        .success();
    let stdout = String::from_utf8(listed.get_output().stdout.clone()).unwrap();
    assert!(
        stdout.contains("ci-prod") && stdout.contains(&pubkey),
        "trust list must show the pinned identity: {stdout}"
    );

    tsafe()
        .env("TSAFE_VAULT_DIR", &vault)
        .args(["attest", "trust", "remove", "ci-prod"])
        .assert()
        .success();

    // After removal, --require-pinned fails closed again.
    let (path, signer) = signed_artifact_path(dir.path());
    let _ = signer;
    let result = tsafe()
        .env("TSAFE_VAULT_DIR", &vault)
        .args(["attest", "verify"])
        .arg(&path)
        .arg("--require-pinned")
        .assert()
        .failure();
    assert_eq!(
        result.get_output().status.code(),
        Some(VERIFY_EXIT_NOT_PINNED)
    );
}

#[test]
fn trust_add_rejects_malformed_pubkey() {
    let dir = tempdir().expect("tempdir");
    let vault = isolated_vault_dir(dir.path());
    tsafe()
        .env("TSAFE_VAULT_DIR", &vault)
        .args(["attest", "trust", "add", "bad", "this-is-not-a-key"])
        .assert()
        .failure();
}

#[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}"
    );
}