Skip to main content

remem/rules/
artifact.rs

1use anyhow::{bail, Result};
2use serde::{Deserialize, Serialize};
3
4pub const ARTIFACT_VERSION: u32 = 2;
5pub const LEGACY_ARTIFACT_VERSION: u32 = 1;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]
9pub struct CompiledRulesArtifact {
10    pub version: u32,
11    pub compiled_at_epoch: i64,
12    pub rules: Vec<CompiledRule>,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct CompiledRule {
18    pub rule_id: String,
19    pub source_memory_id: i64,
20    pub reinforcement_count: i64,
21    pub action: RuleAction,
22    pub override_state: RuleOverrideState,
23    pub predicate: RulePredicate,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum RuleAction {
29    Warn,
30    Block,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct RuleOverrideState {
36    pub disabled: bool,
37    pub action_override: Option<RuleAction>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
42pub enum RulePredicate {
43    CommandRegex { pattern: String, message: String },
44    CommitTrailerForbidden { trailer: String, message: String },
45    GitPushForceForbidden { message: String },
46}
47
48impl CompiledRulesArtifact {
49    pub fn new(compiled_at_epoch: i64, rules: Vec<CompiledRule>) -> Self {
50        Self {
51            version: ARTIFACT_VERSION,
52            compiled_at_epoch,
53            rules,
54        }
55    }
56
57    pub fn validate(&self) -> Result<()> {
58        if !matches!(self.version, LEGACY_ARTIFACT_VERSION | ARTIFACT_VERSION) {
59            bail!(
60                "unsupported compiled rule artifact version {}; expected {} or {}",
61                self.version,
62                LEGACY_ARTIFACT_VERSION,
63                ARTIFACT_VERSION
64            );
65        }
66        if self.compiled_at_epoch < 0 {
67            bail!("compiled rule artifact has negative compiled_at_epoch");
68        }
69        for rule in &self.rules {
70            rule.validate(self.version)?;
71        }
72        Ok(())
73    }
74}
75
76impl CompiledRule {
77    pub fn effective_action(&self) -> RuleAction {
78        self.override_state.action_override.unwrap_or(self.action)
79    }
80
81    fn validate(&self, artifact_version: u32) -> Result<()> {
82        if self.rule_id.trim().is_empty() {
83            bail!("compiled rule has empty rule_id");
84        }
85        if self.source_memory_id <= 0 {
86            bail!(
87                "compiled rule {} has invalid source_memory_id",
88                self.rule_id
89            );
90        }
91        if self.reinforcement_count < 1 {
92            bail!(
93                "compiled rule {} has invalid reinforcement_count {}",
94                self.rule_id,
95                self.reinforcement_count
96            );
97        }
98        self.predicate.validate(&self.rule_id, artifact_version)?;
99        Ok(())
100    }
101}
102
103impl RulePredicate {
104    pub fn message(&self) -> &str {
105        match self {
106            RulePredicate::CommandRegex { message, .. }
107            | RulePredicate::CommitTrailerForbidden { message, .. }
108            | RulePredicate::GitPushForceForbidden { message } => message,
109        }
110    }
111
112    fn validate(&self, rule_id: &str, artifact_version: u32) -> Result<()> {
113        match self {
114            RulePredicate::CommandRegex { pattern, message } => {
115                if pattern.trim().is_empty() {
116                    bail!("compiled rule {rule_id} has empty command_regex pattern");
117                }
118                let regex_error = if artifact_version == LEGACY_ARTIFACT_VERSION {
119                    regex::Regex::new(pattern)
120                        .err()
121                        .map(|error| error.to_string())
122                } else {
123                    regex_lite::Regex::new(pattern)
124                        .err()
125                        .map(|error| error.to_string())
126                };
127                if let Some(error) = regex_error {
128                    bail!("compiled rule {rule_id} has invalid command_regex pattern: {error}");
129                }
130                if message.trim().is_empty() {
131                    bail!("compiled rule {rule_id} has empty command_regex message");
132                }
133            }
134            RulePredicate::CommitTrailerForbidden { trailer, message } => {
135                if trailer.trim().is_empty() {
136                    bail!("compiled rule {rule_id} has empty forbidden trailer");
137                }
138                if message.trim().is_empty() {
139                    bail!("compiled rule {rule_id} has empty forbidden trailer message");
140                }
141            }
142            RulePredicate::GitPushForceForbidden { message } => {
143                if artifact_version == LEGACY_ARTIFACT_VERSION {
144                    bail!("compiled rule {rule_id} uses git_push_force_forbidden in a v1 artifact");
145                }
146                if message.trim().is_empty() {
147                    bail!("compiled rule {rule_id} has empty forbidden force-push message");
148                }
149            }
150        }
151        Ok(())
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::rules::test_support::package_manager_rule;
159
160    #[test]
161    fn artifact_schema_round_trips_versioned_rules() -> Result<()> {
162        let artifact =
163            CompiledRulesArtifact::new(1234, vec![package_manager_rule(RuleAction::Warn)]);
164
165        let text = serde_json::to_string_pretty(&artifact)?;
166        let parsed: CompiledRulesArtifact = serde_json::from_str(&text)?;
167
168        assert_eq!(parsed.version, ARTIFACT_VERSION);
169        assert_eq!(parsed.compiled_at_epoch, 1234);
170        assert_eq!(parsed.rules[0].rule_id, "pref-123-1");
171        assert_eq!(parsed.rules[0].source_memory_id, 123);
172        assert_eq!(parsed.rules[0].reinforcement_count, 3);
173        assert_eq!(parsed.rules[0].action, RuleAction::Warn);
174        assert_eq!(
175            parsed.rules[0].predicate,
176            RulePredicate::CommandRegex {
177                pattern: r"(^|[ \t\r\n])npm[ \t\r\n]+(install|i|add)([ \t\r\n;&|)<>]|$)"
178                    .to_string(),
179                message: "Command violates a compiled package-manager preference".to_string()
180            }
181        );
182        parsed.validate()?;
183        Ok(())
184    }
185
186    #[test]
187    fn artifact_rejects_unsupported_predicate_kind() {
188        let text = r#"{
189          "version": 1,
190          "compiled_at_epoch": 1234,
191          "rules": [{
192            "rule_id": "pref-123-1",
193            "source_memory_id": 123,
194            "reinforcement_count": 3,
195            "action": "warn",
196            "override_state": {"disabled": false, "action_override": null},
197            "predicate": {"kind": "javascript", "source": "return true"}
198          }]
199        }"#;
200
201        let err = serde_json::from_str::<CompiledRulesArtifact>(text)
202            .expect_err("unsupported predicate kind should fail closed at parse time");
203        assert!(
204            err.to_string().contains("unknown variant"),
205            "unexpected error: {err}"
206        );
207    }
208
209    #[test]
210    fn artifact_validation_rejects_invalid_command_regex() {
211        let mut rule = package_manager_rule(RuleAction::Warn);
212        rule.predicate = RulePredicate::CommandRegex {
213            pattern: "(".to_string(),
214            message: "invalid regex fixture".to_string(),
215        };
216        let artifact = CompiledRulesArtifact::new(1234, vec![rule]);
217
218        let error = artifact
219            .validate()
220            .expect_err("invalid command regex must fail artifact validation");
221
222        assert!(error.to_string().contains("invalid command_regex pattern"));
223    }
224
225    #[test]
226    fn legacy_artifact_keeps_unicode_regex_validation() -> Result<()> {
227        let mut artifact =
228            CompiledRulesArtifact::new(1234, vec![package_manager_rule(RuleAction::Warn)]);
229        artifact.version = LEGACY_ARTIFACT_VERSION;
230        artifact.rules[0].predicate = RulePredicate::CommandRegex {
231            pattern: r"\p{Greek}+".to_string(),
232            message: "legacy unicode fixture".to_string(),
233        };
234
235        artifact.validate()?;
236        Ok(())
237    }
238
239    #[test]
240    fn force_push_predicate_requires_v2_and_round_trips() -> Result<()> {
241        let mut artifact =
242            CompiledRulesArtifact::new(1234, vec![package_manager_rule(RuleAction::Warn)]);
243        artifact.rules[0].predicate = RulePredicate::GitPushForceForbidden {
244            message: "Do not force push".to_string(),
245        };
246
247        artifact.validate()?;
248        let encoded = serde_json::to_string(&artifact)?;
249        let parsed: CompiledRulesArtifact = serde_json::from_str(&encoded)?;
250        assert_eq!(parsed, artifact);
251
252        artifact.version = LEGACY_ARTIFACT_VERSION;
253        let error = artifact
254            .validate()
255            .expect_err("v1 artifact must reject the v2-only predicate");
256        assert!(error.to_string().contains("in a v1 artifact"));
257        Ok(())
258    }
259}