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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/lib/protocol/url/absolute.rb +17 -4
- data/lib/protocol/url/encoding.rb +56 -49
- data/lib/protocol/url/path.rb +389 -139
- data/lib/protocol/url/reference.rb +23 -20
- data/lib/protocol/url/relative.rb +34 -17
- data/lib/protocol/url/version.rb +2 -2
- data/lib/protocol/url.rb +1 -1
- data/license.md +1 -1
- data/notes.md +17 -0
- data/readme.md +64 -3
- data.tar.gz.sig +0 -0
- metadata +6 -3
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b1993c620685f91b17b6a707eace1ff03219c1d6e2eace3d272e8ec8c723680
|
|
4
|
+
data.tar.gz: 8969a20bccae90e49a5d541ea9f384127aaf817e7df12b1ac8b16a27ba450b1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
|
|
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(/%(
|
|
42
|
-
|
|
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
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
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
|
|
128
|
+
return value.map do |v|
|
|
122
129
|
self.encode(v, "#{prefix}[]")
|
|
123
|
-
|
|
130
|
+
end.join("&")
|
|
124
131
|
when Hash
|
|
125
|
-
return value.map
|
|
132
|
+
return value.map do |k, v|
|
|
126
133
|
self.encode(v, prefix ? "#{prefix}[#{escape(k.to_s)}]" : escape(k.to_s))
|
|
127
|
-
|
|
134
|
+
end.reject(&:empty?).join("&")
|
|
128
135
|
when nil
|
|
129
136
|
return prefix
|
|
130
137
|
else
|
data/lib/protocol/url/path.rb
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
def self.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
#
|
|
39
|
+
# Construct a path from decoded components.
|
|
88
40
|
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
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
|
-
# @
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
148
|
-
break if
|
|
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 =
|
|
335
|
+
up_levels = from_segments.size - common_length
|
|
154
336
|
|
|
155
|
-
# Build the relative path
|
|
156
|
-
|
|
337
|
+
# Build the relative path segments
|
|
338
|
+
relative_segments = [".."] * up_levels + target_segments[common_length..-1]
|
|
157
339
|
|
|
158
|
-
return
|
|
340
|
+
return Path.new(nil, relative_segments)
|
|
159
341
|
end
|
|
160
342
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
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
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
|
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
|
|
25
|
-
#
|
|
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
|
|
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 # => "
|
|
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
|
-
#
|
|
58
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
90
|
+
# Initialize the reference from an encoded path and reference values.
|
|
92
91
|
#
|
|
93
|
-
# @parameter path [String] The
|
|
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
|
|
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 <<
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
21
|
+
@path = Path[path]
|
|
22
22
|
@query = query
|
|
23
23
|
@fragment = fragment
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
#
|
|
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
|
-
#
|
|
47
|
+
# Resolve the URL path beneath a local filesystem root.
|
|
36
48
|
#
|
|
37
|
-
# @
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/protocol/url/version.rb
CHANGED
|
@@ -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
|
+
VERSION = "0.11.0"
|
|
11
11
|
end
|
|
12
12
|
end
|
data/lib/protocol/url.rb
CHANGED
data/license.md
CHANGED
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
75
|
+
version: '3.3'
|
|
73
76
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
74
77
|
requirements:
|
|
75
78
|
- - ">="
|
metadata.gz.sig
CHANGED
|
Binary file
|