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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e602e52eed45bcb8699e1742827fabc72f8c3579ea04d47a4a4a10349542d201
4
- data.tar.gz: a257dfe77c69f02330cb48be35dc08492eb6e09fc2c11cc177f88ca969f31ab0
3
+ metadata.gz: 7152dbc291ed6d291977555ba6a6b36f6d3d52dad57348a0baf8271212d1bb25
4
+ data.tar.gz: '083bd8ca6fac180455b4847c1634f4ac28455db8e6e85def4e14393700e3008f'
5
5
  SHA512:
6
- metadata.gz: 5c012acd8c48dde81e6c46ec45a2e8c18c4d4b790866f506610c878353ca16e98bc313a0725cc1cabd5a3765325f9e3930a436c1d8ef574513df5abf4345c798
7
- data.tar.gz: e4557199e32432b96e9d9f499fca2b9bf4f35cae723640bc02461625bb41c05efd8d9b8423f8695b3b8fca448658bfdcc12c0cb3780ead253b212098fc31f2a4
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
@@ -198,7 +198,7 @@ dependencies = [
198
198
 
199
199
  [[package]]
200
200
  name = "nosj_native"
201
- version = "0.3.0"
201
+ version = "0.3.1"
202
202
  dependencies = [
203
203
  "ahash",
204
204
  "magnus",
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.0"
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,9 @@
1
+ //! NDJSON framing vs one parse per line, plus a generate_lines
2
+ //! round-trip over whatever the streaming side yielded.
3
+ #![no_main]
4
+
5
+ use libfuzzer_sys::fuzz_target;
6
+
7
+ fuzz_target!(|data: &[u8]| {
8
+ nosj_ruby_fuzz::drive("lines_case", data);
9
+ });
@@ -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
@@ -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(super) allow_nan: bool,
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
@@ -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,
@@ -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
- // Reached only when the parse accepted them (allow_nan);
132
- // the gem's literals go straight through.
133
- self.w.value_raw(if value.is_nan() {
134
- b"NaN"
135
- } else if value > 0.0 {
136
- b"Infinity"
137
- } else {
138
- b"-Infinity"
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module NOSJ
4
4
  # The gem version.
5
- VERSION = "0.3.0"
5
+ VERSION = "0.3.1"
6
6
  end
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.0
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