Skip to content

g1t/crates/rules/src/content.rs

335 lines15,471 bytesCodeBlame
1//! Rules about what commits bring: their messages and addresses, their
2//! signatures and parents, and the files they change. The same checks hold
3//! for a push and for the commits a pull request lands.
4
5use g1t_contracts::rules::{CommitFacts, PatternRule, Rule, Signature};
6
7use crate::{glob, text};
8
9/// One problem a rule found, and how to fix it.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct Problem {
12 pub message: String,
13 pub remedy: String,
14}
15
16impl Problem {
17 pub fn new(message: impl Into<String>, remedy: impl Into<String>) -> Problem {
18 Problem { message: message.into(), remedy: remedy.into() }
19 }
20}
21
22/// The most problems one rule reports; the rest are counted.
23const MAX_PROBLEMS: usize = 5;
24
25fn short(sha: &str) -> &str {
26 &sha[..sha.len().min(7)]
27}
28
29/// Whether a rule is about what commits bring, and so needs them read.
30pub fn about_content(rule: &Rule) -> bool {
31 matches!(
32 rule,
33 Rule::RequiredLinearHistory(_)
34 | Rule::RequiredSignatures(_)
35 | Rule::CommitMessagePattern(_)
36 | Rule::CommitAuthorEmailPattern(_)
37 | Rule::CommitterEmailPattern(_)
38 | Rule::FilePathRestriction(_)
39 | Rule::FileExtensionRestriction(_)
40 | Rule::MaxFileSize(_)
41 | Rule::MaxFilePathLength(_)
42 | Rule::MaxFilesChanged(_)
43 | Rule::SecretScanning(_)
44 )
45}
46
47fn capped(mut problems: Vec<Problem>) -> Vec<Problem> {
48 if problems.len() > MAX_PROBLEMS {
49 let more = problems.len() - (MAX_PROBLEMS - 1);
50 problems.truncate(MAX_PROBLEMS - 1);
51 let remedy = problems[0].remedy.clone();
52 problems.push(Problem::new(format!("…and {more} more like it."), remedy));
53 }
54 problems
55}
56
57fn pattern_problems(
58 rule: &PatternRule,
59 commits: &[CommitFacts],
60 what: &str,
61 read: impl Fn(&CommitFacts) -> Option<&str>,
62) -> Vec<Problem> {
63 let compiled = match text::compile(rule) {
64 Ok(compiled) => compiled,
65 // Saved rules compile; one that no longer does refuses nothing.
66 Err(_) => return Vec::new(),
67 };
68 let remedy = format!("Rewrite the commits so each {what} {}, then push again.", compiled.wants());
69 commits
70 .iter()
71 .filter(|commit| !compiled.allows(read(commit).unwrap_or_default()))
72 .map(|commit| Problem::new(format!("Commit {} has a {what} that does not {}.", short(&commit.sha), compiled.wants()), remedy.clone()))
73 .collect()
74}
75
76/// The extension of a path, lowercase with its dot: `.exe`. Empty without.
77fn extension(path: &str) -> String {
78 let name = path.rsplit('/').next().unwrap_or(path);
79 match name.rfind('.') {
80 Some(0) | None => String::new(),
81 Some(at) => name[at..].to_lowercase(),
82 }
83}
84
85/// The problems `rule` finds in `commits`. `complete` says whether they
86/// are everything the change brings, each read in full: content rules
87/// cannot be met by a change too large to read.
88pub fn problems(rule: &Rule, commits: &[CommitFacts], complete: bool) -> Vec<Problem> {
89 if !about_content(rule) {
90 return Vec::new();
91 }
92 if !complete {
93 return vec![Problem::new(
94 "The change is too large for g1t to check against this rule.",
95 "Split it into smaller pushes or pull requests.",
96 )];
97 }
98 let found = match rule {
99 Rule::RequiredLinearHistory(_) => commits
100 .iter()
101 .filter(|commit| commit.parents > 1)
102 .map(|commit| {
103 Problem::new(
104 format!("Commit {} is a merge commit; this branch keeps a linear history.", short(&commit.sha)),
105 "Rebase onto the branch instead of merging it in, then push again.",
106 )
107 })
108 .collect(),
109 Rule::RequiredSignatures(_) => commits
110 .iter()
111 .filter_map(|commit| match &commit.signature {
112 Signature::Verified { .. } => None,
113 Signature::Unsigned => Some(Problem::new(
114 format!("Commit {} is not signed.", short(&commit.sha)),
115 "Sign your commits with an SSH key registered on your g1t account (git config gpg.format ssh; git commit -S), then push again.",
116 )),
117 Signature::Unverified { reason } => Some(Problem::new(
118 format!("Commit {}'s signature could not be verified: {reason}", short(&commit.sha)),
119 "Sign with an SSH key registered on the g1t account that owns the committer's verified email address.",
120 )),
121 })
122 .collect(),
123 Rule::CommitMessagePattern(pattern) => pattern_problems(pattern, commits, "message", |commit| Some(&commit.message)),
124 Rule::CommitAuthorEmailPattern(pattern) => {
125 pattern_problems(pattern, commits, "author email", |commit| commit.author_email.as_deref())
126 }
127 Rule::CommitterEmailPattern(pattern) => {
128 pattern_problems(pattern, commits, "committer email", |commit| commit.committer_email.as_deref())
129 }
130 Rule::FilePathRestriction(paths) => commits
131 .iter()
132 .flat_map(|commit| {
133 commit.files.iter().filter_map(move |file| {
134 paths
135 .restricted_file_paths
136 .iter()
137 .find(|pattern| glob::path_matches(pattern, &file.path))
138 .map(|pattern| {
139 Problem::new(
140 format!("Commit {} changes {}, which matches the restricted path {pattern}.", short(&commit.sha), file.path),
141 "Leave restricted paths unchanged, or ask someone who may bypass this ruleset.",
142 )
143 })
144 })
145 })
146 .collect(),
147 Rule::FileExtensionRestriction(extensions) => {
148 let restricted: Vec<String> = extensions
149 .restricted_file_extensions
150 .iter()
151 .map(|extension| {
152 let extension = extension.trim().to_lowercase();
153 if extension.starts_with('.') { extension } else { format!(".{extension}") }
154 })
155 .collect();
156 commits
157 .iter()
158 .flat_map(|commit| {
159 let restricted = &restricted;
160 commit.files.iter().filter(|file| !file.deleted).filter_map(move |file| {
161 let found = extension(&file.path);
162 (!found.is_empty() && restricted.contains(&found)).then(|| {
163 Problem::new(
164 format!("Commit {} adds {}, and {found} files are not allowed.", short(&commit.sha), file.path),
165 "Remove the file from the commits (git rm --cached, then amend or rebase).",
166 )
167 })
168 })
169 })
170 .collect()
171 }
172 Rule::MaxFileSize(limit) => {
173 let max = u64::from(limit.max_file_size_mb) * 1024 * 1024;
174 commits
175 .iter()
176 .flat_map(|commit| {
177 commit.files.iter().filter(|file| file.size.is_some_and(|size| size > max)).map(move |file| {
178 Problem::new(
179 format!(
180 "Commit {} adds {} at {:.1} MB; files may be at most {} MB.",
181 short(&commit.sha),
182 file.path,
183 file.size.unwrap_or(0) as f64 / (1024.0 * 1024.0),
184 limit.max_file_size_mb
185 ),
186 "Take the file out of the commits, and keep large files in a package or release instead.",
187 )
188 })
189 })
190 .collect()
191 }
192 Rule::MaxFilePathLength(limit) => commits
193 .iter()
194 .flat_map(|commit| {
195 commit
196 .files
197 .iter()
198 .filter(|file| !file.deleted && file.path.chars().count() > limit.max_file_path_length as usize)
199 .map(move |file| {
200 Problem::new(
201 format!(
202 "Commit {} adds a path {} characters long; paths may be at most {}.",
203 short(&commit.sha),
204 file.path.chars().count(),
205 limit.max_file_path_length
206 ),
207 "Use a shorter path.",
208 )
209 })
210 })
211 .collect(),
212 Rule::MaxFilesChanged(limit) => commits
213 .iter()
214 .filter(|commit| !commit.files_complete || commit.files.len() > limit.max_files as usize)
215 .map(|commit| {
216 Problem::new(
217 format!("Commit {} changes more than {} files.", short(&commit.sha), limit.max_files),
218 "Split the change into smaller commits.",
219 )
220 })
221 .collect(),
222 // Secrets are push protection's to find; this rule only asks that
223 // every push be read whole, which `complete` said it was.
224 _ => Vec::new(),
225 };
226 capped(found)
227}
228
229#[cfg(test)]
230pub(crate) mod tests_support {
231 use g1t_contracts::rules::{CommitFacts, FileChange, Signature};
232
233 /// A commit by ada@acme.com changing `files`, 10 bytes each.
234 pub(crate) fn commit(sha: &str, message: &str, files: &[&str]) -> CommitFacts {
235 CommitFacts {
236 sha: sha.into(),
237 message: message.into(),
238 author_email: Some("ada@acme.com".into()),
239 committer_email: Some("ada@acme.com".into()),
240 parents: 1,
241 signature: Signature::Unsigned,
242 files: files.iter().map(|path| FileChange { path: (*path).into(), size: Some(10), deleted: false }).collect(),
243 files_complete: true,
244 }
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::tests_support::commit;
251 use super::*;
252 use g1t_contracts::rules::{
253 FileExtensionRule, FilePathRule, MaxFilePathLengthRule, MaxFileSizeRule, MaxFilesChangedRule, NoParameters,
254 PatternOperator,
255 };
256
257 #[test]
258 fn merge_commits_break_linear_history() {
259 let mut merge = commit("aaaaaaaaaa", "Merge main", &[]);
260 merge.parents = 2;
261 let found = problems(&Rule::RequiredLinearHistory(NoParameters {}), &[commit("b", "x", &[]), merge], true);
262 assert_eq!(found.len(), 1);
263 assert_eq!(found[0].message, "Commit aaaaaaa is a merge commit; this branch keeps a linear history.");
264 }
265
266 #[test]
267 fn only_verified_signatures_meet_the_signature_rule() {
268 let mut signed = commit("s", "x", &[]);
269 signed.signature = Signature::Verified { signer: "ada".into() };
270 let mut bad = commit("u", "x", &[]);
271 bad.signature = Signature::Unverified { reason: "GPG signatures are not verified yet.".into() };
272 let found = problems(&Rule::RequiredSignatures(NoParameters {}), &[signed, bad, commit("n", "x", &[])], true);
273 assert_eq!(found.len(), 2);
274 assert!(found[0].message.contains("could not be verified: GPG"));
275 assert!(found[1].message.contains("is not signed"));
276 }
277
278 #[test]
279 fn message_and_address_patterns_check_every_commit() {
280 let conventional = PatternRule { name: String::new(), operator: PatternOperator::Regex, pattern: "^(feat|fix): ".into(), negate: false };
281 let found = problems(&Rule::CommitMessagePattern(conventional), &[commit("a1", "feat: x", &[]), commit("b2", "stuff", &[])], true);
282 assert_eq!(found.len(), 1);
283 assert!(found[0].message.starts_with("Commit b2 has a message that does not match"));
284 let domain = PatternRule { name: String::new(), operator: PatternOperator::EndsWith, pattern: "@acme.com".into(), negate: false };
285 let mut outsider = commit("c3", "x", &[]);
286 outsider.author_email = Some("eve@example.com".into());
287 assert_eq!(problems(&Rule::CommitAuthorEmailPattern(domain.clone()), &[outsider.clone()], true).len(), 1);
288 assert!(problems(&Rule::CommitterEmailPattern(domain), &[outsider], true).is_empty());
289 }
290
291 #[test]
292 fn restricted_paths_extensions_sizes_and_lengths() {
293 let paths = Rule::FilePathRestriction(FilePathRule { restricted_file_paths: vec![".g1t/workflows/**".into(), "CODEOWNERS".into()] });
294 let change = commit("a", "x", &["src/a.rs", ".g1t/workflows/ci.yml", "docs/CODEOWNERS"]);
295 assert_eq!(problems(&paths, std::slice::from_ref(&change), true).len(), 2);
296 let extensions = Rule::FileExtensionRestriction(FileExtensionRule { restricted_file_extensions: vec!["exe".into(), ".ZIP".into()] });
297 let binaries = commit("b", "x", &["tool.exe", "a.zip", "README", ".env"]);
298 assert_eq!(problems(&extensions, &[binaries], true).len(), 2);
299 let mut big = commit("c", "x", &["video.mp4"]);
300 big.files[0].size = Some(30 * 1024 * 1024);
301 let found = problems(&Rule::MaxFileSize(MaxFileSizeRule { max_file_size_mb: 10 }), &[big], true);
302 assert_eq!(found[0].message, "Commit c adds video.mp4 at 30.0 MB; files may be at most 10 MB.");
303 let long = commit("d", "x", &[&"a/".repeat(200)]);
304 assert_eq!(problems(&Rule::MaxFilePathLength(MaxFilePathLengthRule { max_file_path_length: 255 }), &[long], true).len(), 1);
305 let many = commit("e", "x", &["1", "2", "3"]);
306 assert_eq!(problems(&Rule::MaxFilesChanged(MaxFilesChangedRule { max_files: 2 }), std::slice::from_ref(&many), true).len(), 1);
307 assert!(problems(&Rule::MaxFilesChanged(MaxFilesChangedRule { max_files: 3 }), &[many], true).is_empty());
308 }
309
310 #[test]
311 fn deleting_a_file_still_changes_a_restricted_path_but_adds_no_extension() {
312 let mut gone = commit("a", "x", &["CODEOWNERS", "tool.exe"]);
313 for file in &mut gone.files {
314 file.deleted = true;
315 }
316 assert_eq!(problems(&Rule::FilePathRestriction(FilePathRule { restricted_file_paths: vec!["CODEOWNERS".into()] }), &[gone.clone()], true).len(), 1);
317 assert!(problems(&Rule::FileExtensionRestriction(FileExtensionRule { restricted_file_extensions: vec!["exe".into()] }), &[gone], true).is_empty());
318 }
319
320 #[test]
321 fn a_change_too_large_to_read_cannot_meet_a_content_rule() {
322 let found = problems(&Rule::SecretScanning(NoParameters {}), &[], false);
323 assert_eq!(found[0].message, "The change is too large for g1t to check against this rule.");
324 assert!(problems(&Rule::SecretScanning(NoParameters {}), &[], true).is_empty());
325 assert!(problems(&Rule::Deletion(NoParameters {}), &[], false).is_empty(), "not a content rule");
326 }
327
328 #[test]
329 fn many_problems_are_summed_up() {
330 let commits: Vec<CommitFacts> = (0..20).map(|n| commit(&format!("c{n}"), "x", &[])).collect();
331 let found = problems(&Rule::RequiredSignatures(NoParameters {}), &commits, true);
332 assert_eq!(found.len(), 5);
333 assert_eq!(found[4].message, "…and 16 more like it.");
334 }
335}