Skip to main content

containerregistry_image/
config.rs

1//! OCI image configuration types.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Deserializer, Serialize};
6
7use crate::{Digest, Error, Result};
8
9/// Deserialize a value that may be JSON `null`, treating null as T::default().
10/// Docker/OCI configs frequently contain `"Volumes": null` or `"Labels": null`
11/// instead of omitting the field or using an empty object/array.
12fn null_as_default<'de, D, T>(deserializer: D) -> std::result::Result<T, D::Error>
13where
14    D: Deserializer<'de>,
15    T: Default + Deserialize<'de>,
16{
17    Option::<T>::deserialize(deserializer).map(|v| v.unwrap_or_default())
18}
19
20/// OCI image configuration.
21///
22/// This contains the execution parameters for the container,
23/// along with the history and layer diff IDs.
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ImageConfig {
26    /// The CPU architecture.
27    pub architecture: String,
28
29    /// The operating system.
30    pub os: String,
31
32    /// Optional OS version.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub os_version: Option<String>,
35
36    /// Optional OS features required by the image.
37    #[serde(default, rename = "os.features", skip_serializing_if = "Vec::is_empty", deserialize_with = "null_as_default")]
38    pub os_features: Vec<String>,
39
40    /// Optional architecture variant.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub variant: Option<String>,
43
44    /// Container runtime configuration.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub config: Option<ContainerConfig>,
47
48    /// Layer content hashes (uncompressed).
49    pub rootfs: RootFs,
50
51    /// Build history.
52    #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "null_as_default")]
53    pub history: Vec<History>,
54
55    /// Creation timestamp (RFC 3339).
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub created: Option<String>,
58
59    /// Author of the image.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub author: Option<String>,
62}
63
64/// Empty object used for ports and volumes.
65#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
66pub struct EmptyObject {}
67
68/// Health check configuration for the container.
69#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "PascalCase")]
71pub struct Healthcheck {
72    /// The test to perform (CMD or CMD-SHELL).
73    #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "null_as_default")]
74    pub test: Vec<String>,
75
76    /// Interval between health checks (nanoseconds).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub interval: Option<i64>,
79
80    /// Timeout for each health check (nanoseconds).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub timeout: Option<i64>,
83
84    /// Number of retries before marking unhealthy.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub retries: Option<i32>,
87
88    /// Start period for the container (nanoseconds).
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub start_period: Option<i64>,
91
92    /// Interval between health checks during start period (nanoseconds).
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub start_interval: Option<i64>,
95}
96
97/// Container runtime configuration.
98#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "PascalCase")]
100pub struct ContainerConfig {
101    /// Hostname.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub hostname: Option<String>,
104
105    /// Domain name.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub domainname: Option<String>,
108
109    /// User to run as.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub user: Option<String>,
112
113    /// Exposed ports.
114    #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "null_as_default")]
115    pub exposed_ports: BTreeMap<String, EmptyObject>,
116
117    /// Environment variables.
118    #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "null_as_default")]
119    pub env: Vec<String>,
120
121    /// Entrypoint command.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub entrypoint: Option<Vec<String>>,
124
125    /// Default command.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub cmd: Option<Vec<String>>,
128
129    /// Volumes.
130    #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "null_as_default")]
131    pub volumes: BTreeMap<String, EmptyObject>,
132
133    /// Working directory.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub working_dir: Option<String>,
136
137    /// Labels.
138    #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "null_as_default")]
139    pub labels: BTreeMap<String, String>,
140
141    /// Stop signal.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub stop_signal: Option<String>,
144
145    /// Health check configuration.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub healthcheck: Option<Healthcheck>,
148
149    /// Dockerfile ONBUILD triggers.
150    #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "null_as_default")]
151    pub on_build: Vec<String>,
152
153    /// Shell for shell-form RUN commands.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub shell: Option<Vec<String>>,
156
157    /// Windows-specific: whether args should be escaped.
158    #[serde(default, skip_serializing_if = "is_false")]
159    pub args_escaped: bool,
160}
161/// Root filesystem configuration.
162#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
163pub struct RootFs {
164    /// Type of rootfs (always "layers").
165    #[serde(rename = "type")]
166    pub fs_type: String,
167
168    /// Layer diff IDs (uncompressed content hashes).
169    pub diff_ids: Vec<Digest>,
170}
171
172/// History entry for a layer.
173#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
174pub struct History {
175    /// Creation timestamp (RFC 3339).
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub created: Option<String>,
178
179    /// Command that created this layer.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub created_by: Option<String>,
182
183    /// Author of this layer.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub author: Option<String>,
186
187    /// Commit message.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub comment: Option<String>,
190
191    /// Whether this is an empty layer (no filesystem changes).
192    #[serde(default, skip_serializing_if = "is_false")]
193    pub empty_layer: bool,
194}
195
196fn is_false(b: &bool) -> bool {
197    !*b
198}
199
200impl ImageConfig {
201    /// Creates a new image config with the given architecture and OS.
202    pub fn new(architecture: impl Into<String>, os: impl Into<String>) -> Self {
203        Self {
204            architecture: architecture.into(),
205            os: os.into(),
206            os_version: None,
207            os_features: Vec::new(),
208            variant: None,
209            config: None,
210            rootfs: RootFs {
211                fs_type: "layers".to_string(),
212                diff_ids: Vec::new(),
213            },
214            history: Vec::new(),
215            created: None,
216            author: None,
217        }
218    }
219
220    /// Parses an image config from JSON bytes.
221    pub fn from_bytes(data: &[u8]) -> Result<Self> {
222        serde_json::from_slice(data).map_err(Error::from)
223    }
224
225    /// Serializes the config to canonical JSON bytes.
226    pub fn to_bytes(&self) -> Result<Vec<u8>> {
227        serde_json::to_vec(self).map_err(Error::from)
228    }
229
230    /// Computes the digest of this config.
231    pub fn digest(&self) -> Result<Digest> {
232        let bytes = self.to_bytes()?;
233        Ok(Digest::sha256(&bytes))
234    }
235
236    /// Validates the image config for strict OCI requirements.
237    ///
238    /// This currently checks:
239    /// - rootfs.type is "layers"
240    pub fn validate(&self) -> Result<()> {
241        if self.rootfs.fs_type != "layers" {
242            return Err(Error::InvalidConfig(format!(
243                "invalid rootfs.type: expected \"layers\", got \"{}\"",
244                self.rootfs.fs_type
245            )));
246        }
247        Ok(())
248    }
249
250    /// Returns the size of the serialized config.
251    pub fn size(&self) -> Result<u64> {
252        let bytes = self.to_bytes()?;
253        Ok(bytes.len() as u64)
254    }
255
256    /// Adds a layer diff ID.
257    pub fn with_layer(mut self, diff_id: Digest) -> Self {
258        self.rootfs.diff_ids.push(diff_id);
259        self
260    }
261
262    /// Sets the container configuration.
263    pub fn with_config(mut self, config: ContainerConfig) -> Self {
264        self.config = Some(config);
265        self
266    }
267
268    /// Adds a history entry.
269    pub fn with_history(mut self, history: History) -> Self {
270        self.history.push(history);
271        self
272    }
273
274    /// Returns the labels from the container config, if any.
275    pub fn labels(&self) -> Option<&BTreeMap<String, String>> {
276        self.config.as_ref().map(|c| &c.labels)
277    }
278
279    /// Returns the entrypoint from the container config, if any.
280    pub fn entrypoint(&self) -> Option<&[String]> {
281        self.config.as_ref().and_then(|c| c.entrypoint.as_deref())
282    }
283
284    /// Returns the cmd from the container config, if any.
285    pub fn cmd(&self) -> Option<&[String]> {
286        self.config.as_ref().and_then(|c| c.cmd.as_deref())
287    }
288
289    /// Returns the environment variables from the container config, if any.
290    pub fn env(&self) -> Option<&[String]> {
291        self.config.as_ref().map(|c| c.env.as_slice())
292    }
293}
294
295impl ContainerConfig {
296    /// Creates a new container config.
297    pub fn new() -> Self {
298        Self::default()
299    }
300
301    /// Sets the entrypoint.
302    pub fn with_entrypoint(mut self, entrypoint: Vec<String>) -> Self {
303        self.entrypoint = Some(entrypoint);
304        self
305    }
306
307    /// Sets the default command.
308    pub fn with_cmd(mut self, cmd: Vec<String>) -> Self {
309        self.cmd = Some(cmd);
310        self
311    }
312
313    /// Adds an environment variable.
314    pub fn with_env(mut self, var: impl Into<String>) -> Self {
315        self.env.push(var.into());
316        self
317    }
318
319    /// Adds a label.
320    pub fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
321        self.labels.insert(key.into(), value.into());
322        self
323    }
324
325    /// Sets the working directory.
326    pub fn with_working_dir(mut self, dir: impl Into<String>) -> Self {
327        self.working_dir = Some(dir.into());
328        self
329    }
330
331    /// Sets the user.
332    pub fn with_user(mut self, user: impl Into<String>) -> Self {
333        self.user = Some(user.into());
334        self
335    }
336}
337
338impl History {
339    /// Creates a new history entry.
340    pub fn new() -> Self {
341        Self::default()
342    }
343
344    /// Sets the created_by field.
345    pub fn with_created_by(mut self, created_by: impl Into<String>) -> Self {
346        self.created_by = Some(created_by.into());
347        self
348    }
349
350    /// Marks this as an empty layer.
351    pub fn as_empty_layer(mut self) -> Self {
352        self.empty_layer = true;
353        self
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn test_image_config_create() {
363        let config = ImageConfig::new("amd64", "linux");
364
365        assert_eq!(config.architecture, "amd64");
366        assert_eq!(config.os, "linux");
367        assert_eq!(config.rootfs.fs_type, "layers");
368    }
369
370    #[test]
371    fn test_image_config_roundtrip() {
372        let config = ImageConfig::new("amd64", "linux")
373            .with_layer(
374                "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
375                    .parse()
376                    .unwrap(),
377            )
378            .with_config(
379                ContainerConfig::new()
380                    .with_entrypoint(vec!["/bin/sh".to_string()])
381                    .with_env("PATH=/usr/bin:/bin".to_string())
382                    .with_label("version", "1.0"),
383            );
384
385        let bytes = config.to_bytes().unwrap();
386        let parsed = ImageConfig::from_bytes(&bytes).unwrap();
387
388        assert_eq!(config, parsed);
389    }
390
391    #[test]
392    fn test_image_config_digest_stability() {
393        let config = ImageConfig::new("amd64", "linux");
394
395        let digest1 = config.digest().unwrap();
396        let digest2 = config.digest().unwrap();
397
398        assert_eq!(digest1, digest2);
399    }
400
401    #[test]
402    fn test_container_config_builder() {
403        let config = ContainerConfig::new()
404            .with_entrypoint(vec!["/bin/sh".to_string()])
405            .with_cmd(vec!["-c".to_string(), "echo hello".to_string()])
406            .with_env("FOO=bar")
407            .with_working_dir("/app")
408            .with_user("nobody")
409            .with_label("maintainer", "test@example.com");
410
411        assert_eq!(config.entrypoint, Some(vec!["/bin/sh".to_string()]));
412        assert_eq!(
413            config.cmd,
414            Some(vec!["-c".to_string(), "echo hello".to_string()])
415        );
416        assert_eq!(config.env, vec!["FOO=bar"]);
417        assert_eq!(config.working_dir, Some("/app".to_string()));
418        assert_eq!(config.user, Some("nobody".to_string()));
419        assert_eq!(
420            config.labels.get("maintainer"),
421            Some(&"test@example.com".to_string())
422        );
423    }
424
425    #[test]
426    fn test_history_builder() {
427        let history = History::new()
428            .with_created_by("ADD file:abc123 /")
429            .as_empty_layer();
430
431        assert_eq!(history.created_by, Some("ADD file:abc123 /".to_string()));
432        assert!(history.empty_layer);
433    }
434
435    #[test]
436    fn test_image_config_accessors() {
437        let config = ImageConfig::new("amd64", "linux").with_config(
438            ContainerConfig::new()
439                .with_entrypoint(vec!["/app".to_string()])
440                .with_cmd(vec!["--help".to_string()])
441                .with_env("DEBUG=1")
442                .with_label("version", "1.0"),
443        );
444
445        assert_eq!(config.entrypoint(), Some(&["/app".to_string()][..]));
446        assert_eq!(config.cmd(), Some(&["--help".to_string()][..]));
447        assert_eq!(config.env(), Some(&["DEBUG=1".to_string()][..]));
448        assert_eq!(
449            config.labels().and_then(|l| l.get("version")),
450            Some(&"1.0".to_string())
451        );
452    }
453}