protocol-url 0.10.0 → 0.11.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: 1b1993c620685f91b17b6a707eace1ff03219c1d6e2eace3d272e8ec8c723680
4
+ data.tar.gz: 8969a20bccae90e49a5d541ea9f384127aaf817e7df12b1ac8b16a27ba450b1a
5
5
  SHA512:
6
- metadata.gz: 82707be363eedcc58a739f62acdca6b5b611f8a5e7e36992a2db6b48fdc2c950d7f0b72ebb38bd05cecee8fbeb42453cdb49a6be2a921053169a0bf633a998a7
7
- data.tar.gz: 808c99891a07593694ce6ab7542986ef7c95d53d4882c4efef79aec34fb35812824e4c69b7ce11e9fe44fa971f0ee8765408f2997db7e4f9fca25d497f314ece
6
+ metadata.gz: 46b12bedaa0fca57e658677545a09ea2839ce6496c149eaa30c10b5cd213d3abbc517f850ddffce5cc159a8c5a73cb99be10c1737e06ed75974ace4448beca70
7
+ data.tar.gz: 513ab9374d04bf04512d11f8610dbae6a14deea9c27a70b1d7a813df569c6642ca1eb679a5f51ad4b99319980f0b5f6c178d895c12de0c2037726337261dbce2
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,7 +14,7 @@ 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 "/").
17
+ # @parameter path [String | Path] The encoded path component (defaults to "/").
18
18
  # @parameter query [String, nil] The query string.
19
19
  # @parameter fragment [String, nil] The fragment identifier.
20
20
  def initialize(scheme, authority, path = "/", query = nil, fragment = nil)
@@ -25,6 +25,17 @@ module Protocol
25
25
  super(path, query, fragment)
26
26
  end
27
27
 
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
38
+
28
39
  # @attribute [String] The URL scheme.
29
40
  attr :scheme
30
41
 
@@ -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
@@ -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
@@ -1,122 +1,62 @@
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 "encoding"
7
7
 
8
8
  module Protocol
9
9
  module URL
10
- # Represents a relative URL, which does not include a scheme or authority.
11
- module Path
12
- # Split the given path into its components.
13
- #
14
- # - `split("")` => `[]`
15
- # - `split("/")` => `["", ""]`
16
- # - `split("/a/b/c")` => `["", "a", "b", "c"]`
17
- # - `split("a/b/c/")` => `["a", "b", "c", ""]`
18
- #
19
- # @parameter path [String] The path to split.
20
- # @returns [Array(String)] The path components.
21
- #
22
- # @example Split an absolute path.
23
- # Path.split("/documents/report.pdf")
24
- # # => ["", "documents", "report.pdf"]
25
- #
26
- # @example Split a relative path.
27
- # Path.split("images/logo.png")
28
- # # => ["images", "logo.png"]
29
- def self.split(path)
30
- return path.split("/", -1)
31
- end
32
-
33
- # Join the given path components into a single path.
34
- #
35
- # @parameter components [Array(String)] The path components to join.
36
- # @returns [String] The joined path.
37
- #
38
- # @example Join absolute path components.
39
- # Path.join(["", "documents", "report.pdf"])
40
- # # => "/documents/report.pdf"
41
- #
42
- # @example Join relative path components.
43
- # Path.join(["images", "logo.png"])
44
- # # => "images/logo.png"
45
- def self.join(components)
46
- return components.join("/")
47
- end
48
-
49
- # Simplify the given path components by resolving "." and "..".
50
- #
51
- # @parameter components [Array(String)] The path components to simplify.
52
- # @returns [Array(String)] The simplified path components.
53
- #
54
- # @example Resolve parent directory references.
55
- # Path.simplify(["documents", "reports", "..", "invoices", "2024.pdf"])
56
- # # => ["documents", "invoices", "2024.pdf"]
57
- #
58
- # @example Remove current directory references.
59
- # Path.simplify(["documents", ".", "report.pdf"])
60
- # # => ["documents", "report.pdf"]
61
- def self.simplify(components)
62
- output = []
63
-
64
- components.each_with_index do |component, index|
65
- if index == 0 && component == ""
66
- # Preserve leading slash:
67
- output << ""
68
- elsif component == "."
69
- # Handle current directory - trailing . means directory, preserve trailing slash:
70
- output << "" if index == components.size - 1
71
- elsif component == "" && index != components.size - 1
72
- # Ignore empty segments (multiple slashes) except at end - no-op.
73
- elsif component == ".." && output.last && output.last != ".."
74
- # Handle parent directory: go up one level if not at root:
75
- output.pop if output.last != ""
76
- # Trailing .. means directory, preserve trailing slash:
77
- output << "" if index == components.size - 1
78
- else
79
- # Regular path component:
80
- output << component
81
- end
10
+ # Represents a URL path without losing its encoded segment boundaries.
11
+ #
12
+ # String input is interpreted as an encoded URL path. A literal `/` is structural,
13
+ # while `%2F` remains encoded data within a single segment. Decoding is explicit and
14
+ # controlled by the encoding object passed to {components}.
15
+ class Path
16
+ include Comparable
17
+
18
+ # The path separator.
19
+ SEPARATOR = "/"
20
+
21
+ EMPTY_SEGMENTS = [].freeze
22
+ ROOT_SEGMENTS = ["", ""].freeze
23
+ private_constant :EMPTY_SEGMENTS, :ROOT_SEGMENTS
24
+
25
+ # Coerce an encoded string or encoded segment array into a path.
26
+ #
27
+ # @parameter path [String | Array(String) | Path] The encoded value to coerce.
28
+ # @returns [Path] The coerced path, or the existing path unchanged.
29
+ def self.[](path)
30
+ if path.is_a?(self)
31
+ return path
32
+ elsif path.is_a?(Array)
33
+ return self.new(nil, path)
34
+ else
35
+ return self.new(path.to_s)
82
36
  end
