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:
parent
61acd6a72e
commit
e8a58f893f
10 changed files with 3666 additions and 3 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
23
interfaces/Crafter.Review-Totp.cppm
Normal file
23
interfaces/Crafter.Review-Totp.cppm
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
export module Crafter.Review:Totp;
|
||||
import std;
|
||||
|
||||
// A current TOTP code (RFC 6238, SHA-1, 30 s, 6 digits) for a login fixture:
|
||||
// what the functional lens types into the one-time-code field.
|
||||
export namespace Crafter::Review::Totp {
|
||||
struct Code {
|
||||
std::string code;
|
||||
// The seconds left in its window.
|
||||
std::int32_t left;
|
||||
};
|
||||
// secret: base32, spaces and case as a seeder writes it; std::invalid_argument
|
||||
// with binascii's message for one that is no base32. The clock is read once,
|
||||
// so the code and the seconds left belong to the same window.
|
||||
Code At(std::string_view secret, double now, std::int32_t step = 30, std::int32_t digits = 6);
|
||||
// `crafter-review totp <fixture.json>` or `--secret <base32>`: prints
|
||||
// "123456 (valid 17s)". Exit 1 for a fixture without a secret or a bad
|
||||
// command line.
|
||||
std::int32_t Main(std::span<const std::string_view> args);
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ export import :Dispositions;
|
|||
export import :State;
|
||||
export import :Normalize;
|
||||
export import :PgWire;
|
||||
export import :Totp;
|
||||
export import :Stream;
|
||||
export import :Prompt;
|
||||
export import :Config;
|
||||
|
|
|
|||
Loading…
Reference in a new issue