use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::Deserialize;
use serde_yaml_ng::Value;
use super::{Allowlist, Mode, PackOverride, RedactionMode, RuleOverride};
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct RawConfig {
#[serde(default)]
pub version: Option<u32>,
#[serde(default)]
pub mode: Option<Mode>,
#[serde(default)]
pub fail_closed: Option<bool>,
#[serde(default)]
pub packs: BTreeMap<String, RawPack>,
#[serde(default)]
pub rules: BTreeMap<String, RawRuleOverride>,
#[serde(default)]
pub allowlists: Vec<RawAllowlist>,
#[serde(default)]
pub plugins: Vec<RawPluginRef>,
#[serde(default)]
pub audit: RawAudit,
}
impl RawConfig {
pub(super) fn into_merge_layer(self) -> MergeLayer {
let protected_branches = self
.packs
.get("core.project_hygiene")
.and_then(|p| p.protected_branches.clone());
let additional_workspaces = self
.packs
.get("core.workspace")
.and_then(|p| p.additional_workspaces.clone());
MergeLayer {
mode: self.mode,
fail_closed: self.fail_closed,
pack_overrides: self
.packs
.into_iter()
.map(|(k, v)| (k, PackOverride { enabled: v.enabled }))
.collect(),
rule_overrides: self.rules.into_iter().map(|(k, v)| (k, v.into())).collect(),
allowlists: self.allowlists.into_iter().map(Into::into).collect(),
plugin_paths: self
.plugins
.into_iter()
.filter(|p| p.enabled.unwrap_or(true))
.map(|p| p.path)
.collect(),
audit_path: self.audit.path,
audit_enabled: self.audit.enabled,
audit_include_allowed: self.audit.include_allowed,
audit_include_denied: self.audit.include_denied,
audit_redaction: self.audit.redaction,
protected_branches,
additional_workspaces,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct RawPack {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub protected_branches: Option<Vec<String>>,
#[serde(default)]
pub additional_workspaces: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct RawRuleOverride {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub decision: Option<crate::decision::DecisionKind>,
#[serde(default)]
pub severity: Option<crate::decision::Severity>,
}
impl From<RawRuleOverride> for RuleOverride {
fn from(value: RawRuleOverride) -> Self {
Self {
enabled: value.enabled,
decision: value.decision,
severity: value.severity,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct RawPluginRef {
pub path: PathBuf,
#[serde(default)]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct RawAudit {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub path: Option<PathBuf>,
#[serde(default)]
pub include_allowed: Option<bool>,
#[serde(default)]
pub include_denied: Option<bool>,
#[serde(default)]
pub redaction: Option<RedactionMode>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct RawAllowlist {
pub id: String,
#[serde(default)]
pub applies_to: RawAllowlistApplies,
#[serde(default)]
pub when: Option<Value>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RawAllowlistApplies {
#[serde(default)]
pub rules: Vec<String>,
}
impl From<RawAllowlist> for Allowlist {
fn from(value: RawAllowlist) -> Self {
Self {
id: value.id,
rule_ids: value.applies_to.rules,
when: value
.when
.as_ref()
.and_then(|v| crate::plugin::dsl::compile(v).ok()),
expires_at: value.expires_at,
reason: value.reason,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct MergeLayer {
pub mode: Option<Mode>,
pub fail_closed: Option<bool>,
pub pack_overrides: BTreeMap<String, PackOverride>,
pub rule_overrides: BTreeMap<String, RuleOverride>,
pub allowlists: Vec<Allowlist>,
pub plugin_paths: Vec<PathBuf>,
pub audit_enabled: Option<bool>,
pub audit_path: Option<PathBuf>,
pub audit_include_allowed: Option<bool>,
pub audit_include_denied: Option<bool>,
pub audit_redaction: Option<RedactionMode>,
pub protected_branches: Option<Vec<String>>,
pub additional_workspaces: Option<Vec<String>>,
}
impl<'de> Deserialize<'de> for RedactionMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
match raw.as_str() {
"strict" => Ok(Self::Strict),
"off" => Ok(Self::Off),
other => Err(serde::de::Error::unknown_variant(other, &["strict", "off"])),
}
}
}
impl<'de> Deserialize<'de> for Mode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
match raw.as_str() {
"enforce" => Ok(Self::Enforce),
"monitor" => Ok(Self::Monitor),
other => Err(serde::de::Error::unknown_variant(
other,
&["enforce", "monitor"],
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redaction_mode_parses_strict_and_off() {
let strict: RedactionMode = serde_yaml_ng::from_str("strict").expect("strict");
assert_eq!(strict, RedactionMode::Strict);
let off: RedactionMode = serde_yaml_ng::from_str("off").expect("off");
assert_eq!(off, RedactionMode::Off);
}
#[test]
fn redaction_mode_rejects_unknown_variant() {
let err = serde_yaml_ng::from_str::<RedactionMode>("loose")
.expect_err("unknown variant must fail");
let msg = err.to_string();
assert!(msg.contains("loose"), "unexpected message: {msg}");
}
#[test]
fn mode_parses_each_known_variant() {
let enforce: Mode = serde_yaml_ng::from_str("enforce").expect("enforce");
assert_eq!(enforce, Mode::Enforce);
let monitor: Mode = serde_yaml_ng::from_str("monitor").expect("monitor");
assert_eq!(monitor, Mode::Monitor);
}
#[test]
fn mode_rejects_unknown_variant() {
let err = serde_yaml_ng::from_str::<Mode>("yolo").expect_err("unknown variant must fail");
let msg = err.to_string();
assert!(msg.contains("yolo"), "unexpected message: {msg}");
}
#[test]
fn mode_rejects_removed_observe_variant() {
let err =
serde_yaml_ng::from_str::<Mode>("observe").expect_err("observe is not a known mode");
let msg = err.to_string();
assert!(msg.contains("observe"), "unexpected message: {msg}");
}
#[test]
fn raw_allowlist_into_allowlist_passes_fields_through() {
let raw = RawAllowlist {
id: "x".into(),
applies_to: RawAllowlistApplies {
rules: vec!["r1".into(), "r2".into()],
},
when: None,
expires_at: Some("2099-01-01T00:00:00Z".into()),
reason: Some("ok".into()),
};
let a: Allowlist = raw.into();
assert_eq!(a.id, "x");
assert_eq!(a.rule_ids, vec!["r1".to_string(), "r2".to_string()]);
assert_eq!(a.expires_at.as_deref(), Some("2099-01-01T00:00:00Z"));
assert_eq!(a.reason.as_deref(), Some("ok"));
}
#[test]
fn raw_rule_override_into_runtime_overlay_passes_fields_through() {
let raw = RawRuleOverride {
enabled: Some(false),
decision: Some(crate::decision::DecisionKind::Monitor),
severity: Some(crate::decision::Severity::Low),
};
let overlay: RuleOverride = raw.into();
assert_eq!(overlay.enabled, Some(false));
assert_eq!(
overlay.decision,
Some(crate::decision::DecisionKind::Monitor)
);
assert_eq!(overlay.severity, Some(crate::decision::Severity::Low));
}
#[test]
fn into_merge_layer_drops_disabled_plugins() {
let raw = RawConfig {
plugins: vec![
RawPluginRef {
path: PathBuf::from("/a.yaml"),
enabled: Some(true),
},
RawPluginRef {
path: PathBuf::from("/b.yaml"),
enabled: Some(false),
},
RawPluginRef {
path: PathBuf::from("/c.yaml"),
enabled: None,
},
],
..Default::default()
};
let layer = raw.into_merge_layer();
assert_eq!(
layer.plugin_paths,
vec![PathBuf::from("/a.yaml"), PathBuf::from("/c.yaml")]
);
}
}