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.
@@ -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