1mod 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#[derive(Clone, Debug)]
49pub struct ComposeImporter {
50 codes: Codes,
51}
52
53impl ComposeImporter {
54 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 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 ("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 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 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
4336fn 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;