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 7 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.
`JSON.Patch.edit` now rejects an operation whose pointer length plus the depth of the value it inserts would exceed `json-max-depth`, reported as a new `PatchErrorKind.DepthLimitExceeded`. `Patch` had no depth guard at all, so a patch could build a document far deeper than `JSON.parse` would accept, and `edit-at` rebuilds the path on the way out with `Map.put`, which deep-copies the whole child subtree at each of the D levels -- O(D^2) allocations for a single operation. A 13-operation, 16877-byte patch document whose `copy` operations graft the subtree at `/a` onto the deepest empty slot doubles the document's depth per operation, reaching 8193 and 3.8 GB of resident memory before SIGSEGV. It is not stack exhaustion: the same binary dies identically with `ulimit -s` at 512 MB. Capping the pointer alone does not close it. Grafting onto a 128-token slot replaces the tail below it rather than doubling the document's size, so depth still grows by ~127 per operation -- 16 grafts reach depth 2159 in a 4673-byte patch. Bounding pointer length plus inserted-value depth holds a patched document to the parser's own limit, which is what the rest of the module already assumes.
|
Addressing @carpentry-reviewer's remaining finding: a 17 KB Reproduced firstI rebuilt the construction from scratch rather than taking the numbers. Same curve, same cliff. And it is the heap, not the stack, by the same discriminator that settled the last round: the same binary on the same input with only The suggested fix does not close itThe review proposed one Grafting onto a slot at the maximum pointer length does not double the size, because
Depth 2159 for 4.6 KB, on the "fixed" build. The pointer cap converts the growth from ×2 per operation into +127 per operation — a real improvement, but it raises the bar rather than closing the construction, which is the opposite of what the review concluded. Worth flagging since that reasoning is what the one-line fix rested on. What actually closes itThe invariant the review was reaching for is the parser's: no document nested deeper than (Int.> (Int.+ (Array.length &tokens) (JSON.Patch.edit-depth &e)) json-max-depth)
This is closed by induction rather than by exhausting a budget: every value entering the document is either parsed (≤ 128 deep) or copied from the document (≤ 128 deep by induction), so no sequence of operations, of any length, reaches 129. Both routes above now stop: It cannot affect a legitimate patch, for the reason the review gave: a document the parser produced is never more than 128 deep, so anything this rejects could only have addressed a location Error shapeNew Tests
Non-vacuity, checked rather than assumed. The new assertions don't compile against Correction to my last comment
That was wrong, and @carpentry-reviewer is right about why: the network constructs it, in 16 KB, through Scope
One thing this does not address, for the record: |
There was a problem hiding this comment.
Build & Tests
carp -x test/json.carp on 1867a88: 360 assertions, 0 failures, matching the comment. CI is green on ubuntu + macOS and the run's head_sha is 1867a888 — the commit under review. The branch's merge-base is current origin/main (4018285) and sits exactly on it, so CI is testing the merged state. Three files vs. main, docs/ untouched, no CHANGELOG in this repo so correctly none added. Still correctly a draft.
Prior feedback
My suggested fix was wrong and your rebuttal is right. The pointer-only cap rests on add into an object growing the document, when it replaces the member and discards the tail — so grafting is linear, not size-bounded, and depth 2159 in 4.6 KB is exactly what that predicts. The guard you shipped is strictly stronger than the one I proposed, so this doesn't affect the outcome, but the correction stands and it was worth catching before it became a "fixed" build that wasn't.
The depth fix is correct, and I re-derived the boundary rather than taking it. The parser accepts depth 128 and rejects 129. The guard rejects at exactly pointer-tokens + inserted-value-depth = 129, and it holds across several splits of the same sum rather than just the pointer-heavy one:
l=100 dv= 27 (sum 127) -> APPLIED, re-parses: YES
l=100 dv= 28 (sum 128) -> APPLIED, re-parses: YES
l=100 dv= 29 (sum 129) -> REJECTED: nesting depth limit exceeded
l=127 dv= 1 (sum 128) -> APPLIED, re-parses: YES
l=128 dv= 1 (sum 129) -> REJECTED: nesting depth limit exceeded
l= 64 dv= 64 (sum 128) -> APPLIED, re-parses: YES
l= 64 dv= 65 (sum 129) -> REJECTED: nesting depth limit exceeded
The right-hand column is the invariant the docstring actually claims — I serialized each applied result and fed it back through JSON.parse. It always re-parses, so "a patched document is never deeper than one JSON.parse would accept" holds as written, not just at the pointer-length boundary. The induction premise checks out too: a value sits three levels down inside a patch operation object, so the deepest value a parsed patch can deliver is ~125, and the copy route is bounded by the document itself.
Findings
A 915-byte patch document still SIGSEGVs apply, and the disclosure at the end of your comment understates it.
The route you named — copy /a -> /b, repeated — is linear, and that is genuinely a size-budget question. But copying a subtree into itself under a fresh key is exponential and adds exactly one level of depth per doubling, so the 128-deep cap permits ~126 of them:
{"op":"copy","from":"/a","path":"/a/k0"}
{"op":"copy","from":"/a","path":"/a/k1"}
...Measured on 1867a88, peak RSS sampled from VmHWM while it ran:
| ops | patch bytes | serialized result | peak RSS | result |
|---|---|---|---|---|
| 10 | 411 B | 7,680 B | 1.9 MB | ok |
| 15 | 621 B | 245,791 B | 51 MB | ok |
| 18 | 747 B | 1,966,335 B | 401 MB | ok |
| 20 | 831 B | 7,865,343 B | 1.6 GB | ok |
| 21 | 873 B | 15,730,687 B | 3.2 GB | ok |
| 22 | 915 B | — | 4.1 GB | SIGSEGV (exit 139) |
Heap, not stack, by the same discriminator that settled the last two rounds: the identical binary on the identical input still exits 139 with ulimit -s raised from 8 MB to 512 MB.
That is roughly a 4.5-million-fold amplification from input bytes to resident memory, and it is not pre-existing debt being tolerated — JSON.Patch does not exist on main at all, so this ships with the module.
The pattern is worth naming, because I think it is the actual finding. Three rounds have each closed a different door into the same room: recursion depth per operation (round 1, 760 KB), quadratic memory per document depth (round 2, 17 KB), exponential growth per document size (now, 915 B). Each fix was correct and each was narrower than the class. Patch is the one place in this module that turns a small input into an unbounded amount of work, and it has no single resource bound — the parser's json-max-depth was the only one available to borrow, and it only constrains one axis.
So I am explicitly not asking for a fourth point fix, and not asking the agent to invent a budget. Picking one is a design decision with real API surface — what the limit counts (nodes? bytes? total allocations?), whether it is configurable, whether it needs another PatchErrorKind. That is yours to make, and the numbers above are here so it is a decision rather than a surprise after merge.
Non-blocking: the README's JSON Patch section documents atomicity and the operation semantics but not that a patch can now be rejected outright for depth; the apply docstring does cover it. One sentence there would close the gap.
Verdict: revise
The depth guard is correct, exactly calibrated, and the docstring's invariant holds under re-parsing — that part is done, and your correction of my proposed fix was right on the mechanism. The RFC semantics remain the strongest part of this PR and have survived three rounds of probing unchanged. What I can't call ready is that the crash class is still reachable in 915 bytes through the size axis, in code that is new in this PR. Unlike the last two rounds there is no obvious in-module bound to borrow, so the next step is a decision about what Patch should refuse — not another round of hardening.
|
I honestly think this is fully an implementation problem, not a design problem. I don’t think there is a genuine use case for things like opying a subtree into itself under a fresh key. Those are not use cases we should tolerate, full stop. |
RFC 6902 4.4 imposes the proper-prefix constraint on `move` only, so `copy`
accepted a `from` naming an ancestor of `path`. Copying a subtree into its own
child doubles that subtree while adding exactly one level of depth, so the
128-level cap from the previous commit permits ~126 doublings: 22 operations of
{"op":"copy","from":"/a","path":"/a/k<i>"} — 915 bytes — reach 4.1 GB RSS and
SIGSEGV. Nesting a location inside itself has no legitimate use, so `copy` now
runs the same check `move` does.
Both operations go through a shared `locations-error`, and `MoveIntoOwnChild`
becomes `IntoOwnChild` since it no longer describes only `move`.
The depth-limit tests launder their copies through a root temporary, because
the direct shape they used is now rejected before the depth guard sees it.
|
@hellerve — done, as an implementation fix. No resource budget added. Reproduced firstRebuilt the construction rather than taking @carpentry-reviewer's numbers. On 915 bytes to the byte, and peak RSS ( The fix
RFC 6902 §4.4 imposes the constraint on VerifiedSame patches on
All four together now run in exit 0 at 7 MB peak RSS — flat, and it errors at operation 0 rather than doing 20-odd doublings first. What is still openA two-step launder still doubles: Tests
The depth-limit tests from the last round needed the launder: Non-vacuity: the new regression assertion doesn't compile against
|
There was a problem hiding this comment.
Build & Tests
carp -x test/json.carp on ef3e403: 366 assertions, 0 failures, matching the comment. CI is green on ubuntu + macOS and the run's head_sha is ef3e4035 — the commit under review. Merge-base is current origin/main (4018285) and the branch sits exactly on it, so CI tested the merged state. Three files vs. main, docs/ untouched, no CHANGELOG in this repo so correctly none added. carp-fmt --check and angler clean on both files. Still correctly a draft.
Prior feedback
@hellerve's call is implemented as asked, with no resource budget. apply-copy now runs the same proper-prefix check apply-move always did, both through one locations-error, and MoveIntoOwnChild becomes IntoOwnChild.
I re-derived the behaviour across 27 shapes rather than taking the description, and the check holds in both directions.
Every doubling shape is rejected at operation 0:
| shape | result |
|---|---|
copy /a -> /a/c |
IntoOwnChild |
copy /a -> /a/- (array append) |
IntoOwnChild |
copy /a -> /a/2 (append index) |
IntoOwnChild |
copy /a/0 -> /a/0/x (through an array) |
IntoOwnChild |
copy /a~1b -> /a~1b/k (decoded token a/b) |
IntoOwnChild |
copy /a~0b -> /a~0b/k (decoded token a~b) |
IntoOwnChild |
And no legitimate patch lost:
| shape | result |
|---|---|
copy /a -> /b |
applies |
copy /a/b -> /c |
applies |
copy /a/b -> /a/c (sibling inside /a) |
applies |
copy /b -> /a/c (into a different subtree) |
applies |
copy /a -> /abc (name prefix only) |
applies |
copy /a -> /a (same location, not proper) |
applies |
copy /a/0 -> /a/1 (array sibling) |
applies |
copy /a -> /a~1b (token a/b, a sibling) |
applies |
Findings
The guard closes more than the commit message claims, including the cheapest route — which nobody had named. An empty from parses to zero tokens, and proper-prefix? compares lengths first, so "" is a proper prefix of every non-empty path. That means
{"op":"copy","from":"","path":"/k0"}
{"op":"copy","from":"","path":"/k1"}— doubling the whole document in one operation per step, strictly cheaper than the /a form that prompted this — is rejected at operation 0 as well. I checked it because a root-pointer form would have walked straight around a subtree-only check; it doesn't. Worth stating on the record, since the commit message only describes the named subtree case.
The residual is exactly as disclosed — I re-derived it rather than taking the numbers. The two-step launder still doubles, on ef3e403:
| doublings | patch bytes | serialized document |
|---|---|---|
| 8 | 633 B | 4,991 B |
| 12 | 951 B | 79,875 B |
| 16 | 1,271 B | 1,278,045 B |
| 18 | 1,431 B | 5,112,189 B |
| 20 | 1,591 B | dies at a 1.8 GB address-space cap |
So the disclosure at the end of your comment is accurate in both directions: the one-operation form is closed, and the exponential class stays reachable in about 1.6 KB through /t. I am not asking for anything about that — @hellerve has answered the question twice now, and no pointer-level check can see provenance. Recording the measurement so the residual is a known quantity rather than a claim.
One stale claim in the PR description. The body still reads:
movemay not move a location into one of its own children. …copyhas no such restriction.
That second line is now false, and the first should name copy too. This body is the module's introduction and will outlive this thread — lua #11 flagged its own stale description rather than leave it, and this one is worth the same treatment.
Non-blocking, so it isn't a surprise later: copy picked up a small error-kind precedence change. A malformed from now gives invalid pointer 'a' where it used to give location 'a' does not exist (more accurate, since the pointer really is malformed), and a from that is both nonexistent and a prefix of path now gives IntoOwnChild rather than NoSuchLocation. Both match the ordering apply-move already had, so the module is internally consistent — just noting the change is real.
The depth tests survived the launder rewrite intact. The rewritten doubling-patch still stops at DepthLimitExceeded, at operation 13 rather than 6 because each doubling now costs two operations — the same step of the same recurrence. The assertion pins the kind as well as the index, so it cannot pass by failing early for a different reason.
Verdict: merge
Four rounds have each closed a real door, and this one does what @hellerve asked as an implementation fix with no budget attached: every one-operation route into the doubling class is now rejected at operation 0, including the root-pointer form that was cheaper than the one that prompted it, and 14 legitimate copy/move shapes still apply unchanged. The exponential class remains reachable in ~1.6 KB through a two-step launder — measured above, disclosed in the comment, and by the maintainer's explicit decision rather than by oversight. The only thing I'd fix before this lands is the PR description, which still tells the reader copy has no such restriction.
|
Correcting the PR description, which
As of
The error kind is |
This reverts commit ef3e403. RFC 6902 4.5 places no restriction on `copy`, so rejecting a `from` that is a proper prefix of `path` makes the module non-conforming. It rejects real patches: snapshotting a subtree under itself with {"op":"copy","from":"/a","path":"/a/snapshot"}, and -- because an empty pointer parses to zero tokens and `proper-prefix?` compares lengths first -- every `copy` out of the whole document, {"op":"copy","from":"","path":"/b"}, to any path at all. The review read that second consequence as the guard closing a cheaper route than the one it was written for; it is the larger of the two regressions. It also does not close the class it was written for. Laundering the doubling through a root temporary gives neither operation a `from` that prefixes its `path`, so no comparison of the two pointers can see it: 20 doublings through /t is a 1591-byte patch that reaches 3.57 GB of resident memory, and 24 doublings build a 312 MB document out of 1911 bytes. The following commit bounds the work instead, which is what closes both.
`copy` duplicates a subtree, so a patch can build a document exponentially
larger than itself: n operations of {"op":"copy","from":"/a","path":"/a/k<i>"}
produce 2^n nodes. Three rounds of point fixes each closed one route into that
and left the class open, because recursion depth, document depth and document
size are three axes of one problem -- `JSON.Patch` is the only place in this
module where a small input buys an unbounded amount of work, and it had no
resource bound at all. No syntactic check can close it: laundering the doubling
through a root temporary hides the provenance of the copied value from both
pointers.
`JSON.Patch.edit` is the single chokepoint through which every value enters the
document, so it now charges an insertion budget and rejects the operation that
exhausts it with a new `PatchErrorKind.SizeLimitExceeded`. The budget is
`json-max-patch-nodes` beyond the combined node count of the document and the
patch, not a flat cap, so it binds on amplification rather than on size:
- a long patch carries the nodes to pay for what it inserts, so patch length
never binds, and the 25000-operation test is unaffected;
- copying a large subtree is paid for by the document term -- a 30000-node
document may copy `/a` to `/b`, which a flat budget would refuse;
- doubling is refused, because nothing in the inputs pays for it.
The invariant is that `apply` never inserts more than its inputs plus a fixed
slack, so its work is linear in the size of what it was given. The two node
walks are O(doc) and O(patch), both dwarfed by the parses that produced them.
Measured against the parent commit: the laundered doubling that reached
3.57 GB of resident memory from a 1591-byte patch is now rejected at operation
25 with a 23 MB peak, and stays at operation 25 and 23 MB when the same patch
shape is extended to 200 operations. Memory is flat in patch length instead of
exponential.
`value-nodes` and `edit-nodes` mirror the `value-depth`/`edit-depth` pair the
depth guard already uses, and `*patch-budget*`/`*patch-inserted*` follow the
parser's own use of module globals in this file.
Seven assertions added, 367 total. Two of them pin the RFC 6902 4.5 behaviour
the reverted commit had broken, and one asserts that the rejection point does
not move with the length of the patch.
The limits paragraphs in `apply`'s docstring, the README and the comments around the budget explained why the bound exists and how generous it is, which belongs in the commit that added it. They now state the rule, in the register the rest of the module's docstrings use.
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 six operationsare 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 (§4.4). Thiscompares decoded pointer tokens, so
/a/bis a child of/abut/abisnot, and moving
/ato/ais allowed.copycarries no such restriction (§4.5), so copying a subtree under itselfand copying out of the empty pointer are both permitted.
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.
Resource limits
Patchis the one place in this module where a small input buys an unboundedamount of work, so it carries two limits. Neither binds on a patch written by
hand, and both were added in response to crashes reachable from a patch
document of a few hundred bytes.
DepthLimitExceededrejects an operation whose pointer length plus the depthof the value it inserts would exceed
json-max-depth, so a patched document isnever deeper than one
JSON.parsewould accept.SizeLimitExceededbounds duplication.copyduplicates a subtree, so noperations can produce 2^n nodes: 20 doublings is a 1591-byte patch that
reached 3.57 GB of resident memory.
editis the single chokepoint throughwhich every value enters the document, so it charges an insertion budget of
json-max-patch-nodesbeyond the combined node count of the document and thepatch. Because the budget scales with the inputs rather than being a flat cap,
it binds on amplification and not on size:
never binds and the 25,000-operation test is unaffected;
document may copy
/ato/b;The invariant is that
applynever inserts more than its inputs plus a fixedslack, so its work is linear in the size of what it was given. That doubling
patch is now rejected at operation 25 with a 23 MB peak, and stays at operation
25 and 23 MB when the same shape is extended to 200 operations: flat in patch
length instead of exponential.
Two earlier attempts at this are worth recording, since both are in the branch
history. Capping pointer length alone does not bound depth, because
addintoan object replaces the member and discards the tail below it, so grafting grows
depth linearly rather than dying on size. And rejecting a
copywhosefromis a proper prefix of its
path(ef3e403, since reverted incdcba97) isboth non-conforming, it rejects every
copyout of the empty pointer, andineffective, since laundering the doubling through a root temporary hides the
provenance of the copied value from both pointers.
Tests
83 new assertions, 367 total, all passing locally (
carp -x test/json.carp),plus
anglerandcarp-fmt --checkclean. They cover all sixteen RFC 6902appendix A cases plus the boundaries around them:
addat exactly the arraylength vs. past it,
replaceat the length, leading-zero and-tokens wherethey are and aren't legal,
~0/~1escapes in bothpathandfrom, theempty pointer for each operation, order-dependence of arrays and
order-independence of objects, and one case per
PatchErrorKind. The limitsadd both halves of each boundary, the RFC 6902 §4.5 shapes that must keep
working, and an assertion that the size limit does not move with the length of
the patch.
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). The last two commits and
this description are Veit's, replacing the agent's
copyrestriction with aresource bound.