netget/server/memcached/protocol.rs
1//! Memcached **text** protocol parsing and reply framing.
2//!
3//! Pure functions over bytes: no sockets, no state, and — importantly — **no cache**.
4//! There is no map, no table, no store anywhere in this protocol. The model answers every
5//! `get`. That is the project rule (protocols must not implement storage) and it is also the
6//! entire point of running Memcached under NetGet: the interesting behaviour is a model
7//! deciding what a key holds, not a hash map doing what hash maps do.
8//!
9//! Scope is the text protocol, as advised: the binary protocol was deprecated in memcached
10//! 1.6 (2020) and is no longer documented in upstream `protocol.txt`.
11
12/// Longest command line accepted before the connection is failed.
13///
14/// Real memcached caps the key at 250 bytes and the whole command line well below this; a
15/// line this long is a client bug or an attack, not a request.
16pub const MAX_COMMAND_LINE: usize = 8 * 1024;
17
18/// Upstream memcached's key limit (`KEY_MAX_LENGTH`).
19pub const MAX_KEY_LEN: usize = 250;
20
21/// Upstream memcached's default item size limit (1 MiB), and this server's declared
22/// `max_inbound_bytes`.
23///
24/// It is the only length in the text protocol a peer chooses: every other unit is one
25/// CRLF-terminated line under [`MAX_COMMAND_LINE`]. It is checked against the `<bytes>` the
26/// storage command **declares**, before a single octet of the data block is buffered, so a
27/// `set k 0 0 4000000000` costs its header line and nothing more. Over it, the peer is told
28/// `SERVER_ERROR object too large for cache` and the connection closes (see `Parsed::Fatal`).
29pub const MAX_VALUE_LEN: usize = 1024 * 1024;
30
31/// A parsed client command.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Command {
34 /// `get <key>*` / `gets <key>*`
35 Retrieval {
36 /// `"get"` or `"gets"`; `gets` additionally returns the CAS unique
37 command: &'static str,
38 keys: Vec<String>,
39 },
40 /// `set|add|replace|append|prepend <key> <flags> <exptime> <bytes> [noreply]`
41 /// followed by exactly `bytes` octets and CRLF.
42 Storage {
43 command: &'static str,
44 key: String,
45 flags: u32,
46 exptime: i64,
47 bytes: usize,
48 /// Present only for `cas`
49 cas_unique: Option<u64>,
50 noreply: bool,
51 data: Vec<u8>,
52 },
53 /// `delete <key> [noreply]`
54 Delete { key: String, noreply: bool },
55 /// `incr|decr <key> <value> [noreply]`
56 Arithmetic {
57 command: &'static str,
58 key: String,
59 delta: u64,
60 noreply: bool,
61 },
62 /// `touch <key> <exptime> [noreply]`
63 Touch {
64 key: String,
65 exptime: i64,
66 noreply: bool,
67 },
68 /// `stats [<argument>]`
69 Stats { argument: Option<String> },
70 /// `version`
71 Version,
72 /// `flush_all [<delay>] [noreply]`
73 FlushAll { delay: i64, noreply: bool },
74 /// `quit` — close without replying
75 Quit,
76 /// A line this server does not recognise. Reported to the model rather than answered
77 /// with a canned `ERROR`, so it can decide what an unknown verb should do.
78 Unknown { line: String },
79}
80
81/// Why a line could not be turned into a `Command`.
82///
83/// These map onto memcached's own `ERROR` / `CLIENT_ERROR` replies, which are part of the
84/// protocol rather than transport failures.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum ParseError {
87 /// Structurally wrong: `CLIENT_ERROR <message>`
88 ClientError(String),
89 /// The storage command's data block has not fully arrived yet. Not an error — the caller
90 /// must read more bytes and retry. Carries the total frame length it is waiting for.
91 Incomplete,
92}
93
94/// Result of trying to take one command off the front of a buffer.
95#[derive(Debug)]
96pub enum Parsed {
97 /// A complete command, and how many bytes of the buffer it consumed.
98 Complete { command: Command, consumed: usize },
99 /// Not enough bytes yet; leave the buffer alone and read more.
100 Incomplete,
101 /// The command line was well-framed but malformed. `consumed` bytes should still be
102 /// dropped so the connection can continue.
103 ///
104 /// For a storage command this **includes its data block**: rejecting the header while
105 /// leaving `<bytes>` octets of attacker-chosen payload in the stream means those octets
106 /// are then parsed as commands. See `parse_storage`.
107 Invalid { message: String, consumed: usize },
108 /// The command line was malformed in a way that makes resynchronising impossible: the
109 /// caller must reply and then **close the connection**.
110 ///
111 /// This is only reachable from a storage command whose `<bytes>` count is missing,
112 /// unparseable, or larger than `MAX_VALUE_LEN`. Every other malformed command is
113 /// self-delimiting (one CRLF-terminated line), so the parser can skip it and carry on;
114 /// a storage command whose length is unknown or unbufferable has no such boundary, and
115 /// guessing one would put the peer's payload back on the command path.
116 ///
117 /// `too_large` separates the one fatal case that is a *size* refusal — a declared
118 /// `<bytes>` over [`MAX_VALUE_LEN`] — from a length that is missing or not a number. The
119 /// two get different replies: upstream memcached answers an oversized item with
120 /// `SERVER_ERROR object too large for cache`, and a client keys its retry logic on that
121 /// exact text.
122 Fatal { message: String, too_large: bool },
123}
124
125/// Try to take one command from the front of `buffer`.
126///
127/// Framing rules that matter, and that are easy to get wrong:
128///
129/// - Every command line ends with `\r\n`. A bare `\n` is **not** accepted; upstream is
130/// lenient about this in places but a strict server keeps clients honest and keeps byte
131/// counting unambiguous.
132/// - A storage command is followed by *exactly* `<bytes>` octets and then `\r\n`. The count
133/// is authoritative: the data block may itself contain `\r\n`, so scanning for a delimiter
134/// instead of counting is the classic memcached implementation bug.
135pub fn parse_command(buffer: &[u8]) -> Parsed {
136 let Some(line_end) = find_crlf(buffer) else {
137 if buffer.len() > MAX_COMMAND_LINE {
138 return Parsed::Invalid {
139 message: format!("command line exceeds {} bytes", MAX_COMMAND_LINE),
140 consumed: buffer.len(),
141 };
142 }
143 return Parsed::Incomplete;
144 };
145
146 let line_bytes = &buffer[..line_end];
147 let header_len = line_end + 2;
148
149 let line = match std::str::from_utf8(line_bytes) {
150 Ok(s) => s,
151 Err(_) => {
152 return Parsed::Invalid {
153 message: "command line is not valid UTF-8".to_string(),
154 consumed: header_len,
155 }
156 }
157 };
158
159 let parts: Vec<&str> = line.split_ascii_whitespace().collect();
160 if parts.is_empty() {
161 return Parsed::Invalid {
162 message: "empty command".to_string(),
163 consumed: header_len,
164 };
165 }
166
167 match parts[0] {
168 "get" | "gets" => {
169 let command = if parts[0] == "get" { "get" } else { "gets" };
170 if parts.len() < 2 {
171 return Parsed::Invalid {
172 message: format!("{} requires at least one key", command),
173 consumed: header_len,
174 };
175 }
176 let keys: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
177 if let Some(bad) = keys.iter().find(|k| k.len() > MAX_KEY_LEN) {
178 return Parsed::Invalid {
179 message: format!("key is {} bytes, maximum is {}", bad.len(), MAX_KEY_LEN),
180 consumed: header_len,
181 };
182 }
183 Parsed::Complete {
184 command: Command::Retrieval { command, keys },
185 consumed: header_len,
186 }
187 }
188
189 cmd @ ("set" | "add" | "replace" | "append" | "prepend" | "cas") => {
190 parse_storage(cmd, &parts, buffer, header_len)
191 }
192
193 "delete" => {
194 if parts.len() < 2 {
195 return Parsed::Invalid {
196 message: "delete requires a key".to_string(),
197 consumed: header_len,
198 };
199 }
200 Parsed::Complete {
201 command: Command::Delete {
202 key: parts[1].to_string(),
203 noreply: has_noreply(&parts),
204 },
205 consumed: header_len,
206 }
207 }
208
209 cmd @ ("incr" | "decr") => {
210 let command = if cmd == "incr" { "incr" } else { "decr" };
211 if parts.len() < 3 {
212 return Parsed::Invalid {
213 message: format!("{} requires a key and a value", command),
214 consumed: header_len,
215 };
216 }
217 let delta = match parts[2].parse::<u64>() {
218 Ok(d) => d,
219 Err(_) => {
220 return Parsed::Invalid {
221 message: "invalid numeric delta argument".to_string(),
222 consumed: header_len,
223 }
224 }
225 };
226 Parsed::Complete {
227 command: Command::Arithmetic {
228 command,
229 key: parts[1].to_string(),
230 delta,
231 noreply: has_noreply(&parts),
232 },
233 consumed: header_len,
234 }
235 }
236
237 "touch" => {
238 if parts.len() < 3 {
239 return Parsed::Invalid {
240 message: "touch requires a key and an expiry".to_string(),
241 consumed: header_len,
242 };
243 }
244 let exptime = match parts[2].parse::<i64>() {
245 Ok(v) => v,
246 Err(_) => {
247 return Parsed::Invalid {
248 message: "invalid exptime argument".to_string(),
249 consumed: header_len,
250 }
251 }
252 };
253 Parsed::Complete {
254 command: Command::Touch {
255 key: parts[1].to_string(),
256 exptime,
257 noreply: has_noreply(&parts),
258 },
259 consumed: header_len,
260 }
261 }
262
263 "stats" => Parsed::Complete {
264 command: Command::Stats {
265 argument: parts.get(1).map(|s| s.to_string()),
266 },
267 consumed: header_len,
268 },
269
270 "version" => Parsed::Complete {
271 command: Command::Version,
272 consumed: header_len,
273 },
274
275 "flush_all" => {
276 let delay = parts
277 .get(1)
278 .filter(|s| **s != "noreply")
279 .and_then(|s| s.parse::<i64>().ok())
280 .unwrap_or(0);
281 Parsed::Complete {
282 command: Command::FlushAll {
283 delay,
284 noreply: has_noreply(&parts),
285 },
286 consumed: header_len,
287 }
288 }
289
290 "quit" => Parsed::Complete {
291 command: Command::Quit,
292 consumed: header_len,
293 },
294
295 _ => Parsed::Complete {
296 command: Command::Unknown {
297 line: line.to_string(),
298 },
299 consumed: header_len,
300 },
301 }
302}
303
304fn has_noreply(parts: &[&str]) -> bool {
305 parts.last().map(|s| *s == "noreply").unwrap_or(false)
306}
307
308/// `<command> <key> <flags> <exptime> <bytes> [<cas unique>] [noreply]\r\n<data>\r\n`
309///
310/// **`<bytes>` is parsed before anything else, and every rejection below it consumes the whole
311/// frame.** A storage command is not self-delimiting: its data block is `<bytes>` octets that
312/// may contain anything, CRLF included. Rejecting the header and consuming only the header
313/// leaves that payload at the front of the buffer, where the next `parse_command` reads it as
314/// commands — so
315///
316/// ```text
317/// set k bad 0 7\r\nversion\r\n
318/// ```
319///
320/// answered `CLIENT_ERROR` for the header and then *executed* `version`. Anything the peer can
321/// put in a value it could put on the command path. Upstream memcached avoids this by entering
322/// `conn_swallow` for exactly `<bytes> + 2` octets, which is what consuming `frame_len` does
323/// here.
324///
325/// Where `<bytes>` itself is unusable there is no boundary to skip to, and the only safe exit
326/// is `Fatal` — reply and close. Guessing a boundary is the bug above.
327fn parse_storage(cmd: &str, parts: &[&str], buffer: &[u8], header_len: usize) -> Parsed {
328 let is_cas = cmd == "cas";
329 let min_parts = if is_cas { 6 } else { 5 };
330
331 let Some(bytes_field) = parts.get(4) else {
332 return Parsed::Fatal {
333 message: format!("bad data chunk: {} needs {} arguments", cmd, min_parts - 1),
334 too_large: false,
335 };
336 };
337 let bytes = match bytes_field.parse::<usize>() {
338 Ok(v) => v,
339 Err(_) => {
340 return Parsed::Fatal {
341 message: "bad command line format: bytes must be a non-negative integer"
342 .to_string(),
343 too_large: false,
344 }
345 }
346 };
347 if bytes > MAX_VALUE_LEN {
348 // Skipping this would mean buffering an arbitrary number of octets purely to throw
349 // them away, which is the allocation the cap exists to prevent.
350 return Parsed::Fatal {
351 message: format!("object too large for cache ({} bytes)", bytes),
352 too_large: true,
353 };
354 }
355
356 // From here the frame's extent is known. Wait for all of it first, so that any rejection
357 // below drops the data block along with the header rather than leaving it to be parsed.
358 // The count is authoritative — the payload may itself contain CRLF.
359 let frame_len = header_len + bytes + 2;
360 if buffer.len() < frame_len {
361 return Parsed::Incomplete;
362 }
363 let reject = |message: String| Parsed::Invalid {
364 message,
365 consumed: frame_len,
366 };
367
368 // Only reachable for `cas` with five parts: fewer than five was caught above, because
369 // `parts.get(4)` is where `<bytes>` lives.
370 if parts.len() < min_parts {
371 return reject(format!(
372 "bad data chunk: {} needs {} arguments",
373 cmd,
374 min_parts - 1
375 ));
376 }
377
378 let key = parts[1].to_string();
379 if key.len() > MAX_KEY_LEN {
380 return reject(format!(
381 "key is {} bytes, maximum is {}",
382 key.len(),
383 MAX_KEY_LEN
384 ));
385 }
386
387 let flags = match parts[2].parse::<u32>() {
388 Ok(v) => v,
389 Err(_) => {
390 return reject(
391 "bad command line format: flags must be a 32-bit unsigned integer".to_string(),
392 )
393 }
394 };
395 let exptime = match parts[3].parse::<i64>() {
396 Ok(v) => v,
397 Err(_) => return reject("bad command line format: exptime must be an integer".to_string()),
398 };
399 let cas_unique = if is_cas {
400 match parts[5].parse::<u64>() {
401 Ok(v) => Some(v),
402 Err(_) => {
403 return reject(
404 "bad command line format: cas unique must be a 64-bit integer".to_string(),
405 )
406 }
407 }
408 } else {
409 None
410 };
411
412 let data = buffer[header_len..header_len + bytes].to_vec();
413 if &buffer[header_len + bytes..frame_len] != b"\r\n" {
414 return reject("bad data chunk".to_string());
415 }
416
417 let command = match cmd {
418 "set" => "set",
419 "add" => "add",
420 "replace" => "replace",
421 "append" => "append",
422 "prepend" => "prepend",
423 _ => "cas",
424 };
425
426 Parsed::Complete {
427 command: Command::Storage {
428 command,
429 key,
430 flags,
431 exptime,
432 bytes,
433 cas_unique,
434 noreply: has_noreply(parts),
435 data,
436 },
437 consumed: frame_len,
438 }
439}
440
441fn find_crlf(buffer: &[u8]) -> Option<usize> {
442 buffer.windows(2).position(|w| w == b"\r\n")
443}
444
445/// Does this command suppress its reply when the client asked for `noreply`?
446pub fn is_noreply(command: &Command) -> bool {
447 match command {
448 Command::Storage { noreply, .. }
449 | Command::Delete { noreply, .. }
450 | Command::Arithmetic { noreply, .. }
451 | Command::Touch { noreply, .. }
452 | Command::FlushAll { noreply, .. } => *noreply,
453 _ => false,
454 }
455}
456
457/// One value in a `get`/`gets` reply.
458#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct ValueItem {
460 pub key: String,
461 pub flags: u32,
462 pub data: Vec<u8>,
463 /// Only emitted for `gets`
464 pub cas_unique: Option<u64>,
465}
466
467/// Frame a retrieval reply.
468///
469/// ```text
470/// VALUE <key> <flags> <bytes> [<cas unique>]\r\n
471/// <data block>\r\n
472/// ...
473/// END\r\n
474/// ```
475///
476/// `<bytes>` is computed here from the actual payload length. It is never taken from the
477/// model: a byte count that disagrees with the payload desynchronises the client's parser
478/// for the rest of the connection, and a model asked to count bytes will eventually
479/// miscount. An empty item list is a cache miss, which is `END\r\n` alone.
480pub fn encode_values(items: &[ValueItem], include_cas: bool) -> Vec<u8> {
481 let mut out = Vec::new();
482 for item in items {
483 let header = match (include_cas, item.cas_unique) {
484 (true, Some(cas)) => format!(
485 "VALUE {} {} {} {}\r\n",
486 item.key,
487 item.flags,
488 item.data.len(),
489 cas
490 ),
491 _ => format!("VALUE {} {} {}\r\n", item.key, item.flags, item.data.len()),
492 };
493 out.extend_from_slice(header.as_bytes());
494 out.extend_from_slice(&item.data);
495 out.extend_from_slice(b"\r\n");
496 }
497 out.extend_from_slice(b"END\r\n");
498 out
499}
500
501/// Keep only the `VALUE` blocks of a retrieval reply whose key the client asked for.
502///
503/// Returns the reply rebuilt from the kept blocks and the keys that were dropped, or `None`
504/// when `reply` is not a `VALUE ... END` block at all (an error line, say), which is left
505/// alone. A memcached server returns a value only for a key in the request (protocol.txt,
506/// "Retrieval command"), and a client relies on it: pymemcache raises `KeyError` on a key it
507/// did not ask for and discards the whole reply. A model told "every other key is missing"
508/// answered `get nothing-here` with the value of the one key it did hold; dropping that block
509/// turns the reply into what the instruction meant - a miss.
510///
511/// Only ever applied to bytes [`encode_values`] produced, so the framing is known exactly.
512pub fn retain_requested_values(reply: &[u8], keys: &[String]) -> Option<(Vec<u8>, Vec<String>)> {
513 let mut kept = Vec::with_capacity(reply.len());
514 let mut dropped = Vec::new();
515 let mut rest = reply;
516 loop {
517 if rest == b"END\r\n" {
518 kept.extend_from_slice(b"END\r\n");
519 return Some((kept, dropped));
520 }
521 let header_end = find_crlf(rest)?;
522 let header = std::str::from_utf8(&rest[..header_end]).ok()?;
523 let mut fields = header.split(' ');
524 if fields.next()? != "VALUE" {
525 return None;
526 }
527 let key = fields.next()?;
528 let _flags = fields.next()?;
529 let len: usize = fields.next()?.parse().ok()?;
530 let block_end = header_end + 2 + len + 2;
531 if rest.len() < block_end || &rest[block_end - 2..block_end] != b"\r\n" {
532 return None;
533 }
534 if keys.iter().any(|k| k == key) {
535 kept.extend_from_slice(&rest[..block_end]);
536 } else {
537 dropped.push(key.to_string());
538 }
539 rest = &rest[block_end..];
540 }
541}
542
543/// Frame a `stats` reply: `STAT <name> <value>\r\n` per entry, then `END\r\n`.
544pub fn encode_stats(entries: &[(String, String)]) -> Vec<u8> {
545 let mut out = Vec::new();
546 for (name, value) in entries {
547 out.extend_from_slice(format!("STAT {} {}\r\n", name, value).as_bytes());
548 }
549 out.extend_from_slice(b"END\r\n");
550 out
551}
552
553/// Fixed status lines the text protocol defines.
554pub fn status_line(status: &str) -> Option<&'static str> {
555 match status {
556 "STORED" => Some("STORED\r\n"),
557 "NOT_STORED" => Some("NOT_STORED\r\n"),
558 "EXISTS" => Some("EXISTS\r\n"),
559 "NOT_FOUND" => Some("NOT_FOUND\r\n"),
560 "DELETED" => Some("DELETED\r\n"),
561 "TOUCHED" => Some("TOUCHED\r\n"),
562 "OK" => Some("OK\r\n"),
563 "ERROR" => Some("ERROR\r\n"),
564 _ => None,
565 }
566}