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 warn!("Failed to parse OCI image index JSON: {}", error);
185 return Vec::new();
186 }
187 };
188
189 if !is_oci_image_index(&index) {
190 return Vec::new();
191 }
192
193 let mut packages = Vec::new();
194 collect_index_packages(index, layout_root, &Identity::default(), 0, &mut packages);
195 packages
196}
197
198#[derive(Default, Clone)]
204struct Identity {
205 name: Option<String>,
206 tag: Option<String>,
207 repository_url: Option<String>,
208 image_ref: Option<String>,
209}
210
211fn collect_index_packages(
216 index: OciImageIndex,
217 layout_root: Option<&Path>,
218 inherited: &Identity,
219 depth: usize,
220 packages: &mut Vec<PackageData>,
221) {
222 let manifest_limit = capped_iteration_limit(index.manifests.len(), "OCI image index manifests");
223 for descriptor in index.manifests.into_iter().take(manifest_limit) {
224 if packages.len() >= MAX_ITERATION_COUNT {
225 break;
226 }
227
228 if descriptor_is_attestation(&descriptor) {
232 continue;
233 }
234
235 let resolved = resolve_identity(&descriptor, inherited);
237
238 if depth < MAX_INDEX_DEPTH
243 && descriptor_is_index(&descriptor)
244 && let Some(nested) = read_index_blob(layout_root, descriptor.digest.as_deref())
245 {
246 collect_index_packages(nested, layout_root, &resolved, depth + 1, packages);
247 continue;
248 }
249
250 if let Some(package) = package_from_descriptor(&descriptor, &resolved, layout_root) {
251 packages.push(package);
252 }
253 }
254}
255
256fn descriptor_is_index(descriptor: &OciDescriptor) -> bool {
257 matches!(
258 descriptor.media_type.as_deref(),
259 Some(OCI_INDEX_MEDIA_TYPE) | Some(DOCKER_MANIFEST_LIST_MEDIA_TYPE)
260 )
261}
262
263const ATTESTATION_REF_TYPE_ANNOTATION: &str = "vnd.docker.reference.type";
264
265fn descriptor_is_attestation(descriptor: &OciDescriptor) -> bool {
269 descriptor
270 .annotations
271 .as_ref()
272 .is_some_and(|annotations| annotations.contains_key(ATTESTATION_REF_TYPE_ANNOTATION))
273}
274
275fn resolve_identity(descriptor: &OciDescriptor, inherited: &Identity) -> Identity {
278 let annotations = descriptor.annotations.as_ref();
279 let containerd_name = annotations
280 .and_then(|annotations| annotations.get(CONTAINERD_IMAGE_NAME_ANNOTATION))
281 .map(|value| value.trim())
282 .filter(|value| !value.is_empty());
283 let ref_name = annotations
284 .and_then(|annotations| annotations.get(REF_NAME_ANNOTATION))
285 .map(|value| value.trim())
286 .filter(|value| !value.is_empty());
287
288 let identity_ref = containerd_name.or(ref_name);
291 if identity_ref.is_none() {
292 return inherited.clone();
294 }
295
296 let parsed = parse_image_ref(identity_ref);
297 let mut tag = parsed.tag;
298 if tag.is_none()
301 && containerd_name.is_some()
302 && let Some(bare) = ref_name
303 && !bare.contains('/')
304 && !bare.contains(':')
305 {
306 tag = Some(bare.to_string());
307 }
308
309 Identity {
310 name: parsed.name.or_else(|| inherited.name.clone()),
311 tag: tag.or_else(|| inherited.tag.clone()),
312 repository_url: parsed
313 .repository_url
314 .or_else(|| inherited.repository_url.clone()),
315 image_ref: identity_ref
316 .map(str::to_string)
317 .or_else(|| inherited.image_ref.clone()),
318 }
319}
320
321fn is_oci_image_index(index: &OciImageIndex) -> bool {
330 let media_type = index.media_type.as_deref();
331 if media_type == Some(OCI_INDEX_MEDIA_TYPE)
332 || media_type == Some(DOCKER_MANIFEST_LIST_MEDIA_TYPE)
333 {
334 return true;
335 }
336
337 index.schema_version == Some(2)
338 && index.manifests.iter().any(|descriptor| {
339 descriptor
340 .digest
341 .as_deref()
342 .is_some_and(|d| !d.trim().is_empty())
343 })
344}
345
346fn package_from_descriptor(
347 descriptor: &OciDescriptor,
348 identity: &Identity,
349 layout_root: Option<&Path>,
350) -> Option<PackageData> {
351 let descriptor_digest = descriptor
352 .digest
353 .as_deref()
354 .map(str::trim)
355 .filter(|value| !value.is_empty())?;
356
357 let name = identity.name.clone()?;
358
359 let (version, digest_source) = match read_config_digest(layout_root, descriptor_digest) {
363 Some(config_digest) => (config_digest, "config"),
364 None => (descriptor_digest.to_string(), "descriptor"),
365 };
366
367 let mut qualifiers: HashMap<String, String> = HashMap::new();
368 if let Some(tag) = identity.tag.clone() {
369 qualifiers.insert("tag".to_string(), truncate_field(tag));
370 }
371 if let Some(platform) = descriptor.platform.as_ref()
372 && let Some(arch) = platform
373 .architecture
374 .as_deref()
375 .map(str::trim)
376 .filter(|value| !value.is_empty())
377 {
378 qualifiers.insert("arch".to_string(), arch.to_string());
379 }
380 if let Some(repository_url) = identity.repository_url.as_deref() {
381 qualifiers.insert(
382 "repository_url".to_string(),
383 truncate_field(repository_url.to_string()),
384 );
385 }
386
387 let mut extra_data: HashMap<String, serde_json::Value> = HashMap::new();
388 if let Some(identity_ref) = identity.image_ref.as_deref() {
389 extra_data.insert("image_ref_name".to_string(), json!(identity_ref));
390 }
391 if let Some(platform) = descriptor.platform.as_ref()
392 && let Some(os) = platform
393 .os
394 .as_deref()
395 .map(str::trim)
396 .filter(|value| !value.is_empty())
397 {
398 extra_data.insert("os".to_string(), json!(os));
399 }
400 extra_data.insert("digest_source".to_string(), json!(digest_source));
401
402 Some(build_package(
403 Some(name),
404 version,
405 qualifiers,
406 extra_data,
407 DatasourceId::OciImageIndex,
408 ))
409}
410
411fn read_config_digest(layout_root: Option<&Path>, digest: &str) -> Option<String> {
416 let content = read_blob(layout_root, digest)?;
417 let manifest: OciImageManifest = serde_json::from_str(&content).ok()?;
418 let config_digest = manifest
419 .config?
420 .digest
421 .map(|value| value.trim().to_string())
422 .filter(|value| !value.is_empty())?;
423 Some(config_digest)
424}
425
426fn read_index_blob(layout_root: Option<&Path>, digest: Option<&str>) -> Option<OciImageIndex> {
430 let content = read_blob(layout_root, digest?)?;
431 let index: OciImageIndex = serde_json::from_str(&content).ok()?;
432 is_oci_image_index(&index).then_some(index)
433}
434
435fn read_blob(layout_root: Option<&Path>, digest: &str) -> Option<String> {
440 let layout_root = layout_root?;
441 let hex = digest.strip_prefix("sha256:")?;
442 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
443 return None;
444 }
445
446 let blob_path: PathBuf = layout_root.join("blobs").join("sha256").join(hex);
447 read_file_to_string(&blob_path, None).ok()
448}
449
450fn parse_docker_save_manifest(content: &str) -> Vec<PackageData> {
451 let entries: Vec<DockerSaveManifestEntry> = match serde_json::from_str(content) {
452 Ok(entries) => entries,
453 Err(error) => {
454 warn!("Failed to parse docker save manifest JSON: {}", error);
455 return Vec::new();
456 }
457 };
458
459 let entry_limit = capped_iteration_limit(entries.len(), "docker save manifest entries");
460 entries
461 .into_iter()
462 .take(entry_limit)
463 .filter_map(package_from_docker_save_entry)
464 .collect()
465}
466
467fn package_from_docker_save_entry(entry: DockerSaveManifestEntry) -> Option<PackageData> {
468 let config = entry
471 .config
472 .as_deref()
473 .map(str::trim)
474 .filter(|value| !value.is_empty())?;
475
476 let digest = config_to_digest(config)?;
477
478 let ref_name = entry
479 .repo_tags
480 .as_ref()
481 .and_then(|tags| tags.iter().find(|tag| !tag.trim().is_empty()))
482 .map(|tag| tag.trim());
483
484 let parsed = parse_image_ref(ref_name);
485 let name = parsed.name.clone()?;
486
487 let mut qualifiers: HashMap<String, String> = HashMap::new();
488 if let Some(tag) = parsed.tag.clone() {
489 qualifiers.insert("tag".to_string(), truncate_field(tag));
490 }
491 if let Some(repository_url) = parsed.repository_url.as_deref() {
492 qualifiers.insert(
493 "repository_url".to_string(),
494 truncate_field(repository_url.to_string()),
495 );
496 }
497
498 let mut extra_data: HashMap<String, serde_json::Value> = HashMap::new();
499 if let Some(ref_name) = ref_name {
500 extra_data.insert("image_ref_name".to_string(), json!(ref_name));
501 }
502 extra_data.insert("digest_source".to_string(), json!("config"));
504
505 Some(build_package(
506 Some(name),
507 digest,
508 qualifiers,
509 extra_data,
510 DatasourceId::OciImageManifest,
511 ))
512}
513
514fn config_to_digest(config: &str) -> Option<String> {
516 if config.starts_with("sha256:") {
517 return Some(config.to_string());
518 }
519
520 let file_name = Path::new(config)
521 .file_name()
522 .and_then(|name| name.to_str())?;
523 let hex = file_name.strip_suffix(".json").unwrap_or(file_name);
524 if hex.is_empty() {
525 return None;
526 }
527
528 Some(format!("sha256:{hex}"))
529}
530
531struct ImageRef {
533 name: Option<String>,
535 tag: Option<String>,
537 repository_url: Option<String>,
541}
542
543fn parse_image_ref(ref_name: Option<&str>) -> ImageRef {
550 let Some(ref_name) = ref_name else {
551 return ImageRef {
552 name: None,
553 tag: None,
554 repository_url: None,
555 };
556 };
557
558 let without_digest = ref_name.split_once('@').map_or(ref_name, |(name, _)| name);
560
561 let last_segment_start = without_digest.rfind('/').map_or(0, |index| index + 1);
564 let (repository, tag) = match without_digest[last_segment_start..].split_once(':') {
565 Some((repo_segment, tag)) => (
566 &without_digest[..last_segment_start + repo_segment.len()],
567 Some(tag.to_string()),
568 ),
569 None => (without_digest, None),
570 };
571
572 let name = repository
573 .rsplit('/')
574 .next()
575 .map(|value| value.trim().to_ascii_lowercase())
576 .filter(|value| !value.is_empty());
577
578 ImageRef {
579 name,
580 tag: tag.filter(|value| !value.trim().is_empty()),
581 repository_url: repository_url_from_repository(repository),
582 }
583}
584
585fn repository_url_from_repository(repository: &str) -> Option<String> {
594 let repository = repository.trim();
595 if repository.is_empty() {
596 return None;
597 }
598
599 let first_segment = repository.split('/').next().unwrap_or(repository);
600 let looks_like_registry =
601 first_segment == "localhost" || first_segment.contains('.') || first_segment.contains(':');
602
603 looks_like_registry.then(|| repository.to_string())
604}
605
606fn build_package(
607 name: Option<String>,
608 digest: String,
609 qualifiers: HashMap<String, String>,
610 extra_data: HashMap<String, serde_json::Value>,
611 datasource_id: DatasourceId,
612) -> PackageData {
613 let name = name.map(truncate_field);
614 let version = truncate_field(digest);
615
616 let purl = name
619 .as_deref()
620 .and_then(|name| build_oci_purl(name, &version, &qualifiers));
621
622 PackageData {
623 package_type: Some(PACKAGE_TYPE),
624 datasource_id: Some(datasource_id),
625 name,
626 version: Some(version),
627 qualifiers: (!qualifiers.is_empty()).then_some(qualifiers),
628 extra_data: (!extra_data.is_empty()).then_some(extra_data),
629 purl,
630 ..Default::default()
631 }
632}
633
634fn build_oci_purl(
638 name: &str,
639 version: &str,
640 qualifiers: &HashMap<String, String>,
641) -> Option<String> {
642 use packageurl::PackageUrl;
643
644 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
645 purl.with_version(version).ok()?;
646
647 let mut keys: Vec<&String> = qualifiers.keys().collect();
649 keys.sort();
650 for key in keys {
651 if let Some(value) = qualifiers.get(key) {
652 let _ = purl.add_qualifier(key.as_str(), value.as_str());
653 }
654 }
655
656 Some(purl.to_string())
657}