@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
package/src/encoding.ts
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CursorState,
|
|
5
|
+
JournalState,
|
|
6
|
+
ManifestId,
|
|
7
|
+
MutationRecord,
|
|
8
|
+
OperationDescriptor,
|
|
9
|
+
RootTopologyIdentity,
|
|
10
|
+
SnapshotManifest,
|
|
11
|
+
SnapshotRoot,
|
|
12
|
+
TopologyFingerprint,
|
|
13
|
+
} from "./model.ts";
|
|
14
|
+
|
|
15
|
+
export type ValidationCode =
|
|
16
|
+
| "unsupported_schema"
|
|
17
|
+
| "invalid_manifest"
|
|
18
|
+
| "invalid_cursor"
|
|
19
|
+
| "invalid_descriptor"
|
|
20
|
+
| "invalid_journal_state"
|
|
21
|
+
| "invalid_mutation_record"
|
|
22
|
+
| "invalid_root_path"
|
|
23
|
+
| "noncanonical_roots"
|
|
24
|
+
| "invalid_manifest_id"
|
|
25
|
+
| "invalid_operation_id"
|
|
26
|
+
| "checksum_mismatch";
|
|
27
|
+
|
|
28
|
+
export class ModelValidationError extends Error {
|
|
29
|
+
readonly code: ValidationCode;
|
|
30
|
+
|
|
31
|
+
constructor(code: ValidationCode, message: string) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "ModelValidationError";
|
|
34
|
+
this.code = code;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function canonicalJson(value: unknown): string {
|
|
39
|
+
return encodeJson(value, new Set<object>());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function checksum(value: string | Uint8Array): string {
|
|
43
|
+
const input = typeof value === "string" ? value : Buffer.from(value);
|
|
44
|
+
return createHash("sha256").update(input).digest("hex");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ignoredPresentClosure(
|
|
48
|
+
root: Pick<SnapshotRoot, "coverage" | "ignorePolicy" | "ignoredPresentPaths">,
|
|
49
|
+
): string {
|
|
50
|
+
return checksum(canonicalJson({
|
|
51
|
+
coverage: root.coverage,
|
|
52
|
+
ignorePolicy: root.ignorePolicy,
|
|
53
|
+
ignoredPresentPaths: root.ignoredPresentPaths,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function topologyFingerprint(
|
|
58
|
+
workspaceIdentity: string,
|
|
59
|
+
roots: readonly RootTopologyIdentity[],
|
|
60
|
+
): TopologyFingerprint {
|
|
61
|
+
return checksum(canonicalJson({
|
|
62
|
+
workspaceIdentity,
|
|
63
|
+
roots: roots.map((root) => ({
|
|
64
|
+
relativeRoot: root.relativeRoot,
|
|
65
|
+
parentRoot: root.parentRoot,
|
|
66
|
+
state: root.state,
|
|
67
|
+
sourceIdentity: root.sourceIdentity,
|
|
68
|
+
privateRepositoryId: root.privateRepositoryId,
|
|
69
|
+
gitlinkOid: root.gitlinkOid ?? null,
|
|
70
|
+
})),
|
|
71
|
+
})) as TopologyFingerprint;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function assertManifest(value: unknown): SnapshotManifest {
|
|
75
|
+
const record = assertRecord(value, "invalid_manifest", "manifest 必须是对象");
|
|
76
|
+
assertSchemaVersion(record, "manifest");
|
|
77
|
+
assertManifestFields(record);
|
|
78
|
+
|
|
79
|
+
assertRoots(record.roots);
|
|
80
|
+
if (record.topologyFingerprint !== topologyFingerprint(record.workspaceIdentity, record.roots)) {
|
|
81
|
+
fail("checksum_mismatch", "topology fingerprint 与 manifest roots 不匹配");
|
|
82
|
+
}
|
|
83
|
+
if (record.coverage === "complete" && record.roots.some((root) => root.coverage !== "complete")) {
|
|
84
|
+
fail("invalid_manifest", "全量 manifest 的所有 root coverage 必须是 complete");
|
|
85
|
+
}
|
|
86
|
+
const manifestId = assertManifestId(record.manifestId);
|
|
87
|
+
|
|
88
|
+
if (manifestId !== checksumWithout(record, "manifestId", "invalid_manifest")) {
|
|
89
|
+
fail("checksum_mismatch", "manifest ID 与内容不匹配");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return value as SnapshotManifest;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function assertCursor(value: unknown): CursorState {
|
|
96
|
+
const record = assertRecord(value, "invalid_cursor", "cursor 必须是对象");
|
|
97
|
+
assertSchemaVersion(record, "cursor");
|
|
98
|
+
assertCursorFields(record);
|
|
99
|
+
|
|
100
|
+
if (record.checksum !== checksumWithout(record, "checksum", "invalid_cursor")) {
|
|
101
|
+
fail("checksum_mismatch", "cursor checksum 与内容不匹配");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return value as CursorState;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function assertOperationDescriptor(value: unknown): OperationDescriptor {
|
|
108
|
+
const record = assertRecord(value, "invalid_descriptor", "operation descriptor 必须是对象");
|
|
109
|
+
assertSchemaVersion(record, "operation descriptor");
|
|
110
|
+
assertOperationId(record.opId);
|
|
111
|
+
assertSessionFileIdentity(record.sessionIdentity, "invalid_descriptor");
|
|
112
|
+
assertNonEmptyString(record.workspaceIdentity, "invalid_descriptor", "workspaceIdentity 无效");
|
|
113
|
+
if (record.action !== "undo" && record.action !== "redo" && record.action !== "tree") {
|
|
114
|
+
fail("invalid_descriptor", "action 无效");
|
|
115
|
+
}
|
|
116
|
+
assertNullableString(record.fromLogicalLeaf, "invalid_descriptor", "fromLogicalLeaf 无效");
|
|
117
|
+
assertNullableString(record.toLogicalLeaf, "invalid_descriptor", "toLogicalLeaf 无效");
|
|
118
|
+
assertManifestId(record.targetManifestId);
|
|
119
|
+
assertManifestId(record.rollbackManifestId);
|
|
120
|
+
assertCoverage(record.coverage, false, "coverage 无效");
|
|
121
|
+
assertScopePaths(record.scopePaths, record.coverage);
|
|
122
|
+
assertChecksum(record.planDigest, "invalid_descriptor", "planDigest 无效");
|
|
123
|
+
assertChecksum(record.checksum, "checksum_mismatch", "checksum 无效");
|
|
124
|
+
if (record.checksum !== checksumWithout(record, "checksum", "invalid_descriptor")) {
|
|
125
|
+
fail("checksum_mismatch", "operation descriptor checksum 不匹配");
|
|
126
|
+
}
|
|
127
|
+
return value as OperationDescriptor;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function assertJournalState(value: unknown): JournalState {
|
|
131
|
+
const record = assertRecord(value, "invalid_journal_state", "journal state 必须是对象");
|
|
132
|
+
assertSchemaVersion(record, "journal state");
|
|
133
|
+
assertOperationId(record.opId);
|
|
134
|
+
if (!isJournalPhase(record.phase)) {
|
|
135
|
+
fail("invalid_journal_state", "journal phase 无效");
|
|
136
|
+
}
|
|
137
|
+
if (typeof record.revision !== "number" || !Number.isInteger(record.revision) || record.revision < 1) {
|
|
138
|
+
fail("invalid_journal_state", "journal revision 无效");
|
|
139
|
+
}
|
|
140
|
+
assertChecksum(record.descriptorChecksum, "invalid_journal_state", "descriptor checksum 无效");
|
|
141
|
+
if (record.observedLogicalLeaf !== undefined) {
|
|
142
|
+
assertNullableString(record.observedLogicalLeaf, "invalid_journal_state", "observedLogicalLeaf 无效");
|
|
143
|
+
}
|
|
144
|
+
assertChecksum(record.checksum, "checksum_mismatch", "checksum 无效");
|
|
145
|
+
if (record.checksum !== checksumWithout(record, "checksum", "invalid_journal_state")) {
|
|
146
|
+
fail("checksum_mismatch", "journal state checksum 不匹配");
|
|
147
|
+
}
|
|
148
|
+
return value as JournalState;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function assertMutationRecord(value: unknown): MutationRecord {
|
|
152
|
+
const record = assertRecord(value, "invalid_mutation_record", "mutation record 必须是对象");
|
|
153
|
+
assertSchemaVersion(record, "mutation record");
|
|
154
|
+
assertOperationId(record.opId);
|
|
155
|
+
if (typeof record.ordinal !== "number" || !Number.isInteger(record.ordinal) || record.ordinal < 1) {
|
|
156
|
+
fail("invalid_mutation_record", "mutation ordinal 无效");
|
|
157
|
+
}
|
|
158
|
+
if (!isMutationState(record.state)) {
|
|
159
|
+
fail("invalid_mutation_record", "mutation state 无效");
|
|
160
|
+
}
|
|
161
|
+
if (record.kind !== "write" && record.kind !== "delete" && record.kind !== "symlink") {
|
|
162
|
+
fail("invalid_mutation_record", "mutation kind 无效");
|
|
163
|
+
}
|
|
164
|
+
if (record.kind === "write" && record.targetArtifact === null) {
|
|
165
|
+
fail("invalid_mutation_record", "write mutation 必须包含 targetArtifact");
|
|
166
|
+
}
|
|
167
|
+
assertWorkspacePath(record.path, "path");
|
|
168
|
+
assertWorkspacePath(record.sourceArtifact, "sourceArtifact");
|
|
169
|
+
if (record.targetArtifact !== null) {
|
|
170
|
+
assertWorkspacePath(record.targetArtifact, "targetArtifact");
|
|
171
|
+
}
|
|
172
|
+
const parent = workspaceParent(record.path);
|
|
173
|
+
if (workspaceParent(record.sourceArtifact) !== parent) {
|
|
174
|
+
fail("invalid_mutation_record", "sourceArtifact 必须与 path 位于同一父目录");
|
|
175
|
+
}
|
|
176
|
+
if (record.targetArtifact !== null && workspaceParent(record.targetArtifact) !== parent) {
|
|
177
|
+
fail("invalid_mutation_record", "targetArtifact 必须与 path 位于同一父目录");
|
|
178
|
+
}
|
|
179
|
+
assertChecksum(record.sourceFingerprint, "invalid_mutation_record", "sourceFingerprint 无效");
|
|
180
|
+
assertChecksum(record.targetFingerprint, "invalid_mutation_record", "targetFingerprint 无效");
|
|
181
|
+
if (record.previousChecksum !== null) {
|
|
182
|
+
assertChecksum(record.previousChecksum, "invalid_mutation_record", "previousChecksum 无效");
|
|
183
|
+
}
|
|
184
|
+
assertChecksum(record.checksum, "checksum_mismatch", "mutation checksum 无效");
|
|
185
|
+
if (record.checksum !== checksumWithout(record, "checksum", "invalid_mutation_record")) {
|
|
186
|
+
fail("checksum_mismatch", "mutation checksum 与内容不匹配");
|
|
187
|
+
}
|
|
188
|
+
return value as MutationRecord;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function assertOperationId(value: unknown): string {
|
|
192
|
+
assertNonEmptyString(value, "invalid_operation_id", "opId 缺失");
|
|
193
|
+
if (
|
|
194
|
+
value === "." ||
|
|
195
|
+
value === ".." ||
|
|
196
|
+
value.includes("/") ||
|
|
197
|
+
value.includes("\\") ||
|
|
198
|
+
value.includes("\0")
|
|
199
|
+
) {
|
|
200
|
+
fail("invalid_operation_id", "opId 不能作为不安全路径使用");
|
|
201
|
+
}
|
|
202
|
+
return value;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function encodeJson(value: unknown, ancestors: Set<object>): string {
|
|
206
|
+
if (value === null) {
|
|
207
|
+
return "null";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
switch (typeof value) {
|
|
211
|
+
case "boolean":
|
|
212
|
+
return value ? "true" : "false";
|
|
213
|
+
case "string":
|
|
214
|
+
return JSON.stringify(value);
|
|
215
|
+
case "number": {
|
|
216
|
+
if (!Number.isFinite(value)) {
|
|
217
|
+
throw new TypeError("canonicalJson 不接受非有限数字");
|
|
218
|
+
}
|
|
219
|
+
const encoded = JSON.stringify(value);
|
|
220
|
+
if (encoded === undefined) {
|
|
221
|
+
throw new TypeError("canonicalJson 无法编码该数字");
|
|
222
|
+
}
|
|
223
|
+
return encoded;
|
|
224
|
+
}
|
|
225
|
+
case "object":
|
|
226
|
+
break;
|
|
227
|
+
default:
|
|
228
|
+
throw new TypeError("canonicalJson 只接受 JSON 值");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (ancestors.has(value)) {
|
|
232
|
+
throw new TypeError("canonicalJson 不接受循环引用");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (Array.isArray(value)) {
|
|
236
|
+
ancestors.add(value);
|
|
237
|
+
try {
|
|
238
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
239
|
+
throw new TypeError("canonicalJson 不接受 symbol 属性");
|
|
240
|
+
}
|
|
241
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
242
|
+
if (key !== "length" && !isArrayIndex(key, value.length)) {
|
|
243
|
+
throw new TypeError("canonicalJson 不接受数组额外属性");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const items: string[] = [];
|
|
247
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
248
|
+
if (!Object.prototype.hasOwnProperty.call(value, index)) {
|
|
249
|
+
throw new TypeError("canonicalJson 不接受稀疏数组");
|
|
250
|
+
}
|
|
251
|
+
items.push(encodeJson(value[index], ancestors));
|
|
252
|
+
}
|
|
253
|
+
return `[${items.join(",")}]`;
|
|
254
|
+
} finally {
|
|
255
|
+
ancestors.delete(value);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!isPlainObject(value)) {
|
|
260
|
+
throw new TypeError("canonicalJson 只接受普通对象");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!hasOnlyEnumerableDataProperties(value)) {
|
|
264
|
+
throw new TypeError("canonicalJson 只接受自身的可枚举数据属性");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
ancestors.add(value);
|
|
268
|
+
try {
|
|
269
|
+
return `{${Object.keys(value)
|
|
270
|
+
.sort()
|
|
271
|
+
.map((key) => `${JSON.stringify(key)}:${encodeJson(value[key], ancestors)}`)
|
|
272
|
+
.join(",")}}`;
|
|
273
|
+
} finally {
|
|
274
|
+
ancestors.delete(value);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function assertManifestFields(record: Record<string, unknown>): asserts record is Record<string, unknown> & {
|
|
279
|
+
workspaceIdentity: string;
|
|
280
|
+
topologyFingerprint: string;
|
|
281
|
+
coverage: string;
|
|
282
|
+
createdAt: string;
|
|
283
|
+
} {
|
|
284
|
+
assertNonEmptyString(record.workspaceIdentity, "invalid_manifest", "workspaceIdentity 缺失");
|
|
285
|
+
assertChecksum(record.topologyFingerprint, "invalid_manifest", "topologyFingerprint 无效");
|
|
286
|
+
assertCoverage(record.coverage, false, "manifest coverage 无效");
|
|
287
|
+
assertNonEmptyString(record.createdAt, "invalid_manifest", "createdAt 缺失");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function assertRoots(value: unknown): asserts value is SnapshotManifest["roots"] {
|
|
291
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
292
|
+
fail("invalid_manifest", "roots 必须是非空数组");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let previousRoot: string | undefined;
|
|
296
|
+
const roots = new Set<string>();
|
|
297
|
+
const rootParents: Array<{ relativeRoot: string; parentRoot: string | null }> = [];
|
|
298
|
+
const ignoredPathsByRoot: Array<{ relativeRoot: string; paths: readonly string[] }> = [];
|
|
299
|
+
for (const root of value) {
|
|
300
|
+
const record = assertRecord(root, "invalid_manifest", "root 必须是对象");
|
|
301
|
+
assertCanonicalRoot(record.relativeRoot);
|
|
302
|
+
const relativeRoot = record.relativeRoot;
|
|
303
|
+
|
|
304
|
+
if (previousRoot !== undefined && previousRoot >= relativeRoot) {
|
|
305
|
+
fail("noncanonical_roots", "roots 必须按 relativeRoot 严格排序");
|
|
306
|
+
}
|
|
307
|
+
previousRoot = relativeRoot;
|
|
308
|
+
roots.add(relativeRoot);
|
|
309
|
+
|
|
310
|
+
let parentRoot: string | null = null;
|
|
311
|
+
if (record.parentRoot !== null) {
|
|
312
|
+
assertCanonicalRoot(record.parentRoot);
|
|
313
|
+
parentRoot = record.parentRoot;
|
|
314
|
+
}
|
|
315
|
+
rootParents.push({ relativeRoot, parentRoot });
|
|
316
|
+
if (record.state !== "active" && record.state !== "uninitialized" && record.state !== "broken") {
|
|
317
|
+
fail("invalid_manifest", "root state 无效");
|
|
318
|
+
}
|
|
319
|
+
assertNonEmptyString(record.sourceIdentity, "invalid_manifest", "sourceIdentity 缺失");
|
|
320
|
+
assertChecksum(record.privateRepositoryId, "invalid_manifest", "privateRepositoryId 无效");
|
|
321
|
+
if (record.privateRepositoryId !== checksum(record.sourceIdentity)) {
|
|
322
|
+
fail("invalid_manifest", "privateRepositoryId 与 sourceIdentity 不匹配");
|
|
323
|
+
}
|
|
324
|
+
if (record.treeId !== null) {
|
|
325
|
+
assertNonEmptyString(record.treeId, "invalid_manifest", "treeId 无效");
|
|
326
|
+
}
|
|
327
|
+
if (record.state === "active" ? record.treeId === null : record.treeId !== null) {
|
|
328
|
+
fail("invalid_manifest", "root state 与 treeId 不一致");
|
|
329
|
+
}
|
|
330
|
+
if (record.gitlinkOid !== undefined) {
|
|
331
|
+
assertNonEmptyString(record.gitlinkOid, "invalid_manifest", "gitlinkOid 无效");
|
|
332
|
+
}
|
|
333
|
+
assertCoverage(record.coverage, true, "root coverage 无效");
|
|
334
|
+
assertNonEmptyString(record.ignorePolicy, "invalid_manifest", "root ignorePolicy 缺失");
|
|
335
|
+
assertIgnoredPresentPaths(record.ignoredPresentPaths);
|
|
336
|
+
if ((record.coverage === "none" || record.state !== "active") && record.ignoredPresentPaths.length > 0) {
|
|
337
|
+
fail("invalid_manifest", "inactive 或 coverage 为 none 的 root 不能包含 ignored-present proof");
|
|
338
|
+
}
|
|
339
|
+
assertChecksum(record.ignoreClosure, "invalid_manifest", "root ignoreClosure 无效");
|
|
340
|
+
if (record.ignoreClosure !== ignoredPresentClosure({
|
|
341
|
+
coverage: record.coverage,
|
|
342
|
+
ignorePolicy: record.ignorePolicy,
|
|
343
|
+
ignoredPresentPaths: record.ignoredPresentPaths,
|
|
344
|
+
})) {
|
|
345
|
+
fail("invalid_manifest", "root ignored-present proof closure 不匹配");
|
|
346
|
+
}
|
|
347
|
+
ignoredPathsByRoot.push({ relativeRoot, paths: record.ignoredPresentPaths });
|
|
348
|
+
assertChecksum(record.objectClosure, "invalid_manifest", "root objectClosure 无效");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (roots.size !== value.length) {
|
|
352
|
+
fail("noncanonical_roots", "roots 不能重复");
|
|
353
|
+
}
|
|
354
|
+
if (!roots.has(".")) {
|
|
355
|
+
fail("invalid_manifest", "roots 必须包含 workspace 根");
|
|
356
|
+
}
|
|
357
|
+
const rootPaths = [...roots];
|
|
358
|
+
for (const { relativeRoot, parentRoot } of rootParents) {
|
|
359
|
+
const nearestParent = rootPaths
|
|
360
|
+
.filter((candidate) => isStrictRootAncestor(candidate, relativeRoot))
|
|
361
|
+
.sort((left, right) => right.length - left.length || compareRoots(left, right))[0] ?? null;
|
|
362
|
+
if (parentRoot !== nearestParent) {
|
|
363
|
+
fail("invalid_manifest", "parentRoot 必须是已声明的最近祖先");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const { relativeRoot, paths } of ignoredPathsByRoot) {
|
|
367
|
+
const descendantRoots = rootPaths.filter((candidate) => isStrictRootAncestor(relativeRoot, candidate));
|
|
368
|
+
for (const path of paths) {
|
|
369
|
+
const workspacePath = relativeRoot === "." ? path : `${relativeRoot}/${path}`;
|
|
370
|
+
if (descendantRoots.some(
|
|
371
|
+
(descendantRoot) => workspacePath === descendantRoot ||
|
|
372
|
+
workspacePath.startsWith(`${descendantRoot}/`) ||
|
|
373
|
+
descendantRoot.startsWith(`${workspacePath}/`),
|
|
374
|
+
)) {
|
|
375
|
+
fail("invalid_manifest", "root ignoredPresentPaths 不能与 descendant root 冲突");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function assertCoverage(value: unknown, allowNone: boolean, message: string): asserts value is string {
|
|
382
|
+
if (
|
|
383
|
+
value === "complete" ||
|
|
384
|
+
(allowNone && value === "none") ||
|
|
385
|
+
(typeof value === "string" && /^paths:[0-9a-f]{64}$/.test(value))
|
|
386
|
+
) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
fail("invalid_manifest", message);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function assertIgnoredPresentPaths(value: unknown): asserts value is string[] {
|
|
393
|
+
if (!Array.isArray(value)) {
|
|
394
|
+
fail("invalid_manifest", "root ignoredPresentPaths 必须是数组");
|
|
395
|
+
}
|
|
396
|
+
let previous: string | undefined;
|
|
397
|
+
const paths = new Set<string>();
|
|
398
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
399
|
+
if (!Object.prototype.hasOwnProperty.call(value, index)) {
|
|
400
|
+
fail("invalid_manifest", "root ignoredPresentPaths 必须是稠密数组");
|
|
401
|
+
}
|
|
402
|
+
const path = value[index];
|
|
403
|
+
if (
|
|
404
|
+
typeof path !== "string" ||
|
|
405
|
+
path === "." ||
|
|
406
|
+
!isCanonicalRoot(path) ||
|
|
407
|
+
path.split("/").some((component) => component.toLowerCase() === ".git")
|
|
408
|
+
) {
|
|
409
|
+
fail("invalid_manifest", "root ignoredPresentPaths 包含不安全路径");
|
|
410
|
+
}
|
|
411
|
+
if (previous !== undefined && previous >= path) {
|
|
412
|
+
fail("invalid_manifest", "root ignoredPresentPaths 必须严格排序且不能重复");
|
|
413
|
+
}
|
|
414
|
+
if (path.split("/").slice(0, -1).some(
|
|
415
|
+
(_part, ancestorIndex, parts) => paths.has(parts.slice(0, ancestorIndex + 1).join("/")),
|
|
416
|
+
)) {
|
|
417
|
+
fail("invalid_manifest", "root ignoredPresentPaths 不能包含叶子前缀冲突");
|
|
418
|
+
}
|
|
419
|
+
paths.add(path);
|
|
420
|
+
previous = path;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function assertCursorFields(record: Record<string, unknown>): void {
|
|
425
|
+
assertOperationId(record.opId);
|
|
426
|
+
if (record.action !== "undo" && record.action !== "redo" && record.action !== "tree") {
|
|
427
|
+
fail("invalid_cursor", "action 无效");
|
|
428
|
+
}
|
|
429
|
+
assertSessionFileIdentity(record.sessionIdentity, "invalid_cursor");
|
|
430
|
+
assertNullableString(record.fromLogicalLeaf, "invalid_cursor", "fromLogicalLeaf 无效");
|
|
431
|
+
assertNullableString(record.toLogicalLeaf, "invalid_cursor", "toLogicalLeaf 无效");
|
|
432
|
+
assertManifestId(record.targetManifestId);
|
|
433
|
+
assertManifestId(record.rollbackManifestId);
|
|
434
|
+
assertNullableString(record.undoHead, "invalid_cursor", "undoHead 无效");
|
|
435
|
+
if (!isDenseStringArray(record.redoStack)) {
|
|
436
|
+
fail("invalid_cursor", "redoStack 无效");
|
|
437
|
+
}
|
|
438
|
+
assertChecksum(record.descriptorChecksum, "invalid_cursor", "descriptorChecksum 无效");
|
|
439
|
+
assertChecksum(record.checksum, "checksum_mismatch", "checksum 无效");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function assertSessionFileIdentity(value: unknown, code: ValidationCode): void {
|
|
443
|
+
const record = assertRecord(value, code, "sessionIdentity 无效");
|
|
444
|
+
if (typeof record.path !== "string" || record.path.length === 0 || record.path.includes("\0")) {
|
|
445
|
+
fail(code, "sessionIdentity path 无效");
|
|
446
|
+
}
|
|
447
|
+
assertChecksum(record.headerChecksum, code, "sessionIdentity headerChecksum 无效");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function assertScopePaths(value: unknown, coverage: unknown): void {
|
|
451
|
+
if (!Array.isArray(value)) {
|
|
452
|
+
fail("invalid_descriptor", "scopePaths 必须是数组");
|
|
453
|
+
}
|
|
454
|
+
let previous: string | undefined;
|
|
455
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
456
|
+
const path = value[index];
|
|
457
|
+
if (!Object.prototype.hasOwnProperty.call(value, index) || typeof path !== "string" || !isCanonicalRoot(path)) {
|
|
458
|
+
fail("invalid_descriptor", "scopePaths 包含不安全路径");
|
|
459
|
+
}
|
|
460
|
+
if (previous !== undefined && previous >= path) {
|
|
461
|
+
fail("invalid_descriptor", "scopePaths 必须严格排序且不能重复");
|
|
462
|
+
}
|
|
463
|
+
previous = path;
|
|
464
|
+
}
|
|
465
|
+
if (typeof coverage === "string" && coverage.startsWith("paths:")) {
|
|
466
|
+
const expected = `paths:${checksum(canonicalJson(value))}`;
|
|
467
|
+
if (coverage !== expected) {
|
|
468
|
+
fail("invalid_descriptor", "scopePaths 与 coverage 不匹配");
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function isJournalPhase(value: unknown): value is JournalState["phase"] {
|
|
474
|
+
return value === "PREPARING" || value === "PREPARED" || value === "SESSION_MOVED" ||
|
|
475
|
+
value === "APPLYING" || value === "FILES_VERIFIED" || value === "CURSOR_COMMITTED" ||
|
|
476
|
+
value === "COMMITTED" || value === "ABORTING" || value === "ABORTED" ||
|
|
477
|
+
value === "RECOVERY_REQUIRED";
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function isMutationState(value: unknown): value is MutationRecord["state"] {
|
|
481
|
+
return value === "INTENT" || value === "SOURCE_QUARANTINED" || value === "SOURCE_VERIFIED" ||
|
|
482
|
+
value === "TARGET_INSTALLED" || value === "TARGET_VERIFIED" || value === "CLEANED";
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function assertWorkspacePath(value: unknown, name: string): asserts value is string {
|
|
486
|
+
if (
|
|
487
|
+
typeof value !== "string" ||
|
|
488
|
+
value === "." ||
|
|
489
|
+
!isCanonicalRoot(value) ||
|
|
490
|
+
value.split("/").some((component) => component.toLowerCase() === ".git")
|
|
491
|
+
) {
|
|
492
|
+
fail("invalid_mutation_record", `${name} 必须是安全的 workspace 相对路径`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function workspaceParent(path: string): string {
|
|
497
|
+
const separator = path.lastIndexOf("/");
|
|
498
|
+
return separator === -1 ? "" : path.slice(0, separator);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function isDenseStringArray(value: unknown): value is string[] {
|
|
502
|
+
if (!Array.isArray(value)) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
506
|
+
if (!Object.prototype.hasOwnProperty.call(value, index) || typeof value[index] !== "string" || value[index].length === 0) {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function assertSchemaVersion(record: Record<string, unknown>, name: string): void {
|
|
514
|
+
if (record.schemaVersion !== 1) {
|
|
515
|
+
fail("unsupported_schema", `${name} schemaVersion 不受支持`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function assertManifestId(value: unknown): ManifestId {
|
|
520
|
+
assertChecksum(value, "invalid_manifest_id", "manifest ID 无效");
|
|
521
|
+
return value as ManifestId;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function assertCanonicalRoot(value: unknown): asserts value is string {
|
|
525
|
+
if (typeof value !== "string" || !isCanonicalRoot(value)) {
|
|
526
|
+
fail("invalid_root_path", "root 路径必须是规范的相对 POSIX 路径");
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function isCanonicalRoot(value: string): boolean {
|
|
531
|
+
if (value === ".") {
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
if (
|
|
535
|
+
value.length === 0 ||
|
|
536
|
+
value.startsWith("/") ||
|
|
537
|
+
value.endsWith("/") ||
|
|
538
|
+
value.includes("\\") ||
|
|
539
|
+
value.includes("\0") ||
|
|
540
|
+
/^[A-Za-z]:/.test(value)
|
|
541
|
+
) {
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
return value.split("/").every((part) => part.length > 0 && part !== "." && part !== "..");
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function isStrictRootAncestor(parentRoot: string, relativeRoot: string): boolean {
|
|
548
|
+
return parentRoot === "." ? relativeRoot !== "." : relativeRoot.startsWith(`${parentRoot}/`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function isArrayIndex(key: string, length: number): boolean {
|
|
552
|
+
const index = Number(key);
|
|
553
|
+
return Number.isInteger(index) && index >= 0 && index < length && String(index) === key;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function compareRoots(left: string, right: string): number {
|
|
557
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function assertRecord(value: unknown, code: ValidationCode, message: string): Record<string, unknown> {
|
|
561
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
562
|
+
fail(code, message);
|
|
563
|
+
}
|
|
564
|
+
if (!isPlainObject(value)) {
|
|
565
|
+
fail(code, message);
|
|
566
|
+
}
|
|
567
|
+
if (!hasOnlyEnumerableDataProperties(value)) {
|
|
568
|
+
fail(code, message);
|
|
569
|
+
}
|
|
570
|
+
return value;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function hasOnlyEnumerableDataProperties(value: object): boolean {
|
|
574
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
return Object.values(Object.getOwnPropertyDescriptors(value)).every(
|
|
578
|
+
(descriptor) => descriptor.enumerable && Object.hasOwn(descriptor, "value"),
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function assertNonEmptyString(value: unknown, code: ValidationCode, message: string): asserts value is string {
|
|
583
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
584
|
+
fail(code, message);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function assertNullableString(value: unknown, code: ValidationCode, message: string): void {
|
|
589
|
+
if (value !== null && (typeof value !== "string" || value.length === 0)) {
|
|
590
|
+
fail(code, message);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function assertChecksum(value: unknown, code: ValidationCode, message: string): asserts value is string {
|
|
595
|
+
if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) {
|
|
596
|
+
fail(code, message);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function checksumWithout(
|
|
601
|
+
record: Record<string, unknown>,
|
|
602
|
+
key: "manifestId" | "checksum",
|
|
603
|
+
code: ValidationCode,
|
|
604
|
+
): string {
|
|
605
|
+
const { [key]: _ignored, ...content } = record;
|
|
606
|
+
try {
|
|
607
|
+
return checksum(canonicalJson(content));
|
|
608
|
+
} catch {
|
|
609
|
+
fail(code, "记录包含无法编码的值");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function isPlainObject(value: object): value is Record<string, unknown> {
|
|
614
|
+
const prototype = Object.getPrototypeOf(value);
|
|
615
|
+
return prototype === Object.prototype || prototype === null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function fail(code: ValidationCode, message: string): never {
|
|
619
|
+
throw new ModelValidationError(code, message);
|
|
620
|
+
}
|