Skip to main content

boxferry_compose/
import.rs

1//! Compose-to-application mapping with explicit fidelity decisions.
2
3use boxferry_engine::{
4    ConversionKind, ConversionOutcome, Diagnostic, DiagnosticCode, DiagnosticField, DiagnosticValue, ImportAdapter,
5    ImportResult, InvalidDiagnosticCode, NativeFinding, RuleId, Severity,
6};
7use std::collections::BTreeSet;
8
9use boxferry_model::{
10    Annotation, Application, BuildAttestation, BuildContext, BuildSettingValues, BuildSourceDeclaration, BuildSyntax,
11    Command, Config, ConfigMaterial, Device, Entrypoint as NeutralEntrypoint,
12    EnvironmentFile as NeutralEnvironmentFile, EnvironmentFileFormat as NeutralEnvironmentFileFormat,
13    EnvironmentFileSyntax, EnvironmentValue, EnvironmentVariable, ExposedPort, Healthcheck, HealthcheckCommand,
14    HealthcheckDuration as NeutralHealthcheckDuration, HealthcheckRetries as NeutralHealthcheckRetries, HostAddress,
15    HostMapping, Identifier, ImageArtifactAssignment, ImageBuild, ImageBuildSetting, ImageReference, KernelParameter,
16    Logging, LoggingOption, MetadataLabel, ModelError, Mount, MountSource, Network, NetworkAttachment,
17    NetworkDriverOption, NetworkIpamConfig, Port, ProtectedString, Protocol, Provenance,
18    PullPolicy as NeutralPullPolicy, ResourceGrant, ResourceGrantSyntax, ResourceLimit, ResourceOwnership,
19    RestartPolicy as NeutralRestartPolicy, Secret, SecretMaterial, SecurityOption, SelinuxRelabel, Service,
20    ServiceDependency, ServiceDependencyCondition, SourceBuildSecret, SourceBuildSetting, SourceSpan, Sourced,
21    StopTimeout, Volume,
22};
23use compose_lens::merge::MergeProvenance;
24use compose_lens::model::{
25    BooleanValue, ComposeScalar, ConfigDefinition, DependencyCondition as ComposeDependencyCondition,
26    Entrypoint as ComposeEntrypoint, EnvironmentFileFormatKind, ExposeItemKind, ExposeProtocol,
27    HealthcheckDuration as ComposeHealthcheckDuration, HealthcheckRetries as ComposeHealthcheckRetries,
28    HealthcheckTest, HealthcheckTestKind, HostnameKind, Labels, LimitValue, LongPort, LongVolumeMount, MemLimitKind,
29    MountType, NetworkDefinition, PidsLimitKind, Port as ComposePort, PullPolicyKind, ResourceExternal,
30    RestartPolicyKind as ComposeRestartPolicyKind, SecretDefinition, SecurityOptionKind,
31    SelinuxRelabel as ComposeSelinuxRelabel, ServiceNetwork, ServiceNetworks, ShmSizeKind, ShmSizeUnit,
32    ShortDeviceKind, ShortPort, ShortVolumeMount, TmpfsItemKind, VolumeDefinition, VolumeMount,
33};
34use compose_lens::project::{
35    ProjectBuild, ProjectBuildAdditionalContexts, ProjectBuildArgs, ProjectBuildDefinition, ProjectBuildExtraHosts,
36    ProjectBuildLabels, ProjectBuildNoCacheFilter, ProjectBuildSsh, ProjectDependsOn, ProjectDevice, ProjectDns,
37    ProjectDnsSearch, ProjectEnvironment, ProjectEnvironmentFile, ProjectFieldReference, ProjectGrant,
38    ProjectHealthcheck, ProjectLabels, ProjectLoggingOptionValue, ProjectResource, ProjectService, ProjectSysctls,
39    ProjectTmpfs, ProjectUlimitValue, ProjectValue, ProjectView, build_project_view,
40};
41use compose_lens::source::SourceSpan as ComposeSpan;
42
43use crate::ComposeSource;
44
45/// Maps an explicitly processed Compose source into `BoxFerry`'s neutral model.
46#[derive(Clone, Debug)]
47pub struct ComposeImporter {
48    codes: Codes,
49}
50
51impl ComposeImporter {
52    /// Creates an importer and validates its stable machine-readable diagnostic codes.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`InvalidDiagnosticCode`] only when a code embedded in this adapter is invalid.
57    pub fn new() -> Result<Self, InvalidDiagnosticCode> {
58        Ok(Self {
59            codes: Codes {
60                invalid_model: RuleId::ComposeModelInvalid.definition().diagnostic_code()?,
61                profile_required: RuleId::ComposeProfileRequired.definition().diagnostic_code()?,
62                profile_mismatch: RuleId::ComposeProfileMismatch.definition().diagnostic_code()?,
63                unsupported: RuleId::ComposeIntentUnsupported.definition().diagnostic_code()?,
64                invalid_value: RuleId::ComposeValueInvalid.definition().diagnostic_code()?,
65                unset_variable: RuleId::ComposeUnsetVariable.definition().diagnostic_code()?,
66                required_variable: RuleId::ComposeRequiredVariable.definition().diagnostic_code()?,
67                interpolation_invalid: RuleId::ComposeInterpolationInvalid.definition().diagnostic_code()?,
68                interpolation_nesting: RuleId::ComposeInterpolationNestingLimit
69                    .definition()
70                    .diagnostic_code()?,
71                unresolved_variable: RuleId::ComposeUnresolvedVariable.definition().diagnostic_code()?,
72                native_error: RuleId::ComposeNativeError.definition().diagnostic_code()?,
73                native_warning: RuleId::ComposeNativeWarning.definition().diagnostic_code()?,
74                native_note: RuleId::ComposeNativeNote.definition().diagnostic_code()?,
75            },
76        })
77    }
78}
79
80impl ImportAdapter for ComposeImporter {
81    type Source = ComposeSource;
82
83    fn import(&self, source: &Self::Source) -> ImportResult {
84        let mut mapping = Mapping::new(&self.codes, source);
85        mapping.report_native_findings(source.native_findings());
86        let project_result = build_project_view(source.project(), source.profile_selection());
87        let Some(view) = project_result.view() else {
88            mapping.invalid_optional_span(
89                self.codes.profile_mismatch.clone(),
90                "services.profiles",
91                "profile selection does not belong to the merged Compose project",
92                "profile selection",
93                source.project().root().provenance().effective_source(),
94            );
95            return ImportResult::new(None, mapping.outcomes, mapping.diagnostics);
96        };
97        mapping.report_project_diagnostics(project_result.diagnostics());
98
99        let application_name = view.name().map_or_else(
100            || source.fallback_application_name().clone(),
101            |name| {
102                Identifier::new(name.value().clone()).unwrap_or_else(|error| {
103                    mapping.invalid_model_optional("application.name", &error, name.effective_source());
104                    source.fallback_application_name().clone()
105                })
106            },
107        );
108        let mut application = Application::new(application_name);
109        mapping.exact_provenance("application", view.provenance());
110        if let Some(version) = view.version() {
111            // Compose `version` is an obsolete marker, not a provider selector. Dropping it at
112            // this cross-format neutral boundary is therefore exact normalization; ComposeLens
113            // retains and reports its native obsolete-version warning independently.
114            mapping.exact_provenance("application.version", version.provenance());
115        }
116
117        let profiles_valid = mapping.validate_profiles(view, source);
118
119        for definition in view.volumes() {
120            if let Some(volume) = mapping.map_volume_definition(definition) {
121                if let Err(error) = application.add_volume(volume) {
122                    mapping.invalid_model_optional("volumes", &error, definition.definition().effective_source());
123                }
124            }
125        }
126        for definition in view.networks() {
127            if let Some(network) = mapping.map_network_definition(definition) {
128                if let Err(error) = application.add_network(network) {
129                    mapping.invalid_model_optional("networks", &error, definition.definition().effective_source());
130                }
131            }
132        }
133        for definition in view.configs() {
134            if let Some(config) = mapping.map_config_definition(definition) {
135                if let Err(error) = application.add_config(config) {
136                    mapping.invalid_model_optional("configs", &error, definition.definition().effective_source());
137                }
138            }
139        }
140        for definition in view.secrets() {
141            if let Some(secret) = mapping.map_secret_definition(definition) {
142                if let Err(error) = application.add_secret(secret) {
143                    mapping.invalid_model_optional("secrets", &error, definition.definition().effective_source());
144                }
145            }
146        }
147        for native_service in view.services() {
148            let active = if native_service
149                .profiles()
150                .is_none_or(|profiles| profiles.value().is_empty())
151            {
152                true
153            } else {
154                profiles_valid
155                    && source
156                        .profile_selection()
157                        .is_some_and(|selection| selection.is_active(native_service.name().value()))
158            };
159            if !active {
160                continue;
161            }
162            if let Some(service) = mapping.map_service(native_service) {
163                let (mut service, origins) = service.into_parts();
164                if let Some(build) = mapping.map_service_build(service.name(), service.image(), native_service) {
165                    let build_name = build.value().name().clone();
166                    let build_origins = build.origins().to_vec();
167                    if let Err(error) = application.add_image_build(build) {
168                        mapping.invalid_model_optional("image_builds", &error, view.provenance().effective_source());
169                    } else {
170                        let mut reference = Sourced::generated(build_name);
171                        for origin in build_origins {
172                            reference.add_origin(origin);
173                        }
174                        service.set_image_build(reference);
175                    }
176                }
177                let mut service = Sourced::generated(service);
178                for origin in origins {
179                    service.add_origin(origin);
180                }
181                if let Err(error) = application.add_service(service) {
182                    mapping.invalid_model_optional("services", &error, view.provenance().effective_source());
183                }
184            }
185        }
186
187        mapping.report_document_unsupported(view);
188        ImportResult::new(Some(application), mapping.outcomes, mapping.diagnostics)
189    }
190}
191
192#[derive(Clone, Debug)]
193struct Codes {
194    invalid_model: DiagnosticCode,
195    profile_required: DiagnosticCode,
196    profile_mismatch: DiagnosticCode,
197    unsupported: DiagnosticCode,
198    invalid_value: DiagnosticCode,
199    unset_variable: DiagnosticCode,
200    required_variable: DiagnosticCode,
201    interpolation_invalid: DiagnosticCode,
202    interpolation_nesting: DiagnosticCode,
203    unresolved_variable: DiagnosticCode,
204    native_error: DiagnosticCode,
205    native_warning: DiagnosticCode,
206    native_note: DiagnosticCode,
207}
208
209enum SecurityOptionMapping {
210    Exact(SecurityOption),
211    Invalid(&'static str),
212    Unsupported(&'static str),
213}
214
215struct Mapping<'a> {
216    codes: &'a Codes,
217    source: &'a ComposeSource,
218    outcomes: Vec<ConversionOutcome>,
219    diagnostics: Vec<Diagnostic>,
220}
221
222impl<'a> Mapping<'a> {
223    const fn new(codes: &'a Codes, source: &'a ComposeSource) -> Self {
224        Self {
225            codes,
226            source,
227            outcomes: Vec::new(),
228            diagnostics: Vec::new(),
229        }
230    }
231
232    fn validate_profiles(&mut self, view: &ProjectView, source: &ComposeSource) -> bool {
233        if view
234            .services()
235            .iter()
236            .all(|service| service.profiles().is_none_or(|profiles| profiles.value().is_empty()))
237        {
238            return true;
239        }
240        let Some(selection) = source.profile_selection() else {
241            self.invalid_optional_span(
242                self.codes.profile_required.clone(),
243                "services.profiles",
244                "profiled Compose services require an explicit ComposeLens profile selection",
245                "profile selection",
246                view.provenance().effective_source(),
247            );
248            return false;
249        };
250        if !selection.is_valid() {
251            self.invalid_optional_span(
252                self.codes.profile_mismatch.clone(),
253                "services.profiles",
254                "profile selection is invalid or does not belong to the merged Compose project",
255                "profile selection",
256                view.provenance().effective_source(),
257            );
258            return false;
259        }
260        true
261    }
262
263    fn map_service(&mut self, native: &ProjectService) -> Option<Sourced<Service>> {
264        let subject = format!("services.{}", native.name().value());
265        let name = self.identifier_optional(
266            &format!("{subject}.name"),
267            native.name().value(),
268            native.name().effective_source(),
269        )?;
270        let mut service = Service::new(name);
271
272        self.map_runtime_name(&subject, native, &mut service);
273        self.map_restart_policy(&subject, native, &mut service);
274        self.map_service_image(&subject, native, &mut service);
275        if let Some(command) = native.command() {
276            if let Some(value) = Self::map_command(command.value(), command.is_sensitive()) {
277                service.set_command(self.sourced_provenance(value, command.provenance()));
278                self.exact_provenance(format!("{subject}.command"), command.provenance());
279            }
280        }
281        if let Some(healthcheck) = native.healthcheck() {
282            service.set_healthcheck(self.map_healthcheck(&subject, healthcheck));
283        }
284        self.map_execution_context(&subject, native, &mut service);
285        self.map_released_container_settings(&subject, native, &mut service);
286        self.map_dns(&subject, native, &mut service);
287        self.map_security_options(&subject, native, &mut service);
288        self.map_service_environment(&subject, native, &mut service);
289        self.map_service_labels(&subject, native, &mut service);
290        if let Some(extra_hosts) = native.extra_hosts() {
291            for (index, entry) in extra_hosts.value().entries().iter().enumerate() {
292                let host_subject = format!("{subject}.extra_hosts[{index}]");
293                let Some(hostname) = self.identifier_optional(
294                    &host_subject,
295                    entry.hostname().value(),
296                    entry.hostname().effective_source(),
297                ) else {
298                    continue;
299                };
300                let address = match HostAddress::new(entry.address().value().raw()) {
301                    Ok(address) => address,
302                    Err(error) => {
303                        self.invalid_model_optional(&host_subject, &error, entry.address().effective_source());
304                        continue;
305                    }
306                };
307                let mapping = self.sourced_host_mapping(
308                    HostMapping::new(hostname, address),
309                    entry.hostname().sources(),
310                    entry.address().provenance(),
311                );
312                self.exact_origins(host_subject, mapping.origins());
313                service.add_host_mapping(mapping);
314            }
315        }
316        if let Some(ports) = native.ports() {
317            for (index, port) in ports.value().iter().enumerate() {
318                let port_subject = format!("{subject}.ports[{index}]");
319                if let Some(value) = self.map_port(&port_subject, port.value()) {
320                    service.add_port(self.sourced_provenance(value, port.provenance()));
321                    self.exact_provenance(port_subject, port.provenance());
322                }
323            }
324        }
325        if let Some(volumes) = native.volumes() {
326            for (index, mount) in volumes.value().iter().enumerate() {
327                let mount_subject = format!("{subject}.volumes[{index}]");
328                if let Some(value) = self.map_mount(&mount_subject, mount.value()) {
329                    service.add_mount(self.sourced_provenance(value, mount.provenance()));
330                    self.exact_provenance(mount_subject, mount.provenance());
331                }
332            }
333        }
334        if let Some(configs) = native.configs() {
335            self.map_service_grants(&subject, "configs", configs, false, &mut service);
336        }
337        if let Some(secrets) = native.secrets() {
338            self.map_service_grants(&subject, "secrets", secrets, true, &mut service);
339        }
340        if let Some(networks) = native.networks() {
341            self.map_service_networks(&subject, networks.value(), networks.provenance(), &mut service);
342        }
343        if let Some(profiles) = native.profiles() {
344            if !profiles.value().is_empty() {
345                self.exact_provenance(format!("{subject}.profiles"), profiles.provenance());
346            }
347        }
348        if let Some(dependencies) = native.depends_on() {
349            self.map_service_dependencies(&subject, dependencies, &mut service);
350        }
351
352        self.report_service_unsupported(&subject, native);
353        self.exact_provenance(&subject, native.provenance());
354        Some(self.sourced_provenance(service, native.provenance()))
355    }
356
357    fn map_service_image(&mut self, service_subject: &str, native: &ProjectService, service: &mut Service) {
358        let Some(image) = native.image() else {
359            return;
360        };
361        let subject = format!("{service_subject}.image");
362        if image.value().raw().contains('$') {
363            self.report_unresolved_variable(
364                &subject,
365                image.value().raw(),
366                "image reference contains unresolved Compose variable syntax",
367            );
368        }
369        match ImageReference::parse(image.value().raw()) {
370            Ok(value) => {
371                service.set_image(self.sourced_provenance(value, image.provenance()));
372                self.exact_provenance(subject, image.provenance());
373            }
374            Err(error) => self.invalid_model_optional(&subject, &error, image.effective_source()),
375        }
376    }
377
378    fn map_runtime_name(&mut self, subject: &str, native: &ProjectService, service: &mut Service) {
379        let Some(container_name) = native.container_name() else {
380            return;
381        };
382        service.set_runtime_name(self.sourced_provenance(
383            ProtectedString::plain(container_name.value()),
384            container_name.provenance(),
385        ));
386        self.exact_provenance(format!("{subject}.container_name"), container_name.provenance());
387    }
388
389    fn map_service_build(
390        &mut self,
391        service_name: &Identifier,
392        service_image: Option<&Sourced<ImageReference>>,
393        native: &ProjectService,
394    ) -> Option<Sourced<ImageBuild>> {
395        let build = native.build()?;
396        let subject = format!("services.{}.build", service_name.as_str());
397        let name = match Identifier::new(format!("{}-build", service_name.as_str())) {
398            Ok(name) => name,
399            Err(error) => {
400                self.invalid_model_optional(&subject, &error, build.effective_source());
401                return None;
402            }
403        };
404        let mut neutral = ImageBuild::new(name);
405        match build.value() {
406            ProjectBuild::Context(context) => {
407                neutral.set_source_declaration(self.sourced_provenance(
408                    BuildSourceDeclaration::Scalar(Self::protected(context.value(), context.is_sensitive())),
409                    context.provenance(),
410                ));
411                self.exact_provenance(&subject, context.provenance());
412            }
413            ProjectBuild::Definition(definition) => self.map_build_definition(&subject, definition, &mut neutral),
414            _ => self.unsupported_optional(
415                &subject,
416                "build declaration variant is newer than this Compose adapter",
417                build.effective_source(),
418            ),
419        }
420        Self::add_service_image_build_tag(&mut neutral, service_image);
421        Some(self.sourced_provenance(neutral, build.provenance()))
422    }
423
424    fn add_service_image_build_tag(build: &mut ImageBuild, service_image: Option<&Sourced<ImageReference>>) {
425        let Some(service_image) = service_image else { return };
426        let image = service_image.value().as_str();
427        let mut settings = build.settings().map_or_else(Vec::new, <[_]>::to_vec);
428        let duplicate = settings.iter().any(|setting| {
429            matches!(
430                setting.value(),
431                ImageBuildSetting::ImageTags(values)
432                    if values.values().iter().any(|value| value.value().expose() == image)
433            )
434        });
435        if !duplicate {
436            let mut tag = Sourced::generated(ProtectedString::plain(image));
437            for origin in service_image.origins() {
438                tag.add_origin(origin.clone());
439            }
440            let mut setting = Sourced::generated(ImageBuildSetting::ImageTags(BuildSettingValues::new(
441                BuildSyntax::Scalar,
442                vec![tag],
443            )));
444            for origin in service_image.origins() {
445                setting.add_origin(origin.clone());
446            }
447            settings.insert(0, setting);
448        }
449        build.set_settings(settings);
450    }
451
452    fn map_build_definition(&mut self, subject: &str, definition: &ProjectBuildDefinition, build: &mut ImageBuild) {
453        let mut settings = Vec::new();
454        let mut overlap = Vec::new();
455
456        self.map_build_direct_settings(definition, &mut settings, &mut overlap);
457        self.map_build_boolean_settings(subject, definition, &mut settings);
458        self.map_build_attestations(definition, &mut settings);
459        self.map_build_string_collections(definition, &mut settings, &mut overlap);
460        self.map_build_additional_contexts(definition.additional_contexts(), &mut settings);
461        self.map_build_args(definition.args(), &mut settings, &mut overlap);
462        self.map_build_labels(definition.labels(), &mut settings, &mut overlap);
463        self.map_build_extra_hosts(definition.extra_hosts(), &mut settings);
464        self.map_build_no_cache_filter(definition.no_cache_filter(), &mut settings);
465        self.map_build_ssh(definition.ssh(), &mut settings);
466        self.map_build_secrets(definition.secrets(), &mut settings);
467        self.map_build_ulimits(definition.ulimits(), &mut settings);
468        self.report_project_fields(subject, "build field", definition.unmodeled_fields());
469
470        settings.sort_by_key(|setting| {
471            setting
472                .origins()
473                .last()
474                .and_then(Provenance::span)
475                .map_or(usize::MAX, SourceSpan::start)
476        });
477        build.set_source_declaration(Sourced::generated(BuildSourceDeclaration::Structured(settings)));
478        if !overlap.is_empty() {
479            build.set_settings(overlap);
480        }
481    }
482
483    fn map_build_direct_settings(
484        &self,
485        definition: &ProjectBuildDefinition,
486        settings: &mut Vec<Sourced<SourceBuildSetting>>,
487        overlap: &mut Vec<Sourced<ImageBuildSetting>>,
488    ) {
489        if let Some(context) = definition.context() {
490            settings.push(self.sourced_provenance(
491                SourceBuildSetting::Context(Self::protected(context.value(), context.is_sensitive())),
492                context.provenance(),
493            ));
494        }
495        if let Some(dockerfile) = definition.dockerfile() {
496            let value = Self::protected(dockerfile.value(), dockerfile.is_sensitive());
497            settings
498                .push(self.sourced_provenance(SourceBuildSetting::RecipeFile(value.clone()), dockerfile.provenance()));
499            overlap.push(self.sourced_provenance(ImageBuildSetting::RecipeFile(value), dockerfile.provenance()));
500        }
501        if let Some(inline) = definition.dockerfile_inline() {
502            settings.push(self.sourced_provenance(
503                SourceBuildSetting::InlineRecipe(Self::protected(inline.value(), inline.is_sensitive())),
504                inline.provenance(),
505            ));
506        }
507        if let Some(target) = definition.target() {
508            let value = Self::protected(target.value(), target.is_sensitive());
509            settings.push(self.sourced_provenance(SourceBuildSetting::Target(value.clone()), target.provenance()));
510            overlap.push(self.sourced_provenance(ImageBuildSetting::Target(value), target.provenance()));
511        }
512        if let Some(network) = definition.network() {
513            settings.push(self.sourced_provenance(
514                SourceBuildSetting::Network(Self::protected(network.value(), network.is_sensitive())),
515                network.provenance(),
516            ));
517        }
518        if let Some(isolation) = definition.isolation() {
519            settings.push(self.sourced_provenance(
520                SourceBuildSetting::Isolation(Self::protected(isolation.value(), isolation.is_sensitive())),
521                isolation.provenance(),
522            ));
523        }
524        if let Some(shm_size) = definition.shm_size() {
525            settings.push(self.sourced_provenance(
526                SourceBuildSetting::ShmSize(Self::protected(shm_size.value().raw().value(), shm_size.is_sensitive())),
527                shm_size.provenance(),
528            ));
529        }
530    }
531
532    fn map_build_boolean_settings(
533        &mut self,
534        subject: &str,
535        definition: &ProjectBuildDefinition,
536        settings: &mut Vec<Sourced<SourceBuildSetting>>,
537    ) {
538        if let Some(privileged) = definition.privileged() {
539            if let BooleanValue::Literal(value) = privileged.value() {
540                settings.push(self.sourced_provenance(SourceBuildSetting::Privileged(*value), privileged.provenance()));
541            } else {
542                self.invalid_value_optional(
543                    &format!("{subject}.privileged"),
544                    "build privileged expression was not resolved",
545                    privileged.effective_source(),
546                );
547            }
548        }
549        if let Some(pull) = definition.pull() {
550            if let BooleanValue::Literal(value) = pull.value() {
551                settings.push(self.sourced_provenance(SourceBuildSetting::Pull(*value), pull.provenance()));
552            } else {
553                self.invalid_value_optional(
554                    &format!("{subject}.pull"),
555                    "build pull expression was not resolved",
556                    pull.effective_source(),
557                );
558            }
559        }
560        if let Some(no_cache) = definition.no_cache() {
561            match no_cache.value() {
562                compose_lens::model::BuildNoCache::Boolean(value) => {
563                    settings.push(self.sourced_provenance(SourceBuildSetting::NoCache(*value), no_cache.provenance()));
564                }
565                compose_lens::model::BuildNoCache::String(_) => self.unsupported_optional(
566                    &format!("{subject}.no_cache"),
567                    "string build no_cache values have no equivalent boolean neutral value",
568                    no_cache.effective_source(),
569                ),
570            }
571        }
572    }
573
574    fn map_build_attestations(
575        &self,
576        definition: &ProjectBuildDefinition,
577        settings: &mut Vec<Sourced<SourceBuildSetting>>,
578    ) {
579        if let Some(sbom) = definition.sbom() {
580            let value = match sbom.value() {
581                compose_lens::model::BuildSbom::Boolean(value) => BuildAttestation::Boolean(*value),
582                compose_lens::model::BuildSbom::String(value) => {
583                    BuildAttestation::Value(Self::protected(value, sbom.is_sensitive()))
584                }
585            };
586            settings.push(self.sourced_provenance(SourceBuildSetting::Sbom(value), sbom.provenance()));
587        }
588        if let Some(provenance) = definition.provenance() {
589            let value = match provenance.value() {
590                compose_lens::model::BuildProvenance::Boolean(value) => BuildAttestation::Boolean(*value),
591                compose_lens::model::BuildProvenance::String(value) => {
592                    BuildAttestation::Value(Self::protected(value, provenance.is_sensitive()))
593                }
594            };
595            settings.push(self.sourced_provenance(SourceBuildSetting::Provenance(value), provenance.provenance()));
596        }
597    }
598
599    fn map_build_string_collections(
600        &self,
601        definition: &ProjectBuildDefinition,
602        settings: &mut Vec<Sourced<SourceBuildSetting>>,
603        overlap: &mut Vec<Sourced<ImageBuildSetting>>,
604    ) {
605        self.map_build_string_list(
606            definition.entitlements(),
607            BuildSyntax::Sequence,
608            SourceBuildSetting::Entitlements,
609            settings,
610        );
611        self.map_build_string_list(
612            definition.cache_from(),
613            BuildSyntax::Sequence,
614            SourceBuildSetting::CacheFrom,
615            settings,
616        );
617        self.map_build_string_list(
618            definition.cache_to(),
619            BuildSyntax::Sequence,
620            SourceBuildSetting::CacheTo,
621            settings,
622        );
623        self.map_build_string_list(
624            definition.platforms(),
625            BuildSyntax::Sequence,
626            SourceBuildSetting::Platforms,
627            settings,
628        );
629        self.map_build_string_list(
630            definition.tags(),
631            BuildSyntax::Sequence,
632            SourceBuildSetting::Tags,
633            settings,
634        );
635        if let Some(tags) = definition.tags() {
636            let values = tags
637                .value()
638                .iter()
639                .map(|value| {
640                    self.sourced_provenance(Self::protected(value.value(), value.is_sensitive()), value.provenance())
641                })
642                .collect();
643            overlap.push(self.sourced_provenance(
644                ImageBuildSetting::ImageTags(BuildSettingValues::new(BuildSyntax::Sequence, values)),
645                tags.provenance(),
646            ));
647        }
648    }
649
650    fn map_build_string_list(
651        &self,
652        native: Option<&ProjectValue<Vec<ProjectValue<String>>>>,
653        syntax: BuildSyntax,
654        constructor: fn(BuildSettingValues<ProtectedString>) -> SourceBuildSetting,
655        settings: &mut Vec<Sourced<SourceBuildSetting>>,
656    ) {
657        let Some(native) = native else { return };
658        let values = native
659            .value()
660            .iter()
661            .map(|value| {
662                self.sourced_provenance(Self::protected(value.value(), value.is_sensitive()), value.provenance())
663            })
664            .collect();
665        settings.push(self.sourced_provenance(
666            constructor(BuildSettingValues::new(syntax, values)),
667            native.provenance(),
668        ));
669    }
670
671    fn map_build_additional_contexts(
672        &self,
673        native: Option<&ProjectValue<ProjectBuildAdditionalContexts>>,
674        settings: &mut Vec<Sourced<SourceBuildSetting>>,
675    ) {
676        let Some(native) = native else { return };
677        let (syntax, values) = match native.value() {
678            ProjectBuildAdditionalContexts::Map(entries) => (
679                BuildSyntax::Mapping,
680                entries
681                    .iter()
682                    .map(|entry| {
683                        self.sourced_project_key_value(
684                            BuildContext::new(
685                                Self::protected(entry.name().value(), entry.name().is_sensitive()),
686                                Self::protected(
687                                    scalar_text(entry.value().value()).as_str(),
688                                    entry.value().is_sensitive(),
689                                ),
690                            ),
691                            entry.name().sources(),
692                            entry.value().provenance(),
693                        )
694                    })
695                    .collect::<Vec<_>>(),
696            ),
697            ProjectBuildAdditionalContexts::List(entries) => (
698                BuildSyntax::Sequence,
699                entries
700                    .iter()
701                    .map(|entry| {
702                        self.sourced_provenance(
703                            BuildContext::new(
704                                Self::protected(entry.value(), entry.is_sensitive()),
705                                ProtectedString::plain(""),
706                            ),
707                            entry.provenance(),
708                        )
709                    })
710                    .collect::<Vec<_>>(),
711            ),
712            _ => return,
713        };
714        settings.push(self.sourced_provenance(
715            SourceBuildSetting::AdditionalContexts(BuildSettingValues::new(syntax, values)),
716            native.provenance(),
717        ));
718    }
719
720    fn map_build_args(
721        &self,
722        native: Option<&ProjectValue<ProjectBuildArgs>>,
723        settings: &mut Vec<Sourced<SourceBuildSetting>>,
724        overlap: &mut Vec<Sourced<ImageBuildSetting>>,
725    ) {
726        let Some(native) = native else { return };
727        let (syntax, values, overlap_values) = match native.value() {
728            ProjectBuildArgs::Map(entries) => (
729                BuildSyntax::Mapping,
730                entries
731                    .iter()
732                    .map(|entry| {
733                        self.sourced_project_key_value(
734                            ImageArtifactAssignment::new(
735                                Self::protected(entry.name().value(), entry.name().is_sensitive()),
736                                scalar_option(entry.value().value())
737                                    .map(|value| Self::protected(&value, entry.value().is_sensitive())),
738                            ),
739                            entry.name().sources(),
740                            entry.value().provenance(),
741                        )
742                    })
743                    .collect::<Vec<_>>(),
744                entries
745                    .iter()
746                    .filter(|entry| scalar_option(entry.value().value()).is_some())
747                    .map(|entry| {
748                        self.sourced_project_key_value(
749                            ImageArtifactAssignment::new(
750                                Self::protected(entry.name().value(), entry.name().is_sensitive()),
751                                scalar_option(entry.value().value())
752                                    .map(|value| Self::protected(&value, entry.value().is_sensitive())),
753                            ),
754                            entry.name().sources(),
755                            entry.value().provenance(),
756                        )
757                    })
758                    .collect::<Vec<_>>(),
759            ),
760            ProjectBuildArgs::List(entries) => (
761                BuildSyntax::Sequence,
762                entries
763                    .iter()
764                    .map(|entry| {
765                        self.sourced_provenance(
766                            ImageArtifactAssignment::new(Self::protected(entry.value(), entry.is_sensitive()), None),
767                            entry.provenance(),
768                        )
769                    })
770                    .collect(),
771                entries
772                    .iter()
773                    .filter_map(|entry| {
774                        let (name, value) = literal_assignment(entry.value())?;
775                        Some(self.sourced_provenance(
776                            ImageArtifactAssignment::new(
777                                Self::protected(name, entry.is_sensitive()),
778                                Some(Self::protected(value, entry.is_sensitive())),
779                            ),
780                            entry.provenance(),
781                        ))
782                    })
783                    .collect(),
784            ),
785            _ => return,
786        };
787        let source_values = BuildSettingValues::new(syntax, values.clone());
788        settings.push(self.sourced_provenance(SourceBuildSetting::Arguments(source_values), native.provenance()));
789        if !overlap_values.is_empty() {
790            overlap.push(self.sourced_provenance(
791                ImageBuildSetting::BuildArguments(BuildSettingValues::new(syntax, overlap_values)),
792                native.provenance(),
793            ));
794        }
795    }
796
797    fn map_build_labels(
798        &self,
799        native: Option<&ProjectValue<ProjectBuildLabels>>,
800        settings: &mut Vec<Sourced<SourceBuildSetting>>,
801        overlap: &mut Vec<Sourced<ImageBuildSetting>>,
802    ) {
803        let Some(native) = native else { return };
804        let (syntax, values) = match native.value() {
805            ProjectBuildLabels::Map(entries) => (
806                BuildSyntax::Mapping,
807                entries
808                    .iter()
809                    .map(|entry| {
810                        self.sourced_project_key_value(
811                            ImageArtifactAssignment::new(
812                                Self::protected(entry.name().value(), entry.name().is_sensitive()),
813                                scalar_option(entry.value().value())
814                                    .map(|value| Self::protected(&value, entry.value().is_sensitive())),
815                            ),
816                            entry.name().sources(),
817                            entry.value().provenance(),
818                        )
819                    })
820                    .collect::<Vec<_>>(),
821            ),
822            ProjectBuildLabels::List(entries) => (
823                BuildSyntax::Sequence,
824                entries
825                    .iter()
826                    .map(|entry| {
827                        self.sourced_provenance(
828                            ImageArtifactAssignment::new(Self::protected(entry.value(), entry.is_sensitive()), None),
829                            entry.provenance(),
830                        )
831                    })
832                    .collect(),
833            ),
834            _ => return,
835        };
836        settings.push(self.sourced_provenance(
837            SourceBuildSetting::Labels(BuildSettingValues::new(syntax, values.clone())),
838            native.provenance(),
839        ));
840        overlap.push(self.sourced_provenance(
841            ImageBuildSetting::Labels(BuildSettingValues::new(syntax, values)),
842            native.provenance(),
843        ));
844    }
845
846    fn map_build_extra_hosts(
847        &self,
848        native: Option<&ProjectValue<ProjectBuildExtraHosts>>,
849        settings: &mut Vec<Sourced<SourceBuildSetting>>,
850    ) {
851        let Some(native) = native else { return };
852        let (syntax, values) = match native.value() {
853            ProjectBuildExtraHosts::List(entries) => (
854                BuildSyntax::Sequence,
855                entries
856                    .iter()
857                    .map(|entry| {
858                        self.sourced_provenance(
859                            ImageArtifactAssignment::new(Self::protected(entry.value(), entry.is_sensitive()), None),
860                            entry.provenance(),
861                        )
862                    })
863                    .collect(),
864            ),
865            ProjectBuildExtraHosts::Map(entries) => (
866                BuildSyntax::Mapping,
867                entries
868                    .iter()
869                    .flat_map(|entry| match entry.addresses() {
870                        compose_lens::project::ProjectBuildExtraHostAddresses::Scalar(value) => {
871                            vec![self.sourced_project_key_value(
872                                ImageArtifactAssignment::new(
873                                    Self::protected(entry.hostname().value(), entry.hostname().is_sensitive()),
874                                    Some(Self::protected(value.value(), value.is_sensitive())),
875                                ),
876                                entry.hostname().sources(),
877                                value.provenance(),
878                            )]
879                        }
880                        compose_lens::project::ProjectBuildExtraHostAddresses::List(values) => values
881                            .iter()
882                            .map(|value| {
883                                self.sourced_project_key_value(
884                                    ImageArtifactAssignment::new(
885                                        Self::protected(entry.hostname().value(), entry.hostname().is_sensitive()),
886                                        Some(Self::protected(value.value(), value.is_sensitive())),
887                                    ),
888                                    entry.hostname().sources(),
889                                    value.provenance(),
890                                )
891                            })
892                            .collect(),
893                        _ => Vec::new(),
894                    })
895                    .collect(),
896            ),
897            _ => return,
898        };
899        settings.push(self.sourced_provenance(
900            SourceBuildSetting::ExtraHosts(BuildSettingValues::new(syntax, values)),
901            native.provenance(),
902        ));
903    }
904
905    fn map_build_no_cache_filter(
906        &self,
907        native: Option<&ProjectValue<ProjectBuildNoCacheFilter>>,
908        settings: &mut Vec<Sourced<SourceBuildSetting>>,
909    ) {
910        let Some(native) = native else { return };
911        let (syntax, values) = match native.value() {
912            ProjectBuildNoCacheFilter::Scalar(value) => (
913                BuildSyntax::Scalar,
914                vec![self.sourced_provenance(Self::protected(value.value(), value.is_sensitive()), value.provenance())],
915            ),
916            ProjectBuildNoCacheFilter::List(values) => (
917                BuildSyntax::Sequence,
918                values
919                    .iter()
920                    .map(|value| {
921                        self.sourced_provenance(
922                            Self::protected(value.value(), value.is_sensitive()),
923                            value.provenance(),
924                        )
925                    })
926                    .collect(),
927            ),
928            _ => return,
929        };
930        settings.push(self.sourced_provenance(
931            SourceBuildSetting::NoCacheFilters(BuildSettingValues::new(syntax, values)),
932            native.provenance(),
933        ));
934    }
935
936    fn map_build_ssh(
937        &self,
938        native: Option<&ProjectValue<ProjectBuildSsh>>,
939        settings: &mut Vec<Sourced<SourceBuildSetting>>,
940    ) {
941        let Some(native) = native else { return };
942        let (syntax, values) = match native.value() {
943            ProjectBuildSsh::List(entries) => (
944                BuildSyntax::Sequence,
945                entries
946                    .iter()
947                    .map(|entry| self.sourced_provenance(ProtectedString::sensitive(entry.value()), entry.provenance()))
948                    .collect(),
949            ),
950            ProjectBuildSsh::Map(entries) => (
951                BuildSyntax::Mapping,
952                entries
953                    .iter()
954                    .map(|entry| {
955                        self.sourced_project_key_value(
956                            ProtectedString::sensitive(format!(
957                                "{}={}",
958                                entry.name().value(),
959                                scalar_text(entry.value().value())
960                            )),
961                            entry.name().sources(),
962                            entry.value().provenance(),
963                        )
964                    })
965                    .collect(),
966            ),
967            _ => return,
968        };
969        settings.push(self.sourced_provenance(
970            SourceBuildSetting::Ssh(BuildSettingValues::new(syntax, values)),
971            native.provenance(),
972        ));
973    }
974
975    fn map_build_secrets(
976        &self,
977        native: Option<&ProjectValue<Vec<ProjectValue<ProjectGrant>>>>,
978        settings: &mut Vec<Sourced<SourceBuildSetting>>,
979    ) {
980        let Some(native) = native else { return };
981        let values = native
982            .value()
983            .iter()
984            .filter_map(|grant| match grant.value() {
985                ProjectGrant::Short(source) => Some(self.sourced_provenance(
986                    SourceBuildSecret::new(Self::protected(source, grant.is_sensitive())),
987                    grant.provenance(),
988                )),
989                ProjectGrant::Long(long) => {
990                    let source = long.source()?;
991                    let mut secret = SourceBuildSecret::new(Self::protected(source.value(), source.is_sensitive()));
992                    if let Some(target) = long.target() {
993                        secret.set_target(Self::protected(target.value(), target.is_sensitive()));
994                    }
995                    if let Some(uid) = long.uid() {
996                        secret.set_uid(Self::protected(uid.value(), uid.is_sensitive()));
997                    }
998                    if let Some(gid) = long.gid() {
999                        secret.set_gid(Self::protected(gid.value(), gid.is_sensitive()));
1000                    }
1001                    if let Some(mode) = long.mode() {
1002                        secret.set_mode(Self::protected(mode.value(), mode.is_sensitive()));
1003                    }
1004                    Some(self.sourced_provenance(secret, grant.provenance()))
1005                }
1006            })
1007            .collect();
1008        settings.push(self.sourced_provenance(
1009            SourceBuildSetting::Secrets(BuildSettingValues::new(BuildSyntax::Sequence, values)),
1010            native.provenance(),
1011        ));
1012    }
1013
1014    fn map_build_ulimits(
1015        &self,
1016        native: Option<&ProjectValue<compose_lens::project::ProjectUlimits>>,
1017        settings: &mut Vec<Sourced<SourceBuildSetting>>,
1018    ) {
1019        let Some(native) = native else { return };
1020        let values = native
1021            .value()
1022            .entries()
1023            .iter()
1024            .map(|entry| {
1025                let value = entry.value();
1026                let (soft, hard) = match value.value() {
1027                    ProjectUlimitValue::Single(value) => {
1028                        let value = self.sourced_provenance(
1029                            Self::protected(value.value().value().raw(), value.is_sensitive()),
1030                            value.provenance(),
1031                        );
1032                        (Some(value.clone()), Some(value))
1033                    }
1034                    ProjectUlimitValue::Range(range) => (
1035                        range.soft().map(|value| {
1036                            self.sourced_provenance(
1037                                Self::protected(value.value().value().raw(), value.is_sensitive()),
1038                                value.provenance(),
1039                            )
1040                        }),
1041                        range.hard().map(|value| {
1042                            self.sourced_provenance(
1043                                Self::protected(value.value().value().raw(), value.is_sensitive()),
1044                                value.provenance(),
1045                            )
1046                        }),
1047                    ),
1048                    _ => (None, None),
1049                };
1050                self.sourced_project_key_value(
1051                    ResourceLimit::new(
1052                        Self::protected(entry.value().name().value(), entry.value().name().is_sensitive()),
1053                        soft,
1054                        hard,
1055                    ),
1056                    entry.value().name().sources(),
1057                    entry.provenance(),
1058                )
1059            })
1060            .collect();
1061        settings.push(self.sourced_provenance(
1062            SourceBuildSetting::Ulimits(BuildSettingValues::new(BuildSyntax::Mapping, values)),
1063            native.provenance(),
1064        ));
1065    }
1066
1067    fn map_service_labels(&mut self, subject: &str, native: &ProjectService, service: &mut Service) {
1068        if let Some(labels) = native.labels() {
1069            self.map_labels(subject, labels.value(), service);
1070        }
1071    }
1072
1073    fn map_restart_policy(&mut self, subject: &str, native: &ProjectService, service: &mut Service) {
1074        let Some(restart) = native.restart() else {
1075            return;
1076        };
1077        let restart_subject = format!("{subject}.restart_policy");
1078        let policy = match restart.value().kind() {
1079            ComposeRestartPolicyKind::No => NeutralRestartPolicy::Never,
1080            ComposeRestartPolicyKind::Always => NeutralRestartPolicy::Always,
1081            ComposeRestartPolicyKind::OnFailure { maximum_retries: None } => NeutralRestartPolicy::on_failure(None),
1082            ComposeRestartPolicyKind::OnFailure {
1083                maximum_retries: Some(maximum_retries),
1084            } => {
1085                let Ok(maximum_retries) = maximum_retries.parse::<u64>() else {
1086                    self.invalid_value_optional(
1087                        &restart_subject,
1088                        "restart maximum retry count exceeds the neutral model's unsigned 64-bit range",
1089                        restart.effective_source(),
1090                    );
1091                    return;
1092                };
1093                let Some(maximum_retries) = std::num::NonZeroU64::new(maximum_retries) else {
1094                    self.invalid_value_optional(
1095                        &restart_subject,
1096                        "an explicitly authored restart maximum retry count must be greater than zero",
1097                        restart.effective_source(),
1098                    );
1099                    return;
1100                };
1101                NeutralRestartPolicy::on_failure(Some(maximum_retries))
1102            }
1103            ComposeRestartPolicyKind::UnlessStopped => NeutralRestartPolicy::UnlessStopped,
1104            ComposeRestartPolicyKind::Expression => {
1105                self.invalid_value_optional(
1106                    &restart_subject,
1107                    "restart policy expression was not resolved before conversion",
1108                    restart.effective_source(),
1109                );
1110                return;
1111            }
1112            ComposeRestartPolicyKind::Other => {
1113                self.invalid_value_optional(
1114                    &restart_subject,
1115                    "restart policy is not a Compose-defined service-level policy",
1116                    restart.effective_source(),
1117                );
1118                return;
1119            }
1120            _ => {
1121                self.unsupported_optional(
1122                    &restart_subject,
1123                    "restart policy variant is newer than this Compose adapter",
1124                    restart.effective_source(),
1125                );
1126                return;
1127            }
1128        };
1129        service.set_restart_policy(self.sourced_provenance(policy, restart.provenance()));
1130        self.exact_provenance(restart_subject, restart.provenance());
1131    }
1132
1133    fn map_execution_context(&mut self, service_subject: &str, native: &ProjectService, service: &mut Service) {
1134        if let Some(user) = native.user() {
1135            let sensitive = user.is_sensitive();
1136            let primary = Self::protected(user.value().user().raw(), sensitive);
1137            service.set_user(self.sourced_provenance(primary, user.provenance()));
1138            self.exact_provenance(format!("{service_subject}.user"), user.provenance());
1139
1140            if let Some(group) = user.value().group() {
1141                service.set_group(self.sourced_provenance(Self::protected(group.raw(), sensitive), user.provenance()));
1142                self.exact_provenance(format!("{service_subject}.group"), user.provenance());
1143            }
1144        }
1145        if let Some(user_namespace) = native.userns_mode() {
1146            service.set_user_namespace(self.sourced_provenance(
1147                Self::protected(user_namespace.value().raw().value(), user_namespace.is_sensitive()),
1148                user_namespace.provenance(),
1149            ));
1150            self.exact_provenance(format!("{service_subject}.user_namespace"), user_namespace.provenance());
1151        }
1152        if let Some(groups) = native.group_add() {
1153            for (index, group) in groups.value().iter().enumerate() {
1154                service.add_supplementary_group(
1155                    self.sourced_provenance(Self::protected(group.value(), group.is_sensitive()), group.provenance()),
1156                );
1157                self.exact_provenance(
1158                    format!("{service_subject}.supplementary_groups[{index}]"),
1159                    group.provenance(),
1160                );
1161            }
1162        }
1163        if let Some(working_directory) = native.working_dir() {
1164            service.set_working_directory(self.sourced_provenance(
1165                Self::protected(working_directory.value(), working_directory.is_sensitive()),
1166                working_directory.provenance(),
1167            ));
1168            self.exact_provenance(
1169                format!("{service_subject}.working_directory"),
1170                working_directory.provenance(),
1171            );
1172        }
1173        if let Some(read_only) = native.read_only() {
1174            let subject = format!("{service_subject}.read_only_root_filesystem");
1175            match read_only.value() {
1176                BooleanValue::Literal(value) => {
1177                    service.set_read_only_root_filesystem(self.sourced_provenance(*value, read_only.provenance()));
1178                    self.exact_provenance(subject, read_only.provenance());
1179                }
1180                BooleanValue::Expression(_) => self.invalid_value_optional(
1181                    &subject,
1182                    "read-only root-filesystem expression was not resolved",
1183                    read_only.effective_source(),
1184                ),
1185            }
1186        }
1187    }
1188
1189    fn map_released_container_settings(
1190        &mut self,
1191        service_subject: &str,
1192        native: &ProjectService,
1193        service: &mut Service,
1194    ) {
1195        self.map_entrypoint_lifecycle_and_pull(service_subject, native, service);
1196        self.map_hostname_pids_and_shm(service_subject, native, service);
1197        self.map_memory_and_expose(service_subject, native, service);
1198        self.map_capabilities_and_tmpfs(service_subject, native, service);
1199        self.map_sysctls(service_subject, native, service);
1200        self.map_ulimits(service_subject, native, service);
1201        self.map_devices_and_stop_signal(service_subject, native, service);
1202        self.map_annotations_and_logging(service_subject, native, service);
1203    }
1204
1205    fn map_entrypoint_lifecycle_and_pull(
1206        &mut self,
1207        service_subject: &str,
1208        native: &ProjectService,
1209        service: &mut Service,
1210    ) {
1211        if let Some(entrypoint) = native.entrypoint() {
1212            let subject = format!("{service_subject}.entrypoint");
1213            let value = match entrypoint.value() {
1214                ComposeEntrypoint::String(value) if value.value().is_empty() => Some(NeutralEntrypoint::Empty),
1215                ComposeEntrypoint::String(value) => Some(NeutralEntrypoint::Shell(Self::protected(
1216                    value.value(),
1217                    entrypoint.is_sensitive(),
1218                ))),
1219                ComposeEntrypoint::List { values, .. } if values.is_empty() => Some(NeutralEntrypoint::Empty),
1220                ComposeEntrypoint::List { values, .. } => Some(NeutralEntrypoint::Exec(
1221                    values
1222                        .iter()
1223                        .map(|value| Self::protected(value.value(), entrypoint.is_sensitive()))
1224                        .collect(),
1225                )),
1226                ComposeEntrypoint::Null(_) => None,
1227            };
1228            if let Some(value) = value {
1229                service.set_entrypoint(self.sourced_provenance(value, entrypoint.provenance()));
1230                self.exact_provenance(subject, entrypoint.provenance());
1231            } else {
1232                self.unsupported_optional(&subject, "Compose null entrypoint restores the image default, which differs from a neutral explicit empty override", entrypoint.effective_source());
1233            }
1234        }
1235        if let Some(init) = native.init() {
1236            let subject = format!("{service_subject}.init");
1237            match init.value() {
1238                BooleanValue::Literal(value) => {
1239                    service.set_run_init(self.sourced_provenance(*value, init.provenance()));
1240                    self.exact_provenance(subject, init.provenance());
1241                }
1242                BooleanValue::Expression(_) => {
1243                    self.invalid_value_optional(&subject, "init expression was not resolved", init.effective_source());
1244                }
1245            }
1246        }
1247        if let Some(timeout) = native.stop_grace_period() {
1248            let subject = format!("{service_subject}.stop_grace_period");
1249            let value = timeout.value().raw();
1250            match StopTimeout::new(value) {
1251                Ok(value) => service.set_stop_timeout(self.sourced_provenance(value, timeout.provenance())),
1252                Err(error) => {
1253                    self.invalid_model_optional(&subject, &error, timeout.effective_source());
1254                    return;
1255                }
1256            }
1257            if timeout.value().is_valid() && !value.contains('$') {
1258                self.exact_provenance(subject, timeout.provenance());
1259            } else {
1260                self.invalid_value_optional(
1261                    &subject,
1262                    "stop_grace_period is unresolved or provider-specific",
1263                    timeout.effective_source(),
1264                );
1265            }
1266        }
1267        if let Some(policy) = native.pull_policy() {
1268            let subject = format!("{service_subject}.pull_policy");
1269            let raw = policy.value().raw().value();
1270            let value = match policy.value().kind() {
1271                PullPolicyKind::Always => NeutralPullPolicy::Always,
1272                PullPolicyKind::Never => NeutralPullPolicy::Never,
1273                PullPolicyKind::Missing => NeutralPullPolicy::Missing,
1274                PullPolicyKind::IfNotPresentAlias => NeutralPullPolicy::IfNotPresent,
1275                PullPolicyKind::Build => NeutralPullPolicy::Build,
1276                PullPolicyKind::Daily => NeutralPullPolicy::Daily,
1277                PullPolicyKind::Weekly => NeutralPullPolicy::Weekly,
1278                PullPolicyKind::Every { duration } => {
1279                    NeutralPullPolicy::Every(Self::protected(duration, policy.is_sensitive()))
1280                }
1281                PullPolicyKind::RefreshSchemaOnly | PullPolicyKind::Expression | PullPolicyKind::Other => {
1282                    NeutralPullPolicy::Raw(Self::protected(raw, policy.is_sensitive()))
1283                }
1284                _ => NeutralPullPolicy::Raw(Self::protected(raw, policy.is_sensitive())),
1285            };
1286            service.set_pull_policy(self.sourced_provenance(value, policy.provenance()));
1287            if matches!(
1288                policy.value().kind(),
1289                PullPolicyKind::RefreshSchemaOnly | PullPolicyKind::Expression | PullPolicyKind::Other
1290            ) {
1291                self.invalid_value_optional(
1292                    &subject,
1293                    "pull_policy is unresolved, schema-only, or provider-specific",
1294                    policy.effective_source(),
1295                );
1296            } else {
1297                self.exact_provenance(subject, policy.provenance());
1298            }
1299        }
1300    }
1301
1302    fn map_memory_and_expose(&mut self, service_subject: &str, native: &ProjectService, service: &mut Service) {
1303        if let Some(limit) = native.mem_limit() {
1304            let subject = format!("{service_subject}.mem_limit");
1305            service.set_memory_limit(self.sourced_provenance(
1306                Self::protected(limit.value().raw().value(), limit.is_sensitive()),
1307                limit.provenance(),
1308            ));
1309            if matches!(limit.value().kind(), MemLimitKind::Documented { .. }) {
1310                self.exact_provenance(subject, limit.provenance());
1311            } else {
1312                self.invalid_value_optional(
1313                    &subject,
1314                    "mem_limit must use a documented positive lowercase-unit value",
1315                    limit.effective_source(),
1316                );
1317            }
1318        }
1319        if let Some(expose) = native.expose() {
1320            let subject = format!("{service_subject}.expose");
1321            let mut ports = Vec::new();
1322            let mut exact = true;
1323            for item in expose.value() {
1324                let kind = item.value().kind();
1325                let (port, protocol) = match kind {
1326                    ExposeItemKind::Documented { port, protocol } if port.end().is_none() => {
1327                        let Ok(port) = port.start().parse::<u16>() else {
1328                            exact = false;
1329                            continue;
1330                        };
1331                        let protocol = match protocol {
1332                            Some(ExposeProtocol::Udp) => Protocol::Udp,
1333                            Some(ExposeProtocol::Tcp) | None => Protocol::Tcp,
1334                            _ => {
1335                                exact = false;
1336                                continue;
1337                            }
1338                        };
1339                        (port, protocol)
1340                    }
1341                    _ => {
1342                        exact = false;
1343                        continue;
1344                    }
1345                };
1346                match ExposedPort::new(port, protocol) {
1347                    Ok(port) => ports.push(self.sourced_provenance(port, item.provenance())),
1348                    Err(error) => {
1349                        exact = false;
1350                        self.invalid_model_optional(&subject, &error, item.effective_source());
1351                    }
1352                }
1353            }
1354            service.set_exposed_ports_with_origins(ports, self.origins(expose.provenance()));
1355            if exact {
1356                self.exact_provenance(subject, expose.provenance());
1357            } else {
1358                self.invalid_value_optional(&subject, "expose accepts only safe single tcp or udp ports; ranges, SCTP, deferred, and unsafe forms remain explicit", expose.effective_source());
1359            }
1360        }
1361    }
1362
1363    fn map_annotations_and_logging(&mut self, service_subject: &str, native: &ProjectService, service: &mut Service) {
1364        if let Some(annotations) = native.annotations() {
1365            let subject = format!("{service_subject}.annotations");
1366            let mut values = Vec::new();
1367            let mut exact = true;
1368            for entry in annotations.value().entries() {
1369                let Some(value) = entry.value() else {
1370                    exact = false;
1371                    continue;
1372                };
1373                let Some(name) =
1374                    self.identifier_optional(&subject, entry.name().value(), entry.name().effective_source())
1375                else {
1376                    exact = false;
1377                    continue;
1378                };
1379                let scalar = Self::compose_scalar(value.value().effective());
1380                values.push(self.sourced_project_key_value(
1381                    Annotation::new(
1382                        self.sourced_spans(name, entry.name().sources()),
1383                        self.sourced_provenance(Self::protected(&scalar, value.is_sensitive()), value.provenance()),
1384                    ),
1385                    entry.name().sources(),
1386                    value.provenance(),
1387                ));
1388            }
1389            service.set_annotations_with_origins(values, self.origins(annotations.provenance()));
1390            if exact {
1391                self.exact_provenance(subject, annotations.provenance());
1392            } else {
1393                self.invalid_value_optional(
1394                    &subject,
1395                    "annotations require explicit valid names and values",
1396                    annotations.effective_source(),
1397                );
1398            }
1399        }
1400        if let Some(logging) = native.logging() {
1401            let subject = format!("{service_subject}.logging");
1402            let mut value = Logging::new();
1403            if let Some(driver) = logging.value().driver() {
1404                value.set_driver(self.sourced_provenance(
1405                    Self::protected(driver.value(), driver.is_sensitive()),
1406                    driver.provenance(),
1407                ));
1408            } else {
1409                self.unsupported_optional(
1410                    &subject,
1411                    "logging options without a driver use provider-default semantics",
1412                    logging.effective_source(),
1413                );
1414            }
1415            if let Some(options) = logging.value().options() {
1416                let mut mapped = Vec::new();
1417                for option in options.value().entries() {
1418                    let raw = match option.value().value().value() {
1419                        ProjectLoggingOptionValue::String { value, .. } | ProjectLoggingOptionValue::Number(value) => {
1420                            value
1421                        }
1422                        ProjectLoggingOptionValue::Null => "",
1423                        _ => {
1424                            self.invalid_value_optional(
1425                                &subject,
1426                                "logging option variant is newer than this adapter",
1427                                option.effective_source(),
1428                            );
1429                            continue;
1430                        }
1431                    };
1432                    let Some(name) = self.identifier_optional(
1433                        &subject,
1434                        option.value().name().value(),
1435                        option.value().name().effective_source(),
1436                    ) else {
1437                        continue;
1438                    };
1439                    mapped.push(self.sourced_project_key_value(
1440                        LoggingOption::new(
1441                            self.sourced_spans(name, option.value().name().sources()),
1442                            self.sourced_provenance(
1443                                Self::protected(raw, option.value().value().is_sensitive()),
1444                                option.value().value().provenance(),
1445                            ),
1446                        ),
1447                        option.value().name().sources(),
1448                        option.value().value().provenance(),
1449                    ));
1450                }
1451                value.set_options_with_origins(mapped, self.origins(options.provenance()));
1452            }
1453            service.set_logging(self.sourced_provenance(value, logging.provenance()));
1454            if logging.value().driver().is_some() {
1455                self.exact_provenance(subject, logging.provenance());
1456            }
1457        }
1458    }
1459
1460    fn map_dns(&mut self, subject: &str, native: &ProjectService, service: &mut Service) {
1461        if let Some(dns) = native.dns() {
1462            let values = match dns.value() {
1463                ProjectDns::Scalar(value) => Some(std::slice::from_ref(value)),
1464                ProjectDns::List(values) => Some(values.as_slice()),
1465                _ => {
1466                    self.unsupported_optional(
1467                        &format!("{subject}.dns"),
1468                        "DNS form is newer than this adapter",
1469                        dns.effective_source(),
1470                    );
1471                    None
1472                }
1473            };
1474            if let Some(values) = values {
1475                let mapped = values
1476                    .iter()
1477                    .map(|value| {
1478                        self.sourced_provenance(
1479                            Self::protected(value.value(), value.is_sensitive()),
1480                            value.provenance(),
1481                        )
1482                    })
1483                    .collect();
1484                service.set_dns_servers_with_origins(mapped, self.origins(dns.provenance()));
1485                self.dns_import_outcome(&format!("{subject}.dns"), values, dns.provenance(), "dns");
1486            }
1487        }
1488        if let Some(options) = native.dns_options() {
1489            let mapped = options
1490                .value()
1491                .iter()
1492                .map(|value| {
1493                    self.sourced_provenance(Self::protected(value.value(), value.is_sensitive()), value.provenance())
1494                })
1495                .collect();
1496            service.set_dns_options_with_origins(mapped, self.origins(options.provenance()));
1497            let duplicate = options.value().iter().enumerate().any(|(index, value)| {
1498                options.value()[..index]
1499                    .iter()
1500                    .any(|prior| prior.value() == value.value())
1501            });
1502            if duplicate {
1503                self.invalid_value_optional(
1504                    &format!("{subject}.dns_opt"),
1505                    "dns_opt contains duplicate resolver options",
1506                    options.effective_source(),
1507                );
1508            } else {
1509                self.dns_import_outcome(
1510                    &format!("{subject}.dns_opt"),
1511                    options.value(),
1512                    options.provenance(),
1513                    "dns_opt",
1514                );
1515            }
1516        }
1517        if let Some(search) = native.dns_search() {
1518            let values = match search.value() {
1519                ProjectDnsSearch::Scalar(value) => Some(std::slice::from_ref(value)),
1520                ProjectDnsSearch::List(values) => Some(values.as_slice()),
1521                _ => {
1522                    self.unsupported_optional(
1523                        &format!("{subject}.dns_search"),
1524                        "DNS search form is newer than this adapter",
1525                        search.effective_source(),
1526                    );
1527                    None
1528                }
1529            };
1530            if let Some(values) = values {
1531                let mapped = values
1532                    .iter()
1533                    .map(|value| {
1534                        self.sourced_provenance(
1535                            Self::protected(value.value(), value.is_sensitive()),
1536                            value.provenance(),
1537                        )
1538                    })
1539                    .collect();
1540                service.set_dns_search_domains_with_origins(mapped, self.origins(search.provenance()));
1541                self.dns_import_outcome(
1542                    &format!("{subject}.dns_search"),
1543                    values,
1544                    search.provenance(),
1545                    "dns_search",
1546                );
1547            }
1548        }
1549    }
1550
1551    fn map_security_options(&mut self, service_subject: &str, native: &ProjectService, service: &mut Service) {
1552        let Some(options) = native.security_options() else {
1553            return;
1554        };
1555        let subject = format!("{service_subject}.security_opt");
1556        let mut mapped = Vec::new();
1557        let mut singleton_counts = [0_usize; 8];
1558        let mut has_label_disable = false;
1559        let mut has_other_label = false;
1560
1561        for (index, item) in options.value().iter().enumerate() {
1562            let item_subject = format!("{subject}[{index}]");
1563            match Self::map_security_option(item.value().kind(), item.is_sensitive()) {
1564                SecurityOptionMapping::Exact(value) => {
1565                    Self::record_security_option_conflict(
1566                        &value,
1567                        &mut singleton_counts,
1568                        &mut has_label_disable,
1569                        &mut has_other_label,
1570                    );
1571                    mapped.push(self.sourced_provenance(value, item.provenance()));
1572                    self.exact_provenance(item_subject, item.provenance());
1573                }
1574                SecurityOptionMapping::Invalid(reason) => {
1575                    self.invalid_value_optional(&item_subject, reason, item.effective_source());
1576                }
1577                SecurityOptionMapping::Unsupported(reason) => {
1578                    self.unsupported_optional(&item_subject, reason, item.effective_source());
1579                }
1580            }
1581        }
1582
1583        service.set_security_options_with_origins(mapped, self.origins(options.provenance()));
1584        let singleton_conflict = singleton_counts.iter().any(|count| *count > 1);
1585        if singleton_conflict {
1586            self.invalid_value_optional(
1587                &subject,
1588                "security_opt contains multiple candidates for a singleton security-option family",
1589                options.effective_source(),
1590            );
1591        }
1592        if has_label_disable && has_other_label {
1593            self.unsupported_optional(
1594                &subject,
1595                "label:disable conflicts semantically with other SELinux label candidates",
1596                options.effective_source(),
1597            );
1598        }
1599        if !(singleton_conflict || has_label_disable && has_other_label) {
1600            self.exact_provenance(subject, options.provenance());
1601        }
1602    }
1603
1604    fn map_security_option(kind: &SecurityOptionKind, sensitive: bool) -> SecurityOptionMapping {
1605        let exact = match kind {
1606            SecurityOptionKind::AppArmor { profile } => SecurityOption::AppArmor(Self::protected(profile, sensitive)),
1607            SecurityOptionKind::NoNewPrivileges { enabled } => SecurityOption::NoNewPrivileges(*enabled),
1608            SecurityOptionKind::Seccomp { profile } => {
1609                SecurityOption::SeccompProfile(Self::protected(profile, sensitive))
1610            }
1611            SecurityOptionKind::SecurityLabelDisable { enabled } => SecurityOption::SecurityLabelDisable(*enabled),
1612            SecurityOptionKind::SecurityLabelFileType { file_type } => {
1613                SecurityOption::SecurityLabelFileType(Self::protected(file_type, sensitive))
1614            }
1615            SecurityOptionKind::SecurityLabelLevel { level } => {
1616                SecurityOption::SecurityLabelLevel(Self::protected(level, sensitive))
1617            }
1618            SecurityOptionKind::SecurityLabelNested { enabled } => SecurityOption::SecurityLabelNested(*enabled),
1619            SecurityOptionKind::SecurityLabelType { label_type } => {
1620                SecurityOption::SecurityLabelType(Self::protected(label_type, sensitive))
1621            }
1622            SecurityOptionKind::Mask { paths } => SecurityOption::Mask(Self::protected(paths, sensitive)),
1623            SecurityOptionKind::Unmask { paths } => SecurityOption::Unmask(Self::protected(paths, sensitive)),
1624            SecurityOptionKind::Expression => {
1625                return SecurityOptionMapping::Invalid("security option expression was not resolved before conversion");
1626            }
1627            SecurityOptionKind::Empty => {
1628                return SecurityOptionMapping::Invalid("security options must not contain empty strings");
1629            }
1630            SecurityOptionKind::AppArmorNearMiss
1631            | SecurityOptionKind::SeccompNearMiss
1632            | SecurityOptionKind::NoNewPrivilegesNearMiss
1633            | SecurityOptionKind::SecurityLabelDisableNearMiss
1634            | SecurityOptionKind::SecurityLabelFileTypeNearMiss
1635            | SecurityOptionKind::SecurityLabelLevelNearMiss
1636            | SecurityOptionKind::SecurityLabelNestedNearMiss
1637            | SecurityOptionKind::SecurityLabelTypeNearMiss
1638            | SecurityOptionKind::MaskNearMiss
1639            | SecurityOptionKind::UnmaskNearMiss => {
1640                return SecurityOptionMapping::Invalid(
1641                    "security option must use a released exact ComposeLens candidate spelling",
1642                );
1643            }
1644            SecurityOptionKind::Other => {
1645                return SecurityOptionMapping::Unsupported("raw security option has no neutral semantic mapping");
1646            }
1647            _ => {
1648                return SecurityOptionMapping::Unsupported(
1649                    "security option variant is newer than this Compose adapter",
1650                );
1651            }
1652        };
1653        SecurityOptionMapping::Exact(exact)
1654    }
1655
1656    fn record_security_option_conflict(
1657        value: &SecurityOption,
1658        singleton_counts: &mut [usize; 8],
1659        has_label_disable: &mut bool,
1660        has_other_label: &mut bool,
1661    ) {
1662        let family = match value {
1663            SecurityOption::AppArmor(_) => Some(0),
1664            SecurityOption::NoNewPrivileges(_) => Some(1),
1665            SecurityOption::SeccompProfile(_) => Some(2),
1666            SecurityOption::SecurityLabelDisable(enabled) => {
1667                *has_label_disable |= *enabled;
1668                Some(3)
1669            }
1670            SecurityOption::SecurityLabelFileType(_) => Some(4),
1671            SecurityOption::SecurityLabelLevel(_) => Some(5),
1672            SecurityOption::SecurityLabelNested(_) => Some(6),
1673            SecurityOption::SecurityLabelType(_) => Some(7),
1674            _ => None,
1675        };
1676        *has_other_label |= matches!(
1677            value,
1678            SecurityOption::SecurityLabelFileType(_)
1679                | SecurityOption::SecurityLabelLevel(_)
1680                | SecurityOption::SecurityLabelNested(_)
1681                | SecurityOption::SecurityLabelType(_)
1682        );
1683        if let Some(family) = family {
1684            singleton_counts[family] += 1;
1685        }
1686    }
1687
1688    fn dns_import_outcome(
1689        &mut self,
1690        subject: &str,
1691        values: &[ProjectValue<String>],
1692        provenance: &MergeProvenance,
1693        field: &str,
1694    ) {
1695        if values.is_empty() {
1696            self.unsupported_optional(
1697                subject,
1698                "explicit empty DNS collections have target-specific reset semantics",
1699                provenance.effective_source(),
1700            );
1701        } else if values
1702            .iter()
1703            .any(|value| value.value().is_empty() || value.value().contains(['\r', '\n']) || value.is_sensitive())
1704        {
1705            self.invalid_value_optional(
1706                subject,
1707                "DNS values must be resolved non-empty single physical lines",
1708                provenance.effective_source(),
1709            );
1710        } else if (field == "dns" && values.iter().any(|value| value.value() == "none"))
1711            || (field == "dns_search" && values.iter().any(|value| value.value() == "."))
1712        {
1713            self.unsupported_optional(
1714                subject,
1715                "special DNS values have target-specific resolver semantics",
1716                provenance.effective_source(),
1717            );
1718        } else {
1719            self.exact_provenance(subject, provenance);
1720        }
1721    }
1722
1723    fn map_hostname_pids_and_shm(&mut self, service_subject: &str, native: &ProjectService, service: &mut Service) {
1724        if let Some(hostname) = native.hostname() {
1725            let subject = format!("{service_subject}.hostname");
1726            let value = hostname.value().raw().value();
1727            service.set_hostname(
1728                self.sourced_provenance(Self::protected(value, hostname.is_sensitive()), hostname.provenance()),
1729            );
1730            if native.uts().is_some() {
1731                self.unsupported_optional(
1732                    &subject,
1733                    "hostname cannot be emitted while host UTS mode remains unmodeled",
1734                    hostname.effective_source(),
1735                );
1736            } else if matches!(hostname.value().kind(), HostnameKind::Resolved) {
1737                self.exact_provenance(subject, hostname.provenance());
1738            } else {
1739                self.invalid_value_optional(
1740                    &subject,
1741                    "hostname is unresolved or outside the conservative portable hostname grammar",
1742                    hostname.effective_source(),
1743                );
1744            }
1745        }
1746        if let Some(uts) = native.uts() {
1747            self.unsupported_optional(
1748                &format!("{service_subject}.uts"),
1749                "Compose UTS namespace mode has no neutral-model mapping",
1750                uts.effective_source(),
1751            );
1752        }
1753        if let Some(limit) = native.pids_limit() {
1754            let subject = format!("{service_subject}.pids_limit");
1755            service.set_pids_limit(self.sourced_provenance(
1756                Self::protected(limit.value().raw().value(), limit.is_sensitive()),
1757                limit.provenance(),
1758            ));
1759            match limit.value().kind() {
1760                PidsLimitKind::Unlimited | PidsLimitKind::Finite { .. } => {
1761                    self.exact_provenance(subject, limit.provenance());
1762                }
1763                _ => self.invalid_value_optional(
1764                    &subject,
1765                    "PID limit must be -1 or a positive ASCII decimal before exact conversion",
1766                    limit.effective_source(),
1767                ),
1768            }
1769        }
1770        if let Some(size) = native.shm_size() {
1771            let subject = format!("{service_subject}.shm_size");
1772            let normalized = quadlet_shm_size(size.value().kind());
1773            service.set_shm_size(self.sourced_provenance(
1774                Self::protected(
1775                    normalized.as_deref().unwrap_or_else(|| size.value().raw().value()),
1776                    size.is_sensitive(),
1777                ),
1778                size.provenance(),
1779            ));
1780            if normalized.is_some() {
1781                self.exact_provenance(subject, size.provenance());
1782            } else {
1783                self.invalid_value_optional(
1784                    &subject,
1785                    "shared-memory size must be a positive ASCII decimal with b, k, kb, m, mb, g, or gb",
1786                    size.effective_source(),
1787                );
1788            }
1789        }
1790    }
1791
1792    fn map_capabilities_and_tmpfs(&mut self, service_subject: &str, native: &ProjectService, service: &mut Service) {
1793        if let Some(values) = native.cap_add() {
1794            let subject = format!("{service_subject}.cap_add");
1795            let mut exact = true;
1796            let mapped = values
1797                .value()
1798                .iter()
1799                .map(|value| {
1800                    if !value.value().is_exact_candidate() || value.value().value().contains('$') {
1801                        exact = false;
1802                    }
1803                    self.sourced_provenance(
1804                        Self::protected(value.value().value(), value.is_sensitive()),
1805                        value.provenance(),
1806                    )
1807                })
1808                .collect();
1809            service.set_cap_add_with_origins(mapped, self.origins(values.provenance()));
1810            if exact {
1811                self.exact_provenance(subject, values.provenance());
1812            } else {
1813                self.invalid_value_optional(
1814                    &subject,
1815                    "capability entries must be resolved non-empty strings without whitespace",
1816                    values.effective_source(),
1817                );
1818            }
1819        }
1820        if let Some(values) = native.cap_drop() {
1821            let subject = format!("{service_subject}.cap_drop");
1822            let mut exact = true;
1823            let mapped = values
1824                .value()
1825                .iter()
1826                .map(|value| {
1827                    if !value.value().is_exact_candidate() || value.value().value().contains('$') {
1828                        exact = false;
1829                    }
1830                    self.sourced_provenance(
1831                        Self::protected(value.value().value(), value.is_sensitive()),
1832                        value.provenance(),
1833                    )
1834                })
1835                .collect();
1836            service.set_cap_drop_with_origins(mapped, self.origins(values.provenance()));
1837            if exact {
1838                self.exact_provenance(subject, values.provenance());
1839            } else {
1840                self.invalid_value_optional(
1841                    &subject,
1842                    "capability entries must be resolved non-empty strings without whitespace",
1843                    values.effective_source(),
1844                );
1845            }
1846        }
1847        if let Some(tmpfs) = native.tmpfs() {
1848            let (values, exact) = match tmpfs.value() {
1849                ProjectTmpfs::Scalar(value) => (vec![value], value.value().kind() == TmpfsItemKind::Documented),
1850                ProjectTmpfs::List(values) => (
1851                    values.iter().collect(),
1852                    values
1853                        .iter()
1854                        .all(|value| value.value().kind() == TmpfsItemKind::Documented),
1855                ),
1856                _ => (Vec::new(), false),
1857            };
1858            let mapped = values
1859                .into_iter()
1860                .map(|value| {
1861                    self.sourced_provenance(
1862                        Self::protected(value.value().value(), value.is_sensitive()),
1863                        value.provenance(),
1864                    )
1865                })
1866                .collect();
1867            service.set_tmpfs_with_origins(mapped, self.origins(tmpfs.provenance()));
1868            if exact {
1869                self.exact_provenance(format!("{service_subject}.tmpfs"), tmpfs.provenance());
1870            } else {
1871                self.invalid_value_optional(
1872                    &format!("{service_subject}.tmpfs"),
1873                    "tmpfs entries must be resolved documented declarations",
1874                    tmpfs.effective_source(),
1875                );
1876            }
1877        }
1878    }
1879
1880    fn map_sysctls(&mut self, service_subject: &str, native: &ProjectService, service: &mut Service) {
1881        if let Some(sysctls) = native.sysctls() {
1882            let mut exact = true;
1883            let values = match sysctls.value() {
1884                ProjectSysctls::Map(values) => values
1885                    .iter()
1886                    .map(|value| {
1887                        let native = value.value();
1888                        let scalar = Self::compose_scalar(native.value().value());
1889                        if native.name().value().is_empty()
1890                            || native.name().value().contains('$')
1891                            || scalar.contains('$')
1892                            || matches!(native.value().value(), ComposeScalar::Null)
1893                        {
1894                            exact = false;
1895                        }
1896                        self.sourced_project_key_value(
1897                            KernelParameter::new(
1898                                Self::protected(native.name().value(), native.name().is_sensitive()),
1899                                Self::protected(&scalar, native.value().is_sensitive()),
1900                            ),
1901                            native.name().sources(),
1902                            native.value().provenance(),
1903                        )
1904                    })
1905                    .collect(),
1906                ProjectSysctls::List(values) => values
1907                    .iter()
1908                    .filter_map(|value| {
1909                        let Some((name, scalar)) = value.value().split_once('=') else {
1910                            exact = false;
1911                            self.invalid_value_optional(
1912                                &format!("{service_subject}.sysctls"),
1913                                "sysctl list entries must use unambiguous name=value spelling",
1914                                value.effective_source(),
1915                            );
1916                            return None;
1917                        };
1918                        if name.is_empty() || name.contains('$') || scalar.contains('$') {
1919                            exact = false;
1920                        }
1921                        Some(self.sourced_provenance(
1922                            KernelParameter::new(
1923                                Self::protected(name, value.is_sensitive()),
1924                                Self::protected(scalar, value.is_sensitive()),
1925                            ),
1926                            value.provenance(),
1927                        ))
1928                    })
1929                    .collect(),
1930                _ => {
1931                    exact = false;
1932                    Vec::new()
1933                }
1934            };
1935            service.set_sysctls_with_origins(values, self.origins(sysctls.provenance()));
1936            if exact {
1937                self.exact_provenance(format!("{service_subject}.sysctls"), sysctls.provenance());
1938            } else {
1939                self.invalid_value_optional(
1940                    &format!("{service_subject}.sysctls"),
1941                    "sysctl entries must use resolved unambiguous name=value spelling",
1942                    sysctls.effective_source(),
1943                );
1944            }
1945        }
1946    }
1947
1948    fn map_ulimits(&mut self, service_subject: &str, native: &ProjectService, service: &mut Service) {
1949        if let Some(ulimits) = native.ulimits() {
1950            let mut mapped = Vec::new();
1951            let mut exact = true;
1952            for limit in ulimits.value().entries() {
1953                let name = limit.value().name();
1954                let (soft, hard) = match limit.value().value() {
1955                    ProjectUlimitValue::Single(value) => {
1956                        if !matches!(value.value().value(), LimitValue::Unlimited | LimitValue::Number(_)) {
1957                            exact = false;
1958                        }
1959                        let value = self.sourced_provenance(
1960                            Self::protected(value.value().authored(), value.is_sensitive()),
1961                            value.provenance(),
1962                        );
1963                        (Some(value.clone()), Some(value))
1964                    }
1965                    ProjectUlimitValue::Range(range) => (
1966                        range.soft().map(|value| {
1967                            if !matches!(value.value().value(), LimitValue::Unlimited | LimitValue::Number(_)) {
1968                                exact = false;
1969                            }
1970                            self.sourced_provenance(
1971                                Self::protected(value.value().authored(), value.is_sensitive()),
1972                                value.provenance(),
1973                            )
1974                        }),
1975                        range.hard().map(|value| {
1976                            if !matches!(value.value().value(), LimitValue::Unlimited | LimitValue::Number(_)) {
1977                                exact = false;
1978                            }
1979                            self.sourced_provenance(
1980                                Self::protected(value.value().authored(), value.is_sensitive()),
1981                                value.provenance(),
1982                            )
1983                        }),
1984                    ),
1985                    _ => {
1986                        exact = false;
1987                        (None, None)
1988                    }
1989                };
1990                if soft.is_none() || hard.is_none() {
1991                    exact = false;
1992                }
1993                let item = ResourceLimit::new(Self::protected(name.value(), name.is_sensitive()), soft, hard);
1994                mapped.push(self.sourced_spans(item, name.sources()));
1995            }
1996            service.set_ulimits_with_origins(mapped, self.origins(ulimits.provenance()));
1997            if exact {
1998                self.exact_provenance(format!("{service_subject}.ulimits"), ulimits.provenance());
1999            } else {
2000                self.invalid_value_optional(
2001                    &format!("{service_subject}.ulimits"),
2002                    "ulimits require complete resolved -1 or non-negative decimal values",
2003                    ulimits.effective_source(),
2004                );
2005            }
2006        }
2007    }
2008
2009    fn map_devices_and_stop_signal(&mut self, service_subject: &str, native: &ProjectService, service: &mut Service) {
2010        if let Some(devices) = native.devices() {
2011            let mut exact = true;
2012            let mapped = devices
2013                .value()
2014                .iter()
2015                .filter_map(|device| match device.value() {
2016                    ProjectDevice::Short(short) => {
2017                        if matches!(short.kind(), ShortDeviceKind::Deferred) {
2018                            exact = false;
2019                        }
2020                        Some(self.sourced_provenance(
2021                            Device::Short(Self::protected(short.raw().value(), device.is_sensitive())),
2022                            device.provenance(),
2023                        ))
2024                    }
2025                    ProjectDevice::Long(long) => {
2026                        if long.source().is_none_or(|value| value.value().contains('$'))
2027                            || long.target().is_some_and(|value| value.value().contains('$'))
2028                            || long.permissions().is_some_and(|value| value.value().contains('$'))
2029                            || !long.extension_fields().is_empty()
2030                            || !long.unknown_fields().is_empty()
2031                        {
2032                            exact = false;
2033                        }
2034                        Some(self.sourced_provenance(
2035                            Device::Long {
2036                                source: long.source().map(|value| {
2037                                    self.sourced_provenance(
2038                                        Self::protected(value.value(), value.is_sensitive()),
2039                                        value.provenance(),
2040                                    )
2041                                }),
2042                                target: long.target().map(|value| {
2043                                    self.sourced_provenance(
2044                                        Self::protected(value.value(), value.is_sensitive()),
2045                                        value.provenance(),
2046                                    )
2047                                }),
2048                                permissions: long.permissions().map(|value| {
2049                                    self.sourced_provenance(
2050                                        Self::protected(value.value(), value.is_sensitive()),
2051                                        value.provenance(),
2052                                    )
2053                                }),
2054                            },
2055                            device.provenance(),
2056                        ))
2057                    }
2058                    _ => {
2059                        exact = false;
2060                        None
2061                    }
2062                })
2063                .collect();
2064            service.set_devices_with_origins(mapped, self.origins(devices.provenance()));
2065            if exact {
2066                self.exact_provenance(format!("{service_subject}.devices"), devices.provenance());
2067            } else {
2068                self.invalid_value_optional(
2069                    &format!("{service_subject}.devices"),
2070                    "devices require complete resolved short or long declarations",
2071                    devices.effective_source(),
2072                );
2073            }
2074        }
2075        if let Some(signal) = native.stop_signal() {
2076            service.set_stop_signal(self.sourced_provenance(
2077                Self::protected(signal.value(), signal.is_sensitive()),
2078                signal.provenance(),
2079            ));
2080            if Self::is_safe_stop_signal(signal.value()) {
2081                self.exact_provenance(format!("{service_subject}.stop_signal"), signal.provenance());
2082            } else {
2083                self.invalid_value_optional(
2084                    &format!("{service_subject}.stop_signal"),
2085                    "stop signal must be a non-empty resolved token or number",
2086                    signal.effective_source(),
2087                );
2088            }
2089        }
2090    }
2091
2092    fn compose_scalar(value: &ComposeScalar) -> String {
2093        match value {
2094            ComposeScalar::Null => String::new(),
2095            ComposeScalar::Boolean(value) => value.to_string(),
2096            ComposeScalar::Number(value) | ComposeScalar::String(value) => value.clone(),
2097        }
2098    }
2099
2100    fn is_safe_stop_signal(value: &str) -> bool {
2101        !value.is_empty()
2102            && value
2103                .bytes()
2104                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
2105    }
2106
2107    fn protected(value: &str, sensitive: bool) -> ProtectedString {
2108        if sensitive {
2109            ProtectedString::sensitive(value)
2110        } else {
2111            ProtectedString::plain(value)
2112        }
2113    }
2114
2115    fn map_service_dependencies(
2116        &mut self,
2117        service_subject: &str,
2118        dependencies: &ProjectValue<ProjectDependsOn>,
2119        service: &mut Service,
2120    ) {
2121        for (index, native) in dependencies.value().services().iter().enumerate() {
2122            let dependency_subject = format!("{service_subject}.depends_on[{index}]");
2123            let key = native.value().service();
2124            if key.is_sensitive() {
2125                self.invalid_value_optional(
2126                    &dependency_subject,
2127                    "dependency service name contains sensitive interpolated content",
2128                    key.effective_source(),
2129                );
2130                continue;
2131            }
2132            let Some(name) = self.identifier_optional(&dependency_subject, key.value(), key.effective_source()) else {
2133                continue;
2134            };
2135            let mut dependency = ServiceDependency::new(name);
2136
2137            if let Some(condition) = native.value().condition() {
2138                if condition.is_sensitive() {
2139                    self.invalid_value_optional(
2140                        &format!("{dependency_subject}.condition"),
2141                        "dependency condition contains sensitive interpolated content",
2142                        condition.effective_source(),
2143                    );
2144                } else {
2145                    let value = match condition.value() {
2146                        ComposeDependencyCondition::ServiceStarted => ServiceDependencyCondition::Started,
2147                        ComposeDependencyCondition::ServiceHealthy => ServiceDependencyCondition::Healthy,
2148                        ComposeDependencyCondition::ServiceCompletedSuccessfully => {
2149                            ServiceDependencyCondition::CompletedSuccessfully
2150                        }
2151                        ComposeDependencyCondition::Other(value) => {
2152                            ServiceDependencyCondition::Other(ProtectedString::plain(value.clone()))
2153                        }
2154                    };
2155                    dependency.set_condition(self.sourced_provenance(value, condition.provenance()));
2156                    self.exact_provenance(format!("{dependency_subject}.condition"), condition.provenance());
2157                }
2158            }
2159
2160            if let Some(restart) = native.value().restart() {
2161                self.map_dependency_boolean(&format!("{dependency_subject}.restart"), restart, |value| {
2162                    dependency.set_restart(value);
2163                });
2164            }
2165            if let Some(required) = native.value().required() {
2166                self.map_dependency_boolean(&format!("{dependency_subject}.required"), required, |value| {
2167                    dependency.set_required(value);
2168                });
2169            }
2170            self.report_project_fields(
2171                &dependency_subject,
2172                "dependency option",
2173                native.value().unmodeled_fields(),
2174            );
2175
2176            let dependency = self.sourced_spans(dependency, key.sources());
2177            self.exact_origins(dependency_subject, dependency.origins());
2178            service.add_dependency(dependency);
2179        }
2180    }
2181
2182    fn map_dependency_boolean(
2183        &mut self,
2184        subject: &str,
2185        native: &ProjectValue<BooleanValue>,
2186        set: impl FnOnce(Sourced<bool>),
2187    ) {
2188        match native.value() {
2189            BooleanValue::Literal(value) => {
2190                set(self.sourced_provenance(*value, native.provenance()));
2191                self.exact_provenance(subject, native.provenance());
2192            }
2193            BooleanValue::Expression(_) => self.invalid_value_optional(
2194                subject,
2195                "dependency boolean expression was not resolved",
2196                native.effective_source(),
2197            ),
2198        }
2199    }
2200
2201    fn map_command(command: &compose_lens::model::Command, sensitive: bool) -> Option<Command> {
2202        let protect = |value: String| {
2203            if sensitive {
2204                ProtectedString::sensitive(value)
2205            } else {
2206                ProtectedString::plain(value)
2207            }
2208        };
2209        match command {
2210            compose_lens::model::Command::Null(_) => None,
2211            compose_lens::model::Command::String(value) if value.value().is_empty() => Some(Command::Empty),
2212            compose_lens::model::Command::String(value) => Some(Command::Shell(protect(value.value().clone()))),
2213            compose_lens::model::Command::List { values, .. } if values.is_empty() => Some(Command::Empty),
2214            compose_lens::model::Command::List { values, .. } => Some(Command::Exec(
2215                values.iter().map(|value| protect(value.value().clone())).collect(),
2216            )),
2217        }
2218    }
2219
2220    fn map_healthcheck(
2221        &mut self,
2222        service_subject: &str,
2223        native: &ProjectValue<ProjectHealthcheck>,
2224    ) -> Sourced<Healthcheck> {
2225        let mut healthcheck = Healthcheck::new();
2226
2227        if let Some(disable) = native.value().disable() {
2228            let subject = format!("{service_subject}.healthcheck.disable");
2229            match disable.value() {
2230                BooleanValue::Literal(value) => {
2231                    healthcheck.set_disabled(self.sourced_provenance(*value, disable.provenance()));
2232                    self.exact_provenance(subject, disable.provenance());
2233                }
2234                BooleanValue::Expression(_) => self.invalid_value_optional(
2235                    &subject,
2236                    "health-check disable expression was not resolved",
2237                    disable.effective_source(),
2238                ),
2239            }
2240        }
2241
2242        if let Some(test) = native.value().test() {
2243            self.map_healthcheck_test(service_subject, test, &mut healthcheck);
2244        }
2245        if let Some(interval) = native.value().interval() {
2246            let subject = format!("{service_subject}.healthcheck.interval");
2247            if let Some(value) = self.map_healthcheck_duration(&subject, interval) {
2248                healthcheck.set_interval(value);
2249            }
2250        }
2251        if let Some(timeout) = native.value().timeout() {
2252            let subject = format!("{service_subject}.healthcheck.timeout");
2253            if let Some(value) = self.map_healthcheck_duration(&subject, timeout) {
2254                healthcheck.set_timeout(value);
2255            }
2256        }
2257        if let Some(retries) = native.value().retries() {
2258            let subject = format!("{service_subject}.healthcheck.retries");
2259            match retries.value() {
2260                ComposeHealthcheckRetries::Count(value) => match NeutralHealthcheckRetries::new(value.clone()) {
2261                    Ok(value) => {
2262                        healthcheck.set_retries(self.sourced_provenance(value, retries.provenance()));
2263                        self.exact_provenance(subject, retries.provenance());
2264                    }
2265                    Err(error) => self.invalid_model_optional(&subject, &error, retries.effective_source()),
2266                },
2267                ComposeHealthcheckRetries::Expression(_) => self.invalid_value_optional(
2268                    &subject,
2269                    "health-check retry expression was not resolved",
2270                    retries.effective_source(),
2271                ),
2272                ComposeHealthcheckRetries::Other(value) => self.invalid_value_optional(
2273                    &subject,
2274                    &format!("invalid health-check retry count `{value}`"),
2275                    retries.effective_source(),
2276                ),
2277            }
2278        }
2279        if let Some(start_period) = native.value().start_period() {
2280            let subject = format!("{service_subject}.healthcheck.start_period");
2281            if let Some(value) = self.map_healthcheck_duration(&subject, start_period) {
2282                healthcheck.set_start_period(value);
2283            }
2284        }
2285        if let Some(start_interval) = native.value().start_interval() {
2286            let subject = format!("{service_subject}.healthcheck.start_interval");
2287            if let Some(value) = self.map_healthcheck_duration(&subject, start_interval) {
2288                healthcheck.set_start_interval(value);
2289            }
2290        }
2291        self.report_project_fields(
2292            &format!("{service_subject}.healthcheck"),
2293            "health-check field",
2294            native.value().unmodeled_fields(),
2295        );
2296
2297        self.sourced_provenance(healthcheck, native.provenance())
2298    }
2299
2300    fn map_healthcheck_test(
2301        &mut self,
2302        service_subject: &str,
2303        test: &ProjectValue<HealthcheckTest>,
2304        healthcheck: &mut Healthcheck,
2305    ) {
2306        let subject = format!("{service_subject}.healthcheck.test");
2307        let protect = |value: String| {
2308            if test.is_sensitive() {
2309                ProtectedString::sensitive(value)
2310            } else {
2311                ProtectedString::plain(value)
2312            }
2313        };
2314        match test.value() {
2315            HealthcheckTest::String(value) => {
2316                healthcheck.set_command(self.sourced_provenance(
2317                    HealthcheckCommand::Shell(protect(value.value().clone())),
2318                    test.provenance(),
2319                ));
2320                self.exact_provenance(subject, test.provenance());
2321            }
2322            HealthcheckTest::List {
2323                kind: Some(HealthcheckTestKind::Cmd),
2324                values,
2325                ..
2326            } if values.len() > 1 => {
2327                healthcheck.set_command(self.sourced_provenance(
2328                    HealthcheckCommand::Exec(values[1..].iter().map(|value| protect(value.value().clone())).collect()),
2329                    test.provenance(),
2330                ));
2331                self.exact_provenance(subject, test.provenance());
2332            }
2333            HealthcheckTest::List {
2334                kind: Some(HealthcheckTestKind::CmdShell),
2335                values,
2336                ..
2337            } if values.len() == 2 => {
2338                healthcheck.set_command(self.sourced_provenance(
2339                    HealthcheckCommand::Shell(protect(values[1].value().clone())),
2340                    test.provenance(),
2341                ));
2342                self.exact_provenance(subject, test.provenance());
2343            }
2344            HealthcheckTest::List {
2345                kind: Some(HealthcheckTestKind::None),
2346                values,
2347                ..
2348            } if values.len() == 1 => {
2349                healthcheck.set_disabled(self.sourced_provenance(true, test.provenance()));
2350                self.exact_provenance(subject, test.provenance());
2351            }
2352            HealthcheckTest::List {
2353                kind: Some(HealthcheckTestKind::Cmd),
2354                ..
2355            } => self.invalid_value_optional(
2356                &subject,
2357                "CMD health check requires at least one command argument",
2358                test.effective_source(),
2359            ),
2360            HealthcheckTest::List {
2361                kind: Some(HealthcheckTestKind::CmdShell),
2362                ..
2363            } => self.unsupported_optional(
2364                &subject,
2365                "CMD-SHELL health check must contain exactly one shell string",
2366                test.effective_source(),
2367            ),
2368            HealthcheckTest::List {
2369                kind: Some(HealthcheckTestKind::None),
2370                ..
2371            } => self.invalid_value_optional(
2372                &subject,
2373                "NONE health check cannot contain command arguments",
2374                test.effective_source(),
2375            ),
2376            HealthcheckTest::List { kind: None, .. } => {
2377                self.invalid_value_optional(&subject, "health-check test list is empty", test.effective_source());
2378            }
2379            HealthcheckTest::List {
2380                kind: Some(HealthcheckTestKind::Other),
2381                ..
2382            } => self.unsupported_optional(&subject, "unknown health-check command mode", test.effective_source()),
2383        }
2384    }
2385
2386    fn map_healthcheck_duration(
2387        &mut self,
2388        subject: &str,
2389        duration: &ProjectValue<ComposeHealthcheckDuration>,
2390    ) -> Option<Sourced<NeutralHealthcheckDuration>> {
2391        match duration.value() {
2392            ComposeHealthcheckDuration::Value(value) => match NeutralHealthcheckDuration::new(value.clone()) {
2393                Ok(value) => {
2394                    let value = self.sourced_provenance(value, duration.provenance());
2395                    self.exact_provenance(subject, duration.provenance());
2396                    Some(value)
2397                }
2398                Err(error) => {
2399                    self.invalid_model_optional(subject, &error, duration.effective_source());
2400                    None
2401                }
2402            },
2403            ComposeHealthcheckDuration::Expression(_) => {
2404                self.invalid_value_optional(
2405                    subject,
2406                    "health-check duration expression was not resolved",
2407                    duration.effective_source(),
2408                );
2409                None
2410            }
2411            ComposeHealthcheckDuration::Other(value) => {
2412                self.invalid_value_optional(
2413                    subject,
2414                    &format!("invalid health-check duration `{value}`"),
2415                    duration.effective_source(),
2416                );
2417                None
2418            }
2419        }
2420    }
2421
2422    fn map_environment(&mut self, service_subject: &str, environment: &ProjectEnvironment, service: &mut Service) {
2423        for entry in environment.entries() {
2424            let subject = format!("{service_subject}.environment.{}", entry.name().value());
2425            let Some(name) = self.identifier_optional(&subject, entry.name().value(), entry.name().effective_source())
2426            else {
2427                continue;
2428            };
2429            let value = match entry.value().value() {
2430                ComposeScalar::Null => EnvironmentValue::Host,
2431                ComposeScalar::Boolean(value) => {
2432                    EnvironmentValue::Literal(ProtectedString::sensitive(value.to_string()))
2433                }
2434                ComposeScalar::Number(value) | ComposeScalar::String(value) => {
2435                    if value.contains('$') {
2436                        self.report_unresolved_variable(
2437                            &subject,
2438                            value,
2439                            "environment value contains unresolved Compose variable syntax",
2440                        );
2441                    }
2442                    EnvironmentValue::Literal(ProtectedString::sensitive(value.clone()))
2443                }
2444            };
2445            service.add_environment(
2446                self.sourced_provenance(EnvironmentVariable::new(name, value), entry.value().provenance()),
2447            );
2448            self.exact_provenance(subject, entry.value().provenance());
2449        }
2450    }
2451
2452    fn map_service_environment(&mut self, service_subject: &str, native: &ProjectService, service: &mut Service) {
2453        if let Some(environment) = native.environment() {
2454            self.map_environment(service_subject, environment.value(), service);
2455        }
2456        if let Some(environment_files) = native.environment_files() {
2457            self.map_environment_files(service_subject, environment_files, service);
2458        }
2459    }
2460
2461    fn map_environment_files(
2462        &mut self,
2463        service_subject: &str,
2464        environment_files: &ProjectValue<Vec<ProjectValue<ProjectEnvironmentFile>>>,
2465        service: &mut Service,
2466    ) {
2467        for (index, native) in environment_files.value().iter().enumerate() {
2468            let subject = format!("{service_subject}.env_file[{index}]");
2469            let environment_file = match native.value() {
2470                ProjectEnvironmentFile::Short(path) => match NeutralEnvironmentFile::new(
2471                    Self::protected(path, native.is_sensitive()),
2472                    EnvironmentFileSyntax::Short,
2473                ) {
2474                    Ok(value) => value,
2475                    Err(error) => {
2476                        self.invalid_model_optional(&subject, &error, native.effective_source());
2477                        continue;
2478                    }
2479                },
2480                ProjectEnvironmentFile::Long(long) => {
2481                    let Some(path) = long.path() else {
2482                        self.invalid_value_optional(
2483                            &subject,
2484                            "long-syntax environment file has no path",
2485                            native.effective_source(),
2486                        );
2487                        continue;
2488                    };
2489                    let mut value = match NeutralEnvironmentFile::new(
2490                        Self::protected(path.value(), path.is_sensitive()),
2491                        EnvironmentFileSyntax::Long,
2492                    ) {
2493                        Ok(value) => value,
2494                        Err(error) => {
2495                            self.invalid_model_optional(&subject, &error, path.effective_source());
2496                            continue;
2497                        }
2498                    };
2499                    if let Some(required) = long.required() {
2500                        match required.value() {
2501                            BooleanValue::Literal(required_value) => {
2502                                value.set_required(self.sourced_provenance(*required_value, required.provenance()));
2503                                self.exact_provenance(format!("{subject}.required"), required.provenance());
2504                            }
2505                            BooleanValue::Expression(_) => {
2506                                self.invalid_value_optional(
2507                                    &format!("{subject}.required"),
2508                                    "environment-file required expression was not resolved",
2509                                    required.effective_source(),
2510                                );
2511                                continue;
2512                            }
2513                        }
2514                    }
2515                    if let Some(format) = long.format() {
2516                        match format.value().kind() {
2517                            EnvironmentFileFormatKind::Raw => {
2518                                value.set_format(
2519                                    self.sourced_provenance(NeutralEnvironmentFileFormat::Raw, format.provenance()),
2520                                );
2521                                self.exact_provenance(format!("{subject}.format"), format.provenance());
2522                            }
2523                            EnvironmentFileFormatKind::Expression => {
2524                                self.invalid_value_optional(
2525                                    &format!("{subject}.format"),
2526                                    "environment-file format expression was not resolved",
2527                                    format.effective_source(),
2528                                );
2529                                continue;
2530                            }
2531                            EnvironmentFileFormatKind::Other => {
2532                                self.invalid_value_optional(
2533                                    &format!("{subject}.format"),
2534                                    "environment-file format is not supported by Compose",
2535                                    format.effective_source(),
2536                                );
2537                                continue;
2538                            }
2539                            _ => {
2540                                self.invalid_value_optional(
2541                                    &format!("{subject}.format"),
2542                                    "environment-file format is newer than this BoxFerry adapter",
2543                                    format.effective_source(),
2544                                );
2545                                continue;
2546                            }
2547                        }
2548                    }
2549                    self.report_project_fields(&subject, "environment-file option", long.unmodeled_fields());
2550                    value
2551                }
2552            };
2553            let sourced = self.sourced_provenance(environment_file, native.provenance());
2554            self.exact_origins(subject, sourced.origins());
2555            service.add_environment_file(sourced);
2556        }
2557    }
2558
2559    fn map_labels(&mut self, service_subject: &str, labels: &ProjectLabels, service: &mut Service) {
2560        for (index, entry) in labels.entries().iter().enumerate() {
2561            if entry.name().is_sensitive() {
2562                self.unsupported_optional(
2563                    &format!("{service_subject}.labels[{index}]"),
2564                    "interpolated sensitive label names cannot be represented safely in the neutral model",
2565                    entry.name().effective_source(),
2566                );
2567                continue;
2568            }
2569            let subject = format!("{service_subject}.labels.{}", entry.name().value());
2570            let Some(name) = self.identifier_optional(&subject, entry.name().value(), entry.name().effective_source())
2571            else {
2572                continue;
2573            };
2574            let value = match entry.value().value() {
2575                ComposeScalar::Null => String::new(),
2576                ComposeScalar::Boolean(value) => value.to_string(),
2577                ComposeScalar::Number(value) | ComposeScalar::String(value) => value.clone(),
2578            };
2579            let label = MetadataLabel::new(name, Self::protected(&value, entry.value().is_sensitive()));
2580            let label = self.sourced_metadata_label(label, entry.name().sources(), entry.value().provenance());
2581            self.exact_origins(subject, label.origins());
2582            service.add_label(label);
2583        }
2584    }
2585
2586    fn map_port(&mut self, subject: &str, port: &ComposePort) -> Option<Port> {
2587        match port {
2588            ComposePort::Short(value) => self.map_short_port(subject, value),
2589            ComposePort::Long(value) => self.map_long_port(subject, value),
2590        }
2591    }
2592
2593    fn map_short_port(&mut self, subject: &str, port: &ShortPort) -> Option<Port> {
2594        let target = self.port_number(subject, "target", port.target(), port.raw().span())?;
2595        let published = match port.published() {
2596            Some(value) => Some(self.port_number(subject, "published", value, port.raw().span())?),
2597            None => None,
2598        };
2599        let protocol = map_protocol(port.protocol());
2600        match Port::new(target, published, port.host_ip().map(str::to_owned), protocol) {
2601            Ok(value) => Some(value),
2602            Err(error) => {
2603                self.invalid_model(subject, &error, port.raw().span());
2604                None
2605            }
2606        }
2607    }
2608
2609    fn map_long_port(&mut self, subject: &str, port: &LongPort) -> Option<Port> {
2610        let Some(target) = port.target() else {
2611            self.invalid_value(subject, "long-syntax port has no target", port.span());
2612            return None;
2613        };
2614        let target = self.port_number(subject, "target", target.value(), target.span())?;
2615        let published = match port.published() {
2616            Some(value) => Some(self.port_number(subject, "published", value.value(), value.span())?),
2617            None => None,
2618        };
2619        if port.app_protocol().is_some() {
2620            self.unsupported(subject, "ports.app_protocol", port.span());
2621        }
2622        if port.mode().is_some() {
2623            self.unsupported(subject, "ports.mode", port.span());
2624        }
2625        if port.name().is_some() {
2626            self.unsupported(subject, "ports.name", port.span());
2627        }
2628        self.report_fields(subject, "port extension", port.extension_fields());
2629        self.report_fields(subject, "unknown port field", port.unknown_fields());
2630
2631        match Port::new(
2632            target,
2633            published,
2634            port.host_ip().map(|value| value.value().clone()),
2635            map_protocol(port.protocol().map(|value| value.value().as_str())),
2636        ) {
2637            Ok(value) => Some(value),
2638            Err(error) => {
2639                self.invalid_model(subject, &error, port.span());
2640                None
2641            }
2642        }
2643    }
2644
2645    fn port_number(&mut self, subject: &str, field: &str, value: &str, span: ComposeSpan) -> Option<u16> {
2646        match value.parse::<u16>() {
2647            Ok(0) => {
2648                self.invalid_value(subject, &format!("{field} port must not be zero"), span);
2649                None
2650            }
2651            Ok(value) => Some(value),
2652            Err(_) => {
2653                self.unsupported(subject, &format!("non-single {field} port `{value}`"), span);
2654                None
2655            }
2656        }
2657    }
2658
2659    fn map_mount(&mut self, subject: &str, mount: &VolumeMount) -> Option<Mount> {
2660        match mount {
2661            VolumeMount::Short(value) => self.map_short_mount(subject, value),
2662            VolumeMount::Long(value) => self.map_long_mount(subject, value),
2663        }
2664    }
2665
2666    fn map_short_mount(&mut self, subject: &str, mount: &ShortVolumeMount) -> Option<Mount> {
2667        let Some(target) = mount.target() else {
2668            self.invalid_value(subject, "short-syntax volume has no target", mount.raw().span());
2669            return None;
2670        };
2671        let source = match mount.source() {
2672            None => MountSource::Anonymous,
2673            Some(value) if is_host_path(value) => MountSource::HostPath(value.to_owned()),
2674            Some(value) if value.contains('$') => {
2675                self.unresolved_volume_variable(subject, value, mount.raw().span());
2676                return None;
2677            }
2678            Some(value) => MountSource::Volume(self.identifier(subject, value, mount.raw().span())?),
2679        };
2680        let mut read_only = false;
2681        let mut relabel = None;
2682        for option in mount.options() {
2683            match option.as_str() {
2684                "" | "rw" => {}
2685                "ro" => read_only = true,
2686                "z" => relabel = Some(SelinuxRelabel::Shared),
2687                "Z" => relabel = Some(SelinuxRelabel::Private),
2688                value => self.unsupported(subject, &format!("volume option `{value}`"), mount.raw().span()),
2689            }
2690        }
2691        let mut value = match Mount::new(source, target, read_only) {
2692            Ok(value) => value,
2693            Err(error) => {
2694                self.invalid_model(subject, &error, mount.raw().span());
2695                return None;
2696            }
2697        };
2698        if let Some(relabel) = relabel {
2699            value.set_selinux_relabel(relabel);
2700        }
2701        Some(value)
2702    }
2703
2704    fn map_long_mount(&mut self, subject: &str, mount: &LongVolumeMount) -> Option<Mount> {
2705        let Some(mount_type) = mount.mount_type() else {
2706            self.invalid_value(subject, "long-syntax volume has no type", mount.span());
2707            return None;
2708        };
2709        let Some(target) = mount.target() else {
2710            self.invalid_value(subject, "long-syntax volume has no target", mount.span());
2711            return None;
2712        };
2713        let source = match mount_type.value() {
2714            MountType::Volume => match mount.source() {
2715                Some(value) => MountSource::Volume(self.identifier(subject, value.value(), value.span())?),
2716                None => MountSource::Anonymous,
2717            },
2718            MountType::Bind => {
2719                let Some(value) = mount.source() else {
2720                    self.invalid_value(subject, "long-syntax bind mount has no source", mount.span());
2721                    return None;
2722                };
2723                MountSource::HostPath(value.value().clone())
2724            }
2725            other => {
2726                self.unsupported(subject, &format!("mount type `{other:?}`"), mount.span());
2727                return None;
2728            }
2729        };
2730        let read_only = match mount.read_only().map(compose_lens::model::Located::value) {
2731            None | Some(BooleanValue::Literal(false)) => false,
2732            Some(BooleanValue::Literal(true)) => true,
2733            Some(BooleanValue::Expression(_)) => {
2734                self.invalid_value(subject, "read_only expression was not resolved", mount.span());
2735                return None;
2736            }
2737        };
2738        let mut value = match Mount::new(source, target.value(), read_only) {
2739            Ok(value) => value,
2740            Err(error) => {
2741                self.invalid_model(subject, &error, mount.span());
2742                return None;
2743            }
2744        };
2745        if let Some(bind) = mount.bind() {
2746            if bind.propagation().is_some() {
2747                self.unsupported(subject, "bind.propagation", bind.span());
2748            }
2749            if bind.create_host_path().is_some() {
2750                self.unsupported(subject, "bind.create_host_path", bind.span());
2751            }
2752            if let Some(relabel) = bind.selinux() {
2753                value.set_selinux_relabel(match relabel.value() {
2754                    ComposeSelinuxRelabel::Shared => SelinuxRelabel::Shared,
2755                    ComposeSelinuxRelabel::Private => SelinuxRelabel::Private,
2756                });
2757            }
2758            self.report_fields(subject, "bind extension", bind.extension_fields());
2759            self.report_fields(subject, "unknown bind field", bind.unknown_fields());
2760        }
2761        self.report_fields(subject, "volume extension", mount.extension_fields());
2762        self.report_fields(subject, "unknown volume field", mount.unknown_fields());
2763        Some(value)
2764    }
2765
2766    fn map_service_networks(
2767        &mut self,
2768        service_subject: &str,
2769        networks: &ServiceNetworks,
2770        provenance: &MergeProvenance,
2771        service: &mut Service,
2772    ) {
2773        match networks {
2774            ServiceNetworks::Short { names, .. } => {
2775                for name in names {
2776                    let subject = format!("{service_subject}.networks.{}", name.value());
2777                    let Some(identifier) = self.identifier(&subject, name.value(), name.span()) else {
2778                        continue;
2779                    };
2780                    service.add_network(
2781                        self.sourced_provenance(NetworkAttachment::new(identifier, Vec::new()), provenance),
2782                    );
2783                    self.exact_provenance(subject, provenance);
2784                }
2785            }
2786            ServiceNetworks::Long { networks, .. } => {
2787                for network in networks {
2788                    self.map_service_network(service_subject, network, provenance, service);
2789                }
2790            }
2791        }
2792    }
2793
2794    fn map_service_network(
2795        &mut self,
2796        service_subject: &str,
2797        network: &ServiceNetwork,
2798        provenance: &MergeProvenance,
2799        service: &mut Service,
2800    ) {
2801        let subject = format!("{service_subject}.networks.{}", network.name().value());
2802        let Some(identifier) = self.identifier(&subject, network.name().value(), network.name().span()) else {
2803            return;
2804        };
2805        let aliases = network
2806            .aliases()
2807            .iter()
2808            .map(|alias| self.sourced_spans(Self::protected(alias.value(), true), &[alias.span()]))
2809            .collect();
2810        let mut attachment = NetworkAttachment::new(identifier, aliases);
2811        if let Some(address) = network.ipv4_address() {
2812            attachment.set_ipv4_address(self.sourced_spans(Self::protected(address.value(), true), &[address.span()]));
2813        }
2814        if let Some(address) = network.ipv6_address() {
2815            attachment.set_ipv6_address(self.sourced_spans(Self::protected(address.value(), true), &[address.span()]));
2816        }
2817        service.add_network(self.sourced_provenance(attachment, provenance));
2818
2819        if network.interface_name().is_some() {
2820            self.unsupported(&subject, "networks.interface_name", network.span());
2821        }
2822        if !network.link_local_ips().is_empty() {
2823            self.unsupported(&subject, "networks.link_local_ips", network.span());
2824        }
2825        if network.mac_address().is_some() {
2826            self.unsupported(&subject, "networks.mac_address", network.span());
2827        }
2828        if !network.driver_opts().is_empty() {
2829            self.unsupported(&subject, "networks.driver_opts", network.span());
2830        }
2831        if network.gw_priority().is_some() {
2832            self.unsupported(&subject, "networks.gw_priority", network.span());
2833        }
2834        if network.priority().is_some() {
2835            self.unsupported(&subject, "networks.priority", network.span());
2836        }
2837        self.report_fields(&subject, "network attachment extension", network.extension_fields());
2838        self.report_fields(&subject, "unknown network attachment field", network.unknown_fields());
2839        self.exact_provenance(subject, provenance);
2840    }
2841
2842    fn map_volume_definition(&mut self, resource: &ProjectResource<VolumeDefinition>) -> Option<Sourced<Volume>> {
2843        let native = resource.definition().value();
2844        let subject = format!("volumes.{}", resource.name().value());
2845        if self.reject_obsolete_external_name_mapping(&subject, native.external()) {
2846            return None;
2847        }
2848        let name = self.identifier_optional(&subject, resource.name().value(), resource.name().effective_source())?;
2849        let ownership = self.resource_ownership(&subject, native.external(), native.span());
2850        let definition_sensitive = resource.definition().is_sensitive();
2851        let external = ownership == ResourceOwnership::External;
2852        let mut volume = Volume::new(name, ownership);
2853
2854        if let Some(runtime_name) = native.custom_name() {
2855            if runtime_name.value().contains('$') {
2856                self.invalid_value(
2857                    &format!("{subject}.name"),
2858                    "volume name expression was not resolved",
2859                    runtime_name.span(),
2860                );
2861            } else {
2862                volume.set_runtime_name(self.sourced_spans(
2863                    Self::protected(runtime_name.value(), definition_sensitive),
2864                    &[runtime_name.span()],
2865                ));
2866                self.exact_spans(format!("{subject}.name"), &[runtime_name.span()]);
2867            }
2868        }
2869
2870        if external {
2871            for (field, present) in [
2872                ("driver", native.driver().is_some()),
2873                // ComposeLens exposes this as a vector, so an empty vector cannot distinguish
2874                // omission from an explicit empty mapping. Do not manufacture a reset.
2875                ("driver_opts", !native.driver_opts().is_empty()),
2876                ("labels", native.labels().is_some()),
2877            ] {
2878                if present {
2879                    self.unsupported(
2880                        &format!("{subject}.{field}"),
2881                        "Compose external volumes may only declare their platform name",
2882                        native.span(),
2883                    );
2884                }
2885            }
2886        } else {
2887            let local_driver = match native.driver() {
2888                Some(driver) if driver.value().contains('$') => {
2889                    self.invalid_value(
2890                        &format!("{subject}.driver"),
2891                        "volume driver expression was not resolved",
2892                        driver.span(),
2893                    );
2894                    false
2895                }
2896                Some(driver) => {
2897                    volume.set_driver(
2898                        self.sourced_spans(Self::protected(driver.value(), definition_sensitive), &[driver.span()]),
2899                    );
2900                    self.exact_spans(format!("{subject}.driver"), &[driver.span()]);
2901                    driver.value() == "local"
2902                }
2903                None => false,
2904            };
2905
2906            if !native.driver_opts().is_empty() {
2907                if local_driver {
2908                    self.map_local_volume_driver_options(
2909                        &mut volume,
2910                        &subject,
2911                        native.driver_opts(),
2912                        resource.definition().provenance(),
2913                        definition_sensitive,
2914                    );
2915                } else {
2916                    self.unsupported(
2917                        &format!("{subject}.driver_opts"),
2918                        "Compose local-driver options require an explicit `driver: local`",
2919                        native.span(),
2920                    );
2921                }
2922            }
2923            if let Some(labels) = native.labels() {
2924                self.map_volume_labels(
2925                    &mut volume,
2926                    &subject,
2927                    labels,
2928                    resource.definition().provenance(),
2929                    definition_sensitive,
2930                );
2931            }
2932        }
2933        self.report_fields(&subject, "volume definition extension", native.extension_fields());
2934        self.report_fields(&subject, "unknown volume definition field", native.unknown_fields());
2935        self.exact_provenance(&subject, resource.definition().provenance());
2936        Some(self.sourced_provenance(volume, resource.definition().provenance()))
2937    }
2938
2939    fn map_local_volume_driver_options(
2940        &mut self,
2941        volume: &mut Volume,
2942        subject: &str,
2943        options: &[compose_lens::model::KeyValueEntry],
2944        provenance: &MergeProvenance,
2945        definition_sensitive: bool,
2946    ) {
2947        let mut seen = BTreeSet::new();
2948        let mut exact = true;
2949        for (index, option) in options.iter().enumerate() {
2950            let option_subject = format!("{subject}.driver_opts[{index}]");
2951            let key = option.key().value();
2952            let value = match option.value().value() {
2953                ComposeScalar::String(value) | ComposeScalar::Number(value) if !value.is_empty() => value,
2954                _ => {
2955                    self.invalid_value(
2956                        &option_subject,
2957                        "local volume driver options require non-empty string or number values",
2958                        option.span(),
2959                    );
2960                    exact = false;
2961                    continue;
2962                }
2963            };
2964            if key.contains('$') || value.contains('$') {
2965                self.invalid_value(
2966                    &option_subject,
2967                    "local volume driver-option expression was not resolved",
2968                    option.span(),
2969                );
2970                exact = false;
2971                continue;
2972            }
2973            if !seen.insert(key) {
2974                self.invalid_value(
2975                    &option_subject,
2976                    "local volume driver option is duplicated",
2977                    option.span(),
2978                );
2979                exact = false;
2980                continue;
2981            }
2982            let value = self.sourced_spans(Self::protected(value, definition_sensitive), &[option.value().span()]);
2983            match key.as_str() {
2984                "type" => volume.set_volume_type(value),
2985                "device" => volume.set_device(value),
2986                "o" => volume.set_options(value),
2987                _ => {
2988                    self.unsupported(
2989                        &option_subject,
2990                        "only local volume driver options `type`, `device`, and `o` are modeled",
2991                        option.span(),
2992                    );
2993                    exact = false;
2994                }
2995            }
2996        }
2997        if exact {
2998            self.exact_provenance(format!("{subject}.driver_opts"), provenance);
2999        }
3000    }
3001
3002    fn map_volume_labels(
3003        &mut self,
3004        volume: &mut Volume,
3005        subject: &str,
3006        labels: &Labels,
3007        provenance: &MergeProvenance,
3008        definition_sensitive: bool,
3009    ) {
3010        let mut mapped = Vec::new();
3011        let mut exact = true;
3012        match labels {
3013            Labels::Map { entries, .. } => {
3014                for (index, entry) in entries.iter().enumerate() {
3015                    let entry_subject = format!("{subject}.labels[{index}]");
3016                    let value = Self::compose_scalar(entry.value().value());
3017                    if entry.key().value().contains('$') || value.contains('$') {
3018                        self.invalid_value(&entry_subject, "volume label expression was not resolved", entry.span());
3019                        exact = false;
3020                        continue;
3021                    }
3022                    let Some(name) =
3023                        self.identifier_optional(&entry_subject, entry.key().value(), Some(entry.key().span()))
3024                    else {
3025                        exact = false;
3026                        continue;
3027                    };
3028                    mapped.push(self.sourced_spans(
3029                        MetadataLabel::new(name, Self::protected(&value, definition_sensitive)),
3030                        &[entry.key().span(), entry.value().span()],
3031                    ));
3032                }
3033            }
3034            Labels::List { values, .. } => {
3035                for (index, value) in values.iter().enumerate() {
3036                    let entry_subject = format!("{subject}.labels[{index}]");
3037                    let Some((name, label_value)) = literal_assignment(value.value()) else {
3038                        self.invalid_value(
3039                            &entry_subject,
3040                            "volume label list entries must use unambiguous name=value spelling",
3041                            value.span(),
3042                        );
3043                        exact = false;
3044                        continue;
3045                    };
3046                    if value.value().contains('$') {
3047                        self.invalid_value(&entry_subject, "volume label expression was not resolved", value.span());
3048                        exact = false;
3049                        continue;
3050                    }
3051                    let Some(name) = self.identifier_optional(&entry_subject, name, Some(value.span())) else {
3052                        exact = false;
3053                        continue;
3054                    };
3055                    mapped.push(self.sourced_spans(
3056                        MetadataLabel::new(name, Self::protected(label_value, definition_sensitive)),
3057                        &[value.span()],
3058                    ));
3059                }
3060            }
3061        }
3062        volume.set_labels_with_origins(mapped, self.origins(provenance));
3063        if exact {
3064            self.exact_provenance(format!("{subject}.labels"), provenance);
3065        }
3066    }
3067
3068    #[allow(clippy::too_many_lines)]
3069    fn map_network_definition(&mut self, resource: &ProjectResource<NetworkDefinition>) -> Option<Sourced<Network>> {
3070        let native = resource.definition().value();
3071        let subject = format!("networks.{}", resource.name().value());
3072        if self.reject_obsolete_external_name_mapping(&subject, native.external()) {
3073            return None;
3074        }
3075        let name = self.identifier_optional(&subject, resource.name().value(), resource.name().effective_source())?;
3076        let ownership = self.resource_ownership(&subject, native.external(), native.span());
3077        let definition_sensitive = resource.definition().is_sensitive();
3078        let external = ownership == ResourceOwnership::External;
3079        let mut network = Network::new(name, ownership);
3080
3081        if let Some(runtime_name) = native.custom_name() {
3082            if runtime_name.value().contains('$') {
3083                self.invalid_value(
3084                    &format!("{subject}.name"),
3085                    "network name expression was not resolved",
3086                    runtime_name.span(),
3087                );
3088            } else {
3089                network.set_runtime_name(self.sourced_spans(
3090                    Self::protected(runtime_name.value(), definition_sensitive),
3091                    &[runtime_name.span()],
3092                ));
3093                self.exact_spans(format!("{subject}.name"), &[runtime_name.span()]);
3094            }
3095        }
3096
3097        if external {
3098            for (field, present) in [
3099                ("driver", native.driver().is_some()),
3100                ("driver_opts", !native.driver_opts().is_empty()),
3101                ("attachable", native.attachable().is_some()),
3102                ("enable_ipv4", native.enable_ipv4().is_some()),
3103                ("enable_ipv6", native.enable_ipv6().is_some()),
3104                ("internal", native.internal().is_some()),
3105                ("ipam", native.ipam().is_some()),
3106                ("labels", native.labels().is_some()),
3107            ] {
3108                if present {
3109                    self.unsupported(
3110                        &format!("{subject}.{field}"),
3111                        "Compose external networks may only declare their platform name",
3112                        native.span(),
3113                    );
3114                }
3115            }
3116        } else {
3117            if let Some(driver) = native.driver() {
3118                if driver.value().contains('$') {
3119                    self.invalid_value(
3120                        &format!("{subject}.driver"),
3121                        "network driver expression was not resolved",
3122                        driver.span(),
3123                    );
3124                } else {
3125                    network.set_driver(
3126                        self.sourced_spans(Self::protected(driver.value(), definition_sensitive), &[driver.span()]),
3127                    );
3128                    self.exact_spans(format!("{subject}.driver"), &[driver.span()]);
3129                }
3130            }
3131
3132            if !native.driver_opts().is_empty() {
3133                let mut options = Vec::with_capacity(native.driver_opts().len());
3134                let mut exact = true;
3135                for (index, option) in native.driver_opts().iter().enumerate() {
3136                    let option_subject = format!("{subject}.driver_opts[{index}]");
3137                    let value = Self::compose_scalar(option.value().value());
3138                    if option.key().value().contains('$') || value.contains('$') {
3139                        self.invalid_value(
3140                            &option_subject,
3141                            "network driver-option expression was not resolved",
3142                            option.span(),
3143                        );
3144                        exact = false;
3145                        continue;
3146                    }
3147                    let Some(option_name) =
3148                        self.identifier_optional(&option_subject, option.key().value(), Some(option.key().span()))
3149                    else {
3150                        exact = false;
3151                        continue;
3152                    };
3153                    let option_span = option.span();
3154                    match NetworkDriverOption::new(
3155                        self.sourced_spans(option_name, &[option.key().span()]),
3156                        self.sourced_spans(Self::protected(&value, definition_sensitive), &[option.value().span()]),
3157                    ) {
3158                        Ok(option) => options.push(self.sourced_spans(option, &[option_span])),
3159                        Err(error) => {
3160                            self.invalid_model(&option_subject, &error, option.span());
3161                            exact = false;
3162                        }
3163                    }
3164                }
3165                network.set_driver_options_with_origins(options, self.origins(resource.definition().provenance()));
3166                if exact {
3167                    self.exact_provenance(format!("{subject}.driver_opts"), resource.definition().provenance());
3168                }
3169            }
3170
3171            if native.attachable().is_some() {
3172                self.unsupported(&format!("{subject}.attachable"), "network.attachable", native.span());
3173            }
3174            if native.enable_ipv4().is_some() {
3175                self.unsupported(&format!("{subject}.enable_ipv4"), "network.enable_ipv4", native.span());
3176            }
3177            self.map_network_boolean(
3178                &mut network,
3179                &subject,
3180                "enable_ipv6",
3181                native.enable_ipv6(),
3182                Network::set_ipv6,
3183            );
3184            self.map_network_boolean(
3185                &mut network,
3186                &subject,
3187                "internal",
3188                native.internal(),
3189                Network::set_internal,
3190            );
3191
3192            if let Some(ipam) = native.ipam() {
3193                if let Some(driver) = ipam.driver() {
3194                    if driver.value().contains('$') {
3195                        self.invalid_value(
3196                            &format!("{subject}.ipam.driver"),
3197                            "IPAM driver expression was not resolved",
3198                            driver.span(),
3199                        );
3200                    } else {
3201                        network.set_ipam_driver(
3202                            self.sourced_spans(Self::protected(driver.value(), definition_sensitive), &[driver.span()]),
3203                        );
3204                        self.exact_spans(format!("{subject}.ipam.driver"), &[driver.span()]);
3205                    }
3206                }
3207                if !ipam.options().is_empty() {
3208                    self.unsupported(&format!("{subject}.ipam.options"), "network.ipam.options", ipam.span());
3209                }
3210                if !ipam.config().is_empty() {
3211                    let mut configs = Vec::with_capacity(ipam.config().len());
3212                    let mut exact = true;
3213                    for (index, config) in ipam.config().iter().enumerate() {
3214                        let config_subject = format!("{subject}.ipam.config[{index}]");
3215                        let Some(subnet) = config.subnet() else {
3216                            self.invalid_value(&config_subject, "IPAM configuration requires a subnet", config.span());
3217                            exact = false;
3218                            continue;
3219                        };
3220                        if subnet.value().contains('$')
3221                            || config.gateway().is_some_and(|value| value.value().contains('$'))
3222                            || config.ip_range().is_some_and(|value| value.value().contains('$'))
3223                        {
3224                            self.invalid_value(&config_subject, "IPAM expression was not resolved", config.span());
3225                            exact = false;
3226                            continue;
3227                        }
3228                        let mut mapped = match NetworkIpamConfig::new(
3229                            self.sourced_spans(Self::protected(subnet.value(), definition_sensitive), &[subnet.span()]),
3230                        ) {
3231                            Ok(value) => value,
3232                            Err(error) => {
3233                                self.invalid_model(&config_subject, &error, subnet.span());
3234                                exact = false;
3235                                continue;
3236                            }
3237                        };
3238                        if let Some(gateway) = config.gateway() {
3239                            if let Err(error) = mapped.set_gateway(self.sourced_spans(
3240                                Self::protected(gateway.value(), definition_sensitive),
3241                                &[gateway.span()],
3242                            )) {
3243                                self.invalid_model(&config_subject, &error, gateway.span());
3244                                exact = false;
3245                                continue;
3246                            }
3247                        }
3248                        if let Some(ip_range) = config.ip_range() {
3249                            if let Err(error) = mapped.set_ip_range(self.sourced_spans(
3250                                Self::protected(ip_range.value(), definition_sensitive),
3251                                &[ip_range.span()],
3252                            )) {
3253                                self.invalid_model(&config_subject, &error, ip_range.span());
3254                                exact = false;
3255                                continue;
3256                            }
3257                        }
3258                        if !config.aux_addresses().is_empty() {
3259                            self.unsupported(
3260                                &format!("{config_subject}.aux_addresses"),
3261                                "network.ipam.config.aux_addresses",
3262                                config.span(),
3263                            );
3264                            exact = false;
3265                        }
3266                        self.report_fields(
3267                            &config_subject,
3268                            "IPAM configuration extension",
3269                            config.extension_fields(),
3270                        );
3271                        self.report_fields(
3272                            &config_subject,
3273                            "unknown IPAM configuration field",
3274                            config.unknown_fields(),
3275                        );
3276                        configs.push(self.sourced_spans(mapped, &[config.span()]));
3277                    }
3278                    network.set_ipam_configs_with_origins(configs, self.origins(resource.definition().provenance()));
3279                    if exact {
3280                        self.exact_provenance(format!("{subject}.ipam.config"), resource.definition().provenance());
3281                    }
3282                }
3283                self.report_fields(&format!("{subject}.ipam"), "IPAM extension", ipam.extension_fields());
3284                self.report_fields(&format!("{subject}.ipam"), "unknown IPAM field", ipam.unknown_fields());
3285            }
3286
3287            if let Some(labels) = native.labels() {
3288                self.map_network_labels(
3289                    &mut network,
3290                    &subject,
3291                    labels,
3292                    resource.definition().provenance(),
3293                    definition_sensitive,
3294                );
3295            }
3296        }
3297        self.report_fields(&subject, "network definition extension", native.extension_fields());
3298        self.report_fields(&subject, "unknown network definition field", native.unknown_fields());
3299        self.exact_provenance(&subject, resource.definition().provenance());
3300        Some(self.sourced_provenance(network, resource.definition().provenance()))
3301    }
3302
3303    fn map_network_boolean(
3304        &mut self,
3305        network: &mut Network,
3306        subject: &str,
3307        field: &str,
3308        value: Option<&compose_lens::model::Located<BooleanValue>>,
3309        setter: fn(&mut Network, Sourced<bool>),
3310    ) {
3311        let Some(value) = value else { return };
3312        match value.value() {
3313            BooleanValue::Literal(literal) => {
3314                setter(network, self.sourced_spans(*literal, &[value.span()]));
3315                self.exact_spans(format!("{subject}.{field}"), &[value.span()]);
3316            }
3317            BooleanValue::Expression(_) => self.invalid_value(
3318                &format!("{subject}.{field}"),
3319                "network boolean expression was not resolved",
3320                value.span(),
3321            ),
3322        }
3323    }
3324
3325    fn map_network_labels(
3326        &mut self,
3327        network: &mut Network,
3328        subject: &str,
3329        labels: &Labels,
3330        provenance: &MergeProvenance,
3331        definition_sensitive: bool,
3332    ) {
3333        let mut mapped = Vec::new();
3334        let mut exact = true;
3335        match labels {
3336            Labels::Map { entries, .. } => {
3337                for (index, entry) in entries.iter().enumerate() {
3338                    let entry_subject = format!("{subject}.labels[{index}]");
3339                    let value = Self::compose_scalar(entry.value().value());
3340                    if entry.key().value().contains('$') || value.contains('$') {
3341                        self.invalid_value(
3342                            &entry_subject,
3343                            "network label expression was not resolved",
3344                            entry.span(),
3345                        );
3346                        exact = false;
3347                        continue;
3348                    }
3349                    let Some(name) =
3350                        self.identifier_optional(&entry_subject, entry.key().value(), Some(entry.key().span()))
3351                    else {
3352                        exact = false;
3353                        continue;
3354                    };
3355                    let label = MetadataLabel::new(name, Self::protected(&value, definition_sensitive));
3356                    mapped.push(self.sourced_spans(label, &[entry.key().span(), entry.value().span()]));
3357                }
3358            }
3359            Labels::List { values, .. } => {
3360                for (index, value) in values.iter().enumerate() {
3361                    let entry_subject = format!("{subject}.labels[{index}]");
3362                    let Some((name, label_value)) = literal_assignment(value.value()) else {
3363                        self.invalid_value(
3364                            &entry_subject,
3365                            "network label list entries must use unambiguous name=value spelling",
3366                            value.span(),
3367                        );
3368                        exact = false;
3369                        continue;
3370                    };
3371                    if value.value().contains('$') {
3372                        self.invalid_value(
3373                            &entry_subject,
3374                            "network label expression was not resolved",
3375                            value.span(),
3376                        );
3377                        exact = false;
3378                        continue;
3379                    }
3380                    let Some(name) = self.identifier_optional(&entry_subject, name, Some(value.span())) else {
3381                        exact = false;
3382                        continue;
3383                    };
3384                    mapped.push(self.sourced_spans(
3385                        MetadataLabel::new(name, Self::protected(label_value, definition_sensitive)),
3386                        &[value.span()],
3387                    ));
3388                }
3389            }
3390        }
3391        network.set_labels_with_origins(mapped, self.origins(provenance));
3392        if exact {
3393            self.exact_provenance(format!("{subject}.labels"), provenance);
3394        }
3395    }
3396
3397    fn map_config_definition(&mut self, resource: &ProjectResource<ConfigDefinition>) -> Option<Sourced<Config>> {
3398        let native = resource.definition().value();
3399        let subject = format!("configs.{}", resource.name().value());
3400        if self.reject_obsolete_external_name_mapping(&subject, native.external()) {
3401            return None;
3402        }
3403        let name = self.identifier_optional(&subject, resource.name().value(), resource.name().effective_source())?;
3404        let ownership = self.resource_ownership(&subject, native.external(), native.span());
3405        let mut config = Config::new(name, ownership);
3406
3407        if let Some(runtime_name) = native.custom_name() {
3408            if runtime_name.value().contains('$') {
3409                self.invalid_value(
3410                    &format!("{subject}.name"),
3411                    "config name expression was not resolved",
3412                    runtime_name.span(),
3413                );
3414            } else {
3415                config.set_runtime_name(self.sourced_spans(
3416                    Self::protected(runtime_name.value(), resource.definition().is_sensitive()),
3417                    &[runtime_name.span()],
3418                ));
3419                self.exact_spans(format!("{subject}.runtime_name"), &[runtime_name.span()]);
3420            }
3421        }
3422
3423        let materials = [
3424            native.file().map(|value| {
3425                (
3426                    ConfigMaterial::File(Self::protected(value.value(), resource.definition().is_sensitive())),
3427                    value.span(),
3428                )
3429            }),
3430            native.environment().map(|value| {
3431                (
3432                    ConfigMaterial::Environment(Self::protected(value.value(), resource.definition().is_sensitive())),
3433                    value.span(),
3434                )
3435            }),
3436            native.content().map(|value| {
3437                (
3438                    ConfigMaterial::Content(Self::protected(value.value(), resource.definition().is_sensitive())),
3439                    value.span(),
3440                )
3441            }),
3442        ]
3443        .into_iter()
3444        .flatten()
3445        .collect::<Vec<_>>();
3446        self.map_config_material(&subject, ownership, &materials, &mut config, resource.definition());
3447        if let Some(labels) = native.labels() {
3448            self.unsupported(
3449                &format!("{subject}.labels"),
3450                "config metadata labels have no reviewed neutral-model mapping",
3451                labels.span(),
3452            );
3453        }
3454        if let Some(template_driver) = native.template_driver() {
3455            self.unsupported(
3456                &format!("{subject}.template_driver"),
3457                "config template driver has no reviewed neutral-model mapping",
3458                template_driver.span(),
3459            );
3460        }
3461
3462        self.report_fields(&subject, "config definition extension", native.extension_fields());
3463        self.report_fields(&subject, "unknown config definition field", native.unknown_fields());
3464        self.exact_provenance(&subject, resource.definition().provenance());
3465        Some(self.sourced_provenance(config, resource.definition().provenance()))
3466    }
3467
3468    fn map_config_material(
3469        &mut self,
3470        subject: &str,
3471        ownership: ResourceOwnership,
3472        materials: &[(ConfigMaterial, ComposeSpan)],
3473        config: &mut Config,
3474        definition: &ProjectValue<ConfigDefinition>,
3475    ) {
3476        match materials {
3477            [(material, span)] => {
3478                config.set_material(self.sourced_spans(material.clone(), &[*span]));
3479                self.exact_spans(format!("{subject}.material"), &[*span]);
3480                if ownership == ResourceOwnership::External {
3481                    self.invalid_value_optional(
3482                        subject,
3483                        "external config cannot also declare application-managed material",
3484                        Some(*span),
3485                    );
3486                }
3487            }
3488            [] if ownership != ResourceOwnership::External => self.invalid_value_optional(
3489                subject,
3490                "application-managed config requires exactly one of file, environment, or content",
3491                definition.effective_source(),
3492            ),
3493            [] => {}
3494            _ => self.invalid_value_optional(
3495                subject,
3496                "config declares multiple material sources; exactly one of file, environment, or content is allowed",
3497                definition.effective_source(),
3498            ),
3499        }
3500    }
3501
3502    fn map_secret_definition(&mut self, resource: &ProjectResource<SecretDefinition>) -> Option<Sourced<Secret>> {
3503        let native = resource.definition().value();
3504        let subject = format!("secrets.{}", resource.name().value());
3505        if self.reject_obsolete_external_name_mapping(&subject, native.external()) {
3506            return None;
3507        }
3508        let name = self.identifier_optional(&subject, resource.name().value(), resource.name().effective_source())?;
3509        let ownership = self.resource_ownership(&subject, native.external(), native.span());
3510        let mut secret = Secret::new(name, ownership);
3511
3512        if let Some(runtime_name) = native.custom_name() {
3513            if runtime_name.value().contains('$') {
3514                self.invalid_value(
3515                    &format!("{subject}.name"),
3516                    "secret name expression was not resolved",
3517                    runtime_name.span(),
3518                );
3519            } else {
3520                secret.set_runtime_name(self.sourced_spans(
3521                    Self::protected(runtime_name.value(), resource.definition().is_sensitive()),
3522                    &[runtime_name.span()],
3523                ));
3524                self.exact_spans(format!("{subject}.runtime_name"), &[runtime_name.span()]);
3525            }
3526        }
3527
3528        let materials = [
3529            native.file().map(|value| {
3530                (
3531                    SecretMaterial::File(Self::protected(value.value(), resource.definition().is_sensitive())),
3532                    value.span(),
3533                )
3534            }),
3535            native.environment().map(|value| {
3536                (
3537                    SecretMaterial::Environment(Self::protected(value.value(), resource.definition().is_sensitive())),
3538                    value.span(),
3539                )
3540            }),
3541        ]
3542        .into_iter()
3543        .flatten()
3544        .collect::<Vec<_>>();
3545        self.map_secret_material(&subject, ownership, &materials, &mut secret, resource.definition());
3546        if let Some(driver) = native.driver() {
3547            self.unsupported(
3548                &format!("{subject}.driver"),
3549                "secret driver has no reviewed neutral-model mapping",
3550                driver.span(),
3551            );
3552        }
3553        let driver_option_spans = native
3554            .driver_opts()
3555            .iter()
3556            .map(compose_lens::model::KeyValueEntry::span)
3557            .collect::<Vec<_>>();
3558        if !driver_option_spans.is_empty() {
3559            self.unsupported_spans(
3560                &format!("{subject}.driver_opts"),
3561                "secret driver options have no reviewed neutral-model mapping",
3562                &driver_option_spans,
3563            );
3564        }
3565        if let Some(labels) = native.labels() {
3566            self.unsupported(
3567                &format!("{subject}.labels"),
3568                "secret metadata labels have no reviewed neutral-model mapping",
3569                labels.span(),
3570            );
3571        }
3572        if let Some(template_driver) = native.template_driver() {
3573            self.unsupported(
3574                &format!("{subject}.template_driver"),
3575                "secret template driver has no reviewed neutral-model mapping",
3576                template_driver.span(),
3577            );
3578        }
3579
3580        self.report_fields(&subject, "secret definition extension", native.extension_fields());
3581        self.report_fields(&subject, "unknown secret definition field", native.unknown_fields());
3582        self.exact_provenance(&subject, resource.definition().provenance());
3583        Some(self.sourced_provenance(secret, resource.definition().provenance()))
3584    }
3585
3586    fn map_secret_material(
3587        &mut self,
3588        subject: &str,
3589        ownership: ResourceOwnership,
3590        materials: &[(SecretMaterial, ComposeSpan)],
3591        secret: &mut Secret,
3592        definition: &ProjectValue<SecretDefinition>,
3593    ) {
3594        match materials {
3595            [(material, span)] => {
3596                secret.set_material(self.sourced_spans(material.clone(), &[*span]));
3597                self.exact_spans(format!("{subject}.material"), &[*span]);
3598                if ownership == ResourceOwnership::External {
3599                    self.invalid_value_optional(
3600                        subject,
3601                        "external secret cannot also declare application-managed material",
3602                        Some(*span),
3603                    );
3604                }
3605            }
3606            [] if ownership != ResourceOwnership::External => self.invalid_value_optional(
3607                subject,
3608                "application-managed secret requires exactly one of file or environment",
3609                definition.effective_source(),
3610            ),
3611            [] => {}
3612            _ => self.invalid_value_optional(
3613                subject,
3614                "secret declares multiple material sources; exactly one of file or environment is allowed",
3615                definition.effective_source(),
3616            ),
3617        }
3618    }
3619
3620    fn map_service_grants(
3621        &mut self,
3622        service_subject: &str,
3623        field: &str,
3624        grants: &ProjectValue<Vec<ProjectValue<ProjectGrant>>>,
3625        secret: bool,
3626        service: &mut Service,
3627    ) {
3628        for (index, native) in grants.value().iter().enumerate() {
3629            let subject = format!("{service_subject}.{field}[{index}]");
3630            let grant = match native.value() {
3631                ProjectGrant::Short(source) => ResourceGrant::new(
3632                    Self::protected(source, native.is_sensitive()),
3633                    ResourceGrantSyntax::Short,
3634                ),
3635                ProjectGrant::Long(long) => {
3636                    let Some(source) = long.source() else {
3637                        self.invalid_value_optional(
3638                            &subject,
3639                            "long-syntax resource grant requires source",
3640                            native.effective_source(),
3641                        );
3642                        continue;
3643                    };
3644                    let mut grant = match ResourceGrant::new(
3645                        Self::protected(source.value(), source.is_sensitive()),
3646                        ResourceGrantSyntax::Long,
3647                    ) {
3648                        Ok(grant) => grant,
3649                        Err(error) => {
3650                            self.invalid_model_optional(&subject, &error, source.effective_source());
3651                            continue;
3652                        }
3653                    };
3654                    Self::set_grant_field(&mut grant, long.target(), ResourceGrant::set_target, self);
3655                    Self::set_grant_field(&mut grant, long.uid(), ResourceGrant::set_uid, self);
3656                    Self::set_grant_field(&mut grant, long.gid(), ResourceGrant::set_gid, self);
3657                    Self::set_grant_field(&mut grant, long.mode(), ResourceGrant::set_mode, self);
3658                    self.report_project_fields(&subject, "resource-grant field", long.unmodeled_fields());
3659                    Ok(grant)
3660                }
3661            };
3662            match grant {
3663                Ok(grant) => {
3664                    let grant = self.sourced_provenance(grant, native.provenance());
3665                    self.exact_origins(&subject, grant.origins());
3666                    if secret {
3667                        service.add_secret_grant(grant);
3668                    } else {
3669                        service.add_config_grant(grant);
3670                    }
3671                }
3672                Err(error) => self.invalid_model_optional(&subject, &error, native.effective_source()),
3673            }
3674        }
3675    }
3676
3677    fn set_grant_field(
3678        grant: &mut ResourceGrant,
3679        value: Option<&ProjectValue<String>>,
3680        setter: fn(&mut ResourceGrant, Sourced<ProtectedString>),
3681        mapping: &Self,
3682    ) {
3683        if let Some(value) = value {
3684            setter(
3685                grant,
3686                mapping.sourced_provenance(Self::protected(value.value(), value.is_sensitive()), value.provenance()),
3687            );
3688        }
3689    }
3690
3691    fn resource_ownership(
3692        &mut self,
3693        subject: &str,
3694        external: Option<&ResourceExternal>,
3695        span: ComposeSpan,
3696    ) -> ResourceOwnership {
3697        match external {
3698            Some(ResourceExternal::Boolean(value)) if matches!(value.value(), BooleanValue::Literal(true)) => {
3699                ResourceOwnership::External
3700            }
3701            None => ResourceOwnership::Application,
3702            Some(ResourceExternal::Boolean(value)) if matches!(value.value(), BooleanValue::Literal(false)) => {
3703                ResourceOwnership::Application
3704            }
3705            Some(ResourceExternal::Boolean(_)) => {
3706                self.invalid_value(subject, "external expression was not resolved", span);
3707                ResourceOwnership::Application
3708            }
3709            Some(ResourceExternal::NameMapping(mapping)) => {
3710                self.invalid_value(
3711                    subject,
3712                    "obsolete `external: {name: ...}` syntax is not accepted; use `external: true` and top-level `name: ...`",
3713                    mapping.span(),
3714                );
3715                ResourceOwnership::Application
3716            }
3717            Some(_) => {
3718                self.invalid_value(
3719                    subject,
3720                    "external lifecycle form is not supported by this BoxFerry version",
3721                    span,
3722                );
3723                ResourceOwnership::Application
3724            }
3725        }
3726    }
3727
3728    fn reject_obsolete_external_name_mapping(&mut self, subject: &str, external: Option<&ResourceExternal>) -> bool {
3729        let Some(ResourceExternal::NameMapping(mapping)) = external else {
3730            return false;
3731        };
3732        self.invalid_value(
3733            subject,
3734            "obsolete `external: {name: ...}` syntax is not accepted; use `external: true` and top-level `name: ...`",
3735            mapping.span(),
3736        );
3737        true
3738    }
3739
3740    fn report_service_unsupported(&mut self, subject: &str, service: &ProjectService) {
3741        macro_rules! report {
3742            ($field:ident, $name:literal) => {
3743                self.report_typed_service_value(subject, $name, service.$field());
3744            };
3745        }
3746
3747        // ComposeLens 0.2 promotes these fields out of the generic unmodeled collection. Until
3748        // BoxFerry has a reviewed neutral contract for each one, retain their effective presence
3749        // as a policy-controlled outcome instead of silently dropping it.
3750        report!(domainname, "domainname");
3751        report!(platform, "platform");
3752        report!(isolation, "isolation");
3753        report!(mac_address, "mac_address");
3754        report!(credential_spec, "credential_spec");
3755        report!(extends, "extends");
3756        report!(provider, "provider");
3757        report!(post_start, "post_start");
3758        report!(pre_stop, "pre_stop");
3759        report!(pre_start, "pre_start");
3760        report!(blkio_config, "blkio_config");
3761        report!(cgroup, "cgroup");
3762        report!(cgroup_parent, "cgroup_parent");
3763        report!(runtime, "runtime");
3764        report!(pull_refresh_after, "pull_refresh_after");
3765        report!(attach, "attach");
3766        report!(stdin_open, "stdin_open");
3767        report!(tty, "tty");
3768        report!(privileged, "privileged");
3769        report!(use_api_socket, "use_api_socket");
3770        report!(label_files, "label_file");
3771        report!(external_links, "external_links");
3772        report!(links, "links");
3773        report!(storage_opt, "storage_opt");
3774        report!(models, "models");
3775        report!(gpus, "gpus");
3776        report!(develop, "develop");
3777        report!(cpu_count, "cpu_count");
3778        report!(cpu_percent, "cpu_percent");
3779        report!(cpu_period, "cpu_period");
3780        report!(cpu_quota, "cpu_quota");
3781        report!(cpu_rt_period, "cpu_rt_period");
3782        report!(cpu_rt_runtime, "cpu_rt_runtime");
3783        report!(cpu_shares, "cpu_shares");
3784        report!(cpus, "cpus");
3785        report!(cpuset, "cpuset");
3786        report!(device_cgroup_rules, "device_cgroup_rules");
3787        report!(ipc, "ipc");
3788        report!(mem_reservation, "mem_reservation");
3789        report!(mem_swappiness, "mem_swappiness");
3790        report!(memswap_limit, "memswap_limit");
3791        report!(network_mode, "network_mode");
3792        report!(oom_kill_disable, "oom_kill_disable");
3793        report!(oom_score_adj, "oom_score_adj");
3794        report!(pid, "pid");
3795        report!(scale, "scale");
3796        report!(volumes_from, "volumes_from");
3797        self.report_volume_mount_options(subject, service);
3798        for item in service.invalid_device_cgroup_rules() {
3799            self.unsupported(
3800                &format!("{subject}.device_cgroup_rules"),
3801                "invalid device_cgroup_rules item",
3802                item.span(),
3803            );
3804        }
3805        for item in service.invalid_volumes_from() {
3806            self.unsupported(
3807                &format!("{subject}.volumes_from"),
3808                "invalid volumes_from item",
3809                item.span(),
3810            );
3811        }
3812        self.report_project_fields(subject, "service field", service.unmodeled_fields());
3813    }
3814
3815    fn report_document_unsupported(&mut self, view: &ProjectView) {
3816        self.report_typed_document_value("application.include", view.include());
3817        self.report_typed_document_value("application.models", view.models());
3818        self.report_project_fields("application", "document field", view.unmodeled_fields());
3819    }
3820
3821    fn report_typed_service_value<T>(&mut self, service_subject: &str, field: &str, value: Option<&ProjectValue<T>>) {
3822        if let Some(value) = value {
3823            self.unsupported_optional(
3824                &format!("{service_subject}.{field}"),
3825                &format!("ComposeLens typed `{field}` has no reviewed neutral-model mapping"),
3826                value.effective_source(),
3827            );
3828        }
3829    }
3830
3831    fn report_typed_document_value<T>(&mut self, subject: &str, value: Option<&ProjectValue<T>>) {
3832        if let Some(value) = value {
3833            self.unsupported_optional(
3834                subject,
3835                "ComposeLens typed document field has no reviewed neutral-model mapping",
3836                value.effective_source(),
3837            );
3838        }
3839    }
3840
3841    fn report_volume_mount_options(&mut self, service_subject: &str, service: &ProjectService) {
3842        let Some(options) = service.volume_mount_options() else {
3843            return;
3844        };
3845        let has_unmapped_options = options.value().iter().any(|item| {
3846            let options = item.value();
3847            options.consistency().is_some()
3848                || options.bind().is_some_and(|bind| {
3849                    bind.recursive().is_some()
3850                        || !bind.extension_fields().is_empty()
3851                        || !bind.unknown_fields().is_empty()
3852                })
3853                || options.image().is_some_and(|image| {
3854                    image.subpath().is_some()
3855                        || !image.extension_fields().is_empty()
3856                        || !image.unknown_fields().is_empty()
3857                })
3858                || options.tmpfs().is_some_and(|tmpfs| {
3859                    tmpfs.size().is_some()
3860                        || tmpfs.mode().is_some()
3861                        || !tmpfs.extension_fields().is_empty()
3862                        || !tmpfs.unknown_fields().is_empty()
3863                })
3864                || options.volume().is_some_and(|volume| {
3865                    volume.nocopy().is_some()
3866                        || volume.subpath().is_some()
3867                        || volume.labels().is_some()
3868                        || !volume.extension_fields().is_empty()
3869                        || !volume.unknown_fields().is_empty()
3870                })
3871                || !options.unmodeled_fields().is_empty()
3872        });
3873        if has_unmapped_options {
3874            self.report_typed_service_value(service_subject, "volume_mount_options", Some(options));
3875        }
3876    }
3877
3878    fn report_fields(&mut self, subject: &str, kind: &str, fields: &[compose_lens::model::FieldReference]) {
3879        for field in fields {
3880            self.unsupported(subject, &format!("{kind} `{}`", field.name().value()), field.span());
3881        }
3882    }
3883
3884    fn report_project_fields(&mut self, subject: &str, kind: &str, fields: &[ProjectFieldReference]) {
3885        for field in fields {
3886            let feature = field.path().join(".");
3887            self.unsupported_optional(
3888                subject,
3889                &format!("{kind} `{feature}`"),
3890                field
3891                    .provenance()
3892                    .effective_source()
3893                    .or_else(|| field.key().effective_source()),
3894            );
3895        }
3896    }
3897
3898    fn identifier(&mut self, subject: &str, value: &str, span: ComposeSpan) -> Option<Identifier> {
3899        self.identifier_optional(subject, value, Some(span))
3900    }
3901
3902    fn identifier_optional(&mut self, subject: &str, value: &str, span: Option<ComposeSpan>) -> Option<Identifier> {
3903        match Identifier::new(value) {
3904            Ok(value) => Some(value),
3905            Err(error) => {
3906                self.invalid_model_optional(subject, &error, span);
3907                None
3908            }
3909        }
3910    }
3911
3912    fn sourced_provenance<T>(&self, value: T, provenance: &MergeProvenance) -> Sourced<T> {
3913        let mut sourced = Sourced::generated(value);
3914        for origin in provenance.sources().iter().filter_map(|span| self.origin(*span)) {
3915            sourced.add_origin(origin);
3916        }
3917        sourced
3918    }
3919
3920    fn origins(&self, provenance: &MergeProvenance) -> Vec<Provenance> {
3921        provenance
3922            .sources()
3923            .iter()
3924            .filter_map(|span| self.origin(*span))
3925            .collect()
3926    }
3927
3928    fn sourced_spans<T>(&self, value: T, source_spans: &[ComposeSpan]) -> Sourced<T> {
3929        let mut result = Sourced::generated(value);
3930        for origin in source_spans.iter().filter_map(|span| self.origin(*span)) {
3931            if !result.origins().contains(&origin) {
3932                result.add_origin(origin);
3933            }
3934        }
3935        result
3936    }
3937
3938    fn sourced_project_key_value<T>(
3939        &self,
3940        value: T,
3941        key_sources: &[ComposeSpan],
3942        value_provenance: &MergeProvenance,
3943    ) -> Sourced<T> {
3944        let mut result = self.sourced_spans(value, key_sources);
3945        for origin in value_provenance.sources().iter().filter_map(|span| self.origin(*span)) {
3946            if !result.origins().contains(&origin) {
3947                result.add_origin(origin);
3948            }
3949        }
3950        result
3951    }
3952
3953    fn sourced_host_mapping(
3954        &self,
3955        value: HostMapping,
3956        hostname_sources: &[ComposeSpan],
3957        address_provenance: &MergeProvenance,
3958    ) -> Sourced<HostMapping> {
3959        let mut sourced = Sourced::generated(value);
3960        for origin in hostname_sources
3961            .iter()
3962            .chain(address_provenance.sources())
3963            .filter_map(|span| self.origin(*span))
3964        {
3965            if !sourced.origins().contains(&origin) {
3966                sourced.add_origin(origin);
3967            }
3968        }
3969        sourced
3970    }
3971
3972    fn sourced_metadata_label(
3973        &self,
3974        value: MetadataLabel,
3975        name_sources: &[ComposeSpan],
3976        value_provenance: &MergeProvenance,
3977    ) -> Sourced<MetadataLabel> {
3978        let mut sourced = Sourced::generated(value);
3979        for origin in name_sources
3980            .iter()
3981            .chain(value_provenance.sources())
3982            .filter_map(|span| self.origin(*span))
3983        {
3984            if !sourced.origins().contains(&origin) {
3985                sourced.add_origin(origin);
3986            }
3987        }
3988        sourced
3989    }
3990
3991    fn exact_provenance(&mut self, subject: impl Into<String>, provenance: &MergeProvenance) {
3992        let outcome = ConversionOutcome::exact(subject);
3993        let outcome = self.with_provenance(outcome, provenance);
3994        self.outcomes.push(outcome);
3995    }
3996
3997    fn exact_origins(&mut self, subject: impl Into<String>, origins: &[Provenance]) {
3998        let mut outcome = ConversionOutcome::exact(subject);
3999        for origin in origins {
4000            outcome = outcome.with_origin(origin.clone());
4001        }
4002        self.outcomes.push(outcome);
4003    }
4004
4005    fn exact_spans(&mut self, subject: impl Into<String>, spans: &[ComposeSpan]) {
4006        let value = self.sourced_spans((), spans);
4007        self.exact_origins(subject, value.origins());
4008    }
4009
4010    fn unsupported(&mut self, subject: &str, feature: &str, span: ComposeSpan) {
4011        self.unsupported_optional(subject, feature, Some(span));
4012    }
4013
4014    fn unsupported_spans(&mut self, subject: &str, feature: &str, spans: &[ComposeSpan]) {
4015        let code = self.codes.unsupported.clone();
4016        self.diagnostics.push(
4017            Diagnostic::new(
4018                code.clone(),
4019                Severity::Warning,
4020                "Compose intent is not represented by the current neutral-model subset",
4021            )
4022            .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject)))
4023            .with_field(DiagnosticField::new("feature", DiagnosticValue::plain(feature))),
4024        );
4025        if let Ok(mut outcome) = ConversionOutcome::loss(subject, ConversionKind::Unsupported, code) {
4026            for span in spans {
4027                if let Some(origin) = self.origin(*span) {
4028                    if !outcome.origins().contains(&origin) {
4029                        outcome = outcome.with_origin(origin);
4030                    }
4031                }
4032            }
4033            self.outcomes.push(outcome);
4034        }
4035    }
4036
4037    fn unsupported_optional(&mut self, subject: &str, feature: &str, span: Option<ComposeSpan>) {
4038        let code = self.codes.unsupported.clone();
4039        self.diagnostics.push(
4040            Diagnostic::new(
4041                code.clone(),
4042                Severity::Warning,
4043                "Compose intent is not represented by the current neutral-model subset",
4044            )
4045            .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject)))
4046            .with_field(DiagnosticField::new("feature", DiagnosticValue::plain(feature))),
4047        );
4048        if let Ok(outcome) = ConversionOutcome::loss(subject, ConversionKind::Unsupported, code) {
4049            self.outcomes.push(self.with_optional_origin(outcome, span));
4050        }
4051    }
4052
4053    fn report_unresolved_variable(&mut self, subject: &str, value: &str, reason: &str) {
4054        let code = self.codes.unresolved_variable.clone();
4055        let mut diagnostic = Diagnostic::new(
4056            code,
4057            Severity::Warning,
4058            "Compose variable expression remains unresolved at the adapter boundary",
4059        )
4060        .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject)))
4061        .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(reason)));
4062        if let Some(variable) = first_compose_variable(value) {
4063            diagnostic = diagnostic.with_field(DiagnosticField::new("variable", DiagnosticValue::plain(variable)));
4064        }
4065        self.diagnostics.push(diagnostic);
4066    }
4067
4068    fn unresolved_volume_variable(&mut self, subject: &str, value: &str, span: ComposeSpan) {
4069        self.report_unresolved_variable(
4070            subject,
4071            value,
4072            "volume source could resolve to either a host path or a named volume",
4073        );
4074        let code = self.codes.unresolved_variable.clone();
4075        if let Ok(outcome) = ConversionOutcome::loss(subject, ConversionKind::Unsupported, code) {
4076            self.outcomes.push(self.with_optional_origin(outcome, Some(span)));
4077        }
4078    }
4079
4080    fn invalid_model(&mut self, subject: &str, error: &ModelError, span: ComposeSpan) {
4081        self.invalid_model_optional(subject, error, Some(span));
4082    }
4083
4084    fn invalid_model_optional(&mut self, subject: &str, error: &ModelError, span: Option<ComposeSpan>) {
4085        self.invalid_optional_span(
4086            self.codes.invalid_model.clone(),
4087            subject,
4088            "Compose value cannot be represented in the neutral application model",
4089            &error.to_string(),
4090            span,
4091        );
4092    }
4093
4094    fn invalid_value(&mut self, subject: &str, reason: &str, span: ComposeSpan) {
4095        self.invalid_with_code(
4096            self.codes.invalid_value.clone(),
4097            subject,
4098            "Compose value must be resolved or corrected before conversion",
4099            reason,
4100            span,
4101        );
4102    }
4103
4104    fn invalid_value_optional(&mut self, subject: &str, reason: &str, span: Option<ComposeSpan>) {
4105        self.invalid_optional_span(
4106            self.codes.invalid_value.clone(),
4107            subject,
4108            "Compose value must be resolved or corrected before conversion",
4109            reason,
4110            span,
4111        );
4112    }
4113
4114    fn invalid_with_code(
4115        &mut self,
4116        code: DiagnosticCode,
4117        subject: &str,
4118        summary: &str,
4119        reason: &str,
4120        span: ComposeSpan,
4121    ) {
4122        self.invalid_optional_span(code, subject, summary, reason, Some(span));
4123    }
4124
4125    fn invalid_optional_span(
4126        &mut self,
4127        code: DiagnosticCode,
4128        subject: &str,
4129        summary: &str,
4130        reason: &str,
4131        span: Option<ComposeSpan>,
4132    ) {
4133        self.diagnostics.push(
4134            Diagnostic::new(code.clone(), Severity::Error, summary)
4135                .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject)))
4136                .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(reason))),
4137        );
4138        if let Ok(outcome) = ConversionOutcome::loss(subject, ConversionKind::Invalid, code) {
4139            self.outcomes.push(self.with_optional_origin(outcome, span));
4140        }
4141    }
4142
4143    fn report_project_diagnostics(&mut self, diagnostics: &[compose_lens::diagnostic::Diagnostic]) {
4144        for diagnostic in diagnostics {
4145            let severity = match diagnostic.severity() {
4146                compose_lens::diagnostic::Severity::Error => Severity::Error,
4147                compose_lens::diagnostic::Severity::Warning => Severity::Warning,
4148                compose_lens::diagnostic::Severity::Note => Severity::Note,
4149            };
4150            let native = crate::source::native_project_finding(diagnostic);
4151            let code = if severity == Severity::Error {
4152                self.codes.invalid_value.clone()
4153            } else {
4154                self.native_code(&native)
4155            };
4156            self.diagnostics.push(
4157                Diagnostic::new(
4158                    code.clone(),
4159                    severity,
4160                    "ComposeLens could not fully type the merged project",
4161                )
4162                .with_native_finding(native),
4163            );
4164            let kind = if severity == Severity::Error {
4165                ConversionKind::Invalid
4166            } else {
4167                ConversionKind::Unsupported
4168            };
4169            if let Ok(mut outcome) = ConversionOutcome::loss("application", kind, code) {
4170                for origin in diagnostic.labels().iter().filter_map(|label| self.origin(label.span())) {
4171                    outcome = outcome.with_origin(origin);
4172                }
4173                self.outcomes.push(outcome);
4174            }
4175        }
4176    }
4177
4178    fn report_native_findings(&mut self, findings: &[NativeFinding]) {
4179        for finding in findings {
4180            let diagnostic = Diagnostic::new(
4181                self.native_code(finding),
4182                finding.severity(),
4183                "ComposeLens reported a native Compose finding",
4184            )
4185            .with_native_finding(finding.clone());
4186            self.diagnostics.push(diagnostic);
4187        }
4188    }
4189
4190    fn native_code(&self, finding: &NativeFinding) -> DiagnosticCode {
4191        match finding.code() {
4192            "compose.interpolation.unset-variable" => self.codes.unset_variable.clone(),
4193            "compose.interpolation.required-variable" => self.codes.required_variable.clone(),
4194            "compose.interpolation.invalid-expression" => self.codes.interpolation_invalid.clone(),
4195            "compose.interpolation.nesting-limit" => self.codes.interpolation_nesting.clone(),
4196            _ => match finding.severity() {
4197                Severity::Error => self.codes.native_error.clone(),
4198                Severity::Note => self.codes.native_note.clone(),
4199                _ => self.codes.native_warning.clone(),
4200            },
4201        }
4202    }
4203
4204    fn with_origin(&self, outcome: ConversionOutcome, span: ComposeSpan) -> ConversionOutcome {
4205        match self.origin(span) {
4206            Some(origin) => outcome.with_origin(origin),
4207            None => outcome,
4208        }
4209    }
4210
4211    fn with_optional_origin(&self, outcome: ConversionOutcome, span: Option<ComposeSpan>) -> ConversionOutcome {
4212        match span {
4213            Some(span) => self.with_origin(outcome, span),
4214            None => outcome,
4215        }
4216    }
4217
4218    fn with_provenance(&self, mut outcome: ConversionOutcome, provenance: &MergeProvenance) -> ConversionOutcome {
4219        for origin in provenance.sources().iter().filter_map(|span| self.origin(*span)) {
4220            outcome = outcome.with_origin(origin);
4221        }
4222        outcome
4223    }
4224
4225    fn origin(&self, span: ComposeSpan) -> Option<Provenance> {
4226        let source_id = self.source.source_id(span.source_id())?.clone();
4227        SourceSpan::new(span.start(), span.end())
4228            .ok()
4229            .map(|span| Provenance::spanned(source_id, span))
4230    }
4231}
4232
4233fn quadlet_shm_size(value: &ShmSizeKind) -> Option<String> {
4234    let ShmSizeKind::Documented { amount_raw, unit } = value else {
4235        return None;
4236    };
4237    if !amount_raw.bytes().any(|byte| byte != b'0') || !amount_raw.bytes().all(|byte| byte.is_ascii_digit()) {
4238        return None;
4239    }
4240    let unit = match unit {
4241        ShmSizeUnit::B => "b",
4242        ShmSizeUnit::K | ShmSizeUnit::Kb => "k",
4243        ShmSizeUnit::M | ShmSizeUnit::Mb => "m",
4244        ShmSizeUnit::G | ShmSizeUnit::Gb => "g",
4245        _ => return None,
4246    };
4247    Some(format!("{amount_raw}{unit}"))
4248}
4249
4250fn scalar_text(value: &ComposeScalar) -> String {
4251    match value {
4252        ComposeScalar::Null => String::new(),
4253        ComposeScalar::Boolean(value) => value.to_string(),
4254        ComposeScalar::Number(value) | ComposeScalar::String(value) => value.clone(),
4255    }
4256}
4257
4258fn scalar_option(value: &ComposeScalar) -> Option<String> {
4259    match value {
4260        ComposeScalar::Null => None,
4261        value => Some(scalar_text(value)),
4262    }
4263}
4264
4265fn literal_assignment(value: &str) -> Option<(&str, &str)> {
4266    let (name, value) = value.split_once('=')?;
4267    if name.is_empty() || name.contains('$') || value.contains('$') {
4268        return None;
4269    }
4270    Some((name, value))
4271}
4272
4273fn first_compose_variable(value: &str) -> Option<&str> {
4274    let (_, remainder) = value.split_once('$')?;
4275    let remainder = remainder.strip_prefix('{').unwrap_or(remainder);
4276    let end = remainder
4277        .bytes()
4278        .position(|byte| byte != b'_' && !byte.is_ascii_alphanumeric())
4279        .unwrap_or(remainder.len());
4280    let variable = &remainder[..end];
4281    let mut bytes = variable.bytes();
4282    let first = bytes.next()?;
4283    ((first == b'_' || first.is_ascii_alphabetic()) && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()))
4284        .then_some(variable)
4285}
4286
4287fn map_protocol(protocol: Option<&str>) -> Protocol {
4288    match protocol.unwrap_or("tcp").to_ascii_lowercase().as_str() {
4289        "tcp" => Protocol::Tcp,
4290        "udp" => Protocol::Udp,
4291        "sctp" => Protocol::Sctp,
4292        value => Protocol::Other(value.to_owned()),
4293    }
4294}
4295
4296fn is_host_path(value: &str) -> bool {
4297    value.starts_with('/')
4298        || value.starts_with("./")
4299        || value.starts_with("../")
4300        || value.starts_with('~')
4301        || value.starts_with('%')
4302        || value.starts_with("//")
4303        || value.starts_with(r"\\")
4304        || value
4305            .as_bytes()
4306            .get(0..2)
4307            .is_some_and(|prefix| prefix[0].is_ascii_alphabetic() && prefix[1] == b':')
4308}