iostruct 0.5.0 → 0.6.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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IOStruct
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/iostruct.rb CHANGED
@@ -1,100 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module IOStruct
3
+ require_relative 'iostruct/pack_fmt'
4
+ require_relative 'iostruct/hash_fmt'
4
5
 
5
- # https://apidock.com/ruby/String/unpack
6
- FMTSPEC = {
7
- 'C' => [1, Integer ], # 8-bit unsigned (unsigned char)
8
- 'S' => [2, Integer ], # 16-bit unsigned, native endian (uint16_t)
9
- 'I' => [4, Integer ], # 32-bit unsigned, native endian (uint32_t)
10
- 'L' => [4, Integer ], # 32-bit unsigned, native endian (uint32_t)
11
- 'Q' => [8, Integer ], # 64-bit unsigned, native endian (uint64_t)
12
-
13
- 'c' => [1, Integer ], # 8-bit signed (signed char)
14
- 's' => [2, Integer ], # 16-bit signed, native endian (int16_t)
15
- 'i' => [4, Integer ], # 32-bit signed, native endian (int32_t)
16
- 'l' => [4, Integer ], # 32-bit signed, native endian (int32_t)
17
- 'q' => [8, Integer ], # 64-bit signed, native endian (int64_t)
18
-
19
- 'n' => [2, Integer ], # 16-bit unsigned, network (big-endian) byte order
20
- 'N' => [4, Integer ], # 32-bit unsigned, network (big-endian) byte order
21
- 'v' => [2, Integer ], # 16-bit unsigned, VAX (little-endian) byte order
22
- 'V' => [4, Integer ], # 32-bit unsigned, VAX (little-endian) byte order
23
-
24
- 'A' => [1, String ], # arbitrary binary string (remove trailing nulls and ASCII spaces)
25
- 'a' => [1, String ], # arbitrary binary string
26
- 'Z' => [1, String ], # arbitrary binary string (remove trailing nulls)
27
- 'H' => [1, String ], # hex string (high nibble first)
28
- 'h' => [1, String ], # hex string (low nibble first)
29
-
30
- 'D' => [8, Float ], # double-precision, native format
31
- 'd' => [8, Float ],
32
- 'F' => [4, Float ], # single-precision, native format
33
- 'f' => [4, Float ],
34
- 'E' => [8, Float ], # double-precision, little-endian byte order
35
- 'e' => [4, Float ], # single-precision, little-endian byte order
36
- 'G' => [8, Float ], # double-precision, network (big-endian) byte order
37
- 'g' => [4, Float ], # single-precision, network (big-endian) byte order
38
-
39
- 'x' => [1, nil ], # skip forward one byte
40
- }.freeze
41
-
42
- FieldInfo = Struct.new :type, :size, :offset
43
-
44
- def self.new fmt, *names, inspect: :hex, inspect_name_override: nil, **renames
45
- fields, size = parse_format(fmt, names)
46
- names = auto_names(fields, size) if names.empty?
47
- names.map!{ |n| renames[n] || n } if renames.any?
48
-
49
- Struct.new( *names ).tap do |x|
50
- x.const_set 'FIELDS', names.zip(fields).to_h
51
- x.const_set 'FORMAT', fmt
52
- x.const_set 'SIZE', size
53
- x.extend ClassMethods
54
- x.include InstanceMethods
55
- x.include HexInspect if inspect == :hex
56
- x.define_singleton_method(:name) { inspect_name_override } if inspect_name_override
6
+ module IOStruct
7
+ extend PackFmt
8
+ extend HashFmt
9
+
10
+ # rubocop:disable Lint/StructNewOverride
11
+ FieldInfo = Struct.new :type, :size, :offset, :count, :fmt
12
+ # rubocop:enable Lint/StructNewOverride
13
+
14
+ def self.new fmt = nil, *names, inspect: :hex, inspect_name_override: nil, struct_name: nil, **kwargs
15
+ struct_name ||= inspect_name_override # XXX inspect_name_override is deprecated
16
+ if fmt
17
+ renames = kwargs
18
+ finfos, size = parse_pack_format(fmt, names)
19
+ names = auto_names(finfos, size) if names.empty?
20
+ names.map! { |n| renames[n] || n } if renames.any?
21
+ elsif kwargs[:fields]
22
+ fmt, names, finfos, size = parse_hash_format(name: struct_name, **kwargs)
23
+ else
24
+ raise "IOStruct: no fmt and no :fields specified"
57
25
  end
