purl 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +170 -1
- data/exe/purl +435 -0
- data/lib/purl/package_url.rb +36 -102
- data/lib/purl/registry_url.rb +146 -21
- data/lib/purl/version.rb +1 -1
- data/purl-types.json +44 -85
- metadata +19 -3
data/lib/purl/package_url.rb
CHANGED
|
@@ -74,13 +74,13 @@ module Purl
|
|
|
74
74
|
def initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil)
|
|
75
75
|
@type = validate_and_normalize_type(type)
|
|
76
76
|
@name = validate_name(name)
|
|
77
|
-
@namespace = validate_namespace(namespace)
|
|
77
|
+
@namespace = validate_namespace(namespace)
|
|
78
78
|
@version = validate_version(version) if version
|
|
79
79
|
@qualifiers = validate_qualifiers(qualifiers) if qualifiers
|
|
80
80
|
@subpath = validate_subpath(subpath) if subpath
|
|
81
81
|
|
|
82
|
-
#
|
|
83
|
-
|
|
82
|
+
# Apply post-validation normalization that depends on other components
|
|
83
|
+
apply_post_validation_normalization
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
# Parse a PURL string into a PackageURL object
|
|
@@ -407,7 +407,7 @@ module Purl
|
|
|
407
407
|
# PyPI names are case-insensitive and _ should be normalized to -
|
|
408
408
|
name_str.downcase.gsub("_", "-")
|
|
409
409
|
when "mlflow"
|
|
410
|
-
# MLflow name normalization
|
|
410
|
+
# MLflow name normalization happens after qualifiers are validated
|
|
411
411
|
name_str
|
|
412
412
|
when "composer"
|
|
413
413
|
# Composer names should be lowercase
|
|
@@ -418,6 +418,16 @@ module Purl
|
|
|
418
418
|
end
|
|
419
419
|
|
|
420
420
|
def validate_namespace(namespace)
|
|
421
|
+
# Check namespace requirements from spec
|
|
422
|
+
if namespace_required_for_type?(@type) && namespace.nil?
|
|
423
|
+
raise ValidationError.new(
|
|
424
|
+
"#{@type.capitalize} PURLs require a namespace per the type definition",
|
|
425
|
+
component: :namespace,
|
|
426
|
+
value: namespace,
|
|
427
|
+
rule: "#{@type.downcase} packages need namespace"
|
|
428
|
+
)
|
|
429
|
+
end
|
|
430
|
+
|
|
421
431
|
return nil if namespace.nil?
|
|
422
432
|
|
|
423
433
|
namespace_str = namespace.to_s.strip
|
|
@@ -516,111 +526,35 @@ module Purl
|
|
|
516
526
|
subpath_str
|
|
517
527
|
end
|
|
518
528
|
|
|
519
|
-
def
|
|
520
|
-
case
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
validate_cran_specific_rules
|
|
525
|
-
when "swift"
|
|
526
|
-
validate_swift_specific_rules
|
|
527
|
-
when "cpan"
|
|
528
|
-
validate_cpan_specific_rules
|
|
529
|
-
when "mlflow"
|
|
530
|
-
validate_mlflow_specific_rules
|
|
529
|
+
def apply_post_validation_normalization
|
|
530
|
+
# MLflow names are case sensitive or insensitive based on repository per spec
|
|
531
|
+
if @type&.downcase == "mlflow" && @qualifiers && @qualifiers["repository_url"] && @qualifiers["repository_url"].include?("azuredatabricks")
|
|
532
|
+
# Databricks MLflow is case insensitive - normalize to lowercase per spec
|
|
533
|
+
@name = @name.downcase
|
|
531
534
|
end
|
|
535
|
+
# Other MLflow repositories (like Azure ML) are case sensitive - no normalization needed
|
|
532
536
|
end
|
|
533
537
|
|
|
534
|
-
def
|
|
535
|
-
|
|
536
|
-
# it's ambiguous. However, any qualifiers (including build settings) make it unambiguous.
|
|
537
|
-
# According to the official spec, user/channel are only required if the package was published with them.
|
|
538
|
-
if @namespace && (@qualifiers.nil? || @qualifiers.empty?)
|
|
539
|
-
raise ValidationError.new(
|
|
540
|
-
"Conan PURLs with namespace require qualifiers to be unambiguous",
|
|
541
|
-
component: :qualifiers,
|
|
542
|
-
value: @qualifiers,
|
|
543
|
-
rule: "conan packages with namespace need qualifiers for disambiguation"
|
|
544
|
-
)
|
|
545
|
-
end
|
|
538
|
+
def namespace_required_for_type?(type)
|
|
539
|
+
return false unless type
|
|
546
540
|
|
|
547
|
-
#
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
"Conan PURLs with 'channel' qualifier require 'user' qualifier to be unambiguous",
|
|
552
|
-
component: :qualifiers,
|
|
553
|
-
value: @qualifiers,
|
|
554
|
-
rule: "conan packages with channel need user qualifier"
|
|
555
|
-
)
|
|
556
|
-
end
|
|
557
|
-
end
|
|
558
|
-
|
|
559
|
-
def validate_cran_specific_rules
|
|
560
|
-
# CRAN packages require a version to be unambiguous
|
|
561
|
-
if @version.nil?
|
|
562
|
-
raise ValidationError.new(
|
|
563
|
-
"CRAN PURLs require a version to be unambiguous",
|
|
564
|
-
component: :version,
|
|
565
|
-
value: @version,
|
|
566
|
-
rule: "cran packages need version"
|
|
567
|
-
)
|
|
568
|
-
end
|
|
569
|
-
end
|
|
570
|
-
|
|
571
|
-
def validate_swift_specific_rules
|
|
572
|
-
# Swift packages require a namespace to be unambiguous
|
|
573
|
-
if @namespace.nil?
|
|
574
|
-
raise ValidationError.new(
|
|
575
|
-
"Swift PURLs require a namespace to be unambiguous",
|
|
576
|
-
component: :namespace,
|
|
577
|
-
value: @namespace,
|
|
578
|
-
rule: "swift packages need namespace"
|
|
579
|
-
)
|
|
580
|
-
end
|
|
541
|
+
# Read from purl-types.json (included in gem)
|
|
542
|
+
types_data = self.class.purl_types_data
|
|
543
|
+
type_config = types_data.dig("types", type.downcase)
|
|
544
|
+
return false unless type_config
|
|
581
545
|
|
|
582
|
-
#
|
|
583
|
-
|
|
584
|
-
raise ValidationError.new(
|
|
585
|
-
"Swift PURLs require a version to be unambiguous",
|
|
586
|
-
component: :version,
|
|
587
|
-
value: @version,
|
|
588
|
-
rule: "swift packages need version"
|
|
589
|
-
)
|
|
590
|
-
end
|
|
591
|
-
end
|
|
592
|
-
|
|
593
|
-
def validate_mlflow_specific_rules
|
|
594
|
-
# MLflow names are case sensitive or insensitive based on repository
|
|
595
|
-
if @qualifiers && @qualifiers["repository_url"] && @qualifiers["repository_url"].include?("azuredatabricks")
|
|
596
|
-
# Azure Databricks MLflow is case insensitive - normalize to lowercase
|
|
597
|
-
@name = @name.downcase
|
|
598
|
-
end
|
|
599
|
-
# Other MLflow repositories are case sensitive - no normalization needed
|
|
546
|
+
# Check namespace_requirement field
|
|
547
|
+
type_config["namespace_requirement"] == "required"
|
|
600
548
|
end
|
|
601
549
|
|
|
602
|
-
def
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
component: :name,
|
|
611
|
-
value: @name,
|
|
612
|
-
rule: "cpan module vs distribution name conflict"
|
|
613
|
-
)
|
|
614
|
-
end
|
|
615
|
-
|
|
616
|
-
# Case 52: namespace with distribution-like name should be invalid
|
|
617
|
-
if @namespace == "GDT" && @name == "URI::PackageURL"
|
|
618
|
-
raise ValidationError.new(
|
|
619
|
-
"CPAN distribution name 'GDT/URI::PackageURL' has invalid format",
|
|
620
|
-
component: :name,
|
|
621
|
-
value: "#{@namespace}/#{@name}",
|
|
622
|
-
rule: "cpan distribution vs module name conflict"
|
|
623
|
-
)
|
|
550
|
+
def self.purl_types_data
|
|
551
|
+
@purl_types_data ||= begin
|
|
552
|
+
require "json"
|
|
553
|
+
types_file = File.join(File.dirname(__FILE__), "..", "..", "purl-types.json")
|
|
554
|
+
JSON.parse(File.read(types_file))
|
|
555
|
+
rescue
|
|
556
|
+
# Fallback to empty structure if file can't be read
|
|
557
|
+
{"types" => {}}
|
|
624
558
|
end
|
|
625
559
|
end
|
|
626
560
|
|
data/lib/purl/registry_url.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "addressable/template"
|
|
4
|
+
|
|
3
5
|
module Purl
|
|
4
6
|
class RegistryURL
|
|
5
7
|
# Load registry patterns from JSON configuration
|
|
@@ -27,8 +29,7 @@ module Purl
|
|
|
27
29
|
# Get the default registry for this type from the extended config
|
|
28
30
|
default_registry = type_config["default_registry"]
|
|
29
31
|
|
|
30
|
-
#
|
|
31
|
-
route_patterns = config["route_patterns"] || []
|
|
32
|
+
# Route patterns are replaced by URI templates
|
|
32
33
|
|
|
33
34
|
# Build reverse regex from template or use legacy format
|
|
34
35
|
reverse_regex = nil
|
|
@@ -45,10 +46,13 @@ module Purl
|
|
|
45
46
|
|
|
46
47
|
{
|
|
47
48
|
base_url: config["base_url"] || (default_registry ? default_registry + config["path_template"]&.split('/:').first : nil),
|
|
48
|
-
route_patterns: route_patterns,
|
|
49
49
|
reverse_regex: reverse_regex,
|
|
50
50
|
pattern: build_generation_lambda(type, config, default_registry),
|
|
51
|
-
reverse_parser: reverse_regex ? build_reverse_parser(type, config) : nil
|
|
51
|
+
reverse_parser: reverse_regex ? build_reverse_parser(type, config) : nil,
|
|
52
|
+
uri_template: config["uri_template"] ? Addressable::Template.new(config["uri_template"]) : nil,
|
|
53
|
+
uri_template_no_namespace: config["uri_template_no_namespace"] ? Addressable::Template.new(config["uri_template_no_namespace"]) : nil,
|
|
54
|
+
uri_template_with_version: config["uri_template_with_version"] ? Addressable::Template.new(config["uri_template_with_version"]) : nil,
|
|
55
|
+
uri_template_with_version_no_namespace: config["uri_template_with_version_no_namespace"] ? Addressable::Template.new(config["uri_template_with_version_no_namespace"]) : nil
|
|
52
56
|
}
|
|
53
57
|
end
|
|
54
58
|
|
|
@@ -376,18 +380,41 @@ module Purl
|
|
|
376
380
|
pattern_config = REGISTRY_PATTERNS[type.to_s.downcase]
|
|
377
381
|
return [] unless pattern_config
|
|
378
382
|
|
|
379
|
-
|
|
383
|
+
# Generate route patterns from URI templates
|
|
384
|
+
patterns = []
|
|
385
|
+
|
|
386
|
+
if pattern_config[:uri_template]
|
|
387
|
+
patterns << uri_template_to_route_pattern(pattern_config[:uri_template])
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
if pattern_config[:uri_template_no_namespace]
|
|
391
|
+
patterns << uri_template_to_route_pattern(pattern_config[:uri_template_no_namespace])
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
if pattern_config[:uri_template_with_version]
|
|
395
|
+
patterns << uri_template_to_route_pattern(pattern_config[:uri_template_with_version])
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
if pattern_config[:uri_template_with_version_no_namespace]
|
|
399
|
+
patterns << uri_template_to_route_pattern(pattern_config[:uri_template_with_version_no_namespace])
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
patterns.uniq
|
|
380
403
|
end
|
|
381
404
|
|
|
382
405
|
def self.all_route_patterns
|
|
383
406
|
result = {}
|
|
384
407
|
REGISTRY_PATTERNS.each do |type, config|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
end
|
|
408
|
+
patterns = route_patterns_for(type)
|
|
409
|
+
result[type] = patterns unless patterns.empty?
|
|
388
410
|
end
|
|
389
411
|
result
|
|
390
412
|
end
|
|
413
|
+
|
|
414
|
+
private_class_method def self.uri_template_to_route_pattern(template)
|
|
415
|
+
# Convert URI template format {variable} to route pattern format :variable
|
|
416
|
+
template.pattern.gsub(/\{([^}]+)\}/, ':\1')
|
|
417
|
+
end
|
|
391
418
|
|
|
392
419
|
def initialize(purl)
|
|
393
420
|
@purl = purl
|
|
@@ -408,8 +435,12 @@ module Purl
|
|
|
408
435
|
if base_url
|
|
409
436
|
# Use custom base URL with the same URL structure
|
|
410
437
|
generate_with_custom_base_url(base_url, pattern_config)
|
|
438
|
+
elsif pattern_config[:uri_template]
|
|
439
|
+
# Use URI template if available
|
|
440
|
+
template = select_uri_template(pattern_config, include_version: false)
|
|
441
|
+
generate_with_uri_template(template)
|
|
411
442
|
else
|
|
412
|
-
#
|
|
443
|
+
# Fall back to legacy lambda pattern
|
|
413
444
|
pattern_config[:pattern].call(@purl)
|
|
414
445
|
end
|
|
415
446
|
rescue MissingRegistryInfoError
|
|
@@ -420,27 +451,121 @@ module Purl
|
|
|
420
451
|
end
|
|
421
452
|
|
|
422
453
|
def generate_with_version(base_url: nil)
|
|
423
|
-
|
|
454
|
+
return generate(base_url: base_url) unless @purl.version
|
|
455
|
+
|
|
456
|
+
pattern_config = REGISTRY_PATTERNS[@purl.type.downcase]
|
|
457
|
+
|
|
458
|
+
if base_url
|
|
459
|
+
# Use custom base URL with version
|
|
460
|
+
generate_with_custom_base_url_and_version(base_url, pattern_config)
|
|
461
|
+
elsif pattern_config[:uri_template_with_version] || pattern_config[:uri_template]
|
|
462
|
+
# Use version-specific URI template if available
|
|
463
|
+
template = select_uri_template(pattern_config, include_version: true)
|
|
464
|
+
generate_with_uri_template(template, include_version: true)
|
|
465
|
+
else
|
|
466
|
+
# Fall back to legacy version handling
|
|
467
|
+
registry_url = generate(base_url: base_url)
|
|
468
|
+
|
|
469
|
+
case @purl.type.downcase
|
|
470
|
+
when "npm"
|
|
471
|
+
"#{registry_url}/v/#{@purl.version}"
|
|
472
|
+
when "pypi"
|
|
473
|
+
"#{registry_url}#{@purl.version}/"
|
|
474
|
+
when "gem"
|
|
475
|
+
"#{registry_url}/versions/#{@purl.version}"
|
|
476
|
+
when "maven"
|
|
477
|
+
"#{registry_url}/#{@purl.version}"
|
|
478
|
+
when "nuget"
|
|
479
|
+
"#{registry_url}/#{@purl.version}"
|
|
480
|
+
else
|
|
481
|
+
registry_url
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
private
|
|
487
|
+
|
|
488
|
+
def select_uri_template(pattern_config, include_version: false)
|
|
489
|
+
if include_version
|
|
490
|
+
if @purl.namespace && pattern_config[:uri_template_with_version]
|
|
491
|
+
pattern_config[:uri_template_with_version]
|
|
492
|
+
elsif !@purl.namespace && pattern_config[:uri_template_with_version_no_namespace]
|
|
493
|
+
pattern_config[:uri_template_with_version_no_namespace]
|
|
494
|
+
elsif pattern_config[:uri_template_with_version]
|
|
495
|
+
pattern_config[:uri_template_with_version]
|
|
496
|
+
elsif @purl.namespace && pattern_config[:uri_template]
|
|
497
|
+
pattern_config[:uri_template]
|
|
498
|
+
elsif !@purl.namespace && pattern_config[:uri_template_no_namespace]
|
|
499
|
+
pattern_config[:uri_template_no_namespace]
|
|
500
|
+
else
|
|
501
|
+
pattern_config[:uri_template]
|
|
502
|
+
end
|
|
503
|
+
else
|
|
504
|
+
if @purl.namespace && pattern_config[:uri_template]
|
|
505
|
+
pattern_config[:uri_template]
|
|
506
|
+
elsif !@purl.namespace && pattern_config[:uri_template_no_namespace]
|
|
507
|
+
pattern_config[:uri_template_no_namespace]
|
|
508
|
+
else
|
|
509
|
+
pattern_config[:uri_template]
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def generate_with_uri_template(template, include_version: false)
|
|
515
|
+
variables = {
|
|
516
|
+
name: @purl.name
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
# Add namespace if present and required
|
|
520
|
+
if @purl.namespace
|
|
521
|
+
variables[:namespace] = @purl.namespace
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Add version if requested and present
|
|
525
|
+
if include_version && @purl.version
|
|
526
|
+
variables[:version] = @purl.version
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Handle namespace requirements based on package type
|
|
530
|
+
case @purl.type.downcase
|
|
531
|
+
when "composer", "maven", "swift", "elm"
|
|
532
|
+
unless @purl.namespace
|
|
533
|
+
raise MissingRegistryInfoError.new(
|
|
534
|
+
"#{@purl.type.capitalize} packages require a namespace",
|
|
535
|
+
type: @purl.type,
|
|
536
|
+
missing: "namespace"
|
|
537
|
+
)
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Build the URL manually to avoid encoding issues with special characters like @
|
|
542
|
+
result = template.pattern
|
|
543
|
+
|
|
544
|
+
variables.each do |key, value|
|
|
545
|
+
result = result.gsub("{#{key}}", value.to_s)
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
result
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def generate_with_custom_base_url_and_version(custom_base_url, pattern_config)
|
|
552
|
+
# For now, fall back to the existing custom base URL method and add version
|
|
553
|
+
base_result = generate_with_custom_base_url(custom_base_url, pattern_config)
|
|
424
554
|
|
|
425
555
|
case @purl.type.downcase
|
|
426
556
|
when "npm"
|
|
427
|
-
|
|
557
|
+
"#{base_result}/v/#{@purl.version}"
|
|
428
558
|
when "pypi"
|
|
429
|
-
|
|
559
|
+
"#{base_result}#{@purl.version}/"
|
|
430
560
|
when "gem"
|
|
431
|
-
|
|
432
|
-
when "maven"
|
|
433
|
-
|
|
434
|
-
when "nuget"
|
|
435
|
-
@purl.version ? "#{registry_url}/#{@purl.version}" : registry_url
|
|
561
|
+
"#{base_result}/versions/#{@purl.version}"
|
|
562
|
+
when "maven", "nuget"
|
|
563
|
+
"#{base_result}/#{@purl.version}"
|
|
436
564
|
else
|
|
437
|
-
|
|
438
|
-
registry_url
|
|
565
|
+
base_result
|
|
439
566
|
end
|
|
440
567
|
end
|
|
441
568
|
|
|
442
|
-
private
|
|
443
|
-
|
|
444
569
|
def generate_with_custom_base_url(custom_base_url, pattern_config)
|
|
445
570
|
|
|
446
571
|
# Replace the base URL in the pattern lambda
|
data/lib/purl/version.rb
CHANGED