cleanlib-client 0.4.0

HTTP client SDK for the CleanLibrary verdict API — VerdictEnvelopeV1 types, derive_status logic, transport, config, and risk-acceptance YAML emitter shared between cleanlib-cli and other CleanLibrary consumers.
Documentation
//! CLEANLIB-870 (integrity half) — artifact-bytes digest verification.
//!
//! **Status: the verification PRIMITIVE below is ready; there is nothing to
//! wire it to yet.** Confirmed live (2026-09-16) that `GET /v1/fetch/...`
//! carries no digest-bearing response header at all — no `ETag`, no
//! `Content-MD5`, no `Digest`, no `x-goog-hash` (the catalog is GCS-backed;
//! a proxied GCS response would normally carry the latter, but the App does
//! not pass it through). The signed attestation's `artifact_hash` field is a
//! hash of the *serialized verdict*, not of the artifact, and changes on
//! every request — it structurally cannot serve as a content digest. So
//! there is currently no real per-artifact digest anywhere on the wire for
//! [`crate::transport::Client::fetch_artifact_stream`] to check the streamed
//! bytes against. That is a server-side gap (filed separately per the
//! ticket's own "RELATED" section), not something this crate can close on
//! its own by inventing a digest source.
//!
//! What IS shippable now, independent of that blocker: this module's
//! [`verify_sha256_digest`] — a pure, tested comparison function — plus the
//! CLI's fetch command already writing to a same-directory temp file and
//! only renaming it onto the real destination on full success (so any
//! failure, a future digest mismatch included, leaves no partial output at
//! the real path — see `cleanlib-cli/src/commands/fetch.rs`). The day a real
//! per-artifact digest becomes available (a response header, most likely,
//! matching how `X-CleanLibrary-Decision`/`X-CleanLibrary-Reason` are
//! already read in `transport.rs::emit_decision_headers` for the sibling
//! verdict-gating half of the same ticket), wiring it in is: read the
//! digest, call [`verify_sha256_digest`] against the fully-streamed bytes
//! before the CLI's rename-into-place step, and fail closed (delete the temp
//! file, non-zero exit) on [`DigestMismatch`].

use sha2::{Digest, Sha256};

/// The streamed bytes' SHA-256 did not match the expected digest. Carries
/// both hex-encoded digests so a caller can report exactly what diverged
/// (never just "integrity check failed" with no evidence).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DigestMismatch {
    pub expected: String,
    pub actual: String,
}

impl std::fmt::Display for DigestMismatch {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "artifact integrity check failed: expected sha256:{}, got sha256:{}",
            self.expected, self.actual
        )
    }
}

impl std::error::Error for DigestMismatch {}

/// Verify `bytes` against an `expected` SHA-256 digest, hex-encoded
/// (lowercase or uppercase, with or without a leading `sha256:` prefix —
/// both are real-world shapes a server might emit and rejecting on
/// formatting alone would be its own false-negative bug). Returns `Ok(())`
/// on a match, [`DigestMismatch`] on any divergence — deliberately fail
/// CLOSED: a malformed `expected` value (wrong length, non-hex characters)
/// is NOT treated as "nothing to check" and passed through, it is compared
/// byte-for-byte and will not match a real digest, which correctly surfaces
/// as a mismatch rather than silently skipping verification.
pub fn verify_sha256_digest(bytes: &[u8], expected: &str) -> Result<(), DigestMismatch> {
    let expected_normalized = expected
        .trim()
        .strip_prefix("sha256:")
        .unwrap_or(expected.trim())
        .to_ascii_lowercase();
    let actual = hex_encode(&Sha256::digest(bytes));
    if actual == expected_normalized {
        Ok(())
    } else {
        Err(DigestMismatch {
            expected: expected_normalized,
            actual,
        })
    }
}

fn hex_encode(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    // Known SHA-256("hello world\n") — an independently-verifiable vector
    // (matches `echo hello world | sha256sum`), not a value derived from the
    // function under test.
    const HELLO_WORLD_NEWLINE: &[u8] = b"hello world\n";
    const HELLO_WORLD_SHA256: &str =
        "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447";

    #[test]
    fn matching_digest_passes() {
        assert!(verify_sha256_digest(HELLO_WORLD_NEWLINE, HELLO_WORLD_SHA256).is_ok());
    }

    #[test]
    fn matching_digest_is_case_insensitive() {
        assert!(
            verify_sha256_digest(HELLO_WORLD_NEWLINE, &HELLO_WORLD_SHA256.to_ascii_uppercase())
                .is_ok()
        );
    }

    #[test]
    fn accepts_the_sha256_prefixed_shape() {
        let prefixed = format!("sha256:{HELLO_WORLD_SHA256}");
        assert!(verify_sha256_digest(HELLO_WORLD_NEWLINE, &prefixed).is_ok());
    }

    #[test]
    fn counterexample_mismatched_digest_fails_closed() {
        // The exact counterexample this whole module exists for: bytes that
        // are NOT what was expected must be REJECTED, not silently passed
        // (which is what the pre-870 `fetch` did unconditionally -- no
        // check at all is indistinguishable from a check that always
        // passes).
        let tampered = b"hello world, tampered\n";
        let err = verify_sha256_digest(tampered, HELLO_WORLD_SHA256).unwrap_err();
        assert_eq!(err.expected, HELLO_WORLD_SHA256);
        assert_ne!(err.actual, HELLO_WORLD_SHA256);
    }

    #[test]
    fn malformed_expected_digest_fails_closed_not_skipped() {
        // A garbage / wrong-length "expected" value must not be treated as
        // "nothing to verify" -- fail-closed means comparing it anyway and
        // reporting the (guaranteed) mismatch, never silently skipping the
        // check because the input looked odd.
        let err = verify_sha256_digest(HELLO_WORLD_NEWLINE, "not-a-real-digest").unwrap_err();
        assert_eq!(err.expected, "not-a-real-digest");
    }

    #[test]
    fn empty_bytes_have_the_well_known_empty_sha256() {
        // Independently-verifiable well-known constant (SHA-256 of zero
        // bytes), guards against an off-by-one in the hasher wiring that a
        // hard-coded-pair test alone wouldn't catch.
        const EMPTY_SHA256: &str =
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
        assert!(verify_sha256_digest(b"", EMPTY_SHA256).is_ok());
    }
}