crates/gunnar-ui-server/src/state.rs
//! The BFF's small persistent state: opaque bytes under string keys.
//!
//! Three things live here and nothing else: the registered passkeys, the key
//! that MACs session cookies, and the session epoch that revokes them all at
//! once. The store stays ignorant of what any of them mean, which is why the
//! passkey code can change shape without a migration.
//!
//! redb rather than a file, because [`StateStore::get_or_init`] has to be atomic:
//! two requests racing to create the session key must not end up with two
//! different keys, one of which silently invalidates the other's cookies.
use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use std::sync::{Arc, Mutex, PoisonError, RwLock};
use redb::{Database, ReadableTable, ReadableTableMetadata, TableDefinition};
const AUTH: TableDefinition<&str, &[u8]> = TableDefinition::new("auth");
/// **A second, BOUNDED table, and the reason it is not the first one.**
///
/// [`AUTH`] holds three values that must never be dropped: lose the session key
/// and every cookie the console minted stops verifying. What goes in here is
/// the opposite — derived bytes that can always be made again, and that would
/// otherwise grow without a limit. Keeping them in the same table would put a
/// registered passkey one eviction away from being deleted, so they are two
/// tables and the eviction only ever walks this one.
///
/// Today its only tenant is the rendered README SVG (see
/// [`crate::readme::cache`]).
const CACHE: TableDefinition<&str, &[u8]> = TableDefinition::new("cache");
/// **A third table: what one PERSON asked the console to remember.**
///
/// Neither of the other two fits, and the difference is what the tables are
/// for. [`AUTH`] holds three values whose loss breaks the console, so nothing in
/// it is ever evicted; [`CACHE`] holds derived bytes that can always be made
/// again, so everything in it is. A preference is a third thing: losing one
/// costs a person one click and nothing else, but it must not be *evicted* by a
/// large repository's README — a setting that quietly forgets itself under load
/// is worse than one that was never offered.
///
/// Keyed `"<user>\u{1f}<setting>"`, the shape `reviewer::store_key` already uses
/// for a per-person record, so a second setting is a second key and never a
/// migration. The value is opaque bytes here, as everything in this file is; it
/// is [`crate::reading`] that knows one of them spells a way of reading
/// markdown.
const PREFS: TableDefinition<&str, &[u8]> = TableDefinition::new("prefs");
/// Insertion order over [`CACHE`]: tick -> key. The lowest tick is the oldest
/// entry and the next one to go.
const CACHE_ORDER: TableDefinition<u64, &str> = TableDefinition::new("cache_order");
/// key -> the tick it was inserted at, so a rewrite can retire its old place in
/// the queue instead of leaving a dangling one.
const CACHE_TICK: TableDefinition<&str, u64> = TableDefinition::new("cache_tick");
/// The running totals: `"tick"` is the next number to hand out, `"bytes"` is
/// what [`CACHE`] currently holds.
///
/// Kept rather than recomputed because the alternative is summing every value's
/// length on every insert, and the values here are hundreds of kilobytes each.
const CACHE_META: TableDefinition<&str, u64> = TableDefinition::new("cache_meta");
/// A persistent key-value corner for authentication state.
#[derive(Clone)]
pub struct StateStore {
db: Arc<Database>,
/// The auth table as redb holds it, for the keys asked about: what every
/// request reads (the epoch, a person's floor and session keys) is
/// answered from memory, not from the disk on an async worker.
auth: Arc<AuthMemory>,
/// How many read transactions [`StateStore::get`] has begun: the cost the
/// request path must not pay per request.
#[cfg(test)]
reads: Arc<std::sync::atomic::AtomicU64>,
}
impl fmt::Debug for StateStore {
/// Never renders values: they include the session MAC key.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StateStore").finish_non_exhaustive()
}
}
/// The state store could not be read or written.
#[derive(Debug)]
pub struct StateError {
what: String,
source: Box<dyn std::error::Error + Send + Sync>,
}
impl fmt::Display for StateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.what, self.source)
}
}
impl std::error::Error for StateError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self.source.as_ref())
}
}
fn err(
what: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> StateError {
StateError {
what: what.into(),
source: Box::new(source),
}
}
/// **The mode this file is created with, and why it is not the umask's.**
///
/// What is in here is the session MAC key (`auth::session_key`) and every
/// reviewer's ed25519 private key (`reviewer::signing_key`). Read the first and
/// you can mint a cookie for any user at any role; read the second and you can
/// sign an approval as a person who never saw the change.
///
/// `Database::create` opened it with whatever the process's umask allowed,
/// which in a container image that never sets one is **0644** — world readable.
/// Nothing about the file's contents says "secret" to anybody looking at a
/// directory listing, so the mode is stated here rather than inherited.
///
/// Both halves are needed and they are not the same half: `mode()` applies only
/// when the file is CREATED, so it protects a new deployment; `set_permissions`
/// tightens one that already exists, which is every deployment that ran before
/// this commit.
#[cfg(unix)]
const PRIVATE_FILE: u32 = 0o600;
/// The mode the directory holding it is created with. Same argument; a
/// traversable directory is how a file with a good mode is found.
#[cfg(unix)]
pub const PRIVATE_DIR: u32 = 0o700;
/// Create `path` and every missing parent, readable only by this user.
///
/// `std::fs::create_dir_all` takes no mode, so the mode is the umask's — and
/// `main` used it for the directory the state file lives in. This is the same
/// climb with a mode on every directory it makes.
///
/// # Errors
///
/// Fails if a directory cannot be created.
pub fn create_private_dir(path: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
std::fs::DirBuilder::new()
.recursive(true)
.mode(PRIVATE_DIR)
.create(path)
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(path)
}
}
/// Make sure the file at `path` exists and is this user's alone.
///
/// Called BEFORE redb opens it, so there is never a moment where the store is
/// on disk with a mode somebody else can read. See [`PRIVATE_FILE`].
#[cfg(unix)]
fn claim_the_file(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
// Created at 0600 rather than created and then chmodded: the gap between
// the two is short and it is still a gap.
std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.mode(PRIVATE_FILE)
.open(path)?;
// And a store that already exists keeps whatever mode it was made with
// until something changes it. This is that something.
std::fs::set_permissions(path, std::fs::Permissions::from_mode(PRIVATE_FILE))
}
/// **The auth table, remembered.** Coherent because nothing else writes it:
/// redb holds the file lock, so this process is its only writer, and every
/// write below goes through [`AuthMemory::writing`] and lands here after its
/// commit, overwriting whatever a racing reader filed.
#[derive(Default)]
struct AuthMemory {
/// key → its value, or `None` for a key redb does not hold.
rows: RwLock<HashMap<String, Option<Vec<u8>>>>,
/// Writers one at a time, so the memory is left in redb's commit order.
writing: Mutex<()>,
}
/// How many keys the memory holds before it stops remembering ABSENT ones:
/// a value is a row of the table and so bounded by it, an absence is bounded
/// only by what is asked about.
const REMEMBERED_KEYS: usize = 4096;
impl AuthMemory {
fn held(&self, key: &str) -> Option<Option<Vec<u8>>> {
self.rows.read().unwrap_or_else(PoisonError::into_inner).get(key).cloned()
}
/// File what a reader found, unless a writer filed something since.
fn found(&self, key: &str, value: Option<&[u8]>) {
let mut rows = self.rows.write().unwrap_or_else(PoisonError::into_inner);
if value.is_some() || rows.len() < REMEMBERED_KEYS {
rows.entry(key.to_owned()).or_insert_with(|| value.map(<[u8]>::to_vec));
}
}
/// File what a writer committed, or forget the key if the write failed.
fn wrote(&self, key: &str, value: Result<Option<&[u8]>, ()>) {
let mut rows = self.rows.write().unwrap_or_else(PoisonError::into_inner);
match value {
Ok(Some(value)) => {
rows.insert(key.to_owned(), Some(value.to_vec()));
}
Ok(None) if rows.contains_key(key) || rows.len() < REMEMBERED_KEYS => {
rows.insert(key.to_owned(), None);
}
Ok(None) | Err(()) => {
rows.remove(key);
}
}
}
fn write_lock(&self) -> std::sync::MutexGuard<'_, ()> {
self.writing.lock().unwrap_or_else(PoisonError::into_inner)
}
}
impl StateStore {
/// Open, creating the file if it is not there.
///
/// On unix the file is this user's alone — mode 0600 — whatever the
/// process's umask says; see [`PRIVATE_FILE`] for what is in it.
///
/// # Errors
///
/// Fails if the file cannot be created, secured or opened.
pub fn open(path: impl AsRef<Path>) -> Result<StateStore, StateError> {
#[cfg(unix)]
claim_the_file(path.as_ref())
.map_err(|e| err("cannot create the state store with a private file mode", e))?;
let db =
Database::create(path.as_ref()).map_err(|e| err("cannot open the state store", e))?;
let store = StateStore {
db: Arc::new(db),
auth: Arc::default(),
#[cfg(test)]
reads: Arc::default(),
};
// Materialise the table so a later read-only transaction does not fail
// on a database that has never been written.
let tx = store
.db
.begin_write()
.map_err(|e| err("cannot begin a write", e))?;
tx.open_table(AUTH)
.map_err(|e| err("cannot open the auth table", e))?;
tx.open_table(CACHE)
.map_err(|e| err("cannot open the cache table", e))?;
tx.open_table(PREFS)
.map_err(|e| err("cannot open the preferences table", e))?;
tx.open_table(CACHE_ORDER)
.map_err(|e| err("cannot open the cache order table", e))?;
tx.open_table(CACHE_TICK)
.map_err(|e| err("cannot open the cache tick table", e))?;
tx.open_table(CACHE_META)
.map_err(|e| err("cannot open the cache meta table", e))?;
tx.commit().map_err(|e| err("cannot commit", e))?;
Ok(store)
}
/// Read a value: from memory once redb has been asked about the key.
///
/// # Errors
///
/// Fails on a store-level read error (never remembered: the next read asks
/// redb again). A missing key is `Ok(None)`.
pub fn get(&self, key: &str) -> Result<Option<Vec<u8>>, StateError> {
if let Some(held) = self.auth.held(key) {
return Ok(held);
}
#[cfg(test)]
self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let tx = self
.db
.begin_read()
.map_err(|e| err("cannot begin a read", e))?;
let value = {
let table = tx
.open_table(AUTH)
.map_err(|e| err("cannot open the auth table", e))?;
let held = table
.get(key)
.map_err(|e| err(format!("cannot read {key:?}"), e))?;
held.map(|v| v.value().to_vec())
};
self.auth.found(key, value.as_deref());
Ok(value)
}
/// Write a value, replacing any previous one.
///
/// # Errors
///
/// Fails on a store-level write error.
pub fn put(&self, key: &str, value: &[u8]) -> Result<(), StateError> {
let _writing = self.auth.write_lock();
let written = self.put_in_redb(key, value);
self.auth.wrote(key, written.as_ref().map(|()| Some(value)).map_err(|_| ()));
written
}
fn put_in_redb(&self, key: &str, value: &[u8]) -> Result<(), StateError> {
let tx = self
.db
.begin_write()
.map_err(|e| err("cannot begin a write", e))?;
{
let mut table = tx
.open_table(AUTH)
.map_err(|e| err("cannot open the auth table", e))?;
table
.insert(key, value)
.map_err(|e| err(format!("cannot write {key:?}"), e))?;
}
tx.commit().map_err(|e| err("cannot commit", e))?;
Ok(())
}
/// Forget a value.
///
/// **A key that was not there is `Ok(false)`, not an error.** The one caller
/// is [`crate::tenant::TenantKeys::forget`], retiring a credential — and
/// "it was already gone" and "it is gone now" are the same state to a
/// revocation, which must be safe to repeat.
///
/// # Errors
///
/// Fails on a store-level write error.
pub fn remove(&self, key: &str) -> Result<bool, StateError> {
let _writing = self.auth.write_lock();
let removed = self.remove_in_redb(key);
self.auth.wrote(key, removed.as_ref().map(|_| None).map_err(|_| ()));
removed
}
fn remove_in_redb(&self, key: &str) -> Result<bool, StateError> {
let tx = self
.db
.begin_write()
.map_err(|e| err("cannot begin a write", e))?;
let had = {
let mut table = tx
.open_table(AUTH)
.map_err(|e| err("cannot open the auth table", e))?;
let gone = table
.remove(key)
.map_err(|e| err(format!("cannot remove {key:?}"), e))?;
// Scoped, so the borrowed value is dropped before `table` is: an
// `AccessGuard` holds the table open.
gone.is_some()
};
tx.commit().map_err(|e| err("cannot commit", e))?;
Ok(had)
}
/// Read a value, creating it from `make` if it is not there yet.
///
/// The read and the write share one transaction, so concurrent callers agree
/// on the value. They must: two session keys means one of them silently
/// invalidates every cookie the other minted.
///
/// # Errors
///
/// Fails on a store-level read or write error.
pub fn get_or_init(
&self,
key: &str,
make: impl FnOnce() -> Vec<u8>,
) -> Result<Vec<u8>, StateError> {
let _writing = self.auth.write_lock();
let held = self.get_or_init_in_redb(key, make);
self.auth.wrote(key, held.as_deref().map(Some).map_err(|_| ()));
held
}
fn get_or_init_in_redb(
&self,
key: &str,
make: impl FnOnce() -> Vec<u8>,
) -> Result<Vec<u8>, StateError> {
let tx = self
.db
.begin_write()
.map_err(|e| err("cannot begin a write", e))?;
let value = {
let mut table = tx
.open_table(AUTH)
.map_err(|e| err("cannot open the auth table", e))?;
// The read guard borrows the table, so it has to be gone before
// the insert below can take it mutably.
let existing = table
.get(key)
.map_err(|e| err(format!("cannot read {key:?}"), e))?
.map(|v| v.value().to_vec());
match existing {
Some(value) => value,
None => {
let fresh = make();
table
.insert(key, fresh.as_slice())
.map_err(|e| err(format!("cannot write {key:?}"), e))?;
fresh
}
}
};
tx.commit().map_err(|e| err("cannot commit", e))?;
Ok(value)
}
/// Forget everything remembered, so the next reads go to redb as every
/// read did before the memory existed. Tests and the bench only.
#[cfg(test)]
pub(crate) fn forget_for_tests(&self) {
self.auth.rows.write().unwrap_or_else(PoisonError::into_inner).clear();
}
/// How many times the store itself was read by [`StateStore::get`].
#[cfg(test)]
pub(crate) fn reads_for_tests(&self) -> u64 {
self.reads.load(std::sync::atomic::Ordering::SeqCst)
}
// ── One person's settings ───────────────────────────────────────────────
/// What this key was set to, if anybody set it.
///
/// # Errors
///
/// Fails on a store-level read error. An unset preference is `Ok(None)`,
/// which every caller reads as "the default" — a person who has never
/// pressed the button and a store that cannot be read are the same
/// situation from the reader's chair.
pub fn pref_get(&self, key: &str) -> Result<Option<Vec<u8>>, StateError> {
let tx = self
.db
.begin_read()
.map_err(|e| err("cannot begin a read", e))?;
let table = tx
.open_table(PREFS)
.map_err(|e| err("cannot open the preferences table", e))?;
let held = table
.get(key)
.map_err(|e| err(format!("cannot read {key:?}"), e))?;
Ok(held.map(|v| v.value().to_vec()))
}
/// Remember this key's value, replacing any previous one.
///
/// # Errors
///
/// Fails on a store-level write error.
pub fn pref_put(&self, key: &str, value: &[u8]) -> Result<(), StateError> {
let tx = self
.db
.begin_write()
.map_err(|e| err("cannot begin a write", e))?;
{
let mut table = tx
.open_table(PREFS)
.map_err(|e| err("cannot open the preferences table", e))?;
table
.insert(key, value)
.map_err(|e| err(format!("cannot write {key:?}"), e))?;
}
tx.commit().map_err(|e| err("cannot commit", e))?;
Ok(())
}
/// How many preferences are remembered, across everybody.
///
/// A count and not a listing: a preference key is `"<user>\u{1f}<setting>"`
/// and the users are the whole of it, so a listing would be a roster of
/// everyone who has ever changed a setting. [`crate::reset`] needs the
/// number to say what a `--all` would take, and nothing needs the names.
///
/// # Errors
///
/// Fails on a store-level read error.
pub fn pref_count(&self) -> Result<usize, StateError> {
let tx = self
.db
.begin_read()
.map_err(|e| err("cannot begin a read", e))?;
let table = tx
.open_table(PREFS)
.map_err(|e| err("cannot open the preferences table", e))?;
Ok(table
.len()
.map_err(|e| err("cannot count the preferences", e))? as usize)
}
// ── The bounded half ────────────────────────────────────────────────────
/// Read a cached value.
///
/// A read transaction and nothing else: no touch, no bookkeeping, no
/// commit. That is what makes a hit cheap enough to be worth having, and
/// it is why eviction is by insertion order — see [`crate::readme::cache`].
///
/// # Errors
///
/// Fails on a store-level read error. A missing key is `Ok(None)`.
pub fn cache_get(&self, key: &str) -> Result<Option<Vec<u8>>, StateError> {
let tx = self
.db
.begin_read()
.map_err(|e| err("cannot begin a read", e))?;
let table = tx
.open_table(CACHE)
.map_err(|e| err("cannot open the cache table", e))?;
let held = table
.get(key)
.map_err(|e| err(format!("cannot read {key:?}"), e))?;
Ok(held.map(|v| v.value().to_vec()))
}
/// Write a cached value, evicting the oldest entries until both caps hold.
///
/// The whole thing — insert, bookkeeping and eviction — is ONE transaction.
/// Two would leave a window in which the totals disagree with the table,
/// and the totals are what the eviction loop trusts; a crash between them
/// would leave a cache that believed it was empty and grew forever.
///
/// A value larger than `max_bytes` on its own is refused rather than
/// stored: keeping it would evict everything else and then still be over.
///
/// # Errors
///
/// Fails on a store-level read or write error.
pub fn cache_put(
&self,
key: &str,
value: &[u8],
max_entries: usize,
max_bytes: u64,
) -> Result<(), StateError> {
let size = value.len() as u64;
if size > max_bytes {
return Ok(());
}
let tx = self
.db
.begin_write()
.map_err(|e| err("cannot begin a write", e))?;
{
let mut cache = tx
.open_table(CACHE)
.map_err(|e| err("cannot open the cache table", e))?;
let mut order = tx
.open_table(CACHE_ORDER)
.map_err(|e| err("cannot open the cache order table", e))?;
let mut ticks = tx
.open_table(CACHE_TICK)
.map_err(|e| err("cannot open the cache tick table", e))?;
let mut meta = tx
.open_table(CACHE_META)
.map_err(|e| err("cannot open the cache meta table", e))?;
let mut next_tick = meta
.get("tick")
.map_err(|e| err("cannot read the cache tick", e))?
.map_or(0, |v| v.value());
let mut bytes = meta
.get("bytes")
.map_err(|e| err("cannot read the cache size", e))?
.map_or(0, |v| v.value());
// Replacing a key retires its old bytes and its old place in the
// queue, so the same README rendered twice does not count twice.
if let Some(old) = cache
.get(key)
.map_err(|e| err("cannot read the cache table", e))?
{
bytes = bytes.saturating_sub(old.value().len() as u64);
}
if let Some(old) = ticks
.get(key)
.map_err(|e| err("cannot read the cache tick table", e))?
{
let old = old.value();
order
.remove(old)
.map_err(|e| err("cannot retire a cache entry", e))?;
}
cache
.insert(key, value)
.map_err(|e| err(format!("cannot cache {key:?}"), e))?;
order
.insert(next_tick, key)
.map_err(|e| err("cannot record a cache entry", e))?;
ticks
.insert(key, next_tick)
.map_err(|e| err("cannot record a cache tick", e))?;
bytes += size;
next_tick += 1;
// Evict from the front until both caps hold. `first()` is the
// lowest tick, which is the oldest insertion.
loop {
let over = cache.len().map_err(|e| err("cannot count the cache", e))? as usize
> max_entries
|| bytes > max_bytes;
if !over {
break;
}
let Some((tick, victim)) = order
.first()
.map_err(|e| err("cannot read the cache order", e))?
.map(|(k, v)| (k.value(), v.value().to_owned()))
else {
break;
};
if let Some(gone) = cache
.remove(victim.as_str())
.map_err(|e| err("cannot evict a cache entry", e))?
{
bytes = bytes.saturating_sub(gone.value().len() as u64);
}
order
.remove(tick)
.map_err(|e| err("cannot evict a cache entry", e))?;
ticks
.remove(victim.as_str())
.map_err(|e| err("cannot evict a cache tick", e))?;
}
meta.insert("tick", next_tick)
.map_err(|e| err("cannot record the cache tick", e))?;
meta.insert("bytes", bytes)
.map_err(|e| err("cannot record the cache size", e))?;
}
tx.commit().map_err(|e| err("cannot commit", e))?;
Ok(())
}
/// What the bounded table currently holds.
///
/// # Errors
///
/// Fails on a store-level read error.
pub fn cache_stats(&self) -> Result<CacheStats, StateError> {
let tx = self
.db
.begin_read()
.map_err(|e| err("cannot begin a read", e))?;
let cache = tx
.open_table(CACHE)
.map_err(|e| err("cannot open the cache table", e))?;
let meta = tx
.open_table(CACHE_META)
.map_err(|e| err("cannot open the cache meta table", e))?;
Ok(CacheStats {
entries: cache.len().map_err(|e| err("cannot count the cache", e))? as usize,
bytes: meta
.get("bytes")
.map_err(|e| err("cannot read the cache size", e))?
.map_or(0, |v| v.value()),
})
}
/// **Every key in [`AUTH`], sorted.** Names only; no value is read.
///
/// The one caller is [`crate::reset`], which has to be able to SAY what it
/// is about to destroy before it destroys it. Values are deliberately left
/// behind: one of them is the key that MACs every session cookie, and a
/// listing that carried it would put it wherever the listing went.
///
/// # Errors
///
/// Fails on a store-level read error.
pub fn auth_keys(&self) -> Result<Vec<String>, StateError> {
let tx = self
.db
.begin_read()
.map_err(|e| err("cannot begin a read", e))?;
let table = tx
.open_table(AUTH)
.map_err(|e| err("cannot open the auth table", e))?;
let mut keys = Vec::new();
for row in table
.iter()
.map_err(|e| err("cannot walk the auth table", e))?
{
let (key, _) = row.map_err(|e| err("cannot read an auth row", e))?;
keys.push(key.value().to_owned());
}
keys.sort();
Ok(keys)
}
/// **Empty every table in one transaction**, and say how many rows went.
///
/// One transaction because the tables refer to each other: [`CACHE`],
/// [`CACHE_ORDER`], [`CACHE_TICK`] and [`CACHE_META`] are four views of one
/// structure, and a crash between two separate clears would leave an
/// eviction queue pointing at entries that are gone and a byte total for
/// bytes nobody holds. redb commits all of it or none of it.
///
/// The file itself is left in place rather than deleted and recreated,
/// which is the whole reason this exists as a method: `rm state.redb` on a
/// running console removes the name and leaves the open file behind, so
/// the process keeps serving from a store nobody can see, and the next
/// restart silently starts a second one. Emptying the tables is visible to
/// the process that has it open — and on a running console this call does
/// not get that far, because redb holds the file lock and
/// [`StateStore::open`] refuses.
///
/// # Errors
///
/// Fails on a store-level write error, having changed nothing.
pub fn clear(&self) -> Result<Cleared, StateError> {
let _writing = self.auth.write_lock();
let cleared = self.clear_in_redb();
self.auth.rows.write().unwrap_or_else(PoisonError::into_inner).clear();
cleared
}
fn clear_in_redb(&self) -> Result<Cleared, StateError> {
let tx = self
.db
.begin_write()
.map_err(|e| err("cannot begin a write", e))?;
let cleared = {
let mut auth = tx
.open_table(AUTH)
.map_err(|e| err("cannot open the auth table", e))?;
let mut cache = tx
.open_table(CACHE)
.map_err(|e| err("cannot open the cache table", e))?;
let mut prefs = tx
.open_table(PREFS)
.map_err(|e| err("cannot open the preferences table", e))?;
let mut order = tx
.open_table(CACHE_ORDER)
.map_err(|e| err("cannot open the cache order table", e))?;
let mut tick = tx
.open_table(CACHE_TICK)
.map_err(|e| err("cannot open the cache tick table", e))?;
let mut meta = tx
.open_table(CACHE_META)
.map_err(|e| err("cannot open the cache meta table", e))?;
let cleared = Cleared {
auth: auth.len().map_err(|e| err("cannot count the auth table", e))? as usize,
cache: cache.len().map_err(|e| err("cannot count the cache", e))? as usize,
prefs: prefs
.len()
.map_err(|e| err("cannot count the preferences", e))?
as usize,
};
// `retain(|_, _| false)` rather than collecting the keys and
// removing them one by one: the keys would be a second copy of the
// whole table in memory, and the cache's values are hundreds of
// kilobytes each.
auth.retain(|_, _| false)
.map_err(|e| err("cannot empty the auth table", e))?;
cache
.retain(|_, _| false)
.map_err(|e| err("cannot empty the cache", e))?;
prefs
.retain(|_, _| false)
.map_err(|e| err("cannot empty the preferences", e))?;
order
.retain(|_, _| false)
.map_err(|e| err("cannot empty the cache order", e))?;
tick.retain(|_, _| false)
.map_err(|e| err("cannot empty the cache ticks", e))?;
meta.retain(|_, _| false)
.map_err(|e| err("cannot empty the cache meta", e))?;
cleared
};
tx.commit().map_err(|e| err("cannot commit", e))?;
Ok(cleared)
}
}
/// How many rows each table held when it was emptied.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Cleared {
/// The passkeys, the session key, the epoch and the floors.
pub auth: usize,
/// Derived bytes that can always be made again.
pub cache: usize,
/// What people asked the console to remember.
pub prefs: usize,
}
/// How much the bounded table is holding.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CacheStats {
pub entries: usize,
pub bytes: u64,
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_store() -> (StateStore, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("a temp dir");
let store = StateStore::open(dir.path().join("state.redb")).expect("a fresh store opens");
(store, dir)
}
/// ★ **The file holding the session key and every reviewer's private key
/// is this user's alone.**
///
/// **Seen red.** `Database::create` opened it with the process's umask, so
/// in a container image that sets none the store is 0644 — world readable.
/// What is in it is the MAC key that mints session cookies and the ed25519
/// keys that sign approvals, and nothing about a `.redb` in a directory
/// listing says so.
#[cfg(unix)]
#[test]
fn the_state_file_is_readable_by_nobody_else() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("state.redb");
let _store = StateStore::open(&path).expect("a fresh store opens");
let mode = std::fs::metadata(&path)
.expect("it is there")
.permissions()
.mode()
& 0o777;
assert_eq!(
mode, PRIVATE_FILE,
"the session key and every reviewer's private key are at mode {mode:o}"
);
}
/// And a store that already exists at a loose mode is tightened when it is
/// opened — which is every deployment that ran before this.
#[cfg(unix)]
#[test]
fn a_world_readable_store_is_tightened_on_the_next_start() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("state.redb");
// A store from before, with what the umask gave it.
let existing = StateStore::open(&path).expect("a fresh store opens");
existing.put("k", b"v").expect("write");
drop(existing);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
.expect("loosen it the way a 0022 umask would have");
let reopened = StateStore::open(&path).expect("the store reopens");
assert_eq!(
std::fs::metadata(&path)
.expect("it is there")
.permissions()
.mode()
& 0o777,
PRIVATE_FILE
);
// And the tightening did not cost the contents.
assert_eq!(reopened.get("k").expect("read"), Some(b"v".to_vec()));
}
#[cfg(unix)]
#[test]
fn the_directory_it_lives_in_is_not_traversable_by_anybody_else() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temp dir");
let made = dir.path().join("var/lib/gunnar-ui");
create_private_dir(&made).expect("the climb");
assert_eq!(
std::fs::metadata(&made)
.expect("it is there")
.permissions()
.mode()
& 0o777,
PRIVATE_DIR,
"a good mode on the file does not survive a directory anybody can walk"
);
}
#[test]
fn a_written_value_reads_back() {
let (store, _dir) = temp_store();
store.put("k", b"v").expect("write");
assert_eq!(store.get("k").expect("read"), Some(b"v".to_vec()));
}
#[test]
fn a_missing_key_is_not_an_error() {
let (store, _dir) = temp_store();
assert_eq!(store.get("absent").expect("read"), None);
}
/// The memory of the auth table is redb's, whatever order writers and
/// readers race in: after the race, every key reads what redb holds.
#[test]
fn racing_writers_and_readers_leave_the_memory_as_redb_has_it() {
let (store, _dir) = temp_store();
let threads: Vec<_> = (0..8u8)
.map(|t| {
let store = store.clone();
std::thread::spawn(move || {
for i in 0..50u8 {
let key = format!("k{}", i % 4);
match (t + i) % 3 {
0 => store.put(&key, &[t, i]).expect("a write"),
1 => {
store.remove(&key).expect("a removal");
}
_ => {
store.get(&key).expect("a read");
}
}
}
})
})
.collect();
for thread in threads {
thread.join().expect("no panic");
}
for i in 0..4 {
let key = format!("k{i}");
let remembered = store.get(&key).expect("a read");
store.forget_for_tests();
assert_eq!(store.get(&key).expect("a read"), remembered, "{key}");
}
}
/// A key is read from redb once and answered from memory after (what a
/// request's reads cost; stinky #18).
#[test]
fn a_key_is_read_from_redb_once() {
let (store, _dir) = temp_store();
store.put("k", b"v").expect("a write");
store.forget_for_tests();
store.get("k").expect("a read");
let after_one = store.reads_for_tests();
for _ in 0..100 {
store.get("k").expect("a read");
}
assert_eq!(store.reads_for_tests(), after_one, "a hundred reads went to redb");
}
#[test]
fn a_write_through_any_clone_is_read_back_at_once() {
let (store, _dir) = temp_store();
let other = store.clone();
assert_eq!(store.get("epoch").expect("a read"), None);
other.put("epoch", b"1").expect("a write");
assert_eq!(store.get("epoch").expect("a read").as_deref(), Some(&b"1"[..]));
other.remove("epoch").expect("a removal");
assert_eq!(store.get("epoch").expect("a read"), None);
assert_eq!(other.get_or_init("epoch", || b"2".to_vec()).expect("made"), b"2");
assert_eq!(store.get("epoch").expect("a read").as_deref(), Some(&b"2"[..]));
store.clear().expect("emptied");
assert_eq!(other.get("epoch").expect("a read"), None);
}
#[test]
fn get_or_init_creates_once_and_then_keeps_it() {
let (store, _dir) = temp_store();
let first = store
.get_or_init("session_key", || vec![1, 2, 3])
.expect("init");
let second = store
.get_or_init("session_key", || vec![9, 9, 9])
.expect("second call");
assert_eq!(first, vec![1, 2, 3]);
assert_eq!(
second, first,
"a second key would invalidate every cookie the first minted"
);
}
/// ★ **A preference is its own table, and the README cache cannot evict
/// it.**
///
/// Red-first: filed in the bounded table beside the rendered pages, a
/// person's setting was one busy afternoon of README renders away from being
/// silently dropped — the cap is 256 entries and every repository anybody
/// opens takes one.
#[test]
fn a_remembered_setting_outlives_a_cache_that_fills_up() {
let (store, _dir) = temp_store();
store
.pref_put("alice\u{1f}markdown", b"source")
.expect("write");
for i in 0..(crate::readme::cache::MAX_ENTRIES + 8) {
store
.cache_put(&format!("page-{i}"), b"x", 256, 64 * 1024 * 1024)
.expect("a cached page");
}
assert_eq!(
store.pref_get("alice\u{1f}markdown").expect("read"),
Some(b"source".to_vec()),
"the cache evicted somebody's setting"
);
assert_eq!(store.pref_get("nobody\u{1f}markdown").expect("read"), None);
}
#[test]
fn state_survives_reopening() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("state.redb");
StateStore::open(&path)
.expect("open")
.put("k", b"v")
.expect("write");
let reopened = StateStore::open(&path).expect("reopen");
assert_eq!(reopened.get("k").expect("read"), Some(b"v".to_vec()));
}
}