use std::net::IpAddr;
use std::time::Duration;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use time::OffsetDateTime;
use crate::error::{AcmeError, Error, Result};
const FIRST_POLL_INTERVAL: Duration = Duration::from_millis(500);
const MAX_POLL_INTERVAL: Duration = Duration::from_secs(5);
pub const DEFAULT_ORDER_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "lowercase")]
pub enum Identifier {
Dns(String),
Ip(IpAddr),
}
impl Identifier {
#[must_use]
pub fn value(&self) -> String {
match self {
Self::Dns(d) => d.clone(),
Self::Ip(ip) => ip.to_string(),
}
}
}
impl From<&str> for Identifier {
fn from(s: &str) -> Self {
Identifier::Dns(s.to_owned())
}
}
impl From<IpAddr> for Identifier {
fn from(ip: IpAddr) -> Self {
Identifier::Ip(ip)
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Challenge {
#[serde(rename = "type")]
pub kind: String,
#[serde(default)]
pub token: String,
pub url: String,
#[serde(default)]
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<Value>,
}
impl Challenge {
pub fn key_authorization(&self, account: &crate::acme::protocol::AccountKey) -> Result<String> {
let thumb = account.thumbprint_b64()?;
crate::acme::protocol::key_authorization(&self.token, &thumb)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Authorization {
pub identifier: Identifier,
#[serde(default)]
pub status: String,
#[serde(default)]
pub challenges: Vec<Challenge>,
#[serde(default)]
pub wildcard: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires: Option<String>,
}
impl Authorization {
#[must_use]
pub fn challenge(&self, kind: &str) -> Option<&Challenge> {
self.challenges.iter().find(|c| c.kind == kind)
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Order {
#[serde(default)]
pub status: String,
#[serde(default)]
pub authorizations: Vec<String>,
#[serde(default)]
pub finalize: String,
#[serde(default)]
pub certificate: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub not_before: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub not_after: Option<String>,
}
impl Order {
#[must_use]
pub fn is_terminal(&self) -> bool {
matches!(self.status.as_str(), "valid" | "invalid")
}
}
#[must_use]
pub fn new_order_body(
identifiers: &[Identifier],
not_before: Option<OffsetDateTime>,
not_after: Option<OffsetDateTime>,
) -> Value {
new_order_body_with_options(identifiers, not_before, not_after, None, None)
}
#[must_use]
pub fn new_order_body_with_options(
identifiers: &[Identifier],
not_before: Option<OffsetDateTime>,
not_after: Option<OffsetDateTime>,
profile: Option<&str>,
replaces: Option<&str>,
) -> Value {
let mut body = json!({
"identifiers": identifiers
.iter()
.map(|id| match id {
Identifier::Dns(d) => json!({"type": "dns", "value": d}),
Identifier::Ip(ip) => json!({"type": "ip", "value": ip.to_string()}),
})
.collect::<Vec<_>>(),
});
if let Some(nb) = not_before {
body["notBefore"] = Value::String(rfc3339(nb));
}
if let Some(na) = not_after {
body["notAfter"] = Value::String(rfc3339(na));
}
if let Some(profile) = profile {
body["profile"] = Value::String(profile.to_owned());
}
if let Some(replaces) = replaces {
body["replaces"] = Value::String(replaces.to_owned());
}
body
}
#[must_use]
pub fn ari_cert_id(aki_key_identifier: &[u8], serial_der: &[u8]) -> String {
format!(
"{}.{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(aki_key_identifier),
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serial_der)
)
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct RenewalInfoResponse {
#[serde(rename = "suggestedWindow", default)]
pub suggested_window: Option<SuggestedWindow>,
#[serde(rename = "explanationURL", default)]
pub explanation_url: Option<String>,
#[serde(rename = "retryAfter", default)]
pub retry_after: Option<u64>,
#[serde(skip)]
pub selected_time: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SuggestedWindow {
pub start: String,
pub end: String,
}
impl RenewalInfoResponse {
pub fn window_opt(&self) -> Result<Option<(OffsetDateTime, OffsetDateTime)>> {
let Some(window) = &self.suggested_window else {
return Ok(None);
};
let parse = |s: &str| -> Result<OffsetDateTime> {
OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
.map_err(|e| Error::Acme(AcmeError::Order(format!("ARI timestamp: {e}"))))
};
let start = parse(&window.start)?;
let end = parse(&window.end)?;
if end <= start {
return Err(Error::Acme(AcmeError::Order(
"ARI renewal window end must be after start".into(),
)));
}
Ok(Some((start, end)))
}
pub fn window(&self) -> Result<(OffsetDateTime, OffsetDateTime)> {
self.window_opt()?.ok_or_else(|| {
Error::Acme(AcmeError::Order(
"ARI response omitted suggestedWindow".into(),
))
})
}
pub fn to_runtime(&self) -> Result<Option<crate::certificate::RenewalInfo>> {
self.window_opt()?
.map(|(start, end)| {
Ok(crate::certificate::RenewalInfo::from_suggested_window(
start, end,
))
})
.transpose()
}
pub fn from_runtime(info: &crate::certificate::RenewalInfo) -> Result<Self> {
let window = info.renewal_window()?;
Ok(Self {
suggested_window: Some(SuggestedWindow {
start: window.start,
end: window.end,
}),
explanation_url: None,
retry_after: None,
selected_time: Some(info.selected_time),
})
}
}
impl From<crate::certificate::RenewalWindow> for SuggestedWindow {
fn from(window: crate::certificate::RenewalWindow) -> Self {
Self {
start: window.start,
end: window.end,
}
}
}
impl From<SuggestedWindow> for crate::certificate::RenewalWindow {
fn from(window: SuggestedWindow) -> Self {
Self {
start: window.start,
end: window.end,
}
}
}
pub async fn poll_until<T, F, Fut>(
mut check: F,
timeout: Duration,
ct: &tokio_util::sync::CancellationToken,
) -> Result<T>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<(T, bool)>>,
{
let polling = async {
let mut interval = FIRST_POLL_INTERVAL;
loop {
let (value, done) = check().await?;
if done {
return Ok(value);
}
tokio::time::sleep(interval).await;
interval = (interval * 2).min(MAX_POLL_INTERVAL);
}
};
tokio::select! {
biased;
() = ct.cancelled() => Err(Error::Acme(AcmeError::Order("canceled".into()))),
result = tokio::time::timeout(timeout, polling) => result.unwrap_or_else(|_| {
Err(Error::Acme(AcmeError::Order(format!("not ready within {}s", timeout.as_secs()))))
}),
}
}
fn rfc3339(t: OffsetDateTime) -> String {
t.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| t.unix_timestamp().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(start_paused = true)]
async fn review_poll_timeout_includes_inflight_check() {
let result: Result<()> = poll_until(
std::future::pending,
Duration::from_secs(2),
&tokio_util::sync::CancellationToken::new(),
)
.await;
assert!(
result
.unwrap_err()
.to_string()
.contains("not ready within 2s")
);
}
#[tokio::test]
async fn review_cancelled_poll_does_not_start_check() {
let ct = tokio_util::sync::CancellationToken::new();
ct.cancel();
let result: Result<()> = poll_until(
|| async { panic!("cancelled poll must not execute") },
Duration::from_secs(1),
&ct,
)
.await;
assert!(result.is_err());
}
#[test]
fn ari_cert_id_is_dot_separated() {
let id = ari_cert_id(&[0x11; 20], &[0x01, 0x02, 0x03]);
assert_eq!(id, "ERERERERERERERERERERERERERE.AQID");
assert!(!id.contains('='));
assert_eq!(id.matches('.').count(), 1, "AKID.serial shape");
assert_ne!(id, ari_cert_id(&[0x11; 20], &[0x01, 0x02, 0x04]));
}
#[test]
fn identifiers_roundtrip() {
let dns = Identifier::Dns("example.com".into());
let ip: Identifier = "127.0.0.1".parse::<IpAddr>().unwrap().into();
assert_eq!(dns.value(), "example.com");
assert_eq!(ip.value(), "127.0.0.1");
let body = new_order_body(&[dns, ip], None, None);
assert_eq!(body["identifiers"][0]["type"], "dns");
assert_eq!(body["identifiers"][1]["type"], "ip");
}
#[tokio::test(start_paused = true)]
async fn poll_until_completes_on_done() {
let ct = tokio_util::sync::CancellationToken::new();
let counter = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let c2 = counter.clone();
let value = poll_until(
move || {
let c = c2.clone();
async move {
let n = c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok((n, n >= 2))
}
},
Duration::from_secs(10),
&ct,
)
.await
.unwrap();
assert_eq!(value, 2);
}
#[test]
fn order_deserializes_minimally() {
let order: Order =
serde_json::from_str(r#"{"status":"pending","finalize":"https://ca/fx"}"#).unwrap();
assert_eq!(order.status, "pending");
assert!(!order.is_terminal());
let invalid: Order = serde_json::from_str(r#"{"status":"invalid"}"#).unwrap();
assert!(invalid.is_terminal());
}
#[test]
fn renewal_info_response_preserves_wire_metadata_without_runtime_state() {
let response: RenewalInfoResponse = serde_json::from_str(
r#"{
"suggestedWindow": {
"start": "2030-01-01T00:00:00Z",
"end": "2030-01-02T00:00:00Z"
},
"explanationURL": "https://ca.example/ari/explanation",
"retryAfter": 3600
}"#,
)
.unwrap();
assert_eq!(
response.explanation_url.as_deref(),
Some("https://ca.example/ari/explanation")
);
assert_eq!(response.retry_after, Some(3600));
assert_eq!(response.selected_time, None);
let runtime = response.to_runtime().unwrap().unwrap();
let (start, end) = response.window().unwrap();
assert!(runtime.selected_time >= start);
assert!(runtime.selected_time <= end);
let encoded = serde_json::to_value(&response).unwrap();
assert_eq!(
encoded["explanationURL"],
"https://ca.example/ari/explanation"
);
assert_eq!(encoded["retryAfter"], 3600);
assert!(encoded.get("selected_time").is_none());
}
#[test]
fn renewal_info_response_allows_metadata_without_window() {
let response: RenewalInfoResponse =
serde_json::from_str(r#"{"explanationURL":"https://ca.example/ari","retryAfter":120}"#)
.unwrap();
assert_eq!(response.window_opt().unwrap(), None);
assert!(response.to_runtime().unwrap().is_none());
assert!(response.window().is_err());
assert_eq!(response.retry_after, Some(120));
}
#[test]
fn renewal_info_response_rejects_reversed_window() {
let response = RenewalInfoResponse {
suggested_window: Some(SuggestedWindow {
start: "2030-01-02T00:00:00Z".into(),
end: "2030-01-01T00:00:00Z".into(),
}),
..RenewalInfoResponse::default()
};
assert!(response.to_runtime().is_err());
}
#[test]
fn new_order_options_include_profile_and_replaces() {
let body = new_order_body_with_options(
&[Identifier::Dns("example.com".into())],
None,
None,
Some("tlsserver"),
Some("https://ca/cert/1"),
);
assert_eq!(body["profile"], "tlsserver");
assert_eq!(body["replaces"], "https://ca/cert/1");
}
}