ridl_ir/lib.rs
1//! RIDL intermediate representation.
2//!
3//! The v2 schema (`proto/ridl/ir/v2/ir.proto`) is compiled from its protobuf
4//! source by `build.rs` (protox + prost-build, ADR-0006 decision 3) and
5//! exposed as [`v2`]. v2 is the typl surface plus the ridl interaction layer
6//! (ridl language reference §3–§14) with exact decimal values — every numeric
7//! value is a canonical decimal string, never a floating-point field (ADR-0007
8//! decision 9, ADR-0008 decision 12). The v1 schema was removed when its last
9//! consumer moved to v2 (task 6 of the E2 plan), mirroring the E1 v0→v1
10//! retirement.
11
12pub mod v2 {
13 //! IR v2 — the typl surface plus the ridl interaction layer (ridl
14 //! language reference §3–§14) with exact decimal values (ADR-0007
15 //! decision 9, ADR-0008 decision 12).
16
17 include!(concat!(env!("OUT_DIR"), "/ridl.ir.v2.rs"));
18 // The canonical protobuf JSON serde impls, generated by pbjson-build in
19 // `build.rs` from the same schema compilation as the types above
20 // (ADR-0014 decision 14).
21 //
22 // The file holds only trait impls, so it sits in a private module to scope
23 // one lint allowance to generated code: pbjson-build 0.9.0 writes
24 // `write!(formatter, "…", &FIELDS)`, which clippy 1.98 reports as
25 // `useless_borrows_in_formatting`. The build script writes the file into
26 // `OUT_DIR`, so an edit to it does not last and the lint cannot be repaired
27 // in source. A lint attribute on the `include!` itself is ignored by rustc.
28 // `expect` rather than `allow`: when the lint no longer fires here, clippy
29 // reports the expectation as unfulfilled. Then remove this module and
30 // include the file directly in `v2` again.
31 #[expect(clippy::useless_borrows_in_formatting)]
32 mod serde_impls {
33 use super::*;
34
35 include!(concat!(env!("OUT_DIR"), "/ridl.ir.v2.serde.rs"));
36 }
37
38 /// The descriptor pool over the compiled IR schema — the reflection data
39 /// the prototext encoding needs (ADR-0014 decision 7; since decision 14
40 /// JSON goes through the pbjson-generated impls and no longer uses the
41 /// pool). Binary needs none of it.
42 /// `build.rs` writes the `FileDescriptorSet` to `OUT_DIR` from the same
43 /// `protox` compilation that generates the types above, so the pool and
44 /// the types cannot disagree; every `expect` on this path leans on that.
45 static DESCRIPTOR_POOL: std::sync::LazyLock<prost_reflect::DescriptorPool> =
46 std::sync::LazyLock::new(|| {
47 prost_reflect::DescriptorPool::decode(
48 include_bytes!(concat!(env!("OUT_DIR"), "/ir_descriptor.binpb")).as_slice(),
49 )
50 .expect("the embedded descriptor set decodes: build.rs wrote it from the schema compilation that generated these types")
51 });
52
53 /// The descriptor of one root message of the compiled schema, by its
54 /// full name.
55 fn descriptor(name: &str) -> prost_reflect::MessageDescriptor {
56 DESCRIPTOR_POOL
57 .get_message_by_name(name)
58 .unwrap_or_else(|| panic!("{name} is declared by the compiled schema"))
59 }
60
61 /// The `Package` message descriptor — the entry point of the prototext
62 /// encoder and decoder, the one reflection path left in this module.
63 pub(crate) fn package_descriptor() -> prost_reflect::MessageDescriptor {
64 descriptor("ridl.ir.v2.Package")
65 }
66
67 /// The `System` message descriptor (`system.proto`), the prototext entry
68 /// point of the rsdl system layer.
69 pub(crate) fn system_descriptor() -> prost_reflect::MessageDescriptor {
70 descriptor("ridl.ir.v2.System")
71 }
72
73 /// The `ridl.codegen.v1.CodegenRequest` message descriptor — the entry
74 /// point of the request reader that ignores unknown keys.
75 pub(crate) fn codegen_request_descriptor() -> prost_reflect::MessageDescriptor {
76 descriptor("ridl.codegen.v1.CodegenRequest")
77 }
78
79 /// The `ridl.codegen.v1.Model` message descriptor — the prototext entry
80 /// point of the lowered codegen model, from the same pool, because
81 /// `build.rs` compiles both schemas in one `protox` call.
82 pub(crate) fn codegen_model_descriptor() -> prost_reflect::MessageDescriptor {
83 descriptor("ridl.codegen.v1.Model")
84 }
85
86 /// Rebuilds a message as a `DynamicMessage` over its descriptor — the
87 /// step `prost-reflect` needs before rendering a text encoding.
88 /// Transcoding goes through the wire encoding, whose decoder enforces
89 /// prost's fixed recursion limit, so a package whose composite nesting
90 /// crosses that limit fails here — an input-dependent failure, not
91 /// schema drift (ADR-0014 decision 12).
92 fn transcode<M: prost::Message>(
93 descriptor: prost_reflect::MessageDescriptor,
94 message: &M,
95 ) -> Result<prost_reflect::DynamicMessage, prost::DecodeError> {
96 let mut dynamic = prost_reflect::DynamicMessage::new(descriptor);
97 dynamic.transcode_from(message)?;
98 Ok(dynamic)
99 }
100
101 /// Derives the synthesized transport identity of an inline `T | E`
102 /// result union (ADR-0008 decision 4): the enclosing interface name plus
103 /// the interaction ordinal plus the ordered arm references. The single
104 /// derivation every consumer — backends and the diff classifier — calls,
105 /// so the identity stays stable under compatible evolution.
106 pub fn fallible_transport_identity(
107 interface: &str,
108 ordinal: u32,
109 fallible: &FallibleType,
110 ) -> String {
111 format!(
112 "{interface}#{ordinal}:{ok}|{err}",
113 ok = fallible.ok,
114 err = fallible.err
115 )
116 }
117
118 /// The error [`to_json_pretty`] and [`to_text_format`] return. The
119 /// serialization surface is fallible on purpose (ADR-0014 decisions 12
120 /// and 14), and the two encodings now fail for different causes, so each
121 /// carries its own variant — its `Display` names the encoding, so a
122 /// build requesting several IR emits attributes each failure to its own
123 /// artifact.
124 #[derive(Debug)]
125 pub enum SerializeError {
126 /// Canonical protobuf JSON (ADR-0014 decision 14): the
127 /// pbjson-generated `Serialize` impl writes the typed message
128 /// directly — no transcode, so no message-level recursion limit —
129 /// and its one error path is an `i32` enum field holding a
130 /// discriminant outside the schema.
131 Json(serde_json::Error),
132 /// Prototext (ADR-0014 decision 12): the transcode into the dynamic
133 /// message goes through the wire encoding, whose decoder enforces
134 /// prost's fixed recursion limit, and legal source can nest
135 /// composites past it.
136 Text(prost::DecodeError),
137 }
138
139 impl std::fmt::Display for SerializeError {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 match self {
142 Self::Json(source) => write!(
143 f,
144 "cannot render the package as canonical protobuf JSON: {source}; the known \
145 cause is an enum field holding a discriminant outside the schema"
146 ),
147 Self::Text(source) => write!(
148 f,
149 "cannot render the package as prototext: {source}; the known cause \
150 is composite nesting deeper than the transcoding decoder's recursion limit"
151 ),
152 }
153 }
154 }
155
156 impl std::error::Error for SerializeError {
157 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
158 match self {
159 Self::Json(source) => Some(source),
160 Self::Text(source) => Some(source),
161 }
162 }
163 }
164
165 /// Renders a package as pretty-printed canonical protobuf JSON — the one
166 /// dialect every IR surface carries: the `--emit ir-json` artifact, the
167 /// baselines, and the goldens (ADR-0014 decision 1) — through the
168 /// pbjson-generated `Serialize` impl (decision 14), byte-identical to
169 /// what the retired reflection path rendered.
170 ///
171 /// A field holding its default is emitted rather than skipped (decision
172 /// 2, `emit_fields()` in `build.rs`); an unset proto3 `optional` field
173 /// is omitted entirely, never rendered as `null` (the answer to that
174 /// decision's open item). 64-bit fields render as strings, the canonical
175 /// mapping JavaScript consumers need (decision 8).
176 ///
177 /// Still fallible (ADR-0014 decision 14, amending decision 12), but the
178 /// error path changed rather than survived: the generated impl writes
179 /// the typed message directly, so the transcode's depth error is gone,
180 /// and the one error path it has is new — an `i32` enum field holding a
181 /// discriminant outside the schema, which the retired reflection path
182 /// serialized successfully as its bare number. The checker never
183 /// produces one, but the value is data, not schema, so the failure is
184 /// returned rather than panicked on.
185 pub fn to_json_pretty(package: &Package) -> Result<String, SerializeError> {
186 render_json(package)
187 }
188
189 /// Renders a lowered system (`system.proto`, rsdl reference §13) as
190 /// pretty-printed canonical protobuf JSON — the `<pkg.Name>.system.json`
191 /// artifact, under the rules of [`to_json_pretty`].
192 pub fn system_to_json_pretty(system: &System) -> Result<String, SerializeError> {
193 render_json(system)
194 }
195
196 /// The one JSON writer behind [`to_json_pretty`] and
197 /// [`system_to_json_pretty`]: the pbjson-generated `Serialize` impl of
198 /// the message, pretty-printed.
199 pub(crate) fn render_json<M: serde::Serialize>(message: &M) -> Result<String, SerializeError> {
200 let mut buf = Vec::new();
201 let mut serializer = serde_json::Serializer::pretty(&mut buf);
202 serde::Serialize::serialize(message, &mut serializer).map_err(SerializeError::Json)?;
203 Ok(String::from_utf8(buf).expect("serde_json emits UTF-8"))
204 }
205
206 /// The nesting ceiling `from_json` enforces, in JSON bracket levels.
207 ///
208 /// It cannot bind on IR this toolchain produces, and the bound is a
209 /// measurement rather than a guess. The parser refuses type nesting past
210 /// 128 levels (FORM-102, `MAX_TYPE_DEPTH` in `ridl-syntax`), and the
211 /// deepest package that limit admits emits JSON **516 brackets** deep —
212 /// so 1,000 leaves a factor of 1.9 over anything `ridlc` can write, and
213 /// the deepest nesting in the corpus is single digits. The figure this
214 /// comment carried until 2026-09-22, 262 brackets and a factor of 3.8,
215 /// was the array shape; the tuple shape costs four brackets per source
216 /// level rather than two and is the one that binds (the IR
217 /// specification, "The nesting bound").
218 ///
219 /// It exists for input this toolchain did not write: a hand-edited
220 /// baseline, or a snapshot from elsewhere. Past the stack ceiling the
221 /// failure mode is a stack-overflow abort, which no caller can catch, so
222 /// the cap turns an abort into a diagnostic (ADR-0014 decisions 12
223 /// and 14).
224 pub(crate) const MAX_JSON_NESTING: usize = 1_000;
225
226 /// The stack `from_json` parses on, in bytes. An explicit size makes the
227 /// depth that fits a constant of this crate rather than of the ambient
228 /// stack, which differs between debug and release builds and between
229 /// platforms — the same input parses everywhere or nowhere.
230 const JSON_PARSE_STACK: usize = 16 * 1024 * 1024;
231
232 /// The maximum bracket nesting of `text`: the largest number of `{` and
233 /// `[` open at once, with string literals skipped — a bracket inside a
234 /// string must not count, an escaped quote (`\"`) must not end the
235 /// string, and an escaped backslash (`\\`) must not disarm the real
236 /// closing quote after it. Runs before the parse in [`from_json`], so it
237 /// tolerates input that is not valid JSON; a stray closer never
238 /// underflows the running depth.
239 pub(crate) fn max_json_nesting(text: &str) -> usize {
240 let mut depth = 0usize;
241 let mut deepest = 0usize;
242 let mut in_string = false;
243 let mut escaped = false;
244 for byte in text.bytes() {
245 if in_string {
246 if escaped {
247 escaped = false;
248 } else if byte == b'\\' {
249 escaped = true;
250 } else if byte == b'"' {
251 in_string = false;
252 }
253 } else {
254 match byte {
255 b'"' => in_string = true,
256 b'{' | b'[' => {
257 depth += 1;
258 deepest = deepest.max(depth);
259 }
260 b'}' | b']' => depth = depth.saturating_sub(1),
261 _ => {}
262 }
263 }
264 }
265 deepest
266 }
267
268 /// Reads a package from canonical protobuf JSON — the inverse of
269 /// [`to_json_pretty`], through the pbjson-generated `Deserialize` impl
270 /// (ADR-0014 decision 14). Unknown fields are rejected (the generated
271 /// deserializer's default; `ignore_unknown_fields()` stays unset in
272 /// `build.rs`), so a snapshot written against a different schema fails
273 /// loudly rather than dropping fields silently.
274 ///
275 /// The generated impl recurses per JSON level, so `serde_json`'s own
276 /// recursion limit of 128 levels is disabled — it would bind far below
277 /// this crate's documented ceiling — and two guards replace it
278 /// (ADR-0014 decision 14):
279 ///
280 /// - input nesting is measured first and refused past
281 /// `MAX_JSON_NESTING`, returning a diagnostic where unbounded
282 /// recursion would eventually abort on a stack overflow no caller can
283 /// catch;
284 /// - the parse runs on a thread of `JSON_PARSE_STACK` bytes, so the
285 /// ceiling behaves identically across build profiles and platforms
286 /// instead of tracking the ambient stack. On the wasm family there is
287 /// no such thread — see the branch below — and the cap alone guards
288 /// the parse.
289 pub fn from_json(text: &str) -> Result<Package, serde_json::Error> {
290 read_json(text)
291 }
292
293 /// Reads a lowered system from canonical protobuf JSON — the inverse of
294 /// [`system_to_json_pretty`], under the rules and guards of
295 /// [`from_json`].
296 pub fn system_from_json(text: &str) -> Result<System, serde_json::Error> {
297 read_json(text)
298 }
299
300 /// The one JSON reader behind [`from_json`] and [`system_from_json`]:
301 /// the nesting cap, then the parse on its own stack.
302 pub(crate) fn read_json<M>(text: &str) -> Result<M, serde_json::Error>
303 where
304 M: serde::de::DeserializeOwned + Send,
305 {
306 check_nesting(text)?;
307 on_parse_stack(|| parse_json(text))
308 }
309
310 /// Reads a request-shaped message the way [`read_json`] does, except that
311 /// an object key the schema does not declare is ignored at every nesting
312 /// level. The same nesting cap and parse stack apply.
313 ///
314 /// The text is read once into a `serde_json::Value` that keeps only the
315 /// keys `descriptor` declares at each point (by JSON name or proto name),
316 /// including inside repeated and map message values, and the value is
317 /// then read by the generated deserializer of `M`. That reader still
318 /// rejects what it rejects for the strict path: a value of the wrong
319 /// type, an unknown enum name, and a field written twice. Only an unknown
320 /// key is dropped; an unknown enum name is an error, because it changes
321 /// the meaning of a known field. A value of a `google.protobuf` message
322 /// is left untouched.
323 ///
324 /// An error from the second phase, the generated deserializer reading the
325 /// filtered value, carries no line and column, because the value no longer
326 /// has a position in the text.
327 pub(crate) fn read_json_ignoring_unknown<M>(
328 descriptor: prost_reflect::MessageDescriptor,
329 text: &str,
330 ) -> Result<M, serde_json::Error>
331 where
332 M: serde::de::DeserializeOwned + Send,
333 {
334 use serde::de::DeserializeSeed as _;
335 check_nesting(text)?;
336 on_parse_stack(|| {
337 let mut deserializer = serde_json::Deserializer::from_str(text);
338 deserializer.disable_recursion_limit();
339 let known = Shape::Message(descriptor).deserialize(&mut deserializer)?;
340 deserializer.end()?;
341 serde::Deserialize::deserialize(known)
342 })
343 }
344
345 /// What a JSON value is expected to be, as far as dropping unknown keys
346 /// needs to know.
347 #[derive(Clone)]
348 enum Shape {
349 /// A message: an object whose unknown keys are dropped.
350 Message(prost_reflect::MessageDescriptor),
351 /// A repeated field: an array of the inner shape.
352 List(Box<Shape>),
353 /// A map field: an object whose values have the inner shape.
354 Map(Box<Shape>),
355 /// Anything else: kept as read.
356 Opaque,
357 }
358
359 impl Shape {
360 /// The shape of the value of one field.
361 fn of(field: &prost_reflect::FieldDescriptor) -> Shape {
362 use prost_reflect::Kind;
363 if field.is_map() {
364 let Kind::Message(entry) = field.kind() else {
365 return Shape::Opaque;
366 };
367 return Shape::Map(Box::new(Shape::of_kind(
368 &entry.map_entry_value_field().kind(),
369 )));
370 }
371 let item = Shape::of_kind(&field.kind());
372 if field.is_list() {
373 Shape::List(Box::new(item))
374 } else {
375 item
376 }
377 }
378
379 fn of_kind(kind: &prost_reflect::Kind) -> Shape {
380 match kind {
381 prost_reflect::Kind::Message(message)
382 if !message.full_name().starts_with("google.protobuf.") =>
383 {
384 Shape::Message(message.clone())
385 }
386 _ => Shape::Opaque,
387 }
388 }
389 }
390
391 impl<'de> serde::de::DeserializeSeed<'de> for Shape {
392 type Value = serde_json::Value;
393
394 fn deserialize<D: serde::Deserializer<'de>>(
395 self,
396 deserializer: D,
397 ) -> Result<Self::Value, D::Error> {
398 match self {
399 Shape::Opaque => serde::Deserialize::deserialize(deserializer),
400 shape => deserializer.deserialize_any(ShapeVisitor(shape)),
401 }
402 }
403 }
404
405 struct ShapeVisitor(Shape);
406
407 impl<'de> serde::de::Visitor<'de> for ShapeVisitor {
408 type Value = serde_json::Value;
409
410 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411 formatter.write_str(match self.0 {
412 Shape::List(_) => "an array",
413 _ => "an object",
414 })
415 }
416
417 // `null` is passed on: the generated reader decides what it means.
418 fn visit_unit<E>(self) -> Result<Self::Value, E> {
419 Ok(serde_json::Value::Null)
420 }
421
422 fn visit_seq<A: serde::de::SeqAccess<'de>>(
423 self,
424 mut seq: A,
425 ) -> Result<Self::Value, A::Error> {
426 let Shape::List(item) = self.0 else {
427 return Err(serde::de::Error::invalid_type(
428 serde::de::Unexpected::Seq,
429 &self,
430 ));
431 };
432 let mut items = Vec::new();
433 while let Some(value) = seq.next_element_seed((*item).clone())? {
434 items.push(value);
435 }
436 Ok(serde_json::Value::Array(items))
437 }
438
439 fn visit_map<A: serde::de::MapAccess<'de>>(
440 self,
441 mut map: A,
442 ) -> Result<Self::Value, A::Error> {
443 use serde::de::Error as _;
444 let mut out = serde_json::Map::new();
445 match &self.0 {
446 Shape::Message(descriptor) => {
447 let mut seen = std::collections::HashSet::new();
448 while let Some(key) = map.next_key::<String>()? {
449 let field = descriptor
450 .get_field_by_json_name(&key)
451 .or_else(|| descriptor.get_field_by_name(&key));
452 match field {
453 Some(field) => {
454 if !seen.insert(field.number()) {
455 return Err(A::Error::custom(format!(
456 "duplicate field `{key}`"
457 )));
458 }
459 let value = map.next_value_seed(Shape::of(&field))?;
460 out.insert(key, value);
461 }
462 None => {
463 map.next_value::<serde::de::IgnoredAny>()?;
464 }
465 }
466 }
467 }
468 Shape::Map(value_shape) => {
469 while let Some(key) = map.next_key::<String>()? {
470 let value = map.next_value_seed((**value_shape).clone())?;
471 if out.insert(key.clone(), value).is_some() {
472 return Err(A::Error::custom(format!("duplicate key `{key}`")));
473 }
474 }
475 }
476 Shape::List(_) | Shape::Opaque => {
477 return Err(A::Error::invalid_type(serde::de::Unexpected::Map, &self));
478 }
479 }
480 Ok(serde_json::Value::Object(out))
481 }
482 }
483
484 /// Refuses input that nests past `MAX_JSON_NESTING`.
485 fn check_nesting(text: &str) -> Result<(), serde_json::Error> {
486 if max_json_nesting(text) > MAX_JSON_NESTING {
487 return Err(<serde_json::Error as serde::de::Error>::custom(format!(
488 "the input nests deeper than {MAX_JSON_NESTING} JSON levels, the ceiling this \
489 reader enforces (ADR-0014 decision 14); real IR nests orders of magnitude \
490 shallower"
491 )));
492 }
493 Ok(())
494 }
495
496 /// Runs a parse on its own stack of `JSON_PARSE_STACK` bytes, or in line
497 /// on the wasm family.
498 fn on_parse_stack<R: Send>(parse: impl FnOnce() -> R + Send) -> R {
499 if cfg!(target_family = "wasm") {
500 // The wasm family has no spawnable threads: `spawn_scoped`
501 // returns `Err(Unsupported)` at run time on
502 // `wasm32-unknown-unknown`, the `just wasm-check` target, so a
503 // spawn here would turn every call into a panic. The parse runs
504 // in line instead, on the caller's stack. What this path loses
505 // is the deterministic stack — the ceiling is the ambient stack
506 // — and the `MAX_JSON_NESTING` cap is the guard that matters: it
507 // is what turns an abort into an error.
508 parse()
509 } else {
510 std::thread::scope(|scope| {
511 let handle = std::thread::Builder::new()
512 .stack_size(JSON_PARSE_STACK)
513 .spawn_scoped(scope, parse)
514 .expect("the JSON parse thread spawns");
515 match handle.join() {
516 Ok(result) => result,
517 Err(payload) => std::panic::resume_unwind(payload),
518 }
519 })
520 }
521 }
522
523 /// The parse both branches of [`from_json`] share; only the stack that
524 /// carries it differs. `serde_json`'s own recursion limit is disabled
525 /// here, so the caller must have applied the `MAX_JSON_NESTING` cap
526 /// first.
527 fn parse_json<M: serde::de::DeserializeOwned>(text: &str) -> Result<M, serde_json::Error> {
528 let mut deserializer = serde_json::Deserializer::from_str(text);
529 deserializer.disable_recursion_limit();
530 let message: M = serde::Deserialize::deserialize(&mut deserializer)?;
531 deserializer.end()?;
532 Ok(message)
533 }
534
535 /// Renders a package in the protobuf text format — the inspection
536 /// encoding (ADR-0014 decision 9): emittable, but not a recommended
537 /// interchange form. Rendered `pretty`, with a field holding its default
538 /// emitted rather than skipped (decision 2) and message fields printed
539 /// in schema index order, so the output ordering is deterministic rather
540 /// than incidental (decision 8).
541 ///
542 /// Fallible on purpose (ADR-0014 decision 12): the transcode into the
543 /// dynamic message goes through the wire encoding, and a package whose
544 /// composite nesting crosses prost's recursion limit fails there. That
545 /// input is legal source, so the failure is returned rather than
546 /// panicked on. JSON lost this failure mode when it moved off the
547 /// transcode (decision 14); prototext keeps it.
548 pub fn to_text_format(package: &Package) -> Result<String, SerializeError> {
549 render_text_for(package_descriptor(), package)
550 }
551
552 /// Renders a lowered system in the protobuf text format — the
553 /// `<pkg.Name>.system.txtpb` artifact, under the rules of
554 /// [`to_text_format`].
555 pub fn system_to_text_format(system: &System) -> Result<String, SerializeError> {
556 render_text_for(system_descriptor(), system)
557 }
558
559 /// The one prototext writer behind [`to_text_format`] and
560 /// [`system_to_text_format`].
561 pub(crate) fn render_text_for<M: prost::Message>(
562 descriptor: prost_reflect::MessageDescriptor,
563 message: &M,
564 ) -> Result<String, SerializeError> {
565 let dynamic = transcode(descriptor, message).map_err(SerializeError::Text)?;
566 Ok(dynamic.to_text_format_with_options(
567 &prost_reflect::text_format::FormatOptions::new()
568 .pretty(true)
569 .skip_default_fields(false)
570 .print_message_fields_in_index_order(true),
571 ))
572 }
573
574 /// The error [`from_text_format`] returns: the input does not parse as
575 /// prototext, or the parsed message does not transcode into the
576 /// generated types. The transcode failure is the read direction of the
577 /// recursion-limit failure mode (ADR-0014 decision 12) — input-dependent,
578 /// so it is mapped into this return rather than expected on.
579 #[cfg(test)]
580 #[derive(Debug)]
581 pub(crate) enum TextFormatError {
582 /// The input is not valid prototext for the `Package` schema.
583 Parse(prost_reflect::text_format::ParseError),
584 /// The parsed message cannot be rebuilt as a typed `Package`.
585 Transcode(prost::DecodeError),
586 }
587
588 #[cfg(test)]
589 impl std::fmt::Display for TextFormatError {
590 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
591 match self {
592 Self::Parse(source) => {
593 write!(f, "cannot parse the text as an IR package: {source}")
594 }
595 Self::Transcode(source) => write!(
596 f,
597 "cannot rebuild the parsed prototext as a package: {source}; the known cause \
598 is composite nesting deeper than the transcoding decoder's recursion limit"
599 ),
600 }
601 }
602 }
603
604 #[cfg(test)]
605 impl std::error::Error for TextFormatError {
606 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
607 match self {
608 Self::Parse(source) => Some(source),
609 Self::Transcode(source) => Some(source),
610 }
611 }
612 }
613
614 /// Reads a package from the protobuf text format — the inverse of
615 /// [`to_text_format`], kept because without it the prototext emit has no
616 /// round-trip test, and a write path with no read path is untested by
617 /// construction (ADR-0014 decision 7).
618 ///
619 /// **Deliberately not public.** `prost-reflect`'s text parser recurses per
620 /// message level with frames large enough that a debug build exhausts a
621 /// 2 MiB stack at roughly 45 levels of nesting — *below* prost's recursion
622 /// limit of 100, so on that path the error return below is unreachable and
623 /// the process aborts instead. A stack overflow cannot be caught, so the
624 /// hazard is contained by reach rather than handled: nothing in the
625 /// toolchain reads prototext, `ridl diff` and `ridl check --baseline`
626 /// refuse the encoding by name (ADR-0014 decision 5), and this function is
627 /// compiled only for this crate's tests. The tests that exercise it run on an
628 /// explicitly sized stack (see `with_sized_stack`). Making it public again
629 /// means giving it a stack strategy first — driftsys/ridl#218.
630 ///
631 /// A package whose nesting crosses prost's limit *before* the stack runs
632 /// out fails in the transcode out of the dynamic message; that failure is
633 /// mapped into the error return, not expected on (ADR-0014 decision 12).
634 #[cfg(test)]
635 pub(crate) fn from_text_format(text: &str) -> Result<Package, TextFormatError> {
636 parse_text(package_descriptor(), text)
637 }
638
639 /// Reads a lowered system from the protobuf text format — the inverse of
640 /// [`system_to_text_format`], test-only for the reason
641 /// [`from_text_format`] states.
642 #[cfg(test)]
643 pub(crate) fn system_from_text_format(text: &str) -> Result<System, TextFormatError> {
644 parse_text(system_descriptor(), text)
645 }
646
647 #[cfg(test)]
648 fn parse_text<M: prost::Message + Default>(
649 descriptor: prost_reflect::MessageDescriptor,
650 text: &str,
651 ) -> Result<M, TextFormatError> {
652 let dynamic = prost_reflect::DynamicMessage::parse_text_format(descriptor, text)
653 .map_err(TextFormatError::Parse)?;
654 dynamic.transcode_to().map_err(TextFormatError::Transcode)
655 }
656
657 /// Encodes a package in the protobuf binary wire format — a derived
658 /// encoding since ADR-0014 decision 9's 2026-09-22 amendment, whose
659 /// reader stops 100 message levels below the root where the canonical
660 /// encoding has no such bound (the IR specification, "The derived
661 /// encodings"). Binary needs no descriptors: prost's generated encoding
662 /// is schema-faithful by construction.
663 pub fn to_binary(package: &Package) -> Vec<u8> {
664 prost::Message::encode_to_vec(package)
665 }
666
667 /// Decodes a package from the protobuf binary wire format — the inverse
668 /// of [`to_binary`].
669 pub fn from_binary(bytes: &[u8]) -> Result<Package, prost::DecodeError> {
670 prost::Message::decode(bytes)
671 }
672
673 /// Encodes a lowered system in the protobuf binary wire format — the
674 /// `<pkg.Name>.system.binpb` artifact (ADR-0014 decision 9).
675 pub fn system_to_binary(system: &System) -> Vec<u8> {
676 prost::Message::encode_to_vec(system)
677 }
678
679 /// Decodes a lowered system from the protobuf binary wire format — the
680 /// inverse of [`system_to_binary`].
681 pub fn system_from_binary(bytes: &[u8]) -> Result<System, prost::DecodeError> {
682 prost::Message::decode(bytes)
683 }
684
685 /// `pkg.Name` — how a system, a component or a distribution is referred
686 /// to across the system layer (`system.proto`); a name with no package,
687 /// the implicit component of a lone service (rsdl §6), is its own
688 /// qualified name.
689 fn qualified(package: &str, name: &str) -> String {
690 if package.is_empty() {
691 name.to_string()
692 } else {
693 format!("{package}.{name}")
694 }
695 }
696
697 impl System {
698 /// The system's qualified name, `pkg.Name` — the base name of its
699 /// artifacts.
700 pub fn qualified_name(&self) -> String {
701 qualified(&self.package, &self.name)
702 }
703 }
704
705 impl Component {
706 /// The name every reference to this component uses: `pkg.Name` for a
707 /// declared component, the service's dotted name for an implicit one.
708 pub fn qualified_name(&self) -> String {
709 qualified(&self.package, &self.name)
710 }
711 }
712
713 impl Distribution {
714 /// The name `Distribution.depends_on` and `Installation.distribution`
715 /// use.
716 pub fn qualified_name(&self) -> String {
717 qualified(&self.package, &self.name)
718 }
719 }
720
721 /// One interface shape of a package (ridl §14.0): a declared `interface`,
722 /// or the inline shape of a `service` (§14.5).
723 ///
724 /// **[`Package::interfaces`] is not the complete set.** A `service`
725 /// declared with an inline body carries a full [`Interface`] inside its
726 /// shape list (the single `INLINE` slot, ADR-0015 decision 14), which
727 /// lives outside `interfaces`; a consumer that walks `interfaces` alone
728 /// silently misses it. Six defects of exactly that shape were found
729 /// independently across E2 — observer-stub lowering, both backends'
730 /// transport identity, `ridl test`'s report, the Rust backend's collision
731 /// check, and the desk check's span index.
732 /// [`Package::shapes`] is the one walk that sees both, the way
733 /// [`fallible_transport_identity`] is the one transport-identity
734 /// derivation.
735 ///
736 /// This view is deliberately not a bare `&Interface`, because two of an
737 /// inline shape's own fields are empty by construction and reading them
738 /// is what produced two of those six defects:
739 ///
740 /// - [`Interface::name`] is `""` for an inline shape, so [`Self::name`]
741 /// carries the **identity** name instead — the interface's own name, or
742 /// the owning service's dotted global name. That is the name the diff
743 /// paths, the observer-stub scoping, and both backends' identity fields
744 /// already use.
745 /// - [`Interface::visibility`] is `VISIBILITY_UNSPECIFIED` for an inline
746 /// shape; the owning [`Service`] carries the authoritative one, which
747 /// [`Self::visibility`] reads.
748 ///
749 /// The generated *type* name is not derived here on purpose: mangling is
750 /// language-specific and stays with each backend.
751 #[derive(Debug, Clone, Copy, PartialEq)]
752 pub struct InterfaceShape<'a> {
753 /// The name this shape is known by outside the package: an
754 /// `interface` declaration's own name, or the owning service's dotted
755 /// global name. Never `Interface::name` for an inline shape.
756 pub name: &'a str,
757 /// The interface body — its interactions and its doc envelope.
758 pub interface: &'a Interface,
759 /// The owning service, for an inline shape; `None` for a declared
760 /// `interface`.
761 pub service: Option<&'a Service>,
762 }
763
764 impl InterfaceShape<'_> {
765 /// The authoritative visibility of this shape: the owning service's
766 /// for an inline shape (an inline shape's own field is
767 /// `VISIBILITY_UNSPECIFIED` by construction), the interface's own
768 /// otherwise.
769 pub fn visibility(&self) -> i32 {
770 match self.service {
771 Some(service) => service.visibility,
772 None => self.interface.visibility,
773 }
774 }
775
776 /// `true` when this shape is the inline body of a `service`.
777 pub fn is_inline(&self) -> bool {
778 self.service.is_some()
779 }
780 }
781
782 impl Package {
783 /// Every interface shape the package carries — the declared
784 /// interfaces and the inline shapes of its services. See
785 /// [`InterfaceShape`] for why walking [`Package::interfaces`] alone is
786 /// a defect.
787 ///
788 /// The order is the one every consumer already walked: the declared
789 /// interfaces in source order, then the services in source order. A
790 /// shape-list entry that names an interface yields nothing — its
791 /// target is a declared interface and is already in the sequence, so
792 /// yielding it again would visit one shape twice; a service composing
793 /// several interfaces (ADR-0015 decision 12) therefore contributes
794 /// nothing at all. A tombstone slot names no shape. Only the `INLINE`
795 /// slot of an inline-form service carries an interface of its own,
796 /// and that is what this walk yields.
797 pub fn shapes(&self) -> impl Iterator<Item = InterfaceShape<'_>> {
798 let named = self.interfaces.iter().map(|interface| InterfaceShape {
799 name: &interface.name,
800 interface,
801 service: None,
802 });
803 let inline = self.services.iter().flat_map(|service| {
804 service
805 .shapes
806 .iter()
807 .filter_map(move |slot| match slot.kind.as_ref()? {
808 service_shape::Kind::Inline(interface) => Some(InterfaceShape {
809 name: &service.name,
810 interface,
811 service: Some(service),
812 }),
813 service_shape::Kind::InterfaceRef(_) => None,
814 })
815 });
816 named.chain(inline)
817 }
818 }
819
820 /// Every package named by a type reference in `package`.
821 ///
822 /// A resolved type-reference string is the fully qualified `pkg.Name` for
823 /// a cross-package reference and the bare `Name` for a same-package one,
824 /// never an import alias — the canonical form stated in
825 /// `proto/ridl/ir/v2/ir.proto`, which also enumerates the fields carrying
826 /// one. **That enumeration and this walk are edited together.** A
827 /// reference-bearing field added there and not read here makes the package
828 /// it names invisible to every caller asking what a package depends on.
829 ///
830 /// Every `oneof` below is matched exhaustively with no wildcard arm, so a
831 /// variant added later fails to compile here rather than going unread.
832 pub fn referenced_packages(package: &Package) -> std::collections::BTreeSet<String> {
833 let mut found = std::collections::BTreeSet::new();
834 for decl in &package.decls {
835 walk_decl(decl, &mut found);
836 }
837 for interface in &package.interfaces {
838 for interaction in &interface.interactions {
839 walk_decl(interaction, &mut found);
840 }
841 }
842 for service in &package.services {
843 for slot in &service.shapes {
844 match &slot.kind {
845 Some(service_shape::Kind::InterfaceRef(reference)) => {
846 qualifier(reference, &mut found);
847 }
848 Some(service_shape::Kind::Inline(interface)) => {
849 for interaction in &interface.interactions {
850 walk_decl(interaction, &mut found);
851 }
852 }
853 None => {}
854 }
855 }
856 }
857 found
858 }
859
860 /// Records the package qualifier of a dotted reference. A bare reference
861 /// is same-package and contributes nothing.
862 fn qualifier(reference: &str, found: &mut std::collections::BTreeSet<String>) {
863 if let Some((package, _)) = reference.rsplit_once('.') {
864 found.insert(package.to_string());
865 }
866 }
867
868 /// Records every reference in one declaration — a package-level one or an
869 /// interaction inside an interface, which share the `Decl` envelope.
870 fn walk_decl(decl: &Decl, found: &mut std::collections::BTreeSet<String>) {
871 match &decl.kind {
872 Some(decl::Kind::TypeDef(type_def)) => walk_type_def(type_def, found),
873 Some(decl::Kind::ConstDef(const_def)) => {
874 if let Some(reference) = &const_def.type_ref {
875 qualifier(reference, found);
876 }
877 }
878 Some(decl::Kind::StructDef(struct_def)) => {
879 for member in &struct_def.members {
880 match &member.member {
881 Some(struct_member::Member::Field(field)) => {
882 if let Some(field_type) = &field.r#type {
883 walk_field_type(field_type, found);
884 }
885 }
886 // A tombstone occupies an ordinal and names no type.
887 Some(struct_member::Member::Reserved(_)) | None => {}
888 }
889 }
890 }
891 // An enum's variants are integers; it names no type.
892 Some(decl::Kind::EnumDef(_)) => {}
893 Some(decl::Kind::EnumSetDef(enum_set)) => {
894 if let Some(reference) = &enum_set.backing_enum {
895 qualifier(reference, found);
896 }
897 }
898 Some(decl::Kind::UnionDef(union_def)) => {
899 for arm in &union_def.arms {
900 qualifier(&arm.type_ref, found);
901 }
902 }
903 Some(decl::Kind::SignalDef(signal)) => qualifier(&signal.payload, found),
904 Some(decl::Kind::EventDef(event)) => qualifier(&event.payload, found),
905 Some(decl::Kind::CommandDef(command)) => {
906 for param in &command.params {
907 if let Some(field_type) = ¶m.r#type {
908 walk_field_type(field_type, found);
909 }
910 }
911 }
912 Some(decl::Kind::QueryDef(query)) => {
913 for param in &query.params {
914 if let Some(field_type) = ¶m.r#type {
915 walk_field_type(field_type, found);
916 }
917 }
918 if let Some(return_type) = &query.return_type {
919 walk_return_type(return_type, found);
920 }
921 }
922 Some(decl::Kind::FixedDef(fixed)) => {
923 if let Some(field_type) = &fixed.payload {
924 walk_field_type(field_type, found);
925 }
926 }
927 // A tombstone occupies an ordinal and names no type.
928 Some(decl::Kind::ReservedSlot(_)) | None => {}
929 }
930 }
931
932 /// The recursive half: a reference is reachable at arbitrary depth through
933 /// tuples, arrays, maps, inline scalars, and streams.
934 fn walk_field_type(field_type: &FieldType, found: &mut std::collections::BTreeSet<String>) {
935 match &field_type.kind {
936 Some(field_type::Kind::Named(reference)) => qualifier(reference, found),
937 // A primitive names no package.
938 Some(field_type::Kind::Primitive(_)) => {}
939 Some(field_type::Kind::InlineScalar(type_def)) => walk_type_def(type_def, found),
940 Some(field_type::Kind::Tuple(tuple)) => {
941 for field in &tuple.fields {
942 if let Some(inner) = &field.r#type {
943 walk_field_type(inner, found);
944 }
945 }
946 }
947 Some(field_type::Kind::Array(array)) => {
948 if let Some(element) = &array.element {
949 walk_field_type(element, found);
950 }
951 }
952 Some(field_type::Kind::Map(map)) => {
953 if let Some(key) = &map.key {
954 walk_field_type(key, found);
955 }
956 if let Some(value) = &map.value {
957 walk_field_type(value, found);
958 }
959 }
960 Some(field_type::Kind::Stream(stream)) => match &stream.element {
961 Some(stream_type::Element::Named(reference)) => qualifier(reference, found),
962 // STRING or BYTES only; names no package.
963 Some(stream_type::Element::Primitive(_)) | None => {}
964 },
965 None => {}
966 }
967 }
968
969 /// A `TypeDef`'s only reference is the constant a `match` bound names.
970 fn walk_type_def(type_def: &TypeDef, found: &mut std::collections::BTreeSet<String>) {
971 if let Some(constraint) = &type_def.constraint
972 && let Some(reference) = &constraint.pattern_const
973 {
974 qualifier(reference, found);
975 }
976 }
977
978 fn walk_return_type(return_type: &ReturnType, found: &mut std::collections::BTreeSet<String>) {
979 match &return_type.kind {
980 Some(return_type::Kind::Value(field_type)) => walk_field_type(field_type, found),
981 Some(return_type::Kind::Fallible(fallible)) => {
982 qualifier(&fallible.ok, found);
983 qualifier(&fallible.err, found);
984 }
985 None => {}
986 }
987 }
988
989 /// Whether a constraint leaves a generated constructor nothing to check.
990 ///
991 /// True when no bound, step or pattern is present. A step is an enforced
992 /// quantization constraint, including when its origin defaults to zero.
993 ///
994 /// A pattern given by name counts as a pattern: `pattern_const` is read as
995 /// well as `pattern`, because a pattern constant that did not resolve leaves
996 /// `pattern` absent while the type still carries a match constraint.
997 /// `ridl-sem` treats the two fields the same way in its derived-init rule
998 /// (`init.rs`).
999 ///
1000 /// Because the checker materializes the typl §4.4 default `[0..256]` into
1001 /// `len_min`/`len_max`, every string and bytes type is non-vacuous. In
1002 /// practice this reduces to `boolean`, and `integer`/`float` with no declared
1003 /// range.
1004 pub fn constraint_is_vacuous(constraint: Option<&Constraint>) -> bool {
1005 let Some(c) = constraint else { return true };
1006 c.min.is_none()
1007 && c.max.is_none()
1008 && c.step.is_none()
1009 && c.len_min.is_none()
1010 && c.len_max.is_none()
1011 && c.pattern.is_none()
1012 && c.pattern_const.is_none()
1013 }
1014}
1015
1016pub mod catalog_hash;
1017pub mod codegen;
1018pub mod name;
1019pub mod projection;
1020pub mod rules;
1021pub mod zero;
1022
1023#[cfg(test)]
1024mod v2_round_trip {
1025 use crate::v2;
1026
1027 /// Wraps an interaction kind in the shared `Decl` envelope. Visibility
1028 /// and `is_error` stay unset on interactions (ridl §14.1); the ordinal is
1029 /// the 1-based declaration order across all interactions of the
1030 /// enclosing interface (ridl §11).
1031 fn interaction(name: &str, ordinal: u32, kind: v2::decl::Kind) -> v2::Decl {
1032 v2::Decl {
1033 name: name.to_string(),
1034 visibility: v2::Visibility::Unspecified as i32,
1035 is_error: false,
1036 doc: String::new(),
1037 labels: Vec::new(),
1038 deprecated: None,
1039 ordinal,
1040 kind: Some(kind),
1041 links: Vec::new(),
1042 see: Vec::new(),
1043 since: Vec::new(),
1044 }
1045 }
1046
1047 fn named_type(name: &str) -> v2::FieldType {
1048 v2::FieldType {
1049 optional: false,
1050 kind: Some(v2::field_type::Kind::Named(name.to_string())),
1051 }
1052 }
1053
1054 fn stream_of(element: v2::stream_type::Element) -> v2::FieldType {
1055 v2::FieldType {
1056 optional: false,
1057 kind: Some(v2::field_type::Kind::Stream(v2::StreamType {
1058 element: Some(element),
1059 })),
1060 }
1061 }
1062
1063 /// A representative ridl package: one interface holding all five
1064 /// interaction kinds plus a reserved tombstone (ordinals 1–6, the
1065 /// tombstone counted, ridl §11), a strict-periodic and a defaulted
1066 /// range timing, a fallible query, and two services — a named
1067 /// reference and an inline shape holding a stream query.
1068 fn fixture() -> v2::Package {
1069 // signal speed : Speed @10ms — strict periodic stores the period
1070 // in both bounds (ADR-0008 decision 12).
1071 let speed = v2::SignalDef {
1072 payload: "Speed".to_string(),
1073 declared_init: None,
1074 init: Some(v2::InitValue {
1075 derivable: true,
1076 value: Some("0.0".to_string()),
1077 }),
1078 timing: Some(v2::Timing {
1079 mode: v2::TimingMode::StrictPeriodic as i32,
1080 min_us: Some("10000".to_string()),
1081 max_us: Some("10000".to_string()),
1082 default_applied: false,
1083 }),
1084 };
1085
1086 // event doorOpened : DoorEvent — untimed in source, so the
1087 // configured default range is resolved at compile time (ridl §9.1).
1088 let door_opened = v2::EventDef {
1089 payload: "DoorEvent".to_string(),
1090 timing: Some(v2::Timing {
1091 mode: v2::TimingMode::Range as i32,
1092 min_us: Some("20000".to_string()),
1093 max_us: Some("500000".to_string()),
1094 default_applied: true,
1095 }),
1096 };
1097
1098 // command setTarget(target : Speed) [ require target >= speed ]
1099 let set_target = v2::CommandDef {
1100 params: vec![v2::Param {
1101 name: "target".to_string(),
1102 r#type: Some(named_type("Speed")),
1103 doc: String::new(),
1104 links: Vec::new(),
1105 see: Vec::new(),
1106 since: Vec::new(),
1107 }],
1108 contracts: vec![v2::Contract {
1109 kind: v2::ContractKind::Require as i32,
1110 source: "target >= speed".to_string(),
1111 signal_refs: vec!["speed".to_string()],
1112 param_refs: vec!["target".to_string()],
1113 uses_result: false,
1114 observer_id: "VehicleStatus.setTarget.require[0]".to_string(),
1115 }],
1116 timing: None,
1117 };
1118
1119 // query fetchFaults(page : PageSpec) : FaultPage | DiagError
1120 // [ ensure result.count <= page.limit ]
1121 let fetch_faults = v2::QueryDef {
1122 params: vec![v2::Param {
1123 name: "page".to_string(),
1124 r#type: Some(named_type("PageSpec")),
1125 doc: String::new(),
1126 links: Vec::new(),
1127 see: Vec::new(),
1128 since: Vec::new(),
1129 }],
1130 return_type: Some(v2::ReturnType {
1131 kind: Some(v2::return_type::Kind::Fallible(v2::FallibleType {
1132 ok: "FaultPage".to_string(),
1133 err: "DiagError".to_string(),
1134 })),
1135 }),
1136 contracts: vec![v2::Contract {
1137 kind: v2::ContractKind::Ensure as i32,
1138 source: "result.count <= page.limit".to_string(),
1139 signal_refs: Vec::new(),
1140 param_refs: vec!["page".to_string()],
1141 uses_result: true,
1142 observer_id: "VehicleStatus.fetchFaults.ensure[0]".to_string(),
1143 }],
1144 timing: None,
1145 };
1146
1147 // fixed vin : Vin
1148 let vin = v2::FixedDef {
1149 payload: Some(named_type("Vin")),
1150 };
1151
1152 let vehicle_status = v2::Interface {
1153 name: "VehicleStatus".to_string(),
1154 visibility: v2::Visibility::Public as i32,
1155 doc: "Vehicle status contract".to_string(),
1156 labels: Vec::new(),
1157 deprecated: None,
1158 interactions: vec![
1159 interaction("speed", 1, v2::decl::Kind::SignalDef(speed)),
1160 interaction("doorOpened", 2, v2::decl::Kind::EventDef(door_opened)),
1161 // reserved legacyMode — the tombstone keeps ordinal 3
1162 // occupied in the one interaction sequence (ridl §11).
1163 v2::Decl {
1164 ordinal: 3,
1165 kind: Some(v2::decl::Kind::ReservedSlot(v2::Reserved {
1166 ordinal: 3,
1167 name: Some("legacyMode".to_string()),
1168 value: None,
1169 })),
1170 ..interaction("", 3, v2::decl::Kind::ReservedSlot(v2::Reserved::default()))
1171 },
1172 interaction("setTarget", 4, v2::decl::Kind::CommandDef(set_target)),
1173 interaction("fetchFaults", 5, v2::decl::Kind::QueryDef(fetch_faults)),
1174 interaction("vin", 6, v2::decl::Kind::FixedDef(vin)),
1175 ],
1176 number: 0,
1177 provisional: false,
1178 links: Vec::new(),
1179 see: Vec::new(),
1180 since: Vec::new(),
1181 };
1182
1183 // query tailLogs(pattern : <string>) : <LogLine> — a stream param
1184 // and a stream return (ridl §12), inside the inline service shape.
1185 let tail_logs = v2::QueryDef {
1186 params: vec![v2::Param {
1187 name: "pattern".to_string(),
1188 r#type: Some(stream_of(v2::stream_type::Element::Primitive(
1189 v2::PrimitiveType::String as i32,
1190 ))),
1191 doc: String::new(),
1192 links: Vec::new(),
1193 see: Vec::new(),
1194 since: Vec::new(),
1195 }],
1196 return_type: Some(v2::ReturnType {
1197 kind: Some(v2::return_type::Kind::Value(stream_of(
1198 v2::stream_type::Element::Named("LogLine".to_string()),
1199 ))),
1200 }),
1201 contracts: Vec::new(),
1202 timing: None,
1203 };
1204
1205 // service veh.adas.status : VehicleStatus — one named reference in
1206 // the service's set (ADR-0015 decision 12).
1207 let status_service = v2::Service {
1208 name: "veh.adas.status".to_string(),
1209 visibility: v2::Visibility::Public as i32,
1210 doc: String::new(),
1211 labels: Vec::new(),
1212 deprecated: None,
1213 shapes: vec![v2::ServiceShape {
1214 kind: Some(v2::service_shape::Kind::InterfaceRef(
1215 "VehicleStatus".to_string(),
1216 )),
1217 }],
1218 links: Vec::new(),
1219 see: Vec::new(),
1220 since: Vec::new(),
1221 };
1222 // service veh.adas.logs { … } — the inline shape as the one entry,
1223 // Interface.name == "" (ridl §14.5).
1224 let logs_service = v2::Service {
1225 name: "veh.adas.logs".to_string(),
1226 visibility: v2::Visibility::Public as i32,
1227 doc: String::new(),
1228 labels: Vec::new(),
1229 deprecated: None,
1230 shapes: vec![v2::ServiceShape {
1231 kind: Some(v2::service_shape::Kind::Inline(v2::Interface {
1232 name: String::new(),
1233 visibility: v2::Visibility::Unspecified as i32,
1234 doc: String::new(),
1235 labels: Vec::new(),
1236 deprecated: None,
1237 interactions: vec![interaction(
1238 "tailLogs",
1239 1,
1240 v2::decl::Kind::QueryDef(tail_logs),
1241 )],
1242 number: 0,
1243 provisional: false,
1244 links: Vec::new(),
1245 see: Vec::new(),
1246 since: Vec::new(),
1247 })),
1248 }],
1249 links: Vec::new(),
1250 see: Vec::new(),
1251 since: Vec::new(),
1252 };
1253
1254 v2::Package {
1255 name: "veh.adas".to_string(),
1256 // One typl declaration proves the verbatim v1 surface rides
1257 // along unchanged in v2; package-level declarations carry
1258 // ordinal 0.
1259 decls: vec![v2::Decl {
1260 name: "Speed".to_string(),
1261 visibility: v2::Visibility::Public as i32,
1262 is_error: false,
1263 doc: String::new(),
1264 labels: Vec::new(),
1265 deprecated: None,
1266 ordinal: 0,
1267 kind: Some(v2::decl::Kind::TypeDef(v2::TypeDef {
1268 backing: Some(v2::Backing {
1269 kind: Some(v2::backing::Kind::Unit("km/h".to_string())),
1270 }),
1271 constraint: None,
1272 declared_init: None,
1273 init: None,
1274 width: Some(v2::type_def::Width::FloatWidth(v2::FloatWidth::F32 as i32)),
1275 })),
1276 links: Vec::new(),
1277 see: Vec::new(),
1278 since: Vec::new(),
1279 }],
1280 interfaces: vec![vehicle_status],
1281 services: vec![status_service, logs_service],
1282 retired: Vec::new(),
1283 }
1284 }
1285
1286 /// The typl vocabulary surface the interaction fixture does not reach:
1287 /// the boxed `inlineScalar` oneof member, genuine 64-bit integer fields
1288 /// (array and map bounds, length bounds, `Reserved.value`,
1289 /// `EnumValue.value`), a tuple, a map, a union, an enum set, a constant,
1290 /// and a set `deprecated`. A second fixture, so each stays readable; the
1291 /// same round-trip tests drive both.
1292 fn vocabulary_fixture() -> v2::Package {
1293 fn decl(name: &str, kind: v2::decl::Kind) -> v2::Decl {
1294 v2::Decl {
1295 name: name.to_string(),
1296 visibility: v2::Visibility::Public as i32,
1297 is_error: false,
1298 doc: String::new(),
1299 labels: Vec::new(),
1300 deprecated: None,
1301 ordinal: 0,
1302 kind: Some(kind),
1303 links: Vec::new(),
1304 see: Vec::new(),
1305 since: Vec::new(),
1306 }
1307 }
1308
1309 fn field(name: &str, ordinal: u32, field_type: v2::FieldType) -> v2::Field {
1310 v2::Field {
1311 name: name.to_string(),
1312 ordinal,
1313 r#type: Some(field_type),
1314 declared_init: None,
1315 init: None,
1316 doc: String::new(),
1317 labels: Vec::new(),
1318 deprecated: None,
1319 links: Vec::new(),
1320 see: Vec::new(),
1321 since: Vec::new(),
1322 }
1323 }
1324
1325 // const MAX_RETRY : integer = 24
1326 let max_retry = v2::ConstDef {
1327 type_ref: Some("integer".to_string()),
1328 value: "24".to_string(),
1329 regex: None,
1330 };
1331
1332 // enum Gear { PARK = 1 DRIVE = 2 reserved 7 } — the tombstone
1333 // retires the integer value, a genuine int64 field.
1334 let gear = v2::EnumDef {
1335 values: vec![
1336 v2::EnumValue {
1337 name: "PARK".to_string(),
1338 value: 1,
1339 doc: String::new(),
1340 links: Vec::new(),
1341 see: Vec::new(),
1342 since: Vec::new(),
1343 },
1344 v2::EnumValue {
1345 name: "DRIVE".to_string(),
1346 value: 2,
1347 doc: String::new(),
1348 links: Vec::new(),
1349 see: Vec::new(),
1350 since: Vec::new(),
1351 },
1352 ],
1353 reserved: vec![v2::Reserved {
1354 ordinal: 0,
1355 name: None,
1356 value: Some(7),
1357 }],
1358 };
1359
1360 // enumset Warnings { LOW_FUEL = 0 ICE_RISK = 33 } — the standalone
1361 // form; bit 33 forces the u64 width and is a genuine int64 value.
1362 let warnings = v2::EnumSetDef {
1363 backing_enum: None,
1364 bits: vec![
1365 v2::EnumValue {
1366 name: "LOW_FUEL".to_string(),
1367 value: 0,
1368 doc: String::new(),
1369 links: Vec::new(),
1370 see: Vec::new(),
1371 since: Vec::new(),
1372 },
1373 v2::EnumValue {
1374 name: "ICE_RISK".to_string(),
1375 value: 33,
1376 doc: String::new(),
1377 links: Vec::new(),
1378 see: Vec::new(),
1379 since: Vec::new(),
1380 },
1381 ],
1382 width: v2::IntWidth::U64 as i32,
1383 };
1384
1385 // type PlateText : string [1..86] — character length bounds, two
1386 // genuine uint64 fields behind proto3 `optional`.
1387 let plate_text = v2::TypeDef {
1388 backing: Some(v2::Backing {
1389 kind: Some(v2::backing::Kind::Primitive(
1390 v2::PrimitiveType::String as i32,
1391 )),
1392 }),
1393 constraint: Some(v2::Constraint {
1394 min: None,
1395 max: None,
1396 step: None,
1397 len_min: Some(1),
1398 len_max: Some(86),
1399 pattern: None,
1400 pattern_const: None,
1401 }),
1402 declared_init: None,
1403 init: None,
1404 width: None,
1405 };
1406
1407 // union Sample { speed : Speed gear : Gear }
1408 let sample = v2::UnionDef {
1409 arms: vec![
1410 v2::UnionArm {
1411 name: "speed".to_string(),
1412 ordinal: 1,
1413 type_ref: "Speed".to_string(),
1414 doc: String::new(),
1415 links: Vec::new(),
1416 see: Vec::new(),
1417 since: Vec::new(),
1418 },
1419 v2::UnionArm {
1420 name: "gear".to_string(),
1421 ordinal: 2,
1422 type_ref: "Gear".to_string(),
1423 doc: String::new(),
1424 links: Vec::new(),
1425 see: Vec::new(),
1426 since: Vec::new(),
1427 },
1428 ],
1429 is_result: false,
1430 reserved: Vec::new(),
1431 };
1432
1433 // retries : integer [0..24] = 3 — the boxed `inlineScalar` oneof
1434 // member: the committed regression guard for ADR-0014 Open item 2,
1435 // which established that the Rust-side `Box` is invisible to the
1436 // reflection path. The enclosing field carries the init; the nested
1437 // TypeDef's stays unset.
1438 let retries = v2::Field {
1439 declared_init: Some("3".to_string()),
1440 init: Some(v2::InitValue {
1441 derivable: true,
1442 value: Some("3".to_string()),
1443 }),
1444 ..field(
1445 "retries",
1446 1,
1447 v2::FieldType {
1448 optional: false,
1449 kind: Some(v2::field_type::Kind::InlineScalar(Box::new(v2::TypeDef {
1450 backing: Some(v2::Backing {
1451 kind: Some(v2::backing::Kind::Primitive(
1452 v2::PrimitiveType::Integer as i32,
1453 )),
1454 }),
1455 constraint: Some(v2::Constraint {
1456 min: Some("0".to_string()),
1457 max: Some("24".to_string()),
1458 step: None,
1459 len_min: None,
1460 len_max: None,
1461 pattern: None,
1462 pattern_const: None,
1463 }),
1464 declared_init: None,
1465 init: None,
1466 width: Some(v2::type_def::Width::IntWidth(v2::IntWidth::U8 as i32)),
1467 }))),
1468 },
1469 )
1470 };
1471
1472 // position : (x : Speed, y : Speed) — an anonymous named-field
1473 // composite (typl §11).
1474 let position = field(
1475 "position",
1476 2,
1477 v2::FieldType {
1478 optional: false,
1479 kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
1480 fields: vec![
1481 v2::TupleField {
1482 name: "x".to_string(),
1483 r#type: Some(named_type("Speed")),
1484 },
1485 v2::TupleField {
1486 name: "y".to_string(),
1487 r#type: Some(named_type("Speed")),
1488 },
1489 ],
1490 })),
1491 },
1492 );
1493
1494 // gears : [Gear; 1..4096] — array bounds are genuine uint64 fields.
1495 let gears = field(
1496 "gears",
1497 3,
1498 v2::FieldType {
1499 optional: false,
1500 kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
1501 element: Some(Box::new(named_type("Gear"))),
1502 min: 1,
1503 max: 4096,
1504 }))),
1505 },
1506 );
1507
1508 // plates : { PlateText -> Gear } [0..53] — map bounds are genuine
1509 // uint64 fields. The field is deprecated, covering the optional
1510 // string on the Field envelope.
1511 let plates = v2::Field {
1512 deprecated: Some("superseded by gears".to_string()),
1513 ..field(
1514 "plates",
1515 4,
1516 v2::FieldType {
1517 optional: false,
1518 kind: Some(v2::field_type::Kind::Map(Box::new(v2::MapType {
1519 key: Some(Box::new(named_type("PlateText"))),
1520 value: Some(Box::new(named_type("Gear"))),
1521 min: 0,
1522 max: 53,
1523 }))),
1524 },
1525 )
1526 };
1527
1528 let snapshot = v2::StructDef {
1529 members: [retries, position, gears, plates]
1530 .into_iter()
1531 .map(|field| v2::StructMember {
1532 member: Some(v2::struct_member::Member::Field(Box::new(field))),
1533 })
1534 .collect(),
1535 fixed_layout: false,
1536 };
1537
1538 v2::Package {
1539 name: "veh.vocab".to_string(),
1540 decls: vec![
1541 decl("MAX_RETRY", v2::decl::Kind::ConstDef(max_retry)),
1542 decl("Gear", v2::decl::Kind::EnumDef(gear)),
1543 decl("Warnings", v2::decl::Kind::EnumSetDef(warnings)),
1544 decl("PlateText", v2::decl::Kind::TypeDef(plate_text)),
1545 // The union is deprecated — the optional string on the Decl
1546 // envelope.
1547 v2::Decl {
1548 deprecated: Some("use Snapshot".to_string()),
1549 ..decl("Sample", v2::decl::Kind::UnionDef(sample))
1550 },
1551 decl("Snapshot", v2::decl::Kind::StructDef(snapshot)),
1552 ],
1553 interfaces: Vec::new(),
1554 services: Vec::new(),
1555 retired: Vec::new(),
1556 }
1557 }
1558
1559 #[test]
1560 fn protobuf_round_trip_preserves_package() {
1561 let package = fixture();
1562
1563 let buf = v2::to_binary(&package);
1564 let decoded = v2::from_binary(buf.as_slice()).expect("decode must succeed");
1565
1566 assert_eq!(package, decoded);
1567
1568 // The vocabulary fixture rides the same round trip.
1569 let vocabulary = vocabulary_fixture();
1570 let decoded_vocabulary =
1571 v2::from_binary(v2::to_binary(&vocabulary).as_slice()).expect("decode must succeed");
1572 assert_eq!(vocabulary, decoded_vocabulary);
1573
1574 let interface = &decoded.interfaces[0];
1575 let ordinals: Vec<u32> = interface.interactions.iter().map(|d| d.ordinal).collect();
1576 assert_eq!(
1577 ordinals,
1578 [1, 2, 3, 4, 5, 6],
1579 "one ordinal sequence, tombstone counted (ridl §11)"
1580 );
1581 let Some(v2::decl::Kind::ReservedSlot(tombstone)) = &interface.interactions[2].kind else {
1582 panic!("ordinal 3 must decode as a reserved tombstone");
1583 };
1584 assert_eq!(tombstone.name.as_deref(), Some("legacyMode"));
1585 let Some(v2::service_shape::Kind::Inline(inline)) = decoded.services[1]
1586 .shapes
1587 .first()
1588 .and_then(|slot| slot.kind.as_ref())
1589 else {
1590 panic!("veh.adas.logs must decode as an inline shape");
1591 };
1592 assert_eq!(inline.name, "", "an inline shape carries no name");
1593 let references: Vec<&str> = decoded.services[0]
1594 .shapes
1595 .iter()
1596 .filter_map(|slot| match &slot.kind {
1597 Some(v2::service_shape::Kind::InterfaceRef(reference)) => Some(reference.as_str()),
1598 _ => None,
1599 })
1600 .collect();
1601 assert_eq!(
1602 references,
1603 ["VehicleStatus"],
1604 "a service's set carries its references and nothing else"
1605 );
1606 }
1607
1608 #[test]
1609 fn json_round_trip_preserves_package() {
1610 for package in [fixture(), vocabulary_fixture()] {
1611 let json = v2::to_json_pretty(&package).expect("the fixture serializes as IR JSON");
1612 let decoded = v2::from_json(&json).expect("json deserialization must succeed");
1613
1614 assert_eq!(package, decoded);
1615 }
1616 }
1617
1618 /// The interface identity fields the lock design §9 adds — `number` and
1619 /// `provisional` on every `Interface`, an inline shape included, and the
1620 /// package's `retired` list — ride all three encodings unchanged, and the
1621 /// JSON writes them under their canonical names even when they hold their
1622 /// defaults (ADR-0014 decision 2), so a reader can tell `number` 0 from an
1623 /// absent field only by the schema, never by the text.
1624 #[test]
1625 fn number_provisional_and_retired_round_trip_through_json_text_and_binary() {
1626 let mut package = fixture();
1627 package.interfaces[0].number = 4;
1628 package.interfaces[0].provisional = true;
1629 let Some(v2::service_shape::Kind::Inline(inline)) =
1630 package.services[1].shapes[0].kind.as_mut()
1631 else {
1632 panic!("veh.adas.logs holds an inline shape in slot 1");
1633 };
1634 inline.number = 5;
1635 package.retired = vec![
1636 v2::RetiredInterface {
1637 name: "LaneAssist".to_string(),
1638 number: 2,
1639 },
1640 v2::RetiredInterface {
1641 name: "service:veh.hvac.cabin".to_string(),
1642 number: 3,
1643 },
1644 ];
1645
1646 let json = v2::to_json_pretty(&package).expect("the package serializes as IR JSON");
1647 assert_eq!(v2::from_json(&json).expect("the JSON parses back"), package);
1648 let text = v2::to_text_format(&package).expect("the package serializes as prototext");
1649 assert_eq!(
1650 v2::from_text_format(&text).expect("the prototext parses back"),
1651 package
1652 );
1653 assert_eq!(
1654 v2::from_binary(v2::to_binary(&package).as_slice()).expect("the binary decodes"),
1655 package
1656 );
1657
1658 for needle in [
1659 r#""number": 4"#,
1660 r#""provisional": true"#,
1661 r#""number": 5"#,
1662 r#""name": "LaneAssist""#,
1663 r#""name": "service:veh.hvac.cabin""#,
1664 ] {
1665 assert!(
1666 json.contains(needle),
1667 "the JSON must carry {needle}, got: {json}"
1668 );
1669 }
1670
1671 // A default holds its place in the text (decision 2): an interface
1672 // that was never numbered writes `0` and `false`, and a package with
1673 // nothing retired writes an empty list.
1674 let unnumbered = v2::to_json_pretty(&fixture()).expect("the fixture serializes as IR JSON");
1675 for needle in [
1676 r#""number": 0"#,
1677 r#""provisional": false"#,
1678 r#""retired": []"#,
1679 ] {
1680 assert!(
1681 unnumbered.contains(needle),
1682 "a default field must still be written, expected {needle} in: {unnumbered}"
1683 );
1684 }
1685 }
1686
1687 /// A baseline published before the lock existed carries no `number`, no
1688 /// `provisional` and no `retired` field. It still loads — a missing field
1689 /// reads as its default, which is the `number` 0 the lock design §7 names
1690 /// as the one transition case — while an unknown field is still rejected
1691 /// (`json_parse_rejects_an_unknown_field`).
1692 #[test]
1693 fn a_snapshot_lacking_the_number_fields_still_loads() {
1694 let package = v2::from_json(
1695 r#"{"name": "veh.x", "interfaces": [{"name": "LaneKeeping"}], "services": [{"name": "veh.x.s", "shapes": [{"inline": {"name": ""}}]}]}"#,
1696 )
1697 .expect("a pre-lock snapshot loads");
1698
1699 assert_eq!(package.interfaces[0].number, 0);
1700 assert!(!package.interfaces[0].provisional);
1701 let Some(v2::service_shape::Kind::Inline(inline)) =
1702 package.services[0].shapes[0].kind.as_ref()
1703 else {
1704 panic!("the service holds an inline shape");
1705 };
1706 assert_eq!(inline.number, 0);
1707 assert!(!inline.provisional);
1708 assert_eq!(package.retired, Vec::new());
1709 }
1710
1711 /// The prototext read path (ADR-0014 decision 7): both fixtures survive
1712 /// `to_text_format` then `from_text_format` unchanged. With the binary
1713 /// and JSON round trips above, this is what proves all three encodings
1714 /// carry the same IR.
1715 #[test]
1716 fn text_format_round_trip_preserves_package() {
1717 for package in [fixture(), vocabulary_fixture()] {
1718 let text = v2::to_text_format(&package).expect("the fixture serializes as prototext");
1719 let decoded = v2::from_text_format(&text).expect("prototext parsing must succeed");
1720
1721 assert_eq!(package, decoded);
1722 }
1723 }
1724
1725 /// The prototext options ADR-0014 decision 8 fixes — `pretty`,
1726 /// `skip_default_fields(false)`, `print_message_fields_in_index_order`.
1727 /// Any option set round-trips, which is why the round-trip test above
1728 /// cannot guard them.
1729 ///
1730 /// The first two are asserted through a visible consequence. The third is
1731 /// **not guarded here and cannot be on this schema**: every message in
1732 /// `ir.proto` declares its fields in ascending field-number order, and
1733 /// field-number order is also `prost-reflect`'s default, so index order
1734 /// and default order coincide everywhere and dropping the option would
1735 /// change no output. It is set because the schema's ordering is a
1736 /// property of the schema rather than a guarantee, and a message whose
1737 /// declaration order departs from its numbering would otherwise reorder
1738 /// every artifact it appears in.
1739 #[test]
1740 fn text_format_is_pretty_with_defaults_in_index_order() {
1741 let text = v2::to_text_format(&fixture()).expect("the fixture serializes as prototext");
1742
1743 // pretty: nested messages are indented, one field per line.
1744 assert!(
1745 text.contains("\n "),
1746 "pretty printing must indent nested fields, got: {text}"
1747 );
1748 // skip_default_fields(false): a field holding its default is present
1749 // (decision 2 — `ordinal: 0` is read, not inferred from absence).
1750 assert!(
1751 text.contains("is_error: false"),
1752 "a field holding its default must be emitted, got: {text}"
1753 );
1754 // print_message_fields_in_index_order: `name` is field 1 of
1755 // `Package`, so it opens the output.
1756 assert!(
1757 text.starts_with("name:"),
1758 "fields must print in schema index order, got: {text}"
1759 );
1760 }
1761
1762 /// Parses emitted JSON the way ADR-0014 decision 11's conformance test
1763 /// requires: unknown fields rejected, trailing input rejected. Since
1764 /// decision 14 the strict parser is the pbjson-generated `Deserialize`
1765 /// impl, whose default already rejects unknown fields
1766 /// (`ignore_unknown_fields()` stays unset in `build.rs`), so the
1767 /// strictness needs no option to opt into.
1768 fn strict_parse(json: &str) -> v2::Package {
1769 let mut deserializer = serde_json::Deserializer::from_str(json);
1770 let package = <v2::Package as serde::Deserialize>::deserialize(&mut deserializer)
1771 .expect("a strict conformant parser must accept the emitted JSON");
1772 deserializer.end().expect("no trailing input");
1773 package
1774 }
1775
1776 /// The conformance claim of ADR-0014 decision 11: a conformant protobuf
1777 /// JSON parser configured to reject unknown fields accepts the emitted
1778 /// JSON. Re-reading tests that claim itself; asserting on the rendered
1779 /// text would only restate the serializer's behaviour back to itself.
1780 #[test]
1781 fn emitted_json_survives_a_strict_conformant_parse() {
1782 for package in [fixture(), vocabulary_fixture()] {
1783 let json = v2::to_json_pretty(&package).expect("the fixture serializes as IR JSON");
1784 assert_eq!(package, strict_parse(&json));
1785 }
1786 }
1787
1788 #[test]
1789 fn json_renders_timing_bounds_and_fallible_arms_exactly() {
1790 let json = v2::to_json_pretty(&fixture()).expect("the fixture serializes as IR JSON");
1791
1792 // Exactness is visible: timing bounds are exact-decimal microsecond
1793 // strings, never floating-point numbers (ADR-0008 decision 12) —
1794 // under the canonical lowerCamelCase field name (ADR-0014 decision 1).
1795 assert!(
1796 json.contains(r#""minUs": "10000""#),
1797 "the timing bound must be a JSON string, got: {json}"
1798 );
1799 // Both arms of the inline T | E return are visible by name.
1800 assert!(
1801 json.contains(r#""ok": "FaultPage""#),
1802 "the ok arm must render, got: {json}"
1803 );
1804 assert!(
1805 json.contains(r#""err": "DiagError""#),
1806 "the err arm must render, got: {json}"
1807 );
1808 }
1809
1810 /// ADR-0014 decision 8's stringification, tested on genuine 64-bit
1811 /// fields. The timing assertion above proves nothing about it —
1812 /// `Timing.min_us` is `optional string` in the schema — so the claim
1813 /// needs fields whose wire type actually is `uint64` or `int64`.
1814 #[test]
1815 fn json_renders_64_bit_integer_fields_as_strings() {
1816 let json = v2::to_json_pretty(&vocabulary_fixture())
1817 .expect("the vocabulary fixture serializes as IR JSON");
1818
1819 // uint64: the array's upper bound.
1820 assert!(
1821 json.contains(r#""max": "4096""#),
1822 "an array bound must be a JSON string, got: {json}"
1823 );
1824 // uint64 behind proto3 `optional`: the character length bound.
1825 assert!(
1826 json.contains(r#""lenMax": "86""#),
1827 "a length bound must be a JSON string, got: {json}"
1828 );
1829 // int64: the retired enum value and the enum-set bit position.
1830 assert!(
1831 json.contains(r#""value": "7""#),
1832 "a retired enum value must be a JSON string, got: {json}"
1833 );
1834 assert!(
1835 json.contains(r#""value": "33""#),
1836 "an enum-set bit position must be a JSON string, got: {json}"
1837 );
1838 }
1839
1840 #[test]
1841 fn fallible_transport_identity_follows_the_derivation_rule() {
1842 // The ADR-0008 decision 4 rule: interface + interaction ordinal +
1843 // both arm references, in that order.
1844 let fallible = v2::FallibleType {
1845 ok: "FaultPage".to_string(),
1846 err: "DiagError".to_string(),
1847 };
1848 assert_eq!(
1849 v2::fallible_transport_identity("VehicleStatus", 9, &fallible),
1850 "VehicleStatus#9:FaultPage|DiagError"
1851 );
1852
1853 // Derived from the fixture: the fallible query sits at ordinal 5.
1854 let package = fixture();
1855 let interface = &package.interfaces[0];
1856 let query_decl = &interface.interactions[4];
1857 let Some(v2::decl::Kind::QueryDef(query)) = &query_decl.kind else {
1858 panic!("ordinal 5 must be the fallible query");
1859 };
1860 let Some(v2::return_type::Kind::Fallible(arms)) = &query.return_type.as_ref().unwrap().kind
1861 else {
1862 panic!("fetchFaults must return a fallible type");
1863 };
1864 assert_eq!(
1865 v2::fallible_transport_identity(&interface.name, query_decl.ordinal, arms),
1866 "VehicleStatus#5:FaultPage|DiagError"
1867 );
1868 }
1869
1870 /// `Package::shapes` yields the named interfaces first, then the inline
1871 /// shapes of the services — and each shape carries the name it is known by
1872 /// OUTSIDE the package. The fixture's inline shape has `Interface.name ==
1873 /// ""` by construction, so a walk that yielded the interface bare would
1874 /// hand every consumer the empty string; two of the six E2 defects were
1875 /// exactly that.
1876 #[test]
1877 fn shapes_walks_named_interfaces_and_inline_service_shapes() {
1878 let package = fixture();
1879 let walk: Vec<(&str, bool, usize)> = package
1880 .shapes()
1881 .map(|shape| {
1882 (
1883 shape.name,
1884 shape.is_inline(),
1885 shape.interface.interactions.len(),
1886 )
1887 })
1888 .collect();
1889 assert_eq!(
1890 walk,
1891 [("VehicleStatus", false, 6), ("veh.adas.logs", true, 1)],
1892 "the named interface, then the inline shape under the service's \
1893 dotted name",
1894 );
1895
1896 // The fixture's third shape-bearing declaration is `service
1897 // veh.adas.status : VehicleStatus`, which names an interface already in
1898 // the walk. Yielding it too would visit `VehicleStatus` twice.
1899 assert_eq!(package.services.len(), 2, "one reference form, one inline");
1900 assert!(
1901 !package
1902 .shapes()
1903 .any(|shape| shape.name == "veh.adas.status"),
1904 "a service naming an interface contributes no shape of its own",
1905 );
1906 }
1907
1908 /// The owning service is carried because `Service.visibility` is the
1909 /// authoritative one: an inline shape's own field is
1910 /// `VISIBILITY_UNSPECIFIED` by construction, which is not "internal" and
1911 /// not "public".
1912 #[test]
1913 fn shape_visibility_reads_the_owning_service_for_an_inline_shape() {
1914 let package = fixture();
1915 let shapes: Vec<v2::InterfaceShape<'_>> = package.shapes().collect();
1916
1917 let named = shapes[0];
1918 assert!(named.service.is_none());
1919 assert_eq!(named.visibility(), v2::Visibility::Public as i32);
1920 assert_eq!(named.visibility(), named.interface.visibility);
1921
1922 let inline = shapes[1];
1923 assert_eq!(
1924 inline.interface.visibility,
1925 v2::Visibility::Unspecified as i32,
1926 "the trap: an inline shape's own visibility field is unset",
1927 );
1928 assert_eq!(
1929 inline.service.expect("an inline shape has an owner").name,
1930 "veh.adas.logs",
1931 );
1932 assert_eq!(
1933 inline.visibility(),
1934 v2::Visibility::Public as i32,
1935 "the accessor reads the owning service's, never the unset field",
1936 );
1937 }
1938
1939 /// A package with no service at all still walks its interfaces, and a
1940 /// package with neither yields nothing — the emptiness both backends test
1941 /// for before emitting any interaction vocabulary.
1942 #[test]
1943 fn shapes_is_empty_only_when_the_package_declares_no_shape() {
1944 let mut package = fixture();
1945 package.services.clear();
1946 assert_eq!(package.shapes().count(), 1);
1947
1948 package.interfaces.clear();
1949 assert_eq!(package.shapes().count(), 0);
1950 }
1951
1952 /// A dotted reference contributes its qualifier; a bare one contributes
1953 /// nothing. Every recursive path through `walk_field_type` — array
1954 /// element, tuple field, map key, map value, stream element — carries a
1955 /// distinct qualifier, so no path's absence can hide behind another
1956 /// path's presence: deleting any one arm's body changes the expected set
1957 /// this test compares against, rather than leaving it unchanged.
1958 #[test]
1959 fn referenced_packages_finds_qualifiers_at_depth() {
1960 fn named(reference: &str) -> v2::FieldType {
1961 v2::FieldType {
1962 kind: Some(v2::field_type::Kind::Named(reference.to_string())),
1963 ..Default::default()
1964 }
1965 }
1966
1967 fn fixed(payload: v2::FieldType) -> v2::decl::Kind {
1968 v2::decl::Kind::FixedDef(v2::FixedDef {
1969 payload: Some(payload),
1970 })
1971 }
1972
1973 let package = v2::Package {
1974 name: "veh.cluster".to_string(),
1975 decls: vec![
1976 v2::Decl {
1977 name: "Local".to_string(),
1978 kind: Some(v2::decl::Kind::SignalDef(v2::SignalDef {
1979 payload: "Speed".to_string(),
1980 ..Default::default()
1981 })),
1982 ..Default::default()
1983 },
1984 v2::Decl {
1985 name: "Stamped".to_string(),
1986 kind: Some(v2::decl::Kind::SignalDef(v2::SignalDef {
1987 payload: "ridl.std.Timestamp".to_string(),
1988 ..Default::default()
1989 })),
1990 ..Default::default()
1991 },
1992 v2::Decl {
1993 name: "ArrLabels".to_string(),
1994 kind: Some(fixed(v2::FieldType {
1995 kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
1996 element: Some(Box::new(named("veh.arr.Label"))),
1997 min: 0,
1998 max: 32,
1999 }))),
2000 ..Default::default()
2001 })),
2002 ..Default::default()
2003 },
2004 v2::Decl {
2005 name: "TupThing".to_string(),
2006 kind: Some(fixed(v2::FieldType {
2007 kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
2008 fields: vec![v2::TupleField {
2009 name: "x".to_string(),
2010 r#type: Some(named("veh.tup.X")),
2011 }],
2012 })),
2013 ..Default::default()
2014 })),
2015 ..Default::default()
2016 },
2017 v2::Decl {
2018 name: "MapThing".to_string(),
2019 kind: Some(fixed(v2::FieldType {
2020 kind: Some(v2::field_type::Kind::Map(Box::new(v2::MapType {
2021 key: Some(Box::new(named("veh.key.X"))),
2022 value: Some(Box::new(named("veh.val.X"))),
2023 min: 0,
2024 max: 8,
2025 }))),
2026 ..Default::default()
2027 })),
2028 ..Default::default()
2029 },
2030 v2::Decl {
2031 name: "StreamThing".to_string(),
2032 kind: Some(fixed(v2::FieldType {
2033 kind: Some(v2::field_type::Kind::Stream(v2::StreamType {
2034 element: Some(v2::stream_type::Element::Named(
2035 "veh.strm.X".to_string(),
2036 )),
2037 })),
2038 ..Default::default()
2039 })),
2040 ..Default::default()
2041 },
2042 ],
2043 ..Default::default()
2044 };
2045
2046 let found = v2::referenced_packages(&package);
2047 let expected: std::collections::BTreeSet<String> = [
2048 "ridl.std", "veh.arr", "veh.tup", "veh.key", "veh.val", "veh.strm",
2049 ]
2050 .into_iter()
2051 .map(str::to_string)
2052 .collect();
2053 assert_eq!(
2054 found, expected,
2055 "each recursive path must contribute its own distinct qualifier"
2056 );
2057 assert!(
2058 !found.contains("Speed") && !found.contains("veh.cluster"),
2059 "a bare reference contributes no package: {found:?}",
2060 );
2061 }
2062
2063 /// An empty package references nothing — the negative case the emit rule in
2064 /// `ridlc` depends on.
2065 #[test]
2066 fn referenced_packages_is_empty_without_references() {
2067 let package = v2::Package {
2068 name: "veh.solo".to_string(),
2069 ..Default::default()
2070 };
2071 assert!(v2::referenced_packages(&package).is_empty());
2072 }
2073
2074 /// Below prost's recursion limit at two message levels per nesting level
2075 /// — the depth ADR-0014 decision 12 measured as round-tripping correctly.
2076 /// Since decision 14 these two constants bound the prototext transcode
2077 /// alone: JSON no longer transcodes and carries its own read-side
2078 /// ceiling, tested separately below.
2079 const NESTING_BELOW_LIMIT: usize = 45;
2080 /// Past the limit today. The tests assert the outcome — an error, never a
2081 /// panic — not the exact threshold, so a prost release that moves the
2082 /// limit moves these constants, not the assertions.
2083 const NESTING_PAST_LIMIT: usize = 60;
2084
2085 /// One declaration whose payload nests `depth` levels of inline arrays —
2086 /// each level costs two message levels on the wire (`FieldType` plus
2087 /// `ArrayType`), the arithmetic ADR-0014 decision 12 records against
2088 /// prost's recursion limit.
2089 fn nested_package(depth: usize) -> v2::Package {
2090 let mut payload = v2::FieldType {
2091 optional: false,
2092 kind: Some(v2::field_type::Kind::Primitive(
2093 v2::PrimitiveType::Integer as i32,
2094 )),
2095 };
2096 for _ in 0..depth {
2097 payload = v2::FieldType {
2098 optional: false,
2099 kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
2100 element: Some(Box::new(payload)),
2101 min: 1,
2102 max: 1,
2103 }))),
2104 };
2105 }
2106 v2::Package {
2107 name: "veh.deep".to_string(),
2108 decls: vec![v2::Decl {
2109 name: "deep".to_string(),
2110 kind: Some(v2::decl::Kind::FixedDef(v2::FixedDef {
2111 payload: Some(payload),
2112 })),
2113 ..Default::default()
2114 }],
2115 ..Default::default()
2116 }
2117 }
2118
2119 /// One package whose nesting sits exactly `levels` message levels below
2120 /// the `Package` root — the unit the derived binary encoding's bound is
2121 /// stated in (the IR specification, "The derived encodings").
2122 ///
2123 /// The chain under a `FixedDef` costs three levels before any nesting
2124 /// (`Decl`, `FixedDef`, the outermost `FieldType`) and two per array level
2125 /// (`ArrayType`, `FieldType`), so an array-only chain reaches the odd
2126 /// depths alone. One tuple level costs three (`FieldType`, `TupleType`,
2127 /// `TupleField`, then the `FieldType` the next level counts), which is what
2128 /// reaches the even depths. Both shapes are what the front end lowers, so
2129 /// neither is a construction the schema would not otherwise see.
2130 fn package_at_message_depth(levels: usize) -> v2::Package {
2131 assert!(levels >= 3, "the chain costs three levels before nesting");
2132 let (arrays, tuple) = if levels % 2 == 1 {
2133 ((levels - 3) / 2, false)
2134 } else {
2135 assert!(levels >= 6, "one tuple level costs three");
2136 ((levels - 6) / 2, true)
2137 };
2138
2139 let mut payload = v2::FieldType {
2140 optional: false,
2141 kind: Some(v2::field_type::Kind::Primitive(
2142 v2::PrimitiveType::Integer as i32,
2143 )),
2144 };
2145 for _ in 0..arrays {
2146 payload = v2::FieldType {
2147 optional: false,
2148 kind: Some(v2::field_type::Kind::Array(Box::new(v2::ArrayType {
2149 element: Some(Box::new(payload)),
2150 min: 1,
2151 max: 1,
2152 }))),
2153 };
2154 }
2155 if tuple {
2156 payload = v2::FieldType {
2157 optional: false,
2158 kind: Some(v2::field_type::Kind::Tuple(v2::TupleType {
2159 fields: vec![v2::TupleField {
2160 name: "f0".to_string(),
2161 r#type: Some(payload),
2162 }],
2163 })),
2164 };
2165 }
2166 v2::Package {
2167 name: "veh.deep".to_string(),
2168 decls: vec![v2::Decl {
2169 name: "deep".to_string(),
2170 kind: Some(v2::decl::Kind::FixedDef(v2::FixedDef {
2171 payload: Some(payload),
2172 })),
2173 ..Default::default()
2174 }],
2175 ..Default::default()
2176 }
2177 }
2178
2179 /// The nesting of JSON objects in a canonical artifact, which in the
2180 /// protobuf JSON mapping is the nesting of messages: every message is an
2181 /// object, a repeated field is an array of them, and the schema declares no
2182 /// `map<>` field. The root `Package` object is included, so a caller
2183 /// counting levels *below* the root subtracts one. Brackets inside a string
2184 /// literal do not count.
2185 fn message_nesting(json: &str) -> usize {
2186 let (mut depth, mut max) = (0usize, 0usize);
2187 let (mut in_string, mut escaped) = (false, false);
2188 for byte in json.bytes() {
2189 if in_string {
2190 if escaped {
2191 escaped = false;
2192 } else if byte == b'\\' {
2193 escaped = true;
2194 } else if byte == b'"' {
2195 in_string = false;
2196 }
2197 continue;
2198 }
2199 match byte {
2200 b'"' => in_string = true,
2201 b'{' => {
2202 depth += 1;
2203 max = max.max(depth);
2204 }
2205 b'}' => depth = depth.saturating_sub(1),
2206 _ => {}
2207 }
2208 }
2209 max
2210 }
2211
2212 /// [`package_at_message_depth`] builds what it claims, on both parities.
2213 /// Without this the two bound tests below would pin a depth nobody
2214 /// measured.
2215 #[test]
2216 fn package_at_message_depth_builds_the_depth_it_names() {
2217 with_sized_stack(|| {
2218 for levels in [3, 6, 7, 99, 100, 101] {
2219 let json = v2::to_json_pretty(&package_at_message_depth(levels))
2220 .expect("the writer is unrestricted at these depths");
2221 assert_eq!(
2222 message_nesting(&json) - 1,
2223 levels,
2224 "the chain must nest {levels} message levels below the root"
2225 );
2226 }
2227 });
2228 }
2229
2230 /// The derived binary encoding's bound, stated by the IR specification and
2231 /// pinned here: 100 message levels below the root round-trip.
2232 ///
2233 /// `to_binary` writes any depth; it is `from_binary` that stops, at prost's
2234 /// `RECURSION_LIMIT` of 100, decremented once per nested message on decode
2235 /// and not consulted on encode. The test asserts the outcome, not prost's
2236 /// constant: a prost release that moves the limit moves these two tests and
2237 /// the specification's paragraph together.
2238 #[test]
2239 fn binary_round_trip_at_100_message_levels_succeeds() {
2240 let package = package_at_message_depth(100);
2241 let bytes = v2::to_binary(&package);
2242 let decoded = v2::from_binary(&bytes).expect("100 message levels decode");
2243 assert_eq!(package, decoded);
2244 }
2245
2246 /// One level past that bound the binary reader refuses — an error, never a
2247 /// panic — while the canonical encoding carries the same package. This is
2248 /// the asymmetry the specification states as the reason binary is derived
2249 /// rather than canonical (driftsys/ridl#231).
2250 #[test]
2251 fn binary_decode_at_101_message_levels_returns_an_error() {
2252 let package = package_at_message_depth(101);
2253 let bytes = v2::to_binary(&package);
2254 let error = v2::from_binary(&bytes).expect_err("101 message levels must fail, not panic");
2255 assert!(
2256 error.to_string().contains("recursion limit reached"),
2257 "the error must name the limit, got: {error}"
2258 );
2259
2260 let json = v2::to_json_pretty(&package).expect("the canonical encoding has no such bound");
2261 assert_eq!(
2262 package,
2263 v2::from_json(&json).expect("the canonical encoding round-trips the same package")
2264 );
2265 }
2266
2267 /// The write side after ADR-0014 decision 14: the pbjson-generated
2268 /// writer recurses the typed message directly — no transcode, so no
2269 /// message-level recursion limit — and 400 levels of array nesting,
2270 /// roughly eight times the ceiling decision 12 recorded, serialize and
2271 /// round-trip. Run on an explicitly sized stack: the writer recurses on
2272 /// the caller's stack, and debug-build frames at this depth overflow the
2273 /// default test-thread stack (the reader sizes its own thread inside
2274 /// `from_json`).
2275 #[test]
2276 fn json_round_trip_at_400_nested_levels_succeeds() {
2277 with_sized_stack(|| {
2278 let package = nested_package(400);
2279 let json = v2::to_json_pretty(&package).expect("the writer has no message-level limit");
2280 let decoded = v2::from_json(&json).expect("the reader parses within its ceiling");
2281 assert_eq!(package, decoded);
2282 });
2283 }
2284
2285 /// The one error path on the JSON write side (ADR-0014 decision 14),
2286 /// and it is new with the generated impl, not a survivor of the
2287 /// transcode's: an `i32` enum field holding a discriminant outside the
2288 /// schema — data, not depth — which the retired reflection path
2289 /// serialized successfully as its bare number. The checker never
2290 /// produces one, so there is no CLI route to this failure; it is pinned
2291 /// here at the crate surface.
2292 #[test]
2293 fn json_serialization_of_an_out_of_schema_discriminant_returns_an_error() {
2294 let mut package = fixture();
2295 package.decls[0].visibility = 999;
2296 let err = v2::to_json_pretty(&package)
2297 .expect_err("an out-of-schema discriminant must fail, not panic");
2298 let message = err.to_string();
2299 assert!(
2300 message.contains("canonical protobuf JSON"),
2301 "the error must name the encoding that failed, got: {message}"
2302 );
2303 assert!(
2304 message.contains("discriminant outside the schema"),
2305 "the error must name the known cause, got: {message}"
2306 );
2307 }
2308
2309 /// The strictness ADR-0014 decision 11 relies on is the generated
2310 /// deserializer's default: `ignore_unknown_fields()` is the opt-out and
2311 /// stays unset, so a field the schema does not declare is an error,
2312 /// never silently dropped.
2313 #[test]
2314 fn json_parse_rejects_an_unknown_field() {
2315 let error = v2::from_json(r#"{"name": "veh.deep", "notAField": 1}"#)
2316 .expect_err("an unknown field must be rejected");
2317 assert!(
2318 error.to_string().contains("unknown field"),
2319 "the error must name the defect, got: {error}"
2320 );
2321 }
2322
2323 /// A reader narrowing ADR-0014 decision 14 records: the proto3 JSON
2324 /// mapping expects parsers to accept numeric enum values, and the
2325 /// generated deserializer does — within the schema's range. A
2326 /// discriminant outside it (`"visibility": 77`) is rejected, where the
2327 /// retired reflection reader accepted it — and the retired *writer*
2328 /// emitted exactly such a number for an out-of-schema discriminant.
2329 /// Pinned so the narrowing stays a decision rather than an accident: a
2330 /// future mechanism change must confront this test.
2331 #[test]
2332 fn json_parse_rejects_an_out_of_range_numeric_enum_value() {
2333 let with_visibility =
2334 |value: &str| format!(r#"{{"name": "veh.x", "decls": [{{"visibility": {value}}}]}}"#);
2335 v2::from_json(&with_visibility("1"))
2336 .expect("an in-range numeric enum value parses, as the mapping expects");
2337 let error = v2::from_json(&with_visibility("77"))
2338 .expect_err("an out-of-range numeric enum value must be rejected");
2339 assert!(
2340 error.to_string().contains("invalid value: integer `77`"),
2341 "the error must name the value, got: {error}"
2342 );
2343 }
2344
2345 /// A reader narrowing ADR-0014 decision 14 records: the mapping accepts
2346 /// float and exponent notation for integer fields (`"min": 1.0`), and
2347 /// the retired reflection reader did; the generated deserializer
2348 /// rejects both. Pinned for the same reason as the numeric-enum case
2349 /// above.
2350 #[test]
2351 fn json_parse_rejects_a_float_form_integer() {
2352 for spelling in ["1.0", "1e0"] {
2353 let error = v2::from_json(&format!(
2354 r#"{{"name": "veh.x", "decls": [{{"fixedDef": {{"payload": {{"array": {{"min": {spelling}}}}}}}}}]}}"#,
2355 ))
2356 .expect_err("a float-form integer must be rejected");
2357 assert!(
2358 error.to_string().contains("did not match any variant"),
2359 "the integer field's deserializer must be the one refusing `{spelling}`, \
2360 got: {error}"
2361 );
2362 }
2363 }
2364
2365 /// A reader narrowing ADR-0014 decision 14 records: `null` for a
2366 /// repeated field (`"decls": null`), which the retired reflection
2367 /// reader read as empty, is rejected. `null` for an optional scalar or
2368 /// message field is still accepted — parity with the retired reader,
2369 /// asserted alongside so the narrowing's edge is pinned from both
2370 /// sides.
2371 #[test]
2372 fn json_parse_rejects_null_for_a_repeated_field() {
2373 let error = v2::from_json(r#"{"name": "veh.x", "decls": null}"#)
2374 .expect_err("null for a repeated field must be rejected");
2375 assert!(
2376 error.to_string().contains("invalid type: null"),
2377 "the error must name the null, got: {error}"
2378 );
2379 v2::from_json(r#"{"name": "veh.x", "decls": [{"deprecated": null}]}"#)
2380 .expect("null for an optional scalar field still parses");
2381 }
2382
2383 /// A reader narrowing ADR-0014 decision 14 records: a duplicate JSON
2384 /// key, which the retired reflection reader resolved last-wins, is
2385 /// rejected.
2386 #[test]
2387 fn json_parse_rejects_a_duplicate_key() {
2388 let error = v2::from_json(r#"{"name": "a", "name": "b"}"#)
2389 .expect_err("a duplicate key must be rejected");
2390 assert!(
2391 error.to_string().contains("duplicate field `name`"),
2392 "the error must name the duplicated field, got: {error}"
2393 );
2394 }
2395
2396 /// The read-side ceiling (ADR-0014 decision 14): nesting past 1,000
2397 /// bracket levels returns an error before the parse begins — a
2398 /// diagnostic, where unbounded recursion would eventually abort on a
2399 /// stack overflow no caller can catch. The input is real writer output:
2400 /// past the ceiling the asymmetry is deliberate — the writer is
2401 /// unrestricted, the reader is not.
2402 #[test]
2403 fn json_parse_past_the_nesting_ceiling_returns_an_error() {
2404 with_sized_stack(|| {
2405 let json = v2::to_json_pretty(&nested_package(500))
2406 .expect("the writer is unrestricted at this depth");
2407 let error = v2::from_json(&json).expect_err("the reader must refuse past its ceiling");
2408 assert!(
2409 error.to_string().contains("1000 JSON levels"),
2410 "the error must name the ceiling, got: {error}"
2411 );
2412 });
2413 }
2414
2415 /// The ceiling is exact: 1,000 open brackets pass the scan and reach the
2416 /// parser — which then rejects the input as not a package — and 1,001 do
2417 /// not. The scan runs before the parse, so the over-ceiling probe needs
2418 /// no valid JSON behind its brackets.
2419 #[test]
2420 fn json_nesting_ceiling_binds_exactly_at_1000() {
2421 let at = v2::from_json(&"[".repeat(1_000)).expect_err("an array is not a package");
2422 assert!(
2423 !at.to_string().contains("JSON levels"),
2424 "at the ceiling the parser, not the scan, must be the one refusing, got: {at}"
2425 );
2426
2427 let past = v2::from_json(&"[".repeat(1_001)).expect_err("past the ceiling, the scan");
2428 assert!(
2429 past.to_string().contains("1000 JSON levels"),
2430 "past the ceiling the error must name it, got: {past}"
2431 );
2432 }
2433
2434 /// The nesting scan behind the ceiling: brackets count only outside
2435 /// string literals, an escaped quote does not end a literal, an escaped
2436 /// backslash does not disarm the real closing quote after it, and a
2437 /// stray closer never underflows the running depth.
2438 #[test]
2439 fn nesting_scan_counts_brackets_outside_string_literals_only() {
2440 // Plain structural nesting counts every open bracket.
2441 assert_eq!(v2::max_json_nesting(r#"{"a": [{"b": []}]}"#), 4);
2442 // Brackets inside a string literal do not count.
2443 assert_eq!(v2::max_json_nesting(r#"{"doc": "{[[[{"}"#), 1);
2444 // An escaped quote does not end the literal, so the brackets after
2445 // it are still inside it.
2446 assert_eq!(v2::max_json_nesting(r#"{"doc": "a\"[[[", "x": []}"#), 2);
2447 // An escaped backslash does not escape the closing quote: the
2448 // literal ends, and the brackets after it count.
2449 assert_eq!(v2::max_json_nesting(r#"{"doc": "a\\", "x": [[]]}"#), 3);
2450 // A stray closer saturates at zero rather than underflowing.
2451 assert_eq!(v2::max_json_nesting("]]]{"), 1);
2452 }
2453
2454 /// The prototext form of [`nested_package`], built by hand for the same
2455 /// reason [`nested_json`] is: past the limit the serializer rejects the
2456 /// package, so its prototext cannot come from [`v2::to_text_format`].
2457 fn nested_text(depth: usize) -> String {
2458 let mut payload = "primitive: PRIMITIVE_TYPE_INTEGER".to_string();
2459 for _ in 0..depth {
2460 payload = format!("array {{ element {{ {payload} }} min: 1 max: 1 }}");
2461 }
2462 format!(
2463 r#"name: "veh.deep" decls {{ name: "deep" fixed_def {{ payload {{ {payload} }} }} }}"#
2464 )
2465 }
2466
2467 /// The prototext write path carries the same recursion-limit failure mode
2468 /// as JSON — both go through the one transcode (ADR-0014 decision 12) —
2469 /// and reports it as an error naming its own encoding, never a panic.
2470 #[test]
2471 fn text_serialization_past_the_nesting_limit_returns_an_error() {
2472 let err = v2::to_text_format(&nested_package(NESTING_PAST_LIMIT))
2473 .expect_err("serialization past the recursion limit must fail, not panic");
2474 let message = err.to_string();
2475 assert!(
2476 message.contains("recursion limit"),
2477 "the error must name the nesting limit as the known cause, got: {message}"
2478 );
2479 assert!(
2480 message.contains("prototext"),
2481 "the error must name the encoding that failed, got: {message}"
2482 );
2483 }
2484
2485 /// Runs `test` on a thread whose stack fits the recursion the test
2486 /// drives on its own thread. Two groups need one. The prototext parser
2487 /// recurses once per message level with debug-build frames large enough
2488 /// that the default 2 MiB test-thread stack overflows near 45 array
2489 /// levels — under prost's own recursion limit, so the depths
2490 /// [`NESTING_BELOW_LIMIT`] and [`NESTING_PAST_LIMIT`] pin are
2491 /// unreachable on that stack; the production paths are unaffected, since
2492 /// the toolchain writes prototext and never parses it (`ridl diff` and
2493 /// the baselines stay `.ir.json`, ADR-0014 decision 5). And the deep
2494 /// JSON tests drive the pbjson-generated writer, which recurses on the
2495 /// caller's stack (ADR-0014 decision 14 — only the reader sizes a
2496 /// thread of its own, inside `from_json`).
2497 fn with_sized_stack(test: impl FnOnce() + Send + 'static) {
2498 let outcome = std::thread::Builder::new()
2499 .stack_size(16 * 1024 * 1024)
2500 .spawn(test)
2501 .expect("spawn the large-stack test thread")
2502 .join();
2503 if let Err(payload) = outcome {
2504 std::panic::resume_unwind(payload);
2505 }
2506 }
2507
2508 /// The read direction: the text-format parser itself has no depth limit,
2509 /// so the failure is the transcode out of the dynamic message, mapped
2510 /// into the error return instead of expected on (ADR-0014 decision 12).
2511 #[test]
2512 fn text_parse_past_the_nesting_limit_returns_an_error() {
2513 with_sized_stack(|| {
2514 let error = v2::from_text_format(&nested_text(NESTING_PAST_LIMIT))
2515 .expect_err("parsing past the recursion limit must fail, not panic");
2516
2517 // Assert *which* stage failed: prost's transcoding decoder says
2518 // "recursion limit reached", and a parse-stage failure would
2519 // render through the `Parse` variant instead.
2520 let message = error.to_string();
2521 assert!(
2522 message.contains("recursion limit reached"),
2523 "the transcode out of the dynamic message must be the failing \
2524 stage, got: {message}"
2525 );
2526 });
2527 }
2528
2529 /// The prototext bound must not tighten silently either: below the limit
2530 /// the package still serializes and round-trips.
2531 #[test]
2532 fn text_round_trip_below_the_nesting_limit_succeeds() {
2533 with_sized_stack(|| {
2534 let package = nested_package(NESTING_BELOW_LIMIT);
2535 let text = v2::to_text_format(&package)
2536 .expect("below the recursion limit, serialization succeeds");
2537 let decoded =
2538 v2::from_text_format(&text).expect("below the recursion limit, parsing succeeds");
2539 assert_eq!(package, decoded);
2540 });
2541 }
2542}
2543
2544#[cfg(test)]
2545mod vacuous_constraint {
2546 use crate::v2;
2547
2548 /// A constraint with every field absent. Each test sets only the field it
2549 /// is about, so no assertion can pass through a neighbouring field.
2550 fn constraint() -> v2::Constraint {
2551 v2::Constraint {
2552 min: None,
2553 max: None,
2554 step: None,
2555 len_min: None,
2556 len_max: None,
2557 pattern: None,
2558 pattern_const: None,
2559 }
2560 }
2561
2562 #[test]
2563 fn an_absent_or_empty_constraint_is_vacuous() {
2564 assert!(v2::constraint_is_vacuous(None));
2565 assert!(v2::constraint_is_vacuous(Some(&constraint())));
2566 }
2567
2568 #[test]
2569 fn a_step_constraint_is_non_vacuous() {
2570 let stepped = v2::Constraint {
2571 step: Some("0.5".to_string()),
2572 ..constraint()
2573 };
2574 assert!(!v2::constraint_is_vacuous(Some(&stepped)));
2575 }
2576
2577 /// Every constrained field on its own. A fixture setting a pair — `min`
2578 /// with `max`, or `len_min` with `len_max` — cannot tell a predicate that
2579 /// reads both from one that reads either, so each bound here is one-sided.
2580 /// The paired shapes are pinned separately by
2581 /// [`a_bound_pair_set_together_is_non_vacuous`], which a one-sided fixture
2582 /// cannot do.
2583 #[test]
2584 fn any_single_constrained_field_is_non_vacuous() {
2585 let cases = [
2586 (
2587 "min",
2588 v2::Constraint {
2589 min: Some("0.0".to_string()),
2590 ..constraint()
2591 },
2592 ),
2593 (
2594 "max",
2595 v2::Constraint {
2596 max: Some("250.0".to_string()),
2597 ..constraint()
2598 },
2599 ),
2600 (
2601 "len_min",
2602 v2::Constraint {
2603 len_min: Some(1),
2604 ..constraint()
2605 },
2606 ),
2607 (
2608 "len_max",
2609 v2::Constraint {
2610 len_max: Some(256),
2611 ..constraint()
2612 },
2613 ),
2614 (
2615 "pattern",
2616 v2::Constraint {
2617 pattern: Some("^[a-z]+$".to_string()),
2618 ..constraint()
2619 },
2620 ),
2621 (
2622 "pattern_const",
2623 v2::Constraint {
2624 pattern_const: Some("NAME_PATTERN".to_string()),
2625 ..constraint()
2626 },
2627 ),
2628 ];
2629 for (field, case) in cases {
2630 assert!(
2631 !v2::constraint_is_vacuous(Some(&case)),
2632 "`{field}` alone must be non-vacuous"
2633 );
2634 }
2635 }
2636
2637 /// The two shapes the checker actually emits: a declared range, and the
2638 /// typl §4.4 default `[0..256]` every string and bytes type carries.
2639 ///
2640 /// A one-sided fixture cannot pin these. A predicate reading each bound as
2641 /// a pair — `(c.min.is_none() == c.max.is_none())` and the same for the
2642 /// length bounds — passes every one-sided case and still reports both
2643 /// shapes below as vacuous, which would drop the range check from every
2644 /// bounded number and every string.
2645 #[test]
2646 fn a_bound_pair_set_together_is_non_vacuous() {
2647 let ranged = v2::Constraint {
2648 min: Some("0.0".to_string()),
2649 max: Some("250.0".to_string()),
2650 ..constraint()
2651 };
2652 assert!(!v2::constraint_is_vacuous(Some(&ranged)));
2653
2654 let default_length = v2::Constraint {
2655 len_min: Some(0),
2656 len_max: Some(256),
2657 ..constraint()
2658 };
2659 assert!(!v2::constraint_is_vacuous(Some(&default_length)));
2660 }
2661}
2662
2663#[cfg(test)]
2664mod system_round_trip {
2665 use crate::v2;
2666
2667 fn attribute(namespace: &str, key: &str, value: Option<v2::AttributeValue>) -> v2::Attribute {
2668 v2::Attribute {
2669 namespace: namespace.to_string(),
2670 key: key.to_string(),
2671 value,
2672 }
2673 }
2674
2675 fn scalar(text: &str) -> v2::AttributeValue {
2676 v2::AttributeValue {
2677 kind: Some(v2::attribute_value::Kind::Scalar(text.to_string())),
2678 }
2679 }
2680
2681 fn list(items: Vec<v2::AttributeValue>) -> v2::AttributeValue {
2682 v2::AttributeValue {
2683 kind: Some(v2::attribute_value::Kind::List(v2::AttributeList { items })),
2684 }
2685 }
2686
2687 fn interface(catalog: &str, name: &str, inline: bool) -> Option<v2::InterfaceRef> {
2688 Some(v2::InterfaceRef {
2689 catalog: catalog.to_string(),
2690 name: name.to_string(),
2691 inline,
2692 })
2693 }
2694
2695 fn endpoint(component: &str, instance: &str, machine: &str) -> v2::Endpoint {
2696 v2::Endpoint {
2697 component: component.to_string(),
2698 instance: instance.to_string(),
2699 machine: machine.to_string(),
2700 }
2701 }
2702
2703 /// A reduced rsdl reference Appendix A: `Cruise` with two instances
2704 /// offering `veh.adas.cruise` and requiring `LaneAssist`, the implicit
2705 /// component of `veh.diag.access`, one distribution and one deployment.
2706 /// Every message of `system.proto` appears at least once, with every
2707 /// scalar set to a value other than its default — a flag and a nested-list
2708 /// attribute value included — so a round trip that drops a field is
2709 /// caught.
2710 fn doc_link() -> v2::DocLink {
2711 v2::DocLink {
2712 text: "Cruise".to_string(),
2713 offset: 16,
2714 len: 6,
2715 target: "veh.topology.Cruise".to_string(),
2716 }
2717 }
2718
2719 fn fixture() -> v2::System {
2720 let link = v2::Link {
2721 interface: interface("veh.diag", "veh.diag.access", true),
2722 service: "veh.diag.access".to_string(),
2723 consumer: Some(endpoint("veh.topology.Backend", "Unit", "Cloud")),
2724 producer: Some(endpoint("veh.diag.access", "Unit", "AdasHpc")),
2725 crossing: v2::Crossing::OffBoard as i32,
2726 };
2727 v2::System {
2728 name: "Vehicle".to_string(),
2729 package: "veh.topology".to_string(),
2730 labels: vec!["ASIL_B".to_string()],
2731 attributes: vec![attribute("rust", "crate", Some(scalar("\"vehicle\"")))],
2732 members: vec![
2733 v2::MemberLine {
2734 component: "veh.topology.Cruise".to_string(),
2735 attributes: vec![attribute("linux", "pinned", None)],
2736 doc: "Documented, see [Cruise].".to_string(),
2737 links: vec![doc_link()],
2738 see: vec![doc_link()],
2739 since: vec!["1.2".to_string()],
2740 },
2741 v2::MemberLine {
2742 component: "veh.diag.access".to_string(),
2743 attributes: vec![],
2744 doc: "Documented, see [Cruise].".to_string(),
2745 links: vec![doc_link()],
2746 see: vec![doc_link()],
2747 since: vec!["1.2".to_string()],
2748 },
2749 ],
2750 components: vec![
2751 v2::Component {
2752 name: "Cruise".to_string(),
2753 package: "veh.topology".to_string(),
2754 implicit: false,
2755 external: true,
2756 instances: vec!["primary".to_string(), "backup".to_string()],
2757 offers: vec![v2::Offer {
2758 service: "veh.adas.cruise".to_string(),
2759 attributes: vec![attribute("someip", "serviceId", Some(scalar("4097")))],
2760 doc: "Documented, see [Cruise].".to_string(),
2761 links: vec![doc_link()],
2762 see: vec![doc_link()],
2763 since: vec!["1.2".to_string()],
2764 }],
2765 requires: vec![v2::Require {
2766 interface: interface("veh.adas", "LaneAssist", false),
2767 service: "veh.adas.lane".to_string(),
2768 producer: "veh.topology.Lane".to_string(),
2769 attributes: vec![attribute(
2770 "linux",
2771 "cpuset",
2772 Some(list(vec![scalar("2"), list(vec![scalar("3")])])),
2773 )],
2774 doc: "Documented, see [Cruise].".to_string(),
2775 links: vec![doc_link()],
2776 see: vec![doc_link()],
2777 since: vec!["1.2".to_string()],
2778 }],
2779 labels: vec!["ASIL_B".to_string()],
2780 attributes: vec![attribute("rust", "crate", None)],
2781 doc: "Documented, see [Cruise].".to_string(),
2782 links: vec![doc_link()],
2783 see: vec![doc_link()],
2784 since: vec!["1.2".to_string()],
2785 },
2786 v2::Component {
2787 name: "veh.diag.access".to_string(),
2788 package: String::new(),
2789 implicit: true,
2790 external: false,
2791 instances: vec!["Unit".to_string()],
2792 offers: vec![v2::Offer {
2793 service: "veh.diag.access".to_string(),
2794 attributes: vec![],
2795 doc: "Documented, see [Cruise].".to_string(),
2796 links: vec![doc_link()],
2797 see: vec![doc_link()],
2798 since: vec!["1.2".to_string()],
2799 }],
2800 requires: vec![],
2801 labels: vec![],
2802 attributes: vec![],
2803 doc: "Documented, see [Cruise].".to_string(),
2804 links: vec![doc_link()],
2805 see: vec![doc_link()],
2806 since: vec!["1.2".to_string()],
2807 },
2808 ],
2809 producers: vec![v2::Producer {
2810 service: "veh.adas.cruise".to_string(),
2811 component: "veh.topology.Cruise".to_string(),
2812 instances: vec!["primary".to_string(), "backup".to_string()],
2813 not_yet_realizable: true,
2814 }],
2815 grants: vec![v2::Grant {
2816 component: "veh.topology.Backend".to_string(),
2817 external: true,
2818 regions: vec!["veh.adas".to_string(), "veh.diag".to_string()],
2819 }],
2820 regions: vec![v2::Region {
2821 catalog: "veh.diag".to_string(),
2822 hash: vec![0xab; 32],
2823 interfaces: vec![v2::RegionInterface {
2824 name: "veh.diag.access".to_string(),
2825 inline: true,
2826 number: 2,
2827 provisional: true,
2828 service: "veh.diag.access".to_string(),
2829 }],
2830 }],
2831 distributions: vec![v2::Distribution {
2832 name: "Adas".to_string(),
2833 package: "veh.topology".to_string(),
2834 members: vec![v2::MemberLine {
2835 component: "veh.topology.Cruise".to_string(),
2836 attributes: vec![],
2837 doc: "Documented, see [Cruise].".to_string(),
2838 links: vec![doc_link()],
2839 see: vec![doc_link()],
2840 since: vec!["1.2".to_string()],
2841 }],
2842 depends_on: vec!["veh.topology.Base".to_string()],
2843 labels: vec!["PLATFORM_BUNDLE".to_string()],
2844 attributes: vec![attribute("deb", "section", Some(scalar("net")))],
2845 doc: "Documented, see [Cruise].".to_string(),
2846 links: vec![doc_link()],
2847 see: vec![doc_link()],
2848 since: vec!["1.2".to_string()],
2849 }],
2850 deployments: vec![v2::Deployment {
2851 name: "Production".to_string(),
2852 package: "veh.topology".to_string(),
2853 labels: vec!["FLEET".to_string()],
2854 attributes: vec![attribute("ota", "channel", Some(scalar("stable")))],
2855 machines: vec![v2::Machine {
2856 name: "Cloud".to_string(),
2857 external: true,
2858 labels: vec!["OFF_BOARD".to_string()],
2859 attributes: vec![attribute("net", "zone", Some(scalar("wan")))],
2860 doc: "Documented, see [Cruise].".to_string(),
2861 links: vec![doc_link()],
2862 see: vec![doc_link()],
2863 since: vec!["1.2".to_string()],
2864 }],
2865 placements: vec![v2::Placement {
2866 component: "veh.topology.Cruise".to_string(),
2867 instance: "backup".to_string(),
2868 machine: "Cockpit".to_string(),
2869 attributes: vec![attribute("linux", "cpuset", Some(list(vec![])))],
2870 sizing: Some(v2::Sizing {
2871 depth: Some(4_294_967_295),
2872 slots: Some(65_536),
2873 budget: Some(18_446_744_073_709_551_615),
2874 }),
2875 }],
2876 links: vec![link.clone()],
2877 routes: vec![v2::Route {
2878 catalog: "veh.adas".to_string(),
2879 interface_number: 2,
2880 member_ordinal: 1,
2881 interface: "LaneAssist".to_string(),
2882 member: "active".to_string(),
2883 service: "veh.adas.lane".to_string(),
2884 producers: vec![endpoint("veh.topology.Lane", "Unit", "AdasHpc")],
2885 }],
2886 surface: vec![v2::Surface {
2887 link: Some(link),
2888 direction: v2::SurfaceDirection::ExternalConsumes as i32,
2889 }],
2890 installations: vec![v2::Installation {
2891 distribution: "veh.topology.Adas".to_string(),
2892 machines: vec!["AdasHpc".to_string(), "Cockpit".to_string()],
2893 }],
2894 doc: "Documented, see [Cruise].".to_string(),
2895 doc_links: vec![doc_link()],
2896 see: vec![doc_link()],
2897 since: vec!["1.2".to_string()],
2898 sizing: Some(v2::Sizing {
2899 depth: Some(3),
2900 slots: Some(8),
2901 budget: Some(4096),
2902 }),
2903 }],
2904 doc: "Documented, see [Cruise].".to_string(),
2905 see: vec![doc_link()],
2906 since: vec!["1.2".to_string()],
2907 links: vec![doc_link()],
2908 }
2909 }
2910
2911 #[test]
2912 fn system_binary_round_trip_preserves_system() {
2913 let system = fixture();
2914 let decoded = v2::system_from_binary(v2::system_to_binary(&system).as_slice())
2915 .expect("decode must succeed");
2916 assert_eq!(system, decoded);
2917 }
2918
2919 /// The canonical JSON of the system artifact re-reads through the same
2920 /// strict pbjson-generated impl the package uses (ADR-0014 decisions 11
2921 /// and 14): unknown fields rejected, the catalog hash as base64, enums by
2922 /// name, the nested attribute list intact.
2923 #[test]
2924 fn system_json_round_trip_preserves_system() {
2925 let system = fixture();
2926 let json = v2::system_to_json_pretty(&system).expect("the fixture serializes as JSON");
2927 assert!(
2928 json.contains("\"hash\": \"q6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s=\""),
2929 "bytes render as base64, got:\n{json}"
2930 );
2931 assert!(
2932 json.contains("\"crossing\": \"CROSSING_OFF_BOARD\""),
2933 "enums render by name, got:\n{json}"
2934 );
2935 assert!(
2936 json.contains("\"notYetRealizable\": true"),
2937 "fields render in lowerCamelCase, got:\n{json}"
2938 );
2939 assert!(
2940 json.contains("\"budget\": \"18446744073709551615\""),
2941 "a 64-bit value renders as a string, got:\n{json}"
2942 );
2943 assert_eq!(
2944 system,
2945 v2::system_from_json(&json).expect("the JSON parses")
2946 );
2947 assert!(
2948 v2::system_from_json(&json.replacen("\"name\"", "\"nam\"", 1)).is_err(),
2949 "an unknown field is rejected"
2950 );
2951 }
2952
2953 /// An absent `sizing` and an empty one are different on the wire: a plugin
2954 /// reads "nothing declared" and "declared nothing" apart, in the binary and
2955 /// the JSON encodings.
2956 #[test]
2957 fn an_absent_sizing_and_an_empty_one_stay_distinct_through_the_encodings() {
2958 let mut absent = fixture();
2959 absent.deployments[0].sizing = None;
2960 absent.deployments[0].placements[0].sizing = None;
2961 let mut empty = absent.clone();
2962 empty.deployments[0].sizing = Some(v2::Sizing::default());
2963 empty.deployments[0].placements[0].sizing = Some(v2::Sizing::default());
2964 for system in [&absent, &empty] {
2965 let binary = v2::system_from_binary(v2::system_to_binary(system).as_slice())
2966 .expect("decode must succeed");
2967 assert_eq!(system, &binary);
2968 let json = v2::system_to_json_pretty(system).expect("serializes as JSON");
2969 assert_eq!(
2970 system,
2971 &v2::system_from_json(&json).expect("the JSON parses")
2972 );
2973 }
2974 assert_ne!(
2975 v2::system_to_binary(&absent),
2976 v2::system_to_binary(&empty),
2977 "the binary encodings differ"
2978 );
2979 }
2980
2981 #[test]
2982 fn system_text_format_round_trip_preserves_system() {
2983 let system = fixture();
2984 let text = v2::system_to_text_format(&system).expect("the fixture serializes as prototext");
2985 assert!(
2986 text.starts_with("name:"),
2987 "fields print in schema index order, got: {text}"
2988 );
2989 assert_eq!(
2990 system,
2991 v2::system_from_text_format(&text).expect("prototext parsing must succeed")
2992 );
2993 }
2994
2995 /// `pkg.Name` for a declared name; the implicit component of a lone
2996 /// service, which no package declares, is its own qualified name.
2997 #[test]
2998 fn qualified_names_follow_the_one_derivation() {
2999 let system = fixture();
3000 assert_eq!(system.qualified_name(), "veh.topology.Vehicle");
3001 assert_eq!(system.components[0].qualified_name(), "veh.topology.Cruise");
3002 assert_eq!(system.components[1].qualified_name(), "veh.diag.access");
3003 assert_eq!(
3004 system.distributions[0].qualified_name(),
3005 "veh.topology.Adas"
3006 );
3007 }
3008}