tebako 0.14.0 → 0.15.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/Brewfile +0 -5
- data/CMakeLists.txt +94 -46
- data/cmake/tebako-libtfs.cmake +456 -0
- data/common.env +1 -1
- data/include/tebako/tpkg.h +575 -0
- data/lib/tebako/cli.rb +91 -0
- data/lib/tebako/cli_helpers.rb +24 -0
- data/lib/tebako/error.rb +10 -0
- data/lib/tebako/launcher_abi.rb +99 -0
- data/lib/tebako/options_manager.rb +71 -0
- data/lib/tebako/packager/patch_libraries.rb +47 -27
- data/lib/tebako/packager.rb +40 -1
- data/lib/tebako/runtime_manager.rb +347 -0
- data/lib/tebako/stitcher.rb +255 -0
- data/lib/tebako/version.rb +1 -1
- data/src/tebako-main.cpp +577 -9
- data/tools/.github/workflows/test.yml +6 -6
- data/tools/cmake-scripts/macos-environment.cmake +11 -2
- metadata +7 -4
- data/tools/ci-scripts/patch-fbthrift.sh +0 -94
- data/tools/ci-scripts/patch-folly.sh +0 -551
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2026 [Ribose Inc](https://www.ribose.com).
|
|
4
|
+
# All rights reserved.
|
|
5
|
+
# This file is a part of the Tebako project.
|
|
6
|
+
#
|
|
7
|
+
# Redistribution and use in source and binary forms, with or without
|
|
8
|
+
# modification, are permitted provided that the following conditions
|
|
9
|
+
# are met:
|
|
10
|
+
# 1. Redistributions of source code must retain the above copyright
|
|
11
|
+
# notice, this list of conditions and the following disclaimer.
|
|
12
|
+
# 2. Redistributions in binary form must reproduce the above copyright
|
|
13
|
+
# notice, this list of conditions and the following disclaimer in the
|
|
14
|
+
# documentation and/or other materials provided with the distribution.
|
|
15
|
+
#
|
|
16
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
17
|
+
# ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
|
18
|
+
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
19
|
+
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS
|
|
20
|
+
# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
21
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
22
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
23
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
24
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
25
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
26
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
|
27
|
+
|
|
28
|
+
require "digest"
|
|
29
|
+
require "fileutils"
|
|
30
|
+
require "json"
|
|
31
|
+
require "net/http"
|
|
32
|
+
require "uri"
|
|
33
|
+
|
|
34
|
+
require_relative "error"
|
|
35
|
+
require_relative "version"
|
|
36
|
+
|
|
37
|
+
# Tebako - an executable packager
|
|
38
|
+
module Tebako
|
|
39
|
+
# Resolution, download, verification and machine-wide caching of prebuilt
|
|
40
|
+
# tebako runtime packages published by tamatebako/tebako-runtime-ruby.
|
|
41
|
+
#
|
|
42
|
+
# Cache layout (rooted at $TEBAKO_HOME or ~/.tebako):
|
|
43
|
+
# runtimes/ruby-<ruby-version>-<tebakoabi>-<platform>/
|
|
44
|
+
# tebako-runtime-<tebakoabi>-<ruby-version>-<platform>[.exe]
|
|
45
|
+
# sha256 -- digest the installed file was verified against
|
|
46
|
+
# origin -- URL the package was downloaded from
|
|
47
|
+
#
|
|
48
|
+
# Installs are serialized per entry with a flock'd lockfile; packages are
|
|
49
|
+
# downloaded to tmp/, SHA256-verified against the release index and moved
|
|
50
|
+
# into place with an atomic rename, so partial downloads never poison the
|
|
51
|
+
# cache.
|
|
52
|
+
class RuntimeManager # rubocop:disable Metrics/ClassLength
|
|
53
|
+
DEFAULT_MIRROR = "https://github.com/tamatebako/tebako-runtime-ruby/releases/download"
|
|
54
|
+
RUNTIMES_DIR = "runtimes"
|
|
55
|
+
TMP_DIR = "tmp"
|
|
56
|
+
LOCK_FILE = ".install.lock"
|
|
57
|
+
SHA256_FILE = "sha256"
|
|
58
|
+
ORIGIN_FILE = "origin"
|
|
59
|
+
RUNTIME_TYPE = "ruby"
|
|
60
|
+
LOCK_TIMEOUT = 300
|
|
61
|
+
DOWNLOAD_ATTEMPTS = 3
|
|
62
|
+
RETRY_DELAY = 1.0
|
|
63
|
+
REDIRECT_LIMIT = 5
|
|
64
|
+
INDEX_FILES = ["manifest.json", "SHA256SUMS.txt"].freeze
|
|
65
|
+
|
|
66
|
+
# The requested index asset is missing or unusable; try the next one
|
|
67
|
+
class IndexUnavailable < StandardError; end
|
|
68
|
+
# A download failed (after redirects/retries, at the HTTP layer)
|
|
69
|
+
class DownloadFailed < StandardError; end
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
def default_cache_root
|
|
73
|
+
tebako_home = ENV.fetch("TEBAKO_HOME", nil)
|
|
74
|
+
return tebako_home unless tebako_home.nil? || tebako_home.empty?
|
|
75
|
+
|
|
76
|
+
if Gem.win_platform?
|
|
77
|
+
File.join(ENV.fetch("LOCALAPPDATA", File.join(Dir.home, "AppData", "Local")), "tebako")
|
|
78
|
+
else
|
|
79
|
+
File.join(Dir.home, ".tebako")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def resolve(ruby_version, platform, tebako_version: Tebako::VERSION)
|
|
84
|
+
new.resolve(ruby_version, platform, tebako_version: tebako_version)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def initialize(cache_root: nil, mirror: nil, lock_timeout: LOCK_TIMEOUT, retry_delay: RETRY_DELAY)
|
|
89
|
+
@cache_root = cache_root || self.class.default_cache_root
|
|
90
|
+
@mirror = (mirror || ENV.fetch("TEBAKO_RUNTIME_MIRROR", nil) || DEFAULT_MIRROR).sub(%r{/+\z}, "")
|
|
91
|
+
@lock_timeout = lock_timeout
|
|
92
|
+
@retry_delay = retry_delay
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
attr_reader :cache_root
|
|
96
|
+
|
|
97
|
+
def resolve(ruby_version, platform, tebako_version: Tebako::VERSION)
|
|
98
|
+
dir = entry_dir(ruby_version, platform, tebako_version)
|
|
99
|
+
executable = File.join(dir, runtime_filename(ruby_version, platform, tebako_version))
|
|
100
|
+
return executable if File.file?(executable)
|
|
101
|
+
|
|
102
|
+
with_entry_lock(dir, runtime_ref(ruby_version, platform, tebako_version)) do
|
|
103
|
+
install(executable, ruby_version, platform, tebako_version) unless File.file?(executable)
|
|
104
|
+
end
|
|
105
|
+
executable
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def entries
|
|
109
|
+
base = File.join(@cache_root, RUNTIMES_DIR)
|
|
110
|
+
return [] unless Dir.exist?(base)
|
|
111
|
+
|
|
112
|
+
Dir.children(base).sort.filter_map do |name|
|
|
113
|
+
path = File.join(base, name)
|
|
114
|
+
next unless File.directory?(path)
|
|
115
|
+
|
|
116
|
+
{ name: name, path: path, size_bytes: dir_size(path), installed_at: File.mtime(path) }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def prune(all: false, older_than_days: nil)
|
|
121
|
+
raise ArgumentError, "prune requires :all or :older_than_days" unless all || older_than_days
|
|
122
|
+
|
|
123
|
+
cutoff = older_than_days && (Time.now - (older_than_days * 86_400))
|
|
124
|
+
entries.each_with_object([]) do |entry, removed|
|
|
125
|
+
next unless all || entry[:installed_at] < cutoff
|
|
126
|
+
|
|
127
|
+
FileUtils.rm_rf(entry[:path], secure: true)
|
|
128
|
+
removed << entry[:name]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def install(executable, ruby_version, platform, tebako_version)
|
|
135
|
+
ref = runtime_ref(ruby_version, platform, tebako_version)
|
|
136
|
+
offline_check(ref, tebako_version)
|
|
137
|
+
entry = find_entry(fetch_index(tebako_version), ruby_version, platform, tebako_version)
|
|
138
|
+
url = package_url(entry["filename"], tebako_version)
|
|
139
|
+
tmp = download(url, entry["filename"])
|
|
140
|
+
verify!(tmp, entry)
|
|
141
|
+
place(tmp, executable, entry, url)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def offline_check(ref, tebako_version)
|
|
145
|
+
return unless offline?
|
|
146
|
+
|
|
147
|
+
Tebako.packaging_error(123, "#{ref} is not cached and downloads are disabled " \
|
|
148
|
+
"(release index: #{index_urls(tebako_version).join(", ")}; " \
|
|
149
|
+
"TEBAKO_RUNTIME_MIRROR=#{@mirror})")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def find_entry(index, ruby_version, platform, tebako_version)
|
|
153
|
+
entry = index.find { |candidate| candidate["ruby_version"] == ruby_version && candidate["platform"] == platform }
|
|
154
|
+
return entry if entry
|
|
155
|
+
|
|
156
|
+
combos = index.map { |candidate| "#{candidate["ruby_version"]}/#{candidate["platform"]}" }
|
|
157
|
+
Tebako.packaging_error(120, "no package for ruby #{ruby_version} on #{platform} " \
|
|
158
|
+
"(tebako #{tebako_version}). Available: #{combos.join(", ")}. " \
|
|
159
|
+
"Use --build-runtime to build the runtime from source instead.")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def verify!(tmp, entry)
|
|
163
|
+
actual = Digest::SHA256.file(tmp).hexdigest
|
|
164
|
+
expected = entry["sha256"].to_s.downcase
|
|
165
|
+
return if actual == expected
|
|
166
|
+
|
|
167
|
+
FileUtils.rm_f(tmp)
|
|
168
|
+
Tebako.packaging_error(121, "#{entry["filename"]}: expected #{expected}, got #{actual}; download deleted")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def place(tmp, executable, entry, url)
|
|
172
|
+
FileUtils.chmod(0o755, tmp)
|
|
173
|
+
File.rename(tmp, executable)
|
|
174
|
+
dir = File.dirname(executable)
|
|
175
|
+
File.write(File.join(dir, SHA256_FILE), "#{entry["sha256"]}\n")
|
|
176
|
+
File.write(File.join(dir, ORIGIN_FILE), "#{url}\n")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def fetch_index(tebako_version)
|
|
180
|
+
tried = []
|
|
181
|
+
INDEX_FILES.each do |name|
|
|
182
|
+
return parse_index(name, fetch_text(index_url(name, tebako_version)), tebako_version)
|
|
183
|
+
rescue IndexUnavailable
|
|
184
|
+
tried << index_url(name, tebako_version)
|
|
185
|
+
rescue DownloadFailed => e
|
|
186
|
+
Tebako.packaging_error(122, e.message)
|
|
187
|
+
end
|
|
188
|
+
Tebako.packaging_error(124, "tebako-runtime-ruby release v#{tebako_version} provides no usable package " \
|
|
189
|
+
"index (tried: #{tried.join(", ")})")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def parse_index(name, body, tebako_version)
|
|
193
|
+
name == "manifest.json" ? parse_manifest(body, tebako_version) : parse_sha256sums(body, tebako_version)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def parse_manifest(body, tebako_version)
|
|
197
|
+
data = JSON.parse(body)
|
|
198
|
+
raise IndexUnavailable, "manifest.json is not an array" unless data.is_a?(Array)
|
|
199
|
+
|
|
200
|
+
data.select { |entry| entry["tebako_version"] == tebako_version && entry["sha256"] && entry["filename"] }
|
|
201
|
+
rescue JSON::ParserError
|
|
202
|
+
raise IndexUnavailable, "manifest.json is not valid JSON"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def parse_sha256sums(body, tebako_version)
|
|
206
|
+
pattern = /\Atebako-runtime-#{Regexp.escape(tebako_version)}-(\d+\.\d+\.\d+)-(.+?)(?:\.exe)?\z/
|
|
207
|
+
body.each_line.filter_map do |line|
|
|
208
|
+
sha256, file = line.strip.split(/\s+/, 2)
|
|
209
|
+
match = file && pattern.match(file.sub(/\A\*/, ""))
|
|
210
|
+
next unless match
|
|
211
|
+
|
|
212
|
+
{ "tebako_version" => tebako_version, "ruby_version" => match[1], "platform" => match[2],
|
|
213
|
+
"filename" => file.sub(/\A\*/, ""), "sha256" => sha256.downcase, "size_bytes" => nil }
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def download(url, filename)
|
|
218
|
+
FileUtils.mkdir_p(File.join(@cache_root, TMP_DIR))
|
|
219
|
+
tmp = File.join(@cache_root, TMP_DIR, "#{filename}.#{Process.pid}.part")
|
|
220
|
+
File.binwrite(tmp, with_retries(url) { read_url(url) })
|
|
221
|
+
tmp
|
|
222
|
+
rescue IndexUnavailable
|
|
223
|
+
FileUtils.rm_f(tmp)
|
|
224
|
+
Tebako.packaging_error(122, "#{url}: not found")
|
|
225
|
+
rescue DownloadFailed => e
|
|
226
|
+
FileUtils.rm_f(tmp)
|
|
227
|
+
Tebako.packaging_error(122, e.message)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def fetch_text(url)
|
|
231
|
+
with_retries(url) { read_url(url) }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def with_retries(url) # rubocop:disable Metrics/MethodLength
|
|
235
|
+
attempts = 0
|
|
236
|
+
begin
|
|
237
|
+
attempts += 1
|
|
238
|
+
yield
|
|
239
|
+
rescue IndexUnavailable
|
|
240
|
+
raise
|
|
241
|
+
rescue StandardError => e
|
|
242
|
+
raise DownloadFailed, retry_message(url, e) if attempts >= DOWNLOAD_ATTEMPTS
|
|
243
|
+
|
|
244
|
+
sleep @retry_delay
|
|
245
|
+
retry
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def retry_message(url, error)
|
|
250
|
+
"failed to download #{url} after #{DOWNLOAD_ATTEMPTS} attempts: #{error.message}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def read_url(url, redirects_left = REDIRECT_LIMIT)
|
|
254
|
+
uri = URI.parse(url)
|
|
255
|
+
return read_file_url(uri) if uri.scheme == "file"
|
|
256
|
+
raise DownloadFailed, "too many redirects fetching #{url}" if redirects_left.zero?
|
|
257
|
+
|
|
258
|
+
read_http_url(url, uri, redirects_left)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def read_file_url(uri)
|
|
262
|
+
File.binread(uri.path)
|
|
263
|
+
rescue Errno::ENOENT
|
|
264
|
+
raise IndexUnavailable, uri.path
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def read_http_url(url, uri, redirects_left)
|
|
268
|
+
response = http_get(uri)
|
|
269
|
+
case response
|
|
270
|
+
when Net::HTTPSuccess then response.body
|
|
271
|
+
when Net::HTTPRedirection
|
|
272
|
+
read_url(URI.join(url, response["location"]).to_s, redirects_left - 1)
|
|
273
|
+
when Net::HTTPNotFound then raise IndexUnavailable, url
|
|
274
|
+
else raise DownloadFailed, "#{response.code} #{response.message} fetching #{url}"
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def http_get(uri)
|
|
279
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
280
|
+
http.use_ssl = uri.scheme == "https"
|
|
281
|
+
http.open_timeout = 15
|
|
282
|
+
http.read_timeout = 300
|
|
283
|
+
http.start { |session| session.get(uri.request_uri.empty? ? "/" : uri.request_uri) }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def with_entry_lock(dir, ref)
|
|
287
|
+
FileUtils.mkdir_p(dir)
|
|
288
|
+
File.open(File.join(dir, LOCK_FILE), File::RDWR | File::CREAT, 0o644) do |lock|
|
|
289
|
+
acquire_lock(lock, ref)
|
|
290
|
+
begin
|
|
291
|
+
yield
|
|
292
|
+
ensure
|
|
293
|
+
lock.flock(File::LOCK_UN)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def acquire_lock(lock, ref)
|
|
299
|
+
deadline = Time.now + @lock_timeout
|
|
300
|
+
until lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
301
|
+
if Time.now >= deadline
|
|
302
|
+
Tebako.packaging_error(125, "#{ref}: another process is installing this runtime " \
|
|
303
|
+
"(no lock after #{@lock_timeout}s; lockfile: #{lock.path})")
|
|
304
|
+
end
|
|
305
|
+
sleep 0.1
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def offline?
|
|
310
|
+
%w[1 true yes].include?(ENV.fetch("TEBAKO_OFFLINE", "").downcase)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def entry_dir(ruby_version, platform, tebako_version)
|
|
314
|
+
File.join(@cache_root, RUNTIMES_DIR,
|
|
315
|
+
"#{RUNTIME_TYPE}-#{ruby_version}-#{tebako_version}-#{platform}")
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def runtime_filename(ruby_version, platform, tebako_version)
|
|
319
|
+
suffix = platform.start_with?("windows") ? ".exe" : ""
|
|
320
|
+
"tebako-runtime-#{tebako_version}-#{ruby_version}-#{platform}#{suffix}"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def runtime_ref(ruby_version, platform, tebako_version)
|
|
324
|
+
"ruby@#{ruby_version} (tebako #{tebako_version}, #{platform})"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def release_url(tebako_version)
|
|
328
|
+
"#{@mirror}/v#{tebako_version.to_s.sub(/\Av/, "")}"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def index_url(name, tebako_version)
|
|
332
|
+
"#{release_url(tebako_version)}/#{name}"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def index_urls(tebako_version)
|
|
336
|
+
INDEX_FILES.map { |name| index_url(name, tebako_version) }
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def package_url(filename, tebako_version)
|
|
340
|
+
"#{release_url(tebako_version)}/#{filename}"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def dir_size(path)
|
|
344
|
+
Dir.glob(File.join(path, "**", "*")).select { |file| File.file?(file) }.sum { |file| File.size(file) }
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2026 [Ribose Inc](https://www.ribose.com).
|
|
4
|
+
# All rights reserved.
|
|
5
|
+
# This file is a part of the Tebako project.
|
|
6
|
+
#
|
|
7
|
+
# Redistribution and use in source and binary forms, with or without
|
|
8
|
+
# modification, are permitted provided that the following conditions
|
|
9
|
+
# are met:
|
|
10
|
+
# 1. Redistributions of source code must retain the above copyright
|
|
11
|
+
# notice, this list of conditions and the following disclaimer.
|
|
12
|
+
# 2. Redistributions in binary form must reproduce the above copyright
|
|
13
|
+
# notice, this list of conditions and the following disclaimer in the
|
|
14
|
+
# documentation and/or other materials provided with the distribution.
|
|
15
|
+
#
|
|
16
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
17
|
+
# ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
|
18
|
+
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
19
|
+
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS
|
|
20
|
+
# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
21
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
22
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
23
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
24
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
25
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
26
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
|
27
|
+
|
|
28
|
+
require "fileutils"
|
|
29
|
+
require "open3"
|
|
30
|
+
require "rbconfig"
|
|
31
|
+
require "zlib"
|
|
32
|
+
|
|
33
|
+
require_relative "error"
|
|
34
|
+
require_relative "version"
|
|
35
|
+
|
|
36
|
+
# Tebako - an executable packager
|
|
37
|
+
module Tebako
|
|
38
|
+
# Stitches tebako images onto a prebuilt runtime binary, producing a
|
|
39
|
+
# single-file package with a tpkg manifest trailer (spec §4.3).
|
|
40
|
+
#
|
|
41
|
+
# Wire layout (all integers little-endian), appended after the runtime:
|
|
42
|
+
# [runtime bytes][pad to 8][image 0][pad to 8]...[image n-1]
|
|
43
|
+
# [slot table: slot_count x 280 bytes][trailer header: 166 bytes at EOF]
|
|
44
|
+
#
|
|
45
|
+
# This is a pure-Ruby reimplementation of the tpkg.h writer (libtfs
|
|
46
|
+
# include/tebako/tpkg.h); the byte stream is identical to tpkg_write_fd()
|
|
47
|
+
# for the same manifest. CRC32 is the zlib polynomial (0xEDB88320,
|
|
48
|
+
# init/xorout 0xFFFFFFFF) — exactly what Zlib.crc32 computes.
|
|
49
|
+
#
|
|
50
|
+
# Codesigning: appending bytes invalidates any embedded code signature.
|
|
51
|
+
# On macOS a signed runtime (ad-hoc included, detected via `codesign -dv`)
|
|
52
|
+
# is re-signed ad-hoc after stitching (codesign --remove-signature, then
|
|
53
|
+
# codesign --sign - --force). Note that codesign(1) refuses to re-sign thin
|
|
54
|
+
# Mach-O binaries carrying trailing payload ("main executable failed strict
|
|
55
|
+
# validation"), so the re-sign is best-effort: on failure a warning is
|
|
56
|
+
# printed and the package is kept — the ad-hoc linker signature is
|
|
57
|
+
# invalidated by construction, but the binary still executes on macOS
|
|
58
|
+
# (verified on macOS 14/arm64). Re-signing with a real identity remains a
|
|
59
|
+
# user post-press step. An unsigned runtime is left alone.
|
|
60
|
+
# On Windows signing is a no-op — re-applying an Authenticode signature
|
|
61
|
+
# (signtool) is left to the user as a post-press step.
|
|
62
|
+
class Stitcher # rubocop:disable Metrics/ClassLength
|
|
63
|
+
MAGIC = "TEBAKOTFS\0".b # 10 bytes, NUL-terminated
|
|
64
|
+
VERSION = 1
|
|
65
|
+
MAX_SLOTS = 8
|
|
66
|
+
HEADER_SIZE = 166
|
|
67
|
+
SLOT_SIZE = 280
|
|
68
|
+
MOUNT_POINT_LEN = 256
|
|
69
|
+
RUNTIME_REF_LEN = 128
|
|
70
|
+
ALIGNMENT = 8
|
|
71
|
+
|
|
72
|
+
FLAG_LEAN = 0x1
|
|
73
|
+
|
|
74
|
+
FORMAT_AUTO = 0
|
|
75
|
+
FORMAT_DWARFS = 1
|
|
76
|
+
FORMAT_SQUASHFS = 2
|
|
77
|
+
FORMAT_ZIP = 3
|
|
78
|
+
FORMAT_IDS = (FORMAT_AUTO..FORMAT_ZIP).freeze
|
|
79
|
+
|
|
80
|
+
# Header field offsets
|
|
81
|
+
OFF_PACKAGE_FLAGS = 14
|
|
82
|
+
OFF_SLOT_COUNT = 18
|
|
83
|
+
OFF_TABLE = 22
|
|
84
|
+
OFF_RUNTIME_REF = 30
|
|
85
|
+
OFF_CRC32 = 162
|
|
86
|
+
|
|
87
|
+
class << self
|
|
88
|
+
# Stitch +images+ (Array of {path:, mount_point:, format_id:}) onto a
|
|
89
|
+
# copy of the runtime at +runtime_path+, writing +output+.
|
|
90
|
+
# lean: true marks the package LEAN and records runtime_ref
|
|
91
|
+
# ("ruby@<ruby_version>;tebako=<tebako_version>"); classic packages
|
|
92
|
+
# carry an empty runtime_ref.
|
|
93
|
+
def stitch(runtime_path, images:, output:, lean: false, ruby_version: nil, # rubocop:disable Metrics/ParameterLists
|
|
94
|
+
tebako_version: Tebako::VERSION, launcher_abi: 0)
|
|
95
|
+
images = normalize_images(images)
|
|
96
|
+
validate_inputs!(runtime_path, images, lean, ruby_version)
|
|
97
|
+
|
|
98
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
99
|
+
FileUtils.cp(runtime_path, output)
|
|
100
|
+
FileUtils.chmod(0o755, output)
|
|
101
|
+
|
|
102
|
+
append(images, output, package_flags(lean), runtime_ref(lean, ruby_version, tebako_version), launcher_abi)
|
|
103
|
+
resign_if_needed(output)
|
|
104
|
+
output
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def macos?
|
|
108
|
+
RbConfig::CONFIG["host_os"] =~ /darwin/i ? true : false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# True when +path+ carries any code signature (ad-hoc included)
|
|
112
|
+
def signed?(path)
|
|
113
|
+
_out, _err, status = Open3.capture3("codesign", "-dv", path)
|
|
114
|
+
status.success?
|
|
115
|
+
rescue Errno::ENOENT
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Re-apply an ad-hoc signature after the binary was mutated; true on
|
|
120
|
+
# success. codesign(1) refuses to re-sign thin Mach-O binaries with
|
|
121
|
+
# trailing payload (strict validation) — callers treat failure as
|
|
122
|
+
# non-fatal (see the class comment).
|
|
123
|
+
def adhoc_resign(path)
|
|
124
|
+
system("codesign", "--remove-signature", path, out: File::NULL, err: File::NULL) &&
|
|
125
|
+
system("codesign", "--sign", "-", "--force", path, out: File::NULL, err: File::NULL)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def normalize_images(images)
|
|
131
|
+
images.map do |image|
|
|
132
|
+
{ path: image[:path], mount_point: image[:mount_point].to_s,
|
|
133
|
+
format_id: image.fetch(:format_id, FORMAT_DWARFS) }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_inputs!(runtime_path, images, lean, ruby_version)
|
|
138
|
+
Tebako.packaging_error(126, "at least one image is required") if images.empty?
|
|
139
|
+
if images.size > MAX_SLOTS
|
|
140
|
+
Tebako.packaging_error(126, "#{images.size} images given, at most #{MAX_SLOTS} are supported")
|
|
141
|
+
end
|
|
142
|
+
if lean && ruby_version.nil?
|
|
143
|
+
Tebako.packaging_error(126, "lean packages require ruby_version for the runtime_ref")
|
|
144
|
+
end
|
|
145
|
+
validate_files!(runtime_path, images)
|
|
146
|
+
validate_images!(images)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate_files!(runtime_path, images)
|
|
150
|
+
Tebako.packaging_error(127, "runtime not found: #{runtime_path}") unless File.file?(runtime_path)
|
|
151
|
+
images.each do |image|
|
|
152
|
+
Tebako.packaging_error(127, "image not found: #{image[:path]}") unless File.file?(image[:path])
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_images!(images)
|
|
157
|
+
seen = {}
|
|
158
|
+
images.each do |image|
|
|
159
|
+
validate_image!(image)
|
|
160
|
+
mount = image[:mount_point]
|
|
161
|
+
Tebako.packaging_error(126, "duplicate mount point '#{mount}'") if seen[mount]
|
|
162
|
+
|
|
163
|
+
seen[mount] = true
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def validate_image!(image)
|
|
168
|
+
unless FORMAT_IDS.cover?(image[:format_id])
|
|
169
|
+
Tebako.packaging_error(126, "invalid format_id #{image[:format_id]} (0..3 expected)")
|
|
170
|
+
end
|
|
171
|
+
return unless image[:mount_point].bytesize >= MOUNT_POINT_LEN
|
|
172
|
+
|
|
173
|
+
Tebako.packaging_error(126,
|
|
174
|
+
"mount point '#{image[:mount_point][0, 32]}...' exceeds #{MOUNT_POINT_LEN - 1} bytes")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def package_flags(lean)
|
|
178
|
+
lean ? FLAG_LEAN : 0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def runtime_ref(lean, ruby_version, tebako_version)
|
|
182
|
+
return "" unless lean
|
|
183
|
+
|
|
184
|
+
ref = "ruby@#{ruby_version};tebako=#{tebako_version}"
|
|
185
|
+
if ref.bytesize >= RUNTIME_REF_LEN
|
|
186
|
+
Tebako.packaging_error(126, "runtime_ref '#{ref}' exceeds #{RUNTIME_REF_LEN - 1} bytes")
|
|
187
|
+
end
|
|
188
|
+
ref
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Appends images at 8-byte-aligned offsets; returns slot descriptors
|
|
192
|
+
def append(images, output, package_flags, runtime_ref, launcher_abi)
|
|
193
|
+
File.open(output, "ab") do |io|
|
|
194
|
+
io.seek(0, IO::SEEK_END) # append mode starts at pos 0 until the first write
|
|
195
|
+
slots = append_images(io, images)
|
|
196
|
+
write_trailer(io, slots, package_flags, runtime_ref, launcher_abi)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def append_images(io, images)
|
|
201
|
+
images.map do |image|
|
|
202
|
+
pad(io)
|
|
203
|
+
offset = io.pos
|
|
204
|
+
File.open(image[:path], "rb") { |src| IO.copy_stream(src, io) }
|
|
205
|
+
{ offset: offset, size: io.pos - offset, format_id: image[:format_id], flags: 0,
|
|
206
|
+
mount_point: image[:mount_point] }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def pad(io)
|
|
211
|
+
remainder = io.pos % ALIGNMENT
|
|
212
|
+
io.write("\0" * (ALIGNMENT - remainder)) unless remainder.zero?
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def write_trailer(io, slots, package_flags, runtime_ref, launcher_abi)
|
|
216
|
+
slot_table_offset = io.pos
|
|
217
|
+
slots.each { |slot| io.write(pack_slot(slot)) }
|
|
218
|
+
io.write(pack_header(slots.size, slot_table_offset, package_flags, runtime_ref, launcher_abi))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def pack_slot(slot)
|
|
222
|
+
[slot[:offset], slot[:size]].pack("Q2") +
|
|
223
|
+
[slot[:format_id], slot[:flags]].pack("V2") +
|
|
224
|
+
fixed_string(slot[:mount_point], MOUNT_POINT_LEN)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def pack_header(slot_count, slot_table_offset, package_flags, runtime_ref, launcher_abi)
|
|
228
|
+
header = MAGIC.dup
|
|
229
|
+
header << [VERSION, package_flags, slot_count].pack("V3")
|
|
230
|
+
header << [slot_table_offset].pack("Q")
|
|
231
|
+
header << fixed_string(runtime_ref, RUNTIME_REF_LEN)
|
|
232
|
+
header << [launcher_abi].pack("V")
|
|
233
|
+
header << [Zlib.crc32(header)].pack("V") # header bytes [0, 162)
|
|
234
|
+
header
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# NUL-padded fixed-width field; callers validate the length beforehand
|
|
238
|
+
def fixed_string(string, width)
|
|
239
|
+
string.b.ljust(width, "\0")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def resign_if_needed(output)
|
|
243
|
+
# Windows: no-op. Re-signing an Authenticode-signed runtime is a
|
|
244
|
+
# documented user post-press step (signtool sign ...).
|
|
245
|
+
return unless macos?
|
|
246
|
+
return unless signed?(output)
|
|
247
|
+
return if adhoc_resign(output)
|
|
248
|
+
|
|
249
|
+
warn "Warning: ad-hoc re-sign failed for #{output}; the package still executes on macOS, " \
|
|
250
|
+
"but its code signature is invalidated by the appended images. " \
|
|
251
|
+
"Re-sign it with your own identity if you need a valid signature."
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
data/lib/tebako/version.rb
CHANGED