Skip to content

feat: pre-announcement hardening across kernel, adapters, lenses, CLI, and supply chain - #43

Merged
cevheri merged 16 commits into
mainfrom
hardening/pre-announcement
Jul 3, 2026
Merged

feat: pre-announcement hardening across kernel, adapters, lenses, CLI, and supply chain#43
cevheri merged 16 commits into
mainfrom
hardening/pre-announcement

Conversation

@cevheri

@cevheri cevheri commented Jul 2, 2026

Copy link
Copy Markdown
Member

Summary

Implements the full pre-announcement audit program (docs/pre-announcement-notes.md): every Wave 1 launch blocker and every Wave 2 hardening item, in one coordinated change so the durability contract, the code, the tests, and the docs land consistent with each other.

Kernel (guarded core)

  • WAL magic/version header (LRDB + u16 version): open() on a non-LibreDB file now throws NOT_A_DATABASE and leaves the file byte-for-byte untouched — previously a typo'd path silently truncated the file to zero. Headerless v0.1.x files keep opening through a legacy read path (verified against the real Studio demo file).
  • Recovery classification: torn tails truncate (fsync'd, reported via the new onRecovery option); mid-log corruption throws CORRUPT_WAL and truncates nothing; record payloads are bounds-validated during replay. One documented limitation: a corrupted length field on the final record is indistinguishable from a torn tail in format v1.
  • IO-failure latch (fsyncgate): a failed append/fsync latches the database; every later transact() throws FAILED until reopen. Without this, later acknowledged commits sat behind the torn record and were silently destroyed by the next recovery — the finding that falsified the README's headline durability claim.
  • Async transact rejection: transact(async tx => ...) now throws ASYNC_TRANSACTION before committing (writes after the first await never reached the WAL).
  • Buffer copy contract: keys/values are copied at the transaction boundary in both directions; caller buffer reuse and mutation of returned buffers can no longer corrupt the committed store or break the sorted invariant.
  • getRange snapshot semantics: delete-while-scanning visits every entry exactly once (previously it silently skipped every other entry).
  • Exclusive open lock via a new optional FileSystem.lock() seam: a second open() on the same file — same process or another — throws LOCKED; locks from verifiably dead holders are reclaimed automatically.
  • Typed errors: every kernel/adapter failure is a LibreDbError with a stable code (exported with ErrorCode, RecoveryInfo).
  • close() inside a transaction throws CLOSE_IN_TRANSACTION instead of raw EBADF.

Adapters

  • node-fs: parent-directory fsync on file creation; fsync after recovery truncation; fd-based positional reads (no more whole-file readFileSync per call).
  • OPFS: reads loop until filled; recovery treats an incomplete read as INCOMPLETE_READ, never as a torn tail; the weaker flush() power-loss guarantee is documented.

Lenses

  • Names may not be empty or contain : (namespace isolation); lone-surrogate strings rejected wherever they become keys/ids/names/values; NaN/Infinity rejected by number columns; doc() refuses relational tables and table() refuses document collections; find()/where() reject explicitly-undefined predicate fields.

CLI

  • Write commands use the kernel lock; --force verifies holder liveness and never deletes non-libredb files; get/scan escape control characters by default (--raw opts out).

Tests

  • New kernel hardening suite, adapter/lock suite, and lens rejection suite; SimFS gains armAppendError/armFsyncError; DST covers the failure latch, fsync faults, and multi-cycle crash-recover-write schedules; a seeded binary fuzz drives the full byte alphabet (empty keys, multi-KB values) through crash cycles and asserts the recovered store is exactly the model and strictly sorted. Coverage stays 100% line/function/statement; 331 tests.

Supply chain / CI

  • npm publish with --provenance (OIDC); release tag verified against package.json; JSR CLI pinned; Dependabot for actions/docker/npm; a Node 22 smoke job exercises the built package; Docker runtime switches to distroless :nonroot.

Docs

  • README and RELIABILITY now state the durability contract precisely (clean-crash guarantee plus every handled failure mode) instead of a publicly falsifiable headline; status corrected to early beta 0.1.x; performance envelope and bulk-load guidance documented; CLI backup/restore procedure added.

Notes for review

Closes #7, #13, #14, #15, #16, #17, #18, #19, #20, #21, #22, #23, #24, #25, #26, #27, #28, #29, #30, #31, #32, #33, #34, #35, #36, #37, #38, #40, #41, #42

cevheri added 3 commits July 3, 2026 01:36
- refuse non-LibreDB files with a WAL magic header; never truncate
  foreign bytes (headerless v0.1.x files keep opening via a legacy
  read path)
- classify recovery failures: torn tails truncate (reported through
  the new onRecovery option), mid-log corruption refuses to open
- validate record payload structure during replay
- reject async transact() callbacks before commit
- latch the database after a failed append/fsync (fsyncgate)
- copy keys and values at the transaction boundary (no aliasing)
- snapshot getRange so delete-while-scanning visits every entry
- name close()-during-transaction instead of surfacing raw EBADF
- exclusive open lock via the FileSystem seam: pid/host/nonce lock
  file with stale-lock reclaim; CLI --force verifies holder liveness
- node-fs: fd-based positional reads, parent-directory fsync on
  create, fsync after recovery truncation
- opfs: loop reads so a short read cannot masquerade as a torn tail
- recovery treats an incomplete read as an IO fault, never truncation
- typed LibreDbError with stable codes on every kernel failure
… profiles

- reject ':' and empty collection/table names (namespace isolation)
- reject lone-surrogate keys, ids, names, and kv values (UTF-8 round-trip)
- reject NaN/Infinity in relational number columns
- doc() refuses relational tables; table() refuses document collections
- find()/where() reject explicitly-undefined predicate fields eagerly
- CLI get/scan escape control characters by default; --raw opts out
- SimFS gains armAppendError/armFsyncError; DST covers partial-append
  latch, fsync-fault latch, and multi-cycle crash-recover-write runs
- seeded binary fuzz: full byte alphabet, empty/large payloads, and the
  sorted invariant asserted on recovered entries
- npm publish with provenance (OIDC id-token); release tag must match
  package.json version; JSR CLI pinned to an exact version
- Dependabot for actions, docker, and npm devDependencies
- Node 22 smoke job exercises the built package (open, lenses, lock,
  reopen) so declared Node support is off the honor system
- Docker runtime switches to distroless :nonroot
- README/RELIABILITY state the durability contract precisely: the
  clean-crash guarantee plus every handled failure mode (IO-error
  latch, exclusive lock, foreign-file refusal, corruption refusal,
  short reads); status line updated to early beta 0.1.x
- performance envelope and bulk-load guidance documented
- CLI docs cover the new lock semantics, output escaping, --raw, and
  the backup/restore procedure
- OPFS flush()'s weaker power-loss guarantee documented in the adapter
  and BROWSER.md
- changeset for the whole hardening wave (minor)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens LibreDB ahead of a public announcement by tightening the kernel durability/format contract, improving adapter correctness (including locking), adding lens-boundary input validation, and expanding test/CI/supply-chain assurances so behavior, docs, and release workflows align.

Changes:

  • Kernel: introduce LRDB magic + format version header, typed LibreDbError codes, corruption vs torn-tail recovery classification, async transact rejection, IO-failure latch (“fsyncgate”), buffer copy contract, getRange snapshot semantics, and an optional filesystem lock seam.
  • Adapters/CLI: implement exclusive <path>.lock locking in node-fs, looped reads in OPFS/node-fs, safer --force unlocking, and default terminal-output sanitization (--raw opt-out).
  • Tests/CI/docs: add extensive hardening + fuzz/DST suites, Node 22 smoke job, Dependabot config, pinned JSR CLI, npm provenance, distroless nonroot Docker runtime, and updated reliability/performance/CLI docs.

Reviewed changes

Copilot reviewed 36 out of 36 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/core.ts WAL header + recovery classification, typed errors, async transact rejection, fsync-failure latch, buffer copying, getRange snapshotting, lock seam
src/index.ts Re-export typed error surface (LibreDbError, codes/types) at Node entry
src/browser.ts Re-export typed error surface at browser entry
src/adapter/node-fs.ts FD-based IO, directory fsync-on-create, <path>.lock exclusive lock + --force unlock helpers
src/adapter/node-fs.test.ts Tests for node-fs IO, directory fsync, lock behavior, and --force semantics
src/adapter/opfs.ts Loop OPFS reads to avoid legal short-read data loss; document flush limitations
src/cli/run.ts Use kernel lock for writes, add --force unlock path, sanitize get/scan output by default (--raw opt-out)
src/cli/run.test.ts CLI tests updated for new locking semantics and output sanitization
src/cli/readonly-fs.ts Make fsync a no-op to support recovery’s fsync-after-truncate behavior
src/cli/readonly-fs.test.ts Update tests to match readonly fsync no-op
src/cli/lock.ts Removed legacy CLI-only advisory lock implementation
src/cli/lock.test.ts Removed tests for legacy CLI-only advisory lock
src/core.hardening.test.ts New kernel hardening suite (foreign-file refusal, corruption policy, latch, locking, copying, async rejection, etc.)
src/core.recovery.test.ts Adjust crash simulation to account for lock behavior
src/lens/catalog.ts Add well-formed UTF-16 checks, stricter namespace validation, catalog kind routing helpers
src/lens/catalog.test.ts Update tests for stricter namespace-name rejection
src/lens/document.ts Enforce catalog kind routing (doc vs table), reject lone surrogates in ids, reject undefined predicate values
src/lens/relational.ts Reject NaN/Infinity, undefined predicate fields in where, use unguarded collection handle for schema lens
src/lens/relational.test.ts Update to use collectionHandle since doc() now blocks relational names
src/lens/kv.ts Reject lone-surrogate keys/values via assertWellFormedText
src/lens/hardening.test.ts New tests pinning lens-boundary rejection rules (names, surrogates, NaN/Infinity, doc/table routing, undefined predicates)
src/sim/simfs.ts Add deterministic append/fsync fault injection for DST
src/sim/dst.ts Export torn-tail injector for composing multi-cycle crash schedules
src/sim/dst.test.ts Expand DST tests for corruption classification, short-read behavior, and IO-failure latch scenarios
src/sim/fuzz.test.ts New seeded binary fuzz for byte-alphabet keys/values + sortedness invariant
scripts/node-smoke.mjs Node runtime smoke test over built dist/ (lenses + locking + reopen)
.github/workflows/ci.yml Add Node 22 smoke job; update size-budget messaging
.github/workflows/publish.yml Add tag/package.json version check, npm provenance, pinned jsr CLI, OIDC permissions
.github/dependabot.yml Add weekly Dependabot updates for actions/docker/npm
Dockerfile Switch runtime to distroless :nonroot pinned digest
.size-limit.json Increase bundle budget to 5 kB and ignore node:crypto
README.md Update durability/performance envelope/status claims to match new contract
docs/RELIABILITY.md Precisely document durability contract + handled failure modes
docs/CLI.md Document new locking/--force, output escaping, and backup/restore procedure
docs/BROWSER.md Document OPFS flush durability caveat more explicitly
.changeset/pre-announcement-hardening.md Minor changeset describing the coordinated hardening release

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/core.ts Outdated
Comment thread src/adapter/node-fs.ts Outdated
…ng wave

Review of PR #43 by a four-lens adversarial workflow confirmed gaps in
the new guarantees; each is closed with a pinning test:

- an all-zeros file parsed as an empty legacy record and was adopted
  (truncated + appended to); isLegacyLog now requires a non-empty first
  record, matching the kernel's own write invariant
- the record length field was outside the checksum, so mid-log damage
  to a length masqueraded as a torn tail and silently truncated
  acknowledged commits; v1 record headers now checksum their own length
  field (refuse instead of truncate), and legacy files keep the legacy
  framing on later appends
- a torn-header prefix check now compares against the full 8 expected
  header bytes, so a short foreign file sharing only the LRDB magic is
  refused instead of truncated
- stale-lock reclaim and forceUnlock were check-then-delete; both now
  claim the lock atomically by renaming it aside, so two racers can
  never both acquire and force can never delete a fresh writer's lock
- release() verifies ownership on the claimed bytes and restores a
  lock it does not own
- a refused open no longer leaks the WAL file descriptor; close()
  releases the lock even when the underlying file close throws
- getRange snapshots entry references and copies per yield, so an
  early-exited scan no longer pays for the whole range
- transact() rejects async callbacks at compile time as well as runtime
- doc()'s relational-name guard runs lazily inside each operation's
  transaction, so handles can be built inside transact() again
- readonlyFileSystem and nodeFileSystem are exported: the supported way
  to inspect a database a live writer holds locked
- node-smoke exercises the CLI entry as the workflow comment claims
- docs: downgrade warning (v1 file opened by <=0.1.3 is truncated),
  legacy torn-first-record refusal, Docker nonroot bind-mount guidance
@cevheri

cevheri commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Adversarial review round 1 (4 lenses, 31 agents) found and this push fixes: all-zeros foreign-file adoption, unprotected record length field (v1 record headers now self-checksummed), non-atomic stale-lock reclaim (rename-aside claims), fd leak on refused opens, lock release on throwing close, getRange early-exit cost, compile-time async rejection, doc() handle construction inside transactions, and a supported readonly open. Docs updated with the downgrade warning and Docker nonroot guidance. Gate green, 336 tests, 100% coverage.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated 5 comments.

Comment thread src/lens/catalog.ts
Comment thread src/adapter/node-fs.ts
Comment thread src/core.ts Outdated
Comment thread src/core.ts
Comment thread src/adapter/node-fs.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated 3 comments.

Comment thread src/core.ts Outdated
Comment thread src/core.ts
Comment thread src/adapter/node-fs.ts
- files shorter than the 8-byte header are refused untouched, even when
  they share magic-prefix bytes; the torn-first-header auto-repair is
  removed (destroying an ambiguous file is never the answer; nothing in
  a sub-header file was ever acknowledged)
- commit IO failures are typed: transact() throws LibreDbError FAILED
  with the adapter error as cause instead of the raw ENOSPC/EIO
- lock sentinel is an exact first-line match, so a file merely starting
  with the sentinel text is foreign
- automatic stale-lock reclaim now requires a VERIFIABLY dead holder;
  anonymous locks (empty, or the sentinel-only 0.1.x format) carry no
  liveness info — they may even be a concurrent lock() between its
  exclusive create and its sentinel write — and need --force
- isStaleLock treats only ENOENT as vanished; other read errors are
  real problems, not staleness
- tryCreateLock loops writeSync so a partial write cannot leave a
  malformed lock that reads as an anonymous stray
- well-formedness validation no longer depends on
  String.prototype.isWellFormed (absent on older browser engines the
  browser entry may reach); a hand-rolled surrogate scan replaces it
- recover() comments clarified: the version bytes gate the v1 path, and
  a foreign file matching the entire 8-byte header is byte-for-byte
  indistinguishable from a real empty database
- size budget 5 kB -> 6 kB for the public entry (lock protocol growth)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated 3 comments.

Comment thread src/lens/document.ts
Comment thread src/adapter/node-fs.ts
Comment thread .github/workflows/ci.yml Outdated
cevheri added 3 commits July 3, 2026 07:50
- doc()'s relational-kind guard memoizes only the settled state (name
  cataloged as document); an uncataloged name keeps re-checking, so a
  handle built before table() cataloged the name can no longer write
  around schema validation
