@davideasden/pi-undo 0.1.2 → 0.2.1
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/README.md +147 -85
- package/extensions/pi-undo.ts +172 -16
- package/package.json +1 -1
- package/src/atomic-fs.ts +7 -2
- package/src/controller.ts +97 -38
- package/src/mutation-journal.ts +180 -40
- package/src/pi-runtime.ts +2 -2
- package/src/quarantine.ts +357 -17
- package/src/restore-engine.ts +269 -49
- package/src/snapshot-store.ts +316 -63
- package/src/status-reporter.ts +15 -2
package/src/quarantine.ts
CHANGED
|
@@ -9,6 +9,8 @@ import type { MutationJournal } from "./mutation-journal.ts";
|
|
|
9
9
|
import type { MutationRecord } from "./model.ts";
|
|
10
10
|
import { assertNoSymlinkEscape, relativeSafePath } from "./path-safety.ts";
|
|
11
11
|
|
|
12
|
+
const BATCH_FILE_IO_CONCURRENCY = 32;
|
|
13
|
+
|
|
12
14
|
export interface ReplaceFileRequest {
|
|
13
15
|
readonly path: string;
|
|
14
16
|
readonly targetBytes: Uint8Array;
|
|
@@ -18,6 +20,14 @@ export interface ReplaceFileRequest {
|
|
|
18
20
|
readonly beforeInstall?: () => void | Promise<void>;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
interface PreparedFileReplacement {
|
|
24
|
+
readonly request: ReplaceFileRequest;
|
|
25
|
+
readonly sourceArtifact: string;
|
|
26
|
+
readonly targetArtifact: string;
|
|
27
|
+
readonly targetAbsolutePath: string;
|
|
28
|
+
readonly intent: MutationRecord;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
export interface ReplaceSymlinkRequest {
|
|
22
32
|
readonly path: string;
|
|
23
33
|
readonly targetLinkText: string;
|
|
@@ -89,7 +99,7 @@ export class QuarantineManager {
|
|
|
89
99
|
this.beforeTargetCreate = options.beforeTargetCreate;
|
|
90
100
|
}
|
|
91
101
|
|
|
92
|
-
async replaceFile(request: ReplaceFileRequest): Promise<
|
|
102
|
+
async replaceFile(request: ReplaceFileRequest): Promise<MutationRecord> {
|
|
93
103
|
await this.assertWorkspaceIdentity();
|
|
94
104
|
await this.assertPath(request.path);
|
|
95
105
|
assertFingerprint(request.sourceFingerprint);
|
|
@@ -106,7 +116,12 @@ export class QuarantineManager {
|
|
|
106
116
|
const targetArtifact = this.absolute(artifacts.targetArtifact!);
|
|
107
117
|
await this.beforeTargetCreate?.();
|
|
108
118
|
await this.assertMutationPaths(request.path, artifacts.targetArtifact!);
|
|
109
|
-
await writeBytesExclusive(
|
|
119
|
+
await writeBytesExclusive(
|
|
120
|
+
targetArtifact,
|
|
121
|
+
request.targetBytes,
|
|
122
|
+
request.targetMode,
|
|
123
|
+
{ syncDirectory: false },
|
|
124
|
+
);
|
|
110
125
|
await this.assertFingerprint(targetArtifact, request.path, request.targetFingerprint);
|
|
111
126
|
await this.quarantineSource(intent);
|
|
112
127
|
await request.beforeInstall?.();
|
|
@@ -121,15 +136,196 @@ export class QuarantineManager {
|
|
|
121
136
|
throw error;
|
|
122
137
|
}
|
|
123
138
|
await fsyncDirectory(dirname(this.absolute(request.path)));
|
|
124
|
-
await this.journal.advance(intent.ordinal, "TARGET_INSTALLED");
|
|
125
139
|
await this.assertFingerprint(this.absolute(request.path), request.path, request.targetFingerprint);
|
|
126
140
|
await this.assertArtifactPath(request.path, artifacts.targetArtifact!);
|
|
141
|
+
const targetStates = await this.journal.advanceMany(
|
|
142
|
+
intent.ordinal,
|
|
143
|
+
["SOURCE_QUARANTINED", "SOURCE_VERIFIED", "TARGET_INSTALLED", "TARGET_VERIFIED"],
|
|
144
|
+
);
|
|
127
145
|
await unlink(targetArtifact);
|
|
128
|
-
|
|
129
|
-
|
|
146
|
+
return targetStates.at(-1)!;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async replaceFiles(requests: readonly ReplaceFileRequest[]): Promise<void> {
|
|
150
|
+
if (requests.length === 0) return;
|
|
151
|
+
const prepared: Array<Omit<PreparedFileReplacement, "intent">> = [];
|
|
152
|
+
for (const request of requests) {
|
|
153
|
+
await this.assertWorkspaceIdentity();
|
|
154
|
+
await this.assertPath(request.path);
|
|
155
|
+
assertFingerprint(request.sourceFingerprint);
|
|
156
|
+
assertFingerprint(request.targetFingerprint);
|
|
157
|
+
assertMode(request.targetMode);
|
|
158
|
+
const artifacts = await this.artifactPaths(request.path, true);
|
|
159
|
+
prepared.push({
|
|
160
|
+
request,
|
|
161
|
+
sourceArtifact: artifacts.sourceArtifact,
|
|
162
|
+
targetArtifact: artifacts.targetArtifact!,
|
|
163
|
+
targetAbsolutePath: this.absolute(artifacts.targetArtifact!),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const intents = await this.journal.beginMany(prepared.map(({ request, sourceArtifact, targetArtifact }) => ({
|
|
167
|
+
kind: "write" as const,
|
|
168
|
+
path: request.path,
|
|
169
|
+
sourceArtifact,
|
|
170
|
+
targetArtifact,
|
|
171
|
+
sourceFingerprint: request.sourceFingerprint,
|
|
172
|
+
targetFingerprint: request.targetFingerprint,
|
|
173
|
+
})));
|
|
174
|
+
const replacements: PreparedFileReplacement[] = prepared.map((replacement, index) => ({
|
|
175
|
+
...replacement,
|
|
176
|
+
intent: intents[index]!,
|
|
177
|
+
}));
|
|
178
|
+
const createTarget = async (replacement: PreparedFileReplacement): Promise<void> => {
|
|
179
|
+
await this.beforeTargetCreate?.();
|
|
180
|
+
await this.assertMutationPaths(replacement.request.path, replacement.targetArtifact);
|
|
181
|
+
await writeBytesExclusive(
|
|
182
|
+
replacement.targetAbsolutePath,
|
|
183
|
+
replacement.request.targetBytes,
|
|
184
|
+
replacement.request.targetMode,
|
|
185
|
+
{ syncDirectory: false },
|
|
186
|
+
);
|
|
187
|
+
await this.assertFingerprint(
|
|
188
|
+
replacement.targetAbsolutePath,
|
|
189
|
+
replacement.request.path,
|
|
190
|
+
replacement.request.targetFingerprint,
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
if (this.beforeTargetCreate === undefined) {
|
|
194
|
+
await mapConcurrentFailClosed(replacements, BATCH_FILE_IO_CONCURRENCY, createTarget);
|
|
195
|
+
} else {
|
|
196
|
+
for (const replacement of replacements) await createTarget(replacement);
|
|
197
|
+
}
|
|
198
|
+
await this.quarantineFileSources(replacements);
|
|
199
|
+
const installDirectories = new Set<string>();
|
|
200
|
+
for (const replacement of replacements) {
|
|
201
|
+
const { request, targetArtifact, targetAbsolutePath } = replacement;
|
|
202
|
+
await request.beforeInstall?.();
|
|
203
|
+
await this.assertMutationPaths(request.path, targetArtifact);
|
|
204
|
+
await this.assertFingerprint(targetAbsolutePath, request.path, request.targetFingerprint);
|
|
205
|
+
try {
|
|
206
|
+
await this.linkFile(targetAbsolutePath, this.absolute(request.path));
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (hasErrorCode(error, "EEXIST")) {
|
|
209
|
+
throw new QuarantineError("external_concurrency", `检测到外部并发修改:${request.path}`);
|
|
210
|
+
}
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
installDirectories.add(dirname(this.absolute(request.path)));
|
|
214
|
+
}
|
|
215
|
+
await syncDirectories(installDirectories);
|
|
216
|
+
for (const { request, targetArtifact, targetAbsolutePath } of replacements) {
|
|
217
|
+
await this.assertArtifactPath(request.path, targetArtifact);
|
|
218
|
+
await this.assertSameFileIdentity(this.absolute(request.path), targetAbsolutePath, request.path);
|
|
219
|
+
await this.assertFingerprint(targetAbsolutePath, request.path, request.targetFingerprint);
|
|
220
|
+
}
|
|
221
|
+
const targetRecords = await this.journal.advanceBatch(replacements.map(({ intent }) => ({
|
|
222
|
+
ordinal: intent.ordinal,
|
|
223
|
+
states: ["SOURCE_QUARANTINED", "SOURCE_VERIFIED", "TARGET_INSTALLED", "TARGET_VERIFIED"],
|
|
224
|
+
})));
|
|
225
|
+
const verifiedByOrdinal = new Map(
|
|
226
|
+
targetRecords.filter((record) => record.state === "TARGET_VERIFIED")
|
|
227
|
+
.map((record) => [record.ordinal, record]),
|
|
228
|
+
);
|
|
229
|
+
const verifiedRecords: MutationRecord[] = [];
|
|
230
|
+
for (const replacement of replacements) {
|
|
231
|
+
const verified = verifiedByOrdinal.get(replacement.intent.ordinal);
|
|
232
|
+
if (verified === undefined) throw new Error("批量 TARGET_VERIFIED record 缺失");
|
|
233
|
+
verifiedRecords.push(await this.assertOwnedRecord(verified));
|
|
234
|
+
}
|
|
235
|
+
await this.cleanupVerifiedArtifactsBatch(verifiedRecords);
|
|
236
|
+
await this.journal.advanceBatch(replacements.map(({ intent }) => ({
|
|
237
|
+
ordinal: intent.ordinal,
|
|
238
|
+
states: ["CLEANED"],
|
|
239
|
+
})));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async quarantineFileSources(
|
|
243
|
+
replacements: readonly { readonly intent: MutationRecord }[],
|
|
244
|
+
): Promise<void> {
|
|
245
|
+
const capturedDirectories = new Set<string>();
|
|
246
|
+
for (const { intent } of replacements) {
|
|
247
|
+
const original = this.absolute(intent.path);
|
|
248
|
+
const source = this.absolute(intent.sourceArtifact);
|
|
249
|
+
await this.assertFingerprint(original, intent.path, intent.sourceFingerprint);
|
|
250
|
+
if (intent.sourceFingerprint === fingerprintAbsent(intent.path)) {
|
|
251
|
+
if (await exists(source)) {
|
|
252
|
+
throw new QuarantineError("unsafe_artifact", `absent source 不应存在 artifact:${intent.path}`);
|
|
253
|
+
}
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const metadata = await lstat(original);
|
|
257
|
+
if (!metadata.isFile()) throw new QuarantineError("unsafe_artifact", "批量 quarantine 只支持普通文件");
|
|
258
|
+
await this.beforeSourceCapture?.();
|
|
259
|
+
await this.assertMutationPaths(intent.path, intent.sourceArtifact);
|
|
260
|
+
try {
|
|
261
|
+
await link(original, source);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (hasErrorCode(error, "EEXIST")) {
|
|
264
|
+
throw new QuarantineError("external_concurrency", `source artifact 被抢占:${intent.path}`);
|
|
265
|
+
}
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
capturedDirectories.add(dirname(original));
|
|
269
|
+
}
|
|
270
|
+
await syncDirectories(capturedDirectories);
|
|
271
|
+
|
|
272
|
+
const removedDirectories = new Set<string>();
|
|
273
|
+
for (const { intent } of replacements) {
|
|
274
|
+
if (intent.sourceFingerprint === fingerprintAbsent(intent.path)) continue;
|
|
275
|
+
const original = this.absolute(intent.path);
|
|
276
|
+
const source = this.absolute(intent.sourceArtifact);
|
|
277
|
+
await this.beforeSourceRemove?.();
|
|
278
|
+
await this.assertMutationPaths(intent.path, intent.sourceArtifact);
|
|
279
|
+
await this.assertFingerprint(source, intent.path, intent.sourceFingerprint);
|
|
280
|
+
await this.assertFingerprint(original, intent.path, intent.sourceFingerprint);
|
|
281
|
+
const [sourceMetadata, originalMetadata] = await Promise.all([lstat(source), lstat(original)]);
|
|
282
|
+
if (sourceMetadata.dev !== originalMetadata.dev || sourceMetadata.ino !== originalMetadata.ino) {
|
|
283
|
+
throw new QuarantineError("external_concurrency", `source 隔离前原路径已变化:${intent.path}`);
|
|
284
|
+
}
|
|
285
|
+
await unlink(original);
|
|
286
|
+
removedDirectories.add(dirname(original));
|
|
287
|
+
}
|
|
288
|
+
await syncDirectories(removedDirectories);
|
|
289
|
+
for (const { intent } of replacements) {
|
|
290
|
+
await this.assertFingerprint(this.absolute(intent.path), intent.path, fingerprintAbsent(intent.path));
|
|
291
|
+
if (intent.sourceFingerprint !== fingerprintAbsent(intent.path)) {
|
|
292
|
+
await this.assertFingerprint(
|
|
293
|
+
this.absolute(intent.sourceArtifact),
|
|
294
|
+
intent.path,
|
|
295
|
+
intent.sourceFingerprint,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async cleanupVerifiedArtifactsBatch(records: readonly MutationRecord[]): Promise<void> {
|
|
302
|
+
const directories = new Set<string>();
|
|
303
|
+
for (const owned of records) {
|
|
304
|
+
if (owned.state !== "TARGET_VERIFIED") {
|
|
305
|
+
throw new QuarantineError("unsafe_artifact", "只有 TARGET_VERIFIED mutation 可以批量清理");
|
|
306
|
+
}
|
|
307
|
+
await this.assertFingerprint(this.absolute(owned.path), owned.path, owned.targetFingerprint);
|
|
308
|
+
const source = this.absolute(owned.sourceArtifact);
|
|
309
|
+
if (await exists(source)) {
|
|
310
|
+
await this.assertArtifactPath(owned.path, owned.sourceArtifact);
|
|
311
|
+
await this.assertFingerprint(source, owned.path, owned.sourceFingerprint);
|
|
312
|
+
await unlink(source);
|
|
313
|
+
directories.add(dirname(source));
|
|
314
|
+
}
|
|
315
|
+
if (owned.targetArtifact !== null) {
|
|
316
|
+
const target = this.absolute(owned.targetArtifact);
|
|
317
|
+
if (await exists(target)) {
|
|
318
|
+
await this.assertArtifactPath(owned.path, owned.targetArtifact);
|
|
319
|
+
await this.assertFingerprint(target, owned.path, owned.targetFingerprint);
|
|
320
|
+
await unlink(target);
|
|
321
|
+
directories.add(dirname(target));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
await syncDirectories(directories);
|
|
130
326
|
}
|
|
131
327
|
|
|
132
|
-
async replaceSymlink(request: ReplaceSymlinkRequest): Promise<
|
|
328
|
+
async replaceSymlink(request: ReplaceSymlinkRequest): Promise<MutationRecord> {
|
|
133
329
|
await this.assertWorkspaceIdentity();
|
|
134
330
|
await this.assertPath(request.path);
|
|
135
331
|
assertFingerprint(request.sourceFingerprint);
|
|
@@ -157,12 +353,14 @@ export class QuarantineManager {
|
|
|
157
353
|
throw error;
|
|
158
354
|
}
|
|
159
355
|
await fsyncDirectory(dirname(this.absolute(request.path)));
|
|
160
|
-
await this.journal.advance(intent.ordinal, "TARGET_INSTALLED");
|
|
161
356
|
await this.assertFingerprint(this.absolute(request.path), request.path, request.targetFingerprint);
|
|
162
|
-
await this.journal.
|
|
357
|
+
return (await this.journal.advanceMany(
|
|
358
|
+
intent.ordinal,
|
|
359
|
+
["SOURCE_QUARANTINED", "SOURCE_VERIFIED", "TARGET_INSTALLED", "TARGET_VERIFIED"],
|
|
360
|
+
)).at(-1)!;
|
|
163
361
|
}
|
|
164
362
|
|
|
165
|
-
async deleteLeaf(request: DeleteLeafRequest): Promise<
|
|
363
|
+
async deleteLeaf(request: DeleteLeafRequest): Promise<MutationRecord> {
|
|
166
364
|
await this.assertWorkspaceIdentity();
|
|
167
365
|
await this.assertPath(request.path);
|
|
168
366
|
assertFingerprint(request.sourceFingerprint);
|
|
@@ -179,9 +377,61 @@ export class QuarantineManager {
|
|
|
179
377
|
targetFingerprint: request.targetFingerprint,
|
|
180
378
|
});
|
|
181
379
|
await this.quarantineSource(intent);
|
|
182
|
-
await this.journal.advance(intent.ordinal, "TARGET_INSTALLED");
|
|
183
380
|
await this.assertFingerprint(this.absolute(request.path), request.path, request.targetFingerprint);
|
|
184
|
-
await this.journal.
|
|
381
|
+
return (await this.journal.advanceMany(
|
|
382
|
+
intent.ordinal,
|
|
383
|
+
["SOURCE_QUARANTINED", "SOURCE_VERIFIED", "TARGET_INSTALLED", "TARGET_VERIFIED"],
|
|
384
|
+
)).at(-1)!;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async deleteFiles(requests: readonly DeleteLeafRequest[]): Promise<void> {
|
|
388
|
+
if (requests.length === 0) return;
|
|
389
|
+
const prepared: Array<{
|
|
390
|
+
readonly request: DeleteLeafRequest;
|
|
391
|
+
readonly sourceArtifact: string;
|
|
392
|
+
}> = [];
|
|
393
|
+
for (const request of requests) {
|
|
394
|
+
await this.assertWorkspaceIdentity();
|
|
395
|
+
await this.assertPath(request.path);
|
|
396
|
+
assertFingerprint(request.sourceFingerprint);
|
|
397
|
+
assertFingerprint(request.targetFingerprint);
|
|
398
|
+
if (request.targetFingerprint !== fingerprintAbsent(request.path)) {
|
|
399
|
+
throw new QuarantineError("fingerprint_mismatch", `删除目标 fingerprint 不是 absent:${request.path}`);
|
|
400
|
+
}
|
|
401
|
+
const artifacts = await this.artifactPaths(request.path, false);
|
|
402
|
+
prepared.push({ request, sourceArtifact: artifacts.sourceArtifact });
|
|
403
|
+
}
|
|
404
|
+
const intents = await this.journal.beginMany(prepared.map(({ request, sourceArtifact }) => ({
|
|
405
|
+
kind: "delete" as const,
|
|
406
|
+
path: request.path,
|
|
407
|
+
sourceArtifact,
|
|
408
|
+
targetArtifact: null,
|
|
409
|
+
sourceFingerprint: request.sourceFingerprint,
|
|
410
|
+
targetFingerprint: request.targetFingerprint,
|
|
411
|
+
})));
|
|
412
|
+
const mutations = intents.map((intent) => ({ intent }));
|
|
413
|
+
await this.quarantineFileSources(mutations);
|
|
414
|
+
for (const { request } of prepared) {
|
|
415
|
+
await this.assertFingerprint(this.absolute(request.path), request.path, request.targetFingerprint);
|
|
416
|
+
}
|
|
417
|
+
const targetRecords = await this.journal.advanceBatch(intents.map((intent) => ({
|
|
418
|
+
ordinal: intent.ordinal,
|
|
419
|
+
states: ["SOURCE_QUARANTINED", "SOURCE_VERIFIED", "TARGET_INSTALLED", "TARGET_VERIFIED"],
|
|
420
|
+
})));
|
|
421
|
+
const verifiedRecords: MutationRecord[] = [];
|
|
422
|
+
for (const record of targetRecords) {
|
|
423
|
+
if (record.state === "TARGET_VERIFIED") {
|
|
424
|
+
verifiedRecords.push(await this.assertOwnedRecord(record));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (verifiedRecords.length !== intents.length) {
|
|
428
|
+
throw new Error("批量 delete TARGET_VERIFIED record 缺失");
|
|
429
|
+
}
|
|
430
|
+
await this.cleanupVerifiedArtifactsBatch(verifiedRecords);
|
|
431
|
+
await this.journal.advanceBatch(intents.map((intent) => ({
|
|
432
|
+
ordinal: intent.ordinal,
|
|
433
|
+
states: ["CLEANED"],
|
|
434
|
+
})));
|
|
185
435
|
}
|
|
186
436
|
|
|
187
437
|
async restoreMutation(record: MutationRecord): Promise<void> {
|
|
@@ -199,11 +449,20 @@ export class QuarantineManager {
|
|
|
199
449
|
await this.assertTargetArtifactAbsent(owned);
|
|
200
450
|
return;
|
|
201
451
|
}
|
|
452
|
+
if (
|
|
453
|
+
owned.state === "TARGET_VERIFIED" && !sourceWasAbsent && !sourceExists &&
|
|
454
|
+
originalFingerprint === owned.targetFingerprint
|
|
455
|
+
) {
|
|
456
|
+
await this.cleanupRollbackTarget(owned);
|
|
457
|
+
await this.journal.markRollbackCleaned(owned.ordinal);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
202
460
|
if (sourceWasAbsent) {
|
|
203
461
|
if (sourceExists) {
|
|
204
462
|
throw new QuarantineError("unsafe_artifact", `absent source 不应存在 artifact:${owned.path}`);
|
|
205
463
|
}
|
|
206
464
|
if (originalFingerprint === owned.targetFingerprint) {
|
|
465
|
+
await this.assertRollbackTargetOwned(owned);
|
|
207
466
|
await this.assertFingerprint(original, owned.path, owned.targetFingerprint);
|
|
208
467
|
await unlink(original);
|
|
209
468
|
await fsyncDirectory(dirname(original));
|
|
@@ -234,6 +493,7 @@ export class QuarantineManager {
|
|
|
234
493
|
originalFingerprint === owned.targetFingerprint
|
|
235
494
|
) {
|
|
236
495
|
await this.assertMutationPaths(owned.path, owned.sourceArtifact);
|
|
496
|
+
await this.assertRollbackTargetOwned(owned);
|
|
237
497
|
await this.assertFingerprint(original, owned.path, owned.targetFingerprint);
|
|
238
498
|
await unlink(original);
|
|
239
499
|
await fsyncDirectory(dirname(original));
|
|
@@ -272,6 +532,35 @@ export class QuarantineManager {
|
|
|
272
532
|
|
|
273
533
|
async rollForwardMutation(record: MutationRecord): Promise<void> {
|
|
274
534
|
let owned = await this.assertOwnedRecord(record);
|
|
535
|
+
if (owned.state === "INTENT") {
|
|
536
|
+
const source = this.absolute(owned.sourceArtifact);
|
|
537
|
+
const original = this.absolute(owned.path);
|
|
538
|
+
const originalFingerprint = await fingerprintLeaf(original, owned.path);
|
|
539
|
+
if (owned.sourceFingerprint === fingerprintAbsent(owned.path)) {
|
|
540
|
+
if (await exists(source) || originalFingerprint !== owned.sourceFingerprint) {
|
|
541
|
+
throw new QuarantineError("external_concurrency", `INTENT absent source 现场冲突:${owned.path}`);
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
544
|
+
await this.assertFingerprint(source, owned.path, owned.sourceFingerprint);
|
|
545
|
+
if (originalFingerprint === owned.sourceFingerprint) {
|
|
546
|
+
const [sourceMetadata, originalMetadata] = await Promise.all([lstat(source), lstat(original)]);
|
|
547
|
+
if (
|
|
548
|
+
sourceMetadata.isFile() &&
|
|
549
|
+
(sourceMetadata.dev !== originalMetadata.dev || sourceMetadata.ino !== originalMetadata.ino)
|
|
550
|
+
) {
|
|
551
|
+
throw new QuarantineError("external_concurrency", `INTENT source inode 冲突:${owned.path}`);
|
|
552
|
+
}
|
|
553
|
+
await unlink(original);
|
|
554
|
+
await fsyncDirectory(dirname(original));
|
|
555
|
+
} else if (originalFingerprint !== fingerprintAbsent(owned.path)) {
|
|
556
|
+
throw new QuarantineError("external_concurrency", `INTENT original 现场冲突:${owned.path}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
owned = (await this.journal.advanceMany(
|
|
560
|
+
owned.ordinal,
|
|
561
|
+
["SOURCE_QUARANTINED", "SOURCE_VERIFIED"],
|
|
562
|
+
)).at(-1)!;
|
|
563
|
+
}
|
|
275
564
|
if (owned.state === "SOURCE_QUARANTINED") {
|
|
276
565
|
if (owned.sourceFingerprint === fingerprintAbsent(owned.path)) {
|
|
277
566
|
if (await exists(this.absolute(owned.sourceArtifact))) {
|
|
@@ -323,9 +612,15 @@ export class QuarantineManager {
|
|
|
323
612
|
async cleanupMutation(record: MutationRecord): Promise<void> {
|
|
324
613
|
const owned = await this.assertOwnedRecord(record);
|
|
325
614
|
if (owned.state === "CLEANED") return;
|
|
615
|
+
await this.cleanupVerifiedArtifacts(owned);
|
|
616
|
+
await this.journal.advance(owned.ordinal, "CLEANED");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private async cleanupVerifiedArtifacts(owned: MutationRecord): Promise<void> {
|
|
326
620
|
if (owned.state !== "TARGET_VERIFIED") {
|
|
327
621
|
throw new QuarantineError("unsafe_artifact", "只有 TARGET_VERIFIED mutation 可以清理");
|
|
328
622
|
}
|
|
623
|
+
await this.assertFingerprint(this.absolute(owned.path), owned.path, owned.targetFingerprint);
|
|
329
624
|
const source = this.absolute(owned.sourceArtifact);
|
|
330
625
|
if (await exists(source)) {
|
|
331
626
|
await this.assertArtifactPath(owned.path, owned.sourceArtifact);
|
|
@@ -342,7 +637,6 @@ export class QuarantineManager {
|
|
|
342
637
|
await fsyncDirectory(dirname(target));
|
|
343
638
|
}
|
|
344
639
|
}
|
|
345
|
-
await this.journal.advance(owned.ordinal, "CLEANED");
|
|
346
640
|
}
|
|
347
641
|
|
|
348
642
|
async inspectArtifacts(): Promise<readonly QuarantineArtifact[]> {
|
|
@@ -373,9 +667,7 @@ export class QuarantineManager {
|
|
|
373
667
|
if (await exists(source)) {
|
|
374
668
|
throw new QuarantineError("unsafe_artifact", `absent source 不应存在 artifact:${record.path}`);
|
|
375
669
|
}
|
|
376
|
-
await this.journal.advance(record.ordinal, "SOURCE_QUARANTINED");
|
|
377
670
|
await this.assertFingerprint(original, record.path, record.sourceFingerprint);
|
|
378
|
-
await this.journal.advance(record.ordinal, "SOURCE_VERIFIED");
|
|
379
671
|
return;
|
|
380
672
|
}
|
|
381
673
|
const metadata = await lstat(original);
|
|
@@ -409,10 +701,7 @@ export class QuarantineManager {
|
|
|
409
701
|
}
|
|
410
702
|
}
|
|
411
703
|
await unlink(original);
|
|
412
|
-
await fsyncDirectory(dirname(original));
|
|
413
|
-
await this.journal.advance(record.ordinal, "SOURCE_QUARANTINED");
|
|
414
704
|
await this.assertFingerprint(source, record.path, record.sourceFingerprint);
|
|
415
|
-
await this.journal.advance(record.ordinal, "SOURCE_VERIFIED");
|
|
416
705
|
}
|
|
417
706
|
|
|
418
707
|
private async cleanupRollbackTarget(record: MutationRecord): Promise<void> {
|
|
@@ -484,6 +773,27 @@ export class QuarantineManager {
|
|
|
484
773
|
}
|
|
485
774
|
}
|
|
486
775
|
|
|
776
|
+
private async assertRollbackTargetOwned(owned: MutationRecord): Promise<void> {
|
|
777
|
+
if (owned.targetArtifact === null) return;
|
|
778
|
+
const target = this.absolute(owned.targetArtifact);
|
|
779
|
+
if (!await exists(target)) return;
|
|
780
|
+
await this.assertArtifactPath(owned.path, owned.targetArtifact);
|
|
781
|
+
await this.assertFingerprint(target, owned.path, owned.targetFingerprint);
|
|
782
|
+
await this.assertSameFileIdentity(this.absolute(owned.path), target, owned.path);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private async assertSameFileIdentity(original: string, artifact: string, path: string): Promise<void> {
|
|
786
|
+
const [originalMetadata, artifactMetadata] = await Promise.all([lstat(original), lstat(artifact)]);
|
|
787
|
+
if (
|
|
788
|
+
!originalMetadata.isFile() ||
|
|
789
|
+
!artifactMetadata.isFile() ||
|
|
790
|
+
originalMetadata.dev !== artifactMetadata.dev ||
|
|
791
|
+
originalMetadata.ino !== artifactMetadata.ino
|
|
792
|
+
) {
|
|
793
|
+
throw new QuarantineError("external_concurrency", `target artifact ownership 已变化:${path}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
487
797
|
private async assertFingerprint(absolutePath: string, logicalPath: string, expected: string): Promise<void> {
|
|
488
798
|
const actual = await fingerprintLeaf(absolutePath, logicalPath);
|
|
489
799
|
if (actual !== expected) {
|
|
@@ -576,6 +886,36 @@ function immutableMutation(record: MutationRecord): string {
|
|
|
576
886
|
});
|
|
577
887
|
}
|
|
578
888
|
|
|
889
|
+
async function mapConcurrentFailClosed<T>(
|
|
890
|
+
values: readonly T[],
|
|
891
|
+
concurrency: number,
|
|
892
|
+
operation: (value: T) => Promise<void>,
|
|
893
|
+
): Promise<void> {
|
|
894
|
+
let nextIndex = 0;
|
|
895
|
+
let failed = false;
|
|
896
|
+
let failure: unknown;
|
|
897
|
+
async function worker(): Promise<void> {
|
|
898
|
+
while (!failed && nextIndex < values.length) {
|
|
899
|
+
const index = nextIndex;
|
|
900
|
+
nextIndex += 1;
|
|
901
|
+
try {
|
|
902
|
+
await operation(values[index]!);
|
|
903
|
+
} catch (error) {
|
|
904
|
+
if (!failed) failure = error;
|
|
905
|
+
failed = true;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
await Promise.all(
|
|
910
|
+
Array.from({ length: Math.min(concurrency, values.length) }, () => worker()),
|
|
911
|
+
);
|
|
912
|
+
if (failed) throw failure;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async function syncDirectories(directories: ReadonlySet<string>): Promise<void> {
|
|
916
|
+
for (const directory of directories) await fsyncDirectory(directory);
|
|
917
|
+
}
|
|
918
|
+
|
|
579
919
|
async function exists(path: string): Promise<boolean> {
|
|
580
920
|
try {
|
|
581
921
|
await lstat(path);
|