Skip to main content

elfpak_core/oci/
layout.rs

1//! Deterministic OCI image-layout directory output.
2
3use 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::io,
11    hash::{HashingWriter, sha256_bytes},
12    rootfs::{
13        TarBuilder, ensure_directory, guard_output, output_parent, path_exists, publish_directory,
14    },
15};
16use std::{
17    collections::BTreeMap,
18    io::{BufWriter, Write},
19    path::{Path, PathBuf},
20};
21
22const OCI_LAYOUT_VERSION: &str = "1.0.0";
23
24#[derive(Debug)]
25pub struct OciLayoutBuilder {
26    output: PathBuf,
27    image: OciImageConfig,
28}
29
30impl OciLayoutBuilder {
31    pub fn new(output: impl Into<PathBuf>) -> OciLayoutBuilder {
32        OciLayoutBuilder {
33            output: output.into(),
34            image: OciImageConfig::default(),
35        }
36    }
37
38    pub fn image(mut self, image: OciImageConfig) -> OciLayoutBuilder {
39        self.image = image;
40        self
41    }
42
43    pub fn apply(&self, plan: &BundlePlan) -> Result<OciReport> {
44        guard_output(&self.output)?;
45        let parent = output_parent(&self.output);
46        std::fs::create_dir_all(parent).map_err(|error| io(parent, error))?;
47        let stage = tempfile::Builder::new()
48            .prefix(".elfpak-oci-")
49            .tempdir_in(parent)
50            .map_err(|error| io(parent, error))?;
51
52        if path_exists(&self.output) {
53            ensure_directory(&self.output)?;
54        }
55
56        set_directory_mode(stage.path(), 0o755)?;
57        let report = build_layout_into(stage.path(), plan, &self.image)?;
58        publish_directory(stage, &self.output)?;
59        Ok(report)
60    }
61}
62
63#[derive(Debug)]
64pub struct OciReport {
65    layer_digest: Digest,
66    layer_size: u64,
67    config_digest: Digest,
68    config_size: u64,
69    manifest_digest: Digest,
70    manifest_size: u64,
71    platform: String,
72    image: ResolvedImageConfig,
73}
74
75impl OciReport {
76    pub fn layer_digest(&self) -> &Digest {
77        &self.layer_digest
78    }
79
80    pub fn layer_size(&self) -> u64 {
81        self.layer_size
82    }
83
84    pub fn config_digest(&self) -> &Digest {
85        &self.config_digest
86    }
87
88    pub fn config_size(&self) -> u64 {
89        self.config_size
90    }
91
92    pub fn manifest_digest(&self) -> &Digest {
93        &self.manifest_digest
94    }
95
96    pub fn manifest_size(&self) -> u64 {
97        self.manifest_size
98    }
99
100    pub fn platform(&self) -> &str {
101        &self.platform
102    }
103
104    pub fn image(&self) -> &ResolvedImageConfig {
105        &self.image
106    }
107}
108
109pub(crate) fn build_layout_into(
110    root: &Path,
111    plan: &BundlePlan,
112    image: &OciImageConfig,
113) -> Result<OciReport> {
114    let image = image.resolve(plan)?;
115    let blobs = root.join("blobs/sha256");
116    std::fs::create_dir_all(&blobs).map_err(|error| io(&blobs, error))?;
117
118    let (layer_digest, layer_size) = write_layer(&blobs, plan)?;
119    let layer_descriptor = descriptor(OCI_LAYER_TAR, &layer_digest, layer_size);
120
121    let config = ImageConfiguration {
122        architecture: image.architecture.clone(),
123        os: image.os.clone(),
124        config: RuntimeConfiguration {
125            user: image.user.clone(),
126            env: image.env.clone(),
127            entrypoint: image.entrypoint.clone(),
128            cmd: image.cmd.clone(),
129            working_dir: image.working_dir.clone(),
130            labels: image.labels.clone(),
131        },
132        rootfs: RootFs {
133            kind: "layers",
134            diff_ids: vec![oci_digest(&layer_digest)],
135        },
136    };
137    let config_bytes = serde_json::to_vec(&config).expect("OCI configuration is serializable");
138    let (config_digest, config_size) = write_blob(&blobs, &config_bytes)?;
139
140    let manifest = ImageManifest {
141        schema_version: 2,
142        media_type: OCI_IMAGE_MANIFEST,
143        config: descriptor(OCI_IMAGE_CONFIG, &config_digest, config_size),
144        layers: vec![layer_descriptor],
145    };
146    let manifest_bytes = serde_json::to_vec(&manifest).expect("OCI manifest is serializable");
147    let (manifest_digest, manifest_size) = write_blob(&blobs, &manifest_bytes)?;
148
149    let index = ImageIndex {
150        schema_version: 2,
151        media_type: OCI_IMAGE_INDEX,
152        manifests: vec![Descriptor {
153            media_type: OCI_IMAGE_MANIFEST,
154            digest: oci_digest(&manifest_digest),
155            size: manifest_size,
156            annotations: Some(BTreeMap::from([(
157                OCI_REF_NAME.to_string(),
158                image.tag.clone(),
159            )])),
160            platform: Some(Platform {
161                architecture: image.architecture.clone(),
162                os: image.os.clone(),
163            }),
164        }],
165    };
166    write_json_document(&root.join("index.json"), &index)?;
167    write_json_document(
168        &root.join("oci-layout"),
169        &serde_json::json!({ "imageLayoutVersion": OCI_LAYOUT_VERSION }),
170    )?;
171
172    Ok(OciReport {
173        layer_digest,
174        layer_size,
175        config_digest,
176        config_size,
177        manifest_digest,
178        manifest_size,
179        platform: format!("{}/{}", image.os, image.architecture),
180        image,
181    })
182}
183
184fn write_layer(blobs: &Path, plan: &BundlePlan) -> Result<(Digest, u64)> {
185    let mut stage = tempfile::NamedTempFile::new_in(blobs).map_err(|error| io(blobs, error))?;
186    let stage_path = stage.path().to_path_buf();
187    let writer = BufWriter::new(stage.as_file_mut());
188    let writer = HashingWriter::new(writer);
189    let (writer, _) = TarBuilder::new(&stage_path).write_to(writer, plan)?;
190    let (mut writer, digest, size) = writer.finish();
191    writer.flush().map_err(|error| io(&stage_path, error))?;
192    drop(writer);
193    stage
194        .as_file()
195        .sync_all()
196        .map_err(|error| io(&stage_path, error))?;
197    let destination = blobs.join(&digest.0);
198    stage
199        .persist(&destination)
200        .map_err(|error| io(&destination, error.error))?;
201    Ok((digest, size))
202}
203
204fn descriptor(media_type: &'static str, digest: &Digest, size: u64) -> Descriptor {
205    Descriptor {
206        media_type,
207        digest: oci_digest(digest),
208        size,
209        annotations: None,
210        platform: None,
211    }
212}
213
214fn oci_digest(digest: &Digest) -> String {
215    format!("sha256:{digest}")
216}
217
218fn write_blob(blobs: &Path, bytes: &[u8]) -> Result<(Digest, u64)> {
219    let digest = sha256_bytes(bytes);
220    let destination = blobs.join(&digest.0);
221    std::fs::write(&destination, bytes).map_err(|error| io(&destination, error))?;
222    Ok((digest, bytes.len() as u64))
223}
224
225fn write_json_document(path: &Path, value: &impl serde::Serialize) -> Result<()> {
226    let mut bytes = serde_json::to_vec(value).expect("OCI metadata is serializable");
227    bytes.push(b'\n');
228    std::fs::write(path, bytes).map_err(|error| io(path, error))
229}
230
231fn set_directory_mode(path: &Path, mode: u32) -> Result<()> {
232    use std::os::unix::fs::PermissionsExt;
233
234    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
235        .map_err(|error| io(path, error))
236}