83
-
84
- return output
85
37
  end
86
38
 
87
- # @parameter pop [Boolean] whether to remove the last path component of the base path, to conform to URI merging behaviour, as defined by RFC2396.
39
+ # Construct a path from decoded components.
88
40
  #
89
- # @example Expand a relative path against a base path.
90
- # Path.expand("/documents/reports/", "invoices/2024.pdf")
91
- # # => "/documents/reports/invoices/2024.pdf"
41
+ # Each component is escaped independently, so decoded `/` characters remain data
42
+ # inside one encoded segment rather than becoming structural separators.
92
43
  #
93
- # @example Navigate to parent directory.
94
- # Path.expand("/documents/reports/2024/", "../summary.pdf")
95
- # # => "/documents/reports/summary.pdf"
96
- def self.expand(base, relative, pop = true)
97
- # Empty relative path means no change:
98
- return base if relative.nil? || relative.empty?
99
-
100
- components = split(base)
101
-
102
- # RFC2396 Section 5.2:
103
- # 6) a) All but the last segment of the base URI's path component is
104
- # copied to the buffer. In other words, any characters after the
105
- # last (right-most) slash character, if any, are excluded.
106
- if pop and components.last != ".."
107
- components.pop
108
- elsif components.last == ""
109
- components.pop
110
- end
111
-
112
- relative = relative.split("/", -1)
113
- if relative.first == ""
114
- components = relative
115
- else
116
- components.concat(relative)
44
+ # @parameter components [Array(String)] The decoded path components.
45
+ # @parameter encoding [Object] An object implementing `escape(String)`.
46
+ # @returns [Path] The encoded path.
47
+ # @raises [ArgumentError] If the encoding does not produce one valid encoded segment per component.
48
+ def self.for(components, encoding: Encoding)
49
+ segments = components.map do |component|
50
+ segment = encoding.escape(component)
51
+
52
+ unless segment.is_a?(String) && !segment.include?(SEPARATOR)
53
+ raise ArgumentError, "Path encoding produced an invalid segment!"
54
+ end
55
+
56
+ segment
117
57
  end
118
58
 
119
- return join(simplify(components))
59
+ return self.new(nil, segments)
120
60
  end
121
61
 
122
62
  # Calculate the relative path from one absolute path to another.
@@ -136,58 +76,368 @@ module Protocol
136
76
  # Path.relative("/docs/guide.html", "/docs/index.html")
137
77
  # # => "guide.html"
138
78
  def self.relative(target, from)
