Stage 4c: the public note and the head of format 3

lib/src/note.dart ports CheckNote, NewNote and Note of package extension
of datekeys-go at c531e93: checkNote and checkNoteData check the length,
the UTF-8 and the rules of text of the declared author, in that order and
with the texts of Go, on the bytes of the note as Go reads its string;
newNote, publicNote and unusableNote, and Header.publicNote and
Header.unusableNote. StandardExtensions checks the data of a note as the
Standard of Go does, and its parameter validateNote, which could replace
those rules, is gone.

lib/src/head.dart ports DecodeHead, EncodeHead and CheckHeadEnd of
format3.go: the limit of HEAD_LEN, the type tag and the version, the CDDL
with R1 and R8 on the UTF-8 bytes of the paths, never on the UTF-16 code
units of a String, and then the comment, the declared author and the files
with the rules of pathrule and their layout, by subtraction, and R7 and R9,
as ERR_HEAD_INVALID with the text of Go, and the critical extensions of the
head. decodeWrittenHead is the decoder without the critical extensions, for
the self-check of the writer of stage 6.

The tests read open_heads.json, open_notes.json, head_schema.json with the
detail of each ERR_HEAD_INVALID, the trees of paths.json as heads of files
of 0 bytes, and the head of each fixture of format 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.11
dev 2 days ago
parent e18045ed1b
commit a60b2be7bb

@ -28,7 +28,9 @@ export 'src/datekey.dart';
export 'src/errors.dart';
export 'src/extension.dart';
export 'src/framing.dart';
export 'src/head.dart' hide decodeWrittenHead;
export 'src/header.dart';
export 'src/note.dart';
export 'src/padding.dart';
export 'src/profile.dart';
export 'src/release.dart'

