Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 75 additions & 11 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,82 @@ permissions:

jobs:

common:
uses: scm-rs/shared-workflows/.github/workflows/ci.yaml@main
msrv:
runs-on: ubuntu-latest
outputs:
rust-version: ${{ steps.get-version.outputs.rust-version }}
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Extract version
id: get-version
run: |
MSRV=$(cargo metadata --no-deps --format-version=1 | jq -r '.packages[0].rust_version')
echo "rust-version=$MSRV" >> $GITHUB_OUTPUT
- name: Show version
run: |
echo "MSRV: ${{ steps.get-version.outputs.rust-version }}"

ci:
preflight:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: webiny/[email protected]

- uses: Swatinem/rust-cache@v2
- uses: obi1kenobi/cargo-semver-checks-action@v2

- name: Check formatting
run: cargo fmt --check

check:
needs:
- common
if: always()
- msrv
- preflight
strategy:
matrix:
rust:
- stable
- ${{ needs.msrv.outputs.rust-version }}
os:
- ubuntu-22.04
- windows-2022
- macos-14
runs-on: ${{ matrix.os }}
steps:
- name: Success
if: ${{ !(contains(needs.*.result, 'failure')) }}
run: exit 0
- name: Failure
if: ${{ contains(needs.*.result, 'failure') }}
run: exit 1
- name: Dump matrix config
run: echo "${{ toJSON(matrix) }}"

- uses: actions/checkout@v4
with:
submodules: true
- uses: Swatinem/rust-cache@v2

- name: Install Rust ${{ matrix.rust }}
run: rustup install ${{ matrix.rust }} --no-self-update --component clippy

- name: Tree
run: cargo +${{ matrix.rust }} tree

- name: Clippy
run: cargo +${{ matrix.rust }} clippy --all-targets --tests --bins --all -- -D warnings

- name: Test
run: cargo +${{ matrix.rust }} test

- name: Install binstall
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash

- name: Install cargo-all-features
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: cargo binstall -y cargo-all-features --force

- name: Check (all features)
run: cargo check-all-features
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "tests/spec/purl-spec"]
path = tests/spec/purl-spec
url = https://github.com/package-url/purl-spec
3 changes: 1 addition & 2 deletions tests/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#[macro_use]
extern crate serde;
extern crate packageurl;
extern crate serde;
extern crate serde_json;