58
- end # self.new
59
26
 
60
- def self.parse_format(fmt, names)
61
- offset = 0
62
- fields = []
63
- fmt.scan(/([a-z])(\d*)/i).map do |type,len|
64
- size, klass = FMTSPEC[type] || raise("Unknown field type #{type.inspect}")
65
- len = len.empty? ? 1 : len.to_i
66
- case type
67
- when 'A', 'a', 'x', 'Z'
68
- fields << FieldInfo.new(klass, size*len, offset) if klass
69
- offset += len
70
- when 'H', 'h'
71
- # XXX ruby's String#unpack length for hex strings is in characters, not bytes, i.e. "x".unpack("H2") => ["78"]
72
- fields << FieldInfo.new(klass, size*len/2, offset) if klass
73
- offset += len/2
27
+ # if first argument to Struct.new() is a string - it creates a named struct in the Struct:: namespace
28
+ # convert all just for the case
29
+ names = names.map(&:to_sym)
30
+
31
+ Struct.new( *names ) do
32
+ const_set 'FIELDS', names.zip(finfos).to_h
33
+ const_set 'FORMAT', fmt
34
+ const_set 'SIZE', size
35
+ extend ClassMethods
36
+ include InstanceMethods
37
+ include NestedInstanceMethods if finfos.any?(&:fmt)
38
+ if inspect == :hex
39
+ include HexInspect
74
40
  else
75
- len.times do |i|
76
- fields << FieldInfo.new(klass, size, offset)
77
- offset += size
78
- end
41
+ include DecInspect
79
42
  end
43
+ define_singleton_method(:to_s) { struct_name } if struct_name
44
+ define_singleton_method(:name) { struct_name } if struct_name
80
45
  end
81
- [fields, offset]
82
- end
46
+ end # self.new
83
47
 
84
- def self.auto_names fields, size
48
+ def self.auto_names(fields, _size)
85
49
  names = []
86
50
  offset = 0
87
51
  fields.each do |f|
88
52
  names << sprintf("f%x", offset).to_sym
89
53
  offset += f.size
90
54
  end
91
- #raise "size mismatch: #{size} != #{offset}" if size != offset
92
55
  names
93
56
  end
94
57
 
95
58
  module ClassMethods
96
59
  # src can be IO or String, or anything that responds to :read or :unpack
97
- def read src, size = nil
60
+ def read(src, size = nil)
98
61
  pos = nil
99
62
  size ||= const_get 'SIZE'
100
63
  data =
@@ -106,18 +69,15 @@ module IOStruct
106
69
  else
107
70
  raise "[?] don't know how to read from #{src.inspect}"
108
71
  end
109
- # if data.size < size
110
- # $stderr.puts "[!] #{self.to_s} want #{size} bytes, got #{data.size}"
111
- # end
112
- new(*data.unpack(const_get('FORMAT'))).tap{ |x| x.__offset = pos }
72
+ new(*data.unpack(const_get('FORMAT'))).tap { |x| x.__offset = pos }
113
73
  end
114
74
 
115
- def size
116
- self::SIZE
75
+ def name
76
+ 'struct'
117
77
  end
118
78
 
119
- def name
120
- self.to_s
79
+ def size
80
+ self::SIZE
121
81
  end
122
82
  end # ClassMethods
123
83
 
@@ -129,7 +89,7 @@ module IOStruct
129
89
  end
130
90
 
131
91
  def empty?
132
- to_a.all?{ |t| t == 0 || t.nil? || t.to_s.tr("\x00","").empty? }
92
+ to_a.all? { |t| t == 0 || t.nil? || t.to_s.tr("\x00", "").empty? }
133
93
  end
134
94
 
135
95
  # allow initializing individual struct members by name, like:
@@ -140,7 +100,7 @@ module IOStruct
140
100
  def initialize *args
141
101
  if args.size == 1 && args.first.is_a?(Hash)
142
102
  super()
143
- args.first.each do |k,v|
103
+ args.first.each do |k, v|
144
104
  send "#{k}=", v
145
105
  end
146
106
  else
