marshalsea 0.1.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 +7 -0
- data/LICENSE +661 -0
- data/README.md +220 -0
- data/lib/marshalsea/chains/base.rb +102 -0
- data/lib/marshalsea/chains/erb_def_method.rb +72 -0
- data/lib/marshalsea/chains/erb_def_module.rb +129 -0
- data/lib/marshalsea/chains/psych_init_with.rb +57 -0
- data/lib/marshalsea/chains.rb +38 -0
- data/lib/marshalsea/marshal/boundary_detector.rb +186 -0
- data/lib/marshalsea/marshal/constants.rb +91 -0
- data/lib/marshalsea/marshal/errors.rb +29 -0
- data/lib/marshalsea/marshal/float_body.rb +40 -0
- data/lib/marshalsea/marshal/limits.rb +172 -0
- data/lib/marshalsea/marshal/load_guard.rb +120 -0
- data/lib/marshalsea/marshal/node.rb +174 -0
- data/lib/marshalsea/marshal/parser.rb +309 -0
- data/lib/marshalsea/psych/inspector.rb +277 -0
- data/lib/marshalsea/scanner.rb +397 -0
- data/lib/marshalsea/version.rb +7 -0
- data/lib/marshalsea.rb +19 -0
- metadata +66 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# ©AngelaMos | 2026
|
|
2
|
+
# inspector.rb
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require "psych"
|
|
6
|
+
|
|
7
|
+
module Marshalsea
|
|
8
|
+
module Psych
|
|
9
|
+
class DocumentError < StandardError; end
|
|
10
|
+
|
|
11
|
+
class MalformedDocumentError < DocumentError; end
|
|
12
|
+
|
|
13
|
+
class InputTypeError < DocumentError; end
|
|
14
|
+
|
|
15
|
+
class LimitExceededError < DocumentError; end
|
|
16
|
+
|
|
17
|
+
module Tags
|
|
18
|
+
PATTERN = %r{\A!ruby/(?<kind>[a-z_-]+)(?::(?<class_name>.+))?\z}
|
|
19
|
+
|
|
20
|
+
KIND_OBJECT = "object"
|
|
21
|
+
KIND_HASH = "hash"
|
|
22
|
+
KIND_ARRAY = "array"
|
|
23
|
+
KIND_STRING = "string"
|
|
24
|
+
KIND_STRUCT = "struct"
|
|
25
|
+
KIND_EXCEPTION = "exception"
|
|
26
|
+
KIND_MARSHALABLE = "marshalable"
|
|
27
|
+
|
|
28
|
+
REVIVAL_METHODS = {
|
|
29
|
+
KIND_OBJECT => "init_with",
|
|
30
|
+
KIND_HASH => "[]=",
|
|
31
|
+
KIND_ARRAY => "init_with",
|
|
32
|
+
KIND_STRING => "init_with",
|
|
33
|
+
KIND_STRUCT => "init_with",
|
|
34
|
+
KIND_EXCEPTION => "init_with",
|
|
35
|
+
KIND_MARSHALABLE => "marshal_load"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
GATED_KINDS = [KIND_MARSHALABLE].freeze
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
def parse(tag)
|
|
43
|
+
match = PATTERN.match(tag.to_s)
|
|
44
|
+
return nil unless match
|
|
45
|
+
|
|
46
|
+
[match[:kind], match[:class_name]]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class Limits
|
|
51
|
+
DEFAULT_MAX_BYTES = 1_048_576
|
|
52
|
+
DEFAULT_MAX_DEPTH = 64
|
|
53
|
+
DEFAULT_MAX_NODES = 10_000
|
|
54
|
+
DEFAULT_MAX_ALIASES = 64
|
|
55
|
+
DEFAULT_MAX_DOCUMENTS = 8
|
|
56
|
+
|
|
57
|
+
ROLE_BYTES = "document bytes"
|
|
58
|
+
ROLE_DEPTH = "nesting depth"
|
|
59
|
+
ROLE_NODES = "nodes"
|
|
60
|
+
ROLE_ALIASES = "aliases"
|
|
61
|
+
ROLE_DOCUMENTS = "documents"
|
|
62
|
+
|
|
63
|
+
attr_reader :max_bytes, :max_depth, :max_nodes, :max_aliases, :max_documents
|
|
64
|
+
|
|
65
|
+
def initialize(max_bytes: DEFAULT_MAX_BYTES, max_depth: DEFAULT_MAX_DEPTH,
|
|
66
|
+
max_nodes: DEFAULT_MAX_NODES, max_aliases: DEFAULT_MAX_ALIASES,
|
|
67
|
+
max_documents: DEFAULT_MAX_DOCUMENTS)
|
|
68
|
+
@max_bytes = max_bytes
|
|
69
|
+
@max_depth = max_depth
|
|
70
|
+
@max_nodes = max_nodes
|
|
71
|
+
@max_aliases = max_aliases
|
|
72
|
+
@max_documents = max_documents
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class Reference
|
|
77
|
+
attr_reader :class_name, :kind, :key_position
|
|
78
|
+
|
|
79
|
+
def initialize(class_name:, kind:, key_position:)
|
|
80
|
+
@class_name = class_name
|
|
81
|
+
@kind = kind
|
|
82
|
+
@key_position = key_position
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def key_position?
|
|
86
|
+
@key_position
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def gated?
|
|
90
|
+
Tags::GATED_KINDS.include?(kind)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def revival_method
|
|
94
|
+
Tags::REVIVAL_METHODS.fetch(kind, nil)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def to_s
|
|
98
|
+
"#{class_name} (!ruby/#{kind})"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class Document
|
|
103
|
+
attr_reader :references, :alias_count, :node_count, :document_count
|
|
104
|
+
|
|
105
|
+
def initialize(references:, alias_count:, node_count:, document_count:)
|
|
106
|
+
@references = references
|
|
107
|
+
@alias_count = alias_count
|
|
108
|
+
@node_count = node_count
|
|
109
|
+
@document_count = document_count
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def class_names
|
|
113
|
+
references.filter_map(&:class_name).uniq
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def revivable
|
|
117
|
+
references.reject { |reference| reference.class_name.nil? }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def key_position_references
|
|
121
|
+
references.select(&:key_position?)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def gated_references
|
|
125
|
+
references.select(&:gated?)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class Inspector
|
|
130
|
+
Decision = Marshalsea::Marshal::BoundaryDetector::Decision
|
|
131
|
+
|
|
132
|
+
REASON_INPUT_TYPE = "input is not a String"
|
|
133
|
+
REASON_MALFORMED = "document is not parseable YAML: %s"
|
|
134
|
+
REASON_UNAPPROVED = "document revives unapproved class %s through %s"
|
|
135
|
+
REASON_KEY_DISPATCH = "document puts %s in a mapping key, so its #hash and #== run " \
|
|
136
|
+
"while the mapping is rebuilt"
|
|
137
|
+
|
|
138
|
+
LIMITATION_NOTICE = <<~NOTICE
|
|
139
|
+
SECURITY LIMITATION
|
|
140
|
+
|
|
141
|
+
Marshalsea::Psych::Inspector reads a YAML document through Psych.parse_stream, which
|
|
142
|
+
builds an AST and revives nothing. It never calls YAML.load or YAML.unsafe_load.
|
|
143
|
+
|
|
144
|
+
Unlike Marshal, Psych's own allowlist is a real veto: Psych checks the tag before it
|
|
145
|
+
revives the object, where Marshal runs its proc after the callback has already fired.
|
|
146
|
+
Same intent, opposite outcome, decided entirely by where the check sits. That means
|
|
147
|
+
YAML.safe_load with permitted_classes is a boundary and this inspector is only
|
|
148
|
+
detection and reporting on top of it.
|
|
149
|
+
|
|
150
|
+
Prefer YAML.safe_load. Use this to see what a document would revive, to log it, or
|
|
151
|
+
to reject a document before it reaches a loader you do not control.
|
|
152
|
+
|
|
153
|
+
Alias expansion is not performed here, so an alias bomb costs nothing to inspect.
|
|
154
|
+
It still costs whatever the eventual loader spends expanding it, which is why the
|
|
155
|
+
alias count is bounded and reported rather than ignored.
|
|
156
|
+
NOTICE
|
|
157
|
+
|
|
158
|
+
def initialize(permitted_class_names: [], limits: Limits.new)
|
|
159
|
+
@permitted_class_names = permitted_class_names.map(&:to_s).freeze
|
|
160
|
+
@limits = limits
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def inspect_document(input)
|
|
164
|
+
return reject(REASON_INPUT_TYPE) unless input.is_a?(String)
|
|
165
|
+
|
|
166
|
+
snapshot = input.dup.freeze
|
|
167
|
+
enforce_size(snapshot)
|
|
168
|
+
evaluate(read(snapshot), snapshot)
|
|
169
|
+
rescue DocumentError => e
|
|
170
|
+
reject(format(REASON_MALFORMED, e.class.name.split("::").last))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def read(source)
|
|
174
|
+
Walk.new(limits).call(::Psych.parse_stream(source))
|
|
175
|
+
rescue ::Psych::SyntaxError => e
|
|
176
|
+
raise MalformedDocumentError, e.message
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
attr_reader :permitted_class_names, :limits
|
|
182
|
+
|
|
183
|
+
def enforce_size(source)
|
|
184
|
+
return if source.bytesize <= limits.max_bytes
|
|
185
|
+
|
|
186
|
+
raise LimitExceededError, "#{Limits::ROLE_BYTES} #{source.bytesize} exceeds #{limits.max_bytes}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def evaluate(document, snapshot)
|
|
190
|
+
violation = violation_for(document)
|
|
191
|
+
return reject(violation) if violation
|
|
192
|
+
|
|
193
|
+
Decision.new(state: Decision::STATE_PROCEED, snapshot: snapshot, result: document)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def violation_for(document)
|
|
197
|
+
keyed = document.key_position_references.first
|
|
198
|
+
return format(REASON_KEY_DISPATCH, keyed.class_name.inspect) if keyed
|
|
199
|
+
|
|
200
|
+
unapproved = document.revivable.reject do |reference|
|
|
201
|
+
permitted_class_names.include?(reference.class_name)
|
|
202
|
+
end
|
|
203
|
+
return nil if unapproved.empty?
|
|
204
|
+
|
|
205
|
+
format(REASON_UNAPPROVED, unapproved.first.class_name.inspect,
|
|
206
|
+
unapproved.first.revival_method)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def reject(reason)
|
|
210
|
+
Decision.new(state: Decision::STATE_BLOCKED, reason: reason)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
class Walk
|
|
215
|
+
MAPPING_KEY_STRIDE = 2
|
|
216
|
+
|
|
217
|
+
def initialize(limits)
|
|
218
|
+
@limits = limits
|
|
219
|
+
@references = []
|
|
220
|
+
@aliases = 0
|
|
221
|
+
@nodes = 0
|
|
222
|
+
@documents = 0
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def call(stream)
|
|
226
|
+
@documents = stream.children.length
|
|
227
|
+
check(@documents, limits.max_documents, Limits::ROLE_DOCUMENTS)
|
|
228
|
+
stream.children.each { |child| visit(child, 1, false) }
|
|
229
|
+
Document.new(references: @references.freeze, alias_count: @aliases,
|
|
230
|
+
node_count: @nodes, document_count: @documents)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private
|
|
234
|
+
|
|
235
|
+
attr_reader :limits
|
|
236
|
+
|
|
237
|
+
def check(value, ceiling, role)
|
|
238
|
+
return if value <= ceiling
|
|
239
|
+
|
|
240
|
+
raise LimitExceededError, "#{role} #{value} exceeds #{ceiling}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def visit(node, depth, key_position)
|
|
244
|
+
check(depth, limits.max_depth, Limits::ROLE_DEPTH)
|
|
245
|
+
@nodes += 1
|
|
246
|
+
check(@nodes, limits.max_nodes, Limits::ROLE_NODES)
|
|
247
|
+
|
|
248
|
+
if node.is_a?(::Psych::Nodes::Alias)
|
|
249
|
+
@aliases += 1
|
|
250
|
+
check(@aliases, limits.max_aliases, Limits::ROLE_ALIASES)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
record(node, key_position)
|
|
254
|
+
descend(node, depth)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def record(node, key_position)
|
|
258
|
+
return unless node.respond_to?(:tag)
|
|
259
|
+
|
|
260
|
+
kind, class_name = Tags.parse(node.tag)
|
|
261
|
+
return unless kind
|
|
262
|
+
|
|
263
|
+
@references << Reference.new(class_name: class_name, kind: kind, key_position: key_position)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def descend(node, depth)
|
|
267
|
+
children = node.children
|
|
268
|
+
return unless children
|
|
269
|
+
|
|
270
|
+
mapping = node.is_a?(::Psych::Nodes::Mapping)
|
|
271
|
+
children.each_with_index do |child, index|
|
|
272
|
+
visit(child, depth + 1, mapping && (index % MAPPING_KEY_STRIDE).zero?)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
# ©AngelaMos | 2026
|
|
2
|
+
# scanner.rb
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module Marshalsea
|
|
6
|
+
class ParseRecoveredError < StandardError
|
|
7
|
+
def initialize(errors)
|
|
8
|
+
super("Prism recovered an incomplete tree from #{errors} syntax errors")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Scanner
|
|
13
|
+
GATE_GATED = :gated
|
|
14
|
+
GATE_SOFT = :soft
|
|
15
|
+
GATE_UNGATED = :ungated
|
|
16
|
+
GATE_LINK = :link
|
|
17
|
+
|
|
18
|
+
FORMAT_MARSHAL = :marshal
|
|
19
|
+
FORMAT_PSYCH = :psych
|
|
20
|
+
|
|
21
|
+
VARIADIC = -1
|
|
22
|
+
|
|
23
|
+
ENTRY_POINTS = {
|
|
24
|
+
"marshal_load" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
|
|
25
|
+
"_load_data" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL] },
|
|
26
|
+
"_load" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL], singleton: true },
|
|
27
|
+
"init_with" => { gate: GATE_SOFT, arity: 1, formats: [FORMAT_PSYCH] },
|
|
28
|
+
"hash" => { gate: GATE_UNGATED, arity: 0, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
|
|
29
|
+
"eql?" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
|
|
30
|
+
"<=>" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_MARSHAL] },
|
|
31
|
+
"==" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_PSYCH] },
|
|
32
|
+
"[]=" => { gate: GATE_UNGATED, arity: 2, formats: [FORMAT_PSYCH] },
|
|
33
|
+
"method_missing" => { gate: GATE_UNGATED, arity: VARIADIC, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
|
|
34
|
+
"respond_to_missing?" => { gate: GATE_UNGATED, arity: 2, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
|
|
35
|
+
"respond_to?" => { gate: GATE_UNGATED, arity: VARIADIC, formats: [FORMAT_PSYCH] },
|
|
36
|
+
"to_s" => { gate: GATE_LINK, arity: 0, formats: [] },
|
|
37
|
+
"coerce" => { gate: GATE_LINK, arity: 1, formats: [] }
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
GATED_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_GATED && !spec[:singleton] }
|
|
41
|
+
.keys.freeze
|
|
42
|
+
GATED_SINGLETON_METHODS = ENTRY_POINTS.select { |_, spec| spec[:singleton] }.keys.freeze
|
|
43
|
+
UNGATED_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_UNGATED }.keys.freeze
|
|
44
|
+
LINK_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_LINK }.keys.freeze
|
|
45
|
+
INSTANCE_METHODS = ENTRY_POINTS.reject { |_, spec| spec[:singleton] }.keys.freeze
|
|
46
|
+
SINGLETON_METHODS = GATED_SINGLETON_METHODS
|
|
47
|
+
|
|
48
|
+
PRISM_AVAILABLE = begin
|
|
49
|
+
require "prism"
|
|
50
|
+
true
|
|
51
|
+
rescue LoadError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
LOCATION_SEPARATOR = ":"
|
|
56
|
+
UNKNOWN_LOCATION = nil
|
|
57
|
+
|
|
58
|
+
STATE_UNREADABLE = :unreadable
|
|
59
|
+
STATE_UNANALYSABLE = :unanalysable
|
|
60
|
+
STATE_VERDICTS = [true, false].freeze
|
|
61
|
+
|
|
62
|
+
SITE_MODULE_NAME = :module_name
|
|
63
|
+
SITE_OWN_METHODS = :own_methods
|
|
64
|
+
SITE_CANDIDATE = :candidate
|
|
65
|
+
SITE_SOURCE_PARSE = :source_parse
|
|
66
|
+
SITE_STATE_ANALYSIS = :state_analysis
|
|
67
|
+
|
|
68
|
+
SITES = [SITE_MODULE_NAME, SITE_OWN_METHODS, SITE_CANDIDATE,
|
|
69
|
+
SITE_SOURCE_PARSE, SITE_STATE_ANALYSIS].freeze
|
|
70
|
+
|
|
71
|
+
LOSSY_SITES = [SITE_MODULE_NAME, SITE_OWN_METHODS, SITE_CANDIDATE].freeze
|
|
72
|
+
|
|
73
|
+
SUBJECT_UNNAMED = "(module that cannot report a name)"
|
|
74
|
+
|
|
75
|
+
class Candidate
|
|
76
|
+
attr_reader :class_name, :method_name, :gate, :source_location, :arity, :formats
|
|
77
|
+
|
|
78
|
+
def initialize(class_name:, method_name:, gate:, source_location:, arity:, singleton:,
|
|
79
|
+
touches_state:, formats:)
|
|
80
|
+
@class_name = class_name
|
|
81
|
+
@method_name = method_name
|
|
82
|
+
@gate = gate
|
|
83
|
+
@source_location = source_location
|
|
84
|
+
@arity = arity
|
|
85
|
+
@singleton = singleton
|
|
86
|
+
@touches_state = touches_state
|
|
87
|
+
@formats = formats
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def singleton?
|
|
91
|
+
@singleton
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def gated?
|
|
95
|
+
gate == GATE_GATED
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def soft_gated?
|
|
99
|
+
gate == GATE_SOFT
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def link?
|
|
103
|
+
gate == GATE_LINK
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def entry_point?
|
|
107
|
+
!link?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def dispatch_arity
|
|
111
|
+
ENTRY_POINTS.fetch(method_name).fetch(:arity)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def accepts_dispatch?
|
|
115
|
+
required = dispatch_arity
|
|
116
|
+
return true if required == VARIADIC
|
|
117
|
+
return arity == required unless arity.negative?
|
|
118
|
+
|
|
119
|
+
required >= (arity.abs - 1)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def zero_arity?
|
|
123
|
+
arity.zero?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def touches_state?
|
|
127
|
+
@touches_state == true
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def state_known?
|
|
131
|
+
STATE_VERDICTS.include?(@touches_state)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def unanalysable?
|
|
135
|
+
@touches_state == STATE_UNANALYSABLE
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def unreadable_source?
|
|
139
|
+
@touches_state == STATE_UNREADABLE
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def reachable?
|
|
143
|
+
return false unless entry_point?
|
|
144
|
+
return false unless accepts_dispatch?
|
|
145
|
+
return true if gated? || soft_gated?
|
|
146
|
+
|
|
147
|
+
touches_state? || unreadable_source?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def to_s
|
|
151
|
+
"#{class_name}#{singleton? ? '.' : '#'}#{method_name}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
class Suppression
|
|
156
|
+
attr_reader :site, :subject, :error_class
|
|
157
|
+
|
|
158
|
+
def initialize(site:, subject:, error_class:)
|
|
159
|
+
@site = site
|
|
160
|
+
@subject = subject
|
|
161
|
+
@error_class = error_class
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def lossy?
|
|
165
|
+
LOSSY_SITES.include?(site)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def to_s
|
|
169
|
+
"#{site} #{subject} (#{error_class})"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
class Report
|
|
174
|
+
attr_reader :candidates, :scanned_modules, :suppressions
|
|
175
|
+
|
|
176
|
+
def initialize(candidates, scanned_modules, suppressions)
|
|
177
|
+
@candidates = candidates
|
|
178
|
+
@scanned_modules = scanned_modules
|
|
179
|
+
@suppressions = suppressions
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def suppressed_count
|
|
183
|
+
suppressions.length
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def suppressions_by_site
|
|
187
|
+
suppressions.each_with_object({}) do |suppression, counts|
|
|
188
|
+
counts[suppression.site] = counts.fetch(suppression.site, 0) + 1
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def complete?
|
|
193
|
+
suppressions.empty?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def candidates_lost?
|
|
197
|
+
suppressions.any?(&:lossy?)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def unanalysable
|
|
201
|
+
candidates.select(&:unanalysable?)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def fully_analysed?
|
|
205
|
+
candidates.all?(&:state_known?)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def gated
|
|
209
|
+
candidates.select(&:gated?)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def ungated
|
|
213
|
+
candidates.select { |candidate| candidate.gate == GATE_UNGATED }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def links
|
|
217
|
+
candidates.select(&:link?)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def entry_points
|
|
221
|
+
candidates.select(&:entry_point?)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def reachable
|
|
225
|
+
candidates.select(&:reachable?)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def reachable_in(format)
|
|
229
|
+
reachable.select { |candidate| candidate.formats.include?(format) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def prism_available?
|
|
233
|
+
PRISM_AVAILABLE
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def initialize(namespace: nil)
|
|
238
|
+
@namespace = namespace
|
|
239
|
+
@candidates = []
|
|
240
|
+
@definition_cache = {}
|
|
241
|
+
@scanned_modules = 0
|
|
242
|
+
@suppressions = []
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def scan
|
|
246
|
+
each_named_module do |mod, name|
|
|
247
|
+
@scanned_modules += 1
|
|
248
|
+
collect_instance_methods(mod, name)
|
|
249
|
+
collect_singleton_methods(mod, name)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
Report.new(@candidates.sort_by(&:to_s), @scanned_modules, @suppressions.freeze)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private
|
|
256
|
+
|
|
257
|
+
attr_reader :namespace
|
|
258
|
+
|
|
259
|
+
def suppress(site, subject, error)
|
|
260
|
+
@suppressions << Suppression.new(site: site, subject: subject, error_class: error.class.name)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def each_named_module
|
|
264
|
+
ObjectSpace.each_object(Module) do |mod|
|
|
265
|
+
name = safe_name(mod)
|
|
266
|
+
next unless name
|
|
267
|
+
next unless in_namespace?(name)
|
|
268
|
+
|
|
269
|
+
yield mod, name
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def safe_name(mod)
|
|
274
|
+
name = mod.name
|
|
275
|
+
name if name.is_a?(String) && !name.empty?
|
|
276
|
+
rescue StandardError => e
|
|
277
|
+
suppress(SITE_MODULE_NAME, SUBJECT_UNNAMED, e)
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def in_namespace?(name)
|
|
282
|
+
namespace.nil? || name == namespace || name.start_with?("#{namespace}::")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def collect_instance_methods(mod, name)
|
|
286
|
+
(own_instance_methods(mod, name) & INSTANCE_METHODS).each do |method_name|
|
|
287
|
+
record(mod, name, method_name, singleton: false)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def collect_singleton_methods(mod, name)
|
|
292
|
+
(own_singleton_methods(mod, name) & SINGLETON_METHODS).each do |method_name|
|
|
293
|
+
record(mod, name, method_name, singleton: true)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def own_instance_methods(mod, name)
|
|
298
|
+
(mod.instance_methods(false) +
|
|
299
|
+
mod.private_instance_methods(false) +
|
|
300
|
+
mod.protected_instance_methods(false)).map(&:to_s)
|
|
301
|
+
rescue StandardError => e
|
|
302
|
+
suppress(SITE_OWN_METHODS, name, e)
|
|
303
|
+
[]
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def own_singleton_methods(mod, name)
|
|
307
|
+
(mod.singleton_methods(false) +
|
|
308
|
+
mod.singleton_class.private_instance_methods(false)).map(&:to_s)
|
|
309
|
+
rescue StandardError => e
|
|
310
|
+
suppress(SITE_OWN_METHODS, name, e)
|
|
311
|
+
[]
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def qualified(name, method_name, singleton)
|
|
315
|
+
"#{name}#{singleton ? '.' : '#'}#{method_name}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def record(mod, name, method_name, singleton:)
|
|
319
|
+
spec = ENTRY_POINTS.fetch(method_name)
|
|
320
|
+
handle = singleton ? mod.singleton_class.instance_method(method_name) : mod.instance_method(method_name)
|
|
321
|
+
|
|
322
|
+
@candidates << Candidate.new(
|
|
323
|
+
class_name: name,
|
|
324
|
+
method_name: method_name,
|
|
325
|
+
gate: spec.fetch(:gate),
|
|
326
|
+
source_location: format_location(handle.source_location),
|
|
327
|
+
arity: handle.arity,
|
|
328
|
+
singleton: singleton,
|
|
329
|
+
touches_state: state_reference_in(handle, qualified(name, method_name, singleton)),
|
|
330
|
+
formats: spec.fetch(:formats)
|
|
331
|
+
)
|
|
332
|
+
rescue StandardError, ScriptError => e
|
|
333
|
+
suppress(SITE_CANDIDATE, qualified(name, method_name, singleton), e)
|
|
334
|
+
nil
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def format_location(location)
|
|
338
|
+
return UNKNOWN_LOCATION unless location
|
|
339
|
+
|
|
340
|
+
location.join(LOCATION_SEPARATOR)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def state_reference_in(handle, subject)
|
|
344
|
+
return STATE_UNANALYSABLE unless PRISM_AVAILABLE
|
|
345
|
+
|
|
346
|
+
path, line = handle.source_location
|
|
347
|
+
return STATE_UNANALYSABLE unless path && line
|
|
348
|
+
|
|
349
|
+
definitions = definitions_for(path)
|
|
350
|
+
return STATE_UNREADABLE unless definitions
|
|
351
|
+
|
|
352
|
+
node = definitions[line]
|
|
353
|
+
return STATE_UNREADABLE unless node
|
|
354
|
+
|
|
355
|
+
node.compact_child_nodes.any? { |child| state_reference?(child) }
|
|
356
|
+
rescue StandardError, ScriptError => e
|
|
357
|
+
suppress(SITE_STATE_ANALYSIS, subject, e)
|
|
358
|
+
STATE_UNREADABLE
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def definitions_for(path)
|
|
362
|
+
return @definition_cache[path] if @definition_cache.key?(path)
|
|
363
|
+
|
|
364
|
+
@definition_cache[path] = parse_definitions(path)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def parse_definitions(path)
|
|
368
|
+
parsed = Prism.parse_file(path)
|
|
369
|
+
if parsed.failure?
|
|
370
|
+
suppress(SITE_SOURCE_PARSE, path, ParseRecoveredError.new(parsed.errors.length))
|
|
371
|
+
return nil
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
found = {}
|
|
375
|
+
collect_definitions(parsed.value, found)
|
|
376
|
+
found
|
|
377
|
+
rescue StandardError, ScriptError => e
|
|
378
|
+
suppress(SITE_SOURCE_PARSE, path, e)
|
|
379
|
+
nil
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def collect_definitions(node, found)
|
|
383
|
+
return unless node.is_a?(Prism::Node)
|
|
384
|
+
|
|
385
|
+
found[node.location.start_line] = node if node.is_a?(Prism::DefNode)
|
|
386
|
+
node.compact_child_nodes.each { |child| collect_definitions(child, found) }
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def state_reference?(node)
|
|
390
|
+
return false unless node.is_a?(Prism::Node)
|
|
391
|
+
return true if node.is_a?(Prism::InstanceVariableReadNode)
|
|
392
|
+
return true if node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
393
|
+
|
|
394
|
+
node.compact_child_nodes.any? { |child| state_reference?(child) }
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
data/lib/marshalsea.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# ©AngelaMos | 2026
|
|
2
|
+
# marshalsea.rb
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative "marshalsea/version"
|
|
6
|
+
require_relative "marshalsea/marshal/constants"
|
|
7
|
+
require_relative "marshalsea/marshal/errors"
|
|
8
|
+
require_relative "marshalsea/marshal/node"
|
|
9
|
+
require_relative "marshalsea/marshal/float_body"
|
|
10
|
+
require_relative "marshalsea/marshal/limits"
|
|
11
|
+
require_relative "marshalsea/marshal/parser"
|
|
12
|
+
require_relative "marshalsea/marshal/boundary_detector"
|
|
13
|
+
require_relative "marshalsea/marshal/load_guard"
|
|
14
|
+
require_relative "marshalsea/psych/inspector"
|
|
15
|
+
require_relative "marshalsea/scanner"
|
|
16
|
+
require_relative "marshalsea/chains"
|
|
17
|
+
|
|
18
|
+
module Marshalsea
|
|
19
|
+
end
|