1use serde::{Deserialize, Serialize};
16use sha2::{Digest as Sha2Digest, Sha256};
17use std::fs;
18use std::path::{Path, PathBuf};
19use time::OffsetDateTime;
20
21const DEFAULT_TTL_DAYS: u32 = 30;
23
24const MAX_ACTIVE_RULES: usize = 15;
26
27const DORMANT_THRESHOLD_DAYS: i64 = 30;
29
30const SETTLED_THRESHOLD_DAYS: i64 = 60;
32
33const DEAD_THRESHOLD_DAYS: i64 = 90;
35
36const MIN_CONFIRMATIONS: u64 = 2;
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum RuleStatus {
43 Proposed,
45 Active,
47 Dormant,
49 Settled,
51 Dead,
53 Superseded,
55}
56
57impl std::fmt::Display for RuleStatus {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self {
60 Self::Proposed => write!(f, "proposed"),
61 Self::Active => write!(f, "active"),
62 Self::Dormant => write!(f, "dormant"),
63 Self::Settled => write!(f, "settled"),
64 Self::Dead => write!(f, "dead"),
65 Self::Superseded => write!(f, "superseded"),
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "snake_case")]
73pub enum RuleCategory {
74 PreCommit,
76 PrePush,
78 CodePattern,
80 Workflow,
82}
83
84impl std::fmt::Display for RuleCategory {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 match self {
87 Self::PreCommit => write!(f, "pre_commit"),
88 Self::PrePush => write!(f, "pre_push"),
89 Self::CodePattern => write!(f, "code_pattern"),
90 Self::Workflow => write!(f, "workflow"),
91 }
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Rule {
98 pub id: String,
99 pub trigger: String,
100 pub action: String,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub anchor_file: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub anchor_hash: Option<String>,
105 pub created: String,
106 pub last_hit: String,
107 pub hits: u64,
108 pub ttl_days: u32,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub superseded_by: Option<String>,
111 pub status: RuleStatus,
112 pub source_session: String,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub source_event: Option<String>,
115 #[serde(default)]
120 pub shows: u64,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub revoked_reason: Option<String>,
124 pub category: RuleCategory,
125}
126
127impl Rule {
128 pub fn is_enforceable(&self) -> bool {
130 self.status == RuleStatus::Active
131 }
132
133 pub fn is_alive(&self) -> bool {
135 !matches!(self.status, RuleStatus::Dead | RuleStatus::Superseded)
136 }
137
138 pub fn record_hit(&mut self) {
140 self.hits += 1;
141 self.last_hit = now_rfc3339();
142 if self.status == RuleStatus::Proposed && self.hits >= MIN_CONFIRMATIONS {
144 self.status = RuleStatus::Active;
145 }
146 if matches!(self.status, RuleStatus::Dormant | RuleStatus::Settled) {
148 self.status = RuleStatus::Active;
149 }
150 }
151
152 pub fn record_shown(&mut self) {
158 self.shows += 1;
159 }
160
161 pub fn revoke(&mut self, reason: String) {
163 self.status = RuleStatus::Dead;
164 self.revoked_reason = Some(reason);
165 }
166
167 pub fn days_since_last_hit(&self) -> Option<i64> {
169 let last = parse_rfc3339(&self.last_hit)?;
170 let now = OffsetDateTime::now_utc();
171 Some((now - last).whole_days())
172 }
173
174 pub fn apply_time_decay(&mut self) {
176 if matches!(
177 self.status,
178 RuleStatus::Dead | RuleStatus::Superseded | RuleStatus::Proposed
179 ) {
180 return;
181 }
182
183 let days = match self.days_since_last_hit() {
184 Some(d) => d,
185 None => return,
186 };
187
188 if days >= DEAD_THRESHOLD_DAYS {
189 self.status = RuleStatus::Dead;
190 } else if days >= SETTLED_THRESHOLD_DAYS {
191 self.status = RuleStatus::Settled;
192 } else if days >= DORMANT_THRESHOLD_DAYS {
193 self.status = RuleStatus::Dormant;
194 }
195 }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, Default)]
201pub struct RulesStore {
202 pub rules: Vec<Rule>,
203 #[serde(default)]
204 pub last_decay_run: Option<String>,
205}
206
207impl RulesStore {
208 pub fn load(path: &Path) -> Self {
210 match fs::read_to_string(path) {
211 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
212 Err(_) => Self::default(),
213 }
214 }
215
216 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
218 let json = serde_json::to_string_pretty(self)?;
219 edda_store::write_atomic(path, json.as_bytes())
220 }
221
222 pub fn project_rules_path(project_id: &str) -> PathBuf {
224 edda_store::project_dir(project_id)
225 .join("state")
226 .join("rules.json")
227 }
228
229 pub fn global_rules_path() -> PathBuf {
231 edda_store::store_root().join("rules.json")
232 }
233
234 pub fn load_project(project_id: &str) -> Self {
236 Self::load(&Self::project_rules_path(project_id))
237 }
238
239 pub fn save_project(&self, project_id: &str) -> anyhow::Result<()> {
241 self.save(&Self::project_rules_path(project_id))
242 }
243
244 pub fn active_rules(&self) -> Vec<&Rule> {
246 self.rules.iter().filter(|r| r.is_enforceable()).collect()
247 }
248
249 pub fn alive_rules(&self) -> Vec<&Rule> {
251 self.rules.iter().filter(|r| r.is_alive()).collect()
252 }
253
254 pub fn propose_rule(
257 &mut self,
258 trigger: String,
259 action: String,
260 anchor_file: Option<String>,
261 category: RuleCategory,
262 source_session: String,
263 source_event: Option<String>,
264 ) -> String {
265 let mut superseded_ids = Vec::new();
267 for rule in &self.rules {
268 if rule.trigger == trigger && rule.is_alive() {
269 if rule.action == action {
270 let rule_id = rule.id.clone();
273 if let Some(existing) = self.rules.iter_mut().find(|r| r.id == rule_id) {
274 existing.record_hit();
275 }
276 return rule_id;
277 }
278 superseded_ids.push(rule.id.clone());
280 }
281 }
282
283 let new_id = new_rule_id();
285 for sid in &superseded_ids {
286 if let Some(old_rule) = self.rules.iter_mut().find(|r| r.id == *sid) {
287 old_rule.status = RuleStatus::Superseded;
288 old_rule.superseded_by = Some(new_id.clone());
289 }
290 }
291
292 let anchor_hash = anchor_file.as_ref().and_then(|f| file_sha256(f));
294
295 let now = now_rfc3339();
296 let rule = Rule {
297 id: new_id.clone(),
298 trigger,
299 action,
300 anchor_file,
301 anchor_hash,
302 created: now.clone(),
303 last_hit: now,
304 hits: 1,
305 ttl_days: DEFAULT_TTL_DAYS,
306 superseded_by: None,
307 status: RuleStatus::Proposed,
308 source_session,
309 source_event,
310 shows: 0,
311 revoked_reason: None,
312 category,
313 };
314
315 self.rules.push(rule);
316 new_id
317 }
318
319 pub fn run_decay_cycle(&mut self) {
325 for rule in &mut self.rules {
329 if rule.is_alive() && is_disallowed_trigger(&rule.trigger) {
330 rule.revoke("disallowed command trigger (builtin/keyword/assignment)".to_string());
331 }
332 }
333
334 for rule in &mut self.rules {
336 rule.apply_time_decay();
337 }
338
339 for rule in &mut self.rules {
341 if !rule.is_alive() {
342 continue;
343 }
344 if let (Some(ref anchor_file), Some(ref stored_hash)) =
345 (&rule.anchor_file, &rule.anchor_hash)
346 {
347 if let Some(current_hash) = file_sha256(anchor_file) {
348 if current_hash != *stored_hash && rule.status == RuleStatus::Active {
349 rule.status = RuleStatus::Dormant;
350 }
351 } else if !Path::new(anchor_file).exists() && rule.status == RuleStatus::Active {
352 rule.status = RuleStatus::Dormant;
353 }
354 }
355 }
356
357 let mut active_ids: Vec<(String, u64)> = self
359 .rules
360 .iter()
361 .filter(|r| r.status == RuleStatus::Active)
362 .map(|r| (r.id.clone(), r.hits))
363 .collect();
364 active_ids.sort_by_key(|entry| std::cmp::Reverse(entry.1)); if active_ids.len() > MAX_ACTIVE_RULES {
366 let demote_ids: Vec<String> = active_ids[MAX_ACTIVE_RULES..]
367 .iter()
368 .map(|(id, _)| id.clone())
369 .collect();
370 for rule in &mut self.rules {
371 if demote_ids.contains(&rule.id) {
372 rule.status = RuleStatus::Dormant;
373 }
374 }
375 }
376
377 self.last_decay_run = Some(now_rfc3339());
378 }
379
380 pub fn record_matched_shows(&mut self, matched_ids: &[String]) {
383 for id in matched_ids {
384 if let Some(rule) = self.get_mut(id) {
385 rule.record_shown();
386 }
387 }
388 }
389
390 pub fn revoke_rule(&mut self, id: &str, reason: String) -> bool {
392 match self.get_mut(id) {
393 Some(rule) => {
394 rule.revoke(reason);
395 true
396 }
397 None => false,
398 }
399 }
400
401 pub fn gc_dead_rules(&mut self) -> usize {
403 let before = self.rules.len();
404 self.rules.retain(|r| !matches!(r.status, RuleStatus::Dead));
405 before - self.rules.len()
406 }
407
408 pub fn find_by_trigger(&self, trigger_pattern: &str) -> Vec<&Rule> {
410 self.rules
411 .iter()
412 .filter(|r| r.trigger.contains(trigger_pattern))
413 .collect()
414 }
415
416 pub fn get(&self, id: &str) -> Option<&Rule> {
418 self.rules.iter().find(|r| r.id == id)
419 }
420
421 pub fn get_mut(&mut self, id: &str) -> Option<&mut Rule> {
423 self.rules.iter_mut().find(|r| r.id == id)
424 }
425
426 pub fn stats(&self) -> StoreStats {
428 let mut stats = StoreStats::default();
429 for rule in &self.rules {
430 match rule.status {
431 RuleStatus::Proposed => stats.proposed += 1,
432 RuleStatus::Active => stats.active += 1,
433 RuleStatus::Dormant => stats.dormant += 1,
434 RuleStatus::Settled => stats.settled += 1,
435 RuleStatus::Dead => stats.dead += 1,
436 RuleStatus::Superseded => stats.superseded += 1,
437 }
438 }
439 stats.total = self.rules.len();
440 stats
441 }
442}
443
444#[derive(Debug, Clone, Default, Serialize, Deserialize)]
446pub struct StoreStats {
447 pub total: usize,
448 pub proposed: usize,
449 pub active: usize,
450 pub dormant: usize,
451 pub settled: usize,
452 pub dead: usize,
453 pub superseded: usize,
454}
455
456pub const DISALLOWED_TRIGGER_WORDS: &[&str] = &[
462 "alias",
463 "bg",
464 "bind",
465 "break",
466 "builtin",
467 "caller",
468 "case",
469 "cd",
470 "command",
471 "compgen",
472 "complete",
473 "compopt",
474 "continue",
475 "coproc",
476 "declare",
477 "dirs",
478 "disown",
479 "do",
480 "done",
481 "echo",
482 "elif",
483 "else",
484 "enable",
485 "esac",
486 "eval",
487 "exec",
488 "exit",
489 "export",
490 "fc",
491 "fg",
492 "fi",
493 "for",
494 "function",
495 "getopts",
496 "hash",
497 "help",
498 "history",
499 "if",
500 "in",
501 "jobs",
502 "kill",
503 "let",
504 "local",
505 "logout",
506 "mapfile",
507 "popd",
508 "printf",
509 "pushd",
510 "pwd",
511 "read",
512 "readarray",
513 "readonly",
514 "return",
515 "select",
516 "set",
517 "shift",
518 "shopt",
519 "source",
520 "suspend",
521 "test",
522 "then",
523 "time",
524 "times",
525 "trap",
526 "true",
527 "type",
528 "typeset",
529 "ulimit",
530 "umask",
531 "unalias",
532 "unset",
533 "until",
534 "wait",
535 "while",
536 "cat",
537 "sed",
538 "grep",
539 "head",
540 "tail",
541 "wc",
542 "find",
543 "ls",
544 "false",
545];
546
547pub fn is_var_assignment(token: &str) -> bool {
552 let Some(eq) = token.find('=') else {
553 return false;
554 };
555 let name = &token[..eq];
556 let name = name.strip_suffix('+').unwrap_or(name);
557 !name.is_empty()
558 && name
559 .chars()
560 .next()
561 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
562 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
563}
564
565pub fn split_command_segments(cmd: &str) -> Vec<&str> {
572 let mut segments = Vec::new();
573 let mut start = 0usize;
574 let mut in_single = false;
575 let mut in_double = false;
576 let mut escaped = false;
577 for (i, ch) in cmd.char_indices() {
578 if escaped {
579 escaped = false;
580 continue;
581 }
582 match ch {
583 '\\' if !in_single => escaped = true,
584 '\'' if !in_double => in_single = !in_single,
585 '"' if !in_single => in_double = !in_double,
586 ';' | '&' | '|' | '\n' if !in_single && !in_double => {
587 segments.push(&cmd[start..i]);
588 start = i + ch.len_utf8();
589 }
590 _ => {}
591 }
592 }
593 segments.push(&cmd[start..]);
594 segments
595}
596
597fn unquoted_words(segment: &str) -> Vec<String> {
606 let mut words = Vec::new();
607 let mut current = String::new();
608 let mut in_word = false;
609 let mut in_single = false;
610 let mut in_double = false;
611 let mut escaped = false;
612 for ch in segment.chars() {
613 if escaped {
614 if !in_double || matches!(ch, '\\' | '"' | '$' | '`') {
617 current.push(ch);
618 } else {
619 current.push('\\');
620 current.push(ch);
621 }
622 escaped = false;
623 continue;
624 }
625 match ch {
626 '\\' if !in_single => {
627 escaped = true;
628 in_word = true;
629 }
630 '\'' if !in_double => {
631 in_single = !in_single;
632 in_word = true;
633 }
634 '"' if !in_single => {
635 in_double = !in_double;
636 in_word = true;
637 }
638 c if c.is_whitespace() && !in_single && !in_double => {
639 if in_word {
640 words.push(std::mem::take(&mut current));
641 in_word = false;
642 }
643 }
644 c => {
645 current.push(c);
646 in_word = true;
647 }
648 }
649 }
650 if in_word {
651 words.push(current);
652 }
653 words
654}
655
656pub fn command_word(segment: &str) -> Option<String> {
664 let mut words = unquoted_words(segment);
665 while words.first().is_some_and(|w| is_var_assignment(w)) {
666 words.remove(0);
667 }
668 words.into_iter().next()
669}
670
671pub fn is_trackable_command(cmd: &str) -> bool {
679 let cmd = cmd.trim();
680 if cmd.is_empty() || cmd.contains([';', '|', '&', '\n']) {
681 return false;
682 }
683 match unquoted_words(cmd).first() {
684 Some(word) if !is_var_assignment(word) => {
685 !DISALLOWED_TRIGGER_WORDS.contains(&word.as_str())
686 }
687 _ => false,
688 }
689}
690
691pub fn is_disallowed_trigger(trigger: &str) -> bool {
697 let Some(cmd) = trigger.strip_prefix("command_failure:") else {
698 return false;
699 };
700 cmd.contains('=') || !is_trackable_command(cmd)
701}
702
703fn new_rule_id() -> String {
704 format!("rule_{}", ulid::Ulid::new().to_string().to_lowercase())
705}
706
707fn now_rfc3339() -> String {
708 let now = OffsetDateTime::now_utc();
709 now.format(&time::format_description::well_known::Rfc3339)
710 .expect("RFC3339 formatting should not fail")
711}
712
713fn parse_rfc3339(s: &str) -> Option<OffsetDateTime> {
714 OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
715}
716
717fn file_sha256(path: &str) -> Option<String> {
719 let data = fs::read(path).ok()?;
720 let hash = Sha256::digest(&data);
721 Some(hex::encode(hash))
722}
723
724#[path = "rules_tests.rs"]
725#[cfg(test)]
726mod tests;