Skip to main content

boxferry_compose/
import.rs

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