| 1 | //! Pattern rules: whether a commit message, an email address, or a branch |
| 2 | //! or tag name is as a rule asks. |
| 3 | //! |
| 4 | //! Regular expressions run on the `regex` crate's engine, which takes time |
| 5 | //! in proportion to the text and never backtracks, so no pattern can make a |
| 6 | //! push slow. Look-around and back-references are not supported, and a |
| 7 | //! pattern that would compile to more than [`SIZE_LIMIT`] is refused. |
| 8 | |
| 9 | use g1t_contracts::rules::{PatternOperator, PatternRule}; |
| 10 | use regex::{Regex, RegexBuilder}; |
| 11 | |
| 12 | /// The most memory one compiled pattern may take. |
| 13 | pub const SIZE_LIMIT: usize = 1 << 20; |
| 14 | /// The most of a text a pattern is tested on. |
| 15 | pub const MAX_TEXT: usize = 64 * 1024; |
| 16 | |
| 17 | /// A pattern rule, ready to test texts. |
| 18 | pub struct Compiled { |
| 19 | rule: PatternRule, |
| 20 | regex: Option<Regex>, |
| 21 | } |
| 22 | |
| 23 | /// Compiles a rule's pattern, or says why it cannot be. |
| 24 | pub fn compile(rule: &PatternRule) -> Result<Compiled, String> { |
| 25 | let regex = match rule.operator { |
| 26 | PatternOperator::Regex => Some( |
| 27 | RegexBuilder::new(&rule.pattern) |
| 28 | .size_limit(SIZE_LIMIT) |
| 29 | .dfa_size_limit(SIZE_LIMIT) |
| 30 | .build() |
| 31 | .map_err(|error| match error { |
| 32 | regex::Error::CompiledTooBig(_) => "The regular expression is too large.".to_owned(), |
| 33 | other => format!("The regular expression is not valid: {}", first_line(&other.to_string())), |
| 34 | })?, |
| 35 | ), |
| 36 | _ => None, |
| 37 | }; |
| 38 | Ok(Compiled { rule: rule.clone(), regex }) |
| 39 | } |
| 40 | |
| 41 | fn first_line(text: &str) -> String { |
| 42 | text.lines().last().unwrap_or(text).trim().trim_start_matches("error: ").to_owned() |
| 43 | } |
| 44 | |
| 45 | impl Compiled { |
| 46 | /// Whether `text` is as the rule asks: it matches, or with `negate` |
| 47 | /// does not. |
| 48 | pub fn allows(&self, text: &str) -> bool { |
| 49 | let text = cut(text); |
| 50 | let pattern = self.rule.pattern.as_str(); |
| 51 | let found = match self.rule.operator { |
| 52 | PatternOperator::StartsWith => text.starts_with(pattern), |
| 53 | PatternOperator::EndsWith => text.ends_with(pattern), |
| 54 | PatternOperator::Contains => text.contains(pattern), |
| 55 | PatternOperator::Regex => self.regex.as_ref().is_some_and(|regex| regex.is_match(text)), |
| 56 | }; |
| 57 | found != self.rule.negate |
| 58 | } |
| 59 | |
| 60 | /// What the rule asks, in words: `start with "feat: "`, `match /^v\d/`. |
| 61 | pub fn wants(&self) -> String { |
| 62 | describe(&self.rule) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | /// What a pattern rule asks, in words. |
| 67 | pub fn describe(rule: &PatternRule) -> String { |
| 68 | let not = if rule.negate { "not " } else { "" }; |
| 69 | let pattern = &rule.pattern; |
| 70 | let what = match rule.operator { |
| 71 | PatternOperator::StartsWith => format!("{not}start with \"{pattern}\""), |
| 72 | PatternOperator::EndsWith => format!("{not}end with \"{pattern}\""), |
| 73 | PatternOperator::Contains => format!("{not}contain \"{pattern}\""), |
| 74 | PatternOperator::Regex => format!("{not}match /{pattern}/"), |
| 75 | }; |
| 76 | if rule.name.trim().is_empty() { |
| 77 | what |
| 78 | } else { |
| 79 | format!("{what} ({})", rule.name.trim()) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | /// At most [`MAX_TEXT`] bytes of `text`, cut on a character boundary. |
| 84 | fn cut(text: &str) -> &str { |
| 85 | if text.len() <= MAX_TEXT { |
| 86 | return text; |
| 87 | } |
| 88 | let mut end = MAX_TEXT; |
| 89 | while !text.is_char_boundary(end) { |
| 90 | end -= 1; |
| 91 | } |
| 92 | &text[..end] |
| 93 | } |
| 94 | |
| 95 | #[cfg(test)] |
| 96 | mod tests { |
| 97 | use super::*; |
| 98 | |
| 99 | fn rule(operator: PatternOperator, pattern: &str, negate: bool) -> PatternRule { |
| 100 | PatternRule { name: String::new(), operator, pattern: pattern.to_owned(), negate } |
| 101 | } |
| 102 | |
| 103 | #[test] |
| 104 | fn each_operator_compares_as_it_says() { |
| 105 | let starts = compile(&rule(PatternOperator::StartsWith, "feat", false)).unwrap(); |
| 106 | assert!(starts.allows("feat: add rules")); |
| 107 | assert!(!starts.allows("fix: rules")); |
| 108 | let ends = compile(&rule(PatternOperator::EndsWith, "@acme.com", false)).unwrap(); |
| 109 | assert!(ends.allows("ada@acme.com")); |
| 110 | assert!(!ends.allows("ada@example.com")); |
| 111 | let contains = compile(&rule(PatternOperator::Contains, "WIP", true)).unwrap(); |
| 112 | assert!(contains.allows("Finish rules")); |
| 113 | assert!(!contains.allows("WIP: rules")); |
| 114 | let regex = compile(&rule(PatternOperator::Regex, r"^(feat|fix)(\(.+\))?: ", false)).unwrap(); |
| 115 | assert!(regex.allows("fix(api): snake case")); |
| 116 | assert!(!regex.allows("Update things")); |
| 117 | } |
| 118 | |
| 119 | #[test] |
| 120 | fn a_bad_or_huge_expression_is_refused() { |
| 121 | assert!(compile(&rule(PatternOperator::Regex, "(unclosed", false)).is_err()); |
| 122 | // Back-references need backtracking, which the engine never does. |
| 123 | assert!(compile(&rule(PatternOperator::Regex, r"(a)\1", false)).is_err()); |
| 124 | let huge = compile(&rule(PatternOperator::Regex, r"\w{1000}\w{1000}\w{1000}", false)); |
| 125 | assert_eq!(huge.err().as_deref(), Some("The regular expression is too large.")); |
| 126 | } |
| 127 | |
| 128 | #[test] |
| 129 | fn a_pathological_pattern_stays_linear() { |
| 130 | let compiled = compile(&rule(PatternOperator::Regex, "(a+)+$", false)).unwrap(); |
| 131 | let text = format!("{}!", "a".repeat(50_000)); |
| 132 | assert!(!compiled.allows(&text)); |
| 133 | } |
| 134 | |
| 135 | #[test] |
| 136 | fn rules_are_described_in_words() { |
| 137 | assert_eq!(describe(&rule(PatternOperator::StartsWith, "feat", false)), "start with \"feat\""); |
| 138 | assert_eq!(describe(&rule(PatternOperator::Regex, "^v", true)), "not match /^v/"); |
| 139 | let named = PatternRule { name: "Conventional commits".into(), ..rule(PatternOperator::Contains, ":", false) }; |
| 140 | assert_eq!(describe(&named), "contain \":\" (Conventional commits)"); |
| 141 | } |
| 142 | } |