Skip to main content

boxferry_compose/
export.rs

1//! Neutral-application-to-Compose planning through `ComposeLens`'s validated generator.
2
3mod metadata;
4use std::convert::TryFrom;
5
6use boxferry_engine::{
7    ConversionKind, ConversionOutcome, ConversionPlan, Diagnostic, DiagnosticCode, DiagnosticField, DiagnosticValue,
8    ExportAdapter, InvalidDiagnosticCode, PlanError, PlatformVersion, RuleId, Severity, TargetProfile,
9    retained_native_evidence_losses,
10};
11use boxferry_model::{
12    Application, Command, Config, ConfigMaterial, Device, Entrypoint, EnvironmentFileFormat, EnvironmentFileSyntax,
13    EnvironmentValue, HostAddressKind, MountSource, Network, ProtectedString, Protocol, Provenance, ProvenanceKind,
14    PullPolicy, ResourceOwnership, RestartPolicy, Secret, SecretMaterial, SecurityOption, SelinuxRelabel, Service,
15    Sourced, Volume,
16};
17use compose_lens::{
18    model::{MemLimitUnit, ShmSizeUnit},
19    render::{
20        ComposeDocumentBuilder, GeneratedAnnotation, GeneratedCommand, GeneratedComposeDocument,
21        GeneratedConfigFileDefinition, GeneratedDevice, GeneratedDns, GeneratedDnsSearch, GeneratedEntrypoint,
22        GeneratedEnvironment, GeneratedEnvironmentFile, GeneratedEnvironmentFileFormat, GeneratedExtraHost,
23        GeneratedHostname, GeneratedLabel, GeneratedLogging, GeneratedLoggingOption, GeneratedLoggingOptionValue,
24        GeneratedLongDevice, GeneratedMemLimit, GeneratedMount, GeneratedNetworkAttachment, GeneratedNetworkDefinition,
25        GeneratedNetworkDriverOption, GeneratedNetworkDriverOptionValue, GeneratedPidsLimit, GeneratedPort,
26        GeneratedProtocol, GeneratedPullPolicy, GeneratedResource, GeneratedRestartPolicy,
27        GeneratedSecretFileDefinition, GeneratedSelinux, GeneratedService, GeneratedShmSize, GeneratedString,
28        GeneratedSysctl, GeneratedSysctls, GeneratedTmpfs, GeneratedUlimit, GeneratedUlimits,
29        GeneratedVolumeDefinition, GeneratedVolumeDriverOption, GeneratedVolumeDriverOptionValue, GenerationError,
30    },
31    source::SourceId,
32    validation::{
33        CompatibilityClassification, CompatibilityFeature, CompatibilityProfile, ContainerRuntime,
34        ImplementationVersion,
35    },
36};
37
38/// Target implementation name for Docker Compose output.
39pub const DOCKER_COMPOSE_TARGET: &str = "docker-compose";
40
41/// Target implementation name for the independent `containers/podman-compose` provider.
42pub const PODMAN_COMPOSE_TARGET: &str = "podman-compose";
43
44/// Provider-neutral target for the rolling Compose Specification.
45///
46/// The internal profile revision is a `BoxFerry` compatibility token, not a Compose Specification
47/// release or a claim that every historical Compose consumer accepts generated output.
48pub const COMPOSE_SPECIFICATION_TARGET: &str = "compose-specification";
49
50/// Internal revision used with [`COMPOSE_SPECIFICATION_TARGET`].
51pub const COMPOSE_SPECIFICATION_PROFILE_REVISION: PlatformVersion = PlatformVersion::new(1, 0, 0);
52
53/// Exact backend runtime used by the selected Compose provider.
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55#[non_exhaustive]
56pub enum ComposeRuntime {
57    /// Docker Engine at an exact version.
58    DockerEngine(PlatformVersion),
59    /// Podman at an exact version.
60    Podman(PlatformVersion),
61}
62
63/// Loss-aware exporter for deterministic, parse-back-validated Compose YAML.
64///
65/// Protected aliases in service network attachments block the entire candidate under every loss
66/// policy. Plain Compose text cannot carry their sensitivity into a fresh importer; no service
67/// alias omission or declassification is authorized by partial loss policy. Group-network aliases
68/// are not serialized; their existing structured unsupported/partial-loss contract is unchanged.
69#[derive(Clone, Debug)]
70pub struct ComposeExporter {
71    codes: Codes,
72    runtime: Option<ComposeRuntime>,
73}
74
75impl ComposeExporter {
76    /// Creates an exporter with no assumed backend runtime.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`InvalidDiagnosticCode`] only when an embedded stable code is malformed.
81    pub fn new() -> Result<Self, InvalidDiagnosticCode> {
82        Ok(Self {
83            codes: Codes {
84                invalid_target: RuleId::ComposeTargetInvalid.definition().diagnostic_code()?,
85                unsupported: RuleId::ComposeOutputUnsupported.definition().diagnostic_code()?,
86                generation: RuleId::ComposeGenerationFailed.definition().diagnostic_code()?,
87                compatibility: RuleId::ComposeCompatibilityConstraint.definition().diagnostic_code()?,
88            },
89            runtime: None,
90        })
91    }
92
93    /// Attaches the exact backend runtime selected by the caller.
94    #[must_use]
95    pub const fn with_runtime(mut self, runtime: ComposeRuntime) -> Self {
96        self.runtime = Some(runtime);
97        self
98    }
99
100    /// Returns the explicitly selected backend runtime, when present.
101    #[must_use]
102    pub const fn runtime(&self) -> Option<ComposeRuntime> {
103        self.runtime
104    }
105}
106
107impl ExportAdapter for ComposeExporter {
108    type Output = GeneratedComposeDocument;
109
110    fn plan(
111        &self,
112        application: &Application,
113        target: &TargetProfile,
114    ) -> Result<ConversionPlan<Self::Output>, PlanError> {
115        let mut mapping = Mapping::new(self, application, target);
116        mapping.reject_protected_aliases();
117        if mapping.validate_target() {
118            mapping.map_application();
119        }
120        let (candidate, mut outcomes, mut diagnostics) = mapping.finish();
121        let (evidence_outcomes, evidence_diagnostics) =
122            retained_native_evidence_losses(application, &self.codes.unsupported)?;
123        outcomes.extend(evidence_outcomes);
124        diagnostics.extend(evidence_diagnostics);
125        ConversionPlan::new(candidate, outcomes, diagnostics)
126    }
127}
128
129#[derive(Clone, Debug)]
130struct Codes {
131    invalid_target: DiagnosticCode,
132    unsupported: DiagnosticCode,
133    generation: DiagnosticCode,
134    compatibility: DiagnosticCode,
135}
136
137struct Mapping<'a> {
138    exporter: &'a ComposeExporter,
139    application: &'a Application,
140    target: &'a TargetProfile,
141    compatibility: Option<CompatibilityProfile>,
142    builder: Option<ComposeDocumentBuilder>,
143    outcomes: Vec<ConversionOutcome>,
144    diagnostics: Vec<Diagnostic>,
145    generation_failed: bool,
146    protected_aliases: bool,
147    protected_metadata: bool,
148}
149
150impl<'a> Mapping<'a> {
151    fn new(exporter: &'a ComposeExporter, application: &'a Application, target: &'a TargetProfile) -> Self {
152        Self {
153            exporter,
154            application,
155            target,
156            compatibility: None,
157            builder: Some(ComposeDocumentBuilder::new()),
158            outcomes: Vec::new(),
159            diagnostics: Vec::new(),
160            generation_failed: false,
161            protected_aliases: false,
162            protected_metadata: false,
163        }
164    }
165
166    fn reject_protected_aliases(&mut self) {
167        for service in self.application.services() {
168            for network in service.value().networks() {
169                for index in 0..network.value().aliases().len() {
170                    if network
171                        .value()
172                        .alias_sensitivities()
173                        .get(index)
174                        .copied()
175                        .unwrap_or(true)
176                    {
177                        self.protected_aliases = true;
178                        let origins = network
179                            .value()
180                            .alias_origins()
181                            .get(index)
182                            .map_or(network.origins(), Vec::as_slice);
183                        self.unsupported(
184                            &format!("services.{}.networks.{}.aliases[{index}]", service.value().name().as_str(), network.value().network().as_str()),
185                            "protected network aliases cannot retain sensitivity in fresh Compose text; keep protected alias configuration outside this generated document",
186                            origins,
187                        );
188                    }
189                }
190            }
191        }
192    }
193
194    fn validate_target(&mut self) -> bool {
195        let versions = self.target.versions();
196        if self.target.implementation() == COMPOSE_SPECIFICATION_TARGET {
197            if versions.maximum() != Some(versions.minimum())
198                || versions.minimum() != COMPOSE_SPECIFICATION_PROFILE_REVISION
199            {
200                self.invalid(
201                    self.exporter.codes.invalid_target.clone(),
202                    "target.versions",
203                    "Compose Specification output requires the BoxFerry compatibility-profile revision",
204                    "use the documented exact BoxFerry Compose Specification profile revision",
205                    &[],
206                );
207                return false;
208            }
209            if self.exporter.runtime.is_some() {
210                self.invalid(
211                    self.exporter.codes.invalid_target.clone(),
212                    "target.runtime",
213                    "Compose Specification output does not select a backend runtime",
214                    "select a provider-aware target before attaching a runtime",
215                    &[],
216                );
217                return false;
218            }
219            self.compatibility = Some(CompatibilityProfile::specification());
220            return true;
221        }
222        let Some(maximum) = versions.maximum() else {
223            self.invalid(
224                self.exporter.codes.invalid_target.clone(),
225                "target.versions",
226                "Compose output requires one exact provider version",
227                "set the minimum and maximum to the same exact Compose provider version",
228                &[],
229            );
230            return false;
231        };
232        if maximum != versions.minimum() {
233            self.invalid(
234                self.exporter.codes.invalid_target.clone(),
235                "target.versions",
236                "Compose output requires one exact provider version",
237                "ComposeLens compatibility evidence is evaluated for exact provider versions, not a range",
238                &[],
239            );
240            return false;
241        }
242        let Some(version) = implementation_version(versions.minimum()) else {
243            self.invalid(
244                self.exporter.codes.invalid_target.clone(),
245                "target.versions",
246                "Compose provider version is outside the supported numeric range",
247                "each version component must fit into an unsigned 32-bit integer",
248                &[],
249            );
250            return false;
251        };
252        let mut profile = match self.target.implementation() {
253            DOCKER_COMPOSE_TARGET => CompatibilityProfile::docker_compose(version),
254            PODMAN_COMPOSE_TARGET => CompatibilityProfile::podman_compose(version),
255            _ => {
256                self.invalid(
257                    self.exporter.codes.invalid_target.clone(),
258                    "target.implementation",
259                    "Compose output requires a recognized provider",
260                    "select `docker-compose` or `podman-compose`; `podman compose` is a wrapper, not a provider",
261                    &[],
262                );
263                return false;
264            }
265        };
266        if let Some(runtime) = self.exporter.runtime {
267            let runtime = match runtime {
268                ComposeRuntime::DockerEngine(version) => {
269                    implementation_version(version).map(ContainerRuntime::DockerEngine)
270                }
271                ComposeRuntime::Podman(version) => implementation_version(version).map(ContainerRuntime::Podman),
272            };
273            let Some(runtime) = runtime else {
274                self.invalid(
275                    self.exporter.codes.invalid_target.clone(),
276                    "target.runtime.version",
277                    "Compose runtime version is outside the supported numeric range",
278                    "each version component must fit into an unsigned 32-bit integer",
279                    &[],
280                );
281                return false;
282            };
283            profile = profile.with_runtime(runtime);
284        }
285        self.compatibility = Some(profile);
286        true
287    }
288
289    fn map_application(&mut self) {
290        let mut builder = self.builder.take().unwrap_or_default();
291        if let Err(error) = builder.set_name(self.application.name().as_str()) {
292            self.generation_error("application.name", &error, &[]);
293        } else {
294            self.exact("application.name", &[]);
295        }
296
297        for network in self.application.networks() {
298            self.map_network_definition(network, &mut builder);
299        }
300        for volume in self.application.volumes() {
301            self.map_volume_definition(volume, &mut builder);
302        }
303
304        for acquisition in self.application.image_acquisitions() {
305            self.unsupported(
306                &format!("image_acquisitions.{}", acquisition.value().name().as_str()),
307                "the current Compose generation boundary does not expose image-acquisition declarations",
308                acquisition.origins(),
309            );
310        }
311        for build in self.application.image_builds() {
312            self.unsupported(
313                &format!("image_builds.{}", build.value().name().as_str()),
314                "the current Compose generation boundary does not expose build declarations",
315                build.origins(),
316            );
317        }
318
319        for config in self.application.configs() {
320            self.map_config_definition(config, &mut builder);
321        }
322        for secret in self.application.secrets() {
323            self.map_secret_definition(secret, &mut builder);
324        }
325        for group in self.application.service_groups() {
326            self.report_service_group_loss(group);
327        }
328
329        for service in self.application.services() {
330            if let Some(generated) = self.map_service(service) {
331                let subject = format!("services.{}", service.value().name().as_str());
332                if let Err(error) = builder.add_service(generated) {
333                    self.generation_error(&subject, &error, service.origins());
334                }
335            }
336        }
337        self.map_root_metadata(&mut builder);
338        self.builder = Some(builder);
339    }
340
341    fn map_config_definition(&mut self, sourced: &Sourced<Config>, builder: &mut ComposeDocumentBuilder) {
342        let config = sourced.value();
343        let subject = format!("configs.{}", config.name().as_str());
344        if config.ownership() != ResourceOwnership::Application {
345            self.unsupported(
346                &subject,
347                "only application-owned file-backed configs can be generated by the current ComposeLens boundary",
348                sourced.origins(),
349            );
350            if let Some(runtime_name) = config.runtime_name() {
351                self.unsupported(
352                    &format!("{subject}.runtime_name"),
353                    "the current ComposeLens file-config generator cannot preserve a config runtime name",
354                    runtime_name.origins(),
355                );
356            }
357            if let Some(material) = config.material() {
358                self.unsupported(
359                    &format!("{subject}.material"),
360                    "external or uncertain config ownership does not authorize emitting application-managed material",
361                    material.origins(),
362                );
363            }
364            return;
365        }
366        if let Some(runtime_name) = config.runtime_name() {
367            self.unsupported(
368                &format!("{subject}.runtime_name"),
369                "the current ComposeLens file-config generator cannot preserve a config runtime name",
370                runtime_name.origins(),
371            );
372        }
373        let Some(material) = config.material() else {
374            self.unsupported(
375                &format!("{subject}.material"),
376                "application-owned config has no file-backed material to generate",
377                sourced.origins(),
378            );
379            return;
380        };
381        let ConfigMaterial::File(path) = material.value() else {
382            self.unsupported(
383                &format!("{subject}.material"),
384                "only file-backed application configs can be generated; inline and environment material remain outside this boundary",
385                material.origins(),
386            );
387            return;
388        };
389        let origins = combined_origins(sourced.origins(), material.origins());
390        match generated_string(path).and_then(|path| GeneratedConfigFileDefinition::new(config.name().as_str(), path)) {
391            Ok(generated) => match builder.add_config_file(generated) {
392                Ok(()) => self.exact(&subject, &origins),
393                Err(error) => self.generation_error(&subject, &error, &origins),
394            },
395            Err(error) => self.generation_error(&format!("{subject}.material"), &error, &origins),
396        }
397    }
398
399    fn map_secret_definition(&mut self, sourced: &Sourced<Secret>, builder: &mut ComposeDocumentBuilder) {
400        let secret = sourced.value();
401        let subject = format!("secrets.{}", secret.name().as_str());
402        if secret.ownership() != ResourceOwnership::Application {
403            self.unsupported(
404                &subject,
405                "only application-owned file-backed secrets can be generated by the current ComposeLens boundary",
406                sourced.origins(),
407            );
408            if let Some(runtime_name) = secret.runtime_name() {
409                self.unsupported(
410                    &format!("{subject}.runtime_name"),
411                    "the current ComposeLens file-secret generator cannot preserve a secret runtime name",
412                    runtime_name.origins(),
413                );
414            }
415            if let Some(material) = secret.material() {
416                self.unsupported(
417                    &format!("{subject}.material"),
418                    "external or uncertain secret ownership does not authorize emitting application-managed material",
419                    material.origins(),
420                );
421            }
422            return;
423        }
424        if let Some(runtime_name) = secret.runtime_name() {
425            self.unsupported(
426                &format!("{subject}.runtime_name"),
427                "the current ComposeLens file-secret generator cannot preserve a secret runtime name",
428                runtime_name.origins(),
429            );
430        }
431        let Some(material) = secret.material() else {
432            self.unsupported(
433                &format!("{subject}.material"),
434                "application-owned secret has no file-backed material to generate",
435                sourced.origins(),
436            );
437            return;
438        };
439        let SecretMaterial::File(path) = material.value() else {
440            self.unsupported(
441                &format!("{subject}.material"),
442                "only file-backed application secrets can be generated; environment material remains outside this boundary",
443                material.origins(),
444            );
445            return;
446        };
447        let origins = combined_origins(sourced.origins(), material.origins());
448        match generated_string(path).and_then(|path| GeneratedSecretFileDefinition::new(secret.name().as_str(), path)) {
449            Ok(generated) => match builder.add_secret_file(generated) {
450                Ok(()) => self.exact(&subject, &origins),
451                Err(error) => self.generation_error(&subject, &error, &origins),
452            },
453            Err(error) => self.generation_error(&format!("{subject}.material"), &error, &origins),
454        }
455    }
456
457    fn map_resource(
458        &mut self,
459        name: &str,
460        ownership: ResourceOwnership,
461        origins: &[Provenance],
462        subject: &str,
463    ) -> Option<GeneratedResource> {
464        let resource = match ownership {
465            ResourceOwnership::Application => GeneratedResource::application(name),
466            ResourceOwnership::External => GeneratedResource::external(name),
467            ResourceOwnership::Implicit => {
468                self.unsupported(
469                    subject,
470                    "implicit source lifecycle is emitted as an externally managed Compose resource",
471                    origins,
472                );
473                GeneratedResource::external(name)
474            }
475            ResourceOwnership::Uncertain => {
476                self.unsupported(
477                    subject,
478                    "runtime inspection did not determine resource ownership; output conservatively reuses the existing resource",
479                    origins,
480                );
481                GeneratedResource::external(name)
482            }
483            _ => {
484                self.unsupported(
485                    subject,
486                    "resource ownership variant is newer than this Compose adapter",
487                    origins,
488                );
489                GeneratedResource::external(name)
490            }
491        };
492        let resource = match resource {
493            Ok(resource) => resource,
494            Err(error) => {
495                self.generation_error(subject, &error, origins);
496                return None;
497            }
498        };
499        if matches!(ownership, ResourceOwnership::Application | ResourceOwnership::External) {
500            self.exact(subject, origins);
501        }
502        Some(resource)
503    }
504
505    #[allow(clippy::too_many_lines)]
506    fn map_network_definition(&mut self, sourced: &Sourced<Network>, builder: &mut ComposeDocumentBuilder) {
507        let network = sourced.value();
508        let subject = format!("networks.{}", network.name().as_str());
509        if network.ownership() != ResourceOwnership::Application {
510            let Some(mut generated) = self.map_resource(
511                network.name().as_str(),
512                network.ownership(),
513                sourced.origins(),
514                &subject,
515            ) else {
516                self.generation_failed = true;
517                return;
518            };
519            if let Some(runtime_name) = network.runtime_name() {
520                if runtime_name.value().is_sensitive() {
521                    self.unsupported(
522                        &format!("{subject}.name"),
523                        "sensitive runtime network names cannot be passed to ComposeLens' plain-string generator",
524                        runtime_name.origins(),
525                    );
526                } else if let Err(error) = generated.set_custom_name(runtime_name.value().expose()) {
527                    self.generation_error(&format!("{subject}.name"), &error, runtime_name.origins());
528                } else {
529                    self.exact(format!("{subject}.name"), runtime_name.origins());
530                }
531            } else if sourced
532                .origins()
533                .iter()
534                .any(|origin| origin.kind() == ProvenanceKind::RuntimeObservation)
535            {
536                // ComposeLens names are single-assignment. Only an absent explicit name
537                // permits the observed logical-name fallback; a protected name does not.
538                if let Err(error) = generated.set_custom_name(network.name().as_str()) {
539                    self.generation_error(&subject, &error, sourced.origins());
540                    return;
541                }
542            }
543            self.report_external_network_configuration(network, &subject);
544            if let Err(error) = builder.add_network(generated) {
545                self.generation_error(&subject, &error, sourced.origins());
546            }
547            return;
548        }
549
550        let mut generated = match GeneratedNetworkDefinition::application(network.name().as_str()) {
551            Ok(value) => value,
552            Err(error) => {
553                self.generation_error(&subject, &error, sourced.origins());
554                return;
555            }
556        };
557        let mut valid = true;
558        if let Some(runtime_name) = network.runtime_name() {
559            if runtime_name.value().is_sensitive() {
560                self.unsupported(
561                    &format!("{subject}.name"),
562                    "sensitive runtime network names cannot be passed to ComposeLens' plain-string generator",
563                    runtime_name.origins(),
564                );
565            } else {
566                match generated.set_custom_name(runtime_name.value().expose()) {
567                    Ok(()) => self.exact(format!("{subject}.name"), runtime_name.origins()),
568                    Err(error) => {
569                        self.generation_error(&format!("{subject}.name"), &error, runtime_name.origins());
570                        valid = false;
571                    }
572                }
573            }
574        }
575        if let Some(driver) = network.driver() {
576            match generated_string(driver.value()).and_then(|value| generated.set_driver(value)) {
577                Ok(()) => self.exact(format!("{subject}.driver"), driver.origins()),
578                Err(error) => {
579                    self.generation_error(&format!("{subject}.driver"), &error, driver.origins());
580                    valid = false;
581                }
582            }
583        }
584        if let Some(options) = network.driver_options() {
585            let origins = collection_or_item_origins(options, network.driver_options_origins());
586            let values: Result<Vec<_>, _> = options
587                .iter()
588                .map(|option| {
589                    GeneratedNetworkDriverOption::new(
590                        option.value().name().value().as_str(),
591                        GeneratedNetworkDriverOptionValue::String(generated_string(option.value().value().value())?),
592                    )
593                })
594                .collect();
595            match values.and_then(|values| generated.set_driver_opts(values)) {
596                Ok(()) => self.exact(format!("{subject}.driver_opts"), &origins),
597                Err(error) => {
598                    self.generation_error(&format!("{subject}.driver_opts"), &error, &origins);
599                    valid = false;
600                }
601            }
602        }
603        if let Some(ipv6) = network.ipv6() {
604            match generated.set_enable_ipv6(*ipv6.value()) {
605                Ok(()) => self.exact(format!("{subject}.enable_ipv6"), ipv6.origins()),
606                Err(error) => {
607                    self.generation_error(&format!("{subject}.enable_ipv6"), &error, ipv6.origins());
608                    valid = false;
609                }
610            }
611        }
612        if let Some(internal) = network.internal() {
613            match generated.set_internal(*internal.value()) {
614                Ok(()) => self.exact(format!("{subject}.internal"), internal.origins()),
615                Err(error) => {
616                    self.generation_error(&format!("{subject}.internal"), &error, internal.origins());
617                    valid = false;
618                }
619            }
620        }
621        if let Some(labels) = network.labels() {
622            let origins = collection_or_item_origins(labels, network.labels_origins());
623            let values: Result<Vec<_>, _> = labels
624                .iter()
625                .map(|label| {
626                    GeneratedLabel::new(label.value().name().as_str(), generated_string(label.value().value())?)
627                })
628                .collect();
629            match values.and_then(|values| generated.set_labels(values)) {
630                Ok(()) => self.exact(format!("{subject}.labels"), &origins),
631                Err(error) => {
632                    self.generation_error(&format!("{subject}.labels"), &error, &origins);
633                    valid = false;
634                }
635            }
636        }
637        if let Some(driver) = network.ipam_driver() {
638            self.unsupported(
639                &format!("{subject}.ipam.driver"),
640                "the current Compose generation boundary does not expose generated IPAM definitions",
641                driver.origins(),
642            );
643        }
644        if let Some(configs) = network.ipam_configs() {
645            self.unsupported(
646                &format!("{subject}.ipam.config"),
647                "the current Compose generation boundary does not expose generated IPAM definitions",
648                &collection_or_item_origins(configs, network.ipam_configs_origins()),
649            );
650        }
651        if valid && let Err(error) = builder.add_network_definition(generated) {
652            self.generation_error(&subject, &error, sourced.origins());
653        }
654    }
655
656    fn report_external_network_configuration(&mut self, network: &Network, subject: &str) {
657        let reason = "Compose external networks may only declare their platform name";
658        if let Some(driver) = network.driver() {
659            self.unsupported(&format!("{subject}.driver"), reason, driver.origins());
660        }
661        if let Some(values) = network.driver_options() {
662            self.unsupported(
663                &format!("{subject}.driver_opts"),
664                reason,
665                &collection_or_item_origins(values, network.driver_options_origins()),
666            );
667        }
668        if let Some(value) = network.internal() {
669            self.unsupported(&format!("{subject}.internal"), reason, value.origins());
670        }
671        if let Some(value) = network.ipv6() {
672            self.unsupported(&format!("{subject}.enable_ipv6"), reason, value.origins());
673        }
674        if let Some(driver) = network.ipam_driver() {
675            self.unsupported(&format!("{subject}.ipam.driver"), reason, driver.origins());
676        }
677        if let Some(values) = network.ipam_configs() {
678            self.unsupported(
679                &format!("{subject}.ipam.config"),
680                reason,
681                &collection_or_item_origins(values, network.ipam_configs_origins()),
682            );
683        }
684        if let Some(values) = network.labels() {
685            self.unsupported(
686                &format!("{subject}.labels"),
687                reason,
688                &collection_or_item_origins(values, network.labels_origins()),
689            );
690        }
691    }
692
693    fn map_volume_definition(&mut self, sourced: &Sourced<Volume>, builder: &mut ComposeDocumentBuilder) {
694        let volume = sourced.value();
695        let subject = format!("volumes.{}", volume.name().as_str());
696        self.report_compose_native_only_volume_fields(volume, &subject);
697        if volume.ownership() != ResourceOwnership::Application {
698            let Some(mut generated) = self.map_external_volume_resource(volume, sourced.origins(), &subject) else {
699                self.generation_failed = true;
700                return;
701            };
702            if let Some(runtime_name) = volume.runtime_name() {
703                if runtime_name.value().is_sensitive() {
704                    self.unsupported(
705                        &format!("{subject}.name"),
706                        "sensitive runtime volume names cannot be passed to ComposeLens' plain-string generator",
707                        runtime_name.origins(),
708                    );
709                } else {
710                    match generated.set_custom_name(runtime_name.value().expose()) {
711                        Ok(()) => self.exact(format!("{subject}.name"), runtime_name.origins()),
712                        Err(error) => {
713                            self.generation_error(&format!("{subject}.name"), &error, runtime_name.origins());
714                        }
715                    }
716                }
717            }
718            self.report_external_volume_configuration(volume, &subject);
719            if let Err(error) = builder.add_volume(generated) {
720                self.generation_error(&subject, &error, sourced.origins());
721            }
722            return;
723        }
724
725        let mut generated = match GeneratedVolumeDefinition::application(volume.name().as_str()) {
726            Ok(value) => value,
727            Err(error) => {
728                self.generation_error(&subject, &error, sourced.origins());
729                return;
730            }
731        };
732        let mut valid = true;
733        if let Some(runtime_name) = volume.runtime_name() {
734            if runtime_name.value().is_sensitive() {
735                self.unsupported(
736                    &format!("{subject}.name"),
737                    "sensitive runtime volume names cannot be passed to ComposeLens' plain-string generator",
738                    runtime_name.origins(),
739                );
740            } else {
741                match generated.set_custom_name(runtime_name.value().expose()) {
742                    Ok(()) => self.exact(format!("{subject}.name"), runtime_name.origins()),
743                    Err(error) => {
744                        self.generation_error(&format!("{subject}.name"), &error, runtime_name.origins());
745                        valid = false;
746                    }
747                }
748            }
749        }
750        if let Some(driver) = volume.driver() {
751            match generated_string(driver.value()).and_then(|value| generated.set_driver(value)) {
752                Ok(()) => self.exact(format!("{subject}.driver"), driver.origins()),
753                Err(error) => {
754                    self.generation_error(&format!("{subject}.driver"), &error, driver.origins());
755                    valid = false;
756                }
757            }
758        }
759        self.map_local_volume_driver_options(volume, &mut generated, &subject, &mut valid);
760        if let Some(labels) = volume.labels() {
761            let origins = collection_or_item_origins(labels, volume.labels_origins());
762            let values: Result<Vec<_>, _> = labels
763                .iter()
764                .map(|label| {
765                    GeneratedLabel::new(label.value().name().as_str(), generated_string(label.value().value())?)
766                })
767                .collect();
768            match values.and_then(|values| generated.set_labels(values)) {
769                Ok(()) => self.exact(format!("{subject}.labels"), &origins),
770                Err(error) => {
771                    self.generation_error(&format!("{subject}.labels"), &error, &origins);
772                    valid = false;
773                }
774            }
775        }
776        if valid && let Err(error) = builder.add_volume_definition(generated) {
777            self.generation_error(&subject, &error, sourced.origins());
778        }
779    }
780
781    fn map_external_volume_resource(
782        &mut self,
783        volume: &Volume,
784        origins: &[Provenance],
785        subject: &str,
786    ) -> Option<GeneratedResource> {
787        let generated = match volume.ownership() {
788            ResourceOwnership::External => GeneratedResource::external(volume.name().as_str()),
789            ResourceOwnership::Implicit => {
790                self.unsupported(
791                    subject,
792                    "implicit source lifecycle is emitted as an externally managed Compose resource",
793                    origins,
794                );
795                GeneratedResource::external(volume.name().as_str())
796            }
797            ResourceOwnership::Uncertain => {
798                self.unsupported(
799                    subject,
800                    "runtime inspection did not determine volume ownership; output conservatively reuses the existing volume",
801                    origins,
802                );
803                GeneratedResource::external(volume.name().as_str())
804            }
805            ResourceOwnership::Application => return None,
806            _ => {
807                self.unsupported(
808                    subject,
809                    "volume ownership variant is newer than this Compose adapter",
810                    origins,
811                );
812                GeneratedResource::external(volume.name().as_str())
813            }
814        };
815        let mut generated = match generated {
816            Ok(generated) => generated,
817            Err(error) => {
818                self.generation_error(subject, &error, origins);
819                return None;
820            }
821        };
822        if origins
823            .iter()
824            .any(|origin| origin.kind() == ProvenanceKind::RuntimeObservation)
825            && volume.runtime_name().is_none()
826            && let Err(error) = generated.set_custom_name(volume.name().as_str())
827        {
828            self.generation_error(subject, &error, origins);
829            return None;
830        }
831        if matches!(volume.ownership(), ResourceOwnership::External) {
832            self.exact(subject, origins);
833        }
834        Some(generated)
835    }
836
837    fn map_local_volume_driver_options(
838        &mut self,
839        volume: &Volume,
840        generated: &mut GeneratedVolumeDefinition,
841        subject: &str,
842        valid: &mut bool,
843    ) {
844        let fields = [
845            ("type", volume.volume_type()),
846            ("device", volume.device()),
847            ("o", volume.options()),
848        ];
849        if fields.iter().all(|(_, value)| value.is_none()) {
850            return;
851        }
852        let local_driver = volume.driver().is_some_and(|driver| driver.value().expose() == "local");
853        if !local_driver {
854            for (field, value) in fields {
855                if let Some(value) = value {
856                    self.unsupported(
857                        &format!("{subject}.driver_opts.{field}"),
858                        "Compose local-driver options require an explicit `driver: local`",
859                        value.origins(),
860                    );
861                }
862            }
863            return;
864        }
865        let origins = fields
866            .iter()
867            .filter_map(|(_, value)| value.map(Sourced::origins))
868            .flatten()
869            .cloned()
870            .collect::<Vec<_>>();
871        let values: Result<Vec<_>, _> = fields
872            .into_iter()
873            .filter_map(|(name, value)| {
874                value.map(|value| {
875                    GeneratedVolumeDriverOption::new(
876                        name,
877                        GeneratedVolumeDriverOptionValue::String(generated_string(value.value())?),
878                    )
879                })
880            })
881            .collect();
882        match values.and_then(|values| generated.set_driver_opts(values)) {
883            Ok(()) => self.exact(format!("{subject}.driver_opts"), &origins),
884            Err(error) => {
885                self.generation_error(&format!("{subject}.driver_opts"), &error, &origins);
886                *valid = false;
887            }
888        }
889    }
890
891    fn report_external_volume_configuration(&mut self, volume: &Volume, subject: &str) {
892        let reason = "Compose external volumes may only declare platform name";
893        if let Some(driver) = volume.driver() {
894            self.unsupported(&format!("{subject}.driver"), reason, driver.origins());
895        }
896        for (field, value) in [
897            ("type", volume.volume_type()),
898            ("device", volume.device()),
899            ("o", volume.options()),
900        ] {
901            if let Some(value) = value {
902                self.unsupported(&format!("{subject}.driver_opts.{field}"), reason, value.origins());
903            }
904        }
905        if let Some(values) = volume.labels() {
906            self.unsupported(
907                &format!("{subject}.labels"),
908                reason,
909                &collection_or_item_origins(values, volume.labels_origins()),
910            );
911        }
912    }
913
914    fn report_compose_native_only_volume_fields(&mut self, volume: &Volume, subject: &str) {
915        for (name, value) in [
916            ("service_name", volume.service_name()),
917            ("user", volume.user()),
918            ("group", volume.group()),
919            ("uid", volume.uid()),
920            ("gid", volume.gid()),
921        ] {
922            if let Some(value) = value {
923                self.unsupported(
924                    &format!("{subject}.{name}"),
925                    "Quadlet-only volume setting has no reviewed Compose volume-definition mapping",
926                    value.origins(),
927                );
928            }
929        }
930        if let Some(value) = volume.copy() {
931            self.unsupported(
932                &format!("{subject}.copy"),
933                "Quadlet-only volume setting has no reviewed Compose volume-definition mapping",
934                value.origins(),
935            );
936        }
937        if let Some(image) = volume.image_source() {
938            self.unsupported(
939                &format!("{subject}.image"),
940                "Quadlet volume image source has no reviewed Compose volume-definition mapping",
941                image.origins(),
942            );
943        }
944    }
945
946    fn map_service(&mut self, sourced: &Sourced<Service>) -> Option<GeneratedService> {
947        let service = sourced.value();
948        let service_name = service.name().as_str();
949        let service_subject = format!("services.{service_name}");
950        let mut generated = match GeneratedService::new(service_name) {
951            Ok(generated) => generated,
952            Err(error) => {
953                self.generation_error(&service_subject, &error, sourced.origins());
954                return None;
955            }
956        };
957        if let Some(runtime_name) = service.runtime_name() {
958            match generated_string(runtime_name.value()).and_then(|name| generated.set_container_name(name)) {
959                Ok(()) => self.exact(format!("{service_subject}.container_name"), runtime_name.origins()),
960                Err(error) => {
961                    self.generation_error(
962                        &format!("{service_subject}.container_name"),
963                        &error,
964                        runtime_name.origins(),
965                    );
966                }
967            }
968        }
969        self.report_compose_native_only_service_fields(service, &service_subject);
970        if !self.map_image(service, sourced.origins(), &mut generated, &service_subject) {
971            return None;
972        }
973        self.map_restart_policy(service, &mut generated, &service_subject);
974        self.map_command(service, &mut generated, &service_subject);
975        self.map_released_container_settings(service, &mut generated, &service_subject);
976        self.map_identity(service, &mut generated, &service_subject);
977        self.map_execution_context(service, &mut generated, &service_subject);
978        self.map_dns(service, &mut generated, &service_subject);
979        self.map_security_options(service, &mut generated, &service_subject);
980        self.map_environment_files(service, &mut generated, &service_subject);
981        self.map_environment(service, &mut generated, &service_subject);
982        self.map_labels(service, &mut generated, &service_subject);
983        self.map_host_mappings(service, &mut generated, &service_subject);
984        self.map_ports(service, &mut generated, &service_subject);
985        self.map_mounts(service, &mut generated, &service_subject);
986        self.map_network_attachments(service, &mut generated, &service_subject);
987        self.map_service_metadata(service.name(), &mut generated);
988        self.report_unimplemented_service_fields(service, &service_subject);
989        Some(generated)
990    }
991
992    fn map_restart_policy(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
993        let Some(restart) = service.restart_policy() else {
994            return;
995        };
996        let subject = format!("{service_subject}.restart_policy");
997        let policy = match restart.value() {
998            RestartPolicy::Never => GeneratedRestartPolicy::No,
999            RestartPolicy::Always => GeneratedRestartPolicy::Always,
1000            RestartPolicy::OnFailure { maximum_retries } => GeneratedRestartPolicy::OnFailure {
1001                maximum_retries: maximum_retries.map(std::num::NonZeroU64::get),
1002            },
1003            RestartPolicy::UnlessStopped => GeneratedRestartPolicy::UnlessStopped,
1004            _ => {
1005                self.unsupported(
1006                    &subject,
1007                    "restart-policy variant is newer than this Compose adapter",
1008                    restart.origins(),
1009                );
1010                return;
1011            }
1012        };
1013        match generated.set_restart(policy) {
1014            Ok(()) => self.exact(subject, restart.origins()),
1015            Err(error) => self.generation_error(&subject, &error, restart.origins()),
1016        }
1017    }
1018
1019    fn map_dns(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1020        if let Some(values) = service.dns_servers() {
1021            let subject = format!("{service_subject}.dns");
1022            let origins = collection_or_item_origins(values, service.dns_servers_origins());
1023            let generated_values: Result<Vec<_>, _> =
1024                values.iter().map(|value| generated_string(value.value())).collect();
1025            match generated_values.and_then(|values| generated.set_dns(GeneratedDns::List(values))) {
1026                Ok(()) if values.is_empty() => self.exact(subject, &origins),
1027                Ok(()) if values.iter().any(|value| value.value().expose() == "none") => {
1028                    self.unsupported(&subject, "DNS `none` has target-specific resolver semantics", &origins);
1029                }
1030                Ok(()) => self.exact(subject, &origins),
1031                Err(error) => self.generation_error(&subject, &error, &origins),
1032            }
1033        }
1034        if let Some(values) = service.dns_options() {
1035            let subject = format!("{service_subject}.dns_opt");
1036            let origins = collection_or_item_origins(values, service.dns_options_origins());
1037            let generated_values: Result<Vec<_>, _> =
1038                values.iter().map(|value| generated_string(value.value())).collect();
1039            match generated_values.and_then(|values| generated.set_dns_options(values)) {
1040                Ok(()) if values.is_empty() => self.exact(subject, &origins),
1041                Ok(()) => self.exact(subject, &origins),
1042                Err(error) => self.generation_error(&subject, &error, &origins),
1043            }
1044        }
1045        if let Some(values) = service.dns_search_domains() {
1046            let subject = format!("{service_subject}.dns_search");
1047            let origins = collection_or_item_origins(values, service.dns_search_domains_origins());
1048            let generated_values: Result<Vec<_>, _> =
1049                values.iter().map(|value| generated_string(value.value())).collect();
1050            match generated_values.and_then(|values| generated.set_dns_search(GeneratedDnsSearch::List(values))) {
1051                Ok(()) if values.is_empty() => self.exact(subject, &origins),
1052                Ok(()) if values.iter().any(|value| value.value().expose() == ".") => self.unsupported(
1053                    &subject,
1054                    "DNS search `.` has target-specific resolver semantics",
1055                    &origins,
1056                ),
1057                Ok(()) => self.exact(subject, &origins),
1058                Err(error) => self.generation_error(&subject, &error, &origins),
1059            }
1060        }
1061    }
1062
1063    fn map_security_options(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1064        let Some(options) = service.security_options() else {
1065            return;
1066        };
1067        let subject = format!("{service_subject}.security_opt");
1068        let origins = collection_or_item_origins(options, service.security_options_origins());
1069        let mut generated_options = Vec::with_capacity(options.len());
1070        let mut all_exact = true;
1071        for (index, option) in options.iter().enumerate() {
1072            match security_option_string(option.value()) {
1073                Ok(SecurityOptionGeneration::Generated(value)) => generated_options.push(value),
1074                Ok(SecurityOptionGeneration::Unsupported(reason)) => {
1075                    all_exact = false;
1076                    self.unsupported(&format!("{subject}[{index}]"), reason, option.origins());
1077                }
1078                Err(error) => {
1079                    self.generation_error(&subject, &error, &origins);
1080                    return;
1081                }
1082            }
1083        }
1084        match generated.set_security_options(generated_options) {
1085            Ok(()) if all_exact => self.exact(subject, &origins),
1086            Ok(()) => {}
1087            Err(error) => self.generation_error(&subject, &error, &origins),
1088        }
1089    }
1090
1091    fn map_image(
1092        &mut self,
1093        service: &Service,
1094        service_origins: &[Provenance],
1095        generated: &mut GeneratedService,
1096        service_subject: &str,
1097    ) -> bool {
1098        let Some(image) = service.image() else {
1099            self.invalid(
1100                self.exporter.codes.generation.clone(),
1101                &format!("{service_subject}.image"),
1102                "generated Compose service has no runnable source",
1103                "the neutral service has neither an image nor a representable build definition",
1104                service_origins,
1105            );
1106            self.generation_failed = true;
1107            return false;
1108        };
1109        let result = GeneratedString::plain(image.value().as_str()).and_then(|image| generated.set_image(image));
1110        if let Err(error) = result {
1111            self.generation_error(&format!("{service_subject}.image"), &error, image.origins());
1112            return false;
1113        }
1114        if image.value().tag().is_some() && image.value().digest().is_some() {
1115            self.compatibility(
1116                &format!("{service_subject}.image"),
1117                CompatibilityFeature::ImageTagAndDigest,
1118                image.origins(),
1119            );
1120        } else {
1121            self.exact(format!("{service_subject}.image"), image.origins());
1122        }
1123        true
1124    }
1125
1126    fn map_command(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1127        let Some(command) = service.command() else {
1128            return;
1129        };
1130        let command_value = match command.value() {
1131            Command::Exec(arguments) => Some(
1132                arguments
1133                    .iter()
1134                    .map(generated_string)
1135                    .collect::<Result<Vec<_>, _>>()
1136                    .map(GeneratedCommand::Exec),
1137            ),
1138            Command::Shell(value) => Some(generated_string(value).map(GeneratedCommand::Shell)),
1139            Command::Empty => Some(Ok(GeneratedCommand::Empty)),
1140            _ => {
1141                self.unsupported(
1142                    &format!("{service_subject}.command"),
1143                    "command variant is newer than this Compose adapter",
1144                    command.origins(),
1145                );
1146                None
1147            }
1148        };
1149        if let Some(command_value) = command_value {
1150            match command_value.and_then(|command_value| generated.set_command(command_value)) {
1151                Ok(()) => self.exact(format!("{service_subject}.command"), command.origins()),
1152                Err(error) => self.generation_error(&format!("{service_subject}.command"), &error, command.origins()),
1153            }
1154        }
1155    }
1156
1157    fn map_execution_context(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1158        if let Some(working_directory) = service.working_directory() {
1159            let field = format!("{subject}.working_directory");
1160            match generated_string(working_directory.value()).and_then(|value| generated.set_working_dir(value)) {
1161                Ok(()) => self.exact(field, working_directory.origins()),
1162                Err(error) => self.generation_error(&field, &error, working_directory.origins()),
1163            }
1164        }
1165        if let Some(read_only) = service.read_only_root_filesystem() {
1166            let field = format!("{subject}.read_only_root_filesystem");
1167            match generated.set_read_only(*read_only.value()) {
1168                Ok(()) => self.exact(field, read_only.origins()),
1169                Err(error) => self.generation_error(&field, &error, read_only.origins()),
1170            }
1171        }
1172    }
1173
1174    fn map_environment(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1175        let mut environment = service.environment().iter().collect::<Vec<_>>();
1176        environment.sort_by(|left, right| left.value().name().as_str().cmp(right.value().name().as_str()));
1177        if let Some(plan) = service.execution_environment() {
1178            self.unsupported(
1179                &format!("{service_subject}.execution_environment"),
1180                "Compose output cannot preserve service execution providers; resolve explicitly at conversion time",
1181                plan.origins(),
1182            );
1183        }
1184        for environment in environment {
1185            let subject = format!("{service_subject}.environment.{}", environment.value().name().as_str());
1186            match environment.value().value() {
1187                EnvironmentValue::Literal(value) if value.expose().contains('$') => self.unsupported(&subject, "Compose renderer cannot safely preserve decoded literal dollars in container environment; supply the literal value on the target or choose Quadlet output", environment.origins()),
1188                EnvironmentValue::Literal(value) => match generated_string(value)
1189                    .and_then(|value| GeneratedEnvironment::literal(environment.value().name().as_str(), value))
1190                {
1191                    Ok(value) => {
1192                        generated.add_environment(value);
1193                        self.exact(subject, environment.origins());
1194                    }
1195                    Err(error) => self.generation_error(&subject, &error, environment.origins()),
1196                },
1197                EnvironmentValue::UnresolvedSource { source_format, expression } if source_format.as_str() == "compose" => match generated_string(expression).and_then(|value| GeneratedEnvironment::literal(environment.value().name().as_str(), value)) {
1198                    Ok(value) => { generated.add_environment(value); self.exact(subject, environment.origins()); }
1199                    Err(error) => self.generation_error(&subject, &error, environment.origins()),
1200                },
1201                EnvironmentValue::UnresolvedSource { .. } => self.unsupported(&subject, "unresolved environment expression belongs to a different source format; resolve it explicitly before Compose output", environment.origins()),
1202                EnvironmentValue::Deferred(_) => self.unsupported(&subject, "deferred service-execution expressions require explicit conversion-time resolution for Compose output", environment.origins()),
1203                EnvironmentValue::Required => self.unsupported(
1204                    &subject,
1205                    "environment value was withheld; supply this named value on the target before use",
1206                    environment.origins(),
1207                ),
1208                EnvironmentValue::Host => match GeneratedEnvironment::host(environment.value().name().as_str()) {
1209                    Ok(value) => {
1210                        generated.add_environment(value);
1211                        self.exact(subject, environment.origins());
1212                    }
1213                    Err(error) => self.generation_error(&subject, &error, environment.origins()),
1214                },
1215                EnvironmentValue::Unset => self.unsupported(
1216                    &subject,
1217                    "Compose environment syntax cannot guarantee that a variable is absent from the container",
1218                    environment.origins(),
1219                ),
1220                _ => self.unsupported(
1221                    &subject,
1222                    "environment value variant is newer than this Compose adapter",
1223                    environment.origins(),
1224                ),
1225            }
1226        }
1227    }
1228
1229    fn map_environment_files(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1230        for (index, sourced) in service.environment_files().iter().enumerate() {
1231            let subject = format!("{service_subject}.environment_files[{index}]");
1232            let environment_file = sourced.value();
1233            let path = match generated_string(environment_file.path()) {
1234                Ok(path) => path,
1235                Err(error) => {
1236                    self.generation_error(&subject, &error, sourced.origins());
1237                    continue;
1238                }
1239            };
1240            let value = match environment_file.syntax() {
1241                EnvironmentFileSyntax::Short => {
1242                    if environment_file.required().is_some() || environment_file.format().is_some() {
1243                        self.unsupported(
1244                            &subject,
1245                            "short-syntax environment files cannot carry long-syntax options",
1246                            sourced.origins(),
1247                        );
1248                        continue;
1249                    }
1250                    GeneratedEnvironmentFile::short(path)
1251                }
1252                EnvironmentFileSyntax::Long => {
1253                    let format = match environment_file.format().map(Sourced::value) {
1254                        Some(EnvironmentFileFormat::Raw) => Some(GeneratedEnvironmentFileFormat::Raw),
1255                        Some(_) => {
1256                            self.unsupported(
1257                                &subject,
1258                                "environment-file format variant is newer than this Compose adapter",
1259                                sourced.origins(),
1260                            );
1261                            continue;
1262                        }
1263                        None => None,
1264                    };
1265                    GeneratedEnvironmentFile::long(
1266                        path,
1267                        environment_file.required().map(|required| *required.value()),
1268                        format,
1269                    )
1270                }
1271                _ => {
1272                    self.unsupported(
1273                        &subject,
1274                        "environment-file syntax variant is newer than this Compose adapter",
1275                        sourced.origins(),
1276                    );
1277                    continue;
1278                }
1279            };
1280            match value {
1281                Ok(value) => {
1282                    generated.add_environment_file(value);
1283                    self.exact(subject, sourced.origins());
1284                }
1285                Err(error) => self.generation_error(&subject, &error, sourced.origins()),
1286            }
1287        }
1288    }
1289
1290    fn map_labels(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1291        for label in service.labels() {
1292            let name = label.value().name().as_str();
1293            let subject = format!("{service_subject}.labels.{name}");
1294            if is_compose_managed_label(name) {
1295                self.unsupported(
1296                    &subject,
1297                    "Compose-managed labels cannot be safely re-authored as application metadata",
1298                    label.origins(),
1299                );
1300                continue;
1301            }
1302            match generated_string(label.value().value())
1303                .and_then(|value| GeneratedLabel::new(name, value))
1304                .and_then(|value| generated.add_label(value))
1305            {
1306                Ok(()) => self.exact(subject, label.origins()),
1307                Err(error) => self.generation_error(&subject, &error, label.origins()),
1308            }
1309        }
1310    }
1311
1312    fn map_host_mappings(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1313        for (index, host) in service.host_mappings().iter().enumerate() {
1314            let subject = format!("{service_subject}.extra_hosts[{index}]");
1315            match GeneratedExtraHost::new(host.value().hostname().as_str(), host.value().address().raw()) {
1316                Ok(value) => {
1317                    generated.add_extra_host(value);
1318                    if host.value().address().kind() == HostAddressKind::HostGateway {
1319                        self.compatibility(&subject, CompatibilityFeature::HostGatewayToken, host.origins());
1320                    } else {
1321                        self.exact(subject, host.origins());
1322                    }
1323                }
1324                Err(error) => self.generation_error(&subject, &error, host.origins()),
1325            }
1326        }
1327    }
1328
1329    fn map_ports(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1330        for (index, port) in service.ports().iter().enumerate() {
1331            let subject = format!("{service_subject}.ports[{index}]");
1332            let protocol = match port.value().protocol() {
1333                Protocol::Tcp => GeneratedProtocol::Tcp,
1334                Protocol::Udp => GeneratedProtocol::Udp,
1335                Protocol::Sctp => GeneratedProtocol::Sctp,
1336                Protocol::Other(protocol) => {
1337                    self.unsupported(
1338                        &subject,
1339                        &format!("Compose generation does not support protocol `{protocol}`"),
1340                        port.origins(),
1341                    );
1342                    continue;
1343                }
1344                _ => {
1345                    self.unsupported(
1346                        &subject,
1347                        "port protocol variant is newer than this Compose adapter",
1348                        port.origins(),
1349                    );
1350                    continue;
1351                }
1352            };
1353            match GeneratedPort::new(
1354                port.value().container(),
1355                port.value().published(),
1356                port.value().host_address().map(str::to_owned),
1357                protocol,
1358            ) {
1359                Ok(value) => {
1360                    generated.add_port(value);
1361                    if matches!(port.value().protocol(), Protocol::Sctp) {
1362                        self.unsupported(
1363                            &subject,
1364                            "SCTP syntax is preserved, but the selected provider/runtime pair has no reviewed compatibility evidence",
1365                            port.origins(),
1366                        );
1367                    } else {
1368                        self.exact(subject, port.origins());
1369                    }
1370                }
1371                Err(error) => self.generation_error(&subject, &error, port.origins()),
1372            }
1373        }
1374    }
1375
1376    fn map_mounts(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1377        for (index, mount) in service.mounts().iter().enumerate() {
1378            let subject = format!("{service_subject}.mounts[{index}]");
1379            if mount.value().paths_are_literal() && mount.value().target().contains('$') {
1380                self.unsupported(&subject, "Compose renderer cannot safely preserve decoded literal dollars in mount targets; supply an explicit target path or choose Quadlet output", mount.origins());
1381                continue;
1382            }
1383            let selinux = match mount.value().selinux_relabel() {
1384                Some(SelinuxRelabel::Shared) => Some(GeneratedSelinux::Shared),
1385                Some(SelinuxRelabel::Private) => Some(GeneratedSelinux::Private),
1386                Some(_) => {
1387                    self.unsupported(
1388                        &subject,
1389                        "SELinux relabel variant is newer than this Compose adapter",
1390                        mount.origins(),
1391                    );
1392                    continue;
1393                }
1394                None => None,
1395            };
1396            let named_volume_relabel = matches!(mount.value().source(), MountSource::Volume(_)) && selinux.is_some();
1397            let value = match mount.value().source() {
1398                MountSource::Volume(source) => {
1399                    GeneratedMount::volume(source.as_str(), mount.value().target(), mount.value().read_only())
1400                }
1401                MountSource::HostPath(source) if mount.value().paths_are_literal() && source.contains('$') => {
1402                    self.unsupported(&subject, "Compose renderer cannot safely preserve decoded literal dollars in bind paths; supply an explicit target path or choose Quadlet output", mount.origins());
1403                    continue;
1404                }
1405                MountSource::HostPath(source) => {
1406                    GeneratedMount::bind(source, mount.value().target(), mount.value().read_only(), selinux)
1407                }
1408                MountSource::DeferredHostPath(_) => {
1409                    self.unsupported(&subject, "deferred service-execution bind requires explicit conversion-time resolution for Compose output", mount.origins());
1410                    continue;
1411                }
1412                MountSource::Anonymous => GeneratedMount::anonymous(mount.value().target(), mount.value().read_only()),
1413                _ => {
1414                    self.unsupported(
1415                        &subject,
1416                        "mount source variant is newer than this Compose adapter",
1417                        mount.origins(),
1418                    );
1419                    continue;
1420                }
1421            };
1422            match value {
1423                Ok(value) => {
1424                    generated.add_mount(value);
1425                    if named_volume_relabel {
1426                        self.unsupported(
1427                            &subject,
1428                            "ComposeLens generated named-volume syntax cannot represent SELinux relabel intent",
1429                            mount.origins(),
1430                        );
1431                    } else if mount.value().selinux_relabel().is_some() {
1432                        self.compatibility(&subject, CompatibilityFeature::ShortBindSelinuxRelabel, mount.origins());
1433                    } else {
1434                        self.exact(subject, mount.origins());
1435                    }
1436                }
1437                Err(error) => self.generation_error(&subject, &error, mount.origins()),
1438            }
1439        }
1440    }
1441
1442    fn map_network_attachments(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1443        for network in service.networks() {
1444            let subject = format!("{service_subject}.networks.{}", network.value().network().as_str());
1445            match GeneratedNetworkAttachment::new(network.value().network().as_str()) {
1446                Ok(mut value) => {
1447                    let mut valid = true;
1448                    for (index, alias) in network.value().aliases().iter().enumerate() {
1449                        if network
1450                            .value()
1451                            .alias_sensitivities()
1452                            .get(index)
1453                            .copied()
1454                            .unwrap_or(true)
1455                        {
1456                            valid = false;
1457                            continue;
1458                        }
1459                        if let Err(error) =
1460                            GeneratedString::plain(alias).and_then(|alias| value.add_alias_value(&alias))
1461                        {
1462                            self.generation_error(&subject, &error, network.origins());
1463                            valid = false;
1464                            break;
1465                        }
1466                    }
1467                    if let Some(address) = network.value().ipv4_address()
1468                        && let Err(error) =
1469                            generated_string(address.value()).and_then(|address| value.set_ipv4_address(address))
1470                    {
1471                        self.generation_error(&subject, &error, address.origins());
1472                        valid = false;
1473                    }
1474                    if let Some(address) = network.value().ipv6_address()
1475                        && let Err(error) =
1476                            generated_string(address.value()).and_then(|address| value.set_ipv6_address(address))
1477                    {
1478                        self.generation_error(&subject, &error, address.origins());
1479                        valid = false;
1480                    }
1481                    if valid {
1482                        match generated.add_network(value) {
1483                            Ok(()) => self.exact(subject, network.origins()),
1484                            Err(error) => self.generation_error(&subject, &error, network.origins()),
1485                        }
1486                    }
1487                }
1488                Err(error) => self.generation_error(&subject, &error, network.origins()),
1489            }
1490        }
1491    }
1492
1493    fn map_released_container_settings(
1494        &mut self,
1495        service: &Service,
1496        generated: &mut GeneratedService,
1497        service_subject: &str,
1498    ) {
1499        self.map_entrypoint_lifecycle_and_pull(service, generated, service_subject);
1500        self.map_hostname_pids_shm_memory(service, generated, service_subject);
1501        self.map_collections(service, generated, service_subject);
1502        self.map_annotations_logging_and_reload(service, generated, service_subject);
1503    }
1504
1505    fn map_entrypoint_lifecycle_and_pull(
1506        &mut self,
1507        service: &Service,
1508        generated: &mut GeneratedService,
1509        subject: &str,
1510    ) {
1511        if let Some(entrypoint) = service.entrypoint() {
1512            let field = format!("{subject}.entrypoint");
1513            let value = match entrypoint.value() {
1514                Entrypoint::Exec(values) => values
1515                    .iter()
1516                    .map(generated_string)
1517                    .collect::<Result<Vec<_>, _>>()
1518                    .map(GeneratedEntrypoint::List),
1519                Entrypoint::Shell(value) => generated_string(value).map(GeneratedEntrypoint::String),
1520                Entrypoint::Empty => Ok(GeneratedEntrypoint::Empty),
1521                _ => {
1522                    self.unsupported(
1523                        &field,
1524                        "entrypoint variant is newer than this Compose adapter",
1525                        entrypoint.origins(),
1526                    );
1527                    return;
1528                }
1529            };
1530            match value.and_then(|value| generated.set_entrypoint(value)) {
1531                Ok(()) => self.exact(field, entrypoint.origins()),
1532                Err(error) => self.generation_error(&field, &error, entrypoint.origins()),
1533            }
1534        }
1535        if let Some(init) = service.run_init() {
1536            let field = format!("{subject}.init");
1537            match generated.set_init(*init.value()) {
1538                Ok(()) => self.exact(field, init.origins()),
1539                Err(error) => self.generation_error(&field, &error, init.origins()),
1540            }
1541        }
1542        if let Some(timeout) = service.stop_timeout() {
1543            let field = format!("{subject}.stop_grace_period");
1544            match generated_string(&ProtectedString::plain(timeout.value().as_str()))
1545                .and_then(|value| generated.set_stop_grace_period(value))
1546            {
1547                Ok(()) => self.exact(field, timeout.origins()),
1548                Err(error) => self.generation_error(&field, &error, timeout.origins()),
1549            }
1550        }
1551        if let Some(policy) = service.pull_policy() {
1552            let field = format!("{subject}.pull_policy");
1553            let value = match policy.value() {
1554                PullPolicy::Always => Ok(GeneratedPullPolicy::Always),
1555                PullPolicy::Missing => Ok(GeneratedPullPolicy::Missing),
1556                PullPolicy::Never => Ok(GeneratedPullPolicy::Never),
1557                PullPolicy::IfNotPresent => Ok(GeneratedPullPolicy::IfNotPresentAlias),
1558                PullPolicy::Build => Ok(GeneratedPullPolicy::Build),
1559                PullPolicy::Daily => Ok(GeneratedPullPolicy::Daily),
1560                PullPolicy::Weekly => Ok(GeneratedPullPolicy::Weekly),
1561                PullPolicy::Every(value) => generated_string(value).map(GeneratedPullPolicy::Every),
1562                PullPolicy::Raw(_) => {
1563                    self.unsupported(
1564                        &field,
1565                        "provider-specific pull policy cannot be silently normalized for Compose output",
1566                        policy.origins(),
1567                    );
1568                    return;
1569                }
1570                _ => {
1571                    self.unsupported(
1572                        &field,
1573                        "pull-policy variant is newer than this Compose adapter",
1574                        policy.origins(),
1575                    );
1576                    return;
1577                }
1578            };
1579            match value.and_then(|value| generated.set_pull_policy(value)) {
1580                Ok(()) => self.exact(field, policy.origins()),
1581                Err(error) => self.generation_error(&field, &error, policy.origins()),
1582            }
1583        }
1584    }
1585
1586    fn map_hostname_pids_shm_memory(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1587        if let Some(value) = service.hostname() {
1588            let field = format!("{subject}.hostname");
1589            match generated_string(value.value())
1590                .map(GeneratedHostname::Resolved)
1591                .and_then(|value| generated.set_hostname(value))
1592            {
1593                Ok(()) => self.exact(field, value.origins()),
1594                Err(error) => self.generation_error(&field, &error, value.origins()),
1595            }
1596        }
1597        if let Some(value) = service.pids_limit() {
1598            let field = format!("{subject}.pids_limit");
1599            let limit = if value.value().expose() == "-1" {
1600                GeneratedPidsLimit::Unlimited
1601            } else {
1602                GeneratedPidsLimit::Finite(value.value().expose().to_owned())
1603            };
1604            match generated.set_pids_limit(limit) {
1605                Ok(()) => self.exact(field, value.origins()),
1606                Err(error) => self.generation_error(&field, &error, value.origins()),
1607            }
1608        }
1609        if let Some(value) = service.shm_size() {
1610            self.map_sized_value(value.value(), value.origins(), &format!("{subject}.shm_size"), |size| {
1611                generated.set_shm_size(size)
1612            });
1613        }
1614        if let Some(value) = service.memory_limit() {
1615            self.map_memory_value(
1616                value.value(),
1617                value.origins(),
1618                &format!("{subject}.mem_limit"),
1619                |limit| generated.set_mem_limit(limit),
1620            );
1621        }
1622    }
1623
1624    fn map_sized_value<F>(&mut self, value: &ProtectedString, origins: &[Provenance], subject: &str, set: F)
1625    where
1626        F: FnOnce(GeneratedShmSize) -> Result<(), GenerationError>,
1627    {
1628        let Some((amount, unit)) = split_size(value.expose()) else {
1629            self.unsupported(
1630                subject,
1631                "shared-memory size requires a documented lowercase unit",
1632                origins,
1633            );
1634            return;
1635        };
1636        let amount = if value.is_sensitive() {
1637            GeneratedString::sensitive(amount)
1638        } else {
1639            GeneratedString::plain(amount)
1640        };
1641        let Ok(amount) = amount else {
1642            self.unsupported(subject, "invalid shared-memory size", origins);
1643            return;
1644        };
1645        match set(GeneratedShmSize::Explicit { amount, unit }) {
1646            Ok(()) => self.exact(subject, origins),
1647            Err(error) => self.generation_error(subject, &error, origins),
1648        }
1649    }
1650
1651    fn map_memory_value<F>(&mut self, value: &ProtectedString, origins: &[Provenance], subject: &str, set: F)
1652    where
1653        F: FnOnce(GeneratedMemLimit) -> Result<(), GenerationError>,
1654    {
1655        let Some((amount, unit)) = split_memory(value.expose()) else {
1656            self.unsupported(subject, "memory limit requires a documented lowercase unit", origins);
1657            return;
1658        };
1659        let amount = if value.is_sensitive() {
1660            GeneratedString::sensitive(amount)
1661        } else {
1662            GeneratedString::plain(amount)
1663        };
1664        match amount.and_then(|amount| set(GeneratedMemLimit::Explicit { amount, unit })) {
1665            Ok(()) => self.exact(subject, origins),
1666            Err(error) => self.generation_error(subject, &error, origins),
1667        }
1668    }
1669
1670    fn map_collections(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1671        self.map_capabilities(service, generated, subject);
1672        self.map_resource_collections(service, generated, subject);
1673        self.map_devices_signals_and_expose(service, generated, subject);
1674    }
1675
1676    fn map_capabilities(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1677        for (name, values, origins, set) in [
1678            (
1679                "cap_add",
1680                service.cap_add(),
1681                service.cap_add_origins(),
1682                GeneratedService::set_cap_add as fn(&mut GeneratedService, Vec<GeneratedString>) -> _,
1683            ),
1684            (
1685                "cap_drop",
1686                service.cap_drop(),
1687                service.cap_drop_origins(),
1688                GeneratedService::set_cap_drop as fn(&mut GeneratedService, Vec<GeneratedString>) -> _,
1689            ),
1690        ] {
1691            if let Some(values) = values {
1692                let field = format!("{subject}.{name}");
1693                let all_origins = collection_or_item_origins(values, origins);
1694                match values
1695                    .iter()
1696                    .map(|value| generated_string(value.value()))
1697                    .collect::<Result<Vec<_>, _>>()
1698                    .and_then(|values| set(generated, values))
1699                {
1700                    Ok(()) => self.exact(field, &all_origins),
1701                    Err(error) => self.generation_error(&field, &error, &all_origins),
1702                }
1703            }
1704        }
1705    }
1706
1707    fn map_resource_collections(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1708        if let Some(values) = service.tmpfs() {
1709            let field = format!("{subject}.tmpfs");
1710            let origins = collection_or_item_origins(values, service.tmpfs_origins());
1711            match values
1712                .iter()
1713                .map(|value| generated_string(value.value()))
1714                .collect::<Result<Vec<_>, _>>()
1715                .and_then(|values| generated.set_tmpfs(GeneratedTmpfs::List(values)))
1716            {
1717                Ok(()) => self.exact(field, &origins),
1718                Err(error) => self.generation_error(&field, &error, &origins),
1719            }
1720        }
1721        if let Some(values) = service.sysctls() {
1722            let field = format!("{subject}.sysctls");
1723            let origins = collection_or_item_origins(values, service.sysctls_origins());
1724            let values = values
1725                .iter()
1726                .map(|value| {
1727                    GeneratedSysctl::new(value.value().name().expose(), generated_string(value.value().value())?)
1728                })
1729                .collect::<Result<Vec<_>, _>>();
1730            match values.and_then(|values| generated.set_sysctls(GeneratedSysctls::Map(values))) {
1731                Ok(()) => self.exact(field, &origins),
1732                Err(error) => self.generation_error(&field, &error, &origins),
1733            }
1734        }
1735        if let Some(values) = service.ulimits() {
1736            let field = format!("{subject}.ulimits");
1737            let origins = resource_limit_collection_origins(values, service.ulimits_origins());
1738            let values = values
1739                .iter()
1740                .map(|value| {
1741                    let limit = value.value();
1742                    match (limit.soft(), limit.hard()) {
1743                        (Some(soft), Some(hard)) if soft.value().expose() == hard.value().expose() => {
1744                            GeneratedUlimit::single(limit.name().expose(), generated_string(soft.value())?)
1745                        }
1746                        (Some(soft), Some(hard)) => GeneratedUlimit::range(
1747                            limit.name().expose(),
1748                            generated_string(soft.value())?,
1749                            generated_string(hard.value())?,
1750                        ),
1751                        _ => Err(GenerationError::MissingUlimitRangeMember("soft/hard")),
1752                    }
1753                })
1754                .collect::<Result<Vec<_>, _>>();
1755            match values
1756                .and_then(GeneratedUlimits::new)
1757                .and_then(|values| generated.set_ulimits(values))
1758            {
1759                Ok(()) => self.exact(field, &origins),
1760                Err(error) => self.generation_error(&field, &error, &origins),
1761            }
1762        }
1763    }
1764
1765    fn map_devices_signals_and_expose(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1766        if let Some(values) = service.devices() {
1767            let field = format!("{subject}.devices");
1768            let origins = device_collection_origins(values, service.devices_origins());
1769            let values = values
1770                .iter()
1771                .map(|value| match value.value() {
1772                    Device::Short(value) => generated_string(value).map(GeneratedDevice::Short),
1773                    Device::Long {
1774                        source,
1775                        target,
1776                        permissions,
1777                    } => {
1778                        let source = source.as_ref().ok_or(GenerationError::InvalidDeviceValue("source"))?;
1779                        GeneratedLongDevice::new(
1780                            generated_string(source.value())?,
1781                            target
1782                                .as_ref()
1783                                .map(|value| generated_string(value.value()))
1784                                .transpose()?,
1785                            permissions
1786                                .as_ref()
1787                                .map(|value| generated_string(value.value()))
1788                                .transpose()?,
1789                        )
1790                        .map(GeneratedDevice::Long)
1791                    }
1792                    _ => Err(GenerationError::InvalidDeviceValue("variant")),
1793                })
1794                .collect::<Result<Vec<_>, _>>();
1795            match values.and_then(|values| generated.set_devices(values)) {
1796                Ok(()) => self.exact(field, &origins),
1797                Err(error) => self.generation_error(&field, &error, &origins),
1798            }
1799        }
1800        if let Some(value) = service.stop_signal() {
1801            let field = format!("{subject}.stop_signal");
1802            match generated_string(value.value()).and_then(|value| generated.set_stop_signal(value)) {
1803                Ok(()) => self.exact(field, value.origins()),
1804                Err(error) => self.generation_error(&field, &error, value.origins()),
1805            }
1806        }
1807        if let Some(values) = service.exposed_ports() {
1808            let field = format!("{subject}.expose");
1809            let origins = collection_or_item_origins(values, service.exposed_ports_origins());
1810            let values = values
1811                .iter()
1812                .map(|value| {
1813                    GeneratedString::plain(format!(
1814                        "{}/{}",
1815                        value.value().container(),
1816                        match value.value().protocol() {
1817                            Protocol::Tcp => "tcp",
1818                            Protocol::Udp => "udp",
1819                            _ => "unsupported",
1820                        }
1821                    ))
1822                })
1823                .collect::<Result<Vec<_>, _>>();
1824            match values.and_then(|values| generated.set_expose(values)) {
1825                Ok(()) => self.exact(field, &origins),
1826                Err(error) => self.generation_error(&field, &error, &origins),
1827            }
1828        }
1829    }
1830
1831    fn map_annotations_logging_and_reload(
1832        &mut self,
1833        service: &Service,
1834        generated: &mut GeneratedService,
1835        subject: &str,
1836    ) {
1837        if let Some(values) = service.annotations() {
1838            let field = format!("{subject}.annotations");
1839            let origins = collection_or_item_origins(values, service.annotations_origins());
1840            let values = values
1841                .iter()
1842                .map(|value| {
1843                    GeneratedAnnotation::new(
1844                        value.value().name().value().as_str(),
1845                        generated_string(value.value().value().value())?,
1846                    )
1847                })
1848                .collect::<Result<Vec<_>, _>>();
1849            match values.and_then(|values| generated.set_annotations(values)) {
1850                Ok(()) => self.exact(field, &origins),
1851                Err(error) => self.generation_error(&field, &error, &origins),
1852            }
1853        }
1854        if let Some(logging) = service.logging() {
1855            let field = format!("{subject}.logging");
1856            let value = logging.value();
1857            let Some(driver) = value.driver() else {
1858                self.unsupported(
1859                    &field,
1860                    "Compose logging requires a driver; neutral logging retains its absence",
1861                    logging.origins(),
1862                );
1863                return;
1864            };
1865            let options = value
1866                .options()
1867                .unwrap_or_default()
1868                .iter()
1869                .map(|option| {
1870                    GeneratedLoggingOption::new(
1871                        option.value().name().value().as_str(),
1872                        GeneratedLoggingOptionValue::String(generated_string(option.value().value().value())?),
1873                    )
1874                })
1875                .collect::<Result<Vec<_>, _>>();
1876            match generated_string(driver.value())
1877                .and_then(|driver| options.and_then(|options| GeneratedLogging::new(driver, options)))
1878                .and_then(|value| generated.set_logging(value))
1879            {
1880                Ok(()) if value.options().is_some() => self.exact(field, logging.origins()),
1881                Ok(()) => self.loss(
1882                    self.exporter.codes.unsupported.clone(),
1883                    &field,
1884                    ConversionKind::Approximate,
1885                    "generated Compose logging adds an explicit empty options mapping",
1886                    "neutral logging omitted options, but ComposeLens GeneratedLogging requires an options collection",
1887                    logging.origins(),
1888                ),
1889                Err(error) => self.generation_error(&field, &error, logging.origins()),
1890            }
1891        }
1892        if let Some(reload) = service.reload_action() {
1893            self.unsupported(
1894                &format!("{subject}.reload_action"),
1895                "Compose has no reload command or signal field; reload intent is never emitted as a lifecycle hook",
1896                reload.origins(),
1897            );
1898        }
1899    }
1900
1901    fn report_unimplemented_service_fields(&mut self, service: &Service, service_subject: &str) {
1902        if let Some(healthcheck) = service.healthcheck() {
1903            self.unsupported(
1904                &format!("{service_subject}.healthcheck"),
1905                "the current Compose generation boundary does not yet expose health-check fields",
1906                healthcheck.origins(),
1907            );
1908        }
1909        for (index, dependency) in service.dependencies().iter().enumerate() {
1910            self.unsupported(
1911                &format!("{service_subject}.dependencies[{index}]"),
1912                "the current Compose generation boundary does not yet expose service dependencies",
1913                dependency.origins(),
1914            );
1915        }
1916        for (index, grant) in service.config_grants().iter().enumerate() {
1917            self.unsupported(
1918                &format!("{service_subject}.config_grants[{index}]"),
1919                "the current Compose generation boundary does not yet expose service config grants",
1920                grant.origins(),
1921            );
1922        }
1923        for (index, grant) in service.secret_grants().iter().enumerate() {
1924            self.unsupported(
1925                &format!("{service_subject}.secret_grants[{index}]"),
1926                "the current Compose generation boundary does not yet expose service secret grants",
1927                grant.origins(),
1928            );
1929        }
1930    }
1931
1932    fn report_compose_native_only_service_fields(&mut self, service: &Service, service_subject: &str) {
1933        if let Some(notification) = service.startup_notification() {
1934            self.unsupported(
1935                &format!("{service_subject}.startup_notification"),
1936                "Compose has no service startup-notification field",
1937                notification.origins(),
1938            );
1939        }
1940        if let Some(rootfs) = service.rootfs() {
1941            self.unsupported(
1942                &format!("{service_subject}.rootfs"),
1943                "Compose has no root-filesystem source field; rootfs is never rewritten as an image",
1944                rootfs.origins(),
1945            );
1946        }
1947    }
1948
1949    fn report_service_group_loss(&mut self, group: &Sourced<boxferry_model::ServiceGroup>) {
1950        let group_subject = format!("service_groups.{}", group.value().name().as_str());
1951        self.unsupported(
1952            &group_subject,
1953            "Compose has no native structural equivalent for a runtime pod or shared namespace group",
1954            group.origins(),
1955        );
1956        let Some(runtime) = group.value().runtime() else {
1957            return;
1958        };
1959        let runtime_subject = format!("{group_subject}.runtime");
1960        self.unsupported(
1961            &runtime_subject,
1962            "Compose has no native service-group runtime; it is never assigned to a member service",
1963            runtime.origins(),
1964        );
1965        let runtime = runtime.value();
1966        if let Some(value) = runtime.runtime_name() {
1967            self.unsupported(
1968                &format!("{runtime_subject}.runtime_name"),
1969                "Compose has no native service-group runtime name",
1970                value.origins(),
1971            );
1972        }
1973        if let Some(value) = runtime.service_name() {
1974            self.unsupported(
1975                &format!("{runtime_subject}.service_name"),
1976                "Compose has no native service-group systemd service name",
1977                value.origins(),
1978            );
1979        }
1980        self.report_group_runtime_collection(
1981            &format!("{runtime_subject}.host_mappings"),
1982            runtime.host_mappings(),
1983            runtime.host_mappings_origins(),
1984        );
1985        self.report_group_runtime_collection(
1986            &format!("{runtime_subject}.ports"),
1987            runtime.ports(),
1988            runtime.ports_origins(),
1989        );
1990        self.report_group_runtime_collection(
1991            &format!("{runtime_subject}.networks"),
1992            runtime.networks(),
1993            runtime.networks_origins(),
1994        );
1995        if let Some(value) = runtime.user_namespace() {
1996            self.unsupported(
1997                &format!("{runtime_subject}.user_namespace"),
1998                "Compose has no native service-group user-namespace field",
1999                value.origins(),
2000            );
2001        }
2002        self.report_group_runtime_collection(
2003            &format!("{runtime_subject}.mounts"),
2004            runtime.mounts(),
2005            runtime.mounts_origins(),
2006        );
2007        if let Some(value) = runtime.shm_size() {
2008            self.unsupported(
2009                &format!("{runtime_subject}.shm_size"),
2010                "Compose has no native service-group shared-memory-size field",
2011                value.origins(),
2012            );
2013        }
2014        if let Some(value) = runtime.exit_policy() {
2015            self.unsupported(
2016                &format!("{runtime_subject}.exit_policy"),
2017                "Compose has no native service-group exit-policy field",
2018                value.origins(),
2019            );
2020        }
2021        if let Some(value) = runtime.stop_timeout() {
2022            self.unsupported(
2023                &format!("{runtime_subject}.stop_timeout"),
2024                "Compose has no native service-group stop-timeout field",
2025                value.origins(),
2026            );
2027        }
2028    }
2029
2030    fn report_group_runtime_collection<T>(
2031        &mut self,
2032        subject: &str,
2033        values: Option<&[Sourced<T>]>,
2034        collection_origins: &[Provenance],
2035    ) {
2036        let Some(values) = values else {
2037            return;
2038        };
2039        if values.is_empty() {
2040            self.unsupported(
2041                subject,
2042                "Compose has no native service-group runtime collection; it is never assigned to a member service",
2043                collection_origins,
2044            );
2045            return;
2046        }
2047        for (index, value) in values.iter().enumerate() {
2048            self.unsupported(
2049                &format!("{subject}[{index}]"),
2050                "Compose has no native service-group runtime collection; it is never assigned to a member service",
2051                value.origins(),
2052            );
2053        }
2054    }
2055
2056    fn map_identity(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
2057        match (service.user(), service.group()) {
2058            (Some(user), group) => {
2059                let mut value = user.value().expose().to_owned();
2060                let mut sensitive = user.value().is_sensitive();
2061                let mut origins = user.origins().to_vec();
2062                if let Some(group) = group {
2063                    value.push(':');
2064                    value.push_str(group.value().expose());
2065                    sensitive |= group.value().is_sensitive();
2066                    origins.extend_from_slice(group.origins());
2067                }
2068                let value = if sensitive {
2069                    GeneratedString::sensitive(value)
2070                } else {
2071                    GeneratedString::plain(value)
2072                };
2073                match value.and_then(|value| generated.set_user(value)) {
2074                    Ok(()) => self.exact(format!("{service_subject}.user"), &origins),
2075                    Err(error) => self.generation_error(&format!("{service_subject}.user"), &error, &origins),
2076                }
2077            }
2078            (None, Some(group)) => self.unsupported(
2079                &format!("{service_subject}.group"),
2080                "Compose combines user and primary group in one field and cannot encode a group without a user",
2081                group.origins(),
2082            ),
2083            (None, None) => {}
2084        }
2085
2086        if let Some(user_namespace) = service.user_namespace() {
2087            let subject = format!("{service_subject}.user_namespace");
2088            match generated_string(user_namespace.value()).and_then(|value| generated.set_userns_mode(value)) {
2089                Ok(()) if is_podman_user_namespace(user_namespace.value().expose()) => self.compatibility(
2090                    &subject,
2091                    CompatibilityFeature::PodmanUserNamespaceMode,
2092                    user_namespace.origins(),
2093                ),
2094                Ok(()) => self.exact(subject, user_namespace.origins()),
2095                Err(error) => self.generation_error(&subject, &error, user_namespace.origins()),
2096            }
2097        }
2098        for (index, group) in service.supplementary_groups().iter().enumerate() {
2099            let subject = format!("{service_subject}.supplementary_groups[{index}]");
2100            match generated_string(group.value()).and_then(|value| generated.add_supplementary_group(value)) {
2101                Ok(()) => self.exact(subject, group.origins()),
2102                Err(error) => self.generation_error(&subject, &error, group.origins()),
2103            }
2104        }
2105    }
2106
2107    fn compatibility(&mut self, subject: &str, feature: CompatibilityFeature, origins: &[Provenance]) {
2108        let Some(profile) = self.compatibility else {
2109            self.invalid(
2110                self.exporter.codes.invalid_target.clone(),
2111                subject,
2112                "Compose compatibility profile is unavailable",
2113                "validate the target before mapping application fields",
2114                origins,
2115            );
2116            return;
2117        };
2118        let rule = profile.classify(feature);
2119        let evidence: Vec<_> = rule.evidence().iter().map(|evidence| evidence.source()).collect();
2120        match rule.classification() {
2121            CompatibilityClassification::Supported | CompatibilityClassification::Extension => {
2122                self.exact(subject, origins);
2123            }
2124            CompatibilityClassification::ImplementationSpecific | CompatibilityClassification::Deprecated => {
2125                self.compatibility_loss(
2126                    subject,
2127                    ConversionKind::Approximate,
2128                    rule.classification(),
2129                    rule.explanation(),
2130                    &evidence,
2131                    origins,
2132                );
2133            }
2134            CompatibilityClassification::Unsupported | CompatibilityClassification::Unknown => {
2135                self.compatibility_loss(
2136                    subject,
2137                    ConversionKind::Unsupported,
2138                    rule.classification(),
2139                    rule.explanation(),
2140                    &evidence,
2141                    origins,
2142                );
2143            }
2144            _ => self.compatibility_loss(
2145                subject,
2146                ConversionKind::Unsupported,
2147                rule.classification(),
2148                rule.explanation(),
2149                &evidence,
2150                origins,
2151            ),
2152        }
2153    }
2154
2155    fn compatibility_loss(
2156        &mut self,
2157        subject: &str,
2158        kind: ConversionKind,
2159        classification: CompatibilityClassification,
2160        reason: &str,
2161        evidence: &[&str],
2162        origins: &[Provenance],
2163    ) {
2164        let classification = format!("{classification:?}").to_ascii_lowercase();
2165        let mut diagnostic = Diagnostic::new(
2166            self.exporter.codes.compatibility.clone(),
2167            Severity::Warning,
2168            "generated Compose construct has a target-specific compatibility constraint",
2169        )
2170        .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject)))
2171        .with_field(DiagnosticField::new(
2172            "classification",
2173            DiagnosticValue::plain(classification),
2174        ))
2175        .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(reason)));
2176        if !evidence.is_empty() {
2177            diagnostic = diagnostic.with_field(DiagnosticField::new(
2178                "evidence",
2179                DiagnosticValue::plain(evidence.join(",")),
2180            ));
2181        }
2182        self.diagnostics.push(diagnostic);
2183        self.push_loss(subject, kind, self.exporter.codes.compatibility.clone(), origins);
2184    }
2185
2186    fn exact(&mut self, subject: impl Into<String>, origins: &[Provenance]) {
2187        let mut outcome = ConversionOutcome::exact(subject);
2188        for origin in origins {
2189            outcome = outcome.with_origin(origin.clone());
2190        }
2191        self.outcomes.push(outcome);
2192    }
2193
2194    fn unsupported(&mut self, subject: &str, reason: &str, origins: &[Provenance]) {
2195        self.loss(
2196            self.exporter.codes.unsupported.clone(),
2197            subject,
2198            ConversionKind::Unsupported,
2199            "neutral application intent is not represented in generated Compose output",
2200            reason,
2201            origins,
2202        );
2203    }
2204
2205    fn generation_error(&mut self, subject: &str, error: &GenerationError, origins: &[Provenance]) {
2206        self.generation_failed = true;
2207        self.invalid(
2208            self.exporter.codes.generation.clone(),
2209            subject,
2210            "Compose document generation failed",
2211            &error.to_string(),
2212            origins,
2213        );
2214    }
2215
2216    fn invalid(&mut self, code: DiagnosticCode, subject: &str, summary: &str, reason: &str, origins: &[Provenance]) {
2217        self.loss(code, subject, ConversionKind::Invalid, summary, reason, origins);
2218    }
2219
2220    fn loss(
2221        &mut self,
2222        code: DiagnosticCode,
2223        subject: &str,
2224        kind: ConversionKind,
2225        summary: &str,
2226        reason: &str,
2227        origins: &[Provenance],
2228    ) {
2229        let severity = if kind == ConversionKind::Invalid {
2230            Severity::Error
2231        } else {
2232            Severity::Warning
2233        };
2234        self.diagnostics.push(
2235            Diagnostic::new(code.clone(), severity, summary)
2236                .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject)))
2237                .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(reason))),
2238        );
2239        self.push_loss(subject, kind, code, origins);
2240    }
2241
2242    fn push_loss(&mut self, subject: &str, kind: ConversionKind, code: DiagnosticCode, origins: &[Provenance]) {
2243        if let Ok(mut outcome) = ConversionOutcome::loss(subject, kind, code) {
2244            for origin in origins {
2245                outcome = outcome.with_origin(origin.clone());
2246            }
2247            self.outcomes.push(outcome);
2248        }
2249    }
2250
2251    fn finish(
2252        mut self,
2253    ) -> (
2254        Option<GeneratedComposeDocument>,
2255        Vec<ConversionOutcome>,
2256        Vec<Diagnostic>,
2257    ) {
2258        let candidate = if self.compatibility.is_some()
2259            && !self.generation_failed
2260            && !self.protected_aliases
2261            && !self.protected_metadata
2262        {
2263            match self.builder.take().unwrap_or_default().build(SourceId::new(1)) {
2264                Ok(document) => Some(document),
2265                Err(error) => {
2266                    self.generation_error("application", &error, &[]);
2267                    None
2268                }
2269            }
2270        } else {
2271            None
2272        };
2273        (candidate, self.outcomes, self.diagnostics)
2274    }
2275}
2276
2277fn generated_string(value: &ProtectedString) -> Result<GeneratedString, GenerationError> {
2278    if value.is_sensitive() {
2279        GeneratedString::sensitive(value.expose())
2280    } else {
2281        GeneratedString::plain(value.expose())
2282    }
2283}
2284
2285fn split_size(value: &str) -> Option<(&str, ShmSizeUnit)> {
2286    for (suffix, unit) in [
2287        ("b", ShmSizeUnit::B),
2288        ("k", ShmSizeUnit::K),
2289        ("m", ShmSizeUnit::M),
2290        ("g", ShmSizeUnit::G),
2291    ] {
2292        if let Some(amount) = value.strip_suffix(suffix) {
2293            return Some((amount, unit));
2294        }
2295    }
2296    None
2297}
2298
2299fn split_memory(value: &str) -> Option<(&str, MemLimitUnit)> {
2300    for (suffix, unit) in [
2301        ("kb", MemLimitUnit::Kb),
2302        ("mb", MemLimitUnit::Mb),
2303        ("gb", MemLimitUnit::Gb),
2304        ("b", MemLimitUnit::B),
2305        ("k", MemLimitUnit::K),
2306        ("m", MemLimitUnit::M),
2307        ("g", MemLimitUnit::G),
2308    ] {
2309        if let Some(amount) = value.strip_suffix(suffix) {
2310            return Some((amount, unit));
2311        }
2312    }
2313    None
2314}
2315
2316enum SecurityOptionGeneration {
2317    Generated(GeneratedString),
2318    Unsupported(&'static str),
2319}
2320
2321fn security_option_string(option: &SecurityOption) -> Result<SecurityOptionGeneration, GenerationError> {
2322    Ok(match option {
2323        SecurityOption::AppArmor(profile) => {
2324            SecurityOptionGeneration::Generated(prefixed_generated_string("apparmor=", profile)?)
2325        }
2326        SecurityOption::NoNewPrivileges(enabled) => {
2327            SecurityOptionGeneration::Generated(GeneratedString::plain(format!("no-new-privileges:{enabled}"))?)
2328        }
2329        SecurityOption::SeccompProfile(profile) => {
2330            SecurityOptionGeneration::Generated(prefixed_generated_string("seccomp=", profile)?)
2331        }
2332        SecurityOption::SecurityLabelDisable(enabled) => {
2333            if *enabled {
2334                SecurityOptionGeneration::Generated(GeneratedString::plain("label:disable")?)
2335            } else {
2336                SecurityOptionGeneration::Unsupported(
2337                    "Compose has no exact `security_opt` spelling that re-enables SELinux label separation",
2338                )
2339            }
2340        }
2341        SecurityOption::SecurityLabelFileType(file_type) => {
2342            SecurityOptionGeneration::Generated(prefixed_generated_string("label:filetype:", file_type)?)
2343        }
2344        SecurityOption::SecurityLabelLevel(level) => {
2345            SecurityOptionGeneration::Generated(prefixed_generated_string("label:level:", level)?)
2346        }
2347        SecurityOption::SecurityLabelNested(enabled) => {
2348            if *enabled {
2349                SecurityOptionGeneration::Generated(GeneratedString::plain("label:nested")?)
2350            } else {
2351                SecurityOptionGeneration::Unsupported(
2352                    "Compose has no exact `security_opt` spelling that disables nested SELinux labeling",
2353                )
2354            }
2355        }
2356        SecurityOption::SecurityLabelType(label_type) => {
2357            SecurityOptionGeneration::Generated(prefixed_generated_string("label:type:", label_type)?)
2358        }
2359        SecurityOption::Mask(paths) => SecurityOptionGeneration::Generated(prefixed_generated_string("mask=", paths)?),
2360        SecurityOption::Unmask(paths) => {
2361            SecurityOptionGeneration::Generated(prefixed_generated_string("unmask=", paths)?)
2362        }
2363        _ => SecurityOptionGeneration::Unsupported("security-option variant is newer than this Compose adapter"),
2364    })
2365}
2366
2367fn prefixed_generated_string(prefix: &str, value: &ProtectedString) -> Result<GeneratedString, GenerationError> {
2368    let raw = format!("{prefix}{}", value.expose());
2369    if value.is_sensitive() {
2370        GeneratedString::sensitive(raw)
2371    } else {
2372        GeneratedString::plain(raw)
2373    }
2374}
2375
2376fn collection_or_item_origins<T>(values: &[Sourced<T>], collection_origins: &[Provenance]) -> Vec<Provenance> {
2377    let mut origins = collection_origins.to_vec();
2378    for value in values {
2379        extend_origins(&mut origins, value.origins());
2380    }
2381    origins
2382}
2383
2384fn combined_origins(first: &[Provenance], second: &[Provenance]) -> Vec<Provenance> {
2385    let mut origins = first.to_vec();
2386    extend_origins(&mut origins, second);
2387    origins
2388}
2389
2390fn resource_limit_collection_origins(
2391    values: &[Sourced<boxferry_model::ResourceLimit>],
2392    collection_origins: &[Provenance],
2393) -> Vec<Provenance> {
2394    let mut origins = collection_or_item_origins(values, collection_origins);
2395    for value in values {
2396        for nested in [value.value().soft(), value.value().hard()].into_iter().flatten() {
2397            extend_origins(&mut origins, nested.origins());
2398        }
2399    }
2400    origins
2401}
2402
2403fn device_collection_origins(values: &[Sourced<Device>], collection_origins: &[Provenance]) -> Vec<Provenance> {
2404    let mut origins = collection_or_item_origins(values, collection_origins);
2405    for value in values {
2406        if let Device::Long {
2407            source,
2408            target,
2409            permissions,
2410        } = value.value()
2411        {
2412            for nested in [source.as_ref(), target.as_ref(), permissions.as_ref()]
2413                .into_iter()
2414                .flatten()
2415            {
2416                extend_origins(&mut origins, nested.origins());
2417            }
2418        }
2419    }
2420    origins
2421}
2422
2423fn extend_origins(origins: &mut Vec<Provenance>, additional: &[Provenance]) {
2424    for origin in additional {
2425        if !origins.contains(origin) {
2426            origins.push(origin.clone());
2427        }
2428    }
2429}
2430
2431fn is_compose_managed_label(name: &str) -> bool {
2432    name.starts_with("com.docker.compose.")
2433}
2434
2435fn is_podman_user_namespace(value: &str) -> bool {
2436    ["keep-id", "auto", "nomap"]
2437        .iter()
2438        .any(|mode| value == *mode || value.strip_prefix(mode).is_some_and(|suffix| suffix.starts_with(':')))
2439}
2440
2441fn implementation_version(version: PlatformVersion) -> Option<ImplementationVersion> {
2442    Some(ImplementationVersion::new(
2443        u32::try_from(version.major()).ok()?,
2444        u32::try_from(version.minor()).ok()?,
2445        u32::try_from(version.patch()).ok()?,
2446    ))
2447}