use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::sync::Mutex;
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::Value;
use crate::canonical::{canonical_json, sha256_hex};
use crate::document::TrustTask;
use crate::error::RejectReason;
use crate::freshness::FreshnessPolicy;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct DocumentDigest(String);
impl DocumentDigest {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for DocumentDigest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
pub fn document_digest<P: Serialize>(
doc: &TrustTask<P>,
) -> Result<DocumentDigest, serde_json::Error> {
let value = serde_json::to_value(doc)?;
Ok(DocumentDigest(sha256_hex(
canonical_json(&value).as_bytes(),
)))
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ReplayVerdict {
Fresh,
Duplicate {
prior_response: Option<Value>,
in_flight: bool,
},
Conflict,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("replay record unavailable: {0}")]
pub struct ReplayGuardError(pub String);
impl From<ReplayGuardError> for RejectReason {
fn from(_: ReplayGuardError) -> Self {
RejectReason::Unavailable { retry_after: None }
}
}
#[async_trait::async_trait]
pub trait ReplayGuard: Send + Sync {
async fn claim(
&self,
id: &str,
digest: &DocumentDigest,
retain_until: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> Result<ReplayVerdict, ReplayGuardError>;
async fn record_response(
&self,
id: &str,
response: Option<&Value>,
) -> Result<(), ReplayGuardError> {
let _ = (id, response);
Ok(())
}
async fn release(&self, id: &str, digest: &DocumentDigest) -> Result<(), ReplayGuardError> {
let _ = (id, digest);
Ok(())
}
}
#[async_trait::async_trait]
impl<T: ReplayGuard + ?Sized> ReplayGuard for &T {
async fn claim(
&self,
id: &str,
digest: &DocumentDigest,
retain_until: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> Result<ReplayVerdict, ReplayGuardError> {
(**self).claim(id, digest, retain_until, now).await
}
async fn record_response(
&self,
id: &str,
response: Option<&Value>,
) -> Result<(), ReplayGuardError> {
(**self).record_response(id, response).await
}
async fn release(&self, id: &str, digest: &DocumentDigest) -> Result<(), ReplayGuardError> {
(**self).release(id, digest).await
}
}
#[non_exhaustive]
pub enum ReplayPolicy<'a> {
Guard(&'a dyn ReplayGuard),
NotConsequential,
}
pub struct InMemoryReplayGuard {
capacity: usize,
inner: Mutex<Inner>,
}
#[derive(Default)]
struct Inner {
entries: HashMap<String, Entry>,
recency: BTreeMap<u64, String>,
tick: u64,
}
struct Entry {
digest: DocumentDigest,
retain_until: Option<DateTime<Utc>>,
response: Option<Value>,
completed: bool,
tick: u64,
}
impl InMemoryReplayGuard {
pub fn new(capacity: usize) -> Self {
assert!(
capacity > 0,
"InMemoryReplayGuard capacity must be non-zero"
);
Self {
capacity,
inner: Mutex::new(Inner::default()),
}
}
pub fn len(&self) -> usize {
self.inner.lock().expect("replay guard mutex").entries.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn purge_expired(&self, now: DateTime<Utc>) {
let mut inner = self.inner.lock().expect("replay guard mutex");
let expired: Vec<String> = inner
.entries
.iter()
.filter(|(_, e)| e.is_expired_at(now))
.map(|(id, _)| id.clone())
.collect();
for id in expired {
inner.remove(&id);
}
}
}
impl Default for InMemoryReplayGuard {
fn default() -> Self {
Self::new(10_000)
}
}
impl Entry {
fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
matches!(self.retain_until, Some(t) if t <= now)
}
}
impl Inner {
fn remove(&mut self, id: &str) {
if let Some(entry) = self.entries.remove(id) {
self.recency.remove(&entry.tick);
}
}
fn touch(&mut self, id: &str) {
self.tick += 1;
let tick = self.tick;
if let Some(entry) = self.entries.get_mut(id) {
self.recency.remove(&entry.tick);
entry.tick = tick;
self.recency.insert(tick, id.to_string());
}
}
fn evict_to_capacity(&mut self, capacity: usize) {
while self.entries.len() > capacity {
let Some((_, victim)) = self.recency.iter().next().map(|(k, v)| (*k, v.clone())) else {
break;
};
self.remove(&victim);
}
}
}
#[async_trait::async_trait]
impl ReplayGuard for InMemoryReplayGuard {
async fn claim(
&self,
id: &str,
digest: &DocumentDigest,
retain_until: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> Result<ReplayVerdict, ReplayGuardError> {
let mut inner = self.inner.lock().expect("replay guard mutex");
if inner
.entries
.get(id)
.is_some_and(|entry| entry.is_expired_at(now))
{
inner.remove(id);
}
if let Some(entry) = inner.entries.get(id) {
let verdict = if &entry.digest == digest {
ReplayVerdict::Duplicate {
prior_response: entry.response.clone(),
in_flight: !entry.completed,
}
} else {
ReplayVerdict::Conflict
};
if matches!(verdict, ReplayVerdict::Duplicate { .. }) {
inner.touch(id);
}
return Ok(verdict);
}
inner.tick += 1;
let tick = inner.tick;
inner.entries.insert(
id.to_string(),
Entry {
digest: digest.clone(),
retain_until,
response: None,
completed: false,
tick,
},
);
inner.recency.insert(tick, id.to_string());
let capacity = self.capacity;
inner.evict_to_capacity(capacity);
Ok(ReplayVerdict::Fresh)
}
async fn record_response(
&self,
id: &str,
response: Option<&Value>,
) -> Result<(), ReplayGuardError> {
let mut inner = self.inner.lock().expect("replay guard mutex");
if let Some(entry) = inner.entries.get_mut(id) {
entry.response = response.cloned();
entry.completed = true;
}
Ok(())
}
async fn release(&self, id: &str, digest: &DocumentDigest) -> Result<(), ReplayGuardError> {
let mut inner = self.inner.lock().expect("replay guard mutex");
if inner
.entries
.get(id)
.is_some_and(|entry| &entry.digest == digest && !entry.completed)
{
inner.remove(id);
}
Ok(())
}
}
pub fn retain_until<P>(
doc: &TrustTask<P>,
policy: &FreshnessPolicy,
now: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
policy.record_expiry(doc, now)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::TypeUri;
use serde_json::json;
fn t(s: &str) -> DateTime<Utc> {
s.parse().unwrap()
}
fn doc(id: &str, payload: Value) -> TrustTask<Value> {
TrustTask::new(id, TypeUri::canonical("acl/grant", 0, 1).unwrap(), payload)
}
#[test]
fn digest_ignores_member_order_but_not_content() {
let a = doc("req-1", json!({"role": "admin", "subject": "alice"}));
let b = doc("req-1", json!({"subject": "alice", "role": "admin"}));
let c = doc("req-1", json!({"subject": "mallory", "role": "admin"}));
assert_eq!(document_digest(&a).unwrap(), document_digest(&b).unwrap());
assert_ne!(document_digest(&a).unwrap(), document_digest(&c).unwrap());
}
#[test]
fn digest_covers_the_proof_member() {
let mut signed = doc("req-1", json!({"role": "admin"}));
signed.proof = Some(crate::Proof {
proof_type: "DataIntegrityProof".into(),
cryptosuite: "eddsa-jcs-2022".into(),
verification_method: "did:web:org.example#key-1".into(),
created: t("2026-08-26T12:00:00Z"),
proof_purpose: "assertionMethod".into(),
proof_value: "zAAA".into(),
extra: Default::default(),
});
let mut resigned = signed.clone();
resigned.proof.as_mut().unwrap().proof_value = "zBBB".into();
let unsigned = doc("req-1", json!({"role": "admin"}));
assert_ne!(
document_digest(&signed).unwrap(),
document_digest(&resigned).unwrap(),
"a re-signed proof must make a different document (SPEC §8.4)"
);
assert_ne!(
document_digest(&signed).unwrap(),
document_digest(&unsigned).unwrap(),
"stripping the proof must not reproduce the item 11 identity"
);
}
#[tokio::test]
async fn first_arrival_is_fresh_and_the_identical_resend_is_a_duplicate() {
let guard = InMemoryReplayGuard::new(8);
let now = t("2026-08-26T12:00:00Z");
let d = doc("req-1", json!({"role": "admin"}));
let digest = document_digest(&d).unwrap();
assert_eq!(
guard.claim("req-1", &digest, None, now).await.unwrap(),
ReplayVerdict::Fresh
);
assert_eq!(
guard.claim("req-1", &digest, None, now).await.unwrap(),
ReplayVerdict::Duplicate {
prior_response: None,
in_flight: true,
}
);
}
#[tokio::test]
async fn a_recorded_response_is_returned_to_the_duplicate() {
let guard = InMemoryReplayGuard::new(8);
let now = t("2026-08-26T12:00:00Z");
let digest = document_digest(&doc("req-1", json!({}))).unwrap();
guard.claim("req-1", &digest, None, now).await.unwrap();
guard
.record_response("req-1", Some(&json!({"granted": true})))
.await
.unwrap();
assert_eq!(
guard.claim("req-1", &digest, None, now).await.unwrap(),
ReplayVerdict::Duplicate {
prior_response: Some(json!({"granted": true})),
in_flight: false,
}
);
}
#[tokio::test]
async fn differing_content_under_a_reused_id_conflicts() {
let guard = InMemoryReplayGuard::new(8);
let now = t("2026-08-26T12:00:00Z");
let first = document_digest(&doc("req-1", json!({"role": "reader"}))).unwrap();
let second = document_digest(&doc("req-1", json!({"role": "admin"}))).unwrap();
guard.claim("req-1", &first, None, now).await.unwrap();
assert_eq!(
guard.claim("req-1", &second, None, now).await.unwrap(),
ReplayVerdict::Conflict
);
assert_eq!(
guard.claim("req-1", &first, None, now).await.unwrap(),
ReplayVerdict::Duplicate {
prior_response: None,
in_flight: true,
}
);
}
#[tokio::test]
async fn the_record_is_released_once_its_retention_deadline_passes() {
let guard = InMemoryReplayGuard::new(8);
let issued = t("2026-08-26T12:00:00Z");
let expiry = t("2026-08-26T12:05:00Z");
let digest = document_digest(&doc("req-1", json!({}))).unwrap();
assert_eq!(
guard
.claim("req-1", &digest, Some(expiry), issued)
.await
.unwrap(),
ReplayVerdict::Fresh
);
assert!(matches!(
guard
.claim("req-1", &digest, Some(expiry), t("2026-08-26T12:04:59Z"))
.await
.unwrap(),
ReplayVerdict::Duplicate { .. }
));
assert_eq!(
guard
.claim("req-1", &digest, Some(expiry), expiry)
.await
.unwrap(),
ReplayVerdict::Fresh
);
}
#[tokio::test]
async fn purge_expired_reclaims_records() {
let guard = InMemoryReplayGuard::new(8);
let now = t("2026-08-26T12:00:00Z");
let digest = document_digest(&doc("req-1", json!({}))).unwrap();
guard
.claim("req-1", &digest, Some(t("2026-08-26T12:05:00Z")), now)
.await
.unwrap();
assert_eq!(guard.len(), 1);
guard.purge_expired(now);
assert_eq!(guard.len(), 1);
guard.purge_expired(t("2026-08-26T13:00:00Z"));
assert_eq!(guard.len(), 0);
}
#[tokio::test]
async fn capacity_evicts_the_least_recently_used_record() {
let guard = InMemoryReplayGuard::new(2);
let now = t("2026-08-26T12:00:00Z");
let d = |id: &str| document_digest(&doc(id, json!({}))).unwrap();
guard.claim("a", &d("a"), None, now).await.unwrap();
guard.claim("b", &d("b"), None, now).await.unwrap();
guard.claim("a", &d("a"), None, now).await.unwrap();
guard.claim("c", &d("c"), None, now).await.unwrap();
assert_eq!(guard.len(), 2);
assert!(matches!(
guard.claim("a", &d("a"), None, now).await.unwrap(),
ReplayVerdict::Duplicate { .. }
));
assert_eq!(
guard.claim("b", &d("b"), None, now).await.unwrap(),
ReplayVerdict::Fresh
);
}
#[tokio::test]
async fn release_frees_an_unfinished_claim_but_not_a_completed_one() {
let guard = InMemoryReplayGuard::new(8);
let now = t("2026-08-26T12:00:00Z");
let digest = document_digest(&doc("req-1", json!({}))).unwrap();
guard.claim("req-1", &digest, None, now).await.unwrap();
guard.release("req-1", &digest).await.unwrap();
assert_eq!(
guard.claim("req-1", &digest, None, now).await.unwrap(),
ReplayVerdict::Fresh
);
guard.record_response("req-1", None).await.unwrap();
guard.release("req-1", &digest).await.unwrap();
assert!(
matches!(
guard.claim("req-1", &digest, None, now).await.unwrap(),
ReplayVerdict::Duplicate { .. }
),
"a completed execution's record must survive a stray release"
);
}
#[test]
fn the_guard_is_object_safe() {
let guard = InMemoryReplayGuard::default();
let _erased: &dyn ReplayGuard = &guard;
let _boxed: Box<dyn ReplayGuard> = Box::new(InMemoryReplayGuard::new(4));
}
#[test]
fn a_store_outage_maps_to_unavailable_and_leaks_no_detail() {
let reason: RejectReason =
ReplayGuardError("redis://replay-1.internal: connection refused".into()).into();
assert_eq!(reason.code(), crate::StandardCode::Unavailable);
let wire = reason.wire_message();
assert!(
!wire.contains("redis"),
"wire message leaked the store: {wire}"
);
assert!(
!wire.contains("internal"),
"wire message leaked the host: {wire}"
);
}
}