Skip to main content

syncweb_core/indexing/
denylist.rs

1//! Local and federated content filtering for the indexing service.
2//!
3//! Denylists are deliberately evaluated before discovery or fetching. They
4//! describe local policy only; accepting a record from a remote filter list
5//! never makes that list authoritative for another node.
6
7use 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/// The scope of a denylist rule.
26#[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/// Information supplied by a discovery or fetch hook.
66#[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/// The rule that caused a filter decision.
102#[derive(Clone, Debug, Eq, PartialEq)]
103#[non_exhaustive]
104pub enum DenyReason {
105    Device(String),
106    File(Vec<u8>),
107    Hash(Hash),
108}
109
110/// A failed denylist check.
111#[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/// A local content denylist.
128#[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    /// Return whether any rule blocks the supplied context.
215    #[must_use]
216    pub fn is_blocked(&self, context: &FilterContext) -> bool {
217        self.matching_rule(context).is_some()
218    }
219
220    /// Return the first matching local or federated rule.
221    #[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    /// Validate a fetch request against this denylist.
242    ///
243    /// # Errors
244    ///
245    /// Returns `InvalidConfig` when a rule blocks the request.
246    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    /// Validate a discovery record before it is shown or imported.
254    ///
255    /// # Errors
256    ///
257    /// Returns `InvalidConfig` when a rule blocks the record.
258    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    /// Apply a newer federated filter list.
267    ///
268    /// Lists are monotonic per namespace. Entries from an older list are
269    /// ignored, so a delayed update cannot remove local filtering rules.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if the list is malformed or unsigned.
274    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    /// Return all rules currently in the denylist.
288    #[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    /// Subscribe to a federated filter namespace and apply its first list.
307    ///
308    /// # Errors
309    ///
310    /// Returns an error if the list is malformed or unsigned.
311    pub fn subscribe_filter_list(&mut self, list: &FilterList) -> Result<bool> {
312        self.sync_filter_list(list)
313    }
314
315    /// Alias for [`Self::subscribe_filter_list`].
316    ///
317    /// # Errors
318    ///
319    /// Returns an error if the list is malformed or unsigned.
320    pub fn subscribe(&mut self, list: &FilterList) -> Result<bool> {
321        self.subscribe_filter_list(list)
322    }
323
324    /// Alias for [`Self::sync_filter_list`].
325    ///
326    /// # Errors
327    ///
328    /// Returns an error if the list is malformed or unsigned.
329    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/// Thread-safe denylist hooks suitable for sharing with fetch and discovery
340/// tasks.
341#[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    /// Create a service backed by an indexing database.
363    ///
364    /// Rules are loaded from the database on construction and persisted on
365    /// every mutation.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if the database cannot be read.
370    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    /// # Errors
399    ///
400    /// Returns an error if the denylist lock is poisoned.
401    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    /// # Errors
409    ///
410    /// Returns an error if the denylist lock is poisoned or persistence fails.
411    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    /// # Errors
420    ///
421    /// Returns an error if the denylist lock is poisoned or persistence fails.
422    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    /// # Errors
435    ///
436    /// Returns an error if the denylist lock is poisoned or the context is blocked.
437    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    /// Return whether any local rule blocks the supplied context.
445    /// # Errors
446    ///
447    /// Returns an error if the denylist lock is poisoned.
448    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    /// # Errors
457    ///
458    /// Returns an error if the denylist lock is poisoned or the record is blocked.
459    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    /// # Errors
467    ///
468    /// Returns an error if the denylist lock is poisoned or the list is invalid.
469    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    /// Alias for [`Self::sync_filter_list`].
482    ///
483    /// # Errors
484    ///
485    /// Returns an error if the denylist lock is poisoned or the list is invalid.
486    pub fn subscribe(&self, list: &FilterList) -> Result<bool> {
487        self.sync_filter_list(list)
488    }
489}
490
491/// A signed, monotonic federated filter-list update.
492#[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    /// Create and sign a filter list.
505    ///
506    /// # Errors
507    ///
508    /// Returns an error when the sequence is zero or serialization/signing
509    /// fails.
510    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    /// # Errors
529    ///
530    /// Returns an error if the signer does not match the publisher or
531    /// serialization fails.
532    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    /// # Errors
543    ///
544    /// Returns an error if the list, publisher, or signature is invalid.
545    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    /// # Errors
570    ///
571    /// Returns an error if the sequence or publisher is invalid.
572    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    /// # Errors
587    ///
588    /// Returns an error if validation or serialization fails.
589    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    /// # Errors
595    ///
596    /// Returns an error if deserialization or validation fails.
597    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}