Skip to main content

prototext_core/serialize/render_text/
mod.rs

1// SPDX-FileCopyrightText: 2025-2026 Frederic Ruget <fred@atlant.is> (GitHub: @douzebis)
2// SPDX-FileCopyrightText: 2025-2026 THALES CLOUD SECURISE SAS
3//
4// SPDX-License-Identifier: MIT
5
6mod helpers;
7mod packed;
8mod varint;
9
10use std::cell::{Cell, RefCell};
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use prost_reflect::{Cardinality, ExtensionDescriptor, FieldDescriptor, Kind, MessageDescriptor};
15
16use crate::helpers::{
17    decode_double, decode_fixed32, decode_fixed64, decode_float, decode_sfixed32, decode_sfixed64,
18};
19use crate::helpers::{
20    parse_varint, parse_wiretag, WiretagResult, WT_END_GROUP, WT_I32, WT_I64, WT_LEN,
21    WT_START_GROUP, WT_VARINT,
22};
23use crate::schema::ParsedSchema;
24use crate::serialize::common::{
25    format_double_protoc, format_fixed32_protoc, format_fixed64_protoc, format_float_protoc,
26    format_sfixed32_protoc, format_sfixed64_protoc, format_wire_fixed32_protoc,
27    format_wire_fixed64_protoc,
28};
29
30use helpers::{
31    render_group_field, render_invalid, render_invalid_tag_type, render_len_field, render_scalar,
32    render_truncated_bytes, ScalarCtx,
33};
34use varint::{decode_varint_typed, render_varint_field, VarintKind};
35
36// Magic prefix that identifies a textual prototext payload.
37const PROTOTEXT_MAGIC: &[u8] = b"#@ prototext:";
38
39// ── FieldOrExt adapter ────────────────────────────────────────────────────────
40
41/// Unifies `FieldDescriptor` (regular field) and `ExtensionDescriptor`
42/// (extension field) for the subset of accessors used by the renderer.
43pub(super) enum FieldOrExt {
44    Field(FieldDescriptor),
45    Ext(ExtensionDescriptor),
46}
47
48impl FieldOrExt {
49    pub(super) fn kind(&self) -> Kind {
50        match self {
51            FieldOrExt::Field(f) => f.kind(),
52            FieldOrExt::Ext(e) => e.kind(),
53        }
54    }
55
56    pub(super) fn cardinality(&self) -> Cardinality {
57        match self {
58            FieldOrExt::Field(f) => f.cardinality(),
59            FieldOrExt::Ext(e) => e.cardinality(),
60        }
61    }
62
63    /// Returns `true` only for regular group fields; extensions cannot be groups.
64    pub(super) fn is_group(&self) -> bool {
65        match self {
66            FieldOrExt::Field(f) => f.is_group(),
67            FieldOrExt::Ext(_) => false,
68        }
69    }
70
71    pub(super) fn is_packed(&self) -> bool {
72        match self {
73            FieldOrExt::Field(f) => f.is_packed(),
74            FieldOrExt::Ext(_) => false,
75        }
76    }
77
78    /// Returns the raw value of the `packed` field option from the descriptor:
79    /// - `None`  — option absent (proto3 default applies)
80    /// - `Some(true)`  — `[packed=true]` explicitly set
81    /// - `Some(false)` — `[packed=false]` explicitly set
82    ///
83    /// Uses `prost_types::FieldDescriptorProto.options.packed: Option<bool>` directly —
84    /// O(1), zero allocation (no DynamicMessage decoding).
85    #[cfg(feature = "prost-bug-workaround")]
86    pub(super) fn raw_packed_option(&self) -> Option<bool> {
87        let proto = match self {
88            FieldOrExt::Field(f) => f.field_descriptor_proto(),
89            FieldOrExt::Ext(e) => e.field_descriptor_proto(),
90        };
91        proto.options.as_ref().and_then(|o| o.packed)
92    }
93
94    #[cfg(feature = "prost-bug-workaround")]
95    pub(super) fn parent_file_syntax(&self) -> prost_reflect::Syntax {
96        match self {
97            FieldOrExt::Field(f) => f.parent_file().syntax(),
98            FieldOrExt::Ext(e) => e.parent_file().syntax(),
99        }
100    }
101
102    /// The name to use in field-line output.
103    ///
104    /// Regular field: `"name"` (bare field name).
105    /// Extension field: `"[full.qualified.name]"`.
106    pub(super) fn display_name(&self) -> String {
107        match self {
108            FieldOrExt::Field(f) => f.name().to_owned(),
109            FieldOrExt::Ext(e) => format!("[{}]", e.full_name()),
110        }
111    }
112
113    /// Returns the underlying `FieldDescriptor` if this is a regular field,
114    /// or `None` for extension fields.
115    ///
116    /// Used to pass to functions that still take `Option<&FieldDescriptor>`.
117    #[allow(dead_code)]
118    pub(super) fn as_field(&self) -> Option<&FieldDescriptor> {
119        match self {
120            FieldOrExt::Field(f) => Some(f),
121            FieldOrExt::Ext(_) => None,
122        }
123    }
124}
125
126// ── Render-mode state ─────────────────────────────────────────────────────────
127//
128// `CBL_START` is set to `out.len()` by `write_close_brace` before writing a
129// `}\n` line, and reset to `out.len()` (past-end) by every other write.  It
130// is currently unused beyond being maintained; the close-brace folding feature
131// it was intended to support has been removed.
132//
133thread_local! {
134    pub(super) static CBL_START:   Cell<usize> = const { Cell::new(0) };
135    // Set once per `decode_and_render` call; read by every internal render fn.
136    pub(super) static ANNOTATIONS: Cell<bool>  = const { Cell::new(false) };
137    pub(super) static INDENT_SIZE: Cell<usize> = const { Cell::new(2) };
138    // Tracks recursion depth; managed via `enter_level()` / `LevelGuard`.
139    pub(super) static LEVEL:       Cell<usize> = const { Cell::new(0) };
140    // When true, google.protobuf.Any fields are expanded inline (spec 0089).
141    pub(super) static EXPAND_ANY:  Cell<bool>  = const { Cell::new(true) };
142    // Optional header lines injected after the magic line (e.g. # Type / # Score).
143    pub static EXTRA_HEADER: RefCell<String> = const { RefCell::new(String::new()) };
144}
145
146/// RAII guard for `LEVEL`: increments on construction, decrements on drop.
147/// Guarantees the level is restored even if the callee panics.
148pub(super) struct LevelGuard;
149
150impl Drop for LevelGuard {
151    fn drop(&mut self) {
152        LEVEL.with(|l| l.set(l.get() - 1));
153    }
154}
155
156pub(super) fn enter_level() -> LevelGuard {
157    LEVEL.with(|l| l.set(l.get() + 1));
158    LevelGuard
159}
160
161/// Return `true` when `data` is already rendered prototext text (fast-path).
162pub fn is_prototext_text(data: &[u8]) -> bool {
163    data.starts_with(PROTOTEXT_MAGIC)
164}
165
166// ── Public entry point ────────────────────────────────────────────────────────
167
168/// Decode raw protobuf binary and render as protoc-style text in one pass.
169///
170/// Writes field lines into a pre-allocated `Vec<u8>`.  When `annotations` is
171/// true, a `#@ prototext: protoc\n` header is prepended; without annotations
172/// the header is omitted (encode is not possible without field annotations
173/// regardless).
174///
175/// Parameters mirror `format_as_text` in `lib.rs`.
176pub fn decode_and_render(
177    buf: &[u8],
178    schema: Option<&ParsedSchema>,
179    annotations: bool,
180    indent_size: usize,
181    expand_any: bool,
182) -> Vec<u8> {
183    let capacity = buf.len() * 8;
184    let mut out = Vec::with_capacity(capacity);
185
186    // Header — only emitted when annotations are on; without field-level
187    // annotations prototext encode cannot reconstruct the binary anyway, so
188    // the header would be misleading.
189    if annotations {
190        out.extend_from_slice(b"#@ prototext: protoc\n");
191    }
192    EXTRA_HEADER.with(|h| {
193        let h = h.borrow();
194        if !h.is_empty() {
195            out.extend_from_slice(h.as_bytes());
196        }
197    });
198    // Initialise render-mode state.
199    // CBL_START past the end so the first write_close_brace always takes
200    // the fresh-write path.
201    CBL_START.with(|c| c.set(out.len()));
202    ANNOTATIONS.with(|c| c.set(annotations));
203    INDENT_SIZE.with(|c| c.set(indent_size));
204    LEVEL.with(|c| c.set(0));
205    EXPAND_ANY.with(|c| c.set(expand_any));
206
207    // Build a flat name→MessageDescriptor map for nested-type lookups.
208    // Keyed by bare FQN (no leading dot), matching prost-reflect's convention.
209    let all_descriptors: Option<HashMap<String, Arc<MessageDescriptor>>> =
210        schema.map(build_descriptor_map);
211    let all_schemas = all_descriptors.as_ref();
212
213    let root_desc: Option<MessageDescriptor> = schema.and_then(|s| s.root_descriptor());
214
215    render_message(buf, 0, None, root_desc.as_ref(), all_schemas, &mut out);
216
217    // Development instrumentation — truncate event
218    #[cfg(debug_assertions)]
219    {
220        let actual = out.len();
221        if actual < capacity {
222            eprintln!(
223                "[render_text] truncate: input_len={} capacity={} actual={} ratio={:.2}x",
224                buf.len(),
225                capacity,
226                actual,
227                actual as f64 / buf.len().max(1) as f64
228            );
229        }
230    }
231
232    out
233}
234
235/// Build a `HashMap<bare_fqn, Arc<MessageDescriptor>>` from a `ParsedSchema`.
236fn build_descriptor_map(schema: &ParsedSchema) -> HashMap<String, Arc<MessageDescriptor>> {
237    schema
238        .pool()
239        .all_messages()
240        .map(|msg| (msg.full_name().to_string(), Arc::new(msg)))
241        .collect()
242}
243
244// ── Core recursive render-while-decode ───────────────────────────────────────
245
246/// Parse and render one protobuf message into `out`.
247///
248/// Returns `(next_pos, group_end_tag)`:
249/// - `next_pos`: byte position after this message (for the caller to
250///   continue its own parse loop, or for GROUP end detection).
251/// - `group_end_tag`: `Some(tag)` when parsing terminated on a `WT_END_GROUP`.
252pub(super) fn render_message(
253    buf: &[u8],
254    start: usize,
255    my_group: Option<u64>,
256    schema: Option<&MessageDescriptor>,
257    all_schemas: Option<&HashMap<String, Arc<MessageDescriptor>>>,
258    out: &mut Vec<u8>,
259) -> (usize, Option<WiretagResult>) {
260    let buflen = buf.len();
261    let mut pos = start;
262
263    loop {
264        if pos == buflen {
265            return (pos, None);
266        }
267
268        // ── Parse wire tag ────────────────────────────────────────────────────
269
270        let tag = parse_wiretag(buf, pos);
271
272        if let Some(ref wtag_gar) = tag.wtag_gar {
273            // Invalid wire tag: consume rest of buffer as INVALID_TAG_TYPE
274            render_invalid_tag_type(wtag_gar, out);
275            return (buflen, None);
276        }
277
278        let field_number = tag.wfield.unwrap();
279        let wire_type = tag.wtype.unwrap();
280        let tag_ohb = tag.wfield_ohb;
281        let tag_oor = tag.wfield_oor.is_some();
282        pos = tag.next_pos;
283
284        // ── Schema lookup ─────────────────────────────────────────────────────
285
286        let field_schema: Option<FieldOrExt> = schema.and_then(|s| {
287            if let Some(f) = s.get_field(field_number as u32) {
288                Some(FieldOrExt::Field(f))
289            } else {
290                s.get_extension(field_number as u32).map(FieldOrExt::Ext)
291            }
292        });
293
294        // ── Wire-type dispatch ────────────────────────────────────────────────
295
296        match wire_type {
297            // ── VARINT ───────────────────────────────────────────────────────
298            WT_VARINT => {
299                let vr = parse_varint(buf, pos);
300                if let Some(ref varint_gar) = vr.varint_gar {
301                    render_invalid(
302                        field_number,
303                        field_schema.as_ref(),
304                        tag_ohb,
305                        tag_oor,
306                        "INVALID_VARINT",
307                        varint_gar,
308                        out,
309                    );
310                    return (buflen, None);
311                }
312                pos = vr.next_pos;
313                let val_ohb = vr.varint_ohb;
314                let val = vr.varint.unwrap();
315
316                let (content_kind, typed_val) = if let Some(ref fs) = field_schema {
317                    decode_varint_typed(val, fs)
318                } else {
319                    (VarintKind::Wire, val)
320                };
321
322                render_varint_field(
323                    field_number,
324                    field_schema.as_ref(),
325                    tag_ohb,
326                    tag_oor,
327                    val_ohb,
328                    content_kind,
329                    typed_val,
330                    all_schemas.is_some(),
331                    out,
332                );
333            }
334
335            // ── FIXED64 ──────────────────────────────────────────────────────
336            WT_I64 => {
337                if pos + 8 > buflen {
338                    let raw = &buf[pos..];
339                    render_invalid(
340                        field_number,
341                        field_schema.as_ref(),
342                        tag_ohb,
343                        tag_oor,
344                        "INVALID_FIXED64",
345                        raw,
346                        out,
347                    );
348                    return (buflen, None);
349                }
350                let data = &buf[pos..pos + 8];
351                pos += 8;
352
353                let is_mismatch;
354                let mut nan_bits: Option<u64> = None;
355                let value_str = if let Some(ref fs) = field_schema {
356                    match fs.kind() {
357                        Kind::Double => {
358                            is_mismatch = false;
359                            let v = decode_double(data);
360                            if v.is_nan() {
361                                let bits = v.to_bits();
362                                if bits != f64::NAN.to_bits() {
363                                    nan_bits = Some(bits);
364                                }
365                            }
366                            format_double_protoc(v)
367                        }
368                        Kind::Fixed64 => {
369                            is_mismatch = false;
370                            format_fixed64_protoc(decode_fixed64(data))
371                        }
372                        Kind::Sfixed64 => {
373                            is_mismatch = false;
374                            format_sfixed64_protoc(decode_sfixed64(data))
375                        }
376                        _ => {
377                            is_mismatch = true;
378                            format_wire_fixed64_protoc(decode_fixed64(data))
379                        } // mismatch → hex
380                    }
381                } else {
382                    is_mismatch = false;
383                    format_wire_fixed64_protoc(decode_fixed64(data)) // unknown → hex
384                };
385
386                render_scalar(
387                    &ScalarCtx {
388                        field_number,
389                        field_schema: field_schema.as_ref(),
390                        tag_ohb,
391                        tag_oor,
392                        len_ohb: None,
393                        wire_type_name: "fixed64",
394                        nan_bits,
395                        type_mismatch: is_mismatch,
396                        schema_present: all_schemas.is_some(),
397                    },
398                    &value_str,
399                    is_mismatch,
400                    out,
401                );
402            }
403
404            // ── LENGTH-DELIMITED ─────────────────────────────────────────────
405            WT_LEN => {
406                let lr = parse_varint(buf, pos);
407                if let Some(ref varint_gar) = lr.varint_gar {
408                    render_invalid(
409                        field_number,
410                        field_schema.as_ref(),
411                        tag_ohb,
412                        tag_oor,
413                        "INVALID_LEN",
414                        varint_gar,
415                        out,
416                    );
417                    return (buflen, None);
418                }
419                let len_ohb = lr.varint_ohb;
420                pos = lr.next_pos;
421                let length = lr.varint.unwrap() as usize;
422
423                if pos + length > buflen {
424                    let missing = (length - (buflen - pos)) as u64;
425                    let raw = &buf[pos..];
426                    render_truncated_bytes(
427                        field_number,
428                        tag_ohb,
429                        tag_oor,
430                        len_ohb,
431                        missing,
432                        raw,
433                        out,
434                    );
435                    return (buflen, None);
436                }
437                let data = &buf[pos..pos + length];
438                pos += length;
439
440                render_len_field(
441                    field_number,
442                    field_schema.as_ref(),
443                    all_schemas,
444                    tag_ohb,
445                    tag_oor,
446                    len_ohb,
447                    data,
448                    out,
449                );
450            }
451
452            // ── START GROUP ──────────────────────────────────────────────────
453            WT_START_GROUP => {
454                render_group_field(
455                    buf,
456                    &mut pos,
457                    field_number,
458                    field_schema.as_ref(),
459                    all_schemas,
460                    tag_ohb,
461                    tag_oor,
462                    out,
463                );
464            }
465
466            // ── END GROUP ────────────────────────────────────────────────────
467            WT_END_GROUP => {
468                if my_group.is_none() {
469                    // Unexpected END_GROUP outside a group
470                    let raw = &buf[pos..];
471                    render_invalid(
472                        field_number,
473                        field_schema.as_ref(),
474                        tag_ohb,
475                        tag_oor,
476                        "INVALID_GROUP_END",
477                        raw,
478                        out,
479                    );
480                    return (buflen, None);
481                }
482                // Valid END_GROUP: return to parent without rendering a field.
483                return (pos, Some(tag));
484            }
485
486            // ── FIXED32 ──────────────────────────────────────────────────────
487            WT_I32 => {
488                if pos + 4 > buflen {
489                    let raw = &buf[pos..];
490                    render_invalid(
491                        field_number,
492                        field_schema.as_ref(),
493                        tag_ohb,
494                        tag_oor,
495                        "INVALID_FIXED32",
496                        raw,
497                        out,
498                    );
499                    return (buflen, None);
500                }
501                let data = &buf[pos..pos + 4];
502                pos += 4;
503
504                let is_mismatch;
505                let mut nan_bits: Option<u64> = None;
506                let value_str = if let Some(ref fs) = field_schema {
507                    match fs.kind() {
508                        Kind::Float => {
509                            is_mismatch = false;
510                            let v = decode_float(data);
511                            if v.is_nan() {
512                                let bits = v.to_bits();
513                                if bits != f32::NAN.to_bits() {
514                                    nan_bits = Some(bits as u64);
515                                }
516                            }
517                            format_float_protoc(v)
518                        }
519                        Kind::Fixed32 => {
520                            is_mismatch = false;
521                            format_fixed32_protoc(decode_fixed32(data))
522                        }
523                        Kind::Sfixed32 => {
524                            is_mismatch = false;
525                            format_sfixed32_protoc(decode_sfixed32(data))
526                        }
527                        _ => {
528                            is_mismatch = true;
529                            format_wire_fixed32_protoc(decode_fixed32(data))
530                        } // mismatch → hex (D2)
531                    }
532                } else {
533                    is_mismatch = false;
534                    format_wire_fixed32_protoc(decode_fixed32(data)) // unknown → hex
535                };
536
537                render_scalar(
538                    &ScalarCtx {
539                        field_number,
540                        field_schema: field_schema.as_ref(),
541                        tag_ohb,
542                        tag_oor,
543                        len_ohb: None,
544                        wire_type_name: "fixed32",
545                        nan_bits,
546                        type_mismatch: is_mismatch,
547                        schema_present: all_schemas.is_some(),
548                    },
549                    &value_str,
550                    is_mismatch,
551                    out,
552                );
553            }
554
555            _ => unreachable!("wire type > 5 caught by parse_wiretag"),
556        }
557    }
558}