1use crate::{BundlePlan, Error, Machine, PlannedFileKind, Result};
4use serde::Serialize;
5use std::{
6 collections::{BTreeMap, BTreeSet},
7 path::Path,
8};
9
10pub(super) const OCI_METADATA_ENTRIES_MAX: usize = 4096;
12pub(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#[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#[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}