Skip to main content

compose_lens/validation/
detect.rs

1//! Compatibility-feature discovery and report generation.
2
3use super::{CompatibilityClassification, CompatibilityFeature, CompatibilityProfile, CompatibilityRule};
4use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
5use crate::merge::{MergeOperation, MergedEntry, MergedProject, MergedValue, MergedValueKind};
6use crate::model::{Located, ShortExtraHost, ShortVolumeMount, UserNamespaceMode};
7use crate::profiles::ProfileSelection;
8use crate::resolution::{effective_span, selection_matches, service_in_scope};
9use crate::source::SourceSpan;
10
11/// A construct is unsupported by the selected compatibility profile.
12pub const UNSUPPORTED_FEATURE: DiagnosticCode = DiagnosticCode::new("compose.compatibility.unsupported");
13
14/// A construct relies on implementation-specific behavior.
15pub const IMPLEMENTATION_SPECIFIC_FEATURE: DiagnosticCode =
16    DiagnosticCode::new("compose.compatibility.implementation-specific");
17
18/// Compatibility evidence is insufficient for the selected versions.
19pub const UNKNOWN_FEATURE_SUPPORT: DiagnosticCode = DiagnosticCode::new("compose.compatibility.unknown");
20
21/// A construct is deprecated by the selected compatibility profile.
22pub const DEPRECATED_FEATURE: DiagnosticCode = DiagnosticCode::new("compose.compatibility.deprecated");
23
24/// One source occurrence of a compatibility-sensitive construct.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CompatibilityOccurrence {
27    feature: CompatibilityFeature,
28    path: Vec<String>,
29    source: SourceSpan,
30    sensitive: bool,
31}
32
33impl CompatibilityOccurrence {
34    /// Returns the detected feature.
35    #[must_use]
36    pub const fn feature(&self) -> CompatibilityFeature {
37        self.feature
38    }
39
40    /// Returns semantic mapping/sequence path segments without embedding the authored value.
41    #[must_use]
42    pub fn path(&self) -> &[String] {
43        &self.path
44    }
45
46    /// Returns the source span identifying the construct.
47    #[must_use]
48    pub const fn source(&self) -> SourceSpan {
49        self.source
50    }
51
52    /// Reports whether the source value includes sensitive interpolation output.
53    #[must_use]
54    pub const fn is_sensitive(&self) -> bool {
55        self.sensitive
56    }
57}
58
59/// One compatibility occurrence and the selected profile's decision.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct CompatibilityFinding {
62    occurrence: CompatibilityOccurrence,
63    rule: CompatibilityRule,
64}
65
66impl CompatibilityFinding {
67    /// Returns the detected source occurrence.
68    #[must_use]
69    pub const fn occurrence(&self) -> &CompatibilityOccurrence {
70        &self.occurrence
71    }
72
73    /// Returns the profile rule applied to that occurrence.
74    #[must_use]
75    pub const fn rule(&self) -> &CompatibilityRule {
76        &self.rule
77    }
78}
79
80/// A non-destructive compatibility assessment for one merged project view.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct CompatibilityReport {
83    profile: CompatibilityProfile,
84    findings: Vec<CompatibilityFinding>,
85    diagnostics: Vec<Diagnostic>,
86}
87
88impl CompatibilityReport {
89    /// Returns the exact compatibility context selected by the caller.
90    #[must_use]
91    pub const fn profile(&self) -> CompatibilityProfile {
92        self.profile
93    }
94
95    /// Returns all discovered constructs, including supported ones, in deterministic source order.
96    #[must_use]
97    pub fn findings(&self) -> &[CompatibilityFinding] {
98        &self.findings
99    }
100
101    /// Returns compatibility diagnostics for non-portable, unknown, deprecated, or unsupported constructs.
102    #[must_use]
103    pub fn diagnostics(&self) -> &[Diagnostic] {
104        &self.diagnostics
105    }
106
107    /// Reports whether compatibility assessment emitted no error diagnostics.
108    #[must_use]
109    pub fn is_valid(&self) -> bool {
110        self.diagnostics
111            .iter()
112            .all(|diagnostic| diagnostic.severity() != Severity::Error)
113    }
114}
115
116/// Detects and classifies compatibility-sensitive constructs in the selected project view.
117#[must_use]
118pub fn validate_compatibility(
119    project: &MergedProject,
120    selection: Option<&ProfileSelection>,
121    profile: CompatibilityProfile,
122) -> CompatibilityReport {
123    let mut diagnostics = Vec::new();
124    if !selection_matches(project, selection, &mut diagnostics) {
125        return CompatibilityReport {
126            profile,
127            findings: Vec::new(),
128            diagnostics,
129        };
130    }
131
132    let occurrences = discover(project, selection);
133    let findings = occurrences
134        .into_iter()
135        .map(|occurrence| {
136            let rule = profile.classify(occurrence.feature);
137            if let Some(severity) = rule.diagnostic_severity() {
138                diagnostics.push(diagnostic(&occurrence, &rule, severity));
139            }
140            CompatibilityFinding { occurrence, rule }
141        })
142        .collect();
143    CompatibilityReport {
144        profile,
145        findings,
146        diagnostics,
147    }
148}
149
150fn discover(project: &MergedProject, selection: Option<&ProfileSelection>) -> Vec<CompatibilityOccurrence> {
151    let mut occurrences = Vec::new();
152    let Some(root) = project.root().as_mapping() else {
153        return occurrences;
154    };
155    let mut path = Vec::new();
156    collect_operation(project.root(), &path, &mut occurrences);
157    for entry in root {
158        if entry.key() == "services" {
159            collect_services(entry, selection, &mut path, &mut occurrences);
160        } else {
161            collect_entry(entry, &mut path, &mut occurrences);
162        }
163    }
164    occurrences
165}
166
167fn collect_services(
168    services: &MergedEntry,
169    selection: Option<&ProfileSelection>,
170    path: &mut Vec<String>,
171    occurrences: &mut Vec<CompatibilityOccurrence>,
172) {
173    path.push("services".to_owned());
174    collect_operation(services.value(), path, occurrences);
175    if let Some(entries) = services.value().as_mapping() {
176        for service in entries {
177            if !service_in_scope(selection, service.key()) {
178                continue;
179            }
180            path.push(service.key().to_owned());
181            collect_service_features(service.value(), path, occurrences);
182            collect_value(service.value(), path, occurrences);
183            let _ = path.pop();
184        }
185    }
186    let _ = path.pop();
187}
188
189fn collect_service_features(service: &MergedValue, path: &[String], occurrences: &mut Vec<CompatibilityOccurrence>) {
190    if let Some(image) = service.get("image") {
191        if let Some(scalar) = image.as_scalar() {
192            if has_tag_and_digest(scalar.value()) {
193                let mut image_path = path.to_owned();
194                image_path.push("image".to_owned());
195                occurrences.push(CompatibilityOccurrence {
196                    feature: CompatibilityFeature::ImageTagAndDigest,
197                    path: image_path,
198                    source: effective_span(image),
199                    sensitive: scalar.is_sensitive(),
200                });
201            }
202        }
203    }
204
205    if let Some(userns_mode) = service.get("userns_mode") {
206        if let Some(scalar) = userns_mode.as_scalar() {
207            let source = effective_span(userns_mode);
208            let mode = UserNamespaceMode::parse(Located::new(scalar.value().to_owned(), source));
209            if mode.is_podman_specific() {
210                let mut mode_path = path.to_owned();
211                mode_path.push("userns_mode".to_owned());
212                occurrences.push(CompatibilityOccurrence {
213                    feature: CompatibilityFeature::PodmanUserNamespaceMode,
214                    path: mode_path,
215                    source,
216                    sensitive: scalar.is_sensitive(),
217                });
218            }
219        }
220    }
221
222    if let Some(extra_hosts) = service.get("extra_hosts") {
223        let mut hosts_path = path.to_owned();
224        hosts_path.push("extra_hosts".to_owned());
225        collect_host_gateway(extra_hosts, &hosts_path, occurrences);
226    }
227    if let Some(extra_hosts) = service.get("build").and_then(|build| build.get("extra_hosts")) {
228        let mut hosts_path = path.to_owned();
229        hosts_path.push("build".to_owned());
230        hosts_path.push("extra_hosts".to_owned());
231        collect_host_gateway(extra_hosts, &hosts_path, occurrences);
232    }
233
234    let Some(volumes) = service.get("volumes").and_then(MergedValue::as_sequence) else {
235        return;
236    };
237    for (index, volume) in volumes.iter().enumerate() {
238        let mut volume_path = path.to_owned();
239        volume_path.push("volumes".to_owned());
240        volume_path.push(index.to_string());
241        if let Some(scalar) = volume.as_scalar() {
242            let source = effective_span(volume);
243            let mount = ShortVolumeMount::new(Located::new(scalar.value().to_owned(), source));
244            if mount.selinux_relabel().is_some() {
245                occurrences.push(CompatibilityOccurrence {
246                    feature: CompatibilityFeature::ShortBindSelinuxRelabel,
247                    path: volume_path,
248                    source,
249                    sensitive: scalar.is_sensitive(),
250                });
251            }
252            continue;
253        }
254        let Some(selinux) = volume.get("bind").and_then(|bind| bind.get("selinux")) else {
255            continue;
256        };
257        volume_path.push("bind".to_owned());
258        volume_path.push("selinux".to_owned());
259        occurrences.push(CompatibilityOccurrence {
260            feature: CompatibilityFeature::LongBindSelinuxRelabel,
261            path: volume_path,
262            source: effective_span(selinux),
263            sensitive: selinux.is_sensitive(),
264        });
265    }
266}
267
268fn collect_host_gateway(extra_hosts: &MergedValue, path: &[String], occurrences: &mut Vec<CompatibilityOccurrence>) {
269    if let Some(values) = extra_hosts.as_sequence() {
270        for (index, value) in values.iter().enumerate() {
271            let Some(scalar) = value.as_scalar() else {
272                continue;
273            };
274            let source = effective_span(value);
275            let entry = ShortExtraHost::parse(Located::new(scalar.value().to_owned(), source));
276            if entry.address().is_some_and(crate::model::HostAddress::is_host_gateway) {
277                let mut occurrence_path = path.to_owned();
278                occurrence_path.push(index.to_string());
279                occurrences.push(CompatibilityOccurrence {
280                    feature: CompatibilityFeature::HostGatewayToken,
281                    path: occurrence_path,
282                    source,
283                    sensitive: scalar.is_sensitive(),
284                });
285            }
286        }
287    } else if let Some(entries) = extra_hosts.as_mapping() {
288        for entry in entries {
289            let Some(address) = entry.value().as_scalar() else {
290                continue;
291            };
292            if address.value() == "host-gateway" {
293                let mut occurrence_path = path.to_owned();
294                occurrence_path.push(entry.key().to_owned());
295                occurrences.push(CompatibilityOccurrence {
296                    feature: CompatibilityFeature::HostGatewayToken,
297                    path: occurrence_path,
298                    source: effective_span(entry.value()),
299                    sensitive: address.is_sensitive(),
300                });
301            }
302        }
303    }
304}
305
306fn collect_entry(entry: &MergedEntry, path: &mut Vec<String>, occurrences: &mut Vec<CompatibilityOccurrence>) {
307    path.push(entry.key().to_owned());
308    if entry.key().starts_with("x-") {
309        occurrences.push(CompatibilityOccurrence {
310            feature: CompatibilityFeature::ExtensionField,
311            path: path.clone(),
312            source: entry
313                .key_sources()
314                .last()
315                .copied()
316                .unwrap_or_else(|| effective_span(entry.value())),
317            sensitive: entry.value().is_sensitive(),
318        });
319    }
320    collect_value(entry.value(), path, occurrences);
321    let _ = path.pop();
322}
323
324fn collect_value(value: &MergedValue, path: &mut Vec<String>, occurrences: &mut Vec<CompatibilityOccurrence>) {
325    collect_operation(value, path, occurrences);
326    match value.kind() {
327        MergedValueKind::Mapping(entries) => {
328            for entry in entries {
329                collect_entry(entry, path, occurrences);
330            }
331        }
332        MergedValueKind::Sequence(values) => {
333            for (index, value) in values.iter().enumerate() {
334                path.push(index.to_string());
335                collect_value(value, path, occurrences);
336                let _ = path.pop();
337            }
338        }
339        MergedValueKind::Tagged { value, .. } => collect_value(value, path, occurrences),
340        MergedValueKind::Null(_) | MergedValueKind::Scalar(_) | MergedValueKind::Alias(_) => {}
341    }
342}
343
344fn collect_operation(value: &MergedValue, path: &[String], occurrences: &mut Vec<CompatibilityOccurrence>) {
345    let feature = match value.provenance().operation() {
346        MergeOperation::Reset => Some(CompatibilityFeature::ResetTag),
347        MergeOperation::Override => Some(CompatibilityFeature::OverrideTag),
348        MergeOperation::Authored
349        | MergeOperation::Added
350        | MergeOperation::Replaced
351        | MergeOperation::Merged
352        | MergeOperation::Appended => None,
353    };
354    if let Some(feature) = feature {
355        occurrences.push(CompatibilityOccurrence {
356            feature,
357            path: path.to_vec(),
358            source: effective_span(value),
359            sensitive: value.is_sensitive(),
360        });
361    }
362}
363
364fn has_tag_and_digest(value: &str) -> bool {
365    let Some((name_and_tag, _)) = value.split_once('@') else {
366        return false;
367    };
368    let last_slash = name_and_tag.rfind('/');
369    name_and_tag
370        .rfind(':')
371        .is_some_and(|separator| last_slash.is_none_or(|slash| separator > slash))
372}
373
374fn diagnostic(occurrence: &CompatibilityOccurrence, rule: &CompatibilityRule, severity: Severity) -> Diagnostic {
375    let (code, message) = match rule.classification() {
376        CompatibilityClassification::ImplementationSpecific => (
377            IMPLEMENTATION_SPECIFIC_FEATURE,
378            "construct has implementation-specific compatibility",
379        ),
380        CompatibilityClassification::Deprecated => (
381            DEPRECATED_FEATURE,
382            "construct is deprecated by the compatibility profile",
383        ),
384        CompatibilityClassification::Unsupported => (
385            UNSUPPORTED_FEATURE,
386            "construct is unsupported by the compatibility profile",
387        ),
388        CompatibilityClassification::Unknown => (
389            UNKNOWN_FEATURE_SUPPORT,
390            "construct has no established support for the compatibility profile",
391        ),
392        CompatibilityClassification::Supported | CompatibilityClassification::Extension => (
393            UNKNOWN_FEATURE_SUPPORT,
394            "construct has an unexpected compatibility diagnostic",
395        ),
396    };
397    Diagnostic::new(code, severity, message)
398        .with_label(DiagnosticLabel::primary(
399            occurrence.source,
400            "compatibility-sensitive construct",
401        ))
402        .with_note(rule.explanation())
403}