Workflow files need workflow_files:write from a token; fine-grained permission table
10 files+922−00/10 viewed
| 856 | 856 | workspaces: vec![Membership::member("acme")], | |
| 857 | 857 | ..User::default() | |
| 858 | 858 | }; | |
| 859 | + | // Acting as the workspace with no token: a service, which does what | |
| 860 | + | // an owner can. | |
| 859 | 861 | assert_eq!(permission(Some(&token), repo("rep_1", "acme", true)), Some(RepoRole::Admin)); | |
| 860 | 862 | assert_eq!(permission(Some(&token), repo("rep_2", "globex", true)), None); | |
| 861 | 863 | } | |
| 862 | 864 | ||
| 863 | 865 | #[test] | |
| 866 | + | fn a_workspace_token_has_write_unless_an_owner_gave_it_admin() { | |
| 867 | + | let mut token = User { | |
| 868 | + | id: "wsp_1".into(), | |
| 869 | + | username: "acme".into(), | |
| 870 | + | kind: PrincipalKind::Workspace, | |
| 871 | + | workspaces: vec![Membership::member("acme")], | |
| 872 | + | token: Some(Box::new(crate::scopes::TokenAccess::full())), | |
| 873 | + | ..User::default() | |
| 874 | + | }; | |
| 875 | + | let private = repo("rep_1", "acme", true); | |
| 876 | + | assert_eq!(permission(Some(&token), private), Some(RepoRole::Write)); | |
| 877 | + | assert!(can(Some(&token), private, Capability::Push)); | |
| 878 | + | assert!(can(Some(&token), private, Capability::Merge)); | |
| 879 | + | assert!(!can(Some(&token), private, Capability::ManageIntegrations), "webhooks, secrets and deploy keys are an admin's"); | |
| 880 | + | assert!(!can(Some(&token), private, Capability::ManageAccess)); | |
| 881 | + | token.token.as_mut().unwrap().admin = true; | |
| 882 | + | assert_eq!(permission(Some(&token), private), Some(RepoRole::Admin)); | |
| 883 | + | assert!(can(Some(&token), private, Capability::ManageIntegrations)); | |
| 884 | + | // Never deleting: that needs an owner, as a person. | |
| 885 | + | assert!(!can(Some(&token), private, Capability::Delete)); | |
| 886 | + | assert_eq!(permission(Some(&token), repo("rep_2", "globex", true)), None); | |
| 887 | + | } | |
| 888 | + | ||
| 889 | + | #[test] | |
| 890 | + | fn a_fine_grained_token_has_a_role_only_inside_its_reach() { | |
| 891 | + | use crate::scopes::{FineGrainedReach, RepositorySelection, TokenAccess}; | |
| 892 | + | let mut person = user(&[("acme", Role::Owner, None), ("globex", Role::Member, None)], &[("rep_9", "initech", RepoRole::Write)]); | |
| 893 | + | let reach = |workspace: Option<&str>, repositories, ids: &[&str]| { | |
| 894 | + | Some(Box::new(TokenAccess { | |
| 895 | + | fine_grained: Some(FineGrainedReach { workspace: workspace.map(str::to_owned), repositories, repo_ids: ids.iter().map(|id| (*id).to_owned()).collect() }), | |
| 896 | + | ..TokenAccess::default() | |
| 897 | + | })) | |
| 898 | + | }; | |
| 899 | + | let web = repo("rep_1", "acme", true); | |
| 900 | + | let api = repo("rep_2", "acme", true); | |
| 901 | + | let site = repo("rep_3", "acme", false); | |
| 902 | + | let elsewhere = repo("rep_4", "globex", true); | |
| 903 | + | person.token = reach(Some("acme"), RepositorySelection::All, &[]); | |
| 904 | + | assert_eq!(permission(Some(&person), web), Some(RepoRole::Admin)); | |
| 905 | + | assert_eq!(permission(Some(&person), elsewhere), None, "only its resource owner"); | |
| 906 | + | assert_eq!(permission(Some(&person), repo("rep_9", "initech", true)), None, "nor where its owner collaborates"); | |
| 907 | + | person.token = reach(Some("acme"), RepositorySelection::Selected, &["rep_1"]); | |
| 908 | + | assert_eq!(permission(Some(&person), web), Some(RepoRole::Admin)); | |
| 909 | + | assert_eq!(permission(Some(&person), api), None); | |
| 910 | + | // A public repository it does not reach still reads, and nothing more. | |
| 911 | + | assert_eq!(permission(Some(&person), site), Some(RepoRole::Read)); | |
| 912 | + | assert!(can(Some(&person), site, Capability::Read)); | |
| 913 | + | assert!(!can(Some(&person), site, Capability::Participate)); | |
| 914 | + | person.token = reach(Some("acme"), RepositorySelection::Public, &[]); | |
| 915 | + | assert_eq!(permission(Some(&person), web), None); | |
| 916 | + | assert_eq!(permission(Some(&person), site), Some(RepoRole::Read)); | |
| 917 | + | person.token = reach(None, RepositorySelection::All, &[]); | |
| 918 | + | assert_eq!(permission(Some(&person), web), None, "your own account reaches no workspace's repositories"); | |
| 919 | + | // A classic token reaches whatever its owner can. | |
| 920 | + | person.token = Some(Box::new(TokenAccess::full())); | |
| 921 | + | assert_eq!(permission(Some(&person), elsewhere), Some(RepoRole::Write)); | |
| 922 | + | } | |
| 923 | + | ||
| 924 | + | #[test] | |
| 864 | 925 | fn roles_and_base_permissions_read_and_write_as_words() { | |
| 865 | 926 | for role in RepoRole::ALL { | |
| 866 | 927 | assert_eq!(RepoRole::parse(role.as_str()), Some(role)); |
| 1 | + | //! Fine-grained personal access tokens: what each permission is, and the | |
| 2 | + | //! scopes it gives. | |
| 3 | + | //! | |
| 4 | + | //! A fine-grained token names one resource owner (the person's own | |
| 5 | + | //! account, or one workspace), which of that workspace's repositories it | |
| 6 | + | //! reaches (all, selected, or public ones only), and a level for each | |
| 7 | + | //! permission: none, read or write (admin for the few that have it). The | |
| 8 | + | //! permission names are the ones GitHub's fine-grained tokens use, so a | |
| 9 | + | //! token's settings read the same there and here; g1t-only ones (agents, | |
| 10 | + | //! memory) sit beside them. | |
| 11 | + | //! | |
| 12 | + | //! Each level maps onto g1t's own scopes ([`crate::scopes`]), and the token | |
| 13 | + | //! stores them: every check that reads a classic token's scopes reads a | |
| 14 | + | //! fine-grained token's the same way. Where g1t has one scope for what | |
| 15 | + | //! GitHub splits in two (checks and statuses, secrets and variables), both | |
| 16 | + | //! permissions give the same scopes, and each says so. | |
| 17 | + | //! | |
| 18 | + | //! `packages/contracts/src/fine-grained.ts` mirrors the table; a test here | |
| 19 | + | //! keeps the two the same. | |
| 20 | + | ||
| 21 | + | use std::collections::BTreeMap; | |
| 22 | + | ||
| 23 | + | use serde::{Deserialize, Serialize}; | |
| 24 | + | ||
| 25 | + | use crate::scopes::{Scope, normalize}; | |
| 26 | + | ||
| 27 | + | /// Where a permission is shown, and which resource owner it needs. | |
| 28 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 29 | + | #[serde(rename_all = "snake_case")] | |
| 30 | + | pub enum PermissionGroup { | |
| 31 | + | /// About repositories: needs a workspace as the resource owner. | |
| 32 | + | Repository, | |
| 33 | + | /// About the workspace itself: needs a workspace as the resource owner. | |
| 34 | + | Workspace, | |
| 35 | + | /// About the person: needs their own account as the resource owner. | |
| 36 | + | Account, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | impl PermissionGroup { | |
| 40 | + | pub fn as_str(self) -> &'static str { | |
| 41 | + | match self { | |
| 42 | + | PermissionGroup::Repository => "repository", | |
| 43 | + | PermissionGroup::Workspace => "workspace", | |
| 44 | + | PermissionGroup::Account => "account", | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// How much of one permission. | |
| 50 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] | |
| 51 | + | #[serde(rename_all = "snake_case")] | |
| 52 | + | pub enum Access { | |
| 53 | + | #[default] | |
| 54 | + | None, | |
| 55 | + | Read, | |
| 56 | + | Write, | |
| 57 | + | Admin, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | impl Access { | |
| 61 | + | pub fn as_str(self) -> &'static str { | |
| 62 | + | match self { | |
| 63 | + | Access::None => "none", | |
| 64 | + | Access::Read => "read", | |
| 65 | + | Access::Write => "write", | |
| 66 | + | Access::Admin => "admin", | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | pub fn parse(text: &str) -> Option<Access> { | |
| 71 | + | match text.trim().to_ascii_lowercase().as_str() { | |
| 72 | + | "none" | "no_access" | "" => Some(Access::None), | |
| 73 | + | "read" | "read_only" => Some(Access::Read), | |
| 74 | + | "write" | "read_write" | "read_and_write" => Some(Access::Write), | |
| 75 | + | "admin" => Some(Access::Admin), | |
| 76 | + | _ => None, | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// One permission a fine-grained token can be given. | |
| 82 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 83 | + | pub struct Permission { | |
| 84 | + | /// As the API and the form name it, such as `pull_requests`. | |
| 85 | + | pub name: &'static str, | |
| 86 | + | pub label: &'static str, | |
| 87 | + | pub group: PermissionGroup, | |
| 88 | + | /// What it covers, in plain words. | |
| 89 | + | pub about: &'static str, | |
| 90 | + | /// The scopes reading gives; empty when it has no read level (written | |
| 91 | + | /// to only, such as workflows). | |
| 92 | + | pub read: &'static [Scope], | |
| 93 | + | /// The scopes writing gives, besides reading's. | |
| 94 | + | pub write: &'static [Scope], | |
| 95 | + | /// The scopes admin gives, besides writing's; empty when it has none. | |
| 96 | + | pub admin: &'static [Scope], | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | impl Permission { | |
| 100 | + | /// The levels it can be set to, least first, none excluded. | |
| 101 | + | pub fn levels(&self) -> Vec<Access> { | |
| 102 | + | let mut levels = Vec::new(); | |
| 103 | + | if !self.read.is_empty() { | |
| 104 | + | levels.push(Access::Read); | |
| 105 | + | } | |
| 106 | + | if !self.write.is_empty() { | |
| 107 | + | levels.push(Access::Write); | |
| 108 | + | } | |
| 109 | + | if !self.admin.is_empty() { | |
| 110 | + | levels.push(Access::Admin); | |
| 111 | + | } | |
| 112 | + | levels | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// The scopes `access` gives, lower levels' included. | |
| 116 | + | pub fn scopes(&self, access: Access) -> Vec<Scope> { | |
| 117 | + | let mut scopes = Vec::new(); | |
| 118 | + | if access >= Access::Read { | |
| 119 | + | scopes.extend_from_slice(self.read); | |
| 120 | + | } | |
| 121 | + | if access >= Access::Write { | |
| 122 | + | scopes.extend_from_slice(self.write); | |
| 123 | + | } | |
| 124 | + | if access >= Access::Admin { | |
| 125 | + | scopes.extend_from_slice(self.admin); | |
| 126 | + | } | |
| 127 | + | scopes | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | use PermissionGroup::{Account as A, Repository as R, Workspace as W}; | |
| 132 | + | ||
| 133 | + | /// Every permission, in the order the form shows them. | |
| 134 | + | pub const PERMISSIONS: [Permission; 29] = [ | |
| 135 | + | // Repository permissions. | |
| 136 | + | Permission { name: "actions", label: "Actions", group: R, about: "Workflow runs, jobs, logs and artifacts: reading them, and running, cancelling and rerunning workflows", read: &[Scope::WorkflowsRead], write: &[Scope::WorkflowsWrite], admin: &[] }, | |
| 137 | + | Permission { name: "administration", label: "Administration", group: R, about: "Repository settings, rulesets, who has access and deploy keys; renaming, archiving, transferring and deleting", read: &[Scope::RepoRead, Scope::AccessRead], write: &[Scope::RepoAdmin, Scope::AccessAdmin], admin: &[] }, | |
| 138 | + | Permission { name: "agents", label: "g1t agents", group: R, about: "Putting g1t's agents to work and messaging them, which uses the workspace's money", read: &[], write: &[Scope::AgentsRun], admin: &[] }, | |
| 139 | + | Permission { name: "checks", label: "Checks", group: R, about: "Check runs and check suites on commits. Shares its scopes with Commit statuses", read: &[Scope::ChecksRead], write: &[Scope::ChecksWrite], admin: &[] }, | |
| 140 | + | Permission { name: "contents", label: "Contents", group: R, about: "Code, branches, commits and releases: cloning and fetching, pushing, and publishing releases", read: &[Scope::CodeRead], write: &[Scope::CodeWrite, Scope::RepoWrite], admin: &[] }, | |
| 141 | + | Permission { name: "deployments", label: "Deployments", group: R, about: "Deployments and their statuses", read: &[Scope::DeploymentsRead], write: &[Scope::DeploymentsWrite], admin: &[] }, | |
| 142 | + | Permission { name: "environments", label: "Environments", group: R, about: "Environments, and their secrets and variables", read: &[Scope::DeploymentsRead, Scope::SecretsRead], write: &[Scope::SecretsAdmin], admin: &[] }, | |
| 143 | + | Permission { name: "issues", label: "Issues", group: R, about: "Issues, their comments, labels and milestones, and plans", read: &[Scope::IssuesRead], write: &[Scope::IssuesWrite], admin: &[] }, | |
| 144 | + | Permission { name: "memory", label: "Memory and context", group: R, about: "Recalling memory and searching the workspace's context, and saving memory for the next agent", read: &[Scope::MemoryRead], write: &[Scope::MemoryWrite], admin: &[] }, | |
| 145 | + | Permission { name: "metadata", label: "Metadata", group: R, about: "Seeing repositories and searching them. Always read", read: &[Scope::RepoRead], write: &[], admin: &[] }, | |
| 146 | + | Permission { name: "packages", label: "Packages", group: R, about: "Pulling private packages, publishing them, and (admin) deleting packages and versions", read: &[Scope::PackagesRead], write: &[Scope::PackagesWrite], admin: &[Scope::PackagesDelete] }, | |
| 147 | + | Permission { name: "pages", label: "Pages", group: R, about: "Deployments on g1t.page. Shares its scopes with Deployments", read: &[Scope::DeploymentsRead], write: &[Scope::DeploymentsWrite], admin: &[] }, | |
| 148 | + | Permission { name: "pull_requests", label: "Pull requests", group: R, about: "Pull requests, their reviews, changes, sessions and merge queues", read: &[Scope::PullRequestsRead], write: &[Scope::PullRequestsWrite], admin: &[] }, | |
| 149 | + | Permission { name: "secrets", label: "Secrets", group: R, about: "Actions secrets: listing them (never their values), setting and deleting them. Shares its scopes with Variables", read: &[Scope::SecretsRead], write: &[Scope::SecretsAdmin], admin: &[] }, | |
| 150 | + | Permission { name: "security_events", label: "Security events and alerts", group: R, about: "Code scanning, secret scanning and vulnerability alerts, SARIF uploads and security settings", read: &[Scope::SecurityRead], write: &[Scope::SecurityWrite], admin: &[] }, | |
| 151 | + | Permission { name: "statuses", label: "Commit statuses", group: R, about: "Statuses on commits. Shares its scopes with Checks", read: &[Scope::ChecksRead], write: &[Scope::ChecksWrite], admin: &[] }, | |
| 152 | + | Permission { name: "variables", label: "Variables", group: R, about: "Actions variables: reading, setting and deleting them. Shares its scopes with Secrets", read: &[Scope::SecretsRead], write: &[Scope::SecretsAdmin], admin: &[] }, | |
| 153 | + | Permission { name: "webhooks", label: "Webhooks", group: R, about: "Webhooks and their deliveries", read: &[Scope::WebhooksRead], write: &[Scope::WebhooksAdmin], admin: &[] }, | |
| 154 | + | Permission { name: "workflows", label: "Workflows", group: R, about: "Adding, changing and deleting workflow files under .g1t/workflows and .github/workflows. Write only", read: &[], write: &[Scope::WorkflowFilesWrite], admin: &[] }, | |
| 155 | + | // Workspace permissions. | |
| 156 | + | Permission { name: "members", label: "Members", group: W, about: "The workspace's people, invitations and teams", read: &[Scope::WorkspaceRead], write: &[Scope::WorkspaceAdmin], admin: &[] }, | |
| 157 | + | Permission { name: "workspace_administration", label: "Administration", group: W, about: "The workspace's settings, integrations, rulesets and base permission", read: &[Scope::WorkspaceRead, Scope::AccessRead], write: &[Scope::WorkspaceAdmin, Scope::AccessAdmin], admin: &[] }, | |
| 158 | + | Permission { name: "workspace_billing", label: "Billing", group: W, about: "Usage, budget, AI credit and invoices, and (write) changing the budget and buying credit", read: &[Scope::BillingRead], write: &[Scope::BillingWrite], admin: &[] }, | |
| 159 | + | Permission { name: "models", label: "AI Gateway", group: W, about: "AI Gateway requests: seeing them, and sending requests, which uses the workspace's AI credit", read: &[Scope::ModelsRead], write: &[Scope::ModelsWrite], admin: &[] }, | |
| 160 | + | Permission { name: "self_hosted_runners", label: "Self-hosted runners", group: W, about: "Runners, their groups and settings", read: &[Scope::RunnersRead], write: &[Scope::RunnersAdmin], admin: &[] }, | |
| 161 | + | Permission { name: "workspace_secrets", label: "Secrets", group: W, about: "The workspace's Actions secrets. Shares its scopes with the repository Secrets permission", read: &[Scope::SecretsRead], write: &[Scope::SecretsAdmin], admin: &[] }, | |
| 162 | + | Permission { name: "workspace_webhooks", label: "Webhooks", group: W, about: "The workspace's webhooks. Shares its scopes with the repository Webhooks permission", read: &[Scope::WebhooksRead], write: &[Scope::WebhooksAdmin], admin: &[] }, | |
| 163 | + | // Account permissions. | |
| 164 | + | Permission { name: "email_addresses", label: "Email addresses", group: A, about: "Your email addresses and email settings, invites and invitations", read: &[Scope::AccountRead], write: &[Scope::AccountWrite], admin: &[] }, | |
| 165 | + | Permission { name: "starring", label: "Starring", group: A, about: "Stars and pinned projects. Shares its scopes with Email addresses", read: &[Scope::AccountRead], write: &[Scope::AccountWrite], admin: &[] }, | |
| 166 | + | Permission { name: "notifications", label: "Notifications", group: A, about: "Your inbox, subscriptions and watched repositories", read: &[Scope::NotificationsRead], write: &[Scope::NotificationsWrite], admin: &[] }, | |
| 167 | + | ]; | |
| 168 | + | ||
| 169 | + | /// The permission named `name`. | |
| 170 | + | pub fn permission(name: &str) -> Option<&'static Permission> { | |
| 171 | + | PERMISSIONS.iter().find(|permission| permission.name == name) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// The longest a fine-grained token may last, in days, whatever a | |
| 175 | + | /// workspace allows. | |
| 176 | + | pub const MAX_LIFETIME_DAYS: u32 = 366; | |
| 177 | + | ||
| 178 | + | /// A token's permissions: each name's level, the ones left out none. | |
| 179 | + | pub type Permissions = BTreeMap<String, Access>; | |
| 180 | + | ||
| 181 | + | /// Checks permissions as asked for, for a token whose resource owner is a | |
| 182 | + | /// workspace (`workspace` true) or the person's own account: the tidied | |
| 183 | + | /// permissions (`metadata` always read, nothing at none) and the scopes | |
| 184 | + | /// they give, or why they cannot be. | |
| 185 | + | pub fn resolve(asked: &BTreeMap<String, String>, workspace: bool) -> Result<(Permissions, Vec<Scope>), String> { | |
| 186 | + | let mut permissions = Permissions::new(); | |
| 187 | + | for (name, level) in asked { | |
| 188 | + | let Some(found) = permission(name) else { | |
| 189 | + | return Err(format!("There is no permission called {name}.")); | |
| 190 | + | }; | |
| 191 | + | let Some(access) = Access::parse(level) else { | |
| 192 | + | return Err(format!("{name} is none, read, write or admin.")); | |
| 193 | + | }; | |
| 194 | + | if access == Access::None { | |
| 195 | + | continue; | |
| 196 | + | } | |
| 197 | + | if !found.levels().contains(&access) { | |
| 198 | + | let levels: Vec<&str> = found.levels().iter().map(|level| level.as_str()).collect(); | |
| 199 | + | return Err(format!("{name} can be {}, not {}.", levels.join(" or "), access.as_str())); | |
| 200 | + | } | |
| 201 | + | let fits = match found.group { | |
| 202 | + | PermissionGroup::Account => !workspace, | |
| 203 | + | PermissionGroup::Repository | PermissionGroup::Workspace => workspace, | |
| 204 | + | }; | |
| 205 | + | if !fits { | |
| 206 | + | return Err(if workspace { | |
| 207 | + | format!("{name} is about your own account: choose yourself as the resource owner to give it.") | |
| 208 | + | } else { | |
| 209 | + | format!("{name} is about a workspace: choose a workspace as the resource owner to give it.") | |
| 210 | + | }); | |
| 211 | + | } | |
| 212 | + | permissions.insert(found.name.to_owned(), access); | |
| 213 | + | } | |
| 214 | + | if workspace { | |
| 215 | + | let metadata = permissions.entry("metadata".to_owned()).or_insert(Access::Read); | |
| 216 | + | *metadata = (*metadata).max(Access::Read); | |
| 217 | + | } | |
| 218 | + | let mut scopes: Vec<Scope> = permissions | |
| 219 | + | .iter() | |
| 220 | + | .filter_map(|(name, access)| permission(name).map(|found| found.scopes(*access))) | |
| 221 | + | .flatten() | |
| 222 | + | .collect(); | |
| 223 | + | normalize(&mut scopes); | |
| 224 | + | Ok((permissions, scopes)) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | #[cfg(test)] | |
| 228 | + | mod tests { | |
| 229 | + | use super::*; | |
| 230 | + | ||
| 231 | + | fn asked(pairs: &[(&str, &str)]) -> BTreeMap<String, String> { | |
| 232 | + | pairs.iter().map(|(name, level)| ((*name).to_owned(), (*level).to_owned())).collect() | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | #[test] | |
| 236 | + | fn names_are_unique_and_every_permission_has_a_level() { | |
| 237 | + | let mut seen = std::collections::HashSet::new(); | |
| 238 | + | for permission in PERMISSIONS { | |
| 239 | + | assert!(seen.insert(permission.name), "{} twice", permission.name); | |
| 240 | + | assert!(!permission.levels().is_empty(), "{}", permission.name); | |
| 241 | + | assert!(permission.name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "{}", permission.name); | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[test] | |
| 246 | + | fn github_permissions_map_onto_g1t_scopes() { | |
| 247 | + | let (permissions, scopes) = resolve(&asked(&[("contents", "write"), ("pull_requests", "read")]), true).unwrap(); | |
| 248 | + | assert_eq!(permissions.get("metadata"), Some(&Access::Read), "metadata is always read"); | |
| 249 | + | assert_eq!(scopes, vec![Scope::RepoRead, Scope::RepoWrite, Scope::CodeRead, Scope::CodeWrite, Scope::PullRequestsRead]); | |
| 250 | + | // Actions is runs; Workflows is the files, write only. | |
| 251 | + | let (_, actions) = resolve(&asked(&[("actions", "write")]), true).unwrap(); | |
| 252 | + | assert!(actions.contains(&Scope::WorkflowsWrite) && !actions.contains(&Scope::WorkflowFilesWrite)); | |
| 253 | + | let (_, files) = resolve(&asked(&[("workflows", "write")]), true).unwrap(); | |
| 254 | + | assert!(files.contains(&Scope::WorkflowFilesWrite) && !files.contains(&Scope::WorkflowsWrite)); | |
| 255 | + | assert!(resolve(&asked(&[("workflows", "read")]), true).unwrap_err().contains("write")); | |
| 256 | + | // Pages are deployments; statuses are checks. | |
| 257 | + | let (_, pages) = resolve(&asked(&[("pages", "write")]), true).unwrap(); | |
| 258 | + | assert!(pages.contains(&Scope::DeploymentsWrite)); | |
| 259 | + | let (_, statuses) = resolve(&asked(&[("statuses", "write")]), true).unwrap(); | |
| 260 | + | assert!(statuses.contains(&Scope::ChecksWrite)); | |
| 261 | + | // Packages have admin, which deletes. | |
| 262 | + | let (_, packages) = resolve(&asked(&[("packages", "admin")]), true).unwrap(); | |
| 263 | + | assert!(packages.contains(&Scope::PackagesDelete) && packages.contains(&Scope::PackagesWrite)); | |
| 264 | + | assert!(resolve(&asked(&[("issues", "admin")]), true).is_err()); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | #[test] | |
| 268 | + | fn permissions_fit_their_resource_owner() { | |
| 269 | + | assert!(resolve(&asked(&[("email_addresses", "read")]), true).unwrap_err().contains("your own account")); | |
| 270 | + | assert!(resolve(&asked(&[("contents", "read")]), false).unwrap_err().contains("workspace")); | |
| 271 | + | let (permissions, scopes) = resolve(&asked(&[("notifications", "write")]), false).unwrap(); | |
| 272 | + | assert!(!permissions.contains_key("metadata"), "no repositories, no metadata"); | |
| 273 | + | assert_eq!(scopes, vec![Scope::NotificationsRead, Scope::NotificationsWrite]); | |
| 274 | + | assert!(resolve(&asked(&[("wiki", "read")]), true).unwrap_err().contains("wiki")); | |
| 275 | + | // None is left out. | |
| 276 | + | let (permissions, _) = resolve(&asked(&[("issues", "none")]), true).unwrap(); | |
| 277 | + | assert!(!permissions.contains_key("issues")); | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// `packages/contracts/src/fine-grained.ts` lists the same permissions, | |
| 281 | + | /// in the same order, with the same scopes. | |
| 282 | + | #[test] | |
| 283 | + | fn the_typescript_mirror_has_the_same_table() { | |
| 284 | + | let ts = include_str!("../../../packages/contracts/src/fine-grained.ts"); | |
| 285 | + | let table = ts | |
| 286 | + | .split_once("export const PERMISSIONS = [") | |
| 287 | + | .and_then(|(_, rest)| rest.split_once("] as const")) | |
| 288 | + | .map(|(table, _)| table) | |
| 289 | + | .expect("PERMISSIONS in fine-grained.ts"); | |
| 290 | + | let rows: Vec<&str> = table.lines().filter(|line| line.trim_start().starts_with("{ name:")).collect(); | |
| 291 | + | assert_eq!(rows.len(), PERMISSIONS.len()); | |
| 292 | + | for (row, permission) in rows.iter().zip(PERMISSIONS) { | |
| 293 | + | assert!(row.contains(&format!("name: \"{}\"", permission.name)), "{row}"); | |
| 294 | + | assert!(row.contains(&format!("group: \"{}\"", permission.group.as_str())), "{row}"); | |
| 295 | + | let list = |scopes: &[Scope]| format!("[{}]", scopes.iter().map(|scope| format!("\"{}\"", scope.as_str())).collect::<Vec<_>>().join(", ")); | |
| 296 | + | assert!(row.contains(&format!("read: {}", list(permission.read))), "{row}"); | |
| 297 | + | assert!(row.contains(&format!("write: {}", list(permission.write))), "{row}"); | |
| 298 | + | assert!(row.contains(&format!("admin: {}", list(permission.admin))), "{row}"); | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | } |
| 17 | 17 | pub mod codeowners; | |
| 18 | 18 | pub mod credentials; | |
| 19 | 19 | pub mod events; | |
| 20 | + | pub mod fine_grained; | |
| 20 | 21 | pub mod github; | |
| 21 | 22 | pub mod guardrails; | |
| 22 | 23 | pub mod identity; |
| 1312 | 1312 | } | |
| 1313 | 1313 | ||
| 1314 | 1314 | #[test] | |
| 1315 | + | fn workflow_files_need_their_own_scope() { | |
| 1316 | + | for path in [".g1t/workflows/ci.yml", ".github/workflows/deploy.yaml", "/.github/workflows/x.yml", ".GitHub/Workflows/ci.yml", ".github/workflows"] { | |
| 1317 | + | assert!(is_workflow_file(path), "{path}"); | |
| 1318 | + | } | |
| 1319 | + | for path in ["README.md", ".github/CODEOWNERS", ".github/workflowsx/ci.yml", "docs/.github/workflows/ci.yml", ".g1t/actions/ci.yml"] { | |
| 1320 | + | assert!(!is_workflow_file(path), "{path}"); | |
| 1321 | + | } | |
| 1322 | + | let code = token(&[Scope::CodeWrite]); | |
| 1323 | + | let refused = decide_workflow_files(Some(&code), ["README.md", ".github/workflows/ci.yml"]).unwrap(); | |
| 1324 | + | assert_eq!(refused.rule, "token:workflows"); | |
| 1325 | + | assert!(refused.reason.as_deref().unwrap().contains(".github/workflows/ci.yml")); | |
| 1326 | + | assert!(refused.reason.as_deref().unwrap().contains("workflow_files:write")); | |
| 1327 | + | assert!(decide_workflow_files(Some(&code), ["README.md"]).is_none()); | |
| 1328 | + | assert!(decide_workflow_files(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite])), [".g1t/workflows/ci.yml"]).is_none()); | |
| 1329 | + | assert!(decide_workflow_files(Some(&TokenAccess::full()), [".g1t/workflows/ci.yml"]).is_none(), "full access"); | |
| 1330 | + | assert!(decide_workflow_files(None, [".g1t/workflows/ci.yml"]).is_none(), "a signed-in person"); | |
| 1331 | + | // A job's token never may, as GITHUB_TOKEN never may. | |
| 1332 | + | let job = TokenAccess { job: Some(JobToken::default()), ..TokenAccess::full() }; | |
| 1333 | + | assert!(decide_workflow_files(Some(&job), [".g1t/workflows/ci.yml"]).unwrap().reason.unwrap().contains("job")); | |
| 1334 | + | // Nothing in a preset changes workflow files but full access. | |
| 1335 | + | for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] { | |
| 1336 | + | assert!(!preset.scopes().unwrap().contains(&Scope::WorkflowFilesWrite), "{}", preset.as_str()); | |
| 1337 | + | } | |
| 1338 | + | assert!(!Scope::WorkflowFilesWrite.includes(Scope::WorkflowsWrite) && !Scope::WorkflowsWrite.includes(Scope::WorkflowFilesWrite)); | |
| 1339 | + | } | |
| 1340 | + | ||
| 1341 | + | #[test] | |
| 1342 | + | fn a_fine_grained_token_only_reads_outside_its_resource_owner() { | |
| 1343 | + | let reach = FineGrainedReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() }; | |
| 1344 | + | let fine = TokenAccess { fine_grained: Some(reach), ..token(&[Scope::RepoRead, Scope::IssuesRead, Scope::IssuesWrite]) }; | |
| 1345 | + | assert!(decide(&fine, "create_issue", &json!({ "repo": "acme/web" })).allowed); | |
| 1346 | + | assert!(decide(&fine, "create_issue", &json!({ "repo": "Acme/web" })).allowed); | |
| 1347 | + | let elsewhere = decide(&fine, "create_issue", &json!({ "repo": "globex/site" })); | |
| 1348 | + | assert_eq!(elsewhere.rule, "token:resource-owner"); | |
| 1349 | + | assert!(elsewhere.reason.unwrap().contains("acme")); | |
| 1350 | + | assert!(decide(&fine, "get_issue", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read"); | |
| 1351 | + | assert!(!decide(&fine, "create_pull_request", &json!({ "repo": "acme/web" })).allowed, "its scopes still hold"); | |
| 1352 | + | let mine = TokenAccess { fine_grained: Some(FineGrainedReach::default()), ..token(&[Scope::IssuesWrite]) }; | |
| 1353 | + | assert!(decide(&mine, "create_issue", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account")); | |
| 1354 | + | assert!(fine.covers_repo("rep_1", "acme") && !fine.covers_repo("rep_1", "globex")); | |
| 1355 | + | let selected = FineGrainedReach { workspace: Some("acme".into()), repositories: RepositorySelection::Selected, repo_ids: vec!["rep_1".into()] }; | |
| 1356 | + | assert!(selected.covers("rep_1", "ACME") && !selected.covers("rep_2", "acme")); | |
| 1357 | + | let public = FineGrainedReach { repositories: RepositorySelection::Public, ..selected.clone() }; | |
| 1358 | + | assert!(!public.covers("rep_1", "acme") && public.owned_by("acme")); | |
| 1359 | + | assert!(token(&[]).covers_repo("rep_1", "anything"), "a classic token's reach is its owner's"); | |
| 1360 | + | assert_eq!(RepositorySelection::parse("public_only"), Some(RepositorySelection::Public)); | |
| 1361 | + | } | |
| 1362 | + | ||
| 1363 | + | #[test] | |
| 1315 | 1364 | fn a_legacy_token_can_do_everything() { | |
| 1316 | 1365 | let legacy = TokenAccess { legacy: true, ..TokenAccess::full() }; | |
| 1317 | 1366 | for (operation, _) in OPERATIONS { |
| 1 | + | /** | |
| 2 | + | * Fine-grained personal access tokens: each permission, as the form and the | |
| 3 | + | * API name it, and the g1t scopes each level gives. Mirrors | |
| 4 | + | * `crates/contracts/src/fine_grained.rs`, which is the source of truth; a | |
| 5 | + | * Rust test keeps the table here the same. | |
| 6 | + | * | |
| 7 | + | * A fine-grained token has one resource owner (your own account, or one | |
| 8 | + | * workspace), reaches all, selected or only public repositories of it, | |
| 9 | + | * and has a level for each permission. Its scopes are stored and checked | |
| 10 | + | * as a classic token's are. | |
| 11 | + | */ | |
| 12 | + | ||
| 13 | + | import type { Scope } from "./scopes"; | |
| 14 | + | ||
| 15 | + | export type PermissionGroup = "repository" | "workspace" | "account"; | |
| 16 | + | ||
| 17 | + | export type PermissionAccess = "none" | "read" | "write" | "admin"; | |
| 18 | + | ||
| 19 | + | export type RepositorySelection = "all" | "selected" | "public"; | |
| 20 | + | ||
| 21 | + | export type FineGrainedPermission = { | |
| 22 | + | name: string; | |
| 23 | + | label: string; | |
| 24 | + | group: PermissionGroup; | |
| 25 | + | about: string; | |
| 26 | + | /** The scopes reading gives; empty when it cannot be read only. */ | |
| 27 | + | read: readonly Scope[]; | |
| 28 | + | /** The scopes writing gives, besides reading's. */ | |
| 29 | + | write: readonly Scope[]; | |
| 30 | + | /** The scopes admin gives, besides writing's; empty when it has none. */ | |
| 31 | + | admin: readonly Scope[]; | |
| 32 | + | }; | |
| 33 | + | ||
| 34 | + | /** Every permission, in the order the form shows them. */ | |
| 35 | + | export const PERMISSIONS = [ | |
| 36 | + | { name: "actions", label: "Actions", group: "repository", about: "Workflow runs, jobs, logs and artifacts: reading them, and running, cancelling and rerunning workflows", read: ["workflows:read"], write: ["workflows:write"], admin: [] }, | |
| 37 | + | { name: "administration", label: "Administration", group: "repository", about: "Repository settings, rulesets, who has access and deploy keys; renaming, archiving, transferring and deleting", read: ["repo:read", "access:read"], write: ["repo:admin", "access:admin"], admin: [] }, | |
| 38 | + | { name: "agents", label: "g1t agents", group: "repository", about: "Putting g1t's agents to work and messaging them, which uses the workspace's money", read: [], write: ["agents:run"], admin: [] }, | |
| 39 | + | { name: "checks", label: "Checks", group: "repository", about: "Check runs and check suites on commits. Shares its scopes with Commit statuses", read: ["checks:read"], write: ["checks:write"], admin: [] }, | |
| 40 | + | { name: "contents", label: "Contents", group: "repository", about: "Code, branches, commits and releases: cloning and fetching, pushing, and publishing releases", read: ["code:read"], write: ["code:write", "repo:write"], admin: [] }, | |
| 41 | + | { name: "deployments", label: "Deployments", group: "repository", about: "Deployments and their statuses", read: ["deployments:read"], write: ["deployments:write"], admin: [] }, | |
| 42 | + | { name: "environments", label: "Environments", group: "repository", about: "Environments, and their secrets and variables", read: ["deployments:read", "secrets:read"], write: ["secrets:admin"], admin: [] }, | |
| 43 | + | { name: "issues", label: "Issues", group: "repository", about: "Issues, their comments, labels and milestones, and plans", read: ["issues:read"], write: ["issues:write"], admin: [] }, | |
| 44 | + | { name: "memory", label: "Memory and context", group: "repository", about: "Recalling memory and searching the workspace's context, and saving memory for the next agent", read: ["memory:read"], write: ["memory:write"], admin: [] }, | |
| 45 | + | { name: "metadata", label: "Metadata", group: "repository", about: "Seeing repositories and searching them. Always read", read: ["repo:read"], write: [], admin: [] }, | |
| 46 | + | { name: "packages", label: "Packages", group: "repository", about: "Pulling private packages, publishing them, and (admin) deleting packages and versions", read: ["packages:read"], write: ["packages:write"], admin: ["packages:delete"] }, | |
| 47 | + | { name: "pages", label: "Pages", group: "repository", about: "Deployments on g1t.page. Shares its scopes with Deployments", read: ["deployments:read"], write: ["deployments:write"], admin: [] }, | |
| 48 | + | { name: "pull_requests", label: "Pull requests", group: "repository", about: "Pull requests, their reviews, changes, sessions and merge queues", read: ["pull_requests:read"], write: ["pull_requests:write"], admin: [] }, | |
| 49 | + | { name: "secrets", label: "Secrets", group: "repository", about: "Actions secrets: listing them (never their values), setting and deleting them. Shares its scopes with Variables", read: ["secrets:read"], write: ["secrets:admin"], admin: [] }, | |
| 50 | + | { name: "security_events", label: "Security events and alerts", group: "repository", about: "Code scanning, secret scanning and vulnerability alerts, SARIF uploads and security settings", read: ["security:read"], write: ["security:write"], admin: [] }, | |
| 51 | + | { name: "statuses", label: "Commit statuses", group: "repository", about: "Statuses on commits. Shares its scopes with Checks", read: ["checks:read"], write: ["checks:write"], admin: [] }, | |
| 52 | + | { name: "variables", label: "Variables", group: "repository", about: "Actions variables: reading, setting and deleting them. Shares its scopes with Secrets", read: ["secrets:read"], write: ["secrets:admin"], admin: [] }, | |
| 53 | + | { name: "webhooks", label: "Webhooks", group: "repository", about: "Webhooks and their deliveries", read: ["webhooks:read"], write: ["webhooks:admin"], admin: [] }, | |
| 54 | + | { name: "workflows", label: "Workflows", group: "repository", about: "Adding, changing and deleting workflow files under .g1t/workflows and .github/workflows. Write only", read: [], write: ["workflow_files:write"], admin: [] }, | |
| 55 | + | { name: "members", label: "Members", group: "workspace", about: "The workspace's people, invitations and teams", read: ["workspace:read"], write: ["workspace:admin"], admin: [] }, | |
| 56 | + | { name: "workspace_administration", label: "Administration", group: "workspace", about: "The workspace's settings, integrations, rulesets and base permission", read: ["workspace:read", "access:read"], write: ["workspace:admin", "access:admin"], admin: [] }, | |
| 57 | + | { name: "workspace_billing", label: "Billing", group: "workspace", about: "Usage, budget, AI credit and invoices, and (write) changing the budget and buying credit", read: ["billing:read"], write: ["billing:write"], admin: [] }, | |
| 58 | + | { name: "models", label: "AI Gateway", group: "workspace", about: "AI Gateway requests: seeing them, and sending requests, which uses the workspace's AI credit", read: ["models:read"], write: ["models:write"], admin: [] }, | |
| 59 | + | { name: "self_hosted_runners", label: "Self-hosted runners", group: "workspace", about: "Runners, their groups and settings", read: ["runners:read"], write: ["runners:admin"], admin: [] }, | |
| 60 | + | { name: "workspace_secrets", label: "Secrets", group: "workspace", about: "The workspace's Actions secrets. Shares its scopes with the repository Secrets permission", read: ["secrets:read"], write: ["secrets:admin"], admin: [] }, | |
| 61 | + | { name: "workspace_webhooks", label: "Webhooks", group: "workspace", about: "The workspace's webhooks. Shares its scopes with the repository Webhooks permission", read: ["webhooks:read"], write: ["webhooks:admin"], admin: [] }, | |
| 62 | + | { name: "email_addresses", label: "Email addresses", group: "account", about: "Your email addresses and email settings, invites and invitations", read: ["account:read"], write: ["account:write"], admin: [] }, | |
| 63 | + | { name: "starring", label: "Starring", group: "account", about: "Stars and pinned projects. Shares its scopes with Email addresses", read: ["account:read"], write: ["account:write"], admin: [] }, | |
| 64 | + | { name: "notifications", label: "Notifications", group: "account", about: "Your inbox, subscriptions and watched repositories", read: ["notifications:read"], write: ["notifications:write"], admin: [] }, | |
| 65 | + | ] as const satisfies readonly FineGrainedPermission[]; | |
| 66 | + | ||
| 67 | + | export type PermissionName = (typeof PERMISSIONS)[number]["name"]; | |
| 68 | + | ||
| 69 | + | export const PERMISSION_GROUPS: { group: PermissionGroup; label: string; about: string }[] = [ | |
| 70 | + | { group: "repository", label: "Repository permissions", about: "What it may do in the repositories it reaches." }, | |
| 71 | + | { group: "workspace", label: "Workspace permissions", about: "What it may do with the workspace itself." }, | |
| 72 | + | { group: "account", label: "Account permissions", about: "What it may do with your own account." }, | |
| 73 | + | ]; | |
| 74 | + | ||
| 75 | + | /** The longest a fine-grained token may last, whatever a workspace allows. */ | |
| 76 | + | export const FINE_GRAINED_MAX_LIFETIME_DAYS = 366; | |
| 77 | + | ||
| 78 | + | /** The levels a permission can be set to, least first, none excluded. */ | |
| 79 | + | export function permissionLevels(permission: FineGrainedPermission): PermissionAccess[] { | |
| 80 | + | const levels: PermissionAccess[] = []; | |
| 81 | + | if (permission.read.length > 0) levels.push("read"); | |
| 82 | + | if (permission.write.length > 0) levels.push("write"); | |
| 83 | + | if (permission.admin.length > 0) levels.push("admin"); | |
| 84 | + | return levels; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | const ORDER: Record<PermissionAccess, number> = { none: 0, read: 1, write: 2, admin: 3 }; | |
| 88 | + | ||
| 89 | + | /** The scopes a level of a permission gives, lower levels' included. */ | |
| 90 | + | export function permissionScopes(permission: FineGrainedPermission, access: PermissionAccess): Scope[] { | |
| 91 | + | const scopes: Scope[] = []; | |
| 92 | + | if (ORDER[access] >= ORDER.read) scopes.push(...permission.read); | |
| 93 | + | if (ORDER[access] >= ORDER.write) scopes.push(...permission.write); | |
| 94 | + | if (ORDER[access] >= ORDER.admin) scopes.push(...permission.admin); | |
| 95 | + | return scopes; | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /** A token's permissions: each name's level; names left out are none. */ | |
| 99 | + | export type TokenPermissions = Partial<Record<string, PermissionAccess>>; | |
| 100 | + | ||
| 101 | + | /** The permission named `name`. */ | |
| 102 | + | export function findPermission(name: string): FineGrainedPermission | undefined { | |
| 103 | + | return PERMISSIONS.find((permission) => permission.name === name); | |
| 104 | + | } |
| 13 | 13 | export * from "./d1"; | |
| 14 | 14 | export * from "./deployments"; | |
| 15 | 15 | export * from "./events"; | |
| 16 | + | export * from "./fine-grained"; | |
| 16 | 17 | export * from "./github"; | |
| 17 | 18 | export * from "./guardrails"; | |
| 18 | 19 | export * from "./identity"; |
| 66 | 66 | if !valid_path(&a.path) { | |
| 67 | 67 | return Ok(Outcome::fail(FailureCode::Invalid, format!("{} is not a path a file can be written to.", a.path))); | |
| 68 | 68 | } | |
| 69 | + | // A workflow file, written for a token without the scope for it. | |
| 70 | + | if let Some(refused) = g1t_contracts::scopes::decide_workflow_files(a.actor.token.as_deref(), [a.path.as_str()]) { | |
| 71 | + | return Ok(Outcome::fail(FailureCode::Forbidden, refused.reason.unwrap_or_default())); | |
| 72 | + | } | |
| 69 | 73 | if a.content.len() > MAX_CONTENT_BYTES { | |
| 70 | 74 | return Ok(Outcome::fail(FailureCode::Invalid, "The file is too large to write this way.")); | |
| 71 | 75 | } |
| 44 | 44 | mod stats; | |
| 45 | 45 | mod store; | |
| 46 | 46 | mod transfer; | |
| 47 | + | mod workflow_gate; | |
| 47 | 48 | ||
| 48 | 49 | use g1t_contracts::events::{ | |
| 49 | 50 | Event, GitPush, NewEvent, Publish, RepoCreated, RepoForked, RepoUpdated, WorkspaceDeleted, |
| 167 | 167 | /// on. `body` is as much of the push as was read; `whole` says whether | |
| 168 | 168 | /// that is all of it, so that its commits can be read. | |
| 169 | 169 | pub(crate) async fn check_push(&self, repo: &Repo, pusher: Option<&User>, body: &[u8], whole: bool) -> Result<Option<Response>> { | |
| 170 | + | // Workflow files need their own scope from a token, on a pull | |
| 171 | + | // request's working copy too (workflow_gate.rs). | |
| 172 | + | if let Some(response) = self.workflow_gate(repo, pusher, body, whole).await? { | |
| 173 | + | return Ok(Some(response)); | |
| 174 | + | } | |
| 170 | 175 | if repo.fork_of.is_some() { | |
| 171 | 176 | return Ok(None); | |
| 172 | 177 | } |
| 1 | + | //! Workflow files: a token adds, changes or deletes files under | |
| 2 | + | //! `.g1t/workflows/` or `.github/workflows/` only with the | |
| 3 | + | //! `workflow_files:write` scope (a fine-grained token's Workflows | |
| 4 | + | //! 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 | + | ||
| 16 | + | use std::cell::Cell; | |
| 17 | + | ||
| 18 | + | use g1t_contracts::User; | |
| 19 | + | use g1t_contracts::scopes::{TokenAccess, WORKFLOW_DIRS, decide_workflow_files}; | |
| 20 | + | use g1t_scan::pack::{ObjectKind, Pack, TreeItem, pack_start}; | |
| 21 | + | use worker::{Response, Result}; | |
| 22 | + | ||
| 23 | + | use crate::registry::store_key; | |
| 24 | + | use crate::rule_facts::{self, MAX_COMMITS}; | |
| 25 | + | use crate::secret_scan::Objects; | |
| 26 | + | use crate::store::{GitRepo, GitStore}; | |
| 27 | + | use crate::Repos; | |
| 28 | + | ||
| 29 | + | /// The token behind a push or an edit, when it is one this gate checks: | |
| 30 | + | /// any token without `workflow_files:write`, and every job's token. | |
| 31 | + | pub(crate) fn gated(actor: Option<&User>) -> Option<&TokenAccess> { | |
| 32 | + | let token = actor?.token.as_deref()?; | |
| 33 | + | decide_workflow_files(Some(token), [WORKFLOW_DIRS[0]]).map(|_| token) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// The id of the tree at `dir` (such as `.github/workflows/`) under the | |
| 37 | + | /// tree `root`, if there is one. | |
| 38 | + | async fn subtree<R: GitRepo>(objects: &Objects<'_, R>, root: &str, dir: &str) -> Result<Option<String>> { | |
| 39 | + | let mut id = root.to_owned(); | |
| 40 | + | for part in dir.trim_end_matches('/').split('/') { | |
| 41 | + | let items = objects.tree(&id).await?; | |
| 42 | + | match items.into_iter().find(|item| item.name == part && item.is_tree()) { | |
| 43 | + | Some(item) => id = item.id, | |
| 44 | + | None => return Ok(None), | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | Ok(Some(id)) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// The first entry that differs between two listings of one directory. | |
| 51 | + | fn first_difference(old: &[TreeItem], new: &[TreeItem]) -> Option<String> { | |
| 52 | + | new.iter() | |
| 53 | + | .find(|item| !old.iter().any(|before| before.name == item.name && before.id == item.id && before.mode == item.mode)) | |
| 54 | + | .or_else(|| old.iter().find(|item| !new.iter().any(|after| after.name == item.name))) | |
| 55 | + | .map(|item| item.name.clone()) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// The first workflow file that differs between two root trees (`None` | |
| 59 | + | /// for a commit with no parent), as a path. | |
| 60 | + | pub(crate) async fn changed_between<R: GitRepo>(objects: &Objects<'_, R>, old_root: Option<&str>, new_root: &str) -> Result<Option<String>> { | |
| 61 | + | for dir in WORKFLOW_DIRS { | |
| 62 | + | let old = match old_root { | |
| 63 | + | Some(root) => subtree(objects, root, dir).await?, | |
| 64 | + | None => None, | |
| 65 | + | }; | |
| 66 | + | let new = subtree(objects, new_root, dir).await?; | |
| 67 | + | if old == new { | |
| 68 | + | continue; | |
| 69 | + | } | |
| 70 | + | let old_items = match &old { | |
| 71 | + | Some(id) => objects.tree(id).await?, | |
| 72 | + | None => Vec::new(), | |
| 73 | + | }; | |
| 74 | + | let new_items = match &new { | |
| 75 | + | Some(id) => objects.tree(id).await?, | |
| 76 | + | None => Vec::new(), | |
| 77 | + | }; | |
| 78 | + | let name = first_difference(&old_items, &new_items).unwrap_or_default(); | |
| 79 | + | return Ok(Some(format!("{dir}{name}"))); | |
| 80 | + | } | |
| 81 | + | Ok(None) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// The first workflow file one of `ids` (commits the pack holds) changes | |
| 85 | + | /// against its first parent. | |
| 86 | + | pub(crate) async fn changed_in_commits<R: GitRepo>(pack: &Pack, repo: &R, ids: &[String]) -> Result<Option<String>> { | |
| 87 | + | let objects = Objects { pack, repo, reads: Cell::new(0) }; | |
| 88 | + | for id in ids { | |
| 89 | + | let Some((ObjectKind::Commit, data)) = pack.get(id) else { | |
| 90 | + | continue; | |
| 91 | + | }; | |
| 92 | + | let commit = rule_facts::read_commit(data); | |
| 93 | + | let old_root = match commit.parents.first() { | |
| 94 | + | Some(parent) => objects.commit_tree(parent).await?, | |
| 95 | + | None => None, | |
| 96 | + | }; | |
| 97 | + | if let Some(path) = changed_between(&objects, old_root.as_deref(), &commit.tree).await? { | |
| 98 | + | return Ok(Some(path)); | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | Ok(None) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Why a push is refused, as the reason git shows beside each ref and the | |
| 105 | + | /// lines it prints, when `token` may not change workflow files and the | |
| 106 | + | /// push (`body`, read `whole` or not) does or cannot be checked. | |
| 107 | + | pub(crate) async fn judge_push<R: GitRepo>(token: &TokenAccess, body: &[u8], whole: bool, git: &R) -> Result<Option<(&'static str, Vec<String>)>> { | |
| 108 | + | let updates = crate::git_http::ref_updates(body); | |
| 109 | + | if updates.iter().all(|(_, _, new)| new.is_none()) { | |
| 110 | + | return Ok(None); | |
| 111 | + | } | |
| 112 | + | 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()]))); | |
| 113 | + | if !whole { | |
| 114 | + | 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()); | |
| 115 | + | } | |
| 116 | + | let mut pack = match pack_start(body).map(|start| Pack::parse(&body[start..])) { | |
| 117 | + | Some(Ok(pack)) => pack, | |
| 118 | + | // Nothing but ref moves to commits the repository has. | |
| 119 | + | None => return Ok(None), | |
| 120 | + | Some(Err(problem)) => { | |
| 121 | + | worker::console_error!("a push's pack could not be read for workflow files: {problem}"); | |
| 122 | + | 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()]))); | |
| 123 | + | } | |
| 124 | + | }; | |
| 125 | + | crate::secret_scan::supply_bases(&mut pack, git).await?; | |
| 126 | + | for (_, _, new) in updates { | |
| 127 | + | let Some(new) = new else { continue }; | |
| 128 | + | let Some(ids) = rule_facts::added(&pack, &new, MAX_COMMITS) else { | |
| 129 | + | 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.")); | |
| 130 | + | }; | |
| 131 | + | if let Some(path) = changed_in_commits(&pack, git, &ids).await? { | |
| 132 | + | let reason = decide_workflow_files(Some(token), [path.as_str()]) | |
| 133 | + | .and_then(|decision| decision.reason) | |
| 134 | + | .unwrap_or_else(|| format!("This access token cannot change the workflow file {path}.")); | |
| 135 | + | return Ok(Some(( | |
| 136 | + | "workflow files need the workflow_files:write scope", | |
| 137 | + | vec![reason, "Push with a token that has the workflow_files:write scope, or make the change signed in on g1t.sh.".to_owned()], | |
| 138 | + | ))); | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | Ok(None) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | impl<S: GitStore> Repos<S> { | |
| 145 | + | /// For a push by a token this gate checks: the response declining it | |
| 146 | + | /// when it changes a workflow file, or when it cannot be checked. | |
| 147 | + | pub(crate) async fn workflow_gate(&self, repo: &g1t_contracts::repos::Repo, pusher: Option<&User>, body: &[u8], whole: bool) -> Result<Option<Response>> { | |
| 148 | + | let Some(token) = gated(pusher) else { | |
| 149 | + | return Ok(None); | |
| 150 | + | }; | |
| 151 | + | let git = self.store.open(&store_key(repo)).await?; | |
| 152 | + | match judge_push(token, body, whole, &git).await? { | |
| 153 | + | Some((reason, lines)) => Ok(Some(crate::git_http::declined(body, reason, &lines)?)), | |
| 154 | + | None => Ok(None), | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[cfg(test)] | |
| 160 | + | mod tests { | |
| 161 | + | use std::collections::HashMap; | |
| 162 | + | use std::future::Future; | |
| 163 | + | use std::pin::pin; | |
| 164 | + | use std::task::{Context, Poll, Waker}; | |
| 165 | + | ||
| 166 | + | use g1t_contracts::repos::{Branch, Commit, EntryKind, GitAccess, Signature, TreeEntry}; | |
| 167 | + | use g1t_contracts::scopes::{JobToken, Scope}; | |
| 168 | + | use g1t_scan::pack::{encode_tree, object_id, write_pack}; | |
| 169 | + | ||
| 170 | + | use super::*; | |
| 171 | + | use crate::store::Scope as StoreScope; | |
| 172 | + | ||
| 173 | + | fn run<F: Future>(future: F) -> F::Output { | |
| 174 | + | match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) { | |
| 175 | + | Poll::Ready(output) => output, | |
| 176 | + | Poll::Pending => panic!("the fake store never waits"), | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// The repository before the push: one commit, with its trees. | |
| 181 | + | #[derive(Default)] | |
| 182 | + | struct Fake { | |
| 183 | + | trees: HashMap<String, Vec<TreeEntry>>, | |
| 184 | + | commits: HashMap<String, Commit>, | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | impl GitRepo for Fake { | |
| 188 | + | async fn access(&self, _scope: StoreScope) -> Result<GitAccess> { | |
| 189 | + | unimplemented!() | |
| 190 | + | } | |
| 191 | + | async fn branches(&self) -> Result<Vec<Branch>> { | |
| 192 | + | Ok(Vec::new()) | |
| 193 | + | } | |
| 194 | + | async fn log(&self, git_ref: &str, _limit: u32) -> Result<Vec<Commit>> { | |
| 195 | + | Ok(self.commits.get(git_ref).cloned().into_iter().collect()) | |
| 196 | + | } | |
| 197 | + | async fn parents(&self, commit_hash: &str) -> Result<Option<Vec<String>>> { | |
| 198 | + | Ok(self.commits.get(commit_hash).map(|commit| commit.parents.clone())) | |
| 199 | + | } | |
| 200 | + | async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> { | |
| 201 | + | Ok(self.trees.get(tree_hash).cloned()) | |
| 202 | + | } | |
| 203 | + | async fn read_blob(&self, _blob_hash: &str) -> Result<Option<Vec<u8>>> { | |
| 204 | + | Ok(None) | |
| 205 | + | } | |
| 206 | + | async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> { | |
| 207 | + | Ok(None) | |
| 208 | + | } | |
| 209 | + | async fn fork(&self, _target_key: &str) -> Result<()> { | |
| 210 | + | Ok(()) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | fn item(mode: &str, name: &str, id: &str) -> TreeItem { | |
| 215 | + | TreeItem { mode: mode.into(), name: name.into(), id: id.into() } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /// Objects for one tree layout: a root with `dir/workflows/<file>` | |
| 219 | + | /// holding `content`, and `README.md`. | |
| 220 | + | struct Layout { | |
| 221 | + | objects: Vec<(ObjectKind, Vec<u8>)>, | |
| 222 | + | root: String, | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | fn layout(dir: &str, file: &str, content: &str, readme: &str) -> Layout { | |
| 226 | + | let mut objects = Vec::new(); | |
| 227 | + | let mut add = |kind: ObjectKind, data: Vec<u8>| { | |
| 228 | + | let id = object_id(kind, &data); | |
| 229 | + | objects.push((kind, data)); | |
| 230 | + | id | |
| 231 | + | }; | |
| 232 | + | let workflow = add(ObjectKind::Blob, content.as_bytes().to_vec()); | |
| 233 | + | let readme = add(ObjectKind::Blob, readme.as_bytes().to_vec()); | |
| 234 | + | let workflows = add(ObjectKind::Tree, encode_tree(&[item("100644", file, &workflow)])); | |
| 235 | + | let parent = add(ObjectKind::Tree, encode_tree(&[item("40000", "workflows", &workflows)])); | |
| 236 | + | let root = add(ObjectKind::Tree, encode_tree(&[item("40000", dir, &parent), item("100644", "README.md", &readme)])); | |
| 237 | + | Layout { objects, root } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | fn commit(tree: &str, parent: Option<&str>) -> (String, Vec<u8>) { | |
| 241 | + | let parent = parent.map(|parent| format!("parent {parent}\n")).unwrap_or_default(); | |
| 242 | + | let data = format!("tree {tree}\n{parent}author A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n\nchange\n").into_bytes(); | |
| 243 | + | (object_id(ObjectKind::Commit, &data), data) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /// The first workflow file a push of `after` on top of `before` changes. | |
| 247 | + | fn check(before: &Layout, after: &Layout) -> Option<String> { | |
| 248 | + | // The repository holds `before` and its commit; the pack, `after`. | |
| 249 | + | let mut repo = Fake::default(); | |
| 250 | + | let base = Pack::parse(&write_pack(&before.objects)).unwrap(); | |
| 251 | + | for (kind, data) in &before.objects { | |
| 252 | + | if *kind == ObjectKind::Tree { | |
| 253 | + | let id = object_id(*kind, data); | |
| 254 | + | let entries = base | |
| 255 | + | .tree(&id) | |
| 256 | + | .unwrap() | |
| 257 | + | .into_iter() | |
| 258 | + | .map(|item| TreeEntry { name: item.name.clone(), hash: item.id.clone(), kind: if item.is_tree() { EntryKind::Tree } else { EntryKind::Blob } }) | |
| 259 | + | .collect(); | |
| 260 | + | repo.trees.insert(id, entries); | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | let (base_id, _) = commit(&before.root, None); | |
| 264 | + | repo.commits.insert( | |
| 265 | + | base_id.clone(), | |
| 266 | + | 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() }, | |
| 267 | + | ); | |
| 268 | + | let (tip, data) = commit(&after.root, Some(&base_id)); | |
| 269 | + | let mut objects = after.objects.clone(); | |
| 270 | + | objects.push((ObjectKind::Commit, data)); | |
| 271 | + | let pack = Pack::parse(&write_pack(&objects)).unwrap(); | |
| 272 | + | run(changed_in_commits(&pack, &repo, &[tip])).unwrap() | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | #[test] | |
| 276 | + | fn a_push_that_changes_a_workflow_is_named_by_its_file() { | |
| 277 | + | let before = layout(".github", "ci.yml", "on: push", "hello"); | |
| 278 | + | let changed = layout(".github", "ci.yml", "on: [push, pull_request]", "hello"); | |
| 279 | + | assert_eq!(check(&before, &changed).as_deref(), Some(".github/workflows/ci.yml")); | |
| 280 | + | let added = layout(".github", "deploy.yml", "on: push", "hello"); | |
| 281 | + | assert!(check(&before, &added).unwrap().starts_with(".github/workflows/")); | |
| 282 | + | // The g1t directory too, added where there was none. | |
| 283 | + | let g1t = layout(".g1t", "ci.yml", "on: push", "hello"); | |
| 284 | + | assert_eq!(check(&before, &g1t).as_deref(), Some(".g1t/workflows/ci.yml")); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | /// A receive-pack request moving `main` from `old` to `new`, with the pack. | |
| 288 | + | fn receive_pack(old: &str, new: &str, pack: &[u8]) -> Vec<u8> { | |
| 289 | + | let command = format!("{old} {new} refs/heads/main\0report-status side-band-64k\n"); | |
| 290 | + | let mut body = format!("{:04x}", command.len() + 4).into_bytes(); | |
| 291 | + | body.extend_from_slice(command.as_bytes()); | |
| 292 | + | body.extend_from_slice(b"0000"); | |
| 293 | + | body.extend_from_slice(pack); | |
| 294 | + | body | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | /// The repository holding `before` at its commit, and a push of `after` | |
| 298 | + | /// on top of it: the refusal, if any, for `pusher`'s token. | |
| 299 | + | fn push(before: &Layout, after: &Layout, pusher: &User, whole: bool) -> Option<(&'static str, Vec<String>)> { | |
| 300 | + | let mut repo = Fake::default(); | |
| 301 | + | let base = Pack::parse(&write_pack(&before.objects)).unwrap(); | |
| 302 | + | for (kind, data) in &before.objects { | |
| 303 | + | if *kind == ObjectKind::Tree { | |
| 304 | + | let id = object_id(*kind, data); | |
| 305 | + | let entries = base | |
| 306 | + | .tree(&id) | |
| 307 | + | .unwrap() | |
| 308 | + | .into_iter() | |
| 309 | + | .map(|item| TreeEntry { name: item.name.clone(), hash: item.id.clone(), kind: if item.is_tree() { EntryKind::Tree } else { EntryKind::Blob } }) | |
| 310 | + | .collect(); | |
| 311 | + | repo.trees.insert(id, entries); | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | let (base_id, _) = commit(&before.root, None); | |
| 315 | + | repo.commits.insert( | |
| 316 | + | base_id.clone(), | |
| 317 | + | 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() }, | |
| 318 | + | ); | |
| 319 | + | let (tip, data) = commit(&after.root, Some(&base_id)); | |
| 320 | + | let mut objects = after.objects.clone(); | |
| 321 | + | objects.push((ObjectKind::Commit, data)); | |
| 322 | + | let body = receive_pack(&base_id, &tip, &write_pack(&objects)); | |
| 323 | + | let token = gated(Some(pusher))?; | |
| 324 | + | run(judge_push(token, &body, whole, &repo)).unwrap() | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | #[test] | |
| 328 | + | fn a_git_push_of_a_workflow_change_is_declined_for_a_token_without_the_scope() { | |
| 329 | + | let before = layout(".github", "ci.yml", "on: push", "hello"); | |
| 330 | + | let changed = layout(".github", "ci.yml", "on: [push, pull_request]\njobs: {}", "hello"); | |
| 331 | + | let (reason, lines) = push(&before, &changed, &token(&[Scope::CodeWrite]), true).expect("declined"); | |
| 332 | + | assert_eq!(reason, "workflow files need the workflow_files:write scope"); | |
| 333 | + | assert!(lines[0].contains(".github/workflows/ci.yml"), "{lines:?}"); | |
| 334 | + | assert!(lines[0].contains("workflow_files:write"), "{lines:?}"); | |
| 335 | + | // With the scope, or full access, it goes through. | |
| 336 | + | assert!(push(&before, &changed, &token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]), true).is_none()); | |
| 337 | + | // A push that changes other files goes through without it. | |
| 338 | + | let readme = layout(".github", "ci.yml", "on: push", "hello, world"); | |
| 339 | + | assert!(push(&before, &readme, &token(&[Scope::CodeWrite]), true).is_none()); | |
| 340 | + | // One too large to read whole cannot be checked, so it is declined. | |
| 341 | + | let (reason, _) = push(&before, &readme, &token(&[Scope::CodeWrite]), false).expect("declined"); | |
| 342 | + | assert_eq!(reason, "push too large to check for workflow files"); | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | #[test] | |
| 346 | + | fn a_job_token_never_pushes_workflow_changes() { | |
| 347 | + | let before = layout(".g1t", "ci.yml", "on: push", "hello"); | |
| 348 | + | let changed = layout(".g1t", "ci.yml", "on: workflow_dispatch", "hello"); | |
| 349 | + | let mut job = token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]); | |
| 350 | + | job.token.as_mut().unwrap().job = Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }); | |
| 351 | + | let (_, lines) = push(&before, &changed, &job, true).expect("declined"); | |
| 352 | + | assert!(lines[0].contains("workflow job's token"), "{lines:?}"); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | #[test] | |
| 356 | + | fn a_push_that_leaves_workflows_alone_passes() { | |
| 357 | + | let before = layout(".github", "ci.yml", "on: push", "hello"); | |
| 358 | + | let readme = layout(".github", "ci.yml", "on: push", "hello, world"); | |
| 359 | + | assert_eq!(check(&before, &readme), None); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | fn token(scopes: &[Scope]) -> User { | |
| 363 | + | User { | |
| 364 | + | token: Some(Box::new(TokenAccess { | |
| 365 | + | token_id: "tok_1".into(), | |
| 366 | + | scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()), | |
| 367 | + | ..TokenAccess::default() | |
| 368 | + | })), | |
| 369 | + | ..User::default() | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | #[test] | |
| 374 | + | fn who_the_gate_checks() { | |
| 375 | + | assert!(gated(None).is_none(), "nobody"); | |
| 376 | + | assert!(gated(Some(&User::default())).is_none(), "a signed-in person"); | |
| 377 | + | assert!(gated(Some(&token(&[Scope::CodeWrite]))).is_some(), "a token that may push code only"); | |
| 378 | + | assert!(gated(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]))).is_none()); | |
| 379 | + | let full = User { token: Some(Box::new(TokenAccess::full())), ..User::default() }; | |
| 380 | + | assert!(gated(Some(&full)).is_none(), "full access includes workflow files"); | |
| 381 | + | let mut job = token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite]); | |
| 382 | + | job.token.as_mut().unwrap().job = Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }); | |
| 383 | + | assert!(gated(Some(&job)).is_some(), "a job's token, whatever it holds"); | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | #[test] | |
| 387 | + | fn the_first_difference_names_added_changed_and_removed_entries() { | |
| 388 | + | let a = item("100644", "a.yml", "1"); | |
| 389 | + | let b = item("100644", "b.yml", "2"); | |
| 390 | + | assert_eq!(first_difference(&[a.clone()], &[a.clone(), b.clone()]).as_deref(), Some("b.yml")); | |
| 391 | + | assert_eq!(first_difference(&[a.clone(), b.clone()], &[a.clone()]).as_deref(), Some("b.yml")); | |
| 392 | + | assert_eq!(first_difference(&[a.clone()], &[item("100644", "a.yml", "3")]).as_deref(), Some("a.yml")); | |
| 393 | + | assert_eq!(first_difference(&[a.clone()], &[a]), None); | |
| 394 | + | } | |
| 395 | + | } |