astrid 0.10.1

Command-line interface for Astrid secure agent runtime
use sigstore_verify::trust_root::{SigstoreInstance, TrustedRoot};

use super::*;

const LEGACY_IDENTITY: &str =
    "https://github.com/unicity-astrid/astrid/.github/workflows/release.yml@refs/tags/v0.9.4";
const FIXTURE_BYTES: &[u8] =
    include_bytes!("../../tests/fixtures/self_update/v0.9.4/SHA256SUMS.txt");
const FIXTURE_BUNDLE: &[u8] =
    include_bytes!("../../tests/fixtures/self_update/v0.9.4/SHA256SUMS.txt.sigstore.json");

fn fixture_root() -> TrustedRoot {
    TrustedRoot::from_embedded(SigstoreInstance::PublicGood)
        .expect("embedded public-good root parses")
}

fn verify_fixture(identity: &str, issuer: &str, bytes: &[u8]) -> anyhow::Result<()> {
    let bundle = parse_bundle(FIXTURE_BUNDLE)?;
    let policy = VerificationPolicy::default()
        .require_identity(identity)
        .require_issuer(issuer);
    verify(bytes, &bundle, &policy, &fixture_root())
        .map(|_| ())
        .context("fixture verification failed")
}

#[test]
fn release_identity_is_exact_and_tag_bound() {
    assert_eq!(
        release_identity("1.2.3"),
        "https://github.com/astrid-runtime/astrid/.github/workflows/release.yml@refs/tags/v1.2.3"
    );
}

#[test]
fn staged_download_failures_preserve_the_bounded_download_cause() {
    let bundle_error =
        anyhow::anyhow!("publisher-authentication bundle exceeds 10485760 byte limit");
    let bundle = publisher_bundle_download_error(&bundle_error);
    assert_eq!(
        bundle.to_string(),
        "publisher authentication failed: could not download Sigstore bundle: \
         publisher-authentication bundle exceeds 10485760 byte limit"
    );

    let manifest_error =
        anyhow::anyhow!("BLAKE3 integrity manifest download failed: HTTP 503 Service Unavailable");
    let manifest = integrity_manifest_download_error(&manifest_error);
    assert_eq!(
        manifest.to_string(),
        "integrity check failed: could not download BLAKE3SUMS.txt: \
         BLAKE3 integrity manifest download failed: HTTP 503 Service Unavailable"
    );
}

#[test]
fn real_bundle_verifies_with_its_historical_identity_and_signed_time() {
    // The leaf certificate expired ten minutes after v0.9.4 was signed. A
    // successful verification now proves the bundle's Rekor/TSA evidence is
    // used as signing time rather than incorrectly requiring a live leaf cert.
    verify_fixture(LEGACY_IDENTITY, GITHUB_ACTIONS_ISSUER, FIXTURE_BYTES)
        .expect("authentic historical bundle verifies");
}

#[test]
fn production_identity_rejects_the_pre_rename_release() {
    let error = authenticate_for_test(
        FIXTURE_BYTES.to_vec(),
        FIXTURE_BUNDLE,
        &release_identity("0.9.4"),
        GITHUB_ACTIONS_ISSUER,
        &fixture_root(),
    )
    .unwrap_err();
    assert!(matches!(
        error,
        UpdateStageError::PublisherAuthentication(_)
    ));
}

#[test]
fn modified_bytes_fail_publisher_authentication() {
    let mut modified = FIXTURE_BYTES.to_vec();
    modified.extend_from_slice(b"modified");
    let error = authenticate_for_test(
        modified,
        FIXTURE_BUNDLE,
        LEGACY_IDENTITY,
        GITHUB_ACTIONS_ISSUER,
        &fixture_root(),
    )
    .unwrap_err();
    assert!(matches!(
        error,
        UpdateStageError::PublisherAuthentication(_)
    ));
}

