#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
use canon_json::CanonicalFormatter;
use cap_std::fs::{Dir, DirBuilderExt};
use cap_std_ext::cap_tempfile;
use cap_std_ext::dirext::CapStdExtDirExt;
use flate2::write::GzEncoder;
use oci_image::MediaType;
use oci_spec::image::{
self as oci_image, Descriptor, Digest, ImageConfiguration, ImageIndex, ImageManifest, Platform,
Sha256Digest,
};
use openssl::hash::{Hasher, MessageDigest};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt::Debug;
use std::fs::File;
use std::io::{BufReader, BufWriter, prelude::*};
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use thiserror::Error;
pub use cap_std_ext::cap_std;
pub use oci_spec;
const BLOBDIR: &str = "blobs/sha256";
const OCI_TAG_ANNOTATION: &str = "org.opencontainers.image.ref.name";
const BLOB_BUF_SIZE: usize = 128 * 1024;
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum Error {
#[error("i/o error")]
Io(#[from] std::io::Error),
#[error("serialization error")]
SerDe(#[from] serde_json::Error),
#[error("parsing OCI value")]
OciSpecError(#[from] oci_spec::OciSpecError),
#[error("unexpected cryptographic routine error")]
CryptographicError(Box<str>),
#[error("Expected digest {expected} but found {found}")]
DigestMismatch {
expected: Box<str>,
found: Box<str>,
},
#[error("Expected size {expected} but found {found}")]
SizeMismatch {
expected: u64,
found: u64,
},
#[error("Expected digest algorithm sha256 but found {found}")]
UnsupportedDigestAlgorithm {
found: Box<str>,
},
#[error("Cannot find the Image Index (index.json)")]
MissingImageIndex,
#[error("Image index contains no manifests")]
EmptyImageIndex,
#[error("Tag '{tag}' not found in image index")]
TagNotFound {
tag: Box<str>,
},
#[error("No manifest found for platform {os}/{architecture}; available: {available}")]
NoMatchingPlatform {
os: Box<str>,
architecture: Box<str>,
available: Box<str>,
},
#[error("Unexpected media type {media_type}")]
UnexpectedMediaType {
media_type: MediaType,
},
#[error("Nested image indices are not supported")]
NestedImageIndex,
#[error("error")]
Other(Box<str>),
}
pub type Result<T> = std::result::Result<T, Error>;
impl From<openssl::error::Error> for Error {
fn from(value: openssl::error::Error) -> Self {
Self::CryptographicError(value.to_string().into())
}
}
impl From<openssl::error::ErrorStack> for Error {
fn from(value: openssl::error::ErrorStack) -> Self {
Self::CryptographicError(value.to_string().into())
}
}
#[derive(Serialize, Deserialize)]
struct EmptyDescriptor {}
#[derive(Debug)]
pub struct Blob {
sha256: oci_image::Sha256Digest,
size: u64,
}
impl Blob {
pub fn sha256(&self) -> &oci_image::Sha256Digest {
&self.sha256
}
pub fn descriptor(&self) -> oci_image::DescriptorBuilder {
oci_image::DescriptorBuilder::default()
.digest(self.sha256.clone())
.size(self.size)
}
pub fn size(&self) -> u64 {
self.size
}
}
#[derive(Debug)]
pub struct ResolvedManifest {
pub manifest: ImageManifest,
pub manifest_descriptor: Descriptor,
pub source_index: Option<(ImageIndex, Descriptor)>,
}
#[derive(Debug)]
pub struct Layer {
pub blob: Blob,
pub uncompressed_sha256: Sha256Digest,
pub media_type: MediaType,
}
impl Layer {
pub fn descriptor(&self) -> oci_image::DescriptorBuilder {
self.blob.descriptor().media_type(self.media_type.clone())
}
pub fn uncompressed_sha256_as_digest(&self) -> Digest {
self.uncompressed_sha256.clone().into()
}
}
pub struct BlobWriter<'a> {
hash: Hasher,
target: Option<BufWriter<cap_tempfile::TempFile<'a>>>,
size: u64,
}
impl Debug for BlobWriter<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BlobWriter")
.field("target", &self.target)
.field("size", &self.size)
.finish()
}
}
#[derive(Debug)]
pub struct OciDir {
dir: Dir,
blobs_dir: Dir,
}
fn sha256_of_descriptor(desc: &Descriptor) -> Result<&str> {
desc.as_digest_sha256()
.ok_or_else(|| Error::UnsupportedDigestAlgorithm {
found: desc.digest().to_string().into(),
})
}
impl OciDir {
fn empty_config_descriptor(&self) -> Result<oci_image::Descriptor> {
let empty_descriptor = oci_image::DescriptorBuilder::default()
.media_type(MediaType::EmptyJSON)
.size(2_u32)
.digest(Sha256Digest::from_str(
"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
)?)
.data("e30=")
.build()?;
if !self
.dir
.exists(OciDir::parse_descriptor_to_path(&empty_descriptor)?)
{
let mut blob = self.create_blob()?;
serde_json::to_writer(&mut blob, &EmptyDescriptor {})?;
blob.complete_verified_as(&empty_descriptor)?;
}
Ok(empty_descriptor)
}
pub fn new_empty_manifest(&self) -> Result<oci_image::ImageManifestBuilder> {
Ok(oci_image::ImageManifestBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.config(self.empty_config_descriptor()?)
.layers(Vec::new()))
}
pub fn ensure(dir: Dir) -> Result<Self> {
let mut db = cap_std::fs::DirBuilder::new();
db.recursive(true).mode(0o755);
dir.ensure_dir_with(BLOBDIR, &db)?;
if !dir.try_exists("oci-layout")? {
dir.atomic_write("oci-layout", r#"{"imageLayoutVersion":"1.0.0"}"#)?;
}
Self::open(dir)
}
pub fn clone_to(&self, destdir: &Dir, p: impl AsRef<Path>) -> Result<Self> {
let p = p.as_ref();
destdir.create_dir(p)?;
let cloned = Self::ensure(destdir.open_dir(p)?)?;
for blob in self.blobs_dir.entries()? {
let blob = blob?;
let path = Path::new(BLOBDIR).join(blob.file_name());
let mut src = self.dir.open(&path).map(BufReader::new)?;
self.dir
.atomic_replace_with(&path, |w| std::io::copy(&mut src, w))?;
}
Ok(cloned)
}
pub fn open(dir: Dir) -> Result<Self> {
let blobs_dir = dir.open_dir(BLOBDIR)?;
Self::open_with_external_blobs(dir, blobs_dir)
}
pub fn open_with_external_blobs(dir: Dir, blobs_dir: Dir) -> Result<Self> {
Ok(Self { dir, blobs_dir })
}
pub fn dir(&self) -> &Dir {
&self.dir
}
pub fn blobs_dir(&self) -> &Dir {
&self.blobs_dir
}
pub fn write_json_blob<S: serde::Serialize>(
&self,
v: &S,
media_type: oci_image::MediaType,
) -> Result<oci_image::DescriptorBuilder> {
let mut w = BlobWriter::new(&self.dir)?;
let mut ser = serde_json::Serializer::with_formatter(&mut w, CanonicalFormatter::new());
v.serialize(&mut ser)?;
let blob = w.complete()?;
Ok(blob.descriptor().media_type(media_type))
}
pub fn create_blob(&self) -> Result<BlobWriter<'_>> {
BlobWriter::new(&self.dir)
}
pub fn create_custom_layer<'a, W: WriteComplete<BlobWriter<'a>>>(
&'a self,
create: impl FnOnce(BlobWriter<'a>) -> std::io::Result<W>,
media_type: MediaType,
) -> Result<LayerWriter<'a, W>> {
let bw = BlobWriter::new(&self.dir)?;
Ok(LayerWriter::new(create(bw)?, media_type))
}
pub fn create_uncompressed_layer(&self) -> Result<LayerWriter<'_, BlobWriter<'_>>> {
let bw = BlobWriter::new(&self.dir)?;
Ok(LayerWriter::new_uncompressed(bw, MediaType::ImageLayer))
}
pub fn create_gzip_layer<'a>(
&'a self,
c: Option<flate2::Compression>,
) -> Result<LayerWriter<'a, GzEncoder<BlobWriter<'a>>>> {
let creator = |bw: BlobWriter<'a>| Ok(GzEncoder::new(bw, c.unwrap_or_default()));
self.create_custom_layer(creator, MediaType::ImageLayerGzip)
}
pub fn create_layer(
&'_ self,
c: Option<flate2::Compression>,
) -> Result<tar::Builder<LayerWriter<'_, GzEncoder<BlobWriter<'_>>>>> {
Ok(tar::Builder::new(self.create_gzip_layer(c)?))
}
#[cfg(feature = "zstd")]
pub fn create_layer_zstd<'a>(
&'a self,
compression_level: Option<i32>,
) -> Result<LayerWriter<'a, zstd::Encoder<'static, BlobWriter<'a>>>> {
let creator = |bw: BlobWriter<'a>| zstd::Encoder::new(bw, compression_level.unwrap_or(0));
self.create_custom_layer(creator, MediaType::ImageLayerZstd)
}
#[cfg(feature = "zstdmt")]
pub fn create_layer_zstd_multithread<'a>(
&'a self,
compression_level: Option<i32>,
n_workers: u32,
) -> Result<LayerWriter<'a, zstd::Encoder<'static, BlobWriter<'a>>>> {
let creator = |bw: BlobWriter<'a>| {
let mut encoder = zstd::Encoder::new(bw, compression_level.unwrap_or(0))?;
encoder.multithread(n_workers)?;
Ok(encoder)
};
self.create_custom_layer(creator, MediaType::ImageLayerZstd)
}
pub fn push_layer(
&self,
manifest: &mut oci_image::ImageManifest,
config: &mut oci_image::ImageConfiguration,
layer: Layer,
description: &str,
annotations: Option<HashMap<String, String>>,
) {
self.push_layer_annotated(manifest, config, layer, annotations, description);
}
pub fn push_layer_annotated(
&self,
manifest: &mut oci_image::ImageManifest,
config: &mut oci_image::ImageConfiguration,
layer: Layer,
annotations: Option<impl Into<HashMap<String, String>>>,
description: &str,
) {
let created = chrono::offset::Utc::now();
self.push_layer_full(manifest, config, layer, annotations, description, created)
}
pub fn push_layer_full(
&self,
manifest: &mut oci_image::ImageManifest,
config: &mut oci_image::ImageConfiguration,
layer: Layer,
annotations: Option<impl Into<HashMap<String, String>>>,
description: &str,
created: chrono::DateTime<chrono::Utc>,
) {
let history = oci_image::HistoryBuilder::default()
.created(created.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.created_by(description.to_string())
.build()
.unwrap();
self.push_layer_with_history_annotated(manifest, config, layer, annotations, Some(history));
}
pub fn push_layer_with_history_annotated(
&self,
manifest: &mut oci_image::ImageManifest,
config: &mut oci_image::ImageConfiguration,
layer: Layer,
annotations: Option<impl Into<HashMap<String, String>>>,
history: Option<oci_image::History>,
) {
let mut builder = layer.descriptor();
if let Some(annotations) = annotations {
builder = builder.annotations(annotations);
}
let blobdesc = builder.build().unwrap();
manifest.layers_mut().push(blobdesc);
let mut rootfs = config.rootfs().clone();
rootfs
.diff_ids_mut()
.push(layer.uncompressed_sha256_as_digest().to_string());
config.set_rootfs(rootfs);
let history = if let Some(history) = history {
history
} else {
oci_image::HistoryBuilder::default().build().unwrap()
};
config.history_mut().get_or_insert_default().push(history);
}
pub fn push_layer_with_history(
&self,
manifest: &mut oci_image::ImageManifest,
config: &mut oci_image::ImageConfiguration,
layer: Layer,
history: Option<oci_image::History>,
) {
let annotations: Option<HashMap<_, _>> = None;
self.push_layer_with_history_annotated(manifest, config, layer, annotations, history);
}
fn parse_descriptor_to_path(desc: &oci_spec::image::Descriptor) -> Result<PathBuf> {
let digest = sha256_of_descriptor(desc)?;
Ok(PathBuf::from(digest))
}
pub fn read_blob(&self, desc: &oci_spec::image::Descriptor) -> Result<File> {
let path = Self::parse_descriptor_to_path(desc)?;
let f = self.blobs_dir.open(path).map(|f| f.into_std())?;
let expected: u64 = desc.size();
let found = f.metadata()?.len();
if expected != found {
return Err(Error::SizeMismatch { expected, found });
}
Ok(f)
}
pub fn has_blob(&self, desc: &oci_spec::image::Descriptor) -> Result<bool> {
let path = Self::parse_descriptor_to_path(desc)?;
self.blobs_dir.try_exists(path).map_err(Into::into)
}
pub fn has_manifest(&self, desc: &oci_spec::image::Descriptor) -> Result<bool> {
let index = self.read_index()?;
Ok(index
.manifests()
.iter()
.any(|m| m.digest() == desc.digest()))
}
pub fn read_json_blob<T: serde::de::DeserializeOwned>(
&self,
desc: &oci_spec::image::Descriptor,
) -> Result<T> {
let blob = BufReader::new(self.read_blob(desc)?);
serde_json::from_reader(blob).map_err(Into::into)
}
pub fn write_config(
&self,
config: oci_image::ImageConfiguration,
) -> Result<oci_image::Descriptor> {
Ok(self
.write_json_blob(&config, MediaType::ImageConfig)?
.build()
.unwrap())
}
pub fn read_index(&self) -> Result<ImageIndex> {
let r = if let Some(index) = self.dir.open_optional("index.json")?.map(BufReader::new) {
oci_image::ImageIndex::from_reader(index)?
} else {
return Err(Error::MissingImageIndex);
};
Ok(r)
}
pub fn insert_manifest(
&self,
manifest: oci_image::ImageManifest,
tag: Option<&str>,
platform: oci_image::Platform,
) -> Result<Descriptor> {
let mut manifest = self
.write_json_blob(&manifest, MediaType::ImageManifest)?
.platform(platform)
.build()
.unwrap();
if let Some(tag) = tag {
let annotations: HashMap<_, _> = [(OCI_TAG_ANNOTATION.to_string(), tag.to_string())]
.into_iter()
.collect();
manifest.set_annotations(Some(annotations));
}
let index = match self.read_index() {
Ok(mut index) => {
let mut manifests = index.manifests().clone();
if let Some(tag) = tag {
manifests.retain(|d| !Self::descriptor_is_tagged(d, tag));
}
manifests.push(manifest.clone());
index.set_manifests(manifests);
index
}
Err(Error::MissingImageIndex) => oci_image::ImageIndexBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.manifests(vec![manifest.clone()])
.build()?,
Err(e) => {
return Err(e);
}
};
self.write_index(&index)?;
Ok(manifest)
}
fn write_index(&self, index: &oci_image::ImageIndex) -> Result<()> {
self.dir
.atomic_replace_with("index.json", |mut w| -> Result<()> {
let mut ser =
serde_json::Serializer::with_formatter(&mut w, CanonicalFormatter::new());
index.serialize(&mut ser)?;
Ok(())
})?;
Ok(())
}
pub fn insert_manifest_and_config(
&self,
mut manifest: oci_image::ImageManifest,
config: oci_image::ImageConfiguration,
tag: Option<&str>,
platform: oci_image::Platform,
) -> Result<Descriptor> {
let config = self.write_config(config)?;
manifest.set_config(config);
self.insert_manifest(manifest, tag, platform)
}
pub fn replace_with_single_manifest(
&self,
manifest: oci_image::ImageManifest,
platform: oci_image::Platform,
) -> Result<()> {
let manifest = self
.write_json_blob(&manifest, MediaType::ImageManifest)?
.platform(platform)
.build()
.unwrap();
let index_data = oci_image::ImageIndexBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.manifests(vec![manifest])
.build()
.unwrap();
self.write_index(&index_data)
}
fn descriptor_is_tagged(d: &Descriptor, tag: &str) -> bool {
d.annotations()
.as_ref()
.and_then(|annos| annos.get(OCI_TAG_ANNOTATION))
.filter(|tagval| tagval.as_str() == tag)
.is_some()
}
pub fn find_manifest_with_tag(&self, tag: &str) -> Result<Option<oci_image::ImageManifest>> {
let desc = self.find_manifest_descriptor_with_tag(tag)?;
desc.map(|img| self.read_json_blob(&img)).transpose()
}
pub fn find_manifest_descriptor_with_tag(
&self,
tag: &str,
) -> Result<Option<oci_image::Descriptor>> {
let idx = self.read_index()?;
Ok(idx
.manifests()
.iter()
.find(|desc| Self::descriptor_is_tagged(desc, tag))
.cloned())
}
pub fn open_image_this_platform(&self, tag: Option<&str>) -> Result<ResolvedManifest> {
let index = self.read_index()?;
let manifests = index.manifests();
let candidates: Vec<_> = if let Some(tag) = tag {
let tagged: Vec<_> = manifests
.iter()
.filter(|d| Self::descriptor_is_tagged(d, tag))
.collect();
if tagged.is_empty() {
return Err(Error::TagNotFound { tag: tag.into() });
}
tagged
} else {
if manifests.is_empty() {
return Err(Error::EmptyImageIndex);
}
manifests.iter().collect()
};
let native_platform = Platform::default();
let mut found_candidates: Vec<Descriptor> = Vec::new();
for desc in candidates {
match desc.media_type() {
MediaType::ImageManifest => {
if let Some(platform) = desc.platform().as_ref()
&& Self::platform_compatible(platform, &native_platform)
{
let manifest = self.read_json_blob::<ImageManifest>(desc)?;
return Ok(ResolvedManifest {
manifest,
manifest_descriptor: desc.clone(),
source_index: None,
});
}
found_candidates.push(desc.clone());
}
MediaType::ImageIndex => {
let nested: ImageIndex = self.read_json_blob(desc)?;
let index_descriptor = desc.clone();
if let Some(resolved) = self.resolve_manifest_list(
nested,
index_descriptor,
&native_platform,
&mut found_candidates,
)? {
return Ok(resolved);
}
}
other => {
return Err(Error::UnexpectedMediaType {
media_type: other.clone(),
});
}
}
}
Err(Error::NoMatchingPlatform {
os: native_platform.os().to_string().into(),
architecture: native_platform.architecture().to_string().into(),
available: Self::format_available_platforms(found_candidates.iter()),
})
}
fn resolve_manifest_list(
&self,
index: ImageIndex,
index_descriptor: Descriptor,
native_platform: &Platform,
found_candidates: &mut Vec<Descriptor>,
) -> Result<Option<ResolvedManifest>> {
for desc in index.manifests() {
match desc.media_type() {
MediaType::ImageIndex => {
return Err(Error::NestedImageIndex);
}
MediaType::ImageManifest => {
if let Some(platform) = desc.platform().as_ref()
&& Self::platform_compatible(platform, native_platform)
{
let manifest = self.read_json_blob::<ImageManifest>(desc)?;
return Ok(Some(ResolvedManifest {
manifest,
manifest_descriptor: desc.clone(),
source_index: Some((index, index_descriptor)),
}));
}
found_candidates.push(desc.clone());
}
other => {
return Err(Error::UnexpectedMediaType {
media_type: other.clone(),
});
}
}
}
Ok(None)
}
fn format_available_platforms<'a>(manifests: impl Iterator<Item = &'a Descriptor>) -> Box<str> {
const MAX_PLATFORMS_IN_ERROR: usize = 10;
let platforms: Vec<_> = manifests
.filter_map(|d| {
d.platform()
.as_ref()
.map(|p| format!("{}/{}", p.os(), p.architecture()))
})
.take(MAX_PLATFORMS_IN_ERROR + 1) .collect();
if platforms.is_empty() {
return "(no platform info)".into();
}
if platforms.len() > MAX_PLATFORMS_IN_ERROR {
let truncated: Vec<_> = platforms.into_iter().take(MAX_PLATFORMS_IN_ERROR).collect();
format!("{}, ...", truncated.join(", ")).into()
} else {
platforms.join(", ").into()
}
}
fn platform_compatible(platform: &Platform, native: &Platform) -> bool {
platform.architecture() == native.architecture() && platform.os() == native.os()
}
fn fsck_one_manifest(
&self,
manifest: &ImageManifest,
validated: &mut HashSet<Box<str>>,
) -> Result<()> {
let config_digest = sha256_of_descriptor(manifest.config())?;
match manifest.config().media_type() {
MediaType::ImageConfig => {
let _: ImageConfiguration = self.read_json_blob(manifest.config())?;
}
MediaType::EmptyJSON => {
let _: EmptyDescriptor = self.read_json_blob(manifest.config())?;
}
media_type => {
return Err(Error::UnexpectedMediaType {
media_type: media_type.clone(),
});
}
}
validated.insert(config_digest.into());
for layer in manifest.layers() {
let expected = sha256_of_descriptor(layer)?;
if validated.contains(expected) {
continue;
}
let mut f = self.read_blob(layer)?;
let mut digest = Hasher::new(MessageDigest::sha256())?;
std::io::copy(&mut f, &mut digest)?;
let found = hex::encode(
digest
.finish()
.map_err(|e| Error::Other(e.to_string().into()))?,
);
if expected != found {
return Err(Error::DigestMismatch {
expected: expected.into(),
found: found.into(),
});
}
validated.insert(expected.into());
}
Ok(())
}
pub fn fsck(&self) -> Result<u64> {
let index = self.read_index()?;
let mut validated_blobs = HashSet::new();
for manifest_descriptor in index.manifests() {
let expected_sha256 = sha256_of_descriptor(manifest_descriptor)?;
let manifest: ImageManifest = self.read_json_blob(manifest_descriptor)?;
validated_blobs.insert(expected_sha256.into());
self.fsck_one_manifest(&manifest, &mut validated_blobs)?;
}
Ok(validated_blobs.len().try_into().unwrap())
}
}
impl<'a> BlobWriter<'a> {
fn new(ocidir: &'a Dir) -> Result<Self> {
Ok(Self {
hash: Hasher::new(MessageDigest::sha256())?,
target: Some(BufWriter::with_capacity(
BLOB_BUF_SIZE,
cap_tempfile::TempFile::new(ocidir)?,
)),
size: 0,
})
}
pub fn complete_verified_as(mut self, descriptor: &Descriptor) -> Result<Blob> {
let expected_digest = sha256_of_descriptor(descriptor)?;
let found_digest = hex::encode(self.hash.finish()?);
if found_digest.as_str() != expected_digest {
return Err(Error::DigestMismatch {
expected: expected_digest.into(),
found: found_digest.into(),
});
}
let descriptor_size: u64 = descriptor.size();
if self.size != descriptor_size {
return Err(Error::SizeMismatch {
expected: descriptor_size,
found: self.size,
});
}
self.complete_as(&found_digest)
}
fn complete_as(mut self, sha256_digest: &str) -> Result<Blob> {
let destname = &format!("{}/{}", BLOBDIR, sha256_digest);
let target = self.target.take().unwrap();
target.into_inner().unwrap().replace(destname)?;
Ok(Blob {
sha256: Sha256Digest::from_str(sha256_digest).unwrap(),
size: self.size,
})
}
pub fn complete(mut self) -> Result<Blob> {
let sha256 = hex::encode(self.hash.finish()?);
self.complete_as(&sha256)
}
}
impl std::io::Write for BlobWriter<'_> {
fn write(&mut self, srcbuf: &[u8]) -> std::io::Result<usize> {
let written = self.target.as_mut().unwrap().write(srcbuf)?;
self.hash.update(&srcbuf[..written])?;
self.size += written as u64;
Ok(written)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
pub trait WriteComplete<W>: Write {
fn complete(self) -> std::io::Result<W>;
}
impl<W> WriteComplete<W> for GzEncoder<W>
where
W: Write,
{
fn complete(self) -> std::io::Result<W> {
self.finish()
}
}
impl<'a> WriteComplete<BlobWriter<'a>> for BlobWriter<'a> {
fn complete(self) -> std::io::Result<Self> {
Ok(self)
}
}
#[cfg(feature = "zstd")]
impl<W> WriteComplete<W> for zstd::Encoder<'_, W>
where
W: Write,
{
fn complete(self) -> std::io::Result<W> {
self.finish()
}
}
pub struct LayerWriter<'a, W>
where
W: WriteComplete<BlobWriter<'a>>,
{
inner: Sha256Writer<W>,
media_type: MediaType,
marker: PhantomData<&'a ()>,
}
impl<'a, W> LayerWriter<'a, W>
where
W: WriteComplete<BlobWriter<'a>>,
{
pub fn new(inner: W, media_type: oci_image::MediaType) -> Self {
Self {
inner: Sha256Writer::new(inner),
media_type,
marker: PhantomData,
}
}
pub fn new_uncompressed(inner: W, media_type: oci_image::MediaType) -> Self {
Self {
inner: Sha256Writer::new_passthrough(inner),
media_type,
marker: PhantomData,
}
}
pub fn complete(self) -> Result<Layer> {
let (uncompressed_sha256, enc) = self.inner.finish();
let blob = enc.complete()?.complete()?;
let uncompressed_sha256 = uncompressed_sha256.unwrap_or_else(|| blob.sha256().clone());
Ok(Layer {
blob,
uncompressed_sha256,
media_type: self.media_type,
})
}
}
impl<'a, W> std::io::Write for LayerWriter<'a, W>
where
W: WriteComplete<BlobWriter<'a>>,
{
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.inner.write(data)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
struct Sha256Writer<W> {
inner: W,
sha: Option<openssl::sha::Sha256>,
}
impl<W> Sha256Writer<W> {
pub(crate) fn new(inner: W) -> Self {
Self {
inner,
sha: Some(openssl::sha::Sha256::new()),
}
}
pub(crate) fn new_passthrough(inner: W) -> Self {
Self { inner, sha: None }
}
pub(crate) fn finish(self) -> (Option<Sha256Digest>, W) {
let digest = self.sha.map(|sha| {
let hex = hex::encode(sha.finish());
Sha256Digest::from_str(&hex).unwrap()
});
(digest, self.inner)
}
}
impl<W> Write for Sha256Writer<W>
where
W: Write,
{
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let len = self.inner.write(buf)?;
if let Some(ref mut sha) = self.sha {
sha.update(&buf[..len]);
}
Ok(len)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
#[cfg(test)]
mod tests {
use cap_std::fs::OpenOptions;
use oci_spec::image::{Arch, HistoryBuilder, Os};
use super::*;
fn new_ocidir() -> Result<(cap_tempfile::TempDir, OciDir)> {
let td = cap_tempfile::tempdir(cap_std::ambient_authority())?;
let w = OciDir::ensure(td.try_clone()?)?;
Ok((td, w))
}
fn new_empty_config() -> oci_image::ImageConfiguration {
oci_image::ImageConfigurationBuilder::default()
.build()
.unwrap()
}
fn create_test_layer(w: &OciDir, content: &[u8]) -> Result<Layer> {
let mut layerw = w.create_gzip_layer(None)?;
layerw.write_all(content)?;
layerw.complete()
}
fn insert_default_manifest(
w: &OciDir,
tag: Option<&str>,
) -> Result<(oci_image::ImageManifest, Descriptor)> {
let manifest = w.new_empty_manifest()?.build()?;
let config = new_empty_config();
let desc = w.insert_manifest_and_config(
manifest.clone(),
config,
tag,
oci_image::Platform::default(),
)?;
Ok((manifest, desc))
}
const MANIFEST_DERIVE: &str = r#"{
"schemaVersion": 2,
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:54977ab597b345c2238ba28fe18aad751e5c59dc38b9393f6f349255f0daa7fc",
"size": 754
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:ee02768e65e6fb2bb7058282338896282910f3560de3e0d6cd9b1d5985e8360d",
"size": 5462
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:d203cef7e598fa167cb9e8b703f9f20f746397eca49b51491da158d64968b429",
"size": 214
}
],
"annotations": {
"ostree.commit": "3cb6170b6945065c2475bc16d7bebcc84f96b4c677811a6751e479b89f8c3770",
"ostree.version": "42.0"
}
}
"#;
#[test]
fn manifest() -> Result<()> {
let m: oci_image::ImageManifest = serde_json::from_str(MANIFEST_DERIVE)?;
assert_eq!(
m.layers()[0].digest().to_string(),
"sha256:ee02768e65e6fb2bb7058282338896282910f3560de3e0d6cd9b1d5985e8360d"
);
Ok(())
}
#[test]
fn test_build() -> Result<()> {
let (_td, w) = new_ocidir()?;
let root_layer = create_test_layer(&w, b"pretend this is a tarball")?;
let root_layer_desc = root_layer.descriptor().build().unwrap();
assert_eq!(
root_layer.uncompressed_sha256.digest(),
"349438e5faf763e8875b43de4d7101540ef4d865190336c2cc549a11f33f8d7c"
);
assert!(matches!(w.fsck().unwrap_err(), Error::MissingImageIndex));
assert!(w.has_blob(&root_layer_desc).unwrap());
assert!(
!w.has_blob(&Descriptor::new(
MediaType::ImageLayerGzip,
root_layer.blob.size,
root_layer.uncompressed_sha256.clone()
))
.unwrap()
);
let mut manifest = w.new_empty_manifest()?.build()?;
let mut config = new_empty_config();
let annotations: Option<HashMap<String, String>> = None;
w.push_layer(&mut manifest, &mut config, root_layer, "root", annotations);
{
let history = config.history().as_ref().unwrap().first().unwrap();
assert_eq!(history.created_by().as_ref().unwrap(), "root");
let created = history.created().as_deref().unwrap();
let ts = chrono::DateTime::parse_from_rfc3339(created)
.unwrap()
.to_utc();
let now = chrono::offset::Utc::now();
assert_eq!(now.years_since(ts).unwrap(), 0);
}
let config = w.write_config(config)?;
manifest.set_config(config);
w.replace_with_single_manifest(manifest.clone(), oci_image::Platform::default())?;
assert_eq!(w.read_index().unwrap().manifests().len(), 1);
assert_eq!(w.fsck().unwrap(), 3);
{
let root_layer_sha256 = root_layer_desc.as_digest_sha256().unwrap();
let mut f = w.dir.open_with(
format!("blobs/sha256/{root_layer_sha256}"),
OpenOptions::new().write(true),
)?;
let l = f.metadata()?.len();
f.seek(std::io::SeekFrom::End(0))?;
f.write_all(b"\0")?;
assert!(w.fsck().is_err());
f.set_len(l)?;
assert_eq!(w.fsck().unwrap(), 3);
}
let idx = w.read_index()?;
let manifest_desc = idx.manifests().first().unwrap();
let read_manifest = w.read_json_blob(manifest_desc).unwrap();
assert_eq!(&read_manifest, &manifest);
let desc: Descriptor =
w.insert_manifest(manifest, Some("latest"), oci_image::Platform::default())?;
assert!(w.has_manifest(&desc).unwrap());
assert_eq!(w.read_index().unwrap().manifests().len(), 2);
assert!(w.find_manifest_with_tag("noent").unwrap().is_none());
let found_via_tag = w.find_manifest_with_tag("latest").unwrap().unwrap();
assert_eq!(found_via_tag, read_manifest);
let root_layer = create_test_layer(&w, b"pretend this is an updated tarball")?;
let mut manifest = w.new_empty_manifest()?.build()?;
let mut config = new_empty_config();
w.push_layer(&mut manifest, &mut config, root_layer, "root", None);
let _: Descriptor = w.insert_manifest_and_config(
manifest,
config,
Some("latest"),
oci_image::Platform::default(),
)?;
assert_eq!(w.read_index().unwrap().manifests().len(), 2);
assert_eq!(w.fsck().unwrap(), 6);
Ok(())
}
#[test]
fn test_complete_verified_as() -> Result<()> {
let (_td, oci_dir) = new_ocidir()?;
let empty_json_digest = oci_image::DescriptorBuilder::default()
.media_type(MediaType::EmptyJSON)
.size(2u32)
.digest(Sha256Digest::from_str(
"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
)?)
.build()?;
let mut empty_json_blob = oci_dir.create_blob()?;
empty_json_blob.write_all(b"{}")?;
let blob = empty_json_blob.complete_verified_as(&empty_json_digest)?;
assert_eq!(blob.sha256().digest(), empty_json_digest.digest().digest());
let test_descriptor = oci_image::DescriptorBuilder::default()
.media_type(MediaType::EmptyJSON)
.size(3u32)
.digest(Sha256Digest::from_str(
"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
)?)
.build()?;
let mut invalid_blob = oci_dir.create_blob()?;
invalid_blob.write_all(b"foo")?;
match invalid_blob
.complete_verified_as(&test_descriptor)
.err()
.unwrap()
{
Error::DigestMismatch { expected, found } => {
assert_eq!(
expected.as_ref(),
"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
);
assert_eq!(
found.as_ref(),
"2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
);
}
o => panic!("Unexpected error {o}"),
}
Ok(())
}
#[test]
fn test_new_empty_manifest() -> Result<()> {
let (_td, w) = new_ocidir()?;
let manifest = w.new_empty_manifest()?.build()?;
let desc: Descriptor =
w.insert_manifest(manifest, Some("latest"), oci_image::Platform::default())?;
assert!(w.has_manifest(&desc).unwrap());
assert_eq!(w.fsck()?, 2);
Ok(())
}
#[test]
fn test_push_layer_with_history() -> Result<()> {
let (_td, w) = new_ocidir()?;
let mut manifest = w.new_empty_manifest()?.build()?;
let mut config = new_empty_config();
let root_layer = create_test_layer(&w, b"pretend this is a tarball")?;
let history = HistoryBuilder::default()
.created_by("/bin/pretend-tar")
.build()
.unwrap();
w.push_layer_with_history(&mut manifest, &mut config, root_layer, Some(history));
{
let history = config.history().as_ref().unwrap().first().unwrap();
assert_eq!(history.created_by().as_deref().unwrap(), "/bin/pretend-tar");
assert_eq!(history.created().as_ref(), None);
}
Ok(())
}
fn build_foreign_platform_desc(w: &OciDir, arch: Arch, os: Os) -> Result<Descriptor> {
let manifest = w.new_empty_manifest()?.build()?;
let manifest_desc = w
.write_json_blob(&manifest, MediaType::ImageManifest)?
.build()?;
w.write_config(new_empty_config())?;
Ok(oci_image::DescriptorBuilder::default()
.media_type(MediaType::ImageManifest)
.digest(manifest_desc.digest().clone())
.size(manifest_desc.size())
.platform(
oci_image::PlatformBuilder::default()
.architecture(arch)
.os(os)
.build()
.unwrap(),
)
.build()?)
}
enum PlatformExpected {
Ok { has_source_index: Option<bool> },
ErrEmpty,
ErrNoMatch {
available_contains: Option<&'static str>,
},
ErrTagNotFound,
}
type TestSetupFn = Box<dyn Fn(&OciDir) -> Result<()>>;
struct PlatformTestCase {
name: &'static str,
setup: TestSetupFn,
tag: Option<&'static str>,
expected: PlatformExpected,
}
#[test]
fn test_open_image_this_platform() -> Result<()> {
let cases: Vec<PlatformTestCase> = vec![
PlatformTestCase {
name: "single manifest with platform",
setup: Box::new(|w| {
let mut manifest = w.new_empty_manifest()?.build()?;
let config_desc = w.write_config(new_empty_config())?;
manifest.set_config(config_desc);
w.replace_with_single_manifest(manifest, oci_image::Platform::default())?;
Ok(())
}),
tag: None,
expected: PlatformExpected::Ok {
has_source_index: Some(false),
},
},
PlatformTestCase {
name: "single manifest without platform info",
setup: Box::new(|w| {
let manifest = w.new_empty_manifest()?.build()?;
let manifest_desc = w
.write_json_blob(&manifest, MediaType::ImageManifest)?
.build()?;
let index = oci_image::ImageIndexBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.manifests(vec![manifest_desc])
.build()?;
w.write_index(&index)
}),
tag: None,
expected: PlatformExpected::ErrNoMatch {
available_contains: None,
},
},
PlatformTestCase {
name: "insert with native platform",
setup: Box::new(|w| {
insert_default_manifest(w, None)?;
Ok(())
}),
tag: None,
expected: PlatformExpected::Ok {
has_source_index: None,
},
},
PlatformTestCase {
name: "find by tag",
setup: Box::new(|w| {
insert_default_manifest(w, Some("v1.0"))?;
Ok(())
}),
tag: Some("v1.0"),
expected: PlatformExpected::Ok {
has_source_index: None,
},
},
PlatformTestCase {
name: "missing tag",
setup: Box::new(|w| {
insert_default_manifest(w, Some("v1.0"))?;
Ok(())
}),
tag: Some("nonexistent"),
expected: PlatformExpected::ErrTagNotFound,
},
PlatformTestCase {
name: "empty index",
setup: Box::new(|w| {
let index = oci_image::ImageIndexBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.manifests(vec![])
.build()?;
w.write_index(&index)
}),
tag: None,
expected: PlatformExpected::ErrEmpty,
},
PlatformTestCase {
name: "no matching platform (foreign arches only)",
setup: Box::new(|w| {
let desc1 = build_foreign_platform_desc(w, Arch::ARM64, Os::Linux)?;
let desc2 = build_foreign_platform_desc(w, Arch::ARM, Os::Linux)?;
let index = oci_image::ImageIndexBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.manifests(vec![desc1, desc2])
.build()?;
w.write_index(&index)
}),
tag: None,
expected: PlatformExpected::ErrNoMatch {
available_contains: Some("linux"),
},
},
PlatformTestCase {
name: "nested index (manifest list peeling)",
setup: Box::new(|w| {
let mut manifest = w.new_empty_manifest()?.build()?;
let config_desc = w.write_config(new_empty_config())?;
manifest.set_config(config_desc);
let manifest_desc = w
.write_json_blob(&manifest, MediaType::ImageManifest)?
.platform(oci_image::Platform::default())
.build()?;
let nested_index = oci_image::ImageIndexBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.manifests(vec![manifest_desc])
.build()?;
let mut blob_writer = w.create_blob()?;
let nested_json = nested_index.to_string()?;
blob_writer.write_all(nested_json.as_bytes())?;
let nested_blob = blob_writer.complete()?;
let nested_desc = oci_image::DescriptorBuilder::default()
.media_type(MediaType::ImageIndex)
.digest(nested_blob.sha256().clone())
.size(nested_json.len() as u64)
.build()?;
let top_index = oci_image::ImageIndexBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.manifests(vec![nested_desc])
.build()?;
w.write_index(&top_index)
}),
tag: None,
expected: PlatformExpected::Ok {
has_source_index: Some(true),
},
},
];
for case in &cases {
let (_td, w) = new_ocidir()?;
(case.setup)(&w)?;
let result = w.open_image_this_platform(case.tag);
let name = case.name;
match &case.expected {
PlatformExpected::Ok { has_source_index } => {
let resolved = result
.unwrap_or_else(|e| panic!("case '{name}': expected Ok, got Err({e})"));
if let Some(expect_index) = has_source_index {
assert_eq!(
resolved.source_index.is_some(),
*expect_index,
"case '{name}': source_index presence mismatch"
);
}
}
PlatformExpected::ErrEmpty => {
assert!(
matches!(result, Err(Error::EmptyImageIndex)),
"case '{name}': expected EmptyImageIndex, got {result:?}"
);
}
PlatformExpected::ErrNoMatch { available_contains } => match &result {
Err(Error::NoMatchingPlatform { available, .. }) => {
if let Some(substr) = available_contains {
assert!(
available.contains(substr),
"case '{name}': expected '{substr}' in available '{available}'"
);
}
}
other => panic!("case '{name}': expected NoMatchingPlatform, got {other:?}"),
},
PlatformExpected::ErrTagNotFound => {
assert!(
matches!(result, Err(Error::TagNotFound { .. })),
"case '{name}': expected TagNotFound, got {result:?}"
);
}
}
}
Ok(())
}
#[test]
fn test_uncompressed_layer() -> Result<()> {
let td = cap_tempfile::tempdir(cap_std::ambient_authority())?;
let w = OciDir::ensure(td.try_clone()?)?;
let data = b"pretend this is an uncompressed tarball";
let mut gz = w.create_gzip_layer(None)?;
gz.write_all(data)?;
let gz_layer = gz.complete()?;
let mut uncompressed = w.create_uncompressed_layer()?;
uncompressed.write_all(data)?;
let uncompressed_layer = uncompressed.complete()?;
assert_ne!(
gz_layer.blob.sha256().digest(),
gz_layer.uncompressed_sha256.digest(),
"gz layer blob digest should not match diffid"
);
assert_eq!(
uncompressed_layer.blob.sha256().digest(),
uncompressed_layer.uncompressed_sha256.digest(),
"uncompressed layer blob digest should match diffid"
);
assert_eq!(
gz_layer.uncompressed_sha256.digest(),
uncompressed_layer.uncompressed_sha256.digest(),
"uncompressed layer diffid should equal gz layer diffid"
);
Ok(())
}
}