use std::collections::HashMap;
use std::fmt::Debug;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use async_trait::async_trait;
use time::OffsetDateTime;
use tokio_util::sync::CancellationToken;
use crate::crypto::hash_certificate_chain;
use crate::error::{Error, Result, StorageError};
#[cfg(feature = "file-storage")]
pub mod file;
#[cfg(feature = "file-storage")]
pub use file::FileStorage;
pub const CERTS_PREFIX: &str = "certificates";
pub const OCSP_PREFIX: &str = "ocsp";
pub const ACME_PREFIX: &str = "acme";
#[derive(Debug, Clone)]
pub struct KeyInfo {
pub key: String,
pub modified: OffsetDateTime,
pub size: u64,
pub is_terminal: bool,
}
#[async_trait]
pub trait Storage: Locker {
async fn store(&self, key: &str, value: &[u8]) -> Result<()>;
async fn load(&self, key: &str) -> Result<Vec<u8>>;
async fn delete(&self, key: &str) -> Result<()>;
async fn exists(&self, key: &str) -> Result<bool>;
async fn list(&self, path: &str, recursive: bool) -> Result<Vec<String>>;
async fn stat(&self, key: &str) -> Result<KeyInfo>;
}
#[async_trait]
pub trait Locker: Send + Sync + Debug {
async fn lock(&self, ct: &CancellationToken, name: &str) -> Result<LockGuard>;
async fn unlock(&self, name: &str) -> Result<()>;
async fn try_lock(&self, _ct: &CancellationToken, _name: &str) -> Result<Option<LockGuard>> {
Err(Error::Storage(StorageError::Other(
"try_lock not supported by this storage".into(),
)))
}
async fn lock_with_timeout(&self, name: &str, timeout: Duration) -> Result<LockGuard> {
let ct = CancellationToken::new();
match tokio::time::timeout(timeout, self.lock(&ct, name)).await {
Ok(result) => result,
Err(_) => Err(Error::Storage(StorageError::LockUnavailable(
name.to_owned(),
))),
}
}
async fn try_lock_with_timeout(
&self,
name: &str,
timeout: Duration,
) -> Result<Option<LockGuard>> {
const RETRY_INTERVAL: Duration = Duration::from_millis(25);
let deadline = tokio::time::Instant::now() + timeout;
let ct = CancellationToken::new();
loop {
if let Some(guard) = self.try_lock(&ct, name).await? {
return Ok(Some(guard));
}
let now = tokio::time::Instant::now();
if now >= deadline {
return Ok(None);
}
let delay = (deadline - now).min(RETRY_INTERVAL);
tokio::time::sleep(delay).await;
}
}
async fn acquire(&self, name: &str, timeout: Duration) -> Result<LockGuard> {
self.lock_with_timeout(name, timeout).await
}
async fn try_acquire(&self, name: &str, timeout: Duration) -> Result<Option<LockGuard>> {
self.try_lock_with_timeout(name, timeout).await
}
async fn renew_lock_lease(&self, _name: &str, _lease: Duration) -> Result<()> {
Err(Error::Storage(StorageError::Other(
"lock lease renewal not supported by this storage".into(),
)))
}
}
pub struct LockGuard {
key: String,
release: Box<dyn LockRelease>,
released: std::sync::atomic::AtomicBool,
}
pub type LockHandle = LockGuard;
pub trait LockRelease: Send + Sync {
fn release(&self);
}
impl LockGuard {
#[cfg(feature = "file-storage")]
pub(crate) fn new(key: impl Into<String>, release: Box<dyn LockRelease>) -> Self {
Self {
key: key.into(),
release,
released: std::sync::atomic::AtomicBool::new(false),
}
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
#[must_use]
pub fn name(&self) -> &str {
self.key()
}
}
impl Drop for LockGuard {
fn drop(&mut self) {
if !self
.released
.swap(true, std::sync::atomic::Ordering::SeqCst)
{
self.release.release();
}
untrack_lock(&self.key);
}
}
impl LockGuard {
pub fn release(self) {
}
}
impl Debug for LockGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LockGuard").field("key", &self.key).finish()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct KeyBuilder;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct StorageKeys {
pub cert: String,
pub key: String,
pub meta: String,
}
impl StorageKeys {
#[must_use]
pub fn new(issuer_key: &str, domain: &str) -> Self {
Self {
cert: site_cert_key(issuer_key, domain),
key: site_private_key(issuer_key, domain),
meta: site_meta_key(issuer_key, domain),
}
}
}
pub const STORAGE_KEYS: KeyBuilder = KeyBuilder;
impl KeyBuilder {
#[must_use]
pub fn certs_prefix(&self, issuer_key: &str) -> String {
format!("{CERTS_PREFIX}/{}", self.safe(issuer_key))
}
#[must_use]
pub fn certs_site_prefix(&self, issuer_key: &str, domain: &str) -> String {
format!(
"{}/{}/{}",
CERTS_PREFIX,
self.safe(issuer_key),
self.safe(domain)
)
}
#[must_use]
pub fn site_cert(&self, issuer_key: &str, domain: &str) -> String {
format!(
"{}/{}.crt",
self.certs_site_prefix(issuer_key, domain),
self.safe(domain)
)
}
#[must_use]
pub fn site_private_key(&self, issuer_key: &str, domain: &str) -> String {
format!(
"{}/{}.key",
self.certs_site_prefix(issuer_key, domain),
self.safe(domain)
)
}
#[must_use]
pub fn site_meta(&self, issuer_key: &str, domain: &str) -> String {
format!(
"{}/{}.json",
self.certs_site_prefix(issuer_key, domain),
self.safe(domain)
)
}
#[must_use]
pub fn ocsp_staple(&self, first_name: Option<&str>, pem_bundle: &[u8]) -> String {
let hash = crate::crypto::sha256_hex(pem_bundle);
match first_name {
Some(name) => format!("{OCSP_PREFIX}/{}-{hash}", self.safe(name)),
None => format!("{OCSP_PREFIX}/{hash}"),
}
}
#[must_use]
pub fn safe(&self, s: &str) -> String {
let lowered = s.to_lowercase();
let trimmed = lowered.trim();
let replaced = trimmed
.replace(' ', "_")
.replace('+', "_plus_")
.replace('*', "wildcard_")
.replace(':', "-")
.replace("..", "");
replaced
.chars()
.filter(|c| c.is_alphanumeric() || matches!(c, '_' | '@' | '.' | '-'))
.collect()
}
}
#[must_use]
pub fn safe_key(value: &str) -> String {
STORAGE_KEYS.safe(value)
}
#[must_use]
pub fn certs_prefix(issuer_key: &str) -> String {
STORAGE_KEYS.certs_prefix(issuer_key)
}
#[must_use]
pub fn certs_site_prefix(issuer_key: &str, domain: &str) -> String {
STORAGE_KEYS.certs_site_prefix(issuer_key, domain)
}
#[must_use]
pub fn site_cert_key(issuer_key: &str, domain: &str) -> String {
STORAGE_KEYS.site_cert(issuer_key, domain)
}
#[must_use]
pub fn site_private_key(issuer_key: &str, domain: &str) -> String {
STORAGE_KEYS.site_private_key(issuer_key, domain)
}
#[must_use]
pub fn site_meta_key(issuer_key: &str, domain: &str) -> String {
STORAGE_KEYS.site_meta(issuer_key, domain)
}
#[must_use]
pub fn ocsp_staple_key(first_name: Option<&str>, pem_bundle: &[u8]) -> String {
STORAGE_KEYS.ocsp_staple(first_name, pem_bundle)
}
#[must_use]
pub fn ocsp_key(domain: &str, hash: &str) -> String {
let filename = if domain.is_empty() {
hash.to_owned()
} else {
format!("{}-{hash}", safe_key(domain))
};
format!("{OCSP_PREFIX}/{filename}")
}
#[must_use]
pub fn acme_ca_prefix(issuer_key: &str) -> String {
format!("{ACME_PREFIX}/{}", safe_key(issuer_key))
}
#[must_use]
pub fn acme_hosts_prefix(issuer_key: &str) -> String {
format!("{ACME_PREFIX}/hosts/{}", safe_key(issuer_key))
}
#[must_use]
pub fn account_key_prefix(issuer_key: &str, email: &str) -> String {
let email = if email.is_empty() { "default" } else { email };
format!(
"{}/users/{}",
acme_hosts_prefix(issuer_key),
safe_key(email)
)
}
#[must_use]
pub fn legacy_account_key_prefix(issuer_key: &str, email: &str) -> String {
let email = if email.is_empty() { "default" } else { email };
format!("{}/users/{}", acme_ca_prefix(issuer_key), safe_key(email))
}
#[must_use]
pub fn account_private_key(issuer_key: &str, email: &str) -> String {
format!("{}/private.key", account_key_prefix(issuer_key, email))
}
#[must_use]
pub fn account_registration(issuer_key: &str, email: &str) -> String {
format!(
"{}/registration.json",
account_key_prefix(issuer_key, email)
)
}
#[must_use]
pub fn locks_key(name: &str) -> String {
format!("locks/{}", safe_key(name))
}
#[must_use]
pub fn issuer_key(ca_url: &str) -> String {
match url::Url::parse(ca_url) {
Ok(parsed) => {
let host = parsed.host_str().unwrap_or(ca_url);
let collapsed = parsed.path().replace(['/', '\\'], "-");
let collapsed = collapsed.trim_matches('-');
if collapsed.is_empty() {
host.to_owned()
} else {
format!("{host}-{collapsed}")
}
}
Err(_) => ca_url.to_owned(),
}
}
pub type KeyValue<'a> = (&'a str, Vec<u8>);
pub async fn store_tx(storage: &dyn Storage, items: &[KeyValue<'_>]) -> Result<()> {
let mut previous = Vec::with_capacity(items.len());
for (key, _) in items {
match storage.load(key).await {
Ok(value) => previous.push(Some(value)),
Err(Error::Storage(StorageError::NotFound(_))) => previous.push(None),
Err(err) => return Err(err),
}
}
for (i, (key, value)) in items.iter().enumerate() {
if let Err(err) = storage.store(key, value).await {
for (j, (key, _)) in items.iter().enumerate().take(i + 1).rev() {
let rollback = match previous[j].as_deref() {
Some(old) => storage.store(key, old).await,
None => storage.delete(key).await,
};
if let Err(rollback_err) = rollback {
tracing::warn!(
key = %key,
error = %rollback_err,
"storage transaction rollback failed"
);
}
}
return Err(err);
}
}
Ok(())
}
pub async fn store_certificate(
storage: &dyn Storage,
issuer_key: &str,
resource: &crate::issuer::CertificateResource,
) -> Result<()> {
let domain = resource.names_key();
let metadata = serde_json::to_vec(resource)
.map_err(|error| Error::Storage(StorageError::Other(format!("meta encode: {error}"))))?;
let private_key = site_private_key(issuer_key, &domain);
let certificate = site_cert_key(issuer_key, &domain);
let metadata_key = site_meta_key(issuer_key, &domain);
store_tx(
storage,
&[
(&private_key, resource.private_key_pem.clone()),
(&certificate, resource.certificate_pem.clone()),
(&metadata_key, metadata),
],
)
.await
}
pub async fn load_certificate(
storage: &dyn Storage,
issuer_key: &str,
domain: &str,
) -> Result<crate::issuer::CertificateResource> {
let private_key = storage.load(&site_private_key(issuer_key, domain)).await?;
let certificate = storage.load(&site_cert_key(issuer_key, domain)).await?;
let metadata = storage.load(&site_meta_key(issuer_key, domain)).await?;
let mut resource: crate::issuer::CertificateResource = serde_json::from_slice(&metadata)
.map_err(|error| Error::Storage(StorageError::Other(format!("meta decode: {error}"))))?;
resource.private_key_pem = private_key;
resource.certificate_pem = certificate;
Ok(resource)
}
static OWNED_LOCKS: OnceLock<Mutex<HashMap<String, Arc<dyn Storage>>>> = OnceLock::new();
pub static STORAGE_DEFAULT: OnceLock<Arc<dyn Storage>> = OnceLock::new();
fn owned_locks() -> &'static Mutex<HashMap<String, Arc<dyn Storage>>> {
OWNED_LOCKS.get_or_init(Mutex::default)
}
pub fn track_lock(storage: &Arc<dyn Storage>, key: &str) {
if let Ok(mut locks) = owned_locks().lock() {
locks.insert(key.to_owned(), Arc::clone(storage));
}
}
pub fn untrack_lock(key: &str) -> bool {
owned_locks()
.lock()
.ok()
.and_then(|mut locks| locks.remove(key))
.is_some()
}
pub async fn acquire_lock(
ct: &CancellationToken,
storage: &Arc<dyn Storage>,
key: &str,
) -> Result<LockGuard> {
let guard = storage.lock(ct, key).await?;
track_lock(storage, key);
Ok(guard)
}
pub async fn try_acquire_lock(
ct: &CancellationToken,
storage: &Arc<dyn Storage>,
key: &str,
) -> Result<Option<LockGuard>> {
if let Some(guard) = storage.try_lock(ct, key).await? {
track_lock(storage, key);
return Ok(Some(guard));
}
Ok(None)
}
pub async fn acquire(storage: Arc<dyn Storage>, key: &str) -> Result<LockGuard> {
let ct = CancellationToken::new();
acquire_lock(&ct, &storage, key).await
}
pub async fn try_acquire(
storage: Arc<dyn Storage>,
key: &str,
timeout: Duration,
) -> Result<Option<LockGuard>> {
let result = storage.try_lock_with_timeout(key, timeout).await?;
if let Some(guard) = result {
track_lock(&storage, key);
Ok(Some(guard))
} else {
Ok(None)
}
}
pub async fn acquire_with_timeout(
storage: Arc<dyn Storage>,
key: &str,
timeout: Duration,
) -> Result<LockGuard> {
let guard = storage.lock_with_timeout(key, timeout).await?;
track_lock(&storage, key);
Ok(guard)
}
pub async fn release_lock(guard: LockGuard) {
let key = guard.key().to_owned();
guard.release();
untrack_lock(&key);
}
pub async fn clean_up_own_locks() {
let entries: Vec<(String, Arc<dyn Storage>)> = match owned_locks().lock() {
Ok(mut locks) => locks.drain().collect(),
Err(_) => return,
};
for (key, storage) in entries {
if let Err(err) = storage.unlock(&key).await {
tracing::warn!(key = %key, error = %err, "failed to clean up lock");
}
}
}
pub async fn cleanup_own_locks() {
clean_up_own_locks().await;
}
#[derive(Debug, Clone, Default)]
pub struct CleanStorageOptions {
pub expired_certs: bool,
pub expired_cert_grace_period: Duration,
pub ocsp_staples: bool,
pub interval: Duration,
pub instance_id: Option<String>,
}
pub const LAST_CLEAN_KEY: &str = "last_clean.json";
pub async fn clean_storage(
ct: &CancellationToken,
storage: &Arc<dyn Storage>,
opts: &CleanStorageOptions,
) -> Result<()> {
let _guard = crate::storage::acquire_lock(ct, storage, "storage_clean").await?;
if !opts.interval.is_zero()
&& let Ok(true) = storage.exists(LAST_CLEAN_KEY).await
&& let Ok(data) = storage.load(LAST_CLEAN_KEY).await
&& let Ok(last) = serde_json::from_slice::<serde_json::Value>(&data)
&& let Some(ts) = last.get("last_clean").and_then(|v| v.as_i64())
{
let last_time =
OffsetDateTime::from_unix_timestamp(ts).unwrap_or_else(|_| OffsetDateTime::now_utc());
let since: Option<std::time::Duration> =
(OffsetDateTime::now_utc() - last_time).try_into().ok();
if since.is_some_and(|d| d < opts.interval) {
tracing::debug!("storage was cleaned recently; skipping");
return Ok(());
}
}
if opts.expired_certs {
delete_expired_certs(storage, opts.expired_cert_grace_period).await;
}
if opts.ocsp_staples {
delete_old_ocsp_staples(storage).await;
}
let record = serde_json::json!({
"last_clean": OffsetDateTime::now_utc().unix_timestamp(),
"instance_id": opts.instance_id,
});
storage
.store(LAST_CLEAN_KEY, record.to_string().as_bytes())
.await?;
Ok(())
}
async fn delete_expired_certs(storage: &Arc<dyn Storage>, grace_period: Duration) {
let Ok(issuer_prefixes) = storage.list(CERTS_PREFIX, false).await else {
return;
};
for issuer_prefix in issuer_prefixes {
let Ok(sites) = storage.list(&issuer_prefix, false).await else {
continue;
};
for site in sites {
let Ok(files) = storage.list(&site, false).await else {
continue;
};
let Some(cert_file) = files.iter().find(|f| f.ends_with(".crt")) else {
continue;
};
let Ok(cert_pem) = storage.load(cert_file).await else {
continue;
};
let Some(expiry) = cert_expiry_from_pem(&cert_pem) else {
continue;
};
let age_expired = OffsetDateTime::now_utc() - expiry;
let expired_long_enough: Option<bool> = age_expired
.try_into()
.ok()
.map(|d: Duration| d >= grace_period);
if expired_long_enough.unwrap_or(false) {
tracing::info!(site = %site, "deleting expired certificate");
let _ = storage.delete(&site).await; }
}
}
}
async fn delete_old_ocsp_staples(storage: &Arc<dyn Storage>) {
let Ok(staples) = storage.list(OCSP_PREFIX, false).await else {
return;
};
for staple in staples {
match storage.load(&staple).await {
Ok(der) => {
match crate::ocsp::next_update_from_response(&der) {
Some(next_update) => {
if OffsetDateTime::now_utc() > next_update {
let _ = storage.delete(&staple).await;
}
}
None => {
let _ = storage.delete(&staple).await;
}
}
}
Err(_) => {
let _ = storage.delete(&staple).await;
}
}
}
}
fn cert_expiry_from_pem(pem_bundle: &[u8]) -> Option<OffsetDateTime> {
let der = crate::pem::first_section_with_label(pem_bundle, &["CERTIFICATE"])?.der;
let (_, cert) = x509_parser::parse_x509_certificate(&der).ok()?;
Some(cert.validity().not_after.to_datetime())
}
#[must_use]
pub fn chain_hash(chain: &[rustls::pki_types::CertificateDer<'_>]) -> String {
hash_certificate_chain(chain)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap as TestMap;
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Debug)]
struct FailingStore {
values: Mutex<TestMap<String, Vec<u8>>>,
fail_key: String,
failed_once: AtomicBool,
}
impl FailingStore {
fn new(fail_key: &str) -> Self {
Self {
values: Mutex::new(TestMap::new()),
fail_key: fail_key.to_owned(),
failed_once: AtomicBool::new(false),
}
}
fn value(&self, key: &str) -> Option<Vec<u8>> {
self.values.lock().unwrap().get(key).cloned()
}
fn seed(&self, key: &str, value: &[u8]) {
self.values
.lock()
.unwrap()
.insert(key.to_owned(), value.to_vec());
}
}
#[async_trait]
impl Locker for FailingStore {
async fn lock(&self, _ct: &CancellationToken, _name: &str) -> Result<LockGuard> {
Err(Error::Storage(StorageError::Other(
"test locker is not implemented".into(),
)))
}
async fn unlock(&self, _name: &str) -> Result<()> {
Ok(())
}
}
#[async_trait]
impl Storage for FailingStore {
async fn store(&self, key: &str, value: &[u8]) -> Result<()> {
if key == self.fail_key && !self.failed_once.swap(true, Ordering::SeqCst) {
return Err(Error::Storage(StorageError::Other(
"injected store failure".into(),
)));
}
self.values
.lock()
.unwrap()
.insert(key.to_owned(), value.to_vec());
Ok(())
}
async fn load(&self, key: &str) -> Result<Vec<u8>> {
self.value(key)
.ok_or_else(|| Error::Storage(StorageError::NotFound(key.to_owned())))
}
async fn delete(&self, key: &str) -> Result<()> {
self.values.lock().unwrap().remove(key);
Ok(())
}
async fn exists(&self, key: &str) -> Result<bool> {
Ok(self.values.lock().unwrap().contains_key(key))
}
async fn list(&self, path: &str, _recursive: bool) -> Result<Vec<String>> {
let prefix = if path.is_empty() {
String::new()
} else {
format!("{}/", path.trim_matches('/'))
};
Ok(self
.values
.lock()
.unwrap()
.keys()
.filter(|key| key.starts_with(&prefix))
.cloned()
.collect())
}
async fn stat(&self, key: &str) -> Result<KeyInfo> {
let value = self.load(key).await?;
Ok(KeyInfo {
key: key.to_owned(),
modified: OffsetDateTime::now_utc(),
size: value.len() as u64,
is_terminal: true,
})
}
}
#[test]
fn safe_is_idempotent_and_escape_proof() {
let cases = [
("Example.COM", "example.com"),
("*.example.com", "wildcard_.example.com"),
("a+b.com", "a_plus_b.com"),
("my site:name", "my_site-name"),
("a..b", "ab"),
("../../etc/passwd", "etcpasswd"),
("we!rd@host", "werd@host"),
];
for (input, want) in cases {
let got = STORAGE_KEYS.safe(input);
assert_eq!(got, want, "safe({input:?})");
assert_eq!(STORAGE_KEYS.safe(&got), got, "idempotency of {input:?}");
}
}
#[tokio::test]
async fn store_tx_restores_overwritten_values_after_failure() {
let storage = FailingStore::new("b");
storage.seed("a", b"old-a");
storage.seed("b", b"old-b");
let result = store_tx(
&storage,
&[
("a", b"new-a".to_vec()),
("b", b"new-b".to_vec()),
("c", b"new-c".to_vec()),
],
)
.await;
assert!(result.is_err());
assert_eq!(storage.value("a").as_deref(), Some(&b"old-a"[..]));
assert_eq!(storage.value("b").as_deref(), Some(&b"old-b"[..]));
assert_eq!(storage.value("c"), None);
}
#[test]
fn key_paths_match_go_layout() {
assert_eq!(
STORAGE_KEYS.certs_prefix("acme-v02"),
"certificates/acme-v02"
);
assert_eq!(
STORAGE_KEYS.site_cert("acme-v02", "example.com"),
"certificates/acme-v02/example.com/example.com.crt"
);
assert_eq!(
STORAGE_KEYS.site_private_key("acme-v02", "example.com"),
"certificates/acme-v02/example.com/example.com.key"
);
assert_eq!(
STORAGE_KEYS.site_meta("acme-v02", "example.com"),
"certificates/acme-v02/example.com/example.com.json"
);
assert_eq!(
STORAGE_KEYS.site_cert("acme", "*.example.com"),
"certificates/acme/wildcard_.example.com/wildcard_.example.com.crt"
);
}
#[test]
fn module_key_helpers_match_builder_and_layout() {
let keys = StorageKeys::new("acme-v02", "example.com");
assert_eq!(
safe_key("*.Example.COM"),
STORAGE_KEYS.safe("*.Example.COM")
);
assert_eq!(
certs_prefix("acme-v02"),
STORAGE_KEYS.certs_prefix("acme-v02")
);
assert_eq!(
certs_site_prefix("acme-v02", "example.com"),
STORAGE_KEYS.certs_site_prefix("acme-v02", "example.com")
);
assert_eq!(
site_cert_key("acme-v02", "example.com"),
"certificates/acme-v02/example.com/example.com.crt"
);
assert_eq!(
site_private_key("acme-v02", "example.com"),
"certificates/acme-v02/example.com/example.com.key"
);
assert_eq!(
site_meta_key("acme-v02", "example.com"),
"certificates/acme-v02/example.com/example.com.json"
);
assert_eq!(keys.cert, site_cert_key("acme-v02", "example.com"));
assert_eq!(keys.key, site_private_key("acme-v02", "example.com"));
assert_eq!(keys.meta, site_meta_key("acme-v02", "example.com"));
assert_eq!(acme_ca_prefix("acme-v02"), "acme/acme-v02");
assert_eq!(acme_hosts_prefix("acme-v02"), "acme/hosts/acme-v02");
assert_eq!(
account_key_prefix("acme-v02", "admin@example.com"),
"acme/hosts/acme-v02/users/admin@example.com"
);
assert_eq!(
account_key_prefix("acme-v02", ""),
"acme/hosts/acme-v02/users/default"
);
assert_eq!(
account_private_key("acme-v02", "admin@example.com"),
"acme/hosts/acme-v02/users/admin@example.com/private.key"
);
assert_eq!(
account_registration("acme-v02", "admin@example.com"),
"acme/hosts/acme-v02/users/admin@example.com/registration.json"
);
assert_eq!(
legacy_account_key_prefix("acme-v02", "admin@example.com"),
"acme/acme-v02/users/admin@example.com"
);
assert_eq!(
issuer_key("https://acme.example.com/v2/directory"),
"acme.example.com-v2-directory"
);
assert_eq!(
locks_key("issue_cert_example.com"),
"locks/issue_cert_example.com"
);
}
#[test]
fn safe_key_preserves_unicode_word_characters() {
assert_eq!(safe_key(" München.例子 "), "münchen.例子");
}
#[test]
fn ocsp_key_helpers_agree_without_a_first_name() {
let bundle = b"certificate-chain";
let hash = crate::crypto::sha256_hex(bundle);
assert_eq!(STORAGE_KEYS.ocsp_staple(None, bundle), ocsp_key("", &hash));
assert_eq!(
STORAGE_KEYS.ocsp_staple(None, bundle),
format!("ocsp/{hash}")
);
}
#[cfg(feature = "file-storage")]
#[tokio::test]
async fn certificate_helpers_roundtrip() {
let directory = tempfile::tempdir().unwrap();
let storage: Arc<dyn Storage> = FileStorage::new(directory.path());
let resource = crate::issuer::CertificateResource {
sans: vec!["example.com".into()],
certificate_pem: b"certificate".to_vec(),
private_key_pem: b"private-key".to_vec(),
issuer_data: None,
};
store_certificate(storage.as_ref(), "acme", &resource)
.await
.unwrap();
let metadata = storage
.load(&site_meta_key("acme", "example.com"))
.await
.unwrap();
let metadata_json: serde_json::Value = serde_json::from_slice(&metadata).unwrap();
assert!(metadata_json.get("certificate_pem").is_none());
assert!(metadata_json.get("private_key_pem").is_none());
let loaded = load_certificate(storage.as_ref(), "acme", "example.com")
.await
.unwrap();
assert_eq!(loaded.sans, resource.sans);
assert_eq!(loaded.certificate_pem, resource.certificate_pem);
assert_eq!(loaded.private_key_pem, resource.private_key_pem);
}
#[cfg(feature = "file-storage")]
#[tokio::test]
async fn dropping_an_acquired_guard_untracks_it_before_reacquire() {
let directory = tempfile::tempdir().unwrap();
let storage: Arc<dyn Storage> = FileStorage::new(directory.path());
let key = "raii-untrack-test";
let first = acquire(Arc::clone(&storage), key).await.unwrap();
drop(first);
assert!(!owned_locks().lock().unwrap().contains_key(key));
let second = acquire(Arc::clone(&storage), key).await.unwrap();
assert!(owned_locks().lock().unwrap().contains_key(key));
drop(second);
}
#[cfg(feature = "file-storage")]
#[tokio::test]
async fn timeout_try_lock_retries_until_guard_is_released() {
let directory = tempfile::tempdir().unwrap();
let storage: Arc<dyn Storage> = FileStorage::new(directory.path());
let key = "timeout-retry-test";
let ct = CancellationToken::new();
let first = storage.lock(&ct, key).await.unwrap();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(35)).await;
drop(first);
});
let acquired = storage
.try_lock_with_timeout(key, Duration::from_millis(250))
.await
.unwrap();
assert!(acquired.is_some());
}
#[cfg(feature = "file-storage")]
#[tokio::test]
async fn timeout_try_lock_returns_none_when_lock_remains_held() {
let directory = tempfile::tempdir().unwrap();
let storage: Arc<dyn Storage> = FileStorage::new(directory.path());
let key = "timeout-none-test";
let ct = CancellationToken::new();
let _held = storage.lock(&ct, key).await.unwrap();
let started = tokio::time::Instant::now();
let acquired = storage
.try_acquire(key, Duration::from_millis(60))
.await
.unwrap();
assert!(acquired.is_none());
assert!(started.elapsed() >= Duration::from_millis(45));
}
#[cfg(feature = "file-storage")]
#[tokio::test]
async fn timeout_lock_reports_lock_unavailable() {
let directory = tempfile::tempdir().unwrap();
let storage: Arc<dyn Storage> = FileStorage::new(directory.path());
let key = "timeout-error-test";
let ct = CancellationToken::new();
let _held = storage.lock(&ct, key).await.unwrap();
let error = storage
.lock_with_timeout(key, Duration::from_millis(30))
.await
.unwrap_err();
assert!(matches!(
error,
Error::Storage(StorageError::LockUnavailable(ref name)) if name == key
));
}
}