Skip to main content

didbot_tls/
storage.rs

1//! Certificate and ACME account-key storage in the data directory.
2//!
3//! An ACME account key is a credential in the same sense a signing key is:
4//! whoever holds it can request certificates in this server's name against
5//! its account. It gets the same treatment `crates/didbot-pds/src/wal/mod.rs`
6//! gives the write-ahead log: `0600` files inside a `0700` directory, and the
7//! directory's mode is set unconditionally rather than only on creation — a
8//! `mkdir`'d-by-hand data directory or a mounted volume gets the same
9//! tightening a freshly created one always did. See that module's
10//! `create_dir` for the fuller argument; this is the same fix, independently
11//! applied, because this crate cannot reach into `didbot-pds`'s
12//! `pub(crate)` copy.
13
14use std::fs;
15use std::io;
16use std::path::{Path, PathBuf};
17
18#[cfg(unix)]
19const FILE_MODE: u32 = 0o600;
20#[cfg(unix)]
21const DIR_MODE: u32 = 0o700;
22
23const ACCOUNT_FILE: &str = "acme-account.json";
24const CA_FILE: &str = "ca.pem";
25const CA_KEY_FILE: &str = "ca-key.pem";
26const CERT_FILE: &str = "cert.pem";
27const KEY_FILE: &str = "cert-key.pem";
28const META_FILE: &str = "meta.json";
29
30/// Why the on-disk certificate state could not be read or written.
31#[derive(Debug, thiserror::Error)]
32pub enum StorageError {
33    /// A filesystem operation on `path` failed.
34    #[error("could not prepare {path}: {source}")]
35    Io {
36        /// The file or directory involved.
37        path: PathBuf,
38        /// The underlying error.
39        #[source]
40        source: io::Error,
41    },
42    /// A zone hostname could not be used as a directory name.
43    #[error("{zone:?} is not usable as a certificate directory name")]
44    InvalidZone {
45        /// The zone that was refused.
46        zone: String,
47    },
48    /// `path` existed but was not valid JSON for the type expected there.
49    #[error("{path} did not parse: {source}")]
50    Parse {
51        /// The file that failed to parse.
52        path: PathBuf,
53        /// The underlying error.
54        #[source]
55        source: serde_json::Error,
56    },
57}
58
59/// Where this crate's own state lives inside a PDS data directory: a `tls/`
60/// subdirectory, so it sits beside the write-ahead log and blob store rather
61/// than mixed into either.
62///
63/// # Two directories, because two things have different scopes
64///
65/// The ACME account is one account per deployment however many zones it
66/// serves — orders are per certificate, registration is not — so
67/// `acme-account.json` stays at the `tls/` root. A certificate covers
68/// exactly one zone (`<zone>` plus `*.<zone>`, and a wildcard matches one
69/// label, so a second zone needs a second certificate), so each zone's
70/// certificate and key live in `tls/zones/<zone>/`. [`CertStore::open_zone`]
71/// is the constructor that splits them; [`CertStore::open`] points both at
72/// the root and is what a caller with no zone at all — a test, or a tool
73/// only touching the account — wants.
74#[derive(Debug, Clone)]
75pub struct CertStore {
76    dir: PathBuf,
77    account_dir: PathBuf,
78}
79
80/// The metadata kept beside the certificate: which directory issued it and
81/// when it was written. The certificate's own `notAfter` is not duplicated
82/// here — `cert.rs::leaf_expiry` reads that from the DER, which cannot drift
83/// from what the certificate actually says.
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct CertMeta {
86    /// Which authority issued this certificate: an ACME directory URL, or
87    /// [`crate::local::LOCAL_SOURCE`]. A certificate is only as good as the
88    /// authority behind it — a staging leaf, a production leaf and a leaf
89    /// from this machine's own authority are served by the same code and
90    /// trusted by different people — so the run that reads one back checks
91    /// this before serving it.
92    pub directory_url: String,
93    /// When this crate last wrote a certificate here.
94    #[serde(with = "time::serde::rfc3339")]
95    pub issued_at: time::OffsetDateTime,
96}
97
98/// An ACME account read back off disk, and which directory registered it.
99///
100/// The pair, rather than the credentials alone, because credentials are only
101/// usable against the directory that issued them: an account registered at
102/// Let's Encrypt staging can place orders at staging and nowhere else, and
103/// the CAA record naming it authorises issuance at staging and nowhere else.
104/// A deployment that switches directories has to know which one it is
105/// holding before it decides whether to restore it.
106pub struct StoredAccount {
107    /// The credentials, ready to restore an `instant_acme::Account` from.
108    pub credentials: instant_acme::AccountCredentials,
109    /// The ACME directory URL these credentials record. `None` for a file
110    /// written by a pre-0.4 `instant-acme`, which recorded the directory's
111    /// individual endpoint URLs instead of the directory itself.
112    pub directory_url: Option<String>,
113}
114
115impl StoredAccount {
116    /// Whether these credentials were registered against `directory_url`.
117    ///
118    /// A file that records no directory answers `false` for every one:
119    /// registering a fresh account costs one round trip, and using
120    /// credentials against a directory that may not have issued them costs
121    /// an outage at the next order.
122    pub fn issued_by(&self, directory_url: &str) -> bool {
123        self.directory_url.as_deref() == Some(directory_url)
124    }
125}
126
127impl CertStore {
128    /// Opens `<data_dir>/tls`, creating it `0700` if absent and tightening it
129    /// to `0700` if it already existed.
130    pub fn open(data_dir: &Path) -> Result<Self, StorageError> {
131        let dir = data_dir.join("tls");
132        create_dir(&dir)?;
133        Ok(Self {
134            account_dir: dir.clone(),
135            dir,
136        })
137    }
138
139    /// Opens `<data_dir>/tls/zones/<zone>` for `zone`'s certificate, with the
140    /// ACME account still read from and written to `<data_dir>/tls`.
141    ///
142    /// One directory per zone is what makes a per-zone certificate pair
143    /// possible at all: two zones' `cert.pem` files cannot share a path, and
144    /// the alternative — one file whose contents depend on which zone was
145    /// bootstrapped last — is the bug this layout exists to make
146    /// unrepresentable.
147    ///
148    /// A zone hostname is a validated DNS name by the time it reaches here,
149    /// but this checks anyway rather than joining an attacker-controlled
150    /// string onto a path: anything with a separator, a `..`, or nothing in
151    /// it at all is refused.
152    pub fn open_zone(data_dir: &Path, zone: &str) -> Result<Self, StorageError> {
153        let component = zone_directory_component(zone)?;
154        let account_dir = data_dir.join("tls");
155        create_dir(&account_dir)?;
156        let dir = account_dir.join("zones").join(component);
157        create_dir(&dir)?;
158        Ok(Self { dir, account_dir })
159    }
160
161    /// The directory this store keeps `zone`'s certificate in.
162    pub fn directory(&self) -> &Path {
163        &self.dir
164    }
165
166    /// The ACME account last saved here, if any: the credentials, and the
167    /// directory they were registered against.
168    pub fn account_credentials(&self) -> Result<Option<StoredAccount>, StorageError> {
169        let path = self.account_dir.join(ACCOUNT_FILE);
170        let Some(value): Option<serde_json::Value> = self.read_json_at(&path)? else {
171            return Ok(None);
172        };
173        // `instant_acme::AccountCredentials` keeps the directory it was
174        // registered against and serializes it, but does not expose it on
175        // the type, so it is read off the same JSON the credentials are
176        // deserialized from rather than out of them.
177        let directory_url = value
178            .get("directory")
179            .and_then(serde_json::Value::as_str)
180            .map(str::to_owned);
181        let credentials =
182            serde_json::from_value(value).map_err(|source| StorageError::Parse { path, source })?;
183        Ok(Some(StoredAccount {
184            credentials,
185            directory_url,
186        }))
187    }
188
189    /// Persists ACME account credentials, `0600`.
190    pub fn save_account_credentials(
191        &self,
192        credentials: &instant_acme::AccountCredentials,
193    ) -> Result<(), StorageError> {
194        write_json_at(&self.account_dir.join(ACCOUNT_FILE), credentials)
195    }
196
197    /// The certificate chain, key and metadata last saved here, if any.
198    pub fn certificate(&self) -> Result<Option<(String, String, CertMeta)>, StorageError> {
199        let cert_path = self.dir.join(CERT_FILE);
200        if !cert_path.is_file() {
201            return Ok(None);
202        }
203        let cert_pem = self.read_string(CERT_FILE)?;
204        let key_pem = self.read_string(KEY_FILE)?;
205        let meta = self.read_json(META_FILE)?.ok_or_else(|| StorageError::Io {
206            path: self.dir.join(META_FILE),
207            source: io::Error::new(io::ErrorKind::NotFound, "cert.pem exists without meta.json"),
208        })?;
209        Ok(Some((cert_pem, key_pem, meta)))
210    }
211
212    /// Persists a certificate chain, its key and metadata, each `0600`.
213    ///
214    /// All three are staged beside their final paths before any is renamed
215    /// into place (see `Staged`), so a save that fails leaves the previous
216    /// set untouched, and a crash leaves every file whole: its old contents
217    /// or its new ones, never empty or cut short. `meta.json` is renamed
218    /// last, so its presence means the key and certificate beside it were
219    /// both written in full. The renames are three steps, not one: a crash
220    /// between them can leave a new key beside the old certificate, or a
221    /// certificate with no `meta.json`. The next bootstrap finds that set
222    /// unusable and orders a new certificate.
223    pub fn save_certificate(
224        &self,
225        cert_pem: &str,
226        key_pem: &str,
227        meta: &CertMeta,
228    ) -> Result<(), StorageError> {
229        let key = Staged::new(&self.dir.join(KEY_FILE), key_pem.as_bytes())?;
230        let cert = Staged::new(&self.dir.join(CERT_FILE), cert_pem.as_bytes())?;
231        let meta = Staged::json(&self.dir.join(META_FILE), meta)?;
232        key.commit()?;
233        cert.commit()?;
234        meta.commit()
235    }
236
237    fn read_string(&self, name: &str) -> Result<String, StorageError> {
238        let path = self.dir.join(name);
239        fs::read_to_string(&path).map_err(|source| StorageError::Io { path, source })
240    }
241
242    fn read_json<T: serde::de::DeserializeOwned>(
243        &self,
244        name: &str,
245    ) -> Result<Option<T>, StorageError> {
246        self.read_json_at(&self.dir.join(name))
247    }
248
249    fn read_json_at<T: serde::de::DeserializeOwned>(
250        &self,
251        path: &Path,
252    ) -> Result<Option<T>, StorageError> {
253        let path = path.to_owned();
254        if !path.is_file() {
255            return Ok(None);
256        }
257        let text = fs::read_to_string(&path).map_err(|source| StorageError::Io {
258            path: path.clone(),
259            source,
260        })?;
261        serde_json::from_str(&text)
262            .map(Some)
263            .map_err(|source| StorageError::Parse { path, source })
264    }
265}
266
267/// The local certificate authority's own certificate and key, in a
268/// directory of their own.
269///
270/// One directory per machine rather than per data directory: a developer
271/// trusts this certificate once, and a second server started with a second
272/// `--data` must present leaves the same trusted authority signed.
273#[derive(Debug, Clone)]
274pub struct CaStore {
275    dir: PathBuf,
276}
277
278impl CaStore {
279    /// Opens `dir`, creating it `0700` if absent and tightening it if it
280    /// already existed.
281    pub fn open(dir: &Path) -> Result<Self, StorageError> {
282        create_dir(dir)?;
283        Ok(Self {
284            dir: dir.to_owned(),
285        })
286    }
287
288    /// Where the certificate sits, whether or not it has been generated
289    /// yet. This is the file a developer trusts, so it has a path even
290    /// before it has contents.
291    pub fn certificate_path(&self) -> PathBuf {
292        self.dir.join(CA_FILE)
293    }
294
295    /// The authority last saved here, as `(certificate, key)`, both PEM.
296    ///
297    /// A key with no certificate, or the reverse, is `None`: the pair is
298    /// useless by halves, and the caller's answer to both is the same.
299    pub fn load(&self) -> Result<Option<(String, String)>, StorageError> {
300        let cert_path = self.certificate_path();
301        let key_path = self.dir.join(CA_KEY_FILE);
302        if !cert_path.is_file() || !key_path.is_file() {
303            return Ok(None);
304        }
305        Ok(Some((
306            read_string_at(&cert_path)?,
307            read_string_at(&key_path)?,
308        )))
309    }
310
311    /// Persists the authority's certificate and key, both `0600`.
312    ///
313    /// The key is committed first and the certificate last, so a crash
314    /// between them leaves a key with no certificate — which [`Self::load`]
315    /// reads as nothing saved, and the next start replaces.
316    pub fn save(&self, cert_pem: &str, key_pem: &str) -> Result<(), StorageError> {
317        let key = Staged::new(&self.dir.join(CA_KEY_FILE), key_pem.as_bytes())?;
318        let cert = Staged::new(&self.certificate_path(), cert_pem.as_bytes())?;
319        key.commit()?;
320        cert.commit()
321    }
322}
323
324/// Reads a file this crate wrote, naming it if it cannot be read.
325fn read_string_at(path: &Path) -> Result<String, StorageError> {
326    fs::read_to_string(path).map_err(|source| StorageError::Io {
327        path: path.to_owned(),
328        source,
329    })
330}
331
332/// Serialises `value` and writes it `0600` at `path`, replacing whatever was
333/// there in one step.
334fn write_json_at<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), StorageError> {
335    Staged::json(path, value)?.commit()
336}
337
338/// The single path component `zone`'s certificate directory is named after:
339/// the hostname, lowercased, with nothing that could escape the directory it
340/// is joined onto.
341fn zone_directory_component(zone: &str) -> Result<String, StorageError> {
342    let component = zone.trim().to_ascii_lowercase();
343    let usable = !component.is_empty()
344        && component != "."
345        && component != ".."
346        && !component.starts_with('.')
347        && component
348            .chars()
349            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_');
350    if !usable {
351        return Err(StorageError::InvalidZone {
352            zone: zone.to_owned(),
353        });
354    }
355    Ok(component)
356}
357
358/// A file's next contents, written and synced beside its final path but not
359/// yet renamed over it.
360///
361/// The shape `didbot-pds`'s write-ahead log gives its compaction: the
362/// replacement is built whole, `0600`, at a scratch path, and one
363/// `rename(2)` makes it the file. A crash before the rename leaves the old
364/// file as it was, plus a scratch file the next write removes; a crash after
365/// it leaves the new one. The directory is synced after the rename, because
366/// on most filesystems a rename that has not been durably recorded in its
367/// parent can be undone by a power cut that kept the file it pointed at.
368///
369/// Dropped without [`commit`](Self::commit), it removes its scratch file, so
370/// a save that fails partway leaves no private key under a name nothing
371/// reads.
372struct Staged {
373    scratch: PathBuf,
374    path: PathBuf,
375    committed: bool,
376}
377
378impl Staged {
379    /// Writes `contents`, `0600`, to a scratch file beside `path`.
380    fn new(path: &Path, contents: &[u8]) -> Result<Self, StorageError> {
381        let scratch = scratch_path(path);
382        // A scratch file left by a write that died is removed rather than
383        // reused: `create_new` below is what guarantees the mode, and
384        // `rename(2)` carries the source's mode with it, so a leftover at a
385        // loose mode would widen the file it replaced.
386        match fs::remove_file(&scratch) {
387            Ok(()) => {}
388            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
389            Err(source) => return Err(io_error(&scratch, source)),
390        }
391        let mut options = fs::OpenOptions::new();
392        options.write(true).create_new(true);
393        #[cfg(unix)]
394        {
395            use std::os::unix::fs::OpenOptionsExt;
396            options.mode(FILE_MODE);
397        }
398        let mut file = options
399            .open(&scratch)
400            .map_err(|source| io_error(&scratch, source))?;
401        let staged = Self {
402            scratch,
403            path: path.to_owned(),
404            committed: false,
405        };
406        io::Write::write_all(&mut file, contents)
407            .map_err(|source| io_error(&staged.scratch, source))?;
408        file.sync_all()
409            .map_err(|source| io_error(&staged.scratch, source))?;
410        Ok(staged)
411    }
412
413    /// Serialises `value` and stages it beside `path`.
414    fn json<T: serde::Serialize>(path: &Path, value: &T) -> Result<Self, StorageError> {
415        let text = serde_json::to_string_pretty(value).map_err(|source| StorageError::Parse {
416            path: path.to_owned(),
417            source,
418        })?;
419        Self::new(path, text.as_bytes())
420    }
421
422    /// Renames the scratch file over its final path, and makes that durable.
423    fn commit(mut self) -> Result<(), StorageError> {
424        fs::rename(&self.scratch, &self.path).map_err(|source| io_error(&self.scratch, source))?;
425        self.committed = true;
426        sync_dir(self.path.parent().unwrap_or_else(|| Path::new(".")))
427    }
428}
429
430impl Drop for Staged {
431    fn drop(&mut self) {
432        if !self.committed {
433            let _ = fs::remove_file(&self.scratch);
434        }
435    }
436}
437
438/// Where `path`'s replacement is built: `<name>.tmp` in the same directory,
439/// so the rename that finishes it never crosses a filesystem.
440fn scratch_path(path: &Path) -> PathBuf {
441    let mut name = path
442        .file_name()
443        .map(std::ffi::OsStr::to_os_string)
444        .unwrap_or_default();
445    name.push(".tmp");
446    path.with_file_name(name)
447}
448
449/// Syncs a directory entry, so a rename cannot be undone by a power cut.
450fn sync_dir(dir: &Path) -> Result<(), StorageError> {
451    // Not every platform lets a directory be opened as a file. Where it does
452    // not, the rename is as durable as that platform makes it and there is
453    // nothing further to do here.
454    match fs::File::open(dir) {
455        Ok(handle) => handle.sync_all().map_err(|source| io_error(dir, source)),
456        Err(_) => Ok(()),
457    }
458}
459
460fn io_error(path: &Path, source: io::Error) -> StorageError {
461    StorageError::Io {
462        path: path.to_owned(),
463        source,
464    }
465}
466
467/// Creates `dir` `0700` if absent, and tightens it to `0700` if it was
468/// already there — the fix `didbot-pds`'s write-ahead log made for the same
469/// reason, applied here because this crate cannot call that one.
470fn create_dir(dir: &Path) -> Result<(), StorageError> {
471    if dir.is_dir() {
472        #[cfg(unix)]
473        {
474            use std::os::unix::fs::PermissionsExt;
475            fs::set_permissions(dir, fs::Permissions::from_mode(DIR_MODE)).map_err(|source| {
476                StorageError::Io {
477                    path: dir.to_owned(),
478                    source,
479                }
480            })?;
481        }
482        return Ok(());
483    }
484    let mut builder = fs::DirBuilder::new();
485    builder.recursive(true);
486    #[cfg(unix)]
487    {
488        use std::os::unix::fs::DirBuilderExt;
489        builder.mode(DIR_MODE);
490    }
491    builder.create(dir).map_err(|source| StorageError::Io {
492        path: dir.to_owned(),
493        source,
494    })
495}
496
497#[cfg(all(test, unix))]
498mod tests {
499    use super::*;
500    use std::os::unix::fs::PermissionsExt;
501
502    #[test]
503    fn creates_the_directory_0700() {
504        let tmp = tempdir();
505        let store = CertStore::open(tmp.path()).unwrap();
506        let mode = fs::metadata(tmp.path().join("tls"))
507            .unwrap()
508            .permissions()
509            .mode()
510            & 0o777;
511        assert_eq!(mode, DIR_MODE);
512        drop(store);
513    }
514
515    #[test]
516    fn tightens_a_directory_that_already_existed_wide_open() {
517        let tmp = tempdir();
518        let dir = tmp.path().join("tls");
519        fs::create_dir_all(&dir).unwrap();
520        fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
521
522        CertStore::open(tmp.path()).unwrap();
523
524        let mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
525        assert_eq!(mode, DIR_MODE);
526    }
527
528    #[test]
529    fn certificate_files_are_0600() {
530        let tmp = tempdir();
531        let store = CertStore::open(tmp.path()).unwrap();
532        let meta = CertMeta {
533            directory_url: "https://example.invalid/directory".into(),
534            issued_at: time::OffsetDateTime::now_utc(),
535        };
536        store.save_certificate("cert", "key", &meta).unwrap();
537
538        for name in [CERT_FILE, KEY_FILE, META_FILE] {
539            let mode = fs::metadata(tmp.path().join("tls").join(name))
540                .unwrap()
541                .permissions()
542                .mode()
543                & 0o777;
544            assert_eq!(mode, FILE_MODE, "{name} should be 0600");
545        }
546    }
547
548    /// A key file that was already on disk at a loose mode — restored from a
549    /// backup, copied into place by hand, or written by a build from before
550    /// the mode was set — is replaced by the next write with one created
551    /// `0600`.
552    #[test]
553    fn a_key_file_that_was_already_loose_is_tightened_by_the_next_write() {
554        let tmp = tempdir();
555        let store = CertStore::open(tmp.path()).unwrap();
556        let meta = CertMeta {
557            directory_url: "https://example.invalid/directory".into(),
558            issued_at: time::OffsetDateTime::now_utc(),
559        };
560        store.save_certificate("cert", "key", &meta).unwrap();
561
562        let key = tmp.path().join("tls").join(KEY_FILE);
563        fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).unwrap();
564
565        store
566            .save_certificate("cert", "a renewed key", &meta)
567            .unwrap();
568
569        let mode = fs::metadata(&key).unwrap().permissions().mode() & 0o777;
570        assert_eq!(
571            mode, FILE_MODE,
572            "the private key is mode {mode:04o}; every local account can read it"
573        );
574    }
575
576    #[test]
577    fn round_trips_a_saved_certificate() {
578        let tmp = tempdir();
579        let store = CertStore::open(tmp.path()).unwrap();
580        let meta = CertMeta {
581            directory_url: "https://example.invalid/directory".into(),
582            issued_at: time::OffsetDateTime::now_utc(),
583        };
584        store
585            .save_certificate("the-cert", "the-key", &meta)
586            .unwrap();
587
588        let (cert, key, read_meta) = store.certificate().unwrap().unwrap();
589        assert_eq!(cert, "the-cert");
590        assert_eq!(key, "the-key");
591        assert_eq!(read_meta.directory_url, meta.directory_url);
592    }
593
594    #[test]
595    fn no_certificate_is_none_not_an_error() {
596        let tmp = tempdir();
597        let store = CertStore::open(tmp.path()).unwrap();
598        assert!(store.certificate().unwrap().is_none());
599    }
600
601    /// A write that died before its rename leaves a scratch file beside the
602    /// real one, at whatever mode and length it had reached. Reads never see
603    /// it, and the next save replaces it rather than renaming it into place.
604    #[test]
605    fn a_scratch_file_left_by_a_dead_write_neither_hides_nor_replaces_the_pair() {
606        let tmp = tempdir();
607        let store = CertStore::open(tmp.path()).unwrap();
608        let meta = CertMeta {
609            directory_url: "https://example.invalid/directory".into(),
610            issued_at: time::OffsetDateTime::now_utc(),
611        };
612        store
613            .save_certificate("the-cert", "the-key", &meta)
614            .unwrap();
615
616        let key = tmp.path().join("tls").join(KEY_FILE);
617        let leftover = scratch_path(&key);
618        fs::write(&leftover, "half of a k").unwrap();
619        fs::set_permissions(&leftover, fs::Permissions::from_mode(0o644)).unwrap();
620
621        let (cert, key_pem, _) = store.certificate().unwrap().unwrap();
622        assert_eq!((cert.as_str(), key_pem.as_str()), ("the-cert", "the-key"));
623
624        store
625            .save_certificate("the-cert", "a renewed key", &meta)
626            .unwrap();
627
628        let (_, key_pem, _) = store.certificate().unwrap().unwrap();
629        assert_eq!(key_pem, "a renewed key");
630        let mode = fs::metadata(&key).unwrap().permissions().mode() & 0o777;
631        assert_eq!(
632            mode, FILE_MODE,
633            "the leftover's mode {mode:04o} travelled with the rename"
634        );
635        assert!(
636            !leftover.exists(),
637            "a committed save leaves no scratch file"
638        );
639    }
640
641    /// A save that cannot finish must not touch the files a restart boots
642    /// from. Staging the metadata is made to fail here — its scratch path is
643    /// taken by a directory, which cannot be removed as a file or created as
644    /// one — after the key and certificate were both staged, and the pair on
645    /// disk is the one from before.
646    #[test]
647    fn a_save_that_fails_leaves_the_previous_pair_untouched() {
648        let tmp = tempdir();
649        let store = CertStore::open(tmp.path()).unwrap();
650        let meta = CertMeta {
651            directory_url: "https://example.invalid/directory".into(),
652            issued_at: time::OffsetDateTime::now_utc(),
653        };
654        store
655            .save_certificate("the-cert", "the-key", &meta)
656            .unwrap();
657
658        let dir = tmp.path().join("tls");
659        fs::create_dir(scratch_path(&dir.join(META_FILE))).unwrap();
660
661        store
662            .save_certificate("a renewed cert", "a renewed key", &meta)
663            .unwrap_err();
664
665        let (cert, key, _) = store.certificate().unwrap().unwrap();
666        assert_eq!((cert.as_str(), key.as_str()), ("the-cert", "the-key"));
667        for name in [CERT_FILE, KEY_FILE] {
668            assert!(
669                !scratch_path(&dir.join(name)).exists(),
670                "{name}'s scratch file outlived the save that failed"
671            );
672        }
673    }
674
675    fn tempdir() -> TempDir {
676        TempDir::new()
677    }
678
679    /// A minimal self-cleaning temp directory, so this crate does not take a
680    /// `tempfile` dependency just for these tests.
681    struct TempDir(PathBuf);
682
683    impl TempDir {
684        fn new() -> Self {
685            let mut dir = std::env::temp_dir();
686            let unique = format!(
687                "didbot-tls-test-{}-{}",
688                std::process::id(),
689                std::time::SystemTime::now()
690                    .duration_since(std::time::UNIX_EPOCH)
691                    .unwrap()
692                    .as_nanos()
693            );
694            dir.push(unique);
695            fs::create_dir_all(&dir).unwrap();
696            Self(dir)
697        }
698
699        fn path(&self) -> &Path {
700            &self.0
701        }
702    }
703
704    impl Drop for TempDir {
705        fn drop(&mut self) {
706            let _ = fs::remove_dir_all(&self.0);
707        }
708    }
709}