use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
pub const OVERLAY_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Limits {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub risk_threshold: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_processes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spawn_budget: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub human_approval_required: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OverlayDeny {
pub id: String,
pub actions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OverlayApproval {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub actions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OverlayFile {
#[serde(default = "schema_one")]
pub schema: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny: Vec<OverlayDeny>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub require_approval: Vec<OverlayApproval>,
#[serde(default)]
pub limits: Limits,
#[serde(flatten, skip_serializing)]
pub extra: BTreeMap<String, toml::Value>,
}
fn schema_one() -> u32 {
OVERLAY_SCHEMA
}
impl Default for OverlayFile {
fn default() -> Self {
Self {
schema: OVERLAY_SCHEMA,
deny: Vec::new(),
require_approval: Vec::new(),
limits: Limits::default(),
extra: BTreeMap::new(),
}
}
}
impl OverlayFile {
pub fn from_toml(s: &str) -> Result<Self, OverlayError> {
toml::from_str(s).map_err(|e| OverlayError::Parse(e.to_string()))
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ParentView {
pub rule_ids: Vec<String>,
pub deny_actions: Vec<String>,
pub require_approval_actions: Vec<String>,
pub limits: Limits,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct EffectiveOverlay {
pub deny_actions: Vec<String>,
pub require_approval_actions: Vec<String>,
pub deny_rules: Vec<OverlayDeny>,
pub approval_rules: Vec<OverlayApproval>,
pub limits: Limits,
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum OverlayError {
#[error("overlay does not parse: {0}")]
Parse(String),
#[error("overlay `schema` is {0}, expected {OVERLAY_SCHEMA}")]
Schema(u32),
#[error("overlay key `{0}` is not allowed: an overlay can only tighten")]
Forbidden(String),
#[error("overlay has unknown key `{0}`")]
UnknownKey(String),
#[error("`{key}`: malformed rule id {id:?}")]
MalformedId { key: String, id: String },
#[error("`{key}`: duplicate rule id {id:?}")]
DuplicateId { key: String, id: String },
#[error("`{key}`: rule id {id:?} shadows a parent rule")]
ShadowsParent { key: String, id: String },
#[error("`{key}`: no actions")]
EmptyActions { key: String },
#[error("`{key}`: malformed action glob {pattern:?}")]
MalformedGlob { key: String, pattern: String },
#[error("`{key}`: invalid value")]
InvalidLimit { key: String },
#[error("`{key}`: {overlay} relaxes the parent value {parent}; set it to {parent} or lower in overlay.toml")]
Relaxes {
key: String,
parent: String,
overlay: String,
},
}
pub fn valid_glob(pattern: &str) -> bool {
let body = pattern.strip_suffix('*').unwrap_or(pattern);
!pattern.is_empty() && !body.contains('*') && !pattern.chars().any(char::is_whitespace)
}
pub fn glob_matches(pattern: &str, action: &str) -> bool {
match pattern.strip_suffix('*') {
Some(prefix) => action.starts_with(prefix),
None => pattern == action,
}
}
fn check_id(key: String, id: &str) -> Result<String, OverlayError> {
let ok = !id.is_empty()
&& id == id.trim()
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':'));
if ok {
Ok(id.to_ascii_lowercase())
} else {
Err(OverlayError::MalformedId { key, id: id.into() })
}
}
fn check_actions(key: &str, actions: &[String]) -> Result<(), OverlayError> {
if actions.is_empty() {
return Err(OverlayError::EmptyActions { key: key.into() });
}
for (i, a) in actions.iter().enumerate() {
if !valid_glob(a) {
return Err(OverlayError::MalformedGlob {
key: format!("{key}[{i}]"),
pattern: a.clone(),
});
}
}
Ok(())
}
fn tighten_rule_id(
key: &str,
id: &str,
parent: &BTreeSet<String>,
seen: &mut BTreeSet<String>,
) -> Result<(), OverlayError> {
let norm = check_id(format!("{key}.id"), id)?;
if parent.contains(&norm) {
return Err(OverlayError::ShadowsParent {
key: format!("{key}.id"),
id: id.into(),
});
}
if !seen.insert(norm) {
return Err(OverlayError::DuplicateId {
key: format!("{key}.id"),
id: id.into(),
});
}
Ok(())
}
fn tighten_num<T: Copy + PartialOrd + std::fmt::Display>(
key: &str,
parent: Option<T>,
overlay: Option<T>,
) -> Result<Option<T>, OverlayError> {
match (parent, overlay) {
(Some(p), Some(o)) if o > p => Err(OverlayError::Relaxes {
key: key.into(),
parent: p.to_string(),
overlay: o.to_string(),
}),
(_, Some(o)) => Ok(Some(o)),
(p, None) => Ok(p),
}
}
fn merge_limits(parent: &Limits, o: &Limits) -> Result<Limits, OverlayError> {
if let Some(r) = o.risk_threshold
&& !(r.is_finite() && (0.0..=1.0).contains(&r))
{
return Err(OverlayError::InvalidLimit {
key: "limits.risk_threshold".into(),
});
}
let human = match (parent.human_approval_required, o.human_approval_required) {
(Some(true), Some(false)) => {
return Err(OverlayError::Relaxes {
key: "limits.human_approval_required".into(),
parent: "true".into(),
overlay: "false".into(),
});
}
(None, None) => None,
(p, o) => Some(p.unwrap_or(false) || o.unwrap_or(false)),
};
Ok(Limits {
risk_threshold: tighten_num(
"limits.risk_threshold",
parent.risk_threshold,
o.risk_threshold,
)?,
max_processes: tighten_num("limits.max_processes", parent.max_processes, o.max_processes)?,
spawn_budget: tighten_num("limits.spawn_budget", parent.spawn_budget, o.spawn_budget)?,
human_approval_required: human,
})
}
fn union(a: &[String], b: impl Iterator<Item = String>) -> Vec<String> {
let set: BTreeSet<String> = a.iter().cloned().chain(b).collect();
set.into_iter().collect()
}
pub fn merge(parent: &ParentView, overlay: &OverlayFile) -> Result<EffectiveOverlay, OverlayError> {
if overlay.schema != OVERLAY_SCHEMA {
return Err(OverlayError::Schema(overlay.schema));
}
if let Some(key) = overlay.extra.keys().next() {
return Err(match key.as_str() {
"permit" | "deactivate" => OverlayError::Forbidden(key.clone()),
_ => OverlayError::UnknownKey(key.clone()),
});
}
if let Some(r) = parent.limits.risk_threshold
&& !(r.is_finite() && (0.0..=1.0).contains(&r))
{
return Err(OverlayError::InvalidLimit {
key: "parent.limits.risk_threshold".into(),
});
}
let parent_ids: BTreeSet<String> = parent
.rule_ids
.iter()
.map(|i| i.trim().to_ascii_lowercase())
.collect();
let mut seen = BTreeSet::new();
for (i, d) in overlay.deny.iter().enumerate() {
let key = format!("deny[{i}]");
tighten_rule_id(&key, &d.id, &parent_ids, &mut seen)?;
check_actions(&format!("{key}.actions"), &d.actions)?;
}
for (i, a) in overlay.require_approval.iter().enumerate() {
let key = format!("require_approval[{i}]");
if let Some(id) = &a.id {
tighten_rule_id(&key, id, &parent_ids, &mut seen)?;
}
check_actions(&format!("{key}.actions"), &a.actions)?;
}
let limits = merge_limits(&parent.limits, &overlay.limits)?;
Ok(EffectiveOverlay {
deny_actions: union(
&parent.deny_actions,
overlay.deny.iter().flat_map(|d| d.actions.iter().cloned()),
),
require_approval_actions: union(
&parent.require_approval_actions,
overlay
.require_approval
.iter()
.flat_map(|a| a.actions.iter().cloned()),
),
deny_rules: overlay.deny.clone(),
approval_rules: overlay.require_approval.clone(),
limits,
})
}