1use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8type Mapping = serde_json::Map<String, Value>;
9use thiserror::Error;
10
11use crate::SourceLanguage;
12
13const MAX_ASYNCAPI_BYTES: usize = 4 * 1024 * 1024;
14const MAX_SOURCE_BYTES: usize = 1024 * 1024;
15const MAX_OBSERVATIONS: usize = 1_024;
16const MAX_SCHEMA_FIELDS: usize = 256;
17const MAX_EVIDENCE_TEXT_CHARS: usize = 160;
18const MAX_ERROR_CHARS: usize = 512;
19const MAX_REFERENCE_DEPTH: usize = 16;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum EventBroker {
25 Kafka,
27 RabbitMq,
29 AwsSns,
31 AwsSqs,
33 Nats,
35 GooglePubSub,
37 Generic,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum EventRole {
45 Publisher,
47 Subscriber,
49 Declaration,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum DeliverySemantics {
57 AtMostOnce,
59 AtLeastOnce,
61 ExactlyOnce,
63 BestEffort,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
71pub struct EventEvidenceLine {
72 pub line: u32,
74 pub text: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
80pub struct EventSchemaField {
81 pub name: String,
83 pub field_type: Option<String>,
85 pub required: bool,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct EventSchemaDefinition {
92 pub name: Option<String>,
94 pub version: Option<String>,
96 pub schema_format: Option<String>,
98 pub fields: Vec<EventSchemaField>,
100}
101
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct EventObservation {
105 pub broker: EventBroker,
107 pub role: EventRole,
109 pub channel: Option<String>,
111 pub namespace: Option<String>,
113 pub protocol: Option<String>,
115 pub event_type: Option<String>,
117 pub schema: Option<EventSchemaDefinition>,
119 pub partition_key: Option<String>,
121 pub routing_key: Option<String>,
123 pub delivery_semantics: Option<DeliverySemantics>,
125 pub dead_letter_channel: Option<String>,
127 pub language: Option<SourceLanguage>,
129 pub evidence: Vec<EventEvidenceLine>,
131 pub confidence: f32,
133 pub incomplete: bool,
135 pub warnings: Vec<String>,
137}
138
139#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
141pub struct EventDocument {
142 pub source_path: Option<String>,
144 pub specification: Option<String>,
146 pub specification_version: Option<String>,
148 pub observations: Vec<EventObservation>,
150 pub warnings: Vec<String>,
152 pub incomplete: bool,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
158#[non_exhaustive]
159pub enum EventExtractionError {
160 #[error("event contract `{source_path}` exceeds the {limit_bytes}-byte input limit")]
162 InputTooLarge {
163 source_path: String,
165 limit_bytes: usize,
167 },
168 #[error("invalid AsyncAPI document `{source_path}`: {message}")]
170 InvalidSyntax {
171 source_path: String,
173 message: String,
175 },
176 #[error("AsyncAPI document `{source_path}` must contain a top-level object")]
178 InvalidRoot {
179 source_path: String,
181 },
182 #[error("unsupported AsyncAPI version in `{source_path}`: {version}")]
184 UnsupportedVersion {
185 source_path: String,
187 version: String,
189 },
190 #[error("invalid AsyncAPI structure in `{source_path}`: {message}")]
192 InvalidStructure {
193 source_path: String,
195 message: String,
197 },
198}
199
200pub fn extract_asyncapi(
211 source_path: &str,
212 input: &str,
213) -> Result<EventDocument, EventExtractionError> {
214 if input.len() > MAX_ASYNCAPI_BYTES {
215 return Err(EventExtractionError::InputTooLarge {
216 source_path: source_path.to_owned(),
217 limit_bytes: MAX_ASYNCAPI_BYTES,
218 });
219 }
220 let root = parse_asyncapi_value(source_path, input)?;
221 let root_map = root
222 .as_object()
223 .ok_or_else(|| EventExtractionError::InvalidRoot {
224 source_path: source_path.to_owned(),
225 })?;
226 let version = mapping_string(root_map, "asyncapi").ok_or_else(|| {
227 EventExtractionError::UnsupportedVersion {
228 source_path: source_path.to_owned(),
229 version: "missing".to_owned(),
230 }
231 })?;
232 let major = if version.starts_with("2.") {
233 2
234 } else if version.starts_with("3.") {
235 3
236 } else {
237 return Err(EventExtractionError::UnsupportedVersion {
238 source_path: source_path.to_owned(),
239 version: bounded_text(version, MAX_ERROR_CHARS),
240 });
241 };
242
243 let mut context = AsyncApiContext::new(source_path, input, &root, version);
244 if major == 2 {
245 extract_asyncapi_v2(&mut context)?;
246 } else {
247 extract_asyncapi_v3(&mut context)?;
248 }
249 Ok(context.finish())
250}
251
252fn parse_asyncapi_value(source_path: &str, input: &str) -> Result<Value, EventExtractionError> {
253 let result = if input.trim_start().starts_with(['{', '[']) {
254 serde_json::from_str(input).map_err(|error| error.to_string())
255 } else {
256 crate::yaml::from_str(input).map_err(|error| error.to_string())
257 };
258 result.map_err(|message| EventExtractionError::InvalidSyntax {
259 source_path: source_path.to_owned(),
260 message: bounded_text(&message, MAX_ERROR_CHARS),
261 })
262}
263
264#[must_use]
270pub fn parse_event_source(language: SourceLanguage, input: &str) -> EventDocument {
271 let (source, truncated) = bounded_source(input);
272 let sanitized = sanitize_source(language, source);
273 let mut document = EventDocument::default();
274 if truncated {
275 document.incomplete = true;
276 document.warnings.push(format!(
277 "source input truncated at {MAX_SOURCE_BYTES} bytes"
278 ));
279 }
280 if input.contains('\0') {
281 document.incomplete = true;
282 document
283 .warnings
284 .push("source input contains a NUL byte".to_owned());
285 }
286
287 for index in 0..sanitized.len() {
288 if document.observations.len() >= MAX_OBSERVATIONS {
289 document.incomplete = true;
290 document.warnings.push(format!(
291 "observations truncated at {MAX_OBSERVATIONS} items"
292 ));
293 break;
294 }
295 let Some(candidate) = source_candidate(&sanitized, index) else {
296 continue;
297 };
298 let Some(recognition) = recognize_source_call(&candidate) else {
299 continue;
300 };
301 let line_number = u32::try_from(index + 1).unwrap_or(u32::MAX);
302 append_source_observations(
303 &mut document.observations,
304 language,
305 line_number,
306 &candidate,
307 recognition,
308 );
309 }
310 finish_document(&mut document);
311 document
312}
313
314struct AsyncApiContext<'a> {
315 source_path: &'a str,
316 input: &'a str,
317 root: &'a Value,
318 version: &'a str,
319 observations: Vec<EventObservation>,
320 warnings: Vec<String>,
321 incomplete: bool,
322 server: ServerFacts,
323}
324
325impl<'a> AsyncApiContext<'a> {
326 fn new(source_path: &'a str, input: &'a str, root: &'a Value, version: &'a str) -> Self {
327 let (server, warnings) = global_server_facts(root);
328 Self {
329 source_path,
330 input,
331 root,
332 version,
333 observations: Vec::new(),
334 incomplete: !warnings.is_empty(),
335 warnings,
336 server,
337 }
338 }
339
340 fn push(&mut self, mut observation: EventObservation) {
341 if self.observations.len() >= MAX_OBSERVATIONS {
342 self.incomplete = true;
343 self.warnings.push(format!(
344 "observations truncated at {MAX_OBSERVATIONS} items"
345 ));
346 return;
347 }
348 normalize_observation(&mut observation);
349 self.observations.push(observation);
350 }
351
352 fn finish(self) -> EventDocument {
353 let mut document = EventDocument {
354 source_path: Some(self.source_path.to_owned()),
355 specification: Some("asyncapi".to_owned()),
356 specification_version: Some(self.version.to_owned()),
357 observations: self.observations,
358 warnings: self.warnings,
359 incomplete: self.incomplete,
360 };
361 finish_document(&mut document);
362 document
363 }
364}
365
366#[derive(Debug, Clone, Default)]
367struct ServerFacts {
368 broker: Option<EventBroker>,
369 protocol: Option<String>,
370 namespace: Option<String>,
371 ambiguous: bool,
372}
373
374fn extract_asyncapi_v2(context: &mut AsyncApiContext<'_>) -> Result<(), EventExtractionError> {
375 let Some(channels) = value_get(context.root, "channels") else {
376 context.incomplete = true;
377 context
378 .warnings
379 .push("AsyncAPI document has no channels object".to_owned());
380 return Ok(());
381 };
382 let channel_map =
383 channels
384 .as_object()
385 .ok_or_else(|| EventExtractionError::InvalidStructure {
386 source_path: context.source_path.to_owned(),
387 message: "`channels` must be an object".to_owned(),
388 })?;
389 for (channel_key, channel_item) in channel_map {
390 let raw_channel = channel_key.as_str();
391 let resolved_item = resolve_local(context.root, channel_item);
392 let item = resolved_item.value;
393 let mut emitted = false;
394 for (operation_name, role) in [
395 ("publish", EventRole::Publisher),
396 ("subscribe", EventRole::Subscriber),
397 ] {
398 let Some(operation) = value_get(item, operation_name) else {
399 continue;
400 };
401 emitted = true;
402 append_asyncapi_operation(
403 context,
404 raw_channel,
405 role,
406 operation,
407 Some(item),
408 &resolved_item.warnings,
409 );
410 }
411 if !emitted {
412 let mut observation = asyncapi_observation(
413 context,
414 raw_channel,
415 EventRole::Declaration,
416 None,
417 Some(item),
418 None,
419 );
420 observation.warnings.extend(resolved_item.warnings);
421 context.push(observation);
422 }
423 }
424 Ok(())
425}
426
427fn extract_asyncapi_v3(context: &mut AsyncApiContext<'_>) -> Result<(), EventExtractionError> {
428 let channel_map = value_get(context.root, "channels")
429 .and_then(Value::as_object)
430 .ok_or_else(|| EventExtractionError::InvalidStructure {
431 source_path: context.source_path.to_owned(),
432 message: "`channels` must be an object".to_owned(),
433 })?;
434 let mut used_channels = BTreeSet::new();
435 if let Some(operations) = value_get(context.root, "operations") {
436 let operation_map =
437 operations
438 .as_object()
439 .ok_or_else(|| EventExtractionError::InvalidStructure {
440 source_path: context.source_path.to_owned(),
441 message: "`operations` must be an object".to_owned(),
442 })?;
443 for (operation_name, operation) in operation_map {
444 let operation_label = operation_name.as_str();
445 let resolved_operation = resolve_local(context.root, operation);
446 let Some(action) =
447 value_get(resolved_operation.value, "action").and_then(Value::as_str)
448 else {
449 context.incomplete = true;
450 context.warnings.push(format!(
451 "AsyncAPI operation `{operation_label}` has no literal action"
452 ));
453 continue;
454 };
455 let role = match action {
456 "send" => EventRole::Publisher,
457 "receive" => EventRole::Subscriber,
458 other => {
459 context.incomplete = true;
460 context.warnings.push(format!(
461 "AsyncAPI operation `{operation_label}` uses unsupported action `{other}`"
462 ));
463 continue;
464 }
465 };
466 let Some(channel_value) = value_get(resolved_operation.value, "channel") else {
467 context.incomplete = true;
468 context.warnings.push(format!(
469 "AsyncAPI operation `{operation_label}` has no channel reference"
470 ));
471 continue;
472 };
473 let channel_key = reference_component_name(channel_value, "channels");
474 let resolved_channel = resolve_local(context.root, channel_value);
475 let channel = value_get(resolved_channel.value, "address").and_then(literal_string);
476 let raw_channel = channel.as_deref().unwrap_or("{dynamic-channel}");
477 if let Some(key) = channel_key {
478 used_channels.insert(key);
479 }
480 let mut reference_warnings = resolved_operation.warnings;
481 reference_warnings.extend(resolved_channel.warnings);
482 append_asyncapi_operation(
483 context,
484 raw_channel,
485 role,
486 resolved_operation.value,
487 Some(resolved_channel.value),
488 &reference_warnings,
489 );
490 }
491 }
492
493 for (channel_key, channel_item) in channel_map {
494 let name = channel_key.as_str();
495 if used_channels.contains(name) {
496 continue;
497 }
498 let resolved = resolve_local(context.root, channel_item);
499 append_asyncapi_declaration(context, resolved);
500 }
501 Ok(())
502}
503
504fn append_asyncapi_declaration(context: &mut AsyncApiContext<'_>, resolved: ResolvedValue<'_>) {
505 let raw_channel = value_get(resolved.value, "address")
506 .and_then(literal_string)
507 .unwrap_or_else(|| "{dynamic-channel}".to_owned());
508 let messages = operation_messages(context.root, resolved.value, Some(resolved.value));
509 if messages.is_empty() {
510 let mut observation = asyncapi_observation(
511 context,
512 &raw_channel,
513 EventRole::Declaration,
514 None,
515 Some(resolved.value),
516 None,
517 );
518 observation.warnings.extend(resolved.warnings);
519 context.push(observation);
520 return;
521 }
522 for message in messages {
523 let mut observation = asyncapi_observation(
524 context,
525 &raw_channel,
526 EventRole::Declaration,
527 None,
528 Some(resolved.value),
529 Some(message.value),
530 );
531 observation
532 .warnings
533 .extend(resolved.warnings.iter().cloned());
534 observation.warnings.extend(message.warnings);
535 context.push(observation);
536 }
537}
538
539fn append_asyncapi_operation(
540 context: &mut AsyncApiContext<'_>,
541 raw_channel: &str,
542 role: EventRole,
543 operation: &Value,
544 channel_item: Option<&Value>,
545 reference_warnings: &[String],
546) {
547 let resolved_operation = resolve_local(context.root, operation);
548 let messages = operation_messages(context.root, resolved_operation.value, channel_item);
549 if messages.is_empty() {
550 let mut observation = asyncapi_observation(
551 context,
552 raw_channel,
553 role,
554 Some(resolved_operation.value),
555 channel_item,
556 None,
557 );
558 observation
559 .warnings
560 .extend(reference_warnings.iter().cloned());
561 observation
562 .warnings
563 .extend(resolved_operation.warnings.iter().cloned());
564 observation.incomplete = true;
565 observation
566 .warnings
567 .push("operation has no statically resolvable message".to_owned());
568 context.push(observation);
569 return;
570 }
571 for message in messages {
572 let mut observation = asyncapi_observation(
573 context,
574 raw_channel,
575 role,
576 Some(resolved_operation.value),
577 channel_item,
578 Some(message.value),
579 );
580 observation
581 .warnings
582 .extend(reference_warnings.iter().cloned());
583 observation
584 .warnings
585 .extend(resolved_operation.warnings.iter().cloned());
586 observation.warnings.extend(message.warnings);
587 context.push(observation);
588 }
589}
590
591struct ResolvedValue<'a> {
592 value: &'a Value,
593 warnings: Vec<String>,
594}
595
596fn operation_messages<'a>(
597 root: &'a Value,
598 operation: &'a Value,
599 channel_item: Option<&'a Value>,
600) -> Vec<ResolvedValue<'a>> {
601 let message_value = value_get(operation, "message").or_else(|| {
602 value_get(operation, "messages")
603 .or_else(|| channel_item.and_then(|channel| value_get(channel, "messages")))
604 });
605 let Some(message_value) = message_value else {
606 return Vec::new();
607 };
608 let mut output = Vec::new();
609 collect_messages(root, message_value, &mut output);
610 output
611}
612
613fn collect_messages<'a>(root: &'a Value, value: &'a Value, output: &mut Vec<ResolvedValue<'a>>) {
614 if let Some(items) = value_get(value, "oneOf").and_then(Value::as_array) {
615 for item in items {
616 output.push(resolve_local(root, item));
617 }
618 return;
619 }
620 if let Some(sequence) = value.as_array() {
621 for item in sequence {
622 output.push(resolve_local(root, item));
623 }
624 return;
625 }
626 if let Some(mapping) = value.as_object()
627 && !mapping.contains_key("$ref")
628 && !mapping.contains_key("payload")
629 && !mapping.contains_key("name")
630 {
631 for item in mapping.values() {
632 output.push(resolve_local(root, item));
633 }
634 return;
635 }
636 output.push(resolve_local(root, value));
637}
638
639fn asyncapi_observation(
640 context: &AsyncApiContext<'_>,
641 raw_channel: &str,
642 role: EventRole,
643 operation: Option<&Value>,
644 channel_item: Option<&Value>,
645 message: Option<&Value>,
646) -> EventObservation {
647 let mut warnings = Vec::new();
648 if context.server.ambiguous {
649 warnings.push("server selection or namespace is ambiguous".to_owned());
650 }
651 let channel = literal_channel(raw_channel);
652 if channel.is_none() {
653 warnings.push("templated or empty channel is not an exact channel".to_owned());
654 }
655 let protocol = context.server.protocol.clone();
656 let broker = context
657 .server
658 .broker
659 .or_else(|| {
660 protocol
661 .as_deref()
662 .map(broker_from_protocol)
663 .filter(|broker| *broker != EventBroker::Generic)
664 })
665 .or_else(|| channel_item.and_then(broker_from_bindings))
666 .or_else(|| operation.and_then(broker_from_bindings))
667 .or_else(|| message.and_then(broker_from_bindings))
668 .unwrap_or(EventBroker::Generic);
669 let event_type = message.and_then(message_name);
670 let schema = message.and_then(|value| schema_definition(context.root, value, &mut warnings));
671 let partition_key = exact_metadata(
672 [message, operation, channel_item],
673 &["partitionKey", "partition_key", "x-partition-key"],
674 );
675 let routing_key = exact_metadata(
676 [operation, channel_item, message],
677 &["routingKey", "routing_key", "x-routing-key"],
678 );
679 let delivery_semantics = [operation, channel_item, message]
680 .into_iter()
681 .flatten()
682 .find_map(explicit_delivery_semantics);
683 let dead_letter_channel = exact_metadata(
684 [operation, channel_item, message],
685 &[
686 "deadLetterChannel",
687 "deadLetterQueue",
688 "deadLetterTopic",
689 "dead_letter_channel",
690 "x-dead-letter-channel",
691 ],
692 );
693 let evidence = evidence_for_token(
694 context.input,
695 channel.as_deref().unwrap_or(raw_channel),
696 &format!("AsyncAPI {role:?} channel"),
697 );
698 EventObservation {
699 broker,
700 role,
701 channel,
702 namespace: context.server.namespace.clone(),
703 protocol,
704 event_type,
705 schema,
706 partition_key,
707 routing_key,
708 delivery_semantics,
709 dead_letter_channel,
710 language: None,
711 evidence: vec![evidence],
712 confidence: if warnings.is_empty() { 1.0 } else { 0.0 },
713 incomplete: !warnings.is_empty(),
714 warnings,
715 }
716}
717
718fn schema_definition(
719 root: &Value,
720 message: &Value,
721 warnings: &mut Vec<String>,
722) -> Option<EventSchemaDefinition> {
723 let payload = value_get(message, "payload")?;
724 let resolved = resolve_local(root, payload);
725 warnings.extend(resolved.warnings);
726 let schema = resolved.value;
727 let name = message_name(message).or_else(|| reference_name(payload));
728 let version = exact_string_recursive(
729 message,
730 &["schemaVersion", "schema_version", "x-schema-version"],
731 3,
732 )
733 .or_else(|| {
734 exact_string_recursive(
735 schema,
736 &["schemaVersion", "schema_version", "x-schema-version"],
737 2,
738 )
739 });
740 let schema_format = value_get(message, "schemaFormat")
741 .and_then(literal_string)
742 .or_else(|| value_get(message, "contentType").and_then(literal_string))
743 .or_else(|| value_get(schema, "$schema").and_then(literal_string));
744 let required = value_get(schema, "required")
745 .and_then(Value::as_array)
746 .map(|items| {
747 items
748 .iter()
749 .filter_map(Value::as_str)
750 .map(str::to_owned)
751 .collect::<BTreeSet<_>>()
752 })
753 .unwrap_or_default();
754 let mut fields = Vec::new();
755 if let Some(properties) = value_get(schema, "properties").and_then(Value::as_object) {
756 for (field_name, field_schema) in properties {
757 if fields.len() >= MAX_SCHEMA_FIELDS {
758 warnings.push(format!(
759 "payload fields truncated at {MAX_SCHEMA_FIELDS} items"
760 ));
761 break;
762 }
763 let field_name = field_name.as_str();
764 let resolved_field = resolve_local(root, field_schema);
765 warnings.extend(resolved_field.warnings);
766 fields.push(EventSchemaField {
767 name: field_name.to_owned(),
768 field_type: schema_type(resolved_field.value)
769 .or_else(|| reference_name(field_schema)),
770 required: required.contains(field_name),
771 });
772 }
773 }
774 fields.sort();
775 fields.dedup();
776 Some(EventSchemaDefinition {
777 name,
778 version,
779 schema_format,
780 fields,
781 })
782}
783
784fn schema_type(value: &Value) -> Option<String> {
785 value_get(value, "type")
786 .and_then(Value::as_str)
787 .map(str::to_owned)
788 .or_else(|| {
789 value_get(value, "format")
790 .and_then(Value::as_str)
791 .map(str::to_owned)
792 })
793}
794
795fn message_name(message: &Value) -> Option<String> {
796 value_get(message, "name")
797 .and_then(literal_string)
798 .or_else(|| value_get(message, "title").and_then(literal_string))
799 .or_else(|| reference_name(message))
800}
801
802fn global_server_facts(root: &Value) -> (ServerFacts, Vec<String>) {
803 let Some(servers) = value_get(root, "servers").and_then(Value::as_object) else {
804 return (ServerFacts::default(), Vec::new());
805 };
806 let mut protocols = BTreeSet::new();
807 let mut namespaces = BTreeSet::new();
808 let mut warnings = Vec::new();
809 for server in servers.values() {
810 let resolved = resolve_local(root, server);
811 warnings.extend(resolved.warnings);
812 if let Some(protocol) = value_get(resolved.value, "protocol").and_then(literal_string) {
813 protocols.insert(protocol.to_ascii_lowercase());
814 }
815 let namespace = server_namespace(resolved.value);
816 if let Some(namespace) = namespace {
817 namespaces.insert(namespace);
818 } else if value_get(resolved.value, "url").is_some()
819 || value_get(resolved.value, "host").is_some()
820 {
821 warnings
822 .push("templated server namespace was not promoted to an exact value".to_owned());
823 }
824 }
825 let protocol = unique_value(&protocols);
826 let namespace = unique_value(&namespaces);
827 if protocols.len() > 1 {
828 warnings.push("multiple server protocols make broker selection ambiguous".to_owned());
829 }
830 if namespaces.len() > 1 {
831 warnings.push("multiple server namespaces make namespace selection ambiguous".to_owned());
832 }
833 let broker = protocol
834 .as_deref()
835 .map(broker_from_protocol)
836 .filter(|broker| *broker != EventBroker::Generic);
837 let ambiguous = !warnings.is_empty();
838 (
839 ServerFacts {
840 broker,
841 protocol,
842 namespace,
843 ambiguous,
844 },
845 warnings,
846 )
847}
848
849fn server_namespace(server: &Value) -> Option<String> {
850 if let Some(namespace) = value_get(server, "namespace").and_then(literal_string) {
851 return literal_namespace(&namespace);
852 }
853 if let Some(url) = value_get(server, "url").and_then(literal_string) {
854 return literal_namespace(&url);
855 }
856 let host = value_get(server, "host").and_then(literal_string)?;
857 let pathname = value_get(server, "pathname")
858 .and_then(literal_string)
859 .unwrap_or_default();
860 literal_namespace(&format!("{host}{pathname}"))
861}
862
863fn unique_value(values: &BTreeSet<String>) -> Option<String> {
864 (values.len() == 1)
865 .then(|| values.iter().next().cloned())
866 .flatten()
867}
868
869fn literal_namespace(value: &str) -> Option<String> {
870 let trimmed = value.trim();
871 if trimmed.is_empty()
872 || trimmed.contains(['{', '}'])
873 || trimmed.contains("${")
874 || trimmed.chars().any(char::is_whitespace)
875 {
876 return None;
877 }
878 let without_scheme = trimmed
879 .split_once("://")
880 .map_or(trimmed, |(_, remainder)| remainder);
881 let without_credentials = without_scheme
882 .rsplit_once('@')
883 .map_or(without_scheme, |(_, remainder)| remainder);
884 let namespace = without_credentials
885 .split(['?', '#'])
886 .next()
887 .unwrap_or_default()
888 .trim_end_matches('/');
889 (!namespace.is_empty()).then(|| namespace.to_owned())
890}
891
892fn broker_from_protocol(protocol: &str) -> EventBroker {
893 match protocol.to_ascii_lowercase().as_str() {
894 "kafka" | "kafka-secure" => EventBroker::Kafka,
895 "amqp" | "amqps" => EventBroker::RabbitMq,
896 "sns" | "aws-sns" => EventBroker::AwsSns,
897 "sqs" | "aws-sqs" => EventBroker::AwsSqs,
898 "nats" | "nats-secure" => EventBroker::Nats,
899 "googlepubsub" | "google-pubsub" | "gcp-pubsub" | "pubsub" => EventBroker::GooglePubSub,
900 _ => EventBroker::Generic,
901 }
902}
903
904fn broker_from_bindings(value: &Value) -> Option<EventBroker> {
905 let bindings = value_get(value, "bindings")?.as_object()?;
906 for key in bindings.keys() {
907 let broker = match key.to_ascii_lowercase().as_str() {
908 "kafka" => Some(EventBroker::Kafka),
909 "amqp" => Some(EventBroker::RabbitMq),
910 "sns" => Some(EventBroker::AwsSns),
911 "sqs" => Some(EventBroker::AwsSqs),
912 "nats" => Some(EventBroker::Nats),
913 "googlepubsub" | "google-pubsub" | "pubsub" => Some(EventBroker::GooglePubSub),
914 _ => None,
915 };
916 if broker.is_some() {
917 return broker;
918 }
919 }
920 None
921}
922
923fn explicit_delivery_semantics(value: &Value) -> Option<DeliverySemantics> {
924 let raw = exact_string_recursive(
925 value,
926 &[
927 "deliverySemantics",
928 "delivery_semantics",
929 "deliveryGuarantee",
930 "x-delivery-semantics",
931 ],
932 4,
933 )?;
934 parse_delivery_semantics(&raw)
935}
936
937fn parse_delivery_semantics(value: &str) -> Option<DeliverySemantics> {
938 let canonical = value
939 .chars()
940 .filter(char::is_ascii_alphanumeric)
941 .flat_map(char::to_lowercase)
942 .collect::<String>();
943 match canonical.as_str() {
944 "atmostonce" => Some(DeliverySemantics::AtMostOnce),
945 "atleastonce" => Some(DeliverySemantics::AtLeastOnce),
946 "exactlyonce" => Some(DeliverySemantics::ExactlyOnce),
947 "besteffort" => Some(DeliverySemantics::BestEffort),
948 _ => None,
949 }
950}
951
952fn exact_metadata<const N: usize>(values: [Option<&Value>; N], keys: &[&str]) -> Option<String> {
953 values
954 .into_iter()
955 .flatten()
956 .find_map(|value| exact_string_recursive(value, keys, 4))
957}
958
959fn exact_string_recursive(value: &Value, keys: &[&str], depth: usize) -> Option<String> {
960 if depth == 0 {
961 return None;
962 }
963 let mapping = value.as_object()?;
964 for key in keys {
965 if let Some(found) = mapping.get(*key)
966 && let Some(literal) = literal_string(found)
967 {
968 return Some(literal);
969 }
970 }
971 mapping
972 .values()
973 .find_map(|child| exact_string_recursive(child, keys, depth - 1))
974}
975
976fn resolve_local<'a>(root: &'a Value, value: &'a Value) -> ResolvedValue<'a> {
977 let mut current = value;
978 let mut warnings = Vec::new();
979 let mut visited = BTreeSet::new();
980 for _ in 0..MAX_REFERENCE_DEPTH {
981 let Some(reference) = value_get(current, "$ref").and_then(Value::as_str) else {
982 return ResolvedValue {
983 value: current,
984 warnings,
985 };
986 };
987 if !reference.starts_with("#/") {
988 warnings.push(format!(
989 "external reference `{}` was not resolved",
990 bounded_text(reference, MAX_EVIDENCE_TEXT_CHARS)
991 ));
992 return ResolvedValue {
993 value: current,
994 warnings,
995 };
996 }
997 if !visited.insert(reference.to_owned()) {
998 warnings.push(format!(
999 "cyclic local reference `{}` was not resolved",
1000 bounded_text(reference, MAX_EVIDENCE_TEXT_CHARS)
1001 ));
1002 return ResolvedValue {
1003 value: current,
1004 warnings,
1005 };
1006 }
1007 let Some(next) = json_pointer(root, reference) else {
1008 warnings.push(format!(
1009 "unresolved local reference `{}`",
1010 bounded_text(reference, MAX_EVIDENCE_TEXT_CHARS)
1011 ));
1012 return ResolvedValue {
1013 value: current,
1014 warnings,
1015 };
1016 };
1017 current = next;
1018 }
1019 warnings.push(format!(
1020 "local reference depth exceeded {MAX_REFERENCE_DEPTH}"
1021 ));
1022 ResolvedValue {
1023 value: current,
1024 warnings,
1025 }
1026}
1027
1028fn json_pointer<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
1029 let mut current = root;
1030 for component in reference.strip_prefix("#/")?.split('/') {
1031 let decoded = component.replace("~1", "/").replace("~0", "~");
1032 current = current.as_object()?.get(&decoded)?;
1033 }
1034 Some(current)
1035}
1036
1037fn reference_component_name(value: &Value, component: &str) -> Option<String> {
1038 let reference = value_get(value, "$ref")?.as_str()?;
1039 let prefix = format!("#/{component}/");
1040 reference
1041 .strip_prefix(&prefix)
1042 .map(|name| name.replace("~1", "/").replace("~0", "~"))
1043}
1044
1045fn reference_name(value: &Value) -> Option<String> {
1046 value_get(value, "$ref")
1047 .and_then(Value::as_str)
1048 .and_then(|reference| reference.rsplit('/').next())
1049 .filter(|name| !name.is_empty())
1050 .map(|name| name.replace("~1", "/").replace("~0", "~"))
1051}
1052
1053fn value_get<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
1054 value.as_object()?.get(key)
1055}
1056
1057fn mapping_string<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a str> {
1058 mapping.get(key).and_then(Value::as_str)
1059}
1060
1061fn literal_string(value: &Value) -> Option<String> {
1062 value
1063 .as_str()
1064 .map(str::trim)
1065 .filter(|value| !value.is_empty())
1066 .map(str::to_owned)
1067}
1068
1069fn literal_channel(value: &str) -> Option<String> {
1070 let trimmed = value.trim();
1071 (!trimmed.is_empty() && !trimmed.contains(['{', '}']) && !trimmed.contains("${"))
1072 .then(|| trimmed.to_owned())
1073}
1074
1075fn evidence_for_token(input: &str, token: &str, label: &str) -> EventEvidenceLine {
1076 let line = input
1077 .lines()
1078 .position(|source| source.contains(token))
1079 .and_then(|index| u32::try_from(index + 1).ok())
1080 .unwrap_or(1);
1081 EventEvidenceLine {
1082 line,
1083 text: bounded_text(label, MAX_EVIDENCE_TEXT_CHARS),
1084 }
1085}
1086
1087#[derive(Debug, Clone, Copy)]
1088struct SourceRecognition {
1089 broker: EventBroker,
1090 role: EventRole,
1091 call: &'static str,
1092}
1093
1094fn recognize_source_call(statement: &str) -> Option<SourceRecognition> {
1095 let masked = mask_string_contents(statement);
1096 if looks_like_function_declaration(&masked) {
1097 return None;
1098 }
1099 let compact = compact_lowercase(&masked);
1100 if let Some(role) = recognize_sns(&compact) {
1101 return Some(SourceRecognition {
1102 broker: EventBroker::AwsSns,
1103 role,
1104 call: "AWS SNS",
1105 });
1106 }
1107 if let Some(role) = recognize_sqs(&compact) {
1108 return Some(SourceRecognition {
1109 broker: EventBroker::AwsSqs,
1110 role,
1111 call: "AWS SQS",
1112 });
1113 }
1114 if let Some(role) = recognize_google_pubsub(&compact) {
1115 return Some(SourceRecognition {
1116 broker: EventBroker::GooglePubSub,
1117 role,
1118 call: "Google Pub/Sub",
1119 });
1120 }
1121 if let Some(role) = recognize_rabbitmq(&compact) {
1122 return Some(SourceRecognition {
1123 broker: EventBroker::RabbitMq,
1124 role,
1125 call: "RabbitMQ",
1126 });
1127 }
1128 if let Some(role) = recognize_kafka(&compact) {
1129 return Some(SourceRecognition {
1130 broker: EventBroker::Kafka,
1131 role,
1132 call: "Kafka",
1133 });
1134 }
1135 if let Some(role) = recognize_nats(&compact) {
1136 return Some(SourceRecognition {
1137 broker: EventBroker::Nats,
1138 role,
1139 call: "NATS",
1140 });
1141 }
1142 recognize_generic(&compact).map(|role| SourceRecognition {
1143 broker: EventBroker::Generic,
1144 role,
1145 call: "generic pub/sub",
1146 })
1147}
1148
1149fn recognize_sns(source: &str) -> Option<EventRole> {
1150 let identified = source.contains("topicarn")
1151 || source.contains("topic_arn")
1152 || source.contains("sns.")
1153 || source.contains("snsclient");
1154 if !identified {
1155 return None;
1156 }
1157 if contains_call(source, &["createtopic", "create_topic"]) {
1158 Some(EventRole::Declaration)
1159 } else if contains_call(source, &["publish", "send"]) {
1160 Some(EventRole::Publisher)
1161 } else if contains_call(source, &["subscribe"]) {
1162 Some(EventRole::Subscriber)
1163 } else {
1164 None
1165 }
1166}
1167
1168fn recognize_sqs(source: &str) -> Option<EventRole> {
1169 let identified = source.contains("queueurl")
1170 || source.contains("queue_url")
1171 || source.contains("sqs.")
1172 || source.contains("sqsclient")
1173 || source.contains("sendmessage")
1174 || source.contains("send_message")
1175 || source.contains("receivemessage")
1176 || source.contains("receive_message");
1177 if !identified {
1178 return None;
1179 }
1180 if contains_call(source, &["createqueue", "create_queue"]) {
1181 Some(EventRole::Declaration)
1182 } else if contains_call(source, &["sendmessage", "send_message"]) {
1183 Some(EventRole::Publisher)
1184 } else if contains_call(
1185 source,
1186 &[
1187 "receivemessage",
1188 "receive_message",
1189 "startmessagepoller",
1190 "start_message_poller",
1191 "sqslistener",
1192 ],
1193 ) {
1194 Some(EventRole::Subscriber)
1195 } else {
1196 None
1197 }
1198}
1199
1200fn recognize_google_pubsub(source: &str) -> Option<EventRole> {
1201 let identified = source.contains("pubsub")
1202 || source.contains("publisherclient")
1203 || source.contains("subscriberclient")
1204 || source.contains("topicpath")
1205 || source.contains("topic_path")
1206 || source.contains("subscriptionpath")
1207 || source.contains("subscription_path");
1208 if !identified {
1209 return None;
1210 }
1211 if contains_call(
1212 source,
1213 &[
1214 "createtopic",
1215 "create_topic",
1216 "createsubscription",
1217 "create_subscription",
1218 ],
1219 ) {
1220 Some(EventRole::Declaration)
1221 } else if contains_call(source, &["publish", "publishmessage", "publish_message"]) {
1222 Some(EventRole::Publisher)
1223 } else if contains_call(
1224 source,
1225 &[
1226 "subscribe",
1227 "pull",
1228 "streamingpull",
1229 "streaming_pull",
1230 "receive",
1231 "pubsubsubscription",
1232 ],
1233 ) {
1234 Some(EventRole::Subscriber)
1235 } else {
1236 None
1237 }
1238}
1239
1240fn recognize_rabbitmq(source: &str) -> Option<EventRole> {
1241 let identified = source.contains("rabbit")
1242 || source.contains("amqp")
1243 || source.contains("basicpublish")
1244 || source.contains("basic_publish")
1245 || source.contains("basicconsume")
1246 || source.contains("basic_consume")
1247 || source.contains("queuedeclare")
1248 || source.contains("queue_declare")
1249 || source.contains("exchangedeclare")
1250 || source.contains("exchange_declare");
1251 if !identified {
1252 return None;
1253 }
1254 if contains_call(
1255 source,
1256 &[
1257 "queuedeclare",
1258 "queue_declare",
1259 "exchangedeclare",
1260 "exchange_declare",
1261 "assertqueue",
1262 "assertexchange",
1263 ],
1264 ) {
1265 Some(EventRole::Declaration)
1266 } else if contains_call(
1267 source,
1268 &["basicpublish", "basic_publish", "publish", "send"],
1269 ) {
1270 Some(EventRole::Publisher)
1271 } else if contains_call(
1272 source,
1273 &[
1274 "basicconsume",
1275 "basic_consume",
1276 "consume",
1277 "get",
1278 "rabbitlistener",
1279 ],
1280 ) {
1281 Some(EventRole::Subscriber)
1282 } else {
1283 None
1284 }
1285}
1286
1287fn recognize_kafka(source: &str) -> Option<EventRole> {
1288 let identified = source.contains("kafka")
1289 || source.contains("kafkatemplate")
1290 || source.contains("kafkaproducer")
1291 || source.contains("kafkaconsumer")
1292 || source.contains("baserecord::to")
1293 || source.contains("futurerecord::to")
1294 || source.contains("newtopic")
1295 || (source.contains("producer.") && source.contains("topic"))
1296 || (source.contains("consumer.") && source.contains("subscribe"));
1297 if !identified {
1298 return None;
1299 }
1300 if contains_call(source, &["newtopic", "createtopics", "create_topics"]) {
1301 Some(EventRole::Declaration)
1302 } else if contains_call(
1303 source,
1304 &[
1305 "send",
1306 "publish",
1307 "produce",
1308 "writemessages",
1309 "write_messages",
1310 ],
1311 ) {
1312 Some(EventRole::Publisher)
1313 } else if contains_call(
1314 source,
1315 &[
1316 "subscribe",
1317 "subscribetopics",
1318 "subscribe_topics",
1319 "assign",
1320 "poll",
1321 "kafkalistener",
1322 ],
1323 ) {
1324 Some(EventRole::Subscriber)
1325 } else {
1326 None
1327 }
1328}
1329
1330fn recognize_nats(source: &str) -> Option<EventRole> {
1331 let identified = source.contains("nats")
1332 || source.contains("jetstream")
1333 || source.starts_with("nc.")
1334 || source.contains(" nc.")
1335 || source.starts_with("js.")
1336 || source.contains(" js.");
1337 if !identified {
1338 return None;
1339 }
1340 if contains_call(
1341 source,
1342 &["addstream", "add_stream", "createstream", "create_stream"],
1343 ) {
1344 Some(EventRole::Declaration)
1345 } else if contains_call(source, &["publish", "request"]) {
1346 Some(EventRole::Publisher)
1347 } else if contains_call(source, &["subscribe", "queuesubscribe", "queue_subscribe"]) {
1348 Some(EventRole::Subscriber)
1349 } else {
1350 None
1351 }
1352}
1353
1354fn recognize_generic(source: &str) -> Option<EventRole> {
1355 if contains_call(
1356 source,
1357 &[
1358 "declarechannel",
1359 "declare_channel",
1360 "createchannel",
1361 "create_channel",
1362 ],
1363 ) {
1364 Some(EventRole::Declaration)
1365 } else if contains_call(source, &["publish", "publisher.publish"]) {
1366 Some(EventRole::Publisher)
1367 } else if contains_call(source, &["subscribe", "subscriber.subscribe"]) {
1368 Some(EventRole::Subscriber)
1369 } else {
1370 None
1371 }
1372}
1373
1374fn contains_call(source: &str, names: &[&str]) -> bool {
1375 names.iter().any(|name| {
1376 let needle = format!("{name}(");
1377 let mut offset = 0;
1378 while let Some(relative) = source[offset..].find(&needle) {
1379 let position = offset + relative;
1380 if !source[..position]
1381 .chars()
1382 .next_back()
1383 .is_some_and(is_identifier_character)
1384 {
1385 return true;
1386 }
1387 offset = position.saturating_add(needle.len());
1388 if offset >= source.len() {
1389 break;
1390 }
1391 }
1392 false
1393 })
1394}
1395
1396fn looks_like_function_declaration(source: &str) -> bool {
1397 let trimmed = source.trim_start().to_ascii_lowercase();
1398 ["fn ", "def ", "func ", "function "]
1399 .iter()
1400 .any(|prefix| trimmed.starts_with(prefix))
1401 || ["public ", "private ", "protected ", "static "]
1402 .iter()
1403 .any(|prefix| {
1404 trimmed.starts_with(prefix)
1405 && trimmed
1406 .split_once('(')
1407 .is_some_and(|(head, _)| !head.contains(['.', '=']))
1408 })
1409}
1410
1411fn append_source_observations(
1412 output: &mut Vec<EventObservation>,
1413 language: SourceLanguage,
1414 line: u32,
1415 statement: &str,
1416 recognition: SourceRecognition,
1417) {
1418 let channels = source_channels(statement, recognition);
1419 let partition_key = literal_after_named(statement, &["partitionKey", "partition_key", "key"]);
1420 let routing_key = literal_after_named(statement, &["routingKey", "routing_key"]);
1421 let delivery_semantics = literal_after_named(
1422 statement,
1423 &[
1424 "deliverySemantics",
1425 "delivery_semantics",
1426 "deliveryGuarantee",
1427 ],
1428 )
1429 .and_then(|value| parse_delivery_semantics(&value));
1430 let dead_letter_channel = literal_after_named(
1431 statement,
1432 &[
1433 "deadLetterChannel",
1434 "dead_letter_channel",
1435 "deadLetterQueue",
1436 "dead_letter_queue",
1437 "deadLetterTopic",
1438 "dead_letter_topic",
1439 "dlq",
1440 ],
1441 );
1442 let event_type = literal_after_named(statement, &["eventType", "event_type", "messageType"]);
1443 let channels = if channels.is_empty() {
1444 vec![None]
1445 } else {
1446 channels.into_iter().map(Some).collect()
1447 };
1448 for channel in channels {
1449 let mut warnings = Vec::new();
1450 if channel.is_none() {
1451 warnings
1452 .push("dynamic channel expression was not promoted to an exact value".to_owned());
1453 }
1454 let exact = channel.is_some();
1455 output.push(EventObservation {
1456 broker: recognition.broker,
1457 role: recognition.role,
1458 channel,
1459 namespace: None,
1460 protocol: None,
1461 event_type: event_type.clone(),
1462 schema: None,
1463 partition_key: partition_key.clone(),
1464 routing_key: routing_key.clone(),
1465 delivery_semantics,
1466 dead_letter_channel: dead_letter_channel.clone(),
1467 language: Some(language),
1468 evidence: vec![EventEvidenceLine {
1469 line,
1470 text: bounded_text(
1471 &format!("{} {:?} literal call", recognition.call, recognition.role),
1472 MAX_EVIDENCE_TEXT_CHARS,
1473 ),
1474 }],
1475 confidence: if exact { 1.0 } else { 0.0 },
1476 incomplete: !exact,
1477 warnings,
1478 });
1479 }
1480}
1481
1482fn source_channels(statement: &str, recognition: SourceRecognition) -> Vec<String> {
1483 let named_keys: &[&str] = match recognition.broker {
1484 EventBroker::Kafka => &["topic", "topics", "Topic"],
1485 EventBroker::RabbitMq => {
1486 if recognition.role == EventRole::Publisher {
1487 &["exchange", "exchangeName", "exchange_name"]
1488 } else {
1489 &["queue", "queueName", "queue_name"]
1490 }
1491 }
1492 EventBroker::AwsSns => &["TopicArn", "topicArn", "topic_arn"],
1493 EventBroker::AwsSqs => &["QueueUrl", "queueUrl", "queue_url"],
1494 EventBroker::Nats => &["subject", "subjects"],
1495 EventBroker::GooglePubSub => {
1496 if recognition.role == EventRole::Subscriber {
1497 &["subscription", "subscriptionName", "subscription_name"]
1498 } else {
1499 &["topic", "topicName", "topic_name"]
1500 }
1501 }
1502 EventBroker::Generic => &["channel", "topic", "queue", "subject"],
1503 };
1504 let mut channels = literals_after_named(statement, named_keys);
1505 if channels.is_empty() {
1506 channels = call_argument_literals(statement, recognition, 0);
1507 }
1508 if recognition.broker == EventBroker::RabbitMq
1509 && recognition.role == EventRole::Publisher
1510 && channels.first().is_some_and(String::is_empty)
1511 {
1512 channels = call_argument_literals(statement, recognition, 1);
1513 }
1514 channels.retain(|channel| !channel.is_empty());
1515 channels.sort();
1516 channels.dedup();
1517 channels
1518}
1519
1520fn call_argument_literals(
1521 statement: &str,
1522 recognition: SourceRecognition,
1523 argument_index: usize,
1524) -> Vec<String> {
1525 let markers: &[&str] = match (recognition.broker, recognition.role) {
1526 (EventBroker::Kafka, EventRole::Publisher) => &[
1527 "BaseRecord::to",
1528 "FutureRecord::to",
1529 "kafkaTemplate.send",
1530 ".send",
1531 ".produce",
1532 "WriteMessages",
1533 ],
1534 (EventBroker::Kafka, EventRole::Subscriber) => &[
1535 ".subscribe",
1536 "SubscribeTopics",
1537 "subscribe_topics",
1538 "KafkaListener",
1539 ],
1540 (EventBroker::RabbitMq, EventRole::Publisher) => {
1541 &["basic_publish", "basicPublish", ".publish", ".Publish"]
1542 }
1543 (EventBroker::RabbitMq, EventRole::Subscriber) => &[
1544 "basic_consume",
1545 "basicConsume",
1546 ".consume",
1547 ".Consume",
1548 "RabbitListener",
1549 ],
1550 (EventBroker::RabbitMq, EventRole::Declaration) => {
1551 &["queue_declare", "queueDeclare", "assertQueue"]
1552 }
1553 (EventBroker::AwsSns | EventBroker::GooglePubSub, EventRole::Publisher) => {
1554 &[".publish", "publish"]
1555 }
1556 (EventBroker::AwsSns, EventRole::Subscriber) => &[".subscribe", "subscribe"],
1557 (EventBroker::AwsSns, EventRole::Declaration) => &["create_topic", "createTopic"],
1558 (EventBroker::AwsSqs, EventRole::Publisher) => &["send_message", "sendMessage"],
1559 (EventBroker::AwsSqs, EventRole::Subscriber) => {
1560 &["receive_message", "receiveMessage", "SqsListener"]
1561 }
1562 (EventBroker::AwsSqs, EventRole::Declaration) => &["create_queue", "createQueue"],
1563 (EventBroker::Nats | EventBroker::Generic, EventRole::Publisher) => {
1564 &[".publish", "publish", ".Publish", "Publish"]
1565 }
1566 (EventBroker::Nats | EventBroker::Generic, EventRole::Subscriber) => {
1567 &[".subscribe", "subscribe", ".Subscribe", "Subscribe"]
1568 }
1569 (EventBroker::Nats, EventRole::Declaration) => &["add_stream", "addStream"],
1570 (EventBroker::GooglePubSub, EventRole::Subscriber) => &[
1571 ".subscribe",
1572 "subscribe",
1573 ".pull",
1574 "pull",
1575 "PubSubSubscription",
1576 ],
1577 (EventBroker::GooglePubSub, EventRole::Declaration) => {
1578 &["create_topic", "createTopic", "create_subscription"]
1579 }
1580 (EventBroker::Generic, EventRole::Declaration) => {
1581 &["declare_channel", "declareChannel", "create_channel"]
1582 }
1583 _ => &[],
1584 };
1585 markers
1586 .iter()
1587 .find_map(|marker| {
1588 argument_expression(statement, marker, argument_index)
1589 .map(static_literals_from_expression)
1590 })
1591 .unwrap_or_default()
1592}
1593
1594fn argument_expression<'a>(source: &'a str, marker: &str, target: usize) -> Option<&'a str> {
1595 let marker_start = source.find(marker)?;
1596 let after_marker = &source[marker_start + marker.len()..];
1597 let open_offset = after_marker.find('(')?;
1598 let arguments = &after_marker[open_offset + 1..];
1599 let mut quote = None;
1600 let mut escaped = false;
1601 let mut depth = 0_u32;
1602 let mut start = 0;
1603 let mut index = 0;
1604 for (offset, character) in arguments.char_indices() {
1605 if let Some(delimiter) = quote {
1606 if escaped {
1607 escaped = false;
1608 } else if character == '\\' {
1609 escaped = true;
1610 } else if character == delimiter {
1611 quote = None;
1612 }
1613 continue;
1614 }
1615 match character {
1616 '"' | '\'' | '`' => quote = Some(character),
1617 '(' | '[' | '{' => depth = depth.saturating_add(1),
1618 ')' if depth == 0 => {
1619 return (index == target).then(|| &arguments[start..offset]);
1620 }
1621 ')' | ']' | '}' => depth = depth.saturating_sub(1),
1622 ',' if depth == 0 => {
1623 if index == target {
1624 return Some(&arguments[start..offset]);
1625 }
1626 index += 1;
1627 start = offset + character.len_utf8();
1628 }
1629 _ => {}
1630 }
1631 }
1632 (index == target).then(|| &arguments[start..])
1633}
1634
1635fn literals_after_named(source: &str, keys: &[&str]) -> Vec<String> {
1636 let mut output = Vec::new();
1637 for key in keys {
1638 let mut offset = 0;
1639 while let Some(relative) = source[offset..].find(key) {
1640 let position = offset + relative;
1641 if is_identifier_boundary(source, position, key.len()) {
1642 let after = &source[position + key.len()..];
1643 let expression = after
1644 .trim_start()
1645 .strip_prefix([':', '='])
1646 .or_else(|| after.trim_start().strip_prefix('('));
1647 if let Some(expression) = expression {
1648 output.extend(static_literals_from_expression(expression));
1649 }
1650 }
1651 offset = position.saturating_add(key.len());
1652 if offset >= source.len() {
1653 break;
1654 }
1655 }
1656 }
1657 output.sort();
1658 output.dedup();
1659 output
1660}
1661
1662fn literal_after_named(source: &str, keys: &[&str]) -> Option<String> {
1663 literals_after_named(source, keys).into_iter().next()
1664}
1665
1666fn static_literals_from_expression(expression: &str) -> Vec<String> {
1667 let trimmed = expression.trim_start_matches(|character: char| {
1668 character.is_whitespace() || matches!(character, '&' | '*')
1669 });
1670 if trimmed.starts_with("format!")
1671 || trimmed.starts_with("format(")
1672 || trimmed.starts_with("f\"")
1673 || trimmed.starts_with("f'")
1674 || trimmed.starts_with("F\"")
1675 || trimmed.starts_with("F'")
1676 {
1677 return Vec::new();
1678 }
1679 if let Some(inner) = trimmed.strip_prefix('[') {
1680 return split_literal_list(inner, ']');
1681 }
1682 if let Some(inner) = trimmed.strip_prefix('(') {
1683 return split_literal_list(inner, ')');
1684 }
1685 let Some((value, consumed)) = parse_static_literal(trimmed) else {
1686 return Vec::new();
1687 };
1688 let remainder = trimmed.get(consumed..).unwrap_or_default().trim_start();
1689 if remainder.is_empty() || remainder.starts_with([',', ')', ']', '}', ';', '.']) {
1690 vec![value]
1691 } else {
1692 Vec::new()
1693 }
1694}
1695
1696fn split_literal_list(source: &str, closing: char) -> Vec<String> {
1697 let mut output = Vec::new();
1698 let mut remainder = source;
1699 loop {
1700 remainder = remainder.trim_start();
1701 if remainder.starts_with(closing) || remainder.is_empty() {
1702 break;
1703 }
1704 let Some((value, consumed)) = parse_static_literal(remainder) else {
1705 return Vec::new();
1706 };
1707 output.push(value);
1708 remainder = remainder.get(consumed..).unwrap_or_default().trim_start();
1709 if remainder.starts_with(',') {
1710 remainder = &remainder[1..];
1711 } else if !remainder.starts_with(closing) {
1712 return Vec::new();
1713 }
1714 }
1715 output
1716}
1717
1718fn parse_static_literal(source: &str) -> Option<(String, usize)> {
1719 let (prefix, delimiter, raw) = if source.starts_with("r#\"") {
1720 ("r#\"", '"', true)
1721 } else if source.starts_with("r\"") {
1722 ("r\"", '"', true)
1723 } else if source.starts_with('"') {
1724 ("\"", '"', false)
1725 } else if source.starts_with('\'') {
1726 ("'", '\'', false)
1727 } else if source.starts_with('`') {
1728 ("`", '`', false)
1729 } else {
1730 return None;
1731 };
1732 let mut escaped = false;
1733 let mut value = String::new();
1734 let content_start = prefix.len();
1735 for (relative, character) in source[content_start..].char_indices() {
1736 if raw && character == delimiter {
1737 let end = content_start + relative;
1738 let suffix = if prefix == "r#\"" { "\"#" } else { "\"" };
1739 if source[end..].starts_with(suffix) {
1740 return Some((value, end + suffix.len()));
1741 }
1742 value.push(character);
1743 continue;
1744 }
1745 if !raw && escaped {
1746 value.push(match character {
1747 'n' => '\n',
1748 'r' => '\r',
1749 't' => '\t',
1750 other => other,
1751 });
1752 escaped = false;
1753 continue;
1754 }
1755 if !raw && character == '\\' {
1756 escaped = true;
1757 continue;
1758 }
1759 if !raw && character == delimiter {
1760 if delimiter == '`' && value.contains("${") {
1761 return None;
1762 }
1763 return Some((value, content_start + relative + character.len_utf8()));
1764 }
1765 value.push(character);
1766 }
1767 None
1768}
1769
1770fn is_identifier_boundary(source: &str, position: usize, length: usize) -> bool {
1771 let before = source[..position].chars().next_back();
1772 let after = source[position + length..].chars().next();
1773 !before.is_some_and(is_identifier_character) && !after.is_some_and(is_identifier_character)
1774}
1775
1776fn is_identifier_character(character: char) -> bool {
1777 character.is_ascii_alphanumeric() || character == '_'
1778}
1779
1780fn source_candidate(lines: &[String], start: usize) -> Option<String> {
1781 let first = lines.get(start)?.trim();
1782 if first.is_empty() || !possible_event_line(first) {
1783 return None;
1784 }
1785 let mut candidate = String::new();
1786 let mut balance = 0_i32;
1787 for line in lines.iter().skip(start).take(12) {
1788 if !candidate.is_empty() {
1789 candidate.push(' ');
1790 }
1791 candidate.push_str(line.trim());
1792 balance += delimiter_balance(line);
1793 if balance <= 0 && candidate.contains('(') {
1794 break;
1795 }
1796 }
1797 Some(candidate)
1798}
1799
1800fn possible_event_line(line: &str) -> bool {
1801 let lower = line.to_ascii_lowercase();
1802 [
1803 "publish",
1804 "subscribe",
1805 "send",
1806 "receive",
1807 "consume",
1808 "produce",
1809 "queue",
1810 "topic",
1811 "writemessages",
1812 "basic_",
1813 "createstream",
1814 "create_stream",
1815 "pull(",
1816 ]
1817 .iter()
1818 .any(|marker| lower.contains(marker))
1819}
1820
1821fn delimiter_balance(line: &str) -> i32 {
1822 let mut balance = 0_i32;
1823 let mut quote = None;
1824 let mut escaped = false;
1825 for character in line.chars() {
1826 if let Some(delimiter) = quote {
1827 if escaped {
1828 escaped = false;
1829 } else if character == '\\' {
1830 escaped = true;
1831 } else if character == delimiter {
1832 quote = None;
1833 }
1834 continue;
1835 }
1836 match character {
1837 '"' | '\'' | '`' => quote = Some(character),
1838 '(' | '[' | '{' => balance += 1,
1839 ')' | ']' | '}' => balance -= 1,
1840 _ => {}
1841 }
1842 }
1843 balance
1844}
1845
1846fn sanitize_source(language: SourceLanguage, input: &str) -> Vec<String> {
1847 let hash_comments = matches!(language, SourceLanguage::Python);
1848 let mut block_comment = false;
1849 input
1850 .lines()
1851 .map(|line| sanitize_line(line, hash_comments, &mut block_comment))
1852 .collect()
1853}
1854
1855fn sanitize_line(line: &str, hash_comments: bool, block_comment: &mut bool) -> String {
1856 let characters = line.char_indices().collect::<Vec<_>>();
1857 let mut output = String::with_capacity(line.len());
1858 let mut index = 0;
1859 let mut quote = None;
1860 let mut escaped = false;
1861 while let Some(&(offset, character)) = characters.get(index) {
1862 let next = characters.get(index + 1).map(|(_, value)| *value);
1863 if *block_comment {
1864 if character == '*' && next == Some('/') {
1865 *block_comment = false;
1866 index += 2;
1867 } else {
1868 index += 1;
1869 }
1870 continue;
1871 }
1872 if let Some(delimiter) = quote {
1873 output.push(character);
1874 if escaped {
1875 escaped = false;
1876 } else if character == '\\' {
1877 escaped = true;
1878 } else if character == delimiter {
1879 quote = None;
1880 }
1881 index += 1;
1882 continue;
1883 }
1884 if matches!(character, '"' | '\'' | '`') {
1885 quote = Some(character);
1886 output.push(character);
1887 index += 1;
1888 continue;
1889 }
1890 if character == '/' && next == Some('/') {
1891 break;
1892 }
1893 if character == '/' && next == Some('*') {
1894 *block_comment = true;
1895 index += 2;
1896 continue;
1897 }
1898 if hash_comments && character == '#' {
1899 break;
1900 }
1901 output.push_str(&line[offset..offset + character.len_utf8()]);
1902 index += 1;
1903 }
1904 output
1905}
1906
1907fn mask_string_contents(source: &str) -> String {
1908 let mut output = String::with_capacity(source.len());
1909 let mut quote = None;
1910 let mut escaped = false;
1911 for character in source.chars() {
1912 if let Some(delimiter) = quote {
1913 if escaped {
1914 escaped = false;
1915 output.push(' ');
1916 } else if character == '\\' {
1917 escaped = true;
1918 output.push(' ');
1919 } else if character == delimiter {
1920 quote = None;
1921 output.push(character);
1922 } else {
1923 output.push(' ');
1924 }
1925 } else if matches!(character, '"' | '\'' | '`') {
1926 quote = Some(character);
1927 output.push(character);
1928 } else {
1929 output.push(character);
1930 }
1931 }
1932 output
1933}
1934
1935fn compact_lowercase(source: &str) -> String {
1936 source
1937 .chars()
1938 .filter(|character| !character.is_whitespace())
1939 .flat_map(char::to_lowercase)
1940 .collect()
1941}
1942
1943fn bounded_source(input: &str) -> (&str, bool) {
1944 if input.len() <= MAX_SOURCE_BYTES {
1945 return (input, false);
1946 }
1947 let boundary = input.floor_char_boundary(MAX_SOURCE_BYTES);
1948 (&input[..boundary], true)
1949}
1950
1951fn bounded_text(value: &str, max_chars: usize) -> String {
1952 let mut output = value.chars().take(max_chars).collect::<String>();
1953 if value.chars().count() > max_chars {
1954 output.push_str("...");
1955 }
1956 output
1957}
1958
1959fn normalize_observation(observation: &mut EventObservation) {
1960 observation.warnings.sort();
1961 observation.warnings.dedup();
1962 observation.evidence.sort();
1963 observation.evidence.dedup();
1964 if let Some(schema) = &mut observation.schema {
1965 schema.fields.sort();
1966 schema.fields.dedup();
1967 }
1968 if !observation.warnings.is_empty() {
1969 observation.incomplete = true;
1970 }
1971}
1972
1973fn finish_document(document: &mut EventDocument) {
1974 for observation in &mut document.observations {
1975 normalize_observation(observation);
1976 }
1977 document.observations.sort_by_key(observation_sort_key);
1978 let mut deduplicated = Vec::<EventObservation>::new();
1979 for observation in std::mem::take(&mut document.observations) {
1980 if let Some(previous) = deduplicated.last_mut()
1981 && observation_sort_key(previous) == observation_sort_key(&observation)
1982 {
1983 previous.evidence.extend(observation.evidence);
1984 normalize_observation(previous);
1985 } else {
1986 deduplicated.push(observation);
1987 }
1988 }
1989 document.observations = deduplicated;
1990 document.warnings.sort();
1991 document.warnings.dedup();
1992 document.incomplete |= document
1993 .observations
1994 .iter()
1995 .any(|observation| observation.incomplete);
1996}
1997
1998fn observation_sort_key(observation: &EventObservation) -> String {
1999 format!(
2000 "{:?}\u{0}{:?}\u{0}{}\u{0}{}\u{0}{}\u{0}{}\u{0}{:?}\u{0}{:?}\u{0}{}\u{0}{}\u{0}{:?}\u{0}{}\u{0}{:?}\u{0}{}\u{0}{:?}",
2001 observation.broker,
2002 observation.role,
2003 observation.channel.as_deref().unwrap_or_default(),
2004 observation.event_type.as_deref().unwrap_or_default(),
2005 observation.namespace.as_deref().unwrap_or_default(),
2006 observation.protocol.as_deref().unwrap_or_default(),
2007 observation.schema,
2008 observation.partition_key,
2009 observation.routing_key.as_deref().unwrap_or_default(),
2010 observation
2011 .dead_letter_channel
2012 .as_deref()
2013 .unwrap_or_default(),
2014 observation.delivery_semantics,
2015 observation.confidence,
2016 observation.language,
2017 observation.incomplete,
2018 observation.warnings
2019 )
2020}
2021
2022#[cfg(test)]
2023mod tests {
2024 use super::*;
2025
2026 fn source_observation(language: SourceLanguage, source: &str) -> EventObservation {
2027 parse_event_source(language, source)
2028 .observations
2029 .into_iter()
2030 .next()
2031 .expect("fixture should produce an observation")
2032 }
2033
2034 #[test]
2035 fn asyncapi_two_extracts_kafka_message_schema_and_bindings() {
2036 let source = r#"
2037asyncapi: 2.6.0
2038servers:
2039 production:
2040 url: kafka.example.test:9092/orders
2041 protocol: kafka
2042channels:
2043 orders.created:
2044 bindings:
2045 kafka:
2046 topicConfiguration: {}
2047 publish:
2048 bindings:
2049 kafka:
2050 x-partition-key: tenant_id
2051 message:
2052 name: OrderCreated
2053 schemaFormat: application/schema+json;version=draft-07
2054 x-schema-version: "2"
2055 payload:
2056 type: object
2057 required: [id]
2058 properties:
2059 tenant_id: { type: string }
2060 id: { type: string }
2061"#;
2062 let document = extract_asyncapi("asyncapi.yaml", source).expect("fixture should parse");
2063
2064 assert!(matches!(
2065 document.observations.as_slice(),
2066 [observation]
2067 if observation.broker == EventBroker::Kafka
2068 && observation.role == EventRole::Publisher
2069 && observation.channel.as_deref() == Some("orders.created")
2070 && observation.partition_key.as_deref() == Some("tenant_id")
2071 && observation.schema.as_ref().is_some_and(|schema|
2072 schema.name.as_deref() == Some("OrderCreated")
2073 && schema.version.as_deref() == Some("2")
2074 && schema.fields.len() == 2)
2075 ));
2076 }
2077
2078 #[test]
2079 fn asyncapi_three_extracts_send_and_receive_operations() {
2080 let source = r"
2081asyncapi: 3.0.0
2082servers:
2083 broker:
2084 host: nats.example.test
2085 protocol: nats
2086channels:
2087 jobs:
2088 address: jobs.ready
2089 messages:
2090 Job:
2091 name: Job
2092 payload:
2093 type: object
2094 properties:
2095 id: { type: string }
2096operations:
2097 sendJob:
2098 action: send
2099 channel:
2100 $ref: '#/channels/jobs'
2101 receiveJob:
2102 action: receive
2103 channel:
2104 $ref: '#/channels/jobs'
2105";
2106 let document = extract_asyncapi("asyncapi.json", source).expect("fixture should parse");
2107
2108 assert!(
2109 document.observations.len() == 2
2110 && document.observations.iter().all(|observation| {
2111 observation.channel.as_deref() == Some("jobs.ready")
2112 && observation.namespace.as_deref() == Some("nats.example.test")
2113 && observation.broker == EventBroker::Nats
2114 })
2115 );
2116 }
2117
2118 #[test]
2119 fn asyncapi_multiple_server_protocols_remain_ambiguous() {
2120 let source = r"
2121asyncapi: 2.6.0
2122servers:
2123 kafka:
2124 url: kafka.example.test
2125 protocol: kafka
2126 amqp:
2127 url: rabbit.example.test
2128 protocol: amqp
2129channels:
2130 events:
2131 publish:
2132 message:
2133 name: Event
2134";
2135 let document = extract_asyncapi("asyncapi.yaml", source).expect("fixture should parse");
2136
2137 assert!(
2138 document.incomplete
2139 && document
2140 .warnings
2141 .iter()
2142 .any(|warning| { warning.contains("multiple server protocols") })
2143 );
2144 }
2145
2146 #[test]
2147 fn asyncapi_templated_channel_is_not_exact() {
2148 let source = r"
2149asyncapi: 2.6.0
2150channels:
2151 orders.{tenant}:
2152 subscribe:
2153 message:
2154 name: Order
2155";
2156 let document = extract_asyncapi("asyncapi.yaml", source).expect("fixture should parse");
2157
2158 assert!(matches!(
2159 document.observations.as_slice(),
2160 [observation]
2161 if observation.channel.is_none()
2162 && observation.incomplete
2163 && observation.confidence == 0.0
2164 ));
2165 }
2166
2167 #[test]
2168 fn asyncapi_supports_json_and_dead_letter_metadata() {
2169 let source = r#"{
2170 "asyncapi": "2.6.0",
2171 "servers": {"queue": {"url": "queue.example.test", "protocol": "sqs"}},
2172 "channels": {
2173 "jobs": {
2174 "subscribe": {
2175 "x-delivery-semantics": "at-least-once",
2176 "deadLetterQueue": "jobs-dead",
2177 "message": {"name": "Job"}
2178 }
2179 }
2180 }
2181 }"#;
2182 let document = extract_asyncapi("asyncapi.json", source).expect("fixture should parse");
2183
2184 assert!(matches!(
2185 document.observations.as_slice(),
2186 [observation]
2187 if observation.broker == EventBroker::AwsSqs
2188 && observation.delivery_semantics
2189 == Some(DeliverySemantics::AtLeastOnce)
2190 && observation.dead_letter_channel.as_deref() == Some("jobs-dead")
2191 ));
2192 }
2193
2194 #[test]
2195 fn kafka_literal_call_is_exact() {
2196 let observation = source_observation(
2197 SourceLanguage::Rust,
2198 r#"producer.send(FutureRecord::to("orders.created").payload(body));"#,
2199 );
2200
2201 assert!(
2202 observation.broker == EventBroker::Kafka
2203 && observation.channel.as_deref() == Some("orders.created")
2204 && observation.role == EventRole::Publisher
2205 );
2206 }
2207
2208 #[test]
2209 fn rabbitmq_literal_call_extracts_exchange() {
2210 let observation = source_observation(
2211 SourceLanguage::Python,
2212 r#"channel.basic_publish(exchange="orders", routing_key="created", body=data)"#,
2213 );
2214
2215 assert!(
2216 observation.broker == EventBroker::RabbitMq
2217 && observation.channel.as_deref() == Some("orders")
2218 && observation.routing_key.as_deref() == Some("created")
2219 );
2220 }
2221
2222 #[test]
2223 fn sns_literal_call_extracts_topic_arn() {
2224 let observation = source_observation(
2225 SourceLanguage::TypeScript,
2226 r#"sns.publish({ TopicArn: "arn:aws:sns:us-east-1:123:orders", Message: body });"#,
2227 );
2228
2229 assert!(
2230 observation.broker == EventBroker::AwsSns
2231 && observation.channel.as_deref() == Some("arn:aws:sns:us-east-1:123:orders")
2232 );
2233 }
2234
2235 #[test]
2236 fn sqs_literal_call_extracts_queue_url() {
2237 let observation = source_observation(
2238 SourceLanguage::JavaScript,
2239 r#"sqs.sendMessage({ QueueUrl: "https://sqs.example.test/orders", MessageBody: body });"#,
2240 );
2241
2242 assert!(
2243 observation.broker == EventBroker::AwsSqs
2244 && observation.channel.as_deref() == Some("https://sqs.example.test/orders")
2245 );
2246 }
2247
2248 #[test]
2249 fn nats_literal_call_extracts_subject() {
2250 let observation = source_observation(
2251 SourceLanguage::Go,
2252 r#"natsConnection.Publish("orders.created", payload)"#,
2253 );
2254
2255 assert!(
2256 observation.broker == EventBroker::Nats
2257 && observation.channel.as_deref() == Some("orders.created")
2258 );
2259 }
2260
2261 #[test]
2262 fn google_pubsub_literal_call_extracts_topic() {
2263 let observation = source_observation(
2264 SourceLanguage::Java,
2265 r#"pubsubPublisher.publish("projects/demo/topics/orders", message);"#,
2266 );
2267
2268 assert!(
2269 observation.broker == EventBroker::GooglePubSub
2270 && observation.channel.as_deref() == Some("projects/demo/topics/orders")
2271 );
2272 }
2273
2274 #[test]
2275 fn generic_literal_subscriber_is_exact() {
2276 let observation = source_observation(
2277 SourceLanguage::Python,
2278 r#"event_bus.subscribe("inventory.changed", handler)"#,
2279 );
2280
2281 assert!(
2282 observation.broker == EventBroker::Generic
2283 && observation.role == EventRole::Subscriber
2284 && observation.channel.as_deref() == Some("inventory.changed")
2285 );
2286 }
2287
2288 #[test]
2289 fn dynamic_channel_remains_incomplete_without_exact_value() {
2290 let observation = source_observation(
2291 SourceLanguage::TypeScript,
2292 "eventBus.publish(topicName, payload);",
2293 );
2294
2295 assert!(
2296 observation.channel.is_none()
2297 && observation.incomplete
2298 && observation.confidence == 0.0
2299 && observation
2300 .warnings
2301 .iter()
2302 .any(|warning| warning.contains("dynamic channel"))
2303 );
2304 }
2305
2306 #[test]
2307 fn interpolated_literal_remains_dynamic() {
2308 let observation = source_observation(
2309 SourceLanguage::JavaScript,
2310 "nats.publish(`orders.${tenant}`, payload);",
2311 );
2312
2313 assert!(observation.channel.is_none() && observation.incomplete);
2314 }
2315
2316 #[test]
2317 fn concatenated_literal_remains_dynamic() {
2318 let observation = source_observation(
2319 SourceLanguage::Java,
2320 r#"eventBus.publish("orders." + tenant, payload);"#,
2321 );
2322
2323 assert!(observation.channel.is_none() && observation.incomplete);
2324 }
2325
2326 #[test]
2327 fn call_text_inside_string_is_not_observed() {
2328 let document = parse_event_source(
2329 SourceLanguage::Rust,
2330 r#"let example = "event_bus.publish(\"orders\", payload)";"#,
2331 );
2332
2333 assert!(document.observations.is_empty());
2334 }
2335
2336 #[test]
2337 fn evidence_does_not_retain_source_or_payload() {
2338 let secret = "private-message-body";
2339 let document = parse_event_source(
2340 SourceLanguage::JavaScript,
2341 &format!(r#"eventBus.publish("orders", "{secret}");"#),
2342 );
2343 let serialized = serde_json::to_string(&document).expect("event document should serialize");
2344
2345 assert!(!serialized.contains(secret));
2346 }
2347
2348 #[test]
2349 fn source_results_are_sorted_and_deduplicated() {
2350 let document = parse_event_source(
2351 SourceLanguage::Python,
2352 "bus.publish(\"z\", body)\nbus.publish(\"a\", body)\nbus.publish(\"a\", body)",
2353 );
2354
2355 assert!(matches!(
2356 document.observations.as_slice(),
2357 [first, second]
2358 if first.channel.as_deref() == Some("a")
2359 && second.channel.as_deref() == Some("z")
2360 ));
2361 }
2362}