nosj 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/Cargo.lock +1 -1
- data/README.md +5 -0
- data/ext/nosj/Cargo.toml +1 -1
- data/ext/nosj/fuzz/Cargo.toml +41 -0
- data/ext/nosj/fuzz/fuzz_targets/lines.rs +9 -0
- data/ext/nosj/fuzz/fuzz_targets/patch.rs +10 -0
- data/ext/nosj/fuzz/fuzz_targets/reformat.rs +10 -0
- data/ext/nosj/fuzz/src/lib.rs +48 -0
- data/ext/nosj/fuzz/src/prelude.rb +400 -0
- data/ext/nosj/src/gen/opts.rs +1 -1
- data/ext/nosj/src/parse.rs +5 -0
- data/ext/nosj/src/reformat.rs +28 -9
- data/ext/nosj/src/sink.rs +5 -0
- data/lib/nosj/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7152dbc291ed6d291977555ba6a6b36f6d3d52dad57348a0baf8271212d1bb25
|
|
4
|
+
data.tar.gz: '083bd8ca6fac180455b4847c1634f4ac28455db8e6e85def4e14393700e3008f'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 254595b89839c50a48adedc03f8ec70498cc0cf6876390565f3dd188cc5dfca564c498443d0091c23bbf6562bb11b1724a25c073fa9e76986b8b1779cc91765d
|
|
7
|
+
data.tar.gz: 4d321fb92d1199649dc10f84853aa3124e40cc642b3da680772f392c315374116f04d09882f4fd95f2c445941b384f15e23ab89fc3c735edebd8868855cb8582
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
## [0.3.1] - 2026-07-17
|
|
2
|
+
|
|
3
|
+
- Fixed: `NOSJ.minify` / `NOSJ.reformat` produced unparseable output
|
|
4
|
+
for a float that overflows to Infinity—a huge-exponent literal like
|
|
5
|
+
`1e999`, or a ~300-digit integer with an exponent (such literals
|
|
6
|
+
parse to Infinity even in strict mode, matching the `json` gem).
|
|
7
|
+
The pipe emitted a bare `Infinity` token; it now raises the same
|
|
8
|
+
`GeneratorError` that `generate` would
|
|
9
|
+
(`"Infinity not allowed in JSON"`). With `allow_nan: true` the
|
|
10
|
+
literal still passes through as `Infinity`. Found by fuzzing.
|
|
11
|
+
- Fixed (second manifestation, also found by fuzzing): the same
|
|
12
|
+
unparseable output could appear with the overflowing literal hidden
|
|
13
|
+
behind a duplicate object key. Because the reformat pipe
|
|
14
|
+
deliberately preserves duplicate-key entries, it now refuses such
|
|
15
|
+
documents with the same `GeneratorError` even though
|
|
16
|
+
`generate(parse(x))` succeeds there (last-key-wins parsing discards
|
|
17
|
+
the shadowed value)—a documented divergence.
|
|
18
|
+
- Differential fuzzing for the native extension (`ext/nosj/fuzz`):
|
|
19
|
+
three cargo-fuzz targets—reformat, NDJSON framing, and
|
|
20
|
+
byte-splicing/JSON Patch—each drive the real entry points on an
|
|
21
|
+
embedded Ruby VM and compare every input against pure-Ruby
|
|
22
|
+
reference implementations, with committed seed corpora and a weekly
|
|
23
|
+
CI workflow (`fuzz.yml`).
|
|
24
|
+
|
|
1
25
|
## [0.3.0] - 2026-07-17
|
|
2
26
|
|
|
3
27
|
- Reformat without parsing. `NOSJ.minify(json, opts)` and
|
data/Cargo.lock
CHANGED
data/README.md
CHANGED
|
@@ -434,6 +434,11 @@ bundle exec rake bench:fast # the sweep without retraining
|
|
|
434
434
|
bundle exec rake "bench:ips[twitter]" # multi-gem shoot-out (benchmark-ips); no args = full corpus
|
|
435
435
|
```
|
|
436
436
|
|
|
437
|
+
The native layer is differentially fuzzed on an embedded VM
|
|
438
|
+
(reformat pipe, NDJSON framing, splice/patch span arithmetic against
|
|
439
|
+
pure-Ruby reference implementations)—see
|
|
440
|
+
[ext/nosj/fuzz/README.md](ext/nosj/fuzz/README.md).
|
|
441
|
+
|
|
437
442
|
## Acknowledgements
|
|
438
443
|
|
|
439
444
|
Thanks to [Jean Boussier](https://github.com/byroot), [Florian Frank](https://github.com/flori), [Hiroshi Shibata](https://github.com/hsbt), [Nobuyoshi Nakada](https://github.com/nobu), [Étienne Barrié](https://github.com/etiennebarrie), and the other authors and maintainers of the [json gem](https://github.com/ruby/json)—for their work on the gem itself, for optimization ideas, and for some of the JSON documents in the benchmark corpus.
|
data/ext/nosj/Cargo.toml
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# required by lib/nosj.rb as "nosj/nosj" (Init_nosj set explicitly in
|
|
6
6
|
# lib.rs). Gem name, module, and API are plain nosj.
|
|
7
7
|
name = "nosj_native"
|
|
8
|
-
version = "0.3.
|
|
8
|
+
version = "0.3.1"
|
|
9
9
|
edition = "2021"
|
|
10
10
|
authors = ["Yaroslav Markin <yaroslav@markin.net>"]
|
|
11
11
|
license = "MIT"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "nosj-ruby-fuzz"
|
|
3
|
+
version = "0.0.0"
|
|
4
|
+
publish = false
|
|
5
|
+
edition = "2021"
|
|
6
|
+
|
|
7
|
+
[package.metadata]
|
|
8
|
+
cargo-fuzz = true
|
|
9
|
+
|
|
10
|
+
[dependencies]
|
|
11
|
+
libfuzzer-sys = "0.4"
|
|
12
|
+
# Must match the extension's own magnus line so cargo unifies the
|
|
13
|
+
# rb-sys link (one libruby, one VM).
|
|
14
|
+
magnus = { git = "https://github.com/matsadler/magnus", features = ["embed", "rb-sys"] }
|
|
15
|
+
# The extension as an rlib; its lib target is named `nosj` (the bundle
|
|
16
|
+
# name), so rename the dependency to keep fuzz code readable.
|
|
17
|
+
nosj_ext = { path = "..", package = "nosj_native" }
|
|
18
|
+
|
|
19
|
+
# Prevent this from being interpreted as part of a parent workspace.
|
|
20
|
+
[workspace]
|
|
21
|
+
|
|
22
|
+
[[bin]]
|
|
23
|
+
name = "reformat"
|
|
24
|
+
path = "fuzz_targets/reformat.rs"
|
|
25
|
+
test = false
|
|
26
|
+
doc = false
|
|
27
|
+
bench = false
|
|
28
|
+
|
|
29
|
+
[[bin]]
|
|
30
|
+
name = "lines"
|
|
31
|
+
path = "fuzz_targets/lines.rs"
|
|
32
|
+
test = false
|
|
33
|
+
doc = false
|
|
34
|
+
bench = false
|
|
35
|
+
|
|
36
|
+
[[bin]]
|
|
37
|
+
name = "patch"
|
|
38
|
+
path = "fuzz_targets/patch.rs"
|
|
39
|
+
test = false
|
|
40
|
+
doc = false
|
|
41
|
+
bench = false
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//! Byte-splicing edits and RFC 6902 patch vs a pure-Ruby tree
|
|
2
|
+
//! reference. Input is "document \0 spec": an object spec drives
|
|
3
|
+
//! splice, an array spec drives patch.
|
|
4
|
+
#![no_main]
|
|
5
|
+
|
|
6
|
+
use libfuzzer_sys::fuzz_target;
|
|
7
|
+
|
|
8
|
+
fuzz_target!(|data: &[u8]| {
|
|
9
|
+
nosj_ruby_fuzz::drive("patch_case", data);
|
|
10
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//! Reformat pipe vs whole-document parse: acceptance must agree
|
|
2
|
+
//! (valid?/stats included), minify must round-trip and be idempotent,
|
|
3
|
+
//! pretty must round-trip.
|
|
4
|
+
#![no_main]
|
|
5
|
+
|
|
6
|
+
use libfuzzer_sys::fuzz_target;
|
|
7
|
+
|
|
8
|
+
fuzz_target!(|data: &[u8]| {
|
|
9
|
+
nosj_ruby_fuzz::drive("reformat_case", data);
|
|
10
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
//! Shared harness for the extension fuzz targets: boot one embedded
|
|
2
|
+
//! Ruby VM per process, register the extension exactly as `require
|
|
3
|
+
//! "nosj/nosj"` would, and load the Ruby-side differential checks.
|
|
4
|
+
//!
|
|
5
|
+
//! The targets exercise the gem-side byte-manipulation layers the
|
|
6
|
+
//! parser crate's own fuzzers never see (reformat, NDJSON framing,
|
|
7
|
+
//! splice/patch span arithmetic). The checking logic lives in
|
|
8
|
+
//! prelude.rb; a raised Ruby exception surfaces here as an Err and
|
|
9
|
+
//! aborts the run.
|
|
10
|
+
|
|
11
|
+
use std::sync::Once;
|
|
12
|
+
|
|
13
|
+
use magnus::prelude::*;
|
|
14
|
+
use magnus::{RModule, Ruby, Value};
|
|
15
|
+
|
|
16
|
+
static VM: Once = Once::new();
|
|
17
|
+
|
|
18
|
+
const PRELUDE: &str = include_str!("prelude.rb");
|
|
19
|
+
|
|
20
|
+
/// Run one fuzz iteration with the VM up and the checks defined.
|
|
21
|
+
/// libFuzzer drives every input on the same thread, which is the
|
|
22
|
+
/// thread the VM was initialized on.
|
|
23
|
+
pub fn with_vm<F: FnOnce(&Ruby, RModule)>(f: F) {
|
|
24
|
+
VM.call_once(|| {
|
|
25
|
+
let cluster = unsafe { magnus::embed::init() };
|
|
26
|
+
// The VM must outlive every future iteration.
|
|
27
|
+
std::mem::forget(cluster);
|
|
28
|
+
let ruby = Ruby::get().expect("VM just initialized on this thread");
|
|
29
|
+
unsafe { nosj_ext::Init_nosj() };
|
|
30
|
+
ruby.eval::<Value>(PRELUDE).expect("fuzz prelude must load");
|
|
31
|
+
});
|
|
32
|
+
let ruby = Ruby::get().expect("fuzz iterations run on the VM thread");
|
|
33
|
+
let checks = ruby
|
|
34
|
+
.define_module("NOSJFuzz")
|
|
35
|
+
.expect("prelude defines NOSJFuzz");
|
|
36
|
+
f(&ruby, checks);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Feed raw bytes to a single-argument check; any raised exception
|
|
40
|
+
/// (an assertion failure or an unexpected error class) aborts.
|
|
41
|
+
pub fn drive(check: &str, data: &[u8]) {
|
|
42
|
+
with_vm(|ruby, checks| {
|
|
43
|
+
let input = ruby.str_from_slice(data);
|
|
44
|
+
if let Err(e) = checks.funcall::<_, _, Value>(check, (input,)) {
|
|
45
|
+
panic!("{check}: {e}");
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Differential checks for the extension fuzz targets. Only the native
|
|
4
|
+
# layer is loaded (Init_nosj, no lib/nosj.rb), so the error classes the
|
|
5
|
+
# extension looks up by name are defined here first.
|
|
6
|
+
module NOSJ
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
class ParserError < Error; end
|
|
9
|
+
class GeneratorError < Error; end
|
|
10
|
+
class NestingError < Error; end
|
|
11
|
+
class PatchError < Error; end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module NOSJFuzz
|
|
15
|
+
# A reference-side rejection: the case must fail natively too.
|
|
16
|
+
RefFail = Class.new(StandardError)
|
|
17
|
+
|
|
18
|
+
PARSE_FAIL = [NOSJ::ParserError, NOSJ::NestingError].freeze
|
|
19
|
+
EDIT_FAIL = (PARSE_FAIL + [NOSJ::PatchError, NOSJ::GeneratorError,
|
|
20
|
+
KeyError, ArgumentError, TypeError]).freeze
|
|
21
|
+
PRETTY = {indent: " ", space: " ", object_nl: "\n", array_nl: "\n"}.freeze
|
|
22
|
+
# A copy op can double the document (copy root into a child), so a
|
|
23
|
+
# long op list can grow it exponentially; keep runs tractable.
|
|
24
|
+
MAX_PATCH_OPS = 12
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def utf8(bytes) = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
29
|
+
|
|
30
|
+
def try_parse(s, opts = nil)
|
|
31
|
+
[:ok, NOSJ.parse_native(s, opts)]
|
|
32
|
+
rescue *PARSE_FAIL
|
|
33
|
+
[:err, nil]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# --- reformat: minify/reformat vs parse-then-generate ---------------
|
|
37
|
+
|
|
38
|
+
def reformat_case(bytes)
|
|
39
|
+
s = utf8(bytes)
|
|
40
|
+
status, value = try_parse(s)
|
|
41
|
+
|
|
42
|
+
unless NOSJ.valid_native(s, nil) == (status == :ok)
|
|
43
|
+
raise "valid? disagrees with parse on acceptance"
|
|
44
|
+
end
|
|
45
|
+
stats_ok = begin
|
|
46
|
+
NOSJ.stats_native(s, nil)
|
|
47
|
+
true
|
|
48
|
+
rescue *PARSE_FAIL
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
raise "stats disagrees with parse on acceptance" unless stats_ok == (status == :ok)
|
|
52
|
+
|
|
53
|
+
min_status, min = begin
|
|
54
|
+
[:ok, NOSJ.reformat_native(s, nil)]
|
|
55
|
+
rescue *PARSE_FAIL
|
|
56
|
+
[:err, nil]
|
|
57
|
+
rescue NOSJ::GeneratorError
|
|
58
|
+
[:generator, nil]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if status == :err
|
|
62
|
+
# The pipe may abort with GeneratorError (lone-surrogate key,
|
|
63
|
+
# non-finite float) before the parser reaches whatever made the
|
|
64
|
+
# whole document unparseable; any refusal is a refusal.
|
|
65
|
+
raise "reformat accepted what parse refused" unless [:err, :generator].include?(min_status)
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
if min_status == :generator
|
|
69
|
+
# On a document parse accepts, only an overflow-to-Infinity
|
|
70
|
+
# float (1e999, a 300-digit integer with a small exponent, maybe
|
|
71
|
+
# hidden behind a duplicate key parse would discard) or a
|
|
72
|
+
# lone-surrogate object key may abort the pipe. Rerunning with
|
|
73
|
+
# allow_nan separates them: it lifts the float refusal but not
|
|
74
|
+
# the key refusal, and the key requires a \u escape.
|
|
75
|
+
begin
|
|
76
|
+
NOSJ.reformat_native(s, {allow_nan: true})
|
|
77
|
+
rescue NOSJ::GeneratorError
|
|
78
|
+
raise "GeneratorError without a \\u escape in source" unless s.include?("\\u")
|
|
79
|
+
end
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
raise "reformat refused what parse accepted" unless min_status == :ok
|
|
83
|
+
|
|
84
|
+
reparsed = stage("reparse minify output #{min.inspect[0, 200]}") do
|
|
85
|
+
NOSJ.parse_native(min, nil)
|
|
86
|
+
end
|
|
87
|
+
raise "minify does not round-trip: #{min.inspect[0, 200]}" unless reparsed.eql?(value)
|
|
88
|
+
raise "minify is not idempotent" unless stage("re-minify") {
|
|
89
|
+
NOSJ.reformat_native(min, nil)
|
|
90
|
+
} == min
|
|
91
|
+
pretty = stage("pretty") { NOSJ.reformat_native(s, PRETTY) }
|
|
92
|
+
raise "pretty does not round-trip" unless stage("reparse pretty") {
|
|
93
|
+
NOSJ.parse_native(pretty, nil)
|
|
94
|
+
}.eql?(value)
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Rebrand an exception from a must-succeed stage with its context;
|
|
99
|
+
# these are harness violations, not expected refusals.
|
|
100
|
+
def stage(name)
|
|
101
|
+
yield
|
|
102
|
+
rescue => e
|
|
103
|
+
raise "#{name} failed: #{e.class}: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# --- lines: each_line vs one parse per line -------------------------
|
|
107
|
+
|
|
108
|
+
def lines_case(bytes)
|
|
109
|
+
s = utf8(bytes)
|
|
110
|
+
yielded = []
|
|
111
|
+
status = begin
|
|
112
|
+
NOSJ.each_line_native(s, nil) { |v| yielded << v }
|
|
113
|
+
:ok
|
|
114
|
+
rescue *PARSE_FAIL
|
|
115
|
+
:err
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
unless s.valid_encoding?
|
|
119
|
+
raise "each_line accepted invalid UTF-8" unless status == :err
|
|
120
|
+
raise "each_line yielded before the encoding gate" unless yielded.empty?
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
reference = []
|
|
125
|
+
ref_status = :ok
|
|
126
|
+
s.split("\n", -1).each do |line|
|
|
127
|
+
next if line.match?(/\A[ \t\r]*\z/)
|
|
128
|
+
line_status, value = try_parse(line)
|
|
129
|
+
if line_status == :err
|
|
130
|
+
ref_status = :err
|
|
131
|
+
break
|
|
132
|
+
end
|
|
133
|
+
reference << value
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
unless status == ref_status
|
|
137
|
+
raise "acceptance: each_line #{status}, per-line parse #{ref_status}"
|
|
138
|
+
end
|
|
139
|
+
# On error the values yielded before the bad line must still match.
|
|
140
|
+
raise "yielded values diverge from per-line parses" unless yielded.eql?(reference)
|
|
141
|
+
|
|
142
|
+
if status == :ok && !yielded.empty?
|
|
143
|
+
begin
|
|
144
|
+
ndjson = NOSJ.generate_lines_native(yielded, nil)
|
|
145
|
+
rescue NOSJ::GeneratorError
|
|
146
|
+
return # WTF-8 strings (lone surrogates) are not generable
|
|
147
|
+
end
|
|
148
|
+
back = []
|
|
149
|
+
NOSJ.each_line_native(ndjson, nil) { |v| back << v }
|
|
150
|
+
raise "generate_lines does not round-trip" unless back.eql?(yielded)
|
|
151
|
+
end
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- patch/splice: raw-byte editing vs tree editing -----------------
|
|
156
|
+
|
|
157
|
+
# Input is "document \0 spec"; a spec parsing to an object fuzzes
|
|
158
|
+
# splice (pointer => value), an array fuzzes RFC 6902 patch.
|
|
159
|
+
#
|
|
160
|
+
# The reference tree parses under the default nesting cap, so
|
|
161
|
+
# deeper-than-100 documents are exercised for crashes only, like
|
|
162
|
+
# malformed ones: resolution scans just the bytes some pointer needs
|
|
163
|
+
# (documented crate semantics), so the native side may legitimately
|
|
164
|
+
# succeed where whole-document parsing refuses.
|
|
165
|
+
def patch_case(bytes)
|
|
166
|
+
doc_bytes, sep, spec_bytes = bytes.partition("\0")
|
|
167
|
+
return if sep.empty?
|
|
168
|
+
doc = utf8(doc_bytes)
|
|
169
|
+
spec_status, spec = try_parse(utf8(spec_bytes))
|
|
170
|
+
return unless spec_status == :ok
|
|
171
|
+
# Replacement values with broken encoding (lone surrogates) raise
|
|
172
|
+
# GeneratorError on insertion; the reference cannot mirror that.
|
|
173
|
+
return unless deep_valid_encoding?(spec)
|
|
174
|
+
|
|
175
|
+
tree_status, tree = try_parse(doc)
|
|
176
|
+
# Duplicate keys: raw-byte resolution sees the first occurrence,
|
|
177
|
+
# tree materialization keeps the last; the two sides cannot agree.
|
|
178
|
+
if tree_status == :ok && NOSJ.stats_native(doc, nil)[:keys] != count_keys(tree)
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
case spec
|
|
183
|
+
when Hash
|
|
184
|
+
splice_case(doc, tree_status, tree, spec)
|
|
185
|
+
when Array
|
|
186
|
+
return if spec.length > MAX_PATCH_OPS
|
|
187
|
+
rfc6902_case(doc, tree_status, tree, spec)
|
|
188
|
+
end
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def splice_case(doc, tree_status, tree, edits)
|
|
193
|
+
native_status, result = begin
|
|
194
|
+
[:ok, NOSJ.splice_native(doc, edits, nil)]
|
|
195
|
+
rescue *EDIT_FAIL
|
|
196
|
+
[:err, nil]
|
|
197
|
+
end
|
|
198
|
+
return unless tree_status == :ok
|
|
199
|
+
|
|
200
|
+
ref_status, ref = begin
|
|
201
|
+
[:ok, ref_splice(tree, edits)]
|
|
202
|
+
rescue RefFail
|
|
203
|
+
[:err, nil]
|
|
204
|
+
end
|
|
205
|
+
unless native_status == ref_status
|
|
206
|
+
raise "splice acceptance: native #{native_status}, reference #{ref_status}"
|
|
207
|
+
end
|
|
208
|
+
return unless native_status == :ok
|
|
209
|
+
got = NOSJ.parse_native(result, {max_nesting: false})
|
|
210
|
+
raise "splice result diverges from reference" unless got.eql?(ref)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def rfc6902_case(doc, tree_status, tree, ops)
|
|
214
|
+
native_status, result = begin
|
|
215
|
+
[:ok, NOSJ.patch_native(doc, ops, nil)]
|
|
216
|
+
rescue *EDIT_FAIL
|
|
217
|
+
[:err, nil]
|
|
218
|
+
end
|
|
219
|
+
return unless tree_status == :ok
|
|
220
|
+
|
|
221
|
+
ref_status, ref = begin
|
|
222
|
+
[:ok, ref_patch(tree, ops)]
|
|
223
|
+
rescue RefFail
|
|
224
|
+
[:err, nil]
|
|
225
|
+
end
|
|
226
|
+
unless native_status == ref_status
|
|
227
|
+
raise "patch acceptance: native #{native_status}, reference #{ref_status}"
|
|
228
|
+
end
|
|
229
|
+
return unless native_status == :ok
|
|
230
|
+
got = NOSJ.parse_native(result, {max_nesting: false})
|
|
231
|
+
raise "patch result diverges from reference" unless got.eql?(ref)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# --- the pure-Ruby reference implementations ------------------------
|
|
235
|
+
|
|
236
|
+
def ref_splice(tree, edits)
|
|
237
|
+
token_lists = edits.keys.map { |ptr| ptr_tokens(ptr) }
|
|
238
|
+
token_lists.combination(2) do |a, b|
|
|
239
|
+
short, long = (a.length <= b.length) ? [a, b] : [b, a]
|
|
240
|
+
raise RefFail if long[0, short.length] == short
|
|
241
|
+
end
|
|
242
|
+
box = [tree]
|
|
243
|
+
edits.each_value.with_index do |value, i|
|
|
244
|
+
toks = token_lists[i]
|
|
245
|
+
if toks.empty?
|
|
246
|
+
box[0] = value
|
|
247
|
+
else
|
|
248
|
+
parent = ref_resolve(box[0], toks[0..-2])
|
|
249
|
+
case parent
|
|
250
|
+
when Hash
|
|
251
|
+
raise RefFail unless parent.key?(toks[-1])
|
|
252
|
+
parent[toks[-1]] = value
|
|
253
|
+
when Array
|
|
254
|
+
parent[strict_index(toks[-1], parent.size)] = value
|
|
255
|
+
else
|
|
256
|
+
raise RefFail
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
box[0]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def ref_patch(tree, ops)
|
|
264
|
+
box = [tree]
|
|
265
|
+
ops.each do |op|
|
|
266
|
+
raise RefFail unless op.is_a?(Hash)
|
|
267
|
+
name = op["op"]
|
|
268
|
+
raise RefFail unless name.is_a?(String)
|
|
269
|
+
path = op["path"]
|
|
270
|
+
raise RefFail unless path.is_a?(String)
|
|
271
|
+
case name
|
|
272
|
+
when "add" then ref_add(box, ptr_tokens(path), fetch_value(op))
|
|
273
|
+
when "replace" then ref_replace(box, ptr_tokens(path), fetch_value(op))
|
|
274
|
+
when "remove" then ref_remove(box, ptr_tokens(path))
|
|
275
|
+
when "move"
|
|
276
|
+
from = op["from"]
|
|
277
|
+
raise RefFail unless from.is_a?(String)
|
|
278
|
+
# The native side short-circuits these before validating the
|
|
279
|
+
# pointers, in this order; mirror it exactly.
|
|
280
|
+
next if path == from
|
|
281
|
+
raise RefFail if path.start_with?("#{from}/") || from.empty?
|
|
282
|
+
from_toks = ptr_tokens(from)
|
|
283
|
+
moved = ref_resolve(box[0], from_toks)
|
|
284
|
+
ref_remove(box, from_toks)
|
|
285
|
+
ref_add(box, ptr_tokens(path), moved)
|
|
286
|
+
when "copy"
|
|
287
|
+
from = op["from"]
|
|
288
|
+
raise RefFail unless from.is_a?(String)
|
|
289
|
+
ref_add(box, ptr_tokens(path), deep_dup(ref_resolve(box[0], ptr_tokens(from))))
|
|
290
|
+
when "test"
|
|
291
|
+
raise RefFail unless ref_resolve(box[0], ptr_tokens(path)) == fetch_value(op)
|
|
292
|
+
else
|
|
293
|
+
raise RefFail
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
box[0]
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def ptr_tokens(ptr)
|
|
300
|
+
return [] if ptr.empty?
|
|
301
|
+
raise RefFail unless ptr.start_with?("/")
|
|
302
|
+
# RFC 6901 unescape order: ~1 before ~0, matching the native side.
|
|
303
|
+
ptr.split("/", -1).drop(1).map { |t| t.gsub("~1", "/").gsub("~0", "~") }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def strict_index(token, size, insert: false)
|
|
307
|
+
raise RefFail unless token.match?(/\A(?:0|[1-9][0-9]*)\z/)
|
|
308
|
+
i = token.to_i
|
|
309
|
+
in_range = i < size || (insert && i == size)
|
|
310
|
+
raise RefFail unless in_range
|
|
311
|
+
i
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def ref_resolve(node, toks)
|
|
315
|
+
toks.each do |t|
|
|
316
|
+
case node
|
|
317
|
+
when Hash
|
|
318
|
+
raise RefFail unless node.key?(t)
|
|
319
|
+
node = node[t]
|
|
320
|
+
when Array
|
|
321
|
+
node = node[strict_index(t, node.size)]
|
|
322
|
+
else
|
|
323
|
+
raise RefFail
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
node
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def ref_add(box, toks, value)
|
|
330
|
+
return box[0] = value if toks.empty?
|
|
331
|
+
parent = ref_resolve(box[0], toks[0..-2])
|
|
332
|
+
case parent
|
|
333
|
+
when Hash
|
|
334
|
+
parent[toks[-1]] = value
|
|
335
|
+
when Array
|
|
336
|
+
i = (toks[-1] == "-") ? parent.size : strict_index(toks[-1], parent.size, insert: true)
|
|
337
|
+
parent.insert(i, value)
|
|
338
|
+
else
|
|
339
|
+
raise RefFail
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def ref_replace(box, toks, value)
|
|
344
|
+
return box[0] = value if toks.empty?
|
|
345
|
+
parent = ref_resolve(box[0], toks[0..-2])
|
|
346
|
+
case parent
|
|
347
|
+
when Hash
|
|
348
|
+
raise RefFail unless parent.key?(toks[-1])
|
|
349
|
+
parent[toks[-1]] = value
|
|
350
|
+
when Array
|
|
351
|
+
parent[strict_index(toks[-1], parent.size)] = value
|
|
352
|
+
else
|
|
353
|
+
raise RefFail
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def ref_remove(box, toks)
|
|
358
|
+
raise RefFail if toks.empty?
|
|
359
|
+
parent = ref_resolve(box[0], toks[0..-2])
|
|
360
|
+
case parent
|
|
361
|
+
when Hash
|
|
362
|
+
raise RefFail unless parent.key?(toks[-1])
|
|
363
|
+
parent.delete(toks[-1])
|
|
364
|
+
when Array
|
|
365
|
+
parent.delete_at(strict_index(toks[-1], parent.size))
|
|
366
|
+
else
|
|
367
|
+
raise RefFail
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def fetch_value(op)
|
|
372
|
+
raise RefFail unless op.key?("value")
|
|
373
|
+
op["value"]
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def deep_dup(v)
|
|
377
|
+
case v
|
|
378
|
+
when Hash then v.transform_values { |e| deep_dup(e) }
|
|
379
|
+
when Array then v.map { |e| deep_dup(e) }
|
|
380
|
+
else v
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def deep_valid_encoding?(v)
|
|
385
|
+
case v
|
|
386
|
+
when String then v.valid_encoding?
|
|
387
|
+
when Array then v.all? { |e| deep_valid_encoding?(e) }
|
|
388
|
+
when Hash then v.all? { |k, e| deep_valid_encoding?(k) && deep_valid_encoding?(e) }
|
|
389
|
+
else true
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def count_keys(v)
|
|
394
|
+
case v
|
|
395
|
+
when Hash then v.size + v.sum { |_, e| count_keys(e) }
|
|
396
|
+
when Array then v.sum { |e| count_keys(e) }
|
|
397
|
+
else 0
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
data/ext/nosj/src/gen/opts.rs
CHANGED
|
@@ -14,7 +14,7 @@ pub(crate) struct GenConfig {
|
|
|
14
14
|
/// 0 = unlimited.
|
|
15
15
|
pub(super) max_nesting: usize,
|
|
16
16
|
pub(super) start_depth: usize,
|
|
17
|
-
pub(
|
|
17
|
+
pub(crate) allow_nan: bool,
|
|
18
18
|
pub(super) strict: bool,
|
|
19
19
|
/// ActiveSupport walk semantics: non-native values recurse through
|
|
20
20
|
/// as_json instead of splicing to_json, and non-finite floats emit
|
data/ext/nosj/src/parse.rs
CHANGED
|
@@ -147,6 +147,11 @@ fn finish_drive(
|
|
|
147
147
|
ruby,
|
|
148
148
|
"source sequence is illegal/malformed utf-8".into(),
|
|
149
149
|
)),
|
|
150
|
+
// Also reformat-pipe-only, kept total for the same reason.
|
|
151
|
+
Err(nosj::DriveError::Sink(SinkAbort::NonFiniteFloat(spelling))) => Err(parser_error(
|
|
152
|
+
ruby,
|
|
153
|
+
format!("{spelling} not allowed in JSON"),
|
|
154
|
+
)),
|
|
150
155
|
Err(nosj::DriveError::Parse(e)) => Err(parser_error_at(
|
|
151
156
|
ruby,
|
|
152
157
|
source,
|
data/ext/nosj/src/reformat.rs
CHANGED
|
@@ -44,6 +44,9 @@ struct PipeSink<'a> {
|
|
|
44
44
|
max_nesting: usize,
|
|
45
45
|
/// For re-escaping WTF-8 string content (see [`quote_wtf8`]).
|
|
46
46
|
mode: EscapeMode,
|
|
47
|
+
/// Non-finite floats pass through as literals only when the
|
|
48
|
+
/// generate side allows them; see [`PipeSink::float`].
|
|
49
|
+
allow_nan: bool,
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
|
|
@@ -127,17 +130,28 @@ impl nosj::Sink for PipeSink<'_> {
|
|
|
127
130
|
fn float(&mut self, value: f64) -> Result<(), SinkAbort> {
|
|
128
131
|
if value.is_finite() {
|
|
129
132
|
self.w.float(value);
|
|
133
|
+
return Ok(());
|
|
134
|
+
}
|
|
135
|
+
let spelling = if value.is_nan() {
|
|
136
|
+
"NaN"
|
|
137
|
+
} else if value > 0.0 {
|
|
138
|
+
"Infinity"
|
|
130
139
|
} else {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
+
"-Infinity"
|
|
141
|
+
};
|
|
142
|
+
// Not just the allow_nan keywords: a huge-exponent literal
|
|
143
|
+
// (1e999) parses to Infinity in strict mode too, and generate
|
|
144
|
+
// refuses to emit it. Gem parity either way. The parser only
|
|
145
|
+
// delivers the f64, never the original digits, so passing the
|
|
146
|
+
// source spelling through is not an option. One asymmetry
|
|
147
|
+
// follows: the pipe streams duplicate-key entries that
|
|
148
|
+
// parse's last-key-wins would discard, so a document with an
|
|
149
|
+
// overflowing literal shadowed by a duplicate key raises here
|
|
150
|
+
// even though generate(parse(x)) would succeed.
|
|
151
|
+
if !self.allow_nan {
|
|
152
|
+
return Err(SinkAbort::NonFiniteFloat(spelling));
|
|
140
153
|
}
|
|
154
|
+
self.w.value_raw(spelling.as_bytes());
|
|
141
155
|
Ok(())
|
|
142
156
|
}
|
|
143
157
|
|
|
@@ -238,6 +252,7 @@ fn reformat_over(ruby: &Ruby, input: &[u8], opts: Value) -> Result<RString, Erro
|
|
|
238
252
|
depth: 0,
|
|
239
253
|
max_nesting: po.max_nesting,
|
|
240
254
|
mode: gcfg.mode,
|
|
255
|
+
allow_nan: gcfg.allow_nan,
|
|
241
256
|
};
|
|
242
257
|
let result = PULL_STATE.with(|state_cell| {
|
|
243
258
|
let mut state = state_cell.borrow_mut();
|
|
@@ -259,6 +274,10 @@ fn reformat_over(ruby: &Ruby, input: &[u8], opts: Value) -> Result<RString, Erro
|
|
|
259
274
|
nosj_exception(ruby, "GeneratorError"),
|
|
260
275
|
"source sequence is illegal/malformed utf-8",
|
|
261
276
|
)),
|
|
277
|
+
Err(nosj::DriveError::Sink(SinkAbort::NonFiniteFloat(spelling))) => Err(Error::new(
|
|
278
|
+
nosj_exception(ruby, "GeneratorError"),
|
|
279
|
+
format!("{spelling} not allowed in JSON"),
|
|
280
|
+
)),
|
|
262
281
|
Err(nosj::DriveError::Sink(_)) => {
|
|
263
282
|
Err(parser_error(ruby, "reformat pass aborted".into()))
|
|
264
283
|
}
|
data/ext/nosj/src/sink.rs
CHANGED
|
@@ -28,6 +28,11 @@ pub(crate) enum SinkAbort {
|
|
|
28
28
|
/// equivalent broken-coderange string. (String VALUES re-escape
|
|
29
29
|
/// as \uXXXX instead.)
|
|
30
30
|
BrokenUtf8Output,
|
|
31
|
+
/// The reformat pipe met a non-finite float without `allow_nan`.
|
|
32
|
+
/// Parsing accepts huge-exponent literals like 1e999 as Infinity
|
|
33
|
+
/// even in strict mode (gem parity), but generation refuses them;
|
|
34
|
+
/// carries the JSON spelling for the gem-exact error message.
|
|
35
|
+
NonFiniteFloat(&'static str),
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
/// `RB_INT2FIX` ported from Ruby's public inline headers: fixnums are
|
data/lib/nosj/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: nosj
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Markin
|
|
@@ -46,6 +46,12 @@ files:
|
|
|
46
46
|
- README.md
|
|
47
47
|
- ext/nosj/Cargo.toml
|
|
48
48
|
- ext/nosj/extconf.rb
|
|
49
|
+
- ext/nosj/fuzz/Cargo.toml
|
|
50
|
+
- ext/nosj/fuzz/fuzz_targets/lines.rs
|
|
51
|
+
- ext/nosj/fuzz/fuzz_targets/patch.rs
|
|
52
|
+
- ext/nosj/fuzz/fuzz_targets/reformat.rs
|
|
53
|
+
- ext/nosj/fuzz/src/lib.rs
|
|
54
|
+
- ext/nosj/fuzz/src/prelude.rb
|
|
49
55
|
- ext/nosj/src/errors.rs
|
|
50
56
|
- ext/nosj/src/files.rs
|
|
51
57
|
- ext/nosj/src/gen/errors.rs
|