diff --git a/README.md b/README.md index 36daa2a..502fd0f 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ To access an existing OCI directory: ```rust,no_run # use ocidir::cap_std; +# use ocidir::prelude::*; # use anyhow::{anyhow, Result}; # fn main() -> anyhow::Result<()> { let d = cap_std::fs::Dir::open_ambient_dir("/path/to/ocidir", cap_std::ambient_authority())?; diff --git a/examples/ocidir.rs b/examples/ocidir.rs index fe4c640..1c181b6 100644 --- a/examples/ocidir.rs +++ b/examples/ocidir.rs @@ -11,6 +11,7 @@ use oci_distribution::secrets::RegistryAuth; use ocidir::OciDir; use ocidir::cap_std::fs::Dir; use ocidir::oci_spec::image::{self as oci_image, Descriptor, ImageManifest, MediaType, Platform}; +use ocidir::prelude::*; const OCI_TAG_ANNOTATION: &str = "org.opencontainers.image.ref.name"; diff --git a/src/lib.rs b/src/lib.rs index 52644a1..3cb889b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,13 @@ #![doc = include_str!("../README.md")] #![deny(missing_docs)] +/// Re-exports of commonly used traits. +/// +/// brings OciRead into scope so that its methods are available on OciDir +pub mod prelude { + pub use crate::OciRead; +} + use canon_json::CanonicalFormatter; use cap_std::fs::{Dir, DirBuilderExt}; use cap_std_ext::cap_tempfile; @@ -18,8 +25,15 @@ use std::fmt::Debug; use std::fs::File; use std::io::{BufReader, BufWriter, prelude::*}; use std::marker::PhantomData; +#[cfg(unix)] +use std::os::unix::fs::FileExt; +#[cfg(windows)] +use std::os::windows::fs::FileExt; +#[cfg(not(any(unix, windows)))] +compile_error!("ocidir requires pread support (unix read_at or windows seek_read)"); use std::path::{Path, PathBuf}; use std::str::FromStr; +use std::sync::Arc; use thiserror::Error; // Re-export our dependencies that are used as part of the public API. @@ -229,6 +243,390 @@ fn sha256_of_descriptor(desc: &Descriptor) -> Result<&str> { }) } +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() +} + +/// Check if a platform is compatible with the native platform. +/// +/// Platform has additional optional fields (variant, os_version, +/// os_features, features) which are primarily used for Windows images. +/// We only compare architecture and OS for compatibility. +fn platform_compatible(platform: &Platform, native: &Platform) -> bool { + platform.architecture() == native.architecture() && platform.os() == native.os() +} + +/// Format the available platforms from a list of descriptors for error messages. +/// Limits output to 10 platforms to prevent excessive memory usage. +fn format_available_platforms<'a>(manifests: impl Iterator) -> Box { + 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) // Take one extra to detect truncation + .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() + } +} + +/// Read-only access to an OCI image store. +/// +/// This trait abstracts over different backends (directory layout, tar archive) +/// for reading OCI images. Implementors provide three primitives; all higher-level +/// operations are provided as default methods. +pub trait OciRead { + /// The concrete reader type returned by [`read_blob`](Self::read_blob). + type BlobReader: Read + Seek + Send + 'static; + + /// Open a blob for reading, validating its size against the descriptor. + fn read_blob(&self, desc: &Descriptor) -> Result; + + /// Read the image index (`index.json`). + fn read_index(&self) -> Result; + + /// Returns `true` if the blob with this digest is already present. + fn has_blob(&self, desc: &Descriptor) -> Result; + + /// Returns `true` if the manifest is already present. + fn has_manifest(&self, desc: &Descriptor) -> Result { + let index = self.read_index()?; + Ok(index + .manifests() + .iter() + .any(|m| m.digest() == desc.digest())) + } + + /// Read a JSON blob. + fn read_json_blob(&self, desc: &Descriptor) -> Result { + let blob = BufReader::new(self.read_blob(desc)?); + serde_json::from_reader(blob).map_err(Into::into) + } + + /// Find the manifest with the provided tag + fn find_manifest_with_tag(&self, tag: &str) -> Result> { + let desc = self.find_manifest_descriptor_with_tag(tag)?; + desc.map(|img| self.read_json_blob(&img)).transpose() + } + + /// Find the manifest descriptor with the provided tag + fn find_manifest_descriptor_with_tag(&self, tag: &str) -> Result> { + let idx = self.read_index()?; + Ok(idx + .manifests() + .iter() + .find(|desc| descriptor_is_tagged(desc, tag)) + .cloned()) + } + + /// Open an image manifest for the current platform. + /// + /// This resolves the appropriate manifest from the index for the native + /// platform (OS and architecture). If `tag` is provided, only manifests + /// with that tag annotation are considered. + /// + /// If the index contains an image index (manifest list), it is "peeled" + /// to get the underlying manifests. Nested image indices are not supported. + /// + /// Returns a [`ResolvedManifest`] containing the manifest, its digest, + /// and optionally the image index it was resolved from with its digest. + /// + /// # Errors + /// + /// Returns an error if: + /// - The index cannot be read + /// - The index is empty + /// - A tag is specified but not found + /// - No manifest matches the native platform + /// - A nested image index is encountered + fn open_image_this_platform(&self, tag: Option<&str>) -> Result { + let index = self.read_index()?; + let manifests = index.manifests(); + + // Filter by tag if specified, returning early on empty results + let candidates: Vec<_> = if let Some(tag) = tag { + let tagged: Vec<_> = manifests + .iter() + .filter(|d| 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() + }; + + // Get the native platform + let native_platform = Platform::default(); + + // Collect all found candidate descriptors for error reporting + let mut found_candidates: Vec = 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 => { + // Peel the manifest list + 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(), + }); + } + } + } + + // No match found + Err(Error::NoMatchingPlatform { + os: native_platform.os().to_string().into(), + architecture: native_platform.architecture().to_string().into(), + available: format_available_platforms(found_candidates.iter()), + }) + } + + /// Resolve a manifest from an image index (manifest list) for a given platform. + /// + /// Iterates the manifests within the index, returning the first one that + /// matches `native_platform`. Non-matching descriptors are appended to + /// `found_candidates` so callers can include them in error messages. + /// + /// Returns `Ok(None)` if no manifest in this index matched. + fn resolve_manifest_list( + &self, + index: ImageIndex, + index_descriptor: Descriptor, + native_platform: &Platform, + found_candidates: &mut Vec, + ) -> Result> { + 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) + } + + /// Resolve a manifest descriptor for the given platform, reading the config + /// blob when the descriptor has no explicit `platform` annotation. + /// + /// Returns `Ok(Some(manifest))` when `desc` is compatible with `native`, + /// `Ok(None)` when it is not, and `Err(_)` on I/O or parse errors. + fn resolve_descriptor_for_platform( + &self, + desc: &Descriptor, + native: &Platform, + ) -> Result> { + // Fast path: explicit platform annotation — no blob I/O needed. + if let Some(platform) = desc.platform().as_ref() { + if platform_compatible(platform, native) { + return Ok(Some(self.read_json_blob::(desc)?)); + } + return Ok(None); + } + + // If there's no annotation then read the manifest and config. + let manifest = self.read_json_blob::(desc)?; + + // Only image manifests (not OCI artifact manifests) carry a platform in + // their config blob. Skip the read entirely for anything else. + 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) + } + } + + /// Find all descriptors in the index that reference the given subject + /// digest, as required by the [Referrers API][referrers]. + /// + /// Returns descriptors from the index whose corresponding manifest has a + /// `subject` field matching the given digest. The returned descriptors + /// include `artifact_type` and annotations as required by the spec. + /// + /// The `artifact_type_filter` parameter optionally filters results to only + /// include referrers with a matching `artifact_type`. + /// + /// Note: this reads each manifest blob from disk to inspect its `subject` + /// field, so the cost scales with the number of manifests in the index. + /// + /// [referrers]: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-referrers + fn find_referrers( + &self, + subject_digest: &Digest, + artifact_type_filter: Option<&MediaType>, + ) -> Result> { + let index = self.read_index()?; + let mut referrers = Vec::new(); + + for desc in index.manifests() { + // Only image manifests can carry a subject field; skip image + // indices and other media types to avoid deserialization errors. + 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; + } + + // Apply artifact_type filter if requested + 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) + } + + /// Verify a blob's SHA-256 digest matches its descriptor. + 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(()) + } + + /// Verify a single manifest and all of its referenced objects. + /// Skips already validated blobs referenced by digest in `validated`, + /// and updates that set with ones we did validate. + fn fsck_one_manifest( + &self, + manifest: &ImageManifest, + validated: &mut HashSet>, + ) -> Result<()> { + let config_digest = sha256_of_descriptor(manifest.config())?; + if !validated.contains(config_digest) { + // Always verify the config blob digest, regardless of media type. + self.verify_blob_digest(manifest.config())?; + // Additionally validate the content structure for known types. + 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())?; + } + // Per the OCI image spec, implementations MUST NOT error on + // encountering an unknown config mediaType. + _ => {} + } + 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(()) + } + + /// Verify consistency of the index, its manifests, the config and blobs (all the latter) + /// by verifying their descriptor. + fn fsck(&self) -> Result { + 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 OciDir { /// Create an empty config descriptor. /// See https://github.com/opencontainers/image-spec/blob/main/manifest.md#guidance-for-an-empty-descriptor @@ -502,42 +900,6 @@ impl OciDir { Ok(PathBuf::from(digest)) } - /// Open a blob; its size is validated as a sanity check. - pub fn read_blob(&self, desc: &oci_spec::image::Descriptor) -> Result { - 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) - } - - /// Returns `true` if the blob with this digest is already present. - pub fn has_blob(&self, desc: &oci_spec::image::Descriptor) -> Result { - let path = Self::parse_descriptor_to_path(desc)?; - self.blobs_dir.try_exists(path).map_err(Into::into) - } - - /// Returns `true` if the manifest is already present. - pub fn has_manifest(&self, desc: &oci_spec::image::Descriptor) -> Result { - let index = self.read_index()?; - Ok(index - .manifests() - .iter() - .any(|m| m.digest() == desc.digest())) - } - - /// Read a JSON blob. - pub fn read_json_blob( - &self, - desc: &oci_spec::image::Descriptor, - ) -> Result { - let blob = BufReader::new(self.read_blob(desc)?); - serde_json::from_reader(blob).map_err(Into::into) - } - /// Write a configuration blob. pub fn write_config( &self, @@ -548,16 +910,6 @@ impl OciDir { .build()?) } - /// Read the image index. - pub fn read_index(&self) -> Result { - 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) - } - /// Write a manifest as a blob, and replace the index with a reference to it. /// /// When the manifest has a `subject` field (i.e. it is a referrer artifact), @@ -684,417 +1036,239 @@ impl OciDir { manifest_builder = manifest_builder.annotations(annos); } - let manifest = manifest_builder.build()?; - - // Write the manifest blob and build a descriptor without a platform - // field. We propagate artifact_type and annotations to the descriptor - // for the Referrers API, as required by the OCI distribution spec. - 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) - } - - /// Append a descriptor to the index, optionally replacing any existing - /// entry with the same tag. - 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) - } - - /// Find all descriptors in the index that reference the given subject - /// digest, as required by the [Referrers API][referrers]. - /// - /// Returns descriptors from the index whose corresponding manifest has a - /// `subject` field matching the given digest. The returned descriptors - /// include `artifact_type` and annotations as required by the spec. - /// - /// The `artifact_type_filter` parameter optionally filters results to only - /// include referrers with a matching `artifact_type`. - /// - /// Note: this reads each manifest blob from disk to inspect its `subject` - /// field, so the cost scales with the number of manifests in the index. - /// - /// [referrers]: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-referrers - pub fn find_referrers( - &self, - subject_digest: &Digest, - artifact_type_filter: Option<&MediaType>, - ) -> Result> { - let index = self.read_index()?; - let mut referrers = Vec::new(); - - for desc in index.manifests() { - // Only image manifests can carry a subject field; skip image - // indices and other media types to avoid deserialization errors. - 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; - } - - // Apply artifact_type filter if requested - 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) - } - - /// Write a manifest as a blob, and replace the index with a reference to it. - 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() - } - - /// Find the manifest with the provided tag - pub fn find_manifest_with_tag(&self, tag: &str) -> Result> { - let desc = self.find_manifest_descriptor_with_tag(tag)?; - desc.map(|img| self.read_json_blob(&img)).transpose() - } - - /// Find the manifest descriptor with the provided tag - pub fn find_manifest_descriptor_with_tag( - &self, - tag: &str, - ) -> Result> { - let idx = self.read_index()?; - Ok(idx - .manifests() - .iter() - .find(|desc| Self::descriptor_is_tagged(desc, tag)) - .cloned()) - } - - /// Open an image manifest for the current platform. - /// - /// This resolves the appropriate manifest from the index for the native - /// platform (OS and architecture). If `tag` is provided, only manifests - /// with that tag annotation are considered. - /// - /// If the index contains an image index (manifest list), it is "peeled" - /// to get the underlying manifests. Nested image indices are not supported. - /// - /// Returns a [`ResolvedManifest`] containing the manifest, its digest, - /// and optionally the image index it was resolved from with its digest. - /// - /// # Errors - /// - /// Returns an error if: - /// - The index cannot be read - /// - The index is empty - /// - A tag is specified but not found - /// - No manifest matches the native platform - /// - A nested image index is encountered - pub fn open_image_this_platform(&self, tag: Option<&str>) -> Result { - let index = self.read_index()?; - let manifests = index.manifests(); - - // Filter by tag if specified, returning early on empty results - 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() - }; - - // Get the native platform - let native_platform = Platform::default(); - - // Collect all found candidate descriptors for error reporting - let mut found_candidates: Vec = 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 => { - // Peel the manifest list - 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(), - }); - } - } + let manifest = manifest_builder.build()?; + + // Write the manifest blob and build a descriptor without a platform + // field. We propagate artifact_type and annotations to the descriptor + // for the Referrers API, as required by the OCI distribution spec. + 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()); } - // No match found - 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()), - }) + let manifest_desc = desc_builder.build()?; + self.append_to_index(manifest_desc.clone(), None)?; + Ok(manifest_desc) } - /// Resolve a manifest from an image index (manifest list) for a given platform. - /// - /// Iterates the manifests within the index, returning the first one that - /// matches `native_platform`. Non-matching descriptors are appended to - /// `found_candidates` so callers can include them in error messages. - /// - /// Returns `Ok(None)` if no manifest in this index matched. - fn resolve_manifest_list( - &self, - index: ImageIndex, - index_descriptor: Descriptor, - native_platform: &Platform, - found_candidates: &mut Vec, - ) -> Result> { - 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(), - }); + /// Append a descriptor to the index, optionally replacing any existing + /// entry with the same tag. + 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| !descriptor_is_tagged(d, tag)); } + manifests.push(desc); + index.set_manifests(manifests); + index } - } - Ok(None) + 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) } - /// Format the available platforms from a list of descriptors for error messages. - /// Limits output to 10 platforms to prevent excessive memory usage. - fn format_available_platforms<'a>(manifests: impl Iterator) -> Box { - const MAX_PLATFORMS_IN_ERROR: usize = 10; + /// Write a manifest as a blob, and replace the index with a reference to it. + 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) + } +} - let platforms: Vec<_> = manifests - .filter_map(|d| { - d.platform() - .as_ref() - .map(|p| format!("{}/{}", p.os(), p.architecture())) - }) - .take(MAX_PLATFORMS_IN_ERROR + 1) // Take one extra to detect truncation - .collect(); +impl OciRead for OciDir { + type BlobReader = File; - if platforms.is_empty() { - return "(no platform info)".into(); + fn read_blob(&self, desc: &Descriptor) -> Result { + 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) + } - if platforms.len() > MAX_PLATFORMS_IN_ERROR { - let truncated: Vec<_> = platforms.into_iter().take(MAX_PLATFORMS_IN_ERROR).collect(); - format!("{}, ...", truncated.join(", ")).into() + fn read_index(&self) -> Result { + let r = if let Some(index) = self.dir.open_optional("index.json")?.map(BufReader::new) { + oci_image::ImageIndex::from_reader(index)? } else { - platforms.join(", ").into() - } + return Err(Error::MissingImageIndex); + }; + Ok(r) } - /// Check if a platform is compatible with the native platform. - /// - /// Platform has additional optional fields (variant, os_version, - /// os_features, features) which are primarily used for Windows images. - /// We only compare architecture and OS for compatibility. - fn platform_compatible(platform: &Platform, native: &Platform) -> bool { - platform.architecture() == native.architecture() && platform.os() == native.os() + fn has_blob(&self, desc: &Descriptor) -> Result { + let path = OciDir::parse_descriptor_to_path(desc)?; + self.blobs_dir.try_exists(path).map_err(Into::into) } +} - /// Resolve a manifest descriptor for the given platform, reading the config - /// blob when the descriptor has no explicit `platform` annotation. - /// - /// Returns `Ok(Some(manifest))` when `desc` is compatible with `native`, - /// `Ok(None)` when it is not, and `Err(_)` on I/O or parse errors. - fn resolve_descriptor_for_platform( - &self, - desc: &Descriptor, - native: &Platform, - ) -> Result> { - // Fast path: explicit platform annotation — no blob I/O needed. - if let Some(platform) = desc.platform().as_ref() { - if Self::platform_compatible(platform, native) { - return Ok(Some(self.read_json_blob::(desc)?)); - } - return Ok(None); +/// A reader for a region of a file, using pread for concurrent access. +#[derive(Debug)] +pub struct ArchiveReader { + file: Arc, + start: u64, + offset: u64, + size: u64, +} + +impl Read for ArchiveReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let remaining = (self.size - self.offset) as usize; + let to_read = buf.len().min(remaining); + if to_read == 0 { + return Ok(0); } + #[cfg(unix)] + let n = self + .file + .read_at(&mut buf[..to_read], self.start + self.offset)?; + #[cfg(windows)] + let n = self + .file + .seek_read(&mut buf[..to_read], self.start + self.offset)?; + self.offset += n as u64; + Ok(n) + } +} - // If there's no annotation then read the manifest and config. - let manifest = self.read_json_blob::(desc)?; +impl Seek for ArchiveReader { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + let new_pos = match pos { + std::io::SeekFrom::Start(n) => n.min(self.size), + std::io::SeekFrom::End(n) => { + if n >= 0 { + self.size + } else { + self.size.saturating_sub((-n) as u64) + } + } + std::io::SeekFrom::Current(n) => { + if n >= 0 { + self.offset.saturating_add(n as u64).min(self.size) + } else { + self.offset.saturating_sub((-n) as u64) + } + } + }; + self.offset = new_pos; + Ok(new_pos) + } +} - // Only image manifests (not OCI artifact manifests) carry a platform in - // their config blob. Skip the read entirely for anything else. - if manifest.config().media_type() != &MediaType::ImageConfig { - return Ok(None); - } +/// A read-only OCI image stored as a tar archive. +/// +/// This type provides read access to OCI images packaged as tar files +/// (the OCI archive format). It scans the tar entries on open and uses +/// pread-based access for concurrent blob reads. +#[derive(Debug)] +pub struct OciArchive { + file: Arc, + /// Map from tar entry path (e.g. "blobs/sha256/abcd...") to (offset, size). + entries: HashMap, +} - let config: ImageConfiguration = self.read_json_blob(manifest.config())?; - if config.architecture() == native.architecture() && config.os() == native.os() { - Ok(Some(manifest)) - } else { - Ok(None) +impl OciArchive { + /// Open an OCI archive from a filesystem path. + pub fn open(path: impl AsRef) -> Result { + let file = File::open(path)?; + Self::from_file(file) + } + + /// Open an OCI archive from an already-opened file. + pub fn from_file(file: File) -> Result { + let mut archive = tar::Archive::new(&file); + let mut entries = HashMap::new(); + for entry in archive.entries()? { + let entry = entry?; + let path: PathBuf = entry + .path()? + .components() + .filter(|c| *c != std::path::Component::CurDir) + .collect(); + let offset = entry.raw_file_position(); + let size = entry.size(); + entries.insert(path, (offset, size)); } + Ok(Self { + file: Arc::new(file), + entries, + }) } - /// Verify a blob's SHA-256 digest matches its descriptor. - 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(), + fn open_entry(&self, path: &Path) -> Result { + let &(offset, size) = self.entries.get(path).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("not found in archive: {}", path.display()), + ) + })?; + Ok(ArchiveReader { + file: Arc::clone(&self.file), + start: offset, + offset: 0, + size, + }) + } +} + +impl OciRead for OciArchive { + type BlobReader = ArchiveReader; + + fn read_blob(&self, desc: &Descriptor) -> Result { + let sha256 = sha256_of_descriptor(desc)?; + let path = Path::new(BLOBDIR).join(sha256); + let reader = self.open_entry(&path)?; + let expected = desc.size(); + if expected != reader.size { + return Err(Error::SizeMismatch { + expected, + found: reader.size, }); } - Ok(()) + Ok(reader) } - /// Verify a single manifest and all of its referenced objects. - /// Skips already validated blobs referenced by digest in `validated`, - /// and updates that set with ones we did validate. - fn fsck_one_manifest( - &self, - manifest: &ImageManifest, - validated: &mut HashSet>, - ) -> Result<()> { - let config_digest = sha256_of_descriptor(manifest.config())?; - if !validated.contains(config_digest) { - // Always verify the config blob digest, regardless of media type. - self.verify_blob_digest(manifest.config())?; - // Additionally validate the content structure for known types. - match manifest.config().media_type() { - MediaType::ImageConfig => { - let _: ImageConfiguration = self.read_json_blob(manifest.config())?; + fn read_index(&self) -> Result { + let reader = self + .open_entry(Path::new("index.json")) + .map_err(|e| match e { + Error::Io(ref io) if io.kind() == std::io::ErrorKind::NotFound => { + Error::MissingImageIndex } - MediaType::EmptyJSON => { - let _: EmptyDescriptor = self.read_json_blob(manifest.config())?; - } - // Per the OCI image spec, implementations MUST NOT error on - // encountering an unknown config mediaType. - _ => {} - } - 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(()) + other => other, + })?; + let index = oci_image::ImageIndex::from_reader(BufReader::new(reader))?; + Ok(index) } - /// Verify consistency of the index, its manifests, the config and blobs (all the latter) - /// by verifying their descriptor. - pub fn fsck(&self) -> Result { - 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()) + fn has_blob(&self, desc: &Descriptor) -> Result { + let digest = sha256_of_descriptor(desc)?; + let path = Path::new(BLOBDIR).join(digest); + Ok(self.entries.contains_key(&path)) } } @@ -2333,4 +2507,75 @@ mod tests { Ok(()) } + + fn append_cap_file( + tar: &mut tar::Builder, + path: &str, + f: cap_std::fs::File, + ) -> Result<()> { + let f = f.into_std(); + let len = f.metadata()?.len(); + let mut header = tar::Header::new_gnu(); + header.set_size(len); + header.set_mode(0o644); + header.set_cksum(); + tar.append_data(&mut header, path, &f)?; + Ok(()) + } + + fn ocidir_to_tar_file(w: &OciDir) -> Result { + let archive_path = std::env::temp_dir().join("test_oci_archive.tar"); + let archive_file = std::fs::File::create(&archive_path)?; + let mut tar_builder = tar::Builder::new(archive_file); + + append_cap_file(&mut tar_builder, "oci-layout", w.dir().open("oci-layout")?)?; + append_cap_file(&mut tar_builder, "index.json", w.dir().open("index.json")?)?; + + for entry in w.blobs_dir().entries()? { + let entry = entry?; + let name = entry.file_name(); + let path = format!("{BLOBDIR}/{}", name.to_string_lossy()); + append_cap_file(&mut tar_builder, &path, w.blobs_dir().open(name)?)?; + } + + tar_builder.finish()?; + let f = std::fs::File::open(&archive_path)?; + std::fs::remove_file(&archive_path)?; + Ok(f) + } + + #[test] + fn test_open_archive() -> Result<()> { + let (_td, w) = new_ocidir()?; + + let root_layer = create_test_layer(&w, b"pretend this is a 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); + w.insert_manifest_and_config( + manifest, + config, + Some("latest"), + oci_image::Platform::default(), + )?; + + let expected_fsck = w.fsck()?; + + let tar_file = ocidir_to_tar_file(&w)?; + let archive = OciArchive::from_file(tar_file)?; + + let index = archive.read_index()?; + assert_eq!(index.manifests().len(), 1); + + let resolved = archive.open_image_this_platform(Some("latest"))?; + assert_eq!(resolved.manifest.layers().len(), 1); + + assert_eq!(archive.fsck()?, expected_fsck); + + let found = archive.find_manifest_with_tag("latest")?; + assert!(found.is_some()); + assert!(archive.find_manifest_with_tag("noent")?.is_none()); + + Ok(()) + } }