Crafter.Review/project.cpp
Jorijn van der Graaf e8a58f893f
feat(sql): the database helper and the TOTP helper, as subcommands
`crafter-review sql` hands the SQL to the driver's broker named in
REVIEW_GATES_DB; the broker runs it as one transaction, rolled back, and
streams the lines back with the exit code last. The client speaks protocol
3.0 over Crafter.Network's TCP client: cleartext, MD5 and SCRAM-SHA-256,
one Parse per statement, the transaction status as the backstop. The
splitter, the refusals, the table layout and every message work on code
points; connect and socket errors read "[Errno 111] Connection refused"
or "timed out". `crafter-review totp` prints "123456 (valid 17s)".

No sleep after the last connect attempt; TOTP reads the clock once; a
broker token that is no string gets the NO DATABASE answer and the exit
mark.

The goldens add the splitter, the comment stripping, the fingerprint, the
refusals, the first words, the headline, the cell, table, error and notice
layouts, the run's refusals and limits and 40 TOTP codes (2774 checks
now). tests/PgWire runs the helper over a Postgres stand-in and compares
its output with the goldens, line for line, plus the broker's protocol.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 07:20:00 +01:00

297 lines
15 KiB
C++

// 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, 23> Interfaces = {
"interfaces/Crafter.Review",
"interfaces/Crafter.Review-Unicode",
"interfaces/Crafter.Review-Json",
"interfaces/Crafter.Review-Text",
"interfaces/Crafter.Review-Crypto",
"interfaces/Crafter.Review-Platform",
"interfaces/Crafter.Review-Process",
"interfaces/Crafter.Review-Core",
"interfaces/Crafter.Review-Schema",
"interfaces/Crafter.Review-Tools",
"interfaces/Crafter.Review-Plan",
"interfaces/Crafter.Review-Artifact",
"interfaces/Crafter.Review-Inventory",
"interfaces/Crafter.Review-Dispositions",
"interfaces/Crafter.Review-State",
"interfaces/Crafter.Review-Normalize",
"interfaces/Crafter.Review-PgWire",
"interfaces/Crafter.Review-Totp",
"interfaces/Crafter.Review-Stream",
"interfaces/Crafter.Review-Prompt",
"interfaces/Crafter.Review-Config",
"interfaces/Crafter.Review-Freeze",
"interfaces/Crafter.Review-Driver",
};
constexpr std::array<std::string_view, 20> Implementations = {
"implementations/Crafter.Review-Text",
"implementations/Crafter.Review-Crypto",
"implementations/Crafter.Review-Platform",
"implementations/Crafter.Review-Process",
"implementations/Crafter.Review-Core",
"implementations/Crafter.Review-Schema",
"implementations/Crafter.Review-Tools",
"implementations/Crafter.Review-Plan",
"implementations/Crafter.Review-Artifact",
"implementations/Crafter.Review-Inventory",
"implementations/Crafter.Review-Dispositions",
"implementations/Crafter.Review-State",
"implementations/Crafter.Review-Normalize",
"implementations/Crafter.Review-PgWire",
"implementations/Crafter.Review-Totp",
"implementations/Crafter.Review-Stream",
"implementations/Crafter.Review-Prompt",
"implementations/Crafter.Review-Config",
"implementations/Crafter.Review-Freeze",
"implementations/Crafter.Review-Driver",
};
// With -march=native clang would fuse a multiply and an add: a cost
// estimate, a usage total or a memory threshold must come out to the
// same last bit on every machine, as the goldens record it.
constexpr std::string_view NoContraction = "-ffp-contract=off";
// 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, 14> Tests = {
"Crypto",
"Text",
"Platform",
"Process",
"Goldens",
"Stream",
"Normalize",
"Merge",
"Dispositions",
"Config",
"CommitsMode",
"CodeKeys",
"Freeze",
"PgWire",
};
// 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;
}
// The local Flatpak: `crafter-build run flatpak` builds it from this
// checkout and `crafter-build run install` installs that build, as
// Crafter.Build's own scripts do. --base=<branch> builds on another branch
// of the Crafter.Build base app than stable: until a Crafter.Build release
// with scripts is on stable, `--base=local` (the branch Crafter.Build's own
// `run install` makes) is what builds this project.
struct FlatpakPaths {
fs::path manifest;
// flatpak-builder's downloads, ccache and per-module cache.
fs::path state;
// The OSTree repo the build is exported to; `install` reads it.
fs::path repo;
fs::path buildDir;
};
// Everything under <project>/build/flatpak, a fixed name rather than a
// BuildDir(): the Flatpak is compiled by the toolchain inside the sandbox,
// not by the host variant. flatpak-builder keys its cache on the
// manifest's absolute path, so a branch other than stable gets a manifest
// copy of its own next to the original (gitignored), and with it a cache
// of its own.
FlatpakPaths FlatpakLayout(const fs::path& projectDir, std::string_view base) {
const fs::path root = projectDir / "build" / "flatpak";
const fs::path packaging = projectDir / "packaging" / "flatpak";
return {
.manifest = base == "stable" ? packaging / "net.catcrafts.CrafterReview.yaml" : packaging / std::format("{}-base.yaml", base),
.state = root / "state",
.repo = root / "repo",
.buildDir = root / "build-dir",
};
}
// Both scripts drive the host's flatpak, which the sandbox of a Flatpak
// crafter-build does not have.
bool RefusedInSandbox(std::string_view script) {
if (!fs::exists("/.flatpak-info")) return false;
std::println(std::cerr, "run {0}: needs the host's flatpak, which the Flatpak build of crafter-build cannot reach. Use a native crafter-build", script);
return true;
}
// flatpak-builder as a host package, else the org.flatpak.Builder app.
// Empty when neither is installed.
std::string FlatpakBuilder() {
if (RunCommandWithTimeout("flatpak-builder --version", std::chrono::seconds(10)).exitCode == 0) return "flatpak-builder";
if (RunCommandWithTimeout("flatpak info org.flatpak.Builder", std::chrono::seconds(10)).exitCode == 0) return "flatpak run org.flatpak.Builder";
return "";
}
// A repo path as the file:// URL of a remote. Percent-encoded, because
// `remote-modify --url=` stores what it is given as it is.
std::string FileUrl(const fs::path& path) {
std::string url = "file://";
for (const char c : path.string()) {
const bool plain = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || std::string_view("/-._~").contains(c);
if (plain) {
url.push_back(c);
} else {
url += std::format("%{:02X}", static_cast<std::uint8_t>(c));
}
}
return url;
}
std::int32_t BuildFlatpak(ScriptContext& ctx) {
if (RefusedInSandbox("flatpak")) return 1;
std::string jobs;
std::string base = "stable";
for (const std::string& arg : ctx.args) {
if (arg.starts_with("--base=") && arg.size() > 7) {
base = arg.substr(7);
continue;
}
const std::string_view count = arg.starts_with("--jobs=") ? std::string_view(arg).substr(7) : std::string_view();
if (count.empty() || !std::ranges::all_of(count, [](char c) { return c >= '0' && c <= '9'; })) {
std::println(std::cerr, "run flatpak: unknown argument '{}' (only --jobs=<N> and --base=<branch> are accepted)", arg);
return 1;
}
jobs = std::format(" --jobs={}", count);
}
if (!std::ranges::all_of(base, [](char c) { return std::isalnum(static_cast<std::uint8_t>(c)) || c == '-' || c == '_' || c == '.'; })) {
std::println(std::cerr, "run flatpak: '{}' is not a branch name", base);
return 1;
}
if (RunCommandWithTimeout(std::format("flatpak info net.catcrafts.CrafterBuild//{}", base), std::chrono::seconds(10)).exitCode != 0) {
std::println(std::cerr, "run flatpak: the base app is not installed: flatpak install catcrafts net.catcrafts.CrafterBuild//{}", base);
return 1;
}
const std::string builder = FlatpakBuilder();
if (builder.empty()) {
std::println(std::cerr, "run flatpak: neither flatpak-builder nor the org.flatpak.Builder app is installed (flatpak install flathub org.flatpak.Builder)");
return 1;
}
const FlatpakPaths paths = FlatpakLayout(ctx.projectDir, base);
if (base != "stable") {
// The manifest with only base-version changed.
std::ifstream in(FlatpakLayout(ctx.projectDir, "stable").manifest);
const std::string manifest((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
constexpr std::string_view Line = "base-version: stable\n";
const std::size_t at = manifest.find(Line);
if (at == std::string::npos) {
std::println(std::cerr, "run flatpak: the manifest has no `{}` line to replace", Line.substr(0, Line.size() - 1));
return 1;
}
std::ofstream(paths.manifest) << manifest.substr(0, at) << std::format("base-version: {}\n", base) << manifest.substr(at + Line.size());
}
const std::string buildDir = ShellQuote(paths.buildDir.string());
if (const std::int32_t code = ctx.Run(std::format("{}{} --user --force-clean --ccache --state-dir={} --repo={} --default-branch=local {} {}", builder, jobs, ShellQuote(paths.state.string()), ShellQuote(paths.repo.string()), buildDir, ShellQuote(paths.manifest.string()))); code != 0) return code;
// The smoke test, through `flatpak build` and not `<builder> --run`:
// the org.flatpak.Builder app reports --run's exit status inverted.
if (const std::int32_t code = ctx.Run(std::format("flatpak build {} crafter-review --version", buildDir)); code != 0) return code;
return ctx.Run(std::format("flatpak build {} sh -c 'ldd /app/bin/crafter-review | grep \"not found\" && exit 1 || exit 0'", buildDir));
}
std::int32_t InstallFlatpak(ScriptContext& ctx) {
if (RefusedInSandbox("install")) return 1;
if (!ctx.args.empty()) {
std::println(std::cerr, "run install: takes no arguments (got '{}')", ctx.args.front());
return 1;
}
const FlatpakPaths paths = FlatpakLayout(ctx.projectDir, "stable");
if (!fs::exists(paths.repo / "config")) {
std::println(std::cerr, "run install: nothing built yet, run: crafter-build run flatpak");
return 1;
}
const std::string url = ShellQuote(FileUrl(paths.repo));
if (const std::int32_t code = ctx.Run(std::format("flatpak remote-add --user --if-not-exists --no-gpg-verify crafter-review-local {}", url)); code != 0) return code;
if (const std::int32_t code = ctx.Run(std::format("flatpak remote-modify --user --url={} crafter-review-local", url)); code != 0) return code;
if (const std::int32_t code = ctx.Run("flatpak install --user --noninteractive --or-update crafter-review-local net.catcrafts.CrafterReview//local"); code != 0) return code;
if (const std::int32_t code = ctx.Run("flatpak make-current --user net.catcrafts.CrafterReview local"); code != 0) return code;
std::println(
R"(
`flatpak run net.catcrafts.CrafterReview` now starts this build.
Back to a published branch, keeping this one installed:
flatpak make-current --user net.catcrafts.CrafterReview <branch>
Remove it (do this before deleting build/, which holds the repo the remote points at):
flatpak uninstall --user net.catcrafts.CrafterReview//local
flatpak remote-delete --user crafter-review-local)");
return 0;
}
}
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 };
ReviewLib->compileFlags.emplace_back(NoContraction);
// 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() };
cfg.compileFlags.emplace_back(NoContraction);
{
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() }).CompileFlag(std::string(NoContraction));
}
LintRules::AddLiteRules(cfg);
cfg.scripts.push_back({"flatpak", BuildFlatpak});
cfg.scripts.push_back({"install", InstallFlatpak});
return cfg;
}