Skip to main content

syncweb_core/daemon/
ipc.rs

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