You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
385 lines
12 KiB
385 lines
12 KiB
// The extension mechanism (lib/src/extension.dart), as extension.test.ts of
|
|
// datekeys-ts and the tests of package extension of datekeys-go: the order
|
|
// of the UTF-8 bytes of extension_id, the copies of the data, the registries
|
|
// with their places, and the rule of an encoder of spec §72 with the
|
|
// extensions of the specification. The texts of Go are in the differential
|
|
// (formats_extension.json). It reads no file: it runs on the VM and compiled
|
|
// to JavaScript.
|
|
library;
|
|
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:datekeys/src/cbor.dart';
|
|
import 'package:datekeys/src/errors.dart';
|
|
import 'package:datekeys/src/extension.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
String codeOf(void Function() body) {
|
|
try {
|
|
body();
|
|
return 'ok';
|
|
} on DateKeysException catch (e) {
|
|
return e.code.code;
|
|
} on ArgumentError {
|
|
return 'error';
|
|
}
|
|
}
|
|
|
|
List<Extension> decodeWhole(List<int> bytes) {
|
|
late List<Extension> exts;
|
|
unmarshalCbor(
|
|
bytes,
|
|
(d) => exts = decodeExtensionArray(d),
|
|
(e) => encodeExtensionArray(e, exts),
|
|
);
|
|
return exts;
|
|
}
|
|
|
|
Uint8List encodeWhole(List<Extension> exts) {
|
|
final e = CborEncoder();
|
|
encodeExtensionArray(e, exts);
|
|
return e.out();
|
|
}
|
|
|
|
/// A registry of org.a and org.b of version 1, with data "ok" valid.
|
|
final class Validator extends ExtensionRegistry {
|
|
const Validator();
|
|
|
|
@override
|
|
bool known(String id, int version) =>
|
|
(id == 'org.a' || id == 'org.b') && version == 1;
|
|
|
|
@override
|
|
Object? validateData(Extension e) =>
|
|
e.data != null && String.fromCharCodes(e.data!) == 'ok'
|
|
? null
|
|
: 'want "ok"';
|
|
}
|
|
|
|
/// A registry whose validator throws.
|
|
final class Throwing extends ExtensionRegistry {
|
|
const Throwing();
|
|
|
|
@override
|
|
bool known(String id, int version) => true;
|
|
|
|
@override
|
|
Object? validateData(Extension e) =>
|
|
throw const FormatException('cannot read');
|
|
}
|
|
|
|
void main() {
|
|
test('newExtension copies the data and validates the extension', () {
|
|
final data = Uint8List.fromList('public label'.codeUnits);
|
|
final e = newExtension('org.example.label', 1, data);
|
|
data[0] = 0x50;
|
|
expect(
|
|
e,
|
|
Extension('org.example.label', 1, 'public label'.codeUnits.toBytes()),
|
|
);
|
|
expect(
|
|
newExtension('a' * 256, maxExtensionVersion, [0]).id,
|
|
hasLength(256),
|
|
);
|
|
// 128 characters of two bytes are 256 bytes of UTF-8; 129 are too many.
|
|
expect(newExtension('é' * 128, 1, [0]).id, hasLength(128));
|
|
for (final (id, version, data) in [
|
|
('org.a', 1, <int>[]),
|
|
('', 1, [1]),
|
|
('org.\u{d800}', 1, [1]),
|
|
('a' * 257, 1, [1]),
|
|
('é' * 129, 1, [1]),
|
|
('org.a', maxExtensionVersion + 1, [1]),
|
|
('org.a', -1, [1]),
|
|
]) {
|
|
expect(
|
|
codeOf(() => newExtension(id, version, data)),
|
|
'ERR_NON_CANONICAL_CBOR',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('sorts by the UTF-8 bytes of extension_id, never UTF-16 code units', () {
|
|
final ids = ['\u{10000}', '\u{ff61}', 'é', 'z', 'Z', 'a\u{0}', 'a'];
|
|
// Dart compares UTF-16 code units.
|
|
expect('\u{10000}'.compareTo('\u{ff61}'), lessThan(0));
|
|
// 5a, 61, 61 00, 7a, c3 a9, ef bd a1, f0 90 80 80.
|
|
expect(
|
|
canonicalExtensions([for (final id in ids) Extension(id, 1)])
|
|
.map((e) => e.id),
|
|
['Z', 'a', 'a\u{0}', 'z', 'é', '\u{ff61}', '\u{10000}'],
|
|
);
|
|
expect(canonicalExtensions(const []), isEmpty);
|
|
});
|
|
|
|
test('canonicalExtensions copies the data, and its array decodes back', () {
|
|
final input = [
|
|
Extension('org.b', 1),
|
|
Extension('org.a', 2, Uint8List.fromList([0x78])),
|
|
Extension('Z', 9),
|
|
];
|
|
final sorted = canonicalExtensions(input);
|
|
input[1].data![0] = 0;
|
|
expect(sorted[1].data, [0x78]);
|
|
expect(decodeWhole(encodeWhole(sorted)), sorted);
|
|
expect(() => sorted.add(Extension('x', 1)), throwsUnsupportedError);
|
|
});
|
|
|
|
test('never writes an array that decodeExtensionArray rejects', () {
|
|
for (final exts in [
|
|
<Extension>[],
|
|
[for (var i = 0; i < 65; i++) Extension('o.$i'.padLeft(5, '0'), 1)],
|
|
[Extension('org.a', 1, Uint8List(0))],
|
|
[Extension('org.b', 1), Extension('org.a', 1)],
|
|
[Extension('org.a', 1), Extension('org.a', 2)],
|
|
[Extension('org.a', maxExtensionVersion + 1)],
|
|
[Extension('', 1)],
|
|
[Extension('org.\u{d800}', 1)],
|
|
]) {
|
|
final e = CborEncoder()..uint(0);
|
|
encodeExtensionArray(e, exts);
|
|
expect(codeOf(e.out), 'ERR_NON_CANONICAL_CBOR');
|
|
}
|
|
});
|
|
|
|
test('keeps the bytes of data exactly, a leading BOM in its identifier', () {
|
|
// ef bb bf, the BOM, sorts after the 6f of "org.b".
|
|
final exts = [
|
|
Extension('org.b', 7),
|
|
Extension('\u{feff}org.a', 1, Uint8List.fromList([0xff, 0, 0xa2])),
|
|
];
|
|
final back = decodeWhole(encodeWhole(exts));
|
|
expect(back, exts);
|
|
expect(back.last.id.codeUnitAt(0), 0xfeff);
|
|
});
|
|
|
|
test('checkDisjoint merges in canonical order without reordering', () {
|
|
final a = [Extension('a', 1), Extension('c', 1), Extension('e', 1)];
|
|
final b = [Extension('b', 1), Extension('d', 1), Extension('f', 1)];
|
|
checkDisjoint(a, b);
|
|
final unsorted = [Extension('z', 1), Extension('e', 2)];
|
|
expect(codeOf(() => checkDisjoint(a, unsorted)), 'ERR_NON_CANONICAL_CBOR');
|
|
expect(unsorted.map((e) => e.id), ['z', 'e']);
|
|
checkDisjoint(const [], b);
|
|
checkDisjoint(a, const []);
|
|
});
|
|
|
|
group('registries', () {
|
|
final ok = Uint8List.fromList('ok'.codeUnits);
|
|
final ko = Uint8List.fromList('ko'.codeUnits);
|
|
|
|
test('a set knows extension_ids by version', () {
|
|
final s = ExtensionSet({
|
|
'org.a': [1, 2],
|
|
});
|
|
expect([s.known('org.a', 2), s.known('org.a', 3)], [true, false]);
|
|
expect(ExtensionSet().known('org.a', 1), isFalse);
|
|
expect(
|
|
knownIn(s, 'org.a', 1, ExtensionObject.head, ExtensionArray.critical),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
knownIn(
|
|
null,
|
|
'org.a',
|
|
1,
|
|
ExtensionObject.head,
|
|
ExtensionArray.critical,
|
|
),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('an unknown critical extension prevails over invalid data', () {
|
|
expect(
|
|
codeOf(() => checkCritical([Extension('org.a', 1)], null)),
|
|
'ERR_EXTENSION_CRITICAL_UNKNOWN',
|
|
);
|
|
checkCritical(const [], null);
|
|
checkCritical([Extension('org.a', 1, ok)], const Validator());
|
|
expect(
|
|
codeOf(
|
|
() => checkCritical([
|
|
Extension('org.a', 1, ok),
|
|
Extension('org.b', 1, ko),
|
|
], const Validator()),
|
|
),
|
|
'ERR_EXTENSION_DATA_INVALID',
|
|
);
|
|
expect(
|
|
codeOf(
|
|
() => checkCritical([
|
|
Extension('org.b', 1, ko),
|
|
Extension('org.c', 1),
|
|
], const Validator()),
|
|
),
|
|
'ERR_EXTENSION_CRITICAL_UNKNOWN',
|
|
);
|
|
});
|
|
|
|
test('a noncritical extension with invalid data is unusable, the object '
|
|
'valid', () {
|
|
final all = [
|
|
Extension('org.a', 1, ok),
|
|
Extension('org.b', 1),
|
|
Extension('org.c', 1, ko),
|
|
];
|
|
expect(checkNoncritical(all, null), isEmpty);
|
|
final unusable = checkNoncritical(all, const Validator());
|
|
expect(unusable.map((u) => [u.id, u.version]), [
|
|
['org.b', 1],
|
|
]);
|
|
expect(unusable.single.error.code, ErrorCode.extensionDataInvalid);
|
|
expect('${unusable.single}', unusable.single.error.message);
|
|
});
|
|
|
|
test('a validator that throws an Exception rejects the data', () {
|
|
expect(
|
|
codeOf(() => checkCritical([Extension('x', 1)], const Throwing())),
|
|
'ERR_EXTENSION_DATA_INVALID',
|
|
);
|
|
expect(
|
|
checkNoncritical([
|
|
Extension('x', 1),
|
|
], const Throwing()).single.error.message,
|
|
contains('cannot read'),
|
|
);
|
|
});
|
|
|
|
test('the extensions of the specification go only where spec §72 '
|
|
'registers them', () {
|
|
final note = Extension(noteExtensionId, 1, Uint8List.fromList([0x41]));
|
|
final capsule = Extension(
|
|
capsuleExtensionId,
|
|
1,
|
|
Uint8List.fromList([0xa0]),
|
|
);
|
|
// Go's extension.Standard, without ValidateCapsule.
|
|
const std = StandardExtensions(validateCapsule: null);
|
|
for (final o in ExtensionObject.values) {
|
|
for (final a in ExtensionArray.values) {
|
|
final noteHere =
|
|
o == ExtensionObject.publicHeader &&
|
|
a == ExtensionArray.noncritical;
|
|
final capsuleHere =
|
|
o == ExtensionObject.accessKey && a == ExtensionArray.noncritical;
|
|
expect(
|
|
codeOf(() => checkWrite(std, o, a, [note])),
|
|
noteHere ? 'ok' : 'error',
|
|
);
|
|
expect(
|
|
codeOf(() => checkWrite(std, o, a, [capsule])),
|
|
capsuleHere ? 'ok' : 'error',
|
|
);
|
|
// Another version, or another identifier, is the application's.
|
|
checkWrite(std, o, a, [
|
|
Extension(noteExtensionId, 2),
|
|
Extension('org.example', 1),
|
|
]);
|
|
// A reader that knows them treats them as unknown out of place.
|
|
expect(
|
|
codeOf(() => checkCritical([note], std, o)),
|
|
'ERR_EXTENSION_CRITICAL_UNKNOWN',
|
|
);
|
|
// A note without data is unusable in the noncritical array of
|
|
// PUBLIC_HEADER, and ignored in that of any other object.
|
|
expect(checkNoncritical([Extension(noteExtensionId, 1)], std, o), [
|
|
if (o == ExtensionObject.publicHeader) isA<Unusable>(),
|
|
]);
|
|
}
|
|
}
|
|
expect(
|
|
codeOf(
|
|
() => checkWrite(
|
|
std,
|
|
ExtensionObject.accessKey,
|
|
ExtensionArray.noncritical,
|
|
[Extension(capsuleExtensionId, 1)],
|
|
context: 'accesskey',
|
|
),
|
|
),
|
|
'ERR_EXTENSION_DATA_INVALID',
|
|
);
|
|
expect(
|
|
() => checkWrite(
|
|
std,
|
|
ExtensionObject.accessKey,
|
|
ExtensionArray.critical,
|
|
[capsule],
|
|
context: 'accesskey',
|
|
),
|
|
throwsA(
|
|
isA<ArgumentError>().having(
|
|
(e) => '${e.message}',
|
|
'message',
|
|
startsWith('accesskey: extension: datekeys.capsule version 1 '),
|
|
),
|
|
),
|
|
);
|
|
checkWrite(null, ExtensionObject.head, ExtensionArray.critical, [note]);
|
|
});
|
|
|
|
test('the rules of a note are those of spec §24.1, and those of a locator '
|
|
'come from its validator', () {
|
|
final seen = <String>[];
|
|
final std = StandardExtensions(
|
|
validateCapsule: (e) {
|
|
seen.add('capsule');
|
|
return DateKeysException(
|
|
ErrorCode.extensionDataInvalid,
|
|
'a bad locator',
|
|
);
|
|
},
|
|
);
|
|
final good = Extension(noteExtensionId, 1, Uint8List.fromList([0x41]));
|
|
// A tab breaks the rules of text of the declared author (spec §29.6).
|
|
final bad = Extension(noteExtensionId, 1, Uint8List.fromList([9]));
|
|
checkWrite(
|
|
std,
|
|
ExtensionObject.publicHeader,
|
|
ExtensionArray.noncritical,
|
|
[good],
|
|
);
|
|
expect(
|
|
codeOf(
|
|
() => checkWrite(
|
|
std,
|
|
ExtensionObject.publicHeader,
|
|
ExtensionArray.noncritical,
|
|
[bad],
|
|
),
|
|
),
|
|
'ERR_EXTENSION_DATA_INVALID',
|
|
);
|
|
expect(
|
|
checkNoncritical(
|
|
[bad],
|
|
std,
|
|
ExtensionObject.publicHeader,
|
|
).single.error.message,
|
|
contains('a public note that breaks the rules of text'),
|
|
);
|
|
expect(
|
|
checkNoncritical([good], std, ExtensionObject.publicHeader),
|
|
isEmpty,
|
|
);
|
|
expect(
|
|
codeOf(
|
|
() => checkWrite(
|
|
std,
|
|
ExtensionObject.accessKey,
|
|
ExtensionArray.noncritical,
|
|
[Extension(capsuleExtensionId, 1, Uint8List(1))],
|
|
),
|
|
),
|
|
'ERR_EXTENSION_DATA_INVALID',
|
|
);
|
|
expect(seen, ['capsule']);
|
|
});
|
|
});
|
|
}
|
|
|
|
extension on List<int> {
|
|
Uint8List toBytes() => Uint8List.fromList(this);
|
|
}
|