diff --git a/.changeset/build-cli-for-production.md b/.changeset/build-cli-for-production.md new file mode 100644 index 000000000..db81b36b7 --- /dev/null +++ b/.changeset/build-cli-for-production.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +print only the message of an error, as intended, instead of a minified class name and a stack trace. The CLI is now built for production unless `NODE_ENV` says otherwise; build with `NODE_ENV=development` to get the full error. diff --git a/.changeset/reject-unknown-config-keys.md b/.changeset/reject-unknown-config-keys.md new file mode 100644 index 000000000..e5c171cad --- /dev/null +++ b/.changeset/reject-unknown-config-keys.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +reject unknown keys in `cartesi.toml`. A key the CLI doesn't read, such as a misspelling like `sise`, a key of another builder, or a key of a removed feature like `[withdrawal.config]`, now fails `build`, `run` and `shell` with a message naming the key, the table it is in and the keys that table accepts, e.g. `Unknown key 'sise' in [drives.root] (builder = "docker" by default), expected one of: …`. A key written after the last table header belongs to that table, so a trailing `sdk = "…"` is reported too. Drive names, nvram labels and the variables under `[machine.env]` stay free, missing keys keep their defaults, and the files merged with `-c` are checked as one, so a later file can't change the builder of a drive whose builder-specific keys an earlier file set. The commands that read the snapshot with the project `sdk`, such as `hash`, `send`, `deposit` and `withdraw`, now report an invalid `cartesi.toml` instead of falling back to the default SDK image. diff --git a/apps/cli/build.ts b/apps/cli/build.ts index 3ed24ffbd..bb7cbe087 100644 --- a/apps/cli/build.ts +++ b/apps/cli/build.ts @@ -1,6 +1,16 @@ +// Bun inlines process.env.NODE_ENV in the bundle, as "development" when it isn't set. +// Default to production so errors print only their message, as in src/index.ts; +// build with NODE_ENV=development to get the full error with its stack. +const define = { + "process.env.NODE_ENV": JSON.stringify( + process.env.NODE_ENV ?? "production", + ), +}; + // build for npm package await Bun.build({ banner: "#!/usr/bin/env node", + define, entrypoints: ["./src/index.ts"], minify: true, outdir: "dist", @@ -24,6 +34,7 @@ await Promise.all( outfile: `bin/cartesi-${target.replace("bun-", "")}`, target, }, + define, entrypoints: ["./src/index.ts"], minify: true, sourcemap: "linked", diff --git a/apps/cli/src/base.ts b/apps/cli/src/base.ts index 006914a91..d121cb792 100644 --- a/apps/cli/src/base.ts +++ b/apps/cli/src/base.ts @@ -41,16 +41,10 @@ export const getContextPath = (...paths: string[]): string => { }; /** - * SDK image of the project, which built its machine snapshot + * SDK image of the project, which built its machine snapshot. An invalid cartesi.toml is + * reported rather than falling back to the default image, whose emulator may not load it. */ -const getProjectSdk = (): string | undefined => { - try { - return getApplicationConfig(["cartesi.toml"]).sdk; - } catch { - // an invalid config is reported by the commands that build with it - return undefined; - } -}; +const getProjectSdk = (): string => getApplicationConfig(["cartesi.toml"]).sdk; /** * Read the hash of the cartesi machine snapshot, if one exists. Without a @@ -64,10 +58,7 @@ export const getMachineHash = async (options?: { const imagePath = getContextPath("image"); if (fs.existsSync(imagePath)) { const image = options?.sdk ?? getProjectSdk(); - return await cartesiMachineStoredHash.computeHash( - imagePath, - image ? { image } : undefined, - ); + return await cartesiMachineStoredHash.computeHash(imagePath, { image }); } return undefined; }; diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index bc2cb3531..af94c2401 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -152,6 +152,28 @@ export class InvalidWithdrawalConfigError extends Error { } } +export class UnknownConfigKeyError extends Error { + /** + * @param where where the keys are, e.g. "in [machine]" or "at the top level" + */ + constructor(keys: string[], known: readonly string[], where: string) { + const list = keys.map((key) => `'${key}'`).join(", "); + super( + `Unknown ${keys.length > 1 ? "keys" : "key"} ${list} ${where}, expected one of: ${[...known].sort().join(", ")}`, + ); + this.name = "UnknownConfigKeyError"; + } +} + +export class InvalidTableError extends Error { + constructor(location: string, value: TomlPrimitive) { + super( + `Invalid ${location}: expected a table, got ${formatValue(value)}`, + ); + this.name = "InvalidTableError"; + } +} + /** * Configuration for drives of a Cartesi Machine. A drive may already exist or be built by a builder */ @@ -421,6 +443,21 @@ export const assertAccountsDriveLength = ( type TomlTable = { [key: string]: TomlPrimitive }; +/** + * Fails on any key of a table that its parser doesn't read, naming the keys the table accepts. + * @param where where the table is, e.g. "in [machine]" or "at the top level" + */ +const assertKnownKeys = ( + table: TomlTable, + known: readonly string[], + where: string, +): void => { + const unknown = Object.keys(table).filter((key) => !known.includes(key)); + if (unknown.length > 0) { + throw new UnknownConfigKeyError(unknown, known, where); + } +}; + export const defaultRootDriveConfig = (): DriveConfig => ({ builder: "docker", buildArgs: [], @@ -650,6 +687,11 @@ const parseSize = (value: TomlPrimitive): number | undefined => { const parseBytes = (value: TomlPrimitive, defaultValue: number): number => parseSize(value) ?? defaultValue; +const ACCOUNTS_DRIVE_KEYS: readonly string[] = [ + "accounts_drive", + "accounts_drive_size", +]; + /** * Parses the keys that mark a drive or nvram as the accounts drive, leaving them out when unset. */ @@ -662,8 +704,20 @@ const parseAccountsDriveMarker = (toml: TomlTable): AccountsDriveMarker => { }; }; +const NVRAM_KEYS: readonly string[] = [ + ...ACCOUNTS_DRIVE_KEYS, + "filename", + "shared", + "size", + "user", +]; + const parseNvram = (label: string, value: TomlPrimitive): NvramConfig => { - const toml = isTomlTable(value) ? value : {}; + if (!isTomlTable(value)) { + throw new InvalidTableError(`[nvrams.${label}]`, value); + } + const toml = value; + assertKnownKeys(toml, NVRAM_KEYS, `in [nvrams.${label}]`); const size = parseSize(toml.size); const filename = parseOptionalString(toml.filename); @@ -684,7 +738,10 @@ const parseNvram = (label: string, value: TomlPrimitive): NvramConfig => { }; const parseNvrams = (config: TomlPrimitive): Record => { - const entries = Object.entries((config as TomlTable) ?? {}); + if (config !== undefined && !isTomlTable(config)) { + throw new InvalidTableError("[nvrams]", config); + } + const entries = Object.entries(config ?? {}); if (entries.length > MAX_NVRAMS) { throw new TooManyNvramsError(entries.length); } @@ -761,15 +818,30 @@ const parseEmptyFormat = (value: TomlPrimitive): "ext2" | "raw" => { throw new InvalidEmptyDriveFormatError(value); }; +const MACHINE_KEYS: readonly string[] = [ + "assert_rolling_template", + "boot_args", + "entrypoint", + "env", + "env_file", + "max_mcycle", + "ram_image", + "ram_length", + "use_docker_env", + "use_docker_workdir", + "user", +]; + const parseMachine = (value: TomlPrimitive): MachineConfig => { if (value === undefined) { // default machine return defaultMachineConfig(); } - if (typeof value !== "object") { - throw new Error(`Invalid machine configuration: ${value}`); + if (!isTomlTable(value)) { + throw new InvalidTableError("[machine]", value); } - const toml = value as TomlTable; + assertKnownKeys(value, MACHINE_KEYS, "in [machine]"); + const toml = value; return { assertRollingTemplate: parseOptionalBoolean( @@ -800,17 +872,58 @@ export const getDriveFormat = (filename: string): DriveFormat => { } }; -const parseDrive = (drive: TomlPrimitive): DriveConfig => { - const toml = drive as TomlTable; - return { ...parseBuilderDrive(toml), ...parseAccountsDriveMarker(toml) }; +const DRIVE_KEYS: readonly string[] = [ + ...ACCOUNTS_DRIVE_KEYS, + "builder", + "mount", + "shared", + "user", +]; + +/** + * The keys each builder reads, on top of DRIVE_KEYS + */ +const BUILDER_KEYS: Record = { + directory: ["directory", "extra_size", "format"], + docker: [ + "build_args", + "context", + "dockerfile", + "extra_size", + "format", + "image", + "tags", + "target", + ], + empty: ["format", "size"], + none: ["filename"], + tar: ["extra_size", "filename", "format"], +}; + +const parseDrive = (name: string, drive: TomlPrimitive): DriveConfig => { + if (!isTomlTable(drive)) { + throw new InvalidTableError(`[drives.${name}]`, drive); + } + const toml = drive; + // the keys depend on the builder, so an invalid one is reported first + const builder = parseBuilder(toml.builder); + const implicit = toml.builder === undefined ? " by default" : ""; + assertKnownKeys( + toml, + [...DRIVE_KEYS, ...BUILDER_KEYS[builder]], + `in [drives.${name}] (builder = "${builder}"${implicit})`, + ); + return { + ...parseBuilderDrive(builder, toml), + ...parseAccountsDriveMarker(toml), + }; }; -const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => { - const builder = parseBuilder((drive as TomlTable).builder); +const parseBuilderDrive = (builder: Builder, drive: TomlTable): DriveConfig => { switch (builder) { case "directory": { const { extra_size, format, mount, directory, shared, user } = - drive as TomlTable; + drive; return { builder: "directory", extraSize: parseBytes(extra_size, 0), @@ -834,7 +947,7 @@ const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => { tags, target, user, - } = drive as TomlTable; + } = drive; return { builder: "docker", buildArgs: parseStringArray(build_args), @@ -851,7 +964,7 @@ const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => { }; } case "empty": { - const { format, mount, size, shared, user } = drive as TomlTable; + const { format, mount, size, shared, user } = drive; return { builder: "empty", format: parseEmptyFormat(format), @@ -862,8 +975,7 @@ const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => { }; } case "tar": { - const { extra_size, filename, format, mount, shared, user } = - drive as TomlTable; + const { extra_size, filename, format, mount, shared, user } = drive; return { builder: "tar", extraSize: parseBytes(extra_size, 0), @@ -875,11 +987,8 @@ const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => { }; } case "none": { - const { shared, mount, user } = drive as TomlTable; - const filename = parseRequiredString( - (drive as TomlTable).filename, - "filename", - ); + const { shared, mount, user } = drive; + const filename = parseRequiredString(drive.filename, "filename"); const format = getDriveFormat(filename); return { builder: "none", @@ -894,11 +1003,14 @@ const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => { }; const parseDrives = (config: TomlPrimitive): Record => { + if (config !== undefined && !isTomlTable(config)) { + throw new InvalidTableError("[drives]", config); + } // load drives from configuration - const drives = Object.entries((config as TomlTable) ?? {}).reduce< + const drives = Object.entries(config ?? {}).reduce< Record >((acc, [name, drive]) => { - acc[name] = parseDrive(drive); + acc[name] = parseDrive(name, drive); return acc; }, {}); @@ -925,9 +1037,15 @@ const parseAccountSize = (value: TomlPrimitive): number => { return size; }; +const WITHDRAWAL_KEYS: readonly string[] = [ + "account_size", + "guardian", + "withdrawal_output_builder", +]; + /** * Parses [withdrawal], which only applies to an application with an accounts drive. Like the - * other tables, it reads the keys it uses and leaves any other key alone. + * other tables, it reads the keys it uses and rejects any other. */ const parseOptionalWithdrawal = ( withdrawal: TomlPrimitive, @@ -936,10 +1054,9 @@ const parseOptionalWithdrawal = ( return undefined; } if (!isTomlTable(withdrawal)) { - throw new InvalidWithdrawalConfigError( - `expected a table, got ${formatValue(withdrawal)}`, - ); + throw new InvalidTableError("[withdrawal]", withdrawal); } + assertKnownKeys(withdrawal, WITHDRAWAL_KEYS, "in [withdrawal]"); const optional = ( key: string, @@ -1018,11 +1135,20 @@ const assertAccountsDrive = (config: Config) => { assertAccountsDriveLength(config, accountsDrive, size); }; +const CONFIG_KEYS: readonly string[] = [ + "drives", + "machine", + "nvrams", + "sdk", + "withdrawal", +]; + export const parse = (str: string[]): Config => { let toml: TomlTable = {}; for (const s of str) { toml = mergeTomlTables(toml, parseToml(s)); } + assertKnownKeys(toml, CONFIG_KEYS, "at the top level"); const drives = parseDrives(toml.drives); const nvrams = parseNvrams(toml.nvrams); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 3d7a8e8e8..365910377 100755 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -48,15 +48,18 @@ const program = new Command() .addCommand(createStatusCommand()) .addCommand(createWithdrawCommand()); -// Global error handling -process.on("uncaughtException", (err) => { +// Global error handling. Node turns an unhandled rejection, such as an error from an async +// command, into an uncaught exception, but Bun, which runs the compiled binaries, doesn't. +const handleError = (err: unknown) => { if (process.env.NODE_ENV === "development") { console.error(err); } else { // in production, only print the error message, not the stack trace - console.error(err.message); + console.error(err instanceof Error ? err.message : String(err)); } process.exit(1); -}); +}; +process.on("uncaughtException", handleError); +process.on("unhandledRejection", handleError); program.parse(); diff --git a/apps/cli/tests/integration/config.ts b/apps/cli/tests/integration/config.ts index dfdb0af46..80c64205b 100644 --- a/apps/cli/tests/integration/config.ts +++ b/apps/cli/tests/integration/config.ts @@ -92,18 +92,18 @@ export async function createTemporaryCartesiApplication(options?: { console.log(`✓ Temporary Cartesi application created at: ${appDir}`); - if (options?.config !== undefined) { - fs.writeFileSync(path.join(appDir, "cartesi.toml"), options.config); - console.log(`✓ Wrote a custom cartesi.toml`); - } - // Build with the same image the snapshot readers use. Without this the // build falls back to the released SDK while the readers use TEST_SDK, - // and a snapshot written by one emulator is rejected by the other. - fs.appendFileSync( + // and a snapshot written by one emulator is rejected by the other. The + // pin comes first: a key written after a table header belongs to that + // table, so appended to a config it would land in the last table. + fs.writeFileSync( path.join(appDir, "cartesi.toml"), - `\nsdk = "${TEST_SDK}"\n`, + `sdk = "${TEST_SDK}"\n${options?.config ?? ""}`, ); + if (options?.config !== undefined) { + console.log(`✓ Wrote a custom cartesi.toml`); + } // Change directory into the created application process.chdir(appDir); diff --git a/apps/cli/tests/integration/machine/nvram.test.ts b/apps/cli/tests/integration/machine/nvram.test.ts index db55bed19..2f4fe00a6 100644 --- a/apps/cli/tests/integration/machine/nvram.test.ts +++ b/apps/cli/tests/integration/machine/nvram.test.ts @@ -24,7 +24,7 @@ const found = await cartesiMachine.version({ const supported = found !== null && satisfies(found.format(), cartesiMachine.requiredVersion); -// no sdk key here: the helper appends `sdk = "${TEST_SDK}"` itself, and TOML rejects duplicates +// no sdk key here: the helper writes `sdk = "${TEST_SDK}"` before this config, and TOML rejects duplicates const CONFIG = `[nvrams.input] size = "4Ki" @@ -54,7 +54,7 @@ describe.skipIf(!supported)("when booting a machine with nvrams", () => { * Boots the built application with a different entrypoint, the way the shell command does. */ const boot = async (command: string) => { - // the app's own config, which carries the sdk pin the helper appended + // the app's own config, which carries the sdk pin the helper wrote first const config = parse([ fs.readFileSync(path.join(app.appDir, "cartesi.toml"), "utf-8"), ]); diff --git a/apps/cli/tests/unit/base.test.ts b/apps/cli/tests/unit/base.test.ts new file mode 100644 index 000000000..9e796d3c3 --- /dev/null +++ b/apps/cli/tests/unit/base.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getMachineHash } from "../../src/base.js"; +import { InvalidBuilderError } from "../../src/config.js"; + +describe("getMachineHash", () => { + it("should fail on an invalid cartesi.toml instead of hashing with the default sdk image", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "base-")); + const cwd = process.cwd(); + try { + // a snapshot exists, so the hash would be computed with the project sdk + fs.mkdirSync(path.join(dir, ".cartesi", "image"), { + recursive: true, + }); + fs.writeFileSync( + path.join(dir, "cartesi.toml"), + '[drives.root]\nbuilder = "invalid"\n', + ); + process.chdir(dir); + + await expect(getMachineHash()).rejects.toThrowError( + new InvalidBuilderError("invalid"), + ); + } finally { + process.chdir(cwd); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/tests/unit/config.test.ts b/apps/cli/tests/unit/config.test.ts index 1d04adb5f..e9338ac43 100644 --- a/apps/cli/tests/unit/config.test.ts +++ b/apps/cli/tests/unit/config.test.ts @@ -17,66 +17,154 @@ import { InvalidNumberValueError, InvalidNvramSizeError, InvalidStringValueError, + InvalidTableError, InvalidWithdrawalConfigError, MissingNvramSourceError, MultipleAccountsDrivesError, parse, RequiredFieldError, TooManyNvramsError, + UnknownConfigKeyError, } from "../../src/config.js"; -const loadFixture = (...segments: string[]) => { - const filePath = path.join(__dirname, "config", "fixtures", ...segments); - return [fs.readFileSync(filePath, "utf-8")]; -}; - -const loadDriveConfig = (driveName: string) => - loadFixture("drives", `${driveName}.toml`); - -const loadNvramConfig = (nvramName: string) => - loadFixture("nvrams", `${nvramName}.toml`); - -describe("when parsing only drive config files", () => { - it("should pass with a basic drive config", () => { - const basic = loadDriveConfig("basic"); - expect(() => parse(basic)).not.toThrow(); - }); - - it("should pass with a data drive config", () => { - const basic = loadDriveConfig("data"); - expect(() => parse(basic)).not.toThrow(); - }); - - it("should pass with an empty drive config", () => { - const basic = loadDriveConfig("empty"); - expect(() => parse(basic)).not.toThrow(); - }); - - it("should pass with a none drive config", () => { - const basic = loadDriveConfig("none"); - expect(() => parse(basic)).not.toThrow(); - }); - - it("should pass with a rives config", () => { - const basic = loadDriveConfig("rives"); - expect(() => parse(basic)).not.toThrow(); +const fixturesDir = path.join(__dirname, "config", "fixtures"); +const fixtures = fs + .readdirSync(fixturesDir, { recursive: true, encoding: "utf-8" }) + .filter((name) => name.endsWith(".toml")) + .sort(); +const loadFixture = (name: string) => [ + fs.readFileSync(path.join(fixturesDir, name), "utf-8"), +]; + +describe("when parsing the fixtures", () => { + // every fixture is a valid config, so no key they use is unknown to the parser + it.each(fixtures)("should parse %s", (name) => { + expect(() => parse(loadFixture(name))).not.toThrow(); }); - it("should pass with a tar drive config", () => { - const basic = loadDriveConfig("rives"); - expect(() => parse(basic)).not.toThrow(); + // full.toml gives every key a value other than its default at least once, so a key the parser + // reads but doesn't list fails here, and so does one it stops reading. The strict match also + // fails on a new field the expected object doesn't list, even an unset one. + it("should parse every key of full.toml", () => { + expect(parse(loadFixture("full.toml"))).toStrictEqual({ + sdk: "my-sdk:dev", + machine: { + assertRollingTemplate: true, + bootargs: ["no4lvl", "quiet"], + entrypoint: "/usr/local/bin/app", + env: { FOO: "bar" }, + envFile: ".env", + maxMCycle: 0n, + ramImage: "/usr/share/cartesi-machine/images/linux.bin", + ramLength: "256Mi", + useDockerEnv: false, + useDockerWorkdir: false, + user: "dapp", + }, + drives: { + root: { + builder: "docker", + buildArgs: ["RELEASE=1"], + context: "backend", + dockerfile: "backend/Dockerfile", + extraSize: 100 * 1024 * 1024, + format: "sqfs", + image: undefined, + mount: undefined, + shared: false, + tags: ["app:latest"], + target: "runtime", + user: "dapp", + }, + tools: { + builder: "docker", + buildArgs: [], + context: ".", + dockerfile: "Dockerfile", + extraSize: 0, + format: "ext2", + image: "cartesi/tools:0.1.0", + mount: "/opt/tools", + shared: undefined, + tags: [], + target: undefined, + user: undefined, + }, + data: { + builder: "directory", + directory: "./data", + extraSize: 10 * 1024 * 1024, + format: "sqfs", + mount: "/usr/share/app", + shared: undefined, + user: undefined, + }, + scratch: { + builder: "empty", + format: "ext2", + mount: "/tmp/scratch", + shared: undefined, + size: 8 * 1024 * 1024, + user: undefined, + }, + doom: { + builder: "none", + filename: "./games/doom.sqfs", + format: "sqfs", + mount: "/usr/local/games/doom", + shared: undefined, + user: undefined, + }, + files: { + builder: "tar", + extraSize: 1024 * 1024, + filename: "build/files.tar", + format: "sqfs", + mount: undefined, + shared: undefined, + user: undefined, + }, + accounts: { + builder: "empty", + format: "raw", + mount: false, + shared: undefined, + size: 8 * 1024 * 1024, + user: undefined, + accountsDrive: true, + accountsDriveSize: 4 * 1024 * 1024, + }, + }, + nvrams: { + input: { + filename: undefined, + size: 4096, + shared: undefined, + user: undefined, + }, + output: { + filename: undefined, + size: 4096, + shared: true, + user: "dapp", + }, + seed: { + filename: "./seed.raw", + size: undefined, + shared: undefined, + user: undefined, + }, + }, + withdrawal: { + guardian: "0x1111111111111111111111111111111111111111", + withdrawal_output_builder: + "0x2222222222222222222222222222222222222222", + account_size: 64, + }, + }); }); }); -describe("when parsing only nvram config files", () => { - it.each(["pristine", "shared", "file", "multi"])( - "should pass with a %s nvram config", - (name) => { - expect(() => parse(loadNvramConfig(name))).not.toThrow(); - }, - ); -}); - describe("when parsing a cartesi.toml config", () => { it("should load the default config when file is empty", () => { const config = parse([""]); @@ -217,6 +305,12 @@ shared = true`, }); }); + it("should fail for a machine that is not a table", () => { + expect(() => parse(["machine = [1]"])).toThrowError( + new InvalidTableError("[machine]", [1]), + ); + }); + it("should fail for an env that is not a table", () => { expect(() => parse(["[machine]\nenv = 42"])).toThrowError( new InvalidEnvError(42), @@ -276,7 +370,7 @@ shared = true`, it("should fail when the section is not a table", () => { expect(() => withdrawal("withdrawal = 42")).toThrowError( - new InvalidWithdrawalConfigError("expected a table, got 42"), + new InvalidTableError("[withdrawal]", 42), ); }); @@ -509,10 +603,27 @@ shared = true`, * [drives] */ describe("when parsing [drives]", () => { - it("should fail for invalid configuration", () => { - expect(parse(["drives = 42"])).toEqual(defaultConfig()); - expect(parse(["drives.root = true"])).toEqual(defaultConfig()); - expect(parse(["drives.root = 42"])).toEqual(defaultConfig()); + it("should fail for drives that are not tables", () => { + expect(() => parse(["drives = 42"])).toThrowError( + new InvalidTableError("[drives]", 42), + ); + expect(() => parse(['drives = "abc"'])).toThrowError( + new InvalidTableError("[drives]", "abc"), + ); + expect(() => parse(["drives.root = true"])).toThrowError( + new InvalidTableError("[drives.root]", true), + ); + expect(() => parse(["drives.root = 42"])).toThrowError( + new InvalidTableError("[drives.root]", 42), + ); + }); + + it("should fail for an array of tables, which double brackets declare", () => { + expect(() => + parse(['[[drives.root]]\nbuilder = "empty"\nsize = "1Mi"']), + ).toThrowError( + 'Invalid [drives.root]: expected a table, got [{"builder":"empty","size":"1Mi"}]', + ); }); it("should fail for invalid builder", () => { @@ -642,6 +753,20 @@ shared = true`, expect(parse([""]).nvrams).toEqual({}); }); + it("should fail for nvrams that are not tables", () => { + expect(() => parse(["nvrams = 5"])).toThrowError( + new InvalidTableError("[nvrams]", 5), + ); + expect(() => parse(["nvrams.input = 5"])).toThrowError( + new InvalidTableError("[nvrams.input]", 5), + ); + expect(() => + parse(['[[nvrams.input]]\nsize = "4Ki"']), + ).toThrowError( + 'Invalid [nvrams.input]: expected a table, got [{"size":"4Ki"}]', + ); + }); + it("should parse a pristine nvram", () => { expect(parse(['[nvrams.input]\nsize = "4Ki"'])).toEqual({ ...defaultConfig(), @@ -935,4 +1060,154 @@ shared = true`, ); }); }); + + /** + * unknown keys + */ + describe("when a key is unknown", () => { + it("should fail for an unknown key at the top level", () => { + expect(() => + parse(['[drive.root]\nbuilder = "docker"']), + ).toThrowError( + "Unknown key 'drive' at the top level, expected one of: drives, machine, nvrams, sdk, withdrawal", + ); + }); + + it("should fail for an unknown key in [machine]", () => { + expect(() => + parse(["[machine]\nassert_rolling_update = true"]), + ).toThrowError( + "Unknown key 'assert_rolling_update' in [machine], expected one of: assert_rolling_template, boot_args, entrypoint, env, env_file, max_mcycle, ram_image, ram_length, use_docker_env, use_docker_workdir, user", + ); + }); + + it("should fail for an unknown key in [nvrams.