tledger 0.3.0 → 0.4.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.
@@ -1,14 +1,21 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
2
3
  import {
3
- chmod,
4
+ lstat,
4
5
  mkdir,
5
- readFile,
6
+ open,
7
+ readdir,
6
8
  rename,
7
9
  rm,
8
- stat,
9
- writeFile,
10
10
  } from "node:fs/promises";
11
- import { dirname, resolve } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import {
13
+ basename,
14
+ dirname,
15
+ isAbsolute,
16
+ relative,
17
+ resolve,
18
+ } from "node:path";
12
19
  import { promisify } from "node:util";
13
20
  import {
14
21
  gunzip as gunzipCallback,
@@ -23,12 +30,34 @@ import {
23
30
  usageBuckets,
24
31
  } from "./token-ledger-usage.mjs";
25
32
 
33
+ const DURABLE_LEDGER_BASENAME = "token-ledger-ledger.sqlite";
34
+ const DURABLE_LEDGER_PATH_SUFFIXES = Object.freeze([
35
+ DURABLE_LEDGER_BASENAME,
36
+ `${DURABLE_LEDGER_BASENAME}.writer-lock.sqlite`,
37
+ `${DURABLE_LEDGER_BASENAME}-journal`,
38
+ `${DURABLE_LEDGER_BASENAME}-wal`,
39
+ `${DURABLE_LEDGER_BASENAME}-shm`,
40
+ ]);
41
+ const PRIVATE_STATE_DIRECTORY = resolve(homedir(), ".token-ledger");
42
+ const TEST_PRIVATE_STATE_DIRECTORY =
43
+ process.env.NODE_TEST_CONTEXT && process.env.TOKEN_LEDGER_TEST_STATE_ROOT
44
+ ? resolve(process.env.TOKEN_LEDGER_TEST_STATE_ROOT)
45
+ : null;
46
+ const PRIVATE_STATE_DIRECTORIES = Object.freeze([
47
+ PRIVATE_STATE_DIRECTORY,
48
+ ...(TEST_PRIVATE_STATE_DIRECTORY ? [TEST_PRIVATE_STATE_DIRECTORY] : []),
49
+ ]);
50
+
26
51
  export const DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024;
27
52
  export const DEFAULT_SNAPSHOT_TARGET_BYTES = 12 * 1024 * 1024;
28
53
  export const DEFAULT_SNAPSHOT_MAX_JSON_BYTES = 64 * 1024 * 1024;
29
54
  export const DEFAULT_SNAPSHOT_TARGET_JSON_BYTES = 48 * 1024 * 1024;
30
55
 
31
56
  const PRECOMPACT_BUCKET_COUNT = 50_000;
57
+ const SNAPSHOT_READ_CHUNK_BYTES = 64 * 1024;
58
+ const SNAPSHOT_TEMP_HASH_LENGTH = 16;
59
+ const SNAPSHOT_TEMP_UUID_PATTERN =
60
+ "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
32
61
 
33
62
  const gzip = promisify(gzipCallback);
34
63
  const gunzip = promisify(gunzipCallback);
@@ -37,6 +66,123 @@ function snapshotEncoding(path) {
37
66
  return path.toLowerCase().endsWith(".gz") ? "gzip" : "json";
38
67
  }
39
68
 
69
+ function snapshotDestinationHash(destination) {
70
+ return createHash("sha256")
71
+ .update(destination)
72
+ .digest("hex")
73
+ .slice(0, SNAPSHOT_TEMP_HASH_LENGTH);
74
+ }
75
+
76
+ function snapshotTemporaryName(destination) {
77
+ return `.token-ledger-${snapshotDestinationHash(destination)}-${process.pid}-${randomUUID()}.tmp`;
78
+ }
79
+
80
+ function snapshotDestinationError(destination) {
81
+ const error = new Error(
82
+ `Snapshot destination is reserved for durable ledger state: ${destination}`,
83
+ );
84
+ error.code = "ERR_SNAPSHOT_RESERVED_PATH";
85
+ return error;
86
+ }
87
+
88
+ function pathIsInside(directory, destination) {
89
+ const child = relative(directory, destination);
90
+ return child === "" || (!child.startsWith("..") && !isAbsolute(child));
91
+ }
92
+
93
+ function assertSnapshotDestination(destination, reservedPaths) {
94
+ const explicitlyReserved = new Set(
95
+ reservedPaths.map((path) => resolve(path)),
96
+ );
97
+ if (
98
+ explicitlyReserved.has(destination) ||
99
+ (
100
+ PRIVATE_STATE_DIRECTORIES.some((directory) =>
101
+ pathIsInside(directory, destination),
102
+ ) &&
103
+ DURABLE_LEDGER_PATH_SUFFIXES.includes(basename(destination))
104
+ )
105
+ ) {
106
+ throw snapshotDestinationError(destination);
107
+ }
108
+ }
109
+
110
+ function processIsDemonstrablyGone(pid) {
111
+ try {
112
+ process.kill(pid, 0);
113
+ return false;
114
+ } catch (error) {
115
+ return error?.code === "ESRCH";
116
+ }
117
+ }
118
+
119
+ async function removeOrphanedSnapshotCandidates(destination) {
120
+ const directory = dirname(destination);
121
+ const destinationHash = snapshotDestinationHash(destination);
122
+ const currentUid = process.getuid instanceof Function
123
+ ? process.getuid()
124
+ : null;
125
+ const candidatePattern = new RegExp(
126
+ `^\\.token-ledger-${destinationHash}-([1-9][0-9]*)-${SNAPSHOT_TEMP_UUID_PATTERN}\\.tmp$`,
127
+ );
128
+ let names;
129
+ try {
130
+ names = await readdir(directory);
131
+ } catch (error) {
132
+ if (["ENOENT", "ENOTDIR"].includes(error?.code)) return;
133
+ throw error;
134
+ }
135
+ for (const name of names) {
136
+ const match = name.match(candidatePattern);
137
+ if (!match) continue;
138
+ const ownerPid = Number(match[1]);
139
+ if (
140
+ !Number.isSafeInteger(ownerPid) ||
141
+ ownerPid === process.pid ||
142
+ !processIsDemonstrablyGone(ownerPid)
143
+ ) continue;
144
+ const candidate = resolve(directory, name);
145
+ let candidateStat;
146
+ try {
147
+ candidateStat = await lstat(candidate);
148
+ } catch (error) {
149
+ if (["ENOENT", "ENOTDIR"].includes(error?.code)) continue;
150
+ throw error;
151
+ }
152
+ // Only unlink an ordinary, single-link candidate. Symlinks, directories,
153
+ // hard links, and targets that race away are left untouched.
154
+ if (
155
+ !candidateStat.isFile() ||
156
+ Number(candidateStat.nlink) !== 1 ||
157
+ (currentUid !== null && Number(candidateStat.uid) !== currentUid)
158
+ ) continue;
159
+ let confirmedStat;
160
+ try {
161
+ confirmedStat = await lstat(candidate);
162
+ } catch (error) {
163
+ if (["ENOENT", "ENOTDIR"].includes(error?.code)) continue;
164
+ throw error;
165
+ }
166
+ if (
167
+ !confirmedStat.isFile() ||
168
+ Number(confirmedStat.nlink) !== 1 ||
169
+ Number(confirmedStat.dev) !== Number(candidateStat.dev) ||
170
+ Number(confirmedStat.ino) !== Number(candidateStat.ino) ||
171
+ (currentUid !== null && Number(confirmedStat.uid) !== currentUid)
172
+ ) continue;
173
+ try {
174
+ await rm(candidate, { force: true });
175
+ } catch (error) {
176
+ if (
177
+ ["ENOENT", "ENOTDIR", "EISDIR", "EACCES", "EPERM"].includes(
178
+ error?.code,
179
+ )
180
+ ) continue;
181
+ throw error;
182
+ }
183
+ }
184
+ }
185
+
40
186
  function formatMebibytes(bytes) {
41
187
  return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
42
188
  }
@@ -118,6 +264,90 @@ async function boundedEncoding(
118
264
  };
119
265
  }
