VirtuProbe Studio
Get the app
← All posts
Build log 5 July 2026 9 min read

Why a request workbench added a database.

Eight of VirtuProbe's protocols are client stacks written from scratch against the RFC, so nothing sits between what you intend to send and the bytes on the socket. MySQL is the ninth, and the only one that isn't. It's a thin, deliberate layer over a JDBC driver and a connection pool. The reasons for that, plus the decisions inside the layer we did write, are the more interesting story than the feature itself.

For most of the tool's life the protocol list read like a testing toolkit: HTTP, SMTP, IMAP, LDAP, DNS, SpamAssassin, SMB, Kerberos. Every one is a hand-written stack, built so you can send the malformed packet, the arbitrary command, the raw byte sequence a normal library would quietly correct before it left the machine. That control is the whole point of those eight.

A database doesn't fit that shape, and when it climbed to the top of the request queue it came with two honest questions. Does a request workbench even need a database? And if it does, do we build it from the protocol up like everything else?

The first question: what a database is for here

Take the most ordinary integration test there is, a POST that's supposed to create a user. The API returns 201 and a tidy {"status":"created"}, and the test goes green on that response.

But the response is a claim about intent, not a record of what happened. The row can fail to land in ways that never touch the payload you asserted on: a transaction rolled back after the handler returned, an async worker that took the job and died, a write that went to the wrong shard, a constraint swallowed three layers down. The place that actually knows whether the user exists is the database. Until now, checking it meant leaving the tool: run the chain here, alt-tab to a SQL client, paste an id, read the result, come back. The step that closes the loop was the one step you couldn't keep in the loop.

So the database earns its place as the last step of a chain: fire the HTTP request, pull the new id out of the response with an extractor, run SELECT … WHERE id = {{userId}}, and assert on the row. That's the whole reason it's there. Everything below is about the second question.

The second question: why not a hand-written stack

The tempting answer, "you don't fuzz a database", is simply false, and worth killing off first. You can. A server's connection handshake, its auth plugins, its packet parser are all C, and memory-safety bugs surface in them the way they do everywhere else; serious ones turn up in the big engines from time to time. "There's nothing down there to break" was never the reason.

The real reason is the cost structure of adding a protocol, which splits into two very unequal halves. Standing up the module is cheap: a Maven archetype scaffolds a new protocol in minutes: the wiring, the probe UI, the chain-step executor, the extractor plumbing, all generated and ready to fill in. That's the half people picture when they say "add a protocol", and it's nearly free.

The expensive half is the client stack underneath, the from-scratch, RFC-in-one-hand implementation of the protocol itself. For each of the eight that was weeks of careful work, and worth every hour, because control over the exact bytes is what those probes are for. MySQL's protocol is no smaller: a multi-step handshake, a spread of pluggable auth methods, prepared statements, a binary result-set encoding. Re-deriving all of it to real parity would be a month or more, and at the end I'd have a database client that was worse than the mature driver that already exists, at connection handling, at auth negotiation and at pooling.

Scaffolding a module is minutes. Writing the client stack under it is weeks. That asymmetry decides more than any principle about hand-rolling does.

And this isn't a solo weekend project anymore. There's a real user base with a concrete backlog. Weighed against that, spending a month reimplementing a protocol to unlock a capability a sliver of users would reach for, when a solid driver ships the querying everyone wants this week, wasn't a close call. "Write it from scratch" was always a means to an end, send exactly what I ask, never a badge worn for its own sake. Where that end matters, we pay the weeks. Where it doesn't, we don't.

What we actually wrote: a thin layer over the driver

So MySQL is backed by MariaDB Connector/J, pooled with HikariCP. The URL is jdbc:mariadb://…, one driver that speaks to both MySQL and MariaDB, so we cover both engines without a second stack. We didn't fork or patch the driver; we wrapped it in three small classes that make it behave like a VirtuProbe probe. That wrapper is where the actual design work is.

  • A connection config that is immutable and does two jobs: it renders the jdbc:mariadb:// URL plus a map of driver properties, and it computes a stable signature string from every field that affects the connection.
  • A pool that holds one HikariCP data source per distinct signature, in a concurrent map. Repeated sends, and the steps of a chain, borrow from the existing pool instead of paying a fresh TCP-plus-handshake each time.
  • A client that wraps a single borrowed connection, runs exactly one statement, maps the outcome, and on close hands the connection back to the pool.

The signature is the load-bearing detail. It deliberately includes the credentials, so changing a password spins up a fresh pool rather than silently reusing a data source configured with the old auth. And it exposes an honest limitation I'd rather state than hide: a pool reuses a connection, not necessarily the same physical one between calls. So session-scoped state, a temp table, a @variable, a USE, is not guaranteed to survive from one chain step to the next. A pinned-session mode, where a chain holds one connection for its whole run, is on the list; it isn't in yet. If you need it today, keep the state in the SQL, not in the session.

