use super::model::{
Descriptor, ImageConfiguration, ImageIndex, ImageManifest, OCI_IMAGE_CONFIG, OCI_IMAGE_INDEX,
OCI_IMAGE_MANIFEST, OCI_LAYER_TAR, OCI_REF_NAME, OciImageConfig, Platform, ResolvedImageConfig,
RootFs, RuntimeConfiguration,
};
use crate::{
BundlePlan, Digest, Result,
error::Error,
error::io,
hash::{HashingWriter, sha256_bytes},
rootfs::{
STAGE_MODE, TarBuilder, ensure_directory, guard_output, output_parent, path_exists,
publish_directory,
},
};
use std::{
collections::BTreeMap,
io::{BufWriter, Read, Write},
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
};
const OCI_LAYOUT_VERSION: &str = "1.0.0";
const OCI_LAYOUT_MARKER_BYTES_MAX: u64 = 1_024;
const OCI_LAYOUT_INDEX_BYTES_MAX: u64 = 16 * 1024 * 1024;
const LAYOUT_FILE_MODE: u32 = 0o644;
#[derive(Debug)]
pub struct OciLayoutBuilder {
output: PathBuf,
image: OciImageConfig,
clean: bool,
}
impl OciLayoutBuilder {
pub fn new(output: impl Into<PathBuf>) -> OciLayoutBuilder {
OciLayoutBuilder {
output: output.into(),
image: OciImageConfig::default(),
clean: false,
}
}
pub fn image(mut self, image: OciImageConfig) -> OciLayoutBuilder {
self.image = image;
self
}
pub fn clean(mut self, clean: bool) -> OciLayoutBuilder {
self.clean = clean;
self
}
pub fn apply(&self, plan: &BundlePlan) -> Result<OciReport> {
guard_output(&self.output)?;
let parent = output_parent(&self.output);
std::fs::create_dir_all(parent).map_err(|error| io(parent, error))?;
let stage = tempfile::Builder::new()
.prefix(".elfpak-oci-")
.permissions(std::fs::Permissions::from_mode(STAGE_MODE))
.tempdir_in(parent)
.map_err(|error| io(parent, error))?;
if path_exists(&self.output) {
ensure_directory(&self.output)?;
if !self.clean && !is_replaceable_layout(&self.output)? {
return Err(Error::Config {
message: format!(
"`{}` is not an empty directory or an OCI layout; \
publishing there would delete its contents (use --clean)",
self.output.display()
),
});
}
}
set_directory_mode(stage.path(), 0o755)?;
let report = build_layout_into(stage.path(), plan, &self.image)?;
publish_directory(stage, &self.output)?;
Ok(report)
}
}
fn is_replaceable_layout(output: &Path) -> Result<bool> {
let marker = output.join("oci-layout");
if path_exists(&marker) {
return Ok(valid_layout_marker(&marker)? && valid_layout_index(&output.join("index.json"))?);
}
let mut entries = std::fs::read_dir(output).map_err(|error| io(output, error))?;
Ok(entries.next().is_none())
}
fn valid_layout_marker(marker: &Path) -> Result<bool> {
let Some(marker) = read_bounded_json(marker, OCI_LAYOUT_MARKER_BYTES_MAX)? else {
return Ok(false);
};
Ok(marker
.get("imageLayoutVersion")
.and_then(|value| value.as_str())
== Some(OCI_LAYOUT_VERSION))
}
fn valid_layout_index(index: &Path) -> Result<bool> {
let Some(index) = read_bounded_json(index, OCI_LAYOUT_INDEX_BYTES_MAX)? else {
return Ok(false);
};
Ok(
index.get("schemaVersion").and_then(|value| value.as_u64()) == Some(2)
&& index.get("manifests").is_some_and(|value| value.is_array()),
)
}
fn read_bounded_json(path: &Path, limit: u64) -> Result<Option<serde_json::Value>> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(io(path, error)),
};
if !metadata.file_type().is_file() || metadata.len() > limit {
return Ok(None);
}
let file = std::fs::File::open(path).map_err(|error| io(path, error))?;
let capacity = usize::try_from(metadata.len()).unwrap_or(0);
let mut bytes = Vec::with_capacity(capacity);
file.take(limit + 1)
.read_to_end(&mut bytes)
.map_err(|error| io(path, error))?;
if bytes.len() as u64 > limit {
return Ok(None);
}
Ok(serde_json::from_slice(&bytes).ok())
}
#[derive(Debug)]
pub struct OciReport {
layer_digest: Digest,
layer_size: u64,
config_digest: Digest,
config_size: u64,
manifest_digest: Digest,
manifest_size: u64,
platform: String,
image: ResolvedImageConfig,
}
impl OciReport {
pub fn layer_digest(&self) -> &Digest {
&self.layer_digest
}
pub fn layer_size(&self) -> u64 {
self.layer_size
}
pub fn config_digest(&self) -> &Digest {
&self.config_digest
}
pub fn config_size(&self) -> u64 {
self.config_size
}
pub fn manifest_digest(&self) -> &Digest {
&self.manifest_digest
}
pub fn manifest_size(&self) -> u64 {
self.manifest_size
}
pub fn platform(&self) -> &str {
&self.platform
}
pub fn image(&self) -> &ResolvedImageConfig {
&self.image
}
}
pub(crate) fn build_layout_into(
root: &Path,
plan: &BundlePlan,
image: &OciImageConfig,
) -> Result<OciReport> {
let image = image.resolve(plan)?;
let blobs = root.join("blobs/sha256");
std::fs::create_dir_all(&blobs).map_err(|error| io(&blobs, error))?;
let (layer_digest, layer_size) = write_layer(&blobs, plan)?;
let layer_descriptor = descriptor(OCI_LAYER_TAR, &layer_digest, layer_size);
let config = ImageConfiguration {
architecture: image.architecture.clone(),
os: image.os.clone(),
config: RuntimeConfiguration {
user: image.user.clone(),
env: image.env.clone(),
entrypoint: image.entrypoint.clone(),
cmd: image.cmd.clone(),
working_dir: image.working_dir.clone(),
labels: image.labels.clone(),
},
rootfs: RootFs {
kind: "layers",
diff_ids: vec![oci_digest(&layer_digest)],
},
};
let config_bytes = serde_json::to_vec(&config).expect("OCI configuration is serializable");
let (config_digest, config_size) = write_blob(&blobs, &config_bytes)?;
let manifest = ImageManifest {
schema_version: 2,
media_type: OCI_IMAGE_MANIFEST,
config: descriptor(OCI_IMAGE_CONFIG, &config_digest, config_size),
layers: vec![layer_descriptor],
};
let manifest_bytes = serde_json::to_vec(&manifest).expect("OCI manifest is serializable");
let (manifest_digest, manifest_size) = write_blob(&blobs, &manifest_bytes)?;
let index = ImageIndex {
schema_version: 2,
media_type: OCI_IMAGE_INDEX,
manifests: vec![Descriptor {
media_type: OCI_IMAGE_MANIFEST,
digest: oci_digest(&manifest_digest),
size: manifest_size,
annotations: Some(BTreeMap::from([(
OCI_REF_NAME.to_string(),
image.tag.clone(),
)])),
platform: Some(Platform {
architecture: image.architecture.clone(),
os: image.os.clone(),
}),
}],
};
write_json_document(&root.join("index.json"), &index)?;
write_json_document(
&root.join("oci-layout"),
&serde_json::json!({ "imageLayoutVersion": OCI_LAYOUT_VERSION }),
)?;
Ok(OciReport {
layer_digest,
layer_size,
config_digest,
config_size,
manifest_digest,
manifest_size,
platform: format!("{}/{}", image.os, image.architecture),
image,
})
}
fn write_layer(blobs: &Path, plan: &BundlePlan) -> Result<(Digest, u64)> {
let mut stage = tempfile::NamedTempFile::new_in(blobs).map_err(|error| io(blobs, error))?;
let stage_path = stage.path().to_path_buf();
let writer = BufWriter::new(stage.as_file_mut());
let writer = HashingWriter::new(writer);
let (writer, _) = TarBuilder::new(&stage_path).write_to(writer, plan)?;
let (mut writer, digest, size) = writer.finish();
writer.flush().map_err(|error| io(&stage_path, error))?;
drop(writer);
stage
.as_file()
.set_permissions(std::fs::Permissions::from_mode(LAYOUT_FILE_MODE))
.map_err(|error| io(&stage_path, error))?;
stage
.as_file()
.sync_all()
.map_err(|error| io(&stage_path, error))?;
let destination = blobs.join(&digest.0);
stage
.persist(&destination)
.map_err(|error| io(&destination, error.error))?;
Ok((digest, size))
}
fn descriptor(media_type: &'static str, digest: &Digest, size: u64) -> Descriptor {
Descriptor {
media_type,
digest: oci_digest(digest),
size,
annotations: None,
platform: None,
}
}
fn oci_digest(digest: &Digest) -> String {
format!("sha256:{digest}")
}
fn write_blob(blobs: &Path, bytes: &[u8]) -> Result<(Digest, u64)> {
let digest = sha256_bytes(bytes);
let destination = blobs.join(&digest.0);
write_layout_file(&destination, bytes)?;
Ok((digest, bytes.len() as u64))
}
fn write_json_document(path: &Path, value: &impl serde::Serialize) -> Result<()> {
let mut bytes = serde_json::to_vec(value).expect("OCI metadata is serializable");
bytes.push(b'\n');
write_layout_file(path, &bytes)
}
fn write_layout_file(path: &Path, bytes: &[u8]) -> Result<()> {
let mut file = std::fs::File::create(path).map_err(|error| io(path, error))?;
file.write_all(bytes).map_err(|error| io(path, error))?;
file.set_permissions(std::fs::Permissions::from_mode(LAYOUT_FILE_MODE))
.map_err(|error| io(path, error))?;
file.sync_all().map_err(|error| io(path, error))
}
fn set_directory_mode(path: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.map_err(|error| io(path, error))
}