use std::collections::HashSet;
#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
use std::sync::{Arc, Mutex};
#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
use std::time::Duration;
use cid::Cid;
use did_ma::Did;
#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
use crate::ipfs::{ipfs_add, name_publish_with_retry, IpnsPublishOptions};
use crate::{Error, Result};
#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
use tokio::task::JoinHandle;
#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
use tokio::time::sleep;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Entry {
Any,
Allow(Did),
Deny(Did),
AllowFragment(String),
DenyFragment(String),
}
impl Entry {
fn parse(s: &str) -> Result<Self> {
let s = s.trim();
if s == "*" {
return Ok(Entry::Any);
}
if let Some(rest) = s.strip_prefix("!#") {
return Ok(Entry::DenyFragment(rest.to_owned()));
}
if let Some(rest) = s.strip_prefix('#') {
return Ok(Entry::AllowFragment(rest.to_owned()));
}
if let Some(rest) = s.strip_prefix('!') {
let did = Did::try_from(rest)
.map_err(|e| Error::Acl(format!("invalid DID in deny entry '{rest}': {e}")))?;
return Ok(Entry::Deny(did));
}
if s.starts_with("did:") {
let did =
Did::try_from(s).map_err(|e| Error::Acl(format!("invalid DID '{s}': {e}")))?;
return Ok(Entry::Allow(did));
}
Err(Error::Acl(format!("unrecognised ACL entry: '{s}'")))
}
}
#[derive(Debug, Clone, Default)]
struct Compiled {
public: bool,
deny_identities: HashSet<String>,
deny_urls: HashSet<(String, String)>,
allow_identities: HashSet<String>,
allow_urls: HashSet<(String, String)>,
deny_fragments: HashSet<String>,
allow_fragments: HashSet<String>,
}
impl Compiled {
fn build(entries: &[Entry]) -> Self {
let mut c = Compiled::default();
for e in entries {
match e {
Entry::Any => c.public = true,
Entry::Allow(did) => {
if let Some(frag) = &did.fragment {
c.allow_urls.insert((did.ipns.clone(), frag.clone()));
} else {
c.allow_identities.insert(did.ipns.clone());
}
}
Entry::Deny(did) => {
if let Some(frag) = &did.fragment {
c.deny_urls.insert((did.ipns.clone(), frag.clone()));
} else {
c.deny_identities.insert(did.ipns.clone());
}
}
Entry::AllowFragment(f) => {
c.allow_fragments.insert(f.clone());
}
Entry::DenyFragment(f) => {
c.deny_fragments.insert(f.clone());
}
}
}
c
}
}
#[derive(Debug, Clone)]
pub struct Acl {
entries: Vec<Entry>,
compiled: Compiled,
pub dirty: bool,
generation: u64,
pub cid: Option<Cid>,
}
impl Acl {
pub fn new_from_yaml(yaml: &str) -> Result<Self> {
#[derive(serde::Deserialize)]
struct Wrapper {
acl: Vec<String>,
}
let w: Wrapper =
serde_yaml::from_str(yaml).map_err(|e| Error::Acl(format!("YAML parse error: {e}")))?;
let entries: Result<Vec<Entry>> = w.acl.iter().map(|s| Entry::parse(s)).collect();
let entries = entries?;
let compiled = Compiled::build(&entries);
Ok(Self {
entries,
compiled,
dirty: true,
generation: 0,
cid: None,
})
}
pub fn new_from_cid(cid: Cid, data: &[u8]) -> Result<Self> {
let yaml = std::str::from_utf8(data)
.map_err(|e| Error::Acl(format!("ACL data is not UTF-8: {e}")))?;
let mut acl = Self::new_from_yaml(yaml)?;
acl.dirty = false;
acl.cid = Some(cid);
Ok(acl)
}
pub fn allow(&mut self, did_str: &str) -> Result<()> {
let entry = Entry::parse(did_str)?;
self.add_entry(entry)
}
pub fn deny(&mut self, did_str: &str) -> Result<()> {
let s = did_str.strip_prefix('!').unwrap_or(did_str);
let deny_str = if s.starts_with('#') || s.starts_with("did:") {
format!("!{s}")
} else {
return Err(Error::Acl(format!(
"cannot deny '{did_str}': not a DID or fragment"
)));
};
let entry = Entry::parse(&deny_str)?;
self.add_entry(entry)
}
#[allow(clippy::unnecessary_wraps)]
fn add_entry(&mut self, entry: Entry) -> Result<()> {
if !self.entries.contains(&entry) {
self.entries.push(entry);
self.compiled = Compiled::build(&self.entries);
self.dirty = true;
self.generation = self.generation.wrapping_add(1);
}
Ok(())
}
pub fn is_allowed(&self, did_str: &str) -> bool {
let c = &self.compiled;
if let Some(frag) = did_str.strip_prefix('#') {
if c.deny_fragments.contains(frag) {
return false;
}
if c.public {
return true;
}
return c.allow_fragments.contains(frag);
}
let did = match Did::try_from(did_str) {
Ok(d) => d,
Err(_) => return false,
};
if c.deny_identities.contains(&did.ipns) {
return false;
}
if let Some(ref frag) = did.fragment {
if c.deny_urls.contains(&(did.ipns.clone(), frag.clone())) {
return false;
}
if c.public {
return true;
}
if c.allow_urls.contains(&(did.ipns.clone(), frag.clone())) {
return true;
}
c.allow_identities.contains(&did.ipns)
} else {
if c.public {
return true;
}
c.allow_identities.contains(&did.ipns)
}
}
pub fn to_yaml(&self) -> Result<String> {
let strings: Vec<String> = self.entries.iter().map(entry_to_string).collect();
#[derive(serde::Serialize)]
struct Wrapper<'a> {
acl: &'a [String],
}
serde_yaml::to_string(&Wrapper { acl: &strings })
.map_err(|e| Error::Acl(format!("YAML serialisation error: {e}")))
}
pub fn mark_published(&mut self, cid: Cid, gen: u64) {
if gen == self.generation {
self.cid = Some(cid);
self.dirty = false;
}
}
pub fn generation(&self) -> u64 {
self.generation
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
#[derive(Debug)]
pub struct AclPublishWorker {
kubo_url: String,
ipns_key_name: String,
retry_delay: Duration,
publish_task: Option<JoinHandle<()>>,
}
#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
impl AclPublishWorker {
pub fn new(kubo_url: impl AsRef<str>, ipns_key_name: impl AsRef<str>) -> Result<Self> {
let kubo_url = kubo_url.as_ref().trim().to_string();
let ipns_key_name = ipns_key_name.as_ref().trim().to_string();
if kubo_url.is_empty() {
return Err(Error::Acl("kubo_url must not be empty".to_string()));
}
if ipns_key_name.is_empty() {
return Err(Error::Acl("ipns_key_name must not be empty".to_string()));
}
Ok(Self {
kubo_url,
ipns_key_name,
retry_delay: Duration::from_secs(2),
publish_task: None,
})
}
#[must_use]
pub fn with_retry_delay(mut self, retry_delay: Duration) -> Self {
self.retry_delay = retry_delay;
self
}
pub fn on_acl_changed(&mut self, acl: Arc<Mutex<Acl>>) {
if let Some(task) = self.publish_task.take() {
task.abort();
}
let kubo_url = self.kubo_url.clone();
let ipns_key_name = self.ipns_key_name.clone();
let retry_delay = self.retry_delay;
self.publish_task = Some(tokio::spawn(async move {
loop {
let snapshot = {
let guard = match acl.lock() {
Ok(guard) => guard,
Err(_) => return,
};
if !guard.dirty {
return;
}
let yaml = match guard.to_yaml() {
Ok(yaml) => yaml,
Err(_) => return,
};
(guard.generation(), yaml)
};
match publish_acl_once(&kubo_url, &ipns_key_name, &snapshot.1).await {
Ok(cid) => {
let mut guard = match acl.lock() {
Ok(guard) => guard,
Err(_) => return,
};
guard.mark_published(cid, snapshot.0);
if !guard.dirty {
return;
}
}
Err(_) => {
sleep(retry_delay).await;
}
}
}
}));
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
impl Drop for AclPublishWorker {
fn drop(&mut self) {
if let Some(task) = self.publish_task.take() {
task.abort();
}
}
}
#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
async fn publish_acl_once(kubo_url: &str, ipns_key_name: &str, yaml: &str) -> Result<Cid> {
let cid_str = ipfs_add(kubo_url, yaml.as_bytes().to_vec())
.await
.map_err(|e| Error::Acl(format!("ACL IPFS add failed: {e}")))?;
name_publish_with_retry(
kubo_url,
ipns_key_name,
&cid_str,
&IpnsPublishOptions::default(),
3,
Duration::from_secs(1),
)
.await
.map_err(|e| Error::Acl(format!("ACL IPNS publish failed: {e}")))?;
cid_str
.parse::<Cid>()
.map_err(|e| Error::Acl(format!("invalid CID from IPFS add: {e}")))
}
fn entry_to_string(e: &Entry) -> String {
match e {
Entry::Any => "*".to_owned(),
Entry::Allow(did) => did.id(),
Entry::Deny(did) => format!("!{}", did.id()),
Entry::AllowFragment(f) => format!("#{f}"),
Entry::DenyFragment(f) => format!("!#{f}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn public_yaml() -> &'static str {
"acl:\n - \"*\"\n"
}
fn restricted_yaml() -> &'static str {
"acl:\n - \"did:ma:Qmalice\"\n - \"!did:ma:Qmeve\"\n"
}
#[test]
fn parse_public() {
let acl = Acl::new_from_yaml(public_yaml()).unwrap();
assert!(acl.compiled.public);
assert!(acl.dirty);
}
#[test]
fn parse_restricted() {
let acl = Acl::new_from_yaml(restricted_yaml()).unwrap();
assert!(!acl.compiled.public);
assert!(acl.compiled.allow_identities.contains("Qmalice"));
assert!(acl.compiled.deny_identities.contains("Qmeve"));
}
#[test]
fn parse_fragments() {
let yaml = "acl:\n - \"#read\"\n - \"!#write\"\n";
let acl = Acl::new_from_yaml(yaml).unwrap();
assert!(acl.compiled.allow_fragments.contains("read"));
assert!(acl.compiled.deny_fragments.contains("write"));
}
#[test]
fn parse_did_url() {
let yaml = "acl:\n - \"did:ma:Qmalice#edit\"\n";
let acl = Acl::new_from_yaml(yaml).unwrap();
assert!(acl
.compiled
.allow_urls
.contains(&("Qmalice".to_owned(), "edit".to_owned())));
}
#[test]
fn parse_unknown_entry_fails() {
let yaml = "acl:\n - \"ftp://bad\"\n";
assert!(Acl::new_from_yaml(yaml).is_err());
}
#[test]
fn public_allows_all() {
let acl = Acl::new_from_yaml(public_yaml()).unwrap();
assert!(acl.is_allowed("did:ma:Qmanyone#read"));
assert!(acl.is_allowed("did:ma:Qmanyone"));
}
#[test]
fn deny_identity_blocks_all_urls() {
let acl = Acl::new_from_yaml("acl:\n - \"*\"\n - \"!did:ma:Qmeve\"\n").unwrap();
assert!(!acl.is_allowed("did:ma:Qmeve"));
assert!(!acl.is_allowed("did:ma:Qmeve#read"));
}
#[test]
fn allow_identity_permits_urls() {
let acl = Acl::new_from_yaml(restricted_yaml()).unwrap();
assert!(acl.is_allowed("did:ma:Qmalice"));
assert!(acl.is_allowed("did:ma:Qmalice#edit"));
}
#[test]
fn unknown_identity_denied_in_restricted() {
let acl = Acl::new_from_yaml(restricted_yaml()).unwrap();
assert!(!acl.is_allowed("did:ma:Qmbob"));
}
#[test]
fn bare_fragment_allow_deny() {
let acl = Acl::new_from_yaml("acl:\n - \"#read\"\n - \"!#write\"\n").unwrap();
assert!(acl.is_allowed("#read"));
assert!(!acl.is_allowed("#write"));
assert!(!acl.is_allowed("#other"));
}
#[test]
fn allow_mutator_idempotent() {
let mut acl = Acl::new_from_yaml("acl: []\n").unwrap();
let gen0 = acl.generation();
acl.allow("did:ma:Qmbob").unwrap();
let gen1 = acl.generation();
acl.allow("did:ma:Qmbob").unwrap(); assert_eq!(acl.generation(), gen1);
assert!(gen1 > gen0);
assert!(acl.is_allowed("did:ma:Qmbob"));
}
#[test]
fn deny_mutator() {
let mut acl = Acl::new_from_yaml("acl:\n - \"*\"\n").unwrap();
acl.deny("did:ma:Qmeve").unwrap();
assert!(acl.dirty);
assert!(!acl.is_allowed("did:ma:Qmeve"));
}
#[test]
fn deny_mutator_with_bang_prefix() {
let mut acl = Acl::new_from_yaml("acl:\n - \"*\"\n").unwrap();
acl.deny("!did:ma:Qmeve").unwrap(); assert!(!acl.is_allowed("did:ma:Qmeve"));
}
#[test]
fn yaml_round_trip() {
let yaml = "acl:\n - \"*\"\n - did:ma:Qmalice\n - '!did:ma:Qmeve'\n";
let acl = Acl::new_from_yaml(yaml).unwrap();
let out = acl.to_yaml().unwrap();
let acl2 = Acl::new_from_yaml(&out).unwrap();
assert_eq!(acl.entries, acl2.entries);
}
#[test]
fn mark_published_clears_dirty() {
let mut acl = Acl::new_from_yaml(public_yaml()).unwrap();
let gen = acl.generation();
let cid: Cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4aq3b6b2x6xkhu"
.parse()
.unwrap();
acl.mark_published(cid, gen);
assert!(!acl.dirty);
assert!(acl.cid.is_some());
}
#[test]
fn mark_published_stale_gen_noop() {
let mut acl = Acl::new_from_yaml(public_yaml()).unwrap();
let old_gen = acl.generation();
acl.allow("did:ma:Qmbob").unwrap(); let cid: Cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4aq3b6b2x6xkhu"
.parse()
.unwrap();
acl.mark_published(cid, old_gen); assert!(acl.dirty);
assert!(acl.cid.is_none());
}
}