Skip to content

[P1][kernel] Refuse open on mid-log corruption; surface torn-tail truncation #22

Description

@cevheri

Summary

Recovery treats all parse failures the same: stop and truncate everything after the failure point. That is correct for a torn tail (crash model) but destructive and silent for mid-log corruption (bit rot, cloud sync conflict, second writer, flipped bit in an early record). One bad early record → reopen silently deletes all later fsync'd transactions permanently.

Audit ID: B8 (High) — pre-announcement blocker
Partially overlaps: WAL header issue (B1 / #9) but kernel response policy is separate.

Impact

  • Long-lived WAL: single flipped bit → catastrophic silent data loss.
  • User receives no error, no count of dropped records, no recovery mode.
  • CRC exists precisely to detect this; current response destroys evidence.

Root cause

while (offset + RECORD_HEADER <= log.length) {
  // ...
  if (end > log.length) break; // torn tail
  if (crc32(payload) !== checksum) break; // mid-log corrupt — same path as torn
  replayPayload(entries, payload);
  offset = end;
}
if (offset < log.length) file.truncate(offset);

No distinction between:

Case Valid records after failure? Correct action
Torn tail No (EOF before promised length) Truncate tail; optional warning
Mid-log corruption Possibly yes Refuse to open; do not truncate

Additional gap: replayPayload bounds

replayPayload (core.ts:371-388) trusts payload structure after CRC pass. CRC-valid but malformed payload (bogus lengths) can replay garbage ops. Add bounds checks; throw on overflow/mismatch.

Proposed fix

1. Classify failure in recover()

  • Torn tail: end > log.length → truncate to offset (keep behavior); optionally return { entries, truncatedBytes } for logging.
  • Mid-log CRC/structural failure: if offset > 0 OR valid records were replayed and failure is not torn tail → throw; never truncate.
  • Foreign file at offset 0: covered by B1 (magic header); align error messages.

2. Harden replayPayload

Validate each op's lengths against payload size; throw libredb: corrupt WAL record on violation.

3. Optional callback / open option

onRecovery?(info: { tornTailTruncated: number }) — nice-to-have; minimum is throw vs truncate correctness.

Tests

  • Valid DB + torn last record → truncates tail only; prior commits kept.
  • Valid records + corrupt middle record + more data after → open throws; file byte length unchanged on disk.
  • Malformed payload lengths with valid CRC → throw during replay (test vector).

Related issues

Acceptance criteria

  • Mid-log corruption never silently truncates committed tail.
  • Torn tail recovery still works.
  • replayPayload bounds validated.
  • Tests pinned; bun run gate green.
  • Changeset required.

Agent constraints

  • Guarded-core; keep recover() readable — extract classifiers if needed, not hidden layers.

Verification

bun run gate

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/kernelPre-announcement auditbugSomething isn't workingpre-announcementPre-announcement auditpriority/highPre-announcement audit

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions