Skip to main content

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}