use std::collections::BTreeMap;
use std::sync::Arc;
use crate::control::ControlStore;
use crate::doc::{impl_doc, DocRef, ShardedMap};
use crate::error::Result;
pub const ENTITLEMENT_REF: &str = "refs/gunnar/entitlements";
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub enum EntitlementState {
Free,
Paid,
Grace,
Suspended,
Retention,
}
impl EntitlementState {
pub fn name(self) -> &'static str {
match self {
EntitlementState::Free => "Free",
EntitlementState::Paid => "Paid",
EntitlementState::Grace => "Grace",
EntitlementState::Suspended => "Suspended",
EntitlementState::Retention => "Retention",
}
}
pub fn allows_write(self) -> bool {
!matches!(
self,
EntitlementState::Suspended | EntitlementState::Retention
)
}
pub fn allows_anonymous_read(self) -> bool {
self.allows_write()
}
}
impl std::fmt::Display for EntitlementState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entitlement {
pub namespace: String,
pub plan: String,
pub state: EntitlementState,
pub paid_until_unix_ms: Option<u64>,
#[serde(default)]
pub caps: BTreeMap<String, u64>,
pub source: String,
pub signature: Vec<u8>,
#[serde(default)]
pub issued_unix_ms: Option<u64>,
#[serde(default)]
pub issued_signature: Vec<u8>,
pub accepted_at_unix_ms: i64,
}
impl Entitlement {
pub fn pack_cap(&self) -> Option<u64> {
self.caps.get("pack_bytes").copied().filter(|cap| *cap > 0)
}
pub fn cache_cap(&self) -> Option<u64> {
self.caps.get("cache_bytes").copied()
}
}
#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EntitlementDoc {
#[serde(default)]
facts: ShardedMap<Entitlement>,
}
impl_doc!(EntitlementDoc { facts });
#[derive(Clone, Debug)]
pub struct EntitlementStore {
doc: DocRef<EntitlementDoc>,
}
impl EntitlementStore {
pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
Ok(EntitlementStore {
doc: DocRef::new(store, ENTITLEMENT_REF)?,
})
}
pub fn ref_name(&self) -> &str {
self.doc.ref_name()
}
pub fn get(&self, namespace: &str) -> Result<Option<Entitlement>> {
self.doc.load()?.facts.get(namespace)
}
pub fn all(&self) -> Result<Vec<Entitlement>> {
let mut facts = self.doc.load()?.facts.values()?;
facts.sort_by(|a, b| a.namespace.cmp(&b.namespace));
Ok(facts)
}
pub fn set(&self, fact: Entitlement) -> Result<Entitlement> {
let key = fact.namespace.clone();
self.doc.update(|doc| {
doc.facts.insert(key.clone(), fact.clone())?;
Ok(fact.clone())
})
}
pub fn remove(&self, namespace: &str) -> Result<bool> {
let key = namespace.to_owned();
self.doc.update(|doc| Ok(doc.facts.remove(&key)?.is_some()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::control::RedbControlStore;
fn fact(ns: &str, state: EntitlementState) -> Entitlement {
Entitlement {
namespace: ns.into(),
plan: "10gb".into(),
state,
paid_until_unix_ms: Some(1_790_812_800_000),
caps: BTreeMap::from([("pack_bytes".to_string(), 10 << 30)]),
source: "payment:invoice:ocr-1".into(),
signature: vec![7; 64],
issued_unix_ms: Some(1_789_000_000_000),
issued_signature: vec![8; 64],
accepted_at_unix_ms: 1,
}
}
#[test]
fn a_fresh_store_is_empty_and_every_namespace_is_free() {
let store =
EntitlementStore::new(Arc::new(RedbControlStore::in_memory().unwrap())).unwrap();
assert!(store.all().unwrap().is_empty());
assert_eq!(store.get("anyone").unwrap(), None);
}
#[test]
fn set_replaces_per_namespace_and_survives_a_reopen() {
let backing: Arc<dyn ControlStore> = Arc::new(RedbControlStore::in_memory().unwrap());
let store = EntitlementStore::new(Arc::clone(&backing)).unwrap();
store.set(fact("a", EntitlementState::Paid)).unwrap();
store.set(fact("b", EntitlementState::Paid)).unwrap();
store.set(fact("a", EntitlementState::Suspended)).unwrap();
let reopened = EntitlementStore::new(backing).unwrap();
let all = reopened.all().unwrap();
assert_eq!(all.len(), 2, "one fact per namespace: {all:?}");
assert_eq!(
all[0].state,
EntitlementState::Suspended,
"the LATEST fact for a"
);
assert_eq!(all[1].state, EntitlementState::Paid);
assert_eq!(
reopened.get("a").unwrap().unwrap().signature,
vec![7; 64],
"the signature is stored untouched"
);
assert!(reopened.remove("a").unwrap());
assert!(!reopened.remove("a").unwrap());
assert_eq!(reopened.get("a").unwrap(), None);
}
#[test]
fn the_ladder_closes_writes_and_anonymous_reads_in_the_same_two_states() {
use EntitlementState::*;
for (state, write) in [
(Free, true),
(Paid, true),
(Grace, true),
(Suspended, false),
(Retention, false),
] {
assert_eq!(state.allows_write(), write, "{state}");
assert_eq!(state.allows_anonymous_read(), write, "{state}");
}
assert_eq!(Suspended.name(), "Suspended");
let zero_cap = Entitlement {
caps: BTreeMap::from([("pack_bytes".to_string(), 0)]),
..fact("z", Free)
};
assert_eq!(
zero_cap.pack_cap(),
None,
"0 is unlimited, not a cap of nothing"
);
assert_eq!(fact("z", Free).pack_cap(), Some(10 << 30));
}
}