Skip to main content

provenant/parsers/
oci.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for OCI image layout `index.json` files and `docker save` tarball
5//! `manifest.json` files.
6//!
7//! Emits `pkg:oci/<name>@sha256:<digest>` PURLs for each resolved image, where
8//! the digest is the image **config** digest. See the purl-spec `oci` type:
9//! <https://github.com/package-url/purl-spec/blob/main/types/oci-definition.json>
10//!
11//! Resolution model: for an OCI image layout (`index.json`) the parser follows
12//! each manifest descriptor statically into the referenced
13//! `blobs/sha256/<hex>` blob to recover the per-platform image manifest, then
14//! uses that manifest's `config.digest` as the PURL version. Nested image
15//! indexes (manifest lists referenced from a top-level index) are followed up
16//! to a bounded depth. For `docker save` `manifest.json`, the `Config` field
17//! already points at the image config blob, so its digest is used directly.
18//!
19//! Inherent limitation: when the referenced blob is not present on disk (for
20//! example a bare `index.json` lifted out of its layout, or a sparse/lazy-pull
21//! layout), the per-platform config digest cannot be recovered statically. In
22//! that case the parser falls back to the manifest *descriptor* digest as the
23//! version and records `digest_source = "descriptor"` in `extra_data`. This is
24//! a property of the input (the blob is simply absent), not a deferred feature.
25
26use 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
43/// OCI image index media type.
44const OCI_INDEX_MEDIA_TYPE: &str = "application/vnd.oci.image.index.v1+json";
45/// Docker distribution manifest list media type (Docker schema 2).
46const DOCKER_MANIFEST_LIST_MEDIA_TYPE: &str =
47    "application/vnd.docker.distribution.manifest.list.v2+json";
48
49/// containerd records the full image reference (registry/name:tag) here; the
50/// standard OCI `ref.name` annotation often holds only the tag, so we prefer
51/// this when present.
52const CONTAINERD_IMAGE_NAME_ANNOTATION: &str = "io.containerd.image.name";
53const REF_NAME_ANNOTATION: &str = "org.opencontainers.image.ref.name";
54
55/// Upper bound on how deeply nested image indexes are followed. Real layouts
56/// nest at most index -> manifest list -> manifest; this keeps a hostile or
57/// cyclic layout bounded.
58const MAX_INDEX_DEPTH: usize = 8;
59
60/// Top-level OCI image index (`index.json`).
61#[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/// A descriptor entry in an OCI image index `manifests` array.
74#[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/// An OCI / Docker schema-2 image manifest blob. Only the config descriptor is
96/// needed to recover the image config digest.
97#[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/// A single entry in a `docker save` `manifest.json` array.
110#[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        // The layout root is the directory containing index.json; blobs live in
153        // `<root>/blobs/sha256/<hex>`. `manifest.json` (docker save) is
154        // self-contained and does not need the root.
155        let layout_root = path.parent().map(Path::to_path_buf);
156        parse_oci_content(&content, layout_root.as_deref())
157    }
158}
159
160/// Parses the JSON content of an OCI `index.json` or Docker `manifest.json`.
161///
162/// `layout_root` is the directory that contains the OCI layout (the parent of
163/// `index.json`); it is used to follow descriptors into `blobs/sha256/<hex>`.
164/// When `None`, descriptor blobs cannot be resolved and the parser falls back
165/// to descriptor digests.
166///
167/// Returns an empty vector when the content is valid JSON but not an OCI image
168/// index or `docker save` manifest, so the parser does not claim unrelated
169/// `index.json` / `manifest.json` files.
170pub(crate) fn parse_oci_content(content: &str, layout_root: Option<&Path>) -> Vec<PackageData> {
171    // `docker save` manifest.json is a JSON array; OCI index.json is an object.
172    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            // `index.json` / `manifest.json` are common filenames the parser
185            // matches broadly; content that is not OCI/docker JSON (e.g. a Chrome
186            // extension or web-app manifest, often JSONC) simply is not ours.
187            // Decline quietly rather than emitting a scan error.
188            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/// Image identity (name / tag / repository_url / full ref) that a parent index
203/// descriptor contributes to its children. In a buildx-style layout the
204/// `io.containerd.image.name` annotation lives only on the top-level
205/// descriptor that points at the nested index; the leaf per-platform manifests
206/// carry no name annotation, so the identity must be inherited downward.
207#[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
215/// Walks an image index, following descriptors into blob manifests to resolve
216/// per-platform config digests, recursing through nested image indexes up to
217/// [`MAX_INDEX_DEPTH`]. `inherited` carries identity contributed by ancestor
218/// descriptors.
219fn 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        // buildx emits attestation manifests (SBOM/provenance) as extra
233        // descriptors with platform `unknown/unknown`; they are not container
234        // images and would otherwise yield bogus `arch=unknown` packages.
235        if descriptor_is_attestation(&descriptor) {
236            continue;
237        }
238
239        // A descriptor's own annotations override the inherited identity.
240        let resolved = resolve_identity(&descriptor, inherited);
241
242        // A descriptor may itself reference a nested image index / manifest
243        // list. Follow it (bounded) so multi-arch images expressed as nested
244        // indexes still resolve per-platform, passing the resolved identity
245        // down to the leaves.
246        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
269/// A buildx attestation manifest carries `vnd.docker.reference.type` and an
270/// `unknown/unknown` platform; it describes provenance/SBOM for a sibling
271/// image, not an image itself.
272fn 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
279/// Resolves the identity for a descriptor: its own name annotations win, and
280/// any field it does not provide is inherited from the ancestor identity.
281fn 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    // Prefer the containerd full image reference for identity; the standard OCI
293    // ref.name often carries only the tag.
294    let identity_ref = containerd_name.or(ref_name);
295    if identity_ref.is_none() {
296        // No own identity: inherit wholesale from the ancestor descriptor.
297        return inherited.clone();
298    }
299
300    let parsed = parse_image_ref(identity_ref);
301    let mut tag = parsed.tag;
302    // If identity came from containerd (full ref) but lacked a tag, fall back to
303    // the bare ref.name annotation as the tag.
304    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
325/// An OCI image index is identified by its media type, or by a schema version
326/// of 2 paired with at least one manifest descriptor.
327///
328/// `index.json` is a very common filename, so the schema-version fallback (used
329/// when no recognized `mediaType` is present) additionally requires that at
330/// least one descriptor carries a non-empty `digest`. Every real OCI/Docker
331/// descriptor has one, so this rejects unrelated `{ "schemaVersion": 2, ... }`
332/// JSON that happens to expose a `manifests` array without descriptor digests.
333fn 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    // Follow the descriptor into its manifest blob to recover the config
364    // digest. When the blob is absent (inherent limitation), fall back to the
365    // descriptor digest.
366    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
415/// Reads `blobs/sha256/<hex>` for `digest` and parses it as an image manifest,
416/// returning its `config.digest`. Returns `None` when the layout root is
417/// unknown, the blob is missing/unreadable, or the manifest has no config
418/// digest.
419fn 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
430/// Reads `blobs/sha256/<hex>` for `digest` and parses it as a nested image
431/// index. Returns `None` when the layout root is unknown or the blob is not a
432/// readable image index.
433fn 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
439/// Resolves and reads the blob file for a `sha256:<hex>` digest within the
440/// layout. The digest must be a well-formed `sha256:` followed by exactly 64
441/// hex characters, so a malformed or path-bearing digest can never escape the
442/// `blobs/sha256/` directory.
443fn 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            // A top-level JSON array that is not a docker-save manifest is not
459            // ours; decline quietly instead of emitting a scan error.
460            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    // The Config field is `blobs/sha256/<hex>.json` (OCI layout) or `<hex>.json`
475    // (legacy docker save). The hex is the image config digest.
476    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    // docker save Config points straight at the image config blob.
509    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
520/// Extracts the `sha256:<hex>` config digest from a docker save `Config` path.
521fn 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
537/// The image-name components recovered from an OCI ref name / docker repo tag.
538struct ImageRef {
539    /// Lowercased last fragment of the repository name (the purl `name`).
540    name: Option<String>,
541    /// Tag that was associated with the digest, when present.
542    tag: Option<String>,
543    /// `repository_url` qualifier: registry host (and port) plus the full
544    /// repository path, with any tag/digest stripped. Only set when the ref
545    /// carries an explicit registry host so it is genuinely derivable.
546    repository_url: Option<String>,
547}
548
549/// Splits an OCI ref name / docker repo tag into a lowercased image name, an
550/// optional tag, and a `repository_url` qualifier. A ref of
551/// `registry.example.com/library/nginx:1.27` yields name `nginx`, tag `1.27`,
552/// and repository_url `registry.example.com/library/nginx`; the
553/// registry/namespace prefix is preserved in `repository_url` rather than the
554/// PURL name to keep the identity portable.
555fn 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    // Strip a digest pin if the ref carries one (`name@sha256:...`).
565    let without_digest = ref_name.split_once('@').map_or(ref_name, |(name, _)| name);
566
567    // A colon after the last `/` is the tag separator; a colon before it is a
568    // registry port and must not be treated as a tag.
569    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
591/// Derives a `repository_url` qualifier from the (tag-stripped) repository
592/// reference, but only when the reference carries an explicit registry host.
593///
594/// A reference is treated as having an explicit registry only when its first
595/// path segment looks like a host: it contains a `.` (domain), a `:` (port),
596/// or is exactly `localhost`. Bare references like `library/nginx` or `ubuntu`
597/// have an implicit Docker Hub registry that is a convention rather than a
598/// stated fact, so no `repository_url` is emitted for them (honest unknown).
599fn 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    // OCI packages are standalone (unassembled), so the parser is responsible
623    // for the PURL identity rather than the assembly pass.
624    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
640/// Builds a `pkg:oci/<name>@sha256:<digest>` PURL with `arch` / `repository_url`
641/// / `tag` qualifiers, per the purl-spec `oci` type. The digest is carried in
642/// the version component.
643fn 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    // Deterministic qualifier order keeps the rendered PURL stable.
654    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}