@ -25,6 +25,7 @@ import 'dart:typed_data';
import 'bytes.dart';
import 'cbor.dart';
import 'errors.dart';
import 'note.dart';
/// The largest extension_id, in UTF-8 bytes: an implementation limit of the
/// reference (spec §74).
@ -533,17 +534,13 @@ const capsuleExtensionId = 'datekeys.capsule';
/// Each is registered only there, and its data is checked as a reader that
/// knows it checks it (spec §54).
///
/// The rules of the text of a note (spec §24.1) need the Unicode tables of
/// the rules of paths, and the data of datekeys.capsule is the locator's:
/// [validateNote] and [validateCapsule] check them, as [validateData]
/// does, and without them only the presence of the data is checked, as Go's
/// Standard without ValidateCapsule.
/// The data of a note is checked with the rules of spec §24.1, as Go's
/// CheckNote ([checkNoteData]). The data of datekeys.capsule is the
/// locator's: [validateCapsule] checks it, and without it only the presence
/// of the data is checked, as Go's Standard without ValidateCapsule.
final class StandardExtensions extends ExtensionRegistry {
/// The registry, with the checks of the data of each extension.
const StandardExtensions({this.validateNote, this.validateCapsule});
/// Checks the data, present, of a public note.
final Object? Function(Extension e)? validateNote;
/// The registry, with the check of the data of datekeys.capsule.
const StandardExtensions({this.validateCapsule});
/// Checks the data, present, of datekeys.capsule.
final Object? Function(Extension e)? validateCapsule;
@ -568,13 +565,19 @@ final class StandardExtensions extends ExtensionRegistry {
Object? validateData(Extension e) {
switch (e.id) {
case noteExtensionId:
if (e.data == null) {
final data = e.data;
if (data == null) {
return DateKeysException(
ErrorCode.extensionDataInvalid,
'a public note without data',
);
}
return validateNote?.call(e);
try {
checkNoteData(data);
} on DateKeysException catch (err) {
return err;
}
return null;
case capsuleExtensionId:
if (e.data == null) {
return DateKeysException(

@ -0,0 +1,464 @@
/// The head of a capsule of format 3 (spec §29.4 to §29.6), as EncodeHead,
/// DecodeHead and CheckHeadEnd of package capsule of datekeys-go (format3.go)
/// and head.ts of datekeys-ts: the same layers of spec §69.1 in the same
/// order, with the same codes and texts. HEAD_CBOR is the map
///
/// {0: "datekeys-head", 1: 1, 2: salt, ? 3: comment,
/// ? 4: declared author, ? 5: [+ file], ? 6: critical extensions,
/// ? 7: noncritical extensions}
///
/// and each file is {0: path, 1: size, 2: start, 3: end, 4: SHA-256,
/// ? 5: mtime}. It is decoded in the layers of spec §69.1: the limit of
/// HEAD_LEN (layer 1, ERR_INTEGRITY); the type tag and the version (layer 2);
/// the CDDL with R1 and R8 (layer 3, ERR_NON_CANONICAL_CBOR); and then, in
/// key order, the comment and the declared author (§29.6), the files with R2
/// to R6c, R10 and their layout, and R7 and R9 over the tree (§29.5), all
/// ERR_HEAD_INVALID, and the critical extensions (layer 4).
///
/// R8 compares the UTF-8 bytes of the paths, never the UTF-16 code units of
/// a Dart String, which put U+FF5E and U+1F600 the other way round.
library;
import 'dart:typed_data';
import 'body.dart';
import 'bytes.dart';
import 'cbor.dart';
import 'errors.dart';
import 'extension.dart';
import 'padding.dart';
import 'pathrule.dart';
import 'schema.dart';
/// The type tag of HEAD_CBOR (spec §29.4).
const headTypeTag = 'datekeys-head';
/// The version of HEAD_CBOR, the only one of format 3 (spec §29.4).
const headVersion = 1;
/// The size of the salt of the head (spec §29.4).
const saltSize = 32;
/// The largest number of files of a head, fixed with format 3 (spec §29.4).
const maxFiles = 65535;
/// The last mtime, 9999-12-31T23:59:59Z in seconds since 1970-01-01 UTC
/// (spec §29.4).
const maxMTime = 253402300799;
const _sha256Size = 32;
/// An entry of the head: a file of CONTENT (spec §29.4).
final class HeadFile {
/// The entry of the file [path] of [size] bytes, from [start] to [end] of
/// CONTENT, whose SHA-256 is [sha256], which it copies, with [mtime] when
/// it has one.
HeadFile({
required this.path,
required this.size,
required this.start,
required this.end,
required List<int> sha256,
this.mtime,
}) : sha256 = Uint8List.fromList(sha256);
/// Key 0, the relative path of the file (spec §29.5).
final String path;
/// Key 1, the size of the file, at most L_MAX.
final int size;
/// Key 2, the offset of its first byte in CONTENT.
final int start;
/// Key 3, the offset after its last byte in CONTENT.
final int end;
/// Key 4, the SHA-256 of the file, 32 bytes.
final Uint8List sha256;
/// Key 5, the modification time of the file at its source, in seconds
/// since 1970-01-01 UTC, or null when the head does not say. It is
/// informative: it proves nothing, and no reader decides anything by it
/// (spec §29.4, §55.1).
final int? mtime;
@override
String toString() =>
'HeadFile(${goQuote(utf8Bytes(path))}, $size bytes, $start..$end)';
}
/// The head of a capsule of format 3 (spec §29.4).
final class Head {
/// A head of [salt], 32 bytes, which it copies, with the other keys.
Head({
required List<int> salt,
this.comment = '',
this.author = '',
List<HeadFile> files = const [],
List<Extension> critical = const [],
List<Extension> noncritical = const [],
}) : salt = Uint8List.fromList(salt),
files = List.unmodifiable(files),
critical = List.unmodifiable(critical),
noncritical = List.unmodifiable(noncritical);
/// Key 2, the salt: 32 bytes of a CSPRNG that make the hash of the head a
/// commitment that hides the files (spec §29.4).
final Uint8List salt;
/// Key 3, the comment, '' when absent.
final String comment;
/// Key 4, the declared author, '' when absent. It is text of the creator
/// and proves nothing (spec §36.1, §55.1).
final String author;
/// Key 5, the files, in strictly ascending order of the UTF-8 bytes of
/// their paths.
final List<HeadFile> files;
/// Key 6, critical_extensions.
final List<Extension> critical;
/// Key 7, noncritical_extensions.
final List<Extension> noncritical;
}
DateKeysException _nonCanonical(String context) =>
DateKeysException(ErrorCode.nonCanonicalCbor, context);
// A file as it is decoded, with the UTF-8 bytes of its path, for R8 and the
// rules of paths.
final class _File {
String path = '';
Uint8List pathBytes = Uint8List(0);
int size = 0;
int start = 0;
int end = 0;
Uint8List sha256 = Uint8List(_sha256Size);
int? mtime;
}
// HEAD_CBOR as it is encoded: keys 2 to 7, keys 0 and 1 being the constants
// headTypeTag and headVersion.
final class _Wire {
Uint8List salt = Uint8List(saltSize);
String comment = '';
String author = '';
List<_File> files = const [];
List<Extension> critical = const [];
List<Extension> noncritical = const [];
}
// Reads HEAD_CBOR with the rules of the third layer: the CDDL, R1 and R8.
// The fourth layer comes after, in _checkFields.
void _decodeWire(CborDecoder d, _Wire w) {
final pairs = d.map(8);
final seen = <int>{};
for (var i = 0; i < pairs; i++) {
final k = d.key();
switch (k) {
case 0:
inKey(k, () => d.text(headTypeTag.length));
case 1:
inKey(k, () => d.uint(headVersion));
case 2:
w.salt = inKey(k, () => d.bstr(saltSize, saltSize));
case 3:
w.comment = inKey(k, () => _decodeText(d, maxCommentLen, 'comment'));
case 4:
w.author = inKey(
k,
() => _decodeText(d, maxAuthorLen, 'declared author'),
);
case 5:
w.files = inKey(k, () => _decodeFiles(d));
case 6:
w.critical = inKey(k, () => decodeExtensionArray(d));
case 7:
w.noncritical = inKey(k, () => decodeExtensionArray(d));
default:
throw keyNotDefined(k);
}
seen.add(k as int);
}
requireKeys(seen, 3);
d.endMap();
}
// A text of 1 to max bytes.
String _decodeText(CborDecoder d, int max, String what) {
final s = d.text(max);
if (s.isEmpty) throw _nonCanonical('empty $what');
return s;
}
// The array of files: 1 to maxFiles, each path of 1 to maxPathLen bytes
// (R1), in strictly ascending order of their UTF-8 bytes (R8).
List<_File> _decodeFiles(CborDecoder d) {
final n = d.array(maxFiles);
if (n == 0) throw _nonCanonical('empty array of files');
final files = <_File>[];
for (var i = 0; i < n; i++) {
final f = withContext('file ${i + 1}', () => _decodeFile(d));
if (i > 0 && compareBytes(f.pathBytes, files[i - 1].pathBytes) <= 0) {
throw _nonCanonical(
'file ${i + 1}: R8: the path is not after the path of file $i in '
'byte order',
);
}
files.add(f);
}
return files;
}
// A file: keys 0 to 4 required, and 5 optional.
_File _decodeFile(CborDecoder d) {
final f = _File();
final pairs = d.map(6);
final seen = <int>{};
for (var i = 0; i < pairs; i++) {
final k = d.key();
inKey(k, () {
switch (k) {
case 0:
f.path = d.text(maxPathLen);
if (f.path.isEmpty) throw _nonCanonical('R1: the path is empty');
f.pathBytes = utf8Bytes(f.path);
case 1:
f.size = d.uint(maxPayloadLength);
case 2:
f.start = d.uint(maxPayloadLength);
case 3:
f.end = d.uint(maxPayloadLength);
case 4:
f.sha256 = d.bstr(_sha256Size, _sha256Size);
case 5:
f.mtime = d.uint(maxMTime);
default:
throw keyNotDefined(k);
}
});
seen.add(k as int);
}
requireKeys(seen, 5);
d.endMap();
return f;
}
void _encodeWire(CborEncoder e, _Wire w) {
e.map(
3 +
(w.comment.isEmpty ? 0 : 1) +
(w.author.isEmpty ? 0 : 1) +
(w.files.isEmpty ? 0 : 1) +
presence(w.critical) +
presence(w.noncritical),
);
e
..uint(0)
..text(headTypeTag)
..uint(1)
..uint(headVersion)
..uint(2)
..bstr(w.salt);
if (w.comment.isNotEmpty) {
e
..uint(3)
..text(w.comment);
}
if (w.author.isNotEmpty) {
e
..uint(4)
..text(w.author);
}
if (w.files.isNotEmpty) {
e
..uint(5)
..array(w.files.length);
for (final f in w.files) {
_encodeFile(e, f);
}
}
encodeExtensionArrays(e, 6, w.critical, w.noncritical);
}
void _encodeFile(CborEncoder e, _File f) {
final mtime = f.mtime;
e
..map(mtime == null ? 5 : 6)
..uint(0)
..text(f.path)
..uint(1)
..uint(f.size)
..uint(2)
..uint(f.start)
..uint(3)
..uint(f.end)
..uint(4)
..bstr(f.sha256);
if (mtime != null) {
e
..uint(5)
..uint(mtime);
}
}
// The rules of the fourth layer with a code of their own, ERR_HEAD_INVALID:
// keys 3, 4 and 5, in that order (spec §29.5, §69.1).
void _checkFields(_Wire w) {
DateKeysException invalid(String what, String detail) =>
DateKeysException(ErrorCode.headInvalid, 'capsule: head: $what$detail');
void rule(String what, void Function() check) {
try {
check();
} on PathRuleException catch (e) {
throw invalid(what, e.message);
}
}
if (w.comment.isNotEmpty) rule('comment: ', () => checkComment(w.comment));
if (w.author.isNotEmpty) {
rule('declared author: ', () => checkAuthor(w.author));
}
var end = 0;
for (var i = 0; i < w.files.length; i++) {
final f = w.files[i];
final what = 'file ${i + 1}: ';
rule(what, () => checkPathUtf8(f.pathBytes));
if (f.start != end) {
throw invalid(
what,
'start ${f.start} is not $end, the end of the file '
'before',
);
}
// By subtraction, never adding values not yet checked (spec §29.4).
if (f.end < f.start || f.end - f.start != f.size) {
throw invalid(
what,
'from start ${f.start} to end ${f.end} is not the size ${f.size}',
);
}
end = f.end;
}
rule('', () => checkTreeUtf8([for (final f in w.files) f.pathBytes]));
}
/// Validates and decodes HEAD_CBOR with the layers of spec §69.1 (spec
/// §29.4, §63 step 17.4), as DecodeHead of Go: its limit of 16 MiB (layer 1,
/// ERR_INTEGRITY); the type tag and the version (layer 2); the CDDL with R1
/// and R8 (layer 3); and then, in key order, the comment and the declared
/// author (§29.6), the files with R2 to R6c, R10 and their layout, and R7 and
/// R9 over the tree (§29.5), all ERR_HEAD_INVALID, and the critical
/// extensions with [reg] (layer 4): an extension of the head that [reg] does
/// not know, or does not register in the head, is unknown (spec §54, §72).
Head decodeHead(List<int> b, [ExtensionRegistry? reg]) {
final h = decodeWrittenHead(b);
withContext(
'capsule: head',
() => checkCritical(h.critical, reg, ExtensionObject.head),
);
return h;
}
/// [decodeHead] but for the critical extensions, whose knowledge depends on
/// the reader: the self-check of a writer decodes with it, as it decodes the
/// control with [decodeControl] (spec §62.1 rule 17). decodeHead of Go.
Head decodeWrittenHead(List<int> b) {
if (b.length > maxHeadLen) {
throw DateKeysException(
ErrorCode.integrity,
'capsule: head: ${b.length} bytes, more than $maxHeadLen',
);
}
final w = _Wire();
withContext('capsule: head', () {
checkSchema(b, headTypeTag, headVersion);
unmarshalCbor(b, (d) => _decodeWire(d, w), (e) => _encodeWire(e, w));
checkDisjoint(w.critical, w.noncritical);
});
_checkFields(w);
return Head(
salt: w.salt,
comment: w.comment,
author: w.author,
files: [
for (final f in w.files)
HeadFile(
path: f.path,
size: f.size,
start: f.start,
end: f.end,
sha256: f.sha256,
mtime: f.mtime,
),
],
critical: w.critical,
noncritical: w.noncritical,
);
}
/// HEAD_CBOR for [h], as EncodeHead of Go: its extensions in canonical order,
/// after the rules of spec §54, and no extension_id in both arrays. The
/// files must already be in the byte order of their paths: the writer checks
/// the result with [decodeWrittenHead], the rules of the reader (spec §62.1
/// rule 17). More than [maxFiles] files is an error of the caller without a
/// normative code, as in Go, and so are a salt or a SHA-256 that is not 32
/// bytes, which the types of Go rule out.
Uint8List encodeHead(Head h) {
if (h.salt.length != saltSize) {
throw ArgumentError(
'capsule: head: salt of ${h.salt.length} bytes, want $saltSize',
);
}
for (var i = 0; i < h.files.length; i++) {
final n = h.files[i].sha256.length;
if (n != _sha256Size) {
throw ArgumentError(
'capsule: head: file ${i + 1}: SHA-256 of $n bytes, want $_sha256Size',
);
}
}
final critical = canonicalExtensions(h.critical);
final noncritical = canonicalExtensions(h.noncritical);
checkDisjoint(critical, noncritical);
if (h.files.length > maxFiles) {
throw ArgumentError(
'capsule: head: ${h.files.length} files, more than $maxFiles',
);
}
final w = _Wire()
..salt = h.salt
..comment = h.comment
..author = h.author
..files = [
for (final f in h.files)
_File()
..path = f.path
..size = f.size
..start = f.start
..end = f.end
..sha256 = f.sha256
..mtime = f.mtime,
]
..critical = critical
..noncritical = noncritical;
final e = CborEncoder();
_encodeWire(e, w);
return e.out();
}
/// Checks that the files fill CONTENT, of [c] bytes: the last one ends at C,
/// or C is 0 without files (spec §29.4, §63 step 17.5), as CheckHeadEnd of
/// Go: ERR_INTEGRITY if not.
void checkHeadEnd(Head h, int c) {
final end = h.files.isEmpty ? 0 : h.files.last.end;
if (end != c) {
throw DateKeysException(
ErrorCode.integrity,
'capsule: BODY: the files end at byte $end of a content of $c bytes',
);
}
}

@ -11,6 +11,7 @@ import 'datekey.dart';
import 'errors.dart';
import 'extension.dart';
import 'framing.dart';
import 'note.dart' as note;
import 'schema.dart';
/// The type tag of PUBLIC_HEADER (spec §24).
@ -88,6 +89,16 @@ final class Header {
/// capsule_id in hexadecimal.
String get capsuleIdHex => toHex(capsuleId);
/// The text of the public note of the header (spec §24.1), or null when it
/// has none that is usable, as PublicNote of Go. It is text of the creator
/// that nobody has checked: a reader shows it as such.
String? get publicNote => note.publicNote(noncritical);
/// Whether the header has a public note that breaks the rules of spec
/// §24.1, which a reader does not show and says so, as UnusableNote of Go.
/// [publicNote] cannot tell that case from a header without a note.
bool get unusableNote => note.unusableNote(noncritical);
}
// PUBLIC_HEADER as it is encoded: keys 2 to 6, keys 0 and 1 being the

@ -0,0 +1,101 @@
/// The public note of a capsule (spec §24.1): the extension datekeys.note,
/// version 1, in the noncritical array of PUBLIC_HEADER. Its data is the text
/// in UTF-8, from 1 to 1024 bytes, with no CBOR around it, and it meets the
/// rules of text of the declared author of spec §29.6: one line, no tabs and
/// no spaces at the ends. As CheckNote, NewNote and Note of package extension
/// of datekeys-go (datekeys.go) and note.ts of datekeys-ts, with the same
/// checks in the same order and the same texts.
///
/// It is public: whoever holds the .dkc reads it before the date, and nobody
/// can check who wrote it (spec §24.1, §36.1). A reader shows it as text of
/// the creator that nobody has checked.
library;
import 'dart:typed_data';
import 'bytes.dart';
import 'errors.dart';
import 'extension.dart';
import 'pathrule.dart';
/// The longest public note, in bytes (spec §24.1).
const maxNoteLen = 1024;
DateKeysException _invalid(String detail) =>
DateKeysException(ErrorCode.extensionDataInvalid, detail);
// The rules of spec §24.1 on the bytes of a note, in the order of Go's
// CheckNote: the length, then UTF-8, then the rules of text.
void _check(Uint8List b) {
if (b.isEmpty || b.length > maxNoteLen) {
throw _invalid('a public note of ${b.length} bytes, not 1 to $maxNoteLen');
}
if (!isValidUtf8(b)) throw _invalid('a public note that is not valid UTF-8');
try {
checkAuthorUtf8(b);
} on PathRuleException catch (e) {
throw _invalid('a public note that breaks the rules of text: ${e.message}');
}
}
/// Checks the text of a public note with the rules of spec §24.1, as Go's
/// CheckNote: from 1 to 1024 bytes of valid UTF-8 that meet the rules of the
/// declared author of spec §29.6. Throws ERR_EXTENSION_DATA_INVALID with the
/// text of Go when it does not.
///
/// The text is measured and checked in UTF-8, as [utf8Bytes] writes it: a
/// lone surrogate, which no UTF-8 holds, counts as the three bytes that Go
/// counts for one, and makes the text invalid UTF-8. A note is never written
/// with U+FFFD in its place: it is public and permanent (spec §62.1).
void checkNote(String text) => _check(utf8Bytes(text));
/// Checks the data of a public note as Go's CheckNote checks the string of
/// its bytes, and returns its text: from 1 to 1024 bytes, then valid UTF-8,
/// then the rules of text, in that order. A leading U+FEFF is part of the
/// text, as in Go, and the rules of text refuse it. Throws
/// ERR_EXTENSION_DATA_INVALID when it is not a note.
String checkNoteData(List<int> data) {
final b = data is Uint8List ? data : Uint8List.fromList(data);
_check(b);
return decodeUtf8(b)!;
}
/// The extension of a public note for [text], which must pass [checkNote],
/// as NewNote of Go. A note is public: whoever has the .dkc reads it before
/// the date, and with the date it can identify someone (spec §24.1).
Extension newNote(String text) {
checkNote(text);
return newExtension(noteExtensionId, 1, utf8Bytes(text));
}
/// The text of the public note among the noncritical extensions of a
/// PUBLIC_HEADER, as Note of Go: the first extension datekeys.note of
/// version 1, when its data meets the rules of spec §24.1; null when there is
/// none, or when it breaks them, and then a reader treats it as unusable and
/// shows nothing (spec §54).
String? publicNote(List<Extension> noncritical) {
for (final e in noncritical) {
if (e.id != noteExtensionId || e.version != 1) continue;
final data = e.data;
if (data == null) return null;
try {
return checkNoteData(data);
} on DateKeysException {
return null;
}
}
return null;
}
/// Whether the noncritical extensions of a PUBLIC_HEADER hold a public note
/// that breaks the rules of spec §24.1, which a reader does not show and says
/// so, as UnusableNote of Go's Header. [publicNote] cannot tell that case
/// from a header without a note.
bool unusableNote(List<Extension> noncritical) {
for (final e in noncritical) {
if (e.id == noteExtensionId && e.version == 1) {
return publicNote(noncritical) == null;
}
}
return false;
}

@ -318,13 +318,10 @@ void main() {
checkWrite(null, ExtensionObject.head, ExtensionArray.critical, [note]);
});
test('the rules of a note and of a locator come from their validators', () {
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(
validateNote: (e) {
seen.add('note');
return String.fromCharCodes(e.data!) == 'A' ? null : 'a bad note';
},
validateCapsule: (e) {
seen.add('capsule');
return DateKeysException(
@ -334,7 +331,8 @@ void main() {
},
);
final good = Extension(noteExtensionId, 1, Uint8List.fromList([0x41]));
final bad = Extension(noteExtensionId, 1, Uint8List.fromList([0x42]));
// 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,
@ -350,7 +348,7 @@ void main() {
[bad],
),
),
'error',
'ERR_EXTENSION_DATA_INVALID',
);
expect(
checkNoncritical(
@ -358,7 +356,11 @@ void main() {
std,
ExtensionObject.publicHeader,
).single.error.message,
contains('a bad note'),
contains('a public note that breaks the rules of text'),
);
expect(
checkNoncritical([good], std, ExtensionObject.publicHeader),
isEmpty,
);
expect(
codeOf(
@ -371,7 +373,7 @@ void main() {
),
'ERR_EXTENSION_DATA_INVALID',
);
expect(seen, ['note', 'note', 'note', 'capsule']);
expect(seen, ['capsule']);
});
});
}

@ -0,0 +1,137 @@
// The head of format 3 against Go: test/vectors/open_heads.json, which
// tool/open_go_vectors.go writes with capsule.DecodeHead and EncodeHead of
// the reference; testdata/vectors/head_schema.json, with the detail of each
// ERR_HEAD_INVALID; and the trees of testdata/vectors/paths.json, each a
// head of files of 0 bytes, through the decoder of the head, from layer 3
// to layer 4.
@TestOn('vm')
library;
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:datekeys/datekeys.dart';
import 'package:datekeys/src/sha256.dart' show sha256;
import 'package:test/test.dart';
import 'open_vectors_support.dart';
Json readJson(String path) => jsonDecode(File(path).readAsStringSync()) as Json;
void main() {
final heads = readJson('test/vectors/open_heads.json');
test('open_heads.json: every head decodes as in Go', () {
expect(heads['spec'], specVersion);
final cases = (heads['decode']! as List).cast<Json>();
expect(cases, hasLength(greaterThan(300)));
checkDecodeCases(cases);
// Every kind of result, and both orders of U+FF5E and U+1F600.
expect(
{for (final c in cases) c['result']},
{
'ok',
'ERR_NON_CANONICAL_CBOR',
'ERR_UNSUPPORTED_VERSION',
'ERR_HEAD_INVALID',
'ERR_EXTENSION_CRITICAL_UNKNOWN',
},
);
});
test('open_heads.json: every head encodes as in Go', () {
final cases = (heads['encode']! as List).cast<Json>();
expect(cases, hasLength(greaterThan(20)));
checkEncodeCases(cases);
});
test('head_schema.json: the result and the detail of each head', () {
final f = readJson('testdata/vectors/head_schema.json');
expect(f['spec'], specVersion);
final cases = (f['heads']! as List).cast<Json>();
expect(cases, hasLength(63));
for (final c in cases) {
final b = fromHex(c['hex']! as String);
var result = 'ok';
var detail = '';
try {
decodeHead(b);
} on DateKeysException catch (e) {
result = e.code.code;
if (e.code == ErrorCode.headInvalid) {
detail = headDetail(e);
}
}
expect(result, c['result'], reason: c['name'] as String?);
expect(detail, c['detail'] ?? '', reason: c['name'] as String?);
}
});
test('paths.json: the trees, as heads of files of 0 bytes', () {
final f = readJson('testdata/vectors/paths.json');
final trees = (f['trees']! as List).cast<Json>();
expect(trees, hasLength(greaterThan(10)));
final empty = sha256(Uint8List(0));
for (final c in trees) {
// TreeHead of the reference: a zero salt and a file of 0 bytes for
// each path, in the order given.
final b = encodeHead(
Head(
salt: Uint8List(saltSize),
files: [
for (final p in (c['paths']! as List).cast<String>())
HeadFile(path: p, size: 0, start: 0, end: 0, sha256: empty),
],
),
);
var result = 'ok';
var detail = '';
try {
decodeHead(b);
} on DateKeysException catch (e) {
result = e.code.code;
if (e.code == ErrorCode.headInvalid) detail = headDetail(e);
}
expect(result, c['result'], reason: c['name'] as String?);
expect(detail, c['detail'] ?? '', reason: c['name'] as String?);
}
});
test('the files of every format 3 fixture, from its record', () {
final names = Directory('testdata/fixtures')
.listSync()
.map((f) => f.uri.pathSegments.last)
.where((n) => n.startsWith('format3_') && n.endsWith('.json'))
.where((n) => !n.endsWith('.inspect.json') && !n.contains('.dkk'))
.toList();
expect(names, hasLength(12));
for (final n in names) {
final r = readJson('testdata/fixtures/$n');
final hb = fromHex(r['head_cbor']! as String);
final h = decodeHead(hb);
expect(toHex(h.salt), r['salt'], reason: n);
expect(
[h.comment, h.author],
[r['comment'] ?? '', r['declared_author'] ?? ''],
reason: n,
);
expect(
[
for (final f in h.files)
{
'path': f.path,
'size': f.size,
'start': f.start,
'end': f.end,
'sha256': toHex(f.sha256),
if (f.mtime != null) 'mtime': f.mtime,
},
],
r['files'] ?? <Object?>[],
reason: n,
);
expect(encodeHead(h), hb, reason: n);
}
});
}

@ -0,0 +1,55 @@
// The public note of spec §24.1 against Go: test/vectors/open_notes.json,
// which tool/open_go_vectors.go writes with extension.CheckNote, Note,
// Header.UnusableNote and extension.Standard of the reference.
@TestOn('vm')
library;
import 'dart:convert';
import 'dart:io';
import 'package:datekeys/datekeys.dart';
import 'package:test/test.dart';
import 'open_vectors_support.dart';
Json readJson(String path) => jsonDecode(File(path).readAsStringSync()) as Json;
void main() {
test('open_notes.json: every note as in Go', () {
final f = readJson('test/vectors/open_notes.json');
expect(f['spec'], specVersion);
final check = (f['check']! as List).cast<Json>();
expect(check, hasLength(greaterThan(300)));
expect(check.where((c) => c['result'] == 'ok'), hasLength(greaterThan(10)));
checkNoteCases(
check,
(f['note']! as List).cast<Json>(),
(f['standard']! as List).cast<Json>(),
);
});
test('the note of a header, as PublicNote and UnusableNote of Go', () {
final r = jsonDecode(
File('testdata/fixtures/time_only_extensions.json').readAsStringSync(),
) as Json;
final h = decodeHeader(fromHex(r['public_header']! as String));
expect([h.publicNote, h.unusableNote], [null, false]);
final noted = Header(
capsuleId: h.capsuleId,
dateKey: h.dateKey,
policy: h.policy,
noncritical: [newNote('Cartas del viaje a Lisboa'), ...h.noncritical],
);
expect(
[noted.publicNote, noted.unusableNote],
['Cartas del viaje a Lisboa', false],
);
final bad = Header(
capsuleId: h.capsuleId,
dateKey: h.dateKey,
policy: h.policy,
noncritical: [Extension(noteExtensionId, 1, fromHex('610962'))],
);
expect([bad.publicNote, bad.unusableNote], [null, true]);
});
}

@ -0,0 +1,270 @@
// Helpers of the tests of the head, the public note, the inspection and the
// opening against the vectors that tool/open_go_vectors.go writes: the
// extension registries of their cases, the capsules of their edits, and the
// checks of the heads and the notes. They read no file, so that the tests
// that run on Node.js can use them.
library;
import 'dart:convert';
import 'dart:typed_data';
import 'package:datekeys/datekeys.dart';
import 'package:datekeys/src/bytes.dart' show utf8Bytes;
import 'package:test/test.dart';
import 'tlock_support.dart' show applyEdits;
typedef Json = Map<String, Object?>;
String str(Json c, String k) => c[k]! as String;
/// The extensions an application implements in a case: known at (id,
/// version), with valid data only when it equals valid_data, with the texts
/// of testkit.KnownExtensions of the reference.
final class KnownExtensions extends ExtensionRegistry {
KnownExtensions(this._known);
final List<({String id, int version, Uint8List valid})> _known;
({String id, int version, Uint8List valid})? _find(String id, int v) {
for (final k in _known) {
if (k.id == id && k.version == v) return k;
}
return null;
}
@override
bool known(String id, int version) => _find(id, version) != null;
@override
Object? validateData(Extension e) {
final k = _find(e.id, e.version);
if (k == null) return 'extension ${e.id} version ${e.version} is not known';
final data = e.data ?? Uint8List(0);
return equalBytes(data, k.valid)
? null
: 'data ${toHex(data)} is not ${toHex(k.valid)}';
}
}
/// A registry that knows every extension and rejects the data of all with
/// [text].
final class RejectAll extends ExtensionRegistry {
const RejectAll(this.text);
final String text;
@override
bool known(String id, int version) => true;
@override
Object? validateData(Extension e) => text;
}
/// The registry of the extensions of a case of the vectors.
ExtensionRegistry? caseExtensions(Json c) {
final list = c['extensions'] as List<Object?>?;
if (list != null) {
return KnownExtensions([
for (final x in list.cast<Json>())
(
id: str(x, 'id'),
version: x['version']! as int,
valid: fromHex(str(x, 'valid_data')),
),
]);
}
final reject = c['reject_all'] as String?;
return reject == null ? null : RejectAll(reject);
}
/// The capsule of a case: the edits of its dkc on [base], the bytes of the
/// fixture it names.
Uint8List capsuleOf(Json c, Uint8List Function(String file) fixture) {
final dkc = c['dkc']! as Json;
final base = dkc['base'] as String?;
return applyEdits(
base == null ? Uint8List(0) : fixture(base),
dkc['edits']! as List<Object?>,
);
}
/// [v] as JSON with the keys of every map sorted, to compare values decoded
/// from the vectors with values built here.
String canonical(Object? v) {
Object? sorted(Object? x) => switch (x) {
final Map<Object?, Object?> m => {
for (final e
in m.entries.toList()
..sort((a, b) => '${a.key}'.compareTo('${b.key}')))
'${e.key}': sorted(e.value),
},
final List<Object?> l => [for (final e in l) sorted(e)],
_ => x,
};
return jsonEncode(sorted(v));
}
/// The violation of an ERR_HEAD_INVALID without its prefix and its code, as
/// HeadDetail of the reference.
String headDetail(DateKeysException e) {
var s = e.message;
const prefix = 'capsule: head: ';
if (s.startsWith(prefix)) s = s.substring(prefix.length);
final suffix = ': ${e.code.code}';
return s.endsWith(suffix) ? s.substring(0, s.length - suffix.length) : s;
}
/// The registry of the heads of open_heads.json: the extension a of version
/// 1 without data, and org.example of version 2 with data 01.
final headRegistry = KnownExtensions([
(id: 'a', version: 1, valid: Uint8List(0)),
(id: 'org.example', version: 2, valid: Uint8List.fromList([1])),
]);
/// The outcome of [body] as the vectors write it: [result, text].
List<String> outcomeOf(void Function() body) {
try {
body();
return ['ok', ''];
} on DateKeysException catch (e) {
return [e.code.code, e.message];
}
}
List<String> _want(Json c) => [
c['result']! as String,
c['text'] as String? ?? '',
];
/// The decode cases of open_heads.json: the result and the text of Go with
/// no registry and with [headRegistry], and a head that decodes encodes to
/// its bytes again.
void checkDecodeCases(List<Json> cases) {
for (final c in cases) {
final b = fromHex(c['hex']! as String);
final reason = '${c['kind']}: ${c['hex']}';
expect(outcomeOf(() => decodeHead(b)), _want(c), reason: reason);
final r = (c['with_registry'] as Json?) ?? c;
expect(
outcomeOf(() => decodeHead(b, headRegistry)),
_want(r),
reason: reason,
);
if (c['result'] == 'ok') {
expect(encodeHead(decodeHead(b)), b, reason: reason);
}
}
}
List<Extension> extensionsOf(Object? v) => [
for (final x in (v! as List).cast<List<Object?>>())
Extension(
x[0]! as String,
x[1]! as int,
x[2] == null ? null : fromHex(x[2]! as String),
),
];
/// The encode cases of open_heads.json: the bytes, or the code and the text,
/// of EncodeHead of Go.
void checkEncodeCases(List<Json> cases) {
for (final c in cases) {
final h = c['head']! as Json;
final head = Head(
salt: fromHex(h['salt']! as String),
comment: h['comment']! as String,
author: h['author']! as String,
files: [
for (final f in (h['files']! as List).cast<List<Object?>>())
HeadFile(
path: f[0]! as String,
size: f[1]! as int,
start: f[2]! as int,
end: f[3]! as int,
sha256: fromHex(f[4]! as String),
mtime: f[5] as int?,
),
],
critical: extensionsOf(h['critical']),
noncritical: extensionsOf(h['noncritical']),
);
Uint8List? out;
final got = outcomeOf(() => out = encodeHead(head));
expect(got, _want(c), reason: canonical(h));
if (c['result'] == 'ok') {
expect(toHex(out!), c['hex'], reason: canonical(h));
}
}
}
/// The note cases of open_notes.json: CheckNote on the bytes, and on the
/// text when they are UTF-8; Note and UnusableNote of arrays; and
/// StandardExtensions on a note in PUBLIC_HEADER.
void checkNoteCases(List<Json> check, List<Json> notes, List<Json> standard) {
expect(standard, hasLength(check.length));
for (var i = 0; i < check.length; i++) {
final c = check[i];
final data = fromHex(c['data']! as String);
final reason = '${c['name']}: ${c['data']}';
String? text;
expect(
outcomeOf(() => text = checkNoteData(data)),
_want(c),
reason: reason,
);
if (c['result'] == 'ok') expect(utf8Bytes(text!), data, reason: reason);
final decoded = decodeUtf8(data);
if (decoded != null) {
expect(outcomeOf(() => checkNote(decoded)), _want(c), reason: reason);
if (c['result'] == 'ok') {
final e = newNote(decoded);
expect(
[e.id, e.version, toHex(e.data!)],
[noteExtensionId, 1, c['data']],
);
}
}
// StandardExtensions, as Standard of Go, on a note of version 1 with
// these bytes, absent data when empty.
final s = standard[i];
expect(s['name'], c['name']);
final e = Extension(noteExtensionId, 1, data.isEmpty ? null : data);
const std = StandardExtensions();
expect(
[
for (final u in checkNoncritical(
[e],
std,
ExtensionObject.publicHeader,
))
[u.id, u.version, u.error.message],
],
s['unusable'],
reason: reason,
);
expect(
outcomeOf(
() => checkWrite(
std,
ExtensionObject.publicHeader,
ExtensionArray.noncritical,
[e],
),
),
_want(s),
reason: reason,
);
}
for (final n in notes) {
final exts = extensionsOf(n['extensions']);
final reason = n['name'] as String?;
expect(
publicNote(exts),
n['usable'] == true ? n['text'] : null,
reason: reason,
);
expect(unusableNote(exts), n['unusable'], reason: reason);
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.