use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::PathBuf;
use std::sync::RwLock;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Scope {
pub hashes: BTreeSet<String>,
pub grants: 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) {
self.scopes
.entry(scope.to_string())
.or_default()
.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 allows(&self, hash_hex: &str, principals: &HashSet<&str>) -> bool {
self.scopes.values().any(|sc| {
sc.hashes.contains(hash_hex)
&& sc.grants.iter().any(|g| principals.contains(g.as_str()))
})
}
pub fn list(&self) -> Vec<(String, Vec<String>, Vec<String>)> {
self.scopes
.iter()
.map(|(name, sc)| {
(
name.clone(),
sc.hashes.iter().cloned().collect(),
sc.grants.iter().cloned().collect(),
)
})
.collect()
}
}
pub struct ScopeStore {
path: PathBuf,
inner: RwLock<BlobScopes>,
}
impl ScopeStore {
pub fn new(path: PathBuf) -> Self {
Self {
path,
inner: RwLock::new(BlobScopes::default()),
}
}
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),
})
}
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 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 snapshot = {
let mut g = self.inner.write().expect("scope lock not poisoned");
g.grant(scope, principal);
g.clone()
};
self.persist(&snapshot)
}
pub fn list(&self) -> Vec<(String, Vec<String>, Vec<String>)> {
self.snapshot().list()
}
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::*;
use std::collections::HashSet;
fn principals<'a>(names: &'a [&'a str]) -> HashSet<&'a str> {
names.iter().copied().collect()
}
#[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());
}
}