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; +}