import { randomBytes } from 'node:crypto'; import fs from 'node:fs'; import { constants, type FileHandle } from 'node:fs/promises'; import path from 'node:path'; import { normalizePluginPackageManifest, type PluginPackageManifest, } from '@qinglong/runtime-core/plugin-package'; import { inspectPluginPackageBundle, PLUGIN_PACKAGE_SIGNATURE_SCHEMA, PluginPackagePublisherTrustRegistry, type PluginPackageSignature, } from '@qinglong/runtime-core/plugin-package-bundle'; import { normalizePluginPackageLock, type PluginPackageLock, type PluginPackageSourceLock, } from '@qinglong/runtime-core/plugin-package-install'; import { createLocalPluginPackagePublisherTrustRegistry, localPluginPackagePublisherKeyRevocationImpactDigest, LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA, } from './pluginPackagePublisherTrust'; export { createLocalPluginPackagePublisherTrustRegistry, LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA, } from './pluginPackagePublisherTrust'; export const LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA = 'qinglong/local-plugin-package-recovery-source@v1' as const; export const MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES = 64; export const MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_BUNDLES = 64; const MAX_ROOT_ENTRIES = 128; const MAX_ENTRY_BYTES = 256 * 1024; const MAX_PATH_BYTES = 4_096; const DIGEST_PATTERN = /^[0-9a-f]{64}$/; const ENTRY_PATTERN = /^([0-9a-f]{64})\.json$/; const BUNDLE_PATTERN = /^([0-9a-f]{64})\.bundle$/; const CATALOG_TEMPORARY_PATTERN = /^\.qlpkg-catalog-[0-9a-f]{32}\.tmp$/; const BUNDLE_TEMPORARY_PATTERN = /^\.qlpkg-bundle-[0-9a-f]{32}\.tmp$/; export interface PublishLocalPluginPackageRecoveryCatalogOptions { readonly catalogRoot: string; readonly bundleRoot: string; readonly sourceBundlePath: string; readonly lock: PluginPackageLock; readonly manifest: PluginPackageManifest; readonly signature: PluginPackageSignature; readonly trust: PluginPackagePublisherTrustRegistry; readonly beforePublish?: () => void | Promise; readonly confirmPublicationAllowed?: () => void | Promise; } export interface LocalPluginPackageRecoveryCatalogPublisherKeyAnalysis { readonly catalogEntryCount: number; readonly bundleCount: number; readonly matchingEntryCount: number; readonly unresolvedTransactions: number; } export interface LocalPluginPackageRecoveryCatalogPublisherKeyImpact extends LocalPluginPackageRecoveryCatalogPublisherKeyAnalysis { readonly impactedLockDigests: readonly string[]; readonly impactDigest: string; } export interface PublishedLocalPluginPackageRecoveryCatalogEntry { readonly status: 'published' | 'existing'; readonly lockDigest: string; readonly artifactDigest: string; } export interface LocalPluginPackageRecoveryCatalogInspection { readonly lockDigests: readonly string[]; readonly entryCount: number; readonly bundleCount: number; readonly unresolvedTransactions: number; } export interface CollectLocalPluginPackageRecoveryCatalogOptions { readonly catalogRoot: string; readonly bundleRoot: string; readonly candidateLockDigests: readonly string[]; readonly maxDeletes: number; readonly beforeDelete?: () => void | Promise; } export interface CollectedLocalPluginPackageRecoveryCatalog { readonly removedEntries: number; readonly removedBundles: number; readonly removedTransactions: number; readonly remaining: boolean; } interface DirectoryIdentity { readonly path: string; readonly uid: number; readonly device: bigint; readonly inode: bigint; readonly entries: readonly string[]; } interface DeletionCandidate { readonly path: string; readonly device: bigint; readonly inode: bigint; readonly uid: number; readonly mode: number; readonly kind: 'entry' | 'bundle' | 'transaction'; } interface RecoverySourceEntry { readonly schema: typeof LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA; readonly lockDigest: string; readonly source: Readonly; readonly bundlePath: string; readonly manifest: Readonly; readonly signature: Readonly; } export class LocalPluginPackageRecoveryCatalogPublicationError extends Error { readonly code = 'LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_PUBLICATION_INVALID'; constructor(message: string, options?: ErrorOptions) { super( `Local Plugin Package recovery catalog publication is invalid: ${message}`, options, ); this.name = 'LocalPluginPackageRecoveryCatalogPublicationError'; } } export class LocalPluginPackageRecoveryCatalogPublicationConflictError extends Error { readonly code = 'LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_PUBLICATION_CONFLICT'; constructor() { super( 'Local Plugin Package recovery catalog already has different content', ); this.name = 'LocalPluginPackageRecoveryCatalogPublicationConflictError'; } } function dataRecord(value: unknown, label: string): Record { if ( !value || typeof value !== 'object' || Array.isArray(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( `${label} must be an object`, ); } const descriptors = Object.getOwnPropertyDescriptors(value); if ( Object.values(descriptors).some( (descriptor) => descriptor.get !== undefined || descriptor.set !== undefined || descriptor.enumerable !== true, ) ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( `${label} must contain enumerable data properties`, ); } return value as Record; } function exactKeys( value: object, expected: readonly string[], label: string, ): void { const actual = Object.keys(value).sort(); const canonical = [...expected].sort(); if ( actual.length !== canonical.length || actual.some((key, index) => key !== canonical[index]) ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( `${label} shape is invalid`, ); } } function absolutePath(value: unknown, label: string): string { if ( typeof value !== 'string' || value.length === 0 || Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES || value.includes('\0') || !path.isAbsolute(value) || path.normalize(value) !== value || path.parse(value).root === value ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( `${label} must be a normalized bounded absolute non-root path`, ); } return value; } function currentUid(): number { if ( typeof process.getuid !== 'function' || typeof process.geteuid !== 'function' || process.getuid() !== process.geteuid() ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'real and effective POSIX users must match', ); } return process.getuid(); } function directory( candidate: string, kind: 'catalog' | 'bundle', ): DirectoryIdentity { const root = absolutePath(candidate, `${kind}Root`); const uid = currentUid(); let stat: fs.BigIntStats; try { stat = fs.lstatSync(root, { bigint: true }); } catch (error) { throw new LocalPluginPackageRecoveryCatalogPublicationError( `${kind} root is unavailable`, { cause: error instanceof Error ? error : undefined }, ); } if ( !stat.isDirectory() || stat.isSymbolicLink() || Number(stat.uid) !== uid || (Number(stat.mode) & 0o777) !== 0o700 || fs.realpathSync(root) !== root ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( `${kind} root must be an owner-only non-symlink directory`, ); } const entries = fs.readdirSync(root).sort(); const finalPattern = kind === 'catalog' ? ENTRY_PATTERN : BUNDLE_PATTERN; const temporaryPattern = kind === 'catalog' ? CATALOG_TEMPORARY_PATTERN : BUNDLE_TEMPORARY_PATTERN; const finalCount = entries.filter((entry) => finalPattern.test(entry)).length; const temporaryCount = entries.filter((entry) => temporaryPattern.test(entry), ).length; if ( entries.length > MAX_ROOT_ENTRIES || finalCount > (kind === 'catalog' ? MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES : MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_BUNDLES) || temporaryCount > MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES || entries.some( (entry) => !finalPattern.test(entry) && !temporaryPattern.test(entry), ) ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( `${kind} root contains unbounded or unknown entries`, ); } return Object.freeze({ path: root, uid, device: stat.dev, inode: stat.ino, entries: Object.freeze(entries), }); } function revalidateDirectory(identity: DirectoryIdentity): void { const stat = fs.lstatSync(identity.path, { bigint: true }); if ( !stat.isDirectory() || stat.isSymbolicLink() || Number(stat.uid) !== identity.uid || (Number(stat.mode) & 0o777) !== 0o700 || stat.dev !== identity.device || stat.ino !== identity.inode || fs.realpathSync(identity.path) !== identity.path ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog authority root identity changed', ); } } function syncDirectory(directoryPath: string): void { const descriptor = fs.openSync(directoryPath, constants.O_RDONLY); try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); } } function readPrivateText( filePath: string, uid: number, maxBytes = MAX_ENTRY_BYTES, ): string { let descriptor: number | undefined; let material: Buffer | undefined; try { const before = fs.lstatSync(filePath, { bigint: true }); if ( !before.isFile() || before.isSymbolicLink() || Number(before.uid) !== uid || (Number(before.mode) & 0o777) !== 0o600 || before.size < 1n || before.size > BigInt(maxBytes) ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog entry is not a bounded private regular file', ); } descriptor = fs.openSync( filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), ); const opened = fs.fstatSync(descriptor, { bigint: true }); if ( !opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size || Number(opened.uid) !== uid || (Number(opened.mode) & 0o777) !== 0o600 ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog entry identity changed while opening', ); } material = Buffer.allocUnsafe(Number(opened.size) + 1); let offset = 0; while (offset < material.byteLength) { const bytesRead = fs.readSync( descriptor, material, offset, material.byteLength - offset, null, ); if (bytesRead === 0) break; offset += bytesRead; } const after = fs.fstatSync(descriptor, { bigint: true }); if ( offset !== Number(opened.size) || after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size || Number(after.uid) !== uid || (Number(after.mode) & 0o777) !== 0o600 ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog entry identity changed while reading', ); } return new TextDecoder('utf-8', { fatal: true }).decode( material.subarray(0, offset), ); } catch (error) { if (error instanceof LocalPluginPackageRecoveryCatalogPublicationError) { throw error; } throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog entry cannot be read', { cause: error instanceof Error ? error : undefined }, ); } finally { material?.fill(0); if (descriptor !== undefined) fs.closeSync(descriptor); } } function sourceLock(value: unknown): Readonly { const source = dataRecord(value, 'source'); exactKeys( source, ['artifactBytes', 'artifactDigest', 'contentDigest', 'kind', 'locator'], 'source', ); if ( (source.kind !== 'offline' && source.kind !== 'oci') || typeof source.locator !== 'string' || source.locator.length === 0 || Buffer.byteLength(source.locator, 'utf8') > MAX_PATH_BYTES || typeof source.artifactDigest !== 'string' || !DIGEST_PATTERN.test(source.artifactDigest) || !Number.isSafeInteger(source.artifactBytes) || (source.artifactBytes as number) < 1 || typeof source.contentDigest !== 'string' || !DIGEST_PATTERN.test(source.contentDigest) ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'source lock is invalid', ); } return Object.freeze({ kind: source.kind, locator: source.locator, artifactDigest: source.artifactDigest, artifactBytes: source.artifactBytes as number, contentDigest: source.contentDigest, }); } function signature(value: unknown): Readonly { const candidate = dataRecord(value, 'signature'); exactKeys( candidate, ['keyId', 'publisher', 'schema', 'signature'], 'signature', ); if ( candidate.schema !== PLUGIN_PACKAGE_SIGNATURE_SCHEMA || typeof candidate.publisher !== 'string' || candidate.publisher.length === 0 || typeof candidate.keyId !== 'string' || candidate.keyId.length === 0 || typeof candidate.signature !== 'string' || candidate.signature.length === 0 ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'signature is invalid', ); } return Object.freeze({ schema: PLUGIN_PACKAGE_SIGNATURE_SCHEMA, publisher: candidate.publisher, keyId: candidate.keyId, signature: candidate.signature, }); } function parseEntry( text: string, bundleRoot: string, ): Readonly { let parsed: unknown; try { parsed = JSON.parse(text); } catch (error) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog entry JSON is invalid', { cause: error instanceof Error ? error : undefined }, ); } const value = dataRecord(parsed, 'catalog entry'); exactKeys( value, ['bundlePath', 'lockDigest', 'manifest', 'schema', 'signature', 'source'], 'catalog entry', ); const source = sourceLock(value.source); if ( value.schema !== LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA || typeof value.lockDigest !== 'string' || !DIGEST_PATTERN.test(value.lockDigest) ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog entry identity is invalid', ); } const expectedBundlePath = path.join( bundleRoot, `${source.artifactDigest}.bundle`, ); if (value.bundlePath !== expectedBundlePath) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog entry bundle path is not content addressed', ); } return Object.freeze({ schema: LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA, lockDigest: value.lockDigest, source, bundlePath: expectedBundlePath, manifest: normalizePluginPackageManifest( value.manifest as PluginPackageManifest, ), signature: signature(value.signature), }); } function canonicalEntry(value: Readonly): string { return `${JSON.stringify(value)}\n`; } function recoveryEntry( lock: Readonly, bundleRoot: string, manifest: Readonly, signatureValue: Readonly, ): Readonly { return Object.freeze({ schema: LOCAL_PLUGIN_PACKAGE_RECOVERY_SOURCE_SCHEMA, lockDigest: lock.lockDigest, source: lock.source, bundlePath: path.join(bundleRoot, `${lock.source.artifactDigest}.bundle`), manifest, signature: signatureValue, }); } async function openPrivateBundle( bundlePath: string, uid: number, expectedBytes: number, ): Promise> { const sourcePath = absolutePath(bundlePath, 'sourceBundlePath'); let before: fs.BigIntStats; try { before = fs.lstatSync(sourcePath, { bigint: true }); } catch (error) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'source bundle is unavailable', { cause: error instanceof Error ? error : undefined }, ); } if ( !before.isFile() || before.isSymbolicLink() || Number(before.uid) !== uid || (Number(before.mode) & 0o077) !== 0 || (Number(before.mode) & 0o111) !== 0 || before.size !== BigInt(expectedBytes) ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'source bundle must be an exact owner-only regular file', ); } const handle = await fs.promises.open( sourcePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), ); const opened = await handle.stat({ bigint: true }); if ( !opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size || Number(opened.uid) !== uid || (Number(opened.mode) & 0o077) !== 0 || (Number(opened.mode) & 0o111) !== 0 ) { await handle.close(); throw new LocalPluginPackageRecoveryCatalogPublicationError( 'source bundle identity changed while opening', ); } return Object.freeze({ handle, identity: opened }); } async function* copyChunks( source: FileHandle, target: FileHandle | undefined, expectedBytes: number, ): AsyncGenerator { let offset = 0; while (offset < expectedBytes) { const buffer = Buffer.allocUnsafe( Math.min(64 * 1024, expectedBytes - offset), ); try { const { bytesRead } = await source.read( buffer, 0, buffer.byteLength, offset, ); if (bytesRead === 0) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'source bundle ended while reading', ); } if (target) { let written = 0; while (written < bytesRead) { const result = await target.write( buffer, written, bytesRead - written, offset + written, ); if (result.bytesWritten === 0) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'materialized bundle write stalled', ); } written += result.bytesWritten; } } offset += bytesRead; yield buffer.subarray(0, bytesRead); } finally { buffer.fill(0); } } } async function verifyBundle( bundlePath: string, temporaryPath: string | undefined, uid: number, lock: Readonly, manifest: Readonly, signatureValue: Readonly, trust: PluginPackagePublisherTrustRegistry, ): Promise { const source = await openPrivateBundle( bundlePath, uid, lock.source.artifactBytes, ); let target: FileHandle | undefined; try { if (temporaryPath) { target = await fs.promises.open(temporaryPath, 'wx', 0o600); } await inspectPluginPackageBundle({ lock, manifest, signature: signatureValue, trust, observedAtMs: lock.createdAtMs, chunks: copyChunks(source.handle, target, lock.source.artifactBytes), }); const after = await source.handle.stat({ bigint: true }); if ( after.dev !== source.identity.dev || after.ino !== source.identity.ino || after.size !== source.identity.size || Number(after.uid) !== uid || (Number(after.mode) & 0o077) !== 0 || (Number(after.mode) & 0o111) !== 0 ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'source bundle identity changed while reading', ); } if (target) await target.sync(); } finally { await target?.close().catch(() => undefined); await source.handle.close().catch(() => undefined); } } function writeEntry( catalog: DirectoryIdentity, entry: Readonly, temporaryPath: string, ): 'published' | 'existing' { const contents = canonicalEntry(entry); if (Buffer.byteLength(contents, 'utf8') > MAX_ENTRY_BYTES) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog entry exceeds its byte bound', ); } const targetPath = path.join(catalog.path, `${entry.lockDigest}.json`); let descriptor: number | undefined; try { descriptor = fs.openSync( temporaryPath, constants.O_WRONLY | constants.O_TRUNC | (typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0), ); const stat = fs.fstatSync(descriptor); if ( !stat.isFile() || stat.uid !== catalog.uid || (stat.mode & 0o777) !== 0o600 ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog transaction marker identity is invalid', ); } fs.writeFileSync(descriptor, contents, { encoding: 'utf8' }); fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined; try { fs.linkSync(temporaryPath, targetPath); syncDirectory(catalog.path); return 'published'; } catch (error) { if ( !error || typeof error !== 'object' || !('code' in error) || (error as { code?: string }).code !== 'EEXIST' ) { throw error; } if (readPrivateText(targetPath, catalog.uid) !== contents) { throw new LocalPluginPackageRecoveryCatalogPublicationConflictError(); } return 'existing'; } } finally { if (descriptor !== undefined) fs.closeSync(descriptor); } } function existingFinalCount( identity: DirectoryIdentity, pattern: RegExp, ): number { return identity.entries.filter((entry) => pattern.test(entry)).length; } export async function publishLocalPluginPackageRecoveryCatalogEntry( value: PublishLocalPluginPackageRecoveryCatalogOptions, ): Promise> { const options = dataRecord(value, 'publication options'); const optional = Object.hasOwn(options, 'beforePublish') ? ['beforePublish'] : []; if (Object.hasOwn(options, 'confirmPublicationAllowed')) { optional.push('confirmPublicationAllowed'); } exactKeys( options, [ 'bundleRoot', 'catalogRoot', 'lock', 'manifest', 'signature', 'sourceBundlePath', 'trust', ...optional, ], 'publication options', ); const lock = normalizePluginPackageLock(value.lock); const manifest = normalizePluginPackageManifest(value.manifest); const signatureValue = signature(value.signature); if ( !(value.trust instanceof PluginPackagePublisherTrustRegistry) || (value.beforePublish !== undefined && typeof value.beforePublish !== 'function') || (value.confirmPublicationAllowed !== undefined && typeof value.confirmPublicationAllowed !== 'function') ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'publication authority is invalid', ); } const catalog = directory(value.catalogRoot, 'catalog'); const bundles = directory(value.bundleRoot, 'bundle'); const sourceBundlePath = absolutePath( value.sourceBundlePath, 'sourceBundlePath', ); if ( catalog.path === bundles.path || catalog.path === sourceBundlePath || bundles.path === sourceBundlePath ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'publication authority paths must be distinct', ); } const entry = recoveryEntry(lock, bundles.path, manifest, signatureValue); const targetPath = entry.bundlePath; const targetExists = fs.existsSync(targetPath); const entryExists = fs.existsSync( path.join(catalog.path, `${lock.lockDigest}.json`), ); if ( !entryExists && existingFinalCount(catalog, ENTRY_PATTERN) >= MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog entry capacity is exhausted', ); } if ( !targetExists && existingFinalCount(bundles, BUNDLE_PATTERN) >= MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_BUNDLES ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog bundle capacity is exhausted', ); } if (catalog.entries.length >= MAX_ROOT_ENTRIES) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog transaction capacity is exhausted', ); } const catalogTemporaryPath = path.join( catalog.path, `.qlpkg-catalog-${randomBytes(16).toString('hex')}.tmp`, ); const temporaryPath = path.join( bundles.path, `.qlpkg-bundle-${randomBytes(16).toString('hex')}.tmp`, ); let catalogMarker: number | undefined; try { catalogMarker = fs.openSync( catalogTemporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600, ); fs.fsyncSync(catalogMarker); fs.closeSync(catalogMarker); catalogMarker = undefined; syncDirectory(catalog.path); await value.confirmPublicationAllowed?.(); await verifyBundle( sourceBundlePath, temporaryPath, bundles.uid, lock, manifest, signatureValue, value.trust, ); await value.beforePublish?.(); await value.confirmPublicationAllowed?.(); revalidateDirectory(catalog); revalidateDirectory(bundles); try { fs.linkSync(temporaryPath, targetPath); syncDirectory(bundles.path); } catch (error) { if ( !error || typeof error !== 'object' || !('code' in error) || (error as { code?: string }).code !== 'EEXIST' ) { throw error; } await verifyBundle( targetPath, undefined, bundles.uid, lock, manifest, signatureValue, value.trust, ); } revalidateDirectory(catalog); revalidateDirectory(bundles); const status = writeEntry(catalog, entry, catalogTemporaryPath); fs.unlinkSync(temporaryPath); syncDirectory(bundles.path); fs.unlinkSync(catalogTemporaryPath); syncDirectory(catalog.path); return Object.freeze({ status, lockDigest: lock.lockDigest, artifactDigest: lock.source.artifactDigest, }); } catch (error) { if (catalogMarker !== undefined) { fs.closeSync(catalogMarker); catalogMarker = undefined; } try { fs.unlinkSync(temporaryPath); syncDirectory(bundles.path); } catch { // An unresolved private transaction is deliberately visible to GC. } try { fs.unlinkSync(catalogTemporaryPath); syncDirectory(catalog.path); } catch { // An unresolved private transaction is deliberately visible to GC. } if ( error instanceof LocalPluginPackageRecoveryCatalogPublicationError || error instanceof LocalPluginPackageRecoveryCatalogPublicationConflictError ) { throw error; } throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog publication is unavailable', { cause: error instanceof Error ? error : undefined }, ); } } function entries( catalog: DirectoryIdentity, bundleRoot: string, ): readonly Readonly[] { return Object.freeze( catalog.entries .filter((entry) => ENTRY_PATTERN.test(entry)) .map((name) => { const lockDigest = ENTRY_PATTERN.exec(name)?.[1]; const value = parseEntry( readPrivateText(path.join(catalog.path, name), catalog.uid), bundleRoot, ); if (value.lockDigest !== lockDigest) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog filename does not match its entry', ); } return value; }), ); } export function inspectLocalPluginPackageRecoveryCatalog( value: Readonly<{ catalogRoot: string; bundleRoot: string }>, ): Readonly { const options = dataRecord(value, 'inspection options'); exactKeys(options, ['bundleRoot', 'catalogRoot'], 'inspection options'); const catalog = directory(value.catalogRoot, 'catalog'); const bundles = directory(value.bundleRoot, 'bundle'); if (catalog.path === bundles.path) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog roots must be distinct', ); } const sourceEntries = entries(catalog, bundles.path); return Object.freeze({ lockDigests: Object.freeze( sourceEntries.map((entry) => entry.lockDigest).sort(), ), entryCount: sourceEntries.length, bundleCount: existingFinalCount(bundles, BUNDLE_PATTERN), unresolvedTransactions: catalog.entries.filter((entry) => CATALOG_TEMPORARY_PATTERN.test(entry)) .length + bundles.entries.filter((entry) => BUNDLE_TEMPORARY_PATTERN.test(entry)) .length, }); } export function analyzeLocalPluginPackageRecoveryCatalogPublisherKey( value: Readonly<{ catalogRoot: string; bundleRoot: string; publisher: string; keyId: string; }>, ): Readonly { const options = dataRecord(value, 'publisher key analysis options'); exactKeys( options, ['bundleRoot', 'catalogRoot', 'keyId', 'publisher'], 'publisher key analysis options', ); if ( typeof value.publisher !== 'string' || value.publisher.length === 0 || Buffer.byteLength(value.publisher, 'utf8') > 256 || value.publisher.includes('\0') || typeof value.keyId !== 'string' || value.keyId.length === 0 || Buffer.byteLength(value.keyId, 'utf8') > 256 || value.keyId.includes('\0') ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'publisher key identity is invalid', ); } const catalog = directory(value.catalogRoot, 'catalog'); const bundles = directory(value.bundleRoot, 'bundle'); if (catalog.path === bundles.path) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog roots must be distinct', ); } const sourceEntries = entries(catalog, bundles.path); return Object.freeze({ catalogEntryCount: sourceEntries.length, bundleCount: existingFinalCount(bundles, BUNDLE_PATTERN), matchingEntryCount: sourceEntries.filter( (entry) => entry.signature.publisher === value.publisher && entry.signature.keyId === value.keyId, ).length, unresolvedTransactions: catalog.entries.filter((entry) => CATALOG_TEMPORARY_PATTERN.test(entry)) .length + bundles.entries.filter((entry) => BUNDLE_TEMPORARY_PATTERN.test(entry)) .length, }); } export function analyzeLocalPluginPackageRecoveryCatalogPublisherKeyImpact( value: Readonly<{ catalogRoot: string; bundleRoot: string; publisher: string; keyId: string; }>, ): Readonly { const options = dataRecord(value, 'publisher key impact options'); exactKeys( options, ['bundleRoot', 'catalogRoot', 'keyId', 'publisher'], 'publisher key impact options', ); if ( typeof value.publisher !== 'string' || value.publisher.length === 0 || Buffer.byteLength(value.publisher, 'utf8') > 256 || value.publisher.includes('\0') || typeof value.keyId !== 'string' || value.keyId.length === 0 || Buffer.byteLength(value.keyId, 'utf8') > 256 || value.keyId.includes('\0') ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'publisher key identity is invalid', ); } const catalog = directory(value.catalogRoot, 'catalog'); const bundles = directory(value.bundleRoot, 'bundle'); if (catalog.path === bundles.path) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog roots must be distinct', ); } const sourceEntries = entries(catalog, bundles.path); const impactedLockDigests = Object.freeze( sourceEntries .filter( (entry) => entry.signature.publisher === value.publisher && entry.signature.keyId === value.keyId, ) .map((entry) => entry.lockDigest) .sort(), ); const result = Object.freeze({ catalogEntryCount: sourceEntries.length, bundleCount: existingFinalCount(bundles, BUNDLE_PATTERN), matchingEntryCount: impactedLockDigests.length, unresolvedTransactions: catalog.entries.filter((entry) => CATALOG_TEMPORARY_PATTERN.test(entry)) .length + bundles.entries.filter((entry) => BUNDLE_TEMPORARY_PATTERN.test(entry)) .length, impactedLockDigests, }); return Object.freeze({ ...result, impactDigest: localPluginPackagePublisherKeyRevocationImpactDigest({ publisher: value.publisher, keyId: value.keyId, ...result, }), }); } function deletionCandidate( candidatePath: string, kind: DeletionCandidate['kind'], uid: number, ): DeletionCandidate { const stat = fs.lstatSync(candidatePath, { bigint: true }); if ( !stat.isFile() || stat.isSymbolicLink() || Number(stat.uid) !== uid || (Number(stat.mode) & 0o777) !== 0o600 ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'collection candidate is not a private regular file', ); } return Object.freeze({ path: candidatePath, device: stat.dev, inode: stat.ino, uid, mode: Number(stat.mode) & 0o777, kind, }); } function unlinkCandidate(candidate: DeletionCandidate): void { const stat = fs.lstatSync(candidate.path, { bigint: true }); if ( !stat.isFile() || stat.isSymbolicLink() || stat.dev !== candidate.device || stat.ino !== candidate.inode || Number(stat.uid) !== candidate.uid || (Number(stat.mode) & 0o777) !== candidate.mode ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'collection candidate identity changed', ); } fs.unlinkSync(candidate.path); } function bundleReferenced( catalogRoot: string, bundleRoot: string, bundlePath: string, ): boolean { const catalog = directory(catalogRoot, 'catalog'); return entries(catalog, bundleRoot).some( (entry) => entry.bundlePath === bundlePath, ); } export async function collectLocalPluginPackageRecoveryCatalog( value: CollectLocalPluginPackageRecoveryCatalogOptions, ): Promise> { const options = dataRecord(value, 'collection options'); const optional = Object.hasOwn(options, 'beforeDelete') ? ['beforeDelete'] : []; exactKeys( options, [ 'bundleRoot', 'candidateLockDigests', 'catalogRoot', 'maxDeletes', ...optional, ], 'collection options', ); if ( !Array.isArray(value.candidateLockDigests) || value.candidateLockDigests.length > MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES || value.candidateLockDigests.some( (digest) => typeof digest !== 'string' || !DIGEST_PATTERN.test(digest), ) || new Set(value.candidateLockDigests).size !== value.candidateLockDigests.length || !Number.isSafeInteger(value.maxDeletes) || value.maxDeletes < 1 || value.maxDeletes > MAX_LOCAL_PLUGIN_PACKAGE_RECOVERY_CATALOG_ENTRIES || (value.beforeDelete !== undefined && typeof value.beforeDelete !== 'function') ) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'collection bounds are invalid', ); } const catalog = directory(value.catalogRoot, 'catalog'); const bundles = directory(value.bundleRoot, 'bundle'); if (catalog.path === bundles.path) { throw new LocalPluginPackageRecoveryCatalogPublicationError( 'catalog roots must be distinct', ); } const sourceEntries = entries(catalog, bundles.path); const entryByDigest = new Map( sourceEntries.map((entry) => [entry.lockDigest, entry]), ); const plan: DeletionCandidate[] = []; for (const name of catalog.entries) { if (CATALOG_TEMPORARY_PATTERN.test(name)) { plan.push( deletionCandidate( path.join(catalog.path, name), 'transaction', catalog.uid, ), ); } } for (const name of bundles.entries) { if (BUNDLE_TEMPORARY_PATTERN.test(name)) { plan.push( deletionCandidate( path.join(bundles.path, name), 'transaction', bundles.uid, ), ); } } for (const lockDigest of [...value.candidateLockDigests].sort()) { if (!entryByDigest.has(lockDigest)) continue; plan.push( deletionCandidate( path.join(catalog.path, `${lockDigest}.json`), 'entry', catalog.uid, ), ); } const retainedBundlePaths = new Set( sourceEntries .filter((entry) => !value.candidateLockDigests.includes(entry.lockDigest)) .map((entry) => entry.bundlePath), ); for (const name of bundles.entries) { const match = BUNDLE_PATTERN.exec(name); if (!match) continue; const bundlePath = path.join(bundles.path, name); if (!retainedBundlePaths.has(bundlePath)) { plan.push(deletionCandidate(bundlePath, 'bundle', bundles.uid)); } } if (plan.length > 0) await value.beforeDelete?.(); revalidateDirectory(catalog); revalidateDirectory(bundles); let removedEntries = 0; let removedBundles = 0; let removedTransactions = 0; let consumed = 0; for (const candidate of plan) { if (consumed >= value.maxDeletes) break; if ( candidate.kind === 'bundle' && bundleReferenced(catalog.path, bundles.path, candidate.path) ) { continue; } try { unlinkCandidate(candidate); } catch (error) { if ( error && typeof error === 'object' && 'code' in error && (error as { code?: string }).code === 'ENOENT' ) { continue; } throw error; } consumed += 1; if (candidate.kind === 'entry') removedEntries += 1; else if (candidate.kind === 'bundle') removedBundles += 1; else removedTransactions += 1; } if (consumed > 0) { syncDirectory(catalog.path); syncDirectory(bundles.path); } return Object.freeze({ removedEntries, removedBundles, removedTransactions, remaining: plan.length > consumed, }); }