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}