#[test]
fn rejected_evidence_cannot_reach_filesystem_mutation() {
    let production_identity = release_identity("0.9.4");
    let cases: [(Vec<u8>, &str); 2] = [
        (
            {
                let mut modified = FIXTURE_BYTES.to_vec();
                modified.extend_from_slice(b"modified");
                modified
            },
            LEGACY_IDENTITY,
        ),
        (FIXTURE_BYTES.to_vec(), production_identity.as_str()),
    ];

    for (bytes, identity) in cases {
        let mutation_root = tempfile::tempdir().expect("create mutation sentinel");
        let digest = blake3::hash(&bytes);
        let result = (|| -> Result<_, UpdateStageError> {
            let authenticated = authenticate_for_test(
                bytes,
                FIXTURE_BUNDLE,
                identity,
                GITHUB_ACTIONS_ISSUER,
                &fixture_root(),
            )?;
            let sums = format!("{digest}  archive.tar.gz\n");
            let verified = verify_integrity(authenticated, &sums, "archive.tar.gz", None)?;
            extract_verified_archive_with(verified, "archive.tar.gz", "archive", || {
                tempfile::Builder::new()
                    .prefix("extraction-")
                    .tempdir_in(mutation_root.path())
            })
            .map_err(UpdateStageError::Preparation)
        })();

        assert!(matches!(
            result.unwrap_err(),
            UpdateStageError::PublisherAuthentication(_)
        ));
        assert_eq!(
            std::fs::read_dir(mutation_root.path()).unwrap().count(),
            0,
            "publisher rejection must happen before any temp directory or archive is written"
        );
    }
}

#[test]
fn wrong_repository_workflow_tag_and_issuer_fail() {
    let wrong_values = [
        (
            "https://github.com/other/astrid/.github/workflows/release.yml@refs/tags/v0.9.4",
            GITHUB_ACTIONS_ISSUER,
        ),
        (
            "https://github.com/unicity-astrid/astrid/.github/workflows/other.yml@refs/tags/v0.9.4",
            GITHUB_ACTIONS_ISSUER,
        ),
        (
            "https://github.com/unicity-astrid/astrid/.github/workflows/release.yml@refs/tags/v0.9.5",
            GITHUB_ACTIONS_ISSUER,
        ),
        (
            "https://github.com/unicity-astrid/astrid/.github/workflows/release.yml@refs/heads/main",
            GITHUB_ACTIONS_ISSUER,
        ),
        (
            "https://github.com/unicity-astrid/astrid/.github/workflows/release.yml@refs/pull/1250/merge",
            GITHUB_ACTIONS_ISSUER,
        ),
        (LEGACY_IDENTITY, "https://issuer.example.invalid"),
    ];

    for (identity, issuer) in wrong_values {
        assert!(
            verify_fixture(identity, issuer, FIXTURE_BYTES).is_err(),
            "unexpectedly accepted identity={identity} issuer={issuer}"
        );
    }
}

#[test]
fn malformed_bundle_is_rejected() {
    assert!(matches!(
        parse_publisher_bundle(b"{not-json").unwrap_err(),
        UpdateStageError::PublisherAuthentication(_)
    ));
}

#[test]
fn production_workflow_identities_are_exact() {
    assert_eq!(
        release_identity("1.2.3"),
        "https://github.com/astrid-runtime/astrid/.github/workflows/release.yml@refs/tags/v1.2.3"
    );
    assert_eq!(
        channel_identity(),
        "https://github.com/astrid-runtime/astrid/.github/workflows/promote-channel.yml@refs/heads/main"
    );
}

#[test]
fn integrity_stage_is_strict_and_consumes_authenticated_bytes() {
    let asset = "astrid-1.0.0-x86_64-unknown-linux-gnu.tar.gz";
    let digest = blake3::hash(b"archive");
    let digest_hex = digest.to_hex().to_string();
    let body = format!("{digest_hex}  {asset}\n");
    let authenticated = PublisherAuthenticatedArchive(b"archive".to_vec());
    let verified = verify_integrity(authenticated, &body, asset, Some(digest_hex.as_str()))
        .expect("matching digest");
    assert_eq!(verified.as_bytes(), b"archive");

    let bad = PublisherAuthenticatedArchive(b"modified".to_vec());
    assert!(matches!(
        verify_integrity(bad, &body, asset, None).unwrap_err(),
        UpdateStageError::Integrity(_)
    ));
}

