ocran 1.4.6 → 1.5.1

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.txt +82 -0
  3. data/README.md +519 -15
  4. data/ext/extconf.rb +5 -1
  5. data/lib/ocran/aot_toolchain.rb +154 -0
  6. data/lib/ocran/build_helper.rb +7 -0
  7. data/lib/ocran/command_output.rb +8 -1
  8. data/lib/ocran/compatibility_scan.rb +414 -0
  9. data/lib/ocran/cosmo_toolchain.rb +29 -7
  10. data/lib/ocran/dir_builder.rb +88 -38
  11. data/lib/ocran/direction.rb +292 -44
  12. data/lib/ocran/ed_resource.rb +113 -0
  13. data/lib/ocran/ed_resource_builder.rb +199 -0
  14. data/lib/ocran/env_hook.rb +98 -0
  15. data/lib/ocran/file_path_set.rb +9 -5
  16. data/lib/ocran/gem_spec_queryable.rb +13 -5
  17. data/lib/ocran/host_config_helper.rb +11 -1
  18. data/lib/ocran/inno_setup_script_builder.rb +24 -14
  19. data/lib/ocran/launcher_batch_builder.rb +8 -11
  20. data/lib/ocran/npm_package.rb +106 -0
  21. data/lib/ocran/option.rb +465 -35
  22. data/lib/ocran/pe_imports.rb +96 -0
  23. data/lib/ocran/pe_resources.rb +226 -0
  24. data/lib/ocran/refine_pathname.rb +29 -32
  25. data/lib/ocran/roundhouse_builder.rb +316 -0
  26. data/lib/ocran/runner.rb +52 -11
  27. data/lib/ocran/runtime_sdk.rb +259 -0
  28. data/lib/ocran/spinel_builder.rb +183 -0
  29. data/lib/ocran/spinel_compatibility.rb +149 -0
  30. data/lib/ocran/static_runtime/boot.rb +443 -0
  31. data/lib/ocran/static_runtime/build_static_ext.rb +86 -0
  32. data/lib/ocran/static_runtime_builder.rb +814 -0
  33. data/lib/ocran/stub_builder.rb +70 -25
  34. data/lib/ocran/version.rb +1 -1
  35. data/lib/ocran/wasm_builder.rb +570 -0
  36. data/lib/ocran/wasm_compatibility.rb +198 -0
  37. data/lib/ocran/windows_command_escaping.rb +21 -0
  38. data/lib/ocran/zip_payload_builder.rb +18 -9
  39. data/lib/ocran/zip_writer.rb +48 -9
  40. data/src/Makefile +18 -2
  41. data/src/Makefile.mswin +65 -0
  42. data/src/inst_dir.c +34 -6
  43. data/src/path_utils.c +144 -0
  44. data/src/script_info.c +5 -0
  45. data/src/stub.c +77 -19
  46. data/src/stub.rc +1 -1
  47. data/src/system_utils.c +276 -155
  48. data/src/system_utils.h +46 -3
  49. data/src/system_utils_posix.c +243 -148
  50. data/src/unpack.c +9 -0
  51. metadata +25 -4
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ocran
4
+ # Locates the ahead-of-time compilers behind --spinel and --roundhouse,
5
+ # and words the instructions for installing whichever of them is missing.
6
+ #
7
+ # None of them is a gem: Spinel (https://github.com/matz/spinel) is built
8
+ # from source and installs two commands, the compiler `spinel` and its
9
+ # project tool `spin`; Roundhouse (https://github.com/rubys/roundhouse) is
10
+ # a single prebuilt binary. Each is looked up in the environment variable
11
+ # named after it (a path to the command or to the directory holding it),
12
+ # then in PATH, then where its own install instructions put it.
13
+ module AotToolchain
14
+ Tool = Struct.new(:command, :env, :locations, keyword_init: true)
15
+
16
+ TOOLS = {
17
+ spinel: Tool.new(
18
+ command: "spinel",
19
+ env: "SPINEL",
20
+ # `make install` (default PREFIX /usr/local, or PREFIX=$HOME/.local),
21
+ # and the convenience symlink `make` leaves at the root of a checkout.
22
+ locations: %w[~/.local/bin/spinel /usr/local/bin/spinel /opt/homebrew/bin/spinel
23
+ ~/spinel/spinel ~/src/spinel/spinel]
24
+ ),
25
+ spin: Tool.new(
26
+ command: "spin",
27
+ env: "SPIN",
28
+ locations: %w[~/.local/bin/spin /usr/local/bin/spin /opt/homebrew/bin/spin
29
+ ~/spinel/bin/spin ~/src/spinel/bin/spin]
30
+ ),
31
+ roundhouse: Tool.new(
32
+ command: "roundhouse",
33
+ env: "ROUNDHOUSE",
34
+ # The installer script puts it in ~/.local/bin, `cargo install` in
35
+ # ~/.cargo/bin.
36
+ locations: %w[~/.local/bin/roundhouse ~/.cargo/bin/roundhouse /usr/local/bin/roundhouse
37
+ /opt/homebrew/bin/roundhouse]
38
+ ),
39
+ }.freeze
40
+
41
+ SPINEL_URL = "https://github.com/matz/spinel"
42
+ ROUNDHOUSE_URL = "https://github.com/rubys/roundhouse"
43
+
44
+ module_function
45
+
46
+ # Absolute path of the given tool (:spinel, :spin or :roundhouse), or
47
+ # nil when it is not installed anywhere OCRAN knows to look. Raises when
48
+ # the tool's environment variable names something that is not it, since
49
+ # silently falling back to another copy would hide the typo.
50
+ def find(name, env = ENV)
51
+ tool = TOOLS.fetch(name)
52
+
53
+ if (specified = env[tool.env]) && !specified.empty?
54
+ found = executable_in(File.expand_path(specified), tool.command)
55
+ return found if found
56
+
57
+ raise "#{tool.env}=#{specified} does not name the #{tool.command} command " \
58
+ "(expected the executable itself or the directory containing it)"
59
+ end
60
+
61
+ search_path(tool.command, env) ||
62
+ tool.locations.lazy.flat_map { |pattern| Dir.glob(File.expand_path(pattern)) }
63
+ .find { |path| executable?(path) }
64
+ end
65
+
66
+ # Hash of name => path for the given tools, with nil for missing ones.
67
+ def find_all(names, env = ENV)
68
+ names.to_h { |name| [name, find(name, env)] }
69
+ end
70
+
71
+ # The first line `<tool> --version` prints, or nil if it cannot be run.
72
+ def version(path)
73
+ out = IO.popen([path, "--version"], err: [:child, :out], &:read)
74
+ line = out.to_s.lines.first&.strip
75
+ $?.success? && line && !line.empty? ? line : nil
76
+ rescue SystemCallError
77
+ nil
78
+ end
79
+
80
+ # Instructions for installing the given missing tools, as one string.
81
+ def install_instructions(missing)
82
+ lines = []
83
+
84
+ if Gem.win_platform?
85
+ lines << "Spinel does not build natively on Windows yet (Roundhouse's Windows build is untested)."
86
+ lines << "Until it does, run OCRAN inside WSL (https://learn.microsoft.com/windows/wsl/install),"
87
+ lines << "where both work as on Linux; the result is then a Linux binary. Once they support"
88
+ lines << "Windows, installing them there is all this option needs."
89
+ lines << ""
90
+ end
91
+
92
+ if missing.include?(:spinel) || missing.include?(:spin)
93
+ lines << "Spinel (provides `spinel` and `spin`) is built from source, it is not a gem:"
94
+ lines << ""
95
+ lines << " git clone #{SPINEL_URL}"
96
+ lines << " cd spinel"
97
+ lines << " make deps # fetch libprism (one-time)"
98
+ lines << " make"
99
+ lines << " make install PREFIX=$HOME/.local # or: sudo make install"
100
+ lines << ""
101
+ lines << "It needs a C compiler (gcc or clang) and make. Release archives that build"
102
+ lines << "offline are on #{SPINEL_URL}/releases."
103
+ lines << ""
104
+ end
105
+
106
+ if missing.include?(:roundhouse)
107
+ lines << "Roundhouse ships as a single binary:"
108
+ lines << ""
109
+ lines << " curl --proto '=https' --tlsv1.2 -LsSf \\"
110
+ lines << " #{ROUNDHOUSE_URL}/releases/latest/download/roundhouse-installer.sh | sh"
111
+ lines << ""
112
+ lines << "or, with a Rust toolchain:"
113
+ lines << ""
114
+ lines << " cargo install --git #{ROUNDHOUSE_URL} --bin roundhouse"
115
+ lines << ""
116
+ lines << "The Rails app compiled through it links SQLite and jemalloc, so their"
117
+ lines << "development headers are needed as well (Debian/Ubuntu:"
118
+ lines << "`sudo apt install libsqlite3-dev libjemalloc-dev`, macOS: `brew install sqlite jemalloc`)."
119
+ lines << ""
120
+ end
121
+
122
+ env_vars = missing.map { |name| TOOLS.fetch(name).env }
123
+ lines << "OCRAN looks in PATH and the usual install locations; set #{env_vars.join(" / ")} " \
124
+ "to point it at a copy installed elsewhere."
125
+ lines.join("\n")
126
+ end
127
+
128
+ def search_path(command, env = ENV)
129
+ exts = Gem.win_platform? ? env.fetch("PATHEXT", ".EXE;.BAT;.CMD").split(";") : [""]
130
+ env.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
131
+ next if dir.empty?
132
+
133
+ exts.each do |ext|
134
+ path = File.join(File.expand_path(dir), "#{command}#{ext}")
135
+ return path if executable?(path)
136
+ end
137
+ end
138
+ nil
139
+ end
140
+
141
+ def executable_in(path, command)
142
+ return path if executable?(path)
143
+ return nil unless File.directory?(path)
144
+
145
+ names = Gem.win_platform? ? ["#{command}.exe", command] : [command]
146
+ [path, File.join(path, "bin")].product(names).map { |dir, name| File.join(dir, name) }
147
+ .find { |p| executable?(p) }
148
+ end
149
+
150
+ def executable?(path)
151
+ File.file?(path) && File.executable?(path)
152
+ end
153
+ end
154
+ end
@@ -30,9 +30,16 @@ module Ocran
30
30
 
