From dbce3a9d855d4a3a3f0c3137c03da613d114ac1b Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 17 Aug 2026 17:47:29 +0100 Subject: [PATCH 01/13] refactor(cli): extract machine argument assembly into buildMachineArgs --- apps/cli/src/machine.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/machine.ts b/apps/cli/src/machine.ts index b7f347ee..b1884a66 100644 --- a/apps/cli/src/machine.ts +++ b/apps/cli/src/machine.ts @@ -27,12 +27,11 @@ export type BootMachineOptions = { store?: string; }; -export const bootMachine = ( +export const buildMachineArgs = ( config: Config, info: ImageInfo | undefined, bootOptions: BootMachineOptions, - options?: ExecaOptionsDockerFallback, -) => { +): string[] => { const { machine } = config; const { assertRollingTemplate, @@ -140,8 +139,16 @@ export const bootMachine = ( args.push("--"); args.push(entrypoint); - return cartesiMachine.boot(args, { + return args; +}; + +export const bootMachine = ( + config: Config, + info: ImageInfo | undefined, + bootOptions: BootMachineOptions, + options?: ExecaOptionsDockerFallback, +) => + cartesiMachine.boot(buildMachineArgs(config, info, bootOptions), { image: config.sdk, ...options, }); -}; From 677e3aa59219c781ae965638b06e8e9ab7a1d668 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 17 Aug 2026 17:49:51 +0100 Subject: [PATCH 02/13] feat(cli): parse [nvrams] section of cartesi.toml --- apps/cli/src/config.ts | 151 ++++++++++++++- apps/cli/tests/unit/config.test.ts | 182 +++++++++++++++++- apps/cli/tests/unit/config/fixtures/full.toml | 17 ++ .../unit/config/fixtures/nvrams/file.toml | 5 + .../unit/config/fixtures/nvrams/multi.toml | 10 + .../unit/config/fixtures/nvrams/pristine.toml | 5 + .../unit/config/fixtures/nvrams/shared.toml | 7 + 7 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 apps/cli/tests/unit/config/fixtures/nvrams/file.toml create mode 100644 apps/cli/tests/unit/config/fixtures/nvrams/multi.toml create mode 100644 apps/cli/tests/unit/config/fixtures/nvrams/pristine.toml create mode 100644 apps/cli/tests/unit/config/fixtures/nvrams/shared.toml diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index 7096f8ff..18faa1de 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -3,6 +3,9 @@ import { extname } from "node:path"; import { parse as parseToml, type TomlPrimitive } from "smol-toml"; import { getAddress, isAddress, isHex, type Address } from "viem"; +const NVRAM_ALIGNMENT = 4096; // cartesi-machine requires length % 4Ki == 0 +const MAX_NVRAMS = 8; // guest exposes nvrams as /dev/uio0 to /dev/uio7 + /** * Typed Errors */ @@ -85,6 +88,36 @@ export class InvalidEnvError extends Error { } } +export class MissingNvramSourceError extends Error { + constructor(label: string) { + super(`Nvram '${label}' must define either 'size' or 'filename'`); + this.name = "MissingNvramSourceError"; + } +} + +export class InvalidNvramSizeError extends Error { + constructor(label: string, size: number) { + super( + `Invalid size ${size} for nvram '${label}': must be a positive multiple of ${NVRAM_ALIGNMENT}`, + ); + this.name = "InvalidNvramSizeError"; + } +} + +export class TooManyNvramsError extends Error { + constructor(count: number) { + super(`Too many nvrams: ${count}, maximum is ${MAX_NVRAMS}`); + this.name = "TooManyNvramsError"; + } +} + +export class DuplicateLabelError extends Error { + constructor(label: string) { + super(`Label '${label}' is used by both a drive and an nvram`); + this.name = "DuplicateLabelError"; + } +} + /** * Configuration for drives of a Cartesi Machine. A drive may already exist or be built by a builder */ @@ -156,6 +189,18 @@ export type DriveConfig = ( user?: string; // default given by cartesi-machine }; +/** + * Configuration for an NVRAM of a Cartesi Machine. Unlike a flash drive, an nvram is a raw + * range of bytes exposed to the guest as a /dev/uio* device, with no filesystem and no mount + * point. Either `size` or `filename` must be defined. + */ +export type NvramConfig = { + filename?: string; // path to an existing raw image with the initial contents + size?: number; // in bytes, a positive multiple of 4Ki + shared?: boolean; // default given by cartesi-machine + user?: string; // default given by cartesi-machine +}; + export type MachineConfig = { assertRollingTemplate?: boolean; // default given by cartesi-machine bootargs: string[]; @@ -186,6 +231,7 @@ export type WithdrawalConfig = { export type Config = { drives: Record; machine: MachineConfig; + nvrams: Record; sdk: string; withdrawalConfig?: WithdrawalConfig; }; @@ -218,6 +264,7 @@ export const defaultMachineConfig = (): MachineConfig => ({ export const defaultConfig = (): Config => ({ drives: { root: defaultRootDriveConfig() }, machine: defaultMachineConfig(), + nvrams: {}, sdk: `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, withdrawalConfig: undefined, }); @@ -394,6 +441,97 @@ const parseBytes = (value: TomlPrimitive, defaultValue: number): number => { throw new InvalidBytesValueError(value); }; +const IEC_MULTIPLIERS: Record = { + "": 1, + b: 1, + k: 1024, + ki: 1024, + kb: 1024, + kib: 1024, + m: 1024 ** 2, + mi: 1024 ** 2, + mb: 1024 ** 2, + mib: 1024 ** 2, + g: 1024 ** 3, + gi: 1024 ** 3, + gb: 1024 ** 3, + gib: 1024 ** 3, +}; + +/** + * Parses a byte size, accepting both the IEC suffixes used by cartesi-machine ("4Ki", "1MiB") + * and the ones understood by the `bytes` package ("4kb", "100Mb"). Not to be confused with + * `parseBytes`, which delegates to `bytes.parse` and reads "4Ki" as 4 bytes. + */ +const parseNvramSize = (value: TomlPrimitive): number | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "bigint") { + return Number(value); + } + if (typeof value === "number") { + return value; + } + if (typeof value === "string") { + const match = /^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$/i.exec(value); + const multiplier = match + ? IEC_MULTIPLIERS[match[2].toLowerCase()] + : undefined; + if (match && multiplier !== undefined) { + return Number(match[1]) * multiplier; + } + } + throw new InvalidBytesValueError(value); +}; + +const parseNvram = (label: string, value: TomlPrimitive): NvramConfig => { + const toml = isTomlTable(value) ? value : {}; + const size = parseNvramSize(toml.size); + const filename = parseOptionalString(toml.filename); + + if (size === undefined && filename === undefined) { + throw new MissingNvramSourceError(label); + } + if (size !== undefined && (size <= 0 || size % NVRAM_ALIGNMENT !== 0)) { + throw new InvalidNvramSizeError(label, size); + } + + return { + filename, + size, + shared: parseOptionalBoolean(toml.shared), + user: parseOptionalString(toml.user), + }; +}; + +const parseNvrams = (config: TomlPrimitive): Record => { + const entries = Object.entries((config as TomlTable) ?? {}); + if (entries.length > MAX_NVRAMS) { + throw new TooManyNvramsError(entries.length); + } + return entries.reduce>( + (acc, [label, nvram]) => { + acc[label] = parseNvram(label, nvram); + return acc; + }, + {}, + ); +}; + +/** + * Filename, relative to the build destination directory, of the image backing an nvram. + */ +export const nvramImageFilename = (label: string): string => `${label}.raw`; + +/** + * Whether an nvram is backed by an image file. A pristine nvram needs no image, as + * cartesi-machine fills its range with zeros. A `shared` one does, as there must be a file for + * the guest writes to be persisted to. + */ +export const nvramHasImage = (nvram: NvramConfig): boolean => + nvram.filename !== undefined || nvram.shared === true; + const parseBuilder = (value: TomlPrimitive): Builder => { if (value === undefined) { return "docker"; @@ -639,10 +777,21 @@ export const parse = (str: string[]): Config => { toml = mergeTomlTables(toml, parseToml(s)); } + const drives = parseDrives(toml.drives); + const nvrams = parseNvrams(toml.nvrams); + + // drives and nvrams share the DTB /aliases namespace, so labels cannot collide + for (const label of Object.keys(nvrams)) { + if (drives[label] !== undefined) { + throw new DuplicateLabelError(label); + } + } + const config: Config = { withdrawalConfig: parseOptionalWithdrawalConfig(toml.withdrawal), - drives: parseDrives(toml.drives), + drives, machine: parseMachine(toml.machine), + nvrams, sdk: parseString( toml.sdk, `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, diff --git a/apps/cli/tests/unit/config.test.ts b/apps/cli/tests/unit/config.test.ts index 9da8006f..69ed958b 100644 --- a/apps/cli/tests/unit/config.test.ts +++ b/apps/cli/tests/unit/config.test.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import { defaultConfig, defaultMachineConfig, + DuplicateLabelError, InvalidAddressValueError, InvalidBooleanValueError, InvalidBuilderError, @@ -12,22 +13,25 @@ import { InvalidEmptyDriveFormatError, InvalidEnvError, InvalidNumberValueError, + InvalidNvramSizeError, InvalidStringValueError, + MissingNvramSourceError, parse, RequiredFieldError, + TooManyNvramsError, } from "../../src/config.js"; -const loadDriveConfig = (driveName: string) => { - const filePath = path.join( - __dirname, - "config", - "fixtures", - "drives", - `${driveName}.toml`, - ); +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"); @@ -60,6 +64,17 @@ describe("when parsing only drive config files", () => { }); }); +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([""]); @@ -520,6 +535,157 @@ shared = true`, }); }); + /** + * [nvrams] + */ + describe("when parsing [nvrams]", () => { + it("should default to no nvrams", () => { + expect(parse([""]).nvrams).toEqual({}); + }); + + it("should parse a pristine nvram", () => { + expect(parse(['[nvrams.input]\nsize = "4Ki"'])).toEqual({ + ...defaultConfig(), + nvrams: { + input: { + filename: undefined, + size: 4096, + shared: undefined, + user: undefined, + }, + }, + }); + }); + + it("should parse an nvram backed by an existing image", () => { + expect(parse(['[nvrams.seed]\nfilename = "./seed.raw"'])).toEqual({ + ...defaultConfig(), + nvrams: { + seed: { + filename: "./seed.raw", + size: undefined, + shared: undefined, + user: undefined, + }, + }, + }); + }); + + it("should parse a shared nvram", () => { + const config = ` + [nvrams.output] + size = "4Ki" + shared = true + user = "dapp" + `; + expect(parse([config])).toEqual({ + ...defaultConfig(), + nvrams: { + output: { + filename: undefined, + size: 4096, + shared: true, + user: "dapp", + }, + }, + }); + }); + + it("should preserve the order of the nvrams", () => { + const config = ` + [nvrams.output] + size = "4Ki" + + [nvrams.input] + size = "4Ki" + `; + expect(Object.keys(parse([config]).nvrams)).toEqual([ + "output", + "input", + ]); + }); + + it.each([ + ["4096", 4096], + ['"4096"', 4096], + ['"4Ki"', 4096], + ['"4KiB"', 4096], + ['"4kb"', 4096], + ['"1Mi"', 1048576], + ['"1Mb"', 1048576], + ])("should parse size %s as %i bytes", (size, expected) => { + expect( + parse([`[nvrams.input]\nsize = ${size}`]).nvrams.input.size, + ).toEqual(expected); + }); + + it("should fail when neither size nor filename is defined", () => { + expect(() => parse(["[nvrams.input]"])).toThrowError( + new MissingNvramSourceError("input"), + ); + expect(() => parse(["[nvrams.input]\nshared = true"])).toThrowError( + new MissingNvramSourceError("input"), + ); + }); + + it("should fail for a size that is not a multiple of 4Ki", () => { + expect(() => parse(['[nvrams.input]\nsize = "5Ki"'])).toThrowError( + new InvalidNvramSizeError("input", 5120), + ); + expect(() => parse(["[nvrams.input]\nsize = 0"])).toThrowError( + new InvalidNvramSizeError("input", 0), + ); + }); + + it("should fail for an unparseable size", () => { + expect(() => parse(['[nvrams.input]\nsize = "abc"'])).toThrowError( + new InvalidBytesValueError("abc"), + ); + expect(() => parse(["[nvrams.input]\nsize = true"])).toThrowError( + new InvalidBytesValueError(true), + ); + }); + + it("should fail for more than 8 nvrams", () => { + const config = Array.from( + { length: 9 }, + (_, i) => `[nvrams.n${i}]\nsize = "4Ki"`, + ).join("\n"); + expect(() => parse([config])).toThrowError( + new TooManyNvramsError(9), + ); + }); + + it("should fail when a label is used by both a drive and an nvram", () => { + const config = ` + [drives.data] + builder = "empty" + size = "100Mb" + + [nvrams.data] + size = "4Ki" + `; + expect(() => parse([config])).toThrowError( + new DuplicateLabelError("data"), + ); + }); + + it("should fail for the root label, which is always a drive", () => { + expect(() => parse(['[nvrams.root]\nsize = "4Ki"'])).toThrowError( + new DuplicateLabelError("root"), + ); + }); + + it("should fail for invalid shared and user values", () => { + expect(() => + parse(['[nvrams.input]\nsize = "4Ki"\nshared = 42']), + ).toThrowError(new InvalidBooleanValueError(42)); + expect(() => + parse(['[nvrams.input]\nsize = "4Ki"\nuser = 42']), + ).toThrowError(new InvalidStringValueError(42)); + }); + }); + /** * field types */ diff --git a/apps/cli/tests/unit/config/fixtures/full.toml b/apps/cli/tests/unit/config/fixtures/full.toml index 0f00964f..54827367 100644 --- a/apps/cli/tests/unit/config/fixtures/full.toml +++ b/apps/cli/tests/unit/config/fixtures/full.toml @@ -49,6 +49,23 @@ # filename = "./games/doom.sqfs" # mount = "/usr/local/games/doom" +# nvrams are raw byte ranges exposed to the guest as /dev/uio0 to /dev/uio7 (up to 8 of them). +# unlike drives they have no filesystem and no mount point, so the guest reads and writes them +# with the readmmap/writemmap tools from machine-guest-tools. requires cartesi-machine 0.21.0. +# an nvram label cannot be the same as a drive label. + +# [nvrams.input] +# size = "4Ki" # required unless 'filename' is given. must be a multiple of 4Ki + +# [nvrams.output] +# size = "4Ki" +# shared = true # guest writes are persisted to .cartesi/output.raw, which the next build wipes +# user = "dapp" # optional. allows the unprivileged entrypoint user to write to it + +# [nvrams.seed] +# filename = "./seed.raw" # existing raw image. it is copied into .cartesi/, never written to +# size = "4Ki" # optional. when given it must match the size of the file exactly + # [withdrawal.config] # guardian = "0x1111111111111111111111111111111111111111" # log2_leaves_per_account = 0 diff --git a/apps/cli/tests/unit/config/fixtures/nvrams/file.toml b/apps/cli/tests/unit/config/fixtures/nvrams/file.toml new file mode 100644 index 00000000..33a7ec1f --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/nvrams/file.toml @@ -0,0 +1,5 @@ +# example of an nvram whose initial contents come from an existing raw image +# size is optional here, as it defaults to the size of the file + +[nvrams.seed] +filename = "./seed.raw" diff --git a/apps/cli/tests/unit/config/fixtures/nvrams/multi.toml b/apps/cli/tests/unit/config/fixtures/nvrams/multi.toml new file mode 100644 index 00000000..7c6b0a89 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/nvrams/multi.toml @@ -0,0 +1,10 @@ +# example of an application reading an input nvram and writing an output one +# the order of the tables is the order of the /dev/uio* devices in the guest + +[nvrams.input] +size = "4Ki" + +[nvrams.output] +size = "4Ki" +shared = true +user = "dapp" diff --git a/apps/cli/tests/unit/config/fixtures/nvrams/pristine.toml b/apps/cli/tests/unit/config/fixtures/nvrams/pristine.toml new file mode 100644 index 00000000..0be7c13c --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/nvrams/pristine.toml @@ -0,0 +1,5 @@ +# example of a pristine nvram, filled with zeros by cartesi-machine +# no image is built for it, as there is nothing to persist + +[nvrams.input] +size = "4Ki" # must be a multiple of 4Ki diff --git a/apps/cli/tests/unit/config/fixtures/nvrams/shared.toml b/apps/cli/tests/unit/config/fixtures/nvrams/shared.toml new file mode 100644 index 00000000..cd3ce7f4 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/nvrams/shared.toml @@ -0,0 +1,7 @@ +# example of an nvram the guest writes its output to +# shared makes the writes land on .cartesi/output.raw, and user lets the entrypoint user write + +[nvrams.output] +size = "4Ki" +shared = true +user = "dapp" From 4d64f9bee966c24408ccf3cc713259fc257ec77b Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 17 Aug 2026 17:50:51 +0100 Subject: [PATCH 03/13] feat(cli): pass nvrams to cartesi-machine as --nvram flags --- apps/cli/src/machine.ts | 34 ++++++++++++- apps/cli/tests/unit/machine.test.ts | 78 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 apps/cli/tests/unit/machine.test.ts diff --git a/apps/cli/src/machine.ts b/apps/cli/src/machine.ts index b1884a66..a1cd7892 100644 --- a/apps/cli/src/machine.ts +++ b/apps/cli/src/machine.ts @@ -1,6 +1,13 @@ import dotenv from "dotenv"; import fs from "node:fs"; -import type { Config, DriveConfig, ImageInfo } from "./config.js"; +import { + type Config, + type DriveConfig, + type ImageInfo, + type NvramConfig, + nvramHasImage, + nvramImageFilename, +} from "./config.js"; import { cartesiMachine } from "./exec/index.js"; import type { ExecaOptionsDockerFallback } from "./exec/util.js"; @@ -21,6 +28,25 @@ const flashDrive = (label: string, drive: DriveConfig): string => { return `--flash-drive=${vars.join(",")}`; }; +const nvram = (label: string, config: NvramConfig): string => { + const { shared, size, user } = config; + const vars = [`label:${label}`]; + if (size !== undefined) { + vars.push(`length:${size}`); + } + if (nvramHasImage(config)) { + vars.push(`data_filename:${nvramImageFilename(label)}`); + } + if (user) { + vars.push(`user:${user}`); + } + if (shared) { + vars.push("shared"); + } + // don't specify start, let cartesi-machine place it + return `--nvram=${vars.join(",")}`; +}; + export type BootMachineOptions = { finalHash?: boolean; interactive?: boolean; @@ -105,11 +131,17 @@ export const buildMachineArgs = ( flashDrive(label, drive), ); + // keep the order the labels were declared in, as it affects the machine layout + const nvrams = Object.entries(config.nvrams).map(([label, nvramConfig]) => + nvram(label, nvramConfig), + ); + // command to change working directory if WORKDIR is defined const args = [ ...bootargs, ...envs, ...flashDrives, + ...nvrams, `--ram-length=${ramLength}`, ]; if (ramImage) { diff --git a/apps/cli/tests/unit/machine.test.ts b/apps/cli/tests/unit/machine.test.ts new file mode 100644 index 00000000..8c819c52 --- /dev/null +++ b/apps/cli/tests/unit/machine.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +import { parse } from "../../src/config.js"; +import { buildMachineArgs } from "../../src/machine.js"; + +const argsOf = (toml: string) => + buildMachineArgs( + parse([`[machine]\nentrypoint = "/bin/sh"\n${toml}`]), + undefined, + {}, + ); + +const nvramArgs = (toml: string) => + argsOf(toml).filter((arg) => arg.startsWith("--nvram=")); + +describe("buildMachineArgs", () => { + it("should not emit any --nvram when none is configured", () => { + expect(nvramArgs("")).toEqual([]); + }); + + it("should emit length only for a pristine nvram", () => { + expect(nvramArgs('[nvrams.input]\nsize = "4Ki"')).toEqual([ + "--nvram=label:input,length:4096", + ]); + }); + + it("should emit a data_filename for a shared nvram", () => { + const toml = ` + [nvrams.output] + size = "4Ki" + shared = true + user = "dapp" + `; + expect(nvramArgs(toml)).toEqual([ + "--nvram=label:output,length:4096,data_filename:output.raw,user:dapp,shared", + ]); + }); + + it("should omit length for an nvram backed by an existing image", () => { + expect(nvramArgs('[nvrams.seed]\nfilename = "./seed.raw"')).toEqual([ + "--nvram=label:seed,data_filename:seed.raw", + ]); + }); + + it("should emit both length and data_filename when both are defined", () => { + const toml = ` + [nvrams.seed] + filename = "./seed.raw" + size = "4Ki" + `; + expect(nvramArgs(toml)).toEqual([ + "--nvram=label:seed,length:4096,data_filename:seed.raw", + ]); + }); + + it("should emit nvrams after all flash drives, in configuration order", () => { + const toml = ` + [nvrams.output] + size = "4Ki" + + [nvrams.input] + size = "4Ki" + `; + const args = argsOf(toml); + const lastFlashDrive = args.reduce( + (last, arg, index) => + arg.startsWith("--flash-drive=") ? index : last, + -1, + ); + const firstNvram = args.findIndex((arg) => arg.startsWith("--nvram=")); + + expect(lastFlashDrive).toBeGreaterThanOrEqual(0); + expect(firstNvram).toBeGreaterThan(lastFlashDrive); + expect(nvramArgs(toml)).toEqual([ + "--nvram=label:output,length:4096", + "--nvram=label:input,length:4096", + ]); + }); +}); From 6e2a5137314f11a4207f4c8bae545fe46c3725f6 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 17 Aug 2026 17:54:06 +0100 Subject: [PATCH 04/13] feat(cli): build nvram backing images --- apps/cli/src/builder/index.ts | 1 + apps/cli/src/builder/nvram.ts | 47 ++++++++++++ apps/cli/src/commands/build.ts | 68 ++++++++++++----- apps/cli/src/config.ts | 2 +- .../tests/integration/builder/nvram.test.ts | 76 +++++++++++++++++++ 5 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 apps/cli/src/builder/nvram.ts create mode 100644 apps/cli/tests/integration/builder/nvram.test.ts diff --git a/apps/cli/src/builder/index.ts b/apps/cli/src/builder/index.ts index cf1a5225..4b01710b 100644 --- a/apps/cli/src/builder/index.ts +++ b/apps/cli/src/builder/index.ts @@ -2,4 +2,5 @@ export { build as buildDirectory } from "./directory.js"; export { build as buildDocker } from "./docker.js"; export { build as buildEmpty } from "./empty.js"; export { build as buildNone } from "./none.js"; +export { build as buildNvram } from "./nvram.js"; export { build as buildTar } from "./tar.js"; diff --git a/apps/cli/src/builder/nvram.ts b/apps/cli/src/builder/nvram.ts new file mode 100644 index 00000000..5ef5e8b7 --- /dev/null +++ b/apps/cli/src/builder/nvram.ts @@ -0,0 +1,47 @@ +import fs from "fs-extra"; +import path from "node:path"; +import { + MissingNvramSourceError, + NVRAM_ALIGNMENT, + type NvramConfig, + nvramHasImage, + nvramImageFilename, +} from "../config.js"; + +export const build = async ( + label: string, + nvram: NvramConfig, + destination: string, +): Promise => { + // a pristine nvram needs no image, cartesi-machine fills its range with zeros + if (!nvramHasImage(nvram)) { + return; + } + + const target = path.join(destination, nvramImageFilename(label)); + const source = nvram.filename; + + if (source === undefined) { + // shared with no image of its own, start it filled with zeros + if (nvram.size === undefined) { + throw new MissingNvramSourceError(label); + } + await fs.writeFile(target, Buffer.alloc(nvram.size)); + return; + } + + const { size } = await fs.stat(source); + if (nvram.size !== undefined && nvram.size !== size) { + throw new Error( + `Size ${nvram.size} of nvram '${label}' does not match the ${size} bytes of ${source}`, + ); + } + if (size % NVRAM_ALIGNMENT !== 0) { + throw new Error( + `Image ${source} of nvram '${label}' has ${size} bytes, which is not a multiple of ${NVRAM_ALIGNMENT}`, + ); + } + + // copy it into the destination, so it is reachable when running inside the sdk image + await fs.copyFile(source, target); +}; diff --git a/apps/cli/src/commands/build.ts b/apps/cli/src/commands/build.ts index fa6cdde9..e805da9d 100755 --- a/apps/cli/src/commands/build.ts +++ b/apps/cli/src/commands/build.ts @@ -10,9 +10,10 @@ import { buildDocker, buildEmpty, buildNone, + buildNvram, buildTar, } from "../builder/index.js"; -import type { Config, DriveConfig, ImageInfo } from "../config.js"; +import type { Config, DriveConfig, ImageInfo, NvramConfig } from "../config.js"; import { bootMachine } from "../machine.js"; // context for Listr build tasks @@ -79,6 +80,17 @@ const buildDriveTask = ( }, }); +const buildNvramTask = ( + label: string, + nvram: NvramConfig, +): ListrTask => ({ + title: `Building nvram ${chalk.cyan(label)}`, + task: async (ctx, task) => { + await buildNvram(label, nvram, ctx.destination); + task.title = `Build nvram ${chalk.cyan(label)}`; + }, +}); + export const createBuildCommand = () => { return new Command("build") .description( @@ -129,23 +141,45 @@ export const createBuildCommand = () => { ([name, drive]) => buildDriveTask(name, drive), ); - const builds = new Listr( - [ - { - title: "Build drives", - task: async (_ctx, task) => { - return task.newListr(driveTasks, { - concurrent: true, - rendererOptions: { - collapseSubtasks: false, - }, - ctx, - }); - }, - }, - ], - { ctx, renderer: verbose ? "verbose" : "default" }, + // tasks to build the images backing nvrams, pristine ones need none + const nvramTasks = Object.entries(config.nvrams).map( + ([label, nvram]) => buildNvramTask(label, nvram), ); + + const groups: ListrTask[] = [ + { + title: "Build drives", + task: async (_ctx, task) => { + return task.newListr(driveTasks, { + concurrent: true, + rendererOptions: { + collapseSubtasks: false, + }, + ctx, + }); + }, + }, + ]; + + if (nvramTasks.length > 0) { + groups.push({ + title: "Build nvrams", + task: async (_ctx, task) => { + return task.newListr(nvramTasks, { + concurrent: true, + rendererOptions: { + collapseSubtasks: false, + }, + ctx, + }); + }, + }); + } + + const builds = new Listr(groups, { + ctx, + renderer: verbose ? "verbose" : "default", + }); const result = await builds.run(); // if only build drives, quit here diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index 18faa1de..c78506db 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -3,7 +3,7 @@ import { extname } from "node:path"; import { parse as parseToml, type TomlPrimitive } from "smol-toml"; import { getAddress, isAddress, isHex, type Address } from "viem"; -const NVRAM_ALIGNMENT = 4096; // cartesi-machine requires length % 4Ki == 0 +export const NVRAM_ALIGNMENT = 4096; // cartesi-machine requires length % 4Ki == 0 const MAX_NVRAMS = 8; // guest exposes nvrams as /dev/uio0 to /dev/uio7 /** diff --git a/apps/cli/tests/integration/builder/nvram.test.ts b/apps/cli/tests/integration/builder/nvram.test.ts new file mode 100644 index 00000000..64dbe22c --- /dev/null +++ b/apps/cli/tests/integration/builder/nvram.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import fs from "fs-extra"; +import path from "node:path"; +import { build } from "../../../src/builder/nvram.js"; +import type { NvramConfig } from "../../../src/config.js"; +import { cleanupTempDir, createTempDir } from "./tmpdirTest.js"; + +describe("when building an nvram", () => { + let destination: string; + let source: string; + + beforeEach(async () => { + destination = await createTempDir(); + source = await createTempDir(); + }); + + afterEach(async () => { + await cleanupTempDir(destination); + await cleanupTempDir(source); + }); + + // writes a raw image of the given size in the source directory + const writeImage = async (name: string, size: number) => { + const filename = path.join(source, name); + await fs.writeFile(filename, Buffer.alloc(size, 1)); + return filename; + }; + + it("should not build an image for a pristine nvram", async () => { + const nvram: NvramConfig = { size: 4096 }; + await build("input", nvram, destination); + expect(fs.existsSync(path.join(destination, "input.raw"))).toBeFalsy(); + }); + + it("should build a zero filled image for a shared nvram", async () => { + const nvram: NvramConfig = { size: 8192, shared: true }; + await build("output", nvram, destination); + + const filename = path.join(destination, "output.raw"); + const stat = await fs.stat(filename); + expect(stat.isFile()).toBeTruthy(); + expect(stat.size).toEqual(8192); + expect(await fs.readFile(filename)).toEqual(Buffer.alloc(8192)); + }); + + it("should copy an existing image", async () => { + const filename = await writeImage("seed.raw", 4096); + await build("seed", { filename }, destination); + + const copy = path.join(destination, "seed.raw"); + expect(await fs.readFile(copy)).toEqual(await fs.readFile(filename)); + }); + + it("should fail for a missing image", async () => { + const filename = path.join(source, "missing.raw"); + await expect(build("seed", { filename }, destination)).rejects.toThrow( + "no such file or directory", + ); + }); + + it("should fail when the size does not match the image", async () => { + const filename = await writeImage("seed.raw", 4096); + await expect( + build("seed", { filename, size: 8192 }, destination), + ).rejects.toThrow( + `Size 8192 of nvram 'seed' does not match the 4096 bytes of ${filename}`, + ); + }); + + it("should fail when the image size is not a multiple of 4096", async () => { + const filename = await writeImage("seed.raw", 5000); + await expect(build("seed", { filename }, destination)).rejects.toThrow( + "which is not a multiple of 4096", + ); + }); +}); From c35279990f65ec9ed871fd13875f83bdfb1b162b Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 17 Aug 2026 17:54:32 +0100 Subject: [PATCH 05/13] feat(cli): verify nvram images are built before opening a shell --- .changeset/tired-lights-worry.md | 5 +++++ apps/cli/src/commands/shell.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 .changeset/tired-lights-worry.md diff --git a/.changeset/tired-lights-worry.md b/.changeset/tired-lights-worry.md new file mode 100644 index 00000000..b89a36e6 --- /dev/null +++ b/.changeset/tired-lights-worry.md @@ -0,0 +1,5 @@ +--- +"@cartesi/cli": patch +--- + +Add support for `nvrams` in `cartesi.toml`. An nvram is a raw range of bytes the guest reaches through a `/dev/uio*` device, with no filesystem and no mount point, so writes are visible to the emulator without a page cache in between. Declare one with `[nvrams.