feat(security): hash-chained egress receipt ledger (core)

Port lib/egress-receipt from the v2 fork as TypeScript: writeReceipt
(sync, fail-closed via typed EGRESS_RECEIPT_FAILED), best-effort
writeOutcome, readLedger/listReceipts/verifyLedger, GSTACK_HOME ->
GSTACK_STATE_DIR -> ~/.gstack resolution, 0600 ledger under a 0700
security dir, and an mkdir spin lock (2.5s budget) with documented
>10s-mtime stale-lock reclaim.

Changes vs the fork:
- lastRawLine tail-reads the final 4KB instead of loading the whole
  ledger, so appends stay O(1) as the file grows.
- WARN-at-size: past 25MB writeReceipt emits one self-explanatory
  stderr warning per process (what the ledger is, how to inspect it,
  rotation TODO); verifyLedger gains a sizeWarning field. Rotation
  TODO carries the chain-genesis sketch (new generation's first record
  embeds the prior file's tail hash).

bin/gstack-egress-receipt is a bun script bridging shell callers:
write|outcome subcommands, exit 3 + EGRESS_RECEIPT_FAILED on stderr on
failure; --no-payload records sha256:null for git-class ops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 619726a3d77d987a2e50151a5727b3faaaf5fc6a)
This commit is contained in:
Garry Tan 2026-08-12 11:01:22 -07:00
parent 4e0725479f
commit d0dfdf4090
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
3 changed files with 718 additions and 0 deletions

84
bin/gstack-egress-receipt Executable file
View File

@ -0,0 +1,84 @@
#!/usr/bin/env bun
// gstack-egress-receipt — bun script that BRIDGES shell callers (the bash
// egress sinks: gstack-telemetry-sync, gstack-update-check, gstack-brain-sync,
// and the sourced helpers in gstack-egress-lib.sh) into lib/egress-receipt.ts.
//
// Usage:
// gstack-egress-receipt write --sink S --host H --class C \
// (--payload-file F | --no-payload) [--consent "key=value"]
// → prints the receipt id on stdout, exit 0.
// → exit 3 + "EGRESS_RECEIPT_FAILED: ..." on stderr when the receipt
// cannot be written. Fail-closed callers MUST then refuse the send.
//
// gstack-egress-receipt outcome <receipt-id> <status>
// → best-effort response-status record; never blocks anything.
//
// Home: GSTACK_HOME, legacy GSTACK_STATE_DIR, else ~/.gstack.
// The payload file is hashed as-is: pass the SAME file to curl (`-d @file`)
// so the receipt hash matches the exact bytes sent (scan-at-sink precedent).
import fs from 'node:fs';
import { EGRESS_RECEIPT_FAILED, sha256Hex, writeOutcome, writeReceipt } from '../lib/egress-receipt';
function parseArgs(args: string[], valueFlags: string[], boolFlags: string[]) {
const values = new Map<string, string>();
const flags = new Set<string>();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (valueFlags.includes(arg)) {
const value = args[++index];
if (value == null) usage(`${arg} requires a value`);
values.set(arg, value);
} else if (boolFlags.includes(arg)) {
flags.add(arg);
} else {
usage(`unknown option: ${arg}`);
}
}
return { values, flags };
}
function usage(message: string): never {
process.stderr.write(`gstack-egress-receipt: ${message}\n`);
process.exit(2);
}
const [command, ...rest] = process.argv.slice(2);
if (command === 'write') {
const { values, flags } = parseArgs(rest,
['--sink', '--host', '--class', '--payload-file', '--consent'], ['--no-payload']);
const payloadFile = values.get('--payload-file');
if (!payloadFile && !flags.has('--no-payload')) usage('write requires --payload-file or --no-payload');
try {
let bytes = 0;
let sha256: string | null = null; // --no-payload = git-class op, subprocess owns the bytes
if (payloadFile) {
const payload = fs.readFileSync(payloadFile);
bytes = payload.byteLength;
sha256 = sha256Hex(payload);
}
const { id } = writeReceipt({
sink: values.get('--sink') as string,
host: values.get('--host') as string,
payloadClass: values.get('--class') as string,
bytes,
sha256,
consent: values.get('--consent') ?? 'unspecified',
});
process.stdout.write(`${id}\n`);
} catch (error) {
process.stderr.write(`${EGRESS_RECEIPT_FAILED}: ${(error as Error)?.message ?? error}\n`);
process.exit(3);
}
} else if (command === 'outcome') {
const [receipt, status, ...extra] = rest;
if (!receipt || !status || extra.length) usage('Usage: gstack-egress-receipt outcome <receipt-id> <status>');
try {
writeOutcome({ receipt, status });
} catch {
// Best-effort: the pre-send receipt is the invariant, the outcome is bookkeeping.
}
} else {
usage('Usage: gstack-egress-receipt write|outcome ...');
}