139
- target_components = split(target)
140
- from_components = split(from)
79
+ return Path[target].relative(from).to_s
80
+ end
81
+
82
+ # Initialize a path from either its complete encoded representation or encoded segments.
83
+ #
84
+ # @parameter encoded [String | Nil] The encoded URL path.
85
+ # @parameter segments [Array(String) | Nil] The encoded path segments.
86
+ # @raises [ArgumentError] If an encoded segment contains a structural separator.
87
+ def initialize(encoded, segments = nil)
88
+ if encoded
89
+ @encoded = -encoded
90
+ end
91
+
92
+ if encoded.nil? && segments.nil?
93
+ segments = EMPTY_SEGMENTS
94
+ elsif segments
95
+ segments.each do |segment|
96
+ unless segment.is_a?(String) && !segment.include?(SEPARATOR)
97
+ raise ArgumentError, "Path contains an invalid encoded segment!"
98
+ end
99
+ end
100
+
101
+ segments = segments.map(&:-@).freeze
102
+ end
103
+
104
+ @segments = segments
105
+ end
106
+
107
+ # Freeze the path and materialize both lossless representations.
108
+ # @returns [Path] The frozen path.
109
+ def freeze
110
+ return self if frozen?
111
+
112
+ self.segments
113
+ self.encoded
114
+
115
+ return super
116
+ end
117
+
118
+ # @returns [Boolean] Whether the path begins at the URL path root.
119
+ def absolute?
120
+ encoded.start_with?(SEPARATOR)
121
+ end
122
+
123
+ # @returns [Boolean] Whether the path is relative to another URL path.
124
+ def relative?
125
+ !absolute?
126
+ end
127
+
128
+ # @returns [Boolean] Whether the path has a trailing separator.
129
+ def directory?
130
+ encoded.end_with?(SEPARATOR)
131
+ end
132
+
133
+ # The final decoded component. A path with a trailing separator has an empty basename.
134
+ #
135
+ # @parameter extension [Boolean] Whether to include the final file extension.
136
+ # @returns [String | Nil] The final component, or `nil` for an empty path.
137
+ def basename(extension: true)
138
+ component = self.components.last
139
+ return component if extension || component.nil?
140
+
141
+ if index = component.rindex(".")
142
+ basename = component[0...index]
143
+ return basename if basename.b.match?(/[^.]/n)
144
+ end
145
+
146
+ return component
147
+ end
148
+
149
+ # Return a path with its final component removed.
150
+ #
151
+ # The empty path and absolute root are their own parents. For a directory path,
152
+ # this removes the trailing empty component which represents its separator.
153
+ #
154
+ # @parameter level [Integer] The number of components to remove.
155
+ # @returns [Path] The parent path.
156
+ # @raises [ArgumentError] If `level` is not a non-negative integer.
157
+ def parent(level = 1)
158
+ unless level.is_a?(Integer) && level >= 0
159
+ raise ArgumentError, "Path parent level must be a non-negative integer!"
160
+ end
161
+
162
+ segments = self.segments
163
+ return self if level == 0 || segments.empty? || segments == ROOT_SEGMENTS
164
+
165
+ remaining = segments.size - level
166
+ if absolute?
167
+ segments = remaining <= 1 ? ROOT_SEGMENTS : segments.first(remaining)
168
+ else
169
+ segments = remaining <= 0 ? EMPTY_SEGMENTS : segments.first(remaining)
170
+ end
171
+
172
+ return self.class.new(nil, segments)
173
+ end
174
+
175
+ # @returns [Array(String)] The encoded segments, preserving their exact spelling.
176
+ def segments
177
+ @segments ||= @encoded.split(SEPARATOR, -1).map!(&:-@).freeze
178
+ end
179
+
180
+ # Decode the path segments using the given encoding.
181
+ #
182
+ # The result is not cached because different encoding objects can produce different
183
+ # component values. In particular, a decoded component may contain `/` without
184
+ # changing its boundary in the returned array.
185
+ #
186
+ # @parameter encoding [Object] An object implementing `unescape(String)`.
187
+ # @returns [Array(String)] The decoded components.
188
+ def components(encoding = Encoding)
189
+ segments.map{|segment| encoding.unescape(segment)}
190
+ end
191
+
192
+ # @returns [String] The encoded URL path.
193
+ def encoded
194
+ @encoded ||= @segments.join(SEPARATOR).freeze
195
+ end
196
+
197
+ # @returns [Boolean] Whether the path contains no components.
198
+ def empty?
199
+ encoded.empty?
200
+ end
201
+
202
+ # Paths compare by their exact encoded representation.
203
+ def <=>(other)
204
+ return nil unless other.is_a?(Path)
205
+
206
+ encoded <=> other.encoded
207
+ end
208
+
209
+ # @parameter other [Object] The value to compare with this path.
210
+ # @returns [Boolean] Whether both paths have the same encoded representation.
211
+ def ==(other)
212
+ eql?(other)
213
+ end
214
+
215
+ # Compare this path with another path using exact encoded string identity.
216
+ # @parameter other [Object] The value to compare with this path.
217
+ # @returns [Boolean] Whether both paths have equal encoded strings.
218
+ def eql?(other)
219
+ other.is_a?(Path) && encoded.eql?(other.encoded)
220
+ end
221
+
222
+ # @returns [Integer] A hash derived from the exact encoded representation.
223
+ def hash
224
+ encoded.hash
225
+ end
226
+
227
+ # Resolve a URL path beneath a local filesystem root.
228
+ #
229
+ # Each decoded URL component must map to exactly one local path component. Components
230
+ # containing NUL or a platform path separator cannot be represented and are rejected.
231
+ # Absolute URL paths are interpreted relative to `root`, not the filesystem root.
232
+ #
233
+ # @parameter root [String] The filesystem root beneath which to resolve the URL path.
234
+ # @returns [String] The expanded local filesystem path.
235
+ # @raises [ArgumentError] If a URL segment is invalid or the path escapes the specified root.
236
+ #
237
+ # This establishes lexical containment only. It does not resolve symbolic links or
238
+ # prevent filesystem races while a returned path is subsequently opened.
239
+ def local_path(root)
240
+ root = File.expand_path(root)
241
+ root_prefix = root.end_with?(File::SEPARATOR) ? root : root + File::SEPARATOR
242
+
243
+ components = self.components(Encoding::System)
244
+ components.shift if components.first == ""
245
+
246
+ path = File.expand_path(File.join(root, *components))
247
+ return path if path == root || path.start_with?(root_prefix)
248
+
249
+ raise ArgumentError, "Path escapes the specified root!"
250
+ end
251
+
252
+ alias to_s encoded
253
+ alias to_str encoded
254
+
255
+ # Simplify this path in place by resolving literal or percent-encoded dot segments and repeated separators.
256
+ #
257
+ # @returns [Path | Nil] This path when changed, otherwise `nil`.
258
+ def simplify!
259
+ simplified = simplify
260
+ return nil if simplified.equal?(self)
261
+
262
+ @encoded = simplified.encoded
263
+ @segments = simplified.segments
264
+
265
+ return self
266
+ end
267
+
268
+ # Return a canonical path by resolving literal or percent-encoded dot segments and repeated separators.
269
+ #
270
+ # Absolute paths do not retain parent components above the root. Relative paths
271
+ # retain leading parent components which cannot be resolved locally.
272
+ #
273
+ # @returns [Path] The simplified path, or this path if already canonical.
274
+ def simplify
275
+ segments = simplify_segments
276
+ return self unless segments
277
+
278
+ return self.class.new(nil, segments)
279
+ end
280
+
281
+ # Resolve another path relative to this path.
282
+ #
283
+ # @parameter other [String | Array(String) | Path] The path to resolve.
284
+ # @parameter pop [Boolean] Whether to remove the final base component first.
285
+ # @parameter simplify [Boolean] Whether to simplify the resulting components.
286
+ # @returns [Path] The resolved path.
287
+ def join(other, pop: true, simplify: true)
288
+ other = Path[other]
289
+ return self if other.empty?
290
+
291
+ if other.absolute?
292
+ return simplify ? other.simplify : other
293
+ end
294
+
295
+ segments = self.segments.dup
296
+
297
+ # RFC2396 Section 5.2:
298
+ # 6) a) All but the last segment of the base URI's path component is
299
+ # copied to the buffer. In other words, any characters after the
300
+ # last (right-most) slash character, if any, are excluded.
301
+ if pop and dot_segment(segments.last) != ".."
302
+ segments.pop
303
+ elsif segments.last == ""
304
+ segments.pop
305
+ end
306
+
307
+ segments.concat(other.segments)
308
+
309
+ if simplify
310
+ simplify_segments!(segments)
311
+ end
312
+
313
+ return Path.new(nil, segments)
314
+ end
315
+
316
+ # Calculate this path relative to another path.
317
+ #
318
+ # @parameter from [String | Array(String) | Path] The source path.
319
+ # @returns [Path] The relative path from `from` to this path.
320
+ def relative(from)
321
+ target_segments = self.segments
322
+ from_segments = Path[from].segments
141
323
 
