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::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
35
36use super::PackageParser;
37use super::metadata::ParserMetadata;
38
39const PACKAGE_TYPE: PackageType = PackageType::Oci;
40
41const OCI_INDEX_MEDIA_TYPE: &str = "application/vnd.oci.image.index.v1+json";
43const DOCKER_MANIFEST_LIST_MEDIA_TYPE: &str =
45 "application/vnd.docker.distribution.manifest.list.v2+json";
46
47const CONTAINERD_IMAGE_NAME_ANNOTATION: &str = "io.containerd.image.name";
51const REF_NAME_ANNOTATION: &str = "org.opencontainers.image.ref.name";
52
53const MAX_INDEX_DEPTH: usize = 8;
57
58#[derive(Debug, Deserialize)]
60struct OciImageIndex {
61 #[serde(default)]
62 #[serde(rename = "mediaType")]
63 media_type: Option<String>,
64 #[serde(default)]
65 #[serde(rename = "schemaVersion")]
66 schema_version: Option<u64>,
67 #[serde(default)]
68 manifests: Vec<OciDescriptor>,
69}
70
71#[derive(Debug, Deserialize)]
73struct OciDescriptor {
74 #[serde(default)]
75 #[serde(rename = "mediaType")]
76 media_type: Option<String>,
77 #[serde(default)]
78 digest: Option<String>,
79 #[serde(default)]
80 platform: Option<OciPlatform>,
81 #[serde(default)]
82 annotations: Option<HashMap<String, String>>,
83}
84
85#[derive(Debug, Deserialize)]
86struct OciPlatform {
87 #[serde(default)]
88 architecture: Option<String>,
89 #[serde(default)]
90 os: Option<String>,
91}
92
93#[derive(Debug, Deserialize)]
96struct OciImageManifest {
97 #[serde(default)]
98 config: Option<OciConfigDescriptor>,
99}
100
101#[derive(Debug, Deserialize)]
102struct OciConfigDescriptor {
103 #[serde(default)]
104 digest: Option<String>,
105}
106
107#[derive(Debug, Deserialize)]
109struct DockerSaveManifestEntry {
110 #[serde(default)]
111 #[serde(rename = "Config")]
112 config: Option<String>,
113 #[serde(default)]
114 #[serde(rename = "RepoTags")]
115 repo_tags: Option<Vec<String>>,
116}
117
118pub struct OciImageLayoutParser;
119
120impl PackageParser for OciImageLayoutParser {
121 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
122
123 fn metadata() -> Vec<ParserMetadata> {
124 vec![ParserMetadata {
125 description: "OCI image layout index.json and docker save manifest.json",
126 file_patterns: &["**/index.json", "**/manifest.json"],
127 package_type: "oci",
128 primary_language: "",
129 documentation_url: Some(
130 "https://github.com/opencontainers/image-spec/blob/main/image-layout.md",
131 ),
132 }]
133 }
134
135 fn is_match(path: &Path) -> bool {
136 path.file_name()
137 .and_then(|name| name.to_str())
138 .is_some_and(|name| name == "index.json" || name == "manifest.json")
139 }
140
141 fn extract_packages(path: &Path) -> Vec<PackageData> {
142 let content = match read_file_to_string(path, None) {
143 Ok(content) => content,
144 Err(error) => {
145 warn!("Failed to read OCI manifest {:?}: {}", path, error);
146 return Vec::new();
147 }
148 };
149
150 let layout_root = path.parent().map(Path::to_path_buf);
154 parse_oci_content(&content, layout_root.as_deref())
155 }
156}
157
158pub(crate) fn parse_oci_content(content: &str, layout_root: Option<&Path>) -> Vec<PackageData> {
169 let trimmed = content.trim_start();
171 if trimmed.starts_with('[') {
172 return parse_docker_save_manifest(content);
173 }
174
175 parse_oci_image_index(content, layout_root)
176}
177
178fn parse_oci_image_index(content: &str, layout_root: Option<&Path>) -> Vec<PackageData> {
179 let index: OciImageIndex = match serde_json::from_str(content) {
180 Ok(index) => index,
181 Err(error) => {
182 warn!("Failed to parse OCI image index JSON: {}", error);
183 return Vec::new();
184 }
185 };
186
187 if !is_oci_image_index(&index) {
188 return Vec::new();
189 }
190
191 let mut packages = Vec::new();
192 collect_index_packages(index, layout_root, &Identity::default(), 0, &mut packages);
193 packages
194}
195
196#[derive(Default, Clone)]
202struct Identity {
203 name: Option<String>,
204 tag: Option<String>,
205 repository_url: Option<String>,
206 image_ref: Option<String>,
207}
208
209fn collect_index_packages(
214 index: OciImageIndex,
215 layout_root: Option<&Path>,
216 inherited: &Identity,
217 depth: usize,
218 packages: &mut Vec<PackageData>,
219) {
220 for descriptor in index.manifests.into_iter().take(MAX_ITERATION_COUNT) {
221 if packages.len() >= MAX_ITERATION_COUNT {
222 break;
223 }
224
225 if descriptor_is_attestation(&descriptor) {
229 continue;
230 }
231
232 let resolved = resolve_identity(&descriptor, inherited);
234
235 if depth < MAX_INDEX_DEPTH
240 && descriptor_is_index(&descriptor)
241 && let Some(nested) = read_index_blob(layout_root, descriptor.digest.as_deref())
242 {
243 collect_index_packages(nested, layout_root, &resolved, depth + 1, packages);
244 continue;
245 }
246
247 if let Some(package) = package_from_descriptor(&descriptor, &resolved, layout_root) {
248 packages.push(package);
249 }
250 }
251}
252
253fn descriptor_is_index(descriptor: &OciDescriptor) -> bool {
254 matches!(
255 descriptor.media_type.as_deref(),
256 Some(OCI_INDEX_MEDIA_TYPE) | Some(DOCKER_MANIFEST_LIST_MEDIA_TYPE)
257 )
258}
259
260const ATTESTATION_REF_TYPE_ANNOTATION: &str = "vnd.docker.reference.type";
261
262fn descriptor_is_attestation(descriptor: &OciDescriptor) -> bool {
266 descriptor
267 .annotations
268 .as_ref()
269 .is_some_and(|annotations| annotations.contains_key(ATTESTATION_REF_TYPE_ANNOTATION))
270}
271
272fn resolve_identity(descriptor: &OciDescriptor, inherited: &Identity) -> Identity {
275 let annotations = descriptor.annotations.as_ref();
276 let containerd_name = annotations
277 .and_then(|annotations| annotations.get(CONTAINERD_IMAGE_NAME_ANNOTATION))
278 .map(|value| value.trim())
279 .filter(|value| !value.is_empty());
280 let ref_name = annotations
281 .and_then(|annotations| annotations.get(REF_NAME_ANNOTATION))
282 .map(|value| value.trim())
283 .filter(|value| !value.is_empty());
284
285 let identity_ref = containerd_name.or(ref_name);
288 if identity_ref.is_none() {
289 return inherited.clone();
291 }
292
293 let parsed = parse_image_ref(identity_ref);
294 let mut tag = parsed.tag;
295 if tag.is_none()
298 && containerd_name.is_some()
299 && let Some(bare) = ref_name
300 && !bare.contains('/')
301 && !bare.contains(':')
302 {
303 tag = Some(bare.to_string());
304 }
305
306 Identity {
307 name: parsed.name.or_else(|| inherited.name.clone()),
308 tag: tag.or_else(|| inherited.tag.clone()),
309 repository_url: parsed
310 .repository_url
311 .or_else(|| inherited.repository_url.clone()),
312 image_ref: identity_ref
313 .map(str::to_string)
314 .or_else(|| inherited.image_ref.clone()),
315 }
316}
317
318fn is_oci_image_index(index: &OciImageIndex) -> bool {
327 let media_type = index.media_type.as_deref();
328 if media_type == Some(OCI_INDEX_MEDIA_TYPE)
329 || media_type == Some(DOCKER_MANIFEST_LIST_MEDIA_TYPE)
330 {
331 return true;
332 }
333
334 index.schema_version == Some(2)
335 && index.manifests.iter().any(|descriptor| {
336 descriptor
337 .digest
338 .as_deref()
339 .is_some_and(|d| !d.trim().is_empty())
340 })
341}
342
343fn package_from_descriptor(
344 descriptor: &OciDescriptor,
345 identity: &Identity,
346 layout_root: Option<&Path>,
347) -> Option<PackageData> {
348 let descriptor_digest = descriptor
349 .digest
350 .as_deref()
351 .map(str::trim)
352 .filter(|value| !value.is_empty())?;
353
354 let name = identity.name.clone()?;
355
356 let (version, digest_source) = match read_config_digest(layout_root, descriptor_digest) {
360 Some(config_digest) => (config_digest, "config"),
361 None => (descriptor_digest.to_string(), "descriptor"),
362 };
363
364 let mut qualifiers: HashMap<String, String> = HashMap::new();
365 if let Some(tag) = identity.tag.clone() {
366 qualifiers.insert("tag".to_string(), truncate_field(tag));
367 }
368 if let Some(platform) = descriptor.platform.as_ref()
369 && let Some(arch) = platform
370 .architecture
371 .as_deref()
372 .map(str::trim)
373 .filter(|value| !value.is_empty())
374 {
375 qualifiers.insert("arch".to_string(), arch.to_string());
376 }
377 if let Some(repository_url) = identity.repository_url.as_deref() {
378 qualifiers.insert(
379 "repository_url".to_string(),
380 truncate_field(repository_url.to_string()),
381 );
382 }
383
384 let mut extra_data: HashMap<String, serde_json::Value> = HashMap::new();
385 if let Some(identity_ref) = identity.image_ref.as_deref() {
386 extra_data.insert("image_ref_name".to_string(), json!(identity_ref));
387 }
388 if let Some(platform) = descriptor.platform.as_ref()
389 && let Some(os) = platform
390 .os
391 .as_deref()
392 .map(str::trim)
393 .filter(|value| !value.is_empty())
394 {
395 extra_data.insert("os".to_string(), json!(os));
396 }
397 extra_data.insert("digest_source".to_string(), json!(digest_source));
398
399 Some(build_package(
400 Some(name),
401 version,
402 qualifiers,
403 extra_data,
404 DatasourceId::OciImageIndex,
405 ))
406}
407
408fn read_config_digest(layout_root: Option<&Path>, digest: &str) -> Option<String> {
413 let content = read_blob(layout_root, digest)?;
414 let manifest: OciImageManifest = serde_json::from_str(&content).ok()?;
415 let config_digest = manifest
416 .config?
417 .digest
418 .map(|value| value.trim().to_string())
419 .filter(|value| !value.is_empty())?;
420 Some(config_digest)
421}
422
423fn read_index_blob(layout_root: Option<&Path>, digest: Option<&str>) -> Option<OciImageIndex> {
427 let content = read_blob(layout_root, digest?)?;
428 let index: OciImageIndex = serde_json::from_str(&content).ok()?;
429 is_oci_image_index(&index).then_some(index)
430}
431
432fn read_blob(layout_root: Option<&Path>, digest: &str) -> Option<String> {
437 let layout_root = layout_root?;
438 let hex = digest.strip_prefix("sha256:")?;
439 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
440 return None;
441 }
442
443 let blob_path: PathBuf = layout_root.join("blobs").join("sha256").join(hex);
444 read_file_to_string(&blob_path, None).ok()
445}
446
447fn parse_docker_save_manifest(content: &str) -> Vec<PackageData> {
448 let entries: Vec<DockerSaveManifestEntry> = match serde_json::from_str(content) {
449 Ok(entries) => entries,
450 Err(error) => {
451 warn!("Failed to parse docker save manifest JSON: {}", error);
452 return Vec::new();
453 }
454 };
455
456 entries
457 .into_iter()
458 .take(MAX_ITERATION_COUNT)
459 .filter_map(package_from_docker_save_entry)
460 .collect()
461}
462
463fn package_from_docker_save_entry(entry: DockerSaveManifestEntry) -> Option<PackageData> {
464 let config = entry
467 .config
468 .as_deref()
469 .map(str::trim)
470 .filter(|value| !value.is_empty())?;
471
472 let digest = config_to_digest(config)?;
473
474 let ref_name = entry
475 .repo_tags
476 .as_ref()
477 .and_then(|tags| tags.iter().find(|tag| !tag.trim().is_empty()))
478 .map(|tag| tag.trim());
479
480 let parsed = parse_image_ref(ref_name);
481 let name = parsed.name.clone()?;
482
483 let mut qualifiers: HashMap<String, String> = HashMap::new();
484 if let Some(tag) = parsed.tag.clone() {
485 qualifiers.insert("tag".to_string(), truncate_field(tag));
486 }
487 if let Some(repository_url) = parsed.repository_url.as_deref() {
488 qualifiers.insert(
489 "repository_url".to_string(),
490 truncate_field(repository_url.to_string()),
491 );
492 }
493
494 let mut extra_data: HashMap<String, serde_json::Value> = HashMap::new();
495 if let Some(ref_name) = ref_name {
496 extra_data.insert("image_ref_name".to_string(), json!(ref_name));
497 }
498 extra_data.insert("digest_source".to_string(), json!("config"));
500
501 Some(build_package(
502 Some(name),
503 digest,
504 qualifiers,
505 extra_data,
506 DatasourceId::OciImageManifest,
507 ))
508}
509
510fn config_to_digest(config: &str) -> Option<String> {
512 if config.starts_with("sha256:") {
513 return Some(config.to_string());
514 }
515
516 let file_name = Path::new(config)
517 .file_name()
518 .and_then(|name| name.to_str())?;
519 let hex = file_name.strip_suffix(".json").unwrap_or(file_name);
520 if hex.is_empty() {
521 return None;
522 }
523
524 Some(format!("sha256:{hex}"))
525}
526
527struct ImageRef {
529 name: Option<String>,
531 tag: Option<String>,
533 repository_url: Option<String>,
537}
538
539fn parse_image_ref(ref_name: Option<&str>) -> ImageRef {
546 let Some(ref_name) = ref_name else {
547 return ImageRef {
548 name: None,
549 tag: None,
550 repository_url: None,
551 };
552 };
553
554 let without_digest = ref_name.split_once('@').map_or(ref_name, |(name, _)| name);
556
557 let last_segment_start = without_digest.rfind('/').map_or(0, |index| index + 1);
560 let (repository, tag) = match without_digest[last_segment_start..].split_once(':') {
561 Some((repo_segment, tag)) => (
562 &without_digest[..last_segment_start + repo_segment.len()],
563 Some(tag.to_string()),
564 ),
565 None => (without_digest, None),
566 };
567
568 let name = repository
569 .rsplit('/')
570 .next()
571 .map(|value| value.trim().to_ascii_lowercase())
572 .filter(|value| !value.is_empty());
573
574 ImageRef {
575 name,
576 tag: tag.filter(|value| !value.trim().is_empty()),
577 repository_url: repository_url_from_repository(repository),
578 }
579}
580
581fn repository_url_from_repository(repository: &str) -> Option<String> {
590 let repository = repository.trim();
591 if repository.is_empty() {
592 return None;
593 }
594
595 let first_segment = repository.split('/').next().unwrap_or(repository);
596 let looks_like_registry =
597 first_segment == "localhost" || first_segment.contains('.') || first_segment.contains(':');
598
599 looks_like_registry.then(|| repository.to_string())
600}
601
602fn build_package(
603 name: Option<String>,
604 digest: String,
605 qualifiers: HashMap<String, String>,
606 extra_data: HashMap<String, serde_json::Value>,
607 datasource_id: DatasourceId,
608) -> PackageData {
609 let name = name.map(truncate_field);
610 let version = truncate_field(digest);
611
612 let purl = name
615 .as_deref()
616 .and_then(|name| build_oci_purl(name, &version, &qualifiers));
617
618 PackageData {
619 package_type: Some(PACKAGE_TYPE),
620 datasource_id: Some(datasource_id),
621 name,
622 version: Some(version),
623 qualifiers: (!qualifiers.is_empty()).then_some(qualifiers),
624 extra_data: (!extra_data.is_empty()).then_some(extra_data),
625 purl,
626 ..Default::default()
627 }
628}
629
630fn build_oci_purl(
634 name: &str,
635 version: &str,
636 qualifiers: &HashMap<String, String>,
637) -> Option<String> {
638 use packageurl::PackageUrl;
639
640 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
641 purl.with_version(version).ok()?;
642
643 let mut keys: Vec<&String> = qualifiers.keys().collect();
645 keys.sort();
646 for key in keys {
647 if let Some(value) = qualifiers.get(key) {
648 let _ = purl.add_qualifier(key.as_str(), value.as_str());
649 }
650 }
651
652 Some(purl.to_string())
653}