Extract familiar installation archive seam

This commit is contained in:
OpenPets Dev 2026-06-19 04:59:23 +00:00
parent 2b1a4e80e2
commit cb591aca0f
5 changed files with 273 additions and 225 deletions

View file

@ -70,6 +70,8 @@ for (const icon of ["claude.svg", "cursor.svg", "opencode.svg", "pi.svg", "vscod
assert.match(readFileSync(join(appDir, "src", "assets.ts"), "utf8"), /assets["']?,\s*["']tray-icon\.png|join\("assets", "tray-icon\.png"\)/, "tray icon code must keep using assets/tray-icon.png.");
const petWindowSource = readFileSync(join(appDir, "src", "familiar-window.ts"), "utf8");
const petWindowHostSource = readFileSync(join(appDir, "src", "familiar-window-host.ts"), "utf8");
const familiarInstallationSource = readFileSync(join(appDir, "src", "familiar-installation.ts"), "utf8");
const familiarInstallationArchiveSource = readFileSync(join(appDir, "src", "familiar-installation-archive.ts"), "utf8");
const petWindowContentSource = readFileSync(join(appDir, "src", "familiar-window-content.ts"), "utf8");
const petWindowInteractionSource = readFileSync(join(appDir, "src", "familiar-window-interactions.ts"), "utf8");
const petWindowPluginMenuSource = readFileSync(join(appDir, "src", "familiar-window-plugin-menu.ts"), "utf8");
@ -298,6 +300,13 @@ assert.match(petWindowSource, /from "\.\/familiar-window-renderer-bridge(?:\.js)
assert.match(petWindowHostSource, /export function createBasePetWindow/, "familiar-window-host must export the shared BrowserWindow shell.");
assert.match(petWindowHostSource, /export function allocateWindowLoadSequence/, "familiar-window-host must export the familiar reload sequence helper.");
assert.match(petWindowHostSource, /export async function loadPetHtmlFile/, "familiar-window-host must export the familiar HTML loader seam.");
assert.match(familiarInstallationSource, /from "\.\/familiar-installation-archive(?:\.js)?"/, "familiar-installation must import the extracted archive seam.");
assert.match(familiarInstallationArchiveSource, /export async function downloadPetZip/, "familiar-installation archive seam must export remote ZIP download.");
assert.match(familiarInstallationArchiveSource, /export async function extractPetZip/, "familiar-installation archive seam must export yauzl extraction.");
assert.match(familiarInstallationArchiveSource, /redirect:\s*"error"/, "familiar-installation archive downloads must reject HTTP redirects.");
assert.match(familiarInstallationArchiveSource, /validateZipUrl\(response\.url\)/, "familiar-installation archive downloads must re-validate the final URL.");
assert.match(familiarInstallationArchiveSource, /strictFileNames:\s*true/, "familiar-installation archive extraction must keep yauzl strict file-name validation.");
assert.match(familiarInstallationArchiveSource, /safePath\.relativeOutputPath === "spritesheet\.webp"[\s\S]*?maxCodexSpritesheetBytes/, "familiar-installation archive extraction must cap spritesheets at the codex size limit.");
assert.match(petWindowContentSource, /export async function createDefaultPetRender/, "familiar-window content helper must export default familiar rendering.");
assert.match(petWindowContentSource, /export async function createInstalledPetRender/, "familiar-window content helper must export installed familiar rendering.");
assert.match(petWindowLayoutSource, /export function getBasePetWindowSize/, "familiar-window-layout must export base window sizing.");

View file

@ -89,8 +89,9 @@ windows.ts (IPC handlers)
familiar-installation.ts
├── installPet()
│ ├── getCatalogPet() → catalog.ts
│ ├── downloadPetZip() → validate ZIP magic
│ ├── extractPetZip() → yauzl with entry validation
│ ├── familiar-installation-archive.ts
│ │ ├── downloadPetZip() → validate ZIP magic
│ │ └── extractPetZip() → yauzl with entry validation
│ └── installPetState() → app-state.ts
│ ├── app-state-familiar-records.ts normalizes installed familiar records and file validation
│ └── app-state-storage.ts handles state-file migration, atomic writes, and install locks
@ -248,6 +249,7 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
**Installation**:
- `familiar-installation.ts`: ZIP download, yauzl extraction with safety limits, familiar validation
- `familiar-installation-archive.ts`: Extracted ZIP download, yauzl entry validation, and bounded archive extraction
- `familiar-paths.ts`: Safe path resolution for familiar directories
- `codex-familiars.ts`: Import from `~/.codex/familiars/` with validation
- `codex-familiars-core.ts`: Codex metadata validation constants

View file

@ -0,0 +1,232 @@
import { createWriteStream } from "node:fs";
import { resolve } from "node:path";
import { pipeline } from "node:stream/promises";
import { Transform } from "node:stream";
import yauzl from "yauzl";
import type { Entry, ZipFile } from "yauzl";
import { maxCodexPetJsonBytes, maxCodexSpritesheetBytes } from "./codex-familiars-core.js";
import { assertOutputPathInside, hasSupportedZipMagic, ZipEntryPathTracker } from "./zip-safety.js";
export const maxZipDownloadBytes = 50 * 1024 * 1024;
const maxExtractedTotalBytes = 200 * 1024 * 1024;
const maxFiles = 500;
const maxIndividualFileBytes = 100 * 1024 * 1024;
const downloadTimeoutMs = 30_000;
export async function downloadPetZip(zipUrl: string): Promise<Buffer> {
validateZipUrl(zipUrl);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs);
try {
const response = await fetch(zipUrl, {
signal: controller.signal,
redirect: "error",
credentials: "omit",
});
validateZipUrl(response.url);
if (response.url !== zipUrl) throw new Error("Zip download final URL changed.");
if (!response.ok) throw new Error(`Zip download failed with HTTP ${response.status}.`);
const buffer = await readLimitedResponse(response, maxZipDownloadBytes);
validateZipMagic(buffer);
return buffer;
} finally {
clearTimeout(timeout);
}
}
export function validateZipUrl(value: string): void {
const url = new URL(value);
if (url.protocol !== "https:") throw new Error("Zip URL must use https.");
if (url.hostname !== "zip.familiaros.dev") throw new Error("Zip URL host is not allowed.");
if (!url.pathname.startsWith("/familiars/")) throw new Error("Zip URL path is not allowed.");
if (url.username || url.password) throw new Error("Zip URL cannot include credentials.");
if (url.port) throw new Error("Zip URL cannot include a custom port.");
}
async function readLimitedResponse(response: Response, maxBytes: number): Promise<Buffer> {
const reader = response.body?.getReader();
if (!reader) throw new Error("Zip response body is unavailable for bounded reading.");
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) throw new Error("Zip download is too large.");
chunks.push(value);
}
return Buffer.concat(chunks, total);
}
export function validateZipMagic(buffer: Buffer): void {
if (!hasSupportedZipMagic(buffer)) {
throw new Error("Downloaded file has an unsupported zip signature.");
}
}
export async function extractPetZip(zip: Buffer, tempDir: string): Promise<void> {
const zipFile = await openZipFromBuffer(zip);
const pathTracker = new ZipEntryPathTracker();
const seenRequired = new Set<string>();
let fileCount = 0;
let extractedTotal = 0;
try {
await new Promise<void>((resolvePromise, rejectPromise) => {
let settled = false;
const reject = (error: unknown): void => {
if (settled) return;
settled = true;
zipFile.close();
rejectPromise(error instanceof Error ? error : new Error("Zip extraction failed."));
};
zipFile.on("error", reject);
zipFile.on("end", () => {
if (settled) return;
settled = true;
resolvePromise();
});
zipFile.on("entry", (entry) => {
void processEntry(entry).then(() => {
if (!settled) zipFile.readEntry();
}).catch(reject);
});
const processEntry = async (entry: Entry): Promise<void> => {
validateEntryMetadata(entry);
const safePath = pathTracker.accept(entry.fileName);
if (safePath.isDirectory) {
return;
}
if (!safePath.relativeOutputPath) {
throw new Error("Zip file entry is missing an output path.");
}
fileCount += 1;
if (fileCount > maxFiles) throw new Error("Zip contains too many files.");
if (safePath.relativeOutputPath === "familiar.json" && entry.uncompressedSize > maxCodexPetJsonBytes) throw new Error("familiar.json is too large.");
if (safePath.relativeOutputPath === "spritesheet.webp" && entry.uncompressedSize > maxCodexSpritesheetBytes) throw new Error("spritesheet.webp is too large.");
if (entry.uncompressedSize > maxIndividualFileBytes) throw new Error("Zip entry is too large.");
extractedTotal += entry.uncompressedSize;
if (extractedTotal > maxExtractedTotalBytes) throw new Error("Zip extracted total is too large.");
const outputPath = resolve(tempDir, safePath.relativeOutputPath);
assertOutputPathInside(tempDir, outputPath);
seenRequired.add(safePath.relativeOutputPath);
const maxEntryBytes = safePath.relativeOutputPath === "familiar.json"
? maxCodexPetJsonBytes
: safePath.relativeOutputPath === "spritesheet.webp"
? maxCodexSpritesheetBytes
: maxIndividualFileBytes;
await writeEntry(entry, zipFile, outputPath, entry.uncompressedSize, maxEntryBytes);
};
zipFile.readEntry();
});
} finally {
zipFile.close();
}
if (!seenRequired.has("familiar.json") || !seenRequired.has("spritesheet.webp")) {
throw new Error("Zip must contain familiar.json and spritesheet.webp.");
}
}
function openZipFromBuffer(buffer: Buffer): Promise<ZipFile> {
return new Promise((resolvePromise, rejectPromise) => {
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true, strictFileNames: true }, (error, zipFile) => {
if (error) {
rejectPromise(error);
return;
}
if (!zipFile) {
rejectPromise(new Error("Zip file could not be opened."));
return;
}
resolvePromise(zipFile);
});
});
}
function validateEntryMetadata(entry: Entry): void {
if (entry.isEncrypted()) throw new Error("Encrypted zip entries are not supported.");
if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
throw new Error("Unsupported zip entry compression method.");
}
if (entry.compressedSize > maxZipDownloadBytes) throw new Error("Zip entry compressed size is too large.");
if (entry.uncompressedSize > maxIndividualFileBytes) throw new Error("Zip entry uncompressed size is too large.");
const unixMode = getUnixMode(entry);
if (unixMode === null) return;
const type = unixMode & 0o170000;
const isKnownFileType = type !== 0;
const isRegularFile = type === 0o100000;
const isDirectory = type === 0o040000;
if (isKnownFileType && !isRegularFile && !isDirectory) {
throw new Error("Zip entry special files are not supported.");
}
}
function getUnixMode(entry: Entry): number | null {
if ((entry.versionMadeBy >> 8) !== 3) {
return null;
}
return (entry.externalFileAttributes >> 16) & 0o177777;
}
function writeEntry(entry: Entry, zipFile: ZipFile, outputPath: string, expectedBytes: number, maxBytes: number): Promise<void> {
return new Promise((resolvePromise, rejectPromise) => {
zipFile.openReadStream(entry, (error, readStream) => {
if (error) {
rejectPromise(error);
return;
}
if (!readStream) {
rejectPromise(new Error("Zip entry stream could not be opened."));
return;
}
let actualBytes = 0;
const counter = new Transform({
transform(chunk: Buffer, _encoding, callback) {
actualBytes += chunk.byteLength;
if (actualBytes > maxBytes) {
callback(new Error("Zip entry exceeded individual size limit."));
return;
}
callback(null, chunk);
},
});
pipeline(readStream, counter, createWriteStream(outputPath, { mode: 0o600 }))
.then(() => {
if (actualBytes !== expectedBytes) {
rejectPromise(new Error("Zip entry extracted size did not match metadata."));
return;
}
resolvePromise();
})
.catch(rejectPromise);
});
});
}

