use chrono::Duration;
use super::model::{EventData, RiskEvent, RiskState, Status};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransitionError {
pub from: Status,
pub to: Status,
}
impl std::fmt::Display for TransitionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"illegal status transition: {} → {}",
self.from.as_str(),
self.to.as_str()
)
}
}
impl std::error::Error for TransitionError {}
pub fn validate_transition(from: Status, to: Status) -> Result<(), TransitionError> {
use Status::*;
let ok = match (from, to) {
(Open | InProgress | Remediated, FalsePositive) => true,
(Open | InProgress, AcceptedException) => true,
(Open, InProgress) => true,
(InProgress, Remediated) => true,
(Remediated, Reopened) => true,
(Reopened, Open) => true,
(Reopened, InProgress) => true,
(AcceptedException, Open) => true,
_ => false,
};
if ok {
Ok(())
} else {
Err(TransitionError { from, to })
}
}
pub fn fold(events: &[RiskEvent]) -> RiskState {
let first = events.first().expect("fold: empty event log");
let mut state = seed(first);
for ev in &events[1..] {
apply(&mut state, ev);
}
state
}
pub fn fold_at(events: &[RiskEvent], cutoff: chrono::NaiveDate) -> Option<RiskState> {
let mut iter = events.iter().filter(|e| e.ts.date_naive() <= cutoff);
let first = iter.next()?;
let mut state = seed(first);
for ev in iter {
apply(&mut state, ev);
}
Some(state)
}
fn seed(first: &RiskEvent) -> RiskState {
match &first.data {
EventData::Opened {
finding_ref,
title,
severity,
impact,
likelihood,
affected_systems,
sla_days,
due_at,
} => RiskState {
status: Status::Open,
title: title.clone(),
severity: *severity,
impact: *impact,
likelihood: *likelihood,
owner: None,
due_at: Some(*due_at),
sla_days: Some(*sla_days),
affected_systems: affected_systems.clone(),
finding_refs: vec![finding_ref.clone()],
external: Vec::new(),
external_status: Default::default(),
resolved_at: None,
exception_expires_at: None,
},
other => panic!(
"fold: first event must be `opened`, got `{}`",
other.type_str()
),
}
}
fn apply(state: &mut RiskState, ev: &RiskEvent) {
match &ev.data {
EventData::Opened { .. } => {}
EventData::OwnerAssigned { owner } => {
state.owner = Some(owner.clone());
}
EventData::ScoreChanged {
impact,
likelihood,
severity,
..
} => {
state.impact = *impact;
state.likelihood = *likelihood;
state.severity = *severity;
if let Some(days) = state.sla_days {
if let Some(first_ref_day) = open_day(state) {
state.due_at = Some(first_ref_day + Duration::days(days as i64));
}
}
}
EventData::Retitled { title, .. } => {
state.title = title.clone();
}
EventData::SlaSet { due_at, .. } => {
state.due_at = Some(*due_at);
state.sla_days = None;
}
EventData::StatusChanged { to, .. } => {
set_status(state, *to, ev);
}
EventData::EvidenceLinked { finding_ref } => {
state.finding_refs.push(finding_ref.clone());
}
EventData::ExternalLinked {
system,
external_id,
url,
} => {
state.external.push(super::model::ExternalLink {
system: system.clone(),
external_id: external_id.clone(),
url: url.clone(),
});
}
EventData::ExternalStatusObserved { system, status, .. } => {
state.external_status.insert(system.clone(), status.clone());
}
EventData::Note { .. } => {}
EventData::Remediated { .. } => {
set_status(state, Status::Remediated, ev);
}
EventData::Reopened { .. } => {
state.status = Status::Open;
state.resolved_at = None;
}
EventData::ExceptionDocumented { expires_at, .. } => {
state.status = Status::AcceptedException;
state.exception_expires_at = Some(*expires_at);
}
}
}
fn set_status(state: &mut RiskState, to: Status, ev: &RiskEvent) {
match to {
Status::Remediated => {
state.status = Status::Remediated;
state.resolved_at = Some(ev.ts);
}
Status::Reopened | Status::Open => {
state.status = Status::Open;
state.resolved_at = None;
}
other => {
state.status = other;
}
}
}
fn open_day(state: &RiskState) -> Option<chrono::NaiveDate> {
let run_id = &state.finding_refs.first()?.run_id;
run_id
.get(0..10)
.and_then(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
}