@@ -149,35 +109,94 @@ module IOStruct
149
109
  end
150
110
  end # InstanceMethods
151
111
 
152
- module HexInspect
153
- def to_s
154
- "<#{self.class.name} " + to_h.map do |k, v|
155
- if v.is_a?(Integer) && v > 9
156
- "#{k}=0x%x" % v
157
- else
158
- "#{k}=#{v.inspect}"
112
+ # initialize nested structures / arrays
113
+ module NestedInstanceMethods
114
+ def initialize *args
115
+ super
116
+ self.class::FIELDS.each do |k, v|
117
+ next unless v.fmt
118
+
119
+ if (value = self[k])
120
+ self[k] = v.fmt.is_a?(String) ? value.unpack(v.fmt) : v.fmt.read(value)
159
121
  end
160
- end.join(' ') + ">"
122
+ end
123
+ end
124
+
125
+ def pack
126
+ values = self.class::FIELDS.map do |k, v|
127
+ value = self[k]
128
+ next value unless v&.fmt && value
129
+
130
+ # Reverse the unpacking done in initialize
131
+ v.fmt.is_a?(String) ? value.pack(v.fmt) : value.pack
132
+ end
133
+ values.pack self.class.const_get('FORMAT')
161
134
  end
135
+ end
162
136
 
137
+ module InspectBase
138
+ INT_MASKS = { 1 => 0xff, 2 => 0xffff, 4 => 0xffffffff, 8 => 0xffffffffffffffff }.freeze
139
+
140
+ # rubocop:disable Lint/DuplicateBranch
163
141
  def to_table
164
- @fmtstr_tbl = "<#{self.class.name} " + self.class.const_get('FIELDS').map do |name, f|
165
- fmt =
166
- case
142
+ values = to_a
143
+ "<#{self.class.name} " + self.class::FIELDS.map.with_index do |el, idx|
144
+ v = values[idx]
145
+ fname, f = el
146
+
147
+ "#{fname}=" +
148
+ case
149
+ when f.nil? # unknown field type
150
+ v.inspect
167
151
  when f.type == Integer
168
- "%#{f.size*2}x"
152
+ v ||= 0 # avoid "`sprintf': can't convert nil into Integer" error
153
+ format_integer(v, f.size, fname)
169
154
  when f.type == Float
170
- "%8.3f"
155
+ v ||= 0 # avoid "`sprintf': can't convert nil into Float" error
156
+ "%8.3f" % v
171
157
  else
172
- "%s"
158
+ v.inspect
173
159
  end
174
- "#{name}=#{fmt}"
175
160
  end.join(' ') + ">"
176
- sprintf @fmtstr_tbl, *to_a.map{ |v| v.is_a?(String) ? v.inspect : (v||0) } # "||0" to avoid "`sprintf': can't convert nil into Integer" error
161
+ end
162
+ # rubocop:enable Lint/DuplicateBranch
163
+ end
164
+
165
+ module DecInspect
166
+ include InspectBase
167
+
168
+ DEC_FMTS = { 1 => "%4d", 2 => "%6d", 4 => "%11d", 8 => "%20d" }.freeze
169
+
170
+ def format_integer(value, size, fname)
171
+ fmt = DEC_FMTS[size] || raise("Unsupported Integer size #{size} for field #{fname}")
172
+ fmt % value
173
+ end
174
+ end
175
+
176
+ module HexInspect
177
+ include InspectBase
178
+
179
+ HEX_FMTS = { 1 => "%2x", 2 => "%4x", 4 => "%8x", 8 => "%16x" }.freeze
180
+
181
+ def to_s
182
+ "<#{self.class.name} " + to_h.map do |k, v|
183
+ if v.is_a?(Integer) && v > 9
184
+ "#{k}=0x%x" % v
185
+ else
186
+ "#{k}=#{v.inspect}"
187
+ end
188
+ end.join(' ') + ">"
177
189
  end
178
190
 
179
191
  def inspect
180
192
  to_s
181
193
  end
194
+
195
+ # display as unsigned, because signed %x looks ugly: "..f" for -1
196
+ def format_integer(value, size, fname)
197
+ fmt = HEX_FMTS[size] || raise("Unsupported Integer size #{size} for field #{fname}")
198
+ mask = INT_MASKS[size] || raise("Unsupported Integer size #{size} for field #{fname}")
199
+ fmt % (value & mask)
200
+ end
182
201
  end
