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.
@@ -0,0 +1,174 @@
1
+ # ©AngelaMos | 2026
2
+ # node.rb
3
+ # frozen_string_literal: true
4
+
5
+ module Marshalsea
6
+ module Marshal
7
+ class Node
8
+ STRING_BACKED_TYPES = %i[string regexp].freeze
9
+ WRAPPER_TYPES = %i[user_class extended].freeze
10
+
11
+ attr_reader :type, :tag, :children, :instance_variables_map, :instance_variable_pairs,
12
+ :auxiliary, :undecoded_tail
13
+ attr_accessor :value, :class_name, :link_target, :regexp_options
14
+
15
+ def initialize(type:, tag: nil, value: nil, class_name: nil, undecoded_tail: nil)
16
+ @type = type
17
+ @tag = tag
18
+ @value = value
19
+ @class_name = class_name
20
+ @undecoded_tail = undecoded_tail
21
+ @children = []
22
+ @instance_variables_map = {}
23
+ @instance_variable_pairs = []
24
+ @auxiliary = []
25
+ end
26
+
27
+ def fully_decoded?
28
+ undecoded_tail.nil?
29
+ end
30
+
31
+ def sink?
32
+ Constants::SINK_TAGS.include?(tag)
33
+ end
34
+
35
+ def sink_method
36
+ Constants::SINK_METHODS[tag]
37
+ end
38
+
39
+ def gated?
40
+ Constants::GATED_SINK_TAGS.include?(tag)
41
+ end
42
+
43
+ def dispatches_key_methods?
44
+ !hash_dispatcher.nil? || !eql_dispatcher.nil?
45
+ end
46
+
47
+ def hash_dispatcher(seen = {}.compare_by_identity)
48
+ return nil if seen.key?(self)
49
+
50
+ seen[self] = true
51
+ return link_target&.hash_dispatcher(seen) if type == :object_link
52
+ return member_dispatcher(:hash_dispatcher, seen) unless class_name
53
+ return nil if WRAPPER_TYPES.include?(type) && string_backed?
54
+
55
+ self
56
+ end
57
+
58
+ def eql_dispatcher(seen = {}.compare_by_identity)
59
+ return nil if seen.key?(self)
60
+
61
+ seen[self] = true
62
+ return link_target&.eql_dispatcher(seen) if type == :object_link
63
+ return member_dispatcher(:eql_dispatcher, seen) unless class_name
64
+
65
+ self
66
+ end
67
+
68
+ def member_dispatcher(probe, seen)
69
+ children.each do |child|
70
+ found = child.public_send(probe, seen)
71
+ return found if found
72
+ end
73
+ nil
74
+ end
75
+
76
+ def range_endpoints
77
+ instance_variable_pairs.filter_map do |name, value|
78
+ next unless Constants::RANGE_ENDPOINT_IVARS.include?(name.value)
79
+
80
+ value if value.effective_class_name
81
+ end
82
+ end
83
+
84
+ def effective_class_name
85
+ link_target ? link_target.class_name : class_name
86
+ end
87
+
88
+ def string_backed?
89
+ wrapped = children.first
90
+ return false unless wrapped
91
+
92
+ STRING_BACKED_TYPES.include?(wrapped.type)
93
+ end
94
+
95
+ def each(&block)
96
+ return enum_for(:each) unless block
97
+
98
+ yield self
99
+ children.each { |child| child.each(&block) }
100
+ auxiliary.each { |child| child.each(&block) }
101
+ end
102
+
103
+ def seal
104
+ value.freeze
105
+ class_name.freeze
106
+ undecoded_tail.freeze
107
+ children.freeze
108
+ auxiliary.freeze
109
+ instance_variables_map.freeze
110
+ instance_variable_pairs.freeze
111
+ freeze
112
+ end
113
+ end
114
+
115
+ class Result
116
+ attr_reader :root, :major, :minor, :role_anomalies
117
+
118
+ def initialize(root, major:, minor:, role_anomalies: [])
119
+ @root = root
120
+ @major = major
121
+ @minor = minor
122
+ @role_anomalies = role_anomalies
123
+ end
124
+
125
+ def canonical_version?
126
+ major == Constants::MAJOR_VERSION && minor == Constants::MINOR_VERSION
127
+ end
128
+
129
+ def canonical_roles?
130
+ role_anomalies.empty?
131
+ end
132
+
133
+ def nodes
134
+ root.each
135
+ end
136
+
137
+ def class_names
138
+ nodes.filter_map(&:class_name).uniq
139
+ end
140
+
141
+ def sinks
142
+ nodes.select(&:sink?)
143
+ end
144
+
145
+ def gated_sinks
146
+ sinks.select(&:gated?)
147
+ end
148
+
149
+ def hash_keys
150
+ nodes.select { |node| node.type == :hash }
151
+ .flat_map(&:children)
152
+ .select { |child| child.type == :pair }
153
+ .filter_map { |pair| pair.children.first }
154
+ end
155
+
156
+ def dispatching_hash_keys
157
+ hash_keys.select(&:dispatches_key_methods?)
158
+ end
159
+
160
+ def hash_dispatching_keys
161
+ hash_keys.filter_map(&:hash_dispatcher)
162
+ end
163
+
164
+ def eql_dispatching_keys
165
+ hash_keys.filter_map(&:eql_dispatcher)
166
+ end
167
+
168
+ def range_endpoint_dispatchers
169
+ nodes.select { |node| node.effective_class_name == Constants::RANGE_CLASS_NAME }
170
+ .flat_map(&:range_endpoints)
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,309 @@
1
+ # ©AngelaMos | 2026
2
+ # parser.rb
3
+ # frozen_string_literal: true
4
+
5
+ module Marshalsea
6
+ module Marshal
7
+ class Parser
8
+ include Constants
9
+
10
+ def initialize(source, max_depth: nil, limits: Limits.new)
11
+ raise InputTypeError, "expected String, got #{source.class}" unless source.is_a?(String)
12
+
13
+ @limits = limits
14
+ @max_depth = max_depth || limits.max_depth
15
+ enforce_size(source)
16
+ @source = source.dup.force_encoding(Encoding::BINARY)
17
+ @budget = Budget.new(limits)
18
+ @position = 0
19
+ @symbols = []
20
+ @objects = []
21
+ @role_anomalies = []
22
+ end
23
+
24
+ def parse
25
+ read_header
26
+ root = read_value(1)
27
+ raise TrailingBytesError, "#{remaining} unread bytes" unless remaining.zero?
28
+
29
+ root.each(&:seal)
30
+ Result.new(root, major: @major, minor: @minor,
31
+ role_anomalies: @role_anomalies.freeze).freeze
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :source, :max_depth, :symbols, :objects, :budget, :limits
37
+
38
+ def enforce_size(candidate)
39
+ return if candidate.bytesize <= limits.max_bytes
40
+
41
+ raise LimitExceededError, "#{Limits::ROLE_BYTES} #{candidate.bytesize} exceeds #{limits.max_bytes}"
42
+ end
43
+
44
+ def remaining
45
+ source.bytesize - @position
46
+ end
47
+
48
+ def take(count)
49
+ raise MalformedCountError, "negative byte count #{count}" if count.negative?
50
+ raise TruncatedStreamError, "wanted #{count} bytes, had #{remaining}" if count > remaining
51
+
52
+ slice = source.byteslice(@position, count)
53
+ @position += count
54
+ slice
55
+ end
56
+
57
+ def read_count(role)
58
+ value = read_fixnum
59
+ raise MalformedCountError, "negative #{role} count #{value}" if value.negative?
60
+
61
+ value
62
+ end
63
+
64
+ def take_byte
65
+ take(1).unpack1("C")
66
+ end
67
+
68
+ def take_signed_byte
69
+ byte = take_byte
70
+ byte >= BYTE_SIGN_THRESHOLD ? byte - BYTE_MODULUS : byte
71
+ end
72
+
73
+ def read_header
74
+ @major, @minor = take(HEADER_LENGTH).unpack("CC")
75
+ return if @major == MAJOR_VERSION && @minor <= MINOR_VERSION
76
+
77
+ raise UnsupportedVersionError, "stream declares #{@major}.#{@minor}"
78
+ end
79
+
80
+ def read_fixnum
81
+ marker = take_signed_byte
82
+ return 0 if marker.zero?
83
+ return marker - FIXNUM_INLINE_OFFSET if marker > FIXNUM_MAX_INLINE
84
+ return marker + FIXNUM_INLINE_OFFSET if marker < FIXNUM_MIN_INLINE
85
+
86
+ width = marker.abs
87
+ value = little_endian(take(width))
88
+ marker.negative? ? value - (1 << (BITS_PER_BYTE * width)) : value
89
+ end
90
+
91
+ def little_endian(bytes)
92
+ bytes.each_byte.with_index.sum { |byte, index| byte << (BITS_PER_BYTE * index) }
93
+ end
94
+
95
+ def read_counted_bytes
96
+ size = read_count(ROLE_LENGTH)
97
+ budget.scalar!(size)
98
+ take(size)
99
+ end
100
+
101
+ def read_entry_count(role)
102
+ count = read_count(role)
103
+ budget.entries!(count)
104
+ count
105
+ end
106
+
107
+ def register(node)
108
+ budget.registered!
109
+ objects << node
110
+ node
111
+ end
112
+
113
+ def read_value(depth)
114
+ raise DepthLimitError, "exceeded depth #{max_depth}" if depth > max_depth
115
+
116
+ budget.node!
117
+ tag = take(1)
118
+
119
+ case tag
120
+ when TAG_NIL then Node.new(type: :nil, tag: tag)
121
+ when TAG_TRUE then Node.new(type: :boolean, tag: tag, value: true)
122
+ when TAG_FALSE then Node.new(type: :boolean, tag: tag, value: false)
123
+ when TAG_FIXNUM then Node.new(type: :fixnum, tag: tag, value: read_fixnum)
124
+ when TAG_SYMBOL then read_symbol(tag)
125
+ when TAG_SYMLINK then read_symlink(tag)
126
+ when TAG_OBJECT_LINK then read_object_link(tag)
127
+ when TAG_BIGNUM then register(read_bignum(tag))
128
+ when TAG_FLOAT then register(read_float(tag))
129
+ when TAG_STRING then register(read_string(tag))
130
+ when TAG_REGEXP then register(read_regexp(tag))
131
+ when TAG_ARRAY then read_array(tag, depth)
132
+ when TAG_HASH, TAG_HASH_DEFAULT then read_hash(tag, depth)
133
+ when TAG_IVAR then read_ivar(tag, depth)
134
+ when TAG_OBJECT then read_object(tag, depth)
135
+ when TAG_STRUCT then read_struct(tag, depth)
136
+ when TAG_USERDEF then register(read_userdef(tag, depth))
137
+ when TAG_USERMARSHAL then read_usermarshal(tag, depth)
138
+ when TAG_DATA then read_wrapped(tag, :data, depth)
139
+ when TAG_USERCLASS then read_wrapped(tag, :user_class, depth)
140
+ when TAG_EXTENDED then read_wrapped(tag, :extended, depth)
141
+ when TAG_CLASS then register(read_named(tag, :class))
142
+ when TAG_MODULE, TAG_MODULE_OLD then register(read_named(tag, :module))
143
+ else raise UnknownTagError, "byte #{tag.unpack1('C')} at offset #{@position - 1}"
144
+ end
145
+ end
146
+
147
+ def read_symbol(tag)
148
+ budget.symbol!
149
+ size = read_count(ROLE_LENGTH)
150
+ budget.symbol_name!(size)
151
+ budget.scalar!(size)
152
+ node = Node.new(type: :symbol, tag: tag, value: take(size).to_sym)
153
+ symbols << node.value
154
+ node
155
+ end
156
+
157
+ def read_symlink(tag)
158
+ budget.symbol_reference!
159
+ index = read_fixnum
160
+ raise InvalidLinkError, "symlink #{index} of #{symbols.length}" if
161
+ index.negative? || index >= symbols.length
162
+
163
+ Node.new(type: :symlink, tag: tag, value: symbols[index])
164
+ end
165
+
166
+ def read_object_link(tag)
167
+ budget.link!
168
+ index = read_fixnum
169
+ raise InvalidLinkError, "object link #{index} of #{objects.length}" if
170
+ index.negative? || index >= objects.length
171
+
172
+ node = Node.new(type: :object_link, tag: tag, value: index)
173
+ node.link_target = objects[index]
174
+ node
175
+ end
176
+
177
+ def read_bignum(tag)
178
+ sign = take(1)
179
+ raise MalformedValueError, "bignum sign #{sign.inspect}" unless BIGNUM_SIGNS.include?(sign)
180
+
181
+ size = read_count(ROLE_BIGNUM) * BIGNUM_WORD_BYTES
182
+ budget.scalar!(size)
183
+ magnitude = little_endian(take(size))
184
+ Node.new(type: :bignum, tag: tag,
185
+ value: sign == BIGNUM_SIGN_NEGATIVE ? -magnitude : magnitude)
186
+ end
187
+
188
+ def read_float(tag)
189
+ value, tail = FloatBody.decode(read_counted_bytes)
190
+ Node.new(type: :float, tag: tag, value: value, undecoded_tail: tail)
191
+ end
192
+
193
+ def read_string(tag)
194
+ Node.new(type: :string, tag: tag, value: read_counted_bytes)
195
+ end
196
+
197
+ def read_regexp(tag)
198
+ node = Node.new(type: :regexp, tag: tag, value: read_counted_bytes)
199
+ node.regexp_options = take_byte
200
+ node
201
+ end
202
+
203
+ def read_array(tag, depth)
204
+ node = register(Node.new(type: :array, tag: tag))
205
+ read_entry_count(ROLE_ARRAY).times { node.children << read_value(depth + 1) }
206
+ node
207
+ end
208
+
209
+ def read_hash(tag, depth)
210
+ node = register(Node.new(type: :hash, tag: tag))
211
+ read_entry_count(ROLE_HASH).times { node.children << read_pair(depth) }
212
+ node.children << read_value(depth + 1) if tag == TAG_HASH_DEFAULT
213
+ node
214
+ end
215
+
216
+ def read_pair(depth)
217
+ pair = Node.new(type: :pair)
218
+ pair.children << read_value(depth + 1)
219
+ pair.children << read_value(depth + 1)
220
+ pair
221
+ end
222
+
223
+ def note_role_anomaly(role, node)
224
+ return if CLASS_NAME_TYPES.include?(node.type)
225
+
226
+ @role_anomalies << format(ROLE_ANOMALY, role, node.type).freeze
227
+ end
228
+
229
+ def read_class_name(node, depth)
230
+ class_node = read_value(depth)
231
+ unless CLASS_NAME_TYPES.include?(class_node.type)
232
+ raise MalformedValueError,
233
+ "class name slot holds #{class_node.type}, not a symbol"
234
+ end
235
+
236
+ node.class_name = class_node.value.to_s
237
+ node.auxiliary << class_node
238
+ node
239
+ end
240
+
241
+ def read_instance_variables(node, depth)
242
+ count = read_entry_count(ROLE_IVAR)
243
+ budget.instance_variables!(count)
244
+ count.times do
245
+ name = read_value(depth + 1)
246
+ value = read_value(depth + 1)
247
+ note_role_anomaly(ROLE_IVAR, name)
248
+ node.auxiliary << name
249
+ node.auxiliary << value
250
+ node.instance_variables_map[name.value] = value
251
+ node.instance_variable_pairs << [name, value].freeze
252
+ end
253
+ node
254
+ end
255
+
256
+ def read_ivar(_tag, depth)
257
+ read_instance_variables(read_value(depth + 1), depth)
258
+ end
259
+
260
+ def read_object(tag, depth)
261
+ node = register(Node.new(type: :object, tag: tag))
262
+ read_class_name(node, depth + 1)
263
+ read_instance_variables(node, depth)
264
+ end
265
+
266
+ def read_struct(tag, depth)
267
+ node = register(Node.new(type: :struct, tag: tag))
268
+ read_class_name(node, depth + 1)
269
+ count = read_entry_count(ROLE_STRUCT)
270
+ budget.struct_members!(count)
271
+ count.times do
272
+ pair = read_pair(depth)
273
+ note_role_anomaly(ROLE_STRUCT, pair.children.first)
274
+ node.children << pair
275
+ end
276
+ node
277
+ end
278
+
279
+ def read_userdef(tag, depth)
280
+ node = Node.new(type: :userdef, tag: tag)
281
+ read_class_name(node, depth + 1)
282
+ node.value = read_counted_bytes
283
+ node
284
+ end
285
+
286
+ def read_usermarshal(tag, depth)
287
+ node = register(Node.new(type: :usermarshal, tag: tag))
288
+ read_class_name(node, depth + 1)
289
+ node.children << read_value(depth + 1)
290
+ node
291
+ end
292
+
293
+ def read_wrapped(tag, type, depth)
294
+ node = Node.new(type: type, tag: tag)
295
+ register(node) if REGISTERED_WRAPPER_TYPES.include?(type)
296
+ read_class_name(node, depth + 1)
297
+ node.children << read_value(depth + 1)
298
+ node
299
+ end
300
+
301
+ def read_named(tag, type)
302
+ size = read_count(ROLE_LENGTH)
303
+ budget.class_name!(size)
304
+ budget.scalar!(size)
305
+ Node.new(type: type, tag: tag, class_name: take(size))
306
+ end
307
+ end
308
+ end
309
+ end