Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,10 @@ const restored = MemoryFileSystem.fromImage(image, { maxByteLength: 1024 * 1024
```

The image must also have been built with a large enough filesystem maximum, for example `MemoryFileSystem.create(sab, 1024 * 1024 * 1024)`. `fromImage(..., { maxByteLength })` only controls the restored buffer's runtime growth ceiling; `statfs`/`df` and allocation remain capped by the image superblock maximum.
Call `MemoryFileSystem.readImageCapacity(image)` when build tooling needs the
serialized buffer length and superblock ceiling without restoring the image.
Call `fromImagePreservingCapacity(image)` to restore a growable buffer with the
same ceiling the image builder recorded.

A consumer that must stage files larger than the image's recorded allocation
ceiling first calls `rebaseToNewFileSystem(requiredMaxBytes)`. Shared image
Expand Down
69 changes: 66 additions & 3 deletions docs/homebrew-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,28 +575,91 @@ those claims require the trusted publish and bottle validation paths below.
The shared planner is `planHomebrewVfs()` in
`host/src/homebrew-vfs-planner.ts`. It consumes `Kandelo/metadata.json` plus a
caller-provided link-manifest loader and rejects bad ABI, unsupported arch,
cache-key drift, missing packages, dependency cycles, unsafe paths, and
link-manifest bottle drift before any bottle bytes are extracted.
tap-identity drift, duplicate roots or metadata, cache-key drift, missing
packages, dependency cycles, unsafe paths, and link-manifest bottle drift
before any bottle bytes are extracted. It resolves the requested roots and
their single-tap dependency closure in deterministic dependency-first order.

The Node-side builder is `buildHomebrewVfs()` in
`host/src/homebrew-vfs-builder.ts`. It verifies bottle byte count and sha256,
extracts supported tar entries, stages kegs under the declared prefix,
validates receipts, applies link manifests, writes
`/etc/kandelo/homebrew-vfs.json`, and emits a build report.

The CLI starts with an empty VFS by default. Pass `--base-image` to overlay the
same verified bottle plan onto an explicit platform-only `.vfs` or `.vfs.zst`
base image. The base must declare the same kernel ABI as the bottle metadata
and must not already carry Homebrew composition metadata or
`/etc/kandelo/homebrew-vfs.json`; merging independently composed Homebrew
prefixes would lose package provenance, so it fails closed. Existing files
remain unchanged except for requested bottle/link paths and the builder-owned
Homebrew manifest (plus the optional profile fragment); path collisions fail
through the normal staging checks.

The output image metadata binds the exact base input with a bounded object:
SHA-256, byte count, and declared kernel ABI. It also records the selected tap
repository and canonical tap name. The JSON report carries the same binding
plus the base's full source metadata for auditing. Base signatures,
attestations, and other metadata are not copied onto the mutated output, and
large source metadata is not nested into each new image.

When `--max-bytes` is omitted, the builder restores the base with its recorded
filesystem maximum and does not rebuild existing inodes. Supplying a different
`--max-bytes` explicitly rebases the filesystem to that exact maximum, so
allocation and `statfs` agree with the requested capacity. Explicit maxima
must be multiples of the 4096-byte SharedFS block size.

For reproducible image composition, use the builder's static Brewfile subset.
For example:

```ruby
tap "automattic/kandelo-homebrew"
brew "sqlite"
brew "automattic/kandelo-homebrew/xz"
```

The subset accepts blank lines, comments, exactly one literal canonical
lowercase `tap "owner/tap"`, and between 1 and 128 literal `brew` entries.
Entries may use a bare formula name or a fully qualified name from that exact
tap. Bare and qualified forms normalize to the same root, so duplicates fail.
The selected tap must exactly match `metadata.json`, and the complete resolved
closure is limited to 128 packages.

The parser uses Ripper to inspect the syntax tree and never evaluates the file.
Options, interpolation, conditionals, variables, nested Ruby, `cask`, `mas`,
`service`, and every other Homebrew Bundle entry are rejected. Full Bundle DSL
belongs to real Homebrew running inside a Kandelo guest; it is not a safe or
deterministic host-side image specification. This builder intentionally accepts
only one tap, so a root or required dependency from another tap must fail until
multi-tap composition has an explicit metadata and provenance contract.

This path does not read `Brewfile.lock.json`; Homebrew Bundle does not define a
lock-file contract. Reproducibility instead comes from the exact Brewfile
SHA-256 and byte count, ordered normalized roots, tap commit, base-image digest,
and verified bottle digests recorded in the report and image manifest. The
root digest is SHA-256 over the UTF-8 JSON array of normalized roots in declared
order. The bounded top-level VFS metadata records only root count and digest
rather than embedding arbitrary source text.

Build a precomposed image with:

```bash
scripts/dev-shell.sh npx tsx images/vfs/scripts/build-homebrew-vfs-image.ts \
--metadata /path/to/kandelo-homebrew/Kandelo/metadata.json \
--tap-root /path/to/kandelo-homebrew \
--package hello \
--brewfile /path/to/Brewfile \
--arch wasm32 \
--runtime node \
--base-image target/platform-base.vfs.zst \
--out target/homebrew-hello.vfs.zst \
--report target/homebrew-hello.vfs-report.json
```

Repeatable `--package <name>` remains available for lower-level tooling and
focused tests. It preserves the provided root order and uses the same planner,
limits, and provenance report. `--package` and `--brewfile` are mutually
exclusive.

The bottle fetcher follows GHCR `WWW-Authenticate` bearer challenges. Public
bottle materializers do not need a GitHub token merely to read public GHCR
blobs.
Expand Down
45 changes: 39 additions & 6 deletions homebrew/kandelo-homebrew/Kandelo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,37 +103,70 @@ Host VFS tooling plans a Homebrew-prefix image with
shared by Node and browser callers. It consumes parsed `Kandelo/metadata.json`
and a caller-provided link-manifest loader, resolves requested packages plus
their dependency closure in dependency-first order, and rejects bad ABI,
unsupported arch, cache-key drift, missing packages, dependency cycles, unsafe
paths, and link-manifest bottle URL/sha/byte/cache-key drift before any bottle
bytes are extracted.
unsupported arch, tap-identity drift, duplicate roots or metadata, cache-key
drift, missing packages, dependency cycles, unsafe paths, and link-manifest
bottle URL/sha/byte/cache-key drift before any bottle bytes are extracted.

For `failed`, `pending`, or `building` bottle entries, the planner uses the
complete last-green fallback fields when available. Without a complete fallback,
the package is not plannable for a VFS image.

## VFS Image Building

Build a precomposed Homebrew-prefix image from generated sidecars and verified
bottle bytes with:
Write a static Brewfile that names one tap and the formula roots for the image:

```ruby
tap "automattic/kandelo-homebrew"
brew "sqlite"
brew "automattic/kandelo-homebrew/xz"
```

Then build a precomposed Homebrew-prefix image from generated sidecars and
verified bottle bytes with:

```bash
scripts/dev-shell.sh npx tsx images/vfs/scripts/build-homebrew-vfs-image.ts \
--metadata /path/to/kandelo-homebrew/Kandelo/metadata.json \
--tap-root /path/to/kandelo-homebrew \
--package hello \
--brewfile /path/to/Brewfile \
--arch wasm32 \
--runtime node \
--base-image target/platform-base.vfs.zst \
--out target/homebrew-hello.vfs.zst \
--report target/homebrew-hello.vfs-report.json
```

This is a deliberately small, non-executing Brewfile subset: blank lines,
comments, exactly one literal lowercase `tap "owner/tap"`, and 1 to 128 literal
`brew` entries. A formula may be bare or fully qualified under that exact tap;
duplicates after normalization are rejected. Ripper validates the syntax tree,
and the builder never evaluates the file. Options, interpolation, conditionals,
variables, nested Ruby, other entry types, and multi-tap selection are rejected.
Use real Homebrew inside a running Kandelo guest when full Homebrew Bundle DSL
behavior is required.

Homebrew Bundle does not define a `Brewfile.lock.json` contract. The report and
image manifest instead bind the Brewfile SHA-256 and byte count, ordered roots,
tap commit, base image, and exact bottle digests. The root digest is SHA-256 over
the UTF-8 JSON array of normalized roots in declared order. Repeatable
`--package <name>` is still available to lower-level callers, but it cannot be
combined with `--brewfile`.

The builder consumes only `metadata.json`, link manifests, and bottle tarballs.
It does not evaluate Formula Ruby. It verifies the selected bottle byte count
and sha256, rejects unsafe or unsupported tar entries, stages files under the
declared keg, validates receipts, applies the link manifest under the declared
prefix, writes `/etc/kandelo/homebrew-vfs.json`, saves a `.vfs.zst`, and emits a
JSON report beside the image.

`--base-image` is optional and accepts only an ABI-matched platform image that
does not already contain a Homebrew composition. Output image metadata records
a bounded binding with the base SHA-256, byte count, and declared ABI; the JSON
report also retains the full source metadata for auditing. Omit `--max-bytes`
to retain the base filesystem maximum without rebuilding existing inodes, or
set it to rebase the filesystem to an exact, 4096-byte-aligned new maximum
before bottles are staged.

Link and receipt paths starting with `Cellar/` are interpreted relative to the
Homebrew prefix. Other link and receipt paths are interpreted relative to the
staged keg. Bottle payload entries under `bottle.payload_root` map to the keg;
Expand Down
1 change: 1 addition & 0 deletions host/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type {
LazyDownloadListener,
LazyDownloadStatus,
LazyFileEntry,
VfsImageCapacity,
} from "./vfs/memory-fs";
export { DeviceFileSystem } from "./vfs/device-fs";
export { OpfsFileSystem } from "./vfs/opfs";
Expand Down
79 changes: 78 additions & 1 deletion host/src/homebrew-vfs-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const S_IFLNK = 0xa000;
const O_RDONLY = 0;
const MODE_BITS = 0o7777;
const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
const TEXT_ENCODER = new TextEncoder();
const SHA256_RE = /^[0-9a-f]{64}$/;
const MAX_BREWFILE_BYTES = 65_536;

export class HomebrewVfsBuildError extends Error {
constructor(message: string) {
Expand All @@ -37,6 +40,26 @@ export interface HomebrewVfsBuildOptions {
) => Uint8Array | Promise<Uint8Array>;
writeProfile?: boolean;
createdBy?: string;
selectionSource?: HomebrewVfsSelectionSource;
}

export interface HomebrewVfsSelectionSource {
kind: "brewfile";
parser: "kandelo-static-brewfile-v1";
sha256: string;
bytes: number;
requestedPackages: string[];
}

export interface HomebrewVfsSelectionReport {
kind: "packages" | "brewfile";
requested_packages: string[];
requested_packages_sha256: string;
brewfile?: {
parser: "kandelo-static-brewfile-v1";
sha256: string;
bytes: number;
};
}

export interface HomebrewVfsPackageReport {
Expand All @@ -62,6 +85,7 @@ export interface HomebrewVfsPackageReport {
export interface HomebrewVfsBuildReport {
schema: 1;
image?: string;
selection: HomebrewVfsSelectionReport;
metadata: {
tap_repository: string;
tap_name: string;
Expand Down Expand Up @@ -101,6 +125,7 @@ export async function buildHomebrewVfs(
): Promise<HomebrewVfsBuildResult> {
const fs = options.fs ?? createDefaultFs();
const packageReports: HomebrewVfsPackageReport[] = [];
const selection = createSelectionReport(plan, options.selectionSource);

ensureDirRecursive(fs, "/etc/kandelo");

Expand Down Expand Up @@ -135,6 +160,7 @@ export async function buildHomebrewVfs(

const report: HomebrewVfsBuildReport = {
schema: 1,
selection,
metadata: {
tap_repository: plan.tapRepository,
tap_name: plan.tapName,
Expand All @@ -153,6 +179,7 @@ export async function buildHomebrewVfs(
JSON.stringify({
schema: 1,
created_by: options.createdBy ?? "host/src/homebrew-vfs-builder.ts",
selection,
metadata: report.metadata,
packages: packageReports.map((pkg) => ({
name: pkg.name,
Expand Down Expand Up @@ -180,6 +207,55 @@ export async function buildHomebrewVfs(
return { fs, report };
}

function createSelectionReport(
plan: HomebrewVfsPlan,
source: HomebrewVfsSelectionSource | undefined,
): HomebrewVfsSelectionReport {
const requestedPackages = [...plan.requestedPackages];
if (requestedPackages.length === 0) {
throw new HomebrewVfsBuildError("Homebrew VFS plan has no requested packages");
}
const requestedPackagesSha256 = sha256(
TEXT_ENCODER.encode(JSON.stringify(requestedPackages)),
);
if (source === undefined) {
return {
kind: "packages",
requested_packages: requestedPackages,
requested_packages_sha256: requestedPackagesSha256,
};
}
if (
source.kind !== "brewfile" ||
source.parser !== "kandelo-static-brewfile-v1" ||
!SHA256_RE.test(source.sha256) ||
!Number.isInteger(source.bytes) ||
source.bytes <= 0 ||
source.bytes > MAX_BREWFILE_BYTES
) {
throw new HomebrewVfsBuildError("Homebrew VFS Brewfile selection provenance is invalid");
}
if (
!Array.isArray(source.requestedPackages) ||
source.requestedPackages.length !== requestedPackages.length ||
source.requestedPackages.some((pkg, index) => pkg !== requestedPackages[index])
) {
throw new HomebrewVfsBuildError(
"Homebrew VFS Brewfile requested packages do not match the plan roots",
);
}
return {
kind: "brewfile",
requested_packages: requestedPackages,
requested_packages_sha256: requestedPackagesSha256,
brewfile: {
parser: source.parser,
sha256: source.sha256,
bytes: source.bytes,
},
};
}

function createDefaultFs(): MemoryFileSystem {
const SharedArrayBufferCtor = SharedArrayBuffer as new (
byteLength: number,
Expand Down Expand Up @@ -276,9 +352,10 @@ function applyLinks(fs: MemoryFileSystem, pkg: HomebrewVfsPackagePlan): string[]
const seenTargets = new Set<string>();

for (const entry of pkg.linkManifest.links) {
if (!seenTargets.add(entry.target)) {
if (seenTargets.has(entry.target)) {
fail(pkg, `link target ${entry.target} is duplicated`);
}
seenTargets.add(entry.target);

const sourcePath = resolveManifestSource(pkg, entry.source);
const targetPath = joinGuestPath(pkg.prefix, entry.target);
Expand Down
Loading
Loading