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.
- checksums.yaml +4 -4
- data/CHANGELOG.txt +18 -0
- data/README.md +435 -16
- data/lib/ocran/cosmo_toolchain.rb +383 -0
- data/lib/ocran/direction.rb +592 -58
- data/lib/ocran/option.rb +165 -3
- data/lib/ocran/rubyopt_processor.rb +95 -0
- data/lib/ocran/runner.rb +80 -0
- data/lib/ocran/runtime_environment.rb +19 -1
- data/lib/ocran/stub_builder.rb +23 -6
- data/lib/ocran/version.rb +1 -1
- data/lib/ocran/zip_payload_builder.rb +282 -0
- data/lib/ocran/zip_writer.rb +287 -0
- data/src/Makefile +9 -0
- data/src/script_info.c +15 -2
- data/src/script_info.h +15 -1
- data/src/stub.c +51 -1
- data/src/system_utils_posix.c +40 -0
- data/src/unpack.c +4 -0
- data/src/unpack.h +10 -0
- metadata +5 -1
|
@@ -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
|