pure-zstd/test/corpus.dart

209 lines
6.4 KiB
Dart
Raw Normal View History

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
/// Loading and generating the test corpus.
///
/// The committed half lives in `test/fixtures/corpus`, built by
/// `tool/make_corpus.py`. Every frame there was either written by libzstd or
/// hand-built and then accepted by pyzstd, and the expected plain bytes of a
/// legal frame are what pyzstd decompressed it to, gzipped. `manifest.json`
/// records the exact command behind each one, so a fixture is never a blob
/// nobody can reproduce.
///
/// The half that is too large to commit is generated here instead, from the
/// seeded generator below and the `zstd` CLI.
const String corpusDirectory = 'test/fixtures/corpus';
/// One entry of `manifest.json`.
class CorpusCase {
CorpusCase(Map<String, Object?> json)
: name = json['name']! as String,
command = json['command']! as String,
frameFile = json['frame']! as String,
legal = json['verdict'] == 'legal',
why = json['why'] as String?,
derive = json['derive'] as String?,
size = json['size'] as int?,
expectedSize = json['expectedSize'] as int?,
trailing = json['trailing'] as String?,
_plain = (json['plain'] as List<Object?>? ?? const [])
.cast<String>()
.toList();
/// The case name, which is also the `test()` description.
final String name;
/// The exact command that produced the frame.
final String command;
final String frameFile;
/// True when libzstd decompresses the frame, false when it refuses it.
final bool legal;
/// For a refused case, what is wrong with it and what pyzstd said.
final String? why;
/// For a refused case built by damaging another case's frame, the damage.
final String? derive;
/// Length of the plain bytes, for a legal case.
final int? size;
/// What the caller must pass to `decode`, for a frame declaring no content
/// size of its own.
final int? expectedSize;
/// What follows the first frame, for a buffer libzstd reads as a stream of
/// frames and this decoder reads as one frame and then leftovers.
final String? trailing;
final List<String> _plain;
/// The frame, with any recorded damage applied.
Uint8List get frame {
final bytes = Uint8List.fromList(
File('$corpusDirectory/$frameFile').readAsBytesSync(),
);
return switch (derive) {
null => bytes,
'the last checksum byte flipped' => Uint8List.fromList(
bytes,
)..[bytes.length - 1] ^= 0x01,
'all four checksum bytes zeroed' => Uint8List.fromList(
bytes,
)..fillRange(bytes.length - 4, bytes.length, 0),
'the last two bytes cut' => Uint8List.sublistView(
bytes,
0,
bytes.length - 2,
),
_ => throw StateError('unknown damage: $derive'),
};
}
/// What pyzstd decompressed the frame to. Only for a legal case.
Uint8List get plain {
final out = BytesBuilder(copy: false);
for (final part in _plain) {
out.add(gzip.decode(File('$corpusDirectory/$part').readAsBytesSync()));
}
return out.takeBytes();
}
@override
String toString() => '$name ($command)';
}
/// Every case in `manifest.json`, in the order the generator wrote them.
List<CorpusCase> loadCorpus() {
final text = File('$corpusDirectory/manifest.json').readAsStringSync();
return (jsonDecode(text) as List<Object?>)
.cast<Map<String, Object?>>()
.map(CorpusCase.new)
.toList();
}
/// xorshift64, the same three shifts `tool/make_corpus.py` uses, so an input
/// built here and one built there are the same bytes.
class Xorshift64 {
Xorshift64(this._state);
int _state;
int next() {
var state = _state;
state ^= state << 13;
state ^= state >>> 7;
state ^= state << 17;
_state = state;
return state;
}
Uint8List bytes(int count) {
final out = Uint8List(count);
for (var i = 0; i < count; i++) {
out[i] = (next() >>> 33) & 0xFF;
}
return out;
}
}
/// `count` bytes of pseudo-random noise. Incompressible, so a compressor has
/// to fall back on raw blocks and raw literals.
Uint8List noise(int count, {int seed = 0x9E3779B97F4A7C15}) =>
Xorshift64(seed).bytes(count);
/// A stream built by drawing `count` tokens of `tokenLength` bytes from a
/// pool of `tokens` of them.
///
/// Short tokens from a small pool are what puts tens of thousands of
/// sequences in one block: every token is a match, and at three bytes each
/// there are more of them than the two-byte sequence count can hold.
Uint8List tokenStream({
required int tokens,
required int tokenLength,
required int count,
int seed = 0x2545F4914F6CDD1D,
}) {
final random = Xorshift64(seed);
final pool = random.bytes(tokens * tokenLength);
final out = Uint8List(count * tokenLength);
for (var i = 0; i < count; i++) {
final token = (random.next() >>> 16) % tokens;
out.setRange(
i * tokenLength,
(i + 1) * tokenLength,
pool,
token * tokenLength,
);
}
return out;
}
/// `count` bytes drawn from a lopsided alphabet: `a` eight times as likely as
/// the rare letters, and no matches to speak of.
///
/// This is the shape that makes libzstd build one Huffman table and then
/// reuse it for the blocks that follow, which is the treeless literals form.
Uint8List skewedAlphabet(int count, {int seed = 0x853C49E6748FEA9B}) {
const alphabet = 'aaaaaaaabbbbccdefghijklmnopqrstuvwxyz';
final random = Xorshift64(seed);
final out = Uint8List(count);
for (var i = 0; i < count; i++) {
out[i] = alphabet.codeUnitAt((random.next() >>> 16) % alphabet.length);
}
return out;
}
/// True when the `zstd` CLI is on the path.
bool get hasZstdCli {
try {
return Process.runSync('zstd', const ['--version']).exitCode == 0;
} on ProcessException {
return false;
}
}
/// Compresses [input] with the `zstd` CLI, as `zstd -q -c <args> <file>`.
///
/// A file argument rather than a pipe, so the frame carries a content size.
Uint8List zstdCli(List<String> args, Uint8List input) {
final directory = Directory.systemTemp.createTempSync('pure_zstd_corpus_');
try {
final file = File('${directory.path}/input.bin')..writeAsBytesSync(input);
final run = Process.runSync('zstd', [
'-q',
'-c',
...args,
file.path,
], stdoutEncoding: null);
if (run.exitCode != 0) {
throw StateError('zstd ${args.join(' ')} failed: ${run.stderr}');
}
return Uint8List.fromList(run.stdout as List<int>);
} finally {
directory.deleteSync(recursive: true);
}
}