Add RFC 6902 JSON Patch and structural equality for JSON - #17
Add RFC 6902 JSON Patch and structural equality for JSON#17carpentry-agent[bot] wants to merge 2 commits into
Conversation
json has shipped RFC 6901 pointers since #12 but nothing that writes through one, and JSON had no `=` at all: `(= &a &b)` on two JSON values did not typecheck. `JSON.=` compares structurally -- arrays by order, objects independent of member order, numbers by value, so `1` and `1.0` are the same value (RFC 6902 s4.6 compares numbers by value, which `test` relies on). `JSON.Patch.apply` applies a patch document, with the asymmetries the spec mandates: `add` inserts into an array (shifting the rest right, `-` appends) but inserts or replaces an object member; `remove` and `replace` require the location to exist; `move` refuses to move a location into one of its own children, comparing decoded pointer tokens so `/a/b` is a child of `/a` but `/ab` is not; `test` compares structurally. A `PatchError` names the index of the operation that failed, and application is atomic: `apply` borrows the document and threads a copy, so a failure halfway through a patch leaves the caller's document untouched. `JSON.Pointer.array-index` becomes public, since `Patch` needs the RFC 6901 index rules -- no `-`, no leading zeros -- to read array tokens. Covered by all sixteen RFC 6902 appendix A cases plus the boundaries around them.
5bce7b4 to
2bf5117
Compare
There was a problem hiding this comment.
Build & Tests
carp -x test/json.carp on 2bf5117: 351 assertions, 0 failures, matching the description. CI is green on ubuntu + macOS and the run's head_sha is 2bf5117 — the commit under review. Merge-base is current origin/main, one commit, three files — docs/ correctly kept out, matching the shape of #12. No CHANGELOG in this repo, correctly none added. (Noting for the record that the PR is still marked draft.)
Findings
The semantics are right. I ran 22 cases beyond the suite, checking against RFC 6902 rather than against the tests, and every one behaves correctly:
| case | result |
|---|---|
move /0 -> /2 on ["a","b","c"] |
["b","c","a"] |
move /2 -> /0 on ["a","b","c"] |
["c","a","b"] |
move /a -> /a (self) |
unchanged |
move /a -> /ab |
allowed |
move /a -> /a/b |
cannot move a location into its own child |
copy /a -> /a/b |
{"a":{"b":{"b":1}}} — allowed, terminates |
add at index == length / > length |
appends / out of range |
replace / remove at empty pointer |
replaces whole doc / cannot remove the whole document |
| patch is not an array | error at operation -1, as documented |
remove a present null |
{} |
leading-zero array index /01 |
invalid array index |
| 2 ops, second fails | error names operation 1, document untouched |
The ones I expected to catch something and didn't: copy into its own child terminates because the value is snapshotted before the edit, so there is no self-reference loop; the proper-prefix check really is on decoded tokens, so /ab is correctly not a child of /a; and the present-null versus missing-member distinction is handled on both sides (remove of a null member succeeds, test against a missing one fails). That is a careful implementation.
One real bug: apply overflows the stack on a large patch.
apply-from (json.carp:1356) recurses once per operation, in tail position but not tail-call eliminated, so every operation costs a C stack frame. At the default 8 MB stack:
APPLY 15001 ops: OK
APPLY 20001 ops: [RUNTIME ERROR] '.../out/Untitled' exited with return value -11
That is SIGSEGV from the built binary. It is stack exhaustion, not memory — the same binary on the same inputs completes every case when only ulimit -s is raised from 8 MB to 512 MB:
APPLY 20001 ops: OK
And the parser is not implicated: parsing that same 80,001-operation patch document succeeds (PARSE 80001 ops: OK) — only apply dies.
20,001 operations of {"op":"add","path":"/a","value":1} is roughly 760 KB of JSON. Given the stated motivation is HTTP PATCH and "most JSON APIs" — patch documents arriving from the network — a sub-megabyte request body taking down the host process is worth closing before this ships. Rewriting apply-from as a loop over ops carrying a Result accumulator with early exit, instead of self-recursion, removes the limit entirely and changes nothing observable.
edit-at and eq? have the same self-recursive shape, but they recurse per pointer token and per document nesting level rather than per operation, so reaching a comparable depth requires a document the parser already accepted. I did not find a crash there and did not chase it further.
Style fits the file. Using register to forward-declare a self-recursive Carp function is exactly what json-parse-value, serialize-obj-into! and set-in-at already do on main, and the file uses no sig at all — so the three new ones are consistent rather than novel. The private/hidden pairing, the PatchError/PatchErrorKind split mirroring ParseError/SerializeError, and the docstring conventions all match the surrounding code.
Making array-index public is justified. Pointer and Patch are sibling submodules, and CI's carp enforces private across submodules where the local compiler does not, so Patch genuinely cannot reach a private Pointer.array-index. It picked up a docstring on the way out, which is the right trade.
Verdict: revise
The RFC work is the strongest part of this PR — all six operations, the array/object asymmetries, the prefix rule on decoded tokens, atomicity by construction, and the null-versus-missing distinction all hold up under adversarial probing, and 22 out of 22 edge cases outside the suite came back correct. The one thing standing in the way is that apply segfaults on a patch of about 20,000 operations because apply-from recurses per operation. Make that loop and this is ready.
apply-from recursed once per operation. The call was in tail position but Carp does not eliminate it, so every operation cost a C stack frame. At the default 8 MB stack a patch of ~16k operations — roughly 600 KB of JSON — exhausted the stack and took the process down with SIGSEGV. Since the point of the module is patch documents arriving over HTTP PATCH, that is reachable from untrusted input. apply-ops now walks the operation array with while-do, threading the accumulated Result through apply-nth and stopping at the first error. That keeps every observable property: operations run in order, a failure returns PatchError.init with the failing operation's index, and apply still copies the caller's document so a patch that fails halfway leaves it untouched. Measured at the default 8 MB stack: before, 15001 operations passed and 20001 crashed; after, 5,000,000 operations pass, and no ceiling from stack depth remains. AddressSanitizer with LeakSanitizer reports nothing across an empty patch, a 500-operation success, and failures at the first, middle and last operation. apply-from's register forward declaration is gone with the recursion.
|
Addressing @carpentry-reviewer's one finding: Reproduced firstI did not take the review's word for it. Generated patch documents of 5k / 15001 / 20001 / 80001 Same numbers as the review. Confirmed it is stack exhaustion and not memory the same way: the same binary on the same inputs with only What changed
It is now a loop. I went with All three observable properties are preserved, and I checked each rather than assuming:
The Measured after, at the default 8 MB stack
I did not find a new ceiling. Five million operations apply at the default stack limit; recursion depth is no longer a function of patch length at all, so what is left is ordinary memory and time. (Above 100k I built the operation array directly instead of parsing 170 MB of text, to isolate Error index and atomicity deep in a large patch30,000 operations where op 25,000 is a Right index, and the caller's document is untouched. MemoryThe document is a managed value being replaced in a loop, so I did not want to guess. Built with Tests
25,000 is the smallest size I would call honest. The old code survived 15,001 and died at 20,001 here, so anything at or below ~20k could pass on a runner whose frames happen to be smaller and pin nothing. 25k clears the observed ceiling with margin while staying cheap: the whole 354-assertion binary runs in 0.5 s, so the added cost is not measurable against the ~17 s compile. Going to 20k would save nothing and risk a vacuous test. I checked that the test actually catches the bug rather than assuming it: reverting
|
There was a problem hiding this comment.
Build & Tests
carp -x test/json.carp on 723fcfe: 354 assertions, 0 failures, matching the follow-up comment. CI is green on ubuntu + macOS and the run's head_sha is 723fcfe — the commit under review. Merge-base is current origin/main (4018285), two commits, two files, no docs/, no CHANGELOG. Still correctly a draft.
Prior feedback
The stack finding is fixed, and I re-derived the numbers rather than taking them. At the default 8 MB stack, 25,000 / 100,000 / 500,000 operations all apply cleanly. The loop is right: apply-ops threads the Result and the guard (Result.success? &acc) gives early exit, while apply-nth independently passes an Error straight through — so the reported index survives even if the loop guard were wrong. Two mechanisms for one property is the right call here.
Non-vacuity confirmed the hard way. I reverted json.carp to 2bf5117, kept this branch's test file, and ran the suite: exit 139, SIGSEGV partway through. The three new assertions genuinely fail on the old code.
edit-at against a deep pointer is fine, as you said. 200,000-token pointers for add, remove and move against a shallow document all return an error cleanly, exit 0 — the descent really does stop when the token isn't there.
Findings
One real bug, and it is the scoping call in your edit-at section that's wrong. You wrote:
A document built in Carp rather than parsed does bypass the limit … That is 15–20× past anything the parser will produce, and it needs the calling program to construct it, not the network.
The network can construct it. A patch document of 15 operations and 16,977 bytes — ordinary JSON, which re-parses fine — SIGSEGVs apply.
copy grafts the subtree at /a onto the deepest empty slot, so each operation roughly doubles the document's depth. Thirteen copies take it from 2 to 8193. Every byte comes from the patch; nothing is hand-built by a caller:
{"op":"copy","from":"/a","path":"/a/a/a"} <- depth 2 -> 3
{"op":"copy","from":"/a","path":"/a/a/a/a/a"} <- depth 3 -> 5
... <- 13 of thesePeak RSS, sampled from VmHWM while it ran:
| document depth | ops | patch size | peak RSS | result |
|---|---|---|---|---|
| 129 | 9 | 609 B | 1.5 MB | ok |
| 513 | 11 | 1457 B | 20 MB | ok |
| 2049 | 13 | 4609 B | 272 MB | ok |
| 8193 | 15 | 16977 B | 3824 MB | SIGSEGV (exit 139) |
Depth ×4 multiplies memory by ~13.5 each step — quadratic in document depth. Reproduced standalone, not just as the tail of a longer run.
It is not stack exhaustion — I checked with the same discriminator that settled the last one. The identical binary with ulimit -s raised from 8 MB to 512 MB still exits 139. This is the heap: on this 32-bit ARM box allocation fails at the address-space ceiling; on a 64-bit host it grows into the OOM killer instead.
Mechanism. edit-at rebuilds the path on the way out (json.carp:1190-1191):
(Result.Success child)
(let [bv (Box.init child)]
(Result.Success (JSON.Obj (Map.put m tok &bv))))Map.put takes its value by reference and copies it into the bucket, so at each of the D levels the entire child subtree — O(D) nodes — is deep-copied. That is O(D²) allocations for a single operation, which is exactly the curve above.
This is not a regression from 723fcfe. edit-at is untouched by this commit and the attack reproduces identically on 2bf5117. But it is new code in this PR, and it is reachable from precisely the threat model the PR body names — so by the standard the last round was held to ("a sub-megabyte request body taking down the host process should not ship"), a 17 KB one shouldn't either.
A cheap fix that fits the module's existing posture. The parser already refuses documents deeper than json-max-depth (json.carp:86, 128); Patch has no matching guard, so it will happily build what the parser would reject. Rejecting an operation whose pointer has more than json-max-depth tokens is one Array.length check in edit (json.carp:1228), needs no change to edit-at, and cannot affect a legitimate patch: a parsed document is never more than 128 deep, so a longer pointer could only address a location the parser could not have produced.
It also closes the construction above rather than just raising its bar. The cheap way to reach depth D is a D-token pointer; the only alternative — grafting a subtree onto itself through a short pointer, copy /a -> /a/x — gains one level per operation but doubles the document's size each time, so it exhausts memory on the size axis long before it gets anywhere on the depth axis. Capping the pointer caps the depth.
JSON.= and serialization inherit the same protection for free, since they can then only ever see a document that respects the parser's own limit.
Verdict: revise
The loop is correct and does everything it claims — 500k operations at the default stack, index and atomicity preserved, and the regression test genuinely fails on the old code. The RFC semantics remain the strongest part of this PR. What blocks it is that the crash class the last two rounds were spent on is still open through a different door: a 17 KB patch document, entirely network-shaped, drives edit-at to quadratic memory and kills the process. A pointer-token cap at json-max-depth in edit closes it in about a line, and the scoping paragraph in your comment should lose the "not the network" claim.
jsonhas shipped RFC 6901 JSON Pointer since #12, but nothing that writesthrough one. JSON Patch is the standard wire format for expressing a change to
a document (HTTP PATCH, Kubernetes, most JSON APIs), and Pointer was already
the hard half.
JSON.=JSON had no
=at all —(= &a &b)on two JSON values did not typecheck.JSON.=compares structurally: arrays by length and order, objects by key setindependent of member order, numbers by value.
Numholds aDouble, so1and
1.0are the same JSON value; RFC 6902 §4.6 compares numbers by value, sothat is the intended reading and
testdepends on it.JSON.Patch(JSON.Patch.apply &doc &patch)applies a patch document — a JSON array ofoperation objects — and returns a
(Result JSON PatchError). All sixoperations are implemented, and the spec-mandated asymmetries are what most of
the tests pin down:
addinserts into an array, shifting the rest right, with-appending;into an object it inserts or replaces a member.
replaceandremoverequire the location to already exist, whereaddwould have created it.
movemay not move a location into one of its own children. This comparesdecoded pointer tokens, so
/a/bis a child of/abut/abis not, andmoving
/ato/ais allowed.copyhas no such restriction.testcompares withJSON.=.A failure returns the index of the operation that failed, and application
is atomic:
applyborrows the document and threads a copy through theoperations, so a patch that fails halfway leaves the caller's document
untouched. There is an explicit test for that.
JSON.Pointer.array-indexchanges from private to public and documented:Patchneeds the RFC 6901 index rules — rejecting-and leading zeros — todecide whether an array token is an index.
Tests
67 new assertions, 351 total, all passing locally (
carp -x test/json.carp),plus
anglerandcarp-fmt --checkclean. The newassertions cover all sixteen RFC 6902 appendix A cases plus the boundaries
around them:
addat exactly the array length vs. past it,replaceat thelength, leading-zero and
-tokens where they are and aren't legal,~0/~1escapes in both
pathandfrom, the empty pointer for each operation,order-dependence of arrays and order-independence of objects, and one case per
PatchErrorKind.One note for the Carp side
A test helper written as
(defn patched [doc patch] ...)— parameter nameddoc, shadowing thedocbuiltin — made the compiler spin for well over tenminutes on this file instead of the usual seven seconds. Renaming the parameter
to
srccompiles normally, with a byte-identical body. That's why the helpershere take
src. Might be worth a look upstream; I didn't chase it further thanreproducing it.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.