sequoia-git 0.5.0

A tool for managing and enforcing a commit signing policy.
Documentation
use std::{
    collections::{
        btree_map::Entry,
        BTreeMap,
        BTreeSet,
    },
    time::{
        Duration,
        SystemTime,
        UNIX_EPOCH,
    },
};

use anyhow::Context;

use sequoia_openpgp as openpgp;
use openpgp::{
    KeyID,
    Packet,
    parse::{
        PacketParser,
        PacketParserResult,
        Parse,
    },
};

use crate::Config;
use crate::Result;
use crate::cli::InitSubcommand;
use crate::git_repo;

// How far in the past to look to find active contributors.
const HORIZON: Duration = Duration::new(183 * 24 * 60 * 60, 0);
// The minimum number of commits to examine.
const MIN_COMMITS: usize = 10;

pub fn dispatch(config: &Config, _c: InitSubcommand) -> Result<()> {
    let git = git_repo()?;
    let head_id = git.head().context("Looking up HEAD")?
        .resolve().context("Resolving HEAD to a commit")?
        .target().expect("resolved to direct reference");

    // We do a breath-first search and examine the commits since
    // CUTOFF, but at least MIN_COMMITS.

    let now = SystemTime::now();
    let cutoff = now - HORIZON;
    // Set to true if there aren't enough commits after the cutoff.
    let mut cutoff_exceeded = false;

    // Whether we've already processed a commit.
    let mut processed: BTreeSet<git2::Oid> = Default::default();
    // The last commit that we processed.
    let mut last_processed = None;

    // Commits that we still need to process.
    let mut pending: BTreeSet<git2::Oid> = Default::default();
    pending.insert(head_id.clone());

    let mut unsigned: Vec<String> = Vec::new();

    struct Committer {
        commit_count: usize,

        // Keys used by this commit and the number of commits they
        // signed.
        signing_keys: BTreeMap<KeyID, usize>,
    }
    let mut committers: BTreeMap<String, Committer> = Default::default();

    while let Some(commit_id) = pending.pop_first() {
        processed.insert(commit_id.clone());
        last_processed = Some(commit_id.clone());

        let commit = git.find_commit(commit_id)
            .with_context(|| {
                format!("Getting commit data for {}", commit_id)
            })?;

        if config.verbose() {
            println!("{}: {}", commit_id, commit.summary().unwrap_or(""));
        }

        // See who signed it.
        let mut signing_keys: BTreeSet<KeyID> = Default::default();
        let mut have_sig = false;
        if let Ok((sig, _data)) = git.extract_signature(&commit_id, None) {
            // We expect signature packets.  Anything else is not ok.
            let mut ok = true;

            if let Ok(mut ppr) = PacketParser::from_bytes(&sig[..]) {
                while let PacketParserResult::Some(pp) = ppr {
                    match pp.next() {
                        Ok((packet, next_ppr)) => {
                            ppr = next_ppr;

                            if let Packet::Signature(sig) = packet {
                                have_sig = true;

                                let issuers = sig.get_issuers();
                                if config.verbose() {
                                    for issuer in issuers.iter() {
                                        println!("  Allegedly signed by {}", issuer);
                                    }
                                }
                                signing_keys.extend(
                                    issuers.into_iter().map(KeyID::from));
                            } else {
                                ok = false;
                                break;
                            }
                        }
                        Err(err) => {
                            eprintln!("Warning: {} contains an invalid \
                                       signature: {}",
                                      commit_id, err);
                            ok = false;
                            break;
                        }
                    }
                }
            }

            if ! ok {
                have_sig = false;
                signing_keys.clear();
            }
        }

        if ! have_sig {
            unsigned.push(
                commit.as_object()
                    .short_id().ok()
                    .and_then(|id| id.as_str().map(|id| id.to_string()))
                    .unwrap_or_else(|| commit.id().to_string()));
        }

        let committer = commit.committer();
        let committer = format!(
            "{}{}{}",
            committer.name().unwrap_or(""),
            if committer.name().is_some() && committer.email().is_some() {
                " "
            } else {
                ""
            },
            committer
                .email()
                .map(|e| format!("<{}>", e))
                .unwrap_or("".to_string()));

        match committers.entry(committer) {
            Entry::Occupied(mut oe) => {
                let e = oe.get_mut();

                e.commit_count += 1;
                for signing_key in signing_keys.into_iter() {
                    e.signing_keys.entry(signing_key)
                        .and_modify(|e| {
                            *e += 1;
                        })
                        .or_insert(1);
                }
            }
            Entry::Vacant(e) => {
                let mut info = Committer {
                    commit_count: 1,
                    signing_keys: BTreeMap::new(),
                };

                for signing_key in signing_keys.into_iter() {
                    info.signing_keys.insert(signing_key, 1);
                }

                e.insert(info);
            }
        }

        for parent in commit.parents() {
            let parent_id = parent.id();
            if processed.contains(&parent_id)
                || pending.contains(&parent_id)
            {
                continue;
            }

            let commit_time = parent.time();
            let commit_time
                = UNIX_EPOCH + Duration::new(commit_time.seconds() as u64, 0);
            if commit_time < cutoff {
                if processed.len() + pending.len() > MIN_COMMITS {
                    continue;
                } else {
                    cutoff_exceeded = true;
                }
            }

            pending.insert(parent_id.clone());
        }
    }

    if cutoff_exceeded {
        println!("# Examined {} recent commits.", processed.len());
    } else {
        println!("# Examined the {} commits in the last {} days.",
                 processed.len(), HORIZON.as_secs() / 24 / 60 / 60);
    }
    if let Some(commit_id) = last_processed {
        println!("# Stopped at commit {}.", commit_id);
    }
    println!();

    print!("# Encountered {} unsigned commits", unsigned.len());
    if ! unsigned.is_empty() {
        print!(" (");
        for (i, id) in unsigned.iter().enumerate() {
            if i == 3 && ! config.verbose() {
                print!("...");
                break;
            }

            if i > 0 {
                print!(" ");
            }
            print!("{}", id);
        }
        print!(")");
    }
    println!();

    let mut committers: Vec<(String, Committer)> = committers.into_iter().collect();
    committers.sort_by_key(|(committer, info)| {
        (usize::MAX - info.commit_count, committer.clone())
    });
    for (i, (committer, info)) in committers.iter().enumerate() {
        println!();
        println!("\
# {} added {} commits ({}%).
#",
                 committer, info.commit_count,
                 (100 * info.commit_count) / processed.len());

        if info.signing_keys.is_empty() {
            println!("\
# Never signed any commits.  To authorize them, you'll need their OpenPGP
# certificate.");
            continue;
        }

        println!("\
# After checking that they really control the following OpenPGP keys:
#
#   {}
#",
                 info
                     .signing_keys.iter()
                     .map(|(keyid, count)| {
                         format!("{} ({} commits)", keyid, count)
                     })
                     .collect::<Vec<String>>()
                     .join("#   "));

        if i == 0 {
            println!("\
# You can make them a project maintainer (someone who can add and
# remove committers) by running:");
        } else {
            println!("\
# You can make them a committer by running:");
        }
        for (keyid, _count) in info.signing_keys.iter() {
            println!("sq-git policy authorize {} {:?} {}",
                     if i == 0 {
                         " --project-maintainer"
                     } else {
                         "--committer"
                     },
                     committer,
                     keyid);
        }
    }

    Ok(())
}