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 /// Drop an assembly that has been idle past [`UPLOAD_IDLE_TIMEOUT`].
167 pub(crate) fn expire(&mut self) {
168 if self
169 .current
170 .as_ref()
171 .is_some_and(|u| u.touched.elapsed() > UPLOAD_IDLE_TIMEOUT)
172 {
173 self.current = None;
174 }
175 }
176
177 /// Take one `rk.upload` frame's params. On any error the upload in progress is dropped, so a
178 /// failed upload never leaves memory behind. `max` bounds the assembled text. `authorize`
179 /// runs on the first piece, with the method it names, before anything is buffered: a refusal
180 /// is the error returned, and no state is created.
181 pub(crate) fn accept_with(
182 &mut self,
183 params: &Value,
184 max: usize,
185 authorize: &dyn Fn(&str) -> Result<(), ServiceError>,
186 ) -> Result<UploadStep, ServiceError> {
187 self.expire();
188 let result = self.accept_inner(params, max, authorize);
189 if result.is_err() {
190 self.current = None;
191 }
192 result
193 }
194
195 fn accept_inner(
196 &mut self,
197 params: &Value,
198 max: usize,
199 authorize: &dyn Fn(&str) -> Result<(), ServiceError>,
200 ) -> Result<UploadStep, ServiceError> {
201 let id = params["id"]
202 .as_str()
203 .filter(|s| !s.is_empty() && s.len() <= 64)
204 .ok_or_else(|| bad("an upload piece needs an id of 1 to 64 characters"))?;
205 let seq = params["seq"]
206 .as_u64()
207 .ok_or_else(|| bad("an upload piece needs a numeric seq"))?;
208 let last = params["last"].as_bool().unwrap_or(false);
209 let text = params["text"]
210 .as_str()
211 .ok_or_else(|| bad("an upload piece needs a text"))?;
212 if seq == 0 {
213 let method = params["method"]
214 .as_str()
215 .filter(|m| !m.is_empty() && !m.starts_with(RESERVED_PREFIX))
216 .ok_or_else(|| bad("the first piece must name a method outside the rk. prefix"))?;
217 // Authorise on the caller's own terms before a byte is kept; a replaced assembly is
218 // freed either way.
219 self.current = None;
220 authorize(method)?;
221 if text.len() > max {
222 return Err(ServiceError::new(
223 ERROR_REQUEST_TOO_LARGE,
224 format!("the message is larger than the {max}-byte message limit"),
225 ));
226 }
227 self.current = Some(Upload {
228 id: id.to_string(),
229 method: method.to_string(),
230 next: 0,
231 text: String::new(),
232 touched: Instant::now(),
233 });
234 }
235 let up = match self.current.as_mut() {
236 Some(up) if up.id == id && up.next == seq => up,
237 _ => {
238 return Err(bad(
239 "upload piece out of order, repeated, or for another upload",
240 ))
241 }
242 };
243 if up.text.len().saturating_add(text.len()) > max {
244 return Err(ServiceError::new(
245 ERROR_REQUEST_TOO_LARGE,
246 format!("the message is larger than the {max}-byte message limit"),
247 ));
248 }
249 up.text.push_str(text);
250 up.next += 1;
251 up.touched = Instant::now();
252 if !last {
253 return Ok(UploadStep::More { received: seq });
254 }
255 let up = self.current.take().expect("the upload was just extended");
256 // Bound the work before the parse: length is already under `max`; nesting and value
257 // count are checked here, so no `Value` is built for a body that would amplify.
258 check_shape(&up.text)?;
259 let params = serde_json::from_str::<Value>(&up.text)
260 .map_err(|e| bad(format!("the assembled params are not JSON: {e}")))?;
261 Ok(UploadStep::Done {
262 method: up.method,
263 params,
264 })
265 }
266}
267
268/// A result held for the client to fetch in pieces.
269#[derive(Debug)]
270pub(crate) struct Download {
271 id: String,
272 text: String,
273 ends: Vec<usize>,
274}
275
276impl Download {
277 pub(crate) fn new(id: String, text: String, chunk: usize) -> Self {
278 let ends = boundaries(&text, chunk);
279 Self { id, text, ends }
280 }
281
282 /// The `chunked` field of the response that announces this result.
283 pub(crate) fn descriptor(&self) -> Value {
284 json!({"id": self.id, "bytes": self.text.len(), "count": self.ends.len()})
285 }
286
287 /// Answer one `rk.fetch` frame.
288 pub(crate) fn fetch(&self, params: &Value) -> Result<Value, ServiceError> {
289 if params["id"].as_str() != Some(self.id.as_str()) {
290 return Err(bad(
291 "no such result on this connection (it was replaced or released)",
292 ));
293 }
294 let seq = params["seq"]
295 .as_u64()
296 .and_then(|n| usize::try_from(n).ok())
297 .filter(|n| *n < self.ends.len())
298 .ok_or_else(|| bad("fetch seq is out of range"))?;
299 let start = if seq == 0 { 0 } else { self.ends[seq - 1] };
300 Ok(json!({"seq": seq, "text": &self.text[start..self.ends[seq]]}))
301 }
302}