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