@devicerail/recorder 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.
@@ -0,0 +1,174 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { TextDecoder } from "node:util";
3
+ export const DEFAULT_CANONICAL_JSON_MAX_BYTES = 1024 * 1024 + 4 * 1024;
4
+ const DEFAULT_MAX_DEPTH = 128;
5
+ const DEFAULT_MAX_NODES = 1_000_000;
6
+ const SHA256_HEX = /^[0-9a-f]{64}$/u;
7
+ export class CanonicalJsonError extends Error {
8
+ constructor(message, options) {
9
+ super(message, options);
10
+ this.name = new.target.name;
11
+ }
12
+ }
13
+ function positiveSafeInteger(value, fallback, name) {
14
+ const selected = value ?? fallback;
15
+ if (!Number.isSafeInteger(selected) || selected <= 0) {
16
+ throw new CanonicalJsonError(`${name} must be a positive safe integer`);
17
+ }
18
+ return selected;
19
+ }
20
+ function encode(value, depth, state) {
21
+ if (depth > state.maxDepth) {
22
+ throw new CanonicalJsonError("canonical JSON exceeds its nesting-depth limit");
23
+ }
24
+ state.nodes += 1;
25
+ if (state.nodes > state.maxNodes) {
26
+ throw new CanonicalJsonError("canonical JSON exceeds its node-count limit");
27
+ }
28
+ if (value === null) {
29
+ return "null";
30
+ }
31
+ switch (typeof value) {
32
+ case "boolean":
33
+ return value ? "true" : "false";
34
+ case "string":
35
+ return JSON.stringify(value);
36
+ case "number":
37
+ if (!Number.isFinite(value)) {
38
+ throw new CanonicalJsonError("canonical JSON contains a non-finite number");
39
+ }
40
+ if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
41
+ throw new CanonicalJsonError("canonical JSON contains an unsafe integer");
42
+ }
43
+ // JSON.stringify normalizes -0 to 0, which would change an already
44
+ // confirmed arbitrary JSON value across checkpoint recovery.
45
+ return Object.is(value, -0) ? "-0" : JSON.stringify(value);
46
+ case "object":
47
+ break;
48
+ default:
49
+ throw new CanonicalJsonError(`canonical JSON cannot encode ${typeof value}`);
50
+ }
51
+ if (state.ancestors.has(value)) {
52
+ throw new CanonicalJsonError("canonical JSON cannot encode a cyclic value");
53
+ }
54
+ state.ancestors.add(value);
55
+ try {
56
+ if (Array.isArray(value)) {
57
+ const items = [];
58
+ for (let index = 0; index < value.length; index += 1) {
59
+ if (!Object.hasOwn(value, index)) {
60
+ throw new CanonicalJsonError("canonical JSON cannot encode a sparse array");
61
+ }
62
+ items.push(encode(value[index], depth + 1, state));
63
+ }
64
+ return `[${items.join(",")}]`;
65
+ }
66
+ const prototype = Object.getPrototypeOf(value);
67
+ if (prototype !== Object.prototype && prototype !== null) {
68
+ throw new CanonicalJsonError("canonical JSON objects must have a plain prototype");
69
+ }
70
+ const keys = Reflect.ownKeys(value);
71
+ if (keys.some((key) => typeof key !== "string")) {
72
+ throw new CanonicalJsonError("canonical JSON objects cannot contain symbol keys");
73
+ }
74
+ const stringKeys = keys;
75
+ for (const key of stringKeys) {
76
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
77
+ if (!descriptor?.enumerable || !("value" in descriptor)) {
78
+ throw new CanonicalJsonError("canonical JSON objects must contain enumerable data properties only");
79
+ }
80
+ }
81
+ stringKeys.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
82
+ const entries = stringKeys.map((key) => {
83
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
84
+ if (!descriptor || !("value" in descriptor)) {
85
+ throw new CanonicalJsonError("canonical JSON object changed while it was encoded");
86
+ }
87
+ return `${JSON.stringify(key)}:${encode(descriptor.value, depth + 1, state)}`;
88
+ });
89
+ return `{${entries.join(",")}}`;
90
+ }
91
+ finally {
92
+ state.ancestors.delete(value);
93
+ }
94
+ }
95
+ /** Encode one value as compact recursively key-sorted UTF-8 JSON plus one LF. */
96
+ export function toCanonicalJson(value, options = {}) {
97
+ const maxBytes = positiveSafeInteger(options.maxBytes, DEFAULT_CANONICAL_JSON_MAX_BYTES, "maxBytes");
98
+ const state = {
99
+ ancestors: new Set(),
100
+ maxDepth: positiveSafeInteger(options.maxDepth, DEFAULT_MAX_DEPTH, "maxDepth"),
101
+ maxNodes: positiveSafeInteger(options.maxNodes, DEFAULT_MAX_NODES, "maxNodes"),
102
+ nodes: 0,
103
+ };
104
+ const bytes = Buffer.from(`${encode(value, 0, state)}\n`, "utf8");
105
+ if (bytes.length > maxBytes) {
106
+ throw new CanonicalJsonError(`canonical JSON exceeds its ${maxBytes}-byte limit`);
107
+ }
108
+ return bytes;
109
+ }
110
+ /**
111
+ * Encode `{ checkpoint, sha256 }` while visiting the checkpoint value once.
112
+ * The checksum covers the checkpoint's standalone canonical bytes, including
113
+ * its LF, while depth/node limits still apply to the complete envelope.
114
+ */
115
+ export function toCanonicalJsonChecksumEnvelope(checkpoint, options = {}) {
116
+ const maxBytes = positiveSafeInteger(options.maxBytes, DEFAULT_CANONICAL_JSON_MAX_BYTES, "maxBytes");
117
+ const state = {
118
+ ancestors: new Set(),
119
+ maxDepth: positiveSafeInteger(options.maxDepth, DEFAULT_MAX_DEPTH, "maxDepth"),
120
+ maxNodes: positiveSafeInteger(options.maxNodes, DEFAULT_MAX_NODES, "maxNodes"),
121
+ nodes: 1,
122
+ };
123
+ const checkpointJson = encode(checkpoint, 1, state);
124
+ const payloadBytes = Buffer.from(`${checkpointJson}\n`, "utf8");
125
+ const checksumJson = encode(sha256Hex(payloadBytes), 1, state);
126
+ const bytes = Buffer.from(`{"checkpoint":${checkpointJson},"sha256":${checksumJson}}\n`, "utf8");
127
+ if (bytes.length > maxBytes) {
128
+ throw new CanonicalJsonError(`canonical JSON exceeds its ${maxBytes}-byte limit`);
129
+ }
130
+ return bytes;
131
+ }
132
+ /** Parse JSON only when its bytes are already in the canonical representation. */
133
+ export function fromCanonicalJson(bytes, options = {}) {
134
+ const maxBytes = positiveSafeInteger(options.maxBytes, DEFAULT_CANONICAL_JSON_MAX_BYTES, "maxBytes");
135
+ if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) {
136
+ throw new CanonicalJsonError(`canonical JSON must contain 1-${maxBytes} bytes`);
137
+ }
138
+ let text;
139
+ try {
140
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
141
+ }
142
+ catch (cause) {
143
+ throw new CanonicalJsonError("canonical JSON is not valid UTF-8", { cause });
144
+ }
145
+ if (!text.endsWith("\n") || text.endsWith("\n\n")) {
146
+ throw new CanonicalJsonError("canonical JSON must end in exactly one LF");
147
+ }
148
+ let value;
149
+ try {
150
+ value = JSON.parse(text.slice(0, -1));
151
+ }
152
+ catch (cause) {
153
+ throw new CanonicalJsonError("canonical JSON is malformed or truncated", { cause });
154
+ }
155
+ const canonical = toCanonicalJson(value, options);
156
+ if (!canonical.equals(Buffer.from(bytes))) {
157
+ throw new CanonicalJsonError("JSON bytes are not canonical");
158
+ }
159
+ return value;
160
+ }
161
+ export function sha256Hex(bytes) {
162
+ return createHash("sha256").update(bytes).digest("hex");
163
+ }
164
+ export function canonicalSha256(value, options = {}) {
165
+ return sha256Hex(toCanonicalJson(value, options));
166
+ }
167
+ export function sha256Matches(actualBytes, expectedHex) {
168
+ if (!SHA256_HEX.test(expectedHex)) {
169
+ return false;
170
+ }
171
+ const actual = createHash("sha256").update(actualBytes).digest();
172
+ const expected = Buffer.from(expectedHex, "hex");
173
+ return timingSafeEqual(actual, expected);
174
+ }
@@ -0,0 +1,28 @@
1
+ import type { TestEvent } from "@devicerail/protocol";
2
+ import { type RecorderCheckpoint, type RecordingCheckpoint } from "./types.js";
3
+ export type { RecorderCheckpoint } from "./types.js";
4
+ /** Fixed metadata/checksum room above the complete 8 MiB BundleSource. */
5
+ export declare const RECORDER_CHECKPOINT_HEADROOM_BYTES: number;
6
+ export declare const RECORDER_CHECKPOINT_MAX_BYTES: number;
7
+ export interface RecorderJournalAppendResult {
8
+ readonly revision: number;
9
+ readonly eventCount: number;
10
+ }
11
+ export interface RecorderCheckpointFileOptions {
12
+ readonly maxBytes?: number;
13
+ readonly signal?: AbortSignal;
14
+ }
15
+ /** Strictly validate and detach one in-memory checkpoint. */
16
+ export declare function validateRecorderCheckpoint(value: unknown): RecorderCheckpoint;
17
+ /** Load a canonical checkpoint; Unix additionally enforces owner-only file metadata. */
18
+ export declare function loadRecorderCheckpoint(path: string, options?: RecorderCheckpointFileOptions): Promise<RecorderCheckpoint | null>;
19
+ /** @internal Append one already-validated Recorder page without rewriting its prefix. */
20
+ export declare function appendRecorderCheckpointPage(path: string, expectedRevision: number, identity: Pick<RecordingCheckpoint, "sessionId" | "eventProtocolVersion">, events: readonly TestEvent[], options?: RecorderCheckpointFileOptions): Promise<RecorderJournalAppendResult>;
21
+ /**
22
+ * CAS-commit one checkpoint revision through a same-directory atomic replace.
23
+ *
24
+ * `expectedRevision === 0` means the target must not exist and `next.revision`
25
+ * must be 1. The rename is the publication linearization point; cancellation
26
+ * is no longer observed after it.
27
+ */
28
+ export declare function commitRecorderCheckpoint(path: string, expectedRevision: number, next: RecorderCheckpoint, options?: RecorderCheckpointFileOptions): Promise<RecorderCheckpoint>;