patchwork-os 1.2.0-beta.2.canary.783 → 1.2.0-beta.2.canary.786
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/dist/evidenceCoverage.js +5 -0
- package/dist/evidenceCoverage.js.map +1 -1
- package/dist/evidenceRelationships.js +6 -1
- package/dist/evidenceRelationships.js.map +1 -1
- package/dist/evidenceVerify.d.ts +28 -0
- package/dist/evidenceVerify.js +74 -0
- package/dist/evidenceVerify.js.map +1 -0
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/ledgerChain.d.ts +160 -0
- package/dist/ledgerChain.js +515 -0
- package/dist/ledgerChain.js.map +1 -0
- package/dist/privacy/boundaryReceiptLog.d.ts +7 -1
- package/dist/privacy/boundaryReceiptLog.js +14 -3
- package/dist/privacy/boundaryReceiptLog.js.map +1 -1
- package/dist/privacy/boundaryReceipts.d.ts +6 -0
- package/dist/privacy/boundaryReceipts.js +2 -0
- package/dist/privacy/boundaryReceipts.js.map +1 -1
- package/dist/privacy/shadowLog.d.ts +13 -1
- package/dist/privacy/shadowLog.js +23 -3
- package/dist/privacy/shadowLog.js.map +1 -1
- package/dist/workerGateDecisionLog.d.ts +7 -3
- package/dist/workerGateDecisionLog.js +31 -110
- package/dist/workerGateDecisionLog.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tamper-evident JSONL ledgers — ADR-0027.
|
|
3
|
+
*
|
|
4
|
+
* ONE append primitive for every ledger on the evidence spine, and ONE
|
|
5
|
+
* verifier over the files it writes. The chain is a property of the BYTES on
|
|
6
|
+
* disk: `iseq` is a per-ledger integrity sequence read from the file's tail
|
|
7
|
+
* under the cross-process lock, and `prev` is the SHA-256 of the exact bytes
|
|
8
|
+
* of the previous line. Nothing a process remembers is trusted for either —
|
|
9
|
+
* two bridges sharing one `$PATCHWORK_HOME` is the normal case, not the edge.
|
|
10
|
+
*
|
|
11
|
+
* ## What is and is not repurposed
|
|
12
|
+
*
|
|
13
|
+
* `seq` on the receipt and gate ledgers is a per-PROCESS counter and stays
|
|
14
|
+
* exactly what it was. `rv` is a schema level and stays one. The integrity
|
|
15
|
+
* fields are new names ADDED behind a per-ledger `rv` bump, so a row written
|
|
16
|
+
* before the chain existed is distinguishable from one that should carry it —
|
|
17
|
+
* the sentinel ADR-0025 required before any stamping.
|
|
18
|
+
*
|
|
19
|
+
* ## Marker rows carry `kind` and NEVER `seq`
|
|
20
|
+
*
|
|
21
|
+
* Two marker shapes live in the same file as the data. Every existing loader
|
|
22
|
+
* keys on its own data fields (`seq` + `workerId` + `toolName` + `action` for
|
|
23
|
+
* the gate, numeric `seq` for receipts, known `kind`s for approvals), so a
|
|
24
|
+
* marker with no `seq` is skipped by all of them. Adding `seq` to a marker
|
|
25
|
+
* would put it in the receipt ring as a receipt. Do not.
|
|
26
|
+
*
|
|
27
|
+
* ## Legacy rows are byte-identical and never re-stamped
|
|
28
|
+
*
|
|
29
|
+
* The first chained append to a file that has no chain writes a `chain-start`
|
|
30
|
+
* marker committing to the SHA-256 of every byte before it. That makes the old
|
|
31
|
+
* evidence verifiable from the migration boundary without touching it, which
|
|
32
|
+
* is the never-backfill doctrine applied rather than bent.
|
|
33
|
+
*
|
|
34
|
+
* ## A failed append is a different fact from "nobody recorded this"
|
|
35
|
+
*
|
|
36
|
+
* Every observability writer swallows its own errors on purpose. The cost was
|
|
37
|
+
* that a ledger that silently stopped writing looked like a quiet day. Now a
|
|
38
|
+
* failure is counted in `<file>.write_failed` (when and which errno, never a
|
|
39
|
+
* payload), the NEXT successful row seals the count as `writeFailed: N`, and
|
|
40
|
+
* until it does the verifier reports the count as PENDING.
|
|
41
|
+
*
|
|
42
|
+
* ## What this cannot do
|
|
43
|
+
*
|
|
44
|
+
* Prove authorship. Anyone with the operator's uid can rewrite the file and
|
|
45
|
+
* both sidecars together. What they cannot do is change one row and leave the
|
|
46
|
+
* rest standing, or shorten the file without the head sidecar disagreeing.
|
|
47
|
+
* Signing is a control-plane concern (ADR-0019); no key material lives here.
|
|
48
|
+
*/
|
|
49
|
+
export declare const CHAIN_HASH_ALGORITHM = "sha256";
|
|
50
|
+
/** Written once: commits to the bytes that precede it. */
|
|
51
|
+
export interface ChainStartMarker {
|
|
52
|
+
kind: "chain-start";
|
|
53
|
+
at: number;
|
|
54
|
+
legacyRows: number;
|
|
55
|
+
legacyBytes: number;
|
|
56
|
+
legacyHash: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Written as the new FIRST line whenever rotation removes rows. Counts are
|
|
60
|
+
* CUMULATIVE across rotations, so one marker answers "how many rows has this
|
|
61
|
+
* file ever lost to rotation". `lastDroppedHash` re-anchors the chain: the
|
|
62
|
+
* first kept chained row's `prev` must equal it.
|
|
63
|
+
*/
|
|
64
|
+
export interface RotationMarker {
|
|
65
|
+
kind: "rotation";
|
|
66
|
+
at: number;
|
|
67
|
+
droppedRows: number;
|
|
68
|
+
droppedLegacyRows: number;
|
|
69
|
+
lastDroppedHash: string | null;
|
|
70
|
+
lastDroppedIseq: number | null;
|
|
71
|
+
firstKeptIseq: number | null;
|
|
72
|
+
}
|
|
73
|
+
export interface AppendChainedOptions {
|
|
74
|
+
now?: () => number;
|
|
75
|
+
/** Rotate when the file exceeds this many bytes. Absent ⇒ never rotate. */
|
|
76
|
+
maxBytes?: number;
|
|
77
|
+
/** Low-water target after rotation. Defaults to 90% of `maxBytes`. */
|
|
78
|
+
rotateTarget?: number;
|
|
79
|
+
/** Rotate when the file exceeds this many lines. Absent ⇒ no line cap. */
|
|
80
|
+
maxLines?: number;
|
|
81
|
+
mode?: number;
|
|
82
|
+
lockTimeoutMs?: number;
|
|
83
|
+
/**
|
|
84
|
+
* Called after a rotation actually removed rows, with how many THIS rotation
|
|
85
|
+
* dropped and how many the file held before. The gate ledger warns with the
|
|
86
|
+
* count because coverage measures over a rotated file converge toward 1.0
|
|
87
|
+
* by deletion (ADR-0025), and a number nobody prints is a number nobody
|
|
88
|
+
* reads.
|
|
89
|
+
*/
|
|
90
|
+
onRotate?: (info: {
|
|
91
|
+
dropped: number;
|
|
92
|
+
before: number;
|
|
93
|
+
}) => void;
|
|
94
|
+
}
|
|
95
|
+
export type ChainBreakKind = "hash" | "gap" | "duplicate" | "order" | "truncated" | "unchained" | "malformed-row" | "malformed-marker";
|
|
96
|
+
export interface ChainBreak {
|
|
97
|
+
kind: ChainBreakKind;
|
|
98
|
+
/** 1-based line in the file. A position, never a value. */
|
|
99
|
+
line: number;
|
|
100
|
+
iseq: number | null;
|
|
101
|
+
}
|
|
102
|
+
export interface ChainVerification {
|
|
103
|
+
file: string;
|
|
104
|
+
absent: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* The chain is internally intact: no breaks, no legacy mismatch, and the
|
|
107
|
+
* head sidecar does not say the file used to be longer. Pending write
|
|
108
|
+
* failures do NOT flip this — the chain is intact; the spine-level report
|
|
109
|
+
* is where "a ledger stopped writing" becomes not-ok.
|
|
110
|
+
*/
|
|
111
|
+
ok: boolean;
|
|
112
|
+
/** Rows before the chain-start marker in THIS file. */
|
|
113
|
+
legacyRows: number;
|
|
114
|
+
legacyPrefix: "none" | "unchained" | "verified" | "mismatch" | "unverifiable-rotated";
|
|
115
|
+
chainedRows: number;
|
|
116
|
+
lastIseq: number | null;
|
|
117
|
+
rotations: number;
|
|
118
|
+
/** Chained rows ever removed by rotation, from the marker's cumulative count. */
|
|
119
|
+
droppedByRotation: number;
|
|
120
|
+
breaks: ChainBreak[];
|
|
121
|
+
writeFailedSealed: number;
|
|
122
|
+
writeFailedPending: number;
|
|
123
|
+
head: "ok" | "missing" | "truncated" | "mismatch" | "stale" | "n/a";
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Is this parsed row one of the two ADR-0027 marker shapes? Shared by every
|
|
127
|
+
* reader that must not count a marker as data — one predicate, so the set of
|
|
128
|
+
* marker kinds cannot drift between readers.
|
|
129
|
+
*/
|
|
130
|
+
export declare function isChainMarker(row: unknown): boolean;
|
|
131
|
+
export declare function hashLine(line: string): string;
|
|
132
|
+
export declare function chainSidecarPaths(file: string): {
|
|
133
|
+
head: string;
|
|
134
|
+
writeFailed: string;
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Count one failed append. Never throws and never carries a payload: the
|
|
138
|
+
* whole point is that the row that failed to land is not on disk anywhere.
|
|
139
|
+
*/
|
|
140
|
+
export declare function recordWriteFailure(file: string, code: string, now?: () => number): void;
|
|
141
|
+
/**
|
|
142
|
+
* Append one row to a chained ledger. Under the cross-process lock: rotate if
|
|
143
|
+
* over cap, read the tail, stamp `iseq` / `prev` / `writeFailed`, append,
|
|
144
|
+
* update the head sidecar, clear the failure counter.
|
|
145
|
+
*
|
|
146
|
+
* The integrity fields are stamped AFTER the caller's fields, so a row cannot
|
|
147
|
+
* forge its own position. The caller's object is never mutated.
|
|
148
|
+
*
|
|
149
|
+
* Throws on failure — after counting it in the write_failed sidecar — so a
|
|
150
|
+
* writer keeps whatever contract it already has (most swallow and warn).
|
|
151
|
+
*/
|
|
152
|
+
export declare function appendChained(file: string, row: Record<string, unknown>, opts?: AppendChainedOptions): {
|
|
153
|
+
line: string;
|
|
154
|
+
iseq: number;
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Verify one ledger from its bytes. Read-only. Reports positions (line, iseq)
|
|
158
|
+
* and counts, never a value.
|
|
159
|
+
*/
|
|
160
|
+
export declare function verifyLedgerChain(file: string): ChainVerification;
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tamper-evident JSONL ledgers — ADR-0027.
|
|
3
|
+
*
|
|
4
|
+
* ONE append primitive for every ledger on the evidence spine, and ONE
|
|
5
|
+
* verifier over the files it writes. The chain is a property of the BYTES on
|
|
6
|
+
* disk: `iseq` is a per-ledger integrity sequence read from the file's tail
|
|
7
|
+
* under the cross-process lock, and `prev` is the SHA-256 of the exact bytes
|
|
8
|
+
* of the previous line. Nothing a process remembers is trusted for either —
|
|
9
|
+
* two bridges sharing one `$PATCHWORK_HOME` is the normal case, not the edge.
|
|
10
|
+
*
|
|
11
|
+
* ## What is and is not repurposed
|
|
12
|
+
*
|
|
13
|
+
* `seq` on the receipt and gate ledgers is a per-PROCESS counter and stays
|
|
14
|
+
* exactly what it was. `rv` is a schema level and stays one. The integrity
|
|
15
|
+
* fields are new names ADDED behind a per-ledger `rv` bump, so a row written
|
|
16
|
+
* before the chain existed is distinguishable from one that should carry it —
|
|
17
|
+
* the sentinel ADR-0025 required before any stamping.
|
|
18
|
+
*
|
|
19
|
+
* ## Marker rows carry `kind` and NEVER `seq`
|
|
20
|
+
*
|
|
21
|
+
* Two marker shapes live in the same file as the data. Every existing loader
|
|
22
|
+
* keys on its own data fields (`seq` + `workerId` + `toolName` + `action` for
|
|
23
|
+
* the gate, numeric `seq` for receipts, known `kind`s for approvals), so a
|
|
24
|
+
* marker with no `seq` is skipped by all of them. Adding `seq` to a marker
|
|
25
|
+
* would put it in the receipt ring as a receipt. Do not.
|
|
26
|
+
*
|
|
27
|
+
* ## Legacy rows are byte-identical and never re-stamped
|
|
28
|
+
*
|
|
29
|
+
* The first chained append to a file that has no chain writes a `chain-start`
|
|
30
|
+
* marker committing to the SHA-256 of every byte before it. That makes the old
|
|
31
|
+
* evidence verifiable from the migration boundary without touching it, which
|
|
32
|
+
* is the never-backfill doctrine applied rather than bent.
|
|
33
|
+
*
|
|
34
|
+
* ## A failed append is a different fact from "nobody recorded this"
|
|
35
|
+
*
|
|
36
|
+
* Every observability writer swallows its own errors on purpose. The cost was
|
|
37
|
+
* that a ledger that silently stopped writing looked like a quiet day. Now a
|
|
38
|
+
* failure is counted in `<file>.write_failed` (when and which errno, never a
|
|
39
|
+
* payload), the NEXT successful row seals the count as `writeFailed: N`, and
|
|
40
|
+
* until it does the verifier reports the count as PENDING.
|
|
41
|
+
*
|
|
42
|
+
* ## What this cannot do
|
|
43
|
+
*
|
|
44
|
+
* Prove authorship. Anyone with the operator's uid can rewrite the file and
|
|
45
|
+
* both sidecars together. What they cannot do is change one row and leave the
|
|
46
|
+
* rest standing, or shorten the file without the head sidecar disagreeing.
|
|
47
|
+
* Signing is a control-plane concern (ADR-0019); no key material lives here.
|
|
48
|
+
*/
|
|
49
|
+
import { createHash } from "node:crypto";
|
|
50
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
51
|
+
import path from "node:path";
|
|
52
|
+
import { withFileLockSync } from "./fileLockSync.js";
|
|
53
|
+
export const CHAIN_HASH_ALGORITHM = "sha256";
|
|
54
|
+
/**
|
|
55
|
+
* Is this parsed row one of the two ADR-0027 marker shapes? Shared by every
|
|
56
|
+
* reader that must not count a marker as data — one predicate, so the set of
|
|
57
|
+
* marker kinds cannot drift between readers.
|
|
58
|
+
*/
|
|
59
|
+
export function isChainMarker(row) {
|
|
60
|
+
if (row === null || typeof row !== "object")
|
|
61
|
+
return false;
|
|
62
|
+
const k = row.kind;
|
|
63
|
+
return k === "chain-start" || k === "rotation";
|
|
64
|
+
}
|
|
65
|
+
export function hashLine(line) {
|
|
66
|
+
return createHash(CHAIN_HASH_ALGORITHM).update(line, "utf8").digest("hex");
|
|
67
|
+
}
|
|
68
|
+
export function chainSidecarPaths(file) {
|
|
69
|
+
return { head: `${file}.head`, writeFailed: `${file}.write_failed` };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Count one failed append. Never throws and never carries a payload: the
|
|
73
|
+
* whole point is that the row that failed to land is not on disk anywhere.
|
|
74
|
+
*/
|
|
75
|
+
export function recordWriteFailure(file, code, now = Date.now) {
|
|
76
|
+
try {
|
|
77
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
78
|
+
appendFileSync(chainSidecarPaths(file).writeFailed, `${JSON.stringify({ at: now(), code })}\n`, { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* the disk that lost the row is losing the counter too; nothing to do */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function countLines(file) {
|
|
85
|
+
try {
|
|
86
|
+
return readFileSync(file, "utf-8")
|
|
87
|
+
.split("\n")
|
|
88
|
+
.filter((l) => l.length > 0).length;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function splitLines(text) {
|
|
95
|
+
const out = text.split("\n");
|
|
96
|
+
if (out.length > 0 && out[out.length - 1] === "")
|
|
97
|
+
out.pop();
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function parseLine(line) {
|
|
101
|
+
try {
|
|
102
|
+
const v = JSON.parse(line);
|
|
103
|
+
return v !== null && typeof v === "object" && !Array.isArray(v)
|
|
104
|
+
? v
|
|
105
|
+
: null;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function isFiniteNumber(v) {
|
|
112
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
113
|
+
}
|
|
114
|
+
function atomicWrite(file, text, mode) {
|
|
115
|
+
const tmp = `${file}.tmp`;
|
|
116
|
+
writeFileSync(tmp, text, { mode });
|
|
117
|
+
// Windows: a rename onto an existing target can fail TRANSIENTLY with
|
|
118
|
+
// EPERM / EBUSY while an indexer or scanner holds the old file, and with
|
|
119
|
+
// EEXIST on some filesystems. Retry briefly, then fall back to unlink +
|
|
120
|
+
// rename for EEXIST (the gate ledger's original path). POSIX never enters
|
|
121
|
+
// the loop: rename there is atomic and replaces.
|
|
122
|
+
const attempts = process.platform === "win32" ? 20 : 1;
|
|
123
|
+
for (let n = 1;; n++) {
|
|
124
|
+
try {
|
|
125
|
+
renameSync(tmp, file);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
const code = err.code;
|
|
130
|
+
const transient = process.platform === "win32" &&
|
|
131
|
+
(code === "EPERM" || code === "EBUSY" || code === "EEXIST");
|
|
132
|
+
if (!transient || n >= attempts) {
|
|
133
|
+
if (process.platform === "win32" && code === "EEXIST") {
|
|
134
|
+
try {
|
|
135
|
+
unlinkSync(file);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* best-effort */
|
|
139
|
+
}
|
|
140
|
+
renameSync(tmp, file);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
const spinUntil = process.hrtime.bigint() + 10000000n; // 10ms
|
|
146
|
+
while (process.hrtime.bigint() < spinUntil) {
|
|
147
|
+
/* yield */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function readTail(lines) {
|
|
153
|
+
if (lines.length === 0)
|
|
154
|
+
return { unchained: true, prevHash: "", nextIseq: 1 };
|
|
155
|
+
let chained = false;
|
|
156
|
+
let lastIseq = null;
|
|
157
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
158
|
+
const p = parseLine(lines[i]);
|
|
159
|
+
if (!p)
|
|
160
|
+
continue;
|
|
161
|
+
if (isFiniteNumber(p.iseq)) {
|
|
162
|
+
chained = true;
|
|
163
|
+
lastIseq = p.iseq;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
if (p.kind === "rotation") {
|
|
167
|
+
chained = true;
|
|
168
|
+
lastIseq = isFiniteNumber(p.lastDroppedIseq) ? p.lastDroppedIseq : 0;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
if (p.kind === "chain-start") {
|
|
172
|
+
chained = true;
|
|
173
|
+
lastIseq = 0;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!chained)
|
|
178
|
+
return { unchained: true, prevHash: "", nextIseq: 1 };
|
|
179
|
+
return {
|
|
180
|
+
unchained: false,
|
|
181
|
+
prevHash: hashLine(lines[lines.length - 1]),
|
|
182
|
+
nextIseq: (lastIseq ?? 0) + 1,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function rotate(file, lines, opts, now, mode) {
|
|
186
|
+
const maxBytes = opts.maxBytes ?? Number.POSITIVE_INFINITY;
|
|
187
|
+
const target = opts.rotateTarget ?? Math.floor(maxBytes * 0.9);
|
|
188
|
+
const maxLines = opts.maxLines ?? Number.POSITIVE_INFINITY;
|
|
189
|
+
// Fold the previous rotation marker's cumulative counts, then drop it: a
|
|
190
|
+
// file has exactly one rotation marker, at line 1.
|
|
191
|
+
let prior = null;
|
|
192
|
+
let body = lines;
|
|
193
|
+
const first = lines.length > 0 ? parseLine(lines[0]) : null;
|
|
194
|
+
if (first && first.kind === "rotation") {
|
|
195
|
+
prior = first;
|
|
196
|
+
body = lines.slice(1);
|
|
197
|
+
}
|
|
198
|
+
let budget = target;
|
|
199
|
+
let keepFrom = body.length;
|
|
200
|
+
let kept = 0;
|
|
201
|
+
for (let i = body.length - 1; i >= 0; i--) {
|
|
202
|
+
const cost = Buffer.byteLength(body[i], "utf8") + 1;
|
|
203
|
+
if (cost > budget || kept + 1 > maxLines)
|
|
204
|
+
break;
|
|
205
|
+
budget -= cost;
|
|
206
|
+
keepFrom = i;
|
|
207
|
+
kept++;
|
|
208
|
+
}
|
|
209
|
+
if (keepFrom === 0 && prior === null)
|
|
210
|
+
return lines; // nothing to drop
|
|
211
|
+
let droppedRows = prior?.droppedRows ?? 0;
|
|
212
|
+
let droppedLegacyRows = prior?.droppedLegacyRows ?? 0;
|
|
213
|
+
let lastDroppedIseq = prior?.lastDroppedIseq ?? null;
|
|
214
|
+
let lastDroppedHash = prior?.lastDroppedHash ?? null;
|
|
215
|
+
let seenChainStart = false;
|
|
216
|
+
for (let i = 0; i < keepFrom; i++) {
|
|
217
|
+
const line = body[i];
|
|
218
|
+
const p = parseLine(line);
|
|
219
|
+
if (p && isFiniteNumber(p.iseq)) {
|
|
220
|
+
droppedRows++;
|
|
221
|
+
lastDroppedIseq = p.iseq;
|
|
222
|
+
}
|
|
223
|
+
else if (p && p.kind === "chain-start") {
|
|
224
|
+
seenChainStart = true;
|
|
225
|
+
}
|
|
226
|
+
else if (!p || p.kind === undefined) {
|
|
227
|
+
// Anything before chain-start that is not a marker is legacy; so is an
|
|
228
|
+
// unparseable line, which the legacy hash committed to as bytes.
|
|
229
|
+
if (!seenChainStart)
|
|
230
|
+
droppedLegacyRows++;
|
|
231
|
+
}
|
|
232
|
+
lastDroppedHash = hashLine(line);
|
|
233
|
+
}
|
|
234
|
+
if (keepFrom > 0 && lastDroppedHash === null)
|
|
235
|
+
lastDroppedHash = null;
|
|
236
|
+
const keptLines = body.slice(keepFrom);
|
|
237
|
+
const firstKept = keptLines.length > 0 ? parseLine(keptLines[0]) : null;
|
|
238
|
+
const marker = {
|
|
239
|
+
kind: "rotation",
|
|
240
|
+
at: now,
|
|
241
|
+
droppedRows,
|
|
242
|
+
droppedLegacyRows,
|
|
243
|
+
lastDroppedHash,
|
|
244
|
+
lastDroppedIseq,
|
|
245
|
+
firstKeptIseq: firstKept && isFiniteNumber(firstKept.iseq) ? firstKept.iseq : null,
|
|
246
|
+
};
|
|
247
|
+
const out = [JSON.stringify(marker), ...keptLines];
|
|
248
|
+
atomicWrite(file, out.length > 0 ? `${out.join("\n")}\n` : "", mode);
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Append one row to a chained ledger. Under the cross-process lock: rotate if
|
|
253
|
+
* over cap, read the tail, stamp `iseq` / `prev` / `writeFailed`, append,
|
|
254
|
+
* update the head sidecar, clear the failure counter.
|
|
255
|
+
*
|
|
256
|
+
* The integrity fields are stamped AFTER the caller's fields, so a row cannot
|
|
257
|
+
* forge its own position. The caller's object is never mutated.
|
|
258
|
+
*
|
|
259
|
+
* Throws on failure — after counting it in the write_failed sidecar — so a
|
|
260
|
+
* writer keeps whatever contract it already has (most swallow and warn).
|
|
261
|
+
*/
|
|
262
|
+
export function appendChained(file, row, opts = {}) {
|
|
263
|
+
const now = opts.now ?? Date.now;
|
|
264
|
+
const mode = opts.mode ?? 0o600;
|
|
265
|
+
const side = chainSidecarPaths(file);
|
|
266
|
+
try {
|
|
267
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
268
|
+
return withFileLockSync(file, () => {
|
|
269
|
+
let text = "";
|
|
270
|
+
try {
|
|
271
|
+
text = readFileSync(file, "utf-8");
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
if (err.code !== "ENOENT")
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
let lines = splitLines(text);
|
|
278
|
+
const overBytes = opts.maxBytes !== undefined &&
|
|
279
|
+
Buffer.byteLength(text, "utf8") > opts.maxBytes;
|
|
280
|
+
const overLines = opts.maxLines !== undefined && lines.length > opts.maxLines;
|
|
281
|
+
if (overBytes || overLines) {
|
|
282
|
+
const before = lines.length;
|
|
283
|
+
lines = rotate(file, lines, opts, now(), mode);
|
|
284
|
+
text = lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
285
|
+
// The marker is one line, so `before - lines.length + 1` is what
|
|
286
|
+
// rotation removed; a file with a prior marker also lost that one.
|
|
287
|
+
const dropped = Math.max(0, before - (lines.length - 1));
|
|
288
|
+
if (dropped > 0)
|
|
289
|
+
opts.onRotate?.({ dropped, before });
|
|
290
|
+
}
|
|
291
|
+
const tail = readTail(lines);
|
|
292
|
+
let prevHash = tail.prevHash;
|
|
293
|
+
let iseq = tail.nextIseq;
|
|
294
|
+
if (tail.unchained) {
|
|
295
|
+
const marker = {
|
|
296
|
+
kind: "chain-start",
|
|
297
|
+
at: now(),
|
|
298
|
+
legacyRows: lines.length,
|
|
299
|
+
legacyBytes: Buffer.byteLength(text, "utf8"),
|
|
300
|
+
legacyHash: hashLine(text),
|
|
301
|
+
};
|
|
302
|
+
const markerLine = JSON.stringify(marker);
|
|
303
|
+
appendFileSync(file, `${markerLine}\n`, { mode });
|
|
304
|
+
prevHash = hashLine(markerLine);
|
|
305
|
+
iseq = 1;
|
|
306
|
+
}
|
|
307
|
+
const pending = countLines(side.writeFailed);
|
|
308
|
+
const stamped = {
|
|
309
|
+
...row,
|
|
310
|
+
iseq,
|
|
311
|
+
prev: prevHash,
|
|
312
|
+
...(pending > 0 ? { writeFailed: pending } : {}),
|
|
313
|
+
};
|
|
314
|
+
const line = JSON.stringify(stamped);
|
|
315
|
+
appendFileSync(file, `${line}\n`, { mode });
|
|
316
|
+
atomicWrite(side.head, `${JSON.stringify({ iseq, hash: hashLine(line) })}\n`, mode);
|
|
317
|
+
if (pending > 0)
|
|
318
|
+
rmSync(side.writeFailed, { force: true });
|
|
319
|
+
return { line, iseq };
|
|
320
|
+
}, { timeoutMs: opts.lockTimeoutMs ?? 5000 });
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
recordWriteFailure(file, err.code ?? "EUNKNOWN", now);
|
|
324
|
+
throw err;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function readHead(file) {
|
|
328
|
+
try {
|
|
329
|
+
const p = parseLine(readFileSync(chainSidecarPaths(file).head, "utf-8").trim());
|
|
330
|
+
if (p && isFiniteNumber(p.iseq) && typeof p.hash === "string") {
|
|
331
|
+
return { iseq: p.iseq, hash: p.hash };
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Verify one ledger from its bytes. Read-only. Reports positions (line, iseq)
|
|
341
|
+
* and counts, never a value.
|
|
342
|
+
*/
|
|
343
|
+
export function verifyLedgerChain(file) {
|
|
344
|
+
const v = {
|
|
345
|
+
file,
|
|
346
|
+
absent: !existsSync(file),
|
|
347
|
+
ok: true,
|
|
348
|
+
legacyRows: 0,
|
|
349
|
+
legacyPrefix: "none",
|
|
350
|
+
chainedRows: 0,
|
|
351
|
+
lastIseq: null,
|
|
352
|
+
rotations: 0,
|
|
353
|
+
droppedByRotation: 0,
|
|
354
|
+
breaks: [],
|
|
355
|
+
writeFailedSealed: 0,
|
|
356
|
+
writeFailedPending: countLines(chainSidecarPaths(file).writeFailed),
|
|
357
|
+
head: "n/a",
|
|
358
|
+
};
|
|
359
|
+
if (v.absent)
|
|
360
|
+
return v;
|
|
361
|
+
const text = readFileSync(file, "utf-8");
|
|
362
|
+
const lines = splitLines(text);
|
|
363
|
+
// Every iseq in the file, so "the expected one exists later" (reordered) can
|
|
364
|
+
// be told apart from "it exists nowhere" (a gap).
|
|
365
|
+
const present = new Set();
|
|
366
|
+
for (const l of lines) {
|
|
367
|
+
const p = parseLine(l);
|
|
368
|
+
if (p && isFiniteNumber(p.iseq))
|
|
369
|
+
present.add(p.iseq);
|
|
370
|
+
}
|
|
371
|
+
let inChain = false;
|
|
372
|
+
let sawChainStart = false;
|
|
373
|
+
let legacyUnverifiable = false;
|
|
374
|
+
let legacyBytes = 0;
|
|
375
|
+
let expectedPrev = "";
|
|
376
|
+
let expectedIseq = null;
|
|
377
|
+
const seen = new Set();
|
|
378
|
+
let lastLine = "";
|
|
379
|
+
let lastHash = "";
|
|
380
|
+
for (let i = 0; i < lines.length; i++) {
|
|
381
|
+
const line = lines[i];
|
|
382
|
+
const lineNo = i + 1;
|
|
383
|
+
const p = parseLine(line);
|
|
384
|
+
const kind = p?.kind;
|
|
385
|
+
if (kind === "rotation") {
|
|
386
|
+
if (i !== 0 || !p) {
|
|
387
|
+
v.breaks.push({ kind: "malformed-marker", line: lineNo, iseq: null });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const ok = isFiniteNumber(p.droppedRows) &&
|
|
391
|
+
isFiniteNumber(p.droppedLegacyRows) &&
|
|
392
|
+
(p.lastDroppedHash === null || typeof p.lastDroppedHash === "string") &&
|
|
393
|
+
(p.lastDroppedIseq === null || isFiniteNumber(p.lastDroppedIseq));
|
|
394
|
+
if (!ok) {
|
|
395
|
+
v.breaks.push({ kind: "malformed-marker", line: lineNo, iseq: null });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
v.rotations++;
|
|
399
|
+
v.droppedByRotation = p.droppedRows;
|
|
400
|
+
inChain = true;
|
|
401
|
+
if (p.droppedLegacyRows > 0)
|
|
402
|
+
legacyUnverifiable = true;
|
|
403
|
+
if (typeof p.lastDroppedHash === "string")
|
|
404
|
+
expectedPrev = p.lastDroppedHash;
|
|
405
|
+
if (isFiniteNumber(p.lastDroppedIseq))
|
|
406
|
+
expectedIseq = p.lastDroppedIseq + 1;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (kind === "chain-start") {
|
|
410
|
+
const ok = p !== null &&
|
|
411
|
+
!sawChainStart &&
|
|
412
|
+
isFiniteNumber(p.legacyRows) &&
|
|
413
|
+
isFiniteNumber(p.legacyBytes) &&
|
|
414
|
+
typeof p.legacyHash === "string";
|
|
415
|
+
sawChainStart = true;
|
|
416
|
+
inChain = true;
|
|
417
|
+
if (!ok) {
|
|
418
|
+
v.breaks.push({ kind: "malformed-marker", line: lineNo, iseq: null });
|
|
419
|
+
}
|
|
420
|
+
else if (legacyUnverifiable) {
|
|
421
|
+
v.legacyPrefix = "unverifiable-rotated";
|
|
422
|
+
}
|
|
423
|
+
else if (v.legacyRows === 0 && p.legacyRows === 0) {
|
|
424
|
+
v.legacyPrefix = "none";
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
const prefix = text.slice(0, legacyBytes);
|
|
428
|
+
v.legacyPrefix =
|
|
429
|
+
hashLine(prefix) === p.legacyHash &&
|
|
430
|
+
Buffer.byteLength(prefix, "utf8") === p.legacyBytes &&
|
|
431
|
+
v.legacyRows === p.legacyRows
|
|
432
|
+
? "verified"
|
|
433
|
+
: "mismatch";
|
|
434
|
+
}
|
|
435
|
+
expectedPrev = hashLine(line);
|
|
436
|
+
if (expectedIseq === null)
|
|
437
|
+
expectedIseq = 1;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (!inChain) {
|
|
441
|
+
// Legacy prefix: committed to as bytes, so the content is not judged.
|
|
442
|
+
v.legacyRows++;
|
|
443
|
+
legacyBytes += Buffer.byteLength(line, "utf8") + 1;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (!p) {
|
|
447
|
+
v.breaks.push({ kind: "malformed-row", line: lineNo, iseq: null });
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (!isFiniteNumber(p.iseq)) {
|
|
451
|
+
v.breaks.push({ kind: "unchained", line: lineNo, iseq: null });
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const iseq = p.iseq;
|
|
455
|
+
v.chainedRows++;
|
|
456
|
+
if (p.prev !== expectedPrev) {
|
|
457
|
+
v.breaks.push({ kind: "hash", line: lineNo, iseq });
|
|
458
|
+
}
|
|
459
|
+
if (expectedIseq !== null && iseq !== expectedIseq) {
|
|
460
|
+
let kind;
|
|
461
|
+
if (iseq > expectedIseq)
|
|
462
|
+
kind = present.has(expectedIseq) ? "order" : "gap";
|
|
463
|
+
else
|
|
464
|
+
kind = seen.has(iseq) ? "duplicate" : "order";
|
|
465
|
+
v.breaks.push({ kind, line: lineNo, iseq });
|
|
466
|
+
}
|
|
467
|
+
if (isFiniteNumber(p.writeFailed))
|
|
468
|
+
v.writeFailedSealed += p.writeFailed;
|
|
469
|
+
seen.add(iseq);
|
|
470
|
+
v.lastIseq = iseq;
|
|
471
|
+
expectedIseq = iseq + 1;
|
|
472
|
+
expectedPrev = hashLine(line);
|
|
473
|
+
lastLine = line;
|
|
474
|
+
lastHash = expectedPrev;
|
|
475
|
+
}
|
|
476
|
+
if (!inChain) {
|
|
477
|
+
v.legacyPrefix = v.legacyRows > 0 ? "unchained" : "none";
|
|
478
|
+
}
|
|
479
|
+
else if (legacyUnverifiable && v.legacyPrefix === "none") {
|
|
480
|
+
v.legacyPrefix = "unverifiable-rotated";
|
|
481
|
+
}
|
|
482
|
+
// The head sidecar is the only thing that can say the file used to be longer.
|
|
483
|
+
if (inChain) {
|
|
484
|
+
const head = readHead(file);
|
|
485
|
+
if (!head) {
|
|
486
|
+
v.head = "missing";
|
|
487
|
+
}
|
|
488
|
+
else if (v.lastIseq === null || head.iseq > v.lastIseq) {
|
|
489
|
+
v.head = "truncated";
|
|
490
|
+
v.breaks.push({
|
|
491
|
+
kind: "truncated",
|
|
492
|
+
line: lines.length,
|
|
493
|
+
iseq: v.lastIseq,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
else if (head.iseq < v.lastIseq) {
|
|
497
|
+
v.head = "stale";
|
|
498
|
+
}
|
|
499
|
+
else if (head.hash !== lastHash || lastLine === "") {
|
|
500
|
+
v.head = "mismatch";
|
|
501
|
+
v.breaks.push({ kind: "hash", line: lines.length, iseq: v.lastIseq });
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
v.head = "ok";
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
v.ok =
|
|
508
|
+
v.breaks.length === 0 &&
|
|
509
|
+
v.legacyPrefix !== "mismatch" &&
|
|
510
|
+
v.head !== "missing" &&
|
|
511
|
+
v.head !== "truncated" &&
|
|
512
|
+
v.head !== "mismatch";
|
|
513
|
+
return v;
|
|
514
|
+
}
|
|
515
|
+
//# sourceMappingURL=ledgerChain.js.map
|