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, 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
29/// Mode every file in a published layout gets, so that a consumer running as
30/// another user sees one consistent set of permissions.
31const 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    /// Permit replacing a destination directory that is not already a layout.
55    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            // Publication replaces the destination wholesale, so anything
73            // already there is deleted. Rebuilding a layout is the ordinary
74            // case; anything else has to be asked for.
75            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
93/// Whether an existing destination is one this builder may replace on its own:
94/// empty, or already carrying a valid layout marker `oci-layout`.
95fn 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
319/// A layout file with a fixed mode, on disk before the layout is published.
320///
321/// The mode is pinned because a layout is meant to be handed to another tool,
322/// sometimes running as another user, and the umask would otherwise make one
323/// file unreadable while the rest were fine. The sync is what keeps
324/// `index.json` from naming a blob whose bytes never reached the disk.
325fn 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}