oci_spec/image/
config.rs

1use super::{Arch, Os};
2use crate::{
3    error::{OciSpecError, Result},
4    from_file, from_reader, to_file, to_string, to_writer,
5};
6use derive_builder::Builder;
7use getset::{CopyGetters, Getters, MutGetters, Setters};
8use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer};
9#[cfg(test)]
10use std::collections::BTreeMap;
11use std::{
12    collections::HashMap,
13    fmt::Display,
14    io::{Read, Write},
15    path::Path,
16};
17
18/// In theory, this key is not standard.  In practice, it's used by at least the
19/// RHEL UBI images for a long time.
20pub const LABEL_VERSION: &str = "version";
21
22#[derive(
23    Builder,
24    Clone,
25    Debug,
26    Default,
27    Deserialize,
28    Eq,
29    Getters,
30    MutGetters,
31    Setters,
32    PartialEq,
33    Serialize,
34)]
35#[builder(
36    default,
37    pattern = "owned",
38    setter(into, strip_option),
39    build_fn(error = "OciSpecError")
40)]
41#[getset(get = "pub", set = "pub")]
42/// The image configuration is associated with an image and describes some
43/// basic information about the image such as date created, author, as
44/// well as execution/runtime configuration like its entrypoint, default
45/// arguments, networking, and volumes.
46pub struct ImageConfiguration {
47    /// An combined date and time at which the image was created,
48    /// formatted as defined by [RFC 3339, section 5.6.](https://tools.ietf.org/html/rfc3339#section-5.6)
49    #[serde(skip_serializing_if = "Option::is_none")]
50    created: Option<String>,
51    /// Gives the name and/or email address of the person or entity
52    /// which created and is responsible for maintaining the image.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    author: Option<String>,
55    /// The CPU architecture which the binaries in this
56    /// image are built to run on. Configurations SHOULD use, and
57    /// implementations SHOULD understand, values listed in the Go
58    /// Language document for [GOARCH](https://golang.org/doc/install/source#environment).
59    architecture: Arch,
60    /// The name of the operating system which the image is built to run on.
61    /// Configurations SHOULD use, and implementations SHOULD understand,
62    /// values listed in the Go Language document for [GOOS](https://golang.org/doc/install/source#environment).
63    os: Os,
64    /// This OPTIONAL property specifies the version of the operating
65    /// system targeted by the referenced blob. Implementations MAY refuse
66    /// to use manifests where os.version is not known to work with
67    /// the host OS version. Valid values are
68    /// implementation-defined. e.g. 10.0.14393.1066 on windows.
69    #[serde(rename = "os.version", skip_serializing_if = "Option::is_none")]
70    os_version: Option<String>,
71    /// This OPTIONAL property specifies an array of strings,
72    /// each specifying a mandatory OS feature. When os is windows, image
73    /// indexes SHOULD use, and implementations SHOULD understand
74    /// the following values:
75    /// - win32k: image requires win32k.sys on the host (Note: win32k.sys is
76    ///   missing on Nano Server)
77    #[serde(rename = "os.features", skip_serializing_if = "Option::is_none")]
78    os_features: Option<Vec<String>>,
79    /// The variant of the specified CPU architecture. Configurations SHOULD
80    /// use, and implementations SHOULD understand, variant values
81    /// listed in the [Platform Variants](https://github.com/opencontainers/image-spec/blob/main/image-index.md#platform-variants) table.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    variant: Option<String>,
84    /// The execution parameters which SHOULD be used as a base when
85    /// running a container using the image. This field can be None, in
86    /// which case any execution parameters should be specified at
87    /// creation of the container.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    config: Option<Config>,
90    /// The rootfs key references the layer content addresses used by the
91    /// image. This makes the image config hash depend on the
92    /// filesystem hash.
93    #[getset(get_mut = "pub", get = "pub", set = "pub")]
94    rootfs: RootFs,
95    /// Describes the history of each layer. The array is ordered from first
96    /// to last.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    #[getset(get_mut = "pub", get = "pub", set = "pub")]
99    history: Option<Vec<History>>,
100}
101
102impl ImageConfiguration {
103    /// Attempts to load an image configuration from a file.
104    /// # Errors
105    /// This function will return an [OciSpecError::Io](crate::OciSpecError::Io)
106    /// if the file does not exist or an
107    /// [OciSpecError::SerDe](crate::OciSpecError::SerDe) if the image configuration
108    /// cannot be deserialized.
109    /// # Example
110    /// ``` no_run
111    /// use oci_spec::image::ImageConfiguration;
112    ///
113    /// let image_index = ImageConfiguration::from_file("config.json").unwrap();
114    /// ```
115    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<ImageConfiguration> {
116        from_file(path)
117    }
118
119    /// Attempts to load an image configuration from a stream.
120    /// # Errors
121    /// This function will return an [OciSpecError::SerDe](crate::OciSpecError::SerDe)
122    /// if the image configuration cannot be deserialized.
123    /// # Example
124    /// ``` no_run
125    /// use oci_spec::image::ImageConfiguration;
126    /// use std::fs::File;
127    ///
128    /// let reader = File::open("config.json").unwrap();
129    /// let image_index = ImageConfiguration::from_reader(reader).unwrap();
130    /// ```
131    pub fn from_reader<R: Read>(reader: R) -> Result<ImageConfiguration> {
132        from_reader(reader)
133    }
134
135    /// Attempts to write an image configuration to a file as JSON. If the file already exists, it
136    /// will be overwritten.
137    /// # Errors
138    /// This function will return an [OciSpecError::SerDe](crate::OciSpecError::SerDe) if
139    /// the image configuration cannot be serialized.
140    /// # Example
141    /// ``` no_run
142    /// use oci_spec::image::ImageConfiguration;
143    ///
144    /// let image_index = ImageConfiguration::from_file("config.json").unwrap();
145    /// image_index.to_file("my-config.json").unwrap();
146    /// ```
147    pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
148        to_file(&self, path, false)
149    }
150
151    /// Attempts to write an image configuration to a file as pretty printed JSON. If the file
152    /// already exists, it will be overwritten.
153    /// # Errors
154    /// This function will return an [OciSpecError::SerDe](crate::OciSpecError::SerDe) if
155    /// the image configuration cannot be serialized.
156    /// # Example
157    /// ``` no_run
158    /// use oci_spec::image::ImageConfiguration;
159    ///
160    /// let image_index = ImageConfiguration::from_file("config.json").unwrap();
161    /// image_index.to_file_pretty("my-config.json").unwrap();
162    /// ```
163    pub fn to_file_pretty<P: AsRef<Path>>(&self, path: P) -> Result<()> {
164        to_file(&self, path, true)
165    }
166
167    /// Attempts to write an image configuration to a stream as JSON.
168    /// # Errors
169    /// This function will return an [OciSpecError::SerDe](crate::OciSpecError::SerDe) if
170    /// the image configuration cannot be serialized.
171    /// # Example
172    /// ``` no_run
173    /// use oci_spec::image::ImageConfiguration;
174    ///
175    /// let image_index = ImageConfiguration::from_file("config.json").unwrap();
176    /// let mut writer = Vec::new();
177    /// image_index.to_writer(&mut writer);
178    /// ```
179    pub fn to_writer<W: Write>(&self, writer: &mut W) -> Result<()> {
180        to_writer(&self, writer, false)
181    }
182
183    /// Attempts to write an image configuration to a stream as pretty printed JSON.
184    /// # Errors
185    /// This function will return an [OciSpecError::SerDe](crate::OciSpecError::SerDe) if
186    /// the image configuration cannot be serialized.
187    /// # Example
188    /// ``` no_run
189    /// use oci_spec::image::ImageConfiguration;
190    ///
191    /// let image_index = ImageConfiguration::from_file("config.json").unwrap();
192    /// let mut writer = Vec::new();
193    /// image_index.to_writer_pretty(&mut writer);
194    /// ```
195    pub fn to_writer_pretty<W: Write>(&self, writer: &mut W) -> Result<()> {
196        to_writer(&self, writer, true)
197    }
198
199    /// Attempts to write an image configuration to a string as JSON.
200    /// # Errors
201    /// This function will return an [OciSpecError::SerDe](crate::OciSpecError::SerDe) if
202    /// the image configuration cannot be serialized.
203    /// # Example
204    /// ``` no_run
205    /// use oci_spec::image::ImageConfiguration;
206    ///
207    /// let image_configuration = ImageConfiguration::from_file("config.json").unwrap();
208    /// let json_str = image_configuration.to_string().unwrap();
209    /// ```
210    pub fn to_string(&self) -> Result<String> {
211        to_string(&self, false)
212    }
213
214    /// Attempts to write an image configuration to a string as pretty printed JSON.
215    /// # Errors
216    /// This function will return an [OciSpecError::SerDe](crate::OciSpecError::SerDe) if
217    /// the image configuration cannot be serialized.
218    /// # Example
219    /// ``` no_run
220    /// use oci_spec::image::ImageConfiguration;
221    ///
222    /// let image_configuration = ImageConfiguration::from_file("config.json").unwrap();
223    /// let json_str = image_configuration.to_string_pretty().unwrap();
224    /// ```
225    pub fn to_string_pretty(&self) -> Result<String> {
226        to_string(&self, true)
227    }
228
229    /// Extract the labels of the configuration, if present.
230    pub fn labels_of_config(&self) -> Option<&HashMap<String, String>> {
231        self.config().as_ref().and_then(|c| c.labels().as_ref())
232    }
233
234    /// Retrieve the version number associated with this configuration.  This will try
235    /// to use several well-known label keys.
236    pub fn version(&self) -> Option<&str> {
237        let labels = self.labels_of_config();
238        if let Some(labels) = labels {
239            for k in [super::ANNOTATION_VERSION, LABEL_VERSION] {
240                if let Some(v) = labels.get(k) {
241                    return Some(v.as_str());
242                }
243            }
244        }
245        None
246    }
247
248    /// Extract the value of a given annotation on the configuration, if present.
249    pub fn get_config_annotation(&self, key: &str) -> Option<&str> {
250        self.labels_of_config()
251            .and_then(|v| v.get(key).map(|s| s.as_str()))
252    }
253}
254
255/// This ToString trait is automatically implemented for any type which implements the Display trait.
256/// As such, ToString shouldn’t be implemented directly: Display should be implemented instead,
257/// and you get the ToString implementation for free.
258impl Display for ImageConfiguration {
259    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260        // Serde serialization never fails since this is
261        // a combination of String and enums.
262        write!(
263            f,
264            "{}",
265            self.to_string_pretty()
266                .expect("ImageConfiguration JSON conversion failed")
267        )
268    }
269}
270
271#[derive(
272    Builder,
273    Clone,
274    Debug,
275    Default,
276    Deserialize,
277    Eq,
278    Getters,
279    MutGetters,
280    Setters,
281    PartialEq,
282    Serialize,
283)]
284#[serde(rename_all = "PascalCase")]
285#[builder(
286    default,
287    pattern = "owned",
288    setter(into, strip_option),
289    build_fn(error = "OciSpecError")
290)]
291#[getset(get = "pub", set = "pub")]
292/// The execution parameters which SHOULD be used as a base when
293/// running a container using the image.
294pub struct Config {
295    /// The username or UID which is a platform-specific
296    /// structure that allows specific control over which
297    /// user the process run as. This acts as a default
298    /// value to use when the value is not specified when
299    /// creating a container. For Linux based systems, all
300    /// of the following are valid: user, uid, user:group,
301    /// uid:gid, uid:group, user:gid. If group/gid is not
302    /// specified, the default group and supplementary
303    /// groups of the given user/uid in /etc/passwd from
304    /// the container are applied.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    user: Option<String>,
307    /// A set of ports to expose from a container running this
308    /// image. Its keys can be in the format of: port/tcp, port/udp,
309    /// port with the default protocol being tcp if not specified.
310    /// These values act as defaults and are merged with any
311    /// specified when creating a container.
312    #[serde(
313        default,
314        skip_serializing_if = "Option::is_none",
315        deserialize_with = "deserialize_as_vec",
316        serialize_with = "serialize_as_map"
317    )]
318    exposed_ports: Option<Vec<String>>,
319    /// Entries are in the format of VARNAME=VARVALUE. These
320    /// values act as defaults and are merged with any
321    /// specified when creating a container.
322    #[serde(skip_serializing_if = "Option::is_none")]
323    env: Option<Vec<String>>,
324    /// A list of arguments to use as the command to execute
325    /// when the container starts. These values act as defaults
326    /// and may be replaced by an entrypoint specified when
327    /// creating a container.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    entrypoint: Option<Vec<String>>,
330    /// Default arguments to the entrypoint of the container.
331    /// These values act as defaults and may be replaced by any
332    /// specified when creating a container. If an Entrypoint
333    /// value is not specified, then the first entry of the Cmd
334    /// array SHOULD be interpreted as the executable to run.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    cmd: Option<Vec<String>>,
337    /// A set of directories describing where the process is
338    /// likely to write data specific to a container instance.
339    #[serde(
340        default,
341        skip_serializing_if = "Option::is_none",
342        deserialize_with = "deserialize_as_vec",
343        serialize_with = "serialize_as_map"
344    )]
345    volumes: Option<Vec<String>>,
346    /// Sets the current working directory of the entrypoint process
347    /// in the container. This value acts as a default and may be
348    /// replaced by a working directory specified when creating
349    /// a container.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    working_dir: Option<String>,
352    /// The field contains arbitrary metadata for the container.
353    /// This property MUST use the annotation rules.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    #[getset(get_mut = "pub", get = "pub", set = "pub")]
356    labels: Option<HashMap<String, String>>,
357    /// The field contains the system call signal that will be
358    /// sent to the container to exit. The signal can be a signal
359    /// name in the format SIGNAME, for instance SIGKILL or SIGRTMIN+3.
360    #[serde(skip_serializing_if = "Option::is_none")]
361    stop_signal: Option<String>,
362}
363
364// Some fields of the image configuration are a json serialization of a
365// Go map[string]struct{} leading to the following json:
366// {
367//    "ExposedPorts": {
368//       "8080/tcp": {},
369//       "443/tcp": {},
370//    }
371// }
372// Instead we treat this as a list
373#[derive(Deserialize, Serialize)]
374struct GoMapSerde {}
375
376fn deserialize_as_vec<'de, D>(deserializer: D) -> std::result::Result<Option<Vec<String>>, D::Error>
377where
378    D: Deserializer<'de>,
379{
380    // ensure stable order of keys in json document for comparison between expected and actual
381    #[cfg(test)]
382    let opt = Option::<BTreeMap<String, GoMapSerde>>::deserialize(deserializer)?;
383    #[cfg(not(test))]
384    let opt = Option::<HashMap<String, GoMapSerde>>::deserialize(deserializer)?;
385
386    if let Some(data) = opt {
387        let vec: Vec<String> = data.keys().cloned().collect();
388        return Ok(Some(vec));
389    }
390
391    Ok(None)
392}
393
394fn serialize_as_map<S>(
395    target: &Option<Vec<String>>,
396    serializer: S,
397) -> std::result::Result<S::Ok, S::Error>
398where
399    S: Serializer,
400{
401    match target {
402        Some(values) => {
403            // ensure stable order of keys in json document for comparison between expected and actual
404            #[cfg(test)]
405            let map: BTreeMap<_, _> = values.iter().map(|v| (v, GoMapSerde {})).collect();
406            #[cfg(not(test))]
407            let map: HashMap<_, _> = values.iter().map(|v| (v, GoMapSerde {})).collect();
408
409            let mut map_ser = serializer.serialize_map(Some(map.len()))?;
410            for (key, value) in map {
411                map_ser.serialize_entry(key, &value)?;
412            }
413            map_ser.end()
414        }
415        _ => unreachable!(),
416    }
417}
418
419#[derive(
420    Builder, Clone, Debug, Deserialize, Eq, Getters, MutGetters, Setters, PartialEq, Serialize,
421)]
422#[builder(
423    default,
424    pattern = "owned",
425    setter(into, strip_option),
426    build_fn(error = "OciSpecError")
427)]
428#[getset(get = "pub", set = "pub")]
429/// RootFs references the layer content addresses used by the image.
430pub struct RootFs {
431    /// MUST be set to layers.
432    #[serde(rename = "type")]
433    typ: String,
434    /// An array of layer content hashes (DiffIDs), in order
435    /// from first to last.
436    #[getset(get_mut = "pub", get = "pub", set = "pub")]
437    diff_ids: Vec<String>,
438}
439
440impl Default for RootFs {
441    fn default() -> Self {
442        Self {
443            typ: "layers".to_owned(),
444            diff_ids: Default::default(),
445        }
446    }
447}
448
449#[derive(
450    Builder,
451    Clone,
452    Debug,
453    Default,
454    Deserialize,
455    Eq,
456    CopyGetters,
457    Getters,
458    Setters,
459    PartialEq,
460    Serialize,
461)]
462#[builder(
463    default,
464    pattern = "owned",
465    setter(into, strip_option),
466    build_fn(error = "OciSpecError")
467)]
468/// Describes the history of a layer.
469pub struct History {
470    /// A combined date and time at which the layer was created,
471    /// formatted as defined by [RFC 3339, section 5.6.](https://tools.ietf.org/html/rfc3339#section-5.6).
472    #[serde(skip_serializing_if = "Option::is_none")]
473    #[getset(get = "pub", set = "pub")]
474    created: Option<String>,
475    /// The author of the build point.
476    #[serde(skip_serializing_if = "Option::is_none")]
477    #[getset(get = "pub", set = "pub")]
478    author: Option<String>,
479    /// The command which created the layer.
480    #[serde(skip_serializing_if = "Option::is_none")]
481    #[getset(get = "pub", set = "pub")]
482    created_by: Option<String>,
483    /// A custom message set when creating the layer.
484    #[serde(skip_serializing_if = "Option::is_none")]
485    #[getset(get = "pub", set = "pub")]
486    comment: Option<String>,
487    /// This field is used to mark if the history item created
488    /// a filesystem diff. It is set to true if this history item
489    /// doesn't correspond to an actual layer in the rootfs section
490    #[serde(skip_serializing_if = "Option::is_none")]
491    #[getset(get_copy = "pub", set = "pub")]
492    empty_layer: Option<bool>,
493}
494
495#[cfg(test)]
496mod tests {
497    use std::{fs, path::PathBuf};
498
499    use super::*;
500    use crate::image::{ANNOTATION_CREATED, ANNOTATION_VERSION};
501
502    fn create_base_config() -> ConfigBuilder {
503        ConfigBuilder::default()
504            .user("alice".to_owned())
505            .exposed_ports(vec!["8080/tcp".to_owned()])
506            .env(vec![
507                "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_owned(),
508                "FOO=oci_is_a".to_owned(),
509                "BAR=well_written_spec".to_owned(),
510            ])
511            .entrypoint(vec!["/bin/my-app-binary".to_owned()])
512            .cmd(vec![
513                "--foreground".to_owned(),
514                "--config".to_owned(),
515                "/etc/my-app.d/default.cfg".to_owned(),
516            ])
517            .volumes(vec![
518                "/var/job-result-data".to_owned(),
519                "/var/log/my-app-logs".to_owned(),
520            ])
521            .working_dir("/home/alice".to_owned())
522    }
523
524    fn create_base_imgconfig(conf: Config) -> ImageConfigurationBuilder {
525        ImageConfigurationBuilder::default()
526            .created("2015-10-31T22:22:56.015925234Z".to_owned())
527            .author("Alyssa P. Hacker <alyspdev@example.com>".to_owned())
528            .architecture(Arch::Amd64)
529            .os(Os::Linux)
530            .config(conf
531            )
532            .rootfs(RootFsBuilder::default()
533            .diff_ids(vec![
534                "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1".to_owned(),
535                "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef".to_owned(),
536            ])
537            .build()
538            .expect("build rootfs"))
539            .history(vec![
540                HistoryBuilder::default()
541                .created("2015-10-31T22:22:54.690851953Z".to_owned())
542                .created_by("/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /".to_owned())
543                .build()
544                .expect("build history"),
545                HistoryBuilder::default()
546                .created("2015-10-31T22:22:55.613815829Z".to_owned())
547                .created_by("/bin/sh -c #(nop) CMD [\"sh\"]".to_owned())
548                .empty_layer(true)
549                .build()
550                .expect("build history"),
551            ])
552    }
553
554    fn create_config() -> ImageConfiguration {
555        create_base_imgconfig(create_base_config().build().expect("config"))
556            .build()
557            .expect("build configuration")
558    }
559
560    /// A config with some additions (labels)
561    fn create_imgconfig_v1() -> ImageConfiguration {
562        let labels = [
563            (ANNOTATION_CREATED, "2023-09-16T19:22:18.014Z"),
564            (ANNOTATION_VERSION, "42.27"),
565        ]
566        .into_iter()
567        .map(|(k, v)| (k.to_owned(), v.to_owned()));
568        let config = create_base_config()
569            .labels(labels.collect::<HashMap<_, _>>())
570            .build()
571            .unwrap();
572        create_base_imgconfig(config).build().unwrap()
573    }
574
575    fn get_config_path() -> PathBuf {
576        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test/data/config.json")
577    }
578
579    #[test]
580    fn load_configuration_from_file() {
581        // arrange
582        let config_path = get_config_path();
583        let expected = create_config();
584
585        // act
586        let actual = ImageConfiguration::from_file(config_path).expect("from file");
587
588        // assert
589        assert_eq!(actual, expected);
590    }
591
592    #[test]
593    fn test_helpers() {
594        let config = create_imgconfig_v1();
595        assert_eq!(config.labels_of_config().unwrap().len(), 2);
596        assert_eq!(
597            config.get_config_annotation(ANNOTATION_CREATED).unwrap(),
598            "2023-09-16T19:22:18.014Z"
599        );
600    }
601
602    #[test]
603    fn load_configuration_from_reader() {
604        // arrange
605        let reader = fs::read(get_config_path()).expect("read config");
606
607        // act
608        let actual = ImageConfiguration::from_reader(&*reader).expect("from reader");
609        println!("{actual:#?}");
610
611        // assert
612        let expected = create_config();
613        println!("{expected:#?}");
614
615        assert_eq!(actual, expected);
616    }
617
618    #[test]
619    fn save_config_to_file() {
620        // arrange
621        let tmp = std::env::temp_dir().join("save_config_to_file");
622        fs::create_dir_all(&tmp).expect("create test directory");
623        let config = create_config();
624        let config_path = tmp.join("config.json");
625
626        // act
627        config
628            .to_file_pretty(&config_path)
629            .expect("write config to file");
630
631        // assert
632        let actual = fs::read_to_string(config_path).expect("read actual");
633        let expected = fs::read_to_string(get_config_path()).expect("read expected");
634        assert_eq!(actual, expected);
635    }
636
637    #[test]
638    fn save_config_to_writer() {
639        // arrange
640        let config = create_config();
641        let mut actual = Vec::new();
642
643        // act
644        config.to_writer_pretty(&mut actual).expect("to writer");
645        let actual = String::from_utf8(actual).unwrap();
646
647        // assert
648        let expected = fs::read_to_string(get_config_path()).expect("read expected");
649        assert_eq!(actual, expected);
650    }
651
652    #[test]
653    fn save_config_to_string() {
654        // arrange
655        let config = create_config();
656
657        // act
658        let actual = config.to_string_pretty().expect("to string");
659
660        // assert
661        let expected = fs::read_to_string(get_config_path()).expect("read expected");
662        assert_eq!(actual, expected);
663    }
664
665    #[test]
666    fn optional_history_field_absent() {
667        let json = r#"{
668            "architecture": "amd64",
669            "os": "linux",
670            "rootfs": {
671                "type": "layers",
672                "diff_ids": ["sha256:abc123"]
673            }
674        }"#;
675
676        let config: ImageConfiguration =
677            serde_json::from_str(json).expect("deserialize without history");
678        assert!(config.history().is_none());
679    }
680
681    #[test]
682    fn serialize_without_history() {
683        let config = ImageConfigurationBuilder::default()
684            .architecture(Arch::Amd64)
685            .os(Os::Linux)
686            .rootfs(
687                RootFsBuilder::default()
688                    .diff_ids(vec!["sha256:abc123".to_owned()])
689                    .build()
690                    .expect("build rootfs"),
691            )
692            .build()
693            .expect("build config");
694
695        let json = config.to_string().expect("serialize");
696        assert!(!json.contains("history"));
697    }
698
699    #[test]
700    fn builder_without_history() {
701        let config = ImageConfigurationBuilder::default()
702            .architecture(Arch::Amd64)
703            .os(Os::Linux)
704            .rootfs(
705                RootFsBuilder::default()
706                    .diff_ids(vec!["sha256:abc123".to_owned()])
707                    .build()
708                    .expect("build rootfs"),
709            )
710            .build()
711            .expect("build config");
712
713        assert!(config.history().is_none());
714    }
715}