Skip to main content

boxferry_compose/
import.rs

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