ctypes 0.2.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,637 @@
1
+ # encoding: ASCII-8BIT
2
+ # frozen_string_literal: true
3
+
4
+ # SPDX-FileCopyrightText: 2025 Cisco
5
+ # SPDX-License-Identifier: MIT
6
+
7
+ module CTypes
8
+ # This class represents a C union in ruby. It provides methods for unpacking
9
+ # unions from their binary representation into a modifiable Ruby instance,
10
+ # and methods for repacking them into binary.
11
+ #
12
+ # The ruby representation of a union does not get the same memory overlap
13
+ # benefits as experienced in C. As a result, the ruby {Union} must unpack
14
+ # the binary string each time a different union member is accessed. To
15
+ # support modification, this also means every time we switch between union
16
+ # members, any active union member must be packed into binary format, then
17
+ # unpacked at the new member. **This is a significant performance penalty
18
+ # when working with read-write Unions.**
19
+ #
20
+ # To get arond the performance penalty, you can do one of the following:
21
+ # - do not swap between multiple union members unless absolutely necessary
22
+ # - {Union#freeze} the unpacked union instance to eliminate the repacking
23
+ # performance hit
24
+ # - figure out a memory-overlayed union implementation in ruby that doesn't
25
+ # require unpack for every member access (it would be welcomed)
26
+ #
27
+ # @example
28
+ # # encoding: ASCII-8BIT
29
+ # require_relative "./lib/ctypes"
30
+ #
31
+ # # subclass Union to define a union
32
+ # class Msg < CTypes::Union
33
+ # layout do
34
+ # # this message uses network-byte order
35
+ # endian :big
36
+ #
37
+ # # create enum for the message type used in members
38
+ # type = enum(uint8, {invalid: 0, hello: 1, read: 2})
39
+ #
40
+ # # define union members
41
+ # member :hello, struct({type:, version: string})
42
+ # member :read, struct({type:, offset: uint64, len: uint64})
43
+ # member :type, type
44
+ # member :raw, string
45
+ # end
46
+ # end
47
+ #
48
+ # # unpack a message and access member values
49
+ # msg = Msg.unpack("\x02" +
50
+ # "\xfe\xfe\xfe\xfe\xfe\xfe\xfe\xfe" +
51
+ # "\xab\xab\xab\xab\xab\xab\xab\xab")
52
+ # msg.type # => :read
53
+ # msg.read.offset # => 0xfefefefefefefefe
54
+ # msg.read.len # => 0xabababababababab
55
+ #
56
+ # # create new messages
57
+ # Msg.pack({hello: {type: :hello, version: "v1.0"}})
58
+ # # => "\1v1.0\0\0\0\0\0\0\0\0\0\0\0\0"
59
+ # Msg.pack({read: {type: :read, offset: 0xffff, len: 0x1000}})
60
+ # # => "\2\0\0\0\0\0\0\xFF\xFF\0\0\0\0\0\0\x10\0"
61
+ #
62
+ # # work with a message instance to create a message
63
+ # msg = Msg.new
64
+ # msg.hello.type = :hello
65
+ # msg.hello.version = "v1.0"
66
+ # msg.to_binstr # => "\1v1.0\0\0\0\0\0\0\0\0\0\0\0\0"
67
+ #
68
+ class Union
69
+ extend Type
70
+ using PrettyPrintHelpers
71
+
72
+ # define the layout of this union
73
+ # @see Builder
74
+ #
75
+ # @example
76
+ # class Msg < CTypes::Union
77
+ # layout do
78
+ # # this message uses network-byte order
79
+ # endian :big
80
+ #
81
+ # # create enum for the message type used in members
82
+ # type = enum(uint8, {invalid: 0, hello: 1, read: 2})
83
+ #
84
+ # # define union members
85
+ # member :hello, struct({type:, version: string})
86
+ # member :read, struct({type:, offset: uint64, len: uint64})
87
+ # member :type, type
88
+ # member :raw, string
89
+ # end
90
+ # end
91
+ def self.layout(&block)
92
+ raise Error, "no block given" unless block
93
+ builder = Builder.new(&block)
94
+ builder.instance_eval(&block)
95
+ apply_layout(builder)
96
+ end
97
+
98
+ # get an instance of the {Union::Builder}
99
+ def self.builder
100
+ Builder.new
101
+ end
102
+
103
+ # @api private
104
+ def self.apply_layout(builder)
105
+ @name, @fields, @dry_type, @size, @fixed_size, @endian = builder.result
106
+
107
+ @field_accessors ||= []
108
+ remove_method(*@field_accessors.flatten)
109
+ @field_accessors.clear
110
+ @field_types = {}
111
+ @greedy = false
112
+
113
+ @fields.each do |field|
114
+ # split out the array; we do it this way because we want to reference
115
+ # the original fields array when assigning @field_types
116
+ name, type = field
117
+
118
+ # the union will be flagged as greedy if size is not defined by a Proc,
119
+ # and the field type is greedy
120
+ @greedy ||= type.greedy? unless @size.is_a?(Proc)
121
+
122
+ case name
123
+ when Symbol
124
+ @field_accessors += [
125
+ define_method(name) { self[name] },
126
+ define_method(:"#{name}=") { |v| self[name] = v }
127
+ ]
128
+ @field_types[name] = field
129
+ when ::Array
130
+ name.each do |n|
131
+ @field_accessors += [
132
+ define_method(n) { self[n] },
133
+ define_method(:"#{n}=") { |v| self[n] = v }
134
+ ]
135
+ @field_types[n] = field
136
+ end
137
+ else
138
+ raise Error, "unsupported field name type: %p", name
139
+ end
140
+ end
141
+ end
142
+ private_class_method :apply_layout
143
+
144
+ # encode a ruby Hash into a String containing the binary representation of
145
+ # the Union
146
+ #
147
+ # @param value [Hash] value to be encoded; must have size <= 1
148
+ # @param endian [Symbol] endian to pack with
149
+ # @param validate [Boolean] set to false to disable value validation
150
+ # @param pad_bytes [String] bytes to used to pad; defaults to null bytes
151
+ # @note do not provide multiple member values to this method; only zero or
152
+ # one member values are supported.
153
+ # @return [::String] binary encoding for value
154
+ #
155
+ # @example
156
+ # include CTypes::Helpers
157
+ # t = union(word: uint32, bytes: array(uint8, 4))
158
+ # t.pack({word: 0xfeedface}) # => "\xCE\xFA\xED\xFE"
159
+ # t.pack({word: 0xfeedface}, endian: :big)
160
+ # # => "\xFE\xED\xFA\xCE"
161
+ #
162
+ # t.pack({bytes: [1, 2, 3, 4]}) # => "\x01\x02\x03\x04"
163
+ # t.pack({bytes: [1, 2, 3, 4]}, endian: :big)
164
+ # # => "\x01\x02\x03\x04"
165
+ #
166
+ # t.pack({bytes: [1, 2]}) # => "\x01\x02\x00\x00"
167
+ # t.pack({bytes: []}) # => "\x00\x00\x00\x00"
168
+ # t.pack({bytes: nil}) # => "\x00\x00\x00\x00"
169
+ # t.pack({}) # => "\x00\x00\x00\x00"
170
+ # t.pack({word: 20, bytes: []}) # => CTypes::Error
171
+ #
172
+ # @example using pad_bytes
173
+ # t = union { member :u8, uint8, member :u32, uint32 }
174
+ # t.pack({u8: 0}, pad_bytes: "ABCD") # => "\0BCD"
175
+ def self.pack(value, endian: default_endian, validate: true, pad_bytes: nil)
176
+ value = value.to_hash.freeze
177
+ unknown_keys = value&.keys
178
+ members = @fields.filter_map do |name, type|
179
+ case name
180
+ when Symbol
181
+ unknown_keys.delete(name)
182
+ [name, type, value[name]] if value.has_key?(name)
183
+ when ::Array
184
+ unknown_keys.reject! { |k| name.include?(k) }
185
+ [name, type, value.slice(*name)] if name.any? { |n| value.has_key?(n) }
186
+ else
187
+ raise Error, "unsupported field name type: %p" % [name]
188
+ end
189
+ end
190
+
191
+ # raise an error if they provided multiple member values
192
+ if members.size > 1
193
+ raise Error, <<~MSG % [members.map { |name, _| name }]
194
+ conflicting values for Union#pack; only supply one union member: %p
195
+ MSG
196
+
197
+ # raise an error if they provided extra keys that aren't for a member
198
+ elsif !unknown_keys.empty?
199
+ raise Error, "unknown member names: %p" % [unknown_keys]
200
+
201
+ # if they didn't provide any key, use the first member
202
+ elsif members.empty?
203
+ name, type = @fields.first
204
+ members << [name, type, type.default_value]
205
+ end
206
+
207
+ # we have a single member value to pack; let's grab the type & value and
208
+ # pack it
209
+ _, type, val = members[0]
210
+ out = if type.respond_to?(:ancestors) && type.ancestors.include?(Union)
211
+ type.pack(val, endian: type.endian || endian, validate:, pad_bytes:)
212
+ else
213
+ type.pack(val, endian: type.endian || endian, validate:)
214
+ end
215
+
216
+ # @size has two different behaviors. When @size is a proc, then the size
217
+ # is an absolute length. Otherwise, size is a minimum length. To start
218
+ # with, let's calculate a minimum length.
219
+ #
220
+ # @size has two different behaviors. When @size is a Proc, it represents
221
+ # an absolute length. Otherwise, size represents a minimum length. We
222
+ # do this to support unions with greedy members without the union itself
223
+ # being greedy.
224
+ #
225
+ # So grab the minimum length of the output string and expand the output
226
+ # to be at least that long.
227
+ min_length = if @size.is_a?(Proc)
228
+
229
+ # Run the size proc with a Union made of our output. Yes we end up
230
+ # unpacking what we just packed in this case, but it's the cost of
231
+ # supporting pack of dynamically sized unions.
232
+ begin
233
+ instance_exec(new(buf: out, endian:).freeze, &@size)
234
+
235
+ # so there's a chance that the packed union value has fewer bytes
236
+ # than what is required to evaluate the size proc. If we get a missing
237
+ # bytes error, let's pad the out string with the needed number of
238
+ # bytes. This may happen multiple times as we have no idea what is
239
+ # in the size proc.
240
+ rescue CTypes::MissingBytesError => ex
241
+ out << if pad_bytes && out.size < pad_bytes.size
242
+ pad_bytes.byteslice(out.size, ex.need)
243
+ else
244
+ "\0" * ex.need
245
+ end
246
+ retry
247
+ end
248
+ else
249
+ @size
250
+ end
251
+
252
+ # when we need to pad the output, use pad_bytes first
253
+ if out.size < min_length && pad_bytes
254
+ out << pad_bytes.byteslice(out.size, min_length - out.size)
255
+ end
256
+
257
+ # if we still need more bytes, pad with zeros
258
+ if out.size < min_length
259
+ out << "\0" * (min_length - out.size)
260
+ end
261
+
262
+ # Now, if @size is a Proc only return the absolute length bytes of our
263
+ # output string. Everything else gets the full output string
264
+ @size.is_a?(Proc) ? out.byteslice(0, min_length) : out
265
+ end
266
+
267
+ # convert a String containing the binary represention of a c union into
268
+ # a ruby type
269
+ #
270
+ # @param buf [String] bytes that make up the type
271
+ # @param endian [Symbol] endian of data within buf
272
+ # @return [::Array(Union, ::String)] Union, and remaining bytes
273
+ #
274
+ # @see Type#unpack
275
+ #
276
+ # @example
277
+ # class Msg < CTypes::Union
278
+ # layout do
279
+ # type = enum(uint8, {invalid: 0, hello: 1, read: 2})
280
+ # member :hello, struct({type:, version: string})
281
+ # member :read, struct({type:, offset: uint64, len: uint64})
282
+ # member :type, type
283
+ # end
284
+ # end
285
+ #
286
+ # msg, rest = Msg.unpack_one("\x02" +
287
+ # "\xfe\xfe\xfe\xfe\xfe\xfe\xfe\xfe" +
288
+ # "\xab\xab\xab\xab\xab\xab\xab\xab" +
289
+ # "extra_bytes")
290
+ # msg.type # => :read
291
+ # msg.read.offset # => 0xfefefefefefefefe
292
+ # msg.read.len # => 0xabababababababab
293
+ # rest # => "extra_bytes"
294
+ def self.unpack_one(buf, endian: default_endian)
295
+ size = if @size.is_a?(Proc)
296
+ instance_exec(new(buf:, endian:).freeze, &@size)
297
+ else
298
+ @size
299
+ end
300
+
301
+ raise missing_bytes_error(input: buf, need: size) if buf.size < size
302
+ if fixed_size? || @size.is_a?(Proc)
303
+ buf, rest = buf.byteslice(0, size), buf.byteslice(size..)
304
+ else
305
+ rest = ""
306
+ end
307
+
308
+ [new(buf:, endian:), rest]
309
+ end
310
+
311
+ # @api private
312
+ def self.unpack_field(field:, buf:, endian:)
313
+ name, type = @field_types[field]
314
+ raise UnknownMemberError, "unknown member: %p" % [field] unless type
315
+
316
+ case name
317
+ when Symbol
318
+ {name => type.with_endian(endian).unpack(buf)}
319
+ when ::Array
320
+ type.with_endian(endian).unpack(buf, endian:)
321
+ end
322
+ end
323
+
324
+ # return the struct name
325
+ # @api private
326
+ def self.type_name
327
+ @name
328
+ end
329
+
330
+ # @api private
331
+ def self.greedy?
332
+ @greedy
333
+ end
334
+
335
+ # check if this is a fixed-size Union
336
+ def self.fixed_size?
337
+ @fixed_size
338
+ end
339
+
340
+ # return minimum size of the Union
341
+ # @see Type.size
342
+ def self.size
343
+ @size
344
+ end
345
+
346
+ # return the size of a member
347
+ # @see Type.size
348
+ def self.sizeof(member)
349
+ @field_types[member][1].size
350
+ end
351
+
352
+ # get the list of members in this Union
353
+ #
354
+ # @return [::Array<Symbol>] member names
355
+ def self.fields
356
+ @field_types.keys
357
+ end
358
+
359
+ # get the list of members in this Union
360
+ #
361
+ # @return array of field layout
362
+ def self.field_layout
363
+ @fields
364
+ end
365
+
366
+ # check if the Union has a given member
367
+ # @param member [Symbol] member name
368
+ def self.has_field?(member)
369
+ @field_types.has_key?(member)
370
+ end
371
+
372
+ # check if another Union subclass has the same members as this Union
373
+ def self.==(other)
374
+ return true if super
375
+ return false unless other.is_a?(Class) && other < Union
376
+ other.field_layout == @fields &&
377
+ other.default_endian == default_endian &&
378
+ other.size == size
379
+ end
380
+
381
+ def self.pretty_print(q) # :nodoc:
382
+ q.ctype("union", @endian) do
383
+ q.seplist(@fields, -> { q.breakable("; ") }) do |name, type|
384
+ case name
385
+ when Symbol
386
+ q.text("member %p, " % name)
387
+ when ::Array
388
+ q.text("member ")
389
+ end
390
+ q.pp(type)
391
+ end
392
+ end
393
+ end
394
+
395
+ class << self
396
+ alias_method :inspect, :pretty_inspect # :nodoc:
397
+ end
398
+
399
+ # @api private
400
+ def self.export_type(q)
401
+ q << "CTypes::Union.builder()"
402
+ q.break
403
+ q.nest(2) do
404
+ q << ".endian(%p)\n" % [@endian] if @endian
405
+ @fields.each do |name, type|
406
+ case name
407
+ when Symbol
408
+ q << ".member(%p, " % [name]
409
+ q << type
410
+ q << ")"
411
+ when ::Array
412
+ q << ".member("
413
+ q << type
414
+ q << ")"
415
+ else
416
+ raise Error, "unsupported field name type: %p" % [name]
417
+ end
418
+ q.break
419
+ end
420
+ q << ".build()"
421
+ end
422
+ end
423
+
424
+ # @param buf [String] binary String containing Union memory
425
+ # @param endian [Symbol] byte-order of buf
426
+ def initialize(
427
+ buf: "\0" * self.class.size,
428
+ endian: self.class.default_endian
429
+ )
430
+ @buf = buf
431
+ @endian = endian
432
+ end
433
+
434
+ # freeze the values within the Union
435
+ #
436
+ # This is used to eliminate the pack/unpack performance penalty when
437
+ # accessing multiple members in a read-only Union. By freezing the Union
438
+ # we can avoid packing the existing member when accessing another memeber.
439
+ def freeze
440
+ @frozen = true
441
+ self
442
+ end
443
+
444
+ def frozen?
445
+ @frozen == true
446
+ end
447
+
448
+ # get a member value
449
+ #
450
+ # @param member [Symbol] member name
451
+ # @note only the value for the most recently accessed member is cached
452
+ # @note WARNING: accessing any member will erase any modifications made to
453
+ # other members of the union
454
+ #
455
+ # @example
456
+ # include CTypes::Helpers
457
+ # t = union(word: uint32, bytes: array(uint8, 4), str: string)
458
+ # u = t.unpack("hello world")
459
+ # u[:str] # => "hello world"
460
+ # u[:word] # => 1819043176
461
+ # u[:bytes] # => [104, 101, 108, 108]
462
+ #
463
+ # @example nested struct
464
+ # include CTypes::Helpers
465
+ # t = union(a: struct(a: uint8, b: uint8, c: uint16), raw: string)
466
+ # u = t.unpack("\x01\x02\xed\xfe")
467
+ # u.a # => #<struct a=1, b=2, c=0xfeed>
468
+ #
469
+ # @example ERROR: wiping modified member value by accident
470
+ # include CTypes::Helpers
471
+ # t = union(word: uint32, bytes: array(uint8, 4))
472
+ # u = t.new
473
+ # u[:bytes] = [1,2,3]
474
+ # u[:word] # ERROR!!! erases changes to bytes
475
+ # u[:bytes] # => [0, 0, 0, 0]
476
+ #
477
+ def [](name)
478
+ v = active_field(name)[name]
479
+ unless frozen? ||
480
+ v.is_a?(Integer) ||
481
+ v.is_a?(TrueClass) ||
482
+ v.is_a?(FalseClass)
483
+ @changed = true
484
+ end
485
+ v
486
+ end
487
+
488
+ # set a member value
489
+ # @param member [Symbol] member name
490
+ # @param value member value
491
+ # @note WARNING: modifying any member will erase any modifications made to
492
+ # other members of the union
493
+ #
494
+ # @example
495
+ # include CTypes::Helpers
496
+ # t = union(word: uint32, bytes: array(uint8, 4), str: string)
497
+ # u = t.new
498
+ # u[:bytes] = [1,2,3,4]
499
+ # u.to_binstr # => "\x01\x02\x03\x04"
500
+ # u[:bytes] = [1,2,3]
501
+ # u.to_binstr # => "\x01\x02\x03\x00"
502
+ def []=(name, value)
503
+ raise FrozenError, "can't modify frozen Union: %p" % [self] if frozen?
504
+ active_field(name)[name] = value
505
+ @changed = true
506
+ end
507
+
508
+ def has_key?(name)
509
+ self.class.has_field?(name)
510
+ end
511
+
512
+ # return a Hash representation of the Union
513
+ # @param shallow [Boolean] set to true to disable deep traversal
514
+ # @return [Hash]
515
+ #
516
+ # @example
517
+ # include CTypes::Helpers
518
+ # t = union(bytes: array(uint8, 4),
519
+ # str: string,
520
+ # nested: struct(a: uint8, b: uint8, c: uint16))
521
+ # u = t.unpack("hello world")
522
+ # u.to_h # => {:bytes=>[104, 101, 108, 108],
523
+ # # :str=>"hello world",
524
+ # # :nested=>{
525
+ # # :a=>104, :b=>101, :c=>27756
526
+ # # }}
527
+ def to_h(shallow: false)
528
+ # grab the cached active field, or the default one
529
+ out = @active_field || active_field
530
+ out = out.is_a?(Hash) ? out.dup : out.to_h
531
+
532
+ # now convert all the values to hashes unless we're doing a shallow to_h
533
+ unless shallow
534
+ out.transform_values! do |v|
535
+ case v
536
+ when ::Array
537
+ v
538
+ else
539
+ v.respond_to?(:to_h) ? v.to_h : v
540
+ end
541
+ end
542
+ end
543
+
544
+ out
545
+ end
546
+ alias_method :to_hash, :to_h
547
+
548
+ def pretty_print(q) # :nodoc:
549
+ # before printing, apply any changes to the buffer
550
+ apply_changes!
551
+
552
+ active = to_h
553
+
554
+ open = if (name = self.class.type_name || self.class.name)
555
+ "union #{name} {"
556
+ else
557
+ "union {"
558
+ end
559
+ q.group(4, open, "}") do
560
+ q.seplist(self.class.fields, -> { q.breakable("") }) do |name|
561
+ q.text(".#{name} = ")
562
+ unless active.has_key?(name)
563
+ begin
564
+ v = self.class.unpack_field(field: name, buf: @buf, endian: @endian)
565
+ active.merge!(v)
566
+ rescue Error => ex
567
+ active[name] = "[unpack failed: %p]" % [ex]
568
+ end
569
+ end
570
+
571
+ q.pp(active[name])
572
+ q.text(", ")
573
+ end
574
+ end
575
+ end
576
+ alias_method :inspect, :pretty_inspect # :nodoc:
577
+
578
+ # return the binary representation of this Union
579
+ #
580
+ # This method calls [Union.pack] on the most recentlu accessed member of
581
+ # the Union. If no member has been accessed, it returns the original
582
+ # String it was initialized with
583
+ #
584
+ # @return [String] binary representation of union
585
+ #
586
+ # @example
587
+ # include CTypes::Helpers
588
+ # t = union(word: uint32, bytes: array(uint8, 4), str: string)
589
+ # u = t.new
590
+ # u.bytes = [1,2,3,4]
591
+ # u.to_binstr # => "\x01\x02\x03\x04"
592
+ #
593
+ # @example accessing member after modifying other member resets members
594
+ # include CTypes::Helpers
595
+ # t = union(word: uint32, bytes: array(uint8, 4), str: string)
596
+ # u = t.new
597
+ # u.bytes = [1,2,3,4]
598
+ # u.to_binstr # => "\x01\x02\x03\x04"
599
+ # u.word # => ERROR: resets changes to .bytes
600
+ # u.to_binstr # => "\x00\x00\x00\x00"
601
+ def to_binstr(endian: @endian)
602
+ endian ||= self.class.default_endian
603
+
604
+ if endian != @endian
605
+ self.class.pack((@active_field || active_field).to_h, endian:)
606
+ else
607
+ apply_changes!
608
+ @buf.dup
609
+ end
610
+ end
611
+
612
+ # @api private
613
+ def active_field(name = nil)
614
+ name ||= self.class.fields.first
615
+
616
+ unless @active_field&.has_key?(name)
617
+ apply_changes!
618
+ @active_field = nil
619
+ @active_field = self.class
620
+ .unpack_field(field: name, buf: @buf, endian: @endian)
621
+ end
622
+ @active_field
623
+ end
624
+ private :active_field
625
+
626
+ # @api private
627
+ def apply_changes!
628
+ return false if frozen?
629
+ return false unless @active_field && @changed
630
+ @buf = self.class.pack(@active_field, pad_bytes: @buf)
631
+ @changed = false
632
+ true
633
+ end
634
+ private :apply_changes!
635
+ end
636
+ end
637
+ require_relative "union/builder"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: 2025 Cisco
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ module CTypes
7
+ VERSION = "0.2.0"
8
+ end