Gunnar

nordisk/heimdall

guests/verify-bundle/src/refusal.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! Why a bundle was refused.
//!
//! The bundle-pipeline refusals mirror Holger's `BundleError` one for one and
//! carry the SAME frozen `holger-update-<n>` codes (from the `holger-errcode`
//! registry), so a Holger importer that reads Heimdall's verdict branches on the
//! code exactly as it would on its own verifier's. Refusals that only this guest
//! can make (the container codec, the parameters, DSSE) carry a
//! `heimdall-bundle-<n>` code of their own.

use core::fmt;

use holger_errcode::{self as ec, ErrCode};

/// A guest-local refusal code. Never reused, never renumbered.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GuestCode(pub u16);

impl GuestCode {
    /// The parameters the host passed could not be read.
    pub const PARAMS: GuestCode = GuestCode(1);
    /// The input is not a znippy v0.7 archive, or its index is malformed.
    pub const ARCHIVE: GuestCode = GuestCode(2);
    /// A member is stored compressed in a way the guest does not decode (built
    /// without the `openzl` feature, or a znippy delta map).
    pub const CODEC: GuestCode = GuestCode(3);
    /// A DSSE envelope was malformed, unsigned, signed by an unpinned key, or
    /// its signature did not verify.
    pub const DSSE: GuestCode = GuestCode(4);
    /// The in-toto statement inside a VERIFIED DSSE envelope does not bind to
    /// the bundle's content.
    pub const DSSE_BINDING: GuestCode = GuestCode(5);
    /// A parser inside the guest panicked; the input is refused, never trusted.
    pub const PARSER_PANIC: GuestCode = GuestCode(6);

    pub fn tag(self) -> String {
        format!("heimdall-bundle-{}", self.0)
    }
}

/// A refusal. `Display` is the human half; [`Refusal::tagged`] adds the code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
    // ---- Holger's BundleError, mirrored (same codes, same prose) ----
    UnsupportedSchema {
        found: String,
        expected: &'static str,
    },
    UnsupportedPolicySchema {
        found: String,
        expected: &'static str,
    },
    UnsafePath(String),
    LimitExceeded(String),
    MissingMember(String),
    UnreferencedMember(String),
    DigestMismatch {
        path: String,
        which: &'static str,
    },
    CandidateDigestMismatch,
    EnvelopeMismatch(String),
    InvalidReleaseSignature,
    InvalidApprovalSignature {
        role: String,
        fingerprint: String,
    },
    UntrustedSigner {
        fingerprint: String,
        role: String,
    },
    UntrustedReleaseSigner(String),
    TwoPersonRule(String),
    MissingRole(String),
    Expired(String),
    TooOld,
    MissingExpiry,
    MissingEvidence(&'static str),
    TargetMismatch(String),
    SequenceRollback {
        found: u64,
        accepted: u64,
    },
    SequenceReplay(u64),
    BundleIdReuse(String),
    Certificate(String),
    VerdictNotPass(String),
    TrustModeUnset,
    TrustRootsEmpty,
    CertificateExpired {
        fingerprint: String,
        not_after: String,
    },
    CertificateNotYetValid {
        fingerprint: String,
        not_before: String,
    },
    Policy(String),
    Malformed(String),
    Container(String),
    Serde(String),

    // ---- the guest's own ----
    Params(String),
    Archive(String),
    Codec(String),
    Dsse(String),
    DsseBinding(String),
    ParserPanic(String),
}

