Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# _next_

- Add whitespace-tolerant decoding: `fromHex`, `fromBase32`, `fromBase64`, and
their `tryFrom` counterparts accept `ignoreWhitespace: true` to skip ASCII
whitespace (tab, line feed, vertical tab, form feed, carriage return, and
space) in the encoded input, e.g. the body of a PEM or MIME document.
`AlphabetDecoder`, `Base32Decoder`, and `Base64Decoder` gain the same opt-in
flag. Strict rejection remains the default and the default output is
unchanged.

# 3.6.1

- Update `README.md` for the 3.6.0 API: the `to<Name>Bytes` byte encoders, the
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ to drop `=`, or a `codec:`:
encoded ASCII as a `Uint8List`, skipping the intermediate `String`.
- **Non-throwing decoders** — the `tryFrom<Name>` decoders return `null` instead
of throwing a `FormatException` on invalid input.
- **Whitespace-tolerant decode** — `fromHex`, `fromBase32`, `fromBase64`, and
their `tryFrom` counterparts accept `ignoreWhitespace: true` to skip ASCII
whitespace (tab, line feed, vertical tab, form feed, carriage return, and
space) in the input — handy for PEM/MIME bodies and hex dumps. Strict
rejection remains the default. `AlphabetDecoder`, `Base32Decoder`, and
`Base64Decoder` expose the same flag for custom codecs.
- **Constant-time compare** — `constantTimeEquals(a, b)` compares two byte lists
without exiting early on the first mismatch, for verifying MACs and digests.
- **Low-level building blocks** — the generic converters `BitEncoder`/
Expand Down Expand Up @@ -228,6 +234,11 @@ void main() {
// Decode back to the original bytes
final back = fromBase64(toBase64(data, url: true));
print('roundtrip : ${fromUtf8(back)}');

// Line-wrapped input (PEM/MIME style) decodes with ignoreWhitespace
const pem = 'YSA+\nPiBi\nLCBj\nL2Q=\n';
final body = fromBase64(pem, ignoreWhitespace: true);
print('pem body : ${fromUtf8(body)}');
}
```

Expand Down
5 changes: 5 additions & 0 deletions example/base64_example.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,9 @@ void main() {
// Decode back to the original bytes
final back = fromBase64(toBase64(data, url: true));
print('roundtrip : ${fromUtf8(back)}');

// Line-wrapped input (PEM/MIME style) decodes with ignoreWhitespace
const pem = 'YSA+\nPiBi\nLCBj\nL2Q=\n';
final body = fromBase64(pem, ignoreWhitespace: true);
print('pem body : ${fromUtf8(body)}');
}
26 changes: 23 additions & 3 deletions lib/src/base16.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ Uint8List toHexBytes(
///
/// Parameters:
/// - [input] should be a valid Base-16 (hexadecimal) string.
/// - If [ignoreWhitespace] is true, ASCII whitespace characters (tab, line
/// feed, vertical tab, form feed, carriage return, and space) in the
/// [input] are skipped instead of rejected, so line-wrapped or
/// space-grouped input can be decoded directly.
/// - [codec] is the [Base16Codec] to use. It is derived from the other
/// parameters if not provided.
///
Expand All @@ -66,22 +70,38 @@ Uint8List toHexBytes(
Uint8List fromHex(
String input, {
Base16Codec? codec,
bool ignoreWhitespace = false,
}) {
codec ??= _codecFromParameters();
return codec.decoder.convert(input.codeUnits);
if (!ignoreWhitespace) {
return codec.decoder.convert(input.codeUnits);
}
// Compact the input once, dropping ASCII whitespace, which is never a valid
// Base-16 digit. The buffer keeps the full 16-bit code units so that the
// decoder sees exactly the characters the strict path would.
int i, k, y, n;
n = input.length;
var compact = Uint16List(n);
for (i = k = 0; i < n; ++i) {
y = input.codeUnitAt(i);
if (y == 0x20 || (y >= 0x09 && y <= 0x0D)) continue;
compact[k++] = y;
}
return codec.decoder.convert(Uint16List.sublistView(compact, 0, k));
}

/// 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].
/// meaning of [codec] and [ignoreWhitespace].
Uint8List? tryFromHex(
String input, {
Base16Codec? codec,
bool ignoreWhitespace = false,
}) {
try {
return fromHex(input, codec: codec);
return fromHex(input, codec: codec, ignoreWhitespace: ignoreWhitespace);
} on FormatException {
return null;
}
Expand Down
25 changes: 22 additions & 3 deletions lib/src/base32.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ Uint8List toBase32Bytes(
/// - [input] should be a valid base-32 encoded string.
/// - If [padding] is true, the [input] may contain padding characters, which
/// are ignored during decoding.
/// - If [ignoreWhitespace] is true, ASCII whitespace characters (tab, line
/// feed, vertical tab, form feed, carriage return, and space) in the
/// [input] are skipped instead of rejected, so line-wrapped input can be
/// decoded directly.
/// - [codec] is the [Base32Codec] to use. It is derived from the other
/// parameters if not provided.
///
Expand All @@ -104,23 +108,38 @@ Uint8List fromBase32(
String input, {
Base32Codec? codec,
bool padding = true,
bool ignoreWhitespace = false,
}) {
codec ??= _codecFromParameters(padding: padding);
return codec.decoder.convert(input.codeUnits);
Base32Decoder decoder = codec.decoder;
if (ignoreWhitespace && !decoder.ignoreWhitespace) {
decoder = Base32Decoder(
alphabet: decoder.alphabet,
padding: decoder.padding,
ignoreWhitespace: true,
);
}
return 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].
/// the meaning of [codec], [padding], and [ignoreWhitespace].
Uint8List? tryFromBase32(
String input, {
Base32Codec? codec,
bool padding = true,
bool ignoreWhitespace = false,
}) {
try {
return fromBase32(input, codec: codec, padding: padding);
return fromBase32(
input,
codec: codec,
padding: padding,
ignoreWhitespace: ignoreWhitespace,
);
} on FormatException {
return null;
}
Expand Down
25 changes: 22 additions & 3 deletions lib/src/base64.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ Uint8List toBase64Bytes(
/// - [input] should be a valid base-64 encoded string.
/// - If [padding] is true, the [input] may contain padding characters, which
/// are ignored during decoding.
/// - If [ignoreWhitespace] is true, ASCII whitespace characters (tab, line
/// feed, vertical tab, form feed, carriage return, and space) in the
/// [input] are skipped instead of rejected, so line-wrapped input such as
/// the body of a PEM or MIME document can be decoded directly.
/// - [codec] is the [Base64Codec] to use. It is derived from the other
/// parameters if not provided.
///
Expand All @@ -101,23 +105,38 @@ Uint8List fromBase64(
String input, {
Base64Codec? codec,
bool padding = true,
bool ignoreWhitespace = false,
}) {
codec ??= _codecFromParameters(padding: padding);
return codec.decoder.convert(input.codeUnits);
Base64Decoder decoder = codec.decoder;
if (ignoreWhitespace && !decoder.ignoreWhitespace) {
decoder = Base64Decoder(
alphabet: decoder.alphabet,
padding: decoder.padding,
ignoreWhitespace: true,
);
}
return 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].
/// the meaning of [codec], [padding], and [ignoreWhitespace].
Uint8List? tryFromBase64(
String input, {
Base64Codec? codec,
bool padding = true,
bool ignoreWhitespace = false,
}) {
try {
return fromBase64(input, codec: codec, padding: padding);
return fromBase64(
input,
codec: codec,
padding: padding,
ignoreWhitespace: ignoreWhitespace,
);
} on FormatException {
return null;
}
Expand Down
44 changes: 34 additions & 10 deletions lib/src/codecs/base32.dart
Original file line number Diff line number Diff line change
Expand Up @@ -256,22 +256,46 @@ class Base32Decoder extends AlphabetDecoder {
/// - The [alphabet] maps each input character to its 5-bit word.
/// - If [padding] is not null, trailing occurrences of it are stripped before
/// decoding.
/// - If [ignoreWhitespace] is true, ASCII whitespace characters (tab, line
/// feed, vertical tab, form feed, carriage return, and space) in the
/// input are skipped instead of rejected.
const Base32Decoder({
required super.alphabet,
super.padding,
super.ignoreWhitespace,
}) : super(bits: 5);

@override
Uint8List convert(List<int> encoded) {
final table = alphabet;
final pad = padding;
final tlen = table.length;
List<int> src = encoded;
int len = encoded.length;

// Opt-in: compact the input once, dropping ASCII whitespace, so that the
// 8-character fast path below still runs on whitespace-laced input. Every
// character is validated here, reporting its original position; only
// valid characters (all below `tlen`) and padding reach the buffer.
if (ignoreWhitespace) {
var compact = Uint16List(len);
int j, k, w;
for (j = k = 0; j < len; ++j) {
w = encoded[j];
if (w != pad && (w < 0 || w >= tlen || table[w] < 0)) {
if (w == 0x20 || (w >= 0x09 && w <= 0x0D)) continue;
throw FormatException('Invalid character $w at $j');
}
compact[k++] = w;
}
src = compact;
len = k;
}

// Padding is only valid as a trailing suffix, strip it here. A padding
// character anywhere else is rejected as an invalid character.
if (pad != null) {
while (len > 0 && encoded[len - 1] == pad) {
while (len > 0 && src[len - 1] == pad) {
len--;
}
}
Expand All @@ -285,14 +309,14 @@ class Base32Decoder extends AlphabetDecoder {
// Fast path: complete 8-character groups into 5 bytes.
int fastEnd = len - (len & 7);
while (i < fastEnd) {
y0 = encoded[i];
y1 = encoded[i + 1];
y2 = encoded[i + 2];
y3 = encoded[i + 3];
y4 = encoded[i + 4];
y5 = encoded[i + 5];
y6 = encoded[i + 6];
y7 = encoded[i + 7];
y0 = src[i];
y1 = src[i + 1];
y2 = src[i + 2];
y3 = src[i + 3];
y4 = src[i + 4];
y5 = src[i + 5];
y6 = src[i + 6];
y7 = src[i + 7];
if (y0 < 0 ||
y1 < 0 ||
y2 < 0 ||
Expand Down Expand Up @@ -341,7 +365,7 @@ class Base32Decoder extends AlphabetDecoder {
// No padding remains, so this only regroups bits and validates characters.
int p = 0, n = 0, x, y;
for (; i < len; ++i) {
y = encoded[i];
y = src[i];
if (y < 0 || y >= tlen || (x = table[y]) < 0) {
throw FormatException('Invalid character $y at $i');
}
Expand Down
36 changes: 30 additions & 6 deletions lib/src/codecs/base64.dart
Original file line number Diff line number Diff line change
Expand Up @@ -156,23 +156,47 @@ class Base64Decoder extends AlphabetDecoder {
/// - The [alphabet] maps each input character to its 6-bit word.
/// - If [padding] is not null, trailing occurrences of it are stripped before
/// decoding.
/// - If [ignoreWhitespace] is true, ASCII whitespace characters (tab, line
/// feed, vertical tab, form feed, carriage return, and space) in the
/// input are skipped instead of rejected.
const Base64Decoder({
required super.alphabet,
super.padding,
super.ignoreWhitespace,
}) : super(bits: 6);

@override
Uint8List convert(List<int> encoded) {
final table = alphabet;
final pad = padding;
final tlen = table.length;
List<int> src = encoded;
int len = encoded.length;

// Opt-in: compact the input once, dropping ASCII whitespace, so that the
// 4-character fast path below still runs on whitespace-laced input. Every
// character is validated here, reporting its original position; only
// valid characters (all below `tlen`) and padding reach the buffer.
if (ignoreWhitespace) {
var compact = Uint16List(len);
int j, k, w;
for (j = k = 0; j < len; ++j) {
w = encoded[j];
if (w != pad && (w < 0 || w >= tlen || table[w] < 0)) {
if (w == 0x20 || (w >= 0x09 && w <= 0x0D)) continue;
throw FormatException('Invalid character $w at $j');
}
compact[k++] = w;
}
src = compact;
len = k;
}

// Padding is only valid as a trailing suffix, strip it here. A padding
// character anywhere else is rejected as an invalid character.
// decode table maps it to -1).
if (pad != null) {
while (len > 0 && encoded[len - 1] == pad) {
while (len > 0 && src[len - 1] == pad) {
len--;
}
}
Expand All @@ -185,10 +209,10 @@ class Base64Decoder extends AlphabetDecoder {
// Fast path: complete 4-character groups into 3 bytes.
int fastEnd = len - (len & 3);
while (i < fastEnd) {
y0 = encoded[i];
y1 = encoded[i + 1];
y2 = encoded[i + 2];
y3 = encoded[i + 3];
y0 = src[i];
y1 = src[i + 1];
y2 = src[i + 2];
y3 = src[i + 3];
if (y0 < 0 ||
y1 < 0 ||
y2 < 0 ||
Expand Down Expand Up @@ -216,7 +240,7 @@ class Base64Decoder extends AlphabetDecoder {
// No padding remains, so this only regroups bits and validates characters.
int p = 0, n = 0, x, y;
for (; i < len; ++i) {
y = encoded[i];
y = src[i];
if (y < 0 || y >= tlen || (x = table[y]) < 0) {
throw FormatException('Invalid character $y at $i');
}
Expand Down
Loading