diff --git a/CHANGELOG.md b/CHANGELOG.md index e8cde63..d6a3392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,23 @@ All notable changes to EigenScript are documented here. `chr of n` == `str_from_bytes of [n]` across the shared range. ### Added +- **Trace-tape format versioning** (#411): every tape now opens with a + `V ` header (both the hosted `EIGS_TRACE` file and the + embed sink), and replay refuses a tape whose format or runtime version + doesn't match the running binary — missing/malformed/mismatched headers + exit with status 3 (hosted) or reject the tape at `eigs_set_replay_tape` + (embed, returns 0). Version-and-reject, no migration, no override flag; + the decision and the residual dev-build honesty gap are recorded in + docs/TRACE.md. Pre-#411 (headerless) tapes are no longer replayable. + Hardened by adversarial review: an unopenable `EIGS_REPLAY` path is now + fatal too (was warn-and-run-live — the same silent divergence); the + embed install validates *every* session header of a concatenated + journal up front and swaps atomically (a refused/OOM install leaves the + previously active tape and strict mode untouched, and can never abort + the host mid-eval); torn mid-stream `V` lines refuse instead of + slipping through the record filter; and `--version`/`--help` are + handled before `trace_init` so a stale exported `EIGS_REPLAY` can't + take down pure queries. - **W016 — bare-predicate aliasing fence** (#396, completing it): a bare trajectory predicate outside a loop condition (`if stable:`, `ok is converged`, `return diverging`) reads the last-observed binding — diff --git a/docs/TRACE.md b/docs/TRACE.md index da25992..ff8d69c 100644 --- a/docs/TRACE.md +++ b/docs/TRACE.md @@ -23,10 +23,11 @@ predicted-not-taken load + branch. ## Tape Format -The tape is plain text, one record per line, three record kinds: +The tape is plain text, one record per line, four record kinds: | Record | Meaning | |--------|---------| +| `V ` | Version header — always the first record (e.g. `V 1 0.26.0`). Stamped once per tape-open; a journal appended across sessions carries one per session. See [Format Versioning](#format-versioning-411). | | `L ` | Source-line event (from `OP_LINE`). Adjacent duplicate lines with no `A`/`N` between them are deduped — the compiler emits per-statement LINEs and bare repeats are noise. | | `A =` | Assignment delta: a top-level binding changed. | | `N =` | Nondeterministic builtin return — the replay-determinism substrate. | @@ -145,11 +146,60 @@ that actually failed, not a fresh one that might not. Under `--json`, each result carries a `"tape"` field for CI to archive as an artifact; the human form prints the replay line. -**Same-binary caveat.** A tape is a reproducer for the interpreter build that -recorded it. Until tape-format versioning lands (#411), do not replay a tape -against a different EigenScript version — archive the tape *and* the version -that produced it. `EIGS_REPLAY_STRICT=1` turns any record/replay mismatch into a -loud abort rather than a silent divergence. +**Same-version enforcement.** A tape is a reproducer for the EigenScript +version that recorded it, and since #411 the runtime enforces that +mechanically: replaying an archived tape after a version bump refuses loudly +instead of silently diverging (see [Format Versioning](#format-versioning-411)). +Archiving jobs need only keep the tape — it names its own version on line 1. +`EIGS_REPLAY_STRICT=1` additionally turns any record/replay *name* mismatch +into a loud abort. + +## Format Versioning (#411) + +The tape is a persisted artifact — CI archives failure tapes, bundles may +carry one, embedders journal them — so it names its own provenance. **The +decision: version-stamped tapes, refuse-on-mismatch. There is no +compatibility promise, ever** — a tape is valid for exactly the format +*and* runtime version that recorded it, matching the no-backcompat policy +everywhere else in the runtime (version-and-reject, never migrate). + +- Every tape's first record is `V `. The format integer + (`TRACE_FORMAT_VERSION` in `src/trace.h`) bumps on **any** change to the + tape encoding; the runtime string is the recording binary's version. +- On replay, a missing header, a malformed (torn) header, a different + format version, a different runtime version, an empty tape, or an + unopenable `EIGS_REPLAY` path each refuse loudly — hosted replay exits + with status 3 (the replay-divergence status), and `eigs_set_replay_tape` + returns 0 without installing the tape. Replay never falls back to a + live run: the user asked for a replay, and a plausible-looking live run + is exactly the silent divergence the header exists to prevent. +- Mid-stream `V` records are legal (a journal appended across sessions + carries one per session) but each must match, or replay aborts. The + embed seam validates **every** session header at install time, so a + mixed-version journal is refused up front (return 0, the previously + installed tape left untouched) rather than aborting the host mid-run; + the mid-stream abort therefore only fires for `EIGS_REPLAY` files. + +``` +$ EIGS_REPLAY=old-v0.26.0.tape eigenscript sim.eigs +trace: tape recorded on EigenScript 0.26.0, this binary is 0.27.0 — +refusing to replay; a tape is valid only for the version that recorded +it (docs/TRACE.md) +$ echo $? +3 +``` + +There is no override flag. The tape is plain text: if you are certain a +tape is valid for this binary (say, two dev builds of the same tree), +editing line 1 is the override — a deliberate, visible act. The residual +honesty gap runs the other way: version *equality* is necessary, not +sufficient. Two different dev builds can share the string `dev` (or an +unreleased version), and the header cannot tell them apart — release +boundaries are enforced; dev builds are on their honor. + +Regression coverage: the `version refuse` cases in `tests/test_replay.sh` +plant each mismatch class (format, runtime, missing header, empty file) +and require the exit-3 refusal. ## Temporal Interrogatives and `state_at` diff --git a/src/eigs_embed.h b/src/eigs_embed.h index 156ace1..32e8571 100644 --- a/src/eigs_embed.h +++ b/src/eigs_embed.h @@ -146,17 +146,29 @@ void eigs_value_buffer_set(EigsValue *v, int i, double x); /* OOB: no- /* ---- Trace tape (record + replay) ---------------------------------- * The runtime's replay tape (hosted: EIGS_TRACE / EIGS_REPLAY files), * reachable without a filesystem. Install a sink and every tape record - * (L/A/N lines) streams to it as bytes — the sink receives complete + * (V/L/A/N lines) streams to it as bytes — the sink receives complete * newline-terminated lines (an oversized record arrives in ordered - * chunks). Installing a sink ENABLES recording; passing NULL stops it. - * The sink fires from inside evaluation — do not re-enter the runtime - * from it; buffer the bytes and act between evals. + * chunks). Installing a sink ENABLES recording — the first bytes are the + * version header (`V `, #411); a journal appended + * across several installs carries one header per session. Passing NULL + * stops recording. The sink fires from inside evaluation — do not + * re-enter the runtime from it; buffer the bytes and act between evals. * * eigs_set_replay_tape hands the whole tape back as the replay source - * (bytes are copied; returns 0 on OOM; NULL clears). While replay is - * active, nondet builtins return the tape's recorded N values in order - * instead of consulting their live sources. `strict` makes a tape/ - * program name mismatch fatal instead of logged-and-tolerated. + * (bytes are copied; NULL clears). Returns 0 on OOM or when the tape is + * REFUSED: a tape whose version headers are missing, torn, or don't + * match this runtime (format and version both) is never installed — + * version-and-reject, no migration (#411, docs/TRACE.md; reason goes to + * stderr). EVERY session header in a concatenated journal is validated + * at install time, so refusal always happens here, not mid-eval. The + * call is atomic: on any 0 return the previously installed tape (and + * its strict mode) survives untouched. An empty journal is refused — + * a fresh store has nothing to replay; install nothing instead. Store + * tape bytes verbatim: strip a header and replay refuses the journal. + * While replay is active, nondet builtins return the tape's recorded N + * values in order instead of consulting their live sources. `strict` + * makes a tape/program name mismatch fatal instead of + * logged-and-tolerated. * * Host builtins participate through the take/record pair — the same * contract the runtime's own nondet builtins use: diff --git a/src/embed_smoke.c b/src/embed_smoke.c index 26428e8..4a8d01e 100644 --- a/src/embed_smoke.c +++ b/src/embed_smoke.c @@ -246,6 +246,35 @@ int main(void) { CHECK(r != NULL && eigs_value_as_num(r) == 104.0, "clear: live again"); if (r) eigs_value_release(r); + /* --- #411 refusal contract: install-time, atomic, return 0. ------ */ + /* Headerless tape: refused, nothing installed, live source intact. */ + static const char noh[] = "N host_sensor=999\n"; + CHECK(eigs_set_replay_tape(noh, sizeof noh - 1, 0) == 0, + "headerless tape refused (return 0)"); + r = eigs_eval_string("host_sensor of []"); + CHECK(r != NULL && eigs_value_as_num(r) == 105.0, + "refused install: live source untouched"); + if (r) eigs_value_release(r); + + /* Mixed-version concatenated journal: a later session's mismatched + * header refuses the WHOLE install up front — never a mid-eval abort. */ + static char mixed[sizeof g_tape + 64]; + size_t ml = g_tape_len; + memcpy(mixed, g_tape, ml); + ml += (size_t)snprintf(mixed + ml, sizeof mixed - ml, + "V 1 0.0.0-elsewhere\nN host_sensor=888\n"); + CHECK(eigs_set_replay_tape(mixed, ml, 0) == 0, + "mixed-version journal refused at install"); + + /* Atomic swap: a refused install leaves the ACTIVE tape serving. */ + CHECK(eigs_set_replay_tape(g_tape, g_tape_len, 0) == 1, "good tape re-set"); + CHECK(eigs_set_replay_tape(noh, sizeof noh - 1, 0) == 0, "swap refused"); + r = eigs_eval_string("host_sensor of []"); + CHECK(r != NULL && eigs_value_as_num(r) == 100.0, + "refused swap: previous tape still serves"); + if (r) eigs_value_release(r); + CHECK(eigs_set_replay_tape(NULL, 0, 0) == 1, "replay cleared (411 block)"); + /* --- Source provider: import resolves from the embedder. --------- */ eigs_set_source_provider(smoke_provider, NULL); r = eigs_eval_string("import smokemod\nsmokemod[\"answer\"]"); diff --git a/src/main.c b/src/main.c index eab6234..f1b3ba4 100644 --- a/src/main.c +++ b/src/main.c @@ -187,10 +187,9 @@ int main(int argc, char **argv) { argc -= 2; } - trace_init(); - atexit(trace_shutdown); - - /* --version doesn't touch the runtime; handle it before any state. */ + /* --version doesn't touch the runtime; handle it before any state — + * including trace_init: a stale EIGS_REPLAY in the environment is a + * fatal header check (#411) and must not take down pure queries. */ if (argc >= 2 && (strcmp(argv[1], "--version") == 0 || strcmp(argv[1], "-v") == 0)) { printf("%s\n", EIGENSCRIPT_VERSION); return 0; @@ -222,6 +221,9 @@ int main(int argc, char **argv) { return 0; } + trace_init(); + atexit(trace_shutdown); + /* --fmt is a pure source transformer; no VM, no arena, no state. */ if (argc >= 2 && strcmp(argv[1], "--fmt") == 0) { if (argc < 3) { diff --git a/src/trace.c b/src/trace.c index 6bb73b5..300b48b 100644 --- a/src/trace.c +++ b/src/trace.c @@ -2,6 +2,7 @@ * EigenScript Trace — implementation (Phase 1 + 2). * ================================================================ * Tape format (text, one record per line): + * V version header — always the first record * L source-line event * A = name-keyed assignment delta * N = nondeterministic builtin return @@ -32,6 +33,14 @@ #include #include +/* Runtime version stamped into the tape header (#411). The Makefile + * injects the real value; embed/freestanding builds without it share + * "dev" — so version equality is necessary, not sufficient, and dev + * builds are on their honor (documented in docs/TRACE.md). */ +#ifndef EIGENSCRIPT_VERSION +#define EIGENSCRIPT_VERSION "dev" +#endif + #define TRACE_STR_MAX 60 /* truncate strings in `A` events */ #define TRACE_NONDET_MAX 65536 /* per-record cap for `N` events; * over-cap payloads emit a marker so @@ -436,6 +445,13 @@ static void tp_printf(const char *fmt, ...) { tp_write(buf, (size_t)n); } +/* #411: stamp the version header. Called once per tape-open (EIGS_TRACE + * fopen, sink install) — a journal appended across several installs + * carries one V record per session; replay verifies each. */ +static void emit_header(void) { + tp_printf("V %d %s\n", TRACE_FORMAT_VERSION, EIGENSCRIPT_VERSION); +} + void trace_set_sink(void (*cb)(const char *, size_t, void *), void *ud) { if (cb) { g_trace_sink = cb; @@ -445,6 +461,7 @@ void trace_set_sink(void (*cb)(const char *, size_t, void *), void *ud) { g_line_dirty = 0; g_trace_enabled = 1; g_trace_hist = 1; + emit_header(); } else { sink_flush(); g_trace_sink = NULL; @@ -498,6 +515,7 @@ void trace_init(void) { setvbuf(g_trace_fp, NULL, _IOFBF, 64 * 1024); g_trace_enabled = 1; g_trace_hist = 1; + emit_header(); #endif /* !EIGENSCRIPT_FREESTANDING */ } @@ -521,20 +539,60 @@ static char *g_replay_mem = NULL; static size_t g_replay_mem_len = 0; static size_t g_replay_mem_pos = 0; +/* #411: consume and verify the tape's V header(s). Defined below + * read_tape_line; prints the refusal reason on failure. */ +static int read_tape_line(void); +static int replay_check_header(void); +static int replay_vline_ok(void); + int trace_set_replay_mem(const char *bytes, size_t len, int strict) { - free(g_replay_mem); - g_replay_mem = NULL; - g_replay_mem_len = 0; - g_replay_mem_pos = 0; if (!bytes) { + free(g_replay_mem); + g_replay_mem = NULL; + g_replay_mem_len = 0; + g_replay_mem_pos = 0; if (!g_replay_fp) g_replay_enabled = 0; return 1; } - g_replay_mem = malloc(len > 0 ? len : 1); - if (!g_replay_mem) return 0; - memcpy(g_replay_mem, bytes, len); + + char *nm = malloc(len > 0 ? len : 1); + if (!nm) return 0; /* prior replay state untouched */ + memcpy(nm, bytes, len); + + /* Stage the new tape and validate EVERY session header up front (#411): + * the first line must be a matching V record, and so must the header of + * each appended session (a journal written across several sink installs). + * Deferring a later header to the take loop would turn a refusable + * install into a mid-eval abort of the host process — the return-0 + * contract exists so the embedder gets to handle refusal. */ + char *om = g_replay_mem; + size_t ol = g_replay_mem_len, op = g_replay_mem_pos; + int os = g_replay_strict, oe = g_replay_enabled; + g_replay_mem = nm; g_replay_mem_len = len; + g_replay_mem_pos = 0; g_replay_strict = strict; + int ok = replay_check_header(); + while (ok) { + if (read_tape_line() < 0) break; + /* Any V-shaped line is a session header (or a torn one) — + * replay_vline_ok refuses the malformed shapes loudly. */ + if (g_replay_line[0] == 'V') ok = replay_vline_ok(); + } + if (!ok) { + /* Version-and-reject (#411), atomically: the refused tape is + * discarded and the PREVIOUS replay state — tape, strict mode, + * enablement — survives untouched, never a half-armed replay. */ + free(nm); + g_replay_mem = om; + g_replay_mem_len = ol; + g_replay_mem_pos = op; + g_replay_strict = os; + g_replay_enabled = oe; + return 0; + } + free(om); + g_replay_mem_pos = 0; g_replay_enabled = 1; return 1; } @@ -549,10 +607,21 @@ static void trace_replay_init(void) { if (!g_replay_fp) { fprintf(stderr, "trace: cannot open EIGS_REPLAY=%s: %s\n", path, strerror(errno)); - return; + /* Fatal, like every other way of not honoring the requested + * replay (#411): a warn-and-run-live here is the same silent + * divergence a mismatched header would be — worse, it's the + * most common operator error (typo'd/deleted tape path). */ + _exit(3); } const char *strict = getenv("EIGS_REPLAY_STRICT"); g_replay_strict = (strict && strict[0] && strcmp(strict, "0") != 0); + if (!replay_check_header()) { + /* Version-and-reject (#411): falling back to a live run would be + * the exact silent divergence the header exists to prevent — the + * user asked for a replay, so a tape this binary can't honor is + * fatal. Same _exit rationale as the strict divergence abort. */ + _exit(3); + } g_replay_enabled = 1; #endif /* !EIGENSCRIPT_FREESTANDING */ } @@ -617,6 +686,59 @@ static int read_tape_line(void) { return (int)len; } +/* #411: validate the V record sitting in g_replay_line against this + * binary. One rule, no migration path: format AND runtime version must + * match exactly, else the tape is refused with the reason on stderr. + * (The tape is plain text — a deliberate override is editing line 1.) */ +static int replay_vline_ok(void) { + const char *p = g_replay_line; + if (p[0] != 'V' || p[1] != ' ') { + if (p[0] == 'V') { + /* A V-shaped line that isn't `V ` is a torn/corrupted header + * (truncated journal write), not a missing one. */ + fprintf(stderr, "trace: malformed tape version header '%s'; " + "refusing to replay (docs/TRACE.md)\n", p); + return 0; + } + fprintf(stderr, "trace: tape has no version header — recorded by a " + "pre-versioning EigenScript or not a tape; refusing to " + "replay (docs/TRACE.md)\n"); + return 0; + } + char *end = NULL; + long fmt = strtol(p + 2, &end, 10); + if (end == p + 2 || *end != ' ') { + fprintf(stderr, "trace: malformed tape version header '%s'; " + "refusing to replay (docs/TRACE.md)\n", p); + return 0; + } + if (fmt != TRACE_FORMAT_VERSION) { + fprintf(stderr, "trace: tape format v%ld, this binary reads v%d — " + "refusing to replay; re-record on this version " + "(docs/TRACE.md)\n", fmt, TRACE_FORMAT_VERSION); + return 0; + } + const char *ver = end + 1; + if (strcmp(ver, EIGENSCRIPT_VERSION) != 0) { + fprintf(stderr, "trace: tape recorded on EigenScript %s, this binary " + "is %s — refusing to replay; a tape is valid only for the " + "version that recorded it (docs/TRACE.md)\n", + ver, EIGENSCRIPT_VERSION); + return 0; + } + return 1; +} + +/* #411: the first record of a tape must be a matching V header. */ +static int replay_check_header(void) { + if (read_tape_line() < 0) { + fprintf(stderr, "trace: empty replay tape — refusing to replay " + "(docs/TRACE.md)\n"); + return 0; + } + return replay_vline_ok(); +} + /* Un-escape a tape-format quoted string in place. Reads the byte stream * starting at `*p` (which must point one past the opening quote), writes * the decoded bytes to `out` (max `out_cap-1` plus NUL), advances `*p` @@ -826,6 +948,23 @@ int trace_replay_take(const char *fn, Value **out) { replay_shutdown(); return 0; } + if (len >= 1 && g_replay_line[0] == 'V') { + /* Mid-stream header: a concatenated tape (journal appended + * across sessions/sink installs). Every session's header must + * match this binary — a mismatch OR a torn/malformed V line is + * always fatal, strict or not (#411): continuing would splice + * another version's (or an unverifiable) session's records + * into this replay. Memory tapes were fully pre-scanned at + * install, so this fires only for EIGS_REPLAY files. */ + if (!replay_vline_ok()) { +#if EIGENSCRIPT_FREESTANDING + abort(); +#else + _exit(3); +#endif + } + continue; + } if (len < 2 || g_replay_line[0] != 'N' || g_replay_line[1] != ' ') continue; /* skip L, A, blanks, anything else */ diff --git a/src/trace.h b/src/trace.h index 7721084..188d16f 100644 --- a/src/trace.h +++ b/src/trace.h @@ -22,6 +22,14 @@ typedef union { double d; uint64_t u; } EigsSlot; #endif +/* Tape format version (#411). Every tape's first line is a header record: + * V + * Bump this integer on ANY change to the tape encoding — record kinds, + * value serialization, escaping, truncation markers, the header itself. + * Replay refuses a tape whose format or runtime version differs from the + * running binary: version-and-reject, never migrate (docs/TRACE.md). */ +#define TRACE_FORMAT_VERSION 1 + /* 1 when EIGS_TRACE was set and a tape was successfully opened. * Hook sites in vm.c gate on this directly so the disabled case * costs one load + one branch. */ diff --git a/tests/test_replay.sh b/tests/test_replay.sh index 0c84e35..6fa9429 100755 --- a/tests/test_replay.sh +++ b/tests/test_replay.sh @@ -71,6 +71,16 @@ else fail "buffer replay" "rec='$REC_B' rep='$REP_B'" fi +# ---- #411: every tape opens with a version header ---- +# Handcrafted tapes below reuse the real header off the recorded tape, so +# they stay valid when the format or runtime version bumps. +VHDR=$(head -1 "$TAPE_L") +if echo "$VHDR" | grep -Eq '^V [0-9]+ .'; then + ok "version header: recorded tape starts with 'V '" +else + fail "version header" "first line='$VHDR'" +fi + # ---- Dict replay (handcrafted tape — no nondet builtin returns dicts) ---- # trace_replay_take is lenient on name mismatch (warns, uses anyway), # so we craft the N record under any nondet name and bind it. @@ -79,7 +89,8 @@ v is env_get of "EIGS_REPLAY_TEST_KEY_DOES_NOT_EXIST" print of v EOF -cat > "$TMPDIR/dict.tape" <<'EOF' +cat > "$TMPDIR/dict.tape" < "$TMPDIR/nested.tape" <<'EOF' +cat > "$TMPDIR/nested.tape" < "$TMPDIR/strict.tape" <<'EOF' +cat > "$TMPDIR/strict.tape" < "$TMPDIR/block.tape" +# Header-only tape so replay is enabled but every builtin's TAKE returns 0 — +# the replay_blocks guard fires before TAKE on these builtins, so no record +# is consumed. The script just counts how many calls raised. +echo "$VHDR" > "$TMPDIR/block.tape" BLOCK_OUT=$(EIGS_REPLAY="$TMPDIR/block.tape" "$EIGS" "$TMPDIR/p_block.eigs" 2>/dev/null) if [ "$BLOCK_OUT" = "10" ]; then ok "replay-block: all 10 subprocess/channel builtins refuse under EIGS_REPLAY (#148)" @@ -216,6 +229,67 @@ else fail "replay-block message" "out='$BLOCK_MSG'" fi +# ---- #411: version-and-reject — every mismatch class refuses loudly ---- +# Control first: the untampered tape must still replay (proves the checks +# below fail because of the tamper, not a broken fixture), on both tiers. +printf 'ABC' > "$INPUT" +GOOD=$(EIGS_TRACE="$TMPDIR/v.tape" "$EIGS" "$TMPDIR/p_list.eigs" 2>&1) +printf 'XYZ' > "$INPUT" +CTRL=$(EIGS_REPLAY="$TMPDIR/v.tape" "$EIGS" "$TMPDIR/p_list.eigs" 2>/dev/null) +CTRL_JOFF=$(EIGS_JIT_OFF=1 EIGS_REPLAY="$TMPDIR/v.tape" "$EIGS" "$TMPDIR/p_list.eigs" 2>/dev/null) +if [ "$CTRL" = "$GOOD" ] && [ "$CTRL_JOFF" = "$GOOD" ]; then + ok "version control: untampered tape replays, JIT on and off" +else + fail "version control" "rec='$GOOD' rep='$CTRL' rep_joff='$CTRL_JOFF'" +fi + +refuse_case() { # $1 label $2 tape $3 expected-stderr-substring + local err rc out + out=$(EIGS_REPLAY="$2" "$EIGS" "$TMPDIR/p_list.eigs" 2>"$TMPDIR/v.err") + rc=$? + err=$(cat "$TMPDIR/v.err") + if [ "$rc" = "3" ] && [ -z "$out" ] && echo "$err" | grep -q "$3"; then + ok "version refuse: $1 aborts with exit 3" + else + fail "version refuse: $1" "rc=$rc out='$out' err='$err'" + fi +} + +# Format-version mismatch. +sed '1s/^V [0-9]*/V 999/' "$TMPDIR/v.tape" > "$TMPDIR/v_fmt.tape" +refuse_case "format mismatch" "$TMPDIR/v_fmt.tape" "tape format v999" + +# Runtime-version mismatch. +sed '1s/^\(V [0-9]*\) .*/\1 0.0.0-elsewhere/' "$TMPDIR/v.tape" > "$TMPDIR/v_run.tape" +refuse_case "runtime mismatch" "$TMPDIR/v_run.tape" "recorded on EigenScript 0.0.0-elsewhere" + +# Headerless (pre-#411 tape shape). +sed '1d' "$TMPDIR/v.tape" > "$TMPDIR/v_none.tape" +refuse_case "missing header" "$TMPDIR/v_none.tape" "no version header" + +# Empty file is not a tape. +: > "$TMPDIR/v_empty.tape" +refuse_case "empty tape" "$TMPDIR/v_empty.tape" "empty replay tape" + +# Unopenable EIGS_REPLAY path (the most common operator error) is fatal too — +# a warn-and-run-live would be the same silent divergence as a bad header. +refuse_case "unopenable path" "$TMPDIR/does_not_exist.tape" "cannot open EIGS_REPLAY" + +# Torn mid-stream session header (concatenated-journal corruption): a bare +# 'V' line must refuse loudly, not slip through the record filter. +# (head/tail, not `sed 2i` — BSD sed rejects the GNU one-liner form.) +{ head -1 "$TMPDIR/v.tape"; echo "V"; tail -n +2 "$TMPDIR/v.tape"; } > "$TMPDIR/v_torn.tape" +refuse_case "torn mid-stream header" "$TMPDIR/v_torn.tape" "malformed tape version header" + +# A stale EIGS_REPLAY must not take down pure queries: --version/--help are +# handled before trace_init, so they succeed even with a refusable tape set. +VOUT=$(EIGS_REPLAY="$TMPDIR/v_none.tape" "$EIGS" --version 2>/dev/null); VRC=$? +if [ "$VRC" = "0" ] && [ -n "$VOUT" ]; then + ok "stale EIGS_REPLAY: --version still works (exit 0)" +else + fail "stale EIGS_REPLAY --version" "rc=$VRC out='$VOUT'" +fi + echo echo "REPLAY: $PASS passed, $FAIL failed" [ "$FAIL" = "0" ] && exit 0 || exit 1