use std::collections::hash_set::HashSet;
use std::fmt::{self, Debug};
use std::process::Command;
use std::sync::Mutex;
use std::time::Duration;
use derive_builder::Builder;
use git_checks_core::impl_prelude::*;
use log::{error, warn};
use ttl_cache::TtlCache;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ValidNameFullNamePolicy {
#[default]
Required,
Preferred,
Optional,
}
impl ValidNameFullNamePolicy {
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;
const DEFAULT_TTL_CACHE_HIT_DURATION: Duration = Duration::from_secs(24 * 60 * 60);
const DEFAULT_TTL_CACHE_MISS_DURATION: Duration = Duration::from_secs(5 * 60);
#[derive(Builder)]
#[builder(field(private))]
pub struct ValidName {
#[builder(default)]
full_name_policy: ValidNameFullNamePolicy,
#[builder(setter(skip))]
#[builder(default = "empty_dns_cache()")]
dns_cache: Mutex<TtlCache<String, bool>>,
#[builder(private)]
#[builder(setter(name = "_trust_domains"))]
#[builder(default = "HashSet::new()")]
trust_domains: HashSet<String>,
}
impl ValidNameBuilder {
pub fn trust_domains<I, D>(&mut self, domains: I) -> &mut Self
where
I: IntoIterator<Item = D>,
D: Into<String>,
{
self.trust_domains = Some(domains.into_iter().map(Into::into).collect());
self
}
#[deprecated(
since = "4.1.0",
note = "better terminology; use `trust_domains` instead"
)]
pub fn whitelisted_domains<I, D>(&mut self, domains: I) -> &mut Self
where
I: IntoIterator<Item = D>,
D: Into<String>,
{
self.trust_domains = Some(domains.into_iter().map(Into::into).collect());
self
}
}
fn empty_dns_cache() -> Mutex<TtlCache<String, bool>> {
Mutex::new(TtlCache::new(DEFAULT_TTL_CACHE_SIZE))
}
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("trust_domains", &self.trust_domains)
.finish()
}
}
impl Default for ValidName {
fn default() -> Self {
Self {
full_name_policy: ValidNameFullNamePolicy::default(),
dns_cache: empty_dns_cache(),
trust_domains: HashSet::new(),
}
}
}
impl Clone for ValidName {
fn clone(&self) -> Self {
Self {
full_name_policy: self.full_name_policy,
dns_cache: empty_dns_cache(),
trust_domains: self.trust_domains.clone(),
}
}
}
impl ValidName {
pub fn builder() -> ValidNameBuilder {
ValidNameBuilder::default()
}
fn check_name(name: &str) -> bool {
name.find(' ').is_some()
}
fn check_host(domain: &str) -> Option<bool> {
let dig = Command::new("host")
.args(["-t", "MX"])
.arg(format!("{}.", 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 {
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)
}
}
}
fn check_email(&self, email: &str) -> bool {
let domain_part = email.split_once('@').map(|t| t.1);
if let Some(domain) = domain_part {
if self.trust_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.into(), res, duration);
res
})
} else {
false
}
}
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, Box<dyn Error>> {
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, Box<dyn Error>> {
Ok(self.check_identity("the topic", "owner", ctx.topic_owner()))
}
}
#[cfg(feature = "config")]
pub(crate) mod config {
use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck};
use log::warn;
use serde::Deserialize;
#[cfg(test)]
use serde_json::json;
use crate::ValidName;
use crate::ValidNameFullNamePolicy;
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidNameFullNamePolicyIo {
#[serde(rename = "required")]
Required,
#[serde(rename = "preferred")]
Preferred,
#[serde(rename = "optional")]
Optional,
}
impl From<ValidNameFullNamePolicyIo> for ValidNameFullNamePolicy {
fn from(policy: ValidNameFullNamePolicyIo) -> Self {
match policy {
ValidNameFullNamePolicyIo::Required => ValidNameFullNamePolicy::Required,
ValidNameFullNamePolicyIo::Preferred => ValidNameFullNamePolicy::Preferred,
ValidNameFullNamePolicyIo::Optional => ValidNameFullNamePolicy::Optional,
}
}
}
#[derive(Deserialize, Debug)]
pub struct ValidNameConfig {
#[serde(default)]
full_name_policy: Option<ValidNameFullNamePolicyIo>,
#[serde(default)]
trust_domains: Option<Vec<String>>,
#[serde(default)]
whitelisted_domains: Option<Vec<String>>,
}
impl IntoCheck for ValidNameConfig {
type Check = ValidName;
fn into_check(self) -> Self::Check {
let mut builder = ValidName::builder();
if let Some(full_name_policy) = self.full_name_policy {
builder.full_name_policy(full_name_policy.into());
}
if let Some(trust_domains) = self.trust_domains {
builder.trust_domains(trust_domains);
} else if let Some(trust_domains) = self.whitelisted_domains {
warn!(
target: "git-checks/valid_name",
"the `whitelisted_domains` configuration key is deprecated; use \
`trust_domains` instead.",
);
builder.trust_domains(trust_domains);
}
builder
.build()
.expect("configuration mismatch for `ValidName`")
}
}
register_checks! {
ValidNameConfig {
"valid_name" => CommitCheckConfig,
},
}
#[test]
fn test_valid_name_full_name_policy_deserialize() {
let value = json!("required");
let policy = ValidNameFullNamePolicyIo::deserialize(value).unwrap();
assert_eq!(policy, ValidNameFullNamePolicyIo::Required);
assert_eq!(
ValidNameFullNamePolicy::from(policy),
ValidNameFullNamePolicy::Required,
);
let value = json!("optional");
let policy = ValidNameFullNamePolicyIo::deserialize(value).unwrap();
assert_eq!(policy, ValidNameFullNamePolicyIo::Optional);
assert_eq!(
ValidNameFullNamePolicy::from(policy),
ValidNameFullNamePolicy::Optional,
);
let value = json!("preferred");
let policy = ValidNameFullNamePolicyIo::deserialize(value).unwrap();
assert_eq!(policy, ValidNameFullNamePolicyIo::Preferred);
assert_eq!(
ValidNameFullNamePolicy::from(policy),
ValidNameFullNamePolicy::Preferred,
);
let value = json!("invalid");
let err = ValidNameFullNamePolicyIo::deserialize(value).unwrap_err();
assert!(!err.is_io());
assert!(!err.is_syntax());
assert!(err.is_data());
assert!(!err.is_eof());
assert_eq!(
err.to_string(),
"unknown variant `invalid`, expected one of `required`, `preferred`, `optional`",
);
}
#[test]
fn test_valid_name_config_empty() {
let json = json!({});
let check: ValidNameConfig = serde_json::from_value(json).unwrap();
assert_eq!(check.full_name_policy, None);
assert_eq!(check.trust_domains, None);
assert_eq!(check.whitelisted_domains, None);
let check = check.into_check();
if let ValidNameFullNamePolicy::Required = check.full_name_policy {
} else {
panic!("unexpected full name policy: {:?}", check.full_name_policy);
}
itertools::assert_equal(&check.trust_domains, &[] as &[&str]);
}
#[test]
fn test_valid_name_config_all_fields() {
let exp_domain: String = "mycompany.invalid".into();
let json = json!({
"full_name_policy": "optional",
"trust_domains": [exp_domain],
});
let check: ValidNameConfig = serde_json::from_value(json).unwrap();
assert_eq!(
check.full_name_policy,
Some(ValidNameFullNamePolicyIo::Optional),
);
itertools::assert_equal(&check.trust_domains, &Some([exp_domain.clone()]));
assert_eq!(check.whitelisted_domains, None);
let check = check.into_check();
if let ValidNameFullNamePolicy::Optional = check.full_name_policy {
} else {
panic!("unexpected full name policy: {:?}", check.full_name_policy);
}
itertools::assert_equal(&check.trust_domains, &[exp_domain]);
}
#[test]
fn test_valid_name_config_all_fields_deprecated() {
let exp_domain: String = "mycompany.invalid".into();
let json = json!({
"whitelisted_domains": [exp_domain],
});
let check: ValidNameConfig = serde_json::from_value(json).unwrap();
assert_eq!(check.full_name_policy, None);
assert_eq!(check.trust_domains, None);
itertools::assert_equal(&check.whitelisted_domains, &Some([exp_domain.clone()]));
let check = check.into_check();
if let ValidNameFullNamePolicy::Required = check.full_name_policy {
} else {
panic!("unexpected full name policy: {:?}", check.full_name_policy);
}
itertools::assert_equal(&check.trust_domains, &[exp_domain]);
}
#[test]
fn test_valid_name_config_all_fields_with_deprecated() {
let exp_domain: String = "mycompany.invalid".into();
let exp_deprecated_domain: String = "myothercompany.invalid".into();
let json = json!({
"full_name_policy": "optional",
"trust_domains": [exp_domain],
"whitelisted_domains": [exp_deprecated_domain],
});
let check: ValidNameConfig = serde_json::from_value(json).unwrap();
assert_eq!(
check.full_name_policy,
Some(ValidNameFullNamePolicyIo::Optional),
);
itertools::assert_equal(&check.trust_domains, &Some([exp_domain.clone()]));
itertools::assert_equal(&check.whitelisted_domains, &Some([exp_deprecated_domain]));
let check = check.into_check();
if let ValidNameFullNamePolicy::Optional = check.full_name_policy {
} else {
panic!("unexpected full name policy: {:?}", check.full_name_policy);
}
itertools::assert_equal(&check.trust_domains, &[exp_domain]);
}
}
#[cfg(test)]
mod tests {
use git_checks_core::{BranchCheck, Check};
use git_workarea::Identity;
use crate::test::*;
use crate::ValidName;
use crate::ValidNameFullNamePolicy;
const BAD_TOPIC: &str = "91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8";
const BAD_AUTHOR_NAME: &str = "edac4e5b3a00eac60280a78ee84b5ef8d4cce97a";
#[test]
fn test_valid_name_builder_default() {
assert!(ValidName::builder().build().is_ok());
}
#[test]
fn test_valid_name_name_commit() {
let check = ValidName::default();
assert_eq!(Check::name(&check), "valid-name");
}
#[test]
fn test_valid_name_name_branch() {
let check = ValidName::default();
assert_eq!(BranchCheck::name(&check), "valid-name");
}
#[test]
fn test_valid_name_required() {
let check = ValidName::default();
let result = run_check("test_valid_name_required", BAD_TOPIC, check);
test_result_errors(result, &[
"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.",
"The author email (`bademail`) for commit 9de4928f5ec425eef414ee7620d0692fda56ebb0 \
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 committer email (`bademail`) for commit da71ae048e5a387d6809558d59ad073d0e4fb089 \
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@baddomain.invalid`) for commit \
dcd8895d299031d607481b4936478f8de4cc28ae has an unknown domain. Please set the \
`user.email` Git configuration value.",
"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.",
]);
}
#[test]
fn test_valid_name_whitelist() {
let check = ValidName::builder()
.trust_domains(["baddomain.invalid"].iter().cloned())
.build()
.unwrap();
let result = run_check("test_valid_name_whitelist", BAD_TOPIC, check);
test_result_errors(result, &[
"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.",
"The author email (`bademail`) for commit 9de4928f5ec425eef414ee7620d0692fda56ebb0 \
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 committer email (`bademail`) for commit da71ae048e5a387d6809558d59ad073d0e4fb089 \
has an unknown domain. Please set the `user.email` Git configuration value.",
"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.",
]);
}
#[test]
fn test_valid_name_preferred() {
let check = ValidName::builder()
.full_name_policy(ValidNameFullNamePolicy::Preferred)
.build()
.unwrap();
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 check = ValidName::builder()
.full_name_policy(ValidNameFullNamePolicy::Optional)
.build()
.unwrap();
run_check_ok("test_valid_name_optional", BAD_AUTHOR_NAME, check);
}
fn mononym_ident() -> Identity {
Identity::new("Mononym", "email@example.com")
}
fn bademail_ident() -> Identity {
Identity::new("Anon E. Mouse", "bademail")
}
fn bademail_mx_ident() -> Identity {
Identity::new("Anon E. Mouse", "bademail@baddomain.invalid")
}
#[test]
fn test_valid_name_branch_required() {
let check = ValidName::default();
run_branch_check_ok(
"test_valid_name_branch_required/ok",
BAD_TOPIC,
check.clone(),
);
let result = run_branch_check_ident(
"test_valid_name_branch_required/mononym",
BAD_TOPIC,
check.clone(),
mononym_ident(),
);
test_result_errors(result, &[
"The owner name (`Mononym`) for the topic has no space in it. A full name is required \
for contribution. Please set the `user.name` Git configuration value.",
]);
let result = run_branch_check_ident(
"test_valid_name_branch_required/bademail",
BAD_TOPIC,
check.clone(),
bademail_ident(),
);
test_result_errors(
result,
&[
"The owner email (`bademail`) for the topic has an unknown domain. Please set the \
`user.email` Git configuration value.",
],
);
let result = run_branch_check_ident(
"test_valid_name_branch_required/bademail_mx",
BAD_TOPIC,
check,
bademail_mx_ident(),
);
test_result_errors(result, &[
"The owner email (`bademail@baddomain.invalid`) for the topic has an unknown domain. \
Please set the `user.email` Git configuration value.",
]);
}
#[test]
fn test_valid_name_branch_whitelist() {
let check = ValidName::builder()
.trust_domains(["baddomain.invalid"].iter().cloned())
.build()
.unwrap();
run_branch_check_ok(
"test_valid_name_branch_whitelist/ok",
BAD_TOPIC,
check.clone(),
);
let result = run_branch_check_ident(
"test_valid_name_branch_required/mononym",
BAD_TOPIC,
check.clone(),
mononym_ident(),
);
test_result_errors(result, &[
"The owner name (`Mononym`) for the topic has no space in it. A full name is required \
for contribution. Please set the `user.name` Git configuration value.",
]);
let result = run_branch_check_ident(
"test_valid_name_branch_whitelist/bademail",
BAD_TOPIC,
check.clone(),
bademail_ident(),
);
test_result_errors(
result,
&[
"The owner email (`bademail`) for the topic has an unknown domain. Please set the \
`user.email` Git configuration value.",
],
);
run_branch_check_ident_ok(
"test_valid_name_branch_whitelist/bademail_mx",
BAD_TOPIC,
check,
bademail_mx_ident(),
);
}
#[test]
fn test_valid_name_branch_preferred() {
let check = ValidName::builder()
.full_name_policy(ValidNameFullNamePolicy::Preferred)
.build()
.unwrap();
run_branch_check_ok(
"test_valid_name_branch_preferred/ok",
BAD_TOPIC,
check.clone(),
);
let result = run_branch_check_ident(
"test_valid_name_branch_preferred/mononym",
BAD_TOPIC,
check.clone(),
mononym_ident(),
);
test_result_warnings(result, &[
"The owner name (`Mononym`) for the topic has \
no space in it. A full name is preferred for contribution. Please set the `user.name` \
Git configuration value.",
]);
let result = run_branch_check_ident(
"test_valid_name_branch_preferred/bademail",
BAD_TOPIC,
check.clone(),
bademail_ident(),
);
test_result_errors(
result,
&[
"The owner email (`bademail`) for the topic has an unknown domain. Please set the \
`user.email` Git configuration value.",
],
);
let result = run_branch_check_ident(
"test_valid_name_branch_preferred/bademail_mx",
BAD_TOPIC,
check,
bademail_mx_ident(),
);
test_result_errors(result, &[
"The owner email (`bademail@baddomain.invalid`) for the topic has an unknown domain. \
Please set the `user.email` Git configuration value.",
]);
}
#[test]
fn test_valid_name_branch_optional() {
let check = ValidName::builder()
.full_name_policy(ValidNameFullNamePolicy::Optional)
.build()
.unwrap();
run_branch_check_ok(
"test_valid_name_branch_optional/ok",
BAD_TOPIC,
check.clone(),
);
run_branch_check_ident_ok(
"test_valid_name_branch_optional/mononym",
BAD_TOPIC,
check.clone(),
mononym_ident(),
);
let result = run_branch_check_ident(
"test_valid_name_branch_optional/bademail",
BAD_TOPIC,
check.clone(),
bademail_ident(),
);
test_result_errors(
result,
&[
"The owner email (`bademail`) for the topic has an unknown domain. Please set the \
`user.email` Git configuration value.",
],
);
let result = run_branch_check_ident(
"test_valid_name_branch_optional/bademail_mx",
BAD_TOPIC,
check,
bademail_mx_ident(),
);
test_result_errors(result, &[
"The owner email (`bademail@baddomain.invalid`) for the topic has an unknown domain. \
Please set the `user.email` Git configuration value.",
]);
}
#[test]
fn test_valid_name_impl_debug() {
let check = ValidName::builder().build().unwrap();
let out = format!("{:?}", check);
assert_eq!(
out,
"ValidName { full_name_policy: Required, trust_domains: {} }",
);
}
#[test]
fn test_valid_name_impl_clone() {
let check = ValidName::builder().build().unwrap();
{
let mut cache = check.dns_cache.lock().unwrap();
cache.insert(
"example.com".into(),
true,
super::DEFAULT_TTL_CACHE_HIT_DURATION,
);
}
let cloned = check.clone();
assert_eq!(cloned.full_name_policy, check.full_name_policy);
assert_eq!(cloned.dns_cache.lock().unwrap().iter().count(), 0);
assert_eq!(cloned.trust_domains, check.trust_domains);
}
}