mockserver-client 7.0.0 → 7.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.
- checksums.yaml +4 -4
- data/README.md +104 -0
- data/lib/mockserver/binary_launcher.rb +634 -0
- data/lib/mockserver/client.rb +151 -6
- data/lib/mockserver/models.rb +62 -27
- data/lib/mockserver/version.rb +1 -1
- data/lib/mockserver/websocket_client.rb +129 -2
- data/lib/mockserver-client.rb +1 -0
- metadata +3 -2
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'logger'
|
|
6
|
+
require 'net/http'
|
|
7
|
+
require 'rbconfig'
|
|
8
|
+
require 'uri'
|
|
9
|
+
|
|
10
|
+
module MockServer
|
|
11
|
+
# On-demand binary launcher for MockServer.
|
|
12
|
+
#
|
|
13
|
+
# Downloads the self-contained, JVM-less MockServer bundle (a jlink runtime +
|
|
14
|
+
# the server + a +mockserver+ launcher) for the current platform from the
|
|
15
|
+
# GitHub Release, verifies its SHA-256, caches it per-user, and launches it.
|
|
16
|
+
# No Java installation and no Docker required.
|
|
17
|
+
#
|
|
18
|
+
# This mirrors the reference implementation at +mockserver-node/downloadBinary.js+.
|
|
19
|
+
#
|
|
20
|
+
# Environment overrides:
|
|
21
|
+
# MOCKSERVER_BINARY_BASE_URL mirror host for the release assets
|
|
22
|
+
# MOCKSERVER_BINARY_CACHE cache directory (default: per-OS user cache)
|
|
23
|
+
# MOCKSERVER_SKIP_BINARY_DOWNLOAD fail instead of downloading (air-gapped CI with pre-seeded cache)
|
|
24
|
+
# HTTP_PROXY / HTTPS_PROXY honoured by Net::HTTP via +ENV['http_proxy']+ (Ruby convention)
|
|
25
|
+
# SSL_CERT_FILE / SSL_CERT_DIR honoured by OpenSSL for corporate TLS proxies
|
|
26
|
+
#
|
|
27
|
+
# @example Start a server on port 1080
|
|
28
|
+
# handle = MockServer::BinaryLauncher.start(port: 1080)
|
|
29
|
+
# # ... use MockServer ...
|
|
30
|
+
# handle.stop
|
|
31
|
+
#
|
|
32
|
+
# @example Just ensure the binary is present
|
|
33
|
+
# path = MockServer::BinaryLauncher.ensure_launcher
|
|
34
|
+
class BinaryLauncher
|
|
35
|
+
REPO = 'mock-server/mockserver-monorepo'
|
|
36
|
+
|
|
37
|
+
# CDN base URL used for SNAPSHOT version downloads.
|
|
38
|
+
SNAPSHOT_CDN = 'https://downloads.mock-server.com'
|
|
39
|
+
|
|
40
|
+
# Maximum number of previous version directories to keep (in addition to the current).
|
|
41
|
+
MAX_PREVIOUS_VERSIONS_TO_KEEP = 1
|
|
42
|
+
|
|
43
|
+
# Strict pattern for version strings — blocks path separators and '..'.
|
|
44
|
+
VERSION_PATTERN = /\A[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?\z/
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# Resolve the current platform to the bundle naming tokens.
|
|
48
|
+
#
|
|
49
|
+
# @return [Hash] with keys +:os_name+, +:arch+, +:ext+
|
|
50
|
+
# @raise [Error] on unsupported platform or architecture
|
|
51
|
+
def resolve_platform
|
|
52
|
+
os_name, ext = case RbConfig::CONFIG['host_os']
|
|
53
|
+
when /linux/i then ['linux', 'tar.gz']
|
|
54
|
+
when /darwin/i then ['darwin', 'tar.gz']
|
|
55
|
+
when /mswin|mingw|cygwin/i then ['windows', 'zip']
|
|
56
|
+
else raise Error, "unsupported platform: #{RbConfig::CONFIG['host_os']}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
arch = case RbConfig::CONFIG['host_cpu']
|
|
60
|
+
when /x86_64|x64|amd64/i then 'x86_64'
|
|
61
|
+
when /aarch64|arm64/i then 'aarch64'
|
|
62
|
+
else raise Error, "unsupported architecture: #{RbConfig::CONFIG['host_cpu']}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
{ os_name: os_name, arch: arch, ext: ext }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Return the bundle base name and extension for a given version.
|
|
69
|
+
#
|
|
70
|
+
# @param version [String]
|
|
71
|
+
# @return [Hash] with keys +:name+ and +:ext+
|
|
72
|
+
def bundle_base_name(version)
|
|
73
|
+
platform = resolve_platform
|
|
74
|
+
{
|
|
75
|
+
name: "mockserver-#{version}-#{platform[:os_name]}-#{platform[:arch]}",
|
|
76
|
+
ext: platform[:ext]
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Return the per-user cache directory for MockServer binaries.
|
|
81
|
+
#
|
|
82
|
+
# @return [String]
|
|
83
|
+
def cache_dir
|
|
84
|
+
if ENV['MOCKSERVER_BINARY_CACHE'] && !ENV['MOCKSERVER_BINARY_CACHE'].empty?
|
|
85
|
+
return ENV['MOCKSERVER_BINARY_CACHE']
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
base = if windows?
|
|
89
|
+
ENV['LOCALAPPDATA'] || File.join(Dir.home, 'AppData', 'Local')
|
|
90
|
+
else
|
|
91
|
+
ENV['XDG_CACHE_HOME'] || File.join(Dir.home, '.cache')
|
|
92
|
+
end
|
|
93
|
+
File.join(base, 'mockserver', 'binaries')
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Return the download URL for a release asset.
|
|
97
|
+
#
|
|
98
|
+
# Uses +MOCKSERVER_BINARY_BASE_URL+ if set; otherwise defaults to GitHub
|
|
99
|
+
# Releases for release versions and the downloads.mock-server.com CDN for
|
|
100
|
+
# SNAPSHOT versions.
|
|
101
|
+
#
|
|
102
|
+
# @param version [String]
|
|
103
|
+
# @param file [String]
|
|
104
|
+
# @return [String]
|
|
105
|
+
def asset_url(version, file)
|
|
106
|
+
base = ENV['MOCKSERVER_BINARY_BASE_URL'] ||
|
|
107
|
+
if snapshot?(version)
|
|
108
|
+
"#{SNAPSHOT_CDN}/mockserver-#{version}"
|
|
109
|
+
else
|
|
110
|
+
"https://github.com/#{REPO}/releases/download/mockserver-#{version}"
|
|
111
|
+
end
|
|
112
|
+
# Strip trailing slashes with a single linear scan rather than a regex,
|
|
113
|
+
# so there is no ReDoS surface at all (CWE-1333) on the operator-supplied
|
|
114
|
+
# MOCKSERVER_BINARY_BASE_URL. Interior slashes are preserved; only the
|
|
115
|
+
# trailing run is removed.
|
|
116
|
+
last = base.length
|
|
117
|
+
last -= 1 while last.positive? && base[last - 1] == '/'
|
|
118
|
+
"#{base[0, last]}/#{file}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Return the expected launcher path inside a versioned cache directory.
|
|
122
|
+
#
|
|
123
|
+
# @param dir [String] the version directory
|
|
124
|
+
# @param bundle_name [String] the bundle base name
|
|
125
|
+
# @return [String]
|
|
126
|
+
def launcher_path(dir, bundle_name)
|
|
127
|
+
exe = windows? ? 'mockserver.bat' : 'mockserver'
|
|
128
|
+
File.join(dir, bundle_name, 'bin', exe)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Ensure the platform bundle is present and return the launcher path,
|
|
132
|
+
# downloading + verifying + extracting + caching on first use.
|
|
133
|
+
#
|
|
134
|
+
# @param version [String] the MockServer version (defaults to MockServer::VERSION)
|
|
135
|
+
# @param log [Logger, nil] optional logger
|
|
136
|
+
# @return [String] absolute path to the launcher executable
|
|
137
|
+
# @raise [Error] on download/verification failure
|
|
138
|
+
def ensure_launcher(version: nil, log: nil)
|
|
139
|
+
version ||= MockServer::VERSION
|
|
140
|
+
log ||= Logger.new($stderr, level: Logger::WARN)
|
|
141
|
+
|
|
142
|
+
validate_version!(version)
|
|
143
|
+
|
|
144
|
+
meta = bundle_base_name(version)
|
|
145
|
+
base = cache_dir
|
|
146
|
+
dir = File.join(base, version)
|
|
147
|
+
launcher = launcher_path(dir, meta[:name])
|
|
148
|
+
|
|
149
|
+
# H1: Assert version dir stays within cache base (path traversal guard)
|
|
150
|
+
assert_within_base!(dir, base)
|
|
151
|
+
|
|
152
|
+
# Check cache
|
|
153
|
+
if File.exist?(launcher) && File.size(launcher) > 0
|
|
154
|
+
log.info("Using cached binary: #{launcher}")
|
|
155
|
+
# Contract section 7: prune only after a successful install, not on cache hit
|
|
156
|
+
return launcher
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Skip-download check
|
|
160
|
+
if ENV['MOCKSERVER_SKIP_BINARY_DOWNLOAD'] && !ENV['MOCKSERVER_SKIP_BINARY_DOWNLOAD'].empty?
|
|
161
|
+
raise Error, "MOCKSERVER_SKIP_BINARY_DOWNLOAD is set but no cached binary at #{launcher}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
FileUtils.mkdir_p(dir)
|
|
165
|
+
archive_file = "#{meta[:name]}.#{meta[:ext]}"
|
|
166
|
+
archive = File.join(dir, archive_file)
|
|
167
|
+
partial = "#{archive}.part"
|
|
168
|
+
sha_file = "#{archive}.sha256"
|
|
169
|
+
|
|
170
|
+
begin
|
|
171
|
+
# Download to a temp file
|
|
172
|
+
url = asset_url(version, archive_file)
|
|
173
|
+
log.info("Downloading #{url}")
|
|
174
|
+
download_file(url, partial)
|
|
175
|
+
|
|
176
|
+
# Verify SHA-256 (fail-closed — always required, no bypass)
|
|
177
|
+
sha_url = asset_url(version, "#{archive_file}.sha256")
|
|
178
|
+
download_file(sha_url, sha_file)
|
|
179
|
+
raw = File.read(sha_file, encoding: 'utf-8').strip
|
|
180
|
+
expected = raw.split(/\s+/).first
|
|
181
|
+
if expected.nil? || expected.empty?
|
|
182
|
+
raise Error, "checksum file for #{meta[:name]} is empty or unparseable"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
actual = Digest::SHA256.file(partial).hexdigest
|
|
186
|
+
if expected != actual
|
|
187
|
+
raise Error,
|
|
188
|
+
"checksum mismatch for #{meta[:name]}: expected #{expected}, got #{actual}"
|
|
189
|
+
end
|
|
190
|
+
log.info('Checksum verified')
|
|
191
|
+
|
|
192
|
+
File.rename(partial, archive)
|
|
193
|
+
rescue StandardError
|
|
194
|
+
# H3: Best-effort cleanup of BOTH .part and .sha256 temp files on failure
|
|
195
|
+
File.delete(partial) if File.exist?(partial)
|
|
196
|
+
File.delete(sha_file) if File.exist?(sha_file)
|
|
197
|
+
raise
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Extract the archive into the version directory.
|
|
201
|
+
log.info("Extracting #{archive}")
|
|
202
|
+
extract_archive(archive, dir, meta[:ext])
|
|
203
|
+
|
|
204
|
+
# H3: Post-extract path traversal guard — enumerate every extracted entry
|
|
205
|
+
# and verify it resolves within the version directory. If any entry escaped
|
|
206
|
+
# (via ../ or absolute paths in the archive), abort with a clear error.
|
|
207
|
+
verify_extracted_paths!(dir)
|
|
208
|
+
|
|
209
|
+
unless File.exist?(launcher) && File.size(launcher) > 0
|
|
210
|
+
raise Error, "launcher missing or empty after extract: #{launcher}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
File.chmod(0o755, launcher) unless windows?
|
|
214
|
+
|
|
215
|
+
# Contract section 7: prune after successful install
|
|
216
|
+
prune_old_versions(version, log: log)
|
|
217
|
+
|
|
218
|
+
launcher
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Start a MockServer instance on the given port.
|
|
222
|
+
#
|
|
223
|
+
# @param port [Integer] the server port
|
|
224
|
+
# @param version [String, nil] the MockServer version
|
|
225
|
+
# @param extra_args [Array<String>] additional CLI arguments
|
|
226
|
+
# @param log [Logger, nil] optional logger
|
|
227
|
+
# @return [ServerHandle] a handle to the running server process
|
|
228
|
+
def start(port:, version: nil, extra_args: [], log: nil)
|
|
229
|
+
launcher = ensure_launcher(version: version, log: log)
|
|
230
|
+
args = ['-serverPort', port.to_s] + extra_args
|
|
231
|
+
|
|
232
|
+
# H4: On Windows, .bat files must be invoked via cmd.exe /c.
|
|
233
|
+
# H5: Drain stdout/stderr via :out/:err redirection to avoid pipe-buffer deadlock.
|
|
234
|
+
pid = if windows?
|
|
235
|
+
Process.spawn('cmd.exe', '/c', launcher, *args,
|
|
236
|
+
out: File::NULL, err: File::NULL)
|
|
237
|
+
else
|
|
238
|
+
Process.spawn(launcher, *args,
|
|
239
|
+
out: File::NULL, err: File::NULL)
|
|
240
|
+
end
|
|
241
|
+
ServerHandle.new(pid: pid, port: port, launcher: launcher)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Remove old version directories from the cache, keeping the current version
|
|
245
|
+
# and at most MAX_PREVIOUS_VERSIONS_TO_KEEP previous versions.
|
|
246
|
+
#
|
|
247
|
+
# Uses semver-aware numeric segment comparison (H7) rather than lexicographic
|
|
248
|
+
# or mtime-based ordering.
|
|
249
|
+
#
|
|
250
|
+
# @param current_version [String]
|
|
251
|
+
# @param log [Logger, nil]
|
|
252
|
+
# @return [void]
|
|
253
|
+
def prune_old_versions(current_version, log: nil)
|
|
254
|
+
log ||= Logger.new($stderr, level: Logger::WARN)
|
|
255
|
+
base = cache_dir
|
|
256
|
+
|
|
257
|
+
return unless File.directory?(base)
|
|
258
|
+
|
|
259
|
+
entries = Dir.entries(base).select do |entry|
|
|
260
|
+
next false if entry == '.' || entry == '..'
|
|
261
|
+
|
|
262
|
+
full = File.join(base, entry)
|
|
263
|
+
# Only consider directories (version directories) — never files
|
|
264
|
+
File.directory?(full)
|
|
265
|
+
rescue Errno::ENOENT
|
|
266
|
+
# Directory vanished between Dir.entries and File.directory? — skip
|
|
267
|
+
false
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Separate current from old
|
|
271
|
+
old_entries = entries.reject { |e| e == current_version }
|
|
272
|
+
|
|
273
|
+
# H7: Sort old entries by semver-aware numeric comparison (highest first = kept)
|
|
274
|
+
old_sorted = old_entries.sort { |a, b| compare_versions(b, a) }
|
|
275
|
+
|
|
276
|
+
# Keep at most MAX_PREVIOUS_VERSIONS_TO_KEEP
|
|
277
|
+
to_remove = old_sorted.drop(MAX_PREVIOUS_VERSIONS_TO_KEEP)
|
|
278
|
+
to_remove.each do |name|
|
|
279
|
+
full = File.join(base, name)
|
|
280
|
+
begin
|
|
281
|
+
# Safety: never delete outside the cache dir
|
|
282
|
+
real_base = File.realpath(base)
|
|
283
|
+
real_full = File.realpath(full)
|
|
284
|
+
unless real_full.start_with?(real_base + File::SEPARATOR) || real_full == real_base
|
|
285
|
+
log.warn("Skipping suspicious path during prune: #{full}")
|
|
286
|
+
next
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
log.info("Pruning old version cache: #{name}")
|
|
290
|
+
FileUtils.rm_rf(full)
|
|
291
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
292
|
+
# COR-06: directory vanished concurrently or permission denied — skip gracefully
|
|
293
|
+
log.warn("Skipping during prune (#{e.class}): #{full}")
|
|
294
|
+
next
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Clean up leftover .part and .sha256 temp files at ALL levels (not just base)
|
|
299
|
+
Dir.glob(File.join(base, '**', '*.part')).each do |part_file|
|
|
300
|
+
log.info("Removing leftover temp file: #{part_file}")
|
|
301
|
+
File.delete(part_file)
|
|
302
|
+
rescue StandardError => e
|
|
303
|
+
log.warn("Failed to remove temp file #{part_file}: #{e.message}")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
Dir.glob(File.join(base, '**', '*.sha256')).each do |sha_file|
|
|
307
|
+
# Only remove orphaned .sha256 files (where the corresponding archive is absent)
|
|
308
|
+
archive_path = sha_file.sub(/\.sha256\z/, '')
|
|
309
|
+
next if File.exist?(archive_path)
|
|
310
|
+
|
|
311
|
+
log.info("Removing orphaned checksum file: #{sha_file}")
|
|
312
|
+
File.delete(sha_file)
|
|
313
|
+
rescue StandardError => e
|
|
314
|
+
log.warn("Failed to remove checksum file #{sha_file}: #{e.message}")
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
private
|
|
319
|
+
|
|
320
|
+
# Validate the version string against the strict pattern (H1).
|
|
321
|
+
#
|
|
322
|
+
# @param version [String]
|
|
323
|
+
# @raise [Error] if the version contains path separators, '..', or does not match the pattern
|
|
324
|
+
def validate_version!(version)
|
|
325
|
+
if version.include?('/') || version.include?('\\') || version.include?('..')
|
|
326
|
+
raise Error, "invalid version (path traversal attempt): #{version}"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
unless VERSION_PATTERN.match?(version)
|
|
330
|
+
raise Error, "invalid version format: #{version}"
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Assert that a resolved path stays within the cache base directory (H1).
|
|
335
|
+
#
|
|
336
|
+
# Uses File.expand_path (not File.realpath) because the target directory may
|
|
337
|
+
# not exist yet at call time. This means a pre-existing symlink inside the
|
|
338
|
+
# cache base that points outside it would bypass this guard. The prune path
|
|
339
|
+
# uses File.realpath for existing entries, which closes the gap for deletions.
|
|
340
|
+
# For creation-time paths the risk is mitigated by validate_version! rejecting
|
|
341
|
+
# path separators and '..' before this method is reached.
|
|
342
|
+
#
|
|
343
|
+
# @param target [String] the directory to validate
|
|
344
|
+
# @param base [String] the cache base directory
|
|
345
|
+
# @raise [Error] if the target escapes the base
|
|
346
|
+
def assert_within_base!(target, base)
|
|
347
|
+
expanded_target = File.expand_path(target)
|
|
348
|
+
expanded_base = File.expand_path(base)
|
|
349
|
+
unless expanded_target.start_with?(expanded_base + File::SEPARATOR) || expanded_target == expanded_base
|
|
350
|
+
raise Error, "path traversal blocked: #{target} is not within #{base}"
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Extract an archive (tar.gz or zip) into the target directory.
|
|
355
|
+
#
|
|
356
|
+
# On Windows with a .zip archive, uses PowerShell Expand-Archive as a
|
|
357
|
+
# fallback when tar.exe is not available (pre-Windows 10 build 17063).
|
|
358
|
+
# For tar.gz archives, uses system tar with GNU/bsdtar safe flags.
|
|
359
|
+
#
|
|
360
|
+
# @param archive [String] path to the archive file
|
|
361
|
+
# @param dir [String] destination directory
|
|
362
|
+
# @param ext [String] 'tar.gz' or 'zip'
|
|
363
|
+
# @raise [Error] on extraction failure
|
|
364
|
+
def extract_archive(archive, dir, ext)
|
|
365
|
+
if ext == 'zip' && windows?
|
|
366
|
+
extract_zip_windows(archive, dir)
|
|
367
|
+
else
|
|
368
|
+
# H3: Use --no-same-owner to avoid permission issues, and pass archive
|
|
369
|
+
# through system tar which auto-detects gzip. GNU tar and bsdtar both
|
|
370
|
+
# support -xf with -C for extraction to a target directory.
|
|
371
|
+
result = system('tar', '-xf', archive, '-C', dir)
|
|
372
|
+
unless result
|
|
373
|
+
raise Error, "extraction failed (tar returned non-zero or not found)"
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Windows-specific zip extraction with PowerShell fallback.
|
|
379
|
+
#
|
|
380
|
+
# Tries system tar first (available on Windows 10 17063+), then falls back
|
|
381
|
+
# to PowerShell's Expand-Archive cmdlet. Archive and dir paths are passed
|
|
382
|
+
# via -LiteralPath to avoid wildcard/injection issues.
|
|
383
|
+
#
|
|
384
|
+
# @param archive [String] path to the .zip file
|
|
385
|
+
# @param dir [String] destination directory
|
|
386
|
+
# @raise [Error] on extraction failure
|
|
387
|
+
def extract_zip_windows(archive, dir)
|
|
388
|
+
# Try tar.exe first (bsdtar, available on modern Windows)
|
|
389
|
+
if system('tar', '-xf', archive, '-C', dir)
|
|
390
|
+
return
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Fallback: PowerShell Expand-Archive (available on all PowerShell 5.0+ systems).
|
|
394
|
+
# Use -LiteralPath to prevent wildcard expansion of the archive path.
|
|
395
|
+
ps_cmd = "Expand-Archive -LiteralPath '#{archive.gsub("'", "''")}' " \
|
|
396
|
+
"-DestinationPath '#{dir.gsub("'", "''")}' -Force"
|
|
397
|
+
result = system('powershell.exe', '-NoProfile', '-NoLogo', '-Command', ps_cmd)
|
|
398
|
+
unless result
|
|
399
|
+
raise Error, "zip extraction failed: neither tar.exe nor PowerShell Expand-Archive succeeded"
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# H3: Verify that all extracted files/directories stay within the version dir.
|
|
404
|
+
#
|
|
405
|
+
# Enumerates every entry under dir via Dir.glob and verifies each one's
|
|
406
|
+
# real path (resolving symlinks) is within the dir. This catches archives
|
|
407
|
+
# containing ../ entries, absolute paths, or symlinks that escape the dir.
|
|
408
|
+
#
|
|
409
|
+
# @param dir [String] the version directory that extraction targeted
|
|
410
|
+
# @raise [Error] if any extracted path escapes the directory
|
|
411
|
+
def verify_extracted_paths!(dir)
|
|
412
|
+
real_dir = File.realpath(dir)
|
|
413
|
+
|
|
414
|
+
Dir.glob(File.join(dir, '**', '*'), File::FNM_DOTMATCH).each do |entry|
|
|
415
|
+
# Skip . and .. pseudo-entries
|
|
416
|
+
next if entry.end_with?('/..') || entry.end_with?('/.')
|
|
417
|
+
|
|
418
|
+
begin
|
|
419
|
+
real_entry = File.realpath(entry)
|
|
420
|
+
rescue Errno::ENOENT
|
|
421
|
+
# Broken symlink — suspicious but not an escape; skip
|
|
422
|
+
next
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
unless real_entry.start_with?(real_dir + File::SEPARATOR) || real_entry == real_dir
|
|
426
|
+
raise Error,
|
|
427
|
+
"tar path traversal detected: extracted entry #{entry} " \
|
|
428
|
+
"resolves outside version directory #{dir}"
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Compare two version strings using semver-aware numeric segment comparison (H7).
|
|
434
|
+
# Pre-release versions (e.g. 7.0.0-SNAPSHOT, 7.0.0-beta) sort LOWER than
|
|
435
|
+
# their release counterpart (7.0.0), per Semantic Versioning 2.0.0 rule 11.
|
|
436
|
+
# Falls back to lexicographic comparison for non-numeric segments.
|
|
437
|
+
#
|
|
438
|
+
# @param a [String]
|
|
439
|
+
# @param b [String]
|
|
440
|
+
# @return [Integer] -1, 0, or 1
|
|
441
|
+
def compare_versions(a, b)
|
|
442
|
+
# Split into numeric core and optional pre-release tag.
|
|
443
|
+
# "7.0.0-beta.1" -> core=[7,0,0], pre=["beta","1"]
|
|
444
|
+
# "7.0.0" -> core=[7,0,0], pre=nil
|
|
445
|
+
core_a, pre_a = split_version(a)
|
|
446
|
+
core_b, pre_b = split_version(b)
|
|
447
|
+
|
|
448
|
+
# Compare numeric core segments first
|
|
449
|
+
max_core = [core_a.length, core_b.length].max
|
|
450
|
+
max_core.times do |i|
|
|
451
|
+
sa = core_a[i] || 0
|
|
452
|
+
sb = core_b[i] || 0
|
|
453
|
+
cmp = sa <=> sb
|
|
454
|
+
return cmp unless cmp == 0
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Cores are equal — apply semver pre-release precedence:
|
|
458
|
+
# "no pre-release" > "any pre-release" (releases outrank pre-releases)
|
|
459
|
+
return 0 if pre_a.nil? && pre_b.nil?
|
|
460
|
+
return 1 if pre_a.nil? # a is release, b is pre-release -> a > b
|
|
461
|
+
return -1 if pre_b.nil? # a is pre-release, b is release -> a < b
|
|
462
|
+
|
|
463
|
+
# Both have pre-release tags — compare segment by segment
|
|
464
|
+
max_pre = [pre_a.length, pre_b.length].max
|
|
465
|
+
max_pre.times do |i|
|
|
466
|
+
sa = pre_a[i]
|
|
467
|
+
sb = pre_b[i]
|
|
468
|
+
|
|
469
|
+
# Fewer pre-release segments = lower precedence (semver rule 11.4.4)
|
|
470
|
+
return -1 if sa.nil?
|
|
471
|
+
return 1 if sb.nil?
|
|
472
|
+
|
|
473
|
+
# Numeric segments compare numerically; string segments lexicographically;
|
|
474
|
+
# numeric < string (semver rule 11.4.3)
|
|
475
|
+
a_num = sa.match?(/\A\d+\z/)
|
|
476
|
+
b_num = sb.match?(/\A\d+\z/)
|
|
477
|
+
|
|
478
|
+
if a_num && b_num
|
|
479
|
+
cmp = sa.to_i <=> sb.to_i
|
|
480
|
+
elsif a_num
|
|
481
|
+
cmp = -1 # numeric < string
|
|
482
|
+
elsif b_num
|
|
483
|
+
cmp = 1 # string > numeric
|
|
484
|
+
else
|
|
485
|
+
cmp = sa <=> sb
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
return cmp unless cmp == 0
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
0
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Split a version string into [core_segments, pre_release_segments_or_nil].
|
|
495
|
+
#
|
|
496
|
+
# @param ver [String] e.g. "7.0.0-beta.1"
|
|
497
|
+
# @return [Array<Array<Integer>, Array<String>|nil>]
|
|
498
|
+
def split_version(ver)
|
|
499
|
+
# The first hyphen separates core from pre-release
|
|
500
|
+
parts = ver.split('-', 2)
|
|
501
|
+
core = parts[0].split('.').map { |s| s.match?(/\A\d+\z/) ? s.to_i : 0 }
|
|
502
|
+
pre = parts[1] ? parts[1].split(/[.\-]/) : nil
|
|
503
|
+
[core, pre]
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# @return [Boolean] true if the version contains '-SNAPSHOT' (case-insensitive)
|
|
507
|
+
def snapshot?(version)
|
|
508
|
+
version.upcase.include?('-SNAPSHOT')
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# @return [Boolean] true if the current platform is Windows
|
|
512
|
+
def windows?
|
|
513
|
+
RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/i ? true : false
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Download a URL to a local file, following redirects.
|
|
517
|
+
#
|
|
518
|
+
# Supports +file://+ URIs (for testing) and +http://+ / +https://+.
|
|
519
|
+
# Respects Ruby's built-in HTTP_PROXY / HTTPS_PROXY handling and
|
|
520
|
+
# SSL_CERT_FILE / SSL_CERT_DIR for corporate TLS proxies.
|
|
521
|
+
#
|
|
522
|
+
# @param url [String]
|
|
523
|
+
# @param dest [String]
|
|
524
|
+
# @raise [Error] on HTTP error or I/O failure
|
|
525
|
+
def download_file(url, dest)
|
|
526
|
+
uri = URI.parse(url)
|
|
527
|
+
|
|
528
|
+
if uri.scheme == 'file'
|
|
529
|
+
src = uri.path
|
|
530
|
+
unless File.exist?(src)
|
|
531
|
+
raise Error, "download #{url} failed: file not found"
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
FileUtils.cp(src, dest)
|
|
535
|
+
return
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Use Net::HTTP with redirect following (up to 5 hops)
|
|
539
|
+
fetch_with_redirects(uri, dest, 5)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Follow redirects manually using Net::HTTP.
|
|
543
|
+
# H6: Stream the body to disk chunk by chunk — never buffer the full response
|
|
544
|
+
# (the JVM-less bundle can be 100-300 MB).
|
|
545
|
+
def fetch_with_redirects(uri, dest, max_redirects)
|
|
546
|
+
raise Error, "too many redirects for #{uri}" if max_redirects <= 0
|
|
547
|
+
|
|
548
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
549
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
550
|
+
http.open_timeout = 30
|
|
551
|
+
http.read_timeout = 300
|
|
552
|
+
|
|
553
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
554
|
+
|
|
555
|
+
http.request(request) do |response|
|
|
556
|
+
case response
|
|
557
|
+
when Net::HTTPSuccess
|
|
558
|
+
File.open(dest, 'wb') do |f|
|
|
559
|
+
response.read_body do |chunk|
|
|
560
|
+
f.write(chunk)
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
when Net::HTTPRedirection
|
|
564
|
+
# Drain the redirect response body so unread bytes do not remain
|
|
565
|
+
# in the socket buffer (harmless today since each redirect opens a
|
|
566
|
+
# fresh connection, but defensive against future keep-alive reuse).
|
|
567
|
+
response.read_body
|
|
568
|
+
location = response['location']
|
|
569
|
+
fetch_with_redirects(URI.parse(location), dest, max_redirects - 1)
|
|
570
|
+
else
|
|
571
|
+
raise Error, "download #{uri} failed: HTTP #{response.code}"
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# Handle to a running MockServer process.
|
|
578
|
+
class ServerHandle
|
|
579
|
+
# @return [Integer] the process ID
|
|
580
|
+
attr_reader :pid
|
|
581
|
+
|
|
582
|
+
# @return [Integer] the server port
|
|
583
|
+
attr_reader :port
|
|
584
|
+
|
|
585
|
+
# @return [String] path to the launcher executable
|
|
586
|
+
attr_reader :launcher
|
|
587
|
+
|
|
588
|
+
def initialize(pid:, port:, launcher:)
|
|
589
|
+
@pid = pid
|
|
590
|
+
@port = port
|
|
591
|
+
@launcher = launcher
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Stop the server by terminating the process.
|
|
595
|
+
#
|
|
596
|
+
# @param timeout [Numeric] seconds to wait before SIGKILL (default 10)
|
|
597
|
+
# @return [void]
|
|
598
|
+
def stop(timeout: 10)
|
|
599
|
+
return unless @pid
|
|
600
|
+
|
|
601
|
+
begin
|
|
602
|
+
Process.kill('TERM', @pid)
|
|
603
|
+
deadline = Time.now + timeout
|
|
604
|
+
loop do
|
|
605
|
+
Process.waitpid(@pid, Process::WNOHANG) && break
|
|
606
|
+
if Time.now > deadline
|
|
607
|
+
Process.kill('KILL', @pid)
|
|
608
|
+
Process.waitpid(@pid)
|
|
609
|
+
break
|
|
610
|
+
end
|
|
611
|
+
sleep 0.1
|
|
612
|
+
end
|
|
613
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
614
|
+
# Process already gone
|
|
615
|
+
end
|
|
616
|
+
@pid = nil
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# @return [Boolean] true if the process is still running
|
|
620
|
+
def running?
|
|
621
|
+
return false unless @pid
|
|
622
|
+
|
|
623
|
+
Process.kill(0, @pid)
|
|
624
|
+
true
|
|
625
|
+
rescue Errno::ESRCH
|
|
626
|
+
# Process does not exist
|
|
627
|
+
false
|
|
628
|
+
rescue Errno::EPERM
|
|
629
|
+
# Process exists but we lack permission to signal it — it IS running
|
|
630
|
+
true
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
end
|