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:
parent
e176dc87b5
commit
36852edf63
16 changed files with 7845 additions and 0 deletions
16
implementations/Crafter.Review-Core.cpp
Normal file
16
implementations/Crafter.Review-Core.cpp
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
module Crafter.Review:Core_impl;
|
||||||
|
import :Core;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
namespace Crafter::Review {
|
||||||
|
std::string_view Version() {
|
||||||
|
return CRAFTER_REVIEW_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string DriverLine() {
|
||||||
|
return std::format("crafter-review {} (prompt v{})", Version(), PromptVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
239
implementations/Crafter.Review-Crypto.cpp
Normal file
239
implementations/Crafter.Review-Crypto.cpp
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
module;
|
||||||
|
#include <openssl/crypto.h>
|
||||||
|
#include <openssl/evp.h>
|
||||||
|
#include <openssl/hmac.h>
|
||||||
|
#include <sys/random.h>
|
||||||
|
#include <cerrno>
|
||||||
|
|
||||||
|
module Crafter.Review:Crypto_impl;
|
||||||
|
import :Crypto;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
namespace Crafter::Review {
|
||||||
|
namespace {
|
||||||
|
std::string Digest(const EVP_MD* method, std::string_view data) {
|
||||||
|
std::array<unsigned char, EVP_MAX_MD_SIZE> out {};
|
||||||
|
std::uint32_t length = 0;
|
||||||
|
if (EVP_Digest(data.data(), data.size(), out.data(), &length, method, nullptr) != 1) {
|
||||||
|
throw std::runtime_error("libcrypto: digest failed");
|
||||||
|
}
|
||||||
|
return std::string(reinterpret_cast<const char*>(out.data()), length);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Hmac(const EVP_MD* method, std::string_view key, std::string_view data) {
|
||||||
|
std::array<unsigned char, EVP_MAX_MD_SIZE> out {};
|
||||||
|
std::uint32_t length = 0;
|
||||||
|
const unsigned char* result = HMAC(method, key.data(), static_cast<int>(key.size()),
|
||||||
|
reinterpret_cast<const unsigned char*>(data.data()), data.size(),
|
||||||
|
out.data(), &length);
|
||||||
|
if (result == nullptr) throw std::runtime_error("libcrypto: HMAC failed");
|
||||||
|
return std::string(reinterpret_cast<const char*>(out.data()), length);
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr std::string_view Base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
constexpr std::string_view Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
|
||||||
|
std::int32_t Base64Value(char c) {
|
||||||
|
const std::size_t at = Base64Alphabet.find(c);
|
||||||
|
return at == std::string_view::npos ? -1 : static_cast<std::int32_t>(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Sha256(std::string_view data) {
|
||||||
|
return Digest(EVP_sha256(), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Sha1(std::string_view data) {
|
||||||
|
return Digest(EVP_sha1(), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Md5(std::string_view data) {
|
||||||
|
return Digest(EVP_md5(), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Sha256Hex(std::string_view data) {
|
||||||
|
return HexEncode(Sha256(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Md5Hex(std::string_view data) {
|
||||||
|
return HexEncode(Md5(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string HmacSha256(std::string_view key, std::string_view data) {
|
||||||
|
return Hmac(EVP_sha256(), key, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string HmacSha1(std::string_view key, std::string_view data) {
|
||||||
|
return Hmac(EVP_sha1(), key, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Pbkdf2Sha256(std::string_view password, std::string_view salt, std::uint32_t iterations) {
|
||||||
|
std::array<unsigned char, 32> out {};
|
||||||
|
if (PKCS5_PBKDF2_HMAC(password.data(), static_cast<std::int32_t>(password.size()),
|
||||||
|
reinterpret_cast<const std::uint8_t*>(salt.data()), static_cast<std::int32_t>(salt.size()),
|
||||||
|
static_cast<std::int32_t>(iterations), EVP_sha256(), static_cast<std::int32_t>(out.size()), out.data()) != 1) {
|
||||||
|
throw std::runtime_error("libcrypto: PBKDF2 failed");
|
||||||
|
}
|
||||||
|
return std::string(reinterpret_cast<const char*>(out.data()), out.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string HexEncode(std::string_view bytes) {
|
||||||
|
constexpr std::string_view Digits = "0123456789abcdef";
|
||||||
|
std::string out;
|
||||||
|
out.reserve(bytes.size() * 2);
|
||||||
|
for (const char c : bytes) {
|
||||||
|
const auto byte = static_cast<std::uint8_t>(c);
|
||||||
|
out.push_back(Digits[byte >> 4]);
|
||||||
|
out.push_back(Digits[byte & 0x0F]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Base64Encode(std::string_view bytes) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve((bytes.size() + 2) / 3 * 4);
|
||||||
|
std::size_t i = 0;
|
||||||
|
for (; i + 3 <= bytes.size(); i += 3) {
|
||||||
|
const std::uint32_t triple = (static_cast<std::uint8_t>(bytes[i]) << 16) | (static_cast<std::uint8_t>(bytes[i + 1]) << 8) | static_cast<std::uint8_t>(bytes[i + 2]);
|
||||||
|
out.push_back(Base64Alphabet[(triple >> 18) & 0x3F]);
|
||||||
|
out.push_back(Base64Alphabet[(triple >> 12) & 0x3F]);
|
||||||
|
out.push_back(Base64Alphabet[(triple >> 6) & 0x3F]);
|
||||||
|
out.push_back(Base64Alphabet[triple & 0x3F]);
|
||||||
|
}
|
||||||
|
const std::size_t left = bytes.size() - i;
|
||||||
|
if (left == 1) {
|
||||||
|
const std::uint32_t one = static_cast<std::uint8_t>(bytes[i]) << 16;
|
||||||
|
out.push_back(Base64Alphabet[(one >> 18) & 0x3F]);
|
||||||
|
out.push_back(Base64Alphabet[(one >> 12) & 0x3F]);
|
||||||
|
out += "==";
|
||||||
|
} else if (left == 2) {
|
||||||
|
const std::uint32_t two = (static_cast<std::uint8_t>(bytes[i]) << 16) | (static_cast<std::uint8_t>(bytes[i + 1]) << 8);
|
||||||
|
out.push_back(Base64Alphabet[(two >> 18) & 0x3F]);
|
||||||
|
out.push_back(Base64Alphabet[(two >> 12) & 0x3F]);
|
||||||
|
out.push_back(Base64Alphabet[(two >> 6) & 0x3F]);
|
||||||
|
out.push_back('=');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Base64Decode(std::string_view text) {
|
||||||
|
// binascii.a2b_base64 in its default, non-strict mode, which is what
|
||||||
|
// b64decode runs: a character outside the alphabet is skipped, a '='
|
||||||
|
// only counts once two characters of a quad are in, and a complete
|
||||||
|
// padding sequence ends the data — whatever follows it is ignored.
|
||||||
|
// What a quad has left at the end decides the error.
|
||||||
|
std::string out;
|
||||||
|
std::int32_t quadPosition = 0;
|
||||||
|
std::uint8_t leftChar = 0;
|
||||||
|
std::int32_t pads = 0;
|
||||||
|
for (const char c : text) {
|
||||||
|
if (c == '=') {
|
||||||
|
if (quadPosition >= 2 && quadPosition + ++pads >= 4) return out;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const std::int32_t value = Base64Value(c);
|
||||||
|
if (value < 0) continue;
|
||||||
|
const auto bits = static_cast<std::uint8_t>(value);
|
||||||
|
pads = 0;
|
||||||
|
switch (quadPosition) {
|
||||||
|
case 0:
|
||||||
|
quadPosition = 1;
|
||||||
|
leftChar = bits;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
quadPosition = 2;
|
||||||
|
out.push_back(static_cast<char>(static_cast<std::uint8_t>(leftChar << 2) | (bits >> 4)));
|
||||||
|
leftChar = bits & 0x0F;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
quadPosition = 3;
|
||||||
|
out.push_back(static_cast<char>(static_cast<std::uint8_t>(leftChar << 4) | (bits >> 2)));
|
||||||
|
leftChar = bits & 0x03;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
quadPosition = 0;
|
||||||
|
out.push_back(static_cast<char>(static_cast<std::uint8_t>(leftChar << 6) | bits));
|
||||||
|
leftChar = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (quadPosition == 1) {
|
||||||
|
throw std::invalid_argument(std::format("Invalid base64-encoded string: number of data characters ({}) cannot be 1 more than a multiple of 4", out.size() / 3 * 4 + 1));
|
||||||
|
}
|
||||||
|
if (quadPosition != 0) throw std::invalid_argument("Incorrect padding");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Base32Decode(std::string_view text) {
|
||||||
|
// base64.b32decode, in its order: the digits are checked while the
|
||||||
|
// quanta are decoded, the length and the padding after.
|
||||||
|
std::string_view data = text;
|
||||||
|
while (!data.empty() && data.back() == '=') data.remove_suffix(1);
|
||||||
|
const std::size_t padChars = text.size() - data.size();
|
||||||
|
std::string out;
|
||||||
|
std::uint64_t accumulator = 0;
|
||||||
|
for (std::size_t start = 0; start < data.size(); start += 8) {
|
||||||
|
accumulator = 0;
|
||||||
|
for (const char c : data.substr(start, 8)) {
|
||||||
|
const std::size_t value = Base32Alphabet.find(c);
|
||||||
|
if (value == std::string_view::npos) throw std::invalid_argument("Non-base32 digit found");
|
||||||
|
accumulator = (accumulator << 5) | value;
|
||||||
|
}
|
||||||
|
for (std::int32_t shift = 32; shift >= 0; shift -= 8) {
|
||||||
|
out.push_back(static_cast<char>((accumulator >> shift) & 0xFF));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text.size() % 8 != 0 || (padChars != 0 && padChars != 1 && padChars != 3 && padChars != 4 && padChars != 6)) {
|
||||||
|
throw std::invalid_argument("Incorrect padding");
|
||||||
|
}
|
||||||
|
if (padChars != 0 && !out.empty()) {
|
||||||
|
// b32decode decoded the partial quantum as if it were 8 digits
|
||||||
|
// short of its padding: redo the last 5 bytes from the
|
||||||
|
// accumulator shifted by the padding, and keep what the data
|
||||||
|
// characters carried.
|
||||||
|
const std::uint64_t shifted = accumulator << (5 * padChars);
|
||||||
|
std::string last;
|
||||||
|
for (std::int32_t shift = 32; shift >= 0; shift -= 8) {
|
||||||
|
last.push_back(static_cast<char>((shifted >> shift) & 0xFF));
|
||||||
|
}
|
||||||
|
const std::size_t leftover = (43 - 5 * padChars) / 8;
|
||||||
|
out.resize(out.size() - 5);
|
||||||
|
out += last.substr(0, leftover);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string RandomBytes(std::size_t count) {
|
||||||
|
std::string out(count, '\0');
|
||||||
|
std::size_t filled = 0;
|
||||||
|
while (filled < count) {
|
||||||
|
const ssize_t got = getrandom(out.data() + filled, count - filled, 0);
|
||||||
|
if (got < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
throw std::runtime_error(std::format("getrandom: {}", std::strerror(errno)));
|
||||||
|
}
|
||||||
|
filled += static_cast<std::size_t>(got);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string TokenHex(std::size_t count) {
|
||||||
|
return HexEncode(RandomBytes(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Uuid4() {
|
||||||
|
std::string bytes = RandomBytes(16);
|
||||||
|
bytes[6] = static_cast<char>((static_cast<std::uint8_t>(bytes[6]) & 0x0F) | 0x40);
|
||||||
|
bytes[8] = static_cast<char>((static_cast<std::uint8_t>(bytes[8]) & 0x3F) | 0x80);
|
||||||
|
const std::string hex = HexEncode(bytes);
|
||||||
|
return std::format("{}-{}-{}-{}-{}", hex.substr(0, 8), hex.substr(8, 4), hex.substr(12, 4), hex.substr(16, 4), hex.substr(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConstantTimeEqual(std::string_view a, std::string_view b) {
|
||||||
|
if (a.size() != b.size()) return false;
|
||||||
|
return CRYPTO_memcmp(a.data(), b.data(), a.size()) == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
implementations/Crafter.Review-Driver.cpp
Normal file
33
implementations/Crafter.Review-Driver.cpp
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
module Crafter.Review:Driver_impl;
|
||||||
|
import :Driver;
|
||||||
|
import :Core;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
namespace Crafter::Review {
|
||||||
|
namespace {
|
||||||
|
constexpr std::string_view Usage =
|
||||||
|
R"(usage: crafter-review [--config FILE] {run,freeze,slots,config} ...
|
||||||
|
crafter-review {seats,inventory,sql,totp,init,doc,browser,stop} ...
|
||||||
|
crafter-review --version
|
||||||
|
|
||||||
|
The pre-push review pipeline: adversarial lenses, one claude process per
|
||||||
|
inventory unit, against a frozen commit range.)";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::int32_t Main(std::span<const std::string_view> args) {
|
||||||
|
const std::span<const std::string_view> rest = args.empty() ? args : args.subspan(1);
|
||||||
|
if (!rest.empty() && rest.front() == "--version") {
|
||||||
|
std::println("{}", DriverLine());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!rest.empty() && (rest.front() == "-h" || rest.front() == "--help")) {
|
||||||
|
std::println("{}", Usage);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::println(std::cerr, "{}", Usage);
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
1061
implementations/Crafter.Review-Text.cpp
Normal file
1061
implementations/Crafter.Review-Text.cpp
Normal file
File diff suppressed because it is too large
Load diff
10
implementations/main.cpp
Normal file
10
implementations/main.cpp
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Crafter.Review;
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
const std::vector<std::string_view> args(argv, argv + argc);
|
||||||
|
return Crafter::Review::Main(args);
|
||||||
|
}
|
||||||
22
interfaces/Crafter.Review-Core.cppm
Normal file
22
interfaces/Crafter.Review-Core.cppm
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
export module Crafter.Review:Core;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
export namespace Crafter::Review {
|
||||||
|
// The release this binary was built from, as project.cpp stamped it:
|
||||||
|
// CRAFTER_REVIEW_VERSION, else `git describe`, else 0.0.0-dev.
|
||||||
|
std::string_view Version();
|
||||||
|
|
||||||
|
// Bumped only when clean results stored under the earlier number must not
|
||||||
|
// be reused: a text change a lens reads that changes what a clean pass
|
||||||
|
// proves. It is one of the inputs of every reuse key, so a bump retires
|
||||||
|
// every stored result at once. A change that leaves what a lens reads the
|
||||||
|
// same — a layout change, a reworded log line — does not bump it.
|
||||||
|
constexpr std::int32_t PromptVersion = 1;
|
||||||
|
|
||||||
|
// `crafter-review <version> (prompt v<n>)`: the dump's `driver` field,
|
||||||
|
// the first log line of a run and `--version`.
|
||||||
|
std::string DriverLine();
|
||||||
|
}
|
||||||
49
interfaces/Crafter.Review-Crypto.cppm
Normal file
49
interfaces/Crafter.Review-Crypto.cppm
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
export module Crafter.Review:Crypto;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
// Byte strings are std::string throughout: a digest, a key or a nonce is a
|
||||||
|
// run of bytes, hex or base64 is text, and both travel through the same APIs
|
||||||
|
// as the text around them. OpenSSL's libcrypto does the hashing; base64 and
|
||||||
|
// base32 are written out here because what they refuse, and with which
|
||||||
|
// message, is pinned.
|
||||||
|
export namespace Crafter::Review {
|
||||||
|
std::string Sha256(std::string_view data);
|
||||||
|
std::string Sha1(std::string_view data);
|
||||||
|
std::string Md5(std::string_view data);
|
||||||
|
// Lowercase hex.
|
||||||
|
std::string Sha256Hex(std::string_view data);
|
||||||
|
std::string Md5Hex(std::string_view data);
|
||||||
|
|
||||||
|
std::string HmacSha256(std::string_view key, std::string_view data);
|
||||||
|
std::string HmacSha1(std::string_view key, std::string_view data);
|
||||||
|
// PBKDF2-HMAC-SHA256 with a 32-byte result: SCRAM-SHA-256's Hi().
|
||||||
|
std::string Pbkdf2Sha256(std::string_view password, std::string_view salt, std::uint32_t iterations);
|
||||||
|
|
||||||
|
// Lowercase, two digits a byte.
|
||||||
|
std::string HexEncode(std::string_view bytes);
|
||||||
|
// Standard alphabet, padded with '=': base64.b64encode.
|
||||||
|
std::string Base64Encode(std::string_view bytes);
|
||||||
|
// base64.b64decode(text) without validate=True: characters outside the
|
||||||
|
// alphabet are dropped first, then bad padding throws
|
||||||
|
// std::invalid_argument, as it raises binascii.Error.
|
||||||
|
std::string Base64Decode(std::string_view text);
|
||||||
|
// base64.b32decode(text): the RFC 4648 alphabet in upper case, the length
|
||||||
|
// a multiple of 8 and the padding one of the five legal amounts; anything
|
||||||
|
// else throws std::invalid_argument with binascii's message.
|
||||||
|
std::string Base32Decode(std::string_view text);
|
||||||
|
|
||||||
|
// From the kernel's CSPRNG (getrandom): secrets.token_bytes.
|
||||||
|
std::string RandomBytes(std::size_t count);
|
||||||
|
// secrets.token_hex(count): 2 * count lowercase hex digits.
|
||||||
|
std::string TokenHex(std::size_t count);
|
||||||
|
// A random (version 4) UUID in its 8-4-4-4-12 form: what `claude
|
||||||
|
// --session-id` takes.
|
||||||
|
std::string Uuid4();
|
||||||
|
|
||||||
|
// hmac.compare_digest: the time taken does not depend on where the two
|
||||||
|
// differ.
|
||||||
|
bool ConstantTimeEqual(std::string_view a, std::string_view b);
|
||||||
|
}
|
||||||
11
interfaces/Crafter.Review-Driver.cppm
Normal file
11
interfaces/Crafter.Review-Driver.cppm
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
export module Crafter.Review:Driver;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
export namespace Crafter::Review {
|
||||||
|
// The command line: `crafter-review <subcommand> ...`, argv[0] first.
|
||||||
|
// Returns the process's exit code.
|
||||||
|
std::int32_t Main(std::span<const std::string_view> args);
|
||||||
|
}
|
||||||
68
interfaces/Crafter.Review-Json.cppm
Normal file
68
interfaces/Crafter.Review-Json.cppm
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
module;
|
||||||
|
#include "../lib/json.hpp"
|
||||||
|
export module Crafter.Review:Json;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
export namespace Crafter::Review {
|
||||||
|
// nlohmann/json, vendored unmodified at lib/json.hpp. ordered_json keeps an
|
||||||
|
// object's keys in the order they were read or inserted, and a duplicate
|
||||||
|
// key keeps its first position and its last value. That is what lets a
|
||||||
|
// driver rewrite the files every driver on the machine shares — the seat
|
||||||
|
// board, the ledger, unit-state.json — with every key in place, the ones
|
||||||
|
// it does not know about included.
|
||||||
|
using Json = nlohmann::ordered_json;
|
||||||
|
|
||||||
|
// Indented by one space per level, one key or item per line, \uXXXX
|
||||||
|
// escapes for everything outside ASCII: every file this pipeline writes. A
|
||||||
|
// string that is not valid UTF-8 has its bad bytes replaced rather than
|
||||||
|
// throwing in the middle of a write.
|
||||||
|
//
|
||||||
|
// A float from 1e15 up to 1e16 prints with an exponent (AGENTS.md).
|
||||||
|
// Readers parse it either way, and none of the files depends on it.
|
||||||
|
inline std::string DumpFile(const Json& value) {
|
||||||
|
return value.dump(1, ' ', true, Json::error_handler_t::replace);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys in code-point order, no whitespace, ASCII only. The
|
||||||
|
// structured-output schema goes to claude in this form and its bytes are
|
||||||
|
// part of the prompt cache, so they must not move by a byte (the goldens
|
||||||
|
// hold them); dedupe keys use it too.
|
||||||
|
inline std::string DumpCompactSorted(const Json& value) {
|
||||||
|
// Converted to nlohmann::json, which keeps objects in a std::map: that
|
||||||
|
// sorts the keys by byte, and byte order is code-point order in UTF-8.
|
||||||
|
const nlohmann::json sorted(value);
|
||||||
|
return sorted.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On one line, with ", " and ": " between items: where a reader matches
|
||||||
|
// on the bytes, such as the first line of an opencode output, `{"type":
|
||||||
|
// "review_gates_meta", …`, which salvage looks for.
|
||||||
|
inline std::string DumpSpaced(const Json& value) {
|
||||||
|
if (value.is_object()) {
|
||||||
|
std::string out = "{";
|
||||||
|
bool first = true;
|
||||||
|
for (const auto& [key, child] : value.items()) {
|
||||||
|
if (!first) out += ", ";
|
||||||
|
first = false;
|
||||||
|
out += Json(key).dump(-1, ' ', true, Json::error_handler_t::replace);
|
||||||
|
out += ": ";
|
||||||
|
out += DumpSpaced(child);
|
||||||
|
}
|
||||||
|
return std::format("{}}}", out);
|
||||||
|
}
|
||||||
|
if (value.is_array()) {
|
||||||
|
std::string out = "[";
|
||||||
|
bool first = true;
|
||||||
|
for (const Json& child : value) {
|
||||||
|
if (!first) out += ", ";
|
||||||
|
first = false;
|
||||||
|
out += DumpSpaced(child);
|
||||||
|
}
|
||||||
|
return std::format("{}]", out);
|
||||||
|
}
|
||||||
|
return value.dump(-1, ' ', true, Json::error_handler_t::replace);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
interfaces/Crafter.Review-Text.cppm
Normal file
155
interfaces/Crafter.Review-Text.cppm
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
export module Crafter.Review:Text;
|
||||||
|
import std;
|
||||||
|
import :Json;
|
||||||
|
|
||||||
|
// The text, number, URL and path semantics every other partition relies on.
|
||||||
|
// Where a result reaches a prompt, a reuse key, a shared file or an exit code
|
||||||
|
// it is decided by these exact rules — Unicode whitespace in Strip(), U+2028
|
||||||
|
// in SplitLines(), code points in Length(), half-even Round(), FnMatch()'s
|
||||||
|
// character classes — never by the nearest C++ equivalent. tests/Text checks
|
||||||
|
// every function against the reference vectors in tests/Text/fixture.
|
||||||
|
//
|
||||||
|
// Text is UTF-8 in std::string. Lengths and indices are in code points.
|
||||||
|
//
|
||||||
|
// Lower() maps Greek capital sigma to σ everywhere: it never writes the
|
||||||
|
// final form ς.
|
||||||
|
export namespace Crafter::Review::Dyn {
|
||||||
|
// ---- UTF-8 -------------------------------------------------------------
|
||||||
|
// Each maximal subpart of an ill-formed sequence becomes one U+FFFD, the
|
||||||
|
// Unicode Standard's recommended practice.
|
||||||
|
std::u32string Decode(std::string_view utf8);
|
||||||
|
std::string Encode(std::u32string_view text);
|
||||||
|
std::string Encode(char32_t c);
|
||||||
|
// Well-formed UTF-8: no overlong forms, no surrogates, nothing above
|
||||||
|
// U+10FFFF.
|
||||||
|
bool IsUtf8(std::string_view bytes);
|
||||||
|
// bytes.decode('utf-8', 'replace').
|
||||||
|
std::string DecodeReplace(std::string_view bytes);
|
||||||
|
// len()
|
||||||
|
std::size_t Length(std::string_view text);
|
||||||
|
// The code points from start to end: a negative index counts from the
|
||||||
|
// end, and both are clipped to the text.
|
||||||
|
std::string Slice(std::string_view text, std::int64_t start, std::optional<std::int64_t> end = std::nullopt);
|
||||||
|
|
||||||
|
// ---- characters ----------------------------------------------------------
|
||||||
|
bool IsSpace(char32_t c);
|
||||||
|
bool IsLineBreak(char32_t c);
|
||||||
|
bool IsDecimal(char32_t c);
|
||||||
|
// The digit's value, -1 for a character that is not a decimal digit.
|
||||||
|
std::int32_t DecimalValue(char32_t c);
|
||||||
|
bool IsPrintable(char32_t c);
|
||||||
|
// The lowercase ASCII letter `c` matches case-insensitively, or '\0':
|
||||||
|
// ASCII letters, and the four code points outside ASCII that fold onto
|
||||||
|
// one (İ ı → i, ſ → s, K → k).
|
||||||
|
char IgnoreCaseLetter(char32_t c);
|
||||||
|
|
||||||
|
// ---- str methods ----------------------------------------------------------
|
||||||
|
// strip()/lstrip()/rstrip(): Unicode whitespace.
|
||||||
|
std::string Strip(std::string_view text);
|
||||||
|
std::string LStrip(std::string_view text);
|
||||||
|
std::string RStrip(std::string_view text);
|
||||||
|
// strip(chars): the code points of `chars`.
|
||||||
|
std::string Strip(std::string_view text, std::string_view chars);
|
||||||
|
std::string LStrip(std::string_view text, std::string_view chars);
|
||||||
|
std::string RStrip(std::string_view text, std::string_view chars);
|
||||||
|
// split(): runs of Unicode whitespace, no empty pieces.
|
||||||
|
std::vector<std::string> Split(std::string_view text);
|
||||||
|
// split(sep, maxsplit) / rsplit(sep, maxsplit): every piece, empty ones
|
||||||
|
// included; a negative maxSplit splits at every separator.
|
||||||
|
std::vector<std::string> Split(std::string_view text, std::string_view separator, std::int64_t maxSplit = -1);
|
||||||
|
std::vector<std::string> RSplit(std::string_view text, std::string_view separator, std::int64_t maxSplit = -1);
|
||||||
|
// splitlines(keepends): \n, \r, \r\n, \v, \f, \x1c-\x1e, \x85, U+2028,
|
||||||
|
// U+2029; no empty piece after a final line break.
|
||||||
|
std::vector<std::string> SplitLines(std::string_view text, bool keepEnds = false);
|
||||||
|
std::string Lower(std::string_view text);
|
||||||
|
std::string Upper(std::string_view text);
|
||||||
|
// count(sub): non-overlapping; an empty `sub` counts len() + 1.
|
||||||
|
std::size_t Count(std::string_view text, std::string_view sub);
|
||||||
|
std::string Replace(std::string_view text, std::string_view from, std::string_view to);
|
||||||
|
std::string Join(std::span<const std::string> parts, std::string_view separator);
|
||||||
|
|
||||||
|
// ---- reading text files ----------------------------------------------------
|
||||||
|
// A text file, read whole: strict UTF-8 — an invalid byte throws
|
||||||
|
// std::runtime_error naming the file and the offset — and universal
|
||||||
|
// newlines.
|
||||||
|
std::string ReadText(const std::filesystem::path& path);
|
||||||
|
// \r\n and a lone \r become \n, as text mode reads them.
|
||||||
|
std::string UniversalNewlines(std::string_view text);
|
||||||
|
|
||||||
|
// ---- numbers ------------------------------------------------------------------
|
||||||
|
// A float as its shortest digits that round-trip; an exponent below 1e-4
|
||||||
|
// and from 1e16 up; ".0" on an integral value; inf, nan.
|
||||||
|
std::string FloatRepr(double value);
|
||||||
|
// value to `digits` decimals: correctly rounded, half to even on exact
|
||||||
|
// ties.
|
||||||
|
double Round(double value, std::int32_t digits);
|
||||||
|
// value to the nearest integer, half to even.
|
||||||
|
std::int64_t RoundToInt(double value);
|
||||||
|
// An integer or a float from text: surrounding Unicode whitespace ignored,
|
||||||
|
// Unicode decimal digits accepted, single underscores between digits
|
||||||
|
// allowed. nullopt for anything else (and, for an integer, beyond 64 bits).
|
||||||
|
std::optional<std::int64_t> ParseInt(std::string_view text);
|
||||||
|
std::optional<double> ParseFloat(std::string_view text);
|
||||||
|
// The ValueError messages for those.
|
||||||
|
std::string IntError(std::string_view text);
|
||||||
|
std::string FloatError(std::string_view text);
|
||||||
|
|
||||||
|
// ---- repr ----------------------------------------------------------------------
|
||||||
|
std::string Repr(std::string_view text);
|
||||||
|
// A JSON value printed for a message: objects as {'key': value}, strings
|
||||||
|
// quoted, true, false and null as True, False and None.
|
||||||
|
std::string ReprValue(const Json& value);
|
||||||
|
|
||||||
|
// ---- shell patterns ---------------------------------------------------------------
|
||||||
|
// A shell-style pattern: case-sensitive, `*` crosses `/`, `[!...]` negates,
|
||||||
|
// an unclosed `[` is literal and a reversed range matches nothing.
|
||||||
|
bool FnMatch(std::string_view name, std::string_view pattern);
|
||||||
|
|
||||||
|
// ---- URLs ---------------------------------------------------------------------------
|
||||||
|
struct SplitUrl {
|
||||||
|
std::string scheme;
|
||||||
|
std::string netloc;
|
||||||
|
std::string path;
|
||||||
|
std::string query;
|
||||||
|
std::string fragment;
|
||||||
|
// .hostname: lowercased, nullopt when there is none.
|
||||||
|
std::optional<std::string> Hostname() const;
|
||||||
|
std::optional<std::string> Username() const;
|
||||||
|
std::optional<std::string> Password() const;
|
||||||
|
// .port: nullopt for none; std::invalid_argument with a ValueError
|
||||||
|
// message for one that is not a number or out of range.
|
||||||
|
std::optional<std::int32_t> Port() const;
|
||||||
|
};
|
||||||
|
// A URL's scheme, netloc, path, query and fragment. Throws
|
||||||
|
// std::invalid_argument with a ValueError message for unbalanced or
|
||||||
|
// invalid brackets.
|
||||||
|
SplitUrl UrlSplit(std::string_view url);
|
||||||
|
// unquote(text): %XX sequences decoded as UTF-8, bad bytes replaced.
|
||||||
|
std::string Unquote(std::string_view text);
|
||||||
|
|
||||||
|
// ---- natural sort ---------------------------------------------------------------------
|
||||||
|
// A name cut into digit runs and the text between them, compared element
|
||||||
|
// by element, a digit run by its value.
|
||||||
|
struct NaturalKey {
|
||||||
|
std::vector<std::variant<std::string, std::int64_t>> parts;
|
||||||
|
std::strong_ordering operator<=>(const NaturalKey& other) const = default;
|
||||||
|
bool operator==(const NaturalKey& other) const = default;
|
||||||
|
};
|
||||||
|
NaturalKey Natural(std::string_view name);
|
||||||
|
|
||||||
|
// ---- POSIX paths -----------------------------------------------------------------------
|
||||||
|
std::string NormPath(std::string_view path);
|
||||||
|
// Against the current directory, lexically: symlinks are not resolved.
|
||||||
|
std::string AbsPath(std::string_view path);
|
||||||
|
std::string RelPath(std::string_view path, std::string_view start = ".");
|
||||||
|
std::string BaseName(std::string_view path);
|
||||||
|
std::string DirName(std::string_view path);
|
||||||
|
std::string JoinPath(std::string_view a, std::string_view b);
|
||||||
|
|
||||||
|
// ---- errno texts -------------------------------------------------------------------------
|
||||||
|
// "[Errno 111] Connection refused": an errno as a message prints it.
|
||||||
|
std::string ErrnoText(std::int32_t code);
|
||||||
|
}
|
||||||
3889
interfaces/Crafter.Review-Unicode.cppm
Normal file
3889
interfaces/Crafter.Review-Unicode.cppm
Normal file
File diff suppressed because it is too large
Load diff
11
interfaces/Crafter.Review.cppm
Normal file
11
interfaces/Crafter.Review.cppm
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
export module Crafter.Review;
|
||||||
|
|
||||||
|
export import :Unicode;
|
||||||
|
export import :Json;
|
||||||
|
export import :Text;
|
||||||
|
export import :Crypto;
|
||||||
|
export import :Core;
|
||||||
|
export import :Driver;
|
||||||
103
project.cpp
Normal file
103
project.cpp
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
// Pinned: the Flatpak builds with the base app's crafter-build, which can be
|
||||||
|
// older than the rules' master. Move the pin together with the base.
|
||||||
|
#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "7d932892017800b4e1f1a15fe7aa49b3daee9ceb"
|
||||||
|
import Crafter.LintRules;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// The umbrella, then the partitions in import order: each after the ones
|
||||||
|
// it imports, which is also the order the module map in AGENTS.md uses.
|
||||||
|
constexpr std::array<std::string_view, 7> Interfaces = {
|
||||||
|
"interfaces/Crafter.Review",
|
||||||
|
"interfaces/Crafter.Review-Unicode",
|
||||||
|
"interfaces/Crafter.Review-Json",
|
||||||
|
"interfaces/Crafter.Review-Text",
|
||||||
|
"interfaces/Crafter.Review-Crypto",
|
||||||
|
"interfaces/Crafter.Review-Core",
|
||||||
|
"interfaces/Crafter.Review-Driver",
|
||||||
|
};
|
||||||
|
constexpr std::array<std::string_view, 4> Implementations = {
|
||||||
|
"implementations/Crafter.Review-Text",
|
||||||
|
"implementations/Crafter.Review-Crypto",
|
||||||
|
"implementations/Crafter.Review-Core",
|
||||||
|
"implementations/Crafter.Review-Driver",
|
||||||
|
};
|
||||||
|
|
||||||
|
// One per directory under tests/, each an ordinary main() that links the
|
||||||
|
// library: exit 0 passes, nonzero fails, 77 skips.
|
||||||
|
constexpr std::array<std::string_view, 2> Tests = {
|
||||||
|
"Crypto",
|
||||||
|
"Text",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The version the binary reports: CI sets it from the tag, a checkout
|
||||||
|
// describes itself, anything else is a development build. A define on
|
||||||
|
// the library, so each version is its own library variant.
|
||||||
|
std::string Version() {
|
||||||
|
if (const char* env = std::getenv("CRAFTER_REVIEW_VERSION"); env && *env) return env;
|
||||||
|
auto described = RunCommandWithTimeout("git describe --tags --always --dirty --exclude=latest 2>/dev/null", std::chrono::seconds(10));
|
||||||
|
std::string version = described.output;
|
||||||
|
while (!version.empty() && (version.back() == '\n' || version.back() == '\r')) version.pop_back();
|
||||||
|
if (described.exitCode != 0 || version.empty()) return "0.0.0-dev";
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||||
|
// Crafter.Network for its TCP and HTTP/1.1 clients and listener, without
|
||||||
|
// QUIC: msquic is most of a fresh build and nothing here speaks HTTP/3.
|
||||||
|
// From forgejo at a pinned commit, or with --local from the checkout next
|
||||||
|
// to this one (which is also how Crafter.Network then finds
|
||||||
|
// Crafter.Thread).
|
||||||
|
std::vector<std::string> depArgs(args.begin(), args.end());
|
||||||
|
depArgs.emplace_back("--no-quic");
|
||||||
|
const bool useLocal = std::ranges::find(args, std::string_view("--local")) != args.end();
|
||||||
|
Configuration* network = useLocal
|
||||||
|
? LocalProject({ .projectFile = "../Crafter.Network/project.cpp", .args = depArgs })
|
||||||
|
: GitProject({
|
||||||
|
.source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Network.git", .commit = "f68321685006be65f6bc0436fd0175611f1e0a55" },
|
||||||
|
.args = depArgs,
|
||||||
|
});
|
||||||
|
|
||||||
|
static auto ReviewLib = std::make_unique<Configuration>();
|
||||||
|
ReviewLib->path = "./";
|
||||||
|
ReviewLib->name = "crafter.review-lib";
|
||||||
|
ReviewLib->outputName = "crafter-review";
|
||||||
|
ApplyStandardArgs(*ReviewLib, args);
|
||||||
|
ReviewLib->type = ConfigurationType::LibraryStatic;
|
||||||
|
ReviewLib->dependencies = { network };
|
||||||
|
// Backslash-escaped quotes: the define travels through the shell before
|
||||||
|
// it reaches clang, which must still see a string literal.
|
||||||
|
ReviewLib->defines.push_back({"CRAFTER_REVIEW_VERSION", std::format("\\\"{}\\\"", Version())});
|
||||||
|
{
|
||||||
|
std::vector<fs::path> interfaces(Interfaces.begin(), Interfaces.end());
|
||||||
|
std::vector<fs::path> implementations(Implementations.begin(), Implementations.end());
|
||||||
|
ReviewLib->GetInterfacesAndImplementations(interfaces, implementations);
|
||||||
|
}
|
||||||
|
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "./";
|
||||||
|
cfg.name = "crafter.review-exe";
|
||||||
|
cfg.outputName = "crafter-review";
|
||||||
|
ApplyStandardArgs(cfg, args);
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
cfg.dependencies = { ReviewLib.get() };
|
||||||
|
{
|
||||||
|
std::array<fs::path, 0> interfaces = {};
|
||||||
|
std::array<fs::path, 1> implementations = { "implementations/main" };
|
||||||
|
cfg.GetInterfacesAndImplementations(interfaces, implementations);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const std::string_view test : Tests) {
|
||||||
|
cfg.AddTest(test).Dependencies({ ReviewLib.get() });
|
||||||
|
}
|
||||||
|
|
||||||
|
LintRules::AddLiteRules(cfg);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
93
tests/Crypto/main.cpp
Normal file
93
tests/Crypto/main.cpp
Normal 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;
|
||||||
|
}
|
||||||
1865
tests/Text/fixture/vectors.json
Normal file
1865
tests/Text/fixture/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
220
tests/Text/main.cpp
Normal file
220
tests/Text/main.cpp
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue