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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10524-forge-json-can-name-a-pinned-toolchain-a → main 2 commits fork 1 thread · 1 unresolved
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` | ||
| 548 | 548 | (up to 4 MiB) are rewritten; JSON, images, compressed or chunked HTML and |
| 549 | 549 | `HEAD` requests pass through byte for byte. `"toolbar": false` keeps it out. |
| 550 | 550 | |
| 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 | ||
| 551 | 559 | `adapter`, at the top level, says the app is an adapter the forge pulls |
| 552 | 560 | from on a schedule (#437): `{ "adapter": { "schedule": { "every": 900 } } }`. |
| 553 | 561 | `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"; | ||
| 17 | 17 | import { isPublicAddress } from "./egress.ts"; |
| 18 | 18 | import { isLaunch, LAUNCHES, type Launch } from "../git/builds.ts"; |
| 19 | 19 | import { parseAdapter, type AdapterSpec } from "./adapter.ts"; |
| 20 | import { parseToolchain, type ToolchainPin } from "./toolchain.ts"; | |
| 20 | 21 | |
| 21 | 22 | export interface AppManifest { |
| 22 | 23 | /** The command that serves on `$PORT`. Run through `sh -c`, `exec`-ed. */ |
| @@ -82,6 +83,8 @@ export interface AppManifest { | ||
| 82 | 83 | * schedule (`apps/adapter.ts`). Unset is not an adapter. |
| 83 | 84 | */ |
| 84 | 85 | readonly adapter?: AdapterSpec | undefined; |
| 86 | /** `toolchain` (top level of `forge.json`): pinned toolchain versions. Unset is none. */ | |
| 87 | readonly toolchain?: ToolchainPin | undefined; | |
| 85 | 88 | } |
| 86 | 89 | |
| 87 | 90 | export type ManifestError = |
| @@ -185,6 +188,8 @@ export function parseManifest( | ||
| 185 | 188 | return invalid("forge.json: toolbar must be true or false"); |
| 186 | 189 | const adapter = parseAdapter(parsed.value["adapter"]); |
| 187 | 190 | if (!adapter.ok) return invalid(adapter.error); |
| 191 | const toolchain = parseToolchain(parsed.value["toolchain"]); | |
| 192 | if (!toolchain.ok) return invalid(toolchain.error); | |
| 188 | 193 | // Only `egress` opens a host (docs/ADAPTERS.md): a source host it does |
| 189 | 194 | // not list would be refused by the proxy, so the block is invalid. |
| 190 | 195 | const unopened = adapter.value?.source?.hosts.find( |
| @@ -226,6 +231,7 @@ export function parseManifest( | ||
| 226 | 231 | limits: limits.value, |
| 227 | 232 | toolbar: toolbar ?? true, |
| 228 | 233 | ...(adapter.value === undefined ? {} : { adapter: adapter.value }), |
| 234 | ...(toolchain.value === undefined ? {} : { toolchain: toolchain.value }), | |
| 229 | 235 | }); |
| 230 | 236 | } |
| 231 | 237 |
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(", ")}`, | |
| 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 | }); |
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.