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.
@@ -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