Skip to content

Commit 1d5f3ff

Browse files
Normalize encoded URL paths. (#14)
1 parent 3b73ea1 commit 1d5f3ff

6 files changed

Lines changed: 199 additions & 8 deletions

File tree

guides/getting-started/readme.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,11 +290,14 @@ messy = Protocol::URL["https://example.com/a/b/../c/./d"]
290290
# Parsing preserves the original path until simplification is requested:
291291
messy.path.to_s # => "/a/b/../c/./d"
292292

293+
# Normalization is intentionally lossy and produces a canonical path:
293294
messy.normalize!
294295
messy.path.to_s # => "/a/c/d"
295296
messy.to_s # => "https://example.com/a/c/d"
296297
```
297298

299+
If the original path structure is significant, retain the parsed URL and do not call `normalize!`.
300+
298301
## Best Practices
299302

300303
### Choose the Right Class

lib/protocol/url/path.rb

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

lib/protocol/url/relative.rb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,17 @@ def with(path: nil, query: @query, fragment: @fragment, pop: true)
132132
self.class.new(path || @path, query, fragment)
133133
end
134134

135-
# Normalize the path by resolving "." and ".." segments and removing duplicate slashes.
135+
# Normalize the encoded path and simplify its structure.
136136
#
137-
# This modifies the URL in-place by simplifying the path component:
137+
# This modifies the URL in-place by normalizing and simplifying the path component:
138+
# - Decodes percent-encoded unreserved characters
139+
# - Uses uppercase hexadecimal digits for retained percent escapes
138140
# - Removes "." segments (current directory)
139141
# - Resolves ".." segments (parent directory)
140-
# - Collapses multiple consecutive slashes to single slashes (except at start)
142+
# - Collapses empty path segments represented by consecutive slashes
143+
#
144+
# Normalization is intentionally lossy. Callers that need to preserve the
145+
# original path structure should retain the parsed URL and avoid this method.
141146
#
142147
# @returns [self] The normalized URL.
143148
#
@@ -146,7 +151,7 @@ def with(path: nil, query: @query, fragment: @fragment, pop: true)
146151
# url.normalize!
147152
# url.path.to_s # => "/foo/bar/qux"
148153
def normalize!
149-
@path = @path.simplify
154+
@path = @path.normalize.simplify
150155

151156
return self
152157
end

releases.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Add conservative normalization of encoded URL paths.
6+
37
## v0.12.0
48

59
- Allow unfrozen relative and absolute URLs to replace their components.

test/protocol/url/path.rb

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,94 @@
309309

310310
expect(path.simplify.encoded).to be == "/a/c"
311311
end
312+
313+
it "resolves a parent following a repeated separator" do
314+
path = Protocol::URL::Path["/a//../b"]
315+
316+
expect(path.simplify.encoded).to be == "/b"
317+
end
318+
end
319+
320+
with "#normalize" do
321+
it "decodes percent-encoded unreserved characters" do
322+
path = Protocol::URL::Path["/%41%7a%30%2d%2e%5f%7e"]
323+
324+
expect(path.normalize.encoded).to be == "/Az0-._~"
325+
end
326+
327+
it "uses uppercase hexadecimal digits for retained percent escapes" do
328+
path = Protocol::URL::Path["/a%2fb%3fc%ff"]
329+
330+
expect(path.normalize.encoded).to be == "/a%2Fb%3Fc%FF"
331+
end
332+
333+
it "preserves literal path segment delimiters" do
334+
path = Protocol::URL::Path["/!$&'()*+,;=:@"]
335+
336+
expect(path.normalize).to be_equal(path)
337+
end
338+
339+
it "preserves the distinction between encoded and literal reserved characters" do
340+
path = Protocol::URL::Path["/a%3Ab:a%2Fb"]
341+
342+
expect(path.normalize).to be_equal(path)
343+
end
344+
345+
it "percent encodes characters outside the path segment grammar" do
346+
path = Protocol::URL::Path["/hello world?[x]#"]
347+
348+
expect(path.normalize.encoded).to be == "/hello%20world%3F%5Bx%5D%23"
349+
end
350+
351+
it "percent encodes literal unicode characters" do
352+
path = Protocol::URL::Path["/❤️"]
353+
354+
expect(path.normalize.encoded).to be == "/%E2%9D%A4%EF%B8%8F"
355+
end
356+
357+
it "preserves path structure" do
358+
path = Protocol::URL::Path["//a/%2e%2e/%77elcome"]
359+
360+
expect(path.normalize.encoded).to be == "//a/../welcome"
361+
end
362+
363+
it "returns itself when already normalized" do
364+
path = Protocol::URL::Path["/welcome/a:b/%2F"]
365+
366+
expect(path.normalize).to be_equal(path)
367+
end
368+
369+
it "rejects literal NUL" do
370+
path = Protocol::URL::Path["/a\0b"]
371+
372+
expect do
373+
path.normalize
374+
end.to raise_exception(ArgumentError, message: be == "Path segment contains NUL!")
375+
end
376+
377+
it "rejects percent-encoded NUL" do
378+
path = Protocol::URL::Path["/a%00b"]
379+
380+
expect do
381+
path.normalize
382+
end.to raise_exception(ArgumentError, message: be == "Path segment contains NUL!")
383+
end
384+
385+
it "rejects malformed percent encoding" do
386+
["/a%", "/a%0", "/a%gg"].each do |encoded|
387+
expect do
388+
Protocol::URL::Path[encoded].normalize
389+
end.to raise_exception(ArgumentError, message: be == "String contains malformed percent encoding!")
390+
end
391+
end
392+
393+
it "rejects invalid string encoding" do
394+
path = Protocol::URL::Path["/a\xFF".dup.force_encoding(::Encoding::UTF_8)]
395+
396+
expect do
397+
path.normalize
398+
end.to raise_exception(ArgumentError, message: be == "Path segment has invalid encoding!")
399+
end
312400
end
313401

314402
with "#simplify!" do
@@ -444,6 +532,12 @@
444532
expect(Protocol::URL::Path["documents/report.pdf"].local_path(root)).to be == expected
445533
end
446534

535+
it "maps empty URL segments to the same local filesystem path" do
536+
expected = File.join(root, "documents", "report.pdf")
537+
538+
expect(Protocol::URL::Path["/documents//report.pdf"].local_path(root)).to be == expected
539+
end
540+
447541
it "unescapes percent-encoded and Unicode characters" do
448542
expect(Protocol::URL::Path["/files/My%20Document.txt"].local_path(root)).to be == File.join(root, "files", "My Document.txt")
449543
expect(Protocol::URL::Path["/files/%E2%9D%A4%EF%B8%8F.txt"].local_path(root)).to be == File.join(root, "files", "❤️.txt")

test/protocol/url/relative.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,18 @@
269269
end
270270

271271
with "#normalize!" do
272+
it "normalizes the encoded path" do
273+
url = Protocol::URL::Relative.new("/%66oo/a%2fb")
274+
url.normalize!
275+
expect(url.path).to be == Protocol::URL::Path["/foo/a%2Fb"]
276+
end
277+
278+
it "simplifies normalized dot segments" do
279+
url = Protocol::URL::Relative.new("/foo/%2e%2e/bar")
280+
url.normalize!
281+
expect(url.path).to be == Protocol::URL::Path["/bar"]
282+
end
283+
272284
it "removes dot segments" do
273285
url = Protocol::URL::Relative.new("/foo/./bar")
274286
url.normalize!
@@ -281,7 +293,7 @@
281293
expect(url.path).to be == Protocol::URL::Path["/foo/baz"]
282294
end
283295

284-
it "collapses multiple slashes" do
296+
it "collapses empty path segments" do
285297
url = Protocol::URL::Relative.new("/foo//bar///baz")
286298
url.normalize!
287299
expect(url.path).to be == Protocol::URL::Path["/foo/bar/baz"]

0 commit comments

Comments
 (0)