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            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/// Image identity (name / tag / repository_url / full ref) that a parent index
199/// descriptor contributes to its children. In a buildx-style layout the
200/// `io.containerd.image.name` annotation lives only on the top-level
201/// descriptor that points at the nested index; the leaf per-platform manifests
202/// carry no name annotation, so the identity must be inherited downward.
203#[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
211/// Walks an image index, following descriptors into blob manifests to resolve
212/// per-platform config digests, recursing through nested image indexes up to
213/// [`MAX_INDEX_DEPTH`]. `inherited` carries identity contributed by ancestor
214/// descriptors.
215fn 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        // buildx emits attestation manifests (SBOM/provenance) as extra
229        // descriptors with platform `unknown/unknown`; they are not container
230        // images and would otherwise yield bogus `arch=unknown` packages.
231        if descriptor_is_attestation(&descriptor) {
232            continue;
233        }
234
235        // A descriptor's own annotations override the inherited identity.
236        let resolved = resolve_identity(&descriptor, inherited);
237
238        // A descriptor may itself reference a nested image index / manifest
239        // list. Follow it (bounded) so multi-arch images expressed as nested
240        // indexes still resolve per-platform, passing the resolved identity
241        // down to the leaves.
242        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
265/// A buildx attestation manifest carries `vnd.docker.reference.type` and an
266/// `unknown/unknown` platform; it describes provenance/SBOM for a sibling
267/// image, not an image itself.
268fn 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
275/// Resolves the identity for a descriptor: its own name annotations win, and
276/// any field it does not provide is inherited from the ancestor identity.
277fn 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    // Prefer the containerd full image reference for identity; the standard OCI
289    // ref.name often carries only the tag.
290    let identity_ref = containerd_name.or(ref_name);
291    if identity_ref.is_none() {
292        // No own identity: inherit wholesale from the ancestor descriptor.
293        return inherited.clone();
294    }
295
296    let parsed = parse_image_ref(identity_ref);
297    let mut tag = parsed.tag;
298    // If identity came from containerd (full ref) but lacked a tag, fall back to
299    // the bare ref.name annotation as the tag.
300    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
321/// An OCI image index is identified by its media type, or by a schema version
322/// of 2 paired with at least one manifest descriptor.
323///
324/// `index.json` is a very common filename, so the schema-version fallback (used
325/// when no recognized `mediaType` is present) additionally requires that at
326/// least one descriptor carries a non-empty `digest`. Every real OCI/Docker
327/// descriptor has one, so this rejects unrelated `{ "schemaVersion": 2, ... }`
328/// JSON that happens to expose a `manifests` array without descriptor digests.
329fn 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    // Follow the descriptor into its manifest blob to recover the config
360    // digest. When the blob is absent (inherent limitation), fall back to the
361    // descriptor digest.
362    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
411/// Reads `blobs/sha256/<hex>` for `digest` and parses it as an image manifest,
412/// returning its `config.digest`. Returns `None` when the layout root is
413/// unknown, the blob is missing/unreadable, or the manifest has no config
414/// digest.
415fn 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
426/// Reads `blobs/sha256/<hex>` for `digest` and parses it as a nested image
427/// index. Returns `None` when the layout root is unknown or the blob is not a
428/// readable image index.
429fn 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
435/// Resolves and reads the blob file for a `sha256:<hex>` digest within the
436/// layout. The digest must be a well-formed `sha256:` followed by exactly 64
437/// hex characters, so a malformed or path-bearing digest can never escape the
438/// `blobs/sha256/` directory.
439fn 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    // The Config field is `blobs/sha256/<hex>.json` (OCI layout) or `<hex>.json`
469    // (legacy docker save). The hex is the image config digest.
470    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    // docker save Config points straight at the image config blob.
503    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
514/// Extracts the `sha256:<hex>` config digest from a docker save `Config` path.
515fn 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
531/// The image-name components recovered from an OCI ref name / docker repo tag.
532struct ImageRef {
533    /// Lowercased last fragment of the repository name (the purl `name`).
534    name: Option<String>,
535    /// Tag that was associated with the digest, when present.
536    tag: Option<String>,
537    /// `repository_url` qualifier: registry host (and port) plus the full
538    /// repository path, with any tag/digest stripped. Only set when the ref
539    /// carries an explicit registry host so it is genuinely derivable.
540    repository_url: Option<String>,
541}
542
543/// Splits an OCI ref name / docker repo tag into a lowercased image name, an
544/// optional tag, and a `repository_url` qualifier. A ref of
545/// `registry.example.com/library/nginx:1.27` yields name `nginx`, tag `1.27`,
546/// and repository_url `registry.example.com/library/nginx`; the
547/// registry/namespace prefix is preserved in `repository_url` rather than the
548/// PURL name to keep the identity portable.
549fn 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    // Strip a digest pin if the ref carries one (`name@sha256:...`).
559    let without_digest = ref_name.split_once('@').map_or(ref_name, |(name, _)| name);
560
561    // A colon after the last `/` is the tag separator; a colon before it is a
562    // registry port and must not be treated as a tag.
563    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
585/// Derives a `repository_url` qualifier from the (tag-stripped) repository
586/// reference, but only when the reference carries an explicit registry host.
587///
588/// A reference is treated as having an explicit registry only when its first
589/// path segment looks like a host: it contains a `.` (domain), a `:` (port),
590/// or is exactly `localhost`. Bare references like `library/nginx` or `ubuntu`
591/// have an implicit Docker Hub registry that is a convention rather than a
592/// stated fact, so no `repository_url` is emitted for them (honest unknown).
593fn 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    // OCI packages are standalone (unassembled), so the parser is responsible
617    // for the PURL identity rather than the assembly pass.
618    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
634/// Builds a `pkg:oci/<name>@sha256:<digest>` PURL with `arch` / `repository_url`
635/// / `tag` qualifiers, per the purl-spec `oci` type. The digest is carried in
636/// the version component.
637fn 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    // Deterministic qualifier order keeps the rendered PURL stable.
648    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}