Skip to content
Open
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
97 changes: 82 additions & 15 deletions host/src/homebrew-bottle-relocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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),
Expand All @@ -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} ` +
Expand All @@ -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) {
Expand All @@ -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("\\") ||
Expand Down
7 changes: 6 additions & 1 deletion host/src/homebrew-lazy-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
7 changes: 6 additions & 1 deletion host/src/homebrew-vfs-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
18 changes: 17 additions & 1 deletion host/src/vfs/memory-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type VfsDeferredTreeUsage,
} from "./deferred-tree-limits";
import {
inferHomebrewBottlePrefix,
parseHomebrewInstallReceiptRelocation,
relocateHomebrewBottleFile,
} from "../homebrew-bottle-relocation";
Expand Down Expand Up @@ -5303,6 +5304,16 @@ export class MemoryFileSystem implements FileSystemBackend {
: []
),
);
const relocationPrefix = relocationSources.size === 0
? 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",
),
);
if (content.source !== undefined) {
const sourceByPath = new Map(
content.source.entries.map((entry) => [entry.sourcePath, entry]),
Expand Down Expand Up @@ -5370,7 +5381,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);
}
}
Expand Down
Loading
Loading