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