Extract familiar installation archive seam
This commit is contained in:
parent
2b1a4e80e2
commit
cb591aca0f
5 changed files with 273 additions and 225 deletions
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
232
apps/desktop/src/familiar-installation-archive.ts
Normal file
232
apps/desktop/src/familiar-installation-archive.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
24
apps/desktop/tests/familiar-installation-archive.test.ts
Normal file
24
apps/desktop/tests/familiar-installation-archive.test.ts
Normal 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.");
|
||||
Loading…
Add table
Reference in a new issue