@sanning/proof 0.12.0 → 0.14.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/README.md +25 -12
- package/dist/agent-proof.d.ts +2 -2
- package/dist/agent-proof.js +4 -4
- package/dist/case-file.d.ts +126 -0
- package/dist/case-file.js +353 -0
- package/dist/case.d.ts +217 -26
- package/dist/case.js +535 -124
- package/dist/cli.d.ts +64 -8
- package/dist/cli.js +799 -219
- package/dist/compose.js +6 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.js +5 -1
- package/dist/declaration.d.ts +58 -52
- package/dist/declaration.js +256 -33
- package/dist/evidence.d.ts +38 -0
- package/dist/evidence.js +238 -22
- package/dist/exit-codes.d.ts +17 -0
- package/dist/exit-codes.js +50 -0
- package/dist/file-match.d.ts +105 -0
- package/dist/file-match.js +374 -0
- package/dist/grading.d.ts +28 -13
- package/dist/grading.js +90 -31
- package/dist/index.d.ts +14 -7
- package/dist/index.js +20 -6
- package/dist/message-parts.d.ts +18 -10
- package/dist/message-parts.js +25 -15
- package/dist/types.d.ts +1 -0
- package/dist/verifier.d.ts +63 -4
- package/dist/verifier.js +223 -15
- package/dist/witness-cache.d.ts +85 -0
- package/dist/witness-cache.js +264 -0
- package/dist/zip-reader.d.ts +50 -0
- package/dist/zip-reader.js +220 -0
- package/package.json +2 -2
- package/src/agent-proof.ts +4 -4
- package/src/case-file.ts +422 -0
- package/src/case.ts +671 -131
- package/src/cli.ts +932 -217
- package/src/compose.ts +8 -1
- package/src/crypto.ts +5 -1
- package/src/declaration.ts +248 -52
- package/src/evidence.ts +257 -23
- package/src/exit-codes.ts +54 -0
- package/src/file-match.ts +461 -0
- package/src/grading.ts +109 -52
- package/src/index.ts +101 -2
- package/src/message-parts.ts +25 -15
- package/src/types.ts +5 -0
- package/src/verifier.ts +225 -16
- package/src/witness-cache.ts +306 -0
- package/src/zip-reader.ts +229 -0
- package/vectors/README.md +66 -8
- package/vectors/case-file-index.json +668 -0
- package/vectors/case-split.json +218 -9
- package/vectors/file-match.json +1808 -0
- package/vectors/jcs-reader.json +82 -3
- package/vectors/pack-grading.json +68 -4
- package/vectors/stamp-on-chain.json +20 -5
- package/vectors/witness-cache.json +1304 -0
package/src/case-file.ts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The case file's index, compared with the drop it describes (A25).
|
|
3
|
+
*
|
|
4
|
+
* The TypeScript half of `src/sanning_proof/case_file.py`. Both kernels are
|
|
5
|
+
* held to one table, `vectors/case-file-index.json`, so neither can pass by
|
|
6
|
+
* agreeing with the other, and `cross-kernel/run_case_cli.sh` holds the two
|
|
7
|
+
* commands to the same `case_file` output.
|
|
8
|
+
*
|
|
9
|
+
* WHAT A CASE FILE IS (evidence-bundle.md 5.6 v1.20). A ZIP archive holding one
|
|
10
|
+
* case's packs, each unpacked under `packs/<ref>/`, an index (`contents.json`),
|
|
11
|
+
* the one command that checks them (`HOW-TO-VERIFY.txt`) and an optional
|
|
12
|
+
* `report/` of files no record commits to. **Nobody signs it.** Each pack in it
|
|
13
|
+
* is byte for byte the pack its signer produced, and a reader reads the packs,
|
|
14
|
+
* never the file.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THIS MODULE DOES, AND WHAT IT MUST NEVER DO. It compares the index with
|
|
17
|
+
* the files the reader was handed and returns findings with NO verdict
|
|
18
|
+
* contribution. **The index is never an input to the case reading**
|
|
19
|
+
* (`readCase`): adding, removing or editing `contents.json` moves no verdict,
|
|
20
|
+
* no exception, no note, no contribution and no exit code. An assembler can lie
|
|
21
|
+
* in every member of the index, and each lie is a finding here and nothing
|
|
22
|
+
* more; it can leave the index out, which is no finding at all.
|
|
23
|
+
*
|
|
24
|
+
* Pure: bytes and a file list in, a reading out. No filesystem, no zip, no
|
|
25
|
+
* network. How the files were listed and hashed is the caller's.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { DuplicateKeyError, jcsParse } from "./verifier.js";
|
|
29
|
+
|
|
30
|
+
/** The index's dispatch key (5.6.2). An absent or unknown value makes the index unreadable. */
|
|
31
|
+
export const CASE_FILE_VERSION = "sanning.case-file/v1";
|
|
32
|
+
|
|
33
|
+
/** The index, at the root of the case file. */
|
|
34
|
+
export const CASE_FILE_INDEX_NAME = "contents.json";
|
|
35
|
+
|
|
36
|
+
/** The one command that checks the file, at its root (5.6.4). */
|
|
37
|
+
export const HOW_TO_VERIFY_NAME = "HOW-TO-VERIFY.txt";
|
|
38
|
+
|
|
39
|
+
/** The first line of `report/README.txt`, exactly (5.6.1). */
|
|
40
|
+
export const REPORT_README_FIRST_LINE =
|
|
41
|
+
"Not evidence: no record in this case file commits to the files in this folder.";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How deep `[` and `{` may nest in the index before it is refused unread. The
|
|
45
|
+
* same limit `bundle.json` is read under, so a hostile index cannot make one
|
|
46
|
+
* kernel's parser recurse where the other's does not.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_INDEX_DEPTH = 512;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every finding this comparison can report, in the order it reports them
|
|
52
|
+
* (5.6.3). None of them is a verdict word and none contributes to one.
|
|
53
|
+
*/
|
|
54
|
+
export const CASE_FILE_FINDING_KINDS = [
|
|
55
|
+
"case_index_unreadable",
|
|
56
|
+
"case_index_pack_missing",
|
|
57
|
+
"case_index_pack_unlisted",
|
|
58
|
+
"case_index_file_missing",
|
|
59
|
+
"case_index_file_unlisted",
|
|
60
|
+
"case_index_file_differs",
|
|
61
|
+
"case_index_names_absent",
|
|
62
|
+
] as const;
|
|
63
|
+
export type CaseFileFindingKind = (typeof CASE_FILE_FINDING_KINDS)[number];
|
|
64
|
+
|
|
65
|
+
// `<ref>`: 1 to 128 characters from A-Z, a-z, 0-9, `.`, `_` and `-`, and not
|
|
66
|
+
// `.` or `..` (5.6.1). The class holds no newline, so `$` cannot match before
|
|
67
|
+
// one here.
|
|
68
|
+
const REF = /^[A-Za-z0-9._-]{1,128}$/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether `ref` is a legal pack folder name in a case file. For a producer; a
|
|
72
|
+
* reader takes whatever folders it finds and names them.
|
|
73
|
+
*/
|
|
74
|
+
export function isPackRef(ref: unknown): boolean {
|
|
75
|
+
return typeof ref === "string" && REF.test(ref) && ref !== "." && ref !== "..";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `HOW-TO-VERIFY.txt`, exactly (5.6.4): ASCII, LF line endings, with `name` the
|
|
80
|
+
* case file's name without `.zip`.
|
|
81
|
+
*/
|
|
82
|
+
export function howToVerifyText(name: string): string {
|
|
83
|
+
return (
|
|
84
|
+
"This case file holds evidence packs. Each pack is signed by its own key.\n" +
|
|
85
|
+
"Nobody signed this file, contents.json, or anything under report/.\n" +
|
|
86
|
+
"\n" +
|
|
87
|
+
"To check it offline, on your own machine:\n" +
|
|
88
|
+
"\n" +
|
|
89
|
+
` unzip ${name}.zip -d ${name}\n` +
|
|
90
|
+
` npx @sanning/proof case ${name}\n` +
|
|
91
|
+
"\n" +
|
|
92
|
+
"or, with the Python kernel (pip install sanning-proof):\n" +
|
|
93
|
+
"\n" +
|
|
94
|
+
` sanning-proof case ${name}\n` +
|
|
95
|
+
"\n" +
|
|
96
|
+
"Exit codes: 0 verified, 1 failed, 2 not readable or a usage error,\n" +
|
|
97
|
+
"3 could not be settled (partial, pending, incomplete, not found), 4 mixed.\n" +
|
|
98
|
+
"\n" +
|
|
99
|
+
"To also look each witness up on the public record, add --gateways <url,url>.\n" +
|
|
100
|
+
"The lookup is reported on its own line and never changes the verdict word.\n" +
|
|
101
|
+
"With it, a witness that does not match exits 1.\n"
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One way the index and the drop disagree, as data. What the index SAYS
|
|
107
|
+
* against what the folder HOLDS, and never a statement about any party.
|
|
108
|
+
*/
|
|
109
|
+
export interface CaseFileFinding {
|
|
110
|
+
kind: CaseFileFindingKind;
|
|
111
|
+
/** The pack folder name, for the two pack kinds. */
|
|
112
|
+
ref?: string;
|
|
113
|
+
/** The file, from the case file's root, for the three file kinds. */
|
|
114
|
+
path?: string;
|
|
115
|
+
/** What the index lists for the file, for `case_index_file_differs`. */
|
|
116
|
+
listedSha256?: string;
|
|
117
|
+
/** What the file in the folder hashes to, for `case_index_file_differs`. */
|
|
118
|
+
sha256?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Why the index is unreadable, or the assembler's reason a pack is absent,
|
|
121
|
+
* for `case_index_unreadable` and `case_index_names_absent`.
|
|
122
|
+
*/
|
|
123
|
+
reason?: string;
|
|
124
|
+
/**
|
|
125
|
+
* The key the assembler says the absent pack would be signed by, as the index
|
|
126
|
+
* writes it, for `case_index_names_absent`.
|
|
127
|
+
*/
|
|
128
|
+
publicKey?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface CaseFileIndexReading {
|
|
132
|
+
/** `absent` (no index was handed in), `unreadable` or `read`. */
|
|
133
|
+
index: "absent" | "unreadable" | "read";
|
|
134
|
+
/**
|
|
135
|
+
* The index's `case_id`, a label the assembler wrote and nothing is compared
|
|
136
|
+
* with, or `null` where the index was not read.
|
|
137
|
+
*/
|
|
138
|
+
caseId: string | null;
|
|
139
|
+
findings: CaseFileFinding[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether `[` and `{` outside strings ever nest deeper than `limit`. One pass,
|
|
144
|
+
* no recursion; the same scan as `json_depth_exceeds` in the Python twin, so
|
|
145
|
+
* both kernels refuse the same text. Given bytes, it reads each byte as the
|
|
146
|
+
* character of that code, which is the same answer for UTF-8: the six
|
|
147
|
+
* characters it reads are single bytes and no multi-byte sequence holds one.
|
|
148
|
+
* The CLI's `bundle.json` reader uses it too.
|
|
149
|
+
*/
|
|
150
|
+
export function jsonDepthExceeds(text: string | Uint8Array, limit: number): boolean {
|
|
151
|
+
let depth = 0;
|
|
152
|
+
let inString = false;
|
|
153
|
+
let escaped = false;
|
|
154
|
+
const n = text.length;
|
|
155
|
+
for (let i = 0; i < n; i++) {
|
|
156
|
+
const ch =
|
|
157
|
+
typeof text === "string" ? text.charCodeAt(i) : (text as Uint8Array)[i];
|
|
158
|
+
if (inString) {
|
|
159
|
+
if (escaped) escaped = false;
|
|
160
|
+
else if (ch === 0x5c) escaped = true;
|
|
161
|
+
else if (ch === 0x22) inString = false;
|
|
162
|
+
} else if (ch === 0x22) {
|
|
163
|
+
inString = true;
|
|
164
|
+
} else if (ch === 0x5b || ch === 0x7b) {
|
|
165
|
+
depth++;
|
|
166
|
+
if (depth > limit) return true;
|
|
167
|
+
} else if (ch === 0x5d || ch === 0x7d) {
|
|
168
|
+
depth--;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const utf8 = new TextEncoder();
|
|
175
|
+
|
|
176
|
+
/** Sorted by the UTF-8 bytes of the value (5.6.2), never by UTF-16 code unit. */
|
|
177
|
+
function byUtf8(a: string, b: string): number {
|
|
178
|
+
const x = utf8.encode(a);
|
|
179
|
+
const y = utf8.encode(b);
|
|
180
|
+
const n = Math.min(x.length, y.length);
|
|
181
|
+
for (let i = 0; i < n; i++) {
|
|
182
|
+
if (x[i] !== y[i]) return x[i] - y[i];
|
|
183
|
+
}
|
|
184
|
+
return x.length - y.length;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
class Unreadable extends Error {
|
|
188
|
+
constructor(readonly reason: string) {
|
|
189
|
+
super(reason);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const has = (o: object, k: string): boolean => Object.prototype.hasOwnProperty.call(o, k);
|
|
194
|
+
|
|
195
|
+
const isObject = (v: unknown): v is Record<string, unknown> =>
|
|
196
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
197
|
+
|
|
198
|
+
/** An own member, and never one the prototype chain answers for. */
|
|
199
|
+
const own = (o: Record<string, unknown>, k: string): unknown => (has(o, k) ? o[k] : undefined);
|
|
200
|
+
|
|
201
|
+
function fileList(value: unknown, where: string): [string, string][] {
|
|
202
|
+
if (!Array.isArray(value)) throw new Unreadable(`${where} is not an array`);
|
|
203
|
+
const out: [string, string][] = [];
|
|
204
|
+
value.forEach((entry, i) => {
|
|
205
|
+
if (
|
|
206
|
+
!(
|
|
207
|
+
isObject(entry) &&
|
|
208
|
+
typeof own(entry, "path") === "string" &&
|
|
209
|
+
typeof own(entry, "sha256") === "string"
|
|
210
|
+
)
|
|
211
|
+
) {
|
|
212
|
+
throw new Unreadable(`${where}[${i}] is not an object with a string path and sha256`);
|
|
213
|
+
}
|
|
214
|
+
out.push([entry["path"] as string, entry["sha256"] as string]);
|
|
215
|
+
});
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
interface ReadIndex {
|
|
220
|
+
caseId: string;
|
|
221
|
+
packs: [string, [string, string][]][];
|
|
222
|
+
absent: [string, string | null][];
|
|
223
|
+
report: [string, string][];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The index, read under the JSON rule 5.4.2 rule 7 pins, and checked for every
|
|
228
|
+
* REQUIRED member and its type. JCS is not required and an unknown member is
|
|
229
|
+
* ignored (5.6.2). The FIRST problem is the reason.
|
|
230
|
+
*/
|
|
231
|
+
function readIndex(raw: Uint8Array): ReadIndex {
|
|
232
|
+
// Scanned as BYTES, before any decode, so both kernels refuse the same index
|
|
233
|
+
// in the same order whatever it holds.
|
|
234
|
+
if (jsonDepthExceeds(raw, MAX_INDEX_DEPTH)) {
|
|
235
|
+
throw new Unreadable(`nests deeper than ${MAX_INDEX_DEPTH} levels`);
|
|
236
|
+
}
|
|
237
|
+
let value: unknown;
|
|
238
|
+
try {
|
|
239
|
+
value = jcsParse(raw);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
// Named, because an index that lists one pack twice under one member name
|
|
242
|
+
// reads as whichever copy came last (I-JSON, proof#144).
|
|
243
|
+
if (e instanceof DuplicateKeyError) throw new Unreadable(e.message);
|
|
244
|
+
throw new Unreadable("not readable as JSON");
|
|
245
|
+
}
|
|
246
|
+
if (!isObject(value)) throw new Unreadable("not a JSON object");
|
|
247
|
+
if (own(value, "contents_version") !== CASE_FILE_VERSION) {
|
|
248
|
+
throw new Unreadable(`contents_version is not ${CASE_FILE_VERSION}`);
|
|
249
|
+
}
|
|
250
|
+
const caseId = own(value, "case_id");
|
|
251
|
+
if (typeof caseId !== "string") throw new Unreadable("case_id is not a string");
|
|
252
|
+
const packs = own(value, "packs");
|
|
253
|
+
if (!Array.isArray(packs)) throw new Unreadable("packs is not an array");
|
|
254
|
+
const listedPacks: [string, [string, string][]][] = [];
|
|
255
|
+
packs.forEach((entry, i) => {
|
|
256
|
+
if (!(isObject(entry) && typeof own(entry, "ref") === "string")) {
|
|
257
|
+
throw new Unreadable(`packs[${i}] is not an object with a string ref`);
|
|
258
|
+
}
|
|
259
|
+
listedPacks.push([entry["ref"] as string, fileList(own(entry, "files"), `packs[${i}].files`)]);
|
|
260
|
+
});
|
|
261
|
+
const absent = own(value, "absent");
|
|
262
|
+
if (!Array.isArray(absent)) throw new Unreadable("absent is not an array");
|
|
263
|
+
const listedAbsent: [string, string | null][] = [];
|
|
264
|
+
absent.forEach((entry, i) => {
|
|
265
|
+
if (!(isObject(entry) && typeof own(entry, "reason") === "string")) {
|
|
266
|
+
throw new Unreadable(`absent[${i}] is not an object with a string reason`);
|
|
267
|
+
}
|
|
268
|
+
const key = own(entry, "public_key");
|
|
269
|
+
if (has(entry, "public_key") && typeof key !== "string") {
|
|
270
|
+
throw new Unreadable(`absent[${i}].public_key is not a string`);
|
|
271
|
+
}
|
|
272
|
+
listedAbsent.push([entry["reason"] as string, has(entry, "public_key") ? (key as string) : null]);
|
|
273
|
+
});
|
|
274
|
+
const report = fileList(own(value, "report"), "report");
|
|
275
|
+
return { caseId, packs: listedPacks, absent: listedAbsent, report };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Compare a case file's index with the folder it sits at the root of.
|
|
280
|
+
*
|
|
281
|
+
* `index` is the bytes of `contents.json`, or `null` where the folder holds
|
|
282
|
+
* none. `files` is every regular file in the folder, as `{ path, sha256 }`
|
|
283
|
+
* with `path` from the folder's root, `/`-separated; only the files under
|
|
284
|
+
* `packs/` and `report/` are compared, so a caller may list the rest or not. A
|
|
285
|
+
* folder with no index is a folder of packs, with no finding.
|
|
286
|
+
*
|
|
287
|
+
* Findings come in the order of `CASE_FILE_FINDING_KINDS`, and within a kind in
|
|
288
|
+
* the order of their `ref` or `path` by UTF-8 bytes; `case_index_names_absent`
|
|
289
|
+
* is in the index's own order, one per `absent[]` entry. An unreadable index is
|
|
290
|
+
* one finding and no other comparison runs.
|
|
291
|
+
*
|
|
292
|
+
* Nothing here is a verdict, and nothing here is ever an input to `readCase`.
|
|
293
|
+
*/
|
|
294
|
+
export function readCaseFileIndex(
|
|
295
|
+
index: Uint8Array | null,
|
|
296
|
+
files: readonly { path: string; sha256: string }[],
|
|
297
|
+
): CaseFileIndexReading {
|
|
298
|
+
if (index === null || index === undefined) {
|
|
299
|
+
return { index: "absent", caseId: null, findings: [] };
|
|
300
|
+
}
|
|
301
|
+
let read: ReadIndex;
|
|
302
|
+
try {
|
|
303
|
+
read = readIndex(index);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
if (!(e instanceof Unreadable)) throw e;
|
|
306
|
+
return {
|
|
307
|
+
index: "unreadable",
|
|
308
|
+
caseId: null,
|
|
309
|
+
findings: [{ kind: "case_index_unreadable", reason: e.reason }],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const here = new Map<string, string>();
|
|
314
|
+
for (const entry of files) {
|
|
315
|
+
const path = isObject(entry) ? own(entry, "path") : undefined;
|
|
316
|
+
const digest = isObject(entry) ? own(entry, "sha256") : undefined;
|
|
317
|
+
if (typeof path === "string" && typeof digest === "string" && !here.has(path)) {
|
|
318
|
+
here.set(path, digest);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const listedRefs = new Set(read.packs.map(([ref]) => ref));
|
|
323
|
+
// The first listing of a path wins, in index order: packs in order, each
|
|
324
|
+
// pack's files in order, then report/. A path listed twice is compared once.
|
|
325
|
+
const listed = new Map<string, string>();
|
|
326
|
+
for (const [, packFiles] of read.packs) {
|
|
327
|
+
for (const [path, digest] of packFiles) if (!listed.has(path)) listed.set(path, digest);
|
|
328
|
+
}
|
|
329
|
+
for (const [path, digest] of read.report) if (!listed.has(path)) listed.set(path, digest);
|
|
330
|
+
|
|
331
|
+
const findings: CaseFileFinding[] = [];
|
|
332
|
+
|
|
333
|
+
for (const ref of [...listedRefs].sort(byUtf8)) {
|
|
334
|
+
if (!here.has(`packs/${ref}/bundle.json`)) {
|
|
335
|
+
findings.push({ kind: "case_index_pack_missing", ref });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const presentRefs = new Set<string>();
|
|
340
|
+
for (const path of here.keys()) {
|
|
341
|
+
const parts = path.split("/");
|
|
342
|
+
if (parts.length === 3 && parts[0] === "packs" && parts[2] === "bundle.json") {
|
|
343
|
+
presentRefs.add(parts[1]);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
for (const ref of [...presentRefs].filter((r) => !listedRefs.has(r)).sort(byUtf8)) {
|
|
347
|
+
findings.push({ kind: "case_index_pack_unlisted", ref });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const listedPaths = [...listed.keys()].sort(byUtf8);
|
|
351
|
+
for (const path of listedPaths) {
|
|
352
|
+
if (!here.has(path)) findings.push({ kind: "case_index_file_missing", path });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const compared = (path: string): boolean => {
|
|
356
|
+
if (path.startsWith("report/")) return true;
|
|
357
|
+
const parts = path.split("/");
|
|
358
|
+
return parts.length >= 3 && parts[0] === "packs" && listedRefs.has(parts[1]);
|
|
359
|
+
};
|
|
360
|
+
for (const path of [...here.keys()].sort(byUtf8)) {
|
|
361
|
+
if (!listed.has(path) && compared(path)) {
|
|
362
|
+
findings.push({ kind: "case_index_file_unlisted", path });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const path of listedPaths) {
|
|
367
|
+
const actual = here.get(path);
|
|
368
|
+
if (actual !== undefined && actual !== listed.get(path)) {
|
|
369
|
+
findings.push({
|
|
370
|
+
kind: "case_index_file_differs",
|
|
371
|
+
path,
|
|
372
|
+
listedSha256: listed.get(path) as string,
|
|
373
|
+
sha256: actual,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const [reason, key] of read.absent) {
|
|
379
|
+
findings.push(
|
|
380
|
+
key === null
|
|
381
|
+
? { kind: "case_index_names_absent", reason }
|
|
382
|
+
: { kind: "case_index_names_absent", reason, publicKey: key },
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return { index: "read", caseId: read.caseId, findings };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The house wording for one finding: what the index says against what the
|
|
391
|
+
* folder holds, and the assembler's own statement for an absent pack. Never a
|
|
392
|
+
* sentence about a party. Both kernels return this text.
|
|
393
|
+
*/
|
|
394
|
+
export function renderCaseFileFinding(finding: CaseFileFinding): string {
|
|
395
|
+
switch (finding.kind) {
|
|
396
|
+
case "case_index_unreadable":
|
|
397
|
+
return `contents.json is not an index this reader can read: ${finding.reason}`;
|
|
398
|
+
case "case_index_pack_missing":
|
|
399
|
+
return `the index lists pack ${finding.ref}; it is not here`;
|
|
400
|
+
case "case_index_pack_unlisted":
|
|
401
|
+
return `pack ${finding.ref} is here; the index does not list it`;
|
|
402
|
+
case "case_index_file_missing":
|
|
403
|
+
return `the index lists ${finding.path}; it is not here`;
|
|
404
|
+
case "case_index_file_unlisted":
|
|
405
|
+
return `${finding.path} is here; the index does not list it`;
|
|
406
|
+
case "case_index_file_differs":
|
|
407
|
+
return (
|
|
408
|
+
`the index lists ${finding.path} with SHA-256 ${finding.listedSha256}; ` +
|
|
409
|
+
`the file here hashes to ${finding.sha256}`
|
|
410
|
+
);
|
|
411
|
+
case "case_index_names_absent":
|
|
412
|
+
if (finding.publicKey !== undefined) {
|
|
413
|
+
return (
|
|
414
|
+
"the assembler says a pack signed by key " +
|
|
415
|
+
`${finding.publicKey} is absent: ${finding.reason}`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return `the assembler says a pack is absent: ${finding.reason}`;
|
|
419
|
+
default:
|
|
420
|
+
return (finding as CaseFileFinding).kind;
|
|
421
|
+
}
|
|
422
|
+
}
|