use std::io;
use std::path::{Path, PathBuf};
pub use crate::custom_domain::StoreFuture;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CertId(pub String);
impl CertId {
#[must_use]
pub fn from_domains(domains: &[String]) -> Self {
use sha2::{Digest as _, Sha256};
let mut sorted: Vec<&str> = domains.iter().map(String::as_str).collect();
sorted.sort_unstable();
sorted.dedup();
let mut hasher = Sha256::new();
for domain in sorted {
hasher.update(domain.as_bytes());
hasher.update(b"\0");
}
let digest = hasher.finalize();
let mut out = String::with_capacity(32);
for byte in &digest[..16] {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
Self(out)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredCert {
pub chain_pem: String,
pub key_pem: String,
}
pub trait AcmeStore: Send + Sync {
fn load_account(&self) -> StoreFuture<'_, io::Result<Option<Vec<u8>>>>;
fn save_account<'a>(&'a self, data: &'a [u8]) -> StoreFuture<'a, io::Result<()>>;
fn load_cert<'a>(&'a self, id: &'a CertId) -> StoreFuture<'a, io::Result<Option<StoredCert>>>;
fn save_cert<'a>(
&'a self,
id: &'a CertId,
cert: &'a StoredCert,
) -> StoreFuture<'a, io::Result<()>>;
fn delete_cert<'a>(&'a self, _id: &'a CertId) -> StoreFuture<'a, io::Result<()>> {
Box::pin(async { Ok(()) })
}
}
#[derive(Debug, Clone)]
pub struct FsAcmeStore {
dir: PathBuf,
directory_label: String,
}
impl FsAcmeStore {
#[must_use]
pub fn new(dir: impl Into<PathBuf>, directory_label: impl Into<String>) -> Self {
Self {
dir: dir.into(),
directory_label: directory_label.into(),
}
}
fn cert_dir(&self) -> PathBuf {
self.dir.join(&self.directory_label)
}
fn account_path(&self) -> PathBuf {
self.cert_dir().join("account.json")
}
fn chain_path(&self, id: &CertId) -> PathBuf {
self.cert_dir().join(format!("{}.chain.pem", id.as_str()))
}
fn key_path(&self, id: &CertId) -> PathBuf {
self.cert_dir().join(format!("{}.key.pem", id.as_str()))
}
#[must_use]
pub fn find_cert_for_domains(&self, domains: &[String]) -> Option<(PathBuf, PathBuf)> {
let id = CertId::from_domains(domains);
let chain = self.chain_path(&id);
let key = self.key_path(&id);
if chain.exists() && key.exists() {
Some((chain, key))
} else {
None
}
}
pub fn list_certs(&self) -> io::Result<Vec<(CertId, PathBuf, PathBuf)>> {
let dir = self.cert_dir();
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut ids = std::collections::BTreeSet::new();
for entry in entries {
let name = entry?.file_name();
if let Some(id) = name.to_string_lossy().strip_suffix(".chain.pem") {
ids.insert(id.to_owned());
}
}
Ok(ids
.into_iter()
.filter_map(|id| {
let id = CertId(id);
let chain = self.chain_path(&id);
let key = self.key_path(&id);
key.is_file().then_some((id, chain, key))
})
.collect())
}
}
impl AcmeStore for FsAcmeStore {
fn load_account(&self) -> StoreFuture<'_, io::Result<Option<Vec<u8>>>> {
let path = self.account_path();
Box::pin(async move { read_optional(&path).await })
}
fn save_account<'a>(&'a self, data: &'a [u8]) -> StoreFuture<'a, io::Result<()>> {
let dir = self.cert_dir();
let path = self.account_path();
let data = data.to_vec();
Box::pin(async move {
ensure_dir(&dir).await?;
write_owner_only(&path, &data).await
})
}
fn load_cert<'a>(&'a self, id: &'a CertId) -> StoreFuture<'a, io::Result<Option<StoredCert>>> {
let chain_path = self.chain_path(id);
let key_path = self.key_path(id);
Box::pin(async move {
let (Some(chain), Some(key)) = (
read_optional(&chain_path).await?,
read_optional(&key_path).await?,
) else {
return Ok(None);
};
Ok(Some(StoredCert {
chain_pem: String::from_utf8(chain)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
key_pem: String::from_utf8(key)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
}))
})
}
fn save_cert<'a>(
&'a self,
id: &'a CertId,
cert: &'a StoredCert,
) -> StoreFuture<'a, io::Result<()>> {
let dir = self.cert_dir();
let chain_path = self.chain_path(id);
let key_path = self.key_path(id);
let chain = cert.chain_pem.clone().into_bytes();
let key = cert.key_pem.clone().into_bytes();
Box::pin(async move {
ensure_dir(&dir).await?;
let chain_tmp = stage_owner_only(&chain_path, &chain).await?;
let key_tmp = stage_owner_only(&key_path, &key).await?;
publish_staged(chain_tmp, &chain_path).await?;
publish_staged(key_tmp, &key_path).await
})
}
fn delete_cert<'a>(&'a self, id: &'a CertId) -> StoreFuture<'a, io::Result<()>> {
let chain_path = self.chain_path(id);
let key_path = self.key_path(id);
Box::pin(async move {
remove_optional(&key_path).await?;
remove_optional(&chain_path).await
})
}
}
async fn remove_optional(path: &Path) -> io::Result<()> {
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
async fn read_optional(path: &Path) -> io::Result<Option<Vec<u8>>> {
match tokio::fs::read(path).await {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
async fn ensure_dir(dir: &Path) -> io::Result<()> {
let dir = dir.to_path_buf();
blocking(move || crate::fs_atomic::ensure_owner_only_dir(&dir)).await
}
async fn write_owner_only(path: &Path, data: &[u8]) -> io::Result<()> {
let path = path.to_path_buf();
let data = data.to_vec();
blocking(move || crate::fs_atomic::write_owner_only(&path, &data)).await
}
async fn stage_owner_only(path: &Path, data: &[u8]) -> io::Result<crate::fs_atomic::StagedFile> {
let path = path.to_path_buf();
let data = data.to_vec();
blocking(move || crate::fs_atomic::stage_owner_only(&path, &data)).await
}
async fn publish_staged(staged: crate::fs_atomic::StagedFile, path: &Path) -> io::Result<()> {
let path = path.to_path_buf();
blocking(move || crate::fs_atomic::publish_staged(staged, &path)).await
}
async fn blocking<T: Send + 'static>(
f: impl FnOnce() -> io::Result<T> + Send + 'static,
) -> io::Result<T> {
crate::fs_atomic::blocking(f).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cert_id_is_order_independent_and_deduped() {
let a = CertId::from_domains(&["b.example.com".into(), "a.example.com".into()]);
let b = CertId::from_domains(&["a.example.com".into(), "b.example.com".into()]);
let c = CertId::from_domains(&[
"a.example.com".into(),
"b.example.com".into(),
"a.example.com".into(),
]);
assert_eq!(a, b);
assert_eq!(a, c);
assert_eq!(a.as_str().len(), 32);
}
#[test]
fn cert_id_distinct_domain_sets_differ() {
let a = CertId::from_domains(&["a.example.com".into()]);
let b = CertId::from_domains(&["b.example.com".into()]);
assert_ne!(a, b);
}
#[tokio::test]
async fn account_save_load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path().join("acme"), "staging");
assert!(store.load_account().await.unwrap().is_none());
store.save_account(b"creds-json").await.unwrap();
assert_eq!(
store.load_account().await.unwrap().as_deref(),
Some(&b"creds-json"[..])
);
}
#[tokio::test]
async fn account_is_keyed_per_directory() {
let dir = tempfile::tempdir().unwrap();
let staging = FsAcmeStore::new(dir.path(), "staging");
let production = FsAcmeStore::new(dir.path(), "production");
staging.save_account(b"staging-creds").await.unwrap();
assert!(production.load_account().await.unwrap().is_none());
assert_eq!(
staging.load_account().await.unwrap().as_deref(),
Some(&b"staging-creds"[..])
);
}
#[tokio::test]
async fn cert_save_load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "staging");
let id = CertId::from_domains(&["app.example.com".into()]);
assert!(store.load_cert(&id).await.unwrap().is_none());
let cert = StoredCert {
chain_pem: "CHAIN".into(),
key_pem: "KEY".into(),
};
store.save_cert(&id, &cert).await.unwrap();
assert_eq!(store.load_cert(&id).await.unwrap(), Some(cert));
}
#[tokio::test]
async fn partial_cert_reads_as_absent() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "staging");
let id = CertId::from_domains(&["app.example.com".into()]);
let chain_path = store.chain_path(&id);
tokio::fs::create_dir_all(chain_path.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&chain_path, b"CHAIN").await.unwrap();
assert!(store.load_cert(&id).await.unwrap().is_none());
}
#[tokio::test]
async fn cert_is_namespaced_per_directory() {
let dir = tempfile::tempdir().unwrap();
let staging = FsAcmeStore::new(dir.path(), "staging");
let production = FsAcmeStore::new(dir.path(), "production");
let id = CertId::from_domains(&["app.example.com".into()]);
let staging_cert = StoredCert {
chain_pem: "STAGING-CHAIN".into(),
key_pem: "STAGING-KEY".into(),
};
staging.save_cert(&id, &staging_cert).await.unwrap();
assert!(production.load_cert(&id).await.unwrap().is_none());
assert_eq!(staging.load_cert(&id).await.unwrap(), Some(staging_cert));
}
#[tokio::test]
async fn save_cert_publishes_both_files_without_leftover_temps() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "staging");
let id = CertId::from_domains(&["app.example.com".into()]);
store
.save_cert(
&id,
&StoredCert {
chain_pem: "CHAIN".into(),
key_pem: "KEY".into(),
},
)
.await
.unwrap();
assert!(store.chain_path(&id).exists(), "chain must be published");
assert!(store.key_path(&id).exists(), "key must be published");
let mut entries = tokio::fs::read_dir(store.cert_dir()).await.unwrap();
while let Some(entry) = entries.next_entry().await.unwrap() {
let name = entry.file_name();
assert!(
!name.to_string_lossy().ends_with(".tmp"),
"no staged temp file should linger, found {name:?}"
);
}
}
#[tokio::test]
async fn save_cert_cleans_up_the_key_temp_when_chain_publish_fails() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "staging");
let id = CertId::from_domains(&["app.example.com".into()]);
let chain_path = store.chain_path(&id);
tokio::fs::create_dir_all(&chain_path).await.unwrap();
tokio::fs::write(chain_path.join("occupied"), b"x")
.await
.unwrap();
let result = store
.save_cert(
&id,
&StoredCert {
chain_pem: "CHAIN".into(),
key_pem: "KEY".into(),
},
)
.await;
assert!(result.is_err(), "chain publish must fail as arranged");
let mut entries = tokio::fs::read_dir(store.cert_dir()).await.unwrap();
while let Some(entry) = entries.next_entry().await.unwrap() {
let name = entry.file_name();
assert!(
!name.to_string_lossy().ends_with(".tmp"),
"the key's staged temp file must not leak, found {name:?}"
);
}
}
#[tokio::test]
async fn find_cert_for_domains_locates_the_configured_pair_and_ignores_others() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "production");
let configured = vec!["app.example.com".to_owned()];
let other = vec!["old.example.com".to_owned()];
store
.save_cert(
&CertId::from_domains(&other),
&StoredCert {
chain_pem: "OLD-CHAIN".into(),
key_pem: "OLD-KEY".into(),
},
)
.await
.unwrap();
assert!(
store.find_cert_for_domains(&configured).is_none(),
"a cert for different domains must not be reported as the configured one"
);
let id = CertId::from_domains(&configured);
store
.save_cert(
&id,
&StoredCert {
chain_pem: "CHAIN".into(),
key_pem: "KEY".into(),
},
)
.await
.unwrap();
let (chain, key) = store
.find_cert_for_domains(&configured)
.expect("configured pair must be found");
assert_eq!(chain, store.chain_path(&id));
assert_eq!(key, store.key_path(&id));
}
#[tokio::test]
async fn find_cert_for_domains_treats_a_partial_pair_as_absent() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "production");
let id = CertId::from_domains(&["app.example.com".into()]);
tokio::fs::create_dir_all(store.cert_dir()).await.unwrap();
tokio::fs::write(store.chain_path(&id), b"CHAIN")
.await
.unwrap();
assert!(
store
.find_cert_for_domains(&["app.example.com".to_owned()])
.is_none()
);
}
#[tokio::test]
async fn find_cert_for_domains_reports_an_occupied_directory_as_present() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "production");
let id = CertId::from_domains(&["app.example.com".into()]);
tokio::fs::create_dir_all(store.chain_path(&id))
.await
.unwrap();
tokio::fs::write(store.key_path(&id), b"KEY").await.unwrap();
assert_eq!(
store.find_cert_for_domains(&["app.example.com".to_owned()]),
Some((store.chain_path(&id), store.key_path(&id))),
"an occupied-but-invalid chain path must not read as absent"
);
}
#[tokio::test]
async fn find_cert_for_domains_is_absent_when_the_store_dir_does_not_exist() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "production");
assert!(
store
.find_cert_for_domains(&["app.example.com".to_owned()])
.is_none()
);
}
#[tokio::test]
async fn list_certs_is_empty_when_the_store_dir_does_not_exist_yet() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "production");
assert_eq!(store.list_certs().unwrap(), Vec::new());
}
#[tokio::test]
async fn list_certs_enumerates_only_complete_pairs() {
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path(), "production");
let a = CertId::from_domains(&["a.example.com".into()]);
let b = CertId::from_domains(&["b.example.com".into()]);
store
.save_cert(
&a,
&StoredCert {
chain_pem: "A-CHAIN".into(),
key_pem: "A-KEY".into(),
},
)
.await
.unwrap();
store
.save_cert(
&b,
&StoredCert {
chain_pem: "B-CHAIN".into(),
key_pem: "B-KEY".into(),
},
)
.await
.unwrap();
let partial = CertId::from_domains(&["partial.example.com".into()]);
tokio::fs::write(store.chain_path(&partial), b"PARTIAL")
.await
.unwrap();
let mut listed = store.list_certs().unwrap();
listed.sort_by(|x, y| x.0.as_str().cmp(y.0.as_str()));
let mut expected = [a.clone(), b.clone()];
expected.sort_by(|x, y| x.as_str().cmp(y.as_str()));
assert_eq!(listed.len(), 2, "the partial pair must not be listed");
for ((id, chain, key), expected_id) in listed.iter().zip(expected.iter()) {
assert_eq!(id, expected_id);
assert_eq!(*chain, store.chain_path(id));
assert_eq!(*key, store.key_path(id));
}
}
#[cfg(unix)]
#[tokio::test]
async fn written_files_are_owner_only_0600() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let store = FsAcmeStore::new(dir.path().join("acme"), "staging");
store.save_account(b"secret").await.unwrap();
let id = CertId::from_domains(&["app.example.com".into()]);
store
.save_cert(
&id,
&StoredCert {
chain_pem: "CHAIN".into(),
key_pem: "KEY".into(),
},
)
.await
.unwrap();
for path in [
store.account_path(),
store.chain_path(&id),
store.key_path(&id),
] {
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode,
0o600,
"{} should be 0600, was {mode:o}",
path.display()
);
}
let subdir_mode = std::fs::metadata(store.cert_dir())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(subdir_mode, 0o700, "cert subdir should be 0700");
}
}