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