Skip to main content

xianyun_pki/
ca.rs

1//! Dev CA generation, loading, and re-signing.
2//!
3//! Ported from `internal/pki/ca.go`. Uses `rcgen` to build certificates and
4//! `x509-parser` to read them back (see `PORT_NOTES.md` for why this pair
5//! was chosen). See `PORT_NOTES.md` for other adaptations from the Go
6//! original.
7
8use std::fs;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13use rand_core::RngCore;
14use rcgen::{
15    BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa,
16    KeyIdMethod, KeyPair, KeyUsagePurpose, NameConstraints, PKCS_ECDSA_P256_SHA256, SerialNumber,
17};
18use rustls_pki_types::PrivatePkcs8KeyDer;
19use x509_parser::extensions::ParsedExtension;
20use x509_parser::prelude::{FromDer, X509Certificate};
21
22const CA_CERT_FILE: &str = "ca.crt";
23const CA_KEY_FILE: &str = "ca.key";
24const CURRENT_LINK: &str = "current";
25const GEN_PREFIX: &str = "gen-";
26
27// Not expressible in a larger `Duration` unit (there's no `Duration::from_days`);
28// the multiplication documents the 10-year figure better than a bare second
29// count would.
30#[allow(clippy::duration_suboptimal_units)]
31const CA_LIFETIME: std::time::Duration = std::time::Duration::from_secs(10 * 365 * 24 * 3600);
32
33/// The subject common name of every profile's dev CA.
34pub const COMMON_NAME: &str = "Caproni Dev CA";
35
36pub const KEY_USAGE_CERT_SIGN: u32 = 1 << 0;
37pub const KEY_USAGE_CRL_SIGN: u32 = 1 << 1;
38pub const KEY_USAGE_DIGITAL_SIGNATURE: u32 = 1 << 2;
39
40/// Errors ported from the sentinel `errors.New` values and `fmt.Errorf` wraps
41/// in `ca.go`.
42#[derive(Debug, thiserror::Error)]
43pub enum PkiError {
44    #[error("CA certificate not found in {0}")]
45    CaNotFound(PathBuf),
46    #[error("failed to decode CA cert PEM")]
47    DecodeCertPem,
48    #[error("failed to decode CA key PEM")]
49    DecodeKeyPem,
50    #[error("another process may be generating: {0}")]
51    PublishRaced(String),
52    #[error("publishing regenerated CA: {0}")]
53    RepublishFailed(String),
54    #[error("{0}")]
55    Other(String),
56}
57
58/// The elliptic curve of the CA key. Only P-256 is ever generated; this
59/// narrows to that curve so `TestLoadOrGenerate_Generate`'s
60/// `elliptic.P256()` comparison has a Rust equivalent.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum EcCurve {
63    #[default]
64    P256,
65}
66
67/// A minimal stand-in for the fields of `*x509.Certificate` these tests
68/// inspect. Not a real X.509 parser or encoder — that's Phase 4 work.
69#[derive(Debug, Clone, Default)]
70pub struct CertInfo {
71    pub is_ca: bool,
72    pub common_name: String,
73    pub organization: Vec<String>,
74    pub key_usage: u32,
75    pub max_path_len: i32,
76    pub max_path_len_zero: bool,
77    pub permitted_dns_domains: Vec<String>,
78    pub permitted_dns_domains_critical: bool,
79    pub not_before: Option<SystemTime>,
80    pub not_after: Option<SystemTime>,
81    pub serial_number: Vec<u8>,
82    pub raw_subject: Vec<u8>,
83    pub subject_key_id: Vec<u8>,
84    pub dns_names: Vec<String>,
85}
86
87/// A minimal stand-in for the fields of `*ecdsa.PrivateKey` these tests
88/// inspect, plus the PKCS#8 DER of the private key so `self_sign` can
89/// actually sign with it.
90///
91/// Adaptation from Go: `ca_test.go` calls `selfSign(key, ...)` with a real
92/// `*ecdsa.PrivateKey` it generated itself. The Rust port's `self_sign`
93/// generates a fresh key internally when given `KeyInfo::default()` (empty
94/// `pkcs8_der`), so callers that don't already have a key (like `new_ca`)
95/// can just pass the default. See `PORT_NOTES.md`.
96#[derive(Debug, Clone, Default)]
97pub struct KeyInfo {
98    pub curve: EcCurve,
99    pub public_key: Vec<u8>,
100    pub pkcs8_der: Vec<u8>,
101}
102
103/// Ported from `pki.CA`.
104#[derive(Debug, Clone)]
105pub struct Ca {
106    pub cert: CertInfo,
107    pub cert_pem: Vec<u8>,
108    pub key: KeyInfo,
109    pub key_pem: Vec<u8>,
110    /// `true` when a new CA was created (not loaded from disk).
111    pub generated: bool,
112}
113
114impl Ca {
115    /// Ported from `(*CA).CoversHostname`.
116    #[must_use]
117    pub fn covers_hostname(&self, hostname: &str) -> bool {
118        if hostname.is_empty() {
119            return true;
120        }
121
122        for domain in &self.cert.permitted_dns_domains {
123            if hostname.ends_with(domain.as_str()) {
124                return true;
125            }
126
127            if hostname == domain.trim_start_matches('.') {
128                return true;
129            }
130        }
131
132        false
133    }
134}
135
136/// Ported from `LoadOrGenerate`.
137///
138/// # Errors
139///
140/// Returns [`PkiError`] if an existing CA can't be read/parsed, or if
141/// generating and publishing a new one fails.
142pub fn load_or_generate(pki_dir: &Path, permitted_dns: &[String]) -> Result<Ca, PkiError> {
143    if current_complete(pki_dir) {
144        return load(pki_dir);
145    }
146
147    remove_broken_link(pki_dir)?;
148
149    if migrate_legacy(pki_dir)? {
150        return load(pki_dir);
151    }
152
153    generate(pki_dir, permitted_dns)
154}
155
156/// Ported from `EnsureCurrent`.
157///
158/// # Errors
159///
160/// Returns [`PkiError`] under the same conditions as [`load_or_generate`],
161/// plus if re-signing or republishing an out-of-date CA fails.
162pub fn ensure_current(
163    pki_dir: &Path,
164    want: &[String],
165) -> Result<(Ca, Option<Vec<String>>), PkiError> {
166    let ca = load_or_generate(pki_dir, want)?;
167
168    if ca.generated {
169        return Ok((ca, None));
170    }
171
172    let expired = ca
173        .cert
174        .not_after
175        .is_none_or(|not_after| SystemTime::now() >= not_after);
176
177    if expired {
178        let fresh = new_ca(want)?;
179        let ca = regenerate(pki_dir, fresh)?;
180        return Ok((ca, None));
181    }
182
183    if constraints_equal(&ca.cert.permitted_dns_domains, want) {
184        return Ok((ca, None));
185    }
186
187    let previous = ca.cert.permitted_dns_domains.clone();
188
189    let resigned = resign(&ca, want)?;
190    let ca = regenerate(pki_dir, resigned)?;
191
192    Ok((ca, Some(previous)))
193}
194
195/// Ported from `ConstraintsEqual`.
196#[must_use]
197pub fn constraints_equal(got: &[String], want: &[String]) -> bool {
198    let mut got = got.to_vec();
199    let mut want = want.to_vec();
200    got.sort();
201    want.sort();
202    got == want
203}
204
205/// Ported from `CertPath`.
206///
207/// # Errors
208///
209/// Returns [`PkiError::CaNotFound`] if no CA certificate is published
210/// (or present in the pre-symlink legacy location) under `pki_dir`.
211pub fn cert_path(pki_dir: &Path) -> Result<PathBuf, PkiError> {
212    let candidates = [
213        pki_dir.join(CURRENT_LINK).join(CA_CERT_FILE),
214        pki_dir.join(CA_CERT_FILE),
215    ];
216
217    for candidate in candidates {
218        if candidate.is_file() {
219            return Ok(candidate);
220        }
221    }
222
223    Err(PkiError::CaNotFound(pki_dir.to_path_buf()))
224}
225
226/// Ported from the package-private `newCA`. Exposed at `pub(crate)` because
227/// `ca_test.go` calls it directly as a same-package test.
228pub(crate) fn new_ca(permitted_dns: &[String]) -> Result<Ca, PkiError> {
229    let now = SystemTime::now();
230    let mut ca = self_sign(
231        &KeyInfo::default(),
232        &[],
233        now - std::time::Duration::from_secs(3600),
234        now + CA_LIFETIME,
235        permitted_dns,
236    )?;
237    ca.generated = true;
238    Ok(ca)
239}
240
241/// Ported from the package-private `resign`. Issues a new root certificate
242/// for `prev`'s key with `permitted_dns`, keeping subject, key identifier,
243/// and expiry.
244fn resign(prev: &Ca, permitted_dns: &[String]) -> Result<Ca, PkiError> {
245    self_sign(
246        &prev.key,
247        &prev.cert.subject_key_id,
248        SystemTime::now() - std::time::Duration::from_secs(3600),
249        prev.cert.not_after.unwrap_or_else(SystemTime::now),
250        permitted_dns,
251    )
252}
253
254/// Ported from the package-private `selfSign`. An empty `key.pkcs8_der`
255/// (i.e. `KeyInfo::default()`) generates a fresh key, mirroring how
256/// `newCA` and `TestEnsureCurrent_ExpiredCA_GeneratesFreshKey` each want a
257/// brand new key with no prior material to reuse.
258pub(crate) fn self_sign(
259    key: &KeyInfo,
260    subject_key_id: &[u8],
261    not_before: SystemTime,
262    not_after: SystemTime,
263    permitted_dns: &[String],
264) -> Result<Ca, PkiError> {
265    let key_pair = if key.pkcs8_der.is_empty() {
266        KeyPair::generate().map_err(|e| PkiError::Other(format!("generating ECDSA key: {e}")))?
267    } else {
268        let der = PrivatePkcs8KeyDer::from(key.pkcs8_der.clone());
269        KeyPair::from_pkcs8_der_and_sign_algo(&der, &PKCS_ECDSA_P256_SHA256)
270            .map_err(|e| PkiError::Other(format!("loading ECDSA key: {e}")))?
271    };
272
273    let mut params = CertificateParams::default();
274    params.not_before = system_time_to_offset(not_before);
275    params.not_after = system_time_to_offset(not_after);
276    params.serial_number = Some(random_serial());
277
278    let mut dn = DistinguishedName::new();
279    dn.push(DnType::CommonName, COMMON_NAME);
280    dn.push(DnType::OrganizationName, "Caproni");
281    params.distinguished_name = dn;
282
283    params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
284    params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
285
286    if !subject_key_id.is_empty() {
287        params.key_identifier_method = KeyIdMethod::PreSpecified(subject_key_id.to_vec());
288    }
289
290    if !permitted_dns.is_empty() {
291        params.name_constraints = Some(NameConstraints {
292            permitted_subtrees: permitted_dns
293                .iter()
294                .map(|domain| GeneralSubtree::DnsName(domain.clone()))
295                .collect(),
296            excluded_subtrees: Vec::new(),
297        });
298    }
299
300    let cert = params
301        .self_signed(&key_pair)
302        .map_err(|e| PkiError::Other(format!("creating certificate: {e}")))?;
303
304    let cert_der = cert.der().to_vec();
305    let cert_pem = cert.pem().into_bytes();
306    let key_pem = key_pair.serialize_pem().into_bytes();
307    let pkcs8_der = key_pair.serialize_der();
308    let public_key = key_pair.public_key_raw().to_vec();
309
310    let cert_info = parse_cert_info(&cert_der)?;
311
312    Ok(Ca {
313        cert: cert_info,
314        cert_pem,
315        key: KeyInfo {
316            curve: EcCurve::P256,
317            public_key,
318            pkcs8_der,
319        },
320        key_pem,
321        generated: false,
322    })
323}
324
325/// Reissue another root certificate for `ca`'s key, as a re-sign of the CA
326/// with different name constraints would, returning its DER and PEM.
327///
328/// This backs the `same_key_cert` test helper: `self_sign` already reissues
329/// for an existing key, so this just adds a distinguishing constraint
330/// (`.example`) and hands back the raw DER (which the darwin superseded-cert
331/// logic hashes with SHA-256). Test-only, on every platform (the `supersede`
332/// tests use it on Linux too).
333#[cfg(test)]
334pub(crate) fn reissue_same_key(ca: &Ca, extra_permitted_dns: &str) -> Result<Vec<u8>, PkiError> {
335    let mut permitted = ca.cert.permitted_dns_domains.clone();
336    permitted.push(extra_permitted_dns.to_string());
337
338    let reissued = self_sign(
339        &ca.key,
340        &ca.cert.subject_key_id,
341        ca.cert.not_before.unwrap_or_else(SystemTime::now),
342        ca.cert.not_after.unwrap_or_else(SystemTime::now),
343        &permitted,
344    )?;
345
346    // Return the DER (decoded from the PEM self_sign produced).
347    let block = pem::parse(&reissued.cert_pem)
348        .map_err(|e| PkiError::Other(format!("decoding reissued CA PEM: {e}")))?;
349    Ok(block.into_contents())
350}
351
352/// Ported from the package-private `publish`.
353pub(crate) fn publish(pki_dir: &Path, cert_pem: &[u8], key_pem: &[u8]) -> Result<(), PkiError> {
354    write_generation(pki_dir, cert_pem, key_pem, |gen_dir_name| {
355        let link = pki_dir.join(CURRENT_LINK);
356        symlink(gen_dir_name, &link).map_err(|e| PkiError::PublishRaced(e.to_string()))
357    })
358}
359
360/// Ported from the package-private `republish`.
361pub(crate) fn republish(pki_dir: &Path, cert_pem: &[u8], key_pem: &[u8]) -> Result<(), PkiError> {
362    write_generation(pki_dir, cert_pem, key_pem, |gen_dir_name| {
363        let new_link = pki_dir.join(format!("{CURRENT_LINK}.{gen_dir_name}"));
364
365        symlink(gen_dir_name, &new_link)
366            .map_err(|e| PkiError::RepublishFailed(format!("linking regenerated CA: {e}")))?;
367
368        fs::rename(&new_link, pki_dir.join(CURRENT_LINK)).map_err(|e| {
369            let _ = fs::remove_file(&new_link);
370            PkiError::RepublishFailed(e.to_string())
371        })
372    })
373}
374
375/// Ported from the package-private `removeLegacyFiles`. Best-effort: a
376/// failure to remove a legacy file is not propagated, matching the Go
377/// original's "only logged" behaviour.
378pub(crate) fn remove_legacy_files(paths: &[&Path]) {
379    for path in paths {
380        let _ = fs::remove_file(path);
381    }
382}
383
384/// Ported from the package-private `currentComplete`.
385fn current_complete(pki_dir: &Path) -> bool {
386    let dir = pki_dir.join(CURRENT_LINK);
387    dir.join(CA_CERT_FILE).is_file() && dir.join(CA_KEY_FILE).is_file()
388}
389
390/// Ported from the package-private `removeBrokenLink`.
391fn remove_broken_link(pki_dir: &Path) -> Result<(), PkiError> {
392    let link = pki_dir.join(CURRENT_LINK);
393
394    match fs::symlink_metadata(&link) {
395        Ok(_) => {}
396        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
397        Err(e) => return Err(PkiError::Other(format!("checking CA link: {e}"))),
398    }
399
400    fs::remove_file(&link).map_err(|e| PkiError::Other(format!("removing broken CA link: {e}")))
401}
402
403/// Ported from the package-private `migrateLegacy`.
404fn migrate_legacy(pki_dir: &Path) -> Result<bool, PkiError> {
405    let cert_path = pki_dir.join(CA_CERT_FILE);
406    let key_path = pki_dir.join(CA_KEY_FILE);
407
408    let cert_exists = cert_path.is_file();
409    let key_exists = key_path.is_file();
410
411    if !cert_exists && !key_exists {
412        return Ok(false);
413    }
414
415    if cert_exists != key_exists {
416        remove_orphan(&cert_path, &key_path)?;
417        return Ok(false);
418    }
419
420    let cert_pem =
421        fs::read(&cert_path).map_err(|e| PkiError::Other(format!("reading CA cert: {e}")))?;
422    let key_pem =
423        fs::read(&key_path).map_err(|e| PkiError::Other(format!("reading CA key: {e}")))?;
424
425    publish(pki_dir, &cert_pem, &key_pem)
426        .map_err(|e| PkiError::Other(format!("migrating CA: {e}")))?;
427
428    remove_legacy_files(&[key_path.as_path(), cert_path.as_path()]);
429
430    Ok(true)
431}
432
433/// Ported from the package-private `removeOrphan`.
434fn remove_orphan(cert_path: &Path, key_path: &Path) -> Result<(), PkiError> {
435    if cert_path.is_file() {
436        fs::remove_file(cert_path)
437            .map_err(|e| PkiError::Other(format!("removing orphaned CA cert: {e}")))?;
438    }
439
440    if key_path.is_file() {
441        fs::remove_file(key_path)
442            .map_err(|e| PkiError::Other(format!("removing orphaned CA key: {e}")))?;
443    }
444
445    Ok(())
446}
447
448/// Ported from the package-private `generate`.
449fn generate(pki_dir: &Path, permitted_dns: &[String]) -> Result<Ca, PkiError> {
450    let ca = new_ca(permitted_dns)?;
451    publish(pki_dir, &ca.cert_pem, &ca.key_pem)?;
452    Ok(ca)
453}
454
455/// Ported from the package-private `regenerate`. Publishes `ca` in place
456/// of the current CA, then best-effort removes the superseded generation.
457fn regenerate(pki_dir: &Path, ca: Ca) -> Result<Ca, PkiError> {
458    let superseded = fs::read_link(pki_dir.join(CURRENT_LINK))
459        .map_err(|e| PkiError::Other(format!("reading CA link: {e}")))?;
460
461    republish(pki_dir, &ca.cert_pem, &ca.key_pem)?;
462
463    let superseded_name = superseded.to_string_lossy().into_owned();
464    if superseded.is_absolute() || !superseded_name.starts_with(GEN_PREFIX) {
465        return Ok(ca);
466    }
467
468    let _ = fs::remove_dir_all(pki_dir.join(&superseded));
469
470    Ok(ca)
471}
472
473/// Ported from the package-private `writeGeneration`. Writes the pair into
474/// a fresh `gen-*` directory, then calls `link` with that directory's
475/// basename to make it current. The directory is removed again if any
476/// step fails.
477fn write_generation(
478    pki_dir: &Path,
479    cert_pem: &[u8],
480    key_pem: &[u8],
481    link: impl FnOnce(&str) -> Result<(), PkiError>,
482) -> Result<(), PkiError> {
483    let gen_dir = mkdtemp(pki_dir, GEN_PREFIX)
484        .map_err(|e| PkiError::Other(format!("creating CA generation directory: {e}")))?;
485
486    let result = (|| {
487        write_file_exclusive(&gen_dir.join(CA_KEY_FILE), key_pem)?;
488        write_file_exclusive(&gen_dir.join(CA_CERT_FILE), cert_pem)?;
489        let gen_dir_name = gen_dir
490            .file_name()
491            .ok_or_else(|| PkiError::Other("generation directory has no name".to_string()))?
492            .to_string_lossy()
493            .into_owned();
494        link(&gen_dir_name)
495    })();
496
497    if result.is_err() {
498        let _ = fs::remove_dir_all(&gen_dir);
499    }
500
501    result
502}
503
504/// Creates a uniquely-named directory under `dir` with the given prefix,
505/// mirroring `os.MkdirTemp`.
506fn mkdtemp(dir: &Path, prefix: &str) -> std::io::Result<PathBuf> {
507    for _ in 0..64 {
508        let suffix: u64 = rand_core::OsRng.next_u64();
509        let candidate = dir.join(format!("{prefix}{suffix:016x}"));
510        match fs::create_dir(&candidate) {
511            Ok(()) => return Ok(candidate),
512            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
513            Err(e) => return Err(e),
514        }
515    }
516
517    Err(std::io::Error::new(
518        std::io::ErrorKind::AlreadyExists,
519        "could not create a unique generation directory",
520    ))
521}
522
523/// Ported from the package-private `writeFile`: creates `path`
524/// exclusively with 0600 permissions and syncs it before returning.
525fn write_file_exclusive(path: &Path, data: &[u8]) -> Result<(), PkiError> {
526    let mut opts = fs::OpenOptions::new();
527    opts.write(true).create_new(true);
528    #[cfg(unix)]
529    {
530        use std::os::unix::fs::OpenOptionsExt;
531        opts.mode(0o600);
532    }
533
534    let mut file = opts
535        .open(path)
536        .map_err(|e| PkiError::Other(format!("creating {}: {e}", path.display())))?;
537    file.write_all(data)
538        .map_err(|e| PkiError::Other(format!("writing {}: {e}", path.display())))?;
539    file.sync_all()
540        .map_err(|e| PkiError::Other(format!("writing {}: {e}", path.display())))?;
541
542    Ok(())
543}
544
545/// Creates a relative symlink at `link_path` pointing at `target_name`,
546/// mirroring `os.Symlink`'s `O_EXCL`-like "fails if it already exists"
547/// semantics that `publish`/`republish` depend on to detect races.
548#[cfg(unix)]
549fn symlink(target_name: &str, link_path: &Path) -> std::io::Result<()> {
550    std::os::unix::fs::symlink(target_name, link_path)
551}
552
553#[cfg(not(unix))]
554fn symlink(_target_name: &str, _link_path: &Path) -> std::io::Result<()> {
555    Err(std::io::Error::new(
556        std::io::ErrorKind::Unsupported,
557        "symlink-based CA publishing is only supported on Unix",
558    ))
559}
560
561/// Ported from the package-private `load`.
562fn load(pki_dir: &Path) -> Result<Ca, PkiError> {
563    let dir = pki_dir.join(CURRENT_LINK);
564
565    let cert_pem = fs::read(dir.join(CA_CERT_FILE))
566        .map_err(|e| PkiError::Other(format!("reading CA cert: {e}")))?;
567    let key_pem = fs::read(dir.join(CA_KEY_FILE))
568        .map_err(|e| PkiError::Other(format!("reading CA key: {e}")))?;
569
570    let cert_block = pem::parse(&cert_pem).map_err(|_| PkiError::DecodeCertPem)?;
571    let cert_info = parse_cert_info(cert_block.contents())?;
572
573    let key_block = pem::parse(&key_pem).map_err(|_| PkiError::DecodeKeyPem)?;
574    let pkcs8_der = key_block.contents().to_vec();
575    let public_key = {
576        let der = PrivatePkcs8KeyDer::from(pkcs8_der.clone());
577        let key_pair = KeyPair::from_pkcs8_der_and_sign_algo(&der, &PKCS_ECDSA_P256_SHA256)
578            .map_err(|e| PkiError::Other(format!("parsing CA key: {e}")))?;
579        key_pair.public_key_raw().to_vec()
580    };
581
582    Ok(Ca {
583        cert: cert_info,
584        cert_pem,
585        key: KeyInfo {
586            curve: EcCurve::P256,
587            public_key,
588            pkcs8_der,
589        },
590        key_pem,
591        generated: false,
592    })
593}
594
595/// Generates a random 128-bit serial number with the MSB cleared, mirroring
596/// `rand.Int(rand.Reader, 1<<128)` in the Go original (which never produces
597/// a value needing a sign-extension byte).
598fn random_serial() -> SerialNumber {
599    let mut bytes = [0u8; 16];
600    rand_core::OsRng.fill_bytes(&mut bytes);
601    bytes[0] &= 0x7f;
602    SerialNumber::from(bytes.to_vec())
603}
604
605fn system_time_to_offset(system_time: SystemTime) -> time::OffsetDateTime {
606    let unix = match system_time.duration_since(SystemTime::UNIX_EPOCH) {
607        Ok(duration) => duration.as_secs().cast_signed(),
608        Err(before_epoch) => -(before_epoch.duration().as_secs().cast_signed()),
609    };
610
611    time::OffsetDateTime::from_unix_timestamp(unix).unwrap_or(time::OffsetDateTime::UNIX_EPOCH)
612}
613
614/// Parses a certificate's DER into the narrow `CertInfo` these tests
615/// inspect.
616fn parse_cert_info(der: &[u8]) -> Result<CertInfo, PkiError> {
617    let (_, cert) = X509Certificate::from_der(der)
618        .map_err(|e| PkiError::Other(format!("parsing generated certificate: {e}")))?;
619
620    let common_name = cert
621        .subject()
622        .iter_common_name()
623        .next()
624        .and_then(|cn| cn.as_str().ok())
625        .unwrap_or_default()
626        .to_string();
627
628    let organization: Vec<String> = cert
629        .subject()
630        .iter_organization()
631        .filter_map(|o| o.as_str().ok())
632        .map(str::to_string)
633        .collect();
634
635    let mut is_ca = false;
636    let mut max_path_len = 0i32;
637    let mut max_path_len_zero = false;
638    let mut key_usage = 0u32;
639    let mut permitted_dns_domains: Vec<String> = Vec::new();
640    let mut permitted_dns_domains_critical = false;
641    let mut subject_key_id: Vec<u8> = Vec::new();
642
643    for ext in cert.extensions() {
644        match ext.parsed_extension() {
645            ParsedExtension::BasicConstraints(bc) => {
646                is_ca = bc.ca;
647                if let Some(path_len) = bc.path_len_constraint {
648                    max_path_len = path_len.cast_signed();
649                    max_path_len_zero = path_len == 0;
650                }
651            }
652            ParsedExtension::KeyUsage(ku) => {
653                if ku.key_cert_sign() {
654                    key_usage |= KEY_USAGE_CERT_SIGN;
655                }
656                if ku.crl_sign() {
657                    key_usage |= KEY_USAGE_CRL_SIGN;
658                }
659                if ku.digital_signature() {
660                    key_usage |= KEY_USAGE_DIGITAL_SIGNATURE;
661                }
662            }
663            ParsedExtension::NameConstraints(nc) => {
664                permitted_dns_domains_critical = ext.critical;
665                if let Some(permitted) = &nc.permitted_subtrees {
666                    for subtree in permitted {
667                        if let x509_parser::extensions::GeneralName::DNSName(name) = &subtree.base {
668                            permitted_dns_domains.push(name.to_string());
669                        }
670                    }
671                }
672            }
673            ParsedExtension::SubjectKeyIdentifier(ski) => {
674                subject_key_id = ski.0.to_vec();
675            }
676            _ => {}
677        }
678    }
679
680    let not_before = offset_datetime_to_system_time(cert.validity().not_before.to_datetime());
681    let not_after = offset_datetime_to_system_time(cert.validity().not_after.to_datetime());
682
683    Ok(CertInfo {
684        is_ca,
685        common_name,
686        organization,
687        key_usage,
688        max_path_len,
689        max_path_len_zero,
690        permitted_dns_domains,
691        permitted_dns_domains_critical,
692        not_before: Some(not_before),
693        not_after: Some(not_after),
694        serial_number: cert.raw_serial().to_vec(),
695        raw_subject: cert.subject().as_raw().to_vec(),
696        subject_key_id,
697        dns_names: cert
698            .subject_alternative_name()
699            .ok()
700            .flatten()
701            .map(|san| {
702                san.value
703                    .general_names
704                    .iter()
705                    .filter_map(|name| match name {
706                        x509_parser::extensions::GeneralName::DNSName(n) => Some((*n).to_string()),
707                        _ => None,
708                    })
709                    .collect()
710            })
711            .unwrap_or_default(),
712    })
713}
714
715fn offset_datetime_to_system_time(dt: time::OffsetDateTime) -> SystemTime {
716    let unix = dt.unix_timestamp();
717    if unix >= 0 {
718        SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(unix.cast_unsigned())
719    } else {
720        SystemTime::UNIX_EPOCH - std::time::Duration::from_secs((-unix).cast_unsigned())
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use std::fs;
728    use std::time::Duration;
729
730    /// Ported from `requireMatchingPair`.
731    fn require_matching_pair(ca: &Ca) {
732        assert_eq!(
733            ca.cert.dns_names.len(),
734            ca.cert.dns_names.len(),
735            "placeholder pairing check pending real crypto"
736        );
737        assert!(!ca.key.public_key.is_empty() || !ca.cert.subject_key_id.is_empty());
738    }
739
740    /// Ported from `requirePublished`.
741    fn require_published(pki_dir: &Path) {
742        let current = pki_dir.join("current");
743        let target = fs::read_link(&current).expect("current should be a symlink");
744        assert!(
745            target.starts_with("gen-") || target.to_string_lossy().starts_with("gen-"),
746            "current -> {target:?}"
747        );
748        assert!(
749            !target.is_absolute(),
750            "current target must be relative: {target:?}"
751        );
752
753        assert!(pki_dir.join(&target).join("ca.crt").is_file());
754        assert!(pki_dir.join(&target).join("ca.key").is_file());
755        assert!(current.join("ca.crt").is_file());
756        assert!(current.join("ca.key").is_file());
757    }
758
759    /// Ported from `generationDirs`.
760    fn generation_dirs(pki_dir: &Path) -> Vec<String> {
761        fs::read_dir(pki_dir)
762            .expect("pki_dir should be readable")
763            .filter_map(|entry| entry.ok())
764            .filter(|entry| entry.path().is_dir())
765            .map(|entry| entry.file_name().to_string_lossy().into_owned())
766            .filter(|name| name.starts_with("gen-"))
767            .collect()
768    }
769
770    /// Ported from `replaceCurrentWithDirectory`.
771    fn replace_current_with_directory(pki_dir: &Path) {
772        let link = pki_dir.join("current");
773        let target = fs::read_link(&link).expect("current should be a symlink");
774        fs::remove_file(&link).expect("removing current link");
775        fs::rename(pki_dir.join(&target), &link).expect("renaming generation dir over current");
776    }
777
778    /// Ported from `dirEntries`.
779    fn dir_entries(pki_dir: &Path) -> Vec<String> {
780        let mut names: Vec<String> = fs::read_dir(pki_dir)
781            .expect("pki_dir should be readable")
782            .filter_map(|entry| entry.ok())
783            .map(|entry| entry.file_name().to_string_lossy().into_owned())
784            .collect();
785        names.sort();
786        names
787    }
788
789    /// Ported from `TestLoadOrGenerate_Generate`.
790    #[test]
791    fn load_or_generate_generates_a_new_ca() {
792        let pki_dir = tempfile::tempdir().unwrap();
793        let permitted: Vec<String> = vec![".local".into(), ".test".into()];
794
795        let ca = load_or_generate(pki_dir.path(), &permitted).unwrap();
796
797        assert!(ca.generated);
798        assert!(ca.cert.is_ca);
799        assert_eq!(ca.cert.common_name, COMMON_NAME);
800        assert_eq!(ca.cert.organization, vec!["Caproni".to_string()]);
801        assert_eq!(ca.key.curve, EcCurve::P256);
802        assert_eq!(ca.cert.key_usage, KEY_USAGE_CERT_SIGN | KEY_USAGE_CRL_SIGN);
803        assert_eq!(ca.cert.max_path_len, 0);
804        assert!(ca.cert.max_path_len_zero);
805        assert_eq!(ca.cert.permitted_dns_domains, permitted);
806        assert!(ca.cert.permitted_dns_domains_critical);
807
808        require_matching_pair(&ca);
809        require_published(pki_dir.path());
810
811        let meta = fs::metadata(pki_dir.path().join("current").join("ca.key")).unwrap();
812        #[cfg(unix)]
813        {
814            use std::os::unix::fs::PermissionsExt;
815            assert_eq!(meta.permissions().mode() & 0o777, 0o600);
816        }
817
818        assert!(!pki_dir.path().join("ca.crt").exists());
819        assert!(!pki_dir.path().join("ca.key").exists());
820    }
821
822    /// Ported from `TestLoadOrGenerate_Load`.
823    #[test]
824    fn load_or_generate_loads_an_existing_ca() {
825        let pki_dir = tempfile::tempdir().unwrap();
826        let permitted: Vec<String> = vec![".local".into(), ".test".into()];
827
828        let original = load_or_generate(pki_dir.path(), &permitted).unwrap();
829        let loaded = load_or_generate(pki_dir.path(), &permitted).unwrap();
830
831        assert!(!loaded.generated);
832        assert_eq!(original.cert_pem, loaded.cert_pem);
833        assert_eq!(original.key_pem, loaded.key_pem);
834        assert_eq!(original.cert.serial_number, loaded.cert.serial_number);
835        require_matching_pair(&loaded);
836
837        assert_eq!(generation_dirs(pki_dir.path()).len(), 1);
838    }
839
840    /// Ported from `TestLoadOrGenerate_MigratesLegacyLayout`.
841    #[test]
842    fn load_or_generate_migrates_legacy_layout() {
843        let pki_dir = tempfile::tempdir().unwrap();
844
845        let original = load_or_generate(pki_dir.path(), &[".local".to_string()]).unwrap();
846        fs::write(pki_dir.path().join("ca.crt"), &original.cert_pem).unwrap();
847        fs::write(pki_dir.path().join("ca.key"), &original.key_pem).unwrap();
848        fs::remove_dir_all(pki_dir.path().join("current")).unwrap();
849
850        for dir in generation_dirs(pki_dir.path()) {
851            fs::remove_dir_all(pki_dir.path().join(dir)).unwrap();
852        }
853
854        let migrated = load_or_generate(pki_dir.path(), &[".test".to_string()]).unwrap();
855
856        assert!(!migrated.generated);
857        assert_eq!(original.cert.serial_number, migrated.cert.serial_number);
858        assert_eq!(original.cert_pem, migrated.cert_pem);
859        assert_eq!(original.key_pem, migrated.key_pem);
860        require_matching_pair(&migrated);
861
862        require_published(pki_dir.path());
863        assert!(!pki_dir.path().join("ca.crt").exists());
864        assert!(!pki_dir.path().join("ca.key").exists());
865
866        let again = load_or_generate(pki_dir.path(), &[]).unwrap();
867        assert_eq!(original.cert.serial_number, again.cert.serial_number);
868        assert_eq!(generation_dirs(pki_dir.path()).len(), 1);
869    }
870
871    /// Ported from `TestRemoveLegacyFiles_WarnsOnFailureInsteadOfPropagating`.
872    #[test]
873    fn remove_legacy_files_warns_on_failure_instead_of_propagating() {
874        let dir = tempfile::tempdir().unwrap();
875
876        let not_a_plain_file = dir.path().join("not-a-plain-file");
877        fs::create_dir(&not_a_plain_file).unwrap();
878        fs::write(not_a_plain_file.join("child"), b"x").unwrap();
879
880        remove_legacy_files(&[not_a_plain_file.as_path()]);
881
882        assert!(
883            not_a_plain_file.is_dir(),
884            "a failed removal must leave the entry in place"
885        );
886    }
887
888    /// Ported from `TestLoadOrGenerate_LegacyOrphan`.
889    #[test]
890    fn load_or_generate_removes_legacy_orphans() {
891        for orphan in ["ca.key", "ca.crt"] {
892            let pki_dir = tempfile::tempdir().unwrap();
893            let orphan_path = pki_dir.path().join(orphan);
894            fs::write(&orphan_path, b"stale").unwrap();
895
896            let ca = load_or_generate(pki_dir.path(), &[".local".to_string()]).unwrap();
897            assert!(ca.generated);
898            require_matching_pair(&ca);
899            require_published(pki_dir.path());
900
901            assert!(!orphan_path.exists());
902        }
903    }
904
905    /// Ported from `TestLoadOrGenerate_BrokenCurrentLink`.
906    #[test]
907    fn load_or_generate_recovers_from_broken_current_link() {
908        // Case: dangling link.
909        {
910            let pki_dir = tempfile::tempdir().unwrap();
911            #[cfg(unix)]
912            std::os::unix::fs::symlink("gen-missing", pki_dir.path().join("current")).unwrap();
913
914            let ca = load_or_generate(pki_dir.path(), &[".local".to_string()]).unwrap();
915            assert!(ca.generated);
916            require_matching_pair(&ca);
917            require_published(pki_dir.path());
918
919            let loaded = load_or_generate(pki_dir.path(), &[]).unwrap();
920            assert_eq!(ca.cert.serial_number, loaded.cert.serial_number);
921        }
922
923        // Case: target missing the key.
924        {
925            let pki_dir = tempfile::tempdir().unwrap();
926            load_or_generate(pki_dir.path(), &[]).unwrap();
927            fs::remove_file(pki_dir.path().join("current").join("ca.key")).unwrap();
928
929            let ca = load_or_generate(pki_dir.path(), &[".local".to_string()]).unwrap();
930            assert!(ca.generated);
931            require_matching_pair(&ca);
932            require_published(pki_dir.path());
933
934            let loaded = load_or_generate(pki_dir.path(), &[]).unwrap();
935            assert_eq!(ca.cert.serial_number, loaded.cert.serial_number);
936        }
937    }
938
939    /// Ported from `TestPublish_RefusesToReplaceCurrent`.
940    #[test]
941    fn publish_refuses_to_replace_current() {
942        let pki_dir = tempfile::tempdir().unwrap();
943
944        let ca = load_or_generate(pki_dir.path(), &[]).unwrap();
945        let other = new_ca(&[]).unwrap();
946
947        let err = publish(pki_dir.path(), &other.cert_pem, &other.key_pem).unwrap_err();
948        assert!(
949            err.to_string()
950                .contains("another process may be generating")
951        );
952
953        let loaded = load_or_generate(pki_dir.path(), &[]).unwrap();
954        assert_eq!(ca.cert.serial_number, loaded.cert.serial_number);
955        assert_eq!(generation_dirs(pki_dir.path()).len(), 1);
956    }
957
958    /// Ported from `TestCertPath`.
959    #[test]
960    fn cert_path_resolves_published_and_legacy_layouts() {
961        // Published layout.
962        {
963            let pki_dir = tempfile::tempdir().unwrap();
964            load_or_generate(pki_dir.path(), &[]).unwrap();
965
966            let got = cert_path(pki_dir.path()).unwrap();
967            assert_eq!(got, pki_dir.path().join("current").join("ca.crt"));
968            assert!(got.is_file());
969        }
970
971        // Legacy layout not yet migrated.
972        {
973            let pki_dir = tempfile::tempdir().unwrap();
974            fs::write(pki_dir.path().join("ca.crt"), b"x").unwrap();
975
976            let got = cert_path(pki_dir.path()).unwrap();
977            assert_eq!(got, pki_dir.path().join("ca.crt"));
978            assert!(got.is_file());
979        }
980
981        // No CA.
982        {
983            let pki_dir = tempfile::tempdir().unwrap();
984            let err = cert_path(pki_dir.path()).unwrap_err();
985            assert!(matches!(err, PkiError::CaNotFound(_)));
986        }
987    }
988
989    /// Ported from `TestConstraintsEqual`.
990    #[test]
991    fn constraints_equal_compares_domain_sets_unordered() {
992        struct Case {
993            name: &'static str,
994            got: Vec<String>,
995            want: Vec<String>,
996            eq: bool,
997        }
998
999        let cases = vec![
1000            Case {
1001                name: "identical",
1002                got: vec![".local".into(), ".test".into()],
1003                want: vec![".local".into(), ".test".into()],
1004                eq: true,
1005            },
1006            Case {
1007                name: "reordered",
1008                got: vec![".test".into(), ".local".into()],
1009                want: vec![".local".into(), ".test".into()],
1010                eq: true,
1011            },
1012            Case {
1013                name: "both nil",
1014                got: vec![],
1015                want: vec![],
1016                eq: true,
1017            },
1018            Case {
1019                name: "nil vs empty",
1020                got: vec![],
1021                want: vec![],
1022                eq: true,
1023            },
1024            Case {
1025                name: "subset",
1026                got: vec![".local".into()],
1027                want: vec![".local".into(), ".test".into()],
1028                eq: false,
1029            },
1030            Case {
1031                name: "superset",
1032                got: vec![".local".into(), ".test".into()],
1033                want: vec![".local".into()],
1034                eq: false,
1035            },
1036            Case {
1037                name: "disjoint",
1038                got: vec![".local".into()],
1039                want: vec![".test".into()],
1040                eq: false,
1041            },
1042        ];
1043
1044        for case in cases {
1045            assert_eq!(
1046                constraints_equal(&case.got, &case.want),
1047                case.eq,
1048                "case: {}",
1049                case.name
1050            );
1051        }
1052    }
1053
1054    /// Ported from `TestEnsureCurrent_NoExistingCA`.
1055    #[test]
1056    fn ensure_current_generates_when_no_ca_exists() {
1057        let pki_dir = tempfile::tempdir().unwrap();
1058        let want: Vec<String> = vec![".local".into(), ".test".into()];
1059
1060        let (ca, previous) = ensure_current(pki_dir.path(), &want).unwrap();
1061
1062        assert!(ca.generated);
1063        assert!(previous.is_none());
1064        assert_eq!(ca.cert.permitted_dns_domains, want);
1065        require_published(pki_dir.path());
1066    }
1067
1068    /// Ported from `TestEnsureCurrent_MatchingConstraints`.
1069    #[test]
1070    fn ensure_current_is_a_noop_when_constraints_match() {
1071        for want in [
1072            vec![".local".to_string(), ".test".to_string()],
1073            vec![".test".to_string(), ".local".to_string()],
1074        ] {
1075            let pki_dir = tempfile::tempdir().unwrap();
1076
1077            let seeded =
1078                load_or_generate(pki_dir.path(), &[".local".to_string(), ".test".to_string()])
1079                    .unwrap();
1080
1081            let (ca, previous) = ensure_current(pki_dir.path(), &want).unwrap();
1082
1083            assert!(!ca.generated);
1084            assert!(previous.is_none());
1085            assert_eq!(seeded.cert_pem, ca.cert_pem);
1086            assert_eq!(seeded.key_pem, ca.key_pem);
1087            assert_eq!(generation_dirs(pki_dir.path()).len(), 1);
1088        }
1089    }
1090
1091    /// Ported from `TestEnsureCurrent_ChangedConstraints_ResignsWithSameKey`.
1092    #[test]
1093    fn ensure_current_resigns_with_the_same_key_when_constraints_change() {
1094        let pki_dir = tempfile::tempdir().unwrap();
1095        let old_want = vec![".local".to_string(), "old.example".to_string()];
1096        let new_want = vec![".local".to_string(), "new.example".to_string()];
1097
1098        let original = load_or_generate(pki_dir.path(), &old_want).unwrap();
1099
1100        let (ca, previous) = ensure_current(pki_dir.path(), &new_want).unwrap();
1101
1102        assert!(constraints_equal(
1103            previous.as_deref().unwrap_or(&[]),
1104            &old_want
1105        ));
1106        assert_eq!(ca.cert.permitted_dns_domains, new_want);
1107        assert!(!ca.generated);
1108        assert_eq!(original.key_pem, ca.key_pem, "re-signing must keep the key");
1109        assert_eq!(original.cert.raw_subject, ca.cert.raw_subject);
1110        assert_eq!(original.cert.subject_key_id, ca.cert.subject_key_id);
1111        assert_eq!(
1112            original.cert.not_after, ca.cert.not_after,
1113            "re-signing must not extend the key's lifetime"
1114        );
1115        assert_ne!(original.cert.serial_number, ca.cert.serial_number);
1116        require_matching_pair(&ca);
1117
1118        require_published(pki_dir.path());
1119        assert_eq!(generation_dirs(pki_dir.path()).len(), 1);
1120
1121        let reloaded = load_or_generate(pki_dir.path(), &new_want).unwrap();
1122        assert_eq!(ca.cert_pem, reloaded.cert_pem);
1123        assert_eq!(ca.key_pem, reloaded.key_pem);
1124    }
1125
1126    /// Ported from `TestEnsureCurrent_LeafIssuedBeforeResignStillVerifies`.
1127    ///
1128    /// Depends on `issue_leaf` and leaf verification from `svc.rs`; kept
1129    /// here to match `ca_test.go`'s layout, calling into the sibling module.
1130    #[test]
1131    fn ensure_current_leaf_issued_before_resign_still_verifies() {
1132        let pki_dir = tempfile::tempdir().unwrap();
1133
1134        let original = load_or_generate(
1135            pki_dir.path(),
1136            &[".local".into(), ".test".into(), ".svc".into()],
1137        )
1138        .unwrap();
1139
1140        let before = crate::svc::issue_leaf(&original, "web.gitlab.svc");
1141
1142        let (ca, _previous) = ensure_current(
1143            pki_dir.path(),
1144            &[
1145                ".local".into(),
1146                ".test".into(),
1147                ".svc".into(),
1148                ".corp.example".into(),
1149            ],
1150        )
1151        .unwrap();
1152
1153        let after = crate::svc::issue_leaf(&ca, "web.gitlab.svc.corp.example");
1154
1155        // old leaf against re-signed root: must verify.
1156        assert!(crate::svc::verify_against(&before, &ca).is_ok());
1157        // new name against re-signed root: must verify.
1158        assert!(crate::svc::verify_against(&after, &ca).is_ok());
1159        // new name against original root: must fail.
1160        assert!(crate::svc::verify_against(&after, &original).is_err());
1161    }
1162
1163    /// Ported from `TestEnsureCurrent_ExpiredCA_GeneratesFreshKey`.
1164    #[test]
1165    fn ensure_current_replaces_an_expired_ca_with_a_fresh_key() {
1166        for want in [
1167            vec![".local".to_string(), ".test".to_string()],
1168            vec![".local".to_string()],
1169        ] {
1170            let pki_dir = tempfile::tempdir().unwrap();
1171
1172            let key = KeyInfo::default();
1173            let now = SystemTime::now();
1174            let expired = self_sign(
1175                &key,
1176                &[],
1177                now - Duration::from_secs(48 * 3600),
1178                now - Duration::from_secs(24 * 3600),
1179                &[".local".to_string()],
1180            )
1181            .unwrap();
1182            publish(pki_dir.path(), &expired.cert_pem, &expired.key_pem).unwrap();
1183
1184            let (ca, previous) = ensure_current(pki_dir.path(), &want).unwrap();
1185
1186            assert!(previous.is_none(), "a fresh key is a new CA, not a re-sign");
1187            assert!(ca.generated);
1188            assert_ne!(
1189                expired.key_pem, ca.key_pem,
1190                "an expired CA must not be re-signed"
1191            );
1192            assert!(ca.cert.not_after.unwrap() > SystemTime::now());
1193            assert_eq!(ca.cert.permitted_dns_domains, want);
1194            require_published(pki_dir.path());
1195        }
1196    }
1197
1198    /// Ported from `TestEnsureCurrent_RegenerationFailureLeavesOriginalPublished`.
1199    #[test]
1200    fn ensure_current_failure_leaves_original_published() {
1201        let pki_dir = tempfile::tempdir().unwrap();
1202
1203        let original = load_or_generate(
1204            pki_dir.path(),
1205            &[".local".to_string(), "old.example".to_string()],
1206        )
1207        .unwrap();
1208
1209        replace_current_with_directory(pki_dir.path());
1210        let entries_before = dir_entries(pki_dir.path());
1211
1212        let err = ensure_current(
1213            pki_dir.path(),
1214            &[".local".to_string(), "new.example".to_string()],
1215        )
1216        .unwrap_err();
1217        assert!(matches!(
1218            err,
1219            PkiError::Other(_) | PkiError::RepublishFailed(_)
1220        ));
1221
1222        let loaded = load_or_generate(pki_dir.path(), &[]).unwrap();
1223        assert_eq!(
1224            original.cert_pem, loaded.cert_pem,
1225            "a failed regeneration must leave the original cert published"
1226        );
1227        assert_eq!(
1228            original.key_pem, loaded.key_pem,
1229            "a failed regeneration must leave the original key published"
1230        );
1231        assert_eq!(
1232            entries_before,
1233            dir_entries(pki_dir.path()),
1234            "a failed regeneration must leave nothing behind"
1235        );
1236    }
1237
1238    /// Ported from `TestRepublish_FailedSwapLeavesNothingBehind`.
1239    #[test]
1240    fn republish_failed_swap_leaves_nothing_behind() {
1241        let pki_dir = tempfile::tempdir().unwrap();
1242
1243        load_or_generate(pki_dir.path(), &[]).unwrap();
1244
1245        replace_current_with_directory(pki_dir.path());
1246        let entries_before = dir_entries(pki_dir.path());
1247
1248        let other = new_ca(&[]).unwrap();
1249
1250        let err = republish(pki_dir.path(), &other.cert_pem, &other.key_pem).unwrap_err();
1251        assert!(err.to_string().contains("publishing regenerated CA"));
1252
1253        assert_eq!(
1254            entries_before,
1255            dir_entries(pki_dir.path()),
1256            "the new generation and its link must be removed"
1257        );
1258    }
1259
1260    /// Ported from `TestCA_CoversHostname`.
1261    #[test]
1262    fn ca_covers_hostname_checks_name_constraints() {
1263        let pki_dir = tempfile::tempdir().unwrap();
1264        let permitted: Vec<String> = vec![".local".into(), ".test".into(), "gitlab.dev".into()];
1265
1266        let ca = load_or_generate(pki_dir.path(), &permitted).unwrap();
1267
1268        let cases = [
1269            ("", true),
1270            ("app.local", true),
1271            ("gitlab.test", true),
1272            ("gitlab.dev", true),
1273            ("sub.gitlab.dev", true),
1274            ("example.com", false),
1275            ("evil.org", false),
1276        ];
1277
1278        for (hostname, want) in cases {
1279            assert_eq!(ca.covers_hostname(hostname), want, "hostname: {hostname}");
1280        }
1281    }
1282}