use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum RiskLevel {
Low,
Medium,
High,
Critical,
}
impl RiskLevel {
pub(crate) const fn as_str(self) -> &'static str {
match self {
RiskLevel::Low => "low",
RiskLevel::Medium => "medium",
RiskLevel::High => "high",
RiskLevel::Critical => "critical",
}
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawRiskFacet {
#[serde(default)]
pub(crate) likelihood: String,
#[serde(default)]
pub(crate) impact: String,
#[serde(default)]
pub(crate) origin: String,
#[serde(default)]
pub(crate) controls: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RiskFacet {
pub(crate) likelihood: Option<RiskLevel>,
pub(crate) impact: Option<RiskLevel>,
pub(crate) origin: Option<String>,
pub(crate) controls: Vec<String>,
}
fn parse_enum<T: serde::de::DeserializeOwned>(token: &str, what: &str) -> anyhow::Result<T> {
use serde::de::IntoDeserializer;
let de: serde::de::value::StrDeserializer<'_, serde::de::value::Error> =
token.into_deserializer();
T::deserialize(de).map_err(|e| anyhow::anyhow!("invalid {what} `{token}`: {e}"))
}
fn optional_enum<T: serde::de::DeserializeOwned>(
token: &str,
what: &str,
) -> anyhow::Result<Option<T>> {
if token.is_empty() {
Ok(None)
} else {
parse_enum(token, what).map(Some)
}
}
fn optional_text(text: String) -> Option<String> {
if text.is_empty() { None } else { Some(text) }
}
pub(crate) fn parse_optional(
table: Option<&toml::value::Table>,
) -> anyhow::Result<Option<RiskFacet>> {
let Some(table) = table else {
return Ok(None);
};
let raw: RawRiskFacet = toml::from_str(&toml::to_string(table)?)?;
let facet = validate_facet(raw)?;
Ok(Some(facet))
}
pub(crate) fn validate_facet(raw: RawRiskFacet) -> anyhow::Result<RiskFacet> {
Ok(RiskFacet {
likelihood: optional_enum(&raw.likelihood, "likelihood")?,
impact: optional_enum(&raw.impact, "impact")?,
origin: optional_text(raw.origin),
controls: raw.controls,
})
}
pub(crate) fn exposure(facet: Option<&RiskFacet>) -> u8 {
const fn weight(level: RiskLevel) -> u8 {
match level {
RiskLevel::Low => 1,
RiskLevel::Medium => 2,
RiskLevel::High => 3,
RiskLevel::Critical => 4,
}
}
match facet.and_then(|f| f.likelihood.zip(f.impact)) {
Some((l, i)) => weight(l) * weight(i),
None => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn risk_levels_map_to_correct_kebab_strings() {
assert_eq!(RiskLevel::Low.as_str(), "low");
assert_eq!(RiskLevel::Medium.as_str(), "medium");
assert_eq!(RiskLevel::High.as_str(), "high");
assert_eq!(RiskLevel::Critical.as_str(), "critical");
}
#[test]
fn risk_level_render_mirror_serde() {
assert_eq!(RiskLevel::Critical.as_str(), "critical");
assert_eq!(
parse_enum::<RiskLevel>("critical", "risk-level").unwrap(),
RiskLevel::Critical
);
}
fn facet(likelihood: Option<RiskLevel>, impact: Option<RiskLevel>) -> RiskFacet {
RiskFacet {
likelihood,
impact,
origin: None,
controls: Vec::new(),
}
}
#[test]
fn exposure_scores_a_fully_assessed_risk() {
use RiskLevel::{Critical, High, Low};
assert_eq!(exposure(Some(&facet(Some(High), Some(Critical)))), 12);
assert_eq!(exposure(Some(&facet(Some(Low), Some(Low)))), 1);
assert_eq!(exposure(Some(&facet(Some(Critical), Some(Critical)))), 16);
}
#[test]
fn exposure_is_baseline_when_unassessed_or_non_risk() {
use RiskLevel::High;
assert_eq!(exposure(Some(&facet(Some(High), None))), 0);
assert_eq!(exposure(Some(&facet(None, Some(High)))), 0);
assert_eq!(exposure(Some(&facet(None, None))), 0);
assert_eq!(exposure(None), 0);
}
fn facet_table_from(s: &str) -> toml::value::Table {
s.parse::<toml::Table>().unwrap()
}
#[test]
fn parse_optional_absent_is_none() {
let result = parse_optional(None).unwrap();
assert!(result.is_none());
}
#[test]
fn parse_optional_valid_facet_is_some() {
let t = facet_table_from("likelihood = \"low\"\nimpact = \"medium\"");
let facet = parse_optional(Some(&t)).unwrap().unwrap();
assert_eq!(facet.likelihood, Some(RiskLevel::Low));
assert_eq!(facet.impact, Some(RiskLevel::Medium));
assert!(facet.origin.is_none());
assert!(facet.controls.is_empty());
}
#[test]
fn parse_optional_malformed_is_err() {
let t = facet_table_from("likelihood = \"bogus\"\nimpact = \"high\"");
let err = parse_optional(Some(&t)).unwrap_err().to_string();
assert!(err.contains("invalid likelihood"), "got: {err}");
}
}