From 8af3025d1fda6d099b9c12ffd7ff6656f70b2bc6 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sun, 2 Aug 2026 15:04:16 -0400 Subject: [PATCH 1/2] [Homebrew/Host] Honor each image's sealed bottle prefix Relocate bottle-owned text with the prefix authenticated by the VFS inventory instead of the repository's current default. This keeps immutable images readable after a prefix migration and preserves Node/browser parity in the shared filesystem path. --- host/src/homebrew-bottle-relocation.ts | 97 ++++++-- host/src/homebrew-lazy-layer.ts | 7 +- host/src/homebrew-vfs-builder.ts | 7 +- host/src/vfs/memory-fs.ts | 15 +- host/test/homebrew-bottle-relocation.test.ts | 243 +++++++++++++++++++ 5 files changed, 351 insertions(+), 18 deletions(-) create mode 100644 host/test/homebrew-bottle-relocation.test.ts diff --git a/host/src/homebrew-bottle-relocation.ts b/host/src/homebrew-bottle-relocation.ts index 130577e655..9941e94d0b 100644 --- a/host/src/homebrew-bottle-relocation.ts +++ b/host/src/homebrew-bottle-relocation.ts @@ -6,23 +6,20 @@ * archive has been verified and decoded. */ -import { KANDELO_HOMEBREW_GUEST_LAYOUT } from "./homebrew-guest-layout"; - const MAX_BOTTLE_CHANGED_FILES = 100_000; const MAX_BOTTLE_PATH_BYTES = 4096; -const HOMEBREW_PREFIX = KANDELO_HOMEBREW_GUEST_LAYOUT.prefix; -const HOMEBREW_REPLACEMENTS = [ - ["@@HOMEBREW_PREFIX@@", HOMEBREW_PREFIX], - ["@@HOMEBREW_CELLAR@@", `${HOMEBREW_PREFIX}/Cellar`], - ["@@HOMEBREW_REPOSITORY@@", HOMEBREW_PREFIX], - ["@@HOMEBREW_LIBRARY@@", `${HOMEBREW_PREFIX}/Library`], - ["@@HOMEBREW_PERL@@", `${HOMEBREW_PREFIX}/opt/perl/bin/perl`], -] as const; const HOMEBREW_JAVA_PLACEHOLDER = "@@HOMEBREW_JAVA@@"; const HOMEBREW_OPENJDK_NAME_RE = /^openjdk(?:@\d+(?:\.\d+)*)?/; const TEXT_ENCODER = new TextEncoder(); +const HOMEBREW_PLACEHOLDERS = [ + "@@HOMEBREW_PREFIX@@", + "@@HOMEBREW_CELLAR@@", + "@@HOMEBREW_REPOSITORY@@", + "@@HOMEBREW_LIBRARY@@", + "@@HOMEBREW_PERL@@", +] as const; const PLACEHOLDER_BYTES = [ - ...HOMEBREW_REPLACEMENTS.map(([placeholder]) => placeholder), + ...HOMEBREW_PLACEHOLDERS, HOMEBREW_JAVA_PLACEHOLDER, ].map((placeholder) => ({ placeholder, @@ -35,6 +32,44 @@ export interface HomebrewInstallReceiptRelocation { runtimeDependencies: unknown; } +export interface HomebrewRelocationInventoryPath { + sourcePath: string; + vfsPath: string; +} + +/** + * Recover the bottle's installation prefix from its authenticated VFS paths. + * + * WHY: a VFS image may outlive the repository's current default prefix. The + * sealed inventory owns the paths and expected byte lengths for that image; + * consulting a process-wide default would silently relocate old bottle bytes + * to a different prefix and then make their authenticated sizes disagree. + */ +export function inferHomebrewBottlePrefix( + entries: readonly HomebrewRelocationInventoryPath[], +): string { + if (entries.length === 0) { + throw new Error("Homebrew receipt relocation has no inventory paths"); + } + let prefix: string | undefined; + for (const entry of entries) { + validateSafeRelativePath(entry.sourcePath, "Homebrew bottle source"); + const suffix = `/Cellar/${entry.sourcePath}`; + if (!entry.vfsPath.endsWith(suffix)) { + throw new Error( + `Homebrew relocation path ${entry.vfsPath} does not end in ${suffix}`, + ); + } + const candidate = entry.vfsPath.slice(0, -suffix.length); + validateHomebrewPrefix(candidate); + if (prefix !== undefined && prefix !== candidate) { + throw new Error("Homebrew relocation inventory mixes installation prefixes"); + } + prefix = candidate; + } + return prefix!; +} + export function parseHomebrewInstallReceiptRelocation( bytes: Uint8Array, ): HomebrewInstallReceiptRelocation { @@ -89,9 +124,18 @@ export function relocateHomebrewBottleFile( bytes: Uint8Array, receipt: HomebrewInstallReceiptRelocation, path: string, + homebrewPrefix: string, ): Uint8Array { + validateHomebrewPrefix(homebrewPrefix); + const replacements = [ + ["@@HOMEBREW_PREFIX@@", homebrewPrefix], + ["@@HOMEBREW_CELLAR@@", `${homebrewPrefix}/Cellar`], + ["@@HOMEBREW_REPOSITORY@@", homebrewPrefix], + ["@@HOMEBREW_LIBRARY@@", `${homebrewPrefix}/Library`], + ["@@HOMEBREW_PERL@@", `${homebrewPrefix}/opt/perl/bin/perl`], + ] as const; let relocated = bytes; - for (const [placeholder, replacement] of HOMEBREW_REPLACEMENTS) { + for (const [placeholder, replacement] of replacements) { relocated = replaceBytes( relocated, TEXT_ENCODER.encode(placeholder), @@ -100,7 +144,10 @@ export function relocateHomebrewBottleFile( } const javaPlaceholder = TEXT_ENCODER.encode(HOMEBREW_JAVA_PLACEHOLDER); if (containsBytes(relocated, javaPlaceholder)) { - const javaHome = homebrewJavaHome(receipt.runtimeDependencies); + const javaHome = homebrewJavaHome( + receipt.runtimeDependencies, + homebrewPrefix, + ); if (javaHome === undefined) { throw new Error( `Homebrew changed file ${path} uses ${HOMEBREW_JAVA_PLACEHOLDER} ` + @@ -118,7 +165,10 @@ export function relocateHomebrewBottleFile( return relocated; } -function homebrewJavaHome(value: unknown): string | undefined { +function homebrewJavaHome( + value: unknown, + homebrewPrefix: string, +): string | undefined { if (!Array.isArray(value)) return undefined; const names: string[] = []; for (const dependency of value) { @@ -140,10 +190,27 @@ function homebrewJavaHome(value: unknown): string | undefined { } const unique = [...new Set(names)]; return unique.length === 1 - ? `${HOMEBREW_PREFIX}/opt/${unique[0]}/libexec` + ? `${homebrewPrefix}/opt/${unique[0]}/libexec` : undefined; } +function validateHomebrewPrefix(value: string): void { + if ( + value === "/" || !value.startsWith("/") || value.endsWith("/") || + value.includes("\\") || value.includes("\0") || + hasLoneUnicodeSurrogate(value) || + TEXT_ENCODER.encode(value).byteLength > MAX_BOTTLE_PATH_BYTES || + value.slice(1).split("/").some((part) => + part === "" || part === "." || part === ".." + ) || + PLACEHOLDER_BYTES.some(({ bytes }) => + containsBytes(TEXT_ENCODER.encode(value), bytes) + ) + ) { + throw new Error(`Homebrew relocation prefix is not canonical: ${value}`); + } +} + function validateSafeRelativePath(value: string, label: string): void { if ( value.length === 0 || value.startsWith("/") || value.includes("\\") || diff --git a/host/src/homebrew-lazy-layer.ts b/host/src/homebrew-lazy-layer.ts index 615f60a049..5194ac4fad 100644 --- a/host/src/homebrew-lazy-layer.ts +++ b/host/src/homebrew-lazy-layer.ts @@ -1013,7 +1013,12 @@ function prepareOriginalBottleRelocation( sourcePaths.add(source.path); const canonical = resolveTarRegularSource(source, sourceByPath, pkg); try { - const relocated = relocateHomebrewBottleFile(canonical.data, receipt, guestPath); + const relocated = relocateHomebrewBottleFile( + canonical.data, + receipt, + guestPath, + pkg.prefix, + ); const prior = bytesByCanonicalSource.get(canonical.path); if (prior !== undefined && !bytesEqual(prior, relocated)) { throw new Error("hard-link aliases produce different relocated bytes"); diff --git a/host/src/homebrew-vfs-builder.ts b/host/src/homebrew-vfs-builder.ts index 80a73cb95b..4e88c24171 100644 --- a/host/src/homebrew-vfs-builder.ts +++ b/host/src/homebrew-vfs-builder.ts @@ -812,7 +812,12 @@ function relocateBottlePlaceholders( } let bytes: Uint8Array; try { - bytes = relocateHomebrewBottleFile(readVfsFile(fs, path), relocation, path); + bytes = relocateHomebrewBottleFile( + readVfsFile(fs, path), + relocation, + path, + pkg.prefix, + ); } catch (error) { fail(pkg, error instanceof Error ? error.message : String(error)); } diff --git a/host/src/vfs/memory-fs.ts b/host/src/vfs/memory-fs.ts index 3fb01f468d..16a255e201 100644 --- a/host/src/vfs/memory-fs.ts +++ b/host/src/vfs/memory-fs.ts @@ -22,6 +22,7 @@ import { type VfsDeferredTreeUsage, } from "./deferred-tree-limits"; import { + inferHomebrewBottlePrefix, parseHomebrewInstallReceiptRelocation, relocateHomebrewBottleFile, } from "../homebrew-bottle-relocation"; @@ -5303,6 +5304,13 @@ export class MemoryFileSystem implements FileSystemBackend { : [] ), ); + const relocationPrefix = relocationSources.size === 0 + ? undefined + : inferHomebrewBottlePrefix( + inventory.filter( + (entry) => entry.materialization === "archive-homebrew-relocate", + ), + ); if (content.source !== undefined) { const sourceByPath = new Map( content.source.entries.map((entry) => [entry.sourcePath, entry]), @@ -5370,7 +5378,12 @@ export class MemoryFileSystem implements FileSystemBackend { ); } if (relocatedCanonicalSources.has(canonical.sourcePath)) continue; - actual.data = relocateHomebrewBottleFile(actual.data, receipt, sourcePath); + actual.data = relocateHomebrewBottleFile( + actual.data, + receipt, + sourcePath, + relocationPrefix!, + ); relocatedCanonicalSources.add(canonical.sourcePath); } } diff --git a/host/test/homebrew-bottle-relocation.test.ts b/host/test/homebrew-bottle-relocation.test.ts new file mode 100644 index 0000000000..7b7d2b1bf9 --- /dev/null +++ b/host/test/homebrew-bottle-relocation.test.ts @@ -0,0 +1,243 @@ +import { createHash } from "node:crypto"; +import { gzipSync } from "fflate"; +import { describe, expect, it } from "vitest"; + +import { + inferHomebrewBottlePrefix, + parseHomebrewInstallReceiptRelocation, + relocateHomebrewBottleFile, +} from "../src/homebrew-bottle-relocation"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; + +const CURRENT_PREFIX = "/opt/kandelo/homebrew"; +const LEGACY_PREFIX = "/home/linuxbrew/.linuxbrew"; + +describe("Homebrew bottle relocation authority", () => { + it.each([CURRENT_PREFIX, LEGACY_PREFIX])( + "derives %s from the authenticated bottle inventory", + (prefix) => { + expect(inferHomebrewBottlePrefix([ + { + sourcePath: "ruby/4.0.5_1/INSTALL_RECEIPT.json", + vfsPath: + `${prefix}/Cellar/ruby/4.0.5_1/INSTALL_RECEIPT.json`, + }, + { + sourcePath: "ruby/4.0.5_1/lib/ruby.conf", + vfsPath: `${prefix}/Cellar/ruby/4.0.5_1/lib/ruby.conf`, + }, + ])).toBe(prefix); + }, + ); + + it.each([CURRENT_PREFIX, LEGACY_PREFIX])( + "relocates receipt-owned placeholders to %s", + (prefix) => { + const receipt = parseHomebrewInstallReceiptRelocation( + new TextEncoder().encode(JSON.stringify({ + changed_files: ["lib/runtime.conf"], + runtime_dependencies: [{ full_name: "openjdk@21" }], + })), + ); + const source = new TextEncoder().encode([ + "prefix=@@HOMEBREW_PREFIX@@", + "cellar=@@HOMEBREW_CELLAR@@", + "repository=@@HOMEBREW_REPOSITORY@@", + "library=@@HOMEBREW_LIBRARY@@", + "perl=@@HOMEBREW_PERL@@", + "java=@@HOMEBREW_JAVA@@", + ].join("\n")); + + expect(new TextDecoder().decode(relocateHomebrewBottleFile( + source, + receipt, + "ruby/4.0.5_1/lib/runtime.conf", + prefix, + ))).toBe([ + `prefix=${prefix}`, + `cellar=${prefix}/Cellar`, + `repository=${prefix}`, + `library=${prefix}/Library`, + `perl=${prefix}/opt/perl/bin/perl`, + `java=${prefix}/opt/openjdk@21/libexec`, + ].join("\n")); + }, + ); + + it("rejects a bottle inventory that mixes installation prefixes", () => { + expect(() => inferHomebrewBottlePrefix([ + { + sourcePath: "ruby/4.0.5_1/INSTALL_RECEIPT.json", + vfsPath: + `${CURRENT_PREFIX}/Cellar/ruby/4.0.5_1/INSTALL_RECEIPT.json`, + }, + { + sourcePath: "ruby/4.0.5_1/lib/ruby.conf", + vfsPath: `${LEGACY_PREFIX}/Cellar/ruby/4.0.5_1/lib/ruby.conf`, + }, + ])).toThrow(/mixes installation prefixes/); + }); + + it.each([ + [ + "a non-Cellar path", + { + sourcePath: "ruby/4.0.5_1/INSTALL_RECEIPT.json", + vfsPath: `${CURRENT_PREFIX}/ruby/4.0.5_1/INSTALL_RECEIPT.json`, + }, + /does not end in/, + ], + [ + "an unsafe source path", + { + sourcePath: "../INSTALL_RECEIPT.json", + vfsPath: `${CURRENT_PREFIX}/Cellar/../INSTALL_RECEIPT.json`, + }, + /unsafe path segment/, + ], + ])("rejects %s", (_label, entry, expected) => { + expect(() => inferHomebrewBottlePrefix([entry])).toThrow(expected); + }); + + it("materializes a retired-prefix bottle from its sealed VFS inventory", async () => { + const sourceRoot = "ruby/4.0.5_1"; + const keg = `${LEGACY_PREFIX}/Cellar/${sourceRoot}`; + const receipt = new TextEncoder().encode(JSON.stringify({ + changed_files: ["lib/runtime.conf"], + }) + "\n"); + const runtime = new TextEncoder().encode("prefix=@@HOMEBREW_PREFIX@@\n"); + const relocatedRuntime = new TextEncoder().encode( + `prefix=${LEGACY_PREFIX}\n`, + ); + const source = [ + { path: sourceRoot, mode: 0o755 }, + { path: `${sourceRoot}/INSTALL_RECEIPT.json`, mode: 0o644, data: receipt }, + { path: `${sourceRoot}/lib`, mode: 0o755 }, + { path: `${sourceRoot}/lib/runtime.conf`, mode: 0o644, data: runtime }, + ]; + const tar = testTar(source); + const payload = gzipSync(tar); + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + fs.setLazyFetcher(async () => new Response(payload)); + fs.registerLazyTree({ + decoder: "homebrew-bottle-tar-gzip-v1", + mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", + sha256: createHash("sha256").update(payload).digest("hex"), + bytes: payload.byteLength, + expandedBytes: tar.byteLength, + sourceEntryCount: source.length, + transports: ["https://example.invalid/ruby.tar.gz"], + source: { + schema: 1, + kind: "homebrew-bottle-tar-gzip-v1", + entries: source.map((entry) => ({ + sourcePath: entry.path, + type: entry.data === undefined ? "directory" as const : "file" as const, + mode: entry.mode, + size: entry.data?.byteLength ?? 0, + })), + }, + }, source.map((entry) => ({ + vfsPath: `${LEGACY_PREFIX}/Cellar/${entry.path}`, + sourcePath: entry.path, + materialization: entry.path.endsWith("/lib/runtime.conf") + ? "archive-homebrew-relocate" as const + : "archive" as const, + type: entry.data === undefined ? "directory" as const : "file" as const, + mode: entry.mode, + size: entry.path.endsWith("/lib/runtime.conf") + ? relocatedRuntime.byteLength + : entry.data?.byteLength ?? 0, + ...(entry.data === undefined ? {} : { inodeGroup: entry.path }), + })), "/", { + mode: "first-use", + capabilities: ["test:retired-homebrew-prefix"], + roots: [keg], + }); + + await expect(fs.preparePath(`${keg}/lib/runtime.conf`)).resolves.toBe(true); + expect(readFile(fs, `${keg}/lib/runtime.conf`)).toBe( + `prefix=${LEGACY_PREFIX}\n`, + ); + }); +}); + +interface TestTarEntry { + path: string; + mode: number; + data?: Uint8Array; +} + +function testTar(entries: readonly TestTarEntry[]): Uint8Array { + const chunks: Uint8Array[] = []; + for (const entry of entries) { + const header = new Uint8Array(512); + writeTarString(header, 0, 100, entry.path); + writeTarOctal(header, 100, 8, entry.mode); + writeTarOctal(header, 108, 8, 0); + writeTarOctal(header, 116, 8, 0); + writeTarOctal(header, 124, 12, entry.data?.byteLength ?? 0); + writeTarOctal(header, 136, 12, 0); + header.fill(0x20, 148, 156); + header[156] = (entry.data === undefined ? "5" : "0").charCodeAt(0); + writeTarString(header, 257, 6, "ustar"); + writeTarString(header, 263, 2, "00"); + writeTarOctal( + header, + 148, + 8, + header.reduce((sum, byte) => sum + byte, 0), + ); + header[155] = 0x20; + chunks.push(header); + if (entry.data !== undefined) { + const padded = new Uint8Array( + Math.ceil(entry.data.byteLength / 512) * 512, + ); + padded.set(entry.data); + chunks.push(padded); + } + } + chunks.push(new Uint8Array(1024)); + const size = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const tar = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + tar.set(chunk, offset); + offset += chunk.byteLength; + } + return tar; +} + +function writeTarString( + target: Uint8Array, + offset: number, + length: number, + value: string, +): void { + const bytes = new TextEncoder().encode(value); + if (bytes.byteLength > length) throw new Error(`test TAR field too long: ${value}`); + target.set(bytes, offset); +} + +function writeTarOctal( + target: Uint8Array, + offset: number, + length: number, + value: number, +): void { + const digits = value.toString(8).padStart(length - 2, "0"); + writeTarString(target, offset, length, `${digits}\0`); +} + +function readFile(fs: MemoryFileSystem, path: string): string { + const stat = fs.stat(path); + const file = fs.open(path, 0, 0); + try { + const bytes = new Uint8Array(stat.size); + fs.read(file, bytes, null, bytes.byteLength); + return new TextDecoder().decode(bytes); + } finally { + fs.close(file); + } +} From 214e13e688581b22c851308c2ea7881323f8bd23 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sun, 2 Aug 2026 17:11:37 -0400 Subject: [PATCH 2/2] [Homebrew/Test] Protect non-bottle VFS path ownership Make the Homebrew Cellar rule explicitly apply only to inventory entries marked for bottle relocation. Cover a mixed inventory so ordinary VFS sources can target valid guest paths outside the Cellar while malformed relocation entries fail closed. --- host/src/vfs/memory-fs.ts | 3 + host/test/homebrew-bottle-relocation.test.ts | 146 +++++++++++-------- 2 files changed, 92 insertions(+), 57 deletions(-) diff --git a/host/src/vfs/memory-fs.ts b/host/src/vfs/memory-fs.ts index 16a255e201..704748cfc3 100644 --- a/host/src/vfs/memory-fs.ts +++ b/host/src/vfs/memory-fs.ts @@ -5308,6 +5308,9 @@ export class MemoryFileSystem implements FileSystemBackend { ? undefined : inferHomebrewBottlePrefix( inventory.filter( + // WHY: only explicitly marked bottle files belong to Homebrew's + // Cellar relocation contract. Other VFS sources may use any valid + // guest path, including paths outside the Homebrew prefix. (entry) => entry.materialization === "archive-homebrew-relocate", ), ); diff --git a/host/test/homebrew-bottle-relocation.test.ts b/host/test/homebrew-bottle-relocation.test.ts index 7b7d2b1bf9..0723febed4 100644 --- a/host/test/homebrew-bottle-relocation.test.ts +++ b/host/test/homebrew-bottle-relocation.test.ts @@ -99,69 +99,101 @@ describe("Homebrew bottle relocation authority", () => { expect(() => inferHomebrewBottlePrefix([entry])).toThrow(expected); }); - it("materializes a retired-prefix bottle from its sealed VFS inventory", async () => { - const sourceRoot = "ruby/4.0.5_1"; - const keg = `${LEGACY_PREFIX}/Cellar/${sourceRoot}`; - const receipt = new TextEncoder().encode(JSON.stringify({ - changed_files: ["lib/runtime.conf"], - }) + "\n"); - const runtime = new TextEncoder().encode("prefix=@@HOMEBREW_PREFIX@@\n"); - const relocatedRuntime = new TextEncoder().encode( + it("limits Cellar ownership to marked Homebrew relocation entries", async () => { + const fixture = homebrewRelocationTreeFixture(); + + await expect(fixture.fs.preparePath(fixture.runtimePath)).resolves.toBe(true); + expect(readFile(fixture.fs, fixture.runtimePath)).toBe( `prefix=${LEGACY_PREFIX}\n`, ); - const source = [ - { path: sourceRoot, mode: 0o755 }, - { path: `${sourceRoot}/INSTALL_RECEIPT.json`, mode: 0o644, data: receipt }, - { path: `${sourceRoot}/lib`, mode: 0o755 }, - { path: `${sourceRoot}/lib/runtime.conf`, mode: 0o644, data: runtime }, - ]; - const tar = testTar(source); - const payload = gzipSync(tar); - const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); - fs.setLazyFetcher(async () => new Response(payload)); - fs.registerLazyTree({ - decoder: "homebrew-bottle-tar-gzip-v1", - mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", - sha256: createHash("sha256").update(payload).digest("hex"), - bytes: payload.byteLength, - expandedBytes: tar.byteLength, - sourceEntryCount: source.length, - transports: ["https://example.invalid/ruby.tar.gz"], - source: { - schema: 1, - kind: "homebrew-bottle-tar-gzip-v1", - entries: source.map((entry) => ({ - sourcePath: entry.path, - type: entry.data === undefined ? "directory" as const : "file" as const, - mode: entry.mode, - size: entry.data?.byteLength ?? 0, - })), - }, - }, source.map((entry) => ({ - vfsPath: `${LEGACY_PREFIX}/Cellar/${entry.path}`, - sourcePath: entry.path, - materialization: entry.path.endsWith("/lib/runtime.conf") - ? "archive-homebrew-relocate" as const - : "archive" as const, - type: entry.data === undefined ? "directory" as const : "file" as const, - mode: entry.mode, - size: entry.path.endsWith("/lib/runtime.conf") - ? relocatedRuntime.byteLength - : entry.data?.byteLength ?? 0, - ...(entry.data === undefined ? {} : { inodeGroup: entry.path }), - })), "/", { - mode: "first-use", - capabilities: ["test:retired-homebrew-prefix"], - roots: [keg], - }); - - await expect(fs.preparePath(`${keg}/lib/runtime.conf`)).resolves.toBe(true); - expect(readFile(fs, `${keg}/lib/runtime.conf`)).toBe( - `prefix=${LEGACY_PREFIX}\n`, + expect(readFile(fixture.fs, fixture.ordinaryPath)).toBe( + "ordinary VFS content\n", + ); + }); + + it("rejects a marked Homebrew relocation entry outside Cellar", async () => { + const malformedPath = "/var/lib/kandelo/runtime.conf"; + const fixture = homebrewRelocationTreeFixture(malformedPath); + + await expect(fixture.fs.preparePath(malformedPath)).rejects.toThrow( + /does not end in/, ); }); }); +function homebrewRelocationTreeFixture( + runtimePath = `${LEGACY_PREFIX}/Cellar/ruby/4.0.5_1/lib/runtime.conf`, +): { + fs: MemoryFileSystem; + runtimePath: string; + ordinaryPath: string; +} { + const sourceRoot = "ruby/4.0.5_1"; + const keg = `${LEGACY_PREFIX}/Cellar/${sourceRoot}`; + const runtimeSourcePath = `${sourceRoot}/lib/runtime.conf`; + const ordinarySourcePath = "share/non-homebrew.txt"; + const ordinaryPath = "/etc/kandelo/non-homebrew.txt"; + const receipt = new TextEncoder().encode(JSON.stringify({ + changed_files: ["lib/runtime.conf"], + }) + "\n"); + const runtime = new TextEncoder().encode("prefix=@@HOMEBREW_PREFIX@@\n"); + const relocatedRuntime = new TextEncoder().encode( + `prefix=${LEGACY_PREFIX}\n`, + ); + const ordinary = new TextEncoder().encode("ordinary VFS content\n"); + const source = [ + { path: sourceRoot, mode: 0o755 }, + { path: `${sourceRoot}/INSTALL_RECEIPT.json`, mode: 0o644, data: receipt }, + { path: `${sourceRoot}/lib`, mode: 0o755 }, + { path: runtimeSourcePath, mode: 0o644, data: runtime }, + { path: ordinarySourcePath, mode: 0o644, data: ordinary }, + ]; + const tar = testTar(source); + const payload = gzipSync(tar); + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + fs.setLazyFetcher(async () => new Response(payload)); + fs.registerLazyTree({ + decoder: "homebrew-bottle-tar-gzip-v1", + mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", + sha256: createHash("sha256").update(payload).digest("hex"), + bytes: payload.byteLength, + expandedBytes: tar.byteLength, + sourceEntryCount: source.length, + transports: ["https://example.invalid/ruby.tar.gz"], + source: { + schema: 1, + kind: "homebrew-bottle-tar-gzip-v1", + entries: source.map((entry) => ({ + sourcePath: entry.path, + type: entry.data === undefined ? "directory" as const : "file" as const, + mode: entry.mode, + size: entry.data?.byteLength ?? 0, + })), + }, + }, source.map((entry) => ({ + vfsPath: entry.path === runtimeSourcePath + ? runtimePath + : entry.path === ordinarySourcePath + ? ordinaryPath + : `${LEGACY_PREFIX}/Cellar/${entry.path}`, + sourcePath: entry.path, + materialization: entry.path === runtimeSourcePath + ? "archive-homebrew-relocate" as const + : "archive" as const, + type: entry.data === undefined ? "directory" as const : "file" as const, + mode: entry.mode, + size: entry.path === runtimeSourcePath + ? relocatedRuntime.byteLength + : entry.data?.byteLength ?? 0, + ...(entry.data === undefined ? {} : { inodeGroup: entry.path }), + })), "/", { + mode: "first-use", + capabilities: ["test:retired-homebrew-prefix"], + roots: [keg], + }); + return { fs, runtimePath, ordinaryPath }; +} + interface TestTarEntry { path: string; mode: number;