plecto 0.11.2

Plecto Proxy — a self-hostable L7 reverse proxy / API gateway extended with sandboxed WASM Component Model filters. This crate is the `plecto` binary and operator CLI.
//! E2E (tdd-workflow Phase 0) for `plecto package` + `plecto validate --resolve` (field report
//! §3.1 / §3.5): the one-shot CI packaging pipeline. `package` turns a built component + an
//! operator key into the signed offline OCI image-layout the loader requires and prints the
//! pinned image-manifest digest — nothing else — to stdout, so `DIGEST=$(plecto package …)`
//! composes. `validate --resolve` then proves, without serving, that a manifest + its layouts
//! would pass the load-time gates: digest pin, trusted signatures, SBOM binding, and the static
//! contract gate that targets each component at every shipped `plecto:filter` world. `plecto
//! conformance` — the gate `package` runs before it signs anything — is covered here too, on the
//! same fixture component.
//!
//! Drives the real compiled binary (`CARGO_BIN_EXE_plecto`).

use std::path::Path;
use std::process::Command;

use plecto_host::DevSigner;
use plecto_host::test_support::{filter_hello_component, filter_v04_component};

fn run(args: &[&str], dir: &Path) -> std::process::Output {
    Command::new(env!("CARGO_BIN_EXE_plecto"))
        .args(args)
        .current_dir(dir)
        .output()
        .unwrap()
}

/// Write a fresh ECDSA P-256 key pair: the PKCS8 private key for `--key`, the SPKI public key
/// for the manifest's `[trust]`.
fn write_key_pair(dir: &Path) {
    let (signer, private_pem) = DevSigner::generate().unwrap();
    std::fs::write(dir.join("key.pem"), private_pem.as_bytes()).unwrap();
    std::fs::write(dir.join("trust.pem"), signer.public_key_pem()).unwrap();
}

fn write_component(dir: &Path) {
    std::fs::write(dir.join("filter.wasm"), filter_hello_component()).unwrap();
}

/// Sign `component` with a fresh key pair and write the offline OCI layout directly, returning
/// the pinned digest. `plecto package` cannot do this: its conformance gate runs before it signs
/// anything, so a component no shipped world accepts never becomes an artifact through the CLI.
fn write_signed_layout(dir: &Path, component: Vec<u8>) -> String {
    let (signer, _private_pem) = DevSigner::generate().unwrap();
    std::fs::write(dir.join("trust.pem"), signer.public_key_pem()).unwrap();
    let sbom = plecto_control::bound_sbom(&component);
    let component_signature = signer.sign(&component).unwrap();
    let sbom_signature = signer.sign(&sbom).unwrap();
    plecto_control::oci::write_layout(
        &dir.join("layout"),
        &plecto_control::ResolvedArtifact {
            component,
            component_signature,
            sbom,
            sbom_signature,
        },
    )
    .unwrap()
}

fn manifest_pinning(digest: &str) -> String {
    format!(
        r#"[trust]
keys = ["trust.pem"]

[[filter]]
id = "hello"
source = "layout"
digest = "{digest}"
isolation = "trusted"

[[upstream]]
name = "app"
addresses = ["127.0.0.1:9000"]
[upstream.health]
path = "/healthz"

[[route]]
filters = ["hello"]
upstream = "app"
[route.match]
path_prefix = "/"
"#
    )
}

fn package(dir: &Path) -> std::process::Output {
    run(
        &[
            "package",
            "filter.wasm",
            "--key",
            "key.pem",
            "--out",
            "layout",
        ],
        dir,
    )
}

#[test]
fn package_prints_only_the_pinned_digest_and_the_layout_passes_resolve() {
    let dir = tempfile::tempdir().unwrap();
    write_key_pair(dir.path());
    write_component(dir.path());

    let out = package(dir.path());
    assert!(
        out.status.success(),
        "package exits 0, stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    // stdout is the pinned digest and nothing else — `DIGEST=$(plecto package …)` in CI.
    let stdout = String::from_utf8_lossy(&out.stdout);
    let digest = stdout.trim_end();
    assert!(
        digest.starts_with("sha256:") && digest.len() == "sha256:".len() + 64,
        "stdout is exactly one sha256:<hex> digest, got: {stdout:?}"
    );
    assert_eq!(
        stdout.lines().count(),
        1,
        "no extra stdout lines: {stdout:?}"
    );

    // The digest pins the layout in a manifest, and `validate --resolve` proves the load-time
    // gates (digest pin + signature + SBOM binding) pass — the closed CI pipeline of §3.1/§3.5.
    std::fs::write(dir.path().join("plecto.toml"), manifest_pinning(digest)).unwrap();
    let resolve = run(&["validate", "--resolve", "plecto.toml"], dir.path());
    assert!(
        resolve.status.success(),
        "validate --resolve accepts the packaged layout, stderr: {}",
        String::from_utf8_lossy(&resolve.stderr)
    );
}

/// The component-model preamble and nothing else: a valid, decodable component whose world
/// imports nothing outside the contract and exports none of the hooks — so every shipped world
/// rejects it, and the static gate has to say so.
const EMPTY_COMPONENT: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x0d, 0x00, 0x01, 0x00];

