#!/usr/bin/ruby
# coding: utf-8
require 'git'
require 'date'

Gitdir = '/home/gwolf/vcs/debian-keyring'
Keyrings = %w(nonupload maintainers keyring)
Dstdir = '/tmp/keyring-data'
LogLevel = 4

Dir.exists?(Dstdir) or Dir.mkdir(Dstdir)

git = Git.open(Gitdir)

# Output the gathered data as a square matrix. Keys are sorted, both
# top-down and left-right.
def gen_csv(keyring, tag, data)
  log(2, '...data CSV')
  sig = 0
  File.open(File.join(Dstdir, '%s-%s.csv' % [keyring, tag]), 'w') do |csv|
    columns = data.keys.sort
    csv.puts ['""', columns].join(',')
    columns.each do |key|
      sig += data[key].size
      csv.puts [key, columns.map {|col| data[key].has_key?(col) ? data[key][col] : '""'}].join(',')
    end
    log(4, '%d keys, %d signatures' % [columns.size, sig])
  end
end

# Generate a plain Neato (Graphviz) representation of the keyring
def gen_plain_neato(keyring, tag, data)
  log(2, '...plain Neato commands')
  out_dir = out_dir_for 'neato_plain'

  write_neato(out_dir, keyring, tag) do |neato|
    data.keys.sort.each do |fromkey|
      neato.puts '    "%s";' % fromkey
      data[fromkey].keys.each do |tokey|
        neato.puts '    "%s" -> "%s";' % [fromkey, tokey]
      end
    end
    neato.puts '    label="%s at %s (%d keys)"' % [keyring, tag, data.size]
  end
end

# Generate a Neato (Graphviz) representation of the keyring focusing
# on the signatures' age, and (optionally, depending on the fourth
# argument) discarding signatures older than a given threshold
def gen_age_neato(keyring, tag, data, expire=nil)
  log(2, '...age-oriented Neato commands ' +
         (expire ? '(expiring after %s)' % expire : 'not expiring'))
  out_dir = out_dir_for (expire ? 'neato_age_%s' % expire: 'neato_age')
  by_age = [0,0,0,0,0]
  skipped = 0

  # Base date: Represented in days since the Epoch
  base = Date.parse(tag).strftime('%s').to_i / 86400

  write_neato(out_dir, keyring, tag) do |neato|
    data.keys.sort.each do |fromkey|
      neato.puts '    "%s" [label=""];' % fromkey
      data[fromkey].keys.each do |tokey|
        # data[fromkey][tokey] is represented in seconds since the
        # Epoch. We operate with days-only resolution.
        age = base - (data[fromkey][tokey].to_i / 86400)

        if expire and age > expire
          skipped += 1
          next
        end

        if age < 365
          by_age[0] += 1
          color = 'blue'
        elsif age < 730
          by_age[1] += 1
          color = 'green'
        elsif age < 1095
          by_age[2] += 1
          color = 'yellow'
        elsif age < 1460
          by_age[3] += 1
          color = 'orange'
        else
          by_age[4] += 1
          color = 'red'
        end
        neato.puts '    "%s" -> "%s" [color=%s,dir=none];' % [fromkey, tokey, color]
      end
    end
    if expire
      neato.puts(('    label="%s at %s, expiring %d sigs over %d days\\n' +
                  '(%d keys: %d <1yr, %d <2yr, %d <3yr, %d <4yr, %d older )"') %
                 [keyring, tag, skipped, expire, data.size, by_age].flatten)
    else
      neato.puts(('    label="%s at %s (%d keys:\\n%d <1yr ' +
                  '%d <2yr, %d <3yr, %d <4yr, %d older )"') %
                 [keyring, tag, data.size, by_age].flatten)
    end
  end
end

