| 1 | /** |
| 2 | * Security and version updates (`RunnerService.startBump`): what the |
| 3 | * security service asks for, checked, and the sandbox it becomes, which |
| 4 | * runs the runner's `bump` mode (crates/runner bump.rs). Pure, so it is |
| 5 | * tested on its own. |
| 6 | */ |
| 7 | import type { BumpArgs, BumpPackage, BumpRegistry, RepoPath, User } from "@g1t/contracts"; |
| 8 | |
| 9 | /** g1t's own identity: `g1t_contracts::system::{ID, USERNAME}`. */ |
| 10 | export const SYSTEM_ID = "g1t"; |
| 11 | export const SYSTEM_USERNAME = "g1t"; |
| 12 | |
| 13 | /** The ecosystems the runner can update, by OSV's names. */ |
| 14 | export const BUMP_ECOSYSTEMS: readonly string[] = ["npm", "crates.io", "Go", "PyPI"]; |
| 15 | |
| 16 | /** How a version update changes a manifest's requirement (`versioning-strategy`). */ |
| 17 | export const BUMP_STRATEGIES: readonly string[] = ["increase", "increase-if-necessary", "widen", "lockfile-only"]; |
| 18 | |
| 19 | /** The kinds of private registry the runner can point its tools at. */ |
| 20 | export const BUMP_REGISTRY_TYPES: readonly string[] = ["npm-registry", "cargo-registry", "python-index", "goproxy-server"]; |
| 21 | |
| 22 | /** Long enough to clone, resolve and push; then the token stops working. */ |
| 23 | export const BUMP_TOKEN_TTL_SECONDS = 30 * 60; |
| 24 | |
| 25 | /** An update's time cap, and what is reserved for it. */ |
| 26 | export const BUMP_MINUTES = 20; |
| 27 | |
| 28 | /** The most lockfiles one update names. */ |
| 29 | const MAX_LOCKFILES = 50; |
| 30 | |
| 31 | /** The most packages one grouped update raises. */ |
| 32 | const MAX_PACKAGES = 50; |
| 33 | |
| 34 | /** The most private registries one update reads. */ |
| 35 | const MAX_REGISTRIES = 20; |
| 36 | |
| 37 | /** The longest text in a registry's entry. */ |
| 38 | const MAX_REGISTRY_TEXT = 2000; |
| 39 | |
| 40 | /** |
| 41 | * g1t itself, working in `workspace`: the actor of the work it does on its |
| 42 | * own, such as a security update or an agent it puts on one. Mirrors |
| 43 | * `g1t_contracts::User::system`. Identity makes its run credentials act |
| 44 | * for the workspace. |
| 45 | */ |
| 46 | export function systemActor(workspace: string): User { |
| 47 | return { |
| 48 | id: SYSTEM_ID, |
| 49 | username: SYSTEM_USERNAME, |
| 50 | kind: "system", |
| 51 | verified: true, |
| 52 | workspaces: [{ slug: workspace.toLowerCase(), role: "member" }], |
| 53 | }; |
| 54 | } |
| 55 | |
| 56 | /** Whether `user` is g1t itself. */ |
| 57 | export function isSystem(user: User | null | undefined): boolean { |
| 58 | return user?.kind === "system"; |
| 59 | } |
| 60 | |
| 61 | function isText(value: unknown): value is string { |
| 62 | return typeof value === "string" && value.trim().length > 0; |
| 63 | } |
| 64 | |
| 65 | /** A name or version the runner passes to a tool as one argument. */ |
| 66 | function isArgument(text: string): boolean { |
| 67 | return text.length <= 214 && !text.startsWith("-") && /^[A-Za-z0-9@/._+~-]+$/.test(text); |
| 68 | } |
| 69 | |
| 70 | /** A lockfile's path from the repository's root, inside it. */ |
| 71 | function isLockfilePath(path: unknown): boolean { |
| 72 | if (!isText(path) || path.length > 512 || path.startsWith("/") || path.includes("\\")) return false; |
| 73 | return path.split("/").every((part) => part !== "" && part !== ".."); |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Whether git takes `branch` as a branch's name, short of a full ref: what |
| 78 | * a version update's branch, named by the dependency update file, must be. |
| 79 | * Mirrors `branch_name_ok` in crates/runner bump.rs. |
| 80 | */ |
| 81 | export function isBranchName(branch: unknown): branch is string { |
| 82 | if (!isText(branch) || branch.length > 200) return false; |
| 83 | // eslint-disable-next-line no-control-regex |
| 84 | if (/[\s\x00-\x1f\x7f~^:?*[\\]/.test(branch) || branch.includes("..") || branch.includes("@{")) return false; |
| 85 | if (branch.startsWith("-") || branch.startsWith("/") || branch.startsWith("refs/")) return false; |
| 86 | return !branch.endsWith("/") && !branch.endsWith(".lock"); |
| 87 | } |
| 88 | |
| 89 | /** Absent, or text no longer than a registry's entry holds. */ |
| 90 | function isRegistryText(value: unknown): boolean { |
| 91 | return value === undefined || (typeof value === "string" && value.length <= MAX_REGISTRY_TEXT); |
| 92 | } |
| 93 | |
| 94 | /** What is wrong with one private registry, or null. */ |
| 95 | function registryProblem(registry: unknown): string | null { |
| 96 | if (!registry || typeof registry !== "object") return "A private registry needs its type and URL."; |
| 97 | const entry = registry as Partial<BumpRegistry>; |
| 98 | if (!isText(entry.type) || !BUMP_REGISTRY_TYPES.includes(entry.type)) { |
| 99 | return `g1t cannot read a ${String(entry.type)} registry; it reads ${BUMP_REGISTRY_TYPES.join(", ")}.`; |
| 100 | } |
| 101 | if (!isText(entry.url) || entry.url.length > MAX_REGISTRY_TEXT || !/^https:\/\/[^\s/]+\S*$/.test(entry.url)) { |
| 102 | return "A private registry's URL starts with https://."; |
| 103 | } |
| 104 | if (!isRegistryText(entry.username) || !isRegistryText(entry.password) || !isRegistryText(entry.token)) { |
| 105 | return `A private registry's credentials are text of at most ${MAX_REGISTRY_TEXT} characters.`; |
| 106 | } |
| 107 | if (entry.replacesBase !== undefined && typeof entry.replacesBase !== "boolean") { |
| 108 | return "A private registry's replacesBase is true or false."; |
| 109 | } |
| 110 | if (entry.scopes !== undefined) { |
| 111 | if (!Array.isArray(entry.scopes) || entry.scopes.length > MAX_REGISTRIES) { |
| 112 | return `A private registry serves at most ${MAX_REGISTRIES} scopes.`; |
| 113 | } |
| 114 | const scope = entry.scopes.find((scope) => typeof scope !== "string" || !/^@[A-Za-z0-9][A-Za-z0-9._-]*$/.test(scope)); |
| 115 | if (scope !== undefined) return `${String(scope)} is not an npm scope.`; |
| 116 | } |
| 117 | return null; |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * What is wrong with a request for an update, or null when it can start: a |
| 122 | * repository, an ecosystem the runner updates, packages and versions it |
| 123 | * can pass to a tool, lockfiles inside the repository, and a branch. A |
| 124 | * security update's branch starts with `prefix` (`UPDATE_BRANCH_PREFIX`); a |
| 125 | * version update (`kind: "version"`) names any branch git takes, and may |
| 126 | * also choose a versioning strategy and name private registries. |
| 127 | */ |
| 128 | export function bumpProblem(input: unknown, prefix: string): string | null { |
| 129 | if (!input || typeof input !== "object") return "A security update needs its arguments."; |
| 130 | const args = input as Partial<BumpArgs>; |
| 131 | if (args.kind !== undefined && args.kind !== "version") return `g1t cannot make a ${String(args.kind)} update.`; |
| 132 | const versionUpdate = args.kind === "version"; |
| 133 | const what = versionUpdate ? "A version update" : "A security update"; |
| 134 | if (!args.repo || !isText(args.repo.namespace) || !isText(args.repo.name)) return `${what} needs its repository.`; |
| 135 | if (!isText(args.ecosystem) || !BUMP_ECOSYSTEMS.includes(args.ecosystem)) { |
| 136 | return `g1t cannot update ${String(args.ecosystem)} dependencies; it updates ${BUMP_ECOSYSTEMS.join(", ")}.`; |
| 137 | } |
| 138 | if (!isText(args.package) || !isArgument(args.package.trim())) return `${what} needs the package's name.`; |
| 139 | if (!isText(args.version) || !isArgument(args.version.trim())) return `${what} needs the version to raise it to.`; |
| 140 | if (args.packages !== undefined) { |
| 141 | if (!Array.isArray(args.packages) || args.packages.length > MAX_PACKAGES) return `${what} raises at most ${MAX_PACKAGES} packages.`; |
| 142 | for (const entry of args.packages as unknown[]) { |
| 143 | const { package: name, version } = (entry && typeof entry === "object" ? entry : {}) as Partial<BumpPackage>; |
| 144 | if (!isText(name) || !isArgument(name.trim())) return `${what} needs each package's name.`; |
| 145 | if (!isText(version) || !isArgument(version.trim())) return `${what} needs the version to raise ${name.trim()} to.`; |
| 146 | } |
| 147 | } |
| 148 | if (args.strategy !== undefined && (typeof args.strategy !== "string" || !BUMP_STRATEGIES.includes(args.strategy))) { |
| 149 | return `${String(args.strategy)} is not a versioning strategy; g1t knows ${BUMP_STRATEGIES.join(", ")}.`; |
| 150 | } |
| 151 | if (args.force !== undefined && typeof args.force !== "boolean") return `${what}'s force is true or false.`; |
| 152 | if (args.base !== undefined && !isBranchName(args.base)) return `${String(args.base)} is not a branch name git takes.`; |
| 153 | if (args.registries !== undefined) { |
| 154 | if (!Array.isArray(args.registries) || args.registries.length > MAX_REGISTRIES) { |
| 155 | return `${what} reads at most ${MAX_REGISTRIES} private registries.`; |
| 156 | } |
| 157 | for (const registry of args.registries as unknown[]) { |
| 158 | const problem = registryProblem(registry); |
| 159 | if (problem) return problem; |
| 160 | } |
| 161 | } |
| 162 | if (!Array.isArray(args.lockfiles) || args.lockfiles.length === 0 || args.lockfiles.length > MAX_LOCKFILES) { |
| 163 | return `${what} names between 1 and ${MAX_LOCKFILES} lockfiles.`; |
| 164 | } |
| 165 | const outside = args.lockfiles.find((path) => !isLockfilePath(path)); |
| 166 | if (outside !== undefined) return `${String(outside)} is not a path inside the repository.`; |
| 167 | if (versionUpdate) { |
| 168 | return isBranchName(args.branch) ? null : `${String(args.branch)} is not a branch name git takes.`; |
| 169 | } |
| 170 | if (!isBranchName(args.branch) || !args.branch.startsWith(prefix) || args.branch.length <= prefix.length) { |
| 171 | return `A security update's branch starts with ${prefix}.`; |
| 172 | } |
| 173 | return null; |
| 174 | } |
| 175 | |
| 176 | /** The sandbox for one update: the same branch is the same sandbox. */ |
| 177 | export function bumpSandboxName(args: BumpArgs): string { |
| 178 | return `bump:${args.repo.namespace}/${args.repo.name}:${args.branch}`.toLowerCase(); |
| 179 | } |
| 180 | |
| 181 | /** The repository's clone URL, as every sandbox is given it. */ |
| 182 | export function remoteOf(repo: RepoPath): string { |
| 183 | return `https://g1t.sh/${repo.namespace}/${repo.name}.git`; |
| 184 | } |
| 185 | |
| 186 | /** The packages an update raises: `packages`, or else its one package. */ |
| 187 | export function bumpPackages(args: BumpArgs): BumpPackage[] { |
| 188 | const listed = (args.packages ?? []).map((entry) => ({ package: entry.package.trim(), version: entry.version.trim() })); |
| 189 | return listed.length > 0 ? listed : [{ package: args.package.trim(), version: args.version.trim() }]; |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * The sandbox's variables: what `bump` mode reads. `token` is a run |
| 194 | * credential that reads the repository and pushes only `args.branch`. |
| 195 | * `BUMP_PACKAGE` and `BUMP_VERSION` are the first of `BUMP_PACKAGES`. |
| 196 | * `BUMP_REGISTRIES` holds credentials, which the runner writes only |
| 197 | * outside the clone. |
| 198 | */ |
| 199 | export function bumpEnv(args: BumpArgs, baseBranch: string, token: string): Record<string, string> { |
| 200 | const packages = bumpPackages(args); |
| 201 | const { package: pkg, version } = packages[0]!; |
| 202 | const named = packages.length === 1 ? `${pkg} to ${version}` : `${pkg} and ${packages.length - 1} more`; |
| 203 | return { |
| 204 | MODE: "bump", |
| 205 | // The credential acts for the workspace; git sends any name with it. |
| 206 | G1T_USER: args.repo.namespace.toLowerCase(), |
| 207 | G1T_TOKEN: token, |
| 208 | GIT_REMOTE: remoteOf(args.repo), |
| 209 | GIT_BRANCH_BASE: baseBranch, |
| 210 | GIT_BRANCH: args.branch, |
| 211 | BUMP_KIND: args.kind === "version" ? "version" : "security", |
| 212 | BUMP_ECOSYSTEM: args.ecosystem, |
| 213 | BUMP_PACKAGE: pkg, |
| 214 | BUMP_VERSION: version, |
| 215 | BUMP_PACKAGES: JSON.stringify(packages), |
| 216 | BUMP_STRATEGY: args.strategy ?? "increase", |
| 217 | BUMP_FORCE: args.force ? "1" : "0", |
| 218 | BUMP_REGISTRIES: JSON.stringify(args.registries ?? []), |
| 219 | BUMP_LOCKFILES: JSON.stringify(args.lockfiles), |
| 220 | COMMIT_MESSAGE: isText(args.message) ? args.message : `Update ${named}`, |
| 221 | }; |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * The hosts of an update's private registries, which its sandbox may reach |
| 226 | * on top of the public ones (`BUMP_HOSTS`). |
| 227 | */ |
| 228 | export function registryHosts(args: BumpArgs): string[] { |
| 229 | const hosts = new Set<string>(); |
| 230 | for (const registry of args.registries ?? []) { |
| 231 | try { |
| 232 | hosts.add(new URL(registry.url).host); |
| 233 | } catch { |
| 234 | // bumpProblem refuses a registry without a URL. |
| 235 | } |
| 236 | } |
| 237 | return [...hosts]; |
| 238 | } |