diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e4888..4f8c97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,80 @@ All notable changes to this project are documented in this file. Format inspired by [Keep a Changelog](https://keepachangelog.com/). Versioning follows [Semantic Versioning](https://semver.org/). Older entries live under [`changelog/`](changelog/). +## [1.4.0] — 2026/10/04 + +Built on rust-samp v3.5.0, now from crates.io. Additive on the Pawn side: four natives and one constant are new, nothing was removed or renamed, so 1.3.0 gamemodes compile unchanged. **One behaviour did change** — `OnQueryError` used to hand its arguments over in the wrong order, and now hands them over as the forward declares. Read the first entry under Fixed before upgrading if you handle that forward. + +### Added + +- **`MYSQL_SYNC` — one call blocks, on purpose.** Everything stays non-blocking by default. Writing `MYSQL_SYNC` where the callback name goes makes *that* call wait for the database and leaves the result readable on the next line, with no forward and no public: + + ```pawn + mysql_query(g_sql, "SELECT COUNT(*) AS n FROM accounts", MYSQL_SYNC); + new total = cache_get_value_name_int(0, "n"); + ``` + + It is a value and not a native because a native called `mysql_query_sync` gets copied into `OnPlayerUpdate`; a value written at the call site states the intent where the cost is paid. It could not be a new argument either: Pawn refuses any parameter after `{Float,_}:...`, and putting one before the callback would break every existing call. + + The rules that keep it honest: **refused inside a callback**, where "the current cache" already means the callback's own and a blocking result would either shadow it or be unreadable; **freed at the next server tick**, the same guarantee the threaded path gets from its callback returning, so nothing has to be freed by hand; and **refused, out loud, on every native that cannot honour it** — prepared statements, transactions, the ORM and password hashing — because otherwise the value would pass for a callback name that never fires and the call would silently become fire-and-forget. Supported on `mysql_query`, `mysql_pquery` and `mysql_query_file`, which is where schema work at start-up lives. + +- **`mysql_tls_active(connId)` and `mysql_tls_cipher(connId, dest[], max_len)`** — what the handshake actually settled on, rather than what `MYSQL_OPT_SSL` asked for. Both read a value captured once when the connection opened, so neither costs a query. An unencrypted session writes an empty cipher and returns `false`: that is the answer, and the return value separates it from an unknown connection id. + +- **`mysql_limit_set(limit, value)` and `mysql_limit_get(limit)`** — the three memory caps stop being hardcoded. `MYSQL_LIMIT_SAVED_CACHES` (1024), `MYSQL_LIMIT_RESULT_ROWS` (100000) and `MYSQL_LIMIT_ORM_STRING_LEN` (4096) can be raised by a job that genuinely needs more or lowered on a tight machine. Global rather than per connection, because the memory being protected is the server's. A value must be positive: `0` is refused rather than read as "unlimited", since unlimited is the state these exist to prevent, and lowering a cap keeps what is already held instead of discarding a gamemode's data mid-round. + +- **`${NAME}` in the connection file.** `mysql_connect_file` already kept the password out of the gamemode; now the file can name the secret instead of holding it, so a stolen copy is worth nothing on its own: + + ```ini + password = ${MYSQL_PASSWORD} + ``` + + An unset name expands to nothing and logs which one was missing — sending the literal `${MYSQL_PASSWORD}` would fail as "access denied" and send the operator looking in the wrong place. Only `${...}` is a reference, so a password containing a dollar sign keeps working. + +### Fixed + +- **`OnQueryError` delivered its five arguments in reverse.** The forward is declared `OnQueryError(errorid, error[], callback[], query[], connId)`, and a gamemode received the connection id as `errorid`, the query text as `error`, the error message as `query`, and the MySQL code as `connId`. The cause was in the dispatch: `exec_public!` pushes its arguments in reverse — the AMX stack convention — so the order listed in the call is the order the public receives, and the code listed them backwards as if it had to invert them by hand. Present since 1.0.0. **Anyone handling this forward was reading the wrong fields and should check their handler.** (#56) + +- **Requesting TLS on a loopback host ran in plaintext.** `MYSQL_OPT_SSL = 1` against `127.0.0.1` reported success while `Ssl_cipher` came back empty. The driver's `prefer_socket` defaults to true and, after the handshake, moves a *loopback* connection onto the server's unix socket — where it secures nothing. The two conditions met on the most ordinary local setup there is. Asking for TLS now switches that optimisation off; without TLS it stays, which is where it helps. Verified against MariaDB 11.8: `TLS_AES_256_GCM_SHA384` where there used to be an empty string. + +- **`rustls` 0.23.43 → 0.23.45 (RUSTSEC-2026-0285).** TLS 1.3 handshake messages were accepted across encryption level boundaries. The transcript stays authenticated, so a network attacker cannot alter or complete a handshake; the practical effect is a peer being able to send in cleartext what should have been encrypted without rustls refusing. (#41) + +- **Driver errors reached Pawn with Rust internals attached.** A missing table arrived as `MySqlError { ERROR 1146 (42S02): Table 'db.t' doesn't exist }` — the variant name wrapping the server's own message. Both the query path and the connection path now pass the server's message through on its own. (#56 and this release) + +- **`mysql_query_file` failed without telling the caller.** An unreadable file or one with no statements was a log line and a `false` return — which the callback-free form, the one the documentation recommends for schema, never sees. It now also fires `OnQueryError`, with the path in place of the query so the message names the file, and `errorid` `0`, as documented for a failure that never reached the server. (#56) + +### Changed + +- **rust-samp moves from a git tag to crates.io** (`version = "3.5.0"`). This also clears a long-standing oddity: the `v3.5.0` tag declared `version = "3.4.0"` in its own manifest, so the lockfile recorded 3.4.0 pointing at the v3.5.0 tag. The SDK's new `mainthread` module is **not** adopted yet: its `post` takes a closure with no parameters and there is no route to plugin state from a worker thread, so the plugin's own `mpsc` channel stays until `post_with` / `post_with_amx` land. + +- **`mysql` 28.0.0 → 28.0.2**, plus a large round of transitive updates. (#38, #45, and the Dependabot group) + +### CI / tooling + +- **Dependabot now tracks transitive crates** (`allow: dependency-type: all`). Almost nothing the plugin ships is in `Cargo.toml` — `rustls` arrives through `mysql`, like most of the lockfile — so the default of watching only the manifest is what let RUSTSEC-2026-0285 sit there until an unrelated pull request went red. A patched version now arrives as an ordinary lockfile bump and goes through `cargo audit` on the way in. A second group exists because a group applies to version updates unless it says `applies-to: security-updates`. (#43) + +- **The security audit also runs weekly**, and on that schedule `rustsec/audit-check` opens an issue instead of failing a pull request. An advisory published against a crate already in the lockfile arrives on the RustSec database's clock, not on ours; waiting for someone to open a PR turned that into a red check on whoever happened to be there. (#41, #43) + +- **Two tests that pin what used to be invisible.** `tests/inc_natives.rs` compares the `initialize_plugin!` list against the include in both directions, so a native registered but never declared — which builds fine and is simply unreachable from Pawn — fails the build instead. `tests/inc_encoding.rs` keeps the includes pure ASCII. rust-samp 3.5.0 can also emit the authoritative list at runtime with `SAMP_PAWN_INCLUDE=/path.inc`, which is how the comparison was checked by hand: 58 declarations plus 17 commented `raw` ones, matching the 75 the include declared. + +- **Community files the repository was missing**: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, issue templates and a pull request template. `SECURITY.md` was added with a private reporting channel and later corrected — its "supported versions" table listed ranges that matched no actual release, and is now one sentence pointing at the releases page. (#57, #58) + +### Documentation + +- **The callback is optional, and the docs now say so where people read.** Nothing forces a callback on a write: `mysql_query(conn, "UPDATE …")` is one line. That was documented in a subsection at the bottom of one page while the opening sentence and all eleven examples implied the opposite, which is where the complaint that the plugin "makes you write callbacks for everything" came from. (#55) + +- **FIFO orders callbacks, not execution.** `mysql_query` dispatches callbacks in submission order, but each statement runs on its own connection, concurrently — a `CREATE TABLE` followed by an `INSERT` races and the insert fails. The comparison table recommended `mysql_query` for exactly that. It now points at `mysql_query_file` and transactions, the two things that actually serialise, and two examples were added for the callback-free and ordering cases. (#55) + +- **Things the plugin guarantees that were never written down**: that `mysql_connect` blocks (≈10 ms locally, ≈70 ms with TLS) while every query does not; that TLS fails closed, so a server without encryption fails the connect instead of continuing in the clear; that `mysql_query_file` resolves its path against the server's working directory and not `scriptfiles/`; and that binary columns cannot travel in a Pawn string — a `VECTOR` reads back empty because its bytes start with a zero, so `VEC_ToText()` and `HEX()` are the way through. + +- **IPv6 is supported, in brackets.** `[::1]` is parsed and validated as an address; without brackets it is taken as a host name, which usually still connects but validates nothing. (#42) + +### Known advisories + +Both are informational, neither is resolvable here, and both are pinned behind the `mysql` crate: + +- **RUSTSEC-2025-0134** — `rustls-pemfile` is unmaintained. The code that runs is the maintained `rustls-pki-types`; upstream still depends on the wrapper, so there is not even an unreleased fix to wait for. +- **RUSTSEC-2026-0253** — `lru` is unsound in `LruCache::pop` if a key's `Drop` panics. The cache keys here are strings and integers, whose `Drop` does not panic. Upstream merged the bump to `lru` 0.18.2 on 2026/09/21 but has not released it; `mysql` still requires `0.16`, which a `cargo update` cannot cross. + ## [1.3.0] — 2026/09/07 Built on rust-samp v3.5.0. Additive release: no Pawn native was removed or renamed, so 1.2.0 gamemodes compile unchanged. diff --git a/CHECKLIST.md b/CHECKLIST.md index 302d2b6..327a674 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -47,7 +47,7 @@ Coverage of the MySQL R41-4 (BlueG / maddinat0r) Pawn API by **mysql_samp**. Sou | `mysql_query` | Yes (sync) | Yes (non-blocking, FIFO) | Always threaded — replaces `tquery` | | `mysql_tquery` | Yes | — | Subsumed by `mysql_query` (which is already non-blocking) | | `mysql_pquery` | Yes | Yes | Parallel, no ordering guarantee | -| `mysql_query_file` | Yes | Yes | Non-blocking, like every query here. Not transactional — see the note in [Queries](docs/queries.md) | +| `mysql_query_file` | Yes | Yes | Non-blocking by default, or blocking with `MYSQL_SYNC`. Not transactional — see the note in [Queries](docs/queries.md) | | `mysql_tquery_file` | Yes | — | Subsumed by `mysql_query_file`, which is already non-blocking | ## Cache @@ -144,23 +144,26 @@ Coverage of the MySQL R41-4 (BlueG / maddinat0r) Pawn API by **mysql_samp**. Sou | Prepared statements | `mysql_stmt_*` — values bound server-side over the binary protocol, immune to injection | | Transactions | `mysql_transaction_*` — atomic batch, rolls back on any failing step | | Argon2id password hashing | `mysql_hash_password` / `mysql_verify_password`, threaded; PHC output with embedded salt | -| TLS | rustls compiled in (`rustls-tls-ring`), CA / mutual TLS / verification toggle | +| TLS | rustls compiled in (`rustls-tls-ring`), CA / mutual TLS / verification toggle. A TLS connection is never downgraded to a unix socket | +| Configurable memory caps | `mysql_limit_set` / `mysql_limit_get` — saved caches, result rows and ORM string length | +| TLS diagnostics | `mysql_tls_active` / `mysql_tls_cipher` — what the handshake settled on, not what was asked for | | Detailed file logs | `logs/mysql.log` with timestamp, 50 MB rotation into gzipped `logs/archive/`, and `MYSQL_SAMP_LOG_*` env overrides | | Build banner | Date/time stamped by `build.rs` via `BUILD_DATE` / `BUILD_TIME` / `BUILD_YEAR` | | Connection pool | `mysql::Pool` (`Clone + Send + Sync`) for safe multi-threaded access | -| Fully non-blocking queries | Both `mysql_query` (FIFO) and `mysql_pquery` (parallel) run on worker threads | +| Non-blocking queries by default | Both `mysql_query` (FIFO) and `mysql_pquery` (parallel) run on worker threads | +| Opt-in blocking, per call | `MYSQL_SYNC` in the callback slot blocks that one call; refused inside a callback, and on every native that could not honour it | | ORM auto-cleanup | `OrmManager::destroy_by_amx` frees instances when their AMX is unloaded | | Universal SA-MP + Open Multiplayer binary | The same `.so` / `.dll` runs natively (component) or in legacy mode (`legacy_plugins`) | | Unified `on_tick` | Dispatches callbacks via `ProcessTick` (SA-MP) and `ITimersComponent` (Open Multiplayer native), no Pawn `SetTimer` required | | `mysql_format` safe truncation | Truncates at the destination buffer boundary respecting UTF-8 char boundaries; warns once per call | | Strict integer conversions | Every cross-width / sign-changing conversion goes through `TryFrom` / `From`; no silent wrap from `as` | -| 167 unit tests | Cover the entire pure surface (parser, renderer, escape modes, placeholder scanner, cache, ORM, statements, transactions, Argon2id) | +| 190 unit tests | Cover the entire pure surface (parser, renderer, escape modes, placeholder scanner, cache, ORM, statements, transactions, Argon2id) | ## Totals | Category | R41-4 | mysql_samp | |---|---|---| -| Pawn natives | — | **75** | +| Pawn natives | — | **79** | | Pawn forwards | — | **1** | | Plugin error codes (`MYSQL_ERROR_*`) | — | 9 (`MYSQL_OK` + 8) | | Connection options (`MYSQL_OPT_*`) | many | 9 (all wired) | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4235d86..9794595 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,21 @@ greps for errors reports success for a file that never compiled. every build; a change made there disappears. - **A new native touches four places:** the file under `src/natives/`, the list in `src/lib.rs`, the declaration in the `.inc.in` (in its section, with a - JavaDoc block) and an example that calls it. + JavaDoc block) and an example that calls it. `tests/inc_natives.rs` compares + the first two against the include and fails when they drift, so a forgotten + declaration stops the build instead of becoming a native Pawn cannot reach. + + To see the list the plugin actually registers, start a server with + + ```bash + SAMP_PAWN_INCLUDE=/tmp/generated.inc ./samp03svr + ``` + + and rust-samp writes a declaration for every `#[native]`. The ones declared + `raw` — our variadic natives — come out commented, since their arity is not + in the signature. It is a cross-check by hand, not a replacement for the + `.inc.in`, which carries the Pawn tags, the enums and the JavaDoc that the + generated file has no way to know about. - **Pawn sources are pure ASCII.** No accents, no typographic dashes, no curly quotes — the compiler reads bytes, and mojibake reaches the player instead of failing the build. Markdown is the exception, and the CI guard enforces the diff --git a/Cargo.lock b/Cargo.lock index a7f7088..4d365b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -678,7 +678,7 @@ dependencies = [ [[package]] name = "mysql_samp" -version = "1.3.0" +version = "1.4.0" dependencies = [ "argon2", "fern", @@ -918,8 +918,9 @@ dependencies = [ [[package]] name = "rust-samp" -version = "3.4.0" -source = "git+https://github.com/NullSablex/rust-samp.git?tag=v3.5.0#df66c770bb7c6f17b333f2dca15f8fff81f98529" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42851d4b9181287c2d563c10252854ab87abd61336f7086feb9f2c6ec9e93320" dependencies = [ "fern", "flate2", @@ -932,8 +933,9 @@ dependencies = [ [[package]] name = "rust-samp-codegen" -version = "1.4.0" -source = "git+https://github.com/NullSablex/rust-samp.git?tag=v3.5.0#df66c770bb7c6f17b333f2dca15f8fff81f98529" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6c4017948d2be2b498361740bc84baa65ee0565572c8b8a7a06dcf237d064b" dependencies = [ "proc-macro2", "quote", @@ -942,8 +944,9 @@ dependencies = [ [[package]] name = "rust-samp-sdk" -version = "3.4.0" -source = "git+https://github.com/NullSablex/rust-samp.git?tag=v3.5.0#df66c770bb7c6f17b333f2dca15f8fff81f98529" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf910ae41fe86cd09c57c440c86524f7e0e06b328df47fff836190a7d6597c84" dependencies = [ "bitflags 2.13.2", ] @@ -1420,7 +1423,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1098818..3c2ad8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mysql_samp" -version = "1.3.0" +version = "1.4.0" edition = "2024" authors = ["NullSablex "] repository = "https://github.com/NullSablex/mysql_samp" @@ -12,7 +12,7 @@ crate-type = ["cdylib", "lib"] [dependencies] # `compression` gzips rotated log archives. It pulls in flate2, which the mysql # crate already brings in via `minimal-rust` — so this costs no new dependency. -samp = { package = "rust-samp", git = "https://github.com/NullSablex/rust-samp.git", tag = "v3.5.0", features = ["compression"] } +samp = { package = "rust-samp", version = "3.5.0", features = ["compression"] } # `rustls-tls-ring` is what actually compiles a TLS backend in. Without it the # crate's `make_secure` is a `panic!` stub, so MYSQL_OPT_SSL would abort the # connection instead of encrypting it. `ring` rather than the default diff --git a/README.md b/README.md index dcbd738..b99873e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ The same binary loads on SA-MP and on Open Multiplayer — natively as a compone ### Highlights - **Zero external dependencies** — no `libmysqlclient`, no OpenSSL. The MySQL protocol and TLS (via rustls) are compiled directly into the binary. -- **All queries are non-blocking** — `mysql_query` runs on background threads and the server never stalls. **That does not mean a callback for everything:** the callback is optional, so a write is one line (`mysql_query(conn, "UPDATE …")`) with no forward and no public. You pass a callback only to read a result back. +- **Non-blocking by default, blocking when you say so** — `mysql_query` runs on background threads, so the server does not stall. **That does not mean a callback for everything:** the callback is optional, so a write is one line (`mysql_query(conn, "UPDATE …")`) with no forward and no public. You pass a callback only to read a result back — or `MYSQL_SYNC`, which makes that one call block and hands you the result on the next line, for start-up work where waiting is the simpler answer. - **Connection pool** — automatic reuse through `mysql::Pool`, thread-safe by design, with a configurable ceiling. - **Credentials out of the source** — `mysql_connect_file` reads them from a config file your repository does not have to carry. - **Schema scripts** — `mysql_query_file` runs a `.sql` file's statements in order, non-blocking. diff --git a/docs/api-reference.md b/docs/api-reference.md index 6cfdabf..c5bdb50 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,6 +1,6 @@ # API reference -Full table of every native and forward exposed by `mysql_samp`. The plugin registers **75 natives** and **1 forward**. +Full table of every native and forward exposed by `mysql_samp`. The plugin registers **79 natives** and **1 forward**. Source of truth: [`include/mysql_samp.inc.in`](https://github.com/NullSablex/mysql_samp/blob/master/include/mysql_samp.inc.in) and [`src/lib.rs`](https://github.com/NullSablex/mysql_samp/blob/master/src/lib.rs). @@ -14,6 +14,15 @@ Every name below is listed in its snake_case form, as declared by `` The literal is regenerated by `build.rs` from `CARGO_PKG_VERSION` on every build, so the value shipped in both includes always tracks the version declared in [`Cargo.toml`](https://github.com/NullSablex/mysql_samp/blob/master/Cargo.toml). This page does not hardcode the current number on purpose — check `Cargo.toml` or the include itself. +```pawn +#define MYSQL_SYNC "!sync" +``` + +Written where a callback name goes, it makes that one call blocking — see +[Queries](queries.md#mysql_sync--making-one-call-block-on-purpose). Accepted by +`mysql_query`, `mysql_pquery` and `mysql_query_file`; every other native that +takes a callback refuses it out loud rather than mistaking it for one. + ### Connection options | Constant | Value | @@ -72,6 +81,10 @@ The literal is regenerated by `build.rs` from `CARGO_PKG_VERSION` on every build | `mysql_connect(const host[], const user[], const password[], const database[], options = 0)` | `int` | Connection id (`>= 1`) or `0` on failure | | `mysql_close(connId)` | `bool` | `false` if `connId` does not exist | | `mysql_status(connId, dest[], max_len = sizeof(dest))` | `bool` | Writes a subset of `SHOW GLOBAL STATUS` into `dest` | +| `mysql_tls_active(connId)` | `bool` | Whether the session is encrypted, as the handshake settled it | +| `mysql_tls_cipher(connId, dest[], max_len = sizeof(dest))` | `bool` | Writes the negotiated cipher suite; empty when unencrypted | +| `mysql_limit_set(limit, value)` | `bool` | Raises or lowers a memory cap (`MYSQL_LIMIT_*`) | +| `mysql_limit_get(limit)` | `int` | Reads a memory cap | ### Error (2) diff --git a/docs/benchmark.md b/docs/benchmark.md index a6961bd..1b33944 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -101,7 +101,7 @@ The gamemode-visible semantics are equivalent (N queries, callbacks in submissio | Memory leak | **Impossible** (cache managed automatically) | Possible without `cache_delete()` | | Runtime dependencies | **None** | MySQL C Connector + Boost | | Open issues | New (active development) | **50+ open issues** on GitHub | -| Synchronous blocking query | **Removed** (never blocks the server tick) | Exists (`mysql_query` blocks) | +| Synchronous blocking query | **Opt-in per call** (`MYSQL_SYNC`), never the default | The default (`mysql_query` blocks) | ## 1. Memory safety diff --git a/docs/cache.md b/docs/cache.md index 14a8a06..8f072a1 100644 --- a/docs/cache.md +++ b/docs/cache.md @@ -244,11 +244,38 @@ stock DropSavedData() `cache_delete` also clears the manual override if the deleted id is currently active. `cache_unset_active` returns `false` if there is no manual override to clear (the stack-top behavior continues normally). +## Binary columns + +Every value in the cache is a string, and a Pawn string ends at the first NUL +byte. Binary data is therefore not readable as it is stored: a `BLOB` comes +back mangled by the conversion to text, and anything whose bytes start with a +zero — a `VECTOR`, most packed formats — comes back **empty**. + +Convert on the SQL side, where the server can do it properly: + +```pawn +// BLOB as hex text +mysql_query(g_mysql, "SELECT HEX(data) AS data FROM files WHERE id = 1", "OnFile"); + +// MySQL 9 / MariaDB 11.7+ VECTOR as text, and distances as plain numbers +mysql_query(g_mysql, + "SELECT VEC_ToText(emb) AS emb, \ + VEC_DISTANCE_EUCLIDEAN(emb, VEC_FromText('[1,2,3]')) AS dist \ + FROM items ORDER BY dist LIMIT 5", "OnNearest"); +``` + +Both reach Pawn as ordinary strings and numbers. There is nothing to add on +the plugin side: the work belongs in the query, and the result is a value Pawn +can actually hold. + ## Limits -| Resource | Limit | What happens when hit | +The caps below are the defaults. They are configurable — +see [memory limits](options.md#memory-limits). + +| Resource | Default | What happens when hit | |---|---|---| | Saved caches | 1 024 | `cache_save()` returns `0`, warning logged | | Rows per query result | 100 000 | Extra rows are drained from the protocol but discarded; one warning is logged | -The 100k-row limit prevents a single runaway `SELECT *` from blowing the server's memory. +The row cap prevents a single runaway `SELECT *` from blowing the server's memory. diff --git a/docs/connection.md b/docs/connection.md index 922e282..ef3b96c 100644 --- a/docs/connection.md +++ b/docs/connection.md @@ -16,7 +16,20 @@ native mysql_connect(const host[], const user[], const password[], const databas | `database` | string | Default schema for the connection | | `options` | int | Options handle from `mysql_options_new`. `0` means "use defaults" | -**Returns:** the connection id (`>= 1`) on success, `0` on failure. On failure the global error state is populated and `mysql_errno(0)` / `mysql_error(0, …)` describe the cause. +**Returns:** the connection id (`>= 1`) on success, `0` on failure. + +!!! warning "This one native does block" + + Every *query* in the plugin is non-blocking, but `mysql_connect` is not: + it opens a connection and waits for the handshake before returning the id. + Measured against a local MariaDB it costs around 10 ms, and around 70 ms + with TLS; a database on another host, or one that is not answering, costs + whatever the network and `MYSQL_OPT_CONNECT_TIMEOUT` say. + + That is fine where it belongs — `OnGameModeInit`, before anyone is + connected. It is not fine in a player callback: reconnecting there freezes + every player for as long as the handshake takes. Connect once at startup + and keep the id. On failure the global error state is populated and `mysql_errno(0)` / `mysql_error(0, …)` describe the cause. ### TCP example @@ -62,6 +75,41 @@ Two things to know: - **The server has to be listening on IPv6.** MySQL binds IPv4 only by default; it needs `bind-address = ::` (or a specific IPv6 address) in the server configuration. A refused connection to `[::1]` is usually this, not the plugin. - **TLS to an IP literal needs a certificate issued for that IP** (an `iPAddress` entry in the certificate's SAN), which is uncommon — certificates are normally issued for names. If you use `MYSQL_OPT_SSL` with certificate verification on, connect by hostname, or expect verification to fail. +## mysql_tls_active + +```pawn +native bool:mysql_tls_active(connId); +``` + +Whether the session is encrypted, as the handshake settled it — not whether +`MYSQL_OPT_SSL` was asked for. The option is the request; this is the answer. + +## mysql_tls_cipher + +```pawn +native bool:mysql_tls_cipher(connId, dest[], max_len = sizeof(dest)); +``` + +Writes the cipher suite the session negotiated, for example +`TLS_AES_256_GCM_SHA384`. An unencrypted session writes an empty string and +returns `false` — that is the answer, not an error; the return value is what +separates it from an unknown connection id. + +Both read a value captured once, on the connection the handshake opened, so +neither costs a query. + +```pawn +new cipher[64]; +if (mysql_tls_cipher(g_mysql, cipher)) + printf("[MySQL] encrypted with %s", cipher); +else + print("[MySQL] NOT encrypted"); +``` + +Worth checking at startup on a server that is supposed to require TLS: a +misconfiguration that leaves the session in plaintext looks exactly like a +working connection from every other angle. + ### Unix socket If `host` starts with `/`, the plugin connects via the local socket and the configured port is ignored. @@ -125,6 +173,38 @@ Format: - Only the first `=` splits the line, so a password may contain `=`. - `host`, `user` and `database` are required. `password` may be absent or empty — a local socket account often has none. - Unknown keys are ignored, so a file shared with another tool still works. +- `${NAME}` in a value is replaced by that environment variable. + +### Keeping the secret out of the file as well + +`mysql_connect_file` keeps the password out of the gamemode. `${NAME}` takes +the next step and keeps it out of the file, which is what makes a stolen copy +of the file worth nothing: + +```ini +host = 127.0.0.1 +user = samp +password = ${MYSQL_PASSWORD} +database = samp_server +``` + +The operator exports `MYSQL_PASSWORD` once — in the service unit, the shell +that starts the server, or the container's environment — and the file only +names the secret. + +- **An unset name expands to nothing** and logs which name was missing. Sending + the literal `${MYSQL_PASSWORD}` to the server instead would fail with a + confusing "access denied"; an empty password fails for a reason you can read. +- **A bare `$NAME` is left alone**, so a password that contains a dollar sign + keeps working. Only `${...}` is a reference. +- **It works in any value**, not just the password, and more than one fits in + the same value. + +This is worth more than encrypting the file. A key that the server must be able +to read at startup has to live somewhere the server can reach, so an encrypted +file plus its key is the same secret in two pieces. Naming an environment +variable removes the secret from the repository and from the deployed files +outright. Connection **options** are not part of the file — they stay with `mysql_options_new` and the second parameter, so there is one place to look for tuning: diff --git a/docs/index.md b/docs/index.md index de6a9d5..fe0a87f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # mysql_samp -MySQL plugin for SA-MP and Open Multiplayer, written entirely in Rust. Non-blocking queries with FIFO ordering, a result cache, an ORM, zero external runtime dependencies. +MySQL plugin for SA-MP and Open Multiplayer, written entirely in Rust. Non-blocking queries with FIFO ordering — plus opt-in blocking, per call, when start-up work is simpler that way — a result cache, an ORM, zero external runtime dependencies. !!! info "Not affiliated" This is an independent, community-maintained project. It is **not** diff --git a/docs/migration-changes.md b/docs/migration-changes.md index d167419..6c17d6d 100644 --- a/docs/migration-changes.md +++ b/docs/migration-changes.md @@ -61,11 +61,11 @@ mysql_query(g_mysql, query, "OnLoad", "i", playerid); | R41-4 | mysql_samp | Behavior | |---|---|---| -| `Cache:mysql_query(handle, query, use_cache)` | *removed* | The synchronous version blocked the server tick | +| `Cache:mysql_query(handle, query, use_cache)` | `mysql_query(connId, query, MYSQL_SYNC)` | Blocking is opt-in per call instead of a native of its own | | `mysql_tquery(handle, query, cb, fmt, ...)` | `mysql_query(connId, query, cb, fmt, ...)` | Threaded, FIFO ordering (same as before) | | `mysql_pquery(handle, query, cb, fmt, ...)` | `mysql_pquery(connId, query, cb, fmt, ...)` | Threaded, no order (same as before) | -Every query is now non-blocking. The cache is only valid inside the callback (or after `cache_set_active` on a saved id). +Every query is non-blocking unless the call is given `MYSQL_SYNC`. With a callback the cache is valid inside it; with `MYSQL_SYNC` it is valid for the rest of the frame; either way `cache_save` keeps it longer. ## Connection diff --git a/docs/migration.md b/docs/migration.md index 6a7c107..de5cf62 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -30,7 +30,7 @@ Remove every `MySQL:`, `Cache:`, `ORM:`, `MySQLOpt:`, `E_ORM_ERROR:` and `E_MYSQ | R41-4 | mysql_samp | Notes | |---|---|---| -| `Cache:mysql_query(handle, query, use_cache)` | *removed* | The sync version blocked the server tick — removed by design | +| `Cache:mysql_query(handle, query, use_cache)` | `mysql_query(connId, query, MYSQL_SYNC)` | No longer a native of its own, since one that exists gets copied into player callbacks. Writing `MYSQL_SYNC` where the callback goes blocks that one call and leaves the result readable on the next line, which is the R41-4 shape. Refused inside a callback | | `mysql_tquery(handle, query, cb, fmt, ...)` | `mysql_query(connId, query, cb, fmt, ...)` | Same behavior (threaded, FIFO) | | `mysql_pquery(handle, query, cb, fmt, ...)` | `mysql_pquery(connId, query, cb, fmt, ...)` | Same behavior (threaded, no order) | | `mysql_query_file(file)` / `mysql_tquery_file(file)` | *removed* | Not supported | diff --git a/docs/options.md b/docs/options.md index 4a4244b..627e5b3 100644 --- a/docs/options.md +++ b/docs/options.md @@ -107,6 +107,8 @@ Setting only one of the two logs a warning and the client certificate is ignored `MYSQL_OPT_SSL` has no effect when the host is a unix socket (a path starting with `/`) — there is nothing to encrypt on a local socket. Use a TCP host: a hostname, an IPv4 address, or an IPv6 address in brackets (`[::1]`). +A loopback TCP host such as `127.0.0.1` stays on TCP when TLS is on. The driver would otherwise move a loopback connection to the server's unix socket after the handshake, quietly dropping the encryption; asking for TLS switches that optimisation off. Verify it rather than assume it — [`mysql_tls_active`](connection.md#mysql_tls_active) reports what the handshake settled on. + #### Disabling verification ```pawn @@ -117,6 +119,51 @@ This accepts **any** certificate and skips the hostname check. Traffic stays enc Reach for `MYSQL_OPT_SSL_CA` instead: pointing at the CA file is the correct fix for a self-signed server, and it keeps verification intact. +## Memory limits + +The options above configure a connection. These configure the plugin, and they +are global for the same reason: the memory being protected is the server's, and +every connection shares it. + +```pawn +native bool:mysql_limit_set(limit, value); +native mysql_limit_get(limit); +``` + +| Constant | Default | Caps | +|---|---|---| +| `MYSQL_LIMIT_SAVED_CACHES` | 1024 | Caches held by `cache_save` at once | +| `MYSQL_LIMIT_RESULT_ROWS` | 100000 | Rows a single result set may carry | +| `MYSQL_LIMIT_ORM_STRING_LEN` | 4096 | Bytes an ORM string variable may bind | + +They exist so a gamemode cannot exhaust the server's memory by accident — a +query that matches a million rows, a loop that saves a cache every tick. The +defaults suit an ordinary game server; raise one when a job legitimately needs +more, lower one to be stricter. + +```pawn +// A report that genuinely reads a large table. +mysql_limit_set(MYSQL_LIMIT_RESULT_ROWS, 500000); + +// Back to the default when it is done. +mysql_limit_set(MYSQL_LIMIT_RESULT_ROWS, 100000); +``` + +Things worth knowing: + +- **A value must be positive.** `0` is refused rather than read as "unlimited", + because a cap of zero would refuse every save and every query — and + unlimited is the state these exist to prevent. The native returns `false` + and logs why, leaving the cap as it was. +- **Hitting the row cap truncates, it does not fail.** The query succeeds, the + cache holds the first N rows and a warning names the cap. A script that must + distinguish "all of it" from "the first N" should count with SQL. +- **Lowering a cap keeps what is already held.** Existing saved caches stay; + the next `cache_save` over the new limit is the one refused. Nothing is + thrown away mid-round. +- **`mysql_limit_get` reads the current value**, which is how a script restores + what it changed instead of hardcoding the default. + ## Setting a string option on an int-only option (or vice-versa) The plugin rejects mismatched setters: diff --git a/docs/queries.md b/docs/queries.md index c20bd88..bb8ae7b 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -1,6 +1,13 @@ # Queries -Every query in mysql_samp is **non-blocking**: the statement runs on a worker thread and the result reaches your script on a later tick. The server never freezes waiting for the database. +Every query in mysql_samp is **non-blocking by default**: the statement runs on a worker thread and the result reaches your script on a later tick, so the server does not freeze waiting for the database. + +Two things do block, and both are deliberate and named: + +- [`mysql_connect`](connection.md#mysql_connect) waits for the handshake, which is why it belongs in `OnGameModeInit` and not in a player callback. +- A call that is given [`MYSQL_SYNC`](#mysql_sync--making-one-call-block-on-purpose) in place of a callback name blocks on purpose, for the cases where waiting is worth more than the tick — schema at start-up, a migration, a one-off command. + +Nothing blocks unless you wrote one of those two, and the second one says so at the call site. **The callback is optional.** Non-blocking does not mean "you have to write a callback for everything" — every native on this page takes `callback` with a default of `""`, and passing nothing means fire-and-forget: @@ -100,6 +107,76 @@ mysql_query(g_mysql, "UPDATE players SET last_login = NOW() WHERE id = 1"); A failure still fires `OnQueryError`. +## MYSQL_SYNC — making one call block, on purpose + +There is no synchronous native, and there will not be one: a native named +`mysql_query_sync` is copied around a gamemode until something blocks inside +`OnPlayerUpdate`. What exists instead is a value you write **where the callback +name goes**, which turns that one call — and only that one — into a blocking +call: + +```pawn +mysql_query(g_mysql, "SELECT COUNT(*) AS n FROM accounts", MYSQL_SYNC); +new total = cache_get_value_name_int(0, "n"); +``` + +The call returns when the database answers, and the result is the current cache +on the very next line. No forward, no public, and the value is available in the +same scope that asked for it. + +### When it is worth it + +Only when nobody is online to feel it, and the alternative is worse: + +- **Schema and migrations at start-up.** The strongest case, because each + statement depends on the previous one having finished, and threaded calls run + concurrently: + + ```pawn + public OnGameModeInit() + { + g_mysql = mysql_connect(...); + mysql_query_file(g_mysql, "schema.sql", MYSQL_SYNC); + // From here on the tables exist. No callback chain, no race. + return 1; + } + ``` + +- **A one-off console or admin command** where reading the answer inline keeps + the code honest and the freeze is a few milliseconds. + +### When it is wrong + +**Never in anything that runs per player or per tick.** `OnPlayerConnect`, +`OnPlayerText`, a timer: the whole server stops for as long as the database +takes. On a local database that is a few milliseconds; on a remote one, or one +under load, it is the difference between a server and a slideshow. A query that +takes 2 s costs every player 2 s — measured, not estimated. + +### The rules + +- **It is refused inside a callback.** There, the current cache already belongs + to the callback, and a blocking result would either shadow it or be + unreadable — both surprises. The call is not run, `false` comes back and the + reason is logged. Run it before the callback, or use a threaded call. +- **The result lives for the current frame.** It is freed on the next server + tick, the same way a threaded result is freed when its callback returns. + Read it on the lines right after the call — which is the whole point — and + use `cache_save()` if it has to last longer. Nothing to free by hand: the + plugin does not ask you to remember a `cache_delete`, and this does not + become the exception. +- **One at a time within the frame.** A second blocking call replaces the + first, and `cache_unset_active()` drops it early. +- **Errors behave the same as threaded ones.** `false` comes back, + `mysql_errno` is set and `OnQueryError` fires, with `MYSQL_SYNC` as the + callback name so the handler can tell where it came from. +- **Supported on `mysql_query`, `mysql_pquery` and `mysql_query_file`.** On + `mysql_pquery` it behaves exactly like `mysql_query`, since a blocking call + has nothing to run in parallel with. Everywhere else — prepared statements, + transactions, the ORM, password hashing — it is refused with a message + naming what to use instead, so it can never be mistaken for a callback that + silently never fires. + ## mysql_pquery — parallel, no ordering ```pawn @@ -274,6 +351,8 @@ native bool:mysql_query_file(connId, const path[], const callback[] = "", const Reads a file and runs its statements in order on one connection, non-blocking like every other query here. Useful for schema setup and migrations. +The path is resolved against the **server's working directory** — the folder holding `samp03svr` or `omp-server` — not against `scriptfiles/`. A path that does not resolve reaches `OnQueryError` with the name it tried, and `errorid` `0`, since nothing was sent to the server. + ```pawn mysql_query_file(g_mysql, "scripts/schema.sql", "OnSchemaReady"); ``` diff --git a/docs/security.md b/docs/security.md index 38afd62..4550f62 100644 --- a/docs/security.md +++ b/docs/security.md @@ -108,18 +108,24 @@ Both natives are non-blocking, and both return `false` if the work queue is satu TLS was checked against a real MariaDB 11.8, not just by reading code: the server's completed-handshake counter advances for each connection, and with verification at its default a self-signed certificate is rejected outright (`invalid peer certificate: UnknownIssuer`) instead of quietly falling back to plaintext. +**Asking for TLS fails closed.** If the server does not offer encryption, the driver returns `TlsNotSupported` and the connect fails: `mysql_connect` gives back `0`. It never continues in plaintext on a connection that asked to be encrypted, so `MYSQL_OPT_SSL` is a requirement and not a preference. + One limitation worth knowing: **TLS is not available over a unix socket.** A host starting with `/` connects over a socket, where there is nothing to encrypt and `MYSQL_OPT_SSL` has no effect. Use a TCP host if you need encryption. +A loopback TCP host is a TCP host and stays encrypted. The driver would normally move a loopback connection onto the server's socket once the handshake is done — dropping the encryption with it — and asking for TLS switches that off. Until 1.3.x it did not, so `MYSQL_OPT_SSL` against `127.0.0.1` ran in plaintext while reporting success. Confirm rather than assume, with [`mysql_tls_active`](connection.md#mysql_tls_active). + > Plugin versions before 1.2.0 accepted `MYSQL_OPT_SSL` but shipped no TLS backend at all, so connections were never encrypted. If you relied on it, treat those credentials as exposed. ## Resource limits -| Resource | Limit | Why | +| Resource | Default | Why | |---|---|---| | Saved caches | 1 024 | Prevents memory growth from misused `cache_save` (CWE-770) | | Rows per single result | 100 000 | Caps the worst-case allocation for a single query (CWE-770) | | `orm_addvar_string` `max_len` | 1..=4 096 | Bounds the size of writes into the AMX heap when `orm_apply_cache` copies a column (CWE-787) | +These are defaults, not hard ceilings: [`mysql_limit_set`](options.md#memory-limits) raises or lowers each one. A script can therefore widen a cap it genuinely needs — and can also narrow one. What it cannot do is remove a cap: zero is refused, and there is no "unlimited" value. + When a limit is hit: - the native returns its failure sentinel (`false`, `0`), diff --git a/examples/mysql.ini.example b/examples/mysql.ini.example index d6877b7..1536081 100644 --- a/examples/mysql.ini.example +++ b/examples/mysql.ini.example @@ -14,6 +14,10 @@ # - host takes a hostname, an IPv4, an IPv6 in brackets ([::1]), or a # unix socket path starting with /. The port is NOT part of host. # - Unknown keys are ignored, so sharing this file with another tool is fine. +# - ${NAME} in a value is replaced by that environment variable, which is how +# the file can NAME a secret instead of holding one. An unset name expands +# to nothing and says so in the log. A bare $NAME is left alone, so a +# password containing a dollar sign still works. # # Connection OPTIONS (port, SSL, timeouts, pool size) are NOT read from here. # They stay with mysql_options_new so there is one place to look for tuning. @@ -22,3 +26,8 @@ host = 127.0.0.1 user = samp password = change-me database = samp_server + +# Better: keep the secret out of the file too. The operator exports +# MYSQL_PASSWORD once, and this file is worth nothing on its own. +# +# password = ${MYSQL_PASSWORD} diff --git a/include/mysql_samp.inc b/include/mysql_samp.inc index d198ccc..4bfbe0a 100644 --- a/include/mysql_samp.inc +++ b/include/mysql_samp.inc @@ -1,5 +1,5 @@ /* - * mysql_samp v1.3.0 - MySQL plugin for SA-MP and Open Multiplayer. + * mysql_samp v1.4.0 - MySQL plugin for SA-MP and Open Multiplayer. * Author: NullSablex * Repository: https://github.com/NullSablex/mysql_samp * License: GPL-3.0 @@ -10,7 +10,7 @@ #endif #define _mysql_samp_included -#define MYSQL_SAMP_VERSION "1.3.0" +#define MYSQL_SAMP_VERSION "1.4.0" // Connection options enum { @@ -31,6 +31,41 @@ enum { MYSQL_OPT_POOL_SIZE, } +/** + * Pass this where a callback name goes to make that ONE call blocking. + * + * Everything in this plugin is non-blocking by default, and that is the right + * default: a query that waits freezes every player. Sometimes waiting is worth + * it anyway - schema work in OnGameModeInit, a migration, a one-off console + * command - and then the alternative is chaining callbacks for something that + * runs once. + * + * The call runs on the server thread and returns when the database answers. + * The result is the current cache right after it, so the cache getters read it + * on the very next line, with no forward and no public. + * + * The result is freed at the next server tick, so read it on the lines right + * after the call and save it if it has to last longer. A second blocking call + * within the same frame replaces the first. A failure returns false and fires + * OnQueryError, the same as the threaded path. See the documentation for + * worked examples. + * + * NEVER use it in a callback that runs per player or per tick. The whole + * server stops for as long as the database takes, and that is visible to + * everyone online. + */ +#define MYSQL_SYNC "!sync" + +// Memory limits, for mysql_limit_set / mysql_limit_get +enum { + // Saved caches held by cache_save at once. Default 1024. + MYSQL_LIMIT_SAVED_CACHES = 0, + // Rows a single result set may carry. Default 100000. + MYSQL_LIMIT_RESULT_ROWS, + // Bytes an ORM string variable may bind. Default 4096. + MYSQL_LIMIT_ORM_STRING_LEN, +} + // Error codes enum { MYSQL_OK = 0, @@ -142,6 +177,62 @@ native bool:mysql_close(connId); */ native bool:mysql_status(connId, dest[], max_len = sizeof(dest)); +/** + * Tells whether the session is encrypted, as the handshake settled it. + * + * This is not the same as having asked for TLS with MYSQL_OPT_SSL: the option + * is the request, and the answer is what the server and the client agreed on. + * Checking it is how a gamemode verifies its own deployment instead of + * trusting the option it passed. + * + * @param connId connection id + * @return true when the session is encrypted, false when it is not, when the + * cipher could not be read, or when the id is unknown + */ +native bool:mysql_tls_active(connId); + +/** + * Writes the cipher suite the session negotiated into dest. + * + * Writes an empty string when the session is not encrypted - that is the + * answer, not an error. The return value separates it from an unknown id. + * + * @param connId connection id + * @param dest destination buffer + * @param max_len size of dest + * @return true when a cipher was written, false when the session is not + * encrypted or the id is unknown + */ +native bool:mysql_tls_cipher(connId, dest[], max_len = sizeof(dest)); + +// Limits + +/** + * Raises or lowers one of the memory caps. + * + * The caps exist so a gamemode cannot exhaust the server's memory by accident + * - a query matching a million rows, a loop saving a cache per tick. They are + * global, not per connection: the memory being protected is shared. + * + * Lowering a cap below what is already held keeps what is there and refuses + * the next one, so nothing is thrown away mid-round. + * + * @param limit one of the MYSQL_LIMIT_* constants + * @param value the new cap, which must be positive. 0 is refused: it would + * refuse everything, and there is no "unlimited" + * @return true when the cap was changed, false on an unknown limit or a value + * that is not positive + */ +native bool:mysql_limit_set(limit, value); + +/** + * Reads a cap, to report it or to restore it later. + * + * @param limit one of the MYSQL_LIMIT_* constants + * @return the current cap, or 0 when the limit is unknown + */ +native mysql_limit_get(limit); + // Error /** diff --git a/include/mysql_samp.inc.in b/include/mysql_samp.inc.in index 402b58b..63f13fd 100644 --- a/include/mysql_samp.inc.in +++ b/include/mysql_samp.inc.in @@ -31,6 +31,41 @@ enum { MYSQL_OPT_POOL_SIZE, } +/** + * Pass this where a callback name goes to make that ONE call blocking. + * + * Everything in this plugin is non-blocking by default, and that is the right + * default: a query that waits freezes every player. Sometimes waiting is worth + * it anyway - schema work in OnGameModeInit, a migration, a one-off console + * command - and then the alternative is chaining callbacks for something that + * runs once. + * + * The call runs on the server thread and returns when the database answers. + * The result is the current cache right after it, so the cache getters read it + * on the very next line, with no forward and no public. + * + * The result is freed at the next server tick, so read it on the lines right + * after the call and save it if it has to last longer. A second blocking call + * within the same frame replaces the first. A failure returns false and fires + * OnQueryError, the same as the threaded path. See the documentation for + * worked examples. + * + * NEVER use it in a callback that runs per player or per tick. The whole + * server stops for as long as the database takes, and that is visible to + * everyone online. + */ +#define MYSQL_SYNC "!sync" + +// Memory limits, for mysql_limit_set / mysql_limit_get +enum { + // Saved caches held by cache_save at once. Default 1024. + MYSQL_LIMIT_SAVED_CACHES = 0, + // Rows a single result set may carry. Default 100000. + MYSQL_LIMIT_RESULT_ROWS, + // Bytes an ORM string variable may bind. Default 4096. + MYSQL_LIMIT_ORM_STRING_LEN, +} + // Error codes enum { MYSQL_OK = 0, @@ -142,6 +177,62 @@ native bool:mysql_close(connId); */ native bool:mysql_status(connId, dest[], max_len = sizeof(dest)); +/** + * Tells whether the session is encrypted, as the handshake settled it. + * + * This is not the same as having asked for TLS with MYSQL_OPT_SSL: the option + * is the request, and the answer is what the server and the client agreed on. + * Checking it is how a gamemode verifies its own deployment instead of + * trusting the option it passed. + * + * @param connId connection id + * @return true when the session is encrypted, false when it is not, when the + * cipher could not be read, or when the id is unknown + */ +native bool:mysql_tls_active(connId); + +/** + * Writes the cipher suite the session negotiated into dest. + * + * Writes an empty string when the session is not encrypted - that is the + * answer, not an error. The return value separates it from an unknown id. + * + * @param connId connection id + * @param dest destination buffer + * @param max_len size of dest + * @return true when a cipher was written, false when the session is not + * encrypted or the id is unknown + */ +native bool:mysql_tls_cipher(connId, dest[], max_len = sizeof(dest)); + +// Limits + +/** + * Raises or lowers one of the memory caps. + * + * The caps exist so a gamemode cannot exhaust the server's memory by accident + * - a query matching a million rows, a loop saving a cache per tick. They are + * global, not per connection: the memory being protected is shared. + * + * Lowering a cap below what is already held keeps what is there and refuses + * the next one, so nothing is thrown away mid-round. + * + * @param limit one of the MYSQL_LIMIT_* constants + * @param value the new cap, which must be positive. 0 is refused: it would + * refuse everything, and there is no "unlimited" + * @return true when the cap was changed, false on an unknown limit or a value + * that is not positive + */ +native bool:mysql_limit_set(limit, value); + +/** + * Reads a cap, to report it or to restore it later. + * + * @param limit one of the MYSQL_LIMIT_* constants + * @return the current cap, or 0 when the limit is unknown + */ +native mysql_limit_get(limit); + // Error /** diff --git a/include/mysql_samp_omp.inc b/include/mysql_samp_omp.inc index 7401a0e..6e718bf 100644 --- a/include/mysql_samp_omp.inc +++ b/include/mysql_samp_omp.inc @@ -16,7 +16,7 @@ #define _mysql_samp_omp_included -#define MYSQL_SAMP_VERSION "1.3.0" +#define MYSQL_SAMP_VERSION "1.4.0" // Connection options enum { @@ -37,6 +37,41 @@ enum { MYSQL_OPT_POOL_SIZE, } +/** + * Pass this where a callback name goes to make that ONE call blocking. + * + * Everything in this plugin is non-blocking by default, and that is the right + * default: a query that waits freezes every player. Sometimes waiting is worth + * it anyway - schema work in OnGameModeInit, a migration, a one-off console + * command - and then the alternative is chaining callbacks for something that + * runs once. + * + * The call runs on the server thread and returns when the database answers. + * The result is the current cache right after it, so the cache getters read it + * on the very next line, with no forward and no public. + * + * The result is freed at the next server tick, so read it on the lines right + * after the call and save it if it has to last longer. A second blocking call + * within the same frame replaces the first. A failure returns false and fires + * OnQueryError, the same as the threaded path. See the documentation for + * worked examples. + * + * NEVER use it in a callback that runs per player or per tick. The whole + * server stops for as long as the database takes, and that is visible to + * everyone online. + */ +#define MYSQL_SYNC "!sync" + +// Memory limits, for mysql_limit_set / mysql_limit_get +enum { + // Saved caches held by cache_save at once. Default 1024. + MYSQL_LIMIT_SAVED_CACHES = 0, + // Rows a single result set may carry. Default 100000. + MYSQL_LIMIT_RESULT_ROWS, + // Bytes an ORM string variable may bind. Default 4096. + MYSQL_LIMIT_ORM_STRING_LEN, +} + // Error codes enum { MYSQL_OK = 0, @@ -148,6 +183,62 @@ native bool:MySQL_Close(connId) = mysql_close; */ native bool:MySQL_Status(connId, dest[], max_len = sizeof(dest)) = mysql_status; +/** + * Tells whether the session is encrypted, as the handshake settled it. + * + * This is not the same as having asked for TLS with MYSQL_OPT_SSL: the option + * is the request, and the answer is what the server and the client agreed on. + * Checking it is how a gamemode verifies its own deployment instead of + * trusting the option it passed. + * + * @param connId connection id + * @return true when the session is encrypted, false when it is not, when the + * cipher could not be read, or when the id is unknown + */ +native bool:MySQL_TlsActive(connId) = mysql_tls_active; + +/** + * Writes the cipher suite the session negotiated into dest. + * + * Writes an empty string when the session is not encrypted - that is the + * answer, not an error. The return value separates it from an unknown id. + * + * @param connId connection id + * @param dest destination buffer + * @param max_len size of dest + * @return true when a cipher was written, false when the session is not + * encrypted or the id is unknown + */ +native bool:MySQL_TlsCipher(connId, dest[], max_len = sizeof(dest)) = mysql_tls_cipher; + +// Limits + +/** + * Raises or lowers one of the memory caps. + * + * The caps exist so a gamemode cannot exhaust the server's memory by accident + * - a query matching a million rows, a loop saving a cache per tick. They are + * global, not per connection: the memory being protected is shared. + * + * Lowering a cap below what is already held keeps what is there and refuses + * the next one, so nothing is thrown away mid-round. + * + * @param limit one of the MYSQL_LIMIT_* constants + * @param value the new cap, which must be positive. 0 is refused: it would + * refuse everything, and there is no "unlimited" + * @return true when the cap was changed, false on an unknown limit or a value + * that is not positive + */ +native bool:MySQL_LimitSet(limit, value) = mysql_limit_set; + +/** + * Reads a cap, to report it or to restore it later. + * + * @param limit one of the MYSQL_LIMIT_* constants + * @return the current cap, or 0 when the limit is unknown + */ +native MySQL_LimitGet(limit) = mysql_limit_get; + // Error /** diff --git a/src/cache.rs b/src/cache.rs index a67e051..31b711b 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -2,9 +2,6 @@ use std::collections::HashMap; use crate::logger::Logger; -/// Maximum number of saved caches allowed to prevent memory exhaustion. -const MAX_SAVED_CACHES: usize = 1024; - /// A single row: Vec of Option where None represents SQL NULL. pub type CacheRow = Vec>; @@ -160,6 +157,15 @@ pub struct CacheManager { saved: HashMap, next_saved_id: i32, manual_active: Option, + /// Result of the last blocking call, which has no callback to be active + /// inside of. + /// + /// It cannot go on `active_stack`: the stack is balanced by one push per + /// callback and one pop when it returns, so an extra push made from inside + /// a callback would be popped by that callback and leave the entry below + /// it stranded. A slot of its own keeps the stack's invariant intact and + /// bounds a blocking call to exactly one live result. + sync_active: Option, } impl CacheManager { @@ -169,6 +175,7 @@ impl CacheManager { saved: HashMap::new(), next_saved_id: 1, manual_active: None, + sync_active: None, } } @@ -190,6 +197,14 @@ impl CacheManager { if let Some(id) = self.manual_active { return self.saved.get_mut(&id); } + // The stack wins over the blocking slot, and it is non-empty exactly + // while a callback is running: inside a callback, "the current cache" + // has to be the one the callback was invoked for, even if a blocking + // call left a result behind earlier. Outside a callback the stack is + // empty and the blocking result is what a read means. + if self.active_stack.is_empty() { + return self.sync_active.as_mut(); + } self.active_stack.last_mut() } @@ -197,14 +212,21 @@ impl CacheManager { if let Some(id) = self.manual_active { return self.saved.get(&id); } + if self.active_stack.is_empty() { + return self.sync_active.as_ref(); + } self.active_stack.last() } /// Clones the current active cache into saved storage. /// Returns the saved cache ID, or 0 if no active cache or limit reached. pub fn save(&mut self) -> i32 { - if self.saved.len() >= MAX_SAVED_CACHES { - Logger::warn("cache_save failed: maximum saved caches reached (1024)."); + if self.saved.len() >= crate::limits::saved_caches() { + Logger::warn(&format!( + "cache_save failed: the saved-cache limit of {} was reached. \ + Free one with cache_delete, or raise MYSQL_LIMIT_SAVED_CACHES.", + crate::limits::saved_caches() + )); return 0; } @@ -232,6 +254,38 @@ impl CacheManager { } /// Manually activates a saved cache (overrides stack top). + /// Frees the blocking result at the frame boundary. + /// + /// The async path frees its cache deterministically - pushed before the + /// callback, popped when it returns - and a blocking call has no callback + /// to hang that on. Tying it to the tick gives the same guarantee for the + /// same reason: the server is single-threaded, so a Pawn function runs + /// whole inside one frame, and by the next tick whoever called has already + /// returned. Anything that must outlive the frame is what `cache_save` is + /// for. + /// + /// Without this the result would sit there until the next blocking call, + /// which in a script that runs one at start-up means for the life of the + /// process - up to the row cap's worth of memory, held for nothing. + pub fn release_sync_result(&mut self) { + self.sync_active = None; + } + + /// Whether a query callback is running right now. + /// + /// The stack is pushed before a callback and popped when it returns, so a + /// non-empty stack means exactly that. + pub fn callback_active(&self) -> bool { + !self.active_stack.is_empty() + } + + /// Makes the result of a blocking call the current one, replacing whatever + /// the previous blocking call left. One live result at a time is the whole + /// lifetime rule: no id to track, nothing to free by hand. + pub fn set_sync_result(&mut self, entry: CacheEntry) { + self.sync_active = Some(entry); + } + pub fn set_active(&mut self, id: i32) -> bool { if self.saved.contains_key(&id) { self.manual_active = Some(id); @@ -243,12 +297,11 @@ impl CacheManager { /// Deactivates the manually set cache. pub fn unset_active(&mut self) -> bool { - if self.manual_active.is_some() { - self.manual_active = None; - true - } else { - false - } + // Both overrides answer to the same native: "stop pointing at this, + // go back to the callback's cache". + let had_manual = self.manual_active.take().is_some(); + let had_sync = self.sync_active.take().is_some(); + had_manual || had_sync } /// Checks if a saved cache ID is valid. @@ -681,4 +734,82 @@ mod tests { let id2 = mgr.save(); assert!(id2 >= 1); // wraps, never 0 } + // Blocking-call result slot. + + #[test] + fn a_blocking_result_is_read_when_no_callback_is_running() { + let mut mgr = CacheManager::new(); + assert!(!mgr.callback_active()); + mgr.set_sync_result(CacheEntry::empty("SELECT 1".to_string())); + assert!(mgr.get_active().is_some()); + } + + #[test] + fn a_callback_cache_wins_over_a_blocking_result() { + let mut mgr = CacheManager::new(); + mgr.set_sync_result(CacheEntry::empty("blocking".to_string())); + mgr.push_active(CacheEntry::empty("callback".to_string())); + + assert!(mgr.callback_active()); + assert_eq!( + mgr.get_active().map(CacheEntry::query_string), + Some("callback"), + "inside a callback, the current cache is the callback's own" + ); + + // ... and the blocking result comes back once the callback returns. + mgr.pop_active(); + assert_eq!( + mgr.get_active().map(CacheEntry::query_string), + Some("blocking") + ); + } + + #[test] + fn a_second_blocking_call_replaces_the_first() { + let mut mgr = CacheManager::new(); + mgr.set_sync_result(CacheEntry::empty("first".to_string())); + mgr.set_sync_result(CacheEntry::empty("second".to_string())); + assert_eq!( + mgr.get_active().map(CacheEntry::query_string), + Some("second"), + "only one blocking result is live at a time" + ); + } + + #[test] + fn unset_active_clears_a_blocking_result() { + let mut mgr = CacheManager::new(); + mgr.set_sync_result(CacheEntry::empty("blocking".to_string())); + assert!(mgr.unset_active()); + assert!(mgr.get_active().is_none()); + assert!(!mgr.unset_active(), "nothing left to clear"); + } + #[test] + fn the_frame_boundary_frees_a_blocking_result() { + let mut mgr = CacheManager::new(); + mgr.set_sync_result(CacheEntry::empty("blocking".to_string())); + assert!(mgr.get_active().is_some()); + + mgr.release_sync_result(); + assert!( + mgr.get_active().is_none(), + "a blocking result must not outlive the frame that asked for it" + ); + } + + #[test] + fn the_frame_boundary_leaves_saved_caches_alone() { + // What a script asked to keep is kept; only the implicit result goes. + let mut mgr = CacheManager::new(); + mgr.push_active(CacheEntry::empty("callback".to_string())); + let id = mgr.save(); + mgr.pop_active(); + + mgr.set_sync_result(CacheEntry::empty("blocking".to_string())); + mgr.release_sync_result(); + + assert!(id > 0); + assert!(mgr.set_active(id), "the saved cache is still there"); + } } diff --git a/src/config.rs b/src/config.rs index 8e0fefe..c7a857a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,8 @@ use std::collections::HashMap; use std::fs; use std::path::Path; +use crate::logger::Logger; + /// Credentials read from a config file. Options (port, SSL, …) are not part of /// it — they stay with `mysql_options_new`, so there is one place to look. pub struct ConnectionFile { @@ -47,6 +49,8 @@ impl ConfigError { /// to keep leading or trailing spaces in a password. /// - Unknown keys are ignored rather than rejected, so a file shared with /// another tool does not break the connect. +/// - `${NAME}` in a value is replaced by that environment variable, which is +/// how a password stays out of the file as well as out of the gamemode. pub fn parse(contents: &str) -> Result { let mut values: HashMap = HashMap::new(); @@ -62,7 +66,7 @@ pub fn parse(contents: &str) -> Result { values.insert( key.trim().to_ascii_lowercase(), - unquote(value.trim()).to_string(), + expand_env(unquote(value.trim())), ); } @@ -83,6 +87,52 @@ pub fn load(path: &Path) -> Result { parse(&contents) } +/// Replaces every `${NAME}` with the environment variable of that name. +/// +/// This is what lets the file name a secret instead of holding one: the +/// operator exports `MYSQL_PASSWORD`, the repository carries +/// `password = ${MYSQL_PASSWORD}`, and a copy of the file is worth nothing on +/// its own. +/// +/// An unset variable expands to nothing rather than being left as the literal +/// `${NAME}`, and says so in the log. Sending `${MYSQL_PASSWORD}` to the +/// server as if it were the password would fail with a confusing "access +/// denied" instead of naming the real problem; an empty password at least +/// fails for a reason the operator can read. A name that is not there is +/// almost always a variable the service unit forgot to pass. +/// +/// Only `${NAME}` is recognised. A bare `$NAME` is left alone, so a password +/// that happens to contain a dollar sign keeps working. +fn expand_env(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut rest = value; + + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + + let Some(end) = after.find('}') else { + // No closing brace: not a reference, just text that starts with + // `${`. Keep it verbatim. + out.push_str(&rest[start..]); + return out; + }; + + let name = &after[..end]; + match std::env::var(name) { + Ok(found) => out.push_str(&found), + Err(_) => Logger::warn(&format!( + "Connection file: ${{{name}}} is not set in the environment; \ + it expanded to nothing." + )), + } + rest = &after[end + 1..]; + } + + out.push_str(rest); + out +} + fn strip_comment(line: &str) -> &str { match line.find(['#', ';']) { Some(idx) => &line[..idx], @@ -189,4 +239,55 @@ database = samp_server }; assert!(!err.message().contains("hunter2")); } + // ${ENV} expansion. The variable names are unique per test: the + // environment is process-wide and the suite runs in parallel. + + #[test] + fn env_reference_is_expanded() { + unsafe { std::env::set_var("MYSQL_SAMP_T1_PASS", "s3cr3t") }; + let cfg = parse("host=h\nuser=u\npassword=${MYSQL_SAMP_T1_PASS}\ndatabase=d\n") + .expect("valid file"); + assert_eq!(cfg.password, "s3cr3t"); + } + + #[test] + fn env_reference_can_sit_inside_a_larger_value() { + unsafe { std::env::set_var("MYSQL_SAMP_T2_HOST", "db.internal") }; + let cfg = parse("host=${MYSQL_SAMP_T2_HOST}\nuser=u\npassword=p\ndatabase=d\n") + .expect("valid file"); + assert_eq!(cfg.host, "db.internal"); + } + + #[test] + fn several_references_in_one_value() { + unsafe { std::env::set_var("MYSQL_SAMP_T3_A", "aa") }; + unsafe { std::env::set_var("MYSQL_SAMP_T3_B", "bb") }; + let cfg = + parse("host=h\nuser=u\npassword=x${MYSQL_SAMP_T3_A}y${MYSQL_SAMP_T3_B}z\ndatabase=d\n") + .expect("valid file"); + assert_eq!(cfg.password, "xaaybbz"); + } + + #[test] + fn an_unset_variable_expands_to_nothing() { + let cfg = parse("host=h\nuser=u\npassword=${MYSQL_SAMP_T4_MISSING}\ndatabase=d\n") + .expect("valid file"); + assert_eq!( + cfg.password, "", + "an unset name must not reach the server as a literal reference" + ); + } + + #[test] + fn a_dollar_without_braces_is_literal() { + // A password is allowed to contain a dollar sign. + let cfg = parse("host=h\nuser=u\npassword=pa$$word\ndatabase=d\n").expect("valid file"); + assert_eq!(cfg.password, "pa$$word"); + } + + #[test] + fn an_unclosed_reference_is_left_alone() { + let cfg = parse("host=h\nuser=u\npassword=${OPEN\ndatabase=d\n").expect("valid file"); + assert_eq!(cfg.password, "${OPEN"); + } } diff --git a/src/connection.rs b/src/connection.rs index b8d75e5..1c50564 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -21,6 +21,10 @@ struct ConnectionEntry { last_error: ErrorState, auto_reconnect: bool, escape_mode: EscapeMode, + /// Cipher the handshake negotiated, or `None` when the session is + /// unencrypted. Read once at connect time, on the connection that is + /// already open, so the diagnostic natives are a lookup and never a query. + tls_cipher: Option, } pub struct QueryError { @@ -118,7 +122,7 @@ impl ConnectionManager { .with_danger_skip_domain_validation(true); } - builder.ssl_opts(Some(ssl_opts)) + builder.ssl_opts(Some(ssl_opts)).prefer_socket(false) } else { builder }; @@ -154,7 +158,7 @@ impl ConnectionManager { let pool = match Pool::new(opts) { Ok(p) => p, Err(e) => { - let detail = format!("Pool creation failed: {}", e); + let detail = format!("Pool creation failed: {}", describe_mysql_error(&e)); let code = MysqlError::ConnectionFailed.code(); Logger::error_detail( &format!( @@ -169,10 +173,10 @@ impl ConnectionManager { }; // Validate by getting a connection (Pool connects lazily on first get_conn) - let escape_mode = match pool.get_conn() { - Ok(mut conn) => detect_escape_mode(&mut conn), + let (escape_mode, tls_cipher) = match pool.get_conn() { + Ok(mut conn) => (detect_escape_mode(&mut conn), detect_tls_cipher(&mut conn)), Err(e) => { - let detail = format!("Connection failed: {}", e); + let detail = format!("Connection failed: {}", describe_mysql_error(&e)); let code = MysqlError::ConnectionFailed.code(); Logger::error_detail( &format!( @@ -197,6 +201,7 @@ impl ConnectionManager { last_error: ErrorState::ok(), auto_reconnect: options.auto_reconnect, escape_mode, + tls_cipher, }, ); @@ -283,6 +288,19 @@ impl ConnectionManager { /// Escaping mode of a connection. Unknown IDs fall back to the MySQL /// default, which is what a server uses unless configured otherwise. + /// The cipher the session negotiated, or `None` when it is unencrypted or + /// the id is unknown. + pub fn tls_cipher(&self, conn_id: i32) -> Option<&str> { + self.connections.get(&conn_id)?.tls_cipher.as_deref() + } + + /// Whether the session is encrypted. An unknown id reads as not encrypted: + /// a diagnostic must not claim a guarantee for a connection that is not + /// there. + pub fn tls_active(&self, conn_id: i32) -> bool { + self.tls_cipher(conn_id).is_some() + } + pub fn escape_mode(&self, conn_id: i32) -> EscapeMode { self.connections .get(&conn_id) @@ -298,6 +316,31 @@ impl ConnectionManager { } } +/// Reads the cipher of the session that was just opened. +/// +/// `Ssl_cipher` is empty on an unencrypted session, which is how the server +/// reports "no TLS" - there is no separate flag to read. A failure to read it +/// is reported as `None` rather than guessed: claiming encryption that was not +/// confirmed is the one wrong answer a security diagnostic can give. +fn detect_tls_cipher(conn: &mut PooledConn) -> Option { + let row: Option<(String, String)> = conn + .query_first("SHOW SESSION STATUS LIKE 'Ssl_cipher'") + .ok() + .flatten(); + + match row { + Some((_, cipher)) if !cipher.is_empty() => Some(cipher), + Some(_) => None, + None => { + Logger::warn( + "Could not read Ssl_cipher; mysql_tls_active will report false for this \ + connection even if the session is encrypted.", + ); + None + } + } +} + /// Reads `sql_mode` from a freshly opened connection to decide how string /// literals must be escaped on it. /// @@ -516,9 +559,6 @@ pub fn attempt_query( } } -/// Maximum number of rows stored in a single CacheEntry to prevent memory exhaustion. -const MAX_RESULT_ROWS: usize = 100_000; - /// Executes a query on a PooledConn and returns a CacheEntry with results. /// Renders a protocol value as the string the cache stores. /// @@ -588,7 +628,7 @@ fn collect_result( Ok(row) => { // The ceiling spans the whole query, not each set, so a // script cannot bypass it by returning many small sets. - if total_rows >= MAX_RESULT_ROWS { + if total_rows >= crate::limits::result_rows() { truncated = true; continue; // drain the rest to avoid protocol desync } @@ -629,7 +669,7 @@ fn build_cache_entry( if raw.truncated { crate::logger::Logger::warn(&format!( "Query result truncated to {} rows.", - MAX_RESULT_ROWS + crate::limits::result_rows() )); } @@ -874,6 +914,35 @@ mod tests { ); } + /// Asking for TLS must also switch off the driver's socket preference. + /// + /// `prefer_socket` defaults to true, and after the handshake the driver + /// reconnects a *loopback* address over the server's unix socket - where + /// it does not secure anything ("won't secure socket connection"). The + /// result was a connection that asked for TLS, reported no error, and ran + /// in plaintext; measured against a real server, `Ssl_cipher` came back + /// empty for `127.0.0.1` with `MYSQL_OPT_SSL = 1`. + #[test] + fn requesting_tls_opts_out_of_the_socket_downgrade() { + // The trap: both conditions the driver needs are met by default on the + // most ordinary local setup there is. + let defaults: Opts = OptsBuilder::new().ip_or_hostname(Some("127.0.0.1")).into(); + assert!(defaults.get_prefer_socket()); + assert!(defaults.addr_is_loopback()); + + // What connect() now builds when MYSQL_OPT_SSL is on. + let secured: Opts = OptsBuilder::new() + .ip_or_hostname(Some("127.0.0.1")) + .ssl_opts(Some(SslOpts::default())) + .prefer_socket(false) + .into(); + assert!(secured.get_ssl_opts().is_some()); + assert!( + !secured.get_prefer_socket(), + "a TLS connection must not be downgraded to a unix socket" + ); + } + /// Live check against a real server. Ignored by default; run with: /// MYSQL_SAMP_TEST_USER=... MYSQL_SAMP_TEST_PASS=... \ /// cargo test --target i686-unknown-linux-gnu -- --ignored tls diff --git a/src/lib.rs b/src/lib.rs index 2fbd859..15b9dd6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ mod callback; mod config; mod connection; mod error; +mod limits; mod logger; mod natives; mod options; @@ -24,6 +25,12 @@ initialize_plugin!( MysqlPlugin::mysql_connect_file, MysqlPlugin::mysql_close, MysqlPlugin::mysql_status, + MysqlPlugin::mysql_tls_active, + MysqlPlugin::mysql_tls_cipher, + + // Limits + MysqlPlugin::mysql_limit_set, + MysqlPlugin::mysql_limit_get, // Options MysqlPlugin::mysql_options_new, MysqlPlugin::mysql_options_set_int, diff --git a/src/limits.rs b/src/limits.rs new file mode 100644 index 0000000..dc665c5 --- /dev/null +++ b/src/limits.rs @@ -0,0 +1,170 @@ +//! Runtime caps on what the plugin will hold in memory. +//! +//! These exist so a gamemode cannot exhaust the server's memory by accident - +//! a query that matches a million rows, a loop that saves a cache per tick. +//! The defaults suit an ordinary game server; a job that legitimately needs +//! more can raise them, and a deployment that wants to be stricter can lower +//! them. +//! +//! They are atomics rather than fields on the plugin because +//! [`crate::connection::collect_result`] enforces the row cap from a worker +//! thread, which has no route to plugin state. Reads happen per row, so they +//! use `Relaxed`: a change racing with a query in flight lands on the next +//! one, which is all the ordering this needs. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::logger::Logger; + +/// Saved caches held by `cache_save` at once. +pub const DEFAULT_SAVED_CACHES: usize = 1024; +/// Rows a single result set may carry. +pub const DEFAULT_RESULT_ROWS: usize = 100_000; +/// Bytes an ORM string variable may bind. +pub const DEFAULT_ORM_STRING_LEN: usize = 4096; + +static SAVED_CACHES: AtomicUsize = AtomicUsize::new(DEFAULT_SAVED_CACHES); +static RESULT_ROWS: AtomicUsize = AtomicUsize::new(DEFAULT_RESULT_ROWS); +static ORM_STRING_LEN: AtomicUsize = AtomicUsize::new(DEFAULT_ORM_STRING_LEN); + +/// Which cap a `mysql_limit_*` native is addressing. +/// +/// The numbering is the Pawn enum in the include and is part of the API: add +/// to the end, never renumber. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[repr(i32)] +pub enum Limit { + SavedCaches = 0, + ResultRows = 1, + OrmStringLen = 2, +} + +impl Limit { + /// The Pawn constant, or `None` when the script passed something else. + #[must_use] + pub fn from_id(id: i32) -> Option { + match id { + 0 => Some(Self::SavedCaches), + 1 => Some(Self::ResultRows), + 2 => Some(Self::OrmStringLen), + _ => None, + } + } + + fn cell(self) -> &'static AtomicUsize { + match self { + Self::SavedCaches => &SAVED_CACHES, + Self::ResultRows => &RESULT_ROWS, + Self::OrmStringLen => &ORM_STRING_LEN, + } + } + + fn name(self) -> &'static str { + match self { + Self::SavedCaches => "MYSQL_LIMIT_SAVED_CACHES", + Self::ResultRows => "MYSQL_LIMIT_RESULT_ROWS", + Self::OrmStringLen => "MYSQL_LIMIT_ORM_STRING_LEN", + } + } +} + +/// Current value of a cap. +#[must_use] +pub fn get(limit: Limit) -> usize { + limit.cell().load(Ordering::Relaxed) +} + +/// Sets a cap, refusing a value that would disable it. +/// +/// Zero and negatives are refused rather than treated as "unlimited": a cap of +/// zero would break every save and every query, and "unlimited" is exactly the +/// state these exist to prevent. Lowering a cap below what is already held +/// keeps what is there and refuses the next one, which is the behaviour that +/// does not throw away a gamemode's data mid-round. +pub fn set(limit: Limit, value: i32) -> bool { + let Ok(value) = usize::try_from(value) else { + Logger::warn(&format!( + "{}: a limit must be positive; {value} was ignored.", + limit.name() + )); + return false; + }; + if value == 0 { + Logger::warn(&format!( + "{}: a limit of 0 would refuse everything; it was ignored.", + limit.name() + )); + return false; + } + + limit.cell().store(value, Ordering::Relaxed); + true +} + +/// Saved caches held at once. +#[must_use] +pub fn saved_caches() -> usize { + get(Limit::SavedCaches) +} + +/// Rows a single result set may carry. +#[must_use] +pub fn result_rows() -> usize { + get(Limit::ResultRows) +} + +/// Bytes an ORM string variable may bind. +#[must_use] +pub fn orm_string_len() -> usize { + get(Limit::OrmStringLen) +} + +/// Same as [`orm_string_len`] where the caller compares against a Pawn cell. +#[must_use] +pub fn orm_string_len_i32() -> i32 { + i32::try_from(orm_string_len()).unwrap_or(i32::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + // The statics are process-wide, so each test restores what it changed. + + #[test] + fn unknown_id_is_rejected() { + assert!(Limit::from_id(-1).is_none()); + assert!(Limit::from_id(3).is_none()); + assert_eq!(Limit::from_id(0), Some(Limit::SavedCaches)); + } + + #[test] + fn a_limit_round_trips() { + let before = get(Limit::SavedCaches); + assert!(set(Limit::SavedCaches, 7)); + assert_eq!(get(Limit::SavedCaches), 7); + assert!(set( + Limit::SavedCaches, + i32::try_from(before).expect("default fits in i32") + )); + } + + #[test] + fn zero_and_negative_are_refused() { + let before = get(Limit::ResultRows); + assert!(!set(Limit::ResultRows, 0)); + assert!(!set(Limit::ResultRows, -5)); + assert_eq!( + get(Limit::ResultRows), + before, + "a refused set must not change the cap" + ); + } + + #[test] + fn defaults_match_the_documented_numbers() { + assert_eq!(DEFAULT_SAVED_CACHES, 1024); + assert_eq!(DEFAULT_RESULT_ROWS, 100_000); + assert_eq!(DEFAULT_ORM_STRING_LEN, 4096); + } +} diff --git a/src/natives/connection.rs b/src/natives/connection.rs index 8692569..f55b6f9 100644 --- a/src/natives/connection.rs +++ b/src/natives/connection.rs @@ -124,6 +124,39 @@ impl MysqlPlugin { } } + /// Whether the session is encrypted, as the handshake settled it. + /// + /// This is not "was TLS asked for": `MYSQL_OPT_SSL` is the request, and a + /// request can be refused. Reading what was negotiated is what lets a + /// gamemode verify its own deployment instead of trusting the option it + /// passed. + #[native(name = "mysql_tls_active")] + pub fn mysql_tls_active(&mut self, _amx: &Amx, conn_id: i32) -> bool { + self.connections.tls_active(conn_id) + } + + /// Writes the negotiated cipher suite, empty when the session is not + /// encrypted. + #[native(name = "mysql_tls_cipher")] + pub fn mysql_tls_cipher( + &mut self, + _amx: &Amx, + conn_id: i32, + dest: UnsizedBuffer, + dest_len: usize, + ) -> AmxResult { + // An unencrypted session is not a failure to report through the error + // state: it is the answer. The empty string says it, and the return + // value distinguishes it from a connection that does not exist. + let cipher = self + .connections + .tls_cipher(conn_id) + .unwrap_or("") + .to_owned(); + dest.write_str(dest_len, &cipher)?; + Ok(!cipher.is_empty()) + } + #[native(name = "mysql_close")] pub fn mysql_close(&mut self, _amx: &Amx, connection_id: i32) -> bool { if self.connections.disconnect(connection_id) { diff --git a/src/natives/limits.rs b/src/natives/limits.rs new file mode 100644 index 0000000..54d245e --- /dev/null +++ b/src/natives/limits.rs @@ -0,0 +1,35 @@ +use samp::native; +use samp::prelude::*; + +use crate::limits::{self, Limit}; +use crate::logger::Logger; +use crate::plugin::MysqlPlugin; + +impl MysqlPlugin { + /// Raises or lowers one of the memory caps. + /// + /// Global on purpose, not per connection: the thing being protected is the + /// server's memory, which every connection shares. + #[native(name = "mysql_limit_set")] + pub fn mysql_limit_set(&mut self, _amx: &Amx, limit: i32, value: i32) -> bool { + let Some(limit) = Limit::from_id(limit) else { + Logger::warn(&format!( + "mysql_limit_set: unknown limit {limit}. Use one of the MYSQL_LIMIT_* constants." + )); + return false; + }; + limits::set(limit, value) + } + + /// Reads a cap, so a script can report it or restore it later. + #[native(name = "mysql_limit_get")] + pub fn mysql_limit_get(&mut self, _amx: &Amx, limit: i32) -> i32 { + let Some(limit) = Limit::from_id(limit) else { + Logger::warn(&format!( + "mysql_limit_get: unknown limit {limit}. Use one of the MYSQL_LIMIT_* constants." + )); + return 0; + }; + i32::try_from(limits::get(limit)).unwrap_or(i32::MAX) + } +} diff --git a/src/natives/mod.rs b/src/natives/mod.rs index 5848b01..b6404b8 100644 --- a/src/natives/mod.rs +++ b/src/natives/mod.rs @@ -1,6 +1,7 @@ pub mod cache; pub mod connection; pub mod error; +pub mod limits; pub mod options; pub mod orm; pub mod password; diff --git a/src/natives/orm.rs b/src/natives/orm.rs index 6071383..a0436a9 100644 --- a/src/natives/orm.rs +++ b/src/natives/orm.rs @@ -6,7 +6,7 @@ use crate::connection::EscapeMode; use crate::error::{ErrorState, MysqlError}; use crate::logger::Logger; use crate::natives::query::parse_variadic_params; -use crate::orm::{MAX_ORM_STRING_LEN, OrmError, OrmInstance, OrmVarBinding}; +use crate::orm::{OrmError, OrmInstance, OrmVarBinding}; use crate::plugin::MysqlPlugin; use crate::query::CallbackInfo; @@ -175,6 +175,10 @@ impl MysqlPlugin { return false; }; + if callback_str == crate::natives::query::SYNC_CALLBACK { + return self.reject_sync(&format!("ORM {name}"), conn_id); + } + let callback_info = if callback_str.is_empty() { None } else { @@ -294,10 +298,10 @@ impl MysqlPlugin { return false; }; - if max_len <= 0 || max_len > MAX_ORM_STRING_LEN { + if max_len <= 0 || max_len > crate::limits::orm_string_len_i32() { Logger::warn(&format!( "ORM addvar_string failed: max_len must be between 1 and {}.", - MAX_ORM_STRING_LEN + crate::limits::orm_string_len_i32() )); return false; } diff --git a/src/natives/password.rs b/src/natives/password.rs index bb18ff9..55dc9fb 100644 --- a/src/natives/password.rs +++ b/src/natives/password.rs @@ -31,6 +31,17 @@ impl MysqlPlugin { let password = password.to_string(); let callback = callback.to_string(); + if callback == crate::natives::query::SYNC_CALLBACK { + // There is nowhere to put the hash without a callback: the + // signature has no output buffer. Hashing is also deliberately + // slow, so blocking on it would be the worst place to do it. + Logger::warn( + "MYSQL_SYNC is not supported here: the result is delivered through the \ + callback, and hashing is deliberately slow enough that blocking on it \ + would stall the server. The call was NOT run.", + ); + return false; + } if callback.is_empty() { Logger::warn( "mysql_hash_password: a callback is required — the hash is delivered through it.", @@ -85,6 +96,17 @@ impl MysqlPlugin { let hash = hash.to_string(); let callback = callback.to_string(); + if callback == crate::natives::query::SYNC_CALLBACK { + // There is nowhere to put the hash without a callback: the + // signature has no output buffer. Hashing is also deliberately + // slow, so blocking on it would be the worst place to do it. + Logger::warn( + "MYSQL_SYNC is not supported here: the result is delivered through the \ + callback, and hashing is deliberately slow enough that blocking on it \ + would stall the server. The call was NOT run.", + ); + return false; + } if callback.is_empty() { Logger::warn( "mysql_verify_password: a callback is required — the result is delivered through it.", diff --git a/src/natives/query.rs b/src/natives/query.rs index c9ebc67..b8e44bd 100644 --- a/src/natives/query.rs +++ b/src/natives/query.rs @@ -10,6 +10,14 @@ use crate::logger::Logger; use crate::plugin::MysqlPlugin; use crate::query::{CallbackInfo, CallbackParam}; +/// The value a script writes where the callback goes to make one call block. +/// +/// A string rather than a new native, and a string no Pawn `public` can be +/// named - identifiers cannot contain `!` - so it cannot collide with a real +/// callback and cannot be produced by accident. Declared in the include as +/// `MYSQL_SYNC`. +pub const SYNC_CALLBACK: &str = "!sync"; + /// Parameters bundled for [`MysqlPlugin::submit_query`]. /// Groups every value that describes a single query submission so the /// internal helper has one data argument instead of seven positional ones. @@ -113,6 +121,10 @@ impl MysqlPlugin { let auto_reconnect = self.connections.get_auto_reconnect(req.conn_id); + if req.callback == SYNC_CALLBACK { + return self.run_blocking(&pool, req.query, req.conn_id, auto_reconnect); + } + let callback_info = if req.callback.is_empty() { None } else { @@ -145,6 +157,150 @@ impl MysqlPlugin { true } + /// Refuses `MYSQL_SYNC` on a native that does not implement it. + /// + /// Without this the sentinel would be taken for a callback name, no public + /// of that name would exist, and the call would quietly behave as + /// fire-and-forget - the script waiting for a blocking call that never + /// blocked. Saying no is the only safe answer. + pub(crate) fn reject_sync(&mut self, native: &str, conn_id: i32) -> bool { + let msg = format!( + "{native} does not support MYSQL_SYNC. Blocking calls are available on \ + mysql_query, mysql_pquery and mysql_query_file; the call was NOT run." + ); + Logger::warn(&msg); + self.connections + .set_error(conn_id, ErrorState::new(MysqlError::QueryFailed, &msg)); + false + } + + /// Runs a statement on the calling thread and keeps the result. + /// + /// This is the one path in the plugin that blocks the server: the tick + /// stops here until the database answers. It exists because ordering and + /// "read the answer on the next line" are sometimes worth more than the + /// tick - schema work at start-up, a migration, a one-off console command - + /// and it is reached only by writing `MYSQL_SYNC` where the callback goes, + /// so no one arrives here by accident. + /// + /// The result becomes the current cache, replacing the previous blocking + /// result. A failure fires `OnQueryError` exactly as the threaded path + /// does, so error handling does not have to be written twice. + fn run_blocking( + &mut self, + pool: &mysql::Pool, + query: &str, + conn_id: i32, + auto_reconnect: bool, + ) -> bool { + // Refused inside a callback, and not out of caution: there, "the + // current cache" already means the one the callback was invoked for. + // A blocking result would have to either shadow it - breaking every + // read after it in the same callback - or be unreadable until the + // callback returns. Both are surprises; refusing is not. Every use + // this exists for (start-up, a migration, a console command) happens + // outside a callback anyway. + if self.cache.callback_active() { + let msg = "MYSQL_SYNC was used inside a callback, where the active cache already \ + belongs to that callback. The query was NOT run. Run it before the \ + callback, or use a normal threaded call."; + Logger::warn(msg); + self.connections + .set_error(conn_id, ErrorState::new(MysqlError::QueryFailed, msg)); + return false; + } + + match crate::connection::attempt_query(pool, query, auto_reconnect) { + Ok(entry) => { + self.cache.set_sync_result(entry); + self.connections.set_error(conn_id, ErrorState::ok()); + true + } + Err(e) => { + let code = i32::from(e.code); + Logger::error_detail( + &format!( + "Blocking query failed on connection {conn_id} (error {code}). \ + See logs/mysql.log for details." + ), + &format!("Query error: {}", e.message), + ); + self.connections.set_error( + conn_id, + ErrorState::new(MysqlError::QueryFailed, e.message.clone()), + ); + callback::fire_on_query_error( + &self.amx_list, + code, + &e.message, + SYNC_CALLBACK, + query, + conn_id, + ); + false + } + } + } + + /// Runs a whole `.sql` file on the calling thread, statements in order. + /// + /// Same rules as [`Self::run_blocking`]; the cache left behind is the one + /// of the last statement, matching what the threaded path delivers to a + /// callback. + fn run_blocking_script( + &mut self, + pool: &mysql::Pool, + statements: &[String], + path: &str, + conn_id: i32, + ) -> bool { + if self.cache.callback_active() { + let msg = "MYSQL_SYNC was used inside a callback, where the active cache already \ + belongs to that callback. The script was NOT run."; + Logger::warn(msg); + self.connections + .set_error(conn_id, ErrorState::new(MysqlError::QueryFailed, msg)); + return false; + } + + Logger::info(&format!( + "Running {} statement(s) from '{}' synchronously.", + statements.len(), + path + )); + + match crate::connection::attempt_script(pool, statements) { + Ok(entry) => { + self.cache.set_sync_result(entry); + self.connections.set_error(conn_id, ErrorState::ok()); + true + } + Err(e) => { + let code = i32::from(e.code); + Logger::error_detail( + &format!( + "Blocking script '{path}' failed on connection {conn_id} (error {code}). \ + See logs/mysql.log for details." + ), + &format!("Script error: {}", e.message), + ); + self.connections.set_error( + conn_id, + ErrorState::new(MysqlError::QueryFailed, e.message.clone()), + ); + callback::fire_on_query_error( + &self.amx_list, + code, + &e.message, + SYNC_CALLBACK, + path, + conn_id, + ); + false + } + } + } + /// mysql_query_file(connId, const path[], const callback[] = "", const format[] = "", {Float,_}:...) /// /// Reads a `.sql` file and runs its statements in order on one connection, @@ -208,6 +364,13 @@ impl MysqlPlugin { return false; }; + if callback == SYNC_CALLBACK { + // The strongest case for blocking there is: schema at start-up, + // where the next statement depends on this one having finished + // and there is no player to freeze. + return self.run_blocking_script(&pool, &statements, &path, conn_id); + } + let callback_info = if callback.is_empty() { None } else { diff --git a/src/natives/stmt.rs b/src/natives/stmt.rs index d6b4238..0d8ff1a 100644 --- a/src/natives/stmt.rs +++ b/src/natives/stmt.rs @@ -128,6 +128,15 @@ impl MysqlPlugin { return false; }; + if callback == crate::natives::query::SYNC_CALLBACK { + let native = if ordered { + "mysql_stmt_execute" + } else { + "mysql_stmt_pexecute" + }; + return self.reject_sync(native, conn_id); + } + let callback_info = if callback.is_empty() { None } else { diff --git a/src/natives/transaction.rs b/src/natives/transaction.rs index 78d72f5..d6e5d89 100644 --- a/src/natives/transaction.rs +++ b/src/natives/transaction.rs @@ -110,6 +110,10 @@ impl MysqlPlugin { return false; }; + if callback == crate::natives::query::SYNC_CALLBACK { + return self.reject_sync("mysql_transaction_execute", conn_id); + } + let callback_info = if callback.is_empty() { None } else { diff --git a/src/orm.rs b/src/orm.rs index a737d37..c6a5ca9 100644 --- a/src/orm.rs +++ b/src/orm.rs @@ -5,9 +5,9 @@ use samp::prelude::*; use crate::connection::{EscapeMode, escape_identifier, escape_string}; -/// Upper bound on the buffer size accepted by `orm_addvar_string`. -/// Protects the AMX heap from oversized writes via a hostile `max_len`. -pub const MAX_ORM_STRING_LEN: i32 = 4096; +// The upper bound on the buffer `orm_addvar_string` accepts - what protects +// the AMX heap from an oversized write through a hostile `max_len` - lives in +// `crate::limits` now, so a script can raise it: MYSQL_LIMIT_ORM_STRING_LEN. /// ORM error codes exposed to Pawn. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -290,10 +290,11 @@ impl OrmInstance { amx_addr, max_len, .. } => { // Clamp max_len to a safe upper bound to prevent OOB writes. - // The clamp guarantees the value is in 0..=MAX_ORM_STRING_LEN, + // The clamp guarantees the value is within the ORM string cap, // so the try_from below is infallible. let safe_max = - usize::try_from((*max_len).clamp(0, MAX_ORM_STRING_LEN)).unwrap_or(0); + usize::try_from((*max_len).clamp(0, crate::limits::orm_string_len_i32())) + .unwrap_or(0); if safe_max == 0 { continue; } diff --git a/src/plugin.rs b/src/plugin.rs index 97eebe3..7a3172a 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -172,6 +172,10 @@ impl SampPlugin for MysqlPlugin { /// Open Multiplayer native mode (ITimersComponent timer). Drives query /// dispatch automatically — no Pawn timer required anymore. fn on_tick(&mut self, _ctx: TickContext) { + // A new frame: whoever made a blocking call last frame has long + // returned, so its result is nobody's any more. + self.cache.release_sync_result(); + self.process_pending_queries(); self.process_pending_passwords(); } diff --git a/tests/inc_natives.rs b/tests/inc_natives.rs new file mode 100644 index 0000000..9c5f1be --- /dev/null +++ b/tests/inc_natives.rs @@ -0,0 +1,107 @@ +//! The includes declare exactly the natives the plugin registers. +//! +//! Adding a native touches four places - the file under `src/natives/`, the +//! list in `src/lib.rs`, the declaration in `include/mysql_samp.inc.in` and an +//! example. Forgetting the third is the easy one: everything builds, and the +//! native is simply unreachable from Pawn, which only shows up when someone +//! tries to call it. +//! +//! rust-samp 3.5.0 can emit the authoritative list at runtime - start the +//! server with `SAMP_PAWN_INCLUDE=/tmp/generated.inc` and the SDK writes the +//! declarations of every registered native. That needs a live server, so it is +//! a tool for checking by hand rather than something CI can run. This test +//! reproduces the comparison from the two files a build already produces. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +fn root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) +} + +fn read(rel: &str) -> String { + let path = root().join(rel); + fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())) +} + +/// Every native registered in the `initialize_plugin!` list. +fn registered_natives() -> BTreeSet { + let src = read("src/lib.rs"); + let list = src + .split_once("natives: [") + .expect("no `natives: [` in src/lib.rs") + .1 + .split_once("],") + .expect("unterminated natives list in src/lib.rs") + .0; + + list.lines() + .filter_map(|line| { + let line = line.trim(); + // Skip the section comments that group the list. + let rest = line.strip_prefix("MysqlPlugin::")?; + Some(rest.trim_end_matches(',').to_string()) + }) + .collect() +} + +/// Every native declared in an include, tags and return types stripped. +fn declared_natives(rel: &str) -> BTreeSet { + read(rel) + .lines() + .filter_map(|line| { + let rest = line.strip_prefix("native ")?; + let name = rest.split('(').next()?; + // `bool:mysql_close` and `Float:cache_...` carry a Pawn tag. + Some(name.rsplit(':').next()?.trim().to_string()) + }) + .collect() +} + +#[test] +fn every_registered_native_is_declared_in_the_include() { + let registered = registered_natives(); + let declared = declared_natives("include/mysql_samp.inc"); + + assert!( + !registered.is_empty(), + "parsed no natives from src/lib.rs - the parser broke, not the include" + ); + + let missing: Vec<_> = registered.difference(&declared).collect(); + assert!( + missing.is_empty(), + "registered in src/lib.rs but not declared in include/mysql_samp.inc.in: {missing:?}" + ); + + let extra: Vec<_> = declared.difference(®istered).collect(); + assert!( + extra.is_empty(), + "declared in the include but not registered in src/lib.rs, so calling \ + them fails at runtime: {extra:?}" + ); +} + +#[test] +fn the_omp_include_aliases_every_native() { + // The styled include is generated from the base one, so a mismatch here + // means `to_omp_name()` in build.rs dropped or renamed something. + let base = declared_natives("include/mysql_samp.inc"); + let omp = read("include/mysql_samp_omp.inc"); + + let aliased: BTreeSet = omp + .lines() + .filter_map(|line| { + // `native MySQL_Connect(...) = mysql_connect;` + let target = line.strip_prefix("native ")?.rsplit_once('=')?.1; + Some(target.trim().trim_end_matches(';').to_string()) + }) + .collect(); + + let missing: Vec<_> = base.difference(&aliased).collect(); + assert!( + missing.is_empty(), + "no open.mp alias generated for: {missing:?}" + ); +}