- parseLock validates the pid field: a sentineled-but-mangled lock
  downgrades to anonymous (never auto-stale) instead of probing pid=NaN
  as a dead holder and stealing a live writer's lock
- the CI job summary reads the bundle budget from .size-limit.json
  instead of hardcoding a stale number
A 10000-seed soak takes ~6s and tripped bun's default 5s per-test
timeout, reporting an invariant failure that was actually the clock.
The invariant itself holds across all 10000 seeds (verified standalone,
both assertions). The timeout now scales with the seed count so soaks
fail only on a broken invariant.
…tion and cleanup

- doc get/delete (and the relational paths through them) validate the
  id like put already did: a lone-surrogate id encodes to the
  replacement character's bytes and would silently read — or delete —
  a document legitimately stored under that id
- a throwing onRecovery callback no longer leaks the WAL file handle:
  the callback runs inside the same guard that closes the file on a
  refused open
- CLI inspect escapes control characters in namespace names (the lens
  validator rejects ':' and surrogates but control bytes are legal
  name characters); --raw opts out, matching get/scan

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated 2 comments.

Comment thread src/core.ts Outdated
Comment thread .size-limit.json
cevheri added 4 commits July 3, 2026 08:17
- the control-regex suppression in sanitize() targets oxlint by name
  (only oxlint's rule fires there; the eslint-disable form was an
  unused directive for eslint while still being consumed by oxlint)