def gen_asymetric_neato(keyring, tag, data)
  log(2, '...asymetric signatures')
  out_dir = out_dir_for 'neato_asymetric'
  totals = {:mutual => 0, :single => 0}

  write_neato(out_dir, keyring, tag) do |neato|
    data.keys.sort.each do |fromkey|
      neato.puts '    "%s";' % fromkey
      data[fromkey].keys.each do |tokey|
        # Ignore self-signatures
        next if fromkey == tokey

        if data[tokey].include?(fromkey)
          totals[:mutual] += 1
          neato.puts '    "%s" -> "%s";' % [fromkey, tokey]
        else
          totals[:single] += 1
          neato.puts '      "%s" -> "%s" [color=red];' % [fromkey, tokey]
        end
      end
    end

    totals[:total] = totals[:mutual] + totals[:single]
    neato.puts(('    label="Asymetric signatures on %s at %s (%d keys, ' +
                '%d mutual, %d single: %2.2f%% single)"') %
               [keyring, tag, data.size, totals[:mutual], totals[:single],
                (100 * totals[:single]/totals[:total].to_f) ] )
  end
end

def write_neato(dir, keyring, tag, &body)
  File.open(File.join(dir, '%s-%s.neato' % [keyring, tag]), 'w') do |neato|
    neato.puts 'digraph G {'
    neato.puts '    layout = neato;'
    neato.puts '    edge[len=4.0,dir=none];'
    neato.puts '    node[shape=point];'
    neato.puts '    fontsize=96;'

    body.call(neato)

    neato.puts '}'
  end
end

def out_dir_for(spec)
  dir = File.join(Dstdir, spec)
  Dir.mkdir(dir) unless File.directory?(dir)
  return dir
end

def log(level, msg)
  return unless level <= LogLevel
  $stderr.puts ' '*level + msg
end

begin
  log(0, 'Starting keyring analysis')
  log(0, 'Processing keyrings: ' + Keyrings.join(', '))
  log(0, 'Git directory: %s; destination directory: %s' % [Gitdir, Dstdir])
  Dir.mkdir(Dstdir) unless File.directory?(Dstdir)

  git.tags.select {|tag| tag.name =~ /^20\d{2}\.\d{2}\.\d{2}$/}.sort_by {|tag| tag.name}.each do |tag|
    Keyrings.each do |keyring|
      log(1, 'Processing keyring «%s» at %s...' % [keyring, tag.name])
      git.checkout(tag.name)

      # Initialize the set of signatures for this Git tag
      sigs = {}
      # Not all Keyrings (⇒ directories) existed at all times. Don't
      # panic!
      begin
        Dir.open( File.join(Gitdir, 'debian-%s-gpg' % keyring) ).entries.
          select {|key| key =~ /^0x[\dA-F]{16}$/}.
          each { |key| sigs[key.gsub(/^0x/, '')] = {} }
      rescue Errno::ENOENT
      end

      # Find the signatures done by each of our keys.
      sigs.keys.sort.each do |key|
        log(5, 'Processing key %s' % key)
        keyfile = File.join(Gitdir, 'debian-%s-gpg' % keyring, '0x%s'%key)
        packets = `gpg --list-packets #{keyfile}`.split(/^:/m)

        packets.each do |pkt|
          next unless pkt =~ /signature packet:/
          pkt =~ /keyid ([\dA-F]{16})/ && signee = $1
          pkt =~ /created (\d+)/ && age = $1
          # We only care for keys that are actually part of our keyring (at
          # its current snapshot)
          next unless sigs.has_key?(signee)
          if signee and age
            sigs[key][signee] = age
          end
        end
      end

      gen_csv(keyring, tag.name, sigs)
      gen_plain_neato(keyring, tag.name, sigs)
      gen_age_neato(keyring, tag.name, sigs)
      gen_age_neato(keyring, tag.name, sigs, 5*365)
      gen_asymetric_neato(keyring, tag.name, sigs)
    end
  end
ensure
  # Git tree should be left at 'master' no matter what happens
  git.checkout('master')
end