A rejected statement is a result, not a crash

Here's a decision that reads small and matters a lot. When the server rejects a statement, whether that is a syntax error, a constraint violation or a permission denial, the driver throws a SQLException. The obvious thing is to let that propagate as a failure. We don't. We catch it and map it into a normal result object with success = false and the vendor errorCode, the SQLSTATE, and the message filled in. Only a genuine connection-level failure is treated as exceptional, and even that comes back to the interface as a failed probe response, never a 500.

The reasoning is the same one that governs the HTTP probe, where a 500 from the target is data you want, not an error in the tool. In a testing tool a constraint violation is frequently the expected outcome. You're asserting the duplicate insert gets rejected, that the NOT NULL holds. If that surfaced as a stack trace instead of a row you can assert on, the tool would be fighting the test.

Everything comes back as a string

Every cell is captured through getString, and a SQL NULL is preserved as an actual null. That's a real tradeoff, taken on purpose: we give up rich per-type values at the probe's edge, and in return the result is trivially JSON-serializable, directly comparable in an ASSERT step, and renderable in one result table with no per-type branching. Most importantly it lets a query result flow through the exact same extractor-and-variable machinery every other protocol uses. A number that arrives as "42" compares just fine against an expected 42; the uniformity is worth more here than the typing.

The chaining surface

The result set is turned into chainable values by a handful of extractors. The primary one pulls a single cell, with a small expression grammar:

# MYSQL_COLUMN expressions email → column "email" of the first row 0.email → column "email" of row 0 2.status → column "status" of row 2 0.1 → the 2nd column of row 0, by index

Column names match case-insensitively, a deliberate accommodation, because our own tests run against an in-memory H2 database that folds identifier case differently from MySQL, and I'd rather the extractor absorb that than make every chain author think about it. Alongside it: MYSQL_ROW_COUNT, MYSQL_AFFECTED_ROWS, MYSQL_SUCCESS, MYSQL_ERROR_CODE, and MYSQL_JSON, which serialises the whole result set to a JSON array for the cases where you want to hand it to a script or a later step wholesale.

One thing worth being explicit about: in a chain, {{variables}} are resolved straight into the SQL text before it runs. That's intentional on two counts. It's what makes cross-step chaining work, since the id from an HTTP response lands in the WHERE clause, and it's what makes the probe usable for SQL-injection testing, where building a deliberately hostile query is the entire exercise. So there is a security use for the MySQL probe. It just lives at the query layer rather than in fuzzing the protocol.

The auth flags, and why they're off by default

MySQL 8 defaults its accounts to the caching_sha2_password plugin. Over a plaintext connection the driver needs the server's RSA public key to send credentials safely, and if it can't get it the first connection fails with "RSA public key is not available client side." That looks like a bug and isn't. It's the driver refusing to do something unsafe.

The fix is a single connection property that lets the driver fetch that key over the plaintext link, and we keep it off by default, exposed as a labelled checkbox, precisely because it opens a window for a man-in-the-middle to harvest the exchange. The honest default is "secure, and make the user opt into the risk with their eyes open." The clean answer, of course, is to turn on TLS and watch the whole problem disappear. The same restraint runs through the rest of the connection options:

  • TLS mode maps onto the driver's four real settings: encrypt-without-verifying, verify the chain, verify the chain and the hostname, or off. We expose exactly those four, and deliberately did not invent a friendly-sounding fifth "preferred/opportunistic" mode the driver doesn't actually implement. A mode that lies about what it does is worse than an honest one that's off.
  • Cleartext auth plugins (the ones used with PAM or LDAP back-ends) are similarly gated behind an explicit opt-in, because the driver otherwise restricts plugins that send the password in the clear.
  • And there's a raw extra-properties escape hatch, applied last, that can override anything above, for the connection setting we didn't anticipate and shouldn't pretend we did.

Nine protocols, two philosophies

So the tool covers nine protocols now, built two different ways on purpose. Eight of them we wrote ourselves, because breaking things means sending bytes nobody else will, and there the weeks of hand-written stack pay for themselves. The ninth wraps a mature driver in a small, careful layer, because verifying a row means trusting the answer you get back, and the honest engineering was to build the thin thing well rather than the big thing badly. Different jobs, different tradeoffs, one workbench, and a chain that finally runs from the first request all the way to the row it was supposed to write.

If you need a first-class HTTP request workbench, VirtuProbe is free forever, the whole thing. The full HTTP probe, request chaining and the built-in AI assistant, with no account, no cloud, no telemetry and no expiry, on macOS, Linux and Windows. The wider integration-testing protocols, the MySQL probe covered here alongside IMAP and LDAP, are part of the Engineering tier.

Join our Discord