diff --git a/README.md b/README.md index 2efd782..0942d5d 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,5 @@ node dist/cli/index.js verify --path fixtures/product/trust-loop/review --target **需要 API Key 吗?** 离线核验无需环境变量或密钥。运行 `gui --state-dir <目录>` 可指定报告存储位置。 [使用指南](docs/user-guide.md) · [构建桌面应用](docs/build.md) · [隐私与备份](docs/privacy.md) · [贡献指南](CONTRIBUTING.md) + +修复前的持久备份、并发编辑保全和中断后的核对步骤见[修复与恢复](docs/repair-recovery.md)。工作台会区分完成历史和保留的未完成材料。 diff --git a/docs/repair-recovery.md b/docs/repair-recovery.md new file mode 100644 index 0000000..9e81249 --- /dev/null +++ b/docs/repair-recovery.md @@ -0,0 +1,34 @@ +# 修复备份与中断恢复 + +`fix --apply` 只修改已审核计划里的文件。开始前停止编辑和运行同一工作区;它不是抵御同一OS用户恶意进程的沙箱,也不是多应用协同编辑协议。 + +```sh +skillsync fix --apply --plan /absolute/path/plan.json --yes --backup \ + --backup-dir /absolute/private/repair-backups --format json +skillsync backup-check --input /absolute/private/repair-backups/slot-00/operation-id +``` + +CLI默认使用 `~/.skillsync/repair-backups`。工作台使用其私有状态目录中的 `repair-backups`;迁移工作台时复制整个状态目录。备份只留在本机,不上传。历史摘要导出不包含备份路径或源文件内容。 + +备份目录要求私有权限和当前用户所有权(POSIX);不会自动更改已有目录权限。最多保留16次操作,达到上限先验证并归档,或指定新的私有目录;程序不自动清理旧材料。单文件16 MiB,准备快照和修复输出各最多64 MiB。备份还可能保留捕获的原文件和失败的新版本,不应只按快照大小估算整个目录占用。 + +## 提交和冲突 + +准备阶段保存 `files/` 和清单,包含相对路径、字节、原权限及SHA-256。每个目标在提交前再次核对;真正发布时先把实际旧文件保留到 `captured/`,再使用不覆盖已有目录项的操作发布新文件。回滚同样先核对当前内容,遇到更晚的编辑不会强制盖回旧版本,失败的新版本可保留在 `rollback/`。 + +这要求备份和目标在同一文件系统且支持文件硬链接;不支持时会拒绝修复,请选择同卷的私有备份目录。发布存在一个短暂的文件名切换窗口,读者应在修复期间停写/停用相关文件。若进程恰在捕获后中断,原文件仍在备份内,但可能需要人工恢复;不能把这种状态称为应用成功。 + +`backup-check`只读核验 `files/` 冻结快照,不认证作者,也不证明之后没有编辑。`captured/` 可能保存比计划更新的内容,或被仍持有原文件的其他编辑器继续写入;应对照检查,不能盲目以冻结快照覆盖它。 + +## 恢复顺序 + +1. 停止修复和编辑,保留原工作区、整个备份目录及原计划。结果未确认时不要再应用一次。 +2. 用 `backup-check` 检查清单和冻结快照,核对回执中的 `planDigest`。旧临时目录备份不含新清单时应保留并人工核对,不能伪造为已验证的新备份。 +3. 复制原工作区到一个新目录;比较 `files/`、`captured/`、`rollback/` 与当前文件。只在新副本中恢复选定版本,再运行只读核验。 +4. 新副本确认正确后,再由用户决定切换。程序不重置原目录,不自动重放Skill,不删除未知材料。 + +## 未完成历史 + +报告数据和索引分别以完整文件发布;索引只有在写入并同步后才发布。中断、存储不足或索引失败的材料保留在状态目录,历史栏显示“未完成或无法读取”,不计入完成记录,也不会自动变成可应用计划。它们仍占用容量,这是保护恢复材料的设计;请先归档核对,再使用新的状态目录。 + +文件同步改善突然退出时的恢复条件,但不是对断电、介质损坏或所有文件系统的零丢失保证;Windows的Node接口不提供相同的目录同步能力。唯一资料仍应有独立、加密并验证过的备份。 diff --git a/docs/workbench-history.md b/docs/workbench-history.md index d39e21b..bc7d876 100644 --- a/docs/workbench-history.md +++ b/docs/workbench-history.md @@ -208,3 +208,5 @@ Existing history comparison remains separately readonly, with its own concurrenc A page leaving for navigation also releases its report string and hides the old result view. Returning from the browser cache cannot display that prior report as the current result; its immutable history record is retained and can be reopened explicitly. Form path nodes are not cleared by status polling. Local source validation:766 tests passed,1 optional Docker-reference test skipped; type checking,lint and build passed. Native synthetic Chrome390px covered refresh/current ID, stale cancel refusal, apply-in-progress refresh/refusal, explicit safe fixture fix, server restart/old nonce denial and browser return with the old report hidden. Seven immutable history files stayed byte-identical. The Skill script was never executed; Provider calls0. Formal release and external business acceptance remain separate. + +修复备份位于私有状态目录内的 `repair-backups/`,由独立的16次操作上限管理,不计入核验报告的64MiB/120文件计数。历史索引失败后保留的材料会显示为未完成,不计作核验成功。恢复与旧临时备份兼容边界见[修复与恢复](repair-recovery.md)。 diff --git a/src/cli/commands/fix.ts b/src/cli/commands/fix.ts index 8b0c56a..a958c70 100644 --- a/src/cli/commands/fix.ts +++ b/src/cli/commands/fix.ts @@ -26,6 +26,7 @@ export type FixApplyOptions = { yes: boolean; approveReviewRequired?: boolean; backup?: boolean; + backupDirectory?: string; }; function selectIssues(issues: Issue[], issueIds: string[] | undefined): Issue[] { @@ -112,6 +113,7 @@ export async function runFixApply(options: FixApplyOptions): Promise", "Retained repair backup directory") + .action(async (options: { input: string }) => { io.writeOut(JSON.stringify(await inspectRepairBackup(options.input), null, 2)); }); + program .command("fix") .description("Plan or explicitly apply fixes for verification Issues.") @@ -362,7 +368,8 @@ export function createCli(io: CliIO = defaultCliIO): Command { .option("--output ", "Write the generated ActionPlan JSON to this new file") .option("--yes", "Confirm the explicit ActionPlan apply") .option("--approve-review-required", "Approve review-required ActionPlan changes") - .option("--backup", "Retain backups while applying the ActionPlan") + .option("--backup", "Retain private backups while applying the ActionPlan") + .option("--backup-dir ", "Private repair backup directory (default: ~/.skillsync/repair-backups)") .option("--format ", "Output format: text or json", "text") .action(async (options: FixCliOptions) => { if (options.apply) { @@ -370,7 +377,7 @@ export function createCli(io: CliIO = defaultCliIO): Command { throw new Error("fix --apply requires --plan ."); } if (options.path || options.target || options.policy || options.issue || options.output) { - throw new Error("fix --apply accepts only --plan, --yes, --approve-review-required, --backup, and --format."); + throw new Error("fix --apply accepts only --plan, --yes, --approve-review-required, --backup, --backup-dir, and --format."); } if (options.format !== "text" && options.format !== "json") { throw new Error(`Unsupported fix output format: ${options.format}`); @@ -381,6 +388,7 @@ export function createCli(io: CliIO = defaultCliIO): Command { yes: options.yes === true, approveReviewRequired: options.approveReviewRequired, backup: options.backup, + backupDirectory: options.backupDir, } satisfies FixApplyOptions); io.writeOut(renderFixApply(receipt, options.format)); } catch (error: unknown) { diff --git a/src/domain/patch-application.ts b/src/domain/patch-application.ts index 882830a..e9669b1 100644 --- a/src/domain/patch-application.ts +++ b/src/domain/patch-application.ts @@ -1,5 +1,5 @@ -import { chmod, lstat, mkdir, mkdtemp, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { chmod, link, lstat, mkdir, open, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { constants, type Stats } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { randomUUID } from "node:crypto"; @@ -7,7 +7,9 @@ import { assertActionPlanIntegrity } from "./action-plan.js"; import type { ActionPlan } from "./action-plan.js"; import { normalizeRelativePath } from "./digest.js"; import type { PatchChange } from "./resolution.js"; -import { readWorkspaceText, WORKSPACE_TREE_LIMITS } from "../sandbox/workspace-tree.js"; +import { readWorkspaceFile, readWorkspaceText, WORKSPACE_TREE_LIMITS } from "../sandbox/workspace-tree.js"; + +import { createRepairBackup, syncDirectory } from "./repair-backups.js"; export type ApplyReceipt = { schema_version: 1; @@ -64,14 +66,17 @@ export class ActionPlanApplyError extends Error { type PreparedChange = { change: PatchChange; + rootPath: string; normalizedPath: string; targetPath: string; content: string; mode: number; + metadata: Stats; }; type Backup = PreparedChange & { backupFilePath: string; + published?: Stats; }; function ensureSafePatchPath(path: string): string { @@ -138,32 +143,71 @@ function receiptFor( }; } -async function replaceFileContents(targetPath: string, content: string, mode: number): Promise { +class TargetRecoveryRequired extends Error { + constructor(readonly target: string, detail: string) { super(`Target publication requires recovery: ${detail}`); } +} +async function assertSnapshot(path: string, content: string, mode: number, metadata: Stats): Promise { + const current = await lstat(path); + if (!current.isFile() || current.isSymbolicLink() || current.dev !== metadata.dev || current.ino !== metadata.ino || (current.mode & 0o777) !== mode || + (await readWorkspaceFile(path, WORKSPACE_TREE_LIMITS.maxFileBytes, current)).toString('utf8') !== content) throw new Error('Patch target changed or is stale.'); +} +async function assertTarget(item: PreparedChange, content = item.content, mode = item.mode, metadata = item.metadata): Promise { + await assertNoSymlinkedParents(item.rootPath, item.normalizedPath); + await assertSnapshot(item.targetPath, content, mode, metadata); +} +async function replaceFileContents(targetPath: string, content: string, mode: number, capturedPath: string, + expected: { content: string; mode: number; metadata: Stats }, beforeCommit: () => Promise): Promise { const temporaryPath = join(dirname(targetPath), `.skillsync-apply-${randomUUID()}.tmp`); + const probePath = `${temporaryPath}.link-check`; + let captured = false, published = false; try { await writeFile(temporaryPath, content, { encoding: "utf8", flag: "wx", mode }); await chmod(temporaryPath, mode); - await rename(temporaryPath, targetPath); - } finally { - await rm(temporaryPath, { force: true }).catch(() => undefined); - } + const handle = await open(temporaryPath, constants.O_RDWR | (constants.O_NOFOLLOW ?? 0)); + let metadata: Stats; + try { await handle.sync(); metadata = await handle.stat(); } finally { await handle.close(); } + // Refuse unsupported filesystems while the original target is still intact. + await link(temporaryPath, probePath); await rm(probePath); + await mkdir(dirname(capturedPath), { recursive: true, mode: 0o700 }); + await beforeCommit(); + // Capture the actual directory entry before publication. A concurrent new file + // is never replaced: link() fails if any writer has populated the target name. + await rename(targetPath, capturedPath); captured = true; + await syncDirectory(dirname(capturedPath)); + await assertSnapshot(capturedPath, expected.content, expected.mode, expected.metadata); + await link(temporaryPath, targetPath); published = true; + return metadata; + } catch (error) { + if (captured && !published) { + try { await link(capturedPath, targetPath); await syncDirectory(dirname(targetPath)); } + catch { throw new TargetRecoveryRequired(targetPath, error instanceof Error ? error.message : 'conflicting publication'); } + } + throw error; + } finally { await rm(probePath, { force: true }).catch(() => undefined); await rm(temporaryPath, { force: true }).catch(() => undefined); } } - -async function restoreBackup(backup: Backup): Promise { - await replaceFileContents( - backup.targetPath, - await readWorkspaceText(backup.backupFilePath, WORKSPACE_TREE_LIMITS.maxFileBytes), - backup.mode, - ); - await chmod(backup.targetPath, backup.mode); +async function changeMode(item: PreparedChange, mode: number, before: () => Promise): Promise { + const handle = await open(item.targetPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); + try { + const bound = await handle.stat(); await before(); + if (bound.dev !== item.metadata.dev || bound.ino !== item.metadata.ino) throw new Error('Patch target changed before mode update.'); + await handle.chmod(mode); + } finally { await handle.close(); } +} +async function restoreBackup(backup: Backup, checkRoot: () => Promise, directory: string): Promise { + const check = async () => { + await checkRoot(); + await assertTarget(backup, hasContentPatch(backup.change) ? backup.change.after : backup.content, backup.change.modeAfter ?? backup.mode, backup.published ?? backup.metadata); + }; + if (hasContentPatch(backup.change)) await replaceFileContents(backup.targetPath, await readWorkspaceText(backup.backupFilePath, WORKSPACE_TREE_LIMITS.maxFileBytes), backup.mode, join(directory, "rollback", backup.normalizedPath), { content: backup.change.after, mode: backup.change.modeAfter ?? backup.mode, metadata: backup.published ?? backup.metadata }, check); + else await changeMode(backup, backup.mode, check); } -async function restoreChangedTargets(changed: Backup[], checkRoot: () => Promise): Promise { +async function restoreChangedTargets(changed: Backup[], checkRoot: () => Promise, directory: string): Promise { const failures: RollbackFailure[] = []; for (const backup of [...changed].reverse()) { try { await checkRoot(); - await restoreBackup(backup); + await restoreBackup(backup, checkRoot, directory); } catch (error) { failures.push({ path: backup.normalizedPath, @@ -180,6 +224,7 @@ export async function applyActionPlan( yes: boolean; approveReviewRequired?: boolean; backup?: boolean; + backupDirectory?: string; }, ): Promise { if (!options.yes) { @@ -208,8 +253,12 @@ export async function applyActionPlan( const prepared: PreparedChange[] = []; const paths = new Set(); + let preparedBytes = 0, patchedBytes = 0; + if (plan.changes.length > WORKSPACE_TREE_LIMITS.maxFiles) throw new Error("Too many repair targets."); for (const change of plan.changes) { const normalizedPath = ensureSafePatchPath(change.path); + const outputBytes = Buffer.byteLength(change.after); patchedBytes += outputBytes; + if (outputBytes > WORKSPACE_TREE_LIMITS.maxFileBytes || patchedBytes > WORKSPACE_TREE_LIMITS.maxTotalBytes) throw new Error("Repair output exceeds the bounded workspace limit."); if (paths.has(normalizedPath)) { throw new Error(`Duplicate normalized patch path: ${normalizedPath}`); } @@ -241,6 +290,8 @@ export async function applyActionPlan( const mode = targetStat.mode & 0o777; const content = await readWorkspaceText(targetPath, WORKSPACE_TREE_LIMITS.maxFileBytes); + preparedBytes += Buffer.byteLength(content); + if (preparedBytes > WORKSPACE_TREE_LIMITS.maxTotalBytes) throw new Error("Repair input exceeds the bounded workspace limit."); if (hasContentPatch(change) && content !== change.before) { throw new Error(`Patch target content is stale: ${normalizedPath}`); } @@ -256,41 +307,38 @@ export async function applyActionPlan( } } - prepared.push({ change, normalizedPath, targetPath, content, mode }); + prepared.push({ change, rootPath, normalizedPath, targetPath, content, mode, metadata: targetStat }); } if (prepared.some(({ change }) => change.safety === "review-required") && !options.approveReviewRequired) { throw new Error("Review-required ActionPlan changes need explicit approval"); } - const temporaryBackupPath = await mkdtemp(join(tmpdir(), "skillsync-action-plan-")); - const backups: Backup[] = []; - for (const item of prepared) { - const backupFilePath = join(temporaryBackupPath, "files", item.normalizedPath); - await mkdir(dirname(backupFilePath), { recursive: true }); - await writeFile(backupFilePath, item.content, { encoding: "utf8", mode: item.mode }); - await chmod(backupFilePath, item.mode); - backups.push({ ...item, backupFilePath }); - } + if (options.backupDirectory && isWithinRoot(rootPath, resolve(options.backupDirectory))) throw new Error("Repair backups must be outside the Skill directory."); + const retained = await createRepairBackup(prepared, plan.planDigest, options.backupDirectory); + const temporaryBackupPath = retained.directory; + const backups: Backup[] = prepared.map(item => ({ ...item, backupFilePath: join(retained.directory, 'files', item.normalizedPath) })); + const retainedMetadata = await stat(retained.directory); + if (backups.some(item => item.metadata.dev !== (retainedMetadata.dev))) { await rm(retained.slot, { force:true, recursive:true }); throw new Error("Repair backups must be on the same filesystem as every target; select --backup-dir on that filesystem."); } const changed: Backup[] = []; try { for (const backup of backups) { - await checkRoot(); + const check = async () => { await checkRoot(); await assertTarget(backup); }; if (hasContentPatch(backup.change)) { - await replaceFileContents(backup.targetPath, backup.change.after, backup.change.modeAfter ?? backup.mode); + backup.published = await replaceFileContents(backup.targetPath, backup.change.after, backup.change.modeAfter ?? backup.mode, join(retained.directory, "captured", backup.normalizedPath), backup, check); changed.push(backup); - } - if (backup.change.modeAfter !== undefined) { - await checkRoot(); - await chmod(backup.targetPath, backup.change.modeAfter); - if (!changed.includes(backup)) { - changed.push(backup); - } + } else if (backup.change.modeAfter !== undefined) { + await changeMode(backup, backup.change.modeAfter, check); changed.push(backup); } } + await checkRoot(); + for (const backup of changed) await assertTarget(backup, hasContentPatch(backup.change) ? backup.change.after : backup.content, backup.change.modeAfter ?? backup.mode, backup.published ?? backup.metadata); + for (const parent of new Set(changed.map(item => dirname(item.targetPath)))) await syncDirectory(parent); + } catch (error) { - const rollbackFailures = await restoreChangedTargets(changed, checkRoot); + const rollbackFailures = await restoreChangedTargets(changed, checkRoot, retained.directory); + if (error instanceof TargetRecoveryRequired) rollbackFailures.push({path: relative(rootPath,error.target).split(sep).join("/"),error}); const detail = error instanceof Error ? error.message : "unknown write failure"; if (rollbackFailures.length > 0) { const rollbackError = new ActionPlanRollbackError(rollbackFailures); @@ -308,7 +356,7 @@ export async function applyActionPlan( } if (options.backup === false) { - await rm(temporaryBackupPath, { force: true, recursive: true }); + await rm(retained.slot, { force: true, recursive: true }); } return receiptFor( diff --git a/src/domain/repair-backups.ts b/src/domain/repair-backups.ts new file mode 100644 index 0000000..304d479 --- /dev/null +++ b/src/domain/repair-backups.ts @@ -0,0 +1,90 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, mkdir, open, realpath, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { z } from 'zod'; +import { readDirectoryEntries, readWorkspaceFile, readWorkspaceText, WORKSPACE_TREE_LIMITS } from '../sandbox/workspace-tree.js'; +import { normalizeRelativePath } from './digest.js'; +import { parseStrictJson } from './strict-json.js'; + +export const REPAIR_BACKUP_FOLDER = 'repair-backups'; +export const MAX_REPAIR_BACKUPS = 16; +const MAX_MANIFEST_BYTES = 2 * 1024 * 1024; +const sha = (text: string | Buffer) => createHash('sha256').update(text).digest('hex'); +const manifestSchema = z.object({ schema: z.literal('skillsync.repair-backup/v1'), planDigest: z.string().min(1).max(128), + createdAt: z.string().datetime(), files: z.array(z.object({ path: z.string().min(1).max(4096), bytes: z.number().int().min(0).max(WORKSPACE_TREE_LIMITS.maxFileBytes), + sha256: z.string().regex(/^[a-f0-9]{64}$/), mode: z.number().int().min(0).max(0o777) }).strict()).max(WORKSPACE_TREE_LIMITS.maxFiles), +}).strict(); +export type RepairBackupFile = { normalizedPath: string; content: string; mode: number }; +export async function syncDirectory(path: string): Promise { + // Windows does not expose a portable directory-fsync operation through Node. + if (process.platform === 'win32') return; + const handle = await open(path, 'r'); try { await handle.sync(); } finally { await handle.close(); } +} +export async function writeSyncedFile(path: string, text: string, mode = 0o600): Promise { + await writeFile(path, text, { encoding: 'utf8', flag: 'wx', mode }); + const handle = await open(path, 'r+'); try { await handle.sync(); } finally { await handle.close(); } +} +export async function validateRepairBackupRoot(path: string): Promise { + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink() || process.platform !== 'win32' && + ((info.mode & 0o077) !== 0 || typeof process.getuid === 'function' && info.uid !== process.getuid())) throw new Error('Repair backups require an owned private directory.'); + return realpath(path); +} +export async function createRepairBackup(files: RepairBackupFile[], planDigest: string, selectedRoot?: string) { + const raw = resolve(selectedRoot ?? join(homedir(), '.skillsync', REPAIR_BACKUP_FOLDER)); + await mkdir(raw, { recursive: true, mode: 0o700 }); + const root = await validateRepairBackupRoot(raw); + const entries = await readDirectoryEntries(root, MAX_REPAIR_BACKUPS); + for (const entry of entries) { + if (!/^slot-\d{2}$/.test(entry.name) || Number(entry.name.slice(5)) >= MAX_REPAIR_BACKUPS) throw new Error('Repair backup directory contains unrecognized material; retain it and select a new directory.'); + await validateRepairBackupRoot(join(root, entry.name)); + } + let bytes = 0; + const manifest = manifestSchema.parse({ schema: 'skillsync.repair-backup/v1', planDigest, createdAt: new Date().toISOString(), files: files.map(file => { + const normalized = normalizeRelativePath(file.normalizedPath); + if (normalized !== file.normalizedPath) throw new Error('Invalid repair backup path.'); + const size = Buffer.byteLength(file.content); bytes += size; + return { path: normalized, bytes: size, sha256: sha(file.content), mode: file.mode }; + }) }); + const text = JSON.stringify(manifest, null, 2); + if (bytes > WORKSPACE_TREE_LIMITS.maxTotalBytes || Buffer.byteLength(text) > MAX_MANIFEST_BYTES) throw new Error('Repair backup exceeds the bounded workspace limit.'); + // Atomic directory reservations enforce the count even across concurrent CLI processes. + let slot: string | undefined; + for (let index = 0; index < MAX_REPAIR_BACKUPS; index++) { + const candidate = join(root, `slot-${String(index).padStart(2, '0')}`); + try { await mkdir(candidate, { mode: 0o700 }); slot = candidate; break; } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; } + } + if (!slot) throw new Error('Repair backup capacity reached (16 retained operations); archive verified backups or select a new private directory.'); + const directory = join(slot, randomUUID()); + try { + await mkdir(directory, { mode: 0o700 }); + for (const file of files) { + const path = join(directory, 'files', file.normalizedPath); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeSyncedFile(path, file.content); + } + await writeSyncedFile(join(directory, 'manifest.json'), text); + await syncDirectory(directory); await syncDirectory(slot); await syncDirectory(root); + return { directory, slot }; + } catch (error) { + // No target has been touched: only this invocation's reserved, partial copy is removed. + await rm(slot, { recursive: true, force: true }); throw error; + } +} +export async function inspectRepairBackup(input: string) { + const directory = await validateRepairBackupRoot(resolve(input)); + const manifest = manifestSchema.parse(parseStrictJson(await readWorkspaceText(join(directory, 'manifest.json'), MAX_MANIFEST_BYTES))); + const paths = new Set(); let bytes = 0; + for (const file of manifest.files) { + if (normalizeRelativePath(file.path) !== file.path || paths.has(file.path)) throw new Error('Invalid repair backup manifest.'); + paths.add(file.path); bytes += file.bytes; + if (bytes > WORKSPACE_TREE_LIMITS.maxTotalBytes) throw new Error('Repair backup exceeds the workspace limit.'); + const path = join(directory, 'files', file.path); + if (await realpath(path) !== path) throw new Error('Linked repair backup material is not accepted.'); + const content = await readWorkspaceFile(path, WORKSPACE_TREE_LIMITS.maxFileBytes); + if (content.length !== file.bytes || sha(content) !== file.sha256) throw new Error('Repair backup checksum or size mismatch.'); + } + return { schema: 'skillsync.repair-backup-check/v1', status: 'verified', planDigest: manifest.planDigest, files: manifest.files.length, bytes, sourceAuthentication: 'not-authenticated' }; +} diff --git a/src/workbench/history.ts b/src/workbench/history.ts index 01c0811..3faaa01 100644 --- a/src/workbench/history.ts +++ b/src/workbench/history.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { lstat, realpath, writeFile } from "node:fs/promises"; +import { lstat, realpath, rename } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { z } from "zod"; import { parseStrictJson } from "../domain/strict-json.js"; @@ -7,6 +7,8 @@ import { readDirectoryEntries, readWorkspaceText } from "../sandbox/workspace-tr import {parseComparisonReport,compareVerificationReports} from "./verification-comparison.js"; import {compareArtifactReports,parseArtifactReport} from "../domain/artifact-report-comparison.js"; +import { REPAIR_BACKUP_FOLDER, syncDirectory, validateRepairBackupRoot, writeSyncedFile } from "../domain/repair-backups.js"; + export const MAX_OUTPUT = 8 * 1024 * 1024; const MAX_META = 16 * 1024; const MAX_STATE = 64 * 1024 * 1024; @@ -43,15 +45,18 @@ export class WorkbenchHistory { private async entries() { const root = await lstat(this.root); if (!root.isDirectory() || root.isSymbolicLink() || identity(root)!==this.rootIdentity || await realpath(this.root)!==this.root) throw new WorkbenchStateError("状态目录已变化,请停止操作并检查原目录。"); - const entries = await readDirectoryEntries(this.root, MAX_FILES); - let bytes = 0; + const entries = await readDirectoryEntries(this.root, MAX_FILES + 1); + let bytes = 0; const reports = []; for (const entry of entries) { + if (entry.name === REPAIR_BACKUP_FOLDER) { await validateRepairBackupRoot(join(this.root, entry.name)); continue; } + reports.push(entry); const info = await lstat(join(this.root,entry.name)); if (!info.isFile() || info.isSymbolicLink()) throw new WorkbenchStateError("状态目录包含非普通文件,请检查;原结果未删除。"); bytes += info.size; } if (bytes>MAX_STATE) throw new WorkbenchStateError("状态目录超过容量上限,请选择新的状态目录;原结果已保留。"); - return {entries,bytes}; + if (reports.length > MAX_FILES) throw new WorkbenchStateError("历史文件超过上限,原材料已保留。"); + return {entries:reports,bytes}; } async capacity(files=2, bytes=MAX_OUTPUT+MAX_META) { const current=await this.entries(); @@ -64,9 +69,15 @@ export class WorkbenchHistory { const index=JSON.stringify(record); if (Buffer.byteLength(index)>MAX_META || (output!==undefined && Buffer.byteLength(output)>MAX_OUTPUT)) throw new Error("历史记录超过上限。"); await this.capacity(output===undefined?1:2,Buffer.byteLength(index)+Buffer.byteLength(output??"")); - if (dataFile) await writeFile(join(this.root,dataFile),output!,{flag:"wx",mode:0o600}); - // Publishing the index last keeps incomplete writes out of completed history. - await writeFile(join(this.root,`${id}-history.json`),index,{flag:"wx",mode:0o600}); + const publish = async (name: string, text: string) => { + const pending = join(this.root, `.${name}.tmp`); + await writeSyncedFile(pending, text); + await rename(pending, join(this.root, name)); + await syncDirectory(this.root); + }; + if (dataFile) await publish(dataFile, output!); + // Failed staging/output remains discoverable and is never counted as completed. + await publish(`${id}-history.json`, index); return record; } private async metadata(id:unknown) { @@ -81,16 +92,29 @@ export class WorkbenchHistory { if(!parsed.success)throw new WorkbenchStateError("历史筛选条件无效,请使用目录名、任务类型和退出码 0–4。"); const {query,kind,exitCode}=parsed.data; const {entries,bytes}=await this.entries(); - const rows:HistoryRecord[]=[];let unavailable=0; + const rows:HistoryRecord[]=[]; const incomplete = new Set(); + const names = new Set(entries.map(entry => entry.name)); for(const entry of entries)if(/^[a-f0-9-]{36}-history\.json$/.test(entry.name)) { - try { rows.push(await this.metadata(entry.name.slice(0,-13))); } catch { unavailable++; } + const id = entry.name.slice(0, -13); + try { + const record = await this.metadata(id); + if (record.dataFile && !names.has(record.dataFile)) throw new Error('History output is missing.'); + rows.push(record); + } catch { incomplete.add(id); } + } + const completed = new Set(rows.map(row => row.id)); + for (const entry of entries) { + const match = /^\.?([a-f0-9-]{36})-(?:data|history)\.json(?:\.tmp)?$/.exec(entry.name); + if (match && !completed.has(match[1])) incomplete.add(match[1]); } + const unavailable = incomplete.size; + const recovery = [...incomplete].sort().map(id => ({ id, status: 'incomplete-material' as const, readOnly: true })); rows.sort((a,b)=>b.createdAt.localeCompare(a.createdAt)||b.id.localeCompare(a.id)); const summaries=rows.map(({id,createdAt,kind,selection,exitCode,baselineId,parentId})=> ({id,createdAt,kind,label:selection.path.split(/[\\/]/).filter(Boolean).at(-1)??"",exitCode,baselineId,parentId})); const matches=summaries.filter(row=>(!query||row.label.toLowerCase().includes(query)) &&(!kind||row.kind===kind)&&(exitCode===undefined||row.exitCode===exitCode)); - return {items:matches.slice(offset,offset+12),total:matches.length,offset,unavailable, + return {items:matches.slice(offset,offset+12),total:matches.length,offset,unavailable,recovery, capacity:{files:entries.length,bytes,maxFiles:MAX_FILES,maxBytes:MAX_STATE}}; } async read(id:unknown) { diff --git a/src/workbench/page.ts b/src/workbench/page.ts index 7d70177..2b12c26 100644 --- a/src/workbench/page.ts +++ b/src/workbench/page.ts @@ -173,7 +173,7 @@ async function loadHistory(newest=false, filters=historyFilters, offset=newest?0 if(item.kind==='verify')for(const [side,text]of [['before','设为较早'],['after','设为较晚']]){const pick=document.createElement('button');pick.type='button';pick.className='history-compare-pick';pick.textContent=text;pick.disabled=busy||historyLoading;pick.setAttribute('aria-label','将记录 '+item.id+' '+text);pick.onclick=()=>{$('history-compare-'+side).value=item.id;clearHistoryComparison();};row.append(pick);} $('history-list').append(row);} renderedHistory=signature; } - $('history-info').textContent=data.total+' 条匹配记录 · 保存文件 '+data.capacity.files+'/'+data.capacity.maxFiles+(data.unavailable?' · '+data.unavailable+' 条索引无法读取(不参与筛选)':''); + $('history-info').textContent=data.total+' 条匹配记录 · 保存文件 '+data.capacity.files+'/'+data.capacity.maxFiles+(data.unavailable?' · '+data.unavailable+' 份材料未完成或无法读取(已保留,不计完成记录)':''); $('history-prev').disabled=busy||historyOffset===0;$('history-next').disabled=busy||historyOffset+12>=historyTotal; }catch(e){$('history-info').textContent='历史暂不可用,保留上次结果与筛选条件,原文件未删除。';showHistoryFilters();}finally{historyLoading=false;renderLocks();if(historyRefreshQueued){historyRefreshQueued=false;await loadHistory(true);}} } diff --git a/src/workbench/server.ts b/src/workbench/server.ts index c9319f4..f6f5d56 100644 --- a/src/workbench/server.ts +++ b/src/workbench/server.ts @@ -302,7 +302,7 @@ export async function startWorkbench(options: { port?: number; stateDir: string; owned.historyId=intent.id; await checkSelection(); applying = true; - const output = await command(["fix", "--apply", "--plan", current.path, "--yes", "--backup", "--format", "json"]); + const output = await command(["fix", "--apply", "--plan", current.path, "--yes", "--backup", "--backup-dir", join(stateDir, "repair-backups"), "--format", "json"]); const applied=await history.append({kind:"apply",selection:selected,snapshot:current.snapshot,baselineId:baseline?.id,parentId:intent.id},output); owned.historyId=applied.id; const receipt = JSON.parse(output) as {status?: string; backupPath?: string}; diff --git a/tests/domain/patch-application.test.ts b/tests/domain/patch-application.test.ts index 35c6b88..464e194 100644 --- a/tests/domain/patch-application.test.ts +++ b/tests/domain/patch-application.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { planDigest } from "../../src/domain/action-plan.js"; import type { ActionPlan } from "../../src/domain/action-plan.js"; import type { PatchChange } from "../../src/domain/resolution.js"; -import { applyActionPlan } from "../../src/domain/patch-application.js"; +import { applyActionPlan as applyPlan } from "../../src/domain/patch-application.js"; const workspaces: string[] = []; @@ -28,6 +28,11 @@ async function put(rootPath: string, relativePath: string, content: string, mode await chmod(targetPath, mode); } +async function applyActionPlan(plan: ActionPlan, options: Parameters[1]) { + const backupDirectory = await workspace(); + return applyPlan(plan, { ...options, backupDirectory }); +} + function change(overrides: Partial = {}): PatchChange { return { path: "SKILL.md", diff --git a/tests/domain/patch-recovery.test.ts b/tests/domain/patch-recovery.test.ts index 32b0cf8..c26f511 100644 --- a/tests/domain/patch-recovery.test.ts +++ b/tests/domain/patch-recovery.test.ts @@ -2,19 +2,19 @@ import {mkdir,mkdtemp,readFile,realpath,rm,stat,writeFile} from 'node:fs/promise import {join} from 'node:path';import {tmpdir} from 'node:os'; import {afterEach,expect,it,vi} from 'vitest'; import {planDigest,type ActionPlan} from '../../src/domain/action-plan'; -import {applyActionPlan,ActionPlanApplyError} from '../../src/domain/patch-application'; +import {applyActionPlan as applyPlan,ActionPlanApplyError} from '../../src/domain/patch-application'; import {renderFixApplyError} from '../../src/cli/commands/fix'; const fault=vi.hoisted(()=>({root:'',moved:'',replace:true,armed:false})); vi.mock('node:fs/promises',async importOriginal=>{ const actual=await importOriginal(); - return {...actual,rename:async(from:Parameters[0],to:Parameters[1])=>{ + return {...actual,link:async(from:Parameters[0],to:Parameters[1])=>{ if(fault.armed&&to===join(fault.root,fault.replace?'a.txt':'b.txt')){ fault.armed=false; if(!fault.replace)throw new Error('synthetic write failure'); - await actual.rename(from,to);await actual.rename(fault.root,fault.moved);await actual.mkdir(fault.root); + await actual.link(from,to);await actual.rename(fault.root,fault.moved);await actual.mkdir(fault.root); await actual.writeFile(join(fault.root,'a.txt'),'replacement-a');await actual.writeFile(join(fault.root,'b.txt'),'replacement-b');return; } - return actual.rename(from,to); + return actual.link(from,to); }}; }); const owned:string[]=[]; @@ -23,7 +23,8 @@ it.each([true,false])('retains a discoverable recovery record when root replacem const parent=await mkdtemp(join(tmpdir(),'skillsync-recovery-'));owned.push(parent);const folder=join(parent,'root');await mkdir(folder);const root=await realpath(folder);await writeFile(join(root,'a.txt'),'before-a');await writeFile(join(root,'b.txt'),'before-b');const metadata=await stat(root); const input={schema_version:1 as const,rootPath:root,rootIdentity:`${metadata.dev}:${metadata.ino}`,issueIds:[],changes:[{path:'a.txt',before:'before-a',after:'after-a',safety:'safe' as const},{path:'b.txt',before:'before-b',after:'after-b',safety:'safe' as const}],manualSteps:[]};const plan:ActionPlan={...input,generatedAt:new Date().toISOString(),planDigest:planDigest(input)}; Object.assign(fault,{root,moved:join(parent,'original'),replace:true,armed:true}); - const error=await applyActionPlan(plan,{yes:true,backup}).then(()=>{throw Error('expected failure');},error=>error as ActionPlanApplyError); + const backupDirectory=join(parent,'backups'); + const error=await applyPlan(plan,{yes:true,backup,backupDirectory}).then(()=>{throw Error('expected failure');},error=>error as ActionPlanApplyError); expect(error).toBeInstanceOf(ActionPlanApplyError);expect(error.receipt).toBeUndefined(); const recovery=JSON.parse(renderFixApplyError(error)).error.recovery; expect(recovery).toMatchObject({schema_version:1,status:'rollback-incomplete',planDigest:plan.planDigest,changedPaths:['a.txt'],failedPaths:['a.txt']}); @@ -38,7 +39,8 @@ it.each([true,false])('completed rollback still exposes retained failure materia const parent=await mkdtemp(join(tmpdir(),'skillsync-restored-'));owned.push(parent);const folder=join(parent,'root');await mkdir(folder);const root=await realpath(folder);await writeFile(join(root,'a.txt'),'before-a');await writeFile(join(root,'b.txt'),'before-b'); const input={schema_version:1 as const,rootPath:root,issueIds:[],changes:[{path:'a.txt',before:'before-a',after:'after-a',safety:'safe' as const},{path:'b.txt',before:'before-b',after:'after-b',safety:'safe' as const}],manualSteps:[]};const plan:ActionPlan={...input,generatedAt:new Date().toISOString(),planDigest:planDigest(input)}; Object.assign(fault,{root,moved:join(parent,'unused'),replace:false,armed:true}); - const error=await applyActionPlan(plan,{yes:true,backup}).then(()=>{throw Error('expected failure');},error=>error as ActionPlanApplyError); + const backupDirectory=join(parent,'backups'); + const error=await applyPlan(plan,{yes:true,backup,backupDirectory}).then(()=>{throw Error('expected failure');},error=>error as ActionPlanApplyError); expect(error).toBeInstanceOf(ActionPlanApplyError);expect(error.recovery).toBeUndefined();expect(error.receipt).toMatchObject({status:'restored',changedPaths:['a.txt']}); expect(error.receipt!.backupPath).toEqual(expect.any(String));owned.push(error.receipt!.backupPath!); expect(await readFile(join(root,'a.txt'),'utf8')).toBe('before-a');expect(await readFile(join(root,'b.txt'),'utf8')).toBe('before-b'); diff --git a/tests/domain/repair-durability.test.ts b/tests/domain/repair-durability.test.ts new file mode 100644 index 0000000..ebdaa83 --- /dev/null +++ b/tests/domain/repair-durability.test.ts @@ -0,0 +1,117 @@ +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { mkdtemp, realpath, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { planDigest, type ActionPlan } from '../../src/domain/action-plan.js'; +import { applyActionPlan } from '../../src/domain/patch-application.js'; +import { WorkbenchHistory } from '../../src/workbench/history.js'; +import { lstat, symlink } from 'node:fs/promises'; +import { inspectRepairBackup, MAX_REPAIR_BACKUPS } from '../../src/domain/repair-backups.js'; + +const fault = vi.hoisted(() => ({ target: '', afterBackup: false, failIndex: false, editAfterPublish: false, failPublish: '', editAtCommit: false, linksUnsupported: false })); +vi.mock('node:fs/promises', async importOriginal => { + const fs = await importOriginal(); + return { ...fs, + writeFile: async (...args: Parameters) => { + await fs.writeFile(...args); + const name = String(args[0]); + if (fault.afterBackup && name.endsWith('/files/one.md')) { fault.afterBackup = false; await fs.writeFile(fault.target, 'concurrent editor\n'); } + if (fault.failIndex && name.includes('-history.json')) throw Object.assign(new Error('synthetic index storage failure'), { code: 'ENOSPC' }); + }, + link: async (...args: Parameters) => { + if (fault.linksUnsupported) throw Object.assign(new Error('synthetic unsupported hard links'), {code:'ENOTSUP'}); + if (fault.editAtCommit && String(args[1]) === fault.target) { fault.editAtCommit = false; await fs.writeFile(fault.target, 'edit at publication\n'); } + if (fault.failPublish && String(args[1]) === fault.failPublish) throw new Error('synthetic publication failure'); + await fs.link(...args); + if (fault.editAfterPublish && String(args[1]) === fault.target) { fault.editAfterPublish = false; await fs.writeFile(fault.target, 'edited after repair\n'); } + }, + rename: async (...args: Parameters) => { + if (fault.failPublish && String(args[1]) === fault.failPublish) throw new Error('synthetic publication failure'); + if (fault.editAtCommit && String(args[1]) === fault.target) { fault.editAtCommit = false; await fs.writeFile(fault.target, 'edit at publication\n'); } + await fs.rename(...args); + if (fault.editAfterPublish && String(args[1]) === fault.target) { fault.editAfterPublish = false; await fs.writeFile(fault.target, 'edited after repair\n'); } + }, + }; +}); +const roots: string[] = []; +async function directory() { const root = await realpath(await mkdtemp(join(tmpdir(), 'skillsync-durability-'))); roots.push(root); return root; } +function plan(rootPath: string, files = ['one.md']): ActionPlan { + const value = { schema_version: 1 as const, rootPath, issueIds: ['repair'], changes: files.map(path => ({ path, before: 'before\n', after: 'after\n', safety: 'safe' as const })), manualSteps: [] }; + return { ...value, planDigest: planDigest(value), generatedAt: new Date().toISOString() }; +} +beforeEach(() => Object.assign(fault, { target: '', afterBackup: false, failIndex: false, editAfterPublish: false, failPublish: '', editAtCommit: false, linksUnsupported: false })); +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); + +test('a normal edit made while repair materials are being prepared is retained', async () => { + const root = await directory(), backupDirectory = await directory(); + fault.target = join(root, 'one.md'); await writeFile(fault.target, 'before\n'); fault.afterBackup = true; + const options = { yes: true, backup: true, backupDirectory }; + await expect(applyActionPlan(plan(root), options)).rejects.toThrow(/stale|changed/i); + expect(await readFile(fault.target, 'utf8')).toBe('concurrent editor\n'); +}); + +test('retained repair material uses the private selected directory rather than system temporary storage', async () => { + const root = await directory(), backupDirectory = await directory(); await writeFile(join(root, 'one.md'), 'before\n'); + const options = { yes: true, backup: true, backupDirectory }; + const receipt = await applyActionPlan(plan(root), options); + try { expect(receipt.backupPath?.startsWith(backupDirectory + '/')).toBe(true); expect(await readFile(join(receipt.backupPath!, 'files/one.md'), 'utf8')).toBe('before\n'); } + finally { if (receipt.backupPath && !receipt.backupPath.startsWith(backupDirectory + '/')) await rm(receipt.backupPath, { recursive: true, force: true }); } +}); + +test('rollback refuses to overwrite a later user edit and exposes retained recovery materials', async () => { + const root = await directory(), backupDirectory = await directory(); + for (const name of ['one.md', 'two.md']) await writeFile(join(root, name), 'before\n'); + fault.target = join(root, 'one.md'); fault.editAfterPublish = true; fault.failPublish = join(root, 'two.md'); + const options = { yes: true, backup: true, backupDirectory }; + const error = await applyActionPlan(plan(root, ['one.md', 'two.md']), options).catch(error => error); + try { expect(await readFile(fault.target, 'utf8')).toBe('edited after repair\n'); expect(error.recovery?.status).toBe('rollback-incomplete'); } + finally { const path = error.recovery?.backupPath ?? error.receipt?.backupPath; if (path && !path.startsWith(backupDirectory + '/')) await rm(path, { recursive: true, force: true }); } +}); + +test('a failed history index remains visible as incomplete material and never appears as a completed task', async () => { + const root = await directory(), selected = await directory(), info = await lstat(root); + const history = new WorkbenchHistory(root, `${info.dev}:${info.ino}`); fault.failIndex = true; + await expect(history.append({ kind: 'verify', selection: { path: selected, identity: '1:2', kind: 'skill', delivery: false }, exitCode: 0 }, '{"synthetic":true}')).rejects.toThrow('synthetic index'); + const listed = await history.list(); + expect(listed.items).toHaveLength(0); expect(listed.unavailable).toBe(1); + expect((await readdir(root)).length).toBeGreaterThan(0); + expect(listed.capacity.bytes).toBeGreaterThan(0); +}); + +test('backup inspection survives reopening and rejects changed or linked original material without writing', async () => { + const root = await directory(), backupDirectory = await directory(); await writeFile(join(root, 'one.md'), 'before\n'); + const receipt = await applyActionPlan(plan(root), { yes: true, backup: true, backupDirectory }); + const before = await readdir(receipt.backupPath!); + expect(await inspectRepairBackup(receipt.backupPath!)).toMatchObject({ status: 'verified', files: 1, bytes: 7, planDigest: receipt.planDigest }); + expect(await readdir(receipt.backupPath!)).toEqual(before); + const material = join(receipt.backupPath!, 'files/one.md'); await writeFile(material, 'tampered'); + await expect(inspectRepairBackup(receipt.backupPath!)).rejects.toThrow(/checksum|size/i); + await rm(material); await symlink(join(root, 'one.md'), material); + await expect(inspectRepairBackup(receipt.backupPath!)).rejects.toThrow(/linked/i); +}); +test('retention is bounded without removing old recovery materials', async () => { + const root = await directory(), backupDirectory = await directory(); await writeFile(join(root, 'one.md'), 'before\n'); + let first = ''; + for (let index = 0; index < MAX_REPAIR_BACKUPS; index++) { + await writeFile(join(root, 'one.md'), 'before\n'); + const receipt = await applyActionPlan(plan(root), { yes: true, backup: true, backupDirectory }); first ||= receipt.backupPath!; + } + await writeFile(join(root, 'one.md'), 'before\n'); + await expect(applyActionPlan(plan(root), { yes: true, backup: true, backupDirectory })).rejects.toThrow(/capacity/i); + expect(await readFile(join(first, 'files/one.md'), 'utf8')).toBe('before\n'); + expect(await readFile(join(root, 'one.md'), 'utf8')).toBe('before\n'); +}); + +test('an editor creating a new file at the final publication boundary is never replaced', async () => { + const root = await directory(), backupDirectory = await directory(); + fault.target = join(root, 'one.md'); await writeFile(fault.target, 'before\n'); fault.editAtCommit = true; + await expect(applyActionPlan(plan(root), { yes: true, backup: true, backupDirectory })).rejects.toThrow(); + expect(await readFile(fault.target, 'utf8')).toBe('edit at publication\n'); +}); + +test('unsupported no-replace publication is rejected before moving any target', async () => { + const root = await directory(), backupDirectory = await directory(); fault.target = join(root, 'one.md'); + await writeFile(fault.target, 'before\n'); fault.linksUnsupported = true; + await expect(applyActionPlan(plan(root), { yes: true, backup:true, backupDirectory })).rejects.toThrow(); + expect(await readFile(fault.target, 'utf8')).toBe('before\n'); +}); diff --git a/tests/workbench/server.test.ts b/tests/workbench/server.test.ts index 8fe2511..2c1e59d 100644 --- a/tests/workbench/server.test.ts +++ b/tests/workbench/server.test.ts @@ -202,7 +202,7 @@ describe("real CLI graphical workbench", () => { expect(after.data.comparison.status).toBe("compared"); expect(after.data.comparison.resolved).toBe(1); expect(after.data.comparison.added).toBe(0); - const saved = await readdir(join(base, "state")); + const saved = (await readdir(join(base, "state"), {withFileTypes:true})).filter(entry => entry.isFile()).map(entry => entry.name); const contents = await Promise.all(saved.map(name => readFile(join(base,"state",name)))); await app.close(); running.splice(running.indexOf(app), 1); const next = await startWorkbench({port:0, stateDir:join(base,"state"),cliPath:resolve("dist/cli/index.js")}); running.push(next);