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, Write},
21 os::unix::fs::PermissionsExt,
22 path::{Path, PathBuf},
23};
24
25const OCI_LAYOUT_VERSION: &str = "1.0.0";
26
27const LAYOUT_FILE_MODE: u32 = 0o644;
30
31#[derive(Debug)]
32pub struct OciLayoutBuilder {
33 output: PathBuf,
34 image: OciImageConfig,
35 clean: bool,
36}
37
38impl OciLayoutBuilder {
39 pub fn new(output: impl Into<PathBuf>) -> OciLayoutBuilder {
40 OciLayoutBuilder {
41 output: output.into(),
42 image: OciImageConfig::default(),
43 clean: false,
44 }
45 }
46
47 pub fn image(mut self, image: OciImageConfig) -> OciLayoutBuilder {
48 self.image = image;
49 self
50 }
51
52 pub fn clean(mut self, clean: bool) -> OciLayoutBuilder {
54 self.clean = clean;
55 self
56 }
57
58 pub fn apply(&self, plan: &BundlePlan) -> Result<OciReport> {
59 guard_output(&self.output)?;
60 let parent = output_parent(&self.output);
61 std::fs::create_dir_all(parent).map_err(|error| io(parent, error))?;
62 let stage = tempfile::Builder::new()
63 .prefix(".elfpak-oci-")
64 .permissions(std::fs::Permissions::from_mode(STAGE_MODE))
65 .tempdir_in(parent)
66 .map_err(|error| io(parent, error))?;
67
68 if path_exists(&self.output) {
69 ensure_directory(&self.output)?;
70 if !self.clean && !is_replaceable_layout(&self.output)? {
74 return Err(Error::Config {
75 message: format!(
76 "`{}` is not an empty directory or an OCI layout; \
77 publishing there would delete its contents (use --clean)",
78 self.output.display()
79 ),
80 });
81 }
82 }
83
84 set_directory_mode(stage.path(), 0o755)?;
85 let report = build_layout_into(stage.path(), plan, &self.image)?;
86 publish_directory(stage, &self.output)?;
87 Ok(report)
88 }
89}
90
91fn is_replaceable_layout(output: &Path) -> Result<bool> {
94 if output.join("oci-layout").is_file() {
95 return Ok(true);
96 }
97 let mut entries = std::fs::read_dir(output).map_err(|error| io(output, error))?;
98 Ok(entries.next().is_none())
99}
100
101#[derive(Debug)]
102pub struct OciReport {
103 layer_digest: Digest,
104 layer_size: u64,
105 config_digest: Digest,
106 config_size: u64,
107 manifest_digest: Digest,
108 manifest_size: u64,
109 platform: String,
110 image: ResolvedImageConfig,
111}
112
113impl OciReport {
114 pub fn layer_digest(&self) -> &Digest {
115 &self.layer_digest
116 }
117
118 pub fn layer_size(&self) -> u64 {
119 self.layer_size
120 }
121
122 pub fn config_digest(&self) -> &Digest {
123 &self.config_digest
124 }
125
126 pub fn config_size(&self) -> u64 {
127 self.config_size
128 }
129
130 pub fn manifest_digest(&self) -> &Digest {
131 &self.manifest_digest
132 }
133
134 pub fn manifest_size(&self) -> u64 {
135 self.manifest_size
136 }
137
138 pub fn platform(&self) -> &str {
139 &self.platform
140 }
141
142 pub fn image(&self) -> &ResolvedImageConfig {
143 &self.image
144 }
145}
146
147pub(crate) fn build_layout_into(
148 root: &Path,
149 plan: &BundlePlan,
150 image: &OciImageConfig,
151) -> Result<OciReport> {
152 let image = image.resolve(plan)?;
153 let blobs = root.join("blobs/sha256");
154 std::fs::create_dir_all(&blobs).map_err(|error| io(&blobs, error))?;
155
156 let (layer_digest, layer_size) = write_layer(&blobs, plan)?;
157 let layer_descriptor = descriptor(OCI_LAYER_TAR, &layer_digest, layer_size);
158
159 let config = ImageConfiguration {
160 architecture: image.architecture.clone(),
161 os: image.os.clone(),
162 config: RuntimeConfiguration {
163 user: image.user.clone(),
164 env: image.env.clone(),
165 entrypoint: image.entrypoint.clone(),
166 cmd: image.cmd.clone(),
167 working_dir: image.working_dir.clone(),
168 labels: image.labels.clone(),
169 },
170 rootfs: RootFs {
171 kind: "layers",
172 diff_ids: vec![oci_digest(&layer_digest)],
173 },
174 };
175 let config_bytes = serde_json::to_vec(&config).expect("OCI configuration is serializable");
176 let (config_digest, config_size) = write_blob(&blobs, &config_bytes)?;
177
178 let manifest = ImageManifest {
179 schema_version: 2,
180 media_type: OCI_IMAGE_MANIFEST,
181 config: descriptor(OCI_IMAGE_CONFIG, &config_digest, config_size),
182 layers: vec![layer_descriptor],
183 };
184 let manifest_bytes = serde_json::to_vec(&manifest).expect("OCI manifest is serializable");
185 let (manifest_digest, manifest_size) = write_blob(&blobs, &manifest_bytes)?;
186
187 let index = ImageIndex {
188 schema_version: 2,
189 media_type: OCI_IMAGE_INDEX,
190 manifests: vec![Descriptor {
191 media_type: OCI_IMAGE_MANIFEST,
192 digest: oci_digest(&manifest_digest),
193 size: manifest_size,
194 annotations: Some(BTreeMap::from([(
195 OCI_REF_NAME.to_string(),
196 image.tag.clone(),
197 )])),
198 platform: Some(Platform {
199 architecture: image.architecture.clone(),
200 os: image.os.clone(),
201 }),
202 }],
203 };
204 write_json_document(&root.join("index.json"), &index)?;
205 write_json_document(
206 &root.join("oci-layout"),
207 &serde_json::json!({ "imageLayoutVersion": OCI_LAYOUT_VERSION }),
208 )?;
209
210 Ok(OciReport {
211 layer_digest,
212 layer_size,
213 config_digest,
214 config_size,
215 manifest_digest,
216 manifest_size,
217 platform: format!("{}/{}", image.os, image.architecture),
218 image,
219 })
220}
221
222fn write_layer(blobs: &Path, plan: &BundlePlan) -> Result<(Digest, u64)> {
223 let mut stage = tempfile::NamedTempFile::new_in(blobs).map_err(|error| io(blobs, error))?;
224 let stage_path = stage.path().to_path_buf();
225 let writer = BufWriter::new(stage.as_file_mut());
226 let writer = HashingWriter::new(writer);
227 let (writer, _) = TarBuilder::new(&stage_path).write_to(writer, plan)?;
228 let (mut writer, digest, size) = writer.finish();
229 writer.flush().map_err(|error| io(&stage_path, error))?;
230 drop(writer);
231 stage
232 .as_file()
233 .set_permissions(std::fs::Permissions::from_mode(LAYOUT_FILE_MODE))
234 .map_err(|error| io(&stage_path, error))?;
235 stage
236 .as_file()
237 .sync_all()
238 .map_err(|error| io(&stage_path, error))?;
239 let destination = blobs.join(&digest.0);
240 stage
241 .persist(&destination)
242 .map_err(|error| io(&destination, error.error))?;
243 Ok((digest, size))
244}
245
246fn descriptor(media_type: &'static str, digest: &Digest, size: u64) -> Descriptor {
247 Descriptor {
248 media_type,
249 digest: oci_digest(digest),
250 size,
251 annotations: None,
252 platform: None,
253 }
254}
255
256fn oci_digest(digest: &Digest) -> String {
257 format!("sha256:{digest}")
258}
259
260fn write_blob(blobs: &Path, bytes: &[u8]) -> Result<(Digest, u64)> {
261 let digest = sha256_bytes(bytes);
262 let destination = blobs.join(&digest.0);
263 write_layout_file(&destination, bytes)?;
264 Ok((digest, bytes.len() as u64))
265}
266
267fn write_json_document(path: &Path, value: &impl serde::Serialize) -> Result<()> {
268 let mut bytes = serde_json::to_vec(value).expect("OCI metadata is serializable");
269 bytes.push(b'\n');
270 write_layout_file(path, &bytes)
271}
272
273fn write_layout_file(path: &Path, bytes: &[u8]) -> Result<()> {
280 let mut file = std::fs::File::create(path).map_err(|error| io(path, error))?;
281 file.write_all(bytes).map_err(|error| io(path, error))?;
282 file.set_permissions(std::fs::Permissions::from_mode(LAYOUT_FILE_MODE))
283 .map_err(|error| io(path, error))?;
284 file.sync_all().map_err(|error| io(path, error))
285}
286
287fn set_directory_mode(path: &Path, mode: u32) -> Result<()> {
288 use std::os::unix::fs::PermissionsExt;
289
290 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
291 .map_err(|error| io(path, error))
292}