1use std::collections::{BTreeMap, BTreeSet};
2
3use super::model::{
4 Action, CommandMatcher, LinePattern, OutputPolicy, PackManifest, Reducer, Rule,
5 SOURCE_SCHEMA_VERSION,
6};
7
8pub const MAX_RULES: usize = 10_000;
9pub const MAX_SOURCE_FILE_BYTES: usize = 64 * 1024;
10pub const MAX_SOURCE_BYTES: usize = 32 * 1024 * 1024;
11pub const MAX_COMPILED_BYTES: u64 = 64 * 1024 * 1024;
12pub const MAX_STREAM_MEMORY_BYTES: usize = 4 * 1024 * 1024;
13
14pub fn validate_manifest(manifest: &PackManifest) -> Result<(), String> {
20 if manifest.schema_version != SOURCE_SCHEMA_VERSION {
21 return Err(format!(
22 "schema_version must be {SOURCE_SCHEMA_VERSION}, got {}",
23 manifest.schema_version
24 ));
25 }
26 validate_pack_id(&manifest.id)?;
27 if manifest.rules.is_empty() || manifest.rules.len() > MAX_RULES {
28 return Err(format!(
29 "rules must contain between 1 and {MAX_RULES} paths"
30 ));
31 }
32 let mut paths = BTreeSet::new();
33 for path in &manifest.rules {
34 validate_rule_path(path)?;
35 if !paths.insert(path) {
36 return Err(format!("duplicate rule path: {path}"));
37 }
38 }
39 Ok(())
40}
41
42pub fn validate_rules(rules: &[Rule]) -> Result<(), String> {
48 if rules.is_empty() || rules.len() > MAX_RULES {
49 return Err(format!("pack must contain between 1 and {MAX_RULES} rules"));
50 }
51 let mut ids = BTreeSet::new();
52 let mut exact_matchers = BTreeMap::<String, &str>::new();
53 for rule in rules {
54 validate_rule(rule)?;
55 if !ids.insert(rule.id.as_str()) {
56 return Err(format!("duplicate rule id: {}", rule.id));
57 }
58 let key = matcher_key(&rule.matcher);
59 if let Some(existing) = exact_matchers.insert(key, &rule.id) {
60 return Err(format!(
61 "rules {existing} and {} have the same command matcher",
62 rule.id
63 ));
64 }
65 }
66 reject_known_reduction_overlaps(rules)
67}
68
69pub fn validate_rule(rule: &Rule) -> Result<(), String> {
75 validate_rule_id(&rule.id)?;
76 validate_matcher(&rule.matcher)?;
77 match rule.action {
78 Action::Passthrough => {
79 if rule.transform.is_some()
80 || rule.reducer.is_some()
81 || rule.success.is_some()
82 || rule.failure.is_some()
83 {
84 return Err(format!(
85 "passthrough rule {} must not define transform, reducer, or output policies",
86 rule.id
87 ));
88 }
89 }
90 Action::Transform => {
91 if rule.transform.is_none() {
92 return Err(format!("transform rule {} is missing transform", rule.id));
93 }
94 if rule.reducer.is_some() || rule.success.is_some() || rule.failure.is_some() {
95 return Err(format!(
96 "transform rule {} must not define reducer or output policies",
97 rule.id
98 ));
99 }
100 }
101 Action::Reduce => {
102 if rule.transform.is_some() {
103 return Err(format!("reduce rule {} must not define transform", rule.id));
104 }
105 let reducer = rule
106 .reducer
107 .as_ref()
108 .ok_or_else(|| format!("reduce rule {} is missing reducer", rule.id))?;
109 let success = rule
110 .success
111 .as_ref()
112 .ok_or_else(|| format!("reduce rule {} is missing success policy", rule.id))?;
113 let failure = rule
114 .failure
115 .as_ref()
116 .ok_or_else(|| format!("reduce rule {} is missing failure policy", rule.id))?;
117 validate_reducer(reducer)?;
118 validate_policy("success", success)?;
119 validate_policy("failure", failure)?;
120 let memory_bound = stream_memory_bound_parts(reducer, success, failure)?;
121 if memory_bound > MAX_STREAM_MEMORY_BYTES {
122 return Err(format!(
123 "reduce rule {} requires {memory_bound} bytes per stream, above the {MAX_STREAM_MEMORY_BYTES}-byte limit",
124 rule.id
125 ));
126 }
127 }
128 }
129 Ok(())
130}
131
132fn validate_pack_id(id: &str) -> Result<(), String> {
133 validate_identifier(id, 128, false, "pack id")
134}
135
136fn validate_rule_id(id: &str) -> Result<(), String> {
137 validate_identifier(id, 128, true, "rule id")
138}
139
140fn validate_identifier(id: &str, max: usize, slash: bool, label: &str) -> Result<(), String> {
141 if id.is_empty() || id.len() > max || !id.is_ascii() {
142 return Err(format!("{label} must contain 1 through {max} ASCII bytes"));
143 }
144 let valid = |byte: u8| {
145 byte.is_ascii_lowercase()
146 || byte.is_ascii_digit()
147 || matches!(byte, b'.' | b'_' | b'-')
148 || (slash && byte == b'/')
149 };
150 if !id.bytes().all(valid) || !id.as_bytes()[0].is_ascii_alphanumeric() {
151 return Err(format!("invalid {label}: {id}"));
152 }
153 if !id.as_bytes()[id.len() - 1].is_ascii_alphanumeric() {
154 return Err(format!("invalid {label}: {id}"));
155 }
156 if id
157 .as_bytes()
158 .windows(2)
159 .any(|pair| is_separator(pair[0], slash) && is_separator(pair[1], slash))
160 {
161 return Err(format!("{label} has consecutive separators: {id}"));
162 }
163 Ok(())
164}
165
166const fn is_separator(byte: u8, slash: bool) -> bool {
167 matches!(byte, b'.' | b'_' | b'-') || (slash && byte == b'/')
168}
169
170#[allow(
172 clippy::case_sensitive_file_extension_comparisons,
173 reason = "source rule paths require the exact lowercase .json suffix"
174)]
175fn validate_rule_path(path: &str) -> Result<(), String> {
176 if path.is_empty()
177 || path.starts_with('/')
178 || path.contains('\\')
179 || path.contains('\0')
180 || !path.ends_with(".json")
181 {
182 return Err(format!("invalid rule path: {path}"));
183 }
184 for component in path.split('/') {
185 if component.is_empty() || matches!(component, "." | "..") {
186 return Err(format!("invalid rule path: {path}"));
187 }
188 }
189 Ok(())
190}
191
192fn validate_matcher(matcher: &CommandMatcher) -> Result<(), String> {
193 if matcher.program.is_empty() || matcher.program.len() > 32 {
194 return Err("program must contain between 1 and 32 names".to_owned());
195 }
196 let mut programs = BTreeSet::new();
197 for program in &matcher.program {
198 if program.is_empty()
199 || program.len() > 128
200 || !program.is_ascii()
201 || program.bytes().any(|byte| {
202 byte.is_ascii_whitespace()
203 || matches!(
204 byte,
205 b'/' | b'\\'
206 | 0
207 | b'|'
208 | b'&'
209 | b';'
210 | b'<'
211 | b'>'
212 | b'('
213 | b')'
214 | b'`'
215 | b'$'
216 | b'#'
217 )
218 })
219 {
220 return Err(format!("invalid program name: {program}"));
221 }
222 if !programs.insert(program) {
223 return Err(format!("duplicate program name: {program}"));
224 }
225 }
226 validate_tokens("argv_prefix", &matcher.argv_prefix)?;
227 validate_tokens("argv_contains_all", &matcher.argv_contains_all)
228}
229
230fn validate_tokens(label: &str, tokens: &[String]) -> Result<(), String> {
231 if tokens.len() > 64 {
232 return Err(format!("{label} must not contain more than 64 tokens"));
233 }
234 let mut unique = BTreeSet::new();
235 for token in tokens {
236 if token.chars().count() > 1_024 || token.contains('\0') {
237 return Err(format!("invalid {label} token"));
238 }
239 if !unique.insert(token) {
240 return Err(format!("duplicate {label} token: {token}"));
241 }
242 }
243 Ok(())
244}
245
246fn validate_reducer(reducer: &Reducer) -> Result<(), String> {
247 if let Reducer::LineFilter {
248 strip_ansi,
249 drop,
250 keep,
251 } = reducer
252 {
253 if drop.len().saturating_add(keep.len()) > 256 {
254 return Err("line_filter must not contain more than 256 patterns".to_owned());
255 }
256 if !strip_ansi && drop.is_empty() && keep.is_empty() {
257 return Err("line_filter must strip ANSI or define a line pattern".to_owned());
258 }
259 let mut patterns = BTreeSet::new();
260 for pattern in drop.iter().chain(keep) {
261 validate_pattern(pattern)?;
262 let key = serde_jcs::to_string(pattern)
263 .map_err(|error| format!("could not canonicalize line pattern: {error}"))?;
264 if !patterns.insert(key) {
265 return Err("duplicate line pattern".to_owned());
266 }
267 }
268 }
269 Ok(())
270}
271
272fn validate_pattern(pattern: &LinePattern) -> Result<(), String> {
273 let bytes = pattern.value.as_bytes();
274 if bytes.is_empty() || bytes.len() > 4_096 || bytes.contains(&0) {
275 return Err("line pattern value must contain 1 through 4096 non-NUL bytes".to_owned());
276 }
277 Ok(())
278}
279
280pub fn stream_memory_bound(rule: &Rule) -> Result<usize, String> {
286 let reducer = rule
287 .reducer
288 .as_ref()
289 .ok_or_else(|| "reduction rule is missing a reducer".to_owned())?;
290 let success = rule
291 .success
292 .as_ref()
293 .ok_or_else(|| "reduction rule is missing a success policy".to_owned())?;
294 let failure = rule
295 .failure
296 .as_ref()
297 .ok_or_else(|| "reduction rule is missing a failure policy".to_owned())?;
298 stream_memory_bound_parts(reducer, success, failure)
299}
300
301fn stream_memory_bound_parts(
302 reducer: &Reducer,
303 success: &OutputPolicy,
304 failure: &OutputPolicy,
305) -> Result<usize, String> {
306 let raw = success.max_output_bytes.max(failure.max_output_bytes);
307 let summaries = success
308 .max_output_bytes
309 .checked_add(failure.max_output_bytes)
310 .and_then(|value| value.checked_mul(5))
311 .ok_or_else(|| "summary memory bound overflowed".to_owned())?;
312 let max_line = success.max_line_bytes.max(failure.max_line_bytes);
313 let line_state = max_line
314 .checked_mul(2)
315 .ok_or_else(|| "line memory bound overflowed".to_owned())?;
316 let pattern_state = match reducer {
317 Reducer::LineFilter { drop, keep, .. } => {
318 drop.iter()
319 .chain(keep)
320 .try_fold(0_usize, |total, pattern| {
321 total
322 .checked_add(pattern.value.len())
323 .ok_or_else(|| "line pattern memory bound overflowed".to_owned())
324 })?
325 }
326 _ => 0,
327 };
328 let record_overhead = 2_usize
331 .checked_mul(5)
332 .and_then(|value| value.checked_mul(128))
333 .and_then(|value| value.checked_mul(64))
334 .ok_or_else(|| "record memory bound overflowed".to_owned())?;
335 let diagnostic_representatives = 2_usize
336 .checked_mul(5)
337 .and_then(|value| value.checked_mul(max_line.saturating_add(64)))
338 .ok_or_else(|| "diagnostic representative memory bound overflowed".to_owned())?;
339 let fingerprint_overhead = 2_usize
340 .checked_mul(5)
341 .and_then(|value| value.checked_mul(128))
342 .and_then(|value| value.checked_mul(96))
343 .ok_or_else(|| "fingerprint memory bound overflowed".to_owned())?;
344 let diversity_key_overhead = 2_usize
345 .checked_mul(128)
346 .and_then(|value| value.checked_mul(224))
347 .ok_or_else(|| "diversity key memory bound overflowed".to_owned())?;
348 [
349 raw,
350 summaries,
351 line_state,
352 pattern_state,
353 record_overhead,
354 diagnostic_representatives,
355 fingerprint_overhead,
356 diversity_key_overhead,
357 8 * 1024,
358 64 * 1024,
359 ]
360 .into_iter()
361 .try_fold(0_usize, |total, value| {
362 total
363 .checked_add(value)
364 .ok_or_else(|| "stream memory bound overflowed".to_owned())
365 })
366}
367
368fn validate_policy(label: &str, policy: &OutputPolicy) -> Result<(), String> {
369 if !(1..=1_048_576).contains(&policy.max_line_bytes) {
370 return Err(format!(
371 "{label}.max_line_bytes must be between 1 and 1048576"
372 ));
373 }
374 if !(704..=4_194_304).contains(&policy.max_output_bytes) {
375 return Err(format!(
376 "{label}.max_output_bytes must be between 704 and 4194304"
377 ));
378 }
379 if policy.min_savings_bytes > 1_048_576 {
380 return Err(format!("{label}.min_savings_bytes must not exceed 1048576"));
381 }
382 if policy.min_savings_basis_points > 10_000 {
383 return Err(format!(
384 "{label}.min_savings_basis_points must not exceed 10000"
385 ));
386 }
387 Ok(())
388}
389
390fn matcher_key(matcher: &CommandMatcher) -> String {
391 serde_jcs::to_string(matcher).unwrap_or_default()
392}
393
394fn reject_known_reduction_overlaps(rules: &[Rule]) -> Result<(), String> {
395 for (index, left) in rules.iter().enumerate() {
396 if left.action == Action::Passthrough {
397 continue;
398 }
399 for right in &rules[index + 1..] {
400 if right.action == Action::Passthrough {
401 continue;
402 }
403 if matchers_may_overlap(&left.matcher, &right.matcher) {
404 return Err(format!(
405 "reduction rules {} and {} can match the same command",
406 left.id, right.id
407 ));
408 }
409 }
410 }
411 Ok(())
412}
413
414fn matchers_may_overlap(left: &CommandMatcher, right: &CommandMatcher) -> bool {
415 let program_overlap = left
416 .program
417 .iter()
418 .any(|program| right.program.contains(program));
419 program_overlap
420 && (left.argv_prefix.starts_with(&right.argv_prefix)
421 || right.argv_prefix.starts_with(&left.argv_prefix))
422}
423
424#[cfg(test)]
425mod tests {
426 use super::super::model::{PatternCase, PatternKind, PatternTrim, Transform};
427 use super::*;
428
429 fn policy() -> OutputPolicy {
430 OutputPolicy {
431 max_line_bytes: 16_384,
432 max_output_bytes: 32_768,
433 min_savings_bytes: 120,
434 min_savings_basis_points: 1_000,
435 }
436 }
437
438 fn rule(id: &str, prefix: &[&str]) -> Rule {
439 Rule {
440 id: id.to_owned(),
441 matcher: CommandMatcher {
442 program: vec!["tool".to_owned()],
443 argv_prefix: prefix.iter().map(ToString::to_string).collect(),
444 argv_contains_all: Vec::new(),
445 },
446 action: Action::Reduce,
447 transform: None,
448 reducer: Some(Reducer::ListSummary),
449 success: Some(policy()),
450 failure: Some(policy()),
451 }
452 }
453
454 #[test]
455 fn rejects_overlapping_reduction_rules() {
456 let error = validate_rules(&[rule("one", &["test"]), rule("two", &["test", "unit"])])
457 .expect_err("overlap");
458 assert!(error.contains("same command"));
459 }
460
461 #[test]
462 fn accepts_disjoint_prefixes_and_passthrough_guards() {
463 let mut guard = rule("guard", &["test"]);
464 guard.action = Action::Passthrough;
465 guard.matcher.argv_contains_all = vec!["--json".to_owned()];
466 guard.reducer = None;
467 guard.transform = None;
468 guard.success = None;
469 guard.failure = None;
470 validate_rules(&[guard, rule("build", &["build"]), rule("test", &["test"])])
471 .expect("valid rules");
472 }
473
474 #[test]
475 fn validates_transform_rule_contracts() {
476 let mut transform = rule("transform", &[]);
477 transform.action = Action::Transform;
478 transform.transform = Some(Transform::LinePreserving);
479 transform.reducer = None;
480 transform.success = None;
481 transform.failure = None;
482 validate_rule(&transform).expect("valid transform");
483
484 transform.reducer = Some(Reducer::ListSummary);
485 let error = validate_rule(&transform).expect_err("transform reducer conflict");
486 assert!(error.contains("must not define reducer"));
487 }
488
489 #[test]
490 fn rejects_policies_that_cannot_fit_mandatory_evidence() {
491 let mut undersized = rule("undersized", &["run"]);
492 undersized
493 .success
494 .as_mut()
495 .expect("success")
496 .max_output_bytes = 703;
497 let error = validate_rule(&undersized).expect_err("mandatory evidence budget");
498 assert!(error.contains("between 704"));
499 }
500
501 #[test]
502 fn rejects_rules_above_the_aggregate_memory_limit() {
503 let mut oversized = rule("oversized", &["run"]);
504 let policy = oversized.success.as_mut().expect("success policy");
505 policy.max_output_bytes = 4_194_304;
506 policy.max_line_bytes = 1_048_576;
507 oversized.failure = oversized.success;
508 let error = validate_rule(&oversized).expect_err("memory limit");
509 assert!(error.contains("per stream"));
510 }
511
512 #[test]
513 fn validates_line_patterns() {
514 let pattern = LinePattern {
515 kind: PatternKind::Prefix,
516 value: "Compiling ".to_owned(),
517 case: PatternCase::Sensitive,
518 trim: PatternTrim::Start,
519 };
520 validate_pattern(&pattern).expect("valid pattern");
521 }
522}