use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::PathBuf;
use std::sync::RwLock;
use anyhow::{Context, Result};
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 {
path: PathBuf,
inner: RwLock<BlobScopes>,
write_lock: std::sync::Mutex<()>,
}
impl ScopeStore {
pub fn new(path: PathBuf) -> Self {
Self {
path,
inner: RwLock::new(BlobScopes::default()),
write_lock: std::sync::Mutex::new(()),
}
}
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(()),
})
}
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(&snapshot)
}
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(&snapshot)
}
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(&snapshot)?;
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(&snapshot)?;
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 json = serde_json::to_string_pretty(scopes).context("serialize blob scopes")?;
crate::roster::atomic_write_str(&self.path, &json)
}
}
#[cfg(test)]
mod tests {
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.json");
let store = ScopeStore::new(path.clone());
store.publish_hash("docs", &"dd".repeat(32)).unwrap();
store.grant("docs", "alice").unwrap();
let reloaded = ScopeStore::load(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::load(dir.path().join("does-not-exist.json")).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.json");
{
let store = ScopeStore::new(path.clone());
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::load(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.json");
std::fs::write(
&path,
r#"{"scopes":{"room":{"hashes":["aa"],"grants":["b64u:alice"]}}}"#,
)
.unwrap();
let store = ScopeStore::load(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");
}
}