Skip to main content

boxferry_model/
image_artifact.rs

1//! Provenance-aware image acquisition and build resources.
2//!
3//! These types describe application artifacts, not any one source or target format. A service's
4//! runtime image remains distinct from the acquisition/build resources that produce or obtain it.
5
6use crate::{Identifier, ProtectedString, Provenance, ResourceLimit, Sourced};
7
8/// The generic syntax family that supplied a build declaration or per-key value collection.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10#[non_exhaustive]
11pub enum BuildSyntax {
12    /// One short scalar declaration.
13    Scalar,
14    /// A structured declaration object.
15    Structured,
16    /// Mapping syntax for named values.
17    Mapping,
18    /// Sequence syntax for ordered values.
19    Sequence,
20    /// Repeated native assignment syntax.
21    Repeated,
22}
23
24/// One typed collection belonging to one explicitly present image-artifact key.
25///
26/// The enclosing setting establishes that its key was present. Consequently, `values: []` is an
27/// explicit empty/reset value, while omission is represented by the absence of that setting.
28/// Repeated settings and values retain their declaration order and duplicate occurrences.
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct BuildSettingValues<T> {
31    syntax: BuildSyntax,
32    values: Vec<Sourced<T>>,
33}
34
35impl<T> BuildSettingValues<T> {
36    /// Creates values for one explicitly present key.
37    #[must_use]
38    pub const fn new(syntax: BuildSyntax, values: Vec<Sourced<T>>) -> Self {
39        Self { syntax, values }
40    }
41
42    /// Returns the source syntax family.
43    #[must_use]
44    pub const fn syntax(&self) -> BuildSyntax {
45        self.syntax
46    }
47
48    /// Returns values in source order, including duplicates.
49    #[must_use]
50    pub fn values(&self) -> &[Sourced<T>] {
51        &self.values
52    }
53}
54
55/// One source-preserving assignment used by image artifact settings.
56///
57/// Empty and key-only assignments are retained for adapters to diagnose rather than discarded by
58/// the neutral model. `ProtectedString` prevents interpolated names and values from leaking into
59/// debug output.
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct ImageArtifactAssignment {
62    name: ProtectedString,
63    value: Option<ProtectedString>,
64}
65
66impl ImageArtifactAssignment {
67    /// Creates a key-only or key/value assignment without applying native naming rules.
68    #[must_use]
69    pub const fn new(name: ProtectedString, value: Option<ProtectedString>) -> Self {
70        Self { name, value }
71    }
72
73    /// Returns the preserved assignment name.
74    #[must_use]
75    pub const fn name(&self) -> &ProtectedString {
76        &self.name
77    }
78
79    /// Returns the explicit value, if the source supplied one.
80    #[must_use]
81    pub const fn value(&self) -> Option<&ProtectedString> {
82        self.value.as_ref()
83    }
84}
85
86/// One named additional context in a structured source build declaration.
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct BuildContext {
89    name: ProtectedString,
90    value: ProtectedString,
91}
92
93impl BuildContext {
94    /// Creates a named additional build context without applying native rules.
95    #[must_use]
96    pub const fn new(name: ProtectedString, value: ProtectedString) -> Self {
97        Self { name, value }
98    }
99
100    /// Returns the declared context name.
101    #[must_use]
102    pub const fn name(&self) -> &ProtectedString {
103        &self.name
104    }
105
106    /// Returns the raw-preserving context value.
107    #[must_use]
108    pub const fn value(&self) -> &ProtectedString {
109        &self.value
110    }
111}
112
113/// A source build-secret declaration whose options remain distinct from target build-secret text.
114#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct SourceBuildSecret {
116    source: ProtectedString,
117    target: Option<ProtectedString>,
118    uid: Option<ProtectedString>,
119    gid: Option<ProtectedString>,
120    mode: Option<ProtectedString>,
121}
122
123impl SourceBuildSecret {
124    /// Creates a source build-secret declaration without applying native validation.
125    #[must_use]
126    pub const fn new(source: ProtectedString) -> Self {
127        Self {
128            source,
129            target: None,
130            uid: None,
131            gid: None,
132            mode: None,
133        }
134    }
135
136    /// Returns the preserved source name.
137    #[must_use]
138    pub const fn source(&self) -> &ProtectedString {
139        &self.source
140    }
141
142    /// Sets the optional target name.
143    pub fn set_target(&mut self, target: ProtectedString) {
144        self.target = Some(target);
145    }
146
147    /// Returns the optional target name.
148    #[must_use]
149    pub const fn target(&self) -> Option<&ProtectedString> {
150        self.target.as_ref()
151    }
152
153    /// Sets the optional UID spelling.
154    pub fn set_uid(&mut self, uid: ProtectedString) {
155        self.uid = Some(uid);
156    }
157
158    /// Returns the optional UID spelling.
159    #[must_use]
160    pub const fn uid(&self) -> Option<&ProtectedString> {
161        self.uid.as_ref()
162    }
163
164    /// Sets the optional GID spelling.
165    pub fn set_gid(&mut self, gid: ProtectedString) {
166        self.gid = Some(gid);
167    }
168
169    /// Returns the optional GID spelling.
170    #[must_use]
171    pub const fn gid(&self) -> Option<&ProtectedString> {
172        self.gid.as_ref()
173    }
174
175    /// Sets the optional mode spelling.
176    pub fn set_mode(&mut self, mode: ProtectedString) {
177        self.mode = Some(mode);
178    }
179
180    /// Returns the optional mode spelling.
181    #[must_use]
182    pub const fn mode(&self) -> Option<&ProtectedString> {
183        self.mode.as_ref()
184    }
185}
186
187/// A source build attestation value.
188#[derive(Clone, Debug, Eq, PartialEq)]
189#[non_exhaustive]
190pub enum BuildAttestation {
191    /// Boolean form.
192    Boolean(bool),
193    /// Raw parameterized form.
194    Value(ProtectedString),
195}
196
197/// One field from a structured source build declaration.
198///
199/// These concepts deliberately describe source declaration intent. They do not claim equivalence
200/// to similarly named acquisition/build settings.
201#[derive(Clone, Debug, Eq, PartialEq)]
202#[non_exhaustive]
203pub enum SourceBuildSetting {
204    /// Additional named contexts.
205    AdditionalContexts(BuildSettingValues<BuildContext>),
206    /// Build arguments.
207    Arguments(BuildSettingValues<ImageArtifactAssignment>),
208    /// Cache import locations.
209    CacheFrom(BuildSettingValues<ProtectedString>),
210    /// Cache export locations.
211    CacheTo(BuildSettingValues<ProtectedString>),
212    /// Build context.
213    Context(ProtectedString),
214    /// Build recipe path.
215    RecipeFile(ProtectedString),
216    /// Inline build recipe.
217    InlineRecipe(ProtectedString),
218    /// Build entitlements.
219    Entitlements(BuildSettingValues<ProtectedString>),
220    /// Extra host mappings.
221    ExtraHosts(BuildSettingValues<ImageArtifactAssignment>),
222    /// Isolation selection.
223    Isolation(ProtectedString),
224    /// Build metadata labels.
225    Labels(BuildSettingValues<ImageArtifactAssignment>),
226    /// Build network selection.
227    Network(ProtectedString),
228    /// Explicit no-cache choice.
229    NoCache(bool),
230    /// No-cache filters.
231    NoCacheFilters(BuildSettingValues<ProtectedString>),
232    /// Build platforms.
233    Platforms(BuildSettingValues<ProtectedString>),
234    /// Explicit privileged choice.
235    Privileged(bool),
236    /// Provenance attestation.
237    Provenance(BuildAttestation),
238    /// Explicit source-side pull choice.
239    Pull(bool),
240    /// SBOM attestation.
241    Sbom(BuildAttestation),
242    /// Source build secrets.
243    Secrets(BuildSettingValues<SourceBuildSecret>),
244    /// Shared-memory size spelling.
245    ShmSize(ProtectedString),
246    /// SSH declarations.
247    Ssh(BuildSettingValues<ProtectedString>),
248    /// Image tags.
249    Tags(BuildSettingValues<ProtectedString>),
250    /// Recipe target.
251    Target(ProtectedString),
252    /// Resource limits.
253    Ulimits(BuildSettingValues<ResourceLimit>),
254}
255
256/// One source declaration for an image build.
257///
258/// A scalar context is not silently expanded into a structured declaration. Structured declarations
259/// retain field order, duplicate fields, and explicitly present empty per-key collections.
260#[derive(Clone, Debug, Eq, PartialEq)]
261#[non_exhaustive]
262pub enum BuildSourceDeclaration {
263    /// Short scalar context syntax.
264    Scalar(ProtectedString),
265    /// Structured declaration syntax.
266    Structured(Vec<Sourced<SourceBuildSetting>>),
267}
268
269impl BuildSourceDeclaration {
270    /// Returns the preserved source syntax family.
271    #[must_use]
272    pub const fn syntax(&self) -> BuildSyntax {
273        match self {
274            Self::Scalar(_) => BuildSyntax::Scalar,
275            Self::Structured(_) => BuildSyntax::Structured,
276        }
277    }
278
279    /// Returns structured settings in source order when this is a structured declaration.
280    #[must_use]
281    pub fn structured_settings(&self) -> Option<&[Sourced<SourceBuildSetting>]> {
282        match self {
283            Self::Scalar(_) => None,
284            Self::Structured(settings) => Some(settings),
285        }
286    }
287}
288
289/// One image-acquisition setting.
290///
291/// The variants cover the full currently supported acquisition surface while keeping every value
292/// target-independent: adapters decide which native key can encode a given setting.
293#[derive(Clone, Debug, Eq, PartialEq)]
294#[non_exhaustive]
295pub enum ImageAcquisitionSetting {
296    /// Artifact image source.
297    Image(ProtectedString),
298    /// Produced/acquired image tags.
299    ImageTags(BuildSettingValues<ProtectedString>),
300    /// Service-manager unit name.
301    ServiceName(ProtectedString),
302    /// Fetch all tags choice.
303    AllTags(bool),
304    /// Architecture selection.
305    Architecture(ProtectedString),
306    /// Authentication file.
307    AuthFile(ProtectedString),
308    /// Certificate directory.
309    CertificateDirectory(ProtectedString),
310    /// Containers configuration modules.
311    ContainersConfigModules(BuildSettingValues<ProtectedString>),
312    /// Credential text.
313    Credentials(ProtectedString),
314    /// Image decryption key.
315    DecryptionKey(ProtectedString),
316    /// Global runtime arguments.
317    GlobalArguments(BuildSettingValues<ProtectedString>),
318    /// Operating-system selection.
319    OperatingSystem(ProtectedString),
320}
321
322/// An image-acquisition resource.
323#[derive(Clone, Debug, Eq, PartialEq)]
324pub struct ImageAcquisition {
325    name: Identifier,
326    settings: Option<Vec<Sourced<ImageAcquisitionSetting>>>,
327    settings_origins: Vec<Provenance>,
328}
329
330impl ImageAcquisition {
331    /// Creates an empty acquisition resource.
332    #[must_use]
333    pub const fn new(name: Identifier) -> Self {
334        Self {
335            name,
336            settings: None,
337            settings_origins: Vec::new(),
338        }
339    }
340
341    /// Returns the neutral resource name.
342    #[must_use]
343    pub const fn name(&self) -> &Identifier {
344        &self.name
345    }
346
347    /// Sets acquisition settings, retaining explicit emptiness separately from omission.
348    pub fn set_settings(&mut self, settings: Vec<Sourced<ImageAcquisitionSetting>>) {
349        self.settings = Some(settings);
350        self.settings_origins.clear();
351    }
352
353    /// Sets acquisition settings and their collection-level provenance.
354    pub fn set_settings_with_origins(
355        &mut self,
356        settings: Vec<Sourced<ImageAcquisitionSetting>>,
357        origins: Vec<Provenance>,
358    ) {
359        self.settings = Some(settings);
360        self.settings_origins = origins;
361    }
362
363    /// Returns settings in source order, if explicitly present.
364    #[must_use]
365    pub fn settings(&self) -> Option<&[Sourced<ImageAcquisitionSetting>]> {
366        self.settings.as_deref()
367    }
368
369    /// Returns acquisition collection provenance.
370    #[must_use]
371    pub fn settings_origins(&self) -> &[Provenance] {
372        &self.settings_origins
373    }
374}
375
376/// One image-build setting.
377///
378/// Fields that look similar to [`SourceBuildSetting`] remain separate: only an adapter with
379/// target evidence may establish an exact or non-exact mapping between them.
380#[derive(Clone, Debug, Eq, PartialEq)]
381#[non_exhaustive]
382pub enum ImageBuildSetting {
383    /// Produced image tags.
384    ImageTags(BuildSettingValues<ProtectedString>),
385    /// Build network selection.
386    Network(ProtectedString),
387    /// Build labels.
388    Labels(BuildSettingValues<ImageArtifactAssignment>),
389    /// Build recipe file.
390    RecipeFile(ProtectedString),
391    /// Working-directory behavior spelling.
392    SetWorkingDirectory(ProtectedString),
393    /// Build recipe target.
394    Target(ProtectedString),
395    /// Native build arguments.
396    BuildArguments(BuildSettingValues<ImageArtifactAssignment>),
397    /// Native build-secret declarations.
398    Secrets(BuildSettingValues<ProtectedString>),
399    /// Architecture selection.
400    Architecture(ProtectedString),
401    /// Architecture variant selection.
402    Variant(ProtectedString),
403    /// Native pull-policy spelling.
404    PullPolicy(ProtectedString),
405    /// Retry-count spelling.
406    Retry(ProtectedString),
407    /// Retry-delay spelling.
408    RetryDelay(ProtectedString),
409    /// TLS verification choice.
410    TlsVerify(bool),
411    /// Remove intermediate artifacts choice.
412    ForceRemove(bool),
413    /// Authentication file.
414    AuthFile(ProtectedString),
415    /// Ignore-file spelling.
416    IgnoreFile(ProtectedString),
417    /// Service-manager unit name.
418    ServiceName(ProtectedString),
419    /// Supplemental group additions.
420    GroupAdd(BuildSettingValues<ProtectedString>),
421    /// DNS servers.
422    DnsServers(BuildSettingValues<ProtectedString>),
423    /// DNS resolver options.
424    DnsOptions(BuildSettingValues<ProtectedString>),
425    /// DNS search domains.
426    DnsSearchDomains(BuildSettingValues<ProtectedString>),
427    /// Build annotations.
428    Annotations(BuildSettingValues<ImageArtifactAssignment>),
429    /// Build environment assignments.
430    Environment(BuildSettingValues<ImageArtifactAssignment>),
431    /// Containers configuration modules.
432    ContainersConfigModules(BuildSettingValues<ProtectedString>),
433    /// Global runtime arguments.
434    GlobalArguments(BuildSettingValues<ProtectedString>),
435    /// Build volume declarations.
436    Volumes(BuildSettingValues<ProtectedString>),
437    /// Runtime-specific build arguments.
438    RuntimeArguments(BuildSettingValues<ProtectedString>),
439}
440
441/// An image-build resource with independently retained source declaration and artifact settings.
442#[derive(Clone, Debug, Eq, PartialEq)]
443pub struct ImageBuild {
444    name: Identifier,
445    source_declaration: Option<Sourced<BuildSourceDeclaration>>,
446    settings: Option<Vec<Sourced<ImageBuildSetting>>>,
447    settings_origins: Vec<Provenance>,
448}
449
450impl ImageBuild {
451    /// Creates an empty image-build resource.
452    #[must_use]
453    pub const fn new(name: Identifier) -> Self {
454        Self {
455            name,
456            source_declaration: None,
457            settings: None,
458            settings_origins: Vec::new(),
459        }
460    }
461
462    /// Returns the neutral build-resource name.
463    #[must_use]
464    pub const fn name(&self) -> &Identifier {
465        &self.name
466    }
467
468    /// Sets the source declaration without conflating scalar and structured syntax.
469    pub fn set_source_declaration(&mut self, declaration: Sourced<BuildSourceDeclaration>) {
470        self.source_declaration = Some(declaration);
471    }
472
473    /// Returns the optional source declaration.
474    #[must_use]
475    pub const fn source_declaration(&self) -> Option<&Sourced<BuildSourceDeclaration>> {
476        self.source_declaration.as_ref()
477    }
478
479    /// Sets artifact settings, retaining explicit emptiness separately from omission.
480    pub fn set_settings(&mut self, settings: Vec<Sourced<ImageBuildSetting>>) {
481        self.settings = Some(settings);
482        self.settings_origins.clear();
483    }
484
485    /// Sets artifact settings and their collection-level provenance.
486    pub fn set_settings_with_origins(&mut self, settings: Vec<Sourced<ImageBuildSetting>>, origins: Vec<Provenance>) {
487        self.settings = Some(settings);
488        self.settings_origins = origins;
489    }
490
491    /// Returns artifact settings in source order, if explicitly present.
492    #[must_use]
493    pub fn settings(&self) -> Option<&[Sourced<ImageBuildSetting>]> {
494        self.settings.as_deref()
495    }
496
497    /// Returns artifact-setting collection provenance.
498    #[must_use]
499    pub fn settings_origins(&self) -> &[Provenance] {
500        &self.settings_origins
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::{
507        BuildSettingValues, BuildSourceDeclaration, BuildSyntax, ImageAcquisition, ImageAcquisitionSetting,
508        ImageArtifactAssignment, ImageBuild, ImageBuildSetting, SourceBuildSetting,
509    };
510    use crate::{Identifier, ProtectedString, Provenance, SourceId, Sourced};
511
512    fn origin() -> Result<Provenance, String> {
513        SourceId::new("build.yaml")
514            .map(Provenance::source)
515            .map_err(|error| error.to_string())
516    }
517
518    #[test]
519    fn native_text_settings_preserve_working_directory_and_pull_policy() -> Result<(), String> {
520        let origin = origin()?;
521        let mut build = ImageBuild::new(Identifier::new("web-build").map_err(|error| error.to_string())?);
522        build.set_settings(vec![
523            Sourced::from_source(
524                ImageBuildSetting::SetWorkingDirectory(ProtectedString::plain("unit")),
525                origin.clone(),
526            ),
527            Sourced::from_source(ImageBuildSetting::PullPolicy(ProtectedString::plain("newer")), origin),
528        ]);
529        let settings = build.settings().ok_or("explicit settings")?;
530        assert!(matches!(
531            settings[0].value(),
532            ImageBuildSetting::SetWorkingDirectory(value) if value.expose() == "unit"
533        ));
534        assert!(matches!(
535            settings[1].value(),
536            ImageBuildSetting::PullPolicy(value) if value.expose() == "newer"
537        ));
538        Ok(())
539    }
540
541    #[test]
542    fn per_key_empty_reset_is_distinct_from_omission_and_retains_order_and_provenance() -> Result<(), String> {
543        let origin = origin()?;
544        let mut build = ImageBuild::new(Identifier::new("web-build").map_err(|error| error.to_string())?);
545        assert_eq!(build.source_declaration(), None);
546        build.set_source_declaration(Sourced::from_source(
547            BuildSourceDeclaration::Structured(vec![
548                Sourced::from_source(
549                    SourceBuildSetting::Tags(BuildSettingValues::new(BuildSyntax::Sequence, Vec::new())),
550                    origin.clone(),
551                ),
552                Sourced::from_source(
553                    SourceBuildSetting::Arguments(BuildSettingValues::new(BuildSyntax::Mapping, Vec::new())),
554                    origin.clone(),
555                ),
556            ]),
557            origin.clone(),
558        ));
559        let declaration = build.source_declaration().ok_or("structured declaration")?;
560        let settings = declaration.value().structured_settings().ok_or("structured settings")?;
561        assert_eq!(settings.len(), 2);
562        assert!(matches!(
563            settings[0].value(),
564            SourceBuildSetting::Tags(values) if values.values().is_empty() && values.syntax() == BuildSyntax::Sequence
565        ));
566        assert!(matches!(
567            settings[1].value(),
568            SourceBuildSetting::Arguments(values) if values.values().is_empty() && values.syntax() == BuildSyntax::Mapping
569        ));
570        assert_eq!(settings[0].origins(), std::slice::from_ref(&origin));
571        Ok(())
572    }
573
574    #[test]
575    fn scalar_and_structured_source_declarations_remain_distinct() -> Result<(), String> {
576        let origin = origin()?;
577        let mut build = ImageBuild::new(Identifier::new("web-build").map_err(|error| error.to_string())?);
578        build.set_source_declaration(Sourced::from_source(
579            BuildSourceDeclaration::Scalar(ProtectedString::plain("./web")),
580            origin,
581        ));
582        assert_eq!(
583            build
584                .source_declaration()
585                .map(|declaration| declaration.value().syntax()),
586            Some(BuildSyntax::Scalar)
587        );
588        assert_eq!(
589            build
590                .source_declaration()
591                .and_then(|declaration| declaration.value().structured_settings()),
592            None
593        );
594        Ok(())
595    }
596
597    #[test]
598    fn repeated_acquisition_values_and_sensitive_settings_retain_duplicates_and_redact() -> Result<(), String> {
599        let origin = origin()?;
600        let mut acquisition = ImageAcquisition::new(Identifier::new("base-image").map_err(|error| error.to_string())?);
601        acquisition.set_settings_with_origins(
602            vec![
603                Sourced::from_source(
604                    ImageAcquisitionSetting::ImageTags(BuildSettingValues::new(
605                        BuildSyntax::Repeated,
606                        vec![
607                            Sourced::from_source(ProtectedString::plain("web:one"), origin.clone()),
608                            Sourced::from_source(ProtectedString::plain("web:one"), origin.clone()),
609                        ],
610                    )),
611                    origin.clone(),
612                ),
613                Sourced::from_source(
614                    ImageAcquisitionSetting::Credentials(ProtectedString::sensitive("operator:secret")),
615                    origin.clone(),
616                ),
617            ],
618            vec![origin.clone()],
619        );
620        let settings = acquisition.settings().ok_or("explicit settings")?;
621        assert!(matches!(
622            settings[0].value(),
623            ImageAcquisitionSetting::ImageTags(values) if values.values().len() == 2 && values.values()[0] == values.values()[1]
624        ));
625        assert_eq!(acquisition.settings_origins(), std::slice::from_ref(&origin));
626        let assignment = ImageArtifactAssignment::new(
627            ProtectedString::plain("TOKEN"),
628            Some(ProtectedString::sensitive("build-secret")),
629        );
630        let target_secrets = ImageBuildSetting::Secrets(BuildSettingValues::new(
631            BuildSyntax::Repeated,
632            vec![Sourced::from_source(
633                ProtectedString::sensitive("target-build-secret"),
634                origin.clone(),
635            )],
636        ));
637        let source_ssh = SourceBuildSetting::Ssh(BuildSettingValues::new(
638            BuildSyntax::Sequence,
639            vec![Sourced::from_source(
640                ProtectedString::sensitive("ssh-agent-secret"),
641                origin.clone(),
642            )],
643        ));
644        let auth = ImageAcquisitionSetting::AuthFile(ProtectedString::sensitive("auth-file-secret"));
645        let decryption = ImageAcquisitionSetting::DecryptionKey(ProtectedString::sensitive("decryption-secret"));
646        let debug = format!("{acquisition:?} {assignment:?} {target_secrets:?} {source_ssh:?} {auth:?} {decryption:?}");
647        for secret in [
648            "operator:secret",
649            "build-secret",
650            "target-build-secret",
651            "ssh-agent-secret",
652            "auth-file-secret",
653            "decryption-secret",
654        ] {
655            assert!(!debug.contains(secret));
656        }
657        assert!(debug.contains("[REDACTED]"));
658        Ok(())
659    }
660
661    #[test]
662    fn malformed_or_empty_assignments_are_retained_for_adapter_diagnostics() {
663        let assignment = ImageArtifactAssignment::new(ProtectedString::plain(""), Some(ProtectedString::plain("")));
664        assert_eq!(assignment.name().expose(), "");
665        assert_eq!(assignment.value().map(ProtectedString::expose), Some(""));
666    }
667}