├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .rubocop.yml ├── Gemfile ├── LICENSE ├── README.md ├── kaitai-struct.gemspec ├── lib └── kaitai │ └── struct │ └── struct.rb └── spec ├── kaitaistream_spec.rb └── subio_spec.rb /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [{*.{rb,gemspec},Gemfile}] 10 | indent_style = space 11 | indent_size = 2 12 | max_line_length = 120 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.gemspec text eol=lf 2 | *.rb text eol=lf 3 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: {} 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | ruby-version: 16 | - '3.4' 17 | - '2.4' 18 | - '1.9' 19 | runs-on: ${{ matrix.ruby-version == '1.9' && 'ubuntu-22.04' || 'ubuntu-24.04' }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby-version }} 26 | bundler-cache: true # runs `bundle install` and caches installed gems automatically 27 | - name: Run tests 28 | run: bundle exec rspec --force-color 29 | 30 | lint: 31 | name: Lint 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Set up Ruby 36 | uses: ruby/setup-ruby@v1 37 | with: 38 | ruby-version: '3.4' 39 | bundler-cache: true 40 | - name: Run RuboCop - only warnings and errors 41 | id: rubocop-warnings 42 | run: bundle exec rubocop --color --fail-level warning --display-only-fail-level-offenses 43 | - name: Run RuboCop - all offenses 44 | run: bundle exec rubocop --color 45 | continue-on-error: true 46 | if: ${{ !cancelled() && steps.rubocop-warnings.conclusion != 'skipped' }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/ruby 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=ruby 3 | 4 | ### Ruby ### 5 | *.gem 6 | *.rbc 7 | /.config 8 | /coverage/ 9 | /InstalledFiles 10 | /pkg/ 11 | /spec/reports/ 12 | /spec/examples.txt 13 | /test/tmp/ 14 | /test/version_tmp/ 15 | /tmp/ 16 | 17 | # Used by dotenv library to load environment variables. 18 | # .env 19 | 20 | # Ignore Byebug command history file. 21 | .byebug_history 22 | 23 | ## Specific to RubyMotion: 24 | .dat* 25 | .repl_history 26 | build/ 27 | *.bridgesupport 28 | build-iPhoneOS/ 29 | build-iPhoneSimulator/ 30 | 31 | ## Specific to RubyMotion (use of CocoaPods): 32 | # 33 | # We recommend against adding the Pods directory to your .gitignore. However 34 | # you should judge for yourself, the pros and cons are mentioned at: 35 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 36 | # vendor/Pods/ 37 | 38 | ## Documentation cache and generated files: 39 | /.yardoc/ 40 | /_yardoc/ 41 | /doc/ 42 | /rdoc/ 43 | 44 | ## Environment normalization: 45 | /.bundle/ 46 | /vendor/bundle 47 | /lib/bundler/man/ 48 | 49 | # for a library or gem, you might want to ignore these files since the code is 50 | # intended to run in multiple environments; otherwise, check them in: 51 | Gemfile.lock 52 | .ruby-version 53 | .ruby-gemset 54 | 55 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 56 | .rvmrc 57 | 58 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 59 | # .rubocop-https?--* 60 | 61 | # End of https://www.toptal.com/developers/gitignore/api/ruby 62 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-rspec 3 | 4 | AllCops: 5 | NewCops: enable 6 | TargetRubyVersion: 2.0 7 | 8 | Layout/EndOfLine: 9 | EnforcedStyle: lf 10 | 11 | Metrics/ClassLength: 12 | Enabled: false 13 | 14 | Metrics/MethodLength: 15 | Enabled: false 16 | 17 | Metrics/BlockLength: 18 | Enabled: false 19 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | group :test do 6 | # * https://rubygems.org/gems/rantly/versions/3.0.0 requires Ruby >= 3.3.0 7 | # * https://rubygems.org/gems/rantly/versions/2.0.0 requires Ruby >= 2.4.0 8 | # * https://rubygems.org/gems/rantly/versions/1.2.0 requires Ruby >= 0 9 | gem 'rantly', '>= 1.2.0', '< 4.0.0' 10 | gem 'rspec', '~> 3.13' 11 | end 12 | 13 | group :development do 14 | gem 'rubocop', require: false 15 | gem 'rubocop-rspec', require: false 16 | end 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015-2025 Kaitai Project 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kaitai Struct: runtime library for Ruby 2 | 3 | [](https://rubygems.org/gems/kaitai-struct/) 4 | [](https://rubygems.org/gems/kaitai-struct/#:~:text=TOTAL%20DOWNLOADS) 5 | 6 | This library implements Kaitai Struct API for Ruby. 7 | 8 | Kaitai Struct is a declarative language used for describe various binary 9 | data structures, laid out in files or in memory: i.e. binary file 10 | formats, network stream packet formats, etc. 11 | 12 | Further reading: 13 | 14 | * [About Kaitai Struct](https://kaitai.io/) 15 | * [About API implemented in this library](https://doc.kaitai.io/stream_api.html) 16 | 17 | ## Installing 18 | 19 | ### Using `Gemfile` 20 | 21 | If your project uses Bundler, just include the line 22 | 23 | ``` 24 | gem 'kaitai-struct' 25 | ``` 26 | 27 | in your project's `Gemfile`. 28 | 29 | ### Using `gem install` 30 | 31 | If you have a RubyGems package manager installed, you can use command 32 | 33 | ``` 34 | gem install kaitai-struct 35 | ``` 36 | 37 | to install this runtime library. 38 | 39 | ### Manually 40 | 41 | This library is intentionally kept as very simple, one `.rb` file. 42 | You can just copy it to your project from [this repository](https://github.com/kaitai-io/kaitai_struct_ruby_runtime). 43 | Usually you won't `require` it directly, it will be loaded 44 | by Ruby source code generated by Kaitai Struct compiler. 45 | -------------------------------------------------------------------------------- /kaitai-struct.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | require File.expand_path('../lib/kaitai/struct/struct', __FILE__) 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'kaitai-struct' 7 | s.version = Kaitai::Struct::VERSION 8 | 9 | s.authors = ['Mikhail Yakshin'] 10 | s.email = 'greycat@kaitai.io' 11 | 12 | s.homepage = 'https://kaitai.io/' 13 | s.summary = 'Kaitai Struct: runtime library for Ruby' 14 | s.license = 'MIT' 15 | s.metadata = { 16 | 'bug_tracker_uri' => 'https://github.com/kaitai-io/kaitai_struct_ruby_runtime/issues', 17 | 'documentation_uri' => 'https://www.rubydoc.info/gems/kaitai-struct', 18 | 'homepage_uri' => s.homepage, 19 | 'source_code_uri' => 'https://github.com/kaitai-io/kaitai_struct_ruby_runtime', 20 | # See https://guides.rubygems.org/mfa-requirement-opt-in/ 21 | 'rubygems_mfa_required' => 'true' 22 | } 23 | s.description = <<-DESC 24 | Kaitai Struct is a declarative language used for describe various binary data structures, laid out in files or in memory: i.e. binary file formats, network stream packet formats, etc. 25 | 26 | The main idea is that a particular format is described in Kaitai Struct language (.ksy file) and then can be compiled with ksc into source files in one of the supported programming languages. These modules will include a generated code for a parser that can read described data structure from a file / stream and give access to it in a nice, easy-to-comprehend API. 27 | 28 | This package provides small runtime library used by code generated by Kaitai Struct compiler. 29 | DESC 30 | 31 | s.required_ruby_version = '>= 1.9.3' 32 | s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to? :required_rubygems_version= 33 | s.require_paths = ['lib'] 34 | 35 | s.files = Dir['lib/**/*.rb'] + ['LICENSE', 'README.md'] 36 | s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } 37 | end 38 | -------------------------------------------------------------------------------- /lib/kaitai/struct/struct.rb: -------------------------------------------------------------------------------- 1 | require 'stringio' 2 | 3 | module Kaitai 4 | module Struct 5 | 6 | VERSION = '0.11' 7 | 8 | ## 9 | # Common base class for all structured generated by Kaitai Struct. 10 | # Stores stream object that this object was parsed from in {#_io}, 11 | # stores reference to parent structure in {#_parent} and root 12 | # structure in {#_root} and provides a few helper methods. 13 | class Struct 14 | 15 | def initialize(_io, _parent = nil, _root = nil) 16 | @_io = _io 17 | @_parent = _parent 18 | @_root = _root 19 | end 20 | 21 | ## 22 | # Factory method to instantiate a Kaitai Struct-powered structure, 23 | # parsing it from a local file with a given filename. 24 | # @param filename [String] local file to parse 25 | def self.from_file(filename) 26 | self.new(Stream.open(filename)) 27 | end 28 | 29 | ## 30 | # Implementation of {Object#inspect} to aid debugging (at the very 31 | # least, to aid exception raising) for KS-based classes. This one 32 | # uses a bit terser syntax than Ruby's default one, purposely skips 33 | # any internal fields (i.e. starting with `_`, such as `_io`, 34 | # `_parent` and `_root`) to reduce confusion, and does no 35 | # recursivity tracking (as proper general-purpose `inspect` 36 | # implementation should do) because there are no endless recursion 37 | # in KS-based classes by design (except for already mentioned 38 | # internal navigation variables). 39 | def inspect 40 | vars = [] 41 | instance_variables.each { |nsym| 42 | nstr = nsym.to_s 43 | 44 | # skip all internal variables 45 | next if nstr[0..1] == '@_' 46 | 47 | # strip mandatory `@` at the beginning of the name for brevity 48 | nstr = nstr[1..-1] 49 | 50 | nvalue = instance_variable_get(nsym).inspect 51 | 52 | vars << "#{nstr}=#{nvalue}" 53 | } 54 | 55 | "#{self.class}(#{vars.join(' ')})" 56 | end 57 | 58 | attr_reader :_io, :_parent, :_root 59 | end 60 | 61 | ## 62 | # Kaitai::Struct::Stream is an implementation of 63 | # {Kaitai Stream API}[https://doc.kaitai.io/stream_api.html] for Ruby. 64 | # It's implemented as a wrapper for generic IO objects. 65 | # 66 | # It provides a wide variety of simple methods to read (parse) binary 67 | # representations of primitive types, such as integer and floating 68 | # point numbers, byte arrays and strings, and also provides stream 69 | # positioning / navigation methods with unified cross-language and 70 | # cross-toolkit semantics. 71 | # 72 | # Typically, end users won't access Kaitai Stream class manually, but 73 | # would describe a binary structure format using .ksy language and 74 | # then would use Kaitai Struct compiler to generate source code in 75 | # desired target language. That code, in turn, would use this class 76 | # and API to do the actual parsing job. 77 | class Stream 78 | ## 79 | # @deprecated Unused since Kaitai Struct compiler 0.9. It 80 | # is only available for backward compatibility and will be 81 | # removed in the future. KSC 0.9 and later versions use 82 | # {ValidationNotEqualError} instead. 83 | # 84 | # Exception class for an error that occurs when some fixed content 85 | # was expected to appear, but actual data read was different. 86 | class UnexpectedDataError < Exception 87 | def initialize(actual, expected) 88 | super("Unexpected fixed contents: got #{Internal.format_hex(actual)}, " \ 89 | "was waiting for #{Internal.format_hex(expected)}") 90 | @actual = actual 91 | @expected = expected 92 | end 93 | end 94 | 95 | # Module#deprecate_constant was added in Ruby 2.3, see 96 | # https://rubyreferences.github.io/rubychanges/evolution.html#modules-and-classes 97 | deprecate_constant :UnexpectedDataError if respond_to?(:deprecate_constant) 98 | 99 | ## 100 | # Constructs new Kaitai Stream object. 101 | # @param arg [String, IO, StringIO, SubIO] if String, it will be used as byte 102 | # array to read data from; if IO (or StringIO, or SubIO), if will be used literally 103 | # as the source of data 104 | def initialize(arg) 105 | if arg.is_a?(String) 106 | @_io = StringIO.new(arg) 107 | elsif arg.is_a?(IO) or arg.is_a?(StringIO) or arg.is_a?(SubIO) 108 | @_io = arg 109 | else 110 | raise TypeError.new('can be initialized with IO, StringIO, SubIO or String only') 111 | end 112 | align_to_byte 113 | end 114 | 115 | ## 116 | # Convenience method to create a Kaitai Stream object, opening a 117 | # local file with a given filename. 118 | # @param filename [String] local file to open 119 | def self.open(filename) 120 | self.new(File.open(filename, 'rb:ASCII-8BIT')) 121 | end 122 | 123 | ## 124 | # Closes underlying IO object. 125 | def close 126 | # NOTE: `unless @_io.closed?` is only needed in Ruby 2.2 and below. Ruby 2.3 127 | # and later versions no longer raise `IOError: closed stream` when 128 | # `StringIO#close` is called a second time, see 129 | # https://github.com/ruby/ruby/commit/2e02f2dfd2dab936e7cd9a68d46bd910c5d184e5 130 | @_io.close unless @_io.closed? 131 | end 132 | 133 | # @!group Stream positioning 134 | 135 | ## 136 | # Check if stream pointer is at the end of stream. 137 | # @return [true, false] true if we are located at the end of the stream 138 | def eof?; @_io.eof? and @bits_left == 0; end 139 | 140 | ## 141 | # Set stream pointer to designated position. 142 | # @param x [Fixnum] new position (offset in bytes from the beginning of the stream) 143 | def seek(x); @_io.seek(x); end 144 | 145 | ## 146 | # Get current position of a stream pointer. 147 | # @return [Fixnum] pointer position, number of bytes from the beginning of the stream 148 | def pos; @_io.pos; end 149 | 150 | ## 151 | # Get total size of the stream in bytes. 152 | # @return [Fixnum] size of the stream in bytes 153 | def size; @_io.size; end 154 | 155 | # @!endgroup 156 | 157 | # @!group Integer numbers 158 | 159 | # ------------------------------------------------------------------------ 160 | # Signed 161 | # ------------------------------------------------------------------------ 162 | 163 | def read_s1 164 | read_bytes(1).unpack('c')[0] 165 | end 166 | 167 | # ........................................................................ 168 | # Big-endian 169 | # ........................................................................ 170 | 171 | def read_s2be 172 | read_bytes(2).unpack('s>')[0] 173 | end 174 | 175 | def read_s4be 176 | read_bytes(4).unpack('l>')[0] 177 | end 178 | 179 | def read_s8be 180 | read_bytes(8).unpack('q>')[0] 181 | end 182 | 183 | # ........................................................................ 184 | # Little-endian 185 | # ........................................................................ 186 | 187 | def read_s2le 188 | read_bytes(2).unpack('s<')[0] 189 | end 190 | 191 | def read_s4le 192 | read_bytes(4).unpack('l<')[0] 193 | end 194 | 195 | def read_s8le 196 | read_bytes(8).unpack('q<')[0] 197 | end 198 | 199 | # ------------------------------------------------------------------------ 200 | # Unsigned 201 | # ------------------------------------------------------------------------ 202 | 203 | def read_u1 204 | read_bytes(1).unpack('C')[0] 205 | end 206 | 207 | # ........................................................................ 208 | # Big-endian 209 | # ........................................................................ 210 | 211 | def read_u2be 212 | read_bytes(2).unpack('S>')[0] 213 | end 214 | 215 | def read_u4be 216 | read_bytes(4).unpack('L>')[0] 217 | end 218 | 219 | def read_u8be 220 | read_bytes(8).unpack('Q>')[0] 221 | end 222 | 223 | # ........................................................................ 224 | # Little-endian 225 | # ........................................................................ 226 | 227 | def read_u2le 228 | read_bytes(2).unpack('S<')[0] 229 | end 230 | 231 | def read_u4le 232 | read_bytes(4).unpack('L<')[0] 233 | end 234 | 235 | def read_u8le 236 | read_bytes(8).unpack('Q<')[0] 237 | end 238 | 239 | # @!endgroup 240 | 241 | # @!group Floating point numbers 242 | 243 | # ------------------------------------------------------------------------ 244 | # Big-endian 245 | # ------------------------------------------------------------------------ 246 | 247 | def read_f4be 248 | read_bytes(4).unpack('g')[0] 249 | end 250 | 251 | def read_f8be 252 | read_bytes(8).unpack('G')[0] 253 | end 254 | 255 | # ------------------------------------------------------------------------ 256 | # Little-endian 257 | # ------------------------------------------------------------------------ 258 | 259 | def read_f4le 260 | read_bytes(4).unpack('e')[0] 261 | end 262 | 263 | def read_f8le 264 | read_bytes(8).unpack('E')[0] 265 | end 266 | 267 | # @!endgroup 268 | 269 | # @!group Unaligned bit values 270 | 271 | def align_to_byte 272 | @bits_left = 0 273 | @bits = 0 274 | end 275 | 276 | def read_bits_int_be(n) 277 | res = 0 278 | 279 | bits_needed = n - @bits_left 280 | @bits_left = -bits_needed % 8 281 | 282 | if bits_needed > 0 283 | # 1 bit => 1 byte 284 | # 8 bits => 1 byte 285 | # 9 bits => 2 bytes 286 | bytes_needed = ((bits_needed - 1) / 8) + 1 # `ceil(bits_needed / 8)` 287 | buf = read_bytes(bytes_needed) 288 | buf.each_byte { |byte| 289 | res = res << 8 | byte 290 | } 291 | 292 | new_bits = res 293 | res = res >> @bits_left | @bits << bits_needed 294 | @bits = new_bits # will be masked at the end of the function 295 | else 296 | res = @bits >> -bits_needed # shift unneeded bits out 297 | end 298 | 299 | mask = (1 << @bits_left) - 1 # `@bits_left` is in range 0..7 300 | @bits &= mask 301 | 302 | res 303 | end 304 | 305 | ## 306 | # @deprecated Unused since Kaitai Struct compiler 0.9. It 307 | # is only available for backward compatibility and will be 308 | # removed in the future. KSC 0.9 and later versions use 309 | # {#read_bits_int_be} instead. 310 | def read_bits_int(n) 311 | Internal.warn_deprecated( 312 | 'method Stream#read_bits_int is deprecated since 0.9, ' \ 313 | 'use Stream#read_bits_int_be instead' 314 | ) 315 | read_bits_int_be(n) 316 | end 317 | 318 | def read_bits_int_le(n) 319 | res = 0 320 | bits_needed = n - @bits_left 321 | 322 | if bits_needed > 0 then 323 | # 1 bit => 1 byte 324 | # 8 bits => 1 byte 325 | # 9 bits => 2 bytes 326 | bytes_needed = ((bits_needed - 1) / 8) + 1 # `ceil(bits_needed / 8)` 327 | buf = read_bytes(bytes_needed) 328 | i = 0 329 | buf.each_byte { |byte| 330 | res |= byte << (i * 8) 331 | i += 1 332 | } 333 | 334 | new_bits = res >> bits_needed 335 | res = res << @bits_left | @bits 336 | @bits = new_bits 337 | else 338 | res = @bits 339 | @bits >>= n 340 | end 341 | 342 | @bits_left = -bits_needed % 8 343 | 344 | mask = (1 << n) - 1 # no problem with this in Ruby (arbitrary precision integers) 345 | res &= mask 346 | return res 347 | end 348 | 349 | # @!endgroup 350 | 351 | # @!group Byte arrays 352 | 353 | ## 354 | # Reads designated number of bytes from the stream. 355 | # @param n [Fixnum] number of bytes to read 356 | # @return [String] read bytes as byte array 357 | # @raise [EOFError] if there were less bytes than requested 358 | # available in the stream 359 | def read_bytes(n) 360 | if n.nil? 361 | # This `read(0)` call is only used to raise `IOError: not opened for reading` 362 | # if the stream is closed. This ensures identical behavior to the `substream` 363 | # method. 364 | @_io.read(0) 365 | raise TypeError.new('no implicit conversion from nil to integer') 366 | end 367 | 368 | r = @_io.read(n) 369 | rl = r ? r.bytesize : 0 370 | n = n.to_int 371 | if rl < n 372 | begin 373 | @_io.seek(@_io.pos - rl) 374 | rescue Errno::ESPIPE 375 | # We have a non-seekable stream, so we can't go back to the 376 | # previous position - that's fine. 377 | end 378 | raise EOFError.new("attempted to read #{n} bytes, got only #{rl}") 379 | end 380 | r 381 | end 382 | 383 | ## 384 | # Reads all the remaining bytes in a stream as byte array. 385 | # @return [String] all remaining bytes in a stream as byte array 386 | def read_bytes_full 387 | @_io.read 388 | end 389 | 390 | def read_bytes_term(term, include_term, consume_term, eos_error) 391 | term_byte = term.chr 392 | r = '' 393 | loop { 394 | c = @_io.getc 395 | if c.nil? 396 | if eos_error 397 | raise EOFError.new("end of stream reached, but no terminator #{term} found") 398 | end 399 | 400 | return r 401 | end 402 | if c == term_byte 403 | r << c if include_term 404 | @_io.seek(@_io.pos - 1) unless consume_term 405 | return r 406 | end 407 | r << c 408 | } 409 | end 410 | 411 | def read_bytes_term_multi(term, include_term, consume_term, eos_error) 412 | unit_size = term.bytesize 413 | r = '' 414 | loop { 415 | c = @_io.read(unit_size) || '' 416 | if c.bytesize < unit_size 417 | if eos_error 418 | raise EOFError.new("end of stream reached, but no terminator #{term} found") 419 | end 420 | 421 | r << c 422 | return r 423 | end 424 | if c == term 425 | r << c if include_term 426 | @_io.seek(@_io.pos - unit_size) unless consume_term 427 | return r 428 | end 429 | r << c 430 | } 431 | end 432 | 433 | ## 434 | # @deprecated Unused since Kaitai Struct compiler 0.9. It 435 | # is only available for backward compatibility and will be 436 | # removed in the future. KSC 0.9 and later versions raise 437 | # {ValidationNotEqualError} instead. 438 | # 439 | # Reads next len bytes from the stream and ensures that they match 440 | # expected fixed byte array. If they differ, throws a 441 | # {UnexpectedDataError} runtime exception. 442 | # @param expected [String] contents to be expected 443 | # @return [String] read bytes as byte array, which are guaranteed to 444 | # equal to expected 445 | # @raise [UnexpectedDataError] 446 | def ensure_fixed_contents(expected) 447 | Internal.warn_deprecated( 448 | 'method Stream#ensure_fixed_contents is deprecated since 0.9, ' \ 449 | 'explicitly raise ValidationNotEqualError from an `if` statement instead' 450 | ) 451 | len = expected.bytesize 452 | actual = @_io.read(len) 453 | raise UnexpectedDataError.new(actual, expected) if actual != expected 454 | actual 455 | end 456 | 457 | def self.bytes_strip_right(bytes, pad_byte) 458 | new_len = bytes.length 459 | while new_len > 0 and bytes.getbyte(new_len - 1) == pad_byte 460 | new_len -= 1 461 | end 462 | 463 | bytes[0, new_len] 464 | end 465 | 466 | def self.bytes_terminate(bytes, term, include_term) 467 | term_index = bytes.index(term.chr) 468 | if term_index.nil? 469 | bytes.dup 470 | else 471 | bytes[0, term_index + (include_term ? 1 : 0)] 472 | end 473 | end 474 | 475 | def self.bytes_terminate_multi(bytes, term, include_term) 476 | unit_size = term.bytesize 477 | search_index = bytes.index(term) 478 | loop { 479 | if search_index.nil? 480 | return bytes.dup 481 | end 482 | mod = search_index % unit_size 483 | if mod == 0 484 | return bytes[0, search_index + (include_term ? unit_size : 0)] 485 | end 486 | search_index = bytes.index(term, search_index + (unit_size - mod)) 487 | } 488 | end 489 | 490 | # @!endgroup 491 | 492 | # @!group Byte array processing 493 | 494 | ## 495 | # Performs a XOR processing with given data, XORing every byte of 496 | # input with a single given value. Uses pure Ruby implementation suggested 497 | # by [Thomas Leitner](https://github.com/gettalong), borrowed from 498 | # https://github.com/fny/xorcist/blob/master/bin/benchmark 499 | # @param data [String] data to process 500 | # @param key [Fixnum] value to XOR with 501 | # @return [String] processed data 502 | def self.process_xor_one(data, key) 503 | out = data.dup 504 | i = 0 505 | max = data.length 506 | while i < max 507 | out.setbyte(i, data.getbyte(i) ^ key) 508 | i += 1 509 | end 510 | out 511 | end 512 | 513 | ## 514 | # Performs a XOR processing with given data, XORing every byte of 515 | # input with a key array, repeating key array many times, if 516 | # necessary (i.e. if data array is longer than key array). 517 | # Uses pure Ruby implementation suggested by 518 | # [Thomas Leitner](https://github.com/gettalong), borrowed from 519 | # https://github.com/fny/xorcist/blob/master/bin/benchmark 520 | # @param data [String] data to process 521 | # @param key [String] array of bytes to XOR with 522 | # @return [String] processed data 523 | def self.process_xor_many(data, key) 524 | out = data.dup 525 | kl = key.length 526 | ki = 0 527 | i = 0 528 | max = data.length 529 | while i < max 530 | out.setbyte(i, data.getbyte(i) ^ key.getbyte(ki)) 531 | ki += 1 532 | ki = 0 if ki >= kl 533 | i += 1 534 | end 535 | out 536 | end 537 | 538 | ## 539 | # Performs a circular left rotation shift for a given buffer by a 540 | # given amount of bits, using groups of groupSize bytes each 541 | # time. Right circular rotation should be performed using this 542 | # procedure with corrected amount. 543 | # @param data [String] source data to process 544 | # @param amount [Fixnum] number of bits to shift by 545 | # @param group_size [Fixnum] number of bytes per group to shift 546 | # @return [String] copy of source array with requested shift applied 547 | def self.process_rotate_left(data, amount, group_size) 548 | raise NotImplementedError.new("unable to rotate group #{group_size} bytes yet") unless group_size == 1 549 | 550 | mask = group_size * 8 - 1 551 | anti_amount = -amount & mask 552 | 553 | # NB: actually, left bit shift (<<) in Ruby would have required 554 | # truncation to type_bits size (i.e. something like "& 0xff" for 555 | # group_size == 8), but we can skip this one, because later these 556 | # number would be packed with Array#pack, which will do truncation 557 | # anyway 558 | 559 | data.bytes.map { |x| (x << amount) | (x >> anti_amount) }.pack('C*') 560 | end 561 | 562 | # @!endgroup 563 | 564 | ## 565 | # Reserves next n bytes from current stream as a 566 | # Kaitai::Struct::Stream substream. Substream has its own pointer 567 | # and addressing in the range of [0, n) bytes. This stream's pointer 568 | # is advanced to the position right after this substream. 569 | # @param n [Fixnum] number of bytes to reserve for a substream 570 | # @return [Stream] substream covering n bytes from the current 571 | # position 572 | def substream(n) 573 | raise IOError.new('not opened for reading') if @_io.closed? 574 | 575 | n = Internal.num2long(n) 576 | raise ArgumentError.new("negative length #{n} given") if n < 0 577 | 578 | rl = [0, @_io.size - @_io.pos].max 579 | raise EOFError.new("attempted to read #{n} bytes, got only #{rl}") if rl < n 580 | 581 | sub = Stream.new(SubIO.new(@_io, @_io.pos, n)) 582 | @_io.seek(@_io.pos + n) 583 | sub 584 | end 585 | 586 | ## 587 | # Resolves value using enum: if the value is not found in the map, 588 | # we'll just use literal value per se. 589 | def self.resolve_enum(enum_map, value) 590 | enum_map[value] || value 591 | end 592 | end 593 | 594 | ## 595 | # Substream IO implementation: a IO object which wraps existing IO object 596 | # and provides similar byte/bytes reading functionality, but only for a 597 | # limited set of bytes starting from specified offset and spanning up to 598 | # specified length. 599 | class SubIO 600 | ## 601 | # Parent IO object that this substream is projecting data from. 602 | attr_reader :parent_io 603 | 604 | ## 605 | # Offset of start of substream in coordinates of parent stream. In 606 | # coordinates of substream itself start will be always 0. 607 | attr_reader :parent_start 608 | 609 | ## 610 | # Size of substream in bytes. 611 | attr_reader :size 612 | 613 | ## 614 | # Current position in a substream. Independent from a position in a 615 | # parent IO. 616 | attr_reader :pos 617 | 618 | def initialize(parent_io, parent_start, size) 619 | @parent_io = parent_io 620 | @parent_start = parent_start 621 | @size = size 622 | @pos = 0 623 | @closed = false 624 | end 625 | 626 | def eof? 627 | raise IOError.new('not opened for reading') if @closed 628 | 629 | @pos >= @size 630 | end 631 | 632 | def seek(offset, whence = IO::SEEK_SET) 633 | raise ArgumentError.new('only IO::SEEK_SET is supported by SubIO#seek') unless whence == IO::SEEK_SET 634 | 635 | offset = Internal.num2long(offset) 636 | raise IOError.new('closed stream') if @closed 637 | raise Errno::EINVAL if offset < 0 638 | @pos = offset.to_int 639 | return 0 640 | end 641 | 642 | def getc 643 | raise IOError.new('not opened for reading') if @closed 644 | 645 | return nil if @pos >= @size 646 | 647 | # remember position in parent IO 648 | old_pos = @parent_io.pos 649 | @parent_io.seek(@parent_start + @pos) 650 | begin 651 | res = @parent_io.getc 652 | @pos += 1 653 | ensure 654 | # restore position in parent IO 655 | @parent_io.seek(old_pos) 656 | end 657 | 658 | res 659 | end 660 | 661 | def read(len = nil) 662 | raise IOError.new('not opened for reading') if @closed 663 | 664 | # read until the end of substream 665 | if len.nil? 666 | len = @size - @pos 667 | return BYTE_STRING_EMPTY.dup if len <= 0 668 | elsif len.respond_to?(:to_int) 669 | len = len.to_int 670 | # special case for requesting exactly 0 bytes 671 | return BYTE_STRING_EMPTY.dup if len == 0 672 | 673 | if len > 0 674 | # cap intent to read if going beyond substream boundary 675 | left = @size - @pos 676 | 677 | # if actually requested reading and we're beyond the boundary, return nil 678 | return nil if left <= 0 679 | 680 | # otherwise, still return something, but less than requested 681 | len = left if len > left 682 | end 683 | end 684 | 685 | # remember position in parent IO 686 | old_pos = @parent_io.pos 687 | 688 | @parent_io.seek(@parent_start + @pos) 689 | begin 690 | res = @parent_io.read(len) 691 | read_len = res.bytesize 692 | @pos += read_len 693 | ensure 694 | # restore position in parent IO 695 | @parent_io.seek(old_pos) 696 | end 697 | 698 | res 699 | end 700 | 701 | def close 702 | @closed = true 703 | nil 704 | end 705 | 706 | def closed? 707 | @closed 708 | end 709 | 710 | BYTE_STRING_EMPTY = ''.force_encoding(Encoding::ASCII_8BIT).freeze 711 | private_constant :BYTE_STRING_EMPTY 712 | end 713 | 714 | ## 715 | # Common ancestor for all error originating from Kaitai Struct usage. 716 | # Stores KSY source path, pointing to an element supposedly guilty of 717 | # an error. 718 | class KaitaiStructError < StandardError 719 | def initialize(msg, src_path) 720 | super("#{src_path}: #{msg}") 721 | @src_path = src_path 722 | end 723 | end 724 | 725 | ## 726 | # Error that occurs when default endianness should be decided with 727 | # a switch, but nothing matches (although using endianness expression 728 | # implies that there should be some positive result). 729 | class UndecidedEndiannessError < KaitaiStructError 730 | def initialize(src_path) 731 | super("unable to decide on endianness for a type", src_path) 732 | end 733 | end 734 | 735 | ## 736 | # Common ancestor for all validation failures. Stores pointer to 737 | # KaitaiStream IO object which was involved in an error. 738 | class ValidationFailedError < KaitaiStructError 739 | def initialize(msg, io, src_path) 740 | super("at pos #{io.pos}: validation failed: #{msg}", src_path) 741 | @io = io 742 | end 743 | end 744 | 745 | ## 746 | # Signals validation failure: we required "actual" value to be equal to 747 | # "expected", but it turned out that it's not. 748 | class ValidationNotEqualError < ValidationFailedError 749 | def initialize(expected, actual, io, src_path) 750 | expected_repr, actual_repr = Internal.inspect_values(expected, actual) 751 | super("not equal, expected #{expected_repr}, but got #{actual_repr}", io, src_path) 752 | 753 | @expected = expected 754 | @actual = actual 755 | end 756 | end 757 | 758 | ## 759 | # Signals validation failure: we required "actual" value to be greater 760 | # than or equal to "min", but it turned out that it's not. 761 | class ValidationLessThanError < ValidationFailedError 762 | def initialize(min, actual, io, src_path) 763 | min_repr, actual_repr = Internal.inspect_values(min, actual) 764 | super("not in range, min #{min_repr}, but got #{actual_repr}", io, src_path) 765 | @min = min 766 | @actual = actual 767 | end 768 | end 769 | 770 | ## 771 | # Signals validation failure: we required "actual" value to be less 772 | # than or equal to "max", but it turned out that it's not. 773 | class ValidationGreaterThanError < ValidationFailedError 774 | def initialize(max, actual, io, src_path) 775 | max_repr, actual_repr = Internal.inspect_values(max, actual) 776 | super("not in range, max #{max_repr}, but got #{actual_repr}", io, src_path) 777 | @max = max 778 | @actual = actual 779 | end 780 | end 781 | 782 | ## 783 | # Signals validation failure: we required "actual" value to be any of 784 | # the given list, but it turned out that it's not. 785 | class ValidationNotAnyOfError < ValidationFailedError 786 | def initialize(actual, io, src_path) 787 | actual_repr = Internal.inspect_values(actual) 788 | super("not any of the list, got #{actual_repr}", io, src_path) 789 | @actual = actual 790 | end 791 | end 792 | 793 | ## 794 | # Signals validation failure: we required "actual" value to be in 795 | # the enum, but it turned out that it's not. 796 | class ValidationNotInEnumError < ValidationFailedError 797 | def initialize(actual, io, src_path) 798 | actual_repr = Internal.inspect_values(actual) 799 | super("not in the enum, got #{actual_repr}", io, src_path) 800 | @actual = actual 801 | end 802 | end 803 | 804 | ## 805 | # Signals validation failure: we required "actual" value to match 806 | # the expression, but it turned out that it doesn't. 807 | class ValidationExprError < ValidationFailedError 808 | def initialize(actual, io, src_path) 809 | actual_repr = Internal.inspect_values(actual) 810 | super("not matching the expression, got #{actual_repr}", io, src_path) 811 | @actual = actual 812 | end 813 | end 814 | 815 | ## 816 | # \Internal implementation helpers. 817 | module Internal 818 | ## 819 | # This method reproduces the behavior of the +rb_num2long+ function: 820 | # https://github.com/ruby/ruby/blob/d2930f8e7a5db8a7337fa43370940381b420cc3e/numeric.c#L3195-L3221 821 | def self.num2long(val) 822 | val_as_int = val.to_int 823 | rescue NoMethodError 824 | raise TypeError.new('no implicit conversion from nil to integer') if val.nil? 825 | 826 | val_as_human = 827 | case val 828 | when true, false 829 | val.to_s 830 | else 831 | val.class 832 | end 833 | raise TypeError.new("no implicit conversion of #{val_as_human} into Integer") 834 | else 835 | val_as_int 836 | end 837 | 838 | def self.format_hex(bytes) 839 | bytes.unpack('H*')[0].gsub(/(..)/, '\1 ').chop 840 | end 841 | 842 | ### 843 | # Guess if the given args are most likely byte arrays. 844 | #
845 | # There's no way to know for sure, but {@code Encoding::ASCII_8BIT} is a special encoding that is 846 | # usually used for a byte array(/string), not a character string. For those reasons, that encoding 847 | # is NOT planned to be allowed for human readable texts by KS in general as well. 848 | #
849 | # @param args [...] Something to check. 850 | # @see Encoding 851 | # @see List of supported encodings 852 | # 853 | def self.is_byte_array?(*args) 854 | args.all? { |arg| arg.is_a?(String) and (arg.encoding == Encoding::ASCII_8BIT) } 855 | end 856 | 857 | private_class_method :is_byte_array? 858 | 859 | def self.inspect_values(*args) 860 | reprs = args.map { |arg| 861 | if is_byte_array?(arg) 862 | "[#{format_hex(arg)}]" 863 | else 864 | arg.inspect 865 | end 866 | } 867 | reprs.length == 1 ? reprs[0] : reprs 868 | end 869 | 870 | # The `uplevel` keyword argument of Kernel#warn was added in Ruby 2.5, 871 | # see https://rubyreferences.github.io/rubychanges/2.5.html#warn-uplevel-keyword-argument 872 | # 873 | # The `category` keyword argument of Kernel#warn was added in Ruby 3.0, 874 | # see https://rubyreferences.github.io/rubychanges/3.0.html#warningwarn-category-keyword-argument 875 | # 876 | # NOTE: `.dup` is needed in Ruby 1.9, otherwise `RuntimeError: can't modify frozen String` occurs 877 | WARN_SUPPORTS_UPLEVEL = Gem::Version.new(RUBY_VERSION.dup) >= Gem::Version.new('2.5.0') 878 | WARN_SUPPORTS_CATEGORY = Gem::Version.new(RUBY_VERSION.dup) >= Gem::Version.new('3.0.0') 879 | private_constant :WARN_SUPPORTS_UPLEVEL 880 | private_constant :WARN_SUPPORTS_CATEGORY 881 | 882 | def self.warn_deprecated(msg) 883 | if WARN_SUPPORTS_CATEGORY 884 | warn(msg, uplevel: 2, category: :deprecated) 885 | elsif WARN_SUPPORTS_UPLEVEL 886 | warn(msg, uplevel: 2) 887 | else 888 | warn(msg) 889 | end 890 | end 891 | end 892 | 893 | private_constant :Internal 894 | 895 | end 896 | end 897 | -------------------------------------------------------------------------------- /spec/kaitaistream_spec.rb: -------------------------------------------------------------------------------- 1 | require 'kaitai/struct/struct' 2 | require 'stringio' 3 | require 'socket' 4 | require 'fileutils' 5 | 6 | require 'rspec' # normally not needed, but RubyMine doesn't autocomplete RSpec methods without it 7 | require 'rantly' 8 | require 'rantly/rspec_extensions' 9 | 10 | # `.dup` is needed in Ruby 1.9, otherwise `RuntimeError: can't modify frozen String` occurs 11 | IS_RUBY_1_9 = Gem::Version.new(RUBY_VERSION.dup) < Gem::Version.new('2.0.0') 12 | 13 | RSpec.describe Kaitai::Struct::Stream do 14 | before(:all) do 15 | @old_wd = Dir::getwd 16 | FileUtils::mkdir_p("test_scratch") 17 | Dir::chdir("test_scratch") 18 | end 19 | 20 | after(:all) do 21 | Dir::chdir(@old_wd) 22 | FileUtils::rm_rf("test_scratch") 23 | end 24 | 25 | describe '#initialize' do 26 | it 'can be initialized from String' do 27 | stream = Kaitai::Struct::Stream.new("12345") 28 | end 29 | 30 | it 'can be initialized from StringIO' do 31 | io = StringIO.new("12345") 32 | stream = Kaitai::Struct::Stream.new(io) 33 | end 34 | 35 | it 'can be initialized from File' do 36 | File.binwrite('test12345.bin', '12345') 37 | File.open('test12345.bin', 'r') { |f| 38 | stream = Kaitai::Struct::Stream.new(f) 39 | } 40 | end 41 | 42 | it 'can be initialized from TCPSocket' do 43 | HOST = '127.0.0.1' 44 | PORT = 26570 45 | 46 | # Start a new TCP server on designated port. This server will only accept one connection and then will cease to listen. 47 | server = TCPServer.new(HOST, PORT) 48 | 49 | # Run `accept` in a separate thread (as we still need the main thread for client-to-server connection) 50 | Thread.start do 51 | s2c_socket = server.accept 52 | s2c_socket.write('12345') 53 | s2c_socket.close 54 | server.close 55 | end 56 | 57 | # Start client-to-server connection 58 | c2s_socket = TCPSocket.new(HOST, PORT) 59 | 60 | stream = Kaitai::Struct::Stream.new(c2s_socket) 61 | 62 | # Check that we can read 1 byte integer from a socket 63 | expect(stream.read_u1).to eq(0x31) 64 | 65 | # Check that we can't seek in a socket IO 66 | expect { stream.seek(2) }.to raise_error(Errno::ESPIPE) 67 | 68 | expect { stream.read_bytes(5) }.to raise_error(EOFError, 'attempted to read 5 bytes, got only 4') 69 | 70 | c2s_socket.close 71 | end 72 | 73 | it 'cannot be initialized from an integer' do 74 | expect { Kaitai::Struct::Stream.new(12345) }.to raise_error(TypeError) 75 | end 76 | end 77 | 78 | describe '#open' do 79 | it 'opens existing local file' do 80 | File.binwrite('test12345.bin', '12345') 81 | stream = Kaitai::Struct::Stream.open('test12345.bin') 82 | expect(stream.read_u1).to eq(0x31) 83 | end 84 | end 85 | 86 | describe '#close' do 87 | it 'closes underlying StringIO stream' do 88 | io = StringIO.new("12345") 89 | expect(io.closed?).to be false 90 | stream = Kaitai::Struct::Stream.new(io) 91 | expect(io.closed?).to be false 92 | stream.close 93 | expect(io.closed?).to be true 94 | end 95 | end 96 | 97 | describe '#substream' do 98 | it 'behaves like #read_bytes + Stream#new' do 99 | prop = property_of do 100 | len = range(0, 8) 101 | s = array(len) { range(0, 255) }.pack('C*') 102 | ops = array(10) do 103 | sub_len = branch( 104 | [:range, -2, len + 1], 105 | [:float, :normal, { center: 1, scale: 4 }], 106 | :boolean, 107 | [:string, :digit], 108 | [:literal, [1, 2]], 109 | [:literal, nil], 110 | [:literal, Complex(2, 0)], 111 | [:literal, Complex(2, 1)] 112 | ) 113 | # Since Ruby 2.0, we could use `%i[...]` here instead, but at the time 114 | # of writing we still support Ruby 1.9. 115 | options = [:enter_subio, :read, :seek].map { |x| [x, [sub_len]] } 116 | options.concat([:exit_subio, :eof?, :pos, :size, :getc, :close_io].map { |x| [x, []] }) 117 | 118 | choose(*options) 119 | end 120 | [s, ops] 121 | end 122 | prop.check(2000) do |(s, ops)| 123 | StringIO.open { |logger| 124 | logger.write("s: #{s.inspect}, length: #{s.bytesize}\n") 125 | begin 126 | old_streams = [Kaitai::Struct::Stream.new(StringIO.new(s))] 127 | new_streams = [Kaitai::Struct::Stream.new(StringIO.new(s))] 128 | ops.each do |(op, op_args)| 129 | exec_stream_op(op, op_args, old_streams, new_streams, logger) 130 | end 131 | rescue RSpec::Expectations::ExpectationNotMetError, StandardError 132 | $stderr.write("\n#{logger.string}\n") 133 | raise 134 | end 135 | } 136 | end 137 | end 138 | 139 | def exec_stream_op(op, op_args, old_streams, new_streams, logger) 140 | # NB: intentionally without a newline ("\n") 141 | logger.write([op, op_args].inspect) 142 | old_stream = old_streams.last 143 | new_stream = new_streams.last 144 | old_io = old_stream.instance_variable_get(:@_io) 145 | new_io = new_stream.instance_variable_get(:@_io) 146 | case op 147 | when :read, :seek, :eof?, :pos, :size, :getc 148 | status, ret = call_io_method(op, op_args, old_io, new_io) 149 | case status 150 | when :ok 151 | old_res, new_res = ret 152 | expect(new_res).to eq(old_res) 153 | logger.write(" -> #{old_res.inspect}\n") 154 | if old_res.is_a?(String) 155 | expect(new_res.encoding).to eq(old_res.encoding) 156 | expect(new_res.frozen?).to eq(old_res.frozen?) 157 | end 158 | when :fail 159 | logger.write(": #{ret.inspect}\n") 160 | end 161 | when :enter_subio 162 | status, ret = call_io_method(:substream, op_args, old_stream, new_stream, :read_bytes) 163 | case status 164 | when :ok 165 | old_res, new_res = ret 166 | logger.write(": OK\n") 167 | old_streams << Kaitai::Struct::Stream.new(old_res) 168 | new_streams << new_res 169 | when :fail 170 | logger.write(": #{ret.inspect}\n") 171 | end 172 | when :exit_subio 173 | if old_streams.length > 1 174 | old_streams.pop.close 175 | new_streams.pop.close 176 | logger.write(": OK\n") 177 | else 178 | logger.write(": ignored\n") 179 | end 180 | when :close_io 181 | expect(new_stream.close).to eq(old_stream.close) 182 | logger.write(": OK\n") 183 | else 184 | raise "unknown operation #{op.inspect}" 185 | end 186 | end 187 | 188 | def call_io_method(method_new, op_args, old_io, new_io, method_old = method_new) 189 | old_res = old_io.public_send(method_old, *op_args) 190 | rescue StandardError => old_err 191 | expected_msg = old_err.message 192 | msg_correction_needed = 193 | IS_RUBY_1_9 && 194 | (method_new == :substream || (method_new == :seek && new_io.is_a?(Kaitai::Struct::SubIO))) && 195 | old_err.is_a?(TypeError) && 196 | expected_msg =~ /^can't convert (.*)/ 197 | expected_msg = "no implicit conversion of #{Regexp.last_match(1)}" if msg_correction_needed 198 | expect do 199 | new_io.public_send(method_new, *op_args) 200 | end.to raise_error(old_err.class, expected_msg) 201 | [:fail, old_err] 202 | else 203 | new_res = new_io.public_send(method_new, *op_args) 204 | [:ok, [old_res, new_res]] 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /spec/subio_spec.rb: -------------------------------------------------------------------------------- 1 | require 'kaitai/struct/struct' 2 | require 'stringio' 3 | 4 | RSpec.describe Kaitai::Struct::SubIO do 5 | context "in 12345 asking for 234" do 6 | before(:each) do 7 | parent_io = StringIO.new("12345") 8 | @io = Kaitai::Struct::SubIO.new(parent_io, 1, 3) 9 | @normal_io = StringIO.new("234") 10 | end 11 | 12 | describe "#seek" do 13 | it "can seek to 0" do 14 | expect(@normal_io.seek(0)).to eq(0) 15 | expect(@io.seek(0)).to eq(0) 16 | 17 | expect(@normal_io.pos).to eq(0) 18 | expect(@io.pos).to eq(0) 19 | end 20 | 21 | it "can seek to 2" do 22 | expect(@normal_io.seek(2)).to eq(0) 23 | expect(@io.seek(2)).to eq(0) 24 | 25 | expect(@normal_io.pos).to eq(2) 26 | expect(@io.pos).to eq(2) 27 | end 28 | 29 | it "can seek to 10 (beyond EOF)" do 30 | expect(@normal_io.seek(10)).to eq(0) 31 | expect(@io.seek(10)).to eq(0) 32 | 33 | expect(@normal_io.pos).to eq(10) 34 | expect(@io.pos).to eq(10) 35 | end 36 | 37 | it "cannot seek to -1" do 38 | expect { @normal_io.seek(-1) }.to raise_error(Errno::EINVAL) 39 | expect { @io.seek(-1) }.to raise_error(Errno::EINVAL) 40 | end 41 | 42 | it "cannot seek to \"foo\"" do 43 | expect { @normal_io.seek("foo") }.to raise_error(TypeError) 44 | expect { @io.seek("foo") }.to raise_error(TypeError) 45 | end 46 | 47 | it "can seek to 2.3" do 48 | expect(@normal_io.seek(2.3)).to eq(0) 49 | expect(@io.seek(2.3)).to eq(0) 50 | 51 | expect(@normal_io.pos).to eq(2) 52 | expect(@io.pos).to eq(2) 53 | end 54 | end 55 | 56 | describe "#pos" do 57 | it "returns 0 by default" do 58 | expect(@normal_io.pos).to eq(0) 59 | expect(@io.pos).to eq(0) 60 | end 61 | 62 | it "returns 2 after reading 2 bytes" do 63 | @normal_io.read(2) 64 | @io.read(2) 65 | 66 | expect(@normal_io.pos).to eq(2) 67 | expect(@io.pos).to eq(2) 68 | end 69 | 70 | it "returns 3 after reading 4 bytes" do 71 | @normal_io.read(4) 72 | @io.read(4) 73 | 74 | expect(@normal_io.pos).to eq(3) 75 | expect(@io.pos).to eq(3) 76 | end 77 | end 78 | 79 | describe "#eof?" do 80 | it "returns false by default" do 81 | expect(@normal_io.eof?).to eq(false) 82 | expect(@io.eof?).to eq(false) 83 | end 84 | 85 | it "returns false after reading 2 bytes" do 86 | @normal_io.read(2) 87 | @io.read(2) 88 | 89 | expect(@normal_io.eof?).to eq(false) 90 | expect(@io.eof?).to eq(false) 91 | end 92 | 93 | it "returns true after reading 3 bytes" do 94 | @normal_io.read(3) 95 | @io.read(3) 96 | 97 | expect(@normal_io.eof?).to eq(true) 98 | expect(@io.eof?).to eq(true) 99 | end 100 | 101 | it "returns true after reading 4 bytes" do 102 | @normal_io.read(4) 103 | @io.read(4) 104 | 105 | expect(@normal_io.eof?).to eq(true) 106 | expect(@io.eof?).to eq(true) 107 | end 108 | 109 | it "returns true after seeking at 3 bytes" do 110 | @normal_io.seek(3) 111 | @io.seek(3) 112 | 113 | expect(@normal_io.eof?).to eq(true) 114 | expect(@io.eof?).to eq(true) 115 | end 116 | 117 | it "returns true after seeking at 10 bytes" do 118 | @normal_io.seek(10) 119 | @io.seek(10) 120 | 121 | expect(@normal_io.eof?).to eq(true) 122 | expect(@io.eof?).to eq(true) 123 | end 124 | end 125 | 126 | describe "#read" do 127 | it "reads 234 with no arguments" do 128 | expect(@normal_io.read).to eq("234") 129 | expect(@io.read).to eq("234") 130 | end 131 | 132 | it "reads 23 when asked to read 2" do 133 | expect(@normal_io.read(2)).to eq("23") 134 | expect(@io.read(2)).to eq("23") 135 | end 136 | 137 | it "reads 23 when asked to read 2.7" do 138 | expect(@normal_io.read(2.7)).to eq("23") 139 | expect(@io.read(2.7)).to eq("23") 140 | end 141 | 142 | it "reads 234 when asked to read 3" do 143 | expect(@normal_io.read(3)).to eq("234") 144 | expect(@io.read(3)).to eq("234") 145 | end 146 | 147 | it "reads 234 when asked to read 4" do 148 | expect(@normal_io.read(4)).to eq("234") 149 | expect(@io.read(4)).to eq("234") 150 | end 151 | 152 | it "reads 234 when asked to read 10" do 153 | expect(@normal_io.read(10)).to eq("234") 154 | expect(@io.read(10)).to eq("234") 155 | end 156 | 157 | it "reads 234 + empty when asked to read + read" do 158 | expect(@normal_io.read).to eq("234") 159 | expect(@io.read).to eq("234") 160 | 161 | expect(@normal_io.read).to eq("") 162 | expect(@io.read).to eq("") 163 | end 164 | 165 | it "reads 2 + 34 when asked to read(1) + read" do 166 | expect(@normal_io.read(1)).to eq("2") 167 | expect(@io.read(1)).to eq("2") 168 | 169 | expect(@normal_io.read).to eq("34") 170 | expect(@io.read).to eq("34") 171 | end 172 | 173 | it "reads 2 + 34 when asked to read(1) + read(2)" do 174 | expect(@normal_io.read(1)).to eq("2") 175 | expect(@io.read(1)).to eq("2") 176 | 177 | expect(@normal_io.read(2)).to eq("34") 178 | expect(@io.read(2)).to eq("34") 179 | end 180 | 181 | it "reads 2 + 34 when asked to read(1) + read(10)" do 182 | expect(@normal_io.read(1)).to eq("2") 183 | expect(@io.read(1)).to eq("2") 184 | 185 | expect(@normal_io.read(10)).to eq("34") 186 | expect(@io.read(10)).to eq("34") 187 | end 188 | 189 | context("after seek to EOF") do 190 | before(:each) do 191 | @normal_io.seek(3) 192 | @io.seek(3) 193 | end 194 | 195 | it "reads nil when asked to read(1)" do 196 | expect(@normal_io.read(1)).to eq(nil) 197 | expect(@io.read(1)).to eq(nil) 198 | end 199 | 200 | it "reads empty when asked to read()" do 201 | expect(@normal_io.read).to eq("") 202 | expect(@io.read).to eq("") 203 | end 204 | 205 | it "reads empty when asked to read(0)" do 206 | expect(@normal_io.read(0)).to eq("") 207 | expect(@io.read(0)).to eq("") 208 | end 209 | end 210 | 211 | context("after seek beyond EOF") do 212 | before(:each) do 213 | @normal_io.seek(10) 214 | @io.seek(10) 215 | end 216 | 217 | it "reads nil when asked to read(1)" do 218 | expect(@normal_io.read(1)).to eq(nil) 219 | expect(@io.read(1)).to eq(nil) 220 | end 221 | 222 | it "reads empty when asked to read()" do 223 | expect(@normal_io.read).to eq("") 224 | expect(@io.read).to eq("") 225 | end 226 | 227 | it "reads empty when asked to read(0)" do 228 | expect(@normal_io.read(0)).to eq("") 229 | expect(@io.read(0)).to eq("") 230 | end 231 | end 232 | end 233 | 234 | describe "#getc" do 235 | it 'restores parent pos if parent #getc fails' do 236 | @io.parent_io.close_read 237 | expect { @io.getc }.to raise_error(IOError) 238 | expect(@io.parent_io.pos).to eq(0) 239 | end 240 | end 241 | end 242 | end 243 | --------------------------------------------------------------------------------