Skip to content

g1t/services/repos/src/rule_facts.rs

291 lines11,883 bytesCodeBlame
1//! What rules about commits look at, read from a pack: each commit's
2//! message, addresses, parents and signature, and the files it adds,
3//! changes or deletes with their sizes. A push's pack is read before it is
4//! stored; a pull request's commits are fetched as a pack from its source
5//! (`inspect_commits`), so both are read by the same code.
6
7use std::cell::Cell;
8use std::collections::{HashMap, HashSet, VecDeque};
9
10use g1t_contracts::rules::{CommitFacts, FileChange, Signature};
11use g1t_scan::pack::{ObjectKind, Pack};
12use worker::Result;
13
14use crate::secret_scan::Objects;
15use crate::store::GitRepo;
16
17/// Who registered each signing key (fingerprint to user id), and who
18/// verified each address (to user id and username).
19pub type Owners = (HashMap<String, String>, HashMap<String, (String, String)>);
20
21/// The most commits read for one ref.
22pub const MAX_COMMITS: usize = 300;
23/// The most files listed for one commit; more is not complete.
24pub const MAX_FILES: usize = 1000;
25/// The most of a message kept.
26const MAX_MESSAGE: usize = 4096;
27
28/// A raw commit's headers and message.
29#[derive(Debug, Default, PartialEq, Eq)]
30pub struct CommitText {
31 pub tree: String,
32 pub parents: Vec<String>,
33 pub author_email: Option<String>,
34 pub committer_email: Option<String>,
35 pub message: String,
36}
37
38fn email(value: &str) -> Option<String> {
39 let start = value.rfind('<')?;
40 let end = start + value[start..].find('>')?;
41 Some(value[start + 1..end].trim().to_owned())
42}
43
44/// Reads a raw commit object.
45pub fn read_commit(data: &[u8]) -> CommitText {
46 let text = String::from_utf8_lossy(data);
47 let (headers, message) = text.split_once("\n\n").unwrap_or((&text, ""));
48 let mut commit = CommitText::default();
49 for line in headers.split('\n') {
50 if let Some(tree) = line.strip_prefix("tree ") {
51 commit.tree = tree.trim().to_owned();
52 } else if let Some(parent) = line.strip_prefix("parent ") {
53 commit.parents.push(parent.trim().to_owned());
54 } else if let Some(author) = line.strip_prefix("author ") {
55 commit.author_email = email(author);
56 } else if let Some(committer) = line.strip_prefix("committer ") {
57 commit.committer_email = email(committer);
58 }
59 }
60 let mut end = message.len().min(MAX_MESSAGE);
61 while !message.is_char_boundary(end) {
62 end -= 1;
63 }
64 commit.message = message[..end].to_owned();
65 commit
66}
67
68/// The commits of the pack reachable from `tip` without leaving it,
69/// newest first: what a push adds to a ref. `None` past `limit`.
70pub fn added(pack: &Pack, tip: &str, limit: usize) -> Option<Vec<String>> {
71 let mut seen = HashSet::new();
72 let mut queue = VecDeque::from([tip.to_owned()]);
73 let mut out = Vec::new();
74 while let Some(id) = queue.pop_front() {
75 if !seen.insert(id.clone()) {
76 continue;
77 }
78 let Some((ObjectKind::Commit, data)) = pack.get(&id) else {
79 continue;
80 };
81 out.push(id);
82 if out.len() > limit {
83 return None;
84 }
85 queue.extend(read_commit(data).parents);
86 }
87 Some(out)
88}
89
90/// The files that differ between two trees, deletions included, with the
91/// size of each new blob the pack holds. Whether the list is complete.
92async fn changed<R: GitRepo>(objects: &Objects<'_, R>, old_root: Option<String>, new_root: Option<String>) -> Result<(Vec<FileChange>, bool)> {
93 let mut files = Vec::new();
94 let mut level: Vec<(String, Option<String>, Option<String>)> = vec![(String::new(), old_root, new_root)];
95 while !level.is_empty() {
96 let mut next = Vec::new();
97 for (prefix, old, new) in level {
98 let old_items = match &old {
99 Some(id) => objects.tree(id).await?,
100 None => Vec::new(),
101 };
102 let new_items = match &new {
103 Some(id) => objects.tree(id).await?,
104 None => Vec::new(),
105 };
106 for item in &new_items {
107 let before = old_items.iter().find(|entry| entry.name == item.name);
108 if before.is_some_and(|before| before.id == item.id && before.mode == item.mode) {
109 continue;
110 }
111 let path = format!("{prefix}{}", item.name);
112 if item.is_tree() {
113 next.push((format!("{path}/"), before.filter(|b| b.is_tree()).map(|b| b.id.clone()), Some(item.id.clone())));
114 // A file replaced by a directory is deleted.
115 if before.is_some_and(|b| !b.is_tree()) {
116 files.push(FileChange { path: path.clone(), size: None, deleted: true });
117 }
118 } else {
119 let size = match objects.pack.get(&item.id) {
120 Some((ObjectKind::Blob, data)) => Some(data.len() as u64),
121 _ => None,
122 };
123 files.push(FileChange { path, size, deleted: false });
124 if let Some(before) = before.filter(|b| b.is_tree()) {
125 next.push((format!("{prefix}{}/", item.name), Some(before.id.clone()), None));
126 }
127 }
128 }
129 for item in &old_items {
130 if new_items.iter().any(|entry| entry.name == item.name) {
131 continue;
132 }
133 let path = format!("{prefix}{}", item.name);
134 if item.is_tree() {
135 next.push((format!("{path}/"), Some(item.id.clone()), None));
136 } else {
137 files.push(FileChange { path, size: None, deleted: true });
138 }
139 }
140 if files.len() > MAX_FILES {
141 files.truncate(MAX_FILES);
142 return Ok((files, false));
143 }
144 }
145 level = next;
146 }
147 Ok((files, true))
148}
149
150/// One commit of the pack, read as rules look at it. `signature` is what
151/// was made of its signature, when one was asked for.
152pub async fn facts<R: GitRepo>(objects: &Objects<'_, R>, id: &str, signature: Option<Signature>) -> Result<Option<CommitFacts>> {
153 let Some((ObjectKind::Commit, data)) = objects.pack.get(id) else {
154 return Ok(None);
155 };
156 let commit = read_commit(data);
157 let old_tree = match commit.parents.first() {
158 Some(parent) => objects.commit_tree(parent).await?,
159 None => None,
160 };
161 let (files, files_complete) = changed(objects, old_tree, Some(commit.tree.clone())).await?;
162 Ok(Some(CommitFacts {
163 sha: id.to_owned(),
164 message: commit.message,
165 author_email: commit.author_email,
166 committer_email: commit.committer_email,
167 parents: commit.parents.len() as u32,
168 signature: signature.unwrap_or_default(),
169 files,
170 files_complete,
171 }))
172}
173
174/// Whether `old` is in the history of `new`: a fast-forward. Walks the
175/// pack's commits, then the repository's history from where it leaves it.
176pub async fn contains<R: GitRepo>(pack: &Pack, repo: &R, new: &str, old: &str, depth: u32) -> Result<bool> {
177 if new == old {
178 return Ok(true);
179 }
180 let mut seen = HashSet::new();
181 let mut queue = VecDeque::from([new.to_owned()]);
182 let mut boundary = Vec::new();
183 while let Some(id) = queue.pop_front() {
184 if id == old {
185 return Ok(true);
186 }
187 if !seen.insert(id.clone()) || seen.len() > 5000 {
188 continue;
189 }
190 match pack.get(&id) {
191 Some((ObjectKind::Commit, data)) => queue.extend(read_commit(data).parents),
192 _ => boundary.push(id),
193 }
194 }
195 for start in boundary.iter().take(20) {
196 let history = repo.log(start, depth).await?;
197 if history.iter().any(|commit| commit.hash == old) {
198 return Ok(true);
199 }
200 // Merges: the history is first-parent only, so look along the
201 // second parents it names too, a step at a time.
202 for commit in history.iter().filter(|commit| commit.parents.len() > 1).take(10) {
203 for parent in commit.parents.iter().skip(1) {
204 if parent == old || repo.log(parent, depth).await?.iter().any(|commit| commit.hash == old) {
205 return Ok(true);
206 }
207 }
208 }
209 }
210 Ok(false)
211}
212
213/// The signature fingerprints and committer addresses of commits, for
214/// looking up who owns them.
215pub fn signing_facts(pack: &Pack, ids: &[String]) -> (Vec<String>, Vec<String>) {
216 let mut fingerprints = Vec::new();
217 let mut emails = Vec::new();
218 for id in ids {
219 let Some((ObjectKind::Commit, data)) = pack.get(id) else { continue };
220 if let Some(fingerprint) = crate::signatures::fingerprint(data)
221 && !fingerprints.contains(&fingerprint)
222 {
223 fingerprints.push(fingerprint);
224 if let Some(email) = read_commit(data).committer_email.map(|email| email.to_lowercase())
225 && !emails.contains(&email)
226 {
227 emails.push(email);
228 }
229 }
230 }
231 (fingerprints, emails)
232}
233
234/// Every commit of `ids` read, with its signature decided against who
235/// owns the keys and addresses (`owners`, when signatures matter).
236pub async fn read_all<R: GitRepo>(
237 pack: &Pack,
238 repo: &R,
239 ids: &[String],
240 owners: Option<&Owners>,
241) -> Result<Vec<CommitFacts>> {
242 let objects = Objects { pack, repo, reads: Cell::new(0) };
243 let mut out = Vec::new();
244 for id in ids {
245 let signature = owners.and_then(|(keys, emails)| {
246 let (_, data) = pack.get(id)?;
247 let committer = read_commit(data).committer_email;
248 Some(crate::signatures::decide(data, committer.as_deref(), keys, emails))
249 });
250 if let Some(facts) = facts(&objects, id, signature).await? {
251 out.push(facts);
252 }
253 }
254 Ok(out)
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn a_commit_reads_its_parents_addresses_and_message() {
263 let raw = b"tree aaaa\nparent bbbb\nparent cccc\nauthor Ada Lovelace <ada@acme.com> 1 +0000\ncommitter G <noreply@g1t.sh> 1 +0000\ngpgsig -----BEGIN SSH SIGNATURE-----\n abc\n -----END SSH SIGNATURE-----\n\nfeat: rules\n\nWith a body.\n";
264 let commit = read_commit(raw);
265 assert_eq!(commit.tree, "aaaa");
266 assert_eq!(commit.parents, vec!["bbbb", "cccc"]);
267 assert_eq!(commit.author_email.as_deref(), Some("ada@acme.com"));
268 assert_eq!(commit.committer_email.as_deref(), Some("noreply@g1t.sh"));
269 assert_eq!(commit.message, "feat: rules\n\nWith a body.\n");
270 }
271
272 #[test]
273 fn a_long_message_is_cut_on_a_character() {
274 let message = "é".repeat(5000);
275 let raw = format!("tree a\n\n{message}");
276 assert!(read_commit(raw.as_bytes()).message.len() <= MAX_MESSAGE);
277 }
278
279 #[test]
280 fn the_commits_a_push_adds_are_those_its_pack_holds() {
281 use g1t_scan::pack::write_pack;
282 let first = b"tree t\nauthor A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n\none\n".to_vec();
283 let first_id = g1t_scan::pack::object_id(ObjectKind::Commit, &first);
284 let second = format!("tree t\nparent {first_id}\nparent {}\nauthor A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n\ntwo\n", "f".repeat(40)).into_bytes();
285 let second_id = g1t_scan::pack::object_id(ObjectKind::Commit, &second);
286 let pack = Pack::parse(&write_pack(&[(ObjectKind::Commit, first), (ObjectKind::Commit, second)])).unwrap();
287 assert_eq!(added(&pack, &second_id, 10), Some(vec![second_id.clone(), first_id.clone()]));
288 assert_eq!(added(&pack, &second_id, 1), None, "past the limit");
289 assert_eq!(added(&pack, &"0".repeat(40), 10), Some(Vec::new()), "a tip the pack does not hold adds nothing");
290 }
291}