"use strict"; const { createHash, randomUUID } = require("node:crypto"); const fs = require("node:fs"); const { constants } = fs; const { mkdir, lstat, open, readFile, readdir, rename, rm, stat, } = require("node:fs/promises"); const path = require("node:path"); const { assertPluginStorageSegment, isPathInside, resolveInstalledVersionDirectory, } = require("./paths.cjs"); const INSTALL_METADATA_FILE = "install.json"; const ARCHIVE_SNAPSHOT_FILE = "package.ncpkg"; const PACKAGE_DIRECTORY = "package"; const REMOVAL_METADATA_FILE = "remove.json"; const REMOVED_PLUGIN_DIRECTORY = "plugin"; async function loadPluginCli() { return import("@netcatty/plugin-cli"); } async function syncDirectory(directory) { let handle; try { handle = await open(directory, constants.O_RDONLY); await handle.sync(); } catch (error) { if (process.platform !== "win32") throw error; } finally { await handle?.close(); } } async function writeDurableMetadata(filePath, value) { const handle = await open(filePath, "wx", 0o600); try { await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8"); await handle.sync(); } finally { await handle.close(); } } async function syncDirectoryTree(directory) { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) await syncDirectoryTree(path.join(directory, entry.name)); } await syncDirectory(directory); } async function copyImmutableArchive(sourcePath, destinationPath, maxBytes) { const sourcePathStats = await lstat(sourcePath); if (!sourcePathStats.isFile() || sourcePathStats.isSymbolicLink()) { throw new Error("Plugin package source must be a regular non-symbolic file"); } const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; const source = await open(sourcePath, constants.O_RDONLY | noFollow); let destination; try { const before = await source.stat(); if (!before.isFile()) throw new Error("Plugin package source must be a regular file"); if (before.size > maxBytes) throw new Error(`Plugin archive exceeds ${maxBytes} bytes`); destination = await open(destinationPath, "wx", 0o600); const sha256 = createHash("sha256"); const buffer = Buffer.allocUnsafe(64 * 1024); let position = 0; while (position < before.size) { const { bytesRead } = await source.read(buffer, 0, buffer.length, position); if (bytesRead === 0) break; position += bytesRead; const chunk = buffer.subarray(0, bytesRead); sha256.update(chunk); let written = 0; while (written < chunk.length) { const result = await destination.write(chunk, written, chunk.length - written); if (result.bytesWritten === 0) throw new Error("Unable to stage the complete plugin package"); written += result.bytesWritten; } } const after = await source.stat(); if ( position !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || (before.ino && after.ino && before.ino !== after.ino) ) { throw new Error("Plugin package source changed while it was staged"); } await destination.sync(); return { bytes: position, sha256: sha256.digest("hex") }; } finally { await destination?.close(); await source.close(); } } function validateInstallMetadata(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Invalid plugin install metadata"); } const pluginId = assertPluginStorageSegment(value.pluginId, "ID"); const version = assertPluginStorageSegment(value.version, "version"); if (!/^[a-f0-9]{64}$/u.test(value.archiveSha256)) { throw new Error("Invalid plugin install archive hash"); } if (!/^[a-f0-9]{64}$/u.test(value.contentSha256)) { throw new Error("Invalid plugin install content hash"); } return { pluginId, version, archiveSha256: value.archiveSha256, contentSha256: value.contentSha256, }; } function validateRemovalMetadata(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Invalid plugin removal metadata"); } return { pluginId: assertPluginStorageSegment(value.pluginId, "ID") }; } class PackageStore { constructor(options) { this.paths = options.paths; this.database = options.database; this.netcattyVersion = options.netcattyVersion; this.apiVersion = options.apiVersion; this.supportedFeatures = [...(options.supportedFeatures ?? [])]; this.logger = options.logger ?? console; this.syncDirectory = options.syncDirectory ?? syncDirectory; this.verifiedArchives = new Map(); } async initialize() { await Promise.all(Object.values(this.paths) .filter((value) => typeof value === "string" && value !== this.paths.database) .map((directory) => mkdir(directory, { recursive: true, mode: 0o700 }))); await this.recover(); } async install(archivePath, options = {}) { if (typeof archivePath !== "string" || !path.isAbsolute(archivePath)) { throw new TypeError("Plugin package path must be absolute"); } if (path.extname(archivePath).toLowerCase() !== ".ncpkg") { throw new Error("Plugin packages must use the .ncpkg extension"); } const pluginCli = await loadPluginCli(); const stagingName = `install-${randomUUID()}`; const stagingDirectory = path.join(this.paths.staging, stagingName); const archiveSnapshot = path.join(stagingDirectory, ARCHIVE_SNAPSHOT_FILE); const extractedDirectory = path.join(stagingDirectory, PACKAGE_DIRECTORY); await mkdir(stagingDirectory, { recursive: false, mode: 0o700 }); try { const snapshot = await copyImmutableArchive( archivePath, archiveSnapshot, pluginCli.PACKAGE_LIMITS.archiveBytes, ); const validation = await pluginCli.extractPluginPackage(archiveSnapshot, extractedDirectory); await syncDirectoryTree(extractedDirectory); const manifest = validation.manifest; const compatibility = pluginCli.checkPluginCompatibility(manifest, { netcattyVersion: this.netcattyVersion, apiVersion: this.apiVersion, features: this.supportedFeatures, }); if (!compatibility.compatible) { throw new Error(`Plugin is incompatible: ${compatibility.errors.join("; ")}`); } const pluginId = assertPluginStorageSegment(manifest.id, "ID"); const version = assertPluginStorageSegment(manifest.version, "version"); const targetDirectory = resolveInstalledVersionDirectory(this.paths, pluginId, version); const previousPlugin = this.database.getActivePlugin(pluginId); const enableAfterActivation = options.enable === true || previousPlugin?.enabled === true; let activationPrepared = false; const prepareActivation = async (reason) => { if (activationPrepared) return; if (typeof options.beforeActivate === "function") { await options.beforeActivate(Object.freeze({ pluginId, version, previousPlugin, reason, })); } activationPrepared = true; }; const existing = this.database.getVersion(pluginId, version); if (existing) { if (existing.archiveSha256 !== snapshot.sha256) { throw new Error(`Plugin ${pluginId}@${version} is already installed with different contents`); } let existingValidation; try { existingValidation = await this.verifyInstalledVersion(existing, { refreshArchive: true }); } catch { if (previousPlugin?.activeVersion === version) await prepareActivation("replace-active-files"); await rm(targetDirectory, { recursive: true, force: true }); } if (existingValidation) { if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version"); const existingPackage = path.join(targetDirectory, PACKAGE_DIRECTORY); this.database.installVersion({ pluginId, version, manifest: existingValidation.manifest, archiveSha256: existing.archiveSha256, packageRelativePath: path.relative(this.paths.packages, existingPackage), }, { enable: enableAfterActivation }); return this.database.getActivePlugin(pluginId); } } try { await stat(targetDirectory); throw new Error(`Plugin ${pluginId}@${version} has an uncommitted installed directory`); } catch (error) { if (!(error && error.code === "ENOENT")) throw error; } const metadata = { pluginId, version, archiveSha256: snapshot.sha256, contentSha256: validation.contentSha256, }; await writeDurableMetadata(path.join(stagingDirectory, INSTALL_METADATA_FILE), metadata); await this.syncDirectory(stagingDirectory); await mkdir(path.dirname(targetDirectory), { recursive: true, mode: 0o700 }); if (previousPlugin?.activeVersion !== version) await prepareActivation("switch-active-version"); await rename(stagingDirectory, targetDirectory); await this.syncDirectory(path.dirname(targetDirectory)); const packageRelativePath = path.relative( this.paths.packages, path.join(targetDirectory, PACKAGE_DIRECTORY), ); try { this.database.installVersion({ pluginId, version, manifest, archiveSha256: snapshot.sha256, packageRelativePath, }, { enable: enableAfterActivation }); } catch (error) { this.verifiedArchives.delete(this.#versionKey(pluginId, version)); await rm(targetDirectory, { recursive: true, force: true }); await this.syncDirectory(path.dirname(targetDirectory)); throw error; } this.verifiedArchives.set(this.#versionKey(pluginId, version), Object.freeze({ archiveSha256: snapshot.sha256, contentSha256: validation.contentSha256, manifest, })); return this.database.getActivePlugin(pluginId); } finally { await rm(stagingDirectory, { recursive: true, force: true }); } } async recover() { this.verifiedArchives.clear(); await mkdir(this.paths.staging, { recursive: true, mode: 0o700 }); const stagedEntries = await readdir(this.paths.staging, { withFileTypes: true }); for (const entry of stagedEntries) { const stagedPath = path.join(this.paths.staging, entry.name); if (!entry.isDirectory() || !entry.name.startsWith("remove-")) { await rm(stagedPath, { recursive: true, force: true }); continue; } const removedPluginPath = path.join(stagedPath, REMOVED_PLUGIN_DIRECTORY); let hasMovedPlugin = false; try { await lstat(removedPluginPath); hasMovedPlugin = true; } catch (error) { if (!(error && error.code === "ENOENT")) throw error; } // A crash can happen after remove-* is created but before remove.json is // durably written or before the installed package is moved. With no // moved package there is nothing to restore, so discard the debris even // when the metadata is missing or partial. If plugin/ exists, metadata // remains mandatory so recovery never deletes an unidentified package. if (!hasMovedPlugin) { await rm(stagedPath, { recursive: true, force: true }); continue; } const metadata = validateRemovalMetadata(JSON.parse(await readFile( path.join(stagedPath, REMOVAL_METADATA_FILE), "utf8", ))); const installedPluginPath = path.join(this.paths.packages, metadata.pluginId); const databasePlugin = this.database.getActivePlugin(metadata.pluginId); if (databasePlugin) { let installedExists = false; try { const installedStats = await lstat(installedPluginPath); installedExists = installedStats.isDirectory() && !installedStats.isSymbolicLink(); } catch (error) { if (!(error && error.code === "ENOENT")) throw error; } if (!installedExists) { const removedStats = await lstat(removedPluginPath); if (!removedStats.isDirectory() || removedStats.isSymbolicLink()) { throw new Error(`Pending plugin removal is incomplete: ${metadata.pluginId}`); } await rename(removedPluginPath, installedPluginPath); await this.syncDirectory(this.paths.packages); } } await rm(stagedPath, { recursive: true, force: true }); } const pluginDirectories = await readdir(this.paths.packages, { withFileTypes: true }); for (const pluginEntry of pluginDirectories) { if (!pluginEntry.isDirectory()) continue; const pluginDirectory = path.join(this.paths.packages, pluginEntry.name); const versions = await readdir(pluginDirectory, { withFileTypes: true }); for (const versionEntry of versions) { if (!versionEntry.isDirectory()) continue; const versionDirectory = path.join(pluginDirectory, versionEntry.name); const committedVersion = this.database.getVersion(pluginEntry.name, versionEntry.name); try { const metadata = validateInstallMetadata(JSON.parse(await readFile( path.join(versionDirectory, INSTALL_METADATA_FILE), "utf8", ))); if (metadata.pluginId !== pluginEntry.name || metadata.version !== versionEntry.name) { throw new Error("Plugin install metadata does not match its directory"); } if (committedVersion && committedVersion.archiveSha256 !== metadata.archiveSha256) { throw new Error("Plugin install archive hash does not match its database record"); } const validation = await this.verifyInstalledVersion(committedVersion ?? { pluginId: metadata.pluginId, version: metadata.version, archiveSha256: metadata.archiveSha256, packageRelativePath: path.relative( this.paths.packages, path.join(versionDirectory, PACKAGE_DIRECTORY), ), }); if (!committedVersion) { const packageDirectory = path.join(versionDirectory, PACKAGE_DIRECTORY); this.database.installVersion({ pluginId: metadata.pluginId, version: metadata.version, manifest: validation.manifest, archiveSha256: metadata.archiveSha256, packageRelativePath: path.relative(this.paths.packages, packageDirectory), }, { forceDisabled: true }); } } catch (error) { this.verifiedArchives.delete(this.#versionKey(pluginEntry.name, versionEntry.name)); this.logger.warn?.(committedVersion ? "[Plugins] Retaining invalid committed package fail-closed" : "[Plugins] Removing invalid uncommitted package", { directory: versionDirectory, error: error?.message ?? String(error), }); if (committedVersion) { const activePlugin = this.database.getActivePlugin(committedVersion.pluginId); if (activePlugin?.activeVersion === committedVersion.version) { this.database.setEnabled(committedVersion.pluginId, false); this.database.setRuntimeState(committedVersion.pluginId, "error", { pluginVersion: committedVersion.version, error: `Installed package integrity check failed: ${error?.message ?? String(error)}`, }); } } else { await rm(versionDirectory, { recursive: true, force: true }); } } } } for (const plugin of this.database.listPlugins()) { if (!plugin.packageRelativePath) continue; try { await this.preparePackageRoot(plugin); } catch (error) { this.database.setEnabled(plugin.id, false); this.database.setRuntimeState(plugin.id, "error", { error: `Installed package integrity check failed: ${error?.message ?? String(error)}`, }); } } } resolvePackageRoot(plugin) { const relativePath = plugin?.packageRelativePath; if (typeof relativePath !== "string" || path.isAbsolute(relativePath)) { throw new Error("Plugin package path is invalid"); } const packageRoot = path.resolve(this.paths.packages, relativePath); if (!isPathInside(this.paths.packages, packageRoot)) { throw new Error("Plugin package path escapes the package store"); } return packageRoot; } #versionKey(pluginId, version) { return `${pluginId}\0${version}`; } #recordIdentity(record) { const pluginId = assertPluginStorageSegment(record?.pluginId ?? record?.id, "ID"); const version = assertPluginStorageSegment(record?.version ?? record?.activeVersion, "version"); if (!/^[a-f0-9]{64}$/u.test(record?.archiveSha256)) { throw new Error("Plugin database archive hash is invalid"); } return { pluginId, version, archiveSha256: record.archiveSha256 }; } async #loadVerifiedArchive(record, options = {}) { const identity = this.#recordIdentity(record); const key = this.#versionKey(identity.pluginId, identity.version); const cached = this.verifiedArchives.get(key); if (options.refresh !== true && cached?.archiveSha256 === identity.archiveSha256) return cached; const pluginCli = await loadPluginCli(); const versionDirectory = resolveInstalledVersionDirectory( this.paths, identity.pluginId, identity.version, ); const metadata = validateInstallMetadata(JSON.parse(await readFile( path.join(versionDirectory, INSTALL_METADATA_FILE), "utf8", ))); if ( metadata.pluginId !== identity.pluginId || metadata.version !== identity.version || metadata.archiveSha256 !== identity.archiveSha256 ) { throw new Error("Plugin install metadata does not match its database record"); } const verificationDirectory = path.join(this.paths.staging, `verify-${randomUUID()}`); const verificationSnapshot = path.join(verificationDirectory, ARCHIVE_SNAPSHOT_FILE); await mkdir(verificationDirectory, { recursive: false, mode: 0o700 }); try { const snapshot = await copyImmutableArchive( path.join(versionDirectory, ARCHIVE_SNAPSHOT_FILE), verificationSnapshot, pluginCli.PACKAGE_LIMITS.archiveBytes, ); if (snapshot.sha256 !== identity.archiveSha256) { throw new Error("Retained plugin archive hash does not match its database record"); } const validation = await pluginCli.validatePluginPackage(verificationSnapshot); if (validation.contentSha256 !== metadata.contentSha256) { throw new Error("Retained plugin archive content does not match install metadata"); } if ( validation.manifest.id !== identity.pluginId || validation.manifest.version !== identity.version ) { throw new Error("Retained plugin archive identity does not match its database record"); } const verified = Object.freeze({ archiveSha256: identity.archiveSha256, contentSha256: validation.contentSha256, manifest: validation.manifest, }); this.verifiedArchives.set(key, verified); return verified; } finally { await rm(verificationDirectory, { recursive: true, force: true }); } } async verifyInstalledVersion(record, options = {}) { const identity = this.#recordIdentity(record); const expected = await this.#loadVerifiedArchive(record, { refresh: options.refreshArchive === true, }); const packageRoot = this.resolvePackageRoot({ packageRelativePath: record.packageRelativePath, }); const packageStats = await stat(packageRoot); if (!packageStats.isDirectory()) throw new Error("Plugin package root is not a directory"); const pluginCli = await loadPluginCli(); const validation = await pluginCli.validatePluginDirectory(packageRoot, { allowIgnoredRootEntries: false, }); if ( validation.manifest.id !== identity.pluginId || validation.manifest.version !== identity.version ) { throw new Error("Installed plugin identity does not match its database record"); } if (validation.contentSha256 !== expected.contentSha256) { throw new Error("Installed plugin files do not match the retained package archive"); } if (record.manifest && JSON.stringify(record.manifest) !== JSON.stringify(expected.manifest)) { throw new Error("Plugin database manifest does not match the retained package archive"); } return validation; } async preparePackageRoot(plugin) { try { await this.verifyInstalledVersion(plugin); return this.resolvePackageRoot(plugin); } catch (error) { const identity = this.#recordIdentity(plugin); const active = this.database.getActivePlugin(identity.pluginId); if (active?.activeVersion === identity.version) { this.database.setEnabled(identity.pluginId, false); this.database.setRuntimeState(identity.pluginId, "error", { pluginVersion: identity.version, error: `Installed package integrity check failed: ${error?.message ?? String(error)}`, }); } throw error; } } async uninstall(pluginId) { const plugin = this.database.getActivePlugin(pluginId); if (!plugin) return false; const pluginDirectory = path.join( this.paths.packages, assertPluginStorageSegment(pluginId, "ID"), ); const removalDirectory = path.join(this.paths.staging, `remove-${randomUUID()}`); const removedPluginPath = path.join(removalDirectory, REMOVED_PLUGIN_DIRECTORY); await mkdir(removalDirectory, { recursive: false, mode: 0o700 }); await writeDurableMetadata( path.join(removalDirectory, REMOVAL_METADATA_FILE), { pluginId }, ); await syncDirectory(removalDirectory); let moved = false; try { await rename(pluginDirectory, removedPluginPath); moved = true; await this.syncDirectory(removalDirectory); await this.syncDirectory(this.paths.packages); } catch (error) { if (!(error && error.code === "ENOENT")) throw error; } try { this.database.removePlugin(pluginId); } catch (error) { let restored = !moved; if (moved) { try { await rename(removedPluginPath, pluginDirectory); await this.syncDirectory(this.paths.packages); restored = true; } catch {} } if (restored) await rm(removalDirectory, { recursive: true, force: true }); throw error; } await rm(removalDirectory, { recursive: true, force: true }); for (const key of this.verifiedArchives.keys()) { if (key.startsWith(`${pluginId}\0`)) this.verifiedArchives.delete(key); } return true; } } module.exports = { ARCHIVE_SNAPSHOT_FILE, INSTALL_METADATA_FILE, PACKAGE_DIRECTORY, REMOVAL_METADATA_FILE, REMOVED_PLUGIN_DIRECTORY, PackageStore, copyImmutableArchive, validateInstallMetadata, validateRemovalMetadata, };