From 6909748a5988b0d1cd31b23196fda8c7cd4259e1 Mon Sep 17 00:00:00 2001 From: "Sergey \"Shnatsel\" Davidoff" Date: Wed, 18 Mar 2026 17:48:01 +0000 Subject: [PATCH] Do not encode `?qualifier=` strings into the vcs_url field of a PURL Signed-off-by: Sergey "Shnatsel" Davidoff --- cargo-cyclonedx/src/purl.rs | 71 ++++- .../fixtures/git_package_with_branch.json | 270 ++++++++++++++++++ 2 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 cargo-cyclonedx/tests/fixtures/git_package_with_branch.json diff --git a/cargo-cyclonedx/src/purl.rs b/cargo-cyclonedx/src/purl.rs index 430386d4..3fbd02b2 100644 --- a/cargo-cyclonedx/src/purl.rs +++ b/cargo-cyclonedx/src/purl.rs @@ -65,10 +65,51 @@ pub fn get_purl( } /// Converts the `cargo metadata`'s `source` field to a valid PURL `vcs_url`. +/// +/// The `vcs_url` qualifier is specified to use the SPDX Package Download Location format: +/// `+://[/][@][#]` +/// +/// Cargo metadata uses a different format: +/// `git+[?branch=|?tag=|?rev=]#` +/// +/// This function strips the query parameters (since the commit hash already identifies the code) +/// and converts the `#commit_hash` to `@commit_hash` per the SPDX format. +/// /// Assumes that the source kind is `git`, panics if it isn't. fn source_to_vcs_url(source: &cargo_metadata::Source) -> String { assert!(source.repr.starts_with("git+")); - source.repr.replace('#', "@") + let url = &source.repr; + // Find where query parameters start (if any) and where the commit hash fragment starts + let query_start = url.find('?'); + let fragment_start = url.find('#'); + match (query_start, fragment_start) { + // Has both query params and commit hash: strip query, keep commit as @ + (Some(q), Some(f)) => { + let base = &url[..q]; + let commit = &url[f + 1..]; + format!("{}@{}", base, commit) + } + // No query params, has commit hash: just replace # with @ + (None, Some(_)) => url.replace('#', "@"), + // Has query params but no commit hash: extract the ref value as @ + (Some(q), None) => { + let base = &url[..q]; + let query = &url[q + 1..]; + // Extract the value from branch=X, tag=X, or rev=X + let ref_value = query + .split('&') + .find_map(|param| { + param + .strip_prefix("branch=") + .or_else(|| param.strip_prefix("tag=")) + .or_else(|| param.strip_prefix("rev=")) + }) + .unwrap_or(query); + format!("{}@{}", base, ref_value) + } + // No query params, no commit hash: return as-is + (None, None) => url.to_string(), + } } /// Converts a relative path to PURL subpath @@ -91,6 +132,8 @@ mod tests { const CRATES_IO_PACKAGE_JSON: &str = include_str!("../tests/fixtures/crates_io_package.json"); const GIT_PACKAGE_JSON: &str = include_str!("../tests/fixtures/git_package.json"); + const GIT_PACKAGE_WITH_BRANCH_JSON: &str = + include_str!("../tests/fixtures/git_package_with_branch.json"); const ROOT_PACKAGE_JSON: &str = include_str!("../tests/fixtures/root_package.json"); const WORKSPACE_PACKAGE_JSON: &str = include_str!("../tests/fixtures/workspace_package.json"); @@ -129,6 +172,32 @@ mod tests { assert!(parsed_purl.namespace().is_none()); } + #[test] + fn git_purl_with_branch() { + let git_package: Package = serde_json::from_str(GIT_PACKAGE_WITH_BRANCH_JSON).unwrap(); + let purl = get_purl(&git_package, &git_package, Utf8Path::new("/foo/bar"), None).unwrap(); + // Validate that data roundtripped correctly + let parsed_purl = Purl::from_str(purl.as_ref()).unwrap(); + assert_eq!(parsed_purl.name(), "rav1d"); + assert_eq!(parsed_purl.version(), Some("1.1.0")); + assert_eq!(parsed_purl.qualifiers().len(), 1); + let (qualifier, value) = parsed_purl.qualifiers().iter().next().unwrap(); + assert_eq!(qualifier.as_str(), "vcs_url"); + // The ?branch= query param must be stripped; only the commit hash remains after @ + let decoded_value = percent_decode(value.as_bytes()) + .decode_utf8() + .unwrap() + .to_string(); + assert_eq!( + decoded_value, + "git+https://github.com/leo030303/rav1d.git@3a50834ce3743bc580f340ba3bfbdbf6a46ab783" + ); + // Ensure ?branch= is NOT present in the vcs_url + assert!(!decoded_value.contains("?branch=")); + assert!(parsed_purl.subpath().is_none()); + assert!(parsed_purl.namespace().is_none()); + } + #[test] fn toplevel_package_purl() { let root_package: Package = serde_json::from_str(ROOT_PACKAGE_JSON).unwrap(); diff --git a/cargo-cyclonedx/tests/fixtures/git_package_with_branch.json b/cargo-cyclonedx/tests/fixtures/git_package_with_branch.json new file mode 100644 index 00000000..73703fd1 --- /dev/null +++ b/cargo-cyclonedx/tests/fixtures/git_package_with_branch.json @@ -0,0 +1,270 @@ +{ + "name": "rav1d", + "version": "1.1.0", + "id": "git+https://github.com/leo030303/rav1d.git?branch=add-rust-api#rav1d@1.1.0", + "license": "BSD-2-Clause", + "license_file": null, + "description": "Rust port of the dav1d AV1 decoder", + "source": "git+https://github.com/leo030303/rav1d.git?branch=add-rust-api#3a50834ce3743bc580f340ba3bfbdbf6a46ab783", + "dependencies": [ + { + "name": "assert_matches", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^1.5.0", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [], + "target": null, + "registry": null + }, + { + "name": "atomig", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^0.4.0", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [ + "derive" + ], + "target": null, + "registry": null + }, + { + "name": "av-data", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^0.4.2", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [], + "target": null, + "registry": null + }, + { + "name": "bitflags", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^2.4.0", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [], + "target": null, + "registry": null + }, + { + "name": "cfg-if", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^1.0.0", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [], + "target": null, + "registry": null + }, + { + "name": "libc", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^0.2", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [], + "target": null, + "registry": null + }, + { + "name": "parking_lot", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^0.12.2", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [], + "target": null, + "registry": null + }, + { + "name": "paste", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^1.0.14", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [], + "target": null, + "registry": null + }, + { + "name": "raw-cpuid", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^11.0.1", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [], + "target": null, + "registry": null + }, + { + "name": "static_assertions", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^1.1.0", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [], + "target": null, + "registry": null + }, + { + "name": "strum", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^0.27", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [ + "derive" + ], + "target": null, + "registry": null + }, + { + "name": "to_method", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^1.1.0", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [], + "target": null, + "registry": null + }, + { + "name": "zerocopy", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^0.7.32", + "kind": null, + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [ + "derive" + ], + "target": null, + "registry": null + }, + { + "name": "cc", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^1.0.79", + "kind": "build", + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [], + "target": null, + "registry": null + }, + { + "name": "nasm-rs", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "req": "^0.3", + "kind": "build", + "rename": null, + "optional": false, + "uses_default_features": true, + "features": [ + "parallel" + ], + "target": null, + "registry": null + } + ], + "targets": [ + { + "kind": [ + "staticlib", + "rlib" + ], + "crate_types": [ + "staticlib", + "rlib" + ], + "name": "rav1d", + "src_path": "/home/user/.cargo/git/checkouts/rav1d-da3745f1281a575f/3a50834/src/lib.rs", + "edition": "2021", + "doc": true, + "doctest": true, + "test": true + }, + { + "kind": [ + "custom-build" + ], + "crate_types": [ + "bin" + ], + "name": "build-script-build", + "src_path": "/home/user/.cargo/git/checkouts/rav1d-da3745f1281a575f/3a50834/build.rs", + "edition": "2021", + "doc": false, + "doctest": false, + "test": false + } + ], + "features": { + "asm": [], + "asm_arm64_dotprod": [ + "asm" + ], + "asm_arm64_i8mm": [ + "asm" + ], + "asm_arm64_sve2": [ + "asm" + ], + "bitdepth_16": [], + "bitdepth_8": [], + "default": [ + "asm", + "asm_arm64_dotprod", + "asm_arm64_i8mm", + "asm_arm64_sve2", + "bitdepth_8", + "bitdepth_16" + ] + }, + "manifest_path": "/home/user/.cargo/git/checkouts/rav1d-da3745f1281a575f/3a50834/Cargo.toml", + "metadata": null, + "publish": null, + "authors": [ + "Rav1d Developers", + "Prossimo" + ], + "categories": [], + "keywords": [], + "readme": "README.md", + "repository": "https://github.com/memorysafety/rav1d", + "homepage": null, + "documentation": null, + "edition": "2021", + "links": null, + "default_run": null, + "rust_version": "1.79" +} \ No newline at end of file