git-checks 3.5.2

Checks to run against a topic in git to enforce coding standards.
Documentation
// Copyright 2016 Kitware, Inc.
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.

use crates::ttl_cache::TtlCache;

use impl_prelude::*;

use std::collections::hash_set::HashSet;
use std::fmt::{self, Debug};
use std::process::Command;
use std::sync::Mutex;
use std::time::Duration;

#[derive(Debug, Clone, Copy)]
/// Configuration value for `ValidName` policy for use of full names in identities.
pub enum ValidNameFullNamePolicy {
    /// A full name is required, error when missing.
    Required,
    /// A full name is preferred, warning when missing.
    Preferred,
    /// A full name is optional, no diagnostic when missing.
    Optional,
}

impl Default for ValidNameFullNamePolicy {
    fn default() -> Self {
        ValidNameFullNamePolicy::Required
    }
}

impl ValidNameFullNamePolicy {
    /// Apply the policy to a check result.
    fn apply<F>(&self, result: &mut CheckResult, msg: F)
        where F: Fn(&str) -> String,
    {
        match *self {
            ValidNameFullNamePolicy::Required => {
                result.add_error(msg("required"));
            },
            ValidNameFullNamePolicy::Preferred => {
                result.add_warning(msg("preferred"));
            },
            ValidNameFullNamePolicy::Optional => {},
        }
    }
}

const LOCK_POISONED: &str = "DNS cache lock poisoned";
const DEFAULT_TTL_CACHE_SIZE: usize = 100;
lazy_static! {
    // 24 hours
    static ref DEFAULT_TTL_CACHE_HIT_DURATION: Duration = Duration::from_secs(24 * 60 * 60);
    // 5 minutes
    static ref DEFAULT_TTL_CACHE_MISS_DURATION: Duration = Duration::from_secs(5 * 60);
}

/// A check which checks for valid identities.
///
/// This check uses the `host` external binary to check the validity of domain names used in email
/// addresses.
///
/// The check can be configured with a policy on how to enforce use of full names.
pub struct ValidName {
    /// The policy for names in commits.
    full_name_policy: ValidNameFullNamePolicy,
    /// A cache of DNS query results.
    dns_cache: Mutex<TtlCache<String, bool>>,
    /// Whitelisted domains.
    whitelisted_domains: HashSet<String>,
}

impl Debug for ValidName {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        f.debug_struct("ValidName")
            .field("full_name_policy", &self.full_name_policy)
            .field("whitelisted_domains", &self.whitelisted_domains)
            .finish()
    }
}

impl Default for ValidName {
    fn default() -> Self {
        Self {
            full_name_policy: ValidNameFullNamePolicy::default(),
            dns_cache: Mutex::new(TtlCache::new(DEFAULT_TTL_CACHE_SIZE)),
            whitelisted_domains: HashSet::new(),
        }
    }
}

impl Clone for ValidName {
    fn clone(&self) -> Self {
        Self {
            full_name_policy: self.full_name_policy,
            dns_cache: Mutex::new(TtlCache::new(DEFAULT_TTL_CACHE_SIZE)),
            whitelisted_domains: self.whitelisted_domains.clone(),
        }
    }
}

impl ValidName {
    /// Create a new check to check identities.
    pub fn new() -> Self {
        Default::default()
    }

    /// Set policy for enforcement of use of full names.
    pub fn set_full_name_policy(&mut self, policy: ValidNameFullNamePolicy) -> &mut Self {
        self.full_name_policy = policy;
        self
    }

    /// Add domains to the domain whitelist.
    pub fn whitelist_domains<I, D>(&mut self, domains: I) -> &mut Self
        where I: IntoIterator<Item = D>,
              D: ToString,
    {
        self.whitelisted_domains
            .extend(domains.into_iter().map(|domain| domain.to_string()));
        self
    }

    /// Check that a name is valid.
    fn check_name(name: &str) -> bool {
        name.find(' ').is_some()
    }

    fn check_host(domain: &str) -> Option<bool> {
        let dig = Command::new("host")
            .arg("-t").arg("MX")
            .arg(format!("{}.", domain)) // Search for the absolute domain.
            .output();
        let dig_output = match dig {
            Ok(dig_output) => dig_output,
            Err(err) => {
                error!(target: "git-checks/valid_name",
                       "failed to construct host command: {:?}",
                       err);

                return None;
            },
        };

        if dig_output.status.success() {
            Some(true)
        } else {
            // The `host` tool always outputs to stdout
            let output = String::from_utf8_lossy(&dig_output.stdout);

            warn!(target: "git-checks/valid_name",
                  "failed to look up MX record for domain {}: {}",
                  domain,
                  output);

            if output.contains("connection timed out") {
                None
            } else {
                Some(false)
            }
        }
    }

    /// Check that an email address is valid.
    fn check_email(&self, email: &str) -> bool {
        let domain_part = email.splitn(2, '@')
            .nth(1);

        if let Some(domain) = domain_part {
            if self.whitelisted_domains.contains(domain) {
                return true;
            }

            let mut cache = self.dns_cache.lock().expect(LOCK_POISONED);
            if let Some(cached_res) = cache.get_mut(domain) {
                return *cached_res;
            }

            Self::check_host(domain)
                .map_or(false, |res| {
                    let duration = if res {
                        *DEFAULT_TTL_CACHE_HIT_DURATION
                    } else {
                        *DEFAULT_TTL_CACHE_MISS_DURATION
                    };

                    cache.insert(domain.to_string(), res, duration);
                    res
                })
        } else {
            false
        }
    }

