1use super::model::{
4 Descriptor, ImageConfiguration, ImageIndex, ImageManifest, OCI_IMAGE_CONFIG, OCI_IMAGE_INDEX,
5 OCI_IMAGE_MANIFEST, OCI_LAYER_TAR, OCI_REF_NAME, OciImageConfig, Platform, ResolvedImageConfig,
6 RootFs, RuntimeConfiguration,
7};
8use crate::{
9 BundlePlan, Digest, Result,
10 error::Error,
11 error::io,
12 hash::{HashingWriter, sha256_bytes},
13 rootfs::{
14 STAGE_MODE, TarBuilder, ensure_directory, guard_output, output_parent, path_exists,
15 publish_directory,
16 },
17};
18use std::{
19 collections::BTreeMap,
20 io::{BufWriter, Read, Write},
21 os::unix::fs::PermissionsExt,
22 path::{Path, PathBuf},
23};
24
25const OCI_LAYOUT_VERSION: &str = "1.0.0";
26const OCI_LAYOUT_MARKER_BYTES_MAX: u64 = 1_024;
27const OCI_LAYOUT_INDEX_BYTES_MAX: u64 = 16 * 1024 * 1024;
28
29const LAYOUT_FILE_MODE: u32 = 0o644;
32
33#[derive(Debug)]
34pub struct OciLayoutBuilder {
35 output: PathBuf,
36 image: OciImageConfig,
37 clean: bool,
38}
39
40impl OciLayoutBuilder {
41 pub fn new(output: impl Into<PathBuf>) -> OciLayoutBuilder {
42 OciLayoutBuilder {
43 output: output.into(),
44 image: OciImageConfig::default(),
45 clean: false,
46 }
47 }
48
49 pub fn image(mut self, image: OciImageConfig) -> OciLayoutBuilder {
50 self.image = image;
51 self
52 }
53
54 pub fn clean(mut self, clean: bool) -> OciLayoutBuilder {
56 self.clean = clean;
57 self
58 }
59
60 pub fn apply(&self, plan: &BundlePlan) -> Result<OciReport> {
61 guard_output(&self.output)?;
62 let parent = output_parent(&self.output);
63 std::fs::create_dir_all(parent).map_err(|error| io(parent, error))?;
64 let stage = tempfile::Builder::new()
65 .prefix(".elfpak-oci-")
66 .permissions(std::fs::Permissions::from_mode(STAGE_MODE))
67 .tempdir_in(parent)
68 .map_err(|error| io(parent, error))?;
69
70 if path_exists(&self.output) {
71 ensure_directory(&self.output)?;
72 if !self.clean && !is_replaceable_layout(&self.output)? {
76 return Err(Error::Config {
77 message: format!(
78 "`{}` is not an empty directory or an OCI layout; \
79 publishing there would delete its contents (use --clean)",
80 self.output.display()
81 ),
82 });
83 }
84 }
85
86 set_directory_mode(stage.path(), 0o755)?;
87 let report = build_layout_into(stage.path(), plan, &self.image)?;
88 publish_directory(stage, &self.output)?;
89 Ok(report)
90 }
91}
92
93fn is_replaceable_layout(output: &Path) -> Result<bool> {
96 let marker = output.join("oci-layout");
97 if path_exists(&marker) {
98 return Ok(valid_layout_marker(&marker)? && valid_layout_index(&output.join("index.json"))?);
99 }
100 let mut entries = std::fs::read_dir(output).map_err(|error| io(output, error))?;
101 Ok(entries.next().is_none())
102}
103
104fn valid_layout_marker(marker: &Path) -> Result<bool> {
105 let Some(marker) = read_bounded_json(marker, OCI_LAYOUT_MARKER_BYTES_MAX)? else {
106 return Ok(false);
107 };
108 Ok(marker
109 .get("imageLayoutVersion")
110 .and_then(|value| value.as_str())
111 == Some(OCI_LAYOUT_VERSION))
112}
113
114fn valid_layout_index(index: &Path) -> Result<bool> {
115 let Some(index) = read_bounded_json(index, OCI_LAYOUT_INDEX_BYTES_MAX)? else {
116 return Ok(false);
117 };
118 Ok(
119 index.get("schemaVersion").and_then(|value| value.as_u64()) == Some(2)
120 && index.get("manifests").is_some_and(|value| value.is_array()),
121 )
122}
123
124fn read_bounded_json(path: &Path, limit: u64) -> Result<Option<serde_json::Value>> {
125 let metadata = match std::fs::symlink_metadata(path) {
126 Ok(metadata) => metadata,
127 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
128 Err(error) => return Err(io(path, error)),
129 };
130 if !metadata.file_type().is_file() || metadata.len() > limit {
131 return Ok(None);
132 }
133
134 let file = std::fs::File::open(path).map_err(|error| io(path, error))?;
135 let capacity = usize::try_from(metadata.len()).unwrap_or(0);
136 let mut bytes = Vec::with_capacity(capacity);
137 file.take(limit + 1)
138 .read_to_end(&mut bytes)
139 .map_err(|error| io(path, error))?;
140 if bytes.len() as u64 > limit {
141 return Ok(None);
142 }
143
144 Ok(serde_json::from_slice(&bytes).ok())
145}
146
147#[derive(Debug)]
148pub struct OciReport {
149 layer_digest: Digest,
150 layer_size: u64,
151 config_digest: Digest,
152 config_size: u64,
153 manifest_digest: Digest,
154 manifest_size: u64,
155 platform: String,
156 image: ResolvedImageConfig,
157}
158
159impl OciReport {
160 pub fn layer_digest(&self) -> &Digest {
161 &self.layer_digest
162 }
163
164 pub fn layer_size(&self) -> u64 {
165 self.layer_size
166 }
167
168 pub fn config_digest(&self) -> &Digest {
169 &self.config_digest
170 }
171
172 pub fn config_size(&self) -> u64 {
173 self.config_size
174 }
175
176 pub fn manifest_digest(&self) -> &Digest {
177 &self.manifest_digest
178 }
179
180 pub fn manifest_size(&self) -> u64 {
181 self.manifest_size
182 }
183
184 pub fn platform(&self) -> &str {
185 &self.platform
186 }
187
188 pub fn image(&self) -> &ResolvedImageConfig {
189 &self.image
190 }
191}
192
193pub(crate) fn build_layout_into(
194 root: &Path,
195 plan: &BundlePlan,
196 image: &OciImageConfig,
197) -> Result<OciReport> {
198 let image = image.resolve(plan)?;
199 let blobs = root.join("blobs/sha256");
200 std::fs::create_dir_all(&blobs).map_err(|error| io(&blobs, error))?;
201
202 let (layer_digest, layer_size) = write_layer(&blobs, plan)?;
203 let layer_descriptor = descriptor(OCI_LAYER_TAR, &layer_digest, layer_size);
204
205 let config = ImageConfiguration {
206 architecture: image.architecture.clone(),
207 os: image.os.clone(),
208 config: RuntimeConfiguration {
209 user: image.user.clone(),
210 env: image.env.clone(),
211 entrypoint: image.entrypoint.clone(),
212 cmd: image.cmd.clone(),
213 working_dir: image.working_dir.clone(),
214 labels: image.labels.clone(),
215 },
216 rootfs: RootFs {
217 kind: "layers",
218 diff_ids: vec![oci_digest(&layer_digest)],
219 },
220 };
221 let config_bytes = serde_json::to_vec(&config).expect("OCI configuration is serializable");
222 let (config_digest, config_size) = write_blob(&blobs, &config_bytes)?;
223
224 let manifest = ImageManifest {
225 schema_version: 2,
226 media_type: OCI_IMAGE_MANIFEST,
227 config: descriptor(OCI_IMAGE_CONFIG, &config_digest, config_size),
228 layers: vec![layer_descriptor],
229 };
230 let manifest_bytes = serde_json::to_vec(&manifest).expect("OCI manifest is serializable");
231 let (manifest_digest, manifest_size) = write_blob(&blobs, &manifest_bytes)?;
232
233 let index = ImageIndex {
234 schema_version: 2,
235 media_type: OCI_IMAGE_INDEX,
236 manifests: vec![Descriptor {
237 media_type: OCI_IMAGE_MANIFEST,
238 digest: oci_digest(&manifest_digest),
239 size: manifest_size,
240 annotations: Some(BTreeMap::from([(
241 OCI_REF_NAME.to_string(),
242 image.tag.clone(),
243 )])),
244 platform: Some(Platform {
245 architecture: image.architecture.clone(),
246 os: image.os.clone(),
247 }),
248 }],
249 };
250 write_json_document(&root.join("index.json"), &index)?;
251 write_json_document(
252 &root.join("oci-layout"),
253 &serde_json::json!({ "imageLayoutVersion": OCI_LAYOUT_VERSION }),
254 )?;
255
256 Ok(OciReport {
257 layer_digest,
258 layer_size,
259 config_digest,
260 config_size,
261 manifest_digest,
262 manifest_size,
263 platform: format!("{}/{}", image.os, image.architecture),
264 image,
265 })
266}
267
268fn write_layer(blobs: &Path, plan: &BundlePlan) -> Result<(Digest, u64)> {
269 let mut stage = tempfile::NamedTempFile::new_in(blobs).map_err(|error| io(blobs, error))?;
270 let stage_path = stage.path().to_path_buf();
271 let writer = BufWriter::new(stage.as_file_mut());
272 let writer = HashingWriter::new(writer);
273 let (writer, _) = TarBuilder::new(&stage_path).write_to(writer, plan)?;
274 let (mut writer, digest, size) = writer.finish();
275 writer.flush().map_err(|error| io(&stage_path, error))?;
276 drop(writer);
277 stage
278 .as_file()
279 .set_permissions(std::fs::Permissions::from_mode(LAYOUT_FILE_MODE))
280 .map_err(|error| io(&stage_path, error))?;
281 stage
282 .as_file()
283 .sync_all()
284 .map_err(|error| io(&stage_path, error))?;
285 let destination = blobs.join(&digest.0);
286 stage
287 .persist(&destination)
288 .map_err(|error| io(&destination, error.error))?;
289 Ok((digest, size))
290}
291
292fn descriptor(media_type: &'static str, digest: &Digest, size: u64) -> Descriptor {
293 Descriptor {
294 media_type,
295 digest: oci_digest(digest),
296 size,
297 annotations: None,
298 platform: None,
299 }
300}
301
302fn oci_digest(digest: &Digest) -> String {
303 format!("sha256:{digest}")
304}
305
306fn write_blob(blobs: &Path, bytes: &[u8]) -> Result<(Digest, u64)> {
307 let digest = sha256_bytes(bytes);
308 let destination = blobs.join(&digest.0);
309 write_layout_file(&destination, bytes)?;
310 Ok((digest, bytes.len() as u64))
311}
312
313fn write_json_document(path: &Path, value: &impl serde::Serialize) -> Result<()> {
314 let mut bytes = serde_json::to_vec(value).expect("OCI metadata is serializable");
315 bytes.push(b'\n');
316 write_layout_file(path, &bytes)
317}
318
319fn write_layout_file(path: &Path, bytes: &[u8]) -> Result<()> {
326 let mut file = std::fs::File::create(path).map_err(|error| io(path, error))?;
327 file.write_all(bytes).map_err(|error| io(path, error))?;
328 file.set_permissions(std::fs::Permissions::from_mode(LAYOUT_FILE_MODE))
329 .map_err(|error| io(path, error))?;
330 file.sync_all().map_err(|error| io(path, error))
331}
332
333fn set_directory_mode(path: &Path, mode: u32) -> Result<()> {
334 use std::os::unix::fs::PermissionsExt;
335
336 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
337 .map_err(|error| io(path, error))
338}