use std::sync::Mutex;
use crate::hashing::LookupKey;
use crate::secret::{CodeId, SubjectId};
use crate::state::{ClaimOutcome, classify_claim};
use crate::store::code::{ClaimRequest, CodeRecord, CodeStore, RedeemableCode};
use crate::store::error::StoreError;
#[derive(Debug, Clone)]
struct MemCodeRow {
id: CodeId,
lookup_keys: Vec<LookupKey>,
key_version: crate::hashing::KeyVersion,
purpose: Option<String>,
scope: Option<String>,
grant: Option<String>,
expires_at: u64,
used_at: Option<u64>,
revoked_at: Option<u64>,
used_by: Option<SubjectId>,
}
impl MemCodeRow {
fn is_redeemable_at(&self, now: u64, scope: Option<&str>) -> bool {
self.used_at.is_none()
&& self.revoked_at.is_none()
&& self.expires_at > now
&& match scope {
Some(s) => self.scope.as_deref() == Some(s),
None => true,
}
}
fn matches_any(&self, candidates: &[LookupKey]) -> bool {
candidates
.iter()
.any(|c| self.lookup_keys.iter().any(|k| k.ct_eq(c)))
}
}
#[derive(Debug, Default)]
pub struct MemCodeStore {
rows: Mutex<Vec<MemCodeRow>>,
}
impl MemCodeStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl CodeStore for MemCodeStore {
async fn find_redeemable(
&self,
candidates: &[LookupKey],
now: u64,
scope: Option<&str>,
) -> Result<Option<RedeemableCode>, StoreError> {
let rows = self
.rows
.lock()
.map_err(|e| StoreError::Backend(e.to_string()))?;
let found = rows
.iter()
.find(|r| r.is_redeemable_at(now, scope) && r.matches_any(candidates))
.map(|r| RedeemableCode {
id: r.id.clone(),
key_version: r.key_version.clone(),
grant: r.grant.clone(),
scope: r.scope.clone(),
expires_at: r.expires_at,
});
Ok(found)
}
async fn claim_code(&self, req: &ClaimRequest<'_>) -> Result<ClaimOutcome, StoreError> {
let mut rows = self
.rows
.lock()
.map_err(|e| StoreError::Backend(e.to_string()))?;
let mut changed = 0usize;
for row in rows.iter_mut() {
if &row.id == req.code_id
&& row.used_at.is_none()
&& row.revoked_at.is_none()
&& row.expires_at > req.now
&& req
.purpose
.is_none_or(|p| row.purpose.as_deref() == Some(p))
&& req.scope.is_none_or(|s| row.scope.as_deref() == Some(s))
{
row.used_at = Some(req.now);
row.used_by = Some(req.subject.clone());
changed += 1;
}
}
if changed > 1 {
return Err(StoreError::InvariantViolation(format!(
"claim_code changed {changed} rows for {:?}",
req.code_id
)));
}
Ok(classify_claim(changed))
}
async fn insert_code(&self, record: CodeRecord) -> Result<(), StoreError> {
let mut rows = self
.rows
.lock()
.map_err(|e| StoreError::Backend(e.to_string()))?;
let lk = record.lookup_key.clone();
if rows
.iter()
.any(|r| r.lookup_keys.iter().any(|k| k.ct_eq(&lk)))
{
return Err(StoreError::Backend(
"duplicate lookup key (unique constraint)".to_string(),
));
}
rows.push(MemCodeRow {
id: record.id,
lookup_keys: vec![record.lookup_key],
key_version: record.key_version,
purpose: record.purpose,
scope: record.scope,
grant: record.grant,
expires_at: record.expires_at,
used_at: None,
revoked_at: None,
used_by: None,
});
Ok(())
}
async fn revoke_code(
&self,
code_id: &CodeId,
scope: Option<&str>,
now: u64,
) -> Result<(), StoreError> {
let mut rows = self
.rows
.lock()
.map_err(|e| StoreError::Backend(e.to_string()))?;
for row in rows.iter_mut() {
if &row.id == code_id
&& row.used_at.is_none()
&& row.revoked_at.is_none()
&& scope.is_none_or(|s| row.scope.as_deref() == Some(s))
{
row.revoked_at = Some(now);
}
}
Ok(())
}
}