Skip to main content

podman_lens/
settings.rs

1//! Typed, bounded deployment settings that do not expose raw configuration strings by default.
2
3use std::fmt;
4
5use crate::{
6    DeploymentResourceId, Diagnostic, DiagnosticCode, PodmanLensResult, ResourceKind, SensitiveInputReference,
7};
8
9const MAX_ARGUMENTS: usize = 128;
10const MAX_ARGUMENT_BYTES: usize = 4096;
11const MAX_VALUE_BYTES: usize = 4096;
12const MAX_LABELS: usize = 128;
13const MAX_ENVIRONMENT: usize = 128;
14const MAX_PATH_BYTES: usize = 4096;
15
16/// An ordered, bounded command or entrypoint argument array.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct ArgumentArray(Vec<String>);
19
20impl ArgumentArray {
21    /// Creates a validated nonempty argument array. Empty individual arguments are valid.
22    ///
23    /// # Errors
24    ///
25    /// Returns `PLN0034` for an empty array, more than 128 arguments, or arguments containing
26    /// controls or exceeding 4096 bytes.
27    pub fn new<I, S>(arguments: I) -> PodmanLensResult<Self>
28    where
29        I: IntoIterator<Item = S>,
30        S: Into<String>,
31    {
32        let arguments = arguments.into_iter().map(Into::into).collect::<Vec<String>>();
33        if arguments.is_empty()
34            || arguments.len() > MAX_ARGUMENTS
35            || arguments
36                .iter()
37                .any(|argument| !valid_non_control(argument, MAX_ARGUMENT_BYTES))
38        {
39            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
40        }
41        Ok(Self(arguments))
42    }
43
44    /// Returns arguments in their declared order.
45    #[must_use]
46    pub fn values(&self) -> &[String] {
47        &self.0
48    }
49}
50
51/// A bounded `user` or `user:group` spelling passed to a container runtime.
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct ContainerUser(String);
54
55impl ContainerUser {
56    /// Creates a user setting from a safe runtime spelling.
57    ///
58    /// # Errors
59    ///
60    /// Returns `PLN0034` for an empty, oversized, unsafe, or malformed component list. Exactly
61    /// one nonempty user or UID component is required; one nonempty group or GID component may
62    /// follow after a single colon.
63    pub fn new(value: impl Into<String>) -> PodmanLensResult<Self> {
64        let value = value.into();
65        let mut components = value.split(':');
66        let Some(user) = components.next() else {
67            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
68        };
69        let group = components.next();
70        if value.len() > MAX_VALUE_BYTES
71            || !valid_user_component(user)
72            || group.is_some_and(|component| !valid_user_component(component))
73            || components.next().is_some()
74        {
75            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
76        }
77        Ok(Self(value))
78    }
79
80    /// Returns the validated user spelling.
81    #[must_use]
82    pub fn as_str(&self) -> &str {
83        &self.0
84    }
85}
86
87/// A normalized absolute path inside a container namespace.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct AbsoluteContainerPath(String);
90
91impl AbsoluteContainerPath {
92    /// Creates an absolute normalized container path.
93    ///
94    /// # Errors
95    ///
96    /// Returns `PLN0034` unless the path begins with `/`, contains no empty, `.` or `..`
97    /// components (except the root path), controls, backslashes, or more than 4096 bytes.
98    pub fn new(value: impl Into<String>) -> PodmanLensResult<Self> {
99        let value = value.into();
100        if !is_absolute_normalized_path(&value) {
101            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
102        }
103        Ok(Self(value))
104    }
105
106    /// Returns the normalized absolute path.
107    #[must_use]
108    pub fn as_str(&self) -> &str {
109        &self.0
110    }
111}
112
113/// A container working directory.
114#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct ContainerWorkdir(AbsoluteContainerPath);
116
117impl ContainerWorkdir {
118    /// Creates a working directory from one normalized absolute container path.
119    #[must_use]
120    pub const fn new(path: AbsoluteContainerPath) -> Self {
121        Self(path)
122    }
123
124    /// Returns the normalized working-directory path.
125    #[must_use]
126    pub fn path(&self) -> &AbsoluteContainerPath {
127        &self.0
128    }
129}
130
131/// A bounded RFC-style container hostname.
132#[derive(Clone, Debug, Eq, PartialEq)]
133pub struct ContainerHostname(String);
134
135impl ContainerHostname {
136    /// Creates a hostname from ASCII labels separated by dots.
137    ///
138    /// # Errors
139    ///
140    /// Returns `PLN0034` for an empty, oversized, or malformed hostname.
141    pub fn new(value: impl Into<String>) -> PodmanLensResult<Self> {
142        let value = value.into();
143        if value.is_empty()
144            || value.len() > 253
145            || value.split('.').any(|label| {
146                label.is_empty()
147                    || label.len() > 63
148                    || label.starts_with('-')
149                    || label.ends_with('-')
150                    || !label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
151            })
152        {
153            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
154        }
155        Ok(Self(value))
156    }
157
158    /// Returns the validated hostname.
159    #[must_use]
160    pub fn as_str(&self) -> &str {
161        &self.0
162    }
163}
164
165/// A label key retained in insertion order by [`ContainerSettings`].
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct LabelKey(String);
168
169impl LabelKey {
170    /// Creates a bounded label key.
171    ///
172    /// # Errors
173    ///
174    /// Returns `PLN0034` for empty, oversized, control-containing, or `=`-containing values.
175    pub fn new(value: impl Into<String>) -> PodmanLensResult<Self> {
176        let value = value.into();
177        if value.is_empty() || !valid_non_control(&value, MAX_VALUE_BYTES) || value.contains('=') {
178            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
179        }
180        Ok(Self(value))
181    }
182
183    /// Returns the validated key.
184    #[must_use]
185    pub fn as_str(&self) -> &str {
186        &self.0
187    }
188}
189
190/// An explicitly caller-authorized public label value. Empty values are valid.
191///
192/// Constructing this type is an explicit declassification decision: it authorizes this value for
193/// deployment artifacts, CLI arguments, Libpod JSON, and shell-review output once the matching
194/// renderer exists. Do not construct it from observed sensitive runtime values.
195#[derive(Clone, Debug, Eq, PartialEq)]
196pub struct PublicLabelValue(String);
197
198impl PublicLabelValue {
199    /// Creates a bounded explicitly public label value.
200    ///
201    /// # Errors
202    ///
203    /// Returns `PLN0034` for oversized or control-containing values.
204    pub fn new(value: impl Into<String>) -> PodmanLensResult<Self> {
205        let value = value.into();
206        if !valid_value(&value) {
207            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
208        }
209        Ok(Self(value))
210    }
211
212    /// Returns the validated value.
213    #[must_use]
214    pub fn as_str(&self) -> &str {
215        &self.0
216    }
217}
218
219/// One ordered container label.
220#[derive(Clone, Debug, Eq, PartialEq)]
221pub struct Label {
222    key: LabelKey,
223    value: PublicLabelValue,
224}
225
226impl Label {
227    /// Creates one label from validated key and value types.
228    #[must_use]
229    pub const fn new(key: LabelKey, value: PublicLabelValue) -> Self {
230        Self { key, value }
231    }
232
233    /// Returns the label key.
234    #[must_use]
235    pub fn key(&self) -> &LabelKey {
236        &self.key
237    }
238
239    /// Returns the label value.
240    #[must_use]
241    pub fn value(&self) -> &PublicLabelValue {
242        &self.value
243    }
244}
245
246/// A validated environment-variable name.
247#[derive(Clone, Debug, Eq, PartialEq)]
248pub struct EnvironmentName(String);
249
250impl EnvironmentName {
251    /// Creates a POSIX-style environment-variable name.
252    ///
253    /// # Errors
254    ///
255    /// Returns `PLN0034` for an empty, oversized, or non-POSIX identifier.
256    pub fn new(value: impl Into<String>) -> PodmanLensResult<Self> {
257        let value = value.into();
258        let mut bytes = value.bytes();
259        let Some(first) = bytes.next() else {
260            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
261        };
262        if value.len() > 256
263            || !(first.is_ascii_alphabetic() || first == b'_')
264            || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
265        {
266            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
267        }
268        Ok(Self(value))
269    }
270
271    /// Returns the environment-variable name.
272    #[must_use]
273    pub fn as_str(&self) -> &str {
274        &self.0
275    }
276}
277
278/// An explicitly caller-authorized public environment value. Empty values are valid.
279///
280/// Constructing this type is an explicit declassification decision: it authorizes this value for
281/// deployment artifacts, CLI arguments, Libpod JSON, and shell-review output once the matching
282/// renderer exists. Do not construct it from observed sensitive runtime values.
283#[derive(Clone, Debug, Eq, PartialEq)]
284pub struct PublicEnvironmentValue(String);
285
286impl PublicEnvironmentValue {
287    /// Creates one bounded explicitly public environment value.
288    ///
289    /// # Errors
290    ///
291    /// Returns `PLN0034` for oversized or control-containing values.
292    pub fn new(value: impl Into<String>) -> PodmanLensResult<Self> {
293        let value = value.into();
294        if !valid_value(&value) {
295            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
296        }
297        Ok(Self(value))
298    }
299
300    /// Returns the plain value.
301    #[must_use]
302    pub fn as_str(&self) -> &str {
303        &self.0
304    }
305}
306
307/// An inline environment value that must never be exposed by diagnostics or debug output.
308#[derive(Clone, Eq, PartialEq)]
309pub struct SensitiveInlineEnvironmentValue(String);
310
311impl SensitiveInlineEnvironmentValue {
312    /// Creates a bounded sensitive inline value. Empty values are valid.
313    ///
314    /// # Errors
315    ///
316    /// Returns `PLN0034` for oversized or control-containing values.
317    pub fn new(value: impl Into<String>) -> PodmanLensResult<Self> {
318        let value = value.into();
319        if !valid_value(&value) {
320            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
321        }
322        Ok(Self(value))
323    }
324}
325
326impl fmt::Debug for SensitiveInlineEnvironmentValue {
327    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
328        formatter.write_str("SensitiveInlineEnvironmentValue([redacted])")
329    }
330}
331
332/// The source of an environment value.
333#[derive(Clone, Debug, Eq, PartialEq)]
334#[non_exhaustive]
335pub enum DeploymentEnvironmentValue {
336    /// An explicitly caller-authorized public, directly declared value.
337    Public(PublicEnvironmentValue),
338    /// A directly declared sensitive value that remains redacted.
339    SensitiveInline(SensitiveInlineEnvironmentValue),
340    /// A sensitive value supplied by a caller-owned external input.
341    External(SensitiveInputReference),
342}
343
344/// One ordered environment assignment.
345#[derive(Clone, Debug, Eq, PartialEq)]
346pub struct EnvironmentAssignment {
347    name: EnvironmentName,
348    value: DeploymentEnvironmentValue,
349}
350
351impl EnvironmentAssignment {
352    /// Creates one typed environment assignment.
353    #[must_use]
354    pub const fn new(name: EnvironmentName, value: DeploymentEnvironmentValue) -> Self {
355        Self { name, value }
356    }
357
358    /// Returns the variable name.
359    #[must_use]
360    pub fn name(&self) -> &EnvironmentName {
361        &self.name
362    }
363
364    /// Returns the typed value source.
365    #[must_use]
366    pub fn value(&self) -> &DeploymentEnvironmentValue {
367        &self.value
368    }
369}
370
371/// A supported container restart policy.
372#[derive(Clone, Copy, Debug, Eq, PartialEq)]
373#[non_exhaustive]
374pub enum RestartPolicy {
375    /// Do not automatically restart the container.
376    No,
377    /// Restart only after a non-zero exit status.
378    OnFailure,
379    /// Always restart the container.
380    Always,
381    /// Restart unless the user explicitly stopped the container.
382    UnlessStopped,
383}
384
385/// Copy behavior for a named volume initialized by a container image.
386#[derive(Clone, Copy, Debug, Eq, PartialEq)]
387#[non_exhaustive]
388pub enum NamedVolumeCopyMode {
389    /// Copy image content into a newly created volume when the runtime supports it.
390    Copy,
391    /// Do not copy image content into the mounted volume.
392    NoCopy,
393}
394
395/// Explicit read/write access for one typed mount.
396#[derive(Clone, Copy, Debug, Eq, PartialEq)]
397#[non_exhaustive]
398pub enum MountAccess {
399    /// The mounted path is writable from the container.
400    ReadWrite,
401    /// The mounted path is read-only from the container.
402    ReadOnly,
403}
404
405impl MountAccess {
406    /// Returns whether the access declaration is read-only.
407    #[must_use]
408    pub const fn is_read_only(self) -> bool {
409        matches!(self, Self::ReadOnly)
410    }
411}
412
413/// A normalized absolute path rooted at the named volume root.
414///
415/// Podman's native `SubPath` is absolute relative to a volume root, not relative to the container
416/// filesystem. Empty, relative, `.` and `..` components are rejected before a renderer can build
417/// a native mount representation.
418#[derive(Clone, Debug, Eq, PartialEq)]
419pub struct VolumeSubpath(String);
420
421impl VolumeSubpath {
422    /// Creates one normalized absolute volume-root subpath.
423    ///
424    /// # Errors
425    ///
426    /// Returns `PLN0034` for an empty, relative, unsafe, or non-normalized spelling.
427    pub fn new(value: impl Into<String>) -> PodmanLensResult<Self> {
428        let value = value.into();
429        if value.len() > MAX_PATH_BYTES
430            || !value.starts_with('/')
431            || value.contains('\\')
432            || value.chars().any(char::is_control)
433            || value
434                .split('/')
435                .skip(1)
436                .any(|component| component.is_empty() || matches!(component, "." | ".."))
437        {
438            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
439        }
440        Ok(Self(value))
441    }
442
443    /// Returns the validated absolute volume-root subpath.
444    #[must_use]
445    pub fn as_str(&self) -> &str {
446        &self.0
447    }
448}
449
450/// One named volume mounted at an exact normalized container destination.
451#[derive(Clone, Debug, Eq, PartialEq)]
452pub struct NamedVolumeMount {
453    source: DeploymentResourceId,
454    destination: AbsoluteContainerPath,
455    access: MountAccess,
456    copy_mode: NamedVolumeCopyMode,
457    subpath: Option<VolumeSubpath>,
458}
459
460impl NamedVolumeMount {
461    /// Creates one named-volume mount.
462    ///
463    /// # Errors
464    ///
465    /// Returns `PLN0034` when `source` is not a volume identity.
466    pub fn new(
467        source: DeploymentResourceId,
468        destination: AbsoluteContainerPath,
469        access: MountAccess,
470        copy_mode: NamedVolumeCopyMode,
471    ) -> PodmanLensResult<Self> {
472        if source.kind() != ResourceKind::Volume {
473            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
474        }
475        Ok(Self {
476            source,
477            destination,
478            access,
479            copy_mode,
480            subpath: None,
481        })
482    }
483
484    /// Adds one volume-root-relative subpath.
485    ///
486    /// Podman's dual CLI/API representation is exact only with normal copy behavior. `nocopy`
487    /// plus `subpath` is deliberately rejected rather than silently changing initialization.
488    ///
489    /// # Errors
490    ///
491    /// Returns `PLN0038` for a repeated subpath or for `NoCopy`.
492    pub fn set_subpath(&mut self, subpath: VolumeSubpath) -> PodmanLensResult<()> {
493        if self.subpath.is_some() || self.copy_mode == NamedVolumeCopyMode::NoCopy {
494            return Err(Diagnostic::new(DiagnosticCode::DeploymentUnsupportedCombination));
495        }
496        self.subpath = Some(subpath);
497        Ok(())
498    }
499
500    /// Returns the named-volume prerequisite identity.
501    #[must_use]
502    pub fn source(&self) -> &DeploymentResourceId {
503        &self.source
504    }
505
506    /// Returns the normalized mount destination.
507    #[must_use]
508    pub fn destination(&self) -> &AbsoluteContainerPath {
509        &self.destination
510    }
511
512    /// Returns whether the mount is read-only.
513    #[must_use]
514    pub const fn is_read_only(&self) -> bool {
515        self.access.is_read_only()
516    }
517
518    /// Returns the explicit mount access mode.
519    #[must_use]
520    pub const fn access(&self) -> MountAccess {
521        self.access
522    }
523
524    /// Returns image-content copy behavior.
525    #[must_use]
526    pub const fn copy_mode(&self) -> NamedVolumeCopyMode {
527        self.copy_mode
528    }
529
530    /// Returns the optional volume-root-relative source subpath.
531    #[must_use]
532    pub fn subpath(&self) -> Option<&VolumeSubpath> {
533        self.subpath.as_ref()
534    }
535}
536
537/// One host bind mount at an exact normalized container destination.
538#[derive(Clone, Debug, Eq, PartialEq)]
539pub struct BindMount {
540    source: AbsoluteContainerPath,
541    destination: AbsoluteContainerPath,
542    access: MountAccess,
543}
544
545impl BindMount {
546    /// Creates one normalized host-path bind mount.
547    #[must_use]
548    pub const fn new(source: AbsoluteContainerPath, destination: AbsoluteContainerPath, access: MountAccess) -> Self {
549        Self {
550            source,
551            destination,
552            access,
553        }
554    }
555
556    /// Returns the normalized absolute host source path.
557    #[must_use]
558    pub fn source(&self) -> &AbsoluteContainerPath {
559        &self.source
560    }
561
562    /// Returns the normalized container destination path.
563    #[must_use]
564    pub fn destination(&self) -> &AbsoluteContainerPath {
565        &self.destination
566    }
567
568    /// Returns the declared access mode.
569    #[must_use]
570    pub const fn access(&self) -> MountAccess {
571        self.access
572    }
573}
574
575/// One tmpfs mount at an exact normalized container destination.
576#[derive(Clone, Debug, Eq, PartialEq)]
577pub struct TmpfsMount {
578    destination: AbsoluteContainerPath,
579    access: MountAccess,
580}
581
582impl TmpfsMount {
583    /// Creates one tmpfs mount.
584    #[must_use]
585    pub const fn new(destination: AbsoluteContainerPath, access: MountAccess) -> Self {
586        Self { destination, access }
587    }
588
589    /// Returns the normalized container destination path.
590    #[must_use]
591    pub fn destination(&self) -> &AbsoluteContainerPath {
592        &self.destination
593    }
594
595    /// Returns the declared access mode.
596    #[must_use]
597    pub const fn access(&self) -> MountAccess {
598        self.access
599    }
600}
601
602/// A typed exact container mount. Raw `--volume` spelling is deliberately not public API.
603#[derive(Clone, Debug, Eq, PartialEq)]
604#[non_exhaustive]
605pub enum MountIntent {
606    /// A named Podman volume.
607    NamedVolume(NamedVolumeMount),
608    /// A host filesystem bind mount.
609    Bind(BindMount),
610    /// An in-memory tmpfs mount.
611    Tmpfs(TmpfsMount),
612}
613
614impl From<NamedVolumeMount> for MountIntent {
615    fn from(mount: NamedVolumeMount) -> Self {
616        Self::NamedVolume(mount)
617    }
618}
619
620impl From<BindMount> for MountIntent {
621    fn from(mount: BindMount) -> Self {
622        Self::Bind(mount)
623    }
624}
625
626impl From<TmpfsMount> for MountIntent {
627    fn from(mount: TmpfsMount) -> Self {
628        Self::Tmpfs(mount)
629    }
630}
631
632impl MountIntent {
633    /// Returns the normalized container destination shared by all mount forms.
634    #[must_use]
635    pub fn destination(&self) -> &AbsoluteContainerPath {
636        match self {
637            Self::NamedVolume(mount) => mount.destination(),
638            Self::Bind(mount) => mount.destination(),
639            Self::Tmpfs(mount) => mount.destination(),
640        }
641    }
642
643    /// Returns the named-volume source identity when this is a named-volume mount.
644    #[must_use]
645    pub fn volume_source(&self) -> Option<&DeploymentResourceId> {
646        match self {
647            Self::NamedVolume(mount) => Some(mount.source()),
648            Self::Bind(_) | Self::Tmpfs(_) => None,
649        }
650    }
651}
652
653/// A bounded Unix ownership value for a volume, mount, or secret declaration.
654#[derive(Clone, Copy, Debug, Eq, PartialEq)]
655pub struct UnixId(u32);
656
657impl UnixId {
658    /// Creates one ownership value in Podman's conservative signed 32-bit range.
659    ///
660    /// # Errors
661    ///
662    /// Returns `PLN0034` for values greater than `i32::MAX`.
663    pub fn new(value: u32) -> PodmanLensResult<Self> {
664        if value > i32::MAX as u32 {
665            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
666        }
667        Ok(Self(value))
668    }
669
670    /// Returns the native numeric ownership value.
671    #[must_use]
672    pub const fn get(self) -> u32 {
673        self.0
674    }
675}
676
677/// A bounded Unix file mode for a mounted secret.
678#[derive(Clone, Copy, Debug, Eq, PartialEq)]
679pub struct SecretMode(u16);
680
681impl SecretMode {
682    /// Creates an ordinary Unix permission mode (`0o000` through `0o777`).
683    ///
684    /// # Errors
685    ///
686    /// Returns `PLN0034` for bits outside the portable permission range.
687    pub fn new(value: u16) -> PodmanLensResult<Self> {
688        if value > 0o777 {
689            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
690        }
691        Ok(Self(value))
692    }
693
694    /// Returns the numeric Unix mode.
695    #[must_use]
696    pub const fn get(self) -> u16 {
697        self.0
698    }
699}
700
701/// A typed secret attachment to a container mount or environment name.
702#[derive(Clone, Debug, Eq, PartialEq)]
703#[non_exhaustive]
704pub enum SecretGrant {
705    /// Mount a secret. An omitted target uses Podman's native secret-name destination.
706    Mount {
707        /// The declared managed or external secret identity.
708        source: DeploymentResourceId,
709        /// The optional target path.
710        target: Option<AbsoluteContainerPath>,
711        /// Optional mount UID.
712        uid: Option<UnixId>,
713        /// Optional mount GID.
714        gid: Option<UnixId>,
715        /// Optional mount mode.
716        mode: Option<SecretMode>,
717    },
718    /// Inject a secret into one exact environment variable name.
719    Environment {
720        /// The declared managed or external secret identity.
721        source: DeploymentResourceId,
722        /// The target environment name.
723        target: EnvironmentName,
724    },
725}
726
727impl SecretGrant {
728    /// Creates one mount-form secret grant with Podman's default target and mode.
729    ///
730    /// # Errors
731    ///
732    /// Returns `PLN0034` when `source` is not a secret identity.
733    pub fn mount(source: DeploymentResourceId) -> PodmanLensResult<Self> {
734        if source.kind() != ResourceKind::Secret {
735            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
736        }
737        Ok(Self::Mount {
738            source,
739            target: None,
740            uid: None,
741            gid: None,
742            mode: None,
743        })
744    }
745
746    /// Creates one environment-form secret grant.
747    ///
748    /// # Errors
749    ///
750    /// Returns `PLN0034` when `source` is not a secret identity.
751    pub fn environment(source: DeploymentResourceId, target: EnvironmentName) -> PodmanLensResult<Self> {
752        if source.kind() != ResourceKind::Secret {
753            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
754        }
755        Ok(Self::Environment { source, target })
756    }
757
758    /// Sets the optional mount target. Environment grants reject this operation.
759    ///
760    /// # Errors
761    ///
762    /// Returns `PLN0038` for an environment grant or a repeated mount target.
763    pub fn set_mount_target(&mut self, target: AbsoluteContainerPath) -> PodmanLensResult<()> {
764        match self {
765            Self::Mount { target: slot, .. } if slot.is_none() => {
766                *slot = Some(target);
767                Ok(())
768            }
769            Self::Mount { .. } | Self::Environment { .. } => {
770                Err(Diagnostic::new(DiagnosticCode::DeploymentUnsupportedCombination))
771            }
772        }
773    }
774
775    /// Sets one optional mount UID. Environment grants reject this operation.
776    ///
777    /// # Errors
778    ///
779    /// Returns `PLN0038` for an environment grant or a repeated UID.
780    pub fn set_mount_uid(&mut self, uid: UnixId) -> PodmanLensResult<()> {
781        set_secret_mount_option(self, uid, |grant| match grant {
782            Self::Mount { uid, .. } => uid,
783            Self::Environment { .. } => unreachable!("environment grants are rejected before access"),
784        })
785    }
786
787    /// Sets one optional mount GID. Environment grants reject this operation.
788    ///
789    /// # Errors
790    ///
791    /// Returns `PLN0038` for an environment grant or a repeated GID.
792    pub fn set_mount_gid(&mut self, gid: UnixId) -> PodmanLensResult<()> {
793        set_secret_mount_option(self, gid, |grant| match grant {
794            Self::Mount { gid, .. } => gid,
795            Self::Environment { .. } => unreachable!("environment grants are rejected before access"),
796        })
797    }
798
799    /// Sets one optional mount file mode. Environment grants reject this operation.
800    ///
801    /// # Errors
802    ///
803    /// Returns `PLN0038` for an environment grant or a repeated mode.
804    pub fn set_mount_mode(&mut self, mode: SecretMode) -> PodmanLensResult<()> {
805        set_secret_mount_option(self, mode, |grant| match grant {
806            Self::Mount { mode, .. } => mode,
807            Self::Environment { .. } => unreachable!("environment grants are rejected before access"),
808        })
809    }
810
811    /// Returns the referenced secret identity.
812    #[must_use]
813    pub fn source(&self) -> &DeploymentResourceId {
814        match self {
815            Self::Mount { source, .. } | Self::Environment { source, .. } => source,
816        }
817    }
818
819    /// Returns the mount target, when this is a mount grant and a target was explicitly set.
820    #[must_use]
821    pub fn mount_target(&self) -> Option<&AbsoluteContainerPath> {
822        match self {
823            Self::Mount { target, .. } => target.as_ref(),
824            Self::Environment { .. } => None,
825        }
826    }
827
828    /// Returns the environment target when this is an environment grant.
829    #[must_use]
830    pub fn environment_target(&self) -> Option<&EnvironmentName> {
831        match self {
832            Self::Environment { target, .. } => Some(target),
833            Self::Mount { .. } => None,
834        }
835    }
836
837    /// Returns optional mount UID.
838    #[must_use]
839    pub fn mount_uid(&self) -> Option<UnixId> {
840        match self {
841            Self::Mount { uid, .. } => *uid,
842            Self::Environment { .. } => None,
843        }
844    }
845
846    /// Returns optional mount GID.
847    #[must_use]
848    pub fn mount_gid(&self) -> Option<UnixId> {
849        match self {
850            Self::Mount { gid, .. } => *gid,
851            Self::Environment { .. } => None,
852        }
853    }
854
855    /// Returns optional mount mode.
856    #[must_use]
857    pub fn mount_mode(&self) -> Option<SecretMode> {
858        match self {
859            Self::Mount { mode, .. } => *mode,
860            Self::Environment { .. } => None,
861        }
862    }
863}
864
865fn set_secret_mount_option<T: Eq>(
866    grant: &mut SecretGrant,
867    value: T,
868    member: impl FnOnce(&mut SecretGrant) -> &mut Option<T>,
869) -> PodmanLensResult<()> {
870    if !matches!(grant, SecretGrant::Mount { .. }) {
871        return Err(Diagnostic::new(DiagnosticCode::DeploymentUnsupportedCombination));
872    }
873    let slot = member(grant);
874    if slot.is_some() {
875        return Err(Diagnostic::new(DiagnosticCode::DeploymentUnsupportedCombination));
876    }
877    *slot = Some(value);
878    Ok(())
879}
880
881/// Typed optional container settings retained separately from topology.
882#[derive(Clone, Debug, Default, Eq, PartialEq)]
883pub struct ContainerSettings {
884    command: Option<ArgumentArray>,
885    entrypoint: Option<ArgumentArray>,
886    user: Option<ContainerUser>,
887    workdir: Option<ContainerWorkdir>,
888    hostname: Option<ContainerHostname>,
889    labels: Vec<Label>,
890    environment: Vec<EnvironmentAssignment>,
891    restart_policy: Option<RestartPolicy>,
892}
893
894impl ContainerSettings {
895    /// Assigns one command array, rejecting repeated or conflicting assignments.
896    ///
897    /// # Errors
898    ///
899    /// Returns `PLN0035` for an identical repeat and `PLN0038` for a conflict.
900    pub fn set_command(&mut self, command: ArgumentArray) -> PodmanLensResult<()> {
901        set_once(&mut self.command, command)
902    }
903
904    /// Assigns one entrypoint array, rejecting repeated or conflicting assignments.
905    ///
906    /// # Errors
907    ///
908    /// Returns `PLN0035` for an identical repeat and `PLN0038` for a conflict.
909    pub fn set_entrypoint(&mut self, entrypoint: ArgumentArray) -> PodmanLensResult<()> {
910        set_once(&mut self.entrypoint, entrypoint)
911    }
912
913    /// Assigns one user, rejecting repeated or conflicting assignments.
914    ///
915    /// # Errors
916    ///
917    /// Returns `PLN0035` for an identical repeat and `PLN0038` for a conflict.
918    pub fn set_user(&mut self, user: ContainerUser) -> PodmanLensResult<()> {
919        set_once(&mut self.user, user)
920    }
921
922    /// Assigns one working directory, rejecting repeated or conflicting assignments.
923    ///
924    /// # Errors
925    ///
926    /// Returns `PLN0035` for an identical repeat and `PLN0038` for a conflict.
927    pub fn set_workdir(&mut self, workdir: ContainerWorkdir) -> PodmanLensResult<()> {
928        set_once(&mut self.workdir, workdir)
929    }
930
931    /// Assigns one hostname, rejecting repeated or conflicting assignments.
932    ///
933    /// # Errors
934    ///
935    /// Returns `PLN0035` for an identical repeat and `PLN0038` for a conflict.
936    pub fn set_hostname(&mut self, hostname: ContainerHostname) -> PodmanLensResult<()> {
937        set_once(&mut self.hostname, hostname)
938    }
939
940    /// Adds one label while preserving declared order.
941    ///
942    /// # Errors
943    ///
944    /// Returns `PLN0035` for a duplicate key and `PLN0034` when the bounded collection is full.
945    pub fn add_label(&mut self, label: Label) -> PodmanLensResult<()> {
946        if self.labels.len() == MAX_LABELS {
947            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
948        }
949        if self.labels.iter().any(|existing| existing.key == label.key) {
950            return Err(Diagnostic::new(DiagnosticCode::DeploymentDuplicateResource));
951        }
952        self.labels.push(label);
953        Ok(())
954    }
955
956    /// Adds one environment assignment while preserving declared order.
957    ///
958    /// # Errors
959    ///
960    /// Returns `PLN0035` for a duplicate name and `PLN0034` when the bounded collection is full.
961    pub fn add_environment(&mut self, assignment: EnvironmentAssignment) -> PodmanLensResult<()> {
962        if self.environment.len() == MAX_ENVIRONMENT {
963            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
964        }
965        if self.environment.iter().any(|existing| existing.name == assignment.name) {
966            return Err(Diagnostic::new(DiagnosticCode::DeploymentDuplicateResource));
967        }
968        self.environment.push(assignment);
969        Ok(())
970    }
971
972    /// Assigns one restart policy, rejecting repeated or conflicting assignments.
973    ///
974    /// # Errors
975    ///
976    /// Returns `PLN0035` for an identical repeat and `PLN0038` for a conflict.
977    pub fn set_restart_policy(&mut self, restart_policy: RestartPolicy) -> PodmanLensResult<()> {
978        set_once(&mut self.restart_policy, restart_policy)
979    }
980
981    /// Returns the optional command.
982    #[must_use]
983    pub fn command(&self) -> Option<&ArgumentArray> {
984        self.command.as_ref()
985    }
986
987    /// Returns the optional entrypoint.
988    #[must_use]
989    pub fn entrypoint(&self) -> Option<&ArgumentArray> {
990        self.entrypoint.as_ref()
991    }
992
993    /// Returns the optional user.
994    #[must_use]
995    pub fn user(&self) -> Option<&ContainerUser> {
996        self.user.as_ref()
997    }
998
999    /// Returns the optional working directory.
1000    #[must_use]
1001    pub fn workdir(&self) -> Option<&ContainerWorkdir> {
1002        self.workdir.as_ref()
1003    }
1004
1005    /// Returns the optional hostname.
1006    #[must_use]
1007    pub fn hostname(&self) -> Option<&ContainerHostname> {
1008        self.hostname.as_ref()
1009    }
1010
1011    /// Returns labels in declared order.
1012    #[must_use]
1013    pub fn labels(&self) -> &[Label] {
1014        &self.labels
1015    }
1016
1017    /// Returns environment assignments in declared order.
1018    #[must_use]
1019    pub fn environment(&self) -> &[EnvironmentAssignment] {
1020        &self.environment
1021    }
1022
1023    /// Returns the optional restart policy.
1024    #[must_use]
1025    pub const fn restart_policy(&self) -> Option<RestartPolicy> {
1026        self.restart_policy
1027    }
1028}
1029
1030fn set_once<T: Eq>(slot: &mut Option<T>, value: T) -> PodmanLensResult<()> {
1031    match slot {
1032        None => {
1033            *slot = Some(value);
1034            Ok(())
1035        }
1036        Some(existing) if existing == &value => Err(Diagnostic::new(DiagnosticCode::DeploymentDuplicateResource)),
1037        Some(_) => Err(Diagnostic::new(DiagnosticCode::DeploymentUnsupportedCombination)),
1038    }
1039}
1040
1041fn valid_value(value: &str) -> bool {
1042    valid_non_control(value, MAX_VALUE_BYTES)
1043}
1044
1045fn valid_non_control(value: &str, maximum_bytes: usize) -> bool {
1046    value.len() <= maximum_bytes && !value.chars().any(char::is_control)
1047}
1048
1049fn valid_user_component(value: &str) -> bool {
1050    !value.is_empty()
1051        && value
1052            .bytes()
1053            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
1054}
1055
1056fn is_absolute_normalized_path(value: &str) -> bool {
1057    value.len() <= MAX_PATH_BYTES
1058        && value.starts_with('/')
1059        && !value.contains('\\')
1060        && !value.chars().any(char::is_control)
1061        && (value == "/"
1062            || value
1063                .split('/')
1064                .skip(1)
1065                .all(|component| !component.is_empty() && component != "." && component != ".."))
1066}