183
202
  end # IOStruct
data/spec/.rubocop.yml ADDED
@@ -0,0 +1,27 @@
1
+ inherit_from: ../.rubocop.yml
2
+
3
+
4
+ Layout/SpaceInsideParens:
5
+ Enabled: false
6
+
7
+
8
+ Style/FrozenStringLiteralComment:
9
+ Enabled: false
10
+
11
+ Style/SymbolArray:
12
+ Enabled: false
13
+
14
+ RSpec/ContextWording:
15
+ Enabled: false
16
+
17
+ RSpec/MultipleExpectations:
18
+ Enabled: false
19
+
20
+ RSpec/ExampleLength:
21
+ Enabled: false
22
+
23
+ RSpec/ExampleWording:
24
+ Enabled: false
25
+
26
+ RSpec/SpecFilePathFormat:
27
+ Enabled: false
@@ -0,0 +1,215 @@
1
+ require 'spec_helper'
2
+ require 'stringio'
3
+
4
+ describe IOStruct do
5
+ describe ".get_type_size" do
6
+ it "returns size for known types" do
7
+ expect(described_class.get_type_size('int')).to eq 4
8
+ expect(described_class.get_type_size('char')).to eq 1
9
+ expect(described_class.get_type_size('short')).to eq 2
10
+ expect(described_class.get_type_size('long long')).to eq 8
11
+ expect(described_class.get_type_size('double')).to eq 8
12
+ expect(described_class.get_type_size('float')).to eq 4
13
+ end
14
+
15
+ it "works with symbol input" do
16
+ expect(described_class.get_type_size(:int)).to eq 4
17
+ end
18
+
19
+ it "returns nil for unknown types" do
20
+ expect(described_class.get_type_size('unknown')).to be_nil
21
+ end
22
+ end
23
+
24
+ describe "hash format ctor" do
25
+ it "works" do
26
+ klass = described_class.new(
27
+ fields: {
28
+ x: "int",
29
+ y: :int,
30
+ z: { type: :int },
31
+ },
32
+ struct_name: 'Point'
33
+ )
34
+ expect(klass.new.inspect).to match(/<Point x=nil y=nil z=nil>/)
35
+ expect(klass.size).to eq(12)
36
+ expect(klass::SIZE).to eq(12)
37
+ end
38
+
39
+ it "respects :name" do
40
+ klass = described_class.new(
41
+ fields: {
42
+ x: "int",
43
+ y: :int,
44
+ z: { type: :int },
45
+ },
46
+ struct_name: 'Point'
47
+ )
48
+ expect(klass.new.inspect).to match(/<Point x=nil y=nil z=nil>/)
49
+ end
50
+
51
+ it "respects :offset" do
52
+ klass = described_class.new(
53
+ struct_name: 'Point',
54
+ fields: {
55
+ x: "int",
56
+ y: :int,
57
+ z: { type: :int, offset: 0x10 },
58
+ }
59
+ )
60
+ expect(klass.new.inspect).to match(/<Point x=nil y=nil z=nil>/)
61
+ expect(klass.size).to eq(0x14)
62
+
63
+ obj = klass.read((0..0x20).to_a.pack('i*'))
64
+ expect(obj.x).to eq(0)
65
+ expect(obj.y).to eq(1)
66
+ expect(obj.z).to eq(4)
67
+ end
68
+
69
+ context 'when two fields have same offset' do
70
+ it 'fails' do
71
+ expect do
72
+ described_class.new(
73
+ struct_name: 'Point',
74
+ fields: {
75
+ x: { type: :int, offset: 0 },
76
+ y: { type: :char, offset: 0 },
77
+ }
78
+ )
79
+ end.to raise_error(RuntimeError)
80
+ end
81
+ end
82
+
83
+ it "can override size" do
84
+ klass = described_class.new(
85
+ fields: {
86
+ x: "int",
87
+ y: :int,
88
+ z: { type: 'int' },
89
+ },
90
+ size: 0x100
91
+ )
92
+ expect(klass.size).to eq(0x100)
93
+ end
94
+
95
+ it "supports arrays" do
96
+ klass = described_class.new(
97
+ fields: {
98
+ x: "int",
99
+ a: { type: 'int', count: 3 },
100
+ y: :int,
101
+ },
102
+ )
103
+ expect(klass.size).to eq(4 * 5)
104
+
105
+ v = klass.read([1, 2, 3, 4, 5, 6, 7].pack('i*'))
106
+
107
+ expect(v.x).to eq(1)
108
+ expect(v.a).to eq([2, 3, 4])
109
+ expect(v.y).to eq(5)
110
+ end
111
+
112
+ it "supports nesting" do
113
+ point = described_class.new( fields: { x: "int", y: :int } )
114
+ rect = described_class.new(
115
+ struct_name: 'Rect',
116
+ fields: {
117
+ topLeft: point,
118
+ bottomRight: point,
119
+ }
120
+ )
121
+ expect(rect.size).to eq(16)
122
+
123
+ r = rect.read([10, 20, 100, 200].pack('i*'))
124
+ expect(r.topLeft).to be_instance_of(point)
125
+ expect(r.bottomRight).to be_instance_of(point)
126
+ expect(r.topLeft.x).to eq(10)
127
+ expect(r.topLeft.y).to eq(20)
128
+ expect(r.bottomRight.x).to eq(100)
129
+ expect(r.bottomRight.y).to eq(200)
130
+ end
131
+
132
+ it "packs nested structs" do
133
+ point = described_class.new(fields: { x: "int", y: :int })
134
+ rect = described_class.new(fields: { topLeft: point, bottomRight: point })
135
+
136
+ r = rect.read([10, 20, 100, 200].pack('i*'))
137
+ packed = r.pack
138
+ reparsed = rect.read(packed)
139
+
140
+ expect(reparsed.topLeft.x).to eq 10
141
+ expect(reparsed.topLeft.y).to eq 20
142
+ expect(reparsed.bottomRight.x).to eq 100
143
+ expect(reparsed.bottomRight.y).to eq 200
144
+ end
145
+
146
+ it "packs arrays" do
147
+ klass = described_class.new(
148
+ fields: {
149
+ x: "int",
150
+ a: { type: 'int', count: 3 },
151
+ y: :int,
152
+ }
153
+ )
154
+
155
+ v = klass.read([1, 2, 3, 4, 5].pack('i*'))
156
+ packed = v.pack
157
+ reparsed = klass.read(packed)
158
+
159
+ expect(reparsed.x).to eq 1
160
+ expect(reparsed.a).to eq [2, 3, 4]
161
+ expect(reparsed.y).to eq 5
162
+ end
163
+
164
+ context "error handling" do
165
+ it "raises on unknown field type" do
166
+ expect do
167
+ described_class.new(fields: { x: "unknown_type" })
168
+ end.to raise_error(/unknown field type/)
169
+ end
170
+
171
+ it "raises on invalid type format" do
172
+ expect do
173
+ described_class.new(fields: { x: 12345 })
174
+ end.to raise_error(/unexpected field desc type/)
175
+ end
176
+
177
+ it "raises when forced size is smaller than actual" do
178
+ expect do
179
+ described_class.new(
180
+ fields: { x: "int", y: "int", z: "int" },
181
+ size: 4
182
+ )
183
+ end.to raise_error(/actual struct size .* is greater than forced size/)
184
+ end
185
+ end
186
+
187
+ context "C type aliases" do
188
+ it "supports uint types" do
189
+ klass = described_class.new(fields: {
190
+ a: 'uint8_t',
191
+ b: 'uint16_t',
192
+ c: 'uint32_t',
193
+ d: 'uint64_t'
194
+ })
195
+ expect(klass.size).to eq(1 + 2 + 4 + 8)
196
+ end
197
+
198
+ it "supports int types" do
199
+ klass = described_class.new(fields: {
200
+ a: 'int8_t',
201
+ b: 'int16_t',
202
+ c: 'int32_t',
203
+ d: 'int64_t'
204
+ })
205
+ expect(klass.size).to eq(1 + 2 + 4 + 8)
206
+ end
207
+
208
+ it "supports _BYTE type" do
209
+ klass = described_class.new(fields: { a: '_BYTE' })
210
+ expect(klass.size).to eq 1
211
+ expect(klass.read("\xff").a).to eq 255 # unsigned
212
+ end
213
+ end
214
+ end
215
+ end