@davideasden/pi-undo 0.2.2 → 0.2.4
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 +17 -8
- package/native/bin/pi-undo-fs-darwin-arm64 +0 -0
- package/package.json +5 -2
- package/src/atomic-fs.ts +2 -2
- package/src/controller.ts +71 -21
- package/src/durable-pack.ts +573 -0
- package/src/journal.ts +33 -6
- package/src/mutation-journal.ts +6 -0
- package/src/native-restore.ts +146 -0
- package/src/packed-recovery.ts +324 -0
- package/src/path-safety.ts +31 -0
- package/src/pi-runtime.ts +96 -5
- package/src/quarantine.ts +58 -32
- package/src/restore-engine.ts +448 -36
- package/src/snapshot-store.ts +110 -41
- package/src/workspace-lock.ts +5 -1
package/src/quarantine.ts
CHANGED
|
@@ -73,6 +73,7 @@ export class QuarantineManager {
|
|
|
73
73
|
private readonly beforeRestoreSourceCleanup: (() => void | Promise<void>) | undefined;
|
|
74
74
|
private readonly afterRestoreSourceCleanup: (() => void | Promise<void>) | undefined;
|
|
75
75
|
private readonly beforeTargetCreate: (() => void | Promise<void>) | undefined;
|
|
76
|
+
private readonly syncTargetArtifacts: boolean;
|
|
76
77
|
|
|
77
78
|
constructor(options: {
|
|
78
79
|
readonly workspaceRoot: string;
|
|
@@ -85,6 +86,7 @@ export class QuarantineManager {
|
|
|
85
86
|
readonly beforeRestoreSourceCleanup?: () => void | Promise<void>;
|
|
86
87
|
readonly afterRestoreSourceCleanup?: () => void | Promise<void>;
|
|
87
88
|
readonly beforeTargetCreate?: () => void | Promise<void>;
|
|
89
|
+
readonly syncTargetArtifacts?: boolean;
|
|
88
90
|
}) {
|
|
89
91
|
this.requestedWorkspaceRoot = resolve(options.workspaceRoot);
|
|
90
92
|
this.workspaceRoot = realpathSync(this.requestedWorkspaceRoot);
|
|
@@ -97,6 +99,7 @@ export class QuarantineManager {
|
|
|
97
99
|
this.beforeRestoreSourceCleanup = options.beforeRestoreSourceCleanup;
|
|
98
100
|
this.afterRestoreSourceCleanup = options.afterRestoreSourceCleanup;
|
|
99
101
|
this.beforeTargetCreate = options.beforeTargetCreate;
|
|
102
|
+
this.syncTargetArtifacts = options.syncTargetArtifacts ?? true;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
async replaceFile(request: ReplaceFileRequest): Promise<MutationRecord> {
|
|
@@ -120,7 +123,7 @@ export class QuarantineManager {
|
|
|
120
123
|
targetArtifact,
|
|
121
124
|
request.targetBytes,
|
|
122
125
|
request.targetMode,
|
|
123
|
-
{ syncDirectory: false },
|
|
126
|
+
{ syncDirectory: false, syncFile: this.syncTargetArtifacts },
|
|
124
127
|
);
|
|
125
128
|
await this.assertFingerprint(targetArtifact, request.path, request.targetFingerprint);
|
|
126
129
|
await this.quarantineSource(intent);
|
|
@@ -182,7 +185,7 @@ export class QuarantineManager {
|
|
|
182
185
|
replacement.targetAbsolutePath,
|
|
183
186
|
replacement.request.targetBytes,
|
|
184
187
|
replacement.request.targetMode,
|
|
185
|
-
{ syncDirectory: false },
|
|
188
|
+
{ syncDirectory: false, syncFile: this.syncTargetArtifacts },
|
|
186
189
|
);
|
|
187
190
|
await this.assertFingerprint(
|
|
188
191
|
replacement.targetAbsolutePath,
|
|
@@ -197,7 +200,7 @@ export class QuarantineManager {
|
|
|
197
200
|
}
|
|
198
201
|
await this.quarantineFileSources(replacements);
|
|
199
202
|
const installDirectories = new Set<string>();
|
|
200
|
-
|
|
203
|
+
const installTarget = async (replacement: PreparedFileReplacement): Promise<void> => {
|
|
201
204
|
const { request, targetArtifact, targetAbsolutePath } = replacement;
|
|
202
205
|
await request.beforeInstall?.();
|
|
203
206
|
await this.assertMutationPaths(request.path, targetArtifact);
|
|
@@ -211,13 +214,19 @@ export class QuarantineManager {
|
|
|
211
214
|
throw error;
|
|
212
215
|
}
|
|
213
216
|
installDirectories.add(dirname(this.absolute(request.path)));
|
|
217
|
+
};
|
|
218
|
+
if (replacements.some(({ request }) => request.beforeInstall !== undefined)) {
|
|
219
|
+
for (const replacement of replacements) await installTarget(replacement);
|
|
220
|
+
} else {
|
|
221
|
+
await mapConcurrentFailClosed(replacements, BATCH_FILE_IO_CONCURRENCY, installTarget);
|
|
214
222
|
}
|
|
215
223
|
await syncDirectories(installDirectories);
|
|
216
|
-
|
|
224
|
+
await mapConcurrentFailClosed(replacements, BATCH_FILE_IO_CONCURRENCY, async (replacement) => {
|
|
225
|
+
const { request, targetArtifact, targetAbsolutePath } = replacement;
|
|
217
226
|
await this.assertArtifactPath(request.path, targetArtifact);
|
|
218
227
|
await this.assertSameFileIdentity(this.absolute(request.path), targetAbsolutePath, request.path);
|
|
219
228
|
await this.assertFingerprint(targetAbsolutePath, request.path, request.targetFingerprint);
|
|
220
|
-
}
|
|
229
|
+
});
|
|
221
230
|
const targetRecords = await this.journal.advanceBatch(replacements.map(({ intent }) => ({
|
|
222
231
|
ordinal: intent.ordinal,
|
|
223
232
|
states: ["SOURCE_QUARANTINED", "SOURCE_VERIFIED", "TARGET_INSTALLED", "TARGET_VERIFIED"],
|
|
@@ -242,17 +251,23 @@ export class QuarantineManager {
|
|
|
242
251
|
private async quarantineFileSources(
|
|
243
252
|
replacements: readonly { readonly intent: MutationRecord }[],
|
|
244
253
|
): Promise<void> {
|
|
254
|
+
const absentSources = replacements.filter(
|
|
255
|
+
({ intent }) => intent.sourceFingerprint === fingerprintAbsent(intent.path),
|
|
256
|
+
);
|
|
257
|
+
await mapConcurrentFailClosed(absentSources, BATCH_FILE_IO_CONCURRENCY, async ({ intent }) => {
|
|
258
|
+
await this.assertFingerprint(this.absolute(intent.path), intent.path, intent.sourceFingerprint);
|
|
259
|
+
if (await exists(this.absolute(intent.sourceArtifact))) {
|
|
260
|
+
throw new QuarantineError("unsafe_artifact", `absent source 不应存在 artifact:${intent.path}`);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
const presentSources = replacements.filter(
|
|
264
|
+
({ intent }) => intent.sourceFingerprint !== fingerprintAbsent(intent.path),
|
|
265
|
+
);
|
|
245
266
|
const capturedDirectories = new Set<string>();
|
|
246
|
-
|
|
267
|
+
const captureSource = async ({ intent }: { readonly intent: MutationRecord }): Promise<void> => {
|
|
247
268
|
const original = this.absolute(intent.path);
|
|
248
269
|
const source = this.absolute(intent.sourceArtifact);
|
|
249
270
|
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
271
|
const metadata = await lstat(original);
|
|
257
272
|
if (!metadata.isFile()) throw new QuarantineError("unsafe_artifact", "批量 quarantine 只支持普通文件");
|
|
258
273
|
await this.beforeSourceCapture?.();
|
|
@@ -266,45 +281,56 @@ export class QuarantineManager {
|
|
|
266
281
|
throw error;
|
|
267
282
|
}
|
|
268
283
|
capturedDirectories.add(dirname(original));
|
|
284
|
+
};
|
|
285
|
+
if (this.beforeSourceCapture === undefined) {
|
|
286
|
+
await mapConcurrentFailClosed(presentSources, BATCH_FILE_IO_CONCURRENCY, captureSource);
|
|
287
|
+
} else {
|
|
288
|
+
for (const replacement of presentSources) await captureSource(replacement);
|
|
269
289
|
}
|
|
270
290
|
await syncDirectories(capturedDirectories);
|
|
271
291
|
|
|
272
292
|
const removedDirectories = new Set<string>();
|
|
273
|
-
|
|
274
|
-
if (intent.sourceFingerprint === fingerprintAbsent(intent.path)) continue;
|
|
293
|
+
const removeSource = async ({ intent }: { readonly intent: MutationRecord }): Promise<void> => {
|
|
275
294
|
const original = this.absolute(intent.path);
|
|
276
295
|
const source = this.absolute(intent.sourceArtifact);
|
|
277
296
|
await this.beforeSourceRemove?.();
|
|
278
297
|
await this.assertMutationPaths(intent.path, intent.sourceArtifact);
|
|
279
298
|
await this.assertFingerprint(source, intent.path, intent.sourceFingerprint);
|
|
280
299
|
await this.assertFingerprint(original, intent.path, intent.sourceFingerprint);
|
|
281
|
-
|
|
282
|
-
if (sourceMetadata.dev !== originalMetadata.dev || sourceMetadata.ino !== originalMetadata.ino) {
|
|
283
|
-
throw new QuarantineError("external_concurrency", `source 隔离前原路径已变化:${intent.path}`);
|
|
284
|
-
}
|
|
300
|
+
await this.assertSameFileIdentity(original, source, intent.path);
|
|
285
301
|
await unlink(original);
|
|
286
302
|
removedDirectories.add(dirname(original));
|
|
303
|
+
};
|
|
304
|
+
if (this.beforeSourceRemove === undefined) {
|
|
305
|
+
await mapConcurrentFailClosed(presentSources, BATCH_FILE_IO_CONCURRENCY, removeSource);
|
|
306
|
+
} else {
|
|
307
|
+
for (const replacement of presentSources) await removeSource(replacement);
|
|
287
308
|
}
|
|
288
309
|
await syncDirectories(removedDirectories);
|
|
289
|
-
|
|
310
|
+
await mapConcurrentFailClosed(presentSources, BATCH_FILE_IO_CONCURRENCY, async ({ intent }) => {
|
|
290
311
|
await this.assertFingerprint(this.absolute(intent.path), intent.path, fingerprintAbsent(intent.path));
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
}
|
|
312
|
+
await this.assertFingerprint(
|
|
313
|
+
this.absolute(intent.sourceArtifact),
|
|
314
|
+
intent.path,
|
|
315
|
+
intent.sourceFingerprint,
|
|
316
|
+
);
|
|
317
|
+
});
|
|
299
318
|
}
|
|
300
319
|
|
|
301
320
|
private async cleanupVerifiedArtifactsBatch(records: readonly MutationRecord[]): Promise<void> {
|
|
302
321
|
const directories = new Set<string>();
|
|
303
|
-
|
|
322
|
+
await mapConcurrentFailClosed(records, BATCH_FILE_IO_CONCURRENCY, async (owned) => {
|
|
304
323
|
if (owned.state !== "TARGET_VERIFIED") {
|
|
305
324
|
throw new QuarantineError("unsafe_artifact", "只有 TARGET_VERIFIED mutation 可以批量清理");
|
|
306
325
|
}
|
|
307
|
-
|
|
326
|
+
if (owned.targetArtifact === null) {
|
|
327
|
+
await this.assertFingerprint(this.absolute(owned.path), owned.path, owned.targetFingerprint);
|
|
328
|
+
} else {
|
|
329
|
+
const target = this.absolute(owned.targetArtifact);
|
|
330
|
+
await this.assertArtifactPath(owned.path, owned.targetArtifact);
|
|
331
|
+
await this.assertSameFileIdentity(this.absolute(owned.path), target, owned.path);
|
|
332
|
+
await this.assertFingerprint(target, owned.path, owned.targetFingerprint);
|
|
333
|
+
}
|
|
308
334
|
const source = this.absolute(owned.sourceArtifact);
|
|
309
335
|
if (await exists(source)) {
|
|
310
336
|
await this.assertArtifactPath(owned.path, owned.sourceArtifact);
|
|
@@ -321,7 +347,7 @@ export class QuarantineManager {
|
|
|
321
347
|
directories.add(dirname(target));
|
|
322
348
|
}
|
|
323
349
|
}
|
|
324
|
-
}
|
|
350
|
+
});
|
|
325
351
|
await syncDirectories(directories);
|
|
326
352
|
}
|
|
327
353
|
|
|
@@ -745,7 +771,7 @@ export class QuarantineManager {
|
|
|
745
771
|
|
|
746
772
|
private async assertOwnedRecord(record: MutationRecord): Promise<MutationRecord> {
|
|
747
773
|
await this.assertWorkspaceIdentity();
|
|
748
|
-
const owned =
|
|
774
|
+
const owned = await this.journal.loadOrdinal(record.ordinal);
|
|
749
775
|
if (owned === undefined || immutableMutation(owned) !== immutableMutation(record)) {
|
|
750
776
|
throw new QuarantineError("unsafe_artifact", "mutation record 未被当前 journal 精确登记");
|
|
751
777
|
}
|
|
@@ -848,7 +874,7 @@ export function fingerprintAbsent(path: string): string {
|
|
|
848
874
|
return checksum(canonicalJson({ path, kind: "absent" }));
|
|
849
875
|
}
|
|
850
876
|
|
|
851
|
-
async function fingerprintLeaf(file: string, logicalPath: string): Promise<string> {
|
|
877
|
+
export async function fingerprintLeaf(file: string, logicalPath: string): Promise<string> {
|
|
852
878
|
const metadata = await lstat(file).catch((error) => {
|
|
853
879
|
if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "ENOTDIR")) return null;
|
|
854
880
|
throw error;
|