ocran 1.4.6 → 1.5.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/CHANGELOG.txt +68 -0
- data/README.md +295 -10
- data/ext/extconf.rb +5 -1
- data/lib/ocran/aot_toolchain.rb +154 -0
- data/lib/ocran/command_output.rb +8 -1
- data/lib/ocran/compatibility_scan.rb +414 -0
- data/lib/ocran/cosmo_toolchain.rb +29 -7
- data/lib/ocran/dir_builder.rb +88 -38
- data/lib/ocran/direction.rb +190 -30
- data/lib/ocran/ed_resource.rb +113 -0
- data/lib/ocran/ed_resource_builder.rb +199 -0
- data/lib/ocran/file_path_set.rb +9 -5
- data/lib/ocran/gem_spec_queryable.rb +7 -3
- data/lib/ocran/host_config_helper.rb +11 -1
- data/lib/ocran/inno_setup_script_builder.rb +24 -14
- data/lib/ocran/launcher_batch_builder.rb +8 -11
- data/lib/ocran/npm_package.rb +106 -0
- data/lib/ocran/option.rb +425 -35
- data/lib/ocran/pe_imports.rb +96 -0
- data/lib/ocran/pe_resources.rb +226 -0
- data/lib/ocran/refine_pathname.rb +29 -32
- data/lib/ocran/roundhouse_builder.rb +316 -0
- data/lib/ocran/runner.rb +43 -11
- data/lib/ocran/runtime_sdk.rb +252 -0
- data/lib/ocran/spinel_builder.rb +183 -0
- data/lib/ocran/spinel_compatibility.rb +149 -0
- data/lib/ocran/static_runtime/boot.rb +443 -0
- data/lib/ocran/static_runtime/build_static_ext.rb +86 -0
- data/lib/ocran/static_runtime_builder.rb +814 -0
- data/lib/ocran/stub_builder.rb +70 -25
- data/lib/ocran/version.rb +1 -1
- data/lib/ocran/wasm_builder.rb +570 -0
- data/lib/ocran/wasm_compatibility.rb +198 -0
- data/lib/ocran/windows_command_escaping.rb +21 -0
- data/lib/ocran/zip_payload_builder.rb +18 -9
- data/lib/ocran/zip_writer.rb +48 -9
- data/src/Makefile +18 -2
- data/src/Makefile.mswin +65 -0
- data/src/inst_dir.c +34 -6
- data/src/path_utils.c +144 -0
- data/src/script_info.c +5 -0
- data/src/stub.c +77 -19
- data/src/stub.rc +1 -1
- data/src/system_utils.c +276 -155
- data/src/system_utils.h +46 -3
- data/src/system_utils_posix.c +243 -148
- data/src/unpack.c +9 -0
- metadata +22 -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
|
data/lib/ocran/command_output.rb
CHANGED
|
@@ -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
|
-
|
|
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([
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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)
|