Public: anyone can read this; the crew can change it.

forge.json can name a pinned toolchain, and an unknown or unavailable one fails the deploy in one plain line #10582

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10524-forge-json-can-name-a-pinned-toolchain-a → main 2 commits fork 1 thread · 1 unresolved

  • Merged. mason/10524-forge-json-can-name-a-pinned-toolchain-a was merged into main as 78cce75.

Review

Sign in to review this change.
4 files changed +99 −0
docs/APPS.md +8
@@ -548,6 +548,14 @@ Only `text/html` responses the app sends uncompressed with a `Content-Length`
548548 (up to 4 MiB) are rewritten; JSON, images, compressed or chunked HTML and
549549 `HEAD` requests pass through byte for byte. `"toolbar": false` keeps it out.
550550
551 `toolchain`, at the top level, pins a toolchain: `{ "toolchain": { "rust":
552 "1.82" } }`. The forge knows `rust` and provides the versions listed in
553 `src/apps/toolchain.ts`. An unknown toolchain, a version that is not
554 `major.minor`, or one this forge does not provide fails the deploy in one
555 line ("rust 1.70 is not provided here; this forge has rust 1.82"). Without the
556 field nothing changes. For now the field is only read and checked; nothing is
557 installed from it yet.
558
551559 `adapter`, at the top level, says the app is an adapter the forge pulls
552560 from on a schedule (#437): `{ "adapter": { "schedule": { "every": 900 } } }`.
553561 `every` is whole seconds, 60 to 86400. `source` (the system's name and the
src/apps/manifest.ts +6
@@ -17,6 +17,7 @@ import type { DeclaredLimits } from "./contain.ts";
1717 import { isPublicAddress } from "./egress.ts";
1818 import { isLaunch, LAUNCHES, type Launch } from "../git/builds.ts";
1919 import { parseAdapter, type AdapterSpec } from "./adapter.ts";
20 import { parseToolchain, type ToolchainPin } from "./toolchain.ts";
2021
2122 export interface AppManifest {
2223 /** The command that serves on `$PORT`. Run through `sh -c`, `exec`-ed. */
@@ -82,6 +83,8 @@ export interface AppManifest {
8283 * schedule (`apps/adapter.ts`). Unset is not an adapter.
8384 */
8485 readonly adapter?: AdapterSpec | undefined;
86 /** `toolchain` (top level of `forge.json`): pinned toolchain versions. Unset is none. */
87 readonly toolchain?: ToolchainPin | undefined;
8588 }
8689
8790 export type ManifestError =
@@ -185,6 +188,8 @@ export function parseManifest(
185188 return invalid("forge.json: toolbar must be true or false");
186189 const adapter = parseAdapter(parsed.value["adapter"]);
187190 if (!adapter.ok) return invalid(adapter.error);
191 const toolchain = parseToolchain(parsed.value["toolchain"]);
192 if (!toolchain.ok) return invalid(toolchain.error);
188193 // Only `egress` opens a host (docs/ADAPTERS.md): a source host it does
189194 // not list would be refused by the proxy, so the block is invalid.
190195 const unopened = adapter.value?.source?.hosts.find(
@@ -226,6 +231,7 @@ export function parseManifest(
226231 limits: limits.value,
227232 toolbar: toolbar ?? true,
228233 ...(adapter.value === undefined ? {} : { adapter: adapter.value }),
234 ...(toolchain.value === undefined ? {} : { toolchain: toolchain.value }),
229235 });
230236 }
231237
src/apps/toolchain.ts added +45
@@ -0,0 +1,45 @@
1 /**
2 * The toolchains an app may pin in `forge.json` (`"toolchain": {"rust":
3 * "1.82"}`): which the forge knows and which versions it provides. This
4 * module only names and validates; nothing here installs or downloads.
5 */
6
7 import { err, ok, type Result } from "../kernel/result.ts";
8
9 /** Every toolchain the forge knows, with the versions it provides. */
10 export const TOOLCHAINS = {
11 rust: ["1.82"],
12 } as const satisfies Record<string, readonly string[]>;
13
14 export type ToolchainName = keyof typeof TOOLCHAINS;
15
16 /** A validated pin: toolchain name to a version this forge provides. */
17 export type ToolchainPin = Readonly<Partial<Record<ToolchainName, string>>>;
18
19 const VERSION_RE = /^\d{1,3}\.\d{1,3}$/;
20
21 const isName = (name: string): name is ToolchainName =>
22 Object.hasOwn(TOOLCHAINS, name);
23
24 /** The `toolchain` value of `forge.json`; `undefined` (absent) is no pin. */
25 export function parseToolchain(value: unknown): Result<ToolchainPin | undefined, string> {
26 if (value === undefined) return ok(undefined);
27 if (typeof value !== "object" || value === null || Array.isArray(value))
28 return err('forge.json: toolchain must be an object like {"rust": "1.82"}');
29 const pin: Partial<Record<ToolchainName, string>> = {};
30 for (const [name, version] of Object.entries(value)) {
31 if (!isName(name))
32 return err(
33 `forge.json: toolchain ${name.replace(/[\x00-\x1f\x7f]/g, "?").slice(0, 40)} is not known here; this forge knows ${Object.keys(TOOLCHAINS).join(", ")}`,
outdated

name is attacker-controlled and echoed raw; a newline/control char breaks the one-line promise. Strip control chars (e.g. name.replace(/[\x00-\x1f\x7f]/g, "?").slice(0, 40)) and add a test.

34 );
35 if (typeof version !== "string" || !VERSION_RE.test(version))
36 return err(`forge.json: toolchain.${name} must be a version like "${TOOLCHAINS[name][0]}"`);
37 const have: readonly string[] = TOOLCHAINS[name];
38 if (!have.includes(version))
39 return err(
40 `forge.json: ${name} ${version} is not provided here; this forge has ${name} ${have.join(", ")}`,
41 );
42 pin[name] = version;
43 }
44 return ok(Object.keys(pin).length === 0 ? undefined : pin);
45 }
test/toolchain-manifest.test.ts added +40
@@ -0,0 +1,40 @@
1 import { describe, expect, test } from "bun:test";
2 import { parseManifest } from "../src/apps/manifest.ts";
3
4 const parse = (extra: Record<string, unknown>) =>
5 parseManifest(JSON.stringify({ app: { run: "bun x" }, ...extra }), undefined);
6 const message = (r: ReturnType<typeof parse>) =>
7 !r.ok && r.error.kind === "invalid" ? r.error.message : "";
8
9 describe("forge.json toolchain", () => {
10 test("a provided version is parsed", () => {
11 const r = parse({ toolchain: { rust: "1.82" } });
12 expect(r.ok && r.value.toolchain).toEqual({ rust: "1.82" });
13 });
14 test("an unknown toolchain fails naming what is known", () => {
15 const m = message(parse({ toolchain: { go: "1.22" } }));
16 expect(m).toContain("go");
17 expect(m).toContain("rust");
18 });
19 test("an unknown name with control characters stays one line", () => {
20 const m = message(parse({ toolchain: { "go\nrm\x07": "1" } }));
21 expect(m).toContain("go?rm?");
22 expect(m).not.toMatch(/[\x00-\x1f\x7f]/);
23 });
24 test("a malformed version fails", () => {
25 for (const v of ["latest", "1", "1.82.0", 1.82, "", null])
26 expect(message(parse({ toolchain: { rust: v } }))).toContain("toolchain.rust");
27 expect(message(parse({ toolchain: "rust" }))).toContain("toolchain must be an object");
28 expect(message(parse({ toolchain: ["rust"] }))).toContain("toolchain must be an object");
29 });
30 test("an unprovided version fails in one plain line", () => {
31 const m = message(parse({ toolchain: { rust: "1.70" } }));
32 expect(m).toContain("rust 1.70 is not provided here; this forge has rust 1.82");
33 expect(m).not.toContain("\n");
34 });
35 test("absent deploys as before", () => {
36 const r = parse({});
37 expect(r.ok && r.value.toolchain).toBeUndefined();
38 expect(r.ok && "toolchain" in r.value).toBe(false);
39 });
40 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs