1use 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
11pub const UNSUPPORTED_FEATURE: DiagnosticCode = DiagnosticCode::new("compose.compatibility.unsupported");
13
14pub const IMPLEMENTATION_SPECIFIC_FEATURE: DiagnosticCode =
16 DiagnosticCode::new("compose.compatibility.implementation-specific");
17
18pub const UNKNOWN_FEATURE_SUPPORT: DiagnosticCode = DiagnosticCode::new("compose.compatibility.unknown");
20
21pub const DEPRECATED_FEATURE: DiagnosticCode = DiagnosticCode::new("compose.compatibility.deprecated");
23
24#[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 #[must_use]
36 pub const fn feature(&self) -> CompatibilityFeature {
37 self.feature
38 }
39
40 #[must_use]
42 pub fn path(&self) -> &[String] {
43 &self.path
44 }
45
46 #[must_use]
48 pub const fn source(&self) -> SourceSpan {
49 self.source
50 }
51
52 #[must_use]
54 pub const fn is_sensitive(&self) -> bool {
55 self.sensitive
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct CompatibilityFinding {
62 occurrence: CompatibilityOccurrence,
63 rule: CompatibilityRule,
64}
65
66impl CompatibilityFinding {
67 #[must_use]
69 pub const fn occurrence(&self) -> &CompatibilityOccurrence {
70 &self.occurrence
71 }
72
73 #[must_use]
75 pub const fn rule(&self) -> &CompatibilityRule {
76 &self.rule
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct CompatibilityReport {
83 profile: CompatibilityProfile,
84 findings: Vec<CompatibilityFinding>,
85 diagnostics: Vec<Diagnostic>,
86}
87
88impl CompatibilityReport {
89 #[must_use]
91 pub const fn profile(&self) -> CompatibilityProfile {
92 self.profile
93 }
94
95 #[must_use]
97 pub fn findings(&self) -> &[CompatibilityFinding] {
98 &self.findings
99 }
100
101 #[must_use]
103 pub fn diagnostics(&self) -> &[Diagnostic] {
104 &self.diagnostics
105 }
106
107 #[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#[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}