From e8a58f893f0df81a3ac781611899ee9efc529b67 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 07:20:00 +0100 Subject: [PATCH] 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 --- implementations/Crafter.Review-Driver.cpp | 6 + implementations/Crafter.Review-PgWire.cpp | 1154 +++++++++++++ implementations/Crafter.Review-Totp.cpp | 97 ++ interfaces/Crafter.Review-PgWire.cppm | 151 ++ interfaces/Crafter.Review-Totp.cppm | 23 + interfaces/Crafter.Review.cppm | 1 + project.cpp | 9 +- tests/Goldens/fixture/pipeline.json | 1790 +++++++++++++++++++++ tests/Goldens/main.cpp | 55 + tests/PgWire/main.cpp | 383 +++++ 10 files changed, 3666 insertions(+), 3 deletions(-) create mode 100644 implementations/Crafter.Review-Totp.cpp create mode 100644 interfaces/Crafter.Review-Totp.cppm create mode 100644 tests/PgWire/main.cpp diff --git a/implementations/Crafter.Review-Driver.cpp b/implementations/Crafter.Review-Driver.cpp index 4b1ff5b..f680970 100644 --- a/implementations/Crafter.Review-Driver.cpp +++ b/implementations/Crafter.Review-Driver.cpp @@ -10,6 +10,8 @@ import :Process; import :Inventory; import :Config; import :Freeze; +import :PgWire; +import :Totp; import std; namespace fs = std::filesystem; @@ -247,6 +249,10 @@ inventory unit, against a frozen commit range.)"; const std::string_view command = rest[at]; const std::span after = rest.subspan(at + 1); try { + // The helpers a lens runs: they read neither the home nor the + // config, only their arguments, stdin and REVIEW_GATES_DB. + if (command == "sql") return PgWire::SqlMain(after); + if (command == "totp") return Totp::Main(after); if (command == "inventory" && !configPath) return Inventory::Main(after); if (command == "config") { if (!after.empty()) return UsageError(Usage, "crafter-review", std::format("unrecognized arguments: {}", after.front())); diff --git a/implementations/Crafter.Review-PgWire.cpp b/implementations/Crafter.Review-PgWire.cpp index bb27752..3dcc2e5 100644 --- a/implementations/Crafter.Review-PgWire.cpp +++ b/implementations/Crafter.Review-PgWire.cpp @@ -1,13 +1,245 @@ // SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +module; +#include +#include +#include +#include + module Crafter.Review:PgWire_impl; import :PgWire; import :Json; import :Text; +import :Crypto; +import :Platform; +import Crafter.Network; import std; namespace Crafter::Review::PgWire { + namespace { + // ---- the statement scanner, on code points ---------------------------------- + bool IdentChar(char32_t c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '$' || (c >= 0x80 && c <= 0xFFFF); + } + + bool TagStart(char32_t c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || (c >= 0x80 && c <= 0xFFFF); + } + + bool TagChar(char32_t c) { + return TagStart(c) || (c >= '0' && c <= '9'); + } + + bool At(std::u32string_view s, std::size_t i, std::u32string_view what) { + return s.substr(i).starts_with(what); + } + + // DOLLAR_TAG.match(s, i): $tag$ or $$, else empty. + std::u32string_view DollarTag(std::u32string_view s, std::size_t i) { + if (i >= s.size() || s[i] != '$') return {}; + std::size_t j = i + 1; + if (j < s.size() && TagStart(s[j])) { + ++j; + while (j < s.size() && TagChar(s[j])) ++j; + } + if (j < s.size() && s[j] == '$') return s.substr(i, j + 1 - i); + return {}; + } + + // The end of a `--` comment: Postgres ends it at \n or \r. + std::size_t EndOfLine(std::u32string_view s, std::size_t i) { + while (i < s.size() && s[i] != '\n' && s[i] != '\r') ++i; + return i; + } + + // The end of a `/* */` comment; they nest. + std::size_t EndBlockComment(std::u32string_view s, std::size_t i) { + std::int64_t depth = 0; + while (i < s.size()) { + if (At(s, i, U"/*")) { + ++depth; + i += 2; + } else if (At(s, i, U"*/")) { + --depth; + i += 2; + if (depth == 0) return i; + } else { + ++i; + } + } + return s.size(); + } + + std::size_t EndQuote(std::u32string_view s, std::size_t i, char32_t quote, bool backslash) { + ++i; + while (i < s.size()) { + if (backslash && s[i] == '\\') { + i += 2; + continue; + } + if (s[i] == quote) { + if (i + 1 < s.size() && s[i + 1] == quote) { + i += 2; + continue; + } + return i + 1; + } + ++i; + } + return s.size(); + } + + // Past whitespace and comments from i. + std::size_t Skip(std::u32string_view s, std::size_t i) { + while (i < s.size()) { + if (Dyn::IsSpace(s[i])) { + ++i; + } else if (At(s, i, U"--")) { + i = EndOfLine(s, i); + } else if (At(s, i, U"/*")) { + i = EndBlockComment(s, i); + } else { + break; + } + } + return i; + } + + // An E'...' string: a backslash escapes in it. + bool EscapeString(std::u32string_view s, std::size_t i) { + return i > 0 && (s[i - 1] == 'e' || s[i - 1] == 'E') && (i < 2 || !IdentChar(s[i - 2])); + } + + constexpr std::array RoutineWords = {"create", "or", "replace", "function", "procedure"}; + + // Does the statement start CREATE [OR REPLACE] FUNCTION|PROCEDURE + // (psql's test, on its first four words)? + bool Routine(const std::vector& marks) { + auto mark = [&](std::size_t i) { return i < marks.size() ? std::string_view(marks[i]) : std::string_view(); }; + auto routine = [](std::string_view w) { return w == "function" || w == "procedure"; }; + return mark(0) == "create" && (routine(mark(1)) || (mark(1) == "or" && mark(2) == "replace" && routine(mark(3)))); + } + + // ---- the wire --------------------------------------------------------------------- + // A failure of the login exchange or of an answer: its kind and its + // text, for "the login exchange broke (KeyError: 'r')". + class Broke : public std::runtime_error { + public: + Broke(std::string type, const std::string& message) : std::runtime_error(message), type(std::move(type)) {} + std::string type; + }; + + // A socket error, as its message prints: "[Errno 104] Connection reset + // by peer", or "timed out". + class SocketError : public std::runtime_error { + public: + SocketError(const std::string& text, bool timeout) : std::runtime_error(text), timeout(timeout) {} + bool timeout; + }; + + std::string ConnectErrorText(const std::exception& error) { + if (dynamic_cast(&error)) return "timed out"; + if (const auto* failure = dynamic_cast(&error)) { + if (failure->kind == SocketErrorKind::Resolver) return std::format("[Errno {}] {}", failure->code, gai_strerror(failure->code)); + return Dyn::ErrnoText(failure->code); + } + return error.what(); + } + + std::string Be32(std::uint32_t value) { + std::string out(4, '\0'); + for (std::int32_t i = 0; i < 4; ++i) out[static_cast(i)] = static_cast((value >> (24 - 8 * i)) & 0xFF); + return out; + } + + std::string Be16(std::uint16_t value) { + return {static_cast(value >> 8), static_cast(value & 0xFF)}; + } + + std::string CString(std::string_view text) { + return std::format("{}{}", text, std::string(1, '\0')); + } + + std::string Message(char type, std::string_view payload = {}) { + return std::format("{}{}{}", type, Be32(static_cast(payload.size() + 4)), payload); + } + + std::uint32_t Unpack32(std::string_view bytes, std::size_t at) { + if (at + 4 > bytes.size()) throw Broke("error", "unpack requires a buffer of 4 bytes"); + std::uint32_t value = 0; + for (std::size_t i = 0; i < 4; ++i) value = (value << 8) | static_cast(bytes[at + i]); + return value; + } + + std::uint16_t Unpack16(std::string_view bytes, std::size_t at) { + if (at + 2 > bytes.size()) throw Broke("error", "unpack requires a buffer of 2 bytes"); + return static_cast((static_cast(bytes[at]) << 8) | static_cast(bytes[at + 1])); + } + + // bytes.decode(): strict. + std::string Decode(std::string_view bytes) { + if (const std::optional error = Dyn::Utf8DecodeError(bytes)) throw Broke("UnicodeDecodeError", *error); + return std::string(bytes); + } + + // ErrorResponse / NoticeResponse fields. + Fields FieldsOf(std::string_view payload) { + Fields out; + std::size_t i = 0; + while (i < payload.size() && payload[i] != '\0') { + const std::size_t j = payload.find('\0', i + 1); + if (j == std::string_view::npos) throw Broke("ValueError", "subsection not found"); + out[payload[i]] = Dyn::DecodeReplace(payload.substr(i + 1, j - i - 1)); + i = j + 1; + } + return out; + } + + std::string Get(const Fields& fields, char key, std::string_view fallback = {}) { + const auto found = fields.find(key); + return found == fields.end() ? std::string(fallback) : found->second; + } + + // `f.get(a) or f.get(b) or fallback` + std::string FirstOf(const Fields& fields, char a, char b, std::string_view fallback) { + if (const std::string first = Get(fields, a); !first.empty()) return first; + if (const std::string second = Get(fields, b); !second.empty()) return second; + return std::string(fallback); + } + + // A SCRAM message's attributes: comma-separated, each cut at its first + // '='. + std::map Attributes(std::string_view text) { + std::map out; + std::size_t n = 0; + for (const std::string& item : Dyn::Split(text, ",")) { + const std::size_t equals = item.find('='); + if (equals == std::string::npos) throw Broke("ValueError", std::format("dictionary update sequence element #{} has length 1; 2 is required", n)); + out[item.substr(0, equals)] = item.substr(equals + 1); + ++n; + } + return out; + } + + const std::string& Attribute(const std::map& attributes, const std::string& key) { + const auto found = attributes.find(key); + if (found == attributes.end()) throw Broke("KeyError", Dyn::Repr(key)); + return found->second; + } + + std::string Base64(std::string_view text) { + try { + return Base64Decode(text); + } catch (const std::invalid_argument& error) { + throw Broke("Error", error.what()); + } + } + + constexpr std::array RetrySqlStates = {"53300", "57P03"}; + } + + // ---- the URL --------------------------------------------------------------------------- Url ParseUrl(std::string_view text) { const Dyn::SplitUrl split = Dyn::UrlSplit(text); Url url; @@ -57,4 +289,926 @@ namespace Crafter::Review::PgWire { const Url parsed = ParseUrl(url); return std::format("{} on {}:{} as {}", parsed.database, parsed.host, parsed.port, parsed.user); } + + // ---- statements ---------------------------------------------------------------------- + std::vector SplitStatements(std::string_view text) { + const std::u32string sql = Dyn::Decode(text); + const std::u32string_view s(sql); + const std::size_t n = s.size(); + std::vector pieces; + std::size_t start = 0; + std::size_t i = 0; + std::int64_t depth = 0; + // psql's identifiers[], identifier_count and begin_depth, per + // statement. + std::vector marks; + std::int64_t words = 0; + std::int64_t block = 0; + while (i < n) { + const char32_t c = s[i]; + const bool letter = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || c > 127; + if (letter && !(i > 0 && IdentChar(s[i - 1])) && !At(s, i, U"e'") && !At(s, i, U"E'")) { + std::size_t j = i + 1; + while (j < n && IdentChar(s[j])) ++j; + const std::string word = Dyn::Lower(Dyn::Encode(s.substr(i, j - i))); + if (words < 4) marks.push_back(std::ranges::contains(RoutineWords, word) ? word : std::string()); + ++words; + if (depth == 0 && Routine(marks)) { + if (word == "begin") { + ++block; + } else if (word == "case" && block >= 1) { + ++block; + } else if (word == "end" && block > 0) { + --block; + } + } + i = j; + continue; + } + if (At(s, i, U"--")) { + i = EndOfLine(s, i); + continue; + } + if (At(s, i, U"/*")) { + i = EndBlockComment(s, i); + continue; + } + if (c == '\'') { + i = EndQuote(s, i, '\'', EscapeString(s, i)); + continue; + } + if (c == '"') { + i = EndQuote(s, i, '"', false); + continue; + } + if (c == '$' && !(i > 0 && IdentChar(s[i - 1]))) { + const std::u32string_view tag = DollarTag(s, i); + if (!tag.empty()) { + const std::size_t close = s.find(tag, i + tag.size()); + i = close == std::u32string_view::npos ? n : close + tag.size(); + continue; + } + } + if (c == '(') { + ++depth; + } else if (c == ')') { + depth = std::max(0, depth - 1); + } else if (c == ';' && depth == 0 && block == 0) { + pieces.push_back(s.substr(start, i - start)); + start = i + 1; + marks.clear(); + words = 0; + block = 0; + } + ++i; + } + pieces.push_back(s.substr(start)); + std::vector out; + for (const std::u32string_view piece : pieces) { + if (Skip(piece, 0) < piece.size()) out.push_back(Dyn::Strip(Dyn::Encode(piece))); + } + return out; + } + + std::string StripComments(std::string_view text) { + const std::u32string sql = Dyn::Decode(text); + const std::u32string_view s(sql); + std::u32string out; + std::size_t i = 0; + while (i < s.size()) { + if (At(s, i, U"--")) { + i = EndOfLine(s, i); + continue; + } + if (At(s, i, U"/*")) { + i = EndBlockComment(s, i); + continue; + } + const char32_t c = s[i]; + std::optional end; + if (c == '\'') { + end = EndQuote(s, i, '\'', EscapeString(s, i)); + } else if (c == '"') { + end = EndQuote(s, i, '"', false); + } else if (c == '$' && !(i > 0 && IdentChar(s[i - 1]))) { + const std::u32string_view tag = DollarTag(s, i); + if (!tag.empty()) { + const std::size_t close = s.find(tag, i + tag.size()); + end = close == std::u32string_view::npos ? s.size() : close + tag.size(); + } + } + if (!end) { + out.push_back(c); + ++i; + } else { + out.append(s.substr(i, *end - i)); + i = *end; + } + } + return Dyn::Encode(out); + } + + std::string Fingerprint(std::string_view sql) { + std::u32string kept; + for (const char32_t c : Dyn::Decode(StripComments(sql))) { + if (!Dyn::IsSpace(c) && c != ';') kept.push_back(c); + } + return Sha256Hex(Dyn::Encode(kept)); + } + + std::vector FirstWords(std::string_view text, std::size_t k) { + const std::u32string statement = Dyn::Decode(text); + const std::u32string_view s(statement); + std::vector words; + std::size_t i = 0; + auto wordStart = [](char32_t c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_'; }; + auto wordChar = [&](char32_t c) { return wordStart(c) || (c >= '0' && c <= '9') || c == '$'; }; + while (words.size() < k) { + i = Skip(s, i); + while (i < s.size() && s[i] == '(' && words.empty()) i = Skip(s, i + 1); + if (i >= s.size() || !wordStart(s[i])) break; + std::size_t j = i + 1; + while (j < s.size() && wordChar(s[j])) ++j; + std::string word = Dyn::Encode(s.substr(i, j - i)); + for (char& ch : word) { + if (ch >= 'A' && ch <= 'Z') ch = static_cast(ch - 'A' + 'a'); + } + words.push_back(std::move(word)); + i = j; + } + return words; + } + + std::optional Refused(std::string_view statement) { + const std::vector w = FirstWords(statement, 4); + const std::string k = w.empty() ? std::string() : w[0]; + std::span rest = w.empty() ? std::span() : std::span(w).subspan(1); + if (k == "rollback" && !rest.empty() && (rest[0] == "work" || rest[0] == "transaction")) rest = rest.subspan(1); + const bool to = !rest.empty() && rest[0] == "to"; + if (k == "begin" || k == "start" || k == "commit" || k == "end" || k == "abort" || (k == "rollback" && !to) + || (k == "prepare" && !rest.empty() && rest[0] == "transaction")) { + return std::format("{} - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)", Dyn::Upper(k)); + } + if (k == "copy") return "COPY - it reads or writes files, programs or the client outside the transaction; use SELECT / INSERT"; + return std::nullopt; + } + + std::string Headline(std::string_view statement, std::size_t width) { + const std::string line = Dyn::Join(Dyn::Split(statement), " "); + if (Dyn::Length(line) <= width) return line; + return std::format("{}…", Dyn::Slice(line, 0, static_cast(width) - 1)); + } + + // ---- the connection ---------------------------------------------------------------------- + SqlError::SqlError(Fields f) : std::runtime_error(Get(f, 'M', "error")), fields(std::move(f)) {} + + struct Connection::State { + Url url; + std::unique_ptr socket; + std::string buffer; + std::size_t position = 0; + char status = '\0'; + std::vector notices; + std::map parameters; + + // `count` bytes, or fewer when the server closed first. + std::string ReadExact(std::size_t count) { + while (buffer.size() - position < count) { + if (position > 0 && position == buffer.size()) { + buffer.clear(); + position = 0; + } + std::array chunk {}; + try { + const std::uint32_t got = socket->RecieveSync(static_cast(chunk.size()), chunk.data()); + buffer.append(chunk.data(), got); + } catch (const SocketClosedException&) { + break; + } catch (const SocketTimeoutException&) { + throw SocketError("timed out", true); + } catch (const SocketErrorException& error) { + throw SocketError(Dyn::ErrnoText(error.code), false); + } + } + const std::size_t take = std::min(count, buffer.size() - position); + std::string out = buffer.substr(position, take); + position += take; + return out; + } + + std::pair Read() { + const std::string header = ReadExact(5); + if (header.size() < 5) throw DbError("the server closed the connection"); + const std::uint32_t length = Unpack32(header, 1); + const std::size_t bodyLength = length >= 4 ? length - 4 : 0; + std::string body = ReadExact(bodyLength); + if (body.size() < bodyLength) throw DbError("the server closed the connection"); + return {header[0], std::move(body)}; + } + + void Send(std::string_view data) { + try { + socket->Send(data.data(), static_cast(data.size())); + } catch (const SocketErrorException& error) { + throw DbError(std::format("the connection broke ({})", Dyn::ErrnoText(error.code))); + } catch (const SocketClosedException&) { + throw DbError(std::format("the connection broke ({})", Dyn::ErrnoText(32))); + } + } + + void Authenticate() { + std::optional scram; + while (true) { + std::pair message; + try { + message = Read(); + } catch (const SocketError& error) { + throw DbError(std::format("no answer at login ({})", error.what())); + } + const auto& [type, body] = message; + if (type == 'E') { + const Fields fields = FieldsOf(body); + throw DbError(Dyn::Strip(std::format("login refused: {} {}", Get(fields, 'C'), Get(fields, 'M')))); + } + if (type == 'R') { + const std::uint32_t code = Unpack32(body, 0); + if (code == 0) continue; + if (code == 3) { + Send(Message('p', CString(url.password))); + } else if (code == 5) { + const std::string inner = Md5Hex(url.password + url.user); + const std::string salt = body.substr(4, 4); + Send(Message('p', CString(std::format("md5{}", Md5Hex(inner + salt))))); + } else if (code == 10) { + Json mechanisms = Json::array(); + bool scramOffered = false; + for (const std::string& mechanism : Dyn::Split(std::string_view(body).substr(4), std::string_view("\0", 1))) { + if (mechanism.empty()) continue; + mechanisms.push_back(Decode(mechanism)); + scramOffered = scramOffered || mechanism == "SCRAM-SHA-256"; + } + if (!scramOffered) throw DbError(std::format("unsupported SASL mechanisms {}", Dyn::ReprValue(mechanisms))); + scram.emplace(url.password); + const std::string first = scram->ClientFirst(); + Send(Message('p', CString("SCRAM-SHA-256") + Be32(static_cast(first.size())) + first)); + } else if (code == 11) { + if (!scram) throw Broke("AttributeError", "'NoneType' object has no attribute 'client_final'"); + Send(Message('p', scram->ClientFinal(Decode(std::string_view(body).substr(4))))); + } else if (code == 12) { + if (!scram) throw Broke("AttributeError", "'NoneType' object has no attribute 'verify'"); + if (!scram->Verify(Decode(std::string_view(body).substr(4)))) throw DbError("the server failed SCRAM verification"); + } else { + throw DbError(std::format("unsupported authentication method {}", code)); + } + } else if (type == 'S') { + const std::vector parts = Dyn::Split(body, std::string_view("\0", 1)); + if (parts.size() < 2) throw Broke("ValueError", std::format("not enough values to unpack (expected 2, got {})", parts.size())); + parameters[Decode(parts[0])] = Decode(parts[1]); + } else if (type == 'Z') { + if (body.empty()) throw Broke("IndexError", "index out of range"); + status = body[0]; + return; + } else if (type == 'N') { + notices.push_back(FieldsOf(body)); + } + } + } + + Answer Collect() { + Answer answer; + while (true) { + std::pair message; + try { + message = Read(); + } catch (const SocketError& error) { + if (error.timeout) throw DbError("no answer within the time budget"); + throw DbError(std::format("the connection broke ({})", error.what())); + } + const auto& [type, body] = message; + if (type == 'T') { + const std::uint16_t count = Unpack16(body, 0); + std::size_t i = 2; + std::vector columns; + for (std::uint16_t c = 0; c < count; ++c) { + const std::size_t end = body.find('\0', i); + if (end == std::string::npos) throw Broke("ValueError", "subsection not found"); + columns.push_back(Dyn::DecodeReplace(std::string_view(body).substr(i, end - i))); + i = end + 1 + 18; + } + answer.columns = std::move(columns); + } else if (type == 'D') { + const std::uint16_t count = Unpack16(body, 0); + std::size_t i = 2; + std::vector> row; + for (std::uint16_t c = 0; c < count; ++c) { + const auto length = static_cast(Unpack32(body, i)); + i += 4; + if (length < 0) { + row.emplace_back(std::nullopt); + } else { + row.emplace_back(Dyn::DecodeReplace(std::string_view(body).substr(std::min(i, body.size()), static_cast(length)))); + i += static_cast(length); + } + } + answer.rows.push_back(std::move(row)); + } else if (type == 'C') { + answer.tag = Decode(body.empty() ? std::string_view() : std::string_view(body).substr(0, body.size() - 1)); + } else if (type == 'I') { + if (!answer.tag || answer.tag->empty()) answer.tag = "(empty)"; + } else if (type == 'E') { + answer.error = FieldsOf(body); + } else if (type == 'N') { + answer.notices.push_back(FieldsOf(body)); + } else if (type == 'G') { + // COPY FROM STDIN: refused before sending, kept for a server + // that starts one anyway. + Send(Message('f', CString("the review-gates helper sends no COPY data")) + Message('S')); + } else if (type == 'Z') { + if (body.empty()) throw Broke("IndexError", "index out of range"); + status = body[0]; + return answer; + } + // '1' ParseComplete, '2' BindComplete, 'n' NoData, 's' + // PortalSuspended, 'S' ParameterStatus, 'A', 'K', 'H'/'d'/'c': + // nothing to keep. + } + } + + Answer TakeAnswer() { + try { + return Collect(); + } catch (const Broke& error) { + throw DbError(std::format("a malformed answer ({}: {})", error.type, error.what())); + } + } + }; + + Connection::Connection(std::string_view url, std::chrono::seconds timeout, std::string_view application) : state(std::make_unique()) { + state->url = ParseUrl(url); + const Url& p = state->url; + try { + state->socket = std::make_unique(p.host, static_cast(p.port), std::chrono::seconds(5)); + } catch (const std::exception& error) { + throw DbError(std::format("cannot connect to {}:{} ({})", p.host, p.port, ConnectErrorText(error))); + } + // Every failure from here on is a DbError, whatever broke: its callers + // catch DbError alone. + try { + state->socket->SetReceiveTimeout(timeout); + std::string body = Be32(196608); + for (const auto& [key, value] : std::array, 4>{{ + {"user", p.user}, {"database", p.database}, {"application_name", application}, {"client_encoding", "UTF8"}}}) { + body += CString(key) + CString(value); + } + body.push_back('\0'); + state->Send(Be32(static_cast(body.size() + 4)) + body); + state->Authenticate(); + } catch (const DbError&) { + Close(); + throw; + } catch (const Broke& error) { + Close(); + throw DbError(std::format("the login exchange broke ({}: {})", error.type, error.what())); + } + } + + Connection::~Connection() { + Close(); + } + + Answer Connection::Simple(std::string_view query) { + state->Send(Message('Q', CString(query))); + return state->TakeAnswer(); + } + + Answer Connection::Extended(std::string_view statement) { + state->Send(Message('P', std::format("{}{}{}", std::string(1, '\0'), CString(statement), Be16(0))) + + Message('B', std::format("{}{}{}{}", std::string(2, '\0'), Be16(0), Be16(0), Be16(0))) + + Message('D', std::string("P\0", 2)) + + Message('E', std::format("{}{}", std::string(1, '\0'), Be32(0))) + + Message('S')); + return state->TakeAnswer(); + } + + void Connection::Close() { + if (!state || !state->socket) return; + try { + const std::string terminate = Message('X'); + state->socket->Send(terminate.data(), static_cast(terminate.size())); + } catch (const std::exception&) { + // Closing anyway. + } + state->socket.reset(); + } + + char Connection::Status() const { + return state->status; + } + + const std::map& Connection::Parameters() const { + return state->parameters; + } + + Scram::Scram(std::string password, std::string user, std::string nonce) : password(std::move(password)), nonce(nonce.empty() ? Base64Encode(RandomBytes(18)) : std::move(nonce)) { + bare = std::format("n={},r={}", user, this->nonce); + } + + std::string Scram::ClientFirst() const { + return std::format("n,,{}", bare); + } + + std::string Scram::ClientFinal(std::string_view serverFirst) { + const std::map a = Attributes(serverFirst); + const std::string& serverNonce = Attribute(a, "r"); + if (!serverNonce.starts_with(nonce)) throw DbError("SCRAM: the server nonce does not extend ours"); + const std::string salt = Base64(Attribute(a, "s")); + const std::string& iterationText = Attribute(a, "i"); + const std::optional iterations = Dyn::ParseInt(iterationText); + if (!iterations) throw Broke("ValueError", Dyn::IntError(iterationText)); + if (*iterations < 1) throw Broke("ValueError", "iteration value must be greater than 0."); + const std::string salted = Pbkdf2Sha256(password, salt, static_cast(*iterations)); + const std::string clientKey = HmacSha256(salted, "Client Key"); + const std::string withoutProof = std::format("c={},r={}", Base64Encode("n,,"), serverNonce); + authMessage = std::format("{},{},{}", bare, serverFirst, withoutProof); + const std::string signature = HmacSha256(Sha256(clientKey), authMessage); + serverKey = HmacSha256(salted, "Server Key"); + std::string proof(clientKey.size(), '\0'); + for (std::size_t i = 0; i < clientKey.size(); ++i) proof[i] = static_cast(clientKey[i] ^ signature[i]); + return std::format("{},p={}", withoutProof, Base64Encode(proof)); + } + + bool Scram::Verify(std::string_view serverFinal) const { + const std::map a = Attributes(serverFinal); + const auto v = a.find("v"); + return v != a.end() && ConstantTimeEqual(Base64(v->second), HmacSha256(serverKey, authMessage)); + } + + std::unique_ptr Connect(std::string_view url, std::int32_t attempts, const std::function& sleep) { + double delay = 0.5; + std::optional last; + for (std::int32_t n = 0; n < attempts; ++n) { + try { + return std::make_unique(url); + } catch (const DbError& error) { + last = error; + const std::string_view text = error.what(); + const bool busy = std::ranges::any_of(RetrySqlStates, [&](std::string_view state) { return text.contains(state); }); + if (!busy && (n >= 2 || text.contains("login refused"))) break; + // No sleep after the last attempt: nothing comes after it. + if (n + 1 < attempts) { + if (sleep) { + sleep(delay); + } else { + std::this_thread::sleep_for(std::chrono::duration(delay)); + } + } + delay = std::min(4.0, delay * 2); + } + } + if (last) throw *last; + throw DbError("no attempt made"); + } + + // ---- output ---------------------------------------------------------------------------- + std::string Cell(const std::optional& value) { + if (!value) return "(null)"; + const std::string v = Dyn::Replace(Dyn::Replace(Dyn::Replace(*value, "\r", "\\r"), "\n", "\\n"), "\t", "\\t"); + if (Dyn::Length(v) <= static_cast(MaxCell)) return v; + return std::format("{}…", Dyn::Slice(v, 0, MaxCell - 1)); + } + + std::string Table(const std::vector& columns, const std::vector>>& rows) { + std::vector> shown; + for (std::size_t r = 0; r < rows.size() && r < static_cast(MaxRows); ++r) { + std::vector cells; + for (const std::optional& value : rows[r]) cells.push_back(Cell(value)); + shown.push_back(std::move(cells)); + } + std::vector widths; + for (std::size_t i = 0; i < columns.size(); ++i) { + std::size_t width = Dyn::Length(columns[i]); + for (const std::vector& row : shown) width = std::max(width, Dyn::Length(row.at(i))); + widths.push_back(width); + } + auto line = [&](const std::vector& cells) { + std::vector padded; + for (std::size_t i = 0; i < cells.size(); ++i) padded.push_back(Dyn::LJust(cells[i], widths.at(i))); + return Dyn::RStrip(std::format(" {}", Dyn::Join(padded, " | "))); + }; + std::vector out = {line(columns)}; + std::vector dashes; + for (const std::size_t width : widths) dashes.emplace_back(width, '-'); + out.push_back(std::format("-{}-", Dyn::Join(dashes, "-+-"))); + for (const std::vector& row : shown) out.push_back(line(row)); + const std::size_t more = rows.size() - shown.size(); + out.push_back(std::format("({} row{}{})", rows.size(), rows.size() != 1 ? "s" : "", more > 0 ? std::format(", {} not shown", more) : std::string())); + return Dyn::Join(out, "\n"); + } + + std::string ErrorText(const Fields& f, std::string_view statement) { + std::vector lines = {Dyn::RStrip(std::format("{} {}: {}", FirstOf(f, 'V', 'S', "ERROR"), Get(f, 'C'), Get(f, 'M')))}; + for (const auto& [key, label] : std::array, 3>{{{'D', "DETAIL"}, {'H', "HINT"}, {'W', "WHERE"}}}) { + if (const std::string value = Get(f, key); !value.empty()) lines.push_back(std::format("{}: {}", label, value)); + } + const std::string position = Get(f, 'P'); + if (!position.empty() && !statement.empty() && std::ranges::all_of(position, [](char c) { return c >= '0' && c <= '9'; })) { + const std::int64_t p = Dyn::ParseInt(position).value_or(1) - 1; + const std::string before = Dyn::Slice(statement, std::max(0, p - 30), p); + const std::string after = Dyn::Slice(statement, p, p + 30); + lines.push_back(Dyn::Replace(std::format("AT: …{}⟦{}⟧…", before, after), "\n", " ")); + } + return Dyn::Join(lines, "\n"); + } + + std::string NoticeText(const Fields& f) { + return std::format("{}: {}", FirstOf(f, 'V', 'S', "NOTICE"), Get(f, 'M')); + } + + // ---- one call of the helper ------------------------------------------------------------ + std::int32_t RunSql(std::string_view url, std::string_view sql, const Out& out, const Connector& connect, const Refuser& refuse) { + if (const std::optional problem = CheckUrl(Json(url))) { + out(std::format("{} - {}", NoDatabase, *problem)); + return ExitNoDb; + } + if (Dyn::Length(sql) > static_cast(MaxSql)) { + out(std::format("review-gates sql: the script is {} characters; the limit is {} - nothing ran", Dyn::Length(sql), MaxSql)); + return ExitUsage; + } + const std::vector statements = SplitStatements(sql); + if (statements.empty()) { + out("review-gates sql: no statement given - the SQL goes on stdin, in a quoted heredoc (<<'SQL' ... SQL) - nothing ran"); + return ExitUsage; + } + if (statements.size() > static_cast(MaxStatements)) { + out(std::format("review-gates sql: {} statements; the limit is {} - nothing ran", statements.size(), MaxStatements)); + return ExitUsage; + } + std::vector bad; + for (std::size_t i = 0; i < statements.size(); ++i) { + if (const std::optional why = refuse ? refuse(statements[i]) : Refused(statements[i])) bad.push_back(std::format("statement {}: {}", i + 1, *why)); + } + if (!bad.empty()) { + out(std::format("review-gates sql: refused, nothing ran - {}", Dyn::Join(bad, "; "))); + return ExitSql; + } + std::unique_ptr conn; + try { + conn = connect ? connect(url) : Connect(url); + } catch (const DbError& error) { + out(std::format("{} - {}", NoDatabase, error.what())); + return ExitNoDb; + } + std::size_t size = 0; + std::int32_t code = ExitOk; + std::optional ended; + auto say = [&](const std::string& text, bool always = false) { + if (size < static_cast(MaxOutput) || always) out(text); + size += Dyn::Length(text) + 1; + }; + // Whatever happened after the connection, the transaction is rolled + // back and the call says how it ended. + auto finish = [&] { + try { + if (conn->Status() == 'T' || conn->Status() == 'E') conn->Simple("ROLLBACK"); + } catch (const std::exception&) { + // Nothing more to do: the connection closes either way. + } + conn->Close(); + out(ended ? std::format("-- the transaction ended at statement {}: what ran up to it may have been committed; the rest was not", *ended) + : std::string("-- rolled back: nothing of this call was committed")); + }; + try { + for (const std::string& query : {std::string("BEGIN"), std::format("SET LOCAL statement_timeout = '{}s'", StatementTimeoutS), + std::format("SET LOCAL lock_timeout = '{}s'", LockTimeoutS), std::string("SET LOCAL idle_in_transaction_session_timeout = '60s'")}) { + const Answer answer = conn->Simple(query); + if (answer.error) { + out(std::format("{} - the session could not be set up ({})", NoDatabase, ErrorText(*answer.error))); + finish(); + return ExitNoDb; + } + } + out(std::format("{} to {} - ONE transaction, rolled back at the end of this call (statement_timeout {}s, lock_timeout {}s; at most {} rows printed per statement)", + Answered, Where(url), StatementTimeoutS, LockTimeoutS, MaxRows)); + for (std::size_t i = 1; i <= statements.size(); ++i) { + const std::string& statement = statements[i - 1]; + say(std::format("-- [{}/{}] {}", i, statements.size(), Headline(statement)), true); + const Answer answer = conn->Extended(statement); + for (const Fields& notice : answer.notices) say(NoticeText(notice)); + if (answer.error) { + say(ErrorText(*answer.error, statement), true); + if (i < statements.size()) say(std::format("-- stopped: statement {} failed, so statements {}-{} did not run", i, i + 1, statements.size()), true); + code = ExitSql; + break; + } + if (answer.columns) say(Table(*answer.columns, answer.rows)); + say(answer.tag && !answer.tag->empty() ? *answer.tag : std::string("(no command tag)"), true); + if (conn->Status() != 'T' && conn->Status() != 'E') { + ended = i; + say(std::format("-- STOPPED: the transaction is no longer open after statement {} (server status {}) - nothing more runs. " + "This is a pipeline defect: report it as a pipeline note; what ran up to it may have been committed.", i, conn->Status()), true); + code = ExitSql; + break; + } + } + if (size > static_cast(MaxOutput)) { + out(std::format("-- output cut at {} characters ({} produced): the statements ran, their rows are not all shown", MaxOutput, size)); + } + } catch (const DbError& error) { + out(std::format("-- the database stopped answering mid-call ({}); nothing of this call was committed", error.what())); + finish(); + return ExitSql; + } catch (...) { + finish(); + throw; + } + finish(); + return code; + } + + std::pair, std::vector>>> Query(std::string_view url, std::string_view sql) { + const std::unique_ptr conn = Connect(url, 3); + auto rollback = [&] { + try { + conn->Simple("ROLLBACK"); + } catch (const std::exception&) { + // Closing anyway. + } + conn->Close(); + }; + try { + conn->Simple("BEGIN READ ONLY"); + Answer answer = conn->Extended(sql); + if (answer.error) throw SqlError(*answer.error); + rollback(); + return {answer.columns.value_or(std::vector()), std::move(answer.rows)}; + } catch (...) { + rollback(); + throw; + } + } + + std::optional BypassesRls(std::string_view url) { + try { + const auto [columns, rows] = Query(url, "select coalesce((select rolsuper or rolbypassrls from pg_roles where rolname = current_user), false)"); + return rows.at(0).at(0) == std::optional("t"); + } catch (const DbError&) { + return std::nullopt; + } catch (const SqlError&) { + return std::nullopt; + } + } + + std::pair Probe(std::string_view url) { + if (const std::optional problem = CheckUrl(Json(url))) return {false, *problem}; + std::vector>> rows; + try { + rows = Query(url, "select current_user, current_database(), version()").second; + } catch (const DbError& error) { + return {false, std::format("{}: {}", Redact(url), error.what())}; + } catch (const SqlError& error) { + return {false, std::format("{}: {}", Redact(url), error.what())}; + } + const std::vector>& row = rows.at(0); + const std::vector version = Dyn::Split(row.at(2).value_or("")); + const std::span head(version.data(), std::min(version.size(), 2)); + const Url parsed = ParseUrl(url); + return {true, std::format("{} on {}:{} as {} - {}", row.at(1).value_or("None"), parsed.host, parsed.port, row.at(0).value_or("None"), Dyn::Join(head, " "))}; + } + + // ---- the broker ------------------------------------------------------------------------------ + struct Broker::State { + Runner run; + Prober probe; + std::mutex lock; + std::map tokens; + std::map> urls; + std::unique_ptr listener; + std::thread accepting; + std::uint16_t port = 0; + + void Serve(ClientTCP& client) { + bool dead = false; + auto out = [&](const std::string& text) { + if (dead) return; + const std::string line = std::format("{}\n", Dyn::DecodeReplace(text)); + try { + client.Send(line.data(), static_cast(line.size())); + } catch (const std::exception&) { + // The helper is gone (its Bash call was stopped): the call + // still runs to its ROLLBACK. + dead = true; + } + }; + // rfile.readline(limit): up to a newline, at most `limit` bytes. + constexpr std::size_t Limit = 4 * static_cast(MaxSql) + 4096; + std::string raw; + while (raw.size() < Limit && !raw.ends_with('\n')) { + std::array chunk {}; + try { + const std::uint32_t got = client.RecieveSync(static_cast(std::min(chunk.size(), Limit - raw.size())), chunk.data()); + const std::string_view piece(chunk.data(), got); + const std::size_t newline = piece.find('\n'); + raw.append(newline == std::string_view::npos ? piece : piece.substr(0, newline + 1)); + } catch (const std::exception&) { + break; + } + } + Json request; + if (raw.ends_with('\n')) { + try { + request = ParseLoose(raw); + } catch (const std::exception&) { + request = nullptr; + } + } + std::int32_t code = ExitOk; + const Json& token = request.is_object() ? Dyn::Get(request, "token") : Dyn::EmptyDict(); + std::optional url; + if (request.is_object() && token.is_string()) { + const std::lock_guard guard(lock); + const auto found = urls.find(token.get_ref()); + if (found != urls.end()) url = found->second; + } + if (!request.is_object()) { + out(std::format("review-gates sql: the call did not arrive whole (more than {} characters, or not the helper) - nothing ran", MaxSql)); + code = ExitUsage; + } else if (!url) { + // A token that is no string (a list, say) gets this answer too. + out(std::format("{} - the driver knows no database by this token (a stale or a foreign one)", NoDatabase)); + code = ExitNoDb; + } else if (Dyn::Truthy(Dyn::Get(request, "probe"))) { + const auto [ok, detail] = probe ? probe(*url) : Probe(*url); + out(std::format("{} - {}", ok ? Answered : NoDatabase, detail)); + code = ok ? ExitOk : ExitNoDb; + } else { + const Json& sql = Dyn::Get(request, "sql"); + const std::string text = sql.is_string() ? sql.get() : std::string(); + code = run ? run(*url, text, out) : RunSql(*url, text, out); + } + out(std::format("{}{}", ExitMark, code)); + } + }; + + Broker::Broker(Runner run, Prober probe) : state(std::make_shared()) { + state->run = std::move(run); + state->probe = std::move(probe); + } + + Broker::~Broker() { + Close(); + } + + void Broker::Start() { + const std::weak_ptr weak = state; + state->listener = std::make_unique("127.0.0.1", 0, [weak](ClientTCP* accepted) { + std::unique_ptr client(accepted); + const std::shared_ptr owner = weak.lock(); + if (!owner) return; + // A thread per call, not Crafter.Thread's CPU-sized pool: a call + // waits on the database, not on a CPU. + std::thread([owner, client = std::move(client)] { + try { + owner->Serve(*client); + } catch (const std::exception&) { + // A call that broke ends here; the helper reports the + // missing exit code. + } + }).detach(); + }); + state->port = state->listener->Port(); + ListenerTCP* listener = state->listener.get(); + state->accepting = std::thread([listener] { listener->ListenSyncSync(); }); + } + + std::string Broker::Token(const std::string& url) { + const std::lock_guard guard(state->lock); + const auto found = state->tokens.find(url); + if (found != state->tokens.end()) return found->second; + const std::string token = TokenHex(16); + state->tokens[url] = token; + state->urls[token] = url; + return token; + } + + std::optional Broker::UrlOf(std::string_view token) { + const std::lock_guard guard(state->lock); + const auto found = state->urls.find(token); + if (found == state->urls.end()) return std::nullopt; + return found->second; + } + + std::string Broker::Address(const std::string& url) { + if (url.empty() || !state->listener) return ""; + return std::format("127.0.0.1:{}#{}", state->port, Token(url)); + } + + void Broker::Close() { + if (!state || !state->listener) return; + state->listener->Stop(); + if (state->accepting.joinable()) state->accepting.join(); + state->listener.reset(); + } + + std::int32_t CallBroker(std::string_view address, std::string_view sql, bool probeOnly, const Out& out) { + if (address.empty() || !address.contains('#')) { + out(std::format("{} - this pass has no database (the driver gives one to a pass with the supabase environment)", NoDatabase)); + return ExitNoDb; + } + const std::size_t hash = address.rfind('#'); + const std::string_view where = address.substr(0, hash); + const std::string token(address.substr(hash + 1)); + const std::size_t colon = where.rfind(':'); + const std::string host(colon == std::string_view::npos ? std::string_view() : where.substr(0, colon)); + const std::string portText(colon == std::string_view::npos ? where : where.substr(colon + 1)); + std::unique_ptr socket; + try { + const std::optional port = Dyn::ParseInt(portText); + if (!port) throw std::invalid_argument(Dyn::IntError(portText)); + socket = std::make_unique(host, static_cast(*port), std::chrono::seconds(5)); + } catch (const std::invalid_argument& error) { + out(std::format("{} - the driver's SQL broker does not answer ({})", NoDatabase, error.what())); + return ExitNoDb; + } catch (const std::exception& error) { + out(std::format("{} - the driver's SQL broker does not answer ({})", NoDatabase, ConnectErrorText(error))); + return ExitNoDb; + } + try { + // Each statement's lines come within its timeout; connecting may + // retry. + socket->SetReceiveTimeout(std::chrono::seconds(StatementTimeoutS + 40)); + const Json request = probeOnly ? Json{{"token", token}, {"probe", true}} : Json{{"token", token}, {"sql", sql}}; + const std::string line = std::format("{}\n", DumpSpaced(request)); + socket->Send(line.data(), static_cast(line.size())); + std::string pending; + bool closed = false; + while (!closed) { + std::array chunk {}; + try { + const std::uint32_t got = socket->RecieveSync(static_cast(chunk.size()), chunk.data()); + pending.append(chunk.data(), got); + } catch (const SocketClosedException&) { + closed = true; + } + std::size_t newline = 0; + while ((newline = pending.find('\n')) != std::string::npos || (closed && !pending.empty())) { + const std::size_t end = newline == std::string::npos ? pending.size() : newline + 1; + std::string text = Dyn::RStrip(Dyn::DecodeReplace(std::string_view(pending).substr(0, end)), "\n"); + pending.erase(0, end); + if (text.starts_with(ExitMark)) { + const std::string number = text.substr(ExitMark.size()); + const std::optional code = Dyn::ParseInt(number); + if (!code) throw std::invalid_argument(Dyn::IntError(number)); + return static_cast(*code); + } + out(text); + } + } + } catch (const SocketTimeoutException&) { + out("-- the driver's SQL broker stopped answering mid-call (timed out); nothing of this call was committed"); + return ExitSql; + } catch (const SocketErrorException& error) { + out(std::format("-- the driver's SQL broker stopped answering mid-call ({}); nothing of this call was committed", Dyn::ErrnoText(error.code))); + return ExitSql; + } catch (const std::exception& error) { + out(std::format("-- the driver's SQL broker stopped answering mid-call ({}); nothing of this call was committed", error.what())); + return ExitSql; + } + out("-- the driver's SQL broker closed the call without its exit code; nothing of this call was committed"); + return ExitSql; + } + + std::int32_t SqlMain(std::span args) { + constexpr std::string_view Usage = R"(SQL against the database under test, for a lens with the `supabase` environment - ONE transaction per call, rolled +back at its end. + + crafter-review sql <<'SQL' the statements on stdin (a quoted heredoc: the shell expands nothing in it) + select ...; + SQL + crafter-review sql 'select 1' or as arguments + crafter-review sql --probe does the database answer? + +The SQL goes to the review driver's broker, named with this pass's database in REVIEW_GATES_DB (an address and a +token - the database's URL stays in the driver); the broker runs it on the local stack and sends the lines back. +Exit 0 every statement ran, 1 one failed or was refused, 2 no database, 64 no statement.)"; + const std::string address = Platform::GetEnv(BrokerEnv).value_or(""); + const Out say = [](const std::string& line) { + std::println("{}", line); + std::fflush(stdout); + }; + if (args.size() == 1 && args[0] == "--probe") return CallBroker(address, "", true, say); + if (!args.empty() && (args[0] == "-h" || args[0] == "--help")) { + std::println("{}", Usage); + return 0; + } + std::string sql; + if (!args.empty()) { + std::vector words(args.begin(), args.end()); + sql = Dyn::Join(words, " "); + } else if (!isatty(0)) { + // All of stdin, as text with universal newlines. + const std::string bytes((std::istreambuf_iterator(std::cin)), std::istreambuf_iterator()); + sql = Dyn::UniversalNewlines(Dyn::DecodeReplace(bytes)); + } + return CallBroker(address, sql, false, say); + } } diff --git a/implementations/Crafter.Review-Totp.cpp b/implementations/Crafter.Review-Totp.cpp new file mode 100644 index 0000000..f934432 --- /dev/null +++ b/implementations/Crafter.Review-Totp.cpp @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Crafter.Review:Totp_impl; +import :Totp; +import :Json; +import :Text; +import :Crypto; +import :Platform; +import std; + +namespace Crafter::Review::Totp { + namespace { + constexpr std::string_view Usage = R"(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. + + crafter-review totp the fixture's `totpSecret` (base32), as the consumer's login seeder writes it + crafter-review totp --secret + +Prints the code and the seconds left in its window; the lens regenerates when the window rolled over.)"; + + // Floor division and modulo of floats, for a positive step: fmod, then + // the quotient of what is left, floored. + double FloorDiv(double x, double y) { + const double mod = std::fmod(x, y); + double div = (x - mod) / y; + if (mod != 0.0 && ((y < 0) != (mod < 0))) div -= 1.0; + if (div == 0.0) return std::copysign(0.0, x / y); + double floored = std::floor(div); + if (div - floored > 0.5) floored += 1.0; + return floored; + } + + double Mod(double x, double y) { + double mod = std::fmod(x, y); + if (mod != 0.0 && ((y < 0) != (mod < 0))) mod += y; + return mod; + } + } + + Code At(std::string_view secret, double now, std::int32_t step, std::int32_t digits) { + // secret.strip().replace(' ', '').upper().rstrip('='), then padded. + const std::string s = Dyn::RStrip(Dyn::Upper(Dyn::Replace(Dyn::Strip(secret), " ", "")), "="); + const std::size_t length = Dyn::Length(s); + const std::string padded = s + std::string((8 - length % 8) % 8, '='); + if (!std::ranges::all_of(padded, [](char c) { return static_cast(c) < 0x80; })) { + throw std::invalid_argument("string argument should contain only ASCII characters"); + } + const std::string key = Base32Decode(padded); + const auto counter = static_cast(FloorDiv(now, step)); + std::string message(8, '\0'); + for (std::int32_t i = 0; i < 8; ++i) message[static_cast(i)] = static_cast((counter >> (56 - 8 * i)) & 0xFF); + const std::string h = HmacSha1(key, message); + const std::size_t o = static_cast(h.back()) & 0x0F; + std::uint32_t value = 0; + for (std::size_t i = 0; i < 4; ++i) value = (value << 8) | static_cast(h[o + i]); + std::uint64_t modulus = 1; + for (std::int32_t i = 0; i < digits; ++i) modulus *= 10; + std::string code = std::to_string((value & 0x7FFFFFFF) % modulus); + if (code.size() < static_cast(digits)) code.insert(0, static_cast(digits) - code.size(), '0'); + return {code, step - static_cast(Mod(now, step))}; + } + + std::int32_t Main(std::span args) { + std::string secret; + try { + if (args.size() == 2 && args[0] == "--secret") { + secret = std::string(args[1]); + } else if (args.size() == 1) { + const Json fixture = Dyn::LoadJsonFile(std::string(args[0])); + const Json* found = nullptr; + for (const std::string_view key : {"totpSecret", "totp_secret", "secret"}) { + const Json& value = Dyn::Get(fixture, key); + if (Dyn::Truthy(value)) { + found = &value; + break; + } + } + if (!found) { + std::println(std::cerr, "{}: no totpSecret in the fixture", args[0]); + return 1; + } + if (!found->is_string()) throw Dyn::Error("AttributeError", std::format("'{}' object has no attribute 'strip'", Dyn::TypeName(*found))); + secret = found->get(); + } else { + std::println(std::cerr, "{}", Usage); + return 1; + } + const Code code = At(secret, Platform::Now()); + std::println("{} (valid {}s)", code.code, code.left); + return 0; + } catch (const std::exception& error) { + std::println(std::cerr, "totp: {}", error.what()); + return 1; + } + } +} diff --git a/interfaces/Crafter.Review-PgWire.cppm b/interfaces/Crafter.Review-PgWire.cppm index 98e121b..6d959d3 100644 --- a/interfaces/Crafter.Review-PgWire.cppm +++ b/interfaces/Crafter.Review-PgWire.cppm @@ -64,4 +64,155 @@ export namespace Crafter::Review::PgWire { 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); } diff --git a/interfaces/Crafter.Review-Totp.cppm b/interfaces/Crafter.Review-Totp.cppm new file mode 100644 index 0000000..b736926 --- /dev/null +++ b/interfaces/Crafter.Review-Totp.cppm @@ -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 ` or `--secret `: prints + // "123456 (valid 17s)". Exit 1 for a fixture without a secret or a bad + // command line. + std::int32_t Main(std::span args); +} diff --git a/interfaces/Crafter.Review.cppm b/interfaces/Crafter.Review.cppm index 6116fce..97855ad 100644 --- a/interfaces/Crafter.Review.cppm +++ b/interfaces/Crafter.Review.cppm @@ -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; diff --git a/project.cpp b/project.cpp index 2a55473..e6882f0 100644 --- a/project.cpp +++ b/project.cpp @@ -13,7 +13,7 @@ using namespace Crafter; namespace { // The umbrella, then the partitions in import order: each after the ones // it imports, which is also the order the module map in AGENTS.md uses. - constexpr std::array Interfaces = { + constexpr std::array Interfaces = { "interfaces/Crafter.Review", "interfaces/Crafter.Review-Unicode", "interfaces/Crafter.Review-Json", @@ -31,13 +31,14 @@ namespace { "interfaces/Crafter.Review-State", "interfaces/Crafter.Review-Normalize", "interfaces/Crafter.Review-PgWire", + "interfaces/Crafter.Review-Totp", "interfaces/Crafter.Review-Stream", "interfaces/Crafter.Review-Prompt", "interfaces/Crafter.Review-Config", "interfaces/Crafter.Review-Freeze", "interfaces/Crafter.Review-Driver", }; - constexpr std::array Implementations = { + constexpr std::array Implementations = { "implementations/Crafter.Review-Text", "implementations/Crafter.Review-Crypto", "implementations/Crafter.Review-Platform", @@ -52,6 +53,7 @@ namespace { "implementations/Crafter.Review-State", "implementations/Crafter.Review-Normalize", "implementations/Crafter.Review-PgWire", + "implementations/Crafter.Review-Totp", "implementations/Crafter.Review-Stream", "implementations/Crafter.Review-Prompt", "implementations/Crafter.Review-Config", @@ -66,7 +68,7 @@ namespace { // One per directory under tests/, each an ordinary main() that links the // library: exit 0 passes, nonzero fails, 77 skips. - constexpr std::array Tests = { + constexpr std::array Tests = { "Crypto", "Text", "Platform", @@ -80,6 +82,7 @@ namespace { "CommitsMode", "CodeKeys", "Freeze", + "PgWire", }; // The version the binary reports: CI sets it from the tag, a checkout diff --git a/tests/Goldens/fixture/pipeline.json b/tests/Goldens/fixture/pipeline.json index 91ab279..e3d9ea4 100644 --- a/tests/Goldens/fixture/pipeline.json +++ b/tests/Goldens/fixture/pipeline.json @@ -26289,5 +26289,1795 @@ "db on ::1:6543 as user" ] ] + }, + "pgwire": { + "split": [ + [ + "select ';' ; select 2;", + [ + "select ';'", + "select 2" + ] + ], + [ + "select $x$ a; $y$ ; $x$; commit", + [ + "select $x$ a; $y$ ; $x$", + "commit" + ] + ], + [ + "select 1 as a$b$; commit; select $b$", + [ + "select 1 as a$b$", + "commit", + "select $b$" + ] + ], + [ + "select E'a\\'; commit; '", + [ + "select E'a\\'; commit; '" + ] + ], + [ + "-- c;\nselect 1; /* x; /* y; */ z; */ select 2", + [ + "-- c;\nselect 1", + "/* x; /* y; */ z; */ select 2" + ] + ], + [ + "select 1 --x\r; commit", + [ + "select 1 --x", + "commit" + ] + ], + [ + "create rule r as on insert to t do also (insert into a values (1); update b set c = 1); select 3", + [ + "create rule r as on insert to t do also (insert into a values (1); update b set c = 1)", + "select 3" + ] + ], + [ + "select \"a;b\" from t; select 2", + [ + "select \"a;b\" from t", + "select 2" + ] + ], + [ + " ;; -- only\n ; ", + [] + ], + [ + "create function f() returns int language sql begin atomic select 1; select 2; end; select 3", + [ + "create function f() returns int language sql begin atomic select 1; select 2; end", + "select 3" + ] + ], + [ + "create or replace procedure p() language sql begin atomic case when true then select 1; end case; end; select 4", + [ + "create or replace procedure p() language sql begin atomic case when true then select 1; end case; end; select 4" + ] + ], + [ + "CREATE OR REPLACE FUNCTION g() RETURNS void AS $body$ begin; end $body$ language plpgsql; select 5", + [ + "CREATE OR REPLACE FUNCTION g() RETURNS void AS $body$ begin; end $body$ language plpgsql", + "select 5" + ] + ], + [ + "select (1; 2); select 3", + [ + "select (1; 2)", + "select 3" + ] + ], + [ + "select ))) ; select 4", + [ + "select )))", + "select 4" + ] + ], + [ + "select 'it''s; ok'; select 6", + [ + "select 'it''s; ok'", + "select 6" + ] + ], + [ + "select $tag", + [ + "select $tag" + ] + ], + [ + "select $1; select $2", + [ + "select $1", + "select $2" + ] + ], + [ + "select \u00e9moji; select \ud83d\ude00x; \ud83d\ude00begin; select 7", + [ + "select \u00e9moji", + "select \ud83d\ude00x", + "\ud83d\ude00begin", + "select 7" + ] + ], + [ + "select e'\\''; select 8", + [ + "select e'\\''", + "select 8" + ] + ], + [ + "select \"unterminated; select 9", + [ + "select \"unterminated; select 9" + ] + ], + [ + "/* unterminated; select 10", + [] + ], + [ + "select 1;\u3000;select 2", + [ + "select 1", + "select 2" + ] + ], + [ + "begin; end", + [ + "begin", + "end" + ] + ], + [ + "", + [] + ] + ], + "stripComments": [ + [ + "select ';' ; select 2;", + "select ';' ; select 2;" + ], + [ + "select $x$ a; $y$ ; $x$; commit", + "select $x$ a; $y$ ; $x$; commit" + ], + [ + "select 1 as a$b$; commit; select $b$", + "select 1 as a$b$; commit; select $b$" + ], + [ + "select E'a\\'; commit; '", + "select E'a\\'; commit; '" + ], + [ + "-- c;\nselect 1; /* x; /* y; */ z; */ select 2", + "\nselect 1; select 2" + ], + [ + "select 1 --x\r; commit", + "select 1 \r; commit" + ], + [ + "create rule r as on insert to t do also (insert into a values (1); update b set c = 1); select 3", + "create rule r as on insert to t do also (insert into a values (1); update b set c = 1); select 3" + ], + [ + "select \"a;b\" from t; select 2", + "select \"a;b\" from t; select 2" + ], + [ + " ;; -- only\n ; ", + " ;; \n ; " + ], + [ + "create function f() returns int language sql begin atomic select 1; select 2; end; select 3", + "create function f() returns int language sql begin atomic select 1; select 2; end; select 3" + ], + [ + "create or replace procedure p() language sql begin atomic case when true then select 1; end case; end; select 4", + "create or replace procedure p() language sql begin atomic case when true then select 1; end case; end; select 4" + ], + [ + "CREATE OR REPLACE FUNCTION g() RETURNS void AS $body$ begin; end $body$ language plpgsql; select 5", + "CREATE OR REPLACE FUNCTION g() RETURNS void AS $body$ begin; end $body$ language plpgsql; select 5" + ], + [ + "select (1; 2); select 3", + "select (1; 2); select 3" + ], + [ + "select ))) ; select 4", + "select ))) ; select 4" + ], + [ + "select 'it''s; ok'; select 6", + "select 'it''s; ok'; select 6" + ], + [ + "select $tag", + "select $tag" + ], + [ + "select $1; select $2", + "select $1; select $2" + ], + [ + "select \u00e9moji; select \ud83d\ude00x; \ud83d\ude00begin; select 7", + "select \u00e9moji; select \ud83d\ude00x; \ud83d\ude00begin; select 7" + ], + [ + "select e'\\''; select 8", + "select e'\\''; select 8" + ], + [ + "select \"unterminated; select 9", + "select \"unterminated; select 9" + ], + [ + "/* unterminated; select 10", + "" + ], + [ + "select 1;\u3000;select 2", + "select 1;\u3000;select 2" + ], + [ + "begin; end", + "begin; end" + ], + [ + "", + "" + ] + ], + "fingerprint": [ + [ + "select ';' ; select 2;", + "f7bed0145a2784e6d145ac0afb68d845212aa85c3af7b520fdd725b68e847ad0" + ], + [ + "select $x$ a; $y$ ; $x$; commit", + "aa17b194761193203faa8479d09d9c916d74425336128bd770cfb73977092695" + ], + [ + "select 1 as a$b$; commit; select $b$", + "0b366bf03711e37dea6a4955099ec02e6f1632c3d6fc623af2273e3869fd9ffc" + ], + [ + "select E'a\\'; commit; '", + "0589c4a3fd5fa7cb4372ae2946e97c0b31064cad9b6e4be8d1f4a94019370c3c" + ], + [ + "-- c;\nselect 1; /* x; /* y; */ z; */ select 2", + "98a548b02072acdab56a77a8eb67ca4248662a20f8cef690a427df6b2d1db082" + ], + [ + "select 1 --x\r; commit", + "5881724952e365fff42f6f3ee01e212d3626259fbea5a6976bf861bf78ef4d5c" + ], + [ + "create rule r as on insert to t do also (insert into a values (1); update b set c = 1); select 3", + "09828fd09a5ae43e697fbf815f5f0e5a2feebffa54a178661c448be67e9e2c29" + ], + [ + "select \"a;b\" from t; select 2", + "6852a88a923381d215613d8e0760eeb136f3feeb231a9100b3297dc03dbddc73" + ], + [ + " ;; -- only\n ; ", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ], + [ + "create function f() returns int language sql begin atomic select 1; select 2; end; select 3", + "9ec4238a0def2b33f942127004826439ca89267822dab045b444d6421b952479" + ], + [ + "create or replace procedure p() language sql begin atomic case when true then select 1; end case; end; select 4", + "eefec7667fec38d58b9fd89e9e8625a93e047ed97b1e007450bfb3428307fc6e" + ], + [ + "CREATE OR REPLACE FUNCTION g() RETURNS void AS $body$ begin; end $body$ language plpgsql; select 5", + "28f135184d6a45550205f861ec12e0cf0da1318d918565c890782c6ca303c4c9" + ], + [ + "select (1; 2); select 3", + "f9837430cafe8b09e4d799fb99f1a9cc305050c26648a3dc9361b484c9cf0d4f" + ], + [ + "select ))) ; select 4", + "0a9f655342fb98700196af2639fe0c1f9e1a367a5f64fb5d87e93360002561d3" + ], + [ + "select 'it''s; ok'; select 6", + "7c520173cb965d1465353d9f61d2e910544e7137bbea790bd508d6fdf4ff6d1c" + ], + [ + "select $tag", + "f73dedd7898481d51e0b12f9050e0dc1dc15d7b7ba394c8535693708936d52f9" + ], + [ + "select $1; select $2", + "95cdf5459c9f6e91ff7cf5baa50997936607b9e31b976c7d20fce3e6eaeea2bc" + ], + [ + "select \u00e9moji; select \ud83d\ude00x; \ud83d\ude00begin; select 7", + "2bdd060fdb229c918c586d99378a75d39862d773eb6320a03e59d5124e2251c8" + ], + [ + "select e'\\''; select 8", + "00be3c09e89529705958fea9f3bb3ec758343c61ad9b79a8283ecf696bff8a2e" + ], + [ + "select \"unterminated; select 9", + "9b94d5eff71b7c7750d9fc065139d4f65897c3682d67484feb2092b34b409b31" + ], + [ + "/* unterminated; select 10", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ], + [ + "select 1;\u3000;select 2", + "98a548b02072acdab56a77a8eb67ca4248662a20f8cef690a427df6b2d1db082" + ], + [ + "begin; end", + "7e4683a7815cf1c48da758328aa2de9b8c3b269699806213d2236ad9dfde5984" + ], + [ + "", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ] + ], + "refused": [ + [ + "commit", + "COMMIT - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "COMMIT AND CHAIN", + "COMMIT - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "end", + "END - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "END WORK", + "END - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "rollback", + "ROLLBACK - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "rollback work", + "ROLLBACK - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "abort", + "ABORT - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "begin", + "BEGIN - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "start transaction", + "START - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "prepare transaction 'x'", + "PREPARE - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "commit prepared 'x'", + "COMMIT - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "rollback prepared 'x'", + "ROLLBACK - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "/* c */ commit", + "COMMIT - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "-- c\ncommit", + "COMMIT - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "--c\rcommit", + "COMMIT - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "(commit)", + "COMMIT - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + [ + "copy t to stdout", + "COPY - it reads or writes files, programs or the client outside the transaction; use SELECT / INSERT" + ], + [ + "COPY t FROM PROGRAM 'x'", + "COPY - it reads or writes files, programs or the client outside the transaction; use SELECT / INSERT" + ], + [ + "rollback to s1", + null + ], + [ + "ROLLBACK TO SAVEPOINT s", + null + ], + [ + "savepoint s", + null + ], + [ + "release s", + null + ], + [ + "prepare p as select 1", + null + ], + [ + "select 1", + null + ], + [ + "with x as (select 1) select * from x", + null + ], + [ + "do $$ begin commit; end $$", + null + ], + [ + "call p()", + null + ], + [ + "endless_fn()", + null + ], + [ + "end1()", + null + ], + [ + "rollback transaction to x", + null + ], + [ + "((( select 1", + null + ], + [ + "SELECT\t1", + null + ], + [ + "_x y", + null + ], + [ + "9 x", + null + ], + [ + "", + null + ] + ], + "firstWords": [ + [ + "commit", + [ + "commit" + ] + ], + [ + "COMMIT AND CHAIN", + [ + "commit", + "and", + "chain" + ] + ], + [ + "end", + [ + "end" + ] + ], + [ + "END WORK", + [ + "end", + "work" + ] + ], + [ + "rollback", + [ + "rollback" + ] + ], + [ + "rollback work", + [ + "rollback", + "work" + ] + ], + [ + "abort", + [ + "abort" + ] + ], + [ + "begin", + [ + "begin" + ] + ], + [ + "start transaction", + [ + "start", + "transaction" + ] + ], + [ + "prepare transaction 'x'", + [ + "prepare", + "transaction" + ] + ], + [ + "commit prepared 'x'", + [ + "commit", + "prepared" + ] + ], + [ + "rollback prepared 'x'", + [ + "rollback", + "prepared" + ] + ], + [ + "/* c */ commit", + [ + "commit" + ] + ], + [ + "-- c\ncommit", + [ + "commit" + ] + ], + [ + "--c\rcommit", + [ + "commit" + ] + ], + [ + "(commit)", + [ + "commit" + ] + ], + [ + "copy t to stdout", + [ + "copy", + "t", + "to", + "stdout" + ] + ], + [ + "COPY t FROM PROGRAM 'x'", + [ + "copy", + "t", + "from", + "program" + ] + ], + [ + "rollback to s1", + [ + "rollback", + "to", + "s1" + ] + ], + [ + "ROLLBACK TO SAVEPOINT s", + [ + "rollback", + "to", + "savepoint", + "s" + ] + ], + [ + "savepoint s", + [ + "savepoint", + "s" + ] + ], + [ + "release s", + [ + "release", + "s" + ] + ], + [ + "prepare p as select 1", + [ + "prepare", + "p", + "as", + "select" + ] + ], + [ + "select 1", + [ + "select" + ] + ], + [ + "with x as (select 1) select * from x", + [ + "with", + "x", + "as" + ] + ], + [ + "do $$ begin commit; end $$", + [ + "do" + ] + ], + [ + "call p()", + [ + "call", + "p" + ] + ], + [ + "endless_fn()", + [ + "endless_fn" + ] + ], + [ + "end1()", + [ + "end1" + ] + ], + [ + "rollback transaction to x", + [ + "rollback", + "transaction", + "to", + "x" + ] + ], + [ + "((( select 1", + [ + "select" + ] + ], + [ + "SELECT\t1", + [ + "select" + ] + ], + [ + "_x y", + [ + "_x", + "y" + ] + ], + [ + "9 x", + [] + ], + [ + "", + [] + ] + ], + "headline": [ + [ + " select\n 1 ", + "select 1" + ], + [ + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\u2026" + ], + [ + "\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9", + "\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u2026" + ], + [ + "\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9", + "\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9" + ], + [ + "a b\u3000c", + "a b c" + ] + ], + "cell": [ + [ + null, + "(null)" + ], + [ + "a\r\nb\tc", + "a\\r\\nb\\tc" + ], + [ + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\u2026" + ], + [ + "\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9", + "\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9" + ], + [ + "", + "" + ] + ], + "table": [ + [ + [ + "x" + ], + [ + [ + "1" + ] + ], + " x\n---\n 1\n(1 row)" + ], + [ + [ + "name", + "n" + ], + [ + [ + "\u00e9", + null + ], + [ + "longer value", + "2" + ], + [ + "a\nb", + "" + ] + ], + " name | n\n--------------+--------\n \u00e9 | (null)\n longer value | 2\n a\\nb |\n(3 rows)" + ], + [ + [ + "a" + ], + [ + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ], + [ + "1" + ] + ], + " a\n---\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n 1\n(101 rows, 1 not shown)" + ], + [ + [ + "a ", + "b" + ], + [ + [ + " ", + " " + ] + ], + " a | b\n----+---\n |\n(1 row)" + ], + [ + [ + "a" + ], + [], + " a\n---\n(0 rows)" + ] + ], + "errorText": [ + [ + { + "S": "ERROR", + "C": "42601", + "M": "syntax error at or near \"boom\"", + "P": "9" + }, + null, + "ERROR 42601: syntax error at or near \"boom\"" + ], + [ + { + "S": "ERROR", + "C": "42601", + "M": "syntax error at or near \"boom\"", + "P": "9" + }, + "select\nboom from \u00e9_table where x = 1", + "ERROR 42601: syntax error at or near \"boom\"\nAT: \u2026select b\u27e6oom from \u00e9_table where x = 1\u27e7\u2026" + ], + [ + { + "V": "FATAL", + "S": "FATAL", + "C": "28P01", + "M": "password authentication failed" + }, + null, + "FATAL 28P01: password authentication failed" + ], + [ + { + "V": "FATAL", + "S": "FATAL", + "C": "28P01", + "M": "password authentication failed" + }, + "select\nboom from \u00e9_table where x = 1", + "FATAL 28P01: password authentication failed" + ], + [ + { + "M": "m", + "D": "detail", + "H": "hint", + "W": "where", + "P": "2" + }, + null, + "ERROR : m\nDETAIL: detail\nHINT: hint\nWHERE: where" + ], + [ + { + "M": "m", + "D": "detail", + "H": "hint", + "W": "where", + "P": "2" + }, + "select\nboom from \u00e9_table where x = 1", + "ERROR : m\nDETAIL: detail\nHINT: hint\nWHERE: where\nAT: \u2026s\u27e6elect boom from \u00e9_table where \u27e7\u2026" + ], + [ + { + "S": "NOTICE", + "M": "hello" + }, + null, + "NOTICE : hello" + ], + [ + { + "S": "NOTICE", + "M": "hello" + }, + "select\nboom from \u00e9_table where x = 1", + "NOTICE : hello" + ], + [ + {}, + null, + "ERROR :" + ], + [ + {}, + "select\nboom from \u00e9_table where x = 1", + "ERROR :" + ], + [ + { + "P": "x", + "M": "no position" + }, + null, + "ERROR : no position" + ], + [ + { + "P": "x", + "M": "no position" + }, + "select\nboom from \u00e9_table where x = 1", + "ERROR : no position" + ], + [ + { + "P": "100", + "M": "past the end" + }, + null, + "ERROR : past the end" + ], + [ + { + "P": "100", + "M": "past the end" + }, + "select\nboom from \u00e9_table where x = 1", + "ERROR : past the end\nAT: \u2026\u27e6\u27e7\u2026" + ] + ], + "noticeText": [ + [ + { + "S": "ERROR", + "C": "42601", + "M": "syntax error at or near \"boom\"", + "P": "9" + }, + "ERROR: syntax error at or near \"boom\"" + ], + [ + { + "V": "FATAL", + "S": "FATAL", + "C": "28P01", + "M": "password authentication failed" + }, + "FATAL: password authentication failed" + ], + [ + { + "M": "m", + "D": "detail", + "H": "hint", + "W": "where", + "P": "2" + }, + "NOTICE: m" + ], + [ + { + "S": "NOTICE", + "M": "hello" + }, + "NOTICE: hello" + ], + [ + {}, + "NOTICE: " + ], + [ + { + "P": "x", + "M": "no position" + }, + "NOTICE: no position" + ], + [ + { + "P": "100", + "M": "past the end" + }, + "NOTICE: past the end" + ] + ], + "runs": [ + { + "sql": "select 1; insert into t values ('a'); boom; select 3", + "code": 1, + "out": [ + "review-gates sql: connected to db on 127.0.0.1:PORT as u - ONE transaction, rolled back at the end of this call (statement_timeout 20s, lock_timeout 3s; at most 100 rows printed per statement)", + "-- [1/4] select 1", + "NOTICE: hello", + " x\n---\n 1\n(1 row)", + "SELECT 1", + "-- [2/4] insert into t values ('a')", + "INSERT", + "-- [3/4] boom", + "ERROR 42601: syntax error at or near \"boom\"", + "-- stopped: statement 3 failed, so statements 4-4 did not run", + "-- rolled back: nothing of this call was committed" + ] + }, + { + "sql": "select 1", + "code": 0, + "out": [ + "review-gates sql: connected to db on 127.0.0.1:PORT as u - ONE transaction, rolled back at the end of this call (statement_timeout 20s, lock_timeout 3s; at most 100 rows printed per statement)", + "-- [1/1] select 1", + "NOTICE: hello", + " x\n---\n 1\n(1 row)", + "SELECT 1", + "-- rolled back: nothing of this call was committed" + ] + }, + { + "sql": "select 1; select 2", + "code": 0, + "out": [ + "review-gates sql: connected to db on 127.0.0.1:PORT as u - ONE transaction, rolled back at the end of this call (statement_timeout 20s, lock_timeout 3s; at most 100 rows printed per statement)", + "-- [1/2] select 1", + "NOTICE: hello", + " x\n---\n 1\n(1 row)", + "SELECT 1", + "-- [2/2] select 2", + "NOTICE: hello", + " x\n---\n 1\n(1 row)", + "SELECT 1", + "-- rolled back: nothing of this call was committed" + ] + }, + { + "sql": "update t set a = 1; delete from t", + "code": 0, + "out": [ + "review-gates sql: connected to db on 127.0.0.1:PORT as u - ONE transaction, rolled back at the end of this call (statement_timeout 20s, lock_timeout 3s; at most 100 rows printed per statement)", + "-- [1/2] update t set a = 1", + "UPDATE", + "-- [2/2] delete from t", + "DELETE", + "-- rolled back: nothing of this call was committed" + ] + }, + { + "sql": "select 1; select 1; select 1; boom", + "code": 1, + "out": [ + "review-gates sql: connected to db on 127.0.0.1:PORT as u - ONE transaction, rolled back at the end of this call (statement_timeout 20s, lock_timeout 3s; at most 100 rows printed per statement)", + "-- [1/4] select 1", + "NOTICE: hello", + " x\n---\n 1\n(1 row)", + "SELECT 1", + "-- [2/4] select 1", + "NOTICE: hello", + " x\n---\n 1\n(1 row)", + "SELECT 1", + "-- [3/4] select 1", + "NOTICE: hello", + " x\n---\n 1\n(1 row)", + "SELECT 1", + "-- [4/4] boom", + "ERROR 42601: syntax error at or near \"boom\"", + "-- rolled back: nothing of this call was committed" + ] + }, + { + "sql": "insert into x values (1)", + "code": 0, + "out": [ + "review-gates sql: connected to db on 127.0.0.1:PORT as u - ONE transaction, rolled back at the end of this call (statement_timeout 20s, lock_timeout 3s; at most 100 rows printed per statement)", + "-- [1/1] insert into x values (1)", + "INSERT", + "-- rolled back: nothing of this call was committed" + ] + }, + { + "sql": "select 1; commit", + "code": 1, + "out": [ + "review-gates sql: refused, nothing ran - statement 2: COMMIT - the helper owns the transaction and rolls it back at the end of the call (SAVEPOINT and ROLLBACK TO are fine)" + ], + "noServer": true + }, + { + "sql": "", + "code": 64, + "out": [ + "review-gates sql: no statement given - the SQL goes on stdin, in a quoted heredoc (<<'SQL' ... SQL) - nothing ran" + ], + "noServer": true + }, + { + "sql": " -- nothing\n", + "code": 64, + "out": [ + "review-gates sql: no statement given - the SQL goes on stdin, in a quoted heredoc (<<'SQL' ... SQL) - nothing ran" + ], + "noServer": true + }, + { + "sql": { + "repeat": "select 1;", + "length": 1808 + }, + "code": 64, + "out": [ + "review-gates sql: 201 statements; the limit is 200 - nothing ran" + ], + "noServer": true + }, + { + "sql": { + "repeat": "xxxxxxxxx", + "length": 200001 + }, + "code": 64, + "out": [ + "review-gates sql: the script is 200001 characters; the limit is 200000 - nothing ran" + ], + "noServer": true + } + ], + "hosted": [ + 2, + [ + "review-gates sql: NO DATABASE - host db.x.supabase.co is not this machine (127.0.0.1, localhost, ::1): the database under test is a local stack, never a hosted one" + ] + ], + "totp": [ + [ + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + 59, + [ + "287082", + 1 + ] + ], + [ + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + 1111111109, + [ + "081804", + 1 + ] + ], + [ + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + 1111111111, + [ + "050471", + 29 + ] + ], + [ + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + 1234567890, + [ + "005924", + 30 + ] + ], + [ + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + 2000000000, + [ + "279037", + 10 + ] + ], + [ + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + 20000000000, + [ + "353130", + 10 + ] + ], + [ + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + 1792966830.75, + [ + "104393", + 30 + ] + ], + [ + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + 0.5, + [ + "755224", + 30 + ] + ], + [ + "gezd gnbv gy3t qojq gezd gnbv gy3t qojq", + 59, + [ + "287082", + 1 + ] + ], + [ + "gezd gnbv gy3t qojq gezd gnbv gy3t qojq", + 1111111109, + [ + "081804", + 1 + ] + ], + [ + "gezd gnbv gy3t qojq gezd gnbv gy3t qojq", + 1111111111, + [ + "050471", + 29 + ] + ], + [ + "gezd gnbv gy3t qojq gezd gnbv gy3t qojq", + 1234567890, + [ + "005924", + 30 + ] + ], + [ + "gezd gnbv gy3t qojq gezd gnbv gy3t qojq", + 2000000000, + [ + "279037", + 10 + ] + ], + [ + "gezd gnbv gy3t qojq gezd gnbv gy3t qojq", + 20000000000, + [ + "353130", + 10 + ] + ], + [ + "gezd gnbv gy3t qojq gezd gnbv gy3t qojq", + 1792966830.75, + [ + "104393", + 30 + ] + ], + [ + "gezd gnbv gy3t qojq gezd gnbv gy3t qojq", + 0.5, + [ + "755224", + 30 + ] + ], + [ + " JBSWY3DPEHPK3PXP ", + 59, + [ + "996554", + 1 + ] + ], + [ + " JBSWY3DPEHPK3PXP ", + 1111111109, + [ + "071271", + 1 + ] + ], + [ + " JBSWY3DPEHPK3PXP ", + 1111111111, + [ + "358462", + 29 + ] + ], + [ + " JBSWY3DPEHPK3PXP ", + 1234567890, + [ + "742275", + 30 + ] + ], + [ + " JBSWY3DPEHPK3PXP ", + 2000000000, + [ + "890699", + 10 + ] + ], + [ + " JBSWY3DPEHPK3PXP ", + 20000000000, + [ + "752434", + 10 + ] + ], + [ + " JBSWY3DPEHPK3PXP ", + 1792966830.75, + [ + "147877", + 30 + ] + ], + [ + " JBSWY3DPEHPK3PXP ", + 0.5, + [ + "282760", + 30 + ] + ], + [ + "JBSWY3DPEHPK3PXP====", + 59, + [ + "996554", + 1 + ] + ], + [ + "JBSWY3DPEHPK3PXP====", + 1111111109, + [ + "071271", + 1 + ] + ], + [ + "JBSWY3DPEHPK3PXP====", + 1111111111, + [ + "358462", + 29 + ] + ], + [ + "JBSWY3DPEHPK3PXP====", + 1234567890, + [ + "742275", + 30 + ] + ], + [ + "JBSWY3DPEHPK3PXP====", + 2000000000, + [ + "890699", + 10 + ] + ], + [ + "JBSWY3DPEHPK3PXP====", + 20000000000, + [ + "752434", + 10 + ] + ], + [ + "JBSWY3DPEHPK3PXP====", + 1792966830.75, + [ + "147877", + 30 + ] + ], + [ + "JBSWY3DPEHPK3PXP====", + 0.5, + [ + "282760", + 30 + ] + ], + [ + "MZXW6===", + 59, + [ + "398408", + 1 + ] + ], + [ + "MZXW6===", + 1111111109, + [ + "352179", + 1 + ] + ], + [ + "MZXW6===", + 1111111111, + [ + "255070", + 29 + ] + ], + [ + "MZXW6===", + 1234567890, + [ + "252675", + 30 + ] + ], + [ + "MZXW6===", + 2000000000, + [ + "215058", + 10 + ] + ], + [ + "MZXW6===", + 20000000000, + [ + "426436", + 10 + ] + ], + [ + "MZXW6===", + 1792966830.75, + [ + "068826", + 30 + ] + ], + [ + "MZXW6===", + 0.5, + [ + "937425", + 30 + ] + ] + ] } } diff --git a/tests/Goldens/main.cpp b/tests/Goldens/main.cpp index 85fa265..e08a7d2 100644 --- a/tests/Goldens/main.cpp +++ b/tests/Goldens/main.cpp @@ -410,6 +410,60 @@ namespace { for (const Json& entry : c.at("redact")) Same(PgWire::Redact(entry.at(0).get()), entry.at(1).get(), std::format("redact {}", entry.at(0).get())); for (const Json& entry : c.at("where")) Same(PgWire::Where(entry.at(0).get()), entry.at(1).get(), std::format("where {}", entry.at(0).get())); } + + PgWire::Fields FieldsOf(const Json& dict) { + PgWire::Fields out; + for (auto it = dict.begin(); it != dict.end(); ++it) out[it.key().at(0)] = it.value().get(); + return out; + } + + std::vector>> RowsOf(const Json& rows) { + std::vector>> out; + for (const Json& row : rows) { + std::vector> cells; + for (const Json& cell : row) cells.push_back(OptionalString(cell)); + out.push_back(std::move(cells)); + } + return out; + } + + void CheckPgWire(const Json& g) { + const Json& p = g.at("pgwire"); + for (const Json& entry : p.at("split")) SameJson(ToJson(PgWire::SplitStatements(entry.at(0).get())), entry.at(1), std::format("split_statements {}", Dyn::Repr(entry.at(0).get()))); + for (const Json& entry : p.at("stripComments")) Same(PgWire::StripComments(entry.at(0).get()), entry.at(1).get(), std::format("strip_comments {}", Dyn::Repr(entry.at(0).get()))); + for (const Json& entry : p.at("fingerprint")) Same(PgWire::Fingerprint(entry.at(0).get()), entry.at(1).get(), std::format("fingerprint {}", Dyn::Repr(entry.at(0).get()))); + for (const Json& entry : p.at("refused")) SameJson(Optional(PgWire::Refused(entry.at(0).get())), entry.at(1), std::format("refused {}", Dyn::Repr(entry.at(0).get()))); + for (const Json& entry : p.at("firstWords")) SameJson(ToJson(PgWire::FirstWords(entry.at(0).get(), 4)), entry.at(1), std::format("first_words {}", Dyn::Repr(entry.at(0).get()))); + for (const Json& entry : p.at("headline")) Same(PgWire::Headline(entry.at(0).get()), entry.at(1).get(), std::format("headline {}", Dyn::Repr(entry.at(0).get()))); + for (const Json& entry : p.at("cell")) Same(PgWire::Cell(OptionalString(entry.at(0))), entry.at(1).get(), std::format("_cell {}", DumpCompactSorted(entry.at(0)))); + for (const Json& entry : p.at("table")) Same(PgWire::Table(Strings(entry.at(0)), RowsOf(entry.at(1))), entry.at(2).get(), std::format("table {}", DumpCompactSorted(entry.at(0)))); + for (const Json& entry : p.at("errorText")) { + Same(PgWire::ErrorText(FieldsOf(entry.at(0)), OptionalString(entry.at(1)).value_or("")), entry.at(2).get(), std::format("error_text {}", DumpCompactSorted(entry.at(0)))); + } + for (const Json& entry : p.at("noticeText")) Same(PgWire::NoticeText(FieldsOf(entry.at(0))), entry.at(1).get(), std::format("notice_text {}", DumpCompactSorted(entry.at(0)))); + for (const Json& entry : p.at("runs")) { + if (!entry.contains("noServer")) continue; + std::string sql; + if (entry.at("sql").is_object()) { + const std::string unit = entry.at("sql").at("repeat").get(); + const auto length = entry.at("sql").at("length").get(); + while (sql.size() < length) sql += unit; + sql.resize(length); + } else { + sql = entry.at("sql").get(); + } + std::vector lines; + const std::int32_t code = PgWire::RunSql("postgresql://u:pw@127.0.0.1:1/db", sql, [&](const std::string& line) { lines.push_back(line); }); + SameJson(Json::array({code, ToJson(lines)}), Json::array({entry.at("code"), entry.at("out")}), std::format("run (no server) {}", Dyn::Repr(Dyn::Slice(sql, 0, 40)))); + } + std::vector hosted; + const std::int32_t code = PgWire::RunSql("postgresql://u@db.x.supabase.co/x", "select 1", [&](const std::string& line) { hosted.push_back(line); }); + SameJson(Json::array({code, ToJson(hosted)}), p.at("hosted"), "run (a hosted database)"); + for (const Json& entry : p.at("totp")) { + const Totp::Code got = Totp::At(entry.at(0).get(), entry.at(1).get()); + SameJson(Json::array({got.code, got.left}), entry.at(2), std::format("totp {} at {}", entry.at(0).get(), Dyn::ReprValue(entry.at(1)))); + } + } } int main() { @@ -425,6 +479,7 @@ int main() { CheckStream(g); CheckState(g); CheckConfig(g); + CheckPgWire(g); std::println("{} checks against the goldens", Checks); if (Failures) std::println(std::cerr, "{} failed", Failures); return Failures ? 1 : 0; diff --git a/tests/PgWire/main.cpp b/tests/PgWire/main.cpp new file mode 100644 index 0000000..d98f8b5 --- /dev/null +++ b/tests/PgWire/main.cpp @@ -0,0 +1,383 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The SQL helper: its statements, refusals, the URL, SCRAM, the protocol on a +// stand-in server (FakePg, below). Then what the helper prints over the same +// stand-in, line for line against the goldens (fixture/pipeline.json, +// `runs`), and the broker: the URL stays in the driver, a pass gets an +// address and a token. + +#include +#include +#include +#include +import std; +import Crafter.Review; +namespace fs = std::filesystem; +using namespace Crafter::Review; + +namespace { + std::int32_t Failures = 0; + + void Check(std::string_view name, bool cond, std::string_view detail = {}) { + std::println("{}{}{}", cond ? " ok " : " FAIL ", name, !cond && !detail.empty() ? std::format(" ({})", detail) : std::string()); + if (!cond) ++Failures; + } + + std::string Be32(std::uint32_t v) { + return {static_cast(v >> 24), static_cast((v >> 16) & 0xFF), static_cast((v >> 8) & 0xFF), static_cast(v & 0xFF)}; + } + + std::string Msg(char type, const std::string& body = {}) { + return std::format("{}{}{}", type, Be32(static_cast(body.size() + 4)), body); + } + + std::string Err(const std::string& code, const std::string& text) { + return Msg('E', std::format("SERROR{}C{}{}M{}{}{}", std::string(1, '\0'), code, std::string(1, '\0'), text, std::string(1, '\0'), std::string(1, '\0'))); + } + + // A Postgres stand-in on 127.0.0.1 for one connection: cleartext + // password, simple Query, Parse/Bind/Describe/Execute/Sync. It records + // what it is sent and answers as the server does where the helper relies + // on it: two commands in one Parse are an error; COMMIT ends the + // transaction (status I); a statement after an error fails. + class FakePg { + public: + explicit FakePg(std::string password = "pw") : password(std::move(password)) { + listener = socket(AF_INET, SOCK_STREAM, 0); + sockaddr_in address {}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + bind(listener, reinterpret_cast(&address), sizeof(address)); + listen(listener, 1); + socklen_t length = sizeof(address); + getsockname(listener, reinterpret_cast(&address), &length); + port = ntohs(address.sin_port); + thread = std::thread([this] { Serve(); }); + } + + ~FakePg() { + Served(); + } + + // The log once the connection is over. + std::vector> Served() { + if (thread.joinable()) thread.join(); + return log; + } + + std::uint16_t port = 0; + + private: + std::string password; + std::int32_t listener = -1; + std::thread thread; + std::vector> log; + + void Serve() { + const std::int32_t c = accept(listener, nullptr, nullptr); + std::string buffer; + auto read = [&](std::size_t n) { + while (buffer.size() < n) { + std::array chunk {}; + const ssize_t got = recv(c, chunk.data(), chunk.size(), 0); + if (got <= 0) break; + buffer.append(chunk.data(), static_cast(got)); + } + const std::string out = buffer.substr(0, std::min(n, buffer.size())); + buffer.erase(0, out.size()); + return out; + }; + auto length = [](const std::string& bytes) { + return (static_cast(static_cast(bytes[0])) << 24) | (static_cast(static_cast(bytes[1])) << 16) + | (static_cast(static_cast(bytes[2])) << 8) | static_cast(bytes[3]); + }; + auto send = [&](const std::string& data) { ::send(c, data.data(), data.size(), MSG_NOSIGNAL); }; + const std::uint32_t startup = length(read(4)); + read(startup - 4); + send(Msg('R', Be32(3))); + read(1); + std::string got = read(length(read(4)) - 4); + while (!got.empty() && got.back() == '\0') got.pop_back(); + log.emplace_back("password", got); + if (got != password) { + send(Err("28P01", "password authentication failed")); + close(c); + close(listener); + return; + } + send(Msg('S', std::string("server_version\x00" "17.6\x00", 20)) + Msg('R', Be32(0)) + Msg('Z', "I")); + std::string status = "I"; + std::string pending; + while (true) { + const std::string type = read(1); + if (type.empty()) break; + const std::string body = read(length(read(4)) - 4); + if (type == "Q") { + std::string q = body; + while (!q.empty() && q.back() == '\0') q.pop_back(); + log.emplace_back("Q", q); + status = q.starts_with("BEGIN") ? "T" : (q == "ROLLBACK" ? "I" : status); + send(Msg('C', Dyn::Split(q)[0] + std::string(1, '\0')) + Msg('Z', status)); + } else if (type == "P") { + pending = body.substr(1, body.find('\0', 1) - 1); + log.emplace_back("P", pending); + } else if (type == "S") { + const std::string low = Dyn::Lower(pending); + std::string out; + if (status == "E") { + out = Err("25P02", "current transaction is aborted"); + } else if (pending.contains(';')) { + out = Err("42601", "cannot insert multiple commands into a prepared statement"); + status = "E"; + } else if (low.starts_with("commit")) { + out = Msg('C', std::string("COMMIT\0", 7)); + status = "I"; + } else if (low.starts_with("select")) { + out = Msg('T', std::string("\x00\x01x\x00", 4) + std::string(18, '\0')) + Msg('D', std::string("\x00\x01\x00\x00\x00\x01" "1", 7)) + + Msg('N', std::string("SNOTICE\0Mhello\0\0", 16)) + Msg('C', std::string("SELECT 1\0", 9)); + } else if (low.starts_with("boom")) { + out = Err("42601", "syntax error at or near \"boom\""); + status = "E"; + } else { + out = Msg('C', Dyn::Upper(Dyn::Split(pending)[0]) + std::string(1, '\0')); + } + send(Msg('1') + Msg('2') + out + Msg('Z', status)); + } else if (type == "X") { + log.emplace_back("X", ""); + break; + } + } + close(c); + close(listener); + } + }; + + std::uint16_t DeadPort() { + const std::int32_t s = socket(AF_INET, SOCK_STREAM, 0); + sockaddr_in address {}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + bind(s, reinterpret_cast(&address), sizeof(address)); + socklen_t length = sizeof(address); + getsockname(s, reinterpret_cast(&address), &length); + close(s); + return ntohs(address.sin_port); + } + + std::vector Of(const std::vector>& log, std::string_view kind) { + std::vector out; + for (const auto& [k, x] : log) { + if (k == kind) out.push_back(x); + } + return out; + } + + bool Any(const std::vector& lines, std::string_view part) { + return std::ranges::any_of(lines, [&](const std::string& line) { return line.contains(part); }); + } + + std::string Show(const std::vector& lines) { + Json out = Json::array(); + for (const std::string& line : lines) out.push_back(line); + return Dyn::ReprValue(out); + } + + // A raw call of the broker: one line in, every line back. + std::vector RawCall(std::uint16_t port, const std::string& line) { + const std::int32_t s = socket(AF_INET, SOCK_STREAM, 0); + sockaddr_in address {}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = htons(port); + connect(s, reinterpret_cast(&address), sizeof(address)); + ::send(s, line.data(), line.size(), MSG_NOSIGNAL); + shutdown(s, SHUT_WR); + std::string all; + std::array chunk {}; + ssize_t got = 0; + while ((got = recv(s, chunk.data(), chunk.size(), 0)) > 0) all.append(chunk.data(), static_cast(got)); + close(s); + return Dyn::Split(Dyn::RStrip(all, "\n"), "\n"); + } +} + +int main() { + std::println("the SQL helper (pgwire): statements, refusals, the URL, SCRAM, the protocol on a stand-in server"); + using Pieces = std::vector; + auto sp = [](std::string_view sql) { return PgWire::SplitStatements(sql); }; + Check("split: semicolons in quotes, E-strings, dollar quotes (tagged, and $ inside identifiers), comments (nested, -- to \\r), double quotes and parentheses do not cut; empty statements drop", + sp("select ';' ; select 2;") == Pieces{"select ';'", "select 2"} && sp("select $x$ a; $y$ ; $x$; commit") == Pieces{"select $x$ a; $y$ ; $x$", "commit"} + && sp("select 1 as a$b$; commit; select $b$") == Pieces{"select 1 as a$b$", "commit", "select $b$"} && sp("select E'a\\'; commit; '") == Pieces{"select E'a\\'; commit; '"} + && sp("-- c;\nselect 1; /* x; /* y; */ z; */ select 2") == Pieces{"-- c;\nselect 1", "/* x; /* y; */ z; */ select 2"} && sp("select 1 --x\r; commit") == Pieces{"select 1 --x", "commit"} + && sp("create rule r as on insert to t do also (insert into a values (1); update b set c = 1); select 3") + == Pieces{"create rule r as on insert to t do also (insert into a values (1); update b set c = 1)", "select 3"} + && sp("select \"a;b\" from t; select 2") == Pieces{"select \"a;b\" from t", "select 2"} && sp(" ;; -- only\n ; ").empty()); + const std::vector refuse = {"commit", "COMMIT AND CHAIN", "end", "END WORK", "rollback", "rollback work", "abort", "begin", "start transaction", "prepare transaction 'x'", + "commit prepared 'x'", "rollback prepared 'x'", "/* c */ commit", "-- c\ncommit", "--c\rcommit", "(commit)", "copy t to stdout", "COPY t FROM PROGRAM 'x'"}; + const std::vector allow = {"rollback to s1", "ROLLBACK TO SAVEPOINT s", "savepoint s", "release s", "prepare p as select 1", "select 1", "with x as (select 1) select * from x", + "do $$ begin commit; end $$", "call p()", "endless_fn()", "end1()"}; + std::vector wrong; + for (const std::string& s : refuse) { + if (!PgWire::Refused(s)) wrong.push_back(s); + } + for (const std::string& s : allow) { + if (PgWire::Refused(s)) wrong.push_back(s); + } + Check("refused: every statement that ends, restarts or prepares the transaction, and COPY - past comments and parentheses; ROLLBACK TO, SAVEPOINT, DO and CALL go to the server (which refuses COMMIT inside them in a transaction block)", + wrong.empty(), Show(wrong)); + Check("URL: this machine only; the password masked for a log or a dump", !PgWire::CheckUrl("postgresql://postgres:postgres@127.0.0.1:54322/postgres") && !PgWire::CheckUrl("postgres://u@localhost/x") + && !PgWire::CheckUrl("postgresql://u@[::1]:5/x") && PgWire::CheckUrl("postgresql://u:p@db.abcdefgh.supabase.co:5432/postgres").value_or("").contains("not this machine") + && PgWire::CheckUrl("http://127.0.0.1:54321") && PgWire::CheckUrl("") && PgWire::CheckUrl(nullptr) + && PgWire::Redact("postgresql://postgres:hunter2@127.0.0.1:54322/postgres") == "postgresql://postgres:***@127.0.0.1:54322/postgres" + && PgWire::Redact("postgresql://127.0.0.1/x") == "postgresql://127.0.0.1/x"); + PgWire::Scram sc("pencil", "user", "rOprNGfwEbeRWgbNEkqO"); + const std::string sf = "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096"; + Check("SCRAM-SHA-256: the RFC 7677 test vector - the client-first, the client proof, the server signature verified (and a wrong one refused)", + sc.ClientFirst() == "n,,n=user,r=rOprNGfwEbeRWgbNEkqO" + && sc.ClientFinal(sf) == "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=" + && sc.Verify("v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=") && !sc.Verify("v=AAAATRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=")); + std::vector out; + const PgWire::Out collect = [&](const std::string& line) { out.push_back(line); }; + { + FakePg pg; + const std::int32_t code = PgWire::RunSql(std::format("postgresql://u:pw@127.0.0.1:{}/db", pg.port), "select 1; insert into t values ('a'); boom; select 3", collect); + const auto log = pg.Served(); + const std::vector queries = Of(log, "Q"); + const std::vector setup(queries.begin(), queries.begin() + std::min(4, static_cast(queries.size()))); + Check("run: the password, BEGIN and the three SET LOCALs, then one Parse per statement up to the failing one, then ROLLBACK and Terminate; the call says it connected, prints rows, the notice, the error and that it stopped; exit 1", + !log.empty() && log[0] == std::pair{"password", "pw"} + && setup == std::vector{"BEGIN", "SET LOCAL statement_timeout = '20s'", "SET LOCAL lock_timeout = '3s'", "SET LOCAL idle_in_transaction_session_timeout = '60s'"} + && Of(log, "P") == std::vector{"select 1", "insert into t values ('a')", "boom"} && log.size() >= 2 && log[log.size() - 2] == std::pair{"Q", "ROLLBACK"} + && log.back() == std::pair{"X", ""} && code == 1 && out.at(0).starts_with(PgWire::Answered) && std::ranges::contains(out, std::string("NOTICE: hello")) + && Any(out, "syntax error") && Any(out, "statement 3 failed") && out.back() == "-- rolled back: nothing of this call was committed", Show(out)); + } + { + FakePg pg; + std::unique_ptr cn = PgWire::Connect(std::format("postgresql://u:pw@127.0.0.1:{}/db", pg.port), 1); + cn->Simple("BEGIN"); + const PgWire::Answer two = cn->Extended("select 1; select 2"); + cn->Simple("ROLLBACK"); + cn->Close(); + const auto log = pg.Served(); + Check("extended: a whole script in one Parse comes back as the server refuses it (two commands), the transaction failed - never run", two.error && two.error->at('C') == "42601" + && two.rows.empty() && std::ranges::contains(log, std::pair{"P", "select 1; select 2"})); + } + { + out.clear(); + FakePg pg; + // A statement the refusal list misses: the transaction status is the + // backstop. + const std::int32_t code = PgWire::RunSql(std::format("postgresql://u:pw@127.0.0.1:{}/db", pg.port), "create table x (a int); commit; select 1", collect, {}, + [](std::string_view) { return std::optional(); }); + const auto log = pg.Served(); + Check("run: the backstop - after a statement that ended the transaction nothing more is sent, the call fails and says what may have been committed", + Of(log, "P") == std::vector{"create table x (a int)", "commit"} && code == 1 && Any(out, "STOPPED") && out.back().contains("may have been committed"), Show(out)); + } + { + out.clear(); + FakePg pg; + const std::string url = std::format("postgresql://u:pw@127.0.0.1:{}/db", pg.port); + const std::int32_t code = PgWire::RunSql(url, "select 1; commit", collect); + PgWire::Connect(url, 1)->Close(); + const auto log = pg.Served(); + Check("run: a refused statement stops the call before it connects - nothing is sent, no connected line", code == 1 && out.at(0).starts_with("review-gates sql: refused, nothing ran") + && out.at(0).contains("statement 2: COMMIT") && !Any(out, PgWire::Answered) && Of(log, "Q").empty() && Of(log, "P").empty()); + } + { + out.clear(); + const std::uint16_t dead = DeadPort(); + const PgWire::Connector once = [](std::string_view url) { return PgWire::Connect(url, 1, [](double) {}); }; + const std::int32_t code = PgWire::RunSql(std::format("postgresql://u:pw@127.0.0.1:{}/db", dead), "select 1", collect, once); + std::vector ignored; + const PgWire::Out drop = [&](const std::string& line) { ignored.push_back(line); }; + Check("run: nothing listening -> NO DATABASE, exit 2, no connected line; a hosted URL never connects; no statement is a usage error", code == 2 && out.at(0).starts_with(PgWire::NoDatabase) + && PgWire::RunSql("postgresql://u@db.x.supabase.co/x", "select 1", drop) == 2 && PgWire::RunSql(std::format("postgresql://u@127.0.0.1:{}/x", dead), " -- nothing\n", drop) == 64); + Check("...and the reason: cannot connect to 127.0.0.1: ([Errno 111] Connection refused)", + out.at(0) == std::format("{} - cannot connect to 127.0.0.1:{} ([Errno 111] Connection refused)", PgWire::NoDatabase, dead), out.at(0)); + } + { + out.clear(); + FakePg pg("other"); + const PgWire::Connector once = [](std::string_view url) { return PgWire::Connect(url, 1); }; + const std::int32_t code = PgWire::RunSql(std::format("postgresql://u:pw@127.0.0.1:{}/db", pg.port), "select 1", collect, once); + const std::string detail = out.at(0).substr(out.at(0).find(" - ") + 3); + Check("run: a refused login is NO DATABASE (exit 2), its message without the password", code == 2 && out.at(0).starts_with(PgWire::NoDatabase) && out.at(0).contains("28P01") + && !detail.contains("pw"), Show(out)); + } + { + // A busy server is asked again, but not slept on after the last try. + std::vector slept; + try { + PgWire::Connect(std::format("postgresql://u@127.0.0.1:{}/x", DeadPort()), 3, [&](double s) { slept.push_back(s); }); + } catch (const PgWire::DbError&) { + // Expected: nothing listens. + } + Check("connect: an unreachable server is tried three times, slept on between tries only", slept == std::vector{0.5, 1.0}); + } + + // What the helper prints over the stand-in, line for line against the + // goldens. + const Json goldens = Dyn::LoadJsonFile(fs::current_path() / "tests" / "Goldens" / "fixture" / "pipeline.json"); + std::int32_t compared = 0; + for (const Json& entry : goldens.at("pgwire").at("runs")) { + if (entry.contains("noServer")) continue; + out.clear(); + FakePg pg; + const std::int32_t code = PgWire::RunSql(std::format("postgresql://u:pw@127.0.0.1:{}/db", pg.port), entry.at("sql").get(), collect); + pg.Served(); + std::vector normalized; + for (const std::string& line : out) normalized.push_back(Dyn::Replace(line, std::format(":{}", pg.port), ":PORT")); + std::vector want; + for (const Json& line : entry.at("out")) want.push_back(line.get()); + if (code == entry.at("code").get() && normalized == want) ++compared; + else Check(std::format("run over the stand-in, as the goldens record it: {}", entry.at("sql").get()), false, Show(normalized)); + } + Check(std::format("run over the stand-in prints what the goldens record, line for line ({} scripts)", compared), compared == 6); + + // The broker. + std::vector ran; + PgWire::Broker broker( + [&](const std::string& url, const std::string& sql, const PgWire::Out& say) { + ran.push_back(std::format("{}|{}", url, sql)); + say(std::format("{} to x", PgWire::Answered)); + say("é line"); + return 1; + }, + [](const std::string& url) { return std::pair{url.contains("good"), "detail"}; }); + broker.Start(); + const std::string url = "postgresql://postgres:hunter2@127.0.0.1:54322/postgres"; + const std::string address = broker.Address(url); + const std::string token = address.substr(address.rfind('#') + 1); + Check("broker: the address is 127.0.0.1:#<32 hex>, one token per URL, and the token names its URL", address.starts_with("127.0.0.1:") && token.size() == 32 + && std::ranges::all_of(token, [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }) && broker.Address(url) == address + && broker.Address("postgresql://other@127.0.0.1/x") != address && broker.UrlOf(token) == url && broker.Address("").empty() && !address.contains("hunter2")); + out.clear(); + std::int32_t code = PgWire::CallBroker(address, "select 1;\nselect 'é';", false, collect); + Check("broker: a call runs the SQL on its token's URL, streams the lines back and returns the call's exit code", code == 1 && ran.size() == 1 && ran[0] == std::format("{}|select 1;\nselect 'é';", url) + && out == std::vector{std::format("{} to x", PgWire::Answered), "é line"}, Show(out)); + out.clear(); + code = PgWire::CallBroker(broker.Address("postgresql://good@127.0.0.1/x"), "", true, collect); + Check("broker: a probe answers with the connected line and exit 0", code == 0 && out == std::vector{std::format("{} - detail", PgWire::Answered)}, Show(out)); + out.clear(); + code = PgWire::CallBroker(std::format("{}#{}", address.substr(0, address.rfind('#')), std::string(32, '0')), "select 1", false, collect); + Check("broker: an unknown token is NO DATABASE, exit 2, and nothing runs", code == 2 && out.size() == 1 && out[0].starts_with(PgWire::NoDatabase) && ran.size() == 1, Show(out)); + const std::uint16_t brokerPort = static_cast(std::stoi(address.substr(10, address.rfind('#') - 10))); + const std::vector notWhole = RawCall(brokerPort, "{\"token\": \"x\""); + Check("broker: a call that never ends its line did not arrive whole - nothing runs, exit 64", notWhole.size() == 2 && notWhole[0].contains("did not arrive whole") + && notWhole[1] == std::format("{}64", PgWire::ExitMark), Show(notWhole)); + const std::vector listToken = RawCall(brokerPort, "{\"token\": [1], \"sql\": \"select 1\"}\n"); + Check("broker: a token that is no string gets NO DATABASE and the exit mark", listToken.size() == 2 + && listToken[0].starts_with(PgWire::NoDatabase) && listToken[1] == std::format("{}2", PgWire::ExitMark), Show(listToken)); + out.clear(); + Check("call_broker: no address, a bad port, a closed port - NO DATABASE, exit 2, with the reason", + PgWire::CallBroker("", "x", false, collect) == 2 && PgWire::CallBroker("127.0.0.1:x#t", "x", false, collect) == 2 + && PgWire::CallBroker(std::format("127.0.0.1:{}#t", DeadPort()), "x", false, collect) == 2 && out.size() == 3 + && out[0] == std::format("{} - this pass has no database (the driver gives one to a pass with the supabase environment)", PgWire::NoDatabase) + && out[1] == std::format("{} - the driver's SQL broker does not answer (invalid literal for int() with base 10: 'x')", PgWire::NoDatabase) + && out[2].ends_with("does not answer ([Errno 111] Connection refused)"), Show(out)); + broker.Close(); + out.clear(); + Check("broker: closed, its port answers no more", PgWire::CallBroker(address, "select 1", false, collect) == 2 && out.at(0).ends_with("([Errno 111] Connection refused)"), Show(out)); + return Failures ? 1 : 0; +}