diff --git a/lib/datekeys.dart b/lib/datekeys.dart index a5b1301..4771584 100644 --- a/lib/datekeys.dart +++ b/lib/datekeys.dart @@ -2,8 +2,12 @@ /// DateKeys Access Key (`.dkk`), as `datekeys-go` and `datekeys-ts` implement /// them, checked against the same test data. /// -/// Stage 0 of docs/PLAN_dart.md: the package and its test data. The protocol +/// Stage 0 of docs/PLAN_dart.md, the package and its test data, and from +/// stage 1 the normative errors of spec §69 and byte helpers. The protocol /// arrives stage by stage. library; +export 'src/bytes.dart' + show compareBytes, concatBytes, decodeUtf8, equalBytes, fromHex, toHex; +export 'src/errors.dart'; export 'src/version.dart'; diff --git a/lib/src/bytes.dart b/lib/src/bytes.dart new file mode 100644 index 0000000..bdba8b1 --- /dev/null +++ b/lib/src/bytes.dart @@ -0,0 +1,379 @@ +/// Byte helpers of the codec and of the schemas: hexadecimal, comparison and +/// concatenation, UTF-8 as strict as Go's, and Go's `%q`, which the error +/// texts of the reference use. As bytes.ts of datekeys-ts. +library; + +import 'dart:typed_data'; + +const _hexDigits = '0123456789abcdef'; + +/// The lowercase hexadecimal of [bytes], as Go's hex.EncodeToString. +String toHex(List bytes) { + final out = StringBuffer(); + for (final b in bytes) { + out + ..write(_hexDigits[b >> 4]) + ..write(_hexDigits[b & 15]); + } + return out.toString(); +} + +/// The bytes of the hexadecimal [s], in lower or upper case. Throws a +/// [FormatException] unless [s] is an even number of hexadecimal digits. +Uint8List fromHex(String s) { + if (s.length.isOdd) { + throw FormatException('hexadecimal of odd length ${s.length}', s); + } + final out = Uint8List(s.length ~/ 2); + for (var i = 0; i < out.length; i++) { + out[i] = _nibble(s, 2 * i) << 4 | _nibble(s, 2 * i + 1); + } + return out; +} + +int _nibble(String s, int i) { + final c = s.codeUnitAt(i); + if (c >= 0x30 && c <= 0x39) return c - 0x30; + final lower = c | 0x20; + if (lower >= 0x61 && lower <= 0x66) return lower - 0x61 + 10; + throw FormatException('not a hexadecimal digit', s, i); +} + +/// Whether [a] and [b] hold the same bytes. +bool equalBytes(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + +/// Orders byte strings as Go's bytes.Compare: -1, 0 or 1, byte by byte and +/// then by length. +int compareBytes(List a, List b) { + final n = a.length < b.length ? a.length : b.length; + for (var i = 0; i < n; i++) { + if (a[i] != b[i]) return a[i] < b[i] ? -1 : 1; + } + return a.length == b.length ? 0 : (a.length < b.length ? -1 : 1); +} + +/// The concatenation of [parts]. +Uint8List concatBytes(Iterable> parts) { + var n = 0; + for (final p in parts) { + n += p.length; + } + final out = Uint8List(n); + var o = 0; + for (final p in parts) { + out.setRange(o, o + p.length, p); + o += p.length; + } + return out; +} + +// --------------------------------------------------------------------------- +// UTF-8 + +/// Go's utf8.RuneError, U+FFFD. +const _runeError = 0xfffd; + +/// Go's utf8.DecodeRune of [bytes] from [offset]: the rune and the number of +/// bytes it takes. An invalid or truncated sequence is `(0xFFFD, 1)`, and the +/// end of the input `(0xFFFD, 0)`. Surrogates, overlong forms and code points +/// above U+10FFFF are invalid. +(int, int) decodeRune(List bytes, int offset) { + final n = bytes.length - offset; + if (n < 1) return (_runeError, 0); + final b0 = bytes[offset]; + if (b0 < 0x80) return (b0, 1); + // The size of the sequence and the range of its second byte, as the + // tables first and acceptRanges of Go's unicode/utf8. + final int size; + var lo = 0x80; + var hi = 0xbf; + if (b0 < 0xc2) { + return (_runeError, 1); + } else if (b0 < 0xe0) { + size = 2; + } else if (b0 < 0xf0) { + size = 3; + if (b0 == 0xe0) lo = 0xa0; + if (b0 == 0xed) hi = 0x9f; + } else if (b0 < 0xf5) { + size = 4; + if (b0 == 0xf0) lo = 0x90; + if (b0 == 0xf4) hi = 0x8f; + } else { + return (_runeError, 1); + } + if (n < size) return (_runeError, 1); + final b1 = bytes[offset + 1]; + if (b1 < lo || b1 > hi) return (_runeError, 1); + if (size == 2) return ((b0 & 0x1f) << 6 | b1 & 0x3f, 2); + final b2 = bytes[offset + 2]; + if (b2 < 0x80 || b2 > 0xbf) return (_runeError, 1); + if (size == 3) return ((b0 & 0x0f) << 12 | (b1 & 0x3f) << 6 | b2 & 0x3f, 3); + final b3 = bytes[offset + 3]; + if (b3 < 0x80 || b3 > 0xbf) return (_runeError, 1); + return ( + (b0 & 0x07) << 18 | (b1 & 0x3f) << 12 | (b2 & 0x3f) << 6 | b3 & 0x3f, + 4, + ); +} + +/// Whether [bytes] are valid UTF-8, as Go's utf8.Valid. +bool isValidUtf8(List bytes) { + for (var i = 0; i < bytes.length;) { + if (bytes[i] < 0x80) { + i++; + continue; + } + final (_, size) = decodeRune(bytes, i); + // A sequence that starts above U+007F is one byte only when invalid. + if (size == 1) return false; + i += size; + } + return true; +} + +/// Decodes [bytes] when they are valid UTF-8, as Go's utf8.Valid has it, and +/// returns null otherwise: no byte is replaced. A leading U+FEFF is kept as a +/// character. dart:convert's Utf8Decoder is as strict about everything else, +/// but drops a leading U+FEFF, on the VM and on the web alike, and two texts +/// that differ in it would decode the same. +String? decodeUtf8(List bytes) { + final runes = []; + for (var i = 0; i < bytes.length;) { + final b = bytes[i]; + if (b < 0x80) { + runes.add(b); + i++; + continue; + } + final (r, size) = decodeRune(bytes, i); + if (size == 1) return null; + runes.add(r); + i += size; + } + return String.fromCharCodes(runes); +} + +/// The UTF-8 bytes of [s]. A lone surrogate, which a Dart String may hold and +/// UTF-8 cannot, takes the three bytes of its code point, as the generalized +/// UTF-8 of WTF-8 writes it (U+D800 is ED A0 80): bytes that are not valid +/// UTF-8, so that whoever checks them rejects the string as Go rejects those +/// bytes. dart:convert writes U+FFFD in its place instead. +Uint8List utf8Bytes(String s) { + var n = 0; + for (var i = 0; i < s.length; i++) { + final c = s.codeUnitAt(i); + if (c < 0x80) { + n += 1; + } else if (c < 0x800) { + n += 2; + } else if (_surrogatePairAt(s, i)) { + n += 4; + i++; + } else { + n += 3; + } + } + final out = Uint8List(n); + var o = 0; + for (var i = 0; i < s.length; i++) { + final c = s.codeUnitAt(i); + if (c < 0x80) { + out[o++] = c; + } else if (c < 0x800) { + out[o++] = 0xc0 | c >> 6; + out[o++] = 0x80 | c & 0x3f; + } else if (_surrogatePairAt(s, i)) { + final r = 0x10000 + ((c & 0x3ff) << 10 | s.codeUnitAt(++i) & 0x3ff); + out[o++] = 0xf0 | r >> 18; + out[o++] = 0x80 | r >> 12 & 0x3f; + out[o++] = 0x80 | r >> 6 & 0x3f; + out[o++] = 0x80 | r & 0x3f; + } else { + out[o++] = 0xe0 | c >> 12; + out[o++] = 0x80 | c >> 6 & 0x3f; + out[o++] = 0x80 | c & 0x3f; + } + } + return out; +} + +/// Whether [s] holds no lone surrogate: whether it has a UTF-8 encoding. +bool isWellFormedUtf16(String s) { + for (var i = 0; i < s.length; i++) { + final c = s.codeUnitAt(i); + if (c & 0xf800 != 0xd800) continue; + if (!_surrogatePairAt(s, i)) return false; + i++; + } + return true; +} + +// Whether a high surrogate at i is followed by a low surrogate. +bool _surrogatePairAt(String s, int i) { + final c = s.codeUnitAt(i); + return c >= 0xd800 && + c <= 0xdbff && + i + 1 < s.length && + s.codeUnitAt(i + 1) & 0xfc00 == 0xdc00; +} + +// --------------------------------------------------------------------------- +// Go's %q + +// Go's strconv.IsPrint above U+007F (letters, marks, numbers, punctuation and +// symbols), generated from strconv.IsPrint of Go 1.26, the toolchain of the +// reference implementation (Unicode 15.0.0: 710 ranges, 148 903 runes). A +// fixed table, the same as GO_PRINTABLE_RANGES of datekeys-ts, rather than +// the Unicode data of the platform, which follows a version of its own. Pairs +// of (gap from the end of the previous range, length) in base 36, from +// U+0080, printed by `go run scripts/go-isprint-table.go` of datekeys-ts; +// bytes_test.dart pins the SHA-256 of the whole rune set. +const _goPrintableRanges = + 'x,c,1,ju,2,6,4,7,1,1,1,k,1,b1,1,12,2,1e,2,3,1,1j,8,r,4,6,h,m,1,5c,1,1c,' + '2,1n,2,2t,e,1n,2,1d,2,f,1,s,2,1,1,b,5,v,9,22,1,4h,1,8,2,2,2,m,1,7,1,1,3,' + '4,2,9,2,2,2,4,8,1,4,2,1,5,2,p,2,3,1,6,4,2,2,m,1,7,1,2,1,2,1,2,2,1,1,5,4,' + '2,2,3,3,1,7,4,1,1,7,h,a,3,1,9,1,3,1,m,1,7,1,2,1,5,2,a,1,3,1,3,2,1,f,4,2,' + 'c,7,7,1,3,1,8,2,2,2,m,1,7,1,2,1,5,2,9,2,2,2,3,7,3,4,2,1,5,2,i,a,2,1,6,3,' + '3,1,4,3,2,1,1,1,2,3,2,3,3,3,c,4,5,3,3,1,4,2,1,6,1,e,l,5,d,1,3,1,n,1,g,2,' + '9,1,3,1,4,7,2,1,3,2,1,2,4,2,a,7,m,1,3,1,n,1,a,1,5,2,9,1,3,1,4,7,2,6,2,1,' + '4,2,a,1,3,c,d,1,3,1,1f,1,3,1,6,4,g,2,q,1,3,1,i,3,o,1,9,1,1,2,7,3,1,4,6,' + '1,1,1,8,6,a,2,3,c,1m,4,t,11,2,1,1,1,5,1,o,1,1,1,n,2,5,1,1,1,7,1,a,2,4,w,' + '20,1,10,4,13,1,10,1,f,1,d,11,5i,1,1,5,1,2,ah,1,4,2,7,1,1,1,4,2,15,1,4,2,' + 'x,1,4,2,7,1,1,1,4,2,f,1,1l,1,4,2,1v,2,w,3,q,6,2e,2,6,2,hs,1,s,3,2h,7,m,' + '9,o,9,k,c,d,1,3,1,2,c,2m,2,a,6,a,6,e,1,b,6,2h,7,17,5,1y,a,v,1,c,4,c,4,1,' + '3,16,2,5,b,18,4,q,6,b,3,1q,2,1t,1,t,2,b,6,a,6,e,2,v,1d,25,3,1b,1,38,8,' + '1o,3,f,3,1o,7,17,2,b,8,17,5,eu,2,6,2,12,2,6,2,8,1,1,1,1,1,1,1,v,2,1h,1,' + 'f,1,e,2,6,1,j,2,3,1,9,h,o,8,1b,h,2,2,r,1,d,3,x,f,x,f,3w,4,if,p,b,l,1ec,' + '2,w,1,9p,5,19,1,1,5,1,2,1k,7,2,e,o,9,7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,3i,' + 'y,q,1,2h,c,5y,q,c,5,1r,1,2e,2,2v,5,17,1,2m,1,2c,c,1b,1,mlp,3,1j,9,9o,k,' + '54,8,5n,5,2,1,1,1,5,o,1n,3,a,6,1k,8,1y,8,c,6,38,b,u,3,26,1,b,4,x,1,1j,9,' + 'e,2,a,2,2v,o,s,a,6,2,6,2,6,9,7,1,7,1,1o,4,3i,2,a,6,8mc,c,n,4,1d,6is,a6,' + '2,2y,12,7,c,5,5,q,1,5,1,1,1,2,1,2,1,3h,g,cd,2,1i,7,1,w,16,6,1f,1,j,1,4,' + '4,5,1,3r,4,5a,3,6,2,6,2,6,2,3,3,7,1,7,d,2,2,c,1,q,1,j,1,2,1,f,2,e,y,3f,' + '5,3,4,19,3,2g,1,d,3,1,1b,1a,3m,t,3,1d,f,s,4,10,9,u,5,17,5,u,1,11,4,e,16,' + '4e,2,a,6,10,4,10,4,14,8,1g,b,c,1,f,1,7,1,2,1,b,1,f,1,7,1,2,1v,8n,9,m,a,' + '8,o,6,1,16,1,9,1x,6,2,1,1,18,1,2,3,1,2,n,1,20,8,9,1c,j,1,2,5,x,3,r,5,1,' + '1s,1k,4,k,2,1e,1,2,5,8,1,3,1,t,2,3,4,a,7,9,7,1s,w,13,4,c,9,1i,3,t,2,r,5,' + 'q,7,4,c,7,28,21,1j,1f,d,1f,7,1a,8,a,86,v,1,16,1,3,2,2,23,17,8,16,m,q,12,' + 's,k,n,9,26,4,10,9,1q,1,5,d,p,7,a,6,1h,1,i,8,13,9,2o,1,k,b,i,1,1b,1q,7,1,' + '1,1,4,1,f,1,b,6,1n,5,a,6,4,1,8,2,2,2,m,1,7,1,2,1,5,1,a,2,2,2,3,2,1,6,1,' + '5,7,2,7,3,5,3v,2k,1,5,u,20,8,a,4m,1i,2,12,y,1x,b,a,6,d,j,1m,6,a,1i,r,2,' + 'f,4,n,55,1o,2s,2b,c,8,2,1,2,8,1,2,1,u,1,2,2,c,9,a,1y,8,2,1a,2,b,r,20,8,' + '2b,d,21,7,a,6u,9,1,19,1,e,a,t,3,w,2,m,1,e,21,7,1,2,1,18,3,1,1,2,1,9,8,a,' + '6,6,1,2,1,11,1,2,1,6,7,a,8m,p,7,h,1,15,3,s,2e,1,f,1e,d,pn,2u,33,1,5,b,' + '5g,218,2r,d,ts,g,m,33e,g7,6nt,ft,7,v,1,a,4,29,1,a,6,u,2,6,a,1y,a,a,1,7,' + '1,l,5,j,j4,2j,2t,23,4,1l,7,h,1s,5,b,2,e,4qg,8,ye,16,9,6w7,4,1,7,1,2,1,' + '83,f,1,t,3,2,1,e,4,8,b0,1s4,2z,5,d,3,9,7,a,2,4,3mo,1a,2,n,9,38,1o,6u,a,' + '13,2,22,8,34,l,1y,3e,k,c,k,c,2f,9,p,3r,2d,1,1z,1,2,2,1,2,2,2,4,1,c,1,1,' + '1,7,1,1t,1,4,2,8,1,7,1,s,1,4,1,5,1,1,3,7,1,9g,2,84,2,ji,f,5,1,f,uo,v,6,' + '6,5x,7,1,h,2,7,1,2,1,5,5,1q,x,1,34,19,3,e,2,a,4,2,8w,v,h,1m,5,1,cw,16,' + 'km,7,1,4,1,2,1,f,1,5h,2,g,15,24,4,a,4,2,lt,1w,24,1p,5e,4,1,r,1,2,1,1,2,' + '1,1,a,1,4,1,1,1,1,6,1,4,1,1,1,1,1,1,3,1,2,1,1,2,1,1,1,1,1,1,1,1,1,1,2,1,' + '1,2,4,1,7,1,4,1,4,1,1,1,a,1,h,5,3,1,5,1,h,1g,2,7i,18,4,2s,c,f,2,f,1,f,1,' + '11,a,4u,1k,t,d,18,4,9,7,2,e,6,4a,rc,4,h,3,d,3,3b,4,2n,6,c,4,1,f,c,4,1k,' + '8,a,6,14,8,u,2,2,26,9g,c,e,2,d,3,9,7,1a,1,7,8,e,4,9,7,9,7,43,1,1j,11,a,' + 'sm,wyo,w,37e,6,66,2,4g2,e,5rl,2e7,f2,15u,3t7,5,38g,f9e8,6o'; + +// The ranges of _goPrintableRanges, decoded on first use: the first and the +// last rune of range k at 2k and 2k + 1. +final Int32List _printable = () { + final nums = _goPrintableRanges.split(','); + final ranges = Int32List(nums.length); + var next = 0x80; + for (var i = 0; i < nums.length; i += 2) { + final first = next + int.parse(nums[i], radix: 36); + final last = first + int.parse(nums[i + 1], radix: 36) - 1; + ranges[i] = first; + ranges[i + 1] = last; + next = last + 1; + } + return ranges; +}(); + +/// Go's strconv.IsPrint of the rune [r], with the Unicode tables of Go 1.26: +/// the ASCII space, letters, marks, numbers, punctuation and symbols. +bool isPrint(int r) { + if (r < 0x80) return r >= 0x20 && r < 0x7f; + // The last range whose first rune is at most r. + var lo = 0; + var hi = _printable.length ~/ 2; + while (lo < hi) { + final mid = (lo + hi) >> 1; + if (_printable[2 * mid] <= r) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo > 0 && r <= _printable[2 * lo - 1]; +} + +/// Go's `%q` (strconv.Quote) of [bytes] taken as a Go string, so that error +/// texts match the reference: printable runes as they are, other runes +/// escaped, and each byte that is not valid UTF-8 as `\xHH`. A Dart String is +/// quoted as its [utf8Bytes]. +String goQuote(List bytes) { + final out = StringBuffer('"'); + for (var i = 0; i < bytes.length;) { + final (r, size) = decodeRune(bytes, i); + if (r == _runeError && size == 1) { + out + ..write('\\x') + ..write(_hex(bytes[i], 2)); + i++; + continue; + } + i += size; + if (r == 0x22 || r == 0x5c) { + out + ..write('\\') + ..writeCharCode(r); + continue; + } + if (isPrint(r)) { + out.writeCharCode(r); + continue; + } + switch (r) { + case 0x07: + out.write('\\a'); + case 0x08: + out.write('\\b'); + case 0x0c: + out.write('\\f'); + case 0x0a: + out.write('\\n'); + case 0x0d: + out.write('\\r'); + case 0x09: + out.write('\\t'); + case 0x0b: + out.write('\\v'); + default: + if (r < 0x20 || r == 0x7f) { + out + ..write('\\x') + ..write(_hex(r, 2)); + } else if (r < 0x10000) { + out + ..write('\\u') + ..write(_hex(r, 4)); + } else { + out + ..write('\\U') + ..write(_hex(r, 8)); + } + } + } + out.write('"'); + return out.toString(); +} + +String _hex(int v, int digits) => v.toRadixString(16).padLeft(digits, '0'); diff --git a/lib/src/errors.dart b/lib/src/errors.dart new file mode 100644 index 0000000..fe6c710 --- /dev/null +++ b/lib/src/errors.dart @@ -0,0 +1,130 @@ +/// The normative errors of spec §69, as errors.go of datekeys-go. +/// +/// Every failure of the protocol that this library reports is a +/// [DateKeysException] carrying exactly one [ErrorCode]. Its message follows +/// the convention of the Go reference: the context, then `: ` and the code, +/// such as `capsule: truncated prelude: ERR_INTEGRITY`. An exception, not an +/// [Error]: in Dart an Error is a bug of the program, and these are failures +/// of the input. +library; + +/// The normative error codes, in the order of spec §69. +enum ErrorCode { + /// The object does not start with DKC1 or DKK1 (spec §22, §40). + invalidMagic('ERR_INVALID_MAGIC'), + + /// An unknown framing or schema version (spec §22, §70). + unsupportedVersion('ERR_UNSUPPORTED_VERSION'), + + /// FLAGS or RESERVED are not zero (spec §22, §40). + invalidFlags('ERR_INVALID_FLAGS'), + + /// The bytes are not the unique deterministic CBOR encoding of a valid + /// instance of the normative schema (spec §58, §58.1). + nonCanonicalCbor('ERR_NON_CANONICAL_CBOR'), + + /// The DateKey names a profile that is not pinned locally (spec §13). + unknownProfile('ERR_UNKNOWN_PROFILE'), + + /// A chain hash or profile does not match the pinned profile (spec §35, + /// §63). + profileMismatch('ERR_PROFILE_MISMATCH'), + + /// A DateKey that cannot be decoded or validated (spec §18, §19). + dateKeyInvalid('ERR_DATEKEY_INVALID'), + + /// A valid DateKey in a non-canonical encoding (spec §19). + dateKeyNonCanonical('ERR_DATEKEY_NON_CANONICAL'), + + /// A round that differs from the locally resolved one (spec §17, §63). + roundMismatch('ERR_ROUND_MISMATCH'), + + /// The release is not published yet or no source delivered it (spec §45 to + /// §50). + releaseUnavailable('ERR_RELEASE_UNAVAILABLE'), + + /// A release that fails local verification (spec §51). + releaseInvalid('ERR_RELEASE_INVALID'), + + /// The policy requires an access credential and none was supplied (spec + /// §33). + accessRequired('ERR_ACCESS_REQUIRED'), + + /// The supplied credentials do not open this capsule (spec §33, §38). + accessInvalid('ERR_ACCESS_INVALID'), + + /// The cryptographic structure does not match the declared access policy + /// or the stanza rules of V1 (spec §25, §29, §32, §33, §36). + policyStructureMismatch('ERR_POLICY_STRUCTURE_MISMATCH'), + + /// header_binding does not match PRELUDE || PUBLIC_HEADER (spec §26). + headerBinding('ERR_HEADER_BINDING'), + + /// Truncation, corruption or failed authentication of framing or age data + /// (spec §4, §55). + integrity('ERR_INTEGRITY'), + + /// A critical extension this implementation does not know (spec §54). + extensionCriticalUnknown('ERR_EXTENSION_CRITICAL_UNKNOWN'), + + /// A known extension whose data does not follow its registered schema. It + /// rejects the object only for a critical extension; a noncritical one is + /// reported as unusable (spec §54, §72). + extensionDataInvalid('ERR_EXTENSION_DATA_INVALID'), + + /// The head of a format 3 capsule is well encoded but one of its fields + /// breaks its rules: a path, the comment, the declared author or the layout + /// of the files (spec §29.4 to §29.6). + headInvalid('ERR_HEAD_INVALID'); + + const ErrorCode(this.code); + + /// The code as spec §69 writes it, such as `ERR_INVALID_MAGIC`. + final String code; + + @override + String toString() => code; +} + +/// A normative DateKeys error (spec §69): a failure of the protocol, with +/// exactly one [code]. +final class DateKeysException implements Exception { + /// An exception of [code] whose message is `context: CODE`, or the bare + /// code when [context] is empty. + DateKeysException(this.code, [String context = '']) + : message = context.isEmpty ? code.code : '$context: ${code.code}'; + + const DateKeysException._(this.code, this.message); + + /// The normative code. + final ErrorCode code; + + /// The text of the error, as Go's err.Error() of the reference: it always + /// ends with the code. + final String message; + + /// Returns this error with `prefix: ` before its message and the same code, + /// as Go's `fmt.Errorf("prefix: %w", err)`. + DateKeysException wrap(String prefix) => + DateKeysException._(code, '$prefix: $message'); + + @override + String toString() => message; +} + +/// The normative code of [error], such as `ERR_INTEGRITY`, or `''` when it is +/// not a [DateKeysException]: Go's datekeys.Code. +String errorCode(Object? error) => + error is DateKeysException ? error.code.code : ''; + +/// Runs [body] and puts `prefix: ` before the message of a +/// [DateKeysException] that it throws, keeping its stack trace: Go's +/// `fmt.Errorf("prefix: %w", err)`. Other exceptions and errors pass through +/// unchanged. +T withContext(String prefix, T Function() body) { + try { + return body(); + } on DateKeysException catch (e, stack) { + Error.throwWithStackTrace(e.wrap(prefix), stack); + } +} diff --git a/test/bytes_test.dart b/test/bytes_test.dart new file mode 100644 index 0000000..10b1f02 --- /dev/null +++ b/test/bytes_test.dart @@ -0,0 +1,269 @@ +// The byte helpers: hexadecimal, comparison, UTF-8 as strict as Go's, Go's +// utf8.DecodeRune and Go's %q, as bytes.test.ts of datekeys-ts. The texts of +// %q are those printed by Go 1.26. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:datekeys/src/bytes.dart'; +import 'package:test/test.dart'; + +// One backslash, for the texts of Go's %q. +const bs = '\\'; + +String cp(List runes) => String.fromCharCodes(runes); + +void main() { + test('converts hexadecimal', () { + expect(toHex([0, 1, 0xab, 0xff]), '0001abff'); + expect(toHex([]), ''); + expect(fromHex('0001ABff'), [0, 1, 0xab, 0xff]); + expect(fromHex(''), isEmpty); + expect(() => fromHex('abc'), throwsFormatException); + expect(() => fromHex('zz'), throwsFormatException); + expect(() => fromHex('0g'), throwsFormatException); + expect(() => fromHex('${cp([0xff10])}0'), throwsFormatException); + }); + + test('compares and joins byte strings', () { + final a = [1, 2]; + expect(equalBytes(a, [1, 2]), isTrue); + expect(equalBytes(a, [1, 3]), isFalse); + expect(equalBytes(a, [1]), isFalse); + expect(compareBytes(a, [1, 2]), 0); + expect(compareBytes(a, [1, 3]), -1); + expect(compareBytes([2], a), 1); + expect(compareBytes([1], a), -1); + expect(compareBytes(a, [1]), 1); + expect(compareBytes([0xff], [0x00, 0x00]), 1); + expect( + concatBytes([ + a, + [], + Uint8List.fromList([3]), + ]), + [1, 2, 3], + ); + expect(concatBytes([]), isEmpty); + }); + + group('UTF-8', () { + // Each byte string that UTF-8 forbids, and what Go's utf8.Valid refuses. + final invalid = >{ + 'an invalid byte': [0x61, 0xff], + 'a byte above F4': [0xf5, 0x80, 0x80, 0x80], + 'an overlong NUL': [0xc0, 0x80], + 'an overlong three-byte form': [0xe0, 0x80, 0x80], + 'an overlong four-byte form': [0xf0, 0x80, 0x80, 0x80], + 'an encoded high surrogate': [0xed, 0xa0, 0x80], + 'an encoded low surrogate': [0xed, 0xbf, 0xbf], + 'a code point above U+10FFFF': [0xf4, 0x90, 0x80, 0x80], + 'a truncated sequence': [0xe2, 0x82], + 'a lone continuation byte': [0x80], + }; + + test('dart:convert refuses what Go refuses, and so does decodeUtf8', () { + for (final MapEntry(key: name, value: b) in invalid.entries) { + // Short inputs and long ones: dart2js decodes the long ones with the + // TextDecoder of the platform. + for (final input in [ + b, + [...b, ...List.filled(40, 0x61)], + ]) { + expect( + () => const Utf8Decoder().convert(input), + throwsFormatException, + reason: name, + ); + expect(decodeUtf8(input), isNull, reason: name); + expect(isValidUtf8(input), isFalse, reason: name); + } + } + }); + + test('dart:convert drops a leading U+FEFF, and decodeUtf8 keeps it', () { + // The reason decodeUtf8 does not use Utf8Decoder: a text that starts + // with U+FEFF would decode as one that does not, on the VM and on the + // web alike. + for (final rest in [ + [0x61], + List.filled(40, 0x61), + ]) { + final input = [0xef, 0xbb, 0xbf, ...rest]; + expect(const Utf8Decoder().convert(input), cp(rest)); + expect(decodeUtf8(input), cp([0xfeff, ...rest])); + } + // Further ones are characters for both. + expect( + decodeUtf8([0x61, 0xef, 0xbb, 0xbf, 0xef, 0xbb, 0xbf]), + cp([0x61, 0xfeff, 0xfeff]), + ); + expect( + decodeUtf8([0xef, 0xbb, 0xbf, 0xef, 0xbb, 0xbf]), + cp([0xfeff, 0xfeff]), + ); + }); + + test('decodes every valid sequence, U+FFFD and U+10FFFF included', () { + final valid = , String>{ + []: '', + [0x00]: cp([0]), + [0x7f]: cp([0x7f]), + [0xc2, 0x80]: cp([0x80]), + [0xdf, 0xbf]: cp([0x7ff]), + [0xe0, 0xa0, 0x80]: cp([0x800]), + [0xed, 0x9f, 0xbf]: cp([0xd7ff]), + [0xee, 0x80, 0x80]: cp([0xe000]), + [0xef, 0xbf, 0xbd]: cp([0xfffd]), + [0xf0, 0x90, 0x80, 0x80]: cp([0x10000]), + [0xf4, 0x8f, 0xbf, 0xbf]: cp([0x10ffff]), + }; + for (final MapEntry(key: b, value: s) in valid.entries) { + expect(decodeUtf8(b), s, reason: toHex(b)); + expect(isValidUtf8(b), isTrue, reason: toHex(b)); + expect(utf8Bytes(s), b, reason: toHex(b)); + } + }); + + test('writes a well-formed string as dart:convert does', () { + final s = cp([0xfeff, 0x61, 0xe9, 0x20ac, 0x10000, 0x1f600, 0x10ffff]); + expect(utf8Bytes(s), utf8.encode(s)); + expect(isWellFormedUtf16(s), isTrue); + }); + + test( + 'writes a lone surrogate in generalized UTF-8, which is not UTF-8', + () { + // dart:convert writes U+FFFD in its place: the string would encode as + // another. + expect(utf8.encode(cp([0xd800])), [0xef, 0xbf, 0xbd]); + final cases = , String>{ + [0xd800]: 'eda080', + [0xdfff]: 'edbfbf', + [0x61, 0xdc00, 0xd800]: '61edb080eda080', + [0xd800, 0xd800, 0xdc00]: 'eda080f0908080', + [0xdbff, 0x61]: 'edafbf61', + }; + for (final MapEntry(key: units, value: hex) in cases.entries) { + final s = cp(units); + expect(toHex(utf8Bytes(s)), hex); + expect(isWellFormedUtf16(s), isFalse); + expect(isValidUtf8(utf8Bytes(s)), isFalse); + } + }, + ); + + test('decodes runes as Go utf8.DecodeRune', () { + final b = [ + 0x41, 0xc3, 0xa9, 0xe2, 0x82, 0xac, 0xf0, 0x9f, // + 0x98, 0x80, 0xc0, 0x80, 0xe0, 0x80, 0xf5, + ]; + expect(decodeRune(b, 0), (0x41, 1)); + expect(decodeRune(b, 1), (0xe9, 2)); + expect(decodeRune(b, 3), (0x20ac, 3)); + expect(decodeRune(b, 6), (0x1f600, 4)); + for (final i in [7, 10, 11, 12, 13, 14]) { + expect(decodeRune(b, i), (0xfffd, 1), reason: '$i'); + } + expect(decodeRune(b, 15), (0xfffd, 0)); + expect(decodeRune([0xf0, 0x9f, 0x98], 0), (0xfffd, 1)); + expect(decodeRune([0xc3], 0), (0xfffd, 1)); + expect(decodeRune([0xef, 0xbf, 0xbd], 0), (0xfffd, 3)); + expect(decodeRune([], 0), (0xfffd, 0)); + }); + }); + + group("Go's %q", () { + test('quotes as strconv.Quote', () { + expect( + goQuote([0x61, 0x00, 0x62, 0xff, 0xc3, 0xa9, 0xe2, 0x80, 0xa8]), + '"a${bs}x00b${bs}xff${cp([0xe9])}${bs}u2028"', + ); + expect( + goQuote(utf8Bytes(cp([7, 8, 12, 10, 13, 9, 11]))), + '"${bs}a${bs}b${bs}f${bs}n${bs}r${bs}t${bs}v"', + ); + expect(goQuote(utf8Bytes('"$bs')), '"$bs"$bs$bs"'); + expect( + goQuote(utf8Bytes(cp([0x10000, 0x1f600, 0x301]))), + '"${cp([0x10000, 0x1f600, 0x301])}"', + ); + expect( + goQuote(utf8Bytes(cp([0xa0, 0xfeff, 0xe000, 0x10ffff, 0x7f]))), + '"${bs}u00a0${bs}ufeff${bs}ue000${bs}U0010ffff${bs}x7f"', + ); + expect( + goQuote([0xed, 0xa0, 0x80, 0xf4, 0x90, 0x80, 0x80, 0xe2, 0x82]), + '"${bs}xed${bs}xa0${bs}x80${bs}xf4${bs}x90${bs}x80${bs}x80${bs}xe2' + '${bs}x82"', + ); + expect(goQuote(utf8Bytes(cp([0xfffd]))), '"${cp([0xfffd])}"'); + expect(goQuote(utf8Bytes(cp([0x378]))), '"${bs}u0378"'); + expect(goQuote([]), '""'); + // New in Unicode 16, which the platform may know and Go 1.26 does not. + expect( + goQuote(utf8Bytes(cp([0x31e4, 0x31e3, 0x1fbcb, 0x1fbca]))), + '"${bs}u31e4${cp([0x31e3])}${bs}U0001fbcb${cp([0x1fbca])}"', + ); + // A lone surrogate, as its generalized UTF-8. + expect( + goQuote(utf8Bytes(cp([0x61, 0xd800]))), + '"a${bs}xed${bs}xa0${bs}x80"', + ); + }); + + test("classifies every rune as Go 1.26's strconv.IsPrint", () { + // The IsPrint bit set of all runes (bit r at byte r >> 3, mask + // 1 << (r & 7)) and its SHA-256, computed by Go 1.26.8 (Unicode + // 15.0.0), as bytes.test.ts pins it. + final bits = Uint8List(0x110000 ~/ 8); + var runes = 0; + var ranges = 0; + var previous = false; + for (var r = 0; r < 0x110000; r++) { + final p = isPrint(r); + if (p) { + bits[r >> 3] |= 1 << (r & 7); + if (r >= 0x80) { + runes++; + if (!previous) ranges++; + } + } + previous = p && r >= 0x80; + } + expect((runes, ranges), (148903, 710)); + expect( + sha256.convert(bits).toString(), + 'dbe4beecc0027b38c16c32d304958e611e27baa40412efea4cf616346eb631a2', + ); + for (final r in [ + 0x80, + 0x9f, + 0xa0, + 0xad, + 0x31e4, + 0xe000, + 0xfeff, + 0x10ffff, + ]) { + expect(isPrint(r), isFalse, reason: r.toRadixString(16)); + } + for (final r in [ + 0x20, + 0x7e, + 0xa1, + 0xe9, + 0x31e3, + 0xfffd, + 0x1f600, + 0xe0100, + ]) { + expect(isPrint(r), isTrue, reason: r.toRadixString(16)); + } + for (final r in [0x00, 0x1f, 0x7f]) { + expect(isPrint(r), isFalse, reason: r.toRadixString(16)); + } + }); + }); +} diff --git a/test/errors_spec_test.dart b/test/errors_spec_test.dart new file mode 100644 index 0000000..be065e9 --- /dev/null +++ b/test/errors_spec_test.dart @@ -0,0 +1,44 @@ +// The catalogue matches spec §69 exactly, in order, as TestCatalogueMatchesSpec +// of datekeys-go. The spec is read from the Go repository next to this one, at +// the tag of the version of the spec that this package implements, so that +// uncommitted changes there never count. +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:datekeys/datekeys.dart'; +import 'package:test/test.dart'; + +void main() { + final repo = Directory('../datekeys-go'); + test( + 'the catalogue is the list of ERR_ lines of spec §69, in order', + () { + final path = + 'spec-v$specVersion:spec/DateKeys_Protocol_Specification_v$specVersion.md'; + final r = Process.runSync( + 'git', + ['-C', repo.path, 'show', path], + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + expect(r.exitCode, 0, reason: '${r.stderr}'); + final spec = r.stdout as String; + final start = spec.indexOf('## 69. Errores normativos'); + final end = spec.indexOf('## 70.'); + expect(start, isNonNegative, reason: 'section 69 not found'); + expect(end, greaterThan(start), reason: 'section 70 not found'); + final want = [ + for (final line in spec.substring(start, end).split('\n')) + if (line.trim().startsWith('ERR_')) line.trim(), + ]; + expect(want, isNotEmpty); + expect([for (final c in ErrorCode.values) c.code], want); + }, + skip: repo.existsSync() + ? false + : '../datekeys-go is missing: the spec is read from it', + ); +} diff --git a/test/errors_test.dart b/test/errors_test.dart new file mode 100644 index 0000000..0f82fe9 --- /dev/null +++ b/test/errors_test.dart @@ -0,0 +1,102 @@ +// The normative errors of spec §69: the message convention of the Go +// reference, wrapping with a prefix and the code of an error, as +// errors_test.go of datekeys-go and the errors tests of bytes.test.ts of +// datekeys-ts. errors_spec_test.dart checks the catalogue against the spec. + +import 'package:datekeys/datekeys.dart'; +import 'package:test/test.dart'; + +void main() { + test('every code is written as the spec writes it, and printed so', () { + for (final c in ErrorCode.values) { + expect(c.code, matches(RegExp(r'^ERR_[A-Z_]+$'))); + expect('$c', c.code); + } + expect(ErrorCode.values.map((c) => c.code).toSet(), hasLength(19)); + }); + + test('a message is the context and the code, or the bare code', () { + final e = DateKeysException( + ErrorCode.integrity, + 'capsule: truncated prelude', + ); + expect(e.message, 'capsule: truncated prelude: ERR_INTEGRITY'); + expect('$e', 'capsule: truncated prelude: ERR_INTEGRITY'); + expect(e.code, ErrorCode.integrity); + expect(DateKeysException(ErrorCode.integrity).message, 'ERR_INTEGRITY'); + expect( + DateKeysException(ErrorCode.invalidMagic, '').message, + 'ERR_INVALID_MAGIC', + ); + }); + + test('wrapping puts a prefix before the message and keeps the code', () { + // fmt.Errorf("capsule: step 8: %w", fmt.Errorf("agewrap: %w", ErrRoundMismatch)) + final err = DateKeysException( + ErrorCode.roundMismatch, + 'agewrap', + ).wrap('capsule: step 8'); + expect(err.message, 'capsule: step 8: agewrap: ERR_ROUND_MISMATCH'); + expect(errorCode(err), 'ERR_ROUND_MISMATCH'); + expect(err.code, isNot(ErrorCode.integrity)); + final data = DateKeysException(ErrorCode.extensionDataInvalid) + .wrap('capsule: step 4'); + expect(data.message, 'capsule: step 4: ERR_EXTENSION_DATA_INVALID'); + expect(data.code, ErrorCode.extensionDataInvalid); + }); + + test('a failure kept as text adds no code', () { + // The failure of a relay kept as text, as provider/drand.Client keeps + // it: the error carries exactly one normative code. + final relay = DateKeysException(ErrorCode.roundMismatch, 'relay'); + final err = DateKeysException( + ErrorCode.releaseUnavailable, + 'drand: no relay returned a verified release for round 1000: ${relay.message}', + ); + expect(errorCode(err), 'ERR_RELEASE_UNAVAILABLE'); + expect(err.message, contains('ERR_ROUND_MISMATCH')); + expect( + err.message, + 'drand: no relay returned a verified release for round 1000: relay: ' + 'ERR_ROUND_MISMATCH: ERR_RELEASE_UNAVAILABLE', + ); + }); + + test('errorCode is empty for anything but a DateKeys error', () { + expect( + errorCode(DateKeysException(ErrorCode.headInvalid)), + 'ERR_HEAD_INVALID', + ); + expect(errorCode(const FormatException('plain')), ''); + expect(errorCode(StateError('plain')), ''); + expect(errorCode(null), ''); + }); + + test('withContext prefixes DateKeys errors only, and keeps the stack', () { + expect(withContext('ctx', () => 1), 1); + StackTrace? inner; + DateKeysException? caught; + StackTrace? outer; + void failing() { + try { + throw DateKeysException(ErrorCode.integrity, 'inner'); + } on DateKeysException catch (_, stack) { + inner = stack; + rethrow; + } + } + + try { + withContext('ctx', failing); + } on DateKeysException catch (e, stack) { + caught = e; + outer = stack; + } + expect(caught?.message, 'ctx: inner: ERR_INTEGRITY'); + expect(caught?.code, ErrorCode.integrity); + expect(inner, isNotNull); + expect('$outer', '$inner'); + final plain = RangeError('plain'); + expect(() => withContext('ctx', () => throw plain), throwsA(same(plain))); + }); +} diff --git a/test/testdata_test.dart b/test/testdata_test.dart index daaedc3..09ea8d8 100644 --- a/test/testdata_test.dart +++ b/test/testdata_test.dart @@ -4,6 +4,8 @@ // every file matches the SHA-256 recorded in testdata/SOURCE.json for the // pinned datekeys-go commit, with none missing and none extra. And every file // names the specification that this package implements. +@TestOn('vm') +library; import 'dart:convert'; import 'dart:io'; diff --git a/test/version_test.dart b/test/version_test.dart index 7ddefca..5eedb84 100644 --- a/test/version_test.dart +++ b/test/version_test.dart @@ -1,5 +1,7 @@ // The version of the library is written twice, in pubspec.yaml and in // lib/src/version.dart: they must agree. +@TestOn('vm') +library; import 'dart:io';