Skip to main content

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_resolver::net::runtime::TokioRuntimeProvider;
18use hickory_resolver::net::{DnsError, NetError, NoRecords};
19use hickory_resolver::proto::rr::domain::Name;
20use hickory_resolver::Resolver;
21use lazy_static::lazy_static;
22use log::{error, warn};
23use thiserror::Error;
24use tokio::runtime::Runtime;
25use ttl_cache::TtlCache;
26
27/// Configuration value for `ValidName` policy for use of full names in identities.
28#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
29pub enum ValidNameFullNamePolicy {
30    /// A full name is required, error when missing.
31    #[default]
32    Required,
33    /// A full name is preferred, warning when missing.
34    Preferred,
35    /// A full name is optional, no diagnostic when missing.
36    Optional,
37}
38
39impl ValidNameFullNamePolicy {
40    /// Apply the policy to a check result.
41    fn apply<F>(self, result: &mut CheckResult, msg: F)
42    where
43        F: Fn(&str) -> String,
44    {
45        match self {
46            ValidNameFullNamePolicy::Required => {
47                result.add_error(msg("required"));
48            },
49            ValidNameFullNamePolicy::Preferred => {
50                result.add_warning(msg("preferred"));
51            },
52            ValidNameFullNamePolicy::Optional => {},
53        }
54    }
55}
56
57const LOCK_POISONED: &str = "DNS cache lock poisoned";
58const DEFAULT_TTL_CACHE_SIZE: usize = 100;
59// 24 hours
60// XXX(rust-???): use `Duration::from_days(1); https://github.com/rust-lang/rust/issues/120301
61const DEFAULT_TTL_CACHE_HIT_DURATION: Duration = Duration::from_secs(24 * 60 * 60);
62// 5 minutes
63// XXX(rust-???): use `Duration::from_mins(5); https://github.com/rust-lang/rust/issues/120301
64const DEFAULT_TTL_CACHE_MISS_DURATION: Duration = Duration::from_secs(5 * 60);
65
66lazy_static! {
67    // DNS resolver.
68    static ref DNS_RESOLVER: Result<Resolver<TokioRuntimeProvider>, NetError> = Resolver::builder_tokio()
69        .and_then(|builder| builder.build())
70        .map_err(|err| {
71            error!(
72                target: "git-checks/valid_name",
73                "failed to construct DNS resolver: {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_resolver::proto::ProtoError,
203    },
204}
205
206impl ValidNameError {
207    fn no_resolver(source: &NetError) -> 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(NetError::Timeout) => None,
245            Err(NetError::Dns(DnsError::NoRecordsFound(NoRecords {
246                negative_ttl, ..
247            }))) => {
248                let valid_until = negative_ttl.and_then(|ttl| {
249                    let ttl_duration = Duration::from_secs(ttl.into());
250                    let now = Instant::now();
251
252                    now.checked_add(ttl_duration)
253                });
254                Some(HostLookup::Miss {
255                    valid_until,
256                })
257            },
258            Err(err) => {
259                warn!(
260                    target: "git-checks/valid_name",
261                    "failed to look up MX record for domain {domain}: {err:?}",
262                );
263
264                None
265            },
266        })
267    }
268
269    /// Check that an email address is valid.
270    fn check_email(&self, email: &str) -> bool {
271        let domain_part = email.split_once('@').map(|t| t.1);
272
273        if let Some(domain) = domain_part {
274            if self.trust_domains.contains(domain) {
275                return true;
276            }
277
278            let mut cache = self.dns_cache.lock().expect(LOCK_POISONED);
279            if let Some(cached_res) = cache.get_mut(domain) {
280                return *cached_res;
281            }
282
283            if let Ok(lookup) = Self::check_host(domain) {
284                lookup.is_some_and(|lookup| {
285                    let hit = lookup.is_hit();
286                    cache.insert(domain.into(), hit, lookup.cache_duration());
287                    hit
288                })
289            } else {
290                false
291            }
292        } else {
293            false
294        }
295    }
296
297    /// Check an identity for its validity.
298    fn check_identity(&self, what: &str, who: &str, identity: &Identity) -> CheckResult {
299        let mut result = CheckResult::new();
300
301        if !Self::check_name(&identity.name) {
302            self.full_name_policy.apply(&mut result, |policy| {
303                format!(
304                    "The {who} name (`{}`) for {what} has no space in it. A full name is {policy} \
305                     for contribution. Please set the `user.name` Git configuration value.",
306                    identity.name,
307                )
308            });
309        }
310
311        if !self.check_email(&identity.email) {
312            result.add_error(format!(
313                "The {who} email (`{}`) for {what} has an unknown domain. Please set the \
314                 `user.email` Git configuration value.",
315                identity.email,
316            ));
317        }
318
319        result
320    }
321}
322
323impl Check for ValidName {
324    fn name(&self) -> &str {
325        "valid-name"
326    }
327
328    fn check(&self, _: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
329        let what = format!("commit {}", commit.sha1);
330
331        Ok(if commit.author == commit.committer {
332            self.check_identity(&what, "given", &commit.author)
333        } else {
334            let author_res = self.check_identity(&what, "author", &commit.author);
335            let commiter_res = self.check_identity(&what, "committer", &commit.committer);
336
337            author_res.combine(commiter_res)
338        })
339    }
340}
341
342impl BranchCheck for ValidName {
343    fn name(&self) -> &str {
344        "valid-name"
345    }
346
347    fn check(&self, ctx: &CheckGitContext, _: &CommitId) -> Result<CheckResult, Box<dyn Error>> {
348        Ok(self.check_identity("the topic", "owner", ctx.topic_owner()))
349    }
350}
351
352#[cfg(feature = "config")]
353pub(crate) mod config {
354    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck};
355    use log::warn;
356    use serde::Deserialize;
357    #[cfg(test)]
358    use serde_json::json;
359
360    use crate::ValidName;
361    use crate::ValidNameFullNamePolicy;
362
363    /// Configuration for full name policies.
364    #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
365    pub enum ValidNameFullNamePolicyIo {
366        /// Full names are required and trigger errors if not found.
367        #[serde(rename = "required")]
368        Required,
369        /// Full names are preferred and trigger warnings if not found.
370        #[serde(rename = "preferred")]
371        Preferred,
372        /// Full names are optional and are not checked.
373        #[serde(rename = "optional")]
374        Optional,
375    }
376
377    impl From<ValidNameFullNamePolicyIo> for ValidNameFullNamePolicy {
378        fn from(policy: ValidNameFullNamePolicyIo) -> Self {
379            match policy {
380                ValidNameFullNamePolicyIo::Required => ValidNameFullNamePolicy::Required,
381                ValidNameFullNamePolicyIo::Preferred => ValidNameFullNamePolicy::Preferred,
382                ValidNameFullNamePolicyIo::Optional => ValidNameFullNamePolicy::Optional,
383            }
384        }
385    }
386
387    /// Configuration for the `ValidName` check.
388    ///
389    /// The `full_name_policy` key is a string which must be one of `"optional"`, `"preferred"`, or
390    /// `"required"` (the default). The `trust_domains` is a list of strings which defaults to
391    /// empty for domains which are assumed to be valid in email addresses. This should contain
392    /// addresses which are common to the project being watched to avoid false positives when DNS
393    /// lookup failures occur.
394    ///
395    /// This check is registered as a commit check with the name `"valid_name"`.
396    ///
397    /// # Example
398    ///
399    /// ```json
400    /// {
401    ///     "full_name_policy": "required",
402    ///     "trust_domains": [
403    ///         "mycompany.invalid"
404    ///     ]
405    /// }
406    /// ```
407    #[derive(Deserialize, Debug)]
408    pub struct ValidNameConfig {
409        #[serde(default)]
410        full_name_policy: Option<ValidNameFullNamePolicyIo>,
411        #[serde(default)]
412        trust_domains: Option<Vec<String>>,
413        #[serde(default)]
414        whitelisted_domains: Option<Vec<String>>,
415    }
416
417    impl IntoCheck for ValidNameConfig {
418        type Check = ValidName;
419
420        fn into_check(self) -> Self::Check {
421            let mut builder = ValidName::builder();
422
423            if let Some(full_name_policy) = self.full_name_policy {
424                builder.full_name_policy(full_name_policy.into());
425            }
426
427            if let Some(trust_domains) = self.trust_domains {
428                builder.trust_domains(trust_domains);
429            } else if let Some(trust_domains) = self.whitelisted_domains {
430                warn!(
431                    target: "git-checks/valid_name",
432                    "the `whitelisted_domains` configuration key is deprecated; use \
433                     `trust_domains` instead.",
434                );
435                builder.trust_domains(trust_domains);
436            }
437
438            builder
439                .build()
440                .expect("configuration mismatch for `ValidName`")
441        }
442    }
443
444    register_checks! {
445        ValidNameConfig {
446            "valid_name" => CommitCheckConfig,
447        },
448    }
449
450    #[test]
451    fn test_valid_name_full_name_policy_deserialize() {
452        let value = json!("required");
453        let policy = ValidNameFullNamePolicyIo::deserialize(value).unwrap();
454        assert_eq!(policy, ValidNameFullNamePolicyIo::Required);
455        assert_eq!(
456            ValidNameFullNamePolicy::from(policy),
457            ValidNameFullNamePolicy::Required,
458        );
459
460        let value = json!("optional");
461        let policy = ValidNameFullNamePolicyIo::deserialize(value).unwrap();
462        assert_eq!(policy, ValidNameFullNamePolicyIo::Optional);
463        assert_eq!(
464            ValidNameFullNamePolicy::from(policy),
465            ValidNameFullNamePolicy::Optional,
466        );
467
468        let value = json!("preferred");
469        let policy = ValidNameFullNamePolicyIo::deserialize(value).unwrap();
470        assert_eq!(policy, ValidNameFullNamePolicyIo::Preferred);
471        assert_eq!(
472            ValidNameFullNamePolicy::from(policy),
473            ValidNameFullNamePolicy::Preferred,
474        );
475
476        let value = json!("invalid");
477        let err = ValidNameFullNamePolicyIo::deserialize(value).unwrap_err();
478
479        assert!(!err.is_io());
480        assert!(!err.is_syntax());
481        assert!(err.is_data());
482        assert!(!err.is_eof());
483
484        assert_eq!(
485            err.to_string(),
486            "unknown variant `invalid`, expected one of `required`, `preferred`, `optional`",
487        );
488    }
489
490    #[test]
491    fn test_valid_name_config_empty() {
492        let json = json!({});
493        let check: ValidNameConfig = serde_json::from_value(json).unwrap();
494
495        assert_eq!(check.full_name_policy, None);
496        assert_eq!(check.trust_domains, None);
497        assert_eq!(check.whitelisted_domains, None);
498
499        let check = check.into_check();
500
501        if let ValidNameFullNamePolicy::Required = check.full_name_policy {
502            // expected
503        } else {
504            panic!("unexpected full name policy: {:?}", check.full_name_policy);
505        }
506        itertools::assert_equal(&check.trust_domains, &[] as &[&str]);
507    }
508
509    #[test]
510    fn test_valid_name_config_all_fields() {
511        let exp_domain: String = "mycompany.invalid".into();
512        let json = json!({
513            "full_name_policy": "optional",
514            "trust_domains": [exp_domain],
515        });
516        let check: ValidNameConfig = serde_json::from_value(json).unwrap();
517
518        assert_eq!(
519            check.full_name_policy,
520            Some(ValidNameFullNamePolicyIo::Optional),
521        );
522        itertools::assert_equal(&check.trust_domains, &Some([exp_domain.clone()]));
523        assert_eq!(check.whitelisted_domains, None);
524
525        let check = check.into_check();
526
527        if let ValidNameFullNamePolicy::Optional = check.full_name_policy {
528            // expected
529        } else {
530            panic!("unexpected full name policy: {:?}", check.full_name_policy);
531        }
532        itertools::assert_equal(&check.trust_domains, &[exp_domain]);
533    }
534
535    #[test]
536    fn test_valid_name_config_all_fields_deprecated() {
537        let exp_domain: String = "mycompany.invalid".into();
538        let json = json!({
539            "whitelisted_domains": [exp_domain],
540        });
541        let check: ValidNameConfig = serde_json::from_value(json).unwrap();
542
543        assert_eq!(check.full_name_policy, None);
544        assert_eq!(check.trust_domains, None);
545        itertools::assert_equal(&check.whitelisted_domains, &Some([exp_domain.clone()]));
546
547        let check = check.into_check();
548
549        if let ValidNameFullNamePolicy::Required = check.full_name_policy {
550            // expected
551        } else {
552            panic!("unexpected full name policy: {:?}", check.full_name_policy);
553        }
554        itertools::assert_equal(&check.trust_domains, &[exp_domain]);
555    }
556
557    #[test]
558    fn test_valid_name_config_all_fields_with_deprecated() {
559        let exp_domain: String = "mycompany.invalid".into();
560        let exp_deprecated_domain: String = "myothercompany.invalid".into();
561        let json = json!({
562            "full_name_policy": "optional",
563            "trust_domains": [exp_domain],
564            "whitelisted_domains": [exp_deprecated_domain],
565        });
566        let check: ValidNameConfig = serde_json::from_value(json).unwrap();
567
568        assert_eq!(
569            check.full_name_policy,
570            Some(ValidNameFullNamePolicyIo::Optional),
571        );
572        itertools::assert_equal(&check.trust_domains, &Some([exp_domain.clone()]));
573        itertools::assert_equal(&check.whitelisted_domains, &Some([exp_deprecated_domain]));
574
575        let check = check.into_check();
576
577        if let ValidNameFullNamePolicy::Optional = check.full_name_policy {
578            // expected
579        } else {
580            panic!("unexpected full name policy: {:?}", check.full_name_policy);
581        }
582        itertools::assert_equal(&check.trust_domains, &[exp_domain]);
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use std::time::{Duration, Instant};
589
590    use git_checks_core::{BranchCheck, Check};
591    use git_workarea::Identity;
592
593    use crate::test::*;
594    use crate::ValidName;
595    use crate::ValidNameFullNamePolicy;
596
597    const BAD_TOPIC: &str = "91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8";
598    const BAD_AUTHOR_NAME: &str = "edac4e5b3a00eac60280a78ee84b5ef8d4cce97a";
599
600    #[test]
601    fn test_valid_name_builder_default() {
602        assert!(ValidName::builder().build().is_ok());
603    }
604
605    #[test]
606    fn test_valid_name_name_commit() {
607        let check = ValidName::default();
608        assert_eq!(Check::name(&check), "valid-name");
609    }
610
611    #[test]
612    fn test_valid_name_name_branch() {
613        let check = ValidName::default();
614        assert_eq!(BranchCheck::name(&check), "valid-name");
615    }
616
617    #[test]
618    fn test_valid_name_required() {
619        let check = ValidName::default();
620        let result = run_check("test_valid_name_required", BAD_TOPIC, check);
621        test_result_errors(result, &[
622            "The author name (`Mononym`) for commit edac4e5b3a00eac60280a78ee84b5ef8d4cce97a has \
623             no space in it. A full name is required for contribution. Please set the `user.name` \
624             Git configuration value.",
625            "The author email (`bademail`) for commit 9de4928f5ec425eef414ee7620d0692fda56ebb0 \
626             has an unknown domain. Please set the `user.email` Git configuration value.",
627            "The committer name (`Mononym`) for commit 1debf1735a6e28880ef08f13baeea4b71a08a846 \
628             has no space in it. A full name is required for contribution. Please set the \
629             `user.name` Git configuration value.",
630            "The committer email (`bademail`) for commit da71ae048e5a387d6809558d59ad073d0e4fb089 \
631             has an unknown domain. Please set the `user.email` Git configuration value.",
632            "The author email (`bademail@baddomain.invalid`) for commit \
633             9002239437a06e81a58fed07150b215a917028d6 has an unknown domain. Please set the \
634             `user.email` Git configuration value.",
635            "The committer email (`bademail@baddomain.invalid`) for commit \
636             dcd8895d299031d607481b4936478f8de4cc28ae has an unknown domain. Please set the \
637             `user.email` Git configuration value.",
638            "The given name (`Mononym`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
639             no space in it. A full name is required for contribution. Please set the `user.name` \
640             Git configuration value.",
641            "The given email (`bademail`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
642             an unknown domain. Please set the `user.email` Git configuration value.",
643        ]);
644    }
645
646    #[test]
647    #[allow(deprecated)]
648    fn test_valid_name_whitelist() {
649        let check = ValidName::builder()
650            .whitelisted_domains(["baddomain.invalid"].iter().cloned())
651            .build()
652            .unwrap();
653        let result = run_check("test_valid_name_whitelist", BAD_TOPIC, check);
654        test_result_errors(result, &[
655            "The author name (`Mononym`) for commit edac4e5b3a00eac60280a78ee84b5ef8d4cce97a has \
656             no space in it. A full name is required for contribution. Please set the `user.name` \
657             Git configuration value.",
658            "The author email (`bademail`) for commit 9de4928f5ec425eef414ee7620d0692fda56ebb0 \
659             has an unknown domain. Please set the `user.email` Git configuration value.",
660            "The committer name (`Mononym`) for commit 1debf1735a6e28880ef08f13baeea4b71a08a846 \
661             has no space in it. A full name is required for contribution. Please set the \
662             `user.name` Git configuration value.",
663            "The committer email (`bademail`) for commit da71ae048e5a387d6809558d59ad073d0e4fb089 \
664             has an unknown domain. Please set the `user.email` Git configuration value.",
665            "The given name (`Mononym`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
666             no space in it. A full name is required for contribution. Please set the `user.name` \
667             Git configuration value.",
668            "The given email (`bademail`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
669             an unknown domain. Please set the `user.email` Git configuration value.",
670        ]);
671    }
672
673    #[test]
674    fn test_valid_name_trust_domains() {
675        let check = ValidName::builder()
676            .trust_domains(["baddomain.invalid"].iter().cloned())
677            .build()
678            .unwrap();
679        let result = run_check("test_valid_name_trust_domains", BAD_TOPIC, check);
680        test_result_errors(result, &[
681            "The author name (`Mononym`) for commit edac4e5b3a00eac60280a78ee84b5ef8d4cce97a has \
682             no space in it. A full name is required for contribution. Please set the `user.name` \
683             Git configuration value.",
684            "The author email (`bademail`) for commit 9de4928f5ec425eef414ee7620d0692fda56ebb0 \
685             has an unknown domain. Please set the `user.email` Git configuration value.",
686            "The committer name (`Mononym`) for commit 1debf1735a6e28880ef08f13baeea4b71a08a846 \
687             has no space in it. A full name is required for contribution. Please set the \
688             `user.name` Git configuration value.",
689            "The committer email (`bademail`) for commit da71ae048e5a387d6809558d59ad073d0e4fb089 \
690             has an unknown domain. Please set the `user.email` Git configuration value.",
691            "The given name (`Mononym`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
692             no space in it. A full name is required for contribution. Please set the `user.name` \
693             Git configuration value.",
694            "The given email (`bademail`) for commit 91d9fceb226bfc0faeb8a4e54b4f0b5a1ffd39e8 has \
695             an unknown domain. Please set the `user.email` Git configuration value.",
696        ]);
697    }
698
699    #[test]
700    fn test_valid_name_preferred() {
701        let check = ValidName::builder()
702            .full_name_policy(ValidNameFullNamePolicy::Preferred)
703            .build()
704            .unwrap();
705        let result = run_check("test_valid_name_preferred", BAD_AUTHOR_NAME, check);
706        test_result_warnings(result, &[
707            "The author name (`Mononym`) for commit edac4e5b3a00eac60280a78ee84b5ef8d4cce97a has \
708             no space in it. A full name is preferred for contribution. Please set the \
709             `user.name` Git configuration value.",
710        ]);
711    }
712
713    #[test]
714    fn test_valid_name_optional() {
715        let check = ValidName::builder()
716            .full_name_policy(ValidNameFullNamePolicy::Optional)
717            .build()
718            .unwrap();
719        run_check_ok("test_valid_name_optional", BAD_AUTHOR_NAME, check);
720    }
721
722    fn mononym_ident() -> Identity {
723        Identity::new("Mononym", "email@example.com")
724    }
725
726    fn bademail_ident() -> Identity {
727        Identity::new("Anon E. Mouse", "bademail")
728    }
729
730    fn bademail_mx_ident() -> Identity {
731        Identity::new("Anon E. Mouse", "bademail@baddomain.invalid")
732    }
733
734    #[test]
735    fn test_valid_name_branch_required() {
736        let check = ValidName::default();
737        run_branch_check_ok(
738            "test_valid_name_branch_required/ok",
739            BAD_TOPIC,
740            check.clone(),
741        );
742        let result = run_branch_check_ident(
743            "test_valid_name_branch_required/mononym",
744            BAD_TOPIC,
745            check.clone(),
746            mononym_ident(),
747        );
748        test_result_errors(result, &[
749            "The owner name (`Mononym`) for the topic has no space in it. A full name is required \
750             for contribution. Please set the `user.name` Git configuration value.",
751        ]);
752        let result = run_branch_check_ident(
753            "test_valid_name_branch_required/bademail",
754            BAD_TOPIC,
755            check.clone(),
756            bademail_ident(),
757        );
758        test_result_errors(
759            result,
760            &[
761                "The owner email (`bademail`) for the topic has an unknown domain. Please set the \
762                 `user.email` Git configuration value.",
763            ],
764        );
765        let result = run_branch_check_ident(
766            "test_valid_name_branch_required/bademail_mx",
767            BAD_TOPIC,
768            check,
769            bademail_mx_ident(),
770        );
771        test_result_errors(result, &[
772            "The owner email (`bademail@baddomain.invalid`) for the topic has an unknown domain. \
773             Please set the `user.email` Git configuration value.",
774        ]);
775    }
776
777    #[test]
778    fn test_valid_name_branch_trust_domains() {
779        let check = ValidName::builder()
780            .trust_domains(["baddomain.invalid"].iter().cloned())
781            .build()
782            .unwrap();
783        run_branch_check_ok(
784            "test_valid_name_branch_trust_domains/ok",
785            BAD_TOPIC,
786            check.clone(),
787        );
788        let result = run_branch_check_ident(
789            "test_valid_name_branch_required/mononym",
790            BAD_TOPIC,
791            check.clone(),
792            mononym_ident(),
793        );
794        test_result_errors(result, &[
795            "The owner name (`Mononym`) for the topic has no space in it. A full name is required \
796             for contribution. Please set the `user.name` Git configuration value.",
797        ]);
798        let result = run_branch_check_ident(
799            "test_valid_name_branch_trust_domains/bademail",
800            BAD_TOPIC,
801            check.clone(),
802            bademail_ident(),
803        );
804        test_result_errors(
805            result,
806            &[
807                "The owner email (`bademail`) for the topic has an unknown domain. Please set the \
808                 `user.email` Git configuration value.",
809            ],
810        );
811        run_branch_check_ident_ok(
812            "test_valid_name_branch_trust_domains/bademail_mx",
813            BAD_TOPIC,
814            check,
815            bademail_mx_ident(),
816        );
817    }
818
819    #[test]
820    fn test_valid_name_branch_preferred() {
821        let check = ValidName::builder()
822            .full_name_policy(ValidNameFullNamePolicy::Preferred)
823            .build()
824            .unwrap();
825        run_branch_check_ok(
826            "test_valid_name_branch_preferred/ok",
827            BAD_TOPIC,
828            check.clone(),
829        );
830        let result = run_branch_check_ident(
831            "test_valid_name_branch_preferred/mononym",
832            BAD_TOPIC,
833            check.clone(),
834            mononym_ident(),
835        );
836        test_result_warnings(result, &[
837            "The owner name (`Mononym`) for the topic has \
838             no space in it. A full name is preferred for contribution. Please set the `user.name` \
839             Git configuration value.",
840        ]);
841        let result = run_branch_check_ident(
842            "test_valid_name_branch_preferred/bademail",
843            BAD_TOPIC,
844            check.clone(),
845            bademail_ident(),
846        );
847        test_result_errors(
848            result,
849            &[
850                "The owner email (`bademail`) for the topic has an unknown domain. Please set the \
851                 `user.email` Git configuration value.",
852            ],
853        );
854        let result = run_branch_check_ident(
855            "test_valid_name_branch_preferred/bademail_mx",
856            BAD_TOPIC,
857            check,
858            bademail_mx_ident(),
859        );
860        test_result_errors(result, &[
861            "The owner email (`bademail@baddomain.invalid`) for the topic has an unknown domain. \
862             Please set the `user.email` Git configuration value.",
863        ]);
864    }
865
866    #[test]
867    fn test_valid_name_branch_optional() {
868        let check = ValidName::builder()
869            .full_name_policy(ValidNameFullNamePolicy::Optional)
870            .build()
871            .unwrap();
872        run_branch_check_ok(
873            "test_valid_name_branch_optional/ok",
874            BAD_TOPIC,
875            check.clone(),
876        );
877        run_branch_check_ident_ok(
878            "test_valid_name_branch_optional/mononym",
879            BAD_TOPIC,
880            check.clone(),
881            mononym_ident(),
882        );
883        let result = run_branch_check_ident(
884            "test_valid_name_branch_optional/bademail",
885            BAD_TOPIC,
886            check.clone(),
887            bademail_ident(),
888        );
889        test_result_errors(
890            result,
891            &[
892                "The owner email (`bademail`) for the topic has an unknown domain. Please set the \
893                 `user.email` Git configuration value.",
894            ],
895        );
896        let result = run_branch_check_ident(
897            "test_valid_name_branch_optional/bademail_mx",
898            BAD_TOPIC,
899            check,
900            bademail_mx_ident(),
901        );
902        test_result_errors(result, &[
903            "The owner email (`bademail@baddomain.invalid`) for the topic has an unknown domain. \
904             Please set the `user.email` Git configuration value.",
905        ]);
906    }
907
908    #[test]
909    fn test_valid_name_impl_debug() {
910        let check = ValidName::builder().build().unwrap();
911        let out = format!("{check:?}");
912        assert_eq!(
913            out,
914            "ValidName { full_name_policy: Required, trust_domains: {} }",
915        );
916    }
917
918    #[test]
919    fn test_valid_name_impl_clone() {
920        let check = ValidName::builder().build().unwrap();
921        {
922            let mut cache = check.dns_cache.lock().unwrap();
923            cache.insert(
924                "example.com".into(),
925                true,
926                super::DEFAULT_TTL_CACHE_HIT_DURATION,
927            );
928        }
929        // https://github.com/rust-lang/rust-clippy/issues/10893
930        #[allow(clippy::redundant_clone)]
931        let cloned = check.clone();
932        assert_eq!(cloned.full_name_policy, check.full_name_policy);
933        assert_eq!(cloned.dns_cache.lock().unwrap().iter().count(), 0);
934        assert_eq!(cloned.trust_domains, check.trust_domains);
935    }
936
937    #[test]
938    fn test_host_lookup_cache_duration() {
939        use super::HostLookup;
940
941        let items = [
942            (HostLookup::Hit, super::DEFAULT_TTL_CACHE_HIT_DURATION),
943            (
944                HostLookup::Miss {
945                    valid_until: None,
946                },
947                super::DEFAULT_TTL_CACHE_MISS_DURATION,
948            ),
949        ];
950
951        for (l, d) in items {
952            assert_eq!(l.cache_duration(), d);
953        }
954
955        let short_timeout = Duration::from_secs(60);
956        let soon = Instant::now() + short_timeout;
957        let long_timeout = Duration::from_secs(10000);
958        let later = Instant::now() + long_timeout;
959
960        let range_items = [
961            (
962                HostLookup::Miss {
963                    valid_until: Some(soon),
964                },
965                short_timeout,
966            ),
967            (
968                HostLookup::Miss {
969                    valid_until: Some(later),
970                },
971                long_timeout,
972            ),
973        ];
974
975        for (l, d) in range_items {
976            let duration = l.cache_duration();
977            // Make sure the duration is less than the given timeout.
978            assert!(duration <= d);
979            // But also "close" to the given timeout. If a platform takes longer than 1 second to
980            // get here, there are other performance issues to consider first.
981            assert!(d - duration <= Duration::from_secs(1));
982        }
983    }
984}