Skip to main content

syncweb_core/daemon/
ipc.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4    str::FromStr,
5    sync::{
6        Arc,
7        atomic::{AtomicBool, Ordering},
8    },
9    time::Duration,
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, FolderManager, PackageAnnouncement, PackageCatalog,
22        SyncMode,
23    },
24    fs::Importer,
25    node::{gossip_service::GossipService, iroh_node::IrohNode},
26    snapshot::SnapshotStore,
27    sync::{
28        ActiveSession, FetchCandidate, FetchStrategy, HealthReport, SubscribeParams, SyncEngine, SyncEvent,
29        cancel_session,
30    },
31    verify::IntegrityChecker,
32};
33
34use super::{
35    ManagedPool,
36    state::{DaemonStatus, daemon_socket_path},
37};
38
39const IPC_TIMEOUT: Duration = Duration::from_millis(500); // 0.5 s
40
41/// A request sent over the local daemon control channel.
42#[derive(Clone, Debug, Deserialize, Serialize)]
43#[non_exhaustive]
44pub struct IpcRequest {
45    pub command: IpcCommand,
46}
47
48impl IpcRequest {
49    #[must_use]
50    pub const fn new(command: IpcCommand) -> Self {
51        Self { command }
52    }
53}
54
55/// Commands supported by the daemon control channel.
56#[derive(Clone, Debug, Deserialize, Serialize)]
57#[serde(tag = "command", rename_all = "snake_case")]
58#[non_exhaustive]
59pub enum IpcCommand {
60    Status,
61    ListFolders,
62    AddFolder {
63        namespace: String,
64        path: PathBuf,
65    },
66    RemoveFolder {
67        namespace: String,
68    },
69    TriggerSync {
70        namespace: Option<String>,
71    },
72    SetLogLevel {
73        level: String,
74    },
75    ReloadConfig,
76    Shutdown {
77        force: bool,
78    },
79    Download {
80        namespace: String,
81        strategy: FetchStrategy,
82    },
83    ImportFiles {
84        namespace: Option<String>,
85        path: PathBuf,
86    },
87    ImportArchive {
88        input: PathBuf,
89        target: PathBuf,
90        filter: Option<FilterConfig>,
91    },
92    ExportArchive {
93        namespace: String,
94        version: Option<String>,
95        output: PathBuf,
96    },
97    Join {
98        ticket: String,
99        path: PathBuf,
100        mode: SyncMode,
101    },
102    Publish {
103        namespace: String,
104        blob: Option<String>,
105    },
106    Subscribe {
107        namespace: String,
108        params: SubscribeParams,
109    },
110    CreateFolder {
111        path: PathBuf,
112        mode: String,
113    },
114    HealthCheck {
115        path: PathBuf,
116    },
117    VerifyIntegrity {
118        path: PathBuf,
119    },
120    Unsubscribe {
121        namespace: String,
122    },
123    LeaveFolder {
124        namespace: String,
125    },
126    Unpublish {
127        namespace: String,
128        blob: String,
129    },
130    SnapshotCreate {
131        path: PathBuf,
132        description: Option<String>,
133        threads: usize,
134    },
135    SnapshotList {
136        path: PathBuf,
137    },
138    SnapshotDelete {
139        id: String,
140    },
141    CollectionPublish {
142        path: PathBuf,
143        namespace: String,
144        sequence: u64,
145        bootstrap: Vec<String>,
146    },
147}
148
149/// A response returned by the daemon control channel.
150#[derive(Clone, Debug, Deserialize, Serialize)]
151#[serde(tag = "response", content = "data", rename_all = "snake_case")]
152#[non_exhaustive]
153pub enum IpcResponse {
154    Ok { message: String },
155    Status(DaemonStatus),
156    FolderList(Vec<FolderStatus>),
157    DownloadComplete { bytes_transferred: u64 },
158    ImportFilesComplete { entries: u64 },
159    ImportComplete(Box<DropImportResult>),
160    ExportComplete(Box<DropExportResult>),
161    Error { message: String },
162}
163
164/// A managed folder summary returned by the daemon.
165#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
166#[non_exhaustive]
167pub struct FolderStatus {
168    pub namespace: String,
169    pub path: PathBuf,
170    pub session_active: bool,
171    pub last_sync_at: Option<u64>,
172    pub sync_count: u64,
173    pub entries_synced: u64,
174    pub errors: Vec<String>,
175}
176
177/// A folder managed by the daemon.
178#[non_exhaustive]
179pub struct FolderEntry {
180    pub namespace: iroh_docs::NamespaceId,
181    pub path: PathBuf,
182    pub session: Option<ActiveSession>,
183    pub last_sync_at: Option<u64>,
184    pub sync_count: u64,
185    pub entries_synced: u64,
186    pub errors: Vec<String>,
187}
188
189impl FolderEntry {
190    #[must_use]
191    pub const fn new(namespace: iroh_docs::NamespaceId, path: PathBuf) -> Self {
192        Self {
193            namespace,
194            path,
195            session: None,
196            last_sync_at: None,
197            sync_count: 0,
198            entries_synced: 0,
199            errors: Vec::new(),
200        }
201    }
202
203    #[must_use]
204    pub fn status(&self) -> FolderStatus {
205        FolderStatus {
206            namespace: self.namespace.to_string(),
207            path: self.path.clone(),
208            session_active: self.session.is_some() || crate::sync::is_active(self.namespace),
209            last_sync_at: self.last_sync_at,
210            sync_count: self.sync_count,
211            entries_synced: self.entries_synced,
212            errors: self.errors.clone(),
213        }
214    }
215}
216
217/// Registry of folders currently managed by the daemon.
218#[derive(Default)]
219pub struct FolderRegistry {
220    folders: HashMap<String, FolderEntry>,
221}
222
223impl FolderRegistry {
224    #[must_use]
225    pub fn new() -> Self {
226        Self::default()
227    }
228
229    /// Add a folder to the registry.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error when the namespace is already registered.
234    pub fn add(&mut self, entry: FolderEntry) -> Result<()> {
235        let key = entry.namespace.to_string();
236        if self.folders.contains_key(&key) {
237            return Err(SyncwebError::FolderAlreadyManaged);
238        }
239        self.folders.insert(key, entry);
240        Ok(())
241    }
242
243    /// Add a folder, or attach a path to a folder restored without one.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error when the namespace is already managed with a path or
248    /// when the requested update conflicts with an existing registration.
249    pub fn add_or_update(&mut self, entry: FolderEntry) -> Result<()> {
250        let key = entry.namespace.to_string();
251        if let Some(existing) = self.folders.get_mut(&key) {
252            if existing.path.as_os_str().is_empty() && !entry.path.as_os_str().is_empty() {
253                existing.path = entry.path;
254                return Ok(());
255            }
256            return Err(SyncwebError::FolderAlreadyManaged);
257        }
258        self.folders.insert(key, entry);
259        Ok(())
260    }
261
262    pub fn remove(&mut self, namespace: &iroh_docs::NamespaceId) -> Option<FolderEntry> {
263        self.folders.remove(&namespace.to_string())
264    }
265
266    #[must_use]
267    pub fn statuses(&self) -> Vec<FolderStatus> {
268        let mut statuses: Vec<_> = self.folders.values().map(FolderEntry::status).collect();
269        statuses.sort_by(|left, right| left.namespace.cmp(&right.namespace));
270        statuses
271    }
272
273    #[must_use]
274    pub fn len(&self) -> usize {
275        self.folders.len()
276    }
277
278    #[must_use]
279    pub fn is_empty(&self) -> bool {
280        self.folders.is_empty()
281    }
282
283    pub fn record_import(&mut self, namespace: iroh_docs::NamespaceId, entries: u64, timestamp: u64) {
284        if let Some(folder) = self.folders.get_mut(&namespace.to_string()) {
285            folder.entries_synced = folder.entries_synced.saturating_add(entries);
286            folder.sync_count = folder.sync_count.saturating_add(1);
287            folder.last_sync_at = Some(timestamp);
288            folder.errors.clear();
289        }
290    }
291
292    pub fn record_error(&mut self, namespace: iroh_docs::NamespaceId, error: impl Into<String>) {
293        if let Some(folder) = self.folders.get_mut(&namespace.to_string()) {
294            folder.errors.push(error.into());
295            if folder.errors.len() > 16 {
296                let remove_count = folder.errors.len().saturating_sub(16);
297                folder.errors.drain(..remove_count);
298            }
299        }
300    }
301}
302
303/// Shared daemon state used by the IPC server.
304#[derive(Clone)]
305#[non_exhaustive]
306pub struct DaemonHandle {
307    pub state: Arc<RwLock<super::state::DaemonState>>,
308    pub folder_registry: Arc<RwLock<FolderRegistry>>,
309    pub shutdown_sender: broadcast::Sender<()>,
310    pub sync_trigger: mpsc::UnboundedSender<Option<String>>,
311    pub reload_requested: Arc<AtomicBool>,
312}
313
314impl DaemonHandle {
315    /// Create a handle with fresh control channels.
316    #[must_use]
317    pub fn new(state: super::state::DaemonState) -> Self {
318        let (shutdown_sender, _) = broadcast::channel(16);
319        let (sync_trigger, _) = mpsc::unbounded_channel();
320        Self::with_channels(
321            Arc::new(RwLock::new(state)),
322            Arc::new(RwLock::new(FolderRegistry::new())),
323            shutdown_sender,
324            sync_trigger,
325        )
326    }
327
328    #[must_use]
329    pub fn with_channels(
330        state: Arc<RwLock<super::state::DaemonState>>,
331        folder_registry: Arc<RwLock<FolderRegistry>>,
332        shutdown_sender: broadcast::Sender<()>,
333        sync_trigger: mpsc::UnboundedSender<Option<String>>,
334    ) -> Self {
335        Self::with_channels_and_reload(
336            state,
337            folder_registry,
338            shutdown_sender,
339            sync_trigger,
340            Arc::new(AtomicBool::new(false)),
341        )
342    }
343
344    #[must_use]
345    pub const fn with_channels_and_reload(
346        state: Arc<RwLock<super::state::DaemonState>>,
347        folder_registry: Arc<RwLock<FolderRegistry>>,
348        shutdown_sender: broadcast::Sender<()>,
349        sync_trigger: mpsc::UnboundedSender<Option<String>>,
350        reload_requested: Arc<AtomicBool>,
351    ) -> Self {
352        Self {
353            state,
354            folder_registry,
355            shutdown_sender,
356            sync_trigger,
357            reload_requested,
358        }
359    }
360
361    /// Update the lifecycle status returned by future status requests.
362    pub async fn set_status(&self, status: DaemonStatus) {
363        self.state.write().await.status = status;
364    }
365}
366
367/// Socket path and binding helper for the daemon.
368#[derive(Clone, Debug)]
369pub struct IpcListener {
370    socket_path: PathBuf,
371}
372
373impl IpcListener {
374    #[must_use]
375    pub const fn new(socket_path: PathBuf) -> Self {
376        Self { socket_path }
377    }
378
379    #[must_use]
380    pub fn for_data_dir(data_dir: &Path) -> Self {
381        Self::new(daemon_socket_path(data_dir))
382    }
383
384    #[must_use]
385    pub fn socket_path(&self) -> &Path {
386        &self.socket_path
387    }
388
389    /// Bind the daemon socket with owner-only permissions on Unix.
390    ///
391    /// # Errors
392    ///
393    /// Returns an error when the parent directory or socket cannot be created.
394    #[cfg(unix)]
395    pub fn bind(&self) -> Result<tokio::net::UnixListener> {
396        if let Some(parent) = self.socket_path.parent() {
397            std::fs::create_dir_all(parent)?;
398        }
399        if self.socket_path.exists() {
400            match std::os::unix::net::UnixStream::connect(&self.socket_path) {
401                Ok(_) => {
402                    return Err(std::io::Error::new(
403                        std::io::ErrorKind::AddrInUse,
404                        "daemon IPC socket is already in use",
405                    )
406                    .into());
407                }
408                Err(_) => std::fs::remove_file(&self.socket_path)?,
409            }
410        }
411        let listener = tokio::net::UnixListener::bind(&self.socket_path)?;
412        set_owner_only_permissions(&self.socket_path)?;
413        Ok(listener)
414    }
415}
416
417/// A server for the daemon's local control channel.
418#[derive(Clone)]
419pub struct IpcServer {
420    listener: IpcListener,
421    daemon_handle: DaemonHandle,
422    archive_context: Option<Arc<ArchiveContext>>,
423}
424
425#[derive(Clone)]
426struct ArchiveContext {
427    node: Arc<IrohNode>,
428    pool: Arc<ManagedPool>,
429}
430
431impl IpcServer {
432    #[must_use]
433    pub const fn new(socket_path: PathBuf, daemon_handle: DaemonHandle) -> Self {
434        Self {
435            listener: IpcListener::new(socket_path),
436            daemon_handle,
437            archive_context: None,
438        }
439    }
440
441    /// Create an IPC server with access to daemon-owned archive resources.
442    #[must_use]
443    pub fn with_archive_context(
444        socket_path: PathBuf,
445        daemon_handle: DaemonHandle,
446        node: Arc<IrohNode>,
447        pool: Arc<ManagedPool>,
448    ) -> Self {
449        Self {
450            listener: IpcListener::new(socket_path),
451            daemon_handle,
452            archive_context: Some(Arc::new(ArchiveContext { node, pool })),
453        }
454    }
455
456    /// Accept and process requests until the daemon broadcasts shutdown.
457    ///
458    /// # Errors
459    ///
460    /// Returns an error when the socket cannot be bound, accepted, or written.
461    pub async fn serve(&self) -> Result<()> {
462        #[cfg(unix)]
463        {
464            let listener = self.listener.bind()?;
465            let mut shutdown = self.daemon_handle.shutdown_sender.subscribe();
466            let result = loop {
467                tokio::select! {
468                    shutdown_result = shutdown.recv() => {
469                        match shutdown_result {
470                            Ok(()) | Err(broadcast::error::RecvError::Closed) => break Ok(()),
471                            Err(broadcast::error::RecvError::Lagged(_)) => {}
472                        }
473                    }
474                    accepted = listener.accept() => {
475                        let (stream, _) = accepted?;
476                        if let Err(error) = self.handle_connection(stream).await {
477                            tracing::error!(%error, "daemon IPC connection failed");
478                            return Err(error);
479                        }
480                    }
481                }
482            };
483            if let Err(error) = std::fs::remove_file(self.listener.socket_path())
484                && self.listener.socket_path().exists()
485            {
486                return Err(error.into());
487            }
488            result
489        }
490        #[cfg(not(unix))]
491        {
492            Err(SyncwebError::operation(
493                "daemon IPC is unavailable",
494                "Unix sockets are not supported on this platform",
495            ))
496        }
497    }
498
499    /// Handle one decoded request without requiring a socket.
500    pub async fn handle_request(&self, request: IpcRequest) -> IpcResponse {
501        match request.command {
502            IpcCommand::Status => IpcResponse::Status(self.daemon_handle.state.read().await.status),
503            IpcCommand::ListFolders => {
504                let folders = self.daemon_handle.folder_registry.read().await.statuses();
505                IpcResponse::FolderList(folders)
506            }
507            IpcCommand::AddFolder { namespace, path } => self.handle_add_folder(namespace, path).await,
508            IpcCommand::RemoveFolder { namespace } => self.handle_remove_folder(namespace).await,
509            IpcCommand::TriggerSync { namespace } => self.handle_trigger_sync(namespace),
510            IpcCommand::SetLogLevel { level } => IpcResponse::Ok {
511                message: format!("log level set to {level}"),
512            },
513            IpcCommand::ReloadConfig => self.handle_reload_config(),
514            IpcCommand::Shutdown { force } => self.handle_shutdown(force),
515            IpcCommand::ImportArchive { input, target, filter } => {
516                match self.handle_import_archive(input, target, filter).await {
517                    Ok(result) => IpcResponse::ImportComplete(Box::new(result)),
518                    Err(error) => response_from_error(error),
519                }
520            }
521            IpcCommand::ImportFiles { namespace, path } => self.handle_import_files_response(namespace, path).await,
522            IpcCommand::ExportArchive {
523                namespace,
524                version,
525                output,
526            } => match self.handle_export_archive(namespace, version, output).await {
527                Ok(result) => IpcResponse::ExportComplete(Box::new(result)),
528                Err(error) => response_from_error(error),
529            },
530            IpcCommand::Download { namespace, strategy } => self.handle_download_response(namespace, strategy).await,
531            IpcCommand::Join { ticket, path, mode } => self.handle_join(ticket, path, mode).await,
532            IpcCommand::Publish { namespace, blob } => self.handle_publish(namespace, blob).await,
533            IpcCommand::Subscribe { namespace, params } => self.handle_subscribe(namespace, params).await,
534            IpcCommand::CreateFolder { path, mode } => self.handle_create_folder(path, mode).await,
535            IpcCommand::HealthCheck { path } => self.handle_health_check(path).await,
536            IpcCommand::VerifyIntegrity { path } => self.handle_verify_integrity(path).await,
537            IpcCommand::Unsubscribe { namespace } => self.handle_unsubscribe(&namespace),
538            IpcCommand::LeaveFolder { namespace } => self.handle_leave_folder(namespace).await,
539            IpcCommand::Unpublish { namespace, blob } => self.handle_unpublish(namespace, blob).await,
540            IpcCommand::SnapshotCreate {
541                path,
542                description,
543                threads,
544            } => self.handle_snapshot_create(path, description, threads).await,
545            IpcCommand::SnapshotList { path } => self.handle_snapshot_list(path).await,
546            IpcCommand::SnapshotDelete { id } => self.handle_snapshot_delete(id).await,
547            IpcCommand::CollectionPublish {
548                path,
549                namespace,
550                sequence,
551                bootstrap,
552            } => {
553                self.handle_collection_publish(path, namespace, sequence, bootstrap)
554                    .await
555            }
556        }
557    }
558
559    async fn handle_add_folder(&self, namespace: String, path: PathBuf) -> IpcResponse {
560        match iroh_docs::NamespaceId::from_str(&namespace) {
561            Ok(namespace_id) => {
562                let mut registry = self.daemon_handle.folder_registry.write().await;
563                match registry.add_or_update(FolderEntry::new(namespace_id, path)) {
564                    Ok(()) => IpcResponse::Ok {
565                        message: "folder added".to_owned(),
566                    },
567                    Err(error) => response_from_error(error),
568                }
569            }
570            Err(error) => IpcResponse::Error {
571                message: format!("invalid folder namespace: {error}"),
572            },
573        }
574    }
575
576    async fn handle_remove_folder(&self, namespace: String) -> IpcResponse {
577        match iroh_docs::NamespaceId::from_str(&namespace) {
578            Ok(namespace_id) => {
579                let removed = self.daemon_handle.folder_registry.write().await.remove(&namespace_id);
580                if removed.is_some() {
581                    let _ = cancel_session(namespace_id);
582                    IpcResponse::Ok {
583                        message: "folder removed".to_owned(),
584                    }
585                } else {
586                    IpcResponse::Error {
587                        message: format!("folder not found: {namespace}"),
588                    }
589                }
590            }
591            Err(error) => IpcResponse::Error {
592                message: format!("invalid folder namespace: {error}"),
593            },
594        }
595    }
596
597    fn handle_trigger_sync(&self, namespace: Option<String>) -> IpcResponse {
598        match self.daemon_handle.sync_trigger.send(namespace) {
599            Ok(()) => IpcResponse::Ok {
600                message: "synchronization requested".to_owned(),
601            },
602            Err(error) => response_from_error(error),
603        }
604    }
605
606    fn handle_reload_config(&self) -> IpcResponse {
607        self.daemon_handle.reload_requested.store(true, Ordering::Release);
608        if self.daemon_handle.sync_trigger.send(None).is_err() {
609            tracing::debug!("daemon reload wake-up channel is not connected");
610        }
611        IpcResponse::Ok {
612            message: "configuration reload requested".to_owned(),
613        }
614    }
615
616    fn handle_shutdown(&self, force: bool) -> IpcResponse {
617        if let Err(error) = self.daemon_handle.shutdown_sender.send(()) {
618            return response_from_error(error);
619        }
620        IpcResponse::Ok {
621            message: if force {
622                "forced shutdown requested".to_owned()
623            } else {
624                "shutdown requested".to_owned()
625            },
626        }
627    }
628
629    async fn handle_download_response(&self, namespace: String, strategy: FetchStrategy) -> IpcResponse {
630        match self.handle_download(namespace, strategy).await {
631            Ok(bytes_transferred) => IpcResponse::DownloadComplete { bytes_transferred },
632            Err(error) => response_from_error(error),
633        }
634    }
635
636    async fn handle_import_files_response(&self, namespace: Option<String>, path: PathBuf) -> IpcResponse {
637        match self.handle_import_files(namespace, path).await {
638            Ok(entries) => IpcResponse::ImportFilesComplete { entries },
639            Err(error) => response_from_error(error),
640        }
641    }
642
643    async fn handle_download(&self, namespace: String, strategy: FetchStrategy) -> Result<u64> {
644        let context = self.archive_context.clone().ok_or_else(|| {
645            SyncwebError::operation("daemon download IPC is unavailable", "server has no node context")
646        })?;
647        let namespace_id = iroh_docs::NamespaceId::from_str(&namespace)
648            .map_err(|error| SyncwebError::operation("invalid download namespace", error))?;
649        let sync = SyncEngine::new(
650            FolderManager::new(&context.node),
651            context.node.blob_store().clone(),
652            context.node.docs_engine().clone(),
653            context.node.gossip_service().clone(),
654        );
655        let mut intent = sync.fetch(namespace_id, strategy).await?;
656        let mut bytes_transferred = 0_u64;
657        while let Some(event) = intent.next().await {
658            match event {
659                SyncEvent::Stats(stats) => {
660                    bytes_transferred = bytes_transferred.max(stats.bytes_transferred);
661                }
662                SyncEvent::Failed(message) => {
663                    return Err(SyncwebError::operation("daemon download failed", message));
664                }
665                SyncEvent::Finished => break,
666                SyncEvent::Started
667                | SyncEvent::Progress { .. }
668                | SyncEvent::Paused
669                | SyncEvent::Resumed
670                | SyncEvent::Cancelled => {}
671            }
672        }
673        Ok(bytes_transferred)
674    }
675
676    async fn handle_import_files(&self, namespace: Option<String>, path: PathBuf) -> Result<u64> {
677        let context = self.archive_context.clone().ok_or_else(|| {
678            SyncwebError::operation("daemon filesystem import is unavailable", "server has no node context")
679        })?;
680        let namespace_id = if let Some(value) = namespace {
681            iroh_docs::NamespaceId::from_str(&value)
682                .map_err(|error| SyncwebError::operation("invalid import namespace", error))?
683        } else {
684            let folders = self.daemon_handle.folder_registry.read().await.statuses();
685            let [folder] = folders.as_slice() else {
686                return Err(SyncwebError::operation(
687                    "cannot infer import namespace",
688                    "specify a folder when more than one folder is managed",
689                ));
690            };
691            iroh_docs::NamespaceId::from_str(&folder.namespace)
692                .map_err(|error| SyncwebError::operation("invalid managed folder namespace", error))?
693        };
694        let folder = FolderManager::new(&context.node).get(namespace_id).await?;
695        let root = if path.is_dir() {
696            path.clone()
697        } else {
698            path.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf)
699        };
700        let importer = Importer::new(
701            context.node.blob_store().clone(),
702            context.node.docs_engine().clone(),
703            folder.doc().clone(),
704            folder.author(),
705        )
706        .with_root(root);
707        let entries = importer.import_path(path).await?;
708        u64::try_from(entries.len()).map_err(|error| SyncwebError::operation("import entry count overflowed", error))
709    }
710
711    async fn handle_import_archive(
712        &self,
713        input: PathBuf,
714        target: PathBuf,
715        filter: Option<FilterConfig>,
716    ) -> Result<DropImportResult> {
717        let context = self.archive_context.clone().ok_or_else(|| {
718            SyncwebError::operation("daemon archive IPC is unavailable", "server has no node context")
719        })?;
720        let filter_engine = filter.map(FilterEngine::new).transpose()?;
721        let options = filter_engine.map_or_else(DropImportOptions::default, |value| {
722            DropImportOptions::default().with_filter(value)
723        });
724        let importer = DropImporter::new(context.node.blob_store().clone());
725        let mut result = importer
726            .import_archive(&input, options, Some(context.pool.as_ref()))
727            .await?;
728        importer.materialize(&result, &target).await?;
729        let folder = FolderManager::new(&context.node).create(SyncMode::SendReceive).await?;
730        let store = CollectionStore::new(
731            folder.doc().clone(),
732            folder.author(),
733            context.node.blob_store().clone(),
734            context.node.docs_engine().clone(),
735        );
736        store.publish(&result.collection_manifest, 1).await?;
737        result.namespace_id = Some(folder.namespace_id());
738        Ok(result)
739    }
740
741    async fn handle_export_archive(
742        &self,
743        namespace: String,
744        version: Option<String>,
745        output: PathBuf,
746    ) -> Result<DropExportResult> {
747        let context = self.archive_context.clone().ok_or_else(|| {
748            SyncwebError::operation("daemon archive IPC is unavailable", "server has no node context")
749        })?;
750        let namespace_id = iroh_docs::NamespaceId::from_str(&namespace)
751            .map_err(|error| SyncwebError::operation("invalid export namespace", error))?;
752        let folder = FolderManager::new(&context.node).get(namespace_id).await?;
753        let head = Self::latest_collection_head(&context.node, folder.doc()).await?;
754        let manifests = Self::collection_manifests(&context.node, folder.doc(), head).await?;
755        let options = version.map_or_else(DropExportOptions::default, |value| {
756            DropExportOptions::default().with_version(value)
757        });
758        DropExporter::new(context.node.blob_store().clone())
759            .export_drop_with_options(&manifests, output, options, Some(context.pool.as_ref()))
760            .await
761    }
762
763    async fn handle_join(&self, ticket: String, path: PathBuf, mode: SyncMode) -> IpcResponse {
764        let context = match &self.archive_context {
765            Some(ctx) => ctx.clone(),
766            None => {
767                return IpcResponse::Error {
768                    message: "daemon join IPC is unavailable: server has no node context".to_owned(),
769                };
770            }
771        };
772        match tokio::fs::create_dir_all(&path).await {
773            Ok(()) => {}
774            Err(error) => {
775                return IpcResponse::Error {
776                    message: format!("failed to create folder path: {error}"),
777                };
778            }
779        }
780        let manager = FolderManager::new(&context.node);
781        match manager.join(ticket, mode).await {
782            Ok(folder) => {
783                let namespace = folder.namespace_id().to_string();
784                IpcResponse::Ok {
785                    message: format!("joined: {namespace}"),
786                }
787            }
788            Err(error) => response_from_error(error),
789        }
790    }
791
792    async fn handle_publish(&self, namespace: String, blob: Option<String>) -> IpcResponse {
793        let context = match &self.archive_context {
794            Some(ctx) => ctx.clone(),
795            None => {
796                return IpcResponse::Error {
797                    message: "daemon publish IPC is unavailable: server has no node context".to_owned(),
798                };
799            }
800        };
801        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
802            Ok(id) => id,
803            Err(error) => {
804                return IpcResponse::Error {
805                    message: format!("invalid namespace: {error}"),
806                };
807            }
808        };
809        let manager = FolderManager::new(&context.node);
810        let folder = match manager.get(namespace_id).await {
811            Ok(f) => f,
812            Err(error) => return response_from_error(error),
813        };
814        match blob {
815            Some(blob_hash) => {
816                let hash = match blob_hash.parse::<iroh_blobs::Hash>() {
817                    Ok(h) => h,
818                    Err(error) => {
819                        return IpcResponse::Error {
820                            message: format!("invalid blob hash: {error}"),
821                        };
822                    }
823                };
824                match folder.publish_blob(context.node.endpoint().addr(), hash).await {
825                    Ok(ticket) => IpcResponse::Ok {
826                        message: format!("blob_ticket: {ticket}"),
827                    },
828                    Err(error) => response_from_error(error),
829                }
830            }
831            None => match folder.ticket(context.node.endpoint().addr(), false).await {
832                Ok(ticket) => IpcResponse::Ok {
833                    message: format!("ticket: {ticket}"),
834                },
835                Err(error) => response_from_error(error),
836            },
837        }
838    }
839
840    async fn handle_subscribe(&self, namespace: String, params: SubscribeParams) -> IpcResponse {
841        let context = match &self.archive_context {
842            Some(ctx) => ctx.clone(),
843            None => {
844                return IpcResponse::Error {
845                    message: "daemon subscribe IPC is unavailable: server has no node context".to_owned(),
846                };
847            }
848        };
849        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
850            Ok(id) => id,
851            Err(error) => {
852                return IpcResponse::Error {
853                    message: format!("invalid namespace: {error}"),
854                };
855            }
856        };
857        let manager = FolderManager::new(&context.node);
858        let sync = SyncEngine::new(
859            manager,
860            context.node.blob_store().clone(),
861            context.node.docs_engine().clone(),
862            context.node.gossip_service().clone(),
863        );
864        match sync.subscribe(namespace_id, params).await {
865            Ok(_intent) => IpcResponse::Ok {
866                message: format!("subscribed: {namespace}"),
867            },
868            Err(error) => response_from_error(error),
869        }
870    }
871
872    async fn handle_create_folder(&self, path: PathBuf, mode: String) -> IpcResponse {
873        let context = match &self.archive_context {
874            Some(ctx) => ctx.clone(),
875            None => {
876                return IpcResponse::Error {
877                    message: "daemon create-folder IPC is unavailable: server has no node context".to_owned(),
878                };
879            }
880        };
881        match std::fs::create_dir_all(&path) {
882            Ok(()) => {}
883            Err(error) => {
884                return IpcResponse::Error {
885                    message: format!("failed to create directory: {error}"),
886                };
887            }
888        }
889        let sync_mode = match SyncMode::from_str(&mode) {
890            Ok(m) => m,
891            Err(error) => {
892                return IpcResponse::Error {
893                    message: format!("invalid sync mode: {error}"),
894                };
895            }
896        };
897        let manager = FolderManager::new(&context.node);
898        match manager.create(sync_mode).await {
899            Ok(folder) => {
900                let namespace = folder.namespace_id().to_string();
901                let namespace_id = folder.namespace_id();
902                if self
903                    .daemon_handle
904                    .folder_registry
905                    .write()
906                    .await
907                    .add(FolderEntry::new(namespace_id, path))
908                    .is_err()
909                {
910                    tracing::warn!(%namespace, "folder already in daemon registry");
911                }
912                match folder.ticket(context.node.endpoint().addr(), true).await {
913                    Ok(ticket) => IpcResponse::Ok {
914                        message: format!("namespace: {namespace}\nticket: {ticket}"),
915                    },
916                    Err(error) => response_from_error(error),
917                }
918            }
919            Err(error) => response_from_error(error),
920        }
921    }
922
923    async fn handle_health_check(&self, path: PathBuf) -> IpcResponse {
924        let context = match &self.archive_context {
925            Some(ctx) => ctx.clone(),
926            None => {
927                return IpcResponse::Error {
928                    message: "daemon health IPC is unavailable: server has no node context".to_owned(),
929                };
930            }
931        };
932        let manager = FolderManager::new(&context.node);
933        let folder = match resolve_folder_for_daemon(&manager, &path).await {
934            Ok(f) => f,
935            Err(error) => return error,
936        };
937        let entries = match context.node.docs_engine().list_latest(folder.doc()).await {
938            Ok(e) => e,
939            Err(error) => return response_from_error(error),
940        };
941        let mut candidates = Vec::new();
942        for entry in entries {
943            if entry.key().starts_with(b"sys/") {
944                continue;
945            }
946            let path_str = match String::from_utf8(entry.key().to_vec()) {
947                Ok(s) => s,
948                Err(error) => {
949                    return IpcResponse::Error {
950                        message: format!("folder entry path is not UTF-8: {error}"),
951                    };
952                }
953            };
954            let local = match folder.has_local(entry.content_hash()).await {
955                Ok(l) => l,
956                Err(error) => return response_from_error(error),
957            };
958            candidates.push(FetchCandidate::new(
959                path_str,
960                entry.content_hash(),
961                entry.content_len(),
962                0,
963                local,
964            ));
965        }
966        let report = HealthReport::from_candidates(&candidates, 4);
967        IpcResponse::Ok {
968            message: format!(
969                "total: {}, well-seeded: {}, under-seeded: {}, unseeded: {}",
970                report.total, report.well_seeded, report.under_seeded, report.unseeded,
971            ),
972        }
973    }
974
975    async fn handle_verify_integrity(&self, path: PathBuf) -> IpcResponse {
976        let context = match &self.archive_context {
977            Some(ctx) => ctx.clone(),
978            None => {
979                return IpcResponse::Error {
980                    message: "daemon verify IPC is unavailable: server has no node context".to_owned(),
981                };
982            }
983        };
984        let manager = FolderManager::new(&context.node);
985        let folder = match resolve_folder_for_daemon(&manager, &path).await {
986            Ok(f) => f,
987            Err(error) => return error,
988        };
989        let checker = IntegrityChecker::new(context.node.blob_store().clone(), context.node.docs_engine().clone());
990        match checker.verify_folder(&folder).await {
991            Ok(result) => {
992                let valid = result.is_valid();
993                IpcResponse::Ok {
994                    message: format!(
995                        "total: {}, verified: {}, corrupted: {}, missing: {}, valid: {valid}",
996                        result.total,
997                        result.verified,
998                        result.corrupted.len(),
999                        result.missing.len(),
1000                    ),
1001                }
1002            }
1003            Err(error) => response_from_error(error),
1004        }
1005    }
1006
1007    #[allow(clippy::unused_self)]
1008    fn handle_unsubscribe(&self, namespace: &str) -> IpcResponse {
1009        match iroh_docs::NamespaceId::from_str(namespace) {
1010            Ok(namespace_id) => {
1011                if cancel_session(namespace_id) {
1012                    IpcResponse::Ok {
1013                        message: format!("unsubscribed: {namespace}"),
1014                    }
1015                } else {
1016                    IpcResponse::Error {
1017                        message: format!("no active session for {namespace}"),
1018                    }
1019                }
1020            }
1021            Err(error) => IpcResponse::Error {
1022                message: format!("invalid namespace: {error}"),
1023            },
1024        }
1025    }
1026
1027    async fn handle_leave_folder(&self, namespace: String) -> IpcResponse {
1028        let context = match &self.archive_context {
1029            Some(ctx) => ctx.clone(),
1030            None => {
1031                return IpcResponse::Error {
1032                    message: "daemon leave-folder IPC is unavailable: server has no node context".to_owned(),
1033                };
1034            }
1035        };
1036        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
1037            Ok(id) => id,
1038            Err(error) => {
1039                return IpcResponse::Error {
1040                    message: format!("invalid namespace: {error}"),
1041                };
1042            }
1043        };
1044        let _ = cancel_session(namespace_id);
1045        let manager = FolderManager::new(&context.node);
1046        match manager.drop(namespace_id).await {
1047            Ok(()) => {
1048                let _ = self.daemon_handle.folder_registry.write().await.remove(&namespace_id);
1049                IpcResponse::Ok {
1050                    message: format!("left: {namespace}"),
1051                }
1052            }
1053            Err(error) => response_from_error(error),
1054        }
1055    }
1056
1057    async fn handle_unpublish(&self, namespace: String, blob: String) -> IpcResponse {
1058        let context = match &self.archive_context {
1059            Some(ctx) => ctx.clone(),
1060            None => {
1061                return IpcResponse::Error {
1062                    message: "daemon unpublish IPC is unavailable: server has no node context".to_owned(),
1063                };
1064            }
1065        };
1066        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
1067            Ok(id) => id,
1068            Err(error) => {
1069                return IpcResponse::Error {
1070                    message: format!("invalid namespace: {error}"),
1071                };
1072            }
1073        };
1074        let hash = match blob.parse::<iroh_blobs::Hash>() {
1075            Ok(h) => h,
1076            Err(error) => {
1077                return IpcResponse::Error {
1078                    message: format!("invalid blob hash: {error}"),
1079                };
1080            }
1081        };
1082        let manager = FolderManager::new(&context.node);
1083        let folder = match manager.get(namespace_id).await {
1084            Ok(f) => f,
1085            Err(error) => return response_from_error(error),
1086        };
1087        match folder.unpublish_blob(hash).await {
1088            Ok(()) => IpcResponse::Ok {
1089                message: format!("unpublished: {blob}"),
1090            },
1091            Err(error) => response_from_error(error),
1092        }
1093    }
1094
1095    async fn handle_snapshot_create(&self, path: PathBuf, description: Option<String>, threads: usize) -> IpcResponse {
1096        let context = match &self.archive_context {
1097            Some(ctx) => ctx.clone(),
1098            None => {
1099                return IpcResponse::Error {
1100                    message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
1101                };
1102            }
1103        };
1104        let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
1105        let result = if path.exists() {
1106            snapshots.create_from_path(&path, threads, description).await
1107        } else {
1108            let manager = FolderManager::new(&context.node);
1109            let folder = match resolve_folder_for_daemon(&manager, &path).await {
1110                Ok(f) => f,
1111                Err(error) => return error,
1112            };
1113            snapshots.create_for_folder(&folder, description).await
1114        };
1115        match result {
1116            Ok(snapshot) => IpcResponse::Ok {
1117                message: format!(
1118                    "snapshot: {}\nroot_hash: {}\nfiles: {}\nsize: {}",
1119                    snapshot.id, snapshot.root_hash, snapshot.file_count, snapshot.total_size,
1120                ),
1121            },
1122            Err(error) => response_from_error(error),
1123        }
1124    }
1125
1126    async fn handle_snapshot_list(&self, path: PathBuf) -> IpcResponse {
1127        let context = match &self.archive_context {
1128            Some(ctx) => ctx.clone(),
1129            None => {
1130                return IpcResponse::Error {
1131                    message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
1132                };
1133            }
1134        };
1135        let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
1136        let namespace = path.to_string_lossy().parse::<iroh_docs::NamespaceId>().ok();
1137        match snapshots.list().await {
1138            Ok(all) => {
1139                let count = all
1140                    .into_iter()
1141                    .filter(|s| namespace.is_none_or(|id| s.namespace_id == Some(id)))
1142                    .count();
1143                IpcResponse::Ok {
1144                    message: format!("snapshots: {count}"),
1145                }
1146            }
1147            Err(error) => response_from_error(error),
1148        }
1149    }
1150
1151    async fn handle_snapshot_delete(&self, id: String) -> IpcResponse {
1152        let context = match &self.archive_context {
1153            Some(ctx) => ctx.clone(),
1154            None => {
1155                return IpcResponse::Error {
1156                    message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
1157                };
1158            }
1159        };
1160        let hash = match id.parse::<iroh_blobs::Hash>() {
1161            Ok(h) => h,
1162            Err(error) => {
1163                return IpcResponse::Error {
1164                    message: format!("invalid snapshot id: {error}"),
1165                };
1166            }
1167        };
1168        let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
1169        match snapshots.delete(hash).await {
1170            Ok(()) => IpcResponse::Ok {
1171                message: format!("deleted: {id}"),
1172            },
1173            Err(error) => response_from_error(error),
1174        }
1175    }
1176
1177    async fn handle_collection_publish(
1178        &self,
1179        path: PathBuf,
1180        namespace: String,
1181        sequence: u64,
1182        bootstrap: Vec<String>,
1183    ) -> IpcResponse {
1184        let context = match &self.archive_context {
1185            Some(ctx) => ctx.clone(),
1186            None => {
1187                return IpcResponse::Error {
1188                    message: "daemon collection-publish IPC is unavailable: server has no node context".to_owned(),
1189                };
1190            }
1191        };
1192        let manifest_path = path.join(".syncweb-collection.json");
1193        let manifest_bytes = match tokio::fs::read(&manifest_path).await {
1194            Ok(bytes) => bytes,
1195            Err(error) => {
1196                return IpcResponse::Error {
1197                    message: format!("failed to read collection manifest: {error}"),
1198                };
1199            }
1200        };
1201        let manifest = match CollectionManifest::from_bytes(manifest_bytes) {
1202            Ok(m) => m,
1203            Err(error) => return response_from_error(error),
1204        };
1205        for entry in &manifest.entries {
1206            let file_path = path.join(&entry.logical_path);
1207            match context.node.blob_store().add_file(&file_path).await {
1208                Ok(hash) => {
1209                    if hash != entry.content_id {
1210                        return IpcResponse::Error {
1211                            message: format!(
1212                                "collection content changed while publishing: {}",
1213                                entry.logical_path.display()
1214                            ),
1215                        };
1216                    }
1217                }
1218                Err(error) => return response_from_error(error),
1219            }
1220        }
1221        let namespace_id = match namespace.parse::<iroh_docs::NamespaceId>() {
1222            Ok(id) => id,
1223            Err(error) => {
1224                return IpcResponse::Error {
1225                    message: format!("invalid namespace: {error}"),
1226                };
1227            }
1228        };
1229        let manager = FolderManager::new(&context.node);
1230        let folder = match manager.get(namespace_id).await {
1231            Ok(f) => f,
1232            Err(error) => return response_from_error(error),
1233        };
1234        let store = CollectionStore::new(
1235            folder.doc().clone(),
1236            folder.author(),
1237            context.node.blob_store().clone(),
1238            context.node.docs_engine().clone(),
1239        );
1240        let head = match store.publish(&manifest, sequence).await {
1241            Ok(h) => h,
1242            Err(error) => return response_from_error(error),
1243        };
1244        let name = manifest
1245            .package
1246            .as_ref()
1247            .map_or_else(|| manifest.collection_id.to_string(), |profile| profile.name.clone());
1248        let ticket = context.node.blob_store().ticket(context.node.endpoint(), head.manifest);
1249        let announcement = match PackageAnnouncement::new(
1250            manifest.collection_id,
1251            name,
1252            manifest.version.clone(),
1253            head.sequence,
1254            head.manifest,
1255            ticket.to_string(),
1256            context.node.endpoint().id(),
1257        ) {
1258            Ok(a) => a,
1259            Err(error) => return response_from_error(error),
1260        };
1261        let bootstrap_nodes: Vec<_> = bootstrap
1262            .into_iter()
1263            .filter_map(|b| b.parse::<iroh::PublicKey>().ok())
1264            .collect();
1265        let catalog = PackageCatalog::new(context.node.gossip_service());
1266        let topic = if bootstrap_nodes.is_empty() {
1267            match catalog.subscribe(bootstrap_nodes).await {
1268                Ok(t) => t,
1269                Err(error) => return response_from_error(error),
1270            }
1271        } else {
1272            match catalog.subscribe_and_join(bootstrap_nodes).await {
1273                Ok(t) => t,
1274                Err(error) => return response_from_error(error),
1275            }
1276        };
1277        let (sender, _receiver) = GossipService::split(topic);
1278        if let Err(error) = catalog.announce(&sender, &announcement).await {
1279            return response_from_error(error);
1280        }
1281        IpcResponse::Ok {
1282            message: format!(
1283                "manifest: {}\nmanifest_ticket: {}\nsequence: {}",
1284                head.manifest, announcement.manifest_ticket, head.sequence,
1285            ),
1286        }
1287    }
1288
1289    #[cfg(unix)]
1290    async fn handle_connection(&self, stream: tokio::net::UnixStream) -> Result<()> {
1291        use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1292        let (read_half, mut write_half) = stream.into_split();
1293        let mut line = Vec::new();
1294        BufReader::new(read_half).read_until(b'\n', &mut line).await?;
1295        let response = match serde_json::from_slice::<IpcRequest>(line.trim_ascii()) {
1296            Ok(request) => self.handle_request(request).await,
1297            Err(error) => IpcResponse::Error {
1298                message: format!("invalid daemon request: {error}"),
1299            },
1300        };
1301        let mut bytes = serde_json::to_vec(&response)
1302            .map_err(|error| SyncwebError::operation("failed to serialize IPC response", error))?;
1303        bytes.push(b'\n');
1304        write_half.write_all(&bytes).await?;
1305        Ok(())
1306    }
1307
1308    async fn latest_collection_head(node: &IrohNode, doc: &iroh_docs::api::Doc) -> Result<CollectionHead> {
1309        let entries = node.docs_engine().list_latest(doc).await?;
1310        let head_entry = entries
1311            .iter()
1312            .find(|entry| entry.key().starts_with(b"collections/") && entry.key().ends_with(b"/head"))
1313            .ok_or_else(|| SyncwebError::InvalidConfig("folder has no published collection head".to_owned()))?;
1314        let bytes = node.blob_store().get(head_entry.content_hash()).await?;
1315        let head = serde_json::from_slice(&bytes)
1316            .map_err(|error| SyncwebError::operation("failed to deserialize collection head", error))?;
1317        Ok(head)
1318    }
1319
1320    async fn collection_manifests(
1321        node: &IrohNode,
1322        doc: &iroh_docs::api::Doc,
1323        head: CollectionHead,
1324    ) -> Result<Vec<CollectionManifest>> {
1325        let prefix = format!("collections/{}/manifests/", head.collection_id);
1326        let entries = node.docs_engine().list_latest(doc).await?;
1327        let mut manifests = Vec::new();
1328        for entry in entries {
1329            if !entry.key().starts_with(prefix.as_bytes()) {
1330                continue;
1331            }
1332            let bytes = node.blob_store().get(entry.content_hash()).await?;
1333            let manifest = CollectionManifest::from_bytes(bytes)?;
1334            manifests.push(manifest);
1335        }
1336        if manifests.is_empty() {
1337            return Err(SyncwebError::InvalidConfig(
1338                "folder has no published collection manifests".to_owned(),
1339            ));
1340        }
1341        Ok(manifests)
1342    }
1343}
1344
1345fn response_from_error(error: impl std::fmt::Display) -> IpcResponse {
1346    IpcResponse::Error {
1347        message: error.to_string(),
1348    }
1349}
1350
1351async fn resolve_folder_for_daemon(
1352    manager: &FolderManager,
1353    selector: &Path,
1354) -> std::result::Result<crate::folder::SyncwebFolder, IpcResponse> {
1355    if let Ok(namespace) = selector.to_string_lossy().parse::<iroh_docs::NamespaceId>() {
1356        return manager.get(namespace).await.map_err(|error| IpcResponse::Error {
1357            message: format!("folder not found: {error}"),
1358        });
1359    }
1360    let folders = manager.list().await.map_err(|error| IpcResponse::Error {
1361        message: format!("failed to list folders: {error}"),
1362    })?;
1363    match folders.as_slice() {
1364        [folder] => Ok(folder.clone()),
1365        [] => Err(IpcResponse::Error {
1366            message: "no synchronized folders are available".to_owned(),
1367        }),
1368        _ => Err(IpcResponse::Error {
1369            message: "folder path is not a namespace ID and more than one synchronized folder is available".to_owned(),
1370        }),
1371    }
1372}
1373
1374/// A client for sending requests to the daemon.
1375#[derive(Clone, Debug)]
1376pub struct IpcClient {
1377    socket_path: PathBuf,
1378}
1379
1380impl IpcClient {
1381    #[must_use]
1382    pub fn new(data_dir: &Path) -> Self {
1383        Self::from_socket_path(daemon_socket_path(data_dir))
1384    }
1385
1386    #[must_use]
1387    pub const fn from_socket_path(socket_path: PathBuf) -> Self {
1388        Self { socket_path }
1389    }
1390
1391    #[must_use]
1392    pub fn socket_path(&self) -> &Path {
1393        &self.socket_path
1394    }
1395
1396    /// Send one newline-delimited JSON request and await its response.
1397    ///
1398    /// # Errors
1399    ///
1400    /// Returns an error when the socket is unavailable, the operation times
1401    /// out, or either JSON message is malformed.
1402    pub async fn send(&self, request: IpcRequest) -> Result<IpcResponse> {
1403        #[cfg(unix)]
1404        {
1405            use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1406            use tokio::time::timeout;
1407            let mut stream = timeout(IPC_TIMEOUT, tokio::net::UnixStream::connect(&self.socket_path))
1408                .await
1409                .map_err(|error| SyncwebError::operation("daemon IPC connection timed out", error))?
1410                .map_err(|error| SyncwebError::operation("daemon IPC connection failed", error))?;
1411            let mut message = serde_json::to_vec(&request)
1412                .map_err(|error| SyncwebError::operation("failed to serialize IPC request", error))?;
1413            message.push(b'\n');
1414            timeout(IPC_TIMEOUT, stream.write_all(&message))
1415                .await
1416                .map_err(|error| SyncwebError::operation("daemon IPC write timed out", error))?
1417                .map_err(|error| SyncwebError::operation("daemon IPC write failed", error))?;
1418            let mut response = Vec::new();
1419            let mut reader = BufReader::new(stream);
1420            timeout(IPC_TIMEOUT, reader.read_until(b'\n', &mut response))
1421                .await
1422                .map_err(|error| SyncwebError::operation("daemon IPC read timed out", error))?
1423                .map_err(|error| SyncwebError::operation("daemon IPC read failed", error))?;
1424            if response.is_empty() {
1425                return Err(SyncwebError::operation(
1426                    "daemon IPC returned no response",
1427                    "connection closed",
1428                ));
1429            }
1430            serde_json::from_slice(response.trim_ascii())
1431                .map_err(|error| SyncwebError::operation("failed to deserialize IPC response", error))
1432        }
1433        #[cfg(not(unix))]
1434        {
1435            let _ = request;
1436            Err(SyncwebError::operation(
1437                "daemon IPC is unavailable",
1438                "Unix sockets are not supported on this platform",
1439            ))
1440        }
1441    }
1442
1443    /// Perform a bounded synchronous status probe for routing decisions.
1444    ///
1445    /// # Errors
1446    ///
1447    /// Returns an error when the daemon does not answer a status request.
1448    pub fn status_sync(&self) -> Result<DaemonStatus> {
1449        #[cfg(unix)]
1450        {
1451            use std::{
1452                io::{BufRead, Write},
1453                os::unix::net::UnixStream,
1454            };
1455
1456            let stream = UnixStream::connect(&self.socket_path)?;
1457            stream.set_read_timeout(Some(IPC_TIMEOUT))?;
1458            stream.set_write_timeout(Some(IPC_TIMEOUT))?;
1459            let mut writer = stream.try_clone()?;
1460            let request = serde_json::to_vec(&IpcRequest::new(IpcCommand::Status))
1461                .map_err(|error| SyncwebError::operation("failed to serialize IPC request", error))?;
1462            writer.write_all(&request)?;
1463            writer.write_all(b"\n")?;
1464            let mut line = String::new();
1465            std::io::BufReader::new(stream).read_line(&mut line)?;
1466            match serde_json::from_str::<IpcResponse>(&line)
1467                .map_err(|error| SyncwebError::operation("failed to deserialize IPC response", error))?
1468            {
1469                IpcResponse::Status(status) => Ok(status),
1470                IpcResponse::Error { message } => Err(SyncwebError::operation("daemon status request failed", message)),
1471                IpcResponse::Ok { .. }
1472                | IpcResponse::FolderList(_)
1473                | IpcResponse::DownloadComplete { .. }
1474                | IpcResponse::ImportFilesComplete { .. }
1475                | IpcResponse::ImportComplete(_)
1476                | IpcResponse::ExportComplete(_) => Err(SyncwebError::operation(
1477                    "daemon status request returned an unexpected response",
1478                    "unexpected response",
1479                )),
1480            }
1481        }
1482        #[cfg(not(unix))]
1483        {
1484            Err(SyncwebError::operation(
1485                "daemon IPC is unavailable",
1486                "Unix sockets are not supported on this platform",
1487            ))
1488        }
1489    }
1490}
1491
1492fn set_owner_only_permissions(path: &Path) -> Result<()> {
1493    #[cfg(unix)]
1494    {
1495        use std::os::unix::fs::PermissionsExt;
1496        let mut permissions = std::fs::metadata(path)?.permissions();
1497        permissions.set_mode(0o600);
1498        std::fs::set_permissions(path, permissions)?;
1499    }
1500    let _ = path;
1501    Ok(())
1502}
1503
1504#[cfg(test)]
1505mod tests {
1506    use std::{path::PathBuf, sync::Arc, time::Duration};
1507
1508    #[cfg(unix)]
1509    use std::os::unix::fs::PermissionsExt;
1510
1511    use super::*;
1512    use crate::daemon::{DaemonState, DaemonStatus};
1513
1514    fn socket_path() -> PathBuf {
1515        std::env::temp_dir().join(format!("syncweb-ipc-{}.sock", uuid::Uuid::new_v4()))
1516    }
1517
1518    fn state() -> DaemonState {
1519        DaemonState::new(
1520            std::process::id(),
1521            "node",
1522            1,
1523            std::env::temp_dir(),
1524            DaemonStatus::Running,
1525        )
1526    }
1527
1528    #[test]
1529    fn request_round_trips_as_json() {
1530        let request = IpcRequest::new(IpcCommand::Download {
1531            namespace: "namespace".to_owned(),
1532            strategy: FetchStrategy::default(),
1533        });
1534        let encoded = serde_json::to_vec(&request).expect("serialize request");
1535        let decoded: IpcRequest = serde_json::from_slice(&encoded).expect("deserialize request");
1536        assert!(matches!(decoded.command, IpcCommand::Download { .. }));
1537    }
1538
1539    #[tokio::test]
1540    async fn handle_request_updates_registry_and_trigger() {
1541        let (sync_trigger, mut sync_receiver) = mpsc::unbounded_channel();
1542        let handle = DaemonHandle::with_channels(
1543            Arc::new(RwLock::new(state())),
1544            Arc::new(RwLock::new(FolderRegistry::new())),
1545            broadcast::channel(4).0,
1546            sync_trigger,
1547        );
1548        let server = IpcServer::new(socket_path(), handle);
1549        let namespace = iroh_docs::NamespaceSecret::from_bytes(&[7; 32]).id().to_string();
1550
1551        assert!(matches!(
1552            server
1553                .handle_request(IpcRequest::new(IpcCommand::AddFolder {
1554                    namespace: namespace.clone(),
1555                    path: PathBuf::from("/tmp/folder"),
1556                }))
1557                .await,
1558            IpcResponse::Ok { .. }
1559        ));
1560        assert!(matches!(
1561            server
1562                .handle_request(IpcRequest::new(IpcCommand::ListFolders))
1563                .await,
1564            IpcResponse::FolderList(folders)
1565                if folders.len() == 1 && folders.first().is_some_and(|folder| folder.namespace == namespace)
1566        ));
1567        assert!(matches!(
1568            server
1569                .handle_request(IpcRequest::new(IpcCommand::TriggerSync {
1570                    namespace: Some(namespace.clone()),
1571                }))
1572                .await,
1573            IpcResponse::Ok { .. }
1574        ));
1575        assert_eq!(sync_receiver.recv().await, Some(Some(namespace)));
1576    }
1577
1578    #[cfg(unix)]
1579    #[tokio::test]
1580    async fn client_round_trips_with_server() {
1581        let path = socket_path();
1582        let handle = DaemonHandle::new(state());
1583        let mut shutdown_receiver = handle.shutdown_sender.subscribe();
1584        let server = IpcServer::new(path.clone(), handle);
1585        let server_task = tokio::spawn(async move { server.serve().await });
1586
1587        tokio::time::timeout(Duration::from_secs(1), async {
1588            while !path.exists() {
1589                tokio::time::sleep(Duration::from_millis(5)).await;
1590            }
1591        })
1592        .await
1593        .expect("server socket should appear");
1594
1595        let client = IpcClient::from_socket_path(path.clone());
1596        assert!(matches!(
1597            client
1598                .send(IpcRequest::new(IpcCommand::Status))
1599                .await
1600                .expect("status response"),
1601            IpcResponse::Status(DaemonStatus::Running)
1602        ));
1603        assert!(matches!(
1604            client
1605                .send(IpcRequest::new(IpcCommand::Shutdown { force: false }))
1606                .await
1607                .expect("shutdown response"),
1608            IpcResponse::Ok { .. }
1609        ));
1610        shutdown_receiver.recv().await.expect("shutdown broadcast");
1611        server_task
1612            .await
1613            .expect("server task should join")
1614            .expect("server should stop cleanly");
1615        assert!(!path.exists());
1616    }
1617
1618    #[cfg(unix)]
1619    #[tokio::test]
1620    async fn socket_is_owner_only() {
1621        let path = socket_path();
1622        let listener = IpcListener::new(path.clone()).bind().expect("bind socket");
1623        assert_eq!(
1624            std::fs::metadata(&path).expect("socket metadata").permissions().mode() & 0o777,
1625            0o600
1626        );
1627        drop(listener);
1628        std::fs::remove_file(path).expect("remove socket");
1629    }
1630
1631    #[tokio::test]
1632    async fn client_reports_missing_server() {
1633        let path = socket_path();
1634        let error = IpcClient::from_socket_path(path)
1635            .send(IpcRequest::new(IpcCommand::Status))
1636            .await
1637            .expect_err("missing server should fail");
1638        assert!(error.to_string().contains("daemon IPC connection failed"));
1639    }
1640
1641    #[test]
1642    fn new_commands_round_trip_as_json() {
1643        let req1 = IpcRequest::new(IpcCommand::Unsubscribe {
1644            namespace: "ns".to_owned(),
1645        });
1646        let enc1 = serde_json::to_vec(&req1).expect("serialize");
1647        let dec1: IpcRequest = serde_json::from_slice(&enc1).expect("deserialize");
1648        assert!(matches!(dec1.command, IpcCommand::Unsubscribe { .. }));
1649
1650        let req2 = IpcRequest::new(IpcCommand::LeaveFolder {
1651            namespace: "ns".to_owned(),
1652        });
1653        let enc2 = serde_json::to_vec(&req2).expect("serialize");
1654        let dec2: IpcRequest = serde_json::from_slice(&enc2).expect("deserialize");
1655        assert!(matches!(dec2.command, IpcCommand::LeaveFolder { .. }));
1656
1657        let req3 = IpcRequest::new(IpcCommand::Unpublish {
1658            namespace: "ns".to_owned(),
1659            blob: "baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
1660        });
1661        let enc3 = serde_json::to_vec(&req3).expect("serialize");
1662        let dec3: IpcRequest = serde_json::from_slice(&enc3).expect("deserialize");
1663        assert!(matches!(dec3.command, IpcCommand::Unpublish { .. }));
1664
1665        let req4 = IpcRequest::new(IpcCommand::SnapshotCreate {
1666            path: PathBuf::from("."),
1667            description: Some("test".to_owned()),
1668            threads: 0,
1669        });
1670        let enc4 = serde_json::to_vec(&req4).expect("serialize");
1671        let dec4: IpcRequest = serde_json::from_slice(&enc4).expect("deserialize");
1672        assert!(matches!(dec4.command, IpcCommand::SnapshotCreate { .. }));
1673
1674        let req5 = IpcRequest::new(IpcCommand::SnapshotList {
1675            path: PathBuf::from("."),
1676        });
1677        let enc5 = serde_json::to_vec(&req5).expect("serialize");
1678        let dec5: IpcRequest = serde_json::from_slice(&enc5).expect("deserialize");
1679        assert!(matches!(dec5.command, IpcCommand::SnapshotList { .. }));
1680
1681        let req6 = IpcRequest::new(IpcCommand::SnapshotDelete {
1682            id: "baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
1683        });
1684        let enc6 = serde_json::to_vec(&req6).expect("serialize");
1685        let dec6: IpcRequest = serde_json::from_slice(&enc6).expect("deserialize");
1686        assert!(matches!(dec6.command, IpcCommand::SnapshotDelete { .. }));
1687
1688        let req7 = IpcRequest::new(IpcCommand::CollectionPublish {
1689            path: PathBuf::from("."),
1690            namespace: "ns".to_owned(),
1691            sequence: 1,
1692            bootstrap: vec![],
1693        });
1694        let enc7 = serde_json::to_vec(&req7).expect("serialize");
1695        let dec7: IpcRequest = serde_json::from_slice(&enc7).expect("deserialize");
1696        assert!(matches!(dec7.command, IpcCommand::CollectionPublish { .. }));
1697    }
1698
1699    #[tokio::test]
1700    async fn test_ipc_unsubscribe_no_active_session() {
1701        let handle = DaemonHandle::new(state());
1702        let server = IpcServer::new(socket_path(), handle);
1703        let namespace = iroh_docs::NamespaceSecret::from_bytes(&[7; 32]).id().to_string();
1704        let response = server
1705            .handle_request(IpcRequest::new(IpcCommand::Unsubscribe {
1706                namespace: namespace.clone(),
1707            }))
1708            .await;
1709        assert!(matches!(
1710            response,
1711            IpcResponse::Error { message } if message.contains("no active session")
1712        ));
1713    }
1714
1715    #[tokio::test]
1716    async fn test_ipc_unsubscribe_invalid_namespace() {
1717        let handle = DaemonHandle::new(state());
1718        let server = IpcServer::new(socket_path(), handle);
1719        let response = server
1720            .handle_request(IpcRequest::new(IpcCommand::Unsubscribe {
1721                namespace: "not-a-namespace".to_owned(),
1722            }))
1723            .await;
1724        assert!(matches!(
1725            response,
1726            IpcResponse::Error { message } if message.contains("invalid namespace")
1727        ));
1728    }
1729
1730    #[tokio::test]
1731    async fn test_ipc_leave_folder_no_context() {
1732        let handle = DaemonHandle::new(state());
1733        let server = IpcServer::new(socket_path(), handle);
1734        let response = server
1735            .handle_request(IpcRequest::new(IpcCommand::LeaveFolder {
1736                namespace: "ns".to_owned(),
1737            }))
1738            .await;
1739        assert!(matches!(
1740            response,
1741            IpcResponse::Error { message } if message.contains("no node context")
1742        ));
1743    }
1744
1745    #[tokio::test]
1746    async fn test_ipc_unpublish_no_context() {
1747        let handle = DaemonHandle::new(state());
1748        let server = IpcServer::new(socket_path(), handle);
1749        let response = server
1750            .handle_request(IpcRequest::new(IpcCommand::Unpublish {
1751                namespace: "ns".to_owned(),
1752                blob: "baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
1753            }))
1754            .await;
1755        assert!(matches!(
1756            response,
1757            IpcResponse::Error { message } if message.contains("no node context")
1758        ));
1759    }
1760
1761    #[tokio::test]
1762    async fn test_ipc_snapshot_create_no_context() {
1763        let handle = DaemonHandle::new(state());
1764        let server = IpcServer::new(socket_path(), handle);
1765        let response = server
1766            .handle_request(IpcRequest::new(IpcCommand::SnapshotCreate {
1767                path: PathBuf::from("."),
1768                description: None,
1769                threads: 0,
1770            }))
1771            .await;
1772        assert!(matches!(
1773            response,
1774            IpcResponse::Error { message } if message.contains("no node context")
1775        ));
1776    }
1777
1778    #[tokio::test]
1779    async fn test_ipc_snapshot_list_no_context() {
1780        let handle = DaemonHandle::new(state());
1781        let server = IpcServer::new(socket_path(), handle);
1782        let response = server
1783            .handle_request(IpcRequest::new(IpcCommand::SnapshotList {
1784                path: PathBuf::from("."),
1785            }))
1786            .await;
1787        assert!(matches!(
1788            response,
1789            IpcResponse::Error { message } if message.contains("no node context")
1790        ));
1791    }
1792
1793    #[tokio::test]
1794    async fn test_ipc_snapshot_delete_no_context() {
1795        let handle = DaemonHandle::new(state());
1796        let server = IpcServer::new(socket_path(), handle);
1797        let response = server
1798            .handle_request(IpcRequest::new(IpcCommand::SnapshotDelete {
1799                id: "baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
1800            }))
1801            .await;
1802        assert!(matches!(
1803            response,
1804            IpcResponse::Error { message } if message.contains("no node context")
1805        ));
1806    }
1807
1808    #[tokio::test]
1809    async fn test_ipc_collection_publish_no_context() {
1810        let handle = DaemonHandle::new(state());
1811        let server = IpcServer::new(socket_path(), handle);
1812        let response = server
1813            .handle_request(IpcRequest::new(IpcCommand::CollectionPublish {
1814                path: PathBuf::from("."),
1815                namespace: "ns".to_owned(),
1816                sequence: 1,
1817                bootstrap: vec![],
1818            }))
1819            .await;
1820        assert!(matches!(
1821            response,
1822            IpcResponse::Error { message } if message.contains("no node context")
1823        ));
1824    }
1825
1826    #[tokio::test]
1827    async fn test_ipc_create_folder_no_context() {
1828        let handle = DaemonHandle::new(state());
1829        let server = IpcServer::new(socket_path(), handle);
1830        let response = server
1831            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
1832                path: PathBuf::from("."),
1833                mode: "sendreceive".to_owned(),
1834            }))
1835            .await;
1836        assert!(matches!(
1837            response,
1838            IpcResponse::Error { message } if message.contains("no node context")
1839        ));
1840    }
1841
1842    #[tokio::test]
1843    async fn test_ipc_create_folder_no_context_with_invalid_mode() {
1844        let handle = DaemonHandle::new(state());
1845        let server = IpcServer::new(socket_path(), handle);
1846        let response = server
1847            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
1848                path: PathBuf::from("/tmp/test-create-folder"),
1849                mode: "invalid".to_owned(),
1850            }))
1851            .await;
1852        assert!(matches!(
1853            response,
1854            IpcResponse::Error { message } if message.contains("no node context")
1855        ));
1856    }
1857
1858    #[tokio::test]
1859    async fn test_ipc_health_check_no_context() {
1860        let handle = DaemonHandle::new(state());
1861        let server = IpcServer::new(socket_path(), handle);
1862        let response = server
1863            .handle_request(IpcRequest::new(IpcCommand::HealthCheck {
1864                path: PathBuf::from("."),
1865            }))
1866            .await;
1867        assert!(matches!(
1868            response,
1869            IpcResponse::Error { message } if message.contains("no node context")
1870        ));
1871    }
1872
1873    #[tokio::test]
1874    async fn test_ipc_verify_integrity_no_context() {
1875        let handle = DaemonHandle::new(state());
1876        let server = IpcServer::new(socket_path(), handle);
1877        let response = server
1878            .handle_request(IpcRequest::new(IpcCommand::VerifyIntegrity {
1879                path: PathBuf::from("."),
1880            }))
1881            .await;
1882        assert!(matches!(
1883            response,
1884            IpcResponse::Error { message } if message.contains("no node context")
1885        ));
1886    }
1887
1888    #[tokio::test]
1889    async fn test_ipc_join_no_context() {
1890        let handle = DaemonHandle::new(state());
1891        let server = IpcServer::new(socket_path(), handle);
1892        let response = server
1893            .handle_request(IpcRequest::new(IpcCommand::Join {
1894                ticket: "ticket".to_owned(),
1895                path: PathBuf::from("/tmp"),
1896                mode: SyncMode::SendReceive,
1897            }))
1898            .await;
1899        assert!(matches!(
1900            response,
1901            IpcResponse::Error { message } if message.contains("no node context")
1902        ));
1903    }
1904
1905    #[tokio::test]
1906    async fn test_ipc_publish_no_context() {
1907        let handle = DaemonHandle::new(state());
1908        let server = IpcServer::new(socket_path(), handle);
1909        let response = server
1910            .handle_request(IpcRequest::new(IpcCommand::Publish {
1911                namespace: "ns".to_owned(),
1912                blob: None,
1913            }))
1914            .await;
1915        assert!(matches!(
1916            response,
1917            IpcResponse::Error { message } if message.contains("no node context")
1918        ));
1919    }
1920
1921    #[tokio::test]
1922    async fn test_ipc_subscribe_no_context() {
1923        let handle = DaemonHandle::new(state());
1924        let server = IpcServer::new(socket_path(), handle);
1925        let response = server
1926            .handle_request(IpcRequest::new(IpcCommand::Subscribe {
1927                namespace: "ns".to_owned(),
1928                params: SubscribeParams::ingest_only(),
1929            }))
1930            .await;
1931        assert!(matches!(
1932            response,
1933            IpcResponse::Error { message } if message.contains("no node context")
1934        ));
1935    }
1936
1937    struct IpcTestFixture {
1938        server: IpcServer,
1939        node: Arc<IrohNode>,
1940        directory: PathBuf,
1941    }
1942
1943    async fn setup_ipc_test() -> IpcTestFixture {
1944        use crate::node::identity::IdentityManager;
1945        use crate::node::iroh_node::RelayMode;
1946
1947        let directory = std::env::temp_dir().join(format!("syncweb-ipc-test-{}", uuid::Uuid::new_v4()));
1948        std::fs::create_dir_all(&directory).expect("test directory should be created");
1949
1950        let identity = IdentityManager::new(directory.join("identity.key")).expect("test identity should open");
1951        let node = Arc::new(
1952            IrohNode::new(identity, directory.join("data"), RelayMode::Default)
1953                .await
1954                .expect("test node should start"),
1955        );
1956        let pool = Arc::new(ManagedPool::new("syncweb-test", 1).expect("test pool should start"));
1957
1958        let daemon_state = DaemonState::new(
1959            std::process::id(),
1960            node.endpoint().id().to_string(),
1961            1,
1962            &directory,
1963            DaemonStatus::Running,
1964        );
1965        let handle = DaemonHandle::new(daemon_state);
1966        let server = IpcServer::with_archive_context(socket_path(), handle, node.clone(), pool);
1967
1968        IpcTestFixture {
1969            server,
1970            node,
1971            directory,
1972        }
1973    }
1974
1975    async fn cleanup_ipc_test(fixture: IpcTestFixture) {
1976        let _ = fixture.node.stop().await;
1977        let _ = std::fs::remove_dir_all(&fixture.directory);
1978    }
1979
1980    #[tokio::test]
1981    async fn test_ipc_create_folder_creates_and_returns_message() {
1982        let fixture = setup_ipc_test().await;
1983        let test_dir = fixture.directory.join("create-folder-test");
1984        let response = fixture
1985            .server
1986            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
1987                path: test_dir.clone(),
1988                mode: "sendreceive".to_owned(),
1989            }))
1990            .await;
1991        assert!(matches!(response, IpcResponse::Ok { .. }));
1992        if let IpcResponse::Ok { message } = response {
1993            assert!(message.contains("namespace:"));
1994            assert!(message.contains("ticket:"));
1995        }
1996        cleanup_ipc_test(fixture).await;
1997        let _ = std::fs::remove_dir_all(&test_dir);
1998    }
1999
2000    #[tokio::test]
2001    async fn test_ipc_create_folder_invalid_mode() {
2002        let fixture = setup_ipc_test().await;
2003        let test_dir = fixture.directory.join("create-folder-invalid");
2004        let response = fixture
2005            .server
2006            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2007                path: test_dir.clone(),
2008                mode: "invalid-mode".to_owned(),
2009            }))
2010            .await;
2011        assert!(matches!(response, IpcResponse::Error { .. }));
2012        if let IpcResponse::Error { message } = response {
2013            assert!(message.contains("invalid sync mode"));
2014        }
2015        cleanup_ipc_test(fixture).await;
2016    }
2017
2018    #[tokio::test]
2019    async fn test_ipc_create_folder_duplicate_namespace() {
2020        let fixture = setup_ipc_test().await;
2021        let test_dir1 = fixture.directory.join("create-folder-dup-1");
2022        let test_dir2 = fixture.directory.join("create-folder-dup-2");
2023
2024        let response1 = fixture
2025            .server
2026            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2027                path: test_dir1.clone(),
2028                mode: "sendreceive".to_owned(),
2029            }))
2030            .await;
2031        assert!(matches!(response1, IpcResponse::Ok { .. }));
2032        let namespace = if let IpcResponse::Ok { message } = &response1 {
2033            message
2034                .lines()
2035                .find(|line| line.starts_with("namespace:"))
2036                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2037        } else {
2038            None
2039        };
2040
2041        if let Some(ref ns) = namespace {
2042            let response2 = fixture
2043                .server
2044                .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2045                    path: test_dir2.clone(),
2046                    mode: "sendreceive".to_owned(),
2047                }))
2048                .await;
2049            assert!(matches!(response2, IpcResponse::Ok { .. }));
2050            let ns2 = if let IpcResponse::Ok { message } = &response2 {
2051                message
2052                    .lines()
2053                    .find(|line| line.starts_with("namespace:"))
2054                    .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2055            } else {
2056                None
2057            };
2058            assert_ne!(Some(ns), ns2.as_ref(), "each create should produce a unique namespace");
2059        }
2060
2061        cleanup_ipc_test(fixture).await;
2062        let _ = std::fs::remove_dir_all(&test_dir1);
2063        let _ = std::fs::remove_dir_all(&test_dir2);
2064    }
2065
2066    #[tokio::test]
2067    async fn test_ipc_health_check_returns_report() {
2068        let fixture = setup_ipc_test().await;
2069        let test_dir = fixture.directory.join("health-test");
2070        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2071
2072        let response1 = fixture
2073            .server
2074            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2075                path: test_dir.clone(),
2076                mode: "sendreceive".to_owned(),
2077            }))
2078            .await;
2079        let namespace = if let IpcResponse::Ok { message } = &response1 {
2080            message
2081                .lines()
2082                .find(|line| line.starts_with("namespace:"))
2083                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2084        } else {
2085            None
2086        };
2087
2088        if let Some(ns) = namespace {
2089            let response2 = fixture
2090                .server
2091                .handle_request(IpcRequest::new(IpcCommand::HealthCheck {
2092                    path: PathBuf::from(&ns),
2093                }))
2094                .await;
2095            assert!(matches!(response2, IpcResponse::Ok { .. }));
2096            if let IpcResponse::Ok { message } = response2 {
2097                assert!(message.contains("total:"));
2098                assert!(message.contains("well-seeded:"));
2099            }
2100        }
2101
2102        cleanup_ipc_test(fixture).await;
2103        let _ = std::fs::remove_dir_all(&test_dir);
2104    }
2105
2106    #[tokio::test]
2107    async fn test_ipc_health_check_unknown_folder() {
2108        let fixture = setup_ipc_test().await;
2109        let response = fixture
2110            .server
2111            .handle_request(IpcRequest::new(IpcCommand::HealthCheck {
2112                path: PathBuf::from("/nonexistent/path/that/does/not/exist"),
2113            }))
2114            .await;
2115        assert!(matches!(response, IpcResponse::Error { .. }));
2116        cleanup_ipc_test(fixture).await;
2117    }
2118
2119    #[tokio::test]
2120    async fn test_ipc_verify_integrity_returns_result() {
2121        let fixture = setup_ipc_test().await;
2122        let test_dir = fixture.directory.join("verify-test");
2123        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2124
2125        let response1 = fixture
2126            .server
2127            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2128                path: test_dir.clone(),
2129                mode: "sendreceive".to_owned(),
2130            }))
2131            .await;
2132        let namespace = if let IpcResponse::Ok { message } = &response1 {
2133            message
2134                .lines()
2135                .find(|line| line.starts_with("namespace:"))
2136                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2137        } else {
2138            None
2139        };
2140
2141        if let Some(ns) = namespace {
2142            let response2 = fixture
2143                .server
2144                .handle_request(IpcRequest::new(IpcCommand::VerifyIntegrity {
2145                    path: PathBuf::from(&ns),
2146                }))
2147                .await;
2148            assert!(matches!(response2, IpcResponse::Ok { .. }));
2149            if let IpcResponse::Ok { message } = response2 {
2150                assert!(message.contains("total:"));
2151                assert!(message.contains("verified:"));
2152                assert!(message.contains("corrupted:"));
2153                assert!(message.contains("missing:"));
2154                assert!(message.contains("valid:"));
2155            }
2156        }
2157
2158        cleanup_ipc_test(fixture).await;
2159        let _ = std::fs::remove_dir_all(&test_dir);
2160    }
2161
2162    #[tokio::test]
2163    async fn test_ipc_verify_integrity_unknown_folder() {
2164        let fixture = setup_ipc_test().await;
2165        let response = fixture
2166            .server
2167            .handle_request(IpcRequest::new(IpcCommand::VerifyIntegrity {
2168                path: PathBuf::from("/nonexistent/path/that/does/not/exist"),
2169            }))
2170            .await;
2171        assert!(matches!(response, IpcResponse::Error { .. }));
2172        cleanup_ipc_test(fixture).await;
2173    }
2174
2175    #[tokio::test]
2176    async fn test_ipc_join_folder_invalid_ticket() {
2177        let fixture = setup_ipc_test().await;
2178        let test_dir = fixture.directory.join("join-invalid");
2179        let response = fixture
2180            .server
2181            .handle_request(IpcRequest::new(IpcCommand::Join {
2182                ticket: "not-a-valid-ticket".to_owned(),
2183                path: test_dir.clone(),
2184                mode: SyncMode::SendReceive,
2185            }))
2186            .await;
2187        assert!(matches!(response, IpcResponse::Error { .. }));
2188        cleanup_ipc_test(fixture).await;
2189    }
2190
2191    #[tokio::test]
2192    async fn test_ipc_publish_folder_ticket() {
2193        let fixture = setup_ipc_test().await;
2194        let test_dir = fixture.directory.join("publish-test");
2195        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2196
2197        let response1 = fixture
2198            .server
2199            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2200                path: test_dir.clone(),
2201                mode: "sendreceive".to_owned(),
2202            }))
2203            .await;
2204        let namespace = if let IpcResponse::Ok { message } = &response1 {
2205            message
2206                .lines()
2207                .find(|line| line.starts_with("namespace:"))
2208                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2209        } else {
2210            None
2211        };
2212
2213        if let Some(ns) = namespace {
2214            let response2 = fixture
2215                .server
2216                .handle_request(IpcRequest::new(IpcCommand::Publish {
2217                    namespace: ns.clone(),
2218                    blob: None,
2219                }))
2220                .await;
2221            assert!(matches!(response2, IpcResponse::Ok { .. }));
2222            if let IpcResponse::Ok { message } = response2 {
2223                assert!(message.contains("ticket:"));
2224            }
2225        }
2226
2227        cleanup_ipc_test(fixture).await;
2228        let _ = std::fs::remove_dir_all(&test_dir);
2229    }
2230
2231    #[tokio::test]
2232    async fn test_ipc_publish_invalid_namespace() {
2233        let fixture = setup_ipc_test().await;
2234        let response = fixture
2235            .server
2236            .handle_request(IpcRequest::new(IpcCommand::Publish {
2237                namespace: "not-a-namespace".to_owned(),
2238                blob: None,
2239            }))
2240            .await;
2241        assert!(matches!(response, IpcResponse::Error { .. }));
2242        if let IpcResponse::Error { message } = response {
2243            assert!(message.contains("invalid namespace"));
2244        }
2245        cleanup_ipc_test(fixture).await;
2246    }
2247
2248    #[tokio::test]
2249    async fn test_ipc_subscribe_returns_ok() {
2250        let fixture = setup_ipc_test().await;
2251        let test_dir = fixture.directory.join("subscribe-test");
2252        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2253
2254        let response1 = fixture
2255            .server
2256            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2257                path: test_dir.clone(),
2258                mode: "sendreceive".to_owned(),
2259            }))
2260            .await;
2261        let namespace = if let IpcResponse::Ok { message } = &response1 {
2262            message
2263                .lines()
2264                .find(|line| line.starts_with("namespace:"))
2265                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2266        } else {
2267            None
2268        };
2269
2270        if let Some(ns) = namespace {
2271            let response = fixture
2272                .server
2273                .handle_request(IpcRequest::new(IpcCommand::Subscribe {
2274                    namespace: ns.clone(),
2275                    params: SubscribeParams::ingest_only(),
2276                }))
2277                .await;
2278            assert!(matches!(response, IpcResponse::Ok { .. }));
2279            if let IpcResponse::Ok { message } = response {
2280                assert!(message.contains("subscribed:"));
2281            }
2282        }
2283
2284        cleanup_ipc_test(fixture).await;
2285        let _ = std::fs::remove_dir_all(&test_dir);
2286    }
2287
2288    #[tokio::test]
2289    async fn test_ipc_subscribe_with_params() {
2290        let fixture = setup_ipc_test().await;
2291        let test_dir = fixture.directory.join("subscribe-params-test");
2292        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2293
2294        let response1 = fixture
2295            .server
2296            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2297                path: test_dir.clone(),
2298                mode: "sendreceive".to_owned(),
2299            }))
2300            .await;
2301        let namespace = if let IpcResponse::Ok { message } = &response1 {
2302            message
2303                .lines()
2304                .find(|line| line.starts_with("namespace:"))
2305                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2306        } else {
2307            None
2308        };
2309
2310        if let Some(ns) = namespace {
2311            let params = SubscribeParams {
2312                ingest_only: true,
2313                ..SubscribeParams::default()
2314            };
2315            let response2 = fixture
2316                .server
2317                .handle_request(IpcRequest::new(IpcCommand::Subscribe {
2318                    namespace: ns.clone(),
2319                    params,
2320                }))
2321                .await;
2322            assert!(matches!(response2, IpcResponse::Ok { .. }));
2323        }
2324
2325        cleanup_ipc_test(fixture).await;
2326        let _ = std::fs::remove_dir_all(&test_dir);
2327    }
2328
2329    #[tokio::test]
2330    async fn test_ipc_leave_folder_removes_from_registry() {
2331        let fixture = setup_ipc_test().await;
2332        let test_dir = fixture.directory.join("leave-test");
2333        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2334
2335        let response = fixture
2336            .server
2337            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2338                path: test_dir.clone(),
2339                mode: "sendreceive".to_owned(),
2340            }))
2341            .await;
2342        let namespace = if let IpcResponse::Ok { message } = &response {
2343            message
2344                .lines()
2345                .find(|line| line.starts_with("namespace:"))
2346                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2347        } else {
2348            None
2349        };
2350
2351        if let Some(ref ns) = namespace {
2352            let statuses1 = fixture.server.daemon_handle.folder_registry.read().await.statuses();
2353            assert!(statuses1.iter().any(|s| s.namespace == *ns));
2354
2355            let response2 = fixture
2356                .server
2357                .handle_request(IpcRequest::new(IpcCommand::LeaveFolder { namespace: ns.clone() }))
2358                .await;
2359            assert!(matches!(response2, IpcResponse::Ok { .. }));
2360
2361            let statuses2 = fixture.server.daemon_handle.folder_registry.read().await.statuses();
2362            assert!(!statuses2.iter().any(|s| s.namespace == *ns));
2363        }
2364
2365        cleanup_ipc_test(fixture).await;
2366        let _ = std::fs::remove_dir_all(&test_dir);
2367    }
2368
2369    #[tokio::test]
2370    async fn test_ipc_leave_folder_nonexistent() {
2371        let fixture = setup_ipc_test().await;
2372        let fake_ns = iroh_docs::NamespaceSecret::from_bytes(&[99; 32]).id().to_string();
2373        let _response = fixture
2374            .server
2375            .handle_request(IpcRequest::new(IpcCommand::LeaveFolder {
2376                namespace: fake_ns.clone(),
2377            }))
2378            .await;
2379        let registry = fixture.server.daemon_handle.folder_registry.read().await;
2380        let statuses = registry.statuses();
2381        assert!(!statuses.iter().any(|s| s.namespace == fake_ns));
2382        drop(registry);
2383        cleanup_ipc_test(fixture).await;
2384    }
2385
2386    #[tokio::test]
2387    async fn test_ipc_unpublish_invalid_hash() {
2388        let fixture = setup_ipc_test().await;
2389        let fake_ns = iroh_docs::NamespaceSecret::from_bytes(&[88; 32]).id().to_string();
2390        let response = fixture
2391            .server
2392            .handle_request(IpcRequest::new(IpcCommand::Unpublish {
2393                namespace: fake_ns,
2394                blob: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".to_owned(),
2395            }))
2396            .await;
2397        assert!(matches!(response, IpcResponse::Error { .. }));
2398        if let IpcResponse::Error { message } = response {
2399            assert!(message.contains("invalid blob hash"));
2400        }
2401        cleanup_ipc_test(fixture).await;
2402    }
2403
2404    #[tokio::test]
2405    async fn test_ipc_snapshot_list_empty() {
2406        let fixture = setup_ipc_test().await;
2407        let response = fixture
2408            .server
2409            .handle_request(IpcRequest::new(IpcCommand::SnapshotList {
2410                path: PathBuf::from("."),
2411            }))
2412            .await;
2413        assert!(matches!(response, IpcResponse::Ok { .. }));
2414        if let IpcResponse::Ok { message } = response {
2415            assert!(message.contains("snapshots:"));
2416        }
2417        cleanup_ipc_test(fixture).await;
2418    }
2419
2420    #[tokio::test]
2421    async fn test_ipc_snapshot_delete_invalid_id() {
2422        let fixture = setup_ipc_test().await;
2423        let response = fixture
2424            .server
2425            .handle_request(IpcRequest::new(IpcCommand::SnapshotDelete {
2426                id: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".to_owned(),
2427            }))
2428            .await;
2429        assert!(matches!(response, IpcResponse::Error { .. }));
2430        if let IpcResponse::Error { message } = response {
2431            assert!(message.contains("invalid snapshot id"));
2432        }
2433        cleanup_ipc_test(fixture).await;
2434    }
2435}