cellos-core 0.7.3

CellOS domain types and ports — typed authority, formation DAG, CloudEvent envelopes, RBAC primitives. No I/O.
Documentation
//! FC-08 — `cell.lifecycle.v1.started` MUST carry the verified SHA256 digests
//! of the kernel, rootfs, and (when manifested) firecracker artifacts so
//! taudit and operators can answer "which boot bytes did this run use?"
//! without backend-side state.
//!
//! These tests pin the on-the-wire JSON contract: field names are the
//! camelCase forms `kernelDigestSha256`, `rootfsDigestSha256`, and
//! `firecrackerDigestSha256`, each is independently optional, and each
//! round-trips through `serde_json` losslessly.
//!
//! The supervisor wires these from [`cellos_core::ports::CellHandle`] which
//! the Firecracker backend populates from its pre-boot manifest verification
//! path; non-Firecracker backends pass `None` and the fields are omitted
//! from the JSON entirely (backward-compatible with v1).

use cellos_core::events::lifecycle_started_data_v1;
use cellos_core::types::{AuthorityBundle, ExecutionCellSpec, Lifetime};

/// Build a minimally-valid spec — fields not under test are left at their
/// `None` / `Default` defaults so the assertions below speak to the digest
/// shape only.
fn minimal_spec(id: &str) -> ExecutionCellSpec {
    ExecutionCellSpec {
        id: id.to_string(),
        correlation: None,
        ingress: None,
        environment: None,
        placement: None,
        policy: None,
        identity: None,
        run: None,
        authority: AuthorityBundle::default(),
        lifetime: Lifetime { ttl_seconds: 60 },
        export: None,
        telemetry: None,
    }
}

/// Sample 64-char lowercase hex digests. Distinct values per role so a
/// regression that swapped fields would be caught by the per-role assertions.
const KERNEL_HEX: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const ROOTFS_HEX: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
const FIRECRACKER_HEX: &str = "1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff";

/// Acceptance: with all three digests `Some`, the started-event JSON
/// surfaces them under their camelCase keys with the exact hex bytes.
#[test]
fn lifecycle_started_emits_all_three_digest_fields() {
    let spec = minimal_spec("cell-fc-08-all");
    let data = lifecycle_started_data_v1(
        &spec,
        "cell-fc-08-all",
        Some("run-fc-08-001"),
        None,
        None,
        None,
        None,
        Some(KERNEL_HEX),
        Some(ROOTFS_HEX),
        Some(FIRECRACKER_HEX),
    )
    .expect("build started data");

    assert_eq!(data["kernelDigestSha256"], KERNEL_HEX);
    assert_eq!(data["rootfsDigestSha256"], ROOTFS_HEX);
    assert_eq!(data["firecrackerDigestSha256"], FIRECRACKER_HEX);

    // Each field is non-empty 64-char lowercase hex — the e2e contract
    // asserted in `Plans/firecracker-release-readiness.md` line 49.
    for key in [
        "kernelDigestSha256",
        "rootfsDigestSha256",
        "firecrackerDigestSha256",
    ] {
        let s = data[key]
            .as_str()
            .unwrap_or_else(|| panic!("{key} is not a string"));
        assert_eq!(s.len(), 64, "{key} must be 64 hex chars, got: {s}");
        assert!(
            s.chars()
                .all(|c| c.is_ascii_hexdigit()
                    && (!c.is_ascii_alphabetic() || c.is_ascii_lowercase())),
            "{key} must be lowercase hex, got: {s}"
        );
    }
}

/// FC-08: kernel + rootfs only (manifest without `firecracker` role) — the
/// firecracker-binary digest is optional, kernel and rootfs are not.
#[test]
fn lifecycle_started_emits_kernel_and_rootfs_when_firecracker_omitted() {
    let spec = minimal_spec("cell-fc-08-no-fc");
    let data = lifecycle_started_data_v1(
        &spec,
        "cell-fc-08-no-fc",
        None,
        None,
        None,
        None,
        None,
        Some(KERNEL_HEX),
        Some(ROOTFS_HEX),
        None,
    )
    .expect("build started data");

    assert_eq!(data["kernelDigestSha256"], KERNEL_HEX);
    assert_eq!(data["rootfsDigestSha256"], ROOTFS_HEX);
    assert!(
        !data
            .as_object()
            .expect("data is object")
            .contains_key("firecrackerDigestSha256"),
        "firecrackerDigestSha256 must be omitted when None (not stamped as null)"
    );
}

/// Backward-compat: backends without a manifest (stub, host-cellos) pass
/// `None` for all three. The started-event JSON must omit the fields
/// entirely so existing v1 consumers see the same shape they always did.
#[test]
fn lifecycle_started_omits_all_three_when_none() {
    let spec = minimal_spec("cell-fc-08-none");
    let data = lifecycle_started_data_v1(
        &spec,
        "cell-fc-08-none",
        None,
        None,
        None,
        None,
        None,
        None,
        None,
        None,
    )
    .expect("build started data");

    let obj = data.as_object().expect("data is object");
    assert!(!obj.contains_key("kernelDigestSha256"));
    assert!(!obj.contains_key("rootfsDigestSha256"));
    assert!(!obj.contains_key("firecrackerDigestSha256"));
}

/// Round-trip through `serde_json::to_string` -> `from_str`: the three
/// digest fields survive a serialize+parse cycle byte-for-byte. This is the
/// shape the e2e test at `Plans/firecracker-release-readiness.md` line 49
/// will parse off the wire.
#[test]
fn lifecycle_started_digest_fields_round_trip_through_serde_json() {
    let spec = minimal_spec("cell-fc-08-rt");
    let original = lifecycle_started_data_v1(
        &spec,
        "cell-fc-08-rt",
        Some("run-fc-08-rt"),
        None,
        None,
        None,
        None,
        Some(KERNEL_HEX),
        Some(ROOTFS_HEX),
        Some(FIRECRACKER_HEX),
    )
    .expect("build started data");

    let wire = serde_json::to_string(&original).expect("serialize");
    let reparsed: serde_json::Value = serde_json::from_str(&wire).expect("reparse");

    assert_eq!(reparsed, original, "round-trip must be lossless");
    assert_eq!(reparsed["kernelDigestSha256"], KERNEL_HEX);
    assert_eq!(reparsed["rootfsDigestSha256"], ROOTFS_HEX);
    assert_eq!(reparsed["firecrackerDigestSha256"], FIRECRACKER_HEX);

    // The wire format is camelCase (per the v1 schema) — assert the literal
    // strings appear in the serialized JSON so a snake_case regression in
    // the constructor is caught here, not in the projector.
    assert!(
        wire.contains("\"kernelDigestSha256\""),
        "wire must use camelCase key kernelDigestSha256, got: {wire}"
    );
    assert!(
        wire.contains("\"rootfsDigestSha256\""),
        "wire must use camelCase key rootfsDigestSha256, got: {wire}"
    );
    assert!(
        wire.contains("\"firecrackerDigestSha256\""),
        "wire must use camelCase key firecrackerDigestSha256, got: {wire}"
    );
}