View file

@ -1,24 +1,14 @@
import { constants, createWriteStream } from "node:fs";
import { constants } from "node:fs";
import { lstat, mkdtemp, mkdir, open, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { basename, join, resolve, sep } from "node:path";
import { pipeline } from "node:stream/promises";
import { Transform } from "node:stream";
import yauzl from "yauzl";
import type { Entry, ZipFile } from "yauzl";
import { getAppStateSnapshot, installPetState, removePetState, setDefaultPet, type FamiliarOSStateV1 } from "./app-state.js";
import { getCatalogPet } from "./catalog.js";
import { maxCodexPetJsonBytes, maxCodexSpritesheetBytes, validateCodexPetMetadata, type CodexPetMetadata } from "./codex-familiars-core.js";
import { builtInPet } from "./built-in-familiar.js";
import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./familiar-paths.js";
import { assertOutputPathInside, hasSupportedZipMagic, ZipEntryPathTracker } from "./zip-safety.js";
const maxZipDownloadBytes = 50 * 1024 * 1024;
const maxExtractedTotalBytes = 200 * 1024 * 1024;
const maxFiles = 500;
const maxIndividualFileBytes = 100 * 1024 * 1024;
const downloadTimeoutMs = 30_000;
import { downloadPetZip, extractPetZip, maxZipDownloadBytes, validateZipMagic } from "./familiar-installation-archive.js";
import { assertOutputPathInside } from "./zip-safety.js";
const operations = new Set<string>();
@ -158,215 +148,6 @@ export async function withPetOperation<T>(key: string, callback: () => Promise<T
}
}
async function downloadPetZip(zipUrl: string): Promise<Buffer> {
validateZipUrl(zipUrl);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs);
try {
const response = await fetch(zipUrl, {
signal: controller.signal,
redirect: "error",
credentials: "omit",
});
validateZipUrl(response.url);
if (response.url !== zipUrl) throw new Error("Zip download final URL changed.");
if (!response.ok) throw new Error(`Zip download failed with HTTP ${response.status}.`);
const buffer = await readLimitedResponse(response, maxZipDownloadBytes);
validateZipMagic(buffer);
return buffer;
} finally {
clearTimeout(timeout);
}
}
function validateZipUrl(value: string): void {
const url = new URL(value);
if (url.protocol !== "https:") throw new Error("Zip URL must use https.");
if (url.hostname !== "zip.familiaros.dev") throw new Error("Zip URL host is not allowed.");
if (!url.pathname.startsWith("/familiars/")) throw new Error("Zip URL path is not allowed.");
if (url.username || url.password) throw new Error("Zip URL cannot include credentials.");
if (url.port) throw new Error("Zip URL cannot include a custom port.");
}
async function readLimitedResponse(response: Response, maxBytes: number): Promise<Buffer> {
const reader = response.body?.getReader();
if (!reader) throw new Error("Zip response body is unavailable for bounded reading.");
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) throw new Error("Zip download is too large.");
chunks.push(value);
}
return Buffer.concat(chunks, total);
}
function validateZipMagic(buffer: Buffer): void {
if (!hasSupportedZipMagic(buffer)) {
throw new Error("Downloaded file has an unsupported zip signature.");
}
}
async function extractPetZip(zip: Buffer, tempDir: string): Promise<void> {
const zipFile = await openZipFromBuffer(zip);
const pathTracker = new ZipEntryPathTracker();
const seenRequired = new Set<string>();
let fileCount = 0;
let extractedTotal = 0;
try {
await new Promise<void>((resolvePromise, rejectPromise) => {
let settled = false;
const reject = (error: unknown): void => {
if (settled) return;
settled = true;
zipFile.close();
rejectPromise(error instanceof Error ? error : new Error("Zip extraction failed."));
};
zipFile.on("error", reject);
zipFile.on("end", () => {
if (settled) return;
settled = true;
resolvePromise();
});
zipFile.on("entry", (entry) => {
void processEntry(entry).then(() => {
if (!settled) zipFile.readEntry();
}).catch(reject);
});
const processEntry = async (entry: Entry): Promise<void> => {
validateEntryMetadata(entry);
const safePath = pathTracker.accept(entry.fileName);
if (safePath.isDirectory) {
return;
}
if (!safePath.relativeOutputPath) {
throw new Error("Zip file entry is missing an output path.");
}
fileCount += 1;
if (fileCount > maxFiles) throw new Error("Zip contains too many files.");
if (safePath.relativeOutputPath === "familiar.json" && entry.uncompressedSize > maxCodexPetJsonBytes) throw new Error("familiar.json is too large.");
if (entry.uncompressedSize > maxIndividualFileBytes) throw new Error("Zip entry is too large.");
extractedTotal += entry.uncompressedSize;
if (extractedTotal > maxExtractedTotalBytes) throw new Error("Zip extracted total is too large.");
const outputPath = resolve(tempDir, safePath.relativeOutputPath);
assertOutputPathInside(tempDir, outputPath);
seenRequired.add(safePath.relativeOutputPath);
await writeEntry(entry, zipFile, outputPath, entry.uncompressedSize, safePath.relativeOutputPath === "familiar.json" ? maxCodexPetJsonBytes : maxIndividualFileBytes);
};
zipFile.readEntry();
});
} finally {
zipFile.close();
}
if (!seenRequired.has("familiar.json") || !seenRequired.has("spritesheet.webp")) {
throw new Error("Zip must contain familiar.json and spritesheet.webp.");
}
}
function openZipFromBuffer(buffer: Buffer): Promise<ZipFile> {
return new Promise((resolvePromise, rejectPromise) => {
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true, strictFileNames: true }, (error, zipFile) => {
if (error) {
rejectPromise(error);
return;
}
if (!zipFile) {
rejectPromise(new Error("Zip file could not be opened."));
return;
}
resolvePromise(zipFile);
});
});
}
function validateEntryMetadata(entry: Entry): void {
if (entry.isEncrypted()) throw new Error("Encrypted zip entries are not supported.");
if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
throw new Error("Unsupported zip entry compression method.");
}
if (entry.compressedSize > maxZipDownloadBytes) throw new Error("Zip entry compressed size is too large.");
if (entry.uncompressedSize > maxIndividualFileBytes) throw new Error("Zip entry uncompressed size is too large.");
const unixMode = getUnixMode(entry);
if (unixMode === null) return;
const type = unixMode & 0o170000;
const isKnownFileType = type !== 0;
const isRegularFile = type === 0o100000;
const isDirectory = type === 0o040000;
if (isKnownFileType && !isRegularFile && !isDirectory) {
throw new Error("Zip entry special files are not supported.");
}
}
function getUnixMode(entry: Entry): number | null {
if ((entry.versionMadeBy >> 8) !== 3) {
return null;
}
return (entry.externalFileAttributes >> 16) & 0o177777;
}
function writeEntry(entry: Entry, zipFile: ZipFile, outputPath: string, expectedBytes: number, maxBytes: number): Promise<void> {
return new Promise((resolvePromise, rejectPromise) => {
zipFile.openReadStream(entry, (error, readStream) => {
if (error) {
rejectPromise(error);
return;
}
if (!readStream) {
rejectPromise(new Error("Zip entry stream could not be opened."));
return;
}
let actualBytes = 0;
const counter = new Transform({
transform(chunk: Buffer, _encoding, callback) {
actualBytes += chunk.byteLength;
if (actualBytes > maxBytes) {
callback(new Error("Zip entry exceeded individual size limit."));
return;
}
callback(null, chunk);
},
});
pipeline(readStream, counter, createWriteStream(outputPath, { mode: 0o600 }))
.then(() => {
if (actualBytes !== expectedBytes) {
rejectPromise(new Error("Zip entry extracted size did not match metadata."));
return;
}
resolvePromise();
})
.catch(rejectPromise);
});
});
}
async function validateExtractedPet(tempDir: string): Promise<CodexPetMetadata> {
const petJsonPath = join(tempDir, "familiar.json");
const spritesheetPath = join(tempDir, "spritesheet.webp");
@ -381,7 +162,7 @@ async function validateExtractedPet(tempDir: string): Promise<CodexPetMetadata>
const spritesheet = await stat(spritesheetPath);
if (!spritesheet.isFile()) throw new Error("spritesheet.webp must be a file.");
if (spritesheet.size <= 0) throw new Error("spritesheet.webp is empty.");
if (spritesheet.size > maxIndividualFileBytes) throw new Error("spritesheet.webp is too large.");
if (spritesheet.size > maxCodexSpritesheetBytes) throw new Error("spritesheet.webp is too large.");
return metadata;
}

