Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

update-bundle/src/error.rs

//! Typed errors for the update-bundle trust model.
//!
//! Every variant carries a **safe, path-free** message suitable to surface to a
//! caller: policy verdicts ("signature invalid", "sequence rollback 7 < 9",
//! "untrusted signer") are meant to be shown, while raw IO / parse detail is
//! collapsed into a short opaque string (the underlying error goes to the caller's
//! log, never a filesystem path or certificate internal into a client channel).

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

/// Result alias for the bundle core.
pub type Result<T> = std::result::Result<T, BundleError>;

/// A bundle-processing error. Messages are safe to display; they never embed a
/// local filesystem path, certificate PEM/DN, or a policy secret.
#[derive(Debug, thiserror::Error)]
pub enum BundleError {
    #[error("bundle schema version {found:?} is not supported (expected {expected})")]
    UnsupportedSchema {
        found: String,
        expected: &'static str,
    },

    #[error("trust-policy schema version {found:?} is not supported (expected {expected})")]
    UnsupportedPolicySchema {
        found: String,
        expected: &'static str,
    },

    #[error("unsafe member path rejected: {0}")]
    UnsafePath(String),

    #[error("bundle exceeds a resource limit: {0}")]
    LimitExceeded(String),

    #[error("bundle member {0:?} is missing")]
    MissingMember(String),

    #[error("bundle contains an unreferenced member {0:?}")]
    UnreferencedMember(String),

    #[error("{which} digest mismatch for member {path:?}")]
    DigestMismatch { path: String, which: &'static str },

    #[error("candidate digest mismatch: core.json does not match the approved candidate")]
    CandidateDigestMismatch,

    #[error("envelope inconsistent with core.json: {0}")]
    EnvelopeMismatch(String),

    #[error("release signature is missing")]
    MissingReleaseSignature,

    #[error("release signature is invalid")]
    InvalidReleaseSignature,

    #[error("approval signature invalid for role {role} signer {fingerprint}")]
    InvalidApprovalSignature { role: String, fingerprint: String },

    #[error("untrusted signer: fingerprint {fingerprint} is not authorized for role {role}")]
    UntrustedSigner { fingerprint: String, role: String },

    #[error("untrusted release signer: fingerprint {0}")]
    UntrustedReleaseSigner(String),

    #[error("two-person rule not satisfied: {0}")]
    TwoPersonRule(String),

    #[error("required approval role missing: {0}")]
    MissingRole(String),

    #[error("bundle has expired (expires_at {0})")]
    Expired(String),

    #[error("bundle exceeds the policy maximum age")]
    TooOld,

    #[error("bundle is missing a required expiry timestamp")]
    MissingExpiry,

    #[error("required evidence missing: {0}")]
    MissingEvidence(&'static str),

    #[error("target mismatch: {0}")]
    TargetMismatch(String),

    #[error(
        "sequence rollback: bundle sequence {found} is not newer than the accepted {accepted}"
    )]
    SequenceRollback { found: u64, accepted: u64 },

    #[error("sequence replay: sequence {0} was already accepted with different content")]
    SequenceReplay(u64),

    #[error("bundle-id reuse: {0} was already accepted with different content")]
    BundleIdReuse(String),

