Skip to main content

actordb_protocol/
client_decode.rs

1//! Decoded-object admission, separate from encoded framing.
2//!
3//! The budget, header/payload and pending-shape walk is allocation-free and
4//! precedes all message/collection materialization. The existing typed receipt
5//! validator can allocate bounded error metadata on malformed receipts; that
6//! rejection does not materialize their decoded messages or payloads.
7
8use std::mem::size_of;
9
10use crate::{client, meta, write_concern, MAX_BATCH_ITEMS, MAX_RPC_MESSAGE_BYTES};
11
12/// Default accounted response allowance, including raw framing, decoded
13/// collections/payloads and portable conversion work. Not an allocator/RSS cap.
14pub const DEFAULT_MAX_DECODED_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
15/// Conservative portable QueryValue header allowance. SDK tests check that
16/// their actual layout fits this charge without coupling the wire crate to SQL.
17pub const PORTABLE_CELL_HEADER_CHARGE: usize = 64;
18const MAP_SLOT_CHARGE: usize = 128;
19const VALIDATION_SLOT_CHARGE: usize = 64;
20const MAX_WRITER_BYTES: usize = 255;
21
22/// Work admitted before Prost or SDK receipt/result conversion allocates.
23/// Header charges include Vec geometric growth/minimum capacity and conservative
24/// map/portable-conversion storage. Payloads are charged twice for decoded and
25/// temporary validation copies. Caller-retained returned results, allocator
26/// slack, transport buffers and outgoing request storage are outside this scope.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct RpcDecodeReport {
30    pub accounted_bytes: usize,
31    pub raw_bytes: usize,
32    pub header_bytes: usize,
33    pub payload_bytes: usize,
34    pub query_cells: usize,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38enum Operation {
39    Read(usize),
40    Mutation(usize),
41    Query,
42    Inspect,
43    Scan,
44    Unsupported,
45}
46
47/// Expectations derived from the actual pending application request, never
48/// from data echoed by the server. Use optional query shape from a trusted
49/// locally parsed plan; the protocol preflight does not parse SQL.
50#[derive(Debug, Clone, Copy)]
51pub struct RpcResponseShape {
52    operation: Operation,
53    columns: Option<usize>,
54    rows: usize,
55    aggregate: bool,
56}
57impl RpcResponseShape {
58    pub fn from_request(request: &client::Request) -> Self {
59        use client::request::Operation as Request;
60        let operation = match &request.operation {
61            Some(Request::Get(_)) => Operation::Read(1),
62            Some(Request::BatchGet(batch)) => Operation::Read(batch.keys.len()),
63            Some(Request::Put(_) | Request::Delete(_)) => Operation::Mutation(1),
64            Some(Request::BatchPut(batch)) => Operation::Mutation(batch.puts.len()),
65            Some(Request::Query(_)) => Operation::Query,
66            Some(Request::Inspect(_)) => Operation::Inspect,
67            Some(Request::Scan(_)) => Operation::Scan,
68            None => Operation::Unsupported,
69        };
70        Self {
71            operation,
72            columns: None,
73            rows: 100_000,
74            aggregate: false,
75        }
76    }
77
78    /// Refine a query using its validated pending plan. Invalid refinements
79    /// fail instead of becoming a less restrictive expectation.
80    pub fn with_query_shape(
81        mut self,
82        columns: usize,
83        limit: Option<u64>,
84        aggregate: bool,
85    ) -> Result<Self, &'static str> {
86        if self.operation != Operation::Query
87            || !(1..=32).contains(&columns)
88            || limit.is_some_and(|limit| limit > 100_000)
89        {
90            return Err("invalid expected query shape");
91        }
92        self.columns = Some(columns);
93        self.rows = if aggregate {
94            1
95        } else {
96            limit.unwrap_or(100_000) as usize
97        };
98        self.aggregate = aggregate;
99        Ok(self)
100    }
101}
102
103/// Allocation-free request preflight before an RPC adapter materializes Prost.
104pub fn preflight_rpc_request(
105    bytes: &[u8],
106    maximum: usize,
107) -> Result<RpcDecodeReport, &'static str> {
108    preflight(bytes, maximum, Context::Request, None)
109}
110
111/// Allocation-free response admission, pinned to the actual pending request.
112pub fn preflight_rpc_response(
113    bytes: &[u8],
114    maximum: usize,
115    shape: &RpcResponseShape,
116) -> Result<RpcDecodeReport, &'static str> {
117    preflight(bytes, maximum, Context::Response, Some(shape))
118}
119
120/// Structural response admission for adapters which validate the pending shape separately.
121pub fn preflight_rpc_response_unchecked_shape(
122    bytes: &[u8],
123    maximum: usize,
124) -> Result<RpcDecodeReport, &'static str> {
125    preflight(bytes, maximum, Context::Response, None)
126}
127
128fn preflight(
129    bytes: &[u8],
130    maximum: usize,
131    context: Context,
132    shape: Option<&RpcResponseShape>,
133) -> Result<RpcDecodeReport, &'static str> {
134    if bytes.len() > MAX_RPC_MESSAGE_BYTES || bytes.len() > maximum {
135        return Err("RPC message exceeds admission allowance");
136    }
137    let mut budget = Budget {
138        maximum,
139        report: RpcDecodeReport {
140            accounted_bytes: bytes.len(),
141            raw_bytes: bytes.len(),
142            header_bytes: 0,
143            payload_bytes: 0,
144            query_cells: 0,
145        },
146        columns: 0,
147        rows: 0,
148        wrong_row_width: false,
149    };
150    walk(bytes, context, &mut budget, shape)?;
151    Ok(budget.report)
152}
153
154struct Budget {
155    maximum: usize,
156    report: RpcDecodeReport,
157    columns: usize,
158    rows: usize,
159    wrong_row_width: bool,
160}
161impl Budget {
162    fn charge(&mut self, bytes: usize, payload: bool) -> Result<(), &'static str> {
163        self.report.accounted_bytes = self
164            .report
165            .accounted_bytes
166            .checked_add(bytes)
167            .ok_or("decoded application accounting overflow")?;
168        if self.report.accounted_bytes > self.maximum {
169            return Err("decoded application response exceeds admission allowance");
170        }
171        let counter = if payload {
172            &mut self.report.payload_bytes
173        } else {
174            &mut self.report.header_bytes
175        };
176        *counter = counter
177            .checked_add(bytes)
178            .ok_or("decoded application accounting overflow")?;
179        Ok(())
180    }
181    fn payload(&mut self, bytes: usize) -> Result<(), &'static str> {
182        self.charge(
183            bytes
184                .max(if bytes == 0 { 0 } else { 8 })
185                .checked_mul(2)
186                .ok_or("decoded payload accounting overflow")?,
187            true,
188        )
189    }
190    fn repeated(&mut self, count: usize, element: usize) -> Result<(), &'static str> {
191        // Current Vec push growth: a non-byte collection starts at four slots,
192        // then doubles. Charge its capacity, not only its populated elements.
193        let capacity = count
194            .checked_next_power_of_two()
195            .ok_or("decoded collection overflow")?
196            .max(4);
197        let previous = if count == 1 {
198            0
199        } else {
200            (count - 1)
201                .checked_next_power_of_two()
202                .ok_or("decoded collection overflow")?
203                .max(4)
204        };
205        self.charge(
206            (capacity - previous)
207                .checked_mul(element)
208                .ok_or("decoded collection overflow")?,
209            false,
210        )
211    }
212}
213
214#[derive(Clone, Copy, PartialEq, Eq)]
215enum Context {
216    Request,
217    Get,
218    Put,
219    Delete,
220    BatchGet,
221    BatchPut,
222    Query,
223    Inspect,
224    Scan,
225    Response,
226    Error,
227    Value,
228    Read,
229    Mutation,
230    QueryResult,
231    Row,
232    Cell,
233    Inspection,
234    Health,
235    Placement,
236    MapEntry,
237    ScanMatch,
238    Receipt,
239    Evidence,
240    OperationEvidence,
241    Acknowledgement,
242}
243impl Context {
244    fn size(self) -> usize {
245        use Context::*;
246        match self {
247            Request => size_of::<client::Request>(),
248            Get => size_of::<client::Get>(),
249            Put => size_of::<client::Put>(),
250            Delete => size_of::<client::Delete>(),
251            BatchGet => size_of::<client::BatchGet>(),
252            BatchPut => size_of::<client::BatchPut>(),
253            Query => size_of::<client::Query>(),
254            Inspect => size_of::<client::Inspect>(),
255            Scan => size_of::<client::Scan>(),
256            Response => size_of::<client::Response>(),
257            Error => size_of::<client::Error>(),
258            Value => size_of::<client::Value>(),
259            Read => size_of::<client::ReadResult>(),
260            Mutation => size_of::<client::MutationResult>(),
261            QueryResult => size_of::<client::QueryResult>(),
262            Row => size_of::<client::QueryRow>(),
263            Cell => size_of::<meta::QueryCell>(),
264            Inspection => size_of::<client::Inspection>(),
265            Health => size_of::<client::FragmentHealth>(),
266            Placement => size_of::<client::Placement>(),
267            MapEntry => size_of::<(String, u64)>(),
268            ScanMatch => size_of::<meta::ScanMatch>(),
269            Receipt => size_of::<meta::WriteConcernResult>(),
270            Evidence => size_of::<meta::WriteConcernEvidence>(),
271            OperationEvidence => size_of::<meta::WriteOperationEvidence>(),
272            Acknowledgement => size_of::<meta::WriteAcknowledgement>(),
273        }
274    }
275}
276
277#[derive(Clone, Copy)]
278enum Field {
279    Scalar,
280    Text,
281    Bytes,
282    Message(Context),
283    Texts {
284        limit: usize,
285        extra: usize,
286    },
287    Messages {
288        context: Context,
289        limit: usize,
290        extra: usize,
291    },
292    Map {
293        limit: usize,
294    },
295    ReceiptBytes,
296}
297fn schema(context: Context, field: u64) -> Option<Field> {
298    use Context::*;
299    use Field::{Bytes, Map, Message, Messages, ReceiptBytes, Scalar, Text, Texts};
300    let strings = |limit| Texts { limit, extra: 0 };
301    let messages = |context, limit| Messages {
302        context,
303        limit,
304        extra: 0,
305    };
306    Some(match (context, field) {
307        (Request, 1) => Text,
308        (Request, 2) => Message(Get),
309        (Request, 3) => Message(Put),
310        (Request, 4) => Message(Delete),
311        (Request, 5) => Message(BatchGet),
312        (Request, 6) => Message(BatchPut),
313        (Request, 7) => Message(Query),
314        (Request, 8) => Message(Inspect),
315        (Request, 9) => Message(Scan),
316        (Get, 1) | (Put, 1) | (Delete, 1) => Text,
317        (Get, 2) | (Put, 3 | 4) | (Delete, 2) => Scalar,
318        (Put, 2 | 5) | (Delete, 3) => Bytes,
319        (BatchGet, 1) => strings(MAX_BATCH_ITEMS),
320        (BatchGet | BatchPut, 2) => Scalar,
321        (BatchPut, 1) => messages(Put, MAX_BATCH_ITEMS),
322        (Query, 1) => Text,
323        (Query, 2) => Messages {
324            context: Cell,
325            limit: MAX_BATCH_ITEMS,
326            extra: PORTABLE_CELL_HEADER_CHARGE,
327        },
328        (Scan, 1 | 3 | 4 | 5 | 6) => Scalar,
329        (Scan, 2) => Bytes,
330        (Response, 1) => Message(Error),
331        (Response, 2) => messages(Read, MAX_BATCH_ITEMS),
332        (Response, 3) => messages(Mutation, MAX_BATCH_ITEMS),
333        (Response, 4) => Message(QueryResult),
334        (Response, 5) => Message(Inspection),
335        (Response, 6) => messages(ScanMatch, 100_000),
336        (Error, 1 | 3 | 5 | 6) => Scalar,
337        (Error, 2 | 4) => Text,
338        (Value, 1) => Bytes,
339        (Value, 2 | 4 | 5) => Scalar,
340        (Value, 3) => Text,
341        (Read, 1) | (Mutation, 1) => Text,
342        (Read, 2) => Message(Value),
343        (Read, 3) | (Mutation, 2) => Message(Error),
344        (Mutation, 3) => ReceiptBytes,
345        (QueryResult, 1) => strings(32),
346        (QueryResult, 2) => Messages {
347            context: Row,
348            limit: 100_000,
349            extra: size_of::<Vec<()>>(),
350        },
351        (Row, 1) => Messages {
352            context: Cell,
353            limit: 32,
354            extra: PORTABLE_CELL_HEADER_CHARGE,
355        },
356        (Cell, 1) => Scalar,
357        (Cell, 2 | 4) => Text,
358        (Cell, 3) => Bytes,
359        (Inspection, 1 | 2) => Text,
360        (Inspection, 3 | 6) => Scalar,
361        (Inspection, 4) => messages(Health, 100_000),
362        (Inspection, 5) => messages(Placement, 100_000),
363        (Health, 1 | 2 | 6 | 7 | 8) => Scalar,
364        (Health, 3) => strings(MAX_BATCH_ITEMS),
365        (Health, 4) => Map {
366            limit: MAX_BATCH_ITEMS,
367        },
368        (Placement, 1) => Scalar,
369        (Placement, 2) => strings(MAX_BATCH_ITEMS),
370        (Placement, 3) => Text,
371        (MapEntry, 1) => Text,
372        (MapEntry, 2) => Scalar,
373        (ScanMatch, 1) => Text,
374        (ScanMatch, 2) => Bytes,
375        (Receipt, 1 | 3 | 4 | 5 | 11) => Scalar,
376        (Receipt, 2 | 7 | 8) => Text,
377        (Receipt, 6 | 9) => Texts {
378            limit: write_concern::MAX_WRITE_CONCERN_REPLICAS,
379            extra: VALIDATION_SLOT_CHARGE,
380        },
381        (Receipt, 10) => Message(Evidence),
382        (Evidence, 1) => Message(OperationEvidence),
383        (Evidence, 3) => Bytes,
384        (Evidence, 2) => Texts {
385            limit: write_concern::MAX_WRITE_CONCERN_REPLICAS,
386            extra: VALIDATION_SLOT_CHARGE,
387        },
388        (Evidence, 4) => Messages {
389            context: Acknowledgement,
390            limit: write_concern::MAX_WRITE_CONCERN_REPLICAS,
391            extra: size_of::<write_concern::WriteAcknowledgement>() + MAX_WRITER_BYTES,
392        },
393        (OperationEvidence, 1 | 4 | 5 | 7 | 8) | (Acknowledgement, 1 | 2) => Scalar,
394        (OperationEvidence, 2 | 3 | 9) => Bytes,
395        (OperationEvidence, 6) => Text,
396        _ => return None,
397    })
398}
399
400fn varint(bytes: &[u8], position: &mut usize) -> Result<u64, &'static str> {
401    let mut result = 0;
402    for shift in (0..70).step_by(7) {
403        let byte = *bytes.get(*position).ok_or("truncated varint")?;
404        *position += 1;
405        if shift == 63 && byte > 1 {
406            return Err("overflowing varint");
407        }
408        result |= u64::from(byte & 127) << shift;
409        if byte & 128 == 0 {
410            return Ok(result);
411        }
412    }
413    Err("overflowing varint")
414}
415
416fn walk(
417    bytes: &[u8],
418    context: Context,
419    budget: &mut Budget,
420    shape: Option<&RpcResponseShape>,
421) -> Result<(), &'static str> {
422    budget.charge(context.size(), false)?;
423    let mut position = 0;
424    let mut counts = [0usize; 12];
425    let mut variants = 0usize;
426    while position < bytes.len() {
427        let tag = varint(bytes, &mut position)?;
428        let field = tag >> 3;
429        if field == 0 {
430            return Err("invalid field");
431        }
432        if (context == Context::Request && (2..=9).contains(&field))
433            || (context == Context::Cell && (1..=4).contains(&field))
434        {
435            variants += 1;
436            if variants > 1 {
437                return Err("duplicate RPC oneof");
438            }
439        }
440        let kind = schema(context, field);
441        if let Some(kind) = kind {
442            let expected_wire = if matches!(kind, Field::Scalar) { 0 } else { 2 };
443            if tag & 7 != expected_wire {
444                return Err("invalid known application field wire type");
445            }
446            let count = counts
447                .get_mut(field as usize)
448                .ok_or("invalid known field")?;
449            *count = count.checked_add(1).ok_or("decoded collection overflow")?;
450            let repeated = match kind {
451                Field::Texts { limit, extra } => Some((limit, size_of::<String>() + extra)),
452                Field::Messages {
453                    context,
454                    limit,
455                    extra,
456                } => Some((limit, context.size() + extra)),
457                Field::Map { limit } => Some((limit, MAP_SLOT_CHARGE)),
458                _ => None,
459            };
460            if repeated.is_none() && *count > 1 {
461                return Err("duplicate RPC field");
462            }
463            if let Some((limit, element)) = repeated {
464                if *count > limit {
465                    return Err("invalid or excessive repeated field");
466                }
467                budget.repeated(*count, element)?;
468            }
469            if context == Context::Row && field == 1 {
470                budget.report.query_cells = budget
471                    .report
472                    .query_cells
473                    .checked_add(1)
474                    .ok_or("decoded cell overflow")?;
475            }
476        }
477        match tag & 7 {
478            0 => {
479                varint(bytes, &mut position)?;
480            }
481            1 => {
482                position = position.checked_add(8).ok_or("overflow")?;
483            }
484            5 => {
485                position = position.checked_add(4).ok_or("overflow")?;
486            }
487            2 => {
488                let length =
489                    usize::try_from(varint(bytes, &mut position)?).map_err(|_| "overflow")?;
490                let end = position.checked_add(length).ok_or("overflow")?;
491                let inner = bytes.get(position..end).ok_or("truncated field")?;
492                match kind {
493                    Some(Field::Text | Field::Texts { .. }) => {
494                        std::str::from_utf8(inner).map_err(|_| "invalid application UTF-8")?;
495                        budget.payload(inner.len())?;
496                    }
497                    Some(Field::Bytes) => {
498                        if matches!((context, field), (Context::Put, 5) | (Context::Delete, 3))
499                            && inner.len() > write_concern::MAX_WRITE_CONCERN_POLICY_BYTES
500                        {
501                            return Err("write concern policy exceeds byte limit");
502                        }
503                        budget.payload(inner.len())?;
504                    }
505                    Some(
506                        Field::Message(nested)
507                        | Field::Messages {
508                            context: nested, ..
509                        },
510                    ) => {
511                        walk(inner, nested, budget, shape)?;
512                    }
513                    Some(Field::Map { .. }) => walk(inner, Context::MapEntry, budget, shape)?,
514                    Some(Field::ReceiptBytes) => {
515                        budget.payload(inner.len())?;
516                        // Opaque application receipt bytes are subsequently decoded
517                        // by the SDK. Admit that nested materialization as well.
518                        walk(inner, Context::Receipt, budget, shape)?;
519                        write_concern::preflight_result(inner)
520                            .map_err(|_| "invalid write concern receipt")?;
521                    }
522                    _ => {}
523                }
524                position = end;
525            }
526            _ => return Err("invalid wire type"),
527        }
528        if position > bytes.len() {
529            return Err("truncated field");
530        }
531    }
532    if context == Context::Row {
533        budget.wrong_row_width |= shape
534            .and_then(|shape| shape.columns)
535            .is_some_and(|columns| counts[1] != columns);
536    }
537    if context == Context::QueryResult {
538        budget.columns = counts[1];
539        budget.rows = counts[2];
540    }
541    if context == Context::Request && (variants != 1 || counts[1] != 1) {
542        return Err("RPC request requires a table and exactly one operation");
543    }
544    if context == Context::Response && counts[1] == 0 {
545        if let Some(shape) = shape {
546            let (reads, mutations, query, inspection, scans) = match shape.operation {
547                Operation::Read(reads) => (reads, 0, 0, 0, false),
548                Operation::Mutation(mutations) => (0, mutations, 0, 0, false),
549                Operation::Query => (0, 0, 1, 0, false),
550                Operation::Inspect => (0, 0, 0, 1, false),
551                Operation::Scan => (0, 0, 0, 0, true),
552                Operation::Unsupported => (0, 0, 0, 0, false),
553            };
554            if counts[2] != reads
555                || counts[3] != mutations
556                || counts[4] != query
557                || counts[5] != inspection
558                || (!scans && counts[6] != 0)
559                || (query == 1
560                    && (shape
561                        .columns
562                        .is_some_and(|columns| columns != budget.columns)
563                        || budget.rows > shape.rows
564                        || (shape.aggregate && budget.rows != 1)
565                        || budget.wrong_row_width))
566            {
567                return Err("decoded response does not match pending request shape");
568            }
569        }
570    }
571    Ok(())
572}