1mod deferred;
4mod metadata;
5use std::{
6 collections::{BTreeMap, BTreeSet},
7 error::Error,
8 fmt::{self, Write as _},
9};
10
11use boxferry_engine::{
12 ConversionKind, ConversionOutcome, ConversionPlan, Diagnostic, DiagnosticCode, DiagnosticField, DiagnosticValue,
13 ExportAdapter, InvalidDiagnosticCode, PlanError, RuleId, Severity, TargetProfile,
14};
15use boxferry_model::{
16 Annotation, Application, ArtifactDependency, ArtifactDependencyNode, BuildSettingValues, BuildSourceDeclaration,
17 Command, Config, Device, Entrypoint, EnvironmentFile, EnvironmentFileFormat, EnvironmentValue, ExposedPort,
18 GroupExitPolicy, Healthcheck, HealthcheckCommand, HostAddressKind, HostMapping, ImageAcquisition,
19 ImageAcquisitionSetting, ImageArtifactAssignment, ImageBuild, ImageBuildSetting, MetadataLabel, Mount, MountSource,
20 NetworkAttachment, NetworkDriverOption, Port, ProtectedString, Protocol, Provenance, ProvenanceKind, PullPolicy,
21 ReloadAction, ResourceGrant, ResourceOwnership, RestartPolicy, Secret, SecurityOption, SelinuxRelabel, Service,
22 ServiceDependency, ServiceDependencyCondition, ServiceGroupRuntime, SourceBuildSetting, Sourced,
23 StartupNotification, VolumeImageSource,
24};
25use quadlet_lens::{
26 capability::{CapabilityCatalogue, CatalogueError, PodmanTarget, PodmanVersion, SupportClassification},
27 model::{BuildKey, ContainerKey, ImageKey, NetworkKey, PodKey, QuadletUnitType, SystemdUnitKey, VolumeKey},
28 path::{PathForm, classify_path},
29 render::{EntryValue, PidsLimit, QuadletDocumentBuilder, RenderError, ShmSize, SystemdSection},
30 source::SourceId,
31};
32
33use crate::QuadletOutput;
34
35#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
42#[non_exhaustive]
43pub enum QuadletGroupingPolicy {
44 #[default]
46 SeparateContainers,
47 SinglePod,
49 PreserveSingleGroup,
54}
55
56#[derive(Clone, Debug)]
58pub struct QuadletExporter {
59 catalogue: CapabilityCatalogue,
60 codes: Codes,
61 relative_bind_root: Option<String>,
62 bind_source_mappings: BTreeMap<String, String>,
63 grouping_policy: QuadletGroupingPolicy,
64 pod_name: Option<String>,
65 deferred_environment_values: bool,
66}
67
68impl QuadletExporter {
69 #[must_use]
72 pub const fn with_deferred_environment_values(mut self, include: bool) -> Self {
73 self.deferred_environment_values = include;
74 self
75 }
76
77 pub fn new() -> Result<Self, QuadletExporterError> {
84 Ok(Self {
85 catalogue: CapabilityCatalogue::supported_range()?,
86 codes: Codes {
87 invalid_target: RuleId::QuadletTargetInvalid.definition().diagnostic_code()?,
88 assumed_future: RuleId::QuadletOpenEndedTarget.definition().diagnostic_code()?,
89 unsupported: RuleId::QuadletOutputUnsupported.definition().diagnostic_code()?,
90 invalid_value: RuleId::QuadletValueInvalid.definition().diagnostic_code()?,
91 generation: RuleId::QuadletGenerationFailed.definition().diagnostic_code()?,
92 capability_unavailable: RuleId::QuadletCapabilityUnavailable.definition().diagnostic_code()?,
93 grouping_approximation: RuleId::QuadletGroupingApproximation.definition().diagnostic_code()?,
94 dependency_unsupported: RuleId::QuadletDependencyUnsupported.definition().diagnostic_code()?,
95 restart: RuleId::QuadletRestartApproximation.definition().diagnostic_code()?,
96 environment_file: RuleId::QuadletEnvironmentFileApproximation
97 .definition()
98 .diagnostic_code()?,
99 grouping_invalid: RuleId::QuadletGroupingInvalid.definition().diagnostic_code()?,
100 dependency_invalid: RuleId::QuadletDependencyInvalid.definition().diagnostic_code()?,
101 capability_deprecated: RuleId::QuadletCapabilityDeprecated.definition().diagnostic_code()?,
102 unresolved_variable: RuleId::QuadletUnresolvedVariable.definition().diagnostic_code()?,
103 },
104 relative_bind_root: None,
105 bind_source_mappings: BTreeMap::new(),
106 grouping_policy: QuadletGroupingPolicy::SeparateContainers,
107 pod_name: None,
108 deferred_environment_values: false,
109 })
110 }
111
112 #[must_use]
114 pub const fn catalogue(&self) -> &CapabilityCatalogue {
115 &self.catalogue
116 }
117
118 pub fn with_relative_bind_root(mut self, root: impl Into<String>) -> Result<Self, QuadletExporterError> {
128 let root = root.into();
129 let Some(root) = normalize_absolute_path(&root) else {
130 return Err(QuadletExporterError::InvalidRelativeBindRoot(root));
131 };
132 self.relative_bind_root = Some(root);
133 Ok(self)
134 }
135
136 pub fn with_relative_host_path_root(self, root: impl Into<String>) -> Result<Self, QuadletExporterError> {
146 self.with_relative_bind_root(root)
147 }
148
149 #[must_use]
151 pub fn relative_bind_root(&self) -> Option<&str> {
152 self.relative_bind_root.as_deref()
153 }
154
155 #[must_use]
157 pub fn relative_host_path_root(&self) -> Option<&str> {
158 self.relative_bind_root()
159 }
160
161 pub fn with_bind_source_mapping(
174 mut self,
175 source: impl Into<String>,
176 target: impl Into<String>,
177 ) -> Result<Self, QuadletExporterError> {
178 let source = source.into();
179 let target = target.into();
180 if !is_valid_mapping_source(&source) || !is_valid_quadlet_bind_source(&target) {
181 return Err(QuadletExporterError::InvalidBindSourceMapping { source, target });
182 }
183 if let Some(existing) = self.bind_source_mappings.get(&source) {
184 if existing != &target {
185 return Err(QuadletExporterError::ConflictingBindSourceMapping {
186 source,
187 existing: existing.clone(),
188 replacement: target,
189 });
190 }
191 return Ok(self);
192 }
193 self.bind_source_mappings.insert(source, target);
194 Ok(self)
195 }
196
197 #[must_use]
199 pub fn bind_source_mapping(&self, source: &str) -> Option<&str> {
200 self.bind_source_mappings.get(source).map(String::as_str)
201 }
202
203 #[must_use]
205 pub const fn with_grouping_policy(mut self, policy: QuadletGroupingPolicy) -> Self {
206 self.grouping_policy = policy;
207 self
208 }
209
210 #[must_use]
212 pub const fn grouping_policy(&self) -> QuadletGroupingPolicy {
213 self.grouping_policy
214 }
215
216 #[must_use]
221 pub fn with_pod_name(mut self, name: impl Into<String>) -> Self {
222 self.pod_name = Some(name.into());
223 self
224 }
225
226 #[must_use]
228 pub fn pod_name(&self) -> Option<&str> {
229 self.pod_name.as_deref()
230 }
231}
232
233impl ExportAdapter for QuadletExporter {
234 type Output = QuadletOutput;
235
236 fn plan(
237 &self,
238 application: &Application,
239 target: &TargetProfile,
240 ) -> Result<ConversionPlan<Self::Output>, PlanError> {
241 let mut mapping = Mapping::new(self, application, target);
242 if mapping.validate_target() {
243 mapping.map_application();
244 }
245 let (candidate, mut outcomes, mut diagnostics) = mapping.finish();
246 let (evidence_outcomes, evidence_diagnostics) =
247 boxferry_engine::retained_native_evidence_losses(application, &self.codes.unsupported)?;
248 outcomes.extend(evidence_outcomes);
249 diagnostics.extend(evidence_diagnostics);
250 ConversionPlan::new(candidate, outcomes, diagnostics)
251 }
252}
253
254#[derive(Debug)]
256#[non_exhaustive]
257pub enum QuadletExporterError {
258 Catalogue(CatalogueError),
260 DiagnosticCode(InvalidDiagnosticCode),
262 InvalidRelativeBindRoot(String),
264 InvalidBindSourceMapping {
266 source: String,
268 target: String,
270 },
271 ConflictingBindSourceMapping {
273 source: String,
275 existing: String,
277 replacement: String,
279 },
280}
281
282impl fmt::Display for QuadletExporterError {
283 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
284 match self {
285 Self::Catalogue(error) => write!(formatter, "cannot load Quadlet capability catalogue: {error}"),
286 Self::DiagnosticCode(error) => write!(formatter, "invalid Quadlet adapter diagnostic code: {error}"),
287 Self::InvalidRelativeBindRoot(root) => {
288 write!(formatter, "invalid absolute relative-bind root `{root}`")
289 }
290 Self::InvalidBindSourceMapping { source, target } => {
291 write!(
292 formatter,
293 "invalid explicit bind-source mapping `{source}` to `{target}`"
294 )
295 }
296 Self::ConflictingBindSourceMapping {
297 source,
298 existing,
299 replacement,
300 } => write!(
301 formatter,
302 "conflicting bind-source mapping for `{source}`: `{existing}` versus `{replacement}`"
303 ),
304 }
305 }
306}
307
308impl Error for QuadletExporterError {
309 fn source(&self) -> Option<&(dyn Error + 'static)> {
310 match self {
311 Self::Catalogue(error) => Some(error),
312 Self::DiagnosticCode(error) => Some(error),
313 Self::InvalidRelativeBindRoot(_)
314 | Self::InvalidBindSourceMapping { .. }
315 | Self::ConflictingBindSourceMapping { .. } => None,
316 }
317 }
318}
319
320impl From<CatalogueError> for QuadletExporterError {
321 fn from(error: CatalogueError) -> Self {
322 Self::Catalogue(error)
323 }
324}
325
326impl From<InvalidDiagnosticCode> for QuadletExporterError {
327 fn from(error: InvalidDiagnosticCode) -> Self {
328 Self::DiagnosticCode(error)
329 }
330}
331
332#[derive(Clone, Debug)]
333struct Codes {
334 invalid_target: DiagnosticCode,
335 assumed_future: DiagnosticCode,
336 unsupported: DiagnosticCode,
337 invalid_value: DiagnosticCode,
338 generation: DiagnosticCode,
339 capability_unavailable: DiagnosticCode,
340 grouping_approximation: DiagnosticCode,
341 dependency_unsupported: DiagnosticCode,
342 restart: DiagnosticCode,
343 environment_file: DiagnosticCode,
344 grouping_invalid: DiagnosticCode,
345 dependency_invalid: DiagnosticCode,
346 capability_deprecated: DiagnosticCode,
347 unresolved_variable: DiagnosticCode,
348}
349
350struct Mapping<'a> {
351 exporter: &'a QuadletExporter,
352 application: &'a Application,
353 target: &'a TargetProfile,
354 podman_target: Option<PodmanTarget>,
355 generated: Vec<(String, quadlet_lens::render::GeneratedQuadletDocument)>,
356 staged: Vec<(String, QuadletDocumentBuilder, String, Vec<Provenance>)>,
357 outcomes: Vec<ConversionOutcome>,
358 diagnostics: Vec<Diagnostic>,
359 next_source_id: u32,
360 generation_failed: bool,
361 consumed_group: Option<String>,
362}
363
364struct PodPlan {
365 name: String,
366 subject: String,
367 origins: Vec<Provenance>,
368 consumed_group: Option<String>,
369 approximation: &'static str,
370 runtime: Option<Sourced<ServiceGroupRuntime>>,
371}
372
373struct ResolvedResourceSource {
379 value: String,
380 approximation: Option<&'static str>,
381}
382
383#[derive(Clone, Copy)]
384enum HealthcheckScalarKind {
385 Duration,
386 RetryCount,
387}
388
389impl<'a> Mapping<'a> {
390 const fn new(exporter: &'a QuadletExporter, application: &'a Application, target: &'a TargetProfile) -> Self {
391 Self {
392 exporter,
393 application,
394 target,
395 podman_target: None,
396 generated: Vec::new(),
397 staged: Vec::new(),
398 outcomes: Vec::new(),
399 diagnostics: Vec::new(),
400 next_source_id: 1,
401 generation_failed: false,
402 consumed_group: None,
403 }
404 }
405
406 fn validate_target(&mut self) -> bool {
407 if self.target.implementation() != "podman" {
408 self.invalid(
409 self.exporter.codes.invalid_target.clone(),
410 "target",
411 "Quadlet output requires the `podman` target implementation",
412 "select target implementation `podman`",
413 &[],
414 );
415 return false;
416 }
417
418 let requested = self.target.versions();
419 let minimum = podman_version(requested.minimum());
420 let maximum = requested.maximum().map(podman_version);
421 let coverage = self.exporter.catalogue.coverage();
422 let evaluated_maximum = maximum.unwrap_or(coverage.maximum());
423 if minimum < coverage.minimum() || minimum > coverage.maximum() || evaluated_maximum > coverage.maximum() {
424 self.invalid(
425 self.exporter.codes.invalid_target.clone(),
426 "target.versions",
427 "requested Podman range is outside the verified QuadletLens catalogue",
428 &format!(
429 "requested {} through {}; verified coverage is {} through {}",
430 minimum,
431 maximum.map_or_else(|| "open ended".to_owned(), |version| version.to_string()),
432 coverage.minimum(),
433 coverage.maximum()
434 ),
435 &[],
436 );
437 return false;
438 }
439 let Ok(target) = PodmanTarget::new(minimum, maximum) else {
440 self.invalid(
441 self.exporter.codes.invalid_target.clone(),
442 "target.versions",
443 "requested Podman range is invalid",
444 "maximum version is before minimum version",
445 &[],
446 );
447 return false;
448 };
449 if maximum.is_none() {
450 self.diagnostics.push(
451 Diagnostic::new(
452 self.exporter.codes.assumed_future.clone(),
453 Severity::Note,
454 "Podman maximum version is omitted; later compatibility remains an assumption",
455 )
456 .with_field(DiagnosticField::new(
457 "verified-through",
458 DiagnosticValue::plain(coverage.maximum().to_string()),
459 )),
460 );
461 }
462 self.podman_target = Some(target);
463 true
464 }
465
466 fn map_application(&mut self) {
467 if !self.validate_dependency_graph() {
468 self.generation_failed = true;
469 return;
470 }
471 let artifact_dependencies = self.artifact_dependencies();
472 if let Err(error) = self
473 .application
474 .validate_image_artifact_dependencies(&artifact_dependencies)
475 {
476 self.invalid(
477 self.exporter.codes.dependency_invalid.clone(),
478 "application.artifact_dependencies",
479 "image artifact references must form a complete acyclic graph",
480 &error.to_string(),
481 &[],
482 );
483 self.generation_failed = true;
484 return;
485 }
486 let pod_plan = match self.exporter.grouping_policy {
487 QuadletGroupingPolicy::SeparateContainers => None,
488 QuadletGroupingPolicy::SinglePod => {
489 if !self.validate_single_pod_grouping() {
490 self.generation_failed = true;
491 return;
492 }
493 Some(PodPlan {
494 name: self
495 .exporter
496 .pod_name
497 .clone()
498 .unwrap_or_else(|| self.application.name().as_str().to_owned()),
499 subject: "application.pod".to_owned(),
500 origins: service_origins(self.application.services()),
501 consumed_group: None,
502 approximation: "caller-selected single-pod grouping shares one network namespace across source services",
503 runtime: None,
504 })
505 }
506 QuadletGroupingPolicy::PreserveSingleGroup => {
507 let Some(plan) = self.preserve_single_group_plan() else {
508 self.generation_failed = true;
509 return;
510 };
511 Some(plan)
512 }
513 };
514 self.consumed_group = pod_plan.as_ref().and_then(|plan| plan.consumed_group.clone());
515 for group in self.application.service_groups() {
516 if pod_plan.as_ref().and_then(|plan| plan.consumed_group.as_deref()) != Some(group.value().name().as_str())
517 {
518 self.map_service_group(group);
519 }
520 }
521 for network in self.application.networks() {
522 self.map_network(network);
523 }
524 for acquisition in self.application.image_acquisitions() {
525 self.map_image_acquisition(acquisition);
526 }
527 for build in self.application.image_builds() {
528 self.map_image_build(build);
529 }
530 for volume in self.application.volumes() {
531 self.map_volume(volume);
532 }
533 for config in self.application.configs() {
534 self.map_config(config);
535 }
536 for secret in self.application.secrets() {
537 self.map_secret(secret);
538 }
539 if let Some(plan) = &pod_plan {
540 self.map_pod(plan);
541 }
542 for service in self.application.services() {
543 self.map_service(service, pod_plan.as_ref().map(|plan| plan.name.as_str()));
544 }
545 }
546
547 fn artifact_dependencies(&self) -> Vec<Sourced<ArtifactDependency>> {
548 let mut dependencies = Vec::new();
549 for volume in self.application.volumes() {
550 let source = ArtifactDependencyNode::Volume(volume.value().name().clone());
551 let Some(image) = volume.value().image_source() else {
552 continue;
553 };
554 let target = match image.value() {
555 VolumeImageSource::ImageAcquisition(name) => ArtifactDependencyNode::ImageAcquisition(name.clone()),
556 VolumeImageSource::ImageBuild(name) => ArtifactDependencyNode::ImageBuild(name.clone()),
557 VolumeImageSource::Literal(_) | _ => continue,
558 };
559 let Some(origin) = image.origins().first() else {
560 continue;
561 };
562 dependencies.push(Sourced::from_source(
563 ArtifactDependency::new(
564 Sourced::from_source(source, origin.clone()),
565 Sourced::from_source(target, origin.clone()),
566 ),
567 origin.clone(),
568 ));
569 }
570 dependencies
571 }
572
573 fn preserve_single_group_plan(&mut self) -> Option<PodPlan> {
574 let groups = self.application.service_groups();
575 if groups.len() != 1 {
576 let origins = groups
577 .iter()
578 .flat_map(|group| group.origins().iter().cloned())
579 .collect::<Vec<_>>();
580 self.invalid(
581 self.exporter.codes.grouping_invalid.clone(),
582 "application.grouping",
583 "preserving a neutral service group requires exactly one group",
584 if groups.is_empty() {
585 "the application contains no service group"
586 } else {
587 "the application contains multiple service groups"
588 },
589 &origins,
590 );
591 return None;
592 }
593
594 let group = &groups[0];
595 if group.value().ownership() != ResourceOwnership::Application {
596 self.invalid(
597 self.exporter.codes.grouping_invalid.clone(),
598 "application.grouping",
599 "only an application-owned service group can be generated as a Quadlet pod",
600 "resolve the group lifecycle as application-owned before selecting preserve-single-group",
601 group.origins(),
602 );
603 return None;
604 }
605
606 let service_names = self
607 .application
608 .services()
609 .iter()
610 .map(|service| service.value().name().as_str())
611 .collect::<BTreeSet<_>>();
612 let member_names = group
613 .value()
614 .members()
615 .iter()
616 .map(|member| member.value().as_str())
617 .collect::<BTreeSet<_>>();
618 if service_names != member_names || group.value().members().len() != self.application.services().len() {
619 let origins = service_group_origins(group, self.application.services());
620 self.invalid(
621 self.exporter.codes.grouping_invalid.clone(),
622 "application.grouping",
623 "preserved Quadlet pod membership must cover the complete application",
624 "the single service group does not contain every application service exactly once",
625 &origins,
626 );
627 return None;
628 }
629 if group.value().runtime().is_none() && !self.validate_single_pod_grouping() {
632 return None;
633 }
634
635 Some(PodPlan {
636 name: group.value().name().as_str().to_owned(),
637 subject: format!("service_groups.{}", group.value().name().as_str()),
638 origins: service_group_origins(group, self.application.services()),
639 consumed_group: Some(group.value().name().as_str().to_owned()),
640 approximation: "caller-selected preservation maps structural membership to one shared Podman pod namespace",
641 runtime: group.value().runtime().cloned(),
642 })
643 }
644
645 fn validate_dependency_graph(&mut self) -> bool {
646 let service_names = self
647 .application
648 .services()
649 .iter()
650 .map(|service| service.value().name().as_str().to_owned())
651 .collect::<BTreeSet<_>>();
652 let mut valid = true;
653 let mut indegree = service_names
654 .iter()
655 .map(|name| (name.clone(), 0_usize))
656 .collect::<BTreeMap<_, _>>();
657 let mut outgoing = BTreeMap::<String, Vec<String>>::new();
658
659 for service in self.application.services() {
660 let dependent = service.value().name().as_str();
661 for (index, dependency) in service.value().dependencies().iter().enumerate() {
662 let target = dependency.value().service().as_str();
663 if !service_names.contains(target) {
664 if dependency.value().is_required() {
665 self.invalid(
666 self.exporter.codes.dependency_invalid.clone(),
667 &format!("services.{dependent}.dependencies[{index}]"),
668 "required service dependency does not exist in the application",
669 &format!("referenced service `{target}` is missing"),
670 dependency.origins(),
671 );
672 valid = false;
673 }
674 continue;
675 }
676 if let Some(count) = indegree.get_mut(dependent) {
677 *count = count.saturating_add(1);
678 }
679 outgoing
680 .entry(target.to_owned())
681 .or_default()
682 .push(dependent.to_owned());
683 }
684 }
685 if !valid {
686 return false;
687 }
688
689 let mut ready = indegree
690 .iter()
691 .filter_map(|(name, count)| (*count == 0).then_some(name.clone()))
692 .collect::<Vec<_>>();
693 let mut visited = 0_usize;
694 while let Some(name) = ready.pop() {
695 visited = visited.saturating_add(1);
696 if let Some(dependents) = outgoing.get(&name) {
697 for dependent in dependents {
698 if let Some(count) = indegree.get_mut(dependent) {
699 *count = count.saturating_sub(1);
700 if *count == 0 {
701 ready.push(dependent.clone());
702 }
703 }
704 }
705 }
706 }
707 if visited == service_names.len() {
708 return true;
709 }
710
711 let cycle_services = indegree
712 .iter()
713 .filter_map(|(name, count)| (*count > 0).then_some(name.as_str()))
714 .collect::<Vec<_>>()
715 .join(", ");
716 let origins = self
717 .application
718 .services()
719 .iter()
720 .flat_map(|service| service.value().dependencies())
721 .flat_map(Sourced::origins)
722 .cloned()
723 .collect::<Vec<_>>();
724 self.invalid(
725 self.exporter.codes.dependency_invalid.clone(),
726 "application.dependencies",
727 "service dependency graph contains an ordering cycle",
728 &format!("cycle includes: {cycle_services}"),
729 &origins,
730 );
731 false
732 }
733
734 fn validate_single_pod_grouping(&mut self) -> bool {
735 let services = self.application.services();
736 let origins = service_origins(services);
737 let Some(first) = services.first() else {
738 self.invalid(
739 self.exporter.codes.grouping_invalid.clone(),
740 "application.grouping",
741 "single-pod grouping requires at least one service",
742 "the neutral application contains no services",
743 &origins,
744 );
745 return false;
746 };
747
748 for service in services {
749 if !same_network_attachments(service.value().networks(), first.value().networks()) {
750 self.invalid(
751 self.exporter.codes.grouping_invalid.clone(),
752 "application.grouping",
753 "single-pod grouping would change declared service networking",
754 "all services must have the same ordered network attachments and no aliases",
755 &origins,
756 );
757 return false;
758 }
759 if service
760 .value()
761 .networks()
762 .iter()
763 .any(|network| !network.value().aliases().is_empty())
764 {
765 self.invalid(
766 self.exporter.codes.grouping_invalid.clone(),
767 "application.grouping",
768 "single-pod grouping cannot preserve per-service network aliases",
769 "remove aliases or retain separate containers",
770 &origins,
771 );
772 return false;
773 }
774 if !same_host_mappings(service.value().host_mappings(), first.value().host_mappings()) {
775 self.invalid(
776 self.exporter.codes.grouping_invalid.clone(),
777 "application.grouping",
778 "single-pod grouping cannot preserve different per-service host mappings",
779 "all services must have the same ordered host mappings or retain separate containers",
780 &origins,
781 );
782 return false;
783 }
784 }
785
786 if !self.validate_grouped_user_namespace(services, &origins) {
787 return false;
788 }
789
790 let mut container_ports = BTreeSet::new();
791 let mut published_ports = BTreeSet::new();
792 for service in services {
793 let mut service_container_ports = BTreeSet::new();
794 let mut service_published_ports = BTreeSet::new();
795 for port in service.value().ports() {
796 let Some(protocol) = protocol_name(port.value().protocol()) else {
797 self.invalid(
798 self.exporter.codes.grouping_invalid.clone(),
799 "application.grouping",
800 "single-pod grouping cannot validate an unknown port protocol",
801 "retain separate containers or use TCP, UDP, or SCTP",
802 &origins,
803 );
804 return false;
805 };
806 let container_port = (port.value().container(), protocol);
807 if !service_container_ports.contains(&container_port) && container_ports.contains(&container_port) {
808 self.invalid(
809 self.exporter.codes.grouping_invalid.clone(),
810 "application.grouping",
811 "single-pod grouping has overlapping declared container ports",
812 "services in one pod share a network namespace; retain separate containers or remove the overlap",
813 &origins,
814 );
815 return false;
816 }
817 service_container_ports.insert(container_port);
818 if let Some(published) = port.value().published() {
819 if !service_published_ports.contains(&(published, protocol))
820 && published_ports.contains(&(published, protocol))
821 {
822 self.invalid(
823 self.exporter.codes.grouping_invalid.clone(),
824 "application.grouping",
825 "single-pod grouping has overlapping published host ports",
826 "retain separate containers or assign distinct host ports",
827 &origins,
828 );
829 return false;
830 }
831 service_published_ports.insert((published, protocol));
832 }
833 }
834 container_ports.extend(service_container_ports);
835 published_ports.extend(service_published_ports);
836 }
837 true
838 }
839
840 fn validate_grouped_user_namespace(&mut self, services: &[Sourced<Service>], origins: &[Provenance]) -> bool {
841 let first_user_namespace = services[0].value().user_namespace().map(|value| value.value().expose());
842 if services
843 .iter()
844 .any(|service| service.value().user_namespace().map(|value| value.value().expose()) != first_user_namespace)
845 {
846 self.invalid(
847 self.exporter.codes.grouping_invalid.clone(),
848 "application.grouping",
849 "single-pod grouping cannot preserve different per-service user namespaces",
850 "all services must declare the same user namespace, or every service must leave it implicit",
851 origins,
852 );
853 return false;
854 }
855 if first_user_namespace.is_some_and(|value| !is_safe_word(value, false)) {
856 self.invalid(
857 self.exporter.codes.grouping_invalid.clone(),
858 "application.grouping",
859 "single-pod grouping cannot encode the shared user namespace safely",
860 "the shared user namespace contains unsupported whitespace or control syntax",
861 origins,
862 );
863 return false;
864 }
865 true
866 }
867
868 fn map_network(&mut self, network: &Sourced<boxferry_model::Network>) {
869 let subject = format!("networks.{}", network.value().name().as_str());
870 match network.value().ownership() {
871 ResourceOwnership::Application => {
872 let Some(file_name) =
873 self.unit_file_name(&subject, network.value().name().as_str(), "network", network.origins())
874 else {
875 return;
876 };
877 if !self.capability("quadlet.unit-type.network", &subject, network.origins()) {
878 return;
879 }
880 let mut builder = QuadletDocumentBuilder::new(QuadletUnitType::Network);
881 let runtime_name_subject = format!("{subject}.runtime_name");
882 if let Some(runtime_name) = network.value().runtime_name() {
883 if !is_safe_network_scalar(runtime_name.value().expose(), false) {
884 self.unsupported(
885 &runtime_name_subject,
886 "NetworkName must be an unquoted systemd-safe network name",
887 runtime_name.origins(),
888 );
889 return;
890 }
891 if !self.capability("quadlet.network.name", &runtime_name_subject, runtime_name.origins())
892 || !self.push_network(
893 &mut builder,
894 NetworkKey::NetworkName,
895 runtime_name.value().expose(),
896 &runtime_name_subject,
897 runtime_name.origins(),
898 )
899 {
900 return;
901 }
902 self.exact(&runtime_name_subject, runtime_name.origins());
903 }
904 self.map_network_settings(&subject, network.value(), &mut builder);
905 self.finish_document(file_name, &builder, &subject, network.origins());
906 }
907 ResourceOwnership::External if self.network_is_referenced(network.value().name().as_str()) => {
908 self.exact(subject, network.origins());
909 }
910 ResourceOwnership::External => self.unsupported(
911 &subject,
912 "an unreferenced external network has no standalone Quadlet representation",
913 network.origins(),
914 ),
915 ResourceOwnership::Implicit => self.unsupported(
916 &subject,
917 "implicit source network lifecycle cannot yet be reproduced safely",
918 network.origins(),
919 ),
920 ResourceOwnership::Uncertain => self.approximate(
921 &subject,
922 "network lifecycle ownership is uncertain; no managed .network unit was generated",
923 network.origins(),
924 ),
925 _ => self.unsupported(&subject, "unknown network ownership", network.origins()),
926 }
927 }
928
929 #[expect(
930 clippy::too_many_lines,
931 reason = "the ten related typed network keys share one generated document and ordered IPAM contract"
932 )]
933 fn map_network_settings(
934 &mut self,
935 network_subject: &str,
936 network: &boxferry_model::Network,
937 builder: &mut QuadletDocumentBuilder,
938 ) {
939 if let Some(driver) = network.driver() {
940 self.map_raw_network_value(
941 &format!("{network_subject}.driver"),
942 driver,
943 "quadlet.network.driver",
944 NetworkKey::Driver,
945 builder,
946 "network driver must be an unquoted systemd-safe scalar",
947 );
948 }
949 if let Some(options) = network.driver_options() {
950 if options.is_empty() {
951 self.unsupported(
952 &format!("{network_subject}.driver_options"),
953 "an explicit empty network option collection has no safe Quadlet reset encoding",
954 network.driver_options_origins(),
955 );
956 }
957 for (index, option) in options.iter().enumerate() {
958 let subject = format!("{network_subject}.driver_options[{index}]");
959 let origins = network_option_origins(network.driver_options_origins(), option);
960 let name = option.value().name().value().as_str();
961 let value = option.value().value().value().expose();
962 if is_safe_network_assignment(name, value) {
963 self.emit_network(
964 builder,
965 NetworkKey::Options,
966 "quadlet.network.options",
967 &format!("{name}={value}"),
968 &subject,
969 &origins,
970 );
971 } else {
972 self.unsupported(
973 &subject,
974 "network option must use an unambiguous systemd-safe NAME=VALUE spelling",
975 &origins,
976 );
977 }
978 }
979 }
980 if let Some(labels) = network.labels() {
981 if labels.is_empty() {
982 self.unsupported(
983 &format!("{network_subject}.labels"),
984 "an explicit empty network label collection has no safe Quadlet reset encoding",
985 network.labels_origins(),
986 );
987 }
988 for (index, label) in labels.iter().enumerate() {
989 let subject = format!("{network_subject}.labels[{index}]");
990 let origins = network_label_origins(network.labels_origins(), label);
991 let name = label.value().name().as_str();
992 let value = label.value().value().expose();
993 if is_label_name(name) && is_safe_network_scalar(value, true) {
994 self.emit_network(
995 builder,
996 NetworkKey::Label,
997 "quadlet.network.label",
998 &format!("{name}={value}"),
999 &subject,
1000 &origins,
1001 );
1002 } else {
1003 self.unsupported(
1004 &subject,
1005 "network label must use an unambiguous systemd-safe NAME=VALUE spelling",
1006 &origins,
1007 );
1008 }
1009 }
1010 }
1011 for (field, value, key, capability) in [
1012 (
1013 "internal",
1014 network.internal(),
1015 NetworkKey::Internal,
1016 "quadlet.network.internal",
1017 ),
1018 ("ipv6", network.ipv6(), NetworkKey::IPv6, "quadlet.network.ipv6"),
1019 ] {
1020 if let Some(value) = value {
1021 let subject = format!("{network_subject}.{field}");
1022 self.emit_network(
1023 builder,
1024 key,
1025 capability,
1026 if *value.value() { "true" } else { "false" },
1027 &subject,
1028 value.origins(),
1029 );
1030 }
1031 }
1032 if let Some(driver) = network.ipam_driver() {
1033 self.map_raw_network_value(
1034 &format!("{network_subject}.ipam_driver"),
1035 driver,
1036 "quadlet.network.ipam-driver",
1037 NetworkKey::IPAMDriver,
1038 builder,
1039 "IPAM driver must be an unquoted systemd-safe scalar",
1040 );
1041 }
1042 if let Some(rows) = network.ipam_configs() {
1043 if rows.is_empty() {
1044 self.unsupported(
1045 &format!("{network_subject}.ipam_configs"),
1046 "an explicit empty IPAM collection has no safe Quadlet reset encoding",
1047 network.ipam_configs_origins(),
1048 );
1049 }
1050 for (index, row) in rows.iter().enumerate() {
1051 let subject = format!("{network_subject}.ipam_configs[{index}]");
1052 let origins = network_ipam_origins(network.ipam_configs_origins(), row);
1053 self.emit_network_ipam_value(
1054 builder,
1055 NetworkKey::Subnet,
1056 "quadlet.network.subnet",
1057 row.value().subnet(),
1058 &format!("{subject}.subnet"),
1059 &origins,
1060 );
1061 if let Some(gateway) = row.value().gateway() {
1062 self.emit_network_ipam_value(
1063 builder,
1064 NetworkKey::Gateway,
1065 "quadlet.network.gateway",
1066 gateway,
1067 &format!("{subject}.gateway"),
1068 &origins,
1069 );
1070 }
1071 if let Some(range) = row.value().ip_range() {
1072 self.emit_network_ipam_value(
1073 builder,
1074 NetworkKey::IPRange,
1075 "quadlet.network.ip-range",
1076 range,
1077 &format!("{subject}.ip_range"),
1078 &origins,
1079 );
1080 }
1081 }
1082 }
1083 }
1084
1085 fn map_raw_network_value(
1086 &mut self,
1087 subject: &str,
1088 value: &Sourced<ProtectedString>,
1089 capability: &str,
1090 key: NetworkKey,
1091 builder: &mut QuadletDocumentBuilder,
1092 unsupported: &str,
1093 ) {
1094 if is_safe_network_scalar(value.value().expose(), false) {
1095 self.emit_network(
1096 builder,
1097 key,
1098 capability,
1099 value.value().expose(),
1100 subject,
1101 value.origins(),
1102 );
1103 } else {
1104 self.unsupported(subject, unsupported, value.origins());
1105 }
1106 }
1107
1108 fn emit_network_ipam_value(
1109 &mut self,
1110 builder: &mut QuadletDocumentBuilder,
1111 key: NetworkKey,
1112 capability: &str,
1113 value: &Sourced<ProtectedString>,
1114 subject: &str,
1115 row_origins: &[Provenance],
1116 ) {
1117 let origins = if value.origins().is_empty() {
1118 row_origins
1119 } else {
1120 value.origins()
1121 };
1122 if is_safe_network_scalar(value.value().expose(), false) {
1123 self.emit_network(builder, key, capability, value.value().expose(), subject, origins);
1124 } else {
1125 self.unsupported(subject, "IPAM values must be unquoted systemd-safe scalars", origins);
1126 }
1127 }
1128
1129 fn map_volume(&mut self, volume: &Sourced<boxferry_model::Volume>) {
1130 let subject = format!("volumes.{}", volume.value().name().as_str());
1131 match volume.value().ownership() {
1132 ResourceOwnership::Application => {
1133 let Some(file_name) =
1134 self.unit_file_name(&subject, volume.value().name().as_str(), "volume", volume.origins())
1135 else {
1136 return;
1137 };
1138 if !self.capability("quadlet.unit-type.volume", &subject, volume.origins()) {
1139 return;
1140 }
1141 let mut builder = QuadletDocumentBuilder::new(QuadletUnitType::Volume);
1142 if let Some(runtime_name) = volume.value().runtime_name() {
1143 let runtime_name_subject = format!("{subject}.runtime_name");
1144 if !self.capability("quadlet.volume.name", &runtime_name_subject, runtime_name.origins())
1145 || !self.push_volume(
1146 &mut builder,
1147 VolumeKey::VolumeName,
1148 runtime_name.value().expose(),
1149 &runtime_name_subject,
1150 runtime_name.origins(),
1151 )
1152 {
1153 return;
1154 }
1155 self.exact(runtime_name_subject, runtime_name.origins());
1156 }
1157 self.map_volume_settings(&mut builder, volume, &subject);
1158 self.finish_document(file_name, &builder, &subject, volume.origins());
1159 }
1160 ResourceOwnership::External if self.volume_is_referenced(volume.value().name().as_str()) => {
1161 self.exact(subject, volume.origins());
1162 }
1163 ResourceOwnership::External => self.unsupported(
1164 &subject,
1165 "an unreferenced external volume has no standalone Quadlet representation",
1166 volume.origins(),
1167 ),
1168 ResourceOwnership::Implicit => self.unsupported(
1169 &subject,
1170 "implicit source volume lifecycle cannot yet be reproduced safely",
1171 volume.origins(),
1172 ),
1173 ResourceOwnership::Uncertain => self.approximate(
1174 &subject,
1175 "volume lifecycle ownership is uncertain; no managed .volume unit was generated",
1176 volume.origins(),
1177 ),
1178 _ => self.unsupported(&subject, "unknown volume ownership", volume.origins()),
1179 }
1180 }
1181
1182 #[expect(
1183 clippy::too_many_lines,
1184 reason = "the typed volume keys share one generated document contract"
1185 )]
1186 fn map_volume_settings(
1187 &mut self,
1188 builder: &mut QuadletDocumentBuilder,
1189 volume: &Sourced<boxferry_model::Volume>,
1190 subject: &str,
1191 ) {
1192 let volume = volume.value();
1193 for (field, value, key, capability) in [
1194 ("driver", volume.driver(), VolumeKey::Driver, "quadlet.volume.driver"),
1195 ("device", volume.device(), VolumeKey::Device, "quadlet.volume.device"),
1196 ("type", volume.volume_type(), VolumeKey::Type, "quadlet.volume.type"),
1197 ("user", volume.user(), VolumeKey::User, "quadlet.volume.user"),
1198 ("group", volume.group(), VolumeKey::Group, "quadlet.volume.group"),
1199 (
1200 "service_name",
1201 volume.service_name(),
1202 VolumeKey::ServiceName,
1203 "quadlet.volume.service-name",
1204 ),
1205 ] {
1206 if let Some(value) = value {
1207 let item = format!("{subject}.{field}");
1208 if is_safe_network_scalar(value.value().expose(), false)
1209 && self.capability(capability, &item, value.origins())
1210 && self.push_volume(builder, key, value.value().expose(), &item, value.origins())
1211 {
1212 self.exact(item, value.origins());
1213 } else {
1214 self.unsupported(
1215 &item,
1216 "volume value requires an unquoted systemd-safe scalar",
1217 value.origins(),
1218 );
1219 }
1220 }
1221 }
1222 if let Some(value) = volume.options() {
1223 let item = format!("{subject}.options");
1224 if volume.device().is_none()
1225 && self
1226 .podman_target
1227 .is_some_and(|target| target.minimum() < PodmanVersion::new(6, 0, 0))
1228 {
1229 self.unsupported(
1230 &item,
1231 "Options without Device is only evidenced for target ranges beginning at Podman 6.0",
1232 value.origins(),
1233 );
1234 } else if is_safe_network_scalar(value.value().expose(), false)
1235 && self.capability("quadlet.volume.options", &item, value.origins())
1236 && self.push_volume(
1237 builder,
1238 VolumeKey::Options,
1239 value.value().expose(),
1240 &item,
1241 value.origins(),
1242 )
1243 {
1244 self.exact(item, value.origins());
1245 } else {
1246 self.unsupported(
1247 &item,
1248 "volume value requires an unquoted systemd-safe scalar",
1249 value.origins(),
1250 );
1251 }
1252 }
1253 for (field, value, key, capability) in [
1254 ("uid", volume.uid(), VolumeKey::UID, "quadlet.volume.uid"),
1255 ("gid", volume.gid(), VolumeKey::GID, "quadlet.volume.gid"),
1256 ] {
1257 if let Some(value) = value {
1258 let item = format!("{subject}.{field}");
1259 if !value.value().expose().is_empty()
1260 && value.value().expose().bytes().all(|b| b.is_ascii_digit())
1261 && self.capability(capability, &item, value.origins())
1262 && self.push_volume(builder, key, value.value().expose(), &item, value.origins())
1263 {
1264 self.exact(item, value.origins());
1265 } else {
1266 self.unsupported(
1267 &item,
1268 "only reviewed canonical numeric identity spellings can be emitted",
1269 value.origins(),
1270 );
1271 }
1272 }
1273 }
1274 if volume.volume_type().is_some() && volume.device().is_none() {
1275 self.invalid(
1276 self.exporter.codes.invalid_value.clone(),
1277 &format!("{subject}.type"),
1278 "Volume Type requires Device",
1279 "set Device with Type",
1280 &[],
1281 );
1282 }
1283 if let Some(copy) = volume.copy() {
1284 let item = format!("{subject}.copy");
1285 if self.capability("quadlet.volume.copy", &item, copy.origins())
1286 && self.push_volume(
1287 builder,
1288 VolumeKey::Copy,
1289 if *copy.value() { "true" } else { "false" },
1290 &item,
1291 copy.origins(),
1292 )
1293 {
1294 self.exact(item, copy.origins());
1295 }
1296 }
1297 if let Some(labels) = volume.labels() {
1298 if labels.is_empty() {
1299 self.unsupported(
1300 &format!("{subject}.labels"),
1301 "an explicit empty volume label collection has no safe Quadlet reset encoding",
1302 volume.labels_origins(),
1303 );
1304 }
1305 for (index, label) in labels.iter().enumerate() {
1306 let item = format!("{subject}.labels[{index}]");
1307 let name = label.value().name().as_str();
1308 let text = label.value().value().expose();
1309 if is_label_name(name)
1310 && is_safe_network_scalar(text, true)
1311 && self.capability("quadlet.volume.label", &item, label.origins())
1312 && self.push_volume(
1313 builder,
1314 VolumeKey::Label,
1315 format!("{name}={text}"),
1316 &item,
1317 label.origins(),
1318 )
1319 {
1320 self.exact(item, label.origins());
1321 } else {
1322 self.unsupported(
1323 &item,
1324 "volume label must use a safe NAME=VALUE assignment",
1325 label.origins(),
1326 );
1327 }
1328 }
1329 }
1330 if let Some(image) = volume.image_source() {
1331 let item = format!("{subject}.image");
1332 let text = match image.value() {
1333 VolumeImageSource::Literal(value) => value.expose().to_owned(),
1334 VolumeImageSource::ImageAcquisition(name) => format!("{}.image", name.as_str()),
1335 VolumeImageSource::ImageBuild(name) => format!("{}.build", name.as_str()),
1336 _ => {
1337 self.unsupported(&item, "unknown volume image source cannot be emitted", image.origins());
1338 return;
1339 }
1340 };
1341 if volume.copy().is_some() {
1342 self.unsupported(
1343 &item,
1344 "Image ignores Copy; the conflict is retained rather than claimed exact",
1345 image.origins(),
1346 );
1347 }
1348 if self.capability("quadlet.volume.image", &item, image.origins())
1349 && self.push_volume(builder, VolumeKey::Image, text, &item, image.origins())
1350 {
1351 self.exact(item, image.origins());
1352 }
1353 }
1354 }
1355
1356 fn map_config(&mut self, config: &Sourced<Config>) {
1357 let subject = format!("configs.{}", config.value().name().as_str());
1358 self.unsupported(
1359 &subject,
1360 "Quadlet has no native configuration-resource lifecycle; create and mount this config through an explicit target policy",
1361 config.origins(),
1362 );
1363 }
1364
1365 fn network_is_referenced(&self, name: &str) -> bool {
1366 self.application.services().iter().any(|service| {
1367 service
1368 .value()
1369 .networks()
1370 .iter()
1371 .any(|attachment| attachment.value().network().as_str() == name)
1372 }) || self.application.service_groups().iter().any(|group| {
1373 self.consumed_group.as_deref() == Some(group.value().name().as_str())
1374 && group
1375 .value()
1376 .runtime()
1377 .and_then(|runtime| runtime.value().networks())
1378 .is_some_and(|networks| {
1379 networks
1380 .iter()
1381 .any(|attachment| attachment.value().network().as_str() == name)
1382 })
1383 })
1384 }
1385
1386 fn volume_is_referenced(&self, name: &str) -> bool {
1387 let uses_volume = |mount: &Sourced<Mount>| matches!(mount.value().source(), MountSource::Volume(volume) if volume.as_str() == name);
1388 self.application
1389 .services()
1390 .iter()
1391 .any(|service| service.value().mounts().iter().any(&uses_volume))
1392 || self.application.service_groups().iter().any(|group| {
1393 self.consumed_group.as_deref() == Some(group.value().name().as_str())
1394 && group
1395 .value()
1396 .runtime()
1397 .and_then(|runtime| runtime.value().mounts())
1398 .is_some_and(|mounts| mounts.iter().any(&uses_volume))
1399 })
1400 }
1401
1402 fn map_service_group(&mut self, group: &Sourced<boxferry_model::ServiceGroup>) {
1403 let subject = format!("service_groups.{}", group.value().name().as_str());
1404 self.unsupported(
1405 &subject,
1406 "neutral service-group membership requires an explicit Quadlet grouping and lifecycle resolution policy",
1407 group.origins(),
1408 );
1409 }
1410
1411 fn map_secret(&mut self, secret: &Sourced<Secret>) {
1412 let subject = format!("secrets.{}", secret.value().name().as_str());
1413 match secret.value().ownership() {
1414 ResourceOwnership::External if secret.value().material().is_none() => self.exact(subject, secret.origins()),
1415 ResourceOwnership::External => self.invalid(
1416 self.exporter.codes.invalid_value.clone(),
1417 &subject,
1418 "external secret cannot also request application-managed materialization",
1419 "remove the material source or make the secret application-owned",
1420 secret.origins(),
1421 ),
1422 ResourceOwnership::Application => self.unsupported(
1423 &subject,
1424 "Quadlet container units can consume Podman secrets but cannot create or update their material",
1425 secret.origins(),
1426 ),
1427 ResourceOwnership::Implicit => self.unsupported(
1428 &subject,
1429 "implicit source secret lifecycle cannot be reproduced safely",
1430 secret.origins(),
1431 ),
1432 _ => self.unsupported(&subject, "unknown secret ownership", secret.origins()),
1433 }
1434 }
1435
1436 fn map_image_acquisition(&mut self, acquisition: &Sourced<ImageAcquisition>) {
1437 let name = acquisition.value().name().as_str();
1438 let subject = format!("image_acquisitions.{name}");
1439 let Some(file_name) = self.unit_file_name(&subject, name, "image", acquisition.origins()) else {
1440 return;
1441 };
1442 let mut builder = QuadletDocumentBuilder::new(QuadletUnitType::Image);
1443 let mut has_image = false;
1444 for (index, setting) in acquisition.value().settings().unwrap_or_default().iter().enumerate() {
1445 let item = format!("{subject}.settings[{index}]");
1446 has_image |= self.emit_image_acquisition_setting(&mut builder, setting, &item);
1447 }
1448 if !has_image {
1449 self.invalid(
1450 self.exporter.codes.invalid_value.clone(),
1451 &subject,
1452 "Quadlet image generation requires Image",
1453 "neutral acquisition has no Image setting",
1454 acquisition.origins(),
1455 );
1456 return;
1457 }
1458 self.finish_document(file_name, &builder, &subject, acquisition.origins());
1459 }
1460
1461 fn emit_image_acquisition_setting(
1462 &mut self,
1463 builder: &mut QuadletDocumentBuilder,
1464 setting: &Sourced<ImageAcquisitionSetting>,
1465 subject: &str,
1466 ) -> bool {
1467 let has_image = matches!(setting.value(), ImageAcquisitionSetting::Image(value) if !value.expose().is_empty());
1468 match setting.value() {
1469 ImageAcquisitionSetting::Image(value) => {
1470 self.emit_image(
1471 builder,
1472 ImageKey::Image,
1473 "quadlet.image.image",
1474 value.expose(),
1475 subject,
1476 setting.origins(),
1477 );
1478 }
1479 ImageAcquisitionSetting::ImageTags(values) => {
1480 self.emit_image_values(builder, ImageKey::ImageTag, "quadlet.image.image-tag", values, subject);
1481 }
1482 ImageAcquisitionSetting::ContainersConfigModules(values) => self.emit_image_values(
1483 builder,
1484 ImageKey::ContainersConfModule,
1485 "quadlet.image.containers-conf-module",
1486 values,
1487 subject,
1488 ),
1489 ImageAcquisitionSetting::GlobalArguments(values) => self.emit_image_values(
1490 builder,
1491 ImageKey::GlobalArgs,
1492 "quadlet.image.global-args",
1493 values,
1494 subject,
1495 ),
1496 ImageAcquisitionSetting::ServiceName(value) => self.emit_image(
1497 builder,
1498 ImageKey::ServiceName,
1499 "quadlet.image.service-name",
1500 value.expose(),
1501 subject,
1502 setting.origins(),
1503 ),
1504 ImageAcquisitionSetting::AllTags(value) => self.emit_image(
1505 builder,
1506 ImageKey::AllTags,
1507 "quadlet.image.all-tags",
1508 bool_text(*value),
1509 subject,
1510 setting.origins(),
1511 ),
1512 ImageAcquisitionSetting::Architecture(value) => self.emit_image(
1513 builder,
1514 ImageKey::Arch,
1515 "quadlet.image.arch",
1516 value.expose(),
1517 subject,
1518 setting.origins(),
1519 ),
1520 ImageAcquisitionSetting::AuthFile(value) => self.emit_image(
1521 builder,
1522 ImageKey::AuthFile,
1523 "quadlet.image.auth-file",
1524 value.expose(),
1525 subject,
1526 setting.origins(),
1527 ),
1528 ImageAcquisitionSetting::CertificateDirectory(value) => self.emit_image(
1529 builder,
1530 ImageKey::CertDir,
1531 "quadlet.image.cert-dir",
1532 value.expose(),
1533 subject,
1534 setting.origins(),
1535 ),
1536 ImageAcquisitionSetting::Credentials(value) => self.emit_image(
1537 builder,
1538 ImageKey::Creds,
1539 "quadlet.image.creds",
1540 value.expose(),
1541 subject,
1542 setting.origins(),
1543 ),
1544 ImageAcquisitionSetting::DecryptionKey(value) => self.emit_image(
1545 builder,
1546 ImageKey::DecryptionKey,
1547 "quadlet.image.decryption-key",
1548 value.expose(),
1549 subject,
1550 setting.origins(),
1551 ),
1552 ImageAcquisitionSetting::OperatingSystem(value) => self.emit_image(
1553 builder,
1554 ImageKey::OS,
1555 "quadlet.image.os",
1556 value.expose(),
1557 subject,
1558 setting.origins(),
1559 ),
1560 _ => self.unsupported(subject, "unrecognized image-acquisition setting", setting.origins()),
1561 }
1562 has_image
1563 }
1564
1565 fn map_image_build(&mut self, build: &Sourced<ImageBuild>) {
1566 let name = build.value().name().as_str();
1567 let subject = format!("image_builds.{name}");
1568 let Some(file_name) = self.unit_file_name(&subject, name, "build", build.origins()) else {
1569 return;
1570 };
1571 if !self.capability("quadlet.unit-type.build", &subject, build.origins()) {
1572 return;
1573 }
1574 self.report_build_source_declaration(build, &subject);
1575 let mut builder = QuadletDocumentBuilder::new(QuadletUnitType::Build);
1576 let mut has_tag = false;
1577 let mut has_context = false;
1578 for (index, setting) in build.value().settings().unwrap_or_default().iter().enumerate() {
1579 let item = format!("{subject}.settings[{index}]");
1580 if self.map_image_build_required_setting(&mut builder, setting, &item, &mut has_tag, &mut has_context)
1581 || self.map_image_build_text_setting(&mut builder, setting, &item)
1582 || self.map_image_build_boolean_setting(&mut builder, setting, &item)
1583 || self.map_image_build_assignment_setting(&mut builder, setting, &item)
1584 || self.map_image_build_value_setting(&mut builder, setting, &item)
1585 {
1586 continue;
1587 }
1588 match setting.value() {
1589 ImageBuildSetting::RuntimeArguments(_) => self.unsupported(
1590 &item,
1591 "PodmanArgs is retained as native evidence and is never synthesized",
1592 setting.origins(),
1593 ),
1594 _ => self.unsupported(&item, "unrecognized image-build setting", setting.origins()),
1595 }
1596 }
1597 if !has_tag || !has_context {
1598 self.invalid(
1599 self.exporter.codes.invalid_value.clone(),
1600 &subject,
1601 "Quadlet build generation requires ImageTag and File or SetWorkingDirectory",
1602 "neutral build is missing a required build tag or context",
1603 build.origins(),
1604 );
1605 return;
1606 }
1607 self.finish_document(file_name, &builder, &subject, build.origins());
1608 }
1609
1610 fn map_image_build_required_setting(
1611 &mut self,
1612 builder: &mut QuadletDocumentBuilder,
1613 setting: &Sourced<ImageBuildSetting>,
1614 item: &str,
1615 has_tag: &mut bool,
1616 has_context: &mut bool,
1617 ) -> bool {
1618 match setting.value() {
1619 ImageBuildSetting::ImageTags(values) => {
1620 *has_tag |= values.values().iter().any(|value| !value.value().expose().is_empty());
1621 self.emit_build_values(builder, BuildKey::ImageTag, "quadlet.build.image-tag", values, item);
1622 }
1623 ImageBuildSetting::SetWorkingDirectory(value) => {
1624 *has_context |= !value.expose().is_empty();
1625 self.emit_build(
1626 builder,
1627 BuildKey::SetWorkingDirectory,
1628 "quadlet.build.set-working-directory",
1629 value.expose(),
1630 item,
1631 setting.origins(),
1632 );
1633 }
1634 ImageBuildSetting::RecipeFile(value) => {
1635 *has_context |= !value.expose().is_empty();
1636 self.emit_build(
1637 builder,
1638 BuildKey::File,
1639 "quadlet.build.file",
1640 value.expose(),
1641 item,
1642 setting.origins(),
1643 );
1644 }
1645 _ => return false,
1646 }
1647 true
1648 }
1649
1650 fn map_image_build_text_setting(
1651 &mut self,
1652 builder: &mut QuadletDocumentBuilder,
1653 setting: &Sourced<ImageBuildSetting>,
1654 item: &str,
1655 ) -> bool {
1656 let (key, capability, value) = match setting.value() {
1657 ImageBuildSetting::Target(value) => (BuildKey::Target, "quadlet.build.target", value),
1658 ImageBuildSetting::Network(value) => (BuildKey::Network, "quadlet.build.network", value),
1659 ImageBuildSetting::Architecture(value) => (BuildKey::Arch, "quadlet.build.arch", value),
1660 ImageBuildSetting::Variant(value) => (BuildKey::Variant, "quadlet.build.variant", value),
1661 ImageBuildSetting::PullPolicy(value) => (BuildKey::Pull, "quadlet.build.pull", value),
1662 ImageBuildSetting::Retry(value) => (BuildKey::Retry, "quadlet.build.retry", value),
1663 ImageBuildSetting::RetryDelay(value) => (BuildKey::RetryDelay, "quadlet.build.retry-delay", value),
1664 ImageBuildSetting::AuthFile(value) => (BuildKey::AuthFile, "quadlet.build.auth-file", value),
1665 ImageBuildSetting::IgnoreFile(value) => (BuildKey::IgnoreFile, "quadlet.build.ignore-file", value),
1666 ImageBuildSetting::ServiceName(value) => (BuildKey::ServiceName, "quadlet.build.service-name", value),
1667 _ => return false,
1668 };
1669 self.emit_build(builder, key, capability, value.expose(), item, setting.origins());
1670 true
1671 }
1672
1673 fn map_image_build_boolean_setting(
1674 &mut self,
1675 builder: &mut QuadletDocumentBuilder,
1676 setting: &Sourced<ImageBuildSetting>,
1677 item: &str,
1678 ) -> bool {
1679 let (key, capability, value) = match setting.value() {
1680 ImageBuildSetting::TlsVerify(value) => (BuildKey::TLSVerify, "quadlet.build.tls-verify", value),
1681 ImageBuildSetting::ForceRemove(value) => (BuildKey::ForceRM, "quadlet.build.force-rm", value),
1682 _ => return false,
1683 };
1684 self.emit_build(builder, key, capability, bool_text(*value), item, setting.origins());
1685 true
1686 }
1687
1688 fn map_image_build_assignment_setting(
1689 &mut self,
1690 builder: &mut QuadletDocumentBuilder,
1691 setting: &Sourced<ImageBuildSetting>,
1692 item: &str,
1693 ) -> bool {
1694 let (key, capability, values) = match setting.value() {
1695 ImageBuildSetting::Labels(values) => (BuildKey::Label, "quadlet.build.label", values),
1696 ImageBuildSetting::BuildArguments(values) => (BuildKey::BuildArg, "quadlet.build.build-arg", values),
1697 ImageBuildSetting::Annotations(values) => (BuildKey::Annotation, "quadlet.build.annotation", values),
1698 ImageBuildSetting::Environment(values) => (BuildKey::Environment, "quadlet.build.environment", values),
1699 _ => return false,
1700 };
1701 self.emit_build_assignments(builder, key, capability, values, item);
1702 true
1703 }
1704
1705 fn map_image_build_value_setting(
1706 &mut self,
1707 builder: &mut QuadletDocumentBuilder,
1708 setting: &Sourced<ImageBuildSetting>,
1709 item: &str,
1710 ) -> bool {
1711 let (key, capability, values) = match setting.value() {
1712 ImageBuildSetting::Secrets(values) => (BuildKey::Secret, "quadlet.build.secret", values),
1713 ImageBuildSetting::GroupAdd(values) => (BuildKey::GroupAdd, "quadlet.build.group-add", values),
1714 ImageBuildSetting::DnsServers(values) => (BuildKey::DNS, "quadlet.build.dns", values),
1715 ImageBuildSetting::DnsOptions(values) => (BuildKey::DNSOption, "quadlet.build.dns-option", values),
1716 ImageBuildSetting::DnsSearchDomains(values) => (BuildKey::DNSSearch, "quadlet.build.dns-search", values),
1717 ImageBuildSetting::ContainersConfigModules(values) => (
1718 BuildKey::ContainersConfModule,
1719 "quadlet.build.containers-conf-module",
1720 values,
1721 ),
1722 ImageBuildSetting::GlobalArguments(values) => (BuildKey::GlobalArgs, "quadlet.build.global-args", values),
1723 ImageBuildSetting::Volumes(values) => (BuildKey::Volume, "quadlet.build.volume", values),
1724 _ => return false,
1725 };
1726 self.emit_build_values(builder, key, capability, values, item);
1727 true
1728 }
1729
1730 fn report_build_source_declaration(&mut self, build: &Sourced<ImageBuild>, subject: &str) {
1731 let Some(declaration) = build.value().source_declaration() else {
1732 return;
1733 };
1734 match declaration.value() {
1735 BuildSourceDeclaration::Scalar(_) => self.unsupported(
1736 &format!("{subject}.source_declaration"),
1737 "a scalar source build context is retained separately and is not silently converted into a Quadlet build context",
1738 declaration.origins(),
1739 ),
1740 BuildSourceDeclaration::Structured(settings) => {
1741 for (index, setting) in settings.iter().enumerate() {
1742 let item = format!("{subject}.source_declaration.settings[{index}]");
1743 let covered = match setting.value() {
1744 SourceBuildSetting::RecipeFile(_) => build_has_setting(build.value(), |value| matches!(value, ImageBuildSetting::RecipeFile(_))),
1745 SourceBuildSetting::Target(_) => build_has_setting(build.value(), |value| matches!(value, ImageBuildSetting::Target(_))),
1746 SourceBuildSetting::Tags(_) => build_has_setting(build.value(), |value| matches!(value, ImageBuildSetting::ImageTags(_))),
1747 SourceBuildSetting::Labels(_) => build_has_setting(build.value(), |value| matches!(value, ImageBuildSetting::Labels(_))),
1748 SourceBuildSetting::Arguments(arguments) => {
1749 build_has_setting(build.value(), |value| matches!(value, ImageBuildSetting::BuildArguments(_)))
1750 && arguments.values().iter().all(|argument| argument.value().value().is_some())
1751 }
1752 _ => false,
1753 };
1754 if !covered {
1755 self.unsupported(&item, "source build declaration is not represented by the emitted Quadlet build settings", setting.origins());
1756 }
1757 }
1758 }
1759 _ => self.unsupported(&format!("{subject}.source_declaration"), "unrecognized source build declaration", declaration.origins()),
1760 }
1761 }
1762
1763 fn map_pod(&mut self, plan: &PodPlan) {
1764 let subject = plan.subject.as_str();
1765 let name = plan.name.as_str();
1766 let origins = &plan.origins;
1767 let Some(file_name) = self.unit_file_name(subject, name, "pod", origins) else {
1768 return;
1769 };
1770 if !self.capability("quadlet.unit-type.pod", subject, origins) {
1771 return;
1772 }
1773
1774 let mut builder = QuadletDocumentBuilder::new(QuadletUnitType::Pod);
1775 if let Some(runtime) = plan.runtime.as_ref() {
1776 if let Some(runtime_name) = runtime.value().runtime_name()
1777 && (!self.capability("quadlet.pod.name", subject, runtime_name.origins())
1778 || !self.push_pod(
1779 &mut builder,
1780 PodKey::PodName,
1781 runtime_name.value().expose(),
1782 subject,
1783 runtime_name.origins(),
1784 ))
1785 {
1786 return;
1787 }
1788 self.map_group_runtime(subject, runtime, &mut builder);
1789 self.exact(subject, origins);
1790 self.finish_document(file_name, &builder, subject, origins);
1791 return;
1792 }
1793 if !self.capability("quadlet.pod.name", subject, origins)
1794 || !self.push_pod(&mut builder, PodKey::PodName, name, subject, origins)
1795 {
1796 return;
1797 }
1798
1799 if let Some(user_namespace) = self
1800 .application
1801 .services()
1802 .first()
1803 .and_then(|service| service.value().user_namespace())
1804 {
1805 let namespace_subject = "application.pod.user_namespace";
1806 let namespace_origins = shared_user_namespace_origins(self.application.services());
1807 if self.capability("quadlet.pod.userns", namespace_subject, &namespace_origins)
1808 && self.push_pod(
1809 &mut builder,
1810 PodKey::UserNS,
1811 user_namespace.value().expose(),
1812 namespace_subject,
1813 &namespace_origins,
1814 )
1815 {
1816 self.exact(namespace_subject, &namespace_origins);
1817 }
1818 }
1819
1820 if let Some(service) = self.application.services().first() {
1821 for (index, mapping) in service.value().host_mappings().iter().enumerate() {
1822 let mapping_origins = shared_host_mapping_origins(self.application.services(), index);
1823 self.map_pod_host_mapping(index, mapping.value(), &mapping_origins, &mut builder);
1824 }
1825 }
1826
1827 for service in self.application.services() {
1828 let service_subject = format!("services.{}", service.value().name().as_str());
1829 for (index, port) in service.value().ports().iter().enumerate() {
1830 self.map_pod_port(&service_subject, index, port, &mut builder);
1831 }
1832 }
1833 if let Some(service) = self.application.services().first() {
1834 for network in service.value().networks() {
1835 self.map_pod_network_attachment(network, &mut builder);
1836 }
1837 }
1838
1839 self.approximate("application.grouping", plan.approximation, origins);
1840 self.finish_document(file_name, &builder, subject, origins);
1841 }
1842
1843 #[allow(clippy::too_many_lines)] fn map_group_runtime(
1845 &mut self,
1846 group_subject: &str,
1847 runtime: &Sourced<ServiceGroupRuntime>,
1848 builder: &mut QuadletDocumentBuilder,
1849 ) {
1850 let value = runtime.value();
1851 if let Some(service_name) = value.service_name() {
1852 let subject = format!("{group_subject}.runtime.service_name");
1853 let name = service_name.value().expose();
1854 if name.ends_with(".service") || !is_native_atom(name) {
1855 self.unsupported(
1856 &subject,
1857 "pod ServiceName must be a safe unsuffixed service-name stem",
1858 service_name.origins(),
1859 );
1860 } else if self.capability("quadlet.pod.service-name", &subject, service_name.origins())
1861 && self.push_pod(builder, PodKey::ServiceName, name, &subject, service_name.origins())
1862 {
1863 self.exact(subject, service_name.origins());
1864 }
1865 }
1866 if let Some(user_namespace) = value.user_namespace() {
1867 let subject = format!("{group_subject}.runtime.user_namespace");
1868 if is_safe_word(user_namespace.value().expose(), false)
1869 && self.capability("quadlet.pod.userns", &subject, user_namespace.origins())
1870 && self.push_pod(
1871 builder,
1872 PodKey::UserNS,
1873 user_namespace.value().expose(),
1874 &subject,
1875 user_namespace.origins(),
1876 )
1877 {
1878 self.exact(subject, user_namespace.origins());
1879 } else {
1880 self.unsupported(
1881 &subject,
1882 "pod UserNS requires a safe one-line native token",
1883 user_namespace.origins(),
1884 );
1885 }
1886 }
1887 if let Some(shm_size) = value.shm_size() {
1888 let subject = format!("{group_subject}.runtime.shm_size");
1889 if ShmSize::new(shm_size.value().expose()).is_ok()
1890 && self.capability("quadlet.pod.shm-size", &subject, shm_size.origins())
1891 && self.push_pod(
1892 builder,
1893 PodKey::ShmSize,
1894 shm_size.value().expose(),
1895 &subject,
1896 shm_size.origins(),
1897 )
1898 {
1899 self.exact(subject, shm_size.origins());
1900 } else {
1901 self.unsupported(
1902 &subject,
1903 "pod ShmSize must use the reviewed native grammar",
1904 shm_size.origins(),
1905 );
1906 }
1907 }
1908 if let Some(exit_policy) = value.exit_policy() {
1909 let subject = format!("{group_subject}.runtime.exit_policy");
1910 let output = match exit_policy.value() {
1911 GroupExitPolicy::Stop => Some("stop"),
1912 GroupExitPolicy::Continue => Some("continue"),
1913 _ => None,
1914 };
1915 if let Some(output) = output {
1916 if self.pod_capability(
1917 "quadlet.pod.exit-policy",
1918 PodmanVersion::new(5, 6, 0),
1919 &subject,
1920 exit_policy.origins(),
1921 ) && self.push_pod(builder, PodKey::ExitPolicy, output, &subject, exit_policy.origins())
1922 {
1923 self.exact(subject, exit_policy.origins());
1924 }
1925 } else {
1926 self.unsupported(
1927 &subject,
1928 "raw pod ExitPolicy is retained as native evidence and is not synthesized",
1929 exit_policy.origins(),
1930 );
1931 }
1932 }
1933 if let Some(timeout) = value.stop_timeout() {
1934 let subject = format!("{group_subject}.runtime.stop_timeout");
1935 if is_canonical_nonnegative_seconds(timeout.value().as_str())
1936 && self.pod_capability(
1937 "quadlet.pod.stop-timeout",
1938 PodmanVersion::new(5, 7, 0),
1939 &subject,
1940 timeout.origins(),
1941 )
1942 && self.push_pod(
1943 builder,
1944 PodKey::StopTimeout,
1945 timeout.value().as_str(),
1946 &subject,
1947 timeout.origins(),
1948 )
1949 {
1950 self.exact(subject, timeout.origins());
1951 } else {
1952 self.unsupported(
1953 &subject,
1954 "pod StopTimeout requires canonical integral seconds",
1955 timeout.origins(),
1956 );
1957 }
1958 }
1959 if let Some(host_mappings) = value.host_mappings() {
1960 if host_mappings.is_empty() {
1961 self.unsupported(
1962 &format!("{group_subject}.runtime.host_mappings"),
1963 "an explicit empty pod AddHost reset has no generated Quadlet representation",
1964 value.host_mappings_origins(),
1965 );
1966 }
1967 for (index, mapping) in host_mappings.iter().enumerate() {
1968 self.map_pod_host_mapping(index, mapping.value(), mapping.origins(), builder);
1969 }
1970 }
1971 let host_network = value.networks().is_some_and(|networks| {
1972 networks
1973 .iter()
1974 .any(|network| network.value().network().as_str() == "host")
1975 });
1976 if host_network && value.ports().is_some_and(|ports| !ports.is_empty()) {
1977 let mut origins = value
1978 .ports()
1979 .map_or_else(Vec::new, |ports| collection_all_origins(ports, value.ports_origins()));
1980 if let Some(networks) = value.networks() {
1981 for origin in collection_all_origins(networks, value.networks_origins()) {
1982 if !origins.contains(&origin) {
1983 origins.push(origin);
1984 }
1985 }
1986 }
1987 self.invalid(
1988 self.exporter.codes.invalid_value.clone(),
1989 &format!("{group_subject}.runtime.ports"),
1990 "Network=host conflicts with PublishPort",
1991 "remove published ports or choose a non-host pod network",
1992 &origins,
1993 );
1994 }
1995 if let Some(ports) = value.ports() {
1996 if ports.is_empty() {
1997 self.unsupported(
1998 &format!("{group_subject}.runtime.ports"),
1999 "an explicit empty pod PublishPort reset has no generated Quadlet representation",
2000 value.ports_origins(),
2001 );
2002 }
2003 for (index, port) in ports.iter().enumerate() {
2004 self.map_pod_port(group_subject, index, port, builder);
2005 }
2006 }
2007 if let Some(networks) = value.networks() {
2008 if networks.is_empty() {
2009 self.unsupported(
2010 &format!("{group_subject}.runtime.networks"),
2011 "an explicit empty pod Network reset has no generated Quadlet representation",
2012 value.networks_origins(),
2013 );
2014 }
2015 for network in networks {
2016 self.map_pod_network_attachment(network, builder);
2017 }
2018 }
2019 if let Some(mounts) = value.mounts() {
2020 if mounts.is_empty() {
2021 self.unsupported(
2022 &format!("{group_subject}.runtime.mounts"),
2023 "an explicit empty pod Volume reset has no generated Quadlet representation",
2024 value.mounts_origins(),
2025 );
2026 }
2027 for (index, mount) in mounts.iter().enumerate() {
2028 self.map_pod_mount(group_subject, index, mount, builder);
2029 }
2030 }
2031 }
2032
2033 #[allow(clippy::too_many_lines)] fn map_service(&mut self, service: &Sourced<Service>, pod_name: Option<&str>) {
2035 let grouped = pod_name.is_some();
2036 let name = service.value().name().as_str();
2037 let subject = format!("services.{name}");
2038 let Some(file_name) = self.unit_file_name(&subject, name, "container", service.origins()) else {
2039 return;
2040 };
2041 if !self.capability("quadlet.unit-type.container", &subject, service.origins()) {
2042 return;
2043 }
2044 let mut builder = QuadletDocumentBuilder::new(QuadletUnitType::Container);
2045 self.map_service_dependencies(service, &mut builder);
2046 if let Some(rootfs) = service.value().rootfs() {
2047 let rootfs_subject = format!("{subject}.rootfs");
2048 if service.value().image().is_some()
2049 || service.value().image_acquisition().is_some()
2050 || service.value().image_build().is_some()
2051 {
2052 self.invalid(
2053 self.exporter.codes.invalid_value.clone(),
2054 &rootfs_subject,
2055 "Rootfs conflicts with every image source",
2056 "remove the image, image acquisition, or image build reference before emitting Rootfs",
2057 rootfs.origins(),
2058 );
2059 return;
2060 }
2061 if !is_safe_absolute_container_path(rootfs.value().expose()) {
2062 self.unsupported(
2063 &rootfs_subject,
2064 "Rootfs requires a safe absolute literal path",
2065 rootfs.origins(),
2066 );
2067 return;
2068 }
2069 if self.capability("quadlet.container.rootfs", &rootfs_subject, rootfs.origins())
2070 && self.push_container(
2071 &mut builder,
2072 ContainerKey::Rootfs,
2073 rootfs.value().expose(),
2074 &rootfs_subject,
2075 rootfs.origins(),
2076 )
2077 {
2078 self.exact(rootfs_subject, rootfs.origins());
2079 }
2080 self.map_service_content(&subject, service, grouped, pod_name, &mut builder);
2081 self.finish_document(file_name, &builder, &subject, service.origins());
2082 return;
2083 }
2084 let direct_image = service.value().image();
2085 let acquisition = service.value().image_acquisition();
2086 let build = service.value().image_build();
2087 let valid_direct_build_pair = match (direct_image, build) {
2088 (Some(image), Some(build_reference)) if acquisition.is_none() => self
2089 .application
2090 .image_builds()
2091 .iter()
2092 .find(|candidate| candidate.value().name() == build_reference.value())
2093 .is_some_and(|candidate| build_has_tag(candidate.value(), image.value().as_str())),
2094 _ => false,
2095 };
2096 let image_sources =
2097 usize::from(direct_image.is_some()) + usize::from(acquisition.is_some()) + usize::from(build.is_some());
2098 if !(image_sources == 1 || valid_direct_build_pair) {
2099 self.invalid(
2100 self.exporter.codes.invalid_value.clone(),
2101 &format!("{subject}.image"),
2102 "Quadlet container generation requires exactly one image source",
2103 "a direct image may accompany only the image build that declares it as a nonempty ImageTag",
2104 service.origins(),
2105 );
2106 return;
2107 }
2108 let (value, origins) = if let Some(build) = build.filter(|_| valid_direct_build_pair) {
2109 (format!("{}.build", build.value().as_str()), build.origins())
2110 } else if let Some(image) = direct_image {
2111 if image.value().as_str().contains('$') {
2112 self.invalid(
2113 self.exporter.codes.unresolved_variable.clone(),
2114 &format!("{subject}.image"),
2115 "image reference contains unresolved source variable syntax",
2116 "Quadlet does not evaluate source-format variable expressions",
2117 image.origins(),
2118 );
2119 return;
2120 }
2121 if !is_safe_word(image.value().as_str(), false) {
2122 self.invalid(
2123 self.exporter.codes.invalid_value.clone(),
2124 &format!("{subject}.image"),
2125 "image reference cannot be emitted as an exact one-line Quadlet value",
2126 "image spelling contains unsupported whitespace or control syntax",
2127 image.origins(),
2128 );
2129 return;
2130 }
2131 (image.value().as_str().to_owned(), image.origins())
2132 } else if let Some(acquisition) = service.value().image_acquisition() {
2133 (format!("{}.image", acquisition.value().as_str()), acquisition.origins())
2134 } else if let Some(build) = service.value().image_build() {
2135 (format!("{}.build", build.value().as_str()), build.origins())
2136 } else {
2137 unreachable!("image source count is one")
2138 };
2139 if !self.capability("quadlet.container.image", &format!("{subject}.image"), origins)
2140 || !self.push_container(
2141 &mut builder,
2142 ContainerKey::Image,
2143 value,
2144 &format!("{subject}.image"),
2145 origins,
2146 )
2147 {
2148 return;
2149 }
2150 self.exact(format!("{subject}.image"), origins);
2151
2152 self.map_service_content(&subject, service, grouped, pod_name, &mut builder);
2153 self.finish_document(file_name, &builder, &subject, service.origins());
2154 }
2155
2156 fn map_service_content(
2157 &mut self,
2158 subject: &str,
2159 service: &Sourced<Service>,
2160 grouped: bool,
2161 pod_name: Option<&str>,
2162 builder: &mut QuadletDocumentBuilder,
2163 ) {
2164 self.map_execution_environment(subject, service.value(), builder);
2165 self.map_container_name(subject, service.value(), builder);
2166 if let Some(command) = service.value().command() {
2167 self.map_command(subject, command, builder);
2168 }
2169 self.map_execution_context(subject, service.value(), grouped, builder);
2170 self.map_released_container_settings(subject, service.value(), builder);
2171 self.map_extended_container_settings(subject, service.value(), grouped, builder);
2172 self.report_grouped_dns(subject, service.value(), grouped);
2173 if let Some(restart_policy) = service.value().restart_policy() {
2174 self.map_restart_policy(subject, restart_policy, builder);
2175 }
2176 if let Some(healthcheck) = service.value().healthcheck() {
2177 self.map_healthcheck(subject, healthcheck, builder);
2178 }
2179 if let Some(notification) = service.value().startup_notification() {
2180 self.map_startup_notification(subject, notification, builder);
2181 } else {
2182 self.map_healthy_readiness(service, builder);
2183 }
2184 let mut environment = service.value().environment().iter().collect::<Vec<_>>();
2185 environment.sort_by(|left, right| left.value().name().as_str().cmp(right.value().name().as_str()));
2186 for environment in environment {
2187 self.map_environment(subject, environment, builder);
2188 }
2189 for (index, environment_file) in service.value().environment_files().iter().enumerate() {
2190 self.map_environment_file(subject, index, environment_file, builder);
2191 }
2192 self.map_metadata_labels(subject, service.value(), builder);
2193 for (index, grant) in service.value().config_grants().iter().enumerate() {
2194 self.unsupported(
2195 &format!("{subject}.configs[{index}]"),
2196 "Quadlet has no native Compose-compatible config grant; define an explicit bind or target-specific materialization policy",
2197 grant.origins(),
2198 );
2199 }
2200 for (index, grant) in service.value().secret_grants().iter().enumerate() {
2201 self.map_secret_grant(subject, index, grant, builder);
2202 }
2203 if !grouped {
2204 for (index, mapping) in service.value().host_mappings().iter().enumerate() {
2205 self.map_container_host_mapping(subject, index, mapping, builder);
2206 }
2207 }
2208 if !grouped {
2209 for (index, port) in service.value().ports().iter().enumerate() {
2210 self.map_port(subject, index, port, builder);
2211 }
2212 }
2213 for (index, mount) in service.value().mounts().iter().enumerate() {
2214 self.map_mount(subject, index, mount, builder);
2215 }
2216 self.map_pod_or_networks(subject, service, pod_name, builder);
2217 }
2218
2219 fn map_startup_notification(
2220 &mut self,
2221 service_subject: &str,
2222 notification: &Sourced<StartupNotification>,
2223 builder: &mut QuadletDocumentBuilder,
2224 ) {
2225 let subject = format!("{service_subject}.startup_notification");
2226 match notification.value() {
2227 StartupNotification::Healthy => {
2228 if self.capability("quadlet.container.notify-healthy", &subject, notification.origins())
2229 && self.push_container(
2230 builder,
2231 ContainerKey::Notify,
2232 "healthy",
2233 &subject,
2234 notification.origins(),
2235 )
2236 {
2237 self.exact(subject, notification.origins());
2238 }
2239 }
2240 StartupNotification::Runtime | StartupNotification::Application => self.unsupported(
2241 &subject,
2242 "Notify=false and Notify=true require explicit capability evidence before they can be generated",
2243 notification.origins(),
2244 ),
2245 _ => self.unsupported(&subject, "unknown startup notification form", notification.origins()),
2246 }
2247 }
2248
2249 fn map_container_name(&mut self, subject: &str, service: &Service, builder: &mut QuadletDocumentBuilder) {
2250 let Some(runtime_name) = service.runtime_name() else {
2251 return;
2252 };
2253 let runtime_subject = format!("{subject}.container_name");
2254 if !valid_podman_container_name(runtime_name.value().expose()) {
2255 self.invalid(
2256 self.exporter.codes.invalid_value.clone(),
2257 &runtime_subject,
2258 "explicit container name does not satisfy Podman's runtime name grammar",
2259 "container names must match `[a-zA-Z0-9][a-zA-Z0-9_.-]*`",
2260 runtime_name.origins(),
2261 );
2262 } else if self.capability(
2263 "quadlet.container.container-name",
2264 &runtime_subject,
2265 runtime_name.origins(),
2266 ) && self.push_container(
2267 builder,
2268 ContainerKey::ContainerName,
2269 runtime_name.value().expose(),
2270 &runtime_subject,
2271 runtime_name.origins(),
2272 ) {
2273 self.exact(runtime_subject, runtime_name.origins());
2274 }
2275 }
2276
2277 fn map_restart_policy(
2278 &mut self,
2279 service_subject: &str,
2280 restart_policy: &Sourced<RestartPolicy>,
2281 builder: &mut QuadletDocumentBuilder,
2282 ) {
2283 let subject = format!("{service_subject}.restart_policy");
2284 let (value, approximation) = match restart_policy.value() {
2285 RestartPolicy::Never => ("no", None),
2286 RestartPolicy::Always => (
2287 "always",
2288 Some("systemd does not reproduce every runtime-specific activation gate and daemon-restart rule"),
2289 ),
2290 RestartPolicy::OnFailure {
2291 maximum_retries: Some(_),
2292 } => {
2293 self.unsupported(
2294 &subject,
2295 "a finite container restart count has no equivalent in Restart=; systemd start-rate limits use different time-window semantics",
2296 restart_policy.origins(),
2297 );
2298 return;
2299 }
2300 RestartPolicy::OnFailure { maximum_retries: None } => (
2301 "on-failure",
2302 Some(
2303 "systemd on-failure also covers selected signals, timeouts, watchdog failures, and OOM termination",
2304 ),
2305 ),
2306 RestartPolicy::UnlessStopped => (
2307 "always",
2308 Some(
2309 "systemd manual-stop handling cannot retain source-runtime unless-stopped state across runtime or host restarts",
2310 ),
2311 ),
2312 _ => {
2313 self.unsupported(
2314 &subject,
2315 "unknown container restart-policy form",
2316 restart_policy.origins(),
2317 );
2318 return;
2319 }
2320 };
2321 if !self.capability("systemd.service.restart", &subject, restart_policy.origins())
2322 || !self.push_systemd(
2323 builder,
2324 SystemdSection::Service,
2325 "Restart",
2326 value,
2327 &subject,
2328 restart_policy.origins(),
2329 )
2330 {
2331 return;
2332 }
2333 if let Some(reason) = approximation {
2334 self.restart_approximation(&subject, reason, restart_policy.origins());
2335 } else {
2336 self.exact(subject, restart_policy.origins());
2337 }
2338 }
2339
2340 fn map_metadata_labels(&mut self, service_subject: &str, service: &Service, builder: &mut QuadletDocumentBuilder) {
2341 for label in service.labels() {
2342 let name = label.value().name().as_str();
2343 let subject = format!("{service_subject}.labels.{name}");
2344 if is_compose_managed_label(name) {
2345 self.unsupported(
2346 &subject,
2347 "Compose-managed labels cannot be safely re-authored as application metadata",
2348 label.origins(),
2349 );
2350 continue;
2351 }
2352 let Some(encoded) = encode_quadlet_label(name, label.value().value().expose()) else {
2353 self.unsupported(
2354 &subject,
2355 "label names and values containing NUL cannot be represented by Quadlet or systemd",
2356 label.origins(),
2357 );
2358 continue;
2359 };
2360 if self.capability("quadlet.container.label", &subject, label.origins())
2361 && self.push_container(builder, ContainerKey::Label, encoded, &subject, label.origins())
2362 {
2363 self.exact(subject, label.origins());
2364 }
2365 }
2366 }
2367
2368 fn map_pod_or_networks(
2369 &mut self,
2370 service_subject: &str,
2371 service: &Sourced<Service>,
2372 pod_name: Option<&str>,
2373 builder: &mut QuadletDocumentBuilder,
2374 ) {
2375 let Some(pod_name) = pod_name else {
2376 for network in service.value().networks() {
2377 self.map_network_attachment(service_subject, network, builder);
2378 }
2379 return;
2380 };
2381 let subject = format!("{service_subject}.pod");
2382 if self.capability("quadlet.container.pod", &subject, service.origins())
2383 && self.push_container(
2384 builder,
2385 ContainerKey::Pod,
2386 format!("{pod_name}.pod"),
2387 &subject,
2388 service.origins(),
2389 )
2390 {
2391 self.exact(subject, service.origins());
2392 }
2393 }
2394
2395 fn map_secret_grant(
2396 &mut self,
2397 service_subject: &str,
2398 index: usize,
2399 grant: &Sourced<ResourceGrant>,
2400 builder: &mut QuadletDocumentBuilder,
2401 ) {
2402 let subject = format!("{service_subject}.secrets[{index}]");
2403 if grant.value().source().is_sensitive() {
2404 self.unsupported(
2405 &subject,
2406 "a sensitive interpolated secret identifier cannot be emitted into a unit file safely",
2407 grant.origins(),
2408 );
2409 return;
2410 }
2411 let source = grant.value().source().expose();
2412 let Some(secret) = self
2413 .application
2414 .secrets()
2415 .iter()
2416 .find(|secret| secret.value().name().as_str() == source)
2417 else {
2418 self.invalid(
2419 self.exporter.codes.invalid_value.clone(),
2420 &subject,
2421 "secret grant references a missing application resource",
2422 "declare the referenced secret before converting the service grant",
2423 grant.origins(),
2424 );
2425 return;
2426 };
2427 if secret.value().ownership() != ResourceOwnership::External || secret.value().material().is_some() {
2428 self.unsupported(
2429 &subject,
2430 "only pre-existing external Podman secrets can be consumed without an out-of-band creation step",
2431 &secret_grant_origins(grant, secret),
2432 );
2433 return;
2434 }
2435
2436 let runtime_name = secret
2437 .value()
2438 .runtime_name()
2439 .map_or(secret.value().name().as_str(), |name| name.value().expose());
2440 if secret
2441 .value()
2442 .runtime_name()
2443 .is_some_and(|name| name.value().is_sensitive())
2444 || !is_safe_secret_component(runtime_name)
2445 {
2446 self.unsupported(
2447 &subject,
2448 "external secret runtime name cannot be encoded in the reviewed Quadlet Secret grammar",
2449 &secret_grant_origins(grant, secret),
2450 );
2451 return;
2452 }
2453
2454 let options = match podman_secret_options(grant.value(), runtime_name, source) {
2455 Ok(options) => options,
2456 Err(reason) => {
2457 self.unsupported(&subject, reason, &secret_grant_origins(grant, secret));
2458 return;
2459 }
2460 };
2461
2462 let value = if options.is_empty() {
2463 runtime_name.to_owned()
2464 } else {
2465 format!("{runtime_name},{}", options.join(","))
2466 };
2467 let origins = secret_grant_origins(grant, secret);
2468 if self.capability("quadlet.container.secret", &subject, &origins)
2469 && self.push_container(builder, ContainerKey::Secret, value, &subject, &origins)
2470 {
2471 self.exact(subject, &origins);
2472 }
2473 }
2474
2475 fn map_execution_context(
2476 &mut self,
2477 service_subject: &str,
2478 service: &Service,
2479 grouped: bool,
2480 builder: &mut QuadletDocumentBuilder,
2481 ) {
2482 if let Some(user) = service.user() {
2483 self.map_protected_container_value(
2484 service_subject,
2485 "user",
2486 user,
2487 "quadlet.container.user",
2488 ContainerKey::User,
2489 builder,
2490 );
2491 }
2492 if let Some(group) = service.group() {
2493 let subject = format!("{service_subject}.group");
2494 if group.value().expose().bytes().all(|byte| byte.is_ascii_digit()) && !group.value().expose().is_empty() {
2495 self.map_protected_container_value(
2496 service_subject,
2497 "group",
2498 group,
2499 "quadlet.container.group",
2500 ContainerKey::Group,
2501 builder,
2502 );
2503 } else {
2504 self.unsupported(
2505 &subject,
2506 "Quadlet Group requires a numeric GID in the supported native contract",
2507 group.origins(),
2508 );
2509 }
2510 }
2511 if let Some(user_namespace) = service.user_namespace()
2512 && !grouped
2513 {
2514 self.map_protected_container_value(
2515 service_subject,
2516 "user_namespace",
2517 user_namespace,
2518 "quadlet.container.userns",
2519 ContainerKey::UserNS,
2520 builder,
2521 );
2522 }
2523 for (index, group) in service.supplementary_groups().iter().enumerate() {
2524 self.map_protected_container_value(
2525 service_subject,
2526 &format!("supplementary_groups[{index}]"),
2527 group,
2528 "quadlet.container.group-add",
2529 ContainerKey::GroupAdd,
2530 builder,
2531 );
2532 }
2533 if let Some(working_directory) = service.working_directory() {
2534 self.map_protected_container_value(
2535 service_subject,
2536 "working_directory",
2537 working_directory,
2538 "quadlet.container.working-dir",
2539 ContainerKey::WorkingDir,
2540 builder,
2541 );
2542 }
2543 if let Some(read_only) = service.read_only_root_filesystem() {
2544 let subject = format!("{service_subject}.read_only_root_filesystem");
2545 let value = if *read_only.value() { "true" } else { "false" };
2546 if self.capability("quadlet.container.read-only", &subject, read_only.origins())
2547 && self.push_container(builder, ContainerKey::ReadOnly, value, &subject, read_only.origins())
2548 {
2549 self.exact(subject, read_only.origins());
2550 }
2551 }
2552 }
2553
2554 #[allow(clippy::too_many_lines)] fn map_extended_container_settings(
2556 &mut self,
2557 service_subject: &str,
2558 service: &Service,
2559 grouped: bool,
2560 builder: &mut QuadletDocumentBuilder,
2561 ) {
2562 if let Some(entrypoint) = service.entrypoint() {
2563 let subject = format!("{service_subject}.entrypoint");
2564 match entrypoint.value() {
2565 Entrypoint::Exec(arguments)
2566 if !arguments.is_empty() && arguments.iter().all(|arg| is_safe_word(arg.expose(), false)) =>
2567 {
2568 let value = format!(
2569 "[{}]",
2570 arguments
2571 .iter()
2572 .map(|arg| format!("\"{}\"", arg.expose()))
2573 .collect::<Vec<_>>()
2574 .join(",")
2575 );
2576 if self.capability("quadlet.container.entrypoint", &subject, entrypoint.origins())
2577 && self.push_container(builder, ContainerKey::Entrypoint, value, &subject, entrypoint.origins())
2578 {
2579 self.exact(subject, entrypoint.origins());
2580 }
2581 }
2582 _ => self.unsupported(
2583 &subject,
2584 "Entrypoint is exact only for a reviewed JSON exec array",
2585 entrypoint.origins(),
2586 ),
2587 }
2588 }
2589 if let Some(run_init) = service.run_init() {
2590 let subject = format!("{service_subject}.run_init");
2591 if self.capability("quadlet.container.run-init", &subject, run_init.origins())
2592 && self.push_container(
2593 builder,
2594 ContainerKey::RunInit,
2595 run_init.value().to_string(),
2596 &subject,
2597 run_init.origins(),
2598 )
2599 {
2600 self.approximate(
2601 &subject,
2602 "RunInit runtime equivalence remains reviewable",
2603 run_init.origins(),
2604 );
2605 }
2606 }
2607 if let Some(timeout) = service.stop_timeout() {
2608 let subject = format!("{service_subject}.stop_timeout");
2609 if let Ok(seconds) = timeout.value().as_str().parse::<u64>() {
2610 if self.capability("quadlet.container.stop-timeout", &subject, timeout.origins())
2611 && self.push_container(
2612 builder,
2613 ContainerKey::StopTimeout,
2614 timeout.value().as_str(),
2615 &subject,
2616 timeout.origins(),
2617 )
2618 {
2619 if seconds == 0 {
2620 self.approximate(
2621 &subject,
2622 "a zero stop timeout can differ from an omitted/default timeout and remains reviewable",
2623 timeout.origins(),
2624 );
2625 } else {
2626 self.exact(subject, timeout.origins());
2627 }
2628 }
2629 } else {
2630 self.unsupported(
2631 &subject,
2632 "only nonnegative integral stop-timeout seconds are encodable; fractional native durations remain source-aware but require review",
2633 timeout.origins(),
2634 );
2635 }
2636 }
2637 if let Some(policy) = service.pull_policy() {
2638 let subject = format!("{service_subject}.pull_policy");
2639 let value = match policy.value() {
2640 PullPolicy::Always => Some("always"),
2641 PullPolicy::Missing => Some("missing"),
2642 PullPolicy::Never => Some("never"),
2643 PullPolicy::Raw(raw) if raw.expose() == "newer" => Some("newer"),
2644 _ => None,
2645 };
2646 if let Some(value) = value {
2647 if self.capability("quadlet.container.pull", &subject, policy.origins())
2648 && self.push_container(builder, ContainerKey::Pull, value, &subject, policy.origins())
2649 {
2650 self.exact(subject, policy.origins());
2651 }
2652 } else {
2653 self.unsupported(
2654 &subject,
2655 "only reviewed native Pull values can be emitted; arbitrary raw policies remain unsupported",
2656 policy.origins(),
2657 );
2658 }
2659 }
2660 if let Some(memory) = service.memory_limit() {
2661 let subject = format!("{service_subject}.memory_limit");
2662 if is_positive_memory(memory.value().expose()) {
2663 if self.capability("quadlet.container.memory", &subject, memory.origins())
2664 && self.push_container(
2665 builder,
2666 ContainerKey::Memory,
2667 memory.value().expose(),
2668 &subject,
2669 memory.origins(),
2670 )
2671 {
2672 self.exact(subject, memory.origins());
2673 }
2674 } else {
2675 self.unsupported(
2676 &subject,
2677 "memory must be a positive canonical byte quantity",
2678 memory.origins(),
2679 );
2680 }
2681 }
2682 if let Some(ports) = service.exposed_ports() {
2683 if ports.is_empty() {
2684 self.unsupported(
2685 &format!("{service_subject}.exposed_ports"),
2686 "an explicit empty exposed-port collection has no reviewed Quadlet reset encoding",
2687 service.exposed_ports_origins(),
2688 );
2689 } else {
2690 for (index, port) in ports.iter().enumerate() {
2691 self.map_exposed_port(service_subject, index, port, builder);
2692 }
2693 }
2694 }
2695 if let Some(annotations) = service.annotations() {
2696 if annotations.is_empty() {
2697 self.unsupported(
2698 &format!("{service_subject}.annotations"),
2699 "an explicit empty annotation collection has no reviewed Quadlet reset encoding",
2700 service.annotations_origins(),
2701 );
2702 } else {
2703 for annotation in annotations {
2704 self.map_annotation(service_subject, annotation, builder);
2705 }
2706 }
2707 }
2708 if let Some(logging) = service.logging() {
2709 self.map_logging(service_subject, logging, builder);
2710 }
2711 if let Some(reload) = service.reload_action() {
2712 self.map_reload_action(service_subject, reload, builder);
2713 }
2714 self.map_network_addresses(service_subject, service, grouped, builder);
2715 }
2716
2717 fn map_exposed_port(
2718 &mut self,
2719 service_subject: &str,
2720 index: usize,
2721 port: &Sourced<ExposedPort>,
2722 builder: &mut QuadletDocumentBuilder,
2723 ) {
2724 let subject = format!("{service_subject}.exposed_ports[{index}]");
2725 let protocol = match port.value().protocol() {
2726 Protocol::Tcp => "tcp",
2727 Protocol::Udp => "udp",
2728 _ => {
2729 self.unsupported(&subject, "ExposeHostPort supports only tcp or udp", port.origins());
2730 return;
2731 }
2732 };
2733 let value = if protocol == "tcp" {
2734 port.value().container().to_string()
2735 } else {
2736 format!("{}/udp", port.value().container())
2737 };
2738 if self.capability("quadlet.container.expose-host-port", &subject, port.origins())
2739 && self.push_container(builder, ContainerKey::ExposeHostPort, value, &subject, port.origins())
2740 {
2741 self.exact(subject, port.origins());
2742 }
2743 }
2744
2745 fn map_annotation(
2746 &mut self,
2747 service_subject: &str,
2748 annotation: &Sourced<Annotation>,
2749 builder: &mut QuadletDocumentBuilder,
2750 ) {
2751 let subject = format!(
2752 "{service_subject}.annotations.{}",
2753 annotation.value().name().value().as_str()
2754 );
2755 let Some(value) = encode_quadlet_label(
2756 annotation.value().name().value().as_str(),
2757 annotation.value().value().value().expose(),
2758 ) else {
2759 self.unsupported(
2760 &subject,
2761 "annotation contains NUL and cannot be represented",
2762 annotation.origins(),
2763 );
2764 return;
2765 };
2766 if self.capability("quadlet.container.annotation", &subject, annotation.origins())
2767 && self.push_container(builder, ContainerKey::Annotation, value, &subject, annotation.origins())
2768 {
2769 self.exact(subject, annotation.origins());
2770 }
2771 }
2772
2773 fn map_logging(
2774 &mut self,
2775 service_subject: &str,
2776 logging: &Sourced<boxferry_model::Logging>,
2777 builder: &mut QuadletDocumentBuilder,
2778 ) {
2779 if let Some(driver) = logging.value().driver() {
2780 let subject = format!("{service_subject}.logging.driver");
2781 if !is_safe_word(driver.value().expose(), false) {
2782 self.unsupported(
2783 &subject,
2784 "LogDriver requires a non-empty systemd-safe token",
2785 driver.origins(),
2786 );
2787 } else if self.capability("quadlet.container.log-driver", &subject, driver.origins())
2788 && self.push_container(
2789 builder,
2790 ContainerKey::LogDriver,
2791 driver.value().expose(),
2792 &subject,
2793 driver.origins(),
2794 )
2795 {
2796 self.approximate(
2797 &subject,
2798 "provider logging remains a reviewable partial mapping",
2799 driver.origins(),
2800 );
2801 }
2802 }
2803 if let Some(options) = logging.value().options() {
2804 if options.is_empty() {
2805 self.unsupported(
2806 &format!("{service_subject}.logging.options"),
2807 "an explicit empty logging-option collection has no reviewed Quadlet reset encoding",
2808 logging.value().options_origins(),
2809 );
2810 }
2811 for option in options {
2812 let subject = format!(
2813 "{service_subject}.logging.options.{}",
2814 option.value().name().value().as_str()
2815 );
2816 let Some(value) = encode_quadlet_label(
2817 option.value().name().value().as_str(),
2818 option.value().value().value().expose(),
2819 ) else {
2820 self.unsupported(
2821 &subject,
2822 "LogOpt contains NUL and cannot be represented",
2823 option.origins(),
2824 );
2825 continue;
2826 };
2827 if self.capability("quadlet.container.log-opt", &subject, option.origins())
2828 && self.push_container(builder, ContainerKey::LogOpt, value, &subject, option.origins())
2829 {
2830 self.approximate(
2831 &subject,
2832 "provider logging remains a reviewable partial mapping",
2833 option.origins(),
2834 );
2835 }
2836 }
2837 }
2838 }
2839
2840 fn map_reload_action(
2841 &mut self,
2842 service_subject: &str,
2843 reload: &Sourced<ReloadAction>,
2844 builder: &mut QuadletDocumentBuilder,
2845 ) {
2846 let subject = format!("{service_subject}.reload_action");
2847 match reload.value() {
2848 ReloadAction::Signal(signal) => {
2849 if is_safe_stop_signal(signal.expose())
2850 && self.capability("quadlet.container.reload-signal", &subject, reload.origins())
2851 && self.push_container(
2852 builder,
2853 ContainerKey::ReloadSignal,
2854 signal.expose(),
2855 &subject,
2856 reload.origins(),
2857 )
2858 {
2859 self.exact(subject, reload.origins());
2860 } else {
2861 self.unsupported(&subject, "reload signal is not safely encodable", reload.origins());
2862 }
2863 }
2864 ReloadAction::Command(Command::Exec(arguments))
2865 if !arguments.is_empty() && arguments.iter().all(|arg| is_safe_word(arg.expose(), false)) =>
2866 {
2867 let value = arguments
2868 .iter()
2869 .map(ProtectedString::expose)
2870 .collect::<Vec<_>>()
2871 .join(" ");
2872 if self.capability("quadlet.container.reload-cmd", &subject, reload.origins())
2873 && self.push_container(builder, ContainerKey::ReloadCmd, value, &subject, reload.origins())
2874 {
2875 self.exact(subject, reload.origins());
2876 }
2877 }
2878 _ => self.unsupported(
2879 &subject,
2880 "reload command requires unsupported systemd command-line encoding",
2881 reload.origins(),
2882 ),
2883 }
2884 }
2885
2886 fn map_network_addresses(
2887 &mut self,
2888 service_subject: &str,
2889 service: &Service,
2890 grouped: bool,
2891 builder: &mut QuadletDocumentBuilder,
2892 ) {
2893 let networks = service.networks();
2894 let has_values = networks.iter().any(|network| {
2895 network.value().ipv4_address().is_some()
2896 || network.value().ipv6_address().is_some()
2897 || !network.value().aliases().is_empty()
2898 });
2899 if !has_values {
2900 return;
2901 }
2902 if grouped {
2903 for network in networks {
2904 if network.value().ipv4_address().is_some()
2905 || network.value().ipv6_address().is_some()
2906 || !network.value().aliases().is_empty()
2907 {
2908 self.unsupported(
2909 &format!("{service_subject}.networks.{}", network.value().network().as_str()),
2910 "IP, IP6, and NetworkAlias remain container-scoped and cannot be moved into a generated pod",
2911 network.origins(),
2912 );
2913 }
2914 }
2915 return;
2916 }
2917 if networks.len() != 1 {
2918 let origins = networks
2919 .iter()
2920 .flat_map(|network| {
2921 network
2922 .origins()
2923 .iter()
2924 .chain(network.value().ipv4_address().into_iter().flat_map(Sourced::origins))
2925 .chain(network.value().ipv6_address().into_iter().flat_map(Sourced::origins))
2926 .chain(network.value().alias_origins().iter().flatten())
2927 })
2928 .cloned()
2929 .collect::<Vec<_>>();
2930 self.unsupported(
2931 &format!("{service_subject}.networks"),
2932 "IP, IP6, and NetworkAlias require exactly one compatible network attachment",
2933 &origins,
2934 );
2935 return;
2936 }
2937 let network = networks[0].value();
2938 for (key, value, capability) in [
2939 (ContainerKey::IP, network.ipv4_address(), "quadlet.container.ip"),
2940 (ContainerKey::IP6, network.ipv6_address(), "quadlet.container.ip6"),
2941 ] {
2942 if let Some(value) = value {
2943 let subject = format!(
2944 "{service_subject}.networks.{}",
2945 if key == ContainerKey::IP {
2946 "ipv4_address"
2947 } else {
2948 "ipv6_address"
2949 }
2950 );
2951 if self.capability(capability, &subject, value.origins())
2952 && self.push_container(builder, key, value.value().expose(), &subject, value.origins())
2953 {
2954 self.exact(subject, value.origins());
2955 }
2956 }
2957 }
2958 for (index, alias) in network.aliases().iter().enumerate() {
2959 let subject = format!("{service_subject}.networks.aliases[{index}]");
2960 let origins = network.alias_origins().get(index).map_or(&[][..], Vec::as_slice);
2961 if self.capability("quadlet.container.network-alias", &subject, origins)
2962 && self.push_container(builder, ContainerKey::NetworkAlias, alias, &subject, origins)
2963 {
2964 self.exact(subject, origins);
2965 }
2966 }
2967 }
2968
2969 fn map_released_container_settings(
2970 &mut self,
2971 service_subject: &str,
2972 service: &Service,
2973 builder: &mut QuadletDocumentBuilder,
2974 ) {
2975 self.map_released_scalars(service_subject, service, builder);
2976 self.map_dns(service_subject, service, builder);
2977 self.map_capabilities(service_subject, service, builder);
2978 self.map_tmpfs(service_subject, service, builder);
2979 self.map_sysctls(service_subject, service, builder);
2980 self.map_ulimits(service_subject, service, builder);
2981 self.map_devices(service_subject, service, builder);
2982 self.map_stop_signal(service_subject, service, builder);
2983 self.map_security_options(service_subject, service, builder);
2984 }
2985
2986 fn map_security_options(&mut self, service_subject: &str, service: &Service, builder: &mut QuadletDocumentBuilder) {
2987 let Some(options) = service.security_options() else {
2988 return;
2989 };
2990 let collection_subject = format!("{service_subject}.security_options");
2991 if options.is_empty() {
2992 self.unsupported(
2993 &collection_subject,
2994 "an explicit empty security-option collection has no safe Quadlet reset encoding",
2995 service.security_options_origins(),
2996 );
2997 return;
2998 }
2999
3000 let mut singleton_counts = BTreeMap::new();
3001 for option in options {
3002 if let Some(name) = security_option_singleton_name(option.value()) {
3003 *singleton_counts.entry(name).or_insert(0_usize) += 1;
3004 }
3005 }
3006 let all_origins = collection_all_origins(options, service.security_options_origins());
3007 for (name, count) in &singleton_counts {
3008 if *count > 1 {
3009 self.invalid(
3010 self.exporter.codes.invalid_value.clone(),
3011 &collection_subject,
3012 "security-option singleton is declared more than once",
3013 &format!("{name} occurs {count} times; retain one explicit value before generating Quadlet"),
3014 &all_origins,
3015 );
3016 }
3017 }
3018
3019 let disable_labels = options
3020 .iter()
3021 .any(|option| matches!(option.value(), SecurityOption::SecurityLabelDisable(true)));
3022 let other_labels = options
3023 .iter()
3024 .any(|option| security_option_is_selinux_label(option.value()));
3025 if disable_labels && other_labels {
3026 self.unsupported(
3027 &collection_subject,
3028 "SecurityLabelDisable=true conflicts with additional SELinux label settings; all native keys are retained but require explicit partial authorization",
3029 &all_origins,
3030 );
3031 }
3032
3033 for (index, option) in options.iter().enumerate() {
3034 let subject = format!("{collection_subject}[{index}]");
3035 let Some((capability, key, value)) = security_option_output(option.value()) else {
3036 self.unsupported(&subject, "unknown security-option variant", option.origins());
3037 continue;
3038 };
3039 if security_option_singleton_name(option.value())
3040 .is_some_and(|name| singleton_counts.get(name).copied().unwrap_or_default() > 1)
3041 {
3042 continue;
3043 }
3044 if !is_safe_security_option_value(value) {
3045 self.unsupported(
3046 &subject,
3047 "security-option value requires quoting, contains a systemd specifier, or is not one safe physical line",
3048 option.origins(),
3049 );
3050 continue;
3051 }
3052 if self.capability(capability, &subject, option.origins())
3053 && self.push_container(builder, key, value, &subject, option.origins())
3054 {
3055 self.exact(subject, option.origins());
3056 }
3057 }
3058 }
3059
3060 fn map_released_scalars(&mut self, service_subject: &str, service: &Service, builder: &mut QuadletDocumentBuilder) {
3061 if let Some(hostname) = service.hostname() {
3062 self.map_raw_container_value(
3063 &format!("{service_subject}.hostname"),
3064 hostname,
3065 hostname.origins(),
3066 "quadlet.container.hostname",
3067 ContainerKey::HostName,
3068 builder,
3069 is_safe_hostname,
3070 "hostname must be a resolved systemd-safe token and must not rely on host UTS mode",
3071 );
3072 }
3073 if let Some(limit) = service.pids_limit() {
3074 let subject = format!("{service_subject}.pids_limit");
3075 let value = limit.value().expose();
3076 let valid = if value == "-1" {
3077 Some(PidsLimit::unlimited().as_str().to_owned())
3078 } else {
3079 PidsLimit::finite(value).ok().map(|value| value.as_str().to_owned())
3080 };
3081 self.map_validated_container_value(
3082 &subject,
3083 limit,
3084 valid,
3085 "quadlet.container.pids-limit",
3086 ContainerKey::PidsLimit,
3087 builder,
3088 "PID limit must be -1 or a positive ASCII decimal without normalization",
3089 );
3090 }
3091 if let Some(size) = service.shm_size() {
3092 let subject = format!("{service_subject}.shm_size");
3093 let valid = ShmSize::new(size.value().expose())
3094 .ok()
3095 .filter(|size| !size.is_unlimited())
3096 .map(|size| size.as_str().to_owned());
3097 self.map_validated_container_value(
3098 &subject,
3099 size,
3100 valid,
3101 "quadlet.container.shm-size",
3102 ContainerKey::ShmSize,
3103 builder,
3104 "shared-memory size must be a positive ASCII decimal with optional b, k, m, or g",
3105 );
3106 }
3107 }
3108
3109 fn map_dns(&mut self, service_subject: &str, service: &Service, builder: &mut QuadletDocumentBuilder) {
3110 self.map_dns_values(
3111 service_subject,
3112 "dns",
3113 service.dns_servers(),
3114 service.dns_servers_origins(),
3115 ContainerKey::DNS,
3116 builder,
3117 );
3118 self.map_dns_values(
3119 service_subject,
3120 "dns_opt",
3121 service.dns_options(),
3122 service.dns_options_origins(),
3123 ContainerKey::DNSOption,
3124 builder,
3125 );
3126 self.map_dns_values(
3127 service_subject,
3128 "dns_search",
3129 service.dns_search_domains(),
3130 service.dns_search_domains_origins(),
3131 ContainerKey::DNSSearch,
3132 builder,
3133 );
3134 }
3135
3136 fn report_grouped_dns(&mut self, service_subject: &str, service: &Service, grouped: bool) {
3137 if !grouped {
3138 return;
3139 }
3140 for (name, values, collection_origins) in [
3141 ("dns", service.dns_servers(), service.dns_servers_origins()),
3142 ("dns_opt", service.dns_options(), service.dns_options_origins()),
3143 (
3144 "dns_search",
3145 service.dns_search_domains(),
3146 service.dns_search_domains_origins(),
3147 ),
3148 ] {
3149 if let Some(values) = values {
3150 let origins = collection_all_origins(values, collection_origins);
3151 self.unsupported(
3152 &format!("{service_subject}.{name}"),
3153 "generated Pod grouping can change effective shared-pod resolver configuration",
3154 &origins,
3155 );
3156 }
3157 }
3158 }
3159
3160 fn map_dns_values(
3161 &mut self,
3162 service_subject: &str,
3163 name: &str,
3164 values: Option<&[Sourced<ProtectedString>]>,
3165 collection_origins: &[Provenance],
3166 key: ContainerKey,
3167 builder: &mut QuadletDocumentBuilder,
3168 ) {
3169 let Some(values) = values else {
3170 return;
3171 };
3172 let subject = format!("{service_subject}.{name}");
3173 if values.is_empty() {
3174 self.unsupported(
3175 &subject,
3176 "an empty Quadlet DNS assignment resets native state and cannot represent an explicit empty collection",
3177 collection_origins,
3178 );
3179 return;
3180 }
3181 if key == ContainerKey::DNSOption {
3182 let mut seen = BTreeSet::new();
3183 if values.iter().any(|value| !seen.insert(value.value().expose())) {
3184 let origins = collection_all_origins(values, collection_origins);
3185 self.invalid(
3186 self.exporter.codes.invalid_value.clone(),
3187 &subject,
3188 "DNS resolver options must be unique; duplicates cannot be emitted safely",
3189 "remove duplicate dns_opt entries",
3190 &origins,
3191 );
3192 return;
3193 }
3194 }
3195 for (index, value) in values.iter().enumerate() {
3196 let item_subject = format!("{subject}[{index}]");
3197 let raw = value.value().expose();
3198 let origins = collection_item_origins(collection_origins, value);
3199 if raw.is_empty() || raw.contains(['\0', '\r', '\n', '%', '$']) {
3200 self.unsupported(
3201 &item_subject,
3202 "DNS values must be non-empty resolved single physical lines without systemd specifiers",
3203 &origins,
3204 );
3205 continue;
3206 }
3207 if (key == ContainerKey::DNS && raw == "none") || (key == ContainerKey::DNSSearch && raw == ".") {
3208 self.unsupported(
3209 &item_subject,
3210 "special DNS values have target-specific resolver semantics",
3211 &origins,
3212 );
3213 }
3214 let emitted = self.capability(
3215 &format!(
3216 "quadlet.container.{}",
3217 match key {
3218 ContainerKey::DNS => "dns",
3219 ContainerKey::DNSOption => "dns-option",
3220 _ => "dns-search",
3221 }
3222 ),
3223 &item_subject,
3224 &origins,
3225 ) && self.push_container(builder, key, raw, &item_subject, &origins);
3226 if emitted
3227 && !((key == ContainerKey::DNS && raw == "none") || (key == ContainerKey::DNSSearch && raw == "."))
3228 {
3229 self.exact(item_subject, &origins);
3230 }
3231 }
3232 }
3233
3234 fn map_tmpfs(&mut self, service_subject: &str, service: &Service, builder: &mut QuadletDocumentBuilder) {
3235 if let Some(tmpfs) = service.tmpfs() {
3236 if tmpfs.is_empty() {
3237 self.unsupported(
3238 &format!("{service_subject}.tmpfs"),
3239 "an explicit empty tmpfs collection is retained but has no safe repeatable Quadlet reset encoding",
3240 service.tmpfs_origins(),
3241 );
3242 }
3243 for (index, value) in tmpfs.iter().enumerate() {
3244 let origins = collection_item_origins(service.tmpfs_origins(), value);
3245 self.map_raw_container_value(
3246 &format!("{service_subject}.tmpfs[{index}]"),
3247 value,
3248 &origins,
3249 "quadlet.container.tmpfs",
3250 ContainerKey::Tmpfs,
3251 builder,
3252 is_safe_tmpfs,
3253 "tmpfs must be a resolved non-empty systemd-safe declaration",
3254 );
3255 }
3256 }
3257 }
3258
3259 fn map_sysctls(&mut self, service_subject: &str, service: &Service, builder: &mut QuadletDocumentBuilder) {
3260 if let Some(sysctls) = service.sysctls() {
3261 if sysctls.is_empty() {
3262 self.unsupported(&format!("{service_subject}.sysctls"), "an explicit empty sysctls collection is retained but has no safe repeatable Quadlet reset encoding", service.sysctls_origins());
3263 }
3264 for (index, sysctl) in sysctls.iter().enumerate() {
3265 let subject = format!("{service_subject}.sysctls[{index}]");
3266 let origins = collection_item_origins(service.sysctls_origins(), sysctl);
3267 let name = sysctl.value().name().expose();
3268 let value = sysctl.value().value().expose();
3269 if !is_safe_sysctl(name, value) {
3270 self.unsupported(
3271 &subject,
3272 "sysctl must use an unambiguous systemd-safe name=value spelling",
3273 &origins,
3274 );
3275 } else if self.capability("quadlet.container.sysctl", &subject, &origins)
3276 && self.push_container(
3277 builder,
3278 ContainerKey::Sysctl,
3279 format!("{name}={value}"),
3280 &subject,
3281 &origins,
3282 )
3283 {
3284 self.exact(subject, &origins);
3285 }
3286 }
3287 }
3288 }
3289
3290 fn map_ulimits(&mut self, service_subject: &str, service: &Service, builder: &mut QuadletDocumentBuilder) {
3291 if let Some(ulimits) = service.ulimits() {
3292 if ulimits.is_empty() {
3293 self.unsupported(&format!("{service_subject}.ulimits"), "an explicit empty ulimits collection is retained but has no safe repeatable Quadlet reset encoding", service.ulimits_origins());
3294 }
3295 for (index, limit) in ulimits.iter().enumerate() {
3296 let subject = format!("{service_subject}.ulimits[{index}]");
3297 let origins = resource_limit_origins(service.ulimits_origins(), limit);
3298 let Some(soft) = limit.value().soft() else {
3299 self.unsupported(
3300 &subject,
3301 "ulimit lacks a complete soft/hard scalar representation",
3302 &origins,
3303 );
3304 continue;
3305 };
3306 let Some(hard) = limit.value().hard() else {
3307 self.unsupported(
3308 &subject,
3309 "ulimit lacks a complete soft/hard scalar representation",
3310 &origins,
3311 );
3312 continue;
3313 };
3314 let name = limit.value().name().expose();
3315 let soft_value = soft.value().expose();
3316 let hard_value = hard.value().expose();
3317 if !is_safe_ulimit(name, soft_value, hard_value) {
3318 self.unsupported(
3319 &subject,
3320 "ulimit requires a lowercase name and -1 or non-negative ASCII decimal soft/hard values",
3321 &origins,
3322 );
3323 } else if self.capability("quadlet.container.ulimit", &subject, &origins)
3324 && self.push_container(
3325 builder,
3326 ContainerKey::Ulimit,
3327 format!("{name}={soft_value}:{hard_value}"),
3328 &subject,
3329 &origins,
3330 )
3331 {
3332 self.exact(subject, &origins);
3333 }
3334 }
3335 }
3336 }
3337
3338 fn map_devices(&mut self, service_subject: &str, service: &Service, builder: &mut QuadletDocumentBuilder) {
3339 if let Some(devices) = service.devices() {
3340 if devices.is_empty() {
3341 self.unsupported(&format!("{service_subject}.devices"), "an explicit empty devices collection is retained but has no safe repeatable Quadlet reset encoding", service.devices_origins());
3342 }
3343 for (index, device) in devices.iter().enumerate() {
3344 let subject = format!("{service_subject}.devices[{index}]");
3345 let origins = device_origins(service.devices_origins(), device);
3346 let Some(value) = reviewed_device_value(device.value()) else {
3347 self.unsupported(
3348 &subject,
3349 "device is CDI, opaque, deferred, or not a reviewed host-device spelling",
3350 &origins,
3351 );
3352 continue;
3353 };
3354 if self.capability("quadlet.container.add-device", &subject, &origins)
3355 && self.push_container(builder, ContainerKey::AddDevice, value, &subject, &origins)
3356 {
3357 self.exact(subject, &origins);
3358 }
3359 }
3360 }
3361 }
3362
3363 fn map_stop_signal(&mut self, service_subject: &str, service: &Service, builder: &mut QuadletDocumentBuilder) {
3364 if let Some(signal) = service.stop_signal() {
3365 self.map_raw_container_value(
3366 &format!("{service_subject}.stop_signal"),
3367 signal,
3368 signal.origins(),
3369 "quadlet.container.stop-signal",
3370 ContainerKey::StopSignal,
3371 builder,
3372 is_safe_stop_signal,
3373 "stop signal must be a non-empty systemd-safe token or number without grammar normalization",
3374 );
3375 }
3376 }
3377
3378 fn map_capabilities(&mut self, service_subject: &str, service: &Service, builder: &mut QuadletDocumentBuilder) {
3379 for (field, values, collection_origins, capability, key) in [
3380 (
3381 "cap_drop",
3382 service.cap_drop(),
3383 service.cap_drop_origins(),
3384 "quadlet.container.drop-capability",
3385 ContainerKey::DropCapability,
3386 ),
3387 (
3388 "cap_add",
3389 service.cap_add(),
3390 service.cap_add_origins(),
3391 "quadlet.container.add-capability",
3392 ContainerKey::AddCapability,
3393 ),
3394 ] {
3395 let Some(values) = values else {
3396 continue;
3397 };
3398 if values.is_empty() {
3399 self.unsupported(
3400 &format!("{service_subject}.{field}"),
3401 "an explicit empty capability collection is retained but has no safe repeatable Quadlet reset encoding",
3402 collection_origins,
3403 );
3404 }
3405 for (index, value) in values.iter().enumerate() {
3406 let origins = collection_item_origins(collection_origins, value);
3407 self.map_raw_container_value(
3408 &format!("{service_subject}.{field}[{index}]"),
3409 value,
3410 &origins,
3411 capability,
3412 key,
3413 builder,
3414 is_safe_capability,
3415 "capability names must use the reviewed uppercase Linux capability grammar",
3416 );
3417 }
3418 }
3419 }
3420
3421 #[allow(clippy::too_many_arguments)]
3422 fn map_raw_container_value(
3423 &mut self,
3424 subject: &str,
3425 value: &Sourced<ProtectedString>,
3426 origins: &[Provenance],
3427 capability: &str,
3428 key: ContainerKey,
3429 builder: &mut QuadletDocumentBuilder,
3430 validate: impl FnOnce(&str) -> bool,
3431 reason: &str,
3432 ) {
3433 if !validate(value.value().expose()) {
3434 self.unsupported(subject, reason, origins);
3435 } else if self.capability(capability, subject, origins)
3436 && self.push_container(builder, key, value.value().expose(), subject, origins)
3437 {
3438 self.exact(subject, origins);
3439 }
3440 }
3441
3442 #[allow(clippy::too_many_arguments)]
3443 fn map_validated_container_value(
3444 &mut self,
3445 subject: &str,
3446 value: &Sourced<ProtectedString>,
3447 encoded: Option<String>,
3448 capability: &str,
3449 key: ContainerKey,
3450 builder: &mut QuadletDocumentBuilder,
3451 reason: &str,
3452 ) {
3453 let Some(encoded) = encoded else {
3454 self.unsupported(subject, reason, value.origins());
3455 return;
3456 };
3457 if self.capability(capability, subject, value.origins())
3458 && self.push_container(builder, key, encoded, subject, value.origins())
3459 {
3460 self.exact(subject, value.origins());
3461 }
3462 }
3463
3464 #[allow(clippy::too_many_arguments)]
3465 fn map_protected_container_value(
3466 &mut self,
3467 service_subject: &str,
3468 field: &str,
3469 value: &Sourced<ProtectedString>,
3470 capability: &str,
3471 key: ContainerKey,
3472 builder: &mut QuadletDocumentBuilder,
3473 ) {
3474 let subject = format!("{service_subject}.{field}");
3475 if !is_safe_word(value.value().expose(), false) {
3476 self.unsupported(
3477 &subject,
3478 "execution-context value requires systemd quoting or target-specific validation not yet encoded",
3479 value.origins(),
3480 );
3481 return;
3482 }
3483 if self.capability(capability, &subject, value.origins())
3484 && self.push_container(builder, key, value.value().expose(), &subject, value.origins())
3485 {
3486 self.exact(subject, value.origins());
3487 }
3488 }
3489
3490 fn map_service_dependencies(&mut self, service: &Sourced<Service>, builder: &mut QuadletDocumentBuilder) {
3491 let service_name = service.value().name().as_str();
3492 for (index, dependency) in service.value().dependencies().iter().enumerate() {
3493 self.map_service_dependency(service_name, index, dependency, builder);
3494 }
3495 }
3496
3497 fn map_service_dependency(
3498 &mut self,
3499 service_name: &str,
3500 index: usize,
3501 dependency: &Sourced<ServiceDependency>,
3502 builder: &mut QuadletDocumentBuilder,
3503 ) {
3504 let subject = format!("services.{service_name}.dependencies[{index}]");
3505 let target_name = dependency.value().service().as_str();
3506 let origins = dependency_mapping_origins(dependency);
3507 let Some(target) = self
3508 .application
3509 .services()
3510 .iter()
3511 .find(|candidate| candidate.value().name().as_str() == target_name)
3512 else {
3513 self.loss(
3514 self.exporter.codes.dependency_unsupported.clone(),
3515 &subject,
3516 ConversionKind::Unsupported,
3517 "optional service dependency is absent from the application",
3518 &format!("optional service `{target_name}` cannot be ordered or activated"),
3519 &origins,
3520 );
3521 return;
3522 };
3523 if !self.dependency_condition_supported(&subject, dependency, target.value(), &origins) {
3524 return;
3525 }
3526
3527 let target_unit = format!("{target_name}.service");
3528 let activation = if dependency.value().is_required() {
3529 ("systemd.unit.requires", SystemdUnitKey::Requires)
3530 } else {
3531 ("systemd.unit.wants", SystemdUnitKey::Wants)
3532 };
3533 let activation_supported = self.capability(activation.0, &subject, &origins);
3534 let ordering_supported = self.capability("systemd.unit.after", &subject, &origins);
3535 if activation_supported
3536 && ordering_supported
3537 && self.push_systemd_unit(builder, activation.1, &target_unit, &subject, &origins)
3538 && self.push_systemd_unit(builder, SystemdUnitKey::After, target_unit, &subject, &origins)
3539 {
3540 self.exact(&subject, &origins);
3541 }
3542 self.map_dependency_restart(&subject, dependency);
3543 }
3544
3545 fn dependency_condition_supported(
3546 &mut self,
3547 subject: &str,
3548 dependency: &Sourced<ServiceDependency>,
3549 target: &Service,
3550 origins: &[Provenance],
3551 ) -> bool {
3552 match dependency
3553 .value()
3554 .condition()
3555 .map_or(&ServiceDependencyCondition::Started, Sourced::value)
3556 {
3557 ServiceDependencyCondition::Started => true,
3558 ServiceDependencyCondition::Healthy if service_has_renderable_healthcheck(target) => true,
3559 ServiceDependencyCondition::Healthy => {
3560 self.loss(
3561 self.exporter.codes.dependency_unsupported.clone(),
3562 subject,
3563 ConversionKind::Unsupported,
3564 "healthy service dependency cannot be established for the target",
3565 "referenced service has no explicit, enabled, safely encodable health command",
3566 origins,
3567 );
3568 false
3569 }
3570 ServiceDependencyCondition::CompletedSuccessfully => {
3571 self.loss(
3572 self.exporter.codes.dependency_unsupported.clone(),
3573 subject,
3574 ConversionKind::Unsupported,
3575 "successful-completion dependency has no verified Quadlet mapping",
3576 "ordinary Quadlet container services do not provide the required one-shot completion contract",
3577 origins,
3578 );
3579 false
3580 }
3581 ServiceDependencyCondition::Other(_) => {
3582 self.loss(
3583 self.exporter.codes.dependency_unsupported.clone(),
3584 subject,
3585 ConversionKind::Unsupported,
3586 "source-specific dependency condition has no verified Quadlet mapping",
3587 "retain or replace the provider-specific condition manually",
3588 origins,
3589 );
3590 false
3591 }
3592 _ => {
3593 self.loss(
3594 self.exporter.codes.dependency_unsupported.clone(),
3595 subject,
3596 ConversionKind::Unsupported,
3597 "unknown dependency condition has no verified Quadlet mapping",
3598 "upgrade BoxFerry or select a supported condition",
3599 origins,
3600 );
3601 false
3602 }
3603 }
3604 }
3605
3606 fn map_dependency_restart(&mut self, subject: &str, dependency: &Sourced<ServiceDependency>) {
3607 let Some(restart) = dependency.value().restart() else {
3608 return;
3609 };
3610 let restart_subject = format!("{subject}.restart");
3611 if *restart.value() {
3612 self.loss(
3613 self.exporter.codes.dependency_unsupported.clone(),
3614 &restart_subject,
3615 ConversionKind::Unsupported,
3616 "Compose-controlled dependency restart propagation has no verified Quadlet mapping",
3617 "systemd runtime restart relationships are not equivalent to explicit Compose update operations",
3618 restart.origins(),
3619 );
3620 } else {
3621 self.exact(restart_subject, restart.origins());
3622 }
3623 }
3624
3625 fn map_healthy_readiness(&mut self, service: &Sourced<Service>, builder: &mut QuadletDocumentBuilder) {
3626 let name = service.value().name().as_str();
3627 if !service_has_renderable_healthcheck(service.value()) {
3628 return;
3629 }
3630 let mut origins = self
3631 .application
3632 .services()
3633 .iter()
3634 .flat_map(|dependent| dependent.value().dependencies())
3635 .filter(|dependency| {
3636 dependency.value().service().as_str() == name
3637 && matches!(
3638 dependency.value().condition().map(Sourced::value),
3639 Some(ServiceDependencyCondition::Healthy)
3640 )
3641 })
3642 .flat_map(dependency_mapping_origins)
3643 .collect::<Vec<_>>();
3644 if origins.is_empty() {
3645 return;
3646 }
3647 if let Some(healthcheck) = service.value().healthcheck() {
3648 for origin in healthcheck.origins() {
3649 if !origins.contains(origin) {
3650 origins.push(origin.clone());
3651 }
3652 }
3653 }
3654 let subject = format!("services.{name}.readiness");
3655 if self.capability("quadlet.container.notify-healthy", &subject, &origins)
3656 && self.push_container(builder, ContainerKey::Notify, "healthy", &subject, &origins)
3657 {
3658 self.exact(subject, &origins);
3659 }
3660 }
3661
3662 fn map_command(&mut self, service_subject: &str, command: &Sourced<Command>, builder: &mut QuadletDocumentBuilder) {
3663 let subject = format!("{service_subject}.command");
3664 let value = match command.value() {
3665 Command::Exec(arguments)
3666 if arguments
3667 .iter()
3668 .any(|argument| contains_unresolved_source_variable(argument.expose(), command.origins())) =>
3669 {
3670 self.unsupported(
3671 &subject,
3672 "command argument contains unresolved authored variable syntax",
3673 command.origins(),
3674 );
3675 None
3676 }
3677 Command::Exec(arguments) if !arguments.is_empty() => arguments
3678 .iter()
3679 .map(|argument| encode_quadlet_word(argument.expose()))
3680 .collect::<Option<Vec<_>>>()
3681 .map(|arguments| arguments.join(" "))
3682 .or_else(|| {
3683 self.unsupported(
3684 &subject,
3685 "command argument contains a physical line break, NUL byte, or control character",
3686 command.origins(),
3687 );
3688 None
3689 }),
3690 Command::Exec(_) => {
3691 self.unsupported(
3692 &subject,
3693 "exec-form command requires at least one argument",
3694 command.origins(),
3695 );
3696 None
3697 }
3698 Command::Shell(_) => {
3699 self.unsupported(
3700 &subject,
3701 "shell-form command semantics are not yet encoded for Quadlet Exec",
3702 command.origins(),
3703 );
3704 None
3705 }
3706 Command::Empty => {
3707 self.unsupported(
3708 &subject,
3709 "explicit command clearing is not yet represented by the Quadlet adapter",
3710 command.origins(),
3711 );
3712 None
3713 }
3714 _ => {
3715 self.unsupported(&subject, "unknown command form", command.origins());
3716 None
3717 }
3718 };
3719 if let Some(value) = value
3720 && self.capability("quadlet.container.exec", &subject, command.origins())
3721 && self.push_container(builder, ContainerKey::Exec, value, &subject, command.origins())
3722 {
3723 self.exact(subject, command.origins());
3724 }
3725 }
3726
3727 fn map_healthcheck(
3728 &mut self,
3729 service_subject: &str,
3730 healthcheck: &Sourced<Healthcheck>,
3731 builder: &mut QuadletDocumentBuilder,
3732 ) {
3733 let disabled = healthcheck.value().disabled().is_some_and(|value| *value.value());
3734 if disabled && healthcheck.value().command().is_some() {
3735 self.invalid(
3736 self.exporter.codes.invalid_value.clone(),
3737 &format!("{service_subject}.healthcheck"),
3738 "health check cannot be both disabled and assigned a command",
3739 "resolve the conflicting neutral health-check intent before target planning",
3740 healthcheck.origins(),
3741 );
3742 return;
3743 }
3744
3745 if let Some(disable) = healthcheck.value().disabled() {
3746 let subject = format!("{service_subject}.healthcheck.disable");
3747 if *disable.value() {
3748 if self.capability("quadlet.container.health-command", &subject, disable.origins())
3749 && self.push_container(builder, ContainerKey::HealthCmd, "none", &subject, disable.origins())
3750 {
3751 self.exact(subject, disable.origins());
3752 }
3753 } else {
3754 self.exact(subject, disable.origins());
3755 }
3756 }
3757
3758 if !disabled && let Some(command) = healthcheck.value().command() {
3759 self.map_healthcheck_command(service_subject, command, builder);
3760 }
3761 if let Some(interval) = healthcheck.value().interval() {
3762 self.map_healthcheck_scalar(
3763 service_subject,
3764 "interval",
3765 interval.value().as_str(),
3766 interval.origins(),
3767 "quadlet.container.health-interval",
3768 ContainerKey::HealthInterval,
3769 HealthcheckScalarKind::Duration,
3770 builder,
3771 );
3772 }
3773 if let Some(timeout) = healthcheck.value().timeout() {
3774 self.map_healthcheck_scalar(
3775 service_subject,
3776 "timeout",
3777 timeout.value().as_str(),
3778 timeout.origins(),
3779 "quadlet.container.health-timeout",
3780 ContainerKey::HealthTimeout,
3781 HealthcheckScalarKind::Duration,
3782 builder,
3783 );
3784 }
3785 if let Some(retries) = healthcheck.value().retries() {
3786 self.map_healthcheck_scalar(
3787 service_subject,
3788 "retries",
3789 retries.value().as_str(),
3790 retries.origins(),
3791 "quadlet.container.health-retries",
3792 ContainerKey::HealthRetries,
3793 HealthcheckScalarKind::RetryCount,
3794 builder,
3795 );
3796 }
3797 if let Some(start_period) = healthcheck.value().start_period() {
3798 self.map_healthcheck_scalar(
3799 service_subject,
3800 "start_period",
3801 start_period.value().as_str(),
3802 start_period.origins(),
3803 "quadlet.container.health-start-period",
3804 ContainerKey::HealthStartPeriod,
3805 HealthcheckScalarKind::Duration,
3806 builder,
3807 );
3808 }
3809 if let Some(start_interval) = healthcheck.value().start_interval() {
3810 self.unsupported(
3811 &format!("{service_subject}.healthcheck.start_interval"),
3812 "Quadlet has no native HealthStartInterval key and no verified fallback is enabled",
3813 start_interval.origins(),
3814 );
3815 }
3816 }
3817
3818 fn map_healthcheck_command(
3819 &mut self,
3820 service_subject: &str,
3821 command: &Sourced<HealthcheckCommand>,
3822 builder: &mut QuadletDocumentBuilder,
3823 ) {
3824 let subject = format!("{service_subject}.healthcheck.test");
3825 let arguments = match command.value() {
3826 HealthcheckCommand::Exec(arguments) if !arguments.is_empty() => {
3827 let mut values = Vec::with_capacity(arguments.len() + 1);
3828 values.push("CMD");
3829 values.extend(arguments.iter().map(ProtectedString::expose));
3830 values
3831 }
3832 HealthcheckCommand::Shell(value) if !value.expose().is_empty() => {
3833 vec!["CMD-SHELL", value.expose()]
3834 }
3835 HealthcheckCommand::Exec(_) => {
3836 self.invalid(
3837 self.exporter.codes.invalid_value.clone(),
3838 &subject,
3839 "health-check exec command requires at least one argument",
3840 "supply a command or explicitly disable the health check",
3841 command.origins(),
3842 );
3843 return;
3844 }
3845 HealthcheckCommand::Shell(_) => {
3846 self.invalid(
3847 self.exporter.codes.invalid_value.clone(),
3848 &subject,
3849 "health-check shell command must not be empty",
3850 "supply shell command text or explicitly disable the health check",
3851 command.origins(),
3852 );
3853 return;
3854 }
3855 _ => {
3856 self.unsupported(&subject, "unknown health-check command form", command.origins());
3857 return;
3858 }
3859 };
3860 if arguments
3861 .iter()
3862 .any(|argument| argument.contains('\0') || argument.contains('%'))
3863 {
3864 self.unsupported(
3865 &subject,
3866 "health command contains a NUL byte or unresolved systemd percent specifier",
3867 command.origins(),
3868 );
3869 return;
3870 }
3871 let encoded = encode_json_array(&arguments);
3872 if self.capability("quadlet.container.health-command", &subject, command.origins())
3873 && self.push_container(builder, ContainerKey::HealthCmd, encoded, &subject, command.origins())
3874 {
3875 self.exact(subject, command.origins());
3876 }
3877 }
3878
3879 #[allow(clippy::too_many_arguments)]
3880 fn map_healthcheck_scalar(
3881 &mut self,
3882 service_subject: &str,
3883 field: &str,
3884 value: &str,
3885 origins: &[Provenance],
3886 capability: &str,
3887 key: ContainerKey,
3888 kind: HealthcheckScalarKind,
3889 builder: &mut QuadletDocumentBuilder,
3890 ) {
3891 let subject = format!("{service_subject}.healthcheck.{field}");
3892 let valid = is_safe_health_scalar(value)
3893 && match kind {
3894 HealthcheckScalarKind::Duration => is_podman_health_duration(value),
3895 HealthcheckScalarKind::RetryCount => value.bytes().all(|byte| byte.is_ascii_digit()),
3896 };
3897 if !valid {
3898 self.unsupported(
3899 &subject,
3900 "health-check scalar is not valid for the selected Podman target",
3901 origins,
3902 );
3903 return;
3904 }
3905 if self.capability(capability, &subject, origins) && self.push_container(builder, key, value, &subject, origins)
3906 {
3907 self.exact(subject, origins);
3908 }
3909 }
3910
3911 fn map_environment(
3912 &mut self,
3913 service_subject: &str,
3914 environment: &Sourced<boxferry_model::EnvironmentVariable>,
3915 builder: &mut QuadletDocumentBuilder,
3916 ) {
3917 let name = environment.value().name().as_str();
3918 let subject = format!("{service_subject}.environment.{name}");
3919 if let EnvironmentValue::Deferred(expression) = environment.value().value() {
3920 self.map_deferred_environment(&subject, name, expression, environment.origins(), builder);
3921 return;
3922 }
3923 if matches!(environment.value().value(), EnvironmentValue::UnresolvedSource { .. }) {
3924 self.loss(
3925 self.exporter.codes.unresolved_variable.clone(),
3926 &subject,
3927 ConversionKind::Unsupported,
3928 "source environment expression remains unresolved",
3929 "resolve authored source syntax using conversion-time --env or --env-file before Quadlet output",
3930 environment.origins(),
3931 );
3932 return;
3933 }
3934 let EnvironmentValue::Literal(value) = environment.value().value() else {
3935 let reason = match environment.value().value() {
3936 EnvironmentValue::Required => {
3937 "environment value was withheld; supply this named value on the target before use"
3938 }
3939 EnvironmentValue::Host => "host environment resolution requires an explicit value provider",
3940 EnvironmentValue::Unset => "ensuring an image variable is absent requires a target-specific fallback",
3941 _ => "unknown environment value form",
3942 };
3943 self.unsupported(&subject, reason, environment.origins());
3944 return;
3945 };
3946 self.map_literal_environment(&subject, name, value.expose(), environment.origins(), builder);
3947 }
3948
3949 fn map_environment_file(
3950 &mut self,
3951 service_subject: &str,
3952 index: usize,
3953 environment_file: &Sourced<EnvironmentFile>,
3954 builder: &mut QuadletDocumentBuilder,
3955 ) {
3956 let subject = format!("{service_subject}.env_file[{index}]");
3957 let origins = environment_file_origins(environment_file);
3958 if !environment_file.value().is_required() {
3959 self.unsupported(
3960 &subject,
3961 "Quadlet EnvironmentFile has no documented Compose-compatible optional-file setting",
3962 &origins,
3963 );
3964 return;
3965 }
3966
3967 let authored_path = environment_file.value().path().expose();
3968 let path = match classify_path(authored_path) {
3969 PathForm::AbsoluteLiteral => normalize_absolute_path(authored_path),
3970 PathForm::UnitRelativeLiteral | PathForm::RelativeLiteral
3971 if is_safe_mount_part(authored_path) && !authored_path.starts_with('~') =>
3972 {
3973 self.exporter
3974 .relative_bind_root
3975 .as_deref()
3976 .and_then(|root| resolve_relative_path(root, authored_path))
3977 }
3978 _ => None,
3979 };
3980 let Some(path) = path else {
3981 let reason = match classify_path(authored_path) {
3982 PathForm::UnitRelativeLiteral | PathForm::RelativeLiteral
3983 if self.exporter.relative_bind_root.is_none() =>
3984 {
3985 "relative environment-file path needs an explicit Compose project root"
3986 }
3987 PathForm::SystemdSpecifier => {
3988 "Compose environment-file paths cannot acquire systemd specifier semantics implicitly"
3989 }
3990 _ => "environment-file path is not a safely encodable POSIX path",
3991 };
3992 self.unsupported(&subject, reason, &origins);
3993 return;
3994 };
3995
3996 if self.capability("quadlet.container.environment-file", &subject, &origins)
3997 && self.push_container(builder, ContainerKey::EnvironmentFile, path, &subject, &origins)
3998 {
3999 let reason = match environment_file.value().format().map(Sourced::value) {
4000 Some(EnvironmentFileFormat::Raw) => {
4001 "Compose raw env-file parsing has no explicit Quadlet selector; Podman parser parity is not yet proven"
4002 }
4003 Some(_) => "the requested env-file parser mode has no proven Quadlet equivalent",
4004 None => "Compose default env-file parsing and Podman env-file parsing are not yet proven equivalent",
4005 };
4006 self.environment_file_approximation(&subject, reason, &origins);
4007 }
4008 }
4009
4010 fn map_container_host_mapping(
4011 &mut self,
4012 service_subject: &str,
4013 index: usize,
4014 mapping: &Sourced<HostMapping>,
4015 builder: &mut QuadletDocumentBuilder,
4016 ) {
4017 let subject = format!("{service_subject}.host_mappings[{index}]");
4018 let Some(encoded) = self.encode_host_mapping(&subject, mapping.value(), mapping.origins()) else {
4019 return;
4020 };
4021 if self.capability("quadlet.container.add-host", &subject, mapping.origins())
4022 && self.push_container(builder, ContainerKey::AddHost, encoded, &subject, mapping.origins())
4023 {
4024 self.exact(subject, mapping.origins());
4025 }
4026 }
4027
4028 fn map_pod_host_mapping(
4029 &mut self,
4030 index: usize,
4031 mapping: &HostMapping,
4032 origins: &[Provenance],
4033 builder: &mut QuadletDocumentBuilder,
4034 ) {
4035 let subject = format!("application.pod.host_mappings[{index}]");
4036 let Some(encoded) = self.encode_host_mapping(&subject, mapping, origins) else {
4037 return;
4038 };
4039 if self.capability("quadlet.pod.add-host", &subject, origins)
4040 && self.push_pod(builder, PodKey::AddHost, encoded, &subject, origins)
4041 {
4042 self.exact(subject, origins);
4043 }
4044 }
4045
4046 fn encode_host_mapping(&mut self, subject: &str, mapping: &HostMapping, origins: &[Provenance]) -> Option<String> {
4047 let hostname = mapping.hostname().as_str();
4048 if !is_native_atom(hostname) {
4049 self.unsupported(subject, "host mapping name requires target-specific encoding", origins);
4050 return None;
4051 }
4052 let address = match mapping.address().kind() {
4053 HostAddressKind::Ipv4 | HostAddressKind::HostGateway | HostAddressKind::Ipv6 { bracketed: true } => {
4054 mapping.address().raw().to_owned()
4055 }
4056 HostAddressKind::Ipv6 { bracketed: false } => format!("[{}]", mapping.address().raw()),
4057 HostAddressKind::Other => {
4058 self.unsupported(
4059 subject,
4060 "host mapping address is deferred or implementation-specific",
4061 origins,
4062 );
4063 return None;
4064 }
4065 _ => {
4066 self.unsupported(subject, "unknown host mapping address kind", origins);
4067 return None;
4068 }
4069 };
4070 Some(format!("{hostname}:{address}"))
4071 }
4072
4073 fn map_port(
4074 &mut self,
4075 service_subject: &str,
4076 index: usize,
4077 port: &Sourced<Port>,
4078 builder: &mut QuadletDocumentBuilder,
4079 ) {
4080 let subject = format!("{service_subject}.ports[{index}]");
4081 let Some(encoded) = self.encode_port(&subject, port) else {
4082 return;
4083 };
4084 if self.capability("quadlet.container.publish-port", &subject, port.origins())
4085 && self.push_container(builder, ContainerKey::PublishPort, encoded, &subject, port.origins())
4086 {
4087 self.exact(subject, port.origins());
4088 }
4089 }
4090
4091 fn map_pod_port(
4092 &mut self,
4093 service_subject: &str,
4094 index: usize,
4095 port: &Sourced<Port>,
4096 builder: &mut QuadletDocumentBuilder,
4097 ) {
4098 let subject = format!("{service_subject}.ports[{index}]");
4099 let Some(encoded) = self.encode_port(&subject, port) else {
4100 return;
4101 };
4102 if self.capability("quadlet.pod.publish-port", &subject, port.origins())
4103 && self.push_pod(builder, PodKey::PublishPort, encoded, &subject, port.origins())
4104 {
4105 self.exact(subject, port.origins());
4106 }
4107 }
4108
4109 fn encode_port(&mut self, subject: &str, port: &Sourced<Port>) -> Option<String> {
4110 let Some(protocol) = protocol_name(port.value().protocol()) else {
4111 self.unsupported(subject, "unknown port protocol", port.origins());
4112 return None;
4113 };
4114 let prefix = match port.value().host_address() {
4115 None => String::new(),
4116 Some(address) if is_ipv4_spelling(address) => format!("{address}:"),
4117 Some(_) => {
4118 self.unsupported(
4119 subject,
4120 "host address requires a Quadlet port form not yet encoded",
4121 port.origins(),
4122 );
4123 return None;
4124 }
4125 };
4126 let publication = match port.value().published() {
4127 Some(published) => format!("{prefix}{published}:{}", port.value().container()),
4128 None if prefix.is_empty() => port.value().container().to_string(),
4129 None => format!("{prefix}:{}", port.value().container()),
4130 };
4131 Some(format!("{publication}/{protocol}"))
4132 }
4133
4134 fn map_mount(
4135 &mut self,
4136 service_subject: &str,
4137 index: usize,
4138 mount: &Sourced<Mount>,
4139 builder: &mut QuadletDocumentBuilder,
4140 ) {
4141 let subject = format!("{service_subject}.mounts[{index}]");
4142 let safe_target = if mount.value().paths_are_literal() {
4143 !mount.value().target().chars().any(|ch| ch.is_control() || ch == ':')
4144 } else {
4145 is_safe_mount_part(mount.value().target())
4146 };
4147 if !safe_target || !mount.value().target().starts_with('/') {
4148 self.unsupported(
4149 &subject,
4150 "container mount target is not a safely encoded absolute path",
4151 mount.origins(),
4152 );
4153 return;
4154 }
4155 if let MountSource::DeferredHostPath(expression) = mount.value().source() {
4156 self.map_deferred_bind(&subject, expression, mount, builder);
4157 return;
4158 }
4159 if mount.value().paths_are_literal()
4160 && (mount.value().target().contains('$') || mount.value().target().contains('%'))
4161 && !matches!(mount.value().source(), MountSource::HostPath(_))
4162 {
4163 self.unsupported(
4164 &subject,
4165 "literal percent in a non-bind mount target needs a native expansion-safe renderer",
4166 mount.origins(),
4167 );
4168 return;
4169 }
4170 let Some(source) = self.mount_source(&subject, mount) else {
4171 return;
4172 };
4173 let inspected_literal = mount.value().paths_are_literal();
4174 let target_expansion = mount.value().paths_are_literal()
4175 && (mount.value().target().contains('$') || mount.value().target().contains('%'));
4176 if matches!(mount.value().source(), MountSource::HostPath(_))
4177 && (source.value.contains('$') || (source.value.contains('%') && inspected_literal) || target_expansion)
4178 {
4179 if source.value.contains('%') && !inspected_literal {
4180 self.unsupported(&subject, "native source specifier intent cannot be combined safely with a literal expansion-bearing target; use an explicit literal target path", mount.origins());
4181 return;
4182 }
4183 self.map_literal_bind(&subject, &source.value, mount, builder);
4184 return;
4185 }
4186 let mut encoded = if source.value.is_empty() {
4187 mount.value().target().to_owned()
4188 } else {
4189 format!("{}:{}", source.value, mount.value().target())
4190 };
4191 let mut options = Vec::new();
4192 if mount.value().read_only() {
4193 options.push("ro");
4194 }
4195 match mount.value().selinux_relabel() {
4196 Some(SelinuxRelabel::Shared) => options.push("z"),
4197 Some(SelinuxRelabel::Private) => options.push("Z"),
4198 None => {}
4199 Some(_) => {
4200 self.unsupported(&subject, "unknown SELinux relabel mode", mount.origins());
4201 return;
4202 }
4203 }
4204 if !options.is_empty() {
4205 encoded.push(':');
4206 encoded.push_str(&options.join(","));
4207 }
4208 if self.capability("quadlet.container.volume", &subject, mount.origins())
4209 && self.push_container(builder, ContainerKey::Volume, encoded, &subject, mount.origins())
4210 {
4211 self.record_reference_outcome(&subject, source.approximation, mount.origins());
4212 }
4213 }
4214
4215 fn mount_source(&mut self, subject: &str, mount: &Sourced<Mount>) -> Option<ResolvedResourceSource> {
4216 match mount.value().source() {
4217 MountSource::Volume(name) => self.volume_source(subject, name.as_str(), mount.origins()),
4218 MountSource::HostPath(path) => {
4219 if path.starts_with('/')
4220 && !path.chars().any(|ch| ch.is_control() || ch == ':')
4221 && mount.value().paths_are_literal()
4222 {
4223 return Some(ResolvedResourceSource {
4224 value: path.clone(),
4225 approximation: None,
4226 });
4227 }
4228 if let Some(mapped) = self.exporter.bind_source_mappings.get(path) {
4229 Some(ResolvedResourceSource {
4230 value: mapped.clone(),
4231 approximation: None,
4232 })
4233 } else {
4234 match classify_path(path) {
4235 PathForm::AbsoluteLiteral | PathForm::SystemdSpecifier if is_safe_mount_part(path) => {
4236 Some(ResolvedResourceSource {
4237 value: path.clone(),
4238 approximation: None,
4239 })
4240 }
4241 PathForm::UnitRelativeLiteral => {
4242 if let Some(root) = self.exporter.relative_bind_root.as_deref() {
4243 if let Some(resolved) = resolve_relative_path(root, path) {
4244 Some(ResolvedResourceSource {
4245 value: resolved,
4246 approximation: None,
4247 })
4248 } else {
4249 self.unsupported(
4250 subject,
4251 "relative bind source traverses above the filesystem root",
4252 mount.origins(),
4253 );
4254 None
4255 }
4256 } else {
4257 self.unsupported(
4258 subject,
4259 "relative bind source needs an explicit Compose project root",
4260 mount.origins(),
4261 );
4262 None
4263 }
4264 }
4265 PathForm::RelativeLiteral => {
4266 self.unsupported(
4267 subject,
4268 "relative bind form needs an explicit source-to-target mapping",
4269 mount.origins(),
4270 );
4271 None
4272 }
4273 _ => {
4274 self.unsupported(
4275 subject,
4276 "bind source needs an explicit source-to-target mapping for Quadlet",
4277 mount.origins(),
4278 );
4279 None
4280 }
4281 }
4282 }
4283 }
4284 MountSource::Anonymous => Some(ResolvedResourceSource {
4285 value: String::new(),
4286 approximation: None,
4287 }),
4288 _ => {
4289 self.unsupported(subject, "unknown mount source", mount.origins());
4290 None
4291 }
4292 }
4293 }
4294
4295 fn map_network_attachment(
4296 &mut self,
4297 service_subject: &str,
4298 network: &Sourced<NetworkAttachment>,
4299 builder: &mut QuadletDocumentBuilder,
4300 ) {
4301 let name = network.value().network().as_str();
4302 let subject = format!("{service_subject}.networks.{name}");
4303 let source = self.network_source(&subject, name, network.origins());
4304 if let Some(source) = source
4305 && self.capability("quadlet.container.network", &subject, network.origins())
4306 && self.push_container(
4307 builder,
4308 ContainerKey::Network,
4309 source.value,
4310 &subject,
4311 network.origins(),
4312 )
4313 {
4314 self.record_reference_outcome(&subject, source.approximation, network.origins());
4315 }
4316 }
4317
4318 fn map_pod_mount(
4319 &mut self,
4320 group_subject: &str,
4321 index: usize,
4322 mount: &Sourced<Mount>,
4323 builder: &mut QuadletDocumentBuilder,
4324 ) {
4325 let subject = format!("{group_subject}.runtime.mounts[{index}]");
4326 let literal_expansion = mount.value().target().contains('$')
4327 || mount.value().target().contains('%')
4328 || matches!(mount.value().source(), MountSource::HostPath(path) if path.contains('$') || path.contains('%'));
4329 if mount.value().paths_are_literal() && literal_expansion {
4330 self.unsupported(&subject, "literal dollar/percent pod paths need a native expansion-safe pod renderer; use explicit literal target mappings or separate container units", mount.origins());
4331 return;
4332 }
4333 let Some((encoded, approximation)) = self.encode_pod_mount(&subject, mount) else {
4334 return;
4335 };
4336 if self.capability("quadlet.pod.volume", &subject, mount.origins())
4337 && self.push_pod(builder, PodKey::Volume, encoded, &subject, mount.origins())
4338 {
4339 self.record_reference_outcome(&subject, approximation, mount.origins());
4340 }
4341 }
4342
4343 fn encode_pod_mount(&mut self, subject: &str, mount: &Sourced<Mount>) -> Option<(String, Option<&'static str>)> {
4344 if !is_safe_mount_part(mount.value().target()) || !mount.value().target().starts_with('/') {
4345 self.unsupported(
4346 subject,
4347 "pod mount target is not a safely encoded absolute path",
4348 mount.origins(),
4349 );
4350 return None;
4351 }
4352 let source = match mount.value().source() {
4353 MountSource::Volume(name) => self.volume_source(subject, name.as_str(), mount.origins()),
4354 MountSource::HostPath(path) => {
4355 if let Some(mapped) = self.exporter.bind_source_mappings.get(path) {
4356 Some(ResolvedResourceSource {
4357 value: mapped.clone(),
4358 approximation: None,
4359 })
4360 } else if matches!(
4361 classify_path(path),
4362 PathForm::AbsoluteLiteral | PathForm::SystemdSpecifier
4363 ) && is_safe_mount_part(path)
4364 {
4365 Some(ResolvedResourceSource {
4366 value: path.clone(),
4367 approximation: None,
4368 })
4369 } else {
4370 self.unsupported(
4371 subject,
4372 "pod bind source needs an explicit safe source-to-target mapping",
4373 mount.origins(),
4374 );
4375 None
4376 }
4377 }
4378 MountSource::Anonymous => Some(ResolvedResourceSource {
4379 value: String::new(),
4380 approximation: None,
4381 }),
4382 _ => {
4383 self.unsupported(subject, "unknown pod mount source", mount.origins());
4384 None
4385 }
4386 }?;
4387 let approximation = source.approximation;
4388 let mut encoded = if source.value.is_empty() {
4389 mount.value().target().to_owned()
4390 } else {
4391 format!("{}:{}", source.value, mount.value().target())
4392 };
4393 let mut options = Vec::new();
4394 if mount.value().read_only() {
4395 options.push("ro");
4396 }
4397 match mount.value().selinux_relabel() {
4398 Some(SelinuxRelabel::Shared) => options.push("z"),
4399 Some(SelinuxRelabel::Private) => options.push("Z"),
4400 None => {}
4401 Some(_) => {
4402 self.unsupported(subject, "unknown pod mount SELinux relabel mode", mount.origins());
4403 return None;
4404 }
4405 }
4406 if !options.is_empty() {
4407 encoded.push(':');
4408 encoded.push_str(&options.join(","));
4409 }
4410 Some((encoded, approximation))
4411 }
4412
4413 fn map_pod_network_attachment(
4414 &mut self,
4415 network: &Sourced<NetworkAttachment>,
4416 builder: &mut QuadletDocumentBuilder,
4417 ) {
4418 let name = network.value().network().as_str();
4419 let subject = format!("application.pod.networks.{name}");
4420 let source = self.network_source(&subject, name, network.origins());
4421 if let Some(source) = source
4422 && self.capability("quadlet.pod.network", &subject, network.origins())
4423 && self.push_pod(builder, PodKey::Network, source.value, &subject, network.origins())
4424 {
4425 self.record_reference_outcome(&subject, source.approximation, network.origins());
4426 }
4427 }
4428
4429 fn network_source(&mut self, subject: &str, name: &str, origins: &[Provenance]) -> Option<ResolvedResourceSource> {
4430 if name == "host" {
4431 return Some(ResolvedResourceSource {
4432 value: name.to_owned(),
4433 approximation: None,
4434 });
4435 }
4436 if !is_native_atom(name) {
4437 self.unsupported(subject, "network name requires target-specific escaping", origins);
4438 return None;
4439 }
4440 let declaration = self
4441 .application
4442 .networks()
4443 .iter()
4444 .find(|network| network.value().name().as_str() == name);
4445 match declaration.map(|network| network.value().ownership()) {
4446 Some(ResourceOwnership::Application) => Some(ResolvedResourceSource {
4447 value: format!("{name}.network"),
4448 approximation: None,
4449 }),
4450 Some(ResourceOwnership::External) => Some(ResolvedResourceSource {
4451 value: name.to_owned(),
4452 approximation: None,
4453 }),
4454 Some(ResourceOwnership::Uncertain) => Some(ResolvedResourceSource {
4455 value: name.to_owned(),
4456 approximation: Some(
4457 "network lifecycle ownership is uncertain; retained the known native name as an external reference",
4458 ),
4459 }),
4460 Some(ResourceOwnership::Implicit) | None => {
4461 self.unsupported(subject, "implicit network cannot yet be reproduced safely", origins);
4462 None
4463 }
4464 Some(_) => {
4465 self.unsupported(subject, "unknown network ownership", origins);
4466 None
4467 }
4468 }
4469 }
4470
4471 fn volume_source(&mut self, subject: &str, name: &str, origins: &[Provenance]) -> Option<ResolvedResourceSource> {
4472 if !is_native_atom(name) {
4473 self.unsupported(subject, "volume name requires target-specific escaping", origins);
4474 return None;
4475 }
4476 let declaration = self
4477 .application
4478 .volumes()
4479 .iter()
4480 .find(|volume| volume.value().name().as_str() == name);
4481 match declaration.map(|volume| volume.value().ownership()) {
4482 Some(ResourceOwnership::Application) => Some(ResolvedResourceSource {
4483 value: format!("{name}.volume"),
4484 approximation: None,
4485 }),
4486 Some(ResourceOwnership::External) => Some(ResolvedResourceSource {
4487 value: name.to_owned(),
4488 approximation: None,
4489 }),
4490 Some(ResourceOwnership::Uncertain) => Some(ResolvedResourceSource {
4491 value: name.to_owned(),
4492 approximation: Some(
4493 "volume lifecycle ownership is uncertain; retained the known native name as an external reference",
4494 ),
4495 }),
4496 Some(ResourceOwnership::Implicit) | None => {
4497 self.unsupported(subject, "implicit volume cannot yet be reproduced safely", origins);
4498 None
4499 }
4500 Some(_) => {
4501 self.unsupported(subject, "unknown volume ownership", origins);
4502 None
4503 }
4504 }
4505 }
4506
4507 fn record_reference_outcome(&mut self, subject: &str, approximation: Option<&str>, origins: &[Provenance]) {
4508 if let Some(reason) = approximation {
4509 self.approximate(subject, reason, origins);
4510 } else {
4511 self.exact(subject, origins);
4512 }
4513 }
4514
4515 fn unit_file_name(&mut self, subject: &str, stem: &str, extension: &str, origins: &[Provenance]) -> Option<String> {
4516 if is_native_atom(stem) {
4517 Some(format!("{stem}.{extension}"))
4518 } else {
4519 self.invalid(
4520 self.exporter.codes.invalid_value.clone(),
4521 subject,
4522 "neutral identifier cannot be used safely as a Quadlet unit filename",
4523 "use an ASCII alphanumeric, dot, underscore, or hyphen name beginning and ending with an alphanumeric character",
4524 origins,
4525 );
4526 None
4527 }
4528 }
4529
4530 fn capability(&mut self, capability: &str, subject: &str, origins: &[Provenance]) -> bool {
4531 let Some(target) = self.podman_target else {
4532 return false;
4533 };
4534 let evaluation = self.exporter.catalogue.evaluate(capability, target);
4535 match evaluation.classification() {
4536 SupportClassification::Native => true,
4537 SupportClassification::Deprecated => {
4538 self.diagnostics.push(
4539 Diagnostic::new(
4540 self.exporter.codes.capability_deprecated.clone(),
4541 Severity::Note,
4542 "required Quadlet capability is deprecated for part of the target range",
4543 )
4544 .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject)))
4545 .with_field(DiagnosticField::new("capability", DiagnosticValue::plain(capability))),
4546 );
4547 true
4548 }
4549 classification => {
4550 self.loss(
4551 self.exporter.codes.capability_unavailable.clone(),
4552 subject,
4553 ConversionKind::Unsupported,
4554 "required Quadlet capability does not cover the complete target range",
4555 &format!("{capability}: {classification:?}"),
4556 origins,
4557 );
4558 false
4559 }
4560 }
4561 }
4562
4563 fn pod_capability(
4566 &mut self,
4567 capability: &str,
4568 minimum: PodmanVersion,
4569 subject: &str,
4570 origins: &[Provenance],
4571 ) -> bool {
4572 let Some(target) = self.podman_target else {
4573 return false;
4574 };
4575 if self.exporter.catalogue.capability(capability).is_some()
4576 && matches!(
4577 self.exporter.catalogue.evaluate(capability, target).classification(),
4578 SupportClassification::Native | SupportClassification::Deprecated
4579 )
4580 {
4581 return self.capability(capability, subject, origins);
4582 }
4583 if target.minimum() >= minimum {
4584 return true;
4585 }
4586 self.loss(
4587 self.exporter.codes.capability_unavailable.clone(),
4588 subject,
4589 ConversionKind::Unsupported,
4590 "required Quadlet capability does not cover the complete target range",
4591 &format!("{capability}: reviewed minimum is {minimum}"),
4592 origins,
4593 );
4594 false
4595 }
4596
4597 fn push_container(
4598 &mut self,
4599 builder: &mut QuadletDocumentBuilder,
4600 key: ContainerKey,
4601 value: impl Into<String>,
4602 subject: &str,
4603 origins: &[Provenance],
4604 ) -> bool {
4605 match EntryValue::new(value).and_then(|value| builder.push_container(key, value)) {
4606 Ok(()) => true,
4607 Err(error) => {
4608 self.generation_error(subject, &error, origins);
4609 false
4610 }
4611 }
4612 }
4613
4614 fn push_image(
4615 &mut self,
4616 builder: &mut QuadletDocumentBuilder,
4617 key: ImageKey,
4618 value: impl Into<String>,
4619 subject: &str,
4620 origins: &[Provenance],
4621 ) -> bool {
4622 match EntryValue::new(value).and_then(|value| builder.push_image(key, value)) {
4623 Ok(()) => true,
4624 Err(error) => {
4625 self.generation_error(subject, &error, origins);
4626 false
4627 }
4628 }
4629 }
4630
4631 fn push_build(
4632 &mut self,
4633 builder: &mut QuadletDocumentBuilder,
4634 key: BuildKey,
4635 value: impl Into<String>,
4636 subject: &str,
4637 origins: &[Provenance],
4638 ) -> bool {
4639 match EntryValue::new(value).and_then(|value| builder.push_build(key, value)) {
4640 Ok(()) => true,
4641 Err(error) => {
4642 self.generation_error(subject, &error, origins);
4643 false
4644 }
4645 }
4646 }
4647
4648 fn emit_image(
4649 &mut self,
4650 builder: &mut QuadletDocumentBuilder,
4651 key: ImageKey,
4652 capability: &str,
4653 value: &str,
4654 subject: &str,
4655 origins: &[Provenance],
4656 ) {
4657 if self.capability(capability, subject, origins) && self.push_image(builder, key, value, subject, origins) {
4658 self.exact(subject, origins);
4659 }
4660 }
4661
4662 fn emit_image_values(
4663 &mut self,
4664 builder: &mut QuadletDocumentBuilder,
4665 key: ImageKey,
4666 capability: &str,
4667 values: &BuildSettingValues<ProtectedString>,
4668 subject: &str,
4669 ) {
4670 if values.values().is_empty() {
4671 self.unsupported(
4672 subject,
4673 "an explicitly empty repeated Image setting has no physical Quadlet entry to emit",
4674 &[],
4675 );
4676 return;
4677 }
4678 for (index, value) in values.values().iter().enumerate() {
4679 self.emit_image(
4680 builder,
4681 key,
4682 capability,
4683 value.value().expose(),
4684 &format!("{subject}[{index}]"),
4685 value.origins(),
4686 );
4687 }
4688 }
4689
4690 fn emit_build(
4691 &mut self,
4692 builder: &mut QuadletDocumentBuilder,
4693 key: BuildKey,
4694 capability: &str,
4695 value: &str,
4696 subject: &str,
4697 origins: &[Provenance],
4698 ) {
4699 if self.capability(capability, subject, origins) && self.push_build(builder, key, value, subject, origins) {
4700 self.exact(subject, origins);
4701 }
4702 }
4703
4704 fn emit_build_values(
4705 &mut self,
4706 builder: &mut QuadletDocumentBuilder,
4707 key: BuildKey,
4708 capability: &str,
4709 values: &BuildSettingValues<ProtectedString>,
4710 subject: &str,
4711 ) {
4712 if values.values().is_empty() {
4713 self.unsupported(
4714 subject,
4715 "an explicitly empty repeated Build setting has no physical Quadlet entry to emit",
4716 &[],
4717 );
4718 return;
4719 }
4720 for (index, value) in values.values().iter().enumerate() {
4721 self.emit_build(
4722 builder,
4723 key,
4724 capability,
4725 value.value().expose(),
4726 &format!("{subject}[{index}]"),
4727 value.origins(),
4728 );
4729 }
4730 }
4731
4732 fn emit_build_assignments(
4733 &mut self,
4734 builder: &mut QuadletDocumentBuilder,
4735 key: BuildKey,
4736 capability: &str,
4737 values: &BuildSettingValues<ImageArtifactAssignment>,
4738 subject: &str,
4739 ) {
4740 if values.values().is_empty() {
4741 self.unsupported(
4742 subject,
4743 "an explicitly empty repeated Build assignment setting has no physical Quadlet entry to emit",
4744 &[],
4745 );
4746 return;
4747 }
4748 for (index, assignment) in values.values().iter().enumerate() {
4749 let value = assignment.value().value().map_or_else(
4750 || assignment.value().name().expose().to_owned(),
4751 |value| format!("{}={}", assignment.value().name().expose(), value.expose()),
4752 );
4753 self.emit_build(
4754 builder,
4755 key,
4756 capability,
4757 &value,
4758 &format!("{subject}[{index}]"),
4759 assignment.origins(),
4760 );
4761 }
4762 }
4763
4764 fn push_systemd_unit(
4765 &mut self,
4766 builder: &mut QuadletDocumentBuilder,
4767 key: SystemdUnitKey,
4768 value: impl Into<String>,
4769 subject: &str,
4770 origins: &[Provenance],
4771 ) -> bool {
4772 match EntryValue::new(value).and_then(|value| builder.push_systemd_unit(key, value)) {
4773 Ok(()) => true,
4774 Err(error) => {
4775 self.generation_error(subject, &error, origins);
4776 false
4777 }
4778 }
4779 }
4780
4781 fn push_systemd(
4782 &mut self,
4783 builder: &mut QuadletDocumentBuilder,
4784 section: SystemdSection,
4785 key: &str,
4786 value: impl Into<String>,
4787 subject: &str,
4788 origins: &[Provenance],
4789 ) -> bool {
4790 match EntryValue::new(value).and_then(|value| builder.push_systemd(section, key, value)) {
4791 Ok(()) => true,
4792 Err(error) => {
4793 self.generation_error(subject, &error, origins);
4794 false
4795 }
4796 }
4797 }
4798
4799 fn push_pod(
4800 &mut self,
4801 builder: &mut QuadletDocumentBuilder,
4802 key: PodKey,
4803 value: impl Into<String>,
4804 subject: &str,
4805 origins: &[Provenance],
4806 ) -> bool {
4807 match EntryValue::new(value).and_then(|value| builder.push_pod(key, value)) {
4808 Ok(()) => true,
4809 Err(error) => {
4810 self.generation_error(subject, &error, origins);
4811 false
4812 }
4813 }
4814 }
4815
4816 fn push_network(
4817 &mut self,
4818 builder: &mut QuadletDocumentBuilder,
4819 key: NetworkKey,
4820 value: impl Into<String>,
4821 subject: &str,
4822 origins: &[Provenance],
4823 ) -> bool {
4824 match EntryValue::new(value).and_then(|value| builder.push_network(key, value)) {
4825 Ok(()) => true,
4826 Err(error) => {
4827 self.generation_error(subject, &error, origins);
4828 false
4829 }
4830 }
4831 }
4832
4833 fn emit_network(
4834 &mut self,
4835 builder: &mut QuadletDocumentBuilder,
4836 key: NetworkKey,
4837 capability: &str,
4838 value: &str,
4839 subject: &str,
4840 origins: &[Provenance],
4841 ) {
4842 if self.capability(capability, subject, origins) && self.push_network(builder, key, value, subject, origins) {
4843 self.exact(subject, origins);
4844 }
4845 }
4846
4847 fn push_volume(
4848 &mut self,
4849 builder: &mut QuadletDocumentBuilder,
4850 key: VolumeKey,
4851 value: impl Into<String>,
4852 subject: &str,
4853 origins: &[Provenance],
4854 ) -> bool {
4855 match EntryValue::new(value).and_then(|value| builder.push_volume(key, value)) {
4856 Ok(()) => true,
4857 Err(error) => {
4858 self.generation_error(subject, &error, origins);
4859 false
4860 }
4861 }
4862 }
4863
4864 fn finish_document(
4865 &mut self,
4866 file_name: String,
4867 builder: &QuadletDocumentBuilder,
4868 subject: &str,
4869 origins: &[Provenance],
4870 ) {
4871 self.staged
4872 .push((file_name, builder.clone(), subject.to_owned(), origins.to_vec()));
4873 }
4874
4875 fn build_document(
4876 &mut self,
4877 file_name: String,
4878 builder: &QuadletDocumentBuilder,
4879 subject: &str,
4880 origins: &[Provenance],
4881 ) {
4882 let source_id = SourceId::new(self.next_source_id);
4883 let Some(next) = self.next_source_id.checked_add(1) else {
4884 self.invalid(
4885 self.exporter.codes.generation.clone(),
4886 subject,
4887 "generated Quadlet document count exceeds the supported source identity space",
4888 "reduce the number of generated unit files",
4889 origins,
4890 );
4891 self.generation_failed = true;
4892 return;
4893 };
4894 self.next_source_id = next;
4895 match builder.build(source_id) {
4896 Ok(document) => {
4897 self.generated.push((file_name, document));
4898 self.exact(subject, origins);
4899 }
4900 Err(error) => self.generation_error(subject, &error, origins),
4901 }
4902 }
4903
4904 fn generation_error(&mut self, subject: &str, error: &RenderError, origins: &[Provenance]) {
4905 self.generation_failed = true;
4906 self.invalid(
4907 self.exporter.codes.generation.clone(),
4908 subject,
4909 "QuadletLens rejected generated native output",
4910 &error.to_string(),
4911 origins,
4912 );
4913 }
4914
4915 fn exact(&mut self, subject: impl Into<String>, origins: &[Provenance]) {
4916 let mut outcome = ConversionOutcome::exact(subject);
4917 for origin in origins {
4918 outcome = outcome.with_origin(origin.clone());
4919 }
4920 self.outcomes.push(outcome);
4921 }
4922
4923 fn unsupported(&mut self, subject: &str, reason: &str, origins: &[Provenance]) {
4924 self.loss(
4925 self.exporter.codes.unsupported.clone(),
4926 subject,
4927 ConversionKind::Unsupported,
4928 "neutral application intent is not represented by the current Quadlet subset",
4929 reason,
4930 origins,
4931 );
4932 }
4933
4934 fn approximate(&mut self, subject: &str, reason: &str, origins: &[Provenance]) {
4935 self.loss(
4936 self.exporter.codes.grouping_approximation.clone(),
4937 subject,
4938 ConversionKind::Approximate,
4939 "generated Quadlet topology intentionally approximates source service isolation",
4940 reason,
4941 origins,
4942 );
4943 }
4944
4945 fn restart_approximation(&mut self, subject: &str, reason: &str, origins: &[Provenance]) {
4946 self.loss(
4947 self.exporter.codes.restart.clone(),
4948 subject,
4949 ConversionKind::Approximate,
4950 "container restart behavior is approximated by the systemd service manager",
4951 reason,
4952 origins,
4953 );
4954 }
4955
4956 fn environment_file_approximation(&mut self, subject: &str, reason: &str, origins: &[Provenance]) {
4957 self.loss(
4958 self.exporter.codes.environment_file.clone(),
4959 subject,
4960 ConversionKind::Approximate,
4961 "environment-file parsing is delegated to Podman",
4962 reason,
4963 origins,
4964 );
4965 }
4966
4967 fn invalid(&mut self, code: DiagnosticCode, subject: &str, summary: &str, reason: &str, origins: &[Provenance]) {
4968 self.loss(code, subject, ConversionKind::Invalid, summary, reason, origins);
4969 }
4970
4971 fn loss(
4972 &mut self,
4973 code: DiagnosticCode,
4974 subject: &str,
4975 kind: ConversionKind,
4976 summary: &str,
4977 reason: &str,
4978 origins: &[Provenance],
4979 ) {
4980 let severity = if kind == ConversionKind::Invalid {
4981 Severity::Error
4982 } else {
4983 Severity::Warning
4984 };
4985 self.diagnostics.push(
4986 Diagnostic::new(code.clone(), severity, summary)
4987 .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject)))
4988 .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(reason))),
4989 );
4990 if let Ok(mut outcome) = ConversionOutcome::loss(subject, kind, code) {
4991 for origin in origins {
4992 outcome = outcome.with_origin(origin.clone());
4993 }
4994 self.outcomes.push(outcome);
4995 }
4996 }
4997
4998 fn finish(mut self) -> (Option<QuadletOutput>, Vec<ConversionOutcome>, Vec<Diagnostic>) {
4999 self.finish_staged_documents();
5000 let candidate = if self.podman_target.is_some() && !self.generation_failed {
5001 let generated = std::mem::take(&mut self.generated);
5002 match QuadletOutput::from_generated(generated) {
5003 Ok(output) if output.document_set().is_valid() => Some(output),
5004 Ok(_) => {
5005 self.invalid(
5006 self.exporter.codes.generation.clone(),
5007 "application",
5008 "generated Quadlet document set has unresolved or ambiguous references",
5009 "inspect native document-set diagnostics",
5010 &[],
5011 );
5012 None
5013 }
5014 Err(error) => {
5015 self.invalid(
5016 self.exporter.codes.generation.clone(),
5017 "application",
5018 "generated Quadlet document metadata is invalid",
5019 &error.to_string(),
5020 &[],
5021 );
5022 None
5023 }
5024 }
5025 } else {
5026 None
5027 };
5028 (candidate, self.outcomes, self.diagnostics)
5029 }
5030}
5031
5032fn podman_version(version: boxferry_engine::PlatformVersion) -> PodmanVersion {
5033 PodmanVersion::new(version.major(), version.minor(), version.patch())
5034}
5035
5036fn protocol_name(protocol: &Protocol) -> Option<&'static str> {
5037 match protocol {
5038 Protocol::Tcp => Some("tcp"),
5039 Protocol::Udp => Some("udp"),
5040 Protocol::Sctp => Some("sctp"),
5041 _ => None,
5042 }
5043}
5044
5045fn same_network_attachments(left: &[Sourced<NetworkAttachment>], right: &[Sourced<NetworkAttachment>]) -> bool {
5046 left.len() == right.len()
5047 && left
5048 .iter()
5049 .zip(right)
5050 .all(|(left, right)| left.value() == right.value())
5051}
5052
5053fn service_has_renderable_healthcheck(service: &Service) -> bool {
5054 let Some(healthcheck) = service.healthcheck() else {
5055 return false;
5056 };
5057 if healthcheck.value().disabled().is_some_and(|disabled| *disabled.value()) {
5058 return false;
5059 }
5060 match healthcheck.value().command().map(Sourced::value) {
5061 Some(HealthcheckCommand::Exec(arguments)) => {
5062 !arguments.is_empty()
5063 && arguments
5064 .iter()
5065 .all(|argument| !argument.expose().contains(['\0', '%']))
5066 }
5067 Some(HealthcheckCommand::Shell(value)) => !value.expose().is_empty() && !value.expose().contains(['\0', '%']),
5068 _ => false,
5069 }
5070}
5071
5072fn dependency_mapping_origins(dependency: &Sourced<ServiceDependency>) -> Vec<Provenance> {
5073 let mut origins = dependency.origins().to_vec();
5074 if let Some(condition) = dependency.value().condition() {
5075 for origin in condition.origins() {
5076 if !origins.contains(origin) {
5077 origins.push(origin.clone());
5078 }
5079 }
5080 }
5081 if let Some(required) = dependency.value().required() {
5082 for origin in required.origins() {
5083 if !origins.contains(origin) {
5084 origins.push(origin.clone());
5085 }
5086 }
5087 }
5088 origins
5089}
5090
5091fn collection_item_origins<T>(collection_origins: &[Provenance], item: &Sourced<T>) -> Vec<Provenance> {
5092 let mut origins = collection_origins.to_vec();
5093 extend_origins(&mut origins, item.origins());
5094 origins
5095}
5096
5097fn network_option_origins(collection_origins: &[Provenance], option: &Sourced<NetworkDriverOption>) -> Vec<Provenance> {
5098 let mut origins = collection_item_origins(collection_origins, option);
5099 extend_origins(&mut origins, option.value().name().origins());
5100 extend_origins(&mut origins, option.value().value().origins());
5101 origins
5102}
5103
5104fn network_label_origins(collection_origins: &[Provenance], label: &Sourced<MetadataLabel>) -> Vec<Provenance> {
5105 collection_item_origins(collection_origins, label)
5106}
5107
5108fn network_ipam_origins(
5109 collection_origins: &[Provenance],
5110 row: &Sourced<boxferry_model::NetworkIpamConfig>,
5111) -> Vec<Provenance> {
5112 let mut origins = collection_item_origins(collection_origins, row);
5113 extend_origins(&mut origins, row.value().subnet().origins());
5114 if let Some(gateway) = row.value().gateway() {
5115 extend_origins(&mut origins, gateway.origins());
5116 }
5117 if let Some(range) = row.value().ip_range() {
5118 extend_origins(&mut origins, range.origins());
5119 }
5120 origins
5121}
5122
5123fn collection_all_origins<T>(values: &[Sourced<T>], collection_origins: &[Provenance]) -> Vec<Provenance> {
5124 let mut origins = collection_origins.to_vec();
5125 for value in values {
5126 for origin in value.origins() {
5127 if !origins.contains(origin) {
5128 origins.push(origin.clone());
5129 }
5130 }
5131 }
5132 origins
5133}
5134
5135fn resource_limit_origins(
5136 collection_origins: &[Provenance],
5137 limit: &Sourced<boxferry_model::ResourceLimit>,
5138) -> Vec<Provenance> {
5139 let mut origins = collection_item_origins(collection_origins, limit);
5140 for value in [limit.value().soft(), limit.value().hard()].into_iter().flatten() {
5141 extend_origins(&mut origins, value.origins());
5142 }
5143 origins
5144}
5145
5146fn device_origins(collection_origins: &[Provenance], device: &Sourced<Device>) -> Vec<Provenance> {
5147 let mut origins = collection_item_origins(collection_origins, device);
5148 if let Device::Long {
5149 source,
5150 target,
5151 permissions,
5152 } = device.value()
5153 {
5154 for value in [source.as_ref(), target.as_ref(), permissions.as_ref()]
5155 .into_iter()
5156 .flatten()
5157 {
5158 extend_origins(&mut origins, value.origins());
5159 }
5160 }
5161 origins
5162}
5163
5164fn extend_origins(origins: &mut Vec<Provenance>, additional: &[Provenance]) {
5165 for origin in additional {
5166 if !origins.contains(origin) {
5167 origins.push(origin.clone());
5168 }
5169 }
5170}
5171
5172fn same_host_mappings(left: &[Sourced<HostMapping>], right: &[Sourced<HostMapping>]) -> bool {
5173 left.len() == right.len()
5174 && left
5175 .iter()
5176 .zip(right)
5177 .all(|(left, right)| left.value() == right.value())
5178}
5179
5180fn shared_host_mapping_origins(services: &[Sourced<Service>], index: usize) -> Vec<Provenance> {
5181 let mut origins = Vec::new();
5182 for origin in services
5183 .iter()
5184 .filter_map(|service| service.value().host_mappings().get(index))
5185 .flat_map(Sourced::origins)
5186 {
5187 if !origins.contains(origin) {
5188 origins.push(origin.clone());
5189 }
5190 }
5191 origins
5192}
5193
5194fn shared_user_namespace_origins(services: &[Sourced<Service>]) -> Vec<Provenance> {
5195 services
5196 .iter()
5197 .filter_map(|service| service.value().user_namespace())
5198 .flat_map(Sourced::origins)
5199 .cloned()
5200 .collect()
5201}
5202
5203fn service_origins(services: &[Sourced<Service>]) -> Vec<Provenance> {
5204 services
5205 .iter()
5206 .flat_map(|service| service.origins().iter().cloned())
5207 .collect()
5208}
5209
5210fn service_group_origins(
5211 group: &Sourced<boxferry_model::ServiceGroup>,
5212 services: &[Sourced<Service>],
5213) -> Vec<Provenance> {
5214 let mut origins = group.origins().to_vec();
5215 for origin in group
5216 .value()
5217 .members()
5218 .iter()
5219 .flat_map(Sourced::origins)
5220 .chain(services.iter().flat_map(Sourced::origins))
5221 {
5222 if !origins.contains(origin) {
5223 origins.push(origin.clone());
5224 }
5225 }
5226 origins
5227}
5228
5229fn secret_grant_origins(grant: &Sourced<ResourceGrant>, secret: &Sourced<Secret>) -> Vec<Provenance> {
5230 let mut origins = grant.origins().to_vec();
5231 for sourced in [
5232 grant.value().target(),
5233 grant.value().uid(),
5234 grant.value().gid(),
5235 grant.value().mode(),
5236 secret.value().runtime_name(),
5237 ]
5238 .into_iter()
5239 .flatten()
5240 {
5241 for origin in sourced.origins() {
5242 if !origins.contains(origin) {
5243 origins.push(origin.clone());
5244 }
5245 }
5246 }
5247 for origin in secret.origins() {
5248 if !origins.contains(origin) {
5249 origins.push(origin.clone());
5250 }
5251 }
5252 origins
5253}
5254
5255fn environment_file_origins(environment_file: &Sourced<EnvironmentFile>) -> Vec<Provenance> {
5256 let mut origins = environment_file.origins().to_vec();
5257 for sourced in [
5258 environment_file.value().required().map(Sourced::origins),
5259 environment_file.value().format().map(Sourced::origins),
5260 ]
5261 .into_iter()
5262 .flatten()
5263 .flatten()
5264 {
5265 if !origins.contains(sourced) {
5266 origins.push(sourced.clone());
5267 }
5268 }
5269 origins
5270}
5271
5272fn is_native_atom(value: &str) -> bool {
5273 value.as_bytes().first().is_some_and(u8::is_ascii_alphanumeric)
5274 && value.as_bytes().last().is_some_and(u8::is_ascii_alphanumeric)
5275 && value
5276 .bytes()
5277 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
5278}
5279
5280fn encode_quadlet_environment_assignment(name: &str, value: &str) -> Option<String> {
5281 if name.is_empty()
5282 || name.contains('=')
5283 || name
5284 .chars()
5285 .any(|character| character == '\0' || character.is_control())
5286 {
5287 return None;
5288 }
5289 let assignment = format!("{name}={value}");
5290 encode_quadlet_word(&assignment).or_else(|| encode_quadlet_environment_control_word(&assignment))
5291}
5292
5293fn encode_quadlet_environment_control_word(value: &str) -> Option<String> {
5294 if value.contains('\0') {
5295 return None;
5296 }
5297
5298 let mut escaped = String::with_capacity(value.len() + 2);
5299 escaped.push('"');
5300 for character in value.chars() {
5301 match character {
5302 '"' => escaped.push_str("\\\""),
5303 '\\' => escaped.push_str("\\\\"),
5304 '\n' => escaped.push_str("\\n"),
5305 '\r' => escaped.push_str("\\r"),
5306 '\t' => escaped.push_str("\\t"),
5307 character if character.is_ascii_control() => {
5308 let _ = write!(escaped, "\\x{:02x}", u32::from(character));
5309 }
5310 character if character.is_control() && u16::try_from(u32::from(character)).is_ok() => {
5311 let _ = write!(escaped, "\\u{:04x}", u32::from(character));
5312 }
5313 character if character.is_control() => {
5314 let _ = write!(escaped, "\\U{:08x}", u32::from(character));
5315 }
5316 character => escaped.push(character),
5317 }
5318 }
5319 escaped.push('"');
5320 Some(escaped)
5321}
5322
5323fn contains_unresolved_source_variable(value: &str, origins: &[Provenance]) -> bool {
5324 value.contains('$')
5325 && origins
5326 .iter()
5327 .any(|origin| origin.kind() == ProvenanceKind::SourceDocument)
5328 && !origins.iter().any(|origin| {
5329 matches!(
5330 origin.kind(),
5331 ProvenanceKind::RuntimeObservation | ProvenanceKind::ConversionDecision
5332 )
5333 })
5334}
5335
5336fn encode_quadlet_word(value: &str) -> Option<String> {
5337 if value
5338 .chars()
5339 .any(|character| character == '\0' || matches!(character, '\r' | '\n') || character.is_control())
5340 {
5341 return None;
5342 }
5343 if is_safe_word(value, false) {
5344 return Some(value.to_owned());
5345 }
5346 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
5347 Some(format!("\"{escaped}\""))
5348}
5349
5350fn encode_json_array(values: &[&str]) -> String {
5351 let mut encoded = String::from("[");
5352 for (index, value) in values.iter().enumerate() {
5353 if index > 0 {
5354 encoded.push(',');
5355 }
5356 encoded.push('"');
5357 for character in value.chars() {
5358 match character {
5359 '"' => encoded.push_str("\\\""),
5360 '\\' => encoded.push_str("\\\\"),
5361 '\u{08}' => encoded.push_str("\\b"),
5362 '\u{0c}' => encoded.push_str("\\f"),
5363 '\n' => encoded.push_str("\\n"),
5364 '\r' => encoded.push_str("\\r"),
5365 '\t' => encoded.push_str("\\t"),
5366 character if character <= '\u{1f}' => {
5367 const HEX: &[u8; 16] = b"0123456789abcdef";
5368 let value = usize::try_from(u32::from(character)).unwrap_or_default();
5369 encoded.push_str("\\u00");
5370 encoded.push(char::from(HEX[(value >> 4) & 0x0f]));
5371 encoded.push(char::from(HEX[value & 0x0f]));
5372 }
5373 character => encoded.push(character),
5374 }
5375 }
5376 encoded.push('"');
5377 }
5378 encoded.push(']');
5379 encoded
5380}
5381
5382fn is_safe_health_scalar(value: &str) -> bool {
5383 !value.is_empty()
5384 && value.trim() == value
5385 && !value.bytes().any(|byte| matches!(byte, b'\0' | b'\n' | b'\r' | b'%'))
5386}
5387
5388fn is_podman_health_duration(mut value: &str) -> bool {
5389 if value == "0" {
5390 return true;
5391 }
5392 let mut found = false;
5393 while !value.is_empty() {
5394 let number_end = value
5395 .char_indices()
5396 .take_while(|(_, character)| character.is_ascii_digit() || *character == '.')
5397 .map(|(index, character)| index + character.len_utf8())
5398 .last()
5399 .unwrap_or(0);
5400 if number_end == 0 {
5401 return false;
5402 }
5403 let number = &value[..number_end];
5404 if number.matches('.').count() > 1 || number == "." {
5405 return false;
5406 }
5407 value = &value[number_end..];
5408 let Some(unit) = ["ns", "us", "µs", "μs", "ms", "s", "m", "h"]
5409 .into_iter()
5410 .find(|unit| value.starts_with(unit))
5411 else {
5412 return false;
5413 };
5414 value = &value[unit.len()..];
5415 found = true;
5416 }
5417 found
5418}
5419
5420fn is_safe_word(value: &str, allow_empty: bool) -> bool {
5421 (allow_empty || !value.is_empty())
5422 && value.bytes().all(|byte| {
5423 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/' | b':' | b'@' | b'+' | b'=' | b',')
5424 })
5425}
5426
5427fn is_safe_network_scalar(value: &str, allow_empty: bool) -> bool {
5428 (allow_empty || !value.is_empty())
5429 && value.trim() == value
5430 && !value.bytes().any(|byte| {
5431 matches!(byte, b'\0' | b'\n' | b'\r' | b'%' | b'\'' | b'"' | b'\\') || byte.is_ascii_whitespace()
5432 })
5433}
5434
5435fn is_safe_network_assignment(name: &str, value: &str) -> bool {
5436 !name.is_empty()
5437 && !name.contains('=')
5438 && is_safe_network_scalar(name, false)
5439 && is_safe_network_scalar(value, true)
5440}
5441
5442fn is_label_name(value: &str) -> bool {
5443 value.as_bytes().first().is_some_and(u8::is_ascii_alphanumeric)
5444 && value.as_bytes().last().is_some_and(u8::is_ascii_alphanumeric)
5445 && value
5446 .bytes()
5447 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/'))
5448}
5449
5450fn is_positive_memory(value: &str) -> bool {
5451 let split = value
5452 .find(|character: char| !character.is_ascii_digit())
5453 .unwrap_or(value.len());
5454 let (amount, suffix) = value.split_at(split);
5455 !amount.is_empty()
5456 && !amount.starts_with('0')
5457 && amount.parse::<u64>().is_ok_and(|amount| amount > 0)
5458 && matches!(suffix, "" | "b" | "k" | "m" | "g")
5459}
5460
5461fn is_safe_security_option_value(value: &str) -> bool {
5462 !value.is_empty()
5463 && value.bytes().all(|byte| {
5464 byte.is_ascii_alphanumeric()
5465 || matches!(
5466 byte,
5467 b'_' | b'-' | b'.' | b'/' | b':' | b'@' | b'+' | b'=' | b',' | b'*'
5468 )
5469 })
5470}
5471
5472fn security_option_singleton_name(option: &SecurityOption) -> Option<&'static str> {
5473 match option {
5474 SecurityOption::AppArmor(_) => Some("AppArmor"),
5475 SecurityOption::NoNewPrivileges(_) => Some("NoNewPrivileges"),
5476 SecurityOption::SeccompProfile(_) => Some("SeccompProfile"),
5477 SecurityOption::SecurityLabelDisable(_) => Some("SecurityLabelDisable"),
5478 SecurityOption::SecurityLabelFileType(_) => Some("SecurityLabelFileType"),
5479 SecurityOption::SecurityLabelLevel(_) => Some("SecurityLabelLevel"),
5480 SecurityOption::SecurityLabelNested(_) => Some("SecurityLabelNested"),
5481 SecurityOption::SecurityLabelType(_) => Some("SecurityLabelType"),
5482 _ => None,
5483 }
5484}
5485
5486fn security_option_is_selinux_label(option: &SecurityOption) -> bool {
5487 matches!(
5488 option,
5489 SecurityOption::SecurityLabelFileType(_)
5490 | SecurityOption::SecurityLabelLevel(_)
5491 | SecurityOption::SecurityLabelNested(_)
5492 | SecurityOption::SecurityLabelType(_)
5493 )
5494}
5495
5496fn security_option_output(option: &SecurityOption) -> Option<(&'static str, ContainerKey, &str)> {
5497 match option {
5498 SecurityOption::AppArmor(value) => Some(("quadlet.container.apparmor", ContainerKey::AppArmor, value.expose())),
5499 SecurityOption::NoNewPrivileges(value) => Some((
5500 "quadlet.container.no-new-privileges",
5501 ContainerKey::NoNewPrivileges,
5502 if *value { "true" } else { "false" },
5503 )),
5504 SecurityOption::SeccompProfile(value) => Some((
5505 "quadlet.container.seccomp-profile",
5506 ContainerKey::SeccompProfile,
5507 value.expose(),
5508 )),
5509 SecurityOption::SecurityLabelDisable(value) => Some((
5510 "quadlet.container.security-label-disable",
5511 ContainerKey::SecurityLabelDisable,
5512 if *value { "true" } else { "false" },
5513 )),
5514 SecurityOption::SecurityLabelFileType(value) => Some((
5515 "quadlet.container.security-label-file-type",
5516 ContainerKey::SecurityLabelFileType,
5517 value.expose(),
5518 )),
5519 SecurityOption::SecurityLabelLevel(value) => Some((
5520 "quadlet.container.security-label-level",
5521 ContainerKey::SecurityLabelLevel,
5522 value.expose(),
5523 )),
5524 SecurityOption::SecurityLabelNested(value) => Some((
5525 "quadlet.container.security-label-nested",
5526 ContainerKey::SecurityLabelNested,
5527 if *value { "true" } else { "false" },
5528 )),
5529 SecurityOption::SecurityLabelType(value) => Some((
5530 "quadlet.container.security-label-type",
5531 ContainerKey::SecurityLabelType,
5532 value.expose(),
5533 )),
5534 SecurityOption::Mask(value) => Some(("quadlet.container.mask", ContainerKey::Mask, value.expose())),
5535 SecurityOption::Unmask(value) => Some(("quadlet.container.unmask", ContainerKey::Unmask, value.expose())),
5536 _ => None,
5537 }
5538}
5539
5540fn is_safe_hostname(value: &str) -> bool {
5541 !value.is_empty()
5542 && value.len() <= 253
5543 && value.is_ascii()
5544 && value.split('.').all(|label| {
5545 !label.is_empty()
5546 && label.len() <= 63
5547 && label.bytes().next().is_some_and(|byte| byte.is_ascii_alphanumeric())
5548 && label.bytes().last().is_some_and(|byte| byte.is_ascii_alphanumeric())
5549 && label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
5550 })
5551}
5552
5553fn is_safe_tmpfs(value: &str) -> bool {
5554 let (target, options) = value
5555 .split_once(':')
5556 .map_or((value, None), |(target, options)| (target, Some(options)));
5557 is_safe_absolute_container_path(target)
5558 && options.is_none_or(|options| {
5559 !options.is_empty()
5560 && options
5561 .split(',')
5562 .all(|option| is_safe_word(option, false) && !option.contains([':', '%']))
5563 })
5564}
5565
5566fn is_safe_sysctl(name: &str, value: &str) -> bool {
5567 !name.is_empty() && !name.contains('=') && is_safe_word(name, false) && is_safe_word(value, true)
5568}
5569
5570fn is_safe_limit_value(value: &str) -> bool {
5571 value == "-1" || value == "0" || (!value.starts_with('0') && value.parse::<u64>().is_ok_and(|value| value > 0))
5572}
5573
5574fn is_safe_ulimit(name: &str, soft: &str, hard: &str) -> bool {
5575 !name.is_empty()
5576 && name.bytes().all(|byte| byte.is_ascii_lowercase())
5577 && is_safe_limit_value(soft)
5578 && is_safe_limit_value(hard)
5579}
5580
5581fn is_safe_stop_signal(value: &str) -> bool {
5582 value
5583 .parse::<u8>()
5584 .is_ok_and(|number| (1..=64).contains(&number) && (value == "0" || !value.starts_with('0')))
5585 || value.strip_prefix("SIG").is_some_and(|name| {
5586 name.as_bytes().first().is_some_and(u8::is_ascii_uppercase)
5587 && name
5588 .bytes()
5589 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
5590 })
5591}
5592
5593fn reviewed_device_value(device: &Device) -> Option<String> {
5594 match device {
5595 Device::Short(value) => reviewed_device_spelling(value.expose()),
5596 Device::Long {
5597 source,
5598 target,
5599 permissions,
5600 } => {
5601 let source = source.as_ref()?.value().expose();
5602 let target = target.as_ref().map(|value| value.value().expose());
5603 let permissions = permissions.as_ref().map(|value| value.value().expose());
5604 if !is_reviewed_device_component(source)
5605 || target.is_some_and(|target| !is_reviewed_device_component(target))
5606 || permissions.is_some_and(|permissions| !is_reviewed_device_permissions(permissions))
5607 {
5608 return None;
5609 }
5610 let mut rendered = source.to_owned();
5611 if let Some(target) = target {
5612 rendered.push(':');
5613 rendered.push_str(target);
5614 }
5615 if let Some(permissions) = permissions {
5616 target?;
5617 rendered.push(':');
5618 rendered.push_str(permissions);
5619 }
5620 Some(rendered)
5621 }
5622 _ => None,
5623 }
5624}
5625
5626fn reviewed_device_spelling(value: &str) -> Option<String> {
5627 let mut parts = value.split(':');
5628 let source = parts.next()?;
5629 let target = parts.next();
5630 let permissions = parts.next();
5631 if parts.next().is_some()
5632 || !is_reviewed_device_component(source)
5633 || target.is_some_and(|target| !is_reviewed_device_component(target))
5634 || permissions.is_some_and(|permissions| !is_reviewed_device_permissions(permissions))
5635 || permissions.is_some() && target.is_none()
5636 {
5637 return None;
5638 }
5639 Some(value.to_owned())
5640}
5641
5642fn is_reviewed_device_component(value: &str) -> bool {
5643 value.starts_with("/dev/")
5644 && value.len() > 5
5645 && value.is_ascii()
5646 && !value.contains(['$', '%', ':', '\0', '\r', '\n'])
5647 && !value.chars().any(char::is_whitespace)
5648 && value.split('/').all(|segment| !matches!(segment, "." | ".."))
5649}
5650
5651fn is_reviewed_device_permissions(value: &str) -> bool {
5652 !value.is_empty()
5653 && value.bytes().all(|byte| matches!(byte, b'r' | b'w' | b'm'))
5654 && value.bytes().collect::<BTreeSet<_>>().len() == value.len()
5655}
5656
5657fn is_safe_capability(value: &str) -> bool {
5658 let name = value.strip_prefix("CAP_").unwrap_or(value);
5659 name.as_bytes().first().is_some_and(u8::is_ascii_uppercase)
5660 && name
5661 .bytes()
5662 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
5663}
5664
5665fn is_safe_absolute_container_path(value: &str) -> bool {
5666 value.starts_with('/')
5667 && !value.contains('%')
5668 && value.split('/').all(|part| part != "..")
5669 && value
5670 .bytes()
5671 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/' | b'@' | b'+'))
5672}
5673
5674fn is_canonical_nonnegative_seconds(value: &str) -> bool {
5675 value == "0" || (!value.is_empty() && !value.starts_with('0') && value.bytes().all(|byte| byte.is_ascii_digit()))
5676}
5677
5678fn valid_podman_container_name(value: &str) -> bool {
5679 let mut bytes = value.bytes();
5680 bytes.next().is_some_and(|byte| byte.is_ascii_alphanumeric())
5681 && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
5682}
5683
5684fn encode_quadlet_label(name: &str, value: &str) -> Option<String> {
5685 if name.contains('\0') || value.contains('\0') {
5686 return None;
5687 }
5688 let mut encoded = String::with_capacity(name.len() + value.len() + 3);
5689 encoded.push('"');
5690 for character in name.chars().chain(std::iter::once('=')).chain(value.chars()) {
5691 match character {
5692 '"' => encoded.push_str("\\\""),
5693 '\\' => encoded.push_str("\\\\"),
5694 '%' => encoded.push_str("%%"),
5695 '\n' => encoded.push_str("\\n"),
5696 '\r' => encoded.push_str("\\r"),
5697 '\t' => encoded.push_str("\\t"),
5698 character if character.is_control() => {
5699 write!(&mut encoded, "\\u{:04x}", u32::from(character)).ok()?;
5700 }
5701 character => encoded.push(character),
5702 }
5703 }
5704 encoded.push('"');
5705 Some(encoded)
5706}
5707
5708fn is_compose_managed_label(name: &str) -> bool {
5709 name.starts_with("com.docker.compose.")
5710}
5711
5712fn is_safe_secret_component(value: &str) -> bool {
5713 is_safe_word(value, false) && !value.contains([',', '='])
5714}
5715
5716fn podman_secret_options(grant: &ResourceGrant, runtime_name: &str, source: &str) -> Result<Vec<String>, &'static str> {
5717 let mut options = Vec::new();
5718 match grant.target() {
5719 Some(target) if is_safe_secret_component(target.value().expose()) => {
5720 options.push(format!("target={}", target.value().expose()));
5721 }
5722 Some(_) => return Err("secret target cannot be encoded in the reviewed Quadlet Secret grammar"),
5723 None if runtime_name != source => options.push(format!("target={source}")),
5724 None => {}
5725 }
5726 for (name, value) in [("uid", grant.uid()), ("gid", grant.gid())] {
5727 if let Some(value) = value {
5728 if value.value().expose().is_empty() || !value.value().expose().bytes().all(|byte| byte.is_ascii_digit()) {
5729 return Err("secret UID and GID options require non-negative decimal integers");
5730 }
5731 options.push(format!("{name}={}", value.value().expose()));
5732 }
5733 }
5734 if let Some(mode) = grant.mode() {
5735 let Some(mode) = normalize_secret_mode(mode.value().expose()) else {
5736 return Err("secret mode must be a one-to-four-digit octal value without writable bits");
5737 };
5738 options.push(format!("mode={mode}"));
5739 }
5740 Ok(options)
5741}
5742
5743fn normalize_secret_mode(value: &str) -> Option<String> {
5744 let digits = value.strip_prefix("0o").unwrap_or(value);
5745 if digits.is_empty() || digits.len() > 4 || !digits.bytes().all(|byte| matches!(byte, b'0'..=b'7')) {
5746 return None;
5747 }
5748 let mode = u16::from_str_radix(digits, 8).ok()?;
5749 if mode > 0o777 || mode & 0o222 != 0 {
5750 return None;
5751 }
5752 Some(format!("{mode:04o}"))
5753}
5754
5755fn is_safe_mount_part(value: &str) -> bool {
5756 !value.is_empty()
5757 && value.bytes().all(|byte| {
5758 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/' | b'@' | b'+' | b'=' | b'%')
5759 })
5760}
5761
5762fn is_valid_mapping_source(value: &str) -> bool {
5763 !value.is_empty() && !value.bytes().any(|byte| matches!(byte, b'\0' | b'\n' | b'\r'))
5764}
5765
5766fn is_valid_quadlet_bind_source(value: &str) -> bool {
5767 matches!(
5768 classify_path(value),
5769 PathForm::AbsoluteLiteral | PathForm::SystemdSpecifier
5770 ) && is_safe_mount_part(value)
5771}
5772
5773fn is_ipv4_spelling(value: &str) -> bool {
5774 !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit() || byte == b'.')
5775}
5776
5777fn normalize_absolute_path(value: &str) -> Option<String> {
5778 if classify_path(value) != PathForm::AbsoluteLiteral || !is_safe_mount_part(value) {
5779 return None;
5780 }
5781 let mut components = Vec::new();
5782 for component in value.split('/') {
5783 match component {
5784 "" | "." => {}
5785 ".." => {
5786 components.pop()?;
5787 }
5788 component => components.push(component.to_owned()),
5789 }
5790 }
5791 Some(format!("/{}", components.join("/")))
5792}
5793
5794const fn bool_text(value: bool) -> &'static str {
5795 if value { "true" } else { "false" }
5796}
5797
5798fn build_has_tag(build: &ImageBuild, image: &str) -> bool {
5799 build
5800 .settings()
5801 .unwrap_or_default()
5802 .iter()
5803 .any(|setting| match setting.value() {
5804 ImageBuildSetting::ImageTags(tags) => tags
5805 .values()
5806 .iter()
5807 .any(|tag| !tag.value().expose().is_empty() && tag.value().expose() == image),
5808 _ => false,
5809 })
5810}
5811
5812fn build_has_setting(build: &ImageBuild, matches: impl Fn(&ImageBuildSetting) -> bool) -> bool {
5813 build
5814 .settings()
5815 .unwrap_or_default()
5816 .iter()
5817 .any(|setting| matches(setting.value()))
5818}
5819
5820fn resolve_relative_path(root: &str, relative: &str) -> Option<String> {
5821 let mut components = root
5822 .trim_start_matches('/')
5823 .split('/')
5824 .filter(|component| !component.is_empty())
5825 .map(str::to_owned)
5826 .collect::<Vec<_>>();
5827 for component in relative.split('/') {
5828 match component {
5829 "" | "." => {}
5830 ".." => {
5831 components.pop()?;
5832 }
5833 component if component != "~" => components.push(component.to_owned()),
5834 _ => return None,
5835 }
5836 }
5837 Some(format!("/{}", components.join("/")))
5838}