haechi 0.6.0 → 0.8.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.
@@ -7,20 +7,55 @@ import { setTimeout as delay } from "node:timers/promises";
7
7
 
8
8
  const FORBIDDEN_KEYS = new Set(["value", "plaintext", "payload", "content", "message", "prompt", "secret"]);
9
9
 
10
- export function createJsonlAuditSink({ path }) {
10
+ export function createJsonlAuditSink({ path, anchor = null }) {
11
11
  if (!path) {
12
12
  throw new Error("JSONL audit sink requires path");
13
13
  }
14
+ const anchorMode = anchor?.mode ?? "none";
15
+ const anchorPath = anchor?.path ?? null;
16
+ const everyRecords = anchor?.everyRecords ?? 1;
17
+ if (!["none", "file", "stdout"].includes(anchorMode)) {
18
+ throw new Error(`Invalid audit anchor mode: ${anchorMode}`);
19
+ }
20
+ if (anchorMode === "file" && !anchorPath) {
21
+ throw new Error("audit anchor mode 'file' requires an anchor path");
22
+ }
23
+ // The sink is a public export reachable via auditSink injection, so it
24
+ // validates everyRecords itself rather than trusting normalizeConfig.
25
+ if (!Number.isInteger(everyRecords) || everyRecords < 1) {
26
+ throw new Error("audit anchor everyRecords must be a positive integer");
27
+ }
14
28
 
15
29
  let writeQueue = Promise.resolve();
16
30
 
31
+ async function writeAnchor(record) {
32
+ const { sequence, eventHash } = record.auditIntegrity;
33
+ // Tamper-evidence against tail truncation: the chain head is appended to a
34
+ // separate append-only stream, so deleting trailing records leaves the
35
+ // chain shorter than the last anchored sequence.
36
+ if (anchorMode === "none" || sequence % everyRecords !== 0) {
37
+ return;
38
+ }
39
+ const line = `${JSON.stringify({ sequence, eventHash, timestamp: record.timestamp })}\n`;
40
+ if (anchorMode === "stdout") {
41
+ process.stdout.write(line);
42
+ } else {
43
+ await mkdir(dirname(anchorPath), { recursive: true });
44
+ // 0600 on creation, like the key/lock files. Note this only matters for
45
+ // confidentiality of the timeline — tamper-evidence still requires the
46
+ // anchor to live on append-only/separate media (see docs).
47
+ await appendFile(anchorPath, line, { mode: 0o600 });
48
+ }
49
+ }
50
+
17
51
  return {
18
52
  id: "haechi.audit.jsonl",
19
53
  version: "0.1.0",
20
54
  capabilities: {
21
55
  writesAudit: true,
22
56
  writesPlaintext: false,
23
- integrity: "sha256-hash-chain"
57
+ appendOnly: true,
58
+ integrity: anchorMode === "none" ? "sha256-hash-chain" : "sha256-hash-chain+anchor"
24
59
  },
25
60
  async record(event) {
26
61
  const write = writeQueue.then(async () => {
@@ -28,6 +63,7 @@ export function createJsonlAuditSink({ path }) {
28
63
  await withFileLock(`${path}.lock`, async () => {
29
64
  const record = await buildIntegrityRecord(path, sanitizeAudit(event));
30
65
  await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
66
+ await writeAnchor(record);
31
67
  });
32
68
  });
33
69
  writeQueue = write.catch(() => {});
@@ -87,7 +123,12 @@ export function sanitizeAudit(value) {
87
123
  return value;
88
124
  }
89
125
 
90
- export async function verifyAuditChain(path) {
126
+ export async function verifyAuditChain(path, { anchorPath = null } = {}) {
127
+ // The anchor stream (if provided) records the chain head at past points; a
128
+ // chain shorter than the last anchor, or a hash that disagrees with an
129
+ // anchor, is tail truncation / tampering the chain alone cannot catch.
130
+ const anchors = anchorPath ? await readAnchors(anchorPath) : null;
131
+
91
132
  const lines = createInterface({
92
133
  input: createReadStream(path, { encoding: "utf8" }),
93
134
  crlfDelay: Infinity
@@ -122,14 +163,66 @@ export async function verifyAuditChain(path) {
122
163
  return { valid: false, records, reason: "event hash mismatch" };
123
164
  }
124
165
 
166
+ if (anchors && anchors.bySequence.has(expectedSequence)
167
+ && anchors.bySequence.get(expectedSequence) !== eventHash) {
168
+ return { valid: false, records, reason: `anchor hash mismatch at sequence ${expectedSequence}` };
169
+ }
170
+
125
171
  expectedPreviousHash = eventHash;
126
172
  expectedSequence += 1;
127
173
  records += 1;
128
174
  }
129
175
 
130
- // headHash anchors the chain externally: publishing it out-of-band is the
131
- // only defense against tail truncation, which the chain alone cannot detect.
132
- return { valid: true, records, headHash: expectedPreviousHash };
176
+ if (anchors && anchors.lastSequence > records) {
177
+ return {
178
+ valid: false,
179
+ records,
180
+ reason: `tail truncation: chain has ${records} records but anchor attests sequence ${anchors.lastSequence}`
181
+ };
182
+ }
183
+
184
+ // headHash anchors the chain externally. With anchorPath, truncation back to
185
+ // the last anchor is now detected; the residual gap is records written after
186
+ // the last anchor.
187
+ const result = { valid: true, records, headHash: expectedPreviousHash };
188
+ if (anchors) {
189
+ result.anchored = { count: anchors.bySequence.size, lastSequence: anchors.lastSequence };
190
+ }
191
+ return result;
192
+ }
193
+
194
+ async function readAnchors(anchorPath) {
195
+ const bySequence = new Map();
196
+ let lastSequence = 0;
197
+ try {
198
+ const lines = createInterface({
199
+ input: createReadStream(anchorPath, { encoding: "utf8" }),
200
+ crlfDelay: Infinity
201
+ });
202
+ for await (const line of lines) {
203
+ if (!line.trim()) {
204
+ continue;
205
+ }
206
+ // A crash can leave a partial trailing anchor line; tolerate it (skip)
207
+ // rather than failing the whole verification. The chain check plus the
208
+ // remaining valid anchors still bound truncation detection.
209
+ let anchor;
210
+ try {
211
+ anchor = JSON.parse(line);
212
+ } catch {
213
+ continue;
214
+ }
215
+ if (typeof anchor.sequence === "number" && typeof anchor.eventHash === "string") {
216
+ bySequence.set(anchor.sequence, anchor.eventHash);
217
+ lastSequence = Math.max(lastSequence, anchor.sequence);
218
+ }
219
+ }
220
+ } catch (error) {
221
+ if (error.code !== "ENOENT") {
222
+ throw error;
223
+ }
224
+ }
225
+ return { bySequence, lastSequence };
133
226
  }
134
227
 
135
228
  async function buildIntegrityRecord(path, event) {
@@ -117,6 +117,51 @@ export async function buildIdentity(record, cryptoProvider) {
117
117
  };
118
118
  }
119
119
 
120
+ // PII-safe identity builder for EXTERNAL auth providers (e.g. the haechi-auth-jwt
121
+ // satellite). Core owns identity construction so the keyed-HMAC domain and the
122
+ // identity shape stay authoritative here — a satellite supplies raw claims and
123
+ // never sees or stores the IDENTITY_DOMAIN. subject/issuer become keyed HMACs;
124
+ // the raw values are never returned. Throws (fail-closed) on a missing
125
+ // cryptoProvider.hmac, an empty subject/issuer, an invalid type, bad scopes, or
126
+ // a disallowed label.
127
+ export async function buildExternalIdentity(
128
+ { provider, subject, issuer, type = "user", scopes = [], labels = {}, allowedLabelKeys = DEFAULT_ALLOWED_LABEL_KEYS },
129
+ cryptoProvider
130
+ ) {
131
+ if (typeof cryptoProvider?.hmac !== "function") {
132
+ throw new Error("buildExternalIdentity requires a cryptoProvider with hmac()");
133
+ }
134
+ if (!provider || typeof provider !== "string") {
135
+ throw new Error("identity requires a non-empty provider string");
136
+ }
137
+ if (!subject || typeof subject !== "string") {
138
+ throw new Error("identity requires a non-empty subject");
139
+ }
140
+ if (!issuer || typeof issuer !== "string") {
141
+ throw new Error("identity requires a non-empty issuer");
142
+ }
143
+ if (!VALID_IDENTITY_TYPES.has(type)) {
144
+ throw new Error(`Invalid identity type: ${type} (expected user | service | agent)`);
145
+ }
146
+ if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string" && scope.trim())) {
147
+ throw new Error("scopes must be an array of non-empty strings");
148
+ }
149
+ validateLabels(labels, allowedLabelKeys);
150
+
151
+ const subjectHash = await cryptoProvider.hmac({ data: subject, domain: IDENTITY_DOMAIN });
152
+ const issuerHash = await cryptoProvider.hmac({ data: issuer, domain: IDENTITY_DOMAIN });
153
+ return {
154
+ // Non-PII, stable per subject: derived from the keyed subject hash.
155
+ id: `${provider}:${subjectHash.slice(0, 16)}`,
156
+ type,
157
+ subjectHash,
158
+ issuerHash,
159
+ provider,
160
+ scopes,
161
+ labels
162
+ };
163
+ }
164
+
120
165
  function bearerTokenFromRequest(request) {
121
166
  const header = request?.headers?.authorization ?? request?.headers?.Authorization;
122
167
  if (typeof header !== "string") {
@@ -147,19 +147,27 @@ async function reportCommand(argv) {
147
147
  async function auditVerifyCommand(argv) {
148
148
  const options = parseOptions(argv);
149
149
  let auditPath = options.audit ?? options.path;
150
- if (!auditPath) {
150
+ let anchorPath = typeof options.anchor === "string" ? options.anchor : null;
151
+ if (!auditPath || (options.anchor === true && !anchorPath)) {
151
152
  try {
152
- auditPath = (await loadConfig(options.config ?? DEFAULT_CONFIG_PATH)).audit.path;
153
+ const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
154
+ auditPath = auditPath ?? config.audit.path;
155
+ // --anchor with no value (or no flag at all) falls back to the configured
156
+ // anchor path when anchoring is enabled.
157
+ if (!anchorPath && config.audit.anchor.mode === "file") {
158
+ anchorPath = config.audit.anchor.path;
159
+ }
153
160
  } catch {
154
- auditPath = ".haechi/audit.jsonl";
161
+ auditPath = auditPath ?? ".haechi/audit.jsonl";
155
162
  }
156
163
  }
157
164
 
158
- const result = await verifyAuditChain(auditPath);
165
+ const result = await verifyAuditChain(auditPath, { anchorPath });
159
166
  writeJson({
160
167
  ok: result.valid,
161
168
  command: "audit-verify",
162
169
  auditPath,
170
+ anchorPath,
163
171
  result
164
172
  });
165
173
  if (!result.valid) {
@@ -208,17 +216,30 @@ async function statusCommand(argv) {
208
216
  warnings.push(`key file ${config.keys.keyFile} does not exist; run haechi init`);
209
217
  }
210
218
 
211
- const audit = { path: config.audit.path, exists: false, chain: null };
219
+ const anchorEnabled = config.audit.anchor.mode === "file";
220
+ const audit = {
221
+ path: config.audit.path,
222
+ exists: false,
223
+ chain: null,
224
+ anchor: { mode: config.audit.anchor.mode, path: anchorEnabled ? config.audit.anchor.path : null }
225
+ };
212
226
  try {
213
227
  await stat(config.audit.path);
214
228
  audit.exists = true;
215
- audit.chain = await verifyAuditChain(config.audit.path);
229
+ audit.chain = await verifyAuditChain(config.audit.path, {
230
+ anchorPath: anchorEnabled ? config.audit.anchor.path : null
231
+ });
216
232
  if (!audit.chain.valid) {
217
233
  warnings.push(`audit chain verification failed: ${audit.chain.reason}`);
218
234
  }
219
235
  } catch {
220
236
  // No audit file yet is a normal pre-first-run state, not a warning.
221
237
  }
238
+ if (config.audit.anchor.mode === "none") {
239
+ warnings.push("audit.anchor.mode is none: tail truncation of the audit log cannot be detected");
240
+ } else if (config.audit.anchor.mode === "file") {
241
+ warnings.push("audit.anchor: real tail-truncation defense requires the anchor on append-only or separate media; on the same writable filesystem an attacker can truncate both files together");
242
+ }
222
243
 
223
244
  writeJson({
224
245
  ok: true,
@@ -566,9 +587,9 @@ const COMMAND_HELP = {
566
587
  summary: "Summarize audit events without raw payloads."
567
588
  },
568
589
  "audit-verify": {
569
- usage: "haechi audit-verify [--audit .haechi/audit.jsonl] [--config haechi.config.json]",
590
+ usage: "haechi audit-verify [--audit .haechi/audit.jsonl] [--anchor [path]] [--config haechi.config.json]",
570
591
  summary: "Verify the audit hash chain; print validity, record count, and head hash.",
571
- detail: "Exit 4 on a broken chain. The head hash is the value to anchor externally against tail truncation."
592
+ detail: "Exit 4 on a broken chain. With --anchor (or audit.anchor.mode: file in config) it cross-checks the anchor stream and detects tail truncation back to the last anchor. The anchor only adds real defense when kept on append-only or separate media — on the same writable filesystem an attacker can truncate both files together."
572
593
  },
573
594
  status: {
574
595
  usage: "haechi status [--config haechi.config.json]",
@@ -698,6 +719,17 @@ Tokenization (model sees token, caller sees plaintext)
698
719
  tokenVault.detokenizeResponses restore request-issued tokens in the response
699
720
  (needs responseProtection.enabled)
700
721
 
722
+ Audit integrity
723
+ audit.anchor.mode none | file | stdout (default none)
724
+ file/stdout anchor the chain head so tail
725
+ truncation is detected (haechi audit-verify --anchor).
726
+ Real defense needs the anchor on append-only or
727
+ separate media; same-filesystem anchors can be
728
+ truncated together. stdout mode is for long-running
729
+ commands (proxy), not JSON-emitting ones.
730
+ audit.anchor.path .haechi/audit.anchor.jsonl (mode: file)
731
+ audit.anchor.everyRecords anchor cadence (default 1)
732
+
701
733
  Privacy + MCP
702
734
  privacy.profile kr-pipa | eu-gdpr | us-general | null
703
735
  mcp.allowedMethods client-callable method allowlist
@@ -60,7 +60,12 @@ export function defaultConfig() {
60
60
  },
61
61
  audit: {
62
62
  sink: "jsonl",
63
- path: ".haechi/audit.jsonl"
63
+ path: ".haechi/audit.jsonl",
64
+ anchor: {
65
+ mode: "none",
66
+ path: ".haechi/audit.anchor.jsonl",
67
+ everyRecords: 1
68
+ }
64
69
  },
65
70
  tokenVault: {
66
71
  provider: "local",
@@ -117,7 +122,18 @@ export function createRuntime(config, providers = {}) {
117
122
  const normalized = normalizeConfig(config);
118
123
  const cryptoProvider = providers.cryptoProvider ?? createConfiguredCryptoProvider(normalized);
119
124
  assertProvider("cryptoProvider", cryptoProvider, ["encrypt", "decrypt"]);
120
- const auditSink = providers.auditSink ?? createJsonlAuditSink({ path: normalized.audit.path });
125
+ // hmac is only required by features that use it (bearer auth, deterministic
126
+ // tokenization). An encrypt-only external provider is valid otherwise; fail
127
+ // closed at construction rather than deep in a request if a needing feature
128
+ // is configured without it.
129
+ if (typeof cryptoProvider.hmac !== "function"
130
+ && (normalized.auth.provider === "bearer" || normalized.tokenVault.deterministic)) {
131
+ throw new Error("cryptoProvider must implement hmac() for bearer auth / deterministic tokenization");
132
+ }
133
+ const auditSink = providers.auditSink ?? createJsonlAuditSink({
134
+ path: normalized.audit.path,
135
+ anchor: normalized.audit.anchor
136
+ });
121
137
  assertProvider("auditSink", auditSink, ["record"]);
122
138
  const tokenVault = providers.tokenVault ?? createLocalTokenVault({
123
139
  path: normalized.tokenVault.path,
@@ -214,7 +230,11 @@ export function normalizeConfig(config) {
214
230
  },
215
231
  audit: {
216
232
  ...defaultConfig().audit,
217
- ...(config.audit ?? {})
233
+ ...(config.audit ?? {}),
234
+ anchor: {
235
+ ...defaultConfig().audit.anchor,
236
+ ...(config.audit?.anchor ?? {})
237
+ }
218
238
  },
219
239
  tokenVault: {
220
240
  ...defaultConfig().tokenVault,
@@ -248,6 +268,16 @@ export function normalizeConfig(config) {
248
268
  if (merged.audit.sink !== "jsonl") {
249
269
  throw new Error("Current implementation only supports jsonl audit sink");
250
270
  }
271
+ if (!["none", "file", "stdout"].includes(merged.audit.anchor.mode)) {
272
+ throw new Error(`Invalid audit.anchor.mode: ${merged.audit.anchor.mode}`);
273
+ }
274
+ if (merged.audit.anchor.mode === "file"
275
+ && (typeof merged.audit.anchor.path !== "string" || !merged.audit.anchor.path.trim())) {
276
+ throw new Error("audit.anchor.mode 'file' requires audit.anchor.path");
277
+ }
278
+ if (!Number.isInteger(merged.audit.anchor.everyRecords) || merged.audit.anchor.everyRecords < 1) {
279
+ throw new Error("audit.anchor.everyRecords must be a positive integer");
280
+ }
251
281
  if (merged.tokenVault.provider !== "local") {
252
282
  throw new Error("0.2 only supports local token vault provider");
253
283
  }
@@ -139,6 +139,113 @@ export async function initLocalKeyFile(keyFile, { force = false } = {}) {
139
139
  return { created: true, keyFile, rotated: retiredKeys.length > 0 };
140
140
  }
141
141
 
142
+ // Conformance suite for any cryptoProvider used via keys.provider: external.
143
+ // Adapter authors (e.g. a KMS satellite) run this to self-test against the
144
+ // contract. encrypt/decrypt are always required; hmac is required for
145
+ // tokenization, auth, deterministic tokens, and policy bundles — pass
146
+ // { requireHmac: false } for an encrypt-only provider.
147
+ export async function assertCryptoProviderConformance(provider, { requireHmac = true } = {}) {
148
+ const failures = [];
149
+ const check = async (name, fn) => {
150
+ try {
151
+ await fn();
152
+ } catch (error) {
153
+ failures.push(`${name}: ${error.message}`);
154
+ }
155
+ };
156
+ const assert = (condition, message) => {
157
+ if (!condition) {
158
+ throw new Error(message);
159
+ }
160
+ };
161
+
162
+ if (typeof provider?.encrypt !== "function" || typeof provider?.decrypt !== "function") {
163
+ throw new Error("cryptoProvider must implement encrypt() and decrypt()");
164
+ }
165
+
166
+ const plaintext = `conformance-${randomBytes(8).toString("hex")}@example.com`;
167
+ const aad = { purpose: "conformance", path: "messages[0].content", type: "email" };
168
+
169
+ const other = `conformance-${randomBytes(8).toString("hex")}@example.org`;
170
+
171
+ await check("encrypt/decrypt round-trip", async () => {
172
+ const envelope = await provider.encrypt({ plaintext, aad });
173
+ assert(envelope && typeof envelope === "object", "encrypt must return an envelope object");
174
+ assert(envelope.kid, "envelope must carry a key id (kid)");
175
+ assert(envelope.aadHash, "envelope must carry an aadHash");
176
+ const back = await provider.decrypt({ envelope, aad });
177
+ assert(back === plaintext, "decrypt did not return the original plaintext");
178
+ // A second, distinct plaintext rules out a decrypt that returns a fixed value.
179
+ const back2 = await provider.decrypt({ envelope: await provider.encrypt({ plaintext: other, aad }), aad });
180
+ assert(back2 === other, "decrypt did not return the second plaintext (fixed/garbage output)");
181
+ });
182
+
183
+ await check("decrypt rejects a different AAD", async () => {
184
+ const envelope = await provider.encrypt({ plaintext, aad });
185
+ let rejected = false;
186
+ try {
187
+ await provider.decrypt({ envelope, aad: { ...aad, type: "phone" } });
188
+ } catch {
189
+ rejected = true;
190
+ }
191
+ assert(rejected, "decrypt accepted a mismatched AAD (no AAD binding)");
192
+ });
193
+
194
+ await check("decrypt rejects tampered ciphertext (real AEAD authentication)", async () => {
195
+ const envelope = await provider.encrypt({ plaintext, aad });
196
+ if (typeof envelope.ct !== "string" || envelope.ct.length === 0) {
197
+ return; // provider uses a non-ct envelope shape; the AAD check above still applies
198
+ }
199
+ // Flip a byte of the ciphertext; a real AEAD provider fails the auth tag.
200
+ const buf = Buffer.from(envelope.ct, "base64url");
201
+ buf[0] ^= 0xff;
202
+ let rejected = false;
203
+ try {
204
+ await provider.decrypt({ envelope: { ...envelope, ct: buf.toString("base64url") }, aad });
205
+ } catch {
206
+ rejected = true;
207
+ }
208
+ assert(rejected, "decrypt accepted tampered ciphertext (no AEAD authentication)");
209
+ });
210
+
211
+ if (requireHmac) {
212
+ if (typeof provider.hmac !== "function") {
213
+ failures.push("hmac: provider does not implement hmac() (required for tokenization/auth/bundles)");
214
+ } else {
215
+ await check("hmac is deterministic and data-dependent", async () => {
216
+ const a = await provider.hmac({ data: "x", domain: "haechi:conformance:v1" });
217
+ const b = await provider.hmac({ data: "x", domain: "haechi:conformance:v1" });
218
+ assert(typeof a === "string" && a.length > 0, "hmac must return a non-empty string");
219
+ assert(a === b, "hmac is not deterministic for the same (data, domain)");
220
+ // Different data MUST give different output — else tokens/identities collide.
221
+ const c = await provider.hmac({ data: "y", domain: "haechi:conformance:v1" });
222
+ assert(a !== c, "hmac ignores the data argument (same output for different data)");
223
+ });
224
+ await check("hmac separates domains", async () => {
225
+ const a = await provider.hmac({ data: "x", domain: "haechi:conformance:a" });
226
+ const b = await provider.hmac({ data: "x", domain: "haechi:conformance:b" });
227
+ assert(a !== b, "hmac does not separate domains (same output for different domains)");
228
+ });
229
+ await check("hmac requires a domain", async () => {
230
+ for (const badDomain of ["", undefined, null]) {
231
+ let rejected = false;
232
+ try {
233
+ await provider.hmac({ data: "x", domain: badDomain });
234
+ } catch {
235
+ rejected = true;
236
+ }
237
+ assert(rejected, `hmac accepted an invalid domain (${JSON.stringify(badDomain)})`);
238
+ }
239
+ });
240
+ }
241
+ }
242
+
243
+ if (failures.length > 0) {
244
+ throw new Error(`cryptoProvider conformance failed:\n- ${failures.join("\n- ")}`);
245
+ }
246
+ return { ok: true };
247
+ }
248
+
142
249
  export function canonicalize(value) {
143
250
  if (Array.isArray(value)) {
144
251
  return `[${value.map((item) => canonicalize(item)).join(",")}]`;
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ // Generate or verify a SHA256SUMS manifest for release artifacts.
3
+ //
4
+ // node scripts/release-checksums.mjs <file...> # print "<hash> <name>" lines
5
+ // node scripts/release-checksums.mjs --check SHA256SUMS # verify files against a manifest
6
+ //
7
+ // Standard `<sha256-hex> <basename>` format (two spaces), so `sha256sum -c`
8
+ // and `shasum -a 256 -c` interoperate with what this prints.
9
+
10
+ import { createHash } from "node:crypto";
11
+ import { createReadStream } from "node:fs";
12
+ import { readFile } from "node:fs/promises";
13
+ import { basename, dirname, isAbsolute, join, relative } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ export async function sha256File(path) {
17
+ const hash = createHash("sha256");
18
+ for await (const chunk of createReadStream(path)) {
19
+ hash.update(chunk);
20
+ }
21
+ return hash.digest("hex");
22
+ }
23
+
24
+ export function formatManifestLine(hashHex, name) {
25
+ return `${hashHex} ${name}`;
26
+ }
27
+
28
+ export function parseManifest(text) {
29
+ return text
30
+ .split(/\r?\n/)
31
+ .map((line) => line.trim())
32
+ .filter(Boolean)
33
+ .map((line) => {
34
+ const match = /^([a-f0-9]{64})\s+(.+)$/.exec(line);
35
+ if (!match) {
36
+ throw new Error(`Malformed SHA256SUMS line: ${line}`);
37
+ }
38
+ return { hash: match[1], name: match[2] };
39
+ });
40
+ }
41
+
42
+ export async function generateManifest(files) {
43
+ const lines = [];
44
+ for (const file of files) {
45
+ lines.push(formatManifestLine(await sha256File(file), basename(file)));
46
+ }
47
+ return `${lines.join("\n")}\n`;
48
+ }
49
+
50
+ export async function verifyManifest(manifestPath) {
51
+ const baseDir = dirname(manifestPath);
52
+ const entries = parseManifest(await readFile(manifestPath, "utf8"));
53
+ const results = [];
54
+ for (const entry of entries) {
55
+ // A manifest is untrusted input: never hash a path that escapes the
56
+ // manifest's own directory (no absolute paths, no `../` traversal).
57
+ const rel = relative(baseDir, join(baseDir, entry.name));
58
+ if (isAbsolute(entry.name) || rel.startsWith("..")) {
59
+ results.push({ name: entry.name, ok: false, reason: "unsafe path" });
60
+ continue;
61
+ }
62
+ let actual = null;
63
+ try {
64
+ actual = await sha256File(join(baseDir, entry.name));
65
+ } catch (error) {
66
+ results.push({ name: entry.name, ok: false, reason: error.code === "ENOENT" ? "missing" : error.message });
67
+ continue;
68
+ }
69
+ results.push({ name: entry.name, ok: actual === entry.hash, reason: actual === entry.hash ? null : "hash mismatch" });
70
+ }
71
+ return { ok: results.every((r) => r.ok), results };
72
+ }
73
+
74
+ async function main(argv) {
75
+ if (argv[0] === "--check") {
76
+ const manifestPath = argv[1];
77
+ if (!manifestPath) {
78
+ throw new Error("--check requires a SHA256SUMS path");
79
+ }
80
+ const { ok, results } = await verifyManifest(manifestPath);
81
+ for (const r of results) {
82
+ process.stderr.write(`${r.ok ? "OK " : "FAIL"} ${r.name}${r.reason ? ` (${r.reason})` : ""}\n`);
83
+ }
84
+ process.exitCode = ok ? 0 : 1;
85
+ return;
86
+ }
87
+ if (argv.length === 0) {
88
+ throw new Error("usage: release-checksums.mjs <file...> | --check SHA256SUMS");
89
+ }
90
+ process.stdout.write(await generateManifest(argv));
91
+ }
92
+
93
+ // Run only as a CLI (not when imported by tests). fileURLToPath handles
94
+ // Windows paths and URL encoding that a raw `file://` compare would miss.
95
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
96
+ main(process.argv.slice(2)).catch((error) => {
97
+ process.stderr.write(`release-checksums: ${error.message}\n`);
98
+ process.exitCode = 1;
99
+ });
100
+ }