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