git_checks/
valid_name.rs

1// Copyright Kitware, Inc.
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9use std::collections::hash_set::HashSet;
10use std::fmt::{self, Debug};
11use std::io;
12use std::sync::Mutex;
13use std::time::{Duration, Instant};
14
15use derive_builder::Builder;
16use git_checks_core::impl_prelude::*;
17use hickory_proto::ProtoErrorKind;
18use hickory_resolver::name_server::TokioConnectionProvider;
19use hickory_resolver::{Name, ResolveError, ResolveErrorKind, Resolver};
20use lazy_static::lazy_static;
21use log::{error, warn};
22use thiserror::Error;
23use tokio::runtime::Runtime;
24use ttl_cache::TtlCache;
25
26/// Configuration value for `ValidName` policy for use of full names in identities.
27#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
28pub enum ValidNameFullNamePolicy {
29    /// A full name is required, error when missing.
30    #[default]
31    Required,
32    /// A full name is preferred, warning when missing.
33    Preferred,
34    /// A full name is optional, no diagnostic when missing.
35    Optional,
36}
37
38impl ValidNameFullNamePolicy {
39    /// Apply the policy to a check result.
40    fn apply<F>(self, result: &mut CheckResult, msg: F)
41    where
42        F: Fn(&str) -> String,
43    {
44        match self {
45            ValidNameFullNamePolicy::Required => {
46                result.add_error(msg("required"));
47            },
48            ValidNameFullNamePolicy::Preferred => {
49                result.add_warning(msg("preferred"));
50            },
51            ValidNameFullNamePolicy::Optional => {},
52        }
53    }
54}
55
56const LOCK_POISONED: &str = "DNS cache lock poisoned";
57const DEFAULT_TTL_CACHE_SIZE: usize = 100;
58// 24 hours
59// XXX(rust-???): use `Duration::from_days(1); https://github.com/rust-lang/rust/issues/120301
60const DEFAULT_TTL_CACHE_HIT_DURATION: Duration = Duration::from_secs(24 * 60 * 60);
61// 5 minutes
62// XXX(rust-???): use `Duration::from_mins(5); https://github.com/rust-lang/rust/issues/120301
63const DEFAULT_TTL_CACHE_MISS_DURATION: Duration = Duration::from_secs(5 * 60);
64
65lazy_static! {
66    // DNS resolver.
67    static ref DNS_RESOLVER: Result<Resolver<TokioConnectionProvider>, ResolveError> = Resolver::builder_tokio()
68        .map(|builder| builder.build())
69        .map_err(|err| {
70            error!(
71                target: "git-checks/valid_name",
72                "failed to construct DNS resolver: {}",
73                err,
74            );
75
76            err
77        });
78}
79
80/// A check which checks for valid identities.
81///
82/// This check uses the `host` external binary to check the validity of domain names used in email
83/// addresses.
84///
85/// The check can be configured with a policy on how to enforce use of full names.
86#[derive(Builder)]
87#[builder(field(private))]
88pub struct ValidName {
89    /// The policy for names in commits.
90    ///
91    /// Configuration: Optional
92    /// Default: `ValidNameFullNamePolicy::Required`
93    #[builder(default)]
94    full_name_policy: ValidNameFullNamePolicy,
95    /// A cache of DNS query results.
96    #[builder(setter(skip))]
97    #[builder(default = "empty_dns_cache()")]
98    dns_cache: Mutex<TtlCache<String, bool>>,
99    /// Implicitly trusted domains.
100    #[builder(private)]
101    #[builder(setter(name = "_trust_domains"))]
102    #[builder(default = "HashSet::new()")]
103    trust_domains: HashSet<String>,
104}
105
106impl ValidNameBuilder {
107    /// Add domains to the implicitly trusted domain list.
108    pub fn trust_domains<I, D>(&mut self, domains: I) -> &mut Self
109    where
110        I: IntoIterator<Item = D>,
111        D: Into<String>,
112    {
113        self.trust_domains = Some(domains.into_iter().map(Into::into).collect());
114        self
115    }
116
117    /// Add domains to the implicitly trusted domain list.
118    #[deprecated(
119        since = "4.1.0",
120        note = "better terminology; use `trust_domains` instead"
121    )]
122    pub fn whitelisted_domains<I, D>(&mut self, domains: I) -> &mut Self
123    where
124        I: IntoIterator<Item = D>,
125        D: Into<String>,
126    {
127        self.trust_domains = Some(domains.into_iter().map(Into::into).collect());
128        self
129    }
130}
131
132fn empty_dns_cache() -> Mutex<TtlCache<String, bool>> {
133    Mutex::new(TtlCache::new(DEFAULT_TTL_CACHE_SIZE))
134}
135
136impl Debug for ValidName {
137    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
138        f.debug_struct("ValidName")
139            .field("full_name_policy", &self.full_name_policy)
140            .field("trust_domains", &self.trust_domains)
141            .finish()
142    }
143}
144
145impl Default for ValidName {
146    fn default() -> Self {
147        Self {
148            full_name_policy: ValidNameFullNamePolicy::default(),
149            dns_cache: empty_dns_cache(),
150            trust_domains: HashSet::new(),
151        }
152    }
153}
154
155impl Clone for ValidName {
156    fn clone(&self) -> Self {
157        Self {
158            full_name_policy: self.full_name_policy,
159            dns_cache: empty_dns_cache(),
160            trust_domains: self.trust_domains.clone(),
161        }
162    }
163}
164
165#[derive(Debug, Clone, Copy)]
166enum HostLookup {
167    Hit,
168    Miss { valid_until: Option<Instant> },
169}
170
171impl HostLookup {
172    fn is_hit(self) -> bool {
173        matches!(self, HostLookup::Hit)
174    }
175
176    fn cache_duration(self) -> Duration {
177        match self {
178            HostLookup::Hit => DEFAULT_TTL_CACHE_HIT_DURATION,
179            HostLookup::Miss {
180                valid_until,
181            } => {
182                valid_until
183                    .and_then(|inst| {
184                        let now = Instant::now();
185                        inst.checked_duration_since(now)
186                    })
187                    .unwrap_or(DEFAULT_TTL_CACHE_MISS_DURATION)
188            },
189        }
190    }
191}
192
193#[derive(Debug, Error)]
194enum ValidNameError {
195    #[error("failed to initialize a DNS resolver: {}", reason)]
196    NoResolver { reason: String },
197    #[error("failed to start a tokio runtime: {}", source)]
198    TokioRuntime { source: io::Error },
199    #[error("failed to parse the domain name: {}", source)]
200    ParseName {
201        #[from]
202        source: hickory_proto::ProtoError,
203    },
204}
205
206impl ValidNameError {
207    fn no_resolver(source: &ResolveError) -> Self {
208        Self::NoResolver {
209            reason: format!("{}", source),
210        }
211    }
212
213    fn tokio_runtime(source: io::Error) -> Self {
214        Self::TokioRuntime {
215            source,
216        }
217    }
218}
219
220impl ValidName {
221    /// Create a new builder.
222    pub fn builder() -> ValidNameBuilder {
223        Default::default()
224    }
225
226    /// Check that a name is valid.
227    fn check_name(name: &str) -> bool {
228        name.find(' ').is_some()
229    }
230
231    fn check_host(domain: &str) -> Result<Option<HostLookup>, ValidNameError> {
232        let resolver = DNS_RESOLVER.as_ref().map_err(ValidNameError::no_resolver)?;
233
234        // Search for the absolute domain.
235        let abs_domain = format!("{}.", domain);
236        let name = Name::from_str_relaxed(abs_domain)?;
237        let lookup_async = resolver.mx_lookup(name);
238
239        let rt = Runtime::new().map_err(ValidNameError::tokio_runtime)?;
240        let lookup = rt.block_on(lookup_async);
241
242        Ok(match lookup {
243            Ok(_) => Some(HostLookup::Hit),
244            Err(err) => {
245                if let ResolveErrorKind::Proto(err) = err.kind() {
246                    match err.kind() {
247                        ProtoErrorKind::NoRecordsFound {
248                            negative_ttl, ..
249                        } => {
250                            let valid_until = negative_ttl.and_then(|ttl| {
251                                let ttl_duration = Duration::from_secs(ttl.into());
252                                let now = Instant::now();
253
254                                now.checked_add(ttl_duration)
255                            });
256                            Some(HostLookup::Miss {
257                                valid_until,
258                            })
259                        },
260                        ProtoErrorKind::Timeout => None,
261                        _ => {
262                            warn!(
263                                target: "git-checks/valid_name",
264                                "failed to look up MX record for domain {} (timeout): {:?}",
265                                domain,
266                                err,
267                            );
268
269                            None
270                        },
271                    }
272                } else {
273                    warn!(
274                        target: "git-checks/valid_name",
275                        "failed to look up MX record for domain {}: {:?}",
276                        domain,
277                        err,
278                    );
279
280                    None
281                }
282            },
283        })
284    }
285
286    /// Check that an email address is valid.
287    fn check_email(&self, email: &str) -> bool {
288        let domain_part = email.split_once('@').map(|t| t.1);
289
290        if let Some(domain) = domain_part {
291            if self.trust_domains.contains(domain) {
292                return true;
293            }
294
295            let mut cache = self.dns_cache.lock().expect(LOCK_POISONED);
296            if let Some(cached_res) = cache.get_mut(domain) {
297                return *cached_res;
298            }
299
300            if let Ok(lookup) = Self::check_host(domain) {
301                lookup.is_some_and(|lookup| {
302                    let hit = lookup.is_hit();
303                    cache.insert(domain.into(), hit, lookup.cache_duration());
304                    hit
305                })
306            } else {
307                false
308            }
309        } else {
310            false
311        }
312    }
313
314    /// Check an identity for its validity.
315    fn check_identity(&self, what: &str, who: &str, identity: &Identity) -> CheckResult {
316        let mut result = CheckResult::new();
317
318        if !Self::check_name(&identity.name) {
319            self.full_name_policy.apply(&mut result, |policy| {
320                format!(
321                    "The {} name (`{}`) for {} has no space in it. A full name is {} for \
322                     contribution. Please set the `user.name` Git configuration value.",
323                    who, identity.name, what, policy,
324                )
325            });
326        }
327
328        if !self.check_email(&identity.email) {
329            result.add_error(format!(
330                "The {} email (`{}`) for {} has an unknown domain. Please set the `user.email` \
331                 Git configuration value.",
332                who, identity.email, what,
333            ));
334        }
335
336        result
337    }
338}
339
340impl Check for ValidName {
341    fn name(&self) -> &str {
342        "valid-name"
343    }
344
345    fn check(&self, _: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
346        let what = format!("commit {}", commit.sha1);
347
348        Ok(if commit.author == commit.committer {
349            self.check_identity(&what, "given", &commit.author)
350        } else {
351            let author_res = self.check_identity(&what, "author", &commit.author);
352            let commiter_res = self.check_identity(&what, "committer", &commit.committer);
353
354            author_res.combine(commiter_res)
355        })
356    }
357}
358
359impl BranchCheck for ValidName {
360    fn name(&self) -> &str {
361        "valid-name"
362    }
363
364    fn check(&self, ctx: &CheckGitContext, _: &CommitId) -> Result<CheckResult, Box<dyn Error>> {
365        Ok(self.check_identity("the topic", "owner", ctx.topic_owner()))
366    }
367}
368
369#[cfg(feature = "config")]
370pub(crate) mod config {
371    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck};
372    use log::warn;
373    use serde::Deserialize;
374    #[cfg(test)]
375    use serde_json::json;
376
377    use crate::ValidName;
378    use crate::ValidNameFullNamePolicy;
379
380    /// Configuration for full name policies.
381    #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
382    pub enum ValidNameFullNamePolicyIo {
383        /// Full names are required and trigger errors if not found.
384        #[serde(rename = "required")]
385        Required,
386        /// Full names are preferred and trigger warnings if not found.
387        #[serde(rename = "preferred")]
388        Preferred,
389        /// Full names are optional and are not checked.
390        #[serde(rename = "optional")]
391        Optional,
392    }
393
394    impl From<ValidNameFullNamePolicyIo> for ValidNameFullNamePolicy {
395        fn from(policy: ValidNameFullNamePolicyIo) -> Self {
396            match policy {
397                ValidNameFullNamePolicyIo::Required => ValidNameFullNamePolicy::Required,
398                ValidNameFullNamePolicyIo::Preferred => ValidNameFullNamePolicy::Preferred,
399                ValidNameFullNamePolicyIo::Optional => ValidNameFullNamePolicy::Optional,
400            }
401        }
402    }
403
404    /// Configuration for the `ValidName` check.
405    ///
406    /// The `full_name_policy` key is a string which must be one of `"optional"`, `"preferred"`, or
407    /// `"required"` (the default). The `trust_domains` is a list of strings which defaults to
408    /// empty for domains which are assumed to be valid in email addresses. This should contain
409    /// addresses which are common to the project being watched to avoid false positives when DNS
410    /// lookup failures occur.
411    ///
412    /// This check is registered as a commit check with the name `"valid_name"`.
413    ///
414    /// # Example
415    ///
416    /// ```json
417    /// {
418    ///     "full_name_policy": "required",
419    ///     "trust_domains": [
420    ///         "mycompany.invalid"
421    ///     ]
422    /// }
423    /// ```
424    #[derive(Deserialize, Debug)]
425    pub struct ValidNameConfig {
426        #[serde(default)]
427        full_name_policy: Option<ValidNameFullNamePolicyIo>,
428        #[serde(default)]
429        trust_domains: Option<Vec<String>>,
430        #[serde(default)]
431        whitelisted_domains: Option<Vec<String>>,
432    }
433
434    impl IntoCheck for ValidNameConfig {
435        type Check = ValidName;
436
437        fn into_check(self) -> Self::Check {
438            let mut builder = ValidName::builder();
439
440            if let Some(full_name_policy) = self.full_name_policy {
441                builder.full_name_policy(full_name_policy.into());
442            }
443
444            if let Some(trust_domains) = self.trust_domains {
445                builder.trust_domains(trust_domains);
446            } else if let Some(trust_domains) = self.whitelisted_domains {
447                warn!(
448                    target: "git-checks/valid_name",
449                    "the `whitelisted_domains` configuration key is deprecated; use \
450                     `trust_domains` instead.",
451                );
452                builder.trust_domains(trust_domains);
453            }
454
455            builder
456                .build()
457                .expect("configuration mismatch for `ValidName`")
458        }
459    }
460
461    register_checks! {
462        ValidNameConfig {
463            "valid_name" => CommitCheckConfig,
464        },
465    }
466
467    #[test]
468    fn test_valid_name_full_name_policy_deserialize() {
469        let value = json!("required");
470        let policy = ValidNameFullNamePolicyIo::deserialize(value).unwrap();
471        assert_eq!(policy, ValidNameFullNamePolicyIo::Required);
472        assert_eq!(
473            ValidNameFullNamePolicy::from(policy),
474            ValidNameFullNamePolicy::Required,
475        );
476
477        let value = json!("optional");
478        let policy = ValidNameFullNamePolicyIo::deserialize(value).unwrap();
479        assert_eq!(policy, ValidNameFullNamePolicyIo::Optional);
480        assert_eq!(
481            ValidNameFullNamePolicy::from(policy),
482            ValidNameFullNamePolicy::Optional,
483        );
484
485        let value = json!("preferred");
486        let policy = ValidNameFullNamePolicyIo::deserialize(value).unwrap();
487        assert_eq!(policy, ValidNameFullNamePolicyIo::Preferred);
488        assert_eq!(
489            ValidNameFullNamePolicy::from(policy),
490            ValidNameFullNamePolicy::Preferred,
491        );
492
493        let value = json!("invalid");
494        let err = ValidNameFullNamePolicyIo::deserialize(value).unwrap_err();
495
496        assert!(!err.is_io());
497        assert!(!err.is_syntax());
498        assert!(err.is_data());
499        assert!(!err.is_eof());
500
501        assert_eq!(
502            err.to_string(),
503            "unknown variant `invalid`, expected one of `required`, `preferred`, `optional`",
504        );
505    }
506
507    #[test]
508    fn test_valid_name_config_empty() {
509        let json = json!({});
510        let check: ValidNameConfig = serde_json::from_value(json).unwrap();
511
512        assert_eq!(check.full_name_policy, None);
513        assert_eq!(check.trust_domains, None);
514        assert_eq!(check.whitelisted_domains, None);
515
516        let check = check.into_check();
517
518        if let ValidNameFullNamePolicy::Required = check.full_name_policy {
519            // expected
520        } else {
521            panic!("unexpected full name policy: {:?}", check.full_name_policy);
522        }
523        itertools::assert_equal(&check.trust_domains, &[] as &[&str]);
524    }
525
526    #[test]
527    fn test_valid_name_config_all_fields() {
528        let exp_domain: String = "mycompany.invalid".into();
529        let json = json!({
530            "full_name_policy": "optional",
531            "trust_domains": [exp_domain],
532        });
533        let check: ValidNameConfig = serde_json::from_value(json).unwrap();
534
535        assert_eq!(
536            check.full_name_policy,
537            Some(ValidNameFullNamePolicyIo::Optional),
538        );
539        itertools::assert_equal(&check.trust_domains, &Some([exp_domain.clone()]));
540        assert_eq!(check.whitelisted_domains, None);
541
542        let check = check.into_check();
543
544        if let ValidNameFullNamePolicy::Optional = check.full_name_policy {
545            // expected
546        } else {
547            panic!("unexpected full name policy: {:?}", check.full_name_policy);
548        }
549        itertools::assert_equal(&check.trust_domains, &[exp_domain]);
550    }
551
552    #[test]
553    fn test_valid_name_config_all_fields_deprecated() {
554        let exp_domain: String = "mycompany.invalid".into();
555        let json = json!({
556            "whitelisted_domains": [exp_domain],
557        });
558        let check: ValidNameConfig = serde_json::from_value(json).unwrap();
559
560        assert_eq!(check.full_name_policy, None);
561        assert_eq!(check.trust_domains, None);
562        itertools::assert_equal(&check.whitelisted_domains, &Some([exp_domain.clone()]));
563
564        let check = check.into_check();
565
566        if let ValidNameFullNamePolicy::Required = check.full_name_policy {
567            // expected
568        } else {
569            panic!("unexpected full name policy: {:?}", check.full_name_policy);
570        }
571        itertools::assert_equal(&check.trust_domains, &[exp_domain]);
572    }
573
574    #[test]
575    fn test_valid_name_config_all_fields_with_deprecated() {
576        let exp_domain: String = "mycompany.invalid".into();
577        let exp_deprecated_domain: String = "myothercompany.invalid".into();
578        let json = json!({
579            "full_name_policy": "optional",
580            "trust_domains": [exp_domain],
581            "whitelisted_domains": [exp_deprecated_domain],
582        });
583        let check: ValidNameConfig = serde_json::from_value(json).unwrap();
584
585        assert_eq!(
586            check.full_name_policy,
587            Some(ValidNameFullNamePolicyIo::Optional),
588        );
589        itertools::assert_equal(&check.trust_domains, &Some([exp_domain.clone()]));
590        itertools::assert_equal(&check.whitelisted_domains, &Some([exp_deprecated_domain]));
591
592        let check = check.into_check();
593
594        if let ValidNameFullNamePolicy::Optional = check.full_name_policy {
595            // expected
596        } else {
597            panic!("unexpected full name policy: {:?}", check.full_name_policy);
598        }
599        itertools::assert_equal(&check.trust_domains, &[exp_domain]);
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use std::time::{Duration, Instant};
606
607    use git_checks_core::{BranchCheck, Check};
608    use git_workarea::Identity;
609
610    use crate::test::*;
611    use crate::ValidName;
612    use crate::ValidNameFullNamePolicy;
613
614    const BAD_TOPIC: &str = "91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8";
615    const BAD_AUTHOR_NAME: &str = "edac4e5b3a00eac60280a78ee84b5ef8d4cce97a";
616
617    #[test]
618    fn test_valid_name_builder_default() {
619        assert!(ValidName::builder().build().is_ok());
620    }
621
622    #[test]
623    fn test_valid_name_name_commit() {
624        let check = ValidName::default();
625        assert_eq!(Check::name(&check), "valid-name");
626    }
627
628    #[test]
629    fn test_valid_name_name_branch() {
630        let check = ValidName::default();
631        assert_eq!(BranchCheck::name(&check), "valid-name");
632    }
633
634    #[test]
635    fn test_valid_name_required() {
636        let check = ValidName::default();
637        let result = run_check("test_valid_name_required", BAD_TOPIC, check);
638        test_result_errors(result, &[
639            "The author name (`Mononym`) for commit edac4e5b3a00eac60280a78ee84b5ef8d4cce97a has \
640             no space in it. A full name is required for contribution. Please set the `user.name` \
641             Git configuration value.",
642            "The author email (`bademail`) for commit 9de4928f5ec425eef414ee7620d0692fda56ebb0 \
643             has an unknown domain. Please set the `user.email` Git configuration value.",
644            "The committer name (`Mononym`) for commit 1debf1735a6e28880ef08f13baeea4b71a08a846 \
645             has no space in it. A full name is required for contribution. Please set the \
646             `user.name` Git configuration value.",
647            "The committer email (`bademail`) for commit da71ae048e5a387d6809558d59ad073d0e4fb089 \
648             has an unknown domain. Please set the `user.email` Git configuration value.",
649            "The author email (`bademail@baddomain.invalid`) for commit \
650             9002239437a06e81a58fed07150b215a917028d6 has an unknown domain. Please set the \
651             `user.email` Git configuration value.",
652            "The committer email (`bademail@baddomain.invalid`) for commit \
653             dcd8895d299031d607481b4936478f8de4cc28ae has an unknown domain. Please set the \
654             `user.email` Git configuration value.",
655            "The given name (`Mononym`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
656             no space in it. A full name is required for contribution. Please set the `user.name` \
657             Git configuration value.",
658            "The given email (`bademail`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
659             an unknown domain. Please set the `user.email` Git configuration value.",
660        ]);
661    }
662
663    #[test]
664    #[allow(deprecated)]
665    fn test_valid_name_whitelist() {
666        let check = ValidName::builder()
667            .whitelisted_domains(["baddomain.invalid"].iter().cloned())
668            .build()
669            .unwrap();
670        let result = run_check("test_valid_name_whitelist", BAD_TOPIC, check);
671        test_result_errors(result, &[
672            "The author name (`Mononym`) for commit edac4e5b3a00eac60280a78ee84b5ef8d4cce97a has \
673             no space in it. A full name is required for contribution. Please set the `user.name` \
674             Git configuration value.",
675            "The author email (`bademail`) for commit 9de4928f5ec425eef414ee7620d0692fda56ebb0 \
676             has an unknown domain. Please set the `user.email` Git configuration value.",
677            "The committer name (`Mononym`) for commit 1debf1735a6e28880ef08f13baeea4b71a08a846 \
678             has no space in it. A full name is required for contribution. Please set the \
679             `user.name` Git configuration value.",
680            "The committer email (`bademail`) for commit da71ae048e5a387d6809558d59ad073d0e4fb089 \
681             has an unknown domain. Please set the `user.email` Git configuration value.",
682            "The given name (`Mononym`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
683             no space in it. A full name is required for contribution. Please set the `user.name` \
684             Git configuration value.",
685            "The given email (`bademail`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
686             an unknown domain. Please set the `user.email` Git configuration value.",
687        ]);
688    }
689
690    #[test]
691    fn test_valid_name_trust_domains() {
692        let check = ValidName::builder()
693            .trust_domains(["baddomain.invalid"].iter().cloned())
694            .build()
695            .unwrap();
696        let result = run_check("test_valid_name_trust_domains", BAD_TOPIC, check);
697        test_result_errors(result, &[
698            "The author name (`Mononym`) for commit edac4e5b3a00eac60280a78ee84b5ef8d4cce97a has \
699             no space in it. A full name is required for contribution. Please set the `user.name` \
700             Git configuration value.",
701            "The author email (`bademail`) for commit 9de4928f5ec425eef414ee7620d0692fda56ebb0 \
702             has an unknown domain. Please set the `user.email` Git configuration value.",
703            "The committer name (`Mononym`) for commit 1debf1735a6e28880ef08f13baeea4b71a08a846 \
704             has no space in it. A full name is required for contribution. Please set the \
705             `user.name` Git configuration value.",
706            "The committer email (`bademail`) for commit da71ae048e5a387d6809558d59ad073d0e4fb089 \
707             has an unknown domain. Please set the `user.email` Git configuration value.",
708            "The given name (`Mononym`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
709             no space in it. A full name is required for contribution. Please set the `user.name` \
710             Git configuration value.",
711            "The given email (`bademail`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
712             an unknown domain. Please set the `user.email` Git configuration value.",
713        ]);
714    }
715
716    #[test]
717    fn test_valid_name_preferred() {
718        let check = ValidName::builder()
719            .full_name_policy(ValidNameFullNamePolicy::Preferred)
720            .build()
721            .unwrap();
722        let result = run_check("test_valid_name_preferred", BAD_AUTHOR_NAME, check);
723        test_result_warnings(result, &[
724            "The author name (`Mononym`) for commit edac4e5b3a00eac60280a78ee84b5ef8d4cce97a has \
725             no space in it. A full name is preferred for contribution. Please set the \
726             `user.name` Git configuration value.",
727        ]);
728    }
729
730    #[test]
731    fn test_valid_name_optional() {
732        let check = ValidName::builder()
733            .full_name_policy(ValidNameFullNamePolicy::Optional)
734            .build()
735            .unwrap();
736        run_check_ok("test_valid_name_optional", BAD_AUTHOR_NAME, check);
737    }
738
739    fn mononym_ident() -> Identity {
740        Identity::new("Mononym", "email@example.com")
741    }
742
743    fn bademail_ident() -> Identity {
744        Identity::new("Anon E. Mouse", "bademail")
745    }
746
747    fn bademail_mx_ident() -> Identity {
748        Identity::new("Anon E. Mouse", "bademail@baddomain.invalid")
749    }
750
751    #[test]
752    fn test_valid_name_branch_required() {
753        let check = ValidName::default();
754        run_branch_check_ok(
755            "test_valid_name_branch_required/ok",
756            BAD_TOPIC,
757            check.clone(),
758        );
759        let result = run_branch_check_ident(
760            "test_valid_name_branch_required/mononym",
761            BAD_TOPIC,
762            check.clone(),
763            mononym_ident(),
764        );
765        test_result_errors(result, &[
766            "The owner name (`Mononym`) for the topic has no space in it. A full name is required \
767             for contribution. Please set the `user.name` Git configuration value.",
768        ]);
769        let result = run_branch_check_ident(
770            "test_valid_name_branch_required/bademail",
771            BAD_TOPIC,
772            check.clone(),
773            bademail_ident(),
774        );
775        test_result_errors(
776            result,
777            &[
778                "The owner email (`bademail`) for the topic has an unknown domain. Please set the \
779                 `user.email` Git configuration value.",
780            ],
781        );
782        let result = run_branch_check_ident(
783            "test_valid_name_branch_required/bademail_mx",
784            BAD_TOPIC,
785            check,
786            bademail_mx_ident(),
787        );
788        test_result_errors(result, &[
789            "The owner email (`bademail@baddomain.invalid`) for the topic has an unknown domain. \
790             Please set the `user.email` Git configuration value.",
791        ]);
792    }
793
794    #[test]
795    fn test_valid_name_branch_trust_domains() {
796        let check = ValidName::builder()
797            .trust_domains(["baddomain.invalid"].iter().cloned())
798            .build()
799            .unwrap();
800        run_branch_check_ok(
801            "test_valid_name_branch_trust_domains/ok",
802            BAD_TOPIC,
803            check.clone(),
804        );
805        let result = run_branch_check_ident(
806            "test_valid_name_branch_required/mononym",
807            BAD_TOPIC,
808            check.clone(),
809            mononym_ident(),
810        );
811        test_result_errors(result, &[
812            "The owner name (`Mononym`) for the topic has no space in it. A full name is required \
813             for contribution. Please set the `user.name` Git configuration value.",
814        ]);
815        let result = run_branch_check_ident(
816            "test_valid_name_branch_trust_domains/bademail",
817            BAD_TOPIC,
818            check.clone(),
819            bademail_ident(),
820        );
821        test_result_errors(
822            result,
823            &[
824                "The owner email (`bademail`) for the topic has an unknown domain. Please set the \
825                 `user.email` Git configuration value.",
826            ],
827        );
828        run_branch_check_ident_ok(
829            "test_valid_name_branch_trust_domains/bademail_mx",
830            BAD_TOPIC,
831            check,
832            bademail_mx_ident(),
833        );
834    }
835
836    #[test]
837    fn test_valid_name_branch_preferred() {
838        let check = ValidName::builder()
839            .full_name_policy(ValidNameFullNamePolicy::Preferred)
840            .build()
841            .unwrap();
842        run_branch_check_ok(
843            "test_valid_name_branch_preferred/ok",
844            BAD_TOPIC,
845            check.clone(),
846        );
847        let result = run_branch_check_ident(
848            "test_valid_name_branch_preferred/mononym",
849            BAD_TOPIC,
850            check.clone(),
851            mononym_ident(),
852        );
853        test_result_warnings(result, &[
854            "The owner name (`Mononym`) for the topic has \
855             no space in it. A full name is preferred for contribution. Please set the `user.name` \
856             Git configuration value.",
857        ]);
858        let result = run_branch_check_ident(
859            "test_valid_name_branch_preferred/bademail",
860            BAD_TOPIC,
861            check.clone(),
862            bademail_ident(),
863        );
864        test_result_errors(
865            result,
866            &[
867                "The owner email (`bademail`) for the topic has an unknown domain. Please set the \
868                 `user.email` Git configuration value.",
869            ],
870        );
871        let result = run_branch_check_ident(
872            "test_valid_name_branch_preferred/bademail_mx",
873            BAD_TOPIC,
874            check,
875            bademail_mx_ident(),
876        );
877        test_result_errors(result, &[
878            "The owner email (`bademail@baddomain.invalid`) for the topic has an unknown domain. \
879             Please set the `user.email` Git configuration value.",
880        ]);
881    }
882
883    #[test]
884    fn test_valid_name_branch_optional() {
885        let check = ValidName::builder()
886            .full_name_policy(ValidNameFullNamePolicy::Optional)
887            .build()
888            .unwrap();
889        run_branch_check_ok(
890            "test_valid_name_branch_optional/ok",
891            BAD_TOPIC,
892            check.clone(),
893        );
894        run_branch_check_ident_ok(
895            "test_valid_name_branch_optional/mononym",
896            BAD_TOPIC,
897            check.clone(),
898            mononym_ident(),
899        );
900        let result = run_branch_check_ident(
901            "test_valid_name_branch_optional/bademail",
902            BAD_TOPIC,
903            check.clone(),
904            bademail_ident(),
905        );
906        test_result_errors(
907            result,
908            &[
909                "The owner email (`bademail`) for the topic has an unknown domain. Please set the \
910                 `user.email` Git configuration value.",
911            ],
912        );
913        let result = run_branch_check_ident(
914            "test_valid_name_branch_optional/bademail_mx",
915            BAD_TOPIC,
916            check,
917            bademail_mx_ident(),
918        );
919        test_result_errors(result, &[
920            "The owner email (`bademail@baddomain.invalid`) for the topic has an unknown domain. \
921             Please set the `user.email` Git configuration value.",
922        ]);
923    }
924
925    #[test]
926    fn test_valid_name_impl_debug() {
927        let check = ValidName::builder().build().unwrap();
928        let out = format!("{:?}", check);
929        assert_eq!(
930            out,
931            "ValidName { full_name_policy: Required, trust_domains: {} }",
932        );
933    }
934
935    #[test]
936    fn test_valid_name_impl_clone() {
937        let check = ValidName::builder().build().unwrap();
938        {
939            let mut cache = check.dns_cache.lock().unwrap();
940            cache.insert(
941                "example.com".into(),
942                true,
943                super::DEFAULT_TTL_CACHE_HIT_DURATION,
944            );
945        }
946        // https://github.com/rust-lang/rust-clippy/issues/10893
947        #[allow(clippy::redundant_clone)]
948        let cloned = check.clone();
949        assert_eq!(cloned.full_name_policy, check.full_name_policy);
950        assert_eq!(cloned.dns_cache.lock().unwrap().iter().count(), 0);
951        assert_eq!(cloned.trust_domains, check.trust_domains);
952    }
953
954    #[test]
955    fn test_host_lookup_cache_duration() {
956        use super::HostLookup;
957
958        let items = [
959            (HostLookup::Hit, super::DEFAULT_TTL_CACHE_HIT_DURATION),
960            (
961                HostLookup::Miss {
962                    valid_until: None,
963                },
964                super::DEFAULT_TTL_CACHE_MISS_DURATION,
965            ),
966        ];
967
968        for (l, d) in items {
969            assert_eq!(l.cache_duration(), d);
970        }
971
972        let short_timeout = Duration::from_secs(60);
973        let soon = Instant::now() + short_timeout;
974        let long_timeout = Duration::from_secs(10000);
975        let later = Instant::now() + long_timeout;
976
977        let range_items = [
978            (
979                HostLookup::Miss {
980                    valid_until: Some(soon),
981                },
982                short_timeout,
983            ),
984            (
985                HostLookup::Miss {
986                    valid_until: Some(later),
987                },
988                long_timeout,
989            ),
990        ];
991
992        for (l, d) in range_items {
993            let duration = l.cache_duration();
994            // Make sure the duration is less than the given timeout.
995            assert!(duration <= d);
996            // But also "close" to the given timeout. If a platform takes longer than 1 second to
997            // get here, there are other performance issues to consider first.
998            assert!(d - duration <= Duration::from_secs(1));
999        }
1000    }
1001}