Skip to main content

yarp_cli/rule_pack/
validation.rs

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
14/// Validate source manifest fields and explicit rule paths.
15///
16/// # Errors
17///
18/// Returns an error when a version, ID, path, count, or uniqueness constraint fails.
19pub 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
42/// Validate all rules and reject duplicate IDs and known reduction overlaps.
43///
44/// # Errors
45///
46/// Returns an error when a rule is invalid or conflicts with another rule.
47pub 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
69/// Validate one declarative command rule.
70///
71/// # Errors
72///
73/// Returns an error when matching, action, reducer, pattern, or budget constraints fail.
74pub 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// Source rule paths require the exact lowercase .json suffix.
171#[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
280/// Calculate the conservative retained-memory bound for one rule stream.
281///
282/// # Errors
283///
284/// Returns an error when the rule is incomplete or the calculation overflows `usize`.
285pub 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    // Two collectors retain at most 128 records in each of five evidence classes, plus one
329    // source-line representative for each registered diagnostic category.
330    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}