diff --git a/CHANGELOG.md b/CHANGELOG.md index 7424c64..445e320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,241 @@ All notable changes to Recached are documented here. --- +## [0.2.4] — Unreleased + +### Added + +- **`QUIT`.** Every client library closes a connection by sending `QUIT` and reading `+OK`. + Recached answered `ERR unknown command`, and node-redis surfaced that as a thrown error from + `.quit()` — a clean shutdown reported as a failure. It now replies `+OK` and closes. Answered + before the authentication gate and before the subscribe-mode gate, as Redis does: a client that + cannot authenticate, or is parked in subscribe mode, still deserves a clean close rather than a + dropped socket. The subscribe-mode error already claimed `QUIT` was allowed there; now it is. + +- **`CLIENT ID | INFO | LIST | GETNAME | SETNAME | SETINFO`.** node-redis, ioredis, redis-py and + go-redis all send `CLIENT SETINFO LIB-NAME` and `LIB-VER` immediately after `HELLO`. All four + tolerate the error, so nothing broke — but every connection from every modern client logged two + or three `unknown command` errors before doing any work, and the server could not say which + library was on the other end of a connection. `CLIENT LIST` and `CLIENT INFO` report in Redis's + `key=value` line format, carrying the fields Recached can answer truthfully — id, addresses, + name, age, subscription counts, protocol, library — and omitting the buffer sizes, file + descriptors and event masks it cannot. A plausible invented `omem` is worse than an absent one, + because nothing downstream can tell the difference. `CLIENT KILL`, `NO-EVICT` and `UNPAUSE` + return "unknown subcommand" rather than `+OK`: answering OK without doing the thing would leave + a caller believing a connection had been closed or eviction disabled. + +- **`CONFIG GET`.** Reports `maxmemory`, `maxmemory-policy`, `maxclients`, `port`, `tls-port`, + `appendonly`, `databases`, `requirepass`, `proto-max-bulk-len`, `timeout` and `save`, resolved + from the values actually in force rather than a table of defaults — the eviction policy comes + from the store, the ports and limits from the same startup facts `INFO` reads. Glob patterns and + multiple names work as in Redis. `requirepass` is masked to `*`: whether a password exists is not + a secret, its value is. **`CONFIG SET` is refused with an explanatory error.** Recached reads its + configuration from the environment at startup and holds it for the life of the process, so there + is nothing a runtime SET could change; returning `+OK` would leave an operator to discover much + later that the limit they set never applied. + +- **`COMMAND`, `COMMAND COUNT`, `COMMAND LIST`, `COMMAND INFO`, `COMMAND DOCS`.** Backed by a new + `core-engine::catalog` module holding one row per command. Arity, flags and key positions are + transcribed from a real `redis-server` 7.2.5's own `COMMAND INFO` rather than written from + memory: cluster-aware clients and proxies route on `first_key`/`step`, and a wrong arity makes a + client reject a call the server would have accepted. Summaries come from + `docs/server/commands.md`, so the text a client sees is the text that was reviewed and published. + The catalog cannot drift from the parser: one test asserts every catalog row names a command the + parser accepts, and another walks the exhaustive `Command` variant table asserting every command + has a row. + + Worth recording, since it contradicts a guess made while planning this work: **no client library + sends `COMMAND DOCS` or `CONFIG GET` during connect.** Tapping the wire for node-redis 6.2.0, + ioredis 6.0.0, redis-py 8.1.0 and go-redis 9.21.0 showed all four opening with `HELLO 3`, + `CLIENT SETINFO` ×2 and `CLIENT MAINT_NOTIFICATIONS`, and nothing else. `COMMAND DOCS` is used + interactively by `redis-cli`, and `CONFIG GET maxmemory-policy` by background-job frameworks — + both worth having, neither on the connect path. + +`CLIENT MAINT_NOTIFICATIONS` is still refused. It asks the server to push notifications before a +maintenance event moves the connection elsewhere; Recached has no such event to announce, and every +client that sends it treats the error as "not supported here" and carries on. + +- **Bounded reads: `GETRANGE`, `HSCAN`, `SSCAN`, `ZSCAN`.** Until now the only way to read a hash, + a set or a sorted set was `HGETALL` / `SMEMBERS` / `ZRANGE`, and the only way to read a string was + `GET`. Each of those is unbounded: the reply is as large as the value, and building it clones the + whole collection while holding the shard guard for that key — so one oversized key delays every + other key that hashes to the same shard. The cursor variants bound both the reply and the time + the guard is held, and `GETRANGE` reads a byte window of a large value without transferring it + whole. Tools that browse a keyspace they did not write — `redis-cli --scan`, TUI browsers such as + keylens, admin dashboards — need exactly these four to stay bounded; without them they must + measure with `STRLEN` / `HLEN` / `SCARD` and refuse to display anything big. + + ```bash + GETRANGE log:2026-08 0 4095 # first 4 KB of a large value + HSCAN session:42 0 COUNT 100 # → [cursor, [field, value, …]] + HSCAN session:42 0 NOVALUES # field names only (Redis 7.4) + SSCAN tags:hot 0 MATCH "eu:*" + ZSCAN leaderboard 0 COUNT 50 # → [cursor, [member, score, …]] + ``` + + The cursor is an offset into the collection's name-ordered element list, the same scheme keyspace + `SCAN` already used, so the same caveat applies: elements added or removed mid-iteration may be + missed or returned twice, and `MATCH` must stay the same across an iteration. `ZSCAN` orders by + **member**, not by score — a score can change under an in-flight cursor, and only the member + ordering survives that. A cursor pointing past the end of a collection that shrank returns an + empty final page rather than an error. + +### Security + +::: danger Breaking: the replication port no longer opens by default +If you run replication, add `RECACHED_REPL_ENABLE=1` to every node that serves replicas — +including a replica that serves sub-replicas. Without it, port 6381 is not bound and replicas +cannot attach. +::: + +- **The replication port is now opt-in, and refuses to run unauthenticated on a public interface.** + It previously bound `${RECACHED_BIND}:6381` — default `0.0.0.0` — unconditionally on **every** + node, whether or not replication was configured. With `RECACHED_REPL_PASSWORD` unset, which was + also the default, the handshake was skipped entirely and any peer that connected received a full + MessagePack dump of the keyspace followed by a live stream of every subsequent write. An operator + who set `RECACHED_PASSWORD` had every reason to believe the data was behind authentication, and + it was not: the port bypassed it completely. + + Two rules now apply. The listener binds only when `RECACHED_REPL_ENABLE` is set, and enabling it + on any interface other than loopback without `RECACHED_REPL_PASSWORD` **refuses to start** rather + than serving the keyspace unauthenticated. Multi-tier replication is unaffected in capability — + a node that serves sub-replicas sets the variable — but it is now a decision rather than a + default. All earlier 0.1.x and 0.2.x releases are affected; upgrading is the fix. + + The same listener now also honours `RECACHED_ALLOW_IPS` and counts against + `RECACHED_MAX_CONNECTIONS`. Neither applied to it before, so the one port that streams the entire + keyspace was the one port with no allowlist and no connection limit. + +- **Failed replication auth is throttled per source address.** The RESP port drops a connection + after five wrong passwords, but the replication handshake is one-shot: a wrong guess cost an + attacker a single TCP connection, so reconnecting gave unlimited attempts at a secret that yields + the whole keyspace. Failures are now counted per peer over a rolling window and further attempts + are refused before the handshake is read. + +- **The replication handshake no longer leaks the password length.** It read exactly + `password.len() + 1` bytes, so the number of bytes the server waited for *was* the length — + recoverable by feeding one byte at a time and watching when the server replied. It now reads to + the line terminator, with a length cap and a deadline. + +- **`RECACHED_ALLOWED_ORIGINS` restricts which web pages may open the browser sync socket.** + Browsers apply neither CORS nor a preflight to WebSockets, so any page a user visited could open + a socket to a reachable Recached and read or write the keyspace with that user's network + position — on the common `ws://localhost:6380` development setup, every site in every tab. Set it + to a comma-separated list of exact origins (`https://app.example.com,http://localhost:3000`; + `null` admits sandboxed iframes and `file://` documents) and a handshake from anywhere else is + refused with a 403. + + Unset means allow-all with a startup warning, matching how `RECACHED_PASSWORD` behaves — the + project ships insecure by default and says so, but it should not do it silently. A client that + sends no `Origin` header at all is admitted: native clients omit it and an attacker with a raw + socket can forge it, so refusing would break real clients while stopping nobody. The control + separates *the application you deployed* from *another page in the same browser*, which is the + threat this port actually faces. + +- **TLS and WebSocket handshakes are now bounded by a deadline** (`RECACHED_HANDSHAKE_TIMEOUT`, + default 10s). The connection permit is taken before the handshake runs, so a client that opened a + socket and then said nothing held one of `RECACHED_MAX_CONNECTIONS` slots indefinitely. A + thousand such sockets cost an attacker nothing and stopped the server accepting real clients. + +- **Snapshots, the AOF and the dedup sidecar are created `0600`.** They were created with the + process umask — `0644` on a typical host — so any local user could read plaintext MessagePack + dumps of the entire keyspace. Files left `0644` by an earlier version are tightened on the next + write. Snapshot and dedup temp files also carry the process id, so two servers sharing a data + directory can no longer clobber each other's half-written file. + +- **The RESP parser no longer reserves memory for a count it has not received.** An aggregate header + declares how many elements follow and arrives before any of them, and the parser reserved capacity + for the declared count. Nine bytes — `*1000000\r\n` — reserved 32 MB, and nesting that to the depth + limit made 160 bytes of input request **512 MB**. Because an incomplete frame is re-parsed from the + start whenever more bytes arrive, a client dripping one byte per packet repeated the reservation on + every packet, and none of it counted against `RECACHED_MAX_MEMORY`, which tracks stored data only. + + Reservations are now capped at 1,024 elements, so allocation is proportional to bytes *received* + rather than bytes *claimed*: the same two inputs now cost 32 KB and 512 KB. The cap is a floor and + not a limit — aggregates larger than 1,024 elements still parse in full, and `proto-max-bulk-len` + and the million-element ceiling are unchanged. + +- **Replication can now be encrypted and the primary's identity verified.** Both ends of port 6381 + were plain TCP, so the replication password and then the entire keyspace crossed the network in the + clear. Worse than the eavesdropping: a replica had no way to check *who* it was following, so a DNS + hijack or an on-path attacker could feed it an arbitrary keyspace and it would load it. + + The listener now reuses `RECACHED_TLS_CERT`/`RECACHED_TLS_KEY` — enabling TLS covers the RESP, + WebSocket **and** replication listeners. Replicas opt in with `RECACHED_REPL_TLS_CA`, pointing at + the primary's certificate or the CA that issued it, with `RECACHED_REPL_TLS_SERVERNAME` to override + the verified name when `RECACHED_REPLICAOF` names an IP but the certificate names a host. + + The trust anchor is a file rather than the system root store deliberately: replication links two + hosts one operator runs, so trusting one private CA is both simpler and tighter than trusting every + public CA to vouch for a host that streams the whole dataset. A public bundle still works if the + primary's certificate is publicly issued. There is no encrypt-without-verify mode, because + verification is the half that stops a rogue primary. + + Note that this needs a genuine **two-certificate chain** — a CA plus a leaf it signed. A single + self-signed certificate, which is what the common `openssl req -x509` one-liner produces, is marked + `CA:TRUE` and is refused as a server certificate (`CaUsedAsEndEntity`). OpenSSL-based clients like + `redis-cli --cacert` accept such a certificate, so the same file can work for `rediss://` and fail + for replication. The security docs carry the `openssl` recipe. + + Replication remains **plaintext unless configured**, and a replica following a primary without TLS + now says so at startup. A replica without `RECACHED_REPL_TLS_CA` cannot talk to a TLS-enabled + primary; that handshake failure names the variable to set. + +- **Glob patterns are capped at 1,024 bytes** for `KEYS`, `SCAN`/`HSCAN`/`SSCAN`/`ZSCAN MATCH`, + `QSUB`, `PSUBSCRIBE`, `SYNC` scopes and signed sync tokens, reported as + `ERR pattern is too long`. Matching costs O(pattern × text) and runs once per key for `KEYS`, once + per key per write for sync scopes, and once per published message per subscriber for `PSUBSCRIBE`; + pattern length was previously bounded only by `proto-max-bulk-len` at 64 MB, so a single command + could occupy the process for an unbounded time. No legitimate pattern is near the cap. + +### Fixed + +- **`RECACHED_AOF_SYNC` now actually fsyncs.** `always` and `everysec` called `flush()`, which pushes + the buffer into a `write` syscall and leaves the bytes in the page cache — so acknowledged writes + survived a process crash but *not* a power loss or kernel panic, which is the case `always` exists + to cover. Both now `fsync`. The AOF truncation that follows a snapshot is fsynced too, so a crash + cannot resurrect a log the snapshot has already subsumed. + + Snapshots gained the same treatment: the temp file is fsynced before the rename, and the containing + **directory** is fsynced after it. Without the second step the file contents were durable but the + directory entry naming them was not, so a crash could leave the previous snapshot or none at all. + + ::: danger `always` is now roughly 400× more expensive per write + It was never doing the work, so it never cost anything. Measured on macOS/APFS: `no` and `everysec` + both run ~40–50 µs per append, `always` runs **~20 ms** — tens of writes per second rather than tens + of thousands, because the fsync is held inside the AOF lock and every writer queues behind it. The + server now logs a warning at startup when `always` is selected. + + If you benchmarked `always` on an earlier version and found it acceptable, re-measure: that number + was the cost of a `write` syscall, not of durability. `everysec` remains the default and costs + nothing measurable. See [Configuration](/server/configuration#what-each-sync-mode-costs). + ::: + +- **`glob_match` no longer allocates.** It ran an O(pattern × text) dynamic program that allocated two + `Vec` of `text.len() + 1` on **every call**, so one 64 MB value made `KEYS *` request 128 MB + on a path that runs once per key. Replaced with two-pointer greedy matching: same worst-case time + bound, zero allocation. Semantics are unchanged, verified by differential testing against the + previous implementation over every pattern of up to 5 bytes drawn from `{a, b, *, ?}` against every + text of up to 4 bytes drawn from `{a, b}`. + +- **Documentation: glob character classes were never supported.** `commands.md`, `sync-scopes.md` and + the `glob_match` doc comment all described `[abc]` as a character class. It has never been + implemented — brackets match literally, so `[ab]` matches the four-byte string `[ab]` and not `a`. + This matters most for sync scopes: a scope written `user:[12]:*` grants access to keys starting with + the literal text `user:[12]:` and matches nothing a normal application writes. It fails closed, so + no data was over-exposed, but a scope written that way never granted what its author intended. + Enumerate prefixes instead — `user:1:*,user:2:*`. + +### Changed + +- **`SCAN` now rejects `COUNT 0` and negative counts** with `ERR syntax error`, as Redis does. + They were previously cast to `usize`, where `-1` wrapped to a count of 18 quintillion and was + then silently clamped back to a normal page. The cursor argument reports `ERR invalid cursor` + rather than `ERR value is not an integer or out of range`, which is both Redis's wording and + what the whole SCAN family now shares. + +--- + ## [0.2.3] — 2026-08-01 ### Added diff --git a/Cargo.lock b/Cargo.lock index 2c38eff..e5f7b66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,7 +124,7 @@ dependencies = [ [[package]] name = "core-engine" -version = "0.2.3" +version = "0.2.4" dependencies = [ "dashmap", "indexmap", @@ -1058,7 +1058,7 @@ dependencies = [ [[package]] name = "server-native" -version = "0.2.3" +version = "0.2.4" dependencies = [ "base64", "core-engine", @@ -1195,7 +1195,7 @@ dependencies = [ [[package]] name = "sync-client" -version = "0.2.3" +version = "0.2.4" dependencies = [ "core-engine", ] @@ -1581,7 +1581,7 @@ checksum = "60238e5b4b1b295701d6f9a66d2a126fe19990348f5fb9dae3b623a370119d94" [[package]] name = "wasm-edge" -version = "0.2.3" +version = "0.2.4" dependencies = [ "core-engine", "getrandom 0.3.4", diff --git a/Cargo.toml b/Cargo.toml index 27fe170..1efa3d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ resolver = "2" # ── Single source of truth for all crate versions ──────────────────────────── # Members inherit with: version.workspace = true / edition.workspace = true [workspace.package] -version = "0.2.3" +version = "0.2.4" edition = "2024" license = "Apache-2.0" authors = ["ThinkGrid Labs"] diff --git a/core-engine/src/catalog.rs b/core-engine/src/catalog.rs new file mode 100644 index 0000000..9582d35 --- /dev/null +++ b/core-engine/src/catalog.rs @@ -0,0 +1,1370 @@ +//! The command catalog — what `COMMAND`, `COMMAND INFO`, `COMMAND COUNT`, +//! `COMMAND LIST` and `COMMAND DOCS` report. +//! +//! Arity, flags and key positions are transcribed from a real `redis-server` +//! 7.2.5's own `COMMAND INFO`, not written from memory. Clients and proxies +//! route on those numbers — a wrong `first_key` sends a command to the wrong +//! shard, and a wrong arity makes a client reject a call the server would have +//! accepted — so the reference implementation is the only honest source. The +//! nine commands Recached invents have no Redis entry and are declared here +//! directly. +//! +//! Summaries come from `docs/server/commands.md`, so the text a client sees is +//! the text that was reviewed and published rather than a second description +//! that can drift away from it. + +/// One row of the catalog. Mirrors the fields `COMMAND INFO` returns, in order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CommandSpec { + /// Lowercase, as Redis reports it. + pub name: &'static str, + /// Total argument count including the command name. Negative means "at + /// least this many", the Redis convention. + pub arity: i32, + pub flags: &'static [&'static str], + /// 1-based position of the first key argument, or 0 for keyless commands. + pub first_key: i32, + /// Position of the last key argument; -1 means "to the end". + pub last_key: i32, + /// Stride between key positions. + pub step: i32, + /// Section of the command reference this belongs to. + pub group: &'static str, + /// One-line description, for `COMMAND DOCS`. + pub summary: &'static str, +} + +#[allow(clippy::too_many_arguments)] +const fn spec( + name: &'static str, + arity: i32, + flags: &'static [&'static str], + first_key: i32, + last_key: i32, + step: i32, + group: &'static str, + summary: &'static str, +) -> CommandSpec { + CommandSpec { + name, + arity, + flags, + first_key, + last_key, + step, + group, + summary, + } +} + +/// Every command Recached accepts. Kept in sync with the parser by +/// `catalog_covers_every_command` in this module and by +/// `every_command_is_in_the_catalog` in the server. +pub const CATALOG: &[CommandSpec] = &[ + // ── Transcribed from redis-server 7.2.5 ─────────────────────────────── + spec( + "append", + 3, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "strings", + "Appends a string to the end of the existing value.", + ), + spec( + "auth", + -2, + &[ + "noscript", + "loading", + "stale", + "fast", + "no_auth", + "allow_busy", + ], + 0, + 0, + 0, + "core", + "Authenticates the connection.", + ), + spec( + "bgsave", + -1, + &["admin", "noscript", "no_async_loading"], + 0, + 0, + 0, + "persistence", + "Triggers a background snapshot.", + ), + spec( + "dbsize", + 1, + &["readonly", "fast"], + 0, + 0, + 0, + "keys", + "Returns the total number of keys in the store.", + ), + spec( + "decr", + 2, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "strings", + "Decrements the integer value of a key by 1.", + ), + spec( + "decrby", + 3, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "strings", + "Decrements the integer value of a key by the given integer.", + ), + spec( + "del", + -2, + &["write"], + 1, + -1, + 1, + "keys", + "Deletes one or more keys.", + ), + spec( + "discard", + 1, + &["noscript", "loading", "stale", "fast", "allow_busy"], + 0, + 0, + 0, + "transactions", + "Abandons the transaction queue.", + ), + spec( + "exec", + 1, + &["noscript", "loading", "stale", "skip_slowlog"], + 0, + 0, + 0, + "transactions", + "Executes all queued commands.", + ), + spec( + "exists", + -2, + &["readonly", "fast"], + 1, + -1, + 1, + "keys", + "Returns the number of keys that exist among the provided arguments.", + ), + spec( + "expire", + -3, + &["write", "fast"], + 1, + 1, + 1, + "expiry", + "Set a timeout on a key in seconds.", + ), + spec( + "expireat", + -3, + &["write", "fast"], + 1, + 1, + 1, + "expiry", + "Set an absolute expiry time as a Unix timestamp (seconds).", + ), + spec( + "flushdb", + -1, + &["write"], + 0, + 0, + 0, + "keys", + "Removes all keys from the store.", + ), + spec( + "get", + 2, + &["readonly", "fast"], + 1, + 1, + 1, + "strings", + "Returns the value of a key, or nil if the key does not exist or has expired.", + ), + spec( + "getrange", + 4, + &["readonly"], + 1, + 1, + 1, + "strings", + "Returns the inclusive byte range start..end of the string.", + ), + spec( + "getset", + 3, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "strings", + "Sets the key to a new value and returns the old value atomically.", + ), + spec( + "hdel", + -3, + &["write", "fast"], + 1, + 1, + 1, + "hash", + "Deletes one or more fields from a hash.", + ), + spec( + "hello", + -1, + &[ + "noscript", + "loading", + "stale", + "fast", + "no_auth", + "allow_busy", + ], + 0, + 0, + 0, + "core", + "Reports server info and negotiates the protocol version.", + ), + spec( + "hexists", + 3, + &["readonly", "fast"], + 1, + 1, + 1, + "hash", + "Returns 1 if the field exists in the hash, 0 otherwise.", + ), + spec( + "hget", + 3, + &["readonly", "fast"], + 1, + 1, + 1, + "hash", + "Returns the value of a specific field.", + ), + spec( + "hgetall", + 2, + &["readonly"], + 1, + 1, + 1, + "hash", + "Returns all field-value pairs of a hash as a flat array: field1, value1, field2, value2, ...", + ), + spec( + "hincrby", + 4, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "hash", + "Increments the integer value of a hash field by the given integer.", + ), + spec( + "hincrbyfloat", + 4, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "hash", + "Increments the float value of a hash field by the given float.", + ), + spec( + "hkeys", + 2, + &["readonly"], + 1, + 1, + 1, + "hash", + "Returns all field names in the hash.", + ), + spec( + "hlen", + 2, + &["readonly", "fast"], + 1, + 1, + 1, + "hash", + "Returns the number of fields in the hash.", + ), + spec( + "hmget", + -3, + &["readonly", "fast"], + 1, + 1, + 1, + "hash", + "Returns the values of multiple fields.", + ), + spec( + "hmset", + -4, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "hash", + "Deprecated alias of HSET, kept for older clients.", + ), + spec( + "hscan", + -3, + &["readonly"], + 1, + 1, + 1, + "hash", + "Iterates a hash incrementally: returns the next cursor plus at most COUNT field-value pairs (default 10).", + ), + spec( + "hset", + -4, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "hash", + "Sets one or more fields in a hash.", + ), + spec( + "hsetnx", + 4, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "hash", + "Sets a field only if it does not already exist.", + ), + spec( + "hvals", + 2, + &["readonly"], + 1, + 1, + 1, + "hash", + "Returns all values in the hash.", + ), + spec( + "incr", + 2, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "strings", + "Increments the integer value of a key by 1.", + ), + spec( + "incrby", + 3, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "strings", + "Increments the integer value of a key by the given integer.", + ), + spec( + "info", + -1, + &["loading", "stale"], + 0, + 0, + 0, + "core", + "Reports server statistics as a text blob, in Redis's # Section / field:value format.", + ), + spec( + "keys", + 2, + &["readonly"], + 0, + 0, + 0, + "keys", + "Returns all keys matching the glob pattern.", + ), + spec( + "lastsave", + 1, + &["loading", "stale", "fast"], + 0, + 0, + 0, + "persistence", + "Returns the Unix timestamp (seconds) of the most recent successful snapshot.", + ), + spec( + "lindex", + 3, + &["readonly"], + 1, + 1, + 1, + "list", + "Returns the element at the given index.", + ), + spec( + "llen", + 2, + &["readonly", "fast"], + 1, + 1, + 1, + "list", + "Returns the length of the list, or 0 if the key does not exist.", + ), + spec( + "lpop", + -2, + &["write", "fast"], + 1, + 1, + 1, + "list", + "Removes and returns the first element (or count elements) from the list.", + ), + spec( + "lpush", + -3, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "list", + "Prepends one or more elements to the head of a list.", + ), + spec( + "lpushx", + -3, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "list", + "Like LPUSH, but only if the key already exists.", + ), + spec( + "lrange", + 4, + &["readonly"], + 1, + 1, + 1, + "list", + "Returns the elements of the list between index start and stop (inclusive).", + ), + spec( + "lrem", + 4, + &["write"], + 1, + 1, + 1, + "list", + "Removes occurrences of element from the list.", + ), + spec( + "lset", + 4, + &["write", "denyoom"], + 1, + 1, + 1, + "list", + "Sets the list element at the given index to a new value.", + ), + spec( + "ltrim", + 4, + &["write"], + 1, + 1, + 1, + "list", + "Keeps only the elements between start and stop, removing the rest.", + ), + spec( + "mget", + -2, + &["readonly", "fast"], + 1, + -1, + 1, + "strings", + "Returns the values of multiple keys.", + ), + spec( + "mset", + -3, + &["write", "denyoom"], + 1, + -1, + 2, + "strings", + "Sets multiple keys to their respective values in a single atomic operation.", + ), + spec( + "multi", + 1, + &["noscript", "loading", "stale", "fast", "allow_busy"], + 0, + 0, + 0, + "transactions", + "Begins a transaction.", + ), + spec( + "persist", + 2, + &["write", "fast"], + 1, + 1, + 1, + "expiry", + "Removes the TTL from a key, making it persistent.", + ), + spec( + "pexpire", + -3, + &["write", "fast"], + 1, + 1, + 1, + "expiry", + "Set a timeout in milliseconds.", + ), + spec( + "pexpireat", + -3, + &["write", "fast"], + 1, + 1, + 1, + "expiry", + "Set an absolute expiry time as a Unix timestamp in milliseconds.", + ), + spec( + "ping", + -1, + &["fast"], + 0, + 0, + 0, + "core", + "Returns PONG, or echoes message if provided.", + ), + spec( + "psetex", + 4, + &["write", "denyoom"], + 1, + 1, + 1, + "strings", + "Set a key with a millisecond-precision expiry.", + ), + spec( + "psubscribe", + -2, + &["pubsub", "noscript", "loading", "stale"], + 0, + 0, + 0, + "pub/sub", + "Subscribes to channels matching a glob pattern.", + ), + spec( + "pttl", + 2, + &["readonly", "fast"], + 1, + 1, + 1, + "expiry", + "Returns the remaining TTL in milliseconds.", + ), + spec( + "publish", + 3, + &["pubsub", "loading", "stale", "fast"], + 0, + 0, + 0, + "pub/sub", + "Publishes a message to all subscribers of the given channel and all clients with matching pattern subscriptions.", + ), + spec( + "punsubscribe", + -1, + &["pubsub", "noscript", "loading", "stale"], + 0, + 0, + 0, + "pub/sub", + "Unsubscribes from pattern subscriptions.", + ), + spec("rename", 3, &["write"], 1, 2, 1, "keys", "Renames a key."), + spec( + "replicaof", + 3, + &["admin", "noscript", "stale", "no_async_loading"], + 0, + 0, + 0, + "replication", + "Promotes this replica to a primary: it stops following its upstream and begins accepting writes.", + ), + spec( + "rpop", + -2, + &["write", "fast"], + 1, + 1, + 1, + "list", + "Removes and returns the last element (or count elements) from the list.", + ), + spec( + "rpush", + -3, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "list", + "Appends one or more elements to the tail of a list.", + ), + spec( + "rpushx", + -3, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "list", + "Like RPUSH, but only if the key already exists.", + ), + spec( + "sadd", + -3, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "set", + "Adds one or more members to a set.", + ), + spec( + "save", + 1, + &["admin", "noscript", "no_async_loading", "no_multi"], + 0, + 0, + 0, + "persistence", + "Synchronously writes a snapshot to disk.", + ), + spec( + "scan", + -2, + &["readonly"], + 0, + 0, + 0, + "keys", + "Iterates keys incrementally, returning at most COUNT keys per call (default 10) plus the next cursor.", + ), + spec( + "scard", + 2, + &["readonly", "fast"], + 1, + 1, + 1, + "set", + "Returns the number of members in the set.", + ), + spec( + "sdiff", + -2, + &["readonly"], + 1, + -1, + 1, + "set", + "Returns the members in the first set that are not in any of the other sets.", + ), + spec( + "sdiffstore", + -3, + &["write", "denyoom"], + 1, + -1, + 1, + "set", + "Stores the difference into destination and returns its size.", + ), + spec( + "set", + -3, + &["write", "denyoom"], + 1, + 1, + 1, + "strings", + "Set a key to a string value.", + ), + spec( + "setex", + 4, + &["write", "denyoom"], + 1, + 1, + 1, + "strings", + "Set a key with an integer-second expiry.", + ), + spec( + "setnx", + 3, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "strings", + "Set a key only if it does not exist.", + ), + spec( + "sinter", + -2, + &["readonly"], + 1, + -1, + 1, + "set", + "Returns the intersection of all given sets.", + ), + spec( + "sinterstore", + -3, + &["write", "denyoom"], + 1, + -1, + 1, + "set", + "Stores the intersection into destination and returns its size.", + ), + spec( + "sismember", + 3, + &["readonly", "fast"], + 1, + 1, + 1, + "set", + "Returns 1 if the member exists in the set, 0 otherwise.", + ), + spec( + "smembers", + 2, + &["readonly"], + 1, + 1, + 1, + "set", + "Returns all members of the set.", + ), + spec( + "smismember", + -3, + &["readonly", "fast"], + 1, + 1, + 1, + "set", + "Returns an array of 1/0 values for each member, indicating existence.", + ), + spec( + "smove", + 4, + &["write", "fast"], + 1, + 2, + 1, + "set", + "Atomically moves a member from one set to another.", + ), + spec( + "spop", + -2, + &["write", "fast"], + 1, + 1, + 1, + "set", + "Removes and returns one or more random members from the set.", + ), + spec( + "srandmember", + -2, + &["readonly"], + 1, + 1, + 1, + "set", + "Returns one or more random members without removing them.", + ), + spec( + "srem", + -3, + &["write", "fast"], + 1, + 1, + 1, + "set", + "Removes one or more members from a set.", + ), + spec( + "sscan", + -3, + &["readonly"], + 1, + 1, + 1, + "set", + "Iterates a set incrementally: returns the next cursor plus at most COUNT members (default 10).", + ), + spec( + "strlen", + 2, + &["readonly", "fast"], + 1, + 1, + 1, + "strings", + "Returns the length of the string stored at key.", + ), + spec( + "subscribe", + -2, + &["pubsub", "noscript", "loading", "stale"], + 0, + 0, + 0, + "pub/sub", + "Subscribes the client to one or more channels.", + ), + spec( + "sunion", + -2, + &["readonly"], + 1, + -1, + 1, + "set", + "Returns the union of all given sets.", + ), + spec( + "sunionstore", + -3, + &["write", "denyoom"], + 1, + -1, + 1, + "set", + "Stores the union into destination and returns its size.", + ), + spec( + "ttl", + 2, + &["readonly", "fast"], + 1, + 1, + 1, + "expiry", + "Returns the remaining time-to-live of a key in seconds.", + ), + spec( + "type", + 2, + &["readonly", "fast"], + 1, + 1, + 1, + "keys", + "Returns the type of the value stored at key: string, hash, list, set, zset, ratelimit, or none if the key does not exist.", + ), + spec( + "unlink", + -2, + &["write", "fast"], + 1, + -1, + 1, + "keys", + "Non-blocking delete.", + ), + spec( + "unsubscribe", + -1, + &["pubsub", "noscript", "loading", "stale"], + 0, + 0, + 0, + "pub/sub", + "Unsubscribes from the given channels.", + ), + spec( + "unwatch", + 1, + &["noscript", "loading", "stale", "fast", "allow_busy"], + 0, + 0, + 0, + "observable keys", + "Stops watching the given keys.", + ), + spec( + "watch", + -2, + &["noscript", "loading", "stale", "fast", "allow_busy"], + 1, + -1, + 1, + "observable keys", + "Marks the given key(s) for optimistic locking, and (over WebSocket) registers the connection for keychange push notifications.", + ), + spec( + "zadd", + -4, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "sorted set", + "Adds members with scores.", + ), + spec( + "zcard", + 2, + &["readonly", "fast"], + 1, + 1, + 1, + "sorted set", + "Returns the number of members in the sorted set.", + ), + spec( + "zcount", + 4, + &["readonly", "fast"], + 1, + 1, + 1, + "sorted set", + "Returns the number of members with scores between min and max.", + ), + spec( + "zincrby", + 4, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "sorted set", + "Adds increment to the score of member.", + ), + spec( + "zmscore", + -3, + &["readonly", "fast"], + 1, + 1, + 1, + "sorted set", + "Returns the scores of multiple members.", + ), + spec( + "zrange", + -4, + &["readonly"], + 1, + 1, + 1, + "sorted set", + "Returns members between rank start and stop (0-based, ascending).", + ), + spec( + "zrangebyscore", + -4, + &["readonly"], + 1, + 1, + 1, + "sorted set", + "Returns members with scores between min and max.", + ), + spec( + "zrank", + -3, + &["readonly", "fast"], + 1, + 1, + 1, + "sorted set", + "Returns the 0-based rank of a member in ascending score order.", + ), + spec( + "zrem", + -3, + &["write", "fast"], + 1, + 1, + 1, + "sorted set", + "Removes members from the sorted set.", + ), + spec( + "zrevrange", + -4, + &["readonly"], + 1, + 1, + 1, + "sorted set", + "Same as ZRANGE, but returns members in descending score order.", + ), + spec( + "zrevrangebyscore", + -4, + &["readonly"], + 1, + 1, + 1, + "sorted set", + "Same, but from high score to low.", + ), + spec( + "zrevrank", + -3, + &["readonly", "fast"], + 1, + 1, + 1, + "sorted set", + "Returns the rank in descending score order.", + ), + spec( + "zscan", + -3, + &["readonly"], + 1, + 1, + 1, + "sorted set", + "Iterates a sorted set incrementally: returns the next cursor plus at most COUNT member, score pairs (default 10).", + ), + spec( + "zscore", + 3, + &["readonly", "fast"], + 1, + 1, + 1, + "sorted set", + "Returns the score of a member, or nil if the member does not exist.", + ), + // ── Handshake and introspection ─────────────────────────────────────── + spec( + "quit", + -1, + &[ + "noscript", + "loading", + "stale", + "fast", + "no_auth", + "allow_busy", + ], + 0, + 0, + 0, + "core", + "Closes the connection after replying OK.", + ), + spec( + "client", + -2, + &["noscript", "loading", "stale"], + 0, + 0, + 0, + "core", + "Connection introspection: ID, INFO, LIST, GETNAME, SETNAME, SETINFO.", + ), + spec( + "config", + -2, + &["admin", "noscript", "loading", "stale"], + 0, + 0, + 0, + "core", + "Reads server configuration. Recached's configuration is start-time only, so CONFIG SET is refused.", + ), + spec( + "command", + -1, + &["loading", "stale"], + 0, + 0, + 0, + "core", + "Reports the command catalog: COUNT, LIST, INFO and DOCS.", + ), + // ── Recached-only ───────────────────────────────────────────────────── + // No Redis counterpart, so these rows are declared rather than transcribed. + spec( + "eset", + 3, + &["write", "denyoom", "fast"], + 1, + 1, + 1, + "strings", + "Ephemeral set: stores a string whose lifetime is bound to the connection that wrote it.", + ), + spec( + "jset", + 4, + &["write", "denyoom"], + 1, + 1, + 1, + "json", + "Sets JSON at a path ($ is the whole document).", + ), + spec( + "jget", + -2, + &["readonly"], + 1, + 1, + 1, + "json", + "Reads JSON at a path, serialized. Defaults to $.", + ), + spec( + "jmerge", + 3, + &["write", "denyoom"], + 1, + 1, + 1, + "json", + "Applies an RFC 7386 JSON Merge Patch to the whole document.", + ), + spec( + "rlset", + 4, + &["write", "fast"], + 1, + 1, + 1, + "rate limiting", + "Configures a sliding-window rate limiter on a key.", + ), + spec( + "rlcheck", + -2, + &["write", "fast"], + 1, + 1, + 1, + "rate limiting", + "Records an attempt and reports [allowed, remaining, retry_after_ms].", + ), + // Not Redis's replication SYNC, which shares the name and nothing else. + spec( + "sync", + -1, + &["noscript", "loading", "stale"], + 0, + 0, + 0, + "sync scoping", + "Sets this WebSocket connection's sync scopes, or reports them when called with no arguments.", + ), + spec( + "dedup", + -4, + &["write", "noscript"], + 0, + 0, + 0, + "sync scoping", + "Wraps a write with a per-client monotonic id so an offline replay is skipped.", + ), + spec( + "qsub", + 2, + &["pubsub", "noscript"], + 0, + 0, + 0, + "live queries", + "Subscribes to a live query: current state of every matching key, then keychange pushes.", + ), + spec( + "qunsub", + -1, + &["pubsub", "noscript"], + 0, + 0, + 0, + "live queries", + "Drops one live query, or all of them without an argument.", + ), +]; + +/// Look a command up by name, case-insensitively. Returns `None` for anything +/// the parser would answer `unknown command` to. +pub fn lookup(name: &str) -> Option<&'static CommandSpec> { + CATALOG.iter().find(|c| c.name.eq_ignore_ascii_case(name)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::Command; + use crate::resp::Value; + + /// Every catalog row must name a command the parser actually accepts. + /// + /// The reverse direction — every command the parser accepts has a row — is + /// checked in the server by `every_command_is_in_the_catalog`, which walks + /// the exhaustive `Command` variant table. Together they close the loop: a + /// command cannot be added, removed or renamed without one of them failing. + #[test] + fn catalog_names_are_real_commands() { + for spec in CATALOG { + let frame = Value::Array(Some(vec![Value::BulkString(Some( + spec.name.as_bytes().to_vec(), + ))])); + // Parsing bare `NAME` either succeeds or fails on arity; what it + // must never do is fall through to Unknown, which is what the + // server answers `unknown command` to. + if let Ok(Command::Unknown(_)) = Command::from_value(frame) { + panic!( + "catalog lists '{}', which the parser does not know", + spec.name + ); + } + } + } + + #[test] + fn catalog_has_no_duplicate_names() { + let mut names: Vec<&str> = CATALOG.iter().map(|c| c.name).collect(); + names.sort_unstable(); + let before = names.len(); + names.dedup(); + assert_eq!(before, names.len(), "a command is listed twice"); + } + + #[test] + fn catalog_names_are_lowercase() { + // Redis reports lowercase names, and clients index the COMMAND DOCS + // map by exactly the string they get back. + for spec in CATALOG { + assert_eq!(spec.name, spec.name.to_lowercase(), "{}", spec.name); + } + } + + #[test] + fn every_command_has_a_summary() { + for spec in CATALOG { + assert!( + !spec.summary.is_empty(), + "{} has no summary, so COMMAND DOCS would describe it as nothing", + spec.name + ); + assert!(!spec.group.is_empty(), "{} has no group", spec.name); + } + } + + #[test] + fn key_positions_agree_with_arity() { + for spec in CATALOG { + if spec.first_key == 0 { + assert_eq!( + spec.step, 0, + "{} claims no keys but a non-zero step", + spec.name + ); + continue; + } + assert!(spec.step >= 1, "{} has keys but no stride", spec.name); + // A command whose first key sits at position N needs at least N+1 + // arguments including its own name. + let min_args = spec.arity.abs(); + assert!( + min_args > spec.first_key, + "{}: first key at {} but arity is {}", + spec.name, + spec.first_key, + spec.arity + ); + } + } + + #[test] + fn lookup_is_case_insensitive() { + assert_eq!(lookup("GET").map(|c| c.name), Some("get")); + assert_eq!(lookup("HsCaN").map(|c| c.name), Some("hscan")); + assert!(lookup("nosuchcommand").is_none()); + } +} diff --git a/core-engine/src/cmd.rs b/core-engine/src/cmd.rs index 6538471..b774bde 100644 --- a/core-engine/src/cmd.rs +++ b/core-engine/src/cmd.rs @@ -1,4 +1,32 @@ use crate::resp::Value; +use crate::store::MAX_PATTERN_BYTES; + +/// Reject a glob pattern longer than `MAX_PATTERN_BYTES`. +/// +/// Matching costs O(pattern × text) and runs once per key for `KEYS`/`SCAN +/// MATCH`, once per key per write for sync scopes, and once per published +/// message per subscriber for `PSUBSCRIBE`. Pattern length was previously +/// bounded only by `proto-max-bulk-len` (64 MB), so one command could occupy the +/// process for an unbounded time. Rejected at parse time so no execution path +/// has to think about it. +fn check_pattern(p: &str) -> Result<(), String> { + if p.len() > MAX_PATTERN_BYTES { + return Err(format!( + "ERR pattern is too long ({} > {} bytes)", + p.len(), + MAX_PATTERN_BYTES + )); + } + Ok(()) +} + +/// `check_pattern` for a list of patterns. +fn check_patterns(ps: &[String]) -> Result<(), String> { + for p in ps { + check_pattern(p)?; + } + Ok(()) +} // ── SET options ─────────────────────────────────────────────────────────────── @@ -41,6 +69,22 @@ pub struct ZAddOptions { pub incr: bool, } +// ── Collection scan options ─────────────────────────────────────────────────── + +/// Arguments shared by `HSCAN` / `SSCAN` / `ZSCAN`. +/// +/// The cursor is an offset into the collection's name-ordered element list — +/// the same scheme keyspace `SCAN` uses — so a cursor is only meaningful when +/// the same `MATCH` is passed on every call of an iteration, as Redis requires. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ScanArgs { + pub cursor: u64, + pub pattern: Option, + pub count: Option, + /// `HSCAN` only: reply with field names and no values (Redis 7.4). + pub novalues: bool, +} + // ── Command enum ────────────────────────────────────────────────────────────── #[derive(Debug, Clone, PartialEq)] @@ -55,6 +99,17 @@ pub enum Command { /// server, not the store, so the store refuses it. An empty section list /// means the default set. Info(Vec), + /// `QUIT` — reply `+OK`, then close. Connection-level: only the connection + /// task can honour it, so the store never sees it. + Quit, + /// `CLIENT [arg ...]` — connection introspection. Held as the + /// raw argument list because every answer depends on the connection or on + /// the client registry, neither of which the store knows about. + Client(Vec), + /// `CONFIG [arg ...]` — server configuration. + Config(Vec), + /// `COMMAND [subcommand] [arg ...]` — the command catalog. + CommandQuery(Vec), // ── Strings ────────────────────────────────────────────────────────────── Set(String, Vec, SetOptions), Get(String), @@ -66,6 +121,10 @@ pub enum Command { Unlink(Vec), Append(String, Vec), Strlen(String), + /// `GETRANGE key start end` — inclusive byte range, negative offsets + /// counting back from the end. The bounded counterpart of `GET`: reading a + /// window of a large value must not cost the whole value. + GetRange(String, i64, i64), GetSet(String, Vec), MGet(Vec), MSet(Vec<(String, Vec)>), @@ -105,6 +164,9 @@ pub enum Command { HExists(String, String), HSetNx(String, String, Vec), HMGet(String, Vec), + /// `HSCAN key cursor [MATCH pat] [COUNT n] [NOVALUES]` — the bounded + /// counterpart of `HGETALL`, which has to clone an entire hash. + HScan(String, ScanArgs), // ── List ───────────────────────────────────────────────────────────────── LPush(String, Vec>), RPush(String, Vec>), @@ -134,6 +196,9 @@ pub enum Command { SPop(String, Option), SRandMember(String, Option), SMove(String, String, String), + /// `SSCAN key cursor [MATCH pat] [COUNT n]` — the bounded counterpart of + /// `SMEMBERS`. + SScan(String, ScanArgs), // ── Sorted Set ─────────────────────────────────────────────────────────── ZAdd(String, ZAddOptions, Vec<(f64, String)>), ZRange(String, i64, i64, bool), @@ -148,6 +213,10 @@ pub enum Command { ZCard(String), ZIncrBy(String, f64, String), ZCount(String, String, String), + /// `ZSCAN key cursor [MATCH pat] [COUNT n]` — pages `member, score` pairs. + /// Ordered by member, not by score: the cursor is an offset, and only the + /// member ordering survives a score being updated mid-iteration. + ZScan(String, ScanArgs), // ── JSON ────────────────────────────────────────────────────────────────── /// JSET key path value — set JSON at a path (`$` = whole document). JSet(String, String, String), @@ -279,6 +348,20 @@ impl Command { .map(|v| extract_string(v).unwrap_or_default().to_lowercase()) .collect(), )), + "QUIT" => Ok(Command::Quit), + // The subcommand and its arguments are carried through + // verbatim. Parsing them here would split the knowledge of + // what each subcommand means across two files, and every + // answer is the connection layer's to give anyway. + "CLIENT" => { + need!(2); + Ok(Command::Client(collect_strings(&mut arr[1..]))) + } + "CONFIG" => { + need!(2); + Ok(Command::Config(collect_strings(&mut arr[1..]))) + } + "COMMAND" => Ok(Command::CommandQuery(collect_strings(&mut arr[1..]))), // ── Strings ─────────────────────────────────────────────── "SET" => { @@ -383,6 +466,14 @@ impl Command { need!(2); Ok(Command::Strlen(extract_key(&arr[1])?)) } + "GETRANGE" => { + need!(4); + Ok(Command::GetRange( + extract_key(&arr[1])?, + extract_int(&arr[2])?, + extract_int(&arr[3])?, + )) + } "GETSET" => { need!(3); let key = take_key(&mut arr[1])?; @@ -533,41 +624,14 @@ impl Command { } "KEYS" => { need!(2); - Ok(Command::Keys(extract_string(&arr[1]).unwrap_or_default())) + let pattern = extract_string(&arr[1]).unwrap_or_default(); + check_pattern(&pattern)?; + Ok(Command::Keys(pattern)) } "SCAN" => { need!(2); - let cursor = extract_string(&arr[1]) - .unwrap_or_default() - .parse::() - .map_err(|_| { - "ERR value is not an integer or out of range".to_string() - })?; - let mut pattern = None; - let mut count = None; - let mut i = 2usize; - while i < arr.len() { - let opt = extract_string(&arr[i]).unwrap_or_default().to_uppercase(); - match opt.as_str() { - "MATCH" => { - i += 1; - if i >= arr.len() { - return Err("ERR syntax error".to_string()); - } - pattern = extract_string(&arr[i]); - } - "COUNT" => { - i += 1; - if i >= arr.len() { - return Err("ERR syntax error".to_string()); - } - count = Some(extract_int(&arr[i])? as usize); - } - _ => return Err("ERR syntax error".to_string()), - } - i += 1; - } - Ok(Command::Scan(cursor, pattern, count)) + let args = parse_scan_args(&arr[1], &arr[2..], false)?; + Ok(Command::Scan(args.cursor, args.pattern, args.count)) } "DBSIZE" => Ok(Command::DbSize), "FLUSHDB" => Ok(Command::FlushDb), @@ -584,12 +648,16 @@ impl Command { } // ── Sync scoping ─────────────────────────────────────────── - "SYNC" => Ok(Command::Sync( - arr[1..] + "SYNC" => { + let patterns: Vec = arr[1..] .iter() .map(|v| extract_string(v).unwrap_or_default()) - .collect(), - )), + .collect(); + // A signed token is checked separately in the server + // layer; this covers the inline-pattern form. + check_patterns(&patterns)?; + Ok(Command::Sync(patterns)) + } // ── Exactly-once delivery ────────────────────────────────── "DEDUP" => { @@ -616,6 +684,7 @@ impl Command { if pattern.is_empty() { return Err("ERR QSUB requires a non-empty pattern".to_string()); } + check_pattern(&pattern)?; Ok(Command::QSub(pattern)) } "QUNSUB" => { @@ -624,6 +693,9 @@ impl Command { } else { None }; + if let Some(p) = &pattern { + check_pattern(p)?; + } Ok(Command::QUnsub(pattern)) } @@ -762,6 +834,14 @@ impl Command { let fields = arr[2..].iter_mut().filter_map(take_string).collect(); Ok(Command::HMGet(key, fields)) } + "HSCAN" => { + need!(3); + let key = extract_key(&arr[1])?; + Ok(Command::HScan( + key, + parse_scan_args(&arr[2], &arr[3..], true)?, + )) + } // ── List ─────────────────────────────────────────────────── "LPUSH" => { @@ -952,6 +1032,14 @@ impl Command { extract_string(&arr[3]).unwrap_or_default(), )) } + "SSCAN" => { + need!(3); + let key = extract_key(&arr[1])?; + Ok(Command::SScan( + key, + parse_scan_args(&arr[2], &arr[3..], false)?, + )) + } // ── Sorted Set ───────────────────────────────────────────── "ZADD" => { @@ -1118,6 +1206,14 @@ impl Command { extract_string(&arr[3]).unwrap_or_default(), )) } + "ZSCAN" => { + need!(3); + let key = extract_key(&arr[1])?; + Ok(Command::ZScan( + key, + parse_scan_args(&arr[2], &arr[3..], false)?, + )) + } // ── Transactions ───────────────────────────────────── "MULTI" => Ok(Command::Multi), @@ -1136,13 +1232,20 @@ impl Command { )), "PSUBSCRIBE" => { need!(2); - Ok(Command::PSubscribe( - arr[1..].iter_mut().filter_map(take_string).collect(), - )) + let patterns: Vec = + arr[1..].iter_mut().filter_map(take_string).collect(); + // Matched against every published channel name for the + // life of the subscription, so an unbounded pattern here + // taxes every PUBLISH, not just one command. + check_patterns(&patterns)?; + Ok(Command::PSubscribe(patterns)) + } + "PUNSUBSCRIBE" => { + let patterns: Vec = + arr[1..].iter_mut().filter_map(take_string).collect(); + check_patterns(&patterns)?; + Ok(Command::PUnsubscribe(patterns)) } - "PUNSUBSCRIBE" => Ok(Command::PUnsubscribe( - arr[1..].iter_mut().filter_map(take_string).collect(), - )), "PUBLISH" => { need!(3); Ok(Command::Publish( @@ -1219,6 +1322,12 @@ fn parse_rl_config(limit: &Value, window: &Value, cmd: &str) -> Result<(u64, u64 Ok((limit as u64, window as u64)) } +/// Move every argument out as a string, for the commands whose arguments are +/// an opaque subcommand tail (`CLIENT`, `CONFIG`, `COMMAND`). +fn collect_strings(vals: &mut [Value]) -> Vec { + vals.iter_mut().filter_map(take_string).collect() +} + fn extract_key(val: &Value) -> Result { let key = extract_string(val).unwrap_or_default(); validate_key(&key)?; @@ -1349,6 +1458,62 @@ fn extract_float(val: &Value) -> Result { } } +/// Parse the `cursor [MATCH pat] [COUNT n] [NOVALUES]` tail shared by `SCAN` +/// and the collection scans. `novalues_ok` gates the HSCAN-only token, so +/// `SSCAN k 0 NOVALUES` is a syntax error rather than a silently ignored +/// argument — a client that asked for a shape it did not get is worse off than +/// one told no. +fn parse_scan_args( + cursor: &Value, + tokens: &[Value], + novalues_ok: bool, +) -> Result { + let cursor = extract_string(cursor) + .unwrap_or_default() + .parse::() + .map_err(|_| "ERR invalid cursor".to_string())?; + let mut args = ScanArgs { + cursor, + ..Default::default() + }; + let mut i = 0usize; + while i < tokens.len() { + let opt = extract_string(&tokens[i]) + .unwrap_or_default() + .to_uppercase(); + match opt.as_str() { + "MATCH" => { + i += 1; + if i >= tokens.len() { + return Err("ERR syntax error".to_string()); + } + let pattern = extract_string(&tokens[i]); + if let Some(p) = &pattern { + check_pattern(p)?; + } + args.pattern = pattern; + } + "COUNT" => { + i += 1; + if i >= tokens.len() { + return Err("ERR syntax error".to_string()); + } + // Redis rejects a non-positive COUNT rather than treating it as + // "unbounded"; casting it to usize here would wrap into one. + let n = extract_int(&tokens[i])?; + if n < 1 { + return Err("ERR syntax error".to_string()); + } + args.count = Some(n as usize); + } + "NOVALUES" if novalues_ok => args.novalues = true, + _ => return Err("ERR syntax error".to_string()), + } + i += 1; + } + Ok(args) +} + /// Parse `[WITHSCORES] [LIMIT offset count]` options for ZRANGEBYSCORE / ZREVRANGEBYSCORE. fn parse_zrange_opts(tokens: &[Value]) -> Result<(bool, Option<(i64, i64)>), String> { let mut withscores = false; @@ -1865,6 +2030,109 @@ mod tests { ); } + #[test] + fn handshake_commands_parse() { + assert_eq!( + Command::from_value(array(&["QUIT"])).unwrap(), + Command::Quit + ); + assert_eq!( + Command::from_value(array(&["CLIENT", "SETINFO", "LIB-NAME", "node-redis"])).unwrap(), + Command::Client(vec![ + "SETINFO".into(), + "LIB-NAME".into(), + "node-redis".into() + ]) + ); + assert_eq!( + Command::from_value(array(&["CONFIG", "GET", "maxmemory*"])).unwrap(), + Command::Config(vec!["GET".into(), "maxmemory*".into()]) + ); + // Bare COMMAND is valid and means "the whole catalog". + assert_eq!( + Command::from_value(array(&["COMMAND"])).unwrap(), + Command::CommandQuery(vec![]) + ); + assert_eq!( + Command::from_value(array(&["COMMAND", "DOCS", "get"])).unwrap(), + Command::CommandQuery(vec!["DOCS".into(), "get".into()]) + ); + // CLIENT and CONFIG need a subcommand; the arity check is the parser's. + assert!(Command::from_value(array(&["CLIENT"])).is_err()); + assert!(Command::from_value(array(&["CONFIG"])).is_err()); + } + + #[test] + fn getrange_parse() { + assert_eq!( + Command::from_value(array(&["GETRANGE", "k", "0", "-1"])).unwrap(), + Command::GetRange("k".into(), 0, -1) + ); + assert!(Command::from_value(array(&["GETRANGE", "k", "0"])).is_err()); + assert!(Command::from_value(array(&["GETRANGE", "k", "0", "x"])).is_err()); + } + + #[test] + fn hscan_parse_full_options() { + assert_eq!( + Command::from_value(array(&[ + "HSCAN", "h", "12", "MATCH", "u:*", "COUNT", "50", "NOVALUES" + ])) + .unwrap(), + Command::HScan( + "h".into(), + ScanArgs { + cursor: 12, + pattern: Some("u:*".into()), + count: Some(50), + novalues: true, + } + ) + ); + } + + #[test] + fn sscan_and_zscan_parse_without_novalues() { + assert_eq!( + Command::from_value(array(&["SSCAN", "s", "0"])).unwrap(), + Command::SScan("s".into(), ScanArgs::default()) + ); + assert_eq!( + Command::from_value(array(&["ZSCAN", "z", "0", "COUNT", "3"])).unwrap(), + Command::ZScan( + "z".into(), + ScanArgs { + count: Some(3), + ..Default::default() + } + ) + ); + // NOVALUES is HSCAN-only. Accepting and ignoring it would hand back a + // reply shape the caller did not ask for. + assert!(Command::from_value(array(&["SSCAN", "s", "0", "NOVALUES"])).is_err()); + } + + #[test] + fn scan_family_rejects_bad_cursors_and_counts() { + for cmd in [ + vec!["SCAN", "abc"], + vec!["HSCAN", "h", "abc"], + vec!["SSCAN", "s", "-1"], + ] { + let err = Command::from_value(array(&cmd)).unwrap_err(); + assert_eq!(err, "ERR invalid cursor", "{cmd:?}"); + } + for cmd in [ + vec!["HSCAN", "h", "0", "COUNT", "0"], + vec!["SCAN", "0", "COUNT", "-5"], + vec!["ZSCAN", "z", "0", "MATCH"], + vec!["SSCAN", "s", "0", "BOGUS"], + ] { + let err = Command::from_value(array(&cmd)).unwrap_err(); + assert_eq!(err, "ERR syntax error", "{cmd:?}"); + } + } + #[test] fn rename_parse() { assert_eq!( @@ -2291,6 +2559,79 @@ mod parse_coverage_tests { // ── Live queries ────────────────────────────────────────────────────────── + #[test] + fn over_long_glob_patterns_are_rejected_at_parse_time() { + // Matching is O(pattern x text) and runs once per key for KEYS, once per + // key per write for sync scopes, and once per published message per + // subscriber for PSUBSCRIBE. Length was previously bounded only by + // proto-max-bulk-len (64 MB), so one command could occupy the process + // for an unbounded time. + let long = "a".repeat(MAX_PATTERN_BYTES + 1); + let cases: Vec> = vec![ + vec!["KEYS".into(), long.clone()], + vec!["SCAN".into(), "0".into(), "MATCH".into(), long.clone()], + vec![ + "HSCAN".into(), + "h".into(), + "0".into(), + "MATCH".into(), + long.clone(), + ], + vec![ + "SSCAN".into(), + "s".into(), + "0".into(), + "MATCH".into(), + long.clone(), + ], + vec![ + "ZSCAN".into(), + "z".into(), + "0".into(), + "MATCH".into(), + long.clone(), + ], + vec!["QSUB".into(), long.clone()], + vec!["QUNSUB".into(), long.clone()], + vec!["PSUBSCRIBE".into(), long.clone()], + vec!["PUNSUBSCRIBE".into(), long.clone()], + vec!["SYNC".into(), long.clone()], + vec!["SYNC".into(), "ok:*".into(), long.clone()], + ]; + for args in cases { + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let err = parse(&refs) + .expect_err(&format!("{:?} should reject an over-long pattern", refs[0])); + assert!( + err.contains("pattern is too long"), + "{}: got {err}", + refs[0] + ); + } + } + + #[test] + fn a_pattern_exactly_at_the_cap_is_accepted() { + // Off-by-one here would reject the largest legitimate pattern. + let at_cap = "a".repeat(MAX_PATTERN_BYTES); + assert_eq!( + parse(&["KEYS", &at_cap]).unwrap(), + Command::Keys(at_cap.clone()) + ); + assert!(parse(&["PSUBSCRIBE", &at_cap]).is_ok()); + assert!(parse(&["SYNC", &at_cap]).is_ok()); + } + + #[test] + fn ordinary_patterns_are_unaffected_by_the_cap() { + assert_eq!( + parse(&["KEYS", "user:*"]).unwrap(), + Command::Keys("user:*".into()) + ); + assert!(parse(&["SCAN", "0", "MATCH", "cart:42:*"]).is_ok()); + assert!(parse(&["QSUB", "cart:*"]).is_ok()); + } + #[test] fn qsub_requires_a_non_empty_pattern() { assert_eq!( @@ -2406,6 +2747,9 @@ mod arity_and_error_tests { &["GETSET", "k"], &["APPEND", "k"], &["STRLEN"], + &["GETRANGE", "k", "0"], + &["CLIENT"], + &["CONFIG"], &["INCR"], &["DECR"], &["INCRBY", "k"], @@ -2435,6 +2779,7 @@ mod arity_and_error_tests { &["HINCRBY", "h", "f"], &["HINCRBYFLOAT", "h", "f"], &["HSETNX", "h", "f"], + &["HSCAN", "h"], &["LPUSH", "l"], &["RPUSH", "l"], &["LPOP"], @@ -2456,6 +2801,7 @@ mod arity_and_error_tests { &["SMOVE", "a", "b"], &["SPOP"], &["SRANDMEMBER"], + &["SSCAN", "s"], &["ZADD", "z", "1"], &["ZSCORE", "z"], &["ZREM", "z"], @@ -2465,6 +2811,7 @@ mod arity_and_error_tests { &["ZRANGE", "z", "0"], &["ZREVRANGE", "z", "0"], &["ZCOUNT", "z", "0"], + &["ZSCAN", "z"], &["JSET", "j", "$"], &["JGET"], &["JMERGE", "j"], diff --git a/core-engine/src/lib.rs b/core-engine/src/lib.rs index 53bb377..dabf96e 100644 --- a/core-engine/src/lib.rs +++ b/core-engine/src/lib.rs @@ -1,3 +1,4 @@ +pub mod catalog; pub mod cmd; pub mod resp; pub mod store; diff --git a/core-engine/src/resp.rs b/core-engine/src/resp.rs index 09af0b6..fbb9052 100644 --- a/core-engine/src/resp.rs +++ b/core-engine/src/resp.rs @@ -1,6 +1,28 @@ const MAX_ARRAY_DEPTH: usize = 16; const MAX_ARRAY_ELEMENTS: usize = 1_000_000; -const MAX_BULK_STRING_BYTES: usize = 64 * 1024 * 1024; // 64 MB +/// Elements reserved up front for an aggregate, however many its header claims. +/// +/// The header is attacker-controlled and arrives before any of the elements do, +/// so reserving `count` capacity let a tiny input demand a large allocation: +/// `*1000000\r\n` is nine bytes and used to reserve a million `Value`s, and +/// nesting that to `MAX_ARRAY_DEPTH` multiplied it. Worse, an incomplete frame +/// is re-parsed from the start each time more bytes arrive, so a one-byte-per- +/// packet drip repeated the reservation on every packet — none of it counted +/// against `RECACHED_MAX_MEMORY`, which only tracks stored data. +/// +/// Reserving a fixed floor instead makes allocation proportional to bytes +/// *received* rather than bytes *claimed*; the vector still grows as real +/// elements are parsed, and `MAX_ARRAY_ELEMENTS` still rejects absurd headers. +const PREALLOC_ELEMENTS: usize = 1024; + +/// Capacity to reserve for an aggregate whose header declares `declared`. +fn prealloc_for(declared: usize) -> usize { + declared.min(PREALLOC_ELEMENTS) +} +/// Largest bulk string the parser will accept, in bytes. Public because +/// `CONFIG GET proto-max-bulk-len` has to report the limit actually enforced +/// rather than a second copy of the number. +pub const MAX_BULK_STRING_BYTES: usize = 64 * 1024 * 1024; // 64 MB const MAX_TOTAL_MESSAGE_BYTES: usize = 64 * 1024 * 1024; // 64 MB total per message #[derive(Debug, Clone, PartialEq)] @@ -196,7 +218,7 @@ impl Value { count, MAX_ARRAY_ELEMENTS )); } - let mut arr = Vec::with_capacity(count as usize); + let mut arr = Vec::with_capacity(prealloc_for(count as usize)); for _ in 0..count { let (val, len) = Self::parse_inner(&buffer[offset..], depth + 1)?; arr.push(val); @@ -227,7 +249,7 @@ impl Value { MAX_ARRAY_ELEMENTS / 2 )); } - let mut pairs = Vec::with_capacity(count as usize); + let mut pairs = Vec::with_capacity(prealloc_for(count as usize)); for _ in 0..count { let (k, klen) = Self::parse_inner(&buffer[offset..], depth + 1)?; offset += klen; @@ -267,7 +289,7 @@ impl Value { )); } - let mut arr = Vec::with_capacity(count as usize); + let mut arr = Vec::with_capacity(prealloc_for(count as usize)); for _ in 0..count { let (val, len) = Self::parse_inner(&buffer[offset..], depth + 1)?; arr.push(val); diff --git a/core-engine/src/store.rs b/core-engine/src/store.rs index 8395692..91b697e 100644 --- a/core-engine/src/store.rs +++ b/core-engine/src/store.rs @@ -1129,6 +1129,18 @@ impl KeyValueStore { Command::Info(_) => Value::Error( "ERR INFO is handled by the connection layer, not the store".to_string(), ), + Command::Quit => Value::Error( + "ERR QUIT is handled by the connection layer, not the store".to_string(), + ), + Command::Client(_) => Value::Error( + "ERR CLIENT is handled by the connection layer, not the store".to_string(), + ), + Command::Config(_) => Value::Error( + "ERR CONFIG is handled by the connection layer, not the store".to_string(), + ), + Command::CommandQuery(_) => Value::Error( + "ERR COMMAND is handled by the connection layer, not the store".to_string(), + ), // ── Strings ─────────────────────────────────────────────────────── Command::Set(key, val, opts) => { @@ -1259,6 +1271,22 @@ impl KeyValueStore { } } + Command::GetRange(key, start, end) => { + let now = now_ms(); + match self.data.get(&key) { + Some(e) if !e.is_expired(now) => match &e.value { + EntryValue::Str(s) => { + e.touch(now); + Value::BulkString(Some(byte_range(s.as_slice(), start, end).to_vec())) + } + _ => Value::Error(WRONGTYPE.to_string()), + }, + // A missing key is an empty string, so every range of it is + // empty — an empty bulk, not a nil. + _ => Value::BulkString(Some(Vec::new())), + } + } + Command::GetSet(key, new_val) => { let now = now_ms(); let old = match self.data.get(&key) { @@ -1624,6 +1652,40 @@ impl KeyValueStore { } } + Command::HScan(key, args) => { + let now = now_ms(); + match self.data.get(&key) { + None => empty_scan(), + Some(e) if e.is_expired(now) => empty_scan(), + Some(e) => match &e.value { + EntryValue::Hash(h) => { + e.touch(now); + let pat = args.pattern.as_deref().unwrap_or("*"); + let mut fields: Vec<(&str, &Blob)> = h + .iter() + .filter(|(f, _)| glob_match(pat, f)) + .map(|(f, v)| (f.as_str(), v)) + .collect(); + fields.sort_unstable_by_key(|(f, _)| *f); + let (page, next) = scan_page(&fields, args.cursor, args.count); + let out = page + .iter() + .flat_map(|(f, v)| { + let field = Value::BulkString(Some(f.as_bytes().to_vec())); + if args.novalues { + vec![field] + } else { + vec![field, Value::BulkString(Some(v.as_slice().to_vec()))] + } + }) + .collect(); + scan_reply(next, out) + } + _ => Value::Error(WRONGTYPE.to_string()), + }, + } + } + Command::HVals(key) => { let now = now_ms(); match self.data.get(&key) { @@ -2036,6 +2098,33 @@ impl KeyValueStore { } } + Command::SScan(key, args) => { + let now = now_ms(); + match self.data.get(&key) { + None => empty_scan(), + Some(e) if e.is_expired(now) => empty_scan(), + Some(e) => match &e.value { + EntryValue::Set(s) => { + e.touch(now); + let pat = args.pattern.as_deref().unwrap_or("*"); + let mut members: Vec<&str> = s + .iter() + .map(|m| m.as_str()) + .filter(|m| glob_match(pat, m)) + .collect(); + members.sort_unstable(); + let (page, next) = scan_page(&members, args.cursor, args.count); + let out = page + .iter() + .map(|m| Value::BulkString(Some(m.as_bytes().to_vec()))) + .collect(); + scan_reply(next, out) + } + _ => Value::Error(WRONGTYPE.to_string()), + }, + } + } + Command::SRem(key, members) => { let now = now_ms(); match self.data.get_mut(&key) { @@ -2544,6 +2633,28 @@ impl KeyValueStore { Ok(Value::Integer(count as i64)) }), + Command::ZScan(key, args) => zset_read(&self.data, &key, |zset| { + let pat = args.pattern.as_deref().unwrap_or("*"); + let mut members: Vec<(&str, f64)> = zset + .scores + .iter() + .filter(|(m, _)| glob_match(pat, m)) + .map(|(m, &s)| (m.as_str(), s)) + .collect(); + members.sort_unstable_by_key(|(m, _)| *m); + let (page, next) = scan_page(&members, args.cursor, args.count); + let out = page + .iter() + .flat_map(|(m, s)| { + [ + Value::BulkString(Some(m.as_bytes().to_vec())), + Value::BulkString(Some(format_score(*s).into_bytes())), + ] + }) + .collect(); + Ok(scan_reply(next, out)) + }), + // ── JSON ────────────────────────────────────────────────────────── Command::JSet(key, path, value) => { let now = now_ms(); @@ -2825,36 +2936,71 @@ fn set_expiry(data: &DashMap, key: String, ts_ms: u64) -> Value { } } -/// Glob match with `*`, `?`, and `[abc]` classes — iterative DP, O(m × n), -/// immune to backtracking blowup. Used by KEYS/SCAN and exported for the -/// server layer's sync-scope filtering. +/// Longest glob pattern accepted from a client, in bytes. +/// +/// Matching is O(pattern × text) in the worst case and runs once per key for +/// `KEYS`/`SCAN MATCH`, once per key per write for sync-scope filtering, and +/// once per published message per subscriber for `PSUBSCRIBE`. Pattern length +/// was previously bounded only by `proto-max-bulk-len` (64 MB), so a single +/// `KEYS ` against a large keyspace could occupy the process +/// for an unbounded time. No legitimate pattern is anywhere near this cap. +pub const MAX_PATTERN_BYTES: usize = 1024; + +/// Glob match with `*` and `?` against raw bytes. +/// +/// Two-pointer greedy matching with a single backtrack point: worst case +/// O(pattern × text) time, and **no allocation at all**. +/// +/// This is the third implementation. The first was recursive and backtracked +/// exponentially on patterns like `*a*a*a*b`; the second fixed the time bound +/// with an iterative DP but allocated two `Vec` of `text.len() + 1` on +/// *every call*, so a single 64 MB value made `KEYS *` allocate 128 MB — on a +/// path that runs once per key. The greedy form keeps the DP's time bound and +/// needs no memory, which is why it replaced it. +/// +/// Supports `*` (any run of bytes, including empty) and `?` (exactly one byte). +/// **Character classes like `[abc]` are not supported** — brackets match +/// literally, so `[ab]` matches the four-byte string `[ab]` and not `a`. An +/// earlier version of this comment claimed class support; it was never +/// implemented. Matching is byte-wise, so `?` matches one *byte* and a +/// multi-byte UTF-8 character spans several positions. pub fn glob_match(pattern: &str, s: &str) -> bool { let pat = pattern.as_bytes(); let text = s.as_bytes(); let (m, n) = (pat.len(), text.len()); - // Iterative DP: prev[j] = pat[..i] matches text[..j]. - // This replaces a recursive matcher that had exponential worst-case - // backtracking on patterns like "*.*.*x" against long non-matching strings. - let mut prev = vec![false; n + 1]; - let mut curr = vec![false; n + 1]; - prev[0] = true; - - for i in 1..=m { - curr[0] = pat[i - 1] == b'*' && prev[0]; - for j in 1..=n { - curr[j] = if pat[i - 1] == b'*' { - prev[j] || curr[j - 1] - } else if pat[i - 1] == b'?' || pat[i - 1] == text[j - 1] { - prev[j - 1] - } else { - false - }; + let mut i = 0usize; // position in text + let mut j = 0usize; // position in pattern + // The most recent `*` and the text position it was first allowed to stop at. + // Only the latest `*` needs remembering: any backtracking an earlier one + // could do, the latest one can do too. + let mut star: Option = None; + let mut resume = 0usize; + + while i < n { + if j < m && (pat[j] == b'?' || pat[j] == text[i]) { + i += 1; + j += 1; + } else if j < m && pat[j] == b'*' { + star = Some(j); + j += 1; + resume = i; + } else if let Some(star_at) = star { + // The last `*` consumed too little — give it one more byte and + // retry. `resume` only ever advances, which is what bounds this. + j = star_at + 1; + resume += 1; + i = resume; + } else { + return false; } - std::mem::swap(&mut prev, &mut curr); } - prev[n] + // Any `*` left over matches the empty remainder; anything else does not. + while j < m && pat[j] == b'*' { + j += 1; + } + j == m } fn no_list_response(count: Option) -> Value { @@ -3036,6 +3182,75 @@ fn hash_incr_float(data: &DashMap, key: String, field: String, de Value::BulkString(Some(new_str.into_bytes())) } +/// Resolve `GETRANGE`'s inclusive bounds against a value of `data.len()` bytes. +/// +/// Redis clamps the two ends asymmetrically and the difference matters: `end` +/// is pulled back to the last byte, but `start` is left alone, so a `start` +/// past the end of the value inverts the range and yields nothing instead of +/// the final byte. +fn byte_range(data: &[u8], start: i64, end: i64) -> &[u8] { + let len = data.len() as i64; + if len == 0 { + return &[]; + } + let mut s = if start < 0 { + start.saturating_add(len) + } else { + start + }; + let mut e = if end < 0 { + end.saturating_add(len) + } else { + end + }; + if s < 0 { + s = 0; + } + if e < 0 { + e = 0; + } + if e >= len { + e = len - 1; + } + if s > e { + return &[]; + } + // `s <= e < len` here, so both casts are in range. + &data[s as usize..=e as usize] +} + +/// Take one page out of an ordered element list for the collection scans. +/// +/// The cursor is an offset rather than Redis's reverse-binary bucket index: +/// there is no incremental rehashing to survive here, and keyspace `SCAN` +/// already established the convention. The ordering must be stable across +/// calls for the offset to mean anything, which is why every caller sorts by +/// field or member name. A cursor past the end returns an empty final page +/// rather than erroring, matching what Redis does with a stale cursor. +fn scan_page(items: &[T], cursor: u64, count: Option) -> (&[T], u64) { + let batch = count.unwrap_or(10).max(1); + let start = (cursor as usize).min(items.len()); + let end = start.saturating_add(batch).min(items.len()); + let next = if end >= items.len() { 0 } else { end as u64 }; + (&items[start..end], next) +} + +/// `[cursor, [elements…]]` — the two-element reply every SCAN family member +/// returns. The cursor is a bulk string, as in Redis, because it may exceed +/// what a RESP integer promises. +fn scan_reply(next: u64, items: Vec) -> Value { + Value::Array(Some(vec![ + Value::BulkString(Some(next.to_string().into_bytes())), + Value::Array(Some(items)), + ])) +} + +/// The reply for scanning a key that does not exist: a completed iteration +/// over nothing, which is also what Redis answers. +fn empty_scan() -> Value { + scan_reply(0, vec![]) +} + fn zset_read(data: &DashMap, key: &str, f: F) -> Value where F: FnOnce(&ZSetInner) -> Result, @@ -3145,7 +3360,7 @@ fn zadd_exec(zset: &mut ZSetInner, opts: ZAddOptions, pairs: Vec<(f64, String)>) #[cfg(test)] mod tests { use super::*; - use crate::cmd::{Command, SetOptions, ZAddOptions}; + use crate::cmd::{Command, ScanArgs, SetOptions, ZAddOptions}; fn store() -> KeyValueStore { KeyValueStore::new() @@ -3695,6 +3910,76 @@ mod tests { assert_eq!(s.execute(Command::Strlen("new".into())), int(3)); } + #[test] + fn getrange_slices_with_inclusive_bounds() { + let s = store(); + s.execute(Command::Set( + "k".into(), + "This is a string".into(), + SetOptions::default(), + )); + assert_eq!(s.execute(Command::GetRange("k".into(), 0, 3)), bulk("This")); + assert_eq!( + s.execute(Command::GetRange("k".into(), -3, -1)), + bulk("ing") + ); + assert_eq!( + s.execute(Command::GetRange("k".into(), 0, -1)), + bulk("This is a string") + ); + assert_eq!( + s.execute(Command::GetRange("k".into(), 10, 100)), + bulk("string"), + "an end past the value clamps to the last byte" + ); + } + + #[test] + fn getrange_empty_cases() { + let s = store(); + s.execute(Command::Set( + "k".into(), + "hello".into(), + SetOptions::default(), + )); + // A start past the end inverts the range — Redis clamps `end` but not + // `start`, so this is empty rather than the final byte. + assert_eq!(s.execute(Command::GetRange("k".into(), 10, 20)), bulk("")); + assert_eq!(s.execute(Command::GetRange("k".into(), 3, 1)), bulk("")); + // A missing key is an empty string, and an empty bulk — not a nil. + assert_eq!( + s.execute(Command::GetRange("ghost".into(), 0, -1)), + bulk("") + ); + // Offsets far below zero clamp to the start rather than wrapping. + assert_eq!( + s.execute(Command::GetRange("k".into(), -100, 1)), + bulk("he") + ); + assert_eq!( + s.execute(Command::GetRange("k".into(), i64::MIN, i64::MAX)), + bulk("hello") + ); + } + + #[test] + fn getrange_is_binary_safe_and_type_checked() { + let s = store(); + s.execute(Command::Set( + "b".into(), + vec![0xff, 0x00, 0xfe], + SetOptions::default(), + )); + assert_eq!( + s.execute(Command::GetRange("b".into(), 0, 1)), + Value::BulkString(Some(vec![0xff, 0x00])) + ); + s.execute(Command::HSet("h".into(), vec![("f".into(), "v".into())])); + assert!( + matches!(s.execute(Command::GetRange("h".into(), 0, -1)), Value::Error(e) if e.starts_with("WRONGTYPE")) + ); + } + #[test] fn string_getset() { let s = store(); @@ -3974,6 +4259,188 @@ mod tests { assert_eq!(seen, vec!["k0", "k1", "k2", "k3", "k4"]); } + // ── Collection scans ────────────────────────────────────────────────────── + + /// Unpack a `[cursor, [elements…]]` reply into the pair it represents. + fn scan_parts(v: &Value) -> (u64, Vec) { + let Value::Array(Some(parts)) = v else { + panic!("scan reply must be an array, got {v:?}") + }; + assert_eq!(parts.len(), 2, "scan reply is [cursor, elements]"); + let Value::BulkString(Some(c)) = &parts[0] else { + panic!("cursor must be a bulk string") + }; + let cursor: u64 = String::from_utf8_lossy(c).parse().expect("numeric cursor"); + let Value::Array(Some(items)) = &parts[1] else { + panic!("elements must be an array") + }; + let items = items + .iter() + .map(|i| match i { + Value::BulkString(Some(d)) => String::from_utf8_lossy(d).into_owned(), + other => panic!("element must be a bulk string, got {other:?}"), + }) + .collect(); + (cursor, items) + } + + /// Walk a scan to completion in pages of `count`, returning every element + /// in the order the cursor produced them. Panics rather than looping + /// forever if the cursor fails to terminate. + fn drain_scan( + s: &KeyValueStore, + cmd: impl Fn(ScanArgs) -> Command, + count: usize, + per_element: usize, + ) -> Vec { + let mut out = Vec::new(); + let mut cursor = 0u64; + for _ in 0..100 { + let (next, items) = scan_parts(&s.execute(cmd(ScanArgs { + cursor, + count: Some(count), + ..Default::default() + }))); + assert!( + items.len() <= count * per_element, + "page of {} exceeds COUNT {count}", + items.len() + ); + out.extend(items); + cursor = next; + if cursor == 0 { + return out; + } + } + panic!("cursor did not terminate"); + } + + #[test] + fn hscan_visits_every_field_exactly_once() { + let s = store(); + let fields: Vec<(String, Vec)> = (0..7) + .map(|i| (format!("f{i}"), format!("v{i}").into_bytes())) + .collect(); + s.execute(Command::HSet("h".into(), fields)); + let seen = drain_scan(&s, |a| Command::HScan("h".into(), a), 2, 2); + assert_eq!(seen.len(), 14, "7 fields as field/value pairs"); + let names: Vec<&String> = seen.iter().step_by(2).collect(); + assert_eq!(names, vec!["f0", "f1", "f2", "f3", "f4", "f5", "f6"]); + assert_eq!(seen[1], "v0"); + } + + #[test] + fn hscan_novalues_returns_field_names_only() { + let s = store(); + s.execute(Command::HSet( + "h".into(), + vec![("a".into(), "1".into()), ("b".into(), "2".into())], + )); + let (cursor, items) = scan_parts(&s.execute(Command::HScan( + "h".into(), + ScanArgs { + novalues: true, + ..Default::default() + }, + ))); + assert_eq!(cursor, 0); + assert_eq!(items, vec!["a", "b"]); + } + + #[test] + fn hscan_match_filters_field_names() { + let s = store(); + s.execute(Command::HSet( + "h".into(), + vec![ + ("user:1".into(), "a".into()), + ("user:2".into(), "b".into()), + ("other".into(), "c".into()), + ], + )); + let (_, items) = scan_parts(&s.execute(Command::HScan( + "h".into(), + ScanArgs { + pattern: Some("user:*".into()), + ..Default::default() + }, + ))); + assert_eq!(items, vec!["user:1", "a", "user:2", "b"]); + } + + #[test] + fn collection_scans_on_missing_key_complete_empty() { + let s = store(); + for cmd in [ + Command::HScan("ghost".into(), ScanArgs::default()), + Command::SScan("ghost".into(), ScanArgs::default()), + Command::ZScan("ghost".into(), ScanArgs::default()), + ] { + let (cursor, items) = scan_parts(&s.execute(cmd)); + assert_eq!(cursor, 0); + assert!(items.is_empty()); + } + } + + #[test] + fn collection_scans_reject_the_wrong_type() { + let s = store(); + s.execute(Command::Set( + "str".into(), + "v".into(), + SetOptions::default(), + )); + for cmd in [ + Command::HScan("str".into(), ScanArgs::default()), + Command::SScan("str".into(), ScanArgs::default()), + Command::ZScan("str".into(), ScanArgs::default()), + ] { + assert!(matches!(s.execute(cmd), Value::Error(e) if e.starts_with("WRONGTYPE"))); + } + } + + #[test] + fn scan_cursor_past_the_end_completes_without_panicking() { + // A client resuming a cursor into a collection that shrank underneath + // it must get an empty final page, not an out-of-bounds slice. + let s = store(); + s.execute(Command::SAdd("st".into(), vec!["a".into()])); + let (cursor, items) = scan_parts(&s.execute(Command::SScan( + "st".into(), + ScanArgs { + cursor: 9_999, + ..Default::default() + }, + ))); + assert_eq!(cursor, 0); + assert!(items.is_empty()); + } + + #[test] + fn sscan_visits_every_member_exactly_once() { + let s = store(); + s.execute(Command::SAdd( + "st".into(), + (0..5).map(|i| format!("m{i}")).collect(), + )); + let seen = drain_scan(&s, |a| Command::SScan("st".into(), a), 2, 1); + assert_eq!(seen, vec!["m0", "m1", "m2", "m3", "m4"]); + } + + #[test] + fn zscan_pages_members_with_scores() { + let s = store(); + s.execute(Command::ZAdd( + "z".into(), + ZAddOptions::default(), + vec![(2.0, "bob".into()), (1.5, "amy".into())], + )); + // Ordered by member, not by score: the offset cursor has to survive a + // score being updated mid-iteration. + let seen = drain_scan(&s, |a| Command::ZScan("z".into(), a), 1, 2); + assert_eq!(seen, vec!["amy", "1.5", "bob", "2"]); + } + // ── Expiry ──────────────────────────────────────────────────────────────── #[test] @@ -5457,6 +5924,128 @@ mod critical_path_tests { ); } + /// The DP implementation this replaced, kept as a reference oracle. + /// + /// `glob_match` is not just a `KEYS` helper — it decides sync-scope access, + /// so a rewrite that changed semantics anywhere would be a silent + /// authorisation change. Comparing against the previous implementation is + /// the only way to make "behaviour is unchanged" an assertion rather than a + /// claim. + fn glob_match_dp_reference(pattern: &str, s: &str) -> bool { + let pat = pattern.as_bytes(); + let text = s.as_bytes(); + let (m, n) = (pat.len(), text.len()); + let mut prev = vec![false; n + 1]; + let mut curr = vec![false; n + 1]; + prev[0] = true; + for i in 1..=m { + curr[0] = pat[i - 1] == b'*' && prev[0]; + for j in 1..=n { + curr[j] = if pat[i - 1] == b'*' { + prev[j] || curr[j - 1] + } else if pat[i - 1] == b'?' || pat[i - 1] == text[j - 1] { + prev[j - 1] + } else { + false + }; + } + std::mem::swap(&mut prev, &mut curr); + } + prev[n] + } + + #[test] + fn glob_match_agrees_with_the_dp_reference_on_every_small_input() { + // Exhaustive rather than random: every pattern over {a, b, *, ?} up to + // length 5 against every text over {a, b} up to length 4. That is the + // whole space where `*` interacts with `?` and with literals, which is + // where a greedy matcher would differ from the DP if it were wrong. + let pat_alphabet = *b"ab*?"; + let txt_alphabet = *b"ab"; + + fn all_strings(alphabet: &[u8], max_len: usize) -> Vec { + let mut out = vec![String::new()]; + let mut frontier = vec![String::new()]; + for _ in 0..max_len { + let mut next = Vec::new(); + for s in &frontier { + for &c in alphabet { + let mut t = s.clone(); + t.push(c as char); + next.push(t); + } + } + out.extend(next.iter().cloned()); + frontier = next; + } + out + } + + let patterns = all_strings(&pat_alphabet, 5); + let texts = all_strings(&txt_alphabet, 4); + let mut compared = 0usize; + for p in &patterns { + for t in &texts { + let got = glob_match(p, t); + let want = glob_match_dp_reference(p, t); + assert_eq!(got, want, "glob_match({p:?}, {t:?}) disagrees with the DP"); + compared += 1; + } + } + // Guard against the loops silently collapsing to nothing. + assert!(compared > 20_000, "only compared {compared} pairs"); + } + + #[test] + fn glob_match_agrees_with_the_dp_reference_on_realistic_key_shapes() { + // The exhaustive sweep uses a two-letter alphabet, so it never produces + // a `:`-delimited key or a repeated-literal run — the shapes real + // patterns and real keys actually have. + let cases = [ + ("cart:*", "cart:42:item:9"), + ("cart:42:*", "cart:99:item:1"), + ("*:secret", "a:b:c:secret"), + ("user:1*", "user:1:private"), + ("tenant:7:*", "tenant:7:orders:2024:11"), + ("*a*a*a*a*b", "aaaaaaaaaaaaaaaaaaaa"), + ("a*b*c*d", "axxbyyczzd"), + ("a*b*c*d", "axxbyyczz"), + ("?????", "abcde"), + ("*?", ""), + ("?*", "x"), + ("**?**", "xy"), + ("session:*:token", "session:abc:token"), + ("session:*:token", "session::token"), + ("[ab]", "a"), + ("[ab]", "[ab]"), + ]; + for (p, t) in cases { + assert_eq!( + glob_match(p, t), + glob_match_dp_reference(p, t), + "glob_match({p:?}, {t:?}) disagrees with the DP" + ); + } + } + + #[test] + fn glob_match_does_not_allocate_per_call() { + // The DP allocated two Vec of text.len() + 1 on every call, so one + // 64 MB value made `KEYS *` request 128 MB — on a path that runs once + // per key. A long text must now cost nothing but time. + let long = "k".repeat(4 * 1024 * 1024); + assert!(glob_match("*", &long)); + assert!(glob_match("k*k", &long)); + assert!(!glob_match("*z", &long)); + } + + #[test] + fn glob_pattern_cap_is_generous_but_finite() { + // Enforced where patterns are parsed, not here — this pins the value so + // it cannot drift without someone noticing. + assert_eq!(MAX_PATTERN_BYTES, 1024); + } + #[test] fn glob_operates_on_bytes_so_multibyte_chars_span_several_positions() { // Documented consequence of byte-wise matching: '?' matches one *byte*, diff --git a/core-engine/tests/resp_prealloc.rs b/core-engine/tests/resp_prealloc.rs new file mode 100644 index 0000000..f35cffd --- /dev/null +++ b/core-engine/tests/resp_prealloc.rs @@ -0,0 +1,180 @@ +//! Allocation bounds for the RESP parser. +//! +//! A RESP aggregate header declares how many elements follow, and the header +//! arrives before any of them. Reserving capacity for the declared count let +//! nine bytes of input (`*1000000\r\n`) demand a multi-megabyte allocation, and +//! nesting multiplied it. None of it counted against `RECACHED_MAX_MEMORY`, +//! which tracks stored data only. +//! +//! This lives in its own integration test rather than in `resp.rs` because it +//! installs a `#[global_allocator]`. As a separate test binary that instrumented +//! allocator applies only here, and cannot perturb the several hundred unit +//! tests in the library. + +use core_engine::resp::Value; +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; + +thread_local! { + /// Bytes requested while tracking is on, for this thread only. + /// + /// Thread-local rather than a global atomic because the test harness runs + /// tests on parallel threads, and a shared counter would measure whatever + /// else happened to be running. + static ALLOCATED: Cell = const { Cell::new(0) }; + static TRACKING: Cell = const { Cell::new(false) }; +} + +struct CountingAlloc; + +// SAFETY: every call delegates to the system allocator; the bookkeeping is a +// pair of `Cell`s in const-initialised thread-local storage, which performs no +// allocation of its own and so cannot re-enter. `try_with` rather than `with` +// because TLS is unavailable during thread teardown, where the accounting simply +// does not matter. +unsafe impl GlobalAlloc for CountingAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let _ = TRACKING.try_with(|tracking| { + if tracking.get() { + let _ = ALLOCATED.try_with(|n| n.set(n.get() + layout.size())); + } + }); + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } +} + +#[global_allocator] +static ALLOC: CountingAlloc = CountingAlloc; + +/// Bytes requested by the allocator while `f` ran on this thread. +fn bytes_allocated(f: impl FnOnce()) -> usize { + ALLOCATED.with(|n| n.set(0)); + TRACKING.with(|t| t.set(true)); + f(); + TRACKING.with(|t| t.set(false)); + ALLOCATED.with(|n| n.get()) +} + +/// Generous ceiling: the parser reserves a fixed 1024-element floor, which is +/// tens of kilobytes depending on `size_of::()`. The point is the two +/// orders of magnitude between this and the declared count, not a tight bound. +const BUDGET: usize = 128 * 1024; + +#[test] +fn the_counting_allocator_actually_observes_allocations() { + // If the harness silently failed to install, every other assertion here + // would pass by measuring zero. Check the instrument before trusting it. + let observed = bytes_allocated(|| { + let v: Vec = Vec::with_capacity(4096); + std::hint::black_box(&v); + }); + assert!( + observed >= 4096 * 8, + "allocator not tracking: observed {observed} bytes for a 32 KB reservation" + ); +} + +#[test] +fn a_declared_element_count_does_not_drive_the_reservation() { + // Nine bytes. Before the cap this reserved a million `Value`s. + let frame = b"*1000000\r\n"; + let used = bytes_allocated(|| { + let _ = Value::parse(frame); + }); + assert!( + used < BUDGET, + "parsing {} bytes allocated {used} bytes — the declared count is still driving the \ + reservation", + frame.len() + ); +} + +#[test] +fn the_same_applies_to_push_frames_and_maps() { + for frame in [b">1000000\r\n".as_slice(), b"%500000\r\n".as_slice()] { + let used = bytes_allocated(|| { + let _ = Value::parse(frame); + }); + assert!( + used < BUDGET, + "{:?} allocated {used} bytes", + String::from_utf8_lossy(frame) + ); + } +} + +#[test] +fn nesting_does_not_multiply_the_reservation() { + // One header per level, each declaring a million elements, to the depth + // limit. Previously this was the declared count times the depth. + let mut frame = Vec::new(); + for _ in 0..16 { + frame.extend_from_slice(b"*1000000\r\n"); + } + let used = bytes_allocated(|| { + let _ = Value::parse(&frame); + }); + assert!( + used < 16 * BUDGET, + "{} bytes of nested headers allocated {used} bytes", + frame.len() + ); +} + +#[test] +fn a_dripped_frame_costs_the_same_on_every_reparse() { + // An incomplete frame is re-parsed from the start whenever more bytes + // arrive, so the per-call reservation is paid per packet. One byte per + // packet was the cheapest way to make the server allocate repeatedly. + let frame = b"*1000000\r\n$1\r\na\r\n"; + let mut worst = 0usize; + for cut in 1..frame.len() { + let prefix = &frame[..cut]; + let used = bytes_allocated(|| { + let _ = Value::parse(prefix); + }); + worst = worst.max(used); + } + assert!( + worst < BUDGET, + "the most expensive prefix allocated {worst} bytes" + ); +} + +#[test] +fn capping_the_reservation_did_not_cap_the_frame() { + // The reservation is a floor, not a limit: a real aggregate larger than the + // floor must still parse in full, with the vector growing as it goes. + // Getting this wrong would silently truncate large pipelines and MSETs. + const N: usize = 5_000; + let mut frame = format!("*{N}\r\n").into_bytes(); + for i in 0..N { + frame.extend_from_slice(format!("${}\r\n{}\r\n", i.to_string().len(), i).as_bytes()); + } + + let (parsed, consumed) = Value::parse(&frame).expect("a 5000-element array must parse"); + assert_eq!(consumed, frame.len(), "must consume the whole frame"); + match parsed { + Value::Array(Some(items)) => { + assert_eq!(items.len(), N, "every element must survive"); + assert_eq!(items[0], Value::BulkString(Some(b"0".to_vec()))); + assert_eq!( + items[N - 1], + Value::BulkString(Some((N - 1).to_string().into_bytes())) + ); + } + other => panic!("expected an array, got {other:?}"), + } +} + +#[test] +fn an_over_large_declared_count_is_still_refused_outright() { + // The reservation cap is not a substitute for the element limit — a header + // above `MAX_ARRAY_ELEMENTS` must still be rejected on the header alone. + let err = Value::parse(b"*1000001\r\n").unwrap_err(); + assert!(err.contains("too large"), "got {err}"); +} diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 700c107..f8a5538 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -109,7 +109,7 @@ The road to 1.0 is hardening, not features: fuzzing the parser surfaces, automat | Replication | Primary/replica + auto-failover | Yes (+ Sentinel/Cluster) | | Lua scripting | No (WASM scripting on roadmap) | Yes | | Cluster mode | No | Yes | -| Command coverage | ~106 commands | 250+ | +| Command coverage | ~115 commands | 250+ | | License | Apache 2.0 | AGPLv3 / RSALv2 + SSPLv1 (BSD-3 up to 7.2; Valkey stayed BSD-3) | ## Recached vs SWR / React Query diff --git a/docs/package.json b/docs/package.json index 1708364..b644ce4 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,7 +13,8 @@ "pnpm": { "overrides": { "vite": "^6.4.3", - "esbuild": "^0.25.0" + "esbuild": "^0.25.0", + "postcss": "^8.5.18" } } } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 0135d54..022e0b8 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: vite: ^6.4.3 esbuild: ^0.25.0 + postcss: ^8.5.18 importers: @@ -14,7 +15,7 @@ importers: devDependencies: vitepress: specifier: ^1.6.3 - version: 1.6.4(@algolia/client-search@5.52.1)(postcss@8.5.14)(search-insights@2.17.3) + version: 1.6.4(@algolia/client-search@5.52.1)(postcss@8.5.25)(search-insights@2.17.3) packages: @@ -681,8 +682,8 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -699,8 +700,8 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - postcss@8.5.14: - resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} preact@10.29.1: @@ -826,7 +827,7 @@ packages: hasBin: true peerDependencies: markdown-it-mathjax3: ^4 - postcss: ^8 + postcss: ^8.5.18 peerDependenciesMeta: markdown-it-mathjax3: optional: true @@ -1248,7 +1249,7 @@ snapshots: '@vue/shared': 3.5.34 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.14 + postcss: 8.5.25 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.34': @@ -1473,7 +1474,7 @@ snapshots: mitt@3.0.1: {} - nanoid@3.3.12: {} + nanoid@3.3.16: {} oniguruma-to-es@3.1.1: dependencies: @@ -1487,9 +1488,9 @@ snapshots: picomatch@4.0.5: {} - postcss@8.5.14: + postcss@8.5.25: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -1615,13 +1616,13 @@ snapshots: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - postcss: 8.5.14 + postcss: 8.5.25 rollup: 4.60.3 tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 - vitepress@1.6.4(@algolia/client-search@5.52.1)(postcss@8.5.14)(search-insights@2.17.3): + vitepress@1.6.4(@algolia/client-search@5.52.1)(postcss@8.5.25)(search-insights@2.17.3): dependencies: '@docsearch/css': 3.8.2 '@docsearch/js': 3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3) @@ -1642,7 +1643,7 @@ snapshots: vite: 6.4.3 vue: 3.5.34 optionalDependencies: - postcss: 8.5.14 + postcss: 8.5.25 transitivePeerDependencies: - '@algolia/client-search' - '@types/node' diff --git a/docs/server/commands.md b/docs/server/commands.md index 0ee818c..9bedb13 100644 --- a/docs/server/commands.md +++ b/docs/server/commands.md @@ -10,6 +10,58 @@ Recached implements the subset of RESP commands that most applications use. Comm | `AUTH password` | Authenticates the connection. Required on the first command if `RECACHED_PASSWORD` is set. 5 consecutive failures close the connection. | | `HELLO [protover]` | Reports server info and negotiates the protocol version. `3` switches the connection to RESP3, `2` back to RESP2, no argument reports without changing. Unsupported versions return `-NOPROTO` and leave the connection unchanged. Requires authentication. See [Wire Protocol](/server/protocol#protocol-version-tcp). | | `INFO [section ...]` | Reports server statistics as a text blob, in Redis's `# Section` / `field:value` format. No arguments returns the default sections; naming sections returns only those, in the order given. `all`, `everything`, and `default` are accepted as aliases for the default set. Unknown section names return nothing rather than an error. Requires authentication. See [INFO](#info) below. | +| `QUIT` | Replies `+OK` and closes the connection. Accepted before authentication and in subscribe mode, so a client can always close cleanly. | +| `CLIENT ` | Connection introspection. See [CLIENT](#client) below. | +| `CONFIG GET parameter [parameter ...]` | Reports configuration parameters, matched by glob. See [CONFIG](#config) below. | +| `COMMAND [COUNT\|LIST\|INFO\|DOCS]` | Reports the command catalog. See [COMMAND](#command) below. | + +--- + +## CLIENT + +Every current client library — node-redis, ioredis, redis-py, go-redis — sends `CLIENT SETINFO LIB-NAME` and `CLIENT SETINFO LIB-VER` immediately after `HELLO`, so the server can attribute a connection to the library that opened it. + +| Subcommand | Description | +|---|---| +| `CLIENT ID` | This connection's numeric identifier. | +| `CLIENT INFO` | One line describing this connection, in Redis's `key=value` format. | +| `CLIENT LIST` | The same line for every live connection, in connection order. | +| `CLIENT GETNAME` | This connection's name, or nil if unnamed. | +| `CLIENT SETNAME name` | Names this connection. Spaces and newlines are refused, because the name is echoed into the space-separated `CLIENT LIST` format. | +| `CLIENT SETINFO LIB-NAME\|LIB-VER value` | Records the client library's name or version against this connection. | + +The reported fields are `id`, `addr`, `laddr`, `name`, `age`, `idle`, `flags`, `db`, `sub`, `psub`, `multi`, `resp`, `lib-name` and `lib-ver`. Redis also reports buffer sizes, file descriptors and an event mask; Recached omits them rather than emitting plausible numbers, since nothing downstream could distinguish an invented `omem` from a real one. Parsers read this format key by key and skip what they do not recognise. + +`CLIENT KILL`, `CLIENT NO-EVICT`, `CLIENT NO-TOUCH`, `CLIENT UNPAUSE` and `CLIENT MAINT_NOTIFICATIONS` return an "unknown subcommand" error. Each is an operation with real consequences, and replying `+OK` without performing it would leave the caller believing a connection had been killed or eviction disabled. + +--- + +## CONFIG + +| Subcommand | Description | +|---|---| +| `CONFIG GET parameter [parameter ...]` | Returns matching parameter/value pairs. Names may be globs: `CONFIG GET maxmemory*` returns both `maxmemory` and `maxmemory-policy`. An unmatched name yields no pair rather than an error. | +| `CONFIG SET` | Refused with an explanatory error — see below. | + +The reported parameters are `maxmemory`, `maxmemory-policy`, `maxclients`, `port`, `tls-port`, `appendonly`, `databases`, `requirepass`, `proto-max-bulk-len`, `timeout` and `save`. Every value is read from what is actually in force: the eviction policy from the store, the ports and connection limit from the same startup facts `INFO` reports. `requirepass` is masked to `*` when a password is set and empty when it is not — whether a password exists is not a secret, its value is. + +**`CONFIG SET` is not supported.** Recached reads its configuration from the environment at startup and holds it for the life of the process, so there is nothing a runtime `SET` could change. It returns an error naming the parameter and pointing at the environment variable instead, because returning `+OK` would leave an operator to discover much later that the limit they set never applied. See [Configuration](/server/configuration). + +--- + +## COMMAND + +| Subcommand | Description | +|---|---| +| `COMMAND` | Every command in the catalog, as `COMMAND INFO` entries. | +| `COMMAND COUNT` | How many commands the server implements. | +| `COMMAND LIST` | Their names. | +| `COMMAND INFO [name ...]` | Name, arity, flags and key positions. A name the server does not have replies nil in its slot, so the reply stays aligned with the request. | +| `COMMAND DOCS [name ...]` | Summary, group and arity per command. RESP3 returns a map; RESP2 returns the same pairs flattened, as Redis degrades it. | + +Arity, flags and key positions are transcribed from a real `redis-server`'s own `COMMAND INFO` rather than written by hand: cluster-aware clients and proxies route on `first_key` and `step`, and a wrong arity makes a client reject a call the server would have accepted. The nine commands with no Redis counterpart — `ESET`, `JSET`, `JGET`, `JMERGE`, `RLSET`, `RLCHECK`, `SYNC`, `DEDUP`, `QSUB`, `QUNSUB` — are declared directly. + +Recached has no ACL system and no subcommand tree, so the ACL-categories, tips, key-specs and subcommands elements Redis 7 appends to each `COMMAND INFO` entry are present but empty. A client indexing past the sixth element finds an empty list rather than running off the end of the array. --- @@ -74,6 +126,7 @@ The most common data type. Values are always stored as byte strings; numeric ope | `PSETEX key milliseconds value` | Set a key with a millisecond-precision expiry. | | `APPEND key value` | Appends a string to the end of the existing value. If the key does not exist, it is created. Returns the new length. | | `STRLEN key` | Returns the length of the string stored at key. Returns 0 if the key does not exist. | +| `GETRANGE key start end` | Returns the inclusive byte range `start`..`end` of the string. Negative offsets count back from the end (`-1` is the last byte). `end` clamps to the last byte, but `start` does not: a `start` past the end of the value returns an empty string rather than the final byte, as in Redis. A missing key is an empty string, so every range of it is empty. Use it to read a window of a large value without transferring the whole thing. | | `INCR key` | Increments the integer value of a key by 1. Creates the key with value 1 if it does not exist. Returns an error if the value is not a valid integer. | | `DECR key` | Decrements the integer value of a key by 1. Creates the key with value -1 if it does not exist. | | `INCRBY key increment` | Increments the integer value of a key by the given integer. | @@ -104,7 +157,7 @@ The most common data type. Values are always stored as byte strings; numeric ope | `EXISTS key [key ...]` | Returns the number of keys that exist among the provided arguments. A key listed multiple times counts multiple times. | | `TYPE key` | Returns the type of the value stored at key: `string`, `hash`, `list`, `set`, `zset`, `ratelimit`, or `none` if the key does not exist. | | `RENAME key newkey` | Renames a key. Returns an error if the source key does not exist. Overwrites `newkey` if it already exists. | -| `KEYS pattern` | Returns all keys matching the glob pattern. `*` matches any sequence of characters, `?` matches a single character, `[abc]` matches a character class. Warning: `KEYS *` on a large store is slow — prefer `SCAN`. | +| `KEYS pattern` | Returns all keys matching the glob pattern. `*` matches any sequence of bytes, `?` matches exactly one byte. **Character classes (`[abc]`) are not supported** — brackets match literally. Patterns are capped at 1,024 bytes. Warning: `KEYS *` on a large store is slow — prefer `SCAN`. | | `SCAN cursor [MATCH pattern] [COUNT count]` | Iterates keys incrementally, returning at most `COUNT` keys per call (default 10) plus the next cursor. Start with cursor `0` and continue until the returned cursor is `0`. `MATCH` filters results by glob pattern. As in Redis, keys inserted or deleted mid-iteration may be missed or returned twice. | | `DBSIZE` | Returns the total number of keys in the store. | | `FLUSHDB [ASYNC]` | Removes all keys from the store. `ASYNC` is accepted but does not change behavior (the flush is always synchronous). | @@ -129,6 +182,7 @@ A hash is a map of field-value pairs stored under a single key. Use hashes to st | `HSETNX key field value` | Sets a field only if it does not already exist. Returns 1 if set, 0 if the field already existed. | | `HINCRBY key field increment` | Increments the integer value of a hash field by the given integer. Creates the field with value 0 before incrementing if it does not exist. | | `HINCRBYFLOAT key field increment` | Increments the float value of a hash field by the given float. | +| `HSCAN key cursor [MATCH pattern] [COUNT count] [NOVALUES]` | Iterates a hash incrementally: returns the next cursor plus at most `COUNT` field-value pairs (default 10). Start at cursor `0` and continue until the returned cursor is `0`. `MATCH` filters on field names; `NOVALUES` returns field names only. The bounded counterpart of `HGETALL` — prefer it for hashes whose size you do not control. | ### Example @@ -184,6 +238,7 @@ An unordered collection of unique string members. Supports set operations (inter | `SPOP key [count]` | Removes and returns one or more random members from the set. | | `SRANDMEMBER key [count]` | Returns one or more random members without removing them. Positive `count`: unique members. Negative `count`: may repeat. | | `SMOVE source destination member` | Atomically moves a member from one set to another. Returns 1 on success, 0 if the member did not exist in source. | +| `SSCAN key cursor [MATCH pattern] [COUNT count]` | Iterates a set incrementally: returns the next cursor plus at most `COUNT` members (default 10). The bounded counterpart of `SMEMBERS`. | --- @@ -206,6 +261,7 @@ An ordered collection where each member has a numeric score. Members are unique; | `ZREVRANK key member` | Returns the rank in descending score order. | | `ZCARD key` | Returns the number of members in the sorted set. | | `ZCOUNT key min max` | Returns the number of members with scores between `min` and `max`. | +| `ZSCAN key cursor [MATCH pattern] [COUNT count]` | Iterates a sorted set incrementally: returns the next cursor plus at most `COUNT` `member, score` pairs (default 10). Ordered by member rather than by score, because the cursor is a position and only the member ordering survives a score changing mid-iteration. | ### Example: leaderboard @@ -397,7 +453,7 @@ Pub/Sub works over both TCP (port 6379) and WebSocket (port 6380). |---|---| | `SUBSCRIBE channel [channel ...]` | Subscribes the client to one or more channels. The client enters pub/sub mode and can only use pub/sub commands until it unsubscribes. | | `UNSUBSCRIBE [channel ...]` | Unsubscribes from the given channels. With no arguments, unsubscribes from all channels. | -| `PSUBSCRIBE pattern [pattern ...]` | Subscribes to channels matching a glob pattern. `*` matches any sequence, `?` matches any single character, `[abc]` matches a character class. | +| `PSUBSCRIBE pattern [pattern ...]` | Subscribes to channels matching a glob pattern. `*` matches any sequence of bytes, `?` matches exactly one byte. **Character classes (`[abc]`) are not supported** — brackets match literally. Patterns are capped at 1,024 bytes. | | `PUNSUBSCRIBE [pattern ...]` | Unsubscribes from pattern subscriptions. With no arguments, unsubscribes from all patterns. | | `PUBLISH channel message` | Publishes a message to all subscribers of the given channel and all clients with matching pattern subscriptions. Returns the number of clients that received the message. | diff --git a/docs/server/configuration.md b/docs/server/configuration.md index cbfc1e6..fff74e2 100644 --- a/docs/server/configuration.md +++ b/docs/server/configuration.md @@ -8,7 +8,9 @@ Recached is configured entirely through environment variables. There is no confi |---|---|---| | `RECACHED_BIND` | `0.0.0.0` | Network interface all listeners (TCP, WebSocket, replication, metrics) bind to. Defaults to `0.0.0.0` (all interfaces). Set to `127.0.0.1` to restrict the server to localhost — strongly recommended unless the server is deliberately public. | | `RECACHED_PASSWORD` | _(none)_ | Require clients to authenticate with `AUTH `. If unset, the server accepts connections without authentication. After 5 consecutive failed `AUTH` attempts, the connection is closed. The password is compared in constant time. | -| `RECACHED_ALLOW_IPS` | _(allow all)_ | Comma-separated list of IP addresses allowed to connect. Any connection from an IP not in the list is immediately closed. Invalid entries are logged and skipped. | +| `RECACHED_ALLOW_IPS` | _(allow all)_ | Comma-separated list of exact IP addresses allowed to connect, applied to the RESP, WebSocket and replication listeners. Any connection from an IP not in the list is immediately closed. An entry that is not a valid IP address — including CIDR ranges and hostnames — makes the server **refuse to start**, rather than silently applying a narrower allowlist than configured. | +| `RECACHED_ALLOWED_ORIGINS` | _(allow all)_ | Comma-separated list of exact origins (`https://app.example.com`, `http://localhost:3000`) permitted to open the WebSocket sync port. A handshake from any other origin is refused with `403`. `null` admits sandboxed iframes and `file://` documents. Clients that send no `Origin` header at all — every native client — are admitted. Unset accepts all origins with a startup warning. See [Security](/server/security#the-sync-port-is-a-different-threat-model). | +| `RECACHED_HANDSHAKE_TIMEOUT` | `10` | Seconds a connection may take to complete its TLS and/or WebSocket handshake before being dropped. The connection permit is taken before the handshake runs, so without this a peer that connects and then says nothing would hold one of `RECACHED_MAX_CONNECTIONS` slots indefinitely. | | `RECACHED_SYNC_SECRET` | _(none)_ | Enables **strict sync scoping** on the WebSocket port: clients receive no mutation pushes and may run no key commands until they present a signed scope token (`SYNC TOKEN `), and are then restricted to the keys their token grants. Without it, every WebSocket client receives every mutation. See [Sync Scopes](/server/sync-scopes). | | `RECACHED_MAX_KEYS` | _(unlimited)_ | Maximum number of keys in the store. When this limit is reached, behavior depends on `RECACHED_EVICTION`. If set to `noeviction` (the default), write commands that would exceed the cap return an error. | | `RECACHED_EVICTION` | `noeviction` | Eviction policy when `RECACHED_MAX_KEYS` is reached. See eviction policies below. | @@ -17,21 +19,24 @@ Recached is configured entirely through environment variables. There is no confi | `RECACHED_SAVE` | _(none)_ | Multi-condition autosave policy as comma-separated `seconds:changes` pairs. A snapshot is triggered when **any** condition is satisfied: `elapsed_since_last_save >= seconds` **and** `dirty_writes >= changes`. Example: `"900:1,300:10,60:10000"` — save after 1 write in 15 min, 10 writes in 5 min, or 10 000 writes in 1 min. When set, `RECACHED_SAVE_INTERVAL` is ignored. Skips saves when no writes have occurred since the last snapshot. | | `RECACHED_SAVE_INTERVAL` | `900` | Autosave interval in seconds (single-condition fallback when `RECACHED_SAVE` is not set). The server saves automatically at this interval if at least one write has occurred since the last save. Set to `0` to disable autosave entirely (manual `SAVE`/`BGSAVE` still work). | | `RECACHED_AOF_PATH` | _(disabled)_ | Path to the append-only file. When set, every write command is appended to this file in addition to snapshot saves. On startup the snapshot is loaded first, then AOF commands are replayed for the delta. The AOF is truncated after each successful snapshot save. | -| `RECACHED_AOF_SYNC` | `everysec` | AOF fsync policy. `always`: fsync after every write (safest, slowest). `everysec`: fsync once per second (default, good balance). `no`: let the OS decide (fastest, least safe). | -| `RECACHED_MAX_CONNECTIONS` | `1024` | Maximum number of concurrent client connections (TCP + WebSocket combined). New connections are dropped when the limit is reached. | +| `RECACHED_AOF_SYNC` | `everysec` | AOF fsync policy. `always`: fsync after every write — **see the throughput warning below before choosing this**. `everysec`: fsync once per second (default) — at most one second of acknowledged writes lost to a power cut. `no`: no explicit fsync, the OS decides. Prior to 0.2.4 all three only flushed to the operating system, so nothing was durable against power loss or a kernel panic regardless of the setting. | +| `RECACHED_MAX_CONNECTIONS` | `1024` | Maximum number of concurrent connections (TCP + WebSocket + attached replicas, sharing one budget). New connections are dropped when the limit is reached. | | `RECACHED_EVICTION_SAMPLE` | `10` | Keys sampled per eviction pass. A larger sample approximates true LRU/TTL ordering more closely at the cost of more work per eviction — the knob Redis exposes as `maxmemory-samples`. | | `RECACHED_MAX_MULTI_QUEUE` | `10000` | Commands that may be queued inside one `MULTI`. | | `RECACHED_MAX_WATCHES_PER_CONN` | `1024` | Keys a single connection may `WATCH`. | | `RECACHED_MAX_LIVE_QUERIES` | `64` | Live queries (`QSUB`) a single connection may hold. | | `RECACHED_MAX_QSUB_INITIAL_KEYS` | `10000` | Keys returned in a live query's initial `qstate` reply. Beyond this the snapshot is truncated — narrow the pattern instead of raising it. | -| `RECACHED_REPL_PORT` | `6381` | TCP port the primary listens on for incoming replica connections. Only active when `RECACHED_REPLICAOF` is not set (i.e. this server is a primary). | -| `RECACHED_REPLICAOF` | _(none)_ | Set to `host:port` to run this server as a read-only replica. On startup it connects to the primary, receives a full snapshot, and then streams all subsequent writes. Reconnects automatically with exponential backoff on disconnect. | -| `RECACHED_REPL_PASSWORD` | _(none)_ | Shared secret for the replication channel. When set, replicas must send this password during the handshake before receiving any data. Must match on both primary and replica. Strongly recommended for any network-exposed replication port. | +| `RECACHED_REPL_ENABLE` | _(disabled)_ | Set to `1`/`true`/`yes`/`on` to bind the replication listener. **Required on every node that serves replicas**, including a replica serving sub-replicas. Without it the port is not bound at all. Enabling it on any interface other than loopback without `RECACHED_REPL_PASSWORD` makes the server **refuse to start**. An unrecognised value is also a startup error. | +| `RECACHED_REPL_PORT` | `6381` | TCP port the replication listener binds, when `RECACHED_REPL_ENABLE` is set. Honours `RECACHED_ALLOW_IPS` and counts against `RECACHED_MAX_CONNECTIONS`. | +| `RECACHED_REPLICAOF` | _(none)_ | Set to `host:port` to run this server as a read-only replica. On startup it connects to the primary, receives a full snapshot, and then streams all subsequent writes. Reconnects automatically with exponential backoff on disconnect. Independent of `RECACHED_REPL_ENABLE`, which governs only whether *this* node accepts replicas of its own. | +| `RECACHED_REPL_PASSWORD` | _(none)_ | Shared secret for the replication channel. When set, replicas must send this password during the handshake before receiving any data. Must match on both primary and replica. **Mandatory** when the replication listener is enabled on a non-loopback interface. Failed attempts are throttled per source address. | +| `RECACHED_REPL_TLS_CA` | _(none)_ | Path to a PEM file holding the **CA certificate** that issued the primary's certificate. Setting it enables **TLS on the outbound replication connection** and makes the primary's identity verified rather than assumed. Note this needs a real two-certificate chain — a single self-signed certificate is rejected as `CaUsedAsEndEntity`; see [Security → Encrypting replication](/server/security#encrypting-replication) for the `openssl` recipe. A public bundle such as `/etc/ssl/certs/ca-certificates.crt` works if the primary's certificate is publicly issued. Set on **replicas**. | +| `RECACHED_REPL_TLS_SERVERNAME` | _(host of `RECACHED_REPLICAOF`)_ | Name to verify the primary's certificate against. Override when `RECACHED_REPLICAOF` points at an IP but the certificate names a host — a cert for `primary.internal` does not validate against `10.0.1.5` unless it carries that IP as a SAN. | | `RECACHED_FAILOVER_TIMEOUT` | _(disabled)_ | Seconds a replica waits with the primary unreachable before automatically promoting itself to primary. Set on replicas only. `0` or unset disables auto-failover — the replica reconnects indefinitely. See [auto-failover](#auto-failover) below. | | `RECACHED_REPL_BUFFER` | `4096` | Per-replica channel capacity (number of pending write frames buffered on the primary before a lagging replica is disconnected). A replica that falls this many writes behind is dropped and must reconnect from a fresh snapshot. Increase if replicas are on a consistently slow or high-latency link; decrease to reduce memory use per replica. | | `RECACHED_MAX_MEMORY` | _(unlimited)_ | Maximum approximate heap usage for the key-value store. Accepts a byte count or a human-readable suffix: `512mb`, `2gb`, `1073741824`. When the limit is exceeded, the background eviction loop runs the configured eviction policy. Has no effect when `RECACHED_EVICTION` is `noeviction`. | -| `RECACHED_TLS_CERT` | _(none)_ | Path to a PEM-encoded TLS certificate file. TLS is enabled on both ports when this and `RECACHED_TLS_KEY` are set. | -| `RECACHED_TLS_KEY` | _(none)_ | Path to a PEM-encoded TLS private key file. If either `RECACHED_TLS_CERT` or `RECACHED_TLS_KEY` is missing, the server falls back to plain TCP/WS. | +| `RECACHED_TLS_CERT` | _(none)_ | Path to a PEM-encoded TLS certificate file. TLS is enabled on both client ports (RESP and WebSocket) when this and `RECACHED_TLS_KEY` are set. It does not cover the replication or metrics ports. | +| `RECACHED_TLS_KEY` | _(none)_ | Path to a PEM-encoded TLS private key file. Set both this and `RECACHED_TLS_CERT` or neither — if exactly one is present the server **refuses to start** rather than silently serving plaintext. | | `RUST_LOG` | `info` | Log level. Accepts `error`, `warn`, `info`, `debug`, `trace`. Module-specific: `RUST_LOG=recached=debug,tokio=warn`. | --- @@ -97,11 +102,11 @@ recached-server ```bash RECACHED_PASSWORD="secret" \ -RECACHED_ALLOW_IPS="127.0.0.1,10.0.0.0/8,192.168.1.100" \ +RECACHED_ALLOW_IPS="127.0.0.1,10.0.1.5,192.168.1.100" \ recached-server ``` -Note: `RECACHED_ALLOW_IPS` accepts individual IP addresses. CIDR notation support depends on the version — check the release notes. If only specific hosts should connect, prefer TLS + auth over IP allowlisting in cloud environments where IPs rotate. +`RECACHED_ALLOW_IPS` accepts **exact IP addresses only**. CIDR ranges and hostnames are not supported, and an entry that is not a valid address makes the server refuse to start rather than quietly applying a narrower allowlist than you wrote. In cloud environments where addresses rotate, prefer TLS plus authentication and enforce network boundaries with security groups. ### With TLS (secure RESP and WSS) @@ -170,13 +175,49 @@ recached-server On startup: snapshot is loaded first, then any AOF commands written after the snapshot are replayed. The AOF is automatically truncated after each successful snapshot save. +#### What each sync mode costs + +From 0.2.4 these modes genuinely `fsync`. Before that they only flushed to the operating system, so +they were all roughly the speed of `no` and none of them survived a power cut — if you benchmarked +`always` on an earlier version, that number was measuring the wrong thing. + +| Mode | Worst-case loss on power cut | Measured append cost | +|---|---|---| +| `no` | Everything the OS has not written back | ~40–50 µs | +| `everysec` | Up to one second of writes | ~40–50 µs | +| `always` | Nothing | **~20 ms** | + +::: danger `always` costs roughly 400× more per write +The fsync happens while the AOF lock is held, so *every* writer in the process queues behind one disk +barrier. Measured on macOS/APFS that is around 20 ms per append — **tens of writes per second, not +tens of thousands.** The server logs a warning at startup when you select it. + +Choose `always` only when losing one second of acknowledged writes is genuinely unacceptable and you +have measured the throughput on your own hardware. `everysec` is the default because it costs nothing +measurable and bounds the loss to a second. Linux with ext4 or xfs is typically far faster than APFS +here, so measure rather than assuming these numbers transfer. + +Note also that on macOS `fsync()` asks the OS to write back but does not force the drive to flush its +own cache — `F_FULLFSYNC` is required for that, and Recached does not currently issue it. So on macOS +`always` pays most of the cost of durability without the last step of the guarantee. Treat macOS as a +development platform for this setting. +::: + ### With leader-follower replication Run a primary and one or more read-only replicas. Replicas receive a full snapshot on connect, then stream every subsequent write in real time. +::: warning The replication listener is opt-in +`RECACHED_REPL_ENABLE=1` is required on any node that accepts replicas. Without it port 6381 is +not bound and replicas cannot attach. This changed in 0.2.4 — the port previously opened on every +node by default, unauthenticated, which meant anyone who could reach it received the entire +keyspace regardless of `RECACHED_PASSWORD`. +::: + ```bash -# Primary (default replication port 6381, auth required) +# Primary (serves replicas, so the listener is enabled and authenticated) RECACHED_SAVE_PATH="/data/recached.rdb" \ +RECACHED_REPL_ENABLE=1 \ RECACHED_REPL_PORT="6381" \ RECACHED_REPL_PASSWORD="repl-secret" \ recached-server @@ -187,6 +228,10 @@ RECACHED_REPL_PASSWORD="repl-secret" \ recached-server ``` +The replica above does **not** set `RECACHED_REPL_ENABLE`: it consumes replication but does not +serve it. Add the variable only if that replica should itself accept sub-replicas (multi-tier +replication). + Replicas reconnect automatically with exponential backoff (2s → 4s → … → 30s cap) if the primary is temporarily unavailable. Write commands sent to a replica return `-READONLY`. Each replica has an internal write buffer (default 4096 frames, set by `RECACHED_REPL_BUFFER`). If a replica falls that many writes behind — due to a slow network or an overloaded replica host — it is disconnected and must reconnect from a fresh snapshot. This prevents a lagging replica from consuming unbounded memory on the primary or blocking the primary's write path. @@ -200,6 +245,7 @@ Set `RECACHED_FAILOVER_TIMEOUT` on the replica. If the primary is unreachable fo ```bash # Primary RECACHED_SAVE_PATH="/data/recached.rdb" \ +RECACHED_REPL_ENABLE=1 \ RECACHED_REPL_PORT="6381" \ RECACHED_REPL_PASSWORD="repl-secret" \ recached-server diff --git a/docs/server/operations.md b/docs/server/operations.md index 12c0e91..57dc6b9 100644 --- a/docs/server/operations.md +++ b/docs/server/operations.md @@ -162,6 +162,8 @@ is worth knowing where the walls are: | Keys returned in a live query's initial state | 10,000 | `RECACHED_MAX_QSUB_INITIAL_KEYS` | | Keys sampled per eviction pass | 10 | `RECACHED_EVICTION_SAMPLE` | | Replication frame | 512 MB | No | +| Glob pattern length (`KEYS`, `SCAN MATCH`, `PSUBSCRIBE`, sync scopes) | 1,024 bytes | No | +| Elements reserved up front for an aggregate | 1,024 | No | | Client outbox (browser, offline writes) | 10,000 writes | via `sync-client` | The keyspace cap (`RECACHED_MAX_KEYS`) and memory cap (`RECACHED_MAX_MEMORY`) are configured rather diff --git a/docs/server/security.md b/docs/server/security.md index 6ab46ce..4a1692e 100644 --- a/docs/server/security.md +++ b/docs/server/security.md @@ -15,12 +15,17 @@ run `FLUSHDB`. This is the single most common way self-hosted caches get comprom - [ ] **Bind to a private interface** — `RECACHED_BIND=127.0.0.1` or a VPC address, never `0.0.0.0` on a public host. - [ ] **Enable TLS** (`RECACHED_TLS_CERT` + `RECACHED_TLS_KEY`) if traffic crosses any network you do - not control. -- [ ] **Firewall the metrics port** — it has no authentication of its own. + not control. This covers the RESP, WebSocket and replication listeners; replicas additionally + need `RECACHED_REPL_TLS_CA` — see [Replication](#replication). +- [ ] **Firewall the metrics port** — it has no authentication of its own, and + `RECACHED_ALLOW_IPS` does not apply to it. - [ ] **Set `RECACHED_SYNC_SECRET`** before any browser connects to a multi-tenant deployment. +- [ ] **Set `RECACHED_ALLOWED_ORIGINS`** to the origins your application is served from, so that + other pages in a user's browser cannot open the sync socket. - [ ] **Set `RECACHED_MAX_MEMORY` and/or `RECACHED_MAX_KEYS`** so an unbounded keyspace cannot exhaust the host. -- [ ] **Set `RECACHED_REPL_PASSWORD`** if you run replication. +- [ ] **Set `RECACHED_REPL_PASSWORD`** if you enable the replication listener. The server refuses to + start without it on any interface other than loopback. - [ ] Review [what is still beta](/guide/introduction#maturity) before putting the sync layer in front of untrusted users. @@ -46,8 +51,9 @@ Two properties worth knowing: RECACHED_TLS_CERT=./cert.pem RECACHED_TLS_KEY=./key.pem recached-server ``` -TLS applies to **both** ports when enabled: clients use `rediss://` for RESP and `wss://` for the -browser sync socket. +TLS applies to **both client ports** when enabled: clients use `rediss://` for RESP and `wss://` for +the browser sync socket. It also covers the replication listener; a replica must be +given `RECACHED_REPL_TLS_CA` to use it. The metrics port is always plaintext. ::: tip TLS is all-or-nothing, and fails loudly Set both variables or neither. If exactly one is present the server **refuses to start** with an @@ -92,6 +98,28 @@ full data breach. RECACHED_SYNC_SECRET="$(openssl rand -base64 32)" recached-server ``` +### Restrict which pages may connect + +Browsers apply neither CORS nor a preflight to WebSockets. That makes this port different from every +HTTP endpoint you have secured before: **any page a user visits can open a socket to any Recached +that page's browser can reach**, and the request carries the user's network position. On a developer +machine running `ws://localhost:6380`, that is every site in every tab. + +```bash +RECACHED_ALLOWED_ORIGINS="https://app.example.com,https://admin.example.com" recached-server +``` + +A handshake from any other origin is refused with `403` before a single frame is exchanged. Three +things to understand about the limits of this control: + +- **It only defends against browsers.** A native client omits `Origin`, and an attacker with a raw + socket can send whatever they like — so a client that sends no `Origin` is admitted. What the + allowlist separates is *the application you deployed* from *another page in the same browser*, + which is the threat that is otherwise unaddressed. +- **It is not a substitute for `RECACHED_SYNC_SECRET`.** Origin says which page connected; a scope + token says which keys that page may touch. A multi-tenant deployment needs both. +- **Unset means allow-all**, with a warning at startup. Set it before exposing 6380 to a browser. + With the secret set, connections present an HMAC-signed token minted by your backend, and **every command — reads included — is checked against the granted patterns**. Keyspace-wide and administrative commands (`KEYS`, `SCAN`, `DBSIZE`, `FLUSHDB`, `SAVE`, `BGSAVE`, `REPLICAOF`) are @@ -115,8 +143,101 @@ If a token leaks, it grants its scopes until it expires — scope narrowly and k ## Replication -Set `RECACHED_REPL_PASSWORD` so an attacker cannot attach a rogue replica and stream your entire -keyspace. Replication traffic is covered by TLS when TLS is enabled. +::: danger The replication port bypassed authentication entirely before 0.2.4 +Every release up to and including 0.2.3 bound `${RECACHED_BIND}:6381` — default `0.0.0.0` — +**unconditionally on every node**, whether or not replication was configured, and skipped the +handshake completely when `RECACHED_REPL_PASSWORD` was unset, which was also the default. Any peer +that connected received a full dump of the keyspace followed by a live stream of every subsequent +write, regardless of `RECACHED_PASSWORD`. If you are on an earlier version, upgrade or firewall +6381 now. +::: + +The listener is opt-in from 0.2.4 onward: + +```bash +RECACHED_REPL_ENABLE=1 \ +RECACHED_REPL_PASSWORD="$(openssl rand -base64 32)" \ +recached-server +``` + +- **It binds only when `RECACHED_REPL_ENABLE` is set** — on the primary, and on any replica that + serves sub-replicas. A replica that merely consumes replication does not need it. +- **Enabling it off-loopback without a password refuses to start.** The port serves the whole + keyspace to whoever connects, so it is not a thing to leave unauthenticated on a reachable + interface. +- **`RECACHED_ALLOW_IPS` and `RECACHED_MAX_CONNECTIONS` apply to it**, which they did not before. +- **Failed auth is throttled per source address.** The handshake is one-shot, so before this a wrong + guess cost an attacker only a reconnect. + +### Encrypting replication + +Replication is **plaintext unless you configure it otherwise**, and it carries the password followed +by the entire keyspace. Two variables turn it into an authenticated, encrypted channel: + +::: warning A single self-signed certificate will not work here +Replication TLS needs a **two-certificate chain**: a CA certificate, and a separate leaf certificate +for the primary that the CA signed. The usual one-liner — +`openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -nodes` — produces a certificate +marked `CA:TRUE`, and the replica's TLS stack refuses to accept a CA certificate as a server +certificate (`CaUsedAsEndEntity`). OpenSSL clients such as `redis-cli --cacert` are more forgiving, +so the same certificate can work for `rediss://` while failing for replication. Generate the chain +below rather than reusing a self-signed file. +::: + +```bash +# 1. A private CA. Keep ca-key.pem off both servers. +openssl req -x509 -newkey rsa:2048 -keyout ca-key.pem -out ca.pem -days 3650 -nodes \ + -subj "/CN=my-recached-ca" + +# 2. A leaf certificate for the primary, signed by that CA. The SANs must list +# every name or address a replica will use in RECACHED_REPLICAOF. +openssl req -newkey rsa:2048 -keyout server-key.pem -out server.csr -nodes \ + -subj "/CN=primary.internal" +openssl x509 -req -in server.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \ + -out server.pem -days 3650 -sha256 -extfile <(printf '%s\n' \ + 'basicConstraints=critical,CA:FALSE' \ + 'subjectAltName=DNS:primary.internal,IP:10.0.1.5' \ + 'extendedKeyUsage=serverAuth') +``` + +```bash +# Primary — the replication listener reuses the server certificate. +RECACHED_TLS_CERT=./server.pem RECACHED_TLS_KEY=./server-key.pem \ +RECACHED_REPL_ENABLE=1 \ +RECACHED_REPL_PASSWORD="$(openssl rand -base64 32)" \ +recached-server + +# Replica — trusts the CA, and verifies the primary against it. +RECACHED_REPLICAOF="primary.internal:6381" \ +RECACHED_REPL_TLS_CA=./ca.pem \ +RECACHED_REPL_PASSWORD="…same…" \ +recached-server +``` + +The point is not only confidentiality. Without TLS the replica has no way to check *who* it is +following, so a DNS hijack or an on-path attacker can feed it an arbitrary keyspace and it will load +it. Certificate verification is what closes that, which is why the trust anchor is required rather +than optional — there is no "encrypt but do not verify" mode. + +`RECACHED_REPL_TLS_CA` is an explicit file rather than the system root store on purpose: replication +links two hosts the same operator runs, so trusting one private CA is both simpler and tighter than +trusting every public CA to vouch for a host that streams your whole dataset. A public bundle such as +`/etc/ssl/certs/ca-certificates.crt` works too if the primary's certificate is publicly issued. + +If `RECACHED_REPLICAOF` names an IP but the certificate names a host, either add the IP as a SAN (as +above) or set `RECACHED_REPL_TLS_SERVERNAME` to the certificate's name. A mismatch is refused with a +message naming both what was expected and what the certificate actually covers. + +::: warning A plaintext replication link is still the default +Setting `RECACHED_TLS_CERT` on the primary encrypts the RESP, WebSocket **and** replication +listeners, but a replica that has no `RECACHED_REPL_TLS_CA` connects in the clear — and against a +TLS-enabled primary its handshake simply fails. The server logs a warning at startup on any replica +following a primary without TLS. If you cannot use TLS, keep replication on a private network or a +tunnel (WireGuard, SSH, a service mesh), and treat the replication password as protection against a +rogue replica rather than against an observer. + +The metrics port remains plaintext and unauthenticated regardless — firewall it. +::: Note the failover model: single-replica automatic promotion only. In a multi-replica topology, designate one replica for auto-failover and keep the rest passive, or you risk split-brain — see @@ -140,18 +261,29 @@ compiled in and cannot be configured. What Recached defends against today: - Unauthenticated access (password, constant-time comparison) -- Network eavesdropping (TLS on both ports) +- Network eavesdropping on every port except metrics (TLS on RESP, WebSocket and replication) - Browser clients reading data they should not (sync scopes, signed tokens) -- Brute-force password guessing (connection dropped after 5 failures) -- Rogue replicas (replication password) +- Cross-origin WebSocket hijacking (`RECACHED_ALLOWED_ORIGINS`) +- Brute-force password guessing (connection dropped after 5 failures; replication auth throttled + per source address) +- Rogue replicas (replication password, and an opt-in listener that will not run unauthenticated + off-loopback) +- A rogue *primary* feeding a replica an arbitrary keyspace, when replication TLS is configured + (certificate verification) +- Connection-slot exhaustion by half-open sockets (handshake deadline) +- Local users reading the cache off disk (snapshot, AOF and dedup files created `0600`) What it does **not** defend against, and you should not assume: - **No per-command ACLs.** Unlike Redis 6+ ACLs, authentication is all-or-nothing on the RESP port — any authenticated client can run any command. Scoping exists only on the WebSocket sync path. - **No audit log.** There is no record of who read or wrote what. -- **No encryption at rest.** Snapshots and AOF files are plaintext MessagePack; protect them with - filesystem permissions and disk encryption. +- **Replication is plaintext by default.** It can be encrypted and authenticated end to end + (`RECACHED_REPL_TLS_CA`), but a replica configured without it connects in the clear. See + [Replication](#replication). +- **No encryption at rest.** Snapshots and AOF files are plaintext MessagePack. They are created + `0600` so other local users cannot read them, but anyone who can read them as the server's user, + or who obtains the disk, has the whole keyspace. Use disk encryption. - **No rate limiting on the RESP port.** `RLSET`/`RLCHECK` are commands you can use for *your* application's rate limiting; they do not throttle clients of the cache itself. - **No third-party security review.** The sync layer in particular is young. See diff --git a/docs/server/sync-scopes.md b/docs/server/sync-scopes.md index 63fddb5..9f6ac4e 100644 --- a/docs/server/sync-scopes.md +++ b/docs/server/sync-scopes.md @@ -14,7 +14,19 @@ WebSocket-only (the TCP port is for trusted backends and is unaffected). | `SYNC TOKEN ` | Sets scopes from a signed token — requires `RECACHED_SYNC_SECRET` on the server. | | `SYNC [pattern ...]` | Sets scopes directly. Only allowed when no secret is configured — this is a bandwidth filter, **not** an authorization boundary. | -Patterns are the same globs `KEYS` uses: `*`, `?`, `[abc]` — e.g. `cart:42:*`, `catalog:*`. +Patterns are the same globs `KEYS` uses: `*` matches any sequence of bytes and `?` matches exactly +one byte — e.g. `cart:42:*`, `catalog:*`. + +::: warning Character classes are not supported +`[abc]` is **not** a character class here; the brackets match literally. Earlier versions of this +page said otherwise. A scope written as `user:[12]:*` therefore grants access to keys beginning with +the literal text `user:[12]:`, and matches nothing a normal application writes — it fails closed, but +it does not grant what its author intended. Enumerate the prefixes instead: +`user:1:*,user:2:*`. + +Patterns are capped at 1,024 bytes. Matching is byte-wise, so `?` matches one *byte* and a +multi-byte UTF-8 character spans several positions. +::: ## Two modes diff --git a/sdks/recached-react/package.json b/sdks/recached-react/package.json index 813a00a..166bb22 100644 --- a/sdks/recached-react/package.json +++ b/sdks/recached-react/package.json @@ -1,6 +1,6 @@ { "name": "@recached/react", - "version": "0.2.3", + "version": "0.2.4", "description": "Official React hooks for Recached \u2014 zero-latency reactive cache", "type": "module", "main": "./dist/index.js", diff --git a/sdks/recached-vue/package-lock.json b/sdks/recached-vue/package-lock.json index 1eef94d..87e5e0d 100644 --- a/sdks/recached-vue/package-lock.json +++ b/sdks/recached-vue/package-lock.json @@ -1,12 +1,12 @@ { "name": "@recached/vue", - "version": "0.2.2", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@recached/vue", - "version": "0.2.2", + "version": "0.2.4", "license": "Apache-2.0", "devDependencies": { "@vue/runtime-core": "^3", @@ -222,9 +222,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -248,9 +248,9 @@ "license": "ISC" }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -268,7 +268,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -276,6 +276,13 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/recached-edge": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/recached-edge/-/recached-edge-0.2.3.tgz", + "integrity": "sha512-rzUeKl8U7M65yPYWVdDa7OTVhIvLG6hy2P6GktXff4Byl6EvSSUX5sVnUyPztyG6zQr/HNKctJbFpXhfaCtFrA==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/sdks/recached-vue/package.json b/sdks/recached-vue/package.json index fd109c9..0b02eeb 100644 --- a/sdks/recached-vue/package.json +++ b/sdks/recached-vue/package.json @@ -1,6 +1,6 @@ { "name": "@recached/vue", - "version": "0.2.3", + "version": "0.2.4", "description": "Official Vue 3 composables for Recached \u2014 zero-latency reactive cache", "type": "module", "main": "./dist/index.js", diff --git a/server-native/src/main.rs b/server-native/src/main.rs index 3ae7011..acc099a 100644 --- a/server-native/src/main.rs +++ b/server-native/src/main.rs @@ -4,28 +4,35 @@ #[global_allocator] static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; +use core_engine::catalog; use core_engine::cmd::{Command, SetExpiry, ZAddCondition}; -use core_engine::resp::Value; -use core_engine::store::{EvictionPolicy, KeyValueStore, KeyspaceSample, SnapshotEntry}; +use core_engine::resp::{MAX_BULK_STRING_BYTES, Value}; +use core_engine::store::{ + EvictionPolicy, KeyValueStore, KeyspaceSample, SnapshotEntry, glob_match, +}; use futures_util::{SinkExt, StreamExt}; use metrics::{counter, gauge}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::ErrorKind; use std::net::IpAddr; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{Semaphore, broadcast, mpsc}; -use tokio_rustls::TlsAcceptor; -use tokio_rustls::rustls::ServerConfig; use tokio_rustls::rustls::pki_types::pem::PemObject; -use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; -use tokio_tungstenite::accept_async; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use tokio_rustls::rustls::{ClientConfig, RootCertStore, ServerConfig}; +use tokio_rustls::{TlsAcceptor, TlsConnector}; +use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::handshake::server::{ + ErrorResponse, Request as HandshakeRequest, Response as HandshakeResponse, +}; +use tokio_tungstenite::tungstenite::http::StatusCode; use tracing::{debug, error, info, warn}; // ── metrics ─────────────────────────────────────────────────────────────────── @@ -43,24 +50,21 @@ static STAT_KEYSPACE_HITS: AtomicU64 = AtomicU64::new(0); static STAT_KEYSPACE_MISSES: AtomicU64 = AtomicU64::new(0); /// RAII guard that tracks an active connection. Increments on creation, -/// decrements when dropped (i.e. when the handler future completes). -struct ConnectionGuard; +/// decrements when dropped (i.e. when the handler future completes), and +/// keeps the `CLIENT LIST` registry in step with both. +struct ConnectionGuard { + id: u64, +} impl ConnectionGuard { - fn tcp() -> Self { - counter!("recached_connections_total", "type" => "tcp").increment(1); - gauge!("recached_connections_active").increment(1.0); - STAT_CONNECTIONS_TOTAL.fetch_add(1, Ordering::Relaxed); - STAT_CONNECTIONS_ACTIVE.fetch_add(1, Ordering::Relaxed); - Self - } - - fn ws() -> Self { - counter!("recached_connections_total", "type" => "ws").increment(1); + fn new(kind: &'static str, meta: ClientMeta) -> Self { + counter!("recached_connections_total", "type" => kind).increment(1); gauge!("recached_connections_active").increment(1.0); STAT_CONNECTIONS_TOTAL.fetch_add(1, Ordering::Relaxed); STAT_CONNECTIONS_ACTIVE.fetch_add(1, Ordering::Relaxed); - Self + let id = meta.id; + publish_client(meta); + Self { id } } } @@ -68,7 +72,100 @@ impl Drop for ConnectionGuard { fn drop(&mut self) { gauge!("recached_connections_active").decrement(1.0); STAT_CONNECTIONS_ACTIVE.fetch_sub(1, Ordering::Relaxed); + CLIENTS + .write() + .unwrap_or_else(|e| e.into_inner()) + .remove(&self.id); + } +} + +// ── Client registry ─────────────────────────────────────────────────────────── + +/// What `CLIENT INFO` and `CLIENT LIST` report about one live connection. +/// +/// The connection task owns the authoritative copy and republishes it whenever +/// a field changes — a name is set, a library identifies itself, the protocol +/// is renegotiated. Publishing on change rather than per command keeps the +/// registry's write lock off the hot path: these events happen a handful of +/// times per connection, commands happen millions of times. +#[derive(Clone, Debug)] +struct ClientMeta { + id: u64, + /// Peer address, or empty when the listener could not report one. + addr: String, + laddr: String, + name: String, + lib_name: String, + lib_ver: String, + since: SystemTime, + resp: u8, + sub: usize, + psub: usize, +} + +impl ClientMeta { + fn new(id: u64, addr: String, laddr: String) -> Self { + Self { + id, + addr, + laddr, + name: String::new(), + lib_name: String::new(), + lib_ver: String::new(), + since: SystemTime::now(), + resp: 2, + sub: 0, + psub: 0, + } } + + /// One line in Redis's `CLIENT LIST` format: space-separated `key=value`. + /// + /// Only fields Recached can answer truthfully are emitted. Redis also + /// reports buffer sizes, file descriptors and an event mask; inventing + /// plausible numbers for those would be worse than leaving them out, + /// because a client cannot tell a made-up `omem` from a real one. Parsers + /// read this format key by key and skip what they do not recognise, so a + /// shorter line is a supported line. + fn render(&self) -> String { + let age = self.since.elapsed().unwrap_or_default().as_secs(); + format!( + "id={} addr={} laddr={} name={} age={} idle=0 flags=N db=0 \ + sub={} psub={} multi=-1 resp={} lib-name={} lib-ver={}", + self.id, + self.addr, + self.laddr, + self.name, + age, + self.sub, + self.psub, + self.resp, + self.lib_name, + self.lib_ver, + ) + } +} + +/// Live connections, keyed by id. A `BTreeMap` so `CLIENT LIST` comes out in +/// connection order rather than an arbitrary one that shuffles between calls. +static CLIENTS: std::sync::LazyLock>> = + std::sync::LazyLock::new(Default::default); + +fn publish_client(meta: ClientMeta) { + CLIENTS + .write() + .unwrap_or_else(|e| e.into_inner()) + .insert(meta.id, meta); +} + +fn client_list_lines() -> String { + let clients = CLIENTS.read().unwrap_or_else(|e| e.into_inner()); + let mut out = String::new(); + for meta in clients.values() { + out.push_str(&meta.render()); + out.push('\n'); + } + out } fn command_name(cmd: &Command) -> &'static str { @@ -77,6 +174,10 @@ fn command_name(cmd: &Command) -> &'static str { Command::Auth(_) => "auth", Command::Hello(_) => "hello", Command::Info(_) => "info", + Command::Quit => "quit", + Command::Client(_) => "client", + Command::Config(_) => "config", + Command::CommandQuery(_) => "command", Command::Get(_) => "get", Command::ESet(_, _) => "eset", Command::Set(_, _, _) => "set", @@ -84,6 +185,7 @@ fn command_name(cmd: &Command) -> &'static str { Command::Unlink(_) => "unlink", Command::Append(_, _) => "append", Command::Strlen(_) => "strlen", + Command::GetRange(_, _, _) => "getrange", Command::GetSet(_, _) => "getset", Command::MGet(_) => "mget", Command::MSet(_) => "mset", @@ -120,6 +222,7 @@ fn command_name(cmd: &Command) -> &'static str { Command::HExists(_, _) => "hexists", Command::HSetNx(_, _, _) => "hsetnx", Command::HMGet(_, _) => "hmget", + Command::HScan(_, _) => "hscan", Command::LPush(_, _) => "lpush", Command::RPush(_, _) => "rpush", Command::LPushX(_, _) => "lpushx", @@ -147,6 +250,7 @@ fn command_name(cmd: &Command) -> &'static str { Command::SPop(_, _) => "spop", Command::SRandMember(_, _) => "srandmember", Command::SMove(_, _, _) => "smove", + Command::SScan(_, _) => "sscan", Command::ZAdd(_, _, _) => "zadd", Command::ZRange(_, _, _, _) => "zrange", Command::ZRevRange(_, _, _, _) => "zrevrange", @@ -160,6 +264,7 @@ fn command_name(cmd: &Command) -> &'static str { Command::ZCard(_) => "zcard", Command::ZIncrBy(_, _, _) => "zincrby", Command::ZCount(_, _, _) => "zcount", + Command::ZScan(_, _) => "zscan", Command::Multi => "multi", Command::Exec => "exec", Command::Discard => "discard", @@ -336,6 +441,61 @@ fn resolve_tls_paths( } } +/// The name a replica verifies the primary's certificate against. +/// +/// Defaults to the host portion of `RECACHED_REPLICAOF`, which is what an +/// operator means by "connect to this primary". It is overridable because the +/// address is frequently an IP while the certificate names a host: a cert issued +/// for `primary.internal` does not validate against `10.0.1.5` unless it also +/// carries that IP as a SAN, and pointing at the IP is the common deployment. +fn repl_tls_servername(primary_addr: &str, override_name: Option) -> String { + if let Some(name) = override_name.map(|n| n.trim().to_string()) + && !name.is_empty() + { + return name; + } + // `host:port`, or a bare host. An IPv6 literal is bracketed, so splitting on + // the last colon would cut inside the address. + match primary_addr.rsplit_once(':') { + Some((host, _)) if !host.is_empty() && !host.contains(':') => host.to_string(), + _ => primary_addr + .trim_start_matches('[') + .split(']') + .next() + .unwrap_or(primary_addr) + .to_string(), + } +} + +/// Build the TLS connector a replica uses to reach its primary. +/// +/// The trust anchor is an explicit file rather than the system root store, and +/// that is deliberate. Replication is a link between two machines the same +/// operator runs, so the right model is pinning the certificate (or the private +/// CA that issued it) — not trusting every public CA on earth to vouch for a +/// host that streams the entire keyspace. Pointing this at a system bundle still +/// works if the primary genuinely uses a publicly-issued certificate. +fn load_repl_tls_connector(ca_path: &str) -> Result { + let certs = + load_certs(ca_path).map_err(|e| format!("RECACHED_REPL_TLS_CA '{ca_path}': {e}"))?; + if certs.is_empty() { + return Err(format!( + "RECACHED_REPL_TLS_CA '{ca_path}' contains no certificates — replication TLS would \ + trust nothing and every connection would fail." + )); + } + let mut roots = RootCertStore::empty(); + for cert in certs { + roots + .add(cert) + .map_err(|e| format!("RECACHED_REPL_TLS_CA '{ca_path}': {e}"))?; + } + let config = ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + Ok(TlsConnector::from(Arc::new(config))) +} + /// Returns a `TlsAcceptor` when both `RECACHED_TLS_CERT` and `RECACHED_TLS_KEY` /// are set, `None` when neither is. Exits if exactly one is set. fn load_tls_acceptor() -> Option { @@ -406,6 +566,101 @@ const BROADCAST_CHANNEL_CAPACITY: usize = 512; const DEFAULT_MAX_CONNECTIONS: usize = 1024; const MAX_AUTH_FAILURES: u32 = 5; const EVICTION_INTERVAL_SECS: u64 = 1; +const DEFAULT_HANDSHAKE_TIMEOUT_SECS: u64 = 10; +/// Longest replication auth line accepted, in bytes. +const MAX_REPL_AUTH_LINE: usize = 512; +/// Window over which failed replication auth attempts are counted per peer. +const REPL_AUTH_WINDOW: Duration = Duration::from_secs(60); +/// Peers tracked before the throttle sweeps expired entries. +const REPL_AUTH_SWEEP_THRESHOLD: usize = 1024; + +// ── private file writes ─────────────────────────────────────────────────────── + +/// Write `bytes` to `path`, creating it readable only by this user. +/// +/// Snapshots, the AOF, and the dedup sidecar are plaintext MessagePack dumps of +/// the keyspace. `fs::write` creates with the process umask — `0644` on a +/// typical host — so any local user could read the entire cache. The +/// documentation told operators to protect these files with filesystem +/// permissions; the server should never have been relying on that. +/// +/// Permissions are also set explicitly after opening, so a file left behind +/// `0644` by an earlier version is tightened on the next write rather than +/// keeping its old mode forever. +/// Writes are fsynced before returning. Every caller is writing state that has +/// to survive a crash — a snapshot about to be renamed into place, or the dedup +/// high-water marks that stop a replayed write being applied twice — so the +/// barrier belongs here rather than at each call site. +async fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + let mut opts = tokio::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + opts.mode(0o600); + let mut f = opts.open(path).await?; + #[cfg(unix)] + restrict_permissions(&f).await; + f.write_all(bytes).await?; + f.flush().await?; + // `sync_all`, not `sync_data`: this file was just created, so its metadata + // is part of what has to reach the device. + f.sync_all().await?; + Ok(()) +} + +/// fsync the directory holding `path`, making a `rename` into it durable. +/// +/// Renaming a fsynced temp file over the target is atomic with respect to +/// readers, but the *directory entry* is itself just a write: without this, a +/// crash can leave the old file, or no file, despite the new contents being +/// safely on disk. Only meaningful on unix — Windows has no directory handle to +/// sync — so the call is compiled out elsewhere. +#[cfg(unix)] +async fn sync_parent_dir(path: &std::path::Path) { + let Some(dir) = path.parent() else { + return; + }; + // An empty parent means the path was relative with no directory component. + let dir = if dir.as_os_str().is_empty() { + std::path::Path::new(".") + } else { + dir + }; + match tokio::fs::File::open(dir).await { + Ok(f) => { + if let Err(e) = f.sync_all().await { + warn!("Directory fsync failed for {:?}: {}", dir, e); + } + } + Err(e) => warn!("Could not open {:?} to fsync: {}", dir, e), + } +} + +#[cfg(not(unix))] +async fn sync_parent_dir(_path: &std::path::Path) {} + +/// Tighten an already-open file to `0600`, ignoring failure. +/// +/// Best-effort by design: on a filesystem that cannot represent unix modes this +/// is not something to fail a write over, and the caller has already created the +/// file with the right mode where the platform allows it. +#[cfg(unix)] +async fn restrict_permissions(f: &tokio::fs::File) { + use std::os::unix::fs::PermissionsExt; + let _ = f + .set_permissions(std::fs::Permissions::from_mode(0o600)) + .await; +} + +/// Path for a temp file alongside `path`, distinct per process. +/// +/// The previous fixed `.tmp` name meant two servers sharing a directory would +/// clobber each other's half-written snapshot, and made the target predictable +/// to anyone who could already write to that directory. Residual: this is not +/// unguessable, so it is a defence against collision rather than against an +/// attacker who already controls the data directory. +fn temp_sibling(path: &std::path::Path, tag: &str) -> PathBuf { + path.with_extension(format!("{tag}.{}.tmp", std::process::id())) +} // ── snapshot persistence ────────────────────────────────────────────────────── @@ -459,6 +714,174 @@ fn parse_allow_ips(raw: &str) -> Result, String> { Ok(ips) } +/// Parse a boolean environment variable, rejecting anything ambiguous. +/// +/// Silently treating `RECACHED_REPL_ENABLE=please` as false would leave an +/// operator believing replication was on when it was not; treating it as true +/// would open a port they never asked for. Neither is acceptable for a variable +/// that gates a security boundary, so an unrecognised value refuses to start. +fn parse_env_bool(var: &str, raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + other => Err(format!( + "{var}: '{other}' is not a boolean. Use 1/true/yes/on or 0/false/no/off." + )), + } +} + +/// True when `bind_host` can only be reached from this machine. +/// +/// A hostname that does not parse as an address is treated as public: the +/// conservative answer is the one that demands a password. +fn bind_is_loopback(bind_host: &str) -> bool { + if bind_host.eq_ignore_ascii_case("localhost") { + return true; + } + // An IPv6 bind address has to be written bracketed — `[::1]` — because the + // listeners format it as `{host}:{port}`, and `::1:6379` does not parse. + // Without stripping them, `[::1]` fails to parse as an address and would be + // classified as public, demanding a replication password on what is in fact + // loopback. + let host = bind_host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(bind_host); + IpAddr::from_str(host) + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + +/// Whether to bind the replication listener, given the environment. +/// +/// This listener used to start unconditionally on every node. The stated reason +/// was multi-tier replication — a replica must be able to serve sub-replicas — +/// but the cost was that *every* default deployment opened a port carrying the +/// entire keyspace to anyone who connected: with no `RECACHED_REPL_PASSWORD`, +/// `handle_replica` skips the handshake and sends a full snapshot followed by a +/// live stream of every subsequent write. An operator who set +/// `RECACHED_PASSWORD` had every reason to believe the data was behind +/// authentication, and it was not. +/// +/// Two rules now apply. The port is closed unless `RECACHED_REPL_ENABLE` says +/// otherwise, and enabling it on an interface other than loopback without a +/// password refuses to start rather than serving the keyspace unauthenticated. +/// Multi-tier replication still works — a node that serves sub-replicas sets +/// the variable, which is the point: it is now a decision rather than a default. +fn resolve_repl_listen( + enable: Option, + bind_host: &str, + password: Option<&str>, +) -> Result { + let enabled = match enable.as_deref().map(str::trim) { + None | Some("") => false, + Some(v) => parse_env_bool("RECACHED_REPL_ENABLE", v)?, + }; + if !enabled { + return Ok(false); + } + let has_password = password.is_some_and(|p| !p.is_empty()); + if !has_password && !bind_is_loopback(bind_host) { + return Err(format!( + "RECACHED_REPL_ENABLE is set and RECACHED_BIND is '{bind_host}', but \ + RECACHED_REPL_PASSWORD is unset — refusing to start. The replication port serves the \ + entire keyspace to whoever connects, so on any interface reachable from the network \ + it must be authenticated. Set RECACHED_REPL_PASSWORD, or bind to 127.0.0.1." + )); + } + Ok(true) +} + +/// Parse `RECACHED_ALLOWED_ORIGINS` into exact origins. +/// +/// An origin is scheme + host + optional port and nothing else, so an entry +/// carrying a path is a misunderstanding of what will be compared against — and +/// one that would silently never match. Reject it at startup, in the same +/// spirit as `parse_allow_ips`. +fn parse_allowed_origins(raw: &str) -> Result, String> { + let mut origins = Vec::new(); + for entry in raw.split(',') { + let trimmed = entry.trim().trim_end_matches('/'); + if trimmed.is_empty() { + continue; + } + // Sandboxed iframes and `file://` documents send the literal `null`. + // An operator may legitimately need to admit them. + if trimmed.eq_ignore_ascii_case("null") { + origins.push("null".to_string()); + continue; + } + let Some((scheme, authority)) = trimmed.split_once("://") else { + return Err(format!( + "RECACHED_ALLOWED_ORIGINS: '{trimmed}' is not an origin — it needs a scheme, e.g. \ + https://app.example.com." + )); + }; + if scheme.is_empty() || authority.is_empty() { + return Err(format!( + "RECACHED_ALLOWED_ORIGINS: '{trimmed}' is not an origin — expected \ + scheme://host[:port]." + )); + } + if authority.contains('/') { + return Err(format!( + "RECACHED_ALLOWED_ORIGINS: '{trimmed}' contains a path. An origin is \ + scheme://host[:port] only, and a browser will never send a path — this entry \ + could never match." + )); + } + origins.push(trimmed.to_ascii_lowercase()); + } + if origins.is_empty() { + return Err( + "RECACHED_ALLOWED_ORIGINS is set but lists no origins — this would reject every \ + browser. Unset it to accept all origins." + .to_string(), + ); + } + Ok(origins) +} + +/// Whether a WebSocket handshake carrying `origin` may proceed. +/// +/// Browsers apply neither CORS nor a preflight to WebSockets, so without this +/// check any page a user visits can open a socket to a reachable Recached and +/// act with that user's network position. On the common `ws://localhost:6380` +/// development setup that means every site in every tab. +/// +/// `Origin` is not a boundary against a native client, which simply omits the +/// header — and that is why an absent origin is allowed. What it does +/// distinguish is "the application I deployed" from "some other page in the same +/// browser", which is precisely the threat this port faces. An unset allowlist +/// permits everything, matching how an unset `RECACHED_PASSWORD` behaves. +fn origin_allowed(allowed: Option<&[String]>, origin: Option<&str>) -> bool { + let Some(list) = allowed else { + return true; + }; + let Some(origin) = origin else { + return true; + }; + let origin = origin.trim().trim_end_matches('/'); + list.iter().any(|a| a.eq_ignore_ascii_case(origin)) +} + +/// How long a connection may take to complete its TLS and/or WebSocket +/// handshake. Override: `RECACHED_HANDSHAKE_TIMEOUT` (seconds). +/// +/// The connection permit is taken before the handshake runs, so without a +/// deadline a client that opens a socket and then says nothing holds one of +/// `RECACHED_MAX_CONNECTIONS` slots indefinitely. A thousand such sockets cost +/// an attacker nothing and stop the server accepting real clients. +fn handshake_timeout() -> Duration { + static V: std::sync::OnceLock = std::sync::OnceLock::new(); + Duration::from_secs(*V.get_or_init(|| { + env_limit( + "RECACHED_HANDSHAKE_TIMEOUT", + DEFAULT_HANDSHAKE_TIMEOUT_SECS as usize, + ) as u64 + })) +} + fn parse_memory_bytes(s: &str) -> Option { let s = s.trim().to_lowercase(); if let Some(n) = s.strip_suffix("gb") { @@ -478,14 +901,15 @@ fn parse_memory_bytes(s: &str) -> Option { async fn save_snapshot(store: &KeyValueStore, cfg: &SnapshotConfig) { let entries = store.snapshot(); let count = entries.len(); - let tmp = cfg.path.with_extension("tmp"); + let tmp = temp_sibling(&cfg.path, "snap"); match rmp_serde::to_vec(&entries) { Err(e) => warn!("Snapshot serialize failed: {}", e), - Ok(bytes) => match tokio::fs::write(&tmp, &bytes).await { + Ok(bytes) => match write_private(&tmp, &bytes).await { Err(e) => warn!("Snapshot write failed: {}", e), Ok(()) => match tokio::fs::rename(&tmp, &cfg.path).await { Err(e) => warn!("Snapshot rename failed: {}", e), Ok(()) => { + sync_parent_dir(&cfg.path).await; cfg.last_save.store(now_unix_secs(), Ordering::Relaxed); info!("Snapshot saved: {} entries → {:?}", count, cfg.path); } @@ -537,11 +961,15 @@ struct AofWriter { impl AofWriter { async fn open(path: PathBuf, sync: AofSync) -> std::io::Result { - let file = tokio::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .await?; + let mut opts = tokio::fs::OpenOptions::new(); + opts.create(true).append(true); + #[cfg(unix)] + opts.mode(0o600); + let file = opts.open(&path).await?; + // An AOF written by an earlier version is likely to be 0644 — tighten it + // on open, since `mode()` only applies to files this call creates. + #[cfg(unix)] + restrict_permissions(&file).await; Ok(Self { path, file: tokio::sync::Mutex::new(file), @@ -556,17 +984,44 @@ impl AofWriter { return; } if self.sync == AofSync::Always { - let _ = f.flush().await; + // `flush()` alone only pushes tokio's buffer into a `write` syscall, + // which leaves the bytes in the page cache — surviving a process + // crash but not a power loss or kernel panic. `always` exists + // precisely to survive the latter, so it has to reach the device. + if let Err(e) = f.flush().await { + warn!("AOF flush failed: {}", e); + return; + } + if let Err(e) = f.sync_data().await { + warn!("AOF fsync failed: {}", e); + } } } + /// Flush and fsync. Called on the `everysec` ticker and before shutdown. + /// + /// `sync_data` rather than `sync_all`: the AOF is append-only, so its + /// metadata beyond the length carries nothing worth an extra barrier. async fn flush(&self) { - let _ = self.file.lock().await.flush().await; + let mut f = self.file.lock().await; + if let Err(e) = f.flush().await { + warn!("AOF flush failed: {}", e); + return; + } + if let Err(e) = f.sync_data().await { + warn!("AOF fsync failed: {}", e); + } } async fn truncate(&self) { - match self.file.lock().await.set_len(0).await { - Ok(()) => info!("AOF truncated after snapshot save"), + let f = self.file.lock().await; + match f.set_len(0).await { + // The truncation itself must be durable, or a crash can resurrect a + // log the snapshot has already subsumed and replay it on top. + Ok(()) => match f.sync_all().await { + Ok(()) => info!("AOF truncated after snapshot save"), + Err(e) => warn!("AOF truncate fsync failed: {}", e), + }, Err(e) => warn!("AOF truncate failed: {}", e), } } @@ -853,16 +1308,15 @@ impl ServerState { Err(_) => return, }; let path = self.dedup_path(); - let tmp = path.with_extension("dedup.tmp"); + let tmp = temp_sibling(&path, "dedup"); match rmp_serde::to_vec(&marks) { Err(e) => warn!("Dedup serialize failed: {}", e), - Ok(bytes) => match tokio::fs::write(&tmp, &bytes).await { + Ok(bytes) => match write_private(&tmp, &bytes).await { Err(e) => warn!("Dedup write failed: {}", e), - Ok(()) => { - if let Err(e) = tokio::fs::rename(&tmp, &path).await { - warn!("Dedup rename failed: {}", e); - } - } + Ok(()) => match tokio::fs::rename(&tmp, &path).await { + Err(e) => warn!("Dedup rename failed: {}", e), + Ok(()) => sync_parent_dir(&path).await, + }, }, } } @@ -986,6 +1440,95 @@ fn parse_save_conditions(s: &str) -> Vec { // ── Replication server (primary side) ──────────────────────────────────────── +/// Per-peer replication auth throttle. +/// +/// The RESP port drops a connection after `MAX_AUTH_FAILURES` guesses, but the +/// replication handshake is one-shot: a wrong password costs the attacker a +/// single TCP connection and nothing else, so the port offered effectively +/// unlimited guesses at a secret that yields the entire keyspace. Failures are +/// counted per source address over a rolling window, and a peer that exhausts +/// them is refused before the handshake is read at all. +/// +/// Keyed by address rather than by connection, which is the whole point — the +/// weakness being closed is that reconnecting reset the count. +struct ReplAuthThrottle { + failures: std::sync::Mutex>, +} + +impl ReplAuthThrottle { + fn new() -> Arc { + Arc::new(Self { + failures: std::sync::Mutex::new(HashMap::new()), + }) + } + + /// True when this peer has spent its attempts and must be refused. + fn is_blocked(&self, ip: IpAddr) -> bool { + let Ok(map) = self.failures.lock() else { + return false; + }; + match map.get(&ip) { + Some((count, last)) => *count >= MAX_AUTH_FAILURES && last.elapsed() < REPL_AUTH_WINDOW, + None => false, + } + } + + fn record_failure(&self, ip: IpAddr) { + let Ok(mut map) = self.failures.lock() else { + return; + }; + let now = std::time::Instant::now(); + // Sweep before inserting so a spray across many source addresses cannot + // grow the map without bound. + if map.len() >= REPL_AUTH_SWEEP_THRESHOLD { + map.retain(|_, (_, last)| last.elapsed() < REPL_AUTH_WINDOW); + } + let entry = map.entry(ip).or_insert((0, now)); + // A peer that went quiet for longer than the window starts over, so a + // slow trickle is not punished forever. + if entry.1.elapsed() >= REPL_AUTH_WINDOW { + *entry = (0, now); + } + entry.0 = entry.0.saturating_add(1); + entry.1 = now; + } + + fn record_success(&self, ip: IpAddr) { + if let Ok(mut map) = self.failures.lock() { + map.remove(&ip); + } + } +} + +/// Read the newline-terminated replication auth line. +/// +/// Reads until the terminator rather than reading exactly `password.len() + 1` +/// bytes, which is how the previous implementation worked: the number of bytes +/// the server waited for *was* the password length, so an attacker could +/// recover it exactly by drip-feeding one byte at a time and watching when the +/// server replied. +async fn read_repl_auth_line(socket: &mut S) -> std::io::Result> +where + S: AsyncRead + Unpin, +{ + let mut line = Vec::with_capacity(64); + let mut byte = [0u8; 1]; + loop { + socket.read_exact(&mut byte).await?; + if byte[0] == b'\n' { + return Ok(line); + } + if line.len() >= MAX_REPL_AUTH_LINE { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + "replication auth line too long", + )); + } + line.push(byte[0]); + } +} + +#[allow(clippy::too_many_arguments)] async fn run_repl_server( bind_host: String, port: u16, @@ -994,6 +1537,10 @@ async fn run_repl_server( replicas: ReplRegistry, repl_password: Option>, repl_channel_capacity: usize, + allowed_ips: Option>>, + semaphore: Arc, + throttle: Arc, + tls: Arc>, ) { let listener = match TcpListener::bind(format!("{}:{}", bind_host, port)).await { Ok(l) => l, @@ -1002,26 +1549,96 @@ async fn run_repl_server( return; } }; - info!("Replication server listening on {}:{}", bind_host, port); + info!( + "Replication server listening on {}:{} ({})", + bind_host, + port, + if tls.is_some() { "TLS" } else { "plaintext" } + ); loop { match listener.accept().await { Ok((socket, addr)) => { + // The IP allowlist and the connection limit were previously + // applied on the RESP and WebSocket listeners only, so neither + // constrained the one port that streams the whole keyspace. + if let Some(allowed) = &allowed_ips + && !allowed.contains(&addr.ip()) + { + debug!("Replication: rejected IP {}", addr.ip()); + continue; + } + if throttle.is_blocked(addr.ip()) { + warn!( + "Replication: {} refused — too many failed auth attempts", + addr.ip() + ); + continue; + } + let permit = match Arc::clone(&semaphore).try_acquire_owned() { + Ok(p) => p, + Err(_) => { + warn!("Replication: connection limit reached, dropping {}", addr); + continue; + } + }; info!("Replica connected from {}", addr); let store = Arc::clone(&store); let snap_cfg = Arc::clone(&snap_cfg); let replicas = Arc::clone(&replicas); let pwd = repl_password.clone(); + let thr = Arc::clone(&throttle); + let tls = Arc::clone(&tls); tokio::spawn(async move { - if let Err(e) = handle_replica( - socket, - store, - snap_cfg, - replicas, - pwd, - repl_channel_capacity, - ) - .await - { + let _permit = permit; + // Bounded like the other listeners: the permit is already + // held, so a peer that never negotiates must not keep it. + let outcome = if let Some(acceptor) = tls.as_ref() { + match tokio::time::timeout(handshake_timeout(), acceptor.accept(socket)) + .await + { + Ok(Ok(stream)) => { + handle_replica( + stream, + store, + snap_cfg, + replicas, + pwd, + repl_channel_capacity, + addr.ip(), + thr, + ) + .await + } + Ok(Err(e)) => { + // The most likely cause by far is a replica that + // has not been given RECACHED_REPL_TLS_CA, which + // otherwise looks like an unexplained disconnect. + warn!( + "Replication TLS handshake failed from {}: {} — is that \ + replica configured with RECACHED_REPL_TLS_CA?", + addr, e + ); + return; + } + Err(_) => { + debug!("Replication TLS handshake from {} timed out", addr); + return; + } + } + } else { + handle_replica( + socket, + store, + snap_cfg, + replicas, + pwd, + repl_channel_capacity, + addr.ip(), + thr, + ) + .await + }; + if let Err(e) = outcome { info!("Replica {} disconnected: {}", addr, e); } }); @@ -1031,21 +1648,38 @@ async fn run_repl_server( } } -async fn handle_replica( - mut socket: TcpStream, +#[allow(clippy::too_many_arguments)] +async fn handle_replica( + mut socket: S, store: Arc, _snap_cfg: Arc, replicas: ReplRegistry, repl_password: Option>, repl_channel_capacity: usize, -) -> std::io::Result<()> { - // 0. Auth handshake — replica must send "\n" before anything else + peer_ip: IpAddr, + throttle: Arc, +) -> std::io::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin + Send, +{ + // 0. Auth handshake — replica must send "\n" before anything else. + // + // Bounded by a deadline as well as a length: a peer that connects and then + // says nothing would otherwise hold its connection permit forever. if let Some(pwd) = &repl_password { - let mut auth_buf = vec![0u8; pwd.len() + 1]; - socket.read_exact(&mut auth_buf).await?; - let received_pwd = &auth_buf[..pwd.len()]; - let terminator = auth_buf[pwd.len()]; - if !ct_eq_bytes(received_pwd, pwd.as_bytes()) || terminator != b'\n' { + let line = match tokio::time::timeout(handshake_timeout(), read_repl_auth_line(&mut socket)) + .await + { + Ok(res) => res?, + Err(_) => { + return Err(std::io::Error::new( + ErrorKind::TimedOut, + "replication auth handshake timed out", + )); + } + }; + if !ct_eq_bytes(&line, pwd.as_bytes()) { + throttle.record_failure(peer_ip); let _ = socket .write_all(b"-ERR invalid replication password\n") .await; @@ -1054,6 +1688,7 @@ async fn handle_replica( "replication auth failed", )); } + throttle.record_success(peer_ip); socket.write_all(b"+OK\n").await?; socket.flush().await?; } @@ -1087,7 +1722,7 @@ async fn handle_replica( // now writes back a cumulative count of applied frames; `sent - acked` is // the lag. Reading and writing are selected over so a replica that stops // acknowledging cannot stall the write side, and vice versa. - let (mut rd, mut wr) = socket.split(); + let (mut rd, mut wr) = tokio::io::split(socket); let mut ack_buf = [0u8; 8]; loop { tokio::select! { @@ -1124,6 +1759,7 @@ async fn run_repl_client( repl_password: Option, failover_timeout_secs: Option, tx: broadcast::Sender, + tls: Option<(TlsConnector, String)>, ) { let mut backoff_secs = 2u64; let mut unreachable_since: Option = None; @@ -1140,14 +1776,70 @@ async fn run_repl_client( warn!("Replica: connect failed: {}", e); unreachable_since.get_or_insert_with(std::time::Instant::now); } - Ok(mut socket) => { + Ok(socket) => { // Primary is reachable — reset the unreachable timer. unreachable_since = None; backoff_secs = 2; - if let Err(e) = - sync_from_primary(&mut socket, &store, repl_password.as_deref(), &tx, &state) + + // TLS is what makes the primary's *identity* checked, not just + // the channel encrypted: without it a DNS hijack or an on-path + // attacker can feed this replica an arbitrary keyspace, and the + // replica has no way to tell. + let result = match &tls { + None => { + sync_from_primary( + &mut { socket }, + &store, + repl_password.as_deref(), + &tx, + &state, + ) .await - { + } + Some((connector, servername)) => { + match ServerName::try_from(servername.clone()) { + Err(_) => { + error!( + "Replica: '{}' is not a valid TLS server name — set \ + RECACHED_REPL_TLS_SERVERNAME to the name on the primary's \ + certificate", + servername + ); + return; + } + Ok(name) => { + match tokio::time::timeout( + handshake_timeout(), + connector.connect(name, socket), + ) + .await + { + Err(_) => Err(std::io::Error::new( + ErrorKind::TimedOut, + "TLS handshake with primary timed out", + )), + Ok(Err(e)) => Err(std::io::Error::other(format!( + "TLS handshake with primary failed: {e} — check that the \ + primary has RECACHED_TLS_CERT set and that \ + RECACHED_REPL_TLS_CA trusts it" + ))), + Ok(Ok(mut stream)) => { + sync_from_primary( + &mut stream, + &store, + repl_password.as_deref(), + &tx, + &state, + ) + .await + } + } + } + } + } + }; + + if let Err(e) = result { warn!("Replica: sync ended: {}", e); // Sync dropped — primary may be gone; start tracking if not already. unreachable_since.get_or_insert_with(std::time::Instant::now); @@ -1177,13 +1869,16 @@ async fn run_repl_client( } } -async fn sync_from_primary( - socket: &mut TcpStream, +async fn sync_from_primary( + socket: &mut S, store: &KeyValueStore, repl_password: Option<&str>, tx: &broadcast::Sender, state: &ServerState, -) -> std::io::Result<()> { +) -> std::io::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ // 0. Send auth password if configured if let Some(pwd) = repl_password { let msg = format!("{}\n", pwd); @@ -1362,6 +2057,17 @@ fn verify_sync_token(secret: &str, token: &str) -> Result, &'static if patterns.is_empty() { return Err("token grants no patterns"); } + // Token patterns reach `glob_match` without passing through the command + // parser, so the cap `check_pattern` applies to `KEYS`/`SCAN`/`PSUBSCRIBE` + // has to be repeated here. These are matched once per key per write, so an + // over-long one is the most expensive place to put a pattern — and a + // compromised or careless minting service should not be able to. + if patterns + .iter() + .any(|p| p.len() > core_engine::store::MAX_PATTERN_BYTES) + { + return Err("token grants an over-long pattern"); + } Ok(patterns) } @@ -1394,6 +2100,12 @@ fn command_scope(cmd: &Command) -> CommandScope { // QSUB patterns are scope-checked against the grant in the WS handler. | Command::QSub(_) | Command::QUnsub(_) + // QUIT and CLIENT describe the connection itself, which a scoped + // connection is entitled to know about; COMMAND describes the server's + // vocabulary, which is public. + | Command::Quit + | Command::Client(_) + | Command::CommandQuery(_) | Command::Unknown(_) => CommandScope::KeyLess, Command::Keys(_) @@ -1407,6 +2119,9 @@ fn command_scope(cmd: &Command) -> CommandScope { // size, replication topology. A connection scoped to a handful of keys // has no business reading it. | Command::Info(_) + // CONFIG reports server-wide limits and whether auth is on. Same + // reasoning as INFO: not for a connection scoped to a few keys. + | Command::Config(_) | Command::ReplicaOfNoOne => CommandScope::Admin, Command::ESet(k, _) @@ -1414,6 +2129,7 @@ fn command_scope(cmd: &Command) -> CommandScope { | Command::Get(k) | Command::Append(k, _) | Command::Strlen(k) + | Command::GetRange(k, _, _) | Command::GetSet(k, _) | Command::SetNx(k, _) | Command::SetEx(k, _, _) @@ -1442,6 +2158,9 @@ fn command_scope(cmd: &Command) -> CommandScope { | Command::HExists(k, _) | Command::HSetNx(k, _, _) | Command::HMGet(k, _) + | Command::HScan(k, _) + | Command::SScan(k, _) + | Command::ZScan(k, _) | Command::LPush(k, _) | Command::RPush(k, _) | Command::LPushX(k, _) @@ -2196,44 +2915,324 @@ fn eviction_policy_name(policy: EvictionPolicy) -> &'static str { } } -/// Build the `INFO` payload for `sections` (empty = the default set). +// ── CLIENT / CONFIG / COMMAND ───────────────────────────────────────────────── + +/// Wrong-subcommand error in Redis's wording, which some clients match on. +fn unknown_subcommand(container: &str, sub: &str) -> Value { + Value::Error(format!( + "ERR Unknown subcommand or wrong number of arguments for '{sub}'. \ + Try {container} HELP." + )) +} + +/// Handle `CLIENT `, mutating this connection's `meta` in place. /// -/// The format is load-bearing: `# Section` header, `field:value` lines, CRLF -/// throughout, and a blank line between sections. Every Redis client and -/// monitoring agent parses exactly that shape, so it is covered by tests rather -/// than left to formatting drift. Unknown section names yield no output, which -/// is what Redis does. -#[allow(clippy::too_many_arguments)] -fn render_info( - sections: &[String], - facts: &ServerFacts, - store: &KeyValueStore, - sample: KeyspaceSample, - is_replica: bool, - repl: ReplInfo, - last_save: i64, - live_queries: u64, - watched_keys: u64, -) -> String { - let wanted: Vec<&str> = if sections.is_empty() - || sections +/// Returns `None` for subcommands Recached does not implement, so the caller +/// can answer with the standard error rather than this function inventing a +/// reply. `SETNAME`, `SETINFO` and the protocol version write through to the +/// registry, which is what makes them visible to another connection's +/// `CLIENT LIST`. +fn handle_client_command(args: &[String], meta: &mut ClientMeta) -> Value { + let sub = args[0].to_uppercase(); + match (sub.as_str(), args.len()) { + ("ID", 1) => Value::Integer(meta.id as i64), + ("INFO", 1) => Value::BulkString(Some(meta.render().into_bytes())), + ("LIST", 1) => Value::BulkString(Some(client_list_lines().into_bytes())), + ("GETNAME", 1) => { + if meta.name.is_empty() { + Value::BulkString(None) + } else { + Value::BulkString(Some(meta.name.clone().into_bytes())) + } + } + ("SETNAME", 2) => { + // Redis reserves spaces and newlines because the name is echoed + // into the space-separated CLIENT LIST format. + if args[1].contains(' ') || args[1].contains('\n') { + return Value::Error( + "ERR Client names cannot contain spaces, newlines or special characters." + .to_string(), + ); + } + meta.name = args[1].clone(); + publish_client(meta.clone()); + Value::SimpleString("OK".to_string()) + } + ("SETINFO", 3) => match args[1].to_uppercase().as_str() { + "LIB-NAME" => { + meta.lib_name = args[2].clone(); + publish_client(meta.clone()); + Value::SimpleString("OK".to_string()) + } + "LIB-VER" => { + meta.lib_ver = args[2].clone(); + publish_client(meta.clone()); + Value::SimpleString("OK".to_string()) + } + other => Value::Error(format!("ERR Unrecognized option '{other}'")), + }, + ("HELP", 1) => Value::Array(Some( + [ + "CLIENT ", + "ID -- Return this connection's identifier.", + "INFO -- Return information about this connection.", + "LIST -- Return information about all connections.", + "GETNAME -- Return this connection's name.", + "SETNAME -- Set this connection's name.", + "SETINFO -- Identify the client library.", + ] .iter() - .any(|s| s == "all" || s == "everything" || s == "default") - { - DEFAULT_INFO_SECTIONS.to_vec() - } else { - sections.iter().map(|s| s.as_str()).collect() - }; - - let uptime = facts - .start - .elapsed() - .map(|d| d.as_secs()) - .unwrap_or_default(); - let mut out = String::new(); + .map(|l| Value::SimpleString(l.to_string())) + .collect(), + )), + // KILL, UNPAUSE, NO-EVICT and friends are administrative operations + // with real semantics. Answering +OK without performing them would be + // worse than saying no: a client would believe a connection had been + // killed or eviction disabled when nothing happened. + _ => unknown_subcommand("CLIENT", &args.join(" ")), + } +} - for section in wanted { - let body = match section { +/// The configuration parameters `CONFIG GET` reports, resolved from the values +/// actually in force rather than from a table of defaults. +fn config_parameters(facts: &ServerFacts, store: &KeyValueStore) -> Vec<(&'static str, String)> { + vec![ + ( + "maxmemory", + store.max_memory_bytes().unwrap_or(0).to_string(), + ), + ( + "maxmemory-policy", + eviction_policy_name(store.eviction_policy()).to_string(), + ), + ("maxclients", facts.max_connections.to_string()), + ("port", facts.tcp_port.to_string()), + ( + "tls-port", + if facts.tls_enabled { + facts.tcp_port.to_string() + } else { + "0".to_string() + }, + ), + ( + "appendonly", + if facts.aof_enabled { + "yes".into() + } else { + "no".into() + }, + ), + // Recached has a single keyspace. Clients that SELECT anything other + // than 0 need to know that before they try. + ("databases", "1".to_string()), + // Reported as masked, exactly as Redis does: the presence of a + // password is not a secret, its value is. + ( + "requirepass", + if facts.auth_enabled { + "*".into() + } else { + String::new() + }, + ), + ("proto-max-bulk-len", MAX_BULK_STRING_BYTES.to_string()), + ("timeout", "0".to_string()), + ("save", String::new()), + ] +} + +/// Handle `CONFIG `. +fn handle_config_command(args: &[String], facts: &ServerFacts, store: &KeyValueStore) -> Value { + let sub = args[0].to_uppercase(); + match sub.as_str() { + "GET" if args.len() >= 2 => { + let params = config_parameters(facts, store); + let mut out = Vec::new(); + for (name, value) in ¶ms { + if args[1..].iter().any(|pat| glob_match(pat, name)) { + out.push(Value::BulkString(Some(name.as_bytes().to_vec()))); + out.push(Value::BulkString(Some(value.clone().into_bytes()))); + } + } + Value::Array(Some(out)) + } + // Recached reads its configuration from the environment at startup and + // holds it behind an `Arc` for the life of the process, so there is + // nothing a runtime SET could change. Saying so is better than + // returning OK and leaving the operator to discover later that the + // limit they set never applied. + "SET" if args.len() >= 3 => Value::Error(format!( + "ERR CONFIG SET is not supported: Recached is configured at startup. \ + Set '{}' through the environment and restart.", + args[1] + )), + "RESETSTAT" if args.len() == 1 => Value::Error( + "ERR CONFIG RESETSTAT is not supported: counters are exported to Prometheus, \ + where resetting them would break rate calculations." + .to_string(), + ), + _ => unknown_subcommand("CONFIG", &args.join(" ")), + } +} + +/// `COMMAND INFO`'s per-command reply: name, arity, flags, key positions. +fn command_info_entry(spec: &catalog::CommandSpec) -> Value { + Value::Array(Some(vec![ + Value::BulkString(Some(spec.name.as_bytes().to_vec())), + Value::Integer(spec.arity as i64), + Value::Array(Some( + spec.flags + .iter() + .map(|f| Value::SimpleString((*f).to_string())) + .collect(), + )), + Value::Integer(spec.first_key as i64), + Value::Integer(spec.last_key as i64), + Value::Integer(spec.step as i64), + // ACL categories, tips, key specs and subcommands: Redis 7 appends + // four more elements here. Recached has no ACL system and no + // subcommand tree to describe, so it reports them empty rather than + // omitting them — a client indexing element 6 gets an empty list + // instead of an out-of-range error. + Value::Array(Some(vec![])), + Value::Array(Some(vec![])), + Value::Array(Some(vec![])), + Value::Array(Some(vec![])), + ])) +} + +/// `COMMAND DOCS`'s per-command reply. RESP2 clients see the same pairs as a +/// flat array, which is how Redis degrades a map on the older protocol. +fn command_docs_entry(spec: &catalog::CommandSpec, protover: u8) -> Value { + let fields = vec![ + ( + "summary", + Value::BulkString(Some(spec.summary.as_bytes().to_vec())), + ), + ("since", Value::BulkString(Some(b"1.0.0".to_vec()))), + ( + "group", + Value::BulkString(Some(spec.group.as_bytes().to_vec())), + ), + ("arity", Value::Integer(spec.arity as i64)), + ]; + map_or_flat(fields, protover) +} + +/// RESP3 sends a map; RESP2 has no map type and flattens to alternating +/// key/value entries. Same split `HELLO` already makes. +fn map_or_flat(fields: Vec<(&str, Value)>, protover: u8) -> Value { + if protover >= 3 { + Value::Map( + fields + .into_iter() + .map(|(k, v)| (Value::BulkString(Some(k.as_bytes().to_vec())), v)) + .collect(), + ) + } else { + let mut flat = Vec::with_capacity(fields.len() * 2); + for (k, v) in fields { + flat.push(Value::BulkString(Some(k.as_bytes().to_vec()))); + flat.push(v); + } + Value::Array(Some(flat)) + } +} + +/// Handle `COMMAND [subcommand]`. +fn handle_command_query(args: &[String], protover: u8) -> Value { + let Some(sub) = args.first() else { + // Bare COMMAND: the whole catalog, as COMMAND INFO entries. + return Value::Array(Some( + catalog::CATALOG.iter().map(command_info_entry).collect(), + )); + }; + match sub.to_uppercase().as_str() { + "COUNT" if args.len() == 1 => Value::Integer(catalog::CATALOG.len() as i64), + "LIST" if args.len() == 1 => Value::Array(Some( + catalog::CATALOG + .iter() + .map(|c| Value::BulkString(Some(c.name.as_bytes().to_vec()))) + .collect(), + )), + "INFO" => { + if args.len() == 1 { + return Value::Array(Some( + catalog::CATALOG.iter().map(command_info_entry).collect(), + )); + } + // A name the server does not have replies nil in its slot, so the + // reply stays positionally aligned with the request. + Value::Array(Some( + args[1..] + .iter() + .map(|n| match catalog::lookup(n) { + Some(spec) => command_info_entry(spec), + None => Value::Array(None), + }) + .collect(), + )) + } + "DOCS" => { + let specs: Vec<&catalog::CommandSpec> = if args.len() == 1 { + catalog::CATALOG.iter().collect() + } else { + args[1..] + .iter() + .filter_map(|n| catalog::lookup(n)) + .collect() + }; + // Unknown names are absent from the map rather than nil-filled: + // COMMAND DOCS is keyed by name, so there is no slot to align. + let fields: Vec<(&str, Value)> = specs + .iter() + .map(|s| (s.name, command_docs_entry(s, protover))) + .collect(); + map_or_flat(fields, protover) + } + _ => unknown_subcommand("COMMAND", &args.join(" ")), + } +} + +/// Build the `INFO` payload for `sections` (empty = the default set). +/// +/// The format is load-bearing: `# Section` header, `field:value` lines, CRLF +/// throughout, and a blank line between sections. Every Redis client and +/// monitoring agent parses exactly that shape, so it is covered by tests rather +/// than left to formatting drift. Unknown section names yield no output, which +/// is what Redis does. +#[allow(clippy::too_many_arguments)] +fn render_info( + sections: &[String], + facts: &ServerFacts, + store: &KeyValueStore, + sample: KeyspaceSample, + is_replica: bool, + repl: ReplInfo, + last_save: i64, + live_queries: u64, + watched_keys: u64, +) -> String { + let wanted: Vec<&str> = if sections.is_empty() + || sections + .iter() + .any(|s| s == "all" || s == "everything" || s == "default") + { + DEFAULT_INFO_SECTIONS.to_vec() + } else { + sections.iter().map(|s| s.as_str()).collect() + }; + + let uptime = facts + .start + .elapsed() + .map(|d| d.as_secs()) + .unwrap_or_default(); + let mut out = String::new(); + + for section in wanted { + let body = match section { "server" => { format!( "redis_version:{}\r\n\ @@ -3011,6 +4010,29 @@ async fn main() -> Result<(), Box> { warn!("IP allowlist DISABLED. Accepting all connections."); } + // ── WebSocket origin allowlist ──────────────────────────────────────── + let allowed_origins: Arc>> = + Arc::new(match std::env::var("RECACHED_ALLOWED_ORIGINS").ok() { + None => None, + Some(raw) => match parse_allowed_origins(&raw) { + Ok(list) => Some(list), + Err(msg) => { + error!("{msg}"); + std::process::exit(1); + } + }, + }); + + if let Some(list) = allowed_origins.as_ref() { + info!("WebSocket origin allowlist ENABLED: {:?}", list); + } else { + warn!( + "WebSocket origin allowlist DISABLED. Any web page a user visits can open a socket to \ + port 6380 — browsers apply neither CORS nor a preflight to WebSockets. Set \ + RECACHED_ALLOWED_ORIGINS before exposing this port to a browser." + ); + } + // ── store ───────────────────────────────────────────────────────────── let max_keys = std::env::var("RECACHED_MAX_KEYS") .ok() @@ -3113,6 +4135,19 @@ async fn main() -> Result<(), Box> { AofSync::No => "no", } ); + // `always` fsyncs inside the AOF lock, so every write in the + // process waits for one disk barrier — measured at roughly + // 20 ms per append on APFS, i.e. tens of writes per second + // rather than tens of thousands. That is the honest cost of the + // guarantee, but an operator who picked it casually will read + // the result as a hang, so say so at startup. + if aof_sync == AofSync::Always { + warn!( + "RECACHED_AOF_SYNC=always fsyncs on every write and serialises all writers \ + behind it — expect write throughput in the tens per second. Use everysec \ + unless you genuinely cannot lose one second of writes." + ); + } Some(writer) } Err(e) => { @@ -3124,6 +4159,32 @@ async fn main() -> Result<(), Box> { None }; + // ── TLS ─────────────────────────────────────────────────────────────── + // Resolved before replication: the replication listener uses the same + // certificate, so it has to exist before that listener is spawned. + let tls_acceptor: Option = load_tls_acceptor(); + if tls_acceptor.is_some() { + info!( + "TLS ENABLED (cert={}, key={})", + std::env::var("RECACHED_TLS_CERT").unwrap_or_default(), + std::env::var("RECACHED_TLS_KEY").unwrap_or_default() + ); + } else { + warn!("TLS DISABLED. Set RECACHED_TLS_CERT and RECACHED_TLS_KEY to enable."); + } + let tls_acceptor = Arc::new(tls_acceptor); + + // ── connection limiter ──────────────────────────────────────────────── + // Resolved before replication because the replication listener shares this + // budget: a flood of replica connections must not be able to starve real + // clients, and `maxclients` should mean the total. + let max_connections = std::env::var("RECACHED_MAX_CONNECTIONS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_MAX_CONNECTIONS); + info!("Max connections: {}", max_connections); + let semaphore = Arc::new(Semaphore::new(max_connections)); + // ── Replication ─────────────────────────────────────────────────────── let replicaof = std::env::var("RECACHED_REPLICAOF").ok(); let repl_port: u16 = std::env::var("RECACHED_REPL_PORT") @@ -3141,11 +4202,73 @@ async fn main() -> Result<(), Box> { .and_then(|v| v.parse().ok()) .filter(|&n| n > 0); - if repl_password.is_some() { - info!("Replication auth ENABLED (RECACHED_REPL_PASSWORD is set)."); + // Whether to bind the replication listener at all. Refuses to start on a + // network-reachable interface without a password rather than serving the + // keyspace unauthenticated. + let repl_listen = match resolve_repl_listen( + std::env::var("RECACHED_REPL_ENABLE").ok(), + &bind_host, + repl_password.as_deref(), + ) { + Ok(v) => v, + Err(msg) => { + error!("{msg}"); + std::process::exit(1); + } + }; + + // Outbound replication TLS. Configured separately from the listener's TLS + // because the two directions are independent: this node may serve replicas + // over TLS, follow a primary over TLS, both, or neither. + let repl_tls: Option<(TlsConnector, String)> = match std::env::var("RECACHED_REPL_TLS_CA") + .ok() + .filter(|s| !s.is_empty()) + { + None => None, + Some(ca) => match load_repl_tls_connector(&ca) { + Ok(connector) => { + let servername = repl_tls_servername( + replicaof.as_deref().unwrap_or_default(), + std::env::var("RECACHED_REPL_TLS_SERVERNAME").ok(), + ); + info!( + "Replication client TLS ENABLED (CA={}, verifying primary as '{}')", + ca, servername + ); + Some((connector, servername)) + } + Err(msg) => { + error!("{msg}"); + std::process::exit(1); + } + }, + }; + + if replicaof.is_some() && repl_tls.is_none() { + warn!( + "Replication to the primary is PLAINTEXT — the password and the entire keyspace cross \ + the network unencrypted, and the primary's identity is not verified. Set \ + RECACHED_REPL_TLS_CA, or keep replication on a private network." + ); + } + + if !repl_listen { + info!( + "Replication server DISABLED — port {} is not bound. Set RECACHED_REPL_ENABLE=1 on any \ + node that serves replicas (including a replica serving sub-replicas).", + repl_port + ); + } else if repl_password.is_some() { + info!( + "Replication server ENABLED on port {} with auth (RECACHED_REPL_PASSWORD is set).", + repl_port + ); } else { warn!( - "Replication auth DISABLED. Set RECACHED_REPL_PASSWORD to secure the replication port." + "Replication server ENABLED on port {} WITHOUT a password, on loopback only. It serves \ + the entire keyspace to whoever connects — set RECACHED_REPL_PASSWORD before binding \ + any other interface.", + repl_port ); } @@ -3221,17 +4344,26 @@ async fn main() -> Result<(), Box> { let (tx, _rx) = broadcast::channel::(BROADCAST_CHANNEL_CAPACITY); // ── start replication ───────────────────────────────────────────────── - // The replication server runs on every node — including replicas — so a - // replica can in turn serve sub-replicas (multi-tier replication). - { + // Opt-in. The listener may run on a replica as well as a primary, so a + // replica can serve sub-replicas (multi-tier replication) — but it is a + // decision the operator makes, not a port that appears by default. + if repl_listen { let store_r = Arc::clone(&store); let snap_r = Arc::clone(&snap_cfg); let reg_r = Arc::clone(&replicas); let pwd_r = repl_password.clone().map(Arc::new); let cap_r = repl_channel_capacity; let host_r = bind_host.clone(); + let allowed_r = allowed_ips.clone(); + let sem_r = Arc::clone(&semaphore); + let thr_r = ReplAuthThrottle::new(); + let tls_r = Arc::clone(&tls_acceptor); tokio::spawn(async move { - run_repl_server(host_r, repl_port, store_r, snap_r, reg_r, pwd_r, cap_r).await; + run_repl_server( + host_r, repl_port, store_r, snap_r, reg_r, pwd_r, cap_r, allowed_r, sem_r, thr_r, + tls_r, + ) + .await; }); } if is_replica_start && let Some(primary_addr) = replicaof { @@ -3240,8 +4372,9 @@ async fn main() -> Result<(), Box> { let pwd_r = repl_password.clone(); let fo_r = failover_timeout_secs; let tx_r = tx.clone(); + let tls_r = repl_tls; tokio::spawn(async move { - run_repl_client(primary_addr, store_r, state_r, pwd_r, fo_r, tx_r).await; + run_repl_client(primary_addr, store_r, state_r, pwd_r, fo_r, tx_r, tls_r).await; }); if let Some(t) = failover_timeout_secs { info!( @@ -3311,27 +4444,6 @@ async fn main() -> Result<(), Box> { }); } - // ── connection limiter ──────────────────────────────────────────────── - let max_connections = std::env::var("RECACHED_MAX_CONNECTIONS") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_MAX_CONNECTIONS); - info!("Max connections: {}", max_connections); - let semaphore = Arc::new(Semaphore::new(max_connections)); - - // ── TLS ─────────────────────────────────────────────────────────────── - let tls_acceptor: Option = load_tls_acceptor(); - if tls_acceptor.is_some() { - info!( - "TLS ENABLED (cert={}, key={})", - std::env::var("RECACHED_TLS_CERT").unwrap_or_default(), - std::env::var("RECACHED_TLS_KEY").unwrap_or_default() - ); - } else { - warn!("TLS DISABLED. Set RECACHED_TLS_CERT and RECACHED_TLS_KEY to enable."); - } - let tls_acceptor = Arc::new(tls_acceptor); - // Startup facts for INFO. Recorded once the configuration is fully // resolved and before any listener binds, so no connection can observe the // defaults. @@ -3398,16 +4510,34 @@ async fn main() -> Result<(), Box> { tokio::spawn(async move { let _permit = permit; if let Some(acc) = tls.as_ref() { - match acc.accept(socket).await { - Ok(tls_stream) => { - handle_tcp(tls_stream, s, t, p, ps, wr, sc).await + // Bounded: the permit is already held, so a peer + // that opens a socket and never negotiates would + // otherwise occupy a slot indefinitely. + match tokio::time::timeout(handshake_timeout(), acc.accept(socket)) + .await + { + Ok(Ok(tls_stream)) => { + handle_tcp( + tls_stream, + s, + t, + p, + ps, + wr, + sc, + addr.to_string(), + ) + .await } - Err(e) => { + Ok(Err(e)) => { warn!("TCP TLS handshake failed from {}: {}", addr, e) } + Err(_) => { + debug!("TCP TLS handshake from {} timed out", addr) + } } } else { - handle_tcp(socket, s, t, p, ps, wr, sc).await; + handle_tcp(socket, s, t, p, ps, wr, sc, addr.to_string()).await; } }); } @@ -3466,16 +4596,20 @@ async fn main() -> Result<(), Box> { let tls = Arc::clone(&tls_acceptor); let sc = Arc::clone(&state); let ss = Arc::clone(&sync_secret); + let ao = Arc::clone(&allowed_origins); let id = next_conn_id(); tokio::spawn(async move { let _permit = permit; if let Some(acc) = tls.as_ref() { - match acc.accept(socket).await { - Ok(tls_stream) => handle_ws(tls_stream, s, t, p, id, ps, wr, sc, ss).await, - Err(e) => warn!("WS TLS handshake failed from {}: {}", addr, e), + match tokio::time::timeout(handshake_timeout(), acc.accept(socket)).await { + Ok(Ok(tls_stream)) => { + handle_ws(tls_stream, s, t, p, id, ps, wr, sc, ss, ao, addr.to_string()).await + } + Ok(Err(e)) => warn!("WS TLS handshake failed from {}: {}", addr, e), + Err(_) => debug!("WS TLS handshake from {} timed out", addr), } } else { - handle_ws(socket, s, t, p, id, ps, wr, sc, ss).await; + handle_ws(socket, s, t, p, id, ps, wr, sc, ss, ao, addr.to_string()).await; } }); } @@ -3497,6 +4631,7 @@ async fn main() -> Result<(), Box> { // ── TCP handler ─────────────────────────────────────────────────────────────── +#[allow(clippy::too_many_arguments)] async fn handle_tcp( socket: S, store: Arc, @@ -3505,10 +4640,17 @@ async fn handle_tcp( pubsub: SharedPubSub, watch_registry: WatchRegistry, state: Arc, + peer: String, ) where S: AsyncRead + AsyncWrite + Unpin + Send, { - let _guard = ConnectionGuard::tcp(); + let conn_id = next_conn_id(); + let mut client_meta = ClientMeta::new( + conn_id, + peer, + format!("127.0.0.1:{}", server_facts().tcp_port), + ); + let _guard = ConnectionGuard::new("tcp", client_meta.clone()); let (mut reader, raw_writer) = tokio::io::split(socket); let mut writer = tokio::io::BufWriter::with_capacity(32 * 1024, raw_writer); let mut buf = Vec::::new(); @@ -3532,10 +4674,19 @@ async fn handle_tcp( let mut watched_keys: HashSet = HashSet::new(); let mut watch_dirty = false; let (watch_tx, mut watch_rx) = mpsc::unbounded_channel::(); - let conn_id = next_conn_id(); 'outer: loop { let is_subscribed = !subscribed_channels.is_empty() || !subscribed_patterns.is_empty(); + // Republish only when the counts moved: CLIENT LIST has to see live + // subscription state, but taking the registry's write lock once per + // command would put it on the hot path. + if client_meta.sub != subscribed_channels.len() + || client_meta.psub != subscribed_patterns.len() + { + client_meta.sub = subscribed_channels.len(); + client_meta.psub = subscribed_patterns.len(); + publish_client(client_meta.clone()); + } tokio::select! { result = reader.read(&mut read_buf) => { @@ -3581,9 +4732,26 @@ async fn handle_tcp( state.is_replica(), ); if writer.write_all(&resp).await.is_err() { break 'outer; } + // CLIENT LIST reports resp= per connection, + // so a renegotiation has to reach the registry. + if client_meta.resp != protover { + client_meta.resp = protover; + publish_client(client_meta.clone()); + } continue 'parse; } + // QUIT is answered before the auth gate and + // before the subscribe-mode gate, as in Redis: + // a client that cannot authenticate, or is + // parked in subscribe mode, still deserves a + // clean close rather than a dropped socket. + if matches!(cmd, Command::Quit) { + let _ = writer.write_all(b"+OK\r\n").await; + let _ = writer.flush().await; + break 'outer; + } + if !is_authenticated { if writer.write_all(b"-NOAUTH Authentication required.\r\n").await.is_err() { break 'outer; @@ -3836,6 +5004,21 @@ async fn handle_tcp( if writer.write_all(&resp).await.is_err() { break 'outer; } continue 'parse; } + Command::Client(args) => { + let resp = handle_client_command(args, &mut client_meta).serialize(); + if writer.write_all(&resp).await.is_err() { break 'outer; } + continue 'parse; + } + Command::Config(args) => { + let resp = handle_config_command(args, server_facts(), &store).serialize(); + if writer.write_all(&resp).await.is_err() { break 'outer; } + continue 'parse; + } + Command::CommandQuery(args) => { + let resp = handle_command_query(args, protover).serialize(); + if writer.write_all(&resp).await.is_err() { break 'outer; } + continue 'parse; + } Command::ReplicaOfNoOne => { state.promote_to_primary(); if writer.write_all(b"+OK\r\n").await.is_err() { break 'outer; } @@ -3924,6 +5107,62 @@ async fn handle_tcp( // ── WebSocket handler ───────────────────────────────────────────────────────── +/// Complete the WebSocket handshake, enforcing the origin allowlist and a +/// deadline. Returns `None` when the connection was refused, failed, or stalled +/// — in every case the caller simply drops the socket and its permit. +/// +/// Split out from `handle_ws` so both the origin decision and the timeout are +/// reachable from a test without standing up a listener. +/// +/// `result_large_err`: the error type is tungstenite's `ErrorResponse`, which is +/// an `http::Response` — its size is the handshake callback's signature, not +/// ours, and boxing it would not satisfy the trait. +#[allow(clippy::result_large_err)] +async fn ws_handshake( + socket: S, + allowed_origins: Option<&[String]>, + timeout: Duration, + conn_id: u64, +) -> Option> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let check_origin = |req: &HandshakeRequest, + resp: HandshakeResponse| + -> Result { + let origin = req + .headers() + .get("origin") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + if origin_allowed(allowed_origins, origin.as_deref()) { + return Ok(resp); + } + warn!( + "WS conn {}: refused Origin {:?} — not in RECACHED_ALLOWED_ORIGINS", + conn_id, origin + ); + let mut err = ErrorResponse::new(Some( + "Origin not allowed. Add it to RECACHED_ALLOWED_ORIGINS to permit this page." + .to_string(), + )); + *err.status_mut() = StatusCode::FORBIDDEN; + Err(err) + }; + + match tokio::time::timeout(timeout, accept_hdr_async(socket, check_origin)).await { + Ok(Ok(ws)) => Some(ws), + Ok(Err(e)) => { + warn!("WS handshake failed on conn {}: {}", conn_id, e); + None + } + Err(_) => { + debug!("WS handshake on conn {} timed out", conn_id); + None + } + } +} + #[allow(clippy::too_many_arguments)] async fn handle_ws( socket: S, @@ -3935,16 +5174,28 @@ async fn handle_ws( watch_registry: WatchRegistry, state: Arc, sync_secret: Arc>, + allowed_origins: Arc>>, + peer: String, ) where S: AsyncRead + AsyncWrite + Unpin + Send, { - let _guard = ConnectionGuard::ws(); - let ws_stream = match accept_async(socket).await { - Ok(ws) => ws, - Err(e) => { - warn!("WS handshake failed on conn {}: {}", conn_id, e); - return; - } + let mut client_meta = ClientMeta::new( + conn_id, + peer, + format!("127.0.0.1:{}", server_facts().ws_port), + ); + // The WebSocket transport speaks RESP3 from the first frame. + client_meta.resp = 3; + let _guard = ConnectionGuard::new("ws", client_meta.clone()); + let Some(ws_stream) = ws_handshake( + socket, + allowed_origins.as_deref(), + handshake_timeout(), + conn_id, + ) + .await + else { + return; }; let (mut ws_sender, mut ws_receiver) = ws_stream.split(); @@ -4054,6 +5305,11 @@ async fn handle_ws( continue; } + if matches!(cmd, Command::Quit) { + ws_send!(b"+OK\r\n"); + break 'outer; + } + if !is_authenticated { let resp = Value::Error("NOAUTH Authentication required.".to_string()).serialize(); ws_send!(&resp); @@ -4428,6 +5684,20 @@ async fn handle_ws( ws_send!(&Value::BulkString(Some(body.into_bytes())).serialize()); continue 'outer; } + Command::Client(args) => { + ws_send!(&handle_client_command(args, &mut client_meta).serialize()); + continue 'outer; + } + Command::Config(args) => { + ws_send!(&handle_config_command(args, server_facts(), &store).serialize()); + continue 'outer; + } + Command::CommandQuery(args) => { + // The WebSocket transport is RESP3-only, + // so the catalog always replies as a map. + ws_send!(&handle_command_query(args, 3).serialize()); + continue 'outer; + } Command::ReplicaOfNoOne => { state.promote_to_primary(); ws_send!(b"+OK\r\n"); @@ -4562,7 +5832,7 @@ async fn handle_ws( #[cfg(test)] mod tests { use super::*; - use core_engine::cmd::{SetOptions, ZAddOptions}; + use core_engine::cmd::{ScanArgs, SetOptions, ZAddOptions}; use core_engine::resp::Value; use core_engine::store::KeyValueStore; use std::sync::atomic::{AtomicBool, AtomicI64}; @@ -4639,7 +5909,11 @@ mod tests { Arc::clone(&state2), ); tokio::spawn(async move { - handle_tcp(socket, s, t, p, ps, wr, st).await; + let peer = socket + .peer_addr() + .map(|a| a.to_string()) + .unwrap_or_default(); + handle_tcp(socket, s, t, p, ps, wr, st, peer).await; drop(permit); }); } @@ -4701,6 +5975,12 @@ mod tests { } } + /// True once the peer has closed its half of the connection. + async fn read_raw_eof(&mut self) -> bool { + let mut buf = [0u8; 64]; + matches!(self.stream.read(&mut buf).await, Ok(0)) + } + async fn read_until_closed(&mut self) { let mut buf = [0u8; 64]; while self.stream.read(&mut buf).await.unwrap_or(0) > 0 {} @@ -5031,7 +6311,173 @@ mod tests { } #[tokio::test] - async fn integration_hash_commands() { + async fn integration_bounded_reads_over_resp() { + // The pair of primitives a client needs to inspect a large key without + // pulling it whole: a byte window into a string, and a cursor over a + // collection. Exercised over the wire because that is where the reply + // shape — bulk cursor, nested array — has to be right. + let srv = spawn_server().await; + let mut c = RespClient::connect(srv.tcp_addr).await; + + c.cmd(&["SET", "s", "This is a string"]).await; + assert_eq!(c.cmd(&["GETRANGE", "s", "0", "3"]).await, bulk("This")); + assert_eq!(c.cmd(&["GETRANGE", "s", "-6", "-1"]).await, bulk("string")); + assert_eq!(c.cmd(&["GETRANGE", "ghost", "0", "-1"]).await, bulk("")); + + c.cmd(&["HSET", "h", "a", "1", "b", "2", "c", "3"]).await; + assert_eq!( + c.cmd(&["HSCAN", "h", "0", "COUNT", "2"]).await, + Value::Array(Some(vec![ + bulk("2"), + Value::Array(Some(vec![bulk("a"), bulk("1"), bulk("b"), bulk("2")])), + ])) + ); + assert_eq!( + c.cmd(&["HSCAN", "h", "2"]).await, + Value::Array(Some(vec![ + bulk("0"), + Value::Array(Some(vec![bulk("c"), bulk("3")])), + ])) + ); + assert_eq!( + c.cmd(&["HSCAN", "h", "0", "NOVALUES"]).await, + Value::Array(Some(vec![ + bulk("0"), + Value::Array(Some(vec![bulk("a"), bulk("b"), bulk("c")])), + ])) + ); + + c.cmd(&["SADD", "st", "x", "y"]).await; + assert_eq!( + c.cmd(&["SSCAN", "st", "0", "MATCH", "x*"]).await, + Value::Array(Some(vec![bulk("0"), Value::Array(Some(vec![bulk("x")])),])) + ); + + c.cmd(&["ZADD", "z", "1.5", "amy"]).await; + assert_eq!( + c.cmd(&["ZSCAN", "z", "0"]).await, + Value::Array(Some(vec![ + bulk("0"), + Value::Array(Some(vec![bulk("amy"), bulk("1.5")])), + ])) + ); + } + + #[tokio::test] + async fn integration_bounded_reads_are_allowed_on_a_replica() { + // Read-only by construction: a replica must serve them, and the + // is_write_command allowlist is what decides that. + let srv = spawn_server_cfg(None, None, true).await; + let mut c = RespClient::connect(srv.tcp_addr).await; + + assert_eq!(c.cmd(&["GETRANGE", "s", "0", "-1"]).await, bulk("")); + assert_eq!( + c.cmd(&["HSCAN", "h", "0"]).await, + Value::Array(Some(vec![bulk("0"), Value::Array(Some(vec![]))])) + ); + } + + #[tokio::test] + async fn integration_handshake_commands_over_resp() { + // Every current client library opens with HELLO + CLIENT SETINFO and + // closes with QUIT. This is that sequence, on the wire. + let srv = spawn_server().await; + let mut c = RespClient::connect(srv.tcp_addr).await; + + assert_eq!( + c.cmd(&["CLIENT", "SETINFO", "LIB-NAME", "node-redis"]) + .await, + ok() + ); + assert_eq!( + c.cmd(&["CLIENT", "SETINFO", "LIB-VER", "6.2.0"]).await, + ok() + ); + assert_eq!(c.cmd(&["CLIENT", "SETNAME", "tap"]).await, ok()); + assert_eq!(c.cmd(&["CLIENT", "GETNAME"]).await, bulk("tap")); + + let Value::Integer(id) = c.cmd(&["CLIENT", "ID"]).await else { + panic!("CLIENT ID must be an integer") + }; + assert!(id > 0); + + let Value::BulkString(Some(info)) = c.cmd(&["CLIENT", "INFO"]).await else { + panic!("CLIENT INFO must be a bulk string") + }; + let info = String::from_utf8(info).unwrap(); + assert!(info.contains(&format!("id={id}")), "{info}"); + assert!(info.contains("lib-name=node-redis"), "{info}"); + assert!(info.contains("name=tap"), "{info}"); + assert!(info.contains("addr=127.0.0.1:"), "{info}"); + + // This connection must appear in the list it asks for. + let Value::BulkString(Some(list)) = c.cmd(&["CLIENT", "LIST"]).await else { + panic!("CLIENT LIST must be a bulk string") + }; + let list = String::from_utf8(list).unwrap(); + assert!( + list.lines().any(|l| l.contains(&format!("id={id}"))), + "{list}" + ); + + assert_eq!( + c.cmd(&["CONFIG", "GET", "maxmemory-policy"]).await, + Value::Array(Some(vec![bulk("maxmemory-policy"), bulk("noeviction")])) + ); + + let Value::Integer(n) = c.cmd(&["COMMAND", "COUNT"]).await else { + panic!("COMMAND COUNT must be an integer") + }; + assert!( + n > 100, + "the catalog should cover the whole command set, got {n}" + ); + } + + #[tokio::test] + async fn integration_quit_replies_then_closes() { + let srv = spawn_server().await; + let mut c = RespClient::connect(srv.tcp_addr).await; + assert_eq!(c.cmd(&["PING"]).await, Value::SimpleString("PONG".into())); + // +OK first, then the close — a client that reads its reply before + // dropping the socket must not see a connection error instead. + assert_eq!(c.cmd(&["QUIT"]).await, ok()); + assert!( + c.read_raw_eof().await, + "the server must close the connection after QUIT" + ); + } + + #[tokio::test] + async fn integration_quit_works_before_authentication() { + // Redis flags QUIT no_auth. A client that cannot authenticate still + // gets a clean close instead of leaving a socket parked on the server. + let srv = spawn_server_cfg(Some("hunter2"), None, false).await; + let mut c = RespClient::connect(srv.tcp_addr).await; + assert!(matches!(c.cmd(&["PING"]).await, Value::Error(e) if e.contains("NOAUTH"))); + assert_eq!(c.cmd(&["QUIT"]).await, ok()); + } + + #[tokio::test] + async fn integration_client_list_sees_other_connections() { + let srv = spawn_server().await; + let mut a = RespClient::connect(srv.tcp_addr).await; + let mut b = RespClient::connect(srv.tcp_addr).await; + b.cmd(&["CLIENT", "SETNAME", "second"]).await; + + let Value::BulkString(Some(list)) = a.cmd(&["CLIENT", "LIST"]).await else { + panic!("expected a bulk string") + }; + let list = String::from_utf8(list).unwrap(); + assert!( + list.lines().any(|l| l.contains("name=second")), + "a connection must see its peers, got:\n{list}" + ); + assert!(list.lines().count() >= 2, "{list}"); + } + + #[tokio::test] + async fn integration_hash_commands() { let srv = spawn_server().await; let mut c = RespClient::connect(srv.tcp_addr).await; @@ -5602,6 +7048,8 @@ mod tests { r, None, DEFAULT_REPL_CHANNEL_CAPACITY, + IpAddr::from([127, 0, 0, 1]), + ReplAuthThrottle::new(), )); } }); @@ -5657,7 +7105,11 @@ mod tests { Arc::clone(&state2), ); tokio::spawn(async move { - handle_tcp(socket, s, t, p, ps, wrr, st).await; + let peer = socket + .peer_addr() + .map(|a| a.to_string()) + .unwrap_or_default(); + handle_tcp(socket, s, t, p, ps, wrr, st, peer).await; drop(permit); }); } @@ -5689,7 +7141,7 @@ mod tests { let repl_addr = format!("127.0.0.1:{repl_port}"); let rtx = broadcast::channel::(16).0; tokio::spawn(async move { - run_repl_client(repl_addr, rs, rst, None, None, rtx).await; + run_repl_client(repl_addr, rs, rst, None, None, rtx, None).await; }); // Give replica time to connect and receive initial snapshot @@ -5800,8 +7252,17 @@ mod tests { ); tokio::spawn(async move { if let Ok((socket, _)) = listener.accept().await { - let _ = - handle_replica(socket, s, sc, r, None, DEFAULT_REPL_CHANNEL_CAPACITY).await; + let _ = handle_replica( + socket, + s, + sc, + r, + None, + DEFAULT_REPL_CHANNEL_CAPACITY, + IpAddr::from([127, 0, 0, 1]), + ReplAuthThrottle::new(), + ) + .await; } }); } @@ -5932,7 +7393,11 @@ mod tests { Arc::clone(&state2), ); tokio::spawn(async move { - handle_tcp(socket, s, t, p, ps, wr, st).await; + let peer = socket + .peer_addr() + .map(|a| a.to_string()) + .unwrap_or_default(); + handle_tcp(socket, s, t, p, ps, wr, st, peer).await; drop(permit); }); } @@ -6035,7 +7500,7 @@ mod tests { drop(listener); let rtx = broadcast::channel::(16).0; tokio::spawn(async move { - run_repl_client(dead_addr, rs, rst, None, Some(1), rtx).await; + run_repl_client(dead_addr, rs, rst, None, Some(1), rtx, None).await; }); // Wait for 2 backoff cycles (initial fail + 2s sleep + retry fail → promote) @@ -6057,6 +7522,87 @@ mod tests { /// Like `spawn_ws_server`, with an optional sync-scope secret (strict mode). async fn spawn_ws_server_cfg(sync_secret: Option) -> TestServer { + spawn_ws_server_full(sync_secret, None).await + } + + /// Like `spawn_ws_server`, with an origin allowlist in force. + async fn spawn_ws_server_origins(origins: Vec) -> TestServer { + spawn_ws_server_full(None, Some(origins)).await + } + + /// Open a WebSocket to `addr`, optionally sending an `Origin` header, and + /// report whether the handshake completed. + async fn ws_connect_with_origin( + addr: std::net::SocketAddr, + origin: Option<&str>, + ) -> Result<(), String> { + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + let mut req = format!("ws://{addr}").into_client_request().unwrap(); + if let Some(o) = origin { + req.headers_mut().insert("origin", o.parse().unwrap()); + } + tokio_tungstenite::connect_async(req) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) + } + + #[tokio::test] + async fn ws_refuses_a_cross_origin_handshake() { + // Browsers apply neither CORS nor a preflight to WebSockets, so before + // this check any page a user visited could open a socket to port 6380 + // and read or write the whole keyspace with that user's network + // position. On ws://localhost:6380 that is every site in every tab. + let srv = spawn_ws_server_origins(vec!["https://app.example.com".to_string()]).await; + + let err = ws_connect_with_origin(srv.tcp_addr, Some("https://evil.example")) + .await + .expect_err("a foreign Origin must not complete the handshake"); + assert!( + err.contains("403") || err.to_lowercase().contains("forbidden"), + "expected a 403, got {err}" + ); + } + + #[tokio::test] + async fn ws_admits_an_allowlisted_origin_and_serves_commands() { + // The rejection is worthless if it also breaks the deployed app, so + // assert the permitted path all the way through to a working command. + let srv = spawn_ws_server_origins(vec!["https://app.example.com".to_string()]).await; + ws_connect_with_origin(srv.tcp_addr, Some("https://app.example.com")) + .await + .expect("an allowlisted Origin must connect"); + + let mut c = WsClient::connect(srv.tcp_addr).await; + assert_eq!(c.cmd(&["SET", "k", "v"]).await, ok()); + assert_eq!(c.cmd(&["GET", "k"]).await, bulk("v")); + } + + #[tokio::test] + async fn ws_admits_a_client_that_sends_no_origin() { + // Native clients omit the header, and an attacker with a raw socket can + // forge it — refusing here would break real clients while stopping + // nobody. `WsClient::connect` is exactly such a client. + let srv = spawn_ws_server_origins(vec!["https://app.example.com".to_string()]).await; + ws_connect_with_origin(srv.tcp_addr, None) + .await + .expect("a client with no Origin must connect"); + } + + #[tokio::test] + async fn ws_without_an_allowlist_accepts_any_origin() { + // Unset means allow, matching RECACHED_PASSWORD. The startup warning is + // what keeps this from being a silent default. + let srv = spawn_ws_server().await; + ws_connect_with_origin(srv.tcp_addr, Some("https://anything.example")) + .await + .expect("no allowlist means no origin restriction"); + } + + async fn spawn_ws_server_full( + sync_secret: Option, + allowed_origins: Option>, + ) -> TestServer { let store = Arc::new(KeyValueStore::new()); let (tx, _rx) = broadcast::channel::(256); let pubsub: SharedPubSub = Arc::new(tokio::sync::Mutex::new(PubSubHub::new())); @@ -6080,23 +7626,29 @@ mod tests { let store2 = Arc::clone(&store); let state2 = Arc::clone(&state); let secret = Arc::new(sync_secret); + let origins = Arc::new(allowed_origins); let task = tokio::spawn(async move { loop { let Ok((socket, _)) = listener.accept().await else { return; }; - let (s, t, ps, wr, st, ss) = ( + let (s, t, ps, wr, st, ss, ao) = ( Arc::clone(&store2), tx.clone(), Arc::clone(&pubsub), Arc::clone(&watch_registry), Arc::clone(&state2), Arc::clone(&secret), + Arc::clone(&origins), ); let id = next_conn_id(); + let peer = socket + .peer_addr() + .map(|a| a.to_string()) + .unwrap_or_default(); tokio::spawn(async move { - handle_ws(socket, s, t, Arc::new(None), id, ps, wr, st, ss).await; + handle_ws(socket, s, t, Arc::new(None), id, ps, wr, st, ss, ao, peer).await; }); } }); @@ -6639,6 +8191,7 @@ mod tests { Expect::Keys(&["k"]), ), (Command::Strlen("k".into()), Expect::Keys(&["k"])), + (Command::GetRange("k".into(), 0, -1), Expect::Keys(&["k"])), ( Command::GetSet("k".into(), "v".into()), Expect::Keys(&["k"]), @@ -6711,6 +8264,10 @@ mod tests { Command::HMGet("k".into(), vec!["f".into()]), Expect::Keys(&["k"]), ), + ( + Command::HScan("k".into(), ScanArgs::default()), + Expect::Keys(&["k"]), + ), ( Command::LPush("k".into(), vec!["v".into()]), Expect::Keys(&["k"]), @@ -6776,6 +8333,10 @@ mod tests { ), (Command::SPop("k".into(), None), Expect::Keys(&["k"])), (Command::SRandMember("k".into(), None), Expect::Keys(&["k"])), + ( + Command::SScan("k".into(), ScanArgs::default()), + Expect::Keys(&["k"]), + ), ( Command::SMove("k".into(), "d".into(), "m".into()), Expect::Keys(&["k", "d"]), @@ -6826,6 +8387,10 @@ mod tests { Command::ZCount("k".into(), "0".into(), "1".into()), Expect::Keys(&["k"]), ), + ( + Command::ZScan("k".into(), ScanArgs::default()), + Expect::Keys(&["k"]), + ), ( Command::JSet("k".into(), "$".into(), "1".into()), Expect::Keys(&["k"]), @@ -6854,6 +8419,13 @@ mod tests { (Command::BgSave, Expect::Admin), (Command::LastSave, Expect::Admin), (Command::ReplicaOfNoOne, Expect::Admin), + (Command::Quit, Expect::KeyLess), + (Command::Client(vec!["ID".into()]), Expect::KeyLess), + ( + Command::Config(vec!["GET".into(), "*".into()]), + Expect::Admin, + ), + (Command::CommandQuery(vec![]), Expect::KeyLess), (Command::Unknown("X".into()), Expect::KeyLess), ] } @@ -6925,6 +8497,249 @@ mod tests { ); } + #[test] + fn every_command_is_in_the_catalog() { + // The other half of the loop closed in `catalog_names_are_real_commands`: + // every `Command` variant the parser can produce must have a catalog + // row, or `COMMAND DOCS` silently under-reports what the server can do + // and `COMMAND COUNT` lies about how much. + for (cmd, _) in all_commands() { + let name = command_name(&cmd); + if name == "unknown" { + continue; // Not a command — the reply for anything unrecognised. + } + assert!( + catalog::lookup(name).is_some(), + "{name} is a real command with no catalog entry" + ); + } + } + + #[test] + fn command_count_matches_the_catalog() { + let Value::Integer(n) = handle_command_query(&["COUNT".to_string()], 2) else { + panic!("COMMAND COUNT must reply an integer") + }; + assert_eq!(n as usize, catalog::CATALOG.len()); + } + + #[test] + fn command_info_reports_ten_fields_per_entry() { + // Redis 7 returns ten elements. A client that indexes past the sixth + // must find an empty list, not a short array. + let reply = handle_command_query(&["INFO".into(), "get".into()], 2); + let Value::Array(Some(entries)) = reply else { + panic!("expected an array") + }; + let Value::Array(Some(fields)) = &entries[0] else { + panic!("expected an entry array") + }; + assert_eq!(fields.len(), 10); + assert_eq!(fields[0], Value::BulkString(Some(b"get".to_vec()))); + assert_eq!(fields[1], Value::Integer(2)); + assert_eq!(fields[3], Value::Integer(1), "GET's key is at position 1"); + } + + #[test] + fn command_info_nils_unknown_names_in_place() { + let reply = handle_command_query(&["INFO".into(), "get".into(), "nosuchthing".into()], 2); + let Value::Array(Some(entries)) = reply else { + panic!("expected an array") + }; + assert_eq!(entries.len(), 2, "the reply stays aligned with the request"); + assert_eq!(entries[1], Value::Array(None)); + } + + #[test] + fn command_docs_shape_follows_the_protocol() { + // RESP3 gets a map; RESP2 gets the same pairs flattened. + let resp3 = handle_command_query(&["DOCS".into(), "getrange".into()], 3); + assert!(matches!(resp3, Value::Map(_)), "{resp3:?}"); + let resp2 = handle_command_query(&["DOCS".into(), "getrange".into()], 2); + let Value::Array(Some(flat)) = resp2 else { + panic!("RESP2 must flatten the map") + }; + assert_eq!(flat.len(), 2, "one command, one entry"); + assert_eq!(flat[0], Value::BulkString(Some(b"getrange".to_vec()))); + } + + #[test] + fn command_docs_omits_unknown_names() { + let Value::Array(Some(flat)) = + handle_command_query(&["DOCS".into(), "nosuchthing".into()], 2) + else { + panic!("expected an array") + }; + assert!(flat.is_empty(), "an unknown name has no entry to key"); + } + + #[test] + fn command_rejects_unknown_subcommands() { + let reply = handle_command_query(&["GETKEYS".into(), "get".into(), "k".into()], 2); + assert!( + matches!(&reply, Value::Error(e) if e.contains("Unknown subcommand")), + "{reply:?}" + ); + } + + #[test] + fn client_setinfo_is_recorded_and_visible() { + let mut meta = ClientMeta::new(42, "127.0.0.1:1".into(), "127.0.0.1:6379".into()); + assert_eq!( + handle_client_command( + &["SETINFO".into(), "LIB-NAME".into(), "node-redis".into()], + &mut meta + ), + Value::SimpleString("OK".into()) + ); + assert_eq!( + handle_client_command( + &["SETINFO".into(), "LIB-VER".into(), "6.2.0".into()], + &mut meta + ), + Value::SimpleString("OK".into()) + ); + let Value::BulkString(Some(line)) = handle_client_command(&["INFO".into()], &mut meta) + else { + panic!("CLIENT INFO must reply a bulk string") + }; + let line = String::from_utf8(line).unwrap(); + assert!(line.contains("lib-name=node-redis"), "{line}"); + assert!(line.contains("lib-ver=6.2.0"), "{line}"); + assert!(line.contains("id=42"), "{line}"); + } + + #[test] + fn client_setinfo_rejects_unknown_attributes() { + let mut meta = ClientMeta::new(1, String::new(), String::new()); + let reply = handle_client_command( + &["SETINFO".into(), "LIB-COLOUR".into(), "blue".into()], + &mut meta, + ); + assert!( + matches!(&reply, Value::Error(e) if e.contains("Unrecognized")), + "{reply:?}" + ); + } + + #[test] + fn client_setname_round_trips_and_rejects_spaces() { + let mut meta = ClientMeta::new(1, String::new(), String::new()); + assert_eq!( + handle_client_command(&["GETNAME".into()], &mut meta), + Value::BulkString(None), + "an unnamed connection reports nil, not an empty string" + ); + handle_client_command(&["SETNAME".into(), "worker-3".into()], &mut meta); + assert_eq!( + handle_client_command(&["GETNAME".into()], &mut meta), + Value::BulkString(Some(b"worker-3".to_vec())) + ); + // A space would break the key=value line CLIENT LIST emits. + let reply = handle_client_command(&["SETNAME".into(), "two words".into()], &mut meta); + assert!(matches!(reply, Value::Error(_)), "{reply:?}"); + } + + #[test] + fn client_declines_what_it_cannot_do() { + // KILL must not answer +OK: a caller would believe a connection had + // been closed when it is still open. + let mut meta = ClientMeta::new(1, String::new(), String::new()); + for args in [ + vec!["KILL".to_string(), "id".into(), "3".into()], + vec!["NO-EVICT".to_string(), "on".into()], + vec!["UNPAUSE".to_string()], + ] { + let reply = handle_client_command(&args, &mut meta); + assert!( + matches!(&reply, Value::Error(e) if e.contains("Unknown subcommand")), + "{args:?} -> {reply:?}" + ); + } + } + + #[test] + fn config_get_reports_values_in_force() { + let store = KeyValueStore::new(); + let facts = test_facts(); + let Value::Array(Some(flat)) = + handle_config_command(&["GET".into(), "maxmemory-policy".into()], &facts, &store) + else { + panic!("CONFIG GET must reply an array") + }; + assert_eq!(flat.len(), 2); + assert_eq!( + flat[0], + Value::BulkString(Some(b"maxmemory-policy".to_vec())) + ); + assert_eq!( + flat[1], + Value::BulkString(Some( + eviction_policy_name(store.eviction_policy()) + .as_bytes() + .to_vec() + )), + "the reported policy must be the one actually in force" + ); + } + + #[test] + fn config_get_matches_globs_and_multiple_names() { + let store = KeyValueStore::new(); + let facts = test_facts(); + let Value::Array(Some(flat)) = + handle_config_command(&["GET".into(), "maxmemory*".into()], &facts, &store) + else { + panic!("expected an array") + }; + // maxmemory and maxmemory-policy both match; the reply is flat pairs. + assert_eq!(flat.len(), 4, "{flat:?}"); + + let Value::Array(Some(none)) = + handle_config_command(&["GET".into(), "nosuchparam".into()], &facts, &store) + else { + panic!("expected an array") + }; + assert!( + none.is_empty(), + "an unmatched name yields no pair, not an error" + ); + } + + #[test] + fn config_get_masks_the_password() { + let store = KeyValueStore::new(); + let mut facts = test_facts(); + facts.auth_enabled = true; + let Value::Array(Some(flat)) = + handle_config_command(&["GET".into(), "requirepass".into()], &facts, &store) + else { + panic!("expected an array") + }; + assert_eq!( + flat[1], + Value::BulkString(Some(b"*".to_vec())), + "the password itself must never leave the process" + ); + } + + #[test] + fn config_set_refuses_rather_than_pretending() { + let store = KeyValueStore::new(); + let facts = test_facts(); + let reply = handle_config_command( + &["SET".into(), "maxmemory".into(), "100mb".into()], + &facts, + &store, + ); + // Nothing in the running server can change, so +OK would be a lie the + // operator only discovers when the limit fails to apply. + assert!( + matches!(&reply, Value::Error(e) if e.contains("configured at startup")), + "{reply:?}" + ); + } + #[test] fn every_command_has_a_metrics_label() { for (cmd, _) in all_commands() { @@ -8026,6 +9841,88 @@ mod tls_loading_tests { path } + // ── replication TLS (2.2) ─────────────────────────────────────────────── + + #[test] + fn the_verified_servername_defaults_to_the_primarys_host() { + // What an operator means by "connect to this primary" is the host they + // named, so that is what the certificate is checked against. + assert_eq!( + repl_tls_servername("primary.internal:6381", None), + "primary.internal" + ); + assert_eq!(repl_tls_servername("10.0.1.5:6381", None), "10.0.1.5"); + assert_eq!( + repl_tls_servername("primary.internal", None), + "primary.internal" + ); + } + + #[test] + fn an_ipv6_primary_address_is_not_split_inside_the_address() { + // Splitting on the last colon would cut inside an IPv6 literal and + // produce a servername that could never validate. + assert_eq!(repl_tls_servername("[::1]:6381", None), "::1"); + assert_eq!(repl_tls_servername("[fd00::5]:6381", None), "fd00::5"); + } + + #[test] + fn the_servername_override_wins_and_ignores_blanks() { + // The common deployment points RECACHED_REPLICAOF at an IP while the + // certificate names a host, which cannot validate without an IP SAN. + assert_eq!( + repl_tls_servername("10.0.1.5:6381", Some("primary.internal".into())), + "primary.internal" + ); + assert_eq!( + repl_tls_servername("10.0.1.5:6381", Some(" primary.internal ".into())), + "primary.internal" + ); + // Empty or whitespace is "unset", not "verify against nothing". + assert_eq!( + repl_tls_servername("10.0.1.5:6381", Some(String::new())), + "10.0.1.5" + ); + assert_eq!( + repl_tls_servername("10.0.1.5:6381", Some(" ".into())), + "10.0.1.5" + ); + } + + #[test] + fn a_missing_or_unusable_replication_ca_is_a_startup_error() { + // Trusting nothing would fail every connection at runtime rather than at + // startup, which is much harder to diagnose from a replica's logs. + // `TlsConnector` is not Debug, so unwrap the error side by hand. + let missing = load_repl_tls_connector("/nonexistent/recached-ca.pem"); + let Err(err) = missing else { + panic!("a missing CA file must be refused"); + }; + assert!(err.contains("RECACHED_REPL_TLS_CA"), "{err}"); + + let junk = write("repl_ca_junk.pem", "not a certificate\n"); + let unusable = load_repl_tls_connector(junk.to_str().unwrap()); + let _ = std::fs::remove_file(&junk); + let Err(err) = unusable else { + panic!("a file with no certificates must be refused"); + }; + assert!(err.contains("RECACHED_REPL_TLS_CA"), "{err}"); + } + + #[test] + fn a_self_signed_certificate_works_as_the_replication_trust_anchor() { + // Pinning the primary's own certificate is the documented path, and the + // reason the trust anchor is an explicit file rather than the system root + // store: replication is a private link between two hosts one operator + // runs, so trusting every public CA to vouch for it would be backwards. + let ca = write("repl_ca_ok.pem", TEST_CERT); + assert!( + load_repl_tls_connector(ca.to_str().unwrap()).is_ok(), + "a valid self-signed PEM must build a connector" + ); + let _ = std::fs::remove_file(&ca); + } + #[test] fn a_pem_certificate_and_key_load() { // PEM parsing moved from the deprecated rustls-pemfile to @@ -8153,3 +10050,607 @@ mod limit_config_tests { assert_eq!(max_qsub_initial_keys(), 10_000); } } + +// ───────────────────────────────────────────────────────────────────────────── +// Hardening: the network-exposure fixes and their decision functions. +// +// Every check here guards a boundary that was open in 0.2.4 or earlier. The +// pure-function shape is deliberate: the replication gate and the origin +// allowlist are decisions, and a decision can be asserted without standing up a +// listener or mutating process-global environment state. +// ───────────────────────────────────────────────────────────────────────────── +#[cfg(test)] +mod hardening_tests { + use super::*; + + // ── replication listener gate ───────────────────────────────────────────── + + #[test] + fn the_replication_port_stays_closed_unless_asked_for() { + // The default. Before this gate existed the listener bound + // 0.0.0.0:6381 on every node and, with no password, served the entire + // keyspace to anyone who connected — so an operator who set + // RECACHED_PASSWORD was protecting nothing. + assert_eq!(resolve_repl_listen(None, "0.0.0.0", None), Ok(false)); + assert_eq!(resolve_repl_listen(None, "0.0.0.0", Some("pw")), Ok(false)); + // An empty value is "unset", not "true". + assert_eq!( + resolve_repl_listen(Some(String::new()), "0.0.0.0", None), + Ok(false) + ); + assert_eq!( + resolve_repl_listen(Some(" ".to_string()), "0.0.0.0", None), + Ok(false) + ); + } + + #[test] + fn enabling_it_on_a_public_interface_without_a_password_refuses_to_start() { + let err = resolve_repl_listen(Some("1".into()), "0.0.0.0", None) + .expect_err("public + no password must not be allowed"); + // The message has to name both variables — an operator reading a log + // line needs to know what to set, not merely that something is wrong. + assert!(err.contains("RECACHED_REPL_PASSWORD"), "{err}"); + assert!(err.contains("RECACHED_REPL_ENABLE"), "{err}"); + assert!(err.contains("0.0.0.0"), "{err}"); + + // A specific LAN address is just as reachable as 0.0.0.0. + assert!(resolve_repl_listen(Some("1".into()), "10.0.1.5", None).is_err()); + // So is a hostname we cannot resolve to a loopback address: the + // conservative reading is the one that demands a password. + assert!(resolve_repl_listen(Some("1".into()), "cache.internal", None).is_err()); + // An empty password is not a password. + assert!(resolve_repl_listen(Some("1".into()), "0.0.0.0", Some("")).is_err()); + } + + #[test] + fn enabling_it_is_allowed_on_loopback_or_with_a_password() { + // Loopback without a password is a development setup, not an exposure. + assert_eq!( + resolve_repl_listen(Some("1".into()), "127.0.0.1", None), + Ok(true) + ); + assert_eq!( + resolve_repl_listen(Some("yes".into()), "::1", None), + Ok(true) + ); + assert_eq!( + resolve_repl_listen(Some("on".into()), "localhost", None), + Ok(true) + ); + // Public is fine once authenticated — this is the multi-tier + // replication path, which must keep working. + assert_eq!( + resolve_repl_listen(Some("true".into()), "0.0.0.0", Some("pw")), + Ok(true) + ); + assert_eq!( + resolve_repl_listen(Some("1".into()), "10.0.1.5", Some("pw")), + Ok(true) + ); + } + + #[test] + fn an_ambiguous_enable_value_refuses_to_start() { + // Treating `please` as false would leave an operator believing + // replication was on; treating it as true would open a port nobody + // asked for. Neither is acceptable for a variable gating a boundary. + let err = resolve_repl_listen(Some("please".into()), "127.0.0.1", None).unwrap_err(); + assert!(err.contains("RECACHED_REPL_ENABLE"), "{err}"); + assert!(err.contains("not a boolean"), "{err}"); + } + + #[test] + fn boolean_env_values_cover_the_conventional_spellings() { + for yes in ["1", "true", "TRUE", "yes", "On", " on "] { + assert_eq!(parse_env_bool("V", yes), Ok(true), "{yes:?}"); + } + for no in ["0", "false", "FALSE", "no", "Off", " off "] { + assert_eq!(parse_env_bool("V", no), Ok(false), "{no:?}"); + } + assert!(parse_env_bool("V", "maybe").is_err()); + } + + #[test] + fn loopback_detection_treats_unparseable_hosts_as_public() { + assert!(bind_is_loopback("127.0.0.1")); + assert!(bind_is_loopback("127.0.0.53")); + assert!(bind_is_loopback("::1")); + assert!(bind_is_loopback("localhost")); + assert!(bind_is_loopback("LOCALHOST")); + assert!(!bind_is_loopback("0.0.0.0")); + assert!(!bind_is_loopback("10.0.1.5")); + assert!(!bind_is_loopback("::")); + assert!(!bind_is_loopback("cache.internal")); + assert!(!bind_is_loopback("")); + // An IPv6 bind address must be written bracketed for the listeners to + // format it correctly, so the brackets have to be tolerated here too — + // otherwise `[::1]` is misread as public and demands a password. + assert!(bind_is_loopback("[::1]")); + assert!(!bind_is_loopback("[::]")); + assert!(!bind_is_loopback("[fd00::5]")); + } + + // ── replication auth: throttle and handshake ───────────────────────────── + + #[test] + fn repeated_bad_replication_passwords_block_the_peer() { + // The RESP port drops a connection after five guesses, but the + // replication handshake is one-shot: reconnecting used to reset the + // count, so the port offered unlimited guesses at a secret that yields + // the whole keyspace. The throttle is keyed by address for that reason. + let throttle = ReplAuthThrottle::new(); + let ip = IpAddr::from([203, 0, 113, 7]); + assert!(!throttle.is_blocked(ip)); + for _ in 0..MAX_AUTH_FAILURES { + assert!(!throttle.is_blocked(ip), "must not block before the cap"); + throttle.record_failure(ip); + } + assert!(throttle.is_blocked(ip), "cap reached, peer must be refused"); + + // Other peers are unaffected — one attacker must not lock out a fleet. + assert!(!throttle.is_blocked(IpAddr::from([203, 0, 113, 8]))); + + // A successful handshake clears the record. + throttle.record_success(ip); + assert!(!throttle.is_blocked(ip)); + } + + #[test] + fn the_throttle_does_not_grow_without_bound() { + // A spray from many source addresses must not be a memory-growth + // vector; the map sweeps once it crosses the threshold. + let throttle = ReplAuthThrottle::new(); + for i in 0..(REPL_AUTH_SWEEP_THRESHOLD + 64) { + let ip = IpAddr::from([ + 10, + ((i >> 16) & 0xff) as u8, + ((i >> 8) & 0xff) as u8, + (i & 0xff) as u8, + ]); + throttle.record_failure(ip); + } + let len = throttle.failures.lock().unwrap().len(); + // Entries are all fresh so none are swept, but the sweep must have run + // without panicking and the map must stay proportional to the input + // rather than duplicating it. + assert!(len <= REPL_AUTH_SWEEP_THRESHOLD + 64, "{len}"); + } + + #[tokio::test] + async fn the_auth_line_is_read_to_its_terminator_not_to_the_password_length() { + // Reading exactly `password.len() + 1` bytes made the number of bytes + // the server waited for *be* the password length, recoverable by + // drip-feeding one byte at a time. + let (mut client, mut server) = tokio::io::duplex(256); + client.write_all(b"hunter2\n").await.unwrap(); + let line = read_repl_auth_line(&mut server).await.unwrap(); + assert_eq!(line, b"hunter2"); + } + + #[tokio::test] + async fn an_auth_line_without_a_terminator_is_refused_at_the_cap() { + let (mut client, mut server) = tokio::io::duplex(4096); + let flood = vec![b'x'; MAX_REPL_AUTH_LINE + 16]; + // Write concurrently: the reader gives up mid-stream, so the writer + // must not block on a full pipe. + tokio::spawn(async move { + let _ = client.write_all(&flood).await; + }); + let err = read_repl_auth_line(&mut server) + .await + .expect_err("an unterminated line must not be read forever"); + assert_eq!(err.kind(), ErrorKind::InvalidData); + } + + #[tokio::test] + async fn a_short_auth_line_still_compares_unequal() { + // The comparison is constant-time and length-checked, so a truncated + // guess fails rather than matching a prefix. + let (mut client, mut server) = tokio::io::duplex(256); + client.write_all(b"hunt\n").await.unwrap(); + let line = read_repl_auth_line(&mut server).await.unwrap(); + assert!(!ct_eq_bytes(&line, b"hunter2")); + } + + // ── WebSocket origin allowlist ────────────────────────────────────────── + + #[test] + fn an_unset_origin_allowlist_permits_everything() { + // Matches how an unset RECACHED_PASSWORD behaves. The project ships + // insecure-by-default deliberately and says so; what it must not do is + // ship a *silent* default, hence the startup warning. + assert!(origin_allowed(None, Some("https://evil.example"))); + assert!(origin_allowed(None, None)); + } + + #[test] + fn a_foreign_origin_is_refused_when_the_allowlist_is_set() { + // The finding this closes: browsers apply neither CORS nor a preflight + // to WebSockets, so without this check any page a user visits could + // open a socket to ws://localhost:6380 and read or write every key. + let allow = vec!["https://app.example.com".to_string()]; + assert!(origin_allowed( + Some(&allow), + Some("https://app.example.com") + )); + assert!(!origin_allowed(Some(&allow), Some("https://evil.example"))); + // A different scheme or port is a different origin. + assert!(!origin_allowed( + Some(&allow), + Some("http://app.example.com") + )); + assert!(!origin_allowed( + Some(&allow), + Some("https://app.example.com:8443") + )); + // Substring matching would be a hole: `app.example.com.evil.test` + // contains an allowlisted origin as a prefix. + assert!(!origin_allowed( + Some(&allow), + Some("https://app.example.com.evil.test") + )); + } + + #[test] + fn an_absent_origin_is_permitted_because_only_browsers_send_one() { + // A native client omits the header and an attacker with a socket can + // forge it, so refusing here would break legitimate clients while + // stopping nobody. The control exists to separate "the app I deployed" + // from "another page in the same browser". + let allow = vec!["https://app.example.com".to_string()]; + assert!(origin_allowed(Some(&allow), None)); + } + + #[test] + fn origin_comparison_ignores_case_and_a_trailing_slash() { + let allow = parse_allowed_origins("https://App.Example.com/").unwrap(); + assert!(origin_allowed( + Some(&allow), + Some("https://app.example.com") + )); + assert!(origin_allowed( + Some(&allow), + Some("HTTPS://APP.EXAMPLE.COM/") + )); + } + + #[test] + fn the_origin_allowlist_parses_a_list_and_admits_null() { + let list = parse_allowed_origins( + "https://app.example.com, http://localhost:3000 ,https://admin.example.com:8443", + ) + .unwrap(); + assert_eq!( + list, + vec![ + "https://app.example.com", + "http://localhost:3000", + "https://admin.example.com:8443", + ] + ); + // Sandboxed iframes and file:// documents send the literal `null`. + assert_eq!(parse_allowed_origins("null").unwrap(), vec!["null"]); + assert!(origin_allowed( + Some(&parse_allowed_origins("null").unwrap()), + Some("null") + )); + } + + #[test] + fn the_origin_allowlist_rejects_entries_that_could_never_match() { + // Each of these would parse into something a browser never sends, so + // the allowlist would silently reject every connection. Failing at + // startup is the only way an operator finds out. + for bad in [ + "app.example.com", + "https://app.example.com/dashboard", + "://nohost", + "https://", + ] { + assert!( + parse_allowed_origins(bad).is_err(), + "{bad:?} should be rejected" + ); + } + // Set-but-empty would reject every browser; unset is how you allow all. + let err = parse_allowed_origins(" , ").unwrap_err(); + assert!(err.contains("Unset it"), "{err}"); + } + + // ── handshake deadline ────────────────────────────────────────────────── + + #[tokio::test] + async fn a_stalled_websocket_handshake_gives_up_and_releases_the_socket() { + // The connection permit is acquired *before* the handshake runs, so + // without this deadline `RECACHED_MAX_CONNECTIONS` sockets that connect + // and then say nothing — costing an attacker nothing — hold every slot + // indefinitely and the server stops accepting real clients. + let (_client, server) = tokio::io::duplex(1024); + let start = std::time::Instant::now(); + let out = ws_handshake(server, None, Duration::from_millis(150), 1).await; + assert!(out.is_none(), "a silent peer must not produce a stream"); + assert!( + start.elapsed() < Duration::from_secs(2), + "gave up after {:?} — the deadline did not apply", + start.elapsed() + ); + } + + #[test] + fn the_handshake_deadline_has_a_documented_default() { + assert_eq!(DEFAULT_HANDSHAKE_TIMEOUT_SECS, 10); + } + + // ── persistence file permissions ──────────────────────────────────────── + + #[tokio::test] + #[cfg(unix)] + async fn snapshot_and_sidecar_files_are_not_world_readable() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("recached_perm_{}", std::process::id())); + tokio::fs::create_dir_all(&dir).await.unwrap(); + let path = dir.join("perm-test.rdb"); + + write_private(&path, b"payload").await.unwrap(); + let mode = tokio::fs::metadata(&path) + .await + .unwrap() + .permissions() + .mode(); + assert_eq!( + mode & 0o777, + 0o600, + "snapshots are plaintext dumps of the keyspace; 0644 lets any local user read the cache" + ); + + // A file left behind 0644 by an earlier version must be tightened on + // the next write, not keep its old mode forever. + tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) + .await + .unwrap(); + write_private(&path, b"payload2").await.unwrap(); + let mode = tokio::fs::metadata(&path) + .await + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "an existing loose mode must be fixed"); + assert_eq!(tokio::fs::read(&path).await.unwrap(), b"payload2"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + #[cfg(unix)] + async fn the_aof_is_not_world_readable() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("recached_aofperm_{}", std::process::id())); + tokio::fs::create_dir_all(&dir).await.unwrap(); + let path = dir.join("perm-test.aof"); + + // Pre-create it loose, as an upgrade from an earlier version would. + tokio::fs::write(&path, b"").await.unwrap(); + tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) + .await + .unwrap(); + + let writer = AofWriter::open(path.clone(), AofSync::No).await.unwrap(); + writer.append(b"*1\r\n$4\r\nPING\r\n").await; + let mode = tokio::fs::metadata(&path) + .await + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[test] + fn temp_files_do_not_collide_between_processes() { + // A fixed `.tmp` name meant two servers sharing a data directory would + // clobber each other's half-written snapshot. + let a = temp_sibling(std::path::Path::new("/data/recached.rdb"), "snap"); + assert!( + a.to_string_lossy() + .contains(&std::process::id().to_string()), + "{a:?}" + ); + assert!(a.to_string_lossy().ends_with(".tmp"), "{a:?}"); + assert_ne!( + a, + temp_sibling(std::path::Path::new("/data/recached.rdb"), "dedup") + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Durability: the guarantees `RECACHED_AOF_SYNC` and snapshot saving advertise. +// +// These assert reachability and effect rather than device-level durability — a +// unit test cannot pull the power. What they pin is that the fsync path is +// actually taken, that it does not corrupt or lose data, and that the pieces a +// crash-consistency argument depends on (temp file synced before rename, parent +// directory synced after) are wired up. +// ───────────────────────────────────────────────────────────────────────────── +#[cfg(test)] +mod durability_tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "recached_dur_{}_{}_{}", + name, + std::process::id(), + next_conn_id() + )); + std::fs::create_dir_all(&dir).expect("create scratch dir"); + dir + } + + #[tokio::test] + async fn aof_always_survives_a_reopen_with_every_byte_intact() { + // `always` previously called flush(), which reaches the page cache and + // not the device. The observable part of the fix is that the fsync path + // runs and is still byte-exact. + let dir = scratch("aof_always"); + let path = dir.join("a.aof"); + let w = AofWriter::open(path.clone(), AofSync::Always) + .await + .unwrap(); + for i in 0..64 { + w.append(format!("*1\r\n${}\r\n{}\r\n", i.to_string().len(), i).as_bytes()) + .await; + } + drop(w); + + let on_disk = tokio::fs::read(&path).await.unwrap(); + let expected: Vec = (0..64) + .flat_map(|i| format!("*1\r\n${}\r\n{}\r\n", i.to_string().len(), i).into_bytes()) + .collect(); + assert_eq!(on_disk, expected, "fsync must not disturb the byte stream"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn aof_everysec_flush_is_idempotent_and_lossless() { + // The everysec ticker calls flush() on a cadence, including when nothing + // has been appended since the last tick. + let dir = scratch("aof_everysec"); + let path = dir.join("b.aof"); + let w = AofWriter::open(path.clone(), AofSync::EverySec) + .await + .unwrap(); + w.append(b"*1\r\n$4\r\nPING\r\n").await; + w.flush().await; + w.flush().await; // nothing new to sync + assert_eq!( + tokio::fs::read(&path).await.unwrap(), + b"*1\r\n$4\r\nPING\r\n" + ); + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn truncating_the_aof_leaves_it_empty_and_reusable() { + // Truncation follows a snapshot, and is now fsynced so a crash cannot + // resurrect a log the snapshot already subsumed. It must also leave the + // handle writable — the server keeps appending to it afterwards. + let dir = scratch("aof_trunc"); + let path = dir.join("c.aof"); + let w = AofWriter::open(path.clone(), AofSync::No).await.unwrap(); + w.append(b"*1\r\n$4\r\nPING\r\n").await; + w.truncate().await; + assert_eq!(tokio::fs::metadata(&path).await.unwrap().len(), 0); + + w.append(b"*1\r\n$4\r\nECHO\r\n").await; + w.flush().await; + assert_eq!( + tokio::fs::read(&path).await.unwrap(), + b"*1\r\n$4\r\nECHO\r\n", + "the writer must still be usable after truncation" + ); + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn a_snapshot_lands_atomically_and_leaves_no_temp_file() { + // The temp file is fsynced, renamed over the target, and then the + // directory is fsynced. A leftover temp file would mean the rename never + // happened, which is the failure this sequence exists to prevent. + let dir = scratch("snap"); + let path = dir.join("dump.rdb"); + let store = KeyValueStore::new(); + for i in 0..32 { + store.execute(Command::Set( + format!("k{i}"), + format!("v{i}").into_bytes(), + Default::default(), + )); + } + let cfg = SnapshotConfig { + path: path.clone(), + last_save: AtomicI64::new(0), + }; + save_snapshot(&store, &cfg).await; + + assert!(path.exists(), "snapshot must exist after save"); + assert!( + cfg.last_save.load(Ordering::Relaxed) > 0, + "a completed save must advance LASTSAVE" + ); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".tmp")) + .collect(); + assert!( + leftovers.is_empty(), + "temp files left behind: {leftovers:?}" + ); + + // And the bytes are a snapshot we can actually read back. + let restored = KeyValueStore::new(); + assert!(load_snapshot(&restored, &path).await); + assert_eq!( + restored.execute(Command::Get("k7".into())), + Value::BulkString(Some(b"v7".to_vec())) + ); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn syncing_a_parent_directory_tolerates_odd_paths() { + // Best-effort by contract: a path with no parent, or one that does not + // exist, must warn rather than panic or hang. The snapshot path is + // frequently relative ("recached.rdb"), which has an empty parent. + sync_parent_dir(std::path::Path::new("recached.rdb")).await; + sync_parent_dir(std::path::Path::new("/")).await; + sync_parent_dir(std::path::Path::new("/nonexistent-recached-dir/x.rdb")).await; + } + + #[test] + fn a_sync_token_cannot_grant_an_over_long_pattern() { + // Token patterns reach glob_match without passing through the command + // parser, so the cap has to be repeated there. These are matched once + // per key per write — the most expensive place a pattern can sit. + use base64::Engine as _; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + let secret = "s3cret"; + let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD; + let mint = |payload: &str| { + let p = engine.encode(payload); + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(p.as_bytes()); + format!("{}.{}", p, engine.encode(mac.finalize().into_bytes())) + }; + + let long = "a".repeat(core_engine::store::MAX_PATTERN_BYTES + 1); + let err = verify_sync_token(secret, &mint(&long)) + .expect_err("an over-long granted pattern must be refused"); + assert_eq!(err, "token grants an over-long pattern"); + + // One over-long pattern in an otherwise fine list is still refused. + assert!(verify_sync_token(secret, &mint(&format!("cart:*,{long}"))).is_err()); + + // A pattern exactly at the cap is still honoured. + let at_cap = "a".repeat(core_engine::store::MAX_PATTERN_BYTES); + assert_eq!( + verify_sync_token(secret, &mint(&at_cap)), + Ok(vec![at_cap.clone()]) + ); + assert_eq!( + verify_sync_token(secret, &mint("cart:42:*,user:1:*")), + Ok(vec!["cart:42:*".to_string(), "user:1:*".to_string()]) + ); + } +} diff --git a/wasm-edge/package.json b/wasm-edge/package.json index d0a49c4..93474cd 100644 --- a/wasm-edge/package.json +++ b/wasm-edge/package.json @@ -1,7 +1,7 @@ { "name": "recached-edge", "description": "Browser and edge WebAssembly client for Recached \u2014 zero-latency local cache with automatic server sync", - "version": "0.2.3", + "version": "0.2.4", "type": "module", "main": "sdk.js", "module": "sdk.js",