- types.test.ts builds comparison arrays with Array.from().concat()
  instead of map-spread, clearing the last standing lint warning
- the node-smoke CI job also compiles the standalone binary and runs a
  set/get round-trip: previously a compile regression would first
  surface during publish, after npm publish had already succeeded
The comment claimed an early-exited scan pays only for consumed
entries; in fact the reference walk over the matching range is eager
at first next() — only the per-yield byte copies are lazy. Say so.
… and stale claims

- CLI import enforces the kv lens's well-formed string invariant: a
  JSON file whose \uD800-style escapes decode to lone surrogates was
  previously written through the kernel directly, where two distinct
  malformed keys collide on the same UTF-8 bytes
- BrowserOpenOptions carries onRecovery, so the browser type surface
  matches the runtime (RecoveryInfo was exported but the option was
  not expressible); pinned by a compile-level test
- README line-count claim updated honestly: the kernel is one file of
  under a thousand lines, roughly half explanatory prose (measured:
  449 code / 456 comment); DESIGN.md gains a dated addendum with the
  same numbers so the decision log tracks reality
- the redundant reclaim continue-branch in lock() is folded away
  (removed and gone both retry the exclusive create), restoring true
  100% line coverage across all files (node-fs was at 99.31%)
The one uncovered line in the repo was lock()'s refusal branch for the
reclaim race: the holder looks dead at the isStaleLock pre-check but
alive when the claimed bytes are re-judged. Desyncing the two liveness
probes (spyOn process.kill) simulates the race exactly, pinning both
the LOCKED refusal and the rename-back. All files are back at true
100% line/function/statement coverage.
This was referenced Jul 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request pre-announcement Pre-announcement audit priority/high Pre-announcement audit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enable npm publish provenance for releases

3 participants