376
lib/egress-receipt.ts Normal file
View File

@ -0,0 +1,376 @@
/**
* egress-receipt hash-chained, content-free receipts for every
* gstack-initiated off-machine send (`~/.gstack/security/egress.jsonl`, 0600).
*
* THREAT MODEL: the egress ledger is forensic observability it records
* ATTEMPTED egress so accidents are auditable; it is not an exfiltration
* control. Receipts are written before send, outcomes are best-effort, and
* fail-open sinks can send unrecorded with a warning.
*
* Semantics:
* - Receipt-before-send: the receipt line is appended BEFORE the network
* call. Fail-closed sinks MUST refuse the send with the typed code
* EGRESS_RECEIPT_FAILED when it cannot be written; fail-open sinks warn
* on stderr and proceed.
* - Content-free: never payload text, never credentials a sha256 of the
* exact bytes sent plus a byte count only (semantic-reviews.jsonl
* precedent). Sinks where a subprocess/SDK owns the bytes record
* sha256: null.
* - Tamper-evident: each line carries `prev` = sha256 of the previous raw
* line ("" for line 1). `verifyLedger` recomputes the chain.
*
* Node builtins only, so bun TS binaries and the compiled browse binary can
* both import it (same constraint as browse/src/security.ts: no native
* modules).
*/
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
export const EGRESS_RECEIPT_FAILED = 'EGRESS_RECEIPT_FAILED';
const SHA256_HEX = /^[0-9a-f]{64}$/;
/**
* WARN-at-size threshold. Above this the ledger still appends (never blocks
* on size), but writeReceipt emits one stderr warning per process so the
* user learns the file exists and how to inspect it before it gets silly.
*/
export const LEDGER_WARN_BYTES = 25 * 1024 * 1024;
/**
* Tail window for reading the last raw line. Receipt lines are ~300 bytes;
* 4KB covers any legal line with an order of magnitude to spare.
*/
const TAIL_READ_BYTES = 4096;
// TODO(rotation): ledger rotation. Chain-genesis sketch: when the ledger
// exceeds the size threshold, rename it to egress.jsonl.1 and start a new
// generation whose FIRST record embeds `genesis: sha256(<tail raw line of
// the prior file>)`, so verifyLedger can walk generations end-to-end
// (verify each file's internal chain, then check each genesis hash against
// the previous generation's last line). Until then we warn at 25MB.
type Env = Record<string, string | undefined>;
export interface WriteReceiptOptions {
/** gstack home; resolved from env when omitted */
home?: string;
/** env for home resolution (tests) */
env?: Env;
/** which gstack component is sending */
sink: string;
/** destination host[:port] */
host: string;
/** content-free payload description */
payloadClass: string;
/** exact byte count sent (0 for bodyless requests) */
bytes?: number;
/** sha256 hex of the exact bytes sent; null when a subprocess/SDK owns the bytes */
sha256?: string | null;
/** the consent key+value that authorizes this send */
consent: string;
}
export interface WriteOutcomeOptions {
home?: string;
env?: Env;
/** receipt id returned by writeReceipt */
receipt: string;
status?: string | number;
}
export interface LedgerLine {
lineNo: number;
raw: string;
record: Record<string, unknown> | null;
}
export interface VerifyResult {
ok: boolean;
count: number;
brokenLine: number | null;
reason: string | null;
/** present when the ledger file exceeds LEDGER_WARN_BYTES */
sizeWarning: string | null;
}
/**
* Same resolution order as the rest of gstack (shell sinks, selection code):
* GSTACK_HOME, legacy GSTACK_STATE_DIR, then $HOME/.gstack.
*/
export function resolveEgressHome(env: Env = process.env): string {
const configured = env.GSTACK_HOME || env.GSTACK_STATE_DIR;
if (configured) return path.resolve(configured);
return path.join(env.HOME || os.homedir(), '.gstack');
}
export function egressLedgerPath(home: string): string {
return path.join(home, 'security', 'egress.jsonl');
}
export function sha256Hex(data: string | Uint8Array): string {
return createHash('sha256').update(data).digest('hex');
}
function receiptError(message: string, cause?: unknown): Error & { code: string } {
const error = (cause === undefined ? new Error(message) : new Error(message, { cause })) as Error & { code: string };
error.code = EGRESS_RECEIPT_FAILED;
return error;
}
function requireString(value: unknown, name: string): string {
if (typeof value !== 'string' || !value) throw receiptError(`Egress receipt requires a non-empty ${name}`);
return value;
}
/**
* mkdir spin lock, ~2.5s budget. Egress events are rare (minutes apart); the
* lock only protects the read-last-line append window.
*
* Stale-lock reclaim: a crashed writer strands the lock dir. Once the spin
* budget is exhausted, a lock dir whose mtime is >10s old is stale by
* definition (appends take milliseconds), so the waiter removes it and
* retries instead of failing. The rmdir/stat races with a concurrent
* reclaimer or the owner's own cleanup are harmless losers just loop.
*/
function withLedgerLock<T>(ledger: string, callback: () => T): T {
const lock = `${ledger}.lock`;
const deadline = Date.now() + 2500;
for (;;) {
try {
fs.mkdirSync(lock);
break;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code !== 'EEXIST') throw error;
if (Date.now() > deadline) {
try {
const age = Date.now() - fs.statSync(lock).mtimeMs;
if (age > 10_000) { fs.rmdirSync(lock); continue; }
} catch { /* raced with the owner's cleanup — retry */ }
throw receiptError(`Egress ledger is locked: ${lock}`);
}
// Sync sleep (node + bun): the API is sync on purpose so shell, bun,
// and node callers all share one implementation.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
}
}
try {
return callback();
} finally {
try { fs.rmdirSync(lock); } catch { /* best-effort unlock */ }
}
}
/**
* Last raw line via a tail read: open the file, read the final
* TAIL_READ_BYTES, take the last newline-terminated chunk. Never loads the
* whole ledger, so appends stay O(1) as the file grows.
*/
function lastRawLine(ledger: string): string | null {
let fd: number;
try {
fd = fs.openSync(ledger, 'r');
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return null;
throw error;
}
try {
const size = fs.fstatSync(fd).size;
if (size === 0) return null;
const length = Math.min(size, TAIL_READ_BYTES);
const buffer = Buffer.alloc(length);
fs.readSync(fd, buffer, 0, length, size - length);
const tail = buffer.toString('utf8');
const lines = tail.split('\n').filter((line) => line.length > 0);
return lines.length ? lines[lines.length - 1] : null;
} finally {
fs.closeSync(fd);
}
}
let warnedLedgerSize = false;
/** Test-only: re-arm the once-per-process size warning. */
export function resetLedgerSizeWarningForTests(): void {
warnedLedgerSize = false;
}
function warnLedgerSizeOnce(ledger: string): void {
let size: number;
try {
size = fs.statSync(ledger).size;
} catch {
return; // no file yet — nothing to warn about
}
if (size <= LEDGER_WARN_BYTES || warnedLedgerSize) return;
warnedLedgerSize = true;
process.stderr.write(ledgerSizeWarning(ledger, size) + '\n');
}
/**
* Self-explanatory size warning: says what the ledger is (records what
* gstack ATTEMPTS to send off-machine), how to inspect it, and that
* trimming arrives with rotation.
*/
export function ledgerSizeWarning(ledger: string, size: number): string {
const mb = (size / (1024 * 1024)).toFixed(1);
return (
`gstack: egress ledger is large (${mb}MB): ${ledger}. ` +
`This file records what gstack ATTEMPTS to send off-machine (content-free receipts, for auditing). ` +
`Inspect it with 'gstack-egress list'. Trimming arrives with ledger rotation (TODO); until then it only grows.`
);
}
function appendChained(
homeOrNull: string | null,
record: Record<string, unknown>,
env?: Env,
): { id: string; path: string } {
const home = homeOrNull ?? resolveEgressHome(env);
const ledger = egressLedgerPath(home);
try {
fs.mkdirSync(path.dirname(ledger), { recursive: true, mode: 0o700 });
return withLedgerLock(ledger, () => {
const previous = lastRawLine(ledger);
const line = JSON.stringify({ ...record, prev: previous == null ? '' : sha256Hex(previous) });
const existed = fs.existsSync(ledger);
fs.appendFileSync(ledger, `${line}\n`, { mode: 0o600 });
if (!existed) fs.chmodSync(ledger, 0o600); // umask must not weaken the ledger
return { id: sha256Hex(line), path: ledger };
});
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === EGRESS_RECEIPT_FAILED) throw error;
throw receiptError(
`Egress receipt could not be written to ${ledger}: ${(error as Error)?.message ?? error}`,
error,
);
}
}
/**
* Append one content-free receipt BEFORE a network send.
*
* @returns id = sha256 of the written line (for writeOutcome)
* @throws Error with code EGRESS_RECEIPT_FAILED fail-closed callers must
* refuse the send; fail-open callers warn and proceed
*/
export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: string } {
const sink = requireString(opts.sink, 'sink');
const host = requireString(opts.host, 'host');
const payloadClass = requireString(opts.payloadClass, 'payloadClass');
const consent = requireString(opts.consent, 'consent');
const bytes = opts.bytes ?? 0;
if (!Number.isSafeInteger(bytes) || bytes < 0) throw receiptError('Egress receipt bytes must be a non-negative integer');
const sha256 = opts.sha256 ?? null;
if (sha256 !== null && !SHA256_HEX.test(String(sha256))) throw receiptError('Egress receipt sha256 must be 64 lowercase hex chars or null');
const home = opts.home ?? resolveEgressHome(opts.env);
warnLedgerSizeOnce(egressLedgerPath(home));
return appendChained(home, {
ts: new Date().toISOString(),
type: 'egress',
sink,
host,
payload_class: payloadClass,
bytes,
sha256,
consent,
}, opts.env);
}
/**
* Append the response status for an earlier receipt (best-effort companion
* record the pre-send receipt is the invariant, the outcome is
* bookkeeping). Chained like every other line.
*/
export function writeOutcome(opts: WriteOutcomeOptions): { id: string; path: string } {
const receipt = requireString(opts.receipt, 'receipt id');
return appendChained(opts.home ?? null, {
ts: new Date().toISOString(),
type: 'outcome',
receipt,
status: String(opts.status ?? 'unknown'),
}, opts.env);
}
/** Raw parsed lines: [{lineNo, raw, record|null}]. Missing ledger → []. */
export function readLedger(home: string): LedgerLine[] {
const ledger = egressLedgerPath(home);
let content: string;
try {
content = fs.readFileSync(ledger, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return [];
throw error;
}
return content.split('\n').filter((line) => line.length > 0).map((raw, index) => {
let record: Record<string, unknown> | null = null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') record = parsed;
} catch { /* malformed line — verifyLedger reports it */ }
return { lineNo: index + 1, raw, record };
});
}
export interface Receipt {
ts: string;
type: 'egress';
sink: string;
host: string;
payload_class: string;
bytes: number;
sha256: string | null;
consent: string;
prev: string;
id: string;
status: string | null;
}
/** Receipts with their joined outcome status (`status: null` = none recorded). */
export function listReceipts(home: string): Receipt[] {
const lines = readLedger(home);
const receipts: Receipt[] = [];
const byId = new Map<string, Receipt>();
for (const { raw, record } of lines) {
if (!record) continue;
if (record.type === 'egress') {
const entry = { ...(record as unknown as Omit<Receipt, 'id' | 'status'>), id: sha256Hex(raw), status: null };
receipts.push(entry);
byId.set(entry.id, entry);
} else if (record.type === 'outcome' && byId.has(record.receipt as string)) {
byId.get(record.receipt as string)!.status = String(record.status);
}
}
return receipts;
}
/**
* Recompute the hash chain. `brokenLine` is the 1-indexed first line whose
* `prev` no longer matches the sha256 of the previous raw line (or that
* fails to parse). `sizeWarning` is set when the ledger exceeds
* LEDGER_WARN_BYTES.
*/
export function verifyLedger(home: string): VerifyResult {
const ledger = egressLedgerPath(home);
let sizeWarning: string | null = null;
try {
const size = fs.statSync(ledger).size;
if (size > LEDGER_WARN_BYTES) sizeWarning = ledgerSizeWarning(ledger, size);
} catch { /* missing ledger — verify of an empty chain below */ }
const lines = readLedger(home);
let previousRaw: string | null = null;
for (const { lineNo, raw, record } of lines) {
if (!record || typeof record.prev !== 'string') {
return { ok: false, count: lines.length, brokenLine: lineNo, reason: 'unparseable or missing prev', sizeWarning };
}
const expected = previousRaw == null ? '' : sha256Hex(previousRaw);
if (record.prev !== expected) {
return { ok: false, count: lines.length, brokenLine: lineNo, reason: 'prev hash does not match previous line', sizeWarning };
}
previousRaw = raw;
}
return { ok: true, count: lines.length, brokenLine: null, reason: null, sizeWarning };
}

