590 lines
23 KiB
JavaScript
590 lines
23 KiB
JavaScript
|
|
"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,
|
||
|
|
};
|