| 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 | |
| 5 | use g1t_contracts::rules::{CommitFacts, PatternRule, Rule, Signature}; |
| 6 | |
| 7 | use crate::{glob, text}; |
| 8 | |
| 9 | /// One problem a rule found, and how to fix it. |
| 10 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 11 | pub struct Problem { |
| 12 | pub message: String, |
| 13 | pub remedy: String, |
| 14 | } |
| 15 | |
| 16 | impl 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. |
| 23 | const MAX_PROBLEMS: usize = 5; |
| 24 | |
| 25 | fn 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. |
| 30 | pub 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 | |
| 47 | fn 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 | |
| 57 | fn 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. |
| 77 | fn 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. |
| 88 | pub 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)] |
| 230 | pub(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)] |
| 249 | mod 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 | } |