require 'stringio' module Neo4j module Core # Implements the the PackStream packing and unpacking specifications # as specified by Neo Technology for the Neo4j graph database module PackStream MARKER_TYPES = { C0: nil, C1: [:float, 64], C2: false, C3: true, C8: [:int, 8], C9: [:int, 16], CA: [:int, 32], CB: [:int, 64], CC: [:bytes, 8], CD: [:bytes, 16], CE: [:bytes, 32], D0: [:text, 8], D1: [:text, 16], D2: [:text, 32], D4: [:list, 8], D5: [:list, 16], D6: [:list, 32], D8: [:map, 8], D9: [:map, 16], DA: [:map, 32], DC: [:struct, 8], DD: [:struct, 16], DE: [:struct, 32] } # For efficiency. Translates directly from bytes to types MARKER_TYPES.keys.each do |key| ord = eval("0x#{key}") # rubocop:disable Security/Eval MARKER_TYPES[ord] = MARKER_TYPES.delete(key) end # Translates directly from types to bytes MARKER_BYTES = MARKER_TYPES.invert MARKER_BYTES.keys.each do |key| MARKER_BYTES.delete(key) if key.is_a?(Array) end MARKER_HEADERS = MARKER_TYPES.each_with_object({}) do |(byte, (type, size)), headers| headers[type] ||= {} headers[type][size] = [byte].pack('C') end HEADER_PACK_STRINGS = %w[C S L].freeze Structure = Struct.new(:signature, :list) # Object which holds a Ruby object and can # pack it into a PackStream stream class Packer def initialize(object) @object = object end def packed_stream if byte = MARKER_BYTES[@object] pack_array_as_string([byte]) else case @object when Date, Time, DateTime then string_stream when Integer, Float, String, Symbol, Array, Structure, Hash send(@object.class.name.split('::').last.downcase + '_stream') end end end # Range Minimum | Range Maximum | Representation | Byte | # ============================|============================|================|======| # -9 223 372 036 854 775 808 | -2 147 483 649 | INT_64 | CB | # -2 147 483 648 | -32 769 | INT_32 | CA | # -32 768 | -129 | INT_16 | C9 | # -128 | -17 | INT_8 | C8 | # -16 | +127 | TINY_INT | N/A | # +128 | +32 767 | INT_16 | C9 | # +32 768 | +2 147 483 647 | INT_32 | CA | # +2 147 483 648 | +9 223 372 036 854 775 807 | INT_64 | CB | INT_HEADERS = MARKER_HEADERS[:int] def integer_stream case @object when -0x10...0x80 # TINY_INT pack_integer_object_as_string when -0x80...-0x10 # INT_8 INT_HEADERS[8] + pack_integer_object_as_string when -0x8000...0x8000 # INT_16 INT_HEADERS[16] + pack_integer_object_as_string(2) when -0x80000000...0x80000000 # INT_32 INT_HEADERS[32] + pack_integer_object_as_string(4) when -0x8000000000000000...0x8000000000000000 # INT_64 INT_HEADERS[64] + pack_integer_object_as_string(8) end end alias fixnum_stream integer_stream alias bignum_stream integer_stream def float_stream MARKER_HEADERS[:float][64] + [@object].pack('G').force_encoding(Encoding::BINARY) end # Marker | Size | Maximum size # ========|=============================================|===================== # 80..8F | contained within low-order nibble of marker | 15 bytes # D0 | 8-bit big-endian unsigned integer | 255 bytes # D1 | 16-bit big-endian unsigned integer | 65 535 bytes # D2 | 32-bit big-endian unsigned integer | 4 294 967 295 bytes def string_stream s = @object.to_s s = s.dup if s.frozen? marker_string(0x80, 0xD0, @object.to_s.bytesize) + s.force_encoding(Encoding::BINARY) end alias symbol_stream string_stream def array_stream marker_string(0x90, 0xD4, @object.size) + @object.map do |e| Packer.new(e).packed_stream end.join end def structure_stream fail 'Structure too big' if @object.list.size > 65_535 marker_string(0xB0, 0xDC, @object.list.size) + [@object.signature].pack('C') + @object.list.map do |e| Packer.new(e).packed_stream end.join end def hash_stream marker_string(0xA0, 0xD8, @object.size) + @object.map do |key, value| Packer.new(key).packed_stream + Packer.new(value).packed_stream end.join end def self.pack_arguments(*objects) objects.map { |o| new(o).packed_stream }.join end private def marker_string(tiny_base, regular_base, size) head_byte = case size when 0...0x10 then tiny_base + size when 0x10...0x100 then regular_base when 0x100...0x10000 then regular_base + 1 when 0x10000...0x100000000 then regular_base + 2 end result = [head_byte].pack('C') result += [size].pack(HEADER_PACK_STRINGS[head_byte - regular_base]).reverse if size >= 0x10 result end def pack_integer_object_as_string(size = 1) bytes = [] (0...size).to_a.reverse.inject(@object) do |current, i| bytes << (current / (256**i)) current % (256**i) end pack_array_as_string(bytes) end def pack_array_as_string(a) a.pack('c*') end end # Object which holds a stream of PackStream data # and can unpack it class Unpacker def initialize(stream) @stream = stream end HEADER_BASE_BYTES = {text: 0xD0, list: 0xD4, struct: 0xDC, map: 0xD8}.freeze def unpack_value! return nil if depleted? marker = shift_byte! if type_and_size = PackStream.marker_type_and_size(marker) type, size = type_and_size shift_value_for_type!(type, size, marker) elsif MARKER_TYPES.key?(marker) MARKER_TYPES[marker] else marker >= 0xF0 ? -0x100 + marker : marker end end private METHOD_MAP = { int: :value_for_int!, float: :value_for_float!, tiny_list: :value_for_list!, list: :value_for_list!, tiny_map: :value_for_map!, map: :value_for_map!, tiny_struct: :value_for_struct!, struct: :value_for_struct! } def shift_value_for_type!(type, size, marker) if %i[text list map struct].include?(type) offset = marker - HEADER_BASE_BYTES[type] size = shift_stream!(2 << (offset - 1)).reverse.unpack(HEADER_PACK_STRINGS[offset])[0] end if %i[tiny_text text bytes].include?(type) shift_stream!(size).force_encoding('UTF-8') else send(METHOD_MAP[type], size) end end def value_for_int!(size) r = shift_bytes!(size >> 3).reverse.each_with_index.inject(0) do |sum, (byte, i)| sum + (byte * (256**i)) end (r >> (size - 1)) == 1 ? (r - (2**size)) : r end def value_for_float!(_size) shift_stream!(8).unpack('G')[0] end def value_for_map!(size) size.times.each_with_object({}) do |_, r| key = unpack_value! r[key] = unpack_value! end end def value_for_list!(size) Array.new(size) { unpack_value! } end def value_for_struct!(size) Structure.new(shift_byte!, value_for_list!(size)) end def shift_byte! shift_bytes!(1).first unless depleted? end def shift_bytes!(length) result = shift_stream!(length) result && result.bytes.to_a end def shift_stream!(length) @stream.read(length) if !depleted? || length.zero? end def depleted? @stream.eof? end end def self.marker_type_and_size(marker) if (marker_spec = MARKER_TYPES[marker]).is_a?(Array) marker_spec else case marker when 0x80..0x8F then [:tiny_text, marker - 0x80] when 0x90..0x9F then [:tiny_list, marker - 0x90] when 0xA0..0xAF then [:tiny_map, marker - 0xA0] when 0xB0..0xBF then [:tiny_struct, marker - 0xB0] end end end end end end