// Tests of the CBOR profile of spec §58, ported from the tests of package // codec of datekeys-go (codec_test.go and internal_test.go, the fuzz targets // as properties over seeded inputs) and from cbor.test.ts of datekeys-ts. // Every error text is the one that the Go reference prints for the same // input (Go 1.26.8, datekeys-go at 601e6d2). import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'package:datekeys/datekeys.dart'; import 'package:test/test.dart'; import 'cbor_reference.dart' as ref; const nc = 'ERR_NON_CANONICAL_CBOR'; // One backslash, for the texts of Go's %q. const bs = '\\'; Uint8List h(String hex) => fromHex(hex.replaceAll(' ', '')); String hexOf(String s) => toHex(utf8.encode(s)); final maxSafe = BigInt.from(maxSafeUint); final twoPow53 = BigInt.two.pow(53); final twoPow63 = BigInt.two.pow(63); /// The DateKeysException that [f] throws. DateKeysException thrown(void Function() f) { try { f(); } on DateKeysException catch (e) { return e; } fail('no DateKeysException'); } /// The message of the DateKeysException that [f] throws. String messageOf(void Function() f) => thrown(f).message; /// The code of what [f] throws, or '' when it returns. String codeOf(void Function() f) { try { f(); } on DateKeysException catch (e) { return e.code.code; } return ''; } String encoded(void Function(CborEncoder e) write) { final e = CborEncoder(); write(e); return toHex(e.out()); } /// Runs [prog], a space-separated list of decoder calls, on the hexadecimal /// [input] and returns the first error, after checking that every later call /// throws the same exception (the run of the Go tests): /// /// m map k key e endMap a array u[] uint64 /// b, bstr t text d done DateKeysException? run(String input, String prog) { final d = CborDecoder(h(input)); DateKeysException? first; for (final op in prog.split(' ').where((s) => s.isNotEmpty)) { final arg = op.substring(1); DateKeysException? err; try { switch (op[0]) { case 'm': d.map(int.parse(arg)); case 'k': d.key(); case 'e': d.endMap(); case 'a': d.array(int.parse(arg)); case 'u': d.uint64(arg.isEmpty ? null : BigInt.parse(arg)); case 'b': final [lo, hi] = arg.split(',').map(int.parse).toList(); d.bstr(lo, hi); case 't': d.text(int.parse(arg)); case 'd': d.done(); default: throw ArgumentError('bad op $op'); } } on DateKeysException catch (e) { err = e; } if (first == null) { first = err; } else if (!identical(err, first)) { fail('error not sticky: $first, then $err'); } } return first; } /// The sample schema of the Go tests: {0: tstr, 1: uint, 2: bstr, ? 10: /// [* uint]}. Its decoder accepts an empty list at key 10 and its encoder /// omits an empty list, so an empty list that is present is left to the /// re-encoding check. final class Sample { String type = ''; BigInt n = BigInt.zero; Uint8List? bytes; List list = []; void decode(CborDecoder d) { final pairs = d.map(4); for (var i = 0; i < pairs; i++) { switch (d.key()) { case 0: type = d.text(16); case 1: n = d.uint64(); case 2: bytes = d.bstr(0, 64); case 10: list = [for (var j = d.array(8); j > 0; j--) d.uint64()]; case final k: throw DateKeysException(ErrorCode.nonCanonicalCbor, 'unknown key $k'); } } if (type.isEmpty || bytes == null) { throw DateKeysException(ErrorCode.nonCanonicalCbor, 'missing key'); } d.endMap(); } void encode(CborEncoder e) { e ..map(list.isEmpty ? 3 : 4) ..uint(0) ..text(type) ..uint(1) ..uint64(n) ..uint(2) ..bstr(bytes ?? Uint8List(0)); if (list.isNotEmpty) { e ..uint(10) ..array(list.length); for (final v in list) { e.uint64(v); } } } Uint8List marshal() { final e = CborEncoder(); encode(e); return e.out(); } } Sample unmarshalSample(String hex) { final s = Sample(); unmarshalCbor(h(hex), s.decode, s.encode); return s; } /// A random unsigned integer of 64 bits shifted right by 0 to 63 bits, as /// `r.Uint64() >> r.IntN(64)` of the Go tests. BigInt randomUint64(Random r) => (BigInt.from(r.nextInt(0x100000000)) << 32 | BigInt.from(r.nextInt(0x100000000))) >> r.nextInt(64); /// A random value of the profile, in the types of cbor_reference.dart, as /// randomValue of the Go tests. Object? randomValue(Random r, int depth) { final k = r.nextInt(6); if (k == 0 && depth < 4) { return [for (var i = r.nextInt(4); i > 0; i--) randomValue(r, depth + 1)]; } if (k == 1 && depth < 4) { final m = {}; for (var i = r.nextInt(4); i > 0; i--) { m[randomUint64(r)] = randomValue(r, depth + 1); } return m; } if (k == 2) return Uint8List(r.nextInt(30)); if (k == 3) return String.fromCharCode(0xe9) * r.nextInt(20); return randomUint64(r); } /// Writes [v], a value of [randomValue], with the encoder: integers up to /// 2^53-1 through uint and the larger ones through uint64. void encodeValue(CborEncoder e, Object? v) { void writeUint(BigInt i) => i <= maxSafe ? e.uint(i.toInt()) : e.uint64(i); switch (v) { case final BigInt i: writeUint(i); case final Uint8List b: e.bstr(b); case final String s: e.text(s); case final List l: e.array(l.length); for (final x in l) { encodeValue(e, x); } case final Map m: e.map(m.length); for (final k in m.keys.toList()..sort()) { writeUint(k); encodeValue(e, m[k]); } default: throw ArgumentError('${v.runtimeType}'); } } /// [b] with one to three random edits, as the mutations of a fuzzer. Uint8List mutate(Random r, List b) { final out = List.of(b); for (var n = 1 + r.nextInt(3); n > 0; n--) { final k = r.nextInt(6); if (k == 0 && out.isNotEmpty) { out[r.nextInt(out.length)] ^= 1 << r.nextInt(8); } else if (k == 1 && out.isNotEmpty) { out[r.nextInt(out.length)] = r.nextInt(256); } else if (k == 2 && out.isNotEmpty) { out.length = r.nextInt(out.length); } else if (k == 3) { out.insert(r.nextInt(out.length + 1), r.nextInt(256)); } else if (k == 4 && out.length > 1) { out.removeAt(r.nextInt(out.length)); } else { out.add(r.nextInt(256)); } } return Uint8List.fromList(out); } /// The seeds of the fuzz targets of the Go tests, and encodings of random /// values. List fuzzSeeds(Random r) => [ for (final s in [ 'a400617801170241010a82011901f4', 'a30061780117024101', 'a3006178011702f6', '9f01ff', 'a200010101', 'a1008181a10040', '64efbbbf61', '1bffffffffffffffff', 'a2006a646174656b657963617001' '01', ]) h(s), for (var i = 0; i < 100; i++) ref.marshal(randomValue(r, 0)), ]; bool containsBytes(List haystack, List needle) { for (var i = 0; i + needle.length <= haystack.length; i++) { var j = 0; while (j < needle.length && haystack[i + j] == needle[j]) { j++; } if (j == needle.length) return true; } return false; } void main() { group('CborEncoder', () { test('writes every integer and length in its shortest form', () { final cases = <(int, String)>[ (0, '00'), (23, '17'), (24, '1818'), (255, '18ff'), (256, '190100'), (65535, '19ffff'), (65536, '1a00010000'), (0xffffffff, '1affffffff'), (0x100000000, '1b0000000100000000'), (maxSafeUint, '1b001fffffffffffff'), ]; for (final (v, want) in cases) { expect(encoded((e) => e.uint(v)), want, reason: '$v'); expect(encoded((e) => e.uint64(BigInt.from(v))), want, reason: '$v'); } expect(encoded((e) => e.uint64(maxUint64)), '1bffffffffffffffff'); // {0: "x", 1: 23, 2: h'01', 10: [1, 500], 11: h'', 12: ""} final map = encoded( (e) => e ..map(6) ..uint(0) ..text('x') ..uint(1) ..uint(23) ..uint(2) ..bstr([1]) ..uint(10) ..array(2) ..uint(1) ..uint(500) ..uint(11) ..bstr([]) ..uint(12) ..text(''), ); expect(map, 'a600617801170241010a82011901f40b400c60'); final long = 'a' * 24; final s = hexOf(long); expect( encoded( (e) => e ..text(long) ..bstr(utf8.encode(long)) ..map(24) ..array(256), ), '7818${s}5818${s}b818990100', ); expect( encoded( (e) => e ..map(0x100000000) ..array(0x100000000), ), 'bb00000001000000009b0000000100000000', ); }); test('writes text as UTF-8, a leading U+FEFF included', () { expect( encoded((e) => e.text(String.fromCharCodes([0xfeff, 0x61]))), '64efbbbf61', ); expect( encoded((e) => e.text(String.fromCharCode(0x10000))), '64f0908080', ); expect( encoded((e) => e.text(String.fromCharCode(0x10ffff))), '64f48fbfbf', ); }); test('keeps the first error: later calls do nothing and out throws it', () { final first = DateKeysException(ErrorCode.nonCanonicalCbor, 'first'); final failures = { 'negative map': ( (e) => e.map(-1), 'codec: map of -1 entries: ERR_NON_CANONICAL_CBOR', ), 'negative array': ( (e) => e.array(-2), 'codec: array of -2 items: ERR_NON_CANONICAL_CBOR', ), // A Dart String cannot hold the invalid UTF-8 of the Go tests, "\xff" // and the overlong "\xc0\x80", but it can hold a lone surrogate. 'lone surrogate': ( (e) => e.text(String.fromCharCode(0xd800)), 'codec: text string "${bs}xed${bs}xa0${bs}x80" is not valid UTF-8: ' 'ERR_NON_CANONICAL_CBOR', ), 'lone surrogates': ( (e) => e.text(String.fromCharCodes([0x61, 0xdc00, 0x62, 0xd800])), 'codec: text string "a${bs}xed${bs}xb0${bs}x80b${bs}xed${bs}xa0${bs}x80"' ' is not valid UTF-8: ERR_NON_CANONICAL_CBOR', ), // Texts of Dart only: Go's Uint takes a uint64, which is never // negative nor above 2^64-1, and has no limit of 2^53-1. 'negative uint': ( (e) => e.uint(-1), 'codec: -1 is not an unsigned integer in 0..2^53-1: ' 'ERR_NON_CANONICAL_CBOR', ), 'uint above 2^53-1': ( (e) => e.uint(maxSafeUint + 1), 'codec: 9007199254740992 is not an unsigned integer in 0..2^53-1: ' 'ERR_NON_CANONICAL_CBOR', ), 'negative uint64': ( (e) => e.uint64(BigInt.from(-1)), 'codec: -1 is not an unsigned integer in 0..2^64-1: ' 'ERR_NON_CANONICAL_CBOR', ), 'uint64 above 2^64-1': ( (e) => e.uint64(maxUint64 + BigInt.one), 'codec: 18446744073709551616 is not an unsigned integer in ' '0..2^64-1: ERR_NON_CANONICAL_CBOR', ), 'fail': ((e) => e.fail(first), 'first: ERR_NON_CANONICAL_CBOR'), 'fail twice': ( (e) => e ..fail(first) ..fail(FormatException('second')), 'first: ERR_NON_CANONICAL_CBOR', ), }; for (final MapEntry(key: name, value: (failure, text)) in failures.entries) { final e = CborEncoder()..bstr(utf8.encode('secret')); failure(e); e ..uint(1) ..bstr([1]) ..text('ok') ..map(1) ..array(1); final err = thrown(e.out); expect(err.message, text, reason: name); expect(err.code, ErrorCode.nonCanonicalCbor, reason: name); expect(identical(thrown(e.out), err), isTrue, reason: name); } // Go's Fail(nil), which records nothing, has no Dart counterpart: fail // takes an exception. }); test('wipes every buffer it outgrows', () { // The internal test of the reference, through the view that out // returns: no stale copy of a secret is left behind. final secret = Uint8List(32)..fillRange(0, 32, 0xab); final e = CborEncoder()..bstr(secret); final old = e.out().buffer.asUint8List(); e.bstr(Uint8List(2 * old.length)); expect(containsBytes(old, secret.sublist(0, 8)), isFalse); expect(containsBytes(e.out(), secret), isTrue); // A buffer with room is not replaced. final f = CborEncoder(capacity: 64)..text('fits'); expect(f.out().buffer.lengthInBytes, 64); }); test('wipes the partial output when it fails', () { final e = CborEncoder()..bstr(Uint8List(8)..fillRange(0, 8, 0xab)); final partial = e.out(); e.map(-1); expect(e.out, throwsA(isA())); expect(partial, everyElement(0)); }); }); group('CborDecoder', () { test('accepts the items of the profile', () { final cases = <(String, String, String)>[ ('uint 23 inline', '17', 'u23 d'), ('uint 24 one byte', '1818', 'u24 d'), ('uint 256 two bytes', '190100', 'u d'), ('uint 65536 four bytes', '1a00010000', 'u d'), ('uint 2^32 eight bytes', '1b0000000100000000', 'u d'), ('uint 2^64-1', '1bffffffffffffffff', 'u d'), ('empty bstr', '40', 'b0,0 d'), ('bstr at its bounds', '420102', 'b2,2 d'), ('bstr 24 bytes', '5818${'00' * 24}', 'b0,24 d'), ('empty text', '60', 't0 d'), ('text with leading BOM', '64efbbbf61', 't4 d'), ('text U+10FFFF', '64f48fbfbf', 't4 d'), ('empty map', 'a0', 'm0 e d'), ('map two sorted keys', 'a200010101', 'm2 k u k u e d'), ( 'map keys 0 and 2^64-1', 'a200001bffffffffffffffff00', 'm2 k u k u e d', ), ('empty array', '80', 'a0 d'), ('array of maps', '82a10000a10101', 'a2 m1 k u e m1 k u e d'), ('nested maps', 'a100a10000', 'm1 k m1 k u e e d'), ]; for (final (name, hex, prog) in cases) { expect(run(hex, prog), isNull, reason: name); } }); test('rejects everything else with the text of the reference', () { final cases = <(String, String, String, String)>[ // Outside the profile of spec §58. ( 'negative int', '20', 'u', 'offset 0: a negative integer (initial byte 0x20) is outside the CBOR profile', ), ( 'tag', 'c101', 'u', 'offset 0: a tag (initial byte 0xc1) is outside the CBOR profile', ), ( 'tag on a byte string', 'c24101', 'b0,9', 'offset 0: a tag (initial byte 0xc2) is outside the CBOR profile', ), ( 'half float', 'f97e00', 'u', 'offset 0: a float or simple value (initial byte 0xf9) is outside the CBOR profile', ), ( 'single float', 'fa3f800000', 'u', 'offset 0: a float or simple value (initial byte 0xfa) is outside the CBOR profile', ), ( 'double float', 'fb3ff0000000000000', 'u', 'offset 0: a float or simple value (initial byte 0xfb) is outside the CBOR profile', ), ( 'false', 'f4', 'u', 'offset 0: a float or simple value (initial byte 0xf4) is outside the CBOR profile', ), ( 'true', 'f5', 'u', 'offset 0: a float or simple value (initial byte 0xf5) is outside the CBOR profile', ), ( 'null', 'f6', 'b0,9', 'offset 0: a float or simple value (initial byte 0xf6) is outside the CBOR profile', ), ( 'undefined', 'f7', 't9', 'offset 0: a float or simple value (initial byte 0xf7) is outside the CBOR profile', ), ( 'break', 'ff', 'u', 'offset 0: a float or simple value (initial byte 0xff) is outside the CBOR profile', ), ( 'indefinite array', '9f01ff', 'a9', 'offset 0: indefinite length (initial byte 0x9f)', ), ( 'indefinite map', 'bf0001ff', 'm9', 'offset 0: indefinite length (initial byte 0xbf)', ), ( 'indefinite byte string', '5f4101ff', 'b0,9', 'offset 0: indefinite length (initial byte 0x5f)', ), ( 'indefinite text', '7f6161ff', 't9', 'offset 0: indefinite length (initial byte 0x7f)', ), ( 'reserved 28', '1c', 'u', 'offset 0: reserved additional information (initial byte 0x1c)', ), ( 'reserved 29', '1d', 'u', 'offset 0: reserved additional information (initial byte 0x1d)', ), ( 'reserved 30', '1e', 'u', 'offset 0: reserved additional information (initial byte 0x1e)', ), // Shortest form. ( 'uint 23 with one extra byte', '1817', 'u', 'offset 0: 23 is not in its shortest form (initial byte 0x18)', ), ( 'uint 255 in two bytes', '1900ff', 'u', 'offset 0: 255 is not in its shortest form (initial byte 0x19)', ), ( 'uint 65535 in four bytes', '1a0000ffff', 'u', 'offset 0: 65535 is not in its shortest form (initial byte 0x1a)', ), ( 'uint 2^32-1 in eight bytes', '1b00000000ffffffff', 'u', 'offset 0: 4294967295 is not in its shortest form (initial byte 0x1b)', ), ( 'bstr length not shortest', '5800', 'b0,9', 'offset 0: 0 is not in its shortest form (initial byte 0x58)', ), ( 'map length not shortest', 'b800', 'm9', 'offset 0: 0 is not in its shortest form (initial byte 0xb8)', ), ( 'key not shortest', 'a1180000', 'm1 k', 'offset 1: 0 is not in its shortest form (initial byte 0x18)', ), // Truncation. ('empty input', '', 'u', 'offset 0: truncated input'), ('truncated uint', '1901', 'u', 'offset 0: truncated input'), ( 'truncated uint 8', '1b00000000000000', 'u', 'offset 0: truncated input', ), ( 'length beyond input', '5affffffff', 'b0,9', 'offset 5: truncated input: a byte string of 4294967295 bytes', ), ( 'bstr shorter than its length', '4300', 'b0,9', 'offset 1: truncated input: a byte string of 3 bytes', ), ( 'text shorter than its length', '6361', 't9', 'offset 1: truncated input: a text string of 3 bytes', ), ( 'map beyond input', 'a300', 'm9', 'offset 1: truncated input: map of 3 entries', ), ( 'huge map', 'bbffffffffffffffff', 'm100', 'offset 9: map of 18446744073709551615 entries, at most 100', ), ( 'array beyond input', '8300', 'a9', 'offset 1: truncated input: array of 3 items', ), ( 'huge array', '9b7fffffffffffffff', 'a100', 'offset 9: array of 9223372036854775807 items, at most 100', ), ( 'truncated inside a map', 'a200', 'm2 k u', 'offset 1: truncated input: map of 2 entries', ), // Types and bounds. ( 'bstr where uint', '4100', 'u', 'offset 0: a byte string where an unsigned integer was expected', ), ( 'uint where bstr', '00', 'b0,9', 'offset 0: an unsigned integer where a byte string was expected', ), ( 'text where bstr', '6161', 'b0,9', 'offset 0: a text string where a byte string was expected', ), ( 'bstr where text', '4161', 't9', 'offset 0: a byte string where a text string was expected', ), ( 'array where map', '80', 'm9', 'offset 0: an array where a map was expected', ), ( 'map where array', 'a0', 'a9', 'offset 0: a map where an array was expected', ), ( 'uint above max', '1818', 'u23', 'offset 2: unsigned integer 24 above 23', ), ( 'bstr below min', '4101', 'b2,9', 'offset 1: a byte string of 1 bytes outside 2..9', ), ( 'bstr above max', '420102', 'b0,1', 'offset 1: a byte string of 2 bytes outside 0..1', ), ( 'text above max', '626161', 't1', 'offset 1: a text string of 2 bytes outside 0..1', ), ( 'map above max', 'a200010101', 'm1', 'offset 1: map of 2 entries, at most 1', ), ( 'negative map max', 'a0', 'm-1', 'offset 1: map of 0 entries, at most -1', ), ( 'array above max', '820101', 'a1', 'offset 1: array of 2 items, at most 1', ), ( 'negative array max', '80', 'a-1', 'offset 1: array of 0 items, at most -1', ), // Keys. ( 'keys out of order', 'a201000001', 'm2 k u k', 'offset 3: map key 0 after key 1: keys must be strictly ascending', ), ( 'duplicate key', 'a200000001', 'm2 k u k', 'offset 3: map key 0 after key 0: keys must be strictly ascending', ), ( 'text key', 'a1616100', 'm1 k', 'offset 1: a text string where an unsigned integer was expected', ), ( 'bstr key', 'a1416100', 'm1 k', 'offset 1: a byte string where an unsigned integer was expected', ), ( 'negative key', 'a12000', 'm1 k', 'offset 1: a negative integer (initial byte 0x20) is outside the CBOR profile', ), ('key outside a map', '00', 'k', 'offset 0: map key outside a map'), ( 'key after the last entry', 'a10000', 'm1 k u k', 'offset 3: map key after the last entry', ), ( 'key after the map closed', 'a0', 'm0 e k', 'offset 1: map key outside a map', ), ( 'end outside a map', '00', 'e', 'offset 0: end of a map outside a map', ), ( 'end with entries left', 'a10000', 'm1 e', 'offset 1: 1 map entries not read', ), ('done with a map open', 'a0', 'm0 d', 'offset 1: 1 maps not closed'), // Trailing bytes and UTF-8. ('trailing byte', '0100', 'u d', 'offset 1: 1 trailing bytes'), ( 'invalid UTF-8', '61ff', 't9', 'offset 0: text string is not valid UTF-8', ), ( 'overlong UTF-8', '62c080', 't9', 'offset 0: text string is not valid UTF-8', ), ( 'UTF-8 surrogate', '63eda080', 't9', 'offset 0: text string is not valid UTF-8', ), ( 'above U+10FFFF', '64f4908080', 't9', 'offset 0: text string is not valid UTF-8', ), ( 'truncated UTF-8', '62e282', 't9', 'offset 0: text string is not valid UTF-8', ), // The first error stays. ( 'sticky after a failed read', 'f600', 'u u d', 'offset 0: a float or simple value (initial byte 0xf6) is outside the CBOR profile', ), ]; for (final (name, hex, prog, text) in cases) { final err = run(hex, prog); expect(err?.message, 'codec: $text: $nc', reason: name); expect(err?.code, ErrorCode.nonCanonicalCbor, reason: name); } }); test('names the offset of the item in its errors', () { expect( run('a2 00 01 00 02', 'm2 k u k')?.message, 'codec: offset 3: map key 0 after key 0: keys must be strictly ' 'ascending: ERR_NON_CANONICAL_CBOR', ); }); test('copies byte strings', () { final input = h('43010203'); final d = CborDecoder(input); final b = d.bstr(3, 3); d.done(); input[1] = 9; expect(b, [1, 2, 3]); expect(CborDecoder(h('40')).bstr(0, 0), isEmpty); }); test('keeps a leading U+FEFF of a text', () { expect(CborDecoder(h('64efbbbf61')).text(4).codeUnits, [0xfeff, 0x61]); expect(CborDecoder(h('66efbbbfefbbbf')).text(6).codeUnits, [ 0xfeff, 0xfeff, ]); expect(CborDecoder(h('63ed9fbf')).text(3).codeUnits, [0xd7ff]); }); test( 'takes the bounds of uint in 0..2^53-1 and of uint64 in 0..2^64-1', () { expect(CborDecoder(h('04')).uint(4), 4); expect(() => CborDecoder(h('00')).uint(-1), throwsArgumentError); expect( () => CborDecoder(h('00')).uint(maxSafeUint + 1), throwsArgumentError, ); expect( () => CborDecoder(h('00')).uint64(BigInt.from(-1)), throwsArgumentError, ); expect( () => CborDecoder(h('00')).uint64(maxUint64 + BigInt.one), throwsArgumentError, ); }, ); }); group('integers at 2^53-1, 2^53, 2^63 and 2^64-1', () { test('are read exactly', () { final d = CborDecoder( h( '1b001fffffffffffff 1b0020000000000000 1b8000000000000000 ' '1bffffffffffffffff', ), ); expect(d.uint64(), maxSafe); expect(d.uint64(), twoPow53); expect(d.uint64(), twoPow63); expect(d.uint64(), maxUint64); d.done(); expect('$maxUint64', '18446744073709551615'); expect(CborDecoder(h('1b001fffffffffffff')).uint(), maxSafeUint); // A map key is an int up to 2^53-1 and a BigInt above. final k = CborDecoder( h( 'a4 1b001fffffffffffff 00 1b0020000000000000 00 ' '1b8000000000000000 00 1bffffffffffffffff 00', ), ); expect(k.map(4), 4); final keys = []; for (var i = 0; i < 4; i++) { keys.add(k.key()); k.uint(0); } k ..endMap() ..done(); expect(keys[0], isA()); expect(keys[0], maxSafeUint); expect(keys.skip(1), everyElement(isA())); expect(keys.skip(1), [twoPow53, twoPow63, maxUint64]); // Keys above 2^53 compare exactly: 2^53 and 2^53+1 ascend. expect( run('a2 1b0020000000000000 00 1b0020000000000001 00', 'm2 k u k u e d'), isNull, ); }); test('are printed exactly in errors', () { final cases = <(String, String, String, String)>[ ( 'uint 2^53 above 2^53-1', '1b0020000000000000', 'u9007199254740991', 'offset 9: unsigned integer 9007199254740992 above 9007199254740991', ), ( 'uint 2^63+1 above 2^63', '1b8000000000000001', 'u9223372036854775808', 'offset 9: unsigned integer 9223372036854775809 above 9223372036854775808', ), ( 'uint 2^64-1 above 2^64-2', '1bffffffffffffffff', 'u18446744073709551614', 'offset 9: unsigned integer 18446744073709551615 above 18446744073709551614', ), ( 'keys 2^53+1 then 2^53', 'a2 1b0020000000000001 00 1b0020000000000000 00', 'm2 k u k', 'offset 11: map key 9007199254740992 after key 9007199254740993: keys must be strictly ascending', ), ( 'keys 2^64-1 then 2^64-2', 'a2 1bffffffffffffffff 00 1bfffffffffffffffe 00', 'm2 k u k', 'offset 11: map key 18446744073709551614 after key 18446744073709551615: keys must be strictly ascending', ), ( 'keys 2^63 twice', 'a2 1b8000000000000000 00 1b8000000000000000 00', 'm2 k u k', 'offset 11: map key 9223372036854775808 after key 9223372036854775808: keys must be strictly ascending', ), ( 'keys 2^53-1 twice', 'a2 1b001fffffffffffff 00 1b001fffffffffffff 00', 'm2 k u k', 'offset 11: map key 9007199254740991 after key 9007199254740991: keys must be strictly ascending', ), ( 'bstr of 2^53 bytes', '5b0020000000000000', 'b0,9', 'offset 9: truncated input: a byte string of 9007199254740992 bytes', ), ( 'text of 2^64-1 bytes', '7bffffffffffffffff', 't9', 'offset 9: truncated input: a text string of 18446744073709551615 bytes', ), ( 'array of 2^63 items', '9b8000000000000000', 'a100', 'offset 9: array of 9223372036854775808 items, at most 100', ), ( 'map of 2^53-1 entries', 'bb001fffffffffffff', 'm9007199254740991', 'offset 9: truncated input: map of 9007199254740991 entries', ), ( 'array of 2^53 items', '9b0020000000000000', 'a9007199254740991', 'offset 9: array of 9007199254740992 items, at most 9007199254740991', ), ]; for (final (name, hex, prog, text) in cases) { expect(run(hex, prog)?.message, 'codec: $text: $nc', reason: name); } expect( messageOf(() => CborDecoder(h('1b0020000000000000')).uint()), 'codec: offset 9: unsigned integer 9007199254740992 above ' '9007199254740991: ERR_NON_CANONICAL_CBOR', ); }); test('are written exactly', () { expect(encoded((e) => e.uint(maxSafeUint)), '1b001fffffffffffff'); expect(encoded((e) => e.uint64(twoPow53)), '1b0020000000000000'); expect(encoded((e) => e.uint64(twoPow63)), '1b8000000000000000'); expect(encoded((e) => e.uint64(maxUint64)), '1bffffffffffffffff'); }); test('bound the map that peekSchema reads at 2^63-1, as Go', () { final cases = <(String, String)>[ ( 'bb8000000000000000', 'offset 9: map of 9223372036854775808 entries, at most 9223372036854775807', ), ( 'bbffffffffffffffff', 'offset 9: map of 18446744073709551615 entries, at most 9223372036854775807', ), ( 'bb7fffffffffffffff', 'offset 9: truncated input: map of 9223372036854775807 entries', ), ( 'bb0020000000000005', 'offset 9: truncated input: map of 9007199254740997 entries', ), ( 'a2 1bffffffffffffffff 00 01 01', 'offset 10: map key 18446744073709551615 where key 0 was expected', ), ( 'a2 00 6161 1b0020000000000000 01', 'offset 13: map key 9007199254740992 where key 1 was expected', ), ]; for (final (hex, text) in cases) { expect(messageOf(() => peekSchema(h(hex))), 'codec: $text: $nc'); } }); }); group('unmarshalCbor', () { test('decodes the deterministic encoding of a value', () { final s = unmarshalSample('a400617801170241010a82011901f4'); expect(s.type, 'x'); expect(s.n, BigInt.from(23)); expect(s.bytes, [1]); expect(s.list, [BigInt.one, BigInt.from(500)]); }); test('rejects every non-canonical or invalid encoding', () { final cases = <(String, String)>[ ( 'integer not in shortest form', 'a300617801181702410' '1', ), ( 'keys out of order', 'a301170061780241' '01', ), ( 'duplicate key', 'a4006178006179011702' '4101', ), ( 'indefinite-length map', 'bf00617801170241' '01ff', ), ( 'indefinite-length byte string', 'a3006178011702' '5f4101ff', ), ( 'tag', 'a3006178011702' 'c24101', ), ( 'unknown key', 'a4006178011702410103' '00', ), ( 'missing key', 'a2006178011' '7', ), ( 'trailing byte', 'a30061780117024101' '00', ), ('invalid UTF-8', 'a30061ff0117024101'), ( 'empty optional array present', 'a400617801170241010a' '80', ), ('wrong type', 'a300617801617a024101'), ( 'not a map', '83006178' '01', ), ('empty input', ''), // Outside the CBOR profile of spec §58. ( 'negative integer', 'a3006178012002' '4101', ), ( 'float', 'a300617801f9400002' '4101', ), ( 'true', 'a300617801f502' '4101', ), ( 'null byte string', 'a30061780117' '02f6', ), ( 'undefined byte string', 'a30061780117' '02f7', ), ( 'null text', 'a300f60117' '024101', ), ( 'text map key', 'a300617801176162' '4101', ), ]; for (final (name, hex) in cases) { expect(codeOf(() => unmarshalSample(hex)), nc, reason: name); } expect( messageOf(() => unmarshalSample('a400617801170241010a80')), 'codec: input is not the deterministic encoding of its value: ' 'ERR_NON_CANONICAL_CBOR', ); expect( messageOf(() => unmarshalSample('a3006178011702410100')), 'codec: offset 9: 1 trailing bytes: ERR_NON_CANONICAL_CBOR', ); }); test('checks the re-encoding, whatever the decoder and the encoder do', () { final input = h('a30061780117024101'); // An error of the schema passes as it is. final own = DateKeysException(ErrorCode.dateKeyInvalid, 'schema'); expect( () => unmarshalCbor(input, (_) => throw own, (_) {}), throwsA(same(own)), ); final other = StateError('not a DateKeys error'); expect( () => unmarshalCbor(input, (_) => throw other, (_) {}), throwsA(same(other)), ); // A decoder that stops early leaves bytes behind. expect( messageOf(() => unmarshalCbor(input, (d) => d.map(3), (_) {})), 'codec: offset 1: 1 maps not closed: ERR_NON_CANONICAL_CBOR', ); // A decoder that ignores an error of the decoder fails anyway. void ignore(CborDecoder d) { try { d.uint(0); } on DateKeysException { // Ignored on purpose: done throws it again. } } expect( messageOf(() => unmarshalCbor(input, ignore, (_) {})), 'codec: offset 0: a map where an unsigned integer was expected: ' 'ERR_NON_CANONICAL_CBOR', ); // An encoder that fails, and one that writes something else. const notDeterministic = 'codec: input is not the deterministic encoding of its value: ' 'ERR_NON_CANONICAL_CBOR'; final s = Sample(); expect( messageOf(() => unmarshalCbor(input, s.decode, (e) => e.map(-1))), notDeterministic, ); expect( messageOf( () => unmarshalCbor(input, s.decode, (e) { s.encode(e); e.uint(0); }), ), notDeterministic, ); }); test('wipes the re-encoding, on success and on failure', () { final input = h('a30061780117024101'); late CborEncoder used; final s = Sample(); unmarshalCbor(input, s.decode, (e) => s.encode(used = e)); expect(used.out(), hasLength(input.length)); expect(used.out(), everyElement(0)); expect( codeOf( () => unmarshalCbor(input, s.decode, (e) { s.encode(used = e); e.uint(0); }), ), nc, ); expect(used.out(), hasLength(input.length + 1)); expect(used.out(), everyElement(0)); }); test('round-trips random values of the sample schema', () { final r = Random(2); for (var i = 0; i < 500; i++) { final s = Sample() ..type = String.fromCharCode(0x61 + r.nextInt(26)) ..n = randomUint64(r) ..bytes = Uint8List(r.nextInt(40)) ..list = [for (var j = r.nextInt(4); j > 0; j--) randomUint64(r)]; final b = Uint8List.fromList(s.marshal()); final back = Sample(); unmarshalCbor(b, back.decode, back.encode); expect(toHex(back.marshal()), toHex(b)); } }); }); group('peekSchema and checkSchema', () { final tag = '6a${hexOf('datekeycap')}'; test('read the type tag and the version, and nothing after them', () { // A future version may use anything after key 1. final future = ref.marshal({ 0: 'datekeycap', 1: 2, 99: 'new', 100: const ref.Raw([0xf9, 0x7e, 0x00]), }); expect(peekSchema(future), (typeTag: 'datekeycap', version: 2)); final long = 'a' * maxTypeTagLen; expect(peekSchema(h('a200 7840 ${hexOf(long)} 0101')).typeTag, long); expect( peekSchema(h('a200 $tag 01 1b001fffffffffffff')).version, maxSafeUint, ); }); test('reject any other start with the text of the reference', () { final cases = <(String, String, String)>[ ('empty', '', 'offset 0: truncated input'), ('not a map', '8200', 'offset 0: an array where a map was expected'), ( 'one entry', 'a1006161', 'offset 1: map without a type tag and a schema version', ), ( 'first key not 0', 'a2016161' '0201', 'offset 2: map key 1 where key 0 was expected', ), ( 'second key not 1', 'a2006161' '0201', 'offset 5: map key 2 where key 1 was expected', ), ( 'text key', 'a2616100' '0101', 'offset 1: a text string where an unsigned integer was expected', ), ( 'type tag not text', 'a2004161' '0101', 'offset 2: a byte string where a text string was expected', ), ( 'version not uint', 'a2006161' '0120', 'offset 5: a negative integer (initial byte 0x20) is outside the CBOR profile', ), ( 'version above 2^53-1', 'a2006161' '011b0020000000000000', 'offset 14: unsigned integer 9007199254740992 above 9007199254740991', ), ( 'keys swapped', 'a2010100' '6161', 'offset 2: map key 1 where key 0 was expected', ), ( 'truncated type tag', 'a2006361', 'offset 1: truncated input: map of 2 entries', ), ( 'map beyond input', 'a5006161' '0101', 'offset 1: truncated input: map of 5 entries', ), ( 'type tag above maxTypeTagLen', 'a200 7841 ${'61' * 65} 0101', 'offset 4: a text string of 65 bytes outside 0..64', ), ( 'type tag not valid UTF-8', 'a20061ff0101', 'offset 2: text string is not valid UTF-8', ), ( 'map head not shortest', 'b802 00 $tag 0102', 'offset 0: 2 is not in its shortest form (initial byte 0xb8)', ), ]; for (final (name, hex, text) in cases) { expect( messageOf(() => peekSchema(h(hex))), 'codec: $text: $nc', reason: name, ); } }); test('check the type tag first, and only then the version', () { final b = (Sample() ..type = 'datekeycap' ..n = BigInt.one ..bytes = Uint8List(0)) .marshal(); checkSchema(b, 'datekeycap', 1); expect( messageOf(() => checkSchema(b, 'datekeys-control', 1)), 'codec: type "datekeycap", want "datekeys-control": ' 'ERR_NON_CANONICAL_CBOR', ); expect( messageOf( () => checkSchema(h('a200 $tag 0102'), 'datekeys-control', 1), ), 'codec: type "datekeycap", want "datekeys-control": ' 'ERR_NON_CANONICAL_CBOR', ); final version = thrown(() => checkSchema(b, 'datekeycap', 2)); expect(version.code, ErrorCode.unsupportedVersion); expect( version.message, 'codec: datekeycap schema version 1, want 2: ERR_UNSUPPORTED_VERSION', ); expect( messageOf( () => checkSchema( h('a200 $tag 01 1b001fffffffffffff'), 'datekeycap', 1, ), ), 'codec: datekeycap schema version 9007199254740991, want 1: ' 'ERR_UNSUPPORTED_VERSION', ); // A future version with unknown keys still reports the version. final future = ref.marshal({0: 'datekeycap', 1: 2, 99: -7}); expect( codeOf(() => checkSchema(future, 'datekeycap', 1)), 'ERR_UNSUPPORTED_VERSION', ); for (final hex in ['', 'ff', '8301', 'a10061']) { expect(codeOf(() => checkSchema(h(hex), 'datekeycap', 1)), nc); } }); test('read a version only as the second key, after a type tag', () { // Spec §70: every other form of the version is ERR_NON_CANONICAL_CBOR, // whatever its value. const uv = 'ERR_UNSUPPORTED_VERSION'; final text = hexOf('datekeycap'); final cases = <(String, String, String)>[ ('version 2', 'a200 $tag 0102', uv), ('version 2, rest malformed', 'a300 $tag 0102 02ff', uv), ('version 2^53-1', 'a200 $tag 011b001fffffffffffff', uv), ('version 2 not in shortest form', 'a200 $tag 011802', nc), ('version 2^53', 'a200 $tag 011b0020000000000000', nc), ('version 2^64-1', 'a200 $tag 011bffffffffffffffff', nc), ('version before key 0', 'a2 0102 00 $tag', nc), ('version after key 2', 'a3 00 $tag 0200 0102', nc), ('map head not in shortest form', 'b802 00 $tag 0102', nc), ('type tag head not in shortest form', 'a200 780a $text 0102', nc), ('version missing', 'a100 $tag', nc), ('version null', 'a200 $tag 01f6', nc), ('version undefined', 'a200 $tag 01f7', nc), ('version true', 'a200 $tag 01f5', nc), ('version false', 'a200 $tag 01f4', nc), ]; for (final (name, hex, want) in cases) { expect( codeOf(() => checkSchema(h(hex), 'datekeycap', 1)), want, reason: name, ); } }); test('quote a type tag as Go %q, with the tables of Go 1.26', () { final cases = <(String, String)>[ ('67 64617465e280a8', '"date${bs}u2028"'), ('65 efbbbf2261', '"${bs}ufeff$bs"a"'), ('62 0a7f', '"${bs}n${bs}x7f"'), ]; for (final (tagHex, quoted) in cases) { expect( messageOf(() => checkSchema(h('a200 $tagHex 0101'), 'datekeycap', 1)), 'codec: type $quoted, want "datekeycap": ERR_NON_CANONICAL_CBOR', ); } }); }); group('walkCbor', () { test('bounds depth and length as the reference', () { final cases = <(String, String, int, int, String?)>[ ('uint', '17', 0, 0, null), ('empty bstr', '40', 0, 0, null), ('text', '626161', 0, 2, null), ( 'text above max', '626161', 0, 1, 'offset 1: a text string of 2 bytes outside 0..1', ), ( 'bstr above max', '420000', 0, 1, 'offset 1: a byte string of 2 bytes outside 0..1', ), ('map two sorted keys', 'a200010101', 1, 2, null), ( 'map at depth 0', 'a0', 0, 0, 'offset 0: containers nested deeper than 0', ), ( 'map above max', 'a200010101', 1, 1, 'offset 1: map of 2 entries, at most 1', ), ( 'array above max', '83010203', 1, 2, 'offset 1: array of 3 items, at most 2', ), ('four levels', 'a1008181a10040', 4, 1, null), ( 'four levels, depth 3', 'a1008181a10040', 3, 1, 'offset 4: containers nested deeper than 3', ), ('empty containers', '82a080', 2, 2, null), ( 'keys out of order', 'a201000001', 1, 2, 'offset 3: map key 0 after key 1: keys must be strictly ascending', ), ( 'text key', 'a1616100', 1, 1, 'offset 1: a text string where an unsigned integer was expected', ), ( 'float inside', '8201f97e00', 1, 2, 'offset 2: a float or simple value (initial byte 0xf9) is outside the CBOR profile', ), ( 'truncated map', 'a20001', 1, 2, 'offset 1: truncated input: map of 2 entries', ), ( 'truncated array', '8201', 1, 2, 'offset 1: truncated input: array of 2 items', ), ('trailing byte', '8000', 1, 0, 'offset 1: 1 trailing bytes'), ('empty input', '', 1, 1, 'offset 0: truncated input'), ( 'invalid UTF-8 inside', 'a10061ff', 1, 1, 'offset 2: text string is not valid UTF-8', ), ( 'null', 'f6', 1, 1, 'offset 0: a float or simple value (initial byte 0xf6) is outside the CBOR profile', ), ('2^64-1 inside', 'a1001bffffffffffffffff', 1, 1, null), ]; for (final (name, hex, maxDepth, maxLen, text) in cases) { void walk() => walkCbor(h(hex), maxDepth, maxLen); if (text == null) { expect(walk, returnsNormally, reason: name); } else { expect(messageOf(walk), 'codec: $text: $nc', reason: name); } } }); test('reads deep input iteratively', () { const n = 1 << 20; final deep = Uint8List(n + 1)..fillRange(0, n, 0x81); walkCbor(deep, n, 1); expect( messageOf(() => walkCbor(deep, n - 1, 1)), 'codec: offset 1048575: containers nested deeper than 1048575: ' 'ERR_NON_CANONICAL_CBOR', ); }); }); // The property tests and the fuzz targets of the Go tests, over seeded // random values and seeded mutations of the seeds of the fuzz targets. group('properties', () { test('the encoder writes what the reference writes, and walk accepts it ' 'within its exact shape and rejects it one level or one byte ' 'tighter', () { final r = Random(1); for (var i = 0; i < 1000; i++) { final v = randomValue(r, 0); final e = CborEncoder(); encodeValue(e, v); final b = e.out(); expect(toHex(b), toHex(ref.marshal(v))); final (depth, length) = ref.shape(v); walkCbor(b, depth, length); if (depth > 0) { expect(codeOf(() => walkCbor(b, depth - 1, length)), nc); } if (length > 0) { expect(codeOf(() => walkCbor(b, depth, length - 1)), nc); } } }); test( 'walk accepts exactly the items of the profile that fit its bounds', () { final r = Random(3); final seeds = fuzzSeeds(r); for (var i = 0; i < 4000; i++) { final input = mutate(r, seeds[r.nextInt(seeds.length)]); final code = codeOf(() => walkCbor(input, 8, 64)); expect(code, anyOf('', nc)); final Object? v; try { v = ref.unmarshal(input); } on FormatException { expect( code, nc, reason: '${toHex(input)}: the reference rejects it', ); continue; } final (depth, length) = ref.shape(v); expect( code == '', depth <= 8 && length <= 64, reason: '${toHex(input)} of depth $depth and length $length', ); } }, ); test('peekSchema reads what the reference reads', () { final r = Random(4); final seeds = fuzzSeeds(r); for (var i = 0; i < 4000; i++) { final input = mutate(r, seeds[r.nextInt(seeds.length)]); ({String typeTag, int version})? got; try { got = peekSchema(input); } on DateKeysException catch (e) { expect(e.code, ErrorCode.nonCanonicalCbor); } final Object? m; try { m = ref.unmarshal(input); } on FormatException { continue; } if (m is! Map) continue; // Keys ascend, so keys 0 and 1, when present, come first. final tag = m[BigInt.zero]; final version = m[BigInt.one]; final want = tag is ref.Text && tag.bytes.length <= maxTypeTagLen && version is BigInt && version <= maxSafe; expect(got != null, want, reason: toHex(input)); if (got != null && tag is ref.Text && version is BigInt) { expect(utf8.encode(got.typeTag), tag.bytes); expect(BigInt.from(got.version), version); } } }); test( 'the decoder keeps its bounds and its first error, of the profile', () { final r = Random(5); final seeds = fuzzSeeds(r); for (var i = 0; i < 4000; i++) { final input = mutate(r, seeds[r.nextInt(seeds.length)]); final prog = [for (var j = r.nextInt(16); j > 0; j--) r.nextInt(256)]; final d = CborDecoder(input); DateKeysException? first; for (var p = 0; p < prog.length; p++) { final bound = p + 1 < prog.length ? prog[p + 1] : 0; DateKeysException? err; try { switch (prog[p] % 8) { case 0: expect(d.map(bound), lessThanOrEqualTo(bound)); p++; case 1: d.key(); case 2: d.endMap(); case 3: expect(d.array(bound), lessThanOrEqualTo(bound)); p++; case 4: expect(d.uint(bound), lessThanOrEqualTo(bound)); p++; case 5: final lo = bound % 16; expect( d.bstr(lo, bound).length, allOf(greaterThanOrEqualTo(lo), lessThanOrEqualTo(bound)), ); p++; case 6: expect( utf8.encode(d.text(bound)).length, lessThanOrEqualTo(bound), ); p++; case 7: d.done(); } } on DateKeysException catch (e) { err = e; expect(e.code, ErrorCode.nonCanonicalCbor); } if (first == null) { first = err; } else { expect(identical(err, first), isTrue, reason: 'not sticky'); } } } }, ); test( 'unmarshalCbor accepts only deterministic encodings of the profile', () { final r = Random(6); final seeds = [ ...fuzzSeeds(r), for (var i = 0; i < 50; i++) (Sample() ..type = 'x' ..n = randomUint64(r) ..bytes = Uint8List(r.nextInt(8)) ..list = [ for (var j = r.nextInt(3); j > 0; j--) randomUint64(r), ]) .marshal(), ]; var accepted = 0; for (var i = 0; i < 4000; i++) { final input = mutate(r, seeds[r.nextInt(seeds.length)]); final s = Sample(); try { unmarshalCbor(input, s.decode, s.encode); } on DateKeysException catch (e) { expect(e.code, ErrorCode.nonCanonicalCbor); continue; } accepted++; expect(toHex(s.marshal()), toHex(input)); walkCbor(input, 2, 64); } expect(accepted, greaterThan(0)); }, ); }); }