audition 0.3.0 → 0.4.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/README.md +77 -33
- data/lib/audition/bundle_sweep.rb +24 -15
- data/lib/audition/cli.rb +127 -140
- data/lib/audition/config.rb +30 -4
- data/lib/audition/dynamic/harness.rb +219 -20
- data/lib/audition/dynamic/prober.rb +150 -44
- data/lib/audition/finding.rb +10 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/report/github.rb +4 -3
- data/lib/audition/report/json.rb +5 -1
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +13 -2
- data/lib/audition/report.rb +8 -1
- data/lib/audition/rewriters.rb +4 -1
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/mutable_constants.rb +71 -0
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +5 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1050 -7
- data/lib/audition/static/literal_classifier.rb +118 -18
- data/lib/audition/static/native_extensions.rb +28 -16
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +82 -13
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +2 -0
- metadata +10 -4
|
@@ -15,7 +15,11 @@ require "json"
|
|
|
15
15
|
require "rbconfig"
|
|
16
16
|
|
|
17
17
|
module AuditionHarness
|
|
18
|
-
|
|
18
|
+
# High enough for the largest targets: the sweep is the backstop
|
|
19
|
+
# for everything static analysis cannot prove, so it must reach
|
|
20
|
+
# every constant the boot defined. A hit is reported as
|
|
21
|
+
# truncated, never silent.
|
|
22
|
+
MAX_CONSTS = 200_000
|
|
19
23
|
|
|
20
24
|
# Directories under the target root that are not the target's
|
|
21
25
|
# own surface. Bundler's deployment mode (and bundler-cache in
|
|
@@ -82,7 +86,18 @@ module AuditionHarness
|
|
|
82
86
|
def describe_error(error)
|
|
83
87
|
root = unwrap(error)
|
|
84
88
|
{"class" => scrub(root.class.name.to_s),
|
|
85
|
-
"message" => scrub(root.message.to_s)[0, 500]
|
|
89
|
+
"message" => scrub(root.message.to_s)[0, 500],
|
|
90
|
+
"backtrace" => backtrace_for(root)}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Enough frames to reach the target's code under framework
|
|
94
|
+
# wrappers; frames carry paths, which can carry arbitrary bytes.
|
|
95
|
+
def backtrace_for(error)
|
|
96
|
+
Array(error.backtrace).first(30).map do |frame|
|
|
97
|
+
scrub(frame.to_s)[0, 300]
|
|
98
|
+
end
|
|
99
|
+
rescue Exception
|
|
100
|
+
[]
|
|
86
101
|
end
|
|
87
102
|
|
|
88
103
|
def scrub(text)
|
|
@@ -137,10 +152,15 @@ module AuditionHarness
|
|
|
137
152
|
Array(payload["load_paths"]).each do |lp|
|
|
138
153
|
$LOAD_PATH.unshift(lp) # audition:disable global-variables
|
|
139
154
|
end
|
|
155
|
+
baseline = module_state_snapshot
|
|
140
156
|
before = Object.constants
|
|
141
157
|
features = $LOADED_FEATURES.dup # audition:disable global-variables
|
|
142
|
-
require_target(payload.fetch("feature"),
|
|
143
|
-
|
|
158
|
+
require_target(payload.fetch("feature"),
|
|
159
|
+
Array(payload["load_paths"]))
|
|
160
|
+
merge_reopened(
|
|
161
|
+
scan(Object.constants - before, root: payload["root"],
|
|
162
|
+
limit: payload["max_constants"]), baseline
|
|
163
|
+
).merge(
|
|
144
164
|
"native_extensions" => native_extensions(
|
|
145
165
|
features, payload["root"], payload["known_compiled"]
|
|
146
166
|
)
|
|
@@ -152,17 +172,17 @@ module AuditionHarness
|
|
|
152
172
|
# rspec/mocks), and squashed names ship snake_case files
|
|
153
173
|
# (activesupport provides active_support). The second has no
|
|
154
174
|
# rule to invert, so when the target ships exactly one top-level
|
|
155
|
-
# file
|
|
156
|
-
# path. An absolute require keeps the path as given, and scan
|
|
175
|
+
# file on its load paths, that file is the entry, required by
|
|
176
|
+
# absolute path. An absolute require keeps the path as given, and scan
|
|
157
177
|
# compares constant origins against the realpathed root, so the
|
|
158
178
|
# candidate is built from the realpath too (a symlinked tmpdir,
|
|
159
179
|
# macOS /var, otherwise turns own findings into dependency ones).
|
|
160
180
|
# The error reported is the last one seen: a candidate that loads
|
|
161
181
|
# but fails inside says more than "cannot load such file".
|
|
162
|
-
def require_target(feature,
|
|
182
|
+
def require_target(feature, load_paths)
|
|
163
183
|
require feature # audition:disable runtime-require
|
|
164
184
|
rescue LoadError => error
|
|
165
|
-
entry_candidates(feature,
|
|
185
|
+
entry_candidates(feature, load_paths).each do |candidate|
|
|
166
186
|
return require candidate # audition:disable runtime-require
|
|
167
187
|
rescue LoadError => e
|
|
168
188
|
error = e
|
|
@@ -170,11 +190,13 @@ module AuditionHarness
|
|
|
170
190
|
raise error
|
|
171
191
|
end
|
|
172
192
|
|
|
173
|
-
def entry_candidates(feature,
|
|
193
|
+
def entry_candidates(feature, load_paths)
|
|
174
194
|
candidates = []
|
|
175
195
|
slashed = feature.tr("-", "/")
|
|
176
196
|
candidates << slashed if slashed != feature
|
|
177
|
-
files =
|
|
197
|
+
files = load_paths.flat_map do |path|
|
|
198
|
+
Dir[File.join(realpath(path), "*.rb")]
|
|
199
|
+
end
|
|
178
200
|
candidates << files.first.delete_suffix(".rb") if files.size == 1
|
|
179
201
|
candidates
|
|
180
202
|
end
|
|
@@ -184,23 +206,28 @@ module AuditionHarness
|
|
|
184
206
|
# are inspected for class-level ivars and class variables, then
|
|
185
207
|
# descended into. const_get can raise (autoload failures) and
|
|
186
208
|
# anything can lie; every step is rescued and counted.
|
|
187
|
-
def scan(root_names, root: nil)
|
|
209
|
+
def scan(root_names, root: nil, limit: nil)
|
|
188
210
|
# Loaded features are realpathed by require; the target root
|
|
189
211
|
# must be too, or symlinked paths (macOS /var vs /private/var)
|
|
190
212
|
# break the own-vs-dependency comparison.
|
|
191
213
|
root = realpath(root)
|
|
214
|
+
limit = (limit || MAX_CONSTS).to_i
|
|
192
215
|
unshareable = []
|
|
193
216
|
class_state = []
|
|
194
217
|
class_vars = []
|
|
195
218
|
errors = 0
|
|
219
|
+
truncated = false
|
|
196
220
|
seen = {}
|
|
197
221
|
queue = root_names.map { |name| [Object, name.to_s] }
|
|
198
222
|
visited = 0
|
|
199
223
|
|
|
200
224
|
until queue.empty?
|
|
225
|
+
if visited >= limit
|
|
226
|
+
truncated = true
|
|
227
|
+
break
|
|
228
|
+
end
|
|
201
229
|
owner, name = queue.shift
|
|
202
230
|
visited += 1
|
|
203
|
-
break if visited > MAX_CONSTS
|
|
204
231
|
|
|
205
232
|
begin
|
|
206
233
|
value = owner.const_get(name, false)
|
|
@@ -223,8 +250,11 @@ module AuditionHarness
|
|
|
223
250
|
else
|
|
224
251
|
begin
|
|
225
252
|
unless Ractor.shareable?(value)
|
|
253
|
+
blocker, blocker_depth = blocker_for(value)
|
|
226
254
|
unshareable << origin.merge(
|
|
227
|
-
"const" => full, "class" => value.class.name
|
|
255
|
+
"const" => full, "class" => value.class.name,
|
|
256
|
+
"blocker" => blocker,
|
|
257
|
+
"blocker_nested" => blocker_depth.positive?
|
|
228
258
|
)
|
|
229
259
|
end
|
|
230
260
|
rescue Exception
|
|
@@ -237,6 +267,8 @@ module AuditionHarness
|
|
|
237
267
|
"class_state" => class_state,
|
|
238
268
|
"class_variables" => class_vars,
|
|
239
269
|
"scanned" => visited,
|
|
270
|
+
"truncated" => truncated,
|
|
271
|
+
"limit" => limit,
|
|
240
272
|
"errors" => errors}
|
|
241
273
|
end
|
|
242
274
|
|
|
@@ -340,6 +372,125 @@ module AuditionHarness
|
|
|
340
372
|
false
|
|
341
373
|
end
|
|
342
374
|
|
|
375
|
+
# Ivar/cvar state of every module defined before the boot. The
|
|
376
|
+
# new-constants sweep never revisits these, so state the boot
|
|
377
|
+
# plants on reopened core and stdlib classes is diffed against
|
|
378
|
+
# this snapshot instead. Autoload stubs are left untouched:
|
|
379
|
+
# forcing them here would load code behind the back of the
|
|
380
|
+
# before/after constant accounting.
|
|
381
|
+
def module_state_snapshot(limit = 50_000)
|
|
382
|
+
snap = {}
|
|
383
|
+
seen = {}
|
|
384
|
+
queue = Object.constants.map { |name| [Object, name.to_s] }
|
|
385
|
+
until queue.empty? || snap.size >= limit
|
|
386
|
+
owner, name = queue.shift
|
|
387
|
+
begin
|
|
388
|
+
next if owner.autoload?(name, false)
|
|
389
|
+
|
|
390
|
+
value = owner.const_get(name, false)
|
|
391
|
+
rescue Exception
|
|
392
|
+
next
|
|
393
|
+
end
|
|
394
|
+
next unless value.is_a?(Module)
|
|
395
|
+
next if seen[value.object_id]
|
|
396
|
+
|
|
397
|
+
seen[value.object_id] = true
|
|
398
|
+
begin
|
|
399
|
+
snap[value] = [value.instance_variables,
|
|
400
|
+
value.class_variables(false)]
|
|
401
|
+
rescue Exception
|
|
402
|
+
next
|
|
403
|
+
end
|
|
404
|
+
value.constants(false).each do |child|
|
|
405
|
+
queue << [value, child.to_s]
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
snap
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# RubyGems, Bundler, and the VM mutate their own module state on
|
|
412
|
+
# every require; that is probe machinery, not the target's doing.
|
|
413
|
+
MACHINERY = /\A(?:Gem|Bundler|RubyVM)(?:::|\z)/
|
|
414
|
+
|
|
415
|
+
# State the boot added to pre-existing modules, reported through
|
|
416
|
+
# the same channels as freshly defined class state. No source
|
|
417
|
+
# location exists for a reopen, and unknown origins count as own.
|
|
418
|
+
def merge_reopened(result, snapshot)
|
|
419
|
+
origin = {"path" => nil, "line" => nil, "own" => true}
|
|
420
|
+
snapshot.each do |mod, (ivars, cvars)|
|
|
421
|
+
new_ivars = mod.instance_variables - ivars
|
|
422
|
+
new_cvars = mod.class_variables(false) - cvars
|
|
423
|
+
next if new_ivars.empty? && new_cvars.empty?
|
|
424
|
+
|
|
425
|
+
full = mod.name || mod.inspect
|
|
426
|
+
next if full.match?(MACHINERY)
|
|
427
|
+
if new_ivars.any?
|
|
428
|
+
unshareable = new_ivars.reject do |ivar|
|
|
429
|
+
safe_shareable?(mod.instance_variable_get(ivar))
|
|
430
|
+
end
|
|
431
|
+
result["class_state"] << origin.merge(
|
|
432
|
+
"const" => full,
|
|
433
|
+
"ivars" => new_ivars.map(&:to_s),
|
|
434
|
+
"unshareable" => unshareable.map(&:to_s)
|
|
435
|
+
)
|
|
436
|
+
end
|
|
437
|
+
if new_cvars.any?
|
|
438
|
+
result["class_variables"] << origin.merge(
|
|
439
|
+
"const" => full, "cvars" => new_cvars.map(&:to_s)
|
|
440
|
+
)
|
|
441
|
+
end
|
|
442
|
+
rescue Exception
|
|
443
|
+
next
|
|
444
|
+
end
|
|
445
|
+
result
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# The innermost unshareable node of an unshareable value, with
|
|
449
|
+
# its depth: an unfrozen node with shareable contents just needs
|
|
450
|
+
# freezing, an inherently unshareable one needs replacing.
|
|
451
|
+
# Bounded, because shareability of each child is itself a
|
|
452
|
+
# recursive check.
|
|
453
|
+
def blocker_for(value, depth = 0)
|
|
454
|
+
return [describe_blocker(value), depth] if depth > 5
|
|
455
|
+
|
|
456
|
+
child = children_of(value).find { |c| !safe_shareable?(c) }
|
|
457
|
+
if child
|
|
458
|
+
blocker_for(child, depth + 1)
|
|
459
|
+
else
|
|
460
|
+
[describe_blocker(value), depth]
|
|
461
|
+
end
|
|
462
|
+
rescue Exception
|
|
463
|
+
[describe_blocker(value), depth]
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def children_of(value)
|
|
467
|
+
children =
|
|
468
|
+
case value
|
|
469
|
+
when Hash then value.keys + value.values
|
|
470
|
+
when Array then value
|
|
471
|
+
when Struct, Data then value.deconstruct
|
|
472
|
+
else
|
|
473
|
+
value.instance_variables.map do |ivar|
|
|
474
|
+
value.instance_variable_get(ivar)
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
children.first(1000)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# "unfrozen X" only when freezing would actually flip the
|
|
481
|
+
# verdict; a Proc or IO stays unshareable frozen, and naming it
|
|
482
|
+
# unfrozen would send the reader to a fix that cannot work.
|
|
483
|
+
def describe_blocker(value)
|
|
484
|
+
return value.class.to_s if value.frozen?
|
|
485
|
+
|
|
486
|
+
frozen_helps = begin
|
|
487
|
+
Ractor.shareable?(value.dup.freeze)
|
|
488
|
+
rescue Exception
|
|
489
|
+
false
|
|
490
|
+
end
|
|
491
|
+
frozen_helps ? "unfrozen #{value.class}" : value.class.to_s
|
|
492
|
+
end
|
|
493
|
+
|
|
343
494
|
# -- rack --------------------------------------------------------
|
|
344
495
|
|
|
345
496
|
# App objects built in config.ru are almost never shareable (the
|
|
@@ -349,6 +500,8 @@ module AuditionHarness
|
|
|
349
500
|
# and serve one request entirely inside a Ractor.
|
|
350
501
|
def rack(payload)
|
|
351
502
|
config_ru = payload.fetch("config_ru")
|
|
503
|
+
root = payload["root"] || File.dirname(config_ru)
|
|
504
|
+
bundler_setup
|
|
352
505
|
begin
|
|
353
506
|
require "rack" # audition:disable runtime-require
|
|
354
507
|
rescue LoadError
|
|
@@ -356,6 +509,9 @@ module AuditionHarness
|
|
|
356
509
|
end
|
|
357
510
|
|
|
358
511
|
out = {"rack_available" => true}
|
|
512
|
+
baseline = module_state_snapshot
|
|
513
|
+
before = Object.constants
|
|
514
|
+
features = $LOADED_FEATURES.dup # audition:disable global-variables
|
|
359
515
|
begin
|
|
360
516
|
app = Rack::Builder.parse_file(config_ru)
|
|
361
517
|
app = app.first if app.is_a?(Array)
|
|
@@ -364,6 +520,16 @@ module AuditionHarness
|
|
|
364
520
|
rescue Exception => e
|
|
365
521
|
out["main_boot_error"] = describe_error(e)
|
|
366
522
|
end
|
|
523
|
+
# The main-process boot defines the app's constant graph;
|
|
524
|
+
# sweeping it gives rack targets the same backstop as require
|
|
525
|
+
# and rails targets. A failed boot still sweeps what loaded.
|
|
526
|
+
out.merge!(merge_reopened(
|
|
527
|
+
scan(Object.constants - before, root: root,
|
|
528
|
+
limit: payload["max_constants"]), baseline
|
|
529
|
+
))
|
|
530
|
+
out["native_extensions"] = native_extensions(
|
|
531
|
+
features, root, payload["known_compiled"]
|
|
532
|
+
)
|
|
367
533
|
|
|
368
534
|
out["ractor_boot_call"] = rack_in_ractor(config_ru)
|
|
369
535
|
if out["ractor_boot_call"]["ok"]
|
|
@@ -445,20 +611,53 @@ module AuditionHarness
|
|
|
445
611
|
|
|
446
612
|
# -- rails -------------------------------------------------------
|
|
447
613
|
|
|
614
|
+
# The prober sets BUNDLE_GEMFILE to the target's Gemfile; the
|
|
615
|
+
# target's gems must resolve before its boot files load. A
|
|
616
|
+
# setup failure propagates: it is the true boot failure, and
|
|
617
|
+
# the standard reporting captures it. Bundler.setup is called
|
|
618
|
+
# directly because bundler/setup exits on failure, swallowing
|
|
619
|
+
# the message.
|
|
620
|
+
def bundler_setup
|
|
621
|
+
return unless ENV["BUNDLE_GEMFILE"]
|
|
622
|
+
|
|
623
|
+
require "bundler" # audition:disable runtime-require
|
|
624
|
+
Bundler.ui.silence { Bundler.setup }
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# A boot failure does not abandon the sweep: everything defined
|
|
628
|
+
# before the failure is still scanned, so the probe reports what
|
|
629
|
+
# it could reach alongside the boot error.
|
|
448
630
|
def rails(payload)
|
|
449
631
|
environment = payload.fetch("environment")
|
|
632
|
+
bundler_setup
|
|
633
|
+
baseline = module_state_snapshot
|
|
450
634
|
before = Object.constants
|
|
451
635
|
features = $LOADED_FEATURES.dup # audition:disable global-variables
|
|
452
636
|
started = Time.now
|
|
453
|
-
|
|
637
|
+
boot_error = nil
|
|
454
638
|
begin
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
639
|
+
# Absolute requires keep the path as given; realpathing it
|
|
640
|
+
# keeps own-vs-dependency attribution honest under symlinked
|
|
641
|
+
# roots (macOS /var).
|
|
642
|
+
require realpath(environment) # audition:disable runtime-require
|
|
643
|
+
begin
|
|
644
|
+
Rails.application.eager_load!
|
|
645
|
+
rescue Exception
|
|
646
|
+
nil
|
|
647
|
+
end
|
|
648
|
+
rescue Exception => e
|
|
649
|
+
boot_error = describe_error(e)
|
|
458
650
|
end
|
|
459
|
-
boot =
|
|
460
|
-
|
|
461
|
-
|
|
651
|
+
boot =
|
|
652
|
+
if boot_error
|
|
653
|
+
{"ok" => false, "error" => boot_error}
|
|
654
|
+
else
|
|
655
|
+
{"ok" => true, "seconds" => (Time.now - started).round(1)}
|
|
656
|
+
end
|
|
657
|
+
merge_reopened(
|
|
658
|
+
scan(Object.constants - before, root: payload["root"],
|
|
659
|
+
limit: payload["max_constants"]), baseline
|
|
660
|
+
).merge(
|
|
462
661
|
"boot" => boot,
|
|
463
662
|
"native_extensions" => native_extensions(
|
|
464
663
|
features, payload["root"], payload["known_compiled"]
|