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