    #[error(
        "no anti-rollback floor for {supplier}/{product}: nothing has ever been accepted here, \
         or the state was erased. An absent floor admits EVERY sequence, including an old \
         vulnerable one. Refusing. Pass --first-import to establish the floor with this bundle."
    )]
    RollbackFloorAbsent { supplier: String, product: String },

    #[error("certificate error: {0}")]
    Certificate(String),

    /// The signed `policy_result.verdict` in core.json is not exactly `pass`.
    #[error(
        "policy verdict is {0:?}, not \"pass\": the reviewers did not clear this bundle. \
         Refusing it, although its signatures may be genuine."
    )]
    VerdictNotPass(String),

    #[error("trust policy states no trust_mode — it must declare RootedChain or PinnedSigners")]
    TrustModeUnset,

    #[error("trust policy is RootedChain but lists no trusted root — nothing can be anchored")]
    TrustRootsEmpty,

    #[error(
        "trust policy's on_expired_metadata is not a posture — it says Some(ReadOnly) (the \
         default when unstated) or Some(Refuse)"
    )]
    ExpiredPostureUnset,

    #[error("signer certificate {fingerprint} expired at {not_after}")]
    CertificateExpired {
        fingerprint: String,
        not_after: String,
    },

    #[error("signer certificate {fingerprint} is not valid until {not_before}")]
    CertificateNotYetValid {
        fingerprint: String,
        not_before: String,
    },

    #[error("policy violation: {0}")]
    Policy(String),

    #[error("malformed bundle: {0}")]
    Malformed(String),

    #[error("container error: {0}")]
    Container(String),

    #[error("io error: {0}")]
    Io(String),

    #[error("serialization error: {0}")]
    Serde(String),
}

impl BundleError {
    /// The FROZEN `holger-update-<n>` code for this variant — the machine half of
    /// the dual channel (the `#[error(..)]` string above is the human half).
    ///
    /// The match is **exhaustive on purpose**: adding a `BundleError` variant
    /// without allocating it a code is a COMPILE error, not a runtime surprise.
    /// That is what keeps the registry honest as the trust model grows.
    ///
    /// See `.nornir/holger-error-codes.md` and the `holger-errcode` crate.
    pub fn code(&self) -> &'static ErrCode {
        use BundleError as B;
        match self {
            B::UnsupportedSchema { .. } => &ec::UPDATE_SCHEMA_UNSUPPORTED,
            B::UnsupportedPolicySchema { .. } => &ec::UPDATE_POLICY_SCHEMA_UNSUPPORTED,
            B::UnsafePath(_) => &ec::UPDATE_UNSAFE_PATH,
            B::LimitExceeded(_) => &ec::UPDATE_LIMIT_EXCEEDED,
            B::MissingMember(_) => &ec::UPDATE_MEMBER_MISSING,
            B::UnreferencedMember(_) => &ec::UPDATE_MEMBER_UNREFERENCED,
            B::DigestMismatch { .. } => &ec::UPDATE_DIGEST_MISMATCH,
            B::CandidateDigestMismatch => &ec::UPDATE_CANDIDATE_DIGEST_MISMATCH,
            B::EnvelopeMismatch(_) => &ec::UPDATE_ENVELOPE_MISMATCH,
            B::MissingReleaseSignature => &ec::UPDATE_RELEASE_SIGNATURE_MISSING,
            B::InvalidReleaseSignature => &ec::UPDATE_RELEASE_SIGNATURE_INVALID,
            B::InvalidApprovalSignature { .. } => &ec::UPDATE_APPROVAL_SIGNATURE_INVALID,
            B::UntrustedSigner { .. } => &ec::UPDATE_UNTRUSTED_SIGNER,
            B::UntrustedReleaseSigner(_) => &ec::UPDATE_UNTRUSTED_RELEASE_SIGNER,
            B::TwoPersonRule(_) => &ec::UPDATE_TWO_PERSON_RULE,
            B::MissingRole(_) => &ec::UPDATE_ROLE_MISSING,
            B::Expired(_) => &ec::UPDATE_EXPIRED,
            B::TooOld => &ec::UPDATE_TOO_OLD,
            B::MissingExpiry => &ec::UPDATE_EXPIRY_MISSING,
            B::MissingEvidence(_) => &ec::UPDATE_EVIDENCE_MISSING,
            B::TargetMismatch(_) => &ec::UPDATE_TARGET_MISMATCH,
            B::SequenceRollback { .. } => &ec::UPDATE_SEQUENCE_ROLLBACK,
            B::SequenceReplay(_) => &ec::UPDATE_SEQUENCE_REPLAY,
            B::BundleIdReuse(_) => &ec::UPDATE_BUNDLE_ID_REUSE,
            B::RollbackFloorAbsent { .. } => &ec::UPDATE_ROLLBACK_FLOOR_ABSENT,
            B::Certificate(_) => &ec::UPDATE_CERTIFICATE,
            B::TrustModeUnset => &ec::UPDATE_TRUST_MODE_UNSET,
            B::VerdictNotPass(_) => &ec::UPDATE_VERDICT_NOT_PASS,
            B::TrustRootsEmpty => &ec::UPDATE_TRUST_ROOTS_EMPTY,
            B::ExpiredPostureUnset => &ec::UPDATE_EXPIRED_POSTURE_UNSET,
            B::CertificateExpired { .. } => &ec::UPDATE_CERTIFICATE_EXPIRED,
            B::CertificateNotYetValid { .. } => &ec::UPDATE_CERTIFICATE_NOT_YET_VALID,
            B::Policy(_) => &ec::UPDATE_POLICY,
            B::Malformed(_) => &ec::UPDATE_MALFORMED,
            B::Container(_) => &ec::UPDATE_CONTAINER,
            B::Io(_) => &ec::UPDATE_IO,
            B::Serde(_) => &ec::UPDATE_SERDE,
        }
    }

    /// BOTH channels in one string: `[holger-update-22] sequence rollback 7 < 9`.
    ///
    /// This is what an operator sees and what an audit row records — the code makes
    /// the verdict machine-checkable, the prose keeps it readable. Use this at every
    /// site that surfaces a `BundleError` to a human, a log, or a client.
    pub fn tagged(&self) -> String {
        self.code().tag(self)
    }

    pub(crate) fn io(context: &str, e: std::io::Error) -> Self {
        // Keep the OS message but not a full local path (callers pass a short context).
        BundleError::Io(format!("{context}: {}", e.kind()))
    }
}

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

