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