Skip to main content

boxferry_quadlet/
export.rs

1//! Neutral-model-to-Quadlet planning.
2
3mod 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/// Caller-selected relationship between source services and generated Quadlet units.
36///
37/// Compose services normally have separate network namespaces, so separate `.container` units
38/// are the lossless default. A shared Podman pod changes that topology and is therefore always
39/// reported as an approximation, even when the adapter proves that declared ports and network
40/// attachments do not conflict.
41#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
42#[non_exhaustive]
43pub enum QuadletGroupingPolicy {
44    /// Preserve each service as an independently networked `.container` unit.
45    #[default]
46    SeparateContainers,
47    /// Place every service in one application-owned `.pod` unit when declared topology permits.
48    SinglePod,
49    /// Preserve one application-owned neutral service group as its correspondingly named `.pod`.
50    ///
51    /// The group must contain every application service exactly once. This policy does not infer
52    /// how multiple or partial groups should map to Quadlet units.
53    PreserveSingleGroup,
54}
55
56/// Native target adapter for validated Podman Quadlet output.
57#[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    /// Authorizes literal-bearing deferred container environment and protected inline providers.
70    /// The safe default withholds both; pure reference expressions remain eligible.
71    #[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    /// Creates an exporter backed by `QuadletLens`'s finite built-in capability catalogue.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`QuadletExporterError`] if embedded capability data or diagnostic codes are
82    /// invalid.
83    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    /// Returns the exact capability catalogue used for target planning.
113    #[must_use]
114    pub const fn catalogue(&self) -> &CapabilityCatalogue {
115        &self.catalogue
116    }
117
118    /// Resolves Compose-relative bind sources against an explicit absolute project root.
119    ///
120    /// Resolution is lexical and performs no filesystem access. The caller is responsible for
121    /// supplying the source implementation's actual project directory.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`QuadletExporterError::InvalidRelativeBindRoot`] when the root is not an absolute,
126    /// safely encodable POSIX path or lexically traverses above `/`.
127    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    /// Resolves Compose-relative host paths against an explicit absolute project root.
137    ///
138    /// This is the general form of [`Self::with_relative_bind_root`]. It applies to bind sources
139    /// and environment-file declarations. Resolution is lexical and performs no filesystem access.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`QuadletExporterError::InvalidRelativeBindRoot`] when the root is not an absolute,
144    /// safely encodable POSIX path or lexically traverses above `/`.
145    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    /// Returns the normalized caller-selected Compose project root, when configured.
150    #[must_use]
151    pub fn relative_bind_root(&self) -> Option<&str> {
152        self.relative_bind_root.as_deref()
153    }
154
155    /// Returns the normalized caller-selected Compose project root, when configured.
156    #[must_use]
157    pub fn relative_host_path_root(&self) -> Option<&str> {
158        self.relative_bind_root()
159    }
160
161    /// Adds one explicit authored bind-source to target-path mapping.
162    ///
163    /// This is the opt-in boundary for source-machine-specific forms such as `~/data`, Windows
164    /// paths, or environment-derived spellings. The source is matched exactly. The target must be
165    /// either an absolute safely encodable POSIX path or a systemd-specifier path such as
166    /// `%h/data`. Explicit mappings take precedence over relative-project-root resolution.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`QuadletExporterError::InvalidBindSourceMapping`] for an empty/control-bearing
171    /// source or unsafe target. Returns [`QuadletExporterError::ConflictingBindSourceMapping`] if
172    /// the same source was already assigned a different target.
173    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    /// Returns the exact target selected for an authored bind-source spelling.
198    #[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    /// Selects how services are grouped into native Quadlet units.
204    #[must_use]
205    pub const fn with_grouping_policy(mut self, policy: QuadletGroupingPolicy) -> Self {
206        self.grouping_policy = policy;
207        self
208    }
209
210    /// Returns the caller-selected service grouping policy.
211    #[must_use]
212    pub const fn grouping_policy(&self) -> QuadletGroupingPolicy {
213        self.grouping_policy
214    }
215
216    /// Selects the native name for a caller-requested single Podman pod.
217    ///
218    /// The override applies only to [`QuadletGroupingPolicy::SinglePod`]. Callers selecting
219    /// another grouping policy retain its established naming behavior.
220    #[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    /// Returns the caller-selected native pod name, when one was supplied.
227    #[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/// Failure while creating a Quadlet exporter.
255#[derive(Debug)]
256#[non_exhaustive]
257pub enum QuadletExporterError {
258    /// `QuadletLens`'s embedded capability catalogue failed validation.
259    Catalogue(CatalogueError),
260    /// A stable diagnostic code embedded in this adapter was malformed.
261    DiagnosticCode(InvalidDiagnosticCode),
262    /// A caller-selected relative bind root was not a safe absolute POSIX path.
263    InvalidRelativeBindRoot(String),
264    /// An explicit bind-source mapping contained an invalid source or target spelling.
265    InvalidBindSourceMapping {
266        /// Exact source spelling supplied by the caller.
267        source: String,
268        /// Requested Quadlet target spelling.
269        target: String,
270    },
271    /// One source spelling was assigned two different target paths.
272    ConflictingBindSourceMapping {
273        /// Exact source spelling supplied by the caller.
274        source: String,
275        /// Previously configured target spelling.
276        existing: String,
277        /// Conflicting replacement target spelling.
278        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
373/// A native resource reference after its lifecycle ownership has been resolved.
374///
375/// `Uncertain` resources have a known native name but no safe lifecycle claim. They can be
376/// referenced by a generated service as external resources, but that is an approximation rather
377/// than an exact managed-unit dependency.
378struct 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        // Native pod settings are authoritative.  Do not combine them with values inferred from
630        // the member containers: those values can intentionally disagree in a converted graph.
631        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)] // Each native pod key remains capability-gated at its mapping site.
1844    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)] // Image-or-rootfs selection and typed service mapping share one document builder.
2034    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)] // All new typed service keys are capability-gated together.
2555    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    /// Uses the Lens catalogue when available and retains the reviewed local floor for newly
4564    /// released Pod keys until the pinned Lens catalogue carries the corresponding evidence.
4565    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}