import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' import { lstat, mkdir, readdir, readFile, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { canonicalJson } from '../../../platform/lib/canonical-json.mjs' import { artifactForReference, parseEnvironmentManifest } from '../../../platform/lib/contracts.mjs' import { hashTree } from '../../../platform/lib/tree-hash.mjs' const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/ const SELECTION_SCHEMA = 1 export const PROTECTED_SYSTEM_PLUGIN_IDS = Object.freeze(['platform-management']) function assertPluginId(pluginId) { if (typeof pluginId !== 'string' || !PLUGIN_ID_PATTERN.test(pluginId)) { throw new Error('System Plugin ID is invalid') } return pluginId } function run(command, args) { return new Promise((resolveRun, reject) => { const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) const stdout = [] const stderr = [] child.stdout.on('data', chunk => stdout.push(chunk)) child.stderr.on('data', chunk => stderr.push(chunk)) child.once('error', reject) child.once('exit', code => { if (code === 0) resolveRun(Buffer.concat(stdout).toString('utf8')) else reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString('utf8').trim()}`)) }) }) } async function extractPackage(archive, destination) { const listing = (await run('tar', ['-tzf', archive])).split('\n').filter(Boolean) if (listing.length === 0) throw new Error('System Plugin archive is empty') for (const name of listing) { if (!name.startsWith('package/') || name.startsWith('/') || name.split('/').includes('..')) { throw new Error(`System Plugin archive path is unsafe: ${name}`) } } await mkdir(destination, { recursive: true }) await run('tar', ['-xzf', archive, '--strip-components=1', '--no-same-owner', '--no-same-permissions', '-C', destination]) } function validatePackage(value, pluginId) { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`System Plugin ${pluginId} package.json must be an object`) } const expectedName = `@dsh-docker/${pluginId}` if (value.name !== expectedName) throw new Error(`System Plugin ${pluginId} package name must be ${expectedName}`) if (typeof value.main !== 'string' || value.main.length === 0 || value.main.startsWith('/') || value.main.includes('\\') || value.main.split('/').includes('..')) { throw new Error(`System Plugin ${pluginId} package main must be a safe relative path`) } return expectedName } function validatePatch(value, pluginId, packageName) { if (!Array.isArray(value)) throw new Error(`System Plugin ${pluginId} cordis.patch.json must be an array`) return value.map((entry, index) => { if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { throw new Error(`System Plugin ${pluginId} patch ${String(index)} must be an object`) } if (Object.keys(entry).length !== 1 || !Array.isArray(entry.insert) || entry.insert.length === 0) { throw new Error(`System Plugin ${pluginId} patches must contain only a non-empty insert list`) } for (const row of entry.insert) { if (row === null || typeof row !== 'object' || Array.isArray(row)) { throw new Error(`System Plugin ${pluginId} inserted rows must be objects`) } if (typeof row.id !== 'string' || !row.id.startsWith(`dsh-docker.${pluginId}.`)) { throw new Error(`System Plugin ${pluginId} patch IDs must use its dsh-docker namespace`) } if (typeof row.name !== 'string' || row.name.length === 0) { throw new Error(`System Plugin ${pluginId} inserted rows must name a package`) } if (row.name !== packageName) { throw new Error(`System Plugin ${pluginId} inserted rows must load its own package`) } } return { insert: entry.insert.map(row => ({ ...row })), } }) } async function replaceLink(root, name, version) { const temporary = join(root, `.${name}.${randomUUID()}.tmp`) await symlink(join('versions', version), temporary) await rename(temporary, join(root, name)) } export async function reconcileSystemPlugins({ root, environmentVersion, plugins, artifactPath }) { const managedRoot = resolve(root) const versions = join(managedRoot, 'versions') const destination = join(versions, environmentVersion) const staging = join(versions, `.${environmentVersion}.${randomUUID()}.tmp`) await mkdir(staging, { recursive: true }) const patches = [] try { for (const plugin of plugins) { const packageRoot = join(staging, 'packages', plugin.id) await extractPackage(artifactPath(plugin), packageRoot) const metadata = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) const packageName = validatePackage(metadata, plugin.id) const patch = JSON.parse(await readFile(join(packageRoot, 'cordis.patch.json'), 'utf8')) patches.push(...validatePatch(patch, plugin.id, packageName)) } await writeFile(join(staging, 'cordis.patch.yml'), canonicalJson(patches), { flag: 'wx' }) await rename(staging, destination) const current = await import('node:fs/promises').then(({ readlink }) => readlink(join(managedRoot, 'current')).catch(error => error.code === 'ENOENT' ? undefined : Promise.reject(error))) if (current !== undefined) await replaceLink(managedRoot, 'previous', current.split('/').at(-1)) await replaceLink(managedRoot, 'current', environmentVersion) return destination } catch (error) { await rm(staging, { recursive: true, force: true }) throw error } } async function readOverlay(root) { const patch = JSON.parse(await readFile(join(root, 'cordis.patch.yml'), 'utf8')) if (!Array.isArray(patch)) throw new Error('System Plugin overlay patch must be an array') return patch } function defaultSelection(pluginIds, protectedIds) { return Object.fromEntries(pluginIds.map(id => [id, { installed: true, enabled: true, protected: protectedIds.has(id), }])) } function validateSelectionDocument(value, pluginIds, protectedIds) { if (value === null || typeof value !== 'object' || Array.isArray(value) || value.schema !== SELECTION_SCHEMA || value.plugins === null || typeof value.plugins !== 'object' || Array.isArray(value.plugins)) { throw new Error('System Plugin selection state is invalid') } const selection = defaultSelection(pluginIds, protectedIds) for (const id of pluginIds) { const saved = value.plugins[id] if (saved === undefined) continue if (saved === null || typeof saved !== 'object' || Array.isArray(saved) || typeof saved.installed !== 'boolean' || typeof saved.enabled !== 'boolean') { throw new Error(`System Plugin ${id} selection state is invalid`) } if (saved.enabled && !saved.installed) { throw new Error(`System Plugin ${id} cannot be enabled while it is not installed`) } selection[id] = { installed: saved.installed, enabled: saved.enabled, protected: protectedIds.has(id) } } return selection } export class SystemPluginSelectionStore { constructor(path, { protectedIds = PROTECTED_SYSTEM_PLUGIN_IDS } = {}) { this.path = resolve(path) this.protectedIds = new Set(protectedIds) } async read(pluginIds) { const ids = pluginIds.map(assertPluginId) let value try { value = JSON.parse(await readFile(this.path, 'utf8')) } catch (error) { if (error?.code === 'ENOENT') return defaultSelection(ids, this.protectedIds) throw error } return validateSelectionDocument(value, ids, this.protectedIds) } async write(pluginIds, selection) { const ids = pluginIds.map(assertPluginId) const normalized = validateSelectionDocument({ schema: SELECTION_SCHEMA, plugins: selection }, ids, this.protectedIds) const document = { schema: SELECTION_SCHEMA, plugins: Object.fromEntries(ids.map(id => [id, { installed: normalized[id].installed, enabled: normalized[id].enabled, }])), } await mkdir(dirname(this.path), { recursive: true }) const temporary = `${this.path}.${randomUUID()}.tmp` try { await writeFile(temporary, `${canonicalJson(document)}\n`, { flag: 'wx', mode: 0o600 }) await rename(temporary, this.path) } finally { await rm(temporary, { force: true }) } return normalized } async configure(pluginIds, pluginId, action) { const ids = pluginIds.map(assertPluginId) assertPluginId(pluginId) if (!ids.includes(pluginId)) throw new Error(`System Plugin ${pluginId} is not provided by the current Environment`) if (this.protectedIds.has(pluginId)) throw new Error(`System Plugin ${pluginId} is managed by the platform and cannot be changed`) return this.configureSelection(ids, pluginId, action) } async recover(pluginIds, pluginId, action) { const ids = pluginIds.map(assertPluginId) assertPluginId(pluginId) if (!ids.includes(pluginId)) throw new Error(`System Plugin ${pluginId} is not provided by the current Environment`) if (!this.protectedIds.has(pluginId)) throw new Error(`System Plugin ${pluginId} is not a platform recovery target`) return this.configureSelection(ids, pluginId, action) } async configureSelection(ids, pluginId, action) { if (!['install', 'uninstall', 'enable', 'disable'].includes(action)) { throw new Error('System Plugin action is invalid') } const selection = await this.read(ids) const current = selection[pluginId] if (action === 'install') selection[pluginId] = { ...current, installed: true, enabled: true } else if (action === 'uninstall') selection[pluginId] = { ...current, installed: false, enabled: false } else if (action === 'enable') { if (!current.installed) throw new Error(`System Plugin ${pluginId} must be installed before it can be enabled`) selection[pluginId] = { ...current, enabled: true } } else selection[pluginId] = { ...current, enabled: false } return this.write(ids, selection) } } async function pluginCatalog(environmentRoot) { const root = resolve(environmentRoot) const manifest = parseEnvironmentManifest(await readFile(join(root, 'environment.manifest.json'))) return { root, plugins: manifest.systemPlugins.map(reference => ({ id: reference.id, descriptor: artifactForReference(manifest, reference), })), } } async function readActiveSelection(root, plugins) { if (root === undefined) return undefined const overlay = await readOverlay(resolve(root)) const enabledPackages = new Set(overlay.flatMap(entry => ( Array.isArray(entry?.insert) ? entry.insert.map(row => row?.name).filter(Boolean) : [] ))) return Object.fromEntries(await Promise.all(plugins.map(async ({ id }) => { const installed = await lstat(join(resolve(root), 'packages', id)).then( () => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error), ) return [id, { installed, enabled: installed && enabledPackages.has(`@dsh-docker/${id}`) }] }))) } export async function listManagedSystemPlugins({ environmentRoot, sourceRoot, selectionStore, activeRoot }) { const catalog = await pluginCatalog(environmentRoot) const selection = await selectionStore.read(catalog.plugins.map(plugin => plugin.id)) const active = await readActiveSelection(activeRoot, catalog.plugins) return Object.freeze(await Promise.all(catalog.plugins.map(async ({ id, descriptor }) => { const metadata = JSON.parse(await readFile(join(resolve(sourceRoot), 'packages', id, 'package.json'), 'utf8')) validatePackage(metadata, id) let description = null const value = metadata.dshDocker?.description if (value !== undefined) { if (value === null || typeof value !== 'object' || Array.isArray(value) || typeof value.zh !== 'string' || typeof value.en !== 'string' || value.zh.length === 0 || value.en.length === 0 || value.zh.length > 240 || value.en.length > 240) { throw new Error(`System Plugin ${id} description is invalid`) } description = Object.freeze({ zh: value.zh, en: value.en }) } const activeInstalled = active?.[id].installed ?? selection[id].installed const activeEnabled = active?.[id].enabled ?? selection[id].enabled return Object.freeze({ id, artifactId: descriptor.id, sha256: descriptor.sha256, description, installed: selection[id].installed, enabled: selection[id].enabled, activeInstalled, activeEnabled, pendingRestart: activeInstalled !== selection[id].installed || activeEnabled !== selection[id].enabled, protected: selection[id].protected, reason: null, }) }))) } export async function discardSystemPluginSelection({ environmentRoot, sourceRoot, selectionStore, activeRoot }) { const catalog = await pluginCatalog(environmentRoot) const pluginIds = catalog.plugins.map(plugin => plugin.id) const active = await readActiveSelection(activeRoot, catalog.plugins) if (active === undefined) throw new Error('active System Plugin view is unavailable') await selectionStore.write(pluginIds, active) return listManagedSystemPlugins({ environmentRoot, sourceRoot, selectionStore, activeRoot }) } export async function materializeSystemPluginSelection({ environmentRoot, sourceRoot, outputRoot, selectionStore, }) { const catalog = await pluginCatalog(environmentRoot) const pluginIds = catalog.plugins.map(plugin => plugin.id) const selection = await selectionStore.read(pluginIds) const root = resolve(outputRoot) const staging = join(root, `.selection.${randomUUID()}.tmp`) const destination = join(root, `selection-${randomUUID()}`) const patches = [] await mkdir(join(staging, 'packages'), { recursive: true }) try { for (const { id } of catalog.plugins) { if (!selection[id].installed) continue const packageRoot = join(resolve(sourceRoot), 'packages', id) const metadata = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) const packageName = validatePackage(metadata, id) const patch = JSON.parse(await readFile(join(packageRoot, 'cordis.patch.json'), 'utf8')) const validatedPatch = validatePatch(patch, id, packageName) await symlink(packageRoot, join(staging, 'packages', id), 'dir') if (selection[id].enabled) patches.push(...validatedPatch) } await writeFile(join(staging, 'cordis.patch.yml'), canonicalJson(patches), { flag: 'wx' }) await rename(staging, destination) return Object.freeze({ path: destination, plugins: await listManagedSystemPlugins({ environmentRoot, sourceRoot, selectionStore }) }) } catch (error) { await rm(staging, { recursive: true, force: true }) throw error } } export async function pruneSystemPluginSelectionViews({ outputRoot, keepPath }) { const root = resolve(outputRoot) const keep = resolve(keepPath) for (const entry of await readdir(root, { withFileTypes: true })) { if (!entry.name.startsWith('selection-')) continue const candidate = join(root, entry.name) if (candidate !== keep) await rm(candidate, { recursive: true, force: true }) } } export async function listBundledSystemPlugins({ environmentRoot, viewRoot }) { const manifest = parseEnvironmentManifest(await readFile(join(resolve(environmentRoot), 'environment.manifest.json'))) const overlay = await readOverlay(resolve(viewRoot)).catch(() => []) const moduleNames = new Set(overlay.flatMap(entry => ( Array.isArray(entry?.insert) ? entry.insert.map(row => row?.name).filter(Boolean) : [] ))) const result = [] for (const reference of manifest.systemPlugins) { const descriptor = artifactForReference(manifest, reference) let installed = false let reason = null try { const metadata = JSON.parse(await readFile(join(resolve(viewRoot), 'packages', reference.id, 'package.json'), 'utf8')) const packageName = validatePackage(metadata, reference.id) installed = moduleNames.has(packageName) if (!installed) reason = 'plugin loader entry is absent' } catch (error) { reason = error instanceof Error ? error.message : 'plugin package is unavailable' } result.push(Object.freeze({ id: reference.id, artifactId: descriptor.id, sha256: descriptor.sha256, installed, reason, })) } return Object.freeze(result) } export async function rebuildBundledSystemPluginView({ environmentRoot, outputRoot, expectedSha256, requestedPluginId, }) { const source = resolve(environmentRoot) const manifest = parseEnvironmentManifest(await readFile(join(source, 'environment.manifest.json'))) if (!manifest.systemPlugins.some(plugin => plugin.id === requestedPluginId)) { throw new Error(`System Plugin ${requestedPluginId} is not bundled by the current Environment`) } const root = resolve(outputRoot) const destination = join(root, expectedSha256) try { const details = await lstat(destination) if (!details.isDirectory() || details.isSymbolicLink() || await hashTree(destination) !== expectedSha256) { await rm(destination, { recursive: true, force: true }) } else { return destination } } catch (error) { if (error?.code !== 'ENOENT') throw error } const buildRoot = join(root, `.build.${randomUUID()}.tmp`) await mkdir(root, { recursive: true }) try { const built = await reconcileSystemPlugins({ root: buildRoot, environmentVersion: expectedSha256, plugins: manifest.systemPlugins, artifactPath: reference => join(source, 'artifacts', artifactForReference(manifest, reference).id), }) if (await hashTree(built) !== expectedSha256) { throw new Error('rebuilt System Plugin view differs from the current Deployment Record') } await rename(built, destination) return destination } finally { await rm(buildRoot, { recursive: true, force: true }) } } export async function linkSystemPluginScope({ dshHome, viewRoot = '/run/dsh-platform/views/system-plugins', }) { const modulesRoot = join(resolve(dshHome), 'profiles', 'node_modules') const scopeLink = join(modulesRoot, '@dsh-docker') const target = join(resolve(viewRoot), 'packages') await mkdir(modulesRoot, { recursive: true }) const existing = await lstat(scopeLink).catch(error => error?.code === 'ENOENT' ? undefined : Promise.reject(error)) if (existing !== undefined) { if (!existing.isSymbolicLink()) { throw new Error(`System Plugin scope ${scopeLink} exists and is not a symlink`) } if (await readlink(scopeLink) === target) return scopeLink await rm(scopeLink) } const temporary = join(modulesRoot, `.@dsh-docker.${randomUUID()}.tmp`) try { await symlink(target, temporary, 'dir') await rename(temporary, scopeLink) } finally { await rm(temporary, { force: true }) } return scopeLink }