From cb591aca0f7b8eb9ea380fd18def72302bf8d55f Mon Sep 17 00:00:00 2001 From: OpenPets Dev Date: Fri, 19 Jun 2026 04:59:23 +0000 Subject: [PATCH] Extract familiar installation archive seam --- apps/desktop/src/check-packaging-contract.ts | 9 + apps/desktop/src/codemap.md | 6 +- .../src/familiar-installation-archive.ts | 232 ++++++++++++++++++ apps/desktop/src/familiar-installation.ts | 227 +---------------- .../familiar-installation-archive.test.ts | 24 ++ 5 files changed, 273 insertions(+), 225 deletions(-) create mode 100644 apps/desktop/src/familiar-installation-archive.ts create mode 100644 apps/desktop/tests/familiar-installation-archive.test.ts diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 2313f5f1..3e4db5c0 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -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."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index fbc9d2b1..e68667dd 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -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 diff --git a/apps/desktop/src/familiar-installation-archive.ts b/apps/desktop/src/familiar-installation-archive.ts new file mode 100644 index 00000000..3aa41478 --- /dev/null +++ b/apps/desktop/src/familiar-installation-archive.ts @@ -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 { + 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 { + 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 { + const zipFile = await openZipFromBuffer(zip); + const pathTracker = new ZipEntryPathTracker(); + const seenRequired = new Set(); + let fileCount = 0; + let extractedTotal = 0; + + try { + await new Promise((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 => { + 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 { + 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 { + 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); + }); + }); +} diff --git a/apps/desktop/src/familiar-installation.ts b/apps/desktop/src/familiar-installation.ts index cc372e1a..87a1a9f9 100644 --- a/apps/desktop/src/familiar-installation.ts +++ b/apps/desktop/src/familiar-installation.ts @@ -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(); @@ -158,215 +148,6 @@ export async function withPetOperation(key: string, callback: () => Promise { - 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 { - 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 { - const zipFile = await openZipFromBuffer(zip); - const pathTracker = new ZipEntryPathTracker(); - const seenRequired = new Set(); - let fileCount = 0; - let extractedTotal = 0; - - try { - await new Promise((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 => { - 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 { - 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 { - 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 { const petJsonPath = join(tempDir, "familiar.json"); const spritesheetPath = join(tempDir, "spritesheet.webp"); @@ -381,7 +162,7 @@ async function validateExtractedPet(tempDir: string): Promise 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; } diff --git a/apps/desktop/tests/familiar-installation-archive.test.ts b/apps/desktop/tests/familiar-installation-archive.test.ts new file mode 100644 index 00000000..1a8fc987 --- /dev/null +++ b/apps/desktop/tests/familiar-installation-archive.test.ts @@ -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.");