Skip to content
393 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Workflow files need workflow_files:write from a token; fine-grained permission table1//! Workflow files: a token adds, changes or deletes files under
2//! `.g1t/workflows/` or `.github/workflows/` only with the
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers3//! `workflow_files:write` scope (a token's Workflow files: write
Workflow files need workflow_files:write from a token; fine-grained permission table4//! permission). A workflow job's token never may. Without the gate, a token
5//! that can push code could write a workflow that runs with the
6//! repository's secrets and a stronger token than its own.
7//!
8//! A push is checked commit by commit: every commit it adds is compared
9//! with its first parent, looking only at the two workflow directories, so
10//! the check is complete however many other files a commit changes. A push
11//! too large to be read whole is refused for such a token, since what it
12//! holds cannot be checked. A signed-in person (no token) is never refused
13//! here, and neither is a token that has the scope: their roles and the
14//! repository's rules decide.
15
16use std::cell::Cell;
17
18use g1t_contracts::User;
19use g1t_contracts::scopes::{TokenAccess, WORKFLOW_DIRS, decide_workflow_files};
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer20use g1t_scan::pack::{ObjectKind, Pack, TreeItem};
21use worker::Result;
Workflow files need workflow_files:write from a token; fine-grained permission table22
23use crate::rule_facts::{self, MAX_COMMITS};
24use crate::secret_scan::Objects;
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer25use crate::store::GitRepo;
Workflow files need workflow_files:write from a token; fine-grained permission table26
27/// The token behind a push or an edit, when it is one this gate checks:
28/// any token without `workflow_files:write`, and every job's token.
29pub(crate) fn gated(actor: Option<&User>) -> Option<&TokenAccess> {
30 let token = actor?.token.as_deref()?;
31 decide_workflow_files(Some(token), [WORKFLOW_DIRS[0]]).map(|_| token)
32}
33
34/// The id of the tree at `dir` (such as `.github/workflows/`) under the
35/// tree `root`, if there is one.
36async fn subtree<R: GitRepo>(objects: &Objects<'_, R>, root: &str, dir: &str) -> Result<Option<String>> {
37 let mut id = root.to_owned();
38 for part in dir.trim_end_matches('/').split('/') {
39 let items = objects.tree(&id).await?;
40 match items.into_iter().find(|item| item.name == part && item.is_tree()) {
41 Some(item) => id = item.id,
42 None => return Ok(None),
43 }
44 }
45 Ok(Some(id))
46}
47
48/// The first entry that differs between two listings of one directory.
49fn first_difference(old: &[TreeItem], new: &[TreeItem]) -> Option<String> {
50 new.iter()
51 .find(|item| !old.iter().any(|before| before.name == item.name && before.id == item.id && before.mode == item.mode))
52 .or_else(|| old.iter().find(|item| !new.iter().any(|after| after.name == item.name)))
53 .map(|item| item.name.clone())
54}
55
56/// The first workflow file that differs between two root trees (`None`
57/// for a commit with no parent), as a path.
58pub(crate) async fn changed_between<R: GitRepo>(objects: &Objects<'_, R>, old_root: Option<&str>, new_root: &str) -> Result<Option<String>> {
59 for dir in WORKFLOW_DIRS {
60 let old = match old_root {
61 Some(root) => subtree(objects, root, dir).await?,
62 None => None,
63 };
64 let new = subtree(objects, new_root, dir).await?;
65 if old == new {
66 continue;
67 }
68 let old_items = match &old {
69 Some(id) => objects.tree(id).await?,
70 None => Vec::new(),
71 };
72 let new_items = match &new {
73 Some(id) => objects.tree(id).await?,
74 None => Vec::new(),
75 };
76 let name = first_difference(&old_items, &new_items).unwrap_or_default();
77 return Ok(Some(format!("{dir}{name}")));
78 }
79 Ok(None)
80}
81
82/// The first workflow file one of `ids` (commits the pack holds) changes
83/// against its first parent.
84pub(crate) async fn changed_in_commits<R: GitRepo>(pack: &Pack, repo: &R, ids: &[String]) -> Result<Option<String>> {
85 let objects = Objects { pack, repo, reads: Cell::new(0) };
86 for id in ids {
87 let Some((ObjectKind::Commit, data)) = pack.get(id) else {
88 continue;
89 };
90 let commit = rule_facts::read_commit(data);
91 let old_root = match commit.parents.first() {
92 Some(parent) => objects.commit_tree(parent).await?,
93 None => None,
94 };
95 if let Some(path) = changed_between(&objects, old_root.as_deref(), &commit.tree).await? {
96 return Ok(Some(path));
97 }
98 }
99 Ok(None)
100}
101
102/// Why a push is refused, as the reason git shows beside each ref and the
103/// lines it prints, when `token` may not change workflow files and the
104/// push (`body`, read `whole` or not) does or cannot be checked.
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer105#[cfg(test)]
Workflow files need workflow_files:write from a token; fine-grained permission table106pub(crate) async fn judge_push<R: GitRepo>(token: &TokenAccess, body: &[u8], whole: bool, git: &R) -> Result<Option<(&'static str, Vec<String>)>> {
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer107 let mut pack = whole.then(|| crate::push_checks::read_pack(body));
108 if let Some(Ok(pack)) = &mut pack {
109 crate::secret_scan::supply_bases(pack, git).await?;
110 }
111 judge_pack(token, body, pack.as_ref(), git).await
112}
113
114/// [`judge_push`] for a push whose pack was read already, with its bases
115/// supplied (push_checks.rs); `None` when it was not read whole.
116pub(crate) async fn judge_pack<R: GitRepo>(
117 token: &TokenAccess,
118 body: &[u8],
119 pack: Option<&std::result::Result<Pack, String>>,
120 git: &R,
121) -> Result<Option<(&'static str, Vec<String>)>> {
Workflow files need workflow_files:write from a token; fine-grained permission table122 let updates = crate::git_http::ref_updates(body);
123 if updates.iter().all(|(_, _, new)| new.is_none()) {
124 return Ok(None);
125 }
126 let too_large = |line: String| Ok(Some(("push too large to check for workflow files", vec![line, "Push in smaller parts, or with a token that has the workflow_files:write scope.".to_owned()])));
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer127 let Some(pack) = pack else {
Workflow files need workflow_files:write from a token; fine-grained permission table128 return too_large("This push is too large for g1t to check whether it changes workflow files, and this token may not change them.".to_owned());
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer129 };
130 // A push with no pack moves refs to commits the repository has: an
131 // empty pack, which changes nothing.
132 let pack = match pack {
133 Ok(pack) => pack,
134 Err(problem) => {
Workflow files need workflow_files:write from a token; fine-grained permission table135 worker::console_error!("a push's pack could not be read for workflow files: {problem}");
136 return Ok(Some(("push could not be checked for workflow files", vec!["g1t could not read this push to check it for workflow files. Push again.".to_owned()])));
137 }
138 };
139 for (_, _, new) in updates {
140 let Some(new) = new else { continue };
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer141 let Some(ids) = rule_facts::added(pack, &new, MAX_COMMITS) else {
Workflow files need workflow_files:write from a token; fine-grained permission table142 return too_large(format!("This push adds more than {MAX_COMMITS} commits to one ref, too many for g1t to check for workflow files, and this token may not change them."));
143 };
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer144 if let Some(path) = changed_in_commits(pack, git, &ids).await? {
Workflow files need workflow_files:write from a token; fine-grained permission table145 let reason = decide_workflow_files(Some(token), [path.as_str()])
146 .and_then(|decision| decision.reason)
147 .unwrap_or_else(|| format!("This access token cannot change the workflow file {path}."));
148 return Ok(Some((
149 "workflow files need the workflow_files:write scope",
150 vec![reason, "Push with a token that has the workflow_files:write scope, or make the change signed in on g1t.sh.".to_owned()],
151 )));
152 }
153 }
154 Ok(None)
155}
156
157#[cfg(test)]
158mod tests {
159 use std::collections::HashMap;
160 use std::future::Future;
161 use std::pin::pin;
162 use std::task::{Context, Poll, Waker};
163
164 use g1t_contracts::repos::{Branch, Commit, EntryKind, GitAccess, Signature, TreeEntry};
165 use g1t_contracts::scopes::{JobToken, Scope};
166 use g1t_scan::pack::{encode_tree, object_id, write_pack};
167
168 use super::*;
169 use crate::store::Scope as StoreScope;
170
171 fn run<F: Future>(future: F) -> F::Output {
172 match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) {
173 Poll::Ready(output) => output,
174 Poll::Pending => panic!("the fake store never waits"),
175 }
176 }
177
178 /// The repository before the push: one commit, with its trees.
179 #[derive(Default)]
180 struct Fake {
181 trees: HashMap<String, Vec<TreeEntry>>,
182 commits: HashMap<String, Commit>,
183 }
184
185 impl GitRepo for Fake {
186 async fn access(&self, _scope: StoreScope) -> Result<GitAccess> {
187 unimplemented!()
188 }
189 async fn branches(&self) -> Result<Vec<Branch>> {
190 Ok(Vec::new())
191 }
192 async fn log(&self, git_ref: &str, _limit: u32) -> Result<Vec<Commit>> {
193 Ok(self.commits.get(git_ref).cloned().into_iter().collect())
194 }
195 async fn parents(&self, commit_hash: &str) -> Result<Option<Vec<String>>> {
196 Ok(self.commits.get(commit_hash).map(|commit| commit.parents.clone()))
197 }
198 async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> {
199 Ok(self.trees.get(tree_hash).cloned())
200 }
201 async fn read_blob(&self, _blob_hash: &str) -> Result<Option<Vec<u8>>> {
202 Ok(None)
203 }
204 async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> {
205 Ok(None)
206 }
207 async fn fork(&self, _target_key: &str) -> Result<()> {
208 Ok(())
209 }
210 }
211
212 fn item(mode: &str, name: &str, id: &str) -> TreeItem {
213 TreeItem { mode: mode.into(), name: name.into(), id: id.into() }
214 }
215
216 /// Objects for one tree layout: a root with `dir/workflows/<file>`
217 /// holding `content`, and `README.md`.
218 struct Layout {
219 objects: Vec<(ObjectKind, Vec<u8>)>,
220 root: String,
221 }
222
223 fn layout(dir: &str, file: &str, content: &str, readme: &str) -> Layout {
224 let mut objects = Vec::new();
225 let mut add = |kind: ObjectKind, data: Vec<u8>| {
226 let id = object_id(kind, &data);
227 objects.push((kind, data));
228 id
229 };
230 let workflow = add(ObjectKind::Blob, content.as_bytes().to_vec());
231 let readme = add(ObjectKind::Blob, readme.as_bytes().to_vec());
232 let workflows = add(ObjectKind::Tree, encode_tree(&[item("100644", file, &workflow)]));
233 let parent = add(ObjectKind::Tree, encode_tree(&[item("40000", "workflows", &workflows)]));
234 let root = add(ObjectKind::Tree, encode_tree(&[item("40000", dir, &parent), item("100644", "README.md", &readme)]));
235 Layout { objects, root }
236 }
237
238 fn commit(tree: &str, parent: Option<&str>) -> (String, Vec<u8>) {
239 let parent = parent.map(|parent| format!("parent {parent}\n")).unwrap_or_default();
240 let data = format!("tree {tree}\n{parent}author A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n\nchange\n").into_bytes();
241 (object_id(ObjectKind::Commit, &data), data)
242 }
243
244 /// The first workflow file a push of `after` on top of `before` changes.
245 fn check(before: &Layout, after: &Layout) -> Option<String> {
246 // The repository holds `before` and its commit; the pack, `after`.
247 let mut repo = Fake::default();
248 let base = Pack::parse(&write_pack(&before.objects)).unwrap();
249 for (kind, data) in &before.objects {
250 if *kind == ObjectKind::Tree {
251 let id = object_id(*kind, data);
252 let entries = base
253 .tree(&id)
254 .unwrap()
255 .into_iter()
256 .map(|item| TreeEntry { name: item.name.clone(), hash: item.id.clone(), kind: if item.is_tree() { EntryKind::Tree } else { EntryKind::Blob } })
257 .collect();
258 repo.trees.insert(id, entries);
259 }
260 }
261 let (base_id, _) = commit(&before.root, None);
262 repo.commits.insert(
263 base_id.clone(),
264 Commit { hash: base_id.clone(), tree_hash: before.root.clone(), message: String::new(), author: Signature { name: String::new(), email: String::new() }, parents: Vec::new(), authored_at: String::new() },
265 );
266 let (tip, data) = commit(&after.root, Some(&base_id));
267 let mut objects = after.objects.clone();
268 objects.push((ObjectKind::Commit, data));
269 let pack = Pack::parse(&write_pack(&objects)).unwrap();
270 run(changed_in_commits(&pack, &repo, &[tip])).unwrap()
271 }
272
273 #[test]
274 fn a_push_that_changes_a_workflow_is_named_by_its_file() {
275 let before = layout(".github", "ci.yml", "on: push", "hello");
276 let changed = layout(".github", "ci.yml", "on: [push, pull_request]", "hello");
277 assert_eq!(check(&before, &changed).as_deref(), Some(".github/workflows/ci.yml"));
278 let added = layout(".github", "deploy.yml", "on: push", "hello");
279 assert!(check(&before, &added).unwrap().starts_with(".github/workflows/"));
280 // The g1t directory too, added where there was none.
281 let g1t = layout(".g1t", "ci.yml", "on: push", "hello");
282 assert_eq!(check(&before, &g1t).as_deref(), Some(".g1t/workflows/ci.yml"));
283 }
284
285 /// A receive-pack request moving `main` from `old` to `new`, with the pack.
286 fn receive_pack(old: &str, new: &str, pack: &[u8]) -> Vec<u8> {
287 let command = format!("{old} {new} refs/heads/main\0report-status side-band-64k\n");
288 let mut body = format!("{:04x}", command.len() + 4).into_bytes();
289 body.extend_from_slice(command.as_bytes());
290 body.extend_from_slice(b"0000");
291 body.extend_from_slice(pack);
292 body
293 }
294
295 /// The repository holding `before` at its commit, and a push of `after`
296 /// on top of it: the refusal, if any, for `pusher`'s token.
297 fn push(before: &Layout, after: &Layout, pusher: &User, whole: bool) -> Option<(&'static str, Vec<String>)> {
298 let mut repo = Fake::default();
299 let base = Pack::parse(&write_pack(&before.objects)).unwrap();
300 for (kind, data) in &before.objects {
301 if *kind == ObjectKind::Tree {
302 let id = object_id(*kind, data);
303 let entries = base
304 .tree(&id)
305 .unwrap()
306 .into_iter()
307 .map(|item| TreeEntry { name: item.name.clone(), hash: item.id.clone(), kind: if item.is_tree() { EntryKind::Tree } else { EntryKind::Blob } })
308 .collect();
309 repo.trees.insert(id, entries);
310 }
311 }
312 let (base_id, _) = commit(&before.root, None);
313 repo.commits.insert(
314 base_id.clone(),
315 Commit { hash: base_id.clone(), tree_hash: before.root.clone(), message: String::new(), author: Signature { name: String::new(), email: String::new() }, parents: Vec::new(), authored_at: String::new() },
316 );
317 let (tip, data) = commit(&after.root, Some(&base_id));
318 let mut objects = after.objects.clone();
319 objects.push((ObjectKind::Commit, data));
320 let body = receive_pack(&base_id, &tip, &write_pack(&objects));
321 let token = gated(Some(pusher))?;
322 run(judge_push(token, &body, whole, &repo)).unwrap()
323 }
324
325 #[test]
326 fn a_git_push_of_a_workflow_change_is_declined_for_a_token_without_the_scope() {
327 let before = layout(".github", "ci.yml", "on: push", "hello");
328 let changed = layout(".github", "ci.yml", "on: [push, pull_request]\njobs: {}", "hello");
329 let (reason, lines) = push(&before, &changed, &token(&[Scope::CodeWrite]), true).expect("declined");
330 assert_eq!(reason, "workflow files need the workflow_files:write scope");
331 assert!(lines[0].contains(".github/workflows/ci.yml"), "{lines:?}");
332 assert!(lines[0].contains("workflow_files:write"), "{lines:?}");
333 // With the scope, or full access, it goes through.
334 assert!(push(&before, &changed, &token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]), true).is_none());
335 // A push that changes other files goes through without it.
336 let readme = layout(".github", "ci.yml", "on: push", "hello, world");
337 assert!(push(&before, &readme, &token(&[Scope::CodeWrite]), true).is_none());
338 // One too large to read whole cannot be checked, so it is declined.
339 let (reason, _) = push(&before, &readme, &token(&[Scope::CodeWrite]), false).expect("declined");
340 assert_eq!(reason, "push too large to check for workflow files");
341 }
342
343 #[test]
344 fn a_job_token_never_pushes_workflow_changes() {
345 let before = layout(".g1t", "ci.yml", "on: push", "hello");
346 let changed = layout(".g1t", "ci.yml", "on: workflow_dispatch", "hello");
347 let mut job = token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]);
348 job.token.as_mut().unwrap().job = Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false });
349 let (_, lines) = push(&before, &changed, &job, true).expect("declined");
350 assert!(lines[0].contains("workflow job's token"), "{lines:?}");
351 }
352
353 #[test]
354 fn a_push_that_leaves_workflows_alone_passes() {
355 let before = layout(".github", "ci.yml", "on: push", "hello");
356 let readme = layout(".github", "ci.yml", "on: push", "hello, world");
357 assert_eq!(check(&before, &readme), None);
358 }
359
360 fn token(scopes: &[Scope]) -> User {
361 User {
362 token: Some(Box::new(TokenAccess {
363 token_id: "tok_1".into(),
364 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
365 ..TokenAccess::default()
366 })),
367 ..User::default()
368 }
369 }
370
371 #[test]
372 fn who_the_gate_checks() {
373 assert!(gated(None).is_none(), "nobody");
374 assert!(gated(Some(&User::default())).is_none(), "a signed-in person");
375 assert!(gated(Some(&token(&[Scope::CodeWrite]))).is_some(), "a token that may push code only");
376 assert!(gated(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]))).is_none());
377 let full = User { token: Some(Box::new(TokenAccess::full())), ..User::default() };
378 assert!(gated(Some(&full)).is_none(), "full access includes workflow files");
379 let mut job = token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]);
380 job.token.as_mut().unwrap().job = Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false });
381 assert!(gated(Some(&job)).is_some(), "a job's token, whatever it holds");
382 }
383
384 #[test]
385 fn the_first_difference_names_added_changed_and_removed_entries() {
386 let a = item("100644", "a.yml", "1");
387 let b = item("100644", "b.yml", "2");
388 assert_eq!(first_difference(&[a.clone()], &[a.clone(), b.clone()]).as_deref(), Some("b.yml"));
389 assert_eq!(first_difference(&[a.clone(), b.clone()], &[a.clone()]).as_deref(), Some("b.yml"));
390 assert_eq!(first_difference(&[a.clone()], &[item("100644", "a.yml", "3")]).as_deref(), Some("a.yml"));
391 assert_eq!(first_difference(&[a.clone()], &[a]), None);
392 }
393}