31
31
  def export(name, value)
32
32
  verbose "export #{name}=#{replace_placeholder(value)}"
33
+ exported_env[name.to_s] = value.to_s
33
34
  super
34
35
  end
35
36
 
37
+ # The variables exported so far (name => value with the extraction root
38
+ # placeholder), whatever the builder does with them.
39
+ def exported_env
40
+ @exported_env ||= {}
41
+ end
42
+
36
43
  def replace_placeholder(s)
37
44
  s.to_s.gsub(EXTRACT_ROOT.to_s, "<tempdir>")
38
45
  end
@@ -11,11 +11,18 @@ module Ocran
11
11
  puts s if Ocran.option&.verbose?
12
12
  end
13
13
 
14
+ # Progress goes to stdout, which is buffered when it is not a terminal:
15
+ # flushed first, so that a warning or an error shows up after what led
16
+ # to it when both streams go to the same log.
14
17
  def warning(s)
15
- STDERR.puts "WARNING: #{s}" if Ocran.option&.warning?
18
+ return unless Ocran.option&.warning?
19
+
20
+ $stdout.flush
21
+ STDERR.puts "WARNING: #{s}"
16
22
  end
17
23
 
18
24
  def error(s)
25
+ $stdout.flush
19
26
  STDERR.puts "ERROR: #{s}"
