@atproto/lex-json 0.0.15 → 0.1.0-next.0

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.
@@ -0,0 +1,889 @@
1
+ import { assert, describe, expect, test } from 'vitest'
2
+ import { parseCid } from '@atproto/lex-data'
3
+ import {
4
+ BASE64_NATIVE_THRESHOLD,
5
+ JsonBytesDecoder,
6
+ } from './json-bytes-decoder.js'
7
+
8
+ describe('JsonBytesDecoder', () => {
9
+ describe('valid JSON parsing', () => {
10
+ test('parses empty object', () => {
11
+ const json = '{}'
12
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
13
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
14
+ })
15
+
16
+ test('parses empty array', () => {
17
+ const json = '[]'
18
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
19
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
20
+ })
21
+
22
+ test('parses string', () => {
23
+ const json = '"hello"'
24
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
25
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
26
+ })
27
+
28
+ test('parses number', () => {
29
+ const json = '123'
30
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
31
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
32
+ })
33
+
34
+ test('parses true', () => {
35
+ const json = 'true'
36
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
37
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
38
+ })
39
+
40
+ test('parses false', () => {
41
+ const json = 'false'
42
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
43
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
44
+ })
45
+
46
+ test('parses null', () => {
47
+ const json = 'null'
48
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
49
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
50
+ })
51
+
52
+ test('parses object with multiple keys', () => {
53
+ const json = '{"a":1,"b":"test","c":true}'
54
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
55
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
56
+ })
57
+
58
+ test('parses array with multiple values', () => {
59
+ const json = '[1,"test",true,null]'
60
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
61
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
62
+ })
63
+
64
+ test('parses nested structures', () => {
65
+ const json = '{"a":{"b":[1,2,3]},"c":[{"d":4}]}'
66
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
67
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
68
+ })
69
+
70
+ test('parses object with repeated keys (last value wins, matching JSON.parse)', () => {
71
+ const json = '{"a":1,"b":2,"a":3}'
72
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
73
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
74
+ })
75
+
76
+ test('handles whitespace', () => {
77
+ const json = ' \n\t{"a" : 1 , "b" : 2} \n'
78
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
79
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
80
+ })
81
+
82
+ test('parses short ASCII string (fast path)', () => {
83
+ const json = '"id"'
84
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
85
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
86
+ })
87
+
88
+ test('parses long ASCII string (TextDecoder path)', () => {
89
+ const json = `"${'a'.repeat(100)}"`
90
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
91
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
92
+ })
93
+
94
+ test('parses UTF-8 string in short string path', () => {
95
+ const json = '"ö"'
96
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
97
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
98
+ })
99
+
100
+ test('parses UTF-8 string', () => {
101
+ const json = '"😀"'
102
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
103
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
104
+ })
105
+ })
106
+
107
+ describe('escape sequences', () => {
108
+ test('parses string with escape sequences', () => {
109
+ const json = '"test\\\\\\n\\r\\t\\b\\f\\/"'
110
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
111
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
112
+ })
113
+
114
+ test('parses escaped double quote', () => {
115
+ const json = '"Say \\"hello\\""'
116
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
117
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
118
+ })
119
+
120
+ test('parses escape sequence followed by more text', () => {
121
+ // This covers the case where we have escape + more text + end quote
122
+ const json = '"test\\nmore text here"'
123
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
124
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
125
+ })
126
+
127
+ test('parses unicode escape', () => {
128
+ const json = '"\\u0041"'
129
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
130
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
131
+ })
132
+
133
+ test('parses surrogate pair', () => {
134
+ const json = '"\\uD83D\\uDE00"'
135
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
136
+ const result = decoder.decode()
137
+ expect(result).toStrictEqual(JSON.parse(json))
138
+ assert(result === '😀')
139
+ })
140
+
141
+ test('parses multiple unicode escapes', () => {
142
+ const json = '"\\u0048\\u0065\\u006C\\u006C\\u006F"'
143
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
144
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
145
+ })
146
+
147
+ test('handles high surrogate without following low surrogate', () => {
148
+ // High surrogate followed by non-surrogate
149
+ const json = '"\\uD800\\u0041"'
150
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
151
+ const result = decoder.decode()
152
+ expect(result).toStrictEqual(JSON.parse(json))
153
+ // The exact result depends on JS implementation but should not throw
154
+ })
155
+
156
+ test('handles low surrogate without preceding high surrogate', () => {
157
+ // Low surrogate without high surrogate
158
+ const json = '"\\uDC00"'
159
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
160
+ const result = decoder.decode()
161
+ expect(result).toStrictEqual(JSON.parse(json))
162
+ })
163
+
164
+ test('handles high surrogate followed by another high surrogate', () => {
165
+ // Two high surrogates in a row
166
+ const json = '"\\uD800\\uD801"'
167
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
168
+ const result = decoder.decode()
169
+ expect(result).toStrictEqual(JSON.parse(json))
170
+ })
171
+
172
+ test('handles high surrogate at end of string', () => {
173
+ // High surrogate at the very end
174
+ const json = '"test\\uD800"'
175
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
176
+ const result = decoder.decode()
177
+ expect(result).toStrictEqual(JSON.parse(json))
178
+ })
179
+
180
+ test('handles invalid surrogate range', () => {
181
+ // Just before high surrogate range
182
+ const json1 = '"\\uD7FF"'
183
+ const decoder1 = new JsonBytesDecoder(Buffer.from(json1))
184
+ expect(decoder1.decode()).toStrictEqual(JSON.parse(json1))
185
+
186
+ // Just after low surrogate range
187
+ const json2 = '"\\uE000"'
188
+ const decoder2 = new JsonBytesDecoder(Buffer.from(json2))
189
+ expect(decoder2.decode()).toStrictEqual(JSON.parse(json2))
190
+ })
191
+ })
192
+
193
+ describe('$bytes parsing (small - manual decoding)', () => {
194
+ test('parses small $bytes with manual decoder', () => {
195
+ // 32 bytes base64 = ~24 bytes, well under 256 threshold
196
+ const base64 = 'nFERjvLLiw9qm45JrqH9QTzyC2Lu1Xb4ne6+sBrCzI0='
197
+ expect(base64.length).toBeLessThan(BASE64_NATIVE_THRESHOLD)
198
+ const decoder = new JsonBytesDecoder(
199
+ Buffer.from(`{"$bytes":"${base64}"}`),
200
+ )
201
+ const result = decoder.decode()
202
+ assert(result instanceof Uint8Array)
203
+ })
204
+
205
+ test('parses small $bytes without padding', () => {
206
+ const base64 = 'nFERjvLLiw9qm45JrqH9QTzyC2Lu1Xb4ne6+sBrCzI0'
207
+ const decoder = new JsonBytesDecoder(
208
+ Buffer.from(`{"$bytes":"${base64}"}`),
209
+ )
210
+ const result = decoder.decode()
211
+ assert(result instanceof Uint8Array)
212
+ })
213
+
214
+ test('parses small $bytes with double padding', () => {
215
+ // Test base64 with == padding to cover padding removal loop
216
+ const base64 = 'YWI=='
217
+ const decoder = new JsonBytesDecoder(
218
+ Buffer.from(`{"$bytes":"${base64}"}`),
219
+ )
220
+ const result = decoder.decode()
221
+ assert(result instanceof Uint8Array)
222
+ expect(result.length).toBe(2)
223
+ })
224
+
225
+ test('parses small $bytes with single padding', () => {
226
+ // Test base64 with = padding
227
+ const base64 = 'YWJj='
228
+ const decoder = new JsonBytesDecoder(
229
+ Buffer.from(`{"$bytes":"${base64}"}`),
230
+ )
231
+ const result = decoder.decode()
232
+ assert(result instanceof Uint8Array)
233
+ expect(result.length).toBe(3)
234
+ })
235
+
236
+ test('parses empty $bytes', () => {
237
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":""}'))
238
+ const result = decoder.decode()
239
+ assert(result instanceof Uint8Array)
240
+ expect(result.length).toBe(0)
241
+ })
242
+ })
243
+
244
+ describe('$bytes parsing (large - native decoding)', () => {
245
+ test('parses large $bytes with native decoder', () => {
246
+ // Create a base64 string > 256 chars to trigger native path
247
+ const largeData = Buffer.alloc(200).fill(42)
248
+ const base64 = largeData.toString('base64')
249
+ expect(base64.length).toBeGreaterThan(BASE64_NATIVE_THRESHOLD)
250
+
251
+ const decoder = new JsonBytesDecoder(
252
+ Buffer.from(`{"$bytes":"${base64}"}`),
253
+ )
254
+ const result = decoder.decode()
255
+ assert(result instanceof Uint8Array)
256
+ expect(result.length).toBe(200)
257
+ })
258
+ })
259
+
260
+ describe('$link parsing', () => {
261
+ test('parses valid $link', () => {
262
+ const cid = 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a'
263
+ const decoder = new JsonBytesDecoder(Buffer.from(`{"$link":"${cid}"}`))
264
+ const result = decoder.decode()
265
+ expect(result).toStrictEqual(parseCid(cid))
266
+ })
267
+ })
268
+
269
+ describe('blob object parsing', () => {
270
+ test('parses valid blob object in strict mode', () => {
271
+ const json = {
272
+ $type: 'blob',
273
+ ref: {
274
+ $link: 'bafkreiccldh766hwcnuxnf2wh6jgzepf2nlu2lvcllt63eww5p6chi4ity',
275
+ },
276
+ mimeType: 'image/jpeg',
277
+ size: 10000,
278
+ }
279
+ const decoder = new JsonBytesDecoder(Buffer.from(JSON.stringify(json)))
280
+ expect(decoder.decode()).toStrictEqual({
281
+ $type: 'blob',
282
+ ref: parseCid(
283
+ 'bafkreiccldh766hwcnuxnf2wh6jgzepf2nlu2lvcllt63eww5p6chi4ity',
284
+ ),
285
+ mimeType: 'image/jpeg',
286
+ size: 10000,
287
+ })
288
+ })
289
+ })
290
+
291
+ describe('invalid JSON - syntax errors', () => {
292
+ test('throws on unexpected data after JSON', () => {
293
+ const decoder = new JsonBytesDecoder(Buffer.from('{}extra'))
294
+ expect(() => decoder.decode()).toThrow()
295
+ })
296
+
297
+ test('throws on invalid value', () => {
298
+ const decoder = new JsonBytesDecoder(Buffer.from('invalid'))
299
+ expect(() => decoder.decode()).toThrow()
300
+ })
301
+
302
+ test('throws on unterminated string', () => {
303
+ const decoder = new JsonBytesDecoder(Buffer.from('"unterminated'))
304
+ expect(() => decoder.decode()).toThrow()
305
+ })
306
+
307
+ test('throws on unterminated string after escape', () => {
308
+ // String starts with escape but never closes
309
+ const decoder = new JsonBytesDecoder(Buffer.from('"test\\n'))
310
+ expect(() => decoder.decode()).toThrow()
311
+ })
312
+
313
+ test('throws on unescaped control character in string', () => {
314
+ const json = '"test\u0000test"' // Null byte in string
315
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
316
+ expect(() => decoder.decode()).toThrow()
317
+ expect(() => JSON.parse(json)).toThrow()
318
+ })
319
+
320
+ test('throws on control character after escape sequence', () => {
321
+ // Control character in the escape handling path
322
+ const invalidJson = Buffer.from([0x22, 0x5c, 0x6e, 0x00, 0x22]) // "\n\0"
323
+ const decoder = new JsonBytesDecoder(invalidJson)
324
+ expect(() => decoder.decode()).toThrow()
325
+ expect(() => JSON.parse(invalidJson.toString())).toThrow()
326
+ })
327
+
328
+ test('throws on invalid escape sequence', () => {
329
+ const json = '"test\\x"'
330
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
331
+ expect(() => decoder.decode()).toThrow()
332
+ expect(() => JSON.parse(json)).toThrow()
333
+ })
334
+
335
+ test('throws on invalid unicode escape', () => {
336
+ const json = '"\\uGGGG"'
337
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
338
+ expect(() => decoder.decode()).toThrow()
339
+ expect(() => JSON.parse(json)).toThrow()
340
+ })
341
+
342
+ test('throws on truncated unicode escape', () => {
343
+ const json = '"\\u00"'
344
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
345
+ expect(() => decoder.decode()).toThrow()
346
+ expect(() => JSON.parse(json)).toThrow()
347
+ })
348
+
349
+ test('throws on invalid number', () => {
350
+ const json = '123.a'
351
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
352
+ expect(() => decoder.decode()).toThrow()
353
+ expect(() => JSON.parse(json)).toThrow()
354
+ })
355
+
356
+ test('throws on invalid decimal in non-strict mode', () => {
357
+ const json = '123.'
358
+ const decoder = new JsonBytesDecoder(Buffer.from(json), false)
359
+ expect(() => decoder.decode()).toThrow()
360
+ expect(() => JSON.parse(json)).toThrow()
361
+ })
362
+
363
+ test('throws on invalid number with exponent', () => {
364
+ const json = '123e'
365
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
366
+ expect(() => decoder.decode()).toThrow()
367
+ expect(() => JSON.parse(json)).toThrow()
368
+ })
369
+
370
+ test('throws on invalid exponent in non-strict mode', () => {
371
+ const json = '1e'
372
+ const decoder = new JsonBytesDecoder(Buffer.from(json), false)
373
+ expect(() => decoder.decode()).toThrow()
374
+ expect(() => JSON.parse(json)).toThrow()
375
+ })
376
+
377
+ test('throws on unexpected character in number', () => {
378
+ const json = '12x'
379
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
380
+ expect(() => decoder.decode()).toThrow()
381
+ expect(() => JSON.parse(json)).toThrow()
382
+ })
383
+
384
+ test('throws on invalid true', () => {
385
+ const json = 'tru'
386
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
387
+ expect(() => decoder.decode()).toThrow()
388
+ expect(() => JSON.parse(json)).toThrow()
389
+ })
390
+
391
+ test('throws on invalid false', () => {
392
+ const json = 'fals'
393
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
394
+ expect(() => decoder.decode()).toThrow()
395
+ expect(() => JSON.parse(json)).toThrow()
396
+ })
397
+
398
+ test('throws on invalid null', () => {
399
+ const json = 'nul'
400
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
401
+ expect(() => decoder.decode()).toThrow()
402
+ expect(() => JSON.parse(json)).toThrow()
403
+ })
404
+
405
+ test('throws on missing object key', () => {
406
+ const json = '{:1}'
407
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
408
+ expect(() => decoder.decode()).toThrow()
409
+ expect(() => JSON.parse(json)).toThrow()
410
+ })
411
+
412
+ test('throws on missing colon in object', () => {
413
+ const json = '{"a"1}'
414
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
415
+ expect(() => decoder.decode()).toThrow()
416
+ expect(() => JSON.parse(json)).toThrow()
417
+ })
418
+
419
+ test('throws on missing comma or closing brace', () => {
420
+ const json = '{"a":1"b":2}'
421
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
422
+ expect(() => decoder.decode()).toThrow()
423
+ expect(() => JSON.parse(json)).toThrow()
424
+ })
425
+
426
+ test('throws on missing comma or closing bracket', () => {
427
+ const json = '[1 2]'
428
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
429
+ expect(() => decoder.decode()).toThrow()
430
+ expect(() => JSON.parse(json)).toThrow()
431
+ })
432
+
433
+ test('throws on __proto__ key', () => {
434
+ const decoder = new JsonBytesDecoder(Buffer.from('{"__proto__":1}'))
435
+ expect(() => decoder.decode()).toThrow()
436
+ // @NOTE JSON.parse does not throw on __proto__ key
437
+ })
438
+ })
439
+
440
+ describe('invalid JSON - $bytes errors', () => {
441
+ test('throws on unterminated $bytes string', () => {
442
+ // $bytes value without closing quote
443
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"abc'))
444
+ expect(() => decoder.decode()).toThrow()
445
+ })
446
+
447
+ test('throws on invalid base64 in small $bytes (manual decoder)', () => {
448
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"!!!"}'))
449
+ expect(() => decoder.decode()).toThrow()
450
+ })
451
+
452
+ test('throws on invalid base64 in large $bytes (native decoder)', () => {
453
+ const invalidBase64 = '!'.repeat(300)
454
+ const decoder = new JsonBytesDecoder(
455
+ Buffer.from(`{"$bytes":"${invalidBase64}"}`),
456
+ )
457
+ expect(() => decoder.decode()).toThrow()
458
+ })
459
+
460
+ test('throws on $bytes with emoji (invalid base64)', () => {
461
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"🐻"}'))
462
+ expect(() => decoder.decode()).toThrow()
463
+ })
464
+
465
+ test('throws on $bytes with extra fields in strict mode', () => {
466
+ const decoder = new JsonBytesDecoder(
467
+ Buffer.from('{"$bytes":"YWJj","extra":"field"}'),
468
+ )
469
+ expect(() => decoder.decode()).toThrow()
470
+ })
471
+
472
+ test('throws on invalid base64 with spaces', () => {
473
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"YWJ j"}'))
474
+ expect(() => decoder.decode()).toThrow()
475
+ })
476
+
477
+ test('throws on invalid base64 with newline', () => {
478
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"YWJ\\nj"}'))
479
+ expect(() => decoder.decode()).toThrow()
480
+ })
481
+
482
+ test('throws on base64 with invalid character at start', () => {
483
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"@abc"}'))
484
+ expect(() => decoder.decode()).toThrow()
485
+ })
486
+
487
+ test('throws on base64 with invalid character in middle', () => {
488
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"ab@c"}'))
489
+ expect(() => decoder.decode()).toThrow()
490
+ })
491
+
492
+ test('throws on base64 with invalid character at end', () => {
493
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"abc@"}'))
494
+ expect(() => decoder.decode()).toThrow()
495
+ })
496
+
497
+ test('throws on base64 with null byte', () => {
498
+ const invalidBytes = Buffer.from([
499
+ 0x7b, 0x22, 0x24, 0x62, 0x79, 0x74, 0x65, 0x73, 0x22, 0x3a, 0x22, 0x59,
500
+ 0x57, 0x00, 0x4a, 0x6a, 0x22, 0x7d,
501
+ ]) // {"$bytes":"YW\0Jj"}
502
+ const decoder = new JsonBytesDecoder(invalidBytes)
503
+ expect(() => decoder.decode()).toThrow()
504
+ })
505
+
506
+ test('throws on base64 with control characters', () => {
507
+ const decoder = new JsonBytesDecoder(
508
+ Buffer.from('{"$bytes":"YWJ\\u0001j"}'),
509
+ )
510
+ expect(() => decoder.decode()).toThrow()
511
+ })
512
+
513
+ test('throws on large base64 with invalid character (native decoder path)', () => {
514
+ const validBase64 = Buffer.alloc(200).fill(42).toString('base64')
515
+ const invalidBase64 =
516
+ validBase64.substring(0, 100) + '@' + validBase64.substring(101)
517
+ const decoder = new JsonBytesDecoder(
518
+ Buffer.from(`{"$bytes":"${invalidBase64}"}`),
519
+ )
520
+ expect(() => decoder.decode()).toThrow()
521
+ })
522
+
523
+ test('throws on large base64 with emoji (native decoder path)', () => {
524
+ const validBase64 = Buffer.alloc(200).fill(42).toString('base64')
525
+ const invalidBase64 =
526
+ validBase64.substring(0, 100) + '😀' + validBase64.substring(100)
527
+ const decoder = new JsonBytesDecoder(
528
+ Buffer.from(`{"$bytes":"${invalidBase64}"}`),
529
+ )
530
+ expect(() => decoder.decode()).toThrow()
531
+ })
532
+
533
+ test('throws on base64 with misplaced padding', () => {
534
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"Y=Jj"}'))
535
+ expect(() => decoder.decode()).toThrow()
536
+ })
537
+
538
+ test('throws on base64 with special characters', () => {
539
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"ab#c"}'))
540
+ expect(() => decoder.decode()).toThrow()
541
+ })
542
+
543
+ test('throws on base64 with brackets', () => {
544
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"ab[c"}'))
545
+ expect(() => decoder.decode()).toThrow()
546
+ })
547
+
548
+ test('throws on base64 with backslash', () => {
549
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"ab\\\\c"}'))
550
+ expect(() => decoder.decode()).toThrow()
551
+ })
552
+ })
553
+
554
+ describe('invalid JSON - $link errors', () => {
555
+ test('throws on unterminated $link string', () => {
556
+ // $link value without closing quote
557
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$link":"bafyrei'))
558
+ expect(() => decoder.decode()).toThrow()
559
+ })
560
+
561
+ test('throws on invalid CID in $link', () => {
562
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$link":"invalid"}'))
563
+ expect(() => decoder.decode()).toThrow()
564
+ })
565
+
566
+ test('throws on $link with extra fields in strict mode', () => {
567
+ const cid = 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a'
568
+ const decoder = new JsonBytesDecoder(
569
+ Buffer.from(`{"$link":"${cid}","extra":"field"}`),
570
+ )
571
+ expect(() => decoder.decode()).toThrow()
572
+ })
573
+ })
574
+
575
+ describe('invalid JSON - blob errors', () => {
576
+ test('throws on invalid blob object in strict mode', () => {
577
+ const decoder = new JsonBytesDecoder(
578
+ Buffer.from('{"$type":"blob","invalid":"field"}'),
579
+ )
580
+ expect(() => decoder.decode()).toThrow()
581
+ })
582
+ })
583
+
584
+ describe('invalid JSON - $type errors', () => {
585
+ test('throws on non-string $type in strict mode', () => {
586
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$type":123}'))
587
+ expect(() => decoder.decode()).toThrow()
588
+ })
589
+ })
590
+
591
+ describe('non-strict mode', () => {
592
+ test('accepts float in non-strict mode', () => {
593
+ const json = '1.5'
594
+ const decoder = new JsonBytesDecoder(Buffer.from(json), false)
595
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
596
+ })
597
+
598
+ test('accepts number with exponent in non-strict mode', () => {
599
+ const json = '1e10'
600
+ const decoder = new JsonBytesDecoder(Buffer.from(json), false)
601
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
602
+ })
603
+
604
+ test('accepts negative exponent in non-strict mode', () => {
605
+ const json = '1e-10'
606
+ const decoder = new JsonBytesDecoder(Buffer.from(json), false)
607
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
608
+ })
609
+
610
+ test('accepts positive exponent sign in non-strict mode', () => {
611
+ const json = '1e+10'
612
+ const decoder = new JsonBytesDecoder(Buffer.from(json), false)
613
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
614
+ })
615
+
616
+ test('accepts $bytes with extra fields in non-strict mode', () => {
617
+ const decoder = new JsonBytesDecoder(
618
+ Buffer.from('{"$bytes":"YWJj","extra":"field"}'),
619
+ false,
620
+ )
621
+ expect(decoder.decode()).toStrictEqual({ $bytes: 'YWJj', extra: 'field' })
622
+ })
623
+
624
+ test('accepts $link with extra fields in non-strict mode', () => {
625
+ const cid = 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a'
626
+ const decoder = new JsonBytesDecoder(
627
+ Buffer.from(`{"$link":"${cid}","extra":"field"}`),
628
+ false,
629
+ )
630
+ expect(decoder.decode()).toStrictEqual({ $link: cid, extra: 'field' })
631
+ })
632
+
633
+ test('accepts non-string $type in non-strict mode', () => {
634
+ const decoder = new JsonBytesDecoder(
635
+ Buffer.from('{"$type":123,"other":"field"}'),
636
+ false,
637
+ )
638
+ expect(decoder.decode()).toStrictEqual({ $type: 123, other: 'field' })
639
+ })
640
+
641
+ test('accepts invalid base64 in $bytes in non-strict mode', () => {
642
+ const decoder = new JsonBytesDecoder(
643
+ Buffer.from('{"$bytes":"!!!"}'),
644
+ false,
645
+ )
646
+ // Should fall back to regular object parsing
647
+ expect(decoder.decode()).toStrictEqual({ $bytes: '!!!' })
648
+ })
649
+
650
+ test('accepts invalid CID in $link in non-strict mode', () => {
651
+ const decoder = new JsonBytesDecoder(
652
+ Buffer.from('{"$link":"invalid"}'),
653
+ false,
654
+ )
655
+ // Should fall back to regular object parsing
656
+ expect(decoder.decode()).toStrictEqual({ $link: 'invalid' })
657
+ })
658
+ })
659
+
660
+ describe('strict mode - number validation', () => {
661
+ test('accepts safe integer in strict mode', () => {
662
+ const json = '123'
663
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
664
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
665
+ })
666
+
667
+ test('accepts negative integer in strict mode', () => {
668
+ const json = '-123'
669
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
670
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
671
+ })
672
+
673
+ test('accepts zero in strict mode', () => {
674
+ const json = '0'
675
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
676
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
677
+ })
678
+
679
+ test('throws on float in strict mode', () => {
680
+ const decoder = new JsonBytesDecoder(Buffer.from('1.5'))
681
+ expect(() => decoder.decode()).toThrow()
682
+ })
683
+
684
+ test('accepts safe integer expressed with exponent in strict mode', () => {
685
+ // 1e10 = 10000000000 which is a safe integer - should NOT throw
686
+ const decoder = new JsonBytesDecoder(Buffer.from('1e10'))
687
+ expect(decoder.decode()).toBe(1e10)
688
+ })
689
+
690
+ test('throws on large exponent that produces unsafe integer in strict mode', () => {
691
+ // 1e20 is much larger than MAX_SAFE_INTEGER - should throw
692
+ const decoder = new JsonBytesDecoder(Buffer.from('1e20'))
693
+ expect(() => decoder.decode()).toThrow(TypeError)
694
+ })
695
+
696
+ test('throws on unsafe integer in strict mode', () => {
697
+ const unsafeInt = '99999999999999999999'
698
+ const decoder = new JsonBytesDecoder(Buffer.from(unsafeInt))
699
+ expect(() => decoder.decode()).toThrow()
700
+ })
701
+ })
702
+
703
+ describe('edge cases', () => {
704
+ test('parses empty string', () => {
705
+ const json = '""'
706
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
707
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
708
+ })
709
+
710
+ test('parses object with empty string key and value', () => {
711
+ const json = '{"":"value", "key":""}'
712
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
713
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
714
+ })
715
+
716
+ test('parses decimal number at end of input in non-strict mode', () => {
717
+ // Decimal that ends exactly at EOF
718
+ const json = '1.5'
719
+ const decoder = new JsonBytesDecoder(Buffer.from(json), false)
720
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
721
+ })
722
+
723
+ test('parses decimal with multiple digits at EOF in non-strict mode', () => {
724
+ // Decimal with multiple decimal digits ending at EOF
725
+ const json = '123.456789'
726
+ const decoder = new JsonBytesDecoder(Buffer.from(json), false)
727
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
728
+ })
729
+
730
+ test('parses zero-prefixed number correctly', () => {
731
+ const json = '0'
732
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
733
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
734
+ })
735
+
736
+ test('parses negative zero', () => {
737
+ const json = '-0'
738
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
739
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
740
+ })
741
+
742
+ test('parses minimum safe integer', () => {
743
+ const json = String(Number.MIN_SAFE_INTEGER)
744
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
745
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
746
+ })
747
+
748
+ test('parses maximum safe integer', () => {
749
+ const json = String(Number.MAX_SAFE_INTEGER)
750
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
751
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
752
+ })
753
+
754
+ test('parses object with only $ keys', () => {
755
+ const json = '{"$custom":"value","$another":"field"}'
756
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
757
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
758
+ })
759
+
760
+ test('parses object with $ key that is not special', () => {
761
+ const json = '{"$custom":"value","normal":"field"}'
762
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
763
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
764
+ })
765
+ })
766
+
767
+ describe('whitespace handling', () => {
768
+ test('handles spaces', () => {
769
+ const json = ' { "a" : 1 } '
770
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
771
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
772
+ })
773
+
774
+ test('handles tabs', () => {
775
+ const json = '\t{\t"a"\t:\t1\t}\t'
776
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
777
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
778
+ })
779
+
780
+ test('handles newlines', () => {
781
+ const json = '\n{\n"a"\n:\n1\n}\n'
782
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
783
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
784
+ })
785
+
786
+ test('handles carriage returns', () => {
787
+ const json = '\r{\r"a"\r:\r1\r}\r'
788
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
789
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
790
+ })
791
+
792
+ test('handles mixed whitespace', () => {
793
+ const json = ' \t\n\r { \t\n\r "a" \t\n\r : \t\n\r 1 \t\n\r } \t\n\r '
794
+ const decoder = new JsonBytesDecoder(Buffer.from(json))
795
+ expect(decoder.decode()).toStrictEqual(JSON.parse(json))
796
+ })
797
+ })
798
+
799
+ describe('base64 edge cases', () => {
800
+ test('parses base64 with URL-safe characters (- and _)', () => {
801
+ // URL-safe base64 uses - and _ instead of + and /
802
+ const decoder = new JsonBytesDecoder(Buffer.from('{"$bytes":"YWJj-_0"}'))
803
+ const result = decoder.decode()
804
+ assert(result instanceof Uint8Array)
805
+ })
806
+
807
+ test('parses base64 with padding at different positions', () => {
808
+ // Test different padding scenarios
809
+ const decoder1 = new JsonBytesDecoder(Buffer.from('{"$bytes":"YQ=="}'))
810
+ expect(decoder1.decode()).toBeInstanceOf(Uint8Array)
811
+
812
+ const decoder2 = new JsonBytesDecoder(Buffer.from('{"$bytes":"YWI="}'))
813
+ expect(decoder2.decode()).toBeInstanceOf(Uint8Array)
814
+
815
+ const decoder3 = new JsonBytesDecoder(Buffer.from('{"$bytes":"YWJj"}'))
816
+ expect(decoder3.decode()).toBeInstanceOf(Uint8Array)
817
+ })
818
+ })
819
+
820
+ describe('invalid UTF-8 sequences', () => {
821
+ test('throws on invalid UTF-8 in string value', () => {
822
+ // Create invalid UTF-8: 0xFF is not valid in UTF-8
823
+ const invalidUtf8 = Buffer.from([0x22, 0xff, 0x22]) // "�"
824
+ const decoder = new JsonBytesDecoder(invalidUtf8)
825
+ expect(() => decoder.decode()).toThrow()
826
+ })
827
+
828
+ test('throws on invalid UTF-8 continuation byte', () => {
829
+ // 0xC2 expects a continuation byte, but 0x20 (space) is not one
830
+ const invalidUtf8 = Buffer.from([0x22, 0xc2, 0x20, 0x22]) // Invalid sequence
831
+ const decoder = new JsonBytesDecoder(invalidUtf8)
832
+ expect(() => decoder.decode()).toThrow()
833
+ })
834
+
835
+ test('throws on truncated UTF-8 multi-byte sequence', () => {
836
+ // 0xC2 expects a continuation byte but string ends
837
+ const invalidUtf8 = Buffer.from([0x22, 0xc2, 0x22]) // Truncated 2-byte sequence
838
+ const decoder = new JsonBytesDecoder(invalidUtf8)
839
+ expect(() => decoder.decode()).toThrow()
840
+ })
841
+
842
+ test('throws on invalid UTF-8 in object key', () => {
843
+ // Invalid UTF-8 in object key
844
+ const invalidUtf8 = Buffer.from([
845
+ 0x7b, 0x22, 0xff, 0x22, 0x3a, 0x31, 0x7d,
846
+ ]) // {"�":1}
847
+ const decoder = new JsonBytesDecoder(invalidUtf8)
848
+ expect(() => decoder.decode()).toThrow()
849
+ })
850
+
851
+ test('throws on overlong UTF-8 encoding', () => {
852
+ // Overlong encoding of 'A' (should be 0x41, not 0xC1 0x81)
853
+ const invalidUtf8 = Buffer.from([0x22, 0xc1, 0x81, 0x22])
854
+ const decoder = new JsonBytesDecoder(invalidUtf8)
855
+ expect(() => decoder.decode()).toThrow()
856
+ })
857
+
858
+ test('throws on invalid 3-byte UTF-8 sequence', () => {
859
+ // 0xE0 expects 2 continuation bytes
860
+ const invalidUtf8 = Buffer.from([0x22, 0xe0, 0xa0, 0x20, 0x22])
861
+ const decoder = new JsonBytesDecoder(invalidUtf8)
862
+ expect(() => decoder.decode()).toThrow()
863
+ })
864
+
865
+ test('throws on invalid 4-byte UTF-8 sequence', () => {
866
+ // 0xF0 expects 3 continuation bytes
867
+ const invalidUtf8 = Buffer.from([0x22, 0xf0, 0x90, 0x80, 0x20, 0x22])
868
+ const decoder = new JsonBytesDecoder(invalidUtf8)
869
+ expect(() => decoder.decode()).toThrow()
870
+ })
871
+
872
+ test('throws on UTF-8 surrogate half (0xED 0xA0 0x80)', () => {
873
+ // UTF-8 should not encode surrogates directly
874
+ const invalidUtf8 = Buffer.from([0x22, 0xed, 0xa0, 0x80, 0x22])
875
+ const decoder = new JsonBytesDecoder(invalidUtf8)
876
+ expect(() => decoder.decode()).toThrow()
877
+ })
878
+
879
+ test('throws on invalid UTF-8 in long string (TextDecoder path)', () => {
880
+ // Create a long string with invalid UTF-8 to trigger TextDecoder path
881
+ const prefix = Buffer.from('"' + 'a'.repeat(25))
882
+ const invalidByte = Buffer.from([0xff])
883
+ const suffix = Buffer.from('"')
884
+ const invalidUtf8 = Buffer.concat([prefix, invalidByte, suffix])
885
+ const decoder = new JsonBytesDecoder(invalidUtf8)
886
+ expect(() => decoder.decode()).toThrow()
887
+ })
888
+ })
889
+ })