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