@davideasden/pi-undo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,591 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { constants, realpathSync } from "node:fs";
3
+ import { lstat, link, open, readlink, realpath, symlink, unlink } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+
6
+ import { fsyncDirectory, writeBytesExclusive } from "./atomic-fs.ts";
7
+ import { canonicalJson, checksum } from "./encoding.ts";
8
+ import type { MutationJournal } from "./mutation-journal.ts";
9
+ import type { MutationRecord } from "./model.ts";
10
+ import { assertNoSymlinkEscape, relativeSafePath } from "./path-safety.ts";
11
+
12
+ export interface ReplaceFileRequest {
13
+ readonly path: string;
14
+ readonly targetBytes: Uint8Array;
15
+ readonly targetMode: number;
16
+ readonly sourceFingerprint: string;
17
+ readonly targetFingerprint: string;
18
+ readonly beforeInstall?: () => void | Promise<void>;
19
+ }
20
+
21
+ export interface ReplaceSymlinkRequest {
22
+ readonly path: string;
23
+ readonly targetLinkText: string;
24
+ readonly sourceFingerprint: string;
25
+ readonly targetFingerprint: string;
26
+ readonly beforeInstall?: () => void | Promise<void>;
27
+ }
28
+
29
+ export interface DeleteLeafRequest {
30
+ readonly path: string;
31
+ readonly sourceFingerprint: string;
32
+ readonly targetFingerprint: string;
33
+ }
34
+
35
+ export interface QuarantineArtifact {
36
+ readonly path: string;
37
+ readonly role: "source" | "target";
38
+ readonly fingerprint: string;
39
+ readonly ordinal: number;
40
+ }
41
+
42
+ export type QuarantineErrorCode = "external_concurrency" | "fingerprint_mismatch" | "unsafe_artifact";
43
+
44
+ export class QuarantineError extends Error {
45
+ readonly code: QuarantineErrorCode;
46
+
47
+ constructor(code: QuarantineErrorCode, message: string) {
48
+ super(message);
49
+ this.name = "QuarantineError";
50
+ this.code = code;
51
+ }
52
+ }
53
+
54
+ export class QuarantineManager {
55
+ private readonly requestedWorkspaceRoot: string;
56
+ private readonly workspaceRoot: string;
57
+ private readonly journal: MutationJournal;
58
+ private readonly linkFile: typeof link;
59
+ private readonly nonce: () => string;
60
+ private readonly beforeSourceCapture: (() => void | Promise<void>) | undefined;
61
+ private readonly beforeSourceRemove: (() => void | Promise<void>) | undefined;
62
+ private readonly beforeRestoreInstall: (() => void | Promise<void>) | undefined;
63
+ private readonly beforeRestoreSourceCleanup: (() => void | Promise<void>) | undefined;
64
+ private readonly afterRestoreSourceCleanup: (() => void | Promise<void>) | undefined;
65
+ private readonly beforeTargetCreate: (() => void | Promise<void>) | undefined;
66
+
67
+ constructor(options: {
68
+ readonly workspaceRoot: string;
69
+ readonly journal: MutationJournal;
70
+ readonly linkFile?: typeof link;
71
+ readonly nonce?: () => string;
72
+ readonly beforeSourceCapture?: () => void | Promise<void>;
73
+ readonly beforeSourceRemove?: () => void | Promise<void>;
74
+ readonly beforeRestoreInstall?: () => void | Promise<void>;
75
+ readonly beforeRestoreSourceCleanup?: () => void | Promise<void>;
76
+ readonly afterRestoreSourceCleanup?: () => void | Promise<void>;
77
+ readonly beforeTargetCreate?: () => void | Promise<void>;
78
+ }) {
79
+ this.requestedWorkspaceRoot = resolve(options.workspaceRoot);
80
+ this.workspaceRoot = realpathSync(this.requestedWorkspaceRoot);
81
+ this.journal = options.journal;
82
+ this.linkFile = options.linkFile ?? link;
83
+ this.nonce = options.nonce ?? (() => randomBytes(16).toString("hex"));
84
+ this.beforeSourceCapture = options.beforeSourceCapture;
85
+ this.beforeSourceRemove = options.beforeSourceRemove;
86
+ this.beforeRestoreInstall = options.beforeRestoreInstall;
87
+ this.beforeRestoreSourceCleanup = options.beforeRestoreSourceCleanup;
88
+ this.afterRestoreSourceCleanup = options.afterRestoreSourceCleanup;
89
+ this.beforeTargetCreate = options.beforeTargetCreate;
90
+ }
91
+
92
+ async replaceFile(request: ReplaceFileRequest): Promise<void> {
93
+ await this.assertWorkspaceIdentity();
94
+ await this.assertPath(request.path);
95
+ assertFingerprint(request.sourceFingerprint);
96
+ assertFingerprint(request.targetFingerprint);
97
+ assertMode(request.targetMode);
98
+ const artifacts = await this.artifactPaths(request.path, true);
99
+ const intent = await this.journal.begin({
100
+ kind: "write",
101
+ path: request.path,
102
+ ...artifacts,
103
+ sourceFingerprint: request.sourceFingerprint,
104
+ targetFingerprint: request.targetFingerprint,
105
+ });
106
+ const targetArtifact = this.absolute(artifacts.targetArtifact!);
107
+ await this.beforeTargetCreate?.();
108
+ await this.assertMutationPaths(request.path, artifacts.targetArtifact!);
109
+ await writeBytesExclusive(targetArtifact, request.targetBytes, request.targetMode);
110
+ await this.assertFingerprint(targetArtifact, request.path, request.targetFingerprint);
111
+ await this.quarantineSource(intent);
112
+ await request.beforeInstall?.();
113
+ await this.assertMutationPaths(request.path, artifacts.targetArtifact!);
114
+ await this.assertFingerprint(targetArtifact, request.path, request.targetFingerprint);
115
+ try {
116
+ await this.linkFile(targetArtifact, this.absolute(request.path));
117
+ } catch (error) {
118
+ if (hasErrorCode(error, "EEXIST")) {
119
+ throw new QuarantineError("external_concurrency", `检测到外部并发修改:${request.path}`);
120
+ }
121
+ throw error;
122
+ }
123
+ await fsyncDirectory(dirname(this.absolute(request.path)));
124
+ await this.journal.advance(intent.ordinal, "TARGET_INSTALLED");
125
+ await this.assertFingerprint(this.absolute(request.path), request.path, request.targetFingerprint);
126
+ await this.assertArtifactPath(request.path, artifacts.targetArtifact!);
127
+ await unlink(targetArtifact);
128
+ await fsyncDirectory(dirname(targetArtifact));
129
+ await this.journal.advance(intent.ordinal, "TARGET_VERIFIED");
130
+ }
131
+
132
+ async replaceSymlink(request: ReplaceSymlinkRequest): Promise<void> {
133
+ await this.assertWorkspaceIdentity();
134
+ await this.assertPath(request.path);
135
+ assertFingerprint(request.sourceFingerprint);
136
+ assertFingerprint(request.targetFingerprint);
137
+ if (fingerprintSymlink(request.path, request.targetLinkText) !== request.targetFingerprint) {
138
+ throw new QuarantineError("fingerprint_mismatch", `symlink target fingerprint 不匹配:${request.path}`);
139
+ }
140
+ const artifacts = await this.artifactPaths(request.path, false);
141
+ const intent = await this.journal.begin({
142
+ kind: "symlink",
143
+ path: request.path,
144
+ ...artifacts,
145
+ sourceFingerprint: request.sourceFingerprint,
146
+ targetFingerprint: request.targetFingerprint,
147
+ });
148
+ await this.quarantineSource(intent);
149
+ await request.beforeInstall?.();
150
+ await this.assertMutationPaths(request.path);
151
+ try {
152
+ await symlink(request.targetLinkText, this.absolute(request.path));
153
+ } catch (error) {
154
+ if (hasErrorCode(error, "EEXIST")) {
155
+ throw new QuarantineError("external_concurrency", `检测到外部并发修改:${request.path}`);
156
+ }
157
+ throw error;
158
+ }
159
+ await fsyncDirectory(dirname(this.absolute(request.path)));
160
+ await this.journal.advance(intent.ordinal, "TARGET_INSTALLED");
161
+ await this.assertFingerprint(this.absolute(request.path), request.path, request.targetFingerprint);
162
+ await this.journal.advance(intent.ordinal, "TARGET_VERIFIED");
163
+ }
164
+
165
+ async deleteLeaf(request: DeleteLeafRequest): Promise<void> {
166
+ await this.assertWorkspaceIdentity();
167
+ await this.assertPath(request.path);
168
+ assertFingerprint(request.sourceFingerprint);
169
+ assertFingerprint(request.targetFingerprint);
170
+ if (request.targetFingerprint !== fingerprintAbsent(request.path)) {
171
+ throw new QuarantineError("fingerprint_mismatch", `删除目标 fingerprint 不是 absent:${request.path}`);
172
+ }
173
+ const artifacts = await this.artifactPaths(request.path, false);
174
+ const intent = await this.journal.begin({
175
+ kind: "delete",
176
+ path: request.path,
177
+ ...artifacts,
178
+ sourceFingerprint: request.sourceFingerprint,
179
+ targetFingerprint: request.targetFingerprint,
180
+ });
181
+ await this.quarantineSource(intent);
182
+ await this.journal.advance(intent.ordinal, "TARGET_INSTALLED");
183
+ await this.assertFingerprint(this.absolute(request.path), request.path, request.targetFingerprint);
184
+ await this.journal.advance(intent.ordinal, "TARGET_VERIFIED");
185
+ }
186
+
187
+ async restoreMutation(record: MutationRecord): Promise<void> {
188
+ const owned = await this.assertOwnedRecord(record);
189
+ const source = this.absolute(owned.sourceArtifact);
190
+ const original = this.absolute(owned.path);
191
+ await this.assertMutationPaths(owned.path, owned.sourceArtifact);
192
+ const originalFingerprint = await fingerprintLeaf(original, owned.path);
193
+ const sourceExists = await exists(source);
194
+ const sourceWasAbsent = owned.sourceFingerprint === fingerprintAbsent(owned.path);
195
+ if (owned.state === "CLEANED") {
196
+ if (originalFingerprint !== owned.sourceFingerprint || sourceExists) {
197
+ throw new QuarantineError("external_concurrency", `已 CLEANED rollback 现场不一致:${owned.path}`);
198
+ }
199
+ await this.assertTargetArtifactAbsent(owned);
200
+ return;
201
+ }
202
+ if (sourceWasAbsent) {
203
+ if (sourceExists) {
204
+ throw new QuarantineError("unsafe_artifact", `absent source 不应存在 artifact:${owned.path}`);
205
+ }
206
+ if (originalFingerprint === owned.targetFingerprint) {
207
+ await this.assertFingerprint(original, owned.path, owned.targetFingerprint);
208
+ await unlink(original);
209
+ await fsyncDirectory(dirname(original));
210
+ } else if (originalFingerprint !== owned.sourceFingerprint) {
211
+ throw new QuarantineError("external_concurrency", `检测到外部并发,恢复路径存在未知内容:${owned.path}`);
212
+ }
213
+ await this.cleanupRollbackTarget(owned);
214
+ await this.journal.markRollbackCleaned(owned.ordinal);
215
+ return;
216
+ }
217
+ if (originalFingerprint === owned.sourceFingerprint) {
218
+ if (sourceExists) {
219
+ await this.assertFingerprint(source, owned.path, owned.sourceFingerprint);
220
+ await this.assertArtifactPath(owned.path, owned.sourceArtifact);
221
+ await unlink(source);
222
+ await fsyncDirectory(dirname(source));
223
+ }
224
+ await this.cleanupRollbackTarget(owned);
225
+ await this.journal.markRollbackCleaned(owned.ordinal);
226
+ return;
227
+ }
228
+ if (!sourceExists) {
229
+ throw new QuarantineError("external_concurrency", `source 已缺失且原路径不是已恢复内容:${owned.path}`);
230
+ }
231
+ await this.assertFingerprint(source, owned.path, owned.sourceFingerprint);
232
+ if (
233
+ originalFingerprint !== fingerprintAbsent(owned.path) &&
234
+ originalFingerprint === owned.targetFingerprint
235
+ ) {
236
+ await this.assertMutationPaths(owned.path, owned.sourceArtifact);
237
+ await this.assertFingerprint(original, owned.path, owned.targetFingerprint);
238
+ await unlink(original);
239
+ await fsyncDirectory(dirname(original));
240
+ } else if (originalFingerprint !== fingerprintAbsent(owned.path)) {
241
+ throw new QuarantineError("external_concurrency", `检测到外部并发,恢复路径存在未知内容:${owned.path}`);
242
+ }
243
+ await this.beforeRestoreInstall?.();
244
+ await this.assertMutationPaths(owned.path, owned.sourceArtifact);
245
+ await this.assertFingerprint(source, owned.path, owned.sourceFingerprint);
246
+ const metadata = await lstat(source);
247
+ try {
248
+ if (metadata.isSymbolicLink()) {
249
+ await symlink(await readlink(source), original);
250
+ } else if (metadata.isFile()) {
251
+ await link(source, original);
252
+ } else {
253
+ throw new QuarantineError("unsafe_artifact", "source artifact 不是受支持的叶子类型");
254
+ }
255
+ } catch (error) {
256
+ if (hasErrorCode(error, "EEXIST")) {
257
+ throw new QuarantineError("external_concurrency", `检测到外部并发,恢复路径已存在:${owned.path}`);
258
+ }
259
+ throw error;
260
+ }
261
+ await fsyncDirectory(dirname(original));
262
+ await this.assertFingerprint(original, owned.path, owned.sourceFingerprint);
263
+ await this.assertFingerprint(source, owned.path, owned.sourceFingerprint);
264
+ await this.beforeRestoreSourceCleanup?.();
265
+ await this.assertMutationPaths(owned.path, owned.sourceArtifact);
266
+ await unlink(source);
267
+ await fsyncDirectory(dirname(source));
268
+ await this.afterRestoreSourceCleanup?.();
269
+ await this.cleanupRollbackTarget(owned);
270
+ await this.journal.markRollbackCleaned(owned.ordinal);
271
+ }
272
+
273
+ async rollForwardMutation(record: MutationRecord): Promise<void> {
274
+ let owned = await this.assertOwnedRecord(record);
275
+ if (owned.state === "SOURCE_QUARANTINED") {
276
+ if (owned.sourceFingerprint === fingerprintAbsent(owned.path)) {
277
+ if (await exists(this.absolute(owned.sourceArtifact))) {
278
+ throw new QuarantineError("unsafe_artifact", `absent source 不应存在 artifact:${owned.path}`);
279
+ }
280
+ } else {
281
+ await this.assertFingerprint(this.absolute(owned.sourceArtifact), owned.path, owned.sourceFingerprint);
282
+ }
283
+ owned = await this.journal.advance(owned.ordinal, "SOURCE_VERIFIED");
284
+ }
285
+ if (owned.state === "SOURCE_VERIFIED") {
286
+ if (owned.kind === "write" && owned.targetArtifact !== null) {
287
+ const targetArtifact = this.absolute(owned.targetArtifact);
288
+ await this.assertMutationPaths(owned.path, owned.targetArtifact);
289
+ await this.assertFingerprint(targetArtifact, owned.path, owned.targetFingerprint);
290
+ try {
291
+ await this.linkFile(targetArtifact, this.absolute(owned.path));
292
+ } catch (error) {
293
+ if (!hasErrorCode(error, "EEXIST")) throw error;
294
+ await this.assertFingerprint(this.absolute(owned.path), owned.path, owned.targetFingerprint)
295
+ .catch(() => {
296
+ throw new QuarantineError("external_concurrency", `检测到外部并发修改:${owned.path}`);
297
+ });
298
+ }
299
+ await fsyncDirectory(dirname(this.absolute(owned.path)));
300
+ } else if (owned.kind === "delete") {
301
+ await this.assertFingerprint(this.absolute(owned.path), owned.path, owned.targetFingerprint);
302
+ } else {
303
+ throw new QuarantineError("unsafe_artifact", "symlink 缺少可重建 target,不能仅凭 fingerprint roll forward");
304
+ }
305
+ owned = await this.journal.advance(owned.ordinal, "TARGET_INSTALLED");
306
+ }
307
+ if (owned.state === "TARGET_INSTALLED") {
308
+ await this.assertFingerprint(this.absolute(owned.path), owned.path, owned.targetFingerprint);
309
+ if (owned.targetArtifact !== null && await exists(this.absolute(owned.targetArtifact))) {
310
+ await this.assertArtifactPath(owned.path, owned.targetArtifact);
311
+ await this.assertFingerprint(this.absolute(owned.targetArtifact), owned.path, owned.targetFingerprint);
312
+ await unlink(this.absolute(owned.targetArtifact));
313
+ await fsyncDirectory(dirname(this.absolute(owned.targetArtifact)));
314
+ }
315
+ await this.journal.advance(owned.ordinal, "TARGET_VERIFIED");
316
+ return;
317
+ }
318
+ if (owned.state !== "TARGET_VERIFIED" && owned.state !== "CLEANED") {
319
+ throw new QuarantineError("unsafe_artifact", `mutation 状态不能 roll forward:${owned.state}`);
320
+ }
321
+ }
322
+
323
+ async cleanupMutation(record: MutationRecord): Promise<void> {
324
+ const owned = await this.assertOwnedRecord(record);
325
+ if (owned.state === "CLEANED") return;
326
+ if (owned.state !== "TARGET_VERIFIED") {
327
+ throw new QuarantineError("unsafe_artifact", "只有 TARGET_VERIFIED mutation 可以清理");
328
+ }
329
+ const source = this.absolute(owned.sourceArtifact);
330
+ if (await exists(source)) {
331
+ await this.assertArtifactPath(owned.path, owned.sourceArtifact);
332
+ await this.assertFingerprint(source, owned.path, owned.sourceFingerprint);
333
+ await unlink(source);
334
+ await fsyncDirectory(dirname(source));
335
+ }
336
+ if (owned.targetArtifact !== null) {
337
+ const target = this.absolute(owned.targetArtifact);
338
+ if (await exists(target)) {
339
+ await this.assertArtifactPath(owned.path, owned.targetArtifact);
340
+ await this.assertFingerprint(target, owned.path, owned.targetFingerprint);
341
+ await unlink(target);
342
+ await fsyncDirectory(dirname(target));
343
+ }
344
+ }
345
+ await this.journal.advance(owned.ordinal, "CLEANED");
346
+ }
347
+
348
+ async inspectArtifacts(): Promise<readonly QuarantineArtifact[]> {
349
+ const result: QuarantineArtifact[] = [];
350
+ for (const record of await this.journal.load()) {
351
+ if (record.state === "CLEANED") continue;
352
+ const artifacts: Array<readonly [QuarantineArtifact["role"], string, string]> = [
353
+ ["source", record.sourceArtifact, record.sourceFingerprint],
354
+ ];
355
+ if (record.targetArtifact !== null) {
356
+ artifacts.push(["target", record.targetArtifact, record.targetFingerprint]);
357
+ }
358
+ for (const [role, path] of artifacts) {
359
+ await this.assertArtifactPath(record.path, path);
360
+ if (!await exists(this.absolute(path))) continue;
361
+ const fingerprint = await fingerprintLeaf(this.absolute(path), record.path);
362
+ result.push({ path, role, fingerprint, ordinal: record.ordinal });
363
+ }
364
+ }
365
+ return result;
366
+ }
367
+
368
+ private async quarantineSource(record: MutationRecord): Promise<void> {
369
+ const original = this.absolute(record.path);
370
+ const source = this.absolute(record.sourceArtifact);
371
+ await this.assertFingerprint(original, record.path, record.sourceFingerprint);
372
+ if (record.sourceFingerprint === fingerprintAbsent(record.path)) {
373
+ if (await exists(source)) {
374
+ throw new QuarantineError("unsafe_artifact", `absent source 不应存在 artifact:${record.path}`);
375
+ }
376
+ await this.journal.advance(record.ordinal, "SOURCE_QUARANTINED");
377
+ await this.assertFingerprint(original, record.path, record.sourceFingerprint);
378
+ await this.journal.advance(record.ordinal, "SOURCE_VERIFIED");
379
+ return;
380
+ }
381
+ const metadata = await lstat(original);
382
+ const linkText = metadata.isSymbolicLink() ? await readlink(original) : undefined;
383
+ if (!metadata.isFile() && !metadata.isSymbolicLink()) {
384
+ throw new QuarantineError("unsafe_artifact", "quarantine 只支持普通文件和 symlink");
385
+ }
386
+ await this.beforeSourceCapture?.();
387
+ await this.assertMutationPaths(record.path, record.sourceArtifact);
388
+ try {
389
+ if (linkText === undefined) {
390
+ await link(original, source);
391
+ } else {
392
+ await symlink(linkText, source);
393
+ }
394
+ } catch (error) {
395
+ if (hasErrorCode(error, "EEXIST")) {
396
+ throw new QuarantineError("external_concurrency", `检测到外部并发,source artifact 被抢占:${record.path}`);
397
+ }
398
+ throw error;
399
+ }
400
+ await fsyncDirectory(dirname(original));
401
+ await this.beforeSourceRemove?.();
402
+ await this.assertMutationPaths(record.path, record.sourceArtifact);
403
+ await this.assertFingerprint(source, record.path, record.sourceFingerprint);
404
+ await this.assertFingerprint(original, record.path, record.sourceFingerprint);
405
+ if (linkText === undefined) {
406
+ const [sourceMetadata, originalMetadata] = await Promise.all([lstat(source), lstat(original)]);
407
+ if (sourceMetadata.dev !== originalMetadata.dev || sourceMetadata.ino !== originalMetadata.ino) {
408
+ throw new QuarantineError("external_concurrency", `source 隔离前原路径已变化:${record.path}`);
409
+ }
410
+ }
411
+ await unlink(original);
412
+ await fsyncDirectory(dirname(original));
413
+ await this.journal.advance(record.ordinal, "SOURCE_QUARANTINED");
414
+ await this.assertFingerprint(source, record.path, record.sourceFingerprint);
415
+ await this.journal.advance(record.ordinal, "SOURCE_VERIFIED");
416
+ }
417
+
418
+ private async cleanupRollbackTarget(record: MutationRecord): Promise<void> {
419
+ if (record.targetArtifact === null) return;
420
+ const target = this.absolute(record.targetArtifact);
421
+ if (!await exists(target)) return;
422
+ await this.assertArtifactPath(record.path, record.targetArtifact);
423
+ await this.assertFingerprint(target, record.path, record.targetFingerprint);
424
+ await unlink(target);
425
+ await fsyncDirectory(dirname(target));
426
+ }
427
+
428
+ private async assertTargetArtifactAbsent(record: MutationRecord): Promise<void> {
429
+ if (record.targetArtifact !== null && await exists(this.absolute(record.targetArtifact))) {
430
+ throw new QuarantineError("unsafe_artifact", `已 CLEANED mutation 仍存在 target artifact:${record.path}`);
431
+ }
432
+ }
433
+
434
+ private async artifactPaths(path: string, withTarget: boolean): Promise<{
435
+ readonly sourceArtifact: string;
436
+ readonly targetArtifact: string | null;
437
+ }> {
438
+ const parent = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
439
+ const relative = (name: string): string => parent === "" ? name : `${parent}/${name}`;
440
+ for (let attempt = 0; attempt < 128; attempt += 1) {
441
+ const nonce = this.nonce();
442
+ if (!/^[0-9a-f]{32}$/.test(nonce)) throw new Error("artifact nonce 无效");
443
+ const candidate = {
444
+ sourceArtifact: relative(`.pi-undo-q1-${nonce}-source`),
445
+ targetArtifact: withTarget ? relative(`.pi-undo-q1-${nonce}-target`) : null,
446
+ };
447
+ if (
448
+ !await exists(this.absolute(candidate.sourceArtifact)) &&
449
+ (candidate.targetArtifact === null || !await exists(this.absolute(candidate.targetArtifact)))
450
+ ) {
451
+ return candidate;
452
+ }
453
+ }
454
+ throw new QuarantineError("unsafe_artifact", "无法分配无碰撞 quarantine artifact");
455
+ }
456
+
457
+ private async assertOwnedRecord(record: MutationRecord): Promise<MutationRecord> {
458
+ await this.assertWorkspaceIdentity();
459
+ const owned = (await this.journal.load()).find((candidate) => candidate.ordinal === record.ordinal);
460
+ if (owned === undefined || immutableMutation(owned) !== immutableMutation(record)) {
461
+ throw new QuarantineError("unsafe_artifact", "mutation record 未被当前 journal 精确登记");
462
+ }
463
+ await this.assertPath(owned.path);
464
+ await this.assertArtifactPath(owned.path, owned.sourceArtifact);
465
+ if (owned.targetArtifact !== null) await this.assertArtifactPath(owned.path, owned.targetArtifact);
466
+ return owned;
467
+ }
468
+
469
+ private async assertPath(path: string): Promise<void> {
470
+ assertNotGitMetadata(path);
471
+ relativeSafePath(this.workspaceRoot, path);
472
+ await assertNoSymlinkEscape(this.workspaceRoot, path);
473
+ }
474
+
475
+ private async assertMutationPaths(...paths: readonly string[]): Promise<void> {
476
+ await this.assertWorkspaceIdentity();
477
+ for (const path of paths) await this.assertPath(path);
478
+ }
479
+
480
+ private async assertArtifactPath(path: string, artifact: string): Promise<void> {
481
+ await this.assertPath(artifact);
482
+ if (dirname(path) !== dirname(artifact) || !/^\.pi-undo-q1-[0-9a-f]{32}-(source|target)$/.test(artifact.split("/").at(-1)!)) {
483
+ throw new QuarantineError("unsafe_artifact", "artifact 路径或名称无效");
484
+ }
485
+ }
486
+
487
+ private async assertFingerprint(absolutePath: string, logicalPath: string, expected: string): Promise<void> {
488
+ const actual = await fingerprintLeaf(absolutePath, logicalPath);
489
+ if (actual !== expected) {
490
+ throw new QuarantineError("fingerprint_mismatch", `路径 fingerprint 不匹配:${logicalPath}`);
491
+ }
492
+ }
493
+
494
+ private async assertWorkspaceIdentity(): Promise<void> {
495
+ const identity = await realpath(this.requestedWorkspaceRoot);
496
+ if (identity !== this.workspaceRoot) {
497
+ throw new QuarantineError("unsafe_artifact", "workspace root identity 已变化");
498
+ }
499
+ }
500
+
501
+ private absolute(path: string): string {
502
+ return join(this.workspaceRoot, ...path.split("/"));
503
+ }
504
+ }
505
+
506
+ export function fingerprintBytes(path: string, bytes: Uint8Array, mode: number): string {
507
+ return checksum(canonicalJson({
508
+ path,
509
+ kind: "file",
510
+ mode: (mode & 0o111) === 0 ? 0o644 : 0o755,
511
+ content: checksum(bytes),
512
+ }));
513
+ }
514
+
515
+ export async function fingerprintFile(file: string, logicalPath: string): Promise<string> {
516
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
517
+ try {
518
+ const metadata = await handle.stat();
519
+ if (!metadata.isFile()) throw new Error("fingerprint 目标不是普通文件");
520
+ return fingerprintBytes(logicalPath, await handle.readFile(), metadata.mode);
521
+ } finally {
522
+ await handle.close();
523
+ }
524
+ }
525
+
526
+ export function fingerprintSymlink(path: string, linkText: string): string;
527
+ export function fingerprintSymlink(file: string, logicalPath: string): Promise<string>;
528
+ export function fingerprintSymlink(fileOrPath: string, linkTextOrPath: string): string | Promise<string> {
529
+ if (!fileOrPath.startsWith("/")) {
530
+ return checksum(canonicalJson({ path: fileOrPath, kind: "symlink", linkText: linkTextOrPath }));
531
+ }
532
+ return readlink(fileOrPath).then((linkText) =>
533
+ checksum(canonicalJson({ path: linkTextOrPath, kind: "symlink", linkText })),
534
+ );
535
+ }
536
+
537
+ export function fingerprintAbsent(path: string): string {
538
+ return checksum(canonicalJson({ path, kind: "absent" }));
539
+ }
540
+
541
+ async function fingerprintLeaf(file: string, logicalPath: string): Promise<string> {
542
+ const metadata = await lstat(file).catch((error) => {
543
+ if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR")) return null;
544
+ throw error;
545
+ });
546
+ if (metadata === null) return fingerprintAbsent(logicalPath);
547
+ if (metadata.isSymbolicLink()) return fingerprintSymlink(file, logicalPath);
548
+ if (metadata.isFile()) return fingerprintFile(file, logicalPath);
549
+ throw new Error(`quarantine 只支持普通文件和 symlink:${logicalPath}`);
550
+ }
551
+
552
+ function assertNotGitMetadata(path: string): void {
553
+ if (path.split("/").some((part) => part.toLowerCase() === ".git")) {
554
+ throw new QuarantineError("unsafe_artifact", "不能修改真实 Git metadata");
555
+ }
556
+ }
557
+
558
+ function assertFingerprint(value: string): void {
559
+ if (!/^[0-9a-f]{64}$/.test(value)) throw new Error("fingerprint 无效");
560
+ }
561
+
562
+ function assertMode(mode: number): void {
563
+ if (mode !== 0o644 && mode !== 0o755) throw new Error("文件 mode 必须是 0644 或 0755");
564
+ }
565
+
566
+ function immutableMutation(record: MutationRecord): string {
567
+ return canonicalJson({
568
+ opId: record.opId,
569
+ ordinal: record.ordinal,
570
+ kind: record.kind,
571
+ path: record.path,
572
+ sourceArtifact: record.sourceArtifact,
573
+ targetArtifact: record.targetArtifact,
574
+ sourceFingerprint: record.sourceFingerprint,
575
+ targetFingerprint: record.targetFingerprint,
576
+ });
577
+ }
578
+
579
+ async function exists(path: string): Promise<boolean> {
580
+ try {
581
+ await lstat(path);
582
+ return true;
583
+ } catch (error) {
584
+ if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR")) return false;
585
+ throw error;
586
+ }
587
+ }
588
+
589
+ function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
590
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
591
+ }