Skip to main content

syncweb_core/daemon/
ipc.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fmt::Write,
4    path::{Path, PathBuf},
5    str::FromStr,
6    sync::{
7        Arc,
8        atomic::{AtomicBool, Ordering},
9    },
10};
11
12use n0_future::StreamExt;
13use serde::{Deserialize, Serialize};
14use tokio::sync::{RwLock, broadcast, mpsc};
15
16use crate::{
17    bandwidth_stats::{FileStatsCollector, FileStatsReport},
18    daemon::state::FolderStatusReport,
19    error::{Result, SyncwebError},
20    filter::{FilterConfig, FilterEngine},
21    folder::{
22        CollectionHead, CollectionManifest, CollectionStore, DropExportOptions, DropExportResult, DropExporter,
23        DropImportOptions, DropImportResult, DropImporter, FolderLike, FolderManager, PublicSubscription, ShareOptions,
24        SyncMode, share_folder,
25    },
26    fs::Importer,
27    indexing::IndexingService,
28    node::iroh_node::IrohNode,
29    snapshot::SnapshotStore,
30    storage::config::SubscribeFilters,
31    storage::node_db::NodeDatabase,
32    sync::{
33        ActiveSession, AreaFilter, FetchFilter, FetchStrategy, SubscribeParams, SyncEngine, SyncEvent, cancel_session,
34    },
35    verify::IntegrityChecker,
36};
37
38use super::{
39    ManagedPool,
40    state::{DaemonStatus, daemon_socket_path},
41};
42
43use std::time::Duration;
44
45const IPC_TIMEOUT: Duration = Duration::from_secs(2);
46
47const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(1);
48
49/// A request sent over the local daemon control channel.
50#[derive(Clone, Debug, Deserialize, Serialize)]
51#[non_exhaustive]
52pub struct IpcRequest {
53    pub command: IpcCommand,
54}
55
56impl IpcRequest {
57    #[must_use]
58    pub const fn new(command: IpcCommand) -> Self {
59        Self { command }
60    }
61}
62
63/// Commands supported by the daemon control channel.
64#[derive(Clone, Debug, Deserialize, Serialize)]
65#[serde(tag = "command", rename_all = "snake_case")]
66#[non_exhaustive]
67pub enum IpcCommand {
68    Status,
69    ListFolders,
70    AddFolder {
71        namespace: String,
72        path: PathBuf,
73    },
74    TriggerSync {
75        namespace: Option<String>,
76    },
77    SetLogLevel {
78        level: String,
79    },
80    ReloadConfig,
81    Shutdown {
82        force: bool,
83    },
84    Download {
85        namespace: String,
86        strategy: FetchStrategy,
87    },
88    MaterializeTransfers {
89        namespace: Option<String>,
90    },
91    ImportFiles {
92        namespace: Option<String>,
93        path: PathBuf,
94    },
95    ImportArchive {
96        input: PathBuf,
97        target: PathBuf,
98        filter: Option<FilterConfig>,
99    },
100    ExportArchive {
101        namespace: String,
102        version: Option<String>,
103        output: PathBuf,
104    },
105    Join {
106        ticket: String,
107        path: PathBuf,
108        mode: SyncMode,
109        #[serde(default)]
110        subscribe: bool,
111        #[serde(default)]
112        filters: SubscribeFilters,
113        #[serde(default)]
114        download: bool,
115        #[serde(default)]
116        indexing: bool,
117    },
118    SetSubscribe {
119        namespace: String,
120        enabled: bool,
121        #[serde(default)]
122        filters: Option<SubscribeFilters>,
123    },
124    SubscribePublic {
125        ticket: String,
126    },
127    CreateFolder {
128        path: PathBuf,
129        mode: String,
130        #[serde(default)]
131        indexing: bool,
132    },
133    StatsFiles {
134        folder: PathBuf,
135    },
136    VerifyIntegrity {
137        path: PathBuf,
138        #[serde(default)]
139        hash: Vec<String>,
140        #[serde(default)]
141        path_filter: Option<String>,
142        #[serde(default)]
143        glob_filter: Option<String>,
144        #[serde(default)]
145        fix: bool,
146        #[serde(default)]
147        from: Vec<String>,
148    },
149    Unsubscribe {
150        namespace: String,
151    },
152    LeaveFolder {
153        namespace: String,
154        #[serde(default)]
155        delete_files: bool,
156    },
157    Share {
158        namespace: String,
159        #[serde(default)]
160        blob: Option<String>,
161        writable: bool,
162        #[serde(default)]
163        pin: bool,
164        #[serde(default)]
165        persist: bool,
166    },
167    ShareList,
168    Unshare {
169        namespace: String,
170        #[serde(default)]
171        blob: Option<String>,
172        writable: bool,
173    },
174    SnapshotCreate {
175        path: PathBuf,
176        description: Option<String>,
177        threads: usize,
178    },
179    SnapshotList {
180        path: PathBuf,
181    },
182    SnapshotDelete {
183        id: String,
184    },
185    CollectionPublish {
186        path: PathBuf,
187        namespace: String,
188        sequence: u64,
189        bootstrap: Vec<String>,
190        #[serde(default)]
191        manifest_bytes: Option<Vec<u8>>,
192    },
193    EnrichSort {
194        path: PathBuf,
195    },
196    NetworkInvite {
197        network_id: String,
198        device: String,
199    },
200    NetworkKick {
201        network_id: String,
202        device: String,
203    },
204    NetworkLeave {
205        network_id: String,
206    },
207    NetworkCreate {
208        name: String,
209        label: String,
210        invite_only: bool,
211        doc_ticket: Option<String>,
212    },
213    NetworkJoin {
214        ticket: String,
215    },
216}
217
218/// A response returned by the daemon control channel.
219#[derive(Clone, Debug, Deserialize, Serialize)]
220#[serde(tag = "response", content = "data", rename_all = "snake_case")]
221#[non_exhaustive]
222pub enum IpcResponse {
223    Ok { message: String },
224    Status(DaemonStatus),
225    FolderList(Vec<FolderStatus>),
226    DownloadComplete { bytes_transferred: u64 },
227    TransferJobsProcessed { completed: u64, failed: u64 },
228    ImportFilesComplete { entries: u64 },
229    ImportComplete(Box<DropImportResult>),
230    ExportComplete(Box<DropExportResult>),
231    EnrichData(HashMap<String, usize>),
232    FileStats(Box<FileStatsReport>),
233    Error { message: String },
234}
235
236/// A managed folder summary returned by the daemon.
237pub use crate::daemon::state::FolderStatusReport as FolderStatus;
238
239/// A folder managed by the daemon.
240#[non_exhaustive]
241pub struct FolderEntry {
242    pub namespace: iroh_docs::NamespaceId,
243    pub path: PathBuf,
244    pub session: Option<ActiveSession>,
245    pub last_sync_at: Option<u64>,
246    pub sync_count: u64,
247    pub entries_synced: u64,
248    pub errors: Vec<String>,
249}
250
251impl FolderEntry {
252    #[must_use]
253    pub const fn new(namespace: iroh_docs::NamespaceId, path: PathBuf) -> Self {
254        Self {
255            namespace,
256            path,
257            session: None,
258            last_sync_at: None,
259            sync_count: 0,
260            entries_synced: 0,
261            errors: Vec::new(),
262        }
263    }
264
265    #[must_use]
266    pub fn status(&self) -> FolderStatusReport {
267        FolderStatusReport {
268            namespace: self.namespace.to_string(),
269            path: self.path.clone(),
270            kind: "folder".to_owned(),
271            session_active: self.session.is_some() || crate::sync::is_active(self.namespace),
272            last_sync_at: self.last_sync_at,
273            sync_count: self.sync_count,
274            entries_synced: self.entries_synced,
275            errors: self.errors.clone(),
276        }
277    }
278}
279
280/// Registry of folders and subscriptions currently managed by the daemon.
281#[derive(Default)]
282pub struct FolderRegistry {
283    folders: HashMap<String, FolderEntry>,
284    subscriptions: HashMap<String, PublicSubscription>,
285    removed: HashSet<String>,
286}
287
288impl FolderRegistry {
289    #[must_use]
290    pub fn new() -> Self {
291        Self::default()
292    }
293
294    /// Add a folder to the registry.
295    ///
296    /// # Errors
297    ///
298    /// Returns an error when the namespace is already registered.
299    pub fn add(&mut self, entry: FolderEntry) -> Result<()> {
300        let key = entry.namespace.to_string();
301        if self.folders.contains_key(&key) {
302            return Err(SyncwebError::FolderAlreadyManaged);
303        }
304        self.removed.remove(&key);
305        self.folders.insert(key, entry);
306        Ok(())
307    }
308
309    /// Add a folder, or attach a path to a folder restored without one.
310    ///
311    /// # Errors
312    ///
313    /// Returns an error when the namespace is already managed with a path or
314    /// when the requested update conflicts with an existing registration.
315    pub fn add_or_update(&mut self, entry: FolderEntry) -> Result<()> {
316        let key = entry.namespace.to_string();
317        if let Some(existing) = self.folders.get_mut(&key) {
318            if existing.path.as_os_str().is_empty() && !entry.path.as_os_str().is_empty() {
319                existing.path = entry.path;
320                self.removed.remove(&key);
321                return Ok(());
322            }
323            return Err(SyncwebError::FolderAlreadyManaged);
324        }
325        self.removed.remove(&key);
326        self.folders.insert(key, entry);
327        Ok(())
328    }
329
330    pub fn remove(&mut self, namespace: &iroh_docs::NamespaceId) -> Option<FolderEntry> {
331        let key = namespace.to_string();
332        self.removed.insert(key.clone());
333        self.folders.remove(&key)
334    }
335
336    #[must_use]
337    pub fn is_removed(&self, namespace: &str) -> bool {
338        self.removed.contains(namespace)
339    }
340
341    /// Add a subscription to the registry.
342    pub fn add_subscription(&mut self, subscription: PublicSubscription) {
343        let key = subscription.namespace_id();
344        self.subscriptions.insert(key, subscription);
345    }
346
347    /// Remove a subscription by its namespace ID (e.g. `"blob:<hash>"`).
348    pub fn remove_subscription(&mut self, namespace_id: &str) -> Option<PublicSubscription> {
349        self.subscriptions.remove(namespace_id)
350    }
351
352    #[must_use]
353    pub fn subscription_statuses(&self) -> Vec<FolderStatusReport> {
354        self.subscriptions
355            .values()
356            .map(|sub| FolderStatusReport {
357                namespace: sub.namespace_id(),
358                path: PathBuf::new(),
359                kind: "subscription".to_owned(),
360                session_active: false,
361                last_sync_at: None,
362                sync_count: 0,
363                entries_synced: sub.size(),
364                errors: Vec::new(),
365            })
366            .collect()
367    }
368
369    #[must_use]
370    pub fn statuses(&self) -> Vec<FolderStatusReport> {
371        let mut statuses: Vec<_> = self.folders.values().map(FolderEntry::status).collect();
372        statuses.extend(self.subscription_statuses());
373        statuses.sort_by(|left, right| left.namespace.cmp(&right.namespace));
374        statuses
375    }
376
377    #[must_use]
378    pub fn len(&self) -> usize {
379        self.folders.len().saturating_add(self.subscriptions.len())
380    }
381
382    #[must_use]
383    pub fn is_empty(&self) -> bool {
384        self.folders.is_empty() && self.subscriptions.is_empty()
385    }
386
387    pub fn record_import(&mut self, namespace: iroh_docs::NamespaceId, entries: u64, timestamp: u64) {
388        if let Some(folder) = self.folders.get_mut(&namespace.to_string()) {
389            folder.entries_synced = folder.entries_synced.saturating_add(entries);
390            folder.sync_count = folder.sync_count.saturating_add(1);
391            folder.last_sync_at = Some(timestamp);
392            folder.errors.clear();
393        }
394    }
395
396    pub fn record_error(&mut self, namespace: iroh_docs::NamespaceId, error: impl Into<String>) {
397        if let Some(folder) = self.folders.get_mut(&namespace.to_string()) {
398            folder.errors.push(error.into());
399            if folder.errors.len() > 16 {
400                let remove_count = folder.errors.len().saturating_sub(16);
401                folder.errors.drain(..remove_count);
402            }
403        }
404    }
405}
406
407/// Shared daemon state used by the IPC server.
408#[derive(Clone)]
409#[non_exhaustive]
410pub struct DaemonHandle {
411    pub state: Arc<RwLock<super::state::DaemonState>>,
412    pub folder_registry: Arc<RwLock<FolderRegistry>>,
413    pub shutdown_sender: broadcast::Sender<()>,
414    pub sync_trigger: mpsc::UnboundedSender<Option<String>>,
415    pub reload_requested: Arc<AtomicBool>,
416}
417
418impl DaemonHandle {
419    /// Create a handle with fresh control channels.
420    #[must_use]
421    pub fn new(state: super::state::DaemonState) -> Self {
422        let (shutdown_sender, _) = broadcast::channel(16);
423        let (sync_trigger, _) = mpsc::unbounded_channel();
424        Self::with_channels(
425            Arc::new(RwLock::new(state)),
426            Arc::new(RwLock::new(FolderRegistry::new())),
427            shutdown_sender,
428            sync_trigger,
429        )
430    }
431
432    #[must_use]
433    pub fn with_channels(
434        state: Arc<RwLock<super::state::DaemonState>>,
435        folder_registry: Arc<RwLock<FolderRegistry>>,
436        shutdown_sender: broadcast::Sender<()>,
437        sync_trigger: mpsc::UnboundedSender<Option<String>>,
438    ) -> Self {
439        Self::with_channels_and_reload(
440            state,
441            folder_registry,
442            shutdown_sender,
443            sync_trigger,
444            Arc::new(AtomicBool::new(false)),
445        )
446    }
447
448    #[must_use]
449    pub const fn with_channels_and_reload(
450        state: Arc<RwLock<super::state::DaemonState>>,
451        folder_registry: Arc<RwLock<FolderRegistry>>,
452        shutdown_sender: broadcast::Sender<()>,
453        sync_trigger: mpsc::UnboundedSender<Option<String>>,
454        reload_requested: Arc<AtomicBool>,
455    ) -> Self {
456        Self {
457            state,
458            folder_registry,
459            shutdown_sender,
460            sync_trigger,
461            reload_requested,
462        }
463    }
464
465    /// Update the lifecycle status returned by future status requests.
466    pub async fn set_status(&self, status: DaemonStatus) {
467        self.state.write().await.status = status;
468    }
469}
470
471/// Socket path and binding helper for the daemon.
472#[derive(Clone, Debug)]
473pub struct IpcListener {
474    socket_path: PathBuf,
475}
476
477impl IpcListener {
478    #[must_use]
479    pub const fn new(socket_path: PathBuf) -> Self {
480        Self { socket_path }
481    }
482
483    #[must_use]
484    pub fn for_data_dir(data_dir: &Path) -> Self {
485        Self::new(daemon_socket_path(data_dir))
486    }
487
488    #[must_use]
489    pub fn socket_path(&self) -> &Path {
490        &self.socket_path
491    }
492
493    /// Bind the daemon socket with owner-only permissions on Unix.
494    ///
495    /// # Errors
496    ///
497    /// Returns an error when the parent directory or socket cannot be created.
498    #[cfg(unix)]
499    pub fn bind(&self) -> Result<tokio::net::UnixListener> {
500        if let Some(parent) = self.socket_path.parent() {
501            std::fs::create_dir_all(parent)?;
502        }
503        if self.socket_path.exists() {
504            match std::os::unix::net::UnixStream::connect(&self.socket_path) {
505                Ok(_) => {
506                    return Err(std::io::Error::new(
507                        std::io::ErrorKind::AddrInUse,
508                        "daemon IPC socket is already in use",
509                    )
510                    .into());
511                }
512                Err(_) => std::fs::remove_file(&self.socket_path)?,
513            }
514        }
515        let listener = tokio::net::UnixListener::bind(&self.socket_path).map_err(|error| {
516            SyncwebError::operation(
517                format!("failed to bind daemon socket at {}", self.socket_path.display()),
518                error,
519            )
520        })?;
521        set_owner_only_permissions(&self.socket_path)?;
522        Ok(listener)
523    }
524}
525
526/// A server for the daemon's local control channel.
527#[derive(Clone)]
528pub struct IpcServer {
529    #[cfg_attr(not(unix), allow(dead_code))]
530    listener: IpcListener,
531    daemon_handle: DaemonHandle,
532    archive_context: Option<Arc<ArchiveContext>>,
533    folder_manager: Option<FolderManager>,
534    node_db: Option<NodeDatabase>,
535    network_manager: Option<std::sync::Arc<tokio::sync::RwLock<crate::net::NetworkManager>>>,
536}
537
538#[derive(Clone)]
539struct ArchiveContext {
540    node: Arc<IrohNode>,
541    pool: Arc<ManagedPool>,
542    indexing: Option<IndexingService>,
543}
544
545/// Boolean toggles for the join workflow, bundled to keep the handler signature
546/// small and free of excessive boolean parameters.
547#[derive(Clone, Copy, Debug, Default)]
548struct JoinOptions {
549    subscribe: bool,
550    download: bool,
551    indexing: bool,
552}
553
554impl IpcServer {
555    #[must_use]
556    pub const fn new(socket_path: PathBuf, daemon_handle: DaemonHandle) -> Self {
557        Self {
558            listener: IpcListener::new(socket_path),
559            daemon_handle,
560            archive_context: None,
561            folder_manager: None,
562            node_db: None,
563            network_manager: None,
564        }
565    }
566
567    /// Create an IPC server with access to daemon-owned archive resources.
568    #[must_use]
569    pub fn with_archive_context(
570        socket_path: PathBuf,
571        daemon_handle: DaemonHandle,
572        node: Arc<IrohNode>,
573        pool: Arc<ManagedPool>,
574        indexing: Option<IndexingService>,
575    ) -> Self {
576        Self {
577            listener: IpcListener::new(socket_path),
578            daemon_handle,
579            archive_context: Some(Arc::new(ArchiveContext { node, pool, indexing })),
580            folder_manager: None,
581            node_db: None,
582            network_manager: None,
583        }
584    }
585
586    /// Set the folder manager for this IPC server.
587    #[must_use]
588    pub fn with_folder_manager(mut self, folder_manager: FolderManager) -> Self {
589        self.folder_manager = Some(folder_manager);
590        self
591    }
592
593    /// Set the node database for persistence.
594    #[must_use]
595    pub fn with_node_db(mut self, node_db: NodeDatabase) -> Self {
596        self.node_db = Some(node_db);
597        self
598    }
599
600    /// Attach a network manager for daemon-side network operations.
601    #[must_use]
602    pub fn with_network_manager(
603        mut self,
604        network_manager: std::sync::Arc<tokio::sync::RwLock<crate::net::NetworkManager>>,
605    ) -> Self {
606        self.network_manager = Some(network_manager);
607        self
608    }
609
610    /// Accept and process requests until the daemon broadcasts shutdown.
611    ///
612    /// # Errors
613    ///
614    /// Returns an error when the socket cannot be bound, accepted, or written.
615    pub async fn serve(&self) -> Result<()> {
616        #[cfg(unix)]
617        {
618            let listener = self.listener.bind()?;
619            let mut shutdown = self.daemon_handle.shutdown_sender.subscribe();
620            let result = loop {
621                tokio::select! {
622                    shutdown_result = shutdown.recv() => {
623                        match shutdown_result {
624                            Ok(()) | Err(broadcast::error::RecvError::Closed) => break Ok(()),
625                            Err(broadcast::error::RecvError::Lagged(_)) => {}
626                        }
627                    }
628                    accepted = listener.accept() => {
629                        let (stream, _) = accepted.map_err(|error| {
630                            SyncwebError::operation(
631                                format!("daemon socket accept failed at {}", self.listener.socket_path().display()),
632                                error,
633                            )
634                        })?;
635                        if let Err(error) = self.handle_connection(stream).await {
636                            tracing::error!(%error, "daemon IPC connection failed");
637                            return Err(error);
638                        }
639                    }
640                }
641            };
642            if let Err(error) = std::fs::remove_file(self.listener.socket_path())
643                && self.listener.socket_path().exists()
644            {
645                return Err(error.into());
646            }
647            result
648        }
649        #[cfg(not(unix))]
650        {
651            Err(SyncwebError::operation(
652                "daemon IPC is unavailable",
653                "Unix sockets are not supported on this platform",
654            ))
655        }
656    }
657
658    async fn handle_status(&self) -> IpcResponse {
659        IpcResponse::Status(self.daemon_handle.state.read().await.status)
660    }
661
662    async fn handle_list_folders(&self) -> IpcResponse {
663        let folders = self.daemon_handle.folder_registry.read().await.statuses();
664        IpcResponse::FolderList(folders)
665    }
666
667    fn handle_set_log_level(level: &str) -> IpcResponse {
668        IpcResponse::Ok {
669            message: format!("log level set to {level}"),
670        }
671    }
672
673    async fn handle_import_archive_response(
674        &self,
675        input: PathBuf,
676        target: PathBuf,
677        filter: Option<FilterConfig>,
678    ) -> IpcResponse {
679        match self.handle_import_archive(input, target, filter).await {
680            Ok(result) => IpcResponse::ImportComplete(Box::new(result)),
681            Err(error) => response_from_error(error),
682        }
683    }
684
685    async fn handle_export_archive_response(
686        &self,
687        namespace: String,
688        version: Option<String>,
689        output: PathBuf,
690    ) -> IpcResponse {
691        match self.handle_export_archive(namespace, version, output).await {
692            Ok(result) => IpcResponse::ExportComplete(Box::new(result)),
693            Err(error) => response_from_error(error),
694        }
695    }
696
697    async fn handle_network_group(&self, cmd: IpcCommand) -> IpcResponse {
698        if let IpcCommand::NetworkInvite { network_id, device } = cmd {
699            return self.handle_network_invite(network_id, device).await;
700        }
701        if let IpcCommand::NetworkKick { network_id, device } = cmd {
702            return self.handle_network_kick(network_id, device).await;
703        }
704        if let IpcCommand::NetworkLeave { network_id } = cmd {
705            return self.handle_network_leave(network_id).await;
706        }
707        if let IpcCommand::NetworkCreate {
708            name,
709            label,
710            invite_only,
711            doc_ticket,
712        } = cmd
713        {
714            return self.handle_network_create(name, label, invite_only, doc_ticket).await;
715        }
716        if let IpcCommand::NetworkJoin { ticket } = cmd {
717            return self.handle_network_join(ticket).await;
718        }
719        IpcResponse::Error {
720            message: format!("unhandled network command: {cmd:?}"),
721        }
722    }
723
724    async fn handle_simple_group(&self, cmd: IpcCommand) -> IpcResponse {
725        if matches!(cmd, IpcCommand::Status) {
726            return self.handle_status().await;
727        }
728        if matches!(cmd, IpcCommand::ListFolders) {
729            return self.handle_list_folders().await;
730        }
731        if matches!(cmd, IpcCommand::ReloadConfig) {
732            return self.handle_reload_config();
733        }
734        IpcResponse::Error {
735            message: format!("unhandled simple command: {cmd:?}"),
736        }
737    }
738
739    /// Handle one decoded request without requiring a socket.
740    pub async fn handle_request(&self, request: IpcRequest) -> IpcResponse {
741        use IpcCommand as C;
742        match request.command {
743            C::Status | C::ListFolders | C::ReloadConfig => self.handle_simple_group(request.command).await,
744            C::AddFolder { namespace, path } => self.handle_add_folder(namespace, path).await,
745            C::TriggerSync { namespace } => self.handle_trigger_sync(namespace),
746            C::SetLogLevel { level } => Self::handle_set_log_level(&level),
747            C::Shutdown { force } => self.handle_shutdown(force),
748            C::ImportArchive { input, target, filter } => {
749                self.handle_import_archive_response(input, target, filter).await
750            }
751            C::ImportFiles { namespace, path } => self.handle_import_files_response(namespace, path).await,
752            C::ExportArchive {
753                namespace,
754                version,
755                output,
756            } => self.handle_export_archive_response(namespace, version, output).await,
757            C::Download { namespace, strategy } => self.handle_download_response(namespace, strategy).await,
758            C::MaterializeTransfers { namespace } => self.handle_materialize_transfers(namespace).await,
759            C::Join {
760                ticket,
761                path,
762                mode,
763                subscribe,
764                filters,
765                download,
766                indexing,
767            } => {
768                let options = JoinOptions {
769                    subscribe,
770                    download,
771                    indexing,
772                };
773                self.handle_join(ticket, path, mode, filters, options).await
774            }
775            C::SetSubscribe {
776                namespace,
777                enabled,
778                filters,
779            } => self.handle_set_subscribe(namespace, enabled, filters).await,
780            C::SubscribePublic { ticket } => self.handle_subscribe_public(ticket).await,
781            C::CreateFolder { path, mode, indexing } => self.handle_create_folder(path, mode, indexing).await,
782            C::StatsFiles { folder } => self.handle_stats_files(folder).await,
783            C::VerifyIntegrity {
784                path,
785                hash,
786                path_filter,
787                glob_filter,
788                fix,
789                from,
790            } => {
791                self.handle_verify_integrity(path, hash, path_filter, glob_filter, fix, from)
792                    .await
793            }
794            C::Unsubscribe { namespace } => self.handle_unsubscribe_command(&namespace).await,
795            C::LeaveFolder {
796                namespace,
797                delete_files,
798            } => self.handle_leave_folder(namespace, delete_files).await,
799            C::Share { .. } | C::ShareList | C::Unshare { .. } => self.handle_share_group(request.command).await,
800            C::SnapshotCreate {
801                path,
802                description,
803                threads,
804            } => self.handle_snapshot_create(path, description, threads).await,
805            C::SnapshotList { path } => self.handle_snapshot_list(path).await,
806            C::SnapshotDelete { id } => self.handle_snapshot_delete(id).await,
807            C::CollectionPublish {
808                path,
809                namespace,
810                sequence,
811                bootstrap,
812                manifest_bytes,
813            } => {
814                self.handle_collection_publish(path, namespace, sequence, bootstrap, manifest_bytes)
815                    .await
816            }
817            C::EnrichSort { path } => self.handle_enrich_sort(path).await,
818            C::NetworkInvite { .. }
819            | C::NetworkKick { .. }
820            | C::NetworkLeave { .. }
821            | C::NetworkCreate { .. }
822            | C::NetworkJoin { .. } => self.handle_network_group(request.command).await,
823        }
824    }
825
826    async fn handle_add_folder(&self, namespace: String, path: PathBuf) -> IpcResponse {
827        match iroh_docs::NamespaceId::from_str(&namespace) {
828            Ok(namespace_id) => {
829                let mut registry = self.daemon_handle.folder_registry.write().await;
830                match registry.add_or_update(FolderEntry::new(namespace_id, path)) {
831                    Ok(()) => IpcResponse::Ok {
832                        message: "folder added".to_owned(),
833                    },
834                    Err(error) => response_from_error(error),
835                }
836            }
837            Err(error) => IpcResponse::Error {
838                message: format!("invalid folder namespace: {error}"),
839            },
840        }
841    }
842
843    async fn handle_leave_folder(&self, namespace: String, delete_files: bool) -> IpcResponse {
844        let context = match &self.archive_context {
845            Some(ctx) => ctx.clone(),
846            None => {
847                return IpcResponse::Error {
848                    message: "daemon leave-folder IPC is unavailable: server has no node context".to_owned(),
849                };
850            }
851        };
852        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
853            Ok(id) => id,
854            Err(error) => {
855                return IpcResponse::Error {
856                    message: format!("invalid namespace: {error}"),
857                };
858            }
859        };
860        let _ = cancel_session(namespace_id);
861        let manager = FolderManager::new(&context.node);
862        match manager.drop_when_ready(namespace_id).await {
863            Ok(()) => {
864                if let Some(node_db) = self.node_db.clone()
865                    && let Ok(mut config) = node_db.load_app_config()
866                {
867                    config.remove_subscribe(&namespace);
868                    let _ = node_db.save_app_config(&config);
869                }
870                let removed = self.daemon_handle.folder_registry.write().await.remove(&namespace_id);
871                if delete_files
872                    && let Some(entry) = removed.as_ref()
873                    && !entry.path.as_os_str().is_empty()
874                    && let Err(error) = FolderManager::delete_folder_files(&entry.path).await
875                {
876                    return IpcResponse::Error {
877                        message: format!("folder left, but failed to delete its files: {error}"),
878                    };
879                }
880                IpcResponse::Ok {
881                    message: format!("left: {namespace}"),
882                }
883            }
884            Err(error) => response_from_error(error),
885        }
886    }
887
888    fn handle_trigger_sync(&self, namespace: Option<String>) -> IpcResponse {
889        match self.daemon_handle.sync_trigger.send(namespace) {
890            Ok(()) => IpcResponse::Ok {
891                message: "synchronization requested".to_owned(),
892            },
893            Err(error) => response_from_error(error),
894        }
895    }
896
897    fn handle_reload_config(&self) -> IpcResponse {
898        self.daemon_handle.reload_requested.store(true, Ordering::Release);
899        if self.daemon_handle.sync_trigger.send(None).is_err() {
900            tracing::debug!("daemon reload wake-up channel is not connected");
901        }
902        IpcResponse::Ok {
903            message: "configuration reload requested".to_owned(),
904        }
905    }
906
907    fn handle_shutdown(&self, force: bool) -> IpcResponse {
908        if let Err(error) = self.daemon_handle.shutdown_sender.send(()) {
909            return response_from_error(error);
910        }
911        IpcResponse::Ok {
912            message: if force {
913                "forced shutdown requested".to_owned()
914            } else {
915                "shutdown requested".to_owned()
916            },
917        }
918    }
919
920    async fn handle_download_response(&self, namespace: String, strategy: FetchStrategy) -> IpcResponse {
921        match self.handle_download(namespace, strategy).await {
922            Ok(bytes_transferred) => IpcResponse::DownloadComplete { bytes_transferred },
923            Err(error) => response_from_error(error),
924        }
925    }
926
927    async fn handle_materialize_transfers(&self, namespace: Option<String>) -> IpcResponse {
928        let Some(context) = self.archive_context.clone() else {
929            return IpcResponse::Error {
930                message: "transfer materialization requires daemon node context".to_owned(),
931            };
932        };
933        let Some(node_db) = self.node_db.clone() else {
934            return IpcResponse::Error {
935                message: "transfer materialization requires node database".to_owned(),
936            };
937        };
938        let summary = match super::transfers::process_transfer_jobs(
939            context.node.as_ref(),
940            &node_db,
941            namespace.as_deref(),
942        )
943        .await
944        {
945            Ok(summary) => summary,
946            Err(error) => return response_from_error(error),
947        };
948        IpcResponse::TransferJobsProcessed {
949            completed: summary.completed,
950            failed: summary.failed,
951        }
952    }
953
954    async fn handle_import_files_response(&self, namespace: Option<String>, path: PathBuf) -> IpcResponse {
955        match self.handle_import_files(namespace, path).await {
956            Ok(entries) => IpcResponse::ImportFilesComplete { entries },
957            Err(error) => response_from_error(error),
958        }
959    }
960
961    async fn handle_download(&self, namespace: String, strategy: FetchStrategy) -> Result<u64> {
962        self.handle_download_with_timeout(namespace, strategy, DOWNLOAD_TIMEOUT)
963            .await
964    }
965
966    async fn handle_download_with_timeout(
967        &self,
968        namespace: String,
969        strategy: FetchStrategy,
970        timeout_duration: Duration,
971    ) -> Result<u64> {
972        let context = self.archive_context.clone().ok_or_else(|| {
973            SyncwebError::operation("daemon download IPC is unavailable", "server has no node context")
974        })?;
975        let namespace_id = iroh_docs::NamespaceId::from_str(&namespace)
976            .map_err(|error| SyncwebError::operation("invalid download namespace", error))?;
977        let sync = SyncEngine::new(
978            FolderManager::new(&context.node),
979            context.node.blob_store().clone(),
980            context.node.docs_engine().clone(),
981            Some(context.node.topic_tracker().clone()),
982        );
983        let mut intent = sync.fetch(namespace_id, strategy).await?;
984        let bytes_transferred = if timeout_duration == DOWNLOAD_TIMEOUT {
985            self.run_download_loop(&mut intent).await?
986        } else {
987            self.run_download_loop_with_timeout(&mut intent, timeout_duration)
988                .await?
989        };
990        Ok(bytes_transferred)
991    }
992
993    async fn run_download_loop(&self, intent: &mut crate::sync::IntentHandle) -> Result<u64> {
994        self.run_download_loop_with_timeout(intent, DOWNLOAD_TIMEOUT).await
995    }
996
997    async fn run_download_loop_with_timeout(
998        &self,
999        intent: &mut crate::sync::IntentHandle,
1000        timeout_duration: Duration,
1001    ) -> Result<u64> {
1002        let mut bytes_transferred = 0_u64;
1003
1004        let loop_body = async {
1005            while let Some(event) = intent.next().await {
1006                match event {
1007                    SyncEvent::Stats(stats) => {
1008                        bytes_transferred = bytes_transferred.max(stats.bytes_transferred);
1009                    }
1010                    SyncEvent::Failed(message) => {
1011                        return Err(SyncwebError::operation("daemon download failed", message));
1012                    }
1013                    SyncEvent::Finished => return Ok(()),
1014                    SyncEvent::Started
1015                    | SyncEvent::Progress { .. }
1016                    | SyncEvent::Paused
1017                    | SyncEvent::Resumed
1018                    | SyncEvent::Cancelled => {}
1019                }
1020            }
1021            Ok(())
1022        };
1023
1024        #[cfg(unix)]
1025        {
1026            use tokio::time::timeout;
1027            match timeout(timeout_duration, loop_body).await {
1028                Ok(Ok(())) => {}
1029                Ok(Err(error)) => return Err(error),
1030                Err(_elapsed) => {
1031                    let _ = intent.cancel();
1032                }
1033            }
1034        }
1035
1036        #[cfg(not(unix))]
1037        {
1038            let _ = timeout_duration;
1039            loop_body.await?;
1040        }
1041
1042        Ok(bytes_transferred)
1043    }
1044
1045    async fn handle_import_files(&self, namespace: Option<String>, path: PathBuf) -> Result<u64> {
1046        let context = self.archive_context.clone().ok_or_else(|| {
1047            SyncwebError::operation("daemon filesystem import is unavailable", "server has no node context")
1048        })?;
1049        let namespace_id = if let Some(value) = namespace {
1050            iroh_docs::NamespaceId::from_str(&value)
1051                .map_err(|error| SyncwebError::operation("invalid import namespace", error))?
1052        } else {
1053            let folders = self.daemon_handle.folder_registry.read().await.statuses();
1054            let [folder] = folders.as_slice() else {
1055                return Err(SyncwebError::operation(
1056                    "cannot infer import namespace",
1057                    "specify a folder when more than one folder is managed",
1058                ));
1059            };
1060            iroh_docs::NamespaceId::from_str(&folder.namespace)
1061                .map_err(|error| SyncwebError::operation("invalid managed folder namespace", error))?
1062        };
1063        let folder = FolderManager::new(&context.node).get(namespace_id).await?;
1064        let root = if path.is_dir() {
1065            path.clone()
1066        } else {
1067            path.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf)
1068        };
1069        let importer = Importer::new(
1070            context.node.blob_store().clone(),
1071            context.node.docs_engine().clone(),
1072            folder.doc().clone(),
1073            folder.author(),
1074        )
1075        .with_root(root);
1076        let entries = importer.import_path(path).await?;
1077        u64::try_from(entries.len()).map_err(|error| SyncwebError::operation("import entry count overflowed", error))
1078    }
1079
1080    async fn handle_import_archive(
1081        &self,
1082        input: PathBuf,
1083        target: PathBuf,
1084        filter: Option<FilterConfig>,
1085    ) -> Result<DropImportResult> {
1086        let context = self.archive_context.clone().ok_or_else(|| {
1087            SyncwebError::operation("daemon archive IPC is unavailable", "server has no node context")
1088        })?;
1089        let filter_engine = filter.map(FilterEngine::new).transpose()?;
1090        let options = filter_engine.map_or_else(DropImportOptions::default, |value| {
1091            DropImportOptions::default().with_filter(value)
1092        });
1093        let importer = DropImporter::new(context.node.blob_store().clone());
1094        let mut result = importer
1095            .import_archive(&input, options, Some(context.pool.as_ref()))
1096            .await?;
1097        importer.materialize(&result, &target).await?;
1098        let folder = FolderManager::new(&context.node).create(SyncMode::SendReceive).await?;
1099        CollectionStore::for_node(&context.node, &folder)
1100            .publish(&result.collection_manifest, 1)
1101            .await?;
1102        result.namespace_id = Some(folder.namespace_id());
1103        Ok(result)
1104    }
1105
1106    async fn handle_export_archive(
1107        &self,
1108        namespace: String,
1109        version: Option<String>,
1110        output: PathBuf,
1111    ) -> Result<DropExportResult> {
1112        let context = self.archive_context.clone().ok_or_else(|| {
1113            SyncwebError::operation("daemon archive IPC is unavailable", "server has no node context")
1114        })?;
1115        let namespace_id = iroh_docs::NamespaceId::from_str(&namespace)
1116            .map_err(|error| SyncwebError::operation("invalid export namespace", error))?;
1117        let folder = FolderManager::new(&context.node).get(namespace_id).await?;
1118        let head = Self::latest_collection_head(&context.node, folder.doc()).await?;
1119        let manifests = Self::collection_manifests(&context.node, folder.doc(), head).await?;
1120        let options = version.map_or_else(DropExportOptions::default, |value| {
1121            DropExportOptions::default().with_version(value)
1122        });
1123        DropExporter::new(context.node.blob_store().clone())
1124            .export_drop_with_options(&manifests, output, options, Some(context.pool.as_ref()))
1125            .await
1126    }
1127
1128    async fn handle_join(
1129        &self,
1130        ticket: String,
1131        path: PathBuf,
1132        mode: SyncMode,
1133        filters: SubscribeFilters,
1134        options: JoinOptions,
1135    ) -> IpcResponse {
1136        let context = match &self.archive_context {
1137            Some(ctx) => ctx.clone(),
1138            None => {
1139                return IpcResponse::Error {
1140                    message: "daemon join IPC is unavailable: server has no node context".to_owned(),
1141                };
1142            }
1143        };
1144        match tokio::fs::create_dir_all(&path).await {
1145            Ok(()) => {}
1146            Err(error) => {
1147                return IpcResponse::Error {
1148                    message: format!("failed to create folder path: {error}"),
1149                };
1150            }
1151        }
1152        let manager = FolderManager::new(&context.node);
1153        match manager.join(ticket, mode).await {
1154            Ok(folder) => {
1155                let namespace = folder.namespace_id().to_string();
1156                if options.indexing
1157                    && let Some(indexing_service) = &context.indexing
1158                    && let Err(error) = indexing_service.enable_folder(&folder).await
1159                {
1160                    tracing::warn!(%error, %namespace, "failed to enable indexing for joined folder");
1161                }
1162                if let Some(ref node_db) = self.node_db {
1163                    let mut config = match node_db.load_app_config() {
1164                        Ok(config) => config,
1165                        Err(error) => return response_from_error(error),
1166                    };
1167                    config.set_subscribe(&namespace, options.subscribe, &filters);
1168                    if let Err(error) = node_db.save_app_config(&config) {
1169                        return response_from_error(error);
1170                    }
1171                }
1172                if options.subscribe {
1173                    let sync = SyncEngine::new(
1174                        manager.clone(),
1175                        context.node.blob_store().clone(),
1176                        context.node.docs_engine().clone(),
1177                        Some(context.node.topic_tracker().clone()),
1178                    );
1179                    let params = match SubscribeParams::from_filters(&filters) {
1180                        Ok(params) => params,
1181                        Err(error) => return response_from_error(error),
1182                    };
1183                    if let Err(error) = sync.subscribe(folder.namespace_id(), params).await {
1184                        return response_from_error(error);
1185                    }
1186                }
1187                let downloaded = if options.download {
1188                    match self
1189                        .materialize_folder(&context, &manager, &folder, &filters, &path)
1190                        .await
1191                    {
1192                        Ok(count) => count,
1193                        Err(error) => return response_from_error(error),
1194                    }
1195                } else {
1196                    0
1197                };
1198                IpcResponse::Ok {
1199                    message: if options.download {
1200                        format!("joined: {namespace}\ndownloaded: {downloaded} files")
1201                    } else {
1202                        format!("joined: {namespace}")
1203                    },
1204                }
1205            }
1206            Err(error) => response_from_error(error),
1207        }
1208    }
1209
1210    async fn materialize_folder(
1211        &self,
1212        context: &ArchiveContext,
1213        manager: &FolderManager,
1214        folder: &crate::folder::SyncwebFolder,
1215        filters: &SubscribeFilters,
1216        destination: &Path,
1217    ) -> Result<usize> {
1218        let sync = SyncEngine::new(
1219            manager.clone(),
1220            context.node.blob_store().clone(),
1221            context.node.docs_engine().clone(),
1222            Some(context.node.topic_tracker().clone()),
1223        );
1224        let strategy =
1225            FetchStrategy::Filter(FetchFilter::new().with_paths(filters.sync_prefix.clone().into_iter().collect()));
1226        let mut intent = sync.fetch(folder.namespace_id(), strategy).await?;
1227        self.run_download_loop_with_timeout(&mut intent, DOWNLOAD_TIMEOUT)
1228            .await?;
1229        let area = filters
1230            .sync_prefix
1231            .clone()
1232            .map(AreaFilter::Prefix)
1233            .or_else(|| filters.glob.clone().map(AreaFilter::Glob))
1234            .unwrap_or(AreaFilter::All);
1235        let entries = folder.list_entries().await?;
1236        let mut count = 0_usize;
1237        for entry in entries {
1238            let rel = Path::new(&entry.path);
1239            if !area.matches_path(rel) {
1240                continue;
1241            }
1242            let dest = destination.join(rel);
1243            if let Some(parent) = dest.parent()
1244                && let Err(error) = tokio::fs::create_dir_all(parent).await
1245            {
1246                return Err(SyncwebError::operation("failed to create download directory", error));
1247            }
1248            context.node.blob_store().export_to_path(entry.hash, &dest).await?;
1249            count = count.saturating_add(1);
1250        }
1251        Ok(count)
1252    }
1253
1254    async fn handle_set_subscribe(
1255        &self,
1256        namespace: String,
1257        enabled: bool,
1258        requested_filters: Option<SubscribeFilters>,
1259    ) -> IpcResponse {
1260        let context = match &self.archive_context {
1261            Some(ctx) => ctx.clone(),
1262            None => {
1263                return IpcResponse::Error {
1264                    message: "daemon subscribe IPC is unavailable: server has no node context".to_owned(),
1265                };
1266            }
1267        };
1268        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
1269            Ok(id) => id,
1270            Err(error) => {
1271                return IpcResponse::Error {
1272                    message: format!("invalid namespace: {error}"),
1273                };
1274            }
1275        };
1276        let filters = requested_filters.unwrap_or_default();
1277        if let Some(ref node_db) = self.node_db {
1278            let mut config = match node_db.load_app_config() {
1279                Ok(config) => config,
1280                Err(error) => return response_from_error(error),
1281            };
1282            if enabled {
1283                config.set_subscribe(&namespace, true, &filters);
1284            } else {
1285                config.remove_subscribe(&namespace);
1286            }
1287            if let Err(error) = node_db.save_app_config(&config) {
1288                return response_from_error(error);
1289            }
1290        }
1291        if enabled {
1292            let manager = FolderManager::new(&context.node);
1293            let sync = SyncEngine::new(
1294                manager,
1295                context.node.blob_store().clone(),
1296                context.node.docs_engine().clone(),
1297                Some(context.node.topic_tracker().clone()),
1298            );
1299            let params = match SubscribeParams::from_filters(&filters) {
1300                Ok(params) => params,
1301                Err(error) => return response_from_error(error),
1302            };
1303            match sync.subscribe(namespace_id, params).await {
1304                Ok(_intent) => IpcResponse::Ok {
1305                    message: format!("subscribed: {namespace}"),
1306                },
1307                Err(error) => response_from_error(error),
1308            }
1309        } else {
1310            let _ = cancel_session(namespace_id);
1311            IpcResponse::Ok {
1312                message: format!("unsubscribed: {namespace}"),
1313            }
1314        }
1315    }
1316
1317    async fn handle_subscribe_public(&self, ticket: String) -> IpcResponse {
1318        let context = match &self.archive_context {
1319            Some(ctx) => ctx.clone(),
1320            None => {
1321                return IpcResponse::Error {
1322                    message: "daemon subscribe-public IPC is unavailable: server has no node context".to_owned(),
1323                };
1324            }
1325        };
1326        let blob_ticket = match ticket.parse::<iroh_blobs::ticket::BlobTicket>() {
1327            Ok(t) => t,
1328            Err(error) => {
1329                return IpcResponse::Error {
1330                    message: format!("invalid blob ticket: {error}"),
1331                };
1332            }
1333        };
1334        let manager = FolderManager::new(&context.node);
1335        match manager.subscribe_public(&blob_ticket).await {
1336            Ok(hash) => {
1337                let provider = Some(blob_ticket.addr().clone());
1338                let size = match context.node.blob_store().get(hash).await {
1339                    Ok(bytes) => u64::try_from(bytes.len()).unwrap_or_default(),
1340                    Err(error) => return response_from_error(error),
1341                };
1342                let subscription = PublicSubscription::new(hash, provider, size);
1343                let namespace = subscription.namespace_id();
1344                self.daemon_handle
1345                    .folder_registry
1346                    .write()
1347                    .await
1348                    .add_subscription(subscription);
1349                if let Some(ref node_db) = self.node_db
1350                    && let Err(error) = node_db.save_subscription(&hash, size)
1351                {
1352                    tracing::warn!(%hash, %error, "failed to persist subscription");
1353                }
1354                IpcResponse::Ok {
1355                    message: format!("subscribed: {namespace}\nhash: {hash}\nsize: {size}"),
1356                }
1357            }
1358            Err(error) => response_from_error(error),
1359        }
1360    }
1361
1362    async fn handle_create_folder(&self, path: PathBuf, mode: String, indexing: bool) -> IpcResponse {
1363        let context = match &self.archive_context {
1364            Some(ctx) => ctx.clone(),
1365            None => {
1366                return IpcResponse::Error {
1367                    message: "daemon create-folder IPC is unavailable: server has no node context".to_owned(),
1368                };
1369            }
1370        };
1371        match std::fs::create_dir_all(&path) {
1372            Ok(()) => {}
1373            Err(error) => {
1374                return IpcResponse::Error {
1375                    message: format!("failed to create directory: {error}"),
1376                };
1377            }
1378        }
1379        let sync_mode = match SyncMode::from_str(&mode) {
1380            Ok(m) => m,
1381            Err(error) => {
1382                return IpcResponse::Error {
1383                    message: format!("invalid sync mode: {error}"),
1384                };
1385            }
1386        };
1387        let manager = FolderManager::new(&context.node);
1388        match manager.create(sync_mode).await {
1389            Ok(folder) => {
1390                let namespace = folder.namespace_id().to_string();
1391                let namespace_id = folder.namespace_id();
1392                if indexing
1393                    && let Some(indexing_service) = &context.indexing
1394                    && let Err(error) = indexing_service.enable_folder(&folder).await
1395                {
1396                    tracing::warn!(%error, %namespace, "failed to enable indexing for new folder");
1397                }
1398                if self
1399                    .daemon_handle
1400                    .folder_registry
1401                    .write()
1402                    .await
1403                    .add(FolderEntry::new(namespace_id, path))
1404                    .is_err()
1405                {
1406                    tracing::warn!(%namespace, "folder already in daemon registry");
1407                }
1408                match folder.ticket(true).await {
1409                    Ok(_ticket) => IpcResponse::Ok {
1410                        message: format!("namespace: {namespace}"),
1411                    },
1412                    Err(error) => response_from_error(error),
1413                }
1414            }
1415            Err(error) => response_from_error(error),
1416        }
1417    }
1418
1419    async fn handle_stats_files(&self, folder_path: PathBuf) -> IpcResponse {
1420        let context = match &self.archive_context {
1421            Some(ctx) => ctx.clone(),
1422            None => {
1423                return IpcResponse::Error {
1424                    message: "daemon stats-files IPC is unavailable: server has no node context".to_owned(),
1425                };
1426            }
1427        };
1428        let manager = FolderManager::new(&context.node);
1429        let folder = match resolve_folder_for_daemon(&manager, &folder_path).await {
1430            Ok(f) => f,
1431            Err(error) => return error,
1432        };
1433        let entries = match folder.content_entries().await {
1434            Ok(e) => e,
1435            Err(error) => return response_from_error(error),
1436        };
1437        let mut collector = FileStatsCollector::new();
1438        for entry in entries {
1439            collector.add_entry_bytes_with_time(entry.key(), entry.content_len(), Some(entry.timestamp()));
1440        }
1441        IpcResponse::FileStats(Box::new(collector.report()))
1442    }
1443
1444    async fn handle_enrich_sort(&self, path: PathBuf) -> IpcResponse {
1445        let Some(context) = self.archive_context.clone() else {
1446            return IpcResponse::EnrichData(HashMap::new());
1447        };
1448        let manager = FolderManager::new(&context.node);
1449        let Ok(folder) = resolve_folder_for_daemon(&manager, &path).await else {
1450            return IpcResponse::EnrichData(HashMap::new());
1451        };
1452        let Ok(entries) = context.node.docs_engine().list_latest(folder.doc()).await else {
1453            return IpcResponse::EnrichData(HashMap::new());
1454        };
1455        let mut path_map: Vec<(String, iroh_blobs::Hash)> = Vec::new();
1456        for entry in entries {
1457            if entry.key().starts_with(b"sys/") {
1458                continue;
1459            }
1460            let Ok(path_str) = String::from_utf8(entry.key().to_vec()) else {
1461                continue;
1462            };
1463            let hash = entry.content_hash();
1464            path_map.push((path_str, hash));
1465        }
1466
1467        let peers_per_hash: HashMap<iroh_blobs::Hash, usize> = HashMap::new();
1468
1469        let result: HashMap<String, usize> = path_map
1470            .into_iter()
1471            .map(|(path_str, hash)| {
1472                let count = peers_per_hash.get(&hash).copied().unwrap_or(0);
1473                (path_str, count)
1474            })
1475            .collect();
1476        IpcResponse::EnrichData(result)
1477    }
1478
1479    async fn handle_verify_integrity(
1480        &self,
1481        path: PathBuf,
1482        hash: Vec<String>,
1483        path_filter: Option<String>,
1484        glob_filter: Option<String>,
1485        fix: bool,
1486        from: Vec<String>,
1487    ) -> IpcResponse {
1488        let context = match &self.archive_context {
1489            Some(ctx) => ctx.clone(),
1490            None => {
1491                return IpcResponse::Error {
1492                    message: "daemon verify IPC is unavailable: server has no node context".to_owned(),
1493                };
1494            }
1495        };
1496        let manager = FolderManager::new(&context.node);
1497        let folder = match resolve_folder_for_daemon(&manager, &path).await {
1498            Ok(f) => f,
1499            Err(error) => return error,
1500        };
1501        let checker = IntegrityChecker::new(context.node.blob_store().clone(), context.node.docs_engine().clone());
1502
1503        let filter = build_ipc_verify_filter(&hash, path_filter.as_ref(), glob_filter.as_ref());
1504
1505        match checker.verify_folder_filtered(&folder, filter.as_ref()).await {
1506            Ok(result) => {
1507                let mut message = format!(
1508                    "total: {}, verified: {}, corrupted: {}, missing: {}",
1509                    result.total,
1510                    result.verified,
1511                    result.corrupted.len(),
1512                    result.missing.len(),
1513                );
1514                if fix {
1515                    let namespace_id = folder.namespace_id();
1516                    let repair = self
1517                        .run_daemon_repair(&context, namespace_id, &result.corrupted, &from)
1518                        .await;
1519                    let _ = write!(
1520                        message,
1521                        ", repair: attempted {}, repaired {}",
1522                        repair.attempted, repair.repaired,
1523                    );
1524                }
1525                let valid = result.is_valid();
1526                let _ = write!(message, ", valid: {valid}");
1527                IpcResponse::Ok { message }
1528            }
1529            Err(error) => response_from_error(error),
1530        }
1531    }
1532
1533    async fn run_daemon_repair(
1534        &self,
1535        context: &ArchiveContext,
1536        namespace_id: iroh_docs::NamespaceId,
1537        corrupted: &[crate::verify::CorruptionInfo],
1538        tickets: &[String],
1539    ) -> crate::verify::RepairResult {
1540        let mut result = crate::verify::RepairResult::default();
1541        if corrupted.is_empty() {
1542            return result;
1543        }
1544
1545        let provider_tickets: Vec<(iroh_blobs::Hash, iroh_blobs::ticket::BlobTicket)> = tickets
1546            .iter()
1547            .filter_map(|t| {
1548                let ticket: iroh_blobs::ticket::BlobTicket = t.parse().ok()?;
1549                Some((ticket.hash(), ticket))
1550            })
1551            .collect();
1552
1553        let namespace_peers = context
1554            .node
1555            .topic_tracker()
1556            .find_peers(namespace_id)
1557            .await
1558            .unwrap_or_default();
1559
1560        for item in corrupted {
1561            result.attempted = result.attempted.saturating_add(1);
1562            let hash = item.expected_hash;
1563
1564            // Try --from tickets first, then namespace peers.
1565            let candidate_tickets: Vec<iroh_blobs::ticket::BlobTicket> = provider_tickets
1566                .iter()
1567                .filter(|(ticket_hash, _)| *ticket_hash == hash)
1568                .map(|(_, ticket)| ticket.clone())
1569                .collect();
1570            let repaired = crate::node::blob_store::try_fetch_first_working(
1571                &candidate_tickets,
1572                |ticket| context.node.blob_store().force_fetch(context.node.endpoint(), ticket),
1573            )
1574            .await
1575            .is_ok()
1576                || crate::node::blob_store::try_fetch_first_working(&namespace_peers, |peer| async {
1577                    context
1578                        .node
1579                        .blob_store()
1580                        .force_fetch_from_peer(context.node.endpoint(), peer, hash)
1581                        .await
1582                })
1583                .await
1584                .is_ok();
1585            if repaired {
1586                result.repaired = result.repaired.saturating_add(1);
1587            } else {
1588                result.failed.push(crate::verify::RepairOutcome {
1589                    path: item.path.clone(),
1590                    hash,
1591                    success: false,
1592                    error: Some("no alternative providers available".to_owned()),
1593                });
1594            }
1595        }
1596        result
1597    }
1598
1599    async fn handle_unsubscribe_command(&self, namespace: &str) -> IpcResponse {
1600        if !namespace.starts_with("blob:") {
1601            return IpcResponse::Error {
1602                message: "unsubscribe applies only to public blob subscriptions; use `leave` for folders".to_owned(),
1603            };
1604        }
1605        self.daemon_handle
1606            .folder_registry
1607            .write()
1608            .await
1609            .remove_subscription(namespace);
1610        if let Some(ref manager) = self.folder_manager
1611            && let Some(hash_str) = namespace.strip_prefix("blob:")
1612            && let Ok(hash) = hash_str.parse::<iroh_blobs::Hash>()
1613        {
1614            manager.drop_subscription(&hash).await;
1615        }
1616        if let Some(ref node_db) = self.node_db
1617            && let Some(hash_str) = namespace.strip_prefix("blob:")
1618            && let Ok(hash) = hash_str.parse::<iroh_blobs::Hash>()
1619            && let Err(error) = node_db.remove_subscription(&hash)
1620        {
1621            tracing::warn!(%hash, %error, "failed to remove subscription from database");
1622        }
1623        IpcResponse::Ok {
1624            message: format!("unsubscribed: {namespace}"),
1625        }
1626    }
1627
1628    async fn handle_share_group(&self, cmd: IpcCommand) -> IpcResponse {
1629        if let IpcCommand::Share {
1630            namespace,
1631            blob,
1632            writable,
1633            pin,
1634            persist,
1635        } = cmd
1636        {
1637            let options = ShareOptions { writable, pin, persist };
1638            return self.handle_share(namespace, blob, options).await;
1639        }
1640        if matches!(cmd, IpcCommand::ShareList) {
1641            return self.handle_share_list();
1642        }
1643        if let IpcCommand::Unshare {
1644            namespace,
1645            blob,
1646            writable,
1647        } = cmd
1648        {
1649            return self.handle_unshare(namespace, blob, writable).await;
1650        }
1651        IpcResponse::Error {
1652            message: format!("unhandled share command: {cmd:?}"),
1653        }
1654    }
1655
1656    async fn handle_share(&self, namespace: String, blob: Option<String>, options: ShareOptions) -> IpcResponse {
1657        let context = match &self.archive_context {
1658            Some(ctx) => ctx.clone(),
1659            None => {
1660                return IpcResponse::Error {
1661                    message: "daemon share IPC is unavailable: server has no node context".to_owned(),
1662                };
1663            }
1664        };
1665        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
1666            Ok(id) => id,
1667            Err(error) => {
1668                return IpcResponse::Error {
1669                    message: format!("invalid namespace: {error}"),
1670                };
1671            }
1672        };
1673        let manager = FolderManager::new(&context.node);
1674        let folder = match manager.get(namespace_id).await {
1675            Ok(f) => f,
1676            Err(error) => return response_from_error(error),
1677        };
1678        if let Some(hash_str) = blob {
1679            let hash = match hash_str.parse::<iroh_blobs::Hash>() {
1680                Ok(h) => h,
1681                Err(error) => {
1682                    return IpcResponse::Error {
1683                        message: format!("invalid blob hash: {error}"),
1684                    };
1685                }
1686            };
1687            return match folder.publish_blob(context.node.endpoint().addr(), hash).await {
1688                Ok(ticket) => IpcResponse::Ok {
1689                    message: ticket.to_string(),
1690                },
1691                Err(error) => response_from_error(error),
1692            };
1693        }
1694        let result = match share_folder(
1695            &folder,
1696            options,
1697            |ns, access, ticket| {
1698                if let Some(node_db) = &self.node_db {
1699                    node_db.add_share(&ns.to_string(), access, &ticket.to_string())?;
1700                }
1701                Ok(())
1702            },
1703        )
1704        .await
1705        {
1706            Ok(result) => result,
1707            Err(error) => return response_from_error(error),
1708        };
1709        IpcResponse::Ok { message: result.url }
1710    }
1711
1712    fn handle_share_list(&self) -> IpcResponse {
1713        let Some(node_db) = &self.node_db else {
1714            return IpcResponse::Error {
1715                message: "daemon share-list IPC is unavailable: no node database".to_owned(),
1716            };
1717        };
1718        match node_db.list_shares() {
1719            Ok(shares) => {
1720                if shares.is_empty() {
1721                    return IpcResponse::Ok {
1722                        message: "no shares".to_owned(),
1723                    };
1724                }
1725                let mut lines = Vec::new();
1726                for (namespace, access, ticket) in shares {
1727                    lines.push(format!("namespace: {namespace}\naccess: {access}\nticket: {ticket}"));
1728                }
1729                IpcResponse::Ok {
1730                    message: lines.join("\n\n"),
1731                }
1732            }
1733            Err(error) => response_from_error(error),
1734        }
1735    }
1736
1737    async fn handle_unshare(&self, namespace: String, blob: Option<String>, writable: bool) -> IpcResponse {
1738        let context = match &self.archive_context {
1739            Some(ctx) => ctx.clone(),
1740            None => {
1741                return IpcResponse::Error {
1742                    message: "daemon unshare IPC is unavailable: server has no node context".to_owned(),
1743                };
1744            }
1745        };
1746        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
1747            Ok(id) => id,
1748            Err(error) => {
1749                return IpcResponse::Error {
1750                    message: format!("invalid namespace: {error}"),
1751                };
1752            }
1753        };
1754        let manager = FolderManager::new(&context.node);
1755        if let Some(hash_str) = blob {
1756            let hash = match hash_str.parse::<iroh_blobs::Hash>() {
1757                Ok(h) => h,
1758                Err(error) => {
1759                    return IpcResponse::Error {
1760                        message: format!("invalid blob hash: {error}"),
1761                    };
1762                }
1763            };
1764            let folder = match manager.get(namespace_id).await {
1765                Ok(f) => f,
1766                Err(error) => return response_from_error(error),
1767            };
1768            return match folder.unpublish_blob(hash).await {
1769                Ok(()) => IpcResponse::Ok {
1770                    message: format!("unshared: {namespace_id} (blob {hash_str})"),
1771                },
1772                Err(error) => response_from_error(error),
1773            };
1774        }
1775        let access = if writable { "write" } else { "read" };
1776        if let Some(node_db) = &self.node_db
1777            && let Err(error) = node_db.remove_share(&namespace_id.to_string(), access)
1778        {
1779            return response_from_error(error);
1780        }
1781        if let Ok(folder) = manager.get(namespace_id).await {
1782            let _ = folder.unpin_all_content().await;
1783        }
1784        IpcResponse::Ok {
1785            message: format!("unshared: {namespace_id} ({access})"),
1786        }
1787    }
1788
1789    async fn handle_snapshot_create(&self, path: PathBuf, description: Option<String>, threads: usize) -> IpcResponse {
1790        let context = match &self.archive_context {
1791            Some(ctx) => ctx.clone(),
1792            None => {
1793                return IpcResponse::Error {
1794                    message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
1795                };
1796            }
1797        };
1798        let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
1799        let result = if path.exists() {
1800            snapshots.create_from_path(&path, threads, description).await
1801        } else {
1802            let manager = FolderManager::new(&context.node);
1803            let folder = match resolve_folder_for_daemon(&manager, &path).await {
1804                Ok(f) => f,
1805                Err(error) => return error,
1806            };
1807            snapshots.create_for_folder(&folder, description).await
1808        };
1809        match result {
1810            Ok(snapshot) => IpcResponse::Ok {
1811                message: snapshot.id.to_string(),
1812            },
1813            Err(error) => response_from_error(error),
1814        }
1815    }
1816
1817    async fn handle_snapshot_list(&self, path: PathBuf) -> IpcResponse {
1818        let context = match &self.archive_context {
1819            Some(ctx) => ctx.clone(),
1820            None => {
1821                return IpcResponse::Error {
1822                    message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
1823                };
1824            }
1825        };
1826        let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
1827        let namespace = path.to_string_lossy().parse::<iroh_docs::NamespaceId>().ok();
1828        match snapshots.list().await {
1829            Ok(all) => {
1830                let count = all
1831                    .into_iter()
1832                    .filter(|s| namespace.is_none_or(|id| s.namespace_id == Some(id)))
1833                    .count();
1834                IpcResponse::Ok {
1835                    message: format!("snapshots: {count}"),
1836                }
1837            }
1838            Err(error) => response_from_error(error),
1839        }
1840    }
1841
1842    async fn handle_snapshot_delete(&self, id: String) -> IpcResponse {
1843        let context = match &self.archive_context {
1844            Some(ctx) => ctx.clone(),
1845            None => {
1846                return IpcResponse::Error {
1847                    message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
1848                };
1849            }
1850        };
1851        let hash = match id.parse::<iroh_blobs::Hash>() {
1852            Ok(h) => h,
1853            Err(error) => {
1854                return IpcResponse::Error {
1855                    message: format!("invalid snapshot id: {error}"),
1856                };
1857            }
1858        };
1859        let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
1860        match snapshots.delete(hash).await {
1861            Ok(()) => IpcResponse::Ok {
1862                message: format!("deleted: {id}"),
1863            },
1864            Err(error) => response_from_error(error),
1865        }
1866    }
1867
1868    async fn handle_network_invite(&self, network_id: String, device: String) -> IpcResponse {
1869        let Some(ref net_mgr) = self.network_manager else {
1870            return IpcResponse::Error {
1871                message: "network manager not available in IPC server".to_owned(),
1872            };
1873        };
1874        let net_id = match network_id.parse::<crate::net::NetworkId>() {
1875            Ok(id) => id,
1876            Err(e) => return response_from_error(e),
1877        };
1878        let peer = match device.parse::<iroh::PublicKey>() {
1879            Ok(pk) => pk,
1880            Err(e) => {
1881                return IpcResponse::Error {
1882                    message: format!("invalid device ID: {e}"),
1883                };
1884            }
1885        };
1886        let result = net_mgr.write().await.invite(net_id, peer);
1887        match result {
1888            Ok(ticket) => IpcResponse::Ok {
1889                message: ticket.to_string(),
1890            },
1891            Err(e) => response_from_error(e),
1892        }
1893    }
1894
1895    async fn handle_network_kick(&self, network_id: String, device: String) -> IpcResponse {
1896        let Some(ref net_mgr) = self.network_manager else {
1897            return IpcResponse::Error {
1898                message: "network manager not available in IPC server".to_owned(),
1899            };
1900        };
1901        let net_id = match network_id.parse::<crate::net::NetworkId>() {
1902            Ok(id) => id,
1903            Err(e) => return response_from_error(e),
1904        };
1905        let peer = match device.parse::<iroh::PublicKey>() {
1906            Ok(pk) => pk,
1907            Err(e) => {
1908                return IpcResponse::Error {
1909                    message: format!("invalid device ID: {e}"),
1910                };
1911            }
1912        };
1913        let result = net_mgr.write().await.kick(net_id, &peer);
1914        match result {
1915            Ok(()) => IpcResponse::Ok {
1916                message: "member kicked".to_owned(),
1917            },
1918            Err(e) => response_from_error(e),
1919        }
1920    }
1921
1922    async fn handle_network_leave(&self, network_id: String) -> IpcResponse {
1923        let Some(ref net_mgr) = self.network_manager else {
1924            return IpcResponse::Error {
1925                message: "network manager not available in IPC server".to_owned(),
1926            };
1927        };
1928        let net_id = match network_id.parse::<crate::net::NetworkId>() {
1929            Ok(id) => id,
1930            Err(e) => return response_from_error(e),
1931        };
1932        let result = net_mgr.write().await.leave(net_id);
1933        match result {
1934            Ok(()) => IpcResponse::Ok {
1935                message: "left network".to_owned(),
1936            },
1937            Err(e) => response_from_error(e),
1938        }
1939    }
1940
1941    async fn handle_network_create(
1942        &self,
1943        name: String,
1944        label: String,
1945        invite_only: bool,
1946        doc_ticket: Option<String>,
1947    ) -> IpcResponse {
1948        let Some(ref net_mgr) = self.network_manager else {
1949            return IpcResponse::Error {
1950                message: "network manager not available in IPC server".to_owned(),
1951            };
1952        };
1953        let options = crate::net::NetworkOptions {
1954            label,
1955            invite_only,
1956            ..crate::net::NetworkOptions::default()
1957        };
1958        let result = net_mgr.write().await.create_with_doc_ticket(&name, options, doc_ticket);
1959        match result {
1960            Ok(id) => IpcResponse::Ok {
1961                message: format!("created network {id}"),
1962            },
1963            Err(e) => response_from_error(e),
1964        }
1965    }
1966
1967    async fn handle_network_join(&self, ticket: String) -> IpcResponse {
1968        let Some(ref net_mgr) = self.network_manager else {
1969            return IpcResponse::Error {
1970                message: "network manager not available in IPC server".to_owned(),
1971            };
1972        };
1973        let parsed = match ticket.parse::<crate::net::NetworkTicket>() {
1974            Ok(t) => t,
1975            Err(e) => return response_from_error(e),
1976        };
1977        let result = net_mgr.write().await.join(parsed);
1978        match result {
1979            Ok(id) => IpcResponse::Ok {
1980                message: format!("joined network {id}"),
1981            },
1982            Err(e) => response_from_error(e),
1983        }
1984    }
1985
1986    async fn handle_collection_publish(
1987        &self,
1988        path: PathBuf,
1989        namespace: String,
1990        sequence: u64,
1991        _bootstrap: Vec<String>,
1992        manifest_bytes: Option<Vec<u8>>,
1993    ) -> IpcResponse {
1994        let context = match &self.archive_context {
1995            Some(ctx) => ctx.clone(),
1996            None => {
1997                return IpcResponse::Error {
1998                    message: "daemon collection-publish IPC is unavailable: server has no node context".to_owned(),
1999                };
2000            }
2001        };
2002        let manifest = match manifest_bytes {
2003            Some(bytes) => match CollectionManifest::from_bytes(bytes) {
2004                Ok(m) => m,
2005                Err(error) => return response_from_error(error),
2006            },
2007            None => {
2008                return IpcResponse::Error {
2009                    message: "collection publish requires manifest_bytes; run `package init` first".to_owned(),
2010                };
2011            }
2012        };
2013        for entry in &manifest.entries {
2014            let file_path = path.join(&entry.logical_path);
2015            match context.node.blob_store().add_file(&file_path).await {
2016                Ok(hash) => {
2017                    if hash != entry.content_id {
2018                        return IpcResponse::Error {
2019                            message: format!(
2020                                "collection content changed while publishing: {}",
2021                                entry.logical_path.display()
2022                            ),
2023                        };
2024                    }
2025                }
2026                Err(error) => return response_from_error(error),
2027            }
2028        }
2029        let namespace_id = match namespace.parse::<iroh_docs::NamespaceId>() {
2030            Ok(id) => id,
2031            Err(error) => {
2032                return IpcResponse::Error {
2033                    message: format!("invalid namespace: {error}"),
2034                };
2035            }
2036        };
2037        let manager = FolderManager::new(&context.node);
2038        let folder = match manager.get(namespace_id).await {
2039            Ok(f) => f,
2040            Err(error) => return response_from_error(error),
2041        };
2042        let store = CollectionStore::for_node(&context.node, &folder);
2043        let head = match store.publish(&manifest, sequence).await {
2044            Ok(h) => h,
2045            Err(error) => return response_from_error(error),
2046        };
2047        let ticket = context.node.blob_store().ticket(context.node.endpoint(), head.manifest);
2048        IpcResponse::Ok {
2049            message: format!(
2050                "manifest: {}\nmanifest_ticket: {}\nsequence: {}",
2051                head.manifest, ticket, head.sequence,
2052            ),
2053        }
2054    }
2055
2056    #[cfg(unix)]
2057    async fn handle_connection(&self, stream: tokio::net::UnixStream) -> Result<()> {
2058        use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
2059        let (read_half, mut write_half) = stream.into_split();
2060        let mut line = Vec::new();
2061        BufReader::new(read_half).read_until(b'\n', &mut line).await?;
2062        let response = match serde_json::from_slice::<IpcRequest>(line.trim_ascii()) {
2063            Ok(request) => self.handle_request(request).await,
2064            Err(error) => IpcResponse::Error {
2065                message: format!("invalid daemon request: {error}"),
2066            },
2067        };
2068        let mut bytes = serde_json::to_vec(&response)
2069            .map_err(|error| SyncwebError::operation("failed to serialize IPC response", error))?;
2070        bytes.push(b'\n');
2071        write_half.write_all(&bytes).await?;
2072        Ok(())
2073    }
2074
2075    async fn latest_collection_head(node: &IrohNode, doc: &iroh_docs::api::Doc) -> Result<CollectionHead> {
2076        let entries = node.docs_engine().list_latest(doc).await?;
2077        let head_entry = entries
2078            .iter()
2079            .find(|entry| entry.key().starts_with(b"collections/") && entry.key().ends_with(b"/head"))
2080            .ok_or_else(|| SyncwebError::InvalidConfig("folder has no published collection head".to_owned()))?;
2081        let bytes = node.blob_store().get(head_entry.content_hash()).await?;
2082        let head = serde_json::from_slice(&bytes)
2083            .map_err(|error| SyncwebError::operation("failed to deserialize collection head", error))?;
2084        Ok(head)
2085    }
2086
2087    async fn collection_manifests(
2088        node: &IrohNode,
2089        doc: &iroh_docs::api::Doc,
2090        head: CollectionHead,
2091    ) -> Result<Vec<CollectionManifest>> {
2092        let prefix = format!("collections/{}/manifests/", head.collection_id);
2093        let entries = node.docs_engine().list_latest(doc).await?;
2094        let mut manifests = Vec::new();
2095        for entry in entries {
2096            if !entry.key().starts_with(prefix.as_bytes()) {
2097                continue;
2098            }
2099            let bytes = node.blob_store().get(entry.content_hash()).await?;
2100            let manifest = CollectionManifest::from_bytes(bytes)?;
2101            manifests.push(manifest);
2102        }
2103        if manifests.is_empty() {
2104            return Err(SyncwebError::InvalidConfig(
2105                "folder has no published collection manifests".to_owned(),
2106            ));
2107        }
2108        Ok(manifests)
2109    }
2110}
2111
2112fn response_from_error(error: impl std::fmt::Display) -> IpcResponse {
2113    IpcResponse::Error {
2114        message: error.to_string(),
2115    }
2116}
2117
2118fn build_ipc_verify_filter(
2119    hash: &[String],
2120    path_filter: Option<&String>,
2121    glob_filter: Option<&String>,
2122) -> Option<crate::verify::VerifyFilter> {
2123    let has_filter = !hash.is_empty() || path_filter.is_some() || glob_filter.is_some();
2124    if !has_filter {
2125        return None;
2126    }
2127    let mut filter = crate::verify::VerifyFilter::new();
2128    if !hash.is_empty() {
2129        let hashes: Vec<iroh_blobs::Hash> = hash.iter().filter_map(|h| h.parse::<iroh_blobs::Hash>().ok()).collect();
2130        if !hashes.is_empty() {
2131            filter = filter.with_hashes(hashes);
2132        }
2133    }
2134    if let Some(p) = path_filter {
2135        filter = filter.with_path(std::path::PathBuf::from(p));
2136    }
2137    if let Some(g) = glob_filter {
2138        filter.glob = Some(g.clone());
2139    }
2140    Some(filter)
2141}
2142
2143async fn resolve_folder_for_daemon(
2144    manager: &FolderManager,
2145    selector: &Path,
2146) -> std::result::Result<crate::folder::SyncwebFolder, IpcResponse> {
2147    manager.resolve(selector).await.map_err(|error| IpcResponse::Error {
2148        message: error.to_string(),
2149    })
2150}
2151
2152/// A client for sending requests to the daemon.
2153#[derive(Clone, Debug)]
2154pub struct IpcClient {
2155    socket_path: PathBuf,
2156}
2157
2158impl IpcClient {
2159    #[must_use]
2160    pub fn new(data_dir: &Path) -> Self {
2161        Self::from_socket_path(daemon_socket_path(data_dir))
2162    }
2163
2164    #[must_use]
2165    pub const fn from_socket_path(socket_path: PathBuf) -> Self {
2166        Self { socket_path }
2167    }
2168
2169    #[must_use]
2170    pub fn socket_path(&self) -> &Path {
2171        &self.socket_path
2172    }
2173
2174    /// Send one newline-delimited JSON request and await its response.
2175    ///
2176    /// # Errors
2177    ///
2178    /// Returns an error when the socket is unavailable, the operation times
2179    /// out, or either JSON message is malformed.
2180    pub async fn send(&self, request: IpcRequest) -> Result<IpcResponse> {
2181        #[cfg(unix)]
2182        {
2183            use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
2184            use tokio::time::timeout;
2185            let mut stream = timeout(IPC_TIMEOUT, tokio::net::UnixStream::connect(&self.socket_path))
2186                .await
2187                .map_err(|error| SyncwebError::operation("daemon IPC connection timed out", error))?
2188                .map_err(|error| {
2189                    SyncwebError::operation(
2190                        format!("failed to connect to daemon socket at {}", self.socket_path.display()),
2191                        error,
2192                    )
2193                })?;
2194            let mut message = serde_json::to_vec(&request)
2195                .map_err(|error| SyncwebError::operation("failed to serialize IPC request", error))?;
2196            message.push(b'\n');
2197            timeout(IPC_TIMEOUT, stream.write_all(&message))
2198                .await
2199                .map_err(|error| SyncwebError::operation("daemon IPC write timed out", error))?
2200                .map_err(|error| SyncwebError::operation("daemon IPC write failed", error))?;
2201            let mut response = Vec::new();
2202            let mut reader = BufReader::new(stream);
2203            timeout(IPC_TIMEOUT, reader.read_until(b'\n', &mut response))
2204                .await
2205                .map_err(|error| SyncwebError::operation("daemon IPC read timed out", error))?
2206                .map_err(|error| SyncwebError::operation("daemon IPC read failed", error))?;
2207            if response.is_empty() {
2208                return Err(SyncwebError::operation(
2209                    "daemon IPC returned no response",
2210                    "connection closed",
2211                ));
2212            }
2213            serde_json::from_slice(response.trim_ascii())
2214                .map_err(|error| SyncwebError::operation("failed to deserialize IPC response", error))
2215        }
2216        #[cfg(not(unix))]
2217        {
2218            let _ = request;
2219            Err(SyncwebError::operation(
2220                "daemon IPC is unavailable",
2221                "Unix sockets are not supported on this platform",
2222            ))
2223        }
2224    }
2225
2226    /// Perform a bounded synchronous status probe for routing decisions.
2227    ///
2228    /// # Errors
2229    ///
2230    /// Returns an error when the daemon does not answer a status request.
2231    pub fn status_sync(&self) -> Result<DaemonStatus> {
2232        #[cfg(unix)]
2233        {
2234            use std::{
2235                io::{BufRead, Write},
2236                os::unix::net::UnixStream,
2237            };
2238
2239            let stream = UnixStream::connect(&self.socket_path).map_err(|error| {
2240                SyncwebError::operation(
2241                    format!("failed to connect to daemon socket at {}", self.socket_path.display()),
2242                    error,
2243                )
2244            })?;
2245            stream.set_read_timeout(Some(IPC_TIMEOUT))?;
2246            stream.set_write_timeout(Some(IPC_TIMEOUT))?;
2247            let mut writer = stream.try_clone()?;
2248            let request = serde_json::to_vec(&IpcRequest::new(IpcCommand::Status))
2249                .map_err(|error| SyncwebError::operation("failed to serialize IPC request", error))?;
2250            writer.write_all(&request)?;
2251            writer.write_all(b"\n")?;
2252            let mut line = String::new();
2253            std::io::BufReader::new(stream).read_line(&mut line)?;
2254            match serde_json::from_str::<IpcResponse>(&line)
2255                .map_err(|error| SyncwebError::operation("failed to deserialize IPC response", error))?
2256            {
2257                IpcResponse::Status(status) => Ok(status),
2258                IpcResponse::Error { message } => Err(SyncwebError::operation("daemon status request failed", message)),
2259                IpcResponse::Ok { .. }
2260                | IpcResponse::FolderList(_)
2261                | IpcResponse::DownloadComplete { .. }
2262                | IpcResponse::ImportFilesComplete { .. }
2263                | IpcResponse::ImportComplete(_)
2264                | IpcResponse::ExportComplete(_)
2265                | IpcResponse::EnrichData(_)
2266                | IpcResponse::FileStats(_)
2267                | IpcResponse::TransferJobsProcessed { .. } => Err(SyncwebError::operation(
2268                    "daemon status request returned an unexpected response",
2269                    "unexpected response",
2270                )),
2271            }
2272        }
2273        #[cfg(not(unix))]
2274        {
2275            Err(SyncwebError::operation(
2276                "daemon IPC is unavailable",
2277                "Unix sockets are not supported on this platform",
2278            ))
2279        }
2280    }
2281}
2282
2283#[cfg(unix)]
2284fn set_owner_only_permissions(path: &Path) -> Result<()> {
2285    use std::os::unix::fs::PermissionsExt;
2286    let mut permissions = std::fs::metadata(path)?.permissions();
2287    permissions.set_mode(0o600);
2288    Ok(std::fs::set_permissions(path, permissions)?)
2289}
2290
2291#[cfg(test)]
2292mod tests {
2293    use std::{path::PathBuf, sync::Arc};
2294
2295    #[cfg(unix)]
2296    use std::{os::unix::fs::PermissionsExt, time::Duration};
2297
2298    use super::*;
2299    use crate::daemon::{DaemonState, DaemonStatus};
2300
2301    fn socket_path() -> PathBuf {
2302        std::env::temp_dir().join(format!("syncweb-ipc-{}.sock", uuid::Uuid::new_v4()))
2303    }
2304
2305    fn state() -> DaemonState {
2306        DaemonState::new(
2307            std::process::id(),
2308            "node",
2309            1,
2310            std::env::temp_dir(),
2311            DaemonStatus::Running,
2312        )
2313    }
2314
2315    #[test]
2316    fn request_round_trips_as_json() {
2317        let request = IpcRequest::new(IpcCommand::Download {
2318            namespace: "namespace".to_owned(),
2319            strategy: FetchStrategy::default(),
2320        });
2321        let encoded = serde_json::to_vec(&request).expect("serialize request");
2322        let decoded: IpcRequest = serde_json::from_slice(&encoded).expect("deserialize request");
2323        assert!(matches!(decoded.command, IpcCommand::Download { .. }));
2324    }
2325
2326    #[tokio::test]
2327    async fn handle_request_updates_registry_and_trigger() {
2328        let (sync_trigger, mut sync_receiver) = mpsc::unbounded_channel();
2329        let handle = DaemonHandle::with_channels(
2330            Arc::new(RwLock::new(state())),
2331            Arc::new(RwLock::new(FolderRegistry::new())),
2332            broadcast::channel(4).0,
2333            sync_trigger,
2334        );
2335        let server = IpcServer::new(socket_path(), handle);
2336        let namespace = iroh_docs::NamespaceSecret::from_bytes(&[7; 32]).id().to_string();
2337
2338        assert!(matches!(
2339            server
2340                .handle_request(IpcRequest::new(IpcCommand::AddFolder {
2341                    namespace: namespace.clone(),
2342                    path: PathBuf::from("/tmp/folder"),
2343                }))
2344                .await,
2345            IpcResponse::Ok { .. }
2346        ));
2347        assert!(matches!(
2348            server
2349                .handle_request(IpcRequest::new(IpcCommand::ListFolders))
2350                .await,
2351            IpcResponse::FolderList(folders)
2352                if folders.len() == 1 && folders.first().is_some_and(|folder| folder.namespace == namespace)
2353        ));
2354        assert!(matches!(
2355            server
2356                .handle_request(IpcRequest::new(IpcCommand::TriggerSync {
2357                    namespace: Some(namespace.clone()),
2358                }))
2359                .await,
2360            IpcResponse::Ok { .. }
2361        ));
2362        assert_eq!(sync_receiver.recv().await, Some(Some(namespace)));
2363    }
2364
2365    #[cfg(unix)]
2366    #[tokio::test]
2367    async fn client_round_trips_with_server() {
2368        let path = socket_path();
2369        let handle = DaemonHandle::new(state());
2370        let mut shutdown_receiver = handle.shutdown_sender.subscribe();
2371        let server = IpcServer::new(path.clone(), handle);
2372        let server_task = tokio::spawn(async move { server.serve().await });
2373
2374        tokio::time::timeout(Duration::from_secs(1), async {
2375            while !path.exists() {
2376                tokio::time::sleep(Duration::from_millis(5)).await;
2377            }
2378        })
2379        .await
2380        .expect("server socket should appear");
2381
2382        let client = IpcClient::from_socket_path(path.clone());
2383        assert!(matches!(
2384            client
2385                .send(IpcRequest::new(IpcCommand::Status))
2386                .await
2387                .expect("status response"),
2388            IpcResponse::Status(DaemonStatus::Running)
2389        ));
2390        assert!(matches!(
2391            client
2392                .send(IpcRequest::new(IpcCommand::Shutdown { force: false }))
2393                .await
2394                .expect("shutdown response"),
2395            IpcResponse::Ok { .. }
2396        ));
2397        shutdown_receiver.recv().await.expect("shutdown broadcast");
2398        server_task
2399            .await
2400            .expect("server task should join")
2401            .expect("server should stop cleanly");
2402        assert!(!path.exists());
2403    }
2404
2405    #[cfg(unix)]
2406    #[tokio::test]
2407    async fn socket_is_owner_only() {
2408        let path = socket_path();
2409        let listener = IpcListener::new(path.clone()).bind().expect("bind socket");
2410        assert_eq!(
2411            std::fs::metadata(&path).expect("socket metadata").permissions().mode() & 0o777,
2412            0o600
2413        );
2414        drop(listener);
2415        std::fs::remove_file(path).expect("remove socket");
2416    }
2417
2418    #[tokio::test]
2419    #[cfg(unix)]
2420    async fn client_reports_missing_server() {
2421        let path = socket_path();
2422        let error = IpcClient::from_socket_path(path)
2423            .send(IpcRequest::new(IpcCommand::Status))
2424            .await
2425            .expect_err("missing server should fail");
2426        assert!(error.to_string().contains("failed to connect to daemon socket"));
2427    }
2428
2429    #[test]
2430    fn new_commands_round_trip_as_json() {
2431        let req1 = IpcRequest::new(IpcCommand::Unsubscribe {
2432            namespace: "blob:baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
2433        });
2434        let enc1 = serde_json::to_vec(&req1).expect("serialize");
2435        let dec1: IpcRequest = serde_json::from_slice(&enc1).expect("deserialize");
2436        assert!(matches!(dec1.command, IpcCommand::Unsubscribe { .. }));
2437
2438        let req2 = IpcRequest::new(IpcCommand::LeaveFolder {
2439            namespace: "ns".to_owned(),
2440            delete_files: false,
2441        });
2442        let enc2 = serde_json::to_vec(&req2).expect("serialize");
2443        let dec2: IpcRequest = serde_json::from_slice(&enc2).expect("deserialize");
2444        assert!(matches!(
2445            dec2.command,
2446            IpcCommand::LeaveFolder {
2447                delete_files: false,
2448                ..
2449            }
2450        ));
2451
2452        let req3 = IpcRequest::new(IpcCommand::Share {
2453            namespace: "ns".to_owned(),
2454            blob: Some("baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned()),
2455            writable: false,
2456            pin: false,
2457            persist: false,
2458        });
2459        let enc3 = serde_json::to_vec(&req3).expect("serialize");
2460        let dec3: IpcRequest = serde_json::from_slice(&enc3).expect("deserialize");
2461        assert!(matches!(dec3.command, IpcCommand::Share { .. }));
2462
2463        let req4 = IpcRequest::new(IpcCommand::SnapshotCreate {
2464            path: PathBuf::from("."),
2465            description: Some("test".to_owned()),
2466            threads: 0,
2467        });
2468        let enc4 = serde_json::to_vec(&req4).expect("serialize");
2469        let dec4: IpcRequest = serde_json::from_slice(&enc4).expect("deserialize");
2470        assert!(matches!(dec4.command, IpcCommand::SnapshotCreate { .. }));
2471
2472        let req5 = IpcRequest::new(IpcCommand::SnapshotList {
2473            path: PathBuf::from("."),
2474        });
2475        let enc5 = serde_json::to_vec(&req5).expect("serialize");
2476        let dec5: IpcRequest = serde_json::from_slice(&enc5).expect("deserialize");
2477        assert!(matches!(dec5.command, IpcCommand::SnapshotList { .. }));
2478
2479        let req6 = IpcRequest::new(IpcCommand::SnapshotDelete {
2480            id: "baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
2481        });
2482        let enc6 = serde_json::to_vec(&req6).expect("serialize");
2483        let dec6: IpcRequest = serde_json::from_slice(&enc6).expect("deserialize");
2484        assert!(matches!(dec6.command, IpcCommand::SnapshotDelete { .. }));
2485
2486        let req7 = IpcRequest::new(IpcCommand::CollectionPublish {
2487            path: PathBuf::from("."),
2488            namespace: "ns".to_owned(),
2489            sequence: 1,
2490            bootstrap: vec![],
2491            manifest_bytes: None,
2492        });
2493        let enc7 = serde_json::to_vec(&req7).expect("serialize");
2494        let dec7: IpcRequest = serde_json::from_slice(&enc7).expect("deserialize");
2495        assert!(matches!(dec7.command, IpcCommand::CollectionPublish { .. }));
2496
2497        let req8 = IpcRequest::new(IpcCommand::SubscribePublic {
2498            ticket: "blob_ticket".to_owned(),
2499        });
2500        let enc8 = serde_json::to_vec(&req8).expect("serialize");
2501        let dec8: IpcRequest = serde_json::from_slice(&enc8).expect("deserialize");
2502        assert!(matches!(dec8.command, IpcCommand::SubscribePublic { .. }));
2503    }
2504
2505    #[tokio::test]
2506    async fn test_ipc_unsubscribe_rejects_folder_namespaces() {
2507        let handle = DaemonHandle::new(state());
2508        let server = IpcServer::new(socket_path(), handle);
2509        let namespace = iroh_docs::NamespaceSecret::from_bytes(&[7; 32]).id().to_string();
2510        let response = server
2511            .handle_request(IpcRequest::new(IpcCommand::Unsubscribe {
2512                namespace: namespace.clone(),
2513            }))
2514            .await;
2515        assert!(matches!(
2516            response,
2517            IpcResponse::Error { message } if message.contains("use `leave` for folders")
2518        ));
2519    }
2520
2521    #[tokio::test]
2522    async fn test_ipc_unsubscribe_invalid_namespace() {
2523        let handle = DaemonHandle::new(state());
2524        let server = IpcServer::new(socket_path(), handle);
2525        let response = server
2526            .handle_request(IpcRequest::new(IpcCommand::Unsubscribe {
2527                namespace: "not-a-namespace".to_owned(),
2528            }))
2529            .await;
2530        assert!(matches!(
2531            response,
2532            IpcResponse::Error { message } if message.contains("use `leave` for folders")
2533        ));
2534    }
2535
2536    #[tokio::test]
2537    async fn test_ipc_leave_folder_no_context() {
2538        let handle = DaemonHandle::new(state());
2539        let server = IpcServer::new(socket_path(), handle);
2540        let response = server
2541            .handle_request(IpcRequest::new(IpcCommand::LeaveFolder {
2542                namespace: "ns".to_owned(),
2543                delete_files: false,
2544            }))
2545            .await;
2546        assert!(matches!(
2547            response,
2548            IpcResponse::Error { message } if message.contains("no node context")
2549        ));
2550    }
2551
2552    #[tokio::test]
2553    async fn test_ipc_share_no_context() {
2554        let handle = DaemonHandle::new(state());
2555        let server = IpcServer::new(socket_path(), handle);
2556        let response = server
2557            .handle_request(IpcRequest::new(IpcCommand::Share {
2558                namespace: "ns".to_owned(),
2559                blob: Some("baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned()),
2560                writable: false,
2561                pin: false,
2562                persist: false,
2563            }))
2564            .await;
2565        assert!(matches!(
2566            response,
2567            IpcResponse::Error { message } if message.contains("no node context")
2568        ));
2569    }
2570
2571    #[tokio::test]
2572    async fn test_ipc_snapshot_create_no_context() {
2573        let handle = DaemonHandle::new(state());
2574        let server = IpcServer::new(socket_path(), handle);
2575        let response = server
2576            .handle_request(IpcRequest::new(IpcCommand::SnapshotCreate {
2577                path: PathBuf::from("."),
2578                description: None,
2579                threads: 0,
2580            }))
2581            .await;
2582        assert!(matches!(
2583            response,
2584            IpcResponse::Error { message } if message.contains("no node context")
2585        ));
2586    }
2587
2588    #[tokio::test]
2589    async fn test_ipc_snapshot_list_no_context() {
2590        let handle = DaemonHandle::new(state());
2591        let server = IpcServer::new(socket_path(), handle);
2592        let response = server
2593            .handle_request(IpcRequest::new(IpcCommand::SnapshotList {
2594                path: PathBuf::from("."),
2595            }))
2596            .await;
2597        assert!(matches!(
2598            response,
2599            IpcResponse::Error { message } if message.contains("no node context")
2600        ));
2601    }
2602
2603    #[tokio::test]
2604    async fn test_ipc_snapshot_delete_no_context() {
2605        let handle = DaemonHandle::new(state());
2606        let server = IpcServer::new(socket_path(), handle);
2607        let response = server
2608            .handle_request(IpcRequest::new(IpcCommand::SnapshotDelete {
2609                id: "baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
2610            }))
2611            .await;
2612        assert!(matches!(
2613            response,
2614            IpcResponse::Error { message } if message.contains("no node context")
2615        ));
2616    }
2617
2618    #[tokio::test]
2619    async fn test_ipc_collection_publish_no_context() {
2620        let handle = DaemonHandle::new(state());
2621        let server = IpcServer::new(socket_path(), handle);
2622        let response = server
2623            .handle_request(IpcRequest::new(IpcCommand::CollectionPublish {
2624                path: PathBuf::from("."),
2625                namespace: "ns".to_owned(),
2626                sequence: 1,
2627                bootstrap: vec![],
2628                manifest_bytes: None,
2629            }))
2630            .await;
2631        assert!(matches!(
2632            response,
2633            IpcResponse::Error { message } if message.contains("no node context")
2634        ));
2635    }
2636
2637    #[tokio::test]
2638    async fn test_ipc_create_folder_no_context() {
2639        let handle = DaemonHandle::new(state());
2640        let server = IpcServer::new(socket_path(), handle);
2641        let response = server
2642            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2643                path: PathBuf::from("."),
2644                mode: "sendreceive".to_owned(),
2645                indexing: false,
2646            }))
2647            .await;
2648        assert!(matches!(
2649            response,
2650            IpcResponse::Error { message } if message.contains("no node context")
2651        ));
2652    }
2653
2654    #[tokio::test]
2655    async fn test_ipc_create_folder_no_context_with_invalid_mode() {
2656        let handle = DaemonHandle::new(state());
2657        let server = IpcServer::new(socket_path(), handle);
2658        let response = server
2659            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2660                path: PathBuf::from("/tmp/test-create-folder"),
2661                mode: "invalid".to_owned(),
2662                indexing: false,
2663            }))
2664            .await;
2665        assert!(matches!(
2666            response,
2667            IpcResponse::Error { message } if message.contains("no node context")
2668        ));
2669    }
2670
2671    #[tokio::test]
2672    async fn test_ipc_stats_files_no_context() {
2673        let handle = DaemonHandle::new(state());
2674        let server = IpcServer::new(socket_path(), handle);
2675        let response = server
2676            .handle_request(IpcRequest::new(IpcCommand::StatsFiles {
2677                folder: PathBuf::from("."),
2678            }))
2679            .await;
2680        assert!(matches!(
2681            response,
2682            IpcResponse::Error { message } if message.contains("no node context")
2683        ));
2684    }
2685
2686    #[tokio::test]
2687    async fn test_ipc_materialize_transfers_no_context() {
2688        let handle = DaemonHandle::new(state());
2689        let server = IpcServer::new(socket_path(), handle);
2690        let response = server
2691            .handle_request(IpcRequest::new(IpcCommand::MaterializeTransfers { namespace: None }))
2692            .await;
2693        assert!(matches!(
2694            response,
2695            IpcResponse::Error { message } if message.contains("node context")
2696        ));
2697    }
2698
2699    #[tokio::test]
2700    async fn test_ipc_verify_integrity_no_context() {
2701        let handle = DaemonHandle::new(state());
2702        let server = IpcServer::new(socket_path(), handle);
2703        let response = server
2704            .handle_request(IpcRequest::new(IpcCommand::VerifyIntegrity {
2705                path: PathBuf::from("."),
2706                hash: Vec::new(),
2707                path_filter: None,
2708                glob_filter: None,
2709                fix: false,
2710                from: Vec::new(),
2711            }))
2712            .await;
2713        assert!(matches!(
2714            response,
2715            IpcResponse::Error { message } if message.contains("no node context")
2716        ));
2717    }
2718
2719    #[tokio::test]
2720    async fn test_ipc_join_no_context() {
2721        let handle = DaemonHandle::new(state());
2722        let server = IpcServer::new(socket_path(), handle);
2723        let response = server
2724            .handle_request(IpcRequest::new(IpcCommand::Join {
2725                ticket: "ticket".to_owned(),
2726                path: PathBuf::from("/tmp"),
2727                mode: SyncMode::SendReceive,
2728                subscribe: false,
2729                filters: SubscribeFilters::default(),
2730                download: false,
2731                indexing: false,
2732            }))
2733            .await;
2734        assert!(matches!(
2735            response,
2736            IpcResponse::Error { message } if message.contains("no node context")
2737        ));
2738    }
2739
2740    #[tokio::test]
2741    async fn test_ipc_unshare_no_context() {
2742        let handle = DaemonHandle::new(state());
2743        let server = IpcServer::new(socket_path(), handle);
2744        let response = server
2745            .handle_request(IpcRequest::new(IpcCommand::Unshare {
2746                namespace: "ns".to_owned(),
2747                blob: None,
2748                writable: false,
2749            }))
2750            .await;
2751        assert!(matches!(
2752            response,
2753            IpcResponse::Error { message } if message.contains("no node context")
2754        ));
2755    }
2756
2757    #[tokio::test]
2758    async fn test_ipc_subscribe_no_context() {
2759        let handle = DaemonHandle::new(state());
2760        let server = IpcServer::new(socket_path(), handle);
2761        let response = server
2762            .handle_request(IpcRequest::new(IpcCommand::SetSubscribe {
2763                namespace: "ns".to_owned(),
2764                enabled: true,
2765                filters: Some(SubscribeFilters {
2766                    ingest_only: true,
2767                    ..Default::default()
2768                }),
2769            }))
2770            .await;
2771        assert!(matches!(
2772            response,
2773            IpcResponse::Error { message } if message.contains("no node context")
2774        ));
2775    }
2776
2777    struct IpcTestFixture {
2778        server: IpcServer,
2779        node: Arc<IrohNode>,
2780        directory: PathBuf,
2781    }
2782
2783    async fn setup_ipc_test() -> IpcTestFixture {
2784        use crate::node::identity::IdentityManager;
2785        use crate::node::iroh_node::RelayMode;
2786        use std::collections::HashSet;
2787        let directory = std::env::temp_dir().join(format!("syncweb-ipc-test-{}", uuid::Uuid::new_v4()));
2788        std::fs::create_dir_all(&directory).expect("test directory should be created");
2789
2790        let identity = IdentityManager::new(directory.join("identity.key")).expect("test identity should open");
2791        let node = Arc::new(
2792            IrohNode::new(
2793                identity,
2794                directory.join("data"),
2795                RelayMode::Default,
2796                Arc::new(std::sync::RwLock::new(HashSet::new())),
2797                crate::node::iroh_node::DiscoveryConfig::disabled(),
2798            )
2799            .await
2800            .expect("test node should start"),
2801        );
2802        let pool = Arc::new(ManagedPool::new("syncweb-test", 1).expect("test pool should start"));
2803
2804        let daemon_state = DaemonState::new(
2805            std::process::id(),
2806            node.endpoint().id().to_string(),
2807            1,
2808            &directory,
2809            DaemonStatus::Running,
2810        );
2811        let handle = DaemonHandle::new(daemon_state);
2812        let server = IpcServer::with_archive_context(socket_path(), handle, node.clone(), pool, None);
2813
2814        IpcTestFixture {
2815            server,
2816            node,
2817            directory,
2818        }
2819    }
2820
2821    async fn cleanup_ipc_test(fixture: IpcTestFixture) {
2822        let _ = fixture.node.stop().await;
2823        let _ = std::fs::remove_dir_all(&fixture.directory);
2824    }
2825
2826    #[tokio::test]
2827    async fn test_ipc_create_folder_creates_and_returns_message() {
2828        let fixture = setup_ipc_test().await;
2829        let test_dir = fixture.directory.join("create-folder-test");
2830        let response = fixture
2831            .server
2832            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2833                path: test_dir.clone(),
2834                mode: "sendreceive".to_owned(),
2835                indexing: false,
2836            }))
2837            .await;
2838        assert!(matches!(response, IpcResponse::Ok { .. }));
2839        if let IpcResponse::Ok { message } = response {
2840            assert!(message.contains("namespace:"));
2841            assert!(!message.contains("ticket:"));
2842        }
2843        cleanup_ipc_test(fixture).await;
2844        let _ = std::fs::remove_dir_all(&test_dir);
2845    }
2846
2847    #[tokio::test]
2848    async fn test_ipc_create_folder_invalid_mode() {
2849        let fixture = setup_ipc_test().await;
2850        let test_dir = fixture.directory.join("create-folder-invalid");
2851        let response = fixture
2852            .server
2853            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2854                path: test_dir.clone(),
2855                mode: "invalid-mode".to_owned(),
2856                indexing: false,
2857            }))
2858            .await;
2859        assert!(matches!(response, IpcResponse::Error { .. }));
2860        if let IpcResponse::Error { message } = response {
2861            assert!(message.contains("invalid sync mode"));
2862        }
2863        cleanup_ipc_test(fixture).await;
2864    }
2865
2866    #[tokio::test]
2867    async fn test_ipc_create_folder_duplicate_namespace() {
2868        let fixture = setup_ipc_test().await;
2869        let test_dir1 = fixture.directory.join("create-folder-dup-1");
2870        let test_dir2 = fixture.directory.join("create-folder-dup-2");
2871
2872        let response1 = fixture
2873            .server
2874            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2875                path: test_dir1.clone(),
2876                mode: "sendreceive".to_owned(),
2877                indexing: false,
2878            }))
2879            .await;
2880        assert!(matches!(response1, IpcResponse::Ok { .. }));
2881        let namespace = if let IpcResponse::Ok { message } = &response1 {
2882            message
2883                .lines()
2884                .find(|line| line.starts_with("namespace:"))
2885                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2886        } else {
2887            None
2888        };
2889
2890        if let Some(ref ns) = namespace {
2891            let response2 = fixture
2892                .server
2893                .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2894                    path: test_dir2.clone(),
2895                    mode: "sendreceive".to_owned(),
2896                    indexing: false,
2897                }))
2898                .await;
2899            assert!(matches!(response2, IpcResponse::Ok { .. }));
2900            let ns2 = if let IpcResponse::Ok { message } = &response2 {
2901                message
2902                    .lines()
2903                    .find(|line| line.starts_with("namespace:"))
2904                    .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2905            } else {
2906                None
2907            };
2908            assert_ne!(Some(ns), ns2.as_ref(), "each create should produce a unique namespace");
2909        }
2910
2911        cleanup_ipc_test(fixture).await;
2912        let _ = std::fs::remove_dir_all(&test_dir1);
2913        let _ = std::fs::remove_dir_all(&test_dir2);
2914    }
2915
2916    #[tokio::test]
2917    async fn test_ipc_stats_files_returns_report() {
2918        let fixture = setup_ipc_test().await;
2919        let test_dir = fixture.directory.join("stats-files-test");
2920        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2921
2922        let response1 = fixture
2923            .server
2924            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2925                path: test_dir.clone(),
2926                mode: "sendreceive".to_owned(),
2927                indexing: false,
2928            }))
2929            .await;
2930        let namespace = if let IpcResponse::Ok { message } = &response1 {
2931            message
2932                .lines()
2933                .find(|line| line.starts_with("namespace:"))
2934                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2935        } else {
2936            None
2937        };
2938
2939        if let Some(ns) = namespace {
2940            let response2 = fixture
2941                .server
2942                .handle_request(IpcRequest::new(IpcCommand::StatsFiles {
2943                    folder: PathBuf::from(&ns),
2944                }))
2945                .await;
2946            assert!(matches!(response2, IpcResponse::FileStats(_)));
2947        }
2948
2949        cleanup_ipc_test(fixture).await;
2950        let _ = std::fs::remove_dir_all(&test_dir);
2951    }
2952
2953    #[tokio::test]
2954    async fn test_ipc_stats_files_unknown_folder() {
2955        let fixture = setup_ipc_test().await;
2956        let response = fixture
2957            .server
2958            .handle_request(IpcRequest::new(IpcCommand::StatsFiles {
2959                folder: PathBuf::from("/nonexistent/path/that/does/not/exist"),
2960            }))
2961            .await;
2962        assert!(matches!(response, IpcResponse::Error { .. }));
2963        cleanup_ipc_test(fixture).await;
2964    }
2965
2966    #[tokio::test]
2967    async fn test_ipc_verify_integrity_returns_result() {
2968        let fixture = setup_ipc_test().await;
2969        let test_dir = fixture.directory.join("verify-test");
2970        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2971
2972        let response1 = fixture
2973            .server
2974            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2975                path: test_dir.clone(),
2976                mode: "sendreceive".to_owned(),
2977                indexing: false,
2978            }))
2979            .await;
2980        let namespace = if let IpcResponse::Ok { message } = &response1 {
2981            message
2982                .lines()
2983                .find(|line| line.starts_with("namespace:"))
2984                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2985        } else {
2986            None
2987        };
2988
2989        if let Some(ns) = namespace {
2990            let response2 = fixture
2991                .server
2992                .handle_request(IpcRequest::new(IpcCommand::VerifyIntegrity {
2993                    path: PathBuf::from(&ns),
2994                    hash: Vec::new(),
2995                    path_filter: None,
2996                    glob_filter: None,
2997                    fix: false,
2998                    from: Vec::new(),
2999                }))
3000                .await;
3001            assert!(matches!(response2, IpcResponse::Ok { .. }));
3002            if let IpcResponse::Ok { message } = response2 {
3003                assert!(message.contains("total:"));
3004                assert!(message.contains("verified:"));
3005                assert!(message.contains("corrupted:"));
3006                assert!(message.contains("missing:"));
3007                assert!(message.contains("valid:"));
3008            }
3009        }
3010
3011        cleanup_ipc_test(fixture).await;
3012        let _ = std::fs::remove_dir_all(&test_dir);
3013    }
3014
3015    #[tokio::test]
3016    async fn test_ipc_verify_integrity_unknown_folder() {
3017        let fixture = setup_ipc_test().await;
3018        let response = fixture
3019            .server
3020            .handle_request(IpcRequest::new(IpcCommand::VerifyIntegrity {
3021                path: PathBuf::from("/nonexistent/path/that/does/not/exist"),
3022                hash: Vec::new(),
3023                path_filter: None,
3024                glob_filter: None,
3025                fix: false,
3026                from: Vec::new(),
3027            }))
3028            .await;
3029        assert!(matches!(response, IpcResponse::Error { .. }));
3030        cleanup_ipc_test(fixture).await;
3031    }
3032
3033    #[tokio::test]
3034    async fn test_ipc_join_folder_invalid_ticket() {
3035        let fixture = setup_ipc_test().await;
3036        let test_dir = fixture.directory.join("join-invalid");
3037        let response = fixture
3038            .server
3039            .handle_request(IpcRequest::new(IpcCommand::Join {
3040                ticket: "not-a-valid-ticket".to_owned(),
3041                path: test_dir.clone(),
3042                mode: SyncMode::SendReceive,
3043                subscribe: false,
3044                filters: SubscribeFilters::default(),
3045                download: false,
3046                indexing: false,
3047            }))
3048            .await;
3049        assert!(matches!(response, IpcResponse::Error { .. }));
3050        cleanup_ipc_test(fixture).await;
3051    }
3052
3053    #[tokio::test]
3054    async fn test_ipc_share_folder_ticket() {
3055        let fixture = setup_ipc_test().await;
3056        let test_dir = fixture.directory.join("share-test");
3057        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
3058
3059        let response1 = fixture
3060            .server
3061            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
3062                path: test_dir.clone(),
3063                mode: "sendreceive".to_owned(),
3064                indexing: false,
3065            }))
3066            .await;
3067        let namespace = if let IpcResponse::Ok { message } = &response1 {
3068            message
3069                .lines()
3070                .find(|line| line.starts_with("namespace:"))
3071                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
3072        } else {
3073            None
3074        };
3075
3076        if let Some(ns) = namespace {
3077            let response2 = fixture
3078                .server
3079                .handle_request(IpcRequest::new(IpcCommand::Share {
3080                    namespace: ns.clone(),
3081                    blob: None,
3082                    writable: false,
3083                    pin: false,
3084                    persist: false,
3085                }))
3086                .await;
3087            assert!(matches!(response2, IpcResponse::Ok { .. }));
3088            if let IpcResponse::Ok { message } = response2 {
3089                assert!(
3090                    crate::uri::is_folder_url(&message),
3091                    "share should emit a URL: {message}"
3092                );
3093            }
3094        }
3095
3096        cleanup_ipc_test(fixture).await;
3097        let _ = std::fs::remove_dir_all(&test_dir);
3098    }
3099
3100    #[tokio::test]
3101    async fn test_ipc_share_invalid_namespace() {
3102        let fixture = setup_ipc_test().await;
3103        let response = fixture
3104            .server
3105            .handle_request(IpcRequest::new(IpcCommand::Share {
3106                namespace: "not-a-namespace".to_owned(),
3107                blob: None,
3108                writable: false,
3109                pin: false,
3110                persist: false,
3111            }))
3112            .await;
3113        assert!(matches!(response, IpcResponse::Error { .. }));
3114        if let IpcResponse::Error { message } = response {
3115            assert!(message.contains("invalid namespace"));
3116        }
3117        cleanup_ipc_test(fixture).await;
3118    }
3119
3120    #[tokio::test]
3121    async fn test_ipc_subscribe_returns_ok() {
3122        let fixture = setup_ipc_test().await;
3123        let test_dir = fixture.directory.join("subscribe-test");
3124        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
3125
3126        let response1 = fixture
3127            .server
3128            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
3129                path: test_dir.clone(),
3130                mode: "sendreceive".to_owned(),
3131                indexing: false,
3132            }))
3133            .await;
3134        let namespace = if let IpcResponse::Ok { message } = &response1 {
3135            message
3136                .lines()
3137                .find(|line| line.starts_with("namespace:"))
3138                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
3139        } else {
3140            None
3141        };
3142
3143        if let Some(ns) = namespace {
3144            let response = fixture
3145                .server
3146                .handle_request(IpcRequest::new(IpcCommand::SetSubscribe {
3147                    namespace: ns.clone(),
3148                    enabled: true,
3149                    filters: Some(SubscribeFilters {
3150                        ingest_only: true,
3151                        ..Default::default()
3152                    }),
3153                }))
3154                .await;
3155            assert!(matches!(response, IpcResponse::Ok { .. }));
3156            if let IpcResponse::Ok { message } = response {
3157                assert!(message.contains("subscribed:"));
3158            }
3159        }
3160
3161        cleanup_ipc_test(fixture).await;
3162        let _ = std::fs::remove_dir_all(&test_dir);
3163    }
3164
3165    #[tokio::test]
3166    async fn test_ipc_subscribe_with_params() {
3167        let fixture = setup_ipc_test().await;
3168        let test_dir = fixture.directory.join("subscribe-params-test");
3169        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
3170
3171        let response1 = fixture
3172            .server
3173            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
3174                path: test_dir.clone(),
3175                mode: "sendreceive".to_owned(),
3176                indexing: false,
3177            }))
3178            .await;
3179        let namespace = if let IpcResponse::Ok { message } = &response1 {
3180            message
3181                .lines()
3182                .find(|line| line.starts_with("namespace:"))
3183                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
3184        } else {
3185            None
3186        };
3187
3188        if let Some(ns) = namespace {
3189            let filters = SubscribeFilters {
3190                ingest_only: true,
3191                ..Default::default()
3192            };
3193            let response2 = fixture
3194                .server
3195                .handle_request(IpcRequest::new(IpcCommand::SetSubscribe {
3196                    namespace: ns.clone(),
3197                    enabled: true,
3198                    filters: Some(filters),
3199                }))
3200                .await;
3201            assert!(matches!(response2, IpcResponse::Ok { .. }));
3202        }
3203
3204        cleanup_ipc_test(fixture).await;
3205        let _ = std::fs::remove_dir_all(&test_dir);
3206    }
3207
3208    #[tokio::test]
3209    async fn test_ipc_leave_folder_removes_from_registry() {
3210        let fixture = setup_ipc_test().await;
3211        let test_dir = fixture.directory.join("leave-test");
3212        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
3213
3214        let response = fixture
3215            .server
3216            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
3217                path: test_dir.clone(),
3218                mode: "sendreceive".to_owned(),
3219                indexing: false,
3220            }))
3221            .await;
3222        let namespace = if let IpcResponse::Ok { message } = &response {
3223            message
3224                .lines()
3225                .find(|line| line.starts_with("namespace:"))
3226                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
3227        } else {
3228            None
3229        };
3230
3231        if let Some(ref ns) = namespace {
3232            let statuses1 = fixture.server.daemon_handle.folder_registry.read().await.statuses();
3233            assert!(statuses1.iter().any(|s| s.namespace == *ns));
3234
3235            let response2 = fixture
3236                .server
3237                .handle_request(IpcRequest::new(IpcCommand::LeaveFolder {
3238                    namespace: ns.clone(),
3239                    delete_files: false,
3240                }))
3241                .await;
3242            assert!(matches!(response2, IpcResponse::Ok { .. }));
3243
3244            let statuses2 = fixture.server.daemon_handle.folder_registry.read().await.statuses();
3245            assert!(!statuses2.iter().any(|s| s.namespace == *ns));
3246        }
3247
3248        cleanup_ipc_test(fixture).await;
3249        let _ = std::fs::remove_dir_all(&test_dir);
3250    }
3251
3252    #[tokio::test]
3253    async fn test_ipc_leave_folder_nonexistent() {
3254        let fixture = setup_ipc_test().await;
3255        let fake_ns = iroh_docs::NamespaceSecret::from_bytes(&[99; 32]).id().to_string();
3256        let _response = fixture
3257            .server
3258            .handle_request(IpcRequest::new(IpcCommand::LeaveFolder {
3259                namespace: fake_ns.clone(),
3260                delete_files: false,
3261            }))
3262            .await;
3263        let registry = fixture.server.daemon_handle.folder_registry.read().await;
3264        let statuses = registry.statuses();
3265        assert!(!statuses.iter().any(|s| s.namespace == fake_ns));
3266        drop(registry);
3267        cleanup_ipc_test(fixture).await;
3268    }
3269
3270    #[tokio::test]
3271    async fn test_ipc_unshare_invalid_hash() {
3272        let fixture = setup_ipc_test().await;
3273        let fake_ns = iroh_docs::NamespaceSecret::from_bytes(&[88; 32]).id().to_string();
3274        let response = fixture
3275            .server
3276            .handle_request(IpcRequest::new(IpcCommand::Unshare {
3277                namespace: fake_ns,
3278                blob: Some("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".to_owned()),
3279                writable: false,
3280            }))
3281            .await;
3282        assert!(matches!(response, IpcResponse::Error { .. }));
3283        if let IpcResponse::Error { message } = response {
3284            assert!(message.contains("invalid blob hash"));
3285        }
3286        cleanup_ipc_test(fixture).await;
3287    }
3288
3289    #[tokio::test]
3290    async fn test_ipc_snapshot_list_empty() {
3291        let fixture = setup_ipc_test().await;
3292        let response = fixture
3293            .server
3294            .handle_request(IpcRequest::new(IpcCommand::SnapshotList {
3295                path: PathBuf::from("."),
3296            }))
3297            .await;
3298        assert!(matches!(response, IpcResponse::Ok { .. }));
3299        if let IpcResponse::Ok { message } = response {
3300            assert!(message.contains("snapshots:"));
3301        }
3302        cleanup_ipc_test(fixture).await;
3303    }
3304
3305    #[tokio::test]
3306    async fn test_ipc_snapshot_delete_invalid_id() {
3307        let fixture = setup_ipc_test().await;
3308        let response = fixture
3309            .server
3310            .handle_request(IpcRequest::new(IpcCommand::SnapshotDelete {
3311                id: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".to_owned(),
3312            }))
3313            .await;
3314        assert!(matches!(response, IpcResponse::Error { .. }));
3315        if let IpcResponse::Error { message } = response {
3316            assert!(message.contains("invalid snapshot id"));
3317        }
3318        cleanup_ipc_test(fixture).await;
3319    }
3320}