g1t/services/runner/src/bump.ts
| 1 | /** |
| 2 | * Security updates (`RunnerService.startBump`): what the security service |
| 3 | * asks for, checked, and the sandbox it becomes, which runs the runner's |
| 4 | * `bump` mode (crates/runner bump.rs). Pure, so it is tested on its own. |
| 5 | */ |
| 6 | import type { BumpArgs, RepoPath, User } from "@g1t/contracts"; |
| 7 | |
| 8 | /** g1t's own identity: `g1t_contracts::system::{ID, USERNAME}`. */ |
| 9 | export const SYSTEM_ID = "g1t"; |
| 10 | export const SYSTEM_USERNAME = "g1t"; |
| 11 | |
| 12 | /** The ecosystems the runner can update, by OSV's names. */ |
| 13 | export const BUMP_ECOSYSTEMS: readonly string[] = ["npm", "crates.io", "Go", "PyPI"]; |
| 14 | |
| 15 | /** Long enough to clone, resolve and push; then the token stops working. */ |
| 16 | export const BUMP_TOKEN_TTL_SECONDS = 30 * 60; |
| 17 | |
| 18 | /** A security update's time cap, and what is reserved for it. */ |
| 19 | export const BUMP_MINUTES = 20; |
| 20 | |
| 21 | /** The most lockfiles one update names. */ |
| 22 | const MAX_LOCKFILES = 50; |
| 23 | |
| 24 | /** |
| 25 | * g1t itself, working in `workspace`: the actor of the work it does on its |
| 26 | * own, such as a security update or an agent it puts on one. Mirrors |
| 27 | * `g1t_contracts::User::system`. Identity makes its run credentials act |
| 28 | * for the workspace. |
| 29 | */ |
| 30 | export function systemActor(workspace: string): User { |
| 31 | return { |
| 32 | id: SYSTEM_ID, |
| 33 | username: SYSTEM_USERNAME, |
| 34 | kind: "system", |
| 35 | verified: true, |
| 36 | workspaces: [{ slug: workspace.toLowerCase(), role: "member" }], |
| 37 | }; |
| 38 | } |
| 39 | |
| 40 | /** Whether `user` is g1t itself. */ |
| 41 | export function isSystem(user: User | null | undefined): boolean { |
| 42 | return user?.kind === "system"; |
| 43 | } |
| 44 | |
| 45 | function isText(value: unknown): value is string { |
| 46 | return typeof value === "string" && value.trim().length > 0; |
| 47 | } |
| 48 | |
| 49 | /** A name or version the runner passes to a tool as one argument. */ |
| 50 | function isArgument(text: string): boolean { |
| 51 | return text.length <= 214 && !text.startsWith("-") && /^[A-Za-z0-9@/._+~-]+$/.test(text); |
| 52 | } |
| 53 | |
| 54 | /** A lockfile's path from the repository's root, inside it. */ |
| 55 | function isLockfilePath(path: unknown): boolean { |
| 56 | if (!isText(path) || path.length > 512 || path.startsWith("/") || path.includes("\\")) return false; |
| 57 | return path.split("/").every((part) => part !== "" && part !== ".."); |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * What is wrong with a request for a security update, or null when it can |
| 62 | * start: a repository, an ecosystem the runner updates, a package and |
| 63 | * version it can pass to a tool, lockfiles inside the repository, and a |
| 64 | * branch under `prefix` (`UPDATE_BRANCH_PREFIX`). |
| 65 | */ |
| 66 | export function bumpProblem(input: unknown, prefix: string): string | null { |
| 67 | if (!input || typeof input !== "object") return "A security update needs its arguments."; |
| 68 | const args = input as Partial<BumpArgs>; |
| 69 | if (!args.repo || !isText(args.repo.namespace) || !isText(args.repo.name)) return "A security update needs its repository."; |
| 70 | if (!isText(args.ecosystem) || !BUMP_ECOSYSTEMS.includes(args.ecosystem)) { |
| 71 | return `g1t cannot update ${String(args.ecosystem)} dependencies; it updates ${BUMP_ECOSYSTEMS.join(", ")}.`; |
| 72 | } |
| 73 | if (!isText(args.package) || !isArgument(args.package.trim())) return "A security update needs the package's name."; |
| 74 | if (!isText(args.version) || !isArgument(args.version.trim())) return "A security update needs the version to raise it to."; |
| 75 | if (!Array.isArray(args.lockfiles) || args.lockfiles.length === 0 || args.lockfiles.length > MAX_LOCKFILES) { |
| 76 | return `A security update names between 1 and ${MAX_LOCKFILES} lockfiles.`; |
| 77 | } |
| 78 | const outside = args.lockfiles.find((path) => !isLockfilePath(path)); |
| 79 | if (outside !== undefined) return `${String(outside)} is not a path inside the repository.`; |
| 80 | if ( |
| 81 | !isText(args.branch) || |
| 82 | !args.branch.startsWith(prefix) || |
| 83 | args.branch.length <= prefix.length || |
| 84 | args.branch.length > 200 || |
| 85 | args.branch.includes("..") || |
| 86 | /[\s~^:?*[\\]/.test(args.branch) |
| 87 | ) { |
| 88 | return `A security update's branch starts with ${prefix}.`; |
| 89 | } |
| 90 | return null; |
| 91 | } |
| 92 | |
| 93 | /** The sandbox for one update: the same branch is the same sandbox. */ |
| 94 | export function bumpSandboxName(args: BumpArgs): string { |
| 95 | return `bump:${args.repo.namespace}/${args.repo.name}:${args.branch}`.toLowerCase(); |
| 96 | } |
| 97 | |
| 98 | /** The repository's clone URL, as every sandbox is given it. */ |
| 99 | export function remoteOf(repo: RepoPath): string { |
| 100 | return `https://g1t.sh/${repo.namespace}/${repo.name}.git`; |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * The sandbox's variables: what `bump` mode reads. `token` is a run |
| 105 | * credential that reads the repository and pushes only `args.branch`. |
| 106 | */ |
| 107 | export function bumpEnv(args: BumpArgs, baseBranch: string, token: string): Record<string, string> { |
| 108 | const pkg = args.package.trim(); |
| 109 | const version = args.version.trim(); |
| 110 | return { |
| 111 | MODE: "bump", |
| 112 | // The credential acts for the workspace; git sends any name with it. |
| 113 | G1T_USER: args.repo.namespace.toLowerCase(), |
| 114 | G1T_TOKEN: token, |
| 115 | GIT_REMOTE: remoteOf(args.repo), |
| 116 | GIT_BRANCH_BASE: baseBranch, |
| 117 | GIT_BRANCH: args.branch, |
| 118 | BUMP_ECOSYSTEM: args.ecosystem, |
| 119 | BUMP_PACKAGE: pkg, |
| 120 | BUMP_VERSION: version, |
| 121 | BUMP_LOCKFILES: JSON.stringify(args.lockfiles), |
| 122 | COMMIT_MESSAGE: isText(args.message) ? args.message : `Update ${pkg} to ${version}`, |
| 123 | }; |
| 124 | } |