diff --git a/dsh-plugin-desktop-beta/src/desktop-runtime-environment.ts b/dsh-plugin-desktop-beta/src/desktop-runtime-environment.ts index 6f18f8856f..8ec8f7fcd7 100644 --- a/dsh-plugin-desktop-beta/src/desktop-runtime-environment.ts +++ b/dsh-plugin-desktop-beta/src/desktop-runtime-environment.ts @@ -1,17 +1,17 @@ /** App-local command environments available to desktop Host plugins. */ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { chmodSync, lstatSync, mkdirSync, + readFileSync, readdirSync, renameSync, - unlinkSync, + rmSync, writeFileSync, } from 'node:fs' -import { basename, dirname, join } from 'node:path' -import { pathToFileURL } from 'node:url' +import { join, win32 as windowsPath } from 'node:path' import { PNPM_IGNORE_MINIMUM_RELEASE_AGE } from './pnpm-policy.ts' import { assertDesktopProfileName } from './profile-manager.ts' @@ -23,7 +23,9 @@ const ELECTRON_HEADERS_URL = 'https://electronjs.org/headers' const DIRECTORY_MODE = 0o700 const EXECUTABLE_FILE_MODE = 0o700 const PRIVATE_FILE_MODE = 0o600 -const TEMPORARY_ID = /^\d+\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u +const GENERATION_SCHEMA_VERSION = 1 +const GENERATION_NAME = /^(?[0-9a-f]{64})-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u +const MANIFEST_NAME = '.generation.json' /** Inputs used to install one app-local pnpm command environment. */ export interface DesktopPnpmRuntimeOptions { @@ -31,7 +33,7 @@ export interface DesktopPnpmRuntimeOptions { platform: NodeJS.Platform /** Electron executable reused in RunAsNode mode. */ appExecutable: string - /** Physical packaged pnpm JavaScript entry. */ + /** Packaged pnpm JavaScript entry executable by Electron RunAsNode, including an ASAR logical path. */ pnpmBinPath: string /** Electron version used when pnpm installs native dependencies. */ electronVersion: string @@ -106,16 +108,6 @@ function escapeBatchSetValue(value: string): string { return value.replaceAll('%', '%%') } -/** Return one lstat result, preserving every failure except absence. */ -function lstatOptional(filename: string): ReturnType | undefined { - try { - return lstatSync(filename) - } catch (cause) { - if ((cause as NodeJS.ErrnoException).code === 'ENOENT') return undefined - throw cause - } -} - /** Create one owner-only real directory and reject a pre-existing alternate file type. */ function preparePrivateDirectory(directory: string): void { mkdirSync(directory, { recursive: true, mode: DIRECTORY_MODE }) @@ -126,77 +118,221 @@ function preparePrivateDirectory(directory: string): void { chmodSync(directory, DIRECTORY_MODE) } -/** Reject command-directory entries not owned by this runtime generation. */ -function assertOwnedDirectoryEntries(directory: string, allowed: readonly string[]): void { - const unexpected = readdirSync(directory).filter(entry => !allowed.includes(entry)) - if (unexpected.length > 0) { - throw new Error( - `dsh-plugin-desktop: command runtime directory contains unexpected entries: ${unexpected.join(', ')}`, - ) +type RuntimeGenerationKind = 'dsh' | 'pnpm' + +interface RuntimeArtifact { + relativePath: string + contents: string + mode: number +} + +interface RuntimeGenerationManifest { + schemaVersion: typeof GENERATION_SCHEMA_VERSION + kind: RuntimeGenerationKind + platform: NodeJS.Platform + contentHash: string + files: Array<{ + path: string + sha256: string + mode: number + }> +} + +interface RuntimeGenerationPlan { + kind: RuntimeGenerationKind + platform: NodeJS.Platform + contentHash: string + directories: string[] + artifacts: RuntimeArtifact[] +} + +interface RuntimeGeneration { + root: string + obsoletePathDirectories: string[] +} + +/** Hash deterministic generated content without depending on its eventual directory name. */ +function sha256(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex') +} + +/** Create a content-addressed plan and its self-describing ownership marker. */ +function runtimeGenerationPlan( + kind: RuntimeGenerationKind, + platform: NodeJS.Platform, + directories: readonly string[], + artifacts: readonly RuntimeArtifact[], +): RuntimeGenerationPlan { + const orderedDirectories = [...directories].sort() + const orderedArtifacts = [...artifacts].sort((left, right) => left.relativePath.localeCompare(right.relativePath)) + const contentHash = sha256(JSON.stringify({ + schemaVersion: GENERATION_SCHEMA_VERSION, + kind, + platform, + directories: orderedDirectories, + files: orderedArtifacts.map(artifact => ({ + path: artifact.relativePath, + contents: artifact.contents, + mode: artifact.mode, + })), + })) + const manifest: RuntimeGenerationManifest = { + schemaVersion: GENERATION_SCHEMA_VERSION, + kind, + platform, + contentHash, + files: orderedArtifacts.map(artifact => ({ + path: artifact.relativePath, + sha256: sha256(artifact.contents), + mode: artifact.mode, + })), + } + return { + kind, + platform, + contentHash, + directories: orderedDirectories, + artifacts: [ + { relativePath: MANIFEST_NAME, contents: `${JSON.stringify(manifest, undefined, 2)}\n`, mode: PRIVATE_FILE_MODE }, + ...orderedArtifacts, + ], } } -/** Remove one stray command entry without recursively deleting unknown data. */ -function removeUnexpectedEntry(directory: string, entry: string): void { - const filename = join(directory, entry) - if (lstatSync(filename).isDirectory()) { - throw new Error( - `dsh-plugin-desktop: command runtime contains an unexpected directory: ${entry}`, - ) - } - process.stderr.write( - `dsh-plugin-desktop: removing unexpected command runtime entry ${JSON.stringify(entry)}\n`, - ) - unlinkSync(filename) +function pathSegments(relativePath: string): string[] { + return relativePath.split('/') } -/** Recover an app-owned command directory to this generation's exact contents. */ -function reconcileOwnedDirectoryEntries(directory: string, allowed: readonly string[]): void { - for (const entry of readdirSync(directory)) { - if (!allowed.includes(entry)) removeUnexpectedEntry(directory, entry) - } +function generationPath(root: string, relativePath: string): string { + return join(root, ...pathSegments(relativePath)) } -/** Remove only stale atomic-write files generated for one exact target name. */ -function removeStaleTemporaryFiles(directory: string, targetName: string): void { - const prefix = `.${targetName}.` - const suffix = '.tmp' - for (const entry of readdirSync(directory)) { - if (!entry.startsWith(prefix) || !entry.endsWith(suffix)) continue - const identity = entry.slice(prefix.length, -suffix.length) - if (!TEMPORARY_ID.test(identity)) continue - const filename = join(directory, entry) - const stat = lstatSync(filename) - if (!stat.isFile() && !stat.isSymbolicLink()) { - throw new Error(`dsh-plugin-desktop: command runtime stale temporary path is not a file: ${filename}`) +/** Inspect a generation tree without following symlinks. */ +function generationTree(root: string): { directories: string[]; files: string[] } | undefined { + try { + const rootStat = lstatSync(root) + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return undefined + const directories: string[] = [] + const files: string[] = [] + const visit = (directory: string, prefix: string): boolean => { + for (const entry of readdirSync(directory)) { + const relativePath = prefix.length === 0 ? entry : `${prefix}/${entry}` + const stat = lstatSync(join(directory, entry)) + if (stat.isSymbolicLink()) return false + if (stat.isDirectory()) { + directories.push(relativePath) + if (!visit(join(directory, entry), relativePath)) return false + continue + } + if (!stat.isFile()) return false + files.push(relativePath) + } + return true } - unlinkSync(filename) + if (!visit(root, '')) return undefined + return { directories: directories.sort(), files: files.sort() } + } catch { + return undefined } } -/** Remove a temporary file while preserving every failure except absence. */ -function unlinkTemporaryFile(filename: string): void { +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +/** Verify exact content before making an existing immutable generation executable via PATH. */ +function reusableGeneration(root: string, plan: RuntimeGenerationPlan): boolean { + const tree = generationTree(root) + if (tree === undefined) return false + const expectedFiles = plan.artifacts.map(artifact => artifact.relativePath).sort() + if (!sameStrings(tree.directories, plan.directories) || !sameStrings(tree.files, expectedFiles)) return false try { - unlinkSync(filename) + for (const artifact of plan.artifacts) { + const filename = generationPath(root, artifact.relativePath) + const stat = lstatSync(filename) + if (!stat.isFile() || stat.isSymbolicLink()) return false + if (process.platform !== 'win32' && (stat.mode & 0o777) !== artifact.mode) return false + if (readFileSync(filename, 'utf8') !== artifact.contents) return false + } + return true + } catch { + return false + } +} + +function reportCleanupFailure(filename: string, cause: unknown): void { + const code = (cause as NodeJS.ErrnoException).code + try { + process.stderr.write( + `dsh-plugin-desktop: deferred command runtime cleanup for ${JSON.stringify(filename)}` + + `${code === undefined ? '' : ` (${code})`}\n`, + ) + } catch { + // Diagnostics must not turn best-effort generation cleanup into a startup failure. + } +} + +/** Materialize a new generation privately, then publish it under a never-reused name. */ +function createRuntimeGeneration(generationsDir: string, plan: RuntimeGenerationPlan): string { + const temporaryRoot = join(generationsDir, `.staging.${process.pid}.${randomUUID()}`) + mkdirSync(temporaryRoot, { mode: DIRECTORY_MODE }) + chmodSync(temporaryRoot, DIRECTORY_MODE) + try { + for (const relativePath of [...plan.directories].sort((left, right) => { + return pathSegments(left).length - pathSegments(right).length || left.localeCompare(right) + })) { + const directory = generationPath(temporaryRoot, relativePath) + mkdirSync(directory, { mode: DIRECTORY_MODE }) + chmodSync(directory, DIRECTORY_MODE) + } + for (const artifact of plan.artifacts) { + const filename = generationPath(temporaryRoot, artifact.relativePath) + writeFileSync(filename, artifact.contents, { encoding: 'utf8', flag: 'wx', mode: artifact.mode }) + chmodSync(filename, artifact.mode) + } + const root = join(generationsDir, `${plan.contentHash}-${randomUUID()}`) + renameSync(temporaryRoot, root) + return root } catch (cause) { - if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause + try { + rmSync(temporaryRoot, { recursive: true, force: true, maxRetries: 0 }) + } catch (cleanupCause) { + reportCleanupFailure(temporaryRoot, cleanupCause) + } + throw cause } } -/** Atomically replace one regular app-owned file without accepting a symlink target. */ -function replacePrivateFile(filename: string, contents: string, mode: number): void { - const existing = lstatOptional(filename) - if (existing !== undefined && (!existing.isFile() || existing.isSymbolicLink())) { - throw new Error(`dsh-plugin-desktop: command runtime file is not a regular file: ${filename}`) - } - const temporary = join(dirname(filename), `.${basename(filename)}.${process.pid}.${randomUUID()}.tmp`) - try { - writeFileSync(temporary, contents, { encoding: 'utf8', flag: 'wx', mode }) - chmodSync(temporary, mode) - renameSync(temporary, filename) - } finally { - unlinkTemporaryFile(temporary) +/** Reuse an exact immutable generation or publish a clean sibling when any candidate is tainted. */ +function installRuntimeGeneration(stateDir: string, plan: RuntimeGenerationPlan): RuntimeGeneration { + preparePrivateDirectory(stateDir) + const generationsDir = join(stateDir, 'generations') + preparePrivateDirectory(generationsDir) + const entries = readdirSync(generationsDir) + let root: string | undefined + for (const entry of entries.sort()) { + const match = GENERATION_NAME.exec(entry) + if (match?.groups?.hash !== plan.contentHash) continue + const candidate = join(generationsDir, entry) + if (reusableGeneration(candidate, plan)) { + root = candidate + break + } } + root ??= createRuntimeGeneration(generationsDir, plan) + const obsoletePathDirectories = [ + join(stateDir, 'bin'), + join(stateDir, 'private', 'node-bin'), + ...entries.flatMap(entry => [ + join(generationsDir, entry, 'bin'), + join(generationsDir, entry, 'private', 'node-bin'), + ]), + ].filter(directory => { + return directory !== join(root, 'bin') && directory !== join(root, 'private', 'node-bin') + }) + // Published generations may still back child processes after the desktop lifecycle advances. + // Keep them immutable and isolate them from PATH; safe garbage collection requires explicit leases. + return { root, obsoletePathDirectories } } /** Module preloaded into RunAsNode children before their requested entry. */ @@ -210,65 +346,68 @@ function clearEnvironmentModule(): string { } /** Build the private POSIX Node command used only by pnpm lifecycle scripts. */ -function posixNodeShim(appExecutable: string, clearEnvironmentUrl: string): string { +function posixNodeShim(appExecutable: string): string { return [ '#!/bin/sh', - `${RUN_AS_NODE}=1 exec ${quoteSh(appExecutable)} --import ${quoteSh(clearEnvironmentUrl)} "$@"`, + 'runtime_node_bin_dir=${0%/*}', + 'runtime_private_dir=${runtime_node_bin_dir%/*}', + 'runtime_clear_environment="$runtime_private_dir/clear-env.cjs"', + `${RUN_AS_NODE}=1 exec ${quoteSh(appExecutable)} --require "$runtime_clear_environment" "$@"`, '', ].join('\n') } /** Build the public POSIX pnpm command. */ -function posixPnpmShim( - options: DesktopPnpmRuntimeOptions, - nodeBinDir: string, - nodeShimPath: string, - clearEnvironmentUrl: string, -): string { +function posixPnpmShim(options: DesktopPnpmRuntimeOptions): string { return [ '#!/bin/sh', + 'runtime_public_dir=${0%/*}', + 'runtime_root_dir=${runtime_public_dir%/*}', + 'runtime_private_dir="$runtime_root_dir/private"', + 'runtime_node_bin_dir="$runtime_private_dir/node-bin"', + 'runtime_node_shim="$runtime_node_bin_dir/node"', + 'runtime_clear_environment="$runtime_private_dir/clear-env.cjs"', [ - `PATH=${quoteSh(nodeBinDir)}:"\${PATH:-}"`, - `NODE=${quoteSh(nodeShimPath)}`, + 'PATH="$runtime_node_bin_dir:${PATH:-}"', + 'NODE="$runtime_node_shim"', `${RUN_AS_NODE}=1`, 'npm_config_runtime=electron', `npm_config_target=${quoteSh(options.electronVersion)}`, `npm_config_disturl=${quoteSh(ELECTRON_HEADERS_URL)}`, - `exec ${quoteSh(options.appExecutable)} --import ${quoteSh(clearEnvironmentUrl)} ${quoteSh(options.pnpmBinPath)} ${PNPM_IGNORE_MINIMUM_RELEASE_AGE} "$@"`, + `exec ${quoteSh(options.appExecutable)} --require "$runtime_clear_environment" ${quoteSh(options.pnpmBinPath)} ${PNPM_IGNORE_MINIMUM_RELEASE_AGE} "$@"`, ].join(' '), '', ].join('\n') } /** Build the private Windows Node command used only by pnpm lifecycle scripts. */ -function windowsNodeShim(appExecutable: string, clearEnvironmentUrl: string): string { +function windowsNodeShim(appExecutable: string): string { return [ '@echo off', 'setlocal DisableDelayedExpansion', + 'set "DSH_RUNTIME_PRIVATE=%~dp0.."', `set "${RUN_AS_NODE}=1"`, - `${quoteBatchWord(appExecutable)} --import ${quoteBatchWord(clearEnvironmentUrl)} %*`, + `${quoteBatchWord(appExecutable)} --require "%DSH_RUNTIME_PRIVATE%\\clear-env.cjs" %*`, 'exit /b %errorlevel%', '', ].join('\r\n') } /** Build the public Windows pnpm command. */ -function windowsPnpmShim( - options: DesktopPnpmRuntimeOptions, - nodeBinDir: string, - nodeShimPath: string, - clearEnvironmentUrl: string, -): string { +function windowsPnpmShim(options: DesktopPnpmRuntimeOptions): string { return [ '@echo off', 'setlocal DisableDelayedExpansion', - `set "PATH=${escapeBatchSetValue(nodeBinDir)};%PATH%"`, - `set "NODE=${escapeBatchSetValue(nodeShimPath)}"`, + 'set "DSH_RUNTIME_ROOT=%~dp0.."', + 'set "DSH_RUNTIME_PRIVATE=%DSH_RUNTIME_ROOT%\\private"', + 'set "DSH_RUNTIME_NODE_BIN=%DSH_RUNTIME_PRIVATE%\\node-bin"', + 'set "PATH=%DSH_RUNTIME_NODE_BIN%;%PATH%"', + 'set "NODE=%DSH_RUNTIME_NODE_BIN%\\node.cmd"', `set "${RUN_AS_NODE}=1"`, 'set "npm_config_runtime=electron"', `set "npm_config_target=${escapeBatchSetValue(options.electronVersion)}"`, `set "npm_config_disturl=${ELECTRON_HEADERS_URL}"`, - `${quoteBatchWord(options.appExecutable)} --import ${quoteBatchWord(clearEnvironmentUrl)} ${quoteBatchWord(options.pnpmBinPath)} ${PNPM_IGNORE_MINIMUM_RELEASE_AGE} %*`, + `${quoteBatchWord(options.appExecutable)} --require "%DSH_RUNTIME_PRIVATE%\\clear-env.cjs" ${quoteBatchWord(options.pnpmBinPath)} ${PNPM_IGNORE_MINIMUM_RELEASE_AGE} %*`, 'exit /b %errorlevel%', '', ].join('\r\n') @@ -305,7 +444,13 @@ function normalizedPathComponent(component: string, platform: NodeJS.Platform): const unquoted = platform === 'win32' && component.startsWith('"') && component.endsWith('"') ? component.slice(1, -1) : component - return platform === 'win32' ? unquoted.toLowerCase() : unquoted + if (platform !== 'win32' || unquoted.length === 0) return unquoted + const normalized = windowsPath.normalize(unquoted) + const root = windowsPath.parse(normalized).root + const withoutTrailingSeparators = normalized.length > root.length + ? normalized.replace(/[\\/]+$/u, '') + : normalized + return withoutTrailingSeparators.toLowerCase() } /** Remove every occurrence of one directory from a PATH value. */ @@ -323,18 +468,21 @@ function installPathDirectory( environment: NodeJS.ProcessEnv, directory: string, platform: NodeJS.Platform, + obsoleteDirectories: readonly string[] = [], ): () => void { const original = pathEntries(environment, platform) const current = original.find(entry => entry.value !== undefined) const currentValue = current?.value ?? '' const key = current?.key ?? PATH const delimiter = platform === 'win32' ? ';' : ':' - const inheritedValue = withoutPathDirectory(currentValue, directory, platform) - const firstComponent = currentValue.split(delimiter)[0] ?? '' - if (normalizedPathComponent(firstComponent, platform) === normalizedPathComponent(directory, platform)) { + const inheritedValue = [directory, ...obsoleteDirectories].reduce( + (value, candidate) => withoutPathDirectory(value, candidate, platform), + currentValue, + ) + const installedValue = inheritedValue.length === 0 ? directory : `${directory}${delimiter}${inheritedValue}` + if (installedValue === currentValue) { return () => {} } - const installedValue = inheritedValue.length === 0 ? directory : `${directory}${delimiter}${inheritedValue}` for (const entry of original) delete environment[entry.key] environment[key] = installedValue @@ -368,18 +516,24 @@ export function installDesktopDshRuntime(options: DesktopDshRuntimeOptions): Des ['state directory', options.stateDir], ] as const) assertScriptValue(label, value) - const pathDir = join(options.stateDir, 'bin') - preparePrivateDirectory(options.stateDir) - preparePrivateDirectory(pathDir) - removeStaleTemporaryFiles(pathDir, 'dsh.cmd') - assertOwnedDirectoryEntries(pathDir, ['dsh.cmd']) + const plan = runtimeGenerationPlan('dsh', options.platform, ['bin'], [{ + relativePath: 'bin/dsh.cmd', + contents: windowsDshShim(options), + mode: PRIVATE_FILE_MODE, + }]) + const generation = installRuntimeGeneration(options.stateDir, plan) + const pathDir = join(generation.root, 'bin') const dshShimPath = join(pathDir, 'dsh.cmd') - replacePrivateFile(dshShimPath, windowsDshShim(options), PRIVATE_FILE_MODE) return { pathDir, dshShimPath, - dispose: installPathDirectory(options.environment ?? process.env, pathDir, options.platform), + dispose: installPathDirectory( + options.environment ?? process.env, + pathDir, + options.platform, + generation.obsoletePathDirectories, + ), } } @@ -399,41 +553,38 @@ export function installDesktopPnpmRuntime(options: DesktopPnpmRuntimeOptions): D ['state directory', options.stateDir], ] as const) assertScriptValue(label, value) - const pathDir = join(options.stateDir, 'bin') - const privateDir = join(options.stateDir, 'private') - const nodeBinDir = join(privateDir, 'node-bin') - preparePrivateDirectory(options.stateDir) - preparePrivateDirectory(pathDir) - preparePrivateDirectory(privateDir) - preparePrivateDirectory(nodeBinDir) - const windows = options.platform === 'win32' const pnpmShimName = windows ? 'pnpm.cmd' : 'pnpm' const nodeShimName = windows ? 'node.cmd' : 'node' - removeStaleTemporaryFiles(pathDir, pnpmShimName) - removeStaleTemporaryFiles(nodeBinDir, nodeShimName) - removeStaleTemporaryFiles(privateDir, 'clear-env.mjs') - reconcileOwnedDirectoryEntries(pathDir, [pnpmShimName]) - reconcileOwnedDirectoryEntries(nodeBinDir, [nodeShimName]) + const plan = runtimeGenerationPlan( + 'pnpm', + options.platform, + ['bin', 'private', 'private/node-bin'], + [ + { + relativePath: 'private/clear-env.cjs', + contents: clearEnvironmentModule(), + mode: PRIVATE_FILE_MODE, + }, + { + relativePath: `private/node-bin/${nodeShimName}`, + contents: windows ? windowsNodeShim(options.appExecutable) : posixNodeShim(options.appExecutable), + mode: windows ? PRIVATE_FILE_MODE : EXECUTABLE_FILE_MODE, + }, + { + relativePath: `bin/${pnpmShimName}`, + contents: windows ? windowsPnpmShim(options) : posixPnpmShim(options), + mode: windows ? PRIVATE_FILE_MODE : EXECUTABLE_FILE_MODE, + }, + ], + ) + const generation = installRuntimeGeneration(options.stateDir, plan) + const pathDir = join(generation.root, 'bin') + const privateDir = join(generation.root, 'private') + const nodeBinDir = join(privateDir, 'node-bin') const pnpmShimPath = join(pathDir, pnpmShimName) const nodeShimPath = join(nodeBinDir, nodeShimName) - const clearEnvironmentPath = join(privateDir, 'clear-env.mjs') - replacePrivateFile(clearEnvironmentPath, clearEnvironmentModule(), PRIVATE_FILE_MODE) - const clearEnvironmentUrl = pathToFileURL(clearEnvironmentPath).href - replacePrivateFile( - nodeShimPath, - windows - ? windowsNodeShim(options.appExecutable, clearEnvironmentUrl) - : posixNodeShim(options.appExecutable, clearEnvironmentUrl), - windows ? PRIVATE_FILE_MODE : EXECUTABLE_FILE_MODE, - ) - replacePrivateFile( - pnpmShimPath, - windows - ? windowsPnpmShim(options, nodeBinDir, nodeShimPath, clearEnvironmentUrl) - : posixPnpmShim(options, nodeBinDir, nodeShimPath, clearEnvironmentUrl), - windows ? PRIVATE_FILE_MODE : EXECUTABLE_FILE_MODE, - ) + const clearEnvironmentPath = join(privateDir, 'clear-env.cjs') return { pathDir, @@ -441,6 +592,11 @@ export function installDesktopPnpmRuntime(options: DesktopPnpmRuntimeOptions): D nodeBinDir, nodeShimPath, clearEnvironmentPath, - dispose: installPathDirectory(options.environment ?? process.env, pathDir, options.platform), + dispose: installPathDirectory( + options.environment ?? process.env, + pathDir, + options.platform, + generation.obsoletePathDirectories, + ), } } diff --git a/dsh-plugin-desktop-beta/src/setup-wizard-settings.ts b/dsh-plugin-desktop-beta/src/setup-wizard-settings.ts index de3b9f8980..932347f9c7 100644 --- a/dsh-plugin-desktop-beta/src/setup-wizard-settings.ts +++ b/dsh-plugin-desktop-beta/src/setup-wizard-settings.ts @@ -260,7 +260,8 @@ function normalizedUpdate( }) } -function sameSettings( +/** Return whether two normalized Setup views have identical persisted leaves. */ +export function sameDesktopSetupWizardSettings( current: DesktopSetupWizardSettings, next: DesktopSetupWizardSettings, ): boolean { @@ -341,7 +342,7 @@ export async function updateDesktopSetupWizardSettings( // Explicit default leaves do not need to be materialized. Apart from making // Setup idempotent, this lets an unchanged first-run choice proceed while an // unrelated or orphaned settings writer lock exists. - if (sameSettings(projectSettings(loadSettingsDocument(path).root), next)) return next + if (sameDesktopSetupWizardSettings(projectSettings(loadSettingsDocument(path).root), next)) return next ensureDocumentDirectory(path) const loaded = loadSettingsDocument(path) // Refuse to cover an invalid known value, including the inactive platform's diff --git a/dsh-plugin-desktop-beta/tests/desktop-runtime-environment.spec.ts b/dsh-plugin-desktop-beta/tests/desktop-runtime-environment.spec.ts index ecae801a11..9a2e83e370 100644 --- a/dsh-plugin-desktop-beta/tests/desktop-runtime-environment.spec.ts +++ b/dsh-plugin-desktop-beta/tests/desktop-runtime-environment.spec.ts @@ -1,5 +1,7 @@ import { spawnSync } from 'node:child_process' +import fs from 'node:fs' import { + existsSync, lstatSync, mkdirSync, mkdtempSync, @@ -9,8 +11,9 @@ import { symlinkSync, writeFileSync, } from 'node:fs' +import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' -import { delimiter as pathDelimiter, join } from 'node:path' +import { basename, delimiter as pathDelimiter, dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -68,6 +71,12 @@ describe('desktop Host pnpm runtime', () => { expect(readdirSync(installation.pathDir)).toEqual(['pnpm']) expect(readdirSync(installation.nodeBinDir)).toEqual(['node']) + const generationRoot = dirname(installation.pathDir) + const manifest = JSON.parse(readFileSync(join(generationRoot, '.generation.json'), 'utf8')) as { + contentHash: string + } + expect(readdirSync(generationRoot).sort()).toEqual(['.generation.json', 'bin', 'private']) + expect(basename(generationRoot)).toMatch(new RegExp(`^${manifest.contentHash}-`)) if (process.platform !== 'win32') { expect(lstatSync(stateDir).mode & 0o777).toBe(0o700) expect(lstatSync(installation.pathDir).mode & 0o777).toBe(0o700) @@ -77,20 +86,20 @@ describe('desktop Host pnpm runtime', () => { expect(lstatSync(installation.clearEnvironmentPath).mode & 0o777).toBe(0o600) } - const clearEnvironmentUrl = pathToFileURL(installation.clearEnvironmentPath).href const pnpm = readFileSync(installation.pnpmShimPath, 'utf8') - expect(pnpm).toContain(`PATH='${installation.nodeBinDir}':"\${PATH:-}"`) - expect(pnpm).toContain(`NODE='${installation.nodeShimPath}'`) + expect(pnpm).toContain('runtime_root_dir=${runtime_public_dir%/*}') + expect(pnpm).toContain('PATH="$runtime_node_bin_dir:${PATH:-}"') + expect(pnpm).toContain('NODE="$runtime_node_shim"') expect(pnpm).toContain('ELECTRON_RUN_AS_NODE=1 npm_config_runtime=electron') expect(pnpm).toContain("npm_config_target='43.4.0'") expect(pnpm).toContain("npm_config_disturl='https://electronjs.org/headers'") expect(pnpm.match(/--config\.minimumReleaseAge=0/gu)).toHaveLength(1) expect(pnpm).toContain('--config.minimumReleaseAge=0 "$@"') - expect(pnpm).toContain(`--import '${clearEnvironmentUrl}'`) - expect(pnpm.indexOf(`--import '${clearEnvironmentUrl}'`)).toBeLessThan(pnpm.indexOf('pnpm/bin/pnpm.mjs')) + expect(pnpm).toContain('--require "$runtime_clear_environment"') + expect(pnpm.indexOf('--require "$runtime_clear_environment"')).toBeLessThan(pnpm.indexOf('pnpm/bin/pnpm.mjs')) const node = readFileSync(installation.nodeShimPath, 'utf8') expect(node).toContain(`ELECTRON_RUN_AS_NODE=1 exec`) - expect(node).toContain(`--import '${clearEnvironmentUrl}' "$@"`) + expect(node).toContain('--require "$runtime_clear_environment" "$@"') expect(node).not.toContain('npm_config_') expect(readFileSync(installation.clearEnvironmentPath, 'utf8')).toContain( "name.toUpperCase() === 'ELECTRON_RUN_AS_NODE'", @@ -178,7 +187,7 @@ describe('desktop Host pnpm runtime', () => { const command = process.platform === 'win32' ? process.env.ComSpec ?? 'cmd.exe' - : installation.pnpmShimPath + : 'pnpm' const args = process.platform === 'win32' ? ['/d', '/s', '/c', `""${installation.pnpmShimPath}" "${captureOutput}""`] : [captureOutput] @@ -217,21 +226,22 @@ describe('desktop Host pnpm runtime', () => { expect(readdirSync(installation.pathDir)).toEqual(['pnpm.cmd']) expect(readdirSync(installation.nodeBinDir)).toEqual(['node.cmd']) - const clearEnvironmentUrl = pathToFileURL(installation.clearEnvironmentPath).href - const escapedClearEnvironmentUrl = clearEnvironmentUrl.replaceAll('%', '%%') const pnpm = readFileSync(installation.pnpmShimPath, 'utf8') - expect(pnpm).toContain(`set "PATH=${installation.nodeBinDir};%PATH%"`) - expect(pnpm).toContain(`set "NODE=${installation.nodeShimPath}"`) + expect(pnpm).toContain('set "DSH_RUNTIME_ROOT=%~dp0.."') + expect(pnpm).toContain('set "PATH=%DSH_RUNTIME_NODE_BIN%;%PATH%"') + expect(pnpm).toContain('set "NODE=%DSH_RUNTIME_NODE_BIN%\\node.cmd"') expect(pnpm).toContain('set "ELECTRON_RUN_AS_NODE=1"') expect(pnpm).toContain('set "npm_config_runtime=electron"') expect(pnpm).toContain('set "npm_config_target=43.4.0"') - expect(pnpm).toContain(`--import "${escapedClearEnvironmentUrl}"`) - expect(pnpm.indexOf(`--import "${escapedClearEnvironmentUrl}"`)).toBeLessThan(pnpm.indexOf('pnpm\\bin\\pnpm.mjs')) + expect(pnpm).toContain('--require "%DSH_RUNTIME_PRIVATE%\\clear-env.cjs"') + expect(pnpm.indexOf('--require "%DSH_RUNTIME_PRIVATE%\\clear-env.cjs"')).toBeLessThan( + pnpm.indexOf('pnpm\\bin\\pnpm.mjs'), + ) expect(pnpm.match(/--config\.minimumReleaseAge=0/gu)).toHaveLength(1) expect(pnpm).toContain('--config.minimumReleaseAge=0 %*') const node = readFileSync(installation.nodeShimPath, 'utf8') expect(node).toContain('set "ELECTRON_RUN_AS_NODE=1"') - expect(node).toContain(`--import "${escapedClearEnvironmentUrl}" %*`) + expect(node).toContain('--require "%DSH_RUNTIME_PRIVATE%\\clear-env.cjs" %*') expect(node).not.toContain('npm_config_') expect(environment).toEqual({ @@ -261,6 +271,28 @@ describe('desktop Host pnpm runtime', () => { expect(environment).toEqual({ PATH: `${laterPath}${pathDelimiter}${originalPath}` }) }) + it('normalizes Windows separators and trailing separators when replacing legacy PATH entries', () => { + const root = temporaryDirectory() + const stateDir = join(root, 'runtime-commands') + const environment: NodeJS.ProcessEnv = { Path: 'C:\\Windows' } + const first = installDesktopPnpmRuntime(options(stateDir, 'win32', environment)) + first.dispose() + const legacyPathDir = join(stateDir, 'bin') + mkdirSync(legacyPathDir, { recursive: true }) + writeFileSync(join(legacyPathDir, 'bsk.exe'), 'legacy unknown command') + const legacyVariant = `${legacyPathDir.replaceAll('/', '\\')}\\` + const activeVariant = `${first.pathDir.replaceAll('/', '\\')}\\` + const systemPath = 'C:/Windows/System32/' + environment.Path = `"${legacyVariant}";"${activeVariant}";${systemPath}` + + const second = installDesktopPnpmRuntime(options(stateDir, 'win32', environment)) + + expect(second.pathDir).toBe(first.pathDir) + expect(environment.Path).toBe(`${second.pathDir};${systemPath}`) + second.dispose() + expect(environment.Path).toBe(`"${legacyVariant}";"${activeVariant}";${systemPath}`) + }) + it.runIf(process.platform === 'win32')('reclaims PATH precedence from an inherited Desktop terminal shim', () => { const root = temporaryDirectory() const stateDir = join(root, 'runtime') @@ -306,17 +338,24 @@ describe('desktop Host pnpm runtime', () => { expect(environment).toEqual(original) }) - it('does not duplicate or remove a runtime already first on PATH', () => { + it('reuses an exact immutable generation without rewriting it', () => { const stateDir = join(temporaryDirectory(), 'runtime') - const pathDir = join(stateDir, 'bin') const platform = process.platform === 'win32' ? 'win32' : 'linux' const originalPath = process.platform === 'win32' ? 'C:\\Windows' : '/usr/bin' - const environment: NodeJS.ProcessEnv = { PATH: `${pathDir}${pathDelimiter}${originalPath}` } - const installation = installDesktopPnpmRuntime(options(stateDir, platform, environment)) + const environment: NodeJS.ProcessEnv = { PATH: originalPath } + const first = installDesktopPnpmRuntime(options(stateDir, platform, environment)) + const firstStat = lstatSync(first.pnpmShimPath) + first.dispose() + environment.PATH = `${first.pathDir}${pathDelimiter}${originalPath}` - expect(environment.PATH).toBe(`${pathDir}${pathDelimiter}${originalPath}`) - installation.dispose() - expect(environment.PATH).toBe(`${pathDir}${pathDelimiter}${originalPath}`) + const second = installDesktopPnpmRuntime(options(stateDir, platform, environment)) + + expect(second.pathDir).toBe(first.pathDir) + expect(lstatSync(second.pnpmShimPath).ino).toBe(firstStat.ino) + expect(lstatSync(second.pnpmShimPath).mtimeMs).toBe(firstStat.mtimeMs) + expect(environment.PATH).toBe(`${first.pathDir}${pathDelimiter}${originalPath}`) + second.dispose() + expect(environment.PATH).toBe(`${first.pathDir}${pathDelimiter}${originalPath}`) }) it('rejects symlinked state directories before changing PATH', () => { @@ -332,89 +371,86 @@ describe('desktop Host pnpm runtime', () => { expect(environment).toEqual({ PATH: '/usr/bin' }) }) - it('rejects a symlinked generated file before changing PATH', () => { + it('publishes a clean sibling instead of trusting a contaminated generation', () => { const root = temporaryDirectory() const stateDir = join(root, 'runtime') - const pathDir = join(stateDir, 'bin') - const privateDir = join(stateDir, 'private') - const nodeBinDir = join(privateDir, 'node-bin') - mkdirSync(pathDir, { recursive: true }) - mkdirSync(nodeBinDir, { recursive: true }) + const environment: NodeJS.ProcessEnv = { PATH: '/usr/bin' } + const first = installDesktopPnpmRuntime(options(stateDir, 'linux', environment)) + first.dispose() const target = join(root, 'outside') writeFileSync(target, 'outside') - symlinkSync(target, join(pathDir, 'pnpm')) - const environment: NodeJS.ProcessEnv = { PATH: '/usr/bin' } + rmSync(first.pnpmShimPath) + symlinkSync(target, first.pnpmShimPath) + const unknownExecutable = join(first.pathDir, 'bsk.exe') + writeFileSync(unknownExecutable, 'locked or foreign') + environment.PATH = `${first.pathDir}:/usr/bin` - expect(() => installDesktopPnpmRuntime(options(stateDir, 'linux', environment))) - .toThrow('not a regular file') + const second = installDesktopPnpmRuntime(options(stateDir, 'linux', environment)) + + expect(second.pathDir).not.toBe(first.pathDir) + expect(readdirSync(second.pathDir)).toEqual(['pnpm']) + expect(environment.PATH).toBe(`${second.pathDir}:/usr/bin`) + expect(readFileSync(unknownExecutable, 'utf8')).toBe('locked or foreign') expect(readFileSync(target, 'utf8')).toBe('outside') - expect(environment).toEqual({ PATH: '/usr/bin' }) + second.dispose() }) - it('recovers from stray command files instead of failing startup', () => { + it('leaves legacy unknown files untouched and excludes their directories from PATH', () => { const root = temporaryDirectory() const stateDir = join(root, 'runtime') - const pathDir = join(stateDir, 'bin') - const nodeBinDir = join(stateDir, 'private', 'node-bin') - mkdirSync(pathDir, { recursive: true }) - mkdirSync(nodeBinDir, { recursive: true }) - writeFileSync(join(pathDir, 'dsh'), 'stray') - writeFileSync(join(nodeBinDir, 'dsh'), 'stray') - const environment: NodeJS.ProcessEnv = { PATH: '/usr/bin' } - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const legacyPathDir = join(stateDir, 'bin') + const legacyNodeBinDir = join(stateDir, 'private', 'node-bin') + mkdirSync(legacyPathDir, { recursive: true }) + mkdirSync(legacyNodeBinDir, { recursive: true }) + const publicUnknown = join(legacyPathDir, 'bsk.exe') + const privateUnknown = join(legacyNodeBinDir, 'bsk.exe') + writeFileSync(publicUnknown, 'locked public command') + writeFileSync(privateUnknown, 'locked private command') + const environment: NodeJS.ProcessEnv = { + PATH: `${legacyPathDir}:${legacyNodeBinDir}:/usr/bin`, + } const installation = installDesktopPnpmRuntime(options(stateDir, 'linux', environment)) - expect(readdirSync(pathDir)).toEqual(['pnpm']) - expect(readdirSync(nodeBinDir)).toEqual(['node']) - expect(environment.PATH).toBe(`${pathDir}:/usr/bin`) - expect(stderr).toHaveBeenCalledTimes(2) + expect(installation.pathDir).not.toBe(legacyPathDir) + expect(environment.PATH).toBe(`${installation.pathDir}:/usr/bin`) + expect(readFileSync(publicUnknown, 'utf8')).toBe('locked public command') + expect(readFileSync(privateUnknown, 'utf8')).toBe('locked private command') installation.dispose() }) - it('removes stray symlinks without touching their targets', () => { + it('does not attempt to delete a published generation that could still be locked', () => { const root = temporaryDirectory() const stateDir = join(root, 'runtime') - const pathDir = join(stateDir, 'bin') - mkdirSync(pathDir, { recursive: true }) - const target = join(root, 'outside') - writeFileSync(target, 'outside') - symlinkSync(target, join(pathDir, 'dsh')) - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - - const installation = installDesktopPnpmRuntime(options(stateDir, 'linux', { PATH: '/usr/bin' })) - - expect(readdirSync(pathDir)).toEqual(['pnpm']) - expect(readFileSync(target, 'utf8')).toBe('outside') - expect(stderr).toHaveBeenCalledOnce() - installation.dispose() - }) - - it('refuses an unexpected directory in the public command directory', () => { - const root = temporaryDirectory() - const stateDir = join(root, 'runtime') - const pathDir = join(stateDir, 'bin') - mkdirSync(join(pathDir, 'dsh'), { recursive: true }) const environment: NodeJS.ProcessEnv = { PATH: '/usr/bin' } + const first = installDesktopPnpmRuntime(options(stateDir, 'linux', environment)) + const firstRoot = dirname(first.pathDir) + first.dispose() + const originalRmSync = fs.rmSync + const rm = vi.spyOn(fs, 'rmSync').mockImplementation((filename, rmOptions) => { + if (filename === firstRoot) { + throw Object.assign(new Error('locked'), { code: 'EPERM' }) + } + return originalRmSync(filename, rmOptions) + }) + syncBuiltinESMExports() - expect(() => installDesktopPnpmRuntime(options(stateDir, 'linux', environment))) - .toThrow('contains an unexpected directory: dsh') - expect(environment).toEqual({ PATH: '/usr/bin' }) - }) + let second: ReturnType | undefined + try { + second = installDesktopPnpmRuntime({ + ...options(stateDir, 'linux', environment), + electronVersion: '44.0.0', + }) + } finally { + rm.mockRestore() + syncBuiltinESMExports() + } - it('removes only strictly named stale atomic files before validating commands', () => { - const root = temporaryDirectory() - const stateDir = join(root, 'runtime') - const pathDir = join(stateDir, 'bin') - mkdirSync(pathDir, { recursive: true }) - const stale = join(pathDir, '.pnpm.123.123e4567-e89b-12d3-a456-426614174000.tmp') - writeFileSync(stale, 'partial') - const environment: NodeJS.ProcessEnv = { PATH: '/usr/bin' } - - const installation = installDesktopPnpmRuntime(options(stateDir, 'linux', environment)) - - expect(readdirSync(pathDir)).toEqual(['pnpm']) - installation.dispose() + expect(second).toBeDefined() + expect(environment.PATH).toBe(`${second?.pathDir}:/usr/bin`) + expect(existsSync(firstRoot)).toBe(true) + expect(rm.mock.calls.some(([filename]) => filename === firstRoot)).toBe(false) + second?.dispose() }) it('fails loud for unsupported platforms and unsafe generated values', () => { @@ -429,6 +465,32 @@ describe('desktop Host pnpm runtime', () => { }) describe('desktop Host dsh runtime', () => { + it('content-addresses Windows DSH shims and reuses an exact generation', () => { + const root = temporaryDirectory() + const stateDir = join(root, 'runtime') + const environment: NodeJS.ProcessEnv = { Path: 'C:\\Windows' } + const runtimeOptions = { + platform: 'win32' as const, + appExecutable: 'C:\\Program Files\\DSH Desktop\\DSH Desktop.exe', + dshBootstrapPath: 'C:\\Program Files\\DSH Desktop\\resources\\app.asar\\desktop-cli.js', + profileName: 'web', + homeDir: 'C:\\Users\\tester\\.dsh', + stateDir, + environment, + } + + const first = installDesktopDshRuntime(runtimeOptions) + const firstRoot = dirname(first.pathDir) + first.dispose() + const second = installDesktopDshRuntime(runtimeOptions) + + expect(second.pathDir).toBe(first.pathDir) + expect(readdirSync(second.pathDir)).toEqual(['dsh.cmd']) + expect(readdirSync(firstRoot).sort()).toEqual(['.generation.json', 'bin']) + expect(readFileSync(second.dshShimPath, 'utf8')).toContain('set "DSH_DESKTOP_DEFAULT_PROFILE=web"') + second.dispose() + }) + it.runIf(process.platform === 'win32')('makes the active profile available to Host plugin child processes', () => { const root = temporaryDirectory() const stateDir = join(root, 'runtime')