Crafter.Review/interfaces/Crafter.Review-PgWire.cppm
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

218 lines
11 KiB
C++

// 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);
}