use std::path::{Path, PathBuf};
use eyre::{Context, Result};
use sha2::{Digest, Sha256};
use crate::file;
use crate::oci::manifest::{
Descriptor, ImageIndex, ImageManifest, MEDIA_TYPE_OCI_INDEX, OciLayout, Platform,
};
pub struct ImageLayout {
pub root: PathBuf,
}
impl ImageLayout {
pub fn init(root: &Path) -> Result<Self> {
file::create_dir_all(root)?;
file::create_dir_all(root.join("blobs/sha256"))?;
let layout_path = root.join("oci-layout");
let layout = OciLayout::default();
file::write(&layout_path, serde_json::to_vec(&layout)?)?;
Ok(Self {
root: root.to_path_buf(),
})
}
pub fn write_blob(&self, bytes: &[u8]) -> Result<(String, u64)> {
let mut h = Sha256::new();
h.update(bytes);
let hex = crate::oci::layer::hex_encode(&h.finalize());
let digest = format!("sha256:{hex}");
let path = self.blob_path(&digest);
if !path.exists() {
file::write(&path, bytes)?;
}
Ok((digest, bytes.len() as u64))
}
pub fn write_blob_with_digest(&self, digest: &str, bytes: &[u8]) -> Result<()> {
validate_sha256_digest(digest)?;
let mut h = Sha256::new();
h.update(bytes);
let actual = format!("sha256:{}", crate::oci::layer::hex_encode(&h.finalize()));
if actual != digest {
eyre::bail!("blob digest mismatch: got {actual}, expected {digest}");
}
let path = self.blob_path(digest);
if !path.exists() {
file::write(&path, bytes)?;
}
Ok(())
}
pub fn blob_path(&self, digest: &str) -> PathBuf {
let hex = digest.trim_start_matches("sha256:");
self.root.join("blobs/sha256").join(hex)
}
#[allow(dead_code)]
pub fn read_blob(&self, digest: &str) -> Result<Vec<u8>> {
let path = self.blob_path(digest);
std::fs::read(&path).wrap_err_with(|| format!("reading blob {}", path.display()))
}
pub fn write_index(
&self,
manifest_digest: &str,
manifest_size: u64,
platform: Option<Platform>,
tag: Option<&str>,
) -> Result<()> {
use indexmap::IndexMap;
let mut annotations = IndexMap::new();
if let Some(tag) = tag {
annotations.insert(
"org.opencontainers.image.ref.name".to_string(),
tag.to_string(),
);
}
let desc = Descriptor {
media_type: crate::oci::manifest::MEDIA_TYPE_OCI_MANIFEST.to_string(),
size: manifest_size,
digest: manifest_digest.to_string(),
annotations,
platform,
};
let index = ImageIndex {
schema_version: 2,
media_type: MEDIA_TYPE_OCI_INDEX.to_string(),
manifests: vec![desc],
};
let path = self.root.join("index.json");
file::write(&path, serde_json::to_vec_pretty(&index)?)?;
Ok(())
}
pub fn write_manifest(&self, manifest: &ImageManifest) -> Result<(String, u64)> {
let bytes = serde_json::to_vec(manifest)?;
self.write_blob(&bytes)
}
}
pub(crate) fn validate_sha256_digest(digest: &str) -> Result<()> {
let Some(hex) = digest.strip_prefix("sha256:") else {
eyre::bail!("invalid blob digest (expected sha256: prefix): {digest}");
};
if hex.len() != 64
|| !hex
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
{
eyre::bail!("invalid blob digest (expected 64 lowercase hex chars): {digest}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_path_traversal() {
assert!(validate_sha256_digest("sha256:../../etc/passwd").is_err());
assert!(validate_sha256_digest("sha256:../foo").is_err());
assert!(validate_sha256_digest("../bad").is_err());
assert!(validate_sha256_digest("sha256:DEADBEEF").is_err());
}
#[test]
fn accepts_valid_digest() {
let d = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert!(validate_sha256_digest(d).is_ok());
}
}