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