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