Skip to main content

arcbox_oci/config/
spec.rs

1//! OCI runtime-spec configuration parsing.
2//!
3//! This module implements the OCI Runtime Specification config.json format.
4//! Reference: <https://github.com/opencontainers/runtime-spec/blob/main/config.md>
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::Path;
9
10use crate::error::{OciError, Result};
11use crate::hooks::Hooks;
12
13use super::{Linux, Mount, Process, Root};
14
15/// OCI specification version supported by this implementation.
16pub const OCI_VERSION: &str = "1.2.0";
17
18/// OCI runtime configuration (config.json).
19///
20/// This is the main configuration structure that defines how a container
21/// should be created and run according to the OCI runtime specification.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Spec {
25    /// OCI specification version (`SemVer` 2.0.0 format).
26    /// REQUIRED field.
27    pub oci_version: String,
28
29    /// Container's root filesystem.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub root: Option<Root>,
32
33    /// Container process to run.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub process: Option<Process>,
36
37    /// Container hostname.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub hostname: Option<String>,
40
41    /// Container domain name.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub domainname: Option<String>,
44
45    /// Additional mounts beyond the root filesystem.
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub mounts: Vec<Mount>,
48
49    /// Lifecycle hooks.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub hooks: Option<Hooks>,
52
53    /// Arbitrary metadata annotations.
54    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
55    pub annotations: HashMap<String, String>,
56
57    /// Linux-specific configuration.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub linux: Option<Linux>,
60}
61
62impl Spec {
63    /// Load OCI spec from a config.json file.
64    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
65        let content = std::fs::read_to_string(path.as_ref())?;
66        Self::from_json(&content)
67    }
68
69    /// Parse OCI spec from JSON string.
70    pub fn from_json(json: &str) -> Result<Self> {
71        let spec: Self = serde_json::from_str(json)?;
72        spec.validate()?;
73        Ok(spec)
74    }
75
76    /// Serialize to JSON string.
77    pub fn to_json(&self) -> Result<String> {
78        Ok(serde_json::to_string_pretty(self)?)
79    }
80
81    /// Serialize to JSON and write to file.
82    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
83        let json = self.to_json()?;
84        std::fs::write(path, json)?;
85        Ok(())
86    }
87
88    /// Validate the specification.
89    pub fn validate(&self) -> Result<()> {
90        // Validate OCI version format (must be valid SemVer).
91        if self.oci_version.is_empty() {
92            return Err(OciError::MissingField("ociVersion"));
93        }
94
95        // Validate version is parseable as semver.
96        if self.oci_version.split('.').count() < 2 {
97            return Err(OciError::InvalidVersion(self.oci_version.clone()));
98        }
99
100        // Validate root if present.
101        if let Some(ref root) = self.root {
102            if root.path.is_empty() {
103                return Err(OciError::InvalidConfig(
104                    "root.path cannot be empty".to_string(),
105                ));
106            }
107        }
108
109        // Validate process if present.
110        if let Some(ref process) = self.process {
111            if process.cwd.is_empty() {
112                return Err(OciError::MissingField("process.cwd"));
113            }
114            if !process.cwd.starts_with('/') {
115                return Err(OciError::InvalidConfig(
116                    "process.cwd must be an absolute path".to_string(),
117                ));
118            }
119        }
120
121        // Validate mounts.
122        for (i, mount) in self.mounts.iter().enumerate() {
123            if mount.destination.is_empty() {
124                return Err(OciError::InvalidConfig(format!(
125                    "mounts[{i}].destination cannot be empty"
126                )));
127            }
128            if !mount.destination.starts_with('/') {
129                return Err(OciError::InvalidConfig(format!(
130                    "mounts[{i}].destination must be an absolute path"
131                )));
132            }
133        }
134
135        Ok(())
136    }
137
138    /// Create a default spec for Linux containers.
139    #[must_use]
140    pub fn default_linux() -> Self {
141        Self {
142            oci_version: OCI_VERSION.to_string(),
143            root: Some(Root {
144                path: "rootfs".to_string(),
145                readonly: false,
146            }),
147            process: Some(Process::default()),
148            hostname: None,
149            domainname: None,
150            mounts: Self::default_mounts(),
151            hooks: None,
152            annotations: HashMap::new(),
153            linux: Some(Linux::default()),
154        }
155    }
156
157    /// Returns the default mounts for a Linux container.
158    fn default_mounts() -> Vec<Mount> {
159        vec![
160            Mount {
161                destination: "/proc".to_string(),
162                source: Some("proc".to_string()),
163                mount_type: Some("proc".to_string()),
164                options: Some(vec![
165                    "nosuid".to_string(),
166                    "noexec".to_string(),
167                    "nodev".to_string(),
168                ]),
169                ..Default::default()
170            },
171            Mount {
172                destination: "/dev".to_string(),
173                source: Some("tmpfs".to_string()),
174                mount_type: Some("tmpfs".to_string()),
175                options: Some(vec![
176                    "nosuid".to_string(),
177                    "strictatime".to_string(),
178                    "mode=755".to_string(),
179                    "size=65536k".to_string(),
180                ]),
181                ..Default::default()
182            },
183            Mount {
184                destination: "/dev/pts".to_string(),
185                source: Some("devpts".to_string()),
186                mount_type: Some("devpts".to_string()),
187                options: Some(vec![
188                    "nosuid".to_string(),
189                    "noexec".to_string(),
190                    "newinstance".to_string(),
191                    "ptmxmode=0666".to_string(),
192                    "mode=0620".to_string(),
193                ]),
194                ..Default::default()
195            },
196            Mount {
197                destination: "/dev/shm".to_string(),
198                source: Some("shm".to_string()),
199                mount_type: Some("tmpfs".to_string()),
200                options: Some(vec![
201                    "nosuid".to_string(),
202                    "noexec".to_string(),
203                    "nodev".to_string(),
204                    "mode=1777".to_string(),
205                    "size=65536k".to_string(),
206                ]),
207                ..Default::default()
208            },
209            Mount {
210                destination: "/sys".to_string(),
211                source: Some("sysfs".to_string()),
212                mount_type: Some("sysfs".to_string()),
213                options: Some(vec![
214                    "nosuid".to_string(),
215                    "noexec".to_string(),
216                    "nodev".to_string(),
217                    "ro".to_string(),
218                ]),
219                ..Default::default()
220            },
221        ]
222    }
223}