diff --git a/AGENTS.md b/AGENTS.md index 45b1762..057a66a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,6 +129,9 @@ the table layout. backticks around identifiers: `Add constructor to ByteCollector abstract class`, `Introduce \`ByteCollector\` abstract class`. One logical change per commit. +**Pull requests.** Do not append the "Generated with Claude Code" footer, or any +other bot attribution, to PR bodies. + **CHANGELOG.md.** Newest first, `# X.Y.Z` heading, `-` bullets. Breaking changes lead with `**Breaking Changes**:` and list renames as `` `old` -> `new` ``. Every released version has an entry; the top entry is extracted verbatim into diff --git a/CHANGELOG.md b/CHANGELOG.md index b06e7b7..feec9cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,66 +1,37 @@ -# _next_ - -- Fix the UTF-8 encoder producing invalid output for scalar code points in - `U+10000..U+10FFFF`. The `UTF8Encoder.convert` 3-byte branch had no upper - bound, so an astral scalar (e.g. `U+1F600`, a documented `source: 32` input) - was force-fit into 3 bytes and emitted invalid UTF-8 (including the byte - `0xFF`). It now emits the correct 4-byte sequence, verified against - `dart:convert`. The public `toUtf8(String)` path was never affected, since - UTF-16 strings deliver astral characters as surrogate pairs. **Observable - output change** for direct `UTF8Encoder().convert([scalar])` callers. -- Make the Crockford Base-32 decoder spec-compliant: it is now case-insensitive - and substitutes the ambiguous letters `I`/`i`/`L`/`l` -> `1` and `O`/`o` -> - `0` (the letter `U`/`u` is still not part of the alphabet). Previously - lowercase Crockford strings and the ambiguous letters were rejected. Verified - against `package:base_codecs`. Encoding output is unchanged. **Observable - change**: inputs that previously threw now decode. -- Fix `benchmark/bit.dart` exposing the private `_BitEncoder`/`_BitDecoder` - types through public fields, which failed `dart analyze --fatal-infos` - (benchmark tooling only; no library code or output is affected). -- Alphabetize the `core/bit.dart` export in `lib/src/codecs_base.dart`. - -# 3.5.2 - -- Renames internal abstract class `CipherlibConverter` -> `BitConverter`. -- Add missing dartdoc to the `Base32Encoder`, `Base32Decoder`, `Base64Encoder`, - and `Base64Decoder` constructors and the `UTF8Encoder` and `UTF8Decoder` - classes. -- Speed up `fromBase32` and `fromBase64` by returning the decoder output - directly instead of copying it into a fresh `Uint8List`. -- Speed up the generic `AlphabetEncoder` by fusing the alphabet lookup into the - bit-regrouping loop and writing straight into a single correctly-sized buffer, - removing an extra allocation and two full passes over the output. Output is - byte-identical (verified against `dart:convert` and RFC 4648 vectors). -- Export the `ByteEncoder`, `ByteDecoder`, `AlphabetEncoder`, and - `AlphabetDecoder` base classes, which were part of the public API surface but - not reachable through `package:convertlib/convertlib.dart`. -- Move `BitEncoder` and `BitDecoder` into the same file. -- `AlphabetDecoder` extends `ByteDecoder` now intead of `BitDecoder`. -- Fix `ByteCollector.number` truncating values wider than 32 bits on the web, - where bitwise operators are limited to 32 bits. It now accumulates with - multiplication and is exact up to `2^53` on the web (a web `int` cannot - represent larger values). Output on the VM is unchanged. -- Allow empty parameter values in the PHC/crypt string format, as permitted by - the specification (e.g. `$id$data=`). `CryptData.validate` no longer rejects - an empty parameter value; salt and hash must still be non-empty. -- Fix the PHC decoder mis-classifying a comma-containing `v=...` segment (e.g. - `v=19,m=8`) as the version; it is now parsed as a parameters segment. -- Add value-based `==` and `hashCode` to `CryptData`. -- `BigIntCodec` decoders now return a `Uint8List`; the `BigIntDecoder` typedef - narrows from `Converter>` to `Converter`, and `fromBigInt` no longer copies the decoded bytes. -- Tighten `ByteCollector.isEqual` parameter type from `dynamic` to `Object?`. -- Standardize the `AlphabetDecoder` invalid-bit-length error on - `ArgumentError.value`, matching `AlphabetEncoder`. -- Add `benchmark/alphabet.dart` covering the generic `AlphabetEncoder` and - `AlphabetDecoder` engine. -- Add `benchmark/bit.dart` covering the generic `BitEncoder` and `BitDecoder`. -- Improve the benchmark harness (`benchmark/_base.dart`): it now reports the - median per-iteration time sampled across ~25ms batches instead of the - arithmetic mean, making results robust against GC pauses and keeping each - batch well above the coarsened web timer resolution. Benchmark tooling only; - no library code or output is affected. -- Add tests from official / well-known sources and cover corner cases. +# 3.6.0 + +- [**Breaking Changes**] + - `CipherlibConverter` -> `BitConverter` (the exported bit-converter base class). + - `BigIntDecoder` narrows from `Converter>` to + `Converter`; `BigIntCodec` decoders now return a `Uint8List`. +- Export the `ByteEncoder`, `ByteDecoder`, `AlphabetEncoder`, and `AlphabetDecoder` + base classes, previously part of the API surface but not reachable through + `package:convertlib/convertlib.dart`. +- Add `constantTimeEquals`, a top-level constant-time byte comparison for + verifying MACs and digests without wrapping them in a `ByteCollector`. +- Add `toHexBytes`, `toBinaryBytes`, `toOctalBytes`, `toBase32Bytes`, and + `toBase64Bytes`, returning the encoded ASCII output as a `Uint8List` without + the intermediate `String`. +- Add non-throwing decoders `tryFromHex`, `tryFromBinary`, `tryFromOctal`, + `tryFromBase32`, `tryFromBase64`, `tryFromUtf8`, and `tryFromBigInt`, which + return `null` instead of throwing a `FormatException` on invalid input. +- Fix the UTF-8 encoder emitting invalid bytes for scalar code points in + `U+10000..U+10FFFF`; `UTF8Encoder.convert` now uses the 4-byte form (the public + `toUtf8` was unaffected). Verified against `dart:convert`. +- Make the Crockford Base-32 decoder case-insensitive and decode the ambiguous + letters `I`/`i`/`L`/`l` as `1` and `O`/`o` as `0`. Encoding output is unchanged. +- Fix `ByteCollector.number` truncating values wider than 32 bits on the web; it + now accumulates with multiplication and is exact up to `2^53` (VM unchanged). +- `ByteCollector.isEqual` returns `false` for a [String] that is not valid + hexadecimal instead of throwing a `FormatException`, matching its contract. +- Add value-based `==` and `hashCode` to `CryptData`; a null and an empty + `params` map now compare equal (both encode to the same string). +- PHC/crypt: allow empty parameter values (e.g. `$id$data=`), and fix the decoder + mis-classifying a comma-containing `v=...` segment as the version. +- Speed up `fromBase32`/`fromBase64` decoding and the generic `AlphabetEncoder` by + removing extra allocations and passes; output is byte-identical. +- Clarify the `FormatException` message for non-canonical encoded input: + `'Invalid length'` is now `'Invalid length or non-zero trailing bits'`. # 3.5.1 diff --git a/lib/src/base16.dart b/lib/src/base16.dart index 8b5e013..4908745 100644 --- a/lib/src/base16.dart +++ b/lib/src/base16.dart @@ -32,6 +32,25 @@ String toHex( return String.fromCharCodes(out); } +/// Converts 8-bit integer sequence to Base-16 and returns the ASCII bytes. +/// +/// This is the same as [toHex] but returns the encoded characters as a +/// [Uint8List] of ASCII codes, skipping the intermediate [String]. +/// +/// Parameters: +/// - [input] is a sequence of 8-bit integers. +/// - If [upper] is true, the uppercase standard alphabet is used. +/// - [codec] is the [Base16Codec] to use. It is derived from the other +/// parameters if not provided. +Uint8List toHexBytes( + List input, { + Base16Codec? codec, + bool upper = false, +}) { + codec ??= _codecFromParameters(upper: upper); + return codec.encoder.convert(input); +} + /// Converts 4-bit Base-16 character sequence to 8-bit integer sequence. /// /// Parameters: @@ -51,3 +70,19 @@ Uint8List fromHex( codec ??= _codecFromParameters(); return codec.decoder.convert(input.codeUnits); } + +/// Converts a Base-16 string to an 8-bit integer sequence, returning `null` +/// instead of throwing when the [input] is not valid. +/// +/// This is the non-throwing counterpart of [fromHex]. See [fromHex] for the +/// meaning of [codec]. +Uint8List? tryFromHex( + String input, { + Base16Codec? codec, +}) { + try { + return fromHex(input, codec: codec); + } on FormatException { + return null; + } +} diff --git a/lib/src/base2.dart b/lib/src/base2.dart index 4280b18..f655f81 100644 --- a/lib/src/base2.dart +++ b/lib/src/base2.dart @@ -24,6 +24,21 @@ String toBinary( return String.fromCharCodes(out); } +/// Converts 8-bit integer sequence to Base-2 and returns the ASCII bytes. +/// +/// This is the same as [toBinary] but returns the encoded characters as a +/// [Uint8List] of ASCII codes, skipping the intermediate [String]. +/// +/// Parameters: +/// - [input] is a sequence of 8-bit integers. +/// - [codec] is the [Base2Codec] to use. Default: [Base2Codec.standard]. +Uint8List toBinaryBytes( + List input, { + Base2Codec codec = Base2Codec.standard, +}) { + return codec.encoder.convert(input); +} + /// Converts 2-bit Base-2 character sequence to 8-bit integer sequence. /// /// Parameters: @@ -46,3 +61,19 @@ Uint8List fromBinary( }) { return codec.decoder.convert(input.codeUnits); } + +/// Converts a Base-2 string to an 8-bit integer sequence, returning `null` +/// instead of throwing when the [input] is not valid. +/// +/// This is the non-throwing counterpart of [fromBinary]. See [fromBinary] for +/// the meaning of [codec]. +Uint8List? tryFromBinary( + String input, { + Base2Codec codec = Base2Codec.standard, +}) { + try { + return fromBinary(input, codec: codec); + } on FormatException { + return null; + } +} diff --git a/lib/src/base32.dart b/lib/src/base32.dart index 8f37c78..8796fe7 100644 --- a/lib/src/base32.dart +++ b/lib/src/base32.dart @@ -54,6 +54,36 @@ String toBase32( return String.fromCharCodes(out); } +/// Converts 8-bit integer sequence to Base-32 and returns the ASCII bytes. +/// +/// This is the same as [toBase32] but returns the encoded characters as a +/// [Uint8List] of ASCII codes, skipping the intermediate [String]. +/// +/// Parameters: +/// - [input] is a sequence of 8-bit integers +/// - If [lower] is true, the [Base32Codec.lowercase] alphabet is used. +/// - If [padding] is true, the output will have padding characters. +/// - [codec] is the [Base32Codec] to use. It is derived from the other +/// parameters if not provided. +Uint8List toBase32Bytes( + List input, { + Base32Codec? codec, + bool lower = false, + bool padding = true, +}) { + codec ??= _codecFromParameters( + lower: lower, + padding: padding, + ); + Uint8List out = codec.encoder.convert(input); + if (!padding && _codecsWithPadding.contains(codec)) { + out = Uint8List.fromList( + out.takeWhile((x) => x != codec!.encoder.padding).toList(), + ); + } + return out; +} + /// Converts 5-bit Base-32 character sequence to 8-bit integer sequence. /// /// Parameters: @@ -78,3 +108,20 @@ Uint8List fromBase32( codec ??= _codecFromParameters(padding: padding); return codec.decoder.convert(input.codeUnits); } + +/// Converts a Base-32 string to an 8-bit integer sequence, returning `null` +/// instead of throwing when the [input] is not valid. +/// +/// This is the non-throwing counterpart of [fromBase32]. See [fromBase32] for +/// the meaning of [codec] and [padding]. +Uint8List? tryFromBase32( + String input, { + Base32Codec? codec, + bool padding = true, +}) { + try { + return fromBase32(input, codec: codec, padding: padding); + } on FormatException { + return null; + } +} diff --git a/lib/src/base64.dart b/lib/src/base64.dart index 9ee96bf..ac7b8e7 100644 --- a/lib/src/base64.dart +++ b/lib/src/base64.dart @@ -50,6 +50,36 @@ String toBase64( return String.fromCharCodes(out); } +/// Converts 8-bit integer sequence to Base-64 and returns the ASCII bytes. +/// +/// This is the same as [toBase64] but returns the encoded characters as a +/// [Uint8List] of ASCII codes, skipping the intermediate [String]. +/// +/// Parameters: +/// - [input] is a sequence of 8-bit integers +/// - If [url] is true, URL and Filename-safe alphabet is used. +/// - If [padding] is true, the output will have padding characters. +/// - [codec] is the [Base64Codec] to use. It is derived from the other +/// parameters if not provided. +Uint8List toBase64Bytes( + List input, { + Base64Codec? codec, + bool url = false, + bool padding = true, +}) { + codec ??= _codecFromParameters( + url: url, + padding: padding, + ); + Uint8List out = codec.encoder.convert(input); + if (!padding && _codecsWithPadding.contains(codec)) { + out = Uint8List.fromList( + out.takeWhile((x) => x != codec!.encoder.padding).toList(), + ); + } + return out; +} + /// Converts 6-bit Base-64 character sequence to 8-bit integer sequence. /// /// Parameters: @@ -75,3 +105,20 @@ Uint8List fromBase64( codec ??= _codecFromParameters(padding: padding); return codec.decoder.convert(input.codeUnits); } + +/// Converts a Base-64 string to an 8-bit integer sequence, returning `null` +/// instead of throwing when the [input] is not valid. +/// +/// This is the non-throwing counterpart of [fromBase64]. See [fromBase64] for +/// the meaning of [codec] and [padding]. +Uint8List? tryFromBase64( + String input, { + Base64Codec? codec, + bool padding = true, +}) { + try { + return fromBase64(input, codec: codec, padding: padding); + } on FormatException { + return null; + } +} diff --git a/lib/src/base8.dart b/lib/src/base8.dart index 84057c9..9aa84e1 100644 --- a/lib/src/base8.dart +++ b/lib/src/base8.dart @@ -24,6 +24,21 @@ String toOctal( return String.fromCharCodes(out); } +/// Converts 8-bit integer sequence to Base-8 and returns the ASCII bytes. +/// +/// This is the same as [toOctal] but returns the encoded characters as a +/// [Uint8List] of ASCII codes, skipping the intermediate [String]. +/// +/// Parameters: +/// - [input] is a sequence of 8-bit integers. +/// - [codec] is the [Base8Codec] to use. Default: [Base8Codec.standard]. +Uint8List toOctalBytes( + List input, { + Base8Codec codec = Base8Codec.standard, +}) { + return codec.encoder.convert(input); +} + /// Converts 3-bit Base-8 character sequence to 8-bit integer sequence. /// /// Parameters: @@ -46,3 +61,19 @@ Uint8List fromOctal( }) { return codec.decoder.convert(input.codeUnits); } + +/// Converts a Base-8 string to an 8-bit integer sequence, returning `null` +/// instead of throwing when the [input] is not valid. +/// +/// This is the non-throwing counterpart of [fromOctal]. See [fromOctal] for the +/// meaning of [codec]. +Uint8List? tryFromOctal( + String input, { + Base8Codec codec = Base8Codec.standard, +}) { + try { + return fromOctal(input, codec: codec); + } on FormatException { + return null; + } +} diff --git a/lib/src/bigint.dart b/lib/src/bigint.dart index 080cea9..2be7817 100644 --- a/lib/src/bigint.dart +++ b/lib/src/bigint.dart @@ -58,3 +58,20 @@ Uint8List fromBigInt( codec ??= _codecFromParameters(msbFirst: msbFirst); return codec.decoder.convert(input); } + +/// Converts a non-negative [BigInt] to an 8-bit integer sequence, returning +/// `null` instead of throwing when the [input] is negative. +/// +/// This is the non-throwing counterpart of [fromBigInt]. See [fromBigInt] for +/// the meaning of [codec] and [msbFirst]. +Uint8List? tryFromBigInt( + BigInt input, { + BigIntCodec? codec, + bool msbFirst = false, +}) { + try { + return fromBigInt(input, codec: codec, msbFirst: msbFirst); + } on FormatException { + return null; + } +} diff --git a/lib/src/bytes_collector.dart b/lib/src/bytes_collector.dart index 366745c..bfd62a7 100644 --- a/lib/src/bytes_collector.dart +++ b/lib/src/bytes_collector.dart @@ -4,13 +4,14 @@ import 'dart:convert' as cvt; import 'dart:typed_data'; -import 'base16.dart' show fromHex; import 'base16.dart' show toHex; +import 'base16.dart' show tryFromHex; import 'base2.dart' show toBinary; import 'base32.dart' show toBase32; import 'base64.dart' show toBase64; import 'base8.dart' show toOctal; import 'bigint.dart' show toBigInt; +import 'constant_time.dart' show constantTimeEquals; /// A container for digest bytes produced by a hash or encoding function. /// @@ -130,8 +131,9 @@ abstract class ByteCollector extends Object { /// - A [String], which will be treated as a hexadecimal encoded byte array /// /// This function will return True if all bytes in the [other] matches with - /// the [bytes] of this object. If the length does not match, or the type of - /// [other] is not supported, it returns False immediately. + /// the [bytes] of this object. If the length does not match, the type of + /// [other] is not supported, or a [String] is not valid hexadecimal, it + /// returns False immediately. /// /// The content comparison is constant-time: it does not exit early on the /// first mismatching byte, making this method safe for comparing MACs and @@ -148,16 +150,13 @@ abstract class ByteCollector extends Object { Uint8List.view(other.buffer, other.offsetInBytes, other.lengthInBytes), ); } else if (other is String) { - return isEqual(fromHex(other)); + // A string that is not valid hexadecimal cannot match these bytes. + final decoded = tryFromHex(other); + return decoded != null && isEqual(decoded); + } else if (other is List) { + return constantTimeEquals(bytes, other); } else if (other is Iterable) { - int i = 0, diff = 0; - for (int x in other) { - if (i >= bytes.length) { - return false; - } - diff |= x ^ bytes[i++]; - } - return i == bytes.length && diff == 0; + return isEqual(List.of(other)); } return false; } diff --git a/lib/src/codecs/base32.dart b/lib/src/codecs/base32.dart index c8dcd91..344b089 100644 --- a/lib/src/codecs/base32.dart +++ b/lib/src/codecs/base32.dart @@ -356,7 +356,7 @@ class Base32Decoder extends AlphabetDecoder { // A non-zero partial word means the input was not a valid length. if (p > 0) { - throw FormatException('Invalid length'); + throw FormatException('Invalid length or non-zero trailing bits'); } return out; diff --git a/lib/src/codecs/base64.dart b/lib/src/codecs/base64.dart index f419b37..f98556d 100644 --- a/lib/src/codecs/base64.dart +++ b/lib/src/codecs/base64.dart @@ -231,7 +231,7 @@ class Base64Decoder extends AlphabetDecoder { // A non-zero partial word means the input was not a valid length. if (p > 0) { - throw FormatException('Invalid length'); + throw FormatException('Invalid length or non-zero trailing bits'); } return out; diff --git a/lib/src/codecs/crypt/crypt_codec.dart b/lib/src/codecs/crypt/crypt_codec.dart index 2a44377..afee6e6 100644 --- a/lib/src/codecs/crypt/crypt_codec.dart +++ b/lib/src/codecs/crypt/crypt_codec.dart @@ -17,6 +17,13 @@ import 'crypt_data.dart'; /// $[$v=][$=(,=)*][$[$]] /// ``` /// +/// This codec follows the PHC grammar above. It also parses the broader Modular +/// Crypt Format (e.g. bcrypt `$2b$$`), but only by the PHC +/// field positions: such strings have no `param=value` segment, so a bcrypt +/// cost is read as the [CryptData.salt] and the combined salt+digest as the +/// [CryptData.hash]. The characters are preserved for round-tripping, but the +/// fields are not split into their MCF meanings. +/// /// [phc]: https://github.com/C2SP/C2SP/blob/main/phc-strings.md class CryptFormat extends Codec { /// Creates a new [CryptFormat] instance. diff --git a/lib/src/codecs/crypt/crypt_data.dart b/lib/src/codecs/crypt/crypt_data.dart index f0e8a5a..b8fcf8f 100644 --- a/lib/src/codecs/crypt/crypt_data.dart +++ b/lib/src/codecs/crypt/crypt_data.dart @@ -150,10 +150,10 @@ class CryptData { hash != other.hash) { return false; } - final p = params; - final q = other.params; - if (p == q) return true; - if (p == null || q == null) return false; + // Treat a null params map and an empty one as equal: both encode to the + // same string (the encoder skips empty params) and have the same hashCode. + final p = params ?? const {}; + final q = other.params ?? const {}; if (p.length != q.length) return false; for (final e in p.entries) { if (!q.containsKey(e.key) || q[e.key] != e.value) { diff --git a/lib/src/codecs_base.dart b/lib/src/codecs_base.dart index 8feeedd..de635c0 100644 --- a/lib/src/codecs_base.dart +++ b/lib/src/codecs_base.dart @@ -8,6 +8,7 @@ export 'base64.dart'; export 'base8.dart'; export 'bigint.dart'; export 'bytes_collector.dart'; +export 'constant_time.dart'; export 'codecs/base16.dart'; export 'codecs/base2.dart'; export 'codecs/base32.dart'; diff --git a/lib/src/constant_time.dart b/lib/src/constant_time.dart new file mode 100644 index 0000000..ac067c2 --- /dev/null +++ b/lib/src/constant_time.dart @@ -0,0 +1,30 @@ +// Copyright (c) 2026, Sudipto Chandra +// All rights reserved. Check LICENSE file for details. + +/// Compares [a] and [b] for equality in constant time. +/// +/// Returns true only if both have the same length and every element is equal. +/// The comparison does not exit early on the first mismatching element, so its +/// running time depends only on the length of the inputs and not on where they +/// differ. This makes it safe for comparing secrets such as MACs and message +/// digests, where an early return would leak how many leading bytes matched. +/// +/// The length of the inputs is not treated as secret: a length mismatch returns +/// false immediately. This is the only early return, and it branches only on +/// the public lengths — never on the contents. Once the lengths match, every +/// element is folded into an accumulator with no data-dependent branch, so the +/// running time depends only on the length. +/// +/// Parameters: +/// - [a] and [b] are the byte sequences to compare. +bool constantTimeEquals(List a, List b) { + int n = a.length; + if (n != b.length) { + return false; + } + int diff = 0; + for (int i = 0; i < n; i++) { + diff |= a[i] ^ b[i]; + } + return diff == 0; +} diff --git a/lib/src/core/alphabet.dart b/lib/src/core/alphabet.dart index dd62123..da8dac9 100644 --- a/lib/src/core/alphabet.dart +++ b/lib/src/core/alphabet.dart @@ -151,7 +151,7 @@ class AlphabetDecoder extends ByteDecoder { } } if (p > 0) { - throw FormatException('Invalid length'); + throw FormatException('Invalid length or non-zero trailing bits'); } if (l < out.length) { return out.sublist(0, l); diff --git a/lib/src/core/bit.dart b/lib/src/core/bit.dart index 66d727d..41e13fe 100644 --- a/lib/src/core/bit.dart +++ b/lib/src/core/bit.dart @@ -12,6 +12,10 @@ import 'codec.dart'; /// right-padding a trailing partial word with zero bits. Both [source] and /// [target] must be in the range 2 to 64. /// +/// The regrouped words are returned in a byte list, so [target] must not exceed +/// 8; a larger [target] produces words above 255 that are truncated to 8 bits. +/// Every shipped codec uses a [target] of 8 or less. +/// /// This is the shared engine behind the fixed-width base codecs. abstract class BitEncoder extends BitConverter { /// Creates a new [BitEncoder] instance. @@ -74,6 +78,10 @@ abstract class BitEncoder extends BitConverter { /// input. A leftover non-zero partial word throws a [FormatException]. Both /// [source] and [target] must be in the range 2 to 64. /// +/// The regrouped words are returned in a byte list, so [target] must not exceed +/// 8; a larger [target] produces words above 255 that are truncated to 8 bits. +/// Every shipped codec decodes to a [target] of 8. +/// /// This is the shared engine behind the fixed-width base codecs. abstract class BitDecoder extends BitConverter { /// Creates a new [BitDecoder] instance. @@ -122,7 +130,7 @@ abstract class BitDecoder extends BitConverter { // p > 0 means that there is a non-zero partial word remaining if (p > 0) { - throw FormatException('Invalid length'); + throw FormatException('Invalid length or non-zero trailing bits'); } if (l < out.length) { diff --git a/lib/src/utf8.dart b/lib/src/utf8.dart index a4e50a6..aa5721a 100644 --- a/lib/src/utf8.dart +++ b/lib/src/utf8.dart @@ -41,3 +41,19 @@ String fromUtf8( codec.decoder.decode( input is Uint8List ? input : Uint8List.fromList(input), ); + +/// Converts a UTF-8 octet sequence to code points, returning `null` instead of +/// throwing when the [input] is not a valid UTF-8 octet sequence. +/// +/// This is the non-throwing counterpart of [fromUtf8]. See [fromUtf8] for the +/// meaning of [codec]. +String? tryFromUtf8( + List input, { + UTF8Codec codec = UTF8Codec.standard, +}) { + try { + return fromUtf8(input, codec: codec); + } on FormatException { + return null; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index a7d63a6..b7462de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: convertlib description: Fast and error resilient codecs. Supports Base2, Base8, Base16, Base32, Base64, BigInt, UTF-8, and the PHC/crypt string format. homepage: https://github.com/bitanon/convertlib -version: 3.5.2 +version: 3.6.0 topics: - codec diff --git a/test/base32_test.dart b/test/base32_test.dart index 247ef36..8d642ef 100644 --- a/test/base32_test.dart +++ b/test/base32_test.dart @@ -373,43 +373,43 @@ void main() { test('"B" (1 char)', () { expect( () => fromBase32("B"), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); test('"MZX" (3 chars)', () { expect( () => fromBase32("MZX"), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); test('"MZXW6Y" (6 chars)', () { expect( () => fromBase32("MZXW6Y"), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); test('"MZXW6YTBO" (full group + 1)', () { expect( () => fromBase32("MZXW6YTBO"), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); test('"MZXW6YTBOI2" (full group + 3)', () { expect( () => fromBase32("MZXW6YTBOI2"), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); test('"MZXW6YTBOI2XX2" (full group + 6)', () { expect( () => fromBase32("MZXW6YTBOI2XX2"), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); }); diff --git a/test/base64_test.dart b/test/base64_test.dart index f0cc52a..0f3b90c 100644 --- a/test/base64_test.dart +++ b/test/base64_test.dart @@ -210,43 +210,43 @@ void main() { test('H', () { expect( () => fromBase64("H"), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); test('Ha', () { expect( () => fromBase64("Ha"), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); test('HaB', () { expect( () => fromBase64("HaB"), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); test('Hashl', () { expect( () => fromBase64("Hashl"), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); test('Hashli', () { expect( () => fromBase64("Hashli"), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); test('Hashlib', () { expect( () => fromBase64("Hashlib"), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); }); diff --git a/test/bytes_collector_test.dart b/test/bytes_collector_test.dart index 5e70cc6..d129c5b 100644 --- a/test/bytes_collector_test.dart +++ b/test/bytes_collector_test.dart @@ -192,6 +192,18 @@ void main() { expect(x.isEqual('bead'), isFalse); }); + test('invalid hex string returns false instead of throwing', () { + // Regression: `isEqual` decodes a String as hex via `fromHex`, which + // throws on invalid input. The dartdoc promises False for unsupported + // input and the method is meant to be safe for untrusted data, so a + // malformed hex string must return false, not throw. + final x = TestCollector(Uint8List.fromList([0xde, 0xad])); + expect(x.isEqual('zz'), isFalse); + expect(x.isEqual('nothex'), isFalse); + expect(x.isEqual('de a'), isFalse); // odd length / space + expect(x.isEqual('deadbeefg'), isFalse); // trailing non-hex nibble + }); + test('List length mismatch', () { final x = TestCollector(Uint8List.fromList([1, 2, 3, 4])); expect(x.isEqual([1, 2, 3]), isFalse); diff --git a/test/constant_time_test.dart b/test/constant_time_test.dart new file mode 100644 index 0000000..3f95cbf --- /dev/null +++ b/test/constant_time_test.dart @@ -0,0 +1,33 @@ +import 'dart:typed_data'; + +import 'package:convertlib/convertlib.dart'; +import 'package:test/test.dart'; + +void main() { + group('constantTimeEquals', () { + test('identical instance is equal', () { + final a = [1, 2, 3]; + expect(constantTimeEquals(a, a), isTrue); + }); + + test('equal content in distinct lists', () { + expect(constantTimeEquals([1, 2, 3], [1, 2, 3]), isTrue); + expect(constantTimeEquals([], []), isTrue); + expect( + constantTimeEquals(Uint8List.fromList([9, 8, 7]), [9, 8, 7]), + isTrue, + ); + }); + + test('different length is not equal', () { + expect(constantTimeEquals([1, 2, 3], [1, 2]), isFalse); + expect(constantTimeEquals([1, 2], [1, 2, 3]), isFalse); + }); + + test('same length but differing content is not equal', () { + expect(constantTimeEquals([1, 2, 3], [1, 2, 4]), isFalse); + // Difference in the first element (would fail an early-exit compare too). + expect(constantTimeEquals([9, 2, 3], [1, 2, 3]), isFalse); + }); + }); +} diff --git a/test/core/alphabet_test.dart b/test/core/alphabet_test.dart index 687b356..ba67e59 100644 --- a/test/core/alphabet_test.dart +++ b/test/core/alphabet_test.dart @@ -147,8 +147,8 @@ void main() { ); expect( () => dec.convert([1]), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); diff --git a/test/core/decoder_test.dart b/test/core/decoder_test.dart index 0d60be0..cd188fa 100644 --- a/test/core/decoder_test.dart +++ b/test/core/decoder_test.dart @@ -48,8 +48,8 @@ void main() { final dec = const Bits3to8Decoder(); expect( () => dec.convert([1]), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); @@ -58,8 +58,8 @@ void main() { final dec = const Bits3to8Decoder(); expect( () => dec.convert([0, 1, 2, 3, 4, 9, 6, 7]), - throwsA(isA() - .having((e) => e.message, 'message', 'Invalid length')), + throwsA(isA().having((e) => e.message, 'message', + 'Invalid length or non-zero trailing bits')), ); }); diff --git a/test/crypt/crypt_data_test.dart b/test/crypt/crypt_data_test.dart index c94f1a4..ce0ca27 100644 --- a/test/crypt/crypt_data_test.dart +++ b/test/crypt/crypt_data_test.dart @@ -225,11 +225,18 @@ void main() { test('not equal when one params is null and the other is not', () { final withParams = CryptData('id', params: {'m': '1'}); final withoutParams = CryptData('id'); - // exercises both operands of the `p == null || q == null` guard expect(withoutParams == withParams, isFalse); expect(withParams == withoutParams, isFalse); }); + test('null params equals empty params (they encode identically)', () { + final nullParams = CryptData('id'); + final emptyParams = CryptData('id', params: {}); + expect(nullParams == emptyParams, isTrue); + expect(emptyParams == nullParams, isTrue); + expect(nullParams.hashCode, emptyParams.hashCode); + }); + test('not equal when params have different lengths', () { final a = CryptData('id', params: {'m': '1', 't': '2'}); final b = CryptData('id', params: {'m': '1'}); diff --git a/test/encode_to_bytes_test.dart b/test/encode_to_bytes_test.dart new file mode 100644 index 0000000..60fe511 --- /dev/null +++ b/test/encode_to_bytes_test.dart @@ -0,0 +1,67 @@ +import 'dart:typed_data'; + +import 'package:convertlib/convertlib.dart'; +import 'package:test/test.dart'; + +import './utils.dart'; + +void main() { + // Each `toBytes` must return the ASCII codes of the corresponding + // `to` string, as a Uint8List, across random inputs of every length. + group('encode-to-bytes matches the string encoders', () { + test('toHexBytes', () { + for (int i = 0; i < 50; ++i) { + final b = randomBytes(i); + expect(toHexBytes(b), isA()); + expect(toHexBytes(b), equals(toHex(b).codeUnits), reason: '$i'); + expect(toHexBytes(b, upper: true), + equals(toHex(b, upper: true).codeUnits)); + } + }); + test('toBinaryBytes', () { + for (int i = 0; i < 50; ++i) { + final b = randomBytes(i); + expect(toBinaryBytes(b), equals(toBinary(b).codeUnits), reason: '$i'); + } + }); + test('toOctalBytes', () { + for (int i = 0; i < 50; ++i) { + final b = randomBytes(i); + expect(toOctalBytes(b), equals(toOctal(b).codeUnits), reason: '$i'); + } + }); + test('toBase32Bytes (padded and unpadded)', () { + for (int i = 0; i < 50; ++i) { + final b = randomBytes(i); + expect(toBase32Bytes(b), equals(toBase32(b).codeUnits), reason: '$i'); + // An explicit padded codec with padding:false exercises the + // padding-strip branch. + expect( + toBase32Bytes(b, codec: Base32Codec.standard, padding: false), + equals(toBase32(b, codec: Base32Codec.standard, padding: false) + .codeUnits), + reason: 'unpadded $i', + ); + // A codec that never pads skips the strip branch. + expect( + toBase32Bytes(b, codec: Base32Codec.crockford, padding: false), + equals(toBase32(b, codec: Base32Codec.crockford).codeUnits), + ); + } + }); + test('toBase64Bytes (padded and unpadded)', () { + for (int i = 0; i < 50; ++i) { + final b = randomBytes(i); + expect(toBase64Bytes(b), equals(toBase64(b).codeUnits), reason: '$i'); + // An explicit padded codec with padding:false exercises the + // padding-strip branch. + expect( + toBase64Bytes(b, codec: Base64Codec.standard, padding: false), + equals(toBase64(b, codec: Base64Codec.standard, padding: false) + .codeUnits), + reason: 'unpadded $i', + ); + } + }); + }); +} diff --git a/test/try_from_test.dart b/test/try_from_test.dart new file mode 100644 index 0000000..5f5c0f0 --- /dev/null +++ b/test/try_from_test.dart @@ -0,0 +1,59 @@ +import 'package:convertlib/convertlib.dart'; +import 'package:test/test.dart'; + +import './utils.dart'; + +void main() { + // Each `tryFrom` returns the same bytes as `from` for valid input, and + // `null` (instead of throwing) for input that `from` would reject. + group('tryFromX returns null instead of throwing', () { + test('tryFromHex', () { + for (int i = 0; i < 30; ++i) { + final b = randomBytes(i); + final s = toHex(b); + expect(tryFromHex(s), equals(fromHex(s)), reason: '$i'); + } + expect(tryFromHex('xyz'), isNull); // invalid characters + expect(tryFromHex('Error'), isNull); + }); + test('tryFromBinary', () { + final b = randomBytes(8); + final s = toBinary(b); + expect(tryFromBinary(s), equals(fromBinary(s))); + expect(tryFromBinary('0102'), isNull); + }); + test('tryFromOctal', () { + final b = randomBytes(8); + final s = toOctal(b); + expect(tryFromOctal(s), equals(fromOctal(s))); + expect(tryFromOctal('089'), isNull); + }); + test('tryFromBase32', () { + for (int i = 0; i < 30; ++i) { + final b = randomBytes(i); + final s = toBase32(b); + expect(tryFromBase32(s), equals(fromBase32(s)), reason: '$i'); + } + expect(tryFromBase32('Error!'), isNull); // invalid char + expect(tryFromBase32('B'), isNull); // invalid length + }); + test('tryFromBase64', () { + for (int i = 0; i < 30; ++i) { + final b = randomBytes(i); + final s = toBase64(b); + expect(tryFromBase64(s), equals(fromBase64(s)), reason: '$i'); + } + expect(tryFromBase64('H'), isNull); // invalid length + }); + test('tryFromUtf8', () { + final bytes = toUtf8('héllo€😀'); + expect(tryFromUtf8(bytes), equals('héllo€😀')); + expect(tryFromUtf8([0xC2]), isNull); // truncated 2-byte sequence + }); + test('tryFromBigInt', () { + final value = toBigInt([1, 2, 3]); + expect(tryFromBigInt(value), equals(fromBigInt(value))); + expect(tryFromBigInt(-BigInt.one), isNull); // negative is rejected + }); + }); +} diff --git a/test_integration/main.dart b/test_integration/main.dart index d12f64a..12df536 100644 --- a/test_integration/main.dart +++ b/test_integration/main.dart @@ -30,4 +30,10 @@ void main() { print("base64 (no padding) => ${toBase64(inp, padding: false)}"); print("bcrypt => ${toBase64(inp, codec: Base64Codec.bcrypt)}"); print(''); + + print("base64 (bytes) => ${toBase64Bytes(inp)}"); + print("tryFromHex('zz') => ${tryFromHex('zz')}"); + print("tryFromBase64(valid) => ${tryFromBase64(toBase64(inp))}"); + print("constantTimeEquals => ${constantTimeEquals(inp, List.of(inp))}"); + print(''); }