Skip to content

Commit

Rulesets on push: refused with the ruleset and rule named, commits read

The repos service enforces rulesets on every push and on every other change to a branch or tag made through g1t, asking the work service which hold (ref_rules) and sending back how each judged it (record_evaluations). - Pushes: git is told, as remote: lines, which ruleset and rule refused the push, what is wrong and how to fix it, and where the branch's rules are shown. Rules about commits read the pushed pack: messages, addresses, parents, files added, changed and deleted with their sizes, and signatures. Force pushes are told apart by walking the pack and the repository's history. A push too large to read cannot meet a rule about commits, and with "Block pushes that add secrets" a push too large to scan is refused rather than let through unscanned. - Signed commits: ed25519 SSH signatures (git's gpg.format ssh) are verified, and count only when the key is registered on the account that owns the committer's verified address (identity ssh_key_owners, new). GPG and other key types are reported as not verified yet. - Renaming a branch (a deletion and a creation), a commit made on the site, and bringing a pull request's branch up to date obey the rules of their branches. - inspect_commits: the commits a pull request would land, fetched from its source as a pack and read the same way, for rules at merge time. - Updating a repository's protected flag sets its branch protection ruleset. Without the work service, the old flag still holds on push. - Restricting updates restricts merges too. - ruleset.created, ruleset.updated and ruleset.deleted webhook events; a workspace's go to the workspace's webhooks.

syntaqxcommitted Parent7bfe9cbBrowse files
19 files+1171−360/19 viewed
+3−0
10621062 version = "0.1.0"
10631063 dependencies = [
10641064 "base64 0.22.1",
1065+ "ed25519-dalek",
10651066 "futures-util",
10661067 "g1t-blobstore",
10671068 "g1t-contracts",
10681069 "g1t-kit",
1070+ "g1t-rules",
10691071 "g1t-scan",
10701072 "g1t-secrets",
10711073 "miniz_oxide",
10721074 "serde",
10731075 "serde_json",
1076+ "sha2 0.10.9",
10741077 "similar",
10751078 "worker",
10761079 ]
+9−0
107107 pub user: User,
108108 }
109109
110+/// `ssh_key_owners`: services only. The account (user id) that registered
111+/// each key, by fingerprint (`SHA256:…`, as `ssh-keygen -lf` prints it),
112+/// for verifying commits signed with SSH keys. Returns a map of the
113+/// fingerprints found to user ids.
114+#[derive(Debug, Serialize, Deserialize)]
115+pub struct SshKeyOwnersArgs {
116+ pub fingerprints: Vec<String>,
117+}
118+
110119 /// `add_ssh_key`: `public_key` is one line in OpenSSH format.
111120 /// Returns `Outcome<SshKey>`.
112121 #[derive(Debug, Serialize, Deserialize)]
+4−1
1515 use crate::{User, Viewer};
1616
1717 /// Every event a webhook can be sent, in the order people are shown them.
18−pub const EVENT_TYPES: [&str; 78] = [
18+pub const EVENT_TYPES: [&str; 81] = [
1919 "git.push",
2020 "branch.renamed",
2121 "repo.created",
4242 "team.repo_added",
4343 "team.repo_role_changed",
4444 "team.repo_removed",
45+ "ruleset.created",
46+ "ruleset.updated",
47+ "ruleset.deleted",
4548 "issue.opened",
4649 "issue.updated",
4750 "issue.assigned",
+20−0
247247 /// The problems one rule finds in a merge.
248248 fn rule_problems(rule: &Rule, facts: &MergeFacts<'_>) -> Vec<Problem> {
249249 match rule {
250+ // Restricting updates restricts merges too: a merge moves the branch.
251+ Rule::Update(_) => {
252+ let branch = facts.git_ref.strip_prefix("refs/heads/").unwrap_or(&facts.git_ref);
253+ vec![Problem::new(
254+ format!("Only people this ruleset lets bypass it may change {branch}, merges included."),
255+ "Ask someone who may bypass this ruleset to merge it.",
256+ )]
257+ }
250258 Rule::PullRequest(rule) => pull_request_problems(rule, facts),
251259 Rule::RequiredStatusChecks(rule) => checks_problems(rule, facts),
252260 Rule::RequiredDeployments(rule) => rule
801809 }
802810
803811 #[test]
812+ fn restricting_updates_restricts_merges() {
813+ let rules = [ruleset(vec![all(Rule::Update(NoParameters {}))])];
814+ assert_eq!(
815+ messages(&judge(&rules, "main", &facts(&[], &[], &[]))),
816+ vec!["Only people this ruleset lets bypass it may change main, merges included."]
817+ );
818+ let mut bypassed = rules[0].clone();
819+ bypassed.bypass = Some(BypassMode::Always);
820+ assert!(!refused(&judge(&[bypassed], "main", &facts(&[], &[], &[]))));
821+ }
822+
823+ #[test]
804824 fn a_merge_freeze_holds_merges() {
805825 let rules = [ruleset(vec![all(Rule::MergeWindow(MergeWindowRule {
806826 freezes: vec![Period { start: "1970-01-01T00:00:00Z".into(), end: None, reason: "Incident".into() }],
+3−0
3535 "team.repo_added",
3636 "team.repo_role_changed",
3737 "team.repo_removed",
38+ "ruleset.created",
39+ "ruleset.updated",
40+ "ruleset.deleted",
3841 "issue.opened",
3942 "issue.updated",
4043 "issue.assigned",
+2−1
9898
9999 test("shared crates and packages are read from workspace metadata", () => {
100100 assert.deepEqual(unit("events").dependsOn, ["crates/contracts", "crates/kit"]);
101− assert.deepEqual(unit("repos").dependsOn, ["crates/blobstore", "crates/contracts", "crates/kit", "crates/scan", "crates/secrets"]);
101+ assert.deepEqual(unit("repos").dependsOn, ["crates/blobstore", "crates/contracts", "crates/kit", "crates/rules", "crates/scan", "crates/secrets"]);
102+ assert.ok(unit("work").dependsOn.includes("crates/rules"));
102103 assert.ok(unit("actions").dependsOn.includes("crates/actions"));
103104 assert.ok(!unit("events").dependsOn.includes("crates/scan"));
104105 assert.deepEqual(unit("web").dependsOn, ["packages/contracts", "packages/theme"]);
+29−0
619619 Ok(rows.into_iter().map(SshKey::from).collect())
620620 }
621621
622+ /// The account (user id) that registered each key, by fingerprint
623+ /// (`SHA256:…`). At most 100; unknown keys are left out.
624+ async fn ssh_key_owners(&self, a: SshKeyOwnersArgs) -> Result<std::collections::HashMap<String, String>> {
625+ #[derive(serde::Deserialize)]
626+ struct Row {
627+ fingerprint: String,
628+ user_id: String,
629+ }
630+ let fingerprints: Vec<&String> = a.fingerprints.iter().take(100).collect();
631+ if fingerprints.is_empty() {
632+ return Ok(std::collections::HashMap::new());
633+ }
634+ let marks = vec!["?"; fingerprints.len()].join(", ");
635+ let binds: Vec<JsValue> = fingerprints.iter().map(|fingerprint| fingerprint.as_str().into()).collect();
636+ Ok(self
637+ .db
638+ .prepare(format!("SELECT fingerprint, user_id FROM ssh_keys WHERE fingerprint IN ({marks})"))
639+ .bind(&binds)?
640+ .all()
641+ .await?
642+ .results::<Row>()?
643+ .into_iter()
644+ .map(|row| (row.fingerprint, row.user_id))
645+ .collect())
646+ }
647+
622648 async fn add_ssh_key(&self, a: AddSshKeyArgs) -> Result<Outcome<SshKey>> {
623649 let Some(key) = crypto::parse_ssh_key(&a.public_key) else {
624650 return Ok(Outcome::fail(
818844 "directory" => reply(&identity.directory(args(body)?).await?),
819845 "profile_workspaces" => reply(&identity.profile_workspaces(args(body)?).await?),
820846 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
847+ // Services only: who registered each key, for verifying commit
848+ // signatures (repos' signatures.rs).
849+ "ssh_key_owners" => reply(&identity.ssh_key_owners(args(body)?).await?),
821850 "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?),
822851 "remove_ssh_key" => reply(&identity.remove("ssh_keys", args(body)?).await?),
823852 "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?),
+3−0
1111 [dependencies]
1212 g1t-contracts.workspace = true
1313 g1t-kit.workspace = true
14+g1t-rules.workspace = true
1415 g1t-scan.workspace = true
1516 g1t-secrets.workspace = true
1617 g1t-blobstore.workspace = true
2122 similar = "2"
2223 miniz_oxide = "0.9"
2324 base64 = "0.22"
25+ed25519-dalek = "3"
26+sha2 = "0.10"
2427
2528 # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
2629 # time (docs/DEPLOYING.md, "Build speed").
+24−0
541541 } else {
542542 write_pack(&objects)
543543 };
544+ // A branch of the repository itself: the rules of that branch hold
545+ // for the merge pushed to it, as for any push (rules.rs). A catch-up
546+ // brings nothing the base branch does not have, so no files.
547+ if !from_fork {
548+ let change = g1t_rules::push::RefChange {
549+ git_ref: format!("refs/heads/{branch}"),
550+ old: Some(head.hash.clone()),
551+ new: Some(commit_id.clone()),
552+ fast_forward: Some(true),
553+ commits: vec![crate::rules::made_commit(
554+ &commit_id,
555+ &merge_message(&base_branch, &branch, a.number),
556+ &author.email,
557+ 2,
558+ Vec::new(),
559+ )],
560+ complete: true,
561+ };
562+ if let crate::rules::Ruled::Refused { message, .. } =
563+ self.check_changes(&source, &a.actor, g1t_contracts::rules::Action::Push, vec![change]).await?
564+ {
565+ return Ok(Outcome::fail(FailureCode::Forbidden, message));
566+ }
567+ }
544568 let source_access = source_git.access(Scope::Write).await?;
545569 // Only if the branch is still where it was: a push that landed
546570 // meanwhile is kept, and this is refused.
+20−0
125125 if let Some(refusal) = self.protect_file(&repo, &a.actor, &a.path, a.content.as_bytes(), &commit_id).await {
126126 return Ok(Outcome::fail(FailureCode::Forbidden, refusal));
127127 }
128+ // The rules of the new branch, as for a push of this commit.
129+ let change = g1t_rules::push::RefChange {
130+ git_ref: format!("refs/heads/{}", a.branch),
131+ old: None,
132+ new: Some(commit_id.clone()),
133+ fast_forward: None,
134+ commits: vec![crate::rules::made_commit(
135+ &commit_id,
136+ message,
137+ &author.email,
138+ 1,
139+ vec![g1t_contracts::rules::FileChange { path: a.path.clone(), size: Some(blob.len() as u64), deleted: false }],
140+ )],
141+ complete: true,
142+ };
143+ if let crate::rules::Ruled::Refused { message, .. } =
144+ self.check_changes(&repo, &a.actor, g1t_contracts::rules::Action::Commit, vec![change]).await?
145+ {
146+ return Ok(Outcome::fail(FailureCode::Forbidden, message));
147+ }
128148 let mut objects: Vec<(ObjectKind, Vec<u8>)> = vec![(ObjectKind::Blob, blob)];
129149 objects.extend(merged.objects.into_iter().map(|bytes| (ObjectKind::Tree, bytes)));
130150 objects.push((ObjectKind::Commit, commit));
+34−15
314314 line
315315 }
316316
317+/// The branches and tags a push asks to change, as rules see them: the
318+/// full ref, where it pointed (`None`: it is created) and where it will
319+/// (`None`: it is deleted).
320+pub(crate) fn ref_updates(body: &[u8]) -> Vec<(String, Option<String>, Option<String>)> {
321+ commands(body)
322+ .0
323+ .into_iter()
324+ .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
325+ .map(|Command { old, new, name }| (name, (old != ZERO_ID).then_some(old), (new != ZERO_ID).then_some(new)))
326+ .collect()
327+}
328+
329+/// A report-status answer, as git expects it.
330+pub(crate) fn report_response(report: Vec<u8>) -> Result<Response> {
331+ let headers = Headers::new();
332+ headers.set("content-type", "application/x-git-receive-pack-result")?;
333+ headers.set("cache-control", "no-cache")?;
334+ Ok(Response::from_bytes(report)?.with_headers(headers))
335+}
336+
317337 /// What git is told when a push would change a protected branch: every ref
318338 /// in it is declined, with the reason against the protected one, so that
319339 /// git prints it beside the branch. `None` if the push leaves the branch
320−/// alone, or creates it in a repository that does not have it yet.
321−fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> {
340+/// alone, or creates it in a repository that does not have it yet. Only
341+/// where rulesets cannot be read (an installation without the work
342+/// service); rulesets decide everywhere else (rules.rs).
343+pub(crate) fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> {
322344 let (commands, capabilities) = commands(body);
323345 let reference = format!("{HEADS}{protected}");
324346 if !commands
438460 /// What became of a git request.
439461 pub enum Push {
440462 Forwarded(Forwarded),
441− /// A push to a protected branch, answered here without reaching the store.
463+ /// A push the rules of its branches or tags refuse (rules.rs), answered
464+ /// here without reaching the store.
442465 Refused(Response),
443466 /// A push that adds a secret nobody allowed, answered the same way.
444467 Blocked(Response),
791814 }
792815
793816 /// Sends the request on to the git store and returns its response as is,
794−/// unless it is a push that would change the `protected` branch, one the
817+/// unless it is a push the rules refuse (`rules`, rules.rs), one the
795818 /// store could not hold (`limits`, pack_limits.rs), or one that `scan`
796819 /// (push protection) answers itself. A fetch's ref listing has its `HEAD`
797820 /// pointed at `default_branch` (see [`with_head`]). A POST's body is
809832 read: Option<Vec<u8>>,
810833 git: &GitRequest,
811834 access: &GitAccess,
812− protected: Option<&str>,
835+ rules: impl AsyncFnOnce(&[u8], bool) -> Result<Option<Response>>,
813836 default_branch: Option<&str>,
814837 limits: PushLimits,
815838 scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>,
828851 let namespace = crate::store::health_namespace(&access.remote);
829852
830853 if method == Method::Post && git.endpoint == "git-receive-pack" {
831− return push(request, &url, headers, protected, limits, scan, &namespace).await;
854+ return push(request, &url, headers, rules, limits, scan, &namespace).await;
832855 }
833856
834857 // A read: the ref advertisement, `ls-refs`, or a fetch of objects.
915938 mut request: Request,
916939 url: &str,
917940 headers: Headers,
918− protected: Option<&str>,
941+ rules: impl AsyncFnOnce(&[u8], bool) -> Result<Option<Response>>,
919942 limits: PushLimits,
920943 scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>,
921944 namespace: &str,
940963 }
941964 }
942965 }
943− let report_headers = || -> Result<Headers> {
944− let headers = Headers::new();
945− headers.set("content-type", "application/x-git-receive-pack-result")?;
946− headers.set("cache-control", "no-cache")?;
947− Ok(headers)
948− };
949− if let Some(report) = protected.and_then(|branch| refusal(&head, branch)) {
966+ // The rules of the branches and tags it changes, first: what they
967+ // refuse is refused whatever else is wrong with it.
968+ if let Some(response) = rules(&head, ended).await? {
950969 if !ended {
951970 drain(&mut stream).await?;
952971 }
953− return Ok(Push::Refused(Response::from_bytes(report)?.with_headers(report_headers()?)));
972+ return Ok(Push::Refused(response));
954973 }
955974 if !ended && limits.large == LargePushes::Refuse && violation.is_none() {
956975 let size = head.len() as u64 + drain(&mut stream).await?;
+33−6
3030 mod refs_cache;
3131 mod registry;
3232 mod resilience;
33+mod rule_facts;
34+mod rules;
3335 mod run_access;
3436 mod secret_scan;
3537 mod shards;
3638 mod shared;
39+mod signatures;
3740 mod store;
3841 mod transfer;
3942
185188 security: Option<Fetcher>,
186189 /// Asked whether a workspace is on a plan, for its private storage.
187190 billing: Option<Fetcher>,
191+ /// Says which rulesets hold for a change to a branch or tag, and keeps
192+ /// how they judged it (rules.rs). `None` where it is not deployed: the
193+ /// old protection flag then holds on push.
194+ work: Option<Fetcher>,
188195 /// Told when a repository moves, for the tokens of agents at work on it.
189196 identity: Option<Fetcher>,
190197 /// What a free workspace's private repositories may hold.
354361 website,
355362 ..repo
356363 };
364+ // Whether the default branch takes only pull requests is now its
365+ // branch protection ruleset's to say (work's rulesets.rs).
366+ if let (Some(protected), Some(work)) = (a.protected, &self.work) {
367+ #[derive(Serialize)]
368+ struct RequirePullRequest<'a> {
369+ repo: &'a Repo,
370+ protected: bool,
371+ actor: &'a User,
372+ }
373+ let set: Result<Outcome<bool>> =
374+ g1t_kit::call(work, "set_requires_pull_request", &RequirePullRequest { repo: &updated, protected, actor: &a.actor }).await;
375+ match set {
376+ Ok(Outcome::Ok(_)) => {}
377+ Ok(Outcome::Fail(failure)) => return Ok(Outcome::Fail(failure)),
378+ Err(error) => return Err(error),
379+ }
380+ }
357381 if let Some(private) = wants_private {
358382 return self.change_visibility(updated, private, &a.actor, a.surface).await;
359383 }
16171641 // again before git uses it (forks.rs).
16181642 self.live(&repo).await?;
16191643 timing.mark("access");
1620− // A protected default branch takes changes only from a merged pull
1621− // request, which lands without going through here.
1622− let protected = (repo.protected && repo.fork_of.is_none()).then(|| repo.default_branch.clone());
16231644 // Clones check out the default branch g1t keeps, which can have
16241645 // changed since the store made the repository.
16251646 let default_branch = repo.fork_of.is_none().then(|| repo.default_branch.clone());
17381759 let again = if get { Some(request.clone()?) } else { None };
17391760 // Push protection: a push that adds a secret is refused. See secret_scan.rs.
17401761 let scan = async |body: &[u8]| self.protect(&repo, viewer.as_ref(), body).await;
1762+ // Rulesets: what the rules of the branches and tags it changes
1763+ // refuse is declined, saying which rule and why (rules.rs).
1764+ let rules = async |head: &[u8], whole: bool| self.check_push(&repo, viewer.as_ref(), head, whole).await;
17411765 // What a push may bring (pack_limits.rs): the repository's size is
17421766 // its own and its pull requests' working copies'.
17431767 let limits = if write && !get {
17551779 body,
17561780 git,
17571781 &access,
1758− protected.as_deref(),
1782+ rules,
17591783 default_branch.as_deref(),
17601784 limits,
17611785 scan,
17751799 None,
17761800 git,
17771801 &access,
1778− protected.as_deref(),
1802+ async |_: &[u8], _: bool| Ok(None),
17791803 default_branch.as_deref(),
17801804 git_http::PushLimits::default(),
17811805 nothing,
17871811 match outcome {
17881812 git_http::Push::Forwarded(forwarded) => forwarded,
17891813 git_http::Push::Refused(response) => {
1790− after.ended(403, Some("The push would change a protected branch.".to_owned()));
1814+ after.ended(403, Some("The push was declined by rules.".to_owned()));
17911815 after.spawn(env, ctx);
17921816 return Ok(response);
17931817 }
20642088 security: env.service("SECURITY").ok(),
20652089 billing: env.service("BILLING").ok(),
20662090 identity: env.service("IDENTITY").ok(),
2091+ work: env.service("WORK").ok(),
20672092 free_private_bytes: git_ops::free_private_bytes(env),
20682093 fork_days: forks::retention_days(env),
20692094 repo_limit: env
22382263 "delete_branch" => reply(&repos.delete_branch(args(body)?).await?),
22392264 "commit_file" => reply(&repos.commit_file(args(body)?).await?),
22402265 "compare" => reply(&repos.compare(args(body)?).await?),
2266+ // Services only: a pull request's commits, as rules look at them (rules.rs).
2267+ "inspect_commits" => reply(&repos.inspect_commits(args(body)?).await?),
22412268 "scan_history" => reply(&repos.scan_history(args(body)?).await?),
22422269 "find_lockfiles" => reply(&repos.find_lockfiles(args(body)?).await?),
22432270 "match_pattern" => reply(&repos.match_pattern(args(body)?).await?),
+23−0
13561356 let Some(head) = branches.iter().find(|b| b.name == from).map(|b| b.hash.clone()) else {
13571357 return Ok(not_found());
13581358 };
1359+ // A rename deletes one name and creates another: the rules of both
1360+ // hold (rules.rs).
1361+ let renamed = vec![
1362+ g1t_rules::push::RefChange {
1363+ git_ref: format!("refs/heads/{from}"),
1364+ old: Some(head.clone()),
1365+ new: None,
1366+ complete: true,
1367+ ..Default::default()
1368+ },
1369+ g1t_rules::push::RefChange {
1370+ git_ref: format!("refs/heads/{to}"),
1371+ old: None,
1372+ new: Some(head.clone()),
1373+ complete: true,
1374+ ..Default::default()
1375+ },
1376+ ];
1377+ if let crate::rules::Ruled::Refused { message, .. } =
1378+ self.check_changes(&repo, &a.actor, g1t_contracts::rules::Action::RenameRef, renamed).await?
1379+ {
1380+ return Ok(Outcome::fail(FailureCode::Forbidden, message));
1381+ }
13591382 let access = git.access(Scope::Write).await?;
13601383 let made = land::push_pack(&access, &to, None, &head, EMPTY_PACK.to_vec()).await?;
13611384 self.refs_moved(&repo.id).await;
+291−0
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+
7+use std::cell::Cell;
8+use std::collections::{HashMap, HashSet, VecDeque};
9+
10+use g1t_contracts::rules::{CommitFacts, FileChange, Signature};
11+use g1t_scan::pack::{ObjectKind, Pack};
12+use worker::Result;
13+
14+use crate::secret_scan::Objects;
15+use 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).
19+pub type Owners = (HashMap<String, String>, HashMap<String, (String, String)>);
20+
21+/// The most commits read for one ref.
22+pub const MAX_COMMITS: usize = 300;
23+/// The most files listed for one commit; more is not complete.
24+pub const MAX_FILES: usize = 1000;
25+/// The most of a message kept.
26+const MAX_MESSAGE: usize = 4096;
27+
28+/// A raw commit's headers and message.
29+#[derive(Debug, Default, PartialEq, Eq)]
30+pub 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+
38+fn 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.
45+pub 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`.
70+pub 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.
92+async 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.
152+pub 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.
176+pub 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.
215+pub 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).
236+pub 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)]
258+mod 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+}
+375−0
1+//! Rulesets, enforced here: on every push, and on every other change to a
2+//! branch or tag made through g1t (renaming a branch, a commit made on the
3+//! site, a pull request brought up to date). The work service keeps the
4+//! rulesets and says which hold (`ref_rules`); `g1t_rules` judges; every
5+//! judgement is sent back to be recorded (`record_evaluations`). Merging a
6+//! pull request is judged by the work service before it asks `land`.
7+//!
8+//! A pull request's working copy (a fork) has no rules of its own: what it
9+//! brings is judged when it merges.
10+
11+use std::collections::HashMap;
12+
13+use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs};
14+use g1t_contracts::repos::Repo;
15+use g1t_contracts::rules::{
16+ Action, Applicable, CommitFacts, Enforcement, FileChange, InspectCommitsArgs, InspectedCommits,
17+ RecordEvaluationsArgs, RefRules, RefRulesArgs, Rule, Target,
18+};
19+use g1t_contracts::{FailureCode, Outcome, User};
20+use g1t_rules::push::{RefChange, judge};
21+use g1t_rules::{ActorFacts, Judged, Who, content, outcome, report};
22+use g1t_scan::pack::{Pack, pack_start};
23+use worker::{Response, Result};
24+
25+use crate::registry::store_key;
26+use crate::rule_facts::{self, MAX_COMMITS};
27+use crate::store::{GitRepo, GitStore};
28+use crate::{MAX_ANCESTRY, Repos};
29+
30+/// Where people read the rules of a branch.
31+const SITE: &str = "https://g1t.sh";
32+
33+/// What the rules said about a change.
34+pub(crate) enum Ruled {
35+ /// No ruleset holds, or none refuses it.
36+ Allowed,
37+ /// Refused: why, in a sentence.
38+ Refused { message: String },
39+}
40+
41+/// `ssh_key_owners` on identity: the account that registered each key.
42+#[derive(serde::Serialize)]
43+struct KeyOwnersArgs<'a> {
44+ fingerprints: &'a [String],
45+}
46+
47+fn who(actor: Option<&User>, repo: &Repo) -> ActorFacts {
48+ match actor {
49+ Some(actor) => ActorFacts::of(actor, repo),
50+ None => ActorFacts { username: "anonymous".to_owned(), ..ActorFacts::default() },
51+ }
52+}
53+
54+/// Whether any ruleset that is evaluated (active, or evaluate) has a rule
55+/// about commits that holds for this actor.
56+fn needs_commits(rulesets: &[Applicable], kind: Who) -> bool {
57+ rulesets.iter().filter(|ruleset| ruleset.enforcement != Enforcement::Disabled).any(|ruleset| {
58+ ruleset
59+ .rules
60+ .iter()
61+ .any(|entry| entry.applies_to.covers(kind.is_agent()) && content::about_content(&entry.rule))
62+ })
63+}
64+
65+fn needs_ancestry(rulesets: &[Applicable]) -> bool {
66+ rulesets
67+ .iter()
68+ .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::NonFastForward(_))))
69+}
70+
71+/// Where the rules of a ref are shown.
72+pub(crate) fn rules_url(repo: &Repo, git_ref: &str) -> String {
73+ let (target, name) = Target::of_ref(git_ref).unwrap_or((Target::Branch, git_ref));
74+ let key = if target == Target::Tag { "tag" } else { "branch" };
75+ format!("{SITE}/{}/{}/settings/rules?{key}={name}", repo.namespace, repo.name)
76+}
77+
78+impl<S: GitStore> Repos<S> {
79+ /// The rulesets that hold for `refs`, from the work service. `None`
80+ /// when this installation runs without it.
81+ async fn ref_rules(&self, repo: &Repo, actor: Option<&User>, refs: Vec<String>) -> Result<Option<RefRules>> {
82+ let Some(work) = &self.work else { return Ok(None) };
83+ let found: Outcome<RefRules> =
84+ g1t_kit::call(work, "ref_rules", &RefRulesArgs { repo: repo.clone(), actor: actor.cloned(), refs }).await?;
85+ match found {
86+ Outcome::Ok(rules) => Ok(Some(rules)),
87+ Outcome::Fail(failure) => Err(worker::Error::RustError(failure.message)),
88+ }
89+ }
90+
91+ /// Sends judgements to be recorded; a failure is logged.
92+ async fn record_judged(&self, repo: &Repo, judged: &[Judged], action: Action, actor: &ActorFacts, sha: Option<&str>) {
93+ let Some(work) = &self.work else { return };
94+ if judged.is_empty() {
95+ return;
96+ }
97+ let evaluations = g1t_rules::evaluations(judged, &repo.id, &repo.namespace, action, actor, None, sha);
98+ let recorded: Result<u32> = g1t_kit::call(work, "record_evaluations", &RecordEvaluationsArgs { evaluations }).await;
99+ if let Err(error) = recorded {
100+ worker::console_error!("rule evaluations not recorded: {error}");
101+ }
102+ }
103+
104+ /// Who owns the keys commits were signed with, and the addresses
105+ /// they were committed as.
106+ async fn signing_owners(
107+ &self,
108+ fingerprints: &[String],
109+ emails: &[String],
110+ ) -> (HashMap<String, String>, HashMap<String, (String, String)>) {
111+ let Some(identity) = &self.identity else { return (HashMap::new(), HashMap::new()) };
112+ if fingerprints.is_empty() {
113+ return (HashMap::new(), HashMap::new());
114+ }
115+ let (keys, owners) = futures_util::future::join(
116+ g1t_kit::call::<_, HashMap<String, String>>(identity, "ssh_key_owners", &KeyOwnersArgs { fingerprints }),
117+ g1t_kit::call::<_, HashMap<String, EmailOwner>>(identity, "email_owners", &EmailOwnersArgs { emails: emails.to_vec() }),
118+ )
119+ .await;
120+ let keys = keys.unwrap_or_else(|error| {
121+ worker::console_error!("ssh_key_owners failed: {error}");
122+ HashMap::new()
123+ });
124+ let owners = owners
125+ .unwrap_or_default()
126+ .into_iter()
127+ .map(|(email, owner)| (email.to_lowercase(), (owner.id, owner.username)))
128+ .collect();
129+ (keys, owners)
130+ }
131+
132+ /// Judges changes made through g1t (not a push), records how each
133+ /// ruleset judged them, and says whether they may go ahead.
134+ pub(crate) async fn check_changes(&self, repo: &Repo, actor: &User, action: Action, changes: Vec<RefChange>) -> Result<Ruled> {
135+ if repo.fork_of.is_some() || changes.is_empty() {
136+ return Ok(Ruled::Allowed);
137+ }
138+ let refs: Vec<String> = changes.iter().map(|change| change.git_ref.clone()).collect();
139+ let rules = match self.ref_rules(repo, Some(actor), refs).await {
140+ Ok(Some(rules)) => rules,
141+ Ok(None) => return Ok(Ruled::Allowed),
142+ Err(error) => {
143+ worker::console_error!("ref_rules failed: {error}");
144+ return Ok(Ruled::Refused {
145+ message: "The rules for this branch could not be checked just now. Try again in a moment.".to_owned(),
146+ });
147+ }
148+ };
149+ if rules.rulesets.is_empty() {
150+ return Ok(Ruled::Allowed);
151+ }
152+ let facts = who(Some(actor), repo);
153+ let judged: Vec<Judged> = changes
154+ .iter()
155+ .flat_map(|change| judge(&rules.rulesets, &rules.default_branch, facts.kind, change))
156+ .collect();
157+ let sha = changes.iter().find_map(|change| change.new.clone());
158+ self.record_judged(repo, &judged, action, &facts, sha.as_deref()).await;
159+ if !outcome::refused(&judged) {
160+ return Ok(Ruled::Allowed);
161+ }
162+ let message = report::summary(&outcome::blocking(&judged)).unwrap_or_else(|| "Rules for this branch refuse it.".to_owned());
163+ Ok(Ruled::Refused { message })
164+ }
165+
166+ /// Rules for a push: the response declining it, or `None` to let it
167+ /// on. `body` is as much of the push as was read; `whole` says whether
168+ /// that is all of it, so that its commits can be read.
169+ pub(crate) async fn check_push(&self, repo: &Repo, pusher: Option<&User>, body: &[u8], whole: bool) -> Result<Option<Response>> {
170+ if repo.fork_of.is_some() {
171+ return Ok(None);
172+ }
173+ let updates = crate::git_http::ref_updates(body);
174+ if updates.is_empty() {
175+ return Ok(None);
176+ }
177+ let refs: Vec<String> = updates.iter().map(|(name, _, _)| name.clone()).collect();
178+ let rules = match self.ref_rules(repo, pusher, refs).await {
179+ Ok(Some(rules)) => rules,
180+ // Without the work service, the old protection holds.
181+ Ok(None) => {
182+ let protected = repo.protected.then(|| repo.default_branch.clone());
183+ return protected
184+ .and_then(|branch| crate::git_http::refusal(body, &branch))
185+ .map(crate::git_http::report_response)
186+ .transpose();
187+ }
188+ Err(error) => {
189+ worker::console_error!("ref_rules failed during a push: {error}");
190+ return Ok(Some(crate::git_http::declined(
191+ body,
192+ "rules could not be checked",
193+ &["The rules for this repository could not be checked just now. Push again in a moment.".to_owned()],
194+ )?));
195+ }
196+ };
197+ if rules.rulesets.is_empty() {
198+ return Ok(None);
199+ }
200+ let facts = who(pusher, repo);
201+ let content = needs_commits(&rules.rulesets, facts.kind);
202+ let ancestry = needs_ancestry(&rules.rulesets);
203+ let git = self.store.open(&store_key(repo)).await?;
204+ // The pack, read when a rule needs what it holds.
205+ let pack = if whole && (content || ancestry) {
206+ match pack_start(body).map(|start| Pack::parse(&body[start..])) {
207+ Some(Ok(mut pack)) => {
208+ crate::secret_scan::supply_bases(&mut pack, &git).await?;
209+ Some(pack)
210+ }
211+ Some(Err(problem)) => {
212+ worker::console_error!("a push's pack could not be read for rules: {problem}");
213+ None
214+ }
215+ // Nothing but deletions, or pointing refs at commits the
216+ // repository has: an empty pack.
217+ None => Pack::parse(crate::land::EMPTY_PACK).ok(),
218+ }
219+ } else {
220+ None
221+ };
222+ let signatures = rules
223+ .rulesets
224+ .iter()
225+ .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::RequiredSignatures(_))));
226+ let mut changes = Vec::new();
227+ for (git_ref, old, new) in updates {
228+ let mut change = RefChange { git_ref, old: old.clone(), new: new.clone(), fast_forward: None, commits: Vec::new(), complete: false };
229+ if let (Some(pack), Some(new)) = (&pack, &new) {
230+ if let Some(old) = &old
231+ && ancestry
232+ {
233+ change.fast_forward = Some(rule_facts::contains(pack, &git, new, old, MAX_ANCESTRY).await?);
234+ }
235+ if content {
236+ match rule_facts::added(pack, new, MAX_COMMITS) {
237+ Some(ids) => {
238+ let owners = if signatures {
239+ let (fingerprints, emails) = rule_facts::signing_facts(pack, &ids);
240+ Some(self.signing_owners(&fingerprints, &emails).await)
241+ } else {
242+ None
243+ };
244+ change.commits = rule_facts::read_all(pack, &git, &ids, owners.as_ref()).await?;
245+ change.complete = change.commits.iter().all(|commit| commit.files_complete);
246+ }
247+ None => change.complete = false,
248+ }
249+ } else {
250+ change.complete = true;
251+ }
252+ } else if new.is_none() || !content {
253+ change.complete = true;
254+ }
255+ changes.push(change);
256+ }
257+ let judged: Vec<Judged> = changes
258+ .iter()
259+ .flat_map(|change| judge(&rules.rulesets, &rules.default_branch, facts.kind, change))
260+ .collect();
261+ let sha = changes.iter().find_map(|change| change.new.clone());
262+ self.record_judged(repo, &judged, Action::Push, &facts, sha.as_deref()).await;
263+ if !outcome::refused(&judged) {
264+ return Ok(None);
265+ }
266+ let refused_ref = judged.iter().find(|one| one.blocks()).map(|one| one.git_ref.clone()).unwrap_or_default();
267+ let lines = report::remote_lines(&refused_ref, &judged, &rules_url(repo, &refused_ref));
268+ Ok(Some(crate::git_http::declined(body, &report::ng_reason(&judged), &lines)?))
269+ }
270+
271+ /// Services only: the commits a pull request would land, read as rules
272+ /// look at them, fetched from its source as a pack.
273+ pub(crate) async fn inspect_commits(&self, a: InspectCommitsArgs) -> Result<Outcome<InspectedCommits>> {
274+ let (Some(source), Some(target)) = (self.registry.by_id(&a.source_id).await?, self.registry.by_id(&a.target_id).await?) else {
275+ return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
276+ };
277+ self.live(&source).await?;
278+ let (source_git, target_git) = (self.store.open(&store_key(&source)).await?, self.store.open(&store_key(&target)).await?);
279+ let (history, base_history) =
280+ futures_util::future::try_join(source_git.log(&a.head, MAX_ANCESTRY), target_git.log(&a.base_branch, MAX_ANCESTRY)).await?;
281+ let shared: std::collections::HashSet<String> = base_history.into_iter().map(|commit| commit.hash).collect();
282+ let merge_base = crate::nearest_ancestor_in(&source_git, &history, &shared).await?;
283+ let Some(head) = history.first() else {
284+ return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: true }));
285+ };
286+ let access = source_git.access(crate::store::Scope::Read).await?;
287+ let fetched = crate::land::fetch_pack(&access, &head.hash, merge_base.as_deref()).await?;
288+ if fetched.len() > crate::secret_scan::MAX_SCANNED_PUSH {
289+ return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false }));
290+ }
291+ let pack = match Pack::parse(&fetched) {
292+ Ok(pack) => pack,
293+ Err(problem) => {
294+ worker::console_error!("a pull request's commits could not be read for rules: {problem}");
295+ return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false }));
296+ }
297+ };
298+ let limit = a.limit.map_or(MAX_COMMITS, |limit| (limit as usize).min(MAX_COMMITS));
299+ let Some(ids) = rule_facts::added(&pack, &head.hash, limit) else {
300+ return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false }));
301+ };
302+ let (fingerprints, emails) = rule_facts::signing_facts(&pack, &ids);
303+ let owners = self.signing_owners(&fingerprints, &emails).await;
304+ let commits = rule_facts::read_all(&pack, &source_git, &ids, Some(&owners)).await?;
305+ let complete = commits.iter().all(|commit| commit.files_complete);
306+ Ok(Outcome::Ok(InspectedCommits { commits, complete }))
307+ }
308+}
309+
310+/// The facts of one commit g1t makes itself (a web edit, a catch-up
311+/// merge): it is not signed, and it changes `files`.
312+pub(crate) fn made_commit(sha: &str, message: &str, email: &str, parents: u32, files: Vec<FileChange>) -> CommitFacts {
313+ CommitFacts {
314+ sha: sha.to_owned(),
315+ message: message.to_owned(),
316+ author_email: Some(email.to_owned()),
317+ committer_email: Some(email.to_owned()),
318+ parents,
319+ signature: g1t_contracts::rules::Signature::Unsigned,
320+ files,
321+ files_complete: true,
322+ }
323+}
324+
325+#[cfg(test)]
326+mod tests {
327+ use super::*;
328+ use g1t_contracts::rules::{AppliesTo, Level, NoParameters, RefCondition, RuleEntry};
329+
330+ fn repo() -> Repo {
331+ serde_json::from_value(serde_json::json!({
332+ "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": false,
333+ "ownerId": "usr_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
334+ }))
335+ .unwrap()
336+ }
337+
338+ fn ruleset(enforcement: Enforcement, rule: Rule, applies_to: AppliesTo) -> Applicable {
339+ Applicable {
340+ id: "rs_1".into(),
341+ name: "R".into(),
342+ level: Level::Repository,
343+ enforcement,
344+ target: Target::Branch,
345+ conditions: RefCondition { include: vec!["~ALL".into()], exclude: Vec::new() },
346+ rules: vec![RuleEntry { rule, applies_to }],
347+ bypass: None,
348+ }
349+ }
350+
351+ #[test]
352+ fn rules_are_linked_by_branch_or_tag() {
353+ assert_eq!(rules_url(&repo(), "refs/heads/release/1"), "https://g1t.sh/acme/web/settings/rules?branch=release/1");
354+ assert_eq!(rules_url(&repo(), "refs/tags/v1"), "https://g1t.sh/acme/web/settings/rules?tag=v1");
355+ }
356+
357+ #[test]
358+ fn commits_are_read_only_when_a_rule_for_this_actor_needs_them() {
359+ let signed = ruleset(Enforcement::Evaluate, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Everyone);
360+ assert!(needs_commits(&[signed], Who::Person), "evaluate-mode rulesets are recorded too");
361+ let agents = ruleset(Enforcement::Active, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Agents);
362+ assert!(!needs_commits(std::slice::from_ref(&agents), Who::Person));
363+ assert!(needs_commits(&[agents], Who::Agent));
364+ let off = ruleset(Enforcement::Disabled, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Everyone);
365+ assert!(!needs_commits(&[off], Who::Person));
366+ assert!(needs_ancestry(&[ruleset(Enforcement::Active, Rule::NonFastForward(NoParameters {}), AppliesTo::Everyone)]));
367+ }
368+
369+ #[test]
370+ fn a_commit_g1t_makes_is_unsigned_and_lists_its_files() {
371+ let made = made_commit("abc", "Add CI", "ada@acme.com", 1, vec![FileChange { path: ".g1t/workflows/ci.yml".into(), size: Some(12), deleted: false }]);
372+ assert_eq!(made.signature, g1t_contracts::rules::Signature::Unsigned);
373+ assert!(made.files_complete);
374+ }
375+}
+7−7
7171 }
7272
7373 /// Objects for a walk: the pushed pack's first, then the repository's.
74−struct Objects<'a, R: GitRepo> {
75− pack: &'a Pack,
76− repo: &'a R,
77− reads: Cell<u32>,
74+pub(crate) struct Objects<'a, R: GitRepo> {
75+ pub(crate) pack: &'a Pack,
76+ pub(crate) repo: &'a R,
77+ pub(crate) reads: Cell<u32>,
7878 }
7979
8080 impl<R: GitRepo> Objects<'_, R> {
81− async fn tree(&self, id: &str) -> Result<Vec<TreeItem>> {
81+ pub(crate) async fn tree(&self, id: &str) -> Result<Vec<TreeItem>> {
8282 if let Some(items) = self.pack.tree(id) {
8383 return Ok(items);
8484 }
101101 self.repo.read_blob(id).await
102102 }
103103
104− async fn commit_tree(&self, id: &str) -> Result<Option<String>> {
104+ pub(crate) async fn commit_tree(&self, id: &str) -> Result<Option<String>> {
105105 if let Some(commit) = self.pack.commit(id) {
106106 return Ok(Some(commit.tree));
107107 }
257257 }
258258
259259 /// Fetches what a thin pack's deltas are based on from the repository.
260−async fn supply_bases<R: GitRepo>(pack: &mut Pack, repo: &R) -> Result<()> {
260+pub(crate) async fn supply_bases<R: GitRepo>(pack: &mut Pack, repo: &R) -> Result<()> {
261261 for _ in 0..3 {
262262 let missing = pack.missing_bases();
263263 if missing.is_empty() {
+275−0
1+//! Commit signatures, for the "Require signed commits" rule.
2+//!
3+//! A commit signed with an SSH key carries an `SSHSIG` signature in its
4+//! `gpgsig` header (git's `gpg.format ssh`). It is verified here when the
5+//! key is ed25519: the signature must be valid over the commit without
6+//! that header, in the `git` namespace, and the key must be registered on
7+//! the g1t account that owns the committer's verified email address.
8+//! Signatures with other key types and GPG signatures are reported as not
9+//! verified, with why.
10+
11+use std::collections::HashMap;
12+
13+use base64::Engine;
14+use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
15+use ed25519_dalek::{Signature as Ed25519Signature, Verifier, VerifyingKey};
16+use g1t_contracts::rules::Signature;
17+use sha2::{Digest, Sha256, Sha512};
18+
19+/// A commit's signature as found in its object: the armored text, and the
20+/// bytes it signs (the commit without its `gpgsig` header).
21+#[derive(Debug, PartialEq, Eq)]
22+pub struct Signed {
23+ pub armored: String,
24+ pub payload: Vec<u8>,
25+}
26+
27+/// The signature in a raw commit object, if it has one.
28+pub fn signed(commit: &[u8]) -> Option<Signed> {
29+ let text = std::str::from_utf8(commit).ok()?;
30+ let (headers, message) = text.split_once("\n\n").unwrap_or((text, ""));
31+ let mut payload = String::with_capacity(text.len());
32+ let mut armored = String::new();
33+ let mut in_signature = false;
34+ for line in headers.split('\n') {
35+ if let Some(first) = line.strip_prefix("gpgsig ").or_else(|| line.strip_prefix("gpgsig-sha256 ")) {
36+ in_signature = true;
37+ armored.push_str(first);
38+ armored.push('\n');
39+ continue;
40+ }
41+ if in_signature && let Some(more) = line.strip_prefix(' ') {
42+ armored.push_str(more);
43+ armored.push('\n');
44+ continue;
45+ }
46+ in_signature = false;
47+ payload.push_str(line);
48+ payload.push('\n');
49+ }
50+ if armored.is_empty() {
51+ return None;
52+ }
53+ payload.push('\n');
54+ payload.push_str(message);
55+ Some(Signed { armored, payload: payload.into_bytes() })
56+}
57+
58+/// What reading an SSH signature found: the key's fingerprint, as
59+/// `ssh-keygen -lf` prints it, once the signature checks out.
60+#[derive(Debug, PartialEq, Eq)]
61+pub enum Checked {
62+ /// Valid; owned by whoever registered this key.
63+ Valid { fingerprint: String },
64+ Invalid(String),
65+}
66+
67+fn take<'a>(bytes: &mut &'a [u8], count: usize) -> Option<&'a [u8]> {
68+ if bytes.len() < count {
69+ return None;
70+ }
71+ let (head, rest) = bytes.split_at(count);
72+ *bytes = rest;
73+ Some(head)
74+}
75+
76+fn string<'a>(bytes: &mut &'a [u8]) -> Option<&'a [u8]> {
77+ let length = u32::from_be_bytes(take(bytes, 4)?.try_into().ok()?) as usize;
78+ take(bytes, length)
79+}
80+
81+fn put_string(out: &mut Vec<u8>, value: &[u8]) {
82+ out.extend_from_slice(&(value.len() as u32).to_be_bytes());
83+ out.extend_from_slice(value);
84+}
85+
86+/// Checks an armored signature over `payload`.
87+pub fn check(armored: &str, payload: &[u8]) -> Checked {
88+ let armored = armored.trim();
89+ if armored.starts_with("-----BEGIN PGP SIGNATURE-----") {
90+ return Checked::Invalid("GPG signatures are not verified yet; sign with an SSH key (git config gpg.format ssh).".to_owned());
91+ }
92+ if armored.starts_with("-----BEGIN SIGNED MESSAGE-----") {
93+ return Checked::Invalid("S/MIME signatures are not verified; sign with an SSH key (git config gpg.format ssh).".to_owned());
94+ }
95+ let Some(body) = armored
96+ .strip_prefix("-----BEGIN SSH SIGNATURE-----")
97+ .and_then(|rest| rest.trim().strip_suffix("-----END SSH SIGNATURE-----"))
98+ else {
99+ return Checked::Invalid("the signature is in a format g1t does not read.".to_owned());
100+ };
101+ let encoded: String = body.chars().filter(|c| !c.is_whitespace()).collect();
102+ let Ok(blob) = STANDARD.decode(encoded) else {
103+ return Checked::Invalid("the signature is not valid base64.".to_owned());
104+ };
105+ let invalid = |why: &str| Checked::Invalid(why.to_owned());
106+ let mut rest = blob.as_slice();
107+ if take(&mut rest, 6) != Some(b"SSHSIG".as_slice()) {
108+ return invalid("the signature is not an SSH signature.");
109+ }
110+ if take(&mut rest, 4).map(|version| u32::from_be_bytes(version.try_into().unwrap_or_default())) != Some(1) {
111+ return invalid("the signature's version is not one g1t reads.");
112+ }
113+ let (Some(public_key), Some(namespace), Some(reserved), Some(hash), Some(signature)) =
114+ (string(&mut rest), string(&mut rest), string(&mut rest), string(&mut rest), string(&mut rest))
115+ else {
116+ return invalid("the signature is cut short.");
117+ };
118+ if namespace != b"git" {
119+ return invalid("the signature is not for git commits (its namespace is not git).");
120+ }
121+ let mut key = public_key;
122+ let (Some(key_type), Some(key_bytes)) = (string(&mut key), string(&mut key)) else {
123+ return invalid("the signing key could not be read.");
124+ };
125+ if key_type != b"ssh-ed25519" {
126+ return Checked::Invalid(format!(
127+ "{} keys are not verified yet; sign with an ed25519 key.",
128+ String::from_utf8_lossy(key_type)
129+ ));
130+ }
131+ let mut sig = signature;
132+ let (Some(sig_type), Some(sig_bytes)) = (string(&mut sig), string(&mut sig)) else {
133+ return invalid("the signature could not be read.");
134+ };
135+ if sig_type != b"ssh-ed25519" {
136+ return invalid("the signature does not match its key's type.");
137+ }
138+ let digest: Vec<u8> = match hash {
139+ b"sha512" => Sha512::digest(payload).to_vec(),
140+ b"sha256" => Sha256::digest(payload).to_vec(),
141+ _ => return invalid("the signature uses a hash g1t does not read."),
142+ };
143+ let mut signed_data = b"SSHSIG".to_vec();
144+ put_string(&mut signed_data, namespace);
145+ put_string(&mut signed_data, reserved);
146+ put_string(&mut signed_data, hash);
147+ put_string(&mut signed_data, &digest);
148+ let (Ok(key_bytes), Ok(sig_bytes)) = (<[u8; 32]>::try_from(key_bytes), <[u8; 64]>::try_from(sig_bytes)) else {
149+ return invalid("the key or signature has the wrong length.");
150+ };
151+ let Ok(verifying) = VerifyingKey::from_bytes(&key_bytes) else {
152+ return invalid("the signing key is not a valid ed25519 key.");
153+ };
154+ if verifying.verify(&signed_data, &Ed25519Signature::from_bytes(&sig_bytes)).is_err() {
155+ return invalid("the signature does not match the commit.");
156+ }
157+ Checked::Valid { fingerprint: format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(public_key))) }
158+}
159+
160+/// A commit's signature, decided: `key_owners` maps fingerprints to the
161+/// account (user id) that registered the key, `email_owners` the
162+/// committer's address to the account (id, username) that verified it.
163+pub fn decide(
164+ commit: &[u8],
165+ committer_email: Option<&str>,
166+ key_owners: &HashMap<String, String>,
167+ email_owners: &HashMap<String, (String, String)>,
168+) -> Signature {
169+ let Some(signed) = signed(commit) else {
170+ return Signature::Unsigned;
171+ };
172+ match check(&signed.armored, &signed.payload) {
173+ Checked::Invalid(reason) => Signature::Unverified { reason },
174+ Checked::Valid { fingerprint } => {
175+ let Some(key_owner) = key_owners.get(&fingerprint) else {
176+ return Signature::Unverified {
177+ reason: format!("the key {fingerprint} is not registered on any g1t account."),
178+ };
179+ };
180+ let email = committer_email.unwrap_or_default().to_lowercase();
181+ match email_owners.get(&email) {
182+ Some((id, username)) if id == key_owner => Signature::Verified { signer: username.clone() },
183+ Some(_) => Signature::Unverified {
184+ reason: format!("the key is not registered on the account that owns {email}."),
185+ },
186+ None => Signature::Unverified { reason: format!("{email} is not a verified email address on g1t.") },
187+ }
188+ }
189+ }
190+}
191+
192+/// The fingerprint a valid SSH signature was made with, to look up its
193+/// owner; `None` for anything else.
194+pub fn fingerprint(commit: &[u8]) -> Option<String> {
195+ let signed = signed(commit)?;
196+ match check(&signed.armored, &signed.payload) {
197+ Checked::Valid { fingerprint } => Some(fingerprint),
198+ Checked::Invalid(_) => None,
199+ }
200+}
201+
202+#[cfg(test)]
203+mod tests {
204+ use super::*;
205+
206+ /// Made with `ssh-keygen -Y sign -n git` and a throwaway ed25519 key.
207+ const PAYLOAD: &str = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor Ada <ada@acme.com> 1759800000 +0000\ncommitter Ada <ada@acme.com> 1759800000 +0000\n\nAdd rules\n";
208+ const SIGNATURE: &str = "-----BEGIN SSH SIGNATURE-----
209+U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgtVGsjkmUevm9NOrwhStbNZ7Njn
210+RXkKOw20fds1RA0lwAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
211+AAAAQHcrDRd94FoOk8mWAMZL9v2urJlYdG5OVKiwlbVcgFuc9qmOeP+8ivMA4duwWx0N8P
212+NP89FV6u51GZJ+01SZ5Ag=
213+-----END SSH SIGNATURE-----";
214+ const PUBLIC_KEY: &str = "AAAAC3NzaC1lZDI1NTE5AAAAILVRrI5JlHr5vTTq8IUrWzWezY50V5CjsNtH3bNUQNJc";
215+
216+ /// The commit as git writes it: the signature in a `gpgsig` header,
217+ /// each line after its first indented by a space.
218+ fn commit(message: &str) -> Vec<u8> {
219+ let (headers, _) = PAYLOAD.split_once("\n\n").unwrap();
220+ let sig = SIGNATURE.lines().collect::<Vec<_>>().join("\n ");
221+ format!("{headers}\ngpgsig {sig}\n\n{message}").into_bytes()
222+ }
223+
224+ fn fingerprint_of_key() -> String {
225+ format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(STANDARD.decode(PUBLIC_KEY).unwrap())))
226+ }
227+
228+ #[test]
229+ fn the_payload_is_the_commit_without_its_signature() {
230+ let found = signed(&commit("Add rules\n")).unwrap();
231+ assert_eq!(String::from_utf8(found.payload).unwrap(), PAYLOAD);
232+ assert!(found.armored.starts_with("-----BEGIN SSH SIGNATURE-----\nU1NIU0lH"));
233+ assert_eq!(signed(PAYLOAD.as_bytes()), None);
234+ }
235+
236+ #[test]
237+ fn a_good_ed25519_signature_checks_out() {
238+ assert_eq!(
239+ check(SIGNATURE, PAYLOAD.as_bytes()),
240+ Checked::Valid { fingerprint: fingerprint_of_key() }
241+ );
242+ assert_eq!(fingerprint(&commit("Add rules\n")), Some(fingerprint_of_key()));
243+ }
244+
245+ #[test]
246+ fn a_changed_commit_does_not() {
247+ assert_eq!(
248+ check(SIGNATURE, b"tree 0000\n\nSomething else\n"),
249+ Checked::Invalid("the signature does not match the commit.".into())
250+ );
251+ assert_eq!(fingerprint(&commit("Add rules, sneakily\n")), None);
252+ }
253+
254+ #[test]
255+ fn gpg_and_unknown_formats_are_not_verified() {
256+ assert!(matches!(check("-----BEGIN PGP SIGNATURE-----\nabc\n-----END PGP SIGNATURE-----", b"x"), Checked::Invalid(why) if why.starts_with("GPG signatures")));
257+ assert!(matches!(check("junk", b"x"), Checked::Invalid(_)));
258+ assert!(matches!(check("-----BEGIN SSH SIGNATURE-----\n!!!\n-----END SSH SIGNATURE-----", b"x"), Checked::Invalid(_)));
259+ }
260+
261+ #[test]
262+ fn verified_means_the_key_belongs_to_whoever_owns_the_committer_address() {
263+ let commit = commit("Add rules\n");
264+ let mut keys = HashMap::new();
265+ let mut emails = HashMap::new();
266+ assert!(matches!(decide(&commit, Some("ada@acme.com"), &keys, &emails), Signature::Unverified { reason } if reason.contains("not registered")));
267+ keys.insert(fingerprint_of_key(), "usr_ada".to_owned());
268+ assert!(matches!(decide(&commit, Some("ada@acme.com"), &keys, &emails), Signature::Unverified { reason } if reason.contains("not a verified email")));
269+ emails.insert("ada@acme.com".to_owned(), ("usr_eve".to_owned(), "eve".to_owned()));
270+ assert!(matches!(decide(&commit, Some("Ada@acme.com"), &keys, &emails), Signature::Unverified { reason } if reason.contains("not registered on the account")));
271+ emails.insert("ada@acme.com".to_owned(), ("usr_ada".to_owned(), "ada".to_owned()));
272+ assert_eq!(decide(&commit, Some("ada@acme.com"), &keys, &emails), Signature::Verified { signer: "ada".into() });
273+ assert_eq!(decide(PAYLOAD.as_bytes(), Some("ada@acme.com"), &keys, &emails), Signature::Unsigned);
274+ }
275+}
+4−1
5353 { "binding": "SECURITY", "service": "g1t-security" },
5454 // Whether a workspace far past its free git operations is on the
5555 // plan (src/git_ops.rs); asked only then.
56− { "binding": "BILLING", "service": "g1t-billing" }
56+ { "binding": "BILLING", "service": "g1t-billing" },
57+ // Rulesets: which hold for a push or another change to a branch or
58+ // tag, and where how they judged it is kept (src/rules.rs).
59+ { "binding": "WORK", "service": "g1t-work" }
5760 ],
5861 // A free workspace past this many git operations in a month is slowed
5962 // to this many an hour, never charged (src/git_ops.rs). It is billing's
+12−5
6464 }
6565
6666 /// The workspace an event belongs to when it is about no repository: a
67−/// package of the workspace's own, unlinked from any repository, or one of
68−/// its teams. Such an event goes to the workspace's webhooks only. Events
69−/// about a repository (a team given a role on one among them), and every
70−/// other kind, are `None`: they are routed by their repository.
67+/// package of the workspace's own, unlinked from any repository, one of
68+/// its teams, or one of its rulesets. Such an event goes to the
69+/// workspace's webhooks only. Events about a repository (a team given a
70+/// role on one among them, a repository's own ruleset), and every other
71+/// kind, are `None`: they are routed by their repository.
7172 pub fn workspace_scoped(event: &Event) -> Option<String> {
72− let own = event.kind.starts_with("package.") || event.kind.starts_with("team.");
73+ let own = event.kind.starts_with("package.") || event.kind.starts_with("team.") || event.kind.starts_with("ruleset.");
7374 if event.repo_id.as_deref().is_some_and(|id| !id.is_empty()) || !own {
7475 return None;
7576 }
246247 assert_eq!(workspace_scoped(&team).as_deref(), Some("acme"));
247248 let granted = repo_event("team.repo_added", json!({ "workspace": "acme", "repoId": "rep_1" }));
248249 assert_eq!(workspace_scoped(&granted), None);
250+ // A workspace's ruleset is the workspace's; a repository's goes by it.
251+ let mut ruleset = repo_event("ruleset.updated", json!({ "workspace": "acme", "ruleset": {} }));
252+ ruleset.repo_id = None;
253+ assert_eq!(workspace_scoped(&ruleset).as_deref(), Some("acme"));
254+ let own = repo_event("ruleset.created", json!({ "workspace": "acme", "repository": "acme/web" }));
255+ assert_eq!(workspace_scoped(&own), None);
249256 // Other events without a repository are not workspace events.
250257 let mut other = repo_event("issue.opened", json!({ "workspace": "acme" }));
251258 other.repo_id = None;