Skip to main content

featherbit/acme/storage/
fs.rs

1//! Filesystem `CertStorage`.
2//!
3//! Layout under `dir`: `account.json`, `certs/<cert_id>/{chain.pem,key.pem,meta.json}`,
4//! `challenges/<domain>` (`{key_auth, expires_at}`), `leases/<cert_id>`
5//! (`{owner, expires_at}`). Every write is temp-file + rename; secret files are
6//! `0600` on unix. Expired challenge/lease files read as absent.
7
8use std::hash::{Hash, Hasher};
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::Duration;
12
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15use tracing::warn;
16
17use super::CertStorage;
18use crate::acme::{now_unix, AcmeError, CertId, StoredCert};
19
20pub struct FsCertStorage {
21    dir: PathBuf,
22}
23
24#[derive(Serialize, Deserialize)]
25struct ChallengeFile {
26    key_auth: String,
27    expires_at: i64,
28}
29
30#[derive(Serialize, Deserialize)]
31struct LeaseFile {
32    owner: String,
33    expires_at: i64,
34}
35
36#[derive(Serialize, Deserialize)]
37struct MetaFile {
38    issued_at: i64,
39}
40
41fn io_err(what: &str, path: &Path, e: std::io::Error) -> AcmeError {
42    AcmeError::Storage(format!("{what} {}: {e}", path.display()))
43}
44
45/// Writes `bytes` to `path` atomically (temp file beside it, then rename).
46/// `secret` files get `0600` on unix before the rename.
47fn atomic_write(path: &Path, bytes: &[u8], secret: bool) -> Result<(), AcmeError> {
48    if let Some(parent) = path.parent() {
49        std::fs::create_dir_all(parent).map_err(|e| io_err("create dir", parent, e))?;
50    }
51    let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
52    tmp_name.push(".tmp");
53    let tmp = path.with_file_name(tmp_name);
54    std::fs::write(&tmp, bytes).map_err(|e| io_err("write", &tmp, e))?;
55    #[cfg(unix)]
56    if secret {
57        use std::os::unix::fs::PermissionsExt;
58        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))
59            .map_err(|e| io_err("chmod", &tmp, e))?;
60    }
61    #[cfg(not(unix))]
62    let _ = secret;
63    if let Err(first) = std::fs::rename(&tmp, path) {
64        // `rename` can still fail if `path` is held open by another handle
65        // (e.g. a transient sharing violation on Windows, or another process
66        // reading it) even though it uses replace-existing semantics; fall
67        // back to an explicit remove-then-rename.
68        std::fs::remove_file(path).map_err(|e| io_err("replace", path, e))?;
69        std::fs::rename(&tmp, path).map_err(|_| io_err("rename", path, first))?;
70    }
71    Ok(())
72}
73
74fn read_opt(path: &Path) -> Result<Option<Vec<u8>>, AcmeError> {
75    match std::fs::read(path) {
76        Ok(b) => Ok(Some(b)),
77        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
78        Err(e) => Err(io_err("read", path, e)),
79    }
80}
81
82fn remove_opt(path: &Path) -> Result<(), AcmeError> {
83    match std::fs::remove_file(path) {
84        Ok(()) => Ok(()),
85        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
86        Err(e) => Err(io_err("remove", path, e)),
87    }
88}
89
90fn expires_at(ttl: Duration) -> i64 {
91    now_unix() + ttl.as_secs().max(1) as i64
92}
93
94/// Per-process, monotonically increasing disambiguator folded into lease temp
95/// file names, so two calls in the same process racing in the same nanosecond
96/// still can't collide on the temp path.
97static LEASE_TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
98
99/// A temp path beside `lease_path` that is unique to this call: no two
100/// concurrent callers (same process or different) land on the same name, so
101/// each writes its own temp file undisturbed before attempting to publish it.
102fn lease_tmp_path(lease_path: &Path, owner: &str) -> PathBuf {
103    let mut hasher = std::collections::hash_map::DefaultHasher::new();
104    owner.hash(&mut hasher);
105    std::process::id().hash(&mut hasher);
106    std::thread::current().id().hash(&mut hasher);
107    LEASE_TMP_COUNTER
108        .fetch_add(1, Ordering::Relaxed)
109        .hash(&mut hasher);
110    let nanos = std::time::SystemTime::now()
111        .duration_since(std::time::UNIX_EPOCH)
112        .unwrap_or_default()
113        .as_nanos();
114    nanos.hash(&mut hasher);
115    let mut name = lease_path.file_name().unwrap_or_default().to_os_string();
116    name.push(format!(".{:x}.tmp", hasher.finish()));
117    lease_path.with_file_name(name)
118}
119
120impl FsCertStorage {
121    pub fn new(dir: impl Into<PathBuf>) -> Self {
122        Self { dir: dir.into() }
123    }
124
125    fn cert_dir(&self, id: &CertId) -> PathBuf {
126        self.dir.join("certs").join(id.as_str())
127    }
128    fn challenge_path(&self, domain: &str) -> PathBuf {
129        self.dir.join("challenges").join(domain)
130    }
131    fn lease_path(&self, id: &CertId) -> PathBuf {
132        self.dir.join("leases").join(id.as_str())
133    }
134
135    fn read_lease(&self, id: &CertId) -> Result<Option<LeaseFile>, AcmeError> {
136        let Some(bytes) = read_opt(&self.lease_path(id))? else {
137            return Ok(None);
138        };
139        let lease: LeaseFile = match serde_json::from_slice(&bytes) {
140            Ok(l) => l,
141            Err(_) => return Ok(None), // a corrupt lease is no lease
142        };
143        if lease.expires_at <= now_unix() {
144            return Ok(None);
145        }
146        Ok(Some(lease))
147    }
148
149    fn write_lease(&self, id: &CertId, owner: &str, ttl: Duration) -> Result<(), AcmeError> {
150        let lease = LeaseFile {
151            owner: owner.to_string(),
152            expires_at: expires_at(ttl),
153        };
154        atomic_write(
155            &self.lease_path(id),
156            &serde_json::to_vec(&lease).unwrap(),
157            false,
158        )
159    }
160
161    /// Creates the lease file iff it does not already exist, and never
162    /// exposes a partially-written lease at `lease_path`: the full JSON is
163    /// written to a private, unique temp file first, then published with
164    /// `hard_link`, which — unlike creating the destination directly — fails
165    /// atomically with `AlreadyExists` when the destination is already there
166    /// (POSIX `link(2)`, NTFS `CreateHardLink`) without ever making an empty
167    /// or partial file visible at `lease_path`. The OS guarantees exactly one
168    /// concurrent caller's `hard_link` wins.
169    ///
170    /// Falls back to the old `create_new` + `write_all` path only when
171    /// `hard_link` itself errors with something other than `AlreadyExists`
172    /// (e.g. unsupported on some network filesystem). That fallback has a
173    /// reduced guarantee: the destination is briefly visible empty before the
174    /// content lands, since content can no longer be written before the path
175    /// is public.
176    fn create_lease_file(&self, id: &CertId, owner: &str, ttl: Duration) -> std::io::Result<()> {
177        let path = self.lease_path(id);
178        if let Some(parent) = path.parent() {
179            std::fs::create_dir_all(parent)?;
180        }
181        let lease = LeaseFile {
182            owner: owner.to_string(),
183            expires_at: expires_at(ttl),
184        };
185        let bytes = serde_json::to_vec(&lease).unwrap();
186
187        let tmp = lease_tmp_path(&path, owner);
188        std::fs::write(&tmp, &bytes)?;
189
190        let result = match std::fs::hard_link(&tmp, &path) {
191            Ok(()) => Ok(()),
192            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
193            Err(_) => std::fs::OpenOptions::new()
194                .write(true)
195                .create_new(true)
196                .open(&path)
197                .and_then(|mut f| {
198                    use std::io::Write;
199                    f.write_all(&bytes)
200                }),
201        };
202        let _ = std::fs::remove_file(&tmp);
203        result
204    }
205
206    /// Takes over a lease that read as expired (or corrupt), atomically with
207    /// respect to every other contender.
208    ///
209    /// The naive `remove_opt` + `create_lease_file` lets two contenders both
210    /// win (A removes and creates; B, already past its own read, removes A's
211    /// *fresh* lease and creates its own). Moving the stale file aside with
212    /// `rename` and creating in its place does not fix it either: between the
213    /// rename and the create the lease path is **empty**, and a contender that
214    /// reaches its own exclusive create in that window wins alongside the one
215    /// doing the takeover.
216    ///
217    /// So the takeover holds a separate mutex — an exclusively created
218    /// `<lease>.takeover` marker, the one primitive the filesystem does give
219    /// atomically — and installs the new lease by `rename`-over (via
220    /// [`atomic_write`]), which replaces the file without the path ever being
221    /// empty. Contenders that lose the marker concede for this round and
222    /// re-evaluate on their next pass.
223    fn take_over_expired_lease(
224        &self,
225        id: &CertId,
226        owner: &str,
227        ttl: Duration,
228    ) -> Result<bool, AcmeError> {
229        let Some(_guard) = TakeoverGuard::acquire(&self.lease_path(id), owner)? else {
230            return Ok(false);
231        };
232        // Re-read under the mutex: the previous holder of the marker may have
233        // just installed a live lease of its own.
234        match self.read_lease(id)? {
235            Some(l) if l.owner != owner => Ok(false),
236            _ => {
237                self.write_lease(id, owner, ttl)?;
238                Ok(true)
239            }
240        }
241    }
242}
243
244/// How long a `<lease>.takeover` marker may exist before it is assumed to
245/// belong to a process that died mid-takeover. The marker is held for a couple
246/// of filesystem operations, so anything older than this is debris.
247const TAKEOVER_MARKER_STALE_SECS: u64 = 60;
248
249/// RAII holder of the exclusive `<lease>.takeover` marker; removes it on drop.
250struct TakeoverGuard(PathBuf);
251
252impl TakeoverGuard {
253    /// `Ok(Some(guard))` when this caller now holds the marker, `Ok(None)`
254    /// when another contender does.
255    fn acquire(lease_path: &Path, owner: &str) -> Result<Option<Self>, AcmeError> {
256        let mut name = lease_path.file_name().unwrap_or_default().to_os_string();
257        name.push(".takeover");
258        let marker = lease_path.with_file_name(name);
259        if let Some(parent) = marker.parent() {
260            std::fs::create_dir_all(parent).map_err(|e| io_err("create dir", parent, e))?;
261        }
262        match create_marker(&marker, owner) {
263            Ok(()) => return Ok(Some(Self(marker))),
264            Err(e) if e.kind() != std::io::ErrorKind::AlreadyExists => {
265                return Err(io_err("create takeover marker", &marker, e))
266            }
267            Err(_) => {}
268        }
269        // Crash recovery: a marker nobody could still be holding is debris.
270        // Removing it can, in principle, race another recoverer — it is the
271        // one path where two takeovers could proceed, and it only opens after
272        // a process died inside a microsecond-wide window.
273        let stale = std::fs::metadata(&marker)
274            .and_then(|m| m.modified())
275            .map(|t| {
276                t.elapsed()
277                    .map(|d| d.as_secs() >= TAKEOVER_MARKER_STALE_SECS)
278                    .unwrap_or(false)
279            })
280            .unwrap_or(false);
281        if !stale {
282            return Ok(None);
283        }
284        warn!(
285            "acme: removing a stale lease-takeover marker at {}",
286            marker.display()
287        );
288        let _ = std::fs::remove_file(&marker);
289        match create_marker(&marker, owner) {
290            Ok(()) => Ok(Some(Self(marker))),
291            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
292            Err(e) => Err(io_err("create takeover marker", &marker, e)),
293        }
294    }
295}
296
297impl Drop for TakeoverGuard {
298    fn drop(&mut self) {
299        let _ = std::fs::remove_file(&self.0);
300    }
301}
302
303/// Exclusive create; the content is only ever read by a human debugging.
304fn create_marker(marker: &Path, owner: &str) -> std::io::Result<()> {
305    use std::io::Write;
306    std::fs::OpenOptions::new()
307        .write(true)
308        .create_new(true)
309        .open(marker)
310        .and_then(|mut f| f.write_all(owner.as_bytes()))
311}
312
313#[async_trait]
314impl CertStorage for FsCertStorage {
315    fn label(&self) -> String {
316        "filesystem".to_string()
317    }
318
319    async fn load_account(&self) -> Result<Option<Vec<u8>>, AcmeError> {
320        read_opt(&self.dir.join("account.json"))
321    }
322
323    async fn save_account(&self, creds: &[u8]) -> Result<(), AcmeError> {
324        atomic_write(&self.dir.join("account.json"), creds, true)
325    }
326
327    async fn load_cert(&self, id: &CertId) -> Result<Option<StoredCert>, AcmeError> {
328        let dir = self.cert_dir(id);
329        let (Some(chain), Some(key)) = (
330            read_opt(&dir.join("chain.pem"))?,
331            read_opt(&dir.join("key.pem"))?,
332        ) else {
333            return Ok(None);
334        };
335        let issued_at = read_opt(&dir.join("meta.json"))?
336            .and_then(|b| serde_json::from_slice::<MetaFile>(&b).ok())
337            .map(|m| m.issued_at)
338            .unwrap_or(0);
339        Ok(Some(StoredCert {
340            chain_pem: String::from_utf8_lossy(&chain).into_owned(),
341            key_pem: String::from_utf8_lossy(&key).into_owned(),
342            issued_at,
343        }))
344    }
345
346    async fn save_cert(&self, id: &CertId, cert: &StoredCert) -> Result<(), AcmeError> {
347        let dir = self.cert_dir(id);
348        // Key first, chain last: a reader that sees a chain always finds its key.
349        atomic_write(&dir.join("key.pem"), cert.key_pem.as_bytes(), true)?;
350        atomic_write(
351            &dir.join("meta.json"),
352            &serde_json::to_vec(&MetaFile {
353                issued_at: cert.issued_at,
354            })
355            .unwrap(),
356            false,
357        )?;
358        atomic_write(&dir.join("chain.pem"), cert.chain_pem.as_bytes(), false)
359    }
360
361    async fn put_challenge(
362        &self,
363        domain: &str,
364        key_auth: &str,
365        ttl: Duration,
366    ) -> Result<(), AcmeError> {
367        let file = ChallengeFile {
368            key_auth: key_auth.to_string(),
369            expires_at: expires_at(ttl),
370        };
371        atomic_write(
372            &self.challenge_path(domain),
373            &serde_json::to_vec(&file).unwrap(),
374            false,
375        )
376    }
377
378    async fn get_challenge(&self, domain: &str) -> Result<Option<String>, AcmeError> {
379        let Some(bytes) = read_opt(&self.challenge_path(domain))? else {
380            return Ok(None);
381        };
382        let file: ChallengeFile = match serde_json::from_slice(&bytes) {
383            Ok(f) => f,
384            Err(_) => return Ok(None),
385        };
386        if file.expires_at <= now_unix() {
387            return Ok(None);
388        }
389        Ok(Some(file.key_auth))
390    }
391
392    async fn remove_challenge(&self, domain: &str) -> Result<(), AcmeError> {
393        remove_opt(&self.challenge_path(domain))
394    }
395
396    async fn try_acquire_lease(
397        &self,
398        id: &CertId,
399        owner: &str,
400        ttl: Duration,
401    ) -> Result<bool, AcmeError> {
402        match self.create_lease_file(id, owner, ttl) {
403            Ok(()) => Ok(true),
404            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
405                match self.read_lease(id)? {
406                    // Live lease held by someone else: no win.
407                    Some(l) if l.owner != owner => Ok(false),
408                    // Already ours (fresh or stale-but-not-expired): refresh in place.
409                    Some(_) => {
410                        self.write_lease(id, owner, ttl)?;
411                        Ok(true)
412                    }
413                    // Expired or corrupt: take it over under the takeover
414                    // mutex (see `take_over_expired_lease`).
415                    None => self.take_over_expired_lease(id, owner, ttl),
416                }
417            }
418            Err(e) => Err(io_err("create lease", &self.lease_path(id), e)),
419        }
420    }
421
422    async fn renew_lease(
423        &self,
424        id: &CertId,
425        owner: &str,
426        ttl: Duration,
427    ) -> Result<bool, AcmeError> {
428        match self.read_lease(id)? {
429            Some(l) if l.owner == owner => {
430                self.write_lease(id, owner, ttl)?;
431                Ok(true)
432            }
433            _ => Ok(false),
434        }
435    }
436
437    async fn release_lease(&self, id: &CertId, owner: &str) -> Result<(), AcmeError> {
438        match self.read_lease(id)? {
439            Some(l) if l.owner == owner => remove_opt(&self.lease_path(id)),
440            _ => Ok(()),
441        }
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use std::sync::Arc;
449
450    fn temp_dir(tag: &str) -> std::path::PathBuf {
451        let d = std::env::temp_dir().join(format!("fb_acme_{}_{}", tag, std::process::id()));
452        let _ = std::fs::remove_dir_all(&d);
453        d
454    }
455
456    #[tokio::test]
457    async fn fs_storage_satisfies_contract() {
458        let dir = temp_dir("contract");
459        let storage = Arc::new(FsCertStorage::new(dir.clone()));
460        assert_eq!(storage.label(), "filesystem");
461        crate::acme::storage::contract::run_all(storage).await;
462        let _ = std::fs::remove_dir_all(dir);
463    }
464
465    #[tokio::test]
466    async fn fs_layout_and_atomic_write() {
467        let dir = temp_dir("layout");
468        let storage = FsCertStorage::new(dir.clone());
469        let (id, _) = CertId::from_domains(&["a.example.com".into()]).unwrap();
470        storage
471            .save_cert(
472                &id,
473                &StoredCert {
474                    chain_pem: "C".into(),
475                    key_pem: "K".into(),
476                    issued_at: 1,
477                },
478            )
479            .await
480            .unwrap();
481        let cert_dir = dir.join("certs").join("a.example.com");
482        assert!(cert_dir.join("chain.pem").exists());
483        assert!(cert_dir.join("key.pem").exists());
484        assert!(cert_dir.join("meta.json").exists());
485        assert!(!cert_dir.join("key.pem.tmp").exists());
486        #[cfg(unix)]
487        {
488            use std::os::unix::fs::PermissionsExt;
489            let mode = std::fs::metadata(cert_dir.join("key.pem"))
490                .unwrap()
491                .permissions()
492                .mode();
493            assert_eq!(mode & 0o777, 0o600);
494        }
495        let _ = std::fs::remove_dir_all(dir);
496    }
497
498    /// Genuine OS-thread concurrency (not tokio task interleaving, which
499    /// never preempts between the non-`.await`ing filesystem calls in
500    /// `try_acquire_lease`): each contender gets its own real thread with its
501    /// own single-threaded runtime, all released at the same instant by a
502    /// `Barrier`, racing `try_acquire_lease` on a shared `FsCertStorage` dir.
503    /// Repeated over many fresh `CertId`s so a rare race isn't masked by one
504    /// lucky round.
505    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
506    async fn try_acquire_lease_is_atomic_under_concurrency() {
507        const CONTENDERS: usize = 8;
508        const ROUNDS: usize = 20;
509
510        let dir = temp_dir("lease_race");
511        std::fs::create_dir_all(&dir).unwrap();
512        let ttl = Duration::from_secs(30);
513
514        for round in 0..ROUNDS {
515            let (id, _) = CertId::from_domains(&[format!("race-{round}.example.com")]).unwrap();
516            let barrier = Arc::new(std::sync::Barrier::new(CONTENDERS));
517
518            let handles: Vec<_> = (0..CONTENDERS)
519                .map(|i| {
520                    let barrier = barrier.clone();
521                    let dir = dir.clone();
522                    let id = id.clone();
523                    std::thread::spawn(move || {
524                        let storage = FsCertStorage::new(dir);
525                        let rt = tokio::runtime::Builder::new_current_thread()
526                            .enable_all()
527                            .build()
528                            .unwrap();
529                        barrier.wait();
530                        rt.block_on(storage.try_acquire_lease(&id, &format!("owner-{i}"), ttl))
531                            .unwrap()
532                    })
533                })
534                .collect();
535
536            let wins: usize = handles
537                .into_iter()
538                .map(|h| h.join().unwrap())
539                .filter(|w| *w)
540                .count();
541            assert_eq!(
542                wins, 1,
543                "round {round}: exactly one concurrent acquirer should win the lease"
544            );
545        }
546
547        let _ = std::fs::remove_dir_all(dir);
548    }
549
550    /// The other half of the race: the lease *file already exists* but has
551    /// expired, so every contender takes the takeover path.
552    ///
553    /// Two earlier implementations both let more than one contender win here,
554    /// and this test caught each of them. Remove-then-create: one removes and
555    /// creates, a second removes that fresh lease and creates its own. A bare
556    /// rename-aside compare-and-swap: the rename succeeds against *whatever*
557    /// is at the path, so a late contender moves the winner's fresh lease
558    /// aside and takes over from it — and even with a content check, the path
559    /// is empty between the rename and the create, so any contender reaching
560    /// its own exclusive create in that window wins alongside the taker.
561    ///
562    /// What admits exactly one is `take_over_expired_lease`: the exclusively
563    /// created `<lease>.takeover` marker serializes takeovers, and the new
564    /// lease is installed with a replacing `rename` so the path is never
565    /// empty.
566    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
567    async fn expired_lease_takeover_is_atomic_under_concurrency() {
568        const CONTENDERS: usize = 8;
569        const ROUNDS: usize = 10;
570
571        let dir = temp_dir("lease_takeover_race");
572        std::fs::create_dir_all(&dir).unwrap();
573        let ttl = Duration::from_secs(30);
574
575        for round in 0..ROUNDS {
576            let (id, _) = CertId::from_domains(&[format!("stale-{round}.example.com")]).unwrap();
577            // Pre-acquire with the shortest TTL the backend keeps (1 s), then
578            // sleep past it so the file is present but dead for everyone.
579            let seed = FsCertStorage::new(dir.clone());
580            assert!(seed
581                .try_acquire_lease(&id, "previous-holder", Duration::from_secs(1))
582                .await
583                .unwrap());
584            tokio::time::sleep(Duration::from_millis(1_500)).await;
585            assert!(seed.lease_path(&id).exists(), "the stale lease file stays");
586
587            let barrier = Arc::new(std::sync::Barrier::new(CONTENDERS));
588            let handles: Vec<_> = (0..CONTENDERS)
589                .map(|i| {
590                    let barrier = barrier.clone();
591                    let dir = dir.clone();
592                    let id = id.clone();
593                    std::thread::spawn(move || {
594                        let storage = FsCertStorage::new(dir);
595                        let rt = tokio::runtime::Builder::new_current_thread()
596                            .enable_all()
597                            .build()
598                            .unwrap();
599                        barrier.wait();
600                        rt.block_on(storage.try_acquire_lease(&id, &format!("owner-{i}"), ttl))
601                            .unwrap()
602                    })
603                })
604                .collect();
605
606            let wins: usize = handles
607                .into_iter()
608                .map(|h| h.join().unwrap())
609                .filter(|w| *w)
610                .count();
611            assert_eq!(
612                wins, 1,
613                "round {round}: exactly one contender should take over an expired lease"
614            );
615        }
616
617        let _ = std::fs::remove_dir_all(dir);
618    }
619}