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,529 @@
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 is used to represent c structures in ruby. It provides methods
9
+ # for converting structs between their byte representation and a ruby
10
+ # representation that can be modified.
11
+ #
12
+ # @note fields are not automatically aligned based on size; if there are gaps
13
+ # present between c struct fields, you'll need to manually add padding in
14
+ # the layout to reflect that alignment.
15
+ #
16
+ # @example working with a Type-Length-Value (TLV) struct
17
+ # # encoding: ASCII-8BIT
18
+ #
19
+ # # subclass Struct to define a structure
20
+ # class TLV < CTypes::Struct
21
+ # # define structure layout
22
+ # layout do
23
+ # attribute :type, enum(uint8, %i[hello read write bye])
24
+ # attribute :len, uint16.with_endian(:big)
25
+ # attribute :value, string
26
+ # size { |struct| offsetof(:value) + struct[:len] }
27
+ # end
28
+ #
29
+ # # add any class or instance methods if needed
30
+ # end
31
+ #
32
+ # # pack the struct into bytes
33
+ # bytes = TLV.pack({type: :hello, len: 5, value: "world"})
34
+ # # => "\0\0\5world"
35
+ #
36
+ # # unpack bytes into a struct instance
37
+ # t = TLV.unpack("\0\0\5world")
38
+ # # => #<TLV type=:hello len=5 value="world">
39
+ #
40
+ # # access struct fields
41
+ # t.value # => "world"
42
+ #
43
+ # # update struct fields, then convert back into bytes
44
+ # t.type = :bye
45
+ # t.value = "goodbye"
46
+ # t.len = t.value.size
47
+ # t.to_binstr # => "\3\0\7goodbye"
48
+ #
49
+ # @example nested structs
50
+ # class Attribute < CTypes::Struct
51
+ # layout do
52
+ # attribute :base, uint8
53
+ # attribute :mod, int8
54
+ # end
55
+ # end
56
+ # class Character < CTypes::Struct
57
+ # layout do
58
+ # attribute :str, Attribute
59
+ # attribute :int, Attribute
60
+ # attribute :wis, Attribute
61
+ # attribute :dex, Attribute
62
+ # attribute :con, Attribute
63
+ # end
64
+ # end
65
+ #
66
+ # ch = Character.new
67
+ # ch.str.base = 18
68
+ # ch.int.base = 8
69
+ # ch.wis.base = 3
70
+ # ch.dex.base = 13
71
+ # ch.con.base = 16
72
+ # ch.to_binstr # => "\x12\x00\x08\x00\x03\x00\x0d\x00\x10\x00"
73
+ # ch.str.mod -= 3
74
+ # ch.to_binstr # => "\x12\xFD\x08\x00\x03\x00\x0d\x00\x10\x00"
75
+ #
76
+ class Struct
77
+ extend Type
78
+ using PrettyPrintHelpers
79
+
80
+ def self.builder
81
+ Builder.new
82
+ end
83
+
84
+ # define the layout for this structure
85
+ # @see Builder
86
+ #
87
+ # @example type-length-value (TLV) struct
88
+ # class TLV < CTypes::Struct
89
+ # layout do
90
+ # attribute :type, uint16
91
+ # attribute :len, uint16
92
+ # attribute :value, string
93
+ # size { |s| offsetof(:len) + s.len }
94
+ # end
95
+ # end
96
+ def self.layout(&block)
97
+ raise Error, "no block given" unless block
98
+ builder = Builder.new
99
+ builder.instance_eval(&block)
100
+ apply_layout(builder)
101
+ end
102
+
103
+ def self.apply_layout(builder) # :nodoc:
104
+ # reset the state of the struct
105
+ @offsets = nil
106
+ @greedy = false
107
+
108
+ @name, @fields, @dry_type, @size, @endian = builder.result
109
+
110
+ @field_accessors ||= {}
111
+ remove_method(*@field_accessors.values.flatten)
112
+ @field_accessors.clear
113
+
114
+ @fields.each do |name, type|
115
+ # the struct will be flagged as greedy if size is not defined by a
116
+ # Proc, and the field type is greedy
117
+ @greedy ||= type.greedy? unless @size.is_a?(Proc)
118
+
119
+ case name
120
+ when Symbol
121
+ @field_accessors[name] = attr_accessor(name)
122
+ when ::Array
123
+ name.each { |n| @field_accessors[n] = attr_accessor(n) }
124
+ when Pad
125
+ # no op
126
+ else
127
+ raise Error, "unsupported field name type: %p", name
128
+ end
129
+ end
130
+ end
131
+ private_class_method :apply_layout
132
+
133
+ # encode a ruby Hash into a String containing the binary representation of
134
+ # the c type
135
+ #
136
+ # @param value [Hash] value to be encoded
137
+ # @param endian [Symbol] endian to pack with
138
+ # @param validate [Boolean] set to false to disable value validation
139
+ # @return [::String] binary encoding for value
140
+ #
141
+ # @example pack with default values
142
+ # include CTypes::Helpers
143
+ # t = struct(id: uint32, value: uint32)
144
+ # t.pack({}) # => "\0\0\0\0\0\0\0\0"
145
+ #
146
+ # @example pack with some fields
147
+ # include CTypes::Helpers
148
+ # t = struct(id: uint32, value: uint32)
149
+ # t.pack({value: 0xfefefefe}) # => "\x00\x00\x00\x00\xfe\xfe\xfe\xfe"
150
+ #
151
+ # @example pack with all fields
152
+ # include CTypes::Helpers
153
+ # t = struct(id: uint32, value: uint32)
154
+ # t.pack({id: 1, value: 2}) # => "\1\0\0\0\2\0\0\0"
155
+ #
156
+ # @example pack with nested struct
157
+ # include CTypes::Helpers
158
+ # t = struct do
159
+ # attribute :id, uint32
160
+ # attribute :a, struct(base: uint8, mod: uint8)
161
+ # end
162
+ # t.pack({id: 1, a: {base: 2, mod: 3}}) # => "\1\0\0\0\2\3"
163
+ #
164
+ def self.pack(value, endian: default_endian, validate: true)
165
+ value = value.to_hash.freeze
166
+ value = @dry_type[value] unless validate == false
167
+ buf = ::String.new
168
+ @fields.each do |(name, type)|
169
+ case name
170
+ when Pad
171
+ buf << type.pack(nil)
172
+ when Symbol
173
+ buf << type.pack(value[name],
174
+ endian: type.endian || endian,
175
+ validate: false)
176
+ when ::Array
177
+ buf << type.pack(value.slice(*name),
178
+ endian: type.endian || endian,
179
+ validate: false)
180
+ else
181
+ raise Error, "unsupported field name type: %p" % [name]
182
+ end
183
+ end
184
+
185
+ return buf if fixed_size? || @size.nil?
186
+
187
+ size = instance_exec(value, &@size)
188
+ if size > buf.size
189
+ buf << "\0" * (size - buf.size)
190
+ elsif size < buf.size
191
+ buf[0, size]
192
+ else
193
+ buf
194
+ end
195
+ end
196
+
197
+ # convert a String containing the binary represention of a c struct into
198
+ # a ruby type
199
+ #
200
+ # @param buf [String] bytes that make up the type
201
+ # @param endian [Symbol] endian of data within buf
202
+ # @return [::Array(Struct, ::String)] decoded struct, and remaining bytes
203
+ #
204
+ # @see Type#unpack
205
+ #
206
+ # @example
207
+ # class TLV < CTypes::Struct
208
+ # layout do
209
+ # attribute :type, enum(uint8, %i[hello, read, write, bye])
210
+ # attribute :len, uint16.with_endian(:big)
211
+ # attribute :value, string
212
+ # size { |struct| offsetof(:value) + struct[:len] }
213
+ # end
214
+ # end
215
+ # TLV.unpack_one("\0\0\5helloextra")
216
+ # # => [#<TLV type=:hello len=5 value="hello">, "extra"]
217
+ #
218
+ def self.unpack_one(buf, endian: default_endian)
219
+ rest = buf
220
+ trimmed = nil # set to the unused portion of buf when we have @size
221
+ out = _new # output structure instance
222
+ out.instance_variable_set(:@endian, endian)
223
+
224
+ @fields.each do |(name, type)|
225
+ # if the type is greedy, and we have a dynamic size, and we haven't
226
+ # already trimmed the input buffer, let's do so now.
227
+ #
228
+ # note: we do this while unpacking because the @size proc may require
229
+ # some of the unpacked fields to determine the size of the struct such
230
+ # as in TLV structs
231
+ if type.greedy? && @size && !trimmed
232
+
233
+ # caluclate the total size of the struct from the decoded fields
234
+ size = instance_exec(out, &@size)
235
+ raise missing_bytes_error(input: buf, need: size) if
236
+ size > buf.size
237
+
238
+ # adjust the size for how much we've already unpacked
239
+ size -= offsetof(name.is_a?(Array) ? name[0] : name)
240
+
241
+ # split the remaining buffer; we stick the part we aren't going to
242
+ # use in trimmed, and update rest to point at our buffer
243
+ trimmed = rest.byteslice(size..)
244
+ rest = rest.byteslice(0, size)
245
+ end
246
+
247
+ value, rest = type.unpack_one(rest, endian: type.endian || endian)
248
+ case name
249
+ when Symbol
250
+ out[name] = value
251
+ when ::Array
252
+ name.each { |n| out[n] = value[n] }
253
+ when Pad
254
+ # no op
255
+ else
256
+ raise Error, "unsupported field name type: %p" % [name]
257
+ end
258
+ end
259
+
260
+ [out, trimmed || rest]
261
+ end
262
+
263
+ # get the offset of a field within the structure in bytes
264
+ #
265
+ # @param attr [Symbol] name of the attribute
266
+ # @return [Integer] byte offset
267
+ def self.offsetof(attr)
268
+ @offsets ||= @fields.inject([0, {}]) do |(offset, o), (key, type)|
269
+ o[key] = offset
270
+ [type.size ? offset + type.size : nil, o]
271
+ end.last
272
+
273
+ @offsets[attr]
274
+ end
275
+
276
+ # check if this type is greedy
277
+ #
278
+ # @api private
279
+ def self.greedy?
280
+ @greedy
281
+ end
282
+
283
+ # get the minimum size of the structure
284
+ #
285
+ # For fixed-size structures, this will return the size of the structure.
286
+ # For dynamic length structures, this will return the minimum size of the
287
+ # structure
288
+ #
289
+ # @return [Integer] structure size in bytes
290
+ def self.size
291
+ return @size if @size.is_a?(Integer)
292
+
293
+ @min_size ||= @fields&.inject(0) { |s, (_, t)| s + t.size } || 0
294
+ end
295
+
296
+ # check if the struct has a given attribute
297
+ # @param k [Symbol] attribute name
298
+ def self.has_field?(k)
299
+ @field_accessors.has_key?(k)
300
+ end
301
+
302
+ # return the list of fields in this structure
303
+ # @api.private
304
+ def self.fields
305
+ @field_accessors.keys
306
+ end
307
+
308
+ # return the list of fields with their associated types
309
+ # @api.private
310
+ def self.field_layout
311
+ @fields
312
+ end
313
+
314
+ # return the struct name if supplied
315
+ # @api.private
316
+ def self.type_name
317
+ @name
318
+ end
319
+
320
+ def self.pretty_print(q) # :nodoc:
321
+ q.ctype("struct", @endian) do
322
+ q.line("name %p" % [@name]) if @name
323
+ q.seplist(@fields, -> { q.breakable(";") }) do |name, type|
324
+ case name
325
+ when Symbol
326
+ q.text("attribute %p, " % name)
327
+ q.pp(type)
328
+ when ::Array
329
+ q.text("attribute ")
330
+ q.pp(type)
331
+ when Pad
332
+ q.pp(type)
333
+ else
334
+ raise Error, "unsupported field name type: %p" % [name]
335
+ end
336
+ end
337
+ end
338
+ end
339
+
340
+ # @api.private
341
+ def self.export_type(q)
342
+ q << "CTypes::Struct.builder()"
343
+ q.break
344
+ q.nest(2) do
345
+ q << ".name(%p)\n" % [@name] if @name
346
+ q << ".endian(%p)\n" % [@endian] if @endian
347
+ @fields.each do |name, type|
348
+ case name
349
+ when Symbol
350
+ q << ".attribute(%p, " % [name]
351
+ q << type
352
+ q << ")"
353
+ q.break
354
+ when ::Array
355
+ q << ".attribute("
356
+ q << type
357
+ q << ")"
358
+ q.break
359
+ when Pad
360
+ q << type
361
+ q.break
362
+ else
363
+ raise Error, "unsupported field name type: %p" % [name]
364
+ end
365
+ end
366
+ q << ".build()"
367
+ end
368
+ end
369
+
370
+ class << self
371
+ alias_method :inspect, :pretty_inspect # :nodoc:
372
+
373
+ # @method _new
374
+ # allocate an uninitialized instance of the struct
375
+ # @return [Struct] uninitialized struct instance
376
+ # @api private
377
+ alias_method :_new, :new
378
+ private :_new
379
+ end
380
+
381
+ # allocate an instance of the Struct and initialize default values
382
+ # @param fields [Hash] values to set
383
+ # @return [Struct]
384
+ def self.new(fields = nil)
385
+ buf = fields.nil? ? ("\0" * size) : pack(fields)
386
+ unpack(buf)
387
+ end
388
+
389
+ # check if another Struct subclass has the same attributes as this Struct
390
+ # @note this method does not handle dynamic sized Structs correctly, but
391
+ # the current implementation is sufficient for testing
392
+ def self.==(other)
393
+ return true if super
394
+ return false unless other.is_a?(Class) && other < Struct
395
+ other.field_layout == @fields &&
396
+ other.default_endian == default_endian &&
397
+ other.size == size
398
+ end
399
+
400
+ # set an attribute value
401
+ # @param k [Symbol] attribute name
402
+ # @param v value
403
+ #
404
+ # @example
405
+ # include CTypes::Helpers
406
+ # t = struct(id: uint32, value: uint32)
407
+ # i = t.new
408
+ # i[:id] = 12
409
+ # i.id # => 12
410
+ # i.id = 55
411
+ # i.id # => 55
412
+ def []=(k, v)
413
+ has_attribute!(k)
414
+ instance_variable_set(:"@#{k}", v)
415
+ end
416
+
417
+ # get an attribute value
418
+ # @param k [Symbol] attribute name
419
+ # @return value
420
+ #
421
+ # @example
422
+ # include CTypes::Helpers
423
+ # t = struct(id: uint32, value: uint32)
424
+ # i = t.new
425
+ # i[:value] = 123
426
+ # i[:value] # => 123
427
+ def [](k)
428
+ has_attribute!(k)
429
+ instance_variable_get(:"@#{k}")
430
+ end
431
+
432
+ # check if the {Struct} has a specific attribute name
433
+ def has_key?(name)
434
+ self.class.has_field?(name)
435
+ end
436
+
437
+ # raise an exception unless {Struct} includes a specific attribute name
438
+ def has_attribute!(name)
439
+ raise UnknownAttributeError, "unknown attribute: %p" % name unless
440
+ self.class.has_field?(name)
441
+ end
442
+ private :has_attribute!
443
+
444
+ # return a Hash representation of the data type
445
+ # @param shallow [Boolean] set to true to disable deep traversal
446
+ # @return [Hash]
447
+ #
448
+ # @example deep
449
+ # include CTypes::Helpers
450
+ # t = struct do
451
+ # attribute :inner, struct(value: uint8)
452
+ # end
453
+ # i = t.new
454
+ # i.inner.value = 5
455
+ # i.to_h # => {inner: {value: 5}}
456
+ #
457
+ # @example shallow
458
+ # include CTypes::Helpers
459
+ # t = struct do
460
+ # attribute :inner, struct(value: uint8)
461
+ # end
462
+ # i = t.new
463
+ # i.inner.value = 5
464
+ # i.to_h(shallow: true) # => {inner: #<Class:0x646456 value=5>}
465
+ def to_h(shallow: false)
466
+ out = {}
467
+ self.class.fields.each do |field|
468
+ value = send(field)
469
+ unless shallow || value.is_a?(::Array) || !value.respond_to?(:to_h)
470
+ value = value.to_h
471
+ end
472
+ out[field] = value
473
+ end
474
+ out
475
+ end
476
+ alias_method :to_hash, :to_h
477
+
478
+ # return the binary representation of this Struct instance
479
+ # @return [String] binary representation of struct
480
+ #
481
+ # @example
482
+ # include CTypes::Helpers
483
+ # t = struct(id: uint32, value: string)
484
+ # i = t.new
485
+ # i.id = 1
486
+ # i.value = "hello"
487
+ # i.to_binstr # => "\1\0\0\0hello"
488
+ def to_binstr(endian: @endian)
489
+ self.class.pack(to_h, endian:)
490
+ end
491
+
492
+ # determine if this instance of the struct is equal to another instance
493
+ #
494
+ # @note this implementation also supports Hash equality through {to_h}
495
+ def ==(other)
496
+ case other
497
+ when self.class
498
+ self.class.field_layout.all? do |field, _|
499
+ instance_variable_get(:"@#{field}") == other[field]
500
+ end
501
+ when Hash
502
+ other == to_h
503
+ else
504
+ super
505
+ end
506
+ end
507
+
508
+ def pretty_print(q) # :nodoc:
509
+ open = if (name = self.class.type_name || self.class.name)
510
+ "struct #{name} {"
511
+ else
512
+ "struct {"
513
+ end
514
+ q.group(4, open, "}") do
515
+ q.seplist(self.class.field_layout, -> { q.breakable("") }) do |name, _|
516
+ names = name.is_a?(::Array) ? name : [name]
517
+ names.each do |name|
518
+ q.text(".#{name} = ")
519
+ q.pp(instance_variable_get(:"@#{name}"))
520
+ q.text(", ")
521
+ end
522
+ end
523
+ end
524
+ end
525
+ alias_method :inspect, :pretty_inspect # :nodoc:
526
+ end
527
+ end
528
+
529
+ require_relative "struct/builder"
@@ -0,0 +1,65 @@
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
+ # Wrap another CTypes::Type to provide terminated implementations of that
9
+ # type. Used by {CTypes::Array} and {CTypes::String} to truncate the buffer
10
+ # passed to the real type to terminate greedy types.
11
+ #
12
+ # During #unpack, this class will locate the terminator in the input buffer,
13
+ # then pass a truncated input to the underlying greedy type for unpacking.
14
+ #
15
+ # During #pack, this class will call the underlying greedy type #pack
16
+ # method, then append the terminator.
17
+ #
18
+ # @api private
19
+ class Terminated
20
+ include Type
21
+
22
+ def initialize(type:, locate:, terminate:)
23
+ @type = type
24
+ @locate = locate
25
+ @term = terminate
26
+ end
27
+
28
+ def dry_type
29
+ @type.dry_type
30
+ end
31
+
32
+ def greedy?
33
+ false
34
+ end
35
+
36
+ def size
37
+ @term_size ||= terminate(@type.default_value.dup,
38
+ endian: default_endian).size
39
+ end
40
+
41
+ def pack(value, endian: default_endian, validate: true)
42
+ buf = @type.pack(value, endian:, validate:)
43
+ terminate(buf, endian:)
44
+ end
45
+
46
+ def unpack_one(buf, endian: default_endian)
47
+ value_size, term_size = @locate.call(buf, endian:)
48
+ if value_size.nil?
49
+ raise TerminatorNotFoundError,
50
+ "terminator not found in: %p" % buf
51
+ end
52
+ value = @type.unpack(buf[0, value_size], endian:)
53
+ [value, buf.byteslice((value_size + term_size)..)]
54
+ end
55
+
56
+ def terminate(buf, endian:)
57
+ buf << case @term
58
+ when Proc
59
+ @term.call(buf, endian)
60
+ else
61
+ @term.to_s
62
+ end
63
+ end
64
+ end
65
+ end