Crafter.Review/interfaces/Crafter.Review-PgWire.cppm

218 lines
11 KiB
Text
Raw Permalink Normal View History

feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Review:PgWire;
import std;
import :Json;
// The database under test: the SQL a lens with the `supabase` environment
// runs, and the driver's probe before that lens's gate. The lens runs
// `crafter-review sql`, which hands the SQL to the driver's broker: the
// database URL, its password included, stays in the driver, and the pass's
// process knows the broker's address and its stack's token alone.
//
// Every call of the helper is ONE transaction that is rolled back at its end,
// so the passes of a run share one local database without seeing each
// other, and the database must be on this machine (127.0.0.1, localhost,
// ::1): the helper never reaches a hosted project.
export namespace Crafter::Review::PgWire {
// Printed once the database answered; the pool looks for it.
constexpr std::string_view Answered = "review-gates sql: connected";
// Printed when it did not (exit 2).
constexpr std::string_view NoDatabase = "review-gates sql: NO DATABASE";
// Retired 2026-10-22: the URL stays in the driver; dropped from every
// pass's environment.
constexpr std::string_view UrlEnv = "REVIEW_GATES_DB_URL";
// `<host>:<port>#<token>`: the driver's broker and the pass's database,
// never a URL.
constexpr std::string_view BrokerEnv = "REVIEW_GATES_DB";
// The broker's last line: the call's exit code. Its NUL is part of it.
constexpr std::string_view ExitMark("\x00review-gates-exit ", 19);
constexpr std::array<std::string_view, 3> Loopback = {"127.0.0.1", "localhost", "::1"};
constexpr std::int32_t StatementTimeoutS = 20;
constexpr std::int32_t LockTimeoutS = 3;
// Rows printed per statement (all are counted).
constexpr std::int32_t MaxRows = 100;
// Characters printed per value.
constexpr std::int32_t MaxCell = 300;
// Characters printed per call; past it, statements still run and print
// their tags only.
constexpr std::int32_t MaxOutput = 40000;
constexpr std::int32_t MaxSql = 200000;
constexpr std::int32_t MaxStatements = 200;
constexpr std::int32_t ExitOk = 0;
constexpr std::int32_t ExitSql = 1;
constexpr std::int32_t ExitNoDb = 2;
constexpr std::int32_t ExitUsage = 64;
struct Url {
std::string scheme;
std::string host;
std::int32_t port = 5432;
std::string user;
std::string password;
std::string database;
};
// The URL's parts with the defaults: port 5432, user postgres, database
// postgres. std::invalid_argument with a ValueError text for a URL
// Dyn::UrlSplit refuses.
Url ParseUrl(std::string_view url);
// nullopt when the URL names a Postgres database on this machine, else the
// problem. Any JSON value: a config may hold anything.
std::optional<std::string> CheckUrl(const Json& url);
// The URL with its password replaced, for a log line or a dump.
std::string Redact(std::string_view url);
// "<database> on <host>:<port> as <user>"
std::string Where(std::string_view url);
// ---- statements ------------------------------------------------------------
// The statements of a script, cut at the semicolons outside quotes, dollar
// quotes, comments, parentheses and the BEGIN ... END body of a
// SQL-standard routine (psql's rules); empty statements dropped. A cut this
// gets wrong is not a hole: every piece goes to the server as one Parse,
// which refuses two commands and fails on half of one.
std::vector<std::string> SplitStatements(std::string_view sql);
// The SQL without its comments, quotes and dollar quotes kept whole.
std::string StripComments(std::string_view sql);
// What a migration's SQL does, blind to comments, whitespace and
// semicolons: a migration file and what the Supabase CLI recorded when it
// applied it compare equal.
std::string Fingerprint(std::string_view sql);
// The first k words, lower case, past whitespace, comments and parentheses.
std::vector<std::string> FirstWords(std::string_view statement, std::size_t k = 3);
// Why the helper will not send this statement, or nullopt. ROLLBACK
// [WORK | TRANSACTION] TO [SAVEPOINT] goes through.
std::optional<std::string> Refused(std::string_view statement);
std::string Headline(std::string_view statement, std::size_t width = 120);
// ---- the connection ----------------------------------------------------------
// The database did not answer: no connection, a refused login, a startup
// failure, a broken exchange. what() is the text the messages print.
class DbError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// An ErrorResponse or NoticeResponse: {field code: text}.
using Fields = std::map<char, std::string>;
class SqlError : public std::runtime_error {
public:
explicit SqlError(Fields fields);
Fields fields;
};
struct Answer {
std::optional<std::vector<std::string>> columns;
std::vector<std::vector<std::optional<std::string>>> rows;
std::optional<std::string> tag;
std::optional<Fields> error;
std::vector<Fields> notices;
};
// A protocol 3.0 session over TCP: the startup message, cleartext, MD5 or
// SCRAM-SHA-256 (without channel binding, as libpq over a plain
// connection), then simple and extended queries. Throws DbError.
class Connection {
public:
explicit Connection(std::string_view url, std::chrono::seconds timeout = std::chrono::seconds(StatementTimeoutS + 15),
std::string_view application = "review-gates-sql");
~Connection();
Connection(const Connection&) = delete;
Connection& operator=(const Connection&) = delete;
Answer Simple(std::string_view query);
// One statement as Parse / Bind / Describe / Execute / Sync: the
// server refuses two commands in one Parse.
Answer Extended(std::string_view statement);
void Close();
// The server's transaction status after the last ReadyForQuery: I, T
// or E.
char Status() const;
const std::map<std::string, std::string>& Parameters() const;
private:
struct State;
std::unique_ptr<State> state;
};
// SCRAM-SHA-256 (RFC 5802 / 7677). user: empty for Postgres, which takes
// the startup message's; user and nonce are set by the RFC 7677 vector.
class Scram {
public:
explicit Scram(std::string password, std::string user = "", std::string nonce = "");
std::string ClientFirst() const;
std::string ClientFinal(std::string_view serverFirst);
bool Verify(std::string_view serverFinal) const;
private:
std::string password;
std::string nonce;
std::string bare;
std::string authMessage;
std::string serverKey;
};
using Connector = std::function<std::unique_ptr<Connection>(std::string_view url)>;
// A connection, retried while the server is busy (too many connections,
// starting up) or briefly unreachable. No sleep after the last attempt.
std::unique_ptr<Connection> Connect(std::string_view url, std::int32_t attempts = 8,
const std::function<void(double)>& sleep = {});
// ---- output ----------------------------------------------------------------------
std::string Cell(const std::optional<std::string>& value);
std::string Table(const std::vector<std::string>& columns, const std::vector<std::vector<std::optional<std::string>>>& rows);
std::string ErrorText(const Fields& fields, std::string_view statement = {});
std::string NoticeText(const Fields& fields);
// ---- one call of the helper ---------------------------------------------------------
using Out = std::function<void(const std::string&)>;
using Refuser = std::function<std::optional<std::string>(std::string_view statement)>;
// Refuse, connect, BEGIN, the statements one by one, ROLLBACK; the exit
// code. connect and refuse replace Connect and Refused, for the tests
// (the backstop is tested with a refusal list that misses a COMMIT).
std::int32_t RunSql(std::string_view url, std::string_view sql, const Out& out, const Connector& connect = {}, const Refuser& refuse = {});
// (columns, rows) of one read-only query, for the driver: the probe and
// the migrations check. Throws DbError or SqlError.
std::pair<std::vector<std::string>, std::vector<std::vector<std::optional<std::string>>>> Query(std::string_view url, std::string_view sql);
// Does the helper's role bypass row-level security? nullopt when it cannot
// be read.
std::optional<bool> BypassesRls(std::string_view url);
// (ok, detail): does the database under test answer?
std::pair<bool, std::string> Probe(std::string_view url);
// ---- the broker: the URL stays in the driver -------------------------------------------
// The database under test as a pass reaches it: the URL, its password
// included, stays in the driver. A pass's process gets the broker's
// address and its stack's token (BrokerEnv); `crafter-review sql` sends
// the SQL there and the broker runs it as RunSql does and streams the
// lines back, the exit code last. One broker per run, on 127.0.0.1, a
// thread per call.
class Broker {
public:
using Runner = std::function<std::int32_t(const std::string& url, const std::string& sql, const Out& out)>;
using Prober = std::function<std::pair<bool, std::string>(const std::string& url)>;
explicit Broker(Runner run = {}, Prober probe = {});
~Broker();
Broker(const Broker&) = delete;
Broker& operator=(const Broker&) = delete;
void Start();
// A token per URL, made once (secrets.token_hex(16)).
std::string Token(const std::string& url);
// The URL a token names, for the tests.
std::optional<std::string> UrlOf(std::string_view token);
// BrokerEnv's value for a pass whose database is url: "" when there
// is none (the helper then says NO DATABASE).
std::string Address(const std::string& url);
void Close();
private:
struct State;
std::shared_ptr<State> state;
};
// `crafter-review sql`'s side of a call: the SQL to the broker at address
// (BrokerEnv's value), its lines printed; the exit code.
std::int32_t CallBroker(std::string_view address, std::string_view sql, bool probeOnly, const Out& out);
// `crafter-review sql [SQL...|--probe]`: the statements as arguments or on
// stdin. Exit 0 every statement ran, 1 one failed or was refused, 2 no
// database, 64 no statement.
std::int32_t SqlMain(std::span<const std::string_view> args);
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
}