protocol-url 0.10.0 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7aeaada96a9b666006635d0589aefee1a0bbd4b0df7787a40a6c5d40faeb87f1
4
- data.tar.gz: 3bb9157f29504fd386b9da7730ad0e346371488a2cd440525ab501a5127d62e4
3
+ metadata.gz: 67f58bb48277d53b3817bb38b2b7c4546a8a8e1f3df62cea531c577705c4c833
4
+ data.tar.gz: 55bcfd924be9ae1eada0b2e5f8ee93fcd4a1a24c0693986ed3f8818b26072fd1
5
5
  SHA512:
6
- metadata.gz: 82707be363eedcc58a739f62acdca6b5b611f8a5e7e36992a2db6b48fdc2c950d7f0b72ebb38bd05cecee8fbeb42453cdb49a6be2a921053169a0bf633a998a7
7
- data.tar.gz: 808c99891a07593694ce6ab7542986ef7c95d53d4882c4efef79aec34fb35812824e4c69b7ce11e9fe44fa971f0ee8765408f2997db7e4f9fca25d497f314ece
6
+ metadata.gz: 7df97ce90f4ce934023217fbedf9cbf864febeee311b9b25e3ee952e647d75d8cc041f16665b84884fec1e1d2dad859f9f3d21a0bfdccf40f832938158a21924
7
+ data.tar.gz: bb2c1f9fece1eda9cb5ee8b96d216b28e0e15c6fe9605c6ec0d43a615b5b9a247f9bc299553e1d46778d9e6c5971e34d56bbd77f172202002ad63d7be89b53c3
checksums.yaml.gz.sig CHANGED
Binary file
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "relative"
7
7
 
@@ -14,9 +14,9 @@ module Protocol
14
14
  #
15
15
  # @parameter scheme [String] The URL scheme (e.g., "https", "http").
16
16
  # @parameter authority [String] The authority component (e.g., "example.com", "user@host:port").
17
- # @parameter path [String] The path component (defaults to "/").
18
- # @parameter query [String, nil] The query string.
19
- # @parameter fragment [String, nil] The fragment identifier.
17
+ # @parameter path [String | Path] The encoded path component (defaults to "/").
18
+ # @parameter query [String | Nil] The query string.
19
+ # @parameter fragment [String | Nil] The fragment identifier.
20
20
  def initialize(scheme, authority, path = "/", query = nil, fragment = nil)
21
21
  @scheme = scheme
22
22
  @authority = authority
@@ -25,11 +25,22 @@ module Protocol
25
25
  super(path, query, fragment)
26
26
  end
27
27
 
28
- # @attribute [String] The URL scheme.
29
- attr :scheme
28
+ # Freeze the URL and its direct components.
29
+ # @returns [Absolute] The frozen URL.
30
+ def freeze
31
+ return self if frozen?
32
+
33
+ @scheme.freeze
34
+ @authority.freeze
35
+
36
+ return super
37
+ end
30
38
 
31
- # @attribute [String] The authority component.
32
- attr :authority
39
+ # @attribute [String | Nil] The URL scheme.
40
+ attr_accessor :scheme
41
+
42
+ # @attribute [String | Nil] The authority component.
43
+ attr_accessor :authority
33
44
 
34
45
  # Check if the URL has a non-empty scheme.
35
46
  #
@@ -97,7 +108,7 @@ module Protocol
97
108
  end
98
109
  else
99
110
  # Relative path: merge with base path:
100
- path = Path.expand(@path, other.path)
111
+ path = @path.join(other.path)
101
112
  Absolute.new(@scheme, @authority, path, other.query, other.fragment)
102
113
  end
103
114
  end
@@ -111,11 +122,11 @@ module Protocol
111
122
 
112
123
  # Create a new Absolute URL with modified components.
113
124
  #
114
- # @parameter scheme [String, nil] The scheme to use (nil to remove scheme).
115
- # @parameter authority [String, nil] The authority to use (nil to remove authority).
116
- # @parameter path [String, nil] The path to merge with the current path.
117
- # @parameter query [String, nil] The query string to use.
118
- # @parameter fragment [String, nil] The fragment to use.
125
+ # @parameter scheme [String | Nil] The scheme to use (nil to remove scheme).
126
+ # @parameter authority [String | Nil] The authority to use (nil to remove authority).
127
+ # @parameter path [String | Nil] The path to merge with the current path.
128
+ # @parameter query [String | Nil] The query string to use.
129
+ # @parameter fragment [String | Nil] The fragment to use.
119
130
  # @parameter pop [Boolean] Whether to pop the last path component before merging.
