Skip to main content

monty_proto/
wire.rs

1//! Borrowed protobuf encoding and incremental decoding of the value arena.
2//!
3//! Codegen maps `monty.v1.Arena` to [`WireArena`] via `extern_path`:
4//! - **encode** writes borrowed [`MontyNode`]s without an intermediate arena or clones;
5//! - **decode** parses one generated [`pb::MontyNode`], validates its completed
6//!   payload, and converts it before appending to the domain arena. Strings and
7//!   bytes move without copying. Extern-mapped containers decode references directly
8//!   into domain buffers, so conversion does not allocate a second reference vector.
9//!
10//! Protobuf field merging stays in prost, including duplicate message kinds.
11//! Arena index invariants are checked once, by [`WireArena::into_graph`]. The
12//! flat layout avoids value-depth-dependent recursion in either direction.
13//!
14//! Generated payloads and domain conversions share one cumulative frame budget,
15//! charged before allocation. Node references also carry an allowance for host
16//! container storage; growing a buffer pays for the full replacement allocation.
17//!
18//! `tests/differential.rs` checks byte compatibility and duplicate-field merging
19//! against a fully generated oracle compiled from the same schema.
20//!
21//! Encoding leaf arms whose wire form is a `Display` rendering (`MontyType`,
22//! builtin functions, exception type names) allocates the rendered string in
23//! both `encoded_len` and `encode_raw`; those arms are rare in real payloads
24//! and the strings are tiny.
25
26mod references;
27
28use std::{
29    fmt::Display,
30    io::{Cursor, Write},
31    ops::RangeInclusive,
32};
33
34use monty_types::{
35    BuiltinsFunctions, CallArgs, MAX_TIMEZONE_OFFSET_SECONDS, MIN_TIMEZONE_OFFSET_SECONDS, MontyComplex, MontyDate,
36    MontyDateTime, MontyFileHandle, MontyTime, MontyTimeDelta, MontyTimeZone, MontyType, MontyUuid, SourceRange,
37    unstable::{self, ClassTypeNode, GraphError, MontyGraph, MontyNode, NodeId},
38};
39use num_bigint::{BigInt, Sign};
40use prost::{
41    DecodeError, Message,
42    bytes::{Buf, BufMut},
43    encoding::{
44        DecodeContext, WireType, decode_varint, encode_key, encode_varint, encoded_len_varint, key_len, skip_field,
45    },
46};
47pub use references::{WireIndexes, WireNamedTuple, WireNodePairs};
48
49use crate::{
50    BudgetVec,
51    budgeted_prost::encoding,
52    convert::ProtoConvertError,
53    decode_budget,
54    pb::{self, monty_node::Kind},
55};
56
57/// The wire form of a [`MontyGraph`]: what the `monty.v1.Arena` proto message
58/// decodes into and encodes from.
59///
60/// Decoding only collects nodes; [`Self::into_graph`] checks the arena
61/// invariants (every child index lower than its holder, class instances
62/// pointing at class nodes) once the whole message has arrived, since prost
63/// has no end-of-message hook. Senders build it from a validated graph via `From`.
64#[derive(Debug, Clone, PartialEq, Default)]
65pub struct WireArena(pub BudgetVec<MontyNode>);
66
67impl WireArena {
68    /// Wraps a graph for sending. Equivalent to `From`, named for call sites
69    /// where `.into()` would be unclear.
70    #[must_use]
71    pub fn new(graph: MontyGraph) -> Self {
72        Self(graph.into_nodes().into())
73    }
74
75    /// Validates the decoded nodes into a graph.
76    pub fn into_graph(self) -> Result<MontyGraph, ProtoConvertError> {
77        MontyGraph::from_nodes(self.0.into_inner()).map_err(|err| graph_error(&err))
78    }
79}
80
81impl From<MontyGraph> for WireArena {
82    fn from(graph: MontyGraph) -> Self {
83        Self::new(graph)
84    }
85}
86
87/// Smallest reservation the decoder makes when a vector starts growing.
88const MIN_VEC_CAPACITY: usize = 4;
89
90/// Fewest wire bytes one node occupies (entry key, length, kind key, empty
91/// `Unit` payload); caps how many nodes a `node_count` hint may reserve.
92const MIN_NODE_WIRE_BYTES: usize = 4;
93
94impl Message for WireArena {
95    fn encode_raw(&self, buf: &mut impl BufMut) {
96        encode_uint32(1, arena_len_u32(self.0.len()), buf);
97        for node in &self.0 {
98            encoding::message::encode(2, &NodeRef(node), buf);
99        }
100    }
101
102    fn encoded_len(&self) -> usize {
103        uint32_len(1, arena_len_u32(self.0.len()))
104            + self
105                .0
106                .iter()
107                .map(|node| encoding::message::encoded_len(2, &NodeRef(node)))
108                .sum::<usize>()
109    }
110
111    fn merge_field(
112        &mut self,
113        tag: u32,
114        wire_type: WireType,
115        buf: &mut impl Buf,
116        ctx: DecodeContext,
117    ) -> Result<(), DecodeError> {
118        match tag {
119            1 => {
120                let mut hint = 0u32;
121                encoding::uint32::merge(wire_type, &mut hint, buf, ctx)?;
122                // capped by the bytes left in the message and charged before
123                // reserving, so a false hint cannot exceed this frame's budget
124                if self.0.capacity() == 0 {
125                    let reserve = (hint as usize).min(buf.remaining() / MIN_NODE_WIRE_BYTES);
126                    self.0.try_reserve_capacity(reserve)?;
127                }
128                Ok(())
129            }
130            2 => {
131                // the node's payload was charged as it decoded; this charges its slot
132                let node = node_from_proto(merge_message(wire_type, buf, ctx)?)?;
133                self.0.try_push(node)
134            }
135            _ => skip_field(wire_type, tag, buf, ctx),
136        }
137    }
138
139    /// Releases the arena's storage without refunding its allocation charge.
140    fn clear(&mut self) {
141        self.0 = BudgetVec::new();
142    }
143}
144
145/// Wire form of `monty.v1.FunctionCall`: the call's argument arena plus the
146/// ids of its positional and keyword arguments.
147///
148/// Installed with `prost_build::extern_path`, so generated `ChildEvent`
149/// decoding still handles the envelope while this payload keeps the argument
150/// vectors as bare ids and uses `WireArena`'s incremental node decoding.
151#[derive(Debug, Clone, PartialEq, Default)]
152pub struct WireFunctionCall {
153    /// Name of the external function the sandbox is calling.
154    pub function_name: String,
155    /// The arena `args` and `kwargs` index.
156    pub values: WireArena,
157    /// Positional arguments, in order.
158    pub args: BudgetVec<NodeId>,
159    /// Keyword arguments as `(key, value)` ids, preserving wire order.
160    pub kwargs: BudgetVec<(NodeId, NodeId)>,
161    /// Child-assigned call id used by the matching resume request.
162    pub call_id: u32,
163    /// Uuid of the routed receiver (a host-backed instance, or a class type
164    /// for `__call__`/classmethod calls); `None` for plain external function
165    /// calls. The receiver is never included in `args`.
166    pub object_id: Option<MontyUuid>,
167    /// The worker accepts an eagerly settled coroutine via `ResumeFutures`.
168    pub allow_eager_await: bool,
169    /// Where the call expression is in the source; `None` for a frame from a
170    /// child that predates the field.
171    pub position: Option<SourceRange>,
172}
173
174impl WireFunctionCall {
175    /// Splits `args` into its arena and the id vectors the wire carries.
176    #[must_use]
177    pub fn new(
178        function_name: String,
179        args: CallArgs,
180        call_id: u32,
181        object_id: Option<MontyUuid>,
182        allow_eager_await: bool,
183        position: SourceRange,
184    ) -> Self {
185        let (graph, args, kwargs) = unstable::into_call_args_parts(args);
186        Self {
187            function_name,
188            values: WireArena::new(graph),
189            args: args.into(),
190            kwargs: kwargs.into(),
191            call_id,
192            object_id,
193            allow_eager_await,
194            position: Some(position),
195        }
196    }
197
198    /// Validates the decoded arena and argument ids into [`CallArgs`].
199    pub fn into_call_args(self) -> Result<CallArgs, ProtoConvertError> {
200        unstable::call_args_from_parts(
201            self.values.into_graph()?,
202            self.args.into_inner(),
203            self.kwargs.into_inner(),
204        )
205        .map_err(|err| graph_error(&err))
206    }
207}
208
209impl Message for WireFunctionCall {
210    fn encode_raw(&self, buf: &mut impl BufMut) {
211        encode_str(1, &self.function_name, buf);
212        encode_packed_ids(2, &self.args, buf);
213        encode_node_pairs(3, &self.kwargs, buf);
214        encode_uint32(4, self.call_id, buf);
215        if let Some(id) = &self.object_id {
216            encoding::message::encode(5, &uuid_to_pb(id), buf);
217        }
218        if self.allow_eager_await {
219            encoding::bool::encode(6, &true, buf);
220        }
221        encoding::message::encode(7, &self.values, buf);
222        if let Some(position) = &self.position {
223            encode_source_range(8, position, buf);
224        }
225    }
226
227    fn encoded_len(&self) -> usize {
228        str_len(1, &self.function_name)
229            + packed_ids_len(2, &self.args)
230            + node_pairs_len(3, &self.kwargs)
231            + uint32_len(4, self.call_id)
232            + self
233                .object_id
234                .as_ref()
235                .map_or(0, |id| encoding::message::encoded_len(5, &uuid_to_pb(id)))
236            + if self.allow_eager_await {
237                encoding::bool::encoded_len(6, &true)
238            } else {
239                0
240            }
241            + encoding::message::encoded_len(7, &self.values)
242            + self
243                .position
244                .as_ref()
245                .map_or(0, |position| submessage_len(8, source_range_len(position)))
246    }
247
248    fn merge_field(
249        &mut self,
250        tag: u32,
251        wire_type: WireType,
252        buf: &mut impl Buf,
253        ctx: DecodeContext,
254    ) -> Result<(), DecodeError> {
255        match tag {
256            1 => encoding::string::merge(wire_type, &mut self.function_name, buf, ctx),
257            2 => merge_ids(wire_type, buf, ctx, &mut self.args),
258            3 => {
259                let pair: pb::NodePair = merge_message(wire_type, buf, ctx)?;
260                push_charged(&mut self.kwargs, (NodeId(pair.key), NodeId(pair.value)))
261            }
262            4 => encoding::uint32::merge(wire_type, &mut self.call_id, buf, ctx),
263            5 => {
264                let mut uuid = pb::Uuid::default();
265                encoding::message::merge(wire_type, &mut uuid, buf, ctx)?;
266                self.object_id = Some(pb_uuid_to_monty(&uuid, "FunctionCall.object_id")?);
267                Ok(())
268            }
269            6 => encoding::bool::merge(wire_type, &mut self.allow_eager_await, buf, ctx),
270            7 => encoding::message::merge(wire_type, &mut self.values, buf, ctx),
271            8 => {
272                // a singular message field repeated on the wire merges, as in
273                // prost's generated decoder; the held value moves rather than
274                // clones, so repeats cost their own bytes and not the filename's
275                let mut position = self
276                    .position
277                    .take()
278                    .map_or_else(pb::SourceRange::default, pb::SourceRange::from);
279                encoding::message::merge(wire_type, &mut position, buf, ctx)?;
280                self.position = Some(SourceRange::from(position));
281                Ok(())
282            }
283            _ => skip_field(wire_type, tag, buf, ctx),
284        }
285    }
286
287    fn clear(&mut self) {
288        self.function_name.clear();
289        self.values.clear();
290        self.args = BudgetVec::new();
291        self.kwargs = BudgetVec::new();
292        self.call_id = 0;
293        self.object_id = None;
294        self.allow_eager_await = false;
295        self.position = None;
296    }
297}
298
299/// Maps an arena invariant failure onto the conversion error hosts see.
300pub(crate) fn graph_error(err: &GraphError) -> ProtoConvertError {
301    ProtoConvertError::InvalidValue {
302        field: "Arena",
303        reason: err.to_string(),
304    }
305}
306
307/// Field numbers of the `MontyNode.kind` oneof — must match
308/// `proto/monty/v1/monty.proto` exactly (the differential oracle test catches drift).
309mod tag {
310    pub const ELLIPSIS: u32 = 1;
311    pub const NONE: u32 = 2;
312    pub const NOT_IMPLEMENTED: u32 = 3;
313    pub const BOOLEAN: u32 = 4;
314    pub const INT: u32 = 5;
315    pub const BIGINT: u32 = 6;
316    pub const FLOAT: u32 = 7;
317    pub const STR: u32 = 8;
318    pub const BYTES: u32 = 9;
319    // 10 is `Uuid uuid`, declared in the schema but not yet implemented
320    // (monty has no uuid module); node conversion rejects it.
321    pub const LIST: u32 = 11;
322    pub const TUPLE: u32 = 12;
323    pub const NAMED_TUPLE: u32 = 13;
324    pub const DICT: u32 = 14;
325    pub const SET: u32 = 15;
326    pub const FROZEN_SET: u32 = 16;
327    pub const DATE: u32 = 17;
328    pub const TIME: u32 = 18;
329    pub const DATETIME: u32 = 19;
330    pub const TIMEDELTA: u32 = 20;
331    pub const TIMEZONE: u32 = 21;
332    pub const EXCEPTION: u32 = 22;
333    pub const TYPE: u32 = 23;
334    pub const CLASS_INSTANCE: u32 = 24;
335    pub const FUNCTION: u32 = 25;
336    pub const BUILTIN_FUNCTION: u32 = 26;
337    pub const PATH: u32 = 27;
338    pub const FILE_HANDLE: u32 = 28;
339    pub const REPR: u32 = 29;
340    pub const CYCLE: u32 = 30;
341    pub const COMPLEX: u32 = 31;
342    pub const MODULE: u32 = 32;
343    pub const DECIMAL: u32 = 33;
344}
345
346// ============================================================================
347// Encoding
348// ============================================================================
349
350/// A borrowed node as one `MontyNode` message, so the arena can encode each
351/// entry through prost's length-delimited helpers without cloning.
352#[derive(Debug)]
353struct NodeRef<'a>(&'a MontyNode);
354
355impl Message for NodeRef<'_> {
356    fn encode_raw(&self, buf: &mut impl BufMut) {
357        encode_node(self.0, buf);
358    }
359
360    fn encoded_len(&self) -> usize {
361        node_len(self.0)
362    }
363
364    fn merge_field(
365        &mut self,
366        _tag: u32,
367        _wire_type: WireType,
368        _buf: &mut impl Buf,
369        _ctx: DecodeContext,
370    ) -> Result<(), DecodeError> {
371        unreachable!("NodeRef is encode-only")
372    }
373
374    fn clear(&mut self) {
375        unreachable!("NodeRef is encode-only")
376    }
377}
378
379/// The `uint32 node_count` hint; an arena cannot outgrow `u32` ids.
380fn arena_len_u32(len: usize) -> u32 {
381    u32::try_from(len).expect("arena exceeds u32::MAX nodes")
382}
383
384/// Writes `node` as one `MontyNode.kind` oneof field. Oneof fields always
385/// encode, even when the payload is a protobuf default (matching prost).
386///
387/// Each sub-message arm writes `encode_message_key(tag, <body len>, ...)` then
388/// the body; the matching `*_len` and [`node_len`] arms must compute the same
389/// length, or the frame corrupts (guarded by `tests/differential.rs`).
390fn encode_node(node: &MontyNode, buf: &mut impl BufMut) {
391    match node {
392        MontyNode::Ellipsis => encoding::message::encode(tag::ELLIPSIS, &pb::Unit {}, buf),
393        MontyNode::NotImplemented => encoding::message::encode(tag::NOT_IMPLEMENTED, &pb::Unit {}, buf),
394        MontyNode::None => encoding::message::encode(tag::NONE, &pb::Unit {}, buf),
395        MontyNode::Bool(b) => encoding::bool::encode(tag::BOOLEAN, b, buf),
396        MontyNode::Int(i) => encoding::sint64::encode(tag::INT, i, buf),
397        MontyNode::BigInt(bi) => encoding::message::encode(tag::BIGINT, &bigint_to_proto(bi), buf),
398        MontyNode::Float(f) => encoding::double::encode(tag::FLOAT, f, buf),
399        MontyNode::Complex(c) => encoding::message::encode(tag::COMPLEX, &complex_to_proto(c), buf),
400        MontyNode::String(s) => encoding::string::encode(tag::STR, s, buf),
401        MontyNode::Bytes(b) => encoding::bytes::encode(tag::BYTES, b, buf),
402        MontyNode::List(ids) => encode_indexes(tag::LIST, ids, buf),
403        MontyNode::Tuple(ids) => encode_indexes(tag::TUPLE, ids, buf),
404        MontyNode::NamedTuple {
405            type_name,
406            field_names,
407            values,
408        } => {
409            encode_message_key(tag::NAMED_TUPLE, named_tuple_len(type_name, field_names, values), buf);
410            encode_str(1, type_name, buf);
411            encode_repeated_str(2, field_names, buf);
412            encode_packed_ids(3, values, buf);
413        }
414        MontyNode::Dict(pairs) => {
415            encode_message_key(tag::DICT, node_pairs_len(1, pairs), buf);
416            encode_node_pairs(1, pairs, buf);
417        }
418        MontyNode::Set(ids) => encode_indexes(tag::SET, ids, buf),
419        MontyNode::FrozenSet(ids) => encode_indexes(tag::FROZEN_SET, ids, buf),
420        MontyNode::Date(d) => encoding::message::encode(tag::DATE, &date_to_proto(d), buf),
421        MontyNode::DateTime(dt) => {
422            encode_message_key(tag::DATETIME, datetime_len(dt), buf);
423            encode_datetime(dt, buf);
424        }
425        MontyNode::Time(t) => {
426            encode_message_key(tag::TIME, time_len(t), buf);
427            encode_time(t, buf);
428        }
429        MontyNode::TimeDelta(td) => encoding::message::encode(tag::TIMEDELTA, &timedelta_to_proto(td), buf),
430        MontyNode::TimeZone(tz) => {
431            encode_message_key(tag::TIMEZONE, timezone_len(tz), buf);
432            encode_int32(1, tz.offset_seconds, buf);
433            encode_opt_str(2, tz.name.as_deref(), buf);
434        }
435        MontyNode::Exception { exc_type, arg } => {
436            let name = exc_type.to_string();
437            encode_message_key(tag::EXCEPTION, str_len(1, &name) + opt_str_len(2, arg.as_deref()), buf);
438            encode_str(1, &name, buf);
439            encode_opt_str(2, arg.as_deref(), buf);
440        }
441        MontyNode::Type(t) => encoding::message::encode(tag::TYPE, &builtin_type_to_pb(*t), buf),
442        MontyNode::ClassType(class) => {
443            encode_message_key(tag::TYPE, class_type_len(class), buf);
444            encode_class_type(class, buf);
445        }
446        MontyNode::ClassInstance {
447            class_type,
448            instance_id,
449            attrs,
450        } => {
451            let id = uuid_to_pb(instance_id);
452            encode_message_key(tag::CLASS_INSTANCE, class_instance_len(*class_type, &id, attrs), buf);
453            encode_uint32(1, class_type.0, buf);
454            // instance_id and attrs are message fields, so they encode even
455            // when empty (message presence, matching prost)
456            encoding::message::encode(2, &id, buf);
457            encode_message_key(3, node_pairs_len(1, attrs), buf);
458            encode_node_pairs(1, attrs, buf);
459        }
460        MontyNode::Module { name, id, attrs } => {
461            let id = uuid_to_pb(id);
462            encode_message_key(tag::MODULE, module_len(name, &id, attrs), buf);
463            encode_str(1, name, buf);
464            encoding::message::encode(2, &id, buf);
465            encode_message_key(3, node_pairs_len(1, attrs), buf);
466            encode_node_pairs(1, attrs, buf);
467        }
468        MontyNode::BuiltinFunction(bf) => encoding::string::encode(tag::BUILTIN_FUNCTION, &bf.to_string(), buf),
469        MontyNode::Path(p) => encoding::string::encode(tag::PATH, p, buf),
470        MontyNode::Decimal(d) => encoding::string::encode(tag::DECIMAL, d, buf),
471        MontyNode::FileHandle(fh) => {
472            encode_message_key(tag::FILE_HANDLE, file_handle_len(fh), buf);
473            encode_str(1, &fh.path, buf);
474            encode_str(2, fh.mode.as_str(), buf);
475            encode_uint64(3, fh.position, buf);
476        }
477        MontyNode::Function { name, docstring } => {
478            encode_message_key(
479                tag::FUNCTION,
480                str_len(1, name) + opt_str_len(2, docstring.as_deref()),
481                buf,
482            );
483            encode_str(1, name, buf);
484            encode_opt_str(2, docstring.as_deref(), buf);
485        }
486        MontyNode::Repr(r) => encoding::string::encode(tag::REPR, r, buf),
487        MontyNode::Cycle(placeholder) => encoding::string::encode(tag::CYCLE, placeholder, buf),
488    }
489}
490
491/// Length of `node` as one `MontyNode.kind` oneof field (key + payload).
492/// Mirrors [`encode_node`] arm for arm.
493fn node_len(node: &MontyNode) -> usize {
494    match node {
495        MontyNode::Ellipsis => encoding::message::encoded_len(tag::ELLIPSIS, &pb::Unit {}),
496        MontyNode::NotImplemented => encoding::message::encoded_len(tag::NOT_IMPLEMENTED, &pb::Unit {}),
497        MontyNode::None => encoding::message::encoded_len(tag::NONE, &pb::Unit {}),
498        MontyNode::Bool(b) => encoding::bool::encoded_len(tag::BOOLEAN, b),
499        MontyNode::Int(i) => encoding::sint64::encoded_len(tag::INT, i),
500        MontyNode::BigInt(bi) => encoding::message::encoded_len(tag::BIGINT, &bigint_to_proto(bi)),
501        MontyNode::Float(f) => encoding::double::encoded_len(tag::FLOAT, f),
502        MontyNode::Complex(c) => encoding::message::encoded_len(tag::COMPLEX, &complex_to_proto(c)),
503        MontyNode::String(s) => encoding::string::encoded_len(tag::STR, s),
504        MontyNode::Bytes(b) => encoding::bytes::encoded_len(tag::BYTES, b),
505        MontyNode::List(ids) => submessage_len(tag::LIST, packed_ids_len(1, ids)),
506        MontyNode::Tuple(ids) => submessage_len(tag::TUPLE, packed_ids_len(1, ids)),
507        MontyNode::NamedTuple {
508            type_name,
509            field_names,
510            values,
511        } => submessage_len(tag::NAMED_TUPLE, named_tuple_len(type_name, field_names, values)),
512        MontyNode::Dict(pairs) => submessage_len(tag::DICT, node_pairs_len(1, pairs)),
513        MontyNode::Set(ids) => submessage_len(tag::SET, packed_ids_len(1, ids)),
514        MontyNode::FrozenSet(ids) => submessage_len(tag::FROZEN_SET, packed_ids_len(1, ids)),
515        MontyNode::Date(d) => encoding::message::encoded_len(tag::DATE, &date_to_proto(d)),
516        MontyNode::DateTime(dt) => submessage_len(tag::DATETIME, datetime_len(dt)),
517        MontyNode::Time(t) => submessage_len(tag::TIME, time_len(t)),
518        MontyNode::TimeDelta(td) => encoding::message::encoded_len(tag::TIMEDELTA, &timedelta_to_proto(td)),
519        MontyNode::TimeZone(tz) => submessage_len(tag::TIMEZONE, timezone_len(tz)),
520        MontyNode::Exception { exc_type, arg } => {
521            let name = exc_type.to_string();
522            submessage_len(tag::EXCEPTION, str_len(1, &name) + opt_str_len(2, arg.as_deref()))
523        }
524        MontyNode::Type(t) => encoding::message::encoded_len(tag::TYPE, &builtin_type_to_pb(*t)),
525        MontyNode::ClassType(class) => submessage_len(tag::TYPE, class_type_len(class)),
526        MontyNode::ClassInstance {
527            class_type,
528            instance_id,
529            attrs,
530        } => submessage_len(
531            tag::CLASS_INSTANCE,
532            class_instance_len(*class_type, &uuid_to_pb(instance_id), attrs),
533        ),
534        MontyNode::Module { name, id, attrs } => submessage_len(tag::MODULE, module_len(name, &uuid_to_pb(id), attrs)),
535        MontyNode::BuiltinFunction(bf) => encoding::string::encoded_len(tag::BUILTIN_FUNCTION, &bf.to_string()),
536        MontyNode::Path(p) => encoding::string::encoded_len(tag::PATH, p),
537        MontyNode::Decimal(d) => encoding::string::encoded_len(tag::DECIMAL, d),
538        MontyNode::FileHandle(fh) => submessage_len(tag::FILE_HANDLE, file_handle_len(fh)),
539        MontyNode::Function { name, docstring } => {
540            submessage_len(tag::FUNCTION, str_len(1, name) + opt_str_len(2, docstring.as_deref()))
541        }
542        MontyNode::Repr(r) => encoding::string::encoded_len(tag::REPR, r),
543        MontyNode::Cycle(placeholder) => encoding::string::encoded_len(tag::CYCLE, placeholder),
544    }
545}
546
547/// A `SourceRange` message encoded from the borrowed domain value, so the
548/// filename is written in place rather than cloned into a `pb::SourceRange`.
549fn encode_source_range(tag: u32, range: &SourceRange, buf: &mut impl BufMut) {
550    encode_message_key(tag, source_range_len(range), buf);
551    encode_str(1, &range.filename, buf);
552    encode_uint32(2, range.start, buf);
553    encode_uint32(3, range.end, buf);
554}
555
556/// Body length of [`encode_source_range`]'s message.
557fn source_range_len(range: &SourceRange) -> usize {
558    str_len(1, &range.filename) + uint32_len(2, range.start) + uint32_len(3, range.end)
559}
560
561/// Writes the key and length prefix of a length-delimited field.
562fn encode_message_key(tag: u32, body_len: usize, buf: &mut impl BufMut) {
563    encode_key(tag, WireType::LengthDelimited, buf);
564    encode_varint(body_len as u64, buf);
565}
566
567/// Length of a length-delimited field: key + length varint + body.
568fn submessage_len(tag: u32, body_len: usize) -> usize {
569    key_len(tag) + encoded_len_varint(body_len as u64) + body_len
570}
571
572/// An `Indexes` message (`repeated uint32 items = 1`) as one oneof arm.
573fn encode_indexes(tag: u32, ids: &[NodeId], buf: &mut impl BufMut) {
574    encode_message_key(tag, packed_ids_len(1, ids), buf);
575    encode_packed_ids(1, ids, buf);
576}
577
578/// Packed `repeated uint32` field of node ids, as prost encodes it: one
579/// length-delimited entry holding every varint, skipped entirely when empty.
580fn encode_packed_ids(tag: u32, ids: &[NodeId], buf: &mut impl BufMut) {
581    if !ids.is_empty() {
582        encode_message_key(tag, packed_body_len(ids), buf);
583        for id in ids {
584            encode_varint(u64::from(id.0), buf);
585        }
586    }
587}
588
589fn packed_ids_len(tag: u32, ids: &[NodeId]) -> usize {
590    if ids.is_empty() {
591        0
592    } else {
593        submessage_len(tag, packed_body_len(ids))
594    }
595}
596
597/// Bytes of the varints inside a packed id field.
598fn packed_body_len(ids: &[NodeId]) -> usize {
599    ids.iter().map(|id| encoded_len_varint(u64::from(id.0))).sum()
600}
601
602/// `repeated NodePair` field: each entry is a length-delimited
603/// `uint32 key = 1; uint32 value = 2` message with implicit presence.
604fn encode_node_pairs(tag: u32, pairs: &[(NodeId, NodeId)], buf: &mut impl BufMut) {
605    for (key, value) in pairs {
606        encode_message_key(tag, node_pair_len(*key, *value), buf);
607        encode_uint32(1, key.0, buf);
608        encode_uint32(2, value.0, buf);
609    }
610}
611
612fn node_pairs_len(tag: u32, pairs: &[(NodeId, NodeId)]) -> usize {
613    pairs
614        .iter()
615        .map(|(key, value)| submessage_len(tag, node_pair_len(*key, *value)))
616        .sum()
617}
618
619fn node_pair_len(key: NodeId, value: NodeId) -> usize {
620    uint32_len(1, key.0) + uint32_len(2, value.0)
621}
622
623/// `NamedTupleNode` body: `string type_name = 1; repeated string
624/// field_names = 2; repeated uint32 values = 3`.
625fn named_tuple_len(type_name: &str, field_names: &[String], values: &[NodeId]) -> usize {
626    str_len(1, type_name) + repeated_str_len(2, field_names) + packed_ids_len(3, values)
627}
628
629/// `Type` body for a class node: `string name = 1; Uuid id = 2; TypeOrigin
630/// origin = 3; bool is_dataclass = 4; NodePairs attrs = 5`, attrs only when
631/// non-empty (matching the generated encoder's absent field).
632fn class_type_len(class: &ClassTypeNode) -> usize {
633    str_len(1, &class.name)
634        + encoding::message::encoded_len(2, &uuid_to_pb(&class.id))
635        + int32_len(3, class_origin(class) as i32)
636        + if class.is_dataclass {
637            encoding::bool::encoded_len(4, &true)
638        } else {
639            0
640        }
641        + if class.attrs.is_empty() {
642            0
643        } else {
644            submessage_len(5, node_pairs_len(1, &class.attrs))
645        }
646}
647
648fn encode_class_type(class: &ClassTypeNode, buf: &mut impl BufMut) {
649    encode_str(1, &class.name, buf);
650    encoding::message::encode(2, &uuid_to_pb(&class.id), buf);
651    encode_int32(3, class_origin(class) as i32, buf);
652    if class.is_dataclass {
653        encoding::bool::encode(4, &true, buf);
654    }
655    if !class.attrs.is_empty() {
656        encode_message_key(5, node_pairs_len(1, &class.attrs), buf);
657        encode_node_pairs(1, &class.attrs, buf);
658    }
659}
660
661fn class_origin(class: &ClassTypeNode) -> pb::TypeOrigin {
662    if class.host_defined {
663        pb::TypeOrigin::Host
664    } else {
665        pb::TypeOrigin::Sandbox
666    }
667}
668
669/// `ClassInstanceNode` body: `uint32 class_type = 1; Uuid instance_id = 2;
670/// NodePairs attrs = 3`.
671fn class_instance_len(class_type: NodeId, id: &pb::Uuid, attrs: &[(NodeId, NodeId)]) -> usize {
672    uint32_len(1, class_type.0) + encoding::message::encoded_len(2, id) + submessage_len(3, node_pairs_len(1, attrs))
673}
674
675/// `ModuleNode` body: `name = 1`, `id = 2`, `attrs = 3`.
676fn module_len(name: &str, id: &pb::Uuid, attrs: &[(NodeId, NodeId)]) -> usize {
677    str_len(1, name) + encoding::message::encoded_len(2, id) + submessage_len(3, node_pairs_len(1, attrs))
678}
679
680/// `DateTime` body: scalar fields 1–7 (implicit presence, skipped at
681/// zero) plus explicit-presence `offset_seconds = 8` / `timezone_name = 9`.
682fn datetime_len(dt: &MontyDateTime) -> usize {
683    int32_len(1, dt.year)
684        + uint32_len(2, u32::from(dt.month))
685        + uint32_len(3, u32::from(dt.day))
686        + uint32_len(4, u32::from(dt.hour))
687        + uint32_len(5, u32::from(dt.minute))
688        + uint32_len(6, u32::from(dt.second))
689        + uint32_len(7, dt.microsecond)
690        + dt.offset_seconds.map_or(0, |off| encoding::int32::encoded_len(8, &off))
691        + opt_str_len(9, dt.timezone_name.as_deref())
692}
693
694fn encode_datetime(dt: &MontyDateTime, buf: &mut impl BufMut) {
695    encode_int32(1, dt.year, buf);
696    encode_uint32(2, u32::from(dt.month), buf);
697    encode_uint32(3, u32::from(dt.day), buf);
698    encode_uint32(4, u32::from(dt.hour), buf);
699    encode_uint32(5, u32::from(dt.minute), buf);
700    encode_uint32(6, u32::from(dt.second), buf);
701    encode_uint32(7, dt.microsecond, buf);
702    if let Some(off) = dt.offset_seconds {
703        encoding::int32::encode(8, &off, buf);
704    }
705    encode_opt_str(9, dt.timezone_name.as_deref(), buf);
706}
707
708/// `Time` body: scalar fields 1–4 and `fold = 7` (implicit presence, skipped
709/// at zero) plus explicit-presence `offset_seconds = 5` / `timezone_name = 6`.
710fn time_len(t: &MontyTime) -> usize {
711    uint32_len(1, u32::from(t.hour))
712        + uint32_len(2, u32::from(t.minute))
713        + uint32_len(3, u32::from(t.second))
714        + uint32_len(4, t.microsecond)
715        + t.offset_seconds.map_or(0, |off| encoding::int32::encoded_len(5, &off))
716        + opt_str_len(6, t.timezone_name.as_deref())
717        + uint32_len(7, u32::from(t.fold))
718}
719
720fn encode_time(t: &MontyTime, buf: &mut impl BufMut) {
721    encode_uint32(1, u32::from(t.hour), buf);
722    encode_uint32(2, u32::from(t.minute), buf);
723    encode_uint32(3, u32::from(t.second), buf);
724    encode_uint32(4, t.microsecond, buf);
725    if let Some(off) = t.offset_seconds {
726        encoding::int32::encode(5, &off, buf);
727    }
728    encode_opt_str(6, t.timezone_name.as_deref(), buf);
729    encode_uint32(7, u32::from(t.fold), buf);
730}
731
732/// `TimeZone` body: `int32 offset_seconds = 1; optional string name = 2`.
733fn timezone_len(tz: &MontyTimeZone) -> usize {
734    int32_len(1, tz.offset_seconds) + opt_str_len(2, tz.name.as_deref())
735}
736
737/// `FileHandle` body: `string path = 1; string mode = 2;
738/// uint64 position = 3`.
739fn file_handle_len(fh: &MontyFileHandle) -> usize {
740    str_len(1, &fh.path) + str_len(2, fh.mode.as_str()) + uint64_len(3, fh.position)
741}
742
743// --- proto3 field helpers, mirroring prost's generated default-skipping ---
744
745/// Implicit-presence string field: skipped when empty.
746fn encode_str(tag: u32, s: &str, buf: &mut impl BufMut) {
747    if !s.is_empty() {
748        encode_message_key(tag, s.len(), buf);
749        buf.put_slice(s.as_bytes());
750    }
751}
752
753fn str_len(tag: u32, s: &str) -> usize {
754    if s.is_empty() { 0 } else { submessage_len(tag, s.len()) }
755}
756
757/// Explicit-presence (`optional`) string field: encoded whenever `Some`,
758/// including `Some("")`.
759fn encode_opt_str(tag: u32, s: Option<&str>, buf: &mut impl BufMut) {
760    if let Some(s) = s {
761        encode_message_key(tag, s.len(), buf);
762        buf.put_slice(s.as_bytes());
763    }
764}
765
766fn opt_str_len(tag: u32, s: Option<&str>) -> usize {
767    s.map_or(0, |s| submessage_len(tag, s.len()))
768}
769
770/// Repeated string field: every element is encoded, including empty strings.
771fn encode_repeated_str(tag: u32, items: &[String], buf: &mut impl BufMut) {
772    for s in items {
773        encode_message_key(tag, s.len(), buf);
774        buf.put_slice(s.as_bytes());
775    }
776}
777
778fn repeated_str_len(tag: u32, items: &[String]) -> usize {
779    items.iter().map(|s| submessage_len(tag, s.len())).sum()
780}
781
782/// Implicit-presence `int32` field: skipped at zero.
783fn encode_int32(tag: u32, value: i32, buf: &mut impl BufMut) {
784    if value != 0 {
785        encoding::int32::encode(tag, &value, buf);
786    }
787}
788
789fn int32_len(tag: u32, value: i32) -> usize {
790    if value == 0 {
791        0
792    } else {
793        encoding::int32::encoded_len(tag, &value)
794    }
795}
796
797/// Implicit-presence `uint32` field: skipped at zero.
798fn encode_uint32(tag: u32, value: u32, buf: &mut impl BufMut) {
799    if value != 0 {
800        encoding::uint32::encode(tag, &value, buf);
801    }
802}
803
804fn uint32_len(tag: u32, value: u32) -> usize {
805    if value == 0 {
806        0
807    } else {
808        encoding::uint32::encoded_len(tag, &value)
809    }
810}
811
812/// Implicit-presence `uint64` field: skipped at zero.
813fn encode_uint64(tag: u32, value: u64, buf: &mut impl BufMut) {
814    if value != 0 {
815        encoding::uint64::encode(tag, &value, buf);
816    }
817}
818
819fn uint64_len(tag: u32, value: u64) -> usize {
820    if value == 0 {
821        0
822    } else {
823        encoding::uint64::encoded_len(tag, &value)
824    }
825}
826
827// ============================================================================
828// Decoding
829// ============================================================================
830
831/// Validates one generated node before retaining it in the domain arena.
832/// Owned buffers transfer without copying; BigInt limbs and class boxes are
833/// allocated under the same frame budget as the generated payload.
834#[expect(clippy::inline_always, reason = "measured improvement in frame decode benchmarks")]
835#[inline(always)]
836fn node_from_proto(node: pb::MontyNode) -> Result<MontyNode, DecodeError> {
837    let kind = node
838        .kind
839        .ok_or_else(|| to_decode_err(ProtoConvertError::MissingField("MontyNode.kind")))?;
840    Ok(match kind {
841        Kind::Ellipsis(_) => MontyNode::Ellipsis,
842        Kind::NotImplemented(_) => MontyNode::NotImplemented,
843        Kind::None(_) => MontyNode::None,
844        Kind::Boolean(value) => MontyNode::Bool(value),
845        Kind::Int(value) => MontyNode::Int(value),
846        Kind::Float(value) => MontyNode::Float(value),
847        Kind::Complex(value) => MontyNode::Complex(complex_from_proto(&value).map_err(to_decode_err)?),
848        Kind::Str(value) => MontyNode::String(value),
849        Kind::Bytes(value) => MontyNode::Bytes(value.into_inner()),
850        Kind::List(value) => MontyNode::List(value.0.into_inner()),
851        Kind::Tuple(value) => MontyNode::Tuple(value.0.into_inner()),
852        Kind::Set(value) => MontyNode::Set(value.0.into_inner()),
853        Kind::FrozenSet(value) => MontyNode::FrozenSet(value.0.into_inner()),
854        Kind::NamedTuple(value) => MontyNode::NamedTuple {
855            type_name: value.type_name,
856            field_names: value.field_names.into_inner(),
857            values: value.values.into_inner(),
858        },
859        Kind::Dict(value) => MontyNode::Dict(value.0.into_inner()),
860        Kind::Path(value) => MontyNode::Path(value),
861        // Validated by whoever turns the text into a `Decimal`: the sandbox parses
862        // it, the bindings call their own `Decimal` constructor.
863        Kind::Decimal(value) => MontyNode::Decimal(value),
864        Kind::Function(value) => MontyNode::Function {
865            name: value.name,
866            docstring: value.docstring,
867        },
868        Kind::Repr(value) => MontyNode::Repr(value),
869        Kind::Cycle(value) => MontyNode::Cycle(value),
870        Kind::Bigint(value) => MontyNode::BigInt(bigint_from_proto(value)?),
871        Kind::Type(value) => type_to_node(value)?,
872        Kind::ClassInstance(value) => {
873            let instance_id = value
874                .instance_id
875                .ok_or_else(|| to_decode_err(ProtoConvertError::MissingField("ClassInstanceNode.instance_id")))?;
876            let attrs = value
877                .attrs
878                .ok_or_else(|| to_decode_err(ProtoConvertError::MissingField("ClassInstanceNode.attrs")))?;
879            MontyNode::ClassInstance {
880                class_type: NodeId(value.class_type),
881                instance_id: pb_uuid_to_monty(&instance_id, "ClassInstanceNode.instance_id")?,
882                attrs: attrs.0.into_inner(),
883            }
884        }
885        Kind::Module(value) => {
886            let id = value
887                .id
888                .ok_or_else(|| to_decode_err(ProtoConvertError::MissingField("ModuleNode.id")))?;
889            let attrs = value
890                .attrs
891                .ok_or_else(|| to_decode_err(ProtoConvertError::MissingField("ModuleNode.attrs")))?;
892            MontyNode::Module {
893                name: value.name,
894                id: pb_uuid_to_monty(&id, "ModuleNode.id")?,
895                attrs: attrs.0.into_inner(),
896            }
897        }
898        Kind::Date(value) => MontyNode::Date(date_from_proto(&value).map_err(to_decode_err)?),
899        Kind::Datetime(value) => MontyNode::DateTime(datetime_from_proto(value).map_err(to_decode_err)?),
900        Kind::Time(value) => MontyNode::Time(time_from_proto(value).map_err(to_decode_err)?),
901        Kind::Timedelta(value) => MontyNode::TimeDelta(timedelta_from_proto(&value).map_err(to_decode_err)?),
902        Kind::Timezone(value) => MontyNode::TimeZone(MontyTimeZone {
903            offset_seconds: timezone_offset(value.offset_seconds, "TimeZone.offset_seconds").map_err(to_decode_err)?,
904            name: value.name,
905        }),
906        Kind::Exception(value) => MontyNode::Exception {
907            exc_type: value
908                .exc_type
909                .parse()
910                .map_err(|_| to_decode_err(ProtoConvertError::UnknownExcType(value.exc_type)))?,
911            arg: value.arg,
912        },
913        Kind::BuiltinFunction(name) => MontyNode::BuiltinFunction(
914            name.parse::<BuiltinsFunctions>()
915                .map_err(|_| to_decode_err(ProtoConvertError::UnknownBuiltinFunction(name)))?,
916        ),
917        Kind::FileHandle(value) => MontyNode::FileHandle(MontyFileHandle {
918            mode: value
919                .mode
920                .parse()
921                .map_err(|_| to_decode_err(ProtoConvertError::InvalidFileMode(value.mode)))?,
922            path: value.path,
923            position: value.position,
924        }),
925        // The schema reserves UUID values, but Monty does not support them yet.
926        Kind::Uuid(_) => return Err(to_decode_err(ProtoConvertError::MissingField("MontyNode.kind"))),
927    })
928}
929
930/// Decodes one length-delimited sub-message into a fresh `M` (the generated
931/// leaf and container types). The arena is flat, so `message::merge`'s
932/// recursion limit only ever sees a fixed two or three levels here.
933fn merge_message<M: Message + Default>(
934    wire_type: WireType,
935    buf: &mut impl Buf,
936    ctx: DecodeContext,
937) -> Result<M, DecodeError> {
938    let mut msg = M::default();
939    encoding::message::merge(wire_type, &mut msg, buf, ctx)?;
940    Ok(msg)
941}
942
943/// Decodes one `repeated uint32` field of node ids (packed or not) straight
944/// into `ids`. A packed run of `n` bytes holds at most `n` ids, so that many
945/// slots are charged and reserved before any is read; no temporary vector.
946fn merge_ids(
947    wire_type: WireType,
948    buf: &mut impl Buf,
949    ctx: DecodeContext,
950    ids: &mut BudgetVec<NodeId>,
951) -> Result<(), DecodeError> {
952    let mut id = 0u32;
953    if wire_type == WireType::LengthDelimited {
954        // the same length checks as prost's packed `merge_loop`
955        let len = decode_varint(buf)?;
956        let len = usize::try_from(len)
957            .ok()
958            .filter(|len| *len <= buf.remaining())
959            .ok_or_else(|| to_decode_err("buffer underflow"))?;
960        reserve_charged(ids, len)?;
961        let end = buf.remaining() - len;
962        while buf.remaining() > end {
963            encoding::uint32::merge(WireType::Varint, &mut id, buf, ctx.clone())?;
964            ids.try_push_reserved(NodeId(id))?;
965        }
966        if buf.remaining() == end {
967            Ok(())
968        } else {
969            Err(to_decode_err("delimited length exceeded"))
970        }
971    } else {
972        encoding::uint32::merge(wire_type, &mut id, buf, ctx)?;
973        push_charged(ids, NodeId(id))
974    }
975}
976
977/// Bytes charged per reference to a node. A host binding turns each reference
978/// into a pointer-sized container entry, often through a temporary vector, so
979/// the 4-byte [`NodeId`] alone would let a list of repeated references
980/// materialise at several times the budget.
981const REFERENCE_COST: usize = 2 * size_of::<usize>();
982
983/// Budget charge for a reference-buffer slot, including host-container headroom.
984trait DecodeCost {
985    const COST: usize;
986}
987
988impl DecodeCost for NodeId {
989    const COST: usize = REFERENCE_COST;
990}
991
992impl DecodeCost for (NodeId, NodeId) {
993    const COST: usize = 2 * REFERENCE_COST;
994}
995
996/// Appends references, including host-container headroom in any growth charge.
997fn push_charged<T: DecodeCost>(vec: &mut BudgetVec<T>, item: T) -> Result<(), DecodeError> {
998    if vec.len() == vec.capacity() {
999        let new_capacity = vec
1000            .capacity()
1001            .checked_mul(2)
1002            .ok_or_else(decode_budget::exhausted)?
1003            .max(MIN_VEC_CAPACITY);
1004        reserve_charged(vec, new_capacity - vec.len())?;
1005    }
1006    vec.try_push_reserved(item)
1007}
1008
1009/// Charges a full replacement buffer, including the host-reference allowance.
1010fn reserve_charged<T: DecodeCost>(vec: &mut BudgetVec<T>, additional: usize) -> Result<(), DecodeError> {
1011    let capacity = vec.len().checked_add(additional).ok_or_else(decode_budget::exhausted)?;
1012    vec.try_reserve_capacity_with_overhead(capacity, T::COST - size_of::<T>())
1013}
1014
1015/// Maps a semantic validation failure onto prost's decode error so it
1016/// surfaces through the normal frame-decode path.
1017//
1018// `DecodeError::new` is deprecated but has no public replacement in prost
1019// 0.14 (`DecodeErrorKind` is crate-private); the deprecation note itself
1020// acknowledges external users. Revisit when prost ships a public constructor.
1021#[expect(deprecated)]
1022fn to_decode_err(err: impl Display) -> DecodeError {
1023    let mut buffer = [0; 512];
1024    let mut cursor = Cursor::new(buffer.as_mut_slice());
1025    if write!(cursor, "{err}").is_ok() {
1026        let len = usize::try_from(cursor.position()).expect("bounded error buffer");
1027        DecodeError::new(str::from_utf8(&buffer[..len]).expect("Display writes UTF-8").to_owned())
1028    } else {
1029        DecodeError::new("invalid wire value (error message exceeds 512 bytes)")
1030    }
1031}
1032
1033// ============================================================================
1034// Leaf conversions and validation (the wire is untrusted)
1035// ============================================================================
1036
1037/// Encodes a [`MontyUuid`] as the wire `Uuid` message (16 raw bytes).
1038pub(crate) fn uuid_to_pb(uuid: &MontyUuid) -> pb::Uuid {
1039    pb::Uuid {
1040        data: uuid.as_bytes().to_vec().into(),
1041    }
1042}
1043
1044/// Validates a wire `Uuid`: exactly 16 bytes, anything else is rejected.
1045fn pb_uuid_to_monty(uuid: &pb::Uuid, field: &'static str) -> Result<MontyUuid, DecodeError> {
1046    MontyUuid::try_from_slice(&uuid.data).ok_or_else(|| {
1047        to_decode_err(ProtoConvertError::InvalidValue {
1048            field,
1049            reason: format!("uuid must be 16 bytes, got {}", uuid.data.len()),
1050        })
1051    })
1052}
1053
1054/// Encodes a builtin [`MontyType`] as the wire `Type` message: only its
1055/// Display name (origin BUILTIN, no id). Class types are [`ClassTypeNode`]s
1056/// and encode via [`encode_class_type`].
1057fn builtin_type_to_pb(t: MontyType) -> pb::Type {
1058    pb::Type {
1059        name: t.to_string(),
1060        origin: pb::TypeOrigin::Builtin as i32,
1061        ..pb::Type::default()
1062    }
1063}
1064
1065/// Validates a wire `Type`, budgeting the class box. Its strings and attribute
1066/// buffers have already been charged during decoding.
1067/// BUILTIN must not carry an id or attrs; SANDBOX/HOST must carry an id.
1068fn type_to_node(ty: pb::Type) -> Result<MontyNode, DecodeError> {
1069    let origin = pb::TypeOrigin::try_from(ty.origin).map_err(|_| {
1070        to_decode_err(ProtoConvertError::InvalidValue {
1071            field: "Type.origin",
1072            reason: format!("unknown origin {}", ty.origin),
1073        })
1074    })?;
1075    let invalid = |reason: &str| {
1076        to_decode_err(ProtoConvertError::InvalidValue {
1077            field: "Type",
1078            reason: reason.to_owned(),
1079        })
1080    };
1081    match origin {
1082        pb::TypeOrigin::Unspecified => Err(invalid("origin must be specified")),
1083        pb::TypeOrigin::Builtin => {
1084            if ty.id.is_some() {
1085                Err(invalid("a builtin type must not carry an id"))
1086            } else if ty.attrs.is_some() {
1087                Err(invalid("a builtin type must not carry attrs"))
1088            } else {
1089                MontyType::from_type_name(&ty.name)
1090                    .map(MontyNode::Type)
1091                    .ok_or_else(|| to_decode_err(ProtoConvertError::UnknownType(ty.name)))
1092            }
1093        }
1094        pb::TypeOrigin::Sandbox | pb::TypeOrigin::Host => {
1095            let id = ty.id.ok_or_else(|| invalid("a class type must carry an id"))?;
1096            Ok(MontyNode::ClassType(decode_budget::boxed(ClassTypeNode {
1097                name: ty.name,
1098                id: pb_uuid_to_monty(&id, "Type.id")?,
1099                host_defined: origin == pb::TypeOrigin::Host,
1100                is_dataclass: ty.is_dataclass,
1101                attrs: ty.attrs.map(|attrs| attrs.0.into_inner()).unwrap_or_default(),
1102            })?))
1103        }
1104    }
1105}
1106
1107/// Encodes a `BigInt` as sign + big-endian magnitude.
1108fn bigint_to_proto(bi: &BigInt) -> pb::BigInt {
1109    let (sign, magnitude) = bi.to_bytes_be();
1110    pb::BigInt {
1111        negative: sign == Sign::Minus,
1112        magnitude: magnitude.into(),
1113    }
1114}
1115
1116/// Decodes sign + big-endian magnitude back to a `BigInt`.
1117///
1118/// An all-zero/empty magnitude decodes to zero regardless of the sign flag —
1119/// `BigInt` normalizes the sign of zero, so no invalid state is possible.
1120fn bigint_from_proto(mut bi: pb::BigInt) -> Result<BigInt, DecodeError> {
1121    let sign = if bi.negative { Sign::Minus } else { Sign::Plus };
1122    // Avoid num-bigint's temporary reversed copy and shrinking allocation for zero padding.
1123    bi.magnitude.reverse();
1124    let len = bi.magnitude.iter().rposition(|&byte| byte != 0).map_or(0, |i| i + 1);
1125    bi.magnitude.truncate(len);
1126    if len > 0 {
1127        // Cover rounding to 32- or 64-bit limbs and Vec's minimum capacity.
1128        decode_budget::charge((len.div_ceil(8) * 8).max(32))?;
1129    }
1130    Ok(BigInt::from_bytes_le(sign, &bi.magnitude))
1131}
1132
1133fn date_to_proto(d: &MontyDate) -> pb::Date {
1134    pb::Date {
1135        year: d.year,
1136        month: u32::from(d.month),
1137        day: u32::from(d.day),
1138    }
1139}
1140
1141fn date_from_proto(d: &pb::Date) -> Result<MontyDate, ProtoConvertError> {
1142    let (year, month, day) = date_fields(d.year, d.month, d.day, ["Date.year", "Date.month", "Date.day"])?;
1143    Ok(MontyDate { year, month, day })
1144}
1145
1146fn datetime_from_proto(dt: pb::DateTime) -> Result<MontyDateTime, ProtoConvertError> {
1147    if dt.offset_seconds.is_none() && dt.timezone_name.is_some() {
1148        return Err(ProtoConvertError::InvalidValue {
1149            field: "DateTime.timezone_name",
1150            reason: "timezone_name requires offset_seconds".to_owned(),
1151        });
1152    }
1153    let (year, month, day) = date_fields(
1154        dt.year,
1155        dt.month,
1156        dt.day,
1157        ["DateTime.year", "DateTime.month", "DateTime.day"],
1158    )?;
1159    Ok(MontyDateTime {
1160        year,
1161        month,
1162        day,
1163        hour: ranged_u8(dt.hour, 0..=23, "DateTime.hour")?,
1164        minute: ranged_u8(dt.minute, 0..=59, "DateTime.minute")?,
1165        second: ranged_u8(dt.second, 0..=59, "DateTime.second")?,
1166        microsecond: bounded(dt.microsecond, 999_999, "DateTime.microsecond")?,
1167        offset_seconds: dt
1168            .offset_seconds
1169            .map(|offset| timezone_offset(offset, "DateTime.offset_seconds"))
1170            .transpose()?,
1171        timezone_name: dt.timezone_name,
1172    })
1173}
1174
1175fn time_from_proto(t: pb::Time) -> Result<MontyTime, ProtoConvertError> {
1176    if t.offset_seconds.is_none() && t.timezone_name.is_some() {
1177        return Err(ProtoConvertError::InvalidValue {
1178            field: "Time.timezone_name",
1179            reason: "timezone_name requires offset_seconds".to_owned(),
1180        });
1181    }
1182    Ok(MontyTime {
1183        hour: ranged_u8(t.hour, 0..=23, "Time.hour")?,
1184        minute: ranged_u8(t.minute, 0..=59, "Time.minute")?,
1185        second: ranged_u8(t.second, 0..=59, "Time.second")?,
1186        microsecond: bounded(t.microsecond, 999_999, "Time.microsecond")?,
1187        offset_seconds: t
1188            .offset_seconds
1189            .map(|offset| timezone_offset(offset, "Time.offset_seconds"))
1190            .transpose()?,
1191        timezone_name: t.timezone_name,
1192        fold: ranged_u8(t.fold, 0..=1, "Time.fold")?,
1193    })
1194}
1195
1196fn complex_to_proto(c: &MontyComplex) -> pb::Complex {
1197    pb::Complex {
1198        real: Some(c.real),
1199        imag: Some(c.imag),
1200    }
1201}
1202
1203/// Both parts are `optional` only so that `-0.0` survives the wire; a frame
1204/// leaving one out is malformed.
1205fn complex_from_proto(c: &pb::Complex) -> Result<MontyComplex, ProtoConvertError> {
1206    Ok(MontyComplex {
1207        real: c.real.ok_or(ProtoConvertError::MissingField("Complex.real"))?,
1208        imag: c.imag.ok_or(ProtoConvertError::MissingField("Complex.imag"))?,
1209    })
1210}
1211
1212fn timedelta_to_proto(td: &MontyTimeDelta) -> pb::TimeDelta {
1213    pb::TimeDelta {
1214        days: td.days,
1215        seconds: td.seconds,
1216        microseconds: td.microseconds,
1217    }
1218}
1219
1220fn timedelta_from_proto(td: &pb::TimeDelta) -> Result<MontyTimeDelta, ProtoConvertError> {
1221    Ok(MontyTimeDelta {
1222        days: td.days,
1223        // out-of-range components would violate `MontyTimeDelta`'s
1224        // documented normalization invariants and corrupt arithmetic
1225        // and formatting once inside the sandbox
1226        seconds: normalized(td.seconds, 86_400, "TimeDelta.seconds")?,
1227        microseconds: normalized(td.microseconds, 1_000_000, "TimeDelta.microseconds")?,
1228    })
1229}
1230
1231/// Validates wire year/month/day fields against the invariants documented on
1232/// `MontyDate`/`MontyDateTime` (year 1..=9999, month 1..=12, day valid for the
1233/// month/year). The wire is untrusted, and an out-of-range date would corrupt
1234/// comparison, arithmetic, and formatting once inside the sandbox.
1235/// `fields` names the year/month/day wire fields for error messages.
1236fn date_fields(year: i32, month: u32, day: u32, fields: [&'static str; 3]) -> Result<(i32, u8, u8), ProtoConvertError> {
1237    let [year_field, month_field, day_field] = fields;
1238    if !(1..=9999).contains(&year) {
1239        return Err(ProtoConvertError::InvalidValue {
1240            field: year_field,
1241            reason: format!("{year} is outside the range 1..=9999"),
1242        });
1243    }
1244    let month = ranged_u8(month, 1..=12, month_field)?;
1245    let day = ranged_u8(day, 1..=u32::from(days_in_month(year, month)), day_field)?;
1246    Ok((year, month, day))
1247}
1248
1249/// Days in a Gregorian month; `month` must already be validated to 1..=12.
1250fn days_in_month(year: i32, month: u8) -> u8 {
1251    let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
1252    match month {
1253        2 if leap => 29,
1254        2 => 28,
1255        4 | 6 | 9 | 11 => 30,
1256        _ => 31,
1257    }
1258}
1259
1260/// Checks a wire `u32` against an inclusive range and narrows it to `u8`.
1261fn ranged_u8(value: u32, range: RangeInclusive<u32>, field: &'static str) -> Result<u8, ProtoConvertError> {
1262    if range.contains(&value) {
1263        Ok(u8::try_from(value).expect("range bounds fit in u8"))
1264    } else {
1265        Err(ProtoConvertError::InvalidValue {
1266            field,
1267            reason: format!("{value} is outside the range {}..={}", range.start(), range.end()),
1268        })
1269    }
1270}
1271
1272/// Checks a wire UTC offset against the range `datetime.timezone`
1273/// accepts, so a forged offset names its own field here rather than surfacing as
1274/// a generic bad value when the sandbox-side constructor rejects it.
1275fn timezone_offset(offset: i32, field: &'static str) -> Result<i32, ProtoConvertError> {
1276    if (MIN_TIMEZONE_OFFSET_SECONDS..=MAX_TIMEZONE_OFFSET_SECONDS).contains(&offset) {
1277        Ok(offset)
1278    } else {
1279        Err(ProtoConvertError::InvalidValue {
1280            field,
1281            reason: format!(
1282                "{offset} is outside the range {MIN_TIMEZONE_OFFSET_SECONDS}..={MAX_TIMEZONE_OFFSET_SECONDS}"
1283            ),
1284        })
1285    }
1286}
1287
1288/// Checks a wire `i32` against the half-open normalized range `0..max`.
1289fn normalized(value: i32, max: i32, field: &'static str) -> Result<i32, ProtoConvertError> {
1290    if (0..max).contains(&value) {
1291        Ok(value)
1292    } else {
1293        Err(ProtoConvertError::InvalidValue {
1294            field,
1295            reason: format!("{value} is outside the normalized range 0..{max}"),
1296        })
1297    }
1298}
1299
1300/// Checks a wire `u32` against an inclusive upper bound.
1301fn bounded(value: u32, max: u32, field: &'static str) -> Result<u32, ProtoConvertError> {
1302    if value <= max {
1303        Ok(value)
1304    } else {
1305        Err(ProtoConvertError::InvalidValue {
1306            field,
1307            reason: format!("{value} exceeds maximum {max}"),
1308        })
1309    }
1310}