├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── maven.yml │ └── semantic-pr.yml ├── .gitignore ├── .java-version ├── .mvn └── jvm.config ├── LICENSE ├── README.md ├── bnd.bnd ├── pom.xml ├── release.sh └── src ├── headers ├── java.txt └── xml.txt ├── main └── java │ └── com │ └── github │ └── packageurl │ ├── MalformedPackageURLException.java │ ├── PackageURL.java │ ├── PackageURLBuilder.java │ ├── ValidationException.java │ ├── internal │ ├── StringUtil.java │ └── package-info.java │ ├── package-info.java │ └── validator │ ├── PackageURL.java │ ├── PackageURLConstraintValidator.java │ └── package-info.java └── test ├── java └── com │ └── github │ └── packageurl │ ├── PackageURLBuilderTest.java │ ├── PackageURLTest.java │ ├── PurlParameters.java │ └── internal │ ├── StringUtilBenchmark.java │ └── StringUtilTest.java └── resources ├── components-constructor-only.json ├── custom-suite.json ├── string-constructor-only.json ├── test-suite-data.json └── type-namespace-constructor-only.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = 120 10 | tab_width = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.java] 14 | indent_size = 4 15 | tab_width = 4 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * -text 2 | .gitattributes text eol=lf 3 | .gitignore text eol=lf 4 | .java-version text eol=lf 5 | LICENSE text eol=lf 6 | *.config text eol=lf 7 | *.java text eol=lf 8 | *.json text eol=lf 9 | *.md text eol=lf 10 | *.sh text eol=lf 11 | *.xml text eol=lf 12 | *.yml text eol=lf 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "maven" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | name: Maven CI 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: read-all 6 | 7 | jobs: 8 | build: 9 | strategy: 10 | matrix: 11 | os: [ ubuntu-latest ] 12 | java-version: [ 17 ] 13 | distro: [ 'zulu', 'temurin' ] 14 | runs-on: ${{ matrix.os }} 15 | 16 | steps: 17 | 18 | - name: Checkout repository 19 | uses: actions/checkout@v4.2.2 20 | 21 | - name: Set up JDK 8 and ${{ matrix.java-version }} 22 | uses: actions/setup-java@v4 23 | with: 24 | distribution: ${{ matrix.distro }} 25 | # We install two JDKs: 26 | # * The first one is used by tests through Maven Toolchains. 27 | # * The second one is used by Maven itself to perform the build. 28 | # 29 | # WARNING: The order matters. 30 | # The last version specified will be assigned to JAVA_HOME. 31 | java-version: | 32 | 8 33 | ${{ matrix.java-version }} 34 | 35 | - name: Build with Maven 36 | shell: bash 37 | run: mvn verify 38 | -------------------------------------------------------------------------------- /.github/workflows/semantic-pr.yml: -------------------------------------------------------------------------------- 1 | name: "Semantic PR" 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | main: 15 | name: Semantic PR title 16 | permissions: 17 | pull-requests: read 18 | statuses: write 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # 5.5.3 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #### ignove maven 2 | #### Source: https://github.com/github/gitignore/blob/master/Maven.gitignore 3 | target/ 4 | pom.xml.tag 5 | pom.xml.releaseBackup 6 | pom.xml.versionsBackup 7 | pom.xml.next 8 | release.properties 9 | dependency-reduced-pom.xml 10 | buildNumber.properties 11 | .mvn/timing.properties 12 | .mvn/wrapper/maven-wrapper.jar 13 | ########################################################################## 14 | 15 | #### ignore JetBrains IntelliJ 16 | .idea 17 | *.iml 18 | ########################################################################## 19 | -------------------------------------------------------------------------------- /.java-version: -------------------------------------------------------------------------------- 1 | 17.0 2 | -------------------------------------------------------------------------------- /.mvn/jvm.config: -------------------------------------------------------------------------------- 1 | --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED 2 | --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED 3 | --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED 4 | --add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED 5 | --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED 6 | --add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED 7 | --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED 8 | --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED 9 | --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED 10 | --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/package-url/packageurl-java/workflows/Maven%20CI/badge.svg)](https://github.com/package-url/packageurl-java/actions?workflow=Maven+CI) 2 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.github.package-url/packageurl-java/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.github.package-url/packageurl-java) 3 | [![License][license-image]][license-url] 4 | 5 | # Package URL (purl) for Java 6 | 7 | This project implements a purl parser and class for Java. 8 | 9 | ## Requirements 10 | 11 | - The library has a **runtime** requirement of JRE 8 or newer. 12 | - To **build** the library from source, you need JDK 17 or newer. 13 | 14 | ## Compiling 15 | 16 | ```bash 17 | mvn clean install 18 | ```` 19 | 20 | ## Maven Usage 21 | Package URL is available on the Maven Central Repository. These can be used without having to compile 22 | the project yourself. 23 | 24 | ```xml 25 | 26 | 27 | com.github.package-url 28 | packageurl-java 29 | 1.5.0 30 | 31 | 32 | ``` 33 | 34 | ## Usage 35 | Creates a new PackageURL object from a purl string: 36 | ```java 37 | PackageURL purl = new PackageURL(purlString); 38 | ```` 39 | 40 | Creates a new PackageURL object from parameters: 41 | ```java 42 | PackageURL purl = new PackageURL(type, namespace, name, version, qualifiers, subpath); 43 | ```` 44 | 45 | Creates a new PackageURL object using the builder pattern: 46 | ```java 47 | PackageURL purl = PackageURLBuilder.aPackageURL() 48 | .withType("type") 49 | .withNamespace("namespace") 50 | .withName("name") 51 | .withVersion("version") 52 | .withQualifier("key","value") 53 | .withSubpath("subpath") 54 | .build(); 55 | ``` 56 | 57 | Validates a String field in a POJO (Bean Validation): 58 | ```java 59 | @PackageURL 60 | private String purl; 61 | ``` 62 | 63 | License 64 | ------------------- 65 | 66 | Permission to modify and redistribute is granted under the terms of the 67 | [MIT License](https://github.com/package-url/packageurl-java/blob/master/LICENSE) 68 | 69 | [license-image]: https://img.shields.io/badge/license-mit%20license-brightgreen.svg 70 | [license-url]: https://github.com/package-url/packageurl-java/blob/master/LICENSE 71 | -------------------------------------------------------------------------------- /bnd.bnd: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | # 22 | ## 23 | 24 | # Create OSGi and JPMS module names based on the `groupId` and `artifactId`. 25 | # This almost agrees with `maven-bundle-plugin`, but replaces non-alphanumeric characters 26 | # with full stops `.`. 27 | Bundle-SymbolicName: com.github.packageurl 28 | -jpms-module-info: $[Bundle-SymbolicName];access=0 29 | 30 | # Convert API leakage warnings to errors 31 | -fixupmessages.priv_refs: "private references";restrict:=warning;is:=error 32 | 33 | # Options specific to dependency packages: 34 | # 35 | # Jakarta Validation and JSpecify are optional dependencies. 36 | Import-Package: \ 37 | jakarta.validation;resolution:=optional,\ 38 | org.jspecify.annotations;resolution:=optional,\ 39 | * 40 | 41 | # Options specific to dependency modules: 42 | # 43 | # Optional dependencies can not be `transitive`, otherwise consumers will need them at compile time. 44 | -jpms-module-info-options: \ 45 | jakarta.validation;transitive=false,\ 46 | org.jspecify;transitive=false 47 | 48 | # Adds certain `Implementation-*` and `Specification-*` entries to the generated `MANIFEST.MF`. 49 | # We set these values to their Maven Archiver defaults: https://maven.apache.org/shared/maven-archiver/#class_manifest 50 | Implementation-Title: ${project.name} 51 | # Implementation-Vendor: ${project.organization.name} 52 | Implementation-Version: ${project.version} 53 | Specification-Title: ${project.name} 54 | # Specification-Vendor: ${project.organization.name} 55 | Specification-Version: ${parsedVersion.majorVersion}.${parsedVersion.minorVersion} 56 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 23 | 24 | 4.0.0 25 | com.github.package-url 26 | packageurl-java 27 | 2.0.0-SNAPSHOT 28 | jar 29 | 30 | Package URL 31 | The official Java implementation of the PackageURL specification. PackageURL (purl) is a minimal 32 | specification for describing a package via a "mostly universal" URL. 33 | https://github.com/package-url/packageurl-java 34 | 2017 35 | 36 | 37 | 38 | MIT 39 | https://opensource.org/licenses/MIT 40 | repo 41 | 42 | 43 | 44 | 45 | 46 | Steve Springett 47 | Steve.Springett@owasp.org 48 | OWASP 49 | http://www.owasp.org/ 50 | 51 | Architect 52 | Developer 53 | 54 | 55 | 56 | 57 | 58 | scm:git:git@github.com:package-url/packageurl-java.git 59 | scm:git:git@github.com:package-url/packageurl-java.git 60 | HEAD 61 | https://github.com/package-url/packageurl-java.git 62 | 63 | 64 | 65 | GitHub 66 | https://github.com/package-url/packageurl-java/issues 67 | 68 | 69 | 70 | travis-ci 71 | https://travis-ci.com/package-url/packageurl-java 72 | 73 | 74 | 75 | 76 | ossrh 77 | https://oss.sonatype.org/service/local/staging/deploy/maven2/ 78 | 79 | 80 | ossrh 81 | https://oss.sonatype.org/content/repositories/snapshots 82 | 83 | 84 | 85 | 86 | 87 | 8 88 | UTF-8 89 | UTF-8 90 | false 91 | 92 | 97 | 2025-03-15T10:12:28Z 98 | 99 | 100 | 104 | 17 105 | 110 | 18 111 | 112 | 113 | 7.1.0 114 | 3.6.0 115 | 2.9.1 116 | 3.5.0 117 | 3.4.1 118 | 3.14.0 119 | 3.8.1 120 | 3.1.4 121 | 3.5.0 122 | 3.2.7 123 | 3.1.4 124 | 3.4.2 125 | 3.11.2 126 | 3.1.1 127 | 3.3.1 128 | 3.21.0 129 | 3.3.1 130 | 3.5.3 131 | 2.18.0 132 | 133 | 2.38.0 134 | 0.8.13 135 | 2.58.0 136 | 4.9.3.0 137 | 2.44.4 138 | 4.9.3 139 | 140 | 3.1.1 141 | 1.37 142 | 20250107 143 | 5.12.2 144 | 1.4.0 145 | 146 | 147 | 148 | 149 | 150 | 151 | org.junit 152 | junit-bom 153 | ${junit-bom.version} 154 | pom 155 | import 156 | 157 | 158 | 159 | 160 | 161 | 162 | jakarta.validation 163 | jakarta.validation-api 164 | ${jakarta.validation-api.version} 165 | provided 166 | true 167 | 168 | 169 | org.osgi 170 | org.osgi.annotation.bundle 171 | 2.0.0 172 | provided 173 | 174 | 175 | org.jspecify 176 | jspecify 177 | 1.0.0 178 | provided 179 | true 180 | 181 | 182 | org.openjdk.jmh 183 | jmh-core 184 | ${jmh.version} 185 | test 186 | 187 | 188 | org.json 189 | json 190 | ${json.version} 191 | test 192 | 193 | 194 | org.junit.jupiter 195 | junit-jupiter-api 196 | test 197 | 198 | 199 | org.junit.jupiter 200 | junit-jupiter-params 201 | test 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | biz.aQute.bnd 210 | bnd-baseline-maven-plugin 211 | ${bnd.maven.plugin.version} 212 | 213 | 214 | biz.aQute.bnd 215 | bnd-maven-plugin 216 | ${bnd.maven.plugin.version} 217 | 218 | 219 | org.apache.maven.plugins 220 | maven-clean-plugin 221 | ${maven.clean.plugin.version} 222 | 223 | 224 | org.apache.maven.plugins 225 | maven-compiler-plugin 226 | ${maven.compiler.plugin.version} 227 | 228 | 229 | org.apache.maven.plugins 230 | maven-deploy-plugin 231 | ${maven.deploy.plugin.version} 232 | 233 | 234 | org.apache.maven.plugins 235 | maven-enforcer-plugin 236 | ${maven.enforcer.plugin.version} 237 | 238 | 239 | org.apache.maven.plugins 240 | maven-install-plugin 241 | ${maven.install.plugin.version} 242 | 243 | 244 | org.apache.maven.plugins 245 | maven-gpg-plugin 246 | ${maven-gpg-plugin.version} 247 | 248 | 249 | org.apache.maven.plugins 250 | maven-jar-plugin 251 | ${maven.jar.plugin.version} 252 | 253 | 254 | org.apache.maven.plugins 255 | maven-release-plugin 256 | ${maven.release.plugin.version} 257 | 258 | 259 | org.apache.maven.plugins 260 | maven-resources-plugin 261 | ${maven.resources.plugin.version} 262 | 263 | 264 | org.apache.maven.plugins 265 | maven-site-plugin 266 | ${maven.site.plugin.version} 267 | 268 | 269 | org.apache.maven.plugins 270 | maven-surefire-plugin 271 | ${maven.surefire.plugin.version} 272 | 273 | 274 | 275 | 276 | 281 | 282 | org.codehaus.mojo 283 | build-helper-maven-plugin 284 | ${builder.helper.maven.plugin.version} 285 | 286 | 287 | parse-version 288 | 289 | parse-version 290 | 291 | validate 292 | 293 | 294 | 295 | 296 | org.apache.maven.plugins 297 | maven-enforcer-plugin 298 | 299 | 300 | enforce-build-environment 301 | 302 | enforce 303 | 304 | 305 | 306 | 307 | true 308 | [${min.jdk.version},) 309 | To build this library you need JDK ${min.jdk.version} or higher. 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | org.apache.maven.plugins 319 | maven-compiler-plugin 320 | 321 | ${maven.compiler.release} 322 | true 323 | 324 | -Xlint:all 325 | 326 | -XDcompilePolicy=simple 327 | --should-stop=ifError=FLOW 328 | -Xplugin:ErrorProne -XepExcludedPaths:.*/generated-test-sources/.* 329 | 340 | 341 | 342 | 343 | com.google.errorprone 344 | error_prone_core 345 | ${error.prone.core.version} 346 | 347 | 348 | 349 | 350 | 351 | default-testCompile 352 | 353 | 354 | -proc:full 355 | 356 | 357 | org.openjdk.jmh.generators.BenchmarkProcessor 358 | 359 | 360 | 361 | org.openjdk.jmh 362 | jmh-generator-annprocess 363 | ${jmh.version} 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | com.diffplug.spotless 372 | spotless-maven-plugin 373 | ${spotless.maven.plugin.version} 374 | 375 | 376 | 377 | src/headers/java.txt 378 | 379 | 380 | ${palantir.java.format.version} 381 | 382 | 383 | 384 | 385 | src/headers/xml.txt 386 | <project 387 | 388 | 389 | false 390 | 391 | true 392 | 393 | 394 | 395 | 396 | 397 | check-formatting 398 | 399 | check 400 | 401 | 402 | 403 | 404 | 405 | com.github.spotbugs 406 | spotbugs-maven-plugin 407 | ${spotbugs.maven.plugin.version} 408 | 409 | 410 | 411 | com.github.spotbugs 412 | spotbugs 413 | ${com.github.spotbugs.version} 414 | 415 | 416 | 417 | 418 | 419 | check 420 | 421 | package 422 | 423 | 424 | 425 | 426 | org.codehaus.mojo 427 | versions-maven-plugin 428 | ${versions-maven-plugin.version} 429 | 430 | (?i).+[-.](alpha|beta|cr|dev|m|rc)([-.]?\d+)? 431 | 432 | 433 | 434 | org.jacoco 435 | jacoco-maven-plugin 436 | ${jacoco.maven.plugin.version} 437 | 438 | 439 | setup 440 | 441 | prepare-agent 442 | 443 | 444 | 445 | report 446 | 447 | report 448 | 449 | prepare-package 450 | 451 | 452 | 453 | 454 | org.apache.maven.plugins 455 | maven-source-plugin 456 | ${maven.source.plugin.version} 457 | 458 | 459 | attach-sources 460 | 461 | jar-no-fork 462 | 463 | 464 | 465 | 466 | 467 | org.apache.maven.plugins 468 | maven-javadoc-plugin 469 | ${maven.javadoc.plugin.version} 470 | 471 | 472 | attach-javadocs 473 | 474 | jar 475 | 476 | 477 | 478 | 479 | 493 | 494 | biz.aQute.bnd 495 | bnd-maven-plugin 496 | 497 | true 498 | 499 | 500 | generate-jar-and-module-descriptors 501 | 502 | jar 503 | 504 | 505 | 506 | 507 | 515 | 516 | biz.aQute.bnd 517 | bnd-baseline-maven-plugin 518 | 519 | 520 | check-api-compatibility 521 | 522 | baseline 523 | 524 | 525 | 526 | 527 | 528 | org.cyclonedx 529 | cyclonedx-maven-plugin 530 | ${cyclonedx-maven-plugin.version} 531 | 532 | library 533 | 534 | 535 | 536 | 537 | makeBom 538 | 539 | package 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 553 | 554 | java8-tests 555 | 556 | 557 | ${user.home}/.m2/toolchains.xml 558 | 559 | 560 | 561 | 562 | 563 | org.apache.maven.plugins 564 | maven-surefire-plugin 565 | 566 | plain 567 | 568 | true 569 | 570 | 571 | 572 | 573 | 574 | me.fabriciorby 575 | maven-surefire-junit5-tree-reporter 576 | ${maven-surefire-junit5-tree-reporter.version} 577 | 578 | 579 | 580 | 581 | default-test 582 | 583 | 584 | [1.8,9) 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | release 595 | 596 | false 597 | 598 | 599 | 600 | 601 | org.apache.maven.plugins 602 | maven-enforcer-plugin 603 | 604 | 605 | enforce-build-environment 606 | 607 | 608 | 609 | true 610 | [${min.jdk.version},${max.jdk.version}) 611 | To release this library you need JDK ${min.jdk.version}. 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | org.apache.maven.plugins 620 | maven-gpg-plugin 621 | 622 | 623 | sign-artifacts 624 | 625 | sign 626 | 627 | verify 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | benchmark 637 | 638 | .* 639 | true 640 | 641 | 642 | test-compile 643 | dependency:build-classpath@build-classpath 644 | exec:exec@run-benchmark 645 | 646 | 647 | org.apache.maven.plugins 648 | maven-dependency-plugin 649 | ${maven.dependency.plugin.version} 650 | 651 | 652 | build-classpath 653 | 654 | build-classpath 655 | 656 | 657 | test 658 | test.classpath 659 | 660 | 661 | 662 | 663 | 664 | org.codehaus.mojo 665 | exec-maven-plugin 666 | ${exec.maven.plugin.version} 667 | 668 | 669 | run-benchmark 670 | 671 | exec 672 | 673 | 674 | 675 | target${file.separator}classes${path.separator}target${file.separator}test-classes${path.separator}${test.classpath} 676 | 677 | ${java.home}${file.separator}bin${file.separator}java 678 | org.openjdk.jmh.Main ${jmh.args} 679 | 680 | 681 | 682 | 683 | 684 | 685 | 686 | 687 | 688 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | export JAVA_HOME=`/usr/libexec/java_home -v 1.8` 3 | export PATH=JAVA_HOME/bin:$PATH 4 | 5 | read -p "Really deploy to Maven Central repository (Y/N)? " 6 | if ( [ "$REPLY" == "Y" ] ) then 7 | 8 | mvn clean 9 | mvn release:clean release:prepare release:perform -Prelease -X -e | tee release.log 10 | 11 | else 12 | echo -e "Exit without deploy" 13 | fi -------------------------------------------------------------------------------- /src/headers/java.txt: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ -------------------------------------------------------------------------------- /src/headers/xml.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/java/com/github/packageurl/MalformedPackageURLException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | package com.github.packageurl; 23 | 24 | import org.jspecify.annotations.Nullable; 25 | 26 | /** 27 | * Exception class intended to be used for PackageURL parsing exceptions. 28 | * 29 | * @author Steve Springett 30 | * @since 1.0.0 31 | */ 32 | public class MalformedPackageURLException extends Exception { 33 | private static final long serialVersionUID = -3428748639194901696L; 34 | 35 | /** 36 | * Constructs a {@code MalformedPackageURLException} with no detail message. 37 | */ 38 | public MalformedPackageURLException() {} 39 | 40 | /** 41 | * Constructs a {@code MalformedPackageURLException} with the 42 | * specified detail message. 43 | * 44 | * @param msg the detail message 45 | */ 46 | public MalformedPackageURLException(@Nullable String msg) { 47 | super(msg); 48 | } 49 | 50 | /** 51 | * Constructs a new {@code MalformedPackageURLException} with the specified detail message and 52 | * cause. 53 | * 54 | * @param message the detail message 55 | * @param cause the cause 56 | * @since 2.0.0 57 | */ 58 | public MalformedPackageURLException(String message, Throwable cause) { 59 | super(message, cause); 60 | } 61 | 62 | /** 63 | * Constructs a new {@code MalformedPackageURLException} with the specified cause and a detail 64 | * message of {@code (cause==null ? null : cause.toString())}. 65 | * 66 | * @param cause the cause 67 | * @since 2.0.0 68 | */ 69 | public MalformedPackageURLException(Throwable cause) { 70 | super(cause); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/java/com/github/packageurl/PackageURL.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | package com.github.packageurl; 23 | 24 | import static java.util.Objects.requireNonNull; 25 | 26 | import com.github.packageurl.internal.StringUtil; 27 | import java.io.Serializable; 28 | import java.net.URI; 29 | import java.net.URISyntaxException; 30 | import java.util.Arrays; 31 | import java.util.Collections; 32 | import java.util.Map; 33 | import java.util.Objects; 34 | import java.util.Set; 35 | import java.util.TreeMap; 36 | import java.util.function.IntPredicate; 37 | import java.util.stream.Collectors; 38 | import org.jspecify.annotations.Nullable; 39 | 40 | /** 41 | *

