require 'spec_helper'

require 'aead/nonce'
require 'tempfile'
require 'set'

describe AEAD::Nonce do
  subject { AEAD::Nonce.new }

  before do
    subject.class.send(:stub_for_testing!, self.state_file)
  end

  after do
    subject.send(:state_file).unlink
  end

  let(:temp_file) do
    Tempfile.new('ruby-aead')
  end

  let(:state_file) do
    Pathname.new(self.temp_file.path)
  end

  it 'must create nonexistent state files with restrictive permissions' do
    _, err = capture_io do
      self.state_file.unlink

      subject.shift

      self.state_file          .must_be :exist?
      self.state_file.stat.mode.must_equal 0100600
      self.state_file.size     .must_equal 12
    end

    err.must_match %{WARNING}
  end

  it 'must generate 12-byte nonces' do
    subject.shift.bytesize.must_equal 12
  end

  it 'must generate sequential nonces' do
    subject.shift.must_be :<, subject.shift
  end

  it 'must never generate duplicate nonces across multiple instances' do
    subject.shift # ensure state is initialized

    copy  = subject.clone
    count = subject.class::COUNTER_BATCH_SIZE * 10

    t_1 = Thread.new { Set.new.tap {|s| count.times { s << subject.shift } } }
    t_2 = Thread.new { Set.new.tap {|s| count.times { s << copy   .shift } } }

    (t_1.value + t_2.value).length.must_equal(count * 2)
  end

  it 'must be thread-safe' do
    count   = subject.class::COUNTER_BATCH_SIZE * 5
    thread  = lambda { Set.new.tap {|s| count.times { s << subject.shift } } }
    threads = 5.times.map { Thread.new(&thread) }

    threads.map(&:value).inject(&:+).length.must_equal(count * 5)
  end

  it 'must not allow the counter to roll over' do
    self.state_file.open('wb') do |io|
      io.write [
        '1' * 12,
        '0' *  4,
        '%08x' % (subject.class::COUNTER_MAXIMUM_VALUE.hex - 5),
      ].pack(subject.class::PACK_FORMAT)
    end

    subject.shift(5)

    lambda { subject.shift }.must_raise ArgumentError
  end

  it 'must reserve chunks of nonces in the state file' do
    subject.shift # prime the state_file

    self.state_file.open('rb') do |io|
      io.read.must_equal subject.shift(subject.class::COUNTER_BATCH_SIZE).last
      io.rewind

      subject.shift

      io.read.must_equal subject.shift(subject.class::COUNTER_BATCH_SIZE).last
    end
  end

  it 'must abort when the nonce state file can be determined to be corrupt' do
    [1, 11, 13, 500].each do |count|
      self.state_file.open('wb') do |io|
        io.write SecureRandom.random_bytes(count)
      end

      lambda { subject.shift }.must_raise ArgumentError
    end
  end

  it 'must abort when the nonce contains the MAC for a different machine' do
    self.state_file.open('wb') do |io|
      io.write [
        # FIXME: use ~MAC_MULTICAST_MASK, but figure out how to do so
        # reliably given Ruby's inability to do binary math correctly :(
        (SecureRandom.hex(6).hex & 0xfeffffffffff).to_s(16),
        SecureRandom.hex(2),
        SecureRandom.hex(4),
      ].pack(subject.class::PACK_FORMAT)
    end

    lambda { subject.shift }.must_raise ArgumentError
  end

  it 'must not abort when the nonce contains a pseudo MAC address' do
    subject.stub(:mac_address, subject.send(:mac_address_pseudo)) do
      subject.shift.must_be_kind_of String
    end
  end
end
