g1t/services/runner/src/bump.ts

124 lines5,085 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1/**
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 */
6import type { BumpArgs, RepoPath, User } from "@g1t/contracts";
7
8/** g1t's own identity: `g1t_contracts::system::{ID, USERNAME}`. */
9export const SYSTEM_ID = "g1t";
10export const SYSTEM_USERNAME = "g1t";
11
12/** The ecosystems the runner can update, by OSV's names. */
13export const BUMP_ECOSYSTEMS: readonly string[] = ["npm", "crates.io", "Go", "PyPI"];
14
15/** Long enough to clone, resolve and push; then the token stops working. */
16export const BUMP_TOKEN_TTL_SECONDS = 30 * 60;
17
18/** A security update's time cap, and what is reserved for it. */
19export const BUMP_MINUTES = 20;
20
21/** The most lockfiles one update names. */
22const 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 */
30export 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. */
41export function isSystem(user: User | null | undefined): boolean {
42 return user?.kind === "system";
43}
44
45function 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. */
50function 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. */
55function 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 */
66export 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. */
94export 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. */
99export 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 */
107export 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}