Skip to content

g1t/services/runner/src/bump.ts

238 lines11,167 bytesCodeBlame
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 */
7import type { BumpArgs, BumpPackage, BumpRegistry, RepoPath, User } from "@g1t/contracts";
8
9/** g1t's own identity: `g1t_contracts::system::{ID, USERNAME}`. */
10export const SYSTEM_ID = "g1t";
11export const SYSTEM_USERNAME = "g1t";
12
13/** The ecosystems the runner can update, by OSV's names. */
14export const BUMP_ECOSYSTEMS: readonly string[] = ["npm", "crates.io", "Go", "PyPI"];
15
16/** How a version update changes a manifest's requirement (`versioning-strategy`). */
17export 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. */
20export 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. */
23export const BUMP_TOKEN_TTL_SECONDS = 30 * 60;
24
25/** An update's time cap, and what is reserved for it. */
26export const BUMP_MINUTES = 20;
27
28/** The most lockfiles one update names. */
29const MAX_LOCKFILES = 50;
30
31/** The most packages one grouped update raises. */
32const MAX_PACKAGES = 50;
33
34/** The most private registries one update reads. */
35const MAX_REGISTRIES = 20;
36
37/** The longest text in a registry's entry. */
38const 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 */
46export 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. */
57export function isSystem(user: User | null | undefined): boolean {
58 return user?.kind === "system";
59}
60
61function 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. */
66function 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. */
71function 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 */
81export 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. */
90function 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. */
95function 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 */
128export 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. */
177export 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. */
182export 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. */
187export 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 */
199export 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 */
228export 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}