/// The machine half of a refusal.
pub enum Code {
    Holger(&'static ErrCode),
    Guest(GuestCode),
}

impl Code {
    pub fn as_string(&self) -> String {
        match self {
            Code::Holger(c) => c.code.to_string(),
            Code::Guest(g) => g.tag(),
        }
    }
}

impl Refusal {
    /// The frozen code for this refusal.
    pub fn code(&self) -> Code {
        use Refusal as R;
        match self {
            R::UnsupportedSchema { .. } => Code::Holger(&ec::UPDATE_SCHEMA_UNSUPPORTED),
            R::UnsupportedPolicySchema { .. } => {
                Code::Holger(&ec::UPDATE_POLICY_SCHEMA_UNSUPPORTED)
            }
            R::UnsafePath(_) => Code::Holger(&ec::UPDATE_UNSAFE_PATH),
            R::LimitExceeded(_) => Code::Holger(&ec::UPDATE_LIMIT_EXCEEDED),
            R::MissingMember(_) => Code::Holger(&ec::UPDATE_MEMBER_MISSING),
            R::UnreferencedMember(_) => Code::Holger(&ec::UPDATE_MEMBER_UNREFERENCED),
            R::DigestMismatch { .. } => Code::Holger(&ec::UPDATE_DIGEST_MISMATCH),
            R::CandidateDigestMismatch => Code::Holger(&ec::UPDATE_CANDIDATE_DIGEST_MISMATCH),
            R::EnvelopeMismatch(_) => Code::Holger(&ec::UPDATE_ENVELOPE_MISMATCH),
            R::InvalidReleaseSignature => Code::Holger(&ec::UPDATE_RELEASE_SIGNATURE_INVALID),
            R::InvalidApprovalSignature { .. } => {
                Code::Holger(&ec::UPDATE_APPROVAL_SIGNATURE_INVALID)
            }
            R::UntrustedSigner { .. } => Code::Holger(&ec::UPDATE_UNTRUSTED_SIGNER),
            R::UntrustedReleaseSigner(_) => Code::Holger(&ec::UPDATE_UNTRUSTED_RELEASE_SIGNER),
            R::TwoPersonRule(_) => Code::Holger(&ec::UPDATE_TWO_PERSON_RULE),
            R::MissingRole(_) => Code::Holger(&ec::UPDATE_ROLE_MISSING),
            R::Expired(_) => Code::Holger(&ec::UPDATE_EXPIRED),
            R::TooOld => Code::Holger(&ec::UPDATE_TOO_OLD),
            R::MissingExpiry => Code::Holger(&ec::UPDATE_EXPIRY_MISSING),
            R::MissingEvidence(_) => Code::Holger(&ec::UPDATE_EVIDENCE_MISSING),
            R::TargetMismatch(_) => Code::Holger(&ec::UPDATE_TARGET_MISMATCH),
            R::SequenceRollback { .. } => Code::Holger(&ec::UPDATE_SEQUENCE_ROLLBACK),
            R::SequenceReplay(_) => Code::Holger(&ec::UPDATE_SEQUENCE_REPLAY),
            R::BundleIdReuse(_) => Code::Holger(&ec::UPDATE_BUNDLE_ID_REUSE),
            R::Certificate(_) => Code::Holger(&ec::UPDATE_CERTIFICATE),
            R::VerdictNotPass(_) => Code::Holger(&ec::UPDATE_VERDICT_NOT_PASS),
            R::TrustModeUnset => Code::Holger(&ec::UPDATE_TRUST_MODE_UNSET),
            R::TrustRootsEmpty => Code::Holger(&ec::UPDATE_TRUST_ROOTS_EMPTY),
            R::CertificateExpired { .. } => Code::Holger(&ec::UPDATE_CERTIFICATE_EXPIRED),
            R::CertificateNotYetValid { .. } => Code::Holger(&ec::UPDATE_CERTIFICATE_NOT_YET_VALID),
            R::Policy(_) => Code::Holger(&ec::UPDATE_POLICY),
            R::Malformed(_) => Code::Holger(&ec::UPDATE_MALFORMED),
            R::Container(_) => Code::Holger(&ec::UPDATE_CONTAINER),
            R::Serde(_) => Code::Holger(&ec::UPDATE_SERDE),
            R::Params(_) => Code::Guest(GuestCode::PARAMS),
            R::Archive(_) => Code::Guest(GuestCode::ARCHIVE),
            R::Codec(_) => Code::Guest(GuestCode::CODEC),
            R::Dsse(_) => Code::Guest(GuestCode::DSSE),
            R::DsseBinding(_) => Code::Guest(GuestCode::DSSE_BINDING),
            R::ParserPanic(_) => Code::Guest(GuestCode::PARSER_PANIC),
        }
    }

