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