1use std::collections::BTreeSet;
8
9use hcl::{Block, BlockLabel, Body, Expression, ObjectKey, Structure};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13type Mapping = serde_json::Map<String, Value>;
14use thiserror::Error;
15
16pub const MAX_INFRASTRUCTURE_INPUT_BYTES: usize = 1024 * 1024;
18pub const MAX_INFRASTRUCTURE_DEPTH: usize = 64;
20pub const MAX_INFRASTRUCTURE_ITEMS: usize = 16_384;
22pub const MAX_INFRASTRUCTURE_OUTPUT_ITEMS: usize = 1_024;
24pub const MAX_INFRASTRUCTURE_EVIDENCE: usize = 1_024;
26pub const MAX_INFRASTRUCTURE_STRING_BYTES: usize = 512;
28pub const MAX_INFRASTRUCTURE_WARNINGS: usize = 32;
30
31const MAX_SOURCE_PATH_BYTES: usize = 4_096;
32const MAX_YAML_DOCUMENTS: usize = 256;
33const HELM_DYNAMIC_MARKER: &str = "CODE_SYSTEM_GRAPH_DYNAMIC_VALUE";
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum InfrastructureArtifactKind {
39 DockerCompose,
41 Kubernetes,
43 HelmTemplate,
45 HelmValues,
47 Terraform,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum DeploymentKind {
55 DockerComposeService,
57 KubernetesDeployment,
59 KubernetesStatefulSet,
61 KubernetesDaemonSet,
63 KubernetesJob,
65 KubernetesCronJob,
67 KubernetesPod,
69 HelmTemplate,
71 TerraformResource,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum InfrastructureResourceKind {
79 Service,
81 Ingress,
83 Topic,
85 Queue,
87 Database,
89 Other,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum InfrastructureEvidenceKind {
97 Declaration,
99 Image,
101 Port,
103 Dependency,
105 EnvironmentKey,
107 Selector,
109 HostAlias,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct InfrastructureEvidence {
117 pub kind: InfrastructureEvidenceKind,
119 pub line: Option<u32>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct InfrastructurePort {
127 pub name: Option<String>,
129 pub port: u16,
131 pub target_port: Option<u16>,
133 pub host_port: Option<u16>,
135 pub protocol: Option<String>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct InfrastructureSelector {
143 pub key: String,
145 pub value: String,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct DeploymentUnit {
153 pub kind: DeploymentKind,
155 pub name: String,
157 pub namespace: Option<String>,
159 pub images: Vec<String>,
161 pub ports: Vec<InfrastructurePort>,
163 pub environment_keys: Vec<String>,
165 pub dependencies: Vec<String>,
167 pub service_names: Vec<String>,
169 pub host_aliases: Vec<String>,
171 pub selectors: Vec<InfrastructureSelector>,
173 pub evidence: Vec<InfrastructureEvidence>,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct InfrastructureResource {
181 pub kind: InfrastructureResourceKind,
183 pub resource_type: String,
185 pub name: String,
187 pub namespace: Option<String>,
189 pub ports: Vec<InfrastructurePort>,
191 pub key_names: Vec<String>,
193 pub dependencies: Vec<String>,
195 pub selectors: Vec<InfrastructureSelector>,
197 pub evidence: Vec<InfrastructureEvidence>,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(deny_unknown_fields)]
204pub struct InfrastructureDocument {
205 pub artifact_kind: InfrastructureArtifactKind,
207 pub source_path: String,
209 pub deployment_units: Vec<DeploymentUnit>,
211 pub resources: Vec<InfrastructureResource>,
213 pub environment_keys: Vec<String>,
215 pub warnings: Vec<String>,
217 pub incomplete: bool,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Error)]
223pub enum InfrastructureExtractionError {
224 #[error("infrastructure input is {actual} bytes; maximum is {maximum}")]
226 InputTooLarge {
227 actual: usize,
229 maximum: usize,
231 },
232 #[error("infrastructure source path is invalid or exceeds the supported length")]
234 InvalidSourcePath,
235 #[error("infrastructure input exceeds the maximum structural depth of {maximum}")]
237 StructureTooDeep {
238 maximum: usize,
240 },
241 #[error("infrastructure input exceeds the maximum syntax item count of {maximum}")]
243 TooManyItems {
244 maximum: usize,
246 },
247 #[error("infrastructure input is not a valid {artifact_kind:?} document")]
249 InvalidDocument {
250 artifact_kind: InfrastructureArtifactKind,
252 },
253}
254
255pub fn extract_docker_compose(
264 source_path: &str,
265 input: &str,
266) -> Result<InfrastructureDocument, InfrastructureExtractionError> {
267 validate_common(source_path, input)?;
268 let documents = parse_yaml_documents(input, InfrastructureArtifactKind::DockerCompose)?;
269 let mut collector = Collector::new(InfrastructureArtifactKind::DockerCompose, source_path);
270
271 for document in &documents {
272 extract_compose_document(document, &mut collector);
273 }
274
275 Ok(collector.finish())
276}
277
278pub fn extract_kubernetes(
287 source_path: &str,
288 input: &str,
289) -> Result<InfrastructureDocument, InfrastructureExtractionError> {
290 validate_common(source_path, input)?;
291 let documents = parse_yaml_documents(input, InfrastructureArtifactKind::Kubernetes)?;
292 let mut collector = Collector::new(InfrastructureArtifactKind::Kubernetes, source_path);
293
294 for document in &documents {
295 extract_kubernetes_value(document, &mut collector, false);
296 }
297
298 Ok(collector.finish())
299}
300
301pub fn extract_helm(
312 source_path: &str,
313 input: &str,
314) -> Result<InfrastructureDocument, InfrastructureExtractionError> {
315 validate_common(source_path, input)?;
316 let artifact_kind = if is_helm_values_path(source_path) {
317 InfrastructureArtifactKind::HelmValues
318 } else {
319 InfrastructureArtifactKind::HelmTemplate
320 };
321 let (masked, has_template) = mask_helm_templates(input);
322 let mut collector = Collector::new(artifact_kind, source_path);
323
324 if has_template {
325 collector.mark_incomplete("dynamic Helm template expressions were omitted");
326 }
327
328 let documents = match parse_yaml_documents(&masked, artifact_kind) {
329 Ok(documents) => documents,
330 Err(InfrastructureExtractionError::InvalidDocument { .. }) if has_template => {
331 collector.mark_incomplete("Helm template requires rendering and was not evaluated");
332 return Ok(collector.finish());
333 }
334 Err(error) => return Err(error),
335 };
336
337 if artifact_kind == InfrastructureArtifactKind::HelmValues {
338 for document in &documents {
339 extract_helm_values(document, &mut collector);
340 }
341 } else {
342 for document in &documents {
343 extract_kubernetes_value(document, &mut collector, true);
344 }
345 }
346
347 Ok(collector.finish())
348}
349
350pub fn extract_terraform(
360 source_path: &str,
361 input: &str,
362) -> Result<InfrastructureDocument, InfrastructureExtractionError> {
363 validate_common(source_path, input)?;
364 let body = hcl::parse(input).map_err(|_| InfrastructureExtractionError::InvalidDocument {
365 artifact_kind: InfrastructureArtifactKind::Terraform,
366 })?;
367 enforce_hcl_budget(&body)?;
368
369 let mut collector = Collector::new(InfrastructureArtifactKind::Terraform, source_path);
370 visit_terraform_body(&body, &mut collector);
371 Ok(collector.finish())
372}
373
374struct Collector {
375 document: InfrastructureDocument,
376 warnings: BTreeSet<String>,
377 evidence_count: usize,
378 output_count: usize,
379}
380
381impl Collector {
382 fn new(artifact_kind: InfrastructureArtifactKind, source_path: &str) -> Self {
383 Self {
384 document: InfrastructureDocument {
385 artifact_kind,
386 source_path: source_path.to_owned(),
387 deployment_units: Vec::new(),
388 resources: Vec::new(),
389 environment_keys: Vec::new(),
390 warnings: Vec::new(),
391 incomplete: false,
392 },
393 warnings: BTreeSet::new(),
394 evidence_count: 0,
395 output_count: 0,
396 }
397 }
398
399 fn mark_incomplete(&mut self, warning: &'static str) {
400 self.document.incomplete = true;
401 if self.warnings.len() < MAX_INFRASTRUCTURE_WARNINGS {
402 self.warnings.insert(warning.to_owned());
403 }
404 }
405
406 fn evidence(&mut self, kind: InfrastructureEvidenceKind) -> Vec<InfrastructureEvidence> {
407 if self.evidence_count >= MAX_INFRASTRUCTURE_EVIDENCE {
408 self.mark_incomplete("evidence limit reached; additional evidence was omitted");
409 return Vec::new();
410 }
411 self.evidence_count += 1;
412 vec![InfrastructureEvidence { kind, line: None }]
413 }
414
415 fn push_unit(&mut self, mut unit: DeploymentUnit) {
416 normalize_unit(&mut unit);
417 if self.output_count >= MAX_INFRASTRUCTURE_OUTPUT_ITEMS {
418 self.mark_incomplete("output item limit reached; additional declarations were omitted");
419 return;
420 }
421 self.output_count += 1;
422 self.document.deployment_units.push(unit);
423 }
424
425 fn push_resource(&mut self, mut resource: InfrastructureResource) {
426 normalize_resource(&mut resource);
427 if self.output_count >= MAX_INFRASTRUCTURE_OUTPUT_ITEMS {
428 self.mark_incomplete("output item limit reached; additional declarations were omitted");
429 return;
430 }
431 self.output_count += 1;
432 self.document.resources.push(resource);
433 }
434
435 fn finish(mut self) -> InfrastructureDocument {
436 for unit in &mut self.document.deployment_units {
437 normalize_unit(unit);
438 }
439 for resource in &mut self.document.resources {
440 normalize_resource(resource);
441 }
442 sort_dedupe(&mut self.document.deployment_units);
443 sort_dedupe(&mut self.document.resources);
444 sort_dedupe(&mut self.document.environment_keys);
445 self.document.warnings = self.warnings.into_iter().collect();
446 self.document
447 }
448}
449
450fn normalize_unit(unit: &mut DeploymentUnit) {
451 sort_dedupe(&mut unit.images);
452 sort_dedupe(&mut unit.ports);
453 sort_dedupe(&mut unit.environment_keys);
454 sort_dedupe(&mut unit.dependencies);
455 sort_dedupe(&mut unit.service_names);
456 sort_dedupe(&mut unit.host_aliases);
457 sort_dedupe(&mut unit.selectors);
458 sort_dedupe(&mut unit.evidence);
459}
460
461fn normalize_resource(resource: &mut InfrastructureResource) {
462 sort_dedupe(&mut resource.ports);
463 sort_dedupe(&mut resource.key_names);
464 sort_dedupe(&mut resource.dependencies);
465 sort_dedupe(&mut resource.selectors);
466 sort_dedupe(&mut resource.evidence);
467}
468
469fn sort_dedupe<T: Ord>(values: &mut Vec<T>) {
470 values.sort();
471 values.dedup();
472}
473
474fn validate_common(source_path: &str, input: &str) -> Result<(), InfrastructureExtractionError> {
475 if source_path.is_empty()
476 || source_path.len() > MAX_SOURCE_PATH_BYTES
477 || source_path.contains('\0')
478 {
479 return Err(InfrastructureExtractionError::InvalidSourcePath);
480 }
481 if input.len() > MAX_INFRASTRUCTURE_INPUT_BYTES {
482 return Err(InfrastructureExtractionError::InputTooLarge {
483 actual: input.len(),
484 maximum: MAX_INFRASTRUCTURE_INPUT_BYTES,
485 });
486 }
487 preflight_depth(input)
488}
489
490fn preflight_depth(input: &str) -> Result<(), InfrastructureExtractionError> {
491 let mut flow_depth = 0_usize;
492 let mut quote = None;
493 let mut escaped = false;
494 let mut block_comment = false;
495 let mut previous = '\0';
496
497 for line in input.lines() {
498 let indentation = line
499 .as_bytes()
500 .iter()
501 .take_while(|byte| **byte == b' ')
502 .count();
503 if indentation > MAX_INFRASTRUCTURE_DEPTH * 4 {
504 return Err(InfrastructureExtractionError::StructureTooDeep {
505 maximum: MAX_INFRASTRUCTURE_DEPTH,
506 });
507 }
508
509 let mut line_comment = false;
510 for current in line.chars() {
511 if line_comment {
512 break;
513 }
514 if block_comment {
515 if previous == '*' && current == '/' {
516 block_comment = false;
517 }
518 previous = current;
519 continue;
520 }
521 if let Some(delimiter) = quote {
522 if delimiter == '"' && escaped {
523 escaped = false;
524 } else if delimiter == '"' && current == '\\' {
525 escaped = true;
526 } else if current == delimiter {
527 quote = None;
528 }
529 previous = current;
530 continue;
531 }
532 match current {
533 '"' | '\'' => quote = Some(current),
534 '#' => line_comment = true,
535 '/' if previous == '/' => line_comment = true,
536 '*' if previous == '/' => block_comment = true,
537 '{' | '[' | '(' => {
538 flow_depth += 1;
539 if flow_depth > MAX_INFRASTRUCTURE_DEPTH {
540 return Err(InfrastructureExtractionError::StructureTooDeep {
541 maximum: MAX_INFRASTRUCTURE_DEPTH,
542 });
543 }
544 }
545 '}' | ']' | ')' => flow_depth = flow_depth.saturating_sub(1),
546 _ => {}
547 }
548 previous = current;
549 }
550 previous = '\0';
551 }
552 Ok(())
553}
554
555fn parse_yaml_documents(
556 input: &str,
557 artifact_kind: InfrastructureArtifactKind,
558) -> Result<Vec<Value>, InfrastructureExtractionError> {
559 let documents: Vec<Value> = crate::yaml::from_multiple(input)
560 .map_err(|_| InfrastructureExtractionError::InvalidDocument { artifact_kind })?;
561 if documents.len() > MAX_YAML_DOCUMENTS {
562 return Err(InfrastructureExtractionError::TooManyItems {
563 maximum: MAX_YAML_DOCUMENTS,
564 });
565 }
566 for value in &documents {
567 enforce_yaml_budget(value)?;
568 }
569 Ok(documents)
570}
571
572fn enforce_yaml_budget(value: &Value) -> Result<(), InfrastructureExtractionError> {
573 let mut stack = vec![(value, 1_usize)];
574 let mut items = 0_usize;
575 while let Some((current, depth)) = stack.pop() {
576 items += 1;
577 if items > MAX_INFRASTRUCTURE_ITEMS {
578 return Err(InfrastructureExtractionError::TooManyItems {
579 maximum: MAX_INFRASTRUCTURE_ITEMS,
580 });
581 }
582 if depth > MAX_INFRASTRUCTURE_DEPTH {
583 return Err(InfrastructureExtractionError::StructureTooDeep {
584 maximum: MAX_INFRASTRUCTURE_DEPTH,
585 });
586 }
587 match current {
588 Value::Array(sequence) => {
589 stack.extend(sequence.iter().map(|item| (item, depth + 1)));
590 }
591 Value::Object(mapping) => {
592 items = items.saturating_add(mapping.len());
593 for nested in mapping.values() {
594 stack.push((nested, depth + 1));
595 }
596 }
597 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
598 }
599 }
600 Ok(())
601}
602
603fn extract_compose_document(value: &Value, collector: &mut Collector) {
604 let Some(root) = value.as_object() else {
605 collector.mark_incomplete("Docker Compose root is not a mapping");
606 return;
607 };
608 let Some(services) = yaml_get(root, "services").and_then(Value::as_object) else {
609 collector.mark_incomplete("Docker Compose services mapping is absent");
610 return;
611 };
612
613 let mut entries = services.iter().collect::<Vec<_>>();
614 entries.sort_by_key(|(key, _)| key.as_str());
615 for (name_value, service_value) in entries {
616 let Some(name) = bounded_literal(name_value) else {
617 collector.mark_incomplete("dynamic or invalid Compose service name was omitted");
618 continue;
619 };
620 let Some(service) = service_value.as_object() else {
621 collector.mark_incomplete("unsupported Compose service definition was omitted");
622 continue;
623 };
624
625 let mut unit = empty_unit(DeploymentKind::DockerComposeService, name.clone());
626 unit.service_names.push(name.clone());
627 unit.evidence = collector.evidence(InfrastructureEvidenceKind::Declaration);
628
629 if let Some(image_value) = yaml_get(service, "image") {
630 match yaml_static_string(image_value) {
631 Some(image) => unit.images.push(image),
632 None => collector.mark_incomplete("dynamic Compose image was omitted"),
633 }
634 }
635 if let Some(environment) = yaml_get(service, "environment") {
636 extract_yaml_environment_keys(environment, &mut unit.environment_keys, collector);
637 }
638 if let Some(ports) = yaml_get(service, "ports") {
639 extract_compose_ports(ports, &mut unit.ports, collector);
640 }
641 if let Some(depends_on) = yaml_get(service, "depends_on") {
642 extract_name_set(depends_on, &mut unit.dependencies, collector);
643 }
644 if let Some(links) = yaml_get(service, "links") {
645 extract_compose_links(
646 links,
647 &mut unit.dependencies,
648 &mut unit.host_aliases,
649 collector,
650 );
651 }
652 if let Some(extra_hosts) = yaml_get(service, "extra_hosts") {
653 extract_host_aliases(extra_hosts, &mut unit.host_aliases, collector);
654 }
655 for field in ["hostname", "container_name"] {
656 if let Some(alias_value) = yaml_get(service, field) {
657 if let Some(alias) = yaml_static_string(alias_value) {
658 unit.host_aliases.push(alias);
659 } else {
660 collector.mark_incomplete("dynamic Compose host alias was omitted");
661 }
662 }
663 }
664
665 let resource = InfrastructureResource {
666 kind: InfrastructureResourceKind::Service,
667 resource_type: "compose_service".to_owned(),
668 name,
669 namespace: None,
670 ports: unit.ports.clone(),
671 key_names: unit.environment_keys.clone(),
672 dependencies: unit.dependencies.clone(),
673 selectors: Vec::new(),
674 evidence: collector.evidence(InfrastructureEvidenceKind::Declaration),
675 };
676 collector.push_unit(unit);
677 collector.push_resource(resource);
678 }
679}
680
681fn extract_compose_ports(
682 value: &Value,
683 output: &mut Vec<InfrastructurePort>,
684 collector: &mut Collector,
685) {
686 let Some(sequence) = value.as_array() else {
687 collector.mark_incomplete("unsupported Compose ports declaration was omitted");
688 return;
689 };
690 for entry in sequence {
691 let port = match entry {
692 Value::String(specification) => parse_compose_port_string(specification),
693 Value::Number(number) => {
694 number
695 .as_u64()
696 .and_then(to_u16)
697 .map(|port| InfrastructurePort {
698 name: None,
699 port,
700 target_port: None,
701 host_port: None,
702 protocol: None,
703 })
704 }
705 Value::Object(mapping) => parse_compose_port_mapping(mapping),
706 _ => None,
707 };
708 match port {
709 Some(port) => output.push(port),
710 None => collector.mark_incomplete("dynamic or invalid Compose port was omitted"),
711 }
712 }
713}
714
715fn parse_compose_port_string(value: &str) -> Option<InfrastructurePort> {
716 if contains_dynamic(value) || value.len() > MAX_INFRASTRUCTURE_STRING_BYTES {
717 return None;
718 }
719 let (port_specification, protocol) = value
720 .rsplit_once('/')
721 .map_or((value, None), |(port_specification, protocol)| {
722 (port_specification, normalize_protocol(protocol))
723 });
724 let parts = port_specification.split(':').collect::<Vec<_>>();
725 let container = parts.last()?.parse::<u16>().ok()?;
726 let host_port = if parts.len() >= 2 {
727 parts.get(parts.len() - 2)?.parse::<u16>().ok()
728 } else {
729 None
730 };
731 Some(InfrastructurePort {
732 name: None,
733 port: container,
734 target_port: None,
735 host_port,
736 protocol,
737 })
738}
739
740fn parse_compose_port_mapping(mapping: &Mapping) -> Option<InfrastructurePort> {
741 let port = yaml_u16(yaml_get(mapping, "target")?)?;
742 Some(InfrastructurePort {
743 name: yaml_get(mapping, "name").and_then(yaml_static_string),
744 port,
745 target_port: None,
746 host_port: yaml_get(mapping, "published").and_then(yaml_u16),
747 protocol: yaml_get(mapping, "protocol")
748 .and_then(Value::as_str)
749 .and_then(normalize_protocol),
750 })
751}
752
753fn extract_compose_links(
754 value: &Value,
755 dependencies: &mut Vec<String>,
756 aliases: &mut Vec<String>,
757 collector: &mut Collector,
758) {
759 let Some(sequence) = value.as_array() else {
760 collector.mark_incomplete("unsupported Compose links declaration was omitted");
761 return;
762 };
763 for entry in sequence {
764 let Some(link) = yaml_static_string(entry) else {
765 collector.mark_incomplete("dynamic Compose link was omitted");
766 continue;
767 };
768 let (dependency, alias) = link
769 .split_once(':')
770 .map_or((link.as_str(), None), |(dependency, alias)| {
771 (dependency, Some(alias))
772 });
773 if let Some(dependency) = bounded_identifier(dependency) {
774 dependencies.push(dependency);
775 }
776 if let Some(alias) = alias.and_then(bounded_identifier) {
777 aliases.push(alias);
778 }
779 }
780}
781
782fn extract_host_aliases(value: &Value, aliases: &mut Vec<String>, collector: &mut Collector) {
783 match value {
784 Value::Array(sequence) => {
785 for entry in sequence {
786 let Some(specification) = yaml_static_string(entry) else {
787 collector.mark_incomplete("dynamic host alias was omitted");
788 continue;
789 };
790 let alias = specification
791 .split_once(':')
792 .map_or(specification.as_str(), |(alias, _)| alias);
793 if let Some(alias) = bounded_identifier(alias) {
794 aliases.push(alias);
795 }
796 }
797 }
798 Value::Object(mapping) => {
799 for key in mapping.keys() {
800 if let Some(alias) = bounded_literal(key) {
801 aliases.push(alias);
802 } else {
803 collector.mark_incomplete("dynamic host alias was omitted");
804 }
805 }
806 }
807 _ => collector.mark_incomplete("unsupported host alias declaration was omitted"),
808 }
809}
810
811fn extract_name_set(value: &Value, output: &mut Vec<String>, collector: &mut Collector) {
812 match value {
813 Value::Array(sequence) => {
814 for entry in sequence {
815 if let Some(name) = yaml_static_string(entry) {
816 output.push(name);
817 } else {
818 collector.mark_incomplete("dynamic dependency name was omitted");
819 }
820 }
821 }
822 Value::Object(mapping) => {
823 for key in mapping.keys() {
824 if let Some(name) = bounded_literal(key) {
825 output.push(name);
826 } else {
827 collector.mark_incomplete("dynamic dependency name was omitted");
828 }
829 }
830 }
831 _ => collector.mark_incomplete("unsupported dependency declaration was omitted"),
832 }
833}
834
835fn extract_yaml_environment_keys(
836 value: &Value,
837 output: &mut Vec<String>,
838 collector: &mut Collector,
839) {
840 match value {
841 Value::Object(mapping) => {
842 for key in mapping.keys() {
843 if let Some(key) = bounded_key_name(key) {
844 output.push(key);
845 } else {
846 collector.mark_incomplete("invalid environment key name was omitted");
847 }
848 }
849 }
850 Value::Array(sequence) => {
851 for entry in sequence {
852 match entry {
853 Value::String(specification) => {
854 let key = specification
855 .split_once('=')
856 .map_or(specification.as_str(), |(key, _)| key);
857 if let Some(key) = bounded_key_name(key) {
858 output.push(key);
859 } else {
860 collector.mark_incomplete("invalid environment key name was omitted");
861 }
862 }
863 Value::Object(mapping) => {
864 if let Some(name) = yaml_get(mapping, "name").and_then(yaml_key_name) {
865 output.push(name);
866 } else {
867 collector.mark_incomplete("invalid environment key name was omitted");
868 }
869 }
870 _ => collector.mark_incomplete("unsupported environment entry was omitted"),
871 }
872 }
873 }
874 _ => collector.mark_incomplete("unsupported environment declaration was omitted"),
875 }
876}
877
878fn extract_kubernetes_value(value: &Value, collector: &mut Collector, from_helm: bool) {
879 let Some(root) = value.as_object() else {
880 if !value.is_null() {
881 collector.mark_incomplete("Kubernetes document root is not a mapping");
882 }
883 return;
884 };
885 let Some(kind) = yaml_get(root, "kind").and_then(yaml_static_string) else {
886 if !root.is_empty() {
887 collector.mark_incomplete("Kubernetes object kind was not statically visible");
888 }
889 return;
890 };
891
892 if kind == "List" {
893 if let Some(items) = yaml_get(root, "items").and_then(Value::as_array) {
894 for item in items {
895 extract_kubernetes_value(item, collector, from_helm);
896 }
897 } else {
898 collector.mark_incomplete("Kubernetes List items were not statically visible");
899 }
900 return;
901 }
902
903 let Some(metadata) = yaml_get(root, "metadata").and_then(Value::as_object) else {
904 if !root.is_empty() {
905 collector.mark_incomplete("Kubernetes object without static metadata was omitted");
906 }
907 return;
908 };
909 let Some(name) = yaml_get(metadata, "name").and_then(yaml_static_string) else {
910 collector.mark_incomplete("dynamic Kubernetes resource name was omitted");
911 return;
912 };
913 let namespace = match yaml_get(metadata, "namespace") {
914 Some(namespace) => {
915 if let Some(namespace) = yaml_static_string(namespace) {
916 Some(namespace)
917 } else {
918 collector.mark_incomplete("dynamic Kubernetes namespace was omitted");
919 None
920 }
921 }
922 None => None,
923 };
924
925 let resource_kind = match kind.as_str() {
926 "Service" => InfrastructureResourceKind::Service,
927 "Ingress" => InfrastructureResourceKind::Ingress,
928 _ => InfrastructureResourceKind::Other,
929 };
930 let mut resource = InfrastructureResource {
931 kind: resource_kind,
932 resource_type: kind.clone(),
933 name: name.clone(),
934 namespace: namespace.clone(),
935 ports: Vec::new(),
936 key_names: Vec::new(),
937 dependencies: Vec::new(),
938 selectors: Vec::new(),
939 evidence: collector.evidence(InfrastructureEvidenceKind::Declaration),
940 };
941
942 match kind.as_str() {
943 "Service" => extract_kubernetes_service(root, &mut resource, collector),
944 "Ingress" => extract_kubernetes_ingress(root, &mut resource, collector),
945 "ConfigMap" => {
946 extract_mapping_key_names(root, &["data", "binaryData"], &mut resource.key_names);
947 }
948 "Secret" => {
949 extract_mapping_key_names(root, &["data", "stringData"], &mut resource.key_names);
950 }
951 _ => {}
952 }
953
954 if let Some(deployment_kind) = kubernetes_deployment_kind(&kind, from_helm) {
955 let mut unit = empty_unit(deployment_kind, name);
956 unit.namespace = namespace;
957 unit.evidence = collector.evidence(InfrastructureEvidenceKind::Declaration);
958 extract_kubernetes_workload(root, &kind, &mut unit, collector);
959 collector.push_unit(unit);
960 }
961 collector.push_resource(resource);
962}
963
964fn kubernetes_deployment_kind(kind: &str, from_helm: bool) -> Option<DeploymentKind> {
965 match kind {
966 "Deployment" => Some(DeploymentKind::KubernetesDeployment),
967 "StatefulSet" => Some(DeploymentKind::KubernetesStatefulSet),
968 "DaemonSet" => Some(DeploymentKind::KubernetesDaemonSet),
969 "Job" => Some(DeploymentKind::KubernetesJob),
970 "CronJob" => Some(DeploymentKind::KubernetesCronJob),
971 "Pod" => Some(DeploymentKind::KubernetesPod),
972 _ if from_helm && !kind.is_empty() => None,
973 _ => None,
974 }
975}
976
977fn extract_kubernetes_service(
978 root: &Mapping,
979 resource: &mut InfrastructureResource,
980 collector: &mut Collector,
981) {
982 let Some(spec) = yaml_get(root, "spec").and_then(Value::as_object) else {
983 return;
984 };
985 if let Some(ports) = yaml_get(spec, "ports").and_then(Value::as_array) {
986 for entry in ports {
987 let Some(port_mapping) = entry.as_object() else {
988 collector.mark_incomplete("unsupported Kubernetes Service port was omitted");
989 continue;
990 };
991 let Some(port) = yaml_get(port_mapping, "port").and_then(yaml_u16) else {
992 collector.mark_incomplete("dynamic Kubernetes Service port was omitted");
993 continue;
994 };
995 resource.ports.push(InfrastructurePort {
996 name: yaml_get(port_mapping, "name").and_then(yaml_static_string),
997 port,
998 target_port: yaml_get(port_mapping, "targetPort").and_then(yaml_u16),
999 host_port: yaml_get(port_mapping, "nodePort").and_then(yaml_u16),
1000 protocol: yaml_get(port_mapping, "protocol")
1001 .and_then(Value::as_str)
1002 .and_then(normalize_protocol),
1003 });
1004 }
1005 }
1006 if let Some(selector) = yaml_get(spec, "selector").and_then(Value::as_object) {
1007 extract_selectors(selector, &mut resource.selectors, collector);
1008 }
1009 if let Some(external_name) = yaml_get(spec, "externalName") {
1010 if let Some(external_name) = yaml_static_string(external_name) {
1011 resource.dependencies.push(external_name);
1012 } else {
1013 collector.mark_incomplete("dynamic Kubernetes external service name was omitted");
1014 }
1015 }
1016}
1017
1018fn extract_kubernetes_ingress(
1019 root: &Mapping,
1020 resource: &mut InfrastructureResource,
1021 collector: &mut Collector,
1022) {
1023 let Some(spec) = yaml_get(root, "spec").and_then(Value::as_object) else {
1024 return;
1025 };
1026 if let Some(default_backend) = yaml_get(spec, "defaultBackend").and_then(Value::as_object) {
1027 extract_ingress_backend(default_backend, resource, collector);
1028 }
1029 if let Some(rules) = yaml_get(spec, "rules").and_then(Value::as_array) {
1030 for rule in rules {
1031 let Some(http) = rule
1032 .as_object()
1033 .and_then(|mapping| yaml_get(mapping, "http"))
1034 .and_then(Value::as_object)
1035 else {
1036 continue;
1037 };
1038 let Some(paths) = yaml_get(http, "paths").and_then(Value::as_array) else {
1039 continue;
1040 };
1041 for path in paths {
1042 if let Some(backend) = path
1043 .as_object()
1044 .and_then(|mapping| yaml_get(mapping, "backend"))
1045 .and_then(Value::as_object)
1046 {
1047 extract_ingress_backend(backend, resource, collector);
1048 }
1049 }
1050 }
1051 }
1052}
1053
1054fn extract_ingress_backend(
1055 backend: &Mapping,
1056 resource: &mut InfrastructureResource,
1057 collector: &mut Collector,
1058) {
1059 if let Some(service) = yaml_get(backend, "service").and_then(Value::as_object) {
1060 if let Some(name) = yaml_get(service, "name").and_then(yaml_static_string) {
1061 resource.dependencies.push(name);
1062 } else {
1063 collector.mark_incomplete("dynamic Kubernetes Ingress backend was omitted");
1064 }
1065 if let Some(port_mapping) = yaml_get(service, "port").and_then(Value::as_object)
1066 && let Some(port) = yaml_get(port_mapping, "number").and_then(yaml_u16)
1067 {
1068 resource.ports.push(InfrastructurePort {
1069 name: yaml_get(port_mapping, "name").and_then(yaml_static_string),
1070 port,
1071 target_port: None,
1072 host_port: None,
1073 protocol: None,
1074 });
1075 }
1076 } else if let Some(name) = yaml_get(backend, "serviceName").and_then(yaml_static_string) {
1077 resource.dependencies.push(name);
1078 if let Some(port) = yaml_get(backend, "servicePort").and_then(yaml_u16) {
1079 resource.ports.push(InfrastructurePort {
1080 name: None,
1081 port,
1082 target_port: None,
1083 host_port: None,
1084 protocol: None,
1085 });
1086 }
1087 }
1088}
1089
1090fn extract_kubernetes_workload(
1091 root: &Mapping,
1092 kind: &str,
1093 unit: &mut DeploymentUnit,
1094 collector: &mut Collector,
1095) {
1096 let Some(spec) = kubernetes_pod_spec(root, kind) else {
1097 collector.mark_incomplete("Kubernetes workload pod specification was not visible");
1098 return;
1099 };
1100
1101 for container_field in ["initContainers", "containers"] {
1102 if let Some(containers) = yaml_get(spec, container_field).and_then(Value::as_array) {
1103 for container in containers {
1104 if let Some(container) = container.as_object() {
1105 extract_kubernetes_container(container, unit, collector);
1106 } else {
1107 collector.mark_incomplete("unsupported Kubernetes container was omitted");
1108 }
1109 }
1110 }
1111 }
1112 if let Some(host_aliases) = yaml_get(spec, "hostAliases").and_then(Value::as_array) {
1113 for alias in host_aliases {
1114 if let Some(hostnames) = alias
1115 .as_object()
1116 .and_then(|mapping| yaml_get(mapping, "hostnames"))
1117 .and_then(Value::as_array)
1118 {
1119 for hostname in hostnames {
1120 if let Some(hostname) = yaml_static_string(hostname) {
1121 unit.host_aliases.push(hostname);
1122 } else {
1123 collector.mark_incomplete("dynamic Kubernetes host alias was omitted");
1124 }
1125 }
1126 }
1127 }
1128 }
1129 extract_kubernetes_volume_dependencies(spec, &mut unit.dependencies, collector);
1130
1131 if kind != "Pod"
1132 && let Some(selector) = yaml_get(root, "spec")
1133 .and_then(Value::as_object)
1134 .and_then(|mapping| yaml_get(mapping, "selector"))
1135 .and_then(Value::as_object)
1136 .and_then(|mapping| yaml_get(mapping, "matchLabels"))
1137 .and_then(Value::as_object)
1138 {
1139 extract_selectors(selector, &mut unit.selectors, collector);
1140 }
1141}
1142
1143fn kubernetes_pod_spec<'a>(root: &'a Mapping, kind: &str) -> Option<&'a Mapping> {
1144 let spec = yaml_get(root, "spec")?.as_object()?;
1145 match kind {
1146 "Pod" => Some(spec),
1147 "CronJob" => yaml_get(spec, "jobTemplate")
1148 .and_then(Value::as_object)
1149 .and_then(|job| yaml_get(job, "spec"))
1150 .and_then(Value::as_object)
1151 .and_then(|job_spec| yaml_get(job_spec, "template"))
1152 .and_then(Value::as_object)
1153 .and_then(|template| yaml_get(template, "spec"))
1154 .and_then(Value::as_object),
1155 _ => yaml_get(spec, "template")
1156 .and_then(Value::as_object)
1157 .and_then(|template| yaml_get(template, "spec"))
1158 .and_then(Value::as_object),
1159 }
1160}
1161
1162fn extract_kubernetes_container(
1163 container: &Mapping,
1164 unit: &mut DeploymentUnit,
1165 collector: &mut Collector,
1166) {
1167 if let Some(image_value) = yaml_get(container, "image") {
1168 if let Some(image) = yaml_static_string(image_value) {
1169 unit.images.push(image);
1170 } else {
1171 collector.mark_incomplete("dynamic Kubernetes image was omitted");
1172 }
1173 }
1174 if let Some(ports) = yaml_get(container, "ports").and_then(Value::as_array) {
1175 for entry in ports {
1176 let Some(port_mapping) = entry.as_object() else {
1177 collector.mark_incomplete("unsupported Kubernetes container port was omitted");
1178 continue;
1179 };
1180 let Some(port) = yaml_get(port_mapping, "containerPort").and_then(yaml_u16) else {
1181 collector.mark_incomplete("dynamic Kubernetes container port was omitted");
1182 continue;
1183 };
1184 unit.ports.push(InfrastructurePort {
1185 name: yaml_get(port_mapping, "name").and_then(yaml_static_string),
1186 port,
1187 target_port: None,
1188 host_port: yaml_get(port_mapping, "hostPort").and_then(yaml_u16),
1189 protocol: yaml_get(port_mapping, "protocol")
1190 .and_then(Value::as_str)
1191 .and_then(normalize_protocol),
1192 });
1193 }
1194 }
1195 if let Some(environment) = yaml_get(container, "env") {
1196 extract_kubernetes_environment(environment, unit, collector);
1197 }
1198 if let Some(environment_from) = yaml_get(container, "envFrom").and_then(Value::as_array) {
1199 for entry in environment_from {
1200 let Some(mapping) = entry.as_object() else {
1201 continue;
1202 };
1203 for (field, kind) in [("configMapRef", "ConfigMap"), ("secretRef", "Secret")] {
1204 if let Some(name) = yaml_get(mapping, field)
1205 .and_then(Value::as_object)
1206 .and_then(|reference| yaml_get(reference, "name"))
1207 .and_then(yaml_static_string)
1208 {
1209 unit.dependencies.push(format!("{kind}/{name}"));
1210 }
1211 }
1212 }
1213 }
1214}
1215
1216fn extract_kubernetes_environment(
1217 value: &Value,
1218 unit: &mut DeploymentUnit,
1219 collector: &mut Collector,
1220) {
1221 let Some(sequence) = value.as_array() else {
1222 collector.mark_incomplete("unsupported Kubernetes environment declaration was omitted");
1223 return;
1224 };
1225 for entry in sequence {
1226 let Some(mapping) = entry.as_object() else {
1227 collector.mark_incomplete("unsupported Kubernetes environment entry was omitted");
1228 continue;
1229 };
1230 if let Some(name) = yaml_get(mapping, "name").and_then(yaml_key_name) {
1231 unit.environment_keys.push(name);
1232 } else {
1233 collector.mark_incomplete("invalid Kubernetes environment key was omitted");
1234 }
1235 let Some(value_from) = yaml_get(mapping, "valueFrom").and_then(Value::as_object) else {
1236 continue;
1237 };
1238 for (field, kind) in [("configMapKeyRef", "ConfigMap"), ("secretKeyRef", "Secret")] {
1239 if let Some(reference) = yaml_get(value_from, field).and_then(Value::as_object)
1240 && let Some(name) = yaml_get(reference, "name").and_then(yaml_static_string)
1241 {
1242 unit.dependencies.push(format!("{kind}/{name}"));
1243 }
1244 }
1245 }
1246}
1247
1248fn extract_kubernetes_volume_dependencies(
1249 spec: &Mapping,
1250 output: &mut Vec<String>,
1251 collector: &mut Collector,
1252) {
1253 let Some(volumes) = yaml_get(spec, "volumes").and_then(Value::as_array) else {
1254 return;
1255 };
1256 for volume in volumes {
1257 let Some(mapping) = volume.as_object() else {
1258 continue;
1259 };
1260 for (field, kind) in [("configMap", "ConfigMap"), ("secret", "Secret")] {
1261 let Some(reference) = yaml_get(mapping, field).and_then(Value::as_object) else {
1262 continue;
1263 };
1264 let name_field = if field == "secret" {
1265 "secretName"
1266 } else {
1267 "name"
1268 };
1269 if let Some(name) = yaml_get(reference, name_field).and_then(yaml_static_string) {
1270 output.push(format!("{kind}/{name}"));
1271 } else {
1272 collector.mark_incomplete("dynamic Kubernetes volume reference was omitted");
1273 }
1274 }
1275 }
1276}
1277
1278fn extract_selectors(
1279 mapping: &Mapping,
1280 output: &mut Vec<InfrastructureSelector>,
1281 collector: &mut Collector,
1282) {
1283 for (key, value) in mapping {
1284 match (bounded_literal(key), yaml_static_string(value)) {
1285 (Some(key), Some(value)) => output.push(InfrastructureSelector { key, value }),
1286 _ => collector.mark_incomplete("dynamic selector was omitted"),
1287 }
1288 }
1289}
1290
1291fn extract_mapping_key_names(root: &Mapping, fields: &[&str], output: &mut Vec<String>) {
1292 for field in fields {
1293 if let Some(mapping) = yaml_get(root, field).and_then(Value::as_object) {
1294 for key in mapping.keys() {
1295 if let Some(key) = bounded_key_name(key) {
1296 output.push(key);
1297 }
1298 }
1299 }
1300 }
1301}
1302
1303fn is_helm_values_path(source_path: &str) -> bool {
1304 let basename = source_path
1305 .rsplit(['/', '\\'])
1306 .next()
1307 .unwrap_or(source_path)
1308 .to_ascii_lowercase();
1309 basename == "values.yaml"
1310 || basename == "values.yml"
1311 || basename.starts_with("values-")
1312 || basename.starts_with("values.")
1313}
1314
1315fn mask_helm_templates(input: &str) -> (String, bool) {
1316 let mut output = String::with_capacity(input.len());
1317 let mut has_template = false;
1318
1319 for segment in input.split_inclusive('\n') {
1320 let line = segment.strip_suffix('\n').unwrap_or(segment);
1321 let newline = if segment.ends_with('\n') { "\n" } else { "" };
1322 let trimmed = line.trim();
1323 if trimmed.starts_with("{{") && trimmed.ends_with("}}") {
1324 has_template = true;
1325 output.push_str(newline);
1326 continue;
1327 }
1328
1329 let mut remainder = line;
1330 while let Some(start) = remainder.find("{{") {
1331 has_template = true;
1332 output.push_str(&remainder[..start]);
1333 let Some(relative_end) = remainder[start + 2..].find("}}") else {
1334 output.push_str(HELM_DYNAMIC_MARKER);
1335 remainder = "";
1336 break;
1337 };
1338 output.push_str(HELM_DYNAMIC_MARKER);
1339 remainder = &remainder[start + 2 + relative_end + 2..];
1340 }
1341 output.push_str(remainder);
1342 output.push_str(newline);
1343 }
1344
1345 (output, has_template)
1346}
1347
1348fn extract_helm_values(value: &Value, collector: &mut Collector) {
1349 let mut environment_keys = Vec::new();
1350 collect_helm_environment_keys(value, None, &mut environment_keys, collector);
1351 collector.document.environment_keys.extend(environment_keys);
1352}
1353
1354fn collect_helm_environment_keys(
1355 value: &Value,
1356 parent_key: Option<&str>,
1357 output: &mut Vec<String>,
1358 collector: &mut Collector,
1359) {
1360 match value {
1361 Value::Object(mapping) => {
1362 let environment_context = parent_key.is_some_and(is_environment_container_key);
1363 for (key, nested) in mapping {
1364 let key = key.as_str();
1365 if environment_context {
1366 if let Some(key) = bounded_key_name(key) {
1367 output.push(key);
1368 }
1369 continue;
1370 }
1371 if contains_dynamic(key) {
1372 collector.mark_incomplete("dynamic Helm values key was omitted");
1373 continue;
1374 }
1375 collect_helm_environment_keys(nested, Some(key), output, collector);
1376 }
1377 }
1378 Value::Array(sequence) if parent_key.is_some_and(is_environment_container_key) => {
1379 for entry in sequence {
1380 if let Some(mapping) = entry.as_object()
1381 && let Some(name) = yaml_get(mapping, "name").and_then(yaml_key_name)
1382 {
1383 output.push(name);
1384 }
1385 }
1386 }
1387 Value::Array(sequence) => {
1388 for entry in sequence {
1389 collect_helm_environment_keys(entry, parent_key, output, collector);
1390 }
1391 }
1392 Value::String(string) if contains_dynamic(string) => {
1393 collector.mark_incomplete("dynamic Helm value was omitted");
1394 }
1395 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
1396 }
1397}
1398
1399fn is_environment_container_key(key: &str) -> bool {
1400 matches!(
1401 key.to_ascii_lowercase().as_str(),
1402 "env" | "environment" | "environmentvariables" | "environment_variables" | "extraenv"
1403 )
1404}
1405
1406fn enforce_hcl_budget(body: &Body) -> Result<(), InfrastructureExtractionError> {
1407 let mut stack = vec![HclNode::Body(body, 1_usize)];
1408 let mut items = 0_usize;
1409 while let Some(node) = stack.pop() {
1410 items += 1;
1411 if items > MAX_INFRASTRUCTURE_ITEMS {
1412 return Err(InfrastructureExtractionError::TooManyItems {
1413 maximum: MAX_INFRASTRUCTURE_ITEMS,
1414 });
1415 }
1416 let depth = match node {
1417 HclNode::Body(body, depth) => {
1418 for structure in &body.0 {
1419 match structure {
1420 Structure::Attribute(attribute) => {
1421 stack.push(HclNode::Expression(&attribute.expr, depth + 1));
1422 }
1423 Structure::Block(block) => {
1424 stack.push(HclNode::Body(&block.body, depth + 1));
1425 }
1426 }
1427 }
1428 depth
1429 }
1430 HclNode::Expression(expression, depth) => {
1431 match expression {
1432 Expression::Array(array) => {
1433 stack.extend(
1434 array
1435 .iter()
1436 .map(|item| HclNode::Expression(item, depth + 1)),
1437 );
1438 }
1439 Expression::Object(object) => {
1440 for (key, value) in object {
1441 if let ObjectKey::Expression(key) = key {
1442 stack.push(HclNode::Expression(key, depth + 1));
1443 }
1444 stack.push(HclNode::Expression(value, depth + 1));
1445 }
1446 }
1447 Expression::Parenthesis(expression) => {
1448 stack.push(HclNode::Expression(expression, depth + 1));
1449 }
1450 _ => {}
1451 }
1452 depth
1453 }
1454 };
1455 if depth > MAX_INFRASTRUCTURE_DEPTH {
1456 return Err(InfrastructureExtractionError::StructureTooDeep {
1457 maximum: MAX_INFRASTRUCTURE_DEPTH,
1458 });
1459 }
1460 }
1461 Ok(())
1462}
1463
1464enum HclNode<'a> {
1465 Body(&'a Body, usize),
1466 Expression(&'a Expression, usize),
1467}
1468
1469fn visit_terraform_body(body: &Body, collector: &mut Collector) {
1470 for structure in &body.0 {
1471 let Structure::Block(block) = structure else {
1472 continue;
1473 };
1474 if block.identifier() == "resource" {
1475 extract_terraform_resource(block, collector);
1476 } else {
1477 visit_terraform_body(&block.body, collector);
1478 }
1479 }
1480}
1481
1482fn extract_terraform_resource(block: &Block, collector: &mut Collector) {
1483 let Some(resource_type) = block.labels.first().and_then(hcl_label_string) else {
1484 collector.mark_incomplete("Terraform resource type was not statically visible");
1485 return;
1486 };
1487 let Some(local_name) = block.labels.get(1).and_then(hcl_label_string) else {
1488 collector.mark_incomplete("Terraform resource name was not statically visible");
1489 return;
1490 };
1491
1492 let name_expression =
1493 hcl_attribute(&block.body, "name").or_else(|| hcl_attribute(&block.body, "db_name"));
1494 let literal_name = name_expression.and_then(hcl_literal_string);
1495 if name_expression.is_some() && literal_name.is_none() {
1496 collector.mark_incomplete("dynamic Terraform resource name was omitted");
1497 }
1498 let name = literal_name.unwrap_or(local_name);
1499 let kind = terraform_resource_kind(&resource_type);
1500 let namespace_expression = hcl_attribute(&block.body, "namespace");
1501 let namespace = namespace_expression.and_then(hcl_literal_string);
1502 if namespace_expression.is_some() && namespace.is_none() {
1503 collector.mark_incomplete("dynamic Terraform namespace was omitted");
1504 }
1505 let mut resource = InfrastructureResource {
1506 kind,
1507 resource_type: resource_type.clone(),
1508 name: name.clone(),
1509 namespace,
1510 ports: Vec::new(),
1511 key_names: Vec::new(),
1512 dependencies: Vec::new(),
1513 selectors: Vec::new(),
1514 evidence: collector.evidence(InfrastructureEvidenceKind::Declaration),
1515 };
1516 let mut images = Vec::new();
1517 let mut environment_keys = Vec::new();
1518 collect_terraform_attributes(
1519 &block.body,
1520 &mut images,
1521 &mut resource.ports,
1522 &mut environment_keys,
1523 &mut resource.dependencies,
1524 &mut resource.key_names,
1525 collector,
1526 );
1527
1528 if terraform_is_deployment(&resource_type) {
1529 let mut unit = empty_unit(DeploymentKind::TerraformResource, name);
1530 unit.namespace.clone_from(&resource.namespace);
1531 unit.images = images;
1532 unit.ports.clone_from(&resource.ports);
1533 unit.environment_keys = environment_keys;
1534 unit.dependencies.clone_from(&resource.dependencies);
1535 if kind == InfrastructureResourceKind::Service {
1536 unit.service_names.push(resource.name.clone());
1537 }
1538 unit.evidence = collector.evidence(InfrastructureEvidenceKind::Declaration);
1539 collector.push_unit(unit);
1540 }
1541 collector.push_resource(resource);
1542}
1543
1544fn collect_terraform_attributes(
1545 body: &Body,
1546 images: &mut Vec<String>,
1547 ports: &mut Vec<InfrastructurePort>,
1548 environment_keys: &mut Vec<String>,
1549 dependencies: &mut Vec<String>,
1550 key_names: &mut Vec<String>,
1551 collector: &mut Collector,
1552) {
1553 for structure in &body.0 {
1554 match structure {
1555 Structure::Attribute(attribute) => {
1556 let key = attribute.key();
1557 if is_image_attribute(key) {
1558 if let Some(image) = hcl_literal_string(&attribute.expr) {
1559 images.push(image);
1560 } else {
1561 collector.mark_incomplete("dynamic Terraform image was omitted");
1562 }
1563 }
1564 if is_port_attribute(key) {
1565 if let Some(port) = hcl_literal_u16(&attribute.expr) {
1566 ports.push(InfrastructurePort {
1567 name: None,
1568 port,
1569 target_port: None,
1570 host_port: None,
1571 protocol: None,
1572 });
1573 } else if !matches!(attribute.expr, Expression::Array(_)) {
1574 collector.mark_incomplete("dynamic Terraform port was omitted");
1575 }
1576 }
1577 if is_environment_container_key(key) {
1578 collect_hcl_object_keys(
1579 &attribute.expr,
1580 environment_keys,
1581 collector,
1582 "dynamic Terraform environment key was omitted",
1583 );
1584 }
1585 if key == "depends_on" {
1586 collect_hcl_dependencies(&attribute.expr, dependencies, collector);
1587 }
1588 if matches!(key, "data" | "string_data" | "binary_data") {
1589 collect_hcl_object_keys(
1590 &attribute.expr,
1591 key_names,
1592 collector,
1593 "dynamic Terraform configuration key was omitted",
1594 );
1595 }
1596 if let Expression::Array(values) = &attribute.expr
1597 && is_port_attribute(key)
1598 {
1599 for value in values {
1600 if let Some(port) = hcl_literal_u16(value) {
1601 ports.push(InfrastructurePort {
1602 name: None,
1603 port,
1604 target_port: None,
1605 host_port: None,
1606 protocol: None,
1607 });
1608 } else {
1609 collector.mark_incomplete("dynamic Terraform port was omitted");
1610 }
1611 }
1612 }
1613 }
1614 Structure::Block(block) => {
1615 if matches!(
1616 block.identifier(),
1617 "environment" | "env" | "environment_variable"
1618 ) && let Some(name) =
1619 hcl_attribute(&block.body, "name").and_then(hcl_literal_string)
1620 && let Some(name) = bounded_key_name(&name)
1621 {
1622 environment_keys.push(name);
1623 }
1624 collect_terraform_attributes(
1625 &block.body,
1626 images,
1627 ports,
1628 environment_keys,
1629 dependencies,
1630 key_names,
1631 collector,
1632 );
1633 }
1634 }
1635 }
1636}
1637
1638fn collect_hcl_object_keys(
1639 expression: &Expression,
1640 output: &mut Vec<String>,
1641 collector: &mut Collector,
1642 warning: &'static str,
1643) {
1644 let Expression::Object(object) = expression else {
1645 collector.mark_incomplete(warning);
1646 return;
1647 };
1648 for key in object.keys() {
1649 let key = match key {
1650 ObjectKey::Identifier(identifier) => bounded_key_name(identifier),
1651 ObjectKey::Expression(Expression::String(string)) => bounded_key_name(string),
1652 _ => None,
1653 };
1654 if let Some(key) = key {
1655 output.push(key);
1656 } else {
1657 collector.mark_incomplete(warning);
1658 }
1659 }
1660}
1661
1662fn collect_hcl_dependencies(
1663 expression: &Expression,
1664 output: &mut Vec<String>,
1665 collector: &mut Collector,
1666) {
1667 match expression {
1668 Expression::Array(values) => {
1669 for value in values {
1670 collect_hcl_dependencies(value, output, collector);
1671 }
1672 }
1673 Expression::Traversal(_) | Expression::Variable(_) => {
1674 let dependency = expression.to_string();
1675 if is_safe_dependency(&dependency) {
1676 output.push(dependency);
1677 } else {
1678 collector.mark_incomplete("dynamic Terraform dependency was omitted");
1679 }
1680 }
1681 Expression::Parenthesis(expression) => {
1682 collect_hcl_dependencies(expression, output, collector);
1683 }
1684 _ => collector.mark_incomplete("dynamic Terraform dependency was omitted"),
1685 }
1686}
1687
1688fn terraform_resource_kind(resource_type: &str) -> InfrastructureResourceKind {
1689 let resource_type = resource_type.to_ascii_lowercase();
1690 if resource_type.contains("ingress") {
1691 InfrastructureResourceKind::Ingress
1692 } else if resource_type.contains("topic")
1693 || resource_type.contains("sns_")
1694 || resource_type.contains("pubsub")
1695 {
1696 InfrastructureResourceKind::Topic
1697 } else if resource_type.contains("queue")
1698 || resource_type.contains("sqs_")
1699 || resource_type.contains("servicebus_queue")
1700 {
1701 InfrastructureResourceKind::Queue
1702 } else if resource_type.contains("database")
1703 || resource_type.contains("db_instance")
1704 || resource_type.contains("rds_cluster")
1705 || resource_type.contains("sql_database")
1706 {
1707 InfrastructureResourceKind::Database
1708 } else if resource_type.contains("service")
1709 || resource_type.contains("deployment")
1710 || resource_type.contains("lambda_function")
1711 || resource_type.contains("cloud_run")
1712 || resource_type.contains("web_app")
1713 {
1714 InfrastructureResourceKind::Service
1715 } else {
1716 InfrastructureResourceKind::Other
1717 }
1718}
1719
1720fn terraform_is_deployment(resource_type: &str) -> bool {
1721 let resource_type = resource_type.to_ascii_lowercase();
1722 [
1723 "deployment",
1724 "ecs_service",
1725 "lambda_function",
1726 "cloud_run",
1727 "container_app",
1728 "web_app",
1729 "kubernetes_pod",
1730 "kubernetes_job",
1731 "nomad_job",
1732 ]
1733 .iter()
1734 .any(|needle| resource_type.contains(needle))
1735}
1736
1737fn hcl_attribute<'a>(body: &'a Body, key: &str) -> Option<&'a Expression> {
1738 body.0.iter().find_map(|structure| match structure {
1739 Structure::Attribute(attribute) if attribute.key() == key => Some(&attribute.expr),
1740 Structure::Attribute(_) | Structure::Block(_) => None,
1741 })
1742}
1743
1744fn hcl_label_string(label: &BlockLabel) -> Option<String> {
1745 let value = match label {
1746 BlockLabel::Identifier(identifier) => identifier.as_str(),
1747 BlockLabel::String(string) => string,
1748 };
1749 bounded_identifier(value)
1750}
1751
1752fn hcl_literal_string(expression: &Expression) -> Option<String> {
1753 match expression {
1754 Expression::String(value) => bounded_literal(value),
1755 Expression::Parenthesis(expression) => hcl_literal_string(expression),
1756 _ => None,
1757 }
1758}
1759
1760fn hcl_literal_u16(expression: &Expression) -> Option<u16> {
1761 match expression {
1762 Expression::Number(number) => number.to_string().parse().ok(),
1763 Expression::Parenthesis(expression) => hcl_literal_u16(expression),
1764 _ => None,
1765 }
1766}
1767
1768fn is_image_attribute(key: &str) -> bool {
1769 matches!(
1770 key,
1771 "image" | "container_image" | "image_uri" | "image_name"
1772 )
1773}
1774
1775fn is_port_attribute(key: &str) -> bool {
1776 matches!(
1777 key,
1778 "port"
1779 | "ports"
1780 | "container_port"
1781 | "container_ports"
1782 | "target_port"
1783 | "host_port"
1784 | "service_port"
1785 )
1786}
1787
1788fn yaml_get<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a Value> {
1789 mapping.get(key)
1790}
1791
1792fn yaml_static_string(value: &Value) -> Option<String> {
1793 value.as_str().and_then(bounded_literal)
1794}
1795
1796fn yaml_key_name(value: &Value) -> Option<String> {
1797 value.as_str().and_then(bounded_key_name)
1798}
1799
1800fn yaml_u16(value: &Value) -> Option<u16> {
1801 match value {
1802 Value::Number(number) => number.as_u64().and_then(to_u16),
1803 Value::String(string) if !contains_dynamic(string) => string.parse().ok(),
1804 _ => None,
1805 }
1806}
1807
1808fn to_u16(value: u64) -> Option<u16> {
1809 u16::try_from(value).ok()
1810}
1811
1812fn normalize_protocol(value: &str) -> Option<String> {
1813 match value.to_ascii_lowercase().as_str() {
1814 "tcp" => Some("tcp".to_owned()),
1815 "udp" => Some("udp".to_owned()),
1816 "sctp" => Some("sctp".to_owned()),
1817 _ => None,
1818 }
1819}
1820
1821fn bounded_literal(value: &str) -> Option<String> {
1822 if value.is_empty()
1823 || value.len() > MAX_INFRASTRUCTURE_STRING_BYTES
1824 || value.chars().any(char::is_control)
1825 || contains_dynamic(value)
1826 {
1827 None
1828 } else {
1829 Some(value.to_owned())
1830 }
1831}
1832
1833fn bounded_identifier(value: &str) -> Option<String> {
1834 let value = bounded_literal(value)?;
1835 if value
1836 .chars()
1837 .all(|character| character.is_ascii_alphanumeric() || "._:/@+-".contains(character))
1838 {
1839 Some(value)
1840 } else {
1841 None
1842 }
1843}
1844
1845fn bounded_key_name(value: &str) -> Option<String> {
1846 if value.is_empty()
1847 || value.len() > MAX_INFRASTRUCTURE_STRING_BYTES
1848 || contains_dynamic(value)
1849 || !value.chars().all(|character| {
1850 character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-')
1851 })
1852 {
1853 None
1854 } else {
1855 Some(value.to_owned())
1856 }
1857}
1858
1859fn contains_dynamic(value: &str) -> bool {
1860 value.contains("${")
1861 || value.contains("{{")
1862 || value.contains(HELM_DYNAMIC_MARKER)
1863 || value.contains("%{")
1864 || value.contains("$(")
1865}
1866
1867fn is_safe_dependency(value: &str) -> bool {
1868 !value.is_empty()
1869 && value.len() <= MAX_INFRASTRUCTURE_STRING_BYTES
1870 && value
1871 .chars()
1872 .all(|character| character.is_ascii_alphanumeric() || "._-[]".contains(character))
1873}
1874
1875fn empty_unit(kind: DeploymentKind, name: String) -> DeploymentUnit {
1876 DeploymentUnit {
1877 kind,
1878 name,
1879 namespace: None,
1880 images: Vec::new(),
1881 ports: Vec::new(),
1882 environment_keys: Vec::new(),
1883 dependencies: Vec::new(),
1884 service_names: Vec::new(),
1885 host_aliases: Vec::new(),
1886 selectors: Vec::new(),
1887 evidence: Vec::new(),
1888 }
1889}
1890
1891#[cfg(test)]
1892mod tests {
1893 use super::*;
1894
1895 #[test]
1896 fn compose_extracts_explicit_service_metadata() {
1897 let input = r#"
1898services:
1899 api:
1900 image: ghcr.io/acme/api:1
1901 ports:
1902 - "8080:80/tcp"
1903 environment:
1904 DATABASE_URL: must-not-persist
1905 LOG_LEVEL: info
1906 depends_on:
1907 db:
1908 condition: service_healthy
1909 links:
1910 - "db:database"
1911 extra_hosts:
1912 - "gateway:127.0.0.1"
1913 db:
1914 image: postgres:17
1915"#;
1916
1917 let document = extract_docker_compose("compose.yaml", input).expect("valid Compose");
1918
1919 assert_eq!(
1920 document.deployment_units[0],
1921 DeploymentUnit {
1922 kind: DeploymentKind::DockerComposeService,
1923 name: "api".to_owned(),
1924 namespace: None,
1925 images: vec!["ghcr.io/acme/api:1".to_owned()],
1926 ports: vec![InfrastructurePort {
1927 name: None,
1928 port: 80,
1929 target_port: None,
1930 host_port: Some(8080),
1931 protocol: Some("tcp".to_owned()),
1932 }],
1933 environment_keys: vec!["DATABASE_URL".to_owned(), "LOG_LEVEL".to_owned()],
1934 dependencies: vec!["db".to_owned()],
1935 service_names: vec!["api".to_owned()],
1936 host_aliases: vec!["database".to_owned(), "gateway".to_owned()],
1937 selectors: Vec::new(),
1938 evidence: vec![InfrastructureEvidence {
1939 kind: InfrastructureEvidenceKind::Declaration,
1940 line: None,
1941 }],
1942 }
1943 );
1944 }
1945
1946 #[test]
1947 fn compose_never_serializes_environment_or_secret_values() {
1948 let secret = "postgres://admin:very-secret@db/orders";
1949 let input = format!(
1950 "services:\n api:\n environment:\n DATABASE_URL: {secret}\nsecrets:\n token:\n data: another-secret\n"
1951 );
1952
1953 let document = extract_docker_compose("compose.yaml", &input).expect("valid Compose");
1954 let serialized = serde_json::to_string(&document).expect("serializable document");
1955
1956 assert!(!serialized.contains("very-secret") && !serialized.contains("another-secret"));
1957 }
1958
1959 #[test]
1960 fn kubernetes_extracts_workload_service_ingress_and_secret_keys() {
1961 let input = r"
1962apiVersion: apps/v1
1963kind: Deployment
1964metadata:
1965 name: orders
1966 namespace: production
1967spec:
1968 selector:
1969 matchLabels:
1970 app: orders
1971 template:
1972 spec:
1973 containers:
1974 - name: app
1975 image: ghcr.io/acme/orders:2
1976 ports:
1977 - name: http
1978 containerPort: 8080
1979 env:
1980 - name: DATABASE_URL
1981 valueFrom:
1982 secretKeyRef:
1983 name: orders-secret
1984 key: database-url
1985---
1986apiVersion: v1
1987kind: Service
1988metadata:
1989 name: orders
1990spec:
1991 selector:
1992 app: orders
1993 ports:
1994 - port: 80
1995 targetPort: 8080
1996---
1997apiVersion: networking.k8s.io/v1
1998kind: Ingress
1999metadata:
2000 name: orders
2001spec:
2002 rules:
2003 - http:
2004 paths:
2005 - path: /
2006 backend:
2007 service:
2008 name: orders
2009 port:
2010 number: 80
2011---
2012apiVersion: v1
2013kind: Secret
2014metadata:
2015 name: orders-secret
2016data:
2017 database-url: cG9zdGdyZXM6Ly9zZWNyZXQ=
2018";
2019
2020 let document = extract_kubernetes("k8s.yaml", input).expect("valid Kubernetes");
2021
2022 assert_eq!(
2023 document.deployment_units[0].environment_keys,
2024 ["DATABASE_URL"]
2025 );
2026 assert_eq!(
2027 document
2028 .resources
2029 .iter()
2030 .find(|resource| resource.resource_type == "Secret")
2031 .expect("secret resource")
2032 .key_names,
2033 ["database-url"]
2034 );
2035 }
2036
2037 #[test]
2038 fn kubernetes_does_not_infer_service_to_workload_link_from_selectors() {
2039 let input = r"
2040kind: Deployment
2041metadata: { name: api }
2042spec:
2043 selector: { matchLabels: { app: api } }
2044 template: { spec: { containers: [{ name: api, image: api:1 }] } }
2045---
2046kind: Service
2047metadata: { name: api }
2048spec:
2049 selector: { app: api }
2050";
2051
2052 let document = extract_kubernetes("objects.yaml", input).expect("valid Kubernetes");
2053
2054 assert!(document.deployment_units[0].service_names.is_empty());
2055 }
2056
2057 #[test]
2058 fn kubernetes_never_serializes_secret_or_environment_values() {
2059 let input = r"
2060kind: Secret
2061metadata: { name: credentials }
2062stringData:
2063 password: super-secret-password
2064 token: ghp_super_secret
2065---
2066kind: Pod
2067metadata: { name: api }
2068spec:
2069 containers:
2070 - name: api
2071 image: api:1
2072 env:
2073 - name: PASSWORD
2074 value: another-secret
2075";
2076
2077 let document = extract_kubernetes("objects.yaml", input).expect("valid Kubernetes");
2078 let serialized = serde_json::to_string(&document).expect("serializable document");
2079
2080 assert!(
2081 !serialized.contains("super-secret-password")
2082 && !serialized.contains("ghp_super_secret")
2083 && !serialized.contains("another-secret")
2084 );
2085 }
2086
2087 #[test]
2088 fn helm_template_retains_static_names_and_marks_dynamic_values_incomplete() {
2089 let input = r"
2090apiVersion: apps/v1
2091kind: Deployment
2092metadata:
2093 name: orders
2094spec:
2095 template:
2096 spec:
2097 containers:
2098 - name: orders
2099 image: {{ .Values.image.repository }}
2100 env:
2101 - name: API_TOKEN
2102 value: {{ .Values.secretToken }}
2103";
2104
2105 let document = extract_helm("templates/deployment.yaml", input).expect("bounded Helm");
2106
2107 assert!(document.incomplete && document.deployment_units[0].images.is_empty());
2108 }
2109
2110 #[test]
2111 fn helm_values_retains_only_environment_key_names() {
2112 let input = r"
2113image:
2114 repository: ghcr.io/acme/api
2115env:
2116 DATABASE_URL: postgres://admin:secret@db/orders
2117 API_TOKEN: ghp_secret
2118nested:
2119 extraEnv:
2120 - name: LOG_LEVEL
2121 value: debug
2122";
2123
2124 let document = extract_helm("values.yaml", input).expect("valid Helm values");
2125 let serialized = serde_json::to_string(&document).expect("serializable document");
2126
2127 assert_eq!(
2128 document.environment_keys,
2129 ["API_TOKEN", "DATABASE_URL", "LOG_LEVEL"]
2130 );
2131 assert!(!serialized.contains("postgres://") && !serialized.contains("ghp_secret"));
2132 }
2133
2134 #[test]
2135 fn terraform_extracts_literal_resources_and_deployment_metadata() {
2136 let input = r#"
2137resource "aws_sqs_queue" "jobs" {
2138 name = "jobs"
2139}
2140
2141resource "aws_db_instance" "orders" {
2142 db_name = "orders"
2143 password = "never-persist"
2144}
2145
2146resource "aws_ecs_service" "api" {
2147 name = "api"
2148 image = "ghcr.io/acme/api:3"
2149 port = 8080
2150 depends_on = [aws_db_instance.orders, aws_sqs_queue.jobs]
2151
2152 environment {
2153 name = "DATABASE_URL"
2154 value = "postgres://admin:secret@db/orders"
2155 }
2156}
2157"#;
2158
2159 let document = extract_terraform("main.tf", input).expect("valid Terraform");
2160
2161 assert_eq!(
2162 document
2163 .resources
2164 .iter()
2165 .map(|resource| resource.kind)
2166 .collect::<Vec<_>>(),
2167 [
2168 InfrastructureResourceKind::Service,
2169 InfrastructureResourceKind::Queue,
2170 InfrastructureResourceKind::Database,
2171 ]
2172 );
2173 assert_eq!(
2174 document.deployment_units[0].dependencies,
2175 ["aws_db_instance.orders", "aws_sqs_queue.jobs"]
2176 );
2177 }
2178
2179 #[test]
2180 fn terraform_never_serializes_credentials_or_connection_strings() {
2181 let input = r#"
2182resource "aws_db_instance" "orders" {
2183 username = "administrator"
2184 password = "super-secret-password"
2185 url = "postgres://administrator:super-secret-password@db/orders"
2186}
2187"#;
2188
2189 let document = extract_terraform("main.tf", input).expect("valid Terraform");
2190 let serialized = serde_json::to_string(&document).expect("serializable document");
2191
2192 assert!(
2193 !serialized.contains("administrator")
2194 && !serialized.contains("super-secret-password")
2195 && !serialized.contains("postgres://")
2196 );
2197 }
2198
2199 #[test]
2200 fn extraction_is_deterministically_sorted_and_deduplicated() {
2201 let first = r"
2202services:
2203 zeta:
2204 environment: [B=2, A=1, A=3]
2205 alpha:
2206 image: alpha:1
2207";
2208 let second = r"
2209services:
2210 alpha:
2211 image: alpha:1
2212 zeta:
2213 environment: [A=3, A=1, B=2]
2214";
2215
2216 let left = extract_docker_compose("compose.yaml", first).expect("valid Compose");
2217 let right = extract_docker_compose("compose.yaml", second).expect("valid Compose");
2218
2219 assert_eq!(left, right);
2220 }
2221
2222 #[test]
2223 fn oversized_input_is_rejected_before_parsing() {
2224 let input = "x".repeat(MAX_INFRASTRUCTURE_INPUT_BYTES + 1);
2225
2226 let error = extract_kubernetes("large.yaml", &input).expect_err("oversized input");
2227
2228 assert!(matches!(
2229 error,
2230 InfrastructureExtractionError::InputTooLarge { .. }
2231 ));
2232 }
2233
2234 #[test]
2235 fn deeply_nested_yaml_parser_bomb_is_rejected() {
2236 let mut input = String::new();
2237 for _ in 0..=MAX_INFRASTRUCTURE_DEPTH {
2238 input.push_str("a: {");
2239 }
2240 input.push_str("null");
2241 for _ in 0..=MAX_INFRASTRUCTURE_DEPTH {
2242 input.push('}');
2243 }
2244
2245 let error = extract_kubernetes("bomb.yaml", &input).expect_err("depth bomb");
2246
2247 assert!(matches!(
2248 error,
2249 InfrastructureExtractionError::StructureTooDeep { .. }
2250 ));
2251 }
2252
2253 #[test]
2254 fn excessive_yaml_items_are_rejected() {
2255 let input = format!("items:\n{}", " - null\n".repeat(MAX_INFRASTRUCTURE_ITEMS));
2256
2257 let error = extract_kubernetes("bomb.yaml", &input).expect_err("item bomb");
2258
2259 assert!(matches!(
2260 error,
2261 InfrastructureExtractionError::TooManyItems { .. }
2262 ));
2263 }
2264
2265 #[test]
2266 fn malformed_errors_do_not_echo_source_or_secret_values() {
2267 let secret = "super-secret-token";
2268 let input = format!("services: [\n {secret}");
2269
2270 let error = extract_docker_compose("compose.yaml", &input).expect_err("malformed YAML");
2271
2272 assert!(!error.to_string().contains(secret));
2273 }
2274
2275 #[test]
2276 fn malformed_terraform_error_does_not_echo_source() {
2277 let secret = "super-secret-password";
2278 let input = format!("resource \"aws_db_instance\" \"db\" {{ password = \"{secret}\"");
2279
2280 let error = extract_terraform("main.tf", &input).expect_err("malformed HCL");
2281
2282 assert!(!error.to_string().contains(secret));
2283 }
2284
2285 #[test]
2286 fn deeply_nested_hcl_parser_bomb_is_rejected() {
2287 let mut input = "value = ".to_owned();
2288 input.push_str(&"[".repeat(MAX_INFRASTRUCTURE_DEPTH + 1));
2289 input.push('0');
2290 input.push_str(&"]".repeat(MAX_INFRASTRUCTURE_DEPTH + 1));
2291
2292 let error = extract_terraform("bomb.tf", &input).expect_err("depth bomb");
2293
2294 assert!(matches!(
2295 error,
2296 InfrastructureExtractionError::StructureTooDeep { .. }
2297 ));
2298 }
2299}