sequoia-git 0.5.0

A tool for managing and enforcing a commit signing policy.
Documentation
use std::borrow::Cow;
use std::io;
use std::sync::Mutex;

use git2::{Oid, Repository};
use serde::Serialize;
use openpgp::{
    Cert,
    Fingerprint,
    Packet,
    packet::Signature,
};
use super::*;

/// What output format to prefer, when there's an option?
#[derive(Clone)]
pub enum Format {
    /// Output that is meant to be read by humans, instead of programs.
    ///
    /// This type of output has no version, and is not meant to be
    /// parsed by programs.
    HumanReadable,

    /// Output as JSON.
    Json,
}

impl std::str::FromStr for Format {
    type Err = anyhow::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "human-readable" => Ok(Self::HumanReadable),
            "json" => Ok(Self::Json),
            _ => Err(anyhow!("unknown output format {:?}", s)),
        }
    }
}

/// Emits a human-readable description of the policy to stdout.
pub fn describe_policy(p: &Policy) -> Result<()> {
    println!("# OpenPGP policy file for git, version {}",
             p.version());
    println!();

    println!("## Commit Goodlist");
    println!();

    for commit in p.commit_goodlist() {
        println!("  - {}", commit);
    }
    println!();

    println!("## Authorizations");
    println!();

    for (i, (name, auth)) in p.authorization().iter().enumerate()
    {
        println!("{}. {}", i, name);
        let ident = vec![' '; i.to_string().len() + 2]
            .into_iter().collect::<String>();

        if auth.sign_commit {
            println!("{}- may sign commits", ident);
        }
        if auth.sign_tag {
            println!("{}- may sign tags", ident);
        }
        if auth.sign_archive {
            println!("{}- may sign archives", ident);
        }
        if auth.add_user {
            println!("{}- may add users", ident);
        }
        if auth.retire_user {
            println!("{}- may retire users", ident);
        }
        if auth.audit {
            println!("{}- may goodlist commits", ident);
        }
        for cert in auth.certs()? {
            println!("{}- has OpenPGP cert: {}", ident,
                     cert?.fingerprint());
        }
    }
    Ok(())
}

/// Emits a human-readable description of a difference between two
/// policies to stdout.
pub fn describe_diff(p: &Diff) -> Result<()> {
    fn quote<'a>(s: &'a str) -> Cow<'a, str> {
        if s.chars().any(|c| {
            ! (c.is_alphanumeric()
               || ['-', '_', '.', '+'].contains(&c))
        })
        {
            format!("{:?}", s).into()
        } else {
            s.into()
        }
    }

    let quote_component = |c: &Packet| -> String {
        match c {
            Packet::PublicSubkey(k) => {
                format!("subkey {}", k.fingerprint())
            }
            Packet::SecretSubkey(k) => {
                format!("subkey (with secret key material) {}", k.fingerprint())
            }
            Packet::UserID(u) => {
                format!("user ID {}", quote(&String::from_utf8_lossy(u.value())))
            }
            c => {
                format!("{} ({:?})", c.tag(), c)
            }
        }
    };

    let is_self_signed = |cert: &Fingerprint, sig: &Signature| -> bool {
        let cert = KeyHandle::from(cert);
        sig.get_issuers().into_iter().any(|kh| kh.aliases(&cert))
    };

    for change in &p.changes {
        use Change::*;
        match change {
            VersionChange { from, to } =>
                println!("  - Version changed from {} to {}.", from, to),

            GoodlistCommit(oid) =>
                println!("  - Commit {} was added to the goodlist.", oid),
            UngoodlistCommit(oid) =>
                println!("  - Commit {} was removed from the goodlist.", oid),

            AddUser(name) =>
                println!("  - User {} was added.", quote(name)),

            RetireUser(name) =>
                println!("  - User {} was retired.", quote(name)),

            AddRight(name, right) =>
                println!("  - User {} was granted the right {}.",
                         quote(name), right),
            RemoveRight(name, right) =>
                println!("  - User {}'s {} right was revoked.",
                         quote(name), right),

            AddCert(name, fpr) =>
                println!("  - User {}: new certificate {}.", quote(name), fpr),
            RemoveCert(name, fpr) =>
                println!("  - User {}: removed certificate {}.", quote(name), fpr),

            AddPacket(name, fpr, component, sig) =>
                println!("  - User {}'s certificate {} has a new {} signature {:02x}{:02x} on {}.",
                         quote(name), fpr,
                         if is_self_signed(fpr, sig) {
                             "self-signed"
                         } else {
                             "third-party"
                         },
                         sig.digest_prefix()[0],
                         sig.digest_prefix()[1],
                         quote_component(component)),
            RemovePacket(name, fpr, component, sig) =>
                println!("  - User {}'s certificate {} lost the {} signature {:02x}{:02x} on {}.",
                         quote(name), fpr,
                         if is_self_signed(fpr, sig) {
                             "self-signed"
                         } else {
                             "third-party"
                         },
                         sig.digest_prefix()[0],
                         sig.digest_prefix()[1],
                         quote_component(component)),
        }
    }

    Ok(())
}

// The version of the commit output.  This follows semantic
// versioning.
static COMMIT_JSON_VERSION: &'static str = "1.0.0";

#[derive(Serialize)]
pub struct Commit<'a> {
    version: &'static str,
    #[serde(serialize_with = "crate::utils::serialize_oid")]
    id: &'a Oid,
    // The commit's summary (if any).
    summary: Option<String>,
    #[serde(serialize_with = "crate::utils::serialize_optional_oid")]
    parent_id: Option<&'a Oid>,
    results: Vec<std::result::Result<String, (String, Option<String>)>>,
}

