/** * Profile snapshots — issue #98 (phase 3), implementing the snapshot half of * #19: before any ordering / preset change is applied, the profile's * composition-critical files are captured as a timestamped snapshot; a failed * or unwanted change can be rolled back in one step. * * A snapshot is a single JSON document under `/.dsh-market/ * snapshots/-.json` describing the files that define the * profile's composition: package.json (dependency + bundle list), * cordis.patch.yml (the user patch layer) and state.json (market disable * list + groups). Version 2 records optional-file absence explicitly; * restoring validates every path, reconciles writes and deletions, and rolls * completed actions back if a later action fails. * * Retention: after every create, snapshots are pruned to the most recent * `maxSnapshots` (default 20); the cap is configurable through the market * plugin config (`maxSnapshots`) and each snapshot can also be deleted * individually from the UI. */ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { logEvent } from './log.ts' const SNAPSHOT_DIR = join('.dsh-market', 'snapshots') const SNAPSHOT_FORMAT = 'dsh-market/profile-snapshot' const SNAPSHOT_VERSION = 2 /** * Snapshot ids are generated by nextSnapshotId and must never be attacker- * shaped: anything else (paths, '..', absolute paths) is refused BEFORE it * reaches the filesystem, so restoreSnapshot/deleteSnapshot cannot escape the * snapshots directory (issue #98 review B3 — same discipline as backup.ts). */ const SNAPSHOT_ID_RE = /^snapshot-[0-9A-Za-z-]+$/ function validSnapshotId(id: string): boolean { return SNAPSHOT_ID_RE.test(id) } /** The files a snapshot captures, relative to the profile directory. */ const SNAPSHOT_FILES = ['package.json', 'cordis.patch.yml', '.dsh-market/state.json'] as const interface SnapshotFile { path: string /** JSON documents keep their parsed form (re-serialized on restore). */ json?: unknown /** Line-oriented text files (cordis.patch.yml) keep their lines. */ lines?: string[] /** V2 records absence explicitly so restore can remove a later file. */ absent?: true } export interface ProfileSnapshot { /** Present on exact snapshots; omitted by legacy snapshots. */ format?: typeof SNAPSHOT_FORMAT /** Version 2 records all tracked paths, including explicit absence. */ version?: typeof SNAPSHOT_VERSION /** Snapshot id: the file basename without the .json suffix. */ id: string createdAt: number files: SnapshotFile[] } export type SnapshotCaptureResult = | { ok: true; snapshot: ProfileSnapshot } | { ok: false; error: string } interface NormalizedSnapshot { snapshot: ProfileSnapshot files: SnapshotFile[] } type SnapshotValidation = | { ok: true; value: NormalizedSnapshot } | { ok: false; error: string } function hasOwn(value: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key) } /** * Strict shape validation for a parsed snapshot document (issue #98 analysis: * snapshot JSON validation). A snapshot that fails these checks is corrupt — * it can never be restored safely, so listing it would only offer the user a * restore that must fail. */ function validateSnapshotDocument(value: unknown): SnapshotValidation { if (value === null || typeof value !== 'object') { return { ok: false, error: 'corrupt snapshot document / 快照文档损坏' } } const snap = value as { format?: unknown; version?: unknown; id?: unknown; createdAt?: unknown; files?: unknown } if (typeof snap.id !== 'string' || !validSnapshotId(snap.id)) { return { ok: false, error: 'corrupt snapshot id / 快照 id 损坏' } } if (typeof snap.createdAt !== 'number' || !Number.isFinite(snap.createdAt)) { return { ok: false, error: 'corrupt snapshot timestamp / 快照时间损坏' } } if (!Array.isArray(snap.files)) { return { ok: false, error: 'corrupt snapshot: files is not an array / 快照损坏:files 不是数组' } } // Existing unversioned documents are legacy v1. Their omitted optional // paths remain no-ops: older capture also omitted unreadable/invalid files, // so absence cannot be inferred safely after the fact. const legacy = !hasOwn(snap, 'format') && !hasOwn(snap, 'version') if (!legacy && (snap.format !== SNAPSHOT_FORMAT || snap.version !== SNAPSHOT_VERSION)) { return { ok: false, error: 'unsupported snapshot format or version / 不支持的快照格式或版本' } } const byPath = new Map() for (const file of snap.files) { if (file === null || typeof file !== 'object') { return { ok: false, error: 'corrupt snapshot file entry / 快照文件条目损坏' } } const entry = file as { path?: unknown; json?: unknown; lines?: unknown; absent?: unknown } if (typeof entry.path !== 'string' || !(SNAPSHOT_FILES as readonly string[]).includes(entry.path) || entry.path.includes('..')) { return { ok: false, error: `unsafe snapshot path: ${String(entry.path)} / 快照路径不安全` } } if (byPath.has(entry.path)) { return { ok: false, error: `duplicate snapshot path: ${entry.path} / 快照路径重复` } } const hasJson = hasOwn(entry, 'json') const hasLines = hasOwn(entry, 'lines') const hasAbsent = hasOwn(entry, 'absent') if (Number(hasJson) + Number(hasLines) + Number(hasAbsent) !== 1) { return { ok: false, error: `snapshot file ${entry.path} must have exactly one representation / 快照文件表示无效` } } if (hasLines && (!Array.isArray(entry.lines) || !entry.lines.every(line => typeof line === 'string'))) { return { ok: false, error: `snapshot file ${entry.path} has invalid lines / 快照文件行无效` } } if (hasAbsent && (legacy || entry.absent !== true || entry.path === 'package.json')) { return { ok: false, error: `snapshot file ${entry.path} has invalid absence marker / 快照文件缺失标记无效` } } if ((entry.path === 'package.json' || entry.path === '.dsh-market/state.json') && !hasJson && !hasAbsent) { return { ok: false, error: `snapshot file ${entry.path} has the wrong representation / 快照文件表示类型错误` } } if (entry.path === 'cordis.patch.yml' && !hasLines && !hasAbsent) { return { ok: false, error: `snapshot file ${entry.path} has the wrong representation / 快照文件表示类型错误` } } byPath.set(entry.path, entry as SnapshotFile) } if (!legacy && SNAPSHOT_FILES.some(path => !byPath.has(path))) { return { ok: false, error: 'v2 snapshot does not describe every composition file / v2 快照未描述全部组合文件' } } if (!byPath.has('package.json')) { return { ok: false, error: 'snapshot has no package.json / 快照缺少 package.json' } } const files = SNAPSHOT_FILES.flatMap(path => { const file = byPath.get(path) return file === undefined ? [] : [file] }) return { ok: true, value: { snapshot: { ...(snap as ProfileSnapshot), files }, files, }, } } /** * Atomic same-directory replace (write temp + rename): a crash mid-write can * never leave a snapshot file truncated — a half-written snapshot would parse * as corrupt forever and could not be restored. */ function writeFileAtomic(file: string, content: string): void { const temp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` try { writeFileSync(temp, content) renameSync(temp, file) } catch (error) { try { rmSync(temp, { force: true }) } catch { /* best-effort temp cleanup */ } throw error } } function isMissingFileError(error: unknown): boolean { return error !== null && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === 'ENOENT' } /** * `readFileSync` also reports ENOENT for a dangling symlink. Check the path * entry itself before recording absence so an unreadable composition path can * never be silently converted into a delete instruction for a later restore. */ function isTrulyAbsent(path: string, readError: unknown): boolean { if (!isMissingFileError(readError)) return false try { lstatSync(path) return false } catch (statError) { return isMissingFileError(statError) } } /** Absolute snapshot directory for a profile. */ function snapshotDir(profileDir: string): string { return join(profileDir, SNAPSHOT_DIR) } function snapshotFile(profileDir: string, id: string): string { if (!validSnapshotId(id)) throw new Error('invalid snapshot id / 无效的快照 id') return join(snapshotDir(profileDir), `${id}.json`) } /** Basename of the next snapshot (timestamp + sequence for same-ms saves). */ function nextSnapshotId(profileDir: string): string { const dir = snapshotDir(profileDir) let names: string[] = [] try { names = readdirSync(dir) } catch { /* directory absent — no collisions */ } const now = new Date().toISOString().replace(/[:.]/g, '-') let seq = 0 let id = `snapshot-${now}` while (names.includes(`${id}.json`)) { seq += 1 id = `snapshot-${now}-${String(seq)}` } return id } /** Default number of snapshots retained; configurable via `maxSnapshots`. */ export const DEFAULT_MAX_SNAPSHOTS = 20 function captureError(path: string, reason: string, reasonZh: string): string { return `profile composition could not be captured: ${path} ${reason} / 无法捕获 profile 组合:${path} ${reasonZh}` } function errorCode(error: unknown): string | undefined { if (error === null || typeof error !== 'object' || !('code' in error)) return undefined return typeof error.code === 'string' ? error.code : undefined } function captureReadError(path: string, error: unknown): string { const code = errorCode(error) const suffix = code === undefined ? '' : ` (${code})` return captureError(path, `could not be read${suffix}`, `无法读取${suffix}`) } /** * Prune the snapshot directory to the `max` most recent snapshots (newest * first). Extra ones are deleted oldest-first. A non-positive cap is clamped * to 1 — a 0/negative max must never drop the snapshot that was just created * nor invert to keeping the OLDEST set (issue #98 review hardening). * @returns the ids that were deleted. */ export function pruneSnapshots(profileDir: string, max: number): string[] { return pruneSnapshotsKeeping(profileDir, max) } /** Refuse an existing parent that could redirect a restore outside the profile. */ function ensureSafeRestoreParent(profileDir: string, parent: string, snapshotPath: string): void { const root = resolve(profileDir) const relativeParent = relative(root, resolve(parent)) if (relativeParent === '') return if (isAbsolute(relativeParent) || relativeParent === '..' || relativeParent.startsWith(`..${sep}`)) { throw new Error(`unsafe snapshot restore path: ${snapshotPath} / 快照恢复路径不安全`) } let current = root for (const part of relativeParent.split(sep)) { current = resolve(current, part) let stat try { stat = lstatSync(current) } catch (error) { if (isMissingFileError(error)) return throw error } if (stat.isSymbolicLink() || !stat.isDirectory()) { throw new Error(`unsafe snapshot restore path: ${snapshotPath} / 快照恢复路径不安全`) } } } interface SnapshotIdOrderKey { category: 0 | 1 primary: string sequence: bigint fallback: string } function snapshotIdOrderKey(id: string): SnapshotIdOrderKey { const generated = /^(snapshot-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)(?:-(\d+))?$/ const match = generated.exec(id) return match === null ? { category: 1, primary: id, sequence: 0n, fallback: id } : { category: 0, primary: match[1]!, sequence: BigInt(match[2] ?? '0'), fallback: id } } /** @internal Deterministic newest-first order for equal-createdAt documents. */ export function compareSnapshotIdsNewest(a: string, b: string): number { const aKey = snapshotIdOrderKey(a) const bKey = snapshotIdOrderKey(b) if (aKey.category !== bKey.category) return aKey.category - bKey.category if (aKey.primary !== bKey.primary) return aKey.primary < bKey.primary ? 1 : -1 if (aKey.sequence !== bKey.sequence) return aKey.sequence < bKey.sequence ? 1 : -1 return aKey.fallback === bKey.fallback ? 0 : aKey.fallback < bKey.fallback ? 1 : -1 } function pruneSnapshotsKeeping(profileDir: string, max: number, keepId?: string): string[] { const cap = Math.max(1, max) const all = listSnapshots(profileDir) if (all.length <= cap) return [] // Sequence-suffixed ids can share createdAt. Creation must pin its own id // ahead of that tie so it never returns a snapshot pruning just removed. const ordered = keepId === undefined ? all : [...all.filter(snap => snap.id === keepId), ...all.filter(snap => snap.id !== keepId)] const dropped = ordered.slice(cap) for (const snap of dropped) deleteSnapshot(profileDir, snap.id) if (dropped.length > 0) { logEvent('info', 'snapshot', `pruned ${dropped.length} old snapshot(s) (cap ${cap}): ${dropped.map(s => s.id).join(', ')}`) } return dropped.map(s => s.id) } /** * Capture the profile's composition into a version 2 snapshot. Missing * optional files are represented explicitly. An existing optional file that * cannot be read makes capture fail. Invalid market state is represented as * absent because every state reader already observes it as empty and the next * state write replaces it. A missing, unreadable, or invalid package.json is * not snapshot-able. After creating, the directory is pruned to the most * recent `maxSnapshots`. */ export function createProfileSnapshot(profileDir: string, maxSnapshots: number = DEFAULT_MAX_SNAPSHOTS): SnapshotCaptureResult { const packagePath = join(profileDir, 'package.json') let packageText: string try { packageText = readFileSync(packagePath, 'utf8') } catch (error) { const failure = isTrulyAbsent(packagePath, error) ? captureError('package.json', 'is missing', '缺失') : captureReadError('package.json', error) logEvent('error', 'snapshot', failure) return { ok: false, error: failure } } let packageJson: unknown try { packageJson = JSON.parse(packageText) } catch { const failure = captureError('package.json', 'contains invalid JSON', '包含无效 JSON') logEvent('error', 'snapshot', failure) return { ok: false, error: failure } } const files: SnapshotFile[] = [{ path: 'package.json', json: packageJson }] for (const path of SNAPSHOT_FILES.slice(1)) { const absolutePath = join(profileDir, path) let text: string try { text = readFileSync(absolutePath, 'utf8') } catch (error) { if (isTrulyAbsent(absolutePath, error)) files.push({ path, absent: true }) else { const failure = captureReadError(path, error) logEvent('error', 'snapshot', failure) return { ok: false, error: failure } } continue } if (path === '.dsh-market/state.json') { try { files.push({ path, json: JSON.parse(text) }) } catch { // readMarketState treats malformed state as empty and every state write // replaces it, so absence is the exact observable composition state. files.push({ path, absent: true }) logEvent('warn', 'snapshot', `${path} contains invalid JSON; captured as absent because Market reads it as empty`) } } else { files.push({ path, lines: text.split('\n') }) } } const id = nextSnapshotId(profileDir) const snapshot: ProfileSnapshot = { format: SNAPSHOT_FORMAT, version: SNAPSHOT_VERSION, id, createdAt: Date.now(), files, } mkdirSync(snapshotDir(profileDir), { recursive: true, mode: 0o700 }) writeFileAtomic(snapshotFile(profileDir, id), `${JSON.stringify(snapshot, null, 2)}\n`) logEvent('info', 'snapshot', `created ${id} (${files.map(file => file.path).join(', ')})`) pruneSnapshotsKeeping(profileDir, maxSnapshots, id) return { ok: true, snapshot } } /** * All snapshots for a profile, newest first. Files that are unparseable, * shape-invalid (bad id/createdAt/files), or whose internal id does not match * the file name are skipped — a snapshot that could never be restored is not * listed (issue #98 analysis: snapshot JSON validation). */ export function listSnapshots(profileDir: string): ProfileSnapshot[] { let names: string[] try { names = readdirSync(snapshotDir(profileDir)).filter(name => name.endsWith('.json')) } catch { return [] } const snapshots: ProfileSnapshot[] = [] for (const name of names) { try { const id = name.slice(0, -5) const value = JSON.parse(readFileSync(snapshotFile(profileDir, id), 'utf8')) as unknown const validated = validateSnapshotDocument(value) if (validated.ok && validated.value.snapshot.id === id) snapshots.push(validated.value.snapshot) } catch { /* corrupt snapshot — skip */ } } return snapshots.sort((a, b) => b.createdAt - a.createdAt || compareSnapshotIdsNewest(a.id, b.id)) } /** * Restore a snapshot's files into the profile. Version 2 absence markers * remove files created after capture; omitted optional files in unversioned * legacy snapshots remain untouched. Every path is validated before mutation, * each write uses a same-directory temp + rename, and a failure mid-restore * rolls completed writes/deletions back to their pre-restore contents. * @returns the paths restored; an error result when the snapshot is unsafe, * unknown, or a write failed (with any partial writes rolled back). */ export function restoreSnapshot(profileDir: string, id: string): { ok: boolean; restored: string[]; error?: string } { if (!validSnapshotId(id)) { return { ok: false, restored: [], error: 'invalid snapshot id / 无效的快照 id' } } let parsed: unknown try { parsed = JSON.parse(readFileSync(snapshotFile(profileDir, id), 'utf8')) as unknown } catch { return { ok: false, restored: [], error: 'snapshot not found / 快照不存在' } } const validated = validateSnapshotDocument(parsed) if (!validated.ok) return { ok: false, restored: [], error: validated.error } if (validated.value.snapshot.id !== id) { return { ok: false, restored: [], error: 'snapshot id does not match its filename / 快照 id 与文件名不匹配' } } // Materialize every target + its current content FIRST, so a rollback can // restore the pre-restore bytes even if the profile already changed. const writes: Array<{ path: string; target: string; content: string | null; previous: string | null }> = [] for (const file of validated.value.files) { const target = join(profileDir, file.path) const content = file.absent === true ? null : file.json !== undefined ? `${JSON.stringify(file.json, null, 2)}\n` : Array.isArray(file.lines) ? `${file.lines.join('\n')}` : '' let previous: string | null = null // Rollback records bytes, not filesystem object identity. Replacing a // symlink or other non-regular target could not be restored faithfully. try { ensureSafeRestoreParent(profileDir, dirname(target), file.path) if (!lstatSync(target).isFile()) { return { ok: false, restored: [], error: `unsafe restore target is not a regular file: ${file.path} / 恢复目标不是常规文件`, } } } catch (error) { if (!isMissingFileError(error)) { return { ok: false, restored: [], error: error instanceof Error ? error.message : String(error) } } } try { previous = readFileSync(target, 'utf8') } catch (error) { if (!isMissingFileError(error)) { return { ok: false, restored: [], error: error instanceof Error ? error.message : String(error) } } } writes.push({ path: file.path, target, content, previous }) } const completed: typeof writes = [] try { for (const write of writes) { if (write.content === null) rmSync(write.target, { force: true }) else { mkdirSync(dirname(write.target), { recursive: true }) writeFileAtomic(write.target, write.content) } completed.push(write) } } catch (error) { // Roll back completed actions in reverse, including recreating a file that // an explicit absence marker deleted before a later action failed. let rollbackError: unknown = null for (const write of [...completed].reverse()) { try { if (write.previous !== null) { mkdirSync(dirname(write.target), { recursive: true }) writeFileAtomic(write.target, write.previous) } else rmSync(write.target, { force: true }) } catch (restoreError) { rollbackError ??= restoreError } } const detail = error instanceof Error ? error.message : String(error) const rollbackDetail = rollbackError === null ? '' : `; rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}` const rollbackStatus = rollbackError === null ? `failed and was rolled back: ${detail}` : `failed; rollback incomplete (${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}); original failure: ${detail}` logEvent('error', 'snapshot', `restore ${id} ${rollbackStatus}`) return { ok: false, restored: [], error: `${detail}${rollbackDetail}` } } const restored = writes.map(write => write.path) logEvent('info', 'snapshot', `restored ${id}: ${restored.join(', ')}`) return { ok: true, restored } } /** Delete one snapshot; true only when it existed and was removed. */ export function deleteSnapshot(profileDir: string, id: string): boolean { if (!validSnapshotId(id)) return false const file = snapshotFile(profileDir, id) if (!existsSync(file)) return false try { rmSync(file) return true } catch { return false } }