import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { validatePluginCatalog } from "../src/plugin-catalog-validation.js"; import { installCatalogPluginPackage, safeDeletePluginInstallDir, validatePluginZipUrl } from "../src/plugin-package.js"; import type { FamiliarOSPluginManifest } from "../src/plugin-manifest.js"; const manifest: FamiliarOSPluginManifest = { manifestVersion: 1, id: "zip-plug", name: "Zip Plug", version: "1.0.0", runtime: "declarative", permissions: ["timer", "familiar:speak"], triggers: [{ on: "timer", everyMinutes: 5, actions: [{ type: "familiar.speak", message: "hi" }] }] }; const text = JSON.stringify(manifest); const zip = makeZip("familiaros.plugin.json", Buffer.from(text)); const entry = { id: manifest.id, name: manifest.name, version: manifest.version, description: "desc", runtime: "declarative" as const, permissions: manifest.permissions, downloadUrl: "https://zip.familiaros.dev/plugins/zip-plug.zip", sha256: createHash("sha256").update(zip).digest("hex") }; validatePluginZipUrl(entry.downloadUrl); assert.throws(() => validatePluginZipUrl("http://zip.familiaros.dev/plugins/x.zip"), /not allowed/); assert.throws(() => validatePluginZipUrl("https://zip.familiaros.dev:444/plugins/x.zip"), /not allowed/); const userData = mkdtempSync(join(tmpdir(), "familiaros-plugin-package-")); const installed = await installCatalogPluginPackage({ userDataPath: userData, catalogEntry: entry, zip }); assert.equal(installed.manifest.id, manifest.id); assert.equal(readFileSync(join(userData, "plugins", manifest.id, "familiaros.plugin.json"), "utf8"), text); assert.rejects(() => installCatalogPluginPackage({ userDataPath: userData, catalogEntry: { ...entry, name: "Other" }, zip }), /does not match/); assert.rejects(() => installCatalogPluginPackage({ userDataPath: userData, catalogEntry: entry, zip: makeZip("nested/familiaros.plugin.json", Buffer.from(text)) }), /invalid|exactly one root manifest|must contain familiaros/); const jsManifest: FamiliarOSPluginManifest = { manifestVersion: 2, id: "js-plug", name: "JS Plug", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.mjs", permissions: ["familiar:speak"] }; const jsText = JSON.stringify(jsManifest); const jsEntryText = "export default {};\n"; const jsZip = makeZipFiles([{ name: "familiaros.plugin.json", data: Buffer.from(jsText) }, { name: "index.mjs", data: Buffer.from(jsEntryText) }]); const jsCatalogEntry = { id: jsManifest.id, name: jsManifest.name, version: jsManifest.version, description: "desc", runtime: "javascript" as const, sdkVersion: "1.0.0", permissions: jsManifest.permissions, downloadUrl: "https://zip.familiaros.dev/plugins/js-plug.zip", sha256: createHash("sha256").update(jsZip).digest("hex") }; const jsInstalled = await installCatalogPluginPackage({ userDataPath: userData, catalogEntry: jsCatalogEntry, zip: jsZip }); assert.equal(readFileSync(join(jsInstalled.installPath, "index.mjs"), "utf8"), jsEntryText); await assert.rejects(() => installCatalogPluginPackage({ userDataPath: userData, catalogEntry: jsCatalogEntry, zip: makeZipFiles([{ name: "familiaros.plugin.json", data: Buffer.from(jsText) }]) }), /manifest and entry/); await assert.rejects(() => installCatalogPluginPackage({ userDataPath: userData, catalogEntry: jsCatalogEntry, zip: makeZipFiles([{ name: "familiaros.plugin.json", data: Buffer.from(jsText) }, { name: "index.mjs", data: Buffer.from(jsEntryText) }, { name: "extra.js", data: Buffer.from("") }]) }), /manifest and entry|too many entries/); const canonicalEntry = validatePluginCatalog({ version: 1, generatedAt: new Date().toISOString(), plugins: [{ ...entry, permissions: ["timer", "familiar:speak"] }] }).plugins[0]; await installCatalogPluginPackage({ userDataPath: userData, catalogEntry: canonicalEntry, zip }); const oldText = JSON.stringify({ ...manifest, version: "0.9.0" }); writeFileSync(join(userData, "plugins", manifest.id, "familiaros.plugin.json"), oldText, "utf8"); await installCatalogPluginPackage({ userDataPath: userData, catalogEntry: entry, zip }); assert.equal(readFileSync(join(userData, "plugins", manifest.id, "familiaros.plugin.json"), "utf8"), text); const deleteRoot = mkdtempSync(join(tmpdir(), "familiaros-plugin-delete-")); const outside = mkdtempSync(join(tmpdir(), "familiaros-plugin-outside-")); symlinkSync(outside, join(deleteRoot, "plugins"), "dir"); await assert.rejects(() => safeDeletePluginInstallDir(deleteRoot, manifest.id, join(deleteRoot, "plugins", manifest.id), "catalog"), /invalid|unexpected/); console.error("Plugin package validation passed."); function makeZip(name: string, data: Buffer): Buffer { return makeZipFiles([{ name, data }]); } function makeZipFiles(files: Array<{ name: string; data: Buffer }>): Buffer { const locals: Buffer[] = []; const centrals: Buffer[] = []; let offset = 0; for (const file of files) { const nameBuffer = Buffer.from(file.name); const crc = crc32(file.data); const now = 0; const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0, 6); local.writeUInt16LE(0, 8); local.writeUInt32LE(now, 10); local.writeUInt32LE(crc, 14); local.writeUInt32LE(file.data.length, 18); local.writeUInt32LE(file.data.length, 22); local.writeUInt16LE(nameBuffer.length, 26); const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0, 8); central.writeUInt16LE(0, 10); central.writeUInt32LE(now, 12); central.writeUInt32LE(crc, 16); central.writeUInt32LE(file.data.length, 20); central.writeUInt32LE(file.data.length, 24); central.writeUInt16LE(nameBuffer.length, 28); central.writeUInt32LE((0o100644 << 16) >>> 0, 38); central.writeUInt32LE(offset, 42); const localPart = Buffer.concat([local, nameBuffer, file.data]); locals.push(localPart); centrals.push(Buffer.concat([central, nameBuffer])); offset += localPart.length; } const localPart = Buffer.concat(locals); const centralPart = Buffer.concat(centrals); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10); end.writeUInt32LE(centralPart.length, 12); end.writeUInt32LE(localPart.length, 16); return Buffer.concat([localPart, centralPart, end]); } function crc32(buffer: Buffer): number { let crc = -1; for (const byte of buffer) { crc ^= byte; for (let i = 0; i < 8; i++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); } return (crc ^ -1) >>> 0; }