diff --git a/src/config.rs b/src/config.rs index 7b503c73..8c90acb8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -122,8 +122,16 @@ pub struct ConfigFile { pub rootfs: Rootfs, /// Describes the history of each layer. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub history: Vec, + #[serde(skip_serializing_if = "is_option_vec_empty")] + pub history: Option>, +} + +fn is_option_vec_empty(opt_vec: &Option>) -> bool { + if let Some(vec) = opt_vec { + vec.is_empty() + } else { + true + } } /// Helper struct to be serialized into and deserialized from `{}` @@ -131,25 +139,34 @@ pub struct ConfigFile { struct Empty {} /// Helper to deserialize a `map[string]struct{}` of golang -fn hashset_from_str<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { - let res = >::deserialize(d)? - .into_keys() - .collect(); +fn optional_hashset_from_str<'de, D: Deserializer<'de>>( + d: D, +) -> Result>, D::Error> { + let res = >>::deserialize(d)?.map(|h| h.into_keys().collect()); Ok(res) } -/// Helper to serialize a time -fn serialize_hashset(hashset: &HashSet, s: S) -> Result +/// Helper to serialize an optional hashset +fn serialize_optional_hashset( + value: &Option>, + serializer: S, +) -> Result where + T: Serialize, S: Serializer, { - let empty = Empty {}; - let mut map = s.serialize_map(Some(hashset.len()))?; - for k in hashset { - map.serialize_entry(k, &empty)?; - } + match value { + Some(set) => { + let empty = Empty {}; + let mut map = serializer.serialize_map(Some(set.len()))?; + for k in set { + map.serialize_entry(k, &empty)?; + } - map.end() + map.end() + } + None => serializer.serialize_none(), + } } /// The execution parameters which SHOULD be used as a base when running a container using the image. @@ -173,32 +190,34 @@ pub struct Config { /// These values act as defaults and are merged with any /// specified when creating a container. #[serde( - skip_serializing_if = "HashSet::is_empty", - deserialize_with = "hashset_from_str", - serialize_with = "serialize_hashset" + skip_serializing_if = "is_option_hashset_empty", + deserialize_with = "optional_hashset_from_str", + serialize_with = "serialize_optional_hashset", + default )] - pub exposed_ports: HashSet, + pub exposed_ports: Option>, /// Entries are in the format of `VARNAME=VARVALUE`. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub env: Vec, + #[serde(skip_serializing_if = "is_option_vec_empty")] + pub env: Option>, /// Default arguments to the entrypoint of the container. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub cmd: Vec, + #[serde(skip_serializing_if = "is_option_vec_empty")] + pub cmd: Option>, /// A list of arguments to use as the command to execute when /// the container starts.. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub entrypoint: Vec, + #[serde(skip_serializing_if = "is_option_vec_empty")] + pub entrypoint: Option>, /// A set of directories describing where the process is likely write data specific to a container instance. #[serde( - skip_serializing_if = "HashSet::is_empty", - deserialize_with = "hashset_from_str", - serialize_with = "serialize_hashset" + skip_serializing_if = "is_option_hashset_empty", + deserialize_with = "optional_hashset_from_str", + serialize_with = "serialize_optional_hashset", + default )] - pub volumes: HashSet, + pub volumes: Option>, /// Sets the current working directory of the entrypoint /// process in the container. @@ -207,8 +226,8 @@ pub struct Config { /// The field contains arbitrary metadata for the container. /// This property MUST use the [annotation rules](https://github.com/opencontainers/image-spec/blob/v1.0/annotations.md#rules). - #[serde(skip_serializing_if = "HashMap::is_empty")] - pub labels: HashMap, + #[serde(skip_serializing_if = "is_option_hashmap_empty")] + pub labels: Option>, /// The field contains the system call signal that will be sent /// to the container to exit. The signal can be a signal name @@ -217,6 +236,22 @@ pub struct Config { pub stop_signal: Option, } +fn is_option_hashset_empty(opt_hash: &Option>) -> bool { + if let Some(hash) = opt_hash { + hash.is_empty() + } else { + true + } +} + +fn is_option_hashmap_empty(opt_hash: &Option>) -> bool { + if let Some(hash) = opt_hash { + hash.is_empty() + } else { + true + } +} + /// Default value of the type of a [`Rootfs`] pub const ROOTFS_TYPE: &str = "layers"; @@ -271,11 +306,11 @@ pub struct History { #[cfg(test)] mod tests { - use std::collections::{HashMap, HashSet}; - use assert_json_diff::assert_json_eq; use chrono::{TimeZone, Utc}; + use rstest::*; use serde_json::Value; + use std::collections::{HashMap, HashSet}; use super::{Architecture, Config, ConfigFile, History, Os, Rootfs}; @@ -336,24 +371,24 @@ mod tests { fn example_config() -> ConfigFile { let config = Config { user: Some("alice".into()), - exposed_ports: HashSet::from_iter(vec!["8080/tcp".into()]), - env: vec![ + exposed_ports: Some(HashSet::from_iter(vec!["8080/tcp".into()])), + env: Some(vec![ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(), "FOO=oci_is_a".into(), "BAR=well_written_spec".into(), - ], - cmd: vec![ + ]), + cmd: Some(vec![ "--foreground".into(), "--config".into(), "/etc/my-app.d/default.cfg".into(), - ], - entrypoint: vec!["/bin/my-app-binary".into()], - volumes: HashSet::from_iter(vec![ + ]), + entrypoint: Some(vec!["/bin/my-app-binary".into()]), + volumes: Some(HashSet::from_iter(vec![ "/var/job-result-data".into(), "/var/log/my-app-logs".into(), - ]), + ])), working_dir: Some("/home/alice".into()), - labels: HashMap::from_iter(vec![ + labels: Some(HashMap::from_iter(vec![ ( "com.example.project.git.url".into(), "https://example.com/project.git".into(), @@ -362,7 +397,7 @@ mod tests { "com.example.project.git.commit".into(), "45a939b2999782a3f005621a8d0f29aa387e1d6b".into(), ), - ]), + ])), stop_signal: None, }; let rootfs = Rootfs { @@ -373,7 +408,7 @@ mod tests { ], }; - let history = vec![History { + let history = Some(vec![History { created: Some(Utc.datetime_from_str("2015-10-31T22:22:54.690851953Z", "%+").expect("parse time failed")), author: None, created_by: Some("/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /".into()), @@ -386,7 +421,7 @@ mod tests { created_by: Some("/bin/sh -c #(nop) CMD [\"sh\"]".into()), comment: None, empty_layer: Some(true), - }]; + }]); ConfigFile { created: Some( Utc.datetime_from_str("2015-10-31T22:22:56.015925234Z", "%+") @@ -401,17 +436,117 @@ mod tests { } } - #[test] - fn deserialize() { - let example = example_config(); - let parsed: ConfigFile = serde_json::from_str(EXAMPLE_CONFIG).expect("parsed failed"); - assert_eq!(example, parsed); + const MINIMAL_CONFIG: &str = r#" + { + "architecture": "amd64", + "os": "linux", + "rootfs": { + "diff_ids": [ + "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1", + "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef" + ], + "type": "layers" + } + }"#; + + fn minimal_config() -> ConfigFile { + let rootfs = Rootfs { + r#type: "layers".into(), + diff_ids: vec![ + "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1".into(), + "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef".into(), + ], + }; + + ConfigFile { + architecture: Architecture::Amd64, + os: Os::Linux, + config: None, + rootfs, + history: None, + created: None, + author: None, + } + } + + const MINIMAL_CONFIG2: &str = r#" + { + "architecture":"arm64", + "config":{ + "Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], + "WorkingDir":"/" + }, + "created":"2023-04-21T11:53:28.176613804Z", + "history":[{ + "created":"2023-04-21T11:53:28.176613804Z", + "created_by":"COPY ./src/main.rs / # buildkit", + "comment":"buildkit.dockerfile.v0" + }], + "os":"linux", + "rootfs":{ + "type":"layers", + "diff_ids":[ + "sha256:267fbf1f5a9377e40a2dc65b355000111e000a35ac77f7b19a59f587d4dd778e" + ] + } + }"#; + + fn minimal_config2() -> ConfigFile { + let config = Some(Config { + env: Some(vec![ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(), + ]), + working_dir: Some("/".into()), + ..Config::default() + }); + let history = Some(vec![History { + created: Some( + Utc.datetime_from_str("2023-04-21T11:53:28.176613804Z", "%+") + .expect("parse time failed"), + ), + author: None, + created_by: Some("COPY ./src/main.rs / # buildkit".into()), + comment: Some("buildkit.dockerfile.v0".into()), + empty_layer: None, + }]); + + let rootfs = Rootfs { + r#type: "layers".into(), + diff_ids: vec![ + "sha256:267fbf1f5a9377e40a2dc65b355000111e000a35ac77f7b19a59f587d4dd778e".into(), + ], + }; + + ConfigFile { + architecture: Architecture::Arm64, + os: Os::Linux, + config, + rootfs, + history, + created: Some( + Utc.datetime_from_str("2023-04-21T11:53:28.176613804Z", "%+") + .expect("parse time failed"), + ), + author: None, + } + } + + #[rstest] + #[case(example_config(), EXAMPLE_CONFIG)] + #[case(minimal_config(), MINIMAL_CONFIG)] + #[case(minimal_config2(), MINIMAL_CONFIG2)] + fn deserialize_test(#[case] config: ConfigFile, #[case] expected: &str) { + let parsed: ConfigFile = serde_json::from_str(expected).expect("parsed failed"); + assert_eq!(config, parsed); } - #[test] - fn serialize() { - let serialized = serde_json::to_value(&example_config()).expect("serialize failed"); - let parsed: Value = serde_json::from_str(EXAMPLE_CONFIG).expect("parsed failed"); + #[rstest] + #[case(example_config(), EXAMPLE_CONFIG)] + #[case(minimal_config(), MINIMAL_CONFIG)] + #[case(minimal_config2(), MINIMAL_CONFIG2)] + fn serialize_test(#[case] config: ConfigFile, #[case] expected: &str) { + let serialized = serde_json::to_value(&config).expect("serialize failed"); + let parsed: Value = serde_json::from_str(expected).expect("parsed failed"); assert_json_eq!(serialized, parsed); } }