Skip to main content

code_system_graph_core/
infrastructure.rs

1//! Secret-safe, bounded extraction for infrastructure and deployment declarations.
2//!
3//! Extractors in this module retain declaration metadata only. They never retain source
4//! fragments or values from environment variables, Kubernetes secrets, credentials, tokens, or
5//! connection strings. Dynamic expressions are omitted and make the returned document incomplete.
6
7use 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
16/// Maximum infrastructure source size accepted by an extractor.
17pub const MAX_INFRASTRUCTURE_INPUT_BYTES: usize = 1024 * 1024;
18/// Maximum structural nesting accepted before or after parsing.
19pub const MAX_INFRASTRUCTURE_DEPTH: usize = 64;
20/// Maximum number of parsed syntax items accepted from one source.
21pub const MAX_INFRASTRUCTURE_ITEMS: usize = 16_384;
22/// Maximum number of deployment units and resources retained from one source.
23pub const MAX_INFRASTRUCTURE_OUTPUT_ITEMS: usize = 1_024;
24/// Maximum number of evidence records retained from one source.
25pub const MAX_INFRASTRUCTURE_EVIDENCE: usize = 1_024;
26/// Maximum byte length of a retained source-derived identifier.
27pub const MAX_INFRASTRUCTURE_STRING_BYTES: usize = 512;
28/// Maximum number of warnings retained from one source.
29pub 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/// Supported infrastructure artifact families.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum InfrastructureArtifactKind {
39    /// Docker Compose YAML.
40    DockerCompose,
41    /// Kubernetes YAML or JSON.
42    Kubernetes,
43    /// A Helm template containing conservatively visible YAML.
44    HelmTemplate,
45    /// A Helm values file.
46    HelmValues,
47    /// Terraform or `OpenTofu` HCL.
48    Terraform,
49}
50
51/// Kinds of explicitly declared deployment units.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum DeploymentKind {
55    /// A Docker Compose service.
56    DockerComposeService,
57    /// A Kubernetes Deployment.
58    KubernetesDeployment,
59    /// A Kubernetes `StatefulSet`.
60    KubernetesStatefulSet,
61    /// A Kubernetes `DaemonSet`.
62    KubernetesDaemonSet,
63    /// A Kubernetes Job.
64    KubernetesJob,
65    /// A Kubernetes `CronJob`.
66    KubernetesCronJob,
67    /// A standalone Kubernetes Pod.
68    KubernetesPod,
69    /// A statically visible workload in a Helm template whose concrete kind is unsupported.
70    HelmTemplate,
71    /// A Terraform or `OpenTofu` resource that directly declares a deployment unit.
72    TerraformResource,
73}
74
75/// Coarse kind of an explicitly declared infrastructure resource.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum InfrastructureResourceKind {
79    /// A discoverable or deployable service.
80    Service,
81    /// An ingress or externally routed endpoint.
82    Ingress,
83    /// A messaging topic.
84    Topic,
85    /// A messaging queue.
86    Queue,
87    /// A database service or database declaration.
88    Database,
89    /// Another explicit resource whose provider type is retained.
90    Other,
91}
92
93/// Kind of bounded, value-free evidence supporting an observation.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum InfrastructureEvidenceKind {
97    /// An explicit resource or deployment declaration.
98    Declaration,
99    /// An explicit image attribute.
100    Image,
101    /// An explicit port declaration.
102    Port,
103    /// An explicit dependency or backend reference.
104    Dependency,
105    /// An explicit environment key declaration.
106    EnvironmentKey,
107    /// An explicit selector.
108    Selector,
109    /// An explicit host alias.
110    HostAlias,
111}
112
113/// A bounded source locator that deliberately contains no source text or values.
114#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct InfrastructureEvidence {
117    /// Evidence category.
118    pub kind: InfrastructureEvidenceKind,
119    /// Optional one-based source line. It is absent when the parser exposes no stable span.
120    pub line: Option<u32>,
121}
122
123/// One explicit network port declaration.
124#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct InfrastructurePort {
127    /// Optional declared port name.
128    pub name: Option<String>,
129    /// Service or container-facing port.
130    pub port: u16,
131    /// Optional target port used by a service declaration.
132    pub target_port: Option<u16>,
133    /// Optional host-published port.
134    pub host_port: Option<u16>,
135    /// Optional normalized transport protocol.
136    pub protocol: Option<String>,
137}
138
139/// One explicit selector key/value pair.
140#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
141#[serde(deny_unknown_fields)]
142pub struct InfrastructureSelector {
143    /// Selector key.
144    pub key: String,
145    /// Literal selector value.
146    pub value: String,
147}
148
149/// One explicit deployment unit.
150#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct DeploymentUnit {
153    /// Deployment technology and workload kind.
154    pub kind: DeploymentKind,
155    /// Literal deployment name.
156    pub name: String,
157    /// Optional literal namespace.
158    pub namespace: Option<String>,
159    /// Literal container images.
160    pub images: Vec<String>,
161    /// Explicit network ports.
162    pub ports: Vec<InfrastructurePort>,
163    /// Environment variable key names. Values are never retained.
164    pub environment_keys: Vec<String>,
165    /// Explicit dependency or resource-reference names.
166    pub dependencies: Vec<String>,
167    /// Explicit service names owned directly by this declaration.
168    pub service_names: Vec<String>,
169    /// Explicit service-discovery or host-alias names.
170    pub host_aliases: Vec<String>,
171    /// Explicit selectors. Selectors are not resolved into inferred deployment links.
172    pub selectors: Vec<InfrastructureSelector>,
173    /// Bounded value-free evidence.
174    pub evidence: Vec<InfrastructureEvidence>,
175}
176
177/// One explicit infrastructure resource.
178#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct InfrastructureResource {
181    /// Coarse resource kind.
182    pub kind: InfrastructureResourceKind,
183    /// Literal provider or manifest type, such as `aws_sqs_queue` or `ConfigMap`.
184    pub resource_type: String,
185    /// Literal resource name.
186    pub name: String,
187    /// Optional literal namespace.
188    pub namespace: Option<String>,
189    /// Explicit network ports.
190    pub ports: Vec<InfrastructurePort>,
191    /// Environment or configuration key names. Values are never retained.
192    pub key_names: Vec<String>,
193    /// Explicit dependency or backend-reference names.
194    pub dependencies: Vec<String>,
195    /// Explicit selectors. They are retained without resolving them to workloads.
196    pub selectors: Vec<InfrastructureSelector>,
197    /// Bounded value-free evidence.
198    pub evidence: Vec<InfrastructureEvidence>,
199}
200
201/// Secret-safe extraction result for one infrastructure source.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(deny_unknown_fields)]
204pub struct InfrastructureDocument {
205    /// Artifact family selected by the extractor.
206    pub artifact_kind: InfrastructureArtifactKind,
207    /// Bounded source path supplied by the caller.
208    pub source_path: String,
209    /// Explicit deployment units in deterministic order.
210    pub deployment_units: Vec<DeploymentUnit>,
211    /// Explicit infrastructure resources in deterministic order.
212    pub resources: Vec<InfrastructureResource>,
213    /// Environment key names visible outside a concrete deployment unit, primarily Helm values.
214    pub environment_keys: Vec<String>,
215    /// Bounded deterministic warnings that contain no source values.
216    pub warnings: Vec<String>,
217    /// Whether unsupported, dynamic, templated, or truncated constructs were observed.
218    pub incomplete: bool,
219}
220
221/// Bounded, secret-safe infrastructure extraction failure.
222#[derive(Debug, Clone, PartialEq, Eq, Error)]
223pub enum InfrastructureExtractionError {
224    /// Input exceeds the byte budget.
225    #[error("infrastructure input is {actual} bytes; maximum is {maximum}")]
226    InputTooLarge {
227        /// Observed input size.
228        actual: usize,
229        /// Configured input limit.
230        maximum: usize,
231    },
232    /// Source path metadata is outside the accepted bound.
233    #[error("infrastructure source path is invalid or exceeds the supported length")]
234    InvalidSourcePath,
235    /// The source exceeded the structural nesting budget.
236    #[error("infrastructure input exceeds the maximum structural depth of {maximum}")]
237    StructureTooDeep {
238        /// Configured depth limit.
239        maximum: usize,
240    },
241    /// The parsed source exceeded the syntax item budget.
242    #[error("infrastructure input exceeds the maximum syntax item count of {maximum}")]
243    TooManyItems {
244        /// Configured item limit.
245        maximum: usize,
246    },
247    /// The source is malformed for the selected artifact family.
248    #[error("infrastructure input is not a valid {artifact_kind:?} document")]
249    InvalidDocument {
250        /// Selected artifact family. Parser diagnostics are intentionally omitted.
251        artifact_kind: InfrastructureArtifactKind,
252    },
253}
254
255/// Extracts explicit Docker Compose services and their declaration metadata.
256///
257/// Environment values, secret definitions, and source fragments are never retained.
258///
259/// # Errors
260///
261/// Returns [`InfrastructureExtractionError`] when the source path, size, structure, or YAML
262/// document exceeds a documented bound or is malformed.
263pub 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
278/// Extracts explicit Kubernetes workloads and resources without resolving selector-based links.
279///
280/// `ConfigMap` and `Secret` values are ignored; only their key names are retained.
281///
282/// # Errors
283///
284/// Returns [`InfrastructureExtractionError`] when the source path, size, structure, or YAML/JSON
285/// document exceeds a documented bound or is malformed.
286pub 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
301/// Extracts statically visible declarations from a Helm template or values file.
302///
303/// Templates are never rendered. Dynamic expressions are masked before conservative YAML parsing,
304/// omitted from output, and reported through [`InfrastructureDocument::incomplete`].
305///
306/// # Errors
307///
308/// Returns [`InfrastructureExtractionError`] when the source path, size, structure, or static YAML
309/// exceeds a documented bound. A syntactically valid-looking templated file that cannot be parsed
310/// without rendering returns an incomplete document instead of executing template logic.
311pub 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
350/// Extracts explicit Terraform or `OpenTofu` resource declarations from HCL.
351///
352/// Only literal names, images, ports, dependency traversals, and environment key names are
353/// retained. Dynamic expressions are omitted.
354///
355/// # Errors
356///
357/// Returns [`InfrastructureExtractionError`] when the source path, size, HCL syntax, structural
358/// depth, or syntax item count exceeds a documented bound.
359pub 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}