142
324
  # Remove the last component from 'from' to get the directory
143
- from_components = from_components[0...-1] if from_components.size > 0
325
+ from_segments = from_segments[0...-1] if from_segments.size > 0
144
326
 
145
327
  # Find the common prefix
146
328
  common_length = 0
147
- [target_components.size, from_components.size].min.times do |i|
148
- break if target_components[i] != from_components[i]
329
+ [target_segments.size, from_segments.size].min.times do |i|
330
+ break if target_segments[i] != from_segments[i]
149
331
  common_length = i + 1
150
332
  end
151
333
 
152
334
  # Calculate how many levels to go up
153
- up_levels = from_components.size - common_length
335
+ up_levels = from_segments.size - common_length
154
336
 
155
- # Build the relative path components
156
- relative_components = [".."] * up_levels + target_components[common_length..-1]
337
+ # Build the relative path segments
338
+ relative_segments = [".."] * up_levels + target_segments[common_length..-1]
157
339
 
158
- return join(relative_components)
340
+ return Path.new(nil, relative_segments)
159
341
  end
160
342
 
161
- # Convert a URL path to a local file system path using the platform's file separator.
162
- #
163
- # This method splits the URL path on `/` characters, unescapes each component using
164
- # {Encoding.unescape_path} (which preserves encoded separators), then joins the
165
- # components using `File.join`.
166
- #
167
- # Percent-encoded path separators (`%2F` for `/` and `%5C` for `\`) are NOT decoded,
168
- # preventing them from being interpreted as directory boundaries. This ensures that
169
- # URL path components map directly to file system path components.
170
- #
171
- # @parameter path [String] The URL path to convert (should be percent-encoded).
172
- # @returns [String] The local file system path.
173
- #
174
- # @example Generating local paths.
175
- # Path.to_local_path("/documents/report.pdf") # => "/documents/report.pdf"
176
- # Path.to_local_path("/files/My%20Document.txt") # => "/files/My Document.txt"
343
+ private
344
+
345
+ # Identify dot segments, including percent-encoded spellings. RFC 3986 treats
346
+ # percent-encoded unreserved characters as equivalent to their literal forms;
347
+ # the WHATWG URL Standard explicitly recognizes `%2e`, `.%2e`, `%2e.`, and
348
+ # `%2e%2e` as dot segments, case-insensitively.
177
349
  #
