5e8020aa38
Editor previews (colour swatches, arrows, embeds), the 18 obsidian-basics tasks (find and replace with regex, back/forward, auto-pair, footnotes, folding, search operators, split view, PDF/audio/ video embeds, spelling, drag and drop, pinned tabs, settings) and round 2: ```query blocks, stacked tabs, pop-out windows, tasks ticked inside embeds, text around an embed, embeds that follow edits, block menu shortcut. New crates: notescraft-regex (linear-time engine) and notescraft-syntax. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
242 lines
11 KiB
Rust
242 lines
11 KiB
Rust
//! Hostile input for the JSON half of the blocks crate: random alphabet soup, deep nesting,
|
|
//! half a million escaped quotes and absurd numbers.
|
|
//!
|
|
//! Invariant everywhere: nothing panics, the size and nesting caps are errors, and for any input
|
|
//! `format_json` accepts, formatting is idempotent and minifying loses nothing.
|
|
|
|
use notescraft_blocks::{Edited, check_json, format_json, format_json_block, json_span, looks_like_json, minify_json, minify_json_block};
|
|
use serde_json::Value;
|
|
|
|
/// Strings in the random sweep.
|
|
const RANDOM_CASES: usize = 3000;
|
|
/// Longest random string, in chars.
|
|
const RANDOM_MAX: usize = 120;
|
|
/// Bytes of the escaped-quote monster.
|
|
const QUOTE_PAIR: &str = "\\\"";
|
|
|
|
/// xorshift64: deterministic, tiny, no dependency.
|
|
struct Rng(u64);
|
|
|
|
impl Rng {
|
|
fn next(&mut self) -> u64 {
|
|
let mut x = self.0;
|
|
x ^= x << 13;
|
|
x ^= x >> 7;
|
|
x ^= x << 17;
|
|
self.0 = x;
|
|
x
|
|
}
|
|
|
|
/// Bytes from the JSON alphabet plus NUL and a few multi-byte shapes.
|
|
fn text(&mut self, len: usize) -> String {
|
|
const ALPHABET: &[char] = &[
|
|
'{', '}', '[', ']', '"', ',', ':', ' ', '\n', '\t', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'e', 'E', '.', '+', '-', 't', 'f', 'n', 'u',
|
|
'l', '\0', '\u{7f}', 'é', '😀', '\u{2028}', '\u{feff}',
|
|
];
|
|
(0..len)
|
|
.map(|_| {
|
|
let at = u64::try_from(ALPHABET.len()).unwrap_or(u64::MAX);
|
|
let at = usize::try_from(self.next() % at).unwrap_or(0);
|
|
*ALPHABET.get(at).unwrap_or(&'?')
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// One note with the hostile text as the body of a `json` code block.
|
|
fn note(body: &str) -> String {
|
|
format!("# n\n\n```json\n{body}\n```\n\nafter\n")
|
|
}
|
|
|
|
/// The edit must be exactly one splice of the old note, and the caret must be inside the new text.
|
|
fn assert_edit_is_consistent(old: &str, edited: &Edited) {
|
|
let (range, new) = &edited.replace;
|
|
let head = old.get(..range.start).unwrap_or_default();
|
|
let tail = old.get(range.end..).unwrap_or_default();
|
|
let rebuilt = format!("{head}{new}{tail}");
|
|
assert_eq!(rebuilt, edited.text, "the edit is one splice of the old text");
|
|
assert!(edited.caret <= edited.text.len(), "the caret is past the text");
|
|
assert!(edited.text.is_char_boundary(edited.caret), "the caret is not on a char boundary");
|
|
assert!(edited.text.contains("after\n"), "the rest of the note survives");
|
|
}
|
|
|
|
/// Whatever the input, formatting must be idempotent and minifying must lose nothing.
|
|
fn assert_format_properties(text: &str) {
|
|
let Ok(formatted) = format_json(text) else { return };
|
|
let minified = minify_json(text).unwrap_or_default();
|
|
assert_eq!(minify_json(&formatted).unwrap_or_default(), minified, "minify(format(x)) == minify(x) for {text:.40}");
|
|
assert_eq!(format_json(&formatted).unwrap_or_default(), formatted, "format is idempotent for {text:.40}");
|
|
assert_eq!(serde_json::from_str::<Value>(text).ok(), serde_json::from_str::<Value>(&formatted).ok(), "same value for {text:.40}");
|
|
assert!(check_json(&formatted).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn random_alphabet_soup_never_panics() {
|
|
let mut rng = Rng(0x1064_5dca_9e37_79b9);
|
|
for _ in 0..RANDOM_CASES {
|
|
let cap = u64::try_from(RANDOM_MAX).unwrap_or(u64::MAX).saturating_add(1);
|
|
let len = usize::try_from(rng.next() % cap).unwrap_or(0);
|
|
let text = rng.text(len);
|
|
let _ = check_json(&text);
|
|
let _ = looks_like_json(&text);
|
|
if let Some(span) = json_span(&text) {
|
|
assert!(span.start <= span.end && span.end <= text.len(), "span out of the body: {text:.40}");
|
|
assert!(text.is_char_boundary(span.start) && text.is_char_boundary(span.end), "span off a char boundary");
|
|
let inner = text.get(span.clone()).unwrap_or_default();
|
|
assert!(check_json(inner).is_ok(), "the span must hold valid JSON");
|
|
assert!(matches!((inner.chars().next(), inner.chars().next_back()), (Some('{'), Some('}')) | (Some('['), Some(']'))));
|
|
assert!(looks_like_json(&text));
|
|
}
|
|
assert_format_properties(&text);
|
|
}
|
|
}
|
|
|
|
/// `{"a":{"a":...1...}}` `depth` levels deep.
|
|
fn nest(depth: usize) -> String {
|
|
format!("{}1{}", "{\"a\":".repeat(depth), "}".repeat(depth))
|
|
}
|
|
|
|
#[test]
|
|
fn deep_nesting_is_handled_without_a_stack_overflow() {
|
|
let deep_ok = nest(100);
|
|
assert!(check_json(&deep_ok).is_ok(), "100 levels is valid JSON");
|
|
assert_format_properties(&deep_ok);
|
|
|
|
// 129 levels: valid JSON, and nothing overflows. See the ignored BUG test below: the rustdoc
|
|
// claims serde_json's limit of 128 applies, but `IgnoredAny` skips values iteratively.
|
|
let at_129 = nest(129);
|
|
assert!(check_json(&at_129).is_ok(), "129 levels parses");
|
|
assert!(check_json(&format!("{}0{}", "[".repeat(129), "]".repeat(129))).is_ok());
|
|
assert_format_properties(&at_129);
|
|
|
|
// Deep but small: under the byte cap, so the parser sees it and survives it.
|
|
let deep = format!("{}0{}", "[".repeat(200_000), "]".repeat(200_000));
|
|
assert!(deep.len() <= notescraft_blocks::MAX_JSON_BYTES, "{} bytes", deep.len());
|
|
assert!(check_json(&deep).is_ok(), "no stack overflow on deep nesting");
|
|
assert!(minify_json(&deep).is_ok());
|
|
|
|
// Unbalanced nesting is an error, at any depth.
|
|
for (open, close) in [("{", ""), ("", "}"), ("[[", "]]"), ("{\"a\":", ""), ("{\"a\": 1,", "")] {
|
|
let junk = format!("{}{}", open.repeat(200), close);
|
|
assert!(check_json(&junk).is_err(), "{junk:.20}");
|
|
assert!(format_json(&junk).is_err(), "{junk:.20}");
|
|
assert!(minify_json(&junk).is_err(), "{junk:.20}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "BUG: check_json's rustdoc says the parser's nesting limit applies, but 129 levels is accepted (IgnoredAny skips iteratively)"]
|
|
fn nesting_over_128_is_refused() {
|
|
let too_deep = nest(129);
|
|
assert!(check_json(&too_deep).is_err(), "129 levels should be over serde_json's limit of 128");
|
|
assert!(format_json(&too_deep).is_err());
|
|
assert!(minify_json(&too_deep).is_err());
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "BUG: format_json expands quadratically with depth: 2000 levels (6 KB in) already make a 4 MB string, so 1 MiB of nesting would try to build ~275 GB"]
|
|
fn formatting_deep_json_stays_bounded() {
|
|
let text = nest(2_000);
|
|
let formatted = format_json(&text).unwrap_or_default();
|
|
assert!(formatted.len() <= notescraft_blocks::MAX_JSON_BYTES, "{} bytes out of {} in", formatted.len(), text.len());
|
|
}
|
|
|
|
#[test]
|
|
fn a_string_with_half_a_million_escaped_quotes_survives() {
|
|
let text = format!("[{}]{}]", "\"", "").to_owned() + "E_PAIR.repeat(500_000) + "\"]";
|
|
assert!(text.len() > 1_000_000 && text.len() < notescraft_blocks::MAX_JSON_BYTES, "{} bytes", text.len());
|
|
assert!(check_json(&text).is_ok(), "a monster string is still valid JSON");
|
|
assert_format_properties(&text);
|
|
let formatted = format_json(&text).unwrap_or_default();
|
|
assert_eq!(formatted.matches(QUOTE_PAIR).count(), 500_000, "no escape is lost");
|
|
assert_eq!(minify_json(&formatted).unwrap_or_default(), minify_json(&text).unwrap_or_default());
|
|
}
|
|
|
|
#[test]
|
|
fn number_spelling_survives_formatting_byte_for_byte() {
|
|
for number in ["1e999999999", "-0", "0.1e-5", "1E5", "-0.0e0", "123456789012345678901234567890", "0", "-1.7976931348623157e308", "1e-999999"] {
|
|
let text = format!("{{\"a\":{number},\"b\":[{number}]}}");
|
|
let formatted = format_json(&text).unwrap_or_default();
|
|
assert_eq!(formatted.matches(number).count(), 2, "{number} must survive byte for byte: {formatted}");
|
|
assert_eq!(minify_json(&formatted).unwrap_or_default(), minify_json(&text).unwrap_or_default());
|
|
assert_eq!(format_json(&formatted).unwrap_or_default(), formatted.clone());
|
|
assert_eq!(serde_json::from_str::<Value>(&text).ok(), serde_json::from_str::<Value>(&formatted).ok(), "{number}");
|
|
}
|
|
assert_format_properties(r#"{"deep":[[[[{"x":1e999999999}]]]],"neg":[-0,-0.1e-5]}"#);
|
|
}
|
|
|
|
#[test]
|
|
fn the_size_cap_is_an_error_and_huge_bodies_do_not_panic() {
|
|
let over = format!("[\"{}\"]", "x".repeat(notescraft_blocks::MAX_JSON_BYTES));
|
|
assert!(check_json(&over).is_err(), "over the size cap");
|
|
assert!(format_json(&over).is_err() && minify_json(&over).is_err());
|
|
assert!(!looks_like_json(&over), "an oversized block holds no offerable JSON");
|
|
assert_eq!(json_span(&over), None);
|
|
let under = format!("[\"{}\"]", "x".repeat(100_000));
|
|
assert!(check_json(&under).is_ok());
|
|
assert_format_properties(&under);
|
|
}
|
|
|
|
#[test]
|
|
fn block_edits_on_hostile_bodies_never_panic_and_stay_consistent() {
|
|
let mut rng = Rng(0x5851_f42d_4c95_7f2d);
|
|
for case in 0..400 {
|
|
let len = usize::try_from(rng.next() % 80).unwrap_or(0);
|
|
let text = note(&rng.text(len));
|
|
let formatted = format_json_block(&text, 0);
|
|
let minified = minify_json_block(&text, 0);
|
|
if let Ok(edited) = &formatted {
|
|
assert_edit_is_consistent(&text, edited);
|
|
}
|
|
if let Ok(edited) = &minified {
|
|
assert_edit_is_consistent(&text, edited);
|
|
}
|
|
if let (Ok(f), Ok(m)) = (&formatted, &minified) {
|
|
assert_eq!(f.text, m.text, "case {case}: formatting and minifying differ");
|
|
}
|
|
}
|
|
// Absurd block indices are errors, never panics; only the fence (index 1) holds JSON.
|
|
for index in [0usize, 2, 3, 9, 10_000, usize::MAX] {
|
|
let text = note("{\"a\":1}");
|
|
assert!(format_json_block(&text, index).is_err(), "index {index}");
|
|
assert!(minify_json_block(&text, index).is_err(), "index {index}");
|
|
}
|
|
// Hostile shapes inside a fence: backticks, new lines, nul, fences of their own.
|
|
let bodies: Vec<String> = vec![
|
|
"`{\"a\":1}`".to_owned(),
|
|
"`{\"a\":${x}}`".to_owned(),
|
|
"`{}` + `{}`".to_owned(),
|
|
"{\"a\":\"```\"}".to_owned(),
|
|
"\u{0}{\"a\":1}".to_owned(),
|
|
"{\"a\":\"\\u0000\\uD83D\\uDE00\"}".to_owned(),
|
|
"```".to_owned(),
|
|
"{\"a\":1}\n{\"b\":2}".to_owned(),
|
|
"\u{feff}{\"a\":1}\u{2028}".to_owned(),
|
|
"\t{\"a\" : 1}\t".to_owned(),
|
|
"[".repeat(500),
|
|
format!("[\"{}\"]", "x".repeat(50_000)),
|
|
];
|
|
for body in &bodies {
|
|
let text = note(body);
|
|
let before = json_span(body).and_then(|span| serde_json::from_str::<Value>(body.get(span).unwrap_or_default()).ok());
|
|
for index in [0usize, 1, usize::MAX] {
|
|
if let Ok(edited) = format_json_block(&text, index) {
|
|
assert_edit_is_consistent(&text, &edited);
|
|
let after_body = json_of(&edited.text);
|
|
if let (Some(span), Some(want)) = (json_span(after_body), before.clone()) {
|
|
let got = after_body.get(span).and_then(|inner| serde_json::from_str::<Value>(inner).ok());
|
|
assert_eq!(got, Some(want), "formatting keeps the value of {body:.20}");
|
|
}
|
|
}
|
|
if let Ok(edited) = minify_json_block(&text, index) {
|
|
assert_edit_is_consistent(&text, &edited);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The body of the first fenced block in `text`.
|
|
fn json_of(text: &str) -> &str {
|
|
text.split("```").nth(1).unwrap_or_default()
|
|
}
|