use std::io;
use std::path::Path;
use assay_core::errors::Diagnostic;
use assay_core::report::summary::Summary;
use crate::cli::commands::pipeline_error::emit_operator_diagnostic;
use crate::exit_codes::{ReasonCode, RunOutcome, EXIT_SUCCESS};
use crate::output_write::{map_write_result, write_stdout_json};
fn evidence_reason(error: &anyhow::Error) -> Option<ReasonCode> {
crate::evidence_verify_reason::reason_code_for_evidence_error(error)
}
#[derive(Debug)]
pub(crate) struct CliFailure {
outcome: RunOutcome,
source: &'static str,
context: serde_json::Value,
}
impl CliFailure {
pub(crate) fn coverage_invalid_args(message: impl Into<String>) -> Self {
let outcome = RunOutcome::from_reason(ReasonCode::EInvalidArgs, Some(message.into()), None);
Self {
outcome,
source: "coverage",
context: serde_json::json!({}),
}
}
pub(crate) fn policy_parse(path: &Path, error: impl std::fmt::Display) -> Self {
let path = path.display().to_string();
let message = format!("failed to parse policy {path}: {error}");
let outcome =
RunOutcome::from_reason(ReasonCode::EPolicyParse, Some(message), Some(path.as_str()));
Self {
outcome,
source: "policy",
context: serde_json::json!({ "path": path }),
}
}
pub(crate) fn evidence_integrity(path: &Path, error: &anyhow::Error) -> Option<Self> {
if evidence_reason(error) != Some(ReasonCode::EEvidenceIntegrity) {
return None;
}
let verifier = error
.chain()
.find_map(|cause| cause.downcast_ref::<assay_evidence::VerifyError>())?;
let path = path.display().to_string();
let verifier_code = verifier.code.to_string();
let message = format!("evidence bundle {path} failed content verification: {error}");
let outcome = RunOutcome::from_reason(
ReasonCode::EEvidenceIntegrity,
Some(message),
Some(path.as_str()),
);
Some(Self {
outcome,
source: "evidence",
context: serde_json::json!({
"path": path,
"verifier_code": verifier_code,
}),
})
}
pub(crate) fn evidence_unreadable(path: &Path, error: &anyhow::Error) -> Option<Self> {
if evidence_reason(error) != Some(ReasonCode::EEvidenceUnreadable) {
return None;
}
let path = path.display().to_string();
let message = format!("evidence bundle {path} could not be opened or read: {error}");
let outcome = RunOutcome::from_reason(
ReasonCode::EEvidenceUnreadable,
Some(message),
Some(path.as_str()),
);
Some(Self {
outcome,
source: "evidence",
context: serde_json::json!({ "path": path }),
})
}
pub(crate) fn evidence_contract(path: &Path, error: &anyhow::Error) -> Option<Self> {
if evidence_reason(error) != Some(ReasonCode::EEvidenceContract) {
return None;
}
let path = path.display().to_string();
let message =
format!("evidence bundle {path} violates its declared format contract: {error:#}");
let outcome = RunOutcome::from_reason(
ReasonCode::EEvidenceContract,
Some(message),
Some(path.as_str()),
);
Some(Self {
outcome,
source: "evidence",
context: serde_json::json!({ "path": path }),
})
}
pub(crate) fn emit(self, machine_output_verify_enabled: Option<bool>) -> i32 {
emit_operator_diagnostic(&self.diagnostic());
if let Some(verify_enabled) = machine_output_verify_enabled {
let summary = summary_from_outcome(&self.outcome, verify_enabled);
let write_code = write_summary_stdout(&summary);
if write_code != EXIT_SUCCESS {
return write_code;
}
}
self.outcome.exit_code
}
fn diagnostic(&self) -> Diagnostic {
let mut diagnostic = Diagnostic::new(
self.outcome.reason_code.clone(),
self.outcome.message.clone().unwrap_or_default(),
)
.with_source(self.source)
.with_context(self.context.clone());
if let Some(next_step) = &self.outcome.next_step {
diagnostic = diagnostic.with_fix_step(next_step.clone());
}
diagnostic
}
}
impl std::fmt::Display for CliFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.outcome.message.as_deref().unwrap_or("CLI failure"))
}
}
impl std::error::Error for CliFailure {}
pub(crate) fn summary_from_outcome(outcome: &RunOutcome, verify_enabled: bool) -> Summary {
let assay_version = env!("CARGO_PKG_VERSION");
if outcome.exit_code == 0 {
Summary::success(assay_version, verify_enabled)
} else {
Summary::failure(
outcome.exit_code,
&outcome.reason_code,
outcome.message.as_deref().unwrap_or(""),
outcome.next_step.as_deref().unwrap_or(""),
assay_version,
verify_enabled,
)
}
}
pub(crate) fn write_summary_stdout(summary: &Summary) -> i32 {
let rendered = match assay_core::report::summary::render_summary_json(summary) {
Ok(rendered) => rendered,
Err(error) => {
return map_write_result(
"stdout",
Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to render machine summary: {error}"),
)),
)
}
};
write_stdout_json(&rendered)
}
#[cfg(test)]
mod tests {
use super::CliFailure;
use crate::evidence_verify_reason::reason_code_for_verify_error;
use crate::exit_codes::ReasonCode;
use assay_evidence::{ErrorClass, ErrorCode, VerifyError};
use std::collections::BTreeSet;
use std::path::Path;
const INTEGRITY_CODES: &[ErrorCode] = &[
ErrorCode::IntegrityManifestHash,
ErrorCode::IntegrityEventHash,
ErrorCode::IntegrityFileSizeMismatch,
ErrorCode::IntegrityRunRootMismatch,
];
const UNREADABLE_CODES: &[ErrorCode] = &[
ErrorCode::IntegrityIo,
ErrorCode::IntegrityGzip,
ErrorCode::IntegrityTar,
];
fn verifier_error(class: ErrorClass, code: ErrorCode) -> anyhow::Error {
anyhow::Error::new(VerifyError::new(class, code, "measured failure"))
.context("bundle reader failed")
}
#[test]
fn evidence_integrity_code_set_matches_the_normative_boundary() {
let boundary = include_str!("exit_codes/evidence_integrity_boundary.md");
let required = boundary
.split_once("An emitter MUST key on")
.and_then(|(_, rest)| rest.split_once("and MUST NOT map"))
.map(|(required, _)| required)
.expect("normative boundary must retain its required/forbidden code clauses");
let normative: BTreeSet<&str> = required
.split('`')
.filter(|token| token.starts_with("Integrity"))
.collect();
assert!(
!normative.is_empty(),
"the normative integrity boundary parser must find verifier codes"
);
let implemented: BTreeSet<String> = INTEGRITY_CODES
.iter()
.copied()
.filter(|code| {
reason_code_for_verify_error(&VerifyError::new(
ErrorClass::Integrity,
*code,
"boundary",
)) == Some(ReasonCode::EEvidenceIntegrity)
})
.map(|code| code.to_string())
.collect();
assert_eq!(
implemented,
normative.into_iter().map(str::to_string).collect(),
"the executable integrity classifier drifted from the one normative boundary"
);
}
#[test]
fn evidence_unreadable_code_set_matches_the_normative_registry() {
let spec = include_str!("../../../docs/architecture/SPEC-PR-Gate-Outputs-v1.md");
let row = spec
.lines()
.find(|line| line.starts_with("| E_EVIDENCE_UNREADABLE |"))
.expect("reason registry must retain E_EVIDENCE_UNREADABLE");
let normative: BTreeSet<&str> = row
.split('`')
.filter(|token| token.starts_with("Integrity"))
.collect();
assert!(
!normative.is_empty(),
"the unreadable reason registry parser must find verifier codes"
);
let implemented: BTreeSet<String> = UNREADABLE_CODES
.iter()
.copied()
.filter(|code| {
reason_code_for_verify_error(&VerifyError::new(
ErrorClass::Integrity,
*code,
"registry",
)) == Some(ReasonCode::EEvidenceUnreadable)
})
.map(|code| code.to_string())
.collect();
assert_eq!(
implemented,
normative.into_iter().map(str::to_string).collect(),
"the executable unreadable classifier drifted from the normative registry"
);
}
#[test]
fn evidence_integrity_classification_matches_the_normative_code_boundary() {
for &code in INTEGRITY_CODES {
let failure = CliFailure::evidence_integrity(
Path::new("bundle.tar.gz"),
&verifier_error(ErrorClass::Integrity, code),
)
.unwrap_or_else(|| panic!("{code} must classify as an evidence mismatch"));
assert_eq!(failure.outcome.reason_code, "E_EVIDENCE_INTEGRITY");
assert_eq!(failure.outcome.exit_code, 2);
assert!(
failure
.outcome
.next_step
.as_deref()
.is_some_and(|step| !step.is_empty()),
"{code} must carry remediation"
);
}
for (class, code) in [
(ErrorClass::Integrity, ErrorCode::IntegrityIo),
(ErrorClass::Integrity, ErrorCode::IntegrityGzip),
(ErrorClass::Integrity, ErrorCode::IntegrityTar),
(ErrorClass::Contract, ErrorCode::ContractInvalidJson),
(ErrorClass::Limits, ErrorCode::LimitBundleBytes),
(ErrorClass::Security, ErrorCode::SecurityPathTraversal),
] {
assert!(
CliFailure::evidence_integrity(
Path::new("bundle.tar.gz"),
&verifier_error(class, code),
)
.is_none(),
"{code} establishes no recorded-value mismatch"
);
}
}
#[test]
fn evidence_unreadable_classification_excludes_content_and_contract_findings() {
let direct_io = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::NotFound));
let failure = CliFailure::evidence_unreadable(Path::new("missing.bundle"), &direct_io)
.expect("a direct open failure must classify as unreadable");
assert_eq!(failure.outcome.reason_code, "E_EVIDENCE_UNREADABLE");
for &code in UNREADABLE_CODES {
assert!(
CliFailure::evidence_unreadable(
Path::new("bundle.tar.gz"),
&verifier_error(ErrorClass::Integrity, code),
)
.is_some(),
"{code} must classify as unreadable"
);
}
for (class, code) in [
(ErrorClass::Integrity, ErrorCode::IntegrityManifestHash),
(ErrorClass::Contract, ErrorCode::ContractInvalidJson),
(ErrorClass::Limits, ErrorCode::LimitBundleBytes),
(ErrorClass::Security, ErrorCode::SecurityPathTraversal),
] {
assert!(
CliFailure::evidence_unreadable(
Path::new("bundle.tar.gz"),
&verifier_error(class, code),
)
.is_none(),
"{code} is not an unreadable-bundle finding"
);
}
let contract_with_io_source = anyhow::Error::new(
VerifyError::new(
ErrorClass::Contract,
ErrorCode::ContractInvalidJson,
"invalid event",
)
.with_source(std::io::Error::from(std::io::ErrorKind::UnexpectedEof)),
);
assert!(
CliFailure::evidence_unreadable(Path::new("bundle.tar.gz"), &contract_with_io_source,)
.is_none(),
"a typed contract code must not be reclassified from its nested I/O source"
);
}
#[test]
fn evidence_contract_cannot_stamp_contract_on_a_non_contract_verifier() {
for (class, code) in [
(ErrorClass::Limits, ErrorCode::LimitBundleBytes),
(ErrorClass::Security, ErrorCode::SecurityPathTraversal),
(ErrorClass::Integrity, ErrorCode::IntegrityEventHash),
] {
assert!(
CliFailure::evidence_contract(
Path::new("bundle.tar.gz"),
&verifier_error(class, code),
)
.is_none(),
"{code} must not be stampable as E_EVIDENCE_CONTRACT"
);
}
}
#[test]
fn evidence_contract_constructs_for_typed_contract_invalid_json() {
let failure = CliFailure::evidence_contract(
Path::new("bundle.tar.gz"),
&verifier_error(ErrorClass::Contract, ErrorCode::ContractInvalidJson),
)
.expect("ContractInvalidJson must construct as E_EVIDENCE_CONTRACT");
assert_eq!(failure.outcome.reason_code, "E_EVIDENCE_CONTRACT");
assert_eq!(failure.outcome.exit_code, 2);
}
}