feat: the crafter-review skeleton, and its semantics for text, JSON and crypto

What the rest of crafter-review stands on.

- project.cpp: a static crafter.review-lib and the crafter-review
  executable; Crafter.Network without QUIC, pinned, or with --local from
  the checkout next to this one; Crafter.LintRules' lite rules, pinned;
  the version from CRAFTER_REVIEW_VERSION, git describe or 0.0.0-dev.
- :Core and :Driver: the version, PromptVersion 1, --version.
- :Json: nlohmann's ordered_json, which keeps key order and unknown keys,
  so the files every driver on a machine shares survive a rewrite; dumps
  in the indented file layout, the spaced one-line form and the compact
  sorted form the structured-output schema needs byte for byte.
- :Crypto: SHA-256/SHA-1/MD5, HMAC, PBKDF2 through libcrypto; base64 and
  base32 written out, because what they refuse, and with which message,
  is pinned.
- :Unicode, Unicode 16.0.0's tables: whitespace, line breaks, decimal
  digits, printable characters, case mappings and case-insensitive folds.
- :Text: string functions on code points, number parsing, the shortest
  float form and half-even rounding, printed values, shell patterns, URL
  splitting and unquoting, POSIX paths, universal newlines and the
  replacing UTF-8 decoder.

Tests: Crypto (published vectors), Text (293 reference vectors).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 05:29:18 +01:00
commit 36852edf63
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
16 changed files with 7845 additions and 0 deletions

93
tests/Crypto/main.cpp Normal file
View file

