1use serde::Serialize;
19
20use super::doctor::ObservationSummary;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30#[serde(tag = "state", rename_all = "snake_case")]
31pub enum AssertionState {
32 Met,
34 NotMet,
36 Unknowable { reason: String },
39}
40
41impl AssertionState {
42 fn rank(&self) -> u8 {
45 match self {
46 AssertionState::Met => 0,
47 AssertionState::Unknowable { .. } => 1,
48 AssertionState::NotMet => 2,
49 }
50 }
51
52 #[must_use]
55 pub fn worst(self, other: AssertionState) -> AssertionState {
56 if other.rank() > self.rank() {
57 other
58 } else {
59 self
60 }
61 }
62
63 pub fn is_met(&self) -> bool {
64 matches!(self, AssertionState::Met)
65 }
66
67 pub fn is_not_met(&self) -> bool {
68 matches!(self, AssertionState::NotMet)
69 }
70
71 pub fn is_unknowable(&self) -> bool {
72 matches!(self, AssertionState::Unknowable { .. })
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78pub struct Assertion {
79 pub id: String,
83 pub subject: String,
86 #[serde(flatten)]
87 pub state: AssertionState,
88 pub evidence: String,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub citation: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
97 pub exempt: Option<String>,
98}
99
100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
103pub struct ConformSummary {
104 pub met: usize,
105 pub not_met: usize,
106 pub unknowable: usize,
107 pub exempt: usize,
108}
109
110impl ConformSummary {
111 pub fn of(assertions: &[Assertion]) -> ConformSummary {
113 let mut s = ConformSummary::default();
114 for a in assertions {
115 match a.state {
116 AssertionState::Met => s.met += 1,
117 AssertionState::NotMet => s.not_met += 1,
118 AssertionState::Unknowable { .. } => s.unknowable += 1,
119 }
120 if a.exempt.is_some() {
121 s.exempt += 1;
122 }
123 }
124 s
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
131#[serde(rename_all = "snake_case")]
132pub enum ConformVerdict {
133 Conforms,
135 Violates,
137 Unproven,
140}
141
142impl ConformVerdict {
143 pub fn of(summary: &ConformSummary) -> ConformVerdict {
146 if summary.not_met > 0 {
147 ConformVerdict::Violates
148 } else if summary.unknowable > 0 {
149 ConformVerdict::Unproven
150 } else {
151 ConformVerdict::Conforms
152 }
153 }
154
155 pub fn to_judgement(self) -> crate::report::Judgement {
171 use crate::report::Judgement;
172 match self {
173 ConformVerdict::Violates => Judgement::Established,
174 ConformVerdict::Conforms => Judgement::NotEstablished {
175 reason: "every declared surface met its declaration".into(),
176 },
177 ConformVerdict::Unproven => Judgement::Unobservable {
178 reason: "no assertion was not met, and at least one could not be \
179 established (RFC 13 §3)"
180 .into(),
181 },
182 }
183 }
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
189#[serde(rename_all = "snake_case")]
190pub enum ConformSource {
191 Bus,
194 Dirs,
196 Union,
198}
199
200#[derive(Debug, Clone, Serialize)]
202pub struct ConformReport {
203 pub producer: String,
205 pub slice_source: ConformSource,
206 pub origins_asked: Vec<String>,
210 pub assertions: Vec<Assertion>,
211 pub summary: ConformSummary,
212 pub verdict: ConformVerdict,
213 #[serde(skip_serializing_if = "Option::is_none")]
217 pub observation: Option<ObservationSummary>,
218 pub deep: bool,
220 #[serde(skip_serializing_if = "Vec::is_empty")]
223 pub not_asked: Vec<String>,
224}
225
226impl ConformReport {
227 pub fn junit(&self) -> String {
237 let s = &self.summary;
238 let mut out = String::new();
239 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
240 out.push_str(&format!(
241 "<testsuites name=\"zenctl check conform\" tests=\"{}\" failures=\"{}\" \
242 errors=\"0\" skipped=\"{}\">\n",
243 self.assertions.len(),
244 s.not_met,
245 s.unknowable
246 ));
247 out.push_str(&format!(
248 " <testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" errors=\"0\" \
249 skipped=\"{}\">\n",
250 xml_escape(&self.producer),
251 self.assertions.len(),
252 s.not_met,
253 s.unknowable
254 ));
255 for a in &self.assertions {
256 out.push_str(&format!(
257 " <testcase classname=\"{}\" name=\"{}\"",
258 xml_escape(&self.producer),
259 xml_escape(&a.id)
260 ));
261 let body = match &a.state {
262 AssertionState::Met => None,
263 AssertionState::NotMet => Some(format!(
264 " <failure message=\"{}\" type=\"not_met\">{}</failure>\n",
265 xml_escape(&a.evidence),
266 xml_escape(a.citation.as_deref().unwrap_or(""))
267 )),
268 AssertionState::Unknowable { reason } => Some(format!(
269 " <skipped message=\"unknowable: {}\"/>\n",
270 xml_escape(reason)
271 )),
272 };
273 let exempt = a.exempt.as_ref().map(|e| {
274 format!(
275 " <system-out>exempt: {} — {}</system-out>\n",
276 xml_escape(e),
277 xml_escape(&a.evidence)
278 )
279 });
280 if body.is_none() && exempt.is_none() {
281 out.push_str("/>\n");
282 continue;
283 }
284 out.push_str(">\n");
285 out.push_str(&body.unwrap_or_default());
286 out.push_str(&exempt.unwrap_or_default());
287 out.push_str(" </testcase>\n");
288 }
289 out.push_str(" </testsuite>\n");
290 out.push_str("</testsuites>\n");
291 out
292 }
293}
294
295fn xml_escape(s: &str) -> String {
297 let mut out = String::with_capacity(s.len());
298 for c in s.chars() {
299 match c {
300 '&' => out.push_str("&"),
301 '<' => out.push_str("<"),
302 '>' => out.push_str(">"),
303 '"' => out.push_str("""),
304 '\'' => out.push_str("'"),
305 c => out.push(c),
306 }
307 }
308 out
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 fn assertion(id: &str, state: AssertionState, exempt: Option<&str>) -> Assertion {
316 Assertion {
317 id: id.into(),
318 subject: format!("@rpc/{}", id.rsplit('/').next().unwrap()),
319 state,
320 evidence: "h-3fa9c2d41b7e: a value reply".into(),
321 citation: Some("RFC 08 §6.1".into()),
322 exempt: exempt.map(str::to_string),
323 }
324 }
325
326 fn report(assertions: Vec<Assertion>) -> ConformReport {
327 let summary = ConformSummary::of(&assertions);
328 ConformReport {
329 producer: "demo".into(),
330 slice_source: ConformSource::Dirs,
331 origins_asked: vec!["h-3fa9c2d41b7e".into()],
332 assertions,
333 verdict: ConformVerdict::of(&summary),
334 summary,
335 observation: None,
336 deep: false,
337 not_asked: vec![],
338 }
339 }
340
341 #[test]
345 fn conform_report_json_shape_is_pinned() {
346 let r = report(vec![
347 assertion("procedure/status", AssertionState::Met, None),
348 assertion(
349 "procedure/dns",
350 AssertionState::Met,
351 Some("when: config:collect.dns"),
352 ),
353 assertion("procedure/reset", AssertionState::NotMet, None),
354 assertion(
355 "procedure/peer/{id}",
356 AssertionState::Unknowable {
357 reason: "a `{var}` path names no concrete key to call".into(),
358 },
359 None,
360 ),
361 ]);
362 let json = serde_json::to_value(&r).unwrap();
363 assert_eq!(
364 json,
365 serde_json::json!({
366 "producer": "demo",
367 "slice_source": "dirs",
368 "origins_asked": ["h-3fa9c2d41b7e"],
369 "assertions": [
370 {
371 "id": "procedure/status",
372 "subject": "@rpc/status",
373 "state": "met",
374 "evidence": "h-3fa9c2d41b7e: a value reply",
375 "citation": "RFC 08 §6.1",
376 },
377 {
378 "id": "procedure/dns",
379 "subject": "@rpc/dns",
380 "state": "met",
381 "evidence": "h-3fa9c2d41b7e: a value reply",
382 "citation": "RFC 08 §6.1",
383 "exempt": "when: config:collect.dns",
384 },
385 {
386 "id": "procedure/reset",
387 "subject": "@rpc/reset",
388 "state": "not_met",
389 "evidence": "h-3fa9c2d41b7e: a value reply",
390 "citation": "RFC 08 §6.1",
391 },
392 {
393 "id": "procedure/peer/{id}",
394 "subject": "@rpc/{id}",
395 "state": "unknowable",
396 "reason": "a `{var}` path names no concrete key to call",
397 "evidence": "h-3fa9c2d41b7e: a value reply",
398 "citation": "RFC 08 §6.1",
399 },
400 ],
401 "summary": {"met": 2, "not_met": 1, "unknowable": 1, "exempt": 1},
402 "verdict": "violates",
403 "deep": false,
404 }),
405 "no window: `observation` absent; nothing unasked: `not_asked` absent"
406 );
407 }
408
409 #[test]
411 fn the_fold_never_hides_a_finding_behind_silence() {
412 let unknowable = || AssertionState::Unknowable { reason: "r".into() };
413 assert!(AssertionState::Met.worst(unknowable()).is_unknowable());
414 assert!(unknowable().worst(AssertionState::NotMet).is_not_met());
415 assert!(AssertionState::NotMet.worst(unknowable()).is_not_met());
416 assert!(AssertionState::Met.worst(AssertionState::Met).is_met());
417
418 let met = report(vec![assertion("a", AssertionState::Met, None)]);
419 assert_eq!(met.verdict, ConformVerdict::Conforms);
420 let unproven = report(vec![
421 assertion("a", AssertionState::Met, None),
422 assertion("b", unknowable(), None),
423 ]);
424 assert_eq!(unproven.verdict, ConformVerdict::Unproven);
425 let violates = report(vec![
426 assertion("b", unknowable(), None),
427 assertion("c", AssertionState::NotMet, None),
428 ]);
429 assert_eq!(violates.verdict, ConformVerdict::Violates);
430 }
431
432 #[test]
435 fn the_verdict_projects_violates_to_the_finding_exit() {
436 use crate::report::judgement_exit_code;
437 assert_eq!(
438 judgement_exit_code(&ConformVerdict::Violates.to_judgement()),
439 1
440 );
441 assert_eq!(
442 judgement_exit_code(&ConformVerdict::Conforms.to_judgement()),
443 0
444 );
445 assert_eq!(
446 judgement_exit_code(&ConformVerdict::Unproven.to_judgement()),
447 2
448 );
449 }
450
451 #[test]
455 fn junit_skips_the_unknowable_and_never_fails_on_it() {
456 let r = report(vec![
457 assertion("procedure/status", AssertionState::Met, None),
458 assertion(
459 "procedure/dns",
460 AssertionState::Met,
461 Some("when: config:collect.dns"),
462 ),
463 assertion("procedure/reset", AssertionState::NotMet, None),
464 assertion(
465 "observed/boom/{id}",
466 AssertionState::Unknowable {
467 reason: "a window proves presence, never absence".into(),
468 },
469 None,
470 ),
471 ]);
472 assert_eq!(
473 r.junit(),
474 r#"<?xml version="1.0" encoding="UTF-8"?>
475<testsuites name="zenctl check conform" tests="4" failures="1" errors="0" skipped="1">
476 <testsuite name="demo" tests="4" failures="1" errors="0" skipped="1">
477 <testcase classname="demo" name="procedure/status"/>
478 <testcase classname="demo" name="procedure/dns">
479 <system-out>exempt: when: config:collect.dns — h-3fa9c2d41b7e: a value reply</system-out>
480 </testcase>
481 <testcase classname="demo" name="procedure/reset">
482 <failure message="h-3fa9c2d41b7e: a value reply" type="not_met">RFC 08 §6.1</failure>
483 </testcase>
484 <testcase classname="demo" name="observed/boom/{id}">
485 <skipped message="unknowable: a window proves presence, never absence"/>
486 </testcase>
487 </testsuite>
488</testsuites>
489"#
490 );
491 assert_eq!(r.junit(), r.junit(), "no clock in the artifact");
492 }
493
494 #[test]
495 fn xml_escape_covers_attribute_and_text() {
496 assert_eq!(
497 xml_escape(r#"<a href="x">&'</a>"#),
498 "<a href="x">&'</a>"
499 );
500 }
501}