Skip to main content

rightkit_daemon/
chunk.rs

1//! Chunked calls: messages larger than one frame (issue #294).
2//!
3//! A frame is capped ([`DEFAULT_FRAME_LIMIT`](crate::DEFAULT_FRAME_LIMIT), 16 MiB) so one peer can
4//! never make the other buffer an unbounded line. That cap stays. A message above it travels as
5//! several frames, and the host assembles it under a second, explicit bound
6//! ([`HostOptions::max_message`](crate::HostOptions::max_message)). Chunking is **off by default**
7//! (`max_message` 0, issue #323): a service opts in with a size, and a host that has not refuses
8//! `rk.upload`.
9//!
10//! **Request** ([`Client::call_large`](crate::Client::call_large)): the params are serialised to
11//! JSON text and cut at character boundaries into pieces of `chunk_bytes` (the host advertises it
12//! in `hello`). Each piece is an ordinary request to the reserved method [`METHOD_UPLOAD`]
13//! (`{id, seq, last, text}`, and `method` on `seq` 0). The host appends the pieces in order (one
14//! upload at a time per connection; a gap, a repeat or a changed id drops it), and on `last` parses
15//! the assembled text and runs the real method through the normal pipeline: allowlist, handler,
16//! events, response. The response to the final piece is the response to the call. A piece is
17//! plain JSON text, so no encoding crate is needed; escaping at most doubles its wire size.
18//!
19//! **Response**: a client that opts in per request (`"chunked_ok": true`, which `call_large`
20//! sets) gets a result that exceeds the frame limit as `{ok: true, result: null, chunked: {id,
21//! bytes, count}}` instead of `response_too_large`; the client then reads the pieces with the
22//! reserved method [`METHOD_FETCH`] (`{id, seq}` answered `{seq, text}`) and parses the join. The
23//! host keeps one such result per connection (a new one replaces it; closing the connection drops
24//! it). A client that does not opt in still gets the typed `response_too_large`.
25//!
26//! Events keep the frame limit: an event larger than it is dropped with
27//! [`ServiceEvent::FrameTooLarge`](crate::ServiceEvent::FrameTooLarge) as before.
28//!
29//! The reserved methods are `rk.upload` and `rk.fetch`; no service method may start with `rk.`.
30//! They need no allowlist entry (any app that passed `hello` may use them), because the call they
31//! carry is authorised on its own method id: when the first piece arrives, before a byte is
32//! buffered (an unauthorised peer is refused there with `not_allowed`), and again when the call is
33//! dispatched. The assembled text is checked for nesting and value count before it is parsed.
34
35use crate::error::{ServiceError, ERROR_BAD_CHUNK, ERROR_REQUEST_TOO_LARGE};
36use serde_json::{json, Value};
37use std::time::{Duration, Instant};
38
39/// Reserved method: one piece of a request too large for a frame.
40pub const METHOD_UPLOAD: &str = "rk.upload";
41/// Reserved method: one piece of a result too large for a frame.
42pub const METHOD_FETCH: &str = "rk.fetch";
43/// Prefix no service method may use.
44pub const RESERVED_PREFIX: &str = "rk.";
45
46/// Largest piece of text in one chunk frame (1 MiB; escaping at most doubles it on the wire).
47pub const CHUNK_TEXT_BYTES: usize = 1024 * 1024;
48/// Default for [`HostOptions::max_message`](crate::HostOptions::max_message): 0, chunking off
49/// (it was 256 MiB earlier in the unpublished 0.2.7; see issue #323). A service that takes large calls opts in.
50pub const DEFAULT_MAX_MESSAGE: usize = 0;
51/// A reasonable opt-in value for a service that does take large calls: 64 MiB.
52pub const SUGGESTED_MAX_MESSAGE: usize = 64 * 1024 * 1024;
53
54/// The chunk size a host with `frame_limit` uses: a quarter of the frame (so a doubled escape
55/// still fits), at most [`CHUNK_TEXT_BYTES`], at least 16 bytes.
56pub(crate) fn chunk_bytes_for(frame_limit: usize) -> usize {
57    (frame_limit / 4).clamp(16, CHUNK_TEXT_BYTES)
58}
59
60/// End offsets of consecutive pieces of `text`, each about `chunk` bytes and cut only at
61/// character boundaries. Never empty: empty text is one empty piece.
62pub(crate) fn boundaries(text: &str, chunk: usize) -> Vec<usize> {
63    let chunk = chunk.max(4);
64    let mut ends = Vec::new();
65    let mut start = 0;
66    while start < text.len() {
67        let mut end = (start + chunk).min(text.len());
68        // `text.len()` is always a boundary, so this ends.
69        while !text.is_char_boundary(end) {
70            end += 1;
71        }
72        ends.push(end);
73        start = end;
74    }
75    if ends.is_empty() {
76        ends.push(0);
77    }
78    ends
79}
80
81fn bad(message: impl Into<String>) -> ServiceError {
82    ServiceError::new(ERROR_BAD_CHUNK, message)
83}
84
85/// Assemblies one connection may hold at once. A new first piece replaces the one in progress, so
86/// the bytes a connection holds are at most one assembly, itself at most `max_message`.
87pub const MAX_ASSEMBLIES_PER_CONNECTION: usize = 1;
88/// An assembly that has not received a piece for this long is dropped (checked on every frame
89/// the connection sends, and the whole state goes with the connection).
90pub const UPLOAD_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
91/// Deepest array/object nesting an assembled request may have (serde_json itself stops at 128).
92pub const MAX_JSON_DEPTH: usize = 64;
93/// Most values (elements, members and containers) an assembled request may contain. A parsed
94/// `Value` costs 16 to 32 times the text of a dense array, so the count, not only the byte length,
95/// bounds the memory a parse can take: 1 Mi values is about 32 to 64 MiB.
96pub const MAX_JSON_NODES: usize = 1 << 20;
97
98/// Refuse text whose nesting or value count is over the bounds, scanning it once without
99/// allocating, so the cost of the real parse is bounded before it starts. Not a validator:
100/// malformed text that passes here is rejected by the parse.
101pub(crate) fn check_shape(text: &str) -> Result<(), ServiceError> {
102    let (mut depth, mut nodes) = (0usize, 1usize);
103    let (mut in_string, mut escaped) = (false, false);
104    for &b in text.as_bytes() {
105        if in_string {
106            if escaped {
107                escaped = false;
108            } else if b == b'\\' {
109                escaped = true;
110            } else if b == b'"' {
111                in_string = false;
112            }
113            continue;
114        }
115        match b {
116            b'"' => in_string = true,
117            b'[' | b'{' => {
118                depth += 1;
119                nodes += 1;
120                if depth > MAX_JSON_DEPTH {
121                    return Err(bad(format!(
122                        "the assembled params nest deeper than {MAX_JSON_DEPTH} levels"
123                    )));
124                }
125            }
126            b']' | b'}' => depth = depth.saturating_sub(1),
127            b',' => nodes += 1,
128            _ => {}
129        }
130        if nodes > MAX_JSON_NODES {
131            return Err(ServiceError::new(
132                ERROR_REQUEST_TOO_LARGE,
133                format!("the assembled params hold more than {MAX_JSON_NODES} values"),
134            ));
135        }
136    }
137    Ok(())
138}
139
140/// An upload in progress on one connection.
141#[derive(Debug)]
142struct Upload {
143    id: String,
144    method: String,
145    next: u64,
146    text: String,
147    touched: Instant,
148}
149
150/// What one upload frame did.
151#[derive(Debug, PartialEq)]
152pub(crate) enum UploadStep {
153    /// Stored; ask for the next piece.
154    More { received: u64 },
155    /// The last piece arrived: run `method` with `params`.
156    Done { method: String, params: Value },
157}
158
159/// Per-connection upload state (at most one upload at a time).
160#[derive(Debug, Default)]
161pub(crate) struct Uploads {
162    current: Option<Upload>,
163}
164
165impl Uploads {
166    /// Bytes currently buffered for the assembly in progress.
167    #[cfg(test)]
168    pub(crate) fn buffered_bytes(&self) -> usize {
169        self.current.as_ref().map_or(0, |u| u.text.len())
170    }
171
172    /// Drop an assembly that has been idle past [`UPLOAD_IDLE_TIMEOUT`].
173    pub(crate) fn expire(&mut self) {
174        if self
175            .current
176            .as_ref()
177            .is_some_and(|u| u.touched.elapsed() > UPLOAD_IDLE_TIMEOUT)
178        {
179            self.current = None;
180        }
181    }
182
183    /// [`accept_with`](Self::accept_with) with every method authorised (unit tests).
184    #[cfg(test)]
185    pub(crate) fn accept(
186        &mut self,
187        params: &Value,
188        max: usize,
189    ) -> Result<UploadStep, ServiceError> {
190        self.accept_with(params, max, &|_| Ok(()))
191    }
192
193    /// Take one `rk.upload` frame's params. On any error the upload in progress is dropped, so a
194    /// failed upload never leaves memory behind. `max` bounds the assembled text. `authorize`
195    /// runs on the first piece, with the method it names, before anything is buffered: a refusal
196    /// is the error returned, and no state is created.
197    pub(crate) fn accept_with(
198        &mut self,
199        params: &Value,
200        max: usize,
201        authorize: &dyn Fn(&str) -> Result<(), ServiceError>,
202    ) -> Result<UploadStep, ServiceError> {
203        self.expire();
204        let result = self.accept_inner(params, max, authorize);
205        if result.is_err() {
206            self.current = None;
207        }
208        result
209    }
210
211    fn accept_inner(
212        &mut self,
213        params: &Value,
214        max: usize,
215        authorize: &dyn Fn(&str) -> Result<(), ServiceError>,
216    ) -> Result<UploadStep, ServiceError> {
217        let id = params["id"]
218            .as_str()
219            .filter(|s| !s.is_empty() && s.len() <= 64)
220            .ok_or_else(|| bad("an upload piece needs an id of 1 to 64 characters"))?;
221        let seq = params["seq"]
222            .as_u64()
223            .ok_or_else(|| bad("an upload piece needs a numeric seq"))?;
224        let last = params["last"].as_bool().unwrap_or(false);
225        let text = params["text"]
226            .as_str()
227            .ok_or_else(|| bad("an upload piece needs a text"))?;
228        if seq == 0 {
229            let method = params["method"]
230                .as_str()
231                .filter(|m| !m.is_empty() && !m.starts_with(RESERVED_PREFIX))
232                .ok_or_else(|| bad("the first piece must name a method outside the rk. prefix"))?;
233            // Authorise on the caller's own terms before a byte is kept; a replaced assembly is
234            // freed either way.
235            self.current = None;
236            authorize(method)?;
237            if text.len() > max {
238                return Err(ServiceError::new(
239                    ERROR_REQUEST_TOO_LARGE,
240                    format!("the message is larger than the {max}-byte message limit"),
241                ));
242            }
243            self.current = Some(Upload {
244                id: id.to_string(),
245                method: method.to_string(),
246                next: 0,
247                text: String::new(),
248                touched: Instant::now(),
249            });
250        }
251        let up = match self.current.as_mut() {
252            Some(up) if up.id == id && up.next == seq => up,
253            _ => {
254                return Err(bad(
255                    "upload piece out of order, repeated, or for another upload",
256                ))
257            }
258        };
259        if up.text.len().saturating_add(text.len()) > max {
260            return Err(ServiceError::new(
261                ERROR_REQUEST_TOO_LARGE,
262                format!("the message is larger than the {max}-byte message limit"),
263            ));
264        }
265        up.text.push_str(text);
266        up.next += 1;
267        up.touched = Instant::now();
268        if !last {
269            return Ok(UploadStep::More { received: seq });
270        }
271        let up = self.current.take().expect("the upload was just extended");
272        // Bound the work before the parse: length is already under `max`; nesting and value
273        // count are checked here, so no `Value` is built for a body that would amplify.
274        check_shape(&up.text)?;
275        let params = serde_json::from_str::<Value>(&up.text)
276            .map_err(|e| bad(format!("the assembled params are not JSON: {e}")))?;
277        Ok(UploadStep::Done {
278            method: up.method,
279            params,
280        })
281    }
282}
283
284/// A result held for the client to fetch in pieces.
285#[derive(Debug)]
286pub(crate) struct Download {
287    id: String,
288    text: String,
289    ends: Vec<usize>,
290}
291
292impl Download {
293    pub(crate) fn new(id: String, text: String, chunk: usize) -> Self {
294        let ends = boundaries(&text, chunk);
295        Self { id, text, ends }
296    }
297
298    /// The `chunked` field of the response that announces this result.
299    pub(crate) fn descriptor(&self) -> Value {
300        json!({"id": self.id, "bytes": self.text.len(), "count": self.ends.len()})
301    }
302
303    /// Answer one `rk.fetch` frame.
304    pub(crate) fn fetch(&self, params: &Value) -> Result<Value, ServiceError> {
305        if params["id"].as_str() != Some(self.id.as_str()) {
306            return Err(bad(
307                "no such result on this connection (it was replaced or released)",
308            ));
309        }
310        let seq = params["seq"]
311            .as_u64()
312            .and_then(|n| usize::try_from(n).ok())
313            .filter(|n| *n < self.ends.len())
314            .ok_or_else(|| bad("fetch seq is out of range"))?;
315        let start = if seq == 0 { 0 } else { self.ends[seq - 1] };
316        Ok(json!({"seq": seq, "text": &self.text[start..self.ends[seq]]}))
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    fn piece(id: &str, seq: u64, last: bool, text: &str, method: Option<&str>) -> Value {
325        let mut p = json!({"id": id, "seq": seq, "last": last, "text": text});
326        if let Some(m) = method {
327            p["method"] = json!(m);
328        }
329        p
330    }
331
332    #[test]
333    fn boundaries_cover_the_text_and_never_split_a_character() {
334        let text = "aé€😀".repeat(50);
335        for chunk in [4, 5, 7, 64, 1000] {
336            let ends = boundaries(&text, chunk);
337            assert_eq!(*ends.last().unwrap(), text.len());
338            let mut start = 0;
339            for end in &ends {
340                assert!(text.is_char_boundary(*end));
341                assert!(*end > start);
342                start = *end;
343            }
344        }
345        assert_eq!(boundaries("", 8), vec![0]);
346    }
347
348    #[test]
349    fn an_upload_assembles_in_order_and_yields_the_params() {
350        let whole = json!({"a": "x".repeat(100), "b": [1, 2, 3]});
351        let text = serde_json::to_string(&whole).unwrap();
352        let ends = boundaries(&text, 30);
353        let mut up = Uploads::default();
354        let mut start = 0;
355        for (i, end) in ends.iter().enumerate() {
356            let last = i + 1 == ends.len();
357            let p = piece(
358                "u1",
359                i as u64,
360                last,
361                &text[start..*end],
362                (i == 0).then_some("m.big"),
363            );
364            start = *end;
365            match up.accept(&p, 1 << 20).unwrap() {
366                UploadStep::More { received } => {
367                    assert!(!last);
368                    assert_eq!(received, i as u64);
369                }
370                UploadStep::Done { method, params } => {
371                    assert!(last);
372                    assert_eq!(method, "m.big");
373                    assert_eq!(params, whole);
374                }
375            }
376        }
377    }
378
379    #[test]
380    fn a_gap_a_repeat_or_a_foreign_id_drops_the_upload() {
381        let mut up = Uploads::default();
382        up.accept(&piece("u1", 0, false, "{", Some("m")), 1000)
383            .unwrap();
384        assert!(up.accept(&piece("u1", 2, false, "x", None), 1000).is_err());
385        // Dropped: even the right next piece now fails.
386        assert!(up.accept(&piece("u1", 1, false, "x", None), 1000).is_err());
387
388        up.accept(&piece("u2", 0, false, "{", Some("m")), 1000)
389            .unwrap();
390        assert!(up.accept(&piece("u2", 0, false, "{", None), 1000).is_err());
391        up.accept(&piece("u3", 0, false, "{", Some("m")), 1000)
392            .unwrap();
393        assert!(up
394            .accept(&piece("other", 1, false, "x", None), 1000)
395            .is_err());
396    }
397
398    #[test]
399    fn the_message_bound_and_the_reserved_prefix_are_enforced() {
400        let mut up = Uploads::default();
401        up.accept(&piece("u", 0, false, "12345", Some("m")), 8)
402            .unwrap();
403        let err = up
404            .accept(&piece("u", 1, false, "6789", None), 8)
405            .unwrap_err();
406        assert_eq!(err.code, ERROR_REQUEST_TOO_LARGE);
407        assert!(up
408            .accept(&piece("v", 0, false, "{", Some("rk.fetch")), 8)
409            .is_err());
410        assert!(up.accept(&piece("v", 0, false, "{", None), 8).is_err());
411    }
412
413    #[test]
414    fn assembled_text_that_is_not_json_is_refused() {
415        let mut up = Uploads::default();
416        let err = up
417            .accept(&piece("u", 0, true, "{not json", Some("m")), 1000)
418            .unwrap_err();
419        assert_eq!(err.code, ERROR_BAD_CHUNK);
420    }
421
422    #[test]
423    fn the_first_piece_is_authorised_before_anything_is_buffered() {
424        let mut up = Uploads::default();
425        let deny = |_: &str| Err(ServiceError::new(crate::error::ERROR_NOT_ALLOWED, "no"));
426        let err = up
427            .accept_with(&piece("u", 0, false, "{\"a\":1", Some("m")), 1000, &deny)
428            .unwrap_err();
429        assert_eq!(err.code, crate::error::ERROR_NOT_ALLOWED);
430        assert_eq!(up.buffered_bytes(), 0);
431        // Nothing was created, so the next piece has nothing to continue.
432        let err = up
433            .accept_with(&piece("u", 1, true, "}", None), 1000, &|_| Ok(()))
434            .unwrap_err();
435        assert_eq!(err.code, ERROR_BAD_CHUNK);
436        // A refused replacement also frees the assembly it replaced.
437        up.accept(&piece("v", 0, false, "{", Some("m")), 1000)
438            .unwrap();
439        assert_eq!(up.buffered_bytes(), 1);
440        assert!(up
441            .accept_with(&piece("w", 0, false, "{", Some("m")), 1000, &deny)
442            .is_err());
443        assert_eq!(up.buffered_bytes(), 0);
444    }
445
446    #[test]
447    fn deep_or_dense_bodies_are_refused_before_the_parse() {
448        let deep = format!(
449            "{}{}",
450            "[".repeat(MAX_JSON_DEPTH + 1),
451            "]".repeat(MAX_JSON_DEPTH + 1)
452        );
453        assert_eq!(check_shape(&deep).unwrap_err().code, ERROR_BAD_CHUNK);
454        let ok = format!(
455            "{}{}",
456            "[".repeat(MAX_JSON_DEPTH),
457            "]".repeat(MAX_JSON_DEPTH)
458        );
459        assert!(check_shape(&ok).is_ok());
460        let dense = format!("[{}0]", "0,".repeat(MAX_JSON_NODES));
461        assert_eq!(
462            check_shape(&dense).unwrap_err().code,
463            ERROR_REQUEST_TOO_LARGE
464        );
465        // Brackets and commas inside strings do not count.
466        let text = serde_json::to_string(&json!({"s": "[[[[,,,,\"]]".repeat(50_000)})).unwrap();
467        assert!(check_shape(&text).is_ok());
468
469        let mut up = Uploads::default();
470        let err = up
471            .accept(&piece("u", 0, true, &deep, Some("m")), 1 << 20)
472            .unwrap_err();
473        assert_eq!(err.code, ERROR_BAD_CHUNK);
474        assert_eq!(up.buffered_bytes(), 0);
475    }
476
477    #[test]
478    fn an_idle_assembly_expires_and_a_failure_frees_it() {
479        let mut up = Uploads::default();
480        up.accept(&piece("u", 0, false, "{", Some("m")), 1000)
481            .unwrap();
482        assert_eq!(up.buffered_bytes(), 1);
483        let Some(stale) = Instant::now().checked_sub(UPLOAD_IDLE_TIMEOUT + Duration::from_secs(1))
484        else {
485            return; // host uptime shorter than the idle timeout: nothing to backdate
486        };
487        up.current.as_mut().unwrap().touched = stale;
488        up.expire();
489        assert_eq!(up.buffered_bytes(), 0);
490        up.accept(&piece("u", 0, false, "{", Some("m")), 1000)
491            .unwrap();
492        assert!(up.accept(&piece("u", 5, false, "x", None), 1000).is_err());
493        assert_eq!(up.buffered_bytes(), 0);
494    }
495
496    #[test]
497    fn chunking_is_off_by_default() {
498        assert_eq!(DEFAULT_MAX_MESSAGE, 0);
499    }
500
501    #[test]
502    fn a_download_serves_every_piece_and_rejects_strangers() {
503        let text = "héllo wörld ".repeat(40);
504        let dl = Download::new("d1".into(), text.clone(), 25);
505        let desc = dl.descriptor();
506        assert_eq!(desc["bytes"], text.len());
507        let count = desc["count"].as_u64().unwrap();
508        let mut joined = String::new();
509        for seq in 0..count {
510            let r = dl.fetch(&json!({"id": "d1", "seq": seq})).unwrap();
511            joined.push_str(r["text"].as_str().unwrap());
512        }
513        assert_eq!(joined, text);
514        assert!(dl.fetch(&json!({"id": "d1", "seq": count})).is_err());
515        assert!(dl.fetch(&json!({"id": "d2", "seq": 0})).is_err());
516    }
517
518    #[test]
519    fn the_chunk_size_scales_with_the_frame_limit() {
520        assert_eq!(chunk_bytes_for(16 * 1024 * 1024), CHUNK_TEXT_BYTES);
521        assert_eq!(chunk_bytes_for(4096), 1024);
522        assert_eq!(chunk_bytes_for(10), 16);
523    }
524}