ocran 1.4.4 → 1.4.5

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,383 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # NOTE: no file-scope require of digest/fileutils/tmpdir. This file is
4
+ # loaded while the command line is parsed, i.e. before OCRAN snapshots
5
+ # $LOADED_FEATURES for dependency detection (see
6
+ # Ocran::Option#load_cosmo_toolchain); anything required here would end up
7
+ # in that diff and be packed into the user's application. The three
8
+ # libraries are only needed at build time - after the snapshot - so they
9
+ # are required inside the methods that use them.
10
+
11
+ module Ocran
12
+ # Builds the launcher stub from the C sources in src/ with a
13
+ # Cosmopolitan Libc toolchain (cosmocc, https://cosmo.zip) at packaging
14
+ # time, producing an Actually Portable Executable (APE) stub that is
15
+ # used instead of the pre-built native stub shipped with the gem.
16
+ #
17
+ # The toolchain is named explicitly by --cosmo (alias: --cosmo-toolchain)
18
+ # or, when only --cosmo-ruby is given, discovered on the build host (see
19
+ # find_cc). See docs/cosmocc-port-plan.md for background and caveats.
20
+ module CosmoToolchain
21
+ # Stub C sources shipped with the gem (also included in the binary
22
+ # platform gems specifically so this feature works from an installed gem).
23
+ SRC_DIR = File.expand_path("../../src", __dir__)
24
+
25
+ # Every APE binary starts with this MZ/shell-script polyglot magic.
26
+ APE_MAGIC = "MZqFpD"
27
+
28
+ # Environment overrides that keep a child Ruby out of whatever bundle
29
+ # OCRAN itself was started under. RUBYOPT is how `bundle exec` set a
30
+ # process up historically; BUNDLER_SETUP is how it does it now, and
31
+ # RubyGems requires that file before any of the child's own code runs.
32
+ # BUNDLE_GEMFILE and BUNDLE_LOCKFILE would still misdirect a child that
33
+ # sets Bundler up by itself.
34
+ BUNDLER_FREE_ENV = {
35
+ "RUBYOPT" => nil,
36
+ "BUNDLER_SETUP" => nil,
37
+ "BUNDLE_GEMFILE" => nil,
38
+ "BUNDLE_LOCKFILE" => nil
39
+ }.freeze
40
+
41
+ # Name of the environment variable a CosmoRuby build honors to switch
42
+ # OFF running an embedded /zip/main.rb, i.e. to behave as an ordinary
43
+ # interpreter (useful for inspecting a packaged application). Its
44
+ # presence in the binary is what marks the build as one that runs an
45
+ # embedded main script at all, which is the capability the
46
+ # compiler-free ZIP packaging mode is built on - a build without it
47
+ # would ignore the injected main.rb and try to run the first argument
48
+ # as a script instead.
49
+ ZIP_MAIN_MARKER = "COSMORUBY_NO_ZIP_MAIN"
50
+
51
+ # How much of the binary is read at a time when scanning for the
52
+ # marker. The marker sits in the interpreter's code, not in its ZIP
53
+ # store, so the whole file may have to be read; it is a ~20 MB
54
+ # sequential scan, a few tens of milliseconds.
55
+ SCAN_CHUNK_SIZE = 1 << 20
56
+
57
+ # Environment variable naming a cosmocc toolchain (the cosmocc
58
+ # executable or its installation directory), checked before PATH.
59
+ COSMOCC_ENV = "COSMOCC"
60
+
61
+ # Where a cosmocc toolchain is conventionally unpacked, searched when
62
+ # neither COSMOCC nor PATH names one. Cosmopolitan's own quick
63
+ # start unzips cosmocc.zip into a directory named "cosmocc" and adds
64
+ # its bin/ to PATH; "~" is the user's home directory and "*" matches
65
+ # version directories of vendored toolchains (the layout the
66
+ # cosmo-adjacent projects use, e.g. .cosmocc/3.9.2/bin/cosmocc). The
67
+ # cosmopolitan monorepo checkout at /opt/cosmo is covered too.
68
+ CONVENTIONAL_CC_PATHS = [
69
+ "~/.cosmocc/*/bin/cosmocc",
70
+ "~/.cosmocc/bin/cosmocc",
71
+ "~/cosmocc/*/bin/cosmocc",
72
+ "~/cosmocc/bin/cosmocc",
73
+ "/opt/cosmocc/*/bin/cosmocc",
74
+ "/opt/cosmocc/bin/cosmocc",
75
+ "/opt/cosmo/bin/cosmocc",
76
+ "/usr/local/cosmocc/bin/cosmocc",
77
+ ].freeze
78
+
79
+ module_function
80
+
81
+ # Resolves the path given to --cosmo-ruby to a cosmopolitan Ruby
82
+ # executable (conventionally ruby.com). Validates that the file
83
+ # exists and is an APE binary; returns the absolute path.
84
+ def resolve_ruby(path)
85
+ if path.nil? || path.to_s.empty?
86
+ raise "--cosmo-ruby requires a path to a cosmopolitan Ruby executable (e.g. ruby.com)"
87
+ end
88
+
89
+ path = File.expand_path(path.to_s)
90
+ unless File.file?(path)
91
+ raise "cosmopolitan Ruby not found at #{path}"
92
+ end
93
+
94
+ magic = File.binread(path, APE_MAGIC.bytesize)
95
+ unless magic == APE_MAGIC
96
+ raise "#{path} does not look like an APE (Actually Portable Executable) — expected the #{APE_MAGIC.inspect} magic (got #{magic.inspect}); --cosmo-ruby needs a cosmopolitan-built ruby.com"
97
+ end
98
+
99
+ path
100
+ end
101
+
102
+ # True when the given cosmopolitan Ruby runs an embedded /zip/main.rb
103
+ # on startup. Such a build can be packaged without any compiler: the
104
+ # application is injected into the binary's own ZIP store and the
105
+ # binary runs it (see ZipPayloadBuilder). Builds without the hook need
106
+ # the APE launcher stub, and therefore cosmocc.
107
+ #
108
+ # Detected by scanning for the name of the opt-out environment
109
+ # variable, which only a build implementing the hook contains. The
110
+ # alternative - copying the 20 MB binary, injecting a probe script and
111
+ # running it - is an order of magnitude more expensive for the same
112
+ # answer, and the scan cannot produce a false positive on a build that
113
+ # never looks at the variable.
114
+ def zip_main_support?(ruby)
115
+ marker = ZIP_MAIN_MARKER.b
116
+ overlap = marker.bytesize - 1
117
+ previous = "".b
118
+
119
+ File.open(ruby, "rb") do |io|
120
+ while (chunk = io.read(SCAN_CHUNK_SIZE))
121
+ return true if (previous + chunk).include?(marker)
122
+
123
+ previous = chunk.byteslice(-overlap, overlap) || chunk
124
+ end
125
+ end
126
+ false
127
+ end
128
+
129
+ # Runs the given cosmopolitan Ruby once on the build host and returns
130
+ # { version:, default_gem_dir:, gem_names: }. The version is used to
131
+ # warn about build-host/payload skew; the default gem dir (inside the
132
+ # APE's /zip store) must be appended to GEM_PATH in the package,
133
+ # because setting GEM_PATH stops RubyGems from scanning its
134
+ # compiled-in default directory, where the APE's bundled gems live;
135
+ # the gem names are the default/bundled gems the payload provides
136
+ # itself (used to decide whether a host native-extension gem can be
137
+ # dropped in favor of the payload's own copy).
138
+ #
139
+ # The binary is executed through /bin/sh: an APE bootstraps itself
140
+ # via its shell-script header on kernels without APE binfmt support,
141
+ # while on kernels that do support it, sh's ENOEXEC fallback is
142
+ # simply never needed. This also validates that the payload actually
143
+ # runs on the build host. GEM_HOME/GEM_PATH are cleared so the query
144
+ # sees only the payload's embedded gems, not the build host's, and
145
+ # Bundler is kept out of it entirely: run OCRAN under `bundle exec` and
146
+ # the payload would otherwise be asked to set up the build host's bundle
147
+ # and die materializing gems it has never heard of. RUBYOPT carries that
148
+ # instruction in older Bundler versions, BUNDLER_SETUP - which RubyGems
149
+ # requires at interpreter startup - in current ones.
150
+ def query_ruby(ruby)
151
+ script = 'print RUBY_VERSION; print "\t"; print Gem.default_dir; ' \
152
+ 'print "\t"; print Gem::Specification.map(&:name).uniq.sort.join(",")'
153
+ out = IO.popen([BUNDLER_FREE_ENV.merge("GEM_HOME" => nil, "GEM_PATH" => nil, "RUBYLIB" => nil),
154
+ "/bin/sh", ruby, "-e", script],
155
+ err: IO::NULL, &:read)
156
+ ok = $?.success?
157
+ version, default_gem_dir, gem_names = out.to_s.split("\t", 3)
158
+ unless ok && version =~ /\A\d+\.\d+/ && default_gem_dir && !default_gem_dir.empty?
159
+ raise "Failed to run the cosmopolitan Ruby #{ruby} on this host (exit status #{$?.exitstatus.inspect}, output #{out.inspect}); cannot package it with --cosmo-ruby"
160
+ end
161
+ { version: version,
162
+ default_gem_dir: default_gem_dir,
163
+ gem_names: gem_names.to_s.split(",") }
164
+ end
165
+
166
+ # Which of the given feature names (the strings passed to
167
+ # Kernel#require) the payload can resolve out of its own embedded
168
+ # stdlib and gems. Returns the subset it can, in the given order.
169
+ #
170
+ # This is what makes the gemspec name in query_ruby's gem_names a
171
+ # sufficient but not a necessary condition for "the payload provides
172
+ # this gem": an extension that is statically linked into the APE, or
173
+ # a library that lives in its embedded stdlib rather than in
174
+ # /zip/lib/ruby/gems, answers require without owning a gemspec.
175
+ #
176
+ # Resolution goes through $LOAD_PATH.resolve_feature_path, which
177
+ # consults exactly the same search that require does - including
178
+ # built-in extensions, which resolve to a bare "foo.so" with no
179
+ # directory - but does not run any of the code it finds, so probing
180
+ # cannot have side effects. Ruby answers a missing feature with nil
181
+ # or with LoadError depending on the version; both mean "not
182
+ # provided".
183
+ def resolvable_features(ruby, features)
184
+ features = Array(features).map(&:to_s).reject(&:empty?).uniq
185
+ return [] if features.empty?
186
+
187
+ script = <<~'RUBY'
188
+ ARGV.each do |feature|
189
+ begin
190
+ puts feature if $LOAD_PATH.resolve_feature_path(feature)
191
+ rescue LoadError
192
+ # not provided
193
+ end
194
+ end
195
+ RUBY
196
+ out = IO.popen([{ "GEM_HOME" => nil, "GEM_PATH" => nil, "RUBYOPT" => nil, "RUBYLIB" => nil },
197
+ "/bin/sh", ruby, "-e", script, *features],
198
+ err: IO::NULL, &:read)
199
+ return [] unless $?.success?
200
+
201
+ found = out.to_s.split("\n")
202
+ features & found
203
+ end
204
+
205
+ # Resolves the path given on the command line to the cosmocc compiler
206
+ # driver. Accepts either the cosmocc executable itself, the toolchain
207
+ # installation directory (containing bin/cosmocc), or its bin directory.
208
+ # Returns the absolute path to cosmocc; raises with a clear message
209
+ # when nothing usable is found.
210
+ def resolve_cc(path)
211
+ if path.nil? || path.to_s.empty?
212
+ raise "--cosmo requires a path to a cosmocc toolchain (the cosmocc executable or its installation directory)"
213
+ end
214
+
215
+ path = File.expand_path(path.to_s)
216
+
217
+ candidates =
218
+ if File.directory?(path)
219
+ [File.join(path, "bin", "cosmocc"), File.join(path, "cosmocc")]
220
+ else
221
+ [path]
222
+ end
223
+
224
+ cc = candidates.find { |c| File.file?(c) }
225
+ unless cc
226
+ raise "cosmocc not found at #{path} (expected the cosmocc executable itself, or a toolchain directory containing bin/cosmocc)"
227
+ end
228
+ unless File.executable?(cc)
229
+ raise "cosmocc found at #{cc} but it is not executable"
230
+ end
231
+ cc
232
+ end
233
+
234
+ # The cosmocc toolchain to compile the APE launcher stub with. An
235
+ # explicitly given --cosmo path always wins; otherwise the build host
236
+ # is searched (see find_cc), which is what makes --cosmo-ruby alone
237
+ # sufficient to package a portable application. Raises an actionable
238
+ # error when no toolchain can be found.
239
+ def require_cc(explicit = nil, env = ENV)
240
+ return resolve_cc(explicit) unless explicit.nil? || explicit.to_s.empty?
241
+
242
+ find_cc(env) ||
243
+ raise("no cosmocc toolchain found, but one is needed to build the APE launcher stub: " \
244
+ "#{COSMOCC_ENV} is not set, cosmocc is not in PATH, and none of the conventional " \
245
+ "install locations (#{CONVENTIONAL_CC_PATHS.join(", ")}) has one. " \
246
+ "Install the toolchain from https://cosmo.zip/pub/cosmocc/cosmocc.zip (unzip it, " \
247
+ "then either add its bin directory to PATH or set #{COSMOCC_ENV} to it), " \
248
+ "or name it explicitly with --cosmo <path-to-cosmocc>")
249
+ end
250
+
251
+ # Searches the build host for a cosmocc toolchain, in order: the
252
+ # COSMOCC environment variable (the cosmocc executable or its
253
+ # installation directory), cosmocc in PATH, and finally the
254
+ # conventional install locations (CONVENTIONAL_CC_PATHS). Returns the
255
+ # absolute path to cosmocc, or nil when nothing is found.
256
+ #
257
+ # COSMOCC is authoritative: if it is set but does not point at a
258
+ # usable toolchain, that error is raised rather than silently using a
259
+ # different toolchain than the user configured.
260
+ def find_cc(env = ENV)
261
+ specified = env[COSMOCC_ENV]
262
+ unless specified.nil? || specified.empty?
263
+ begin
264
+ return resolve_cc(specified)
265
+ rescue RuntimeError => e
266
+ raise "#{COSMOCC_ENV}=#{specified} does not name a usable cosmocc toolchain: #{e.message}"
267
+ end
268
+ end
269
+
270
+ search_path(env["PATH"]) || conventional_cc(env)
271
+ end
272
+
273
+ # The first executable cosmocc in the given PATH string, or nil.
274
+ def search_path(path)
275
+ return nil if path.nil? || path.empty?
276
+
277
+ path.split(File::PATH_SEPARATOR).each do |dir|
278
+ next if dir.empty?
279
+
280
+ cc = File.expand_path(File.join(dir, "cosmocc"))
281
+ return cc if File.file?(cc) && File.executable?(cc)
282
+ end
283
+ nil
284
+ end
285
+
286
+ # cosmocc in one of the conventional install locations, or nil. Within
287
+ # a location holding several versioned toolchains (e.g.
288
+ # ~/.cosmocc/3.9.2, ~/.cosmocc/4.0.2) the newest version wins.
289
+ def conventional_cc(env = ENV)
290
+ home = env["HOME"]
291
+
292
+ CONVENTIONAL_CC_PATHS.each do |pattern|
293
+ if pattern.start_with?("~/")
294
+ next if home.nil? || home.empty?
295
+
296
+ pattern = File.join(home, pattern.delete_prefix("~/"))
297
+ end
298
+
299
+ candidates = Dir.glob(pattern).select { |cc| File.file?(cc) && File.executable?(cc) }
300
+ next if candidates.empty?
301
+
302
+ return File.expand_path(candidates.max_by { |cc| version_key(cc) })
303
+ end
304
+ nil
305
+ end
306
+
307
+ # Sort key that orders <root>/<version>/bin/cosmocc paths newest
308
+ # first; unversioned or unparsable directory names sort oldest.
309
+ def version_key(cc)
310
+ name = File.basename(File.dirname(File.dirname(cc)))
311
+ Gem::Version.correct?(name) ? [1, Gem::Version.new(name)] : [0, Gem::Version.new("0")]
312
+ end
313
+
314
+ # Compiles the stub sources with the given cosmocc and returns the
315
+ # path to the resulting APE stub binary. Results are cached in the
316
+ # user cache directory, keyed on the toolchain and the stub sources,
317
+ # so repeated packaging runs do not recompile. On compile failure the
318
+ # compiler output is included in the raised error.
319
+ def build_stub(cc)
320
+ require "fileutils"
321
+ require "tmpdir"
322
+
323
+ if Gem.win_platform?
324
+ raise "--cosmo is not supported when building on Windows (build the APE stub on a Linux/macOS host)"
325
+ end
326
+ unless system("command -v make > /dev/null 2>&1")
327
+ raise "make not found in PATH (required to build the stub with cosmocc)"
328
+ end
329
+ unless File.directory?(SRC_DIR)
330
+ raise "stub sources not found at #{SRC_DIR} (cannot build with cosmocc)"
331
+ end
332
+
333
+ cached = File.join(cache_dir, "stub-#{cache_key(cc)}")
334
+ return cached if File.file?(cached)
335
+
336
+ Dir.mktmpdir("ocran-cosmo") do |tmp|
337
+ build_dir = File.join(tmp, "src")
338
+ FileUtils.cp_r(SRC_DIR, build_dir)
339
+ log = File.join(tmp, "make.log")
340
+ # A development checkout may contain native build artifacts
341
+ # (.o files, stub) that cp_r copied along — clean them so the
342
+ # stub is fully rebuilt with cosmocc.
343
+ system("make", "-C", build_dir, "clean", { [:out, :err] => IO::NULL })
344
+ ok = system("make", "-C", build_dir, "stub", "CC=#{cc}",
345
+ { [:out, :err] => log })
346
+ unless ok
347
+ output = File.exist?(log) ? File.read(log) : "(no build output captured)"
348
+ raise "Failed to build the stub with cosmocc (make -C src stub CC=#{cc}):\n#{output}"
349
+ end
350
+ FileUtils.mkdir_p(File.dirname(cached))
351
+ FileUtils.cp(File.join(build_dir, "stub"), cached)
352
+ File.chmod(0755, cached)
353
+ end
354
+ cached
355
+ end
356
+
357
+ # Cache key covering the toolchain (path, mtime, size — so an updated
358
+ # toolchain at the same path recompiles) and every stub source file.
359
+ def cache_key(cc)
360
+ require "digest"
361
+
362
+ digest = Digest::SHA256.new
363
+ stat = File.stat(cc)
364
+ digest << cc << stat.mtime.to_i.to_s << stat.size.to_s
365
+ Dir.glob("**/*", base: SRC_DIR).sort.each do |rel|
366
+ abs = File.join(SRC_DIR, rel)
367
+ next unless File.file?(abs)
368
+ digest << rel << File.binread(abs)
369
+ end
370
+ digest.hexdigest[0, 16]
371
+ end
372
+
373
+ def cache_dir
374
+ require "tmpdir"
375
+
376
+ base = ENV["XDG_CACHE_HOME"]
377
+ base = File.join(Dir.home, ".cache") if base.nil? || base.empty?
378
+ File.join(base, "ocran")
379
+ rescue ArgumentError # Dir.home unavailable (no HOME)
380
+ File.join(Dir.tmpdir, "ocran-cache")
381
+ end
382
+ end
383
+ end