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.
272 lines
9.0 KiB
272 lines
9.0 KiB
// Vendors testdata/, the word lists of wordkey/lists/ and the recovery annex
|
|
// of annex/ from the Go reference implementation
|
|
// (g.activething.com/go/DateKeys) at one pinned commit and verifies them. The
|
|
// Go fixtures, vectors, lists and annex are the source of truth: this package
|
|
// never generates its own. A port of scripts/sync-testdata.mjs of
|
|
// datekeys-ts, with the same testdata/SOURCE.json.
|
|
//
|
|
// dart run tool/sync_testdata.dart sync [--repo ../datekeys-go] [--commit HEAD]
|
|
// dart run tool/sync_testdata.dart check [--against ../datekeys-go]
|
|
//
|
|
// sync copies every blob under testdata/, wordkey/lists/ and annex/ at the
|
|
// commit (read with git, so uncommitted changes in the Go checkout are never
|
|
// picked up) into testdata/, wordlists/ and annex/, and writes the
|
|
// SOURCE.json of each, with the full commit id and the SHA-256 of each file.
|
|
// It reads every blob of every tree before it touches any copy, and a tree
|
|
// missing at the commit is an error. check verifies that the files on disk of
|
|
// each tree match its SOURCE.json exactly, with no missing or extra files;
|
|
// with --against it also re-reads every blob from the Go repository at the
|
|
// recorded commit. It prints one line per tree: testdata, wordlists, annex.
|
|
// test/testdata_test.dart runs check. No dependencies but package:crypto,
|
|
// Dart and git.
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
|
|
/// The module whose trees are vendored.
|
|
const module = 'g.activething.com/go/DateKeys';
|
|
|
|
/// A tree that sync vendors: [dir], a directory of this package, holds the
|
|
/// files of [upstream], a directory of the Go repository.
|
|
class Tree {
|
|
const Tree(this.dir, this.upstream);
|
|
|
|
final String dir;
|
|
final String upstream;
|
|
}
|
|
|
|
/// testdata/, the fixtures and the vectors of the Go reference.
|
|
const testdataTree = Tree('testdata', 'testdata');
|
|
|
|
/// wordlists/, the word lists of wordkey.Generate, whose SHA-256
|
|
/// lib/src/wordlist.dart pins.
|
|
const wordlistsTree = Tree('wordlists', 'wordkey/lists');
|
|
|
|
/// annex/, the recovery annex of datekeys.RecoveryAnnex, which
|
|
/// tool/recovery_annex_copy.dart writes as the constant of
|
|
/// lib/src/recovery_annex.g.dart.
|
|
const annexTree = Tree('annex', 'annex');
|
|
|
|
/// The trees, in the order of the lines of check.
|
|
const trees = [testdataTree, wordlistsTree, annexTree];
|
|
|
|
/// What check found in a tree: the files, the commit and the module of its
|
|
/// SOURCE.json, and every difference, none when the copy is intact.
|
|
class CheckResult {
|
|
CheckResult(this.tree, this.module, this.commit, this.files, this.errors);
|
|
|
|
final Tree tree;
|
|
final String module;
|
|
final String commit;
|
|
final int files;
|
|
final List<String> errors;
|
|
|
|
/// The line that check prints for an intact copy.
|
|
String get line => '${tree.dir}: $files files match $module at $commit';
|
|
}
|
|
|
|
void main(List<String> args) {
|
|
final root = File(Platform.script.toFilePath()).parent.parent;
|
|
final command = args.isEmpty ? '' : args.first;
|
|
final rest = args.skip(1).toList();
|
|
try {
|
|
switch (command) {
|
|
case 'sync':
|
|
final repo = _option(rest, '--repo') ?? '${root.path}/../datekeys-go';
|
|
final commit = _option(rest, '--commit') ?? 'HEAD';
|
|
sync(root, Directory(repo).absolute, commit);
|
|
case 'check':
|
|
final against = _option(rest, '--against');
|
|
final verified = against == null
|
|
? ''
|
|
: ' (verified against the repository)';
|
|
for (final tree in trees) {
|
|
try {
|
|
final r = check(
|
|
root,
|
|
tree: tree,
|
|
against: against == null ? null : Directory(against).absolute,
|
|
);
|
|
if (r.errors.isEmpty) {
|
|
stdout.writeln('${r.line}$verified');
|
|
continue;
|
|
}
|
|
for (final e in r.errors) {
|
|
stderr.writeln('${tree.dir}: $e');
|
|
}
|
|
} catch (e) {
|
|
stderr.writeln('${tree.dir}: $e');
|
|
}
|
|
exitCode = 1;
|
|
}
|
|
default:
|
|
stderr.writeln(
|
|
'usage: sync_testdata.dart sync [--repo PATH] [--commit REV] | check [--against PATH]',
|
|
);
|
|
exitCode = 2;
|
|
}
|
|
} catch (e) {
|
|
stderr.writeln('$command: $e');
|
|
exitCode = 1;
|
|
}
|
|
}
|
|
|
|
/// Copies each tree of [trees] of the Go repository [repo] at [rev] into
|
|
/// [root], and writes its SOURCE.json.
|
|
void sync(Directory root, Directory repo, String rev) {
|
|
final commit = utf8
|
|
.decode(_git(repo, ['rev-parse', '--verify', '$rev^{commit}']))
|
|
.trim();
|
|
final listed = [
|
|
for (final tree in trees) (tree, _listBlobs(repo, commit, tree)),
|
|
];
|
|
for (final (tree, paths) in listed) {
|
|
if (paths.isEmpty) throw StateError('no ${tree.upstream}/ at $commit');
|
|
}
|
|
// Read everything before touching the local copies, so a failed read
|
|
// leaves them intact.
|
|
final read = [
|
|
for (final (tree, paths) in listed)
|
|
(tree, [for (final p in paths) (p, _readBlob(repo, commit, tree, p))]),
|
|
];
|
|
for (final (tree, blobs) in read) {
|
|
final dir = Directory('${root.path}/${tree.dir}');
|
|
if (dir.existsSync()) dir.deleteSync(recursive: true);
|
|
final files = <String, String>{};
|
|
for (final (path, data) in blobs) {
|
|
File('${dir.path}/$path')
|
|
..createSync(recursive: true)
|
|
..writeAsBytesSync(data);
|
|
files[path] = _sha256(data);
|
|
}
|
|
final source = {'module': module, 'commit': commit, 'files': files};
|
|
File('${dir.path}/SOURCE.json').writeAsStringSync(
|
|
'${const JsonEncoder.withIndent(' ').convert(source)}\n',
|
|
);
|
|
stdout.writeln(
|
|
'${tree.dir}: ${blobs.length} files from $module at $commit',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Checks [tree] of [root], testdata/ by default, against its SOURCE.json
|
|
/// and, with [against], against the Go repository at the recorded commit.
|
|
CheckResult check(
|
|
Directory root, {
|
|
Tree tree = testdataTree,
|
|
Directory? against,
|
|
}) {
|
|
final dir = Directory('${root.path}/${tree.dir}');
|
|
final source = jsonDecode(
|
|
File('${dir.path}/SOURCE.json').readAsStringSync(),
|
|
) as Map<String, Object?>;
|
|
final recorded = (source['files']! as Map<String, Object?>)
|
|
.cast<String, String>();
|
|
final commit = source['commit']! as String;
|
|
final expected = recorded.keys.toList()..sort();
|
|
final actual = _listLocal(dir);
|
|
final errors = <String>[];
|
|
for (final path in expected) {
|
|
if (!actual.contains(path)) {
|
|
errors.add('missing $path');
|
|
continue;
|
|
}
|
|
final data = File('${dir.path}/$path').readAsBytesSync();
|
|
if (_sha256(data) != recorded[path]) {
|
|
errors.add('modified $path');
|
|
}
|
|
}
|
|
for (final path in actual) {
|
|
if (!recorded.containsKey(path)) errors.add('extra $path');
|
|
}
|
|
if (against != null) {
|
|
final upstream = _listBlobs(against, commit, tree);
|
|
if (upstream.join('\n') != expected.join('\n')) {
|
|
errors.add('file list differs from $commit');
|
|
}
|
|
for (final path in upstream) {
|
|
if (_sha256(_readBlob(against, commit, tree, path)) != recorded[path]) {
|
|
errors.add('differs from upstream $path');
|
|
}
|
|
}
|
|
}
|
|
return CheckResult(
|
|
tree,
|
|
source['module']! as String,
|
|
commit,
|
|
expected.length,
|
|
errors,
|
|
);
|
|
}
|
|
|
|
List<int> _git(Directory repo, List<String> args) {
|
|
final r = Process.runSync(
|
|
'git',
|
|
['-C', repo.path, ...args],
|
|
stdoutEncoding: null,
|
|
stderrEncoding: utf8,
|
|
);
|
|
if (r.exitCode != 0) {
|
|
final message = (r.stderr as String).trim();
|
|
throw ProcessException('git', args, message, r.exitCode);
|
|
}
|
|
return r.stdout as List<int>;
|
|
}
|
|
|
|
String _sha256(List<int> data) => sha256.convert(data).toString();
|
|
|
|
// Files under the upstream directory of tree at commit, as paths relative to
|
|
// it with forward slashes.
|
|
List<String> _listBlobs(Directory repo, String commit, Tree tree) {
|
|
final out = utf8.decode(
|
|
_git(repo, [
|
|
'ls-tree',
|
|
'-r',
|
|
'-z',
|
|
'--full-tree',
|
|
commit,
|
|
'--',
|
|
tree.upstream,
|
|
]),
|
|
);
|
|
final paths = <String>[];
|
|
for (final line in out.split('\u0000').where((l) => l.isNotEmpty)) {
|
|
final tab = line.indexOf('\t');
|
|
final meta = line.substring(0, tab).split(' ');
|
|
final path = line.substring(tab + 1);
|
|
if (meta[1] != 'blob' || meta[0] == '120000') {
|
|
throw StateError('unsupported entry $path (${meta[0]} ${meta[1]})');
|
|
}
|
|
paths.add(path.substring('${tree.upstream}/'.length));
|
|
}
|
|
return paths..sort();
|
|
}
|
|
|
|
List<int> _readBlob(Directory repo, String commit, Tree tree, String path) =>
|
|
_git(repo, ['cat-file', 'blob', '$commit:${tree.upstream}/$path']);
|
|
|
|
// Files on disk under dir, excluding its SOURCE.json.
|
|
List<String> _listLocal(Directory dir) {
|
|
final prefix = dir.absolute.path.length + 1;
|
|
final files = <String>[];
|
|
for (final f
|
|
in dir.absolute
|
|
.listSync(recursive: true, followLinks: false)
|
|
.whereType<File>()) {
|
|
final path = f.path
|
|
.substring(prefix)
|
|
.replaceAll(Platform.pathSeparator, '/');
|
|
if (path != 'SOURCE.json') files.add(path);
|
|
}
|
|
return files..sort();
|
|
}
|
|
|
|
String? _option(List<String> args, String name) {
|
|
final i = args.indexOf(name);
|
|
if (i == -1) return null;
|
|
if (i + 1 >= args.length) throw ArgumentError('$name needs a value');
|
|
return args[i + 1];
|
|
}
|