@hraness/message-like-me 0.8.0 → 0.8.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.
@@ -0,0 +1,638 @@
1
+ #!/usr/bin/env bun
2
+ /** Validate an Ensoul source packet without printing evidence content. */
3
+
4
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
5
+ import { isAbsolute } from "node:path";
6
+
7
+ import {
8
+ ArchiveError,
9
+ canonicalBytes,
10
+ canonicalText,
11
+ failPacket,
12
+ type JsonValue,
13
+ rejectNonIJson,
14
+ sha256Hex,
15
+ strictJsonParse,
16
+ } from "./source-packet.ts";
17
+
18
+ export { PacketValidationError } from "./source-packet.ts";
19
+
20
+ const MAX_PACKET_BYTES = 128 * 1024 * 1024;
21
+ const SHA256 = /^[a-f0-9]{64}$/u;
22
+ const PREFIXED_SHA256 = /^sha256:[a-f0-9]{64}$/u;
23
+ const DATE_TIME =
24
+ /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]+))?(Z|[+-][0-9]{2}:[0-9]{2})$/u;
25
+
26
+ type JsonObject = { [key: string]: JsonValue };
27
+ type DateTime = bigint;
28
+
29
+ export type ValidationReceipt = Readonly<{
30
+ valid: true;
31
+ schemaVersion: JsonValue;
32
+ packetDigest: string;
33
+ adapter: JsonValue;
34
+ payloadSchema: JsonValue;
35
+ records: number;
36
+ claims: number;
37
+ visibility: Readonly<{ private: number; public: number }>;
38
+ }>;
39
+
40
+ function expectObject(value: JsonValue, path: string): JsonObject {
41
+ if (value === null || Array.isArray(value) || typeof value !== "object") {
42
+ failPacket(path, "must be an object");
43
+ }
44
+ return value;
45
+ }
46
+
47
+ function expectArray(value: JsonValue, path: string): JsonValue[] {
48
+ if (!Array.isArray(value)) failPacket(path, "must be an array");
49
+ return value;
50
+ }
51
+
52
+ function codePointLength(value: string): number {
53
+ return Array.from(value).length;
54
+ }
55
+
56
+ function expectString(value: JsonValue, path: string, minimum: number, maximum: number): string {
57
+ if (typeof value !== "string") failPacket(path, "must be a string");
58
+ const length = codePointLength(value);
59
+ if (length < minimum || length > maximum) failPacket(path, "has an invalid length");
60
+ return value;
61
+ }
62
+
63
+ function expectBoolean(value: JsonValue, path: string): boolean {
64
+ if (typeof value !== "boolean") failPacket(path, "must be a boolean");
65
+ return value;
66
+ }
67
+
68
+ function exactKeys(
69
+ value: JsonObject,
70
+ path: string,
71
+ required: ReadonlySet<string>,
72
+ optional: ReadonlySet<string> = new Set<string>(),
73
+ ): void {
74
+ const keys = new Set(Object.keys(value));
75
+ const missing = Array.from(required).filter((key) => !keys.has(key)).sort();
76
+ const extra = Array.from(keys)
77
+ .filter((key) => !required.has(key) && !optional.has(key))
78
+ .sort();
79
+ if (missing.length !== 0) failPacket(path, `missing required member ${missing[0]}`);
80
+ if (extra.length !== 0) failPacket(path, `contains unknown member ${extra[0]}`);
81
+ }
82
+
83
+ function expectEnum(
84
+ value: JsonValue,
85
+ path: string,
86
+ allowed: ReadonlySet<string>,
87
+ ): string {
88
+ const text = expectString(value, path, 1, 200);
89
+ if (!allowed.has(text)) failPacket(path, "has an unknown enum value");
90
+ return text;
91
+ }
92
+
93
+ function isLeapYear(year: number): boolean {
94
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
95
+ }
96
+
97
+ function daysInMonth(year: number, month: number): number {
98
+ const lengths = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
99
+ return lengths[month - 1] ?? 0;
100
+ }
101
+
102
+ function daysSinceUnixEpoch(year: number, month: number, day: number): number {
103
+ const adjustedYear = year - (month <= 2 ? 1 : 0);
104
+ const era = Math.floor(adjustedYear / 400);
105
+ const yearOfEra = adjustedYear - era * 400;
106
+ const adjustedMonth = month + (month > 2 ? -3 : 9);
107
+ const dayOfYear = Math.floor((153 * adjustedMonth + 2) / 5) + day - 1;
108
+ const dayOfEra =
109
+ yearOfEra * 365 +
110
+ Math.floor(yearOfEra / 4) -
111
+ Math.floor(yearOfEra / 100) +
112
+ dayOfYear;
113
+ return era * 146_097 + dayOfEra - 719_468;
114
+ }
115
+
116
+ function parseDateTime(value: JsonValue, path: string): DateTime {
117
+ const text = expectString(value, path, 1, 100);
118
+ const match = DATE_TIME.exec(text);
119
+ if (match === null) failPacket(path, "must be an RFC 3339 date-time");
120
+
121
+ const year = Number(match[1]);
122
+ const month = Number(match[2]);
123
+ const day = Number(match[3]);
124
+ const hour = Number(match[4]);
125
+ const minute = Number(match[5]);
126
+ const second = Number(match[6]);
127
+ const fraction = match[7] ?? "";
128
+ const zone = match[8]!;
129
+ if (
130
+ year < 1 ||
131
+ month < 1 ||
132
+ month > 12 ||
133
+ day < 1 ||
134
+ day > daysInMonth(year, month) ||
135
+ hour > 23 ||
136
+ minute > 59 ||
137
+ second > 59
138
+ ) {
139
+ failPacket(path, "must be a valid date-time");
140
+ }
141
+
142
+ let offsetSeconds = 0;
143
+ if (zone !== "Z") {
144
+ const offsetHour = Number(zone.slice(1, 3));
145
+ const offsetMinute = Number(zone.slice(4, 6));
146
+ if (offsetHour > 23 || offsetMinute > 59) failPacket(path, "must be a valid date-time");
147
+ const sign = zone[0] === "-" ? -1 : 1;
148
+ offsetSeconds = sign * (offsetHour * 60 + offsetMinute) * 60;
149
+ }
150
+
151
+ const localSeconds =
152
+ BigInt(daysSinceUnixEpoch(year, month, day)) * 86_400n +
153
+ BigInt(hour * 3_600 + minute * 60 + second);
154
+ const microseconds = BigInt((fraction.slice(0, 6) + "000000").slice(0, 6));
155
+ return (localSeconds - BigInt(offsetSeconds)) * 1_000_000n + microseconds;
156
+ }
157
+
158
+ function expectDigest(value: JsonValue, path: string, prefixed: boolean): string {
159
+ const length = prefixed ? 71 : 64;
160
+ const text = expectString(value, path, length, length);
161
+ if (!(prefixed ? PREFIXED_SHA256 : SHA256).test(text)) {
162
+ failPacket(path, "must be a lowercase SHA-256 digest");
163
+ }
164
+ return text;
165
+ }
166
+
167
+ function validateSubject(value: JsonValue): JsonObject {
168
+ const subject = expectObject(value, "subject");
169
+ exactKeys(
170
+ subject,
171
+ "subject",
172
+ new Set(["localId", "kind", "identityBasis"]),
173
+ new Set(["displayName"]),
174
+ );
175
+ expectString(subject.localId!, "subject.localId", 1, 200);
176
+ expectEnum(subject.kind!, "subject.kind", new Set(["owner", "contact", "person"]));
177
+ expectString(subject.identityBasis!, "subject.identityBasis", 1, 1_000);
178
+ if ("displayName" in subject) expectString(subject.displayName!, "subject.displayName", 1, 300);
179
+ return subject;
180
+ }
181
+
182
+ function effectiveBounds(limits: JsonObject): readonly [DateTime | null, DateTime | null] {
183
+ const directAfter = typeof limits.after === "string" ? limits.after : null;
184
+ const aliasAfter = typeof limits.afterInclusive === "string" ? limits.afterInclusive : null;
185
+ const directBefore = typeof limits.before === "string" ? limits.before : null;
186
+ const aliasBefore = typeof limits.beforeExclusive === "string" ? limits.beforeExclusive : null;
187
+ if (directAfter !== null && aliasAfter !== null) {
188
+ failPacket("scope.limits", "declares two lower-bound aliases");
189
+ }
190
+ if (directBefore !== null && aliasBefore !== null) {
191
+ failPacket("scope.limits", "declares two upper-bound aliases");
192
+ }
193
+ const lowerRaw = directAfter ?? aliasAfter;
194
+ const upperRaw = directBefore ?? aliasBefore;
195
+ const lower = lowerRaw === null ? null : parseDateTime(lowerRaw, "scope.limits lower bound");
196
+ const upper = upperRaw === null ? null : parseDateTime(upperRaw, "scope.limits upper bound");
197
+ if (lower !== null && upper !== null && lower >= upper) {
198
+ failPacket("scope.limits", "lower bound must be earlier than upper bound");
199
+ }
200
+ return [lower, upper];
201
+ }
202
+
203
+ function validateLimits(value: JsonValue): JsonObject {
204
+ const limits = expectObject(value, "scope.limits");
205
+ if (Object.keys(limits).length > 32) failPacket("scope.limits", "has too many members");
206
+ for (const [key, member] of Object.entries(limits)) {
207
+ expectString(key, "scope.limits key", 1, 200);
208
+ if (Array.isArray(member)) {
209
+ if (member.length > 32) failPacket(`scope.limits.${key}`, "has too many array items");
210
+ const values = member.map((item) =>
211
+ expectString(item, `scope.limits.${key}[]`, 1, 200),
212
+ );
213
+ if (new Set(values).size !== values.length) {
214
+ failPacket(`scope.limits.${key}`, "contains duplicate array items");
215
+ }
216
+ } else if (
217
+ member !== null &&
218
+ typeof member !== "string" &&
219
+ typeof member !== "number" &&
220
+ typeof member !== "boolean"
221
+ ) {
222
+ failPacket(`scope.limits.${key}`, "has a disallowed value type");
223
+ }
224
+ rejectNonIJson(member, `scope.limits.${key}`);
225
+ }
226
+ for (const name of ["after", "afterInclusive", "before", "beforeExclusive"] as const) {
227
+ const member = limits[name];
228
+ if (typeof member === "string") parseDateTime(member, `scope.limits.${name}`);
229
+ }
230
+ effectiveBounds(limits);
231
+ return limits;
232
+ }
233
+
234
+ function validateScope(
235
+ value: JsonValue,
236
+ ): readonly [JsonObject, JsonObject, DateTime | null, DateTime | null] {
237
+ const scope = expectObject(value, "scope");
238
+ exactKeys(
239
+ scope,
240
+ "scope",
241
+ new Set(["adapter", "payloadSchema", "completeness", "limits"]),
242
+ new Set(["asOf", "sourceCutoff", "sourceRevision"]),
243
+ );
244
+ expectString(scope.adapter!, "scope.adapter", 1, 100);
245
+ expectString(scope.payloadSchema!, "scope.payloadSchema", 1, 160);
246
+ expectEnum(
247
+ scope.completeness!,
248
+ "scope.completeness",
249
+ new Set(["complete", "sampled", "bounded", "unknown"]),
250
+ );
251
+ const asOf = "asOf" in scope ? parseDateTime(scope.asOf!, "scope.asOf") : null;
252
+ const sourceCutoff =
253
+ "sourceCutoff" in scope
254
+ ? parseDateTime(scope.sourceCutoff!, "scope.sourceCutoff")
255
+ : null;
256
+ if ("sourceRevision" in scope) {
257
+ expectString(scope.sourceRevision!, "scope.sourceRevision", 1, 300);
258
+ }
259
+ return [scope, validateLimits(scope.limits!), asOf, sourceCutoff];
260
+ }
261
+
262
+ function containsUnicodeWhitespace(value: string): boolean {
263
+ for (const character of value) {
264
+ const code = character.codePointAt(0)!;
265
+ if (
266
+ (code >= 0x09 && code <= 0x0d) ||
267
+ (code >= 0x1c && code <= 0x20) ||
268
+ code === 0x85 ||
269
+ code === 0xa0 ||
270
+ code === 0x1680 ||
271
+ (code >= 0x2000 && code <= 0x200a) ||
272
+ code === 0x2028 ||
273
+ code === 0x2029 ||
274
+ code === 0x202f ||
275
+ code === 0x205f ||
276
+ code === 0x3000
277
+ ) {
278
+ return true;
279
+ }
280
+ }
281
+ return false;
282
+ }
283
+
284
+ function validateContent(value: JsonValue, path: string): JsonObject {
285
+ const content = expectObject(value, path);
286
+ exactKeys(content, path, new Set(), new Set(["text", "title", "url", "truncated"]));
287
+ if (!("text" in content) && !("title" in content) && !("url" in content)) {
288
+ failPacket(path, "must include text, title, or url");
289
+ }
290
+ if ("text" in content) expectString(content.text!, `${path}.text`, 0, 50_000);
291
+ if ("title" in content) expectString(content.title!, `${path}.title`, 0, 1_000);
292
+ if ("url" in content) {
293
+ const url = expectString(content.url!, `${path}.url`, 1, 4_096);
294
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(url) || containsUnicodeWhitespace(url)) {
295
+ failPacket(`${path}.url`, "must be an absolute URI");
296
+ }
297
+ }
298
+ if ("truncated" in content) expectBoolean(content.truncated!, `${path}.truncated`);
299
+ return content;
300
+ }
301
+
302
+ function validateProvenance(value: JsonValue, path: string, content: JsonObject): void {
303
+ const provenance = expectObject(value, path);
304
+ exactKeys(
305
+ provenance,
306
+ path,
307
+ new Set(["provider", "contentSha256"]),
308
+ new Set(["operation", "sourceId", "runId", "policyVersion", "model"]),
309
+ );
310
+ expectString(provenance.provider!, `${path}.provider`, 1, 100);
311
+ for (const key of ["operation", "policyVersion", "model"] as const) {
312
+ if (key in provenance) expectString(provenance[key]!, `${path}.${key}`, 1, 160);
313
+ }
314
+ for (const key of ["sourceId", "runId"] as const) {
315
+ if (key in provenance) expectString(provenance[key]!, `${path}.${key}`, 1, 300);
316
+ }
317
+ const digest = expectDigest(provenance.contentSha256!, `${path}.contentSha256`, false);
318
+ let expected: string;
319
+ try {
320
+ expected = sha256Hex(canonicalBytes(content));
321
+ } catch (error) {
322
+ if (error instanceof ArchiveError) failPacket(path, error.message);
323
+ throw error;
324
+ }
325
+ if (digest !== expected) failPacket(path, "content digest mismatch");
326
+ }
327
+
328
+ type ValidatedRecord = Readonly<{
329
+ record: JsonObject;
330
+ occurred: DateTime | null;
331
+ observed: DateTime | null;
332
+ }>;
333
+
334
+ function validateRecord(value: JsonValue, index: number): ValidatedRecord {
335
+ const path = `records[${index}]`;
336
+ const record = expectObject(value, path);
337
+ exactKeys(
338
+ record,
339
+ path,
340
+ new Set([
341
+ "id",
342
+ "digest",
343
+ "kind",
344
+ "authorRole",
345
+ "contentRole",
346
+ "authorshipConfidence",
347
+ "sentStatus",
348
+ "visibility",
349
+ "sourceClass",
350
+ "content",
351
+ "provenance",
352
+ ]),
353
+ new Set(["occurredAt", "observedAt"]),
354
+ );
355
+ if (!("occurredAt" in record) && !("observedAt" in record)) {
356
+ failPacket(path, "must include occurredAt or observedAt");
357
+ }
358
+ expectString(record.id!, `${path}.id`, 1, 200);
359
+ const digest = expectDigest(record.digest!, `${path}.digest`, true);
360
+ expectString(record.kind!, `${path}.kind`, 1, 100);
361
+ expectEnum(
362
+ record.authorRole!,
363
+ `${path}.authorRole`,
364
+ new Set(["subject", "counterpart", "third_party", "mixed", "unknown"]),
365
+ );
366
+ expectEnum(
367
+ record.contentRole!,
368
+ `${path}.contentRole`,
369
+ new Set(["original", "quoted", "forwarded", "summary", "ai_assisted", "mixed", "unknown"]),
370
+ );
371
+ expectEnum(
372
+ record.authorshipConfidence!,
373
+ `${path}.authorshipConfidence`,
374
+ new Set(["verified", "strong", "weak", "unknown"]),
375
+ );
376
+ expectEnum(
377
+ record.sentStatus!,
378
+ `${path}.sentStatus`,
379
+ new Set(["sent", "draft", "received", "published", "unknown"]),
380
+ );
381
+ expectEnum(record.visibility!, `${path}.visibility`, new Set(["public", "private"]));
382
+ expectEnum(
383
+ record.sourceClass!,
384
+ `${path}.sourceClass`,
385
+ new Set([
386
+ "private_capture",
387
+ "polished_self_presentation",
388
+ "observed_behavior",
389
+ "public_web_evidence",
390
+ "third_party_description",
391
+ "institutional",
392
+ "metadata",
393
+ ]),
394
+ );
395
+ const occurred =
396
+ "occurredAt" in record ? parseDateTime(record.occurredAt!, `${path}.occurredAt`) : null;
397
+ const observed =
398
+ "observedAt" in record ? parseDateTime(record.observedAt!, `${path}.observedAt`) : null;
399
+ const content = validateContent(record.content!, `${path}.content`);
400
+ validateProvenance(record.provenance!, `${path}.provenance`, content);
401
+ const withoutDigest: JsonObject = { ...record };
402
+ delete withoutDigest.digest;
403
+ let expected: string;
404
+ try {
405
+ expected = `sha256:${sha256Hex(canonicalBytes(withoutDigest))}`;
406
+ } catch (error) {
407
+ if (error instanceof ArchiveError) failPacket(path, error.message);
408
+ throw error;
409
+ }
410
+ if (digest !== expected) failPacket(path, "record digest mismatch");
411
+ return { record, occurred, observed };
412
+ }
413
+
414
+ function validateClaims(
415
+ value: JsonValue,
416
+ subjectLocalId: string,
417
+ recordIds: ReadonlySet<string>,
418
+ ): number {
419
+ const claims = expectArray(value, "claims");
420
+ if (claims.length > 500) failPacket("claims", "has too many items");
421
+ const claimIds = new Set<string>();
422
+ const required = new Set([
423
+ "id",
424
+ "text",
425
+ "recordIds",
426
+ "status",
427
+ "claimantRole",
428
+ "claimKind",
429
+ "subjectLocalId",
430
+ "sensitivity",
431
+ ]);
432
+ claims.forEach((raw, index) => {
433
+ const path = `claims[${index}]`;
434
+ const claim = expectObject(raw, path);
435
+ exactKeys(claim, path, required);
436
+ const claimId = expectString(claim.id!, `${path}.id`, 1, 200);
437
+ if (claimIds.has(claimId)) failPacket(path, "duplicates another claim id");
438
+ claimIds.add(claimId);
439
+ expectString(claim.text!, `${path}.text`, 1, 4_000);
440
+ const refs = expectArray(claim.recordIds!, `${path}.recordIds`);
441
+ if (refs.length < 1 || refs.length > 50) {
442
+ failPacket(`${path}.recordIds`, "has an invalid item count");
443
+ }
444
+ const normalizedRefs = refs.map((ref, refIndex) =>
445
+ expectString(ref, `${path}.recordIds[${refIndex}]`, 1, 200),
446
+ );
447
+ if (new Set(normalizedRefs).size !== normalizedRefs.length) {
448
+ failPacket(`${path}.recordIds`, "contains duplicates");
449
+ }
450
+ if (normalizedRefs.some((ref) => !recordIds.has(ref))) {
451
+ failPacket(`${path}.recordIds`, "references an unknown record");
452
+ }
453
+ expectEnum(
454
+ claim.status!,
455
+ `${path}.status`,
456
+ new Set(["source_reported", "adapter_structured", "contested"]),
457
+ );
458
+ expectEnum(
459
+ claim.claimantRole!,
460
+ `${path}.claimantRole`,
461
+ new Set(["subject", "counterpart", "third_party", "institutional", "adapter", "unknown"]),
462
+ );
463
+ expectEnum(
464
+ claim.claimKind!,
465
+ `${path}.claimKind`,
466
+ new Set(["fact", "stated_belief", "reported_observation", "derived_index"]),
467
+ );
468
+ if (expectString(claim.subjectLocalId!, `${path}.subjectLocalId`, 1, 200) !== subjectLocalId) {
469
+ failPacket(`${path}.subjectLocalId`, "does not match packet subject");
470
+ }
471
+ expectEnum(
472
+ claim.sensitivity!,
473
+ `${path}.sensitivity`,
474
+ new Set(["ordinary", "sensitive_explicit"]),
475
+ );
476
+ });
477
+ return claims.length;
478
+ }
479
+
480
+ export function validateSourcePacket(value: unknown): ValidationReceipt {
481
+ rejectNonIJson(value);
482
+ const packet = expectObject(value, "packet");
483
+ exactKeys(
484
+ packet,
485
+ "packet",
486
+ new Set([
487
+ "schemaVersion",
488
+ "digestCanonicalization",
489
+ "packetId",
490
+ "generatedAt",
491
+ "subject",
492
+ "scope",
493
+ "records",
494
+ "limitations",
495
+ "packetDigest",
496
+ ]),
497
+ new Set(["claims"]),
498
+ );
499
+ if (packet.schemaVersion !== "ensoul.source-packet.v1") {
500
+ failPacket("schemaVersion", "unsupported schema");
501
+ }
502
+ if (packet.digestCanonicalization !== "JCS-RFC8785") {
503
+ failPacket("digestCanonicalization", "unsupported digest canonicalization");
504
+ }
505
+ expectString(packet.packetId!, "packetId", 8, 160);
506
+ const generatedAt = parseDateTime(packet.generatedAt!, "generatedAt");
507
+ const subject = validateSubject(packet.subject!);
508
+ const [scope, limits, asOf, sourceCutoff] = validateScope(packet.scope!);
509
+ if (asOf !== null && asOf > generatedAt) {
510
+ failPacket("scope.asOf", "must not be later than generatedAt");
511
+ }
512
+ if (sourceCutoff !== null && sourceCutoff > generatedAt) {
513
+ failPacket("scope.sourceCutoff", "must not be later than generatedAt");
514
+ }
515
+ if (sourceCutoff !== null && asOf !== null && sourceCutoff > asOf) {
516
+ failPacket("scope.sourceCutoff", "must not be later than scope.asOf");
517
+ }
518
+
519
+ const records = expectArray(packet.records!, "records");
520
+ if (records.length > 2_000) failPacket("records", "has too many items");
521
+ const recordIds = new Set<string>();
522
+ const timeValues: Array<readonly [DateTime | null, DateTime | null]> = [];
523
+ const validatedRecords: JsonObject[] = [];
524
+ records.forEach((raw, index) => {
525
+ const { record, occurred, observed } = validateRecord(raw, index);
526
+ const recordId = String(record.id);
527
+ if (recordIds.has(recordId)) failPacket(`records[${index}].id`, "duplicates another record id");
528
+ recordIds.add(recordId);
529
+ timeValues.push([occurred, observed]);
530
+ validatedRecords.push(record);
531
+ });
532
+
533
+ const [lower, upper] = effectiveBounds(limits);
534
+ timeValues.forEach(([occurred, observed], index) => {
535
+ if (occurred !== null && observed !== null && occurred > observed) {
536
+ failPacket(`records[${index}]`, "occurredAt must not be later than observedAt");
537
+ }
538
+ const values: Array<readonly ["occurredAt" | "observedAt", DateTime | null]> = [
539
+ ["occurredAt", occurred],
540
+ ["observedAt", observed],
541
+ ];
542
+ for (const [label, time] of values) {
543
+ if (time === null) continue;
544
+ if (time > generatedAt) {
545
+ failPacket(`records[${index}].${label}`, "must not be later than generatedAt");
546
+ }
547
+ if (asOf !== null && time > asOf) {
548
+ failPacket(`records[${index}].${label}`, "must not be later than scope.asOf");
549
+ }
550
+ if (sourceCutoff !== null && time > sourceCutoff) {
551
+ failPacket(`records[${index}].${label}`, "must not be later than scope.sourceCutoff");
552
+ }
553
+ }
554
+ const effectiveTime = occurred ?? observed;
555
+ if (effectiveTime !== null && lower !== null && effectiveTime < lower) {
556
+ failPacket(`records[${index}]`, "evidence time is before the declared lower bound");
557
+ }
558
+ if (effectiveTime !== null && upper !== null && effectiveTime >= upper) {
559
+ failPacket(`records[${index}]`, "evidence time is at or after the declared upper bound");
560
+ }
561
+ });
562
+
563
+ const limitations = expectArray(packet.limitations!, "limitations");
564
+ if (limitations.length < 1 || limitations.length > 32) {
565
+ failPacket("limitations", "has an invalid item count");
566
+ }
567
+ limitations.forEach((limitation, index) =>
568
+ expectString(limitation, `limitations[${index}]`, 1, 1_000),
569
+ );
570
+ const subjectLocalId = String(subject.localId);
571
+ const claimCount = validateClaims(packet.claims ?? [], subjectLocalId, recordIds);
572
+ const digest = expectDigest(packet.packetDigest!, "packetDigest", true);
573
+ const withoutDigest: JsonObject = { ...packet };
574
+ delete withoutDigest.packetDigest;
575
+ let expected: string;
576
+ try {
577
+ expected = `sha256:${sha256Hex(canonicalBytes(withoutDigest))}`;
578
+ } catch (error) {
579
+ if (error instanceof ArchiveError) failPacket("packet", error.message);
580
+ throw error;
581
+ }
582
+ if (digest !== expected) failPacket("packetDigest", "packet digest mismatch");
583
+
584
+ const visibility = { private: 0, public: 0 };
585
+ for (const record of validatedRecords) {
586
+ const label = String(record.visibility);
587
+ if (label === "private") visibility.private += 1;
588
+ else visibility.public += 1;
589
+ }
590
+ return {
591
+ valid: true,
592
+ schemaVersion: packet.schemaVersion,
593
+ packetDigest: digest,
594
+ adapter: scope.adapter!,
595
+ payloadSchema: scope.payloadSchema!,
596
+ records: records.length,
597
+ claims: claimCount,
598
+ visibility,
599
+ };
600
+ }
601
+
602
+ export function validateSourcePacketFile(path: string): ValidationReceipt {
603
+ if (!isAbsolute(path)) failPacket("input", "path must be absolute");
604
+ if (!existsSync(path)) failPacket("input", "must be a regular non-symlink file");
605
+ const status = lstatSync(path);
606
+ if (status.isSymbolicLink() || !status.isFile()) {
607
+ failPacket("input", "must be a regular non-symlink file");
608
+ }
609
+ if (status.size > MAX_PACKET_BYTES) failPacket("input", "packet exceeds the size limit");
610
+ return validateSourcePacket(strictJsonParse(readFileSync(path)));
611
+ }
612
+
613
+ function printUsage(): void {
614
+ console.error("usage: validate-source-packet.ts ABSOLUTE_PACKET_PATH");
615
+ }
616
+
617
+ export function main(argv: string[] = Bun.argv.slice(2)): number {
618
+ if (argv.length === 1 && (argv[0] === "-h" || argv[0] === "--help")) {
619
+ console.log("Validate an Ensoul source packet without printing evidence content.");
620
+ console.log("usage: validate-source-packet.ts ABSOLUTE_PACKET_PATH");
621
+ return 0;
622
+ }
623
+ if (argv.length !== 1) {
624
+ printUsage();
625
+ return 2;
626
+ }
627
+ try {
628
+ const receipt = validateSourcePacketFile(argv[0]!);
629
+ console.log(canonicalText(receipt));
630
+ return 0;
631
+ } catch (error) {
632
+ const message = error instanceof Error ? error.message : String(error);
633
+ console.error(`invalid Ensoul source packet: ${message}`);
634
+ return 2;
635
+ }
636
+ }
637
+
638
+ if (import.meta.main) process.exitCode = main();