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::{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
41/// OCI image index media type.
42const OCI_INDEX_MEDIA_TYPE: &str = "application/vnd.oci.image.index.v1+json";
43/// Docker distribution manifest list media type (Docker schema 2).
44const DOCKER_MANIFEST_LIST_MEDIA_TYPE: &str =
45    "application/vnd.docker.distribution.manifest.list.v2+json";
46
47/// containerd records the full image reference (registry/name:tag) here; the
48/// standard OCI `ref.name` annotation often holds only the tag, so we prefer
49/// this when present.
50const CONTAINERD_IMAGE_NAME_ANNOTATION: &str = "io.containerd.image.name";
51const REF_NAME_ANNOTATION: &str = "org.opencontainers.image.ref.name";
52
53/// Upper bound on how deeply nested image indexes are followed. Real layouts
54/// nest at most index -> manifest list -> manifest; this keeps a hostile or
55/// cyclic layout bounded.
56const MAX_INDEX_DEPTH: usize = 8;
57
58/// Top-level OCI image index (`index.json`).
59#[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/// A descriptor entry in an OCI image index `manifests` array.
72#[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/// An OCI / Docker schema-2 image manifest blob. Only the config descriptor is
94/// needed to recover the image config digest.
95#[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/// A single entry in a `docker save` `manifest.json` array.
108#[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        // The layout root is the directory containing index.json; blobs live in
151        // `<root>/blobs/sha256/<hex>`. `manifest.json` (docker save) is
152        // self-contained and does not need the root.
153        let layout_root = path.parent().map(Path::to_path_buf);
154        parse_oci_content(&content, layout_root.as_deref())
155    }
156}
157
158/// Parses the JSON content of an OCI `index.json` or Docker `manifest.json`.
159///
160/// `layout_root` is the directory that contains the OCI layout (the parent of
161/// `index.json`); it is used to follow descriptors into `blobs/sha256/<hex>`.
162/// When `None`, descriptor blobs cannot be resolved and the parser falls back
163/// to descriptor digests.
164///
165/// Returns an empty vector when the content is valid JSON but not an OCI image
166/// index or `docker save` manifest, so the parser does not claim unrelated
167/// `index.json` / `manifest.json` files.
168pub(crate) fn parse_oci_content(content: &str, layout_root: Option<&Path>) -> Vec<PackageData> {
169    // `docker save` manifest.json is a JSON array; OCI index.json is an object.
170    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/// Image identity (name / tag / repository_url / full ref) that a parent index
197/// descriptor contributes to its children. In a buildx-style layout the
198/// `io.containerd.image.name` annotation lives only on the top-level
199/// descriptor that points at the nested index; the leaf per-platform manifests
200/// carry no name annotation, so the identity must be inherited downward.
201#[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
209/// Walks an image index, following descriptors into blob manifests to resolve
210/// per-platform config digests, recursing through nested image indexes up to
211/// [`MAX_INDEX_DEPTH`]. `inherited` carries identity contributed by ancestor
212/// descriptors.
213fn 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        // buildx emits attestation manifests (SBOM/provenance) as extra
226        // descriptors with platform `unknown/unknown`; they are not container
227        // images and would otherwise yield bogus `arch=unknown` packages.
228        if descriptor_is_attestation(&descriptor) {
229            continue;
230        }
231
232        // A descriptor's own annotations override the inherited identity.
233        let resolved = resolve_identity(&descriptor, inherited);
234
235        // A descriptor may itself reference a nested image index / manifest
236        // list. Follow it (bounded) so multi-arch images expressed as nested
237        // indexes still resolve per-platform, passing the resolved identity
238        // down to the leaves.
239        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
262/// A buildx attestation manifest carries `vnd.docker.reference.type` and an
263/// `unknown/unknown` platform; it describes provenance/SBOM for a sibling
264/// image, not an image itself.
265fn 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
272/// Resolves the identity for a descriptor: its own name annotations win, and
273/// any field it does not provide is inherited from the ancestor identity.
274fn 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    // Prefer the containerd full image reference for identity; the standard OCI
286    // ref.name often carries only the tag.
287    let identity_ref = containerd_name.or(ref_name);
288    if identity_ref.is_none() {
289        // No own identity: inherit wholesale from the ancestor descriptor.
290        return inherited.clone();
291    }
292
293    let parsed = parse_image_ref(identity_ref);
294    let mut tag = parsed.tag;
295    // If identity came from containerd (full ref) but lacked a tag, fall back to
296    // the bare ref.name annotation as the tag.
297    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
318/// An OCI image index is identified by its media type, or by a schema version
319/// of 2 paired with at least one manifest descriptor.
320///
321/// `index.json` is a very common filename, so the schema-version fallback (used
322/// when no recognized `mediaType` is present) additionally requires that at
323/// least one descriptor carries a non-empty `digest`. Every real OCI/Docker
324/// descriptor has one, so this rejects unrelated `{ "schemaVersion": 2, ... }`
325/// JSON that happens to expose a `manifests` array without descriptor digests.
326fn 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    // Follow the descriptor into its manifest blob to recover the config
357    // digest. When the blob is absent (inherent limitation), fall back to the
358    // descriptor digest.
359    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
408/// Reads `blobs/sha256/<hex>` for `digest` and parses it as an image manifest,
409/// returning its `config.digest`. Returns `None` when the layout root is
410/// unknown, the blob is missing/unreadable, or the manifest has no config
411/// digest.
412fn 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
423/// Reads `blobs/sha256/<hex>` for `digest` and parses it as a nested image
424/// index. Returns `None` when the layout root is unknown or the blob is not a
425/// readable image index.
426fn 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
432/// Resolves and reads the blob file for a `sha256:<hex>` digest within the
433/// layout. The digest must be a well-formed `sha256:` followed by exactly 64
434/// hex characters, so a malformed or path-bearing digest can never escape the
435/// `blobs/sha256/` directory.
436fn 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    // The Config field is `blobs/sha256/<hex>.json` (OCI layout) or `<hex>.json`
465    // (legacy docker save). The hex is the image config digest.
466    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    // docker save Config points straight at the image config blob.
499    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
510/// Extracts the `sha256:<hex>` config digest from a docker save `Config` path.
511fn 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
527/// The image-name components recovered from an OCI ref name / docker repo tag.
528struct ImageRef {
529    /// Lowercased last fragment of the repository name (the purl `name`).
530    name: Option<String>,
531    /// Tag that was associated with the digest, when present.
532    tag: Option<String>,
533    /// `repository_url` qualifier: registry host (and port) plus the full
534    /// repository path, with any tag/digest stripped. Only set when the ref
535    /// carries an explicit registry host so it is genuinely derivable.
536    repository_url: Option<String>,
537}
538
539/// Splits an OCI ref name / docker repo tag into a lowercased image name, an
540/// optional tag, and a `repository_url` qualifier. A ref of
541/// `registry.example.com/library/nginx:1.27` yields name `nginx`, tag `1.27`,
542/// and repository_url `registry.example.com/library/nginx`; the
543/// registry/namespace prefix is preserved in `repository_url` rather than the
544/// PURL name to keep the identity portable.
545fn 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    // Strip a digest pin if the ref carries one (`name@sha256:...`).
555    let without_digest = ref_name.split_once('@').map_or(ref_name, |(name, _)| name);
556
557    // A colon after the last `/` is the tag separator; a colon before it is a
558    // registry port and must not be treated as a tag.
559    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
581/// Derives a `repository_url` qualifier from the (tag-stripped) repository
582/// reference, but only when the reference carries an explicit registry host.
583///
584/// A reference is treated as having an explicit registry only when its first
585/// path segment looks like a host: it contains a `.` (domain), a `:` (port),
586/// or is exactly `localhost`. Bare references like `library/nginx` or `ubuntu`
587/// have an implicit Docker Hub registry that is a convention rather than a
588/// stated fact, so no `repository_url` is emitted for them (honest unknown).
589fn 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    // OCI packages are standalone (unassembled), so the parser is responsible
613    // for the PURL identity rather than the assembly pass.
614    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
630/// Builds a `pkg:oci/<name>@sha256:<digest>` PURL with `arch` / `repository_url`
631/// / `tag` qualifiers, per the purl-spec `oci` type. The digest is carried in
632/// the version component.
633fn 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    // Deterministic qualifier order keeps the rendered PURL stable.
644    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}