178
- # @example Preserves encoded separators.
179
- # Path.to_local_path("/folder/safe%2Fname/file.txt")
180
- # # => "/folder/safe%2Fname/file.txt"
181
- # # %2F is NOT decoded to prevent creating additional path components
182
- def self.to_local_path(path)
183
- components = split(path)
350
+ # This classification does not decode or rewrite the stored encoded segment.
351
+ # Paths retain their exact encoded representation unless a structural operation
352
+ # removes the segment. General percent-encoding normalization, such as decoding
353
+ # other unreserved characters or uppercasing hexadecimal digits, must be an
354
+ # explicit operation rather than part of lossless path storage or simplification.
355
+ def dot_segment(segment)
356
+ return nil unless segment
357
+ return "." if segment.match?(/\A(?:\.|%2e)\z/i)
358
+ return ".." if segment.match?(/\A(?:\.|%2e){2}\z/i)
359
+ end
360
+
361
+ # Find the first encoded segment which requires simplification.
362
+ def simplification_index(segments)
363
+ absolute = segments.first == ""
364
+ regular_segment = false
365
+ last_index = segments.size - 1
366
+
367
+ segments.each_with_index do |segment, index|
368
+ dot = dot_segment(segment)
369
+
370
+ if dot == "."
371
+ return index
372
+ elsif segment == ""
373
+ # Leading and trailing empty components are significant.
374
+ return index if index > 0 && index < last_index
375
+ elsif dot == ".."
376
+ # Absolute paths cannot retain parent components. Relative paths
377
+ # can retain them only before the first regular component.
378
+ return index if absolute || regular_segment
379
+ else
380
+ regular_segment = true
381
+ end
382
+ end
383
+
384
+ return nil
385
+ end
386
+
387
+ # Return simplified encoded segments, or nil if they are already canonical.
388
+ def simplify_segments
389
+ segments = self.segments
390
+ return nil unless start_index = simplification_index(segments)
391
+
392
+ segments = segments.dup
393
+ simplify_segments!(segments, start_index)
394
+
395
+ return segments
396
+ end
397
+
398
+ # Simplify the given encoded segments in place.
399
+ def simplify_segments!(segments, start_index = nil)
400
+ start_index ||= simplification_index(segments)
401
+ return nil unless start_index
402
+
403
+ offset = start_index
404
+ index = start_index
405
+ last_index = segments.size - 1
406
+
407
+ while index <= last_index
408
+ segment = segments[index]
409
+ dot = dot_segment(segment)
410
+
411
+ if dot == "."
412
+ # A trailing dot denotes a directory.
413
+ if index == last_index
414
+ segments[offset] = ""
415
+ offset += 1
416
+ end
417
+ elsif segment == "" && index != last_index
418
+ # Collapse repeated separators.
419
+ elsif dot == ".." && offset > 0 && dot_segment(segments[offset - 1]) != ".."
420
+ # Pop a component, but never pop the absolute-path root.
421
+ offset -= 1 if segments[offset - 1] != ""
422
+
423
+ # A trailing parent reference also denotes a directory.
424
+ if index == last_index
425
+ segments[offset] = ""
426
+ offset += 1
427
+ end
428
+ else
429
+ segments[offset] = segment if offset < index
430
+ offset += 1
431
+ end
432
+
433
+ index += 1
434
+ end
184
435
 
185
- # Unescape each component, preserving encoded path separators
186
- components.map! do |component|
187
- Encoding.unescape_path(component)
436
+ if offset < segments.size
437
+ segments[offset, segments.size - offset] = EMPTY_SEGMENTS
188
438
  end
189
439
 
190
- return File.join(*components)
440
+ return segments
191
441
  end
192
442
  end
193
443
  end
@@ -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 "pattern"
7
7
  require_relative "encoding"
@@ -21,8 +21,8 @@ module Protocol
21
21
  #
22
22
  # This method provides flexible conversion from various types into a {Reference}.
23
23
  # When given a {String}, it parses the URL-encoded path, query, and fragment components
24
- # and unescapes them for internal storage. When given a {Relative}, it converts the
25
- # encoded values to unescaped form suitable for {Reference} instances.
24
+ # and preserves the path as a {Path}. When given a {Relative}, it preserves the
25
+ # existing path and its component boundaries.
26
26
  #
27
27
  # @parameter value [String | Relative | Nil] The value to coerce.
28
28
  # @parameter parameters [Hash | Nil] Optional user-supplied parameters to append to the query string.
@@ -34,7 +34,7 @@ module Protocol
34
34
  #
35
35
  # @example Coerce a string with path, query, and fragment.
36
36
  # reference = Reference["/search?q=ruby#results"]
37
- # reference.path # => "/search"
37
+ # reference.path.to_s # => "/search"
38
38
  # reference.query # => "q=ruby"
39
39
  # reference.fragment # => "results"
40
40
  #
@@ -45,7 +45,7 @@ module Protocol
45
45
  # @example Coerce a Relative instance.
46
46
  # relative = Relative.new("/path%20with%20spaces", nil, "top")
47
47
  # reference = Reference[relative]
48
- # reference.path # => "/path with spaces"
48
+ # reference.path.components # => ["", "path with spaces"]
49
49
  def self.[](value, parameters = nil)
50
50
  case value
51
51
  when String
@@ -54,9 +54,8 @@ module Protocol
54
54
  query = match[:query]
55
55
  fragment = match[:fragment]
