1use std::collections::HashSet;
29#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
30use std::sync::{Arc, Mutex};
31#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
32use std::time::Duration;
33
34use cid::Cid;
35use did_ma::Did;
36
37#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
38use crate::ipfs::{ipfs_add, name_publish_with_retry, IpnsPublishOptions};
39use crate::{Error, Result};
40#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
41use tokio::task::JoinHandle;
42#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
43use tokio::time::sleep;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
48enum Entry {
49 Any,
51 Allow(Did),
53 Deny(Did),
55 AllowFragment(String),
57 DenyFragment(String),
59}
60
61impl Entry {
62 fn parse(s: &str) -> Result<Self> {
63 let s = s.trim();
64 if s == "*" {
65 return Ok(Entry::Any);
66 }
67 if let Some(rest) = s.strip_prefix("!#") {
68 return Ok(Entry::DenyFragment(rest.to_owned()));
69 }
70 if let Some(rest) = s.strip_prefix('#') {
71 return Ok(Entry::AllowFragment(rest.to_owned()));
72 }
73 if let Some(rest) = s.strip_prefix('!') {
74 let did = Did::try_from(rest)
75 .map_err(|e| Error::Acl(format!("invalid DID in deny entry '{rest}': {e}")))?;
76 return Ok(Entry::Deny(did));
77 }
78 if s.starts_with("did:") {
79 let did =
80 Did::try_from(s).map_err(|e| Error::Acl(format!("invalid DID '{s}': {e}")))?;
81 return Ok(Entry::Allow(did));
82 }
83 Err(Error::Acl(format!("unrecognised ACL entry: '{s}'")))
84 }
85}
86
87#[derive(Debug, Clone, Default)]
90struct Compiled {
91 public: bool,
93 deny_identities: HashSet<String>,
95 deny_urls: HashSet<(String, String)>,
97 allow_identities: HashSet<String>,
99 allow_urls: HashSet<(String, String)>,
101 deny_fragments: HashSet<String>,
103 allow_fragments: HashSet<String>,
105}
106
107impl Compiled {
108 fn build(entries: &[Entry]) -> Self {
109 let mut c = Compiled::default();
110 for e in entries {
111 match e {
112 Entry::Any => c.public = true,
113 Entry::Allow(did) => {
114 if let Some(frag) = &did.fragment {
115 c.allow_urls.insert((did.ipns.clone(), frag.clone()));
116 } else {
117 c.allow_identities.insert(did.ipns.clone());
118 }
119 }
120 Entry::Deny(did) => {
121 if let Some(frag) = &did.fragment {
122 c.deny_urls.insert((did.ipns.clone(), frag.clone()));
123 } else {
124 c.deny_identities.insert(did.ipns.clone());
125 }
126 }
127 Entry::AllowFragment(f) => {
128 c.allow_fragments.insert(f.clone());
129 }
130 Entry::DenyFragment(f) => {
131 c.deny_fragments.insert(f.clone());
132 }
133 }
134 }
135 c
136 }
137}
138
139#[derive(Debug, Clone)]
145pub struct Acl {
146 entries: Vec<Entry>,
147 compiled: Compiled,
148 pub dirty: bool,
150 generation: u64,
151 pub cid: Option<Cid>,
153}
154
155impl Acl {
156 pub fn new_from_yaml(yaml: &str) -> Result<Self> {
166 #[derive(serde::Deserialize)]
167 struct Wrapper {
168 acl: Vec<String>,
169 }
170 let w: Wrapper =
171 serde_yaml::from_str(yaml).map_err(|e| Error::Acl(format!("YAML parse error: {e}")))?;
172
173 let entries: Result<Vec<Entry>> = w.acl.iter().map(|s| Entry::parse(s)).collect();
174 let entries = entries?;
175 let compiled = Compiled::build(&entries);
176 Ok(Self {
177 entries,
178 compiled,
179 dirty: true,
180 generation: 0,
181 cid: None,
182 })
183 }
184
185 pub fn new_from_cid(cid: Cid, data: &[u8]) -> Result<Self> {
193 let yaml = std::str::from_utf8(data)
194 .map_err(|e| Error::Acl(format!("ACL data is not UTF-8: {e}")))?;
195 let mut acl = Self::new_from_yaml(yaml)?;
196 acl.dirty = false;
197 acl.cid = Some(cid);
198 Ok(acl)
199 }
200
201 pub fn allow(&mut self, did_str: &str) -> Result<()> {
210 let entry = Entry::parse(did_str)?;
211 self.add_entry(entry)
212 }
213
214 pub fn deny(&mut self, did_str: &str) -> Result<()> {
223 let s = did_str.strip_prefix('!').unwrap_or(did_str);
225 let deny_str = if s.starts_with('#') || s.starts_with("did:") {
226 format!("!{s}")
227 } else {
228 return Err(Error::Acl(format!(
229 "cannot deny '{did_str}': not a DID or fragment"
230 )));
231 };
232 let entry = Entry::parse(&deny_str)?;
233 self.add_entry(entry)
234 }
235
236 #[allow(clippy::unnecessary_wraps)]
237 fn add_entry(&mut self, entry: Entry) -> Result<()> {
238 if !self.entries.contains(&entry) {
239 self.entries.push(entry);
240 self.compiled = Compiled::build(&self.entries);
241 self.dirty = true;
242 self.generation = self.generation.wrapping_add(1);
243 }
244 Ok(())
245 }
246
247 pub fn is_allowed(&self, did_str: &str) -> bool {
259 let c = &self.compiled;
260
261 if let Some(frag) = did_str.strip_prefix('#') {
263 if c.deny_fragments.contains(frag) {
264 return false;
265 }
266 if c.public {
267 return true;
268 }
269 return c.allow_fragments.contains(frag);
270 }
271
272 let did = match Did::try_from(did_str) {
274 Ok(d) => d,
275 Err(_) => return false,
276 };
277
278 if c.deny_identities.contains(&did.ipns) {
280 return false;
281 }
282
283 if let Some(ref frag) = did.fragment {
284 if c.deny_urls.contains(&(did.ipns.clone(), frag.clone())) {
286 return false;
287 }
288 if c.public {
289 return true;
290 }
291 if c.allow_urls.contains(&(did.ipns.clone(), frag.clone())) {
293 return true;
294 }
295 c.allow_identities.contains(&did.ipns)
296 } else {
297 if c.public {
299 return true;
300 }
301 c.allow_identities.contains(&did.ipns)
302 }
303 }
304
305 pub fn to_yaml(&self) -> Result<String> {
313 let strings: Vec<String> = self.entries.iter().map(entry_to_string).collect();
314 #[derive(serde::Serialize)]
315 struct Wrapper<'a> {
316 acl: &'a [String],
317 }
318 serde_yaml::to_string(&Wrapper { acl: &strings })
319 .map_err(|e| Error::Acl(format!("YAML serialisation error: {e}")))
320 }
321
322 pub fn mark_published(&mut self, cid: Cid, gen: u64) {
330 if gen == self.generation {
331 self.cid = Some(cid);
332 self.dirty = false;
333 }
334 }
335
336 pub fn generation(&self) -> u64 {
341 self.generation
342 }
343}
344
345#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
346#[derive(Debug)]
347pub struct AclPublishWorker {
348 kubo_url: String,
349 ipns_key_name: String,
350 retry_delay: Duration,
351 publish_task: Option<JoinHandle<()>>,
352}
353
354#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
355impl AclPublishWorker {
356 pub fn new(kubo_url: impl AsRef<str>, ipns_key_name: impl AsRef<str>) -> Result<Self> {
357 let kubo_url = kubo_url.as_ref().trim().to_string();
358 let ipns_key_name = ipns_key_name.as_ref().trim().to_string();
359
360 if kubo_url.is_empty() {
361 return Err(Error::Acl("kubo_url must not be empty".to_string()));
362 }
363 if ipns_key_name.is_empty() {
364 return Err(Error::Acl("ipns_key_name must not be empty".to_string()));
365 }
366
367 Ok(Self {
368 kubo_url,
369 ipns_key_name,
370 retry_delay: Duration::from_secs(2),
371 publish_task: None,
372 })
373 }
374
375 #[must_use]
376 pub fn with_retry_delay(mut self, retry_delay: Duration) -> Self {
377 self.retry_delay = retry_delay;
378 self
379 }
380
381 pub fn on_acl_changed(&mut self, acl: Arc<Mutex<Acl>>) {
382 if let Some(task) = self.publish_task.take() {
383 task.abort();
384 }
385
386 let kubo_url = self.kubo_url.clone();
387 let ipns_key_name = self.ipns_key_name.clone();
388 let retry_delay = self.retry_delay;
389
390 self.publish_task = Some(tokio::spawn(async move {
391 loop {
392 let snapshot = {
393 let guard = match acl.lock() {
394 Ok(guard) => guard,
395 Err(_) => return,
396 };
397
398 if !guard.dirty {
399 return;
400 }
401
402 let yaml = match guard.to_yaml() {
403 Ok(yaml) => yaml,
404 Err(_) => return,
405 };
406
407 (guard.generation(), yaml)
408 };
409
410 match publish_acl_once(&kubo_url, &ipns_key_name, &snapshot.1).await {
411 Ok(cid) => {
412 let mut guard = match acl.lock() {
413 Ok(guard) => guard,
414 Err(_) => return,
415 };
416 guard.mark_published(cid, snapshot.0);
417 if !guard.dirty {
418 return;
419 }
420 }
421 Err(_) => {
422 sleep(retry_delay).await;
423 }
424 }
425 }
426 }));
427 }
428}
429
430#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
431impl Drop for AclPublishWorker {
432 fn drop(&mut self) {
433 if let Some(task) = self.publish_task.take() {
434 task.abort();
435 }
436 }
437}
438
439#[cfg(all(not(target_arch = "wasm32"), feature = "kubo"))]
440async fn publish_acl_once(kubo_url: &str, ipns_key_name: &str, yaml: &str) -> Result<Cid> {
441 let cid_str = ipfs_add(kubo_url, yaml.as_bytes().to_vec())
442 .await
443 .map_err(|e| Error::Acl(format!("ACL IPFS add failed: {e}")))?;
444
445 name_publish_with_retry(
446 kubo_url,
447 ipns_key_name,
448 &cid_str,
449 &IpnsPublishOptions::default(),
450 3,
451 Duration::from_secs(1),
452 )
453 .await
454 .map_err(|e| Error::Acl(format!("ACL IPNS publish failed: {e}")))?;
455
456 cid_str
457 .parse::<Cid>()
458 .map_err(|e| Error::Acl(format!("invalid CID from IPFS add: {e}")))
459}
460
461fn entry_to_string(e: &Entry) -> String {
464 match e {
465 Entry::Any => "*".to_owned(),
466 Entry::Allow(did) => did.id(),
467 Entry::Deny(did) => format!("!{}", did.id()),
468 Entry::AllowFragment(f) => format!("#{f}"),
469 Entry::DenyFragment(f) => format!("!#{f}"),
470 }
471}
472
473#[cfg(test)]
476mod tests {
477 use super::*;
478
479 fn public_yaml() -> &'static str {
480 "acl:\n - \"*\"\n"
481 }
482
483 fn restricted_yaml() -> &'static str {
484 "acl:\n - \"did:ma:Qmalice\"\n - \"!did:ma:Qmeve\"\n"
485 }
486
487 #[test]
490 fn parse_public() {
491 let acl = Acl::new_from_yaml(public_yaml()).unwrap();
492 assert!(acl.compiled.public);
493 assert!(acl.dirty);
494 }
495
496 #[test]
497 fn parse_restricted() {
498 let acl = Acl::new_from_yaml(restricted_yaml()).unwrap();
499 assert!(!acl.compiled.public);
500 assert!(acl.compiled.allow_identities.contains("Qmalice"));
501 assert!(acl.compiled.deny_identities.contains("Qmeve"));
502 }
503
504 #[test]
505 fn parse_fragments() {
506 let yaml = "acl:\n - \"#read\"\n - \"!#write\"\n";
507 let acl = Acl::new_from_yaml(yaml).unwrap();
508 assert!(acl.compiled.allow_fragments.contains("read"));
509 assert!(acl.compiled.deny_fragments.contains("write"));
510 }
511
512 #[test]
513 fn parse_did_url() {
514 let yaml = "acl:\n - \"did:ma:Qmalice#edit\"\n";
515 let acl = Acl::new_from_yaml(yaml).unwrap();
516 assert!(acl
517 .compiled
518 .allow_urls
519 .contains(&("Qmalice".to_owned(), "edit".to_owned())));
520 }
521
522 #[test]
523 fn parse_unknown_entry_fails() {
524 let yaml = "acl:\n - \"ftp://bad\"\n";
525 assert!(Acl::new_from_yaml(yaml).is_err());
526 }
527
528 #[test]
531 fn public_allows_all() {
532 let acl = Acl::new_from_yaml(public_yaml()).unwrap();
533 assert!(acl.is_allowed("did:ma:Qmanyone#read"));
534 assert!(acl.is_allowed("did:ma:Qmanyone"));
535 }
536
537 #[test]
538 fn deny_identity_blocks_all_urls() {
539 let acl = Acl::new_from_yaml("acl:\n - \"*\"\n - \"!did:ma:Qmeve\"\n").unwrap();
540 assert!(!acl.is_allowed("did:ma:Qmeve"));
541 assert!(!acl.is_allowed("did:ma:Qmeve#read"));
542 }
543
544 #[test]
545 fn allow_identity_permits_urls() {
546 let acl = Acl::new_from_yaml(restricted_yaml()).unwrap();
547 assert!(acl.is_allowed("did:ma:Qmalice"));
548 assert!(acl.is_allowed("did:ma:Qmalice#edit"));
549 }
550
551 #[test]
552 fn unknown_identity_denied_in_restricted() {
553 let acl = Acl::new_from_yaml(restricted_yaml()).unwrap();
554 assert!(!acl.is_allowed("did:ma:Qmbob"));
555 }
556
557 #[test]
558 fn bare_fragment_allow_deny() {
559 let acl = Acl::new_from_yaml("acl:\n - \"#read\"\n - \"!#write\"\n").unwrap();
560 assert!(acl.is_allowed("#read"));
561 assert!(!acl.is_allowed("#write"));
562 assert!(!acl.is_allowed("#other"));
563 }
564
565 #[test]
568 fn allow_mutator_idempotent() {
569 let mut acl = Acl::new_from_yaml("acl: []\n").unwrap();
570 let gen0 = acl.generation();
571 acl.allow("did:ma:Qmbob").unwrap();
572 let gen1 = acl.generation();
573 acl.allow("did:ma:Qmbob").unwrap(); assert_eq!(acl.generation(), gen1);
575 assert!(gen1 > gen0);
576 assert!(acl.is_allowed("did:ma:Qmbob"));
577 }
578
579 #[test]
580 fn deny_mutator() {
581 let mut acl = Acl::new_from_yaml("acl:\n - \"*\"\n").unwrap();
582 acl.deny("did:ma:Qmeve").unwrap();
583 assert!(acl.dirty);
584 assert!(!acl.is_allowed("did:ma:Qmeve"));
585 }
586
587 #[test]
588 fn deny_mutator_with_bang_prefix() {
589 let mut acl = Acl::new_from_yaml("acl:\n - \"*\"\n").unwrap();
590 acl.deny("!did:ma:Qmeve").unwrap(); assert!(!acl.is_allowed("did:ma:Qmeve"));
592 }
593
594 #[test]
597 fn yaml_round_trip() {
598 let yaml = "acl:\n - \"*\"\n - did:ma:Qmalice\n - '!did:ma:Qmeve'\n";
599 let acl = Acl::new_from_yaml(yaml).unwrap();
600 let out = acl.to_yaml().unwrap();
601 let acl2 = Acl::new_from_yaml(&out).unwrap();
602 assert_eq!(acl.entries, acl2.entries);
604 }
605
606 #[test]
609 fn mark_published_clears_dirty() {
610 let mut acl = Acl::new_from_yaml(public_yaml()).unwrap();
611 let gen = acl.generation();
612 let cid: Cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4aq3b6b2x6xkhu"
613 .parse()
614 .unwrap();
615 acl.mark_published(cid, gen);
616 assert!(!acl.dirty);
617 assert!(acl.cid.is_some());
618 }
619
620 #[test]
621 fn mark_published_stale_gen_noop() {
622 let mut acl = Acl::new_from_yaml(public_yaml()).unwrap();
623 let old_gen = acl.generation();
624 acl.allow("did:ma:Qmbob").unwrap(); let cid: Cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4aq3b6b2x6xkhu"
626 .parse()
627 .unwrap();
628 acl.mark_published(cid, old_gen); assert!(acl.dirty);
630 assert!(acl.cid.is_none());
631 }
632}