120
266
 
267
+ function snapshotSizeLimitError(message) {
268
+ const error = new Error(message);
269
+ error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
270
+ return error;
271
+ }
272
+
273
+ function snapshotNotRegularFileError() {
274
+ const error = new Error("Snapshot input must be a regular file.");
275
+ error.code = "ERR_SNAPSHOT_NOT_REGULAR";
276
+ return error;
277
+ }
278
+
279
+ function snapshotCandidateChangedError() {
280
+ const error = new Error(
281
+ "Snapshot candidate changed before it could be published.",
282
+ );
283
+ error.code = "ERR_SNAPSHOT_CANDIDATE_CHANGED";
284
+ return error;
285
+ }
286
+
287
+ function isSameSnapshotCandidate(candidate, pinned) {
288
+ return Boolean(
289
+ candidate?.isFile() &&
290
+ Number(candidate.nlink) === 1 &&
291
+ Number(candidate.dev) === Number(pinned.dev) &&
292
+ Number(candidate.ino) === Number(pinned.ino),
293
+ );
294
+ }
295
+
296
+ async function readBoundedSnapshot(source, sourceLimit, encoding) {
297
+ const handle = await open(
298
+ source,
299
+ fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0),
300
+ );
301
+ try {
302
+ const sourceStats = await handle.stat();
303
+ if (!sourceStats.isFile()) {
304
+ throw snapshotNotRegularFileError();
305
+ }
306
+ if (sourceStats.size > sourceLimit) {
307
+ throw snapshotSizeLimitError(
308
+ `Snapshot input is ${formatMebibytes(sourceStats.size)}, exceeding the ${formatMebibytes(sourceLimit)} ${encoding === "gzip" ? "compressed" : "JSON"} read limit.`,
309
+ );
310
+ }
311
+
312
+ const chunks = [];
313
+ let bytesRead = 0;
314
+ while (bytesRead < sourceLimit) {
315
+ const chunkLength = Math.min(
316
+ SNAPSHOT_READ_CHUNK_BYTES,
317
+ sourceLimit - bytesRead,
318
+ );
319
+ const chunk = Buffer.allocUnsafe(chunkLength);
320
+ const result = await handle.read(
321
+ chunk,
322
+ 0,
323
+ chunkLength,
324
+ bytesRead,
325
+ );
326
+ if (result.bytesRead === 0) break;
327
+ chunks.push(chunk.subarray(0, result.bytesRead));
328
+ bytesRead += result.bytesRead;
329
+ }
330
+ if (bytesRead >= sourceLimit) {
331
+ const extra = Buffer.allocUnsafe(1);
332
+ const result = await handle.read(extra, 0, 1, bytesRead);
333
+ if (result.bytesRead > 0) {
334
+ throw snapshotSizeLimitError(
335
+ `Snapshot input exceeds the ${formatMebibytes(sourceLimit)} ${encoding === "gzip" ? "compressed" : "JSON"} read limit.`,
336
+ );
337
+ }
338
+ }
339
+ const finalStats = await handle.stat();
340
+ if (finalStats.size > sourceLimit) {
341
+ throw snapshotSizeLimitError(
342
+ `Snapshot input grew beyond the ${formatMebibytes(sourceLimit)} ${encoding === "gzip" ? "compressed" : "JSON"} read limit.`,
343
+ );
344
+ }
345
+ return Buffer.concat(chunks, bytesRead);
346
+ } finally {
347
+ await handle.close();
348
+ }
349
+ }
350
+
121
351
  export async function readPrivateSnapshot(
122
352
  input,
123
353
  {
@@ -134,22 +364,7 @@ export async function readPrivateSnapshot(
134
364
  }
135
365
  const encoding = snapshotEncoding(source);
136
366
  const sourceLimit = encoding === "gzip" ? maxBytes : maxJsonBytes;
137
- const sourceStats = await stat(source);
138
- if (sourceStats.size > sourceLimit) {
139
- const error = new Error(
140
- `Snapshot input is ${formatMebibytes(sourceStats.size)}, exceeding the ${formatMebibytes(sourceLimit)} ${encoding === "gzip" ? "compressed" : "JSON"} read limit.`,
141
- );
142
- error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
143
- throw error;
144
- }
145
- const encoded = await readFile(source);
146
- if (encoded.byteLength > sourceLimit) {
147
- const error = new Error(
148
- `Snapshot input grew beyond the ${formatMebibytes(sourceLimit)} ${encoding === "gzip" ? "compressed" : "JSON"} read limit.`,
149
- );
150
- error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
151
- throw error;
152
- }
367
+ const encoded = await readBoundedSnapshot(source, sourceLimit, encoding);
153
368
  let decoded;
154
369
  if (encoding === "gzip") {
155
370
  try {
@@ -167,16 +382,14 @@ export async function readPrivateSnapshot(
167
382
  decoded = encoded;
168
383
  }
169
384
  if (decoded.byteLength > maxJsonBytes) {
170
- const error = new Error(
385
+ throw snapshotSizeLimitError(
171
386
  `Snapshot JSON representation is ${formatMebibytes(decoded.byteLength)}, exceeding the ${formatMebibytes(maxJsonBytes)} read limit.`,
172
387
  );
173
- error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
174
- throw error;
175
388
  }
176
389
  return JSON.parse(decoded.toString("utf8"));
177
390
  }
178
391
 
179
- export async function writePrivateSnapshot(
392
+ export async function stagePrivateSnapshot(
180
393
  output,
181
394
  snapshot,
182
395
  {
@@ -187,13 +400,15 @@ export async function writePrivateSnapshot(
187
400
  DEFAULT_SNAPSHOT_TARGET_JSON_BYTES,
188
401
  maxJsonBytes,
189
402
  ),
403
+ reservedPaths = [],
190
404
  } = {},
191
405
  ) {
192
406
  const destination = resolve(output);
407
+ assertSnapshotDestination(destination, reservedPaths);
193
408
  const directory = dirname(destination);
194
409
  const temporary = resolve(
195
410
  directory,
196
- `.token-ledger-${process.pid}-${randomUUID()}.tmp`,
411
+ snapshotTemporaryName(destination),
197
412
  );
198
413
  const encoding = snapshotEncoding(destination);
199
414
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
@@ -241,19 +456,85 @@ export async function writePrivateSnapshot(
241
456
  }
242
457
 
243
458
  await mkdir(directory, { recursive: true });
459
+ await removeOrphanedSnapshotCandidates(destination);
460
+ let candidateHandle = null;
461
+ let candidateIdentity = null;
244
462
  try {
245
- await writeFile(temporary, encoded, {
246
- flag: "wx",
247
- mode: 0o600,
248
- });
249
- await chmod(temporary, 0o600);
250
- await rename(temporary, destination);
251
- await chmod(destination, 0o600);
252
- } finally {
463
+ candidateHandle = await open(
464
+ temporary,
465
+ fsConstants.O_WRONLY |
466
+ fsConstants.O_CREAT |
467
+ fsConstants.O_EXCL |
468
+ (fsConstants.O_NOFOLLOW ?? 0),
469
+ 0o600,
470
+ );
471
+ await candidateHandle.writeFile(encoded);
472
+ await candidateHandle.chmod(0o600);
473
+ await candidateHandle.sync();
474
+ candidateIdentity = await candidateHandle.stat();
475
+ if (!candidateIdentity.isFile() || Number(candidateIdentity.nlink) !== 1) {
476
+ throw snapshotCandidateChangedError();
477
+ }
478
+ } catch (error) {
479
+ await candidateHandle?.close();
253
480
  await rm(temporary, { force: true });
481
+ throw error;
254
482
  }
255
483
 
484
+ let published = false;
485
+ let discarded = false;
486
+ const publish = async () => {
487
+ if (published) return;
488
+ if (discarded) {
489
+ throw new Error("Cannot publish a discarded snapshot candidate.");
490
+ }
491
+ let currentIdentity;
492
+ try {
493
+ currentIdentity = await lstat(temporary);
494
+ } catch (error) {
495
+ if (["ENOENT", "ENOTDIR"].includes(error?.code)) {
496
+ throw snapshotCandidateChangedError();
497
+ }
498
+ throw error;
499
+ }
500
+ if (!isSameSnapshotCandidate(currentIdentity, candidateIdentity)) {
501
+ throw snapshotCandidateChangedError();
502
+ }
503
+ await rename(temporary, destination);
504
+ const publishedIdentity = await lstat(destination);
505
+ if (!isSameSnapshotCandidate(publishedIdentity, candidateIdentity)) {
506
+ // A competing writer may have replaced the destination between the
507
+ // rename and this verification. Never remove an inode we do not own;
508
+ // the candidate handle still pins our original file and can be closed
509
+ // without touching the competing replacement.
510
+ await candidateHandle?.close();
511
+ candidateHandle = null;
512
+ throw snapshotCandidateChangedError();
513
+ }
514
+ await candidateHandle.close();
515
+ candidateHandle = null;
516
+ published = true;
517
+ };
518
+ const discard = async () => {
519
+ if (published || discarded) return;
520
+ discarded = true;
521
+ await candidateHandle?.close();
522
+ candidateHandle = null;
523
+ let currentIdentity;
524
+ try {
525
+ currentIdentity = await lstat(temporary);
526
+ } catch (error) {
527
+ if (["ENOENT", "ENOTDIR"].includes(error?.code)) return;
528
+ throw error;
529
+ }
530
+ if (isSameSnapshotCandidate(currentIdentity, candidateIdentity)) {
531
+ await rm(temporary, { force: true });
532
+ }
533
+ };
534
+
256
535
  return {
536
+ publish,
537
+ discard,
257
538
  encoding,
258
539
  bytesWritten: encoded.byteLength,
259
540
  jsonBytes: serialized.byteLength,
@@ -265,3 +546,23 @@ export async function writePrivateSnapshot(
265
546
  snapshot: storedSnapshot,
266
547
  };
267
548
  }
549
+
550
+ export async function writePrivateSnapshot(output, snapshot, options = {}) {
551
+ const staged = await stagePrivateSnapshot(output, snapshot, options);
552
+ try {
553
+ await staged.publish();
554
+ return {
555
+ encoding: staged.encoding,
556
+ bytesWritten: staged.bytesWritten,
557
+ jsonBytes: staged.jsonBytes,
558
+ maxBytes: staged.maxBytes,
559
+ targetBytes: staged.targetBytes,
560
+ maxJsonBytes: staged.maxJsonBytes,
561
+ targetJsonBytes: staged.targetJsonBytes,
562
+ adaptiveResolutionSeconds: staged.adaptiveResolutionSeconds,
563
+ snapshot: staged.snapshot,
564
+ };
565
+ } finally {
566
+ await staged.discard();
567
+ }
568
+ }
@@ -0,0 +1,11 @@
1
+ const OSC_SEQUENCE =
2
+ /(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c|$)/g;
3
+ const CSI_SEQUENCE = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g;
4
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
5
+
6
+ export function sanitizeTerminalText(value) {
7
+ return String(value ?? "")
8
+ .replace(OSC_SEQUENCE, "")
9
+ .replace(CSI_SEQUENCE, "")
10
+ .replace(CONTROL_CHARACTERS, " ");
11
+ }