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.
@@ -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) if 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
- # Type-specific validation
83
- validate_type_specific_rules
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 is deferred until after qualifiers are set
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 validate_type_specific_rules
520
- case @type.downcase
521
- when "conan"
522
- validate_conan_specific_rules
523
- when "cran"
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 validate_conan_specific_rules
535
- # For conan packages, if a namespace is present WITHOUT any qualifiers at all,
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
- # If channel qualifier is present without namespace, user qualifier is also needed (test case 31)
548
- # But if namespace is present, channel alone can be valid (test case 29)
549
- if @qualifiers && @qualifiers["channel"] && @qualifiers["user"].nil? && @namespace.nil?
550
- raise ValidationError.new(
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
- # Swift packages require a version to be unambiguous
583
- if @version.nil?
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 validate_cpan_specific_rules
603
- # CPAN has complex rules about module vs distribution names
604
- # These test cases are checking for specific invalid patterns
605
-
606
- # Case 51: "Perl-Version" should be invalid (module name like distribution name)
607
- if @name == "Perl-Version"
608
- raise ValidationError.new(
609
- "CPAN module name 'Perl-Version' conflicts with distribution naming",
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
 
@@ -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
- # Use route_patterns directly from our extended registry config
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
- pattern_config[:route_patterns] || []
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
- if config[:route_patterns]
386
- result[type] = config[:route_patterns]
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
- # Use default base URL
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
- registry_url = generate(base_url: base_url)
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
- @purl.version ? "#{registry_url}/v/#{@purl.version}" : registry_url
557
+ "#{base_result}/v/#{@purl.version}"
428
558
  when "pypi"
429
- @purl.version ? "#{registry_url}#{@purl.version}/" : registry_url
559
+ "#{base_result}#{@purl.version}/"
430
560
  when "gem"
431
- @purl.version ? "#{registry_url}/versions/#{@purl.version}" : registry_url
432
- when "maven"
433
- @purl.version ? "#{registry_url}/#{@purl.version}" : registry_url
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
- # For other types, just return the base URL since version-specific URLs vary
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Purl
4
- VERSION = "1.2.0"
4
+ VERSION = "1.4.0"
5
5
  end