Skip to main content

elfpak_core/oci/
model.rs

1//! OCI image metadata defaults, validation, and serialized models.
2
3use crate::{BundlePlan, Error, Machine, PlannedFileKind, Result};
4use serde::Serialize;
5use std::{
6    collections::{BTreeMap, BTreeSet},
7    path::Path,
8};
9
10/// Upper bound for independently supplied environment variables or labels.
11pub(super) const OCI_METADATA_ENTRIES_MAX: usize = 4096;
12/// Upper bound for one image-metadata string, in UTF-8 bytes.
13pub(super) const OCI_METADATA_VALUE_BYTES_MAX: usize = 1 << 20;
14
15pub(super) const OCI_IMAGE_INDEX: &str = "application/vnd.oci.image.index.v1+json";
16pub(super) const OCI_IMAGE_MANIFEST: &str = "application/vnd.oci.image.manifest.v1+json";
17pub(super) const OCI_IMAGE_CONFIG: &str = "application/vnd.oci.image.config.v1+json";
18pub(super) const OCI_LAYER_TAR: &str = "application/vnd.oci.image.layer.v1.tar";
19pub(super) const OCI_REF_NAME: &str = "org.opencontainers.image.ref.name";
20
21#[derive(Debug, Serialize)]
22#[serde(rename_all = "camelCase")]
23pub(super) struct Descriptor {
24    pub media_type: &'static str,
25    pub digest: String,
26    pub size: u64,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub annotations: Option<BTreeMap<String, String>>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub platform: Option<Platform>,
31}
32
33#[derive(Debug, Serialize)]
34pub(super) struct Platform {
35    pub architecture: String,
36    pub os: String,
37}
38
39#[derive(Debug, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub(super) struct ImageIndex {
42    pub schema_version: u32,
43    pub media_type: &'static str,
44    pub manifests: Vec<Descriptor>,
45}
46
47#[derive(Debug, Serialize)]
48#[serde(rename_all = "camelCase")]
49pub(super) struct ImageManifest {
50    pub schema_version: u32,
51    pub media_type: &'static str,
52    pub config: Descriptor,
53    pub layers: Vec<Descriptor>,
54}
55
56#[derive(Debug, Serialize)]
57pub(super) struct ImageConfiguration {
58    pub architecture: String,
59    pub os: String,
60    pub config: RuntimeConfiguration,
61    pub rootfs: RootFs,
62}
63
64#[derive(Debug, Serialize)]
65#[serde(rename_all = "PascalCase")]
66pub(super) struct RuntimeConfiguration {
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub user: Option<String>,
69    #[serde(skip_serializing_if = "Vec::is_empty")]
70    pub env: Vec<String>,
71    pub entrypoint: Vec<String>,
72    #[serde(skip_serializing_if = "Vec::is_empty")]
73    pub cmd: Vec<String>,
74    pub working_dir: String,
75    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
76    pub labels: BTreeMap<String, String>,
77}
78
79#[derive(Debug, Serialize)]
80pub(super) struct RootFs {
81    #[serde(rename = "type")]
82    pub kind: &'static str,
83    pub diff_ids: Vec<String>,
84}
85
86/// User-selected process and descriptive metadata for an OCI image.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct OciImageConfig {
89    pub tag: String,
90    pub entrypoint: Vec<String>,
91    pub cmd: Vec<String>,
92    pub working_dir: Option<String>,
93    pub env: Vec<String>,
94    pub labels: BTreeMap<String, String>,
95}
96
97impl Default for OciImageConfig {
98    fn default() -> OciImageConfig {
99        OciImageConfig {
100            tag: "latest".to_string(),
101            entrypoint: Vec::new(),
102            cmd: Vec::new(),
103            working_dir: None,
104            env: Vec::new(),
105            labels: BTreeMap::new(),
106        }
107    }
108}
109
110/// Fully defaulted and validated OCI metadata for one bundle plan.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ResolvedImageConfig {
113    pub(crate) tag: String,
114    pub(crate) os: String,
115    pub(crate) architecture: String,
116    pub(crate) user: Option<String>,
117    pub(crate) entrypoint: Vec<String>,
118    pub(crate) cmd: Vec<String>,
119    pub(crate) working_dir: String,
120    pub(crate) env: Vec<String>,
121    pub(crate) labels: BTreeMap<String, String>,
122}
123
124impl OciImageConfig {
125    pub fn resolve(&self, plan: &BundlePlan) -> Result<ResolvedImageConfig> {
126        validate_tag(&self.tag)?;
127        validate_count("environment", self.env.len())?;
128        validate_count("labels", self.labels.len())?;
129
130        let entrypoint = if self.entrypoint.is_empty() {
131            if plan.applications().len() != 1 {
132                return Err(config_error(
133                    "OCI entrypoint is required for a multi-binary bundle",
134                ));
135            }
136            vec![plan.executable().destination().display().to_string()]
137        } else {
138            self.entrypoint.clone()
139        };
140        validate_process_args("entrypoint", &entrypoint)?;
141        validate_process_args("command", &self.cmd)?;
142        validate_entrypoint(plan, &entrypoint[0])?;
143
144        let working_dir = self.working_dir.as_deref().unwrap_or("/").to_string();
145        validate_working_dir(plan, &working_dir)?;
146        validate_environment(&self.env)?;
147        validate_labels(&self.labels)?;
148
149        let architecture = match plan.architecture().machine {
150            Machine::X86_64 => "amd64",
151            Machine::Aarch64 => "arm64",
152            other => {
153                return Err(config_error(format!(
154                    "cannot map architecture `{other}` to an OCI platform"
155                )));
156            }
157        };
158        let user = plan
159            .runtime_policy()
160            .user
161            .as_ref()
162            .map(|user| format!("{}:{}", user.uid(), user.gid()));
163
164        Ok(ResolvedImageConfig {
165            tag: self.tag.clone(),
166            os: "linux".to_string(),
167            architecture: architecture.to_string(),
168            user,
169            entrypoint,
170            cmd: self.cmd.clone(),
171            working_dir,
172            env: self.env.clone(),
173            labels: self.labels.clone(),
174        })
175    }
176}
177
178impl ResolvedImageConfig {
179    pub fn tag(&self) -> &str {
180        &self.tag
181    }
182
183    pub fn os(&self) -> &str {
184        &self.os
185    }
186
187    pub fn architecture(&self) -> &str {
188        &self.architecture
189    }
190
191    pub fn user(&self) -> Option<&str> {
192        self.user.as_deref()
193    }
194
195    pub fn entrypoint(&self) -> &[String] {
196        &self.entrypoint
197    }
198
199    pub fn cmd(&self) -> &[String] {
200        &self.cmd
201    }
202
203    pub fn working_dir(&self) -> &str {
204        &self.working_dir
205    }
206
207    pub fn env(&self) -> &[String] {
208        &self.env
209    }
210
211    pub fn labels(&self) -> &BTreeMap<String, String> {
212        &self.labels
213    }
214}
215
216fn validate_tag(tag: &str) -> Result<()> {
217    validate_value("image tag", tag)?;
218    let mut bytes = tag.bytes();
219    let Some(first) = bytes.next() else {
220        return Err(config_error("OCI image tag cannot be empty"));
221    };
222    if tag.len() > 128
223        || !(first.is_ascii_alphanumeric() || first == b'_')
224        || !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
225    {
226        return Err(config_error(format!(
227            "invalid OCI image tag `{tag}` (expected 1-128 characters from [A-Za-z0-9_.-])"
228        )));
229    }
230    Ok(())
231}
232
233fn validate_process_args(kind: &str, values: &[String]) -> Result<()> {
234    for value in values {
235        validate_value(kind, value)?;
236    }
237    Ok(())
238}
239
240fn validate_entrypoint(plan: &BundlePlan, entrypoint: &str) -> Result<()> {
241    let path = Path::new(entrypoint);
242    if !is_normalized_absolute(path)
243        || !plan
244            .files()
245            .iter()
246            .any(|file| file.destination() == path && file.kind() != PlannedFileKind::Directory)
247    {
248        return Err(config_error(format!(
249            "OCI entrypoint `{entrypoint}` must name an absolute planned file"
250        )));
251    }
252    Ok(())
253}
254
255fn validate_working_dir(plan: &BundlePlan, working_dir: &str) -> Result<()> {
256    validate_value("working directory", working_dir)?;
257    let path = Path::new(working_dir);
258    let planned = path == Path::new("/")
259        || plan
260            .files()
261            .iter()
262            .any(|file| file.destination() == path && file.kind() == PlannedFileKind::Directory);
263    if !is_normalized_absolute(path) || !planned {
264        return Err(config_error(format!(
265            "OCI working directory `{working_dir}` must name an absolute planned directory"
266        )));
267    }
268    Ok(())
269}
270
271fn is_normalized_absolute(path: &Path) -> bool {
272    path.is_absolute() && crate::paths::normalize_absolute(path) == path
273}
274
275fn validate_environment(env: &[String]) -> Result<()> {
276    let mut keys = BTreeSet::new();
277    for value in env {
278        validate_value("environment", value)?;
279        let Some((key, _)) = value.split_once('=') else {
280            return Err(config_error(format!(
281                "invalid OCI environment value `{value}` (expected KEY=VALUE)"
282            )));
283        };
284        if key.is_empty() || key.contains('\0') || !keys.insert(key) {
285            return Err(config_error(format!(
286                "OCI environment keys must be non-empty and unique (`{key}`)"
287            )));
288        }
289    }
290    Ok(())
291}
292
293fn validate_labels(labels: &BTreeMap<String, String>) -> Result<()> {
294    for (key, value) in labels {
295        validate_value("label key", key)?;
296        validate_value("label value", value)?;
297        if key.is_empty() {
298            return Err(config_error("OCI label key cannot be empty"));
299        }
300    }
301    Ok(())
302}
303
304fn validate_count(kind: &str, count: usize) -> Result<()> {
305    if count > OCI_METADATA_ENTRIES_MAX {
306        return Err(config_error(format!(
307            "OCI {kind} has {count} entries; the supported limit is 4,096"
308        )));
309    }
310    Ok(())
311}
312
313fn validate_value(kind: &str, value: &str) -> Result<()> {
314    if value.contains('\0') {
315        return Err(config_error(format!("OCI {kind} contains a NUL byte")));
316    }
317    if value.len() > OCI_METADATA_VALUE_BYTES_MAX {
318        return Err(config_error(format!(
319            "OCI {kind} exceeds the supported limit of 1,048,576 bytes"
320        )));
321    }
322    Ok(())
323}
324
325fn config_error(message: impl Into<String>) -> Error {
326    Error::Config {
327        message: message.into(),
328    }
329}