@@ -20,7 +20,8 @@ class Path
2020
2121 EMPTY_SEGMENTS = [ ] . freeze
2222 ROOT_SEGMENTS = [ "" , "" ] . freeze
23- private_constant :EMPTY_SEGMENTS , :ROOT_SEGMENTS
23+ NORMALIZATION_PATTERN = /%[0-9A-Fa-f]{2}|%|[^a-zA-Z0-9_.~!$&'()*+,;=:@-]/
24+ private_constant :EMPTY_SEGMENTS , :ROOT_SEGMENTS , :NORMALIZATION_PATTERN
2425
2526 # Coerce an encoded string or encoded segment array into a path.
2627 #
@@ -252,6 +253,42 @@ def local_path(root)
252253 alias to_s encoded
253254 alias to_str encoded
254255
256+ # Normalize the encoded spelling of this path.
257+ #
258+ # Percent-encoded unreserved characters are decoded, retained percent escapes
259+ # use uppercase hexadecimal digits, and literal characters outside the path
260+ # segment grammar are percent encoded. Reserved characters retain their
261+ # encoded or literal form because those forms are not generally equivalent.
262+ #
263+ # This operation preserves the path structure. Use {simplify} separately when
264+ # application semantics permit resolving dot segments or collapsing repeated separators.
265+ #
266+ # @returns [Path] The normalized path, or this path if already normalized.
267+ # @raises [ArgumentError] If the path contains malformed percent encoding, NUL, or invalid string encoding.
268+ def normalize
269+ encoded = self . encoded
270+ unless encoded . valid_encoding? && encoded . encoding . ascii_compatible?
271+ raise ArgumentError , "Path segment has invalid encoding!"
272+ end
273+
274+ segments = self . segments
275+ normalized_segments = nil
276+
277+ segments . each_with_index do |segment , index |
278+ next unless NORMALIZATION_PATTERN . match? ( segment )
279+
280+ normalized = normalize_segment ( segment )
281+ next if normalized == segment
282+
283+ normalized_segments ||= segments . dup
284+ normalized_segments [ index ] = normalized
285+ end
286+
287+ return self unless normalized_segments
288+
289+ return self . class . new ( nil , normalized_segments )
290+ end
291+
255292 # Simplify this path in place by resolving literal or percent-encoded dot segments and repeated separators.
256293 #
257294 # @returns [Path | Nil] This path when changed, otherwise `nil`.
@@ -342,6 +379,42 @@ def relative(from)
342379
343380 private
344381
382+ # Normalize one encoded path segment:
383+ def normalize_segment ( segment )
384+ return segment . gsub ( NORMALIZATION_PATTERN ) do |character |
385+ byte = character . getbyte ( 0 )
386+
387+ if byte == 0
388+ raise ArgumentError , "Path segment contains NUL!"
389+ elsif byte == 0x25
390+ if character . bytesize == 1
391+ raise ArgumentError , "String contains malformed percent encoding!"
392+ end
393+
394+ byte = character . byteslice ( 1 , 2 ) . to_i ( 16 )
395+ if byte == 0
396+ raise ArgumentError , "Path segment contains NUL!"
397+ elsif unreserved_byte? ( byte )
398+ byte . chr
399+ else
400+ character . upcase
401+ end
402+ else
403+ Encoding . escape ( character )
404+ end
405+ end
406+ end
407+
408+ # Whether the byte represents an unreserved URI character:
409+ def unreserved_byte? ( byte )
410+ case byte
411+ when 0x30 ..0x39 , 0x41 ..0x5A , 0x61 ..0x7A , 0x2D , 0x2E , 0x5F , 0x7E
412+ return true
413+ else
414+ return false
415+ end
416+ end
417+
345418 # Identify dot segments, including percent-encoded spellings. RFC 3986 treats
346419 # percent-encoded unreserved characters as equivalent to their literal forms;
347420 # the WHATWG URL Standard explicitly recognizes `%2e`, `.%2e`, `%2e.`, and
@@ -415,9 +488,9 @@ def simplify_segments!(segments, start_index = nil)
415488 offset += 1
416489 end
417490 elsif segment == "" && index != last_index
418- # Collapse repeated separators.
491+ # Collapse repeated separators:
419492 elsif dot == ".." && offset > 0 && dot_segment ( segments [ offset - 1 ] ) != ".."
420- # Pop a component, but never pop the absolute-path root.
493+ # Pop a component, but never pop the absolute-path root:
421494 offset -= 1 if segments [ offset - 1 ] != ""
422495
423496 # A trailing parent reference also denotes a directory.
0 commit comments