    /// Both channels in one string: `[holger-update-22] sequence rollback ...`.
    pub fn tagged(&self) -> String {
        format!("[{}] {}", self.code().as_string(), self)
    }
}

impl fmt::Display for Refusal {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        use Refusal as R;
        match self {
            R::UnsupportedSchema { found, expected } => write!(
                f,
                "bundle schema version {found:?} is not supported (expected {expected})"
            ),
            R::UnsupportedPolicySchema { found, expected } => write!(
                f,
                "trust-policy schema version {found:?} is not supported (expected {expected})"
            ),
            R::UnsafePath(s) => write!(f, "unsafe member path rejected: {s}"),
            R::LimitExceeded(s) => write!(f, "bundle exceeds a resource limit: {s}"),
            R::MissingMember(s) => write!(f, "bundle member {s:?} is missing"),
            R::UnreferencedMember(s) => write!(f, "bundle contains an unreferenced member {s:?}"),
            R::DigestMismatch { path, which } => {
                write!(f, "{which} digest mismatch for member {path:?}")
            }
            R::CandidateDigestMismatch => write!(
                f,
                "candidate digest mismatch: core.json does not match the approved candidate"
            ),
            R::EnvelopeMismatch(s) => write!(f, "envelope inconsistent with core.json: {s}"),
            R::InvalidReleaseSignature => write!(f, "release signature is invalid"),
            R::InvalidApprovalSignature { role, fingerprint } => write!(
                f,
                "approval signature invalid for role {role} signer {fingerprint}"
            ),
            R::UntrustedSigner { fingerprint, role } => write!(
                f,
                "untrusted signer: fingerprint {fingerprint} is not authorized for role {role}"
            ),
            R::UntrustedReleaseSigner(s) => write!(f, "untrusted release signer: fingerprint {s}"),
            R::TwoPersonRule(s) => write!(f, "two-person rule not satisfied: {s}"),
            R::MissingRole(s) => write!(f, "required approval role missing: {s}"),
            R::Expired(s) => write!(f, "bundle has expired (expires_at {s})"),
            R::TooOld => write!(f, "bundle exceeds the policy maximum age"),
            R::MissingExpiry => write!(f, "bundle is missing a required expiry timestamp"),
            R::MissingEvidence(s) => write!(f, "required evidence missing: {s}"),
            R::TargetMismatch(s) => write!(f, "target mismatch: {s}"),
            R::SequenceRollback { found, accepted } => write!(
                f,
                "sequence rollback: bundle sequence {found} is not newer than the accepted {accepted}"
            ),
            R::SequenceReplay(n) => write!(
                f,
                "sequence replay: sequence {n} was already accepted with different content"
            ),
            R::BundleIdReuse(s) => write!(
                f,
                "bundle-id reuse: {s} was already accepted with different content"
            ),
            R::Certificate(s) => write!(f, "certificate error: {s}"),
            R::VerdictNotPass(v) => write!(
                f,
                "policy verdict is {v:?}, not \"pass\": the reviewers did not clear this bundle. \
                 Refusing it, although its signatures may be genuine."
            ),
            R::TrustModeUnset => write!(
                f,
                "trust policy states no trust_mode — it must declare RootedChain or PinnedSigners"
            ),
            R::TrustRootsEmpty => write!(
                f,
                "trust policy is RootedChain but lists no trusted root — nothing can be anchored"
            ),
            R::CertificateExpired {
                fingerprint,
                not_after,
            } => write!(f, "signer certificate {fingerprint} expired at {not_after}"),
            R::CertificateNotYetValid {
                fingerprint,
                not_before,
            } => write!(
                f,
                "signer certificate {fingerprint} is not valid until {not_before}"
            ),
            R::Policy(s) => write!(f, "policy violation: {s}"),
            R::Malformed(s) => write!(f, "malformed bundle: {s}"),
            R::Container(s) => write!(f, "container error: {s}"),
            R::Serde(s) => write!(f, "serialization error: {s}"),
            R::Params(s) => write!(f, "the verification parameters are unusable: {s}"),
            R::Archive(s) => write!(f, "not a readable znippy v0.7 archive: {s}"),
            R::Codec(s) => write!(f, "cannot decode in the guest: {s}"),
            R::Dsse(s) => write!(f, "DSSE envelope refused: {s}"),
            R::DsseBinding(s) => write!(f, "verified DSSE statement does not bind: {s}"),
            R::ParserPanic(s) => write!(f, "a parser panicked on this input: {s}"),
        }
    }
}

impl From<serde_json::Error> for Refusal {
    fn from(e: serde_json::Error) -> Self {
        Refusal::Serde(format!("json: {e}"))
    }
}

pub type Result<T> = core::result::Result<T, Refusal>;