20
27
  end
21
28
  end
@@ -0,0 +1,414 @@
1
+ # frozen_string_literal: true
2
+ require "pathname"
3
+ require "rbconfig"
4
+
5
+ module Ocran
6
+ # A static look at whether a program is likely to run on a Ruby other
7
+ # than the one OCRAN normally packages: Spinel (--spinel), ruby.wasm and
8
+ # PicoRuby (--wasm). Each of them runs a subset of what CRuby runs, and
9
+ # their own errors name the first thing they trip over at best. This walks
10
+ # the whole program instead - the script, the files it requires, and the
11
+ # source of every gem it requires - and reports everything known to stand
12
+ # in the way, sorted by where it comes from, so that "which of my gems
13
+ # won't work" has an answer before the first build is attempted.
14
+ #
15
+ # It is advice, not a verdict: nothing here prevents a build. Subclasses
16
+ # say what their target provides and what it rejects; the walk, the
17
+ # require resolution and the report are shared.
18
+ class CompatibilityScan
19
+ # A construct or require that will (:error) or may (:warning) fail.
20
+ Finding = Struct.new(:severity, :origin, :path, :line, :message, keyword_init: true)
21
+
22
+ # A gem the program requires.
23
+ GemUse = Struct.new(:spec, :native, :stdlib, keyword_init: true) do
24
+ def name = spec.name
25
+ def label = "#{spec.name} #{spec.version}"
26
+ end
27
+
28
+ # A `require` or `require_relative` of one of the program's own files,
29
+ # by byte range in the requiring file.
30
+ LocalRequire = Struct.new(:from, :start_offset, :end_offset, :target, keyword_init: true)
31
+
32
+ # Requires CRuby itself satisfies that mean nothing outside it.
33
+ IGNORED_FEATURES = %w[rubygems bundler/setup].freeze
34
+
35
+ MAX_SHOWN_PER_ORIGIN = 8
36
+
37
+ PROJECT = "your code"
38
+
39
+ attr_reader :findings, :gems, :include_dirs, :files, :local_requires
40
+
41
+ # The scanned files that are the program's own rather than a gem's.
42
+ attr_reader :project_files
43
+
44
+ # script: the program's entry file
45
+ # project_dirs: directories whose files count as the program's own
46
+ def initialize(script, project_dirs: nil)
47
+ @script = Pathname(script).expand_path
48
+ @root = @script.dirname
49
+ @project_dirs = (project_dirs || [@root]).map { |d| Pathname(d).expand_path }
50
+ @findings = []
51
+ @gems = {}
52
+ @include_dirs = []
53
+ @files = []
54
+ @project_files = []
55
+ @local_requires = []
56
+ end
57
+
58
+ # The name the report uses for the target, e.g. "Spinel".
59
+ def target = raise(NotImplementedError)
60
+
61
+ # Walks the program. Returns self.
62
+ def analyze
63
+ unless prism_available?
64
+ @prism_missing = true
65
+ return self
66
+ end
67
+
68
+ queue = [[@script, PROJECT]]
69
+ seen = {}
70
+ until queue.empty?
71
+ path, origin = queue.shift
72
+ next if seen[path.to_s]
73
+
74
+ seen[path.to_s] = true
75
+ @files << path
76
+ @project_files << path if origin == PROJECT
77
+ scan_file(path, origin) { |dep, dep_origin| queue << [dep, dep_origin] }
78
+ end
79
+ self
80
+ end
81
+
82
+ def errors = @findings.select { |f| f.severity == :error }
83
+
84
+ def problem_gems
85
+ @gems.values.select { |g| gem_problem(g) || @findings.any? { |f| f.severity == :error && f.origin == g.name } }
86
+ end
87
+
88
+ def clean?
89
+ !@prism_missing && errors.empty?
90
+ end
91
+
92
+ def scanned? = !@prism_missing
93
+
94
+ # The report as text: a summary of the gems involved, then what was
95
+ # found, grouped by the code it was found in.
96
+ def report
97
+ out = []
98
+ if @prism_missing
99
+ out << "The source could not be scanned: the prism gem is not available (gem install prism)."
100
+ return out.join("\n")
101
+ end
102
+
103
+ unless @gems.empty?
104
+ out << "Gems required by the program:"
105
+ @gems.values.sort_by(&:name).each do |gem|
106
+ out << " #{gem.label}: #{gem_status(gem)}"
107
+ end
108
+ out << ""
109
+ end
110
+
111
+ by_origin = @findings.group_by(&:origin)
112
+ origins = by_origin.keys.sort_by { |o| o == PROJECT ? "" : o }
113
+ origins.each do |origin|
114
+ list = by_origin[origin].sort_by { |f| [f.severity == :error ? 0 : 1, f.path.to_s, f.line.to_i] }
115
+ heading = origin == PROJECT ? "In your code:" : "In gem #{@gems[origin]&.label || origin}:"
116
+ out << heading
117
+ list.first(MAX_SHOWN_PER_ORIGIN).each do |f|
118
+ out << " #{f.severity == :error ? "error" : "warning"}: #{location(f)}#{f.message}"
119
+ end
120
+ if list.size > MAX_SHOWN_PER_ORIGIN
121
+ out << " ... and #{list.size - MAX_SHOWN_PER_ORIGIN} more"
122
+ end
123
+ out << ""
124
+ end
125
+
126
+ if @findings.empty?
127
+ out << "Nothing #{target} is known to reject was found in #{@files.size} file(s)."
128
+ else
129
+ errors_count = errors.size
130
+ warnings_count = @findings.size - errors_count
131
+ out << "#{errors_count} error(s), #{warnings_count} warning(s) in #{@files.size} scanned file(s)."
132
+ end
133
+ out.join("\n")
134
+ end
135
+
136
+ private
137
+
138
+ # --- Hooks for subclasses -------------------------------------------
139
+
140
+ # True when the target provides the feature itself, so `require` of it
141
+ # needs nothing from the host.
142
+ def provided_feature?(_feature) = false
143
+
144
+ # Why the target cannot use the given library (by its require name),
145
+ # as [severity, reason], or nil to resolve it as usual.
146
+ def feature_problem(_feature) = nil
147
+
148
+ # Why the target cannot use the given gem, as [severity, reason], or nil
149
+ # when it can. A gem without a problem has its source scanned too.
150
+ def gem_problem(_gem) = nil
151
+
152
+ # The status line of a gem in the report.
153
+ def gem_status(gem)
154
+ if (problem = gem_problem(gem))
155
+ "#{problem[0] == :error ? "incompatible" : "may not work"} - #{problem[1]}"
156
+ elsif (n = @findings.count { |f| f.severity == :error && f.origin == gem.name }).positive?
157
+ "likely incompatible - #{n} unsupported construct(s), see below"
158
+ else
159
+ "pure Ruby, nothing unsupported found"
160
+ end
161
+ end
162
+
163
+ # Called with a gem the program requires that has no problem, before
164
+ # its source is scanned. Spinel compiles gems from source, so it makes
165
+ # their require paths -I roots.
166
+ def gem_used(gem); end
167
+
168
+ # What to say about `require` of a standard library the target lacks,
169
+ # or nil when the target has Ruby's whole standard library.
170
+ def missing_stdlib_message(name) = "require #{name.inspect}: part of Ruby's standard library, but not one #{target} provides"
171
+
172
+ def unresolved_message(name) = "require #{name.inspect}: not a #{target} library, a project file or an installed gem"
173
+
174
+ def dynamic_require_message(name) = "#{name} with a computed name: the file cannot be found before the program runs"
175
+
176
+ # Per-node checks: called with the node, and flag findings with #flag.
177
+ def check_call(_node, _name, _args, _receiver, _kernel); end
178
+ def check_def(_node); end
179
+ def check_global(_node); end
180
+ def check_constant(_node); end
181
+ def check_xstring(_node); end
182
+
183
+ # --- Shared machinery -----------------------------------------------
184
+
185
+ def flag(severity, node, message)
186
+ add(severity, @current_origin, @current_path, node&.location&.start_line, message)
187
+ end
188
+
189
+ def literal?(node)
190
+ node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
191
+ end
192
+
193
+ def kernel_receiver?(receiver)
194
+ receiver.nil? || (receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :Kernel)
195
+ end
196
+
197
+ def constant?(node, name)
198
+ node.is_a?(Prism::ConstantReadNode) && node.name == name
199
+ end
200
+
201
+ def location(f)
202
+ return "" unless f.path
203
+
204
+ path = display_path(f.path, @gems[f.origin])
205
+ f.line ? "#{path}:#{f.line}: " : "#{path}: "
206
+ end
207
+
208
+ # Gem files relative to the gem, the program's own relative to the
209
+ # script's directory.
210
+ def display_path(path, gem)
211
+ base = gem ? Pathname(gem.spec.full_gem_path) : @root
212
+ rel = Pathname(path).relative_path_from(base).to_s
213
+ rel.start_with?("../..") ? path.to_s : rel
214
+ rescue ArgumentError
215
+ path.to_s
216
+ end
217
+
218
+ def prism_available?
219
+ require "prism"
220
+ true
221
+ rescue LoadError
222
+ false
223
+ end
224
+
225
+ def add(severity, origin, path, line, message)
226
+ @findings << Finding.new(severity: severity, origin: origin, path: path, line: line, message: message)
227
+ end
228
+
229
+ def scan_file(path, origin, &enqueue)
230
+ result = Prism.parse_file(path.to_s)
231
+ @current_path = path
232
+ @current_origin = origin
233
+ @enqueue = enqueue
234
+ result.errors.first(3).each do |e|
235
+ add(:error, origin, path, e.location.start_line, "syntax error: #{e.message}")
236
+ end
237
+ Visitor.new(self).visit(result.value)
238
+ rescue SystemCallError => e
239
+ add(:error, origin, path, nil, "cannot be read: #{e.message}")
240
+ end
241
+
242
+ # Every call: requires are resolved here, the rest goes to the target's
243
+ # check_call.
244
+ def visit_call(node)
245
+ name = node.name
246
+ args = node.arguments&.arguments || []
247
+ receiver = node.receiver
248
+ kernel = kernel_receiver?(receiver)
249
+
250
+ if %i[require require_relative].include?(name) && kernel
251
+ arg = args.first
252
+ if arg.is_a?(Prism::StringNode)
253
+ if name == :require
254
+ require_feature(node, arg.unescaped)
255
+ else
256
+ require_relative(node, arg.unescaped)
257
+ end
258
+ elsif arg
259
+ flag(:error, node, dynamic_require_message(name))
260
+ end
261
+ return
262
+ end
263
+
264
+ check_call(node, name, args, receiver, kernel)
265
+ end
266
+
267
+ def require_relative(node, name)
268
+ target = Pathname(File.expand_path(name, File.dirname(@current_path)))
269
+ target = Pathname("#{target}.rb") unless target.extname == ".rb"
270
+ if target.file?
271
+ record_local(node, target)
272
+ @enqueue.call(target, @current_origin)
273
+ else
274
+ flag(:error, node, "require_relative #{name.inspect}: #{target} does not exist")
275
+ end
276
+ end
277
+
278
+ def require_feature(node, name)
279
+ feature = name.delete_suffix(".rb")
280
+ return if IGNORED_FEATURES.include?(feature)
281
+
282
+ if (problem = feature_problem(feature))
283
+ flag(problem[0], node, "require #{name.inspect}: #{problem[1]}")
284
+ return
285
+ end
286
+ return if provided_feature?(feature)
287
+
288
+ # The program's own files, on a load path it would set up with -I or
289
+ # $LOAD_PATH: the script's directory and its lib/, and the require
290
+ # paths of the gems already found (a gem requiring its own files).
291
+ local_roots = [@root, @root + "lib"] + @include_dirs.map { |d| Pathname(d) }
292
+ local_roots.each do |root|
293
+ candidate = root + "#{feature}.rb"
294
+ next unless candidate.file?
295
+
296
+ add_include_dir(root) unless root == @root
297
+ owner = owner_of(candidate) || (project_file?(candidate) ? PROJECT : @current_origin)
298
+ record_local(node, candidate) if owner == PROJECT
299
+ @enqueue.call(candidate, owner)
300
+ return
301
+ end
302
+
303
+ spec = find_gem(feature)
304
+ unless spec
305
+ message = stdlib_feature?(feature) ? missing_stdlib_message(name) : unresolved_message(name)
306
+ flag(:error, node, message) if message
307
+ return
308
+ end
309
+
310
+ gem = (@gems[spec.name] ||= classify_gem(spec))
311
+ if (problem = gem_problem(gem))
312
+ # Reported once per requiring site outside the gem itself.
313
+ flag(problem[0], node, "require #{name.inspect}: gem #{gem.label} - #{problem[1]}") if @current_origin != gem.name
314
+ return
315
+ end
316
+
317
+ gem_used(gem)
318
+ spec.full_require_paths.each { |dir| add_include_dir(dir) }
319
+ file = spec.full_require_paths.lazy.map { |dir| Pathname(dir) + "#{feature}.rb" }.find(&:file?)
320
+ @enqueue.call(file, spec.name) if file
321
+ end
322
+
323
+ def record_local(node, target)
324
+ return unless @current_origin == PROJECT
325
+
326
+ @local_requires << LocalRequire.new(from: @current_path, start_offset: node.location.start_offset,
327
+ end_offset: node.location.end_offset, target: target)
328
+ end
329
+
330
+ def add_include_dir(dir)
331
+ dir = dir.to_s
332
+ @include_dirs << dir if File.directory?(dir) && !@include_dirs.include?(dir)
333
+ end
334
+
335
+ # The gem a file belongs to, when it is in one already found.
336
+ def owner_of(path)
337
+ @gems.each_value do |gem|
338
+ return gem.name if path.to_s.start_with?(gem.spec.full_gem_path + "/")
339
+ end
340
+ nil
341
+ end
342
+
343
+ def project_file?(path)
344
+ @project_dirs.any? { |dir| path.to_s.start_with?("#{dir}/") }
345
+ end
346
+
347
+ def find_gem(feature)
348
+ Gem::Specification.find_by_path(feature)
349
+ rescue StandardError
350
+ nil
351
+ end
352
+
353
+ def classify_gem(spec)
354
+ # A default gem is how Ruby ships its standard library.
355
+ stdlib = spec.default_gem?
356
+ native = !stdlib && (!spec.extensions.empty? ||
357
+ Dir.glob(File.join(spec.full_gem_path, "**", "*.{so,bundle,dll}")).any?)
358
+ GemUse.new(spec: spec, native: native, stdlib: stdlib)
359
+ end
360
+
361
+ def stdlib_feature?(feature)
362
+ %w[rubylibdir rubyarchdir].any? do |key|
363
+ dir = RbConfig::CONFIG[key] or next false
364
+ Dir.glob(File.join(dir, "#{feature}.{rb,so,bundle,dll}")).any?
365
+ end
366
+ end
367
+
368
+ # Walks one file's syntax tree, handing every node of interest to the
369
+ # scan. Defined on first use, so that Prism is only loaded when a scan
370
+ # actually runs.
371
+ def self.const_missing(name)
372
+ return super unless name == :Visitor
373
+
374
+ require "prism"
375
+ visitor = Class.new(Prism::Visitor) do
376
+ def initialize(scan)
377
+ super()
378
+ @scan = scan
379
+ end
380
+
381
+ def visit_call_node(node)
382
+ @scan.send(:visit_call, node)
383
+ super
384
+ end
385
+
386
+ def visit_def_node(node)
387
+ @scan.send(:check_def, node)
388
+ super
389
+ end
390
+
391
+ def visit_global_variable_read_node(node)
392
+ @scan.send(:check_global, node)
393
+ super
394
+ end
395
+
396
+ def visit_constant_read_node(node)
397
+ @scan.send(:check_constant, node)
398
+ super
399
+ end
400
+
401
+ def visit_x_string_node(node)
402
+ @scan.send(:check_xstring, node)
403
+ super
404
+ end
405
+
406
+ def visit_interpolated_x_string_node(node)
407
+ @scan.send(:check_xstring, node)
408
+ super
409
+ end
410
+ end
411
+ CompatibilityScan.const_set(:Visitor, visitor)
412
+ end
413
+ end
414
+ end
@@ -38,6 +38,11 @@ module Ocran
38
38
  "BUNDLE_LOCKFILE" => nil
