use serde::{Deserialize, Serialize};
use sha2::{Digest as Sha2Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use time::OffsetDateTime;
const DEFAULT_TTL_DAYS: u32 = 30;
const MAX_ACTIVE_RULES: usize = 15;
const DORMANT_THRESHOLD_DAYS: i64 = 30;
const SETTLED_THRESHOLD_DAYS: i64 = 60;
const DEAD_THRESHOLD_DAYS: i64 = 90;
const MIN_CONFIRMATIONS: u64 = 2;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuleStatus {
Proposed,
Active,
Dormant,
Settled,
Dead,
Superseded,
}
impl std::fmt::Display for RuleStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Proposed => write!(f, "proposed"),
Self::Active => write!(f, "active"),
Self::Dormant => write!(f, "dormant"),
Self::Settled => write!(f, "settled"),
Self::Dead => write!(f, "dead"),
Self::Superseded => write!(f, "superseded"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuleCategory {
PreCommit,
PrePush,
CodePattern,
Workflow,
}
impl std::fmt::Display for RuleCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PreCommit => write!(f, "pre_commit"),
Self::PrePush => write!(f, "pre_push"),
Self::CodePattern => write!(f, "code_pattern"),
Self::Workflow => write!(f, "workflow"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
pub id: String,
pub trigger: String,
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anchor_file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anchor_hash: Option<String>,
pub created: String,
pub last_hit: String,
pub hits: u64,
pub ttl_days: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
pub status: RuleStatus,
pub source_session: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_event: Option<String>,
#[serde(default)]
pub shows: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_reason: Option<String>,
pub category: RuleCategory,
}
impl Rule {
pub fn is_enforceable(&self) -> bool {
self.status == RuleStatus::Active
}
pub fn is_alive(&self) -> bool {
!matches!(self.status, RuleStatus::Dead | RuleStatus::Superseded)
}
pub fn record_hit(&mut self) {
self.hits += 1;
self.last_hit = now_rfc3339();
if self.status == RuleStatus::Proposed && self.hits >= MIN_CONFIRMATIONS {
self.status = RuleStatus::Active;
}
if matches!(self.status, RuleStatus::Dormant | RuleStatus::Settled) {
self.status = RuleStatus::Active;
}
}
pub fn record_shown(&mut self) {
self.shows += 1;
}
pub fn revoke(&mut self, reason: String) {
self.status = RuleStatus::Dead;
self.revoked_reason = Some(reason);
}
pub fn days_since_last_hit(&self) -> Option<i64> {
let last = parse_rfc3339(&self.last_hit)?;
let now = OffsetDateTime::now_utc();
Some((now - last).whole_days())
}
pub fn apply_time_decay(&mut self) {
if matches!(
self.status,
RuleStatus::Dead | RuleStatus::Superseded | RuleStatus::Proposed
) {
return;
}
let days = match self.days_since_last_hit() {
Some(d) => d,
None => return,
};
if days >= DEAD_THRESHOLD_DAYS {
self.status = RuleStatus::Dead;
} else if days >= SETTLED_THRESHOLD_DAYS {
self.status = RuleStatus::Settled;
} else if days >= DORMANT_THRESHOLD_DAYS {
self.status = RuleStatus::Dormant;
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RulesStore {
pub rules: Vec<Rule>,
#[serde(default)]
pub last_decay_run: Option<String>,
}
impl RulesStore {
pub fn load(path: &Path) -> Self {
match fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
let json = serde_json::to_string_pretty(self)?;
edda_store::write_atomic(path, json.as_bytes())
}
pub fn project_rules_path(project_id: &str) -> PathBuf {
edda_store::project_dir(project_id)
.join("state")
.join("rules.json")
}
pub fn global_rules_path() -> PathBuf {
edda_store::store_root().join("rules.json")
}
pub fn load_project(project_id: &str) -> Self {
Self::load(&Self::project_rules_path(project_id))
}
pub fn save_project(&self, project_id: &str) -> anyhow::Result<()> {
self.save(&Self::project_rules_path(project_id))
}
pub fn active_rules(&self) -> Vec<&Rule> {
self.rules.iter().filter(|r| r.is_enforceable()).collect()
}
pub fn alive_rules(&self) -> Vec<&Rule> {
self.rules.iter().filter(|r| r.is_alive()).collect()
}
pub fn propose_rule(
&mut self,
trigger: String,
action: String,
anchor_file: Option<String>,
category: RuleCategory,
source_session: String,
source_event: Option<String>,
) -> String {
let mut superseded_ids = Vec::new();
for rule in &self.rules {
if rule.trigger == trigger && rule.is_alive() {
if rule.action == action {
let rule_id = rule.id.clone();
if let Some(existing) = self.rules.iter_mut().find(|r| r.id == rule_id) {
existing.record_hit();
}
return rule_id;
}
superseded_ids.push(rule.id.clone());
}
}
let new_id = new_rule_id();
for sid in &superseded_ids {
if let Some(old_rule) = self.rules.iter_mut().find(|r| r.id == *sid) {
old_rule.status = RuleStatus::Superseded;
old_rule.superseded_by = Some(new_id.clone());
}
}
let anchor_hash = anchor_file.as_ref().and_then(|f| file_sha256(f));
let now = now_rfc3339();
let rule = Rule {
id: new_id.clone(),
trigger,
action,
anchor_file,
anchor_hash,
created: now.clone(),
last_hit: now,
hits: 1,
ttl_days: DEFAULT_TTL_DAYS,
superseded_by: None,
status: RuleStatus::Proposed,
source_session,
source_event,
shows: 0,
revoked_reason: None,
category,
};
self.rules.push(rule);
new_id
}
pub fn run_decay_cycle(&mut self) {
for rule in &mut self.rules {
if rule.is_alive() && is_disallowed_trigger(&rule.trigger) {
rule.revoke("disallowed command trigger (builtin/keyword/assignment)".to_string());
}
}
for rule in &mut self.rules {
rule.apply_time_decay();
}
for rule in &mut self.rules {
if !rule.is_alive() {
continue;
}
if let (Some(ref anchor_file), Some(ref stored_hash)) =
(&rule.anchor_file, &rule.anchor_hash)
{
if let Some(current_hash) = file_sha256(anchor_file) {
if current_hash != *stored_hash && rule.status == RuleStatus::Active {
rule.status = RuleStatus::Dormant;
}
} else if !Path::new(anchor_file).exists() && rule.status == RuleStatus::Active {
rule.status = RuleStatus::Dormant;
}
}
}
let mut active_ids: Vec<(String, u64)> = self
.rules
.iter()
.filter(|r| r.status == RuleStatus::Active)
.map(|r| (r.id.clone(), r.hits))
.collect();
active_ids.sort_by_key(|entry| std::cmp::Reverse(entry.1)); if active_ids.len() > MAX_ACTIVE_RULES {
let demote_ids: Vec<String> = active_ids[MAX_ACTIVE_RULES..]
.iter()
.map(|(id, _)| id.clone())
.collect();
for rule in &mut self.rules {
if demote_ids.contains(&rule.id) {
rule.status = RuleStatus::Dormant;
}
}
}
self.last_decay_run = Some(now_rfc3339());
}
pub fn record_matched_shows(&mut self, matched_ids: &[String]) {
for id in matched_ids {
if let Some(rule) = self.get_mut(id) {
rule.record_shown();
}
}
}
pub fn revoke_rule(&mut self, id: &str, reason: String) -> bool {
match self.get_mut(id) {
Some(rule) => {
rule.revoke(reason);
true
}
None => false,
}
}
pub fn gc_dead_rules(&mut self) -> usize {
let before = self.rules.len();
self.rules.retain(|r| !matches!(r.status, RuleStatus::Dead));
before - self.rules.len()
}
pub fn find_by_trigger(&self, trigger_pattern: &str) -> Vec<&Rule> {
self.rules
.iter()
.filter(|r| r.trigger.contains(trigger_pattern))
.collect()
}
pub fn get(&self, id: &str) -> Option<&Rule> {
self.rules.iter().find(|r| r.id == id)
}
pub fn get_mut(&mut self, id: &str) -> Option<&mut Rule> {
self.rules.iter_mut().find(|r| r.id == id)
}
pub fn stats(&self) -> StoreStats {
let mut stats = StoreStats::default();
for rule in &self.rules {
match rule.status {
RuleStatus::Proposed => stats.proposed += 1,
RuleStatus::Active => stats.active += 1,
RuleStatus::Dormant => stats.dormant += 1,
RuleStatus::Settled => stats.settled += 1,
RuleStatus::Dead => stats.dead += 1,
RuleStatus::Superseded => stats.superseded += 1,
}
}
stats.total = self.rules.len();
stats
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StoreStats {
pub total: usize,
pub proposed: usize,
pub active: usize,
pub dormant: usize,
pub settled: usize,
pub dead: usize,
pub superseded: usize,
}
pub const DISALLOWED_TRIGGER_WORDS: &[&str] = &[
"alias",
"bg",
"bind",
"break",
"builtin",
"caller",
"case",
"cd",
"command",
"compgen",
"complete",
"compopt",
"continue",
"coproc",
"declare",
"dirs",
"disown",
"do",
"done",
"echo",
"elif",
"else",
"enable",
"esac",
"eval",
"exec",
"exit",
"export",
"fc",
"fg",
"fi",
"for",
"function",
"getopts",
"hash",
"help",
"history",
"if",
"in",
"jobs",
"kill",
"let",
"local",
"logout",
"mapfile",
"popd",
"printf",
"pushd",
"pwd",
"read",
"readarray",
"readonly",
"return",
"select",
"set",
"shift",
"shopt",
"source",
"suspend",
"test",
"then",
"time",
"times",
"trap",
"true",
"type",
"typeset",
"ulimit",
"umask",
"unalias",
"unset",
"until",
"wait",
"while",
"cat",
"sed",
"grep",
"head",
"tail",
"wc",
"find",
"ls",
"false",
];
pub fn is_var_assignment(token: &str) -> bool {
let Some(eq) = token.find('=') else {
return false;
};
let name = &token[..eq];
let name = name.strip_suffix('+').unwrap_or(name);
!name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn split_command_segments(cmd: &str) -> Vec<&str> {
let mut segments = Vec::new();
let mut start = 0usize;
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
for (i, ch) in cmd.char_indices() {
if escaped {
escaped = false;
continue;
}
match ch {
'\\' if !in_single => escaped = true,
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
';' | '&' | '|' | '\n' if !in_single && !in_double => {
segments.push(&cmd[start..i]);
start = i + ch.len_utf8();
}
_ => {}
}
}
segments.push(&cmd[start..]);
segments
}
fn unquoted_words(segment: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
let mut in_word = false;
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
for ch in segment.chars() {
if escaped {
if !in_double || matches!(ch, '\\' | '"' | '$' | '`') {
current.push(ch);
} else {
current.push('\\');
current.push(ch);
}
escaped = false;
continue;
}
match ch {
'\\' if !in_single => {
escaped = true;
in_word = true;
}
'\'' if !in_double => {
in_single = !in_single;
in_word = true;
}
'"' if !in_single => {
in_double = !in_double;
in_word = true;
}
c if c.is_whitespace() && !in_single && !in_double => {
if in_word {
words.push(std::mem::take(&mut current));
in_word = false;
}
}
c => {
current.push(c);
in_word = true;
}
}
}
if in_word {
words.push(current);
}
words
}
pub fn command_word(segment: &str) -> Option<String> {
let mut words = unquoted_words(segment);
while words.first().is_some_and(|w| is_var_assignment(w)) {
words.remove(0);
}
words.into_iter().next()
}
pub fn is_trackable_command(cmd: &str) -> bool {
let cmd = cmd.trim();
if cmd.is_empty() || cmd.contains([';', '|', '&', '\n']) {
return false;
}
match unquoted_words(cmd).first() {
Some(word) if !is_var_assignment(word) => {
!DISALLOWED_TRIGGER_WORDS.contains(&word.as_str())
}
_ => false,
}
}
pub fn is_disallowed_trigger(trigger: &str) -> bool {
let Some(cmd) = trigger.strip_prefix("command_failure:") else {
return false;
};
cmd.contains('=') || !is_trackable_command(cmd)
}
fn new_rule_id() -> String {
format!("rule_{}", ulid::Ulid::new().to_string().to_lowercase())
}
fn now_rfc3339() -> String {
let now = OffsetDateTime::now_utc();
now.format(&time::format_description::well_known::Rfc3339)
.expect("RFC3339 formatting should not fail")
}
fn parse_rfc3339(s: &str) -> Option<OffsetDateTime> {
OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
}
fn file_sha256(path: &str) -> Option<String> {
let data = fs::read(path).ok()?;
let hash = Sha256::digest(&data);
Some(hex::encode(hash))
}
#[path = "rules_tests.rs"]
#[cfg(test)]
mod tests;