mod spec;
193 changes: 116 additions & 77 deletions tests/spec/macros.rs
Original file line number Diff line number Diff line change
@@ -1,99 +1,138 @@
macro_rules! spec_tests {
($name:ident, $desc:expr) => {
mod $name {
use crate::spec::testcase::SpecTestCase;
use crate::spec::testcase::TestSuite;
use crate::spec::testcase::PurlOrString;
use std::path::Path;
use std::fs;
use std::borrow::Cow;
use packageurl::PackageUrl;
use std::str::FromStr;

use super::testcase::SpecTestCase;
use packageurl::PackageUrl;
use std::borrow::Cow;
use std::str::FromStr;
use std::sync::LazyLock;

static TEST_CASE: LazyLock<SpecTestCase<'static>> =
LazyLock::new(|| SpecTestCase::new($desc));
pub fn run_parse_test(case: &SpecTestCase) {
if let PurlOrString::String(input) = &case.input {
if let Ok(purl) = PackageUrl::from_str(input) {
assert!(!case.expected_failure, "Expected failure: but parsing succeeded for PURL: {}", input);

#[test]
fn purl_to_components() {
if let Ok(purl) = PackageUrl::from_str(&TEST_CASE.purl) {
assert!(!TEST_CASE.is_invalid);
assert_eq!(TEST_CASE.ty.as_ref().unwrap().as_ref(), purl.ty());
assert_eq!(TEST_CASE.name.as_ref().unwrap().as_ref(), purl.name());
assert_eq!(
TEST_CASE.namespace.as_ref().map(Cow::as_ref),
purl.namespace()
);
assert_eq!(TEST_CASE.version.as_ref().map(Cow::as_ref), purl.version());
assert_eq!(TEST_CASE.subpath.as_ref().map(Cow::as_ref), purl.subpath());
if let Some(ref quals) = TEST_CASE.qualifiers {
assert_eq!(quals, purl.qualifiers());
} else {
assert!(purl.qualifiers().is_empty());
}
if let Some(PurlOrString::PurlComponent(expected)) = &case.expected_output {
assert_eq!(Some(purl.ty()), expected.ty.as_ref().map(Cow::as_ref));
assert_eq!(Some(purl.name()), expected.name.as_ref().map(Cow::as_ref));
assert_eq!(purl.namespace(), expected.namespace.as_ref().map(Cow::as_ref));
assert_eq!(purl.version(), expected.version.as_ref().map(Cow::as_ref));
assert_eq!(purl.subpath(), expected.subpath.as_ref().map(Cow::as_ref));

if let Some(ref expected_quals) = expected.qualifiers {
assert_eq!(purl.qualifiers(), expected_quals);
} else {
assert!(TEST_CASE.is_invalid);
assert!(purl.qualifiers().is_empty());
}
} else {
panic!("Expected PurlComponent as expected_output for: {}", case.description);
}
} else {
assert!(case.expected_failure, "Unexpected parse failure: {}", case.description);
}
}
}

#[test]
fn components_to_canonical() {
if TEST_CASE.is_invalid {
return;
}

let mut purl = PackageUrl::new(
TEST_CASE.ty.as_ref().unwrap().clone(),
TEST_CASE.name.as_ref().unwrap().clone(),
)
.unwrap();
pub fn run_build_test(case: &SpecTestCase) {
let PurlOrString::PurlComponent(ref input) = case.input else {
panic!("Expected PurlComponent as input for build test: {}", case.description);
};

if let Some(ref ns) = TEST_CASE.namespace {
purl.with_namespace(ns.as_ref());
}
if input.ty.is_none() || input.name.is_none() {
assert!(case.expected_failure, "Missing type or name, but test not marked as failure: {}", case.description);
return;
}

if let Some(ref v) = TEST_CASE.version {
purl.with_version(v.as_ref());
}
let ty = input.ty.as_ref().unwrap().as_ref();
let name = input.name.as_ref().unwrap().as_ref();

if let Some(ref sp) = TEST_CASE.subpath {
purl.with_subpath(sp.as_ref()).unwrap();
}
let purl_result = PackageUrl::new(ty, name);

if let Some(ref quals) = TEST_CASE.qualifiers {
for (k, v) in quals.iter() {
purl.add_qualifier(k.as_ref(), v.as_ref()).unwrap();
}
}
if purl_result.is_err() {
assert!(case.expected_failure, "Purl build failed: {}", case.description);
return;
}

let mut purl = purl_result.unwrap();
if let Some(ref ns) = input.namespace {
purl.with_namespace(ns.as_ref());
}

if let Some(ref v) = input.version {
purl.with_version(v.as_ref());
}

assert_eq!(
TEST_CASE.canonical_purl.as_ref().unwrap(),
&purl.to_string()
);
if let Some(ref sp) = input.subpath {
purl.with_subpath(sp.as_ref()).unwrap();
}

if let Some(ref quals) = input.qualifiers {
for (k, v) in quals.iter() {
if purl.add_qualifier(k.as_ref(), v.as_ref()).is_err() {
assert!(case.expected_failure, "add_qualifier failed unexpectedly");
return;
}
}
}

#[test]
fn canonical_to_canonical() {
if TEST_CASE.is_invalid {
return;
}
assert!(!case.expected_failure, "Test was expected to fail but succeeded: {}", case.description);
if let Some(PurlOrString::String(expected)) = &case.expected_output {
assert_eq!(&purl.to_string(), expected);
} else {
panic!("Expected String as expected_output for build test: {}", case.description);
}
}

pub fn run_roundtrip_test(case: &SpecTestCase) {
let input = match &case.input {
PurlOrString::String(s) => s,
_ => panic!("Input must be a string: {}", case.description),
};

if let Ok(purl) = PackageUrl::from_str(input) {
assert!(!case.expected_failure, "Test was expected to fail but succeeded: {}", case.description);
if let Some(PurlOrString::String(expected)) = &case.expected_output {
assert_eq!(&purl.to_string(), expected);
}
} else {
assert!(case.expected_failure, "Failed to create PURL for: {}", input);
}
}


pub fn run_tests_from_spec(path: &Path) {
let data = fs::read(path).expect("Failed to read test file");
let suite: TestSuite = serde_json::from_slice(&data).expect("Invalid test file");

for case in suite.tests {

let purl =
PackageUrl::from_str(&TEST_CASE.canonical_purl.as_ref().unwrap()).unwrap();
assert_eq!(
TEST_CASE.canonical_purl.as_ref().unwrap(),
&purl.to_string()
);
match case.test_type.as_ref() {
"parse" => {
run_parse_test(&case);
}
"build" => {
run_build_test(&case);
}
"roundtrip" => {
run_roundtrip_test(&case);
}
other => {
println!("Unknown test type '{}', skipping: {}", other, case.description);
}
}
}
}


macro_rules! generate_json_tests {
($($test_name:ident => $file_path:expr),* $(,)?) => {
$(
#[test]
fn purl_to_canonical() {
if TEST_CASE.is_invalid {
return;
}
let purl = PackageUrl::from_str(&TEST_CASE.purl).unwrap();
assert_eq!(
TEST_CASE.canonical_purl.as_ref().unwrap(),
&purl.to_string()
)
fn $test_name() {
crate::spec::macros::run_tests_from_spec(std::path::Path::new($file_path));
}
}
)*
};
}
62 changes: 37 additions & 25 deletions tests/spec/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,40 @@
mod macros;
mod testcase;

spec_tests!(type_required, "a type is always required");
spec_tests!(scheme_required, "a scheme is always required");
spec_tests!(name_required, "a name is required");
spec_tests!(invalid_qualifier_key, "checks for invalid qualifier keys");
spec_tests!(gem, "Java gem can use a qualifier");
spec_tests!(npm, "npm can be scoped");
spec_tests!(rpm, "rpm often use qualifiers");
spec_tests!(nuget, "nuget names are case sensitive");
spec_tests!(pypi, "pypi names have special rules and not case sensitive");
spec_tests!(debian, "debian can use qualifiers");
spec_tests!(bitbucket, "bitbucket namespace and name should be lowercased");
spec_tests!(github, "github namespace and name should be lowercased");
spec_tests!(docker, "docker uses qualifiers and hash image id as versions");
spec_tests!(maven, "valid maven purl");
spec_tests!(maven_basic, "basic valid maven purl without version");
spec_tests!(maven_case_sensitive, "valid maven purl with case sensitive namespace and name");
spec_tests!(maven_space, "valid maven purl containing a space in the version and qualifier");
spec_tests!(go_subpath, "valid go purl without version and with subpath");
spec_tests!(go_version, "valid go purl with version and subpath");
spec_tests!(maven_qualifiers, "maven often uses qualifiers");
spec_tests!(maven_pom, "maven pom reference");
spec_tests!(maven_type, "maven can come with a type qualifier");
spec_tests!(simple_slash, "slash / after scheme is not significant");
spec_tests!(double_slash, "double slash // after scheme is not significant");
spec_tests!(triple_slash, "slash /// after type is not significant");

generate_json_tests! {
alpm_test => "tests/spec/purl-spec/tests/types/alpm-test.json",
apk_test => "tests/spec/purl-spec/tests/types/apk-test.json",
bintray_test => "tests/spec/purl-spec/tests/types/bintray-test.json",
bitbucket_test => "tests/spec/purl-spec/tests/types/bitbucket-test.json",
bitnami_test => "tests/spec/purl-spec/tests/types/bitnami-test.json",
cargo_test => "tests/spec/purl-spec/tests/types/cargo-test.json",
cocoapods_test => "tests/spec/purl-spec/tests/types/cocoapods-test.json",
// composer_test => "tests/spec/purl-spec/tests/types/composer-test.json",
// conan_test => "tests/spec/purl-spec/tests/types/conan-test.json",
conda_test => "tests/spec/purl-spec/tests/types/conda-test.json",
// cpan_test => "tests/spec/purl-spec/tests/types/cpan-test.json",
// cran_test => "tests/spec/purl-spec/tests/types/cran-test.json",
deb_test => "tests/spec/purl-spec/tests/types/deb-test.json",
docker_test => "tests/spec/purl-spec/tests/types/docker-test.json",
gem_test => "tests/spec/purl-spec/tests/types/gem-test.json",
generic_test => "tests/spec/purl-spec/tests/types/generic-test.json",
github_test => "tests/spec/purl-spec/tests/types/github-test.json",
golang_test => "tests/spec/purl-spec/tests/types/golang-test.json",
hackage_test => "tests/spec/purl-spec/tests/types/hackage-test.json",
hex_test => "tests/spec/purl-spec/tests/types/hex-test.json",
// huggingface_test => "tests/spec/purl-spec/tests/types/huggingface-test.json",
luarocks_test => "tests/spec/purl-spec/tests/types/luarocks-test.json",
// maven_test => "tests/spec/purl-spec/tests/types/maven-test.json",
// mlflow_test => "tests/spec/purl-spec/tests/types/mlflow-test.json",
// npm_test => "tests/spec/purl-spec/tests/types/npm-test.json",
nuget_test => "tests/spec/purl-spec/tests/types/nuget-test.json",
oci_test => "tests/spec/purl-spec/tests/types/oci-test.json",
pub_test => "tests/spec/purl-spec/tests/types/pub-test.json",
pypi_test => "tests/spec/purl-spec/tests/types/pypi-test.json",
qpkg_test => "tests/spec/purl-spec/tests/types/qpkg-test.json",
rpm_test => "tests/spec/purl-spec/tests/types/rpm-test.json",
swid_test => "tests/spec/purl-spec/tests/types/swid-test.json",
// swift_test => "tests/spec/purl-spec/tests/types/swift-test.json",
}

1 change: 1 addition & 0 deletions tests/spec/purl-spec
Submodule purl-spec added at 96d6f8
Loading