Package-URL (aka purl) is a "mostly universal" URL to describe a package. A purl is a URL composed of seven components:

42 | *
 43 |  * scheme:type/namespace/name@version?qualifiers#subpath
 44 |  * 
45 | *

46 | * Components are separated by a specific character for unambiguous parsing. 47 | * A purl must NOT contain a URL Authority, i.e., there is no support for username, 48 | * password, host and port components. A namespace segment may sometimes look 49 | * like a host, but its interpretation is specific to a type. 50 | *

51 | *

SPEC: https://github.com/package-url/purl-spec

52 | * 53 | * @author Steve Springett 54 | * @since 1.0.0 55 | */ 56 | public final class PackageURL implements Serializable { 57 | private static final long serialVersionUID = 3243226021636427586L; 58 | 59 | /** 60 | * The PackageURL scheme constant 61 | */ 62 | public static final String SCHEME = "pkg"; 63 | 64 | /** 65 | * The PackageURL scheme ({@code "pkg"}) constant followed by a colon ({@code ':'}). 66 | */ 67 | private static final String SCHEME_PART = SCHEME + ':'; 68 | 69 | /** 70 | * The package "type" or package "protocol" such as maven, npm, nuget, gem, pypi, etc. 71 | * Required. 72 | */ 73 | private final String type; 74 | 75 | /** 76 | * The name prefix such as a Maven groupid, a Docker image owner, a GitHub user or organization. 77 | * Optional and type-specific. 78 | */ 79 | private final @Nullable String namespace; 80 | 81 | /** 82 | * The name of the package. 83 | * Required. 84 | */ 85 | private final String name; 86 | 87 | /** 88 | * The version of the package. 89 | * Optional. 90 | */ 91 | private final @Nullable String version; 92 | 93 | /** 94 | * Extra qualifying data for a package such as an OS, architecture, a distro, etc. 95 | * Optional and type-specific. 96 | */ 97 | private final @Nullable Map qualifiers; 98 | 99 | /** 100 | * Extra subpath within a package, relative to the package root. 101 | * Optional. 102 | */ 103 | private final @Nullable String subpath; 104 | 105 | /** 106 | * Constructs a new PackageURL object by parsing the specified string. 107 | * 108 | * @param purl a valid package URL string to parse 109 | * @throws MalformedPackageURLException if parsing fails 110 | * @throws NullPointerException if {@code purl} is {@code null} 111 | */ 112 | public PackageURL(final String purl) throws MalformedPackageURLException { 113 | requireNonNull(purl, "purl"); 114 | 115 | if (purl.isEmpty()) { 116 | throw new MalformedPackageURLException("Invalid purl: Is empty or null"); 117 | } 118 | 119 | try { 120 | if (!purl.startsWith(SCHEME_PART)) { 121 | throw new MalformedPackageURLException( 122 | "Invalid purl: " + purl + ". It does not start with '" + SCHEME_PART + "'"); 123 | } 124 | 125 | final int length = purl.length(); 126 | int start = SCHEME_PART.length(); 127 | 128 | while (start < length && '/' == purl.charAt(start)) { 129 | start++; 130 | } 131 | 132 | final URI uri = new URI(String.join("/", SCHEME_PART, purl.substring(start))); 133 | 134 | validateScheme(uri.getScheme()); 135 | 136 | // Check to ensure that none of these parts are parsed. If so, it's an invalid purl. 137 | if (uri.getRawAuthority() != null) { 138 | throw new MalformedPackageURLException("Invalid purl: A purl must NOT contain a URL Authority "); 139 | } 140 | 141 | // subpath is optional - check for existence 142 | final String rawFragment = uri.getRawFragment(); 143 | if (rawFragment != null && !rawFragment.isEmpty()) { 144 | this.subpath = validatePath(parsePath(rawFragment, true), true); 145 | } else { 146 | this.subpath = null; 147 | } 148 | // qualifiers are optional - check for existence 149 | final String rawQuery = uri.getRawQuery(); 150 | if (rawQuery != null && !rawQuery.isEmpty()) { 151 | this.qualifiers = parseQualifiers(rawQuery); 152 | } else { 153 | this.qualifiers = null; 154 | } 155 | // this is the rest of the purl that needs to be parsed 156 | String remainder = uri.getRawPath(); 157 | // trim trailing '/' 158 | int end = remainder.length() - 1; 159 | while (end > 0 && '/' == remainder.charAt(end)) { 160 | end--; 161 | } 162 | remainder = remainder.substring(0, end + 1); 163 | // there is exactly one leading '/' at this point 164 | start = 1; 165 | // type 166 | int index = remainder.indexOf('/', start); 167 | if (index <= start) { 168 | throw new MalformedPackageURLException("Invalid purl: does not contain both a type and name"); 169 | } 170 | this.type = StringUtil.toLowerCase(validateType(remainder.substring(start, index))); 171 | 172 | start = index + 1; 173 | 174 | // version is optional - check for existence 175 | index = remainder.lastIndexOf('@'); 176 | if (index >= start) { 177 | this.version = validateVersion(this.type, StringUtil.percentDecode(remainder.substring(index + 1))); 178 | remainder = remainder.substring(0, index); 179 | } else { 180 | this.version = null; 181 | } 182 | 183 | // The 'remainder' should now consist of an optional namespace and the name 184 | index = remainder.lastIndexOf('/'); 185 | if (index <= start) { 186 | this.name = validateName(this.type, StringUtil.percentDecode(remainder.substring(start))); 187 | this.namespace = null; 188 | } else { 189 | this.name = validateName(this.type, StringUtil.percentDecode(remainder.substring(index + 1))); 190 | remainder = remainder.substring(0, index); 191 | this.namespace = validateNamespace(this.type, parsePath(remainder.substring(start), false)); 192 | } 193 | verifyTypeConstraints(this.type, this.namespace, this.name); 194 | } catch (URISyntaxException e) { 195 | throw new MalformedPackageURLException("Invalid purl: " + e.getMessage(), e); 196 | } 197 | } 198 | 199 | /** 200 | * Constructs a new PackageURL object by specifying only the required 201 | * parameters necessary to create a valid PackageURL. 202 | * 203 | * @param type the type of package (i.e. maven, npm, gem, etc) 204 | * @param name the name of the package 205 | * @throws MalformedPackageURLException if parsing fails 206 | */ 207 | public PackageURL(final String type, final String name) throws MalformedPackageURLException { 208 | this(type, null, name, null, null, null); 209 | } 210 | 211 | /** 212 | * Constructs a new PackageURL object. 213 | * 214 | * @param type the type of package (i.e. maven, npm, gem, etc) 215 | * @param namespace the name prefix (i.e., group, owner, organization) 216 | * @param name the name of the package 217 | * @param version the version of the package 218 | * @param qualifiers an array of key/value pair qualifiers 219 | * @param subpath the subpath string 220 | * @throws MalformedPackageURLException if parsing fails 221 | * @throws NullPointerException if {@code type} or {@code name} are {@code null} 222 | * @since 2.0.0 223 | */ 224 | public PackageURL( 225 | final String type, 226 | final @Nullable String namespace, 227 | final String name, 228 | final @Nullable String version, 229 | final @Nullable Map qualifiers, 230 | final @Nullable String subpath) 231 | throws MalformedPackageURLException { 232 | this.type = StringUtil.toLowerCase(validateType(requireNonNull(type, "type"))); 233 | this.namespace = validateNamespace(this.type, namespace); 234 | this.name = validateName(this.type, requireNonNull(name, "name")); 235 | this.version = validateVersion(this.type, version); 236 | this.qualifiers = parseQualifiers(qualifiers); 237 | this.subpath = validateSubpath(subpath); 238 | verifyTypeConstraints(this.type, this.namespace, this.name); 239 | } 240 | 241 | /** 242 | * Converts this {@link PackageURL} to a {@link PackageURLBuilder}. 243 | * 244 | * @return the builder 245 | * @since 1.5.0 246 | * @deprecated use {@link PackageURLBuilder#aPackageURL(PackageURL)} or {@link PackageURLBuilder#aPackageURL(String)} 247 | */ 248 | @Deprecated 249 | public PackageURLBuilder toBuilder() { 250 | return PackageURLBuilder.aPackageURL(this); 251 | } 252 | 253 | /** 254 | * Returns the package url scheme. 255 | * 256 | * @return the scheme 257 | */ 258 | public String getScheme() { 259 | return SCHEME; 260 | } 261 | 262 | /** 263 | * Returns the package "type" or package "protocol" such as maven, npm, nuget, gem, pypi, etc. 264 | * 265 | * @return the type 266 | */ 267 | public String getType() { 268 | return type; 269 | } 270 | 271 | /** 272 | * Returns the name prefix such as a Maven groupId, a Docker image owner, a GitHub user or organization. 273 | * 274 | * @return the namespace 275 | */ 276 | public @Nullable String getNamespace() { 277 | return namespace; 278 | } 279 | 280 | /** 281 | * Returns the name of the package. 282 | * 283 | * @return the name of the package 284 | */ 285 | public String getName() { 286 | return name; 287 | } 288 | 289 | /** 290 | * Returns the version of the package. 291 | * 292 | * @return the version of the package 293 | */ 294 | public @Nullable String getVersion() { 295 | return version; 296 | } 297 | 298 | /** 299 | * Returns extra qualifying data for a package such as an OS, architecture, a distro, etc. 300 | * This method returns an UnmodifiableMap. 301 | * 302 | * @return all the qualifiers, or an empty map if none are set 303 | */ 304 | public Map getQualifiers() { 305 | return qualifiers != null ? Collections.unmodifiableMap(qualifiers) : Collections.emptyMap(); 306 | } 307 | 308 | /** 309 | * Returns extra subpath within a package, relative to the package root. 310 | * 311 | * @return the subpath 312 | */ 313 | public @Nullable String getSubpath() { 314 | return subpath; 315 | } 316 | 317 | private static void validateScheme(final String value) throws MalformedPackageURLException { 318 | if (!SCHEME.equals(value)) { 319 | throw new MalformedPackageURLException( 320 | "The PackageURL scheme '" + value + "' is invalid. It should be '" + SCHEME + "'"); 321 | } 322 | } 323 | 324 | private static String validateType(final String value) throws MalformedPackageURLException { 325 | if (value.isEmpty()) { 326 | throw new MalformedPackageURLException("The PackageURL type cannot be empty"); 327 | } 328 | 329 | validateChars(value, StringUtil::isValidCharForType, "type"); 330 | 331 | return value; 332 | } 333 | 334 | private static void validateChars(String value, IntPredicate predicate, String component) 335 | throws MalformedPackageURLException { 336 | char firstChar = value.charAt(0); 337 | 338 | if (StringUtil.isDigit(firstChar)) { 339 | throw new MalformedPackageURLException( 340 | "The PackageURL " + component + " cannot start with a number: " + firstChar); 341 | } 342 | 343 | String invalidChars = value.chars() 344 | .filter(predicate.negate()) 345 | .mapToObj(c -> String.valueOf((char) c)) 346 | .collect(Collectors.joining(", ")); 347 | 348 | if (!invalidChars.isEmpty()) { 349 | throw new MalformedPackageURLException( 350 | "The PackageURL " + component + " '" + value + "' contains invalid characters: " + invalidChars); 351 | } 352 | } 353 | 354 | private static @Nullable String validateNamespace(final String type, final @Nullable String value) 355 | throws MalformedPackageURLException { 356 | if (isEmpty(value)) { 357 | return null; 358 | } 359 | return validateNamespace(type, value.split("/")); 360 | } 361 | 362 | private static @Nullable String validateNamespace(final String type, final String[] values) 363 | throws MalformedPackageURLException { 364 | if (values.length == 0) { 365 | return null; 366 | } 367 | final String tempNamespace = validatePath(values, false); 368 | final String retVal; 369 | switch (type) { 370 | case StandardTypes.APK: 371 | case StandardTypes.BITBUCKET: 372 | case StandardTypes.COMPOSER: 373 | case StandardTypes.DEB: 374 | case StandardTypes.GITHUB: 375 | case StandardTypes.GOLANG: 376 | case StandardTypes.HEX: 377 | case StandardTypes.LUAROCKS: 378 | case StandardTypes.QPKG: 379 | case StandardTypes.RPM: 380 | retVal = tempNamespace != null ? StringUtil.toLowerCase(tempNamespace) : null; 381 | break; 382 | case StandardTypes.MLFLOW: 383 | case StandardTypes.OCI: 384 | if (tempNamespace != null) { 385 | throw new MalformedPackageURLException( 386 | "The PackageURL specified contains a namespace which is not allowed for type: " + type); 387 | } 388 | retVal = null; 389 | break; 390 | default: 391 | retVal = tempNamespace; 392 | break; 393 | } 394 | return retVal; 395 | } 396 | 397 | private static String validateName(final String type, final String value) throws MalformedPackageURLException { 398 | if (value.isEmpty()) { 399 | throw new MalformedPackageURLException("The PackageURL name specified is invalid"); 400 | } 401 | String temp; 402 | switch (type) { 403 | case StandardTypes.APK: 404 | case StandardTypes.BITBUCKET: 405 | case StandardTypes.BITNAMI: 406 | case StandardTypes.COMPOSER: 407 | case StandardTypes.DEB: 408 | case StandardTypes.GITHUB: 409 | case StandardTypes.GOLANG: 410 | case StandardTypes.HEX: 411 | case StandardTypes.LUAROCKS: 412 | case StandardTypes.OCI: 413 | temp = StringUtil.toLowerCase(value); 414 | break; 415 | case StandardTypes.PUB: 416 | temp = StringUtil.toLowerCase(value).replaceAll("[^a-z0-9_]", "_"); 417 | break; 418 | case StandardTypes.PYPI: 419 | temp = StringUtil.toLowerCase(value).replace('_', '-'); 420 | break; 421 | default: 422 | temp = value; 423 | break; 424 | } 425 | return temp; 426 | } 427 | 428 | private static @Nullable String validateVersion(final String type, final @Nullable String value) { 429 | if (value == null) { 430 | return null; 431 | } 432 | 433 | switch (type) { 434 | case StandardTypes.HUGGINGFACE: 435 | case StandardTypes.LUAROCKS: 436 | case StandardTypes.OCI: 437 | return StringUtil.toLowerCase(value); 438 | default: 439 | return value; 440 | } 441 | } 442 | 443 | private static @Nullable Map validateQualifiers(final @Nullable Map values) 444 | throws MalformedPackageURLException { 445 | if (values == null || values.isEmpty()) { 446 | return null; 447 | } 448 | 449 | for (Map.Entry entry : values.entrySet()) { 450 | String key = entry.getKey(); 451 | validateKey(key); 452 | validateValue(key, entry.getValue()); 453 | } 454 | return values; 455 | } 456 | 457 | private static void validateKey(final @Nullable String value) throws MalformedPackageURLException { 458 | if (isEmpty(value)) { 459 | throw new MalformedPackageURLException("Qualifier key is invalid: " + value); 460 | } 461 | 462 | validateChars(value, StringUtil::isValidCharForKey, "qualifier key"); 463 | } 464 | 465 | private static void validateValue(final String key, final @Nullable String value) 466 | throws MalformedPackageURLException { 467 | if (isEmpty(value)) { 468 | throw new MalformedPackageURLException( 469 | "The specified PackageURL contains an empty or null qualifier value for key " + key); 470 | } 471 | } 472 | 473 | private static @Nullable String validateSubpath(final @Nullable String value) throws MalformedPackageURLException { 474 | if (isEmpty(value)) { 475 | return null; 476 | } 477 | return validatePath(value.split("/"), true); 478 | } 479 | 480 | private static @Nullable String validatePath(final String[] segments, final boolean isSubPath) 481 | throws MalformedPackageURLException { 482 | if (segments.length == 0) { 483 | return null; 484 | } 485 | try { 486 | return Arrays.stream(segments) 487 | .peek(segment -> { 488 | if (isSubPath && ("..".equals(segment) || ".".equals(segment))) { 489 | throw new ValidationException( 490 | "Segments in the subpath may not be a period ('.') or repeated period ('..')"); 491 | } else if (segment.contains("/")) { 492 | throw new ValidationException( 493 | "Segments in the namespace and subpath may not contain a forward slash ('/')"); 494 | } else if (segment.isEmpty()) { 495 | throw new ValidationException("Segments in the namespace and subpath may not be empty"); 496 | } 497 | }) 498 | .collect(Collectors.joining("/")); 499 | } catch (ValidationException e) { 500 | throw new MalformedPackageURLException(e); 501 | } 502 | } 503 | 504 | /** 505 | * Returns the canonicalized representation of the purl. 506 | * 507 | * @return the canonicalized representation of the purl 508 | * @since 1.1.0 509 | */ 510 | @Override 511 | public String toString() { 512 | return canonicalize(); 513 | } 514 | 515 | /** 516 | * Returns the canonicalized representation of the purl. 517 | * 518 | * @return the canonicalized representation of the purl 519 | */ 520 | public String canonicalize() { 521 | return canonicalize(false); 522 | } 523 | 524 | /** 525 | * Returns the canonicalized representation of the purl. 526 | * 527 | * @return the canonicalized representation of the purl 528 | * @since 1.3.2 529 | */ 530 | private String canonicalize(boolean coordinatesOnly) { 531 | final StringBuilder purl = new StringBuilder(); 532 | purl.append(SCHEME_PART).append(type).append('/'); 533 | if (namespace != null) { 534 | purl.append(encodePath(namespace)); 535 | purl.append('/'); 536 | } 537 | purl.append(StringUtil.percentEncode(name)); 538 | if (version != null) { 539 | purl.append('@').append(StringUtil.percentEncode(version)); 540 | } 541 | 542 | if (!coordinatesOnly) { 543 | if (qualifiers != null) { 544 | purl.append('?'); 545 | Set> entries = qualifiers.entrySet(); 546 | boolean separator = false; 547 | for (Map.Entry entry : entries) { 548 | if (separator) { 549 | purl.append('&'); 550 | } 551 | purl.append(entry.getKey()); 552 | purl.append('='); 553 | purl.append(StringUtil.percentEncode(entry.getValue())); 554 | separator = true; 555 | } 556 | } 557 | if (subpath != null) { 558 | purl.append('#').append(encodePath(subpath)); 559 | } 560 | } 561 | return purl.toString(); 562 | } 563 | 564 | /** 565 | * Some purl types may have specific constraints. This method attempts to verify them. 566 | * @param type the purl type 567 | * @param namespace the purl namespace 568 | * @throws MalformedPackageURLException if constraints are not met 569 | */ 570 | private static void verifyTypeConstraints(String type, @Nullable String namespace, @Nullable String name) 571 | throws MalformedPackageURLException { 572 | if (StandardTypes.MAVEN.equals(type)) { 573 | if (isEmpty(namespace) || isEmpty(name)) { 574 | throw new MalformedPackageURLException( 575 | "The PackageURL specified is invalid. Maven requires both a namespace and name."); 576 | } 577 | } 578 | } 579 | 580 | private static @Nullable Map parseQualifiers(final @Nullable Map qualifiers) 581 | throws MalformedPackageURLException { 582 | if (qualifiers == null || qualifiers.isEmpty()) { 583 | return null; 584 | } 585 | 586 | try { 587 | final TreeMap results = qualifiers.entrySet().stream() 588 | .filter(entry -> !isEmpty(entry.getValue())) 589 | .collect( 590 | TreeMap::new, 591 | (map, value) -> map.put(StringUtil.toLowerCase(value.getKey()), value.getValue()), 592 | TreeMap::putAll); 593 | return validateQualifiers(results); 594 | } catch (ValidationException ex) { 595 | throw new MalformedPackageURLException(ex.getMessage()); 596 | } 597 | } 598 | 599 | @SuppressWarnings("StringSplitter") // reason: surprising behavior is okay in this case 600 | private static @Nullable Map parseQualifiers(final String encodedString) 601 | throws MalformedPackageURLException { 602 | try { 603 | final TreeMap results = Arrays.stream(encodedString.split("&")) 604 | .collect( 605 | TreeMap::new, 606 | (map, value) -> { 607 | final String[] entry = value.split("=", 2); 608 | if (entry.length == 2 && !entry[1].isEmpty()) { 609 | String key = StringUtil.toLowerCase(entry[0]); 610 | if (map.put(key, StringUtil.percentDecode(entry[1])) != null) { 611 | throw new ValidationException( 612 | "Duplicate package qualifier encountered. More then one value was specified for " 613 | + key); 614 | } 615 | } 616 | }, 617 | TreeMap::putAll); 618 | return validateQualifiers(results); 619 | } catch (ValidationException e) { 620 | throw new MalformedPackageURLException(e); 621 | } 622 | } 623 | 624 | private static String[] parsePath(final String path, final boolean isSubpath) { 625 | return Arrays.stream(path.split("/")) 626 | .filter(segment -> !segment.isEmpty() && !(isSubpath && (".".equals(segment) || "..".equals(segment)))) 627 | .map(StringUtil::percentDecode) 628 | .toArray(String[]::new); 629 | } 630 | 631 | private static String encodePath(final String path) { 632 | return Arrays.stream(path.split("/")).map(StringUtil::percentEncode).collect(Collectors.joining("/")); 633 | } 634 | 635 | /** 636 | * Evaluates if the specified Package URL has the same values up to, but excluding 637 | * the qualifier (querystring). 638 | * This includes equivalence of the scheme, type, namespace, name, and version, but excludes qualifier and subpath 639 | * from evaluation. 640 | * 641 | * @deprecated This method is no longer recommended and will be removed from a future release. 642 | *

Use {@link PackageURL#isCoordinatesEquals} instead.

643 | * 644 | * @param purl the Package URL to evaluate 645 | * @return true if equivalence passes, false if not 646 | * @since 1.2.0 647 | */ 648 | // @Deprecated(since = "1.4.0", forRemoval = true) 649 | @Deprecated 650 | public boolean isBaseEquals(final PackageURL purl) { 651 | return isCoordinatesEquals(purl); 652 | } 653 | 654 | /** 655 | * Evaluates if the specified Package URL has the same values up to, but excluding 656 | * the qualifier (querystring). 657 | * This includes equivalence of the scheme, type, namespace, name, and version, but excludes qualifier and subpath 658 | * from evaluation. 659 | * 660 | * @param purl the Package URL to evaluate, not {@code null} 661 | * @return true if equivalence passes, false if not 662 | * @since 1.4.0 663 | */ 664 | public boolean isCoordinatesEquals(final PackageURL purl) { 665 | return type.equals(purl.type) 666 | && Objects.equals(namespace, purl.namespace) 667 | && name.equals(purl.name) 668 | && Objects.equals(version, purl.version); 669 | } 670 | 671 | /** 672 | * Returns only the canonicalized coordinates of the Package URL which includes the type, namespace, name, 673 | * and version, and which omits the qualifier and subpath. 674 | * @return A canonicalized PackageURL String excluding the qualifier and subpath. 675 | * @since 1.4.0 676 | */ 677 | public String getCoordinates() { 678 | return canonicalize(true); 679 | } 680 | 681 | /** 682 | * Evaluates if the specified Package URL has the same canonical value. This method 683 | * canonicalizes the Package URLs being evaluated and performs an equivalence on the 684 | * canonical values. Canonical equivalence is especially useful for qualifiers, which 685 | * can be in any order, but have a predictable order in canonicalized form. 686 | * 687 | * @param purl the Package URL to evaluate, not {@code null} 688 | * @return true if equivalence passes, false if not 689 | * @since 1.2.0 690 | */ 691 | public boolean isCanonicalEquals(final PackageURL purl) { 692 | return this.canonicalize().equals(purl.canonicalize()); 693 | } 694 | 695 | private static boolean isEmpty(@Nullable String value) { 696 | return value == null || value.isEmpty(); 697 | } 698 | 699 | @Override 700 | public boolean equals(@Nullable Object o) { 701 | if (this == o) return true; 702 | if (o == null || getClass() != o.getClass()) return false; 703 | final PackageURL other = (PackageURL) o; 704 | return type.equals(other.type) 705 | && Objects.equals(namespace, other.namespace) 706 | && name.equals(other.name) 707 | && Objects.equals(version, other.version) 708 | && Objects.equals(qualifiers, other.qualifiers) 709 | && Objects.equals(subpath, other.subpath); 710 | } 711 | 712 | @Override 713 | public int hashCode() { 714 | return Objects.hash(type, namespace, name, version, qualifiers, subpath); 715 | } 716 | 717 | /** 718 | * Convenience constants that defines common Package-URL 'type's. 719 | */ 720 | public static final class StandardTypes { 721 | /** 722 | * Arch Linux and other users of the libalpm/pacman package manager. 723 | * 724 | * @since 2.0.0 725 | */ 726 | public static final String ALPM = "alpm"; 727 | /** 728 | * APK-based packages. 729 | * 730 | * @since 2.0.0 731 | */ 732 | public static final String APK = "apk"; 733 | /** 734 | * Bitbucket-based packages. 735 | */ 736 | public static final String BITBUCKET = "bitbucket"; 737 | /** 738 | * Bitnami-based packages. 739 | * 740 | * @since 2.0.0 741 | */ 742 | public static final String BITNAMI = "bitnami"; 743 | /** 744 | * Rust. 745 | * 746 | * @since 1.2.0 747 | */ 748 | public static final String CARGO = "cargo"; 749 | /** 750 | * CocoaPods. 751 | * 752 | * @since 2.0.0 753 | */ 754 | public static final String COCOAPODS = "cocoapods"; 755 | /** 756 | * Composer PHP packages. 757 | */ 758 | public static final String COMPOSER = "composer"; 759 | /** 760 | * Conan C/C++ packages. 761 | * 762 | * @since 2.0.0 763 | */ 764 | public static final String CONAN = "conan"; 765 | /** 766 | * Conda packages. 767 | * 768 | * @since 2.0.0 769 | */ 770 | public static final String CONDA = "conda"; 771 | /** 772 | * CPAN Perl packages. 773 | * 774 | * @since 2.0.0 775 | */ 776 | public static final String CPAN = "cpan"; 777 | /** 778 | * CRAN R packages. 779 | * 780 | * @since 2.0.0 781 | */ 782 | public static final String CRAN = "cran"; 783 | /** 784 | * Debian, Debian derivatives, and Ubuntu packages. 785 | * 786 | * @since 2.0.0 787 | */ 788 | public static final String DEB = "deb"; 789 | /** 790 | * Docker images. 791 | */ 792 | public static final String DOCKER = "docker"; 793 | /** 794 | * RubyGems. 795 | */ 796 | public static final String GEM = "gem"; 797 | /** 798 | * Plain, generic packages that do not fit anywhere else, such as for "upstream-from-distro" packages. 799 | */ 800 | public static final String GENERIC = "generic"; 801 | /** 802 | * GitHub-based packages. 803 | */ 804 | public static final String GITHUB = "github"; 805 | /** 806 | * Go packages. 807 | */ 808 | public static final String GOLANG = "golang"; 809 | /** 810 | * Haskell packages. 811 | */ 812 | public static final String HACKAGE = "hackage"; 813 | /** 814 | * Hex packages. 815 | * 816 | * @since 2.0.0 817 | */ 818 | public static final String HEX = "hex"; 819 | /** 820 | * Hugging Face ML models. 821 | * 822 | * @since 2.0.0 823 | */ 824 | public static final String HUGGINGFACE = "huggingface"; 825 | /** 826 | * Lua packages installed with LuaRocks. 827 | * 828 | * @since 2.0.0 829 | */ 830 | public static final String LUAROCKS = "luarocks"; 831 | /** 832 | * Maven JARs and related artifacts. 833 | */ 834 | public static final String MAVEN = "maven"; 835 | /** 836 | * MLflow ML models (Azure ML, Databricks, etc.). 837 | * 838 | * @since 2.0.0 839 | */ 840 | public static final String MLFLOW = "mlflow"; 841 | /** 842 | * Nixos packages 843 | * 844 | * @since 2.0.0 845 | */ 846 | public static final String NIX = "nix"; 847 | /** 848 | * Node NPM packages. 849 | */ 850 | public static final String NPM = "npm"; 851 | /** 852 | * NuGet .NET packages. 853 | */ 854 | public static final String NUGET = "nuget"; 855 | /** 856 | * All artifacts stored in registries that conform to the 857 | * OCI Distribution Specification, including 858 | * container images built by Docker and others. 859 | * 860 | * @since 2.0.0 861 | */ 862 | public static final String OCI = "oci"; 863 | /** 864 | * Dart and Flutter packages. 865 | * 866 | * @since 2.0.0 867 | */ 868 | public static final String PUB = "pub"; 869 | /** 870 | * Python packages. 871 | */ 872 | public static final String PYPI = "pypi"; 873 | /** 874 | * QNX packages. 875 | * 876 | * @since 2.0.0 877 | */ 878 | public static final String QPKG = "qpkg"; 879 | /** 880 | * RPMs. 881 | */ 882 | public static final String RPM = "rpm"; 883 | /** 884 | * ISO-IEC 19770-2 Software Identification (SWID) tags. 885 | * 886 | * @since 2.0.0 887 | */ 888 | public static final String SWID = "swid"; 889 | /** 890 | * Swift packages. 891 | * 892 | * @since 2.0.0 893 | */ 894 | public static final String SWIFT = "swift"; 895 | /** 896 | * Debian, Debian derivatives, and Ubuntu packages. 897 | * 898 | * @deprecated use {@link #DEB} instead 899 | */ 900 | @Deprecated 901 | public static final String DEBIAN = "deb"; 902 | /** 903 | * Nixos packages. 904 | * 905 | * @since 1.1.0 906 | * @deprecated use {@link #NIX} instead 907 | */ 908 | @Deprecated 909 | public static final String NIXPKGS = "nix"; 910 | 911 | private StandardTypes() {} 912 | } 913 | } 914 | -------------------------------------------------------------------------------- /src/main/java/com/github/packageurl/PackageURLBuilder.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | package com.github.packageurl; 23 | 24 | import static java.util.Objects.requireNonNull; 25 | 26 | import java.util.Collections; 27 | import java.util.Map; 28 | import java.util.Set; 29 | import java.util.TreeMap; 30 | import org.jspecify.annotations.Nullable; 31 | 32 | /** 33 | * A builder construct for Package-URL objects. 34 | * 35 | * @since 1.1.0 36 | */ 37 | public final class PackageURLBuilder { 38 | private @Nullable String type = null; 39 | private @Nullable String namespace = null; 40 | private @Nullable String name = null; 41 | private @Nullable String version = null; 42 | private @Nullable String subpath = null; 43 | private @Nullable Map qualifiers = null; 44 | 45 | private PackageURLBuilder() { 46 | // empty constructor for utility class 47 | } 48 | 49 | /** 50 | * Obtains a reference to a new builder object. 51 | * 52 | * @return a new builder object 53 | */ 54 | public static PackageURLBuilder aPackageURL() { 55 | return new PackageURLBuilder(); 56 | } 57 | 58 | private static PackageURLBuilder toBuilder(PackageURL packageURL) { 59 | return PackageURLBuilder.aPackageURL() 60 | .withType(packageURL.getType()) 61 | .withNamespace(packageURL.getNamespace()) 62 | .withName(packageURL.getName()) 63 | .withVersion(packageURL.getVersion()) 64 | .withQualifiers(packageURL.getQualifiers()) 65 | .withSubpath(packageURL.getSubpath()); 66 | } 67 | 68 | /** 69 | * Obtains a reference to a new builder object initialized with the existing {@link PackageURL} object. 70 | * 71 | * @param packageURL the existing Package URL object 72 | * @return a new builder object 73 | * @since 2.0.0 74 | */ 75 | public static PackageURLBuilder aPackageURL(final PackageURL packageURL) { 76 | return toBuilder(packageURL); 77 | } 78 | 79 | /** 80 | * Obtain a reference to a new builder object initialized with the existing Package URL string. 81 | * 82 | * @param purl the existing Package URL string 83 | * @return a new builder object 84 | * @throws MalformedPackageURLException if an error occurs while parsing the input 85 | * @since 2.0.0 86 | */ 87 | public static PackageURLBuilder aPackageURL(final String purl) throws MalformedPackageURLException { 88 | return toBuilder(new PackageURL(purl)); 89 | } 90 | 91 | /** 92 | * Adds the package URL type. 93 | * 94 | * @param type the package type, not {@code null} 95 | * @return a reference to the builder 96 | * @throws NullPointerException if the argument is {@code null} 97 | * @see PackageURL#getName() 98 | * @see com.github.packageurl.PackageURL.StandardTypes 99 | */ 100 | public PackageURLBuilder withType(final String type) { 101 | this.type = requireNonNull(type, "type"); 102 | return this; 103 | } 104 | 105 | /** 106 | * Adds the package namespace. 107 | * 108 | * @param namespace the package namespace or {@code null} 109 | * @return a reference to the builder 110 | * @see PackageURL#getNamespace() 111 | */ 112 | public PackageURLBuilder withNamespace(final @Nullable String namespace) { 113 | this.namespace = namespace; 114 | return this; 115 | } 116 | 117 | /** 118 | * Adds the package name. 119 | * 120 | * @param name the package name, not {@code null} 121 | * @return a reference to the builder 122 | * @throws NullPointerException if the argument is {@code null} 123 | * @see PackageURL#getName() 124 | */ 125 | public PackageURLBuilder withName(final String name) { 126 | this.name = requireNonNull(name, "name"); 127 | return this; 128 | } 129 | 130 | /** 131 | * Adds the package version. 132 | * 133 | * @param version the package version or {@code null} 134 | * @return a reference to the builder 135 | * @see PackageURL#getVersion() 136 | */ 137 | public PackageURLBuilder withVersion(final @Nullable String version) { 138 | this.version = version; 139 | return this; 140 | } 141 | 142 | /** 143 | * Adds the package subpath. 144 | * 145 | * @param subpath the package subpath or {@code null} 146 | * @return a reference to the builder 147 | * @see PackageURL#getSubpath() 148 | */ 149 | public PackageURLBuilder withSubpath(final @Nullable String subpath) { 150 | this.subpath = subpath; 151 | return this; 152 | } 153 | 154 | /** 155 | * Adds a package qualifier. 156 | *

157 | * If {@code value} is empty or {@code null}, the given qualifier is removed instead. 158 | *

159 | * 160 | * @param key the package qualifier key, not {@code null} 161 | * @param value the package qualifier value or {@code null} 162 | * @return a reference to the builder 163 | * @throws NullPointerException if {@code key} is {@code null} 164 | * @see PackageURL#getQualifiers() 165 | */ 166 | public PackageURLBuilder withQualifier(final String key, final @Nullable String value) { 167 | requireNonNull(key, "qualifier key can not be null"); 168 | if (value == null || value.isEmpty()) { 169 | if (qualifiers != null) { 170 | qualifiers.remove(key); 171 | } 172 | } else { 173 | if (qualifiers == null) { 174 | qualifiers = new TreeMap<>(); 175 | } 176 | qualifiers.put(requireNonNull(key, "qualifier key can not be null"), value); 177 | } 178 | return this; 179 | } 180 | 181 | /** 182 | * Adds the package qualifiers. 183 | * 184 | * @param qualifiers the package qualifiers, or {@code null} 185 | * @return a reference to the builder 186 | * @see PackageURL#getQualifiers() 187 | * @since 2.0.0 188 | */ 189 | public PackageURLBuilder withQualifiers(final @Nullable Map qualifiers) { 190 | if (qualifiers == null) { 191 | this.qualifiers = null; 192 | } else { 193 | if (this.qualifiers == null) { 194 | this.qualifiers = new TreeMap<>(qualifiers); 195 | } else { 196 | this.qualifiers.putAll(qualifiers); 197 | } 198 | } 199 | return this; 200 | } 201 | 202 | /** 203 | * Removes a package qualifier. This is a no-op if the qualifier is not present. 204 | * 205 | * @param key the package qualifier key to remove 206 | * @return a reference to the builder 207 | * @throws NullPointerException if {@code key} is {@code null} 208 | * @since 1.5.0 209 | */ 210 | public PackageURLBuilder withoutQualifier(final String key) { 211 | if (qualifiers != null) { 212 | qualifiers.remove(requireNonNull(key)); 213 | if (qualifiers.isEmpty()) { 214 | qualifiers = null; 215 | } 216 | } 217 | return this; 218 | } 219 | 220 | /** 221 | * Removes a package qualifier. This is a no-op if the qualifier is not present. 222 | * @param keys the package qualifier keys to remove 223 | * @return a reference to the builder 224 | * @since 2.0.0 225 | */ 226 | public PackageURLBuilder withoutQualifiers(final Set keys) { 227 | if (this.qualifiers != null) { 228 | keys.forEach(k -> this.qualifiers.remove(k)); 229 | if (this.qualifiers.isEmpty()) { 230 | this.qualifiers = null; 231 | } 232 | } 233 | return this; 234 | } 235 | 236 | /** 237 | * Removes all qualifiers, if any. 238 | * @return a reference to this builder. 239 | * @since 2.0.0 240 | */ 241 | public PackageURLBuilder withoutQualifiers() { 242 | qualifiers = null; 243 | return this; 244 | } 245 | 246 | /** 247 | * Removes all qualifiers, if any. 248 | * 249 | * @return a reference to this builder. 250 | * @deprecated use {@link #withoutQualifiers()} instead 251 | * @since 1.5.0 252 | */ 253 | @Deprecated 254 | public PackageURLBuilder withNoQualifiers() { 255 | return withoutQualifiers(); 256 | } 257 | 258 | /** 259 | * Returns current type value set in the builder. 260 | * 261 | * @return type set in this builder 262 | * @since 1.5.0 263 | */ 264 | public @Nullable String getType() { 265 | return type; 266 | } 267 | 268 | /** 269 | * Returns current namespace value set in the builder. 270 | * 271 | * @return namespace set in this builder 272 | * @since 1.5.0 273 | */ 274 | public @Nullable String getNamespace() { 275 | return namespace; 276 | } 277 | 278 | /** 279 | * Returns current name value set in the builder. 280 | * 281 | * @return name set in this builder 282 | * @since 1.5.0 283 | */ 284 | public @Nullable String getName() { 285 | return name; 286 | } 287 | 288 | /** 289 | * Returns current version value set in the builder. 290 | * 291 | * @return version set in this builder 292 | * @since 1.5.0 293 | */ 294 | public @Nullable String getVersion() { 295 | return version; 296 | } 297 | 298 | /** 299 | * Returns current subpath value set in the builder. 300 | * 301 | * @return subpath set in this builder 302 | * @since 1.5.0 303 | */ 304 | public @Nullable String getSubpath() { 305 | return subpath; 306 | } 307 | 308 | /** 309 | * Returns sorted map containing all qualifiers set in this builder. 310 | * An empty map is returned if no qualifiers are set 311 | * 312 | * @return all qualifiers set in this builder, or an empty map if none are set 313 | * @since 1.5.0 314 | */ 315 | public Map getQualifiers() { 316 | return qualifiers != null ? Collections.unmodifiableMap(qualifiers) : Collections.emptyMap(); 317 | } 318 | 319 | /** 320 | * Returns a currently set qualifier value set in the builder for the specified key. 321 | *s 322 | * @param key qualifier key 323 | * @return qualifier value or {@code null} if one is not set 324 | * @since 1.5.0 325 | */ 326 | public @Nullable String getQualifier(String key) { 327 | return qualifiers == null ? null : qualifiers.get(requireNonNull(key)); 328 | } 329 | 330 | /** 331 | * Builds the new PackageURL object. 332 | * 333 | * @return the new PackageURL object 334 | * @throws MalformedPackageURLException thrown if the type or name has not been specified or if a field fails validation 335 | */ 336 | public PackageURL build() throws MalformedPackageURLException { 337 | if (type == null) { 338 | throw new MalformedPackageURLException("type is required"); 339 | } 340 | if (name == null) { 341 | throw new MalformedPackageURLException("name is required"); 342 | } 343 | return new PackageURL(type, namespace, name, version, qualifiers, subpath); 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /src/main/java/com/github/packageurl/ValidationException.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | package com.github.packageurl; 23 | 24 | /** 25 | * Internal exception class intended to be used within validation contained in lambda expressions. 26 | * 27 | * @author Jeremy Long 28 | * @since 1.1.0 29 | */ 30 | public class ValidationException extends RuntimeException { 31 | 32 | private static final long serialVersionUID = 2045474478691037663L; 33 | 34 | /** 35 | * Constructs a {@code ValidationException}. 36 | * @param msg the error message 37 | */ 38 | public ValidationException(String msg) { 39 | super(msg); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/github/packageurl/internal/StringUtil.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | package com.github.packageurl.internal; 23 | 24 | import static java.lang.Byte.toUnsignedInt; 25 | 26 | import com.github.packageurl.ValidationException; 27 | import java.nio.charset.StandardCharsets; 28 | import org.jspecify.annotations.NonNull; 29 | 30 | /** 31 | * String utility for validation and encoding. 32 | * 33 | * @since 2.0.0 34 | */ 35 | public final class StringUtil { 36 | 37 | private static final byte PERCENT_CHAR = '%'; 38 | 39 | private static final boolean[] UNRESERVED_CHARS = new boolean[128]; 40 | 41 | static { 42 | for (char c = '0'; c <= '9'; c++) { 43 | UNRESERVED_CHARS[c] = true; 44 | } 45 | for (char c = 'A'; c <= 'Z'; c++) { 46 | UNRESERVED_CHARS[c] = true; 47 | } 48 | for (char c = 'a'; c <= 'z'; c++) { 49 | UNRESERVED_CHARS[c] = true; 50 | } 51 | UNRESERVED_CHARS['-'] = true; 52 | UNRESERVED_CHARS['.'] = true; 53 | UNRESERVED_CHARS['_'] = true; 54 | UNRESERVED_CHARS['~'] = true; 55 | } 56 | 57 | private StringUtil() { 58 | throw new AssertionError("Cannot instantiate StringUtil"); 59 | } 60 | 61 | /** 62 | * Returns the lower case version of the string. 63 | * 64 | * @param s the string to convert to lower case 65 | * @return the lower case version of the string 66 | * 67 | * @since 2.0.0 68 | */ 69 | public static @NonNull String toLowerCase(@NonNull String s) { 70 | int pos = indexOfFirstUpperCaseChar(s); 71 | 72 | if (pos == -1) { 73 | return s; 74 | } 75 | 76 | char[] chars = s.toCharArray(); 77 | 78 | for (int length = chars.length; pos < length; pos++) { 79 | chars[pos] = (char) toLowerCase(chars[pos]); 80 | } 81 | 82 | return new String(chars); 83 | } 84 | 85 | /** 86 | * Percent decodes the given string. 87 | * 88 | * @param source the string to decode 89 | * @return the percent decoded string 90 | * 91 | * @since 2.0.0 92 | */ 93 | public static @NonNull String percentDecode(@NonNull final String source) { 94 | if (source.indexOf(PERCENT_CHAR) == -1) { 95 | return source; 96 | } 97 | 98 | byte[] bytes = source.getBytes(StandardCharsets.UTF_8); 99 | 100 | int readPos = indexOfFirstPercentChar(bytes); 101 | int writePos = readPos; 102 | int length = bytes.length; 103 | while (readPos < length) { 104 | byte b = bytes[readPos]; 105 | if (b == PERCENT_CHAR) { 106 | bytes[writePos++] = percentDecode(bytes, readPos++); 107 | readPos += 2; 108 | } else { 109 | bytes[writePos++] = bytes[readPos++]; 110 | } 111 | } 112 | 113 | return new String(bytes, 0, writePos, StandardCharsets.UTF_8); 114 | } 115 | 116 | /** 117 | * Percent encodes the given string. 118 | * 119 | * @param source the string to encode 120 | * @return the percent encoded string 121 | * 122 | * @since 2.0.0 123 | */ 124 | public static @NonNull String percentEncode(@NonNull final String source) { 125 | if (!shouldEncode(source)) { 126 | return source; 127 | } 128 | 129 | byte[] src = source.getBytes(StandardCharsets.UTF_8); 130 | byte[] dest = new byte[3 * src.length]; 131 | 132 | int writePos = 0; 133 | for (byte b : src) { 134 | if (shouldEncode(toUnsignedInt(b))) { 135 | dest[writePos++] = PERCENT_CHAR; 136 | dest[writePos++] = toHexDigit(b >> 4); 137 | dest[writePos++] = toHexDigit(b); 138 | } else { 139 | dest[writePos++] = b; 140 | } 141 | } 142 | 143 | return new String(dest, 0, writePos, StandardCharsets.UTF_8); 144 | } 145 | 146 | /** 147 | * Determines if the character is a digit. 148 | * 149 | * @param c the character to check 150 | * @return true if the character is a digit; otherwise, false 151 | * 152 | * @since 2.0.0 153 | */ 154 | public static boolean isDigit(int c) { 155 | return (c >= '0' && c <= '9'); 156 | } 157 | 158 | /** 159 | * Determines if the character is valid for the package-url type. 160 | * 161 | * @param c the character to check 162 | * @return true if the character is valid for the package-url type; otherwise, false 163 | * 164 | * @since 2.0.0 165 | */ 166 | public static boolean isValidCharForType(int c) { 167 | return (isAlphaNumeric(c) || c == '.' || c == '+' || c == '-'); 168 | } 169 | 170 | /** 171 | * Determines if the character is valid for the package-url qualifier key. 172 | * 173 | * @param c the character to check 174 | * @return true if the character is valid for the package-url qualifier key; otherwise, false 175 | * 176 | * @since 2.0.0 177 | */ 178 | public static boolean isValidCharForKey(int c) { 179 | return (isAlphaNumeric(c) || c == '.' || c == '_' || c == '-'); 180 | } 181 | 182 | private static byte toHexDigit(int b) { 183 | return (byte) Character.toUpperCase(Character.forDigit(b & 0xF, 16)); 184 | } 185 | 186 | /** 187 | * Returns {@code true} if the character is in the unreserved RFC 3986 set. 188 | *

189 | * Warning: Profiling shows that the performance of {@link #percentEncode} relies heavily on this method. 190 | * Modify with care. 191 | *

192 | * @param c non-negative integer. 193 | */ 194 | private static boolean isUnreserved(int c) { 195 | return c < 128 && UNRESERVED_CHARS[c]; 196 | } 197 | 198 | /** 199 | * @param c non-negative integer 200 | */ 201 | private static boolean shouldEncode(int c) { 202 | return !isUnreserved(c); 203 | } 204 | 205 | private static boolean shouldEncode(String s) { 206 | for (int i = 0, length = s.length(); i < length; i++) { 207 | if (shouldEncode(s.charAt(i))) { 208 | return true; 209 | } 210 | } 211 | return false; 212 | } 213 | 214 | private static boolean isAlpha(int c) { 215 | return (isLowerCase(c) || isUpperCase(c)); 216 | } 217 | 218 | private static boolean isAlphaNumeric(int c) { 219 | return (isDigit(c) || isAlpha(c)); 220 | } 221 | 222 | private static boolean isUpperCase(int c) { 223 | return 'A' <= c && c <= 'Z'; 224 | } 225 | 226 | private static boolean isLowerCase(int c) { 227 | return (c >= 'a' && c <= 'z'); 228 | } 229 | 230 | private static int toLowerCase(int c) { 231 | return isUpperCase(c) ? (c ^ 0x20) : c; 232 | } 233 | 234 | private static int indexOfFirstUpperCaseChar(String s) { 235 | for (int i = 0, length = s.length(); i < length; i++) { 236 | if (isUpperCase(s.charAt(i))) { 237 | return i; 238 | } 239 | } 240 | return -1; 241 | } 242 | 243 | private static int indexOfFirstPercentChar(final byte[] bytes) { 244 | for (int i = 0, length = bytes.length; i < length; i++) { 245 | if (bytes[i] == PERCENT_CHAR) { 246 | return i; 247 | } 248 | } 249 | return -1; 250 | } 251 | 252 | private static byte percentDecode(final byte[] bytes, final int start) { 253 | if (start + 2 >= bytes.length) { 254 | throw new ValidationException("Incomplete percent encoding at offset " + start + " with value '" 255 | + new String(bytes, start, bytes.length - start, StandardCharsets.UTF_8) + "'"); 256 | } 257 | 258 | int pos1 = start + 1; 259 | byte b1 = bytes[pos1]; 260 | int c1 = Character.digit(b1, 16); 261 | 262 | if (c1 == -1) { 263 | throw new ValidationException( 264 | "Invalid percent encoding char 1 at offset " + pos1 + " with value '" + ((char) b1) + "'"); 265 | } 266 | 267 | int pos2 = pos1 + 1; 268 | byte b2 = bytes[pos2]; 269 | int c2 = Character.digit(bytes[pos2], 16); 270 | 271 | if (c2 == -1) { 272 | throw new ValidationException( 273 | "Invalid percent encoding char 2 at offset " + pos2 + " with value '" + ((char) b2) + "'"); 274 | } 275 | 276 | return ((byte) ((c1 << 4) + c2)); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/main/java/com/github/packageurl/internal/package-info.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | 23 | /** 24 | * This package contains utility classes used by the PackageURL library. 25 | */ 26 | @NullMarked 27 | package com.github.packageurl.internal; 28 | 29 | import org.jspecify.annotations.NullMarked; 30 | -------------------------------------------------------------------------------- /src/main/java/com/github/packageurl/package-info.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | 23 | /** 24 | *

Java implementation of the Package-URL Specification.

25 | *

https://github.com/package-url/purl-spec

26 | */ 27 | @NullMarked 28 | @Export 29 | package com.github.packageurl; 30 | 31 | import org.jspecify.annotations.NullMarked; 32 | import org.osgi.annotation.bundle.Export; 33 | -------------------------------------------------------------------------------- /src/main/java/com/github/packageurl/validator/PackageURL.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | package com.github.packageurl.validator; 23 | 24 | import jakarta.validation.Constraint; 25 | import jakarta.validation.Payload; 26 | import java.lang.annotation.ElementType; 27 | import java.lang.annotation.Retention; 28 | import java.lang.annotation.RetentionPolicy; 29 | import java.lang.annotation.Target; 30 | 31 | /** 32 | * Annotation that can be applied to a String field within a class. The PackageURL annotation instructs 33 | * the JSR-303 compliant validator to validate the field to ensure it meets the Package URL specification. 34 | * @since 1.3.0 35 | */ 36 | @Target({ElementType.FIELD}) 37 | @Retention(RetentionPolicy.RUNTIME) 38 | @Constraint(validatedBy = PackageURLConstraintValidator.class) 39 | public @interface PackageURL { 40 | /** 41 | * Gets the error message. 42 | * 43 | * @return the error message 44 | */ 45 | String message() default 46 | "The Package URL (purl) must be a valid URI and conform to https://github.com/package-url/purl-spec"; 47 | 48 | /** 49 | * Gets the validation groups. 50 | * 51 | * @return the validation groups 52 | */ 53 | Class[] groups() default {}; 54 | 55 | /** 56 | * Gets the payload for the constraint. 57 | * 58 | * @return the payload for the constraint 59 | */ 60 | Class[] payload() default {}; 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/com/github/packageurl/validator/PackageURLConstraintValidator.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | package com.github.packageurl.validator; 23 | 24 | import com.github.packageurl.MalformedPackageURLException; 25 | import jakarta.validation.ConstraintValidator; 26 | import jakarta.validation.ConstraintValidatorContext; 27 | import org.jspecify.annotations.Nullable; 28 | 29 | /** 30 | * A JSR-303 compliant validator that validates String fields conform to the Package URL specification. 31 | * @since 1.3.0 32 | */ 33 | public class PackageURLConstraintValidator implements ConstraintValidator { 34 | 35 | @Override 36 | public boolean isValid(@Nullable String value, ConstraintValidatorContext context) { 37 | try { 38 | if (value != null) { 39 | new com.github.packageurl.PackageURL(value); 40 | } 41 | return true; 42 | } catch (MalformedPackageURLException e) { 43 | return false; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/github/packageurl/validator/package-info.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | 23 | /** 24 | * This package contains a validator for Jakarta Validation. 25 | */ 26 | @NullMarked 27 | @Export 28 | package com.github.packageurl.validator; 29 | 30 | import org.jspecify.annotations.NullMarked; 31 | import org.osgi.annotation.bundle.Export; 32 | -------------------------------------------------------------------------------- /src/test/java/com/github/packageurl/PackageURLBuilderTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | package com.github.packageurl; 23 | 24 | import static org.junit.jupiter.api.Assertions.assertEquals; 25 | import static org.junit.jupiter.api.Assertions.assertThrows; 26 | import static org.junit.jupiter.api.Assertions.assertThrowsExactly; 27 | import static org.junit.jupiter.api.Assertions.assertTrue; 28 | 29 | import java.io.IOException; 30 | import java.util.Collections; 31 | import java.util.HashMap; 32 | import java.util.Map; 33 | import java.util.stream.Stream; 34 | import org.jspecify.annotations.Nullable; 35 | import org.junit.jupiter.api.Test; 36 | import org.junit.jupiter.params.ParameterizedTest; 37 | import org.junit.jupiter.params.provider.Arguments; 38 | import org.junit.jupiter.params.provider.MethodSource; 39 | 40 | class PackageURLBuilderTest { 41 | 42 | static Stream packageURLBuilder() throws IOException { 43 | return PurlParameters.getTestDataFromFiles("test-suite-data.json", "components-constructor-only.json"); 44 | } 45 | 46 | @ParameterizedTest(name = "{0}: {1}") 47 | @MethodSource 48 | void packageURLBuilder( 49 | String description, 50 | @Nullable String ignoredPurl, 51 | PurlParameters parameters, 52 | String canonicalPurl, 53 | boolean invalid) 54 | throws MalformedPackageURLException { 55 | if (parameters.getType() == null || parameters.getName() == null) { 56 | assertTrue(invalid, "valid test case with type or name `null`"); 57 | return; 58 | } 59 | PackageURLBuilder builder = 60 | PackageURLBuilder.aPackageURL().withType(parameters.getType()).withName(parameters.getName()); 61 | String namespace = parameters.getNamespace(); 62 | if (namespace != null) { 63 | builder.withNamespace(namespace); 64 | } 65 | String version = parameters.getVersion(); 66 | if (version != null) { 67 | builder.withVersion(version); 68 | } 69 | parameters.getQualifiers().forEach(builder::withQualifier); 70 | String subpath = parameters.getSubpath(); 71 | if (subpath != null) { 72 | builder.withSubpath(subpath); 73 | } 74 | if (invalid) { 75 | assertThrows(MalformedPackageURLException.class, builder::build, "Build should fail due to " + description); 76 | } else { 77 | assertEquals(parameters.getType(), builder.getType(), "type"); 78 | assertEquals(parameters.getNamespace(), builder.getNamespace(), "namespace"); 79 | assertEquals(parameters.getName(), builder.getName(), "name"); 80 | assertEquals(parameters.getVersion(), builder.getVersion(), "version"); 81 | assertEquals(parameters.getQualifiers(), builder.getQualifiers(), "qualifiers"); 82 | assertEquals(parameters.getSubpath(), builder.getSubpath(), "subpath"); 83 | PackageURL actual = builder.build(); 84 | assertEquals(canonicalPurl, actual.canonicalize(), "canonical PURL"); 85 | } 86 | } 87 | 88 | @Test 89 | void packageURLBuilderException1() throws MalformedPackageURLException { 90 | PackageURL purl = PackageURLBuilder.aPackageURL() 91 | .withType("type") 92 | .withName("name") 93 | .withQualifier("key", "") 94 | .build(); 95 | assertEquals(0, purl.getQualifiers().size(), "qualifier count"); 96 | } 97 | 98 | @Test 99 | void packageURLBuilderException1Null() throws MalformedPackageURLException { 100 | PackageURL purl = PackageURLBuilder.aPackageURL() 101 | .withType("type") 102 | .withName("name") 103 | .withQualifier("key", null) 104 | .build(); 105 | assertEquals(0, purl.getQualifiers().size(), "qualifier count"); 106 | } 107 | 108 | @Test 109 | void packageURLBuilderException2() { 110 | assertThrowsExactly( 111 | MalformedPackageURLException.class, 112 | () -> PackageURLBuilder.aPackageURL() 113 | .withType("type") 114 | .withNamespace("invalid//namespace") 115 | .withName("name") 116 | .build(), 117 | "Build should fail due to invalid namespace"); 118 | } 119 | 120 | @Test 121 | void packageURLBuilderException3() { 122 | assertThrowsExactly( 123 | MalformedPackageURLException.class, 124 | () -> PackageURLBuilder.aPackageURL() 125 | .withType("typ^e") 126 | .withSubpath("invalid/name%2Fspace") 127 | .withName("name") 128 | .build(), 129 | "Build should fail due to invalid subpath"); 130 | } 131 | 132 | @Test 133 | void packageURLBuilderException4() { 134 | assertThrowsExactly( 135 | MalformedPackageURLException.class, 136 | () -> PackageURLBuilder.aPackageURL() 137 | .withType("0_type") 138 | .withName("name") 139 | .build(), 140 | "Build should fail due to invalid type"); 141 | } 142 | 143 | @Test 144 | void packageURLBuilderException5() { 145 | assertThrowsExactly( 146 | MalformedPackageURLException.class, 147 | () -> PackageURLBuilder.aPackageURL() 148 | .withType("ype") 149 | .withName("name") 150 | .withQualifier("0_key", "value") 151 | .build(), 152 | "Build should fail due to invalid qualifier key"); 153 | } 154 | 155 | @Test 156 | void packageURLBuilderException6() { 157 | assertThrowsExactly( 158 | MalformedPackageURLException.class, 159 | () -> PackageURLBuilder.aPackageURL() 160 | .withType("ype") 161 | .withName("name") 162 | .withQualifier("", "value") 163 | .build(), 164 | "Build should fail due to invalid qualifier key"); 165 | } 166 | 167 | @Test 168 | void editBuilder1() throws MalformedPackageURLException { 169 | PackageURL p = new PackageURL("pkg:generic/namespace/name@1.0.0?k=v#s"); 170 | PackageURLBuilder b = PackageURLBuilder.aPackageURL(p); 171 | assertBuilderMatch(p, b); 172 | 173 | assertBuilderMatch(new PackageURL("pkg:generic/namespace/name@1.0.0#s"), b.withoutQualifiers()); 174 | b.withType("maven") 175 | .withNamespace("org.junit") 176 | .withName("junit5") 177 | .withVersion("3.1.2") 178 | .withSubpath("sub") 179 | .withQualifier("repo", "maven") 180 | .withQualifier("dark", "matter") 181 | .withQualifier("ping", "pong") 182 | .withoutQualifier("dark"); 183 | 184 | assertBuilderMatch(new PackageURL("pkg:maven/org.junit/junit5@3.1.2?repo=maven&ping=pong#sub"), b); 185 | } 186 | 187 | @Test 188 | void qualifiers() throws MalformedPackageURLException { 189 | Map qualifiers = new HashMap<>(); 190 | qualifiers.put("key2", "value2"); 191 | Map qualifiers2 = new HashMap<>(); 192 | qualifiers.put("key3", "value3"); 193 | PackageURL purl = PackageURLBuilder.aPackageURL() 194 | .withType(PackageURL.StandardTypes.GENERIC) 195 | .withNamespace("") 196 | .withName("name") 197 | .withVersion("version") 198 | .withQualifier("key", "value") 199 | .withQualifier("next", "value") 200 | .withQualifiers(qualifiers) 201 | .withQualifier("key4", "value4") 202 | .withQualifiers(qualifiers2) 203 | .withSubpath("") 204 | .withoutQualifiers(Collections.singleton("key4")) 205 | .build(); 206 | 207 | assertEquals("pkg:generic/name@version?key=value&key2=value2&key3=value3&next=value", purl.toString()); 208 | } 209 | 210 | @Test 211 | void fromExistingPurl() throws MalformedPackageURLException { 212 | String purl = "pkg:generic/namespace/name@1.0.0?k=v#s"; 213 | PackageURL p = new PackageURL(purl); 214 | PackageURL purl2 = PackageURLBuilder.aPackageURL(p).build(); 215 | PackageURL purl3 = PackageURLBuilder.aPackageURL(purl).build(); 216 | assertEquals(p, purl2); 217 | assertEquals(purl2, purl3); 218 | } 219 | 220 | private void assertBuilderMatch(PackageURL expected, PackageURLBuilder actual) throws MalformedPackageURLException { 221 | 222 | assertEquals(expected.toString(), actual.build().toString()); 223 | assertEquals(expected.getType(), actual.getType()); 224 | assertEquals(expected.getNamespace(), actual.getNamespace()); 225 | assertEquals(expected.getName(), actual.getName()); 226 | assertEquals(expected.getVersion(), actual.getVersion()); 227 | assertEquals(expected.getSubpath(), actual.getSubpath()); 228 | 229 | Map eQualifiers = expected.getQualifiers(); 230 | Map aQualifiers = actual.getQualifiers(); 231 | 232 | assertEquals(eQualifiers, aQualifiers); 233 | 234 | eQualifiers.forEach((k, v) -> assertEquals(v, actual.getQualifier(k))); 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/test/java/com/github/packageurl/PackageURLTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | package com.github.packageurl; 23 | 24 | import static org.junit.jupiter.api.Assertions.assertEquals; 25 | import static org.junit.jupiter.api.Assertions.assertNotNull; 26 | import static org.junit.jupiter.api.Assertions.assertThrows; 27 | import static org.junit.jupiter.api.Assertions.assertThrowsExactly; 28 | import static org.junit.jupiter.api.Assertions.assertTrue; 29 | 30 | import java.io.IOException; 31 | import java.util.Locale; 32 | import java.util.stream.Stream; 33 | import org.jspecify.annotations.Nullable; 34 | import org.junit.jupiter.api.AfterAll; 35 | import org.junit.jupiter.api.BeforeAll; 36 | import org.junit.jupiter.api.DisplayName; 37 | import org.junit.jupiter.api.Test; 38 | import org.junit.jupiter.params.ParameterizedTest; 39 | import org.junit.jupiter.params.provider.Arguments; 40 | import org.junit.jupiter.params.provider.MethodSource; 41 | 42 | /** 43 | * Test cases for PackageURL parsing 44 | *

45 | * Original test cases retrieved from: 46 | * https://raw.githubusercontent.com/package-url/purl-spec/master/test-suite-data.json 47 | * 48 | * @author Steve Springett 49 | */ 50 | class PackageURLTest { 51 | private static final Locale DEFAULT_LOCALE = Locale.getDefault(); 52 | 53 | @BeforeAll 54 | static void setup() { 55 | Locale.setDefault(new Locale("tr")); 56 | } 57 | 58 | @AfterAll 59 | static void resetLocale() { 60 | Locale.setDefault(DEFAULT_LOCALE); 61 | } 62 | 63 | @Test 64 | void validPercentEncoding() throws MalformedPackageURLException { 65 | PackageURL purl = new PackageURL("maven", "com.google.summit", "summit-ast", "2.2.0\n", null, null); 66 | assertEquals("pkg:maven/com.google.summit/summit-ast@2.2.0%0A", purl.toString()); 67 | PackageURL purl2 = 68 | new PackageURL("pkg:nuget/%D0%9Cicros%D0%BEft.%D0%95ntit%D1%83Fram%D0%B5work%D0%A1%D0%BEr%D0%B5"); 69 | assertEquals("Мicrosоft.ЕntitуFramеworkСоrе", purl2.getName()); 70 | assertEquals( 71 | "pkg:nuget/%D0%9Cicros%D0%BEft.%D0%95ntit%D1%83Fram%D0%B5work%D0%A1%D0%BEr%D0%B5", purl2.toString()); 72 | } 73 | 74 | @Test 75 | void invalidPercentEncoding() { 76 | assertThrowsExactly( 77 | MalformedPackageURLException.class, 78 | () -> new PackageURL("pkg:maven/com.google.summit/summit-ast@2.2.0%")); 79 | assertThrowsExactly( 80 | MalformedPackageURLException.class, 81 | () -> new PackageURL("pkg:maven/com.google.summit/summit-ast@2.2.0%0")); 82 | } 83 | 84 | static Stream constructorParsing() throws IOException { 85 | return PurlParameters.getTestDataFromFiles( 86 | "test-suite-data.json", "custom-suite.json", "string-constructor-only.json"); 87 | } 88 | 89 | @SuppressWarnings({"ConstantConditions", "unused"}) 90 | @DisplayName("Test constructor parsing") 91 | @ParameterizedTest(name = "{0}: ''{1}''") 92 | @MethodSource 93 | void constructorParsing( 94 | String description, 95 | @Nullable String purlString, 96 | PurlParameters parameters, 97 | @Nullable String canonicalPurl, 98 | boolean invalid) 99 | throws Exception { 100 | if (invalid) { 101 | assertThrows( 102 | getExpectedException(purlString), 103 | () -> new PackageURL(purlString), 104 | "Build should fail due to " + description); 105 | } else { 106 | PackageURL purl = new PackageURL(purlString); 107 | assertPurlEquals(parameters, purl); 108 | assertEquals(canonicalPurl, purl.canonicalize(), "canonical PURL"); 109 | } 110 | } 111 | 112 | static Stream constructorParameters() throws IOException { 113 | return PurlParameters.getTestDataFromFiles( 114 | "test-suite-data.json", "custom-suite.json", "components-constructor-only.json"); 115 | } 116 | 117 | @SuppressWarnings({"ConstantConditions", "unused"}) 118 | @DisplayName("Test constructor parameters") 119 | @ParameterizedTest(name = "{0}: {2}") 120 | @MethodSource 121 | void constructorParameters( 122 | String description, 123 | @Nullable String purlString, 124 | PurlParameters parameters, 125 | @Nullable String canonicalPurl, 126 | boolean invalid) 127 | throws Exception { 128 | if (invalid) { 129 | assertThrows( 130 | getExpectedException(parameters), 131 | () -> new PackageURL( 132 | parameters.getType(), 133 | parameters.getNamespace(), 134 | parameters.getName(), 135 | parameters.getVersion(), 136 | parameters.getQualifiers(), 137 | parameters.getSubpath()), 138 | "Build should fail due to " + description); 139 | } else { 140 | PackageURL purl = new PackageURL( 141 | parameters.getType(), 142 | parameters.getNamespace(), 143 | parameters.getName(), 144 | parameters.getVersion(), 145 | parameters.getQualifiers(), 146 | parameters.getSubpath()); 147 | assertPurlEquals(parameters, purl); 148 | assertEquals(canonicalPurl, purl.canonicalize(), "canonical PURL"); 149 | } 150 | } 151 | 152 | static Stream constructorTypeNameSpace() throws IOException { 153 | return PurlParameters.getTestDataFromFiles("type-namespace-constructor-only.json"); 154 | } 155 | 156 | @SuppressWarnings({"ConstantConditions", "unused"}) 157 | @ParameterizedTest 158 | @MethodSource 159 | void constructorTypeNameSpace( 160 | String description, 161 | @Nullable String purlString, 162 | PurlParameters parameters, 163 | @Nullable String canonicalPurl, 164 | boolean invalid) 165 | throws Exception { 166 | if (invalid) { 167 | assertThrows( 168 | getExpectedException(parameters), () -> new PackageURL(parameters.getType(), parameters.getName())); 169 | } else { 170 | PackageURL purl = new PackageURL(parameters.getType(), parameters.getName()); 171 | assertPurlEquals(parameters, purl); 172 | assertEquals(canonicalPurl, purl.canonicalize(), "canonical PURL"); 173 | } 174 | } 175 | 176 | private static void assertPurlEquals(PurlParameters expected, PackageURL actual) { 177 | assertEquals("pkg", actual.getScheme(), "scheme"); 178 | assertEquals(expected.getType(), actual.getType(), "type"); 179 | assertEquals(emptyToNull(expected.getNamespace()), actual.getNamespace(), "namespace"); 180 | assertEquals(expected.getName(), actual.getName(), "name"); 181 | assertEquals(emptyToNull(expected.getVersion()), actual.getVersion(), "version"); 182 | assertEquals(emptyToNull(expected.getSubpath()), actual.getSubpath(), "subpath"); 183 | assertNotNull(actual.getQualifiers(), "qualifiers"); 184 | assertEquals(actual.getQualifiers(), expected.getQualifiers(), "qualifiers"); 185 | } 186 | 187 | private static Class getExpectedException(PurlParameters json) { 188 | return json.getType() == null || json.getName() == null 189 | ? NullPointerException.class 190 | : MalformedPackageURLException.class; 191 | } 192 | 193 | private static Class getExpectedException(@Nullable String purl) { 194 | return purl == null ? NullPointerException.class : MalformedPackageURLException.class; 195 | } 196 | 197 | private static @Nullable String emptyToNull(@Nullable String value) { 198 | return value == null || value.isEmpty() ? null : value; 199 | } 200 | 201 | @SuppressWarnings("java:S5961") 202 | @Test 203 | void standardTypes() { 204 | assertEquals("alpm", PackageURL.StandardTypes.ALPM); 205 | assertEquals("apk", PackageURL.StandardTypes.APK); 206 | assertEquals("bitbucket", PackageURL.StandardTypes.BITBUCKET); 207 | assertEquals("bitnami", PackageURL.StandardTypes.BITNAMI); 208 | assertEquals("cocoapods", PackageURL.StandardTypes.COCOAPODS); 209 | assertEquals("cargo", PackageURL.StandardTypes.CARGO); 210 | assertEquals("composer", PackageURL.StandardTypes.COMPOSER); 211 | assertEquals("conan", PackageURL.StandardTypes.CONAN); 212 | assertEquals("conda", PackageURL.StandardTypes.CONDA); 213 | assertEquals("cpan", PackageURL.StandardTypes.CPAN); 214 | assertEquals("cran", PackageURL.StandardTypes.CRAN); 215 | assertEquals("deb", PackageURL.StandardTypes.DEB); 216 | assertEquals("docker", PackageURL.StandardTypes.DOCKER); 217 | assertEquals("gem", PackageURL.StandardTypes.GEM); 218 | assertEquals("generic", PackageURL.StandardTypes.GENERIC); 219 | assertEquals("github", PackageURL.StandardTypes.GITHUB); 220 | assertEquals("golang", PackageURL.StandardTypes.GOLANG); 221 | assertEquals("hackage", PackageURL.StandardTypes.HACKAGE); 222 | assertEquals("hex", PackageURL.StandardTypes.HEX); 223 | assertEquals("huggingface", PackageURL.StandardTypes.HUGGINGFACE); 224 | assertEquals("luarocks", PackageURL.StandardTypes.LUAROCKS); 225 | assertEquals("maven", PackageURL.StandardTypes.MAVEN); 226 | assertEquals("mlflow", PackageURL.StandardTypes.MLFLOW); 227 | assertEquals("npm", PackageURL.StandardTypes.NPM); 228 | assertEquals("nuget", PackageURL.StandardTypes.NUGET); 229 | assertEquals("qpkg", PackageURL.StandardTypes.QPKG); 230 | assertEquals("oci", PackageURL.StandardTypes.OCI); 231 | assertEquals("pub", PackageURL.StandardTypes.PUB); 232 | assertEquals("pypi", PackageURL.StandardTypes.PYPI); 233 | assertEquals("rpm", PackageURL.StandardTypes.RPM); 234 | assertEquals("swid", PackageURL.StandardTypes.SWID); 235 | assertEquals("swift", PackageURL.StandardTypes.SWIFT); 236 | } 237 | 238 | @Test 239 | void coordinatesEquals() throws Exception { 240 | PackageURL p1 = new PackageURL("pkg:generic/acme/example-component@1.0.0?key1=value1&key2=value2"); 241 | PackageURL p2 = new PackageURL("pkg:generic/acme/example-component@1.0.0"); 242 | assertTrue(p1.isCoordinatesEquals(p2)); 243 | } 244 | 245 | @Test 246 | void canonicalEquals() throws Exception { 247 | PackageURL p1 = new PackageURL("pkg:generic/acme/example-component@1.0.0?key1=value1&key2=value2"); 248 | PackageURL p2 = new PackageURL("pkg:generic/acme/example-component@1.0.0?key2=value2&key1=value1"); 249 | assertTrue(p1.isCanonicalEquals(p2)); 250 | } 251 | 252 | @Test 253 | void getCoordinates() throws Exception { 254 | PackageURL purl = new PackageURL("pkg:generic/acme/example-component@1.0.0?key1=value1&key2=value2"); 255 | assertEquals("pkg:generic/acme/example-component@1.0.0", purl.getCoordinates()); 256 | } 257 | 258 | @Test 259 | void getCoordinatesNoCacheIssue89() throws Exception { 260 | PackageURL purl = new PackageURL("pkg:generic/acme/example-component@1.0.0?key1=value1&key2=value2"); 261 | purl.canonicalize(); 262 | assertEquals("pkg:generic/acme/example-component@1.0.0", purl.getCoordinates()); 263 | } 264 | 265 | @Test 266 | void npmCaseSensitive() throws Exception { 267 | // e.g. https://www.npmjs.com/package/base64/v/1.0.0 268 | PackageURL base64Lowercase = new PackageURL("pkg:npm/base64@1.0.0"); 269 | assertEquals("npm", base64Lowercase.getType()); 270 | assertEquals("base64", base64Lowercase.getName()); 271 | assertEquals("1.0.0", base64Lowercase.getVersion()); 272 | 273 | // e.g. https://www.npmjs.com/package/Base64/v/1.0.0 274 | PackageURL base64Uppercase = new PackageURL("pkg:npm/Base64@1.0.0"); 275 | assertEquals("npm", base64Uppercase.getType()); 276 | assertEquals("Base64", base64Uppercase.getName()); 277 | assertEquals("1.0.0", base64Uppercase.getVersion()); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/test/java/com/github/packageurl/PurlParameters.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | package com.github.packageurl; 23 | 24 | import static org.junit.jupiter.api.Assertions.assertNotNull; 25 | 26 | import java.io.IOException; 27 | import java.io.InputStream; 28 | import java.util.Collections; 29 | import java.util.HashMap; 30 | import java.util.Map; 31 | import java.util.Objects; 32 | import java.util.stream.IntStream; 33 | import java.util.stream.Stream; 34 | import org.json.JSONArray; 35 | import org.json.JSONObject; 36 | import org.json.JSONTokener; 37 | import org.jspecify.annotations.Nullable; 38 | import org.junit.jupiter.params.provider.Arguments; 39 | 40 | class PurlParameters { 41 | static Stream getTestDataFromFiles(String... names) throws IOException { 42 | Stream result = Stream.empty(); 43 | for (String name : names) { 44 | try (InputStream is = PackageURLTest.class.getResourceAsStream("/" + name)) { 45 | assertNotNull(is); 46 | JSONArray jsonArray = new JSONArray(new JSONTokener(is)); 47 | result = Stream.concat( 48 | result, 49 | IntStream.range(0, jsonArray.length()) 50 | .mapToObj(jsonArray::getJSONObject) 51 | .map(PurlParameters::createTestDefinition)); 52 | } 53 | } 54 | return result; 55 | } 56 | 57 | /** 58 | * Returns test arguments: 59 | *

    60 | *
  1. Serialized PURL
  2. 61 | *
  3. PURL split into components
  4. 62 | *
  5. Canonical serialized PURL
  6. 63 | *
  7. {@code true} if the PURL is not valid
  8. 64 | *
65 | */ 66 | private static Arguments createTestDefinition(JSONObject testDefinition) { 67 | return Arguments.of( 68 | testDefinition.getString("description"), 69 | testDefinition.optString("purl"), 70 | new PurlParameters( 71 | testDefinition.optString("type", null), 72 | testDefinition.optString("namespace", null), 73 | testDefinition.optString("name", null), 74 | testDefinition.optString("version", null), 75 | testDefinition.optJSONObject("qualifiers"), 76 | testDefinition.optString("subpath", null)), 77 | testDefinition.optString("canonical_purl"), 78 | testDefinition.getBoolean("is_invalid")); 79 | } 80 | 81 | private final @Nullable String type; 82 | private final @Nullable String namespace; 83 | private final @Nullable String name; 84 | private final @Nullable String version; 85 | private final Map qualifiers; 86 | private final @Nullable String subpath; 87 | 88 | private PurlParameters( 89 | @Nullable String type, 90 | @Nullable String namespace, 91 | @Nullable String name, 92 | @Nullable String version, 93 | @Nullable JSONObject qualifiers, 94 | @Nullable String subpath) { 95 | this.type = type; 96 | this.namespace = namespace; 97 | this.name = name; 98 | this.version = version; 99 | if (qualifiers != null) { 100 | this.qualifiers = qualifiers.toMap().entrySet().stream() 101 | .collect( 102 | HashMap::new, 103 | (m, e) -> m.put(e.getKey(), Objects.toString(e.getValue(), null)), 104 | HashMap::putAll); 105 | } else { 106 | this.qualifiers = Collections.emptyMap(); 107 | } 108 | this.subpath = subpath; 109 | } 110 | 111 | public @Nullable String getType() { 112 | return type; 113 | } 114 | 115 | public @Nullable String getNamespace() { 116 | return namespace; 117 | } 118 | 119 | public @Nullable String getName() { 120 | return name; 121 | } 122 | 123 | public @Nullable String getVersion() { 124 | return version; 125 | } 126 | 127 | public Map getQualifiers() { 128 | return Collections.unmodifiableMap(qualifiers); 129 | } 130 | 131 | public @Nullable String getSubpath() { 132 | return subpath; 133 | } 134 | 135 | @Override 136 | public String toString() { 137 | return "(" + type + ", " + namespace + ", " + name + ", " + version + ", " + qualifiers + ", " + subpath + ")"; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/test/java/com/github/packageurl/internal/StringUtilBenchmark.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | package com.github.packageurl.internal; 23 | 24 | import java.nio.charset.StandardCharsets; 25 | import java.util.Locale; 26 | import java.util.Random; 27 | import java.util.concurrent.TimeUnit; 28 | import org.openjdk.jmh.annotations.Benchmark; 29 | import org.openjdk.jmh.annotations.BenchmarkMode; 30 | import org.openjdk.jmh.annotations.Mode; 31 | import org.openjdk.jmh.annotations.OutputTimeUnit; 32 | import org.openjdk.jmh.annotations.Param; 33 | import org.openjdk.jmh.annotations.Scope; 34 | import org.openjdk.jmh.annotations.Setup; 35 | import org.openjdk.jmh.annotations.State; 36 | import org.openjdk.jmh.infra.Blackhole; 37 | 38 | /** 39 | * Measures the performance of performance StringUtil's decoding and encoding. 40 | *

41 | * Run the benchmark with: 42 | *

43 | *
 44 |  *     mvn -Pbenchmark
 45 |  * 
46 | *

47 | * To pass arguments to JMH use: 48 | *

49 | *
 50 |  *     mvn -Pbenchmark -Djmh.args=""
 51 |  * 
52 | */ 53 | @BenchmarkMode(Mode.AverageTime) 54 | @OutputTimeUnit(TimeUnit.MICROSECONDS) 55 | @State(Scope.Benchmark) 56 | public class StringUtilBenchmark { 57 | 58 | private static final int DATA_COUNT = 1000; 59 | private static final int DECODED_LENGTH = 256; 60 | private static final byte[] UNRESERVED = 61 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".getBytes(StandardCharsets.US_ASCII); 62 | 63 | @Param({"0", "0.1", "0.5"}) 64 | private double nonAsciiProb; 65 | 66 | private String[] decodedData; 67 | private String[] encodedData; 68 | 69 | @Setup 70 | public void setup() { 71 | decodedData = createDecodedData(); 72 | encodedData = encodeData(decodedData); 73 | } 74 | 75 | private String[] createDecodedData() { 76 | Random random = new Random(); 77 | String[] decodedData = new String[DATA_COUNT]; 78 | for (int i = 0; i < DATA_COUNT; i++) { 79 | char[] chars = new char[DECODED_LENGTH]; 80 | for (int j = 0; j < DECODED_LENGTH; j++) { 81 | if (random.nextDouble() < nonAsciiProb) { 82 | chars[j] = (char) (Byte.MAX_VALUE + 1 + random.nextInt(Short.MAX_VALUE - Byte.MAX_VALUE - 1)); 83 | } else { 84 | chars[j] = (char) UNRESERVED[random.nextInt(UNRESERVED.length)]; 85 | } 86 | } 87 | decodedData[i] = new String(chars); 88 | } 89 | return decodedData; 90 | } 91 | 92 | private static String[] encodeData(String[] decodedData) { 93 | String[] encodedData = new String[decodedData.length]; 94 | for (int i = 0; i < encodedData.length; i++) { 95 | encodedData[i] = StringUtil.percentEncode(decodedData[i]); 96 | if (!StringUtil.percentDecode(encodedData[i]).equals(decodedData[i])) { 97 | throw new RuntimeException( 98 | "Invalid implementation of `percentEncode` and `percentDecode`.\nOriginal data: " 99 | + encodedData[i] + "\nEncoded and decoded data: " 100 | + StringUtil.percentDecode(encodedData[i])); 101 | } 102 | } 103 | return encodedData; 104 | } 105 | 106 | @Benchmark 107 | public void baseline(Blackhole blackhole) { 108 | for (int i = 0; i < DATA_COUNT; i++) { 109 | byte[] buffer = decodedData[i].getBytes(StandardCharsets.UTF_8); 110 | // Prevent JIT compiler from assuming the buffer was not modified 111 | for (int idx = 0; idx < buffer.length; idx++) { 112 | buffer[idx] ^= 0x20; 113 | } 114 | blackhole.consume(new String(buffer, StandardCharsets.UTF_8)); 115 | } 116 | } 117 | 118 | @Benchmark 119 | public void toLowerCaseJre(Blackhole blackhole) { 120 | for (int i = 0; i < DATA_COUNT; i++) { 121 | blackhole.consume(decodedData[i].toLowerCase(Locale.ROOT)); 122 | } 123 | } 124 | 125 | @Benchmark 126 | public void toLowerCase(Blackhole blackhole) { 127 | for (int i = 0; i < DATA_COUNT; i++) { 128 | blackhole.consume(StringUtil.toLowerCase(decodedData[i])); 129 | } 130 | } 131 | 132 | @Benchmark 133 | public void percentDecode(final Blackhole blackhole) { 134 | for (int i = 0; i < DATA_COUNT; i++) { 135 | blackhole.consume(StringUtil.percentDecode(encodedData[i])); 136 | } 137 | } 138 | 139 | @Benchmark 140 | public void percentEncode(final Blackhole blackhole) { 141 | for (int i = 0; i < DATA_COUNT; i++) { 142 | blackhole.consume(StringUtil.percentEncode(decodedData[i])); 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/test/java/com/github/packageurl/internal/StringUtilTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | package com.github.packageurl.internal; 23 | 24 | import static org.junit.jupiter.api.Assertions.assertEquals; 25 | import static org.junit.jupiter.api.Assertions.assertThrowsExactly; 26 | 27 | import com.github.packageurl.ValidationException; 28 | import org.junit.jupiter.api.Test; 29 | 30 | class StringUtilTest { 31 | @Test 32 | void invalidPercentEncoding() { 33 | Throwable t1 = assertThrowsExactly(ValidationException.class, () -> StringUtil.percentDecode("a%0")); 34 | assertEquals("Incomplete percent encoding at offset 1 with value '%0'", t1.getMessage()); 35 | Throwable t2 = assertThrowsExactly(ValidationException.class, () -> StringUtil.percentDecode("aaaa%%0A")); 36 | assertEquals("Invalid percent encoding char 1 at offset 5 with value '%'", t2.getMessage()); 37 | Throwable t3 = assertThrowsExactly(ValidationException.class, () -> StringUtil.percentDecode("%0G")); 38 | assertEquals("Invalid percent encoding char 2 at offset 2 with value 'G'", t3.getMessage()); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/test/resources/components-constructor-only.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "minimal valid PURL", 4 | "type": "my.type-9+", 5 | "name": "name", 6 | "canonical_purl": "pkg:my.type-9+/name", 7 | "is_invalid": false 8 | }, 9 | { 10 | "description": "PURL with all components", 11 | "type": "type", 12 | "namespace": "namespace", 13 | "name": "name", 14 | "version": "version", 15 | "qualifiers": { 16 | "key": "value" 17 | }, 18 | "subpath": "subpath", 19 | "canonical_purl": "pkg:type/namespace/name@version?key=value#subpath", 20 | "is_invalid": false 21 | }, 22 | { 23 | "description": "Non alphanumeric key", 24 | "type": "generic", 25 | "namespace": "namespace", 26 | "name": "name", 27 | "version": "version", 28 | "qualifiers": { 29 | "key_1.1-": "value" 30 | }, 31 | "subpath": "subpath", 32 | "canonical_purl": "pkg:generic/namespace/name@version?key_1.1-=value#subpath", 33 | "is_invalid": false 34 | }, 35 | { 36 | "description": "Two qualifiers", 37 | "type": "generic", 38 | "namespace": "", 39 | "name": "name", 40 | "version": "version", 41 | "qualifiers": { 42 | "key": "value", 43 | "next": "value" 44 | }, 45 | "subpath": "", 46 | "canonical_purl": "pkg:generic/name@version?key=value&next=value", 47 | "is_invalid": false 48 | } 49 | ] 50 | -------------------------------------------------------------------------------- /src/test/resources/custom-suite.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "empty fragment", 4 | "purl": "pkg:generic/namespace/name@1.0.0#", 5 | "type": "generic", 6 | "namespace": "namespace", 7 | "name": "name", 8 | "version": "1.0.0", 9 | "canonical_purl": "pkg:generic/namespace/name@1.0.0", 10 | "is_invalid": false 11 | }, 12 | { 13 | "description": "value with `=`", 14 | "purl": "pkg:generic/namespace/name@1.0.0?key=value==", 15 | "type": "generic", 16 | "namespace": "namespace", 17 | "name": "name", 18 | "version": "1.0.0", 19 | "qualifiers": { 20 | "key": "value==" 21 | }, 22 | "canonical_purl": "pkg:generic/namespace/name@1.0.0?key=value%3D%3D", 23 | "is_invalid": false 24 | }, 25 | { 26 | "description": "everything null", 27 | "is_invalid": true 28 | } 29 | ] 30 | -------------------------------------------------------------------------------- /src/test/resources/string-constructor-only.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "constructor with `invalid/%2F/subpath` should have thrown an error", 4 | "purl": "pkg:GOLANG/google.golang.org/genproto@abcdedf#invalid/%2F/subpath", 5 | "is_invalid": true 6 | }, 7 | { 8 | "description": "constructor with null purl", 9 | "purl": null, 10 | "is_invalid": true 11 | }, 12 | { 13 | "description": "constructor with empty purl", 14 | "purl": "", 15 | "is_invalid": true 16 | }, 17 | { 18 | "description": "constructor with port number", 19 | "purl": "pkg://generic:8080/name", 20 | "is_invalid": true 21 | }, 22 | { 23 | "description": "constructor with username", 24 | "purl": "pkg://user@generic/name", 25 | "is_invalid": true 26 | }, 27 | { 28 | "description": "constructor with invalid url", 29 | "purl": "invalid url", 30 | "is_invalid": true 31 | }, 32 | { 33 | "description": "constructor with url with duplicate qualifiers", 34 | "purl": "pkg://generic/name?key=one&key=two", 35 | "is_invalid": true 36 | }, 37 | { 38 | "description": "constructor with url with duplicate qualifiers", 39 | "purl": "pkg://generic/name?key=one&KEY=two", 40 | "is_invalid": true 41 | }, 42 | { 43 | "description": "constructor with upper case key", 44 | "purl": "pkg://generic/name?KEY=one", 45 | "type": "generic", 46 | "name": "name", 47 | "qualifiers": { 48 | "key": "one" 49 | }, 50 | "canonical_purl": "pkg:generic/name?key=one", 51 | "is_invalid": false 52 | }, 53 | { 54 | "description": "constructor with empty key", 55 | "purl": "pkg://generic/name?KEY=", 56 | "type": "generic", 57 | "name": "name", 58 | "canonical_purl": "pkg:generic/name", 59 | "is_invalid": false 60 | }, 61 | { 62 | "description": "constructor with null key", 63 | "purl": "pkg://generic/name?KEY", 64 | "type": "generic", 65 | "name": "name", 66 | "canonical_purl": "pkg:generic/name", 67 | "is_invalid": false 68 | } 69 | ] 70 | -------------------------------------------------------------------------------- /src/test/resources/test-suite-data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "valid maven purl", 4 | "purl": "pkg:maven/org.apache.commons/io@1.3.4", 5 | "canonical_purl": "pkg:maven/org.apache.commons/io@1.3.4", 6 | "type": "maven", 7 | "namespace": "org.apache.commons", 8 | "name": "io", 9 | "version": "1.3.4", 10 | "qualifiers": null, 11 | "subpath": null, 12 | "is_invalid": false 13 | }, 14 | { 15 | "description": "basic valid maven purl without version", 16 | "purl": "pkg:maven/org.apache.commons/io", 17 | "canonical_purl": "pkg:maven/org.apache.commons/io", 18 | "type": "maven", 19 | "namespace": "org.apache.commons", 20 | "name": "io", 21 | "version": null, 22 | "qualifiers": null, 23 | "subpath": null, 24 | "is_invalid": false 25 | }, 26 | { 27 | "description": "valid go purl without version and with subpath", 28 | "purl": "pkg:GOLANG/google.golang.org/genproto#/googleapis/api/annotations/", 29 | "canonical_purl": "pkg:golang/google.golang.org/genproto#googleapis/api/annotations", 30 | "type": "golang", 31 | "namespace": "google.golang.org", 32 | "name": "genproto", 33 | "version": null, 34 | "qualifiers": null, 35 | "subpath": "googleapis/api/annotations", 36 | "is_invalid": false 37 | }, 38 | { 39 | "description": "valid go purl with version and subpath", 40 | "purl": "pkg:GOLANG/google.golang.org/genproto@abcdedf#/googleapis/api/annotations/", 41 | "canonical_purl": "pkg:golang/google.golang.org/genproto@abcdedf#googleapis/api/annotations", 42 | "type": "golang", 43 | "namespace": "google.golang.org", 44 | "name": "genproto", 45 | "version": "abcdedf", 46 | "qualifiers": null, 47 | "subpath": "googleapis/api/annotations", 48 | "is_invalid": false 49 | }, 50 | { 51 | "description": "bitbucket namespace and name should be lowercased", 52 | "purl": "pkg:bitbucket/birKenfeld/pyGments-main@244fd47e07d1014f0aed9c", 53 | "canonical_purl": "pkg:bitbucket/birkenfeld/pygments-main@244fd47e07d1014f0aed9c", 54 | "type": "bitbucket", 55 | "namespace": "birkenfeld", 56 | "name": "pygments-main", 57 | "version": "244fd47e07d1014f0aed9c", 58 | "qualifiers": null, 59 | "subpath": null, 60 | "is_invalid": false 61 | }, 62 | { 63 | "description": "github namespace and name should be lowercased", 64 | "purl": "pkg:github/Package-url/purl-Spec@244fd47e07d1004f0aed9c", 65 | "canonical_purl": "pkg:github/package-url/purl-spec@244fd47e07d1004f0aed9c", 66 | "type": "github", 67 | "namespace": "package-url", 68 | "name": "purl-spec", 69 | "version": "244fd47e07d1004f0aed9c", 70 | "qualifiers": null, 71 | "subpath": null, 72 | "is_invalid": false 73 | }, 74 | { 75 | "description": "debian can use qualifiers", 76 | "purl": "pkg:deb/debian/curl@7.50.3-1?arch=i386&distro=jessie", 77 | "canonical_purl": "pkg:deb/debian/curl@7.50.3-1?arch=i386&distro=jessie", 78 | "type": "deb", 79 | "namespace": "debian", 80 | "name": "curl", 81 | "version": "7.50.3-1", 82 | "qualifiers": {"arch": "i386", "distro": "jessie"}, 83 | "subpath": null, 84 | "is_invalid": false 85 | }, 86 | { 87 | "description": "docker uses qualifiers and hash image id as versions", 88 | "purl": "pkg:docker/customer/dockerimage@sha256:244fd47e07d1004f0aed9c?repository_url=gcr.io", 89 | "canonical_purl": "pkg:docker/customer/dockerimage@sha256%3A244fd47e07d1004f0aed9c?repository_url=gcr.io", 90 | "type": "docker", 91 | "namespace": "customer", 92 | "name": "dockerimage", 93 | "version": "sha256:244fd47e07d1004f0aed9c", 94 | "qualifiers": {"repository_url": "gcr.io"}, 95 | "subpath": null, 96 | "is_invalid": false 97 | }, 98 | { 99 | "description": "Java gem can use a qualifier", 100 | "purl": "pkg:gem/jruby-launcher@1.1.2?Platform=java", 101 | "canonical_purl": "pkg:gem/jruby-launcher@1.1.2?platform=java", 102 | "type": "gem", 103 | "namespace": null, 104 | "name": "jruby-launcher", 105 | "version": "1.1.2", 106 | "qualifiers": {"platform": "java"}, 107 | "subpath": null, 108 | "is_invalid": false 109 | }, 110 | { 111 | "description": "maven often uses qualifiers", 112 | "purl": "pkg:Maven/org.apache.xmlgraphics/batik-anim@1.9.1?repositorY_url=repo.spring.io/release&classifier=sources", 113 | "canonical_purl": "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?classifier=sources&repository_url=repo.spring.io%2Frelease", 114 | "type": "maven", 115 | "namespace": "org.apache.xmlgraphics", 116 | "name": "batik-anim", 117 | "version": "1.9.1", 118 | "qualifiers": {"classifier": "sources", "repository_url": "repo.spring.io/release"}, 119 | "subpath": null, 120 | "is_invalid": false 121 | }, 122 | { 123 | "description": "maven pom reference", 124 | "purl": "pkg:Maven/org.apache.xmlgraphics/batik-anim@1.9.1?repositorY_url=repo.spring.io/release&extension=pom", 125 | "canonical_purl": "pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?extension=pom&repository_url=repo.spring.io%2Frelease", 126 | "type": "maven", 127 | "namespace": "org.apache.xmlgraphics", 128 | "name": "batik-anim", 129 | "version": "1.9.1", 130 | "qualifiers": {"extension": "pom", "repository_url": "repo.spring.io/release"}, 131 | "subpath": null, 132 | "is_invalid": false 133 | }, 134 | { 135 | "description": "maven can come with a type qualifier", 136 | "purl": "pkg:Maven/net.sf.jacob-project/jacob@1.14.3?type=dll&classifier=x86", 137 | "canonical_purl": "pkg:maven/net.sf.jacob-project/jacob@1.14.3?classifier=x86&type=dll", 138 | "type": "maven", 139 | "namespace": "net.sf.jacob-project", 140 | "name": "jacob", 141 | "version": "1.14.3", 142 | "qualifiers": {"classifier": "x86", "type": "dll"}, 143 | "subpath": null, 144 | "is_invalid": false 145 | }, 146 | { 147 | "description": "npm can be scoped", 148 | "purl": "pkg:npm/%40angular/animation@12.3.1", 149 | "canonical_purl": "pkg:npm/%40angular/animation@12.3.1", 150 | "type": "npm", 151 | "namespace": "@angular", 152 | "name": "animation", 153 | "version": "12.3.1", 154 | "qualifiers": null, 155 | "subpath": null, 156 | "is_invalid": false 157 | }, 158 | { 159 | "description": "nuget names are case sensitive", 160 | "purl": "pkg:Nuget/EnterpriseLibrary.Common@6.0.1304", 161 | "canonical_purl": "pkg:nuget/EnterpriseLibrary.Common@6.0.1304", 162 | "type": "nuget", 163 | "namespace": null, 164 | "name": "EnterpriseLibrary.Common", 165 | "version": "6.0.1304", 166 | "qualifiers": null, 167 | "subpath": null, 168 | "is_invalid": false 169 | }, 170 | { 171 | "description": "pypi names have special rules and not case sensitive", 172 | "purl": "pkg:PYPI/Django_package@1.11.1.dev1", 173 | "canonical_purl": "pkg:pypi/django-package@1.11.1.dev1", 174 | "type": "pypi", 175 | "namespace": null, 176 | "name": "django-package", 177 | "version": "1.11.1.dev1", 178 | "qualifiers": null, 179 | "subpath": null, 180 | "is_invalid": false 181 | }, 182 | { 183 | "description": "rpm often use qualifiers", 184 | "purl": "pkg:Rpm/fedora/curl@7.50.3-1.fc25?Arch=i386&Distro=fedora-25", 185 | "canonical_purl": "pkg:rpm/fedora/curl@7.50.3-1.fc25?arch=i386&distro=fedora-25", 186 | "type": "rpm", 187 | "namespace": "fedora", 188 | "name": "curl", 189 | "version": "7.50.3-1.fc25", 190 | "qualifiers": {"arch": "i386", "distro": "fedora-25"}, 191 | "subpath": null, 192 | "is_invalid": false 193 | }, 194 | { 195 | "description": "a scheme is always required", 196 | "purl": "EnterpriseLibrary.Common@6.0.1304", 197 | "canonical_purl": "EnterpriseLibrary.Common@6.0.1304", 198 | "type": null, 199 | "namespace": null, 200 | "name": "EnterpriseLibrary.Common", 201 | "version": null, 202 | "qualifiers": null, 203 | "subpath": null, 204 | "is_invalid": true 205 | }, 206 | { 207 | "description": "a type is always required", 208 | "purl": "pkg:EnterpriseLibrary.Common@6.0.1304", 209 | "canonical_purl": "pkg:EnterpriseLibrary.Common@6.0.1304", 210 | "type": null, 211 | "namespace": null, 212 | "name": "EnterpriseLibrary.Common", 213 | "version": null, 214 | "qualifiers": null, 215 | "subpath": null, 216 | "is_invalid": true 217 | }, 218 | { 219 | "description": "a name is required", 220 | "purl": "pkg:maven/@1.3.4", 221 | "canonical_purl": "pkg:maven/@1.3.4", 222 | "type": "maven", 223 | "namespace": null, 224 | "name": null, 225 | "version": null, 226 | "qualifiers": null, 227 | "subpath": null, 228 | "is_invalid": true 229 | }, 230 | { 231 | "description": "a namespace is required", 232 | "purl": "pkg:maven/io@1.3.4", 233 | "canonical_purl": "pkg:maven/io@1.3.4", 234 | "type": "maven", 235 | "namespace": null, 236 | "name": null, 237 | "version": null, 238 | "qualifiers": null, 239 | "subpath": null, 240 | "is_invalid": true 241 | }, 242 | { 243 | "description": "a namespace is required", 244 | "purl": "pkg:maven//io@1.3.4", 245 | "canonical_purl": "pkg:maven//io@1.3.4", 246 | "type": "maven", 247 | "namespace": null, 248 | "name": null, 249 | "version": null, 250 | "qualifiers": null, 251 | "subpath": null, 252 | "is_invalid": true 253 | }, 254 | { 255 | "description": "slash / after scheme is not significant", 256 | "purl": "pkg:/maven/org.apache.commons/io", 257 | "canonical_purl": "pkg:maven/org.apache.commons/io", 258 | "type": "maven", 259 | "namespace": "org.apache.commons", 260 | "name": "io", 261 | "version": null, 262 | "qualifiers": null, 263 | "subpath": null, 264 | "is_invalid": false 265 | }, 266 | { 267 | "description": "double slash // after scheme is not significant", 268 | "purl": "pkg://maven/org.apache.commons/io", 269 | "canonical_purl": "pkg:maven/org.apache.commons/io", 270 | "type": "maven", 271 | "namespace": "org.apache.commons", 272 | "name": "io", 273 | "version": null, 274 | "qualifiers": null, 275 | "subpath": null, 276 | "is_invalid": false 277 | }, 278 | { 279 | "description": "slash /// after type is not significant", 280 | "purl": "pkg:///maven/org.apache.commons/io", 281 | "canonical_purl": "pkg:maven/org.apache.commons/io", 282 | "type": "maven", 283 | "namespace": "org.apache.commons", 284 | "name": "io", 285 | "version": null, 286 | "qualifiers": null, 287 | "subpath": null, 288 | "is_invalid": false 289 | }, 290 | { 291 | "description": "valid maven purl", 292 | "purl": "pkg:maven/HTTPClient/HTTPClient@0.3-3", 293 | "canonical_purl": "pkg:maven/HTTPClient/HTTPClient@0.3-3", 294 | "type": "maven", 295 | "namespace": "HTTPClient", 296 | "name": "HTTPClient", 297 | "version": "0.3-3", 298 | "qualifiers": null, 299 | "subpath": null, 300 | "is_invalid": false 301 | }, 302 | { 303 | "description": "valid maven purl containing a space in the version and qualifier", 304 | "purl": "pkg:maven/mygroup/myartifact@1.0.0%20Final?mykey=my%20value", 305 | "canonical_purl": "pkg:maven/mygroup/myartifact@1.0.0%20Final?mykey=my%20value", 306 | "type": "maven", 307 | "namespace": "mygroup", 308 | "name": "myartifact", 309 | "version": "1.0.0 Final", 310 | "qualifiers": {"mykey": "my value"}, 311 | "subpath": null, 312 | "is_invalid": false 313 | }, 314 | { 315 | "description": "valid debian purl containing a plus in the name and version", 316 | "purl": "pkg:deb/debian/g++-10@10.2.1+6", 317 | "canonical_purl": "pkg:deb/debian/g%2B%2B-10@10.2.1%2B6", 318 | "type": "deb", 319 | "namespace": "debian", 320 | "name": "g++-10", 321 | "version": "10.2.1+6", 322 | "qualifiers": null, 323 | "subpath": null, 324 | "is_invalid": false 325 | }, 326 | { 327 | "description": "checks for invalid qualifier keys", 328 | "purl": "pkg:npm/myartifact@1.0.0?in%20production=true", 329 | "canonical_purl": null, 330 | "type": "npm", 331 | "namespace": null, 332 | "name": "myartifact", 333 | "version": "1.0.0", 334 | "qualifiers": {"in production": "true"}, 335 | "subpath": null, 336 | "is_invalid": true 337 | }, 338 | { 339 | "description": "valid hackage purl", 340 | "purl": "pkg:hackage/AC-HalfInteger@1.2.1", 341 | "canonical_purl": "pkg:hackage/AC-HalfInteger@1.2.1", 342 | "type": "hackage", 343 | "namespace": null, 344 | "name": "AC-HalfInteger", 345 | "version": "1.2.1", 346 | "qualifiers": null, 347 | "subpath": null, 348 | "is_invalid": false 349 | }, 350 | { 351 | "description": "name and version are always required", 352 | "purl": "pkg:hackage", 353 | "canonical_purl": "pkg:hackage", 354 | "type": "hackage", 355 | "namespace": null, 356 | "name": null, 357 | "version": null, 358 | "qualifiers": null, 359 | "subpath": null, 360 | "is_invalid": true 361 | } 362 | ] 363 | -------------------------------------------------------------------------------- /src/test/resources/type-namespace-constructor-only.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "valid", 4 | "type": "validtype", 5 | "name": "name", 6 | "canonical_purl": "pkg:validtype/name", 7 | "is_invalid": false 8 | }, 9 | { 10 | "description": "constructor with an empty type should have thrown an error", 11 | "type": "", 12 | "name": "name", 13 | "is_invalid": true 14 | }, 15 | { 16 | "description": "constructor with `invalid^type` should have thrown an error", 17 | "type": "invalid^type", 18 | "name": "name", 19 | "is_invalid": true 20 | }, 21 | { 22 | "description": "constructor with `0invalid` should have thrown an error", 23 | "type": "0invalid", 24 | "name": "name", 25 | "is_invalid": true 26 | } 27 | ] 28 | --------------------------------------------------------------------------------