1use 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ProtoSyntax {
21 Proto2,
23 Proto3,
25 Edition {
27 version: String,
29 },
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ProtoFieldCardinality {
36 Singular,
38 Optional,
40 Required,
42 Repeated,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum ProtoWireType {
50 Varint,
52 Fixed64,
54 LengthDelimited,
56 StartGroup,
58 Fixed32,
60 Unknown,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ProtoField {
67 pub name: String,
69 pub number: i64,
71 pub type_name: String,
73 pub wire_type: ProtoWireType,
75 pub cardinality: ProtoFieldCardinality,
77 pub oneof: Option<String>,
79 pub map_key_type: Option<String>,
81 pub map_value_type: Option<String>,
83 pub options: BTreeMap<String, String>,
85 pub line: u32,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct ProtoMessage {
92 pub name: String,
94 pub full_name: String,
96 pub fields: Vec<ProtoField>,
98 pub messages: Vec<ProtoMessage>,
100 pub enums: Vec<ProtoEnum>,
102 pub reserved_numbers: Vec<String>,
104 pub reserved_names: Vec<String>,
106 pub options: BTreeMap<String, String>,
108 pub is_extension: bool,
110 pub line: u32,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct ProtoEnumValue {
117 pub name: String,
119 pub number: i64,
121 pub options: BTreeMap<String, String>,
123 pub line: u32,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct ProtoEnum {
130 pub name: String,
132 pub full_name: String,
134 pub values: Vec<ProtoEnumValue>,
136 pub reserved_numbers: Vec<String>,
138 pub reserved_names: Vec<String>,
140 pub options: BTreeMap<String, String>,
142 pub line: u32,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct ProtoRpcMethod {
149 pub name: String,
151 pub request_type: String,
153 pub response_type: String,
155 pub client_streaming: bool,
157 pub server_streaming: bool,
159 pub options: BTreeMap<String, String>,
161 pub line: u32,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct ProtoService {
168 pub name: String,
170 pub full_name: String,
172 pub methods: Vec<ProtoRpcMethod>,
174 pub options: BTreeMap<String, String>,
176 pub line: u32,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum ProtoGeneratedRole {
184 Client,
186 Server,
188 Unknown,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
194pub struct ProtoGeneratedMarker {
195 pub language: SourceLanguage,
197 pub source_path: String,
199 pub generator: String,
201 pub role: ProtoGeneratedRole,
203 pub service: String,
205 pub method: String,
207 pub rpc_path: String,
209 pub header_line: u32,
211 pub line: u32,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct ProtoFile {
218 pub source_path: String,
220 pub syntax: ProtoSyntax,
222 pub syntax_line: Option<u32>,
224 pub package: Option<String>,
226 pub package_line: Option<u32>,
228 pub imports: Vec<String>,
230 pub public_imports: Vec<String>,
232 pub weak_imports: Vec<String>,
234 pub import_lines: BTreeMap<String, u32>,
236 pub options: BTreeMap<String, String>,
238 pub messages: Vec<ProtoMessage>,
240 pub enums: Vec<ProtoEnum>,
242 pub services: Vec<ProtoService>,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
249pub enum ProtobufDocument {
250 File(Box<ProtoFile>),
252 Generated(Vec<ProtoGeneratedMarker>),
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
258#[serde(tag = "kind", rename_all = "snake_case")]
259pub enum ProtobufExtractionError {
260 #[error("invalid protobuf `{source_path}` at {line}:{column}: {message}")]
262 Parse {
263 source_path: String,
265 line: u32,
267 column: u32,
269 message: String,
271 },
272 #[error("invalid protobuf declaration in `{source_path}` at line {line}: {message}")]
274 InvalidDeclaration {
275 source_path: String,
277 line: u32,
279 message: String,
281 },
282 #[error(
284 "invalid protobuf field number {number} for `{field}` in `{source_path}` at line {line}"
285 )]
286 InvalidFieldNumber {
287 source_path: String,
289 field: String,
291 number: i64,
293 line: u32,
295 },
296 #[error("duplicate protobuf field {coordinate} `{value}` in `{message}` at line {line}")]
298 DuplicateField {
299 message: String,
301 coordinate: String,
303 value: String,
305 line: u32,
307 },
308 #[error(transparent)]
310 LimitExceeded(#[from] ExtractionLimitExceeded),
311}
312
313pub 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
334pub 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#[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 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}