1use std::collections::{HashMap, HashSet};
6
7use chrono::{DateTime, Utc};
8use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer};
9
10pub type Architecture = oci_spec::image::Arch;
14
15pub type Os = oci_spec::image::Os;
19
20#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
26pub struct ConfigFile {
27 #[serde(skip_serializing_if = "Option::is_none")]
31 pub created: Option<DateTime<Utc>>,
32
33 #[serde(skip_serializing_if = "Option::is_none")]
36 pub author: Option<String>,
37
38 pub architecture: Architecture,
41
42 pub os: Os,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub config: Option<Config>,
49
50 pub rootfs: Rootfs,
52
53 #[serde(skip_serializing_if = "is_option_vec_empty")]
55 pub history: Option<Vec<History>>,
56}
57
58fn is_option_vec_empty<T>(opt_vec: &Option<Vec<T>>) -> bool {
59 if let Some(vec) = opt_vec {
60 vec.is_empty()
61 } else {
62 true
63 }
64}
65
66#[derive(Deserialize, Serialize)]
68struct Empty {}
69
70fn optional_hashset_from_str<'de, D: Deserializer<'de>>(
72 d: D,
73) -> Result<Option<HashSet<String>>, D::Error> {
74 let res = <Option<HashMap<String, Empty>>>::deserialize(d)?.map(|h| h.into_keys().collect());
75 Ok(res)
76}
77
78fn serialize_optional_hashset<T, S>(
80 value: &Option<HashSet<T>>,
81 serializer: S,
82) -> Result<S::Ok, S::Error>
83where
84 T: Serialize,
85 S: Serializer,
86{
87 match value {
88 Some(set) => {
89 let empty = Empty {};
90 let mut map = serializer.serialize_map(Some(set.len()))?;
91 for k in set {
92 map.serialize_entry(k, &empty)?;
93 }
94
95 map.end()
96 }
97 None => serializer.serialize_none(),
98 }
99}
100
101#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
103#[serde(rename_all = "PascalCase")]
104pub struct Config {
105 #[serde(skip_serializing_if = "Option::is_none")]
114 pub user: Option<String>,
115
116 #[serde(
122 skip_serializing_if = "is_option_hashset_empty",
123 deserialize_with = "optional_hashset_from_str",
124 serialize_with = "serialize_optional_hashset",
125 default
126 )]
127 pub exposed_ports: Option<HashSet<String>>,
128
129 #[serde(skip_serializing_if = "is_option_vec_empty")]
131 pub env: Option<Vec<String>>,
132
133 #[serde(skip_serializing_if = "is_option_vec_empty")]
135 pub cmd: Option<Vec<String>>,
136
137 #[serde(skip_serializing_if = "is_option_vec_empty")]
140 pub entrypoint: Option<Vec<String>>,
141
142 #[serde(
144 skip_serializing_if = "is_option_hashset_empty",
145 deserialize_with = "optional_hashset_from_str",
146 serialize_with = "serialize_optional_hashset",
147 default
148 )]
149 pub volumes: Option<HashSet<String>>,
150
151 #[serde(skip_serializing_if = "Option::is_none")]
154 pub working_dir: Option<String>,
155
156 #[serde(skip_serializing_if = "is_option_hashmap_empty")]
159 pub labels: Option<HashMap<String, String>>,
160
161 #[serde(skip_serializing_if = "Option::is_none")]
165 pub stop_signal: Option<String>,
166}
167
168fn is_option_hashset_empty<T>(opt_hash: &Option<HashSet<T>>) -> bool {
169 if let Some(hash) = opt_hash {
170 hash.is_empty()
171 } else {
172 true
173 }
174}
175
176fn is_option_hashmap_empty<T, V>(opt_hash: &Option<HashMap<T, V>>) -> bool {
177 if let Some(hash) = opt_hash {
178 hash.is_empty()
179 } else {
180 true
181 }
182}
183
184pub const ROOTFS_TYPE: &str = "layers";
186
187#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
190pub struct Rootfs {
191 pub r#type: String,
193
194 pub diff_ids: Vec<String>,
196}
197
198impl Default for Rootfs {
199 fn default() -> Self {
200 Self {
201 r#type: String::from(ROOTFS_TYPE),
202 diff_ids: Default::default(),
203 }
204 }
205}
206
207#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
209pub struct History {
210 #[serde(skip_serializing_if = "Option::is_none")]
213 pub created: Option<DateTime<Utc>>,
214
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub author: Option<String>,
218
219 #[serde(skip_serializing_if = "Option::is_none")]
221 pub created_by: Option<String>,
222
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub comment: Option<String>,
226
227 #[serde(skip_serializing_if = "Option::is_none")]
233 pub empty_layer: Option<bool>,
234}
235
236#[cfg(test)]
237mod tests {
238 use assert_json_diff::assert_json_eq;
239 use chrono::DateTime;
240 use oci_spec::image::Arch;
241 use rstest::*;
242 use serde_json::Value;
243 use std::collections::{HashMap, HashSet};
244
245 use super::{Config, ConfigFile, History, Os, Rootfs};
246
247 const EXAMPLE_CONFIG: &str = r#"
248 {
249 "created": "2015-10-31T22:22:56.015925234Z",
250 "author": "Alyssa P. Hacker <alyspdev@example.com>",
251 "architecture": "amd64",
252 "os": "linux",
253 "config": {
254 "User": "alice",
255 "ExposedPorts": {
256 "8080/tcp": {}
257 },
258 "Env": [
259 "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
260 "FOO=oci_is_a",
261 "BAR=well_written_spec"
262 ],
263 "Entrypoint": [
264 "/bin/my-app-binary"
265 ],
266 "Cmd": [
267 "--foreground",
268 "--config",
269 "/etc/my-app.d/default.cfg"
270 ],
271 "Volumes": {
272 "/var/job-result-data": {},
273 "/var/log/my-app-logs": {}
274 },
275 "WorkingDir": "/home/alice",
276 "Labels": {
277 "com.example.project.git.url": "https://example.com/project.git",
278 "com.example.project.git.commit": "45a939b2999782a3f005621a8d0f29aa387e1d6b"
279 }
280 },
281 "rootfs": {
282 "diff_ids": [
283 "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1",
284 "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
285 ],
286 "type": "layers"
287 },
288 "history": [
289 {
290 "created": "2015-10-31T22:22:54.690851953Z",
291 "created_by": "/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /"
292 },
293 {
294 "created": "2015-10-31T22:22:55.613815829Z",
295 "created_by": "/bin/sh -c #(nop) CMD [\"sh\"]",
296 "empty_layer": true
297 }
298 ]
299 }"#;
300
301 fn example_config() -> ConfigFile {
302 let config = Config {
303 user: Some("alice".into()),
304 exposed_ports: Some(HashSet::from_iter(vec!["8080/tcp".into()])),
305 env: Some(vec![
306 "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(),
307 "FOO=oci_is_a".into(),
308 "BAR=well_written_spec".into(),
309 ]),
310 cmd: Some(vec![
311 "--foreground".into(),
312 "--config".into(),
313 "/etc/my-app.d/default.cfg".into(),
314 ]),
315 entrypoint: Some(vec!["/bin/my-app-binary".into()]),
316 volumes: Some(HashSet::from_iter(vec![
317 "/var/job-result-data".into(),
318 "/var/log/my-app-logs".into(),
319 ])),
320 working_dir: Some("/home/alice".into()),
321 labels: Some(HashMap::from_iter(vec![
322 (
323 "com.example.project.git.url".into(),
324 "https://example.com/project.git".into(),
325 ),
326 (
327 "com.example.project.git.commit".into(),
328 "45a939b2999782a3f005621a8d0f29aa387e1d6b".into(),
329 ),
330 ])),
331 stop_signal: None,
332 };
333 let rootfs = Rootfs {
334 r#type: "layers".into(),
335 diff_ids: vec![
336 "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1".into(),
337 "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef".into(),
338 ],
339 };
340
341 let history = Some(vec![History {
342 created: Some(DateTime::parse_from_rfc3339("2015-10-31T22:22:54.690851953Z").expect("parse time failed").into()),
343 author: None,
344 created_by: Some("/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /".into()),
345 comment: None,
346 empty_layer: None,
347 },
348 History {
349 created: Some(DateTime::parse_from_rfc3339("2015-10-31T22:22:55.613815829Z").expect("parse time failed").into()),
350 author: None,
351 created_by: Some("/bin/sh -c #(nop) CMD [\"sh\"]".into()),
352 comment: None,
353 empty_layer: Some(true),
354 }]);
355 ConfigFile {
356 created: Some(
357 DateTime::parse_from_rfc3339("2015-10-31T22:22:56.015925234Z")
358 .expect("parse time failed")
359 .into(),
360 ),
361 author: Some("Alyssa P. Hacker <alyspdev@example.com>".into()),
362 architecture: Arch::Amd64,
363 os: Os::Linux,
364 config: Some(config),
365 rootfs,
366 history,
367 }
368 }
369
370 const MINIMAL_CONFIG: &str = r#"
371 {
372 "architecture": "amd64",
373 "os": "linux",
374 "rootfs": {
375 "diff_ids": [
376 "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1",
377 "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
378 ],
379 "type": "layers"
380 }
381 }"#;
382
383 fn minimal_config() -> ConfigFile {
384 let rootfs = Rootfs {
385 r#type: "layers".into(),
386 diff_ids: vec![
387 "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1".into(),
388 "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef".into(),
389 ],
390 };
391
392 ConfigFile {
393 architecture: Arch::Amd64,
394 os: Os::Linux,
395 config: None,
396 rootfs,
397 history: None,
398 created: None,
399 author: None,
400 }
401 }
402
403 const MINIMAL_CONFIG2: &str = r#"
404 {
405 "architecture":"arm64",
406 "config":{
407 "Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
408 "WorkingDir":"/"
409 },
410 "created":"2023-04-21T11:53:28.176613804Z",
411 "history":[{
412 "created":"2023-04-21T11:53:28.176613804Z",
413 "created_by":"COPY ./src/main.rs / # buildkit",
414 "comment":"buildkit.dockerfile.v0"
415 }],
416 "os":"linux",
417 "rootfs":{
418 "type":"layers",
419 "diff_ids":[
420 "sha256:267fbf1f5a9377e40a2dc65b355000111e000a35ac77f7b19a59f587d4dd778e"
421 ]
422 }
423 }"#;
424
425 fn minimal_config2() -> ConfigFile {
426 let config = Some(Config {
427 env: Some(vec![
428 "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(),
429 ]),
430 working_dir: Some("/".into()),
431 ..Config::default()
432 });
433 let history = Some(vec![History {
434 created: Some(
435 DateTime::parse_from_rfc3339("2023-04-21T11:53:28.176613804Z")
436 .expect("parse time failed")
437 .into(),
438 ),
439 author: None,
440 created_by: Some("COPY ./src/main.rs / # buildkit".into()),
441 comment: Some("buildkit.dockerfile.v0".into()),
442 empty_layer: None,
443 }]);
444
445 let rootfs = Rootfs {
446 r#type: "layers".into(),
447 diff_ids: vec![
448 "sha256:267fbf1f5a9377e40a2dc65b355000111e000a35ac77f7b19a59f587d4dd778e".into(),
449 ],
450 };
451
452 ConfigFile {
453 architecture: Arch::ARM64,
454 os: Os::Linux,
455 config,
456 rootfs,
457 history,
458 created: Some(
459 DateTime::parse_from_rfc3339("2023-04-21T11:53:28.176613804Z")
460 .expect("parse time failed")
461 .into(),
462 ),
463 author: None,
464 }
465 }
466
467 #[rstest]
468 #[case(example_config(), EXAMPLE_CONFIG)]
469 #[case(minimal_config(), MINIMAL_CONFIG)]
470 #[case(minimal_config2(), MINIMAL_CONFIG2)]
471 fn deserialize_test(#[case] config: ConfigFile, #[case] expected: &str) {
472 let parsed: ConfigFile = serde_json::from_str(expected).expect("parsed failed");
473 assert_eq!(config, parsed);
474 }
475
476 #[rstest]
477 #[case(example_config(), EXAMPLE_CONFIG)]
478 #[case(minimal_config(), MINIMAL_CONFIG)]
479 #[case(minimal_config2(), MINIMAL_CONFIG2)]
480 fn serialize_test(#[case] config: ConfigFile, #[case] expected: &str) {
481 let serialized = serde_json::to_value(&config).expect("serialize failed");
482 let parsed: Value = serde_json::from_str(expected).expect("parsed failed");
483 assert_json_eq!(serialized, parsed);
484 }
485}