    /// Check an identity for its validity.
    fn check_identity(&self, what: &str, who: &str, identity: &Identity) -> CheckResult {
        let mut result = CheckResult::new();

        if !Self::check_name(&identity.name) {
            self.full_name_policy.apply(&mut result, |policy| {
                format!("The {} name (`{}`) for {} has no space in it. \
                         A full name is {} for contribution. Please set the \
                         `user.name` Git configuration value.",
                        who,
                        identity.name,
                        what,
                        policy)
            });
        }

        if !self.check_email(&identity.email) {
            result.add_error(format!("The {} email (`{}`) for {} has an unknown domain. Please set \
                                      the `user.email` Git configuration value.",
                                     who,
                                     identity.email,
                                     what));
        }

        result
    }
}

impl Check for ValidName {
    fn name(&self) -> &str {
        "valid-name"
    }

    fn check(&self, _: &CheckGitContext, commit: &Commit) -> Result<CheckResult> {
        let what = format!("commit {}", commit.sha1);

        Ok(if commit.author == commit.committer {
            self.check_identity(&what, "given", &commit.author)
        } else {
            let author_res = self.check_identity(&what, "author", &commit.author);
            let commiter_res =
                self.check_identity(&what, "committer", &commit.committer);

            author_res.combine(commiter_res)
        })
    }
}

impl BranchCheck for ValidName {
    fn name(&self) -> &str {
        "valid-name"
    }

    fn check(&self, ctx: &CheckGitContext, _: &CommitId) -> Result<CheckResult> {
        Ok(self.check_identity("the topic",
                               "owner",
                               ctx.topic_owner()))
    }
}

#[cfg(test)]
mod tests {
    use checks::ValidName;
    use checks::ValidNameFullNamePolicy;
    use checks::test::*;

    const BAD_TOPIC: &str = "91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8";
    const BAD_AUTHOR_NAME: &str = "edac4e5b3a00eac60280a78ee84b5ef8d4cce97a";

    #[test]
    fn test_valid_name_required() {
        let check = ValidName::new();
        let result = run_check("test_valid_name_required", BAD_TOPIC, check);
        test_result_errors(result, &[
            "The given name (`Mononym`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
             no space in it. A full name is required for contribution. Please set the `user.name` \
             Git configuration value.",
            "The given email (`bademail`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
             an unknown domain. Please set the `user.email` Git configuration value.",
            "The committer email (`bademail@baddomain.invalid`) for commit \
             dcd8895d299031d607481b4936478f8de4cc28ae has an unknown domain. Please set the \
            `user.email` Git configuration value.",
            "The author email (`bademail@baddomain.invalid`) for commit \
             9002239437a06e81a58fed07150b215a917028d6 has an unknown domain. Please set the \
            `user.email` Git configuration value.",
            "The committer email (`bademail`) for commit da71ae048e5a387d6809558d59ad073d0e4fb089 \
             has an unknown domain. Please set the `user.email` Git configuration value.",
            "The committer name (`Mononym`) for commit 1debf1735a6e28880ef08f13baeea4b71a08a846 \
             has no space in it. A full name is required for contribution. Please set the \
             `user.name` Git configuration value.",
            "The author email (`bademail`) for commit 9de4928f5ec425eef414ee7620d0692fda56ebb0 \
             has an unknown domain. Please set the `user.email` Git configuration value.",
            "The author name (`Mononym`) for commit edac4e5b3a00eac60280a78ee84b5ef8d4cce97a has \
             no space in it. A full name is required for contribution. Please set the `user.name` \
             Git configuration value.",
        ]);
    }

    #[test]
    fn test_valid_name_whitelist() {
        let mut check = ValidName::new();
        check.whitelist_domains(&["baddomain.invalid"]);
        let result = run_check("test_valid_name_whitelist", BAD_TOPIC, check);
        test_result_errors(result, &[
            "The given name (`Mononym`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
             no space in it. A full name is required for contribution. Please set the `user.name` \
             Git configuration value.",
            "The given email (`bademail`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
             an unknown domain. Please set the `user.email` Git configuration value.",
            "The committer email (`bademail`) for commit da71ae048e5a387d6809558d59ad073d0e4fb089 \
             has an unknown domain. Please set the `user.email` Git configuration value.",
            "The committer name (`Mononym`) for commit 1debf1735a6e28880ef08f13baeea4b71a08a846 \
             has no space in it. A full name is required for contribution. Please set the \
             `user.name` Git configuration value.",
            "The author email (`bademail`) for commit 9de4928f5ec425eef414ee7620d0692fda56ebb0 \
             has an unknown domain. Please set the `user.email` Git configuration value.",
            "The author name (`Mononym`) for commit edac4e5b3a00eac60280a78ee84b5ef8d4cce97a has \
             no space in it. A full name is required for contribution. Please set the `user.name` \
             Git configuration value.",
        ]);
    }

    #[test]
    fn test_valid_name_preferred() {
        let mut check = ValidName::new();
        check.set_full_name_policy(ValidNameFullNamePolicy::Preferred);
        let result = run_check("test_valid_name_preferred", BAD_AUTHOR_NAME, check);
        test_result_warnings(result, &[
            "The author name (`Mononym`) for commit edac4e5b3a00eac60280a78ee84b5ef8d4cce97a has \
             no space in it. A full name is preferred for contribution. Please set the \
             `user.name` Git configuration value.",
        ]);
    }

    #[test]
    fn test_valid_name_optional() {
        let mut check = ValidName::new();
        check.set_full_name_policy(ValidNameFullNamePolicy::Optional);
        run_check_ok("test_valid_name_optional", BAD_AUTHOR_NAME, check);
    }
}