use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tatara_core::domain::compliance_binding as core;
use crate::phase::ProcessPhase;
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ComplianceSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub baseline: Option<String>,
#[serde(default)]
pub bindings: Vec<ComplianceBinding>,
#[serde(default)]
pub auto_remediate: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ComplianceBinding {
pub framework: String,
pub control_id: String,
#[serde(default)]
pub phase: VerificationPhase,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
JsonSchema,
Default,
tatara_closed_set::DeriveClosedSet,
)]
#[serde(rename_all = "PascalCase")]
#[closed_set(via = "as_str", generate_unknown, display)]
pub enum VerificationPhase {
PlanTime,
#[default]
AtBoundary,
PostConvergence,
}
impl VerificationPhase {
pub const ALL: [Self; 3] = [Self::PlanTime, Self::AtBoundary, Self::PostConvergence];
pub const fn as_str(self) -> &'static str {
match self {
Self::PlanTime => "PlanTime",
Self::AtBoundary => "AtBoundary",
Self::PostConvergence => "PostConvergence",
}
}
pub const fn gates_phase(self) -> Option<ProcessPhase> {
match self {
Self::PlanTime => Some(ProcessPhase::Execing),
Self::AtBoundary => Some(ProcessPhase::Attested),
Self::PostConvergence => None,
}
}
}
impl From<VerificationPhase> for core::VerificationPhase {
fn from(v: VerificationPhase) -> Self {
match v {
VerificationPhase::PlanTime => Self::PlanTime,
VerificationPhase::AtBoundary => Self::AtBoundary,
VerificationPhase::PostConvergence => Self::PostConvergence,
}
}
}
impl From<core::VerificationPhase> for VerificationPhase {
fn from(v: core::VerificationPhase) -> Self {
use core::VerificationPhase as C;
match v {
C::PlanTime => Self::PlanTime,
C::AtBoundary => Self::AtBoundary,
C::PostConvergence => Self::PostConvergence,
}
}
}
impl ComplianceBinding {
pub fn to_core(&self) -> core::ComplianceControl {
core::ComplianceControl {
framework: self.framework.clone(),
control_id: self.control_id.clone(),
description: self.description.clone().unwrap_or_default(),
}
}
}
pub trait ComplianceBindingSliceExt {
fn has_verification_phase(&self, kind: VerificationPhase) -> bool;
}
impl ComplianceBindingSliceExt for [ComplianceBinding] {
fn has_verification_phase(&self, kind: VerificationPhase) -> bool {
self.iter().any(|b| b.phase == kind)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_phase_is_at_boundary() {
assert_eq!(VerificationPhase::default(), VerificationPhase::AtBoundary);
}
#[test]
fn binding_roundtrip_to_core() {
let b = ComplianceBinding {
framework: "nist-800-53".into(),
control_id: "SC-7".into(),
phase: VerificationPhase::AtBoundary,
description: Some("boundary protection".into()),
};
let c = b.to_core();
assert_eq!(c.framework, "nist-800-53");
assert_eq!(c.control_id, "SC-7");
}
#[test]
fn verification_phase_is_well_formed_closed_set() {
tatara_closed_set::assert_closed_set_well_formed::<VerificationPhase>();
}
#[test]
fn verification_phase_as_str_matches_serde() {
crate::tagged_union::assert_label_matches_serde_serialization::<VerificationPhase>();
}
#[test]
fn verification_phase_display_matches_as_str() {
crate::tagged_union::assert_display_matches_label::<VerificationPhase>();
}
#[test]
fn unknown_verification_phase_errors() {
use std::str::FromStr;
for bad in [
"plantime",
"ATBOUNDARY",
"Plan-Time",
"post_convergence",
"Continuous",
] {
let err = VerificationPhase::from_str(bad).unwrap_err();
assert_eq!(err.0, bad, "error payload should echo input verbatim");
}
}
#[test]
fn verification_phase_gates_phase_truth_table() {
assert_eq!(
VerificationPhase::PlanTime.gates_phase(),
Some(ProcessPhase::Execing)
);
assert_eq!(
VerificationPhase::AtBoundary.gates_phase(),
Some(ProcessPhase::Attested)
);
assert_eq!(VerificationPhase::PostConvergence.gates_phase(), None);
}
#[test]
fn verification_phase_gates_phase_projects_to_reachable_phases() {
for vp in VerificationPhase::ALL {
if let Some(target) = vp.gates_phase() {
let reachable = ProcessPhase::ALL
.into_iter()
.any(|src| src != target && src.can_transition_to(target));
assert!(
reachable,
"{vp:?}.gates_phase() = Some({target:?}) but no legal transition lands on {target:?}",
);
}
}
}
#[test]
fn verification_phase_gates_phase_is_injective() {
let projections: Vec<ProcessPhase> = VerificationPhase::ALL
.into_iter()
.filter_map(VerificationPhase::gates_phase)
.collect();
let unique: std::collections::HashSet<_> = projections.iter().copied().collect();
assert_eq!(
projections.len(),
unique.len(),
"gates_phase projection is not injective: {projections:?}",
);
}
fn binding_at(phase: VerificationPhase) -> ComplianceBinding {
ComplianceBinding {
framework: "nist-800-53".into(),
control_id: "SC-7".into(),
phase,
description: None,
}
}
#[test]
fn compliance_binding_slice_has_verification_phase_returns_false_on_empty_slice_for_every_kind()
{
let empty: &[ComplianceBinding] = &[];
for kind in VerificationPhase::ALL {
assert!(
!empty.has_verification_phase(kind),
"empty slice must return false for {kind:?}",
);
}
}
#[test]
fn compliance_binding_slice_has_verification_phase_reads_phase_field_per_variant() {
for populated in VerificationPhase::ALL {
let slice = [binding_at(populated)];
for query in VerificationPhase::ALL {
let expected = query == populated;
assert_eq!(
slice.has_verification_phase(query),
expected,
"populated={populated:?}: query {query:?} drifted",
);
}
}
}
#[test]
fn compliance_binding_slice_has_verification_phase_scans_beyond_the_first_position() {
let slice = [
binding_at(VerificationPhase::PlanTime),
binding_at(VerificationPhase::PostConvergence),
];
for present in [
VerificationPhase::PlanTime,
VerificationPhase::PostConvergence,
] {
assert!(
slice.has_verification_phase(present),
"phase at any position must resolve true: {present:?}",
);
}
assert!(
!slice.has_verification_phase(VerificationPhase::AtBoundary),
"phase absent from the slice must resolve false: AtBoundary",
);
}
}