Skip to main content

podman_lens/
render.rs

1//! Version-aware, transport-neutral deployment-plan rendering.
2//!
3//! This M6-A boundary turns validated M5 semantics into reviewable CLI and Libpod request
4//! descriptions. It never opens a connection, sends a request, or serializes secret material.
5
6use std::{
7    collections::{BTreeMap, BTreeSet, HashSet},
8    fmt::Write as _,
9};
10
11use semver::Version;
12use serde::{Deserialize, de};
13use serde_json::{Map, Value, json};
14
15use crate::settings::{MountIntent, SecretGrant, SecretMode};
16use crate::{
17    DeploymentConnectionReference, DeploymentOperation, DeploymentPlan, DeploymentResource, DeploymentResourceId,
18    Diagnostic, DiagnosticCode, ExternalPrecondition, HostAlias, NetworkAttachment, NetworkIntent, NetworkRoute,
19    PortMapping, PortProtocol, ResourceKind, RestartPolicy, RouteType, SensitiveInputReference,
20};
21
22const RENDERING_CATALOGUE_JSON: &str = include_str!("../catalogue/v1/podman-deployment-rendering.json");
23const RENDERED_OPERATION_CATEGORIES: [&str; 8] = [
24    "network-create",
25    "volume-create",
26    "secret-create",
27    "image-pull",
28    "pod-create",
29    "container-create",
30    "pod-start",
31    "container-start",
32];
33
34#[derive(Deserialize)]
35#[serde(deny_unknown_fields)]
36struct RenderingCatalogue {
37    schema_version: u8,
38    provenance: String,
39    runtime_field_claims: Vec<RuntimeFieldClaimJson>,
40    reviewed_lines: Vec<ReviewedRenderingLine>,
41}
42
43#[derive(Deserialize)]
44#[serde(deny_unknown_fields)]
45struct ReviewedRenderingLine {
46    version: String,
47    revision: String,
48    tag: String,
49    common_module: ModulePin,
50    b4_evidence: ReviewedB4Evidence,
51    runtime_evidence: ReviewedRuntimeEvidence,
52    operations: Vec<ReviewedRenderingOperation>,
53    field_evidence: Vec<ReviewedFieldEvidence>,
54}
55
56/// Immutable per-release evidence for the bounded M6-B4 mount, secret, volume, and image
57/// surface.  It deliberately records blocked and manual boundaries beside exact fields so a
58/// future renderer cannot turn a known one-plane-only or portability-sensitive spelling into an
59/// accidental exact claim.
60#[derive(Deserialize)]
61#[serde(deny_unknown_fields)]
62struct ReviewedB4Evidence {
63    exact_fields: Vec<B4RenderedField>,
64    target_gated_fields: Vec<B4RenderedField>,
65    manual_fields: Vec<B4RenderedField>,
66    blocked_fields: Vec<B4RenderedField>,
67    cli_sources: Vec<SourceReference>,
68    model_sources: Vec<SourceReference>,
69    handler_sources: Vec<SourceReference>,
70}
71
72/// Immutable all-fields evidence for the bounded M6-B3 container runtime surface.
73///
74/// The fields share the native container-create route and handler, while the explicit source
75/// bundle retains the CLI flag declaration, CLI-to-SpecGenerator transform, body model,
76/// namespace grammar, and health semantics for the exact reviewed Podman revision.
77#[derive(Deserialize)]
78#[serde(deny_unknown_fields)]
79struct ReviewedRuntimeEvidence {
80    exact_fields: Vec<RuntimeRenderedField>,
81    target_gated_fields: Vec<RuntimeRenderedField>,
82    cli_flag_source: SourceReference,
83    cli_transform_source: SourceReference,
84    command_route_source: SourceReference,
85    model_sources: Vec<SourceReference>,
86    route_source: SourceReference,
87    handler_source: SourceReference,
88}
89
90#[derive(Deserialize)]
91#[serde(deny_unknown_fields)]
92struct RuntimeFieldClaimJson {
93    field: RuntimeRenderedField,
94    cli: CliFieldClaim,
95    libpod: RuntimeLibpodFieldClaim,
96}
97
98#[derive(Deserialize)]
99#[serde(deny_unknown_fields)]
100struct RuntimeLibpodFieldClaim {
101    json_member: LibpodBodyMember,
102    value_shape: RuntimeLibpodValueShape,
103}
104
105#[derive(Deserialize)]
106#[serde(deny_unknown_fields)]
107struct ReviewedRenderingOperation {
108    category: RenderingOperationCategory,
109    cli_source: SourceReference,
110    libpod_endpoint_source: SourceReference,
111    body_source: Option<SourceReference>,
112}
113
114#[derive(Deserialize)]
115#[serde(deny_unknown_fields)]
116struct ReviewedFieldEvidence {
117    field: RenderedField,
118    availability: FieldAvailability,
119    operation: RenderingOperationCategory,
120    cli: CliFieldClaim,
121    libpod: LibpodFieldClaim,
122    cli_source: SourceReference,
123    model_sources: Vec<SourceReference>,
124    handler_source: SourceReference,
125}
126
127#[derive(Clone, Copy, Deserialize, Eq, PartialEq)]
128#[serde(rename_all = "kebab-case")]
129enum FieldAvailability {
130    Exact,
131    Unsupported,
132}
133
134/// One immutable source location backing a rendering claim.
135///
136/// URLs are deliberately not accepted here: they obscure repository ownership and permit a
137/// revision from one repository to be paired with a path from another. The parser validates this
138/// structured repository, revision, path, and module information against the canonical field
139/// matrix.
140#[derive(Deserialize, Eq, PartialEq)]
141#[serde(deny_unknown_fields)]
142struct SourceReference {
143    repository: SourceRepository,
144    revision: String,
145    path: String,
146    module: Option<ModulePin>,
147}
148
149#[derive(Clone, Copy, Deserialize, Eq, PartialEq)]
150#[serde(rename_all = "kebab-case")]
151enum SourceRepository {
152    ContainersPodman,
153    ContainersCommon,
154    ContainersContainerLibs,
155    PodmanContainerToolsContainerLibs,
156}
157
158#[derive(Deserialize, Eq, PartialEq)]
159#[serde(deny_unknown_fields)]
160struct ModulePin {
161    repository: SourceRepository,
162    path: String,
163    version: String,
164    revision: String,
165}
166
167#[derive(Deserialize)]
168#[serde(deny_unknown_fields)]
169struct CliFieldClaim {
170    flag: Option<CliFlag>,
171    value_shape: CliValueShape,
172}
173
174#[derive(Deserialize)]
175#[serde(deny_unknown_fields)]
176struct LibpodFieldClaim {
177    json_member: LibpodBodyMember,
178}
179
180#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
181#[serde(rename_all = "kebab-case")]
182enum RenderingOperationCategory {
183    NetworkCreate,
184    VolumeCreate,
185    SecretCreate,
186    ImagePull,
187    PodCreate,
188    ContainerCreate,
189    PodStart,
190    ContainerStart,
191}
192
193impl RenderingOperationCategory {
194    const fn as_str(self) -> &'static str {
195        match self {
196            Self::NetworkCreate => "network-create",
197            Self::VolumeCreate => "volume-create",
198            Self::SecretCreate => "secret-create",
199            Self::ImagePull => "image-pull",
200            Self::PodCreate => "pod-create",
201            Self::ContainerCreate => "container-create",
202            Self::PodStart => "pod-start",
203            Self::ContainerStart => "container-start",
204        }
205    }
206}
207
208#[derive(Clone, Copy, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
209#[serde(rename_all = "kebab-case")]
210enum RenderedField {
211    ContainerCommand,
212    ContainerEntrypoint,
213    ContainerUser,
214    ContainerWorkdir,
215    ContainerHostname,
216    ContainerLabel,
217    ContainerEnvironment,
218    ContainerRestartPolicy,
219    ContainerNamedVolumeMount,
220    ContainerNetworkAttachment,
221    PodNetworkAttachment,
222    ContainerNetworkAlias,
223    PodNetworkAlias,
224    ContainerNetworkStaticIpv4,
225    PodNetworkStaticIpv4,
226    ContainerNetworkStaticIpv6,
227    PodNetworkStaticIpv6,
228    ContainerNetworkStaticMac,
229    PodNetworkStaticMac,
230    ContainerPortMapping,
231    PodPortMapping,
232    ContainerDnsServers,
233    ContainerDnsSearch,
234    ContainerDnsOptions,
235    PodDnsServers,
236    PodDnsSearch,
237    PodDnsOptions,
238    ContainerHostAlias,
239    PodHostAlias,
240    ContainerNetworkOrder,
241    NetworkIpamSubnet,
242    NetworkIpamGateway,
243    NetworkIpamRange,
244    NetworkRouteDestination,
245    NetworkRouteGateway,
246    NetworkRouteMetric,
247    NetworkRouteTypeUnicast,
248    NetworkRouteTypeBlackhole,
249    NetworkRouteTypeUnreachable,
250    NetworkRouteTypeProhibit,
251}
252
253/// A field rendered by the bounded M6-B3 container-runtime surface.
254#[derive(Clone, Copy, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
255#[serde(rename_all = "kebab-case")]
256#[allow(clippy::enum_variant_names)] // Stable public catalogue identifiers retain the resource kind.
257enum RuntimeRenderedField {
258    ContainerHealthDisabled,
259    ContainerHealthCommand,
260    ContainerHealthInterval,
261    ContainerHealthTimeout,
262    ContainerHealthRetries,
263    ContainerHealthStartPeriod,
264    ContainerHealthOnFailure,
265    ContainerStartupHealthCommand,
266    ContainerStartupHealthInterval,
267    ContainerStartupHealthTimeout,
268    ContainerStartupHealthRetries,
269    ContainerStartupHealthSuccesses,
270    ContainerLogDriver,
271    ContainerLogMaxSize,
272    ContainerLogJournaldLabels,
273    ContainerPrivileged,
274    ContainerCapabilityAdd,
275    ContainerCapabilityDrop,
276    ContainerNoNewPrivileges,
277    ContainerReadOnlyFilesystem,
278    ContainerReadWriteTmpfs,
279    ContainerPidNamespace,
280    ContainerIpcNamespace,
281    ContainerUtsNamespace,
282    ContainerCgroupNamespace,
283    ContainerCpuShares,
284    ContainerCpuPeriod,
285    ContainerCpuQuota,
286    ContainerMemoryLimit,
287    ContainerPidsLimit,
288    ContainerRlimitFinite,
289    ContainerRlimitUnlimited,
290}
291
292/// One exact, target-gated, manual, or deliberately blocked M6-B4 output surface.
293#[derive(Clone, Copy, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
294#[serde(rename_all = "kebab-case")]
295#[allow(clippy::enum_variant_names)] // Stable evidence identifiers retain their resource scope.
296enum B4RenderedField {
297    ContainerNamedVolumeCopyMount,
298    ContainerNamedVolumeSubpathCopyMount,
299    ContainerNamedVolumeNoCopyMount,
300    ContainerNamedVolumeSubpathNoCopyMount,
301    ContainerBindMount,
302    ContainerTmpfsMount,
303    ContainerSecretMountGrant,
304    ContainerSecretEnvironmentGrant,
305    VolumeUid,
306    VolumeGid,
307    ImagePullPolicyAlways,
308    ImagePullPolicyMissing,
309    ImagePullPolicyNever,
310    ImagePullPolicyNewer,
311    ImageSourcePortability,
312    PodInfraMount,
313}
314
315#[derive(Clone, Copy, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
316enum CliFlag {
317    #[serde(rename = "--entrypoint")]
318    Entrypoint,
319    #[serde(rename = "--user")]
320    User,
321    #[serde(rename = "--workdir")]
322    Workdir,
323    #[serde(rename = "--hostname")]
324    Hostname,
325    #[serde(rename = "--label")]
326    Label,
327    #[serde(rename = "--env")]
328    Environment,
329    #[serde(rename = "--restart")]
330    Restart,
331    #[serde(rename = "--volume")]
332    Volume,
333    #[serde(rename = "--network")]
334    Network,
335    #[serde(rename = "--network-alias")]
336    NetworkAlias,
337    #[serde(rename = "--ip")]
338    Ip,
339    #[serde(rename = "--ip6")]
340    Ip6,
341    #[serde(rename = "--mac-address")]
342    MacAddress,
343    #[serde(rename = "--publish")]
344    Publish,
345    #[serde(rename = "--dns")]
346    Dns,
347    #[serde(rename = "--dns-search")]
348    DnsSearch,
349    #[serde(rename = "--dns-option")]
350    DnsOption,
351    #[serde(rename = "--add-host")]
352    AddHost,
353    #[serde(rename = "--subnet")]
354    Subnet,
355    #[serde(rename = "--gateway")]
356    Gateway,
357    #[serde(rename = "--ip-range")]
358    IpRange,
359    #[serde(rename = "--route")]
360    Route,
361    #[serde(rename = "--no-healthcheck")]
362    NoHealthcheck,
363    #[serde(rename = "--health-cmd")]
364    HealthCommand,
365    #[serde(rename = "--health-interval")]
366    HealthInterval,
367    #[serde(rename = "--health-timeout")]
368    HealthTimeout,
369    #[serde(rename = "--health-retries")]
370    HealthRetries,
371    #[serde(rename = "--health-start-period")]
372    HealthStartPeriod,
373    #[serde(rename = "--health-on-failure")]
374    HealthOnFailure,
375    #[serde(rename = "--health-startup-cmd")]
376    HealthStartupCommand,
377    #[serde(rename = "--health-startup-interval")]
378    HealthStartupInterval,
379    #[serde(rename = "--health-startup-timeout")]
380    HealthStartupTimeout,
381    #[serde(rename = "--health-startup-retries")]
382    HealthStartupRetries,
383    #[serde(rename = "--health-startup-success")]
384    HealthStartupSuccess,
385    #[serde(rename = "--log-driver")]
386    LogDriver,
387    #[serde(rename = "--log-opt")]
388    LogOption,
389    #[serde(rename = "--privileged")]
390    Privileged,
391    #[serde(rename = "--cap-add")]
392    CapabilityAdd,
393    #[serde(rename = "--cap-drop")]
394    CapabilityDrop,
395    #[serde(rename = "--security-opt")]
396    SecurityOption,
397    #[serde(rename = "--read-only")]
398    ReadOnly,
399    #[serde(rename = "--read-only-tmpfs")]
400    ReadOnlyTmpfs,
401    #[serde(rename = "--pid")]
402    PidNamespace,
403    #[serde(rename = "--ipc")]
404    IpcNamespace,
405    #[serde(rename = "--uts")]
406    UtsNamespace,
407    #[serde(rename = "--cgroupns")]
408    CgroupNamespace,
409    #[serde(rename = "--cpu-shares")]
410    CpuShares,
411    #[serde(rename = "--cpu-period")]
412    CpuPeriod,
413    #[serde(rename = "--cpu-quota")]
414    CpuQuota,
415    #[serde(rename = "--memory")]
416    Memory,
417    #[serde(rename = "--pids-limit")]
418    PidsLimit,
419    #[serde(rename = "--ulimit")]
420    Ulimit,
421}
422
423#[derive(Clone, Copy, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
424#[serde(rename_all = "kebab-case")]
425enum CliValueShape {
426    ArgumentArray,
427    ContainerUser,
428    AbsoluteContainerPath,
429    Hostname,
430    LabelAssignment,
431    EnvironmentAssignment,
432    RestartPolicy,
433    NamedVolumeMount,
434    NetworkAttachment,
435    NetworkAlias,
436    StaticIp,
437    StaticMac,
438    PortMapping,
439    DnsServer,
440    DnsSearch,
441    DnsOption,
442    HostAlias,
443    NetworkOrder,
444    NetworkSubnet,
445    NetworkGateway,
446    NetworkIpRange,
447    NetworkRoute,
448    FlagOnly,
449    Boolean,
450    HealthCommand,
451    HealthInterval,
452    HealthDuration,
453    HealthRetries,
454    HealthOnFailure,
455    StartupHealthSuccesses,
456    LogDriver,
457    LogMaxSize,
458    JournaldLabel,
459    Capability,
460    NoNewPrivileges,
461    NamespaceMode,
462    CpuShares,
463    CpuPeriod,
464    CpuQuota,
465    MemoryBytes,
466    PidsLimit,
467    Rlimit,
468}
469
470#[derive(Clone, Copy, Deserialize, Eq, Ord, PartialEq, PartialOrd)]
471#[serde(rename_all = "snake_case")]
472enum LibpodBodyMember {
473    Command,
474    Entrypoint,
475    User,
476    WorkDir,
477    Hostname,
478    Labels,
479    Env,
480    RestartPolicy,
481    Volumes,
482    #[serde(rename = "Networks")]
483    Networks,
484    Portmappings,
485    DnsServer,
486    DnsSearch,
487    DnsOption,
488    Hostadd,
489    #[serde(rename = "networkOrder")]
490    NetworkOrder,
491    Subnets,
492    Routes,
493    Healthconfig,
494    HealthCheckOnFailureAction,
495    #[serde(rename = "startupHealthConfig")]
496    StartupHealthConfig,
497    #[serde(rename = "log_configuration.driver")]
498    LogConfigurationDriver,
499    #[serde(rename = "log_configuration.size")]
500    LogConfigurationSize,
501    #[serde(rename = "log_configuration.labels")]
502    LogConfigurationLabels,
503    Privileged,
504    CapAdd,
505    CapDrop,
506    NoNewPrivileges,
507    ReadOnlyFilesystem,
508    ReadWriteTmpfs,
509    Pidns,
510    Ipcns,
511    Utsns,
512    Cgroupns,
513    #[serde(rename = "resource_limits.cpu.shares")]
514    ResourceLimitsCpuShares,
515    #[serde(rename = "resource_limits.cpu.period")]
516    ResourceLimitsCpuPeriod,
517    #[serde(rename = "resource_limits.cpu.quota")]
518    ResourceLimitsCpuQuota,
519    #[serde(rename = "resource_limits.memory.limit")]
520    ResourceLimitsMemoryLimit,
521    #[serde(rename = "resource_limits.pids.limit")]
522    ResourceLimitsPidsLimit,
523    RLimits,
524}
525
526#[derive(Clone, Copy, Deserialize, Eq, PartialEq)]
527#[serde(rename_all = "kebab-case")]
528enum RuntimeLibpodValueShape {
529    HealthConfiguration,
530    HealthOnFailureAction,
531    StartupHealthConfiguration,
532    String,
533    Integer,
534    StringMap,
535    Boolean,
536    StringArray,
537    Namespace,
538    ResourceLimitInteger,
539    RlimitArray,
540}
541
542/// Exactness of a rendered operation or plan.
543#[derive(Clone, Copy, Debug, Eq, PartialEq)]
544#[non_exhaustive]
545pub enum RenderStatus {
546    /// Every required input is represented by the rendered forms.
547    Exact,
548    /// A secret payload must be supplied by the caller at deployment time.
549    DeferredSensitiveInput,
550    /// A human must complete a reviewed manual step.
551    Manual,
552    /// The rendered operation intentionally approximates a semantic request.
553    Approximate,
554    /// No reviewed representation exists for the semantic request.
555    Unsupported,
556}
557
558/// A redacted, stable finding produced while rendering an M5 plan.
559#[derive(Clone, Debug, Eq, PartialEq)]
560pub struct RenderingFinding {
561    code: DiagnosticCode,
562    subject: Option<DeploymentResourceId>,
563    field: Option<&'static str>,
564}
565
566impl Ord for RenderingFinding {
567    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
568        (self.code.as_str(), &self.subject, self.field).cmp(&(other.code.as_str(), &other.subject, other.field))
569    }
570}
571
572impl PartialOrd for RenderingFinding {
573    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
574        Some(self.cmp(other))
575    }
576}
577
578impl RenderingFinding {
579    fn new(code: DiagnosticCode, subject: Option<DeploymentResourceId>, field: Option<&'static str>) -> Self {
580        Self { code, subject, field }
581    }
582
583    /// Returns the stable rendering rule code.
584    #[must_use]
585    pub const fn code(&self) -> DiagnosticCode {
586        self.code
587    }
588
589    /// Returns the redacted rule explanation.
590    #[must_use]
591    pub const fn message(&self) -> &'static str {
592        Diagnostic::new(self.code).message()
593    }
594
595    /// Returns the affected safe target-side resource identity, when applicable.
596    #[must_use]
597    pub fn subject(&self) -> Option<&DeploymentResourceId> {
598        self.subject.as_ref()
599    }
600
601    /// Returns the affected intent field, when applicable.
602    #[must_use]
603    pub const fn field(&self) -> Option<&'static str> {
604        self.field
605    }
606}
607
608/// A deterministic Podman CLI invocation represented without shell quoting.
609#[derive(Clone, Debug, Eq, PartialEq)]
610pub struct CliInvocation {
611    program: &'static str,
612    argv: Vec<String>,
613    external_input: Option<SensitiveInputReference>,
614}
615
616impl CliInvocation {
617    fn new(argv: Vec<String>, external_input: Option<SensitiveInputReference>) -> Self {
618        Self {
619            program: "podman",
620            argv,
621            external_input,
622        }
623    }
624
625    /// Returns the fixed executable name.
626    #[must_use]
627    pub const fn program(&self) -> &'static str {
628        self.program
629    }
630
631    /// Returns command arguments without shell quoting or interpolation.
632    #[must_use]
633    pub fn argv(&self) -> &[String] {
634        &self.argv
635    }
636
637    /// Returns the required secret-material reference for stdin/file provision, if any.
638    #[must_use]
639    pub fn external_input(&self) -> Option<&SensitiveInputReference> {
640        self.external_input.as_ref()
641    }
642}
643
644/// HTTP method in a rendered Libpod request.
645#[derive(Clone, Copy, Debug, Eq, PartialEq)]
646pub enum RenderedHttpMethod {
647    /// HTTP `GET`.
648    Get,
649    /// HTTP `POST`.
650    Post,
651}
652
653/// A Libpod request body that is either safe JSON, absent, or deferred sensitive bytes.
654#[derive(Clone, Debug, PartialEq)]
655pub enum RenderedHttpBody {
656    /// No HTTP body is required.
657    Empty,
658    /// A deterministic typed JSON body.
659    Json(Value),
660    /// The caller must supply raw sensitive material; the reference is never serialized.
661    ExternalSensitiveInput(SensitiveInputReference),
662}
663
664/// A deterministic, non-executable Libpod request description.
665#[derive(Clone, Debug, PartialEq)]
666pub struct LibpodInvocation {
667    method: RenderedHttpMethod,
668    path_and_query: String,
669    body: RenderedHttpBody,
670}
671
672impl LibpodInvocation {
673    fn new(method: RenderedHttpMethod, path_and_query: String, body: RenderedHttpBody) -> Self {
674        Self {
675            method,
676            path_and_query,
677            body,
678        }
679    }
680
681    /// Returns the request method.
682    #[must_use]
683    pub const fn method(&self) -> RenderedHttpMethod {
684        self.method
685    }
686
687    /// Returns the fully versioned and percent-encoded path/query.
688    #[must_use]
689    pub fn path_and_query(&self) -> &str {
690        &self.path_and_query
691    }
692
693    /// Returns the safe JSON, no body, or external sensitive-input requirement.
694    #[must_use]
695    pub fn body(&self) -> &RenderedHttpBody {
696        &self.body
697    }
698}
699
700/// One semantic operation with both independently reviewable transport renderings.
701#[derive(Clone, Debug, PartialEq)]
702pub struct RenderedOperation {
703    operation: DeploymentOperation,
704    status: RenderStatus,
705    cli: CliInvocation,
706    libpod: LibpodInvocation,
707}
708
709impl RenderedOperation {
710    /// Returns the retained semantic source operation.
711    #[must_use]
712    pub fn operation(&self) -> &DeploymentOperation {
713        &self.operation
714    }
715
716    /// Returns the exactness of this operation.
717    #[must_use]
718    pub const fn status(&self) -> RenderStatus {
719        self.status
720    }
721
722    /// Returns the non-executable CLI rendering.
723    #[must_use]
724    pub fn cli(&self) -> &CliInvocation {
725        &self.cli
726    }
727
728    /// Returns the non-executable Libpod request rendering.
729    #[must_use]
730    pub fn libpod(&self) -> &LibpodInvocation {
731        &self.libpod
732    }
733}
734
735/// Complete rendering of a validated M5 deployment plan.
736#[derive(Clone, Debug, PartialEq)]
737pub struct DeploymentRendering {
738    status: RenderStatus,
739    connection: Option<DeploymentConnectionReference>,
740    external_preconditions: Vec<ExternalPrecondition>,
741    operations: Vec<RenderedOperation>,
742}
743
744impl DeploymentRendering {
745    /// Returns the aggregate exactness state.
746    #[must_use]
747    pub const fn status(&self) -> RenderStatus {
748        self.status
749    }
750
751    /// Returns the caller-selected non-sensitive output connection reference, when present.
752    ///
753    /// The rendering never opens this connection. CLI invocations retain it as their explicit
754    /// `--connection` argument; the deployment JSON preserves it for API consumers.
755    #[must_use]
756    pub fn connection(&self) -> Option<&DeploymentConnectionReference> {
757        self.connection.as_ref()
758    }
759
760    /// Returns explicit external prerequisites preserved from the semantic plan.
761    #[must_use]
762    pub fn external_preconditions(&self) -> &[ExternalPrecondition] {
763        &self.external_preconditions
764    }
765
766    /// Returns operations in the authoritative M5 sequence.
767    #[must_use]
768    pub fn operations(&self) -> &[RenderedOperation] {
769        &self.operations
770    }
771
772    /// Generates a review-only POSIX `sh` script from the stored CLI argument arrays.
773    ///
774    /// The library never executes the script. It deliberately has no command substitution, glob
775    /// expansion, or `eval`; it contains only the displayed Podman invocations. Each deferred
776    /// secret requires a caller-provided regular file path in deterministic
777    /// `PODMAN_LENS_SECRET_INPUT_<n>` order.
778    #[must_use]
779    pub fn shell_script(&self) -> String {
780        let mut script =
781            String::from("#!/bin/sh\n# Review generated Podman commands before running this file.\nset -eu\n");
782        for precondition in &self.external_preconditions {
783            let _ = writeln!(
784                script,
785                "# Requires external {}: {}",
786                resource_kind_name(precondition.identity().kind()),
787                shell_quote(precondition.identity().name())
788            );
789        }
790        let mut secret_number = 0_usize;
791        for operation in &self.operations {
792            if operation.cli.external_input().is_some() {
793                secret_number += 1;
794                let _ = writeln!(
795                    script,
796                    ": \"${{PODMAN_LENS_SECRET_INPUT_{secret_number}:?set to a readable secret file path}}\""
797                );
798            }
799        }
800        secret_number = 0;
801        for operation in &self.operations {
802            script.push_str("podman");
803            for value in operation.cli.argv() {
804                script.push(' ');
805                script.push_str(&shell_quote(value));
806            }
807            if operation.cli.external_input().is_some() {
808                secret_number += 1;
809                let _ = write!(script, " < \"${{PODMAN_LENS_SECRET_INPUT_{secret_number}}}\"");
810            }
811            script.push('\n');
812        }
813        script
814    }
815}
816
817/// Outcome of rendering a plan for its explicitly selected target profile.
818#[derive(Clone, Debug, PartialEq)]
819pub struct RenderingOutcome {
820    rendering: Option<DeploymentRendering>,
821    findings: Vec<RenderingFinding>,
822}
823
824impl RenderingOutcome {
825    /// Returns the complete rendering when no rendering errors occurred.
826    #[must_use]
827    pub fn rendering(&self) -> Option<&DeploymentRendering> {
828        self.rendering.as_ref()
829    }
830
831    /// Returns deterministic sorted rendering findings.
832    #[must_use]
833    pub fn findings(&self) -> &[RenderingFinding] {
834        &self.findings
835    }
836
837    /// Returns whether a rendering was produced.
838    #[must_use]
839    pub const fn is_success(&self) -> bool {
840        self.rendering.is_some()
841    }
842}
843
844/// Renders a validated semantic plan into non-executable CLI and Libpod descriptions.
845///
846/// M6-A accepts only targets whose engine and API versions are semantically identical and exactly
847/// listed in its immutable renderer evidence. Existing M5 target validation permits a broader API
848/// relation; per-field wire evidence is required before that relation can be rendered.
849#[must_use]
850pub fn render_deployment(plan: &DeploymentPlan) -> RenderingOutcome {
851    let Some(version) = reviewed_renderer_target_version(plan) else {
852        return RenderingOutcome {
853            rendering: None,
854            findings: vec![RenderingFinding::new(
855                DiagnosticCode::RenderingTargetMismatch,
856                None,
857                Some("target"),
858            )],
859        };
860    };
861    let connection = plan.connection().map(DeploymentConnectionReference::as_str);
862    let managed_image_sources = managed_image_sources(plan);
863    let mut deferred = false;
864    let mut operations = Vec::with_capacity(plan.operations().len());
865    let mut findings = Vec::new();
866    let mut blocked_resources = BTreeSet::new();
867    for operation in plan.operations() {
868        if blocked_resources.contains(operation.id().resource()) {
869            continue;
870        }
871        let unsupported_fields =
872            unsupported_fields(operation.resource_intent(), &version, plan.target().execution_context());
873        if !unsupported_fields.is_empty() {
874            blocked_resources.insert(operation.id().resource().clone());
875            findings.extend(unsupported_fields.into_iter().map(|field| {
876                RenderingFinding::new(
877                    if field == "source.portability" {
878                        DiagnosticCode::ImagePortabilityManual
879                    } else {
880                        DiagnosticCode::RenderingUnsupported
881                    },
882                    Some(operation.id().resource().clone()),
883                    Some(field),
884                )
885            }));
886            continue;
887        }
888        match render_operation(operation, &version, connection, &managed_image_sources) {
889            Ok(rendered) => {
890                deferred |= rendered.status == RenderStatus::DeferredSensitiveInput;
891                operations.push(rendered);
892            }
893            Err(finding) => findings.push(finding),
894        }
895    }
896    findings.sort_unstable();
897    if findings.is_empty() {
898        RenderingOutcome {
899            rendering: Some(DeploymentRendering {
900                status: if deferred {
901                    RenderStatus::DeferredSensitiveInput
902                } else {
903                    RenderStatus::Exact
904                },
905                connection: plan.connection().cloned(),
906                external_preconditions: plan.external_preconditions().to_vec(),
907                operations,
908            }),
909            findings,
910        }
911    } else {
912        RenderingOutcome {
913            rendering: None,
914            findings,
915        }
916    }
917}
918
919#[allow(clippy::too_many_lines)] // One exhaustive match keeps the finite M5 semantic surface auditable.
920fn render_operation(
921    operation: &DeploymentOperation,
922    version: &str,
923    connection: Option<&str>,
924    managed_image_sources: &BTreeMap<DeploymentResourceId, String>,
925) -> Result<RenderedOperation, RenderingFinding> {
926    let id = operation.id().resource();
927    let mut prefix = connection.map_or_else(Vec::new, |value| vec!["--connection".to_owned(), value.to_owned()]);
928    let (status, cli_suffix, method, path, body, input) = match operation.resource_intent() {
929        DeploymentResource::Network(network) => {
930            let mut cli_suffix = vec!["network".to_owned(), "create".to_owned()];
931            append_network_create_arguments(&mut cli_suffix, network);
932            cli_suffix.push(network.identity().name().to_owned());
933            (
934                RenderStatus::Exact,
935                cli_suffix,
936                RenderedHttpMethod::Post,
937                format!("/v{version}/libpod/networks/create"),
938                RenderedHttpBody::Json(network_create_configuration(
939                    network,
940                    supports_network_route_type(version),
941                )),
942                None,
943            )
944        }
945        DeploymentResource::Volume(volume) => (
946            RenderStatus::Exact,
947            volume_create_cli_arguments(volume),
948            RenderedHttpMethod::Post,
949            format!("/v{version}/libpod/volumes/create"),
950            RenderedHttpBody::Json(volume_create_json(volume)),
951            None,
952        ),
953        DeploymentResource::Secret(secret) => (
954            RenderStatus::DeferredSensitiveInput,
955            vec![
956                "secret".to_owned(),
957                "create".to_owned(),
958                secret.identity().name().to_owned(),
959                "-".to_owned(),
960            ],
961            RenderedHttpMethod::Post,
962            format!(
963                "/v{version}/libpod/secrets/create?name={}",
964                percent_encode(secret.identity().name())
965            ),
966            RenderedHttpBody::ExternalSensitiveInput(secret.material().clone()),
967            Some(secret.material().clone()),
968        ),
969        DeploymentResource::Image(image) => (
970            RenderStatus::Exact,
971            vec![
972                "image".to_owned(),
973                "pull".to_owned(),
974                format!("--policy={}", image.pull_policy().as_str()),
975                image.source().as_str().to_owned(),
976            ],
977            RenderedHttpMethod::Post,
978            format!(
979                "/v{version}/libpod/images/pull?reference={}&policy={}",
980                percent_encode(image.source().as_str()),
981                image.pull_policy().as_str(),
982            ),
983            RenderedHttpBody::Empty,
984            None,
985        ),
986        DeploymentResource::Pod(pod) => {
987            if operation.id().action() == crate::SemanticOperationAction::StartPod {
988                (
989                    RenderStatus::Exact,
990                    vec!["pod".to_owned(), "start".to_owned(), pod.identity().name().to_owned()],
991                    RenderedHttpMethod::Post,
992                    format!(
993                        "/v{version}/libpod/pods/{}/start",
994                        percent_encode(pod.identity().name())
995                    ),
996                    RenderedHttpBody::Empty,
997                    None,
998                )
999            } else {
1000                let networks = network_configuration(pod.networks());
1001                let mut cli_suffix = vec![
1002                    "pod".to_owned(),
1003                    "create".to_owned(),
1004                    "--name".to_owned(),
1005                    pod.identity().name().to_owned(),
1006                ];
1007                if !append_network_arguments(&mut cli_suffix, pod.networks(), None) {
1008                    return Err(RenderingFinding::new(
1009                        DiagnosticCode::RenderingUnsupported,
1010                        Some(id.clone()),
1011                        Some("networks.cli_ambiguous"),
1012                    ));
1013                }
1014                append_port_arguments(&mut cli_suffix, pod.ports());
1015                append_dns_arguments(&mut cli_suffix, pod.dns());
1016                append_host_alias_arguments(&mut cli_suffix, pod.host_aliases());
1017                let mut body = Map::new();
1018                body.insert("name".to_owned(), Value::String(pod.identity().name().to_owned()));
1019                body.insert("Networks".to_owned(), networks);
1020                append_networking_json(&mut body, pod.ports(), pod.dns(), pod.host_aliases(), None);
1021                (
1022                    RenderStatus::Exact,
1023                    cli_suffix,
1024                    RenderedHttpMethod::Post,
1025                    format!("/v{version}/libpod/pods/create"),
1026                    RenderedHttpBody::Json(Value::Object(body)),
1027                    None,
1028                )
1029            }
1030        }
1031        DeploymentResource::Container(container) => {
1032            if operation.id().action() == crate::SemanticOperationAction::StartContainer {
1033                (
1034                    RenderStatus::Exact,
1035                    vec![
1036                        "container".to_owned(),
1037                        "start".to_owned(),
1038                        container.identity().name().to_owned(),
1039                    ],
1040                    RenderedHttpMethod::Post,
1041                    format!(
1042                        "/v{version}/libpod/containers/{}/start",
1043                        percent_encode(container.identity().name())
1044                    ),
1045                    RenderedHttpBody::Empty,
1046                    None,
1047                )
1048            } else {
1049                let image = managed_image_sources
1050                    .get(container.image())
1051                    .map_or_else(|| container.image().name(), String::as_str);
1052                let mut cli_suffix = vec![
1053                    "container".to_owned(),
1054                    "create".to_owned(),
1055                    "--name".to_owned(),
1056                    container.identity().name().to_owned(),
1057                    "--pull=never".to_owned(),
1058                ];
1059                let mut body = if let Some(pod) = container.pod() {
1060                    cli_suffix.push("--pod".to_owned());
1061                    cli_suffix.push(pod.name().to_owned());
1062                    json!({"image": image, "pod": pod.name()})
1063                } else {
1064                    if !append_network_arguments(&mut cli_suffix, container.networks(), container.network_order()) {
1065                        return Err(RenderingFinding::new(
1066                            DiagnosticCode::RenderingUnsupported,
1067                            Some(id.clone()),
1068                            Some("networks.cli_ambiguous"),
1069                        ));
1070                    }
1071                    append_port_arguments(&mut cli_suffix, container.ports());
1072                    append_dns_arguments(&mut cli_suffix, container.dns());
1073                    append_host_alias_arguments(&mut cli_suffix, container.host_aliases());
1074                    let mut body = json!({"image": image, "Networks": network_configuration(container.networks())});
1075                    let Some(body_map) = body.as_object_mut() else {
1076                        return Err(RenderingFinding::new(
1077                            DiagnosticCode::RenderingUnsupported,
1078                            Some(id.clone()),
1079                            Some("container_body"),
1080                        ));
1081                    };
1082                    append_networking_json(
1083                        body_map,
1084                        container.ports(),
1085                        container.dns(),
1086                        container.host_aliases(),
1087                        container.network_order(),
1088                    );
1089                    body
1090                };
1091                if !append_mount_arguments(&mut cli_suffix, container.mounts()) {
1092                    return Err(RenderingFinding::new(
1093                        DiagnosticCode::RenderingUnsupported,
1094                        Some(id.clone()),
1095                        Some("mounts.cli_ambiguous"),
1096                    ));
1097                }
1098                if !append_secret_grants_arguments(&mut cli_suffix, container.secret_grants()) {
1099                    return Err(RenderingFinding::new(
1100                        DiagnosticCode::RenderingUnsupported,
1101                        Some(id.clone()),
1102                        Some("secret_grants.cli_ambiguous"),
1103                    ));
1104                }
1105                append_container_setting_arguments(&mut cli_suffix, container, id)?;
1106                append_container_runtime_arguments(&mut cli_suffix, container, id, version)?;
1107                let Some(body_map) = body.as_object_mut() else {
1108                    return Err(RenderingFinding::new(
1109                        DiagnosticCode::RenderingUnsupported,
1110                        Some(id.clone()),
1111                        Some("container_body"),
1112                    ));
1113                };
1114                append_container_setting_json(body_map, container, id)?;
1115                append_secret_grants_json(body_map, container.secret_grants());
1116                append_container_runtime_json(body_map, container, id, version)?;
1117                cli_suffix.push(image.to_owned());
1118                if let Some(command) = container.settings().command() {
1119                    cli_suffix.extend(command.values().iter().cloned());
1120                }
1121                (
1122                    RenderStatus::Exact,
1123                    cli_suffix,
1124                    RenderedHttpMethod::Post,
1125                    format!(
1126                        "/v{version}/libpod/containers/create?name={}",
1127                        percent_encode(container.identity().name())
1128                    ),
1129                    RenderedHttpBody::Json(body),
1130                    None,
1131                )
1132            }
1133        }
1134        DeploymentResource::ExternalPrecondition(_) => {
1135            return Err(RenderingFinding::new(
1136                DiagnosticCode::RenderingUnsupported,
1137                Some(id.clone()),
1138                Some("resource"),
1139            ));
1140        }
1141    };
1142    prefix.extend(cli_suffix);
1143    Ok(RenderedOperation {
1144        operation: operation.clone(),
1145        status,
1146        cli: CliInvocation::new(prefix, input),
1147        libpod: LibpodInvocation::new(method, path, body),
1148    })
1149}
1150
1151fn reviewed_renderer_target_version(plan: &DeploymentPlan) -> Option<String> {
1152    let engine = plan.target().podman_version().as_semver();
1153    let api = plan.target().api_version().as_semver();
1154    if engine != api || !engine.build.is_empty() || !api.build.is_empty() {
1155        return None;
1156    }
1157    let canonical = canonical_version(engine);
1158    renderer_catalogue_versions()
1159        .ok()?
1160        .into_iter()
1161        .find(|version| version == &canonical)
1162}
1163
1164fn renderer_catalogue_versions() -> Result<Vec<String>, ()> {
1165    parse_renderer_catalogue(RENDERING_CATALOGUE_JSON)
1166}
1167
1168fn parse_renderer_catalogue(source: &str) -> Result<Vec<String>, ()> {
1169    reject_duplicate_json_keys(source)?;
1170    let catalogue: RenderingCatalogue = serde_json::from_str(source).map_err(|_| ())?;
1171    let expected_operations = RENDERED_OPERATION_CATEGORIES.into_iter().collect::<BTreeSet<_>>();
1172    let capabilities = crate::capability_catalogue()
1173        .map_err(|_| ())?
1174        .into_iter()
1175        .filter(crate::CapabilityCatalogueEntry::output_supported)
1176        .collect::<Vec<_>>();
1177    if catalogue.schema_version != 8
1178        || catalogue.provenance.trim().is_empty()
1179        || catalogue.reviewed_lines.len() != capabilities.len()
1180        || !validated_runtime_field_claims(&catalogue.runtime_field_claims)
1181    {
1182        return Err(());
1183    }
1184    let mut versions = Vec::with_capacity(catalogue.reviewed_lines.len());
1185    for (line, capability) in catalogue.reviewed_lines.into_iter().zip(capabilities) {
1186        let version = Version::parse(&line.version).map_err(|_| ())?;
1187        if !version.pre.is_empty()
1188            || !version.build.is_empty()
1189            || line.version != canonical_version(&version)
1190            || line.version != capability.observed_podman_version()
1191            || line.revision != capability.evidence().revision()
1192            || line.tag != capability.evidence().release_tag()
1193            || !is_lowercase_sha40(&line.revision)
1194            || !valid_common_module(&line.version, &line.common_module)
1195            || !validated_renderer_operations(&line, &expected_operations)
1196            || !validated_b4_evidence(&line)
1197            || !validated_field_evidence(&line)
1198            || !validated_runtime_evidence(&line)
1199        {
1200            return Err(());
1201        }
1202        versions.push(line.version);
1203    }
1204    Ok(versions)
1205}
1206
1207fn validated_b4_evidence(line: &ReviewedRenderingLine) -> bool {
1208    let evidence = &line.b4_evidence;
1209    let exact = evidence.exact_fields.iter().copied().collect::<BTreeSet<_>>();
1210    let target_gated = evidence.target_gated_fields.iter().copied().collect::<BTreeSet<_>>();
1211    let manual = evidence.manual_fields.iter().copied().collect::<BTreeSet<_>>();
1212    let blocked = evidence.blocked_fields.iter().copied().collect::<BTreeSet<_>>();
1213    let expected_target_gated = expected_b4_target_gated_fields(&line.version);
1214    exact.len() == evidence.exact_fields.len()
1215        && target_gated.len() == evidence.target_gated_fields.len()
1216        && manual.len() == evidence.manual_fields.len()
1217        && blocked.len() == evidence.blocked_fields.len()
1218        && exact.is_disjoint(&target_gated)
1219        && exact.is_disjoint(&manual)
1220        && exact.is_disjoint(&blocked)
1221        && target_gated.is_disjoint(&manual)
1222        && target_gated.is_disjoint(&blocked)
1223        && manual.is_disjoint(&blocked)
1224        && exact.union(&target_gated).copied().collect::<BTreeSet<_>>() == expected_b4_rendered_fields()
1225        && target_gated == expected_target_gated
1226        && manual == expected_b4_manual_fields()
1227        && blocked == expected_b4_blocked_fields()
1228        && evidence.cli_sources.len() == 4
1229        && evidence
1230            .cli_sources
1231            .iter()
1232            .zip([
1233                "cmd/podman/common/create.go",
1234                "cmd/podman/containers/create.go",
1235                "cmd/podman/volumes/create.go",
1236                "cmd/podman/images/pull.go",
1237            ])
1238            .all(|(source, path)| source_matches_podman(source, &line.revision, path))
1239        && evidence.model_sources.len() == 3
1240        && evidence
1241            .model_sources
1242            .iter()
1243            .zip([
1244                "pkg/specgenutil/volumes.go",
1245                "pkg/specgen/specgen.go",
1246                "pkg/specgen/volumes.go",
1247            ])
1248            .all(|(source, path)| source_matches_podman(source, &line.revision, path))
1249        && evidence.handler_sources.len() == 3
1250        && evidence
1251            .handler_sources
1252            .iter()
1253            .zip([
1254                "pkg/api/handlers/libpod/containers_create.go",
1255                "pkg/api/handlers/libpod/volumes.go",
1256                "pkg/api/handlers/libpod/images_pull.go",
1257            ])
1258            .all(|(source, path)| source_matches_podman(source, &line.revision, path))
1259}
1260
1261fn expected_b4_rendered_fields() -> BTreeSet<B4RenderedField> {
1262    use B4RenderedField::{
1263        ContainerBindMount, ContainerNamedVolumeCopyMount, ContainerNamedVolumeNoCopyMount,
1264        ContainerNamedVolumeSubpathCopyMount, ContainerSecretEnvironmentGrant, ContainerSecretMountGrant,
1265        ContainerTmpfsMount, ImagePullPolicyAlways, ImagePullPolicyMissing, ImagePullPolicyNever, ImagePullPolicyNewer,
1266        VolumeGid, VolumeUid,
1267    };
1268    [
1269        ContainerNamedVolumeCopyMount,
1270        ContainerNamedVolumeSubpathCopyMount,
1271        ContainerNamedVolumeNoCopyMount,
1272        ContainerBindMount,
1273        ContainerTmpfsMount,
1274        ContainerSecretMountGrant,
1275        ContainerSecretEnvironmentGrant,
1276        VolumeUid,
1277        VolumeGid,
1278        ImagePullPolicyAlways,
1279        ImagePullPolicyMissing,
1280        ImagePullPolicyNever,
1281        ImagePullPolicyNewer,
1282    ]
1283    .into_iter()
1284    .collect()
1285}
1286
1287fn expected_b4_target_gated_fields(version: &str) -> BTreeSet<B4RenderedField> {
1288    if supports_image_policy(version) {
1289        BTreeSet::new()
1290    } else {
1291        use B4RenderedField::{
1292            ImagePullPolicyAlways, ImagePullPolicyMissing, ImagePullPolicyNever, ImagePullPolicyNewer, VolumeGid,
1293            VolumeUid,
1294        };
1295        [
1296            VolumeUid,
1297            VolumeGid,
1298            ImagePullPolicyAlways,
1299            ImagePullPolicyMissing,
1300            ImagePullPolicyNever,
1301            ImagePullPolicyNewer,
1302        ]
1303        .into_iter()
1304        .collect()
1305    }
1306}
1307
1308fn expected_b4_manual_fields() -> BTreeSet<B4RenderedField> {
1309    [B4RenderedField::ImageSourcePortability].into_iter().collect()
1310}
1311
1312fn expected_b4_blocked_fields() -> BTreeSet<B4RenderedField> {
1313    [
1314        B4RenderedField::ContainerNamedVolumeSubpathNoCopyMount,
1315        B4RenderedField::PodInfraMount,
1316    ]
1317    .into_iter()
1318    .collect()
1319}
1320
1321fn validated_runtime_field_claims(claims: &[RuntimeFieldClaimJson]) -> bool {
1322    let expected = expected_runtime_field_claims();
1323    let observed = claims.iter().map(|claim| claim.field).collect::<BTreeSet<_>>();
1324    claims.len() == expected.len()
1325        && observed.len() == claims.len()
1326        && observed == expected.keys().copied().collect()
1327        && claims.iter().all(|claim| {
1328            expected.get(&claim.field).is_some_and(|expected| {
1329                claim.cli.flag == Some(expected.0)
1330                    && claim.cli.value_shape == expected.1
1331                    && claim.libpod.json_member == expected.2
1332                    && claim.libpod.value_shape == expected_runtime_libpod_value_shape(claim.field)
1333            })
1334        })
1335}
1336
1337fn validated_runtime_evidence(line: &ReviewedRenderingLine) -> bool {
1338    let evidence = &line.runtime_evidence;
1339    let exact = evidence.exact_fields.iter().copied().collect::<BTreeSet<_>>();
1340    let target_gated = evidence.target_gated_fields.iter().copied().collect::<BTreeSet<_>>();
1341    let expected_target_gated = expected_runtime_target_gated_fields(&line.version);
1342    exact.len() == evidence.exact_fields.len()
1343        && target_gated.len() == evidence.target_gated_fields.len()
1344        && exact.is_disjoint(&target_gated)
1345        && exact.union(&target_gated).copied().collect::<BTreeSet<_>>() == expected_runtime_fields()
1346        && target_gated == expected_target_gated
1347        && source_matches_podman(&evidence.cli_flag_source, &line.revision, "cmd/podman/common/create.go")
1348        && source_matches_podman(
1349            &evidence.cli_transform_source,
1350            &line.revision,
1351            "pkg/specgenutil/specgen.go",
1352        )
1353        && source_matches_podman(
1354            &evidence.command_route_source,
1355            &line.revision,
1356            "cmd/podman/containers/create.go",
1357        )
1358        && source_matches_podman(
1359            &evidence.route_source,
1360            &line.revision,
1361            "pkg/api/server/register_containers.go",
1362        )
1363        && source_matches_podman(
1364            &evidence.handler_source,
1365            &line.revision,
1366            "pkg/api/handlers/libpod/containers_create.go",
1367        )
1368        && evidence.model_sources.len() == 3
1369        && evidence
1370            .model_sources
1371            .iter()
1372            .zip([
1373                "pkg/specgen/specgen.go",
1374                "pkg/specgen/namespaces.go",
1375                "libpod/define/healthchecks.go",
1376            ])
1377            .all(|(source, path)| source_matches_podman(source, &line.revision, path))
1378        && target_gated.iter().all(|field| {
1379            matches!(
1380                field,
1381                RuntimeRenderedField::ContainerLogJournaldLabels | RuntimeRenderedField::ContainerRlimitUnlimited
1382            )
1383        })
1384}
1385
1386fn expected_runtime_fields() -> BTreeSet<RuntimeRenderedField> {
1387    [
1388        RuntimeRenderedField::ContainerHealthDisabled,
1389        RuntimeRenderedField::ContainerHealthCommand,
1390        RuntimeRenderedField::ContainerHealthInterval,
1391        RuntimeRenderedField::ContainerHealthTimeout,
1392        RuntimeRenderedField::ContainerHealthRetries,
1393        RuntimeRenderedField::ContainerHealthStartPeriod,
1394        RuntimeRenderedField::ContainerHealthOnFailure,
1395        RuntimeRenderedField::ContainerStartupHealthCommand,
1396        RuntimeRenderedField::ContainerStartupHealthInterval,
1397        RuntimeRenderedField::ContainerStartupHealthTimeout,
1398        RuntimeRenderedField::ContainerStartupHealthRetries,
1399        RuntimeRenderedField::ContainerStartupHealthSuccesses,
1400        RuntimeRenderedField::ContainerLogDriver,
1401        RuntimeRenderedField::ContainerLogMaxSize,
1402        RuntimeRenderedField::ContainerLogJournaldLabels,
1403        RuntimeRenderedField::ContainerPrivileged,
1404        RuntimeRenderedField::ContainerCapabilityAdd,
1405        RuntimeRenderedField::ContainerCapabilityDrop,
1406        RuntimeRenderedField::ContainerNoNewPrivileges,
1407        RuntimeRenderedField::ContainerReadOnlyFilesystem,
1408        RuntimeRenderedField::ContainerReadWriteTmpfs,
1409        RuntimeRenderedField::ContainerPidNamespace,
1410        RuntimeRenderedField::ContainerIpcNamespace,
1411        RuntimeRenderedField::ContainerUtsNamespace,
1412        RuntimeRenderedField::ContainerCgroupNamespace,
1413        RuntimeRenderedField::ContainerCpuShares,
1414        RuntimeRenderedField::ContainerCpuPeriod,
1415        RuntimeRenderedField::ContainerCpuQuota,
1416        RuntimeRenderedField::ContainerMemoryLimit,
1417        RuntimeRenderedField::ContainerPidsLimit,
1418        RuntimeRenderedField::ContainerRlimitFinite,
1419        RuntimeRenderedField::ContainerRlimitUnlimited,
1420    ]
1421    .into_iter()
1422    .collect()
1423}
1424
1425fn expected_runtime_target_gated_fields(version: &str) -> BTreeSet<RuntimeRenderedField> {
1426    let mut fields = BTreeSet::new();
1427    if !supports_journald_labels(version) {
1428        fields.insert(RuntimeRenderedField::ContainerLogJournaldLabels);
1429    }
1430    if !supports_unlimited_rlimit(version) {
1431        fields.insert(RuntimeRenderedField::ContainerRlimitUnlimited);
1432    }
1433    fields
1434}
1435
1436type RuntimeFieldClaim = (CliFlag, CliValueShape, LibpodBodyMember);
1437
1438const fn expected_runtime_libpod_value_shape(field: RuntimeRenderedField) -> RuntimeLibpodValueShape {
1439    use RuntimeLibpodValueShape::{
1440        Boolean, HealthConfiguration, HealthOnFailureAction, Integer, Namespace, ResourceLimitInteger, RlimitArray,
1441        StartupHealthConfiguration, String, StringArray, StringMap,
1442    };
1443    use RuntimeRenderedField::{
1444        ContainerCapabilityAdd, ContainerCapabilityDrop, ContainerCgroupNamespace, ContainerCpuPeriod,
1445        ContainerCpuQuota, ContainerCpuShares, ContainerHealthCommand, ContainerHealthDisabled,
1446        ContainerHealthInterval, ContainerHealthOnFailure, ContainerHealthRetries, ContainerHealthStartPeriod,
1447        ContainerHealthTimeout, ContainerIpcNamespace, ContainerLogDriver, ContainerLogJournaldLabels,
1448        ContainerLogMaxSize, ContainerMemoryLimit, ContainerNoNewPrivileges, ContainerPidNamespace, ContainerPidsLimit,
1449        ContainerPrivileged, ContainerReadOnlyFilesystem, ContainerReadWriteTmpfs, ContainerRlimitFinite,
1450        ContainerRlimitUnlimited, ContainerStartupHealthCommand, ContainerStartupHealthInterval,
1451        ContainerStartupHealthRetries, ContainerStartupHealthSuccesses, ContainerStartupHealthTimeout,
1452        ContainerUtsNamespace,
1453    };
1454    match field {
1455        ContainerHealthDisabled
1456        | ContainerHealthCommand
1457        | ContainerHealthInterval
1458        | ContainerHealthTimeout
1459        | ContainerHealthRetries
1460        | ContainerHealthStartPeriod => HealthConfiguration,
1461        ContainerHealthOnFailure => HealthOnFailureAction,
1462        ContainerStartupHealthCommand
1463        | ContainerStartupHealthInterval
1464        | ContainerStartupHealthTimeout
1465        | ContainerStartupHealthRetries
1466        | ContainerStartupHealthSuccesses => StartupHealthConfiguration,
1467        ContainerLogDriver => String,
1468        ContainerLogMaxSize => Integer,
1469        ContainerLogJournaldLabels => StringMap,
1470        ContainerPrivileged | ContainerNoNewPrivileges | ContainerReadOnlyFilesystem | ContainerReadWriteTmpfs => {
1471            Boolean
1472        }
1473        ContainerCapabilityAdd | ContainerCapabilityDrop => StringArray,
1474        ContainerPidNamespace | ContainerIpcNamespace | ContainerUtsNamespace | ContainerCgroupNamespace => Namespace,
1475        ContainerCpuShares | ContainerCpuPeriod | ContainerCpuQuota | ContainerMemoryLimit | ContainerPidsLimit => {
1476            ResourceLimitInteger
1477        }
1478        ContainerRlimitFinite | ContainerRlimitUnlimited => RlimitArray,
1479    }
1480}
1481
1482#[allow(clippy::too_many_lines)] // The finite B3 claim matrix is intentionally audit-friendly.
1483fn expected_runtime_field_claims() -> BTreeMap<RuntimeRenderedField, RuntimeFieldClaim> {
1484    use RuntimeRenderedField::{
1485        ContainerCapabilityAdd, ContainerCapabilityDrop, ContainerCgroupNamespace, ContainerCpuPeriod,
1486        ContainerCpuQuota, ContainerCpuShares, ContainerHealthCommand, ContainerHealthDisabled,
1487        ContainerHealthInterval, ContainerHealthOnFailure, ContainerHealthRetries, ContainerHealthStartPeriod,
1488        ContainerHealthTimeout, ContainerIpcNamespace, ContainerLogDriver, ContainerLogJournaldLabels,
1489        ContainerLogMaxSize, ContainerMemoryLimit, ContainerNoNewPrivileges, ContainerPidNamespace, ContainerPidsLimit,
1490        ContainerPrivileged, ContainerReadOnlyFilesystem, ContainerReadWriteTmpfs, ContainerRlimitFinite,
1491        ContainerRlimitUnlimited, ContainerStartupHealthCommand, ContainerStartupHealthInterval,
1492        ContainerStartupHealthRetries, ContainerStartupHealthSuccesses, ContainerStartupHealthTimeout,
1493        ContainerUtsNamespace,
1494    };
1495    [
1496        (
1497            ContainerHealthDisabled,
1498            (
1499                CliFlag::NoHealthcheck,
1500                CliValueShape::FlagOnly,
1501                LibpodBodyMember::Healthconfig,
1502            ),
1503        ),
1504        (
1505            ContainerHealthCommand,
1506            (
1507                CliFlag::HealthCommand,
1508                CliValueShape::HealthCommand,
1509                LibpodBodyMember::Healthconfig,
1510            ),
1511        ),
1512        (
1513            ContainerHealthInterval,
1514            (
1515                CliFlag::HealthInterval,
1516                CliValueShape::HealthInterval,
1517                LibpodBodyMember::Healthconfig,
1518            ),
1519        ),
1520        (
1521            ContainerHealthTimeout,
1522            (
1523                CliFlag::HealthTimeout,
1524                CliValueShape::HealthDuration,
1525                LibpodBodyMember::Healthconfig,
1526            ),
1527        ),
1528        (
1529            ContainerHealthRetries,
1530            (
1531                CliFlag::HealthRetries,
1532                CliValueShape::HealthRetries,
1533                LibpodBodyMember::Healthconfig,
1534            ),
1535        ),
1536        (
1537            ContainerHealthStartPeriod,
1538            (
1539                CliFlag::HealthStartPeriod,
1540                CliValueShape::HealthDuration,
1541                LibpodBodyMember::Healthconfig,
1542            ),
1543        ),
1544        (
1545            ContainerHealthOnFailure,
1546            (
1547                CliFlag::HealthOnFailure,
1548                CliValueShape::HealthOnFailure,
1549                LibpodBodyMember::HealthCheckOnFailureAction,
1550            ),
1551        ),
1552        (
1553            ContainerStartupHealthCommand,
1554            (
1555                CliFlag::HealthStartupCommand,
1556                CliValueShape::HealthCommand,
1557                LibpodBodyMember::StartupHealthConfig,
1558            ),
1559        ),
1560        (
1561            ContainerStartupHealthInterval,
1562            (
1563                CliFlag::HealthStartupInterval,
1564                CliValueShape::HealthInterval,
1565                LibpodBodyMember::StartupHealthConfig,
1566            ),
1567        ),
1568        (
1569            ContainerStartupHealthTimeout,
1570            (
1571                CliFlag::HealthStartupTimeout,
1572                CliValueShape::HealthDuration,
1573                LibpodBodyMember::StartupHealthConfig,
1574            ),
1575        ),
1576        (
1577            ContainerStartupHealthRetries,
1578            (
1579                CliFlag::HealthStartupRetries,
1580                CliValueShape::HealthRetries,
1581                LibpodBodyMember::StartupHealthConfig,
1582            ),
1583        ),
1584        (
1585            ContainerStartupHealthSuccesses,
1586            (
1587                CliFlag::HealthStartupSuccess,
1588                CliValueShape::StartupHealthSuccesses,
1589                LibpodBodyMember::StartupHealthConfig,
1590            ),
1591        ),
1592        (
1593            ContainerLogDriver,
1594            (
1595                CliFlag::LogDriver,
1596                CliValueShape::LogDriver,
1597                LibpodBodyMember::LogConfigurationDriver,
1598            ),
1599        ),
1600        (
1601            ContainerLogMaxSize,
1602            (
1603                CliFlag::LogOption,
1604                CliValueShape::LogMaxSize,
1605                LibpodBodyMember::LogConfigurationSize,
1606            ),
1607        ),
1608        (
1609            ContainerLogJournaldLabels,
1610            (
1611                CliFlag::LogOption,
1612                CliValueShape::JournaldLabel,
1613                LibpodBodyMember::LogConfigurationLabels,
1614            ),
1615        ),
1616        (
1617            ContainerPrivileged,
1618            (
1619                CliFlag::Privileged,
1620                CliValueShape::Boolean,
1621                LibpodBodyMember::Privileged,
1622            ),
1623        ),
1624        (
1625            ContainerCapabilityAdd,
1626            (
1627                CliFlag::CapabilityAdd,
1628                CliValueShape::Capability,
1629                LibpodBodyMember::CapAdd,
1630            ),
1631        ),
1632        (
1633            ContainerCapabilityDrop,
1634            (
1635                CliFlag::CapabilityDrop,
1636                CliValueShape::Capability,
1637                LibpodBodyMember::CapDrop,
1638            ),
1639        ),
1640        (
1641            ContainerNoNewPrivileges,
1642            (
1643                CliFlag::SecurityOption,
1644                CliValueShape::NoNewPrivileges,
1645                LibpodBodyMember::NoNewPrivileges,
1646            ),
1647        ),
1648        (
1649            ContainerReadOnlyFilesystem,
1650            (
1651                CliFlag::ReadOnly,
1652                CliValueShape::Boolean,
1653                LibpodBodyMember::ReadOnlyFilesystem,
1654            ),
1655        ),
1656        (
1657            ContainerReadWriteTmpfs,
1658            (
1659                CliFlag::ReadOnlyTmpfs,
1660                CliValueShape::Boolean,
1661                LibpodBodyMember::ReadWriteTmpfs,
1662            ),
1663        ),
1664        (
1665            ContainerPidNamespace,
1666            (
1667                CliFlag::PidNamespace,
1668                CliValueShape::NamespaceMode,
1669                LibpodBodyMember::Pidns,
1670            ),
1671        ),
1672        (
1673            ContainerIpcNamespace,
1674            (
1675                CliFlag::IpcNamespace,
1676                CliValueShape::NamespaceMode,
1677                LibpodBodyMember::Ipcns,
1678            ),
1679        ),
1680        (
1681            ContainerUtsNamespace,
1682            (
1683                CliFlag::UtsNamespace,
1684                CliValueShape::NamespaceMode,
1685                LibpodBodyMember::Utsns,
1686            ),
1687        ),
1688        (
1689            ContainerCgroupNamespace,
1690            (
1691                CliFlag::CgroupNamespace,
1692                CliValueShape::NamespaceMode,
1693                LibpodBodyMember::Cgroupns,
1694            ),
1695        ),
1696        (
1697            ContainerCpuShares,
1698            (
1699                CliFlag::CpuShares,
1700                CliValueShape::CpuShares,
1701                LibpodBodyMember::ResourceLimitsCpuShares,
1702            ),
1703        ),
1704        (
1705            ContainerCpuPeriod,
1706            (
1707                CliFlag::CpuPeriod,
1708                CliValueShape::CpuPeriod,
1709                LibpodBodyMember::ResourceLimitsCpuPeriod,
1710            ),
1711        ),
1712        (
1713            ContainerCpuQuota,
1714            (
1715                CliFlag::CpuQuota,
1716                CliValueShape::CpuQuota,
1717                LibpodBodyMember::ResourceLimitsCpuQuota,
1718            ),
1719        ),
1720        (
1721            ContainerMemoryLimit,
1722            (
1723                CliFlag::Memory,
1724                CliValueShape::MemoryBytes,
1725                LibpodBodyMember::ResourceLimitsMemoryLimit,
1726            ),
1727        ),
1728        (
1729            ContainerPidsLimit,
1730            (
1731                CliFlag::PidsLimit,
1732                CliValueShape::PidsLimit,
1733                LibpodBodyMember::ResourceLimitsPidsLimit,
1734            ),
1735        ),
1736        (
1737            ContainerRlimitFinite,
1738            (CliFlag::Ulimit, CliValueShape::Rlimit, LibpodBodyMember::RLimits),
1739        ),
1740        (
1741            ContainerRlimitUnlimited,
1742            (CliFlag::Ulimit, CliValueShape::Rlimit, LibpodBodyMember::RLimits),
1743        ),
1744    ]
1745    .into_iter()
1746    .collect()
1747}
1748
1749fn reject_duplicate_json_keys(source: &str) -> Result<(), ()> {
1750    let mut deserializer = serde_json::Deserializer::from_str(source);
1751    RejectDuplicateJsonKeys::deserialize(&mut deserializer).map_err(|_| ())?;
1752    deserializer.end().map_err(|_| ())
1753}
1754
1755struct RejectDuplicateJsonKeys;
1756
1757impl<'de> Deserialize<'de> for RejectDuplicateJsonKeys {
1758    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1759    where
1760        D: serde::Deserializer<'de>,
1761    {
1762        deserializer.deserialize_any(RejectDuplicateJsonKeysVisitor)
1763    }
1764}
1765
1766struct RejectDuplicateJsonKeysVisitor;
1767
1768impl<'de> de::Visitor<'de> for RejectDuplicateJsonKeysVisitor {
1769    type Value = RejectDuplicateJsonKeys;
1770
1771    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1772        formatter.write_str("a JSON value without duplicate object keys")
1773    }
1774
1775    fn visit_bool<E>(self, _value: bool) -> Result<Self::Value, E>
1776    where
1777        E: de::Error,
1778    {
1779        Ok(RejectDuplicateJsonKeys)
1780    }
1781
1782    fn visit_i64<E>(self, _value: i64) -> Result<Self::Value, E>
1783    where
1784        E: de::Error,
1785    {
1786        Ok(RejectDuplicateJsonKeys)
1787    }
1788
1789    fn visit_u64<E>(self, _value: u64) -> Result<Self::Value, E>
1790    where
1791        E: de::Error,
1792    {
1793        Ok(RejectDuplicateJsonKeys)
1794    }
1795
1796    fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E>
1797    where
1798        E: de::Error,
1799    {
1800        Ok(RejectDuplicateJsonKeys)
1801    }
1802
1803    fn visit_str<E>(self, _value: &str) -> Result<Self::Value, E>
1804    where
1805        E: de::Error,
1806    {
1807        Ok(RejectDuplicateJsonKeys)
1808    }
1809
1810    fn visit_string<E>(self, _value: String) -> Result<Self::Value, E>
1811    where
1812        E: de::Error,
1813    {
1814        Ok(RejectDuplicateJsonKeys)
1815    }
1816
1817    fn visit_none<E>(self) -> Result<Self::Value, E>
1818    where
1819        E: de::Error,
1820    {
1821        Ok(RejectDuplicateJsonKeys)
1822    }
1823
1824    fn visit_unit<E>(self) -> Result<Self::Value, E>
1825    where
1826        E: de::Error,
1827    {
1828        Ok(RejectDuplicateJsonKeys)
1829    }
1830
1831    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1832    where
1833        A: de::SeqAccess<'de>,
1834    {
1835        while sequence.next_element::<RejectDuplicateJsonKeys>()?.is_some() {}
1836        Ok(RejectDuplicateJsonKeys)
1837    }
1838
1839    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1840    where
1841        A: de::MapAccess<'de>,
1842    {
1843        let mut keys = HashSet::new();
1844        while let Some(key) = map.next_key::<String>()? {
1845            if !keys.insert(key) {
1846                return Err(de::Error::custom("duplicate JSON object key"));
1847            }
1848            map.next_value::<RejectDuplicateJsonKeys>()?;
1849        }
1850        Ok(RejectDuplicateJsonKeys)
1851    }
1852}
1853
1854fn validated_renderer_operations(line: &ReviewedRenderingLine, expected_categories: &BTreeSet<&str>) -> bool {
1855    let observed_categories = line
1856        .operations
1857        .iter()
1858        .map(|operation| operation.category.as_str())
1859        .collect::<BTreeSet<_>>();
1860    line.operations.len() == expected_categories.len()
1861        && observed_categories == expected_categories.clone()
1862        && line.operations.iter().all(|operation| {
1863            let Some((cli_path, libpod_path, body_path)) = rendering_source_paths(operation.category.as_str()) else {
1864                return false;
1865            };
1866            source_matches_podman(&operation.cli_source, &line.revision, cli_path)
1867                && source_matches_podman(&operation.libpod_endpoint_source, &line.revision, libpod_path)
1868                && match body_path {
1869                    Some(body_path) => operation
1870                        .body_source
1871                        .as_ref()
1872                        .is_some_and(|source| source_matches_podman(source, &line.revision, body_path)),
1873                    None => operation.body_source.is_none(),
1874                }
1875        })
1876}
1877
1878fn validated_field_evidence(line: &ReviewedRenderingLine) -> bool {
1879    let observed = line
1880        .field_evidence
1881        .iter()
1882        .map(|evidence| evidence.field)
1883        .collect::<BTreeSet<_>>();
1884    line.field_evidence.len() == expected_rendered_fields().len()
1885        && observed == expected_rendered_fields()
1886        && line.field_evidence.iter().all(|evidence| {
1887            if evidence.availability != expected_field_availability(evidence.field, &line.version) {
1888                return false;
1889            }
1890            if let Some((operation, flag, shape, member, cli_source, model_sources, handler_source)) =
1891                expected_network_field_claim(evidence.field)
1892            {
1893                return evidence.operation == operation
1894                    && evidence.cli.flag == flag
1895                    && evidence.cli.value_shape == shape
1896                    && evidence.libpod.json_member == member
1897                    && source_matches_network_evidence(&evidence.cli_source, line, cli_source)
1898                    && evidence.model_sources.len() == model_sources.len()
1899                    && evidence
1900                        .model_sources
1901                        .iter()
1902                        .zip(model_sources)
1903                        .all(|(source, expected)| source_matches_network_evidence(source, line, *expected))
1904                    && source_matches_network_evidence(&evidence.handler_source, line, handler_source);
1905            }
1906            let (operation, flag, shape, member, cli_path, model_paths, handler_path) =
1907                expected_field_claim(evidence.field);
1908            evidence.operation == operation
1909                && evidence.cli.flag == flag
1910                && evidence.cli.value_shape == shape
1911                && evidence.libpod.json_member == member
1912                && source_matches_podman(&evidence.cli_source, &line.revision, cli_path)
1913                && evidence.model_sources.len() == model_paths.len()
1914                && evidence
1915                    .model_sources
1916                    .iter()
1917                    .zip(model_paths)
1918                    .all(|(source, path)| source_matches_podman(source, &line.revision, path))
1919                && source_matches_podman(&evidence.handler_source, &line.revision, handler_path)
1920        })
1921}
1922
1923fn expected_field_availability(field: RenderedField, version: &str) -> FieldAvailability {
1924    match field {
1925        RenderedField::ContainerNetworkOrder
1926        | RenderedField::NetworkRouteTypeBlackhole
1927        | RenderedField::NetworkRouteTypeUnreachable
1928        | RenderedField::NetworkRouteTypeProhibit
1929            if !supports_network_order(version) =>
1930        {
1931            FieldAvailability::Unsupported
1932        }
1933        _ => FieldAvailability::Exact,
1934    }
1935}
1936
1937fn expected_rendered_fields() -> BTreeSet<RenderedField> {
1938    [
1939        RenderedField::ContainerCommand,
1940        RenderedField::ContainerEntrypoint,
1941        RenderedField::ContainerUser,
1942        RenderedField::ContainerWorkdir,
1943        RenderedField::ContainerHostname,
1944        RenderedField::ContainerLabel,
1945        RenderedField::ContainerEnvironment,
1946        RenderedField::ContainerRestartPolicy,
1947        RenderedField::ContainerNamedVolumeMount,
1948        RenderedField::ContainerNetworkAttachment,
1949        RenderedField::PodNetworkAttachment,
1950        RenderedField::ContainerNetworkAlias,
1951        RenderedField::PodNetworkAlias,
1952        RenderedField::ContainerNetworkStaticIpv4,
1953        RenderedField::PodNetworkStaticIpv4,
1954        RenderedField::ContainerNetworkStaticIpv6,
1955        RenderedField::PodNetworkStaticIpv6,
1956        RenderedField::ContainerNetworkStaticMac,
1957        RenderedField::PodNetworkStaticMac,
1958        RenderedField::ContainerPortMapping,
1959        RenderedField::PodPortMapping,
1960        RenderedField::ContainerDnsServers,
1961        RenderedField::ContainerDnsSearch,
1962        RenderedField::ContainerDnsOptions,
1963        RenderedField::PodDnsServers,
1964        RenderedField::PodDnsSearch,
1965        RenderedField::PodDnsOptions,
1966        RenderedField::ContainerHostAlias,
1967        RenderedField::PodHostAlias,
1968        RenderedField::ContainerNetworkOrder,
1969        RenderedField::NetworkIpamSubnet,
1970        RenderedField::NetworkIpamGateway,
1971        RenderedField::NetworkIpamRange,
1972        RenderedField::NetworkRouteDestination,
1973        RenderedField::NetworkRouteGateway,
1974        RenderedField::NetworkRouteMetric,
1975        RenderedField::NetworkRouteTypeUnicast,
1976        RenderedField::NetworkRouteTypeBlackhole,
1977        RenderedField::NetworkRouteTypeUnreachable,
1978        RenderedField::NetworkRouteTypeProhibit,
1979    ]
1980    .into_iter()
1981    .collect()
1982}
1983
1984#[allow(clippy::too_many_lines)]
1985fn expected_field_claim(
1986    field: RenderedField,
1987) -> (
1988    RenderingOperationCategory,
1989    Option<CliFlag>,
1990    CliValueShape,
1991    LibpodBodyMember,
1992    &'static str,
1993    &'static [&'static str],
1994    &'static str,
1995) {
1996    const CONTAINER_MODEL: &[&str] = &["pkg/specgen/specgen.go"];
1997    const CONTAINER_VOLUME_MODEL: &[&str] = &["pkg/specgen/specgen.go", "pkg/specgen/volumes.go"];
1998    const CONTAINER_HANDLER: &str = "pkg/api/handlers/libpod/containers_create.go";
1999    match field {
2000        RenderedField::ContainerCommand => (
2001            RenderingOperationCategory::ContainerCreate,
2002            None,
2003            CliValueShape::ArgumentArray,
2004            LibpodBodyMember::Command,
2005            "cmd/podman/containers/create.go",
2006            CONTAINER_MODEL,
2007            CONTAINER_HANDLER,
2008        ),
2009        RenderedField::ContainerEntrypoint => (
2010            RenderingOperationCategory::ContainerCreate,
2011            Some(CliFlag::Entrypoint),
2012            CliValueShape::ArgumentArray,
2013            LibpodBodyMember::Entrypoint,
2014            "cmd/podman/common/create.go",
2015            CONTAINER_MODEL,
2016            CONTAINER_HANDLER,
2017        ),
2018        RenderedField::ContainerUser => (
2019            RenderingOperationCategory::ContainerCreate,
2020            Some(CliFlag::User),
2021            CliValueShape::ContainerUser,
2022            LibpodBodyMember::User,
2023            "cmd/podman/common/create.go",
2024            CONTAINER_MODEL,
2025            CONTAINER_HANDLER,
2026        ),
2027        RenderedField::ContainerWorkdir => (
2028            RenderingOperationCategory::ContainerCreate,
2029            Some(CliFlag::Workdir),
2030            CliValueShape::AbsoluteContainerPath,
2031            LibpodBodyMember::WorkDir,
2032            "cmd/podman/common/create.go",
2033            CONTAINER_MODEL,
2034            CONTAINER_HANDLER,
2035        ),
2036        RenderedField::ContainerHostname => (
2037            RenderingOperationCategory::ContainerCreate,
2038            Some(CliFlag::Hostname),
2039            CliValueShape::Hostname,
2040            LibpodBodyMember::Hostname,
2041            "cmd/podman/common/create.go",
2042            CONTAINER_MODEL,
2043            CONTAINER_HANDLER,
2044        ),
2045        RenderedField::ContainerLabel => (
2046            RenderingOperationCategory::ContainerCreate,
2047            Some(CliFlag::Label),
2048            CliValueShape::LabelAssignment,
2049            LibpodBodyMember::Labels,
2050            "cmd/podman/common/create.go",
2051            CONTAINER_MODEL,
2052            CONTAINER_HANDLER,
2053        ),
2054        RenderedField::ContainerEnvironment => (
2055            RenderingOperationCategory::ContainerCreate,
2056            Some(CliFlag::Environment),
2057            CliValueShape::EnvironmentAssignment,
2058            LibpodBodyMember::Env,
2059            "cmd/podman/common/create.go",
2060            CONTAINER_MODEL,
2061            CONTAINER_HANDLER,
2062        ),
2063        RenderedField::ContainerRestartPolicy => (
2064            RenderingOperationCategory::ContainerCreate,
2065            Some(CliFlag::Restart),
2066            CliValueShape::RestartPolicy,
2067            LibpodBodyMember::RestartPolicy,
2068            "cmd/podman/common/create.go",
2069            CONTAINER_MODEL,
2070            CONTAINER_HANDLER,
2071        ),
2072        RenderedField::ContainerNamedVolumeMount => (
2073            RenderingOperationCategory::ContainerCreate,
2074            Some(CliFlag::Volume),
2075            CliValueShape::NamedVolumeMount,
2076            LibpodBodyMember::Volumes,
2077            "cmd/podman/common/create.go",
2078            CONTAINER_VOLUME_MODEL,
2079            CONTAINER_HANDLER,
2080        ),
2081        _ => unreachable!("networking fields are handled by the dedicated evidence matrix"),
2082    }
2083}
2084
2085#[derive(Clone, Copy)]
2086enum ExpectedNetworkSource {
2087    Podman(&'static str),
2088    Common,
2089}
2090
2091type NetworkFieldClaim = (
2092    RenderingOperationCategory,
2093    Option<CliFlag>,
2094    CliValueShape,
2095    LibpodBodyMember,
2096    ExpectedNetworkSource,
2097    &'static [ExpectedNetworkSource],
2098    ExpectedNetworkSource,
2099);
2100
2101#[allow(clippy::too_many_lines)] // The finite M6-B2 field matrix is deliberately audit-friendly.
2102fn expected_network_field_claim(field: RenderedField) -> Option<NetworkFieldClaim> {
2103    const CONTAINER_NETWORK: &[ExpectedNetworkSource] = &[
2104        ExpectedNetworkSource::Podman("pkg/specgen/specgen.go"),
2105        ExpectedNetworkSource::Common,
2106    ];
2107    const POD_NETWORK: &[ExpectedNetworkSource] = &[
2108        ExpectedNetworkSource::Podman("pkg/specgen/podspecgen.go"),
2109        ExpectedNetworkSource::Common,
2110    ];
2111    const CONTAINER_NETWORK_CONFIG: &[ExpectedNetworkSource] =
2112        &[ExpectedNetworkSource::Podman("pkg/specgen/specgen.go")];
2113    const POD_NETWORK_CONFIG: &[ExpectedNetworkSource] = &[ExpectedNetworkSource::Podman("pkg/specgen/podspecgen.go")];
2114    const CONTAINER_NETWORK_ORDER: &[ExpectedNetworkSource] = &[
2115        ExpectedNetworkSource::Podman("pkg/specgen/specgen.go"),
2116        ExpectedNetworkSource::Podman("pkg/specgen/namespaces.go"),
2117    ];
2118    const NETWORK: &[ExpectedNetworkSource] = &[ExpectedNetworkSource::Common];
2119    const CONTAINER_HANDLER: ExpectedNetworkSource =
2120        ExpectedNetworkSource::Podman("pkg/api/handlers/libpod/containers_create.go");
2121    const POD_HANDLER: ExpectedNetworkSource = ExpectedNetworkSource::Podman("pkg/api/handlers/libpod/pods.go");
2122    const NETWORK_HANDLER: ExpectedNetworkSource = ExpectedNetworkSource::Podman("pkg/api/handlers/libpod/networks.go");
2123    let container_network = |shape| {
2124        (
2125            RenderingOperationCategory::ContainerCreate,
2126            Some(CliFlag::Network),
2127            shape,
2128            LibpodBodyMember::Networks,
2129            ExpectedNetworkSource::Podman("cmd/podman/common/netflags.go"),
2130            CONTAINER_NETWORK,
2131            CONTAINER_HANDLER,
2132        )
2133    };
2134    let pod_network = |shape| {
2135        (
2136            RenderingOperationCategory::PodCreate,
2137            Some(CliFlag::Network),
2138            shape,
2139            LibpodBodyMember::Networks,
2140            ExpectedNetworkSource::Podman("cmd/podman/pods/create.go"),
2141            POD_NETWORK,
2142            POD_HANDLER,
2143        )
2144    };
2145    let container_network_config = |flag, shape, member| {
2146        (
2147            RenderingOperationCategory::ContainerCreate,
2148            Some(flag),
2149            shape,
2150            member,
2151            ExpectedNetworkSource::Podman("cmd/podman/common/netflags.go"),
2152            CONTAINER_NETWORK_CONFIG,
2153            CONTAINER_HANDLER,
2154        )
2155    };
2156    let pod_network_config = |flag, shape, member| {
2157        (
2158            RenderingOperationCategory::PodCreate,
2159            Some(flag),
2160            shape,
2161            member,
2162            ExpectedNetworkSource::Podman("cmd/podman/pods/create.go"),
2163            POD_NETWORK_CONFIG,
2164            POD_HANDLER,
2165        )
2166    };
2167    let network_create = |flag, shape, member| {
2168        (
2169            RenderingOperationCategory::NetworkCreate,
2170            Some(flag),
2171            shape,
2172            member,
2173            ExpectedNetworkSource::Podman("cmd/podman/networks/create.go"),
2174            NETWORK,
2175            NETWORK_HANDLER,
2176        )
2177    };
2178    Some(match field {
2179        RenderedField::ContainerNetworkAttachment => container_network(CliValueShape::NetworkAttachment),
2180        RenderedField::PodNetworkAttachment => pod_network(CliValueShape::NetworkAttachment),
2181        RenderedField::ContainerNetworkAlias => container_network(CliValueShape::NetworkAlias),
2182        RenderedField::PodNetworkAlias => pod_network(CliValueShape::NetworkAlias),
2183        RenderedField::ContainerNetworkStaticIpv4 | RenderedField::ContainerNetworkStaticIpv6 => {
2184            container_network(CliValueShape::StaticIp)
2185        }
2186        RenderedField::PodNetworkStaticIpv4 | RenderedField::PodNetworkStaticIpv6 => {
2187            pod_network(CliValueShape::StaticIp)
2188        }
2189        RenderedField::ContainerNetworkStaticMac => container_network(CliValueShape::StaticMac),
2190        RenderedField::PodNetworkStaticMac => pod_network(CliValueShape::StaticMac),
2191        RenderedField::ContainerPortMapping => (
2192            RenderingOperationCategory::ContainerCreate,
2193            Some(CliFlag::Publish),
2194            CliValueShape::PortMapping,
2195            LibpodBodyMember::Portmappings,
2196            ExpectedNetworkSource::Podman("cmd/podman/common/netflags.go"),
2197            CONTAINER_NETWORK,
2198            CONTAINER_HANDLER,
2199        ),
2200        RenderedField::PodPortMapping => (
2201            RenderingOperationCategory::PodCreate,
2202            Some(CliFlag::Publish),
2203            CliValueShape::PortMapping,
2204            LibpodBodyMember::Portmappings,
2205            ExpectedNetworkSource::Podman("cmd/podman/pods/create.go"),
2206            POD_NETWORK,
2207            POD_HANDLER,
2208        ),
2209        RenderedField::ContainerDnsServers => {
2210            container_network_config(CliFlag::Dns, CliValueShape::DnsServer, LibpodBodyMember::DnsServer)
2211        }
2212        RenderedField::ContainerDnsSearch => container_network_config(
2213            CliFlag::DnsSearch,
2214            CliValueShape::DnsSearch,
2215            LibpodBodyMember::DnsSearch,
2216        ),
2217        RenderedField::ContainerDnsOptions => container_network_config(
2218            CliFlag::DnsOption,
2219            CliValueShape::DnsOption,
2220            LibpodBodyMember::DnsOption,
2221        ),
2222        RenderedField::PodDnsServers => {
2223            pod_network_config(CliFlag::Dns, CliValueShape::DnsServer, LibpodBodyMember::DnsServer)
2224        }
2225        RenderedField::PodDnsSearch => pod_network_config(
2226            CliFlag::DnsSearch,
2227            CliValueShape::DnsSearch,
2228            LibpodBodyMember::DnsSearch,
2229        ),
2230        RenderedField::PodDnsOptions => pod_network_config(
2231            CliFlag::DnsOption,
2232            CliValueShape::DnsOption,
2233            LibpodBodyMember::DnsOption,
2234        ),
2235        RenderedField::ContainerHostAlias => {
2236            container_network_config(CliFlag::AddHost, CliValueShape::HostAlias, LibpodBodyMember::Hostadd)
2237        }
2238        RenderedField::PodHostAlias => {
2239            pod_network_config(CliFlag::AddHost, CliValueShape::HostAlias, LibpodBodyMember::Hostadd)
2240        }
2241        RenderedField::ContainerNetworkOrder => (
2242            RenderingOperationCategory::ContainerCreate,
2243            Some(CliFlag::Network),
2244            CliValueShape::NetworkOrder,
2245            LibpodBodyMember::NetworkOrder,
2246            ExpectedNetworkSource::Podman("cmd/podman/common/netflags.go"),
2247            CONTAINER_NETWORK_ORDER,
2248            CONTAINER_HANDLER,
2249        ),
2250        RenderedField::NetworkIpamSubnet => {
2251            network_create(CliFlag::Subnet, CliValueShape::NetworkSubnet, LibpodBodyMember::Subnets)
2252        }
2253        RenderedField::NetworkIpamGateway => network_create(
2254            CliFlag::Gateway,
2255            CliValueShape::NetworkGateway,
2256            LibpodBodyMember::Subnets,
2257        ),
2258        RenderedField::NetworkIpamRange => network_create(
2259            CliFlag::IpRange,
2260            CliValueShape::NetworkIpRange,
2261            LibpodBodyMember::Subnets,
2262        ),
2263        RenderedField::NetworkRouteDestination
2264        | RenderedField::NetworkRouteGateway
2265        | RenderedField::NetworkRouteMetric
2266        | RenderedField::NetworkRouteTypeUnicast
2267        | RenderedField::NetworkRouteTypeBlackhole
2268        | RenderedField::NetworkRouteTypeUnreachable
2269        | RenderedField::NetworkRouteTypeProhibit => {
2270            network_create(CliFlag::Route, CliValueShape::NetworkRoute, LibpodBodyMember::Routes)
2271        }
2272        _ => return None,
2273    })
2274}
2275
2276fn source_matches_network_evidence(
2277    source: &SourceReference,
2278    line: &ReviewedRenderingLine,
2279    expected: ExpectedNetworkSource,
2280) -> bool {
2281    match expected {
2282        ExpectedNetworkSource::Podman(path) => source_matches_podman(source, &line.revision, path),
2283        ExpectedNetworkSource::Common => {
2284            source.repository == line.common_module.repository
2285                && source.revision == line.common_module.revision
2286                && source.path == common_model_source_path(line)
2287                && source.module.as_ref() == Some(&line.common_module)
2288                && is_lowercase_sha40(&source.revision)
2289        }
2290    }
2291}
2292
2293fn common_model_source_path(line: &ReviewedRenderingLine) -> &'static str {
2294    match line.version.as_str() {
2295        "5.4.0" | "5.5.0" | "5.6.0" => "libnetwork/types/network.go",
2296        "5.7.0" | "5.8.6" | "6.0.0" | "6.1.0" => "common/libnetwork/types/network.go",
2297        _ => unreachable!("reviewed renderer versions are validated before field evidence"),
2298    }
2299}
2300
2301fn rendering_source_paths(category: &str) -> Option<(&'static str, &'static str, Option<&'static str>)> {
2302    match category {
2303        "network-create" => Some((
2304            "cmd/podman/networks/create.go",
2305            "pkg/api/server/register_networks.go",
2306            Some("pkg/api/handlers/libpod/networks.go"),
2307        )),
2308        "volume-create" => Some((
2309            "cmd/podman/volumes/create.go",
2310            "pkg/api/server/register_volumes.go",
2311            Some("pkg/api/handlers/libpod/volumes.go"),
2312        )),
2313        "secret-create" => Some((
2314            "cmd/podman/secrets/create.go",
2315            "pkg/api/server/register_secrets.go",
2316            Some("pkg/api/handlers/libpod/secrets.go"),
2317        )),
2318        "image-pull" => Some(("cmd/podman/images/pull.go", "pkg/api/server/register_images.go", None)),
2319        "pod-create" => Some((
2320            "cmd/podman/pods/create.go",
2321            "pkg/api/server/register_pods.go",
2322            Some("pkg/api/handlers/libpod/pods.go"),
2323        )),
2324        "container-create" => Some((
2325            "cmd/podman/containers/create.go",
2326            "pkg/api/server/register_containers.go",
2327            Some("pkg/api/handlers/libpod/containers_create.go"),
2328        )),
2329        "pod-start" => Some(("cmd/podman/pods/start.go", "pkg/api/server/register_pods.go", None)),
2330        "container-start" => Some((
2331            "cmd/podman/containers/start.go",
2332            "pkg/api/server/register_containers.go",
2333            None,
2334        )),
2335        _ => None,
2336    }
2337}
2338
2339fn source_matches_podman(source: &SourceReference, revision: &str, path: &str) -> bool {
2340    source.repository == SourceRepository::ContainersPodman
2341        && source.revision == revision
2342        && source.path == path
2343        && source.module.is_none()
2344        && is_lowercase_sha40(&source.revision)
2345}
2346
2347fn valid_common_module(version: &str, module: &ModulePin) -> bool {
2348    matches!(
2349        (
2350            version,
2351            module.repository,
2352            module.path.as_str(),
2353            module.version.as_str(),
2354            module.revision.as_str()
2355        ),
2356        (
2357            "5.4.0",
2358            SourceRepository::ContainersCommon,
2359            "github.com/containers/common",
2360            "v0.62.0",
2361            "cde1afdf623bdb9595d1fdc7acffa6e2f03a06b2"
2362        ) | (
2363            "5.5.0",
2364            SourceRepository::ContainersCommon,
2365            "github.com/containers/common",
2366            "v0.63.0",
2367            "92927328862e4837d87bcbc0725b713387399984"
2368        ) | (
2369            "5.6.0",
2370            SourceRepository::ContainersCommon,
2371            "github.com/containers/common",
2372            "v0.64.1",
2373            "c007f37a6c55a53a7d41419d094c815bf2e46ca3"
2374        ) | (
2375            "5.7.0",
2376            SourceRepository::ContainersContainerLibs,
2377            "go.podman.io/common",
2378            "v0.66.0",
2379            "8163ca799c317e3dea886be7228406ec8cf06abc"
2380        ) | (
2381            "5.8.6",
2382            SourceRepository::ContainersContainerLibs,
2383            "go.podman.io/common",
2384            "v0.67.1",
2385            "c8b7f74383aa8bac5a84118db1a6fa870602af63"
2386        ) | (
2387            "6.0.0",
2388            SourceRepository::ContainersContainerLibs,
2389            "go.podman.io/common",
2390            "v0.68.0",
2391            "bb6a37c8946a977f24a8eed4d97b2dc3608cd05e"
2392        ) | (
2393            "6.1.0",
2394            SourceRepository::PodmanContainerToolsContainerLibs,
2395            "go.podman.io/common",
2396            "v0.69.1",
2397            "e47c7ccf66a1b89b0807e053dd426ff26eedd7a7"
2398        )
2399    )
2400}
2401
2402fn canonical_version(version: &Version) -> String {
2403    format!("{}.{}.{}", version.major, version.minor, version.patch)
2404}
2405
2406fn is_lowercase_sha40(value: &str) -> bool {
2407    value.len() == 40
2408        && value
2409            .bytes()
2410            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2411}
2412
2413fn managed_image_sources(plan: &DeploymentPlan) -> BTreeMap<DeploymentResourceId, String> {
2414    plan.operations()
2415        .iter()
2416        .filter_map(|operation| match operation.resource_intent() {
2417            DeploymentResource::Image(image) => Some((image.identity().clone(), image.source().as_str().to_owned())),
2418            _ => None,
2419        })
2420        .collect()
2421}
2422
2423#[allow(clippy::too_many_lines)] // One exhaustive renderer boundary keeps the semantic surface auditable.
2424fn unsupported_fields(
2425    resource: &DeploymentResource,
2426    version: &str,
2427    context: crate::TargetExecutionContext,
2428) -> Vec<&'static str> {
2429    match resource {
2430        DeploymentResource::Image(image) => {
2431            let mut fields = Vec::new();
2432            if !matches!(
2433                image.source().classification(),
2434                crate::ImageSourceClassification::Portable
2435            ) {
2436                fields.push("source.portability");
2437            }
2438            if !supports_image_policy(version) {
2439                fields.push("pull_policy.target_version");
2440            }
2441            fields
2442        }
2443        DeploymentResource::Volume(volume) => {
2444            if !supports_volume_ownership(version) && (volume.uid().is_some() || volume.gid().is_some()) {
2445                vec!["owner.target_version"]
2446            } else {
2447                Vec::new()
2448            }
2449        }
2450        DeploymentResource::Network(network) => {
2451            let mut fields = Vec::new();
2452            if !supports_network_route_type(version)
2453                && network
2454                    .routes()
2455                    .iter()
2456                    .any(|route| route.route_type() != RouteType::Unicast)
2457            {
2458                fields.push("routes.route_type");
2459            }
2460            fields
2461        }
2462        DeploymentResource::Pod(pod) => {
2463            let mut fields = Vec::new();
2464            if context != crate::TargetExecutionContext::Rootful
2465                && pod.networks().iter().any(network_attachment_has_static_address)
2466            {
2467                fields.push("networks.static_address_requires_rootful");
2468            }
2469            if !pod.infra_mounts().is_empty() {
2470                fields.push("infra_mounts");
2471            }
2472            fields
2473        }
2474        DeploymentResource::Container(container) => {
2475            let mut fields = Vec::new();
2476            fields.extend(unsupported_runtime_fields(container, version));
2477            if container.pod().is_some() && !container.networks().is_empty() {
2478                fields.push("networks");
2479            }
2480            if container.pod().is_some() && !container.ports().is_empty() {
2481                fields.push("ports");
2482            }
2483            if container.pod().is_some()
2484                && (!container.dns().servers().is_empty()
2485                    || !container.dns().search().is_empty()
2486                    || !container.dns().options().is_empty())
2487            {
2488                fields.push("dns");
2489            }
2490            if container.pod().is_some() && !container.host_aliases().is_empty() {
2491                fields.push("host_aliases");
2492            }
2493            if container.pod().is_some() && container.network_order().is_some() {
2494                fields.push("network_order");
2495            }
2496            if context != crate::TargetExecutionContext::Rootful
2497                && container.networks().iter().any(network_attachment_has_static_address)
2498            {
2499                fields.push("networks.static_address_requires_rootful");
2500            }
2501            if container.network_order().is_some() && !supports_network_order(version) {
2502                fields.push("network_order");
2503            }
2504            if !container.secret_grants().is_empty() && !secret_grants_renderable(container.secret_grants()) {
2505                fields.push("secret_grants");
2506            }
2507            if container.mounts().iter().any(mount_has_unsupported_subpath) {
2508                fields.push("mounts.subpath_nocopy");
2509            }
2510            let settings = container.settings();
2511            if settings.environment().iter().any(|assignment| {
2512                matches!(
2513                    assignment.value(),
2514                    crate::DeploymentEnvironmentValue::SensitiveInline(_)
2515                )
2516            }) {
2517                fields.push("environment.sensitive_inline");
2518            }
2519            if settings
2520                .environment()
2521                .iter()
2522                .any(|assignment| matches!(assignment.value(), crate::DeploymentEnvironmentValue::External(_)))
2523            {
2524                fields.push("environment.external");
2525            }
2526            if container.pod().is_some() && settings.restart_policy().is_some() {
2527                fields.push("restart_policy.pod_member");
2528            }
2529            fields
2530        }
2531        _ => Vec::new(),
2532    }
2533}
2534
2535fn network_attachment_has_static_address(attachment: &NetworkAttachment) -> bool {
2536    attachment.static_ipv4().is_some() || attachment.static_ipv6().is_some() || attachment.static_mac().is_some()
2537}
2538
2539fn supports_network_order(version: &str) -> bool {
2540    matches!(version, "6.0.0" | "6.1.0")
2541}
2542
2543fn supports_image_policy(version: &str) -> bool {
2544    !matches!(version, "5.4.0" | "5.5.0")
2545}
2546
2547fn supports_volume_ownership(version: &str) -> bool {
2548    supports_image_policy(version)
2549}
2550
2551fn supports_network_route_type(version: &str) -> bool {
2552    supports_network_order(version)
2553}
2554
2555fn append_network_arguments(
2556    arguments: &mut Vec<String>,
2557    networks: &[NetworkAttachment],
2558    order: Option<&[DeploymentResourceId]>,
2559) -> bool {
2560    let attachments = ordered_network_attachments(networks, order);
2561    let Some(attachments) = attachments else {
2562        return false;
2563    };
2564    for network in attachments {
2565        let Some(value) = network_attachment_cli_value(network) else {
2566            return false;
2567        };
2568        arguments.push("--network".to_owned());
2569        arguments.push(value);
2570    }
2571    true
2572}
2573
2574fn ordered_network_attachments<'a>(
2575    attachments: &'a [NetworkAttachment],
2576    order: Option<&[DeploymentResourceId]>,
2577) -> Option<Vec<&'a NetworkAttachment>> {
2578    match order {
2579        None => Some(attachments.iter().collect()),
2580        Some(order) => order
2581            .iter()
2582            .map(|identity| attachments.iter().find(|attachment| attachment.network() == identity))
2583            .collect(),
2584    }
2585}
2586
2587fn network_attachment_cli_value(attachment: &NetworkAttachment) -> Option<String> {
2588    let name = attachment.network().name();
2589    if name.contains([':', ',']) {
2590        return None;
2591    }
2592    let mut value = name.to_owned();
2593    let mut options = Vec::new();
2594    options.extend(attachment.aliases().iter().map(|alias| format!("alias={alias}")));
2595    if let Some(ipv4) = attachment.static_ipv4() {
2596        options.push(format!("ip={ipv4}"));
2597    }
2598    if let Some(ipv6) = attachment.static_ipv6() {
2599        options.push(format!("ip6={ipv6}"));
2600    }
2601    if let Some(mac) = attachment.static_mac() {
2602        options.push(format!("mac={}", mac.as_str()));
2603    }
2604    if !options.is_empty() {
2605        value.push(':');
2606        value.push_str(&options.join(","));
2607    }
2608    Some(value)
2609}
2610
2611fn network_configuration(networks: &[NetworkAttachment]) -> Value {
2612    let networks = networks
2613        .iter()
2614        .map(|network| {
2615            let mut options = Map::new();
2616            if !network.aliases().is_empty() {
2617                options.insert("aliases".to_owned(), string_array(network.aliases()));
2618            }
2619            let static_ips = [network.static_ipv4(), network.static_ipv6()]
2620                .into_iter()
2621                .flatten()
2622                .map(|address| Value::String(address.to_string()))
2623                .collect::<Vec<_>>();
2624            if !static_ips.is_empty() {
2625                options.insert("static_ips".to_owned(), Value::Array(static_ips));
2626            }
2627            if let Some(mac) = network.static_mac() {
2628                options.insert("static_mac".to_owned(), Value::String(mac.as_str().to_owned()));
2629            }
2630            (network.network().name().to_owned(), Value::Object(options))
2631        })
2632        .collect::<Map<String, Value>>();
2633    Value::Object(networks)
2634}
2635
2636fn append_port_arguments(arguments: &mut Vec<String>, ports: &[PortMapping]) {
2637    for port in ports {
2638        arguments.push("--publish".to_owned());
2639        arguments.push(port_mapping_cli_value(port));
2640    }
2641}
2642
2643fn port_mapping_cli_value(port: &PortMapping) -> String {
2644    let mut value = String::new();
2645    if let Some(host_ip) = port.host_ip() {
2646        if host_ip.is_ipv6() {
2647            value.push('[');
2648            value.push_str(&host_ip.to_string());
2649            value.push(']');
2650        } else {
2651            value.push_str(&host_ip.to_string());
2652        }
2653        value.push(':');
2654    }
2655    let protocol = match port.protocol() {
2656        PortProtocol::Tcp => "tcp",
2657        PortProtocol::Udp => "udp",
2658        PortProtocol::Sctp => "sctp",
2659    };
2660    format!("{value}{}:{}/{protocol}", port.host_port(), port.container_port())
2661}
2662
2663fn append_dns_arguments(arguments: &mut Vec<String>, dns: &crate::DnsConfiguration) {
2664    for server in dns.servers() {
2665        arguments.extend(["--dns".to_owned(), server.to_string()]);
2666    }
2667    for search in dns.search() {
2668        arguments.extend(["--dns-search".to_owned(), search.clone()]);
2669    }
2670    for option in dns.options() {
2671        arguments.extend(["--dns-option".to_owned(), option.clone()]);
2672    }
2673}
2674
2675fn append_host_alias_arguments(arguments: &mut Vec<String>, aliases: &[HostAlias]) {
2676    for alias in aliases {
2677        arguments.extend(["--add-host".to_owned(), host_alias_value(alias)]);
2678    }
2679}
2680
2681fn append_networking_json(
2682    body: &mut Map<String, Value>,
2683    ports: &[PortMapping],
2684    dns: &crate::DnsConfiguration,
2685    aliases: &[HostAlias],
2686    network_order: Option<&[DeploymentResourceId]>,
2687) {
2688    if !ports.is_empty() {
2689        body.insert(
2690            "portmappings".to_owned(),
2691            Value::Array(
2692                ports
2693                    .iter()
2694                    .map(|port| {
2695                        let protocol = match port.protocol() {
2696                            PortProtocol::Tcp => "tcp",
2697                            PortProtocol::Udp => "udp",
2698                            PortProtocol::Sctp => "sctp",
2699                        };
2700                        json!({
2701                            "host_ip": port.host_ip().map_or_else(String::new, |address| address.to_string()),
2702                            "host_port": port.host_port(),
2703                            "container_port": port.container_port(),
2704                            "range": 1,
2705                            "protocol": protocol,
2706                        })
2707                    })
2708                    .collect(),
2709            ),
2710        );
2711    }
2712    if !dns.servers().is_empty() {
2713        body.insert(
2714            "dns_server".to_owned(),
2715            Value::Array(
2716                dns.servers()
2717                    .iter()
2718                    .map(|server| Value::String(server.to_string()))
2719                    .collect(),
2720            ),
2721        );
2722    }
2723    if !dns.search().is_empty() {
2724        body.insert("dns_search".to_owned(), string_array(dns.search()));
2725    }
2726    if !dns.options().is_empty() {
2727        body.insert("dns_option".to_owned(), string_array(dns.options()));
2728    }
2729    if !aliases.is_empty() {
2730        body.insert(
2731            "hostadd".to_owned(),
2732            Value::Array(
2733                aliases
2734                    .iter()
2735                    .map(|alias| Value::String(host_alias_value(alias)))
2736                    .collect(),
2737            ),
2738        );
2739    }
2740    if let Some(order) = network_order {
2741        body.insert(
2742            "networkOrder".to_owned(),
2743            Value::Array(
2744                order
2745                    .iter()
2746                    .map(|network| Value::String(network.name().to_owned()))
2747                    .collect(),
2748            ),
2749        );
2750    }
2751}
2752
2753fn host_alias_value(alias: &HostAlias) -> String {
2754    // Podman separates the hostname once at the first colon. The validated hostname grammar
2755    // excludes colons, so a literal IPv6 address remains unambiguous without brackets in both
2756    // the CLI and Libpod `hostadd` representation.
2757    format!("{}:{}", alias.hostname(), alias.address())
2758}
2759
2760fn append_network_create_arguments(arguments: &mut Vec<String>, network: &NetworkIntent) {
2761    for subnet in network.subnets() {
2762        arguments.extend(["--subnet".to_owned(), subnet.subnet().as_str().to_owned()]);
2763    }
2764    for subnet in network.subnets() {
2765        if let Some(gateway) = subnet.gateway() {
2766            arguments.extend(["--gateway".to_owned(), gateway.to_string()]);
2767        }
2768    }
2769    for subnet in network.subnets() {
2770        if let Some((start, end)) = subnet.range() {
2771            arguments.extend(["--ip-range".to_owned(), format!("{start}-{end}")]);
2772        }
2773    }
2774    for route in network.routes() {
2775        arguments.extend(["--route".to_owned(), network_route_cli_value(route)]);
2776    }
2777}
2778
2779fn network_route_cli_value(route: &NetworkRoute) -> String {
2780    let next_hop = match route.route_type() {
2781        RouteType::Unicast => route.gateway().map_or_else(String::new, |gateway| gateway.to_string()),
2782        RouteType::Blackhole => "blackhole".to_owned(),
2783        RouteType::Unreachable => "unreachable".to_owned(),
2784        RouteType::Prohibit => "prohibit".to_owned(),
2785    };
2786    let mut value = format!("{},{}", route.destination().as_str(), next_hop);
2787    if let Some(metric) = route.metric() {
2788        let _ = write!(value, ",{metric}");
2789    }
2790    value
2791}
2792
2793fn network_create_configuration(network: &NetworkIntent, supports_route_type: bool) -> Value {
2794    let mut body = Map::new();
2795    body.insert("name".to_owned(), Value::String(network.identity().name().to_owned()));
2796    if !network.subnets().is_empty() {
2797        body.insert(
2798            "subnets".to_owned(),
2799            Value::Array(
2800                network
2801                    .subnets()
2802                    .iter()
2803                    .map(|subnet| {
2804                        let mut value = Map::new();
2805                        value.insert("subnet".to_owned(), Value::String(subnet.subnet().as_str().to_owned()));
2806                        if let Some(gateway) = subnet.gateway() {
2807                            value.insert("gateway".to_owned(), Value::String(gateway.to_string()));
2808                        }
2809                        if let Some((start, end)) = subnet.range() {
2810                            value.insert(
2811                                "lease_range".to_owned(),
2812                                json!({"start_ip": start.to_string(), "end_ip": end.to_string()}),
2813                            );
2814                        }
2815                        Value::Object(value)
2816                    })
2817                    .collect(),
2818            ),
2819        );
2820    }
2821    if !network.routes().is_empty() {
2822        body.insert(
2823            "routes".to_owned(),
2824            Value::Array(
2825                network
2826                    .routes()
2827                    .iter()
2828                    .map(|route| {
2829                        let mut value = Map::new();
2830                        value.insert(
2831                            "destination".to_owned(),
2832                            Value::String(route.destination().as_str().to_owned()),
2833                        );
2834                        if let Some(gateway) = route.gateway() {
2835                            value.insert("gateway".to_owned(), Value::String(gateway.to_string()));
2836                        }
2837                        if let Some(metric) = route.metric() {
2838                            value.insert("metric".to_owned(), json!(metric));
2839                        }
2840                        if supports_route_type {
2841                            let route_type = match route.route_type() {
2842                                RouteType::Unicast => "unicast",
2843                                RouteType::Blackhole => "blackhole",
2844                                RouteType::Unreachable => "unreachable",
2845                                RouteType::Prohibit => "prohibit",
2846                            };
2847                            value.insert("route_type".to_owned(), Value::String(route_type.to_owned()));
2848                        }
2849                        Value::Object(value)
2850                    })
2851                    .collect(),
2852            ),
2853        );
2854    }
2855    Value::Object(body)
2856}
2857
2858fn append_mount_arguments(arguments: &mut Vec<String>, mounts: &[MountIntent]) -> bool {
2859    for mount in mounts {
2860        match mount {
2861            MountIntent::NamedVolume(mount) => {
2862                if mount.subpath().is_some() {
2863                    if !cli_safe_mount_component(mount.source().name())
2864                        || !cli_safe_mount_component(mount.destination().as_str())
2865                        || mount
2866                            .subpath()
2867                            .is_some_and(|subpath| !cli_safe_mount_component(subpath.as_str()))
2868                    {
2869                        return false;
2870                    }
2871                    arguments.extend([
2872                        "--mount".to_owned(),
2873                        mount_cli_value(
2874                            "volume",
2875                            mount.source().name(),
2876                            mount.destination().as_str(),
2877                            mount.access(),
2878                            mount.subpath().map(crate::VolumeSubpath::as_str),
2879                        ),
2880                    ]);
2881                    continue;
2882                }
2883                if !cli_safe_mount_component(mount.source().name())
2884                    || !cli_safe_mount_component(mount.destination().as_str())
2885                {
2886                    return false;
2887                }
2888                let access = if mount.is_read_only() { "ro" } else { "rw" };
2889                let copy = match mount.copy_mode() {
2890                    crate::NamedVolumeCopyMode::Copy => "copy",
2891                    crate::NamedVolumeCopyMode::NoCopy => "nocopy",
2892                };
2893                let options = format!("{access},{copy}");
2894                arguments.extend([
2895                    "--volume".to_owned(),
2896                    format!("{}:{}:{options}", mount.source().name(), mount.destination().as_str()),
2897                ]);
2898            }
2899            MountIntent::Bind(mount) => {
2900                if !cli_safe_mount_component(mount.source().as_str())
2901                    || !cli_safe_mount_component(mount.destination().as_str())
2902                {
2903                    return false;
2904                }
2905                arguments.extend([
2906                    "--mount".to_owned(),
2907                    mount_cli_value(
2908                        "bind",
2909                        mount.source().as_str(),
2910                        mount.destination().as_str(),
2911                        mount.access(),
2912                        None,
2913                    ),
2914                ]);
2915            }
2916            MountIntent::Tmpfs(mount) => {
2917                if !cli_safe_mount_component(mount.destination().as_str()) {
2918                    return false;
2919                }
2920                arguments.extend([
2921                    "--mount".to_owned(),
2922                    mount_cli_value("tmpfs", "", mount.destination().as_str(), mount.access(), None),
2923                ]);
2924            }
2925        }
2926    }
2927    true
2928}
2929
2930fn cli_safe_mount_component(value: &str) -> bool {
2931    !value.contains([':', ','])
2932}
2933
2934fn mount_json(mounts: &[MountIntent]) -> Value {
2935    Value::Array(
2936        mounts
2937            .iter()
2938            .filter_map(|mount| match mount {
2939                MountIntent::NamedVolume(mount) if mount.subpath().is_none() => {
2940                    let access = if mount.is_read_only() { "ro" } else { "rw" };
2941                    let copy = match mount.copy_mode() {
2942                        crate::NamedVolumeCopyMode::Copy => "copy",
2943                        crate::NamedVolumeCopyMode::NoCopy => "nocopy",
2944                    };
2945                    let options = vec![access.to_owned(), copy.to_owned()];
2946                    Some(json!({"Name": mount.source().name(), "Dest": mount.destination().as_str(), "Options": options}))
2947                }
2948                MountIntent::NamedVolume(mount) => Some(json!({
2949                    "Name": mount.source().name(),
2950                    "Dest": mount.destination().as_str(),
2951                    "Options": if mount.access().is_read_only() { vec!["ro"] } else { Vec::<&str>::new() },
2952                    "SubPath": mount.subpath().map(crate::VolumeSubpath::as_str),
2953                })),
2954                MountIntent::Bind(_) | MountIntent::Tmpfs(_) => None,
2955            })
2956            .collect(),
2957    )
2958}
2959
2960fn mount_cli_value(
2961    kind: &str,
2962    source: &str,
2963    destination: &str,
2964    access: crate::MountAccess,
2965    subpath: Option<&str>,
2966) -> String {
2967    let mut value = format!("type={kind}");
2968    if !source.is_empty() {
2969        let _ = write!(value, ",source={source}");
2970    }
2971    let _ = write!(value, ",target={destination}");
2972    if access.is_read_only() {
2973        value.push_str(",readonly");
2974    }
2975    if let Some(subpath) = subpath {
2976        let _ = write!(value, ",subpath={subpath}");
2977    }
2978    value
2979}
2980
2981fn native_mount_json(mounts: &[MountIntent]) -> Value {
2982    Value::Array(
2983        mounts
2984            .iter()
2985            .filter_map(|mount| match mount {
2986                MountIntent::Bind(mount) => Some(json!({
2987                    "destination": mount.destination().as_str(),
2988                    "type": "bind",
2989                    "source": mount.source().as_str(),
2990                    "options": if mount.access().is_read_only() { ["ro"] } else { ["rw"] },
2991                })),
2992                MountIntent::Tmpfs(mount) => Some(json!({
2993                    "destination": mount.destination().as_str(),
2994                    "type": "tmpfs",
2995                    "source": "tmpfs",
2996                    "options": if mount.access().is_read_only() { ["ro"] } else { ["rw"] },
2997                })),
2998                MountIntent::NamedVolume(_) => None,
2999            })
3000            .collect(),
3001    )
3002}
3003
3004fn append_secret_grants_arguments(arguments: &mut Vec<String>, grants: &[SecretGrant]) -> bool {
3005    for grant in grants {
3006        if !cli_safe_mount_component(grant.source().name()) {
3007            return false;
3008        }
3009        let value = match grant {
3010            SecretGrant::Mount { .. } => {
3011                let mut value = format!("source={},type=mount", grant.source().name());
3012                if let Some(target) = grant.mount_target() {
3013                    if !cli_safe_mount_component(target.as_str()) {
3014                        return false;
3015                    }
3016                    let _ = write!(value, ",target={}", target.as_str());
3017                }
3018                if let Some(uid) = grant.mount_uid() {
3019                    let _ = write!(value, ",uid={}", uid.get());
3020                }
3021                if let Some(gid) = grant.mount_gid() {
3022                    let _ = write!(value, ",gid={}", gid.get());
3023                }
3024                if let Some(mode) = grant.mount_mode() {
3025                    let _ = write!(value, ",mode={:o}", mode.get());
3026                }
3027                value
3028            }
3029            SecretGrant::Environment { target, .. } => {
3030                format!("source={},type=env,target={}", grant.source().name(), target.as_str())
3031            }
3032        };
3033        arguments.extend(["--secret".to_owned(), value]);
3034    }
3035    true
3036}
3037
3038fn append_secret_grants_json(body: &mut Map<String, Value>, grants: &[SecretGrant]) {
3039    let mut mounts = Vec::new();
3040    let mut environment = Map::new();
3041    for grant in grants {
3042        match grant {
3043            SecretGrant::Mount { .. } => {
3044                let mut value = Map::new();
3045                value.insert("Source".to_owned(), Value::String(grant.source().name().to_owned()));
3046                if let Some(target) = grant.mount_target() {
3047                    value.insert("Target".to_owned(), Value::String(target.as_str().to_owned()));
3048                }
3049                if let Some(uid) = grant.mount_uid() {
3050                    value.insert("UID".to_owned(), json!(uid.get()));
3051                }
3052                if let Some(gid) = grant.mount_gid() {
3053                    value.insert("GID".to_owned(), json!(gid.get()));
3054                }
3055                // `podman --secret` defaults omitted mount modes to 0444, while the Libpod
3056                // request model decodes an omitted `Mode` as zero. Emit the CLI default
3057                // explicitly in the API representation so both non-executing planes retain
3058                // the same declared semantics.
3059                value.insert(
3060                    "Mode".to_owned(),
3061                    json!(grant.mount_mode().map_or(0o444, SecretMode::get)),
3062                );
3063                mounts.push(Value::Object(value));
3064            }
3065            SecretGrant::Environment { target, .. } => {
3066                environment.insert(
3067                    target.as_str().to_owned(),
3068                    Value::String(grant.source().name().to_owned()),
3069                );
3070            }
3071        }
3072    }
3073    if !mounts.is_empty() {
3074        body.insert("secrets".to_owned(), Value::Array(mounts));
3075    }
3076    if !environment.is_empty() {
3077        body.insert("secret_env".to_owned(), Value::Object(environment));
3078    }
3079}
3080
3081fn volume_create_cli_arguments(volume: &crate::VolumeIntent) -> Vec<String> {
3082    let mut arguments = vec!["volume".to_owned(), "create".to_owned()];
3083    if let Some(uid) = volume.uid() {
3084        arguments.extend(["--uid".to_owned(), uid.get().to_string()]);
3085    }
3086    if let Some(gid) = volume.gid() {
3087        arguments.extend(["--gid".to_owned(), gid.get().to_string()]);
3088    }
3089    arguments.push(volume.identity().name().to_owned());
3090    arguments
3091}
3092
3093fn volume_create_json(volume: &crate::VolumeIntent) -> Value {
3094    let mut body = Map::new();
3095    body.insert("Name".to_owned(), Value::String(volume.identity().name().to_owned()));
3096    if let Some(uid) = volume.uid() {
3097        body.insert("UID".to_owned(), json!(uid.get()));
3098    }
3099    if let Some(gid) = volume.gid() {
3100        body.insert("GID".to_owned(), json!(gid.get()));
3101    }
3102    Value::Object(body)
3103}
3104
3105fn mount_has_unsupported_subpath(mount: &MountIntent) -> bool {
3106    matches!(mount, MountIntent::NamedVolume(volume) if volume.subpath().is_some() && volume.copy_mode() == crate::NamedVolumeCopyMode::NoCopy)
3107}
3108
3109fn secret_grants_renderable(grants: &[SecretGrant]) -> bool {
3110    let mut arguments = Vec::new();
3111    append_secret_grants_arguments(&mut arguments, grants)
3112}
3113
3114fn unsupported_runtime_fields(container: &crate::ContainerIntent, version: &str) -> Vec<&'static str> {
3115    let runtime = container.runtime();
3116    let mut fields = Vec::new();
3117    if runtime.startup_health().is_some() && !matches!(runtime.health(), Some(crate::HealthCheck::Command(_))) {
3118        fields.push("runtime.startup_health_requires_health");
3119    }
3120    for (health, field) in [
3121        (
3122            runtime.health().and_then(|health| match health {
3123                crate::HealthCheck::Command(configured) => Some(configured.command()),
3124                crate::HealthCheck::Disabled => None,
3125            }),
3126            "runtime.health.command",
3127        ),
3128        (
3129            runtime.startup_health().map(crate::StartupHealthCheck::command),
3130            "runtime.startup_health.command",
3131        ),
3132    ] {
3133        if health.is_some_and(health_command_is_sensitive) {
3134            fields.push(field);
3135        }
3136    }
3137    if !runtime.logging().journald_labels().is_empty() && !supports_journald_labels(version) {
3138        fields.push("runtime.logging.journald_labels.target_version");
3139    }
3140    if runtime.logging().driver().is_none()
3141        && (runtime.logging().max_size().is_some() || !runtime.logging().journald_labels().is_empty())
3142    {
3143        fields.push("runtime.logging.driver");
3144    }
3145    if !runtime.logging().journald_labels().is_empty() && runtime.logging().driver() != Some(crate::LogDriver::Journald)
3146    {
3147        fields.push("runtime.logging.journald_labels");
3148    }
3149    if runtime.logging().max_size().is_some() && runtime.logging().driver() != Some(crate::LogDriver::K8sFile) {
3150        fields.push("runtime.logging.max_size");
3151    }
3152    if runtime.resources().rlimits().iter().any(rlimit_is_unlimited) && !supports_unlimited_rlimit(version) {
3153        fields.push("runtime.resources.rlimits.unlimited.target_version");
3154    }
3155    if runtime.security().privileged() == Some(true)
3156        && (!runtime.security().cap_add().is_empty() || !runtime.security().cap_drop().is_empty())
3157    {
3158        fields.push("runtime.security.privileged_capabilities");
3159    }
3160    if runtime
3161        .security()
3162        .cap_add()
3163        .iter()
3164        .any(|capability| runtime.security().cap_drop().contains(capability))
3165    {
3166        fields.push("runtime.security.capability_overlap");
3167    }
3168    if runtime.security().read_write_tmpfs() == Some(true) && runtime.security().read_only_filesystem() != Some(true) {
3169        fields.push("runtime.security.read_write_tmpfs");
3170    }
3171    if container.pod().is_some() && !runtime.namespaces().is_empty() {
3172        fields.push("runtime.namespaces.pod_member");
3173    }
3174    fields
3175}
3176
3177fn health_command_is_sensitive(command: &crate::HealthCommand) -> bool {
3178    matches!(
3179        command,
3180        crate::HealthCommand::SensitiveInlineShell(_)
3181            | crate::HealthCommand::SensitiveInlineExec(_)
3182            | crate::HealthCommand::ExternalShell(_)
3183            | crate::HealthCommand::ExternalExec(_)
3184    )
3185}
3186
3187fn rlimit_is_unlimited(limit: &crate::Rlimit) -> bool {
3188    matches!(limit.soft(), crate::RlimitValue::Unlimited) || matches!(limit.hard(), crate::RlimitValue::Unlimited)
3189}
3190
3191fn supports_journald_labels(version: &str) -> bool {
3192    matches!(version, "6.0.0" | "6.1.0")
3193}
3194
3195fn supports_unlimited_rlimit(version: &str) -> bool {
3196    !matches!(version, "5.4.0" | "5.5.0")
3197}
3198
3199fn append_container_runtime_arguments(
3200    arguments: &mut Vec<String>,
3201    container: &crate::ContainerIntent,
3202    identity: &DeploymentResourceId,
3203    version: &str,
3204) -> Result<(), RenderingFinding> {
3205    let runtime = container.runtime();
3206    if let Some(health) = runtime.health() {
3207        match health {
3208            crate::HealthCheck::Disabled => arguments.push("--no-healthcheck".to_owned()),
3209            crate::HealthCheck::Command(configured) => {
3210                arguments.extend([
3211                    "--health-cmd".to_owned(),
3212                    health_command_cli(configured.command(), identity, "runtime.health.command")?,
3213                ]);
3214                append_health_arguments(arguments, configured);
3215            }
3216        }
3217    }
3218    if let Some(startup) = runtime.startup_health() {
3219        arguments.extend([
3220            "--health-startup-cmd".to_owned(),
3221            health_command_cli(startup.command(), identity, "runtime.startup_health.command")?,
3222        ]);
3223        if let Some(interval) = startup.interval() {
3224            arguments.extend(["--health-startup-interval".to_owned(), health_interval_cli(interval)]);
3225        }
3226        if let Some(timeout) = startup.timeout() {
3227            arguments.extend([
3228                "--health-startup-timeout".to_owned(),
3229                health_duration_cli(timeout.nanoseconds()),
3230            ]);
3231        }
3232        if let Some(retries) = startup.retries() {
3233            arguments.extend(["--health-startup-retries".to_owned(), retries.value().to_string()]);
3234        }
3235        if let Some(successes) = startup.successes() {
3236            arguments.extend(["--health-startup-success".to_owned(), successes.value().to_string()]);
3237        }
3238    }
3239    let logging = runtime.logging();
3240    if let Some(driver) = logging.driver() {
3241        arguments.extend(["--log-driver".to_owned(), log_driver_name(driver).to_owned()]);
3242    }
3243    if let Some(size) = logging.max_size() {
3244        arguments.extend(["--log-opt".to_owned(), format!("max-size={}", size.bytes())]);
3245    }
3246    if !logging.journald_labels().is_empty() && !supports_journald_labels(version) {
3247        return Err(rendering_runtime_finding(
3248            identity,
3249            "runtime.logging.journald_labels.target_version",
3250        ));
3251    }
3252    for label in logging.journald_labels() {
3253        arguments.extend([
3254            "--log-opt".to_owned(),
3255            format!("label={}={}", label.key().as_str(), label.value().as_str()),
3256        ]);
3257    }
3258    append_security_arguments(arguments, runtime.security());
3259    append_namespace_arguments(arguments, runtime.namespaces());
3260    append_resource_arguments(arguments, runtime.resources(), identity, version)?;
3261    Ok(())
3262}
3263
3264fn append_health_arguments(arguments: &mut Vec<String>, health: &crate::ConfiguredHealthCheck) {
3265    if let Some(interval) = health.interval() {
3266        arguments.extend(["--health-interval".to_owned(), health_interval_cli(interval)]);
3267    }
3268    if let Some(timeout) = health.timeout() {
3269        arguments.extend([
3270            "--health-timeout".to_owned(),
3271            health_duration_cli(timeout.nanoseconds()),
3272        ]);
3273    }
3274    if let Some(retries) = health.retries() {
3275        arguments.extend(["--health-retries".to_owned(), retries.value().to_string()]);
3276    }
3277    if let Some(period) = health.start_period() {
3278        arguments.extend([
3279            "--health-start-period".to_owned(),
3280            health_duration_cli(period.nanoseconds()),
3281        ]);
3282    }
3283    if let Some(on_failure) = health.on_failure() {
3284        arguments.extend([
3285            "--health-on-failure".to_owned(),
3286            health_on_failure_name(on_failure).to_owned(),
3287        ]);
3288    }
3289}
3290
3291fn append_security_arguments(arguments: &mut Vec<String>, security: &crate::SecuritySettings) {
3292    if let Some(value) = security.privileged() {
3293        arguments.push(format!("--privileged={value}"));
3294    }
3295    if let Some(value) = security.no_new_privileges() {
3296        arguments.extend(["--security-opt".to_owned(), format!("no-new-privileges={value}")]);
3297    }
3298    if let Some(value) = security.read_only_filesystem() {
3299        arguments.push(format!("--read-only={value}"));
3300    }
3301    if let Some(value) = security.read_write_tmpfs() {
3302        arguments.push(format!("--read-only-tmpfs={value}"));
3303    }
3304    for capability in security.cap_add() {
3305        arguments.extend(["--cap-add".to_owned(), capability.as_str().to_owned()]);
3306    }
3307    for capability in security.cap_drop() {
3308        arguments.extend(["--cap-drop".to_owned(), capability.as_str().to_owned()]);
3309    }
3310}
3311
3312fn append_namespace_arguments(arguments: &mut Vec<String>, namespaces: &crate::ContainerNamespaceSettings) {
3313    if let Some(pid) = namespaces.pid() {
3314        arguments.extend(["--pid".to_owned(), namespace_mode_name(pid).to_owned()]);
3315    }
3316    if let Some(ipc) = namespaces.ipc() {
3317        arguments.extend(["--ipc".to_owned(), ipc_namespace_mode_name(ipc).to_owned()]);
3318    }
3319    if let Some(uts) = namespaces.uts() {
3320        arguments.extend(["--uts".to_owned(), namespace_mode_name(uts).to_owned()]);
3321    }
3322    if let Some(cgroup) = namespaces.cgroup() {
3323        arguments.extend(["--cgroupns".to_owned(), namespace_mode_name(cgroup).to_owned()]);
3324    }
3325}
3326
3327fn append_resource_arguments(
3328    arguments: &mut Vec<String>,
3329    resources: &crate::ContainerResourceControls,
3330    identity: &DeploymentResourceId,
3331    version: &str,
3332) -> Result<(), RenderingFinding> {
3333    for (flag, value) in [
3334        ("--cpu-shares", resources.cpu_shares()),
3335        ("--cpu-period", resources.cpu_period()),
3336        ("--cpu-quota", resources.cpu_quota()),
3337        ("--memory", resources.memory_bytes()),
3338        ("--pids-limit", resources.pids()),
3339    ] {
3340        if let Some(value) = value {
3341            arguments.extend([flag.to_owned(), value.to_string()]);
3342        }
3343    }
3344    for rlimit in resources.rlimits() {
3345        if rlimit_is_unlimited(rlimit) && !supports_unlimited_rlimit(version) {
3346            return Err(rendering_runtime_finding(
3347                identity,
3348                "runtime.resources.rlimits.unlimited.target_version",
3349            ));
3350        }
3351        arguments.extend(["--ulimit".to_owned(), rlimit_cli_value(rlimit)]);
3352    }
3353    Ok(())
3354}
3355
3356fn append_container_runtime_json(
3357    body: &mut Map<String, Value>,
3358    container: &crate::ContainerIntent,
3359    identity: &DeploymentResourceId,
3360    version: &str,
3361) -> Result<(), RenderingFinding> {
3362    let runtime = container.runtime();
3363    if let Some(health) = runtime.health() {
3364        body.insert(
3365            "healthconfig".to_owned(),
3366            health_json(health, identity, "runtime.health.command")?,
3367        );
3368        if let crate::HealthCheck::Command(configured) = health {
3369            if let Some(on_failure) = configured.on_failure() {
3370                body.insert(
3371                    "health_check_on_failure_action".to_owned(),
3372                    json!(health_on_failure_code(on_failure)),
3373                );
3374            }
3375        }
3376    }
3377    if let Some(startup) = runtime.startup_health() {
3378        let mut configuration = Map::new();
3379        configuration.insert(
3380            "Test".to_owned(),
3381            health_command_json(startup.command(), identity, "runtime.startup_health.command")?,
3382        );
3383        append_startup_health_json(&mut configuration, startup);
3384        body.insert("startupHealthConfig".to_owned(), Value::Object(configuration));
3385    }
3386    append_logging_json(body, runtime.logging(), identity, version)?;
3387    append_security_json(body, runtime.security());
3388    append_namespace_json(body, runtime.namespaces());
3389    append_resource_json(body, runtime.resources(), identity, version)?;
3390    Ok(())
3391}
3392
3393fn health_command_cli(
3394    command: &crate::HealthCommand,
3395    identity: &DeploymentResourceId,
3396    field: &'static str,
3397) -> Result<String, RenderingFinding> {
3398    serde_json::to_string(&health_command_json(command, identity, field)?)
3399        .map_err(|_| rendering_runtime_finding(identity, field))
3400}
3401
3402fn health_command_json(
3403    command: &crate::HealthCommand,
3404    identity: &DeploymentResourceId,
3405    field: &'static str,
3406) -> Result<Value, RenderingFinding> {
3407    match command {
3408        crate::HealthCommand::Shell(command) => Ok(json!(["CMD-SHELL", command.as_str()])),
3409        crate::HealthCommand::Exec(arguments) => Ok(Value::Array(
3410            std::iter::once(Value::String("CMD".to_owned()))
3411                .chain(arguments.values().iter().cloned().map(Value::String))
3412                .collect(),
3413        )),
3414        crate::HealthCommand::SensitiveInlineShell(_)
3415        | crate::HealthCommand::SensitiveInlineExec(_)
3416        | crate::HealthCommand::ExternalShell(_)
3417        | crate::HealthCommand::ExternalExec(_) => Err(rendering_runtime_finding(identity, field)),
3418    }
3419}
3420
3421fn health_json(
3422    health: &crate::HealthCheck,
3423    identity: &DeploymentResourceId,
3424    field: &'static str,
3425) -> Result<Value, RenderingFinding> {
3426    match health {
3427        crate::HealthCheck::Disabled => Ok(json!({"Test": ["NONE"]})),
3428        crate::HealthCheck::Command(configured) => {
3429            let mut configuration = Map::new();
3430            configuration.insert(
3431                "Test".to_owned(),
3432                health_command_json(configured.command(), identity, field)?,
3433            );
3434            append_configured_health_json(&mut configuration, configured);
3435            Ok(Value::Object(configuration))
3436        }
3437    }
3438}
3439
3440fn append_configured_health_json(configuration: &mut Map<String, Value>, health: &crate::ConfiguredHealthCheck) {
3441    if let Some(interval) = health.interval() {
3442        configuration.insert("Interval".to_owned(), json!(health_interval_nanoseconds(interval)));
3443    }
3444    if let Some(timeout) = health.timeout() {
3445        configuration.insert("Timeout".to_owned(), json!(timeout.nanoseconds()));
3446    }
3447    if let Some(retries) = health.retries() {
3448        configuration.insert("Retries".to_owned(), json!(retries.value()));
3449    }
3450    if let Some(period) = health.start_period() {
3451        configuration.insert("StartPeriod".to_owned(), json!(period.nanoseconds()));
3452    }
3453}
3454
3455fn append_startup_health_json(configuration: &mut Map<String, Value>, health: &crate::StartupHealthCheck) {
3456    if let Some(interval) = health.interval() {
3457        configuration.insert("Interval".to_owned(), json!(health_interval_nanoseconds(interval)));
3458    }
3459    if let Some(timeout) = health.timeout() {
3460        configuration.insert("Timeout".to_owned(), json!(timeout.nanoseconds()));
3461    }
3462    if let Some(retries) = health.retries() {
3463        configuration.insert("Retries".to_owned(), json!(retries.value()));
3464    }
3465    if let Some(successes) = health.successes() {
3466        configuration.insert("Successes".to_owned(), json!(successes.value()));
3467    }
3468}
3469
3470fn health_interval_cli(interval: crate::HealthInterval) -> String {
3471    match interval {
3472        crate::HealthInterval::Disabled => "disable".to_owned(),
3473        crate::HealthInterval::Every(duration) => health_duration_cli(duration.nanoseconds()),
3474    }
3475}
3476
3477fn health_interval_nanoseconds(interval: crate::HealthInterval) -> i64 {
3478    match interval {
3479        crate::HealthInterval::Disabled => 0,
3480        crate::HealthInterval::Every(duration) => duration.nanoseconds(),
3481    }
3482}
3483
3484fn health_duration_cli(nanoseconds: i64) -> String {
3485    format!("{nanoseconds}ns")
3486}
3487
3488fn health_on_failure_name(value: crate::HealthOnFailure) -> &'static str {
3489    match value {
3490        crate::HealthOnFailure::None => "none",
3491        crate::HealthOnFailure::Kill => "kill",
3492        crate::HealthOnFailure::Restart => "restart",
3493        crate::HealthOnFailure::Stop => "stop",
3494    }
3495}
3496
3497const fn health_on_failure_code(value: crate::HealthOnFailure) -> u8 {
3498    match value {
3499        crate::HealthOnFailure::None => 0,
3500        crate::HealthOnFailure::Kill => 2,
3501        crate::HealthOnFailure::Restart => 3,
3502        crate::HealthOnFailure::Stop => 4,
3503    }
3504}
3505
3506fn log_driver_name(value: crate::LogDriver) -> &'static str {
3507    match value {
3508        crate::LogDriver::Journald => "journald",
3509        crate::LogDriver::K8sFile => "k8s-file",
3510    }
3511}
3512
3513fn append_logging_json(
3514    body: &mut Map<String, Value>,
3515    logging: &crate::LoggingSettings,
3516    identity: &DeploymentResourceId,
3517    version: &str,
3518) -> Result<(), RenderingFinding> {
3519    if logging.driver().is_none() && logging.max_size().is_none() && logging.journald_labels().is_empty() {
3520        return Ok(());
3521    }
3522    if !logging.journald_labels().is_empty() && !supports_journald_labels(version) {
3523        return Err(rendering_runtime_finding(
3524            identity,
3525            "runtime.logging.journald_labels.target_version",
3526        ));
3527    }
3528    let mut configuration = Map::new();
3529    if let Some(driver) = logging.driver() {
3530        configuration.insert("driver".to_owned(), Value::String(log_driver_name(driver).to_owned()));
3531    }
3532    if let Some(size) = logging.max_size() {
3533        configuration.insert("size".to_owned(), json!(size.bytes()));
3534    }
3535    if !logging.journald_labels().is_empty() {
3536        configuration.insert(
3537            "labels".to_owned(),
3538            Value::Object(
3539                logging
3540                    .journald_labels()
3541                    .iter()
3542                    .map(|label| {
3543                        (
3544                            label.key().as_str().to_owned(),
3545                            Value::String(label.value().as_str().to_owned()),
3546                        )
3547                    })
3548                    .collect(),
3549            ),
3550        );
3551    }
3552    body.insert("log_configuration".to_owned(), Value::Object(configuration));
3553    Ok(())
3554}
3555
3556fn append_security_json(body: &mut Map<String, Value>, security: &crate::SecuritySettings) {
3557    if let Some(value) = security.privileged() {
3558        body.insert("privileged".to_owned(), json!(value));
3559    }
3560    if let Some(value) = security.no_new_privileges() {
3561        body.insert("no_new_privileges".to_owned(), json!(value));
3562    }
3563    if let Some(value) = security.read_only_filesystem() {
3564        body.insert("read_only_filesystem".to_owned(), json!(value));
3565    }
3566    if let Some(value) = security.read_write_tmpfs() {
3567        body.insert("read_write_tmpfs".to_owned(), json!(value));
3568    }
3569    if !security.cap_add().is_empty() {
3570        body.insert(
3571            "cap_add".to_owned(),
3572            Value::Array(
3573                security
3574                    .cap_add()
3575                    .iter()
3576                    .map(|value| Value::String(value.as_str().to_owned()))
3577                    .collect(),
3578            ),
3579        );
3580    }
3581    if !security.cap_drop().is_empty() {
3582        body.insert(
3583            "cap_drop".to_owned(),
3584            Value::Array(
3585                security
3586                    .cap_drop()
3587                    .iter()
3588                    .map(|value| Value::String(value.as_str().to_owned()))
3589                    .collect(),
3590            ),
3591        );
3592    }
3593}
3594
3595fn append_namespace_json(body: &mut Map<String, Value>, namespaces: &crate::ContainerNamespaceSettings) {
3596    for (member, mode) in [
3597        ("pidns", namespaces.pid().map(namespace_mode_name)),
3598        ("ipcns", namespaces.ipc().map(ipc_namespace_mode_name)),
3599        ("utsns", namespaces.uts().map(namespace_mode_name)),
3600        ("cgroupns", namespaces.cgroup().map(namespace_mode_name)),
3601    ] {
3602        if let Some(mode) = mode {
3603            body.insert(member.to_owned(), json!({"nsmode": mode}));
3604        }
3605    }
3606}
3607
3608fn namespace_mode_name(value: crate::NamespaceMode) -> &'static str {
3609    match value {
3610        crate::NamespaceMode::Private => "private",
3611        crate::NamespaceMode::Host => "host",
3612    }
3613}
3614
3615fn ipc_namespace_mode_name(value: crate::IpcNamespaceMode) -> &'static str {
3616    match value {
3617        crate::IpcNamespaceMode::Private => "private",
3618        crate::IpcNamespaceMode::Host => "host",
3619        crate::IpcNamespaceMode::Shareable => "shareable",
3620        crate::IpcNamespaceMode::None => "none",
3621    }
3622}
3623
3624fn append_resource_json(
3625    body: &mut Map<String, Value>,
3626    resources: &crate::ContainerResourceControls,
3627    identity: &DeploymentResourceId,
3628    version: &str,
3629) -> Result<(), RenderingFinding> {
3630    let mut limits = Map::new();
3631    let mut cpu = Map::new();
3632    if let Some(value) = resources.cpu_shares() {
3633        cpu.insert("shares".to_owned(), json!(value));
3634    }
3635    if let Some(value) = resources.cpu_period() {
3636        cpu.insert("period".to_owned(), json!(value));
3637    }
3638    if let Some(value) = resources.cpu_quota() {
3639        cpu.insert("quota".to_owned(), json!(value));
3640    }
3641    if !cpu.is_empty() {
3642        limits.insert("cpu".to_owned(), Value::Object(cpu));
3643    }
3644    if let Some(value) = resources.memory_bytes() {
3645        limits.insert("memory".to_owned(), json!({"limit": value}));
3646    }
3647    if let Some(value) = resources.pids() {
3648        limits.insert("pids".to_owned(), json!({"limit": value}));
3649    }
3650    if !limits.is_empty() {
3651        body.insert("resource_limits".to_owned(), Value::Object(limits));
3652    }
3653    if !resources.rlimits().is_empty() {
3654        let mut limits = Vec::with_capacity(resources.rlimits().len());
3655        for rlimit in resources.rlimits() {
3656            if rlimit_is_unlimited(rlimit) && !supports_unlimited_rlimit(version) {
3657                return Err(rendering_runtime_finding(
3658                    identity,
3659                    "runtime.resources.rlimits.unlimited.target_version",
3660                ));
3661            }
3662            limits.push(json!({
3663                "type": rlimit_kind_name(rlimit.kind()),
3664                "soft": rlimit_value_json(rlimit.soft()),
3665                "hard": rlimit_value_json(rlimit.hard()),
3666            }));
3667        }
3668        body.insert("r_limits".to_owned(), Value::Array(limits));
3669    }
3670    Ok(())
3671}
3672
3673fn rlimit_cli_value(rlimit: &crate::Rlimit) -> String {
3674    format!(
3675        "{}={}:{}",
3676        rlimit_kind_cli_name(rlimit.kind()),
3677        rlimit_value_cli(rlimit.soft()),
3678        rlimit_value_cli(rlimit.hard())
3679    )
3680}
3681
3682fn rlimit_kind_cli_name(kind: crate::RlimitKind) -> &'static str {
3683    match kind {
3684        crate::RlimitKind::NoFile => "nofile",
3685        crate::RlimitKind::NProc => "nproc",
3686    }
3687}
3688
3689fn rlimit_kind_name(kind: crate::RlimitKind) -> &'static str {
3690    match kind {
3691        crate::RlimitKind::NoFile => "RLIMIT_NOFILE",
3692        crate::RlimitKind::NProc => "RLIMIT_NPROC",
3693    }
3694}
3695
3696fn rlimit_value_cli(value: crate::RlimitValue) -> String {
3697    match value {
3698        crate::RlimitValue::Finite(value) => value.to_string(),
3699        crate::RlimitValue::Unlimited => "-1".to_owned(),
3700    }
3701}
3702
3703fn rlimit_value_json(value: crate::RlimitValue) -> Value {
3704    match value {
3705        crate::RlimitValue::Finite(value) => json!(value),
3706        crate::RlimitValue::Unlimited => json!(-1),
3707    }
3708}
3709
3710fn rendering_runtime_finding(identity: &DeploymentResourceId, field: &'static str) -> RenderingFinding {
3711    RenderingFinding::new(
3712        DiagnosticCode::RenderingUnsupported,
3713        Some(identity.clone()),
3714        Some(field),
3715    )
3716}
3717
3718fn append_container_setting_arguments(
3719    arguments: &mut Vec<String>,
3720    container: &crate::ContainerIntent,
3721    identity: &DeploymentResourceId,
3722) -> Result<(), RenderingFinding> {
3723    let settings = container.settings();
3724    if let Some(entrypoint) = settings.entrypoint() {
3725        let encoded = serde_json::to_string(entrypoint.values()).map_err(|_| {
3726            RenderingFinding::new(
3727                DiagnosticCode::RenderingUnsupported,
3728                Some(identity.clone()),
3729                Some("entrypoint"),
3730            )
3731        })?;
3732        arguments.push("--entrypoint".to_owned());
3733        arguments.push(encoded);
3734    }
3735    if let Some(user) = settings.user() {
3736        arguments.push("--user".to_owned());
3737        arguments.push(user.as_str().to_owned());
3738    }
3739    if let Some(workdir) = settings.workdir() {
3740        arguments.push("--workdir".to_owned());
3741        arguments.push(workdir.path().as_str().to_owned());
3742    }
3743    if let Some(hostname) = settings.hostname() {
3744        arguments.push("--hostname".to_owned());
3745        arguments.push(hostname.as_str().to_owned());
3746    }
3747    for label in settings.labels() {
3748        arguments.push("--label".to_owned());
3749        arguments.push(format!("{}={}", label.key().as_str(), label.value().as_str()));
3750    }
3751    for assignment in settings.environment() {
3752        let crate::DeploymentEnvironmentValue::Public(value) = assignment.value() else {
3753            return Err(RenderingFinding::new(
3754                DiagnosticCode::RenderingUnsupported,
3755                Some(identity.clone()),
3756                Some("environment"),
3757            ));
3758        };
3759        arguments.push("--env".to_owned());
3760        arguments.push(format!("{}={}", assignment.name().as_str(), value.as_str()));
3761    }
3762    if let Some(restart) = settings.restart_policy() {
3763        arguments.push("--restart".to_owned());
3764        arguments.push(restart_policy_name(restart).to_owned());
3765    }
3766    Ok(())
3767}
3768
3769fn append_container_setting_json(
3770    body: &mut Map<String, Value>,
3771    container: &crate::ContainerIntent,
3772    identity: &DeploymentResourceId,
3773) -> Result<(), RenderingFinding> {
3774    let settings = container.settings();
3775    if let Some(command) = settings.command() {
3776        body.insert("command".to_owned(), string_array(command.values()));
3777    }
3778    if let Some(entrypoint) = settings.entrypoint() {
3779        body.insert("entrypoint".to_owned(), string_array(entrypoint.values()));
3780    }
3781    if let Some(user) = settings.user() {
3782        body.insert("user".to_owned(), Value::String(user.as_str().to_owned()));
3783    }
3784    if let Some(workdir) = settings.workdir() {
3785        body.insert("work_dir".to_owned(), Value::String(workdir.path().as_str().to_owned()));
3786    }
3787    if let Some(hostname) = settings.hostname() {
3788        body.insert("hostname".to_owned(), Value::String(hostname.as_str().to_owned()));
3789    }
3790    if !settings.labels().is_empty() {
3791        body.insert(
3792            "labels".to_owned(),
3793            Value::Object(
3794                settings
3795                    .labels()
3796                    .iter()
3797                    .map(|label| {
3798                        (
3799                            label.key().as_str().to_owned(),
3800                            Value::String(label.value().as_str().to_owned()),
3801                        )
3802                    })
3803                    .collect(),
3804            ),
3805        );
3806    }
3807    if !settings.environment().is_empty() {
3808        let mut environment = Map::new();
3809        for assignment in settings.environment() {
3810            let crate::DeploymentEnvironmentValue::Public(value) = assignment.value() else {
3811                return Err(RenderingFinding::new(
3812                    DiagnosticCode::RenderingUnsupported,
3813                    Some(identity.clone()),
3814                    Some("environment"),
3815                ));
3816            };
3817            environment.insert(
3818                assignment.name().as_str().to_owned(),
3819                Value::String(value.as_str().to_owned()),
3820            );
3821        }
3822        body.insert("env".to_owned(), Value::Object(environment));
3823    }
3824    if let Some(restart) = settings.restart_policy() {
3825        body.insert(
3826            "restart_policy".to_owned(),
3827            Value::String(restart_policy_name(restart).to_owned()),
3828        );
3829    }
3830    if !container.mounts().is_empty() {
3831        let volumes = mount_json(container.mounts());
3832        if !volumes.as_array().is_some_and(Vec::is_empty) {
3833            body.insert("volumes".to_owned(), volumes);
3834        }
3835        let mounts = native_mount_json(container.mounts());
3836        if !mounts.as_array().is_some_and(Vec::is_empty) {
3837            body.insert("mounts".to_owned(), mounts);
3838        }
3839    }
3840    Ok(())
3841}
3842
3843fn string_array(values: &[String]) -> Value {
3844    Value::Array(values.iter().cloned().map(Value::String).collect())
3845}
3846
3847fn restart_policy_name(policy: RestartPolicy) -> &'static str {
3848    match policy {
3849        RestartPolicy::No => "no",
3850        RestartPolicy::OnFailure => "on-failure",
3851        RestartPolicy::Always => "always",
3852        RestartPolicy::UnlessStopped => "unless-stopped",
3853    }
3854}
3855
3856fn percent_encode(value: &str) -> String {
3857    let mut output = String::new();
3858    for byte in value.bytes() {
3859        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
3860            output.push(char::from(byte));
3861        } else {
3862            let _ = write!(output, "%{byte:02X}");
3863        }
3864    }
3865    output
3866}
3867
3868fn shell_quote(value: &str) -> String {
3869    format!("'{}'", value.replace('\'', "'\"'\"'"))
3870}
3871
3872fn resource_kind_name(kind: ResourceKind) -> &'static str {
3873    match kind {
3874        ResourceKind::Container => "container",
3875        ResourceKind::Pod => "pod",
3876        ResourceKind::Network => "network",
3877        ResourceKind::Volume => "volume",
3878        ResourceKind::Image => "image",
3879        ResourceKind::Secret => "secret",
3880    }
3881}
3882
3883#[cfg(test)]
3884mod tests {
3885    #![allow(clippy::expect_used)]
3886
3887    use super::{RENDERING_CATALOGUE_JSON, host_alias_value, parse_renderer_catalogue};
3888    use crate::HostAlias;
3889
3890    fn decoded_catalogue() -> serde_json::Value {
3891        serde_json::from_str(RENDERING_CATALOGUE_JSON).expect("embedded catalogue JSON")
3892    }
3893
3894    fn encoded_catalogue(value: &serde_json::Value) -> String {
3895        serde_json::to_string(value).expect("catalogue JSON")
3896    }
3897
3898    #[test]
3899    fn renderer_catalogue_rejects_invalid_operation_evidence_revisions_and_noncanonical_versions() {
3900        let missing_category = RENDERING_CATALOGUE_JSON.replacen(
3901            "\"category\": \"container-start\"",
3902            "\"category\": \"container-run\"",
3903            1,
3904        );
3905        assert!(parse_renderer_catalogue(&missing_category).is_err());
3906
3907        let invalid_source = RENDERING_CATALOGUE_JSON.replacen(
3908            "pkg/api/server/register_networks.go",
3909            "pkg/api/server/register_unknown.go",
3910            1,
3911        );
3912        assert!(parse_renderer_catalogue(&invalid_source).is_err());
3913
3914        let invalid_revision = RENDERING_CATALOGUE_JSON.replacen(
3915            "f9f7d48b24b1ca4403f189caaeab1cb8ff4a9aa2",
3916            "F9f7d48b24b1ca4403f189caaeab1cb8ff4a9aa2",
3917            1,
3918        );
3919        assert!(parse_renderer_catalogue(&invalid_revision).is_err());
3920
3921        let build_metadata =
3922            RENDERING_CATALOGUE_JSON.replacen("\"version\": \"5.4.0\"", "\"version\": \"5.4.0+build\"", 1);
3923        assert!(parse_renderer_catalogue(&build_metadata).is_err());
3924    }
3925
3926    #[test]
3927    fn renderer_catalogue_requires_the_complete_strict_field_matrix() {
3928        let mut missing = decoded_catalogue();
3929        missing["reviewed_lines"][0]["field_evidence"]
3930            .as_array_mut()
3931            .expect("field evidence")
3932            .pop();
3933        assert!(parse_renderer_catalogue(&encoded_catalogue(&missing)).is_err());
3934
3935        let mut duplicate = decoded_catalogue();
3936        let fields = duplicate["reviewed_lines"][0]["field_evidence"]
3937            .as_array_mut()
3938            .expect("field evidence");
3939        fields.push(fields[0].clone());
3940        assert!(parse_renderer_catalogue(&encoded_catalogue(&duplicate)).is_err());
3941
3942        let mut wrong_operation = decoded_catalogue();
3943        wrong_operation["reviewed_lines"][0]["field_evidence"][0]["operation"] = serde_json::json!("pod-create");
3944        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_operation)).is_err());
3945
3946        let mut wrong_claim = decoded_catalogue();
3947        wrong_claim["reviewed_lines"][0]["field_evidence"][1]["cli"]["flag"] = serde_json::json!("--label");
3948        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_claim)).is_err());
3949
3950        let mut wrong_shape = decoded_catalogue();
3951        wrong_shape["reviewed_lines"][0]["field_evidence"][1]["cli"]["value_shape"] = serde_json::json!("hostname");
3952        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_shape)).is_err());
3953
3954        let mut wrong_member = decoded_catalogue();
3955        wrong_member["reviewed_lines"][0]["field_evidence"][1]["libpod"]["json_member"] = serde_json::json!("hostname");
3956        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_member)).is_err());
3957    }
3958
3959    #[test]
3960    fn renderer_catalogue_rejects_nonimmutable_field_evidence_and_capability_substitutions() {
3961        let mut legacy_source_shape = decoded_catalogue();
3962        legacy_source_shape["reviewed_lines"][0]["field_evidence"][0]["cli_source"] =
3963            serde_json::json!("https://github.com/containers/podman/blob/main/cmd/podman/containers/create.go");
3964        assert!(parse_renderer_catalogue(&encoded_catalogue(&legacy_source_shape)).is_err());
3965        let mut wrong_module_version = decoded_catalogue();
3966        wrong_module_version["reviewed_lines"][0]["common_module"]["version"] = serde_json::json!("v0.0.0");
3967        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_module_version)).is_err());
3968
3969        let mut wrong_module_revision = decoded_catalogue();
3970        wrong_module_revision["reviewed_lines"][0]["common_module"]["revision"] =
3971            serde_json::json!("0000000000000000000000000000000000000000");
3972        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_module_revision)).is_err());
3973
3974        let mut wrong_module_repository = decoded_catalogue();
3975        wrong_module_repository["reviewed_lines"][0]["common_module"]["repository"] =
3976            serde_json::json!("podman-container-tools-container-libs");
3977        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_module_repository)).is_err());
3978
3979        let mut wrong_module_path = decoded_catalogue();
3980        wrong_module_path["reviewed_lines"][0]["common_module"]["path"] = serde_json::json!("go.podman.io/common");
3981        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_module_path)).is_err());
3982
3983        let mut mutable_field_source = decoded_catalogue();
3984        mutable_field_source["reviewed_lines"][0]["field_evidence"][0]["cli_source"]["revision"] =
3985            serde_json::json!("main");
3986        assert!(parse_renderer_catalogue(&encoded_catalogue(&mutable_field_source)).is_err());
3987
3988        let mut wrong_field_repository = decoded_catalogue();
3989        wrong_field_repository["reviewed_lines"][0]["field_evidence"][0]["cli_source"]["repository"] =
3990            serde_json::json!("containers-common");
3991        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_field_repository)).is_err());
3992
3993        let mut wrong_field_path = decoded_catalogue();
3994        wrong_field_path["reviewed_lines"][0]["field_evidence"][0]["cli_source"]["path"] =
3995            serde_json::json!("cmd/podman/unknown.go");
3996        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_field_path)).is_err());
3997
3998        let mut wrong_model = decoded_catalogue();
3999        wrong_model["reviewed_lines"][0]["field_evidence"][8]["model_sources"][1]["repository"] =
4000            serde_json::json!("containers-common");
4001        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_model)).is_err());
4002
4003        let mut mutable_model = decoded_catalogue();
4004        mutable_model["reviewed_lines"][0]["field_evidence"][8]["model_sources"][1]["revision"] =
4005            serde_json::json!("main");
4006        assert!(parse_renderer_catalogue(&encoded_catalogue(&mutable_model)).is_err());
4007
4008        let mut wrong_model_path = decoded_catalogue();
4009        wrong_model_path["reviewed_lines"][0]["field_evidence"][8]["model_sources"][1]["path"] =
4010            serde_json::json!("pkg/specgen/not-volumes.go");
4011        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_model_path)).is_err());
4012
4013        let mut wrong_handler = decoded_catalogue();
4014        wrong_handler["reviewed_lines"][0]["field_evidence"][9]["handler_source"]["repository"] =
4015            serde_json::json!("containers-common");
4016        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_handler)).is_err());
4017
4018        let mut mutable_handler = decoded_catalogue();
4019        mutable_handler["reviewed_lines"][0]["field_evidence"][9]["handler_source"]["revision"] =
4020            serde_json::json!("main");
4021        assert!(parse_renderer_catalogue(&encoded_catalogue(&mutable_handler)).is_err());
4022
4023        let mut wrong_handler_path = decoded_catalogue();
4024        wrong_handler_path["reviewed_lines"][0]["field_evidence"][9]["handler_source"]["path"] =
4025            serde_json::json!("pkg/api/handlers/libpod/not-containers.go");
4026        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_handler_path)).is_err());
4027
4028        let mut missing_line = decoded_catalogue();
4029        missing_line["reviewed_lines"]
4030            .as_array_mut()
4031            .expect("reviewed lines")
4032            .remove(0);
4033        assert!(parse_renderer_catalogue(&encoded_catalogue(&missing_line)).is_err());
4034
4035        let mut substituted_line = decoded_catalogue();
4036        let lines = substituted_line["reviewed_lines"]
4037            .as_array_mut()
4038            .expect("reviewed lines");
4039        lines[0] = lines[1].clone();
4040        assert!(parse_renderer_catalogue(&encoded_catalogue(&substituted_line)).is_err());
4041    }
4042
4043    #[test]
4044    fn renderer_catalogue_rejects_b2_field_mutations_and_fabricated_pre_six_support() {
4045        const FIRST_B2_FIELD: usize = 9;
4046        const NETWORK_ORDER_FIELD: usize = 29;
4047        const ROUTE_TYPE_BLACKHOLE_FIELD: usize = 37;
4048
4049        let mut missing = decoded_catalogue();
4050        missing["reviewed_lines"][0]["field_evidence"]
4051            .as_array_mut()
4052            .expect("field evidence")
4053            .remove(FIRST_B2_FIELD);
4054        assert!(parse_renderer_catalogue(&encoded_catalogue(&missing)).is_err());
4055
4056        let mut duplicate = decoded_catalogue();
4057        let fields = duplicate["reviewed_lines"][0]["field_evidence"]
4058            .as_array_mut()
4059            .expect("field evidence");
4060        fields.push(fields[FIRST_B2_FIELD].clone());
4061        assert!(parse_renderer_catalogue(&encoded_catalogue(&duplicate)).is_err());
4062
4063        for (path, value) in [
4064            (["operation", "", ""], serde_json::json!("pod-create")),
4065            (["cli", "flag", ""], serde_json::json!("--publish")),
4066            (["cli", "value_shape", ""], serde_json::json!("port-mapping")),
4067            (["libpod", "json_member", ""], serde_json::json!("portmappings")),
4068        ] {
4069            let mut mutated = decoded_catalogue();
4070            let field = &mut mutated["reviewed_lines"][0]["field_evidence"][FIRST_B2_FIELD];
4071            if path[1].is_empty() {
4072                field[path[0]] = value;
4073            } else if path[2].is_empty() {
4074                field[path[0]][path[1]] = value;
4075            }
4076            assert!(parse_renderer_catalogue(&encoded_catalogue(&mutated)).is_err());
4077        }
4078
4079        for (path, value) in [
4080            (["repository", ""], serde_json::json!("containers-podman")),
4081            (["revision", ""], serde_json::json!("main")),
4082            (["path", ""], serde_json::json!("libnetwork/types/fabricated.go")),
4083            (
4084                ["module", "revision"],
4085                serde_json::json!("0000000000000000000000000000000000000000"),
4086            ),
4087        ] {
4088            let mut mutated = decoded_catalogue();
4089            let source = &mut mutated["reviewed_lines"][0]["field_evidence"][FIRST_B2_FIELD]["model_sources"][1];
4090            if path[1].is_empty() {
4091                source[path[0]] = value;
4092            } else {
4093                source[path[0]][path[1]] = value;
4094            }
4095            assert!(parse_renderer_catalogue(&encoded_catalogue(&mutated)).is_err());
4096        }
4097
4098        let mut fabricated_pre_six_support = decoded_catalogue();
4099        fabricated_pre_six_support["reviewed_lines"][0]["field_evidence"][NETWORK_ORDER_FIELD]["availability"] =
4100            serde_json::json!("exact");
4101        assert!(parse_renderer_catalogue(&encoded_catalogue(&fabricated_pre_six_support)).is_err());
4102
4103        let mut fabricated_pre_six_route_type = decoded_catalogue();
4104        fabricated_pre_six_route_type["reviewed_lines"][0]["field_evidence"][ROUTE_TYPE_BLACKHOLE_FIELD]["availability"] =
4105            serde_json::json!("exact");
4106        assert!(parse_renderer_catalogue(&encoded_catalogue(&fabricated_pre_six_route_type)).is_err());
4107
4108        for line_index in [3, 4, 5] {
4109            let mut substituted_repository = decoded_catalogue();
4110            substituted_repository["reviewed_lines"][line_index]["field_evidence"][FIRST_B2_FIELD]["model_sources"]
4111                [1]["repository"] = serde_json::json!("containers-common");
4112            assert!(parse_renderer_catalogue(&encoded_catalogue(&substituted_repository)).is_err());
4113        }
4114
4115        for line_index in [3, 4, 5, 6] {
4116            let mut wrong_common_path = decoded_catalogue();
4117            wrong_common_path["reviewed_lines"][line_index]["field_evidence"][FIRST_B2_FIELD]["model_sources"][1]["path"] =
4118                serde_json::json!("libnetwork/types/network.go");
4119            assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_common_path)).is_err());
4120        }
4121
4122        let mut missing_network_order_wire_model = decoded_catalogue();
4123        missing_network_order_wire_model["reviewed_lines"][5]["field_evidence"][NETWORK_ORDER_FIELD]["model_sources"]
4124            [0]["path"] = serde_json::json!("pkg/specgen/namespaces.go");
4125        assert!(parse_renderer_catalogue(&encoded_catalogue(&missing_network_order_wire_model)).is_err());
4126    }
4127
4128    #[test]
4129    fn renderer_catalogue_rejects_b3_claim_and_per_release_evidence_mutations() {
4130        let mut missing_claim = decoded_catalogue();
4131        missing_claim["runtime_field_claims"]
4132            .as_array_mut()
4133            .expect("runtime claims")
4134            .pop();
4135        assert!(parse_renderer_catalogue(&encoded_catalogue(&missing_claim)).is_err());
4136
4137        let mut duplicate_claim = decoded_catalogue();
4138        let claims = duplicate_claim["runtime_field_claims"]
4139            .as_array_mut()
4140            .expect("runtime claims");
4141        claims.push(claims[0].clone());
4142        assert!(parse_renderer_catalogue(&encoded_catalogue(&duplicate_claim)).is_err());
4143
4144        for (path, value) in [
4145            (["cli", "flag"], serde_json::json!("--health-timeout")),
4146            (["cli", "value_shape"], serde_json::json!("health-duration")),
4147            (["libpod", "json_member"], serde_json::json!("healthconfig_timeout")),
4148        ] {
4149            let mut substituted = decoded_catalogue();
4150            substituted["runtime_field_claims"][1][path[0]][path[1]] = value;
4151            assert!(parse_renderer_catalogue(&encoded_catalogue(&substituted)).is_err());
4152        }
4153
4154        for (claim_index, wrong_shape) in [
4155            (0, "boolean"),
4156            (6, "boolean"),
4157            (7, "boolean"),
4158            (12, "boolean"),
4159            (13, "boolean"),
4160            (14, "boolean"),
4161            (15, "string"),
4162            (16, "boolean"),
4163            (21, "boolean"),
4164            (25, "boolean"),
4165            (30, "boolean"),
4166        ] {
4167            let mut substituted = decoded_catalogue();
4168            substituted["runtime_field_claims"][claim_index]["libpod"]["value_shape"] = serde_json::json!(wrong_shape);
4169            assert!(parse_renderer_catalogue(&encoded_catalogue(&substituted)).is_err());
4170        }
4171
4172        let mut fabricated_journald = decoded_catalogue();
4173        let fields = fabricated_journald["reviewed_lines"][0]["runtime_evidence"]["exact_fields"]
4174            .as_array_mut()
4175            .expect("exact runtime fields");
4176        fields.push(serde_json::json!("container-log-journald-labels"));
4177        let gated = fabricated_journald["reviewed_lines"][0]["runtime_evidence"]["target_gated_fields"]
4178            .as_array_mut()
4179            .expect("target-gated runtime fields");
4180        gated.retain(|field| field != "container-log-journald-labels");
4181        assert!(parse_renderer_catalogue(&encoded_catalogue(&fabricated_journald)).is_err());
4182
4183        let mut fabricated_unlimited = decoded_catalogue();
4184        let fields = fabricated_unlimited["reviewed_lines"][1]["runtime_evidence"]["exact_fields"]
4185            .as_array_mut()
4186            .expect("exact runtime fields");
4187        fields.push(serde_json::json!("container-rlimit-unlimited"));
4188        let gated = fabricated_unlimited["reviewed_lines"][1]["runtime_evidence"]["target_gated_fields"]
4189            .as_array_mut()
4190            .expect("target-gated runtime fields");
4191        gated.retain(|field| field != "container-rlimit-unlimited");
4192        assert!(parse_renderer_catalogue(&encoded_catalogue(&fabricated_unlimited)).is_err());
4193
4194        for (member, source) in [
4195            ("cli_flag_source", "cmd/podman/common/unknown.go"),
4196            ("cli_transform_source", "pkg/specgenutil/unknown.go"),
4197            ("command_route_source", "cmd/podman/containers/unknown.go"),
4198            ("route_source", "pkg/api/server/register_unknown.go"),
4199            ("handler_source", "pkg/api/handlers/libpod/unknown.go"),
4200        ] {
4201            let mut substituted = decoded_catalogue();
4202            substituted["reviewed_lines"][0]["runtime_evidence"][member]["path"] = serde_json::json!(source);
4203            assert!(parse_renderer_catalogue(&encoded_catalogue(&substituted)).is_err());
4204        }
4205
4206        let mut wrong_model = decoded_catalogue();
4207        wrong_model["reviewed_lines"][2]["runtime_evidence"]["model_sources"][1]["path"] =
4208            serde_json::json!("pkg/specgen/specgen.go");
4209        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_model)).is_err());
4210
4211        let mut wrong_revision = decoded_catalogue();
4212        wrong_revision["reviewed_lines"][6]["runtime_evidence"]["handler_source"]["revision"] =
4213            serde_json::json!("0000000000000000000000000000000000000000");
4214        assert!(parse_renderer_catalogue(&encoded_catalogue(&wrong_revision)).is_err());
4215    }
4216
4217    #[test]
4218    fn renderer_catalogue_rejects_b4_boundary_and_evidence_mutations() {
4219        let mut missing_exact = decoded_catalogue();
4220        missing_exact["reviewed_lines"][0]["b4_evidence"]["exact_fields"]
4221            .as_array_mut()
4222            .expect("B4 exact fields")
4223            .pop();
4224        assert!(parse_renderer_catalogue(&encoded_catalogue(&missing_exact)).is_err());
4225
4226        let mut duplicate_blocked = decoded_catalogue();
4227        let blocked = duplicate_blocked["reviewed_lines"][0]["b4_evidence"]["blocked_fields"]
4228            .as_array_mut()
4229            .expect("B4 blocked fields");
4230        blocked.push(blocked[0].clone());
4231        assert!(parse_renderer_catalogue(&encoded_catalogue(&duplicate_blocked)).is_err());
4232
4233        let mut fabricated_pre_five_six = decoded_catalogue();
4234        let target_gated = fabricated_pre_five_six["reviewed_lines"][0]["b4_evidence"]["target_gated_fields"]
4235            .as_array_mut()
4236            .expect("B4 target-gated fields");
4237        let volume_uid = target_gated.remove(0);
4238        fabricated_pre_five_six["reviewed_lines"][0]["b4_evidence"]["exact_fields"]
4239            .as_array_mut()
4240            .expect("B4 exact fields")
4241            .push(volume_uid);
4242        assert!(parse_renderer_catalogue(&encoded_catalogue(&fabricated_pre_five_six)).is_err());
4243
4244        let mut mutable_source = decoded_catalogue();
4245        mutable_source["reviewed_lines"][0]["b4_evidence"]["cli_sources"][0]["revision"] = serde_json::json!("main");
4246        assert!(parse_renderer_catalogue(&encoded_catalogue(&mutable_source)).is_err());
4247
4248        let mut incorrect_model = decoded_catalogue();
4249        incorrect_model["reviewed_lines"][0]["b4_evidence"]["model_sources"][0]["path"] =
4250            serde_json::json!("pkg/specgenutil/specgen.go");
4251        assert!(parse_renderer_catalogue(&encoded_catalogue(&incorrect_model)).is_err());
4252
4253        let mut unsupported_pod_mount = decoded_catalogue();
4254        let blocked = unsupported_pod_mount["reviewed_lines"][0]["b4_evidence"]["blocked_fields"]
4255            .as_array_mut()
4256            .expect("B4 blocked fields");
4257        blocked.retain(|field| field != "pod-infra-mount");
4258        unsupported_pod_mount["reviewed_lines"][0]["b4_evidence"]["exact_fields"]
4259            .as_array_mut()
4260            .expect("B4 exact fields")
4261            .push(serde_json::json!("pod-infra-mount"));
4262        assert!(parse_renderer_catalogue(&encoded_catalogue(&unsupported_pod_mount)).is_err());
4263    }
4264
4265    #[test]
4266    fn renderer_catalogue_rejects_unknown_and_duplicate_json_keys() {
4267        let unknown = RENDERING_CATALOGUE_JSON.replacen(
4268            "\"schema_version\": 8,",
4269            "\"schema_version\": 8, \"unexpected\": true,",
4270            1,
4271        );
4272        assert!(parse_renderer_catalogue(&unknown).is_err());
4273
4274        let duplicate_root = RENDERING_CATALOGUE_JSON.replacen(
4275            "\"schema_version\": 8,",
4276            "\"schema_version\": 8, \"schema_version\": 8,",
4277            1,
4278        );
4279        assert!(parse_renderer_catalogue(&duplicate_root).is_err());
4280
4281        let duplicate_nested =
4282            RENDERING_CATALOGUE_JSON.replacen("\"flag\": null,", "\"flag\": null, \"flag\": null,", 1);
4283        assert!(parse_renderer_catalogue(&duplicate_nested).is_err());
4284    }
4285
4286    #[test]
4287    fn host_alias_rendering_uses_podmans_single_hostname_separator_for_both_ip_families() {
4288        let ipv4 = HostAlias::new("192.0.2.53".parse().expect("IPv4"), "resolver.example").expect("host alias");
4289        assert_eq!(host_alias_value(&ipv4), "resolver.example:192.0.2.53");
4290        let ipv6 = HostAlias::new("2001:db8::53".parse().expect("IPv6"), "resolver-v6.example").expect("host alias");
4291        assert_eq!(host_alias_value(&ipv6), "resolver-v6.example:2001:db8::53");
4292    }
4293}