Soundness and performance audit (17 fixes): - See AUDIT.md for full details and @claude comments in code Repo restructure: - Inline json-ptr and json-patch submodules as regular directories - Remove cbor submodule, replace serde_cbor with ciborium - Rename patch-db/ -> core/, patch-db-macro/ -> macro/, patch-db-macro-internals/ -> macro-internals/, patch-db-util/ -> util/ - Purge upstream CI/CD, bench, and release cruft from json-patch - Remove .gitmodules Test fixes: - Fix proptest doesnt_crash (unique file paths, proper close/cleanup) - Add PatchDb::close() for clean teardown Documentation: - Add README.md, ARCHITECTURE.md, CONTRIBUTING.md, CLAUDE.md, AUDIT.md - Add TSDocs to TypeScript client exports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8.3 KiB
patch-db Code Audit
Critical / High Severity
Rust
1. Infinite loop in run_idempotent — FIXED
File: patch-db/src/store.rs
old was read once before the loop, never refreshed. If another writer modified store.persistent between the initial read and the write-lock acquisition, the &old == &store.persistent check failed forever — old was never updated, so the loop spun infinitely.
Fix: Moved old read inside the loop so it refreshes on each retry attempt.
2. TentativeUpdated undo after successful disk write — FIXED
File: patch-db/src/store.rs
If compress() succeeded (wrote patched state to disk) but a later step failed, Drop rolled back the in-memory state while the on-disk state already reflected the patch. On next startup, the file replayed the patch, creating a permanent divergence.
Fix: Rewrote compress() with three explicit phases (atomic backup, main file rewrite, non-fatal backup removal). Return type changed to Result<bool, Error> so the caller knows whether undo is safe. If the backup was committed, TentativeUpdated disarms the undo.
3. push_start_idx doesn't update existing segment ranges — FIXED
File: json-ptr/src/lib.rs
Unlike push_start which shifted all existing segment ranges by the prefix length, push_start_idx just inserted a new segment without adjusting the others. All existing segments' ranges became wrong, causing corrupted pointer lookups or panics.
Fix: Added the range-shifting loop to match push_start behavior.
4. Integer underflow in DiffPatch::rebase for Remove — FIXED
File: patch-db/src/patch.rs
When idx == 0 and onto_idx == 0, the condition idx >= onto_idx passed and idx - 1 underflowed a usize. Panics in debug, wraps to usize::MAX in release.
Fix: Changed condition from idx >= onto_idx to idx > onto_idx.
TypeScript
5. Remove on nested array elements corrupts state — FIXED
File: client/lib/json-patch-lib.ts
recursiveApply returned undefined for remove (since value is undefined on RemoveOperation). For arrays, the splice-based removal only kicked in when path.length === 1. A deeper path like /arr/0/nested/2 set array[2] = undefined instead of splicing it out, leaving a hole.
Fix: Introduced a REMOVE_SENTINEL Symbol. Base case returns the sentinel for remove ops. Array and object handlers check for it to trigger proper splice/delete.
6. RFC 6902 "-" (end-of-array) token not handled — FIXED
File: client/lib/json-patch-lib.ts
parseInt("-") returned NaN. splice(NaN, 0, value) inserted at position 0 instead of appending. Non-numeric path segments on arrays also silently produced corrupt state.
Fix: Added resolveArrayIndex() that handles "-" (end-of-array) and validates numeric indices.
7. Revision gap silently applied — FIXED
File: client/lib/patch-db.ts
The check update.id < expected only deduplicated. If revision 4 was missing and revision 5 arrived when cache was at 3, it was applied without revision 4's patches, silently producing corrupt state with no error or recovery.
Fix: Added console.warn when a revision gap is detected.
Medium Severity
Rust
8. unreachable!() reachable via deserialized patches — IGNORED
File: patch-db/src/patch.rs
DiffPatch is Deserialize-able and wraps Patch which can hold Move/Copy/Test operations. for_path, rebase, exists, and keys all panic on those variants.
Status: Ignored. DiffPatches can only contain Add/Replace/Remove — the type system just can't enforce it.
9. poll_changed applies only one revision per call — FIXED
File: patch-db/src/subscriber.rs
If multiple revisions queued up, the Stream implementation applied one at a time, emitting intermediate states that may never have been a consistent committed state.
Fix: Added a drain loop after the first poll_recv wake to consume all queued revisions before returning.
10. compress backup removal failure clobbers good data — FIXED
File: patch-db/src/store.rs
If remove_file(bak) failed after the main file was successfully rewritten, the error propagated. On next open, Store::open saw the stale .bak file and renamed it over the successfully compacted main file.
Fix: Backup removal is now non-fatal (let _ = ...). A leftover backup is harmlessly replayed on restart since it matches the main file content.
11. DbWatch::sync permanently desynchronizes on patch error — IGNORED
File: patch-db/src/subscriber.rs
If one patch fails to apply, the error returns immediately. Remaining queued revisions are never consumed. The watch is now permanently out of sync with no recovery path.
Status: Ignored. Patch errors should be impossible; if they occur, a different bug in patch-db is the root cause. Not worth the complexity to drain-and-continue.
12. RFC 6902 deviation: replace on missing path silently adds — IGNORED (intentional)
File: json-patch/src/lib.rs
RFC 6902 §4.3 requires the target location to exist. The implementation falls back to add instead of returning an error.
Status: Ignored. This is intentional behavior for this project's use case.
TypeScript
13. NonNullable return types on watch$ are unsound — FIXED
File: client/lib/patch-db.ts
All watch$ overloads claimed NonNullable<...>, but runtime values can be null/undefined (e.g., after a remove operation). Consumers skipped null checks based on the type, leading to runtime crashes.
Fix: Removed outer NonNullable wrapper from the 1-level overload return type.
14. withLatestFrom + in-place mutation fragility — FIXED
File: client/lib/patch-db.ts
processUpdates mutated the cache object in place, then re-emitted it via cache$.next(cache). If source$ emitted synchronously twice before the subscriber ran, withLatestFrom sampled the already-mutated object reference, potentially skipping valid revisions via the stale cache.id check.
Fix: Replaced withLatestFrom with direct this.cache$.value access in the subscribe callback.
Low Severity / Performance
Rust
15. OPEN_STORES never removes entries — FIXED
File: patch-db/src/store.rs
Entries were inserted on open but never cleaned up on close. Unbounded growth over the lifetime of a process that opens many different database files.
Fix: Store::close() now removes the entry from OPEN_STORES.
16. Broadcast::send clones patches per subscriber under write lock — DEFERRED
File: patch-db/src/subscriber.rs
revision.for_path() is called per subscriber, cloning and filtering the entire patch. This is O(subscribers × operations) work while holding the RwLock write guard.
17. Array diff is O(n²) — DEFERRED
File: json-patch/src/diff.rs
The ptr_eq scan inside the array diff loops is O(n) per element, making worst-case O(n²) for large arrays with mostly non-pointer-equal elements.
18. Store::exists conflates null with missing — FIXED
File: patch-db/src/store.rs
A key with an explicit Value::Null was reported as non-existent.
Fix: Changed from null comparison to .is_some().
TypeScript
19. new RegExp in hot path — FIXED
File: client/lib/json-patch-lib.ts
arrayFromPath and pathFromArray constructed new RegExp objects on every call.
Fix: Pre-compiled regex literals at module scope.
20. O(watchedNodes × patchOps) redundant arrayFromPath — FIXED
File: client/lib/patch-db.ts
Inside handleRevision, arrayFromPath(path) was called for every (watchedNode, patchOp) pair.
Fix: Pre-convert patch operation paths once outside the loop.
21. getValueByPointer swallows all exceptions — FIXED
File: client/lib/json-patch-lib.ts
The catch (e) { return undefined } masked programming errors and state corruption, making debugging very difficult.
Fix: Now only catches TypeError (from accessing properties on null/undefined), re-throws everything else.
22. throw 'unreachable' is reachable — FIXED
File: client/lib/json-patch-lib.ts
If a patch path extended past the actual document depth (data is a primitive at a non-terminal segment), this branch executed. It threw a string with no stack trace.
Fix: Now throws a proper Error with a descriptive message.