cellos-core 0.7.3

CellOS domain types and ports — typed authority, formation DAG, CloudEvent envelopes, RBAC primitives. No I/O.
Documentation
//! E4-01 — SecretView zeroization regression test.
//!
//! Verifies the post-review Lane E1 hardening (E1-01 + E1-02): `SecretView`
//! holds its value in `zeroize::Zeroizing<String>` AND derives `ZeroizeOnDrop`,
//! so secret bytes are wiped from heap memory when the struct is dropped.
//!
//! Per `Plans/post-review-roadmap.md` E4-01:
//!   "SecretView zeroization test … verifies E1-01/02".
//!
//! NOTE: depends on E1-01/02 — fails until Slot 1 lands. At test-write time the
//! struct is `#[derive(Debug)]` only (no `ZeroizeOnDrop`). Once Slot 1 adds
//! `#[derive(ZeroizeOnDrop)]`, the static_assertions / trait bounds in this
//! file resolve and the runtime checks pass.
//!
//! Test approach:
//!   1. **Compile-time** — assert `SecretView: zeroize::ZeroizeOnDrop` via a
//!      generic function that requires the bound. This catches removal of the
//!      derive in CI without needing a dedicated `static_assertions` dep.
//!   2. **Runtime + heap-residue probe** — capture the inner `String`'s heap
//!      pointer and length, invoke `Zeroize::zeroize()` on the inner
//!      `Zeroizing<String>` (which owns the allocation), and verify every
//!      byte at that location is `0`. This is sound because we only read
//!      while the allocation is still live — `Zeroizing<String>` overwrites
//!      in place rather than freeing.
//!   3. **Drop-path smoke** — call `zeroize()` then `drop()` to exercise the
//!      `ZeroizeOnDrop` Drop glue and confirm idempotent zeroization is sound.

use cellos_core::types::SecretView;
use zeroize::{Zeroize, ZeroizeOnDrop};

/// Compile-time bound: `SecretView` must implement `ZeroizeOnDrop`.
/// If E1-02 regresses (derive removed), this fails to compile.
fn _assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}

#[test]
fn secret_view_implements_zeroize_on_drop() {
    // Forces monomorphization of the bound — a compile-time guarantee that
    // `SecretView: ZeroizeOnDrop`. Runs as a no-op at execution time.
    _assert_zeroize_on_drop::<SecretView>();
}

#[test]
fn secret_view_value_is_zeroizing_string() {
    // E1-01 — the inner value type is `Zeroizing<String>`, not bare `String`.
    // We probe this via type inference: `as_str()` is provided by `Zeroizing`'s
    // `Deref<Target = String>` (which derefs to `&str`), and the value must
    // round-trip a non-empty secret.
    let view = SecretView {
        key: "API_TOKEN".to_string(),
        value: zeroize::Zeroizing::new("super-secret-payload".to_string()),
    };
    assert_eq!(view.value.as_str(), "super-secret-payload");
    assert_eq!(view.key, "API_TOKEN");
}

#[test]
fn secret_view_zeroize_wipes_inner_string_bytes() {
    // The signature secret bytes we are going to look for in heap memory.
    // 64 ASCII '!' chars — distinctive, non-zero, and a single allocation
    // owned by the inner `String` (not inlined in any small-string optimization,
    // since std `String` is heap-allocated unconditionally).
    const SECRET: &str = "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!";
    assert_eq!(SECRET.len(), 64);

    let mut view = SecretView {
        key: "API_TOKEN".to_string(),
        value: zeroize::Zeroizing::new(SECRET.to_string()),
    };

    // Capture the heap pointer + length of the inner String *before* zeroize.
    // After `zeroize()`, `Zeroizing<String>` overwrites the bytes in place but
    // the allocation is still owned by `view` (not freed), so reading the
    // memory through the captured pointer is sound while `view` is alive.
    let inner: &String = &view.value;
    let ptr: *const u8 = inner.as_ptr();
    let len: usize = inner.len();
    assert_eq!(len, 64, "inner String should hold the 64-byte secret");

    // Sanity: bytes are non-zero before zeroize.
    // SAFETY: `view` is alive, `ptr` is valid for `len` bytes, `len > 0`.
    let pre = unsafe { std::slice::from_raw_parts(ptr, len) };
    assert!(
        pre.iter().all(|&b| b == b'!'),
        "pre-zeroize bytes should match the secret payload"
    );

    // Idiomatic zeroize-crate pattern: invoke the `Zeroize` trait directly
    // on the inner `Zeroizing<String>`. `Zeroizing<T>: Zeroize` is provided
    // by the zeroize crate for any `T: Zeroize`, and `String: Zeroize` is
    // inherent — so this resolves regardless of whether the SecretView struct
    // itself implements `Zeroize`. (The compile-time bound at top of file
    // separately verifies `ZeroizeOnDrop` is derived.)
    view.value.zeroize();

    // SAFETY: `view` is still alive (we have not dropped it). `Zeroizing<String>`
    // zeroes in place rather than reallocating, so `ptr`/`len` remain valid.
    // Reading those `len` bytes therefore aliases the still-owned allocation.
    let post = unsafe { std::slice::from_raw_parts(ptr, len) };
    assert!(
        post.iter().all(|&b| b == 0),
        "post-zeroize: every byte at the original heap location must be 0; \
         got non-zero residue: {:?}",
        post.iter().filter(|&&b| b != 0).count()
    );

    // Drop happens here at end of scope — exercises `ZeroizeOnDrop` derive
    // through normal RAII; if the derive is missing the previous compile-time
    // assertion already failed, so reaching this point implies the contract
    // holds end-to-end.
    drop(view);
}

#[test]
fn secret_view_drop_zeroizes_via_derive() {
    // End-to-end: build a SecretView, capture its inner heap pointer + length,
    // then drop it. `Zeroizing<String>` overwrites bytes in-place during its
    // own `Drop` *before* freeing the allocation, so the bytes at `ptr` are
    // zero at the moment the allocator reclaims them.
    //
    // We can't safely read the buffer *after* free, so this test instead
    // proves the `Drop` path is wired by checking that a manually-invoked
    // `zeroize()` produces zeros (covered above) AND that the type's Drop
    // glue compiles with `ZeroizeOnDrop` semantics (compile-time bound at
    // top of file).
    //
    // To exercise the actual drop path with a runtime assertion, we use the
    // pattern documented in the `zeroize` crate: call `zeroize()` explicitly
    // *before* drop, then drop. If `ZeroizeOnDrop` is wired correctly, the
    // double-zeroize is idempotent and does not panic / UB.
    let mut view = SecretView {
        key: "K".to_string(),
        value: zeroize::Zeroizing::new("payload".to_string()),
    };
    view.value.zeroize();
    // Dropping a zero-length-ish Zeroizing<String> through ZeroizeOnDrop must
    // be a clean RAII path — this line will fail to compile if the derive is
    // not present (because the compile-time bound at top of file would fail
    // first), and would crash at runtime if the Drop impl were unsound.
    drop(view);
}