1use std::{
8 collections::{HashMap, HashSet},
9 sync::{Arc, RwLock},
10 time::{SystemTime, UNIX_EPOCH},
11};
12
13use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
14use iroh_blobs::Hash;
15use iroh_docs::NamespaceId;
16use serde::{Deserialize, Serialize};
17
18use crate::{
19 error::{Result, SyncwebError},
20 indexing::{CatalogRecord, IndexingDatabase},
21};
22
23const FILTER_LIST_CONTEXT: &[u8] = b"syncweb/filter-list/v1\0";
24
25#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
27#[non_exhaustive]
28pub enum DenylistRule {
29 Device(String),
30 File {
31 namespace_id: Option<NamespaceId>,
32 key: Vec<u8>,
33 },
34 Hash(Hash),
35}
36
37impl DenylistRule {
38 #[must_use]
39 pub fn device<D: ToString + ?Sized>(device: &D) -> Self {
40 Self::Device(device.to_string())
41 }
42
43 #[must_use]
44 pub fn file(key: impl AsRef<[u8]>) -> Self {
45 Self::File {
46 namespace_id: None,
47 key: key.as_ref().to_vec(),
48 }
49 }
50
51 #[must_use]
52 pub fn folder_file(namespace_id: NamespaceId, key: impl AsRef<[u8]>) -> Self {
53 Self::File {
54 namespace_id: Some(namespace_id),
55 key: key.as_ref().to_vec(),
56 }
57 }
58
59 #[must_use]
60 pub const fn hash(hash: Hash) -> Self {
61 Self::Hash(hash)
62 }
63}
64
65#[derive(Clone, Debug, Default, Eq, PartialEq)]
67#[non_exhaustive]
68pub struct FilterContext {
69 pub device: Option<String>,
70 pub namespace_id: Option<NamespaceId>,
71 pub key: Option<Vec<u8>>,
72 pub hash: Option<Hash>,
73}
74
75impl FilterContext {
76 #[must_use]
77 pub fn new(hash: Hash) -> Self {
78 Self {
79 hash: Some(hash),
80 ..Self::default()
81 }
82 }
83
84 #[must_use]
85 pub fn for_file(namespace_id: NamespaceId, key: impl AsRef<[u8]>, hash: Hash) -> Self {
86 Self {
87 namespace_id: Some(namespace_id),
88 key: Some(key.as_ref().to_vec()),
89 hash: Some(hash),
90 ..Self::default()
91 }
92 }
93
94 #[must_use]
95 pub fn with_device<D: ToString + ?Sized>(mut self, device: &D) -> Self {
96 self.device = Some(device.to_string());
97 self
98 }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq)]
103#[non_exhaustive]
104pub enum DenyReason {
105 Device(String),
106 File(Vec<u8>),
107 Hash(Hash),
108}
109
110#[derive(Clone, Debug, Eq, PartialEq)]
112#[non_exhaustive]
113pub struct Denied {
114 pub reason: DenyReason,
115}
116
117impl std::fmt::Display for Denied {
118 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 match &self.reason {
120 DenyReason::Device(device) => write!(formatter, "device is denylisted: {device}"),
121 DenyReason::File(key) => write!(formatter, "file is denylisted: {}", String::from_utf8_lossy(key)),
122 DenyReason::Hash(hash) => write!(formatter, "content hash is denylisted: {hash}"),
123 }
124 }
125}
126
127#[derive(Clone, Debug, Default)]
129pub struct Denylist {
130 devices: HashSet<String>,
131 files: HashSet<(Option<NamespaceId>, Vec<u8>)>,
132 hashes: HashSet<Hash>,
133 federated: HashMap<NamespaceId, u64>,
134}
135
136impl Denylist {
137 #[must_use]
138 pub fn new() -> Self {
139 Self::default()
140 }
141
142 pub fn add(&mut self, rule: DenylistRule) {
143 match rule {
144 DenylistRule::Device(device) => {
145 self.devices.insert(device);
146 }
147 DenylistRule::File { namespace_id, key } => {
148 self.files.insert((namespace_id, key));
149 }
150 DenylistRule::Hash(hash) => {
151 self.hashes.insert(hash);
152 }
153 }
154 }
155
156 #[must_use]
157 pub fn remove(&mut self, rule: &DenylistRule) -> bool {
158 match rule {
159 DenylistRule::Device(device) => self.devices.remove(device),
160 DenylistRule::File { namespace_id, key } => self.files.remove(&(*namespace_id, key.clone())),
161 DenylistRule::Hash(hash) => self.hashes.remove(hash),
162 }
163 }
164
165 pub fn block_device<D: ToString + ?Sized>(&mut self, device: &D) {
166 self.devices.insert(device.to_string());
167 }
168
169 pub fn unblock_device<D: ToString + ?Sized>(&mut self, device: &D) -> bool {
170 self.devices.remove(&device.to_string())
171 }
172
173 pub fn block_file(&mut self, key: impl AsRef<[u8]>) {
174 self.files.insert((None, key.as_ref().to_vec()));
175 }
176
177 pub fn unblock_file(&mut self, key: impl AsRef<[u8]>) -> bool {
178 self.files.remove(&(None, key.as_ref().to_vec()))
179 }
180
181 pub fn block_folder_file(&mut self, namespace_id: NamespaceId, key: impl AsRef<[u8]>) {
182 self.files.insert((Some(namespace_id), key.as_ref().to_vec()));
183 }
184
185 pub fn unblock_folder_file(&mut self, namespace_id: NamespaceId, key: impl AsRef<[u8]>) -> bool {
186 self.files.remove(&(Some(namespace_id), key.as_ref().to_vec()))
187 }
188
189 pub fn block_hash(&mut self, hash: Hash) {
190 self.hashes.insert(hash);
191 }
192
193 pub fn unblock_hash(&mut self, hash: &Hash) -> bool {
194 self.hashes.remove(hash)
195 }
196
197 #[must_use]
198 pub fn is_device_blocked<D: ToString + ?Sized>(&self, device: &D) -> bool {
199 self.devices.contains(&device.to_string())
200 }
201
202 #[must_use]
203 pub fn is_file_blocked(&self, namespace_id: Option<NamespaceId>, key: impl AsRef<[u8]>) -> bool {
204 let key_bytes = key.as_ref();
205 self.files.contains(&(None, key_bytes.to_vec()))
206 || namespace_id.is_some_and(|namespace| self.files.contains(&(Some(namespace), key_bytes.to_vec())))
207 }
208
209 #[must_use]
210 pub fn is_hash_blocked(&self, hash: &Hash) -> bool {
211 self.hashes.contains(hash)
212 }
213
214 #[must_use]
216 pub fn is_blocked(&self, context: &FilterContext) -> bool {
217 self.matching_rule(context).is_some()
218 }
219
220 #[must_use]
222 pub fn matching_rule(&self, context: &FilterContext) -> Option<DenyReason> {
223 if let Some(device) = context.device.as_deref()
224 && self.devices.contains(device)
225 {
226 return Some(DenyReason::Device(device.to_owned()));
227 }
228 if let Some(key) = context.key.as_deref()
229 && self.is_file_blocked(context.namespace_id, key)
230 {
231 return Some(DenyReason::File(key.to_vec()));
232 }
233 if let Some(hash) = context.hash
234 && self.hashes.contains(&hash)
235 {
236 return Some(DenyReason::Hash(hash));
237 }
238 None
239 }
240
241 pub fn check_fetch(&self, context: &FilterContext) -> Result<()> {
247 if let Some(reason) = self.matching_rule(context) {
248 return Err(SyncwebError::InvalidConfig(Denied { reason }.to_string()));
249 }
250 Ok(())
251 }
252
253 pub fn check_discovery(&self, record: &CatalogRecord) -> Result<()> {
259 self.check_fetch(&FilterContext::for_file(
260 record.folder_namespace_id,
261 &record.key,
262 record.hash,
263 ))
264 }
265
266 pub fn sync_filter_list(&mut self, list: &FilterList) -> Result<bool> {
275 list.verify_signature()?;
276 let current = self.federated.get(&list.namespace_id).copied().unwrap_or(0);
277 if list.sequence <= current {
278 return Ok(false);
279 }
280 for rule in &list.entries {
281 self.add(rule.clone());
282 }
283 self.federated.insert(list.namespace_id, list.sequence);
284 Ok(true)
285 }
286
287 #[must_use]
289 pub fn list_rules(&self) -> Vec<DenylistRule> {
290 let mut rules = Vec::new();
291 for device in &self.devices {
292 rules.push(DenylistRule::Device(device.clone()));
293 }
294 for (namespace_id, key) in &self.files {
295 rules.push(DenylistRule::File {
296 namespace_id: *namespace_id,
297 key: key.clone(),
298 });
299 }
300 for hash in &self.hashes {
301 rules.push(DenylistRule::Hash(*hash));
302 }
303 rules
304 }
305
306 pub fn subscribe_filter_list(&mut self, list: &FilterList) -> Result<bool> {
312 self.sync_filter_list(list)
313 }
314
315 pub fn subscribe(&mut self, list: &FilterList) -> Result<bool> {
321 self.subscribe_filter_list(list)
322 }
323
324 pub fn sync(&mut self, list: &FilterList) -> Result<bool> {
330 self.sync_filter_list(list)
331 }
332
333 #[must_use]
334 pub fn federated_sequence(&self, namespace_id: &NamespaceId) -> Option<u64> {
335 self.federated.get(namespace_id).copied()
336 }
337}
338
339#[derive(Clone, Debug)]
342pub struct DenylistService {
343 denylist: Arc<RwLock<Denylist>>,
344 database: Option<IndexingDatabase>,
345}
346
347impl Default for DenylistService {
348 fn default() -> Self {
349 Self {
350 denylist: Arc::new(RwLock::new(Denylist::new())),
351 database: None,
352 }
353 }
354}
355
356impl DenylistService {
357 #[must_use]
358 pub fn new() -> Self {
359 Self::default()
360 }
361
362 pub fn with_database(database: IndexingDatabase) -> Result<Self> {
371 let rules = database.load_denylist_rules()?;
372 let lists = database.load_filter_lists().unwrap_or_default();
373 let mut denylist = Denylist::new();
374 for rule in rules {
375 denylist.add(rule);
376 }
377 for list in &lists {
378 let _: std::result::Result<bool, _> = denylist.sync_filter_list(list);
379 }
380 Ok(Self {
381 denylist: Arc::new(RwLock::new(denylist)),
382 database: Some(database),
383 })
384 }
385
386 fn persist(&self) -> Result<()> {
387 let Some(ref database) = self.database else {
388 return Ok(());
389 };
390 let rules: Vec<DenylistRule> = self
391 .denylist
392 .read()
393 .map_err(|error| SyncwebError::operation("denylist lock poisoned", error))?
394 .list_rules();
395 database.save_denylist_rules(&rules)
396 }
397
398 pub fn snapshot(&self) -> Result<Denylist> {
402 self.denylist
403 .read()
404 .map(|denylist| denylist.clone())
405 .map_err(|error| SyncwebError::operation("denylist lock poisoned", error))
406 }
407
408 pub fn add(&self, rule: DenylistRule) -> Result<()> {
412 self.denylist
413 .write()
414 .map_err(|error| SyncwebError::operation("denylist lock poisoned", error))?
415 .add(rule);
416 self.persist()
417 }
418
419 pub fn remove(&self, rule: &DenylistRule) -> Result<bool> {
423 let removed = self
424 .denylist
425 .write()
426 .map_err(|error| SyncwebError::operation("denylist lock poisoned", error))?
427 .remove(rule);
428 if removed {
429 self.persist()?;
430 }
431 Ok(removed)
432 }
433
434 pub fn check_fetch(&self, context: &FilterContext) -> Result<()> {
438 self.denylist
439 .read()
440 .map_err(|error| SyncwebError::operation("denylist lock poisoned", error))?
441 .check_fetch(context)
442 }
443
444 pub fn is_blocked(&self, context: &FilterContext) -> Result<bool> {
449 Ok(self
450 .denylist
451 .read()
452 .map_err(|error| SyncwebError::operation("denylist lock poisoned", error))?
453 .is_blocked(context))
454 }
455
456 pub fn check_discovery(&self, record: &CatalogRecord) -> Result<()> {
460 self.denylist
461 .read()
462 .map_err(|error| SyncwebError::operation("denylist lock poisoned", error))?
463 .check_discovery(record)
464 }
465
466 pub fn sync_filter_list(&self, list: &FilterList) -> Result<bool> {
470 let changed = self
471 .denylist
472 .write()
473 .map_err(|error| SyncwebError::operation("denylist lock poisoned", error))?
474 .sync_filter_list(list)?;
475 if changed && let Some(ref database) = self.database {
476 let _ = database.upsert_filter_list(list);
477 }
478 Ok(changed)
479 }
480
481 pub fn subscribe(&self, list: &FilterList) -> Result<bool> {
487 self.sync_filter_list(list)
488 }
489}
490
491#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
493#[non_exhaustive]
494pub struct FilterList {
495 pub namespace_id: NamespaceId,
496 pub sequence: u64,
497 pub publisher: String,
498 pub entries: Vec<DenylistRule>,
499 pub created_at: u64,
500 pub signature: Option<String>,
501}
502
503impl FilterList {
504 pub fn new(
511 namespace_id: NamespaceId,
512 sequence: u64,
513 entries: Vec<DenylistRule>,
514 signing_key: &SigningKey,
515 ) -> Result<Self> {
516 let mut list = Self {
517 namespace_id,
518 sequence,
519 publisher: hex::encode(signing_key.verifying_key().to_bytes()),
520 entries,
521 created_at: current_epoch_seconds(),
522 signature: None,
523 };
524 list.sign(signing_key)?;
525 Ok(list)
526 }
527
528 pub fn sign(&mut self, signing_key: &SigningKey) -> Result<()> {
533 if self.publisher != hex::encode(signing_key.verifying_key().to_bytes()) {
534 return Err(SyncwebError::InvalidIdentity(
535 "filter list signer does not match publisher".to_owned(),
536 ));
537 }
538 self.signature = Some(hex::encode(signing_key.sign(&self.unsigned_bytes()?).to_bytes()));
539 Ok(())
540 }
541
542 pub fn verify_signature(&self) -> Result<()> {
546 self.validate()?;
547 let signature = self
548 .signature
549 .as_deref()
550 .ok_or_else(|| SyncwebError::InvalidConfig("filter list must be signed".to_owned()))?;
551 let bytes = hex::decode(signature)
552 .map_err(|error| SyncwebError::InvalidConfig(format!("invalid filter list signature: {error}")))?;
553 let parsed_signature = Signature::from_slice(&bytes)
554 .map_err(|error| SyncwebError::InvalidConfig(format!("invalid filter list signature: {error}")))?;
555 let publisher_bytes = hex::decode(&self.publisher)
556 .map_err(|error| SyncwebError::InvalidIdentity(format!("invalid filter list publisher: {error}")))?;
557 let publisher: [u8; 32] = publisher_bytes.try_into().map_err(|publisher_bytes_vec: Vec<u8>| {
558 SyncwebError::InvalidIdentity(format!(
559 "filter list publisher must be 32 bytes, got {}",
560 publisher_bytes_vec.len()
561 ))
562 })?;
563 let key = VerifyingKey::from_bytes(&publisher)
564 .map_err(|error| SyncwebError::InvalidIdentity(format!("invalid filter list publisher: {error}")))?;
565 key.verify(&self.unsigned_bytes()?, &parsed_signature)
566 .map_err(|error| SyncwebError::InvalidConfig(format!("filter list signature is invalid: {error}")))
567 }
568
569 pub fn validate(&self) -> Result<()> {
573 if self.sequence == 0 {
574 return Err(SyncwebError::InvalidConfig(
575 "filter list sequence must be greater than zero".to_owned(),
576 ));
577 }
578 if self.publisher.trim().is_empty() {
579 return Err(SyncwebError::InvalidIdentity(
580 "filter list publisher cannot be empty".to_owned(),
581 ));
582 }
583 Ok(())
584 }
585
586 pub fn to_bytes(&self) -> Result<Vec<u8>> {
590 self.validate()?;
591 serde_json::to_vec(self).map_err(|error| SyncwebError::operation("failed to serialize filter list", error))
592 }
593
594 pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
598 let list: Self = serde_json::from_slice(bytes.as_ref())
599 .map_err(|error| SyncwebError::operation("failed to deserialize filter list", error))?;
600 list.validate()?;
601 Ok(list)
602 }
603
604 fn unsigned_bytes(&self) -> Result<Vec<u8>> {
605 let mut unsigned = self.clone();
606 unsigned.signature = None;
607 let encoded = serde_json::to_vec(&unsigned)
608 .map_err(|error| SyncwebError::operation("failed to serialize filter list", error))?;
609 let capacity = FILTER_LIST_CONTEXT
610 .len()
611 .checked_add(encoded.len())
612 .ok_or_else(|| SyncwebError::operation("filter list payload is too large", "capacity overflow"))?;
613 let mut bytes = Vec::with_capacity(capacity);
614 bytes.extend_from_slice(FILTER_LIST_CONTEXT);
615 bytes.extend_from_slice(&encoded);
616 Ok(bytes)
617 }
618}
619
620fn current_epoch_seconds() -> u64 {
621 SystemTime::now()
622 .duration_since(UNIX_EPOCH)
623 .map_or(0, |duration| duration.as_secs())
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 fn key(seed: u8) -> SigningKey {
631 SigningKey::from_bytes(&[seed; 32])
632 }
633
634 #[test]
635 fn device_file_and_hash_rules_block_fetches() -> anyhow::Result<()> {
636 let namespace = NamespaceId::from([1_u8; 32]);
637 let hash = Hash::from_bytes([2_u8; 32]);
638 let mut denylist = Denylist::new();
639 denylist.block_device("device-a");
640 denylist.block_folder_file(namespace, b"private.txt");
641 denylist.block_hash(hash);
642
643 anyhow::ensure!(
644 denylist
645 .check_fetch(&FilterContext::new(Hash::from_bytes([3_u8; 32])).with_device("device-a"))
646 .is_err()
647 );
648 anyhow::ensure!(
649 denylist
650 .check_fetch(&FilterContext::for_file(
651 namespace,
652 b"private.txt",
653 Hash::from_bytes([3_u8; 32])
654 ))
655 .is_err()
656 );
657 anyhow::ensure!(denylist.check_fetch(&FilterContext::new(hash)).is_err());
658 Ok(())
659 }
660
661 #[test]
662 fn federated_filter_lists_are_signed_and_monotonic() -> anyhow::Result<()> {
663 let namespace = NamespaceId::from([4_u8; 32]);
664 let list = FilterList::new(
665 namespace,
666 1,
667 vec![DenylistRule::hash(Hash::from_bytes([5_u8; 32]))],
668 &key(1),
669 )?;
670 let mut denylist = Denylist::new();
671 anyhow::ensure!(denylist.subscribe_filter_list(&list.clone())?);
672 anyhow::ensure!(!denylist.subscribe_filter_list(&list)?);
673 anyhow::ensure!(denylist.is_hash_blocked(&Hash::from_bytes([5_u8; 32])));
674 Ok(())
675 }
676}