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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/lib/protocol/url/absolute.rb +28 -15
- data/lib/protocol/url/encoding.rb +56 -49
- data/lib/protocol/url/path.rb +389 -139
- data/lib/protocol/url/reference.rb +25 -22
- data/lib/protocol/url/relative.rb +50 -26
- 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 +113 -3
- data/releases.md +4 -0
- data.tar.gz.sig +0 -0
- metadata +6 -3
- metadata.gz.sig +0 -0
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
|