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