use affinidi_status_list::{DEFAULT_BITSTRING_SIZE, StatusPurpose};
use serde::{Deserialize, Serialize};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use super::INITIAL_DECOY_FRACTION;
pub const STATUS_LIST_PREFIX: &[u8] = b"status_lists:";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StatusListState {
pub purpose: StatusPurpose,
pub capacity: usize,
#[serde(with = "hex_bytes")]
pub bits: Vec<u8>,
#[serde(with = "compact_bool_vec")]
pub assigned: Vec<bool>,
pub list_credential_id: String,
}
impl StatusListState {
pub fn new(purpose: StatusPurpose, list_credential_id: String) -> Self {
let capacity = DEFAULT_BITSTRING_SIZE;
Self {
purpose,
capacity,
bits: vec![0u8; capacity.div_ceil(8)],
assigned: vec![false; capacity],
list_credential_id,
}
}
pub fn is_set(&self, index: usize) -> bool {
let byte = index / 8;
let bit = 7 - (index % 8);
(self.bits[byte] >> bit) & 1 == 1
}
pub fn count_set(&self) -> usize {
self.bits.iter().map(|b| b.count_ones() as usize).sum()
}
pub fn count_assigned(&self) -> usize {
self.assigned.iter().filter(|a| **a).count()
}
pub fn initial_decoy_count(&self) -> usize {
(self.capacity as f64 * INITIAL_DECOY_FRACTION) as usize
}
}
fn purpose_key(purpose: StatusPurpose) -> Vec<u8> {
let mut k = STATUS_LIST_PREFIX.to_vec();
k.extend_from_slice(purpose.to_string().as_bytes());
k
}
pub async fn get_state(
ks: &KeyspaceHandle,
purpose: StatusPurpose,
) -> Result<Option<StatusListState>, AppError> {
let raw = ks.get_raw(purpose_key(purpose)).await?;
match raw {
Some(bytes) => Ok(Some(serde_json::from_slice(&bytes).map_err(|e| {
AppError::Internal(format!("StatusListState decode: {e}"))
})?)),
None => Ok(None),
}
}
pub async fn store_state(ks: &KeyspaceHandle, state: &StatusListState) -> Result<(), AppError> {
ks.insert(
String::from_utf8(purpose_key(state.purpose)).expect("status-list key is ASCII"),
state,
)
.await
}
pub async fn list_states(ks: &KeyspaceHandle) -> Result<Vec<StatusListState>, AppError> {
let pairs = ks.prefix_iter_raw(STATUS_LIST_PREFIX.to_vec()).await?;
let mut out = Vec::with_capacity(pairs.len());
for (_k, v) in pairs {
match serde_json::from_slice::<StatusListState>(&v) {
Ok(s) => out.push(s),
Err(err) => tracing::warn!(error = %err, "skipping unparseable status-list row"),
}
}
Ok(out)
}
pub async fn ensure_initial(
ks: &KeyspaceHandle,
purpose: StatusPurpose,
list_credential_id: String,
) -> Result<StatusListState, AppError> {
if let Some(existing) = get_state(ks, purpose).await? {
return Ok(existing);
}
let mut state = StatusListState::new(purpose, list_credential_id);
let decoy_count = state.initial_decoy_count();
super::allocator::add_initial_decoys(&mut state, decoy_count);
store_state(ks, &state).await?;
Ok(state)
}
mod hex_bytes {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
s.serialize_str(&hex::encode(bytes))
}
pub fn deserialize<'de, D>(d: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(d)?;
hex::decode(&s).map_err(serde::de::Error::custom)
}
}
mod compact_bool_vec {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
struct Packed {
len: usize,
#[serde(with = "super::hex_bytes")]
bits: Vec<u8>,
}
pub fn serialize<S>(v: &[bool], s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut bits = vec![0u8; v.len().div_ceil(8)];
for (i, b) in v.iter().enumerate() {
if *b {
bits[i / 8] |= 1 << (7 - (i % 8));
}
}
Packed { len: v.len(), bits }.serialize(s)
}
pub fn deserialize<'de, D>(d: D) -> Result<Vec<bool>, D::Error>
where
D: Deserializer<'de>,
{
let Packed { len, bits } = Packed::deserialize(d)?;
let mut v = vec![false; len];
for (i, slot) in v.iter_mut().enumerate() {
let byte = bits.get(i / 8).copied().unwrap_or(0);
*slot = (byte >> (7 - (i % 8))) & 1 == 1;
}
Ok(v)
}
}
#[cfg(test)]
mod tests {
use super::*;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
async fn temp_ks() -> (KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("store");
let ks = store.keyspace("status_lists").expect("ks");
(ks, dir)
}
#[tokio::test]
async fn round_trip_through_keyspace_preserves_bits_and_assigned() {
let (ks, _dir) = temp_ks().await;
let mut state = StatusListState::new(
StatusPurpose::Revocation,
"https://vtc.example.com/v1/status-lists/revocation".into(),
);
state.bits[0] = 0b10101010;
state.assigned[3] = true;
state.assigned[5] = true;
store_state(&ks, &state).await.unwrap();
let got = get_state(&ks, StatusPurpose::Revocation)
.await
.unwrap()
.unwrap();
assert_eq!(got.purpose, StatusPurpose::Revocation);
assert_eq!(got.capacity, state.capacity);
assert_eq!(got.bits[0], 0b10101010);
assert!(got.assigned[3]);
assert!(got.assigned[5]);
assert!(!got.assigned[4]);
assert_eq!(got.list_credential_id, state.list_credential_id);
}
#[tokio::test]
async fn ensure_initial_is_idempotent() {
let (ks, _dir) = temp_ks().await;
let a = ensure_initial(
&ks,
StatusPurpose::Revocation,
"https://vtc.example.com/v1/status-lists/revocation".into(),
)
.await
.unwrap();
let b = ensure_initial(
&ks,
StatusPurpose::Revocation,
"https://vtc.example.com/v1/status-lists/revocation".into(),
)
.await
.unwrap();
assert_eq!(a.bits, b.bits);
assert_eq!(a.assigned, b.assigned);
}
#[tokio::test]
async fn ensure_initial_seeds_decoys() {
let (ks, _dir) = temp_ks().await;
let state = ensure_initial(
&ks,
StatusPurpose::Revocation,
"https://vtc.example.com/v1/status-lists/revocation".into(),
)
.await
.unwrap();
let expected = state.initial_decoy_count();
let actual = state.count_set();
assert!(
actual >= (expected * 9) / 10,
"expected at least ~{expected} decoys, got {actual}"
);
assert_eq!(state.count_assigned(), 0);
}
#[test]
fn count_set_matches_bit_arithmetic() {
let mut state = StatusListState::new(StatusPurpose::Revocation, "id".into());
state.bits[0] = 0b11110000;
state.bits[1] = 0b00000011;
assert_eq!(state.count_set(), 6);
}
#[test]
fn compact_bool_vec_round_trips() {
let mut state = StatusListState::new(StatusPurpose::Revocation, "id".into());
for idx in [0usize, 7, 8, 9, state.capacity - 1] {
state.assigned[idx] = true;
}
let s = serde_json::to_string(&state).unwrap();
let back: StatusListState = serde_json::from_str(&s).unwrap();
assert_eq!(back.assigned, state.assigned);
}
#[tokio::test]
async fn revoked_index_survives_restart_and_is_not_reallocated() {
use crate::status_list::allocator::{allocate, flip};
let (ks, _dir) = temp_ks().await;
let mut state = StatusListState::new(
StatusPurpose::Revocation,
"https://vtc.example.com/v1/status-lists/revocation".into(),
);
state.capacity = 256;
state.bits = vec![0u8; state.capacity.div_ceil(8)];
state.assigned = vec![false; state.capacity];
let revoked = allocate(&mut state).expect("first allocate");
flip(&mut state, revoked, true).expect("flip");
store_state(&ks, &state).await.unwrap();
let mut reloaded = get_state(&ks, StatusPurpose::Revocation)
.await
.unwrap()
.unwrap();
let revoked_idx = revoked as usize;
assert!(
reloaded.assigned[revoked_idx],
"reloaded state lost the assigned mark for revoked slot"
);
let byte = revoked_idx / 8;
let bit = 7 - (revoked_idx % 8);
assert!(
reloaded.bits[byte] & (1 << bit) != 0,
"reloaded state lost the flipped revocation bit"
);
let mut handed_out = Vec::with_capacity(reloaded.capacity);
while let Some(idx) = allocate(&mut reloaded) {
assert_ne!(
idx, revoked,
"allocator reallocated the revoked slot — invariant broken"
);
handed_out.push(idx);
}
assert_eq!(
handed_out.len(),
reloaded.capacity - 1,
"expected to fill every slot except the revoked one"
);
assert!(
reloaded.bits[byte] & (1 << bit) != 0,
"revocation bit was cleared during reallocation drain"
);
}
}