#[cfg(test)]
mod errcode_tests {
    //! The Secure Update Gateway's trust verdicts are the errors that MOST need to
    //! be machine-checkable: an operator must be able to tell "your signature is
    //! invalid" from "this bundle is a replay" from "the disk is full", and an
    //! automated importer must branch on that without parsing English.
    //!
    //! These assert the CODE, never a substring of the prose.
    use super::*;

    /// One representative value of EVERY `BundleError` variant. If a variant is
    /// added, `BundleError::code`'s exhaustive match stops compiling — and this
    /// list is where the new variant's code gets proven distinct.
    fn every_variant() -> Vec<BundleError> {
        vec![
            BundleError::UnsupportedSchema {
                found: "v9".into(),
                expected: "v1",
            },
            BundleError::UnsupportedPolicySchema {
                found: "v9".into(),
                expected: "v1",
            },
            BundleError::UnsafePath("../../etc/shadow".into()),
            BundleError::LimitExceeded("member count 10001 > 10000".into()),
            BundleError::MissingMember("core.json".into()),
            BundleError::UnreferencedMember("stowaway.bin".into()),
            BundleError::DigestMismatch {
                path: "a.bin".into(),
                which: "sha256",
            },
            BundleError::CandidateDigestMismatch,
            BundleError::EnvelopeMismatch("sequence differs".into()),
            BundleError::MissingReleaseSignature,
            BundleError::InvalidReleaseSignature,
            BundleError::InvalidApprovalSignature {
                role: "sec".into(),
                fingerprint: "ab12".into(),
            },
            BundleError::UntrustedSigner {
                fingerprint: "ab12".into(),
                role: "sec".into(),
            },
            BundleError::UntrustedReleaseSigner("ab12".into()),
            BundleError::TwoPersonRule("same signer twice".into()),
            BundleError::MissingRole("security".into()),
            BundleError::Expired("2026-01-01T00:00:00Z".into()),
            BundleError::TooOld,
            BundleError::MissingExpiry,
            BundleError::MissingEvidence("scan-report"),
            BundleError::TargetMismatch("site-b".into()),
            BundleError::SequenceRollback {
                found: 7,
                accepted: 9,
            },
            BundleError::SequenceReplay(9),
            BundleError::BundleIdReuse("bundle-7".into()),
            BundleError::RollbackFloorAbsent {
                supplier: "vetra".into(),
                product: "holger".into(),
            },
            BundleError::Certificate("chain does not verify".into()),
            BundleError::TrustModeUnset,
            BundleError::VerdictNotPass("block".into()),
            BundleError::TrustRootsEmpty,
            BundleError::ExpiredPostureUnset,
            BundleError::CertificateExpired {
                fingerprint: "ab12".into(),
                not_after: "2021-01-01T00:00:00Z".into(),
            },
            BundleError::CertificateNotYetValid {
                fingerprint: "ab12".into(),
                not_before: "2031-01-01T00:00:00Z".into(),
            },
            BundleError::Policy("no roles configured".into()),
            BundleError::Malformed("core.json is not an object".into()),
            BundleError::Container("truncated archive".into()),
            BundleError::Io("write: no space left".into()),
            BundleError::Serde("json: trailing comma".into()),
        ]
    }

