lib/sixword/lib.rb

Summary

Maintainability
A
2 hrs
Test Coverage
module Sixword

  # The Lib module contains various internal utility functions. They are not
  # really part of the public API and will probably not be useful to external
  # callers.
  module Lib

    # Encode an array of 8 bytes as an array of 6 words.
    #
    # @param byte_array [Array<Fixnum>] An array of length 8 containing
    #   integers in 0..255
    #
    # @return [Array<String>] An array of length 6 containing String words from
    #   {Sixword::WORDS}
    #
    # @example
    #   >> Sixword::Lib.encode_64_bits([0] * 8)
    #   => ["A", "A", "A", "A", "A", "A"]
    #
    # @example
    #   >> Sixword::Lib.encode_64_bits([0xff] * 8)
    #   => ["YOKE", "YOKE", "YOKE", "YOKE", "YOKE", "YEAR"]
    #
    def self.encode_64_bits(byte_array)
      unless byte_array.length == 8
        raise ArgumentError.new("Must pass an 8-byte array")
      end

      int = byte_array_to_int(byte_array)

      parity_bits = parity_int(int)

      encoded = Array.new(6)

      last_index = ((int & 511) << 2) | parity_bits
      encoded[5] = WORDS.fetch(last_index).dup
      int >>= 9

      4.downto(0) do |i|
        encoded[i] = WORDS.fetch(int & 2047).dup
        int >>= 11
      end

      encoded
    end

    # Decode an array of 6 words into a 64-bit integer (representing 8 bytes).
    #
    # @param word_array [Array<String>] A 6 element array of String words
    # @param padding_ok [Boolean]
    #
    # @return [Array(Integer, Integer)] a 64-bit integer (the data) and the
    # length of the byte array that it represents (will always be 8 unless
    # padding_ok)
    #
    # @example
    #   >> Sixword::Lib.decode_6_words(%w{COAT ACHE A A A ACT6}, true)
    #   => [26729, 2]
    #
    #   >> Sixword::Lib.decode_6_words(%w{ACRE ADEN INN SLID MAD PAP}, false)
    #   => [5217737340628397156, 8]
    #
    def self.decode_6_words(word_array, padding_ok)
      unless word_array.length == 6
        raise ArgumentError.new("Must pass a six-word array")
      end

      bits_array = []

      padding = 0

      # extract padding, if any
      if padding_ok && word_array[-1][-1] =~ /[1-7]/
        word, padding = extract_padding(word_array[-1])
        word_array[-1] = word
      end

      bits_array = word_array.map {|w| word_to_bits(w) }

      bits_array.each do |bits|
        if bits >= 2048 || bits < 0
          raise RuntimeError.new("Somehow got bits of #{bits.inspect}")
        end
      end

      int = 0
      (0..4).each do |i|
        int <<= 11
        int += bits_array.fetch(i)
      end

      # slice out parity from last word
      parity = bits_array.fetch(5) & 0b11
      int <<= 9
      int += bits_array.fetch(5) >> 2

      # check parity
      unless parity_int(int) == parity
        raise InvalidParity.new("Parity bits do not match: " +
                                word_array.join(' ').inspect)
      end

      # omit padding bits, if any
      int >>= padding * 8

      [int, 8 - padding]
    end

    # Extract the numeric padding from a word.
    #
    # @param word [String]
    # @return [Array(String, Integer)] The String word, the Integer padding
    #
    # @example
    #   >> Sixword::Lib.extract_padding("WORD3")
    #   => ["WORD", 3]
    #
    def self.extract_padding(word)
      unless word[-1] =~ /[1-7]/
        raise ArgumentError.new("Not a valid padded word: #{word.inspect}")
      end

      return word[0...-1], Integer(word[-1])
    end

    # Decode an array of 6 words into a String of bytes.
    #
    # @param word_array [Array<String>] A 6 element array of String words
    # @param padding_ok [Boolean]
    #
    # @return [String]
    #
    # @see Sixword.decode_6_words
    # @see Sixword.int_to_byte_array
    #
    # @example
    #   >> Lib.decode_6_words_to_bstring(%w{COAT ACHE A A A ACT6}, true)
    #   => "hi"
    #
    #   >> Lib.decode_6_words_to_bstring(%w{ACRE ADEN INN SLID MAD PAP}, false)
    #   => "Hi world"
    #
    def self.decode_6_words_to_bstring(word_array, padding_ok)
      int_to_byte_array(*decode_6_words(word_array, padding_ok)).
        map(&:chr).join
    end

    # Given a word, return the 11 bits it represents as an integer (i.e. its
    # index in the WORDS list).
    #
    # @param word [String]
    # @return [Fixnum] An integer 0..2047
    #
    def self.word_to_bits(word)
      word = word.upcase
      return WORDS_HASH.fetch(word)
    rescue KeyError
      if (1..4).include?(word.length)
        raise UnknownWord.new("Unknown word: #{word.inspect}")
      else
        raise InvalidWord.new("Word must be 1-4 chars, not #{word.inspect}")
      end
    end

    # Compute two-bit parity on a byte array by summing each pair of bits.
    # TODO: figure out which is faster
    #
    # @param byte_array [Array<Fixnum>]
    # @return [Fixnum] An integer 0..3
    #
    # @see parity_int
    #
    def self.parity_array(byte_array)

      # sum pairs of bits through the whole array
      parity = 0
      byte_array.each do |byte|
        while byte > 0
          parity += byte & 0b11
          byte >>= 2
        end
      end

      # return the least significant two bits
      parity & 0b11
    end

    # Compute two-bit parity on a 64-bit integer representing an 8-byte array
    # by summing each pair of bits.
    # TODO: figure out which is faster
    #
    # @param int [Integer] A 64-bit integer representing 8 bytes
    # @return [Fixnum] An integer 0..3
    #
    # @see parity_array
    #
    def self.parity_int(int)
      parity = 0
      while int > 0
        parity += int & 0b11
        int >>= 2
      end

      parity & 0b11
    end

    # Given an array of bytes, pack them into a single Integer.
    #
    # @example
    #
    #     >> byte_array_to_int([1, 2])
    #     => 258
    #
    # @param byte_array [Array<Fixnum>]
    #
    # @return [Integer]
    #
    def self.byte_array_to_int(byte_array)
      int = 0
      byte_array.each do |byte|
        int <<= 8
        int |= byte
      end
      int
    end

    # Given an Integer, unpack it into an array of bytes.
    #
    # @example
    #     >> int_to_byte_array(258)
    #     => [1, 2]
    #
    # @example
    #     >> int_to_byte_array(258, 3)
    #     => [0, 1, 2]
    #
    # @param int [Integer]
    # @param length [Integer] Left zero padded size of byte array to return. If
    #   not provided, no leading zeroes will be added.
    #
    # @return [Array<Fixnum>]
    #
    def self.int_to_byte_array(int, length=nil)
      unless int >= 0
        raise ArgumentError.new("Not sure what to do with negative numbers")
      end

      arr = []

      while int > 0
        arr << (int & 255)
        int >>= 8
      end

      # pad to appropriate length with leading zeroes
      if length
        raise ArgumentError.new("Cannot pad to length < 0") if length < 0

        while arr.length < length
          arr << 0
        end
      end

      arr.reverse!

      arr
    end
  end
end