// 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"; // `:#`: 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 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 CheckUrl(const Json& url); // The URL with its password replaced, for a log line or a dump. std::string Redact(std::string_view url); // " on : as " 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 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 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 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; class SqlError : public std::runtime_error { public: explicit SqlError(Fields fields); Fields fields; }; struct Answer { std::optional> columns; std::vector>> rows; std::optional tag; std::optional error; std::vector 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& Parameters() const; private: struct State; std::unique_ptr 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::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 Connect(std::string_view url, std::int32_t attempts = 8, const std::function& sleep = {}); // ---- output ---------------------------------------------------------------------- std::string Cell(const std::optional& value); std::string Table(const std::vector& columns, const std::vector>>& 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; using Refuser = std::function(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>>> 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 BypassesRls(std::string_view url); // (ok, detail): does the database under test answer? std::pair 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; using Prober = std::function(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 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; }; // `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 args); }