39
39
  }.freeze
40
40
 
41
+ # Environment for running the payload on the build host: out of any
42
+ # bundle (see above), and seeing only its own embedded stdlib and gems,
43
+ # not the build host's.
44
+ PAYLOAD_ENV = BUNDLER_FREE_ENV.merge("GEM_HOME" => nil, "GEM_PATH" => nil, "RUBYLIB" => nil).freeze
45
+
41
46
  # Name of the environment variable a CosmoRuby build honors to switch
42
47
  # OFF running an embedded /zip/main.rb, i.e. to behave as an ordinary
43
48
  # interpreter (useful for inspecting a packaged application). Its
@@ -150,8 +155,7 @@ module Ocran
150
155
  def query_ruby(ruby)
151
156
  script = 'print RUBY_VERSION; print "\t"; print Gem.default_dir; ' \
152
157
  '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],
158
+ out = IO.popen([PAYLOAD_ENV, "/bin/sh", ruby, "-e", script],
155
159
  err: IO::NULL, &:read)
156
160
  ok = $?.success?
157
161
  version, default_gem_dir, gem_names = out.to_s.split("\t", 3)
@@ -193,8 +197,10 @@ module Ocran
193
197
  end
194
198
  end
195
199
  RUBY
196
- out = IO.popen([{ "GEM_HOME" => nil, "GEM_PATH" => nil, "RUBYOPT" => nil, "RUBYLIB" => nil },
197
- "/bin/sh", ruby, "-e", script, *features],
200
+ # The same environment as query_ruby: under `bundle exec` the payload
201
+ # would otherwise die setting up the build host's bundle, and every
202
+ # feature would come back as not provided.
203
+ out = IO.popen([PAYLOAD_ENV, "/bin/sh", ruby, "-e", script, *features],
198
204
  err: IO::NULL, &:read)
199
205
  return [] unless $?.success?
200
206
 
@@ -347,13 +353,29 @@ module Ocran
347
353
  output = File.exist?(log) ? File.read(log) : "(no build output captured)"
348
354
  raise "Failed to build the stub with cosmocc (make -C src stub CC=#{cc}):\n#{output}"
349
355
  end
350
- FileUtils.mkdir_p(File.dirname(cached))
351
- FileUtils.cp(File.join(build_dir, "stub"), cached)
352
- File.chmod(0755, cached)
356
+ install_cached(File.join(build_dir, "stub"), cached)
353
357
  end
354
358
  cached
355
359
  end
356
360
 
361
+ # Puts a compiled stub into the cache. The cache entry is used as soon
362
+ # as it exists, so it must never be seen half-written - by a build that
363
+ # runs at the same time, or after one that was interrupted: the copy is
364
+ # made under a temporary name and renamed into place, which is atomic.
365
+ def install_cached(stub, cached)
366
+ require "fileutils"
367
+
368
+ FileUtils.mkdir_p(File.dirname(cached))
369
+ tmp = "#{cached}.#{Process.pid}.tmp"
370
+ begin
371
+ FileUtils.cp(stub, tmp)
372
+ File.chmod(0755, tmp)
373
+ File.rename(tmp, cached)
374
+ ensure
375
+ FileUtils.rm_f(tmp)
376
+ end
377
+ end
378
+
357
379
  # Cache key covering the toolchain (path, mtime, size — so an updated
358
380
  # toolchain at the same path recompiles) and every stub source file.
359
381
  def cache_key(cc)