Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'
11 files+219−130/11 viewed
| 126 | 126 | ||
| 127 | 127 | A step's `uses: owner/repo@ref` (or `owner/repo/path@ref`) and a job's | |
| 128 | 128 | `uses: owner/repo/.g1t/workflows/build.yml@ref` name another repository. | |
| 129 | − | g1t looks for it on g1t first: | |
| 129 | + | g1t looks for it on g1t first, but a name means what it means on GitHub | |
| 130 | + | whenever GitHub has it: | |
| 130 | 131 | ||
| 131 | 132 | | The repository | What happens | | |
| 132 | 133 | | --- | --- | | |
| 133 | − | | On g1t and public | Your workflows use it, from any workspace. | | |
| 134 | + | | On g1t and public: in your workspace, under `g1t/`, or with a name GitHub has no public repository of | Your workflows use it, from any workspace. | | |
| 134 | 135 | | On g1t, private, in your workspace, with **Access** set to *Accessible from repositories in* the workspace | Your private repositories' workflows use it. A job reads it with a read-only token for that repository alone, which ends with the job. | | |
| 135 | 136 | | On g1t, private, and not shared that way | The step or job fails, and says why. A private repository's actions are never used by a public repository's workflows, whose logs anyone can read, nor from another workspace. | | |
| 136 | 137 | | Not on g1t, or private in a workspace you cannot see | An action comes from GitHub, at the commit its ref names there (see [actions from GitHub](#actions-from-github)); a reusable workflow is read from a public repository on GitHub. | | |
| 138 | + | | On g1t, public, in another workspace, with a name GitHub has a public repository of | GitHub's is used, as on GitHub: anyone can make a workspace, so a workspace called `actions` never answers for `actions/setup-node`. When g1t can't reach GitHub to tell, GitHub's is used too. | | |
| 137 | 139 | ||
| 138 | 140 | `ref` is a branch, a tag or a commit. A reusable workflow may be under | |
| 139 | 141 | `.g1t/workflows/` or `.github/workflows/`; a `.github/workflows/` path |
| 17 | 17 | be registered by anyone else, signing in works in any case, | |
| 18 | 18 | `g1t.sh/u/ANA-LOPEZ` opens the same profile, and an `@ana-lopez` mention | |
| 19 | 19 | reaches you. Addresses and git URLs use the lowercase form. Reserved words | |
| 20 | − | (such as `settings`, `api` or `g1t`) are reserved in every case. | |
| 20 | + | (such as `settings`, `api` or `g1t`) are reserved in every case. So are | |
| 21 | + | the names of the GitHub accounts whose actions workflows use most, such as | |
| 22 | + | `actions`, `docker` and `aws-actions`, so that nobody on g1t is mistaken | |
| 23 | + | for them; see [actions from other repositories](/guides/actions/#actions-and-workflows-from-other-repositories). | |
| 24 | + | Signing up with a GitHub account of one of those names suggests the name | |
| 25 | + | with `-gh` added. | |
| 21 | 26 | ||
| 22 | 27 | Before you can do anything else, you [confirm your email | |
| 23 | 28 | address](#confirming-your-email-address) with the code g1t emails you, |
| 154 | 154 | register a username with the old name. Links and remotes that still use | |
| 155 | 155 | it then reach whatever has the name, or nothing. | |
| 156 | 156 | - The new name follows the same rules as a new workspace: lowercase letters, | |
| 157 | − | digits and single hyphens, up to 39 characters, not a reserved word, and | |
| 157 | + | digits and single hyphens, up to 39 characters, not a reserved word or a | |
| 158 | + | well-known GitHub action owner's name (such as `actions` or `docker`), and | |
| 158 | 159 | not another workspace's slug or someone else's username. | |
| 159 | 160 | ||
| 160 | 161 | ## Data residency |
| 52 | 52 | ||
| 53 | 53 | pub use ids::{id_floor, new_id}; | |
| 54 | 54 | pub use names::{ | |
| 55 | − | Username, aliasable_name, claimable_namespace, claimable_username, is_namespace_shaped, is_reserved_name, is_route_name, | |
| 55 | + | Username, aliasable_name, claimable_namespace, is_action_owner_name, is_claimable_name, claimable_username, is_namespace_shaped, is_reserved_name, is_route_name, | |
| 56 | 56 | is_valid_namespace, is_valid_repo_name, | |
| 57 | 57 | }; | |
| 58 | 58 | pub use outcome::{Failure, FailureCode, Outcome}; |
| 57 | 57 | /// `aliases.rs`). | |
| 58 | 58 | const OWN: &[&str] = &["g1t", "g1t-agent", "ghost"]; | |
| 59 | 59 | ||
| 60 | + | /// The GitHub accounts whose actions workflows use most. A workflow's | |
| 61 | + | /// `uses: actions/setup-node@v4` means GitHub's `actions`, so nobody may | |
| 62 | + | /// register these on g1t and be mistaken for them. They are not reserved | |
| 63 | + | /// as [`is_reserved_name`] is: an account that already has one keeps it, | |
| 64 | + | /// mentions and invitations still find it, and staff can give one, as an | |
| 65 | + | /// alias, to the account that proves it owns the name on GitHub. | |
| 66 | + | const ACTION_OWNERS: &[&str] = &[ | |
| 67 | + | "actions", "actions-ecosystem", "actions-rs", "amannn", "anchore", "appleboy", "aquasecurity", | |
| 68 | + | "arduino", "astral-sh", "aws-actions", "azure", "browser-actions", "bufbuild", "cachix", | |
| 69 | + | "changesets", "chromaui", "cloudflare", "codecov", "conda-incubator", "coursier", "crazy-max", | |
| 70 | + | "cypress-io", "dawidd6", "denoland", "dependabot", "dessant", "determinatesystems", | |
| 71 | + | "devcontainers", "digitalocean", "docker", "dorny", "dtolnay", "endbug", "erlef", | |
| 72 | + | "firebaseextended", "fluxcd", "getsentry", "github", "golangci", "google-github-actions", | |
| 73 | + | "googleapis", "goreleaser", "gradle", "gradle-update", "hadolint", "hashicorp", | |
| 74 | + | "haskell-actions", "hecrj", "helm", "ilammy", "jamesives", "jetbrains", "julia-actions", | |
| 75 | + | "kubernetes-sigs", "lukka", "marocchino", "microsoft", "mikepenz", "mozilla-actions", "msys2", | |
| 76 | + | "mxschmitt", "ncipollo", "netlify", "nick-fields", "nwtgck", "octokit", "ossf", "oven-sh", | |
| 77 | + | "peaceiris", "peter-evans", "pnpm", "pre-commit", "pypa", "r-lib", "release-drafter", | |
| 78 | + | "renovatebot", "reviewdog", "rhysd", "ruby", "rust-lang", "sbt", "shivammathur", "sigstore", | |
| 79 | + | "slackapi", "slsa-framework", "snyk", "softprops", "sonarsource", "stefanzweifel", | |
| 80 | + | "step-security", "styfle", "subosito", "supabase", "super-linter", "superfly", "svenstaro", | |
| 81 | + | "swatinem", "taiki-e", "tj-actions", "treosh", "vercel", "webfactory", | |
| 82 | + | ]; | |
| 83 | + | ||
| 60 | 84 | /// Whether `value`, whatever its case, is a name nobody can register or | |
| 61 | 85 | /// rename a workspace to: a route, or g1t's own. | |
| 62 | 86 | pub fn is_reserved_name(value: &str) -> bool { | |
| 63 | 87 | is_route_name(value) || OWN.iter().any(|own| own.eq_ignore_ascii_case(value.trim())) | |
| 64 | 88 | } | |
| 65 | 89 | ||
| 90 | + | /// Whether `value`, whatever its case, names one of the GitHub accounts | |
| 91 | + | /// whose actions workflows use most: see [`ACTION_OWNERS`]. | |
| 92 | + | pub fn is_action_owner_name(value: &str) -> bool { | |
| 93 | + | ACTION_OWNERS.iter().any(|owner| owner.eq_ignore_ascii_case(value.trim())) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Whether a person or workspace may take `value` as its name now, by | |
| 97 | + | /// signing up, creating a workspace or renaming one: a valid namespace | |
| 98 | + | /// that no well-known GitHub action owner goes by. | |
| 99 | + | pub fn is_claimable_name(value: &str) -> bool { | |
| 100 | + | is_valid_namespace(value) && !is_action_owner_name(value) | |
| 101 | + | } | |
| 102 | + | ||
| 66 | 103 | /// Whether `value`, whatever its case, is one of the site's own routes. | |
| 67 | 104 | pub fn is_route_name(value: &str) -> bool { | |
| 68 | 105 | let value = value.trim(); | |
| ⋯ | |||
| 99 | 136 | /// reserved. | |
| 100 | 137 | pub fn claimable_namespace(value: &str) -> Option<String> { | |
| 101 | 138 | let value = value.trim().to_lowercase(); | |
| 102 | − | is_valid_namespace(&value).then_some(value) | |
| 139 | + | is_claimable_name(&value).then_some(value) | |
| 103 | 140 | } | |
| 104 | 141 | ||
| 105 | 142 | /// A username as someone typed it, if it can be registered: the name | |
| ⋯ | |||
| 114 | 151 | return None; | |
| 115 | 152 | } | |
| 116 | 153 | let canonical = display.to_ascii_lowercase(); | |
| 117 | − | is_valid_namespace(&canonical).then(|| Username { canonical, display: display.to_owned() }) | |
| 154 | + | is_claimable_name(&canonical).then(|| Username { canonical, display: display.to_owned() }) | |
| 118 | 155 | } | |
| 119 | 156 | ||
| 120 | 157 | /// A username in its two forms: see [`claimable_username`]. | |
| ⋯ | |||
| 169 | 206 | } | |
| 170 | 207 | ||
| 171 | 208 | #[test] | |
| 209 | + | fn github_action_owners_cannot_be_claimed_but_stay_findable() { | |
| 210 | + | for name in ["actions", "Actions", " docker ", "aws-actions", "Swatinem"] { | |
| 211 | + | assert!(is_action_owner_name(name), "{name}"); | |
| 212 | + | assert_eq!(claimable_namespace(name), None, "{name}"); | |
| 213 | + | assert_eq!(claimable_username(name), None, "{name}"); | |
| 214 | + | } | |
| 215 | + | // An account that has one already is still a valid name to find, | |
| 216 | + | // mention and invite, can be given one as an alias, and is not | |
| 217 | + | // protected the way g1t's own names are. | |
| 218 | + | assert!(is_valid_namespace("actions")); | |
| 219 | + | assert!(!is_reserved_name("actions")); | |
| 220 | + | assert_eq!(aliasable_name("Actions").as_deref(), Some("actions")); | |
| 221 | + | assert!(!is_action_owner_name("actions-team")); | |
| 222 | + | for owner in ACTION_OWNERS { | |
| 223 | + | assert!(is_namespace_shaped(owner) && *owner == owner.to_ascii_lowercase(), "{owner}"); | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | #[test] | |
| 172 | 228 | fn names_near_g1ts_are_anyones() { | |
| 173 | 229 | assert!(!is_reserved_name("g1t-agents")); | |
| 174 | 230 | assert_eq!(claimable_namespace("G1T-Bot").as_deref(), Some("g1t-bot")); | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { USERNAME_PATTERN, canonicalUsername, isReservedName, isValidNamespace, isValidUsername, shownUsername } from "./names.ts"; | |
| 4 | + | import { USERNAME_PATTERN, canonicalUsername, isActionOwnerName, isClaimableName, isReservedName, isValidNamespace, isValidUsername, shownUsername } from "./names.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("usernames take letters of either case, digits and single hyphens, up to 39", () => { | |
| 7 | 7 | for (const good of ["ana", "Ana", "ANA-Lopez", "a", "A1", "x".repeat(39)]) assert.ok(isValidUsername(good), good); | |
| ⋯ | |||
| 31 | 31 | for (const name of ["Ana", "ana-lopez", "A1"]) assert.ok(pattern.test(name), name); | |
| 32 | 32 | for (const name of ["-ana", "ana-", "an--a", "an_a"]) assert.ok(!pattern.test(name), name); | |
| 33 | 33 | }); | |
| 34 | + | ||
| 35 | + | test("well-known GitHub action owners cannot be claimed, but stay valid names", () => { | |
| 36 | + | for (const name of ["actions", "Docker", " aws-actions "]) { | |
| 37 | + | assert.ok(isActionOwnerName(name), name); | |
| 38 | + | assert.ok(!isValidUsername(name), name); | |
| 39 | + | } | |
| 40 | + | assert.ok(!isClaimableName("actions")); | |
| 41 | + | assert.ok(isValidNamespace("actions")); | |
| 42 | + | assert.ok(isClaimableName("actions-team")); | |
| 43 | + | }); | |
| 25 | 25 | "download", "downloads", | |
| 26 | 26 | ]); | |
| 27 | 27 | ||
| 28 | + | /** | |
| 29 | + | * The GitHub accounts whose actions workflows use most. `uses: | |
| 30 | + | * actions/setup-node@v4` means GitHub's `actions`, so nobody may register | |
| 31 | + | * these on g1t; an account that already has one keeps it. The list is | |
| 32 | + | * crates/contracts/src/names.rs's `ACTION_OWNERS`. | |
| 33 | + | */ | |
| 34 | + | const ACTION_OWNERS = new Set([ | |
| 35 | + | "actions", "actions-ecosystem", "actions-rs", "amannn", "anchore", "appleboy", "aquasecurity", | |
| 36 | + | "arduino", "astral-sh", "aws-actions", "azure", "browser-actions", "bufbuild", "cachix", | |
| 37 | + | "changesets", "chromaui", "cloudflare", "codecov", "conda-incubator", "coursier", "crazy-max", | |
| 38 | + | "cypress-io", "dawidd6", "denoland", "dependabot", "dessant", "determinatesystems", | |
| 39 | + | "devcontainers", "digitalocean", "docker", "dorny", "dtolnay", "endbug", "erlef", | |
| 40 | + | "firebaseextended", "fluxcd", "getsentry", "github", "golangci", "google-github-actions", | |
| 41 | + | "googleapis", "goreleaser", "gradle", "gradle-update", "hadolint", "hashicorp", | |
| 42 | + | "haskell-actions", "hecrj", "helm", "ilammy", "jamesives", "jetbrains", "julia-actions", | |
| 43 | + | "kubernetes-sigs", "lukka", "marocchino", "microsoft", "mikepenz", "mozilla-actions", "msys2", | |
| 44 | + | "mxschmitt", "ncipollo", "netlify", "nick-fields", "nwtgck", "octokit", "ossf", "oven-sh", | |
| 45 | + | "peaceiris", "peter-evans", "pnpm", "pre-commit", "pypa", "r-lib", "release-drafter", | |
| 46 | + | "renovatebot", "reviewdog", "rhysd", "ruby", "rust-lang", "sbt", "shivammathur", "sigstore", | |
| 47 | + | "slackapi", "slsa-framework", "snyk", "softprops", "sonarsource", "stefanzweifel", | |
| 48 | + | "step-security", "styfle", "subosito", "supabase", "super-linter", "superfly", "svenstaro", | |
| 49 | + | "swatinem", "taiki-e", "tj-actions", "treosh", "vercel", "webfactory", | |
| 50 | + | ]); | |
| 51 | + | ||
| 52 | + | /** Whether `value`, whatever its case, names a well-known GitHub action owner. */ | |
| 53 | + | export function isActionOwnerName(value: string): boolean { | |
| 54 | + | return ACTION_OWNERS.has(value.trim().toLowerCase()); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /** Whether a person or workspace may take `value` as its name now: valid, and no well-known action owner's. */ | |
| 58 | + | export function isClaimableName(value: string): boolean { | |
| 59 | + | return isValidNamespace(value) && !isActionOwnerName(value); | |
| 60 | + | } | |
| 61 | + | ||
| 28 | 62 | /** Whether `value`, whatever its case, is a name nobody can register: a route, or g1t's own. */ | |
| 29 | 63 | export function isReservedName(value: string): boolean { | |
| 30 | 64 | return RESERVED.has(value.trim().toLowerCase()); | |
| ⋯ | |||
| 55 | 89 | ||
| 56 | 90 | export function isValidUsername(value: string): boolean { | |
| 57 | 91 | const name = value.trim(); | |
| 58 | − | return USERNAME.test(name) && !isReservedName(name); | |
| 92 | + | return USERNAME.test(name) && !isReservedName(name) && !isActionOwnerName(name); | |
| 59 | 93 | } | |
| 60 | 94 | ||
| 61 | 95 | /** The name a person is found, linked and mentioned by: lowercased. */ | |
| 75 | 75 | Ok(()) | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | + | /// Whether `target` on g1t answers for its `owner/repo` name even when | |
| 79 | + | /// GitHub has a repository of the same name: when it is in the caller's own | |
| 80 | + | /// workspace, or is one of g1t's own (`g1t` is a name nobody can register). | |
| 81 | + | /// Any other workspace's repository answers only for names GitHub doesn't | |
| 82 | + | /// publish. | |
| 83 | + | pub(crate) fn answers_for_its_name(caller: &Repo, target: &Repo) -> bool { | |
| 84 | + | caller.namespace.eq_ignore_ascii_case(&target.namespace) || target.namespace.eq_ignore_ascii_case("g1t") | |
| 85 | + | } | |
| 86 | + | ||
| 78 | 87 | /// Where another repository's action or workflow comes from. | |
| 79 | 88 | pub(crate) enum Found { | |
| 80 | 89 | /// g1t has it, and the caller may use it. | |
| ⋯ | |||
| 91 | 100 | let Some(target) = self.visible_repo(&path, &Some(ws.clone())).await? else { | |
| 92 | 101 | return Ok(Outcome::Ok(Found::GitHub)); | |
| 93 | 102 | }; | |
| 103 | + | // A name GitHub publishes means GitHub's repository, unless the | |
| 104 | + | // repository on g1t is the caller's own workspace's or g1t's: anyone | |
| 105 | + | // can make a workspace called `actions`, and must not get to answer | |
| 106 | + | // for `actions/setup-node`. | |
| 107 | + | if !answers_for_its_name(caller, &target) && self.github_publishes(&used.full_name()).await? != Some(false) { | |
| 108 | + | return Ok(Outcome::Ok(Found::GitHub)); | |
| 109 | + | } | |
| 94 | 110 | let level = if target.is_private && target.id != caller.id { self.access_level_of(&target.id).await? } else { "none" }; | |
| 95 | 111 | Ok(match may_use(caller, &target, level) { | |
| 96 | 112 | Ok(()) => Outcome::Ok(Found::G1t(target)), | |
| ⋯ | |||
| 437 | 453 | assert!(may_use(&repo("x", "other", true), &shared, "organization").unwrap_err().contains("only repositories in acme")); | |
| 438 | 454 | assert!(may_use(&repo("site", "acme", false), &shared, "organization").unwrap_err().contains("public repository")); | |
| 439 | 455 | } | |
| 456 | + | ||
| 457 | + | #[test] | |
| 458 | + | fn only_its_own_workspace_or_g1t_answers_for_a_name_github_has() { | |
| 459 | + | let web = repo("web", "acme", true); | |
| 460 | + | // The caller's own workspace, and g1t's own actions, whatever GitHub has. | |
| 461 | + | assert!(answers_for_its_name(&web, &repo("setup", "acme", true))); | |
| 462 | + | assert!(answers_for_its_name(&web, &repo("setup", "ACME", false))); | |
| 463 | + | assert!(answers_for_its_name(&web, &repo("deploy", "g1t", false))); | |
| 464 | + | // Anyone else's public repository named like GitHub's `actions/setup-node` | |
| 465 | + | // does not: GitHub's is used when GitHub has one. | |
| 466 | + | assert!(!answers_for_its_name(&web, &repo("setup-node", "actions", false))); | |
| 467 | + | } | |
| 440 | 468 | } | |
| 129 | 129 | Some(body) | |
| 130 | 130 | } | |
| 131 | 131 | ||
| 132 | + | /// The status GitHub answers a GET with, or None when it doesn't answer. | |
| 133 | + | async fn status_of(url: &str) -> Option<u16> { | |
| 134 | + | let headers = worker::Headers::new(); | |
| 135 | + | headers.set("user-agent", "git/2.47.0 (g1t)").ok()?; | |
| 136 | + | let mut init = worker::RequestInit::new(); | |
| 137 | + | init.with_method(worker::Method::Get).with_headers(headers); | |
| 138 | + | let request = worker::Request::new_with_init(url, &init).ok()?; | |
| 139 | + | let response = worker::Fetch::Request(request).send().await.ok()?; | |
| 140 | + | Some(response.status_code()) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// What GitHub's answer to a repository's advertisement says about it: | |
| 144 | + | /// Some(true) when it publishes the repository, Some(false) when it says | |
| 145 | + | /// there is none it will show (GitHub answers 401 for a repository that | |
| 146 | + | /// doesn't exist as for a private one), None for anything else, such as a | |
| 147 | + | /// rate limit or an error. | |
| 148 | + | pub(crate) fn published_by(status: Option<u16>) -> Option<bool> { | |
| 149 | + | match status? { | |
| 150 | + | 200 => Some(true), | |
| 151 | + | 401 | 404 => Some(false), | |
| 152 | + | _ => None, | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 132 | 156 | #[derive(Deserialize)] | |
| 133 | 157 | struct RefRow { | |
| 134 | 158 | sha: String, | |
| ⋯ | |||
| 152 | 176 | Ok(json!({ "source": "github", "sha": sha, "archive": archive, "note": note })) | |
| 153 | 177 | } | |
| 154 | 178 | ||
| 179 | + | /// Whether GitHub publishes `repository` (`owner/repo`): Some(true) when | |
| 180 | + | /// it does, or once named a commit of it for a job; Some(false) when it | |
| 181 | + | /// says it has no such repository; None when it could not say. | |
| 182 | + | pub(crate) async fn github_publishes(&self, repository: &str) -> Result<Option<bool>> { | |
| 183 | + | let repository = repository.to_ascii_lowercase(); | |
| 184 | + | let seen = self | |
| 185 | + | .db | |
| 186 | + | .prepare("SELECT sha FROM action_refs WHERE repository = ? LIMIT 1") | |
| 187 | + | .bind(&[repository.as_str().into()])? | |
| 188 | + | .first::<Value>(None) | |
| 189 | + | .await?; | |
| 190 | + | if seen.is_some() { | |
| 191 | + | return Ok(Some(true)); | |
| 192 | + | } | |
| 193 | + | Ok(published_by(status_of(&format!("https://github.com/{repository}.git/info/refs?service=git-upload-pack")).await)) | |
| 194 | + | } | |
| 195 | + | ||
| 155 | 196 | /// The commit `git_ref` names in GitHub's `repository`, and a note when | |
| 156 | 197 | /// it is the one it last named because GitHub did not answer. | |
| 157 | 198 | async fn resolve_github(&self, repository: &str, git_ref: &str, now: u64) -> Result<(Option<String>, Option<String>)> { | |
| ⋯ | |||
| 338 | 379 | } | |
| 339 | 380 | ||
| 340 | 381 | #[test] | |
| 382 | + | fn only_an_answer_from_github_says_it_has_no_such_repository() { | |
| 383 | + | assert_eq!(published_by(Some(200)), Some(true)); | |
| 384 | + | assert_eq!(published_by(Some(401)), Some(false)); | |
| 385 | + | assert_eq!(published_by(Some(404)), Some(false)); | |
| 386 | + | // A rate limit, an error or no answer says nothing, so the name stays GitHub's. | |
| 387 | + | assert_eq!(published_by(Some(429)), None); | |
| 388 | + | assert_eq!(published_by(Some(503)), None); | |
| 389 | + | assert_eq!(published_by(None), None); | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | #[test] | |
| 341 | 393 | fn an_advertisement_lists_its_refs_capabilities_aside() { | |
| 342 | 394 | let refs = parse_advertisement(&advertisement()); | |
| 343 | 395 | assert_eq!(refs.len(), 6); | |
| 26 | 26 | use g1t_contracts::github::*; | |
| 27 | 27 | use g1t_contracts::identity::{SignedIn, UserArgs}; | |
| 28 | 28 | use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after}; | |
| 29 | − | use g1t_contracts::{FailureCode, Outcome, User, claimable_username, is_reserved_name, is_valid_namespace, new_id}; | |
| 29 | + | use g1t_contracts::{FailureCode, Outcome, User, claimable_username, is_action_owner_name, is_claimable_name, is_reserved_name, is_valid_namespace, new_id}; | |
| 30 | 30 | use g1t_kit::now_ms; | |
| 31 | 31 | use g1t_secrets::Sealer; | |
| 32 | 32 | use serde::{Deserialize, Serialize}; | |
| ⋯ | |||
| 137 | 137 | } | |
| 138 | 138 | let out: String = out.trim_matches('-').chars().take(39).collect(); | |
| 139 | 139 | let out = out.trim_end_matches('-'); | |
| 140 | − | if is_reserved_name(out) { format!("{out}-gh") } else { out.to_owned() } | |
| 140 | + | if is_reserved_name(out) || is_action_owner_name(out) { format!("{out}-gh") } else { out.to_owned() } | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | 143 | /// What a return from GitHub should do. | |
| ⋯ | |||
| 458 | 458 | async fn username_free(&self, username: &str) -> Result<bool> { | |
| 459 | 459 | // Taken in any case: names are found lowercased. | |
| 460 | 460 | let username = &username.to_ascii_lowercase(); | |
| 461 | − | if !is_valid_namespace(username) { | |
| 461 | + | if !is_claimable_name(username) { | |
| 462 | 462 | return Ok(false); | |
| 463 | 463 | } | |
| 464 | 464 | let taken = self | |
| ⋯ | |||
| 1058 | 1058 | assert!(is_valid_namespace(&suggest_username("g1t"))); | |
| 1059 | 1059 | assert!(claimable_username(&suggest_username("G1T")).is_some()); | |
| 1060 | 1060 | assert_eq!(suggest_username("g1t-fan"), "g1t-fan"); | |
| 1061 | + | // A GitHub account named like a well-known action owner signs up | |
| 1062 | + | // under a name of its own too. | |
| 1063 | + | assert_eq!(suggest_username("Docker"), "Docker-gh"); | |
| 1064 | + | assert!(claimable_username(&suggest_username("actions")).is_some()); | |
| 1061 | 1065 | // So signing up goes ahead, rather than failing on the login. | |
| 1062 | 1066 | let facts = Facts { suggestion: suggest_username("g1t"), ..facts() }; | |
| 1063 | 1067 | assert_eq!(decide(&facts), Decision::Create("g1t-gh".to_owned())); | |
| 16 | 16 | use g1t_contracts::events::{NewEvent, Publish, WorkspaceRenamed}; | |
| 17 | 17 | use g1t_contracts::identity::*; | |
| 18 | 18 | use g1t_contracts::time::rfc3339; | |
| 19 | − | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, is_valid_namespace}; | |
| 19 | + | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, is_action_owner_name, is_valid_namespace}; | |
| 20 | 20 | use g1t_kit::now_ms; | |
| 21 | 21 | use serde::Deserialize; | |
| 22 | 22 | use worker::Result; | |
| ⋯ | |||
| 71 | 71 | "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.", | |
| 72 | 72 | ); | |
| 73 | 73 | } | |
| 74 | + | if is_action_owner_name(facts.wanted) { | |
| 75 | + | return refuse( | |
| 76 | + | FailureCode::Invalid, | |
| 77 | + | "That name is a GitHub account whose actions workflows use, so it is kept for that account. If it is yours, contact support to prove it.", | |
| 78 | + | ); | |
| 79 | + | } | |
| 74 | 80 | if facts.wanted == facts.current { | |
| 75 | 81 | return refuse(FailureCode::Invalid, "That is already this workspace's name."); | |
| 76 | 82 | } | |
| ⋯ | |||
| 359 | 365 | } | |
| 360 | 366 | ||
| 361 | 367 | #[test] | |
| 368 | + | fn no_workspace_is_renamed_to_a_github_action_owner() { | |
| 369 | + | for name in ["actions", "docker", "aws-actions"] { | |
| 370 | + | assert_eq!(refused(&facts("acme", name)), FailureCode::Invalid, "{name}"); | |
| 371 | + | } | |
| 372 | + | assert!(check(&facts("acme", "actions-team")).is_ok()); | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | #[test] | |
| 362 | 376 | fn refuses_names_in_use() { | |
| 363 | 377 | let taken = Facts { | |
| 364 | 378 | someone_elses_username: true, | |