#[test]
fn integrity_manifest_rejects_noncanonical_and_duplicate_entries() {
    let asset = "astrid-1.0.0-x86_64-unknown-linux-gnu.tar.gz";
    let digest = blake3::hash(b"archive").to_hex();
    let uppercase = format!("{}  {asset}\n", digest.to_string().to_uppercase());
    assert!(matches!(
        verify_integrity(
            PublisherAuthenticatedArchive(b"archive".to_vec()),
            &uppercase,
            asset,
            None,
        )
        .unwrap_err(),
        UpdateStageError::Integrity(_)
    ));

    let duplicate = format!("{digest}  {asset}\n{digest}  {asset}\n");
    assert!(matches!(
        verify_integrity(
            PublisherAuthenticatedArchive(b"archive".to_vec()),
            &duplicate,
            asset,
            None,
        )
        .unwrap_err(),
        UpdateStageError::Integrity(_)
    ));

    let overlong = "a".repeat(MAX_MANIFEST_LINE_BYTES + 1);
    let error = verify_integrity(
        PublisherAuthenticatedArchive(b"archive".to_vec()),
        &overlong,
        asset,
        None,
    )
    .unwrap_err();
    assert_eq!(
        error.to_string(),
        format!(
            "integrity check failed: BLAKE3SUMS.txt line 1 exceeds {MAX_MANIFEST_LINE_BYTES} byte limit"
        )
    );
}

#[test]
fn integrity_manifest_must_match_the_signed_channel_digest() {
    let asset = "astrid-1.0.0-x86_64-unknown-linux-gnu.tar.gz";
    let digest = blake3::hash(b"archive");
    let body = format!("{}  {asset}\n", digest.to_hex());
    let wrong = blake3::hash(b"different").to_hex();
    let error = verify_integrity(
        PublisherAuthenticatedArchive(b"archive".to_vec()),
        &body,
        asset,
        Some(&wrong),
    )
    .unwrap_err();
    assert!(error.to_string().contains("signed channel digest"));
}

#[tokio::test]
#[ignore = "release workflow only: requires freshly signed archives and live TUF metadata"]
async fn release_gate_authenticates_all_archives_with_production_policy() {
    let artifacts = std::path::PathBuf::from(
        std::env::var_os("ASTRID_RELEASE_GATE_ARTIFACTS")
            .expect("ASTRID_RELEASE_GATE_ARTIFACTS must name the release artifact directory"),
    );
    let version = std::env::var("ASTRID_RELEASE_GATE_VERSION")
        .expect("ASTRID_RELEASE_GATE_VERSION must contain canonical semver without v");
    let parsed = semver::Version::parse(&version).expect("release gate version must be semver");
    assert_eq!(
        version,
        parsed.to_string(),
        "release gate version is not canonical"
    );

    let mut archives = std::fs::read_dir(&artifacts)
        .expect("read release artifact directory")
        .map(|entry| entry.expect("read release artifact entry").path())
        .filter(|path| {
            path.file_name()
                .and_then(std::ffi::OsStr::to_str)
                .is_some_and(|name| name.ends_with(".tar.gz"))
        })
        .collect::<Vec<_>>();
    archives.sort();
    assert!(!archives.is_empty(), "release gate found no archives");
    let authenticator = PublisherAuthenticator::production()
        .await
        .expect("refresh production Sigstore trust root");

    for archive_path in archives {
        let archive_name = archive_path
            .file_name()
            .and_then(std::ffi::OsStr::to_str)
            .expect("release archive name must be UTF-8");
        let bundle_path = archive_path.with_file_name(format!("{archive_name}.sigstore.json"));
        let archive = std::fs::read(&archive_path).expect("read release archive");
        let bundle = std::fs::read(&bundle_path).unwrap_or_else(|error| {
            panic!(
                "read native-verifier bundle {}: {error}",
                bundle_path.display()
            )
        });

        authenticator
            .authenticate(archive, &bundle, &version)
            .unwrap_or_else(|error| {
                panic!(
                    "native verifier rejected {} after Cosign accepted it: {error}",
                    archive_path.display()
                )
            });
    }
}