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.
@@ -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
@@ -26,5 +26,5 @@
26
26
  # POSSIBILITY OF SUCH DAMAGE.
27
27
 
28
28
  module Tebako
29
- VERSION = "0.14.0"
29
+ VERSION = "0.15.0"
30
30
  end