56
56
 
57
- # Unescape path and fragment for user-friendly internal storage:
58
- # Query strings are kept as-is since they contain = and & syntax
59
- path = Encoding.unescape(path) if path && !path.empty?
57
+ # Paths retain their encoded structure while exposing decoded components.
58
+ path = Path[path]
60
59
  fragment = Encoding.unescape(fragment) if fragment
61
60
 
62
61
  self.new(path, query, fragment, parameters)
@@ -64,11 +63,9 @@ module Protocol
64
63
  raise ArgumentError, "Invalid URL (contains whitespace or control characters): #{value.inspect}"
65
64
  end
66
65
  when Relative
67
- # Relative stores encoded values, so we need to unescape them for Reference:
66
+ # Relative stores an encoded path; preserve its component boundaries.
68
67
  path = value.path
69
68
  fragment = value.fragment
70
-
71
- path = Encoding.unescape(path) if path && !path.empty?
72
69
  fragment = Encoding.unescape(fragment) if fragment
73
70
 
74
71
  self.new(path, value.query, fragment, parameters)
@@ -77,20 +74,22 @@ module Protocol
77
74
  else
78
75
  raise ArgumentError, "Cannot coerce #{value.inspect} to Reference!"
79
76
  end
80
- end # Generate a reference from a path and user parameters. The path may contain a `#fragment` or `?query=parameters`.
77
+ end
78
+
79
+ # Generate a reference from a path and user parameters. The path may contain a `#fragment` or `?query=parameters`.
81
80
  #
82
81
  # @example Parse a path with query and fragment.
83
82
  # reference = Reference.parse("/search?query=ruby#results")
84
- # reference.path # => "/search"
83
+ # reference.path.to_s # => "/search"
85
84
  # reference.query # => "query=ruby"
86
85
  # reference.fragment # => "results"
87
86
  def self.parse(value = "/", parameters = nil)
88
87
  self.[](value, parameters)
89
88
  end
90
89
 
91
- # Initialize the reference with raw, unescaped values.
90
+ # Initialize the reference from an encoded path and reference values.
92
91
  #
93
- # @parameter path [String] The unescaped path.
92
+ # @parameter path [String | Path] The encoded path string, or an existing path.
94
93
  # @parameter query [String | Nil] An already-formatted query string.
95
94
  # @parameter fragment [String | Nil] The unescaped fragment.
96
95
  # @parameter parameters [Hash | Nil] User supplied parameters that will be safely encoded.
@@ -161,10 +160,10 @@ module Protocol
161
160
  end
162
161
 
163
162
  # Append the reference to the given buffer.
164
- # Encodes the path and fragment which are stored unescaped internally.
163
+ # Encodes the fragment; the path already retains its encoded structure.
165
164
  # Query strings are passed through as-is (they contain = and & which are valid syntax).
166
165
  def append(buffer = String.new)
167
- buffer << Encoding.escape_path(@path)
166
+ buffer << @path.encoded
168
167
 
169
168
  if @query and !@query.empty?
170
169
  buffer << "?" << @query
@@ -185,7 +184,7 @@ module Protocol
185
184
  other = self.class[other]
186
185
 
