1use std::collections::{HashMap, HashSet};
6
7use chrono::{DateTime, Utc};
8use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer};
9
10#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
14#[serde(rename_all = "lowercase")]
15pub enum Architecture {
16 Arm,
18 Arm64,
20 #[default]
22 Amd64,
23 #[serde(rename = "386")]
25 I386,
26 Wasm,
28 Loong64,
30 Mips,
32 Mipsle,
34 Mips64,
36 Mips64le,
38 PPC64,
40 PPC64le,
42 Riscv64,
44 S390x,
46 #[serde(rename = "")]
48 None,
49}
50
51#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
55#[serde(rename_all = "lowercase")]
56pub enum Os {
57 Aix,
59 Android,
61 Darwin,
63 Dragonfly,
65 Freebsd,
67 Illumos,
69 Ios,
71 Js,
73 #[default]
75 Linux,
76 Netbsd,
78 Openbsd,
80 Plan9,
82 Solaris,
84 Wasip1,
86 Windows,
88 #[serde(rename = "")]
90 None,
91}
92
93#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
99pub struct ConfigFile {
100 #[serde(skip_serializing_if = "Option::is_none")]
104 pub created: Option<DateTime<Utc>>,
105
106 #[serde(skip_serializing_if = "Option::is_none")]
109 pub author: Option<String>,
110
111 pub architecture: Architecture,
114
115 pub os: Os,
118
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub config: Option<Config>,
122
123 pub rootfs: Rootfs,
125
126 #[serde(skip_serializing_if = "is_option_vec_empty")]
128 pub history: Option<Vec<History>>,
129}
130
131fn is_option_vec_empty<T>(opt_vec: &Option<Vec<T>>) -> bool {
132 if let Some(vec) = opt_vec {
133 vec.is_empty()
134 } else {
135 true
136 }
137}
138
139#[derive(Deserialize, Serialize)]
141struct Empty {}
142
143fn optional_hashset_from_str<'de, D: Deserializer<'de>>(
145 d: D,
146) -> Result<Option<HashSet<String>>, D::Error> {
147 let res = <Option<HashMap<String, Empty>>>::deserialize(d)?.map(|h| h.into_keys().collect());
148 Ok(res)
149}
150
151fn serialize_optional_hashset<T, S>(
153 value: &Option<HashSet<T>>,
154 serializer: S,
155) -> Result<S::Ok, S::Error>
156where
157 T: Serialize,
158 S: Serializer,
159{
160 match value {
161 Some(set) => {
162 let empty = Empty {};
163 let mut map = serializer.serialize_map(Some(set.len()))?;
164 for k in set {
165 map.serialize_entry(k, &empty)?;
166 }
167
168 map.end()
169 }
170 None => serializer.serialize_none(),
171 }
172}
173
174#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
176#[serde(rename_all = "PascalCase")]
177pub struct Config {
178 #[serde(skip_serializing_if = "Option::is_none")]
187 pub user: Option<String>,
188
189 #[serde(
195 skip_serializing_if = "is_option_hashset_empty",
196 deserialize_with = "optional_hashset_from_str",
197 serialize_with = "serialize_optional_hashset",
198 default
199 )]
200 pub exposed_ports: Option<HashSet<String>>,
201
202 #[serde(skip_serializing_if = "is_option_vec_empty")]
204 pub env: Option<Vec<String>>,
205
206 #[serde(skip_serializing_if = "is_option_vec_empty")]
208 pub cmd: Option<Vec<String>>,
209
210 #[serde(skip_serializing_if = "is_option_vec_empty")]
213 pub entrypoint: Option<Vec<String>>,
214
215 #[serde(
217 skip_serializing_if = "is_option_hashset_empty",
218 deserialize_with = "optional_hashset_from_str",
219 serialize_with = "serialize_optional_hashset",
220 default
221 )]
222 pub volumes: Option<HashSet<String>>,
223
224 #[serde(skip_serializing_if = "Option::is_none")]
227 pub working_dir: Option<String>,
228
229 #[serde(skip_serializing_if = "is_option_hashmap_empty")]
232 pub labels: Option<HashMap<String, String>>,
233
234 #[serde(skip_serializing_if = "Option::is_none")]
238 pub stop_signal: Option<String>,
239}
240
241fn is_option_hashset_empty<T>(opt_hash: &Option<HashSet<T>>) -> bool {
242 if let Some(hash) = opt_hash {
243 hash.is_empty()
244 } else {
245 true
246 }
247}
248
249fn is_option_hashmap_empty<T, V>(opt_hash: &Option<HashMap<T, V>>) -> bool {
250 if let Some(hash) = opt_hash {
251 hash.is_empty()
252 } else {
253 true
254 }
255}
256
257pub const ROOTFS_TYPE: &str = "layers";
259
260#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
263pub struct Rootfs {
264 pub r#type: String,
266
267 pub diff_ids: Vec<String>,
269}
270
271impl Default for Rootfs {
272 fn default() -> Self {
273 Self {
274 r#type: String::from(ROOTFS_TYPE),
275 diff_ids: Default::default(),
276 }
277 }
278}
279
280#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
282pub struct History {
283 #[serde(skip_serializing_if = "Option::is_none")]
286 pub created: Option<DateTime<Utc>>,
287
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub author: Option<String>,
291
292 #[serde(skip_serializing_if = "Option::is_none")]
294 pub created_by: Option<String>,
295
296 #[serde(skip_serializing_if = "Option::is_none")]
298 pub comment: Option<String>,
299
300 #[serde(skip_serializing_if = "Option::is_none")]
306 pub empty_layer: Option<bool>,
307}
308
309#[cfg(test)]
310mod tests {
311 use assert_json_diff::assert_json_eq;
312 use chrono::DateTime;
313 use rstest::*;
314 use serde_json::Value;
315 use std::collections::{HashMap, HashSet};
316
317 use super::{Architecture, Config, ConfigFile, History, Os, Rootfs};
318
319 const EXAMPLE_CONFIG: &str = r#"
320 {
321 "created": "2015-10-31T22:22:56.015925234Z",
322 "author": "Alyssa P. Hacker <alyspdev@example.com>",
323 "architecture": "amd64",
324 "os": "linux",
325 "config": {
326 "User": "alice",
327 "ExposedPorts": {
328 "8080/tcp": {}
329 },
330 "Env": [
331 "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
332 "FOO=oci_is_a",
333 "BAR=well_written_spec"
334 ],
335 "Entrypoint": [
336 "/bin/my-app-binary"
337 ],
338 "Cmd": [
339 "--foreground",
340 "--config",
341 "/etc/my-app.d/default.cfg"
342 ],
343 "Volumes": {
344 "/var/job-result-data": {},
345 "/var/log/my-app-logs": {}
346 },
347 "WorkingDir": "/home/alice",
348 "Labels": {
349 "com.example.project.git.url": "https://example.com/project.git",
350 "com.example.project.git.commit": "45a939b2999782a3f005621a8d0f29aa387e1d6b"
351 }
352 },
353 "rootfs": {
354 "diff_ids": [
355 "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1",
356 "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
357 ],
358 "type": "layers"
359 },
360 "history": [
361 {
362 "created": "2015-10-31T22:22:54.690851953Z",
363 "created_by": "/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /"
364 },
365 {
366 "created": "2015-10-31T22:22:55.613815829Z",
367 "created_by": "/bin/sh -c #(nop) CMD [\"sh\"]",
368 "empty_layer": true
369 }
370 ]
371 }"#;
372
373 fn example_config() -> ConfigFile {
374 let config = Config {
375 user: Some("alice".into()),
376 exposed_ports: Some(HashSet::from_iter(vec!["8080/tcp".into()])),
377 env: Some(vec![
378 "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(),
379 "FOO=oci_is_a".into(),
380 "BAR=well_written_spec".into(),
381 ]),
382 cmd: Some(vec![
383 "--foreground".into(),
384 "--config".into(),
385 "/etc/my-app.d/default.cfg".into(),
386 ]),
387 entrypoint: Some(vec!["/bin/my-app-binary".into()]),
388 volumes: Some(HashSet::from_iter(vec![
389 "/var/job-result-data".into(),
390 "/var/log/my-app-logs".into(),
391 ])),
392 working_dir: Some("/home/alice".into()),
393 labels: Some(HashMap::from_iter(vec![
394 (
395 "com.example.project.git.url".into(),
396 "https://example.com/project.git".into(),
397 ),
398 (
399 "com.example.project.git.commit".into(),
400 "45a939b2999782a3f005621a8d0f29aa387e1d6b".into(),
401 ),
402 ])),
403 stop_signal: None,
404 };
405 let rootfs = Rootfs {
406 r#type: "layers".into(),
407 diff_ids: vec![
408 "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1".into(),
409 "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef".into(),
410 ],
411 };
412
413 let history = Some(vec![History {
414 created: Some(DateTime::parse_from_rfc3339("2015-10-31T22:22:54.690851953Z").expect("parse time failed").into()),
415 author: None,
416 created_by: Some("/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /".into()),
417 comment: None,
418 empty_layer: None,
419 },
420 History {
421 created: Some(DateTime::parse_from_rfc3339("2015-10-31T22:22:55.613815829Z").expect("parse time failed").into()),
422 author: None,
423 created_by: Some("/bin/sh -c #(nop) CMD [\"sh\"]".into()),
424 comment: None,
425 empty_layer: Some(true),
426 }]);
427 ConfigFile {
428 created: Some(
429 DateTime::parse_from_rfc3339("2015-10-31T22:22:56.015925234Z")
430 .expect("parse time failed")
431 .into(),
432 ),
433 author: Some("Alyssa P. Hacker <alyspdev@example.com>".into()),
434 architecture: Architecture::Amd64,
435 os: Os::Linux,
436 config: Some(config),
437 rootfs,
438 history,
439 }
440 }
441
442 const MINIMAL_CONFIG: &str = r#"
443 {
444 "architecture": "amd64",
445 "os": "linux",
446 "rootfs": {
447 "diff_ids": [
448 "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1",
449 "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"
450 ],
451 "type": "layers"
452 }
453 }"#;
454
455 fn minimal_config() -> ConfigFile {
456 let rootfs = Rootfs {
457 r#type: "layers".into(),
458 diff_ids: vec![
459 "sha256:c6f988f4874bb0add23a778f753c65efe992244e148a1d2ec2a8b664fb66bbd1".into(),
460 "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef".into(),
461 ],
462 };
463
464 ConfigFile {
465 architecture: Architecture::Amd64,
466 os: Os::Linux,
467 config: None,
468 rootfs,
469 history: None,
470 created: None,
471 author: None,
472 }
473 }
474
475 const MINIMAL_CONFIG2: &str = r#"
476 {
477 "architecture":"arm64",
478 "config":{
479 "Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
480 "WorkingDir":"/"
481 },
482 "created":"2023-04-21T11:53:28.176613804Z",
483 "history":[{
484 "created":"2023-04-21T11:53:28.176613804Z",
485 "created_by":"COPY ./src/main.rs / # buildkit",
486 "comment":"buildkit.dockerfile.v0"
487 }],
488 "os":"linux",
489 "rootfs":{
490 "type":"layers",
491 "diff_ids":[
492 "sha256:267fbf1f5a9377e40a2dc65b355000111e000a35ac77f7b19a59f587d4dd778e"
493 ]
494 }
495 }"#;
496
497 fn minimal_config2() -> ConfigFile {
498 let config = Some(Config {
499 env: Some(vec![
500 "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(),
501 ]),
502 working_dir: Some("/".into()),
503 ..Config::default()
504 });
505 let history = Some(vec![History {
506 created: Some(
507 DateTime::parse_from_rfc3339("2023-04-21T11:53:28.176613804Z")
508 .expect("parse time failed")
509 .into(),
510 ),
511 author: None,
512 created_by: Some("COPY ./src/main.rs / # buildkit".into()),
513 comment: Some("buildkit.dockerfile.v0".into()),
514 empty_layer: None,
515 }]);
516
517 let rootfs = Rootfs {
518 r#type: "layers".into(),
519 diff_ids: vec![
520 "sha256:267fbf1f5a9377e40a2dc65b355000111e000a35ac77f7b19a59f587d4dd778e".into(),
521 ],
522 };
523
524 ConfigFile {
525 architecture: Architecture::Arm64,
526 os: Os::Linux,
527 config,
528 rootfs,
529 history,
530 created: Some(
531 DateTime::parse_from_rfc3339("2023-04-21T11:53:28.176613804Z")
532 .expect("parse time failed")
533 .into(),
534 ),
535 author: None,
536 }
537 }
538
539 #[rstest]
540 #[case(example_config(), EXAMPLE_CONFIG)]
541 #[case(minimal_config(), MINIMAL_CONFIG)]
542 #[case(minimal_config2(), MINIMAL_CONFIG2)]
543 fn deserialize_test(#[case] config: ConfigFile, #[case] expected: &str) {
544 let parsed: ConfigFile = serde_json::from_str(expected).expect("parsed failed");
545 assert_eq!(config, parsed);
546 }
547
548 #[rstest]
549 #[case(example_config(), EXAMPLE_CONFIG)]
550 #[case(minimal_config(), MINIMAL_CONFIG)]
551 #[case(minimal_config2(), MINIMAL_CONFIG2)]
552 fn serialize_test(#[case] config: ConfigFile, #[case] expected: &str) {
553 let serialized = serde_json::to_value(&config).expect("serialize failed");
554 let parsed: Value = serde_json::from_str(expected).expect("parsed failed");
555 assert_json_eq!(serialized, parsed);
556 }
557}