use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde_json::json;
use crate::models::{DatasourceId, PackageData, PackageType};
use crate::parser_warn as warn;
use crate::parsers::utils::{
MAX_ITERATION_COUNT, capped_iteration_limit, read_file_to_string, truncate_field,
};
use super::PackageParser;
use super::metadata::ParserMetadata;
const PACKAGE_TYPE: PackageType = PackageType::Oci;
const OCI_INDEX_MEDIA_TYPE: &str = "application/vnd.oci.image.index.v1+json";
const DOCKER_MANIFEST_LIST_MEDIA_TYPE: &str =
"application/vnd.docker.distribution.manifest.list.v2+json";
const CONTAINERD_IMAGE_NAME_ANNOTATION: &str = "io.containerd.image.name";
const REF_NAME_ANNOTATION: &str = "org.opencontainers.image.ref.name";
const MAX_INDEX_DEPTH: usize = 8;
#[derive(Debug, Deserialize)]
struct OciImageIndex {
#[serde(default)]
#[serde(rename = "mediaType")]
media_type: Option<String>,
#[serde(default)]
#[serde(rename = "schemaVersion")]
schema_version: Option<u64>,
#[serde(default)]
manifests: Vec<OciDescriptor>,
}
#[derive(Debug, Deserialize)]
struct OciDescriptor {
#[serde(default)]
#[serde(rename = "mediaType")]
media_type: Option<String>,
#[serde(default)]
digest: Option<String>,
#[serde(default)]
platform: Option<OciPlatform>,
#[serde(default)]
annotations: Option<HashMap<String, String>>,
}
#[derive(Debug, Deserialize)]
struct OciPlatform {
#[serde(default)]
architecture: Option<String>,
#[serde(default)]
os: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OciImageManifest {
#[serde(default)]
config: Option<OciConfigDescriptor>,
}
#[derive(Debug, Deserialize)]
struct OciConfigDescriptor {
#[serde(default)]
digest: Option<String>,
}
#[derive(Debug, Deserialize)]
struct DockerSaveManifestEntry {
#[serde(default)]
#[serde(rename = "Config")]
config: Option<String>,
#[serde(default)]
#[serde(rename = "RepoTags")]
repo_tags: Option<Vec<String>>,
}
pub struct OciImageLayoutParser;
impl PackageParser for OciImageLayoutParser {
const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
fn metadata() -> Vec<ParserMetadata> {
vec![ParserMetadata {
description: "OCI image layout index.json and docker save manifest.json",
file_patterns: &["**/index.json", "**/manifest.json"],
package_type: "oci",
primary_language: "",
documentation_url: Some(
"https://github.com/opencontainers/image-spec/blob/main/image-layout.md",
),
}]
}
fn is_match(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "index.json" || name == "manifest.json")
}
fn extract_packages(path: &Path) -> Vec<PackageData> {
let content = match read_file_to_string(path, None) {
Ok(content) => content,
Err(error) => {
warn!("Failed to read OCI manifest {:?}: {}", path, error);
return Vec::new();
}
};
let layout_root = path.parent().map(Path::to_path_buf);
parse_oci_content(&content, layout_root.as_deref())
}
}
pub(crate) fn parse_oci_content(content: &str, layout_root: Option<&Path>) -> Vec<PackageData> {
let trimmed = content.trim_start();
if trimmed.starts_with('[') {
return parse_docker_save_manifest(content);
}
parse_oci_image_index(content, layout_root)
}
fn parse_oci_image_index(content: &str, layout_root: Option<&Path>) -> Vec<PackageData> {
let index: OciImageIndex = match serde_json::from_str(content) {
Ok(index) => index,
Err(error) => {
warn!("Failed to parse OCI image index JSON: {}", error);
return Vec::new();
}
};
if !is_oci_image_index(&index) {
return Vec::new();
}
let mut packages = Vec::new();
collect_index_packages(index, layout_root, &Identity::default(), 0, &mut packages);
packages
}
#[derive(Default, Clone)]
struct Identity {
name: Option<String>,
tag: Option<String>,
repository_url: Option<String>,
image_ref: Option<String>,
}
fn collect_index_packages(
index: OciImageIndex,
layout_root: Option<&Path>,
inherited: &Identity,
depth: usize,
packages: &mut Vec<PackageData>,
) {
let manifest_limit = capped_iteration_limit(index.manifests.len(), "OCI image index manifests");
for descriptor in index.manifests.into_iter().take(manifest_limit) {
if packages.len() >= MAX_ITERATION_COUNT {
break;
}
if descriptor_is_attestation(&descriptor) {
continue;
}
let resolved = resolve_identity(&descriptor, inherited);
if depth < MAX_INDEX_DEPTH
&& descriptor_is_index(&descriptor)
&& let Some(nested) = read_index_blob(layout_root, descriptor.digest.as_deref())
{
collect_index_packages(nested, layout_root, &resolved, depth + 1, packages);
continue;
}
if let Some(package) = package_from_descriptor(&descriptor, &resolved, layout_root) {
packages.push(package);
}
}
}
fn descriptor_is_index(descriptor: &OciDescriptor) -> bool {
matches!(
descriptor.media_type.as_deref(),
Some(OCI_INDEX_MEDIA_TYPE) | Some(DOCKER_MANIFEST_LIST_MEDIA_TYPE)
)
}
const ATTESTATION_REF_TYPE_ANNOTATION: &str = "vnd.docker.reference.type";
fn descriptor_is_attestation(descriptor: &OciDescriptor) -> bool {
descriptor
.annotations
.as_ref()
.is_some_and(|annotations| annotations.contains_key(ATTESTATION_REF_TYPE_ANNOTATION))
}
fn resolve_identity(descriptor: &OciDescriptor, inherited: &Identity) -> Identity {
let annotations = descriptor.annotations.as_ref();
let containerd_name = annotations
.and_then(|annotations| annotations.get(CONTAINERD_IMAGE_NAME_ANNOTATION))
.map(|value| value.trim())
.filter(|value| !value.is_empty());
let ref_name = annotations
.and_then(|annotations| annotations.get(REF_NAME_ANNOTATION))
.map(|value| value.trim())
.filter(|value| !value.is_empty());
let identity_ref = containerd_name.or(ref_name);
if identity_ref.is_none() {
return inherited.clone();
}
let parsed = parse_image_ref(identity_ref);
let mut tag = parsed.tag;
if tag.is_none()
&& containerd_name.is_some()
&& let Some(bare) = ref_name
&& !bare.contains('/')
&& !bare.contains(':')
{
tag = Some(bare.to_string());
}
Identity {
name: parsed.name.or_else(|| inherited.name.clone()),
tag: tag.or_else(|| inherited.tag.clone()),
repository_url: parsed
.repository_url
.or_else(|| inherited.repository_url.clone()),
image_ref: identity_ref
.map(str::to_string)
.or_else(|| inherited.image_ref.clone()),
}
}
fn is_oci_image_index(index: &OciImageIndex) -> bool {
let media_type = index.media_type.as_deref();
if media_type == Some(OCI_INDEX_MEDIA_TYPE)
|| media_type == Some(DOCKER_MANIFEST_LIST_MEDIA_TYPE)
{
return true;
}
index.schema_version == Some(2)
&& index.manifests.iter().any(|descriptor| {
descriptor
.digest
.as_deref()
.is_some_and(|d| !d.trim().is_empty())
})
}
fn package_from_descriptor(
descriptor: &OciDescriptor,
identity: &Identity,
layout_root: Option<&Path>,
) -> Option<PackageData> {
let descriptor_digest = descriptor
.digest
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())?;
let name = identity.name.clone()?;
let (version, digest_source) = match read_config_digest(layout_root, descriptor_digest) {
Some(config_digest) => (config_digest, "config"),
None => (descriptor_digest.to_string(), "descriptor"),
};
let mut qualifiers: HashMap<String, String> = HashMap::new();
if let Some(tag) = identity.tag.clone() {
qualifiers.insert("tag".to_string(), truncate_field(tag));
}
if let Some(platform) = descriptor.platform.as_ref()
&& let Some(arch) = platform
.architecture
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
qualifiers.insert("arch".to_string(), arch.to_string());
}
if let Some(repository_url) = identity.repository_url.as_deref() {
qualifiers.insert(
"repository_url".to_string(),
truncate_field(repository_url.to_string()),
);
}
let mut extra_data: HashMap<String, serde_json::Value> = HashMap::new();
if let Some(identity_ref) = identity.image_ref.as_deref() {
extra_data.insert("image_ref_name".to_string(), json!(identity_ref));
}
if let Some(platform) = descriptor.platform.as_ref()
&& let Some(os) = platform
.os
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
extra_data.insert("os".to_string(), json!(os));
}
extra_data.insert("digest_source".to_string(), json!(digest_source));
Some(build_package(
Some(name),
version,
qualifiers,
extra_data,
DatasourceId::OciImageIndex,
))
}
fn read_config_digest(layout_root: Option<&Path>, digest: &str) -> Option<String> {
let content = read_blob(layout_root, digest)?;
let manifest: OciImageManifest = serde_json::from_str(&content).ok()?;
let config_digest = manifest
.config?
.digest
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())?;
Some(config_digest)
}
fn read_index_blob(layout_root: Option<&Path>, digest: Option<&str>) -> Option<OciImageIndex> {
let content = read_blob(layout_root, digest?)?;
let index: OciImageIndex = serde_json::from_str(&content).ok()?;
is_oci_image_index(&index).then_some(index)
}
fn read_blob(layout_root: Option<&Path>, digest: &str) -> Option<String> {
let layout_root = layout_root?;
let hex = digest.strip_prefix("sha256:")?;
if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let blob_path: PathBuf = layout_root.join("blobs").join("sha256").join(hex);
read_file_to_string(&blob_path, None).ok()
}
fn parse_docker_save_manifest(content: &str) -> Vec<PackageData> {
let entries: Vec<DockerSaveManifestEntry> = match serde_json::from_str(content) {
Ok(entries) => entries,
Err(error) => {
warn!("Failed to parse docker save manifest JSON: {}", error);
return Vec::new();
}
};
let entry_limit = capped_iteration_limit(entries.len(), "docker save manifest entries");
entries
.into_iter()
.take(entry_limit)
.filter_map(package_from_docker_save_entry)
.collect()
}
fn package_from_docker_save_entry(entry: DockerSaveManifestEntry) -> Option<PackageData> {
let config = entry
.config
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())?;
let digest = config_to_digest(config)?;
let ref_name = entry
.repo_tags
.as_ref()
.and_then(|tags| tags.iter().find(|tag| !tag.trim().is_empty()))
.map(|tag| tag.trim());
let parsed = parse_image_ref(ref_name);
let name = parsed.name.clone()?;
let mut qualifiers: HashMap<String, String> = HashMap::new();
if let Some(tag) = parsed.tag.clone() {
qualifiers.insert("tag".to_string(), truncate_field(tag));
}
if let Some(repository_url) = parsed.repository_url.as_deref() {
qualifiers.insert(
"repository_url".to_string(),
truncate_field(repository_url.to_string()),
);
}
let mut extra_data: HashMap<String, serde_json::Value> = HashMap::new();
if let Some(ref_name) = ref_name {
extra_data.insert("image_ref_name".to_string(), json!(ref_name));
}
extra_data.insert("digest_source".to_string(), json!("config"));
Some(build_package(
Some(name),
digest,
qualifiers,
extra_data,
DatasourceId::OciImageManifest,
))
}
fn config_to_digest(config: &str) -> Option<String> {
if config.starts_with("sha256:") {
return Some(config.to_string());
}
let file_name = Path::new(config)
.file_name()
.and_then(|name| name.to_str())?;
let hex = file_name.strip_suffix(".json").unwrap_or(file_name);
if hex.is_empty() {
return None;
}
Some(format!("sha256:{hex}"))
}
struct ImageRef {
name: Option<String>,
tag: Option<String>,
repository_url: Option<String>,
}
fn parse_image_ref(ref_name: Option<&str>) -> ImageRef {
let Some(ref_name) = ref_name else {
return ImageRef {
name: None,
tag: None,
repository_url: None,
};
};
let without_digest = ref_name.split_once('@').map_or(ref_name, |(name, _)| name);
let last_segment_start = without_digest.rfind('/').map_or(0, |index| index + 1);
let (repository, tag) = match without_digest[last_segment_start..].split_once(':') {
Some((repo_segment, tag)) => (
&without_digest[..last_segment_start + repo_segment.len()],
Some(tag.to_string()),
),
None => (without_digest, None),
};
let name = repository
.rsplit('/')
.next()
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty());
ImageRef {
name,
tag: tag.filter(|value| !value.trim().is_empty()),
repository_url: repository_url_from_repository(repository),
}
}
fn repository_url_from_repository(repository: &str) -> Option<String> {
let repository = repository.trim();
if repository.is_empty() {
return None;
}
let first_segment = repository.split('/').next().unwrap_or(repository);
let looks_like_registry =
first_segment == "localhost" || first_segment.contains('.') || first_segment.contains(':');
looks_like_registry.then(|| repository.to_string())
}
fn build_package(
name: Option<String>,
digest: String,
qualifiers: HashMap<String, String>,
extra_data: HashMap<String, serde_json::Value>,
datasource_id: DatasourceId,
) -> PackageData {
let name = name.map(truncate_field);
let version = truncate_field(digest);
let purl = name
.as_deref()
.and_then(|name| build_oci_purl(name, &version, &qualifiers));
PackageData {
package_type: Some(PACKAGE_TYPE),
datasource_id: Some(datasource_id),
name,
version: Some(version),
qualifiers: (!qualifiers.is_empty()).then_some(qualifiers),
extra_data: (!extra_data.is_empty()).then_some(extra_data),
purl,
..Default::default()
}
}
fn build_oci_purl(
name: &str,
version: &str,
qualifiers: &HashMap<String, String>,
) -> Option<String> {
use packageurl::PackageUrl;
let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
purl.with_version(version).ok()?;
let mut keys: Vec<&String> = qualifiers.keys().collect();
keys.sort();
for key in keys {
if let Some(value) = qualifiers.get(key) {
let _ = purl.add_qualifier(key.as_str(), value.as_str());
}
}
Some(purl.to_string())
}