120
131
  # @returns [Absolute] A new Absolute URL with the modified components.
121
132
  #
@@ -129,7 +140,9 @@ module Protocol
129
140
  # updated = url.with(query: "query=python")
130
141
  # updated.to_s # => "https://example.com/search?query=python"
131
142
  def with(scheme: @scheme, authority: @authority, path: nil, query: @query, fragment: @fragment, pop: true)
132
- self.class.new(scheme, authority, Path.expand(@path, path, pop), query, fragment)
143
+ path = @path.join(path, pop: pop) unless path.nil?
144
+
145
+ self.class.new(scheme, authority, path || @path, query, fragment)
133
146
  end
134
147
 
135
148
  # Convert the URL to an array representation.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  module Protocol
7
7
  module URL
@@ -29,6 +29,7 @@ module Protocol
29
29
  #
30
30
  # @parameter string [String] The string to unescape.
31
31
  # @returns [String] The unescaped string.
32
+ # @raises [ArgumentError] If the string contains malformed percent encoding.
32
33
  #
33
34
  # @example Unescape spaces and special characters.
34
35
  # Encoding.unescape("hello%20world%21")
@@ -38,60 +39,66 @@ module Protocol
38
39
  # Encoding.unescape("caf%C3%A9")
39
40
  # # => "café"
40
41
  def self.unescape(string, encoding = string.encoding)
41
- string.b.gsub(/%(\h\h)/) do |hex|
42
- Integer($1, 16).chr
42
+ string.b.gsub(/%([0-9A-Fa-f]{2})?/) do
43
+ unless hexadecimal = $1
44
+ raise ArgumentError, "String contains malformed percent encoding!"
45
+ end
46
+
47
+ Integer(hexadecimal, 16).chr
43
48
  end.force_encoding(encoding)
44
49
  end
45
50
 
46
- # Unescapes a percent encoded path component, preserving encoded path separators.
47
- #
48
- # This method unescapes percent-encoded characters except for path separators
49
- # (forward slash `/` and backslash `\`). This prevents encoded separators like
50
- # `%2F` or `%5C` from being decoded into actual path separators, which could
51
- # allow bypassing path component boundaries.
52
- #
53
- # @parameter string [String] The path component to unescape.
54
- # @returns [String] The unescaped string with separators still encoded.
55
- #
56
- # @example
57
- # Encoding.unescape_path("hello%20world") # => "hello world"
58
- # Encoding.unescape_path("safe%2Fname") # => "safe%2Fname" (%2F not decoded)
59
- # Encoding.unescape_path("name%5Cfile") # => "name%5Cfile" (%5C not decoded)
60
- def self.unescape_path(string, encoding = string.encoding)
61
- string.b.gsub(/%(\h\h)/) do |hex|
62
- byte = Integer($1, 16)
63
- char = byte.chr
51
+ # Maps individual URL path segments to and from local filesystem components.
52
+ #
53
+ # Unlike generic URL decoding, this encoding rejects values which would turn one
54
+ # URL segment into multiple local path components.
55
+ module System
56
+ ENCODING = ::Encoding.find("filesystem")
57
+ INVALID_CHARACTER_PATTERN = Regexp.union(["\0", File::SEPARATOR, File::ALT_SEPARATOR].compact)
58
+
59
+ # Encode one local filesystem component as one URL path segment.
60
+ # @parameter component [String] The local filesystem component.
61
+ # @returns [String] The encoded URL segment.
62
+ # @raises [ArgumentError] If the component cannot be converted or contains a system path separator.
63
+ def self.escape(component)
64
+ validate(component)
65
+ Encoding.escape(transcode(component, ::Encoding::UTF_8))
66
+ end
67
+
68
+ # Decode one URL path segment as one local filesystem component.
69
+ # @parameter segment [String] The encoded URL segment.
70
+ # @returns [String] The local filesystem component.
71
+ # @raises [ArgumentError] If the segment cannot map to one local filesystem component.
72
+ def self.unescape(segment)
73
+ component = Encoding.unescape(segment, ::Encoding::UTF_8)
74
+ validate(component)
75
+ transcode(component, ENCODING)
76
+ end
77
+
78
+ # Transcode a path component to the requested character encoding.
79
+ def self.transcode(component, encoding)
80
+ component.encode(encoding)
81
+ rescue ::Encoding::InvalidByteSequenceError, ::Encoding::UndefinedConversionError
82
+ raise ArgumentError, "Path component could not be transcoded!"
83
+ end
84
+
85
+ # Validate that a string can represent exactly one local filesystem component.
86
+ def self.validate(component)
87
+ unless component.valid_encoding?
88
+ raise ArgumentError, "Path component has invalid encoding!"
89
+ end
64
90
 
