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>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 07:20:00 +01:00
commit e8a58f893f
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
10 changed files with 3666 additions and 3 deletions

View file

@ -64,4 +64,155 @@ export namespace Crafter::Review::PgWire {
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);
}