Skip to content

Commit c3cb36c

Browse files
tmmampagent
andauthored
fix(zero-rust): take JSON nested as deep as zero-cache writes (#205)
The parser rejected JSON nested deeper than 1,500 levels, because cloning, comparing, and writing a value recursed and a debug build overflowed a 2 MB thread stack by about 2,400 to 3,500 levels. zero-cache writes up to about 4,100, and JS takes them, so a row with a JSON column nested deeper than 1,500 made the client reject the poke, disconnect, and fail the same way after every reconnect. Clone, PartialEq, and json::write now recurse 128 levels and then go on over a heap stack, and MAX_DEPTH is 5,000. Dropping fits in a 2 MB debug stack at that depth without changes. The conformance runner no longer has a nesting exception: harden-json's 1,502-level case matches JS exactly, and a rejection for depth now counts as a failure. PARITY.md records why the two remaining exceptions (lone surrogates, unwritable JSON paths) stay. Amp-Thread-ID: https://ampcode.com/threads/T-db04f816-97ed-4bc9-b19e-9ee68611a0ab Co-authored-by: Amp <amp@ampcode.com>
1 parent e3789b0 commit c3cb36c

7 files changed

Lines changed: 288 additions & 33 deletions

File tree

‎.changeset/zero-rust-json-depth.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'zero-rust': patch
3+
---
4+
5+
Take JSON nested up to 5,000 levels deep, more than zero-cache can write, instead of rejecting it past 1,500: a row with a JSON column nested that deep no longer makes the client disconnect after every reconnect. Cloning, comparing, and writing such a value no longer recurse past a hundred or so levels, so it fits in a 2 MB thread stack even in a debug build.

‎rust/PARITY.md‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,19 @@ shared zbugs model (`apps/zbugs-model`); a GPUI app is in progress.
193193
## Documented differences
194194

195195
Each is counted separately in the conformance report, never as an exact
196-
match.
197-
198-
| Difference | Why |
199-
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
200-
| Lone surrogate | A Rust `String` can't hold a lone surrogate, which a JS string can. The parser reads an escaped one (`"\ud800"`) as U+FFFD, as the Swift client does; such a case matches when JS's output, with each such escape read as U+FFFD, is what Rust writes. Real servers send valid UTF-8, so this only shows in fuzzing. |
201-
| Nesting | JSON nested deeper than `json::MAX_DEPTH` (1,500) is rejected. Dropping, comparing, and writing a value recurse, and a debug build overflows a 2 MB thread stack by 3,000 levels. JS has no limit short of its own stack (zero-cache writes about 4,100 levels). The Swift client has the same limit. |
202-
| Unwritable path | A JSON path index is a `u32`, so a program with a negative or fractional index (`json('metadata', -1)`) can't be written in Rust. JS rejects it with "Invalid JSON path segment …"; such a case matches when JS rejects it that way. An index past `MAX_JSON_PATH_INDEX` can be written, and is rejected with JS's message. |
196+
match. Both come from what Rust's types can hold, not from the client's
197+
logic, and are kept on purpose.
198+
199+
| Difference | Why it stays |
200+
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
201+
| Lone surrogate | A Rust `String` can't hold a lone surrogate, which a JS string can. The parser reads an escaped one (`"\ud800"`) as U+FFFD, as the Swift client does; such a case matches when JS's output, with each such escape read as U+FFFD, is what Rust writes. Holding one would take a string type of Rust's own (WTF-8) throughout the client and in every app's code. Postgres text and `jsonb` can't store one: only a `json` (not `jsonb`) column, which keeps its text as written, could bring one to the client, and it shows as U+FFFD, as a browser renders it. Nearly all such cases come from the JSON fuzz corpus. |
202+
| Unwritable path | A JSON path index is a `u32`, so a program with a negative or fractional index (`json('metadata', -1)`) can't be written in Rust. JS rejects it with "Invalid JSON path segment …"; such a case matches when JS rejects it that way. Rust's types rule out at compile time what JS throws for at run time. An index past `MAX_JSON_PATH_INDEX` can be written, and is rejected with JS's message. |
203+
204+
JSON nesting is no longer a difference. JSON nested up to `json::MAX_DEPTH`
205+
(5,000) levels deep, more than the about 4,100 zero-cache can write, is
206+
parsed, cloned, compared, written, and dropped within a 2 MB thread stack,
207+
even in a debug build, so the client takes any value the server sends.
208+
Deeper text is rejected ("JSON nested too deeply"), where JS takes whatever
209+
fits its stack. The Swift client rejects past 1,500 levels, so a row with a
210+
JSON column nested deeper than that makes it disconnect after every
211+
reconnect. Formatting a value with `Debug` still recurses.

‎rust/conformance/src/suites/json.rs‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,6 @@ use zero_client::json::{self, Value, number};
1010
pub const LONE_SURROGATE: &str =
1111
"lone surrogate: a Rust String can't hold one, so `\\ud800` parses as U+FFFD";
1212

13-
/// The exception for JSON nested deeper than `json::MAX_DEPTH`.
14-
pub const TOO_DEEP: &str = "nesting: JSON deeper than json::MAX_DEPTH is rejected";
15-
1613
pub fn suites() -> Vec<Suite> {
1714
vec![
1815
Suite {
@@ -95,7 +92,6 @@ fn parse_and_stringify(c: &Value, _: &Value) -> Result<Outcome, String> {
9592
let ok = c.bool("ok")?;
9693
let value = match json::parse(input) {
9794
Ok(value) => value,
98-
Err(e) if ok && e.message == json::TOO_DEEP => return Ok(Outcome::Exception(TOO_DEEP)),
9995
Err(e) => {
10096
ensure(!ok, || format!("rejected {}: {e}", quote(input)))?;
10197
return Ok(Outcome::Pass);

‎rust/conformance/src/suites/protocol.rs‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,6 @@ fn downstream_text(c: &Value, _: &Value) -> Result<Outcome, String> {
186186
let result = Downstream::from_text(text);
187187
match (result, c.bool("ok")?) {
188188
(Ok(_), true) | (Err(_), false) => Ok(Outcome::Pass),
189-
(Err(e), true) if e.message.contains(json::TOO_DEEP) => {
190-
Ok(Outcome::Exception(super::json::TOO_DEEP))
191-
}
192189
(Ok(_), false) => Err(format!("accepted {}", quote(text))),
193190
(Err(e), true) => Err(format!("rejected {}: {e}", quote(text))),
194191
}

‎rust/zero-client/src/json/parse.rs‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ impl std::error::Error for ParseError {}
2020

2121
/// Nesting deeper than this is rejected with [`TOO_DEEP`].
2222
///
23-
/// Parsing doesn't recurse, but dropping, comparing, and writing a value do,
24-
/// so the limit keeps them within a 2 MB thread stack (the default for
25-
/// spawned Rust threads) in a debug build, which overflows one by 3,000
26-
/// levels. JS has no limit short of its own stack; see `rust/PARITY.md`.
27-
pub const MAX_DEPTH: usize = 1_500;
23+
/// It is above the about 4,100 levels zero-cache can write, so the client
24+
/// takes any value the server sends. JS has no limit short of its own
25+
/// stack; see `rust/PARITY.md`. Parsing, cloning, comparing, and writing a
26+
/// value don't recurse that deep, and dropping one fits in a 2 MB thread
27+
/// stack (the default for spawned Rust threads) even in a debug build.
28+
pub const MAX_DEPTH: usize = 5_000;
2829

2930
/// The message of the error for JSON nested deeper than [`MAX_DEPTH`].
3031
pub const TOO_DEEP: &str = "JSON nested too deeply";

‎rust/zero-client/src/json/stringify.rs‎

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
use super::value::RECURSION_DEPTH;
12
use super::{Value, number};
3+
use crate::map;
24

35
/// Encodes a value as `JSON.stringify` does: the same string escaping and
46
/// number formatting, with non-finite numbers written as `null`.
@@ -13,34 +15,99 @@ pub fn stringify(value: &Value) -> String {
1315

1416
/// Appends `value` as JSON text.
1517
pub fn write(value: &Value, out: &mut String) {
18+
write_within(value, out, RECURSION_DEPTH);
19+
}
20+
21+
/// [`write`], recursing `depth` levels deep.
22+
fn write_within(value: &Value, out: &mut String, depth: usize) {
1623
match value {
17-
Value::Null => out.push_str("null"),
18-
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
19-
Value::Number(n) if n.is_finite() => out.push_str(&number::format(*n)),
20-
Value::Number(_) => out.push_str("null"),
21-
Value::String(s) => write_string(s, out),
22-
Value::Array(a) => {
24+
Value::Array(a) if depth > 0 => {
2325
out.push('[');
2426
for (i, e) in a.iter().enumerate() {
2527
if i > 0 {
2628
out.push(',');
2729
}
28-
write(e, out);
30+
write_within(e, out, depth - 1);
2931
}
3032
out.push(']');
3133
}
32-
Value::Object(o) => {
34+
Value::Object(o) if depth > 0 => {
3335
out.push('{');
3436
for (i, (k, v)) in o.iter().enumerate() {
3537
if i > 0 {
3638
out.push(',');
3739
}
3840
write_string(k, out);
3941
out.push(':');
40-
write(v, out);
42+
write_within(v, out, depth - 1);
4143
}
4244
out.push('}');
4345
}
46+
Value::Array(_) | Value::Object(_) => write_iteratively(value, out),
47+
scalar => write_scalar(scalar, out),
48+
}
49+
}
50+
51+
fn write_iteratively(value: &Value, out: &mut String) {
52+
enum Open<'a> {
53+
Array(std::slice::Iter<'a, Value>),
54+
Object(map::Iter<'a, Value>),
55+
}
56+
// The arrays and objects being written, each with whether it has
57+
// written an element yet.
58+
let mut stack: Vec<(Open, bool)> = Vec::new();
59+
let mut next = Some(value);
60+
loop {
61+
match next.take() {
62+
Some(Value::Array(a)) => {
63+
out.push('[');
64+
stack.push((Open::Array(a.iter()), false));
65+
}
66+
Some(Value::Object(o)) => {
67+
out.push('{');
68+
stack.push((Open::Object(o.iter()), false));
69+
}
70+
Some(scalar) => write_scalar(scalar, out),
71+
None => {}
72+
}
73+
let Some((open, started)) = stack.last_mut() else {
74+
return;
75+
};
76+
let element = match open {
77+
Open::Array(rest) => rest.next().map(|v| (None, v)),
78+
Open::Object(rest) => rest.next().map(|(k, v)| (Some(k), v)),
79+
};
80+
match element {
81+
Some((key, v)) => {
82+
if *started {
83+
out.push(',');
84+
}
85+
*started = true;
86+
if let Some(key) = key {
87+
write_string(key, out);
88+
out.push(':');
89+
}
90+
next = Some(v);
91+
}
92+
None => {
93+
out.push(if matches!(open, Open::Array(_)) {
94+
']'
95+
} else {
96+
'}'
97+
});
98+
stack.pop();
99+
}
100+
}
101+
}
102+
}
103+
104+
fn write_scalar(value: &Value, out: &mut String) {
105+
match value {
106+
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
107+
Value::Number(n) if n.is_finite() => out.push_str(&number::format(*n)),
108+
Value::String(s) => write_string(s, out),
109+
// Null, the non-finite numbers, and (never passed here) containers.
110+
_ => out.push_str("null"),
44111
}
45112
}
46113

0 commit comments

Comments
 (0)