use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::PathBuf;
use std::sync::RwLock;
use anyhow::{Context, Result};
use redb::{Database, ReadableTable, TableDefinition};
use serde::{Deserialize, Serialize};
pub const DEFAULT_LIST_LIMIT: usize = 256;
pub const MAX_LIST_LIMIT: usize = 4096;
pub type ScopePageRow = (
String,
Vec<String>,
Vec<String>,
Vec<String>,
usize,
usize,
usize,
);
#[derive(Debug, Clone, Default)]
pub struct ListQuery {
pub scope: Option<String>,
pub hash: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
pub counts_only: bool,
}
#[derive(Debug, Clone)]
pub struct ScopePage {
pub rows: Vec<ScopePageRow>,
pub total: usize,
pub truncated: bool,
}
pub type ScopeRow = (String, Vec<String>, Vec<String>, Vec<String>);
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Scope {
pub hashes: BTreeSet<String>,
pub grants: BTreeSet<String>,
#[serde(default)]
pub withdrawn: BTreeSet<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlobScopes {
#[serde(default)]
pub scopes: BTreeMap<String, Scope>,
}
impl BlobScopes {
pub fn is_empty(&self) -> bool {
self.scopes.is_empty()
}
pub fn publish_hash(&mut self, scope: &str, hash_hex: &str) {
let sc = self.scopes.entry(scope.to_string()).or_default();
sc.withdrawn.remove(hash_hex);
sc.hashes.insert(hash_hex.to_string());
}
pub fn grant(&mut self, scope: &str, principal: &str) {
self.scopes
.entry(scope.to_string())
.or_default()
.grants
.insert(principal.to_string());
}
pub fn revoke_principals(&mut self, principals: &[String]) -> bool {
let mut changed = false;
for sc in self.scopes.values_mut() {
for p in principals {
changed |= sc.grants.remove(p);
}
}
changed
}
pub fn revoke_from_scope(&mut self, scope: &str, principals: &[String]) -> bool {
let Some(sc) = self.scopes.get_mut(scope) else {
return false;
};
let mut changed = false;
for p in principals {
changed |= sc.grants.remove(p);
}
changed
}
pub fn unpublish_hash(&mut self, scope: &str, hash_hex: &str) -> bool {
self.scopes.get_mut(scope).is_some_and(|sc| {
sc.withdrawn.insert(hash_hex.to_string());
sc.hashes.remove(hash_hex)
})
}
pub fn list_page(&self, q: &ListQuery) -> anyhow::Result<ScopePage> {
let want_hash = match q.hash.as_deref() {
Some(h) => Some(crate::blobs::parse_blob_hash(h)?.to_hex().to_string()),
None => None,
};
let matching: Vec<(&String, &Scope)> = self
.scopes
.iter()
.filter(|(name, sc)| {
q.scope.as_deref().is_none_or(|want| want == name.as_str())
&& want_hash.as_deref().is_none_or(|h| sc.hashes.contains(h))
})
.collect();
let total = matching.len();
let offset = q.offset.unwrap_or(0);
let limit = q.limit.unwrap_or(DEFAULT_LIST_LIMIT).min(MAX_LIST_LIMIT);
let rows: Vec<ScopePageRow> = matching
.into_iter()
.skip(offset)
.take(limit)
.map(|(name, sc)| {
let (h, g, w) = (sc.hashes.len(), sc.grants.len(), sc.withdrawn.len());
let take = |set: &BTreeSet<String>| -> Vec<String> {
if q.counts_only {
Vec::new()
} else {
set.iter().cloned().collect()
}
};
(
name.clone(),
take(&sc.hashes),
take(&sc.grants),
take(&sc.withdrawn),
h,
g,
w,
)
})
.collect();
let truncated = offset + rows.len() < total;
Ok(ScopePage {
rows,
total,
truncated,
})
}
pub fn is_withdrawn(&self, scope: &str, hash_hex: &str) -> bool {
self.scopes
.get(scope)
.is_some_and(|sc| sc.withdrawn.contains(hash_hex))
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.contains_key(scope)
}
pub fn allows(&self, hash_hex: &str, principals: &HashSet<&str>) -> bool {
self.scopes.values().any(|sc| {
!sc.withdrawn.contains(hash_hex)
&& sc.hashes.contains(hash_hex)
&& sc.grants.iter().any(|g| principals.contains(g.as_str()))
})
}
pub fn list(&self) -> Vec<ScopeRow> {
self.scopes
.iter()
.map(|(name, sc)| {
(
name.clone(),
sc.hashes.iter().cloned().collect(),
sc.grants.iter().cloned().collect(),
sc.withdrawn.iter().cloned().collect(),
)
})
.collect()
}
}
pub struct ScopeStore {
#[allow(dead_code)]
path: PathBuf,
inner: RwLock<BlobScopes>,
write_lock: std::sync::Mutex<()>,
db: Option<Database>,
}
const SCOPES: TableDefinition<&str, &[u8]> = TableDefinition::new("scopes");
pub(crate) static ROWS_WRITTEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
impl ScopeStore {
pub fn new(path: PathBuf) -> Self {
Self {
path,
inner: RwLock::new(BlobScopes::default()),
write_lock: std::sync::Mutex::new(()),
db: None, }
}
pub fn open(path: PathBuf) -> Result<Self> {
let sidecar = path.with_extension("json");
let db = Database::create(&path)
.with_context(|| format!("open blob scope store {}", path.display()))?;
{
let txn = db.begin_write()?;
txn.open_table(SCOPES)?;
txn.commit()?;
}
let fresh = {
let txn = db.begin_read()?;
txn.open_table(SCOPES)?.iter()?.next().is_none()
};
let mut scopes = BlobScopes::default();
if fresh && sidecar.exists() {
let bytes = std::fs::read(&sidecar)
.with_context(|| format!("read blob scopes {}", sidecar.display()))?;
scopes = serde_json::from_slice::<BlobScopes>(&bytes)
.with_context(|| format!("parse blob scopes {}", sidecar.display()))?;
let txn = db.begin_write()?;
{
let mut t = txn.open_table(SCOPES)?;
for (name, scope) in &scopes.scopes {
t.insert(name.as_str(), serde_json::to_vec(scope)?.as_slice())?;
}
}
txn.commit()?;
let keep = sidecar.with_extension("json.migrated");
std::fs::rename(&sidecar, &keep).with_context(|| {
format!(
"rename migrated sidecar {} -> {}",
sidecar.display(),
keep.display()
)
})?;
tracing::info!(
sidecar = %sidecar.display(), kept = %keep.display(),
"migrated blob scopes to the keyed store (#84c)"
);
} else {
if sidecar.exists() {
tracing::warn!(
sidecar = %sidecar.display(),
"a blob-scope sidecar sits beside a non-empty keyed store and is being IGNORED"
);
}
let txn = db.begin_read()?;
let t = txn.open_table(SCOPES)?;
for row in t.iter()? {
let (k, v) = row?;
scopes
.scopes
.insert(k.value().to_string(), serde_json::from_slice(v.value())?);
}
}
Ok(Self {
path,
inner: RwLock::new(scopes),
write_lock: std::sync::Mutex::new(()),
db: Some(db),
})
}
#[deprecated(note = "#84c: use `open`, which is keyed and O(log N) per mutation")]
#[allow(dead_code)]
pub fn load(path: PathBuf) -> Result<Self> {
let scopes = match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice::<BlobScopes>(&bytes)
.with_context(|| format!("parse blob scopes {}", path.display()))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => BlobScopes::default(),
Err(e) => {
return Err(anyhow::Error::new(e))
.with_context(|| format!("read blob scopes {}", path.display()));
}
};
Ok(Self {
path,
inner: RwLock::new(scopes),
write_lock: std::sync::Mutex::new(()),
db: None,
})
}
pub fn snapshot(&self) -> BlobScopes {
self.inner.read().expect("scope lock not poisoned").clone()
}
pub fn publish_hash(&self, scope: &str, hash_hex: &str) -> Result<()> {
let _w = self
.write_lock
.lock()
.expect("scope write lock not poisoned");
let snapshot = {
let mut g = self.inner.write().expect("scope lock not poisoned");
g.publish_hash(scope, hash_hex);
g.clone()
};
self.persist_scope(scope, snapshot.scopes.get(scope))
}
pub fn grant(&self, scope: &str, principal: &str) -> Result<()> {
let _w = self
.write_lock
.lock()
.expect("scope write lock not poisoned");
let snapshot = {
let mut g = self.inner.write().expect("scope lock not poisoned");
g.grant(scope, principal);
g.clone()
};
self.persist_scope(scope, snapshot.scopes.get(scope))
}
pub fn revoke_from_scope(&self, scope: &str, principals: &[String]) -> Result<bool> {
let _w = self
.write_lock
.lock()
.expect("scope write lock not poisoned");
let (changed, snapshot) = {
let mut g = self.inner.write().expect("scope lock not poisoned");
let changed = g.revoke_from_scope(scope, principals);
(changed, g.clone())
};
self.persist_scope(scope, snapshot.scopes.get(scope))?;
Ok(changed)
}
pub fn is_withdrawn(&self, scope: &str, hash_hex: &str) -> bool {
self.inner
.read()
.expect("scope lock not poisoned")
.is_withdrawn(scope, hash_hex)
}
pub fn has_scope(&self, scope: &str) -> bool {
self.inner
.read()
.expect("scope lock not poisoned")
.has_scope(scope)
}
pub fn unpublish_hash(&self, scope: &str, hash_hex: &str) -> Result<bool> {
let _w = self
.write_lock
.lock()
.expect("scope write lock not poisoned");
let (changed, snapshot) = {
let mut g = self.inner.write().expect("scope lock not poisoned");
let changed = g.unpublish_hash(scope, hash_hex);
(changed, g.clone())
};
self.persist_scope(scope, snapshot.scopes.get(scope))?;
Ok(changed)
}
pub fn revoke_principals(&self, principals: &[String]) -> Result<bool> {
let _w = self
.write_lock
.lock()
.expect("scope write lock not poisoned");
let (changed, snapshot) = {
let mut g = self.inner.write().expect("scope lock not poisoned");
let changed = g.revoke_principals(principals);
(changed, g.clone())
};
if changed {
self.persist(&snapshot)?;
}
Ok(changed)
}
pub fn list(&self) -> Vec<ScopeRow> {
self.inner.read().expect("scope lock not poisoned").list()
}
pub fn list_page(&self, q: &ListQuery) -> anyhow::Result<ScopePage> {
self.inner
.read()
.expect("scope lock not poisoned")
.list_page(q)
}
fn persist(&self, scopes: &BlobScopes) -> Result<()> {
let Some(db) = &self.db else { return Ok(()) };
let txn = db.begin_write()?;
{
let mut t = txn.open_table(SCOPES)?;
let stale: Vec<String> = t
.iter()?
.filter_map(|r| r.ok().map(|(k, _)| k.value().to_string()))
.filter(|k| !scopes.scopes.contains_key(k))
.collect();
for k in stale {
t.remove(k.as_str())?;
}
for (name, scope) in &scopes.scopes {
t.insert(name.as_str(), serde_json::to_vec(scope)?.as_slice())?;
ROWS_WRITTEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
txn.commit()?;
Ok(())
}
fn persist_scope(&self, name: &str, scope: Option<&Scope>) -> Result<()> {
let Some(db) = &self.db else { return Ok(()) };
let txn = db.begin_write()?;
{
let mut t = txn.open_table(SCOPES)?;
match scope {
Some(sc) => {
t.insert(name, serde_json::to_vec(sc)?.as_slice())?;
ROWS_WRITTEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
None => {
t.remove(name)?;
}
}
}
txn.commit()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_single_scope_mutation_does_not_rewrite_the_whole_table() {
let dir = tempfile::tempdir().unwrap();
let store = ScopeStore::open(dir.path().join("scopes.redb")).unwrap();
for i in 0..200 {
store
.publish_hash(&format!("file:{i:04}"), &format!("{i:064x}"))
.unwrap();
}
drop(store);
let store = ScopeStore::open(dir.path().join("scopes.redb")).unwrap();
assert_eq!(
store.snapshot().scopes.len(),
200,
"all scopes survive a reopen"
);
assert!(
!store
.snapshot()
.allows(&format!("{:064x}", 199), &HashSet::new()),
"no principal is granted by publishing alone"
);
let before = ROWS_WRITTEN.load(std::sync::atomic::Ordering::Relaxed);
store
.publish_hash("file:9999", &format!("{:064x}", 9999))
.unwrap();
let wrote = ROWS_WRITTEN.load(std::sync::atomic::Ordering::Relaxed) - before;
assert_eq!(
wrote, 1,
"publishing one more scope into a 200-scope table wrote {wrote} rows — a keyed write \
must cost ONE row, not a re-serialize of the whole table (#84c)"
);
}
#[test]
fn an_empty_store_left_by_a_failed_migration_still_migrates() {
let dir = tempfile::tempdir().unwrap();
let redb = dir.path().join("blob-scopes.redb");
let sidecar = dir.path().join("blob-scopes.json");
let mut scopes = BlobScopes::default();
let sc = scopes.scopes.entry("file:abc".to_string()).or_default();
sc.grants.insert("eid:deadbeef".to_string());
sc.withdrawn.insert("bb".repeat(32));
std::fs::write(&sidecar, serde_json::to_string(&scopes).unwrap()).unwrap();
{
let db = Database::create(&redb).unwrap();
let txn = db.begin_write().unwrap();
txn.open_table(SCOPES).unwrap();
txn.commit().unwrap();
}
let store = ScopeStore::open(redb).expect("open after a failed migration");
let snap = store.snapshot();
let got = snap
.scopes
.get("file:abc")
.expect("the retry must find the sidecar — an empty store is not a migrated store");
assert!(got.grants.contains("eid:deadbeef"), "grants recovered");
assert!(
got.withdrawn.contains(&"bb".repeat(32)),
"and withdrawals — losing a tombstone silently un-revokes a withdrawn hash (#107)"
);
assert!(
dir.path().join("blob-scopes.json.migrated").exists(),
"and the sidecar is kept, as on a first-time migration"
);
}
#[test]
fn an_old_sidecar_is_migrated_and_renamed_not_deleted() {
let dir = tempfile::tempdir().unwrap();
let redb = dir.path().join("blob-scopes.redb");
let sidecar = dir.path().join("blob-scopes.json");
let mut scopes = BlobScopes::default();
let sc = scopes.scopes.entry("file:abc".to_string()).or_default();
sc.hashes.insert("aa".repeat(32));
sc.grants.insert("eid:deadbeef".to_string());
sc.withdrawn.insert("bb".repeat(32));
std::fs::write(&sidecar, serde_json::to_string(&scopes).unwrap()).unwrap();
let store = ScopeStore::open(redb.clone()).unwrap();
let snap = store.snapshot();
let got = snap.scopes.get("file:abc").expect("the scope migrated");
assert!(got.hashes.contains(&"aa".repeat(32)), "hashes survive");
assert!(
got.grants.contains("eid:deadbeef"),
"GRANTS survive — this is authorization state"
);
assert!(
got.withdrawn.contains(&"bb".repeat(32)),
"withdrawals survive (#107)"
);
assert!(!sidecar.exists(), "the sidecar is moved aside");
assert!(
dir.path().join("blob-scopes.json.migrated").exists(),
"and KEPT — a downgrade after migration would otherwise find no sidecar and start with \
every grant and withdrawal invisible, which is worse than the state it replaced"
);
drop(store);
let again = ScopeStore::open(redb).unwrap();
assert_eq!(
again.snapshot().scopes.len(),
1,
"migration does not run twice"
);
}
use super::*;
#[test]
fn revoke_from_scope_touches_only_that_scope() {
let mut s = BlobScopes::default();
s.grant("photos", "b64u:alice");
s.grant("notes", "b64u:alice");
assert!(s.revoke_from_scope("photos", &["b64u:alice".to_string()]));
assert!(
!s.scopes["photos"].grants.contains("b64u:alice"),
"revoked from the named scope"
);
assert!(
s.scopes["notes"].grants.contains("b64u:alice"),
"the OTHER scope's grant must survive — this is not unpair hygiene"
);
assert!(!s.revoke_from_scope("photos", &["b64u:alice".to_string()]));
assert!(!s.revoke_from_scope("nope", &["b64u:alice".to_string()]));
}
#[test]
fn unpublish_denies_the_hash_without_touching_the_grant() {
let mut s = BlobScopes::default();
s.publish_hash("photos", "abc123");
s.grant("photos", "b64u:alice");
let who: HashSet<&str> = ["b64u:alice"].into_iter().collect();
assert!(s.allows("abc123", &who), "reachable before");
assert!(s.unpublish_hash("photos", "abc123"));
assert!(
!s.allows("abc123", &who),
"an unpublished hash must be unfetchable at once — no GC needed for the security half"
);
assert!(
s.scopes["photos"].grants.contains("b64u:alice"),
"the grant survives: access to the SCOPE is unchanged"
);
assert!(!s.unpublish_hash("photos", "abc123"), "idempotent");
}
#[test]
fn unpublish_is_scoped_and_leaves_other_references_live() {
let mut s = BlobScopes::default();
s.publish_hash("photos", "abc123");
s.publish_hash("backup", "abc123");
s.grant("backup", "b64u:alice");
s.unpublish_hash("photos", "abc123");
let who: HashSet<&str> = ["b64u:alice"].into_iter().collect();
assert!(
s.allows("abc123", &who),
"still reachable via the scope that still lists it"
);
assert!(
s.scopes["backup"].hashes.contains("abc123"),
"the other scope still lists it — unpublish is per-scope, never a global delete"
);
}
use std::collections::HashSet;
fn principals<'a>(names: &'a [&'a str]) -> HashSet<&'a str> {
names.iter().copied().collect()
}
#[test]
fn revoke_principals_strips_grants_and_denies_after() {
let mut s = BlobScopes::default();
let hash = "aa".repeat(32);
s.publish_hash("docs", &hash);
s.grant("docs", "eid:beef");
s.grant("docs", "team-eng");
assert!(
s.allows(&hash, &principals(&["eid:beef"])),
"granted before revoke"
);
assert!(s.revoke_principals(&["eid:beef".to_string()]));
assert!(
!s.allows(&hash, &principals(&["eid:beef"])),
"denied after revoke"
);
assert!(
s.allows(&hash, &principals(&["team-eng"])),
"an unrelated grant is untouched"
);
assert!(!s.revoke_principals(&["eid:beef".to_string()]));
}
#[test]
fn allows_requires_hash_in_scope_and_a_matching_grant() {
let mut s = BlobScopes::default();
s.publish_hash("docs", "aa".repeat(32).as_str());
s.grant("docs", "alice");
s.grant("docs", "team-eng");
assert!(s.allows(&"aa".repeat(32), &principals(&["alice"])));
assert!(s.allows(&"aa".repeat(32), &principals(&["bob", "team-eng"])));
assert!(!s.allows(&"aa".repeat(32), &principals(&["carol", "team-sales"])));
assert!(!s.allows(&"bb".repeat(32), &principals(&["alice"])));
assert!(!s.allows(&"aa".repeat(32), &principals(&[])));
}
#[test]
fn a_hash_in_one_scope_is_not_reachable_via_a_different_scopes_grant() {
let mut s = BlobScopes::default();
s.publish_hash("a", "cc".repeat(32).as_str());
s.grant("a", "alice");
s.grant("b", "bob");
assert!(!s.allows(&"cc".repeat(32), &principals(&["bob"])));
assert!(s.allows(&"cc".repeat(32), &principals(&["alice"])));
}
#[test]
fn store_persists_and_reloads_the_same_scopes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("blob-scopes.redb");
let store = ScopeStore::open(path.clone()).unwrap();
store.publish_hash("docs", &"dd".repeat(32)).unwrap();
store.grant("docs", "alice").unwrap();
drop(store);
let reloaded = ScopeStore::open(path).unwrap();
let snap = reloaded.snapshot();
assert!(snap.allows(&"dd".repeat(32), &principals(&["alice"])));
let listed = reloaded.list();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].0, "docs");
assert_eq!(listed[0].1, vec!["dd".repeat(32)]);
assert_eq!(listed[0].2, vec!["alice".to_string()]);
}
#[test]
fn loading_a_missing_sidecar_is_an_empty_store() {
let dir = tempfile::tempdir().unwrap();
let store = ScopeStore::open(dir.path().join("does-not-exist.redb")).unwrap();
assert!(store.snapshot().is_empty());
}
}
#[cfg(test)]
mod withdrawal_tests {
use super::*;
#[test]
fn unpublish_records_a_withdrawal_that_blocks_republish() {
let mut s = BlobScopes::default();
s.publish_hash("room", "aa");
s.grant("room", "b64u:alice");
assert!(s.unpublish_hash("room", "aa"), "the hash was there");
assert!(
s.is_withdrawn("room", "aa"),
"unpublish must record the withdrawal, not merely drop the hash"
);
}
#[test]
fn a_withdrawal_is_scoped_to_one_scope_and_one_hash() {
let mut s = BlobScopes::default();
s.publish_hash("a", "h1");
s.publish_hash("b", "h1");
s.publish_hash("a", "h2");
s.unpublish_hash("a", "h1");
assert!(s.is_withdrawn("a", "h1"));
assert!(!s.is_withdrawn("b", "h1"), "another scope is unaffected");
assert!(!s.is_withdrawn("a", "h2"), "another hash is unaffected");
}
#[test]
fn publishing_from_a_path_clears_the_withdrawal() {
let mut s = BlobScopes::default();
s.publish_hash("room", "aa");
s.unpublish_hash("room", "aa");
assert!(s.is_withdrawn("room", "aa"));
s.publish_hash("room", "aa");
assert!(
!s.is_withdrawn("room", "aa"),
"a deliberate publish-from-path is the un-withdraw"
);
}
#[test]
fn granting_a_principal_does_not_clear_a_withdrawal() {
let mut s = BlobScopes::default();
s.publish_hash("room", "aa");
s.unpublish_hash("room", "aa");
s.grant("room", "b64u:bob");
assert!(
s.is_withdrawn("room", "aa"),
"a grant must never un-withdraw — it names a principal, not a hash"
);
}
#[test]
fn a_withdrawal_survives_a_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("scopes.redb");
{
let store = ScopeStore::open(path.clone()).unwrap();
store.publish_hash("room", "aa").unwrap();
store.grant("room", "b64u:alice").unwrap();
store.unpublish_hash("room", "aa").unwrap();
assert!(store.is_withdrawn("room", "aa"));
}
let reloaded = ScopeStore::open(path).unwrap();
assert!(
reloaded.is_withdrawn("room", "aa"),
"the withdrawal must persist — a daemon restart must not silently un-revoke"
);
}
#[test]
fn a_pre_0_17_sidecar_loads_with_no_withdrawals() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("old.redb");
std::fs::write(
dir.path().join("old.json"),
r#"{"scopes":{"room":{"hashes":["aa"],"grants":["b64u:alice"]}}}"#,
)
.unwrap();
let store = ScopeStore::open(path).expect("an old sidecar still loads");
assert!(!store.is_withdrawn("room", "aa"), "nothing was withdrawn");
assert!(
store
.snapshot()
.allows("aa", &["b64u:alice"].into_iter().collect()),
"and the existing grant still works"
);
}
}
#[cfg(test)]
mod listing_tests {
use super::*;
fn table(n: usize) -> BlobScopes {
let mut s = BlobScopes::default();
for i in 0..n {
s.publish_hash(&format!("file:{i:04}"), &format!("{i:064x}"));
s.grant(&format!("file:{i:04}"), "b64u:alice");
}
s
}
#[test]
fn an_unfiltered_listing_reports_its_total_and_is_not_truncated() {
let page = table(5).list_page(&ListQuery::default()).unwrap();
assert_eq!(page.rows.len(), 5);
assert_eq!(page.total, 5);
assert!(!page.truncated, "5 scopes fit under any sane default");
}
#[test]
fn the_default_limit_truncates_and_says_so() {
let page = table(300).list_page(&ListQuery::default()).unwrap();
assert_eq!(page.rows.len(), DEFAULT_LIST_LIMIT, "default limit applies");
assert_eq!(page.total, 300, "total counts MATCHES, not returned rows");
assert!(
page.truncated,
"a clipped answer must announce itself — a caller that cannot tell is the silent \
wrong answer this repo keeps re-learning"
);
}
#[test]
fn offset_and_limit_page_without_overlap_or_gaps() {
let t = table(25);
let p1 = t
.list_page(&ListQuery {
limit: Some(10),
..Default::default()
})
.unwrap();
let p2 = t
.list_page(&ListQuery {
limit: Some(10),
offset: Some(10),
..Default::default()
})
.unwrap();
let n1: Vec<&String> = p1.rows.iter().map(|r| &r.0).collect();
let n2: Vec<&String> = p2.rows.iter().map(|r| &r.0).collect();
assert_eq!(n1.len(), 10);
assert_eq!(n2.len(), 10);
assert!(
n1.iter().all(|n| !n2.contains(n)),
"pages must be disjoint: {n1:?} vs {n2:?}"
);
let mut union: Vec<String> = n1.iter().chain(n2.iter()).map(|s| (*s).clone()).collect();
union.sort();
let expected: Vec<String> = (0..20).map(|i| format!("file:{i:04}")).collect();
assert_eq!(
union, expected,
"and together they are the first 20 in order"
);
}
#[test]
fn the_scope_filter_is_exact_not_a_prefix() {
let mut s = BlobScopes::default();
s.publish_hash("file:aa", "11");
s.publish_hash("file:aabb", "22");
let page = s
.list_page(&ListQuery {
scope: Some("file:aa".into()),
..Default::default()
})
.unwrap();
assert_eq!(page.rows.len(), 1);
assert_eq!(page.rows[0].0, "file:aa");
assert_eq!(page.total, 1, "and the total reflects the filter");
}
#[test]
fn the_hash_filter_normalizes_the_callers_rendering() {
let canonical = format!("{:064x}", 42);
let mut s = BlobScopes::default();
s.publish_hash("file:has-it", &canonical);
s.publish_hash("file:lacks-it", &format!("{:064x}", 43));
let parsed = crate::blobs::parse_blob_hash(&canonical).unwrap();
let base32 = data_encoding::BASE32_NOPAD
.encode(parsed.as_bytes())
.to_ascii_lowercase();
assert_ne!(base32, canonical, "the fixture must actually differ");
let page = s
.list_page(&ListQuery {
hash: Some(base32),
..Default::default()
})
.unwrap();
assert_eq!(page.rows.len(), 1, "the base32 rendering must match");
assert_eq!(page.rows[0].0, "file:has-it");
}
#[test]
fn a_malformed_hash_filter_errors_instead_of_matching_everything() {
let s = table(5);
for bad in ["abc", "", "not-a-hash", &format!("{:064x}\n", 1)] {
let got = s.list_page(&ListQuery {
hash: Some(bad.to_string()),
..Default::default()
});
assert!(
got.is_err(),
"{bad:?} must be refused, not treated as no-filter (would have returned all 5)"
);
}
}
#[test]
fn an_enormous_limit_is_clamped_to_the_ceiling() {
let big = table(MAX_LIST_LIMIT + 50);
let page = big
.list_page(&ListQuery {
limit: Some(usize::MAX),
..Default::default()
})
.unwrap();
assert_eq!(
page.rows.len(),
MAX_LIST_LIMIT,
"a caller cannot opt out of the cap"
);
assert_eq!(page.total, MAX_LIST_LIMIT + 50, "total is still honest");
assert!(page.truncated, "and it says it truncated");
}
#[test]
fn counts_only_omits_the_vectors_but_keeps_the_counts() {
let mut s = table(3);
s.unpublish_hash("file:0000", &format!("{:064x}", 0));
let page = s
.list_page(&ListQuery {
counts_only: true,
..Default::default()
})
.unwrap();
let row = page.rows.iter().find(|r| r.0 == "file:0000").unwrap();
assert!(row.1.is_empty(), "hashes omitted");
assert!(row.2.is_empty(), "grants omitted");
assert!(row.3.is_empty(), "withdrawn omitted");
assert_eq!(row.4, 0, "hash_count: the one hash was withdrawn");
assert_eq!(row.5, 1, "but the grant count survives");
assert_eq!(row.6, 1, "and the withdrawn count");
}
}