#[test]
fn validate_resolve_rejects_a_component_no_shipped_contract_accepts() {
    // ADR 000114: a filter built against a WIT the host no longer ships passes the digest pin,
    // the signature and the SBOM binding — and only the contract gate catches it before the
    // deploy.
    let dir = tempfile::tempdir().unwrap();
    let digest = write_signed_layout(dir.path(), EMPTY_COMPONENT.to_vec());
    std::fs::write(dir.path().join("plecto.toml"), manifest_pinning(&digest)).unwrap();

    let out = run(&["validate", "--resolve", "plecto.toml"], dir.path());
    assert!(
        !out.status.success(),
        "a component no shipped world accepts fails the pre-flight, stdout: {}",
        String::from_utf8_lossy(&out.stdout)
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        stderr.contains("component satisfies no shipped plecto:filter contract"),
        "the rejection names the contract gate: {stderr}"
    );
    for version in ["plecto:filter@0.1.0", "@0.2.0", "@0.3.0", "@0.4.0"] {
        assert!(
            stderr.contains(version),
            "every shipped contract was tried, {version} missing: {stderr}"
        );
    }
}

#[test]
fn validate_resolve_reports_the_contract_a_green_layout_satisfies() {
    let dir = tempfile::tempdir().unwrap();
    write_key_pair(dir.path());
    std::fs::write(dir.path().join("filter.wasm"), filter_v04_component()).unwrap();

    let out = package(dir.path());
    assert!(
        out.status.success(),
        "package exits 0, stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    std::fs::write(
        dir.path().join("plecto.toml"),
        manifest_pinning(stdout.trim_end()),
    )
    .unwrap();

    let resolve = run(&["validate", "--resolve", "plecto.toml"], dir.path());
    assert!(
        resolve.status.success(),
        "a conformant layout still passes, stderr: {}",
        String::from_utf8_lossy(&resolve.stderr)
    );
    let stdout = String::from_utf8_lossy(&resolve.stdout);
    assert!(
        stdout.contains("filter hello OK: artifact verified"),
        "the provenance line is unchanged: {stdout}"
    );
    assert!(
        stdout.contains(
            "filter hello contract OK: satisfies plecto:filter@0.4.0 (static check — a pass is \
             not a load guarantee; the real load remains authoritative)"
        ),
        "the contract line names the satisfied world and claims nothing more: {stdout}"
    );
}

#[test]
fn package_gates_on_conformance_and_writes_nothing_for_junk() {
    let dir = tempfile::tempdir().unwrap();
    write_key_pair(dir.path());
    std::fs::write(dir.path().join("filter.wasm"), b"not a component").unwrap();

    let out = package(dir.path());
    assert!(!out.status.success(), "junk input is rejected");
    assert!(
        out.stdout.is_empty(),
        "no digest on stdout for a rejected component: {:?}",
        String::from_utf8_lossy(&out.stdout)
    );
    assert!(
        !dir.path().join("layout").exists(),
        "a non-conformant component never produces a layout"
    );
}

#[test]
fn conformance_json_scores_every_case_with_an_id_and_a_verdict() {
    // ADR 000108: the machine-readable report carries the stable case id and the five-way verdict
    // next to the legacy `passed` bool, so CI can track one case across battery versions and tell
    // an environment shortfall apart from world non-conformance.
    let dir = tempfile::tempdir().unwrap();
    write_component(dir.path());

    let out = run(&["conformance", "filter.wasm", "--json"], dir.path());
    assert!(
        out.status.success(),
        "the fixture filter is conformant, stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let report: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
    assert_eq!(report["conformant"], serde_json::json!(true));
    let checks = report["checks"].as_array().expect("the report lists cases");
    assert!(
        !checks.is_empty(),
        "the battery reports its cases: {report}"
    );
    for check in checks {
        for key in ["id", "name", "passed", "verdict", "detail"] {
            assert!(
                check.get(key).is_some(),
                "every case carries `{key}`: {check}"
            );
        }
        assert_eq!(check["verdict"], serde_json::json!("pass"), "{check}");
        assert_eq!(
            check["passed"],
            serde_json::json!(true),
            "`passed` stays the `verdict == pass` projection: {check}"
        );
    }
    let ids: Vec<&str> = checks.iter().filter_map(|c| c["id"].as_str()).collect();
    assert_eq!(
        ids,
        ["v1.0.0-S1", "v1.0.0-D1"],
        "the ids are the battery's stable case names"
    );
}

#[test]
fn conformance_reports_the_verdict_of_a_component_that_does_not_load() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("filter.wasm"), b"not a component").unwrap();

    let json = run(&["conformance", "filter.wasm", "--json"], dir.path());
    assert!(
        !json.status.success(),
        "a non-conformant component exits non-zero"
    );
    let report: serde_json::Value = serde_json::from_slice(&json.stdout).expect("stdout is JSON");
    assert_eq!(report["conformant"], serde_json::json!(false));
    assert_eq!(report["checks"][0]["verdict"], serde_json::json!("fail"));
    assert_eq!(report["checks"][0]["passed"], serde_json::json!(false));

    // The text arm speaks the same verdict vocabulary, one marker per case (a `detail` carrying
    // a multi-line parser diagnostic wraps under its own marker).
    let text = run(&["conformance", "filter.wasm"], dir.path());
    let stdout = String::from_utf8_lossy(&text.stdout);
    let markers: Vec<&str> = stdout.lines().filter(|l| l.starts_with('[')).collect();
    assert_eq!(markers.len(), 2, "one marker per case: {stdout:?}");
    assert!(
        markers.iter().all(|line| line.starts_with("[fail]")),
        "the text arm prints the verdict, not a bool-derived word: {stdout:?}"
    );
}

