kettle-paths 0.1.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.
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "version_gem"
5
+ require_relative "paths/version"
6
+
7
+ module Kettle
8
+ # Filesystem path comparison by identity rather than by spelling.
9
+ #
10
+ # == Why string comparison of paths is not safe
11
+ #
12
+ # A path string is a *name*, not an identity. One directory can have several
13
+ # valid names, and two names can reach the same code from different APIs
14
+ # within one process. Two cases bite these tools on every platform:
15
+ #
16
+ # 1. A symlinked ancestor. On Fedora Silverblue/Aurora +/home/x+ is a symlink
17
+ # to +/var/home/x+, so +Dir.pwd+ can report one form while a path built
18
+ # from an ENV var, a stored fixture, a TMPDIR, or
19
+ # +git rev-parse --show-toplevel+ reports the other.
20
+ #
21
+ # 2. Windows 8.3 short names. Measured on a GitHub Actions windows-latest
22
+ # runner:
23
+ #
24
+ # Dir.pwd => "C:/Users/RUNNER~1/AppData/Local/Temp/turbo-tests2-..."
25
+ # ENV["TEMP"] => "C:\\Users\\RUNNER~1\\AppData\\Local\\Temp"
26
+ # Dir.glob result => "C:/Users/runneradmin/AppData/Local/Temp/turbo-tests2-..."
27
+ #
28
+ # +RUNNER~1+ is the 8.3 short name and +runneradmin+ the long name of the
29
+ # same directory. +Dir.pwd+ and the environment report the short form while
30
+ # +Dir.glob+ — which RSpec uses to expand +--pattern+ — reports the long
31
+ # form. Both are correct and they are never equal as strings.
32
+ #
33
+ # The concrete damage: +Pathname#relative_path_from+ is purely lexical, so
34
+ # with differing spellings it walks all the way up and back down and returns a
35
+ # plausible-looking escape such as
36
+ # +"../../../../../runneradmin/AppData/Local/Temp/..."+. It does not raise, so
37
+ # a +rescue ArgumentError+ cannot catch it. Those escapes then reach
38
+ # +git add+, which fails with "is outside repository at", or a test runner
39
+ # that silently selects the wrong files.
40
+ #
41
+ # == The approach that works
42
+ #
43
+ # +File.identical?+ asks the filesystem whether two names denote one object —
44
+ # by inode on Unix and by file index on Windows. It is spelling-agnostic,
45
+ # which is exactly the property needed. So containment and relativization here
46
+ # ascend the candidate path asking the filesystem "is this the root?", and
47
+ # re-join basenames on the way back down.
48
+ #
49
+ # Lexical component comparison is kept only as a fallback for paths that do
50
+ # not exist yet, which have no identity to ask about. That is what keeps the
51
+ # comparison useful for generated output paths.
52
+ #
53
+ # == Approaches that do NOT work, and why
54
+ #
55
+ # All four were tried against the real failing environments and all failed;
56
+ # recorded so the sequence is not repeated.
57
+ #
58
+ # 1. String prefix (+expanded.start_with?("#{root}/")+): never matches when
59
+ # the two spellings differ, so absolute temp paths leak through to
60
+ # consumers that then stat them. It also wrongly reports "project-other"
61
+ # as inside "project".
62
+ #
63
+ # 2. Canonicalize with +File.realpath+, then compare: WRONG PREMISE —
64
+ # +File.realpath+ does NOT expand Windows 8.3 short names. Measured on the
65
+ # runner, realpath of the short path returns the short path unchanged, so
66
+ # canonicalization cannot reconcile the two spellings at all. It also
67
+ # resolves symlinks, which adds a second failure mode: root and file may
68
+ # legitimately traverse different symlink chains to one directory, and
69
+ # canonicalizing only one side makes them diverge.
70
+ #
71
+ # 3. Component-wise comparison of canonicalized parts: same false premise as
72
+ # #2, so +RUNNER~1+ never equals +runneradmin+ under any case folding. It
73
+ # also relied on +File::ALT_SEPARATOR+, which measured as +""+ (empty
74
+ # string, not nil) on that runner, making the separator-normalizing +tr+ a
75
+ # silent no-op. Do not trust ALT_SEPARATOR to be present.
76
+ #
77
+ # 4. +Pathname#relative_path_from+: purely lexical, as described above.
78
+ #
79
+ # == How the Windows case was finally diagnosed
80
+ #
81
+ # It only reproduced on the Windows CI runner, and the runner's output was
82
+ # swallowed: the discovery specs invoke a subprocess whose stderr surfaces
83
+ # only when a status check fails, and the assertion that failed was a JSON
84
+ # comparison. Four blind fixes failed in a row. What resolved it was a
85
+ # temporary, Windows-guarded spec (+if: Gem.win_platform?+) that deliberately
86
+ # failed with the diagnostic JSON embedded in the expectation message, so the
87
+ # runner's real path forms printed into the CI log. Guarding it kept the other
88
+ # checks green while one job reported the data; that spec was then deleted and
89
+ # its findings became this documentation plus the specs in this gem.
90
+ #
91
+ # Lesson: when a bug is environment-specific and the environment is not
92
+ # available locally, spend the iteration on instrumenting the real environment
93
+ # rather than on another hypothesized fix.
94
+ #
95
+ # == Dependency-free by design
96
+ #
97
+ # No runtime dependencies and MIT licensed, so it can be shared by both MIT
98
+ # and AGPL tooling without license friction. Keep it that way.
99
+ #
100
+ # The Ruby floor is 2.4.0 to match its consumers (kettle-dev, kettle-family and
101
+ # turbo_tests2 all declare ">= 2.4.0"), so this file avoids +filter_map+
102
+ # (2.7+), multi-argument +String#start_with?+ (2.5+), +Hash#to_h+ with a block
103
+ # (2.6+) and endless method definitions.
104
+ module Paths
105
+ # Raised for programmer error only. Path operations here return nil or false
106
+ # rather than raising, because a missing or unreadable path is a normal
107
+ # condition in these tools.
108
+ class Error < StandardError; end
109
+
110
+ module_function
111
+
112
+ LOCAL_REMOTE_PREFIXES = ["/", "./", "../", ".\\", "..\\"].freeze
113
+
114
+ # Expands +path+ and resolves its longest existing ancestor, re-attaching
115
+ # any non-existent suffix. Keeps comparisons useful for generated paths that
116
+ # do not exist yet, which is common in templating and release tooling.
117
+ #
118
+ # NOTE: this does NOT make differently spelled paths comparable as strings —
119
+ # see the class documentation on +File.realpath+ and 8.3 short names. Use
120
+ # {same?}, {within?} or {relative} to compare, never string equality on the
121
+ # result of this method.
122
+ #
123
+ # @param path [String, Pathname, nil]
124
+ # @param base [String, nil] directory to expand a relative +path+ against
125
+ # @return [String, nil] the expanded path, or the plain expansion when the
126
+ # real path cannot be resolved; nil when +path+ is blank
127
+ def canonical(path, base: nil)
128
+ return nil if blank?(path)
129
+
130
+ expanded = File.expand_path(path.to_s, base)
131
+ existing = expanded
132
+ suffix = []
133
+ until File.exist?(existing) || File.symlink?(existing)
134
+ parent = File.dirname(existing)
135
+ return expanded if parent == existing
136
+
137
+ suffix.unshift(File.basename(existing))
138
+ existing = parent
139
+ end
140
+
141
+ suffix.reduce(File.realpath(existing)) { |resolved, component| File.join(resolved, component) }
142
+ rescue Errno::EACCES, Errno::ENOENT
143
+ expanded
144
+ end
145
+
146
+ # True when +left+ and +right+ name the same filesystem object, compared by
147
+ # identity rather than by spelling.
148
+ #
149
+ # @param left [String, Pathname, nil]
150
+ # @param right [String, Pathname, nil]
151
+ # @param base [String, nil] directory to expand relative arguments against
152
+ # @return [Boolean] false when either argument is blank
153
+ def same?(left, right, base: nil)
154
+ return false if blank?(left) || blank?(right)
155
+
156
+ left_path = canonical(left, base: base)
157
+ right_path = canonical(right, base: base)
158
+ return false if blank?(left_path) || blank?(right_path)
159
+ return true if left_path == right_path
160
+
161
+ identical?(left_path, right_path) || comparable_parts(left_path) == comparable_parts(right_path)
162
+ rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR, NotImplementedError
163
+ comparable_parts(left_path || left.to_s) == comparable_parts(right_path || right.to_s)
164
+ end
165
+
166
+ # Filesystem-identity comparison that tolerates a missing path.
167
+ #
168
+ # +File.identical?+ is not consistent across Rubies here: some raise
169
+ # +SystemCallError+ for a path that does not exist, while others (measured on
170
+ # Ruby 4.0.7) return false. Both are handled, because callers pass paths that
171
+ # may not exist yet — generated output, a lockfile entry for a file about to
172
+ # be written.
173
+ #
174
+ # The fallback is string equality, which is the correct answer when neither
175
+ # side is a symlink or an 8.3 short name. Rescues +SystemCallError+ rather
176
+ # than +Errno::ENOENT+ alone, because ENOTDIR, ELOOP and EACCES all mean
177
+ # "identity unknown" and the string fallback is the safest answer in each
178
+ # case.
179
+ #
180
+ # @param left [String, Pathname, nil]
181
+ # @param right [String, Pathname, nil]
182
+ # @return [Boolean]
183
+ def identical?(left, right)
184
+ return false if blank?(left) || blank?(right)
185
+
186
+ left_s = left.to_s
187
+ right_s = right.to_s
188
+ File.identical?(left_s, right_s)
189
+ rescue SystemCallError
190
+ left_s == right_s
191
+ end
192
+
193
+ # True when +path+ is +root+ itself or lives inside it, compared by
194
+ # filesystem identity.
195
+ #
196
+ # Unlike a string prefix test this does not treat a sibling directory that
197
+ # merely shares a prefix ("project-other" vs "project") as being inside
198
+ # "project". Unlike lexical component comparison it survives a symlinked
199
+ # ancestor and Windows 8.3 short names.
200
+ #
201
+ # When identity cannot be established, because one side does not exist, this
202
+ # falls back to lexical component comparison. That is what keeps it usable
203
+ # for generated output paths that have not been written yet.
204
+ #
205
+ # @param path [String, Pathname, nil]
206
+ # @param root [String, Pathname, nil]
207
+ # @param base [String, nil]
208
+ # @return [Boolean] false when either argument is blank
209
+ def within?(path, root, base: nil)
210
+ return false if blank?(path) || blank?(root)
211
+
212
+ candidate = File.expand_path(path.to_s, base)
213
+ boundary = File.expand_path(root.to_s, base)
214
+ return true if identical?(candidate, boundary)
215
+
216
+ !relative_parts(candidate, boundary).nil? || lexical_within?(candidate, boundary)
217
+ rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR
218
+ false
219
+ end
220
+
221
+ # The path of +path+ relative to +root+, computed by ascending +path+ and
222
+ # asking the filesystem at each level whether that ancestor is +root+.
223
+ #
224
+ # Returns nil when +root+ is not an ancestor of +path+. Callers must not
225
+ # substitute a lexical result for that nil: +Pathname#relative_path_from+
226
+ # returns a plausible but wrong +../../..+ escape in exactly that case, which
227
+ # is worse than nil because it silently selects the wrong files or fails
228
+ # later inside +git add+.
229
+ #
230
+ # The returned string is built from +path+'s own spelling, which is what
231
+ # callers need when the result is stat'd or handed to a process spawned from
232
+ # this working directory. For that reason the path is expanded WITHOUT
233
+ # +File.realpath+: a mid-path symlink must not be silently rewritten to its
234
+ # target, because that changes which files a glob or a test runner sees.
235
+ # Identity comparison via {identical?} already reconciles differing ancestor
236
+ # spellings, so realpath buys nothing here and costs the caller's spelling.
237
+ # See the class documentation on +File.realpath+.
238
+ #
239
+ # @param path [String, Pathname]
240
+ # @param root [String, Pathname]
241
+ # @return [String, nil] relative path using forward slashes, "." when the two
242
+ # name the same directory, or nil when +root+ is not an ancestor
243
+ def relative(path, root)
244
+ return nil if blank?(path) || blank?(root)
245
+
246
+ candidate = File.expand_path(path.to_s)
247
+ boundary = File.expand_path(root.to_s)
248
+ return "." if identical?(candidate, boundary)
249
+
250
+ relative_parts(candidate, boundary)
251
+ end
252
+
253
+ # {relative}, falling back to the expanded +path+ when +root+ is not an
254
+ # ancestor, i.e. when there is nothing to strip. This is the shape spec
255
+ # discovery needs: it passes through paths outside the configuration root
256
+ # untouched rather than producing a wrong relative path.
257
+ #
258
+ # @param path [String, Pathname]
259
+ # @param root [String, Pathname]
260
+ # @return [String] relative path, "." for the same directory, or the expanded
261
+ # +path+ when it is not inside +root+
262
+ def relative_from(path, root)
263
+ expanded = File.expand_path(path.to_s)
264
+ relative(expanded, root) || expanded
265
+ end
266
+
267
+ # Dir.glob with forward-slash normalization, so patterns assembled from
268
+ # Windows paths still match.
269
+ #
270
+ # @param parts [Array<String>] joined with +File::SEPARATOR+ before globbing
271
+ # @return [Array<String>]
272
+ def glob(*parts)
273
+ # Windows 8.3 short names (RUNNER~1) hide that two spellings are one
274
+ # file, and File.expand_path does NOT resolve them: the base may stay
275
+ # short-named while Dir.glob results come back long-named (or vice
276
+ # versa). Compare both sides in expanded space to compute the relative
277
+ # path, then re-prefix with the caller's original spelling, so results
278
+ # spell the base exactly as the caller did and comparisons against
279
+ # TMPDIR-derived expectations are spelling-independent.
280
+ pattern = File.join(*parts).tr("\\", "/")
281
+ original_base = pattern.split("/")[0..-2].join("/")
282
+ original_base = original_base.empty? ? "." : original_base
283
+ expanded_base = File.expand_path(original_base)
284
+ base_prefix = "#{expanded_base}/"
285
+ results = Dir.glob([pattern, File.join(expanded_base, File.basename(pattern))].uniq)
286
+ results.map do |result|
287
+ expanded_result = File.expand_path(result)
288
+ relative = expanded_result.start_with?(base_prefix) ? expanded_result.delete_prefix(base_prefix) : File.basename(result)
289
+ File.join(original_base, relative)
290
+ end
291
+ end
292
+
293
+ # True when a bundler/lockfile "remote:" value denotes a local path rather
294
+ # than a registry URL.
295
+ #
296
+ # @param remote [String, nil]
297
+ # @return [Boolean]
298
+ def local_path_remote?(remote)
299
+ text = remote.to_s
300
+ return false if text.empty?
301
+
302
+ # Windows drive-absolute: "C:/x" or "C:\x"
303
+ third_char = text[2].to_s
304
+ drive_absolute = text.length >= 3 && text[1] == ":" && (third_char == "/" || third_char == "\\")
305
+ drive_absolute ||
306
+ LOCAL_REMOTE_PREFIXES.any? { |prefix| text.start_with?(prefix) } ||
307
+ absolute_pathname?(text)
308
+ end
309
+
310
+ # Splits a path into its components, normalizing backslashes so Windows and
311
+ # POSIX paths compare alike.
312
+ #
313
+ # @param path [String, Pathname, nil]
314
+ # @return [Array<String>]
315
+ def path_parts(path)
316
+ path.to_s.tr("\\", "/").split("/").reject(&:empty?)
317
+ end
318
+
319
+ # {path_parts}, case-folded on Windows where the filesystem is
320
+ # case-insensitive.
321
+ #
322
+ # @param path [String, Pathname, nil]
323
+ # @return [Array<String>]
324
+ def comparable_parts(path)
325
+ parts = path_parts(path)
326
+ windows? ? parts.map(&:downcase) : parts
327
+ end
328
+
329
+ # @api private
330
+ def windows?
331
+ Gem.win_platform?
332
+ end
333
+
334
+ # @api private
335
+ def blank?(value)
336
+ value.nil? || value.to_s.empty?
337
+ end
338
+
339
+ # @api private
340
+ def absolute_pathname?(text)
341
+ Pathname.new(text).absolute?
342
+ rescue
343
+ false
344
+ end
345
+
346
+ # Lexical containment, used only when identity cannot be established because
347
+ # one side does not exist. Component-wise rather than a string prefix, so a
348
+ # sibling sharing a prefix is not reported as contained.
349
+ #
350
+ # @api private
351
+ def lexical_within?(candidate, boundary)
352
+ candidate_parts = comparable_parts(candidate)
353
+ boundary_parts = comparable_parts(boundary)
354
+ boundary_length = boundary_parts.length
355
+ return false if candidate_parts.length < boundary_length
356
+
357
+ candidate_parts[0, boundary_length] == boundary_parts
358
+ end
359
+
360
+ # Ascend from +candidate+ asking the filesystem whether each ancestor is
361
+ # +boundary+, re-joining basenames on the way back down.
362
+ #
363
+ # Recursion is avoided in favour of a loop: +File.dirname+ of the filesystem
364
+ # root returns itself ("C:/" or "/"), so the +parent == current+ check ends
365
+ # every walk without needing a depth constant. Depth is bounded by path
366
+ # length and the cost is one stat pair per level.
367
+ #
368
+ # Both arguments are expected to be canonicalized already, by the public
369
+ # entry points, so the loop compares with {identical?} rather than paying
370
+ # for a fresh {canonical} per level.
371
+ #
372
+ # @return [String, nil] relative path, "." for the same directory, or nil
373
+ # when the boundary was never reached
374
+ # @api private
375
+ def relative_parts(candidate, boundary)
376
+ directory = File.directory?(candidate)
377
+ current = directory ? candidate : File.dirname(candidate)
378
+ parts = directory ? [] : [File.basename(candidate)]
379
+
380
+ until identical?(current, boundary)
381
+ parent = File.dirname(current)
382
+ return nil if parent == current
383
+
384
+ parts.unshift(File.basename(current))
385
+ current = parent
386
+ end
387
+
388
+ return "." if parts.empty?
389
+
390
+ File.join(*parts).tr("\\", "/")
391
+ end
392
+ end
393
+ end
394
+
395
+ Kettle::Paths::Version.class_eval do
396
+ extend VersionGem::Basic
397
+ end
@@ -0,0 +1,78 @@
1
+ module Kettle
2
+ module Paths
3
+ VERSION: String
4
+
5
+ module Version
6
+ VERSION: String
7
+ end
8
+
9
+ # Raised for programmer error only; the path operations return nil or false
10
+ # rather than raising, because a missing or unreadable path is a normal
11
+ # condition in these tools.
12
+ class Error < StandardError
13
+ end
14
+
15
+ LOCAL_REMOTE_PREFIXES: Array[String]
16
+
17
+ # Expands a path and resolves its longest existing ancestor, re-attaching any
18
+ # non-existent suffix. Returns nil for a blank path.
19
+ #
20
+ # Does NOT make differently spelled paths comparable as strings; use
21
+ # `same?` / `within?` / `relative` to compare.
22
+ def self.canonical: (String | Pathname | nil path, ?base: String | nil) -> String?
23
+
24
+ # True when both arguments name the same filesystem object, compared by
25
+ # identity rather than by spelling. False when either argument is blank.
26
+ def self.same?: (untyped left, untyped right, ?base: String | nil) -> bool
27
+
28
+ # Filesystem-identity comparison that tolerates a missing path, falling back
29
+ # to string equality when identity is unknown.
30
+ def self.identical?: (untyped left, untyped right) -> bool
31
+
32
+ # True when path is root itself or lives inside it, compared by filesystem
33
+ # identity. Falls back to lexical component comparison when identity cannot
34
+ # be established, which is what keeps it usable for generated output paths.
35
+ def self.within?: (untyped path, untyped root, ?base: String | nil) -> bool
36
+
37
+ # Path of `path` relative to `root`, computed by ascending `path` and asking
38
+ # the filesystem at each level whether that ancestor is `root`.
39
+ #
40
+ # Returns "." when they name the same directory and nil when root is not an
41
+ # ancestor. Never returns a lexical parent-directory escape, which is what
42
+ # `Pathname#relative_path_from` produces for differently spelled paths.
43
+ def self.relative: (untyped path, untyped root) -> String?
44
+
45
+ # `relative`, falling back to the expanded path when root is not an ancestor,
46
+ # i.e. when there is nothing to strip.
47
+ def self.relative_from: (untyped path, untyped root) -> String
48
+
49
+ # Dir.glob with forward-slash normalization, so patterns assembled from
50
+ # Windows paths still match.
51
+ def self.glob: (*untyped parts) -> Array[String]
52
+
53
+ # True when a bundler/lockfile "remote:" value denotes a local path rather
54
+ # than a registry URL.
55
+ def self.local_path_remote?: (untyped remote) -> bool
56
+
57
+ # Path components, normalizing backslashes so Windows and POSIX compare alike.
58
+ def self.path_parts: (untyped path) -> Array[String]
59
+
60
+ # `path_parts`, case-folded on Windows where the filesystem is
61
+ # case-insensitive.
62
+ def self.comparable_parts: (untyped path) -> Array[String]
63
+
64
+ def self.windows?: () -> bool
65
+
66
+ def self.blank?: (untyped value) -> bool
67
+
68
+ def self.absolute_pathname?: (String text) -> bool
69
+
70
+ # Lexical containment, used only when identity cannot be established because
71
+ # one side does not exist.
72
+ def self.lexical_within?: (untyped candidate, untyped boundary) -> bool
73
+
74
+ # Ascends from candidate asking the filesystem whether each ancestor is
75
+ # boundary, re-joining basenames on the way back down.
76
+ def self.relative_parts: (untyped candidate, untyped boundary) -> String?
77
+ end
78
+ end
data.tar.gz.sig ADDED
@@ -0,0 +1,3 @@
1
+ C%�d:��� RJ�o��E4���Ҁ�%
2
+ �|�[���lwdPS���v��`���. 9R����KK�[��v5��,ܶ~��B�0;7H��X����`��q������������\����';�i���������xM�����w�gw�e�wh�{�_� 49��W��mo�����|���/x���,d%m����P�*������fV<k! �3������3��S (jϿᱜ}z"�ڙF���- �(ѡT�0�Ş�s��;6 �=769� ��x(�0����­8�9�LFaq{?�Vi��|=�!��|��&hg�H����*%iEI�h
3
+ �T$5���Z�KX7�-mĘsH�k.�yC�����9H�@��s�JX ��