187
186
  self.class.new(
188
- Path.expand(self.path, other.path, true),
187
+ @path.join(other.path),
189
188
  other.query,
190
189
  other.fragment,
191
190
  other.parameters,
@@ -199,7 +198,7 @@ module Protocol
199
198
 
200
199
  # Update the reference with the given path, query, fragment, and parameters.
201
200
  #
202
- # @parameter path [String] Append the string to this reference similar to `File.join`.
201
+ # @parameter path [String | Path] Append the encoded path to this reference similar to `File.join`.
203
202
  # @parameter query [String | Nil] Replace the query string. Defaults to keeping the existing query if not specified.
204
203
  # @parameter fragment [String | Nil] Replace the fragment. Defaults to keeping the existing fragment if not specified.
205
204
  # @parameter parameters [Hash | false] Parameters to merge or replace. Pass `false` (default) to keep existing parameters.
@@ -251,7 +250,11 @@ module Protocol
251
250
  end
252
251
  end
253
252
 
254
- path = Path.expand(@path, path, pop)
253
+ if path.nil?
254
+ path = @path
255
+ else
256
+ path = @path.join(path, pop: pop)
257
+ end
255
258
 
256
259
  self.class.new(path, query, fragment, parameters)
257
260
  end
@@ -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 "encoding"
7
7
  require_relative "path"
@@ -14,16 +14,28 @@ module Protocol
14
14
 
15
15
  # Initialize a new relative URL.
16
16
  #
17
- # @parameter path [String] The path component.
17
+ # @parameter path [String | Path] The encoded path component.
18
18
  # @parameter query [String, nil] The query string.
19
19
  # @parameter fragment [String, nil] The fragment identifier.
20
20
  def initialize(path, query = nil, fragment = nil)
21
- @path = path.to_s
21
+ @path = Path[path]
22
22
  @query = query
23
23
  @fragment = fragment
24
24
  end
25
25
 
26
- # @attribute [String] The path component of the URL.
26
+ # Freeze the URL and its direct components.
27
+ # @returns [Relative] The frozen URL.
28
+ def freeze
29
+ return self if frozen?
30
+
31
+ @path.freeze
32
+ @query.freeze
33
+ @fragment.freeze
34
+
35
+ return super
36
+ end
37
+
38
+ # @attribute [Path] The path component of the URL.
27
39
  attr :path
28
40
 
29
41
  # @attribute [String, nil] The query string component.
@@ -32,13 +44,17 @@ module Protocol
32
44
  # @attribute [String, nil] The fragment identifier.
33
45
  attr :fragment
34
46
 
35
- # Convert the URL path to a local filesystem path.
47
+ # Resolve the URL path beneath a local filesystem root.
36
48
  #
37
- # @returns [String] The local filesystem path.
38
- def to_local_path
39
- Path.to_local_path(@path)
49
+ # @parameter root [String] The filesystem root beneath which to resolve the URL path.
50
+ # @returns [String] The expanded local filesystem path.
51
+ # @raises [ArgumentError] If a URL segment is invalid or the path escapes the specified root.
52
+ def local_path(root)
53
+ @path.local_path(root)
40
54
  end
41
55
 
56
+ alias to_local_path local_path
57
+
42
58
  # @returns [Boolean] If there is a query string.
43
59
  def query?
44
60
  @query and !@query.empty?
@@ -58,13 +74,13 @@ module Protocol
58
74
  # base = Relative.new("/documents/reports/")
59
75
  # other = Relative.new("invoices/2024.pdf")
60
76
  # result = base + other
61
- # result.path # => "/documents/reports/invoices/2024.pdf"
77
+ # result.path.to_s # => "/documents/reports/invoices/2024.pdf"
62
78
  #
63
79
  # @example Navigate to parent directory.
64
80
  # base = Relative.new("/documents/reports/archive/")
65
81
  # other = Relative.new("../../summary.pdf")
66
82
  # result = base + other
67
- # result.path # => "/documents/summary.pdf"
83
+ # result.path.to_s # => "/documents/summary.pdf"
68
84
  def +(other)
69
85
  case other
70
86
  when Absolute
@@ -74,7 +90,7 @@ module Protocol
74
90
  when Relative
75
91
  # Relative + Relative: merge paths directly
76
92
  self.class.new(
77
- Path.expand(self.path, other.path, true),
93
+ @path.join(other.path),
78
94
  other.query,
79
95
  other.fragment
80
96
  )
@@ -104,7 +120,9 @@ module Protocol
104
120
  # updated = url.with(path: "report.pdf", pop: false)
105
121
  # updated.to_s # => "/documents/report.pdf"
106
122
  def with(path: nil, query: @query, fragment: @fragment, pop: true)
107
- self.class.new(Path.expand(@path, path, pop), query, fragment)
123
+ path = @path.join(path, pop: pop) unless path.nil?
124
+
125
+ self.class.new(path || @path, query, fragment)
108
126
  end
109
127
 
110
128
  # Normalize the path by resolving "." and ".." segments and removing duplicate slashes.
@@ -119,11 +137,9 @@ module Protocol
119
137
  # @example Basic normalization
120
138
  # url = Relative.new("/foo//bar/./baz/../qux")
121
139
  # url.normalize!
122
- # url.path # => "/foo/bar/qux"
140
+ # url.path.to_s # => "/foo/bar/qux"
123
141
  def normalize!
124
- components = Path.split(@path)
125
- normalized = Path.simplify(components)
126
- @path = Path.join(normalized)
142
+ @path = @path.simplify
127
143
 
128
144
  return self
129
145
  end
@@ -131,7 +147,7 @@ module Protocol
131
147
  # Append the relative URL to the given buffer.
132
148
  # The path, query, and fragment are expected to already be properly encoded.
133
149
  def append(buffer = String.new)
134
- buffer << @path
150
+ buffer << @path.encoded
135
151
 
136
152
  if @query and !@query.empty?
137
153
  buffer << "?" << @query
@@ -217,6 +233,7 @@ module Protocol
217
233
  def inspect
218
234
  "#<#{self.class} #{to_s}>"
219
235
  end
236
+
220
237
  end
221
238
  end
222
239
  end
@@ -1,12 +1,12 @@
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
  # @namespace
7
7
  module Protocol
8
8
  # @namespace
9
9
  module URL
10
- VERSION = "0.10.0"
10
+ VERSION = "0.11.0"
11
11
  end
12
12
  end
data/lib/protocol/url.rb CHANGED
@@ -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 "url/version"
7
7
  require_relative "url/error"
data/license.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2025, by Samuel Williams.
3
+ Copyright, 2025-2026, by Samuel Williams.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/notes.md ADDED
@@ -0,0 +1,17 @@
1
+ # Follow-up Notes
2
+
3
+ ## Update `low-rb/low_loop`
4
+
5
+ File a follow-up PR against [`low-rb/low_loop`](https://github.com/low-rb/low_loop). Its file server currently uses the removed `Protocol::URL::Path.to_local_path` API in `lib/servers/file_server.rb`:
6
+
7
+ ```ruby
8
+ filepath = Protocol::URL::Path.to_local_path(Protocol::URL[event.request.path].path)
9
+ ```
10
+
11
+ Update it to use the `Path` instance returned by `URL#path`:
12
+
13
+ ```ruby
14
+ filepath = Protocol::URL[event.request.path].path.local_path(web_root)
15
+ ```
16
+
17
+ This call is security-sensitive because it converts an untrusted request path into a filesystem path. Confirm that the updated code passes `web_root` directly to `local_path`, handles traversal errors, and has an explicit policy for symlinks beneath the served root.
data/readme.md CHANGED
@@ -16,11 +16,72 @@ Please see the [project documentation](https://socketry.github.io/protocol-url/)
16
16
 
17
17
  We welcome contributions to this project.
18
18
 
19
- 1. Fork it.
19
+ 1. Fork the repository.
20
20
  2. Create your feature branch (`git checkout -b my-new-feature`).
21
- 3. Commit your changes (`git commit -am 'Add some feature'`).
21
+ 3. Commit your changes (`git commit -am 'Add some feature.'`).
22
22
  4. Push to the branch (`git push origin my-new-feature`).
23
- 5. Create new Pull Request.
23
+ 5. Create a new pull request.
24
+
25
+ ### Running Tests
26
+
27
+ To run the test suite:
28
+
29
+ ``` shell
30
+ bundle exec sus
31
+ ```
32
+
33
+ ### Making Releases
34
+
35
+ Please see the [project releases](https://socketry.github.io/protocol-url/releases/index) for all releases.
36
+
37
+ ### v0.10.0
38
+
39
+ - Rename `Protocol::URL::FormData::Parser::CONTENT_TYPE` to `MEDIA_TYPE`.
40
+
41
+ ### v0.9.0
42
+
43
+ - Add `Protocol::URL::LimitError` for configured processing limits.
44
+
45
+ ### v0.8.0
46
+
47
+ - Use consistent limit naming for form data parser constraints.
48
+
49
+ ### v0.7.0
50
+
51
+ - Allow `Protocol::URL::FormData::Parser#parse` to populate a supplied result object.
52
+
53
+ ### v0.6.0
54
+
55
+ - Add `Protocol::URL::FormData::Parser` for incremental, limited parsing of `application/x-www-form-urlencoded` form data.
56
+ - Add `Protocol::URL::FormData::Nested` for consistently building nested form data while preserving absent and empty values.
57
+
58
+ ### v0.5.0
59
+
60
+ - Add `Protocol::URL::Encoding.decode_www_form` for decoding HTML form data where `+` represents a space.
61
+
62
+ ### v0.4.0
63
+
64
+ - Add comparison methods to `Protocol::URL::Relative` (and by inheritance to `Protocol::URL::Absolute`):
65
+ - `#==` for structural equality comparison (compares path, query, fragment components).
66
+ - `#===` for string equality comparison (enables case statement matching).
67
+ - `#<=>` for ordering and sorting.
68
+ - `#hash` for hash key support.
69
+ - `#equal?` for component-based equality checking.
70
+ - Add JSON serialization support to `Protocol::URL::Relative`:
71
+ - `#as_json` returns the string representation.
72
+ - `#to_json` returns a JSON-encoded string.
73
+
74
+ ### v0.3.0
75
+
76
+ - Add `relative(target, from)` for computing relative paths between URLs.
77
+
78
+ ### v0.2.0
79
+
80
+ - Move `Protocol::URL::PATTERN` to `protocol/url/pattern.rb` so it can be shared more easily.
81
+
82
+ ### v0.1.0
83
+
84
+ - Initial implementation.
24
85
 
25
86
  ### Developer Certificate of Origin
26
87
 
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-url
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -54,14 +54,17 @@ files:
54
54
  - lib/protocol/url/relative.rb
55
55
  - lib/protocol/url/version.rb
56
56
  - license.md
57
+ - notes.md
57
58
  - readme.md
58
59
  - releases.md
59
60
  homepage: https://github.com/socketry/protocol-url
60
61
  licenses:
61
62
  - MIT
62
63
  metadata:
63
- source_code_uri: https://github.com/socketry/protocol-url.git
64
+ bug_tracker_uri: https://github.com/socketry/protocol-url/issues
65
+ changelog_uri: https://github.com/socketry/protocol-url/blob/main/releases.md
64
66
  documentation_uri: https://socketry.github.io/protocol-url/
67
+ source_code_uri: https://github.com/socketry/protocol-url.git
65
68
  rdoc_options: []
66
69
  require_paths:
67
70
  - lib
@@ -69,7 +72,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
69
72
  requirements:
70
73
  - - ">="
71
74
  - !ruby/object:Gem::Version
72
- version: '3.2'
75
+ version: '3.3'
73
76
  required_rubygems_version: !ruby/object:Gem::Requirement
74
77
  requirements:
75
78
  - - ">="
metadata.gz.sig CHANGED
Binary file