View file

@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { validateZipUrl } from "../src/familiar-installation-archive.js";
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
const familiarInstallationSource = readFileSync(resolve(desktopRoot, "src/familiar-installation.ts"), "utf8");
const familiarInstallationArchiveSource = readFileSync(resolve(desktopRoot, "src/familiar-installation-archive.ts"), "utf8");
assert.match(familiarInstallationSource, /from "\.\/familiar-installation-archive(?:\.js)?"/, "familiar-installation must import the extracted archive seam.");
assert.match(familiarInstallationArchiveSource, /export async function downloadPetZip/, "archive seam must export ZIP download.");
assert.match(familiarInstallationArchiveSource, /export async function extractPetZip/, "archive seam must export ZIP extraction.");
assert.match(familiarInstallationArchiveSource, /export function validateZipUrl/, "archive seam must export ZIP URL validation.");
assert.doesNotThrow(() => validateZipUrl("https://zip.familiaros.dev/familiars/sample.zip"), "official familiar ZIP URLs must remain allowed.");
assert.throws(() => validateZipUrl("http://zip.familiaros.dev/familiars/sample.zip"), /https/i, "ZIP downloads must reject non-HTTPS URLs.");
assert.throws(() => validateZipUrl("https://example.com/familiars/sample.zip"), /host/i, "ZIP downloads must reject foreign hosts.");
assert.throws(() => validateZipUrl("https://zip.familiaros.dev:444/familiars/sample.zip"), /port/i, "ZIP downloads must reject custom ports.");
assert.throws(() => validateZipUrl("https://zip.familiaros.dev/other/sample.zip"), /path/i, "ZIP downloads must reject unexpected paths.");
assert.throws(() => validateZipUrl("https://user:pass@zip.familiaros.dev/familiars/sample.zip"), /credentials/i, "ZIP downloads must reject embedded credentials.");
console.error("Familiar installation archive seam validation passed.");