Skip to main content

code_system_graph_core/
protobuf_contracts.rs

1//! Owned, deterministic protobuf and generated gRPC source contracts.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use proto_parser::{
6    Element, Enum, FieldCommon, Group, ImportKind, Literal, Message, Parser, ProtoOption, Rpc, Service
7};
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11use crate::{ExtractionBudgets, ExtractionLimitExceeded, ExtractionTracker, SourceLanguage};
12
13const MAX_FIELD_NUMBER: i64 = 536_870_911;
14const FIRST_RESERVED_IMPLEMENTATION_FIELD: i64 = 19_000;
15const LAST_RESERVED_IMPLEMENTATION_FIELD: i64 = 19_999;
16
17/// Protobuf language mode declared by a contract.
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ProtoSyntax {
21    /// Protocol Buffers version 2 syntax.
22    Proto2,
23    /// Protocol Buffers version 3 syntax.
24    Proto3,
25    /// Editions syntax and its declared edition identifier.
26    Edition {
27        /// Edition identifier, such as `2023`.
28        version: String,
29    },
30}
31
32/// Cardinality attached to a protobuf field.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ProtoFieldCardinality {
36    /// An unlabeled singular field.
37    Singular,
38    /// An explicitly optional field.
39    Optional,
40    /// A required proto2 field.
41    Required,
42    /// A repeated field, including maps.
43    Repeated,
44}
45
46/// Protobuf wire encoding used by a field value.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum ProtoWireType {
50    /// Wire type 0.
51    Varint,
52    /// Wire type 1.
53    Fixed64,
54    /// Wire type 2.
55    LengthDelimited,
56    /// Wire type 3 used by deprecated groups.
57    StartGroup,
58    /// Wire type 5.
59    Fixed32,
60    /// A custom type whose declaration is not available in this file.
61    Unknown,
62}
63
64/// Fully owned protobuf field contract.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ProtoField {
67    /// Field name as declared.
68    pub name: String,
69    /// Positive protobuf field number.
70    pub number: i64,
71    /// Declared scalar, enum, message, or map value type.
72    pub type_name: String,
73    /// Known protobuf wire encoding.
74    pub wire_type: ProtoWireType,
75    /// Declared field cardinality.
76    pub cardinality: ProtoFieldCardinality,
77    /// Enclosing `oneof` name when this is a oneof alternative.
78    pub oneof: Option<String>,
79    /// Map key type when this is a map field.
80    pub map_key_type: Option<String>,
81    /// Map value type when this is a map field.
82    pub map_value_type: Option<String>,
83    /// Generator-relevant field options.
84    pub options: BTreeMap<String, String>,
85    /// One-based declaration line.
86    pub line: u32,
87}
88
89/// Fully owned protobuf message contract.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct ProtoMessage {
92    /// Unqualified message name.
93    pub name: String,
94    /// Package-qualified and nesting-qualified message name.
95    pub full_name: String,
96    /// Direct fields sorted by field number and name.
97    pub fields: Vec<ProtoField>,
98    /// Directly nested messages sorted by fully qualified name.
99    pub messages: Vec<ProtoMessage>,
100    /// Directly nested enums sorted by fully qualified name.
101    pub enums: Vec<ProtoEnum>,
102    /// Reserved number declarations in canonical protobuf notation.
103    pub reserved_numbers: Vec<String>,
104    /// Reserved field names.
105    pub reserved_names: Vec<String>,
106    /// Generator-relevant message options.
107    pub options: BTreeMap<String, String>,
108    /// Whether this declaration extends an existing message.
109    pub is_extension: bool,
110    /// One-based declaration line.
111    pub line: u32,
112}
113
114/// Fully owned protobuf enum value.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct ProtoEnumValue {
117    /// Enum value name.
118    pub name: String,
119    /// Signed numeric enum value.
120    pub number: i64,
121    /// Generator-relevant value options.
122    pub options: BTreeMap<String, String>,
123    /// One-based declaration line.
124    pub line: u32,
125}
126
127/// Fully owned protobuf enum contract.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct ProtoEnum {
130    /// Unqualified enum name.
131    pub name: String,
132    /// Package-qualified and nesting-qualified enum name.
133    pub full_name: String,
134    /// Numeric values sorted by number and name.
135    pub values: Vec<ProtoEnumValue>,
136    /// Reserved numeric declarations in canonical protobuf notation.
137    pub reserved_numbers: Vec<String>,
138    /// Reserved enum value names.
139    pub reserved_names: Vec<String>,
140    /// Generator-relevant enum options.
141    pub options: BTreeMap<String, String>,
142    /// One-based declaration line.
143    pub line: u32,
144}
145
146/// Fully owned protobuf RPC method contract.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct ProtoRpcMethod {
149    /// RPC method name.
150    pub name: String,
151    /// Declared request message type.
152    pub request_type: String,
153    /// Declared response message type.
154    pub response_type: String,
155    /// Whether the client streams request messages.
156    pub client_streaming: bool,
157    /// Whether the server streams response messages.
158    pub server_streaming: bool,
159    /// Generator-relevant method options.
160    pub options: BTreeMap<String, String>,
161    /// One-based declaration line.
162    pub line: u32,
163}
164
165/// Fully owned protobuf service contract.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct ProtoService {
168    /// Unqualified service name.
169    pub name: String,
170    /// Package-qualified service name.
171    pub full_name: String,
172    /// RPC methods sorted by name and signature.
173    pub methods: Vec<ProtoRpcMethod>,
174    /// Generator-relevant service options.
175    pub options: BTreeMap<String, String>,
176    /// One-based declaration line.
177    pub line: u32,
178}
179
180/// Exact generated gRPC service/method marker found in generated source.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum ProtoGeneratedRole {
184    /// Generated client or stub invocation.
185    Client,
186    /// Generated server or handler registration.
187    Server,
188    /// Exact method marker whose generated role is not explicit.
189    Unknown,
190}
191
192/// Exact generated gRPC service/method marker found in generated source.
193#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
194pub struct ProtoGeneratedMarker {
195    /// Generated source language.
196    pub language: SourceLanguage,
197    /// Repository-relative source path supplied by the caller.
198    pub source_path: String,
199    /// Generator family identified by the explicit header.
200    pub generator: String,
201    /// Generated client/server role when explicit on the marker line.
202    pub role: ProtoGeneratedRole,
203    /// Package-qualified service name from the exact RPC path.
204    pub service: String,
205    /// Method name from the exact RPC path.
206    pub method: String,
207    /// Exact canonical gRPC path, such as `/example.Greeter/SayHello`.
208    pub rpc_path: String,
209    /// One-based generated header line.
210    pub header_line: u32,
211    /// One-based line containing the exact RPC marker.
212    pub line: u32,
213}
214
215/// Fully owned protobuf file contract.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct ProtoFile {
218    /// Repository-relative source path supplied by the caller.
219    pub source_path: String,
220    /// Declared syntax, with absent syntax represented by the proto2 default.
221    pub syntax: ProtoSyntax,
222    /// One-based syntax or edition declaration line.
223    pub syntax_line: Option<u32>,
224    /// Declared package name.
225    pub package: Option<String>,
226    /// One-based package declaration line.
227    pub package_line: Option<u32>,
228    /// All imported protobuf paths.
229    pub imports: Vec<String>,
230    /// Imports declared with the `public` qualifier.
231    pub public_imports: Vec<String>,
232    /// Imports declared with the `weak` qualifier.
233    pub weak_imports: Vec<String>,
234    /// First one-based line for each imported path.
235    pub import_lines: BTreeMap<String, u32>,
236    /// Generator-relevant file options.
237    pub options: BTreeMap<String, String>,
238    /// Top-level messages sorted by fully qualified name.
239    pub messages: Vec<ProtoMessage>,
240    /// Top-level enums sorted by fully qualified name.
241    pub enums: Vec<ProtoEnum>,
242    /// Services sorted by fully qualified name.
243    pub services: Vec<ProtoService>,
244}
245
246/// Persistable protobuf contract payload produced from one source-owned artifact.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
249pub enum ProtobufDocument {
250    /// Declarative `.proto` file.
251    File(Box<ProtoFile>),
252    /// Exact method markers from an explicitly generated source file.
253    Generated(Vec<ProtoGeneratedMarker>),
254}
255
256/// Error returned when a protobuf contract is malformed or internally inconsistent.
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
258#[serde(tag = "kind", rename_all = "snake_case")]
259pub enum ProtobufExtractionError {
260    /// The protobuf parser rejected the source.
261    #[error("invalid protobuf `{source_path}` at {line}:{column}: {message}")]
262    Parse {
263        /// Source path supplied by the caller.
264        source_path: String,
265        /// One-based error line when available.
266        line: u32,
267        /// One-based error column when available.
268        column: u32,
269        /// Parser diagnostic without source content.
270        message: String,
271    },
272    /// The file contains conflicting or unsupported syntax declarations.
273    #[error("invalid protobuf declaration in `{source_path}` at line {line}: {message}")]
274    InvalidDeclaration {
275        /// Source path supplied by the caller.
276        source_path: String,
277        /// One-based declaration line.
278        line: u32,
279        /// Stable diagnostic without source content.
280        message: String,
281    },
282    /// A field number violates protobuf's valid numeric range.
283    #[error(
284        "invalid protobuf field number {number} for `{field}` in `{source_path}` at line {line}"
285    )]
286    InvalidFieldNumber {
287        /// Source path supplied by the caller.
288        source_path: String,
289        /// Field name.
290        field: String,
291        /// Rejected field number.
292        number: i64,
293        /// One-based declaration line.
294        line: u32,
295    },
296    /// A message repeats a field name or number.
297    #[error("duplicate protobuf field {coordinate} `{value}` in `{message}` at line {line}")]
298    DuplicateField {
299        /// Fully qualified message name.
300        message: String,
301        /// Duplicate coordinate: `name` or `number`.
302        coordinate: String,
303        /// Duplicate value.
304        value: String,
305        /// One-based declaration line of the duplicate.
306        line: u32,
307    },
308    /// Extraction exceeded one configured invocation resource.
309    #[error(transparent)]
310    LimitExceeded(#[from] ExtractionLimitExceeded),
311}
312
313/// Extracts an owned protobuf contract from a `.proto` source.
314///
315/// Output collections are sorted and deduplicated. The result contains contract metadata and
316/// line coordinates, but never retains the input source or source snippets.
317///
318/// # Errors
319///
320/// Returns [`ProtobufExtractionError`] when parsing fails, declarations conflict, field numbers
321/// are invalid, or a message repeats a field name or number.
322pub fn extract_protobuf(
323    source_path: &str,
324    input: &str,
325) -> Result<ProtoFile, ProtobufExtractionError> {
326    let mut tracker = ExtractionTracker::new(
327        source_path,
328        "code-system-graph.protobuf",
329        &ExtractionBudgets::default(),
330    );
331    extract_protobuf_with_tracker(source_path, input, &mut tracker)
332}
333
334/// Extracts a protobuf contract using an existing per-invocation tracker.
335///
336/// # Errors
337///
338/// Returns an error for malformed input or an exhausted extraction budget.
339pub fn extract_protobuf_with_tracker(
340    source_path: &str,
341    input: &str,
342    tracker: &mut ExtractionTracker,
343) -> Result<ProtoFile, ProtobufExtractionError> {
344    tracker.check_input_bytes(u64::try_from(input.len()).unwrap_or(u64::MAX))?;
345    tracker.charge_portable_path(source_path)?;
346    precheck_protobuf_depth(input, tracker)?;
347    let mut parser = Parser::with_filename(input, source_path);
348    let parsed = parser.parse();
349    tracker.check_structured_time()?;
350    let parsed = parsed.map_err(|error| ProtobufExtractionError::Parse {
351        source_path: source_path.to_owned(),
352        line: source_line(error.position.line),
353        column: source_line(error.position.column),
354        message: "parser rejected malformed input".to_owned(),
355    })?;
356
357    let (syntax, syntax_line) = extract_syntax(source_path, &parsed.elements)?;
358    let (package, package_line) = extract_package(source_path, &parsed.elements)?;
359    let package_scope = package.as_deref().unwrap_or_default();
360    let known_types = collect_type_names(&parsed.elements, package_scope, tracker)?;
361    let mut imports = Vec::new();
362    let mut public_imports = Vec::new();
363    let mut weak_imports = Vec::new();
364    let mut import_lines = BTreeMap::new();
365    let mut messages = Vec::new();
366    let mut enums = Vec::new();
367    let mut services = Vec::new();
368
369    for element in &parsed.elements {
370        tracker.charge_work(1)?;
371        match element {
372            Element::Import(import) => {
373                imports.push(import.filename.clone());
374                import_lines
375                    .entry(import.filename.clone())
376                    .or_insert_with(|| source_line(import.position.line));
377                match import.kind {
378                    ImportKind::Default => {}
379                    ImportKind::Public => public_imports.push(import.filename.clone()),
380                    ImportKind::Weak => weak_imports.push(import.filename.clone()),
381                }
382            }
383            Element::Message(message) => messages.push(extract_message(
384                source_path,
385                message,
386                package_scope,
387                &known_types,
388                1,
389                tracker,
390            )?),
391            Element::Enum(enumeration) => {
392                enums.push(extract_enum(enumeration, package_scope, 1, tracker)?);
393            }
394            Element::Service(service) => {
395                services.push(extract_service(service, package_scope, 1, tracker)?);
396            }
397            _ => {}
398        }
399    }
400
401    sort_dedup(&mut imports);
402    sort_dedup(&mut public_imports);
403    sort_dedup(&mut weak_imports);
404    messages.sort_by(|left, right| left.full_name.cmp(&right.full_name));
405    messages.dedup_by(|left, right| left.full_name == right.full_name);
406    enums.sort_by(|left, right| left.full_name.cmp(&right.full_name));
407    enums.dedup_by(|left, right| left.full_name == right.full_name);
408    services.sort_by(|left, right| left.full_name.cmp(&right.full_name));
409    services.dedup_by(|left, right| left.full_name == right.full_name);
410
411    let output = ProtoFile {
412        source_path: source_path.to_owned(),
413        syntax,
414        syntax_line,
415        package,
416        package_line,
417        imports,
418        public_imports,
419        weak_imports,
420        import_lines,
421        options: options_from_elements(&parsed.elements, 1, tracker)?,
422        messages,
423        enums,
424        services,
425    };
426    tracker.check_structured_time()?;
427    Ok(output)
428}
429
430/// Finds exact gRPC method paths in explicitly generated source.
431///
432/// A marker is emitted only when the file contains a recognized generated-code header and a
433/// string literal whose complete value has the canonical `/qualified.Service/Method` shape.
434/// Paths and filenames alone never establish that a source file is generated.
435#[must_use]
436pub fn parse_protobuf_generated_source(
437    language: SourceLanguage,
438    source_path: &str,
439    input: &str,
440) -> Vec<ProtoGeneratedMarker> {
441    let Some((header_line, generator)) = input
442        .lines()
443        .enumerate()
444        .find_map(|(index, line)| generated_header(line).map(|name| (index + 1, name)))
445    else {
446        return Vec::new();
447    };
448    let header_line = source_line(header_line);
449    let mut markers = Vec::new();
450
451    let lines = input.lines().collect::<Vec<_>>();
452    for (index, line) in lines.iter().copied().enumerate() {
453        if is_comment_only(line) {
454            continue;
455        }
456        for literal in quoted_literals(line) {
457            let Some((service, method)) = split_rpc_path(&literal) else {
458                continue;
459            };
460            markers.push(ProtoGeneratedMarker {
461                language,
462                source_path: source_path.to_owned(),
463                generator: generator.to_owned(),
464                role: generated_role(&lines, index),
465                service: service.to_owned(),
466                method: method.to_owned(),
467                rpc_path: literal,
468                header_line,
469                line: source_line(index + 1),
470            });
471        }
472    }
473
474    markers.sort();
475    markers.dedup_by(|left, right| {
476        left.language == right.language
477            && left.source_path == right.source_path
478            && left.service == right.service
479            && left.method == right.method
480            && left.line == right.line
481    });
482    markers
483}
484
485fn generated_role(lines: &[&str], index: usize) -> ProtoGeneratedRole {
486    let start = index.saturating_sub(3);
487    let end = (index + 1).min(lines.len());
488    let normalized = lines[start..end].join(" ").to_ascii_lowercase();
489    if ["channel", "client", "stub", "invoke"]
490        .iter()
491        .any(|token| normalized.contains(token))
492    {
493        ProtoGeneratedRole::Client
494    } else if ["server", "handler", "servicer", "bind_service"]
495        .iter()
496        .any(|token| normalized.contains(token))
497    {
498        ProtoGeneratedRole::Server
499    } else {
500        ProtoGeneratedRole::Unknown
501    }
502}
503
504fn extract_syntax(
505    source_path: &str,
506    elements: &[Element],
507) -> Result<(ProtoSyntax, Option<u32>), ProtobufExtractionError> {
508    let declarations = elements
509        .iter()
510        .filter_map(|element| match element {
511            Element::Syntax(syntax) => Some((
512                syntax.position.line,
513                match syntax.value.as_str() {
514                    "proto2" => Ok(ProtoSyntax::Proto2),
515                    "proto3" => Ok(ProtoSyntax::Proto3),
516                    other => Err(format!("unsupported syntax `{other}`")),
517                },
518            )),
519            Element::Edition(edition) => Some((
520                edition.position.line,
521                Ok(ProtoSyntax::Edition {
522                    version: edition.value.clone(),
523                }),
524            )),
525            _ => None,
526        })
527        .collect::<Vec<_>>();
528
529    if declarations.len() > 1 {
530        return Err(ProtobufExtractionError::InvalidDeclaration {
531            source_path: source_path.to_owned(),
532            line: source_line(declarations[1].0),
533            message: "multiple syntax or edition declarations".to_owned(),
534        });
535    }
536    match declarations.into_iter().next() {
537        Some((line, syntax)) => syntax
538            .map(|syntax| (syntax, Some(source_line(line))))
539            .map_err(|message| ProtobufExtractionError::InvalidDeclaration {
540                source_path: source_path.to_owned(),
541                line: source_line(line),
542                message,
543            }),
544        None => Ok((ProtoSyntax::Proto2, None)),
545    }
546}
547
548fn extract_package(
549    source_path: &str,
550    elements: &[Element],
551) -> Result<(Option<String>, Option<u32>), ProtobufExtractionError> {
552    let packages = elements
553        .iter()
554        .filter_map(|element| match element {
555            Element::Package(package) => Some((&package.name, package.position.line)),
556            _ => None,
557        })
558        .collect::<Vec<_>>();
559    if packages.len() > 1 {
560        return Err(ProtobufExtractionError::InvalidDeclaration {
561            source_path: source_path.to_owned(),
562            line: source_line(packages[1].1),
563            message: "multiple package declarations".to_owned(),
564        });
565    }
566    Ok(packages.first().map_or((None, None), |(name, line)| {
567        (Some((*name).clone()), Some(source_line(*line)))
568    }))
569}
570
571#[derive(Debug, Default)]
572struct KnownTypes {
573    enums: BTreeSet<String>,
574    messages: BTreeSet<String>,
575}
576
577fn collect_type_names(
578    elements: &[Element],
579    scope: &str,
580    tracker: &mut ExtractionTracker,
581) -> Result<KnownTypes, ExtractionLimitExceeded> {
582    let mut known = KnownTypes::default();
583    // The top-level file is not a recursive message scope. Its first message is depth one.
584    collect_type_names_into(elements, scope, &mut known, 0, tracker)?;
585    Ok(known)
586}
587
588fn collect_type_names_into(
589    elements: &[Element],
590    scope: &str,
591    known: &mut KnownTypes,
592    depth: u64,
593    tracker: &mut ExtractionTracker,
594) -> Result<(), ExtractionLimitExceeded> {
595    tracker.check_structural_depth(depth)?;
596    for element in elements {
597        tracker.charge_work(1)?;
598        match element {
599            Element::Enum(enumeration) => {
600                known.enums.insert(qualified_name(scope, &enumeration.name));
601            }
602            Element::Message(message) => {
603                let message_scope = qualified_name(scope, &message.name);
604                known.messages.insert(message_scope.clone());
605                collect_type_names_into(
606                    &message.elements,
607                    &message_scope,
608                    known,
609                    depth.saturating_add(1),
610                    tracker,
611                )?;
612            }
613            Element::Group(group) => {
614                let group_scope = qualified_name(scope, &group.name);
615                known.messages.insert(group_scope.clone());
616                collect_type_names_into(
617                    &group.elements,
618                    &group_scope,
619                    known,
620                    depth.saturating_add(1),
621                    tracker,
622                )?;
623            }
624            _ => {}
625        }
626    }
627    Ok(())
628}
629
630fn extract_message(
631    source_path: &str,
632    message: &Message,
633    parent_scope: &str,
634    known_types: &KnownTypes,
635    depth: u64,
636    tracker: &mut ExtractionTracker,
637) -> Result<ProtoMessage, ProtobufExtractionError> {
638    extract_message_elements(
639        source_path,
640        &message.name,
641        message.is_extend,
642        message.position.line,
643        &message.elements,
644        parent_scope,
645        known_types,
646        depth,
647        tracker,
648    )
649}
650
651fn extract_group_message(
652    source_path: &str,
653    group: &Group,
654    parent_scope: &str,
655    known_types: &KnownTypes,
656    depth: u64,
657    tracker: &mut ExtractionTracker,
658) -> Result<ProtoMessage, ProtobufExtractionError> {
659    extract_message_elements(
660        source_path,
661        &group.name,
662        false,
663        group.position.line,
664        &group.elements,
665        parent_scope,
666        known_types,
667        depth,
668        tracker,
669    )
670}
671
672#[expect(
673    clippy::too_many_lines,
674    reason = "One traversal keeps message fields, nesting, options, and reservations consistent"
675)]
676#[expect(
677    clippy::too_many_arguments,
678    reason = "The parser message context and shared budget tracker are independent inputs"
679)]
680fn extract_message_elements(
681    source_path: &str,
682    name: &str,
683    is_extension: bool,
684    line: usize,
685    elements: &[Element],
686    parent_scope: &str,
687    known_types: &KnownTypes,
688    depth: u64,
689    tracker: &mut ExtractionTracker,
690) -> Result<ProtoMessage, ProtobufExtractionError> {
691    tracker.check_structural_depth(depth)?;
692    let full_name = qualified_name(parent_scope, name);
693    let mut fields = Vec::new();
694    let mut messages = Vec::new();
695    let mut enums = Vec::new();
696    let mut reserved_numbers = Vec::new();
697    let mut reserved_names = Vec::new();
698
699    for element in elements {
700        tracker.charge_work(1)?;
701        match element {
702            Element::NormalField(field) => fields.push(field_from_common(
703                source_path,
704                &field.field,
705                cardinality(field.optional, field.required, field.repeated),
706                None,
707                None,
708                &full_name,
709                known_types,
710                depth,
711                tracker,
712            )?),
713            Element::MapField(field) => fields.push(field_from_common(
714                source_path,
715                &field.field,
716                ProtoFieldCardinality::Repeated,
717                None,
718                Some(&field.key_type),
719                &full_name,
720                known_types,
721                depth,
722                tracker,
723            )?),
724            Element::Oneof(oneof) => {
725                for child in &oneof.elements {
726                    if let Element::OneofField(field) = child {
727                        fields.push(field_from_common(
728                            source_path,
729                            &field.field,
730                            ProtoFieldCardinality::Singular,
731                            Some(&oneof.name),
732                            None,
733                            &full_name,
734                            known_types,
735                            depth,
736                            tracker,
737                        )?);
738                    }
739                }
740            }
741            Element::Message(nested) => {
742                messages.push(extract_message(
743                    source_path,
744                    nested,
745                    &full_name,
746                    known_types,
747                    depth.saturating_add(1),
748                    tracker,
749                )?);
750            }
751            Element::Group(group) => {
752                validate_field_number(
753                    source_path,
754                    &group.name,
755                    group.sequence,
756                    group.position.line,
757                )?;
758                fields.push(ProtoField {
759                    name: group.name.clone(),
760                    number: group.sequence,
761                    type_name: group.name.clone(),
762                    wire_type: ProtoWireType::StartGroup,
763                    cardinality: cardinality(group.optional, group.required, group.repeated),
764                    oneof: None,
765                    map_key_type: None,
766                    map_value_type: None,
767                    options: BTreeMap::new(),
768                    line: source_line(group.position.line),
769                });
770                messages.push(extract_group_message(
771                    source_path,
772                    group,
773                    &full_name,
774                    known_types,
775                    depth.saturating_add(1),
776                    tracker,
777                )?);
778            }
779            Element::Enum(enumeration) => enums.push(extract_enum(
780                enumeration,
781                &full_name,
782                depth.saturating_add(1),
783                tracker,
784            )?),
785            Element::Reserved(reserved) => {
786                reserved_numbers.extend(
787                    reserved
788                        .ranges
789                        .iter()
790                        .map(proto_parser::Range::source_representation),
791                );
792                reserved_names.extend(reserved.field_names.iter().cloned());
793            }
794            _ => {}
795        }
796    }
797
798    fields.sort_by(|left, right| {
799        left.number
800            .cmp(&right.number)
801            .then_with(|| left.name.cmp(&right.name))
802    });
803    validate_unique_fields(&full_name, &fields)?;
804    messages.sort_by(|left, right| left.full_name.cmp(&right.full_name));
805    messages.dedup_by(|left, right| left.full_name == right.full_name);
806    enums.sort_by(|left, right| left.full_name.cmp(&right.full_name));
807    enums.dedup_by(|left, right| left.full_name == right.full_name);
808    sort_dedup(&mut reserved_numbers);
809    sort_dedup(&mut reserved_names);
810
811    Ok(ProtoMessage {
812        name: name.to_owned(),
813        full_name,
814        fields,
815        messages,
816        enums,
817        reserved_numbers,
818        reserved_names,
819        options: options_from_elements(elements, depth, tracker)?,
820        is_extension,
821        line: source_line(line),
822    })
823}
824
825#[expect(
826    clippy::too_many_arguments,
827    reason = "The parser field shape and shared budget tracker are independent inputs"
828)]
829fn field_from_common(
830    source_path: &str,
831    field: &FieldCommon,
832    cardinality: ProtoFieldCardinality,
833    oneof: Option<&str>,
834    map_key_type: Option<&str>,
835    message_scope: &str,
836    known_types: &KnownTypes,
837    depth: u64,
838    tracker: &mut ExtractionTracker,
839) -> Result<ProtoField, ProtobufExtractionError> {
840    tracker.check_structural_depth(depth)?;
841    validate_field_number(
842        source_path,
843        &field.name,
844        field.sequence,
845        field.position.line,
846    )?;
847    let map_value_type = map_key_type.map(|_| field.type_name.clone());
848    Ok(ProtoField {
849        name: field.name.clone(),
850        number: field.sequence,
851        type_name: if let Some(key_type) = map_key_type {
852            format!("map<{key_type}, {}>", field.type_name)
853        } else {
854            field.type_name.clone()
855        },
856        wire_type: if map_key_type.is_some() {
857            ProtoWireType::LengthDelimited
858        } else {
859            wire_type(&field.type_name, message_scope, known_types)
860        },
861        cardinality,
862        oneof: oneof.map(str::to_owned),
863        map_key_type: map_key_type.map(str::to_owned),
864        map_value_type,
865        options: relevant_options(&field.options, depth, tracker)?,
866        line: source_line(field.position.line),
867    })
868}
869
870fn validate_field_number(
871    source_path: &str,
872    field: &str,
873    number: i64,
874    line: usize,
875) -> Result<(), ProtobufExtractionError> {
876    if number <= 0
877        || number > MAX_FIELD_NUMBER
878        || (FIRST_RESERVED_IMPLEMENTATION_FIELD..=LAST_RESERVED_IMPLEMENTATION_FIELD)
879            .contains(&number)
880    {
881        return Err(ProtobufExtractionError::InvalidFieldNumber {
882            source_path: source_path.to_owned(),
883            field: field.to_owned(),
884            number,
885            line: source_line(line),
886        });
887    }
888    Ok(())
889}
890
891fn validate_unique_fields(
892    message: &str,
893    fields: &[ProtoField],
894) -> Result<(), ProtobufExtractionError> {
895    let mut names = BTreeSet::new();
896    let mut numbers = BTreeSet::new();
897    for field in fields {
898        if !names.insert(&field.name) {
899            return Err(ProtobufExtractionError::DuplicateField {
900                message: message.to_owned(),
901                coordinate: "name".to_owned(),
902                value: field.name.clone(),
903                line: field.line,
904            });
905        }
906        if !numbers.insert(field.number) {
907            return Err(ProtobufExtractionError::DuplicateField {
908                message: message.to_owned(),
909                coordinate: "number".to_owned(),
910                value: field.number.to_string(),
911                line: field.line,
912            });
913        }
914    }
915    Ok(())
916}
917
918fn extract_enum(
919    enumeration: &Enum,
920    parent_scope: &str,
921    depth: u64,
922    tracker: &mut ExtractionTracker,
923) -> Result<ProtoEnum, ProtobufExtractionError> {
924    tracker.check_structural_depth(depth)?;
925    let mut values = Vec::new();
926    let mut reserved_numbers = Vec::new();
927    let mut reserved_names = Vec::new();
928    for element in &enumeration.elements {
929        tracker.charge_work(1)?;
930        match element {
931            Element::EnumField(value) => values.push(ProtoEnumValue {
932                name: value.name.clone(),
933                number: value.integer,
934                options: options_from_elements(&value.elements, depth, tracker)?,
935                line: source_line(value.position.line),
936            }),
937            Element::Reserved(reserved) => {
938                reserved_numbers.extend(
939                    reserved
940                        .ranges
941                        .iter()
942                        .map(proto_parser::Range::source_representation),
943                );
944                reserved_names.extend(reserved.field_names.iter().cloned());
945            }
946            _ => {}
947        }
948    }
949    values.sort_by(|left, right| {
950        left.number
951            .cmp(&right.number)
952            .then_with(|| left.name.cmp(&right.name))
953    });
954    values.dedup_by(|left, right| left.number == right.number && left.name == right.name);
955    sort_dedup(&mut reserved_numbers);
956    sort_dedup(&mut reserved_names);
957    Ok(ProtoEnum {
958        name: enumeration.name.clone(),
959        full_name: qualified_name(parent_scope, &enumeration.name),
960        values,
961        reserved_numbers,
962        reserved_names,
963        options: options_from_elements(&enumeration.elements, depth, tracker)?,
964        line: source_line(enumeration.position.line),
965    })
966}
967
968fn extract_service(
969    service: &Service,
970    package: &str,
971    depth: u64,
972    tracker: &mut ExtractionTracker,
973) -> Result<ProtoService, ProtobufExtractionError> {
974    tracker.check_structural_depth(depth)?;
975    let mut methods = Vec::new();
976    for element in &service.elements {
977        tracker.charge_work(1)?;
978        if let Element::Rpc(rpc) = element {
979            methods.push(extract_rpc(rpc, depth.saturating_add(1), tracker)?);
980        }
981    }
982    methods.sort_by(|left, right| {
983        left.name
984            .cmp(&right.name)
985            .then_with(|| left.request_type.cmp(&right.request_type))
986            .then_with(|| left.response_type.cmp(&right.response_type))
987    });
988    methods.dedup();
989    Ok(ProtoService {
990        name: service.name.clone(),
991        full_name: qualified_name(package, &service.name),
992        methods,
993        options: options_from_elements(&service.elements, depth, tracker)?,
994        line: source_line(service.position.line),
995    })
996}
997
998fn extract_rpc(
999    rpc: &Rpc,
1000    depth: u64,
1001    tracker: &mut ExtractionTracker,
1002) -> Result<ProtoRpcMethod, ProtobufExtractionError> {
1003    tracker.check_structural_depth(depth)?;
1004    Ok(ProtoRpcMethod {
1005        name: rpc.name.clone(),
1006        request_type: rpc.request_type.clone(),
1007        response_type: rpc.returns_type.clone(),
1008        client_streaming: rpc.streams_request,
1009        server_streaming: rpc.streams_returns,
1010        options: options_from_elements(&rpc.elements, depth, tracker)?,
1011        line: source_line(rpc.position.line),
1012    })
1013}
1014
1015fn cardinality(optional: bool, required: bool, repeated: bool) -> ProtoFieldCardinality {
1016    if repeated {
1017        ProtoFieldCardinality::Repeated
1018    } else if required {
1019        ProtoFieldCardinality::Required
1020    } else if optional {
1021        ProtoFieldCardinality::Optional
1022    } else {
1023        ProtoFieldCardinality::Singular
1024    }
1025}
1026
1027fn wire_type(type_name: &str, message_scope: &str, known_types: &KnownTypes) -> ProtoWireType {
1028    match type_name.trim_start_matches('.') {
1029        "double" | "fixed64" | "sfixed64" => ProtoWireType::Fixed64,
1030        "float" | "fixed32" | "sfixed32" => ProtoWireType::Fixed32,
1031        "int32" | "int64" | "uint32" | "uint64" | "sint32" | "sint64" | "bool" => {
1032            ProtoWireType::Varint
1033        }
1034        "string" | "bytes" => ProtoWireType::LengthDelimited,
1035        custom if resolves_type(custom, message_scope, &known_types.enums) => ProtoWireType::Varint,
1036        custom if resolves_type(custom, message_scope, &known_types.messages) => {
1037            ProtoWireType::LengthDelimited
1038        }
1039        _ => ProtoWireType::Unknown,
1040    }
1041}
1042
1043fn resolves_type(type_name: &str, message_scope: &str, names: &BTreeSet<String>) -> bool {
1044    if let Some(absolute) = type_name.strip_prefix('.') {
1045        return names.contains(absolute);
1046    }
1047    let mut scope = message_scope;
1048    loop {
1049        if names.contains(&qualified_name(scope, type_name)) {
1050            return true;
1051        }
1052        let Some((parent, _)) = scope.rsplit_once('.') else {
1053            break;
1054        };
1055        scope = parent;
1056    }
1057    names.contains(type_name)
1058}
1059
1060fn options_from_elements(
1061    elements: &[Element],
1062    depth: u64,
1063    tracker: &mut ExtractionTracker,
1064) -> Result<BTreeMap<String, String>, ExtractionLimitExceeded> {
1065    let mut options = BTreeMap::new();
1066    for element in elements {
1067        if let Element::Option(option) = element
1068            && is_relevant_option(&option.name)
1069        {
1070            tracker.charge_work(1)?;
1071            options.insert(
1072                option.name.clone(),
1073                literal_value(&option.constant, depth.saturating_add(1), tracker)?,
1074            );
1075        }
1076    }
1077    Ok(options)
1078}
1079
1080fn relevant_options(
1081    source: &[ProtoOption],
1082    depth: u64,
1083    tracker: &mut ExtractionTracker,
1084) -> Result<BTreeMap<String, String>, ExtractionLimitExceeded> {
1085    let mut options = BTreeMap::new();
1086    for option in source
1087        .iter()
1088        .filter(|option| is_relevant_option(&option.name))
1089    {
1090        tracker.charge_work(1)?;
1091        options.insert(
1092            option.name.clone(),
1093            literal_value(&option.constant, depth.saturating_add(1), tracker)?,
1094        );
1095    }
1096    Ok(options)
1097}
1098
1099fn is_relevant_option(name: &str) -> bool {
1100    matches!(
1101        name,
1102        "cc_enable_arenas"
1103            | "cc_generic_services"
1104            | "csharp_namespace"
1105            | "ctype"
1106            | "deprecated"
1107            | "go_package"
1108            | "idempotency_level"
1109            | "java_generic_services"
1110            | "java_multiple_files"
1111            | "java_outer_classname"
1112            | "java_package"
1113            | "json_name"
1114            | "jstype"
1115            | "objc_class_prefix"
1116            | "optimize_for"
1117            | "packed"
1118            | "php_class_prefix"
1119            | "php_metadata_namespace"
1120            | "php_namespace"
1121            | "py_generic_services"
1122            | "ruby_package"
1123            | "swift_prefix"
1124    ) || option_name_matches_root(name, "google.api.http")
1125        || [
1126            "grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger",
1127            "grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation",
1128            "grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema",
1129            "grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field",
1130            "grpc.gateway.protoc_gen_openapiv2.options.openapiv2_tag",
1131        ]
1132        .iter()
1133        .any(|root| option_name_matches_root(name, root))
1134}
1135
1136fn option_name_matches_root(name: &str, root: &str) -> bool {
1137    if name == root {
1138        return true;
1139    }
1140    name.strip_prefix('(')
1141        .and_then(|value| value.strip_prefix(root))
1142        .is_some_and(|suffix| suffix == ")" || suffix.starts_with(")."))
1143}
1144
1145fn literal_value(
1146    literal: &Literal,
1147    depth: u64,
1148    tracker: &mut ExtractionTracker,
1149) -> Result<String, ExtractionLimitExceeded> {
1150    tracker.check_structural_depth(depth)?;
1151    tracker.charge_work(1)?;
1152    if let Some(array) = &literal.array {
1153        let mut values = Vec::new();
1154        for value in array {
1155            values.push(literal_value(value, depth.saturating_add(1), tracker)?);
1156        }
1157        let value = format!("[{}]", values.join(","));
1158        tracker.charge_string(&value)?;
1159        return Ok(value);
1160    }
1161    if let Some(map) = &literal.ordered_map {
1162        let mut values = Vec::new();
1163        for entry in map {
1164            let nested = literal_value(&entry.literal, depth.saturating_add(1), tracker)?;
1165            values.push(format!("{}:{nested}", entry.name));
1166        }
1167        let value = format!("{{{}}}", values.join(","));
1168        tracker.charge_string(&value)?;
1169        return Ok(value);
1170    }
1171    tracker.charge_string(&literal.source)?;
1172    Ok(literal.source.clone())
1173}
1174
1175fn precheck_protobuf_depth(
1176    input: &str,
1177    tracker: &mut ExtractionTracker,
1178) -> Result<(), ExtractionLimitExceeded> {
1179    let bytes = input.as_bytes();
1180    let mut cursor = 0;
1181    let mut depth = 0_u64;
1182    let mut quote = None;
1183    let mut escaped = false;
1184    let mut line_comment = false;
1185    let mut block_comment = false;
1186    let mut string_start = None;
1187    let mut accumulated_string_bytes = 0_u64;
1188    let mut literal_context = false;
1189    let mut consecutive_literal_comments = 0_u64;
1190    while cursor < bytes.len() {
1191        if cursor.is_multiple_of(1_024) {
1192            tracker.check_structured_time()?;
1193        }
1194        let byte = bytes[cursor];
1195        let next = bytes.get(cursor.saturating_add(1)).copied();
1196        if line_comment {
1197            line_comment = byte != b'\n';
1198        } else if block_comment {
1199            if byte == b'*' && next == Some(b'/') {
1200                block_comment = false;
1201                cursor = cursor.saturating_add(1);
1202            }
1203        } else if let Some(delimiter) = quote {
1204            if escaped {
1205                escaped = false;
1206            } else if byte == b'\\' {
1207                escaped = true;
1208            } else if byte == delimiter {
1209                let start = string_start.take().unwrap_or(cursor);
1210                let observed = u64::try_from(cursor.saturating_sub(start)).unwrap_or(u64::MAX);
1211                tracker.check_string_bytes(observed)?;
1212                accumulated_string_bytes = accumulated_string_bytes.saturating_add(observed);
1213                tracker.check_accumulated_string_bytes(accumulated_string_bytes)?;
1214                quote = None;
1215            }
1216        } else if byte == b'/' && next == Some(b'/') {
1217            charge_protobuf_comment_recursion(
1218                literal_context,
1219                &mut consecutive_literal_comments,
1220                tracker,
1221            )?;
1222            line_comment = true;
1223            cursor = cursor.saturating_add(1);
1224        } else if byte == b'/' && next == Some(b'*') {
1225            charge_protobuf_comment_recursion(
1226                literal_context,
1227                &mut consecutive_literal_comments,
1228                tracker,
1229            )?;
1230            block_comment = true;
1231            cursor = cursor.saturating_add(1);
1232        } else if matches!(byte, b'"' | b'\'') {
1233            consecutive_literal_comments = 0;
1234            tracker.charge_work(1)?;
1235            quote = Some(byte);
1236            string_start = Some(cursor.saturating_add(1));
1237        } else if matches!(byte, b'{' | b'[' | b'(') {
1238            consecutive_literal_comments = 0;
1239            tracker.charge_work(1)?;
1240            depth = depth.saturating_add(1);
1241            tracker.check_structural_depth(depth)?;
1242        } else if matches!(byte, b'}' | b']' | b')') {
1243            consecutive_literal_comments = 0;
1244            tracker.charge_work(1)?;
1245            depth = depth.saturating_sub(1);
1246        } else if byte == b';' {
1247            literal_context = false;
1248            consecutive_literal_comments = 0;
1249            tracker.charge_work(1)?;
1250            tracker.charge_observation(1)?;
1251        } else if byte == b'=' || (literal_context && byte == b':') {
1252            literal_context = true;
1253            consecutive_literal_comments = 0;
1254            tracker.charge_work(1)?;
1255        } else if byte == b'_' || byte.is_ascii_alphabetic() {
1256            consecutive_literal_comments = 0;
1257            cursor = precheck_protobuf_identifier(
1258                input,
1259                cursor,
1260                &mut accumulated_string_bytes,
1261                tracker,
1262            )?;
1263            continue;
1264        } else if !byte.is_ascii_whitespace() {
1265            consecutive_literal_comments = 0;
1266            tracker.charge_work(1)?;
1267        }
1268        cursor = cursor.saturating_add(1);
1269    }
1270    Ok(())
1271}
1272
1273fn precheck_protobuf_identifier(
1274    input: &str,
1275    start: usize,
1276    accumulated_string_bytes: &mut u64,
1277    tracker: &mut ExtractionTracker,
1278) -> Result<usize, ExtractionLimitExceeded> {
1279    let bytes = input.as_bytes();
1280    let mut cursor = start.saturating_add(1);
1281    while cursor < bytes.len()
1282        && (bytes[cursor] == b'_' || bytes[cursor] == b'.' || bytes[cursor].is_ascii_alphanumeric())
1283    {
1284        cursor = cursor.saturating_add(1);
1285    }
1286    let observed = u64::try_from(cursor.saturating_sub(start)).unwrap_or(u64::MAX);
1287    tracker.charge_work(1)?;
1288    tracker.check_identifier_bytes(observed)?;
1289    *accumulated_string_bytes = accumulated_string_bytes.saturating_add(observed);
1290    tracker.check_accumulated_string_bytes(*accumulated_string_bytes)?;
1291    if matches!(
1292        &input[start..cursor],
1293        "message" | "enum" | "service" | "rpc"
1294    ) {
1295        tracker.charge_observation(1)?;
1296    }
1297    Ok(cursor)
1298}
1299
1300fn charge_protobuf_comment_recursion(
1301    literal_context: bool,
1302    consecutive_comments: &mut u64,
1303    tracker: &ExtractionTracker,
1304) -> Result<(), ExtractionLimitExceeded> {
1305    if literal_context {
1306        *consecutive_comments = consecutive_comments.saturating_add(1);
1307        tracker.check_structural_depth(*consecutive_comments)?;
1308    }
1309    Ok(())
1310}
1311
1312fn qualified_name(scope: &str, name: &str) -> String {
1313    if scope.is_empty() || name.starts_with('.') {
1314        name.trim_start_matches('.').to_owned()
1315    } else {
1316        format!("{scope}.{name}")
1317    }
1318}
1319
1320fn generated_header(line: &str) -> Option<&'static str> {
1321    let trimmed = line.trim().trim_start_matches('\u{feff}');
1322    if trimmed.starts_with("// Code generated by protoc-gen-") && trimmed.ends_with("DO NOT EDIT.")
1323    {
1324        return Some("protoc");
1325    }
1326    if (trimmed.starts_with("// Generated by the protocol buffer compiler.")
1327        || trimmed.starts_with("# Generated by the protocol buffer compiler."))
1328        && trimmed.contains("DO NOT EDIT!")
1329    {
1330        return Some("protoc");
1331    }
1332    if trimmed == "# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!" {
1333        return Some("grpc-python");
1334    }
1335    if trimmed.starts_with("// This file is @generated by prost-build.") {
1336        return Some("prost-build");
1337    }
1338    None
1339}
1340
1341fn is_comment_only(line: &str) -> bool {
1342    let trimmed = line.trim_start();
1343    trimmed.starts_with("//") || trimmed.starts_with('#') || trimmed.starts_with('*')
1344}
1345
1346fn quoted_literals(line: &str) -> Vec<String> {
1347    let mut output = Vec::new();
1348    let characters = line.char_indices().collect::<Vec<_>>();
1349    let mut cursor = 0;
1350    while cursor < characters.len() {
1351        let quote = characters[cursor].1;
1352        if !matches!(quote, '"' | '\'' | '`') {
1353            cursor += 1;
1354            continue;
1355        }
1356        let start = characters[cursor].0 + quote.len_utf8();
1357        let mut end = None;
1358        let mut escaped = false;
1359        cursor += 1;
1360        while cursor < characters.len() {
1361            let character = characters[cursor].1;
1362            if character == '\\' {
1363                escaped = true;
1364                cursor = cursor.saturating_add(2);
1365                continue;
1366            }
1367            if character == quote {
1368                end = Some(characters[cursor].0);
1369                cursor += 1;
1370                break;
1371            }
1372            cursor += 1;
1373        }
1374        if !escaped
1375            && let Some(end) = end
1376            && let Some(value) = line.get(start..end)
1377        {
1378            output.push(value.to_owned());
1379        }
1380    }
1381    output
1382}
1383
1384fn split_rpc_path(path: &str) -> Option<(&str, &str)> {
1385    let rest = path.strip_prefix('/')?;
1386    let (service, method) = rest.split_once('/')?;
1387    if service.is_empty()
1388        || method.is_empty()
1389        || method.contains('/')
1390        || !service.split('.').all(is_proto_identifier)
1391        || !is_proto_identifier(method)
1392    {
1393        return None;
1394    }
1395    Some((service, method))
1396}
1397
1398fn is_proto_identifier(value: &str) -> bool {
1399    let mut characters = value.chars();
1400    characters
1401        .next()
1402        .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
1403        && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
1404}
1405
1406fn source_line(value: usize) -> u32 {
1407    u32::try_from(value).unwrap_or(u32::MAX)
1408}
1409
1410fn sort_dedup<T: Ord>(values: &mut Vec<T>) {
1411    values.sort();
1412    values.dedup();
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417    use std::fmt::Write as _;
1418    use std::process::Command;
1419
1420    use super::{
1421        ProtoFieldCardinality, ProtoSyntax, ProtoWireType, ProtobufExtractionError, extract_protobuf, extract_protobuf_with_tracker, parse_protobuf_generated_source
1422    };
1423    use crate::{ExtractionBudgets, ExtractionResource, ExtractionTracker, SourceLanguage};
1424
1425    const COMPLETE_PROTO: &str = r#"syntax = "proto3";
1426package example.v1;
1427
1428import "google/protobuf/empty.proto";
1429import public "common.proto";
1430import weak "optional.proto";
1431option java_package = "com.example.v1";
1432
1433message Request {
1434  reserved 7, 9 to 11;
1435  reserved "old_name";
1436  string name = 1 [json_name = "displayName"];
1437  repeated int64 ids = 2;
1438  map<string, bytes> labels = 3;
1439  oneof target {
1440    string email = 4;
1441    int32 account_id = 5;
1442  }
1443  enum State {
1444    STATE_UNSPECIFIED = 0;
1445    ACTIVE = 1;
1446  }
1447  State state = 6;
1448  Nested nested = 8;
1449  message Nested {
1450    fixed32 token = 1;
1451  }
1452}
1453
1454enum Result {
1455  RESULT_UNSPECIFIED = 0;
1456  OK = 1;
1457  FAILED = -1;
1458}
1459
1460service Greeter {
1461  rpc Chat(stream Request) returns (stream Request) {
1462    option idempotency_level = NO_SIDE_EFFECTS;
1463  }
1464  rpc Get(google.protobuf.Empty) returns (Request);
1465}
1466"#;
1467
1468    #[test]
1469    fn extract_should_preserve_field_numbers_wire_types_and_shapes() {
1470        let result = extract_protobuf("api/example.proto", COMPLETE_PROTO);
1471
1472        assert!(matches!(
1473            result,
1474            Ok(file)
1475                if file.messages[0].fields.iter().map(|field| (
1476                    field.number,
1477                    field.wire_type,
1478                    field.cardinality,
1479                    field.oneof.as_deref(),
1480                    field.map_key_type.as_deref(),
1481                )).collect::<Vec<_>>() == vec![
1482                    (1, ProtoWireType::LengthDelimited, ProtoFieldCardinality::Singular, None, None),
1483                    (2, ProtoWireType::Varint, ProtoFieldCardinality::Repeated, None, None),
1484                    (3, ProtoWireType::LengthDelimited, ProtoFieldCardinality::Repeated, None, Some("string")),
1485                    (4, ProtoWireType::LengthDelimited, ProtoFieldCardinality::Singular, Some("target"), None),
1486                    (5, ProtoWireType::Varint, ProtoFieldCardinality::Singular, Some("target"), None),
1487                    (6, ProtoWireType::Varint, ProtoFieldCardinality::Singular, None, None),
1488                    (8, ProtoWireType::LengthDelimited, ProtoFieldCardinality::Singular, None, None),
1489                ]
1490        ));
1491    }
1492
1493    #[test]
1494    fn extract_should_preserve_rpc_streaming_and_method_types() {
1495        let result = extract_protobuf("api/example.proto", COMPLETE_PROTO);
1496
1497        assert!(matches!(
1498            result,
1499            Ok(file)
1500                if file.services[0].full_name == "example.v1.Greeter"
1501                    && file.services[0].methods[0].name == "Chat"
1502                    && file.services[0].methods[0].client_streaming
1503                    && file.services[0].methods[0].server_streaming
1504                    && file.services[0].methods[0].request_type == "Request"
1505                    && file.services[0].methods[0].response_type == "Request"
1506        ));
1507    }
1508
1509    #[test]
1510    fn extract_should_preserve_sorted_import_kinds_and_lines() {
1511        let result = extract_protobuf("api/example.proto", COMPLETE_PROTO);
1512
1513        assert!(matches!(
1514            result,
1515            Ok(file)
1516                if file.imports == vec![
1517                    "common.proto",
1518                    "google/protobuf/empty.proto",
1519                    "optional.proto",
1520                ]
1521                    && file.public_imports == vec!["common.proto"]
1522                    && file.weak_imports == vec!["optional.proto"]
1523                    && file.import_lines.get("common.proto") == Some(&5)
1524        ));
1525    }
1526
1527    #[test]
1528    fn extract_should_preserve_enums_nested_messages_and_reservations() {
1529        let result = extract_protobuf("api/example.proto", COMPLETE_PROTO);
1530
1531        assert!(matches!(
1532            result,
1533            Ok(file)
1534                if file.syntax == ProtoSyntax::Proto3
1535                    && file.package.as_deref() == Some("example.v1")
1536                    && file.enums[0].values.iter().map(|value| value.number).collect::<Vec<_>>() == vec![-1, 0, 1]
1537                    && file.messages[0].messages[0].full_name == "example.v1.Request.Nested"
1538                    && file.messages[0].enums[0].full_name == "example.v1.Request.State"
1539                    && file.messages[0].reserved_numbers == vec!["7", "9 to 11"]
1540                    && file.messages[0].reserved_names == vec!["old_name"]
1541        ));
1542    }
1543
1544    #[test]
1545    fn extract_should_return_parse_error_for_malformed_source() {
1546        let result = extract_protobuf(
1547            "api/broken.proto",
1548            "syntax = \"proto3\"; message Broken { string value = ; }",
1549        );
1550
1551        assert!(matches!(
1552            result,
1553            Err(ProtobufExtractionError::Parse { source_path, .. })
1554                if source_path == "api/broken.proto"
1555        ));
1556    }
1557
1558    #[test]
1559    fn extract_should_reject_invalid_field_number() {
1560        let result = extract_protobuf(
1561            "api/broken.proto",
1562            "syntax = \"proto3\"; message Broken { string value = 19000; }",
1563        );
1564
1565        assert!(matches!(
1566            result,
1567            Err(ProtobufExtractionError::InvalidFieldNumber { number: 19_000, .. })
1568        ));
1569    }
1570
1571    fn nested_messages(depth: usize) -> String {
1572        let mut source = String::from("syntax = \"proto3\";\n");
1573        for index in 0..depth {
1574            writeln!(source, "message M{index} {{").expect("String writes are infallible");
1575        }
1576        source.push_str("string value = 1;\n");
1577        source.push_str(&"}\n".repeat(depth));
1578        source
1579    }
1580
1581    #[test]
1582    fn protobuf_depth_should_accept_64_and_reject_65_before_recursive_parse() {
1583        let budgets = ExtractionBudgets {
1584            max_structural_depth_per_artifact: 64,
1585            ..ExtractionBudgets::default()
1586        };
1587        let mut exact = ExtractionTracker::new("exact.proto", "protobuf", &budgets);
1588        let mut above = ExtractionTracker::new("above.proto", "protobuf", &budgets);
1589
1590        let exact_result =
1591            extract_protobuf_with_tracker("exact.proto", &nested_messages(64), &mut exact);
1592        assert!(exact_result.is_ok(), "exact depth failed: {exact_result:?}");
1593        assert!(matches!(
1594            extract_protobuf_with_tracker("above.proto", &nested_messages(65), &mut above),
1595            Err(ProtobufExtractionError::LimitExceeded(error))
1596                if error.resource == ExtractionResource::StructuralDepth
1597                    && error.observed == 65
1598                    && error.maximum == 64
1599        ));
1600    }
1601
1602    #[test]
1603    fn protobuf_preflight_should_charge_observations_before_parsing() {
1604        let budgets = ExtractionBudgets {
1605            max_observations_per_artifact: 1,
1606            ..ExtractionBudgets::default()
1607        };
1608        let mut tracker = ExtractionTracker::new("facts.proto", "protobuf", &budgets);
1609        let result = extract_protobuf_with_tracker(
1610            "facts.proto",
1611            "syntax = \"proto3\"; message Item { string value = 1; }",
1612            &mut tracker,
1613        );
1614
1615        assert!(matches!(
1616            result,
1617            Err(ProtobufExtractionError::LimitExceeded(error))
1618                if error.resource == ExtractionResource::Observations
1619                    && error.observed == 2
1620                    && error.maximum == 1
1621        ));
1622    }
1623
1624    #[test]
1625    fn protobuf_should_not_persist_substring_matched_custom_option_literals() {
1626        let secret = "top-secret-value-51f2";
1627        let source =
1628            format!("syntax = \"proto3\"; option (evil.google.api.http_secret) = \"{secret}\";");
1629        let file = extract_protobuf("api/options.proto", &source).expect("valid protobuf");
1630        let payload = serde_json::to_string(&file).expect("protobuf contract should serialize");
1631
1632        assert!(file.options.is_empty());
1633        assert!(!payload.contains(secret));
1634    }
1635
1636    #[test]
1637    fn deeply_nested_protobuf_should_fail_cleanly_in_a_subprocess() {
1638        const CHILD_ENV: &str = "CSGRAPH_PROTO_DEPTH_CHILD";
1639        if std::env::var_os(CHILD_ENV).is_some() {
1640            let budgets = ExtractionBudgets {
1641                max_structural_depth_per_artifact: 64,
1642                ..ExtractionBudgets::default()
1643            };
1644            let mut tracker = ExtractionTracker::new("deep.proto", "protobuf", &budgets);
1645            assert!(matches!(
1646                extract_protobuf_with_tracker("deep.proto", &nested_messages(20_000), &mut tracker),
1647                Err(ProtobufExtractionError::LimitExceeded(_))
1648            ));
1649            return;
1650        }
1651
1652        let output = Command::new(std::env::current_exe().expect("test executable should exist"))
1653            .args([
1654                "--exact",
1655                "protobuf_contracts::tests::deeply_nested_protobuf_should_fail_cleanly_in_a_subprocess",
1656            ])
1657            .env(CHILD_ENV, "1")
1658            .output()
1659            .expect("child test should launch");
1660        assert!(
1661            output.status.success(),
1662            "child failed: {}",
1663            String::from_utf8_lossy(&output.stderr)
1664        );
1665    }
1666
1667    #[test]
1668    fn recursive_literal_comments_should_fail_cleanly_before_the_parser() {
1669        const CHILD_ENV: &str = "CSGRAPH_PROTO_COMMENT_CHILD";
1670        if std::env::var_os(CHILD_ENV).is_some() {
1671            let budgets = ExtractionBudgets {
1672                max_structural_depth_per_artifact: 64,
1673                ..ExtractionBudgets::default()
1674            };
1675            let comments = "// comment\n".repeat(20_000);
1676            let source = format!("syntax = \"proto3\"; option optimize_for = {comments}SPEED;");
1677            let mut tracker = ExtractionTracker::new("comments.proto", "protobuf", &budgets);
1678            assert!(matches!(
1679                extract_protobuf_with_tracker("comments.proto", &source, &mut tracker),
1680                Err(ProtobufExtractionError::LimitExceeded(error))
1681                    if error.resource == ExtractionResource::StructuralDepth
1682                        && error.observed == 65
1683                        && error.maximum == 64
1684            ));
1685            return;
1686        }
1687
1688        let output = Command::new(std::env::current_exe().expect("test executable should exist"))
1689            .args([
1690                "--exact",
1691                "protobuf_contracts::tests::recursive_literal_comments_should_fail_cleanly_before_the_parser",
1692            ])
1693            .env(CHILD_ENV, "1")
1694            .output()
1695            .expect("child test should launch");
1696        assert!(
1697            output.status.success(),
1698            "child failed without a typed limit: {}",
1699            String::from_utf8_lossy(&output.stderr)
1700        );
1701    }
1702
1703    #[test]
1704    fn generated_source_should_require_explicit_header_and_exact_rpc_path() {
1705        let source = r#"// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
1706const Greeter_Chat_FullMethodName = "/example.v1.Greeter/Chat"
1707const invalid = "/example.v1.Greeter/Chat/extra"
1708"#;
1709        let markers = parse_protobuf_generated_source(
1710            SourceLanguage::Go,
1711            "generated/service_grpc.pb.go",
1712            source,
1713        );
1714
1715        assert!(matches!(
1716            markers.as_slice(),
1717            [marker]
1718                if marker.generator == "protoc"
1719                    && marker.service == "example.v1.Greeter"
1720                    && marker.method == "Chat"
1721                    && marker.rpc_path == "/example.v1.Greeter/Chat"
1722                    && marker.header_line == 1
1723                    && marker.line == 2
1724        ));
1725    }
1726
1727    #[test]
1728    fn generated_source_should_not_infer_from_generated_filename() {
1729        let markers = parse_protobuf_generated_source(
1730            SourceLanguage::Python,
1731            "generated/service_pb2_grpc.py",
1732            "channel.unary_unary('/example.v1.Greeter/Get')",
1733        );
1734
1735        assert!(markers.is_empty());
1736    }
1737
1738    #[test]
1739    fn generated_source_should_ignore_rpc_paths_inside_comments() {
1740        let markers = parse_protobuf_generated_source(
1741            SourceLanguage::Java,
1742            "GeneratedService.java",
1743            "// Generated by the protocol buffer compiler.  DO NOT EDIT!\n// \"/example.Greeter/Get\"\n",
1744        );
1745
1746        assert!(markers.is_empty());
1747    }
1748}