diff --git a/dsh-plugin-desktop/src/crash-evidence.ts b/dsh-plugin-desktop/src/crash-evidence.ts index 62a85940a4..4c97693f26 100644 --- a/dsh-plugin-desktop/src/crash-evidence.ts +++ b/dsh-plugin-desktop/src/crash-evidence.ts @@ -1,5 +1,21 @@ -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' -import { dirname } from 'node:path' +import { randomUUID } from 'node:crypto' +import { + chmodSync, + closeSync, + constants, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { basename, dirname, join } from 'node:path' + +const PRIVATE_DIRECTORY_MODE = 0o700 +const PRIVATE_FILE_MODE = 0o600 /** Minimal Electron crash reporter surface used before the app is ready. */ export interface DesktopCrashReporter { @@ -51,34 +67,111 @@ export interface DesktopRun { markClean(): void } -function readPreviousRun(statePath: string): DesktopRunRecord | UnreadableDesktopRun | undefined { - if (!existsSync(statePath)) return undefined +interface StoredDesktopRun extends DesktopRunRecord { + readonly ownerId?: string +} + +function lstatOptional(filename: string): ReturnType | undefined { try { - const value: unknown = JSON.parse(readFileSync(statePath, 'utf8')) + return lstatSync(filename) + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw cause + } +} + +function assertOwnedMarker(stats: NonNullable>): void { + if (stats.isSymbolicLink() || !stats.isFile() || stats.nlink > 1) { + throw new Error('dsh-plugin-desktop: active run marker is invalid') + } +} + +function readStoredRun(statePath: string): StoredDesktopRun | UnreadableDesktopRun | undefined { + const pathStats = lstatOptional(statePath) + if (pathStats === undefined) return undefined + assertOwnedMarker(pathStats) + const descriptor = openSync(statePath, constants.O_RDONLY | noFollowFlag()) + try { + assertOwnedMarker(fstatSync(descriptor)) + const value: unknown = JSON.parse(readFileSync(descriptor, 'utf8')) if (typeof value !== 'object' || value === null) return { unreadable: true } - const record = value as Partial + const record = value as Partial if (typeof record.startedAt !== 'string' || typeof record.pid !== 'number' || typeof record.version !== 'string') return { unreadable: true } - return { startedAt: record.startedAt, pid: record.pid, version: record.version } + return { + startedAt: record.startedAt, + pid: record.pid, + version: record.version, + ...(typeof record.ownerId === 'string' ? { ownerId: record.ownerId } : {}), + } } catch (cause) { if (cause instanceof SyntaxError) return { unreadable: true } throw cause + } finally { + closeSync(descriptor) + } +} + +function unlinkTemporary(filename: string): void { + try { + unlinkSync(filename) + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause + } +} + +function writeCurrentRun(statePath: string, currentRun: StoredDesktopRun): void { + const directory = dirname(statePath) + mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }) + const directoryStats = lstatSync(directory) + if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) { + throw new Error('dsh-plugin-desktop: active run directory is invalid') + } + try { chmodSync(directory, PRIVATE_DIRECTORY_MODE) } catch {} + + const temporary = join(directory, `.${basename(statePath)}.${process.pid}.${randomUUID()}.tmp`) + try { + writeFileSync(temporary, `${JSON.stringify(currentRun)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: PRIVATE_FILE_MODE, + }) + try { chmodSync(temporary, PRIVATE_FILE_MODE) } catch {} + renameSync(temporary, statePath) + } finally { + unlinkTemporary(temporary) } } /** Persist this launch and return a marker left behind by an unclean exit. */ export function beginDesktopRun(statePath: string, currentRun: DesktopRunRecord): DesktopRun { - const previousRun = readPreviousRun(statePath) - mkdirSync(dirname(statePath), { recursive: true }) - writeFileSync(statePath, `${JSON.stringify(currentRun)}\n`, 'utf8') + const storedPreviousRun = readStoredRun(statePath) + const previousRun = storedPreviousRun === undefined || 'unreadable' in storedPreviousRun + ? storedPreviousRun + : { + startedAt: storedPreviousRun.startedAt, + pid: storedPreviousRun.pid, + version: storedPreviousRun.version, + } + const ownerId = randomUUID() + writeCurrentRun(statePath, { ...currentRun, ownerId }) let clean = false return { previousRun, markClean() { if (clean) return - clean = true + const storedRun = readStoredRun(statePath) + if (storedRun === undefined || 'unreadable' in storedRun || storedRun.ownerId !== ownerId) { + clean = true + return + } unlinkSync(statePath) + clean = true }, } } + +function noFollowFlag(): number { + return process.platform === 'win32' ? 0 : constants.O_NOFOLLOW +} diff --git a/dsh-plugin-desktop/tests/crash-evidence.spec.ts b/dsh-plugin-desktop/tests/crash-evidence.spec.ts index 9996ef0c3c..74555087ae 100644 --- a/dsh-plugin-desktop/tests/crash-evidence.spec.ts +++ b/dsh-plugin-desktop/tests/crash-evidence.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { existsSync, linkSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' @@ -81,4 +81,39 @@ describe('desktop crash evidence', () => { expect(run.previousRun).toEqual({ unreadable: true }) expect(() => run.markClean()).not.toThrow() }) + + it('only clears the marker owned by the current run', () => { + const statePath = join(mkdtempSync(join(tmpdir(), 'dsh-run-')), 'active-run.json') + const first = beginDesktopRun(statePath, { + startedAt: '2026-08-18T00:00:00.000Z', + pid: 41, + version: '2.0.1', + }) + const second = beginDesktopRun(statePath, { + startedAt: '2026-08-18T00:01:00.000Z', + pid: 42, + version: '2.0.1', + }) + + first.markClean() + + expect(JSON.parse(readFileSync(statePath, 'utf8'))).toMatchObject({ pid: 42 }) + expect(() => second.markClean()).not.toThrow() + expect(existsSync(statePath)).toBe(false) + }) + + it('does not read or overwrite a linked marker', () => { + const directory = mkdtempSync(join(tmpdir(), 'dsh-run-')) + const target = join(directory, 'outside.json') + const statePath = join(directory, 'active-run.json') + writeFileSync(target, '{"outside":true}\n', 'utf8') + linkSync(target, statePath) + + expect(() => beginDesktopRun(statePath, { + startedAt: '2026-08-18T00:01:00.000Z', + pid: 42, + version: '2.0.1', + })).toThrow('active run marker is invalid') + expect(readFileSync(target, 'utf8')).toBe('{"outside":true}\n') + }) })