Skip to main content

syncweb_core/folder/
manager.rs

1use std::{
2    collections::{HashMap, HashSet, hash_map::Entry},
3    sync::Arc,
4};
5
6use iroh::PublicKey;
7use iroh_blobs::ticket::BlobTicket;
8use iroh_docs::{DocTicket, NamespaceId, api::Doc};
9use n0_future::StreamExt;
10use tokio::sync::RwLock;
11
12use crate::constants::{MODE_KEY, NETWORK_MEMBERS_KEY};
13use crate::error::{Result, SyncwebError};
14use crate::node::discovery::TopicTracker;
15use crate::node::iroh_node::IrohNode;
16
17use super::{Capability, SyncMode, SyncwebFolder};
18
19#[derive(Clone)]
20pub struct FolderManager {
21    endpoint: iroh::Endpoint,
22    node_id: PublicKey,
23    blob_store: crate::node::blob_store::BlobStore,
24    docs_engine: crate::node::docs_engine::DocsEngine,
25    folders: Arc<RwLock<HashMap<NamespaceId, SyncwebFolder>>>,
26    subscriptions: Arc<RwLock<HashSet<iroh_blobs::Hash>>>,
27    topic_tracker: TopicTracker,
28    announced: Arc<RwLock<HashSet<NamespaceId>>>,
29}
30
31impl FolderManager {
32    #[must_use]
33    pub fn new(node: &IrohNode) -> Self {
34        Self {
35            endpoint: node.endpoint().clone(),
36            node_id: node.endpoint().id(),
37            blob_store: node.blob_store().clone(),
38            docs_engine: node.docs_engine().clone(),
39            folders: Arc::new(RwLock::new(HashMap::new())),
40            subscriptions: Arc::new(RwLock::new(HashSet::new())),
41            topic_tracker: node.topic_tracker().clone(),
42            announced: Arc::new(RwLock::new(HashSet::new())),
43        }
44    }
45
46    /// # Errors
47    ///
48    /// Returns an error if the folder namespace cannot be created or initialized.
49    pub async fn create(&self, mode: SyncMode) -> Result<SyncwebFolder> {
50        let provisioned = self
51            .docs_engine
52            .provision(crate::node::docs_engine::ProvisionKind::Folder, None, mode.to_string())
53            .await?;
54        let author = self.docs_engine.author().await?;
55        let folder = SyncwebFolder::new(
56            provisioned.doc,
57            author,
58            self.blob_store.clone(),
59            self.docs_engine.clone(),
60            mode,
61            true,
62        );
63        folder.grant(self.node_id, Capability::Admin).await;
64        self.folders.write().await.insert(folder.namespace_id(), folder.clone());
65        self.announce_namespace(folder.namespace_id()).await;
66        Ok(folder)
67    }
68
69    /// # Errors
70    ///
71    /// Returns an error if the folder ticket cannot be joined or parsed.
72    pub async fn join(&self, ticket_str: impl AsRef<str>, mode: SyncMode) -> Result<SyncwebFolder> {
73        self.join_with_options(ticket_str, mode, false).await
74    }
75
76    /// Join a folder, optionally in metadata-only mode (doc entries sync but
77    /// content blobs stay out of the local store until an explicit download).
78    ///
79    /// The download policy is applied before the namespace is announced, so
80    /// peers never push content for a metadata-only folder.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the folder ticket cannot be joined or parsed, or the
85    /// download policy cannot be set.
86    pub async fn join_with_options(
87        &self,
88        ticket_str: impl AsRef<str>,
89        mode: SyncMode,
90        metadata_only: bool,
91    ) -> Result<SyncwebFolder> {
92        let ticket = crate::uri::parse_folder_ticket(ticket_str.as_ref())?;
93        let writable = matches!(ticket.capability.kind(), iroh_docs::CapabilityKind::Write);
94        // Remember the sharer's advertised addresses (including the Syncthing
95        // relay custom address) so content fetches that only know the node id
96        // can still reach them when direct paths are blocked.
97        for node in &ticket.nodes {
98            self.blob_store.add_endpoint_addr(node.clone());
99        }
100        let doc = self.docs_engine.import_ticket(ticket).await?;
101        let folder = self.folder_from_doc(doc, mode, writable).await?;
102        if metadata_only {
103            folder.set_metadata_only().await?;
104        }
105        self.folders.write().await.insert(folder.namespace_id(), folder.clone());
106        self.announce_namespace(folder.namespace_id()).await;
107        Ok(folder)
108    }
109
110    /// Subscribe to a public blob ticket.
111    /// Fetches the blob and returns its hash. Does NOT create an Iroh doc
112    /// namespace.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if the blob cannot be fetched.
117    pub async fn subscribe_public(&self, ticket: &BlobTicket) -> Result<iroh_blobs::Hash> {
118        let hash = ticket.hash();
119        self.blob_store.fetch(&self.endpoint, ticket).await?;
120        self.subscriptions.write().await.insert(hash);
121        Ok(hash)
122    }
123
124    /// # Errors
125    ///
126    /// Returns an error if the folder namespace cannot be accepted.
127    pub async fn accept(&self, namespace_id: NamespaceId) -> Result<SyncwebFolder> {
128        let existing_folder = self.folders.read().await.get(&namespace_id).cloned();
129        if let Some(folder) = existing_folder {
130            return Ok(folder);
131        }
132        let (doc, ticket) = self.docs_engine.create_or_open_namespace(Some(namespace_id)).await?;
133        let writable = matches!(ticket.capability.kind(), iroh_docs::CapabilityKind::Write);
134        let folder = self.folder_from_doc(doc, SyncMode::ReceiveOnly, writable).await?;
135        self.folders.write().await.insert(namespace_id, folder.clone());
136        self.announce_namespace(namespace_id).await;
137        Ok(folder)
138    }
139
140    /// Announce a namespace to the topic tracker if not already announced.
141    /// Idempotent — safe to call multiple times for the same namespace.
142    pub async fn announce_namespace(&self, namespace_id: NamespaceId) {
143        if self.announced.read().await.contains(&namespace_id) {
144            return;
145        }
146        if let Err(error) = self.topic_tracker.announce(namespace_id).await {
147            tracing::warn!(%namespace_id, %error, "failed to announce folder namespace");
148        } else {
149            self.announced.write().await.insert(namespace_id);
150        }
151    }
152
153    /// Return all managed public subscription hashes.
154    #[must_use]
155    pub async fn subscriptions(&self) -> Vec<iroh_blobs::Hash> {
156        self.subscriptions.read().await.iter().copied().collect()
157    }
158
159    /// Seed the subscription set from persisted hashes (e.g. loaded from `SQLite`
160    /// on startup).
161    pub async fn seed_subscriptions(&self, hashes: impl IntoIterator<Item = iroh_blobs::Hash>) {
162        self.subscriptions.write().await.extend(hashes);
163    }
164
165    /// Drop (unsubscribe from) a public blob subscription.
166    pub async fn drop_subscription(&self, hash: &iroh_blobs::Hash) {
167        self.subscriptions.write().await.remove(hash);
168    }
169
170    /// Check whether a public subscription for the given hash exists.
171    #[must_use]
172    pub async fn has_subscription(&self, hash: &iroh_blobs::Hash) -> bool {
173        self.subscriptions.read().await.contains(hash)
174    }
175
176    /// # Errors
177    ///
178    /// Returns an error if the folder namespace cannot be dropped.
179    pub async fn drop(&self, namespace_id: NamespaceId) -> Result<()> {
180        self.docs_engine.drop_namespace(namespace_id).await?;
181        self.folders.write().await.remove(&namespace_id);
182        Ok(())
183    }
184
185    /// Drop a namespace, retrying briefly while its live session replica is
186    /// still closing.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the folder namespace cannot be dropped.
191    pub async fn drop_when_ready(&self, namespace_id: NamespaceId) -> Result<()> {
192        const MAX_ATTEMPTS: u32 = 50;
193        const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
194        let mut attempts = 0_u32;
195        loop {
196            match self.drop(namespace_id).await {
197                Err(error) if attempts < MAX_ATTEMPTS && error.to_string().contains("replica is not closed") => {
198                    attempts = attempts.saturating_add(1);
199                    tokio::time::sleep(RETRY_DELAY).await;
200                }
201                result => return result,
202            }
203        }
204    }
205
206    /// Delete the local files of a materialized folder at `path`.
207    ///
208    /// Refuses to delete a filesystem root or the current working directory;
209    /// only the folder's registered path may be deleted.
210    ///
211    /// # Errors
212    ///
213    /// Returns an error if the path cannot be resolved, is unsafe, or cannot be
214    /// deleted.
215    pub async fn delete_folder_files(path: &std::path::Path) -> Result<()> {
216        let canonical = std::fs::canonicalize(path)
217            .map_err(|error| SyncwebError::operation("failed to resolve folder path for deletion", error))?;
218        let current = std::env::current_dir()
219            .map_err(|error| SyncwebError::operation("failed to resolve current directory", error))?;
220        if canonical.as_os_str().is_empty()
221            || canonical.parent().is_none_or(|parent| parent.as_os_str().is_empty())
222            || canonical == current
223        {
224            return Err(SyncwebError::operation(
225                "refusing to delete files at a filesystem root or the current directory",
226                "unsafe folder path",
227            ));
228        }
229        let metadata = tokio::fs::symlink_metadata(&canonical).await?;
230        if metadata.is_dir() {
231            tokio::fs::remove_dir_all(&canonical).await?;
232        } else if metadata.is_file() {
233            tokio::fs::remove_file(&canonical).await?;
234        } else {
235            return Err(SyncwebError::operation(
236                "refusing to delete a non-file, non-directory path",
237                "unsupported folder path",
238            ));
239        }
240        Ok(())
241    }
242
243    /// # Errors
244    ///
245    /// Returns an error if the folders cannot be listed.
246    pub async fn list(&self) -> Result<Vec<SyncwebFolder>> {
247        let mut documents = self
248            .docs_engine
249            .inner()
250            .list()
251            .await
252            .map_err(|error| SyncwebError::operation("failed to list documents", error))?;
253        let mut listed = Vec::new();
254        while let Some(document) = documents.next().await {
255            listed.push(document.map_err(|error| SyncwebError::operation("failed to read document list", error))?);
256        }
257        for (namespace_id, capability) in listed {
258            if self.folders.read().await.contains_key(&namespace_id) {
259                continue;
260            }
261            let doc = self
262                .docs_engine
263                .open(namespace_id)
264                .await?
265                .ok_or(SyncwebError::NamespaceNotAvailable)?;
266            if self.docs_engine.get_any(&doc, NETWORK_MEMBERS_KEY).await?.is_some() {
267                continue;
268            }
269            let writable = matches!(capability, iroh_docs::CapabilityKind::Write);
270            let fallback_mode = match capability {
271                iroh_docs::CapabilityKind::Write => SyncMode::SendReceive,
272                iroh_docs::CapabilityKind::Read => SyncMode::ReceiveOnly,
273            };
274            let mode = self.mode_from_doc(&doc, fallback_mode).await?;
275            let folder = self.folder_from_doc(doc, mode, writable).await?;
276            if let Entry::Vacant(entry) = self.folders.write().await.entry(namespace_id) {
277                entry.insert(folder);
278            }
279        }
280        Ok(self.folders.read().await.values().cloned().collect())
281    }
282
283    /// Return a managed folder, loading locally available namespaces first.
284    ///
285    /// # Errors
286    ///
287    /// Returns an error if the local namespace list cannot be read.
288    pub async fn get(&self, namespace_id: NamespaceId) -> Result<SyncwebFolder> {
289        let existing = self.folders.read().await.get(&namespace_id).cloned();
290        if let Some(folder) = existing {
291            return Ok(folder);
292        }
293        self.list()
294            .await?
295            .into_iter()
296            .find(|folder| folder.namespace_id() == namespace_id)
297            .ok_or_else(|| SyncwebError::FolderNotFound(namespace_id.to_string()))
298    }
299
300    /// Resolve a folder selector (namespace ID, managed path, or the sole managed
301    /// folder) to a folder. Mirrors the CLI's path-or-namespace resolution so the
302    /// logic lives in one place.
303    ///
304    /// # Errors
305    ///
306    /// Returns an error when no folder matches the selector or the selector is
307    /// ambiguous.
308    pub async fn resolve(&self, selector: &std::path::Path) -> Result<SyncwebFolder> {
309        let namespace_id = self.resolve_namespace(&selector.to_string_lossy()).await?;
310        self.get(namespace_id).await
311    }
312
313    /// Resolve a string selector to a folder namespace ID. Accepts a namespace ID,
314    /// an existing managed path, or falls back to the sole managed folder.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error when the selector cannot be resolved to a namespace.
319    pub async fn resolve_namespace(&self, selector: &str) -> Result<NamespaceId> {
320        if let Ok(namespace_id) = selector.parse() {
321            return Ok(namespace_id);
322        }
323        let folders = self.list().await?;
324        match folders.as_slice() {
325            [folder] => Ok(folder.namespace_id()),
326            [] => Err(SyncwebError::NoFolders),
327            _ => Err(SyncwebError::AmbiguousFolderSelector(selector.to_owned())),
328        }
329    }
330
331    /// # Errors
332    ///
333    /// Returns an error if the ticket cannot be generated.
334    pub async fn ticket(&self, namespace_id: NamespaceId, writable: bool) -> Result<DocTicket> {
335        self.ticket_with_options(
336            namespace_id,
337            writable,
338            iroh_docs::api::protocol::AddrInfoOptions::RelayAndAddresses,
339        )
340        .await
341    }
342
343    /// # Errors
344    ///
345    /// Returns an error if the ticket cannot be generated.
346    pub async fn ticket_with_options(
347        &self,
348        namespace_id: NamespaceId,
349        writable: bool,
350        addr_options: iroh_docs::api::protocol::AddrInfoOptions,
351    ) -> Result<DocTicket> {
352        let folder = self
353            .folders
354            .read()
355            .await
356            .get(&namespace_id)
357            .cloned()
358            .ok_or(SyncwebError::FolderNotFound(namespace_id.to_string()))?;
359        folder.ticket_with_options(writable, addr_options).await
360    }
361
362    async fn folder_from_doc(&self, doc: Doc, mode: SyncMode, writable: bool) -> Result<SyncwebFolder> {
363        Ok(SyncwebFolder::new(
364            doc,
365            self.docs_engine.author().await?,
366            self.blob_store.clone(),
367            self.docs_engine.clone(),
368            mode,
369            writable,
370        ))
371    }
372
373    async fn mode_from_doc(&self, doc: &Doc, fallback: SyncMode) -> Result<SyncMode> {
374        let author = self.docs_engine.author().await?;
375        let Some(entry) = self.docs_engine.get(doc, author, MODE_KEY).await? else {
376            return Ok(fallback);
377        };
378        let mode_hash = entry.content_hash();
379        let mode_bytes = match self.blob_store.get(mode_hash).await {
380            Ok(bytes) => bytes,
381            Err(error) => {
382                tracing::warn!(
383                    %mode_hash,
384                    %error,
385                    fallback = %fallback,
386                    "folder mode metadata is unavailable; using capability-derived mode"
387                );
388                return Ok(fallback);
389            }
390        };
391        let mode_value = std::str::from_utf8(&mode_bytes)
392            .map_err(|error| SyncwebError::operation("folder mode metadata is not UTF-8", error))?;
393        mode_value.parse()
394    }
395}