Skip to main content

syncweb_core/sync/
subscribe.rs

1use std::path::{Path, PathBuf};
2
3use iroh_blobs::Hash;
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// Restricts subscription events to a portion of a folder.
8#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
9#[non_exhaustive]
10pub enum AreaFilter {
11    All,
12    Prefix(PathBuf),
13    Glob(String),
14    HashRange([u8; 32], [u8; 32]),
15}
16
17impl AreaFilter {
18    #[must_use]
19    pub fn matches_path(&self, path: &Path) -> bool {
20        match self {
21            Self::Prefix(prefix) => path.starts_with(prefix),
22            Self::Glob(pattern) => globset::Glob::new(pattern).is_ok_and(|glob| glob.compile_matcher().is_match(path)),
23            Self::All | Self::HashRange(_, _) => true,
24        }
25    }
26
27    #[must_use]
28    pub fn matches_hash(&self, hash: &Hash) -> bool {
29        match self {
30            Self::All | Self::Prefix(_) | Self::Glob(_) => true,
31            Self::HashRange(start, end) => {
32                let bytes = hash.as_bytes();
33                bytes >= start && bytes < end
34            }
35        }
36    }
37
38    #[must_use]
39    pub fn matches_entry(&self, path: &Path, hash: &Hash) -> bool {
40        self.matches_path(path) && self.matches_hash(hash)
41    }
42}
43
44/// Controls which changes are delivered to a folder subscriber.
45#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
46#[non_exhaustive]
47pub struct SubscribeParams {
48    pub ingest_only: bool,
49    pub ignore_session: Option<Uuid>,
50    pub area_filter: Option<AreaFilter>,
51    pub area_of_interest: Option<AreaOfInterest>,
52}
53
54impl SubscribeParams {
55    #[must_use]
56    pub const fn ingest_only() -> Self {
57        Self {
58            ingest_only: true,
59            ignore_session: None,
60            area_filter: None,
61            area_of_interest: None,
62        }
63    }
64
65    #[must_use]
66    pub const fn ignore_session(session_id: Uuid) -> Self {
67        Self {
68            ingest_only: false,
69            ignore_session: Some(session_id),
70            area_filter: None,
71            area_of_interest: None,
72        }
73    }
74
75    #[must_use]
76    pub fn with_area(mut self, area_filter: AreaFilter) -> Self {
77        self.area_filter = Some(area_filter);
78        self
79    }
80
81    #[must_use]
82    pub fn with_limits(mut self, area: AreaOfInterest) -> Self {
83        self.area_of_interest = Some(area);
84        self
85    }
86
87    /// Build subscription parameters from the persisted `join --subscribe` filters.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the configured limits are already exhausted.
92    pub fn from_filters(filters: &crate::storage::config::SubscribeFilters) -> crate::error::Result<Self> {
93        let mut params = if filters.ingest_only {
94            Self::ingest_only()
95        } else {
96            Self::default()
97        };
98        if filters.ignore_self {
99            params.ignore_session = Some(Uuid::new_v4());
100        }
101        let area = filters
102            .sync_prefix
103            .clone()
104            .map(AreaFilter::Prefix)
105            .or_else(|| filters.glob.clone().map(AreaFilter::Glob));
106        if let Some(filter) = area.clone() {
107            params = params.with_area(filter);
108        }
109        if filters.max_size.is_some() || filters.max_count.is_some() {
110            let limit_area = area.unwrap_or(AreaFilter::All);
111            let limits = AreaOfInterest::with_limits(
112                limit_area,
113                filters.max_size.unwrap_or(0),
114                filters.max_count.unwrap_or(0),
115            );
116            if limits.is_limit_reached(0, 0) {
117                return Err(crate::error::SyncwebError::InvalidConfig(
118                    "subscription limits are already exhausted".to_owned(),
119                ));
120            }
121            params = params.with_limits(limits);
122        }
123        Ok(params)
124    }
125
126    #[must_use]
127    pub fn accepts(&self, path: &Path, hash: &Hash) -> bool {
128        let area_matches = self
129            .area_of_interest
130            .as_ref()
131            .is_none_or(|area| area.area.matches_entry(path, hash));
132        let filter_matches = self
133            .area_filter
134            .as_ref()
135            .is_none_or(|filter| filter.matches_entry(path, hash));
136        area_matches && filter_matches
137    }
138}
139
140/// A subscription area with aggregate entry and byte limits.
141#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
142#[non_exhaustive]
143pub struct AreaOfInterest {
144    pub area: AreaFilter,
145    pub max_size: u64,
146    pub max_count: u64,
147}
148
149impl AreaOfInterest {
150    #[must_use]
151    pub const fn unlimited(area: AreaFilter) -> Self {
152        Self {
153            area,
154            max_size: 0,
155            max_count: 0,
156        }
157    }
158
159    #[must_use]
160    pub const fn with_count_limit(area: AreaFilter, max_count: u64) -> Self {
161        Self {
162            area,
163            max_size: 0,
164            max_count,
165        }
166    }
167
168    #[must_use]
169    pub const fn with_size_limit(area: AreaFilter, max_size: u64) -> Self {
170        Self {
171            area,
172            max_size,
173            max_count: 0,
174        }
175    }
176
177    #[must_use]
178    pub const fn with_limits(area: AreaFilter, max_size: u64, max_count: u64) -> Self {
179        Self {
180            area,
181            max_size,
182            max_count,
183        }
184    }
185
186    #[must_use]
187    pub const fn is_limit_reached(&self, synced_count: u64, synced_size: u64) -> bool {
188        (self.max_count > 0 && synced_count >= self.max_count) || (self.max_size > 0 && synced_size >= self.max_size)
189    }
190
191    #[must_use]
192    pub const fn permits(&self, synced_count: u64, synced_size: u64, next_size: u64) -> bool {
193        let next_count = synced_count.saturating_add(1);
194        let next_total_size = synced_size.saturating_add(next_size);
195        (self.max_count == 0 || next_count <= self.max_count)
196            && (self.max_size == 0 || next_total_size <= self.max_size)
197    }
198}