#[test]
fn package_accepts_a_supplied_bound_sbom() {
    let dir = tempfile::tempdir().unwrap();
    write_key_pair(dir.path());
    write_component(dir.path());
    // A supplier-provided statement, still bound to this component (subject digest = its
    // sha256) — binding stays the supplier's responsibility and the loader still verifies it.
    let component = filter_hello_component();
    let default_statement = plecto_host::bound_sbom(&component);
    let mut statement: serde_json::Value = serde_json::from_slice(&default_statement).unwrap();
    statement["predicate"] = serde_json::json!({"supplier": "moka-1"});
    std::fs::write(
        dir.path().join("sbom.json"),
        serde_json::to_vec(&statement).unwrap(),
    )
    .unwrap();

    let out = run(
        &[
            "package",
            "filter.wasm",
            "--key",
            "key.pem",
            "--out",
            "layout",
            "--sbom",
            "sbom.json",
        ],
        dir.path(),
    );
    assert!(
        out.status.success(),
        "package --sbom exits 0, stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let digest = String::from_utf8_lossy(&out.stdout).trim_end().to_string();

    // The supplied statement rides in the layout and the full gate still passes.
    std::fs::write(dir.path().join("plecto.toml"), manifest_pinning(&digest)).unwrap();
    let resolve = run(&["validate", "--resolve", "plecto.toml"], dir.path());
    assert!(
        resolve.status.success(),
        "resolve accepts the supplier SBOM, stderr: {}",
        String::from_utf8_lossy(&resolve.stderr)
    );
}

#[test]
fn resolve_fails_closed_on_an_untrusted_signature() {
    let dir = tempfile::tempdir().unwrap();
    write_key_pair(dir.path());
    write_component(dir.path());
    let out = package(dir.path());
    assert!(out.status.success());
    let digest = String::from_utf8_lossy(&out.stdout).trim_end().to_string();

    // Swap the trust root for a key that never signed this layout: static validation still
    // passes (the PEM is well-formed), but --resolve must reject — same gate as the loader.
    let (stranger, _) = DevSigner::generate().unwrap();
    std::fs::write(dir.path().join("trust.pem"), stranger.public_key_pem()).unwrap();
    std::fs::write(dir.path().join("plecto.toml"), manifest_pinning(&digest)).unwrap();

    let stat = run(&["validate", "plecto.toml"], dir.path());
    assert!(
        stat.status.success(),
        "static validate does not resolve artifacts"
    );
    let resolve = run(&["validate", "--resolve", "plecto.toml"], dir.path());
    assert!(
        !resolve.status.success(),
        "--resolve rejects a layout no trusted key signed"
    );
}

#[test]
fn resolve_fails_closed_on_a_tampered_blob() {
    let dir = tempfile::tempdir().unwrap();
    write_key_pair(dir.path());
    write_component(dir.path());
    let out = package(dir.path());
    assert!(out.status.success());
    let digest = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
    std::fs::write(dir.path().join("plecto.toml"), manifest_pinning(&digest)).unwrap();

    // Flip a byte in the largest blob (the component layer): the content no longer matches
    // its digest-addressed filename, so resolution fails before any signature check.
    let blobs = dir.path().join("layout/blobs/sha256");
    let component_blob = std::fs::read_dir(&blobs)
        .unwrap()
        .map(|e| e.unwrap().path())
        .max_by_key(|p| std::fs::metadata(p).unwrap().len())
        .unwrap();
    let mut bytes = std::fs::read(&component_blob).unwrap();
    bytes[0] ^= 0xFF;
    std::fs::write(&component_blob, bytes).unwrap();

    let resolve = run(&["validate", "--resolve", "plecto.toml"], dir.path());
    assert!(
        !resolve.status.success(),
        "--resolve rejects a blob whose content diverges from its digest"
    );
}