Skip to main content

syncweb_core/daemon/
ipc.rs

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