@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// The primitives the pipeline's keys, its Postgres login and its one-time
// codes rest on, against their published vectors: SHA-256, SHA-1 and MD5
// (FIPS 180, RFC 1321), HMAC (RFC 4231, RFC 2202), PBKDF2-HMAC-SHA256 (the
// RFC 7914 vectors), base32 (RFC 4648). A reuse key that hashed differently
// would silently stop every stored result from being reused; a wrong HMAC
// would lock the lens out of the database.
//
// base64 and base32 also have to fail with their fixed messages, because
// those reach the lens: a SCRAM exchange that cannot decode the server's
// salt, a TOTP secret that is not base32.
import std;
import Crafter.Review;
using namespace Crafter::Review;
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
std::string Unhex(std::string_view hex) {
std::string out;
for (std::size_t i = 0; i + 1 < hex.size(); i += 2) {
out.push_back(static_cast<char>(std::stoi(std::string(hex.substr(i, 2)), nullptr, 16)));
}
return out;
}
// What the call threw as std::invalid_argument, or "" when it did not.
template <typename F>
std::string Refusal(F call) {
try {
call();
} catch (const std::invalid_argument& e) {
return e.what();
}
return "";
}
}
int main() {
Check(Sha256Hex("") == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "sha256 of the empty string");
Check(Sha256Hex("abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", "sha256 of abc");
Check(HexEncode(Sha1("abc")) == "a9993e364706816aba3e25717850c26c9cd0d89d", "sha1 of abc");
Check(Md5Hex("abc") == "900150983cd24fb0d6963f7d28e17f72", "md5 of abc");
const std::string key20(20, '\x0b');
Check(HexEncode(HmacSha256(key20, "Hi There")) == "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7", "HMAC-SHA256, RFC 4231 case 1");
Check(HexEncode(HmacSha256("Jefe", "what do ya want for nothing?")) == "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843", "HMAC-SHA256, RFC 4231 case 2");
Check(HexEncode(HmacSha1(key20, "Hi There")) == "b617318655057264e28bc0b6fb378c8ef146be00", "HMAC-SHA1, RFC 2202 case 1");
Check(HexEncode(Pbkdf2Sha256("password", "salt", 1)) == "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b", "PBKDF2-HMAC-SHA256, 1 iteration");
Check(HexEncode(Pbkdf2Sha256("password", "salt", 4096)) == "c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a", "PBKDF2-HMAC-SHA256, 4096 iterations");
Check(Base64Encode("") == "" && Base64Encode("f") == "Zg==" && Base64Encode("fo") == "Zm8=" && Base64Encode("foo") == "Zm9v" && Base64Encode("foobar") == "Zm9vYmFy", "base64 encoding, RFC 4648 vectors");
Check(Base64Decode("Zm9vYmFy") == "foobar" && Base64Decode("Zg==") == "f" && Base64Decode("Zm8=") == "fo", "base64 decoding");
Check(Base64Decode("Zm9v\nYmFy!") == "foobar", "characters outside the alphabet are skipped, as b64decode skips them");
Check(Base64Decode("YQ==YmM=") == "a", "a complete padding sequence ends the data");
Check(Refusal([] { Base64Decode("Zm9vY"); }) == "Invalid base64-encoded string: number of data characters (5) cannot be 1 more than a multiple of 4", "one data character too many: binascii's message");
Check(Refusal([] { Base64Decode("Zm9vYm"); }) == "Incorrect padding", "two left over without padding: Incorrect padding");
const std::string salt = Base64Decode("W22ZaJ0SNY7soEsUEjb6gQ==");
Check(salt.size() == 16 && Base64Encode(salt) == "W22ZaJ0SNY7soEsUEjb6gQ==", "the RFC 7677 salt round-trips");
Check(Base32Decode("") == "", "base32 of nothing");
Check(Base32Decode("MY======") == "f" && Base32Decode("MZXQ====") == "fo" && Base32Decode("MZXW6===") == "foo", "base32, RFC 4648 vectors (1-3 bytes)");
Check(Base32Decode("MZXW6YQ=") == "foob" && Base32Decode("MZXW6YTB") == "fooba" && Base32Decode("MZXW6YTBOI======") == "foobar", "base32, RFC 4648 vectors (4-6 bytes)");
Check(Base32Decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") == "12345678901234567890", "base32 of the RFC 6238 secret");
Check(Refusal([] { Base32Decode("MZXW6Y=="); }) == "Incorrect padding", "two padding characters are not a legal amount");
Check(Refusal([] { Base32Decode("MZXW6"); }) == "Incorrect padding", "a length that is not a multiple of 8");
Check(Refusal([] { Base32Decode("mzxw6ytb"); }) == "Non-base32 digit found", "lower case is not the alphabet (b32decode without casefold)");
Check(Refusal([] { Base32Decode("1!"); }) == "Non-base32 digit found", "a bad digit is reported before a bad length, as b32decode reports it");
Check(Unhex("00ff") == std::string("\x00\xff", 2) && HexEncode(std::string("\x00\xff", 2)) == "00ff", "hex both ways, lowercase");
Check(RandomBytes(18).size() == 18 && RandomBytes(0).empty(), "random bytes of the length asked for");
Check(RandomBytes(16) != RandomBytes(16), "two draws differ");
const std::string token = TokenHex(16);
Check(token.size() == 32 && std::ranges::all_of(token, [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }), "token_hex(16) is 32 lowercase hex digits");
const std::string uuid = Uuid4();
Check(uuid.size() == 36 && uuid[8] == '-' && uuid[13] == '-' && uuid[18] == '-' && uuid[23] == '-', "a uuid4 is 8-4-4-4-12");
Check(uuid[14] == '4' && std::string_view("89ab").contains(uuid[19]), "version 4, RFC 4122 variant");
Check(ConstantTimeEqual("abc", "abc") && !ConstantTimeEqual("abc", "abd") && !ConstantTimeEqual("abc", "ab"), "constant-time comparison");
return Failures == 0 ? 0 : 1;
}

File diff suppressed because it is too large Load diff

220
tests/Text/main.cpp Normal file
View file

@ -0,0 +1,220 @@
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// The semantics of :Text, checked against reference vectors: every vector in
// fixture/vectors.json is a recorded answer (the file names the Unicode
// version it holds).
//
// The driver's output is decided by these rules: a unit's excerpt is cut by
// Strip() with Unicode whitespace, a key by Length() in code points, a peak
// by Round() half to even, an exclusion by FnMatch()'s character classes, a
// stack key by UrlSplit() and Lower(). The nearest C++ equivalent would agree
// on ASCII and drift exactly where nobody looks.
//
// Also :Json's three dump layouts — DumpFile(), DumpSpaced() and
// DumpCompactSorted() — and its handling of a duplicate key.
import std;
import Crafter.Review;
namespace fs = std::filesystem;
using namespace Crafter::Review;
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
std::vector<std::string> Strings(const Json& array) {
std::vector<std::string> out;
for (const Json& item : array) out.push_back(item.get<std::string>());
return out;
}
std::string Show(const std::vector<std::string>& pieces) {
Json array = Json::array();
for (const std::string& piece : pieces) array.push_back(piece);
return Dyn::ReprValue(array);
}
std::string ShowOptional(const std::optional<std::string>& value) {
return value ? Dyn::Repr(*value) : std::string("None");
}
bool SameOptional(const std::optional<std::string>& value, const Json& expected) {
if (expected.is_null()) return !value.has_value();
return value && *value == expected.get<std::string>();
}
bool IsError(const Json& value) {
return value.is_object() && value.contains("error");
}
}
int main() {
const fs::path path = fs::current_path() / "tests" / "Text" / "fixture" / "vectors.json";
const Json vectors = Json::parse(Dyn::ReadText(path));
for (const Json& v : vectors["strip"]) {
const std::string input = v[0];
Check(Dyn::Strip(input) == v[1].get<std::string>(), std::format("strip({}) = {}", Dyn::Repr(input), Dyn::Repr(Dyn::Strip(input))));
}
for (const Json& v : vectors["split"]) {
const std::string input = v[0];
Check(Dyn::Split(input) == Strings(v[1]), std::format("split({}) = {}", Dyn::Repr(input), Show(Dyn::Split(input))));
}
for (const Json& v : vectors["splitlines"]) {
const std::string input = v[0];
Check(Dyn::SplitLines(input) == Strings(v[1]), std::format("splitlines({}) = {}", Dyn::Repr(input), Show(Dyn::SplitLines(input))));
}
for (const Json& v : vectors["splitlinesKeep"]) {
const std::string input = v[0];
Check(Dyn::SplitLines(input, true) == Strings(v[1]), std::format("splitlines({}, keepends) = {}", Dyn::Repr(input), Show(Dyn::SplitLines(input, true))));
}
for (const Json& v : vectors["lower"]) {
const std::string input = v[0];
Check(Dyn::Lower(input) == v[1].get<std::string>(), std::format("lower({}) = {}", Dyn::Repr(input), Dyn::Repr(Dyn::Lower(input))));
}
for (const Json& v : vectors["upper"]) {
const std::string input = v[0];
Check(Dyn::Upper(input) == v[1].get<std::string>(), std::format("upper({}) = {}", Dyn::Repr(input), Dyn::Repr(Dyn::Upper(input))));
}
for (const Json& v : vectors["floatRepr"]) {
const double value = v[0].get<double>();
Check(Dyn::FloatRepr(value) == v[1].get<std::string>(), std::format("repr({}) = {}", v[1].get<std::string>(), Dyn::FloatRepr(value)));
}
for (const Json& v : vectors["round"]) {
const double value = v[0].get<double>();
const std::int32_t digits = v[1].get<std::int32_t>();
const std::string got = digits == 0 ? std::to_string(Dyn::RoundToInt(value)) : Dyn::FloatRepr(Dyn::Round(value, digits));
Check(got == v[2].get<std::string>(), std::format("Round({}, {}) = {}, expected {}", Dyn::FloatRepr(value), digits, got, v[2].get<std::string>()));
}
for (const Json& v : vectors["int"]) {
const std::string input = v[0];
const std::optional<std::int64_t> got = Dyn::ParseInt(input);
if (IsError(v[1])) {
Check(!got && Dyn::IntError(input) == v[1]["message"].get<std::string>(), std::format("ParseInt({}) fails with the recorded message ({})", Dyn::Repr(input), Dyn::IntError(input)));
} else {
Check(got && *got == v[1].get<std::int64_t>(), std::format("int({}) = {}", Dyn::Repr(input), got ? std::to_string(*got) : std::string("error")));
}
}
for (const Json& v : vectors["float"]) {
const std::string input = v[0];
const std::optional<double> got = Dyn::ParseFloat(input);
if (IsError(v[1])) {
Check(!got && Dyn::FloatError(input) == v[1]["message"].get<std::string>(), std::format("ParseFloat({}) fails with the recorded message", Dyn::Repr(input)));
} else {
Check(got && Dyn::FloatRepr(*got) == v[1].get<std::string>(), std::format("float({}) = {}", Dyn::Repr(input), got ? Dyn::FloatRepr(*got) : std::string("error")));
}
}
for (const Json& v : vectors["repr"]) {
const std::string input = v[0];
Check(Dyn::Repr(input) == v[1].get<std::string>(), std::format("repr of {} = {}", v[1].get<std::string>(), Dyn::Repr(input)));
}
for (const char* key : { "reprList", "reprDict" }) {
for (const Json& v : vectors[key]) {
Check(Dyn::ReprValue(v[0]) == v[1].get<std::string>(), std::format("ReprValue = {}, expected {}", Dyn::ReprValue(v[0]), v[1].get<std::string>()));
}
}
for (const Json& v : vectors["fnmatch"]) {
const std::string name = v[0];
const std::string pattern = v[1];
Check(Dyn::FnMatch(name, pattern) == v[2].get<bool>(), std::format("fnmatch({}, {}) should be {}", Dyn::Repr(name), Dyn::Repr(pattern), v[2].get<bool>()));
}
for (const Json& v : vectors["urlsplit"]) {
const std::string url = v[0];
const Json& expected = v[1];
if (IsError(expected)) {
std::string message = "(no error)";
try {
Dyn::UrlSplit(url);
} catch (const std::invalid_argument& e) {
message = e.what();
}
Check(message == expected["message"].get<std::string>(), std::format("UrlSplit({}) fails with {}, expected {}", Dyn::Repr(url), Dyn::Repr(message), Dyn::Repr(expected["message"].get<std::string>())));
continue;
}
const Dyn::SplitUrl got = Dyn::UrlSplit(url);
Check(got.scheme == expected["scheme"].get<std::string>() && got.netloc == expected["netloc"].get<std::string>()
&& got.path == expected["path"].get<std::string>() && got.query == expected["query"].get<std::string>()
&& got.fragment == expected["fragment"].get<std::string>(),
std::format("urlsplit({}) parts: {} {} {} {} {}", Dyn::Repr(url), got.scheme, got.netloc, got.path, got.query, got.fragment));
Check(SameOptional(got.Hostname(), expected["hostname"]), std::format("urlsplit({}).hostname = {}", Dyn::Repr(url), ShowOptional(got.Hostname())));
Check(SameOptional(got.Username(), expected["username"]), std::format("urlsplit({}).username = {}", Dyn::Repr(url), ShowOptional(got.Username())));
Check(SameOptional(got.Password(), expected["password"]), std::format("urlsplit({}).password = {}", Dyn::Repr(url), ShowOptional(got.Password())));
const Json& port = expected["port"];
if (IsError(port)) {
std::string message = "(no error)";
try {
got.Port();
} catch (const std::invalid_argument& e) {
message = e.what();
}
Check(message == port["message"].get<std::string>(), std::format("urlsplit({}).port fails with {}", Dyn::Repr(url), Dyn::Repr(message)));
} else if (port.is_null()) {
Check(!got.Port(), std::format("urlsplit({}).port is None", Dyn::Repr(url)));
} else {
Check(got.Port() == port.get<std::int32_t>(), std::format("urlsplit({}).port", Dyn::Repr(url)));
}
}
for (const Json& v : vectors["unquote"]) {
const std::string input = v[0];
Check(Dyn::Unquote(input) == v[1].get<std::string>(), std::format("unquote({}) = {}", Dyn::Repr(input), Dyn::Repr(Dyn::Unquote(input))));
}
for (const Json& v : vectors["natural"]) {
const std::string name = v[0];
Dyn::NaturalKey expected;
for (const Json& part : v[1]) {
if (part.is_string()) {
expected.parts.emplace_back(part.get<std::string>());
} else {
expected.parts.emplace_back(part.get<std::int64_t>());
}
}
Check(Dyn::Natural(name) == expected, std::format("natural key of {}", Dyn::Repr(name)));
}
for (const Json& v : vectors["paths"]) {
const std::string op = v[0][0];
const std::string a = v[0][1];
std::string got;
if (op == "normpath") got = Dyn::NormPath(a);
if (op == "abspath") got = Dyn::AbsPath(a);
if (op == "basename") got = Dyn::BaseName(a);
if (op == "dirname") got = Dyn::DirName(a);
if (op == "relpath") got = Dyn::RelPath(a, v[0][2].get<std::string>());
if (op == "join") got = Dyn::JoinPath(a, v[0][2].get<std::string>());
Check(got == v[1].get<std::string>(), std::format("{}({}) = {}, expected {}", op, Dyn::Repr(a), Dyn::Repr(got), Dyn::Repr(v[1].get<std::string>())));
}
for (const Json& v : vectors["jsonIndent1"]) {
Check(DumpFile(v[0]) == v[1].get<std::string>(), std::format("DumpFile:\n{}\nexpected:\n{}", DumpFile(v[0]), v[1].get<std::string>()));
}
for (const Json& v : vectors["jsonDefault"]) {
Check(DumpSpaced(v[0]) == v[1].get<std::string>(), std::format("DumpSpaced: {} / expected {}", DumpSpaced(v[0]), v[1].get<std::string>()));
}
for (const Json& v : vectors["jsonSorted"]) {
Check(DumpCompactSorted(v[0]) == v[1].get<std::string>(), std::format("DumpCompactSorted: {} / expected {}", DumpCompactSorted(v[0]), v[1].get<std::string>()));
}
for (const Json& v : vectors["jsonDuplicate"]) {
const Json parsed = Json::parse(v[0].get<std::string>());
Json pairs = Json::array();
for (auto it = parsed.begin(); it != parsed.end(); ++it) pairs.push_back(Json::array({ it.key(), it.value() }));
Check(pairs == v[1], std::format("a duplicate key keeps its first place and its last value: {}", pairs.dump()));
}
// Read the way open() reads: \r\n and a lone \r are \n.
Check(Dyn::UniversalNewlines("a\r\nb\rc\n") == "a\nb\nc\n", "universal newlines");
Check(Dyn::Length("é😀a") == 3 && Dyn::Slice("é😀abc", 1, 3) == "😀a" && Dyn::Slice("abc", -2) == "bc", "len and slicing count code points");
Check(Dyn::DecodeReplace("a\xC3(b") == "a\xEF\xBF\xBD(b", "a bad byte becomes U+FFFD, the next character survives");
Check(!Dyn::IsUtf8("\xED\xA0\x80") && Dyn::IsUtf8("\xEF\xBF\xBD"), "a surrogate is not UTF-8; a real U+FFFD is");
Check(Dyn::IgnoreCaseLetter(U'K') == 'k' && Dyn::IgnoreCaseLetter(U'ſ') == 's' && Dyn::IgnoreCaseLetter(U'é') == '\0', "re.IGNORECASE folds");
Check(Dyn::ErrnoText(111) == "[Errno 111] Connection refused", "OSError text");
Check(Dyn::Count("abab", "ab") == 2 && Dyn::Count("aaa", "aa") == 1 && Dyn::Count("ab", "") == 3, "count: non-overlapping, empty counts len+1");
Check(Dyn::RSplit("a)b)c", ")", 1) == std::vector<std::string>{ "a)b", "c" }, "rsplit with maxsplit");
std::println("{} vector groups, Unicode {}", vectors.size() - 1, vectors["unicode"].get<std::string>());
return Failures == 0 ? 0 : 1;
}