@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.
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/extensions/pi-undo.ts +133 -0
- package/package.json +54 -0
- package/src/atomic-fs.ts +156 -0
- package/src/controller.ts +598 -0
- package/src/encoding.ts +620 -0
- package/src/git-runner.ts +308 -0
- package/src/journal.ts +297 -0
- package/src/model.ts +160 -0
- package/src/mutation-journal.ts +229 -0
- package/src/path-safety.ts +121 -0
- package/src/pi-runtime.ts +415 -0
- package/src/quarantine.ts +591 -0
- package/src/recovery.ts +143 -0
- package/src/restore-engine.ts +1184 -0
- package/src/root-discovery.ts +388 -0
- package/src/session-state.ts +448 -0
- package/src/snapshot-store.ts +1279 -0
- package/src/status-reporter.ts +80 -0
- package/src/workspace-lock.ts +407 -0
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, mkdtemp, readFile, readlink, realpath, rm, rmdir } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { assertManifest, assertOperationId, canonicalJson, checksum } from "./encoding.ts";
|
|
8
|
+
import { MutationJournal } from "./mutation-journal.ts";
|
|
9
|
+
import type { ManifestId, RestorePath, SnapshotManifest, SnapshotRoot } from "./model.ts";
|
|
10
|
+
import {
|
|
11
|
+
assertNoSymlinkEscape,
|
|
12
|
+
relativeSafePath,
|
|
13
|
+
sortDeletePaths,
|
|
14
|
+
sortWritePaths,
|
|
15
|
+
} from "./path-safety.ts";
|
|
16
|
+
import { RootDiscovery, type RootTopology } from "./root-discovery.ts";
|
|
17
|
+
import {
|
|
18
|
+
QuarantineManager,
|
|
19
|
+
fingerprintAbsent,
|
|
20
|
+
fingerprintBytes,
|
|
21
|
+
fingerprintSymlink,
|
|
22
|
+
} from "./quarantine.ts";
|
|
23
|
+
import { SnapshotStoreError, type SnapshotStore } from "./snapshot-store.ts";
|
|
24
|
+
|
|
25
|
+
export interface RestorePlan {
|
|
26
|
+
currentManifestId: ManifestId;
|
|
27
|
+
targetManifestId: ManifestId;
|
|
28
|
+
boundaryRoots: string[];
|
|
29
|
+
deletePaths: string[];
|
|
30
|
+
writePaths: string[];
|
|
31
|
+
scopePaths?: string[];
|
|
32
|
+
planDigest: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RestoreResult {
|
|
36
|
+
code: "ok" | "restore_failed_safe" | "partial_restore" | "recovery_required";
|
|
37
|
+
verifiedPaths: number;
|
|
38
|
+
totalPaths: number;
|
|
39
|
+
postFingerprint?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RestoreEngine {
|
|
43
|
+
plan(current: SnapshotManifest, target: SnapshotManifest, scopePaths?: readonly string[]): Promise<RestorePlan>;
|
|
44
|
+
apply(plan: RestorePlan, target: SnapshotManifest, options?: RestoreApplyOptions): Promise<RestoreResult>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RestoreApplyOptions {
|
|
48
|
+
readonly opId: string;
|
|
49
|
+
readonly mutationJournal: MutationJournal;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RestoreEngineOptions {
|
|
53
|
+
readonly workspaceRoot: string;
|
|
54
|
+
readonly store: SnapshotStore;
|
|
55
|
+
readonly discovery?: RootDiscovery;
|
|
56
|
+
readonly beforeMutation?: (mutation: RestoreMutation) => void | Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RestoreMutation {
|
|
60
|
+
readonly phase: "apply" | "rollback";
|
|
61
|
+
readonly ordinal: number;
|
|
62
|
+
readonly kind: "delete" | "mkdir" | "write" | "symlink";
|
|
63
|
+
readonly path: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface OwnedPath {
|
|
67
|
+
readonly absolutePath: string;
|
|
68
|
+
readonly entry: RestorePath;
|
|
69
|
+
readonly root: SnapshotRoot;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface MutationContext {
|
|
73
|
+
readonly phase: RestoreMutation["phase"];
|
|
74
|
+
readonly sourceManifestId: ManifestId;
|
|
75
|
+
readonly targetManifestId: ManifestId;
|
|
76
|
+
readonly sourcePaths: ReadonlyMap<string, OwnedPath>;
|
|
77
|
+
readonly targetPaths: ReadonlyMap<string, OwnedPath>;
|
|
78
|
+
readonly plannedDeletePaths: ReadonlySet<string>;
|
|
79
|
+
readonly sourceIgnoredPaths: ReadonlySet<string>;
|
|
80
|
+
readonly mutationJournal: MutationJournal;
|
|
81
|
+
readonly quarantine: QuarantineManager;
|
|
82
|
+
ordinal: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class RestoreEngine {
|
|
86
|
+
private readonly requestedWorkspaceRoot: string;
|
|
87
|
+
private readonly workspaceRoot: string;
|
|
88
|
+
private readonly store: SnapshotStore;
|
|
89
|
+
private readonly discovery: RootDiscovery;
|
|
90
|
+
private readonly beforeMutation: RestoreEngineOptions["beforeMutation"];
|
|
91
|
+
|
|
92
|
+
constructor(options: RestoreEngineOptions) {
|
|
93
|
+
this.requestedWorkspaceRoot = resolve(options.workspaceRoot);
|
|
94
|
+
this.workspaceRoot = realpathSync(this.requestedWorkspaceRoot);
|
|
95
|
+
this.store = options.store;
|
|
96
|
+
this.discovery = options.discovery ?? new RootDiscovery();
|
|
97
|
+
this.beforeMutation = options.beforeMutation;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async compatibleApplyOptions(): Promise<{
|
|
101
|
+
readonly directory: string;
|
|
102
|
+
readonly options: RestoreApplyOptions;
|
|
103
|
+
}> {
|
|
104
|
+
const directory = await mkdtemp(join(tmpdir(), "pi-undo-restore-compat-"));
|
|
105
|
+
const opId = `compat-${randomUUID()}`;
|
|
106
|
+
return {
|
|
107
|
+
directory,
|
|
108
|
+
options: {
|
|
109
|
+
opId,
|
|
110
|
+
mutationJournal: new MutationJournal(join(directory, "mutations.jsonl"), opId),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async plan(
|
|
116
|
+
current: SnapshotManifest,
|
|
117
|
+
target: SnapshotManifest,
|
|
118
|
+
scopePaths?: readonly string[],
|
|
119
|
+
): Promise<RestorePlan> {
|
|
120
|
+
assertManifest(current);
|
|
121
|
+
assertManifest(target);
|
|
122
|
+
assertCompatibleManifests(current, target);
|
|
123
|
+
const scope = scopePaths === undefined ? undefined : this.canonicalScope(scopePaths);
|
|
124
|
+
const isScopedPath = (path: string): boolean => scope === undefined || scope.has(path);
|
|
125
|
+
await Promise.all([
|
|
126
|
+
this.store.assertComplete(current.manifestId),
|
|
127
|
+
this.store.assertComplete(target.manifestId),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const [currentPaths, targetPaths] = await Promise.all([
|
|
131
|
+
this.readOwnedPaths(current),
|
|
132
|
+
this.readOwnedPaths(target),
|
|
133
|
+
]);
|
|
134
|
+
const targetIgnoredPaths = ignoredWorkspacePaths(target);
|
|
135
|
+
const deleteByRoot = new Map<string, string[]>();
|
|
136
|
+
const writeByRoot = new Map<string, string[]>();
|
|
137
|
+
|
|
138
|
+
for (const [path, owned] of currentPaths) {
|
|
139
|
+
if (!isScopedPath(path)) continue;
|
|
140
|
+
const targetOwned = targetPaths.get(path);
|
|
141
|
+
if (targetOwned !== undefined && !sameEntry(owned.entry, targetOwned.entry)) {
|
|
142
|
+
if (targetOwned.entry.kind !== owned.entry.kind || targetOwned.entry.kind === "symlink") {
|
|
143
|
+
appendPath(deleteByRoot, owned.root.relativeRoot, path);
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (targetOwned === undefined) {
|
|
148
|
+
if (isProtectedByIgnoredProof(path, owned.entry.kind, targetIgnoredPaths)) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
appendPath(deleteByRoot, owned.root.relativeRoot, path);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const [path, owned] of targetPaths) {
|
|
156
|
+
if (!isScopedPath(path)) continue;
|
|
157
|
+
const currentOwned = currentPaths.get(path);
|
|
158
|
+
if (currentOwned === undefined || !sameEntry(currentOwned.entry, owned.entry)) {
|
|
159
|
+
appendPath(writeByRoot, owned.root.relativeRoot, path);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const boundaryRoots = [...new Set([
|
|
164
|
+
...current.roots.map((root) => root.relativeRoot),
|
|
165
|
+
...target.roots.map((root) => root.relativeRoot),
|
|
166
|
+
])].sort(comparePaths);
|
|
167
|
+
const deletePaths = sortDeletePaths([...deleteByRoot.values()].flat());
|
|
168
|
+
const writePaths = orderedWritePaths(target.roots, writeByRoot, targetPaths);
|
|
169
|
+
const semanticPlan = {
|
|
170
|
+
currentManifestId: current.manifestId,
|
|
171
|
+
targetManifestId: target.manifestId,
|
|
172
|
+
boundaryRoots,
|
|
173
|
+
deletePaths,
|
|
174
|
+
writePaths,
|
|
175
|
+
...(scope === undefined ? {} : { scopePaths: [...scope] }),
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
...semanticPlan,
|
|
179
|
+
planDigest: checksum(canonicalJson(semanticPlan)),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async apply(
|
|
184
|
+
plan: RestorePlan,
|
|
185
|
+
target: SnapshotManifest,
|
|
186
|
+
options?: RestoreApplyOptions,
|
|
187
|
+
): Promise<RestoreResult> {
|
|
188
|
+
const compatibility = options === undefined ? await this.compatibleApplyOptions() : undefined;
|
|
189
|
+
const effectiveOptions = options ?? compatibility!.options;
|
|
190
|
+
try {
|
|
191
|
+
return await this.applyWithOptions(plan, target, effectiveOptions, compatibility !== undefined);
|
|
192
|
+
} finally {
|
|
193
|
+
if (compatibility !== undefined && await this.mutationsAreClean(effectiveOptions.mutationJournal)) {
|
|
194
|
+
await rm(compatibility.directory, { recursive: true });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async applyWithOptions(
|
|
200
|
+
plan: RestorePlan,
|
|
201
|
+
target: SnapshotManifest,
|
|
202
|
+
effectiveOptions: RestoreApplyOptions,
|
|
203
|
+
compatibilityMode: boolean,
|
|
204
|
+
): Promise<RestoreResult> {
|
|
205
|
+
assertManifest(target);
|
|
206
|
+
// Task 6 会由 controller 强制传入 operation identity;此兼容分支仅保持现有调用方可运行。
|
|
207
|
+
assertOperationId(effectiveOptions.opId);
|
|
208
|
+
if (effectiveOptions.opId !== effectiveOptions.mutationJournal.operationId) {
|
|
209
|
+
throw new Error("restore opId 与 mutation journal identity 不匹配");
|
|
210
|
+
}
|
|
211
|
+
if (plan.targetManifestId !== target.manifestId) {
|
|
212
|
+
throw new Error("restore plan target manifest ID 不匹配");
|
|
213
|
+
}
|
|
214
|
+
if (!/^[0-9a-f]{64}$/.test(plan.planDigest)) {
|
|
215
|
+
throw new Error("restore plan digest 无效");
|
|
216
|
+
}
|
|
217
|
+
if (!hasValidPlanDigest(plan)) {
|
|
218
|
+
throw new Error("restore plan digest 与语义字段不匹配");
|
|
219
|
+
}
|
|
220
|
+
const recoveryReason = `restore:${plan.planDigest}`;
|
|
221
|
+
const attemptReason = `${recoveryReason}:attempt:${randomUUID()}`;
|
|
222
|
+
const pinned = [...new Set([plan.currentManifestId, target.manifestId])];
|
|
223
|
+
const acquired: ManifestId[] = [];
|
|
224
|
+
try {
|
|
225
|
+
for (const manifestId of pinned) {
|
|
226
|
+
await this.store.pin(manifestId, attemptReason);
|
|
227
|
+
acquired.push(manifestId);
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
await Promise.all(acquired.map(
|
|
231
|
+
(manifestId) => this.store.unpin(manifestId, attemptReason).catch(() => {}),
|
|
232
|
+
));
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const result = await this.applyPinned(plan, target, effectiveOptions, compatibilityMode);
|
|
238
|
+
if (result.code === "partial_restore" || result.code === "recovery_required") {
|
|
239
|
+
let recoveryPinned = true;
|
|
240
|
+
for (const manifestId of pinned) {
|
|
241
|
+
try {
|
|
242
|
+
await this.store.pin(manifestId, recoveryReason);
|
|
243
|
+
} catch {
|
|
244
|
+
recoveryPinned = false;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (recoveryPinned) {
|
|
249
|
+
await Promise.all(pinned.map(
|
|
250
|
+
(manifestId) => this.store.unpin(manifestId, attemptReason).catch(() => {}),
|
|
251
|
+
));
|
|
252
|
+
}
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
await Promise.all(pinned.map(
|
|
257
|
+
(manifestId) => this.store.unpin(manifestId, attemptReason).catch(() => {}),
|
|
258
|
+
));
|
|
259
|
+
if (result.code === "ok" || result.postFingerprint !== undefined) {
|
|
260
|
+
await Promise.all(pinned.map(
|
|
261
|
+
(manifestId) => this.store.unpin(manifestId, recoveryReason).catch(() => {}),
|
|
262
|
+
));
|
|
263
|
+
}
|
|
264
|
+
return result;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
await Promise.all(pinned.map(
|
|
267
|
+
(manifestId) => this.store.unpin(manifestId, attemptReason).catch(() => {}),
|
|
268
|
+
));
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async applyPinned(
|
|
274
|
+
plan: RestorePlan,
|
|
275
|
+
target: SnapshotManifest,
|
|
276
|
+
options: RestoreApplyOptions,
|
|
277
|
+
compatibilityMode: boolean,
|
|
278
|
+
): Promise<RestoreResult> {
|
|
279
|
+
const [current, storedTarget] = await Promise.all([
|
|
280
|
+
this.store.loadManifest(plan.currentManifestId),
|
|
281
|
+
this.store.loadManifest(target.manifestId),
|
|
282
|
+
]);
|
|
283
|
+
if (canonicalJson(storedTarget) !== canonicalJson(target)) {
|
|
284
|
+
throw new Error("target manifest 与 store 内容不一致");
|
|
285
|
+
}
|
|
286
|
+
assertCompatibleManifests(current, target);
|
|
287
|
+
let expectedPlan: RestorePlan;
|
|
288
|
+
try {
|
|
289
|
+
expectedPlan = await this.plan(current, target, plan.scopePaths);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (error instanceof SnapshotStoreError && error.code === "object_missing") {
|
|
292
|
+
return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
|
|
293
|
+
}
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
if (canonicalJson(plan) !== canonicalJson(expectedPlan)) {
|
|
297
|
+
throw new Error("restore plan 已被篡改或与 manifest 不匹配");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let topologyBefore: RootTopology;
|
|
301
|
+
try {
|
|
302
|
+
if (await this.assertWorkspaceRootIdentity() !== current.workspaceIdentity) {
|
|
303
|
+
throw new Error("restore workspace root 必须使用 canonical identity");
|
|
304
|
+
}
|
|
305
|
+
topologyBefore = await this.discovery.discover(this.workspaceRoot);
|
|
306
|
+
this.assertCurrentTopology(current, target, topologyBefore);
|
|
307
|
+
} catch {
|
|
308
|
+
return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
|
|
309
|
+
}
|
|
310
|
+
const [currentPaths, targetPaths] = await Promise.all([
|
|
311
|
+
this.readOwnedPaths(current),
|
|
312
|
+
this.readOwnedPaths(target),
|
|
313
|
+
]);
|
|
314
|
+
const quarantine = new QuarantineManager({
|
|
315
|
+
workspaceRoot: this.requestedWorkspaceRoot,
|
|
316
|
+
journal: options.mutationJournal,
|
|
317
|
+
});
|
|
318
|
+
if (
|
|
319
|
+
compatibilityMode
|
|
320
|
+
? !await this.restorePendingMutations(quarantine, options.mutationJournal)
|
|
321
|
+
: (await options.mutationJournal.activeArtifacts()).size > 0
|
|
322
|
+
) {
|
|
323
|
+
return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
await this.assertCompleteVisibleSubset(topologyBefore, [current, target], options.mutationJournal);
|
|
327
|
+
} catch {
|
|
328
|
+
return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
|
|
329
|
+
}
|
|
330
|
+
const preflight = await this.verifyKnownState(current, target, currentPaths, targetPaths, plan.scopePaths);
|
|
331
|
+
if (!preflight.ok) {
|
|
332
|
+
return {
|
|
333
|
+
code: "restore_failed_safe",
|
|
334
|
+
verifiedPaths: preflight.verifiedPaths,
|
|
335
|
+
totalPaths: preflight.totalPaths,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const mutationContext: MutationContext = {
|
|
340
|
+
phase: "apply",
|
|
341
|
+
ordinal: 0,
|
|
342
|
+
sourceManifestId: current.manifestId,
|
|
343
|
+
targetManifestId: target.manifestId,
|
|
344
|
+
sourcePaths: currentPaths,
|
|
345
|
+
targetPaths,
|
|
346
|
+
plannedDeletePaths: new Set(plan.deletePaths),
|
|
347
|
+
sourceIgnoredPaths: ignoredWorkspacePaths(current),
|
|
348
|
+
mutationJournal: options.mutationJournal,
|
|
349
|
+
quarantine,
|
|
350
|
+
};
|
|
351
|
+
try {
|
|
352
|
+
for (const path of plan.deletePaths) {
|
|
353
|
+
if (await this.pathIsShadowedByTarget(target.manifestId, path, targetPaths)) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
await this.deletePath(path, mutationContext);
|
|
357
|
+
}
|
|
358
|
+
await this.writePlannedPaths(target.manifestId, targetPaths, plan.writePaths, mutationContext);
|
|
359
|
+
|
|
360
|
+
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
361
|
+
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
362
|
+
await this.assertCompleteVisibleSubset(topologyAfter, [target], options.mutationJournal);
|
|
363
|
+
const verification = await this.verifyTarget(
|
|
364
|
+
target,
|
|
365
|
+
currentPaths,
|
|
366
|
+
targetPaths,
|
|
367
|
+
plan.deletePaths,
|
|
368
|
+
plan.scopePaths,
|
|
369
|
+
);
|
|
370
|
+
const result: RestoreResult = {
|
|
371
|
+
code: "ok",
|
|
372
|
+
verifiedPaths: verification.verifiedPaths,
|
|
373
|
+
totalPaths: verification.totalPaths,
|
|
374
|
+
postFingerprint: postFingerprint(target.manifestId, topologyAfter, verification.pathFingerprints),
|
|
375
|
+
};
|
|
376
|
+
return await this.mutationsAreClean(options.mutationJournal)
|
|
377
|
+
? result
|
|
378
|
+
: { code: "recovery_required", verifiedPaths: 0, totalPaths: verification.totalPaths };
|
|
379
|
+
} catch {
|
|
380
|
+
if (!await this.restorePendingMutations(mutationContext.quarantine, options.mutationJournal)) {
|
|
381
|
+
return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
|
|
382
|
+
}
|
|
383
|
+
return this.rollback(
|
|
384
|
+
current,
|
|
385
|
+
target,
|
|
386
|
+
topologyBefore,
|
|
387
|
+
currentPaths,
|
|
388
|
+
targetPaths,
|
|
389
|
+
options,
|
|
390
|
+
plan.scopePaths,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private async readOwnedPaths(manifest: SnapshotManifest): Promise<Map<string, OwnedPath>> {
|
|
396
|
+
const result = new Map<string, OwnedPath>();
|
|
397
|
+
for (const root of manifest.roots) {
|
|
398
|
+
assertNotGitMetadata(root.relativeRoot);
|
|
399
|
+
const entries = await this.store.listTree(manifest.manifestId, root.relativeRoot);
|
|
400
|
+
if (entries.length > 0) {
|
|
401
|
+
for (const boundaryPath of rootBoundaryDirectories(root.relativeRoot)) {
|
|
402
|
+
if (!result.has(boundaryPath)) {
|
|
403
|
+
result.set(boundaryPath, {
|
|
404
|
+
absolutePath: boundaryPath,
|
|
405
|
+
entry: {
|
|
406
|
+
relativePath: boundaryPath,
|
|
407
|
+
kind: "directory",
|
|
408
|
+
mode: 0o755,
|
|
409
|
+
blobId: null,
|
|
410
|
+
size: 0,
|
|
411
|
+
rootHash: root.treeId ?? root.objectClosure,
|
|
412
|
+
},
|
|
413
|
+
root,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const entry of entries) {
|
|
419
|
+
const absolutePath = workspacePath(root.relativeRoot, entry.relativePath);
|
|
420
|
+
assertNotGitMetadata(absolutePath);
|
|
421
|
+
relativeSafePath(this.workspaceRoot, absolutePath);
|
|
422
|
+
if (result.has(absolutePath)) {
|
|
423
|
+
throw new Error(`restore path 被多个 root 覆盖:${absolutePath}`);
|
|
424
|
+
}
|
|
425
|
+
result.set(absolutePath, { absolutePath, entry, root });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private assertCurrentTopology(
|
|
432
|
+
current: SnapshotManifest,
|
|
433
|
+
target: SnapshotManifest,
|
|
434
|
+
actual: RootTopology,
|
|
435
|
+
): void {
|
|
436
|
+
if (
|
|
437
|
+
actual.workspaceIdentity !== current.workspaceIdentity ||
|
|
438
|
+
actual.fingerprint !== current.topologyFingerprint
|
|
439
|
+
) {
|
|
440
|
+
throw new Error("apply 前 workspace topology 与 current manifest 不一致");
|
|
441
|
+
}
|
|
442
|
+
const currentRoots = new Map(current.roots.map((root) => [root.relativeRoot, root]));
|
|
443
|
+
for (const targetRoot of target.roots) {
|
|
444
|
+
const currentRoot = currentRoots.get(targetRoot.relativeRoot);
|
|
445
|
+
if (currentRoot !== undefined && targetRoot.state === "active" && currentRoot.state !== "active") {
|
|
446
|
+
throw new Error(`restore 不能把 inactive root 物化为 active:${targetRoot.relativeRoot}`);
|
|
447
|
+
}
|
|
448
|
+
if (
|
|
449
|
+
currentRoot !== undefined &&
|
|
450
|
+
targetRoot.state === "active" &&
|
|
451
|
+
(currentRoot.sourceIdentity !== targetRoot.sourceIdentity ||
|
|
452
|
+
currentRoot.privateRepositoryId !== targetRoot.privateRepositoryId)
|
|
453
|
+
) {
|
|
454
|
+
throw new Error(`restore boundary root identity 冲突:${targetRoot.relativeRoot}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private async verifyKnownState(
|
|
460
|
+
current: SnapshotManifest,
|
|
461
|
+
target: SnapshotManifest,
|
|
462
|
+
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
463
|
+
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
464
|
+
scopePaths?: readonly string[],
|
|
465
|
+
): Promise<{ ok: boolean; verifiedPaths: number; totalPaths: number }> {
|
|
466
|
+
const scope = scopePaths === undefined ? undefined : new Set(scopePaths);
|
|
467
|
+
const paths = [...new Set([...currentPaths.keys(), ...targetPaths.keys()])]
|
|
468
|
+
.filter((path) => scope === undefined || scope.has(path))
|
|
469
|
+
.sort(comparePaths);
|
|
470
|
+
let verifiedPaths = 0;
|
|
471
|
+
for (const path of paths) {
|
|
472
|
+
if (await this.pathIsShadowedByTarget(target.manifestId, path, targetPaths)) {
|
|
473
|
+
verifiedPaths += 1;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const currentPath = currentPaths.get(path);
|
|
477
|
+
const targetPath = targetPaths.get(path);
|
|
478
|
+
const matchesCurrent = currentPath !== undefined &&
|
|
479
|
+
await this.entryMatches(current.manifestId, currentPath);
|
|
480
|
+
const matchesTarget = !matchesCurrent && targetPath !== undefined &&
|
|
481
|
+
await this.entryMatches(target.manifestId, targetPath);
|
|
482
|
+
const matchesAbsentSide = !matchesCurrent && !matchesTarget &&
|
|
483
|
+
(currentPath === undefined || targetPath === undefined) &&
|
|
484
|
+
await this.pathIsAbsent(path);
|
|
485
|
+
if (!matchesCurrent && !matchesTarget && !matchesAbsentSide) {
|
|
486
|
+
return { ok: false, verifiedPaths, totalPaths: paths.length };
|
|
487
|
+
}
|
|
488
|
+
verifiedPaths += 1;
|
|
489
|
+
}
|
|
490
|
+
return { ok: true, verifiedPaths, totalPaths: paths.length };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private async deletePath(path: string, context: MutationContext): Promise<void> {
|
|
494
|
+
const absolutePath = this.absolutePath(path);
|
|
495
|
+
try {
|
|
496
|
+
await this.assertMutationPath(path);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
if (hasErrorCode(error, "unsafe_path")) {
|
|
499
|
+
try {
|
|
500
|
+
await lstat(absolutePath);
|
|
501
|
+
} catch (pathError) {
|
|
502
|
+
if (hasErrorCode(pathError, "ENOTDIR")) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
509
|
+
const metadata = await lstat(absolutePath).catch((error) => {
|
|
510
|
+
if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR")) return null;
|
|
511
|
+
throw error;
|
|
512
|
+
});
|
|
513
|
+
if (metadata === null) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (metadata.isDirectory() && !metadata.isSymbolicLink()) {
|
|
517
|
+
await this.mutate(context, "delete", path, async () => {
|
|
518
|
+
await rmdir(absolutePath).catch((error) => {
|
|
519
|
+
if (!hasErrorCode(error, "ENOTEMPTY") && !hasErrorCode(error, "EEXIST")) {
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
await this.mutate(context, "delete", path, async () => {
|
|
527
|
+
await context.quarantine.deleteLeaf({
|
|
528
|
+
path,
|
|
529
|
+
sourceFingerprint: await this.expectedMutationFingerprint(context, path),
|
|
530
|
+
targetFingerprint: fingerprintAbsent(path),
|
|
531
|
+
});
|
|
532
|
+
await this.cleanupLatestMutation(context);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private async writePath(
|
|
537
|
+
manifestId: ManifestId,
|
|
538
|
+
target: OwnedPath,
|
|
539
|
+
context: MutationContext,
|
|
540
|
+
): Promise<void> {
|
|
541
|
+
const path = target.absolutePath;
|
|
542
|
+
if (target.entry.kind === "directory") {
|
|
543
|
+
const metadata = await lstat(this.absolutePath(path)).catch((error) => {
|
|
544
|
+
if (hasErrorCode(error, "ENOENT")) return null;
|
|
545
|
+
throw error;
|
|
546
|
+
});
|
|
547
|
+
if (metadata !== null) {
|
|
548
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
549
|
+
throw new Error(`目录骨架存在类型冲突:${path}`);
|
|
550
|
+
}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
await this.mutate(context, "mkdir", path, () => mkdir(this.absolutePath(path)));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (target.entry.kind === "symlink") {
|
|
558
|
+
const linkText = target.entry.linkText;
|
|
559
|
+
if (linkText === undefined || Buffer.from(linkText, "utf8").toString("utf8") !== linkText) {
|
|
560
|
+
throw new Error(`symlink target 无法安全表示:${path}`);
|
|
561
|
+
}
|
|
562
|
+
const absolutePath = this.absolutePath(path);
|
|
563
|
+
const metadata = await lstat(absolutePath).catch((error) => {
|
|
564
|
+
if (hasErrorCode(error, "ENOENT")) return null;
|
|
565
|
+
throw error;
|
|
566
|
+
});
|
|
567
|
+
if (metadata !== null) {
|
|
568
|
+
if (metadata.isSymbolicLink() && await readlink(absolutePath) === linkText) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
throw new Error(`symlink 存在类型或内容冲突:${path}`);
|
|
572
|
+
}
|
|
573
|
+
await this.mutate(context, "symlink", path, async (beforeInstall) => {
|
|
574
|
+
await context.quarantine.replaceSymlink({
|
|
575
|
+
path,
|
|
576
|
+
targetLinkText: linkText,
|
|
577
|
+
sourceFingerprint: await this.expectedMutationFingerprint(context, path),
|
|
578
|
+
targetFingerprint: fingerprintSymlink(path, linkText),
|
|
579
|
+
beforeInstall,
|
|
580
|
+
});
|
|
581
|
+
await this.cleanupLatestMutation(context);
|
|
582
|
+
}, true);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (target.entry.blobId === null) {
|
|
587
|
+
throw new Error(`普通文件缺少 blob:${path}`);
|
|
588
|
+
}
|
|
589
|
+
const bytes = await this.store.readBlob(manifestId, target.root.relativeRoot, target.entry.blobId);
|
|
590
|
+
if (bytes.byteLength !== target.entry.size) {
|
|
591
|
+
throw new Error(`普通文件 blob 大小不匹配:${path}`);
|
|
592
|
+
}
|
|
593
|
+
await this.mutate(
|
|
594
|
+
context,
|
|
595
|
+
"write",
|
|
596
|
+
path,
|
|
597
|
+
async (beforeInstall) => {
|
|
598
|
+
await context.quarantine.replaceFile({
|
|
599
|
+
path,
|
|
600
|
+
targetBytes: bytes,
|
|
601
|
+
targetMode: target.entry.mode & 0o777,
|
|
602
|
+
sourceFingerprint: await this.expectedMutationFingerprint(context, path),
|
|
603
|
+
targetFingerprint: fingerprintBytes(path, bytes, target.entry.mode),
|
|
604
|
+
beforeInstall,
|
|
605
|
+
});
|
|
606
|
+
await this.cleanupLatestMutation(context);
|
|
607
|
+
},
|
|
608
|
+
true,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private async rollback(
|
|
613
|
+
current: SnapshotManifest,
|
|
614
|
+
target: SnapshotManifest,
|
|
615
|
+
topologyBefore: RootTopology,
|
|
616
|
+
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
617
|
+
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
618
|
+
options: RestoreApplyOptions,
|
|
619
|
+
scopePaths?: readonly string[],
|
|
620
|
+
): Promise<RestoreResult> {
|
|
621
|
+
let rollbackPlan: RestorePlan | undefined;
|
|
622
|
+
try {
|
|
623
|
+
rollbackPlan = await this.plan(target, current, scopePaths);
|
|
624
|
+
const context: MutationContext = {
|
|
625
|
+
phase: "rollback",
|
|
626
|
+
ordinal: 0,
|
|
627
|
+
sourceManifestId: target.manifestId,
|
|
628
|
+
targetManifestId: current.manifestId,
|
|
629
|
+
sourcePaths: targetPaths,
|
|
630
|
+
targetPaths: currentPaths,
|
|
631
|
+
plannedDeletePaths: new Set(rollbackPlan.deletePaths),
|
|
632
|
+
sourceIgnoredPaths: ignoredWorkspacePaths(target),
|
|
633
|
+
mutationJournal: options.mutationJournal,
|
|
634
|
+
quarantine: new QuarantineManager({
|
|
635
|
+
workspaceRoot: this.requestedWorkspaceRoot,
|
|
636
|
+
journal: options.mutationJournal,
|
|
637
|
+
}),
|
|
638
|
+
};
|
|
639
|
+
for (const path of rollbackPlan.deletePaths) {
|
|
640
|
+
if (await this.pathIsShadowedByTarget(current.manifestId, path, currentPaths)) {
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
await this.deletePath(path, context);
|
|
644
|
+
}
|
|
645
|
+
await this.writePlannedPaths(current.manifestId, currentPaths, rollbackPlan.writePaths, context);
|
|
646
|
+
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
647
|
+
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
648
|
+
await this.assertCompleteVisibleSubset(topologyAfter, [current], options.mutationJournal);
|
|
649
|
+
const verification = await this.verifyTarget(
|
|
650
|
+
current,
|
|
651
|
+
targetPaths,
|
|
652
|
+
currentPaths,
|
|
653
|
+
rollbackPlan.deletePaths,
|
|
654
|
+
rollbackPlan.scopePaths,
|
|
655
|
+
);
|
|
656
|
+
const result: RestoreResult = {
|
|
657
|
+
code: "restore_failed_safe",
|
|
658
|
+
verifiedPaths: verification.verifiedPaths,
|
|
659
|
+
totalPaths: verification.totalPaths,
|
|
660
|
+
postFingerprint: postFingerprint(
|
|
661
|
+
current.manifestId,
|
|
662
|
+
topologyAfter,
|
|
663
|
+
verification.pathFingerprints,
|
|
664
|
+
),
|
|
665
|
+
};
|
|
666
|
+
return await this.mutationsAreClean(options.mutationJournal)
|
|
667
|
+
? result
|
|
668
|
+
: { code: "recovery_required", verifiedPaths: 0, totalPaths: verification.totalPaths };
|
|
669
|
+
} catch {
|
|
670
|
+
const pendingRestored = await this.restorePendingMutations(
|
|
671
|
+
new QuarantineManager({
|
|
672
|
+
workspaceRoot: this.requestedWorkspaceRoot,
|
|
673
|
+
journal: options.mutationJournal,
|
|
674
|
+
}),
|
|
675
|
+
options.mutationJournal,
|
|
676
|
+
);
|
|
677
|
+
if (!pendingRestored) {
|
|
678
|
+
return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
|
|
679
|
+
}
|
|
680
|
+
if (rollbackPlan !== undefined) {
|
|
681
|
+
try {
|
|
682
|
+
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
683
|
+
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
684
|
+
await this.assertCompleteVisibleSubset(topologyAfter, [current], options.mutationJournal);
|
|
685
|
+
const verification = await this.verifyTarget(
|
|
686
|
+
current,
|
|
687
|
+
targetPaths,
|
|
688
|
+
currentPaths,
|
|
689
|
+
rollbackPlan.deletePaths,
|
|
690
|
+
rollbackPlan.scopePaths,
|
|
691
|
+
);
|
|
692
|
+
const result: RestoreResult = {
|
|
693
|
+
code: "restore_failed_safe",
|
|
694
|
+
verifiedPaths: verification.verifiedPaths,
|
|
695
|
+
totalPaths: verification.totalPaths,
|
|
696
|
+
postFingerprint: postFingerprint(
|
|
697
|
+
current.manifestId,
|
|
698
|
+
topologyAfter,
|
|
699
|
+
verification.pathFingerprints,
|
|
700
|
+
),
|
|
701
|
+
};
|
|
702
|
+
return await this.mutationsAreClean(options.mutationJournal)
|
|
703
|
+
? result
|
|
704
|
+
: { code: "recovery_required", verifiedPaths: 0, totalPaths: verification.totalPaths };
|
|
705
|
+
} catch {
|
|
706
|
+
// 完整 current 状态仍不可证明,继续返回保守的 partial/recovery 结果。
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
let verifiedPaths = 0;
|
|
710
|
+
const scope = scopePaths === undefined ? undefined : new Set(scopePaths);
|
|
711
|
+
for (const [path, owned] of currentPaths) {
|
|
712
|
+
if (scope !== undefined && !scope.has(path)) continue;
|
|
713
|
+
try {
|
|
714
|
+
await this.verifyEntry(current.manifestId, owned);
|
|
715
|
+
verifiedPaths += 1;
|
|
716
|
+
} catch {
|
|
717
|
+
// rollback 已失败,只统计仍可证明安全的路径。
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
code: verifiedPaths > 0 ? "partial_restore" : "recovery_required",
|
|
722
|
+
verifiedPaths,
|
|
723
|
+
totalPaths: scope === undefined ? currentPaths.size : scope.size,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private async mutate(
|
|
729
|
+
context: MutationContext,
|
|
730
|
+
kind: RestoreMutation["kind"],
|
|
731
|
+
path: string,
|
|
732
|
+
mutation: (beforeInstall: () => Promise<void>) => Promise<void>,
|
|
733
|
+
deferHook = false,
|
|
734
|
+
): Promise<void> {
|
|
735
|
+
context.ordinal += 1;
|
|
736
|
+
const beforeInstall = async (): Promise<void> => {
|
|
737
|
+
await this.beforeMutation?.({ phase: context.phase, ordinal: context.ordinal, kind, path });
|
|
738
|
+
};
|
|
739
|
+
if (!deferHook) await beforeInstall();
|
|
740
|
+
await this.assertMutationPath(path);
|
|
741
|
+
await this.assertMutationState(context, kind, path);
|
|
742
|
+
await mutation(deferHook ? beforeInstall : async () => {});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private async writePlannedPaths(
|
|
746
|
+
manifestId: ManifestId,
|
|
747
|
+
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
748
|
+
writePaths: readonly string[],
|
|
749
|
+
context: MutationContext,
|
|
750
|
+
): Promise<void> {
|
|
751
|
+
for (const kind of ["directory", "leaf"] as const) {
|
|
752
|
+
for (const path of writePaths) {
|
|
753
|
+
const target = targetPaths.get(path);
|
|
754
|
+
if (target === undefined) {
|
|
755
|
+
throw new Error(`${context.phase} plan 引用了 manifest 外路径:${path}`);
|
|
756
|
+
}
|
|
757
|
+
if ((target.entry.kind === "directory") !== (kind === "directory")) {
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
if (context.sourceIgnoredPaths.has(path)) {
|
|
761
|
+
if (await this.entryMatches(manifestId, target)) {
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
throw new Error(`${context.phase} 的 ignored-present 路径与目标内容冲突:${path}`);
|
|
765
|
+
}
|
|
766
|
+
await this.writePath(manifestId, target, context);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private async assertMutationState(
|
|
772
|
+
context: MutationContext,
|
|
773
|
+
kind: RestoreMutation["kind"],
|
|
774
|
+
path: string,
|
|
775
|
+
): Promise<void> {
|
|
776
|
+
if (
|
|
777
|
+
kind === "delete" &&
|
|
778
|
+
(await this.pathIsShadowedByTarget(context.targetManifestId, path, context.targetPaths) ||
|
|
779
|
+
await this.pathIsShadowedByTarget(context.sourceManifestId, path, context.sourcePaths))
|
|
780
|
+
) {
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const source = context.sourcePaths.get(path);
|
|
784
|
+
if (source !== undefined && await this.entryMatches(context.sourceManifestId, source)) {
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const target = context.targetPaths.get(path);
|
|
788
|
+
if (target !== undefined && await this.entryMatches(context.targetManifestId, target)) {
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (await this.pathIsAbsent(path)) {
|
|
792
|
+
if (kind === "delete" || source === undefined || context.plannedDeletePaths.has(path)) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
throw new Error(`${context.phase} mutation 前路径不再处于已知状态:${path}`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private async assertCompleteVisibleSubset(
|
|
800
|
+
topology: RootTopology,
|
|
801
|
+
allowedManifests: readonly SnapshotManifest[],
|
|
802
|
+
mutationJournal?: MutationJournal,
|
|
803
|
+
): Promise<void> {
|
|
804
|
+
if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const allowedPaths = new Set<string>();
|
|
808
|
+
for (const manifest of allowedManifests) {
|
|
809
|
+
for (const path of ignoredWorkspacePaths(manifest)) {
|
|
810
|
+
allowedPaths.add(path);
|
|
811
|
+
}
|
|
812
|
+
const paths = await this.readOwnedPaths(manifest);
|
|
813
|
+
for (const [path, owned] of paths) {
|
|
814
|
+
if (owned.entry.kind !== "directory") {
|
|
815
|
+
allowedPaths.add(path);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const live = await this.store.capture(topology, undefined, {
|
|
821
|
+
excludePaths: mutationJournal === undefined ? undefined : [...await mutationJournal.activeArtifacts()],
|
|
822
|
+
});
|
|
823
|
+
await this.store.assertComplete(live.manifestId);
|
|
824
|
+
for (const [path, owned] of await this.readOwnedPaths(live)) {
|
|
825
|
+
if (owned.entry.kind !== "directory" && !allowedPaths.has(path)) {
|
|
826
|
+
throw new Error(`complete coverage 发现 manifest 集合外路径:${path}`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private async verifyTarget(
|
|
832
|
+
target: SnapshotManifest,
|
|
833
|
+
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
834
|
+
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
835
|
+
deletePaths: readonly string[],
|
|
836
|
+
scopePaths?: readonly string[],
|
|
837
|
+
): Promise<{ verifiedPaths: number; totalPaths: number; pathFingerprints: string[] }> {
|
|
838
|
+
const pathFingerprints: string[] = [];
|
|
839
|
+
const scope = scopePaths === undefined ? undefined : new Set(scopePaths);
|
|
840
|
+
for (const [path, owned] of targetPaths) {
|
|
841
|
+
if (scope !== undefined && !scope.has(path)) continue;
|
|
842
|
+
pathFingerprints.push(await this.verifyEntry(target.manifestId, owned));
|
|
843
|
+
}
|
|
844
|
+
let verifiedPaths = pathFingerprints.length;
|
|
845
|
+
let totalPaths = pathFingerprints.length;
|
|
846
|
+
for (const path of deletePaths) {
|
|
847
|
+
if (
|
|
848
|
+
targetPaths.has(path) ||
|
|
849
|
+
currentPaths.get(path)?.entry.kind === "directory" ||
|
|
850
|
+
hasNonDirectoryAncestor(path, targetPaths)
|
|
851
|
+
) {
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
totalPaths += 1;
|
|
855
|
+
try {
|
|
856
|
+
await lstat(this.absolutePath(path));
|
|
857
|
+
throw new Error(`目标应删除的路径仍然存在:${path}`);
|
|
858
|
+
} catch (error) {
|
|
859
|
+
if (!hasErrorCode(error, "ENOENT") && !hasErrorCode(error, "ENOTDIR")) {
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
verifiedPaths += 1;
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
verifiedPaths,
|
|
867
|
+
totalPaths,
|
|
868
|
+
pathFingerprints,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private async entryMatches(manifestId: ManifestId, owned: OwnedPath): Promise<boolean> {
|
|
873
|
+
try {
|
|
874
|
+
await this.verifyEntry(manifestId, owned);
|
|
875
|
+
return true;
|
|
876
|
+
} catch {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
private async pathIsAbsent(path: string): Promise<boolean> {
|
|
882
|
+
try {
|
|
883
|
+
await lstat(this.absolutePath(path));
|
|
884
|
+
return false;
|
|
885
|
+
} catch (error) {
|
|
886
|
+
if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR")) {
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
throw error;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
private async expectedMutationFingerprint(context: MutationContext, path: string): Promise<string> {
|
|
894
|
+
for (const [manifestId, owned] of [
|
|
895
|
+
[context.sourceManifestId, context.sourcePaths.get(path)],
|
|
896
|
+
[context.targetManifestId, context.targetPaths.get(path)],
|
|
897
|
+
] as const) {
|
|
898
|
+
if (owned === undefined || owned.entry.kind === "directory") continue;
|
|
899
|
+
if (!await this.entryMatches(manifestId, owned)) continue;
|
|
900
|
+
if (owned.entry.kind === "symlink") {
|
|
901
|
+
return fingerprintSymlink(path, owned.entry.linkText!);
|
|
902
|
+
}
|
|
903
|
+
if (owned.entry.blobId === null) throw new Error(`普通文件缺少 blob:${path}`);
|
|
904
|
+
const bytes = await this.store.readBlob(manifestId, owned.root.relativeRoot, owned.entry.blobId);
|
|
905
|
+
return fingerprintBytes(path, bytes, owned.entry.mode);
|
|
906
|
+
}
|
|
907
|
+
if (await this.pathIsAbsent(path)) return fingerprintAbsent(path);
|
|
908
|
+
throw new Error(`mutation 前路径不再处于已知叶子状态:${path}`);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
private async mutationsAreClean(journal: MutationJournal): Promise<boolean> {
|
|
912
|
+
try {
|
|
913
|
+
await journal.assertCleaned();
|
|
914
|
+
return true;
|
|
915
|
+
} catch {
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
private async cleanupLatestMutation(context: MutationContext): Promise<void> {
|
|
921
|
+
const records = await context.mutationJournal.load();
|
|
922
|
+
const record = records.at(-1);
|
|
923
|
+
if (record === undefined) throw new Error("quarantine mutation 记录缺失");
|
|
924
|
+
await context.quarantine.cleanupMutation(record);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
private async restorePendingMutations(
|
|
928
|
+
quarantine: QuarantineManager,
|
|
929
|
+
journal: MutationJournal,
|
|
930
|
+
): Promise<boolean> {
|
|
931
|
+
try {
|
|
932
|
+
for (const record of [...await journal.load()].reverse()) {
|
|
933
|
+
if (record.state !== "CLEANED") await quarantine.restoreMutation(record);
|
|
934
|
+
}
|
|
935
|
+
await journal.assertCleaned();
|
|
936
|
+
return true;
|
|
937
|
+
} catch {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
private async pathIsShadowedByTarget(
|
|
943
|
+
manifestId: ManifestId,
|
|
944
|
+
path: string,
|
|
945
|
+
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
946
|
+
): Promise<boolean> {
|
|
947
|
+
for (const ancestor of strictPathAncestors(path)) {
|
|
948
|
+
const target = targetPaths.get(ancestor);
|
|
949
|
+
if (target !== undefined && target.entry.kind !== "directory") {
|
|
950
|
+
return this.entryMatches(manifestId, target);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
private async verifyEntry(manifestId: ManifestId, owned: OwnedPath): Promise<string> {
|
|
957
|
+
const path = owned.absolutePath;
|
|
958
|
+
await assertNoSymlinkEscape(this.workspaceRoot, path);
|
|
959
|
+
const metadata = await lstat(this.absolutePath(path));
|
|
960
|
+
if (owned.entry.kind === "directory") {
|
|
961
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
962
|
+
throw new Error(`目录类型校验失败:${path}`);
|
|
963
|
+
}
|
|
964
|
+
return checksum(canonicalJson({ path, kind: "directory" }));
|
|
965
|
+
}
|
|
966
|
+
if (owned.entry.kind === "symlink") {
|
|
967
|
+
if (!metadata.isSymbolicLink() || await readlink(this.absolutePath(path)) !== owned.entry.linkText) {
|
|
968
|
+
throw new Error(`symlink 校验失败:${path}`);
|
|
969
|
+
}
|
|
970
|
+
return checksum(canonicalJson({ path, kind: "symlink", linkText: owned.entry.linkText }));
|
|
971
|
+
}
|
|
972
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
973
|
+
throw new Error(`普通文件类型校验失败:${path}`);
|
|
974
|
+
}
|
|
975
|
+
if ((metadata.mode & 0o111) !== (owned.entry.mode & 0o111)) {
|
|
976
|
+
throw new Error(`普通文件 mode 校验失败:${path}`);
|
|
977
|
+
}
|
|
978
|
+
if (owned.entry.blobId === null) {
|
|
979
|
+
throw new Error(`普通文件缺少 blob:${path}`);
|
|
980
|
+
}
|
|
981
|
+
const [actual, expected] = await Promise.all([
|
|
982
|
+
readFile(this.absolutePath(path)),
|
|
983
|
+
this.store.readBlob(manifestId, owned.root.relativeRoot, owned.entry.blobId),
|
|
984
|
+
]);
|
|
985
|
+
if (!actual.equals(Buffer.from(expected))) {
|
|
986
|
+
throw new Error(`普通文件内容校验失败:${path}`);
|
|
987
|
+
}
|
|
988
|
+
return checksum(canonicalJson({
|
|
989
|
+
path,
|
|
990
|
+
kind: "file",
|
|
991
|
+
mode: owned.entry.mode,
|
|
992
|
+
blobId: owned.entry.blobId,
|
|
993
|
+
}));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
private async assertMutationPath(path: string): Promise<void> {
|
|
997
|
+
await this.assertWorkspaceRootIdentity();
|
|
998
|
+
assertNotGitMetadata(path);
|
|
999
|
+
relativeSafePath(this.workspaceRoot, path);
|
|
1000
|
+
await assertNoSymlinkEscape(this.workspaceRoot, path);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
private async assertWorkspaceRootIdentity(): Promise<string> {
|
|
1004
|
+
const [requestedIdentity, workspaceIdentity] = await Promise.all([
|
|
1005
|
+
realpath(this.requestedWorkspaceRoot),
|
|
1006
|
+
realpath(this.workspaceRoot),
|
|
1007
|
+
]);
|
|
1008
|
+
if (requestedIdentity !== this.workspaceRoot || workspaceIdentity !== this.workspaceRoot) {
|
|
1009
|
+
throw new Error("restore workspace root identity 已变化");
|
|
1010
|
+
}
|
|
1011
|
+
return workspaceIdentity;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
private absolutePath(path: string): string {
|
|
1015
|
+
return join(this.workspaceRoot, ...path.split("/"));
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
private canonicalScope(paths: readonly string[]): ReadonlySet<string> {
|
|
1019
|
+
const canonical = [...new Set(paths)].sort(comparePaths);
|
|
1020
|
+
for (const path of canonical) {
|
|
1021
|
+
assertNotGitMetadata(path);
|
|
1022
|
+
relativeSafePath(this.workspaceRoot, path);
|
|
1023
|
+
}
|
|
1024
|
+
return new Set(canonical);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function sameEntry(left: RestorePath, right: RestorePath): boolean {
|
|
1029
|
+
return left.kind === right.kind &&
|
|
1030
|
+
left.mode === right.mode &&
|
|
1031
|
+
left.blobId === right.blobId &&
|
|
1032
|
+
left.size === right.size &&
|
|
1033
|
+
left.linkText === right.linkText;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function assertCompatibleManifests(current: SnapshotManifest, target: SnapshotManifest): void {
|
|
1037
|
+
if (current.workspaceIdentity !== target.workspaceIdentity) {
|
|
1038
|
+
throw new Error("restore manifest 不属于同一 workspace");
|
|
1039
|
+
}
|
|
1040
|
+
if (current.coverage !== target.coverage) {
|
|
1041
|
+
throw new Error("restore manifest coverage 不一致,不能推断缺失路径");
|
|
1042
|
+
}
|
|
1043
|
+
if (current.roots.some((root) => root.state === "broken") || target.roots.some((root) => root.state === "broken")) {
|
|
1044
|
+
throw new Error("broken root 不能用于 restore");
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function orderedWritePaths(
|
|
1049
|
+
roots: readonly SnapshotRoot[],
|
|
1050
|
+
paths: ReadonlyMap<string, readonly string[]>,
|
|
1051
|
+
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
1052
|
+
): string[] {
|
|
1053
|
+
const directories = [...paths.values()].flat().filter(
|
|
1054
|
+
(path) => targetPaths.get(path)?.entry.kind === "directory",
|
|
1055
|
+
);
|
|
1056
|
+
const result: string[] = [];
|
|
1057
|
+
for (const root of roots) {
|
|
1058
|
+
const leaves = (paths.get(root.relativeRoot) ?? []).filter(
|
|
1059
|
+
(path) => targetPaths.get(path)?.entry.kind !== "directory",
|
|
1060
|
+
);
|
|
1061
|
+
result.push(...sortWritePaths(leaves));
|
|
1062
|
+
}
|
|
1063
|
+
return [...sortWritePaths(directories), ...result];
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function appendPath(paths: Map<string, string[]>, root: string, path: string): void {
|
|
1067
|
+
const owned = paths.get(root);
|
|
1068
|
+
if (owned === undefined) {
|
|
1069
|
+
paths.set(root, [path]);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
owned.push(path);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function workspacePath(root: string, path: string): string {
|
|
1076
|
+
return root === "." ? path : path === "." ? root : `${root}/${path}`;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function ignoredWorkspacePaths(manifest: SnapshotManifest): Set<string> {
|
|
1080
|
+
return new Set(manifest.roots.flatMap((root) =>
|
|
1081
|
+
root.ignoredPresentPaths.map((path) => workspacePath(root.relativeRoot, path))
|
|
1082
|
+
));
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function isProtectedByIgnoredProof(
|
|
1086
|
+
path: string,
|
|
1087
|
+
kind: RestorePath["kind"],
|
|
1088
|
+
ignoredPaths: ReadonlySet<string>,
|
|
1089
|
+
): boolean {
|
|
1090
|
+
if (kind !== "directory") {
|
|
1091
|
+
return ignoredPaths.has(path);
|
|
1092
|
+
}
|
|
1093
|
+
return [...ignoredPaths].some((ignoredPath) => ignoredPath.startsWith(`${path}/`));
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function rootBoundaryDirectories(root: string): string[] {
|
|
1097
|
+
if (root === ".") {
|
|
1098
|
+
return [];
|
|
1099
|
+
}
|
|
1100
|
+
const parts = root.split("/");
|
|
1101
|
+
return parts.map((_part, index) => parts.slice(0, index + 1).join("/"));
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function strictPathAncestors(path: string): string[] {
|
|
1105
|
+
const parts = path.split("/");
|
|
1106
|
+
return parts.slice(0, -1).map((_part, index) => parts.slice(0, index + 1).join("/")).reverse();
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function hasNonDirectoryAncestor(path: string, paths: ReadonlyMap<string, OwnedPath>): boolean {
|
|
1110
|
+
return strictPathAncestors(path).some((ancestor) => {
|
|
1111
|
+
const owned = paths.get(ancestor);
|
|
1112
|
+
return owned !== undefined && owned.entry.kind !== "directory";
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function comparePaths(left: string, right: string): number {
|
|
1117
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function assertNotGitMetadata(path: string): void {
|
|
1121
|
+
if (path.split("/").some((component) => component.toLowerCase() === ".git")) {
|
|
1122
|
+
throw new Error(`restore 永远不操作真实 Git metadata:${path}`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function assertUnchangedTopology(before: RootTopology, after: RootTopology): void {
|
|
1127
|
+
const rootKindsMatch = before.roots.length === after.roots.length && before.roots.every((root, index) => {
|
|
1128
|
+
const candidate = after.roots[index];
|
|
1129
|
+
return candidate !== undefined &&
|
|
1130
|
+
candidate.relativeRoot === root.relativeRoot &&
|
|
1131
|
+
candidate.gitBacked === root.gitBacked;
|
|
1132
|
+
});
|
|
1133
|
+
if (
|
|
1134
|
+
before.workspaceIdentity !== after.workspaceIdentity ||
|
|
1135
|
+
before.fingerprint !== after.fingerprint ||
|
|
1136
|
+
!rootKindsMatch
|
|
1137
|
+
) {
|
|
1138
|
+
throw new Error("restore 期间 workspace topology 发生变化");
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function postFingerprint(
|
|
1143
|
+
manifestId: ManifestId,
|
|
1144
|
+
topology: RootTopology,
|
|
1145
|
+
pathFingerprints: readonly string[],
|
|
1146
|
+
): string {
|
|
1147
|
+
return checksum(canonicalJson({
|
|
1148
|
+
manifestId,
|
|
1149
|
+
topologyFingerprint: topology.fingerprint,
|
|
1150
|
+
paths: pathFingerprints,
|
|
1151
|
+
}));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function hasValidPlanDigest(plan: RestorePlan): boolean {
|
|
1155
|
+
const keys = Object.keys(plan).sort();
|
|
1156
|
+
const expectedKeys = [
|
|
1157
|
+
"boundaryRoots",
|
|
1158
|
+
"currentManifestId",
|
|
1159
|
+
"deletePaths",
|
|
1160
|
+
"planDigest",
|
|
1161
|
+
...(plan.scopePaths === undefined ? [] : ["scopePaths"]),
|
|
1162
|
+
"targetManifestId",
|
|
1163
|
+
"writePaths",
|
|
1164
|
+
].sort();
|
|
1165
|
+
if (canonicalJson(keys) !== canonicalJson(expectedKeys)) {
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
try {
|
|
1169
|
+
return checksum(canonicalJson({
|
|
1170
|
+
currentManifestId: plan.currentManifestId,
|
|
1171
|
+
targetManifestId: plan.targetManifestId,
|
|
1172
|
+
boundaryRoots: plan.boundaryRoots,
|
|
1173
|
+
deletePaths: plan.deletePaths,
|
|
1174
|
+
writePaths: plan.writePaths,
|
|
1175
|
+
...(plan.scopePaths === undefined ? {} : { scopePaths: plan.scopePaths }),
|
|
1176
|
+
})) === plan.planDigest;
|
|
1177
|
+
} catch {
|
|
1178
|
+
return false;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
1183
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1184
|
+
}
|