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
parent
e18045ed1b
commit
a60b2be7bb
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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…
Reference in new issue