mimic-browser 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.
- checksums.yaml +7 -0
- data/LICENSE +57 -0
- data/README.md +96 -0
- data/bin/mimic-sdk +29 -0
- data/lib/mimic_sdk/ferrum.rb +126 -0
- data/lib/mimic_sdk/generated.rb +878 -0
- data/lib/mimic_sdk/runtime-lock.json +57 -0
- data/lib/mimic_sdk/runtime.rb +413 -0
- data/lib/mimic_sdk/transport.rb +240 -0
- data/lib/mimic_sdk.rb +7 -0
- metadata +86 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"release": "v0.2.3",
|
|
3
|
+
"manifestSha256": "11964875450f3526da487cc8d66618065fb3e0d1a5d69250a27a8def2b514fb5",
|
|
4
|
+
"manifestJson": "{\r\n \"version\": \"v0.2.3\",\r\n \"sourceRevision\": \"cfcabd40e52e86bb30461888fe101f37b4996774\",\r\n \"packagingRevision\": \"cfcabd40e52e86bb30461888fe101f37b4996774\",\r\n \"ciRun\": \"https://github.com/mimic-browser/runtime/actions/runs/37752215291\",\r\n \"binariesReusedUnchanged\": true,\r\n \"license\": \"Prosperity-3.0.0\",\r\n \"exampleLicense\": \"MIT\",\r\n \"requirements\": {\r\n \"windows-amd64\": \"Windows x64; validated on Windows 11\",\r\n \"linux-amd64\": \"glibc 2.39+, libgcc_s, installed fonts; Ubuntu 24.04 / WSL2\"\r\n },\r\n \"clients\": {\r\n \"playwright-core\": \"1.63.0\",\r\n \"puppeteer-core\": \"25.10.0\"\r\n },\r\n \"artifacts\": [\r\n {\r\n \"platform\": \"windows-amd64\",\r\n \"archive\": \"mimic-v0.2.3-windows-amd64.zip\",\r\n \"sha256\": \"6040ed4fa90f7cb42df6cb54d9a1481c0945a3cbde340ffebd5d45883ee49a19\",\r\n \"size\": 47677460,\r\n \"binarySha256\": \"a657174860a4e570ef8e0ee8737a566134f4c1c15d595e8f6f823437c1556cb5\",\r\n \"binaryVersion\": \"v0.2.3\",\r\n \"checks\": [\r\n \"runtimecheck:v8\",\r\n \"runtimecheck:quickjs\",\r\n \"runtimecheck:goja\",\r\n \"examples:puppeteer\",\r\n \"examples:playwright\",\r\n \"examples:concurrency\"\r\n ]\r\n },\r\n {\r\n \"platform\": \"linux-amd64\",\r\n \"archive\": \"mimic-v0.2.3-linux-amd64.tar.gz\",\r\n \"sha256\": \"4fb6a71f7b2c5d5ad49136c9456e6f2cc1e70701e91259ff905ae72218c56a9a\",\r\n \"size\": 51026543,\r\n \"binarySha256\": \"414fc217b24301861c078e0e39dafb79110996ea4c4c23ff7d33fab41175eb32\",\r\n \"binaryVersion\": \"v0.2.3\",\r\n \"checks\": [\r\n \"runtimecheck:v8\",\r\n \"runtimecheck:quickjs\",\r\n \"runtimecheck:goja\",\r\n \"examples:puppeteer\",\r\n \"examples:playwright\",\r\n \"examples:concurrency\"\r\n ]\r\n }\r\n ]\r\n}\r\n",
|
|
5
|
+
"manifest": {
|
|
6
|
+
"version": "v0.2.3",
|
|
7
|
+
"sourceRevision": "cfcabd40e52e86bb30461888fe101f37b4996774",
|
|
8
|
+
"packagingRevision": "cfcabd40e52e86bb30461888fe101f37b4996774",
|
|
9
|
+
"ciRun": "https://github.com/mimic-browser/runtime/actions/runs/37752215291",
|
|
10
|
+
"binariesReusedUnchanged": true,
|
|
11
|
+
"license": "Prosperity-3.0.0",
|
|
12
|
+
"exampleLicense": "MIT",
|
|
13
|
+
"requirements": {
|
|
14
|
+
"windows-amd64": "Windows x64; validated on Windows 11",
|
|
15
|
+
"linux-amd64": "glibc 2.39+, libgcc_s, installed fonts; Ubuntu 24.04 / WSL2"
|
|
16
|
+
},
|
|
17
|
+
"clients": {
|
|
18
|
+
"playwright-core": "1.63.0",
|
|
19
|
+
"puppeteer-core": "25.10.0"
|
|
20
|
+
},
|
|
21
|
+
"artifacts": [
|
|
22
|
+
{
|
|
23
|
+
"platform": "windows-amd64",
|
|
24
|
+
"archive": "mimic-v0.2.3-windows-amd64.zip",
|
|
25
|
+
"sha256": "6040ed4fa90f7cb42df6cb54d9a1481c0945a3cbde340ffebd5d45883ee49a19",
|
|
26
|
+
"size": 47677460,
|
|
27
|
+
"binarySha256": "a657174860a4e570ef8e0ee8737a566134f4c1c15d595e8f6f823437c1556cb5",
|
|
28
|
+
"binaryVersion": "v0.2.3",
|
|
29
|
+
"checks": [
|
|
30
|
+
"runtimecheck:v8",
|
|
31
|
+
"runtimecheck:quickjs",
|
|
32
|
+
"runtimecheck:goja",
|
|
33
|
+
"examples:puppeteer",
|
|
34
|
+
"examples:playwright",
|
|
35
|
+
"examples:concurrency"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"platform": "linux-amd64",
|
|
40
|
+
"archive": "mimic-v0.2.3-linux-amd64.tar.gz",
|
|
41
|
+
"sha256": "4fb6a71f7b2c5d5ad49136c9456e6f2cc1e70701e91259ff905ae72218c56a9a",
|
|
42
|
+
"size": 51026543,
|
|
43
|
+
"binarySha256": "414fc217b24301861c078e0e39dafb79110996ea4c4c23ff7d33fab41175eb32",
|
|
44
|
+
"binaryVersion": "v0.2.3",
|
|
45
|
+
"checks": [
|
|
46
|
+
"runtimecheck:v8",
|
|
47
|
+
"runtimecheck:quickjs",
|
|
48
|
+
"runtimecheck:goja",
|
|
49
|
+
"examples:puppeteer",
|
|
50
|
+
"examples:playwright",
|
|
51
|
+
"examples:concurrency"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"baseUrl": "https://github.com/mimic-browser/runtime/releases/download/v0.2.3"
|
|
57
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
require 'digest'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'rubygems/package'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
require 'socket'
|
|
9
|
+
require 'time'
|
|
10
|
+
require 'zlib'
|
|
11
|
+
require 'zip'
|
|
12
|
+
|
|
13
|
+
module MimicSDK
|
|
14
|
+
class RuntimeError < StandardError; end
|
|
15
|
+
|
|
16
|
+
class RuntimeManager
|
|
17
|
+
attr_reader :options
|
|
18
|
+
VERSION_PATTERN = /\Av?\d+\.\d+\.\d+(?:-beta\.\d+)?\z/
|
|
19
|
+
SHA_PATTERN = /\A[0-9a-f]{64}\z/
|
|
20
|
+
|
|
21
|
+
def initialize(**options)
|
|
22
|
+
@options = options
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def normalize_version(value)
|
|
26
|
+
raise RuntimeError, "Expected an exact runtime version: #{value.inspect}" unless value.is_a?(String) && VERSION_PATTERN.match?(value)
|
|
27
|
+
'v' + value.delete_prefix('v')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def root
|
|
31
|
+
File.expand_path(options[:runtime_dir] || ENV['MIMIC_RUNTIME_DIR'] ||
|
|
32
|
+
if Gem.win_platform?
|
|
33
|
+
File.join(ENV.fetch('LOCALAPPDATA'), 'Mimic', 'runtimes')
|
|
34
|
+
else
|
|
35
|
+
File.join(ENV['XDG_CACHE_HOME'] || File.join(Dir.home, '.cache'), 'Mimic', 'runtimes')
|
|
36
|
+
end)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def platform
|
|
40
|
+
cpu = RbConfig::CONFIG['host_cpu']
|
|
41
|
+
raise RuntimeError, "Unsupported runtime CPU: #{cpu}" unless %w[x86_64 amd64].include?(cpu)
|
|
42
|
+
return 'windows-amd64' if Gem.win_platform?
|
|
43
|
+
raise RuntimeError, 'Only Windows x64 and Linux glibc x64 are packaged' unless RUBY_PLATFORM.include?('linux')
|
|
44
|
+
version, status = Open3.capture2('getconf', 'GNU_LIBC_VERSION')
|
|
45
|
+
match = version.match(/\Aglibc (\d+)\.(\d+)/)
|
|
46
|
+
raise RuntimeError, 'Linux runtime requires glibc 2.39+' unless status.success? && match && ([match[1].to_i, match[2].to_i] <=> [2, 39]) >= 0
|
|
47
|
+
'linux-amd64'
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def downloads?
|
|
51
|
+
options.fetch(:allow_download) { ENV['MIMIC_DOWNLOAD'] != '0' }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_lock(lock)
|
|
55
|
+
version = normalize_version(lock.fetch('release'))
|
|
56
|
+
raise RuntimeError, 'Lock release must be normalized' unless version == lock['release']
|
|
57
|
+
bytes = lock.fetch('manifestJson')
|
|
58
|
+
raise RuntimeError, 'Manifest integrity mismatch' unless SHA_PATTERN.match?(lock.fetch('manifestSha256')) && Digest::SHA256.hexdigest(bytes) == lock['manifestSha256']
|
|
59
|
+
manifest = JSON.parse(bytes)
|
|
60
|
+
raise RuntimeError, 'Manifest bytes and parsed identity differ' unless manifest == lock.fetch('manifest')
|
|
61
|
+
raise RuntimeError, 'Manifest release/source mismatch' unless manifest['version'] == version && manifest['sourceRevision'].is_a?(String) && manifest['sourceRevision'].match?(/\A[0-9a-f]{40}\z/)
|
|
62
|
+
raise RuntimeError, 'Lock must use the exact official release URL' unless lock['baseUrl'] == "https://github.com/mimic-browser/runtime/releases/download/#{version}"
|
|
63
|
+
platforms = []
|
|
64
|
+
manifest.fetch('artifacts').each do |artifact|
|
|
65
|
+
host = artifact.fetch('platform')
|
|
66
|
+
raise RuntimeError, 'Invalid manifest platform' unless %w[windows-amd64 linux-amd64].include?(host) && !platforms.include?(host)
|
|
67
|
+
platforms << host
|
|
68
|
+
suffix = host.start_with?('windows') ? '.zip' : '.tar.gz'
|
|
69
|
+
valid = artifact['archive'] == "mimic-#{version}-#{host}#{suffix}" && artifact['binaryVersion'] == version &&
|
|
70
|
+
SHA_PATTERN.match?(artifact.fetch('sha256')) && SHA_PATTERN.match?(artifact.fetch('binarySha256')) &&
|
|
71
|
+
artifact['size'].is_a?(Integer) && artifact['size'].positive? && artifact['size'] <= 1024**3
|
|
72
|
+
raise RuntimeError, 'Invalid artifact identity' unless valid
|
|
73
|
+
end
|
|
74
|
+
raise RuntimeError, 'Empty artifact manifest' if platforms.empty?
|
|
75
|
+
lock
|
|
76
|
+
rescue KeyError, JSON::ParserError, TypeError => error
|
|
77
|
+
raise RuntimeError, "Invalid runtime lock: #{error.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def resolve_lock
|
|
81
|
+
explicit = options[:lock_file] && validate_lock(JSON.parse(File.binread(options[:lock_file])))
|
|
82
|
+
version = options[:runtime_version] && normalize_version(options[:runtime_version])
|
|
83
|
+
raise RuntimeError, 'Explicit version and lock conflict' if explicit && version && explicit['release'] != version
|
|
84
|
+
return explicit if explicit
|
|
85
|
+
version ||= normalize_version(ENV['MIMIC_RUNTIME_VERSION']) if ENV['MIMIC_RUNTIME_VERSION']
|
|
86
|
+
default = validate_lock(JSON.parse(File.binread(File.join(__dir__, 'runtime-lock.json'))))
|
|
87
|
+
return default if !version || version == default['release']
|
|
88
|
+
cached = File.join(root, '.manifests', "#{version}.json")
|
|
89
|
+
if File.file?(cached)
|
|
90
|
+
lock = validate_lock(JSON.parse(File.binread(cached)))
|
|
91
|
+
raise RuntimeError, 'Cached release mismatch' unless lock['release'] == version
|
|
92
|
+
return lock
|
|
93
|
+
end
|
|
94
|
+
base = "https://github.com/mimic-browser/runtime/releases/download/#{version}"
|
|
95
|
+
bytes = download("#{base}/release-manifest.json", limit: 4 * 1024**2)
|
|
96
|
+
sums = download("#{base}/SHA256SUMS", limit: 1024**2)
|
|
97
|
+
sha = Digest::SHA256.hexdigest(bytes)
|
|
98
|
+
raise RuntimeError, 'Manifest checksum list mismatch' unless sums.lines.any? { |line| line.strip == "#{sha} release-manifest.json" }
|
|
99
|
+
lock = validate_lock({ 'release' => version, 'manifestJson' => bytes, 'manifest' => JSON.parse(bytes), 'manifestSha256' => sha, 'baseUrl' => base })
|
|
100
|
+
FileUtils.mkdir_p(File.dirname(cached))
|
|
101
|
+
publish_manifest(cached, lock)
|
|
102
|
+
lock
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def download(url, limit:, redirects: 5)
|
|
106
|
+
raise RuntimeError, 'Downloads disabled; preinstall the exact runtime or supply its verified archive' unless downloads?
|
|
107
|
+
uri = URI(url)
|
|
108
|
+
raise RuntimeError, 'Runtime downloads require HTTPS' unless uri.scheme == 'https'
|
|
109
|
+
proxy = uri.find_proxy
|
|
110
|
+
http = Net::HTTP.new(uri.host, uri.port, proxy&.host, proxy&.port,
|
|
111
|
+
proxy&.user && URI.decode_www_form_component(proxy.user),
|
|
112
|
+
proxy&.password && URI.decode_www_form_component(proxy.password))
|
|
113
|
+
http.use_ssl = true
|
|
114
|
+
http.open_timeout = 20
|
|
115
|
+
http.read_timeout = 120
|
|
116
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
117
|
+
response = http.start do |connection|
|
|
118
|
+
connection.request_get(uri.request_uri) do |result|
|
|
119
|
+
if result.is_a?(Net::HTTPSuccess)
|
|
120
|
+
result.read_body do |chunk|
|
|
121
|
+
check_cancelled
|
|
122
|
+
raise RuntimeError, 'Download exceeds declared size' if body.bytesize + chunk.bytesize > limit
|
|
123
|
+
body << chunk
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
result
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
if response.is_a?(Net::HTTPRedirection)
|
|
130
|
+
raise RuntimeError, 'Too many download redirects' unless redirects.positive?
|
|
131
|
+
return download(URI.join(url, response.fetch('location')).to_s, limit: limit, redirects: redirects - 1)
|
|
132
|
+
end
|
|
133
|
+
raise RuntimeError, "Runtime download HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
134
|
+
body
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def with_install_lock(release, host)
|
|
138
|
+
parent = File.join(root, '.locks')
|
|
139
|
+
FileUtils.mkdir_p(parent)
|
|
140
|
+
directory = File.join(parent, "#{release}-#{host}.lock")
|
|
141
|
+
deadline = monotonic + options.fetch(:lock_timeout, 120)
|
|
142
|
+
loop do
|
|
143
|
+
check_cancelled
|
|
144
|
+
begin
|
|
145
|
+
Dir.mkdir(directory)
|
|
146
|
+
break
|
|
147
|
+
rescue Errno::EEXIST
|
|
148
|
+
raise RuntimeError, "Installation lock timed out: #{directory}; inspect owner before stale-lock repair" if monotonic >= deadline
|
|
149
|
+
sleep 0.05
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
begin
|
|
153
|
+
owner_token = SecureRandom.uuid
|
|
154
|
+
atomic_json(File.join(directory, 'owner.json'), { 'pid' => Process.pid, 'hostname' => Socket.gethostname, 'token' => owner_token, 'createdAt' => Time.now.utc.iso8601 })
|
|
155
|
+
yield
|
|
156
|
+
ensure
|
|
157
|
+
owner_path = File.join(directory, 'owner.json')
|
|
158
|
+
if File.file?(owner_path) && JSON.parse(File.binread(owner_path))['token'] == owner_token
|
|
159
|
+
File.delete(owner_path)
|
|
160
|
+
Dir.rmdir(directory)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def install
|
|
166
|
+
check_cancelled
|
|
167
|
+
host = platform
|
|
168
|
+
lock = resolve_lock
|
|
169
|
+
artifact = lock['manifest']['artifacts'].find { |item| item['platform'] == host }
|
|
170
|
+
raise RuntimeError, "No artifact for #{host}" unless artifact
|
|
171
|
+
name = host.start_with?('windows') ? 'mimic.exe' : 'mimic'
|
|
172
|
+
destination = File.join(root, lock['release'], host, artifact['binarySha256'])
|
|
173
|
+
receipt = { 'release' => lock['release'], 'platform' => host, 'sourceRevision' => lock['manifest']['sourceRevision'],
|
|
174
|
+
'archiveSha256' => artifact['sha256'], 'binarySha256' => artifact['binarySha256'],
|
|
175
|
+
'manifestSha256' => lock['manifestSha256'], 'executable' => name }
|
|
176
|
+
with_install_lock(lock['release'], host) do
|
|
177
|
+
if File.exist?(destination)
|
|
178
|
+
verify(destination, receipt)
|
|
179
|
+
return receipt.merge('path' => File.join(destination, name))
|
|
180
|
+
end
|
|
181
|
+
archive = options[:archive_path] ? File.binread(options[:archive_path]) : download("#{lock['baseUrl']}/#{artifact['archive']}", limit: artifact['size'])
|
|
182
|
+
raise RuntimeError, 'Archive integrity mismatch' unless archive.bytesize == artifact['size'] && Digest::SHA256.hexdigest(archive) == artifact['sha256']
|
|
183
|
+
staging = File.join(root, '.staging', SecureRandom.uuid)
|
|
184
|
+
FileUtils.mkdir_p(staging)
|
|
185
|
+
begin
|
|
186
|
+
extract(archive, artifact['archive'], staging, "mimic-#{lock['release']}-#{host}")
|
|
187
|
+
binary = File.join(staging, name)
|
|
188
|
+
raise RuntimeError, 'Executable integrity mismatch' unless File.file?(binary) && Digest::SHA256.file(binary).hexdigest == artifact['binarySha256']
|
|
189
|
+
File.chmod(0o700, binary)
|
|
190
|
+
atomic_json(File.join(staging, 'installation.json'), receipt)
|
|
191
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
192
|
+
File.rename(staging, destination)
|
|
193
|
+
FileUtils.mkdir_p(File.join(root, '.manifests'))
|
|
194
|
+
publish_manifest(File.join(root, '.manifests', "#{lock['release']}.json"), lock)
|
|
195
|
+
ensure
|
|
196
|
+
FileUtils.rm_rf(staging)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
receipt.merge('path' => File.join(destination, name))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def verify(directory, expected = nil)
|
|
203
|
+
receipt = JSON.parse(File.binread(File.join(directory, 'installation.json')))
|
|
204
|
+
expected&.each { |key, value| raise RuntimeError, 'Cached provenance mismatch' unless receipt[key] == value }
|
|
205
|
+
executable = receipt.fetch('executable')
|
|
206
|
+
raise RuntimeError, 'Invalid executable receipt path' unless %w[mimic mimic.exe].include?(executable)
|
|
207
|
+
binary = File.join(directory, executable)
|
|
208
|
+
raise RuntimeError, 'Cached executable is not a regular file' unless File.file?(binary) && !File.symlink?(binary)
|
|
209
|
+
raise RuntimeError, 'Cached executable hash mismatch' unless Digest::SHA256.file(binary).hexdigest == receipt.fetch('binarySha256')
|
|
210
|
+
receipt.merge('path' => binary)
|
|
211
|
+
rescue Errno::ENOENT, JSON::ParserError, KeyError => error
|
|
212
|
+
raise RuntimeError, "Incomplete cached installation: #{error.message}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def list
|
|
216
|
+
Dir.glob(File.join(root, 'v*', '*-amd64', '*', 'installation.json')).map { |file| verify(File.dirname(file)) }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def prune(installation)
|
|
220
|
+
directory = File.dirname(File.expand_path(installation.fetch('path')))
|
|
221
|
+
expected = File.join(root, installation.fetch('release'), installation.fetch('platform'), installation.fetch('binarySha256'))
|
|
222
|
+
raise RuntimeError, 'Prune target escapes the installation root' unless directory == expected
|
|
223
|
+
resolved_root = File.realpath(root)
|
|
224
|
+
resolved_directory = File.realpath(directory)
|
|
225
|
+
raise RuntimeError, 'Prune target traverses a symbolic link' unless resolved_directory == File.join(resolved_root, installation['release'], installation['platform'], installation['binarySha256'])
|
|
226
|
+
with_install_lock(installation['release'], installation['platform']) do
|
|
227
|
+
verify(directory, installation.reject { |key, _| key == 'path' })
|
|
228
|
+
Dir.glob(File.join(directory, '.leases', '*.json')).each do |file|
|
|
229
|
+
lease = JSON.parse(File.binread(file))
|
|
230
|
+
raise RuntimeError, 'Foreign or incomplete runtime lease' unless lease['hostname'] == Socket.gethostname && lease['runtimePid'].is_a?(Integer) && lease['runtimePid'].positive?
|
|
231
|
+
begin
|
|
232
|
+
Process.kill(0, lease['runtimePid'])
|
|
233
|
+
raise RuntimeError, 'Cannot prune a running runtime'
|
|
234
|
+
rescue Errno::ESRCH
|
|
235
|
+
# Confirmed dead local process; this retained lease is stale.
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
FileUtils.rm_rf(directory)
|
|
239
|
+
end
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def launch
|
|
244
|
+
check_cancelled
|
|
245
|
+
platform
|
|
246
|
+
explicit = options[:executable_path] || ENV['MIMIC_EXECUTABLE_PATH']
|
|
247
|
+
if explicit
|
|
248
|
+
path = File.expand_path(explicit)
|
|
249
|
+
raise RuntimeError, "Invalid explicit executable: #{path}" unless File.file?(path) && File.executable?(path)
|
|
250
|
+
lock = options[:lock_file] && resolve_lock
|
|
251
|
+
if lock
|
|
252
|
+
artifact = lock['manifest']['artifacts'].find { |item| item['platform'] == platform }
|
|
253
|
+
raise RuntimeError, 'Explicit executable does not match lock' unless artifact && Digest::SHA256.file(path).hexdigest == artifact['binarySha256']
|
|
254
|
+
end
|
|
255
|
+
expected = lock ? lock['release'] : options[:runtime_version] || ENV['MIMIC_RUNTIME_VERSION']
|
|
256
|
+
return RuntimeProcess.new(path, expected_version: expected && normalize_version(expected), timeout: options.fetch(:startup_timeout, 30), cancelled: options[:cancelled])
|
|
257
|
+
end
|
|
258
|
+
installation = install
|
|
259
|
+
with_install_lock(installation['release'], installation['platform']) do
|
|
260
|
+
verify(File.dirname(installation['path']), installation.reject { |key, _| key == 'path' })
|
|
261
|
+
RuntimeProcess.new(installation['path'], expected_version: installation['release'], timeout: options.fetch(:startup_timeout, 30), cancelled: options[:cancelled], lease_directory: File.join(File.dirname(installation['path']), '.leases'))
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private
|
|
266
|
+
|
|
267
|
+
def check_cancelled
|
|
268
|
+
raise RuntimeError, 'Runtime operation cancelled' if options[:cancelled]&.call
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
272
|
+
def publish_manifest(path, value)
|
|
273
|
+
temporary = path + '.' + SecureRandom.uuid + '.tmp'
|
|
274
|
+
File.binwrite(temporary, JSON.generate(value))
|
|
275
|
+
begin
|
|
276
|
+
File.link(temporary, path)
|
|
277
|
+
rescue Errno::EEXIST
|
|
278
|
+
existing = validate_lock(JSON.parse(File.binread(path)))
|
|
279
|
+
raise RuntimeError, 'Conflicting immutable release manifest' unless existing['release'] == value['release'] && existing['manifestSha256'] == value['manifestSha256']
|
|
280
|
+
end
|
|
281
|
+
ensure
|
|
282
|
+
File.delete(temporary) if temporary && File.exist?(temporary)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def atomic_json(path, value)
|
|
286
|
+
temporary = path + '.' + SecureRandom.uuid + '.tmp'
|
|
287
|
+
File.binwrite(temporary, JSON.generate(value))
|
|
288
|
+
File.rename(temporary, path)
|
|
289
|
+
ensure
|
|
290
|
+
File.delete(temporary) if temporary && File.exist?(temporary)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def extract(bytes, name, directory, prefix)
|
|
294
|
+
seen = {}
|
|
295
|
+
total = 0
|
|
296
|
+
write = lambda do |entry_name, size, type, data|
|
|
297
|
+
raise RuntimeError, 'Unsafe archive path' if entry_name.start_with?('/') || entry_name.include?('\\') || entry_name.include?(':') || entry_name.split('/').any? { |part| %w[. ..].include?(part) }
|
|
298
|
+
next if entry_name == prefix + '/' && type == :directory
|
|
299
|
+
raise RuntimeError, 'Unexpected archive root' unless entry_name.start_with?(prefix + '/')
|
|
300
|
+
relative = entry_name.delete_prefix(prefix + '/').delete_suffix('/')
|
|
301
|
+
raise RuntimeError, 'Duplicate archive entry' if relative.empty? || seen[relative]
|
|
302
|
+
seen[relative] = true
|
|
303
|
+
output = File.join(directory, relative)
|
|
304
|
+
if type == :directory
|
|
305
|
+
FileUtils.mkdir_p(output)
|
|
306
|
+
next
|
|
307
|
+
end
|
|
308
|
+
raise RuntimeError, 'Archive links/special files are unsupported' unless type == :file
|
|
309
|
+
total += size
|
|
310
|
+
raise RuntimeError, 'Archive extraction size exceeds bound' if size.negative? || total > 2 * 1024**3
|
|
311
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
312
|
+
File.open(output, 'wx', 0o600) do |file|
|
|
313
|
+
count = IO.copy_stream(data, file, size)
|
|
314
|
+
raise RuntimeError, 'Archive entry length mismatch' unless count == size
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
if name.end_with?('.zip')
|
|
318
|
+
Zip::InputStream.open(StringIO.new(bytes)) do |zip|
|
|
319
|
+
while (entry = zip.get_next_entry)
|
|
320
|
+
type = entry.symlink? ? :link : entry.directory? ? :directory : :file
|
|
321
|
+
write.call(entry.name, entry.size, type, zip)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
else
|
|
325
|
+
Zlib::GzipReader.wrap(StringIO.new(bytes)) do |gzip|
|
|
326
|
+
Gem::Package::TarReader.new(gzip) do |tar|
|
|
327
|
+
tar.each { |entry| write.call(entry.full_name, entry.header.size, entry.directory? ? :directory : entry.file? ? :file : :link, entry) }
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
class RuntimeProcess
|
|
335
|
+
attr_reader :endpoint, :transport, :mimic, :pid
|
|
336
|
+
def initialize(executable, expected_version: nil, timeout: 30, lease_directory: nil, cancelled: nil)
|
|
337
|
+
@mutex, @close_mutex, @ready = Mutex.new, Mutex.new, ConditionVariable.new
|
|
338
|
+
@log = ''
|
|
339
|
+
stdin, stdout, stderr, @waiter = Open3.popen3(executable, '--browser-mode', 'headless', '--listen', '127.0.0.1:0')
|
|
340
|
+
stdin.close
|
|
341
|
+
@pid = @waiter.pid
|
|
342
|
+
@readers = [stdout, stderr].map do |stream|
|
|
343
|
+
Thread.new do
|
|
344
|
+
stream.each_line do |line|
|
|
345
|
+
@mutex.synchronize do
|
|
346
|
+
@log = (@log + line).byteslice(-16_384, 16_384) || (@log + line)
|
|
347
|
+
if stream == stdout && (match = line.match(%r{\AMimic listening on (http://127\.0\.0\.1:(\d+))\s*\z}))
|
|
348
|
+
@endpoint = match[1] if (1..65_535).cover?(match[2].to_i)
|
|
349
|
+
@ready.broadcast
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
ensure
|
|
354
|
+
stream.close
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
358
|
+
@mutex.synchronize do
|
|
359
|
+
until @endpoint
|
|
360
|
+
raise RuntimeError, 'Runtime startup cancelled' if cancelled&.call
|
|
361
|
+
raise RuntimeError, "Runtime exited: #{@log}" unless @waiter.alive?
|
|
362
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
363
|
+
raise RuntimeError, "Runtime startup timed out: #{@log}" unless remaining.positive?
|
|
364
|
+
@ready.wait(@mutex, [remaining, 0.05].min)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
368
|
+
raise RuntimeError, 'Runtime startup timed out before discovery' unless remaining.positive?
|
|
369
|
+
@transport = Transport.new(@endpoint, timeout: remaining)
|
|
370
|
+
@mimic = Client.new(@transport)
|
|
371
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
372
|
+
raise RuntimeError, 'Runtime startup timed out before identity' unless remaining.positive?
|
|
373
|
+
identity = @transport.call_raw('Mimic.getVersion', {}, timeout: remaining, cancelled: cancelled)
|
|
374
|
+
raise RuntimeError, 'Invalid Mimic runtime identity' if identity['version'].to_s.empty? || identity['chromeVersion'].to_s.empty?
|
|
375
|
+
raise RuntimeError, "Requested #{expected_version}, runtime reports #{identity['version']}" if expected_version && ![expected_version, expected_version.delete_prefix('v')].include?(identity['version'])
|
|
376
|
+
if lease_directory
|
|
377
|
+
FileUtils.mkdir_p(lease_directory)
|
|
378
|
+
@lease = File.join(lease_directory, SecureRandom.uuid + '.json')
|
|
379
|
+
File.binwrite(@lease, JSON.generate({ 'launcherPid' => Process.pid, 'runtimePid' => @pid, 'hostname' => Socket.gethostname, 'createdAt' => Time.now.utc.iso8601 }))
|
|
380
|
+
end
|
|
381
|
+
rescue StandardError
|
|
382
|
+
close
|
|
383
|
+
raise
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def close
|
|
387
|
+
@close_mutex.synchronize do
|
|
388
|
+
return if @closed
|
|
389
|
+
@closed = true
|
|
390
|
+
begin
|
|
391
|
+
@transport&.call_raw('Browser.close', {}, timeout: 2)
|
|
392
|
+
rescue StandardError
|
|
393
|
+
# Disconnect during graceful shutdown is expected; the child wait below
|
|
394
|
+
# remains authoritative and force-stop is bounded to this owned PID.
|
|
395
|
+
ensure
|
|
396
|
+
@transport&.close
|
|
397
|
+
end
|
|
398
|
+
if @waiter&.alive?
|
|
399
|
+
unless @waiter.join(2)
|
|
400
|
+
Process.kill('TERM', @pid) rescue Errno::ESRCH
|
|
401
|
+
unless @waiter.join(2)
|
|
402
|
+
Process.kill('KILL', @pid) rescue Errno::ESRCH
|
|
403
|
+
@waiter.join
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
@readers&.each { |reader| reader.join(1) }
|
|
408
|
+
File.delete(@lease) if @lease && File.exist?(@lease)
|
|
409
|
+
end
|
|
410
|
+
nil
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|