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));
}
}