Skip to main content

boxferry_model/
application.rs

1//! Ordered neutral application graph and resource attachments.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    error::Error,
6    fmt,
7    net::IpAddr,
8};
9
10use crate::{ImageAcquisition, ImageBuild, ImageReference, ProtectedString, Provenance, Sourced};
11
12/// Error raised when constructing an invalid neutral model value.
13#[derive(Clone, Debug, Eq, PartialEq)]
14#[non_exhaustive]
15pub enum ModelError {
16    /// A required value was empty.
17    EmptyValue(&'static str),
18    /// A value contained a NUL byte.
19    ContainsNul(&'static str),
20    /// A source byte range ended before it started.
21    ReversedSpan {
22        /// Inclusive start offset.
23        start: usize,
24        /// Exclusive end offset.
25        end: usize,
26    },
27    /// An application already contains a resource of this kind and name.
28    DuplicateResource {
29        /// Neutral resource kind.
30        kind: &'static str,
31        /// Duplicated name.
32        name: String,
33    },
34    /// Retained native evidence referred to a resource absent from the application.
35    UnknownNativeEvidenceOwner {
36        /// Neutral resource kind.
37        kind: &'static str,
38        /// Missing resource name.
39        name: String,
40    },
41    /// Retained native evidence omitted required authored provenance.
42    MissingNativeEvidenceProvenance {
43        /// Evidence component that lacked a source origin.
44        component: &'static str,
45    },
46    /// Retained native evidence contained no physical source segments.
47    EmptyNativeEvidenceEvent,
48    /// Retained native evidence contained an unprotected physical segment.
49    UnprotectedNativeEvidenceSegment,
50    /// A service was added to the same group more than once.
51    DuplicateServiceGroupMember {
52        /// Service-group name.
53        group: String,
54        /// Duplicate service name.
55        service: String,
56    },
57    /// A service group referenced a service absent from the application.
58    UnknownServiceGroupMember {
59        /// Service-group name.
60        group: String,
61        /// Missing service name.
62        service: String,
63    },
64    /// A service was assigned to more than one application group.
65    ServiceInMultipleGroups {
66        /// Service name.
67        service: String,
68        /// Existing service-group name.
69        existing: String,
70        /// Conflicting service-group name.
71        replacement: String,
72    },
73    /// A service referenced an image acquisition absent from the application.
74    UnknownImageAcquisitionReference {
75        /// Referencing service name.
76        service: String,
77        /// Missing image-acquisition resource name.
78        acquisition: String,
79    },
80    /// A service referenced an image build absent from the application.
81    UnknownImageBuildReference {
82        /// Referencing service name.
83        service: String,
84        /// Missing image-build resource name.
85        build: String,
86    },
87    /// A volume referenced an image-acquisition resource absent from the application.
88    UnknownVolumeImageAcquisitionReference {
89        /// Referencing volume name.
90        volume: String,
91        /// Missing image-acquisition resource name.
92        acquisition: String,
93    },
94    /// A volume referenced an image-build resource absent from the application.
95    UnknownVolumeImageBuildReference {
96        /// Referencing volume name.
97        volume: String,
98        /// Missing image-build resource name.
99        build: String,
100    },
101    /// An explicitly supplied artifact dependency referred to a resource absent from the application.
102    UnknownArtifactDependencyNode {
103        /// Missing resource kind.
104        kind: &'static str,
105        /// Missing resource name.
106        name: String,
107    },
108    /// Explicit artifact dependencies formed a cycle.
109    ImageArtifactDependencyCycle {
110        /// Stable, resource-qualified members of one detected cycle.
111        nodes: Vec<String>,
112    },
113    /// An image reference had invalid component structure.
114    InvalidImageReference(&'static str),
115    /// A container port was zero.
116    ZeroContainerPort,
117    /// A requested service network-attachment index did not exist.
118    UnknownNetworkAttachmentIndex {
119        /// Requested attachment index.
120        index: usize,
121        /// Number of attachments present when replacement was attempted.
122        len: usize,
123    },
124    /// A requested group-runtime network-attachment index did not exist.
125    UnknownServiceGroupRuntimeNetworkIndex {
126        /// Requested attachment index.
127        index: usize,
128        /// Number of attachments present when replacement was attempted.
129        len: usize,
130    },
131    /// A service combined a root filesystem with an image source.
132    RootfsImageSourceConflict {
133        /// Referencing service name.
134        service: String,
135        /// Conflicting image source kind.
136        source: &'static str,
137    },
138    /// A health-check retry count was not a non-negative decimal integer.
139    InvalidHealthcheckRetries,
140    /// Invalid deferred expression or provider plan; reason never contains protected values.
141    InvalidDeferred(&'static str),
142}
143
144impl fmt::Display for ModelError {
145    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
146        match self {
147            Self::EmptyValue(kind) => write!(formatter, "{kind} must not be empty"),
148            Self::ContainsNul(kind) => write!(formatter, "{kind} must not contain a NUL byte"),
149            Self::ReversedSpan { start, end } => {
150                write!(formatter, "source span end {end} is before start {start}")
151            }
152            Self::DuplicateResource { kind, name } => {
153                write!(formatter, "duplicate {kind} `{name}`")
154            }
155            Self::UnknownNativeEvidenceOwner { kind, name } => {
156                write!(formatter, "retained native evidence references unknown {kind} `{name}`")
157            }
158            Self::MissingNativeEvidenceProvenance { component } => {
159                write!(
160                    formatter,
161                    "retained native evidence {component} must carry source provenance"
162                )
163            }
164            Self::EmptyNativeEvidenceEvent => {
165                formatter.write_str("retained native evidence must contain at least one physical source segment")
166            }
167            Self::UnprotectedNativeEvidenceSegment => {
168                formatter.write_str("retained native evidence physical segments must be sensitive")
169            }
170            Self::DuplicateServiceGroupMember { group, service } => {
171                write!(
172                    formatter,
173                    "service group `{group}` contains duplicate member `{service}`"
174                )
175            }
176            Self::UnknownServiceGroupMember { group, service } => {
177                write!(
178                    formatter,
179                    "service group `{group}` references unknown service `{service}`"
180                )
181            }
182            Self::ServiceInMultipleGroups {
183                service,
184                existing,
185                replacement,
186            } => write!(
187                formatter,
188                "service `{service}` belongs to both service groups `{existing}` and `{replacement}`"
189            ),
190            Self::UnknownImageAcquisitionReference { service, acquisition } => write!(
191                formatter,
192                "service `{service}` references unknown image acquisition `{acquisition}`"
193            ),
194            Self::UnknownImageBuildReference { service, build } => {
195                write!(
196                    formatter,
197                    "service `{service}` references unknown image build `{build}`"
198                )
199            }
200            Self::UnknownVolumeImageAcquisitionReference { volume, acquisition } => write!(
201                formatter,
202                "volume `{volume}` references unknown image acquisition `{acquisition}`"
203            ),
204            Self::UnknownVolumeImageBuildReference { volume, build } => {
205                write!(formatter, "volume `{volume}` references unknown image build `{build}`")
206            }
207            Self::UnknownArtifactDependencyNode { kind, name } => {
208                write!(formatter, "artifact dependency references unknown {kind} `{name}`")
209            }
210            Self::ImageArtifactDependencyCycle { nodes } => {
211                write!(formatter, "image-artifact dependency cycle: {}", nodes.join(" -> "))
212            }
213            Self::InvalidImageReference(reason) => write!(formatter, "invalid image reference: {reason}"),
214            Self::ZeroContainerPort => formatter.write_str("container port must not be zero"),
215            Self::UnknownNetworkAttachmentIndex { index, len } => {
216                write!(
217                    formatter,
218                    "network attachment index {index} is outside collection length {len}"
219                )
220            }
221            Self::UnknownServiceGroupRuntimeNetworkIndex { index, len } => {
222                write!(
223                    formatter,
224                    "group-runtime network attachment index {index} is outside collection length {len}"
225                )
226            }
227            Self::RootfsImageSourceConflict { service, source } => write!(
228                formatter,
229                "service `{service}` combines rootfs with image source `{source}`"
230            ),
231            Self::InvalidDeferred(reason) => formatter.write_str(reason),
232            Self::InvalidHealthcheckRetries => {
233                formatter.write_str("health-check retries must be a non-negative decimal integer")
234            }
235        }
236    }
237}
238
239impl Error for ModelError {}
240
241/// Opaque, non-empty application or resource identifier.
242#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
243pub struct Identifier(String);
244
245impl Identifier {
246    /// Creates an identifier without applying native-format naming rules.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
251    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
252        let value = value.into();
253        validate_text("identifier", &value)?;
254        Ok(Self(value))
255    }
256
257    /// Returns the authored identifier.
258    #[must_use]
259    pub fn as_str(&self) -> &str {
260        &self.0
261    }
262}
263
264/// Who owns a named application resource lifecycle.
265#[derive(Clone, Copy, Debug, Eq, PartialEq)]
266#[non_exhaustive]
267pub enum ResourceOwnership {
268    /// The application declares and owns the resource.
269    Application,
270    /// The target environment owns the resource outside this application.
271    External,
272    /// A source implementation supplied the resource implicitly.
273    Implicit,
274    /// Runtime inspection established the resource but not who should manage its lifecycle.
275    Uncertain,
276}
277
278/// Resource-qualified opaque source fact retained for explanation, never execution.
279///
280/// The variants are model-owned identifiers rather than native-library types. They
281/// deliberately enumerate only facts that have no reviewed portable meaning.
282#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
283#[non_exhaustive]
284pub enum RetainedNativeEvidenceSubject {
285    /// Quadlet `[Container] PodmanArgs=` entries attached to one service.
286    QuadletServicePodmanArgs(Identifier),
287    /// Quadlet `[Volume] ContainersConfModule=` entries attached to one volume.
288    QuadletVolumeContainersConfModules(Identifier),
289    /// Quadlet `[Volume] GlobalArgs=` entries attached to one volume.
290    QuadletVolumeGlobalArgs(Identifier),
291    /// Quadlet `[Volume] PodmanArgs=` entries attached to one volume.
292    QuadletVolumePodmanArgs(Identifier),
293}
294
295impl RetainedNativeEvidenceSubject {
296    /// Returns the stable conversion subject shared by every exporter.
297    #[must_use]
298    pub fn conversion_subject(&self) -> String {
299        match self {
300            Self::QuadletServicePodmanArgs(name) => {
301                format!("services.{}.podman_args", name.as_str())
302            }
303            Self::QuadletVolumeContainersConfModules(name) => {
304                format!("volumes.{}.containers_conf_modules", name.as_str())
305            }
306            Self::QuadletVolumeGlobalArgs(name) => {
307                format!("volumes.{}.global_args", name.as_str())
308            }
309            Self::QuadletVolumePodmanArgs(name) => {
310                format!("volumes.{}.podman_args", name.as_str())
311            }
312        }
313    }
314
315    const fn owner(&self) -> (&'static str, &Identifier) {
316        match self {
317            Self::QuadletServicePodmanArgs(name) => ("service", name),
318            Self::QuadletVolumeContainersConfModules(name)
319            | Self::QuadletVolumeGlobalArgs(name)
320            | Self::QuadletVolumePodmanArgs(name) => ("volume", name),
321        }
322    }
323}
324
325/// One authored opaque native occurrence retained in source order.
326#[derive(Clone, Debug, Eq, PartialEq)]
327#[non_exhaustive]
328pub enum RetainedNativeEvidenceEvent {
329    /// Protected physical source segments for one non-reset assignment.
330    Value(Vec<Sourced<ProtectedString>>),
331    /// Protected physical source segments for an assignment with native reset semantics.
332    Reset(Vec<Sourced<ProtectedString>>),
333}
334
335impl RetainedNativeEvidenceEvent {
336    /// Returns exact physical value segments in authored order.
337    #[must_use]
338    pub fn physical_segments(&self) -> &[Sourced<ProtectedString>] {
339        match self {
340            Self::Value(segments) | Self::Reset(segments) => segments,
341        }
342    }
343}
344
345/// One provenance-bearing native evidence event.
346///
347/// Applications keep these events separately from portable service and volume
348/// configuration. Repeated records retain global authored order, including values
349/// that precede a later reset.
350#[derive(Clone, Debug, Eq, PartialEq)]
351pub struct RetainedNativeEvidence {
352    subject: RetainedNativeEvidenceSubject,
353    event: Sourced<RetainedNativeEvidenceEvent>,
354}
355
356impl RetainedNativeEvidence {
357    /// Creates one retained source event.
358    ///
359    /// # Errors
360    ///
361    /// Returns [`ModelError::MissingNativeEvidenceProvenance`] when the authored
362    /// event or any physical segment has no source origin, and
363    /// [`ModelError::EmptyNativeEvidenceEvent`] when no physical source segment
364    /// is present, or [`ModelError::UnprotectedNativeEvidenceSegment`] when a
365    /// segment is not marked sensitive.
366    pub fn new(
367        subject: RetainedNativeEvidenceSubject,
368        event: Sourced<RetainedNativeEvidenceEvent>,
369    ) -> Result<Self, ModelError> {
370        if event.origins().is_empty() {
371            return Err(ModelError::MissingNativeEvidenceProvenance { component: "event" });
372        }
373        let segments = event.value().physical_segments();
374        if segments.is_empty() {
375            return Err(ModelError::EmptyNativeEvidenceEvent);
376        }
377        if segments.iter().any(|segment| segment.origins().is_empty()) {
378            return Err(ModelError::MissingNativeEvidenceProvenance {
379                component: "physical segment",
380            });
381        }
382        if segments.iter().any(|segment| !segment.value().is_sensitive()) {
383            return Err(ModelError::UnprotectedNativeEvidenceSegment);
384        }
385        Ok(Self { subject, event })
386    }
387
388    /// Returns the typed resource-qualified native subject.
389    #[must_use]
390    pub const fn subject(&self) -> &RetainedNativeEvidenceSubject {
391        &self.subject
392    }
393
394    /// Returns the protected event and its source provenance.
395    #[must_use]
396    pub const fn event(&self) -> &Sourced<RetainedNativeEvidenceEvent> {
397        &self.event
398    }
399}
400
401/// One application-level volume declaration.
402#[derive(Clone, Debug, Eq, PartialEq)]
403pub struct Volume {
404    name: Identifier,
405    ownership: ResourceOwnership,
406    runtime_name: Option<Sourced<ProtectedString>>,
407    service_name: Option<Sourced<ProtectedString>>,
408    driver: Option<Sourced<ProtectedString>>,
409    device: Option<Sourced<ProtectedString>>,
410    type_spelling: Option<Sourced<ProtectedString>>,
411    options: Option<Sourced<ProtectedString>>,
412    labels: Option<Vec<Sourced<MetadataLabel>>>,
413    labels_origins: Vec<Provenance>,
414    copy: Option<Sourced<bool>>,
415    user: Option<Sourced<ProtectedString>>,
416    group: Option<Sourced<ProtectedString>>,
417    uid: Option<Sourced<ProtectedString>>,
418    gid: Option<Sourced<ProtectedString>>,
419    image_source: Option<Sourced<VolumeImageSource>>,
420}
421
422impl Volume {
423    /// Creates a volume declaration without inferring native defaults or names.
424    #[must_use]
425    pub const fn new(name: Identifier, ownership: ResourceOwnership) -> Self {
426        Self {
427            name,
428            ownership,
429            runtime_name: None,
430            service_name: None,
431            driver: None,
432            device: None,
433            type_spelling: None,
434            options: None,
435            labels: None,
436            labels_origins: Vec::new(),
437            copy: None,
438            user: None,
439            group: None,
440            uid: None,
441            gid: None,
442            image_source: None,
443        }
444    }
445
446    /// Returns the neutral resource name.
447    #[must_use]
448    pub const fn name(&self) -> &Identifier {
449        &self.name
450    }
451
452    /// Returns the resource lifecycle owner.
453    #[must_use]
454    pub const fn ownership(&self) -> ResourceOwnership {
455        self.ownership
456    }
457
458    /// Sets the explicit runtime name, distinct from the logical resource key.
459    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
460        self.runtime_name = Some(name);
461    }
462
463    /// Returns the explicit provider/runtime name.
464    #[must_use]
465    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
466        self.runtime_name.as_ref()
467    }
468
469    /// Sets the explicit service-manager unit name without changing its spelling.
470    pub fn set_service_name(&mut self, name: Sourced<ProtectedString>) {
471        self.service_name = Some(name);
472    }
473
474    /// Returns the explicit service-manager unit name.
475    #[must_use]
476    pub const fn service_name(&self) -> Option<&Sourced<ProtectedString>> {
477        self.service_name.as_ref()
478    }
479
480    /// Sets the source-authored volume driver spelling.
481    pub fn set_driver(&mut self, driver: Sourced<ProtectedString>) {
482        self.driver = Some(driver);
483    }
484
485    /// Returns the explicit volume driver spelling.
486    #[must_use]
487    pub const fn driver(&self) -> Option<&Sourced<ProtectedString>> {
488        self.driver.as_ref()
489    }
490
491    /// Sets the source-authored local-driver device spelling.
492    pub fn set_device(&mut self, device: Sourced<ProtectedString>) {
493        self.device = Some(device);
494    }
495
496    /// Returns the explicit local-driver device spelling.
497    #[must_use]
498    pub const fn device(&self) -> Option<&Sourced<ProtectedString>> {
499        self.device.as_ref()
500    }
501
502    /// Sets the source-authored local-driver type spelling.
503    pub fn set_volume_type(&mut self, volume_type: Sourced<ProtectedString>) {
504        self.type_spelling = Some(volume_type);
505    }
506
507    /// Returns the explicit local-driver type spelling.
508    #[must_use]
509    pub const fn volume_type(&self) -> Option<&Sourced<ProtectedString>> {
510        self.type_spelling.as_ref()
511    }
512
513    /// Sets the explicit singleton local-driver options spelling.
514    ///
515    /// This is intentionally not a generic option bag: it retains the reviewed `Options=`
516    /// setting, including the Compose local-driver `driver_opts.o` mapping.
517    pub fn set_options(&mut self, options: Sourced<ProtectedString>) {
518        self.options = Some(options);
519    }
520
521    /// Returns the explicit singleton local-driver options spelling.
522    #[must_use]
523    pub const fn options(&self) -> Option<&Sourced<ProtectedString>> {
524        self.options.as_ref()
525    }
526
527    /// Sets metadata labels, retaining omission separately from an explicit empty reset.
528    pub fn set_labels(&mut self, labels: Vec<Sourced<MetadataLabel>>) {
529        self.set_labels_with_origins(labels, Vec::new());
530    }
531
532    /// Sets metadata labels with collection-level provenance.
533    pub fn set_labels_with_origins(&mut self, labels: Vec<Sourced<MetadataLabel>>, origins: Vec<Provenance>) {
534        self.labels = Some(labels);
535        self.labels_origins = origins;
536    }
537
538    /// Appends one metadata label in source order.
539    pub fn add_label(&mut self, label: Sourced<MetadataLabel>) {
540        self.labels.get_or_insert_default().push(label);
541    }
542
543    /// Returns metadata labels in authored order, preserving omitted versus explicit-empty state.
544    #[must_use]
545    pub fn labels(&self) -> Option<&[Sourced<MetadataLabel>]> {
546        self.labels.as_deref()
547    }
548
549    /// Returns collection-level label provenance.
550    #[must_use]
551    pub fn labels_origins(&self) -> &[Provenance] {
552        &self.labels_origins
553    }
554
555    /// Sets the explicit copy choice without inferring an omitted source default.
556    pub fn set_copy(&mut self, copy: Sourced<bool>) {
557        self.copy = Some(copy);
558    }
559
560    /// Returns the explicit copy choice.
561    #[must_use]
562    pub const fn copy(&self) -> Option<&Sourced<bool>> {
563        self.copy.as_ref()
564    }
565
566    /// Sets the source-authored volume user identity.
567    pub fn set_user(&mut self, user: Sourced<ProtectedString>) {
568        self.user = Some(user);
569    }
570
571    /// Returns the source-authored volume user identity.
572    #[must_use]
573    pub const fn user(&self) -> Option<&Sourced<ProtectedString>> {
574        self.user.as_ref()
575    }
576
577    /// Sets the source-authored volume group identity.
578    pub fn set_group(&mut self, group: Sourced<ProtectedString>) {
579        self.group = Some(group);
580    }
581
582    /// Returns the source-authored volume group identity.
583    #[must_use]
584    pub const fn group(&self) -> Option<&Sourced<ProtectedString>> {
585        self.group.as_ref()
586    }
587
588    /// Sets the source-authored numeric user-ID spelling.
589    pub fn set_uid(&mut self, uid: Sourced<ProtectedString>) {
590        self.uid = Some(uid);
591    }
592
593    /// Returns the source-authored numeric user-ID spelling.
594    #[must_use]
595    pub const fn uid(&self) -> Option<&Sourced<ProtectedString>> {
596        self.uid.as_ref()
597    }
598
599    /// Sets the source-authored numeric group-ID spelling.
600    pub fn set_gid(&mut self, gid: Sourced<ProtectedString>) {
601        self.gid = Some(gid);
602    }
603
604    /// Returns the source-authored numeric group-ID spelling.
605    #[must_use]
606    pub const fn gid(&self) -> Option<&Sourced<ProtectedString>> {
607        self.gid.as_ref()
608    }
609
610    /// Sets the explicitly selected image source for an image-backed volume.
611    ///
612    /// Artifact references are checked by [`Application::validate_image_artifact_references`]
613    /// after a complete application graph has been assembled.
614    ///
615    /// # Errors
616    ///
617    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`] for an invalid literal
618    /// image source.
619    pub fn set_image_source(&mut self, image_source: Sourced<VolumeImageSource>) -> Result<(), ModelError> {
620        image_source.value().validate()?;
621        self.image_source = Some(image_source);
622        Ok(())
623    }
624
625    /// Returns the explicitly selected image source for an image-backed volume.
626    #[must_use]
627    pub const fn image_source(&self) -> Option<&Sourced<VolumeImageSource>> {
628        self.image_source.as_ref()
629    }
630}
631
632/// The explicit source of an image-backed volume.
633///
634/// Literal images and named image artifacts remain distinct. The literal form is protected so a
635/// private registry location cannot leak through model debug output.
636#[derive(Clone, Debug, Eq, PartialEq)]
637#[non_exhaustive]
638pub enum VolumeImageSource {
639    /// One protected literal image spelling.
640    Literal(ProtectedString),
641    /// One named image-acquisition resource.
642    ImageAcquisition(Identifier),
643    /// One named image-build resource.
644    ImageBuild(Identifier),
645}
646
647impl VolumeImageSource {
648    fn validate(&self) -> Result<(), ModelError> {
649        if let Self::Literal(image) = self {
650            validate_text("volume image", image.expose())?;
651        }
652        Ok(())
653    }
654}
655
656/// A format-neutral node used by explicit image-artifact dependency validation.
657///
658/// Native adapters create values only after they have established a typed reference. This model
659/// does not parse native raw argument or mount spellings to invent an edge.
660#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
661#[non_exhaustive]
662pub enum ArtifactDependencyNode {
663    /// One application volume.
664    Volume(Identifier),
665    /// One image-acquisition resource.
666    ImageAcquisition(Identifier),
667    /// One image-build resource.
668    ImageBuild(Identifier),
669}
670
671impl ArtifactDependencyNode {
672    fn kind_and_name(&self) -> (&'static str, &Identifier) {
673        match self {
674            Self::Volume(name) => ("volume", name),
675            Self::ImageAcquisition(name) => ("image acquisition", name),
676            Self::ImageBuild(name) => ("image build", name),
677        }
678    }
679
680    fn display_name(&self) -> String {
681        let (kind, name) = self.kind_and_name();
682        format!("{kind}:{}", name.as_str())
683    }
684}
685
686/// One explicit, directed image-artifact dependency.
687///
688/// It intentionally carries independently sourced endpoints. Collection-level provenance belongs
689/// to the caller's enclosing source declaration.
690#[derive(Clone, Debug, Eq, PartialEq)]
691pub struct ArtifactDependency {
692    source: Sourced<ArtifactDependencyNode>,
693    target: Sourced<ArtifactDependencyNode>,
694}
695
696impl ArtifactDependency {
697    /// Creates an explicit typed dependency.
698    #[must_use]
699    pub const fn new(source: Sourced<ArtifactDependencyNode>, target: Sourced<ArtifactDependencyNode>) -> Self {
700        Self { source, target }
701    }
702
703    /// Returns the depending node and its provenance.
704    #[must_use]
705    pub const fn source(&self) -> &Sourced<ArtifactDependencyNode> {
706        &self.source
707    }
708
709    /// Returns the required node and its provenance.
710    #[must_use]
711    pub const fn target(&self) -> &Sourced<ArtifactDependencyNode> {
712        &self.target
713    }
714}
715
716/// One application-level network declaration.
717#[derive(Clone, Debug, Eq, PartialEq)]
718pub struct Network {
719    name: Identifier,
720    ownership: ResourceOwnership,
721    runtime_name: Option<Sourced<ProtectedString>>,
722    driver: Option<Sourced<ProtectedString>>,
723    driver_options: Option<Vec<Sourced<NetworkDriverOption>>>,
724    driver_options_origins: Vec<Provenance>,
725    labels: Option<Vec<Sourced<MetadataLabel>>>,
726    labels_origins: Vec<Provenance>,
727    internal: Option<Sourced<bool>>,
728    ipv6: Option<Sourced<bool>>,
729    ipam_driver: Option<Sourced<ProtectedString>>,
730    ipam_configs: Option<Vec<Sourced<NetworkIpamConfig>>>,
731    ipam_configs_origins: Vec<Provenance>,
732}
733
734/// One driver-specific network option with independently sourced key and value.
735///
736/// Values remain protected because provider options can carry deployment-specific credentials or
737/// topology details. They remain separate fields so adapters never need to parse a synthetic
738/// `key=value` assignment.
739#[derive(Clone, Debug, Eq, PartialEq)]
740pub struct NetworkDriverOption {
741    name: Sourced<Identifier>,
742    value: Sourced<ProtectedString>,
743}
744
745impl NetworkDriverOption {
746    /// Creates one driver option with field-level provenance.
747    ///
748    /// # Errors
749    ///
750    /// Returns [`ModelError::ContainsNul`] when the option value contains a NUL byte. An empty
751    /// value is retained because source mappings may explicitly reset a driver option.
752    pub fn new(name: Sourced<Identifier>, value: Sourced<ProtectedString>) -> Result<Self, ModelError> {
753        validate_no_nul("network driver option value", value.value().expose())?;
754        Ok(Self { name, value })
755    }
756
757    /// Returns the driver-option key and its provenance.
758    #[must_use]
759    pub const fn name(&self) -> &Sourced<Identifier> {
760        &self.name
761    }
762
763    /// Returns the protected driver-option value and its provenance.
764    #[must_use]
765    pub const fn value(&self) -> &Sourced<ProtectedString> {
766        &self.value
767    }
768}
769
770/// One explicitly associated IPAM configuration row.
771///
772/// A row never pairs independent native subnet, gateway, and range collections by position:
773/// adapters retain the source association only when it is present. The subnet is required, while
774/// gateway and range remain independently optional and sourced.
775#[derive(Clone, Debug, Eq, PartialEq)]
776pub struct NetworkIpamConfig {
777    subnet: Sourced<ProtectedString>,
778    gateway: Option<Sourced<ProtectedString>>,
779    ip_range: Option<Sourced<ProtectedString>>,
780}
781
782impl NetworkIpamConfig {
783    /// Creates one IPAM row with a required non-empty subnet spelling.
784    ///
785    /// # Errors
786    ///
787    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`] for the subnet.
788    pub fn new(subnet: Sourced<ProtectedString>) -> Result<Self, ModelError> {
789        validate_text("network IPAM subnet", subnet.value().expose())?;
790        Ok(Self {
791            subnet,
792            gateway: None,
793            ip_range: None,
794        })
795    }
796
797    /// Returns the required subnet spelling and its provenance.
798    #[must_use]
799    pub const fn subnet(&self) -> &Sourced<ProtectedString> {
800        &self.subnet
801    }
802
803    /// Sets an explicitly associated gateway spelling.
804    ///
805    /// # Errors
806    ///
807    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
808    pub fn set_gateway(&mut self, gateway: Sourced<ProtectedString>) -> Result<(), ModelError> {
809        validate_text("network IPAM gateway", gateway.value().expose())?;
810        self.gateway = Some(gateway);
811        Ok(())
812    }
813
814    /// Returns the optional gateway spelling and its provenance.
815    #[must_use]
816    pub const fn gateway(&self) -> Option<&Sourced<ProtectedString>> {
817        self.gateway.as_ref()
818    }
819
820    /// Sets an explicitly associated IP-range spelling.
821    ///
822    /// # Errors
823    ///
824    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
825    pub fn set_ip_range(&mut self, ip_range: Sourced<ProtectedString>) -> Result<(), ModelError> {
826        validate_text("network IPAM IP range", ip_range.value().expose())?;
827        self.ip_range = Some(ip_range);
828        Ok(())
829    }
830
831    /// Returns the optional IP-range spelling and its provenance.
832    #[must_use]
833    pub const fn ip_range(&self) -> Option<&Sourced<ProtectedString>> {
834        self.ip_range.as_ref()
835    }
836}
837
838/// Material source for an application-managed configuration resource.
839#[derive(Clone, Debug, Eq, PartialEq)]
840#[non_exhaustive]
841pub enum ConfigMaterial {
842    /// Read configuration bytes from a caller-resolved file source.
843    File(ProtectedString),
844    /// Read configuration bytes from an explicitly supplied environment value.
845    Environment(ProtectedString),
846    /// Use source-authored inline configuration content.
847    Content(ProtectedString),
848}
849
850/// One application-level configuration declaration.
851#[derive(Clone, Debug, Eq, PartialEq)]
852pub struct Config {
853    name: Identifier,
854    ownership: ResourceOwnership,
855    runtime_name: Option<Sourced<ProtectedString>>,
856    material: Option<Sourced<ConfigMaterial>>,
857}
858
859impl Config {
860    /// Creates a configuration declaration without guessing material or a runtime name.
861    #[must_use]
862    pub const fn new(name: Identifier, ownership: ResourceOwnership) -> Self {
863        Self {
864            name,
865            ownership,
866            runtime_name: None,
867            material: None,
868        }
869    }
870
871    /// Returns the neutral resource name.
872    #[must_use]
873    pub const fn name(&self) -> &Identifier {
874        &self.name
875    }
876
877    /// Returns the resource lifecycle owner.
878    #[must_use]
879    pub const fn ownership(&self) -> ResourceOwnership {
880        self.ownership
881    }
882
883    /// Sets a provider/runtime-level name distinct from the neutral resource key.
884    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
885        self.runtime_name = Some(name);
886    }
887
888    /// Returns the explicit provider/runtime-level name.
889    #[must_use]
890    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
891        self.runtime_name.as_ref()
892    }
893
894    /// Sets the application-managed material source.
895    pub fn set_material(&mut self, material: Sourced<ConfigMaterial>) {
896        self.material = Some(material);
897    }
898
899    /// Returns the optional material source.
900    #[must_use]
901    pub const fn material(&self) -> Option<&Sourced<ConfigMaterial>> {
902        self.material.as_ref()
903    }
904}
905
906/// Material source for an application-managed secret resource.
907#[derive(Clone, Debug, Eq, PartialEq)]
908#[non_exhaustive]
909pub enum SecretMaterial {
910    /// Read secret bytes from a caller-resolved file source.
911    File(ProtectedString),
912    /// Read secret bytes from an explicitly supplied environment value.
913    Environment(ProtectedString),
914}
915
916/// One application-level secret declaration.
917#[derive(Clone, Debug, Eq, PartialEq)]
918pub struct Secret {
919    name: Identifier,
920    ownership: ResourceOwnership,
921    runtime_name: Option<Sourced<ProtectedString>>,
922    material: Option<Sourced<SecretMaterial>>,
923}
924
925impl Secret {
926    /// Creates a secret declaration without guessing material or a runtime name.
927    #[must_use]
928    pub const fn new(name: Identifier, ownership: ResourceOwnership) -> Self {
929        Self {
930            name,
931            ownership,
932            runtime_name: None,
933            material: None,
934        }
935    }
936
937    /// Returns the neutral resource name.
938    #[must_use]
939    pub const fn name(&self) -> &Identifier {
940        &self.name
941    }
942
943    /// Returns the resource lifecycle owner.
944    #[must_use]
945    pub const fn ownership(&self) -> ResourceOwnership {
946        self.ownership
947    }
948
949    /// Sets a provider/runtime-level name distinct from the neutral resource key.
950    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
951        self.runtime_name = Some(name);
952    }
953
954    /// Returns the explicit provider/runtime-level name.
955    #[must_use]
956    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
957        self.runtime_name.as_ref()
958    }
959
960    /// Sets the application-managed material source.
961    pub fn set_material(&mut self, material: Sourced<SecretMaterial>) {
962        self.material = Some(material);
963    }
964
965    /// Returns the optional material source.
966    #[must_use]
967    pub const fn material(&self) -> Option<&Sourced<SecretMaterial>> {
968        self.material.as_ref()
969    }
970}
971
972/// Authored syntax family retained for a config or secret grant.
973#[derive(Clone, Copy, Debug, Eq, PartialEq)]
974#[non_exhaustive]
975pub enum ResourceGrantSyntax {
976    /// Resource-name short syntax with source-format defaults.
977    Short,
978    /// Mapping-based syntax with separately authored options.
979    Long,
980}
981
982/// One ordered service grant of a configuration or secret resource.
983///
984/// The containing service collection determines whether this grants a config or secret. Keeping
985/// one shared shape avoids inventing differences between the common source/target/ownership
986/// options while preserving the short/long syntax decision.
987#[derive(Clone, Debug, Eq, PartialEq)]
988pub struct ResourceGrant {
989    source: ProtectedString,
990    syntax: ResourceGrantSyntax,
991    target: Option<Sourced<ProtectedString>>,
992    uid: Option<Sourced<ProtectedString>>,
993    gid: Option<Sourced<ProtectedString>>,
994    mode: Option<Sourced<ProtectedString>>,
995}
996
997impl ResourceGrant {
998    /// Creates a grant with a non-empty source resource name.
999    ///
1000    /// # Errors
1001    ///
1002    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
1003    pub fn new(source: ProtectedString, syntax: ResourceGrantSyntax) -> Result<Self, ModelError> {
1004        validate_text("resource grant source", source.expose())?;
1005        Ok(Self {
1006            source,
1007            syntax,
1008            target: None,
1009            uid: None,
1010            gid: None,
1011            mode: None,
1012        })
1013    }
1014
1015    /// Returns the referenced neutral resource name.
1016    #[must_use]
1017    pub const fn source(&self) -> &ProtectedString {
1018        &self.source
1019    }
1020
1021    /// Returns the authored short/long syntax family.
1022    #[must_use]
1023    pub const fn syntax(&self) -> ResourceGrantSyntax {
1024        self.syntax
1025    }
1026
1027    /// Sets the requested container path or environment-variable name.
1028    pub fn set_target(&mut self, target: Sourced<ProtectedString>) {
1029        self.target = Some(target);
1030    }
1031
1032    /// Returns the explicitly authored target.
1033    #[must_use]
1034    pub const fn target(&self) -> Option<&Sourced<ProtectedString>> {
1035        self.target.as_ref()
1036    }
1037
1038    /// Sets the requested container user-ID spelling.
1039    pub fn set_uid(&mut self, uid: Sourced<ProtectedString>) {
1040        self.uid = Some(uid);
1041    }
1042
1043    /// Returns the explicitly authored user-ID spelling.
1044    #[must_use]
1045    pub const fn uid(&self) -> Option<&Sourced<ProtectedString>> {
1046        self.uid.as_ref()
1047    }
1048
1049    /// Sets the requested container group-ID spelling.
1050    pub fn set_gid(&mut self, gid: Sourced<ProtectedString>) {
1051        self.gid = Some(gid);
1052    }
1053
1054    /// Returns the explicitly authored group-ID spelling.
1055    #[must_use]
1056    pub const fn gid(&self) -> Option<&Sourced<ProtectedString>> {
1057        self.gid.as_ref()
1058    }
1059
1060    /// Sets the requested permission-mode spelling.
1061    pub fn set_mode(&mut self, mode: Sourced<ProtectedString>) {
1062        self.mode = Some(mode);
1063    }
1064
1065    /// Returns the explicitly authored permission-mode spelling.
1066    #[must_use]
1067    pub const fn mode(&self) -> Option<&Sourced<ProtectedString>> {
1068        self.mode.as_ref()
1069    }
1070}
1071
1072impl Network {
1073    /// Creates a network declaration without inferring runtime settings or defaults.
1074    #[must_use]
1075    pub const fn new(name: Identifier, ownership: ResourceOwnership) -> Self {
1076        Self {
1077            name,
1078            ownership,
1079            runtime_name: None,
1080            driver: None,
1081            driver_options: None,
1082            driver_options_origins: Vec::new(),
1083            labels: None,
1084            labels_origins: Vec::new(),
1085            internal: None,
1086            ipv6: None,
1087            ipam_driver: None,
1088            ipam_configs: None,
1089            ipam_configs_origins: Vec::new(),
1090        }
1091    }
1092
1093    /// Returns the neutral resource name.
1094    #[must_use]
1095    pub const fn name(&self) -> &Identifier {
1096        &self.name
1097    }
1098
1099    /// Returns the resource lifecycle owner.
1100    #[must_use]
1101    pub const fn ownership(&self) -> ResourceOwnership {
1102        self.ownership
1103    }
1104
1105    /// Sets the explicit provider/runtime name distinct from the logical resource key.
1106    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
1107        self.runtime_name = Some(name);
1108    }
1109
1110    /// Returns the explicit provider/runtime name.
1111    #[must_use]
1112    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
1113        self.runtime_name.as_ref()
1114    }
1115
1116    /// Sets the source-authored network driver spelling.
1117    pub fn set_driver(&mut self, driver: Sourced<ProtectedString>) {
1118        self.driver = Some(driver);
1119    }
1120
1121    /// Returns the explicit network driver spelling.
1122    #[must_use]
1123    pub const fn driver(&self) -> Option<&Sourced<ProtectedString>> {
1124        self.driver.as_ref()
1125    }
1126
1127    /// Sets ordered driver options while retaining explicit emptiness as a reset.
1128    pub fn set_driver_options(&mut self, options: Vec<Sourced<NetworkDriverOption>>) {
1129        self.driver_options = Some(options);
1130        self.driver_options_origins.clear();
1131    }
1132
1133    /// Sets ordered driver options with collection-level provenance.
1134    pub fn set_driver_options_with_origins(
1135        &mut self,
1136        options: Vec<Sourced<NetworkDriverOption>>,
1137        origins: Vec<Provenance>,
1138    ) {
1139        self.driver_options = Some(options);
1140        self.driver_options_origins = origins;
1141    }
1142
1143    /// Appends one driver option in source order.
1144    pub fn add_driver_option(&mut self, option: Sourced<NetworkDriverOption>) {
1145        self.driver_options.get_or_insert_default().push(option);
1146    }
1147
1148    /// Returns ordered driver options, preserving omitted versus explicit-empty state.
1149    #[must_use]
1150    pub fn driver_options(&self) -> Option<&[Sourced<NetworkDriverOption>]> {
1151        self.driver_options.as_deref()
1152    }
1153
1154    /// Returns collection-level driver-option provenance.
1155    #[must_use]
1156    pub fn driver_options_origins(&self) -> &[Provenance] {
1157        &self.driver_options_origins
1158    }
1159
1160    /// Sets ordered network metadata labels while retaining explicit emptiness as a reset.
1161    pub fn set_labels(&mut self, labels: Vec<Sourced<MetadataLabel>>) {
1162        self.labels = Some(labels);
1163        self.labels_origins.clear();
1164    }
1165
1166    /// Sets ordered network metadata labels with collection-level provenance.
1167    pub fn set_labels_with_origins(&mut self, labels: Vec<Sourced<MetadataLabel>>, origins: Vec<Provenance>) {
1168        self.labels = Some(labels);
1169        self.labels_origins = origins;
1170    }
1171
1172    /// Appends one network metadata label in source order.
1173    pub fn add_label(&mut self, label: Sourced<MetadataLabel>) {
1174        self.labels.get_or_insert_default().push(label);
1175    }
1176
1177    /// Returns ordered network labels, preserving omitted versus explicit-empty state.
1178    #[must_use]
1179    pub fn labels(&self) -> Option<&[Sourced<MetadataLabel>]> {
1180        self.labels.as_deref()
1181    }
1182
1183    /// Returns collection-level network-label provenance.
1184    #[must_use]
1185    pub fn labels_origins(&self) -> &[Provenance] {
1186        &self.labels_origins
1187    }
1188
1189    /// Sets the literal `internal` network flag without inferring a source default.
1190    pub fn set_internal(&mut self, internal: Sourced<bool>) {
1191        self.internal = Some(internal);
1192    }
1193
1194    /// Returns the explicitly authored `internal` network flag.
1195    #[must_use]
1196    pub const fn internal(&self) -> Option<&Sourced<bool>> {
1197        self.internal.as_ref()
1198    }
1199
1200    /// Sets the literal IPv6-enable network flag without inferring a source default.
1201    pub fn set_ipv6(&mut self, ipv6: Sourced<bool>) {
1202        self.ipv6 = Some(ipv6);
1203    }
1204
1205    /// Returns the explicitly authored IPv6-enable network flag.
1206    #[must_use]
1207    pub const fn ipv6(&self) -> Option<&Sourced<bool>> {
1208        self.ipv6.as_ref()
1209    }
1210
1211    /// Sets the source-authored IPAM driver spelling.
1212    pub fn set_ipam_driver(&mut self, driver: Sourced<ProtectedString>) {
1213        self.ipam_driver = Some(driver);
1214    }
1215
1216    /// Returns the explicit IPAM driver spelling.
1217    #[must_use]
1218    pub const fn ipam_driver(&self) -> Option<&Sourced<ProtectedString>> {
1219        self.ipam_driver.as_ref()
1220    }
1221
1222    /// Sets ordered associated IPAM rows while retaining explicit emptiness as a reset.
1223    pub fn set_ipam_configs(&mut self, configs: Vec<Sourced<NetworkIpamConfig>>) {
1224        self.ipam_configs = Some(configs);
1225        self.ipam_configs_origins.clear();
1226    }
1227
1228    /// Sets ordered associated IPAM rows with collection-level provenance.
1229    pub fn set_ipam_configs_with_origins(
1230        &mut self,
1231        configs: Vec<Sourced<NetworkIpamConfig>>,
1232        origins: Vec<Provenance>,
1233    ) {
1234        self.ipam_configs = Some(configs);
1235        self.ipam_configs_origins = origins;
1236    }
1237
1238    /// Appends one independently associated IPAM row in source order.
1239    pub fn add_ipam_config(&mut self, config: Sourced<NetworkIpamConfig>) {
1240        self.ipam_configs.get_or_insert_default().push(config);
1241    }
1242
1243    /// Returns ordered associated IPAM rows, preserving omitted versus explicit-empty state.
1244    #[must_use]
1245    pub fn ipam_configs(&self) -> Option<&[Sourced<NetworkIpamConfig>]> {
1246        self.ipam_configs.as_deref()
1247    }
1248
1249    /// Returns collection-level IPAM configuration provenance.
1250    #[must_use]
1251    pub fn ipam_configs_origins(&self) -> &[Provenance] {
1252        &self.ipam_configs_origins
1253    }
1254}
1255
1256/// One structural group of application services.
1257///
1258/// Membership alone does not imply shared Linux namespaces, an infra container, or a target
1259/// workload kind. Source and target adapters must model or report those semantics separately.
1260#[derive(Clone, Debug, Eq, PartialEq)]
1261pub struct ServiceGroup {
1262    name: Identifier,
1263    ownership: ResourceOwnership,
1264    members: Vec<Sourced<Identifier>>,
1265    runtime: Option<Sourced<ServiceGroupRuntime>>,
1266}
1267
1268impl ServiceGroup {
1269    /// Creates an empty structural service group.
1270    #[must_use]
1271    pub const fn new(name: Identifier, ownership: ResourceOwnership) -> Self {
1272        Self {
1273            name,
1274            ownership,
1275            members: Vec::new(),
1276            runtime: None,
1277        }
1278    }
1279
1280    /// Returns the neutral group name.
1281    #[must_use]
1282    pub const fn name(&self) -> &Identifier {
1283        &self.name
1284    }
1285
1286    /// Returns the group lifecycle owner.
1287    #[must_use]
1288    pub const fn ownership(&self) -> ResourceOwnership {
1289        self.ownership
1290    }
1291
1292    /// Appends one uniquely named member in source order.
1293    ///
1294    /// # Errors
1295    ///
1296    /// Returns [`ModelError::DuplicateServiceGroupMember`] when the service was already added.
1297    pub fn add_member(&mut self, member: Sourced<Identifier>) -> Result<(), ModelError> {
1298        if self.members.iter().any(|candidate| candidate.value() == member.value()) {
1299            return Err(ModelError::DuplicateServiceGroupMember {
1300                group: self.name.as_str().to_owned(),
1301                service: member.value().as_str().to_owned(),
1302            });
1303        }
1304        self.members.push(member);
1305        Ok(())
1306    }
1307
1308    /// Returns member service names in source order with relationship provenance.
1309    #[must_use]
1310    pub fn members(&self) -> &[Sourced<Identifier>] {
1311        &self.members
1312    }
1313
1314    /// Sets the optional runtime settings associated with this structural group.
1315    ///
1316    /// These settings do not alter membership semantics. In particular, their presence does not
1317    /// infer namespace sharing for a group that did not author such settings.
1318    pub fn set_runtime(&mut self, runtime: Sourced<ServiceGroupRuntime>) {
1319        self.runtime = Some(runtime);
1320    }
1321
1322    /// Returns the optional native group-runtime settings.
1323    #[must_use]
1324    pub const fn runtime(&self) -> Option<&Sourced<ServiceGroupRuntime>> {
1325        self.runtime.as_ref()
1326    }
1327}
1328
1329/// Pod exit behavior retained without assigning lifecycle semantics to group membership.
1330#[derive(Clone, Debug, Eq, PartialEq)]
1331#[non_exhaustive]
1332pub enum GroupExitPolicy {
1333    /// Stop the pod when a member container exits.
1334    Stop,
1335    /// Keep the pod running when a member container exits.
1336    Continue,
1337    /// Preserve a source-native exit-policy spelling for target-side classification.
1338    Raw(ProtectedString),
1339}
1340
1341/// Native runtime settings owned by one [`ServiceGroup`].
1342///
1343/// The group's logical [`ServiceGroup::name`] remains distinct from an optional runtime pod name
1344/// and the optional systemd service name. All settings are group-scoped; adapters must not assign
1345/// them to an arbitrary member service.
1346#[derive(Clone, Debug, Default, Eq, PartialEq)]
1347pub struct ServiceGroupRuntime {
1348    runtime_name: Option<Sourced<ProtectedString>>,
1349    service_name: Option<Sourced<ProtectedString>>,
1350    host_mappings: Option<Vec<Sourced<HostMapping>>>,
1351    host_mappings_origins: Vec<Provenance>,
1352    ports: Option<Vec<Sourced<Port>>>,
1353    ports_origins: Vec<Provenance>,
1354    networks: Option<Vec<Sourced<NetworkAttachment>>>,
1355    networks_origins: Vec<Provenance>,
1356    user_namespace: Option<Sourced<ProtectedString>>,
1357    mounts: Option<Vec<Sourced<Mount>>>,
1358    mounts_origins: Vec<Provenance>,
1359    shm_size: Option<Sourced<ProtectedString>>,
1360    exit_policy: Option<Sourced<GroupExitPolicy>>,
1361    stop_timeout: Option<Sourced<StopTimeout>>,
1362}
1363
1364impl ServiceGroupRuntime {
1365    /// Creates empty group-runtime settings for incremental adapter mapping.
1366    #[must_use]
1367    pub const fn new() -> Self {
1368        Self {
1369            runtime_name: None,
1370            service_name: None,
1371            host_mappings: None,
1372            host_mappings_origins: Vec::new(),
1373            ports: None,
1374            ports_origins: Vec::new(),
1375            networks: None,
1376            networks_origins: Vec::new(),
1377            user_namespace: None,
1378            mounts: None,
1379            mounts_origins: Vec::new(),
1380            shm_size: None,
1381            exit_policy: None,
1382            stop_timeout: None,
1383        }
1384    }
1385
1386    /// Sets the pod/runtime name distinct from the neutral group key.
1387    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
1388        self.runtime_name = Some(name);
1389    }
1390
1391    /// Returns the explicit pod/runtime name.
1392    #[must_use]
1393    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
1394        self.runtime_name.as_ref()
1395    }
1396
1397    /// Sets the systemd service name distinct from the logical and runtime names.
1398    pub fn set_service_name(&mut self, name: Sourced<ProtectedString>) {
1399        self.service_name = Some(name);
1400    }
1401
1402    /// Returns the explicit systemd service name.
1403    #[must_use]
1404    pub const fn service_name(&self) -> Option<&Sourced<ProtectedString>> {
1405        self.service_name.as_ref()
1406    }
1407
1408    /// Sets pod host mappings, preserving omitted versus explicit-empty state.
1409    pub fn set_host_mappings(&mut self, values: Vec<Sourced<HostMapping>>) {
1410        self.set_host_mappings_with_origins(values, Vec::new());
1411    }
1412
1413    /// Sets pod host mappings with collection-level provenance.
1414    pub fn set_host_mappings_with_origins(&mut self, values: Vec<Sourced<HostMapping>>, origins: Vec<Provenance>) {
1415        self.host_mappings = Some(values);
1416        self.host_mappings_origins = origins;
1417    }
1418
1419    /// Appends one pod host mapping in source order.
1420    pub fn add_host_mapping(&mut self, value: Sourced<HostMapping>) {
1421        self.host_mappings.get_or_insert_default().push(value);
1422    }
1423
1424    /// Returns pod host mappings, preserving omitted versus explicit-empty state.
1425    #[must_use]
1426    pub fn host_mappings(&self) -> Option<&[Sourced<HostMapping>]> {
1427        self.host_mappings.as_deref()
1428    }
1429
1430    /// Returns collection-level pod-host-mapping provenance.
1431    #[must_use]
1432    pub fn host_mappings_origins(&self) -> &[Provenance] {
1433        &self.host_mappings_origins
1434    }
1435
1436    /// Sets pod ports, preserving omitted versus explicit-empty state.
1437    pub fn set_ports(&mut self, values: Vec<Sourced<Port>>) {
1438        self.set_ports_with_origins(values, Vec::new());
1439    }
1440
1441    /// Sets pod ports with collection-level provenance.
1442    pub fn set_ports_with_origins(&mut self, values: Vec<Sourced<Port>>, origins: Vec<Provenance>) {
1443        self.ports = Some(values);
1444        self.ports_origins = origins;
1445    }
1446
1447    /// Appends one pod port in source order.
1448    pub fn add_port(&mut self, value: Sourced<Port>) {
1449        self.ports.get_or_insert_default().push(value);
1450    }
1451
1452    /// Returns pod ports, preserving omitted versus explicit-empty state.
1453    #[must_use]
1454    pub fn ports(&self) -> Option<&[Sourced<Port>]> {
1455        self.ports.as_deref()
1456    }
1457
1458    /// Returns collection-level pod-port provenance.
1459    #[must_use]
1460    pub fn ports_origins(&self) -> &[Provenance] {
1461        &self.ports_origins
1462    }
1463
1464    /// Sets pod network attachments, preserving omitted versus explicit-empty state.
1465    pub fn set_networks(&mut self, values: Vec<Sourced<NetworkAttachment>>) {
1466        self.set_networks_with_origins(values, Vec::new());
1467    }
1468
1469    /// Sets pod network attachments with collection-level provenance.
1470    pub fn set_networks_with_origins(&mut self, values: Vec<Sourced<NetworkAttachment>>, origins: Vec<Provenance>) {
1471        self.networks = Some(values);
1472        self.networks_origins = origins;
1473    }
1474
1475    /// Appends one pod network attachment in source order.
1476    pub fn add_network(&mut self, value: Sourced<NetworkAttachment>) {
1477        self.networks.get_or_insert_default().push(value);
1478    }
1479
1480    /// Replaces one pod network attachment without changing authored order.
1481    ///
1482    /// # Errors
1483    ///
1484    /// Returns [`ModelError::UnknownServiceGroupRuntimeNetworkIndex`] when `index` is outside
1485    /// the explicitly authored attachment collection.
1486    pub fn replace_network(
1487        &mut self,
1488        index: usize,
1489        value: Sourced<NetworkAttachment>,
1490    ) -> Result<Sourced<NetworkAttachment>, ModelError> {
1491        let len = self.networks.as_ref().map_or(0, Vec::len);
1492        let Some(networks) = self.networks.as_mut() else {
1493            return Err(ModelError::UnknownServiceGroupRuntimeNetworkIndex { index, len });
1494        };
1495        let Some(slot) = networks.get_mut(index) else {
1496            return Err(ModelError::UnknownServiceGroupRuntimeNetworkIndex { index, len });
1497        };
1498        Ok(std::mem::replace(slot, value))
1499    }
1500
1501    /// Returns pod network attachments, preserving omitted versus explicit-empty state.
1502    #[must_use]
1503    pub fn networks(&self) -> Option<&[Sourced<NetworkAttachment>]> {
1504        self.networks.as_deref()
1505    }
1506
1507    /// Returns collection-level pod-network provenance.
1508    #[must_use]
1509    pub fn networks_origins(&self) -> &[Provenance] {
1510        &self.networks_origins
1511    }
1512
1513    /// Sets the raw-preserving pod user-namespace mode.
1514    pub fn set_user_namespace(&mut self, value: Sourced<ProtectedString>) {
1515        self.user_namespace = Some(value);
1516    }
1517
1518    /// Returns the explicit pod user-namespace mode.
1519    #[must_use]
1520    pub const fn user_namespace(&self) -> Option<&Sourced<ProtectedString>> {
1521        self.user_namespace.as_ref()
1522    }
1523
1524    /// Sets pod mounts, preserving omitted versus explicit-empty state.
1525    pub fn set_mounts(&mut self, values: Vec<Sourced<Mount>>) {
1526        self.set_mounts_with_origins(values, Vec::new());
1527    }
1528
1529    /// Sets pod mounts with collection-level provenance.
1530    pub fn set_mounts_with_origins(&mut self, values: Vec<Sourced<Mount>>, origins: Vec<Provenance>) {
1531        self.mounts = Some(values);
1532        self.mounts_origins = origins;
1533    }
1534
1535    /// Appends one pod mount in source order.
1536    pub fn add_mount(&mut self, value: Sourced<Mount>) {
1537        self.mounts.get_or_insert_default().push(value);
1538    }
1539
1540    /// Returns pod mounts, preserving omitted versus explicit-empty state.
1541    #[must_use]
1542    pub fn mounts(&self) -> Option<&[Sourced<Mount>]> {
1543        self.mounts.as_deref()
1544    }
1545
1546    /// Returns collection-level pod-mount provenance.
1547    #[must_use]
1548    pub fn mounts_origins(&self) -> &[Provenance] {
1549        &self.mounts_origins
1550    }
1551
1552    /// Sets the raw protected pod shared-memory-size spelling.
1553    pub fn set_shm_size(&mut self, value: Sourced<ProtectedString>) {
1554        self.shm_size = Some(value);
1555    }
1556
1557    /// Returns the raw protected pod shared-memory-size spelling.
1558    #[must_use]
1559    pub const fn shm_size(&self) -> Option<&Sourced<ProtectedString>> {
1560        self.shm_size.as_ref()
1561    }
1562
1563    /// Sets the pod exit policy.
1564    pub fn set_exit_policy(&mut self, value: Sourced<GroupExitPolicy>) {
1565        self.exit_policy = Some(value);
1566    }
1567
1568    /// Returns the explicit pod exit policy.
1569    #[must_use]
1570    pub const fn exit_policy(&self) -> Option<&Sourced<GroupExitPolicy>> {
1571        self.exit_policy.as_ref()
1572    }
1573
1574    /// Sets the raw stop-grace duration for the pod.
1575    pub fn set_stop_timeout(&mut self, value: Sourced<StopTimeout>) {
1576        self.stop_timeout = Some(value);
1577    }
1578
1579    /// Returns the explicit raw pod stop-grace duration.
1580    #[must_use]
1581    pub const fn stop_timeout(&self) -> Option<&Sourced<StopTimeout>> {
1582        self.stop_timeout.as_ref()
1583    }
1584}
1585
1586/// How a service command overrides the image command.
1587#[derive(Clone, Debug, Eq, PartialEq)]
1588#[non_exhaustive]
1589pub enum Command {
1590    /// Execute an argument vector without target-specific shell parsing.
1591    Exec(Vec<ProtectedString>),
1592    /// Execute authored shell text with target-specific shell semantics still unresolved.
1593    Shell(ProtectedString),
1594    /// Explicitly clear the image command.
1595    Empty,
1596}
1597
1598/// Startup notification behavior requested for one service.
1599///
1600/// The variants retain the portable meanings of Quadlet's `Notify=false`, `Notify=true`, and
1601/// `Notify=healthy` without making a target implementation implicit.
1602#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1603#[non_exhaustive]
1604pub enum StartupNotification {
1605    /// The runtime owns readiness notification (`Notify=false`).
1606    Runtime,
1607    /// The application process owns readiness notification (`Notify=true`).
1608    Application,
1609    /// Readiness is reported from the service health check (`Notify=healthy`).
1610    Healthy,
1611}
1612
1613/// How a service overrides the image entrypoint.
1614///
1615/// This remains distinct from [`Command`]: container runtimes combine the two values differently,
1616/// and a source can explicitly clear either image default independently.
1617#[derive(Clone, Debug, Eq, PartialEq)]
1618#[non_exhaustive]
1619pub enum Entrypoint {
1620    /// Execute an argument vector without target-specific shell parsing.
1621    Exec(Vec<ProtectedString>),
1622    /// Execute authored shell text with target-specific shell semantics still unresolved.
1623    Shell(ProtectedString),
1624    /// Explicitly clear the image entrypoint.
1625    Empty,
1626}
1627
1628/// Source-independent image pull intent.
1629///
1630/// Variants beyond the shared policies intentionally retain source-native behavior for a target
1631/// adapter to classify rather than silently reducing it to a different policy.
1632#[derive(Clone, Debug, Eq, PartialEq)]
1633#[non_exhaustive]
1634pub enum PullPolicy {
1635    /// Always acquire the image before starting.
1636    Always,
1637    /// Acquire the image only when it is absent locally.
1638    Missing,
1639    /// Never acquire the image automatically.
1640    Never,
1641    /// A source-specific spelling with semantics similar to [`Self::Missing`].
1642    IfNotPresent,
1643    /// Build an image instead of pulling it.
1644    Build,
1645    /// Refresh the image daily.
1646    Daily,
1647    /// Refresh the image weekly.
1648    Weekly,
1649    /// Refresh using a source-native interval spelling.
1650    Every(ProtectedString),
1651    /// Retain any other target-native policy spelling.
1652    Raw(ProtectedString),
1653}
1654
1655/// Raw-preserving stop grace duration.
1656///
1657/// Source adapters validate native duration grammar; target adapters report spellings their
1658/// selected implementation cannot express.
1659#[derive(Clone, Debug, Eq, PartialEq)]
1660pub struct StopTimeout(String);
1661
1662impl StopTimeout {
1663    /// Creates a non-empty duration spelling without imposing one source format's grammar.
1664    ///
1665    /// # Errors
1666    ///
1667    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
1668    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
1669        let value = value.into();
1670        validate_text("stop timeout", &value)?;
1671        Ok(Self(value))
1672    }
1673
1674    /// Returns the source adapter's retained duration spelling.
1675    #[must_use]
1676    pub fn as_str(&self) -> &str {
1677        &self.0
1678    }
1679}
1680
1681/// A container port exposed for inter-service use without publishing it to a host.
1682#[derive(Clone, Debug, Eq, PartialEq)]
1683pub struct ExposedPort {
1684    container: u16,
1685    protocol: Protocol,
1686}
1687
1688impl ExposedPort {
1689    /// Creates one exposed container port.
1690    ///
1691    /// # Errors
1692    ///
1693    /// Returns [`ModelError::ZeroContainerPort`] when `container` is zero.
1694    pub fn new(container: u16, protocol: Protocol) -> Result<Self, ModelError> {
1695        if container == 0 {
1696            return Err(ModelError::ZeroContainerPort);
1697        }
1698        Ok(Self { container, protocol })
1699    }
1700
1701    /// Returns the container-side port number.
1702    #[must_use]
1703    pub const fn container(&self) -> u16 {
1704        self.container
1705    }
1706
1707    /// Returns the requested transport protocol.
1708    #[must_use]
1709    pub const fn protocol(&self) -> &Protocol {
1710        &self.protocol
1711    }
1712}
1713
1714/// Container-level automatic restart intent.
1715///
1716/// This policy is distinct from Compose dependency restart propagation and orchestrator-level
1717/// deployment restart policies. A limited on-failure policy uses a non-zero retry count so the
1718/// absence of a limit remains distinguishable from an invalid zero-valued limit.
1719#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1720#[non_exhaustive]
1721pub enum RestartPolicy {
1722    /// Never restart the container automatically.
1723    Never,
1724    /// Restart after every container exit.
1725    Always,
1726    /// Restart after a failed container exit, optionally up to a finite retry count.
1727    OnFailure {
1728        /// Maximum number of restart attempts; `None` means no policy-specific limit.
1729        maximum_retries: Option<std::num::NonZeroU64>,
1730    },
1731    /// Restart automatically unless an explicit stop state must survive runtime restart.
1732    UnlessStopped,
1733}
1734
1735impl RestartPolicy {
1736    /// Creates an on-failure policy with an optional non-zero retry limit.
1737    #[must_use]
1738    pub const fn on_failure(maximum_retries: Option<std::num::NonZeroU64>) -> Self {
1739        Self::OnFailure { maximum_retries }
1740    }
1741
1742    /// Returns the finite retry limit of an on-failure policy.
1743    #[must_use]
1744    pub const fn maximum_retries(self) -> Option<std::num::NonZeroU64> {
1745        match self {
1746            Self::OnFailure { maximum_retries } => maximum_retries,
1747            Self::Never | Self::Always | Self::UnlessStopped => None,
1748        }
1749    }
1750}
1751
1752/// How a container runtime executes one service health check.
1753#[derive(Clone, Debug, Eq, PartialEq)]
1754#[non_exhaustive]
1755pub enum HealthcheckCommand {
1756    /// Execute an argument vector without a container shell.
1757    Exec(Vec<ProtectedString>),
1758    /// Execute authored command text through the container shell.
1759    Shell(ProtectedString),
1760}
1761
1762/// Raw-preserving duration shared by container health-check implementations.
1763#[derive(Clone, Debug, Eq, PartialEq)]
1764pub struct HealthcheckDuration(String);
1765
1766impl HealthcheckDuration {
1767    /// Creates a non-empty duration spelling without imposing one source format's grammar.
1768    ///
1769    /// Source adapters remain responsible for validating native duration syntax. Target adapters
1770    /// must report spellings that their selected implementation cannot represent.
1771    ///
1772    /// # Errors
1773    ///
1774    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
1775    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
1776        let value = value.into();
1777        validate_text("health-check duration", &value)?;
1778        Ok(Self(value))
1779    }
1780
1781    /// Returns the source adapter's retained duration spelling.
1782    #[must_use]
1783    pub fn as_str(&self) -> &str {
1784        &self.0
1785    }
1786}
1787
1788/// Raw-preserving non-negative health-check retry count.
1789#[derive(Clone, Debug, Eq, PartialEq)]
1790pub struct HealthcheckRetries(String);
1791
1792impl HealthcheckRetries {
1793    /// Creates a retry count while retaining its authored decimal spelling.
1794    ///
1795    /// # Errors
1796    ///
1797    /// Returns [`ModelError::EmptyValue`], [`ModelError::ContainsNul`], or
1798    /// [`ModelError::InvalidHealthcheckRetries`].
1799    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
1800        let value = value.into();
1801        validate_text("health-check retries", &value)?;
1802        if !value.bytes().all(|byte| byte.is_ascii_digit()) {
1803            return Err(ModelError::InvalidHealthcheckRetries);
1804        }
1805        Ok(Self(value))
1806    }
1807
1808    /// Returns the source adapter's retained decimal spelling.
1809    #[must_use]
1810    pub fn as_str(&self) -> &str {
1811        &self.0
1812    }
1813}
1814
1815/// Format-independent service health-check intent with field-level provenance.
1816#[derive(Clone, Debug, Default, Eq, PartialEq)]
1817pub struct Healthcheck {
1818    command: Option<Sourced<HealthcheckCommand>>,
1819    disabled: Option<Sourced<bool>>,
1820    interval: Option<Sourced<HealthcheckDuration>>,
1821    timeout: Option<Sourced<HealthcheckDuration>>,
1822    retries: Option<Sourced<HealthcheckRetries>>,
1823    start_period: Option<Sourced<HealthcheckDuration>>,
1824    start_interval: Option<Sourced<HealthcheckDuration>>,
1825}
1826
1827impl Healthcheck {
1828    /// Creates an empty health-check definition for incremental source-adapter mapping.
1829    #[must_use]
1830    pub const fn new() -> Self {
1831        Self {
1832            command: None,
1833            disabled: None,
1834            interval: None,
1835            timeout: None,
1836            retries: None,
1837            start_period: None,
1838            start_interval: None,
1839        }
1840    }
1841
1842    /// Sets the command executed by the runtime.
1843    pub fn set_command(&mut self, command: Sourced<HealthcheckCommand>) {
1844        self.command = Some(command);
1845    }
1846
1847    /// Returns the optional health command.
1848    #[must_use]
1849    pub const fn command(&self) -> Option<&Sourced<HealthcheckCommand>> {
1850        self.command.as_ref()
1851    }
1852
1853    /// Retains an explicit enable/disable decision.
1854    pub fn set_disabled(&mut self, disabled: Sourced<bool>) {
1855        self.disabled = Some(disabled);
1856    }
1857
1858    /// Returns the explicit disable decision, if the source supplied one.
1859    #[must_use]
1860    pub const fn disabled(&self) -> Option<&Sourced<bool>> {
1861        self.disabled.as_ref()
1862    }
1863
1864    /// Sets the interval between regular checks.
1865    pub fn set_interval(&mut self, interval: Sourced<HealthcheckDuration>) {
1866        self.interval = Some(interval);
1867    }
1868
1869    /// Returns the interval between regular checks.
1870    #[must_use]
1871    pub const fn interval(&self) -> Option<&Sourced<HealthcheckDuration>> {
1872        self.interval.as_ref()
1873    }
1874
1875    /// Sets the maximum duration of one check.
1876    pub fn set_timeout(&mut self, timeout: Sourced<HealthcheckDuration>) {
1877        self.timeout = Some(timeout);
1878    }
1879
1880    /// Returns the maximum duration of one check.
1881    #[must_use]
1882    pub const fn timeout(&self) -> Option<&Sourced<HealthcheckDuration>> {
1883        self.timeout.as_ref()
1884    }
1885
1886    /// Sets the number of failures required before becoming unhealthy.
1887    pub fn set_retries(&mut self, retries: Sourced<HealthcheckRetries>) {
1888        self.retries = Some(retries);
1889    }
1890
1891    /// Returns the failure threshold.
1892    #[must_use]
1893    pub const fn retries(&self) -> Option<&Sourced<HealthcheckRetries>> {
1894        self.retries.as_ref()
1895    }
1896
1897    /// Sets the startup grace period.
1898    pub fn set_start_period(&mut self, start_period: Sourced<HealthcheckDuration>) {
1899        self.start_period = Some(start_period);
1900    }
1901
1902    /// Returns the startup grace period.
1903    #[must_use]
1904    pub const fn start_period(&self) -> Option<&Sourced<HealthcheckDuration>> {
1905        self.start_period.as_ref()
1906    }
1907
1908    /// Sets the check interval used during the startup grace period.
1909    pub fn set_start_interval(&mut self, start_interval: Sourced<HealthcheckDuration>) {
1910        self.start_interval = Some(start_interval);
1911    }
1912
1913    /// Returns the check interval used during the startup grace period.
1914    #[must_use]
1915    pub const fn start_interval(&self) -> Option<&Sourced<HealthcheckDuration>> {
1916        self.start_interval.as_ref()
1917    }
1918}
1919
1920/// Source of an environment variable's value.
1921#[derive(Clone, Debug, Eq, PartialEq)]
1922#[non_exhaustive]
1923pub enum EnvironmentValue {
1924    /// A literal plain or sensitive value, never executable source expression syntax.
1925    Literal(ProtectedString),
1926    /// Explicit service-execution environment lookup expression.
1927    Deferred(crate::DeferredExpression),
1928    /// Opaque authored source intent, preserved only by a matching source-format exporter.
1929    UnresolvedSource {
1930        /// Explicit native format identity; no source grammar is interpreted by the model.
1931        source_format: Identifier,
1932        /// Protected authored expression, never a resolved literal value.
1933        expression: ProtectedString,
1934    },
1935    /// A named value withheld from this migration artifact; the target operator must supply it.
1936    Required,
1937    /// Resolve the value from an explicit caller-provided host environment provider.
1938    Host,
1939    /// Ensure the variable is absent.
1940    Unset,
1941}
1942
1943/// One ordered service environment entry.
1944#[derive(Clone, Debug, Eq, PartialEq)]
1945pub struct EnvironmentVariable {
1946    name: Identifier,
1947    value: EnvironmentValue,
1948}
1949
1950/// Authored syntax family retained for an environment-file declaration.
1951#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1952#[non_exhaustive]
1953pub enum EnvironmentFileSyntax {
1954    /// Path-only short syntax with source-format defaults.
1955    Short,
1956    /// Mapping-based syntax with separately authored options.
1957    Long,
1958}
1959
1960/// Explicit parsing mode requested for an environment file.
1961#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1962#[non_exhaustive]
1963pub enum EnvironmentFileFormat {
1964    /// Preserve values without interpolation or quote processing when the source supports it.
1965    Raw,
1966}
1967
1968/// One ordered environment-file declaration.
1969///
1970/// This value describes source intent only. Importing it never reads the referenced file. A
1971/// caller that wants to materialize environment values must cross a separate filesystem-access
1972/// boundary and apply the source implementation's parsing rules explicitly.
1973#[derive(Clone, Debug, Eq, PartialEq)]
1974pub struct EnvironmentFile {
1975    path: ProtectedString,
1976    syntax: EnvironmentFileSyntax,
1977    required: Option<Sourced<bool>>,
1978    format: Option<Sourced<EnvironmentFileFormat>>,
1979}
1980
1981impl EnvironmentFile {
1982    /// Creates an environment-file declaration with a non-empty path.
1983    ///
1984    /// # Errors
1985    ///
1986    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
1987    pub fn new(path: ProtectedString, syntax: EnvironmentFileSyntax) -> Result<Self, ModelError> {
1988        validate_text("environment-file path", path.expose())?;
1989        Ok(Self {
1990            path,
1991            syntax,
1992            required: None,
1993            format: None,
1994        })
1995    }
1996
1997    /// Returns the source-authored path without resolving or reading it.
1998    #[must_use]
1999    pub const fn path(&self) -> &ProtectedString {
2000        &self.path
2001    }
2002
2003    /// Returns the authored short/long syntax family.
2004    #[must_use]
2005    pub const fn syntax(&self) -> EnvironmentFileSyntax {
2006        self.syntax
2007    }
2008
2009    /// Retains an explicit required/optional choice.
2010    pub fn set_required(&mut self, required: Sourced<bool>) {
2011        self.required = Some(required);
2012    }
2013
2014    /// Returns the explicit required/optional choice, if authored.
2015    #[must_use]
2016    pub const fn required(&self) -> Option<&Sourced<bool>> {
2017        self.required.as_ref()
2018    }
2019
2020    /// Returns whether the source requires the file, including the default of `true`.
2021    #[must_use]
2022    pub fn is_required(&self) -> bool {
2023        self.required.as_ref().is_none_or(|required| *required.value())
2024    }
2025
2026    /// Retains an explicitly selected parsing mode.
2027    pub fn set_format(&mut self, format: Sourced<EnvironmentFileFormat>) {
2028        self.format = Some(format);
2029    }
2030
2031    /// Returns the explicitly selected parsing mode, if authored.
2032    #[must_use]
2033    pub const fn format(&self) -> Option<&Sourced<EnvironmentFileFormat>> {
2034        self.format.as_ref()
2035    }
2036}
2037
2038/// One portable metadata label attached to an application resource.
2039///
2040/// Label names remain opaque because Docker, Podman, Compose, and future targets do not share one
2041/// useful restrictive grammar. Values use [`ProtectedString`] so runtime-derived metadata cannot
2042/// leak through debug output before a caller explicitly authorizes rendering it.
2043#[derive(Clone, Debug, Eq, PartialEq)]
2044pub struct MetadataLabel {
2045    name: Identifier,
2046    value: ProtectedString,
2047}
2048
2049impl MetadataLabel {
2050    /// Creates one metadata label.
2051    #[must_use]
2052    pub const fn new(name: Identifier, value: ProtectedString) -> Self {
2053        Self { name, value }
2054    }
2055
2056    /// Returns the opaque metadata-label name.
2057    #[must_use]
2058    pub const fn name(&self) -> &Identifier {
2059        &self.name
2060    }
2061
2062    /// Returns the protected metadata-label value.
2063    #[must_use]
2064    pub const fn value(&self) -> &ProtectedString {
2065        &self.value
2066    }
2067}
2068
2069/// One protected annotation with independently sourced name and value.
2070///
2071/// Unlike service metadata labels, annotations can be target-scoped. Their opaque spelling and
2072/// field provenance remain available for a later target adapter to make that decision explicitly.
2073#[derive(Clone, Debug, Eq, PartialEq)]
2074pub struct Annotation {
2075    name: Sourced<Identifier>,
2076    value: Sourced<ProtectedString>,
2077}
2078
2079impl Annotation {
2080    /// Creates one annotation with separately retained name and value provenance.
2081    #[must_use]
2082    pub const fn new(name: Sourced<Identifier>, value: Sourced<ProtectedString>) -> Self {
2083        Self { name, value }
2084    }
2085
2086    /// Returns the opaque annotation name and its provenance.
2087    #[must_use]
2088    pub const fn name(&self) -> &Sourced<Identifier> {
2089        &self.name
2090    }
2091
2092    /// Returns the protected annotation value and its provenance.
2093    #[must_use]
2094    pub const fn value(&self) -> &Sourced<ProtectedString> {
2095        &self.value
2096    }
2097}
2098
2099/// One provider-specific logging option with independently sourced name and value.
2100#[derive(Clone, Debug, Eq, PartialEq)]
2101pub struct LoggingOption {
2102    name: Sourced<Identifier>,
2103    value: Sourced<ProtectedString>,
2104}
2105
2106impl LoggingOption {
2107    /// Creates one logging option without imposing a provider's option grammar.
2108    #[must_use]
2109    pub const fn new(name: Sourced<Identifier>, value: Sourced<ProtectedString>) -> Self {
2110        Self { name, value }
2111    }
2112
2113    /// Returns the provider-specific option name and its provenance.
2114    #[must_use]
2115    pub const fn name(&self) -> &Sourced<Identifier> {
2116        &self.name
2117    }
2118
2119    /// Returns the protected provider-specific option value and its provenance.
2120    #[must_use]
2121    pub const fn value(&self) -> &Sourced<ProtectedString> {
2122        &self.value
2123    }
2124}
2125
2126/// Provider-specific logging intent.
2127///
2128/// A missing options collection differs from an explicit empty collection, which can reset
2129/// provider defaults. Option names and values remain opaque and ordered.
2130#[derive(Clone, Debug, Default, Eq, PartialEq)]
2131pub struct Logging {
2132    driver: Option<Sourced<ProtectedString>>,
2133    options: Option<Vec<Sourced<LoggingOption>>>,
2134    options_origins: Vec<Provenance>,
2135}
2136
2137impl Logging {
2138    /// Creates an empty logging declaration for incremental source-adapter mapping.
2139    #[must_use]
2140    pub const fn new() -> Self {
2141        Self {
2142            driver: None,
2143            options: None,
2144            options_origins: Vec::new(),
2145        }
2146    }
2147
2148    /// Sets the provider-specific logging driver spelling.
2149    pub fn set_driver(&mut self, driver: Sourced<ProtectedString>) {
2150        self.driver = Some(driver);
2151    }
2152
2153    /// Returns the explicitly authored logging driver.
2154    #[must_use]
2155    pub const fn driver(&self) -> Option<&Sourced<ProtectedString>> {
2156        self.driver.as_ref()
2157    }
2158
2159    /// Sets ordered logging options while preserving explicit emptiness.
2160    pub fn set_options(&mut self, options: Vec<Sourced<LoggingOption>>) {
2161        self.options = Some(options);
2162        self.options_origins.clear();
2163    }
2164
2165    /// Appends one logging option in source order.
2166    pub fn add_option(&mut self, option: Sourced<LoggingOption>) {
2167        self.options.get_or_insert_default().push(option);
2168    }
2169
2170    /// Sets ordered logging options and collection-level provenance.
2171    pub fn set_options_with_origins(&mut self, options: Vec<Sourced<LoggingOption>>, origins: Vec<Provenance>) {
2172        self.options = Some(options);
2173        self.options_origins = origins;
2174    }
2175
2176    /// Returns ordered logging options, preserving omitted versus explicit-empty state.
2177    #[must_use]
2178    pub fn options(&self) -> Option<&[Sourced<LoggingOption>]> {
2179        self.options.as_deref()
2180    }
2181
2182    /// Returns collection-level logging option provenance.
2183    #[must_use]
2184    pub fn options_origins(&self) -> &[Provenance] {
2185        &self.options_origins
2186    }
2187}
2188
2189/// One mutually exclusive reload action.
2190///
2191/// Reloading is lifecycle control, not a regular command or a lifecycle hook. A service therefore
2192/// retains one explicit action rather than allowing command and signal declarations to conflict.
2193#[derive(Clone, Debug, Eq, PartialEq)]
2194#[non_exhaustive]
2195pub enum ReloadAction {
2196    /// Execute the supplied command to request a reload.
2197    Command(Command),
2198    /// Deliver the supplied signal spelling to request a reload.
2199    Signal(ProtectedString),
2200}
2201
2202impl EnvironmentVariable {
2203    /// Creates an environment entry.
2204    #[must_use]
2205    pub const fn new(name: Identifier, value: EnvironmentValue) -> Self {
2206        Self { name, value }
2207    }
2208
2209    /// Returns the variable name.
2210    #[must_use]
2211    pub const fn name(&self) -> &Identifier {
2212        &self.name
2213    }
2214
2215    /// Returns the unresolved value form.
2216    #[must_use]
2217    pub const fn value(&self) -> &EnvironmentValue {
2218        &self.value
2219    }
2220}
2221
2222/// One raw-preserving address or runtime token used by a service host mapping.
2223#[derive(Clone, Debug, Eq, PartialEq)]
2224pub struct HostAddress {
2225    raw: String,
2226    kind: HostAddressKind,
2227}
2228
2229impl HostAddress {
2230    /// Classifies an authored host-mapping address without normalizing it.
2231    ///
2232    /// # Errors
2233    ///
2234    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
2235    pub fn new(raw: impl Into<String>) -> Result<Self, ModelError> {
2236        let raw = raw.into();
2237        validate_text("host mapping address", &raw)?;
2238        let unbracketed = raw
2239            .strip_prefix('[')
2240            .and_then(|value| value.strip_suffix(']'))
2241            .unwrap_or(&raw);
2242        let kind = if raw == "host-gateway" {
2243            HostAddressKind::HostGateway
2244        } else {
2245            match unbracketed.parse::<IpAddr>() {
2246                Ok(IpAddr::V4(_)) => HostAddressKind::Ipv4,
2247                Ok(IpAddr::V6(_)) => HostAddressKind::Ipv6 {
2248                    bracketed: raw.starts_with('[') && raw.ends_with(']'),
2249                },
2250                Err(_) => HostAddressKind::Other,
2251            }
2252        };
2253        Ok(Self { raw, kind })
2254    }
2255
2256    /// Returns the address or runtime token exactly as supplied by the source adapter.
2257    #[must_use]
2258    pub fn raw(&self) -> &str {
2259        &self.raw
2260    }
2261
2262    /// Returns the conservative lexical classification.
2263    #[must_use]
2264    pub const fn kind(&self) -> HostAddressKind {
2265        self.kind
2266    }
2267}
2268
2269/// Conservative kind of one service host-mapping address.
2270#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2271#[non_exhaustive]
2272pub enum HostAddressKind {
2273    /// An IPv4 address.
2274    Ipv4,
2275    /// An IPv6 address, retaining whether its source spelling used brackets.
2276    Ipv6 {
2277        /// Whether the source adapter observed `[::1]` rather than `::1`.
2278        bracketed: bool,
2279    },
2280    /// The runtime-specific `host-gateway` token.
2281    HostGateway,
2282    /// A deferred or implementation-specific value.
2283    Other,
2284}
2285
2286/// One ordered service hostname-to-address mapping.
2287#[derive(Clone, Debug, Eq, PartialEq)]
2288pub struct HostMapping {
2289    hostname: Identifier,
2290    address: HostAddress,
2291}
2292
2293impl HostMapping {
2294    /// Creates a service host mapping.
2295    #[must_use]
2296    pub const fn new(hostname: Identifier, address: HostAddress) -> Self {
2297        Self { hostname, address }
2298    }
2299
2300    /// Returns the hostname written into the target hosts file.
2301    #[must_use]
2302    pub const fn hostname(&self) -> &Identifier {
2303        &self.hostname
2304    }
2305
2306    /// Returns the raw-preserving address or runtime token.
2307    #[must_use]
2308    pub const fn address(&self) -> &HostAddress {
2309        &self.address
2310    }
2311}
2312
2313/// Transport protocol attached to a published port.
2314#[derive(Clone, Debug, Eq, PartialEq)]
2315#[non_exhaustive]
2316pub enum Protocol {
2317    /// Transmission Control Protocol.
2318    Tcp,
2319    /// User Datagram Protocol.
2320    Udp,
2321    /// Stream Control Transmission Protocol.
2322    Sctp,
2323    /// A preserved protocol not yet understood by the neutral model.
2324    Other(String),
2325}
2326
2327/// One container port published to a fixed or automatically allocated host port.
2328///
2329/// An absent published port requests automatic host-port allocation. Exposure without host
2330/// publication is represented separately by [`ExposedPort`].
2331#[derive(Clone, Debug, Eq, PartialEq)]
2332pub struct Port {
2333    container: u16,
2334    published: Option<u16>,
2335    host_address: Option<String>,
2336    protocol: Protocol,
2337}
2338
2339impl Port {
2340    /// Creates a host-publication declaration; `published: None` requests automatic allocation.
2341    ///
2342    /// # Errors
2343    ///
2344    /// Returns [`ModelError::ZeroContainerPort`] when `container` is zero.
2345    pub fn new(
2346        container: u16,
2347        published: Option<u16>,
2348        host_address: Option<String>,
2349        protocol: Protocol,
2350    ) -> Result<Self, ModelError> {
2351        if container == 0 {
2352            return Err(ModelError::ZeroContainerPort);
2353        }
2354        Ok(Self {
2355            container,
2356            published,
2357            host_address,
2358            protocol,
2359        })
2360    }
2361
2362    /// Returns the container port.
2363    #[must_use]
2364    pub const fn container(&self) -> u16 {
2365        self.container
2366    }
2367
2368    /// Returns the fixed host port, or `None` when the runtime should allocate one automatically.
2369    #[must_use]
2370    pub const fn published(&self) -> Option<u16> {
2371        self.published
2372    }
2373
2374    /// Returns the optional host address spelling.
2375    #[must_use]
2376    pub fn host_address(&self) -> Option<&str> {
2377        self.host_address.as_deref()
2378    }
2379
2380    /// Returns the protocol.
2381    #[must_use]
2382    pub const fn protocol(&self) -> &Protocol {
2383        &self.protocol
2384    }
2385}
2386
2387/// Storage backing attached to a service.
2388#[derive(Clone, Debug, Eq, PartialEq)]
2389#[non_exhaustive]
2390pub enum MountSource {
2391    /// Application-level named volume.
2392    Volume(Identifier),
2393    /// Authored host path whose target-specific resolution is deferred.
2394    HostPath(String),
2395    /// Explicit host-bind path resolved in the service execution environment.
2396    DeferredHostPath(crate::DeferredExpression),
2397    /// Anonymous target-managed storage.
2398    Anonymous,
2399}
2400
2401/// `SELinux` label sharing requested for a bind mount.
2402#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2403#[non_exhaustive]
2404pub enum SelinuxRelabel {
2405    /// Share the relabeled content between multiple containers (`z`).
2406    Shared,
2407    /// Give the content a private label for one container (`Z`).
2408    Private,
2409}
2410
2411/// One service storage attachment.
2412#[derive(Clone, Debug, Eq, PartialEq)]
2413pub struct Mount {
2414    source: MountSource,
2415    target: String,
2416    read_only: bool,
2417    selinux_relabel: Option<SelinuxRelabel>,
2418    literal_paths: bool,
2419}
2420
2421impl Mount {
2422    /// Marks path text as literal rather than target-context syntax.
2423    /// Explicit deferred reference segments remain references; resource identifiers retain identity.
2424    #[must_use]
2425    pub const fn with_literal_paths(mut self) -> Self {
2426        self.literal_paths = true;
2427        self
2428    }
2429
2430    /// Returns whether host-source and container-target text must remain literal.
2431    #[must_use]
2432    pub const fn paths_are_literal(&self) -> bool {
2433        self.literal_paths
2434    }
2435
2436    /// Creates a storage attachment with a non-empty target path.
2437    ///
2438    /// # Errors
2439    ///
2440    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`] for the target.
2441    pub fn new(source: MountSource, target: impl Into<String>, read_only: bool) -> Result<Self, ModelError> {
2442        let target = target.into();
2443        validate_text("mount target", &target)?;
2444        let literal_paths = matches!(&source, MountSource::DeferredHostPath(_));
2445        Ok(Self {
2446            source,
2447            target,
2448            read_only,
2449            selinux_relabel: None,
2450            literal_paths,
2451        })
2452    }
2453
2454    /// Returns the source storage kind.
2455    #[must_use]
2456    pub const fn source(&self) -> &MountSource {
2457        &self.source
2458    }
2459
2460    /// Returns the authored container target path.
2461    #[must_use]
2462    pub fn target(&self) -> &str {
2463        &self.target
2464    }
2465
2466    /// Returns whether the target is read-only.
2467    #[must_use]
2468    pub const fn read_only(&self) -> bool {
2469        self.read_only
2470    }
2471
2472    /// Sets the requested `SELinux` relabel mode without erasing the source syntax decision.
2473    pub fn set_selinux_relabel(&mut self, relabel: SelinuxRelabel) {
2474        self.selinux_relabel = Some(relabel);
2475    }
2476
2477    /// Returns the requested `SELinux` relabel mode.
2478    #[must_use]
2479    pub const fn selinux_relabel(&self) -> Option<SelinuxRelabel> {
2480        self.selinux_relabel
2481    }
2482}
2483
2484/// One service attachment to an application network.
2485#[derive(Clone, Eq, PartialEq)]
2486pub struct NetworkAttachment {
2487    network: Identifier,
2488    aliases: Vec<String>,
2489    alias_sensitivities: Vec<bool>,
2490    alias_origins: Vec<Vec<Provenance>>,
2491    ipv4_address: Option<Sourced<ProtectedString>>,
2492    ipv6_address: Option<Sourced<ProtectedString>>,
2493}
2494
2495impl NetworkAttachment {
2496    /// Creates a network attachment with ordered provenance-bearing aliases.
2497    #[must_use]
2498    pub fn new(network: Identifier, aliases: Vec<Sourced<ProtectedString>>) -> Self {
2499        Self {
2500            network,
2501            aliases: aliases.iter().map(|alias| alias.value().expose().to_owned()).collect(),
2502            alias_sensitivities: aliases.iter().map(|alias| alias.value().is_sensitive()).collect(),
2503            alias_origins: aliases.into_iter().map(|alias| alias.origins().to_vec()).collect(),
2504            ipv4_address: None,
2505            ipv6_address: None,
2506        }
2507    }
2508
2509    /// Returns the application network name.
2510    #[must_use]
2511    pub const fn network(&self) -> &Identifier {
2512        &self.network
2513    }
2514
2515    /// Returns aliases in authored order.
2516    #[must_use]
2517    pub fn aliases(&self) -> &[String] {
2518        &self.aliases
2519    }
2520
2521    /// Returns alias origins in the same order as [`Self::aliases`].
2522    #[must_use]
2523    pub fn alias_origins(&self) -> &[Vec<Provenance>] {
2524        &self.alias_origins
2525    }
2526
2527    /// Returns per-alias sensitivity flags in the same order as [`Self::aliases`].
2528    ///
2529    /// Target adapters use this boundary to avoid passing protected aliases into native APIs that
2530    /// cannot redact them.
2531    #[must_use]
2532    pub fn alias_sensitivities(&self) -> &[bool] {
2533        &self.alias_sensitivities
2534    }
2535
2536    /// Replaces aliases with ordered provenance-bearing source values.
2537    pub fn set_aliases_with_provenance(&mut self, aliases: Vec<Sourced<ProtectedString>>) {
2538        self.aliases = aliases.iter().map(|alias| alias.value().expose().to_owned()).collect();
2539        self.alias_sensitivities = aliases.iter().map(|alias| alias.value().is_sensitive()).collect();
2540        self.alias_origins = aliases.into_iter().map(|alias| alias.origins().to_vec()).collect();
2541    }
2542
2543    /// Appends one alias and its provenance.
2544    pub fn add_alias(&mut self, alias: &Sourced<ProtectedString>) {
2545        self.aliases.push(alias.value().expose().to_owned());
2546        self.alias_sensitivities.push(alias.value().is_sensitive());
2547        self.alias_origins.push(alias.origins().to_vec());
2548    }
2549
2550    /// Sets the attachment's explicit IPv4 address spelling.
2551    pub fn set_ipv4_address(&mut self, address: Sourced<ProtectedString>) {
2552        self.ipv4_address = Some(address);
2553    }
2554
2555    /// Returns the attachment's explicit IPv4 address spelling.
2556    #[must_use]
2557    pub const fn ipv4_address(&self) -> Option<&Sourced<ProtectedString>> {
2558        self.ipv4_address.as_ref()
2559    }
2560
2561    /// Sets the attachment's explicit IPv6 address spelling.
2562    pub fn set_ipv6_address(&mut self, address: Sourced<ProtectedString>) {
2563        self.ipv6_address = Some(address);
2564    }
2565
2566    /// Returns the attachment's explicit IPv6 address spelling.
2567    #[must_use]
2568    pub const fn ipv6_address(&self) -> Option<&Sourced<ProtectedString>> {
2569        self.ipv6_address.as_ref()
2570    }
2571}
2572
2573impl fmt::Debug for NetworkAttachment {
2574    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2575        let aliases = self
2576            .aliases
2577            .iter()
2578            .enumerate()
2579            .map(|(index, alias)| {
2580                if self.alias_sensitivities.get(index).copied().unwrap_or(false) {
2581                    "[REDACTED]"
2582                } else {
2583                    alias.as_str()
2584                }
2585            })
2586            .collect::<Vec<_>>();
2587        formatter
2588            .debug_struct("NetworkAttachment")
2589            .field("network", &self.network)
2590            .field("aliases", &aliases)
2591            .field("alias_origins", &self.alias_origins)
2592            .field("ipv4_address", &self.ipv4_address)
2593            .field("ipv6_address", &self.ipv6_address)
2594            .finish()
2595    }
2596}
2597
2598/// Readiness state a service dependency must reach before its dependent starts.
2599#[derive(Clone, Debug, Eq, PartialEq)]
2600#[non_exhaustive]
2601pub enum ServiceDependencyCondition {
2602    /// The dependency's service startup completed.
2603    Started,
2604    /// The dependency reported healthy readiness.
2605    Healthy,
2606    /// The dependency exited successfully.
2607    CompletedSuccessfully,
2608    /// A source-specific condition retained for explicit target-side reporting.
2609    Other(ProtectedString),
2610}
2611
2612/// One ordered dependency edge from a service to another application service.
2613///
2614/// Optional fields distinguish source defaults from explicitly authored values. The surrounding
2615/// [`Sourced`] value carries the referenced service-name provenance, while each option retains its
2616/// own field-level provenance.
2617#[derive(Clone, Debug, Eq, PartialEq)]
2618pub struct ServiceDependency {
2619    service: Identifier,
2620    condition: Option<Sourced<ServiceDependencyCondition>>,
2621    restart: Option<Sourced<bool>>,
2622    required: Option<Sourced<bool>>,
2623}
2624
2625/// One raw-preserving kernel-parameter assignment.
2626#[derive(Clone, Debug, Eq, PartialEq)]
2627pub struct KernelParameter {
2628    name: ProtectedString,
2629    value: ProtectedString,
2630}
2631
2632impl KernelParameter {
2633    /// Creates an assignment without interpreting kernel namespaces or privileges.
2634    #[must_use]
2635    pub const fn new(name: ProtectedString, value: ProtectedString) -> Self {
2636        Self { name, value }
2637    }
2638
2639    /// Returns the authored parameter name.
2640    #[must_use]
2641    pub const fn name(&self) -> &ProtectedString {
2642        &self.name
2643    }
2644
2645    /// Returns the authored scalar value spelling.
2646    #[must_use]
2647    pub const fn value(&self) -> &ProtectedString {
2648        &self.value
2649    }
2650}
2651
2652/// One raw-preserving resource-limit declaration.
2653#[derive(Clone, Debug, Eq, PartialEq)]
2654pub struct ResourceLimit {
2655    name: ProtectedString,
2656    soft: Option<Sourced<ProtectedString>>,
2657    hard: Option<Sourced<ProtectedString>>,
2658}
2659
2660impl ResourceLimit {
2661    /// Creates a limit with independently sourced soft and hard values.
2662    #[must_use]
2663    pub const fn new(
2664        name: ProtectedString,
2665        soft: Option<Sourced<ProtectedString>>,
2666        hard: Option<Sourced<ProtectedString>>,
2667    ) -> Self {
2668        Self { name, soft, hard }
2669    }
2670
2671    /// Returns the raw limit name.
2672    #[must_use]
2673    pub const fn name(&self) -> &ProtectedString {
2674        &self.name
2675    }
2676
2677    /// Returns the optional soft value.
2678    #[must_use]
2679    pub const fn soft(&self) -> Option<&Sourced<ProtectedString>> {
2680        self.soft.as_ref()
2681    }
2682
2683    /// Returns the optional hard value.
2684    #[must_use]
2685    pub const fn hard(&self) -> Option<&Sourced<ProtectedString>> {
2686        self.hard.as_ref()
2687    }
2688}
2689
2690/// A service device declaration with its authored syntax retained.
2691#[derive(Clone, Debug, Eq, PartialEq)]
2692#[non_exhaustive]
2693pub enum Device {
2694    /// A raw short device spelling.
2695    Short(ProtectedString),
2696    /// A long device mapping with independently sourced members.
2697    Long {
2698        /// Host-device source spelling.
2699        source: Option<Sourced<ProtectedString>>,
2700        /// Container-device target spelling.
2701        target: Option<Sourced<ProtectedString>>,
2702        /// Raw permission spelling.
2703        permissions: Option<Sourced<ProtectedString>>,
2704    },
2705}
2706
2707/// One format-independent service security option.
2708///
2709/// Native adapters retain ordering and duplicates around this value. They classify any
2710/// source-specific singleton conflicts rather than imposing those rules on the neutral model.
2711#[derive(Clone, Debug, Eq, PartialEq)]
2712#[non_exhaustive]
2713pub enum SecurityOption {
2714    /// Selects an `AppArmor` profile.
2715    AppArmor(ProtectedString),
2716    /// Enables or disables the no-new-privileges security bit.
2717    NoNewPrivileges(bool),
2718    /// Selects a seccomp profile.
2719    SeccompProfile(ProtectedString),
2720    /// Selects whether `SELinux` labeling is disabled (`true` disables labels).
2721    SecurityLabelDisable(bool),
2722    /// Selects the `SELinux` file type.
2723    SecurityLabelFileType(ProtectedString),
2724    /// Selects the `SELinux` level.
2725    SecurityLabelLevel(ProtectedString),
2726    /// Enables or disables nested `SELinux` labeling.
2727    SecurityLabelNested(bool),
2728    /// Selects the `SELinux` type.
2729    SecurityLabelType(ProtectedString),
2730    /// Masks one or more colon-separated container paths, or `ALL`.
2731    Mask(ProtectedString),
2732    /// Unmasks one or more colon-separated container paths, or `ALL`.
2733    Unmask(ProtectedString),
2734}
2735
2736impl ServiceDependency {
2737    /// Creates an edge using source-format defaults for readiness, restart propagation, and
2738    /// requirement strength.
2739    #[must_use]
2740    pub const fn new(service: Identifier) -> Self {
2741        Self {
2742            service,
2743            condition: None,
2744            restart: None,
2745            required: None,
2746        }
2747    }
2748
2749    /// Returns the referenced application service.
2750    #[must_use]
2751    pub const fn service(&self) -> &Identifier {
2752        &self.service
2753    }
2754
2755    /// Sets the explicitly authored readiness condition.
2756    pub fn set_condition(&mut self, condition: Sourced<ServiceDependencyCondition>) {
2757        self.condition = Some(condition);
2758    }
2759
2760    /// Returns the explicitly authored readiness condition, if any.
2761    #[must_use]
2762    pub const fn condition(&self) -> Option<&Sourced<ServiceDependencyCondition>> {
2763        self.condition.as_ref()
2764    }
2765
2766    /// Retains whether source-controlled dependency updates restart the dependent service.
2767    pub fn set_restart(&mut self, restart: Sourced<bool>) {
2768        self.restart = Some(restart);
2769    }
2770
2771    /// Returns the explicit restart-propagation choice, if any.
2772    #[must_use]
2773    pub const fn restart(&self) -> Option<&Sourced<bool>> {
2774        self.restart.as_ref()
2775    }
2776
2777    /// Retains whether absence or failure of the dependency blocks the dependent service.
2778    pub fn set_required(&mut self, required: Sourced<bool>) {
2779        self.required = Some(required);
2780    }
2781
2782    /// Returns the explicit requirement-strength choice, if any.
2783    #[must_use]
2784    pub const fn required(&self) -> Option<&Sourced<bool>> {
2785        self.required.as_ref()
2786    }
2787
2788    /// Returns the effective source requirement, including the default of `true`.
2789    #[must_use]
2790    pub fn is_required(&self) -> bool {
2791        self.required.as_ref().is_none_or(|required| *required.value())
2792    }
2793}
2794
2795/// One application service with ordered attachments and source provenance.
2796#[derive(Clone, Debug, Eq, PartialEq)]
2797pub struct Service {
2798    name: Identifier,
2799    runtime_name: Option<Sourced<ProtectedString>>,
2800    rootfs: Option<Sourced<ProtectedString>>,
2801    image: Option<Sourced<ImageReference>>,
2802    image_acquisition: Option<Sourced<Identifier>>,
2803    image_build: Option<Sourced<Identifier>>,
2804    command: Option<Sourced<Command>>,
2805    startup_notification: Option<Sourced<StartupNotification>>,
2806    entrypoint: Option<Sourced<Entrypoint>>,
2807    run_init: Option<Sourced<bool>>,
2808    stop_timeout: Option<Sourced<StopTimeout>>,
2809    pull_policy: Option<Sourced<PullPolicy>>,
2810    memory_limit: Option<Sourced<ProtectedString>>,
2811    exposed_ports: Option<Vec<Sourced<ExposedPort>>>,
2812    exposed_ports_origins: Vec<Provenance>,
2813    restart_policy: Option<Sourced<RestartPolicy>>,
2814    healthcheck: Option<Sourced<Healthcheck>>,
2815    labels: Vec<Sourced<MetadataLabel>>,
2816    annotations: Option<Vec<Sourced<Annotation>>>,
2817    annotations_origins: Vec<Provenance>,
2818    logging: Option<Sourced<Logging>>,
2819    reload_action: Option<Sourced<ReloadAction>>,
2820    user: Option<Sourced<ProtectedString>>,
2821    group: Option<Sourced<ProtectedString>>,
2822    user_namespace: Option<Sourced<ProtectedString>>,
2823    supplementary_groups: Vec<Sourced<ProtectedString>>,
2824    working_directory: Option<Sourced<ProtectedString>>,
2825    read_only_root_filesystem: Option<Sourced<bool>>,
2826    hostname: Option<Sourced<ProtectedString>>,
2827    dns_servers: Option<Vec<Sourced<ProtectedString>>>,
2828    dns_servers_origins: Vec<Provenance>,
2829    dns_options: Option<Vec<Sourced<ProtectedString>>>,
2830    dns_options_origins: Vec<Provenance>,
2831    dns_search_domains: Option<Vec<Sourced<ProtectedString>>>,
2832    dns_search_domains_origins: Vec<Provenance>,
2833    security_options: Option<Vec<Sourced<SecurityOption>>>,
2834    security_options_origins: Vec<Provenance>,
2835    pids_limit: Option<Sourced<ProtectedString>>,
2836    shm_size: Option<Sourced<ProtectedString>>,
2837    cap_add: Option<Vec<Sourced<ProtectedString>>>,
2838    cap_add_origins: Vec<Provenance>,
2839    cap_drop: Option<Vec<Sourced<ProtectedString>>>,
2840    cap_drop_origins: Vec<Provenance>,
2841    tmpfs: Option<Vec<Sourced<ProtectedString>>>,
2842    tmpfs_origins: Vec<Provenance>,
2843    sysctls: Option<Vec<Sourced<KernelParameter>>>,
2844    sysctls_origins: Vec<Provenance>,
2845    ulimits: Option<Vec<Sourced<ResourceLimit>>>,
2846    ulimits_origins: Vec<Provenance>,
2847    devices: Option<Vec<Sourced<Device>>>,
2848    devices_origins: Vec<Provenance>,
2849    stop_signal: Option<Sourced<ProtectedString>>,
2850    environment: Vec<Sourced<EnvironmentVariable>>,
2851    environment_files: Vec<Sourced<EnvironmentFile>>,
2852    execution_environment: Option<Sourced<crate::ServiceExecutionEnvironment>>,
2853    host_mappings: Vec<Sourced<HostMapping>>,
2854    ports: Vec<Sourced<Port>>,
2855    mounts: Vec<Sourced<Mount>>,
2856    config_grants: Vec<Sourced<ResourceGrant>>,
2857    secret_grants: Vec<Sourced<ResourceGrant>>,
2858    networks: Vec<Sourced<NetworkAttachment>>,
2859    dependencies: Vec<Sourced<ServiceDependency>>,
2860}
2861
2862impl Service {
2863    /// Sets an explicit external execution-environment plan. Validated when added to an application.
2864    pub fn set_execution_environment(&mut self, value: Sourced<crate::ServiceExecutionEnvironment>) {
2865        self.execution_environment = Some(value);
2866    }
2867    /// Returns the explicit external execution-environment plan.
2868    #[must_use]
2869    pub const fn execution_environment(&self) -> Option<&Sourced<crate::ServiceExecutionEnvironment>> {
2870        self.execution_environment.as_ref()
2871    }
2872
2873    /// Creates an empty service shell for incremental adapter mapping.
2874    #[must_use]
2875    pub const fn new(name: Identifier) -> Self {
2876        Self {
2877            name,
2878            runtime_name: None,
2879            rootfs: None,
2880            image: None,
2881            image_acquisition: None,
2882            image_build: None,
2883            command: None,
2884            startup_notification: None,
2885            entrypoint: None,
2886            run_init: None,
2887            stop_timeout: None,
2888            pull_policy: None,
2889            memory_limit: None,
2890            exposed_ports: None,
2891            exposed_ports_origins: Vec::new(),
2892            restart_policy: None,
2893            healthcheck: None,
2894            labels: Vec::new(),
2895            annotations: None,
2896            annotations_origins: Vec::new(),
2897            logging: None,
2898            reload_action: None,
2899            user: None,
2900            group: None,
2901            user_namespace: None,
2902            supplementary_groups: Vec::new(),
2903            working_directory: None,
2904            read_only_root_filesystem: None,
2905            hostname: None,
2906            dns_servers: None,
2907            dns_servers_origins: Vec::new(),
2908            dns_options: None,
2909            dns_options_origins: Vec::new(),
2910            dns_search_domains: None,
2911            dns_search_domains_origins: Vec::new(),
2912            security_options: None,
2913            security_options_origins: Vec::new(),
2914            pids_limit: None,
2915            shm_size: None,
2916            cap_add: None,
2917            cap_add_origins: Vec::new(),
2918            cap_drop: None,
2919            cap_drop_origins: Vec::new(),
2920            tmpfs: None,
2921            tmpfs_origins: Vec::new(),
2922            sysctls: None,
2923            sysctls_origins: Vec::new(),
2924            ulimits: None,
2925            ulimits_origins: Vec::new(),
2926            devices: None,
2927            devices_origins: Vec::new(),
2928            stop_signal: None,
2929            environment: Vec::new(),
2930            environment_files: Vec::new(),
2931            execution_environment: None,
2932            host_mappings: Vec::new(),
2933            ports: Vec::new(),
2934            mounts: Vec::new(),
2935            config_grants: Vec::new(),
2936            secret_grants: Vec::new(),
2937            networks: Vec::new(),
2938            dependencies: Vec::new(),
2939        }
2940    }
2941
2942    /// Returns the service name.
2943    #[must_use]
2944    pub const fn name(&self) -> &Identifier {
2945        &self.name
2946    }
2947
2948    /// Sets an explicit provider/runtime-level container name distinct from the service key.
2949    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
2950        self.runtime_name = Some(name);
2951    }
2952
2953    /// Returns the explicit provider/runtime-level container name.
2954    #[must_use]
2955    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
2956        self.runtime_name.as_ref()
2957    }
2958
2959    /// Sets a protected root-filesystem path instead of an image source.
2960    ///
2961    /// # Errors
2962    ///
2963    /// Returns [`ModelError::RootfsImageSourceConflict`] when this service already has an image,
2964    /// image acquisition, or image build reference.
2965    pub fn set_rootfs(&mut self, rootfs: Sourced<ProtectedString>) -> Result<(), ModelError> {
2966        self.ensure_rootfs_is_compatible()?;
2967        self.rootfs = Some(rootfs);
2968        Ok(())
2969    }
2970
2971    /// Returns the protected root-filesystem path, if explicitly authored.
2972    #[must_use]
2973    pub const fn rootfs(&self) -> Option<&Sourced<ProtectedString>> {
2974        self.rootfs.as_ref()
2975    }
2976
2977    /// Sets the optional image reference.
2978    pub fn set_image(&mut self, image: Sourced<ImageReference>) {
2979        self.image = Some(image);
2980    }
2981
2982    /// Returns the optional image reference.
2983    #[must_use]
2984    pub const fn image(&self) -> Option<&Sourced<ImageReference>> {
2985        self.image.as_ref()
2986    }
2987
2988    /// References a separately declared image-acquisition resource.
2989    ///
2990    /// This does not replace [`Self::image`], which remains the runtime container image reference.
2991    pub fn set_image_acquisition(&mut self, acquisition: Sourced<Identifier>) {
2992        self.image_acquisition = Some(acquisition);
2993    }
2994
2995    /// Returns the separately declared image-acquisition resource reference.
2996    #[must_use]
2997    pub const fn image_acquisition(&self) -> Option<&Sourced<Identifier>> {
2998        self.image_acquisition.as_ref()
2999    }
3000
3001    /// References a separately declared image-build resource.
3002    ///
3003    /// This does not replace [`Self::image`] or any container runtime settings.
3004    pub fn set_image_build(&mut self, build: Sourced<Identifier>) {
3005        self.image_build = Some(build);
3006    }
3007
3008    /// Returns the separately declared image-build resource reference.
3009    #[must_use]
3010    pub const fn image_build(&self) -> Option<&Sourced<Identifier>> {
3011        self.image_build.as_ref()
3012    }
3013
3014    /// Sets the command override.
3015    pub fn set_command(&mut self, command: Sourced<Command>) {
3016        self.command = Some(command);
3017    }
3018
3019    /// Returns the command override.
3020    #[must_use]
3021    pub const fn command(&self) -> Option<&Sourced<Command>> {
3022        self.command.as_ref()
3023    }
3024
3025    /// Sets the source-authored startup-notification behavior.
3026    pub fn set_startup_notification(&mut self, notification: Sourced<StartupNotification>) {
3027        self.startup_notification = Some(notification);
3028    }
3029
3030    /// Returns the explicit startup-notification behavior.
3031    #[must_use]
3032    pub const fn startup_notification(&self) -> Option<&Sourced<StartupNotification>> {
3033        self.startup_notification.as_ref()
3034    }
3035
3036    /// Sets the entrypoint override independently from the command override.
3037    pub fn set_entrypoint(&mut self, entrypoint: Sourced<Entrypoint>) {
3038        self.entrypoint = Some(entrypoint);
3039    }
3040
3041    /// Returns the optional entrypoint override.
3042    #[must_use]
3043    pub const fn entrypoint(&self) -> Option<&Sourced<Entrypoint>> {
3044        self.entrypoint.as_ref()
3045    }
3046
3047    /// Sets whether the runtime should run its init process.
3048    pub fn set_run_init(&mut self, run_init: Sourced<bool>) {
3049        self.run_init = Some(run_init);
3050    }
3051
3052    /// Returns the explicit init-process choice.
3053    #[must_use]
3054    pub const fn run_init(&self) -> Option<&Sourced<bool>> {
3055        self.run_init.as_ref()
3056    }
3057
3058    /// Sets the raw stop-grace duration.
3059    pub fn set_stop_timeout(&mut self, timeout: Sourced<StopTimeout>) {
3060        self.stop_timeout = Some(timeout);
3061    }
3062
3063    /// Returns the explicit raw stop-grace duration.
3064    #[must_use]
3065    pub const fn stop_timeout(&self) -> Option<&Sourced<StopTimeout>> {
3066        self.stop_timeout.as_ref()
3067    }
3068
3069    /// Sets the source-independent image pull intent.
3070    pub fn set_pull_policy(&mut self, policy: Sourced<PullPolicy>) {
3071        self.pull_policy = Some(policy);
3072    }
3073
3074    /// Returns the explicit image pull intent.
3075    #[must_use]
3076    pub const fn pull_policy(&self) -> Option<&Sourced<PullPolicy>> {
3077        self.pull_policy.as_ref()
3078    }
3079
3080    /// Sets the raw protected memory-limit spelling.
3081    pub fn set_memory_limit(&mut self, limit: Sourced<ProtectedString>) {
3082        self.memory_limit = Some(limit);
3083    }
3084
3085    /// Returns the raw protected memory-limit spelling.
3086    #[must_use]
3087    pub const fn memory_limit(&self) -> Option<&Sourced<ProtectedString>> {
3088        self.memory_limit.as_ref()
3089    }
3090
3091    /// Sets exposed container ports, preserving omission separately from an explicit empty list.
3092    pub fn set_exposed_ports(&mut self, ports: Vec<Sourced<ExposedPort>>) {
3093        self.exposed_ports = Some(ports);
3094        self.exposed_ports_origins.clear();
3095    }
3096
3097    /// Sets exposed container ports and collection-level provenance.
3098    pub fn set_exposed_ports_with_origins(&mut self, ports: Vec<Sourced<ExposedPort>>, origins: Vec<Provenance>) {
3099        self.exposed_ports = Some(ports);
3100        self.exposed_ports_origins = origins;
3101    }
3102
3103    /// Appends one exposed container port without publishing it to a host.
3104    pub fn add_exposed_port(&mut self, port: Sourced<ExposedPort>) {
3105        self.exposed_ports.get_or_insert_default().push(port);
3106    }
3107
3108    /// Returns exposed container ports in authored order, preserving omitted versus explicit-empty state.
3109    #[must_use]
3110    pub fn exposed_ports(&self) -> Option<&[Sourced<ExposedPort>]> {
3111        self.exposed_ports.as_deref()
3112    }
3113
3114    /// Returns collection-level exposed-port provenance.
3115    #[must_use]
3116    pub fn exposed_ports_origins(&self) -> &[Provenance] {
3117        &self.exposed_ports_origins
3118    }
3119
3120    /// Sets the container-level automatic restart policy.
3121    pub fn set_restart_policy(&mut self, restart_policy: Sourced<RestartPolicy>) {
3122        self.restart_policy = Some(restart_policy);
3123    }
3124
3125    /// Returns the container-level automatic restart policy.
3126    #[must_use]
3127    pub const fn restart_policy(&self) -> Option<&Sourced<RestartPolicy>> {
3128        self.restart_policy.as_ref()
3129    }
3130
3131    /// Sets the service health-check definition.
3132    pub fn set_healthcheck(&mut self, healthcheck: Sourced<Healthcheck>) {
3133        self.healthcheck = Some(healthcheck);
3134    }
3135
3136    /// Returns the optional service health-check definition.
3137    #[must_use]
3138    pub const fn healthcheck(&self) -> Option<&Sourced<Healthcheck>> {
3139        self.healthcheck.as_ref()
3140    }
3141
3142    /// Appends one service metadata label while preserving source order and provenance.
3143    pub fn add_label(&mut self, label: Sourced<MetadataLabel>) {
3144        self.labels.push(label);
3145    }
3146
3147    /// Returns service metadata labels in source order.
3148    #[must_use]
3149    pub fn labels(&self) -> &[Sourced<MetadataLabel>] {
3150        &self.labels
3151    }
3152
3153    /// Sets repeatable annotations while preserving omission separately from an explicit empty list.
3154    pub fn set_annotations(&mut self, annotations: Vec<Sourced<Annotation>>) {
3155        self.annotations = Some(annotations);
3156        self.annotations_origins.clear();
3157    }
3158
3159    /// Appends one annotation in source order.
3160    pub fn add_annotation(&mut self, annotation: Sourced<Annotation>) {
3161        self.annotations.get_or_insert_default().push(annotation);
3162    }
3163
3164    /// Sets repeatable annotations with collection-level provenance.
3165    pub fn set_annotations_with_origins(&mut self, annotations: Vec<Sourced<Annotation>>, origins: Vec<Provenance>) {
3166        self.annotations = Some(annotations);
3167        self.annotations_origins = origins;
3168    }
3169
3170    /// Returns annotations, preserving omitted versus explicit-empty state.
3171    #[must_use]
3172    pub fn annotations(&self) -> Option<&[Sourced<Annotation>]> {
3173        self.annotations.as_deref()
3174    }
3175
3176    /// Returns collection-level annotation provenance.
3177    #[must_use]
3178    pub fn annotations_origins(&self) -> &[Provenance] {
3179        &self.annotations_origins
3180    }
3181
3182    /// Sets provider-specific logging intent.
3183    pub fn set_logging(&mut self, logging: Sourced<Logging>) {
3184        self.logging = Some(logging);
3185    }
3186
3187    /// Returns provider-specific logging intent.
3188    #[must_use]
3189    pub const fn logging(&self) -> Option<&Sourced<Logging>> {
3190        self.logging.as_ref()
3191    }
3192
3193    /// Sets the mutually exclusive reload action.
3194    pub fn set_reload_action(&mut self, reload_action: Sourced<ReloadAction>) {
3195        self.reload_action = Some(reload_action);
3196    }
3197
3198    /// Returns the one explicit reload action, if any.
3199    #[must_use]
3200    pub const fn reload_action(&self) -> Option<&Sourced<ReloadAction>> {
3201        self.reload_action.as_ref()
3202    }
3203
3204    /// Sets the primary identity used inside the service container.
3205    pub fn set_user(&mut self, user: Sourced<ProtectedString>) {
3206        self.user = Some(user);
3207    }
3208
3209    /// Returns the primary identity used inside the service container.
3210    #[must_use]
3211    pub const fn user(&self) -> Option<&Sourced<ProtectedString>> {
3212        self.user.as_ref()
3213    }
3214
3215    /// Sets the primary group used inside the service container.
3216    pub fn set_group(&mut self, group: Sourced<ProtectedString>) {
3217        self.group = Some(group);
3218    }
3219
3220    /// Returns the primary group used inside the service container.
3221    #[must_use]
3222    pub const fn group(&self) -> Option<&Sourced<ProtectedString>> {
3223        self.group.as_ref()
3224    }
3225
3226    /// Sets the requested user-namespace mode without imposing one runtime's grammar.
3227    pub fn set_user_namespace(&mut self, user_namespace: Sourced<ProtectedString>) {
3228        self.user_namespace = Some(user_namespace);
3229    }
3230
3231    /// Returns the raw-preserving user-namespace mode.
3232    #[must_use]
3233    pub const fn user_namespace(&self) -> Option<&Sourced<ProtectedString>> {
3234        self.user_namespace.as_ref()
3235    }
3236
3237    /// Appends one supplementary group in source order.
3238    pub fn add_supplementary_group(&mut self, group: Sourced<ProtectedString>) {
3239        self.supplementary_groups.push(group);
3240    }
3241
3242    /// Returns supplementary groups in source order.
3243    #[must_use]
3244    pub fn supplementary_groups(&self) -> &[Sourced<ProtectedString>] {
3245        &self.supplementary_groups
3246    }
3247
3248    /// Sets the working directory inside the service container.
3249    pub fn set_working_directory(&mut self, working_directory: Sourced<ProtectedString>) {
3250        self.working_directory = Some(working_directory);
3251    }
3252
3253    /// Returns the working directory inside the service container.
3254    #[must_use]
3255    pub const fn working_directory(&self) -> Option<&Sourced<ProtectedString>> {
3256        self.working_directory.as_ref()
3257    }
3258
3259    /// Sets the explicit read-only root-filesystem choice.
3260    pub fn set_read_only_root_filesystem(&mut self, read_only: Sourced<bool>) {
3261        self.read_only_root_filesystem = Some(read_only);
3262    }
3263
3264    /// Returns the explicit read-only root-filesystem choice.
3265    #[must_use]
3266    pub const fn read_only_root_filesystem(&self) -> Option<&Sourced<bool>> {
3267        self.read_only_root_filesystem.as_ref()
3268    }
3269
3270    /// Sets the explicit container hostname without inferring namespace ownership.
3271    pub fn set_hostname(&mut self, hostname: Sourced<ProtectedString>) {
3272        self.hostname = Some(hostname);
3273    }
3274
3275    /// Returns the raw-preserving explicit hostname.
3276    #[must_use]
3277    pub const fn hostname(&self) -> Option<&Sourced<ProtectedString>> {
3278        self.hostname.as_ref()
3279    }
3280
3281    /// Sets ordered DNS servers, preserving omission separately from an explicit empty list.
3282    pub fn set_dns_servers_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3283        self.dns_servers = Some(values);
3284        self.dns_servers_origins = origins;
3285    }
3286
3287    /// Sets ordered DNS servers without separate collection provenance.
3288    pub fn set_dns_servers(&mut self, values: Vec<Sourced<ProtectedString>>) {
3289        self.set_dns_servers_with_origins(values, Vec::new());
3290    }
3291
3292    /// Returns ordered DNS servers when explicitly authored.
3293    #[must_use]
3294    pub fn dns_servers(&self) -> Option<&[Sourced<ProtectedString>]> {
3295        self.dns_servers.as_deref()
3296    }
3297
3298    /// Returns collection provenance for explicitly authored DNS servers.
3299    #[must_use]
3300    pub fn dns_servers_origins(&self) -> &[Provenance] {
3301        &self.dns_servers_origins
3302    }
3303
3304    /// Sets ordered DNS resolver options, preserving omission separately from an explicit empty list.
3305    pub fn set_dns_options_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3306        self.dns_options = Some(values);
3307        self.dns_options_origins = origins;
3308    }
3309
3310    /// Sets ordered DNS resolver options without separate collection provenance.
3311    pub fn set_dns_options(&mut self, values: Vec<Sourced<ProtectedString>>) {
3312        self.set_dns_options_with_origins(values, Vec::new());
3313    }
3314
3315    /// Returns ordered DNS resolver options when explicitly authored.
3316    #[must_use]
3317    pub fn dns_options(&self) -> Option<&[Sourced<ProtectedString>]> {
3318        self.dns_options.as_deref()
3319    }
3320
3321    /// Returns collection provenance for explicitly authored DNS resolver options.
3322    #[must_use]
3323    pub fn dns_options_origins(&self) -> &[Provenance] {
3324        &self.dns_options_origins
3325    }
3326
3327    /// Sets ordered DNS search domains, preserving omission separately from an explicit empty list.
3328    pub fn set_dns_search_domains_with_origins(
3329        &mut self,
3330        values: Vec<Sourced<ProtectedString>>,
3331        origins: Vec<Provenance>,
3332    ) {
3333        self.dns_search_domains = Some(values);
3334        self.dns_search_domains_origins = origins;
3335    }
3336
3337    /// Sets ordered DNS search domains without separate collection provenance.
3338    pub fn set_dns_search_domains(&mut self, values: Vec<Sourced<ProtectedString>>) {
3339        self.set_dns_search_domains_with_origins(values, Vec::new());
3340    }
3341
3342    /// Returns ordered DNS search domains when explicitly authored.
3343    #[must_use]
3344    pub fn dns_search_domains(&self) -> Option<&[Sourced<ProtectedString>]> {
3345        self.dns_search_domains.as_deref()
3346    }
3347
3348    /// Returns collection provenance for explicitly authored DNS search domains.
3349    #[must_use]
3350    pub fn dns_search_domains_origins(&self) -> &[Provenance] {
3351        &self.dns_search_domains_origins
3352    }
3353
3354    /// Sets ordered security options, preserving omission separately from an explicit empty list.
3355    pub fn set_security_options_with_origins(
3356        &mut self,
3357        values: Vec<Sourced<SecurityOption>>,
3358        origins: Vec<Provenance>,
3359    ) {
3360        self.security_options = Some(values);
3361        self.security_options_origins = origins;
3362    }
3363
3364    /// Sets ordered security options without separate collection provenance.
3365    pub fn set_security_options(&mut self, values: Vec<Sourced<SecurityOption>>) {
3366        self.set_security_options_with_origins(values, Vec::new());
3367    }
3368
3369    /// Returns ordered security options when explicitly authored.
3370    #[must_use]
3371    pub fn security_options(&self) -> Option<&[Sourced<SecurityOption>]> {
3372        self.security_options.as_deref()
3373    }
3374
3375    /// Returns collection provenance for explicitly authored security options.
3376    #[must_use]
3377    pub fn security_options_origins(&self) -> &[Provenance] {
3378        &self.security_options_origins
3379    }
3380
3381    /// Sets the raw process-ID limit spelling.
3382    pub fn set_pids_limit(&mut self, limit: Sourced<ProtectedString>) {
3383        self.pids_limit = Some(limit);
3384    }
3385
3386    /// Returns the raw process-ID limit spelling.
3387    #[must_use]
3388    pub const fn pids_limit(&self) -> Option<&Sourced<ProtectedString>> {
3389        self.pids_limit.as_ref()
3390    }
3391
3392    /// Sets the raw shared-memory size spelling.
3393    pub fn set_shm_size(&mut self, size: Sourced<ProtectedString>) {
3394        self.shm_size = Some(size);
3395    }
3396
3397    /// Returns the raw shared-memory size spelling.
3398    #[must_use]
3399    pub const fn shm_size(&self) -> Option<&Sourced<ProtectedString>> {
3400        self.shm_size.as_ref()
3401    }
3402
3403    /// Sets the complete ordered capability-add collection; `Some([])` retains an explicit reset.
3404    pub fn set_cap_add(&mut self, values: Vec<Sourced<ProtectedString>>) {
3405        self.cap_add = Some(values);
3406        self.cap_add_origins.clear();
3407    }
3408
3409    /// Sets capability additions with collection-level provenance for an explicit empty/reset value.
3410    pub fn set_cap_add_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3411        self.cap_add = Some(values);
3412        self.cap_add_origins = origins;
3413    }
3414
3415    /// Returns capability additions, preserving omitted versus explicit-empty state.
3416    #[must_use]
3417    pub fn cap_add(&self) -> Option<&[Sourced<ProtectedString>]> {
3418        self.cap_add.as_deref()
3419    }
3420
3421    /// Returns the collection-level capability-add provenance.
3422    #[must_use]
3423    pub fn cap_add_origins(&self) -> &[Provenance] {
3424        &self.cap_add_origins
3425    }
3426
3427    /// Sets the complete ordered capability-drop collection; `Some([])` retains an explicit reset.
3428    pub fn set_cap_drop(&mut self, values: Vec<Sourced<ProtectedString>>) {
3429        self.cap_drop = Some(values);
3430        self.cap_drop_origins.clear();
3431    }
3432
3433    /// Sets capability removals with collection-level provenance for an explicit empty/reset value.
3434    pub fn set_cap_drop_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3435        self.cap_drop = Some(values);
3436        self.cap_drop_origins = origins;
3437    }
3438
3439    /// Returns capability removals, preserving omitted versus explicit-empty state.
3440    #[must_use]
3441    pub fn cap_drop(&self) -> Option<&[Sourced<ProtectedString>]> {
3442        self.cap_drop.as_deref()
3443    }
3444
3445    /// Returns the collection-level capability-drop provenance.
3446    #[must_use]
3447    pub fn cap_drop_origins(&self) -> &[Provenance] {
3448        &self.cap_drop_origins
3449    }
3450
3451    /// Sets ordered raw temporary-filesystem declarations.
3452    pub fn set_tmpfs(&mut self, values: Vec<Sourced<ProtectedString>>) {
3453        self.tmpfs = Some(values);
3454        self.tmpfs_origins.clear();
3455    }
3456
3457    /// Sets temporary filesystems with collection-level provenance.
3458    pub fn set_tmpfs_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3459        self.tmpfs = Some(values);
3460        self.tmpfs_origins = origins;
3461    }
3462
3463    /// Returns temporary-filesystem declarations, preserving explicit-empty state.
3464    #[must_use]
3465    pub fn tmpfs(&self) -> Option<&[Sourced<ProtectedString>]> {
3466        self.tmpfs.as_deref()
3467    }
3468
3469    /// Returns collection-level temporary-filesystem provenance.
3470    #[must_use]
3471    pub fn tmpfs_origins(&self) -> &[Provenance] {
3472        &self.tmpfs_origins
3473    }
3474
3475    /// Sets ordered raw kernel-parameter assignments.
3476    pub fn set_sysctls(&mut self, values: Vec<Sourced<KernelParameter>>) {
3477        self.sysctls = Some(values);
3478        self.sysctls_origins.clear();
3479    }
3480
3481    /// Sets kernel parameters with collection-level provenance.
3482    pub fn set_sysctls_with_origins(&mut self, values: Vec<Sourced<KernelParameter>>, origins: Vec<Provenance>) {
3483        self.sysctls = Some(values);
3484        self.sysctls_origins = origins;
3485    }
3486
3487    /// Returns kernel-parameter assignments, preserving explicit-empty state.
3488    #[must_use]
3489    pub fn sysctls(&self) -> Option<&[Sourced<KernelParameter>]> {
3490        self.sysctls.as_deref()
3491    }
3492
3493    /// Returns collection-level kernel-parameter provenance.
3494    #[must_use]
3495    pub fn sysctls_origins(&self) -> &[Provenance] {
3496        &self.sysctls_origins
3497    }
3498
3499    /// Sets ordered resource limits.
3500    pub fn set_ulimits(&mut self, values: Vec<Sourced<ResourceLimit>>) {
3501        self.ulimits = Some(values);
3502        self.ulimits_origins.clear();
3503    }
3504
3505    /// Sets resource limits with collection-level provenance.
3506    pub fn set_ulimits_with_origins(&mut self, values: Vec<Sourced<ResourceLimit>>, origins: Vec<Provenance>) {
3507        self.ulimits = Some(values);
3508        self.ulimits_origins = origins;
3509    }
3510
3511    /// Returns resource limits, preserving explicit-empty state.
3512    #[must_use]
3513    pub fn ulimits(&self) -> Option<&[Sourced<ResourceLimit>]> {
3514        self.ulimits.as_deref()
3515    }
3516
3517    /// Returns collection-level resource-limit provenance.
3518    #[must_use]
3519    pub fn ulimits_origins(&self) -> &[Provenance] {
3520        &self.ulimits_origins
3521    }
3522
3523    /// Sets ordered short/long device declarations.
3524    pub fn set_devices(&mut self, values: Vec<Sourced<Device>>) {
3525        self.devices = Some(values);
3526        self.devices_origins.clear();
3527    }
3528
3529    /// Sets devices with collection-level provenance.
3530    pub fn set_devices_with_origins(&mut self, values: Vec<Sourced<Device>>, origins: Vec<Provenance>) {
3531        self.devices = Some(values);
3532        self.devices_origins = origins;
3533    }
3534
3535    /// Returns device declarations, preserving explicit-empty state.
3536    #[must_use]
3537    pub fn devices(&self) -> Option<&[Sourced<Device>]> {
3538        self.devices.as_deref()
3539    }
3540
3541    /// Returns collection-level device provenance.
3542    #[must_use]
3543    pub fn devices_origins(&self) -> &[Provenance] {
3544        &self.devices_origins
3545    }
3546
3547    /// Sets the explicit stop-signal spelling.
3548    pub fn set_stop_signal(&mut self, signal: Sourced<ProtectedString>) {
3549        self.stop_signal = Some(signal);
3550    }
3551
3552    /// Returns the raw explicit stop-signal spelling.
3553    #[must_use]
3554    pub const fn stop_signal(&self) -> Option<&Sourced<ProtectedString>> {
3555        self.stop_signal.as_ref()
3556    }
3557
3558    /// Appends an environment entry.
3559    pub fn add_environment(&mut self, value: Sourced<EnvironmentVariable>) {
3560        self.environment.push(value);
3561    }
3562
3563    /// Returns environment entries in authored order.
3564    #[must_use]
3565    pub fn environment(&self) -> &[Sourced<EnvironmentVariable>] {
3566        &self.environment
3567    }
3568
3569    /// Appends an environment-file declaration without reading the referenced file.
3570    pub fn add_environment_file(&mut self, value: Sourced<EnvironmentFile>) {
3571        self.environment_files.push(value);
3572    }
3573
3574    /// Returns environment-file declarations in authored order.
3575    #[must_use]
3576    pub fn environment_files(&self) -> &[Sourced<EnvironmentFile>] {
3577        &self.environment_files
3578    }
3579
3580    /// Appends an explicit hostname-to-address mapping.
3581    pub fn add_host_mapping(&mut self, value: Sourced<HostMapping>) {
3582        self.host_mappings.push(value);
3583    }
3584
3585    /// Returns explicit host mappings in authored order.
3586    #[must_use]
3587    pub fn host_mappings(&self) -> &[Sourced<HostMapping>] {
3588        &self.host_mappings
3589    }
3590
3591    /// Appends a port.
3592    pub fn add_port(&mut self, value: Sourced<Port>) {
3593        self.ports.push(value);
3594    }
3595
3596    /// Returns ports in authored order.
3597    #[must_use]
3598    pub fn ports(&self) -> &[Sourced<Port>] {
3599        &self.ports
3600    }
3601
3602    /// Appends a storage attachment.
3603    pub fn add_mount(&mut self, value: Sourced<Mount>) {
3604        self.mounts.push(value);
3605    }
3606
3607    /// Returns storage attachments in authored order.
3608    #[must_use]
3609    pub fn mounts(&self) -> &[Sourced<Mount>] {
3610        &self.mounts
3611    }
3612
3613    /// Appends a configuration grant in source order.
3614    pub fn add_config_grant(&mut self, value: Sourced<ResourceGrant>) {
3615        self.config_grants.push(value);
3616    }
3617
3618    /// Returns configuration grants in source order.
3619    #[must_use]
3620    pub fn config_grants(&self) -> &[Sourced<ResourceGrant>] {
3621        &self.config_grants
3622    }
3623
3624    /// Appends a secret grant in source order.
3625    pub fn add_secret_grant(&mut self, value: Sourced<ResourceGrant>) {
3626        self.secret_grants.push(value);
3627    }
3628
3629    /// Returns secret grants in source order.
3630    #[must_use]
3631    pub fn secret_grants(&self) -> &[Sourced<ResourceGrant>] {
3632        &self.secret_grants
3633    }
3634
3635    /// Appends a network attachment.
3636    pub fn add_network(&mut self, value: Sourced<NetworkAttachment>) {
3637        self.networks.push(value);
3638    }
3639
3640    /// Replaces one existing network attachment without changing authored order.
3641    ///
3642    /// Returns the previous attachment. This narrow mutation boundary lets importers enrich an
3643    /// attachment only after later native entries establish attachment-scoped details.
3644    ///
3645    /// # Errors
3646    ///
3647    /// Returns [`ModelError::UnknownNetworkAttachmentIndex`] when `index` is outside the ordered
3648    /// attachment collection.
3649    pub fn replace_network(
3650        &mut self,
3651        index: usize,
3652        value: Sourced<NetworkAttachment>,
3653    ) -> Result<Sourced<NetworkAttachment>, ModelError> {
3654        let len = self.networks.len();
3655        let Some(slot) = self.networks.get_mut(index) else {
3656            return Err(ModelError::UnknownNetworkAttachmentIndex { index, len });
3657        };
3658        Ok(std::mem::replace(slot, value))
3659    }
3660
3661    /// Returns network attachments in authored order.
3662    #[must_use]
3663    pub fn networks(&self) -> &[Sourced<NetworkAttachment>] {
3664        &self.networks
3665    }
3666
3667    /// Appends a service dependency in source order.
3668    pub fn add_dependency(&mut self, value: Sourced<ServiceDependency>) {
3669        self.dependencies.push(value);
3670    }
3671
3672    /// Returns service dependencies in source order.
3673    #[must_use]
3674    pub fn dependencies(&self) -> &[Sourced<ServiceDependency>] {
3675        &self.dependencies
3676    }
3677
3678    /// Validates that a root filesystem was not combined with any image source.
3679    ///
3680    /// This is public so adapters that incrementally map services can surface an invalid native
3681    /// combination before inserting it into an [`Application`].
3682    ///
3683    /// # Errors
3684    ///
3685    /// Returns [`ModelError::RootfsImageSourceConflict`] when both forms are present.
3686    pub fn validate_image_source_exclusivity(&self) -> Result<(), ModelError> {
3687        if self.rootfs.is_some() {
3688            self.ensure_rootfs_is_compatible()?;
3689        }
3690        Ok(())
3691    }
3692
3693    fn ensure_rootfs_is_compatible(&self) -> Result<(), ModelError> {
3694        let source = if self.image.is_some() {
3695            Some("image")
3696        } else if self.image_acquisition.is_some() {
3697            Some("image acquisition")
3698        } else if self.image_build.is_some() {
3699            Some("image build")
3700        } else {
3701            None
3702        };
3703        if let Some(source) = source {
3704            return Err(ModelError::RootfsImageSourceConflict {
3705                service: self.name.as_str().to_owned(),
3706                source,
3707            });
3708        }
3709        Ok(())
3710    }
3711}
3712
3713/// One ordered multi-service application graph.
3714#[derive(Clone, Debug, Eq, PartialEq)]
3715pub struct Application {
3716    name: Identifier,
3717    retained_native_evidence: Vec<RetainedNativeEvidence>,
3718    vendor_metadata: Vec<crate::VendorMetadata>,
3719    image_acquisitions: Vec<Sourced<ImageAcquisition>>,
3720    image_builds: Vec<Sourced<ImageBuild>>,
3721    services: Vec<Sourced<Service>>,
3722    service_groups: Vec<Sourced<ServiceGroup>>,
3723    volumes: Vec<Sourced<Volume>>,
3724    networks: Vec<Sourced<Network>>,
3725    configs: Vec<Sourced<Config>>,
3726    secrets: Vec<Sourced<Secret>>,
3727}
3728
3729impl Application {
3730    /// Creates an empty application.
3731    #[must_use]
3732    pub const fn new(name: Identifier) -> Self {
3733        Self {
3734            name,
3735            retained_native_evidence: Vec::new(),
3736            vendor_metadata: Vec::new(),
3737            image_acquisitions: Vec::new(),
3738            image_builds: Vec::new(),
3739            services: Vec::new(),
3740            service_groups: Vec::new(),
3741            volumes: Vec::new(),
3742            networks: Vec::new(),
3743            configs: Vec::new(),
3744            secrets: Vec::new(),
3745        }
3746    }
3747
3748    /// Returns the application name.
3749    #[must_use]
3750    pub const fn name(&self) -> &Identifier {
3751        &self.name
3752    }
3753
3754    /// Returns ordered inert vendor extensions, including exact namespaces and ownership.
3755    #[must_use]
3756    pub fn vendor_metadata(&self) -> &[crate::VendorMetadata] {
3757        &self.vendor_metadata
3758    }
3759
3760    /// Adds bounded metadata after declaring its service owner.
3761    ///
3762    /// # Errors
3763    /// Rejects absent owners, duplicate exact scope/namespace identities and aggregate size excess.
3764    pub fn add_vendor_metadata(&mut self, metadata: crate::VendorMetadata) -> Result<(), crate::MetadataError> {
3765        if let crate::MetadataScope::Service(owner) = metadata.scope()
3766            && !self.services.iter().any(|service| service.value().name() == owner)
3767        {
3768            return Err(crate::MetadataError::UnknownOwner);
3769        }
3770        if self.vendor_metadata.iter().any(|existing| {
3771            existing.scope() == metadata.scope()
3772                && existing.namespace().value().expose() == metadata.namespace().value().expose()
3773        }) {
3774            return Err(crate::MetadataError::Duplicate);
3775        }
3776        let bytes = self.vendor_metadata.iter().try_fold(metadata.measure()?, |sum, item| {
3777            sum.checked_add(item.measure()?).ok_or(crate::MetadataError::Limit)
3778        })?;
3779        if bytes > crate::METADATA_MAX_BYTES {
3780            return Err(crate::MetadataError::Limit);
3781        }
3782        self.vendor_metadata.push(metadata);
3783        Ok(())
3784    }
3785
3786    /// Adds one opaque source event after its owning resource has been declared.
3787    ///
3788    /// # Errors
3789    ///
3790    /// Returns [`ModelError::UnknownNativeEvidenceOwner`] when the typed subject
3791    /// refers to a service or volume absent from this application.
3792    pub fn add_retained_native_evidence(&mut self, evidence: RetainedNativeEvidence) -> Result<(), ModelError> {
3793        let (kind, name) = evidence.subject.owner();
3794        let present = match kind {
3795            "service" => self.services.iter().any(|entry| entry.value().name() == name),
3796            "volume" => self.volumes.iter().any(|entry| entry.value().name() == name),
3797            _ => false,
3798        };
3799        if !present {
3800            return Err(ModelError::UnknownNativeEvidenceOwner {
3801                kind,
3802                name: name.as_str().to_owned(),
3803            });
3804        }
3805        self.retained_native_evidence.push(evidence);
3806        Ok(())
3807    }
3808
3809    /// Returns opaque source events in global authored order.
3810    #[must_use]
3811    pub fn retained_native_evidence(&self) -> &[RetainedNativeEvidence] {
3812        &self.retained_native_evidence
3813    }
3814
3815    /// Adds a uniquely named image-acquisition resource while preserving declaration order.
3816    ///
3817    /// # Errors
3818    ///
3819    /// Returns [`ModelError::DuplicateResource`] for a duplicate acquisition name.
3820    pub fn add_image_acquisition(&mut self, acquisition: Sourced<ImageAcquisition>) -> Result<(), ModelError> {
3821        ensure_unique(
3822            "image acquisition",
3823            acquisition.value().name(),
3824            self.image_acquisitions.iter().map(|candidate| candidate.value().name()),
3825        )?;
3826        self.image_acquisitions.push(acquisition);
3827        Ok(())
3828    }
3829
3830    /// Returns image-acquisition resources in declaration order.
3831    #[must_use]
3832    pub fn image_acquisitions(&self) -> &[Sourced<ImageAcquisition>] {
3833        &self.image_acquisitions
3834    }
3835
3836    /// Adds a uniquely named image-build resource while preserving declaration order.
3837    ///
3838    /// # Errors
3839    ///
3840    /// Returns [`ModelError::DuplicateResource`] for a duplicate build name.
3841    pub fn add_image_build(&mut self, build: Sourced<ImageBuild>) -> Result<(), ModelError> {
3842        ensure_unique(
3843            "image build",
3844            build.value().name(),
3845            self.image_builds.iter().map(|candidate| candidate.value().name()),
3846        )?;
3847        self.image_builds.push(build);
3848        Ok(())
3849    }
3850
3851    /// Returns image-build resources in declaration order.
3852    #[must_use]
3853    pub fn image_builds(&self) -> &[Sourced<ImageBuild>] {
3854        &self.image_builds
3855    }
3856
3857    /// Validates every typed image-artifact reference after the complete graph is assembled.
3858    ///
3859    /// Unlike incremental insertion, this validation does not make a source document's
3860    /// declaration order significant. Adapters that receive forward references should add their
3861    /// resources first and invoke this method before treating the application as convertible.
3862    ///
3863    /// # Errors
3864    ///
3865    /// Returns the matching unknown-reference error for a service or image-backed volume.
3866    pub fn validate_image_artifact_references(&self) -> Result<(), ModelError> {
3867        for service in &self.services {
3868            if let Some(acquisition) = service.value().image_acquisition()
3869                && !self.contains_image_acquisition(acquisition.value())
3870            {
3871                return Err(ModelError::UnknownImageAcquisitionReference {
3872                    service: service.value().name().as_str().to_owned(),
3873                    acquisition: acquisition.value().as_str().to_owned(),
3874                });
3875            }
3876            if let Some(build) = service.value().image_build()
3877                && !self.contains_image_build(build.value())
3878            {
3879                return Err(ModelError::UnknownImageBuildReference {
3880                    service: service.value().name().as_str().to_owned(),
3881                    build: build.value().as_str().to_owned(),
3882                });
3883            }
3884        }
3885        for volume in &self.volumes {
3886            let Some(source) = volume.value().image_source() else {
3887                continue;
3888            };
3889            match source.value() {
3890                VolumeImageSource::Literal(_) => {}
3891                VolumeImageSource::ImageAcquisition(acquisition) => {
3892                    if !self.contains_image_acquisition(acquisition) {
3893                        return Err(ModelError::UnknownVolumeImageAcquisitionReference {
3894                            volume: volume.value().name().as_str().to_owned(),
3895                            acquisition: acquisition.as_str().to_owned(),
3896                        });
3897                    }
3898                }
3899                VolumeImageSource::ImageBuild(build) => {
3900                    if !self.contains_image_build(build) {
3901                        return Err(ModelError::UnknownVolumeImageBuildReference {
3902                            volume: volume.value().name().as_str().to_owned(),
3903                            build: build.as_str().to_owned(),
3904                        });
3905                    }
3906                }
3907            }
3908        }
3909        Ok(())
3910    }
3911
3912    /// Validates explicit format-neutral artifact edges for missing nodes and cycles.
3913    ///
3914    /// The supplied edges must already be typed by a source adapter. In particular, `BoxFerry` does
3915    /// not parse native raw argument, mount, or unit-name text to infer dependencies. Duplicate
3916    /// edges are ignored deterministically, and input order does not affect validation.
3917    ///
3918    /// # Errors
3919    ///
3920    /// Returns a missing-reference error, [`ModelError::UnknownArtifactDependencyNode`], or
3921    /// [`ModelError::ImageArtifactDependencyCycle`].
3922    pub fn validate_image_artifact_dependencies(
3923        &self,
3924        dependencies: &[Sourced<ArtifactDependency>],
3925    ) -> Result<(), ModelError> {
3926        self.validate_image_artifact_references()?;
3927
3928        let mut graph = BTreeMap::<ArtifactDependencyNode, BTreeSet<ArtifactDependencyNode>>::new();
3929        for dependency in dependencies {
3930            let source = dependency.value().source().value();
3931            let target = dependency.value().target().value();
3932            self.validate_artifact_dependency_node(source)?;
3933            self.validate_artifact_dependency_node(target)?;
3934            graph.entry(source.clone()).or_default().insert(target.clone());
3935            graph.entry(target.clone()).or_default();
3936        }
3937
3938        let mut state = BTreeMap::<ArtifactDependencyNode, VisitState>::new();
3939        let mut path = Vec::new();
3940        for node in graph.keys() {
3941            if state.get(node).is_some_and(|state| *state == VisitState::Finished) {
3942                continue;
3943            }
3944            if let Some(cycle) = detect_artifact_cycle(node, &graph, &mut state, &mut path) {
3945                return Err(ModelError::ImageArtifactDependencyCycle {
3946                    nodes: cycle.into_iter().map(|node| node.display_name()).collect(),
3947                });
3948            }
3949        }
3950        Ok(())
3951    }
3952
3953    fn contains_image_acquisition(&self, name: &Identifier) -> bool {
3954        self.image_acquisitions
3955            .iter()
3956            .any(|candidate| candidate.value().name() == name)
3957    }
3958
3959    fn contains_image_build(&self, name: &Identifier) -> bool {
3960        self.image_builds
3961            .iter()
3962            .any(|candidate| candidate.value().name() == name)
3963    }
3964
3965    fn validate_artifact_dependency_node(&self, node: &ArtifactDependencyNode) -> Result<(), ModelError> {
3966        let (kind, name) = node.kind_and_name();
3967        let exists = match node {
3968            ArtifactDependencyNode::Volume(_) => self.volumes.iter().any(|volume| volume.value().name() == name),
3969            ArtifactDependencyNode::ImageAcquisition(_) => self.contains_image_acquisition(name),
3970            ArtifactDependencyNode::ImageBuild(_) => self.contains_image_build(name),
3971        };
3972        if exists {
3973            Ok(())
3974        } else {
3975            Err(ModelError::UnknownArtifactDependencyNode {
3976                kind,
3977                name: name.as_str().to_owned(),
3978            })
3979        }
3980    }
3981
3982    /// Adds a uniquely named service while preserving declaration order.
3983    ///
3984    /// # Errors
3985    ///
3986    /// Returns [`ModelError::DuplicateResource`] for a duplicate service name,
3987    /// [`ModelError::UnknownImageAcquisitionReference`], or
3988    /// [`ModelError::UnknownImageBuildReference`] for an unresolved artifact reference.
3989    pub fn add_service(&mut self, service: Sourced<Service>) -> Result<(), ModelError> {
3990        ensure_unique(
3991            "service",
3992            service.value().name(),
3993            self.services.iter().map(|candidate| candidate.value().name()),
3994        )?;
3995        service.value().validate_image_source_exclusivity()?;
3996        crate::deferred::validate_service(service.value())?;
3997        if let Some(acquisition) = service.value().image_acquisition()
3998            && !self
3999                .image_acquisitions
4000                .iter()
4001                .any(|candidate| candidate.value().name() == acquisition.value())
4002        {
4003            return Err(ModelError::UnknownImageAcquisitionReference {
4004                service: service.value().name().as_str().to_owned(),
4005                acquisition: acquisition.value().as_str().to_owned(),
4006            });
4007        }
4008        if let Some(build) = service.value().image_build()
4009            && !self
4010                .image_builds
4011                .iter()
4012                .any(|candidate| candidate.value().name() == build.value())
4013        {
4014            return Err(ModelError::UnknownImageBuildReference {
4015                service: service.value().name().as_str().to_owned(),
4016                build: build.value().as_str().to_owned(),
4017            });
4018        }
4019        self.services.push(service);
4020        Ok(())
4021    }
4022
4023    /// Returns services in declaration order.
4024    #[must_use]
4025    pub fn services(&self) -> &[Sourced<Service>] {
4026        &self.services
4027    }
4028
4029    /// Withholds literal and opaque-source service environment values, retaining names and origins.
4030    ///
4031    /// This is an artifact authorization boundary, independent of conversion fidelity. An
4032    /// exporter must report each required value as an unsupported target prerequisite.
4033    pub fn withhold_literal_environment_values(&mut self) {
4034        for service in &mut self.services {
4035            for environment in &mut service.value_mut().environment {
4036                if matches!(
4037                    environment.value().value(),
4038                    EnvironmentValue::Literal(_) | EnvironmentValue::UnresolvedSource { .. }
4039                ) {
4040                    environment.value_mut().value = EnvironmentValue::Required;
4041                }
4042            }
4043        }
4044    }
4045
4046    /// Adds a uniquely named structural service group.
4047    ///
4048    /// Every referenced service must already exist in the application, and one service may belong
4049    /// to at most one group.
4050    ///
4051    /// # Errors
4052    ///
4053    /// Returns [`ModelError::DuplicateResource`], [`ModelError::UnknownServiceGroupMember`], or
4054    /// [`ModelError::ServiceInMultipleGroups`] when a relationship is ambiguous.
4055    pub fn add_service_group(&mut self, group: Sourced<ServiceGroup>) -> Result<(), ModelError> {
4056        ensure_unique(
4057            "service group",
4058            group.value().name(),
4059            self.service_groups.iter().map(|candidate| candidate.value().name()),
4060        )?;
4061        for member in group.value().members() {
4062            if !self
4063                .services
4064                .iter()
4065                .any(|service| service.value().name() == member.value())
4066            {
4067                return Err(ModelError::UnknownServiceGroupMember {
4068                    group: group.value().name().as_str().to_owned(),
4069                    service: member.value().as_str().to_owned(),
4070                });
4071            }
4072            if let Some(existing) = self.service_groups.iter().find(|candidate| {
4073                candidate
4074                    .value()
4075                    .members()
4076                    .iter()
4077                    .any(|candidate_member| candidate_member.value() == member.value())
4078            }) {
4079                return Err(ModelError::ServiceInMultipleGroups {
4080                    service: member.value().as_str().to_owned(),
4081                    existing: existing.value().name().as_str().to_owned(),
4082                    replacement: group.value().name().as_str().to_owned(),
4083                });
4084            }
4085        }
4086        self.service_groups.push(group);
4087        Ok(())
4088    }
4089
4090    /// Returns structural service groups in source order.
4091    #[must_use]
4092    pub fn service_groups(&self) -> &[Sourced<ServiceGroup>] {
4093        &self.service_groups
4094    }
4095
4096    /// Adds a uniquely named volume while preserving declaration order.
4097    ///
4098    /// # Errors
4099    ///
4100    /// Returns [`ModelError::DuplicateResource`] for a duplicate volume name.
4101    pub fn add_volume(&mut self, volume: Sourced<Volume>) -> Result<(), ModelError> {
4102        ensure_unique(
4103            "volume",
4104            volume.value().name(),
4105            self.volumes.iter().map(|candidate| candidate.value().name()),
4106        )?;
4107        self.volumes.push(volume);
4108        Ok(())
4109    }
4110
4111    /// Returns volumes in declaration order.
4112    #[must_use]
4113    pub fn volumes(&self) -> &[Sourced<Volume>] {
4114        &self.volumes
4115    }
4116
4117    /// Adds a uniquely named network while preserving declaration order.
4118    ///
4119    /// # Errors
4120    ///
4121    /// Returns [`ModelError::DuplicateResource`] for a duplicate network name.
4122    pub fn add_network(&mut self, network: Sourced<Network>) -> Result<(), ModelError> {
4123        ensure_unique(
4124            "network",
4125            network.value().name(),
4126            self.networks.iter().map(|candidate| candidate.value().name()),
4127        )?;
4128        self.networks.push(network);
4129        Ok(())
4130    }
4131
4132    /// Returns networks in declaration order.
4133    #[must_use]
4134    pub fn networks(&self) -> &[Sourced<Network>] {
4135        &self.networks
4136    }
4137
4138    /// Adds a uniquely named configuration while preserving declaration order.
4139    ///
4140    /// # Errors
4141    ///
4142    /// Returns [`ModelError::DuplicateResource`] for a duplicate configuration name.
4143    pub fn add_config(&mut self, config: Sourced<Config>) -> Result<(), ModelError> {
4144        ensure_unique(
4145            "config",
4146            config.value().name(),
4147            self.configs.iter().map(|candidate| candidate.value().name()),
4148        )?;
4149        self.configs.push(config);
4150        Ok(())
4151    }
4152
4153    /// Returns configuration resources in declaration order.
4154    #[must_use]
4155    pub fn configs(&self) -> &[Sourced<Config>] {
4156        &self.configs
4157    }
4158
4159    /// Adds a uniquely named secret while preserving declaration order.
4160    ///
4161    /// # Errors
4162    ///
4163    /// Returns [`ModelError::DuplicateResource`] for a duplicate secret name.
4164    pub fn add_secret(&mut self, secret: Sourced<Secret>) -> Result<(), ModelError> {
4165        ensure_unique(
4166            "secret",
4167            secret.value().name(),
4168            self.secrets.iter().map(|candidate| candidate.value().name()),
4169        )?;
4170        self.secrets.push(secret);
4171        Ok(())
4172    }
4173
4174    /// Returns secret resources in declaration order.
4175    #[must_use]
4176    pub fn secrets(&self) -> &[Sourced<Secret>] {
4177        &self.secrets
4178    }
4179}
4180
4181#[derive(Clone, Copy, Eq, PartialEq)]
4182enum VisitState {
4183    Visiting,
4184    Finished,
4185}
4186
4187fn detect_artifact_cycle(
4188    node: &ArtifactDependencyNode,
4189    graph: &BTreeMap<ArtifactDependencyNode, BTreeSet<ArtifactDependencyNode>>,
4190    state: &mut BTreeMap<ArtifactDependencyNode, VisitState>,
4191    path: &mut Vec<ArtifactDependencyNode>,
4192) -> Option<Vec<ArtifactDependencyNode>> {
4193    if state.get(node).is_some_and(|state| *state == VisitState::Visiting) {
4194        let index = path.iter().position(|candidate| candidate == node)?;
4195        let mut cycle = path[index..].to_vec();
4196        cycle.push(node.clone());
4197        return Some(cycle);
4198    }
4199    if state.get(node).is_some_and(|state| *state == VisitState::Finished) {
4200        return None;
4201    }
4202
4203    state.insert(node.clone(), VisitState::Visiting);
4204    path.push(node.clone());
4205    if let Some(targets) = graph.get(node) {
4206        for target in targets {
4207            if let Some(cycle) = detect_artifact_cycle(target, graph, state, path) {
4208                return Some(cycle);
4209            }
4210        }
4211    }
4212    path.pop();
4213    state.insert(node.clone(), VisitState::Finished);
4214    None
4215}
4216
4217fn ensure_unique<'a>(
4218    kind: &'static str,
4219    name: &Identifier,
4220    existing: impl Iterator<Item = &'a Identifier>,
4221) -> Result<(), ModelError> {
4222    if existing.into_iter().any(|candidate| candidate == name) {
4223        return Err(ModelError::DuplicateResource {
4224            kind,
4225            name: name.as_str().to_owned(),
4226        });
4227    }
4228    Ok(())
4229}
4230
4231fn validate_text(kind: &'static str, value: &str) -> Result<(), ModelError> {
4232    if value.is_empty() {
4233        return Err(ModelError::EmptyValue(kind));
4234    }
4235    validate_no_nul(kind, value)
4236}
4237
4238fn validate_no_nul(kind: &'static str, value: &str) -> Result<(), ModelError> {
4239    if value.contains('\0') {
4240        return Err(ModelError::ContainsNul(kind));
4241    }
4242    Ok(())
4243}
4244
4245#[cfg(test)]
4246mod tests {
4247    use super::{
4248        Annotation, Application, ArtifactDependency, ArtifactDependencyNode, Command, Config, ConfigMaterial, Device,
4249        Entrypoint, EnvironmentFile, EnvironmentFileFormat, EnvironmentFileSyntax, EnvironmentValue,
4250        EnvironmentVariable, ExposedPort, GroupExitPolicy, HealthcheckDuration, HealthcheckRetries, HostAddress,
4251        HostAddressKind, HostMapping, Identifier, KernelParameter, Logging, LoggingOption, MetadataLabel, ModelError,
4252        Mount, MountSource, Network, NetworkAttachment, NetworkDriverOption, NetworkIpamConfig, Protocol, PullPolicy,
4253        ReloadAction, ResourceGrant, ResourceGrantSyntax, ResourceLimit, ResourceOwnership, RestartPolicy, Secret,
4254        SecretMaterial, SecurityOption, Service, ServiceDependency, ServiceDependencyCondition, ServiceGroup,
4255        ServiceGroupRuntime, StartupNotification, StopTimeout, Volume, VolumeImageSource,
4256    };
4257    use crate::{ImageAcquisition, ImageBuild, ImageReference, ProtectedString, Sourced};
4258
4259    #[test]
4260    fn withholding_environment_values_keeps_names_and_provenance_without_value_bytes() -> Result<(), String> {
4261        let mut application = Application::new(id("application")?);
4262        let mut service = Service::new(id("web")?);
4263        let origin =
4264            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
4265        service.add_environment(Sourced::from_source(
4266            EnvironmentVariable::new(
4267                id("MODE")?,
4268                EnvironmentValue::Literal(ProtectedString::sensitive("benign-name-private-canary")),
4269            ),
4270            origin.clone(),
4271        ));
4272        service.add_environment(Sourced::generated(EnvironmentVariable::new(
4273            id("FROM_HOST")?,
4274            EnvironmentValue::Host,
4275        )));
4276        application
4277            .add_service(Sourced::generated(service))
4278            .map_err(|error| error.to_string())?;
4279
4280        application.withhold_literal_environment_values();
4281        let environment = application.services()[0].value().environment();
4282        assert_eq!(environment[0].value().name().as_str(), "MODE");
4283        assert_eq!(environment[0].origins(), &[origin]);
4284        assert!(matches!(environment[0].value().value(), EnvironmentValue::Required));
4285        assert!(matches!(environment[1].value().value(), EnvironmentValue::Host));
4286        assert!(!format!("{application:?}").contains("benign-name-private-canary"));
4287        Ok(())
4288    }
4289
4290    #[test]
4291    fn preserves_service_order_and_rejects_duplicate_names() -> Result<(), String> {
4292        let mut application = Application::new(id("example")?);
4293        application
4294            .add_service(Sourced::generated(Service::new(id("web")?)))
4295            .map_err(|error| error.to_string())?;
4296        application
4297            .add_service(Sourced::generated(Service::new(id("database")?)))
4298            .map_err(|error| error.to_string())?;
4299
4300        let names: Vec<_> = application
4301            .services()
4302            .iter()
4303            .map(|service| service.value().name().as_str())
4304            .collect();
4305        assert_eq!(names, ["web", "database"]);
4306
4307        let duplicate = application.add_service(Sourced::generated(Service::new(id("web")?)));
4308        assert!(matches!(duplicate, Err(ModelError::DuplicateResource { .. })));
4309        Ok(())
4310    }
4311
4312    #[test]
4313    fn keeps_the_service_key_and_explicit_runtime_name_distinct() -> Result<(), String> {
4314        let mut service = Service::new(id("web")?);
4315        service.set_runtime_name(Sourced::generated(ProtectedString::plain("production-web")));
4316
4317        assert_eq!(service.name().as_str(), "web");
4318        assert_eq!(
4319            service.runtime_name().map(|name| name.value().expose()),
4320            Some("production-web")
4321        );
4322        Ok(())
4323    }
4324
4325    #[test]
4326    fn network_keeps_logical_and_runtime_names_and_literal_flags_distinct() -> Result<(), String> {
4327        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4328        let origin = crate::Provenance::source(source);
4329        let mut network = Network::new(id("frontend")?, ResourceOwnership::Application);
4330        network.set_runtime_name(Sourced::from_source(
4331            ProtectedString::plain("production-frontend"),
4332            origin.clone(),
4333        ));
4334        network.set_driver(Sourced::from_source(ProtectedString::plain("bridge"), origin.clone()));
4335        network.set_internal(Sourced::from_source(true, origin.clone()));
4336        network.set_ipv6(Sourced::from_source(false, origin.clone()));
4337        network.set_ipam_driver(Sourced::from_source(ProtectedString::plain("default"), origin));
4338
4339        assert_eq!(network.name().as_str(), "frontend");
4340        assert_eq!(
4341            network.runtime_name().map(|value| value.value().expose()),
4342            Some("production-frontend")
4343        );
4344        assert_eq!(network.driver().map(|value| value.value().expose()), Some("bridge"));
4345        assert_eq!(network.internal().map(Sourced::value), Some(&true));
4346        assert_eq!(network.ipv6().map(Sourced::value), Some(&false));
4347        assert_eq!(
4348            network.ipam_driver().map(|value| value.value().expose()),
4349            Some("default")
4350        );
4351        Ok(())
4352    }
4353
4354    #[test]
4355    fn network_collections_retain_resets_provenance_and_redact_protected_values() -> Result<(), String> {
4356        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4357        let origin = crate::Provenance::source(source);
4358        let mut network = Network::new(id("frontend")?, ResourceOwnership::Application);
4359        let option = NetworkDriverOption::new(
4360            Sourced::from_source(id("com.example.token")?, origin.clone()),
4361            Sourced::from_source(ProtectedString::sensitive("never-print-this"), origin.clone()),
4362        )
4363        .map_err(|error| error.to_string())?;
4364        let label = MetadataLabel::new(id("com.example.label")?, ProtectedString::sensitive("also-private"));
4365
4366        network
4367            .set_driver_options_with_origins(vec![Sourced::from_source(option, origin.clone())], vec![origin.clone()]);
4368        network.set_labels_with_origins(vec![Sourced::from_source(label, origin.clone())], vec![origin.clone()]);
4369        network.set_ipam_configs_with_origins(Vec::new(), vec![origin]);
4370
4371        assert_eq!(network.driver_options().map(<[_]>::len), Some(1));
4372        assert_eq!(network.labels().map(<[_]>::len), Some(1));
4373        assert_eq!(network.ipam_configs().map(<[_]>::len), Some(0));
4374        assert_eq!(network.driver_options_origins().len(), 1);
4375        assert_eq!(network.labels_origins().len(), 1);
4376        assert_eq!(network.ipam_configs_origins().len(), 1);
4377        let debug = format!("{network:?}");
4378        assert!(!debug.contains("never-print-this"));
4379        assert!(!debug.contains("also-private"));
4380        assert!(debug.contains("[REDACTED]"));
4381
4382        network.set_driver_options(Vec::new());
4383        network.set_labels(Vec::new());
4384        network.set_ipam_configs(Vec::new());
4385        assert_eq!(network.driver_options().map(<[_]>::len), Some(0));
4386        assert_eq!(network.labels().map(<[_]>::len), Some(0));
4387        assert_eq!(network.ipam_configs().map(<[_]>::len), Some(0));
4388        assert!(network.driver_options_origins().is_empty());
4389        assert!(network.labels_origins().is_empty());
4390        assert!(network.ipam_configs_origins().is_empty());
4391        Ok(())
4392    }
4393
4394    #[test]
4395    fn network_ipam_rows_preserve_association_order_and_reject_subnetless_values() -> Result<(), String> {
4396        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4397        let origin = crate::Provenance::source(source);
4398        let mut first = NetworkIpamConfig::new(Sourced::from_source(
4399            ProtectedString::plain("10.10.0.0/24"),
4400            origin.clone(),
4401        ))
4402        .map_err(|error| error.to_string())?;
4403        first
4404            .set_gateway(Sourced::from_source(
4405                ProtectedString::plain("10.10.0.1"),
4406                origin.clone(),
4407            ))
4408            .map_err(|error| error.to_string())?;
4409        let mut second = NetworkIpamConfig::new(Sourced::from_source(
4410            ProtectedString::plain("fd00:10::/64"),
4411            origin.clone(),
4412        ))
4413        .map_err(|error| error.to_string())?;
4414        second
4415            .set_ip_range(Sourced::from_source(
4416                ProtectedString::plain("fd00:10::100/120"),
4417                origin.clone(),
4418            ))
4419            .map_err(|error| error.to_string())?;
4420
4421        let mut network = Network::new(id("frontend")?, ResourceOwnership::Application);
4422        network.set_ipam_configs_with_origins(
4423            vec![
4424                Sourced::from_source(first, origin.clone()),
4425                Sourced::from_source(second, origin),
4426            ],
4427            Vec::new(),
4428        );
4429        let rows = network
4430            .ipam_configs()
4431            .ok_or_else(|| "IPAM configs were omitted".to_owned())?;
4432        assert_eq!(rows.len(), 2);
4433        assert_eq!(rows[0].value().subnet().value().expose(), "10.10.0.0/24");
4434        assert_eq!(
4435            rows[0].value().gateway().map(|value| value.value().expose()),
4436            Some("10.10.0.1")
4437        );
4438        assert_eq!(rows[0].value().ip_range(), None);
4439        assert_eq!(rows[1].value().subnet().value().expose(), "fd00:10::/64");
4440        assert_eq!(rows[1].value().gateway(), None);
4441        assert_eq!(
4442            rows[1].value().ip_range().map(|value| value.value().expose()),
4443            Some("fd00:10::100/120")
4444        );
4445
4446        assert!(matches!(
4447            NetworkIpamConfig::new(Sourced::generated(ProtectedString::plain(""))),
4448            Err(ModelError::EmptyValue("network IPAM subnet"))
4449        ));
4450        assert!(matches!(
4451            NetworkIpamConfig::new(Sourced::generated(ProtectedString::plain("10.0.0.0/24\0bad"))),
4452            Err(ModelError::ContainsNul("network IPAM subnet"))
4453        ));
4454        assert!(matches!(
4455            NetworkDriverOption::new(
4456                Sourced::generated(id("option")?),
4457                Sourced::generated(ProtectedString::plain("bad\0value")),
4458            ),
4459            Err(ModelError::ContainsNul("network driver option value"))
4460        ));
4461        Ok(())
4462    }
4463
4464    #[test]
4465    fn image_artifact_resources_are_ordered_unique_and_referenced_explicitly() -> Result<(), String> {
4466        let mut application = Application::new(id("example")?);
4467        application
4468            .add_image_acquisition(Sourced::generated(ImageAcquisition::new(id("base-image")?)))
4469            .map_err(|error| error.to_string())?;
4470        application
4471            .add_image_build(Sourced::generated(ImageBuild::new(id("web-build")?)))
4472            .map_err(|error| error.to_string())?;
4473
4474        let mut web = Service::new(id("web")?);
4475        web.set_image_acquisition(Sourced::generated(id("base-image")?));
4476        web.set_image_build(Sourced::generated(id("web-build")?));
4477        application
4478            .add_service(Sourced::generated(web))
4479            .map_err(|error| error.to_string())?;
4480
4481        assert_eq!(
4482            application.image_acquisitions()[0].value().name().as_str(),
4483            "base-image"
4484        );
4485        assert_eq!(application.image_builds()[0].value().name().as_str(), "web-build");
4486        assert!(matches!(
4487            application.add_image_build(Sourced::generated(ImageBuild::new(id("web-build")?))),
4488            Err(ModelError::DuplicateResource {
4489                kind: "image build",
4490                ..
4491            })
4492        ));
4493
4494        let mut missing = Service::new(id("missing")?);
4495        missing.set_image_build(Sourced::generated(id("absent-build")?));
4496        assert!(matches!(
4497            application.add_service(Sourced::generated(missing)),
4498            Err(ModelError::UnknownImageBuildReference { .. })
4499        ));
4500        Ok(())
4501    }
4502
4503    #[test]
4504    fn volume_keeps_logical_runtime_and_service_names_and_local_fields_distinct() -> Result<(), String> {
4505        let origin = crate::Provenance::source(crate::SourceId::new("data.volume").map_err(|error| error.to_string())?);
4506        let mut volume = Volume::new(id("data")?, ResourceOwnership::Application);
4507        volume.set_runtime_name(Sourced::from_source(
4508            ProtectedString::plain("production-data"),
4509            origin.clone(),
4510        ));
4511        volume.set_service_name(Sourced::from_source(
4512            ProtectedString::plain("data-volume.service"),
4513            origin.clone(),
4514        ));
4515        volume.set_driver(Sourced::from_source(ProtectedString::plain("local"), origin.clone()));
4516        volume.set_device(Sourced::from_source(
4517            ProtectedString::plain("/srv/data"),
4518            origin.clone(),
4519        ));
4520        volume.set_volume_type(Sourced::from_source(ProtectedString::plain("none"), origin.clone()));
4521        volume.set_options(Sourced::from_source(ProtectedString::plain("bind"), origin.clone()));
4522
4523        assert_eq!(volume.name().as_str(), "data");
4524        assert_eq!(
4525            volume.runtime_name().map(|name| name.value().expose()),
4526            Some("production-data")
4527        );
4528        assert_eq!(
4529            volume.service_name().map(|name| name.value().expose()),
4530            Some("data-volume.service")
4531        );
4532        assert_eq!(volume.driver().map(|value| value.value().expose()), Some("local"));
4533        assert_eq!(volume.device().map(|value| value.value().expose()), Some("/srv/data"));
4534        assert_eq!(volume.volume_type().map(|value| value.value().expose()), Some("none"));
4535        assert_eq!(volume.options().map(|value| value.value().expose()), Some("bind"));
4536        assert_eq!(
4537            volume.options().map(Sourced::origins),
4538            Some(std::slice::from_ref(&origin))
4539        );
4540        Ok(())
4541    }
4542
4543    #[test]
4544    fn volume_preserves_resets_order_protected_values_and_identity_dimensions() -> Result<(), String> {
4545        let origin = crate::Provenance::source(crate::SourceId::new("data.volume").map_err(|error| error.to_string())?);
4546        let mut volume = Volume::new(id("data")?, ResourceOwnership::Application);
4547        assert!(volume.labels().is_none());
4548        volume.set_labels_with_origins(Vec::new(), vec![origin.clone()]);
4549        volume.set_user(Sourced::from_source(
4550            ProtectedString::plain("named-user"),
4551            origin.clone(),
4552        ));
4553        volume.set_group(Sourced::from_source(
4554            ProtectedString::plain("named-group"),
4555            origin.clone(),
4556        ));
4557        volume.set_uid(Sourced::from_source(ProtectedString::plain("1001"), origin.clone()));
4558        volume.set_gid(Sourced::from_source(ProtectedString::plain("1002"), origin));
4559
4560        assert_eq!(volume.labels().map(<[_]>::len), Some(0));
4561        assert_eq!(volume.user().map(|value| value.value().expose()), Some("named-user"));
4562        assert_eq!(volume.group().map(|value| value.value().expose()), Some("named-group"));
4563        assert_eq!(volume.uid().map(|value| value.value().expose()), Some("1001"));
4564        assert_eq!(volume.gid().map(|value| value.value().expose()), Some("1002"));
4565        Ok(())
4566    }
4567
4568    #[test]
4569    fn volume_copy_and_image_sources_preserve_absence_and_typed_distinctions() -> Result<(), String> {
4570        let origin =
4571            crate::Provenance::source(crate::SourceId::new("cache.volume").map_err(|error| error.to_string())?);
4572        let mut volume = Volume::new(id("cache")?, ResourceOwnership::Application);
4573        assert_eq!(volume.copy(), None);
4574        volume.set_copy(Sourced::from_source(false, origin.clone()));
4575        assert_eq!(volume.copy().map(Sourced::value), Some(&false));
4576        volume.set_copy(Sourced::from_source(true, origin.clone()));
4577        assert_eq!(volume.copy().map(Sourced::value), Some(&true));
4578
4579        volume
4580            .set_image_source(Sourced::from_source(
4581                VolumeImageSource::Literal(ProtectedString::sensitive("registry.example/private:1")),
4582                origin.clone(),
4583            ))
4584            .map_err(|error| error.to_string())?;
4585        assert!(matches!(
4586            volume.image_source().map(Sourced::value),
4587            Some(VolumeImageSource::Literal(_))
4588        ));
4589        assert!(!format!("{volume:?}").contains("registry.example/private:1"));
4590
4591        volume
4592            .set_image_source(Sourced::from_source(
4593                VolumeImageSource::ImageAcquisition(id("cache-image")?),
4594                origin.clone(),
4595            ))
4596            .map_err(|error| error.to_string())?;
4597        assert!(matches!(
4598            volume.image_source().map(Sourced::value),
4599            Some(VolumeImageSource::ImageAcquisition(name)) if name.as_str() == "cache-image"
4600        ));
4601        volume
4602            .set_image_source(Sourced::from_source(
4603                VolumeImageSource::ImageBuild(id("cache-build")?),
4604                origin,
4605            ))
4606            .map_err(|error| error.to_string())?;
4607        assert!(matches!(
4608            volume.image_source().map(Sourced::value),
4609            Some(VolumeImageSource::ImageBuild(name)) if name.as_str() == "cache-build"
4610        ));
4611        Ok(())
4612    }
4613
4614    #[test]
4615    fn volume_artifact_validation_is_deferred_and_explicit_edges_find_cycles() -> Result<(), String> {
4616        let mut application = Application::new(id("example")?);
4617        let mut volume = Volume::new(id("cache")?, ResourceOwnership::Application);
4618        volume
4619            .set_image_source(Sourced::generated(VolumeImageSource::ImageBuild(id("cache-build")?)))
4620            .map_err(|error| error.to_string())?;
4621        application
4622            .add_volume(Sourced::generated(volume))
4623            .map_err(|error| error.to_string())?;
4624        assert!(matches!(
4625            application.validate_image_artifact_references(),
4626            Err(ModelError::UnknownVolumeImageBuildReference { .. })
4627        ));
4628
4629        application
4630            .add_image_build(Sourced::generated(ImageBuild::new(id("cache-build")?)))
4631            .map_err(|error| error.to_string())?;
4632        application
4633            .validate_image_artifact_references()
4634            .map_err(|error| error.to_string())?;
4635
4636        let volume_node = ArtifactDependencyNode::Volume(id("cache")?);
4637        let build_node = ArtifactDependencyNode::ImageBuild(id("cache-build")?);
4638        let dependencies = vec![
4639            Sourced::generated(ArtifactDependency::new(
4640                Sourced::generated(volume_node.clone()),
4641                Sourced::generated(build_node.clone()),
4642            )),
4643            Sourced::generated(ArtifactDependency::new(
4644                Sourced::generated(build_node),
4645                Sourced::generated(volume_node),
4646            )),
4647        ];
4648        assert!(matches!(
4649            application.validate_image_artifact_dependencies(&dependencies),
4650            Err(ModelError::ImageArtifactDependencyCycle { .. })
4651        ));
4652        let missing = vec![Sourced::generated(ArtifactDependency::new(
4653            Sourced::generated(ArtifactDependencyNode::ImageBuild(id("cache-build")?)),
4654            Sourced::generated(ArtifactDependencyNode::Volume(id("missing")?)),
4655        ))];
4656        assert!(matches!(
4657            application.validate_image_artifact_dependencies(&missing),
4658            Err(ModelError::UnknownArtifactDependencyNode { kind: "volume", .. })
4659        ));
4660        Ok(())
4661    }
4662
4663    #[test]
4664    fn volume_rejects_invalid_literal_image_values() -> Result<(), String> {
4665        let mut volume = Volume::new(id("data")?, ResourceOwnership::Application);
4666        assert!(matches!(
4667            volume.set_image_source(Sourced::generated(VolumeImageSource::Literal(ProtectedString::plain(
4668                ""
4669            )))),
4670            Err(ModelError::EmptyValue("volume image"))
4671        ));
4672        assert!(matches!(
4673            volume.set_image_source(Sourced::generated(VolumeImageSource::Literal(ProtectedString::plain(
4674                "bad\0image"
4675            )))),
4676            Err(ModelError::ContainsNul("volume image"))
4677        ));
4678        Ok(())
4679    }
4680
4681    #[test]
4682    fn collection_resets_retain_explicit_emptiness_and_clear_stale_origins() -> Result<(), String> {
4683        let origin =
4684            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
4685        let mut service = Service::new(id("web")?);
4686
4687        service.set_cap_add_with_origins(Vec::new(), vec![origin.clone()]);
4688        service.set_cap_drop_with_origins(Vec::new(), vec![origin.clone()]);
4689        service.set_tmpfs_with_origins(Vec::new(), vec![origin.clone()]);
4690        service.set_sysctls_with_origins(Vec::new(), vec![origin.clone()]);
4691        service.set_ulimits_with_origins(Vec::new(), vec![origin.clone()]);
4692        service.set_devices_with_origins(Vec::new(), vec![origin]);
4693
4694        assert_eq!(service.cap_add().map(<[_]>::len), Some(0));
4695        assert_eq!(service.cap_drop().map(<[_]>::len), Some(0));
4696        assert_eq!(service.tmpfs().map(<[_]>::len), Some(0));
4697        assert_eq!(service.sysctls().map(<[_]>::len), Some(0));
4698        assert_eq!(service.ulimits().map(<[_]>::len), Some(0));
4699        assert_eq!(service.devices().map(<[_]>::len), Some(0));
4700        assert_eq!(service.cap_add_origins().len(), 1);
4701        assert_eq!(service.cap_drop_origins().len(), 1);
4702        assert_eq!(service.tmpfs_origins().len(), 1);
4703        assert_eq!(service.sysctls_origins().len(), 1);
4704        assert_eq!(service.ulimits_origins().len(), 1);
4705        assert_eq!(service.devices_origins().len(), 1);
4706
4707        service.set_cap_add(Vec::new());
4708        service.set_cap_drop(Vec::new());
4709        service.set_tmpfs(Vec::new());
4710        service.set_sysctls(Vec::<Sourced<KernelParameter>>::new());
4711        service.set_ulimits(Vec::<Sourced<ResourceLimit>>::new());
4712        service.set_devices(Vec::<Sourced<Device>>::new());
4713
4714        assert!(service.cap_add_origins().is_empty());
4715        assert!(service.cap_drop_origins().is_empty());
4716        assert!(service.tmpfs_origins().is_empty());
4717        assert!(service.sysctls_origins().is_empty());
4718        assert!(service.ulimits_origins().is_empty());
4719        assert!(service.devices_origins().is_empty());
4720        Ok(())
4721    }
4722
4723    #[test]
4724    fn restart_policy_keeps_unlimited_and_finite_on_failure_distinct() {
4725        let finite = std::num::NonZeroU64::new(4);
4726        assert_eq!(RestartPolicy::on_failure(None).maximum_retries(), None);
4727        assert_eq!(RestartPolicy::on_failure(finite).maximum_retries(), finite);
4728        assert_eq!(RestartPolicy::Always.maximum_retries(), None);
4729    }
4730
4731    #[test]
4732    fn metadata_labels_preserve_empty_and_protected_values() -> Result<(), String> {
4733        let empty = MetadataLabel::new(id("com.example.empty")?, ProtectedString::plain(""));
4734        let protected = MetadataLabel::new(id("com.example.token")?, ProtectedString::sensitive("never-print-this"));
4735        let mut service = Service::new(id("web")?);
4736        service.add_label(Sourced::generated(empty));
4737        service.add_label(Sourced::generated(protected));
4738
4739        assert_eq!(service.labels()[0].value().value().expose(), "");
4740        let debug = format!("{:?}", service.labels()[1]);
4741        assert!(!debug.contains("never-print-this"));
4742        assert!(debug.contains("[REDACTED]"));
4743        Ok(())
4744    }
4745
4746    #[test]
4747    fn environment_files_preserve_order_options_provenance_and_redaction() -> Result<(), String> {
4748        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4749        let origin = crate::Provenance::source(source);
4750        let mut service = Service::new(id("web")?);
4751        service.add_environment_file(Sourced::from_source(
4752            EnvironmentFile::new(ProtectedString::plain("./base.env"), EnvironmentFileSyntax::Short)
4753                .map_err(|error| error.to_string())?,
4754            origin.clone(),
4755        ));
4756        let mut local = EnvironmentFile::new(ProtectedString::sensitive("./private.env"), EnvironmentFileSyntax::Long)
4757            .map_err(|error| error.to_string())?;
4758        local.set_required(Sourced::from_source(false, origin.clone()));
4759        local.set_format(Sourced::from_source(EnvironmentFileFormat::Raw, origin.clone()));
4760        service.add_environment_file(Sourced::from_source(local, origin));
4761
4762        assert_eq!(service.environment_files().len(), 2);
4763        assert_eq!(service.environment_files()[0].value().path().expose(), "./base.env");
4764        assert_eq!(
4765            service.environment_files()[0].value().syntax(),
4766            EnvironmentFileSyntax::Short
4767        );
4768        assert!(service.environment_files()[0].value().is_required());
4769        let local = service.environment_files()[1].value();
4770        assert_eq!(local.syntax(), EnvironmentFileSyntax::Long);
4771        assert!(!local.is_required());
4772        assert_eq!(local.required().map_or(0, |value| value.origins().len()), 1);
4773        assert!(matches!(
4774            local.format().map(Sourced::value),
4775            Some(EnvironmentFileFormat::Raw)
4776        ));
4777        let debug = format!("{service:?}");
4778        assert!(!debug.contains("private.env"));
4779        assert!(debug.contains("[REDACTED]"));
4780        assert!(matches!(
4781            EnvironmentFile::new(ProtectedString::plain(""), EnvironmentFileSyntax::Short),
4782            Err(ModelError::EmptyValue("environment-file path"))
4783        ));
4784        Ok(())
4785    }
4786
4787    #[test]
4788    fn service_groups_preserve_order_and_reject_ambiguous_membership() -> Result<(), String> {
4789        let mut application = Application::new(id("example")?);
4790        for name in ["web", "worker"] {
4791            application
4792                .add_service(Sourced::generated(Service::new(id(name)?)))
4793                .map_err(|error| error.to_string())?;
4794        }
4795
4796        let mut frontend = ServiceGroup::new(id("frontend")?, ResourceOwnership::Uncertain);
4797        frontend
4798            .add_member(Sourced::generated(id("web")?))
4799            .map_err(|error| error.to_string())?;
4800        assert!(matches!(
4801            frontend.add_member(Sourced::generated(id("web")?)),
4802            Err(ModelError::DuplicateServiceGroupMember { .. })
4803        ));
4804        application
4805            .add_service_group(Sourced::generated(frontend))
4806            .map_err(|error| error.to_string())?;
4807
4808        assert_eq!(application.service_groups()[0].value().name().as_str(), "frontend");
4809        assert_eq!(
4810            application.service_groups()[0].value().members()[0].value().as_str(),
4811            "web"
4812        );
4813
4814        let mut conflicting = ServiceGroup::new(id("backend")?, ResourceOwnership::Application);
4815        conflicting
4816            .add_member(Sourced::generated(id("web")?))
4817            .map_err(|error| error.to_string())?;
4818        assert!(matches!(
4819            application.add_service_group(Sourced::generated(conflicting)),
4820            Err(ModelError::ServiceInMultipleGroups { .. })
4821        ));
4822
4823        let mut missing = ServiceGroup::new(id("missing")?, ResourceOwnership::External);
4824        missing
4825            .add_member(Sourced::generated(id("database")?))
4826            .map_err(|error| error.to_string())?;
4827        assert!(matches!(
4828            application.add_service_group(Sourced::generated(missing)),
4829            Err(ModelError::UnknownServiceGroupMember { .. })
4830        ));
4831        Ok(())
4832    }
4833
4834    #[test]
4835    fn group_runtime_keeps_group_names_and_pod_settings_distinct() -> Result<(), String> {
4836        let source = crate::SourceId::new("pod.pod").map_err(|error| error.to_string())?;
4837        let origin = crate::Provenance::source(source);
4838        let mut group = ServiceGroup::new(id("frontend")?, ResourceOwnership::Application);
4839        let mut runtime = ServiceGroupRuntime::new();
4840        runtime.set_runtime_name(Sourced::from_source(
4841            ProtectedString::plain("production-frontend"),
4842            origin.clone(),
4843        ));
4844        runtime.set_service_name(Sourced::from_source(
4845            ProtectedString::plain("frontend-pod"),
4846            origin.clone(),
4847        ));
4848        runtime.set_host_mappings_with_origins(
4849            vec![Sourced::from_source(
4850                HostMapping::new(
4851                    id("host.docker.internal")?,
4852                    HostAddress::new("host-gateway").map_err(|error| error.to_string())?,
4853                ),
4854                origin.clone(),
4855            )],
4856            vec![origin.clone()],
4857        );
4858        runtime.set_ports_with_origins(Vec::new(), vec![origin.clone()]);
4859        runtime.set_networks_with_origins(
4860            vec![Sourced::from_source(
4861                NetworkAttachment::new(
4862                    id("edge")?,
4863                    vec![Sourced::from_source(
4864                        ProtectedString::sensitive("private-alias"),
4865                        origin.clone(),
4866                    )],
4867                ),
4868                origin.clone(),
4869            )],
4870            vec![origin.clone()],
4871        );
4872        runtime.set_user_namespace(Sourced::from_source(ProtectedString::plain("keep-id"), origin.clone()));
4873        runtime.set_mounts_with_origins(
4874            vec![Sourced::from_source(
4875                Mount::new(MountSource::Anonymous, "/cache", false).map_err(|error| error.to_string())?,
4876                origin.clone(),
4877            )],
4878            vec![origin.clone()],
4879        );
4880        runtime.set_shm_size(Sourced::from_source(ProtectedString::sensitive("64m"), origin.clone()));
4881        runtime.set_exit_policy(Sourced::from_source(
4882            GroupExitPolicy::Raw(ProtectedString::sensitive("preserve-this")),
4883            origin.clone(),
4884        ));
4885        runtime.set_stop_timeout(Sourced::from_source(
4886            StopTimeout::new("30s").map_err(|error| error.to_string())?,
4887            origin.clone(),
4888        ));
4889        assert!(matches!(
4890            runtime.replace_network(1, Sourced::generated(NetworkAttachment::new(id("other")?, Vec::new()))),
4891            Err(ModelError::UnknownServiceGroupRuntimeNetworkIndex { index: 1, len: 1 })
4892        ));
4893        group.set_runtime(Sourced::from_source(runtime, origin));
4894
4895        let runtime = group
4896            .runtime()
4897            .ok_or_else(|| "group runtime was omitted".to_owned())?
4898            .value();
4899        assert_eq!(group.name().as_str(), "frontend");
4900        assert_eq!(
4901            runtime.runtime_name().map(|name| name.value().expose()),
4902            Some("production-frontend")
4903        );
4904        assert_eq!(
4905            runtime.service_name().map(|name| name.value().expose()),
4906            Some("frontend-pod")
4907        );
4908        assert_eq!(runtime.host_mappings().map(<[_]>::len), Some(1));
4909        assert_eq!(runtime.ports().map(<[_]>::len), Some(0));
4910        assert_eq!(runtime.networks_origins().len(), 1);
4911        assert_eq!(runtime.mounts().map(<[_]>::len), Some(1));
4912        assert!(matches!(
4913            runtime.exit_policy().map(Sourced::value),
4914            Some(GroupExitPolicy::Raw(_))
4915        ));
4916        let debug = format!("{group:?}");
4917        for sensitive in ["private-alias", "64m", "preserve-this"] {
4918            assert!(!debug.contains(sensitive));
4919        }
4920        assert!(debug.contains("[REDACTED]"));
4921        Ok(())
4922    }
4923
4924    #[test]
4925    fn rootfs_startup_notification_and_podman_args_preserve_safe_contracts() -> Result<(), String> {
4926        let source = crate::SourceId::new("web.container").map_err(|error| error.to_string())?;
4927        let origin = crate::Provenance::source(source);
4928        let mut service = Service::new(id("web")?);
4929        service.set_startup_notification(Sourced::from_source(StartupNotification::Healthy, origin.clone()));
4930        assert!(matches!(
4931            service.startup_notification().map(Sourced::value),
4932            Some(StartupNotification::Healthy)
4933        ));
4934
4935        let mut with_image = Service::new(id("image-first")?);
4936        with_image.set_image(Sourced::generated(
4937            ImageReference::parse("example.invalid/web:1").map_err(|error| error.to_string())?,
4938        ));
4939        assert!(matches!(
4940            with_image.set_rootfs(Sourced::generated(ProtectedString::plain("/srv/rootfs"))),
4941            Err(ModelError::RootfsImageSourceConflict { source: "image", .. })
4942        ));
4943
4944        let mut with_rootfs = Service::new(id("rootfs-first")?);
4945        with_rootfs
4946            .set_rootfs(Sourced::generated(ProtectedString::sensitive("/private/rootfs")))
4947            .map_err(|error| error.to_string())?;
4948        with_rootfs.set_image_build(Sourced::generated(id("web-build")?));
4949        let mut application = Application::new(id("example")?);
4950        application
4951            .add_image_build(Sourced::generated(ImageBuild::new(id("web-build")?)))
4952            .map_err(|error| error.to_string())?;
4953        assert!(matches!(
4954            application.add_service(Sourced::generated(with_rootfs)),
4955            Err(ModelError::RootfsImageSourceConflict {
4956                source: "image build",
4957                ..
4958            })
4959        ));
4960        Ok(())
4961    }
4962
4963    #[test]
4964    fn validates_raw_preserving_healthcheck_scalars() -> Result<(), String> {
4965        let duration = HealthcheckDuration::new("1m30s").map_err(|error| error.to_string())?;
4966        let retries = HealthcheckRetries::new("003").map_err(|error| error.to_string())?;
4967        assert_eq!(duration.as_str(), "1m30s");
4968        assert_eq!(retries.as_str(), "003");
4969        assert_eq!(
4970            HealthcheckRetries::new("three"),
4971            Err(ModelError::InvalidHealthcheckRetries)
4972        );
4973        assert!(matches!(
4974            HealthcheckDuration::new(""),
4975            Err(ModelError::EmptyValue("health-check duration"))
4976        ));
4977        Ok(())
4978    }
4979
4980    #[test]
4981    fn preserves_ordered_dependency_edges_and_field_provenance() -> Result<(), String> {
4982        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4983        let origin = crate::Provenance::source(source);
4984        let mut service = Service::new(id("web")?);
4985
4986        let mut database = ServiceDependency::new(id("database")?);
4987        database.set_condition(Sourced::from_source(
4988            ServiceDependencyCondition::Healthy,
4989            origin.clone(),
4990        ));
4991        database.set_required(Sourced::from_source(true, origin.clone()));
4992        service.add_dependency(Sourced::from_source(database, origin.clone()));
4993
4994        let cache = ServiceDependency::new(id("cache")?);
4995        assert!(cache.is_required());
4996        service.add_dependency(Sourced::from_source(cache, origin));
4997
4998        assert_eq!(
4999            service
5000                .dependencies()
5001                .iter()
5002                .map(|dependency| dependency.value().service().as_str())
5003                .collect::<Vec<_>>(),
5004            ["database", "cache"]
5005        );
5006        assert!(matches!(
5007            service.dependencies()[0].value().condition().map(Sourced::value),
5008            Some(ServiceDependencyCondition::Healthy)
5009        ));
5010        assert_eq!(service.dependencies()[0].origins().len(), 1);
5011        assert_eq!(
5012            service.dependencies()[0]
5013                .value()
5014                .condition()
5015                .map_or(0, |condition| condition.origins().len()),
5016            1
5017        );
5018        Ok(())
5019    }
5020
5021    #[test]
5022    fn retains_execution_identity_context_order_provenance_and_redaction() -> Result<(), String> {
5023        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
5024        let origin = crate::Provenance::source(source);
5025        let mut service = Service::new(id("web")?);
5026
5027        service.set_user(Sourced::from_source(ProtectedString::sensitive("1001"), origin.clone()));
5028        service.set_group(Sourced::from_source(ProtectedString::plain("1002"), origin.clone()));
5029        service.set_user_namespace(Sourced::from_source(ProtectedString::plain("keep-id"), origin.clone()));
5030        service.add_supplementary_group(Sourced::from_source(ProtectedString::plain("audio"), origin.clone()));
5031        service.add_supplementary_group(Sourced::from_source(ProtectedString::plain("44"), origin.clone()));
5032        service.set_working_directory(Sourced::from_source(ProtectedString::plain("/srv/app"), origin.clone()));
5033        service.set_read_only_root_filesystem(Sourced::from_source(true, origin));
5034
5035        assert_eq!(service.user().map(|value| value.value().expose()), Some("1001"));
5036        assert_eq!(service.group().map(|value| value.value().expose()), Some("1002"));
5037        assert_eq!(
5038            service.user_namespace().map(|value| value.value().expose()),
5039            Some("keep-id")
5040        );
5041        assert_eq!(
5042            service
5043                .supplementary_groups()
5044                .iter()
5045                .map(|group| group.value().expose())
5046                .collect::<Vec<_>>(),
5047            ["audio", "44"]
5048        );
5049        assert_eq!(
5050            service.working_directory().map(|value| value.value().expose()),
5051            Some("/srv/app")
5052        );
5053        assert_eq!(service.read_only_root_filesystem().map(Sourced::value), Some(&true));
5054        assert_eq!(service.user().map_or(0, |value| value.origins().len()), 1);
5055        let debug = format!("{service:?}");
5056        assert!(!debug.contains("1001"));
5057        assert!(debug.contains("[REDACTED]"));
5058        Ok(())
5059    }
5060
5061    #[test]
5062    fn retains_config_secret_resources_grants_provenance_and_redaction() -> Result<(), String> {
5063        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
5064        let origin = crate::Provenance::source(source);
5065        let mut application = Application::new(id("example")?);
5066
5067        let mut config = Config::new(id("settings")?, ResourceOwnership::Application);
5068        config.set_material(Sourced::from_source(
5069            ConfigMaterial::Content(ProtectedString::sensitive("private-config")),
5070            origin.clone(),
5071        ));
5072        application
5073            .add_config(Sourced::from_source(config, origin.clone()))
5074            .map_err(|error| error.to_string())?;
5075
5076        let mut secret = Secret::new(id("password")?, ResourceOwnership::External);
5077        secret.set_runtime_name(Sourced::from_source(
5078            ProtectedString::plain("production-password"),
5079            origin.clone(),
5080        ));
5081        secret.set_material(Sourced::from_source(
5082            SecretMaterial::Environment(ProtectedString::sensitive("private-environment-name")),
5083            origin.clone(),
5084        ));
5085        application
5086            .add_secret(Sourced::from_source(secret, origin.clone()))
5087            .map_err(|error| error.to_string())?;
5088
5089        let mut service = Service::new(id("web")?);
5090        service.add_config_grant(Sourced::from_source(
5091            ResourceGrant::new(ProtectedString::plain("settings"), ResourceGrantSyntax::Short)
5092                .map_err(|error| error.to_string())?,
5093            origin.clone(),
5094        ));
5095        let mut secret_grant = ResourceGrant::new(
5096            ProtectedString::sensitive("private-grant-source"),
5097            ResourceGrantSyntax::Long,
5098        )
5099        .map_err(|error| error.to_string())?;
5100        secret_grant.set_target(Sourced::from_source(
5101            ProtectedString::plain("database-password"),
5102            origin.clone(),
5103        ));
5104        secret_grant.set_uid(Sourced::from_source(ProtectedString::plain("1001"), origin.clone()));
5105        secret_grant.set_gid(Sourced::from_source(ProtectedString::plain("1002"), origin.clone()));
5106        secret_grant.set_mode(Sourced::from_source(ProtectedString::plain("0440"), origin.clone()));
5107        service.add_secret_grant(Sourced::from_source(secret_grant, origin.clone()));
5108        application
5109            .add_service(Sourced::from_source(service, origin))
5110            .map_err(|error| error.to_string())?;
5111
5112        assert_eq!(application.configs().len(), 1);
5113        assert_eq!(application.secrets().len(), 1);
5114        assert_eq!(application.services()[0].value().config_grants().len(), 1);
5115        let grant = &application.services()[0].value().secret_grants()[0];
5116        assert_eq!(grant.value().syntax(), ResourceGrantSyntax::Long);
5117        assert_eq!(
5118            grant.value().target().map(|value| value.value().expose()),
5119            Some("database-password")
5120        );
5121        assert_eq!(grant.value().uid().map_or(0, |value| value.origins().len()), 1);
5122        assert_eq!(grant.origins().len(), 1);
5123        let debug = format!("{application:?}");
5124        for secret in ["private-config", "private-environment-name", "private-grant-source"] {
5125            assert!(!debug.contains(secret));
5126        }
5127        assert!(debug.contains("[REDACTED]"));
5128
5129        assert!(matches!(
5130            ResourceGrant::new(ProtectedString::plain(""), ResourceGrantSyntax::Short),
5131            Err(ModelError::EmptyValue("resource grant source"))
5132        ));
5133        assert!(matches!(
5134            application.add_config(Sourced::generated(Config::new(
5135                id("settings")?,
5136                ResourceOwnership::External,
5137            ))),
5138            Err(ModelError::DuplicateResource { kind: "config", .. })
5139        ));
5140        assert!(matches!(
5141            application.add_secret(Sourced::generated(Secret::new(
5142                id("password")?,
5143                ResourceOwnership::External,
5144            ))),
5145            Err(ModelError::DuplicateResource { kind: "secret", .. })
5146        ));
5147        Ok(())
5148    }
5149
5150    #[test]
5151    fn host_mappings_preserve_order_spelling_and_runtime_tokens() -> Result<(), String> {
5152        let mut service = Service::new(id("web")?);
5153        service.add_host_mapping(Sourced::generated(HostMapping::new(
5154            id("host.docker.internal")?,
5155            HostAddress::new("host-gateway").map_err(|error| error.to_string())?,
5156        )));
5157        service.add_host_mapping(Sourced::generated(HostMapping::new(
5158            id("ipv6")?,
5159            HostAddress::new("[::1]").map_err(|error| error.to_string())?,
5160        )));
5161
5162        assert_eq!(service.host_mappings().len(), 2);
5163        assert_eq!(
5164            service.host_mappings()[0].value().address().kind(),
5165            HostAddressKind::HostGateway
5166        );
5167        assert_eq!(service.host_mappings()[1].value().address().raw(), "[::1]");
5168        assert_eq!(
5169            service.host_mappings()[1].value().address().kind(),
5170            HostAddressKind::Ipv6 { bracketed: true }
5171        );
5172        assert!(matches!(HostAddress::new(""), Err(ModelError::EmptyValue(_))));
5173        Ok(())
5174    }
5175
5176    #[test]
5177    fn dns_collections_preserve_order_provenance_and_explicit_empty_state() -> Result<(), String> {
5178        let mut service = Service::new(id("web")?);
5179        assert!(service.dns_servers().is_none());
5180        service.set_dns_servers(Vec::new());
5181        assert!(matches!(service.dns_servers(), Some(values) if values.is_empty()));
5182        service.set_dns_options(vec![
5183            Sourced::generated(ProtectedString::plain("ndots:5")),
5184            Sourced::generated(ProtectedString::sensitive("rotate")),
5185        ]);
5186        service.set_dns_search_domains(vec![Sourced::generated(ProtectedString::plain("example.test"))]);
5187        assert_eq!(
5188            service
5189                .dns_options()
5190                .unwrap_or_default()
5191                .iter()
5192                .map(|value| value.value().expose())
5193                .collect::<Vec<_>>(),
5194            ["ndots:5", "rotate"]
5195        );
5196        assert!(!format!("{service:?}").contains("rotate"));
5197        Ok(())
5198    }
5199
5200    #[test]
5201    fn security_options_preserve_empty_order_duplicates_provenance_and_redaction() -> Result<(), String> {
5202        let origin =
5203            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
5204        let mut service = Service::new(id("web")?);
5205
5206        assert!(service.security_options().is_none());
5207        service.set_security_options_with_origins(Vec::new(), vec![origin.clone()]);
5208        assert_eq!(service.security_options().map(<[_]>::len), Some(0));
5209        assert_eq!(service.security_options_origins(), std::slice::from_ref(&origin));
5210
5211        service.set_security_options_with_origins(
5212            vec![
5213                Sourced::from_source(
5214                    SecurityOption::AppArmor(ProtectedString::sensitive("apparmor-secret")),
5215                    origin.clone(),
5216                ),
5217                Sourced::from_source(SecurityOption::NoNewPrivileges(true), origin.clone()),
5218                Sourced::from_source(
5219                    SecurityOption::SeccompProfile(ProtectedString::sensitive("seccomp-secret")),
5220                    origin.clone(),
5221                ),
5222                Sourced::from_source(SecurityOption::SecurityLabelDisable(false), origin.clone()),
5223                Sourced::from_source(
5224                    SecurityOption::SecurityLabelFileType(ProtectedString::sensitive("file-type-secret")),
5225                    origin.clone(),
5226                ),
5227                Sourced::from_source(
5228                    SecurityOption::SecurityLabelLevel(ProtectedString::sensitive("level-secret")),
5229                    origin.clone(),
5230                ),
5231                Sourced::from_source(SecurityOption::SecurityLabelNested(true), origin.clone()),
5232                Sourced::from_source(
5233                    SecurityOption::SecurityLabelType(ProtectedString::sensitive("type-secret")),
5234                    origin.clone(),
5235                ),
5236                Sourced::from_source(
5237                    SecurityOption::Mask(ProtectedString::sensitive("mask-secret")),
5238                    origin.clone(),
5239                ),
5240                Sourced::from_source(
5241                    SecurityOption::Unmask(ProtectedString::sensitive("unmask-secret")),
5242                    origin.clone(),
5243                ),
5244                Sourced::from_source(
5245                    SecurityOption::Mask(ProtectedString::sensitive("mask-secret")),
5246                    origin.clone(),
5247                ),
5248            ],
5249            vec![origin.clone()],
5250        );
5251
5252        let options = service.security_options().unwrap_or_default();
5253        assert_eq!(options.len(), 11);
5254        assert!(
5255            matches!(options[0].value(), SecurityOption::AppArmor(profile) if profile.expose() == "apparmor-secret")
5256        );
5257        assert!(matches!(options[1].value(), SecurityOption::NoNewPrivileges(true)));
5258        assert!(
5259            matches!(options[2].value(), SecurityOption::SeccompProfile(profile) if profile.expose() == "seccomp-secret")
5260        );
5261        assert!(matches!(
5262            options[3].value(),
5263            SecurityOption::SecurityLabelDisable(false)
5264        ));
5265        assert!(
5266            matches!(options[4].value(), SecurityOption::SecurityLabelFileType(profile) if profile.expose() == "file-type-secret")
5267        );
5268        assert!(
5269            matches!(options[5].value(), SecurityOption::SecurityLabelLevel(profile) if profile.expose() == "level-secret")
5270        );
5271        assert!(matches!(options[6].value(), SecurityOption::SecurityLabelNested(true)));
5272        assert!(
5273            matches!(options[7].value(), SecurityOption::SecurityLabelType(profile) if profile.expose() == "type-secret")
5274        );
5275        assert!(matches!(options[8].value(), SecurityOption::Mask(path) if path.expose() == "mask-secret"));
5276        assert!(matches!(options[9].value(), SecurityOption::Unmask(path) if path.expose() == "unmask-secret"));
5277        assert!(matches!(options[10].value(), SecurityOption::Mask(path) if path.expose() == "mask-secret"));
5278        assert_eq!(options[0].origins(), std::slice::from_ref(&origin));
5279        assert_eq!(service.security_options_origins(), std::slice::from_ref(&origin));
5280
5281        let debug = format!("{service:?}");
5282        for secret in [
5283            "apparmor-secret",
5284            "seccomp-secret",
5285            "file-type-secret",
5286            "level-secret",
5287            "type-secret",
5288            "mask-secret",
5289            "unmask-secret",
5290        ] {
5291            assert!(!debug.contains(secret));
5292        }
5293        assert!(debug.contains("[REDACTED]"));
5294
5295        service.set_security_options(Vec::new());
5296        assert_eq!(service.security_options().map(<[_]>::len), Some(0));
5297        assert!(service.security_options_origins().is_empty());
5298        Ok(())
5299    }
5300
5301    #[test]
5302    fn retains_entrypoint_run_init_stop_pull_memory_and_exposed_port_intent() -> Result<(), String> {
5303        let origin =
5304            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
5305        let mut service = Service::new(id("web")?);
5306        service.set_command(Sourced::from_source(
5307            Command::Exec(vec![ProtectedString::plain("serve")]),
5308            origin.clone(),
5309        ));
5310        service.set_entrypoint(Sourced::from_source(
5311            Entrypoint::Shell(ProtectedString::sensitive("/bin/sh -c private-entrypoint")),
5312            origin.clone(),
5313        ));
5314        service.set_run_init(Sourced::from_source(true, origin.clone()));
5315        service.set_stop_timeout(Sourced::from_source(
5316            StopTimeout::new("01m30s").map_err(|error| error.to_string())?,
5317            origin.clone(),
5318        ));
5319        service.set_pull_policy(Sourced::from_source(
5320            PullPolicy::Every(ProtectedString::sensitive("12h")),
5321            origin.clone(),
5322        ));
5323        service.set_memory_limit(Sourced::from_source(
5324            ProtectedString::sensitive("512MiB"),
5325            origin.clone(),
5326        ));
5327        assert!(service.exposed_ports().is_none());
5328        service.set_exposed_ports_with_origins(Vec::new(), vec![origin.clone()]);
5329        assert_eq!(service.exposed_ports().map(<[_]>::len), Some(0));
5330        assert_eq!(service.exposed_ports_origins(), std::slice::from_ref(&origin));
5331        service.add_exposed_port(Sourced::from_source(
5332            ExposedPort::new(8080, Protocol::Tcp).map_err(|error| error.to_string())?,
5333            origin.clone(),
5334        ));
5335        service.add_exposed_port(Sourced::from_source(
5336            ExposedPort::new(8080, Protocol::Tcp).map_err(|error| error.to_string())?,
5337            origin,
5338        ));
5339
5340        assert!(matches!(service.command().map(Sourced::value), Some(Command::Exec(_))));
5341        assert!(matches!(
5342            service.entrypoint().map(Sourced::value),
5343            Some(Entrypoint::Shell(_))
5344        ));
5345        assert_eq!(service.run_init().map(Sourced::value), Some(&true));
5346        assert_eq!(
5347            service.stop_timeout().map(|timeout| timeout.value().as_str()),
5348            Some("01m30s")
5349        );
5350        assert!(matches!(
5351            service.pull_policy().map(Sourced::value),
5352            Some(PullPolicy::Every(_))
5353        ));
5354        assert_eq!(
5355            service.memory_limit().map(|limit| limit.value().expose()),
5356            Some("512MiB")
5357        );
5358        let exposed_ports = service.exposed_ports().ok_or("missing exposed ports")?;
5359        assert_eq!(exposed_ports.len(), 2);
5360        assert_eq!(exposed_ports[0].value().container(), 8080);
5361        assert_eq!(exposed_ports[0].value().protocol(), &Protocol::Tcp);
5362        assert!(matches!(
5363            ExposedPort::new(0, Protocol::Udp),
5364            Err(ModelError::ZeroContainerPort)
5365        ));
5366        assert!(matches!(
5367            StopTimeout::new(""),
5368            Err(ModelError::EmptyValue("stop timeout"))
5369        ));
5370
5371        let debug = format!("{service:?}");
5372        for secret in ["private-entrypoint", "512MiB", "12h"] {
5373            assert!(!debug.contains(secret));
5374        }
5375        assert!(debug.contains("[REDACTED]"));
5376        Ok(())
5377    }
5378
5379    #[test]
5380    fn annotations_and_logging_preserve_empty_order_field_provenance_and_redaction() -> Result<(), String> {
5381        let origin =
5382            crate::Provenance::source(crate::SourceId::new("quadlet.container").map_err(|error| error.to_string())?);
5383        let mut service = Service::new(id("web")?);
5384
5385        assert!(service.annotations().is_none());
5386        service.set_annotations_with_origins(Vec::new(), vec![origin.clone()]);
5387        assert_eq!(service.annotations().map(<[_]>::len), Some(0));
5388        assert_eq!(service.annotations_origins(), std::slice::from_ref(&origin));
5389
5390        service.set_annotations_with_origins(
5391            vec![
5392                Sourced::from_source(
5393                    Annotation::new(
5394                        Sourced::from_source(id("io.example.first")?, origin.clone()),
5395                        Sourced::from_source(ProtectedString::sensitive("annotation-secret"), origin.clone()),
5396                    ),
5397                    origin.clone(),
5398                ),
5399                Sourced::from_source(
5400                    Annotation::new(
5401                        Sourced::from_source(id("io.example.second")?, origin.clone()),
5402                        Sourced::from_source(ProtectedString::plain(""), origin.clone()),
5403                    ),
5404                    origin.clone(),
5405                ),
5406            ],
5407            vec![origin.clone()],
5408        );
5409        let annotations = service.annotations().unwrap_or_default();
5410        assert_eq!(annotations.len(), 2);
5411        assert_eq!(annotations[0].value().name().value().as_str(), "io.example.first");
5412        assert_eq!(annotations[1].value().value().value().expose(), "");
5413        assert_eq!(annotations[0].value().name().origins(), std::slice::from_ref(&origin));
5414        assert_eq!(annotations[0].value().value().origins(), std::slice::from_ref(&origin));
5415
5416        let mut logging = Logging::new();
5417        assert!(logging.options().is_none());
5418        logging.set_driver(Sourced::from_source(ProtectedString::plain("journald"), origin.clone()));
5419        logging.set_options_with_origins(
5420            vec![
5421                Sourced::from_source(
5422                    LoggingOption::new(
5423                        Sourced::from_source(id("tag")?, origin.clone()),
5424                        Sourced::from_source(ProtectedString::sensitive("logging-secret"), origin.clone()),
5425                    ),
5426                    origin.clone(),
5427                ),
5428                Sourced::from_source(
5429                    LoggingOption::new(
5430                        Sourced::from_source(id("labels")?, origin.clone()),
5431                        Sourced::from_source(ProtectedString::plain(""), origin.clone()),
5432                    ),
5433                    origin.clone(),
5434                ),
5435            ],
5436            vec![origin.clone()],
5437        );
5438        service.set_logging(Sourced::from_source(logging, origin));
5439
5440        let logging = service.logging().map(Sourced::value).ok_or("missing logging")?;
5441        assert_eq!(logging.driver().map(|driver| driver.value().expose()), Some("journald"));
5442        assert_eq!(logging.options().map(<[_]>::len), Some(2));
5443        assert_eq!(
5444            logging.options().unwrap_or_default()[0].value().name().value().as_str(),
5445            "tag"
5446        );
5447        assert_eq!(logging.options_origins().len(), 1);
5448        let debug = format!("{service:?}");
5449        assert!(!debug.contains("annotation-secret"));
5450        assert!(!debug.contains("logging-secret"));
5451        assert!(debug.contains("[REDACTED]"));
5452        Ok(())
5453    }
5454
5455    #[test]
5456    fn network_attachments_retain_alias_provenance_and_redact_sensitive_values() -> Result<(), String> {
5457        let origin =
5458            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
5459        let mut attachment = NetworkAttachment::new(
5460            id("frontend")?,
5461            vec![
5462                Sourced::from_source(ProtectedString::plain("web"), origin.clone()),
5463                Sourced::from_source(ProtectedString::sensitive("private-alias"), origin.clone()),
5464            ],
5465        );
5466        attachment.set_ipv4_address(Sourced::from_source(
5467            ProtectedString::plain("192.0.2.10"),
5468            origin.clone(),
5469        ));
5470        attachment.set_ipv6_address(Sourced::from_source(
5471            ProtectedString::plain("2001:db8::10"),
5472            origin.clone(),
5473        ));
5474        let metrics = Sourced::generated(ProtectedString::plain("metrics"));
5475        attachment.add_alias(&metrics);
5476
5477        assert_eq!(attachment.aliases(), ["web", "private-alias", "metrics"]);
5478        assert_eq!(attachment.alias_sensitivities(), [false, true, false]);
5479        assert_eq!(attachment.alias_origins().len(), 3);
5480        assert_eq!(attachment.alias_origins()[0].len(), 1);
5481        assert_eq!(attachment.alias_origins()[1], std::slice::from_ref(&origin));
5482        assert!(attachment.alias_origins()[2].is_empty());
5483        assert_eq!(
5484            attachment.ipv4_address().map(|address| address.value().expose()),
5485            Some("192.0.2.10")
5486        );
5487        assert_eq!(
5488            attachment.ipv6_address().map(|address| address.value().expose()),
5489            Some("2001:db8::10")
5490        );
5491        let debug = format!("{attachment:?}");
5492        assert!(!debug.contains("private-alias"));
5493        assert!(debug.contains("[REDACTED]"));
5494
5495        let mut service = Service::new(id("web")?);
5496        service.add_network(Sourced::generated(NetworkAttachment::new(
5497            id("previous")?,
5498            vec![Sourced::generated(ProtectedString::plain("previous-alias"))],
5499        )));
5500        let previous = service
5501            .replace_network(0, Sourced::generated(attachment))
5502            .map_err(|error| error.to_string())?;
5503        assert_eq!(previous.value().network().as_str(), "previous");
5504        assert_eq!(service.networks()[0].value().network().as_str(), "frontend");
5505        assert!(matches!(
5506            service.replace_network(1, Sourced::generated(NetworkAttachment::new(id("unused")?, Vec::new()))),
5507            Err(ModelError::UnknownNetworkAttachmentIndex { index: 1, len: 1 })
5508        ));
5509        Ok(())
5510    }
5511
5512    #[test]
5513    fn reload_action_is_one_explicit_command_or_signal() -> Result<(), String> {
5514        let origin =
5515            crate::Provenance::source(crate::SourceId::new("quadlet.container").map_err(|error| error.to_string())?);
5516        let mut service = Service::new(id("web")?);
5517        service.set_reload_action(Sourced::from_source(
5518            ReloadAction::Command(Command::Exec(vec![ProtectedString::plain("reload")])),
5519            origin.clone(),
5520        ));
5521        assert!(matches!(
5522            service.reload_action().map(Sourced::value),
5523            Some(ReloadAction::Command(Command::Exec(_)))
5524        ));
5525
5526        service.set_reload_action(Sourced::from_source(
5527            ReloadAction::Signal(ProtectedString::sensitive("SIGHUP")),
5528            origin,
5529        ));
5530        assert!(matches!(
5531            service.reload_action().map(Sourced::value),
5532            Some(ReloadAction::Signal(_))
5533        ));
5534        let debug = format!("{service:?}");
5535        assert!(!debug.contains("SIGHUP"));
5536        assert!(debug.contains("[REDACTED]"));
5537        Ok(())
5538    }
5539
5540    fn id(value: &str) -> Result<Identifier, String> {
5541        Identifier::new(value).map_err(|error| error.to_string())
5542    }
5543}