    /// EVERY variant maps to a DISTINCT frozen code. Two variants sharing a code
    /// would make the two failures indistinguishable to an automated importer —
    /// exactly what the registry exists to prevent.
    #[test]
    fn every_bundle_error_variant_has_a_unique_code() {
        let mut seen: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
        for e in every_variant() {
            let code = e.code().code;
            if let Some(prev) = seen.insert(code, format!("{e:?}")) {
                panic!("code {code} is shared by {prev:?} and {e:?} — codes must be 1:1 with a failure");
            }
            assert!(
                code.starts_with("holger-update-"),
                "{code} is not in the `update` subsystem"
            );
            // every code must be in the app-global registry
            assert!(
                holger_errcode::by_code(code).is_some(),
                "{code} is not registered in holger_errcode::ALL"
            );
        }
        assert_eq!(seen.len(), every_variant().len(), "a variant lost its code");
    }

    /// The DUAL CHANNEL: `tagged()` carries the human verdict AND the machine code,
    /// and the code parses back out. The prose stays free to be reworded.
    #[test]
    fn tagged_carries_both_channels() {
        let e = BundleError::SequenceRollback {
            found: 7,
            accepted: 9,
        };
        let msg = e.tagged();
        // machine channel — asserted as the CODE
        assert_eq!(
            holger_errcode::parse(&msg),
            Some(&holger_errcode::UPDATE_SEQUENCE_ROLLBACK)
        );
        // human channel — the operator still sees the numbers
        assert!(msg.contains('7') && msg.contains('9'), "{msg}");
    }

    /// The three refusals an importer MUST tell apart: a rollback, a replay, and an
    /// invalid signature are three different codes — never one opaque "rejected".
    #[test]
    fn the_trust_verdicts_are_distinguishable_by_code() {
        let rollback = BundleError::SequenceRollback {
            found: 7,
            accepted: 9,
        }
        .tagged();
        let replay = BundleError::SequenceReplay(9).tagged();
        let bad_sig = BundleError::InvalidReleaseSignature.tagged();
        assert_eq!(
            holger_errcode::parse(&rollback),
            Some(&holger_errcode::UPDATE_SEQUENCE_ROLLBACK)
        );
        assert_eq!(
            holger_errcode::parse(&replay),
            Some(&holger_errcode::UPDATE_SEQUENCE_REPLAY)
        );
        assert_eq!(
            holger_errcode::parse(&bad_sig),
            Some(&holger_errcode::UPDATE_RELEASE_SIGNATURE_INVALID)
        );
        // and an IO failure is NOT mistaken for a trust verdict
        let io = BundleError::Io("no space left".into()).tagged();
        assert_eq!(holger_errcode::parse(&io), Some(&holger_errcode::UPDATE_IO));
    }
}