258
test/egress-receipt.test.ts Normal file
View File

@ -0,0 +1,258 @@
/**
* Egress receipts chain, fail-closed, verify, shell bridge. Free tier, no network.
*
* THREAT MODEL: the egress ledger is forensic observability it records
* ATTEMPTED egress so accidents are auditable; it is not an exfiltration
* control.
*
* Pins the auditor contract:
* - receipt-before-send fail-closed (EGRESS_RECEIPT_FAILED, no ledger = no send)
* - content-free lines chained by prev = sha256(previous raw line)
* - tail-read correctness (last line found without loading the whole file)
* - abandoned-lock reclaim (stale lock dir >10s old is removed, not fatal)
* - WARN-at-size (one self-explanatory stderr warning per process >25MB)
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import {
EGRESS_RECEIPT_FAILED,
LEDGER_WARN_BYTES,
egressLedgerPath,
ledgerSizeWarning,
listReceipts,
resetLedgerSizeWarningForTests,
sha256Hex,
verifyLedger,
writeOutcome,
writeReceipt,
} from '../lib/egress-receipt';
const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..');
let home: string;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-egress-'));
});
afterEach(() => {
try { fs.chmodSync(path.join(home, 'security'), 0o700); } catch {} // undo fail-closed fixtures
fs.rmSync(home, { recursive: true, force: true });
});
describe('egress receipt library', () => {
test('receipts chain: prev = sha256 of the previous raw line, "" for line 1', () => {
writeReceipt({ home, sink: 'a', host: 'h1', payloadClass: 'c', bytes: 3, sha256: sha256Hex('abc'), consent: 'k=v' });
writeReceipt({ home, sink: 'b', host: 'h2', payloadClass: 'c', bytes: 0, sha256: null, consent: 'k=v' });
const lines = fs.readFileSync(egressLedgerPath(home), 'utf-8').trim().split('\n');
expect(lines.length).toBe(2);
const first = JSON.parse(lines[0]);
const second = JSON.parse(lines[1]);
expect(first.prev).toBe('');
expect(second.prev).toBe(sha256Hex(lines[0]));
expect(first.sha256).toBe(sha256Hex('abc'));
expect(second.sha256).toBeNull();
expect(verifyLedger(home)).toMatchObject({ ok: true, count: 2 });
});
test('ledger is 0600 and the security dir 0700', () => {
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
const ledger = egressLedgerPath(home);
expect(fs.statSync(ledger).mode & 0o777).toBe(0o600);
expect(fs.statSync(path.dirname(ledger)).mode & 0o777).toBe(0o700);
});
test('fail-closed: unwritable security dir throws typed EGRESS_RECEIPT_FAILED', () => {
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod is advisory there
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
fs.chmodSync(path.join(home, 'security'), 0o500);
try {
expect(() =>
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' }),
).toThrow();
try {
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
} catch (err: any) {
expect(err.code).toBe(EGRESS_RECEIPT_FAILED);
}
} finally {
fs.chmodSync(path.join(home, 'security'), 0o700);
}
});
test('outcome records join back onto their receipt in listReceipts', () => {
const { id } = writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
writeOutcome({ home, receipt: id, status: 204 });
const receipts = listReceipts(home);
expect(receipts.length).toBe(1);
expect(receipts[0].status).toBe('204');
expect(verifyLedger(home)).toMatchObject({ ok: true, count: 2 });
});
test('tampering with a middle line breaks verification at that line', () => {
for (let i = 0; i < 3; i += 1) {
writeReceipt({ home, sink: `s${i}`, host: 'h', payloadClass: 'c', consent: 'telemetry=community' });
}
const ledger = egressLedgerPath(home);
const lines = fs.readFileSync(ledger, 'utf-8').trim().split('\n');
lines[1] = lines[1].replace('community', 'communitX');
fs.writeFileSync(ledger, `${lines.join('\n')}\n`);
// Line 2's edited bytes no longer hash to line 3's recorded prev.
expect(verifyLedger(home)).toMatchObject({ ok: false, brokenLine: 3 });
});
test('validation rejects garbage before touching the ledger', () => {
expect(() => writeReceipt({ home, sink: '', host: 'h', payloadClass: 'c', consent: 'k' } as any)).toThrow();
expect(() => writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'c', consent: 'k', bytes: -1 })).toThrow();
expect(() => writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'c', consent: 'k', sha256: 'nothex' })).toThrow();
expect(fs.existsSync(egressLedgerPath(home))).toBe(false);
});
test('abandoned lock: a stale lock dir (>10s-old mtime) is reclaimed by the next writer', () => {
// First write creates the security dir so the lock path's parent exists.
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
const lock = `${egressLedgerPath(home)}.lock`;
fs.mkdirSync(lock);
const old = new Date(Date.now() - 60_000);
fs.utimesSync(lock, old, old);
const started = Date.now();
const { id } = writeReceipt({ home, sink: 'b', host: 'h', payloadClass: 'c', consent: 'k=v' });
expect(id).toMatch(/^[0-9a-f]{64}$/);
// Spin budget is 2.5s; reclaim happens right after budget exhaustion.
expect(Date.now() - started).toBeLessThan(10_000);
expect(fs.existsSync(lock)).toBe(false);
expect(verifyLedger(home)).toMatchObject({ ok: true, count: 2 });
});
test('a fresh (recent-mtime) lock held past the budget fails closed instead of being stolen', () => {
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
const lock = `${egressLedgerPath(home)}.lock`;
fs.mkdirSync(lock);
// Keep the mtime fresh so the reclaim path never fires: refresh it in the
// background while the writer spins out its 2.5s budget.
const refresher = setInterval(() => {
const now = new Date();
try { fs.utimesSync(lock, now, now); } catch { /* test teardown race */ }
}, 1000);
try {
expect(() =>
writeReceipt({ home, sink: 'b', host: 'h', payloadClass: 'c', consent: 'k=v' }),
).toThrow(/locked/);
} finally {
clearInterval(refresher);
fs.rmdirSync(lock);
}
}, 15_000);
test('tail-read: last line is found correctly on a multi-record ledger larger than the tail window', () => {
// 30 records ≈ 9KB > the 4KB tail window, so the append path must find
// the true last line from a partial read.
for (let i = 0; i < 30; i += 1) {
writeReceipt({
home,
sink: `sink-${i}`,
host: 'h',
payloadClass: `class-${'x'.repeat(200)}-${i}`,
consent: 'k=v',
});
}
const ledger = egressLedgerPath(home);
expect(fs.statSync(ledger).size).toBeGreaterThan(4096);
const result = verifyLedger(home);
expect(result).toMatchObject({ ok: true, count: 30 });
});
test('chain verify stays intact across 100+ records', () => {
for (let i = 0; i < 120; i += 1) {
writeReceipt({ home, sink: `s${i}`, host: 'h', payloadClass: 'c', consent: 'k=v' });
if (i % 10 === 0) writeOutcome({ home, receipt: 'f'.repeat(64), status: 200 });
}
const result = verifyLedger(home);
expect(result.ok).toBe(true);
expect(result.count).toBe(132);
expect(listReceipts(home).length).toBe(120);
});
test('WARN-at-size: >25MB ledger emits one self-explanatory stderr warning per process', () => {
const ledger = egressLedgerPath(home);
fs.mkdirSync(path.dirname(ledger), { recursive: true, mode: 0o700 });
// Grow the file past the threshold with valid-looking filler; the warning
// keys off file size only.
const filler = `${JSON.stringify({ type: 'egress', pad: 'x'.repeat(1024) })}\n`;
const chunk = filler.repeat(1024); // ~1MB
const writes = Math.ceil(LEDGER_WARN_BYTES / chunk.length) + 1;
for (let i = 0; i < writes; i += 1) fs.appendFileSync(ledger, chunk);
expect(fs.statSync(ledger).size).toBeGreaterThan(LEDGER_WARN_BYTES);
resetLedgerSizeWarningForTests();
const captured: string[] = [];
const originalWrite = process.stderr.write.bind(process.stderr);
(process.stderr as any).write = (chunk: string) => { captured.push(String(chunk)); return true; };
try {
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
writeReceipt({ home, sink: 'b', host: 'h', payloadClass: 'c', consent: 'k=v' });
} finally {
(process.stderr as any).write = originalWrite;
}
const warnings = captured.filter((c) => c.includes('egress ledger is large'));
expect(warnings.length).toBe(1); // once per process, not per write
// Self-explanatory shape: what the ledger is, how to inspect, what's coming.
expect(warnings[0]).toContain('ATTEMPTS to send off-machine');
expect(warnings[0]).toContain('gstack-egress list');
expect(warnings[0]).toContain('rotation');
expect(warnings[0]).toContain(ledger);
// verifyLedger surfaces the same warning as data.
const message = ledgerSizeWarning(ledger, fs.statSync(ledger).size);
expect(message).toContain('MB');
expect(verifyLedger(home).sizeWarning).toBe(message);
});
});
describe('gstack-egress-receipt shell bridge', () => {
const bin = path.join(ROOT, 'bin', 'gstack-egress-receipt');
test('write hashes the exact payload file, prints the receipt id; outcome joins', () => {
const payload = path.join(home, 'payload.json');
fs.writeFileSync(payload, '[{"v":1}]');
const write = spawnSync(bin, ['write', '--sink', 'telemetry-sync', '--host', '127.0.0.1:8399',
'--class', 'telemetry-events', '--payload-file', payload, '--consent', 'telemetry=community'],
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
expect(write.status).toBe(0);
const id = write.stdout.trim();
expect(id).toMatch(/^[0-9a-f]{64}$/);
const outcome = spawnSync(bin, ['outcome', id, '204'],
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
expect(outcome.status).toBe(0);
const receipts = listReceipts(home);
expect(receipts.length).toBe(1);
expect(receipts[0].bytes).toBe(9);
expect(receipts[0].sha256).toBe(sha256Hex('[{"v":1}]'));
expect(receipts[0].status).toBe('204');
});
test('--no-payload records sha256:null (git-class: a subprocess owns the bytes)', () => {
const write = spawnSync(bin, ['write', '--sink', 'brain-sync', '--host', 'github.com',
'--class', 'git-push', '--no-payload', '--consent', 'artifacts_sync_mode=auto'],
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
expect(write.status).toBe(0);
const receipts = listReceipts(home);
expect(receipts.length).toBe(1);
expect(receipts[0].sha256).toBeNull();
expect(receipts[0].bytes).toBe(0);
});
test('write exits 3 with EGRESS_RECEIPT_FAILED when the ledger is unwritable', () => {
if (process.platform === 'win32' || process.getuid?.() === 0) return;
fs.mkdirSync(path.join(home, 'security'), { recursive: true, mode: 0o500 });
const write = spawnSync(bin, ['write', '--sink', 's', '--host', 'h', '--class', 'c', '--no-payload'],
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
expect(write.status).toBe(3);
expect(write.stderr).toContain('EGRESS_RECEIPT_FAILED');
fs.chmodSync(path.join(home, 'security'), 0o700);
});
});