Skip to main content

syncweb_core/daemon/
ipc.rs

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