use serde::Serialize;
use super::doctor::ObservationSummary;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum AssertionState {
Met,
NotMet,
Unknowable { reason: String },
}
impl AssertionState {
fn rank(&self) -> u8 {
match self {
AssertionState::Met => 0,
AssertionState::Unknowable { .. } => 1,
AssertionState::NotMet => 2,
}
}
#[must_use]
pub fn worst(self, other: AssertionState) -> AssertionState {
if other.rank() > self.rank() {
other
} else {
self
}
}
pub fn is_met(&self) -> bool {
matches!(self, AssertionState::Met)
}
pub fn is_not_met(&self) -> bool {
matches!(self, AssertionState::NotMet)
}
pub fn is_unknowable(&self) -> bool {
matches!(self, AssertionState::Unknowable { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Assertion {
pub id: String,
pub subject: String,
#[serde(flatten)]
pub state: AssertionState,
pub evidence: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub citation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exempt: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct ConformSummary {
pub met: usize,
pub not_met: usize,
pub unknowable: usize,
pub exempt: usize,
}
impl ConformSummary {
pub fn of(assertions: &[Assertion]) -> ConformSummary {
let mut s = ConformSummary::default();
for a in assertions {
match a.state {
AssertionState::Met => s.met += 1,
AssertionState::NotMet => s.not_met += 1,
AssertionState::Unknowable { .. } => s.unknowable += 1,
}
if a.exempt.is_some() {
s.exempt += 1;
}
}
s
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConformVerdict {
Conforms,
Violates,
Unproven,
}
impl ConformVerdict {
pub fn of(summary: &ConformSummary) -> ConformVerdict {
if summary.not_met > 0 {
ConformVerdict::Violates
} else if summary.unknowable > 0 {
ConformVerdict::Unproven
} else {
ConformVerdict::Conforms
}
}
pub fn to_judgement(self) -> crate::report::Judgement {
use crate::report::Judgement;
match self {
ConformVerdict::Violates => Judgement::Established,
ConformVerdict::Conforms => Judgement::NotEstablished {
reason: "every declared surface met its declaration".into(),
},
ConformVerdict::Unproven => Judgement::Unobservable {
reason: "no assertion was not met, and at least one could not be \
established (RFC 13 §3)"
.into(),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConformSource {
Bus,
Dirs,
Union,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConformReport {
pub producer: String,
pub slice_source: ConformSource,
pub origins_asked: Vec<String>,
pub assertions: Vec<Assertion>,
pub summary: ConformSummary,
pub verdict: ConformVerdict,
#[serde(skip_serializing_if = "Option::is_none")]
pub observation: Option<ObservationSummary>,
pub deep: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub not_asked: Vec<String>,
}
impl ConformReport {
pub fn junit(&self) -> String {
let s = &self.summary;
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str(&format!(
"<testsuites name=\"zenctl check conform\" tests=\"{}\" failures=\"{}\" \
errors=\"0\" skipped=\"{}\">\n",
self.assertions.len(),
s.not_met,
s.unknowable
));
out.push_str(&format!(
" <testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" errors=\"0\" \
skipped=\"{}\">\n",
xml_escape(&self.producer),
self.assertions.len(),
s.not_met,
s.unknowable
));
for a in &self.assertions {
out.push_str(&format!(
" <testcase classname=\"{}\" name=\"{}\"",
xml_escape(&self.producer),
xml_escape(&a.id)
));
let body = match &a.state {
AssertionState::Met => None,
AssertionState::NotMet => Some(format!(
" <failure message=\"{}\" type=\"not_met\">{}</failure>\n",
xml_escape(&a.evidence),
xml_escape(a.citation.as_deref().unwrap_or(""))
)),
AssertionState::Unknowable { reason } => Some(format!(
" <skipped message=\"unknowable: {}\"/>\n",
xml_escape(reason)
)),
};
let exempt = a.exempt.as_ref().map(|e| {
format!(
" <system-out>exempt: {} — {}</system-out>\n",
xml_escape(e),
xml_escape(&a.evidence)
)
});
if body.is_none() && exempt.is_none() {
out.push_str("/>\n");
continue;
}
out.push_str(">\n");
out.push_str(&body.unwrap_or_default());
out.push_str(&exempt.unwrap_or_default());
out.push_str(" </testcase>\n");
}
out.push_str(" </testsuite>\n");
out.push_str("</testsuites>\n");
out
}
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn assertion(id: &str, state: AssertionState, exempt: Option<&str>) -> Assertion {
Assertion {
id: id.into(),
subject: format!("@rpc/{}", id.rsplit('/').next().unwrap()),
state,
evidence: "h-3fa9c2d41b7e: a value reply".into(),
citation: Some("RFC 08 §6.1".into()),
exempt: exempt.map(str::to_string),
}
}
fn report(assertions: Vec<Assertion>) -> ConformReport {
let summary = ConformSummary::of(&assertions);
ConformReport {
producer: "demo".into(),
slice_source: ConformSource::Dirs,
origins_asked: vec!["h-3fa9c2d41b7e".into()],
assertions,
verdict: ConformVerdict::of(&summary),
summary,
observation: None,
deep: false,
not_asked: vec![],
}
}
#[test]
fn conform_report_json_shape_is_pinned() {
let r = report(vec![
assertion("procedure/status", AssertionState::Met, None),
assertion(
"procedure/dns",
AssertionState::Met,
Some("when: config:collect.dns"),
),
assertion("procedure/reset", AssertionState::NotMet, None),
assertion(
"procedure/peer/{id}",
AssertionState::Unknowable {
reason: "a `{var}` path names no concrete key to call".into(),
},
None,
),
]);
let json = serde_json::to_value(&r).unwrap();
assert_eq!(
json,
serde_json::json!({
"producer": "demo",
"slice_source": "dirs",
"origins_asked": ["h-3fa9c2d41b7e"],
"assertions": [
{
"id": "procedure/status",
"subject": "@rpc/status",
"state": "met",
"evidence": "h-3fa9c2d41b7e: a value reply",
"citation": "RFC 08 §6.1",
},
{
"id": "procedure/dns",
"subject": "@rpc/dns",
"state": "met",
"evidence": "h-3fa9c2d41b7e: a value reply",
"citation": "RFC 08 §6.1",
"exempt": "when: config:collect.dns",
},
{
"id": "procedure/reset",
"subject": "@rpc/reset",
"state": "not_met",
"evidence": "h-3fa9c2d41b7e: a value reply",
"citation": "RFC 08 §6.1",
},
{
"id": "procedure/peer/{id}",
"subject": "@rpc/{id}",
"state": "unknowable",
"reason": "a `{var}` path names no concrete key to call",
"evidence": "h-3fa9c2d41b7e: a value reply",
"citation": "RFC 08 §6.1",
},
],
"summary": {"met": 2, "not_met": 1, "unknowable": 1, "exempt": 1},
"verdict": "violates",
"deep": false,
}),
"no window: `observation` absent; nothing unasked: `not_asked` absent"
);
}
#[test]
fn the_fold_never_hides_a_finding_behind_silence() {
let unknowable = || AssertionState::Unknowable { reason: "r".into() };
assert!(AssertionState::Met.worst(unknowable()).is_unknowable());
assert!(unknowable().worst(AssertionState::NotMet).is_not_met());
assert!(AssertionState::NotMet.worst(unknowable()).is_not_met());
assert!(AssertionState::Met.worst(AssertionState::Met).is_met());
let met = report(vec![assertion("a", AssertionState::Met, None)]);
assert_eq!(met.verdict, ConformVerdict::Conforms);
let unproven = report(vec![
assertion("a", AssertionState::Met, None),
assertion("b", unknowable(), None),
]);
assert_eq!(unproven.verdict, ConformVerdict::Unproven);
let violates = report(vec![
assertion("b", unknowable(), None),
assertion("c", AssertionState::NotMet, None),
]);
assert_eq!(violates.verdict, ConformVerdict::Violates);
}
#[test]
fn the_verdict_projects_violates_to_the_finding_exit() {
use crate::report::judgement_exit_code;
assert_eq!(
judgement_exit_code(&ConformVerdict::Violates.to_judgement()),
1
);
assert_eq!(
judgement_exit_code(&ConformVerdict::Conforms.to_judgement()),
0
);
assert_eq!(
judgement_exit_code(&ConformVerdict::Unproven.to_judgement()),
2
);
}
#[test]
fn junit_skips_the_unknowable_and_never_fails_on_it() {
let r = report(vec![
assertion("procedure/status", AssertionState::Met, None),
assertion(
"procedure/dns",
AssertionState::Met,
Some("when: config:collect.dns"),
),
assertion("procedure/reset", AssertionState::NotMet, None),
assertion(
"observed/boom/{id}",
AssertionState::Unknowable {
reason: "a window proves presence, never absence".into(),
},
None,
),
]);
assert_eq!(
r.junit(),
r#"<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="zenctl check conform" tests="4" failures="1" errors="0" skipped="1">
<testsuite name="demo" tests="4" failures="1" errors="0" skipped="1">
<testcase classname="demo" name="procedure/status"/>
<testcase classname="demo" name="procedure/dns">
<system-out>exempt: when: config:collect.dns — h-3fa9c2d41b7e: a value reply</system-out>
</testcase>
<testcase classname="demo" name="procedure/reset">
<failure message="h-3fa9c2d41b7e: a value reply" type="not_met">RFC 08 §6.1</failure>
</testcase>
<testcase classname="demo" name="observed/boom/{id}">
<skipped message="unknowable: a window proves presence, never absence"/>
</testcase>
</testsuite>
</testsuites>
"#
);
assert_eq!(r.junit(), r.junit(), "no clock in the artifact");
}
#[test]
fn xml_escape_covers_attribute_and_text() {
assert_eq!(
xml_escape(r#"<a href="x">&'</a>"#),
"<a href="x">&'</a>"
);
}
}