1use std::collections::HashMap;
27use std::path::{Path, PathBuf};
28
29use serde::Deserialize;
30use serde_json::json;
31
32use crate::models::{DatasourceId, PackageData, PackageType};
33use crate::parser_warn as warn;
34use crate::parsers::utils::{
35 MAX_ITERATION_COUNT, capped_iteration_limit, read_file_to_string, truncate_field,
36};
37
38use super::PackageParser;
39use super::metadata::ParserMetadata;
40
41const PACKAGE_TYPE: PackageType = PackageType::Oci;
42
43const OCI_INDEX_MEDIA_TYPE: &str = "application/vnd.oci.image.index.v1+json";
45const DOCKER_MANIFEST_LIST_MEDIA_TYPE: &str =
47 "application/vnd.docker.distribution.manifest.list.v2+json";
48
49const CONTAINERD_IMAGE_NAME_ANNOTATION: &str = "io.containerd.image.name";
53const REF_NAME_ANNOTATION: &str = "org.opencontainers.image.ref.name";
54
55const MAX_INDEX_DEPTH: usize = 8;
59
60#[derive(Debug, Deserialize)]
62struct OciImageIndex {
63 #[serde(default)]
64 #[serde(rename = "mediaType")]
65 media_type: Option<String>,
66 #[serde(default)]
67 #[serde(rename = "schemaVersion")]
68 schema_version: Option<u64>,
69 #[serde(default)]
70 manifests: Vec<OciDescriptor>,
71}
72
73#[derive(Debug, Deserialize)]
75struct OciDescriptor {
76 #[serde(default)]
77 #[serde(rename = "mediaType")]
78 media_type: Option<String>,
79 #[serde(default)]
80 digest: Option<String>,
81 #[serde(default)]
82 platform: Option<OciPlatform>,
83 #[serde(default)]
84 annotations: Option<HashMap<String, String>>,
85}
86
87#[derive(Debug, Deserialize)]
88struct OciPlatform {
89 #[serde(default)]
90 architecture: Option<String>,
91 #[serde(default)]
92 os: Option<String>,
93}
94
95#[derive(Debug, Deserialize)]
98struct OciImageManifest {
99 #[serde(default)]
100 config: Option<OciConfigDescriptor>,
101}
102
103#[derive(Debug, Deserialize)]
104struct OciConfigDescriptor {
105 #[serde(default)]
106 digest: Option<String>,
107}
108
109#[derive(Debug, Deserialize)]
111struct DockerSaveManifestEntry {
112 #[serde(default)]
113 #[serde(rename = "Config")]
114 config: Option<String>,
115 #[serde(default)]
116 #[serde(rename = "RepoTags")]
117 repo_tags: Option<Vec<String>>,
118}
119
120pub struct OciImageLayoutParser;
121
122impl PackageParser for OciImageLayoutParser {
123 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
124
125 fn metadata() -> Vec<ParserMetadata> {
126 vec![ParserMetadata {
127 description: "OCI image layout index.json and docker save manifest.json",
128 file_patterns: &["**/index.json", "**/manifest.json"],
129 package_type: "oci",
130 primary_language: "",
131 documentation_url: Some(
132 "https://github.com/opencontainers/image-spec/blob/main/image-layout.md",
133 ),
134 }]
135 }
136
137 fn is_match(path: &Path) -> bool {
138 path.file_name()
139 .and_then(|name| name.to_str())
140 .is_some_and(|name| name == "index.json" || name == "manifest.json")
141 }
142
143 fn extract_packages(path: &Path) -> Vec<PackageData> {
144 let content = match read_file_to_string(path, None) {
145 Ok(content) => content,
146 Err(error) => {
147 warn!("Failed to read OCI manifest {:?}: {}", path, error);
148 return Vec::new();
149 }
150 };
151
152 let layout_root = path.parent().map(Path::to_path_buf);
156 parse_oci_content(&content, layout_root.as_deref())
157 }
158}
159
160pub(crate) fn parse_oci_content(content: &str, layout_root: Option<&Path>) -> Vec<PackageData> {
171 let trimmed = content.trim_start();
173 if trimmed.starts_with('[') {
174 return parse_docker_save_manifest(content);
175 }
176
177 parse_oci_image_index(content, layout_root)
178}
179
180fn parse_oci_image_index(content: &str, layout_root: Option<&Path>) -> Vec<PackageData> {
181 let index: OciImageIndex = match serde_json::from_str(content) {
182 Ok(index) => index,
183 Err(error) => {
184 log::debug!("Declining non-OCI index.json/manifest.json: {error}");
189 return Vec::new();
190 }
191 };
192
193 if !is_oci_image_index(&index) {
194 return Vec::new();
195 }
196
197 let mut packages = Vec::new();
198 collect_index_packages(index, layout_root, &Identity::default(), 0, &mut packages);
199 packages
200}
201
202#[derive(Default, Clone)]
208struct Identity {
209 name: Option<String>,
210 tag: Option<String>,
211 repository_url: Option<String>,
212 image_ref: Option<String>,
213}
214
215fn collect_index_packages(
220 index: OciImageIndex,
221 layout_root: Option<&Path>,
222 inherited: &Identity,
223 depth: usize,
224 packages: &mut Vec<PackageData>,
225) {
226 let manifest_limit = capped_iteration_limit(index.manifests.len(), "OCI image index manifests");
227 for descriptor in index.manifests.into_iter().take(manifest_limit) {
228 if packages.len() >= MAX_ITERATION_COUNT {
229 break;
230 }
231
232 if descriptor_is_attestation(&descriptor) {
236 continue;
237 }
238
239 let resolved = resolve_identity(&descriptor, inherited);
241
242 if depth < MAX_INDEX_DEPTH
247 && descriptor_is_index(&descriptor)
248 && let Some(nested) = read_index_blob(layout_root, descriptor.digest.as_deref())
249 {
250 collect_index_packages(nested, layout_root, &resolved, depth + 1, packages);
251 continue;
252 }
253
254 if let Some(package) = package_from_descriptor(&descriptor, &resolved, layout_root) {
255 packages.push(package);
256 }
257 }
258}
259
260fn descriptor_is_index(descriptor: &OciDescriptor) -> bool {
261 matches!(
262 descriptor.media_type.as_deref(),
263 Some(OCI_INDEX_MEDIA_TYPE) | Some(DOCKER_MANIFEST_LIST_MEDIA_TYPE)
264 )
265}
266
267const ATTESTATION_REF_TYPE_ANNOTATION: &str = "vnd.docker.reference.type";
268
269fn descriptor_is_attestation(descriptor: &OciDescriptor) -> bool {
273 descriptor
274 .annotations
275 .as_ref()
276 .is_some_and(|annotations| annotations.contains_key(ATTESTATION_REF_TYPE_ANNOTATION))
277}
278
279fn resolve_identity(descriptor: &OciDescriptor, inherited: &Identity) -> Identity {
282 let annotations = descriptor.annotations.as_ref();
283 let containerd_name = annotations
284 .and_then(|annotations| annotations.get(CONTAINERD_IMAGE_NAME_ANNOTATION))
285 .map(|value| value.trim())
286 .filter(|value| !value.is_empty());
287 let ref_name = annotations
288 .and_then(|annotations| annotations.get(REF_NAME_ANNOTATION))
289 .map(|value| value.trim())
290 .filter(|value| !value.is_empty());
291
292 let identity_ref = containerd_name.or(ref_name);
295 if identity_ref.is_none() {
296 return inherited.clone();
298 }
299
300 let parsed = parse_image_ref(identity_ref);
301 let mut tag = parsed.tag;
302 if tag.is_none()
305 && containerd_name.is_some()
306 && let Some(bare) = ref_name
307 && !bare.contains('/')
308 && !bare.contains(':')
309 {
310 tag = Some(bare.to_string());
311 }
312
313 Identity {
314 name: parsed.name.or_else(|| inherited.name.clone()),
315 tag: tag.or_else(|| inherited.tag.clone()),
316 repository_url: parsed
317 .repository_url
318 .or_else(|| inherited.repository_url.clone()),
319 image_ref: identity_ref
320 .map(str::to_string)
321 .or_else(|| inherited.image_ref.clone()),
322 }
323}
324
325fn is_oci_image_index(index: &OciImageIndex) -> bool {
334 let media_type = index.media_type.as_deref();
335 if media_type == Some(OCI_INDEX_MEDIA_TYPE)
336 || media_type == Some(DOCKER_MANIFEST_LIST_MEDIA_TYPE)
337 {
338 return true;
339 }
340
341 index.schema_version == Some(2)
342 && index.manifests.iter().any(|descriptor| {
343 descriptor
344 .digest
345 .as_deref()
346 .is_some_and(|d| !d.trim().is_empty())
347 })
348}
349
350fn package_from_descriptor(
351 descriptor: &OciDescriptor,
352 identity: &Identity,
353 layout_root: Option<&Path>,
354) -> Option<PackageData> {
355 let descriptor_digest = descriptor
356 .digest
357 .as_deref()
358 .map(str::trim)
359 .filter(|value| !value.is_empty())?;
360
361 let name = identity.name.clone()?;
362
363 let (version, digest_source) = match read_config_digest(layout_root, descriptor_digest) {
367 Some(config_digest) => (config_digest, "config"),
368 None => (descriptor_digest.to_string(), "descriptor"),
369 };
370
371 let mut qualifiers: HashMap<String, String> = HashMap::new();
372 if let Some(tag) = identity.tag.clone() {
373 qualifiers.insert("tag".to_string(), truncate_field(tag));
374 }
375 if let Some(platform) = descriptor.platform.as_ref()
376 && let Some(arch) = platform
377 .architecture
378 .as_deref()
379 .map(str::trim)
380 .filter(|value| !value.is_empty())
381 {
382 qualifiers.insert("arch".to_string(), arch.to_string());
383 }
384 if let Some(repository_url) = identity.repository_url.as_deref() {
385 qualifiers.insert(
386 "repository_url".to_string(),
387 truncate_field(repository_url.to_string()),
388 );
389 }
390
391 let mut extra_data: HashMap<String, serde_json::Value> = HashMap::new();
392 if let Some(identity_ref) = identity.image_ref.as_deref() {
393 extra_data.insert("image_ref_name".to_string(), json!(identity_ref));
394 }
395 if let Some(platform) = descriptor.platform.as_ref()
396 && let Some(os) = platform
397 .os
398 .as_deref()
399 .map(str::trim)
400 .filter(|value| !value.is_empty())
401 {
402 extra_data.insert("os".to_string(), json!(os));
403 }
404 extra_data.insert("digest_source".to_string(), json!(digest_source));
405
406 Some(build_package(
407 Some(name),
408 version,
409 qualifiers,
410 extra_data,
411 DatasourceId::OciImageIndex,
412 ))
413}
414
415fn read_config_digest(layout_root: Option<&Path>, digest: &str) -> Option<String> {
420 let content = read_blob(layout_root, digest)?;
421 let manifest: OciImageManifest = serde_json::from_str(&content).ok()?;
422 let config_digest = manifest
423 .config?
424 .digest
425 .map(|value| value.trim().to_string())
426 .filter(|value| !value.is_empty())?;
427 Some(config_digest)
428}
429
430fn read_index_blob(layout_root: Option<&Path>, digest: Option<&str>) -> Option<OciImageIndex> {
434 let content = read_blob(layout_root, digest?)?;
435 let index: OciImageIndex = serde_json::from_str(&content).ok()?;
436 is_oci_image_index(&index).then_some(index)
437}
438
439fn read_blob(layout_root: Option<&Path>, digest: &str) -> Option<String> {
444 let layout_root = layout_root?;
445 let hex = digest.strip_prefix("sha256:")?;
446 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
447 return None;
448 }
449
450 let blob_path: PathBuf = layout_root.join("blobs").join("sha256").join(hex);
451 read_file_to_string(&blob_path, None).ok()
452}
453
454fn parse_docker_save_manifest(content: &str) -> Vec<PackageData> {
455 let entries: Vec<DockerSaveManifestEntry> = match serde_json::from_str(content) {
456 Ok(entries) => entries,
457 Err(error) => {
458 log::debug!("Declining non-docker-save manifest.json: {error}");
461 return Vec::new();
462 }
463 };
464
465 let entry_limit = capped_iteration_limit(entries.len(), "docker save manifest entries");
466 entries
467 .into_iter()
468 .take(entry_limit)
469 .filter_map(package_from_docker_save_entry)
470 .collect()
471}
472
473fn package_from_docker_save_entry(entry: DockerSaveManifestEntry) -> Option<PackageData> {
474 let config = entry
477 .config
478 .as_deref()
479 .map(str::trim)
480 .filter(|value| !value.is_empty())?;
481
482 let digest = config_to_digest(config)?;
483
484 let ref_name = entry
485 .repo_tags
486 .as_ref()
487 .and_then(|tags| tags.iter().find(|tag| !tag.trim().is_empty()))
488 .map(|tag| tag.trim());
489
490 let parsed = parse_image_ref(ref_name);
491 let name = parsed.name.clone()?;
492
493 let mut qualifiers: HashMap<String, String> = HashMap::new();
494 if let Some(tag) = parsed.tag.clone() {
495 qualifiers.insert("tag".to_string(), truncate_field(tag));
496 }
497 if let Some(repository_url) = parsed.repository_url.as_deref() {
498 qualifiers.insert(
499 "repository_url".to_string(),
500 truncate_field(repository_url.to_string()),
501 );
502 }
503
504 let mut extra_data: HashMap<String, serde_json::Value> = HashMap::new();
505 if let Some(ref_name) = ref_name {
506 extra_data.insert("image_ref_name".to_string(), json!(ref_name));
507 }
508 extra_data.insert("digest_source".to_string(), json!("config"));
510
511 Some(build_package(
512 Some(name),
513 digest,
514 qualifiers,
515 extra_data,
516 DatasourceId::OciImageManifest,
517 ))
518}
519
520fn config_to_digest(config: &str) -> Option<String> {
522 if config.starts_with("sha256:") {
523 return Some(config.to_string());
524 }
525
526 let file_name = Path::new(config)
527 .file_name()
528 .and_then(|name| name.to_str())?;
529 let hex = file_name.strip_suffix(".json").unwrap_or(file_name);
530 if hex.is_empty() {
531 return None;
532 }
533
534 Some(format!("sha256:{hex}"))
535}
536
537struct ImageRef {
539 name: Option<String>,
541 tag: Option<String>,
543 repository_url: Option<String>,
547}
548
549fn parse_image_ref(ref_name: Option<&str>) -> ImageRef {
556 let Some(ref_name) = ref_name else {
557 return ImageRef {
558 name: None,
559 tag: None,
560 repository_url: None,
561 };
562 };
563
564 let without_digest = ref_name.split_once('@').map_or(ref_name, |(name, _)| name);
566
567 let last_segment_start = without_digest.rfind('/').map_or(0, |index| index + 1);
570 let (repository, tag) = match without_digest[last_segment_start..].split_once(':') {
571 Some((repo_segment, tag)) => (
572 &without_digest[..last_segment_start + repo_segment.len()],
573 Some(tag.to_string()),
574 ),
575 None => (without_digest, None),
576 };
577
578 let name = repository
579 .rsplit('/')
580 .next()
581 .map(|value| value.trim().to_ascii_lowercase())
582 .filter(|value| !value.is_empty());
583
584 ImageRef {
585 name,
586 tag: tag.filter(|value| !value.trim().is_empty()),
587 repository_url: repository_url_from_repository(repository),
588 }
589}
590
591fn repository_url_from_repository(repository: &str) -> Option<String> {
600 let repository = repository.trim();
601 if repository.is_empty() {
602 return None;
603 }
604
605 let first_segment = repository.split('/').next().unwrap_or(repository);
606 let looks_like_registry =
607 first_segment == "localhost" || first_segment.contains('.') || first_segment.contains(':');
608
609 looks_like_registry.then(|| repository.to_string())
610}
611
612fn build_package(
613 name: Option<String>,
614 digest: String,
615 qualifiers: HashMap<String, String>,
616 extra_data: HashMap<String, serde_json::Value>,
617 datasource_id: DatasourceId,
618) -> PackageData {
619 let name = name.map(truncate_field);
620 let version = truncate_field(digest);
621
622 let purl = name
625 .as_deref()
626 .and_then(|name| build_oci_purl(name, &version, &qualifiers));
627
628 PackageData {
629 package_type: Some(PACKAGE_TYPE),
630 datasource_id: Some(datasource_id),
631 name,
632 version: Some(version),
633 qualifiers: (!qualifiers.is_empty()).then_some(qualifiers),
634 extra_data: (!extra_data.is_empty()).then_some(extra_data),
635 purl,
636 ..Default::default()
637 }
638}
639
640fn build_oci_purl(
644 name: &str,
645 version: &str,
646 qualifiers: &HashMap<String, String>,
647) -> Option<String> {
648 use packageurl::PackageUrl;
649
650 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
651 purl.with_version(version).ok()?;
652
653 let mut keys: Vec<&String> = qualifiers.keys().collect();
655 keys.sort();
656 for key in keys {
657 if let Some(value) = qualifiers.get(key) {
658 let _ = purl.add_qualifier(key.as_str(), value.as_str());
659 }
660 }
661
662 Some(purl.to_string())
663}