65
- # Don't decode forward slash (0x2F) or backslash (0x5C)
66
- if byte == 0x2F || byte == 0x5C
67
- hex # Keep as %2F or %5C
68
- else
69
- char
91
+ if INVALID_CHARACTER_PATTERN.match?(component)
92
+ raise ArgumentError, "Path component contains invalid characters!"
70
93
  end
71
- end.force_encoding(encoding)
94
+ end
95
+ private_class_method :transcode, :validate
96
+ private_constant :ENCODING, :INVALID_CHARACTER_PATTERN
72
97
  end
73
98
 
74
- # Matches characters that are not allowed in a URI path segment. According to RFC 3986 Section 3.3 (https://tools.ietf.org/html/rfc3986#section-3.3), a valid path segment consists of "pchar" characters. This pattern identifies characters that must be percent-encoded when included in a URI path segment.
75
- NON_PATH_CHARACTER_PATTERN = /([^a-zA-Z0-9_\-\.~!$&'()*+,;=:@\/]+)/.freeze
76
-
77
99
  # Matches characters that are not allowed in a URI fragment. According to RFC 3986 Section 3.5, a valid fragment consists of pchar / "/" / "?" characters.
78
100
  NON_FRAGMENT_CHARACTER_PATTERN = /([^a-zA-Z0-9_\-\.~!$&'()*+,;=:@\/\?]+)/.freeze
79
101
 
80
- # Escapes non-path characters using percent encoding. In other words, this method escapes characters that are not allowed in a URI path segment. According to RFC 3986 Section 3.3 (https://tools.ietf.org/html/rfc3986#section-3.3), a valid path segment consists of "pchar" characters. This method percent-encodes characters that are not "pchar" characters.
81
- #
82
- # @parameter path [String] The path to escape.
83
- # @returns [String] The escaped path.
84
- #
85
- # @example Escape spaces while preserving path separators.
86
- # Encoding.escape_path("/documents/my reports/summary.pdf")
87
- # # => "/documents/my%20reports/summary.pdf"
88
- def self.escape_path(path)
89
- encoding = path.encoding
90
- path.b.gsub(NON_PATH_CHARACTER_PATTERN) do |m|
91
- "%" + m.unpack("H2" * m.bytesize).join("%").upcase
92
- end.force_encoding(encoding)
93
- end
94
-
95
102
  # Escapes non-fragment characters using percent encoding. According to RFC 3986 Section 3.5, fragments can contain pchar / "/" / "?" characters.
96
103
  #
97
104
  # @parameter fragment [String] The fragment to escape.
@@ -118,13 +125,13 @@ module Protocol
118
125
  def self.encode(value, prefix = nil)
119
126
  case value
120
127
  when Array
121
- return value.map {|v|
128
+ return value.map do |v|
122
129
  self.encode(v, "#{prefix}[]")
123
- }.join("&")
130
+ end.join("&")
124
131
  when Hash
125
- return value.map {|k, v|
132
+ return value.map do |k, v|
126
133
  self.encode(v, prefix ? "#{prefix}[#{escape(k.to_s)}]" : escape(k.to_s))
127
- }.reject(&:empty?).join("&")
134
+ end.reject(&:empty?).join("&")
128
135
  when nil
129
136
  return prefix
130
137
  else