static MISSING_SIGNATURE_HINT: Mutex<bool> = Mutex::new(false);
static MISSING_KEY_HINT: Mutex<bool> = Mutex::new(false);
static MALFORMED_MESSAGE_HINT: Mutex<bool> = Mutex::new(false);

impl<'a> Commit<'a> {
    pub fn new(git: &Repository,
               id: &'a Oid,
               parent_id: Option<&'a Oid>,
               shadow_policy: &Option<PathBuf>,
               result: &'a sequoia_git::Result<Vec<sequoia_git::Result<(String, Signature, Cert, Fingerprint)>>>)
               -> Result<Self>
    {
        let hint = |e: &Error| -> Option<String> {
            match (shadow_policy, e) {
                (None, _) => None,
                (Some(p), Error::MissingSignature(commit)) => {
                    let mut shown = MISSING_SIGNATURE_HINT.lock().unwrap();
                    if ! *shown {
                        *shown = true;
                        Some(format!("when using an external policy, do\n\n\
                                      git show {1} \n\
                                      \n  and verify that the commit is good.  \
                                      If satisfied, do\n\n\
                                      sq-git policy goodlist --policy-file {0} {1}",
                                     p.display(), commit))
                    } else {
                        None
                    }
                }
                (Some(p), Error::MissingKey(handle)) => {
                    let mut shown = MISSING_KEY_HINT.lock().unwrap();
                    if ! *shown {
                        *shown = true;
                        Some(format!("when using an external policy, do\n\n\
                                      sq keyserver get {1} \n\
                                      \n  and verify that the cert belongs to the \
                                      committer.  If satisfied, do\n\n\
                                      sq-git policy authorize --policy-file {} \
                                      <ROLE-NAME> {} --sign-commit",
                                     p.display(), handle))
                    } else {
                        None
                    }
                }
                (_, Error::Other(e)) => {
                    if let Some(e) = e.downcast_ref::<openpgp::Error>() {
                        if let openpgp::Error::MalformedMessage(_) = e {
                            let mut shown
                                = MALFORMED_MESSAGE_HINT.lock().unwrap();
                            if ! *shown {
                                *shown = true;
                                Some(format!("\
a signature is malformed.  It was probably created by GitHub, which\n\
is known to created invalid signatures.  See the following discussion for\n\
more information:\n\
\n\
https://github.com/orgs/community/discussions/27607"))
                            } else {
                                None
                            }
                        } else {
                            None
                        }
                    } else {
                        None
                    }
                }
                _ => None,
            }
        };

        let mut r = Vec::new();
        match result {
            Ok(results) => {
                for e in results.iter()
                    .filter_map(|r| r.as_ref().err())
                {
                    r.push(Err((e.to_string(), hint(e))));
                }
                for (name, _s, c, _signer_fpr) in results.iter()
                    .filter_map(|r| r.as_ref().ok())
                {
                    r.push(Ok(format!("{} [{}]", name, c.fingerprint())));
                }
            },
            Err(e) => {
                r.push(Err((e.to_string(), hint(e))));
            },
        }

        let mut summary = None;
        match git.find_commit(id.clone()) {
            Ok(commit) => {
                summary = commit.summary().map(String::from);
            }
            Err(err) => {
                eprintln!("Error looking up commit: {}", err);
            }
        }

        Ok(Commit {
            version: COMMIT_JSON_VERSION,
            id,
            summary,
            parent_id,
            results: r,
        })
    }

    pub fn describe(&self, sink: &mut dyn io::Write, verbosity: &Verbosity) -> Result<()> {
        for r in &self.results {
            let id = if let Some(parent_id) = self.parent_id {
                format!("Authenticating {} with {}",
                        self.id, parent_id)
            } else {
                self.id.to_string()
            };

            match r {
                Err((e, hint)) => {
                    if ! verbosity.quiet() {
                        writeln!(sink, "{}:\n  Error: {}", id, e)?;
                        if let Some(summary) = self.summary.as_ref() {
                            writeln!(sink, "  {}", summary)?;
                        }
                        if let Some(h) = hint {
                            writeln!(sink, "\n  Hint: {}", h)?;
                        }
                    }
                },
                Ok(fp) => {
                    if verbosity.verbose() {
                        writeln!(sink, "{}:\n  Signer: {}", id, fp)?;
                        if let Some(summary) = self.summary.as_ref() {
                            writeln!(sink, "  {}", summary)?;
                        }
                    }
                },
            }
        }

        Ok(())
    }
}

// The version of the commit output.  This follows semantic
// versioning.
static ARCHIVE_JSON_VERSION: &'static str = "1.0.0";

#[derive(Serialize)]
pub struct Archive {
    version: &'static str,
    results: Vec<std::result::Result<String, String>>,
}

impl Archive {
    pub fn new(result: sequoia_git::Result<Vec<sequoia_git::Result<(String, Signature, Cert, Fingerprint)>>>)
               -> Result<Self>
    {
        let mut r = Vec::new();
        match result {
            Ok(results) => {
                for e in results.iter()
                    .filter_map(|r| r.as_ref().err())
                {
                    r.push(Err(e.to_string()));
                }
                for (name, _s, c, _signer_fpr) in results.iter()
                    .filter_map(|r| r.as_ref().ok())
                {
                    r.push(Ok(format!("{} [{}]", name, c.fingerprint())));
                }
            },
            Err(e) => {
                r.push(Err(e.to_string()));
            },
        }

        Ok(Self {
            version: ARCHIVE_JSON_VERSION,
            results: r,
        })
    }

    pub fn describe(&self, sink: &mut dyn io::Write) -> Result<()> {
        for r in &self.results {
            match r {
                Err(e) => {
                    writeln!(sink, "{}", e)?;
                },
                Ok(fp) => {
                    writeln!(sink, "{}", fp)?;
                },
            }
        }

        Ok(())
    }
}