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::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
27/// Mode every file in a published layout gets, so that a consumer running as
28/// another user sees one consistent set of permissions.
29const 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    /// Permit replacing a destination directory that is not already a layout.
53    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            // Publication replaces the destination wholesale, so anything
71            // already there is deleted. Rebuilding a layout is the ordinary
72            // case; anything else has to be asked for.
73            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
91/// Whether an existing destination is one this builder may replace on its own:
92/// empty, or already carrying the layout marker `oci-layout`.
93fn 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
273/// A layout file with a fixed mode, on disk before the layout is published.
274///
275/// The mode is pinned because a layout is meant to be handed to another tool,
276/// sometimes running as another user, and the umask would otherwise make one
277/// file unreadable while the rest were fine. The sync is what keeps
278/// `index.json` from naming a blob whose bytes never reached the disk.
279fn 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}