Skip to main content

boxferry_compose/
export.rs

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