#![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()?)
}
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 desc_builder = self
.write_json_blob(&manifest, MediaType::ImageManifest)?
.platform(platform);
if manifest.subject().is_some() {
let effective_artifact_type = manifest
.artifact_type()
.clone()
.unwrap_or_else(|| manifest.config().media_type().clone());
desc_builder = desc_builder.artifact_type(effective_artifact_type);
if let Some(annos) = manifest.annotations() {
desc_builder = desc_builder.annotations(annos.clone());
}
} else if let Some(at) = manifest.artifact_type() {
desc_builder = desc_builder.artifact_type(at.clone());
}
let mut manifest_desc = desc_builder.build()?;
if let Some(tag) = tag {
let mut annotations = manifest_desc.annotations().clone().unwrap_or_default();
annotations.insert(OCI_TAG_ANNOTATION.to_string(), tag.to_string());
manifest_desc.set_annotations(Some(annotations));
}
self.append_to_index(manifest_desc.clone(), tag)?;
Ok(manifest_desc)
}
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 insert_artifact_manifest(
&self,
subject: Descriptor,
artifact_type: MediaType,
layers: Vec<Descriptor>,
annotations: Option<HashMap<String, String>>,
) -> Result<Descriptor> {
let empty_descriptor = self.empty_config_descriptor()?;
let layers = if layers.is_empty() {
vec![empty_descriptor.clone()]
} else {
layers
};
let mut manifest_builder = oci_image::ImageManifestBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.config(empty_descriptor)
.layers(layers)
.artifact_type(artifact_type.clone())
.subject(subject);
if let Some(annos) = annotations {
manifest_builder = manifest_builder.annotations(annos);
}
let manifest = manifest_builder.build()?;
let mut desc_builder = self
.write_json_blob(&manifest, MediaType::ImageManifest)?
.artifact_type(artifact_type);
if let Some(annos) = manifest.annotations() {
desc_builder = desc_builder.annotations(annos.clone());
}
let manifest_desc = desc_builder.build()?;
self.append_to_index(manifest_desc.clone(), None)?;
Ok(manifest_desc)
}
fn append_to_index(&self, desc: Descriptor, tag: Option<&str>) -> Result<()> {
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(desc);
index.set_manifests(manifests);
index
}
Err(Error::MissingImageIndex) => oci_image::ImageIndexBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.manifests(vec![desc])
.build()?,
Err(e) => return Err(e),
};
self.write_index(&index)
}
pub fn find_referrers(
&self,
subject_digest: &Digest,
artifact_type_filter: Option<&MediaType>,
) -> Result<Vec<Descriptor>> {
let index = self.read_index()?;
let mut referrers = Vec::new();
for desc in index.manifests() {
if desc.media_type() != &MediaType::ImageManifest {
continue;
}
let manifest: ImageManifest = self.read_json_blob(desc)?;
let subject = match manifest.subject() {
Some(s) => s,
None => continue,
};
if subject.digest() != subject_digest {
continue;
}
if let Some(filter) = artifact_type_filter {
let effective_type = manifest
.artifact_type()
.as_ref()
.unwrap_or(manifest.config().media_type());
if effective_type != filter {
continue;
}
}
referrers.push(desc.clone());
}
Ok(referrers)
}
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(manifest) =
self.resolve_descriptor_for_platform(desc, &native_platform)?
{
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(manifest) =
self.resolve_descriptor_for_platform(desc, native_platform)?
{
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 resolve_descriptor_for_platform(
&self,
desc: &Descriptor,
native: &Platform,
) -> Result<Option<ImageManifest>> {
if let Some(platform) = desc.platform().as_ref() {
if Self::platform_compatible(platform, native) {
return Ok(Some(self.read_json_blob::<ImageManifest>(desc)?));
}
return Ok(None);
}
let manifest = self.read_json_blob::<ImageManifest>(desc)?;
if manifest.config().media_type() != &MediaType::ImageConfig {
return Ok(None);
}
let config: ImageConfiguration = self.read_json_blob(manifest.config())?;
if config.architecture() == native.architecture() && config.os() == native.os() {
Ok(Some(manifest))
} else {
Ok(None)
}
}
fn verify_blob_digest(&self, desc: &Descriptor) -> Result<()> {
let expected = sha256_of_descriptor(desc)?;
let mut f = self.read_blob(desc)?;
let mut hasher = Hasher::new(MessageDigest::sha256())?;
std::io::copy(&mut f, &mut hasher)?;
let found = hex::encode(hasher.finish()?);
if expected != found {
return Err(Error::DigestMismatch {
expected: expected.into(),
found: found.into(),
});
}
Ok(())
}
fn fsck_one_manifest(
&self,
manifest: &ImageManifest,
validated: &mut HashSet<Box<str>>,
) -> Result<()> {
let config_digest = sha256_of_descriptor(manifest.config())?;
if !validated.contains(config_digest) {
self.verify_blob_digest(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())?;
}
_ => {}
}
validated.insert(config_digest.into());
}
for layer in manifest.layers() {
let expected = sha256_of_descriptor(layer)?;
if validated.contains(expected) {
continue;
}
self.verify_blob_digest(layer)?;
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> std::fmt::Debug for LayerWriter<'a, W>
where
W: WriteComplete<BlobWriter<'a>>,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LayerWriter")
.field("media_type", &self.media_type)
.finish_non_exhaustive()
}
}
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: "native config, no platform annotation on descriptor",
setup: Box::new(|w| {
let config = oci_image::ImageConfigurationBuilder::default()
.architecture(oci_image::Platform::default().architecture().clone())
.os(oci_image::Platform::default().os().clone())
.build()
.unwrap();
let config_desc = w.write_config(config)?;
let mut manifest = w.new_empty_manifest()?.build()?;
manifest.set_config(config_desc);
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::Ok {
has_source_index: Some(false),
},
},
PlatformTestCase {
name: "foreign config, no platform annotation on descriptor",
setup: Box::new(|w| {
let config = oci_image::ImageConfigurationBuilder::default()
.architecture(Arch::ARM64)
.os(Os::Linux)
.build()
.unwrap();
let config_desc = w.write_config(config)?;
let mut manifest = w.new_empty_manifest()?.build()?;
manifest.set_config(config_desc);
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: "mixed: annotated foreign first, unannotated native second",
setup: Box::new(|w| {
let foreign_desc = build_foreign_platform_desc(w, Arch::ARM64, Os::Linux)?;
let native_config = oci_image::ImageConfigurationBuilder::default()
.architecture(oci_image::Platform::default().architecture().clone())
.os(oci_image::Platform::default().os().clone())
.build()
.unwrap();
let native_config_desc = w.write_config(native_config)?;
let mut native_manifest = w.new_empty_manifest()?.build()?;
native_manifest.set_config(native_config_desc);
let native_manifest_desc = w
.write_json_blob(&native_manifest, MediaType::ImageManifest)?
.build()?;
let index = oci_image::ImageIndexBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.manifests(vec![foreign_desc, native_manifest_desc])
.build()?;
w.write_index(&index)
}),
tag: None,
expected: PlatformExpected::Ok {
has_source_index: Some(false),
},
},
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(())
}
struct ArtifactTestCase {
name: &'static str,
artifact_type: &'static str,
has_content_layer: bool,
annotations: Option<HashMap<String, String>>,
}
#[test]
fn test_insert_artifact_manifest() -> Result<()> {
let cases = vec![
ArtifactTestCase {
name: "minimal artifact (no layers, no annotations)",
artifact_type: "application/vnd.example.sbom.v1",
has_content_layer: false,
annotations: None,
},
ArtifactTestCase {
name: "artifact with content layer",
artifact_type: "application/vnd.example.signature.v1",
has_content_layer: true,
annotations: None,
},
ArtifactTestCase {
name: "artifact with annotations",
artifact_type: "application/vnd.example.attestation.v1",
has_content_layer: false,
annotations: Some(
[
(
"org.opencontainers.image.created".into(),
"2024-01-01T00:00:00Z".into(),
),
("com.example.key".into(), "value".into()),
]
.into_iter()
.collect(),
),
},
];
for case in &cases {
let (_td, w) = new_ocidir()?;
let name = case.name;
let (_, subject_desc) = insert_default_manifest(&w, Some("base"))?;
let layers = if case.has_content_layer {
let mut blob = w.create_blob()?;
blob.write_all(b"artifact content")?;
let blob = blob.complete()?;
vec![
blob.descriptor()
.media_type(MediaType::Other("application/vnd.example.data".into()))
.build()
.unwrap(),
]
} else {
vec![]
};
let artifact_type = MediaType::Other(case.artifact_type.into());
let desc = w.insert_artifact_manifest(
subject_desc.clone(),
artifact_type.clone(),
layers,
case.annotations.clone(),
)?;
assert_eq!(
desc.artifact_type().as_ref(),
Some(&artifact_type),
"case '{name}': descriptor should carry artifact_type"
);
assert!(
desc.platform().is_none(),
"case '{name}': artifact descriptor should not have platform"
);
if let Some(expected_annos) = &case.annotations {
let desc_annos = desc
.annotations()
.as_ref()
.expect("annotations should be set");
for (k, v) in expected_annos {
assert_eq!(
desc_annos.get(k),
Some(v),
"case '{name}': annotation '{k}' should be propagated"
);
}
}
let manifest: ImageManifest = w.read_json_blob(&desc)?;
assert_eq!(
manifest.artifact_type().as_ref(),
Some(&artifact_type),
"case '{name}': manifest should have artifact_type"
);
assert_eq!(
manifest.subject().as_ref().map(|s| s.digest()),
Some(subject_desc.digest()),
"case '{name}': manifest subject should match"
);
assert_eq!(
manifest.config().media_type(),
&MediaType::EmptyJSON,
"case '{name}': config should be empty descriptor"
);
if case.has_content_layer {
assert_eq!(
manifest.layers().len(),
1,
"case '{name}': should have one content layer"
);
assert_ne!(
manifest.layers()[0].media_type(),
&MediaType::EmptyJSON,
"case '{name}': content layer should not be empty"
);
} else {
assert_eq!(
manifest.layers().len(),
1,
"case '{name}': should have one (empty) layer"
);
assert_eq!(
manifest.layers()[0].media_type(),
&MediaType::EmptyJSON,
"case '{name}': layer should be empty descriptor"
);
}
let validated = w.fsck()?;
assert!(
validated >= 4,
"case '{name}': fsck should validate at least 4 blobs, got {validated}:
base manifest + base config + artifact manifest + empty config = 4 minimum"
);
}
Ok(())
}
#[test]
fn test_find_referrers() -> Result<()> {
let (_td, w) = new_ocidir()?;
let (_, subject_desc) = insert_default_manifest(&w, Some("base"))?;
let sbom_type = MediaType::Other("application/vnd.example.sbom.v1".into());
let sig_type = MediaType::Other("application/vnd.example.signature.v1".into());
let sbom_desc = w.insert_artifact_manifest(
subject_desc.clone(),
sbom_type.clone(),
vec![],
Some(
[("org.example.format".into(), "json".into())]
.into_iter()
.collect(),
),
)?;
let sig_desc =
w.insert_artifact_manifest(subject_desc.clone(), sig_type.clone(), vec![], None)?;
let root_layer = create_test_layer(&w, b"other image content")?;
let mut other_manifest = w.new_empty_manifest()?.build()?;
let mut other_config = new_empty_config();
w.push_layer(
&mut other_manifest,
&mut other_config,
root_layer,
"root",
None,
);
let other_desc = w.insert_manifest_and_config(
other_manifest,
other_config,
Some("other"),
Platform::default(),
)?;
let referrers = w.find_referrers(subject_desc.digest(), None)?;
assert_eq!(referrers.len(), 2, "should find 2 referrers");
let referrer_digests: HashSet<_> = referrers.iter().map(|d| d.digest().clone()).collect();
assert!(
referrer_digests.contains(sbom_desc.digest()),
"should find SBOM referrer"
);
assert!(
referrer_digests.contains(sig_desc.digest()),
"should find signature referrer"
);
for r in &referrers {
assert!(
r.artifact_type().is_some(),
"referrer descriptor should carry artifact_type"
);
}
let sbom_referrer = referrers
.iter()
.find(|r| r.digest() == sbom_desc.digest())
.expect("SBOM referrer should exist");
let sbom_annos = sbom_referrer
.annotations()
.as_ref()
.expect("SBOM referrer should have annotations");
assert_eq!(
sbom_annos.get("org.example.format"),
Some(&"json".to_string()),
"SBOM referrer should carry manifest annotations"
);
let sbom_only = w.find_referrers(subject_desc.digest(), Some(&sbom_type))?;
assert_eq!(sbom_only.len(), 1, "should find 1 SBOM referrer");
assert_eq!(
sbom_only[0].artifact_type().as_ref(),
Some(&sbom_type),
"filtered referrer should be SBOM type"
);
let sig_only = w.find_referrers(subject_desc.digest(), Some(&sig_type))?;
assert_eq!(sig_only.len(), 1, "should find 1 signature referrer");
let no_referrers = w.find_referrers(other_desc.digest(), None)?;
assert!(
no_referrers.is_empty(),
"other image should have no referrers"
);
let fake_digest = Digest::from(Sha256Digest::from_str(
"0000000000000000000000000000000000000000000000000000000000000000",
)?);
let no_referrers = w.find_referrers(&fake_digest, None)?;
assert!(
no_referrers.is_empty(),
"nonexistent digest should have no referrers"
);
let unknown_type = MediaType::Other("application/vnd.example.unknown".into());
let no_match = w.find_referrers(subject_desc.digest(), Some(&unknown_type))?;
assert!(
no_match.is_empty(),
"unknown type filter should return empty"
);
Ok(())
}
#[test]
fn test_insert_manifest_propagates_artifact_type() -> Result<()> {
let (_td, w) = new_ocidir()?;
let (_, subject_desc) = insert_default_manifest(&w, Some("base"))?;
let artifact_type = MediaType::Other("application/vnd.example.sbom.v1".into());
let empty_config = w.empty_config_descriptor()?;
let manifest = oci_image::ImageManifestBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.config(empty_config.clone())
.layers(vec![empty_config])
.artifact_type(artifact_type.clone())
.subject(subject_desc)
.annotations(
[("com.example.key".into(), "value".into())]
.into_iter()
.collect::<HashMap<String, String>>(),
)
.build()?;
let desc = w.insert_manifest(manifest, None, Platform::default())?;
assert_eq!(
desc.artifact_type().as_ref(),
Some(&artifact_type),
"descriptor should carry artifact_type from manifest"
);
let annos = desc
.annotations()
.as_ref()
.expect("should have annotations");
assert_eq!(
annos.get("com.example.key"),
Some(&"value".to_string()),
"descriptor should carry annotations from manifest"
);
Ok(())
}
#[test]
fn test_artifact_type_fallback_to_config_media_type() -> Result<()> {
let (_td, w) = new_ocidir()?;
let (_, subject_desc) = insert_default_manifest(&w, Some("base"))?;
let config_type = MediaType::Other("application/vnd.example.config.v1+json".into());
let mut blob = w.create_blob()?;
blob.write_all(b"{}")?;
let config_blob = blob.complete()?;
let config_desc = config_blob
.descriptor()
.media_type(config_type.clone())
.build()
.unwrap();
let manifest = oci_image::ImageManifestBuilder::default()
.schema_version(oci_image::SCHEMA_VERSION)
.config(config_desc)
.layers(vec![])
.subject(subject_desc)
.build()?;
let desc = w.insert_manifest(manifest, None, Platform::default())?;
assert_eq!(
desc.artifact_type().as_ref(),
Some(&config_type),
"descriptor artifact_type should fall back to config.mediaType"
);
Ok(())
}
#[test]
fn test_artifact_fsck() -> Result<()> {
let (_td, w) = new_ocidir()?;
let (_, subject_desc) = insert_default_manifest(&w, Some("base"))?;
let artifact_type = MediaType::Other("application/vnd.example.sbom.v1".into());
let mut blob = w.create_blob()?;
blob.write_all(b"sbom content here")?;
let content_blob = blob.complete()?;
let content_desc = content_blob
.descriptor()
.media_type(MediaType::Other("application/vnd.example.sbom".into()))
.build()
.unwrap();
w.insert_artifact_manifest(subject_desc, artifact_type, vec![content_desc], None)?;
let validated = w.fsck()?;
assert_eq!(validated, 5, "fsck should validate exactly 5 blobs");
Ok(())
}
}