Skip to main content

syncweb_core/daemon/
ipc.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fmt::Write,
4    path::{Path, PathBuf},
5    str::FromStr,
6    sync::{
7        Arc,
8        atomic::{AtomicBool, Ordering},
9    },
10};
11
12use n0_future::StreamExt;
13use serde::{Deserialize, Serialize};
14use tokio::sync::{RwLock, broadcast, mpsc};
15
16use crate::{
17    bandwidth_stats::{FileStatsCollector, FileStatsReport},
18    daemon::state::FolderStatusReport,
19    error::{Result, SyncwebError},
20    filter::{FilterAction, FilterConfig, FilterEngine, FilterEntry},
21    folder::{
22        CollectionHead, CollectionManifest, CollectionStore, DropExportOptions, DropExportResult, DropExporter,
23        DropImportOptions, DropImportResult, DropImporter, FolderLike, FolderManager, PublicSubscription, ShareOptions,
24        SyncMode, share_folder,
25    },
26    fs::{Importer, Scanner},
27    indexing::{IndexingService, Link, LinkStore},
28    node::iroh_node::IrohNode,
29    snapshot::SnapshotStore,
30    storage::config::SubscribeFilters,
31    storage::node_db::NodeDatabase,
32    sync::{
33        ActiveSession, AreaFilter, FetchFilter, FetchStrategy, SubscribeParams, SyncEngine, SyncEvent, cancel_session,
34    },
35    verify::IntegrityChecker,
36};
37
38use super::{
39    ManagedPool,
40    state::{DaemonStatus, daemon_socket_path, load_filter_engine},
41};
42
43use std::time::Duration;
44
45const IPC_TIMEOUT: Duration = Duration::from_secs(2);
46
47const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(1);
48
49/// Transport used by an IPC client to exchange newline-delimited messages.
50#[async_trait::async_trait]
51pub trait IpcTransport: Send + Sync {
52    /// Serialize and send one request.
53    async fn send_request(&mut self, request: &IpcRequest) -> Result<()>;
54
55    /// Receive and deserialize one response.
56    async fn recv_response(&mut self) -> Result<IpcResponse>;
57}
58
59fn encode_ipc_request(request: &IpcRequest) -> Result<Vec<u8>> {
60    let mut message = serde_json::to_vec(request)
61        .map_err(|error| SyncwebError::operation("failed to serialize IPC request", error))?;
62    message.push(b'\n');
63    Ok(message)
64}
65
66fn encode_ipc_response(response: &IpcResponse) -> Result<Vec<u8>> {
67    let mut message = serde_json::to_vec(response)
68        .map_err(|error| SyncwebError::operation("failed to serialize IPC response", error))?;
69    message.push(b'\n');
70    Ok(message)
71}
72
73fn decode_ipc_response(bytes: &[u8]) -> Result<IpcResponse> {
74    if bytes.is_empty() {
75        return Err(SyncwebError::operation(
76            "daemon IPC returned no response",
77            "connection closed",
78        ));
79    }
80    serde_json::from_slice(bytes.trim_ascii())
81        .map_err(|error| SyncwebError::operation("failed to deserialize IPC response", error))
82}
83
84#[cfg(unix)]
85#[derive(Debug)]
86pub struct UnixSocketTransport {
87    socket_path: PathBuf,
88    stream: Option<tokio::net::UnixStream>,
89}
90
91#[cfg(unix)]
92impl UnixSocketTransport {
93    #[must_use]
94    pub const fn new(socket_path: PathBuf) -> Self {
95        Self {
96            socket_path,
97            stream: None,
98        }
99    }
100}
101
102#[cfg(unix)]
103#[async_trait::async_trait]
104impl IpcTransport for UnixSocketTransport {
105    async fn send_request(&mut self, request: &IpcRequest) -> Result<()> {
106        use tokio::io::AsyncWriteExt;
107        use tokio::time::timeout;
108
109        let mut stream = timeout(IPC_TIMEOUT, tokio::net::UnixStream::connect(&self.socket_path))
110            .await
111            .map_err(|error| SyncwebError::operation("daemon IPC connection timed out", error))?
112            .map_err(|error| {
113                SyncwebError::operation(
114                    format!("failed to connect to daemon socket at {}", self.socket_path.display()),
115                    error,
116                )
117            })?;
118        let message = encode_ipc_request(request)?;
119        timeout(IPC_TIMEOUT, stream.write_all(&message))
120            .await
121            .map_err(|error| SyncwebError::operation("daemon IPC write timed out", error))?
122            .map_err(|error| SyncwebError::operation("daemon IPC write failed", error))?;
123        self.stream = Some(stream);
124        Ok(())
125    }
126
127    async fn recv_response(&mut self) -> Result<IpcResponse> {
128        use tokio::io::{AsyncBufReadExt, BufReader};
129        use tokio::time::timeout;
130
131        let stream = self
132            .stream
133            .take()
134            .ok_or_else(|| SyncwebError::operation("daemon IPC read failed", "request was not sent"))?;
135        let mut response = Vec::new();
136        timeout(IPC_TIMEOUT, BufReader::new(stream).read_until(b'\n', &mut response))
137            .await
138            .map_err(|error| SyncwebError::operation("daemon IPC read timed out", error))?
139            .map_err(|error| SyncwebError::operation("daemon IPC read failed", error))?;
140        decode_ipc_response(&response)
141    }
142}
143
144/// A request sent over the local daemon control channel.
145#[derive(Clone, Debug, Deserialize, Serialize)]
146#[non_exhaustive]
147pub struct IpcRequest {
148    pub command: IpcCommand,
149}
150
151impl IpcRequest {
152    #[must_use]
153    pub const fn new(command: IpcCommand) -> Self {
154        Self { command }
155    }
156}
157
158/// Commands supported by the daemon control channel.
159#[derive(Clone, Debug, Deserialize, Serialize)]
160#[serde(tag = "command", rename_all = "snake_case")]
161#[non_exhaustive]
162pub enum IpcCommand {
163    Status,
164    ListFolders,
165    AddFolder {
166        namespace: String,
167        path: PathBuf,
168    },
169    WatchStart {
170        namespace: String,
171        path: PathBuf,
172        #[serde(default)]
173        exclude: Vec<String>,
174        #[serde(default)]
175        filter: Option<FilterConfig>,
176    },
177    TriggerSync {
178        namespace: Option<String>,
179    },
180    SetLogLevel {
181        level: String,
182    },
183    ReloadConfig,
184    Shutdown {
185        force: bool,
186    },
187    Download {
188        namespace: String,
189        strategy: FetchStrategy,
190    },
191    MaterializeTransfers {
192        namespace: Option<String>,
193    },
194    ImportFiles {
195        namespace: Option<String>,
196        path: PathBuf,
197    },
198    WatchOnce {
199        namespace: Option<String>,
200        path: PathBuf,
201        #[serde(default)]
202        filter: Option<FilterConfig>,
203        #[serde(default)]
204        exclude: Vec<String>,
205    },
206    FetchBlob {
207        hash: String,
208        tickets: Vec<String>,
209        #[serde(default)]
210        export_path: Option<PathBuf>,
211    },
212    LinkPublish {
213        namespace: String,
214        link: String,
215    },
216    ImportArchive {
217        input: PathBuf,
218        target: PathBuf,
219        filter: Option<FilterConfig>,
220    },
221    ExportArchive {
222        namespace: String,
223        version: Option<String>,
224        output: PathBuf,
225    },
226    ExportCollectionArchive {
227        collection: String,
228        version: Option<String>,
229        output: PathBuf,
230        #[serde(default)]
231        filter: Option<FilterConfig>,
232    },
233    Join {
234        ticket: String,
235        path: PathBuf,
236        mode: SyncMode,
237        #[serde(default)]
238        subscribe: bool,
239        #[serde(default)]
240        filters: SubscribeFilters,
241        #[serde(default)]
242        download: bool,
243        #[serde(default)]
244        indexing: bool,
245        #[serde(default)]
246        metadata_only: bool,
247    },
248    SetSubscribe {
249        namespace: String,
250        enabled: bool,
251        #[serde(default)]
252        filters: Option<SubscribeFilters>,
253    },
254    SubscribePublic {
255        ticket: String,
256    },
257    CreateFolder {
258        path: PathBuf,
259        mode: String,
260        #[serde(default)]
261        indexing: bool,
262    },
263    StatsFiles {
264        folder: PathBuf,
265    },
266    ListEntries {
267        folder: String,
268        #[serde(default = "default_entry_enrich")]
269        enrich: bool,
270    },
271    VerifyIntegrity {
272        path: PathBuf,
273        #[serde(default)]
274        hash: Vec<String>,
275        #[serde(default)]
276        path_filter: Option<String>,
277        #[serde(default)]
278        glob_filter: Option<String>,
279        #[serde(default)]
280        fix: bool,
281        #[serde(default)]
282        from: Vec<String>,
283    },
284    Unsubscribe {
285        namespace: String,
286    },
287    LeaveFolder {
288        namespace: String,
289        #[serde(default)]
290        delete_files: bool,
291    },
292    Share {
293        namespace: String,
294        #[serde(default)]
295        blob: Option<String>,
296        writable: bool,
297        #[serde(default)]
298        pin: bool,
299        #[serde(default)]
300        persist: bool,
301        #[serde(default = "default_addr_info")]
302        addr_info: iroh_docs::api::protocol::AddrInfoOptions,
303    },
304    ShareList,
305    Unshare {
306        namespace: String,
307        #[serde(default)]
308        blob: Option<String>,
309        writable: bool,
310    },
311    SnapshotCreate {
312        path: PathBuf,
313        description: Option<String>,
314        threads: usize,
315    },
316    SnapshotList {
317        path: PathBuf,
318    },
319    SnapshotDelete {
320        id: String,
321    },
322    SnapshotDiff {
323        path: PathBuf,
324        first: String,
325        second: String,
326    },
327    SnapshotRestore {
328        path: PathBuf,
329        snapshot: String,
330    },
331    CollectionPublish {
332        path: PathBuf,
333        namespace: String,
334        sequence: u64,
335        bootstrap: Vec<String>,
336        #[serde(default)]
337        manifest_bytes: Option<Vec<u8>>,
338    },
339    EnrichSort {
340        path: PathBuf,
341    },
342    NetworkInvite {
343        network_id: String,
344        device: String,
345    },
346    NetworkKick {
347        network_id: String,
348        device: String,
349    },
350    NetworkLeave {
351        network_id: String,
352    },
353    NetworkCreate {
354        name: String,
355        label: String,
356        invite_only: bool,
357        doc_ticket: Option<String>,
358    },
359    NetworkJoin {
360        ticket: String,
361    },
362    PeerAvailability {
363        folder: String,
364    },
365    PackageInfo {
366        #[serde(default)]
367        ticket: Option<String>,
368        #[serde(default)]
369        hash: Option<String>,
370        #[serde(default)]
371        node_id: Option<String>,
372    },
373    PackageInstall {
374        ticket: String,
375    },
376    IndexingEnable {
377        namespace: String,
378    },
379    IndexingPublish {
380        namespace: String,
381        catalog: String,
382        #[serde(default)]
383        tags: Vec<String>,
384    },
385}
386
387/// A response returned by the daemon control channel.
388#[derive(Clone, Debug, Deserialize, Serialize)]
389#[serde(tag = "response", content = "data", rename_all = "snake_case")]
390#[non_exhaustive]
391pub enum IpcResponse {
392    Ok { message: String },
393    Status(DaemonStatus),
394    FolderList(Vec<FolderStatus>),
395    DownloadComplete { bytes_transferred: u64 },
396    TransferJobsProcessed { completed: u64, failed: u64 },
397    ImportFilesComplete { entries: u64 },
398    WatchOnceComplete { imported: u64, rejected: Vec<String> },
399    FetchBlobComplete { hash: String },
400    ImportComplete(Box<DropImportResult>),
401    ExportComplete(Box<DropExportResult>),
402    EnrichData(HashMap<String, usize>),
403    FileStats(Box<FileStatsReport>),
404    Entries(Vec<EntryRow>),
405    PeerAvailability(Box<PeerAvailabilityReport>),
406    SnapshotList(Vec<SnapshotInfo>),
407    SnapshotDiff(Box<SnapshotDiffReport>),
408    PackageInfo(Box<PackageInfoResult>),
409    PackageInstall { collection_id: String, version: String },
410    NetworkCreated { name: String, id: String },
411    Error { message: String },
412}
413
414/// Metadata about a package manifest returned by the daemon for `package info`.
415#[derive(Clone, Debug, Deserialize, Serialize)]
416#[non_exhaustive]
417pub struct PackageInfoResult {
418    pub collection_id: String,
419    pub name: Option<String>,
420    pub version: String,
421    pub parent: Option<String>,
422    pub entries: usize,
423}
424
425impl PackageInfoResult {
426    #[must_use]
427    pub const fn new(
428        collection_id: String,
429        name: Option<String>,
430        version: String,
431        parent: Option<String>,
432        entries: usize,
433    ) -> Self {
434        Self {
435            collection_id,
436            name,
437            version,
438            parent,
439            entries,
440        }
441    }
442}
443
444/// Path-level changes between two snapshots, as returned by the daemon.
445#[derive(Clone, Debug, Deserialize, Serialize)]
446#[non_exhaustive]
447pub struct SnapshotDiffReport {
448    pub added: Vec<String>,
449    pub removed: Vec<String>,
450    /// `(path, old_hash, new_hash)` for every modified entry.
451    pub modified: Vec<(String, String, String)>,
452}
453
454impl SnapshotDiffReport {
455    #[must_use]
456    pub const fn new(added: Vec<String>, removed: Vec<String>, modified: Vec<(String, String, String)>) -> Self {
457        Self {
458            added,
459            removed,
460            modified,
461        }
462    }
463}
464
465/// One snapshot's summary row returned by the daemon for `snapshot list`.
466#[derive(Clone, Debug, Deserialize, Serialize)]
467#[non_exhaustive]
468pub struct SnapshotInfo {
469    pub id: String,
470    pub created_at: u64,
471    pub total_size: u64,
472    pub file_count: u64,
473    pub description: Option<String>,
474}
475
476impl SnapshotInfo {
477    #[must_use]
478    pub const fn new(
479        id: String,
480        created_at: u64,
481        total_size: u64,
482        file_count: u64,
483        description: Option<String>,
484    ) -> Self {
485        Self {
486            id,
487            created_at,
488            total_size,
489            file_count,
490            description,
491        }
492    }
493}
494
495/// One row of a metadata-only folder listing returned by the daemon.
496///
497/// `size`/`modified` are the doc metadata unless the blob is local, in which
498/// case they are overlaid with the on-disk `stat` of the materialized file.
499#[derive(Clone, Debug, Deserialize, Serialize)]
500#[non_exhaustive]
501pub struct EntryRow {
502    pub path: String,
503    pub hash: iroh_blobs::Hash,
504    pub size: u64,
505    pub local: bool,
506    pub modified: Option<u64>,
507}
508
509/// Serde default for `ListEntries.enrich`: enrichment stays on for callers
510/// that predate the `--no-enrich` flag.
511const fn default_entry_enrich() -> bool {
512    true
513}
514
515/// Serde default for `Share.addr_info`: callers that predate the field still
516/// get full relay + address info in the ticket.
517const fn default_addr_info() -> iroh_docs::api::protocol::AddrInfoOptions {
518    iroh_docs::api::protocol::AddrInfoOptions::RelayAndAddresses
519}
520
521/// A read-only snapshot of which peers can serve a folder's blobs and which
522/// peers joined the folder.
523#[derive(Clone, Debug, Default, Deserialize, Serialize)]
524#[non_exhaustive]
525pub struct PeerAvailabilityReport {
526    /// The folder's namespace, as resolved by the daemon.
527    pub folder: String,
528    /// Inbound peers (who joined) for the folder. Syncthing device IDs only.
529    #[serde(default)]
530    pub peers: Vec<PeerInfo>,
531    /// Per-blob availability: each blob's path/hash plus the peers observed
532    /// serving it.
533    #[serde(default)]
534    pub per_blob: Vec<BlobPeerAvailability>,
535}
536
537/// One inbound peer that joined a folder.
538#[derive(Clone, Debug, Deserialize, Serialize)]
539#[non_exhaustive]
540pub struct PeerInfo {
541    /// The peer's Syncthing device ID.
542    pub device_id: String,
543    /// Display name, empty-typed until inbound-device acceptance adds names.
544    #[serde(default)]
545    pub name: Option<String>,
546    /// Connection state ("online"/"offline"/"member") when known.
547    #[serde(default)]
548    pub connection: Option<String>,
549}
550
551/// One blob's peer availability within a folder.
552#[derive(Clone, Debug, Deserialize, Serialize)]
553#[non_exhaustive]
554pub struct BlobPeerAvailability {
555    /// Folder-relative path of the blob's entry.
556    pub path: String,
557    /// Content hash of the blob.
558    pub hash: String,
559    /// Number of peers observed serving the blob (point-in-time snapshot).
560    #[serde(default)]
561    pub peer_count: usize,
562    /// Device IDs of the peers serving this blob.
563    #[serde(default)]
564    pub peers: Vec<String>,
565    /// Percentage of the folder's inbound peers that serve the blob.
566    #[serde(default)]
567    pub pct_seeded: f64,
568}
569
570/// A managed folder summary returned by the daemon.
571pub use crate::daemon::state::FolderStatusReport as FolderStatus;
572
573/// Per-folder watch configuration owned by the daemon.
574///
575/// `exclude` holds glob patterns skipped during filesystem import. `filter`
576/// is an optional per-folder override for the daemon's canonical
577/// `filters.toml` engine; when `None` the daemon-wide engine applies.
578#[derive(Clone, Debug, Default, Deserialize, Serialize)]
579#[non_exhaustive]
580pub struct WatchConfig {
581    #[serde(default)]
582    pub exclude: Vec<String>,
583    #[serde(default)]
584    pub filter: Option<FilterConfig>,
585}
586
587impl WatchConfig {
588    #[must_use]
589    pub const fn new(exclude: Vec<String>, filter: Option<FilterConfig>) -> Self {
590        Self { exclude, filter }
591    }
592
593    /// Compile the per-folder filter override, if any.
594    ///
595    /// # Errors
596    ///
597    /// Returns an error when the filter configuration is invalid.
598    pub fn compiled_filter(&self) -> Result<Option<FilterEngine>> {
599        self.filter.clone().map(FilterEngine::new).transpose()
600    }
601}
602
603/// A folder managed by the daemon.
604#[non_exhaustive]
605pub struct FolderEntry {
606    pub namespace: iroh_docs::NamespaceId,
607    pub path: PathBuf,
608    pub mode: String,
609    pub session: Option<ActiveSession>,
610    pub last_sync_at: Option<u64>,
611    pub sync_count: u64,
612    pub entries_synced: u64,
613    pub errors: Vec<String>,
614    pub watch: WatchConfig,
615}
616
617impl FolderEntry {
618    #[must_use]
619    pub const fn new(namespace: iroh_docs::NamespaceId, path: PathBuf) -> Self {
620        Self {
621            namespace,
622            path,
623            mode: String::new(),
624            session: None,
625            last_sync_at: None,
626            sync_count: 0,
627            entries_synced: 0,
628            errors: Vec::new(),
629            watch: WatchConfig {
630                exclude: Vec::new(),
631                filter: None,
632            },
633        }
634    }
635
636    /// Attach the folder's watch configuration.
637    #[must_use]
638    pub fn with_watch(mut self, watch: WatchConfig) -> Self {
639        self.watch = watch;
640        self
641    }
642
643    /// Attach the folder's sync mode (empty when unknown).
644    #[must_use]
645    pub fn with_mode(mut self, mode: impl Into<String>) -> Self {
646        self.mode = mode.into();
647        self
648    }
649
650    #[must_use]
651    pub fn status(&self) -> FolderStatusReport {
652        FolderStatusReport {
653            namespace: self.namespace.to_string(),
654            path: self.path.clone(),
655            kind: "folder".to_owned(),
656            mode: self.mode.clone(),
657            session_active: self.session.is_some() || crate::sync::is_active(self.namespace),
658            last_sync_at: self.last_sync_at,
659            sync_count: self.sync_count,
660            entries_synced: self.entries_synced,
661            errors: self.errors.clone(),
662        }
663    }
664}
665
666/// Registry of folders and subscriptions currently managed by the daemon.
667#[derive(Default)]
668pub struct FolderRegistry {
669    folders: HashMap<String, FolderEntry>,
670    subscriptions: HashMap<String, PublicSubscription>,
671    removed: HashSet<String>,
672}
673
674impl FolderRegistry {
675    #[must_use]
676    pub fn new() -> Self {
677        Self::default()
678    }
679
680    /// Add a folder to the registry.
681    ///
682    /// # Errors
683    ///
684    /// Returns an error when the namespace is already registered.
685    pub fn add(&mut self, entry: FolderEntry) -> Result<()> {
686        let key = entry.namespace.to_string();
687        if self.folders.contains_key(&key) {
688            return Err(SyncwebError::FolderAlreadyManaged);
689        }
690        self.removed.remove(&key);
691        self.folders.insert(key, entry);
692        Ok(())
693    }
694
695    /// Add a folder, or attach a path to a folder restored without one.
696    ///
697    /// # Errors
698    ///
699    /// Returns an error when the namespace is already managed with a path or
700    /// when the requested update conflicts with an existing registration.
701    pub fn add_or_update(&mut self, entry: FolderEntry) -> Result<()> {
702        let key = entry.namespace.to_string();
703        if let Some(existing) = self.folders.get_mut(&key) {
704            if existing.path.as_os_str().is_empty() && !entry.path.as_os_str().is_empty() {
705                existing.path = entry.path;
706                self.removed.remove(&key);
707                return Ok(());
708            }
709            return Err(SyncwebError::FolderAlreadyManaged);
710        }
711        self.removed.remove(&key);
712        self.folders.insert(key, entry);
713        Ok(())
714    }
715
716    /// Upsert a folder's path and watch configuration, preserving any existing
717    /// sync mode. Unlike [`FolderRegistry::add_or_update`], this never errors
718    /// when the folder is already managed.
719    pub fn add_or_update_watch(&mut self, entry: FolderEntry) {
720        let key = entry.namespace.to_string();
721        self.removed.remove(&key);
722        if let Some(existing) = self.folders.get_mut(&key) {
723            if !entry.path.as_os_str().is_empty() {
724                existing.path = entry.path;
725            }
726            if existing.mode.is_empty() && !entry.mode.is_empty() {
727                existing.mode = entry.mode;
728            }
729            existing.watch = entry.watch;
730            return;
731        }
732        self.folders.insert(key, entry);
733    }
734
735    /// Return the watch configuration registered for a folder namespace.
736    #[must_use]
737    pub fn watch_for(&self, namespace: &str) -> WatchConfig {
738        self.folders
739            .get(namespace)
740            .map_or_else(WatchConfig::default, |entry| entry.watch.clone())
741    }
742
743    pub fn remove(&mut self, namespace: &iroh_docs::NamespaceId) -> Option<FolderEntry> {
744        let key = namespace.to_string();
745        self.removed.insert(key.clone());
746        self.folders.remove(&key)
747    }
748
749    #[must_use]
750    pub fn is_removed(&self, namespace: &str) -> bool {
751        self.removed.contains(namespace)
752    }
753
754    /// Add a subscription to the registry.
755    pub fn add_subscription(&mut self, subscription: PublicSubscription) {
756        let key = subscription.namespace_id();
757        self.subscriptions.insert(key, subscription);
758    }
759
760    /// Remove a subscription by its namespace ID (e.g. `"blob:<hash>"`).
761    pub fn remove_subscription(&mut self, namespace_id: &str) -> Option<PublicSubscription> {
762        self.subscriptions.remove(namespace_id)
763    }
764
765    #[must_use]
766    pub fn subscription_statuses(&self) -> Vec<FolderStatusReport> {
767        self.subscriptions
768            .values()
769            .map(|sub| FolderStatusReport {
770                namespace: sub.namespace_id(),
771                path: PathBuf::new(),
772                kind: "subscription".to_owned(),
773                mode: String::new(),
774                session_active: false,
775                last_sync_at: None,
776                sync_count: 0,
777                entries_synced: sub.size(),
778                errors: Vec::new(),
779            })
780            .collect()
781    }
782
783    #[must_use]
784    pub fn statuses(&self) -> Vec<FolderStatusReport> {
785        let mut statuses: Vec<_> = self.folders.values().map(FolderEntry::status).collect();
786        statuses.extend(self.subscription_statuses());
787        statuses.sort_by(|left, right| left.namespace.cmp(&right.namespace));
788        statuses
789    }
790
791    #[must_use]
792    pub fn len(&self) -> usize {
793        self.folders.len().saturating_add(self.subscriptions.len())
794    }
795
796    #[must_use]
797    pub fn is_empty(&self) -> bool {
798        self.folders.is_empty() && self.subscriptions.is_empty()
799    }
800
801    /// Return the mount path registered for a folder namespace, if any.
802    #[must_use]
803    pub fn path_for(&self, namespace: &str) -> Option<PathBuf> {
804        self.folders.get(namespace).map(|entry| entry.path.clone())
805    }
806
807    /// Reverse lookup: the namespace registered for a mount path, if any.
808    #[must_use]
809    pub fn namespace_for_path(&self, selector: &Path) -> Option<String> {
810        self.folders
811            .iter()
812            .find(|(_, entry)| entry.path == *selector)
813            .map(|(namespace, _)| namespace.clone())
814    }
815
816    pub fn record_import(&mut self, namespace: iroh_docs::NamespaceId, entries: u64, timestamp: u64) {
817        if let Some(folder) = self.folders.get_mut(&namespace.to_string()) {
818            folder.entries_synced = folder.entries_synced.saturating_add(entries);
819            folder.sync_count = folder.sync_count.saturating_add(1);
820            folder.last_sync_at = Some(timestamp);
821            folder.errors.clear();
822        }
823    }
824
825    pub fn record_error(&mut self, namespace: iroh_docs::NamespaceId, error: impl Into<String>) {
826        if let Some(folder) = self.folders.get_mut(&namespace.to_string()) {
827            folder.errors.push(error.into());
828            if folder.errors.len() > 16 {
829                let remove_count = folder.errors.len().saturating_sub(16);
830                folder.errors.drain(..remove_count);
831            }
832        }
833    }
834}
835
836/// Shared daemon state used by the IPC server.
837#[derive(Clone)]
838#[non_exhaustive]
839pub struct DaemonHandle {
840    pub state: Arc<RwLock<super::state::DaemonState>>,
841    pub folder_registry: Arc<RwLock<FolderRegistry>>,
842    pub shutdown_sender: broadcast::Sender<()>,
843    pub sync_trigger: mpsc::UnboundedSender<Option<String>>,
844    pub reload_requested: Arc<AtomicBool>,
845}
846
847impl DaemonHandle {
848    /// Create a handle with fresh control channels.
849    #[must_use]
850    pub fn new(state: super::state::DaemonState) -> Self {
851        let (shutdown_sender, _) = broadcast::channel(16);
852        let (sync_trigger, _) = mpsc::unbounded_channel();
853        Self::with_channels(
854            Arc::new(RwLock::new(state)),
855            Arc::new(RwLock::new(FolderRegistry::new())),
856            shutdown_sender,
857            sync_trigger,
858        )
859    }
860
861    #[must_use]
862    pub fn with_channels(
863        state: Arc<RwLock<super::state::DaemonState>>,
864        folder_registry: Arc<RwLock<FolderRegistry>>,
865        shutdown_sender: broadcast::Sender<()>,
866        sync_trigger: mpsc::UnboundedSender<Option<String>>,
867    ) -> Self {
868        Self::with_channels_and_reload(
869            state,
870            folder_registry,
871            shutdown_sender,
872            sync_trigger,
873            Arc::new(AtomicBool::new(false)),
874        )
875    }
876
877    #[must_use]
878    pub const fn with_channels_and_reload(
879        state: Arc<RwLock<super::state::DaemonState>>,
880        folder_registry: Arc<RwLock<FolderRegistry>>,
881        shutdown_sender: broadcast::Sender<()>,
882        sync_trigger: mpsc::UnboundedSender<Option<String>>,
883        reload_requested: Arc<AtomicBool>,
884    ) -> Self {
885        Self {
886            state,
887            folder_registry,
888            shutdown_sender,
889            sync_trigger,
890            reload_requested,
891        }
892    }
893
894    /// Update the lifecycle status returned by future status requests.
895    pub async fn set_status(&self, status: DaemonStatus) {
896        self.state.write().await.status = status;
897    }
898}
899
900/// Socket path and binding helper for the daemon.
901#[derive(Clone, Debug)]
902pub struct IpcListener {
903    socket_path: PathBuf,
904}
905
906impl IpcListener {
907    #[must_use]
908    pub const fn new(socket_path: PathBuf) -> Self {
909        Self { socket_path }
910    }
911
912    #[must_use]
913    pub fn for_data_dir(data_dir: &Path) -> Self {
914        Self::new(daemon_socket_path(data_dir))
915    }
916
917    #[must_use]
918    pub fn socket_path(&self) -> &Path {
919        &self.socket_path
920    }
921
922    /// Bind the daemon socket with owner-only permissions on Unix.
923    ///
924    /// # Errors
925    ///
926    /// Returns an error when the parent directory or socket cannot be created.
927    #[cfg(unix)]
928    pub fn bind(&self) -> Result<tokio::net::UnixListener> {
929        if let Some(parent) = self.socket_path.parent() {
930            std::fs::create_dir_all(parent)?;
931        }
932        if self.socket_path.exists() {
933            match std::os::unix::net::UnixStream::connect(&self.socket_path) {
934                Ok(_) => {
935                    return Err(std::io::Error::new(
936                        std::io::ErrorKind::AddrInUse,
937                        "daemon IPC socket is already in use",
938                    )
939                    .into());
940                }
941                Err(_) => std::fs::remove_file(&self.socket_path)?,
942            }
943        }
944        let listener = tokio::net::UnixListener::bind(&self.socket_path).map_err(|error| {
945            SyncwebError::operation(
946                format!("failed to bind daemon socket at {}", self.socket_path.display()),
947                error,
948            )
949        })?;
950        set_owner_only_permissions(&self.socket_path)?;
951        Ok(listener)
952    }
953}
954
955/// A server for the daemon's local control channel.
956#[derive(Clone)]
957pub struct IpcServer {
958    #[cfg_attr(not(unix), allow(dead_code))]
959    listener: IpcListener,
960    daemon_handle: DaemonHandle,
961    archive_context: Option<Arc<ArchiveContext>>,
962    folder_manager: Option<FolderManager>,
963    node_db: Option<NodeDatabase>,
964    network_manager: Option<std::sync::Arc<tokio::sync::RwLock<crate::net::NetworkManager>>>,
965}
966
967#[derive(Clone)]
968struct ArchiveContext {
969    node: Arc<IrohNode>,
970    pool: Arc<ManagedPool>,
971    indexing: Option<IndexingService>,
972    data_dir: PathBuf,
973}
974
975/// Boolean toggles for the join workflow, bundled to keep the handler signature
976/// small and free of excessive boolean parameters.
977#[derive(Clone, Copy, Debug, Default)]
978#[non_exhaustive]
979pub struct JoinOptions {
980    pub subscribe: bool,
981    pub download: bool,
982    pub indexing: bool,
983    pub metadata_only: bool,
984}
985
986impl JoinOptions {
987    #[must_use]
988    pub const fn new() -> Self {
989        Self {
990            subscribe: false,
991            download: false,
992            indexing: false,
993            metadata_only: false,
994        }
995    }
996
997    #[must_use]
998    pub const fn with_subscribe(mut self, subscribe: bool) -> Self {
999        self.subscribe = subscribe;
1000        self
1001    }
1002
1003    #[must_use]
1004    pub const fn with_download(mut self, download: bool) -> Self {
1005        self.download = download;
1006        self
1007    }
1008
1009    #[must_use]
1010    pub const fn with_indexing(mut self, indexing: bool) -> Self {
1011        self.indexing = indexing;
1012        self
1013    }
1014
1015    #[must_use]
1016    pub const fn with_metadata_only(mut self, metadata_only: bool) -> Self {
1017        self.metadata_only = metadata_only;
1018        self
1019    }
1020}
1021
1022impl IpcServer {
1023    #[must_use]
1024    pub const fn new(socket_path: PathBuf, daemon_handle: DaemonHandle) -> Self {
1025        Self {
1026            listener: IpcListener::new(socket_path),
1027            daemon_handle,
1028            archive_context: None,
1029            folder_manager: None,
1030            node_db: None,
1031            network_manager: None,
1032        }
1033    }
1034
1035    /// Create an IPC server with access to daemon-owned archive resources.
1036    #[must_use]
1037    pub fn with_archive_context(
1038        socket_path: PathBuf,
1039        data_dir: PathBuf,
1040        daemon_handle: DaemonHandle,
1041        node: Arc<IrohNode>,
1042        pool: Arc<ManagedPool>,
1043        indexing: Option<IndexingService>,
1044    ) -> Self {
1045        Self {
1046            listener: IpcListener::new(socket_path),
1047            daemon_handle,
1048            archive_context: Some(Arc::new(ArchiveContext {
1049                node,
1050                pool,
1051                indexing,
1052                data_dir,
1053            })),
1054            folder_manager: None,
1055            node_db: None,
1056            network_manager: None,
1057        }
1058    }
1059
1060    /// Set the folder manager for this IPC server.
1061    #[must_use]
1062    pub fn with_folder_manager(mut self, folder_manager: FolderManager) -> Self {
1063        self.folder_manager = Some(folder_manager);
1064        self
1065    }
1066
1067    /// Set the node database for persistence.
1068    #[must_use]
1069    pub fn with_node_db(mut self, node_db: NodeDatabase) -> Self {
1070        self.node_db = Some(node_db);
1071        self
1072    }
1073
1074    /// Attach a network manager for daemon-side network operations.
1075    #[must_use]
1076    pub fn with_network_manager(
1077        mut self,
1078        network_manager: std::sync::Arc<tokio::sync::RwLock<crate::net::NetworkManager>>,
1079    ) -> Self {
1080        self.network_manager = Some(network_manager);
1081        self
1082    }
1083
1084    /// Accept and process requests until the daemon broadcasts shutdown.
1085    ///
1086    /// # Errors
1087    ///
1088    /// Returns an error when the socket cannot be bound, accepted, or written.
1089    pub async fn serve(&self) -> Result<()> {
1090        #[cfg(unix)]
1091        {
1092            let listener = self.listener.bind()?;
1093            let mut shutdown = self.daemon_handle.shutdown_sender.subscribe();
1094            let result = loop {
1095                tokio::select! {
1096                    shutdown_result = shutdown.recv() => {
1097                        match shutdown_result {
1098                            Ok(()) | Err(broadcast::error::RecvError::Closed) => break Ok(()),
1099                            Err(broadcast::error::RecvError::Lagged(_)) => {}
1100                        }
1101                    }
1102                    accepted = listener.accept() => {
1103                        let (stream, _) = accepted.map_err(|error| {
1104                            SyncwebError::operation(
1105                                format!("daemon socket accept failed at {}", self.listener.socket_path().display()),
1106                                error,
1107                            )
1108                        })?;
1109                        if let Err(error) = Box::pin(self.handle_connection(stream)).await {
1110                            tracing::error!(%error, "daemon IPC connection failed");
1111                            return Err(error);
1112                        }
1113                    }
1114                }
1115            };
1116            if let Err(error) = std::fs::remove_file(self.listener.socket_path())
1117                && self.listener.socket_path().exists()
1118            {
1119                return Err(error.into());
1120            }
1121            result
1122        }
1123        #[cfg(not(unix))]
1124        {
1125            Err(SyncwebError::operation(
1126                "daemon IPC is unavailable",
1127                "Unix sockets are not supported on this platform",
1128            ))
1129        }
1130    }
1131
1132    async fn handle_status(&self) -> IpcResponse {
1133        IpcResponse::Status(self.daemon_handle.state.read().await.status)
1134    }
1135
1136    async fn handle_list_folders(&self) -> IpcResponse {
1137        let folders = self.daemon_handle.folder_registry.read().await.statuses();
1138        IpcResponse::FolderList(folders)
1139    }
1140
1141    fn handle_set_log_level(level: &str) -> IpcResponse {
1142        IpcResponse::Ok {
1143            message: format!("log level set to {level}"),
1144        }
1145    }
1146
1147    async fn handle_import_archive_response(
1148        &self,
1149        input: PathBuf,
1150        target: PathBuf,
1151        filter: Option<FilterConfig>,
1152    ) -> IpcResponse {
1153        match self.handle_import_archive(input, target, filter).await {
1154            Ok(result) => IpcResponse::ImportComplete(Box::new(result)),
1155            Err(error) => response_from_error(error),
1156        }
1157    }
1158
1159    async fn handle_export_archive_response(
1160        &self,
1161        namespace: String,
1162        version: Option<String>,
1163        output: PathBuf,
1164    ) -> IpcResponse {
1165        match self.handle_export_archive(namespace, version, output).await {
1166            Ok(result) => IpcResponse::ExportComplete(Box::new(result)),
1167            Err(error) => response_from_error(error),
1168        }
1169    }
1170
1171    async fn handle_export_collection_archive_response(
1172        &self,
1173        collection: String,
1174        version: Option<String>,
1175        output: PathBuf,
1176        filter: Option<FilterConfig>,
1177    ) -> IpcResponse {
1178        match self
1179            .handle_export_collection_archive(collection, version, output, filter)
1180            .await
1181        {
1182            Ok(result) => IpcResponse::ExportComplete(Box::new(result)),
1183            Err(error) => response_from_error(error),
1184        }
1185    }
1186
1187    async fn handle_network_group(&self, cmd: IpcCommand) -> IpcResponse {
1188        if let IpcCommand::NetworkInvite { network_id, device } = cmd {
1189            return self.handle_network_invite(network_id, device).await;
1190        }
1191        if let IpcCommand::NetworkKick { network_id, device } = cmd {
1192            return self.handle_network_kick(network_id, device).await;
1193        }
1194        if let IpcCommand::NetworkLeave { network_id } = cmd {
1195            return self.handle_network_leave(network_id).await;
1196        }
1197        if let IpcCommand::NetworkCreate {
1198            name,
1199            label,
1200            invite_only,
1201            doc_ticket,
1202        } = cmd
1203        {
1204            return self.handle_network_create(name, label, invite_only, doc_ticket).await;
1205        }
1206        if let IpcCommand::NetworkJoin { ticket } = cmd {
1207            return self.handle_network_join(ticket).await;
1208        }
1209        IpcResponse::Error {
1210            message: format!("unhandled network command: {cmd:?}"),
1211        }
1212    }
1213
1214    async fn handle_simple_group(&self, cmd: IpcCommand) -> IpcResponse {
1215        if matches!(cmd, IpcCommand::Status) {
1216            return self.handle_status().await;
1217        }
1218        if matches!(cmd, IpcCommand::ListFolders) {
1219            return self.handle_list_folders().await;
1220        }
1221        if matches!(cmd, IpcCommand::ReloadConfig) {
1222            return self.handle_reload_config();
1223        }
1224        IpcResponse::Error {
1225            message: format!("unhandled simple command: {cmd:?}"),
1226        }
1227    }
1228
1229    async fn handle_folder_group(&self, cmd: IpcCommand) -> IpcResponse {
1230        use IpcCommand as C;
1231        match cmd {
1232            C::ImportArchive { input, target, filter } => {
1233                self.handle_import_archive_response(input, target, filter).await
1234            }
1235            C::ImportFiles { namespace, path } => self.handle_import_files_response(namespace, path).await,
1236            C::WatchOnce {
1237                namespace,
1238                path,
1239                filter,
1240                exclude,
1241            } => self.handle_watch_once_response(namespace, path, filter, exclude).await,
1242            C::FetchBlob {
1243                hash,
1244                tickets,
1245                export_path,
1246            } => self.handle_fetch_blob_response(hash, tickets, export_path).await,
1247            C::LinkPublish { namespace, link } => self.handle_link_publish_response(namespace, link).await,
1248            C::ExportArchive {
1249                namespace,
1250                version,
1251                output,
1252            } => self.handle_export_archive_response(namespace, version, output).await,
1253            C::ExportCollectionArchive {
1254                collection,
1255                version,
1256                output,
1257                filter,
1258            } => {
1259                self.handle_export_collection_archive_response(collection, version, output, filter)
1260                    .await
1261            }
1262            C::Download { namespace, strategy } => self.handle_download_response(namespace, strategy).await,
1263            C::MaterializeTransfers { namespace } => self.handle_materialize_transfers(namespace).await,
1264            C::Join {
1265                ticket,
1266                path,
1267                mode,
1268                subscribe,
1269                filters,
1270                download,
1271                indexing,
1272                metadata_only,
1273            } => {
1274                let options = JoinOptions {
1275                    subscribe,
1276                    download,
1277                    indexing,
1278                    metadata_only,
1279                };
1280                self.handle_join(ticket, path, mode, filters, options).await
1281            }
1282            C::SetSubscribe {
1283                namespace,
1284                enabled,
1285                filters,
1286            } => self.handle_set_subscribe(namespace, enabled, filters).await,
1287            C::SubscribePublic { ticket } => self.handle_subscribe_public(ticket).await,
1288            C::CreateFolder { path, mode, indexing } => self.handle_create_folder(path, mode, indexing).await,
1289            C::StatsFiles { folder } => self.handle_stats_files(folder).await,
1290            C::ListEntries { folder, enrich } => self.handle_list_entries(&folder, enrich).await,
1291            C::VerifyIntegrity {
1292                path,
1293                hash,
1294                path_filter,
1295                glob_filter,
1296                fix,
1297                from,
1298            } => {
1299                self.handle_verify_integrity(path, hash, path_filter, glob_filter, fix, from)
1300                    .await
1301            }
1302            C::Unsubscribe { namespace } => self.handle_unsubscribe_command(&namespace).await,
1303            C::LeaveFolder {
1304                namespace,
1305                delete_files,
1306            } => self.handle_leave_folder(namespace, delete_files).await,
1307            C::Status
1308            | C::ListFolders
1309            | C::AddFolder { .. }
1310            | C::WatchStart { .. }
1311            | C::TriggerSync { .. }
1312            | C::SetLogLevel { .. }
1313            | C::ReloadConfig
1314            | C::Shutdown { .. }
1315            | C::Share { .. }
1316            | C::ShareList
1317            | C::Unshare { .. }
1318            | C::SnapshotCreate { .. }
1319            | C::SnapshotList { .. }
1320            | C::SnapshotDelete { .. }
1321            | C::SnapshotDiff { .. }
1322            | C::SnapshotRestore { .. }
1323            | C::CollectionPublish { .. }
1324            | C::EnrichSort { .. }
1325            | C::NetworkInvite { .. }
1326            | C::NetworkKick { .. }
1327            | C::NetworkLeave { .. }
1328            | C::NetworkCreate { .. }
1329            | C::NetworkJoin { .. }
1330            | C::PeerAvailability { .. }
1331            | C::PackageInfo { .. }
1332            | C::PackageInstall { .. }
1333            | C::IndexingEnable { .. }
1334            | C::IndexingPublish { .. } => unreachable!("non-folder command routed to folder handler"),
1335        }
1336    }
1337
1338    /// Handle one decoded request without requiring a socket.
1339    pub async fn handle_request(&self, request: IpcRequest) -> IpcResponse {
1340        use IpcCommand as C;
1341        match request.command {
1342            C::Status | C::ListFolders | C::ReloadConfig => self.handle_simple_group(request.command).await,
1343            C::AddFolder { namespace, path } => self.handle_add_folder(namespace, path).await,
1344            C::WatchStart {
1345                namespace,
1346                path,
1347                exclude,
1348                filter,
1349            } => self.handle_watch_start(namespace, path, exclude, filter).await,
1350            C::TriggerSync { namespace } => self.handle_trigger_sync(namespace),
1351            C::SetLogLevel { level } => Self::handle_set_log_level(&level),
1352            C::Shutdown { force } => self.handle_shutdown(force),
1353            C::ImportArchive { .. }
1354            | C::ImportFiles { .. }
1355            | C::WatchOnce { .. }
1356            | C::FetchBlob { .. }
1357            | C::LinkPublish { .. }
1358            | C::ExportArchive { .. }
1359            | C::ExportCollectionArchive { .. }
1360            | C::Download { .. }
1361            | C::MaterializeTransfers { .. }
1362            | C::Join { .. }
1363            | C::SetSubscribe { .. }
1364            | C::SubscribePublic { .. }
1365            | C::CreateFolder { .. }
1366            | C::StatsFiles { .. }
1367            | C::ListEntries { .. }
1368            | C::VerifyIntegrity { .. }
1369            | C::Unsubscribe { .. }
1370            | C::LeaveFolder { .. } => self.handle_folder_group(request.command).await,
1371            C::Share { .. } | C::ShareList | C::Unshare { .. } => self.handle_share_group(request.command).await,
1372            C::SnapshotCreate { .. }
1373            | C::SnapshotList { .. }
1374            | C::SnapshotDelete { .. }
1375            | C::SnapshotDiff { .. }
1376            | C::SnapshotRestore { .. } => self.handle_snapshot_group(request.command).await,
1377            C::CollectionPublish {
1378                path,
1379                namespace,
1380                sequence,
1381                bootstrap,
1382                manifest_bytes,
1383            } => {
1384                self.handle_collection_publish(path, namespace, sequence, bootstrap, manifest_bytes)
1385                    .await
1386            }
1387            C::EnrichSort { path } => self.handle_enrich_sort(path).await,
1388            C::NetworkInvite { .. }
1389            | C::NetworkKick { .. }
1390            | C::NetworkLeave { .. }
1391            | C::NetworkCreate { .. }
1392            | C::NetworkJoin { .. } => self.handle_network_group(request.command).await,
1393            C::PeerAvailability { folder } => self.handle_peer_availability(folder).await,
1394            C::PackageInfo { .. } | C::PackageInstall { .. } => self.handle_package_group(request.command).await,
1395            C::IndexingEnable { namespace } => self.handle_indexing_enable(namespace).await,
1396            C::IndexingPublish {
1397                namespace,
1398                catalog,
1399                tags,
1400            } => self.handle_indexing_publish(namespace, catalog, tags).await,
1401        }
1402    }
1403
1404    async fn handle_add_folder(&self, namespace: String, path: PathBuf) -> IpcResponse {
1405        match iroh_docs::NamespaceId::from_str(&namespace) {
1406            Ok(namespace_id) => {
1407                let mut registry = self.daemon_handle.folder_registry.write().await;
1408                match registry.add_or_update(FolderEntry::new(namespace_id, path)) {
1409                    Ok(()) => IpcResponse::Ok {
1410                        message: "folder added".to_owned(),
1411                    },
1412                    Err(error) => response_from_error(error),
1413                }
1414            }
1415            Err(error) => IpcResponse::Error {
1416                message: format!("invalid folder namespace: {error}"),
1417            },
1418        }
1419    }
1420
1421    async fn handle_watch_start(
1422        &self,
1423        namespace: String,
1424        path: PathBuf,
1425        exclude: Vec<String>,
1426        filter: Option<FilterConfig>,
1427    ) -> IpcResponse {
1428        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
1429            Ok(namespace_id) => namespace_id,
1430            Err(error) => {
1431                return IpcResponse::Error {
1432                    message: format!("invalid folder namespace: {error}"),
1433                };
1434            }
1435        };
1436        let watch = WatchConfig::new(exclude, filter);
1437        if let Some(node_db) = &self.node_db {
1438            if let Err(error) = node_db.upsert_folder_mount(&namespace, &path) {
1439                return response_from_error(error);
1440            }
1441            match serde_json::to_string(&watch) {
1442                Ok(encoded) => {
1443                    if let Err(error) = node_db.save_folder_watch_config(&namespace, &encoded) {
1444                        return response_from_error(error);
1445                    }
1446                }
1447                Err(error) => {
1448                    return IpcResponse::Error {
1449                        message: format!("failed to encode watch configuration: {error}"),
1450                    };
1451                }
1452            }
1453        }
1454        self.daemon_handle
1455            .folder_registry
1456            .write()
1457            .await
1458            .add_or_update_watch(FolderEntry::new(namespace_id, path).with_watch(watch));
1459        IpcResponse::Ok {
1460            message: format!("watching: {namespace}"),
1461        }
1462    }
1463
1464    async fn handle_leave_folder(&self, namespace: String, delete_files: bool) -> IpcResponse {
1465        let context = match &self.archive_context {
1466            Some(ctx) => ctx.clone(),
1467            None => {
1468                return IpcResponse::Error {
1469                    message: "daemon leave-folder IPC is unavailable: server has no node context".to_owned(),
1470                };
1471            }
1472        };
1473        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
1474            Ok(id) => id,
1475            Err(error) => {
1476                return IpcResponse::Error {
1477                    message: format!("invalid namespace: {error}"),
1478                };
1479            }
1480        };
1481        let _ = cancel_session(namespace_id);
1482        let manager = FolderManager::new(&context.node);
1483        match manager.drop_when_ready(namespace_id).await {
1484            Ok(()) => {
1485                if let Some(node_db) = self.node_db.clone()
1486                    && let Ok(mut config) = node_db.load_app_config()
1487                {
1488                    config.remove_subscribe(&namespace);
1489                    let _ = node_db.save_app_config(&config);
1490                }
1491                let removed = self.daemon_handle.folder_registry.write().await.remove(&namespace_id);
1492                if let Some(ref node_db) = self.node_db {
1493                    if let Err(error) = node_db.remove_folder_mount(&namespace) {
1494                        tracing::warn!(%error, %namespace, "failed to remove folder mount");
1495                    }
1496                    if let Err(error) = node_db.remove_folder_watch_config(&namespace) {
1497                        tracing::warn!(%error, %namespace, "failed to remove folder watch config");
1498                    }
1499                }
1500                if delete_files
1501                    && let Some(entry) = removed.as_ref()
1502                    && !entry.path.as_os_str().is_empty()
1503                    && let Err(error) = FolderManager::delete_folder_files(&entry.path).await
1504                {
1505                    return IpcResponse::Error {
1506                        message: format!("folder left, but failed to delete its files: {error}"),
1507                    };
1508                }
1509                IpcResponse::Ok {
1510                    message: format!("left: {namespace}"),
1511                }
1512            }
1513            Err(error) => response_from_error(error),
1514        }
1515    }
1516
1517    fn handle_trigger_sync(&self, namespace: Option<String>) -> IpcResponse {
1518        match self.daemon_handle.sync_trigger.send(namespace) {
1519            Ok(()) => IpcResponse::Ok {
1520                message: "synchronization requested".to_owned(),
1521            },
1522            Err(error) => response_from_error(error),
1523        }
1524    }
1525
1526    fn handle_reload_config(&self) -> IpcResponse {
1527        self.daemon_handle.reload_requested.store(true, Ordering::Release);
1528        if self.daemon_handle.sync_trigger.send(None).is_err() {
1529            tracing::debug!("daemon reload wake-up channel is not connected");
1530        }
1531        IpcResponse::Ok {
1532            message: "configuration reload requested".to_owned(),
1533        }
1534    }
1535
1536    fn handle_shutdown(&self, force: bool) -> IpcResponse {
1537        if let Err(error) = self.daemon_handle.shutdown_sender.send(()) {
1538            return response_from_error(error);
1539        }
1540        IpcResponse::Ok {
1541            message: if force {
1542                "forced shutdown requested".to_owned()
1543            } else {
1544                "shutdown requested".to_owned()
1545            },
1546        }
1547    }
1548
1549    async fn handle_download_response(&self, namespace: String, strategy: FetchStrategy) -> IpcResponse {
1550        match self.handle_download(namespace, strategy).await {
1551            Ok(bytes_transferred) => IpcResponse::DownloadComplete { bytes_transferred },
1552            Err(error) => response_from_error(error),
1553        }
1554    }
1555
1556    async fn handle_materialize_transfers(&self, namespace: Option<String>) -> IpcResponse {
1557        let Some(context) = self.archive_context.clone() else {
1558            return IpcResponse::Error {
1559                message: "transfer materialization requires daemon node context".to_owned(),
1560            };
1561        };
1562        let Some(node_db) = self.node_db.clone() else {
1563            return IpcResponse::Error {
1564                message: "transfer materialization requires node database".to_owned(),
1565            };
1566        };
1567        let summary = match super::transfers::process_transfer_jobs(
1568            context.node.as_ref(),
1569            &node_db,
1570            namespace.as_deref(),
1571        )
1572        .await
1573        {
1574            Ok(summary) => summary,
1575            Err(error) => return response_from_error(error),
1576        };
1577        IpcResponse::TransferJobsProcessed {
1578            completed: summary.completed,
1579            failed: summary.failed,
1580        }
1581    }
1582
1583    async fn handle_import_files_response(&self, namespace: Option<String>, path: PathBuf) -> IpcResponse {
1584        match self.handle_import_files(namespace, path).await {
1585            Ok(entries) => IpcResponse::ImportFilesComplete { entries },
1586            Err(error) => response_from_error(error),
1587        }
1588    }
1589
1590    async fn handle_watch_once_response(
1591        &self,
1592        namespace: Option<String>,
1593        path: PathBuf,
1594        filter: Option<FilterConfig>,
1595        exclude: Vec<String>,
1596    ) -> IpcResponse {
1597        match self.handle_watch_once(namespace, path, filter, exclude).await {
1598            Ok((imported, rejected)) => IpcResponse::WatchOnceComplete { imported, rejected },
1599            Err(error) => response_from_error(error),
1600        }
1601    }
1602
1603    async fn handle_fetch_blob_response(
1604        &self,
1605        hash: String,
1606        tickets: Vec<String>,
1607        export_path: Option<PathBuf>,
1608    ) -> IpcResponse {
1609        match self.handle_fetch_blob(hash, tickets, export_path).await {
1610            Ok(fetched) => IpcResponse::FetchBlobComplete { hash: fetched },
1611            Err(error) => response_from_error(error),
1612        }
1613    }
1614
1615    async fn handle_link_publish_response(&self, namespace: String, link: String) -> IpcResponse {
1616        match self.handle_link_publish(namespace, link).await {
1617            Ok(message) => IpcResponse::Ok { message },
1618            Err(error) => response_from_error(error),
1619        }
1620    }
1621
1622    async fn handle_download(&self, namespace: String, strategy: FetchStrategy) -> Result<u64> {
1623        self.handle_download_with_timeout(namespace, strategy, DOWNLOAD_TIMEOUT)
1624            .await
1625    }
1626
1627    async fn handle_download_with_timeout(
1628        &self,
1629        namespace: String,
1630        strategy: FetchStrategy,
1631        timeout_duration: Duration,
1632    ) -> Result<u64> {
1633        let context = self.archive_context.clone().ok_or_else(|| {
1634            SyncwebError::operation("daemon download IPC is unavailable", "server has no node context")
1635        })?;
1636        let namespace_id = iroh_docs::NamespaceId::from_str(&namespace)
1637            .map_err(|error| SyncwebError::operation("invalid download namespace", error))?;
1638        let manager = FolderManager::new(&context.node);
1639        let folder = manager.get(namespace_id).await?;
1640        // Downloading content the node already holds locally is a no-op for
1641        // fetching: a doc re-sync for already-present blobs can fail on the
1642        // publishing node (no remote peer to fetch from). Skip straight to
1643        // materialization so piped downloads of local content succeed.
1644        if !folder.is_metadata_only().await? && crate::sync::strategy_selects_only_local(&folder, &strategy).await? {
1645            let mount_root = self.daemon_handle.folder_registry.read().await.path_for(&namespace);
1646            if let Some(root) = mount_root {
1647                crate::sync::materialize_selected_content(&context.node, &folder, &strategy, &root).await?;
1648            }
1649            return Ok(0);
1650        }
1651        // A metadata-only folder keeps content out of the store; a doc re-sync
1652        // will not re-download it, so an explicit download fetches each selected
1653        // blob directly from the folder's peers.
1654        let bytes_transferred = if folder.is_metadata_only().await? {
1655            crate::sync::fetch_selected_content(&context.node, &folder, &strategy).await?
1656        } else {
1657            let sync = SyncEngine::new(
1658                manager,
1659                context.node.blob_store().clone(),
1660                context.node.docs_engine().clone(),
1661                Some(context.node.topic_tracker().clone()),
1662            );
1663            let mut intent = sync.fetch(namespace_id, strategy.clone()).await?;
1664            if timeout_duration == DOWNLOAD_TIMEOUT {
1665                self.run_download_loop(&mut intent).await?
1666            } else {
1667                self.run_download_loop_with_timeout(&mut intent, timeout_duration)
1668                    .await?
1669            }
1670        };
1671        // `download` materializes the fetched blobs into the folder's mount path,
1672        // not just the blob store, so the files are readable on disk.
1673        let mount_root = self.daemon_handle.folder_registry.read().await.path_for(&namespace);
1674        if let Some(root) = mount_root {
1675            crate::sync::materialize_selected_content(&context.node, &folder, &strategy, &root).await?;
1676        }
1677        Ok(bytes_transferred)
1678    }
1679
1680    async fn run_download_loop(&self, intent: &mut crate::sync::IntentHandle) -> Result<u64> {
1681        self.run_download_loop_with_timeout(intent, DOWNLOAD_TIMEOUT).await
1682    }
1683
1684    async fn run_download_loop_with_timeout(
1685        &self,
1686        intent: &mut crate::sync::IntentHandle,
1687        timeout_duration: Duration,
1688    ) -> Result<u64> {
1689        let mut bytes_transferred = 0_u64;
1690
1691        let loop_body = async {
1692            while let Some(event) = intent.next().await {
1693                match event {
1694                    SyncEvent::Stats(stats) => {
1695                        bytes_transferred = bytes_transferred.max(stats.bytes_transferred);
1696                    }
1697                    SyncEvent::Failed(message) => {
1698                        return Err(SyncwebError::operation("daemon download failed", message));
1699                    }
1700                    SyncEvent::Finished => return Ok(()),
1701                    SyncEvent::Started
1702                    | SyncEvent::Progress { .. }
1703                    | SyncEvent::Paused
1704                    | SyncEvent::Resumed
1705                    | SyncEvent::Cancelled => {}
1706                }
1707            }
1708            Ok(())
1709        };
1710
1711        #[cfg(unix)]
1712        {
1713            use tokio::time::timeout;
1714            match timeout(timeout_duration, loop_body).await {
1715                Ok(Ok(())) => {}
1716                Ok(Err(error)) => return Err(error),
1717                Err(_elapsed) => {
1718                    let _ = intent.cancel();
1719                }
1720            }
1721        }
1722
1723        #[cfg(not(unix))]
1724        {
1725            let _ = timeout_duration;
1726            loop_body.await?;
1727        }
1728
1729        Ok(bytes_transferred)
1730    }
1731
1732    async fn handle_import_files(&self, namespace: Option<String>, path: PathBuf) -> Result<u64> {
1733        let context = self.archive_context.clone().ok_or_else(|| {
1734            SyncwebError::operation("daemon filesystem import is unavailable", "server has no node context")
1735        })?;
1736        let namespace_id = if let Some(value) = namespace {
1737            iroh_docs::NamespaceId::from_str(&value)
1738                .map_err(|error| SyncwebError::operation("invalid import namespace", error))?
1739        } else {
1740            let folders = self.daemon_handle.folder_registry.read().await.statuses();
1741            let [folder] = folders.as_slice() else {
1742                return Err(SyncwebError::operation(
1743                    "cannot infer import namespace",
1744                    "specify a folder when more than one folder is managed",
1745                ));
1746            };
1747            iroh_docs::NamespaceId::from_str(&folder.namespace)
1748                .map_err(|error| SyncwebError::operation("invalid managed folder namespace", error))?
1749        };
1750        let folder = FolderManager::new(&context.node).get(namespace_id).await?;
1751        let root = if path.is_dir() {
1752            path.clone()
1753        } else {
1754            path.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf)
1755        };
1756        let importer = Importer::new(
1757            context.node.blob_store().clone(),
1758            context.node.docs_engine().clone(),
1759            folder.doc().clone(),
1760            folder.author(),
1761        )
1762        .with_root(root);
1763        let entries = importer.import_path(path).await?;
1764        u64::try_from(entries.len()).map_err(|error| SyncwebError::operation("import entry count overflowed", error))
1765    }
1766
1767    /// One-shot filtered scan/import used by `watch --once` when a daemon owns
1768    /// the live data directory. Scans `path` recursively, evaluates each file
1769    /// against the caller-supplied filter, imports the accepted entries into
1770    /// the resolved folder, and reports the accepted count plus rejected
1771    /// folder-relative paths.
1772    ///
1773    /// # Errors
1774    ///
1775    /// Returns an error when the folder cannot be resolved, the filesystem
1776    /// cannot be scanned, or the entries cannot be imported.
1777    async fn handle_watch_once(
1778        &self,
1779        namespace: Option<String>,
1780        path: PathBuf,
1781        filter: Option<FilterConfig>,
1782        exclude: Vec<String>,
1783    ) -> Result<(u64, Vec<String>)> {
1784        let context = self
1785            .archive_context
1786            .clone()
1787            .ok_or_else(|| SyncwebError::operation("daemon watch is unavailable", "server has no node context"))?;
1788        let namespace_id = if let Some(value) = namespace {
1789            iroh_docs::NamespaceId::from_str(&value)
1790                .map_err(|error| SyncwebError::operation("invalid watch namespace", error))?
1791        } else {
1792            let folders = self.daemon_handle.folder_registry.read().await.statuses();
1793            let [folder] = folders.as_slice() else {
1794                return Err(SyncwebError::operation(
1795                    "cannot infer watch namespace",
1796                    "specify a folder when more than one folder is managed",
1797                ));
1798            };
1799            iroh_docs::NamespaceId::from_str(&folder.namespace)
1800                .map_err(|error| SyncwebError::operation("invalid managed folder namespace", error))?
1801        };
1802        let folder = FolderManager::new(&context.node).get(namespace_id).await?;
1803        let folder_key = namespace_id.to_string();
1804        let watch = self.daemon_handle.folder_registry.read().await.watch_for(&folder_key);
1805        let effective_exclude = if exclude.is_empty() { watch.exclude } else { exclude };
1806        let filter_engine = match filter.or(watch.filter) {
1807            Some(config) => Some(FilterEngine::new(config)?),
1808            None => match &self.node_db {
1809                Some(node_db) => load_filter_engine(node_db, &context.data_dir)?,
1810                None => None,
1811            },
1812        };
1813        let root = if path.is_dir() {
1814            path
1815        } else {
1816            path.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf)
1817        };
1818        let scanned = Scanner::new(&root, effective_exclude).scan()?;
1819        let mut accepted = Vec::with_capacity(scanned.len());
1820        let mut rejected = Vec::new();
1821        for entry in scanned {
1822            let is_rejected = filter_engine.as_ref().is_some_and(|engine| {
1823                engine.evaluate_for_folder(&folder_key, &FilterEntry::new(entry.relative_path.clone(), entry.size))
1824                    == FilterAction::Reject
1825            });
1826            if is_rejected {
1827                rejected.push(entry.relative_path.to_string_lossy().into_owned());
1828            } else {
1829                accepted.push(entry);
1830            }
1831        }
1832        let importer = Importer::new(
1833            context.node.blob_store().clone(),
1834            context.node.docs_engine().clone(),
1835            folder.doc().clone(),
1836            folder.author(),
1837        )
1838        .with_root(&root);
1839        let written = importer.import_entries(accepted).await?;
1840        let count = u64::try_from(written.len())
1841            .map_err(|error| SyncwebError::operation("watch entry count overflowed", error))?;
1842        Ok((count, rejected))
1843    }
1844
1845    /// Fetch and pin a content blob through daemon-owned provider tickets,
1846    /// optionally exporting it to a filesystem path. Backs `download` for blob
1847    /// tickets and `link resolve` so neither opens a second node on the live
1848    /// data directory.
1849    ///
1850    /// # Errors
1851    ///
1852    /// Returns an error when the hash or tickets are malformed, the fetch
1853    /// fails, or the export path cannot be written.
1854    async fn handle_fetch_blob(
1855        &self,
1856        hash: String,
1857        tickets: Vec<String>,
1858        export_path: Option<PathBuf>,
1859    ) -> Result<String> {
1860        let context = self
1861            .archive_context
1862            .clone()
1863            .ok_or_else(|| SyncwebError::operation("daemon blob fetch is unavailable", "server has no node context"))?;
1864        let content_id = hash
1865            .parse::<iroh_blobs::Hash>()
1866            .map_err(|error| SyncwebError::operation("invalid content hash", error))?;
1867        let ticket_list = tickets
1868            .iter()
1869            .map(|value| {
1870                value
1871                    .parse::<iroh_blobs::ticket::BlobTicket>()
1872                    .map_err(|error| SyncwebError::operation("invalid blob ticket", error))
1873            })
1874            .collect::<Result<Vec<_>>>()?;
1875        context
1876            .node
1877            .blob_store()
1878            .fetch_and_pin(context.node.endpoint(), content_id, &ticket_list)
1879            .await?;
1880        if let Some(path) = export_path {
1881            if let Some(parent) = path.parent() {
1882                tokio::fs::create_dir_all(parent).await?;
1883            }
1884            context
1885                .node
1886                .blob_store()
1887                .export_to_path_with_mode(content_id, &path, iroh_blobs::api::blobs::ExportMode::Copy)
1888                .await?;
1889        }
1890        Ok(content_id.to_string())
1891    }
1892
1893    /// Publish a stable link into a managed folder's document through the
1894    /// daemon-owned node, so `link create --publish` does not open a second
1895    /// node on the live data directory.
1896    ///
1897    /// # Errors
1898    ///
1899    /// Returns an error when the daemon has no indexing service, the link is
1900    /// malformed, or the folder document cannot be written.
1901    async fn handle_link_publish(&self, namespace: String, link: String) -> Result<String> {
1902        let context = self.archive_context.clone().ok_or_else(|| {
1903            SyncwebError::operation("daemon link publish is unavailable", "server has no node context")
1904        })?;
1905        let indexing = context.indexing.as_ref().ok_or_else(|| {
1906            SyncwebError::operation("daemon link publish is unavailable", "server has no indexing service")
1907        })?;
1908        let store = LinkStore::with_database(indexing.database().clone())?;
1909        let parsed = link
1910            .parse::<Link>()
1911            .map_err(|error| SyncwebError::operation("invalid link", error))?;
1912        store.publish_link(&context.node, &namespace, &parsed).await?;
1913        Ok(format!("published: {link}"))
1914    }
1915
1916    async fn handle_import_archive(
1917        &self,
1918        input: PathBuf,
1919        target: PathBuf,
1920        filter: Option<FilterConfig>,
1921    ) -> Result<DropImportResult> {
1922        let context = self.archive_context.clone().ok_or_else(|| {
1923            SyncwebError::operation("daemon archive IPC is unavailable", "server has no node context")
1924        })?;
1925        let filter_engine = filter.map(FilterEngine::new).transpose()?;
1926        let options = filter_engine.map_or_else(DropImportOptions::default, |value| {
1927            DropImportOptions::default().with_filter(value)
1928        });
1929        let importer = DropImporter::new(context.node.blob_store().clone());
1930        let mut result = importer
1931            .import_archive(&input, options, Some(context.pool.as_ref()))
1932            .await?;
1933        importer.materialize(&result, &target).await?;
1934        let folder = FolderManager::new(&context.node).create(SyncMode::SendReceive).await?;
1935        CollectionStore::for_node(&context.node, &folder)
1936            .publish(&result.collection_manifest, 1)
1937            .await?;
1938        result.namespace_id = Some(folder.namespace_id());
1939        Ok(result)
1940    }
1941
1942    async fn handle_export_archive(
1943        &self,
1944        namespace: String,
1945        version: Option<String>,
1946        output: PathBuf,
1947    ) -> Result<DropExportResult> {
1948        let context = self.archive_context.clone().ok_or_else(|| {
1949            SyncwebError::operation("daemon archive IPC is unavailable", "server has no node context")
1950        })?;
1951        let namespace_id = iroh_docs::NamespaceId::from_str(&namespace)
1952            .map_err(|error| SyncwebError::operation("invalid export namespace", error))?;
1953        let folder = FolderManager::new(&context.node).get(namespace_id).await?;
1954        let head = Self::latest_collection_head(&context.node, folder.doc()).await?;
1955        let manifests = Self::collection_manifests(&context.node, folder.doc(), head).await?;
1956        let options = version.map_or_else(DropExportOptions::default, |value| {
1957            DropExportOptions::default().with_version(value)
1958        });
1959        DropExporter::new(context.node.blob_store().clone())
1960            .export_drop_with_options(&manifests, output, options, Some(context.pool.as_ref()))
1961            .await
1962    }
1963
1964    async fn handle_export_collection_archive(
1965        &self,
1966        collection: String,
1967        version: Option<String>,
1968        output: PathBuf,
1969        filter: Option<FilterConfig>,
1970    ) -> Result<DropExportResult> {
1971        let context = self.archive_context.clone().ok_or_else(|| {
1972            SyncwebError::operation("daemon archive IPC is unavailable", "server has no node context")
1973        })?;
1974        let collection_id = collection
1975            .parse::<uuid::Uuid>()
1976            .map_err(|error| SyncwebError::operation("invalid export collection ID", error))?;
1977        let manager = FolderManager::new(&context.node);
1978        let folder_statuses = self.daemon_handle.folder_registry.read().await.statuses();
1979        let mut manifests = None;
1980        for status in folder_statuses {
1981            let Ok(namespace_id) = status.namespace.parse::<iroh_docs::NamespaceId>() else {
1982                continue;
1983            };
1984            let folder = manager.get(namespace_id).await?;
1985            let store = CollectionStore::for_node(&context.node, &folder);
1986            if let Some(head) = store.head(collection_id).await? {
1987                manifests = Some(Self::collection_manifests(&context.node, folder.doc(), head).await?);
1988                break;
1989            }
1990        }
1991        let collection_manifests = manifests.ok_or_else(|| {
1992            SyncwebError::InvalidConfig(format!(
1993                "collection {collection_id} was not found in daemon-managed folders"
1994            ))
1995        })?;
1996        let filter_engine = filter.map(FilterEngine::new).transpose()?;
1997        let mut options = version.map_or_else(DropExportOptions::default, |value| {
1998            DropExportOptions::default().with_version(value)
1999        });
2000        if let Some(engine) = filter_engine {
2001            options = options.with_filter(engine);
2002        }
2003        DropExporter::new(context.node.blob_store().clone())
2004            .export_drop_with_options(&collection_manifests, output, options, Some(context.pool.as_ref()))
2005            .await
2006    }
2007
2008    async fn register_folder(
2009        &self,
2010        namespace_id: iroh_docs::NamespaceId,
2011        namespace: &str,
2012        path: &Path,
2013        mode: SyncMode,
2014    ) {
2015        if self
2016            .daemon_handle
2017            .folder_registry
2018            .write()
2019            .await
2020            .add(FolderEntry::new(namespace_id, path.to_path_buf()).with_mode(mode.to_string()))
2021            .is_err()
2022        {
2023            tracing::warn!(%namespace, "folder already in daemon registry");
2024        }
2025        if let Some(ref node_db) = self.node_db
2026            && let Err(error) = node_db.upsert_folder_mount(namespace, path)
2027        {
2028            tracing::warn!(%error, %namespace, "failed to record folder mount");
2029        }
2030    }
2031
2032    async fn handle_join(
2033        &self,
2034        ticket: String,
2035        path: PathBuf,
2036        mode: SyncMode,
2037        filters: SubscribeFilters,
2038        options: JoinOptions,
2039    ) -> IpcResponse {
2040        let context = match &self.archive_context {
2041            Some(ctx) => ctx.clone(),
2042            None => {
2043                return IpcResponse::Error {
2044                    message: "daemon join IPC is unavailable: server has no node context".to_owned(),
2045                };
2046            }
2047        };
2048        match tokio::fs::create_dir_all(&path).await {
2049            Ok(()) => {}
2050            Err(error) => {
2051                return IpcResponse::Error {
2052                    message: format!("failed to create folder path: {error}"),
2053                };
2054            }
2055        }
2056        let manager = FolderManager::new(&context.node);
2057        match manager.join_with_options(ticket, mode, options.metadata_only).await {
2058            Ok(folder) => {
2059                let namespace = folder.namespace_id().to_string();
2060                self.register_folder(folder.namespace_id(), &namespace, &path, mode)
2061                    .await;
2062                if options.indexing
2063                    && let Some(indexing_service) = &context.indexing
2064                    && let Err(error) = indexing_service.enable_folder(&folder).await
2065                {
2066                    tracing::warn!(%error, %namespace, "failed to enable indexing for joined folder");
2067                }
2068                self.finalize_join(&context, &manager, &folder, &filters, &path, options)
2069                    .await
2070            }
2071            Err(error) => response_from_error(error),
2072        }
2073    }
2074
2075    async fn finalize_join(
2076        &self,
2077        context: &ArchiveContext,
2078        manager: &FolderManager,
2079        folder: &crate::folder::SyncwebFolder,
2080        filters: &SubscribeFilters,
2081        path: &Path,
2082        options: JoinOptions,
2083    ) -> IpcResponse {
2084        let namespace = folder.namespace_id().to_string();
2085        if let Some(ref node_db) = self.node_db {
2086            let mut config = match node_db.load_app_config() {
2087                Ok(config) => config,
2088                Err(error) => return response_from_error(error),
2089            };
2090            config.set_subscribe(&namespace, options.subscribe, filters);
2091            if let Err(error) = node_db.save_app_config(&config) {
2092                return response_from_error(error);
2093            }
2094        }
2095        // Run the initial download first. It opens a short-lived one-shot
2096        // intent over the same document, and when it times out it cancels that
2097        // intent, which calls `Doc::leave` and unregisters the namespace from
2098        // the docs engine. Triggering the supervised intent before that would
2099        // start live sync and then let the download tear it down, so peers
2100        // would receive `NotFound` on the next sync and local changes would
2101        // never propagate. Wake the supervisor only after the download is done.
2102        let downloaded = if options.download {
2103            match self.materialize_folder(context, manager, folder, filters, path).await {
2104                Ok(count) => count,
2105                Err(error) => return response_from_error(error),
2106            }
2107        } else {
2108            0
2109        };
2110        if options.subscribe && self.daemon_handle.sync_trigger.send(Some(namespace.clone())).is_err() {
2111            tracing::warn!(%namespace, "failed to trigger live sync after join");
2112        }
2113        IpcResponse::Ok {
2114            message: if options.download {
2115                format!("joined: {namespace}\ndownloaded: {downloaded} files")
2116            } else {
2117                format!("joined: {namespace}")
2118            },
2119        }
2120    }
2121
2122    async fn materialize_folder(
2123        &self,
2124        context: &ArchiveContext,
2125        manager: &FolderManager,
2126        folder: &crate::folder::SyncwebFolder,
2127        filters: &SubscribeFilters,
2128        destination: &Path,
2129    ) -> Result<usize> {
2130        let sync = SyncEngine::new(
2131            manager.clone(),
2132            context.node.blob_store().clone(),
2133            context.node.docs_engine().clone(),
2134            Some(context.node.topic_tracker().clone()),
2135        );
2136        let strategy =
2137            FetchStrategy::Filter(FetchFilter::new().with_paths(filters.sync_prefix.clone().into_iter().collect()));
2138        let mut intent = sync.fetch(folder.namespace_id(), strategy).await?;
2139        self.run_download_loop_with_timeout(&mut intent, DOWNLOAD_TIMEOUT)
2140            .await?;
2141        let area = filters
2142            .sync_prefix
2143            .clone()
2144            .map(AreaFilter::Prefix)
2145            .or_else(|| filters.glob.clone().map(AreaFilter::Glob))
2146            .unwrap_or(AreaFilter::All);
2147        let entries = folder.list_entries().await?;
2148        let mut count = 0_usize;
2149        for entry in entries {
2150            let rel = Path::new(&entry.path);
2151            if !area.matches_path(rel) {
2152                continue;
2153            }
2154            let dest = destination.join(rel);
2155            if let Some(parent) = dest.parent()
2156                && let Err(error) = tokio::fs::create_dir_all(parent).await
2157            {
2158                return Err(SyncwebError::operation("failed to create download directory", error));
2159            }
2160            context.node.blob_store().export_to_path(entry.hash, &dest).await?;
2161            count = count.saturating_add(1);
2162        }
2163        Ok(count)
2164    }
2165
2166    async fn handle_set_subscribe(
2167        &self,
2168        namespace: String,
2169        enabled: bool,
2170        requested_filters: Option<SubscribeFilters>,
2171    ) -> IpcResponse {
2172        let context = match &self.archive_context {
2173            Some(ctx) => ctx.clone(),
2174            None => {
2175                return IpcResponse::Error {
2176                    message: "daemon subscribe IPC is unavailable: server has no node context".to_owned(),
2177                };
2178            }
2179        };
2180        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
2181            Ok(id) => id,
2182            Err(error) => {
2183                return IpcResponse::Error {
2184                    message: format!("invalid namespace: {error}"),
2185                };
2186            }
2187        };
2188        let filters = requested_filters.unwrap_or_default();
2189        if let Some(ref node_db) = self.node_db {
2190            let mut config = match node_db.load_app_config() {
2191                Ok(config) => config,
2192                Err(error) => return response_from_error(error),
2193            };
2194            if enabled {
2195                config.set_subscribe(&namespace, true, &filters);
2196            } else {
2197                config.remove_subscribe(&namespace);
2198            }
2199            if let Err(error) = node_db.save_app_config(&config) {
2200                return response_from_error(error);
2201            }
2202        }
2203        if enabled {
2204            let manager = FolderManager::new(&context.node);
2205            let sync = SyncEngine::new(
2206                manager,
2207                context.node.blob_store().clone(),
2208                context.node.docs_engine().clone(),
2209                Some(context.node.topic_tracker().clone()),
2210            );
2211            let params = match SubscribeParams::from_filters(&filters) {
2212                Ok(params) => params,
2213                Err(error) => return response_from_error(error),
2214            };
2215            match sync.subscribe(namespace_id, params).await {
2216                Ok(_intent) => IpcResponse::Ok {
2217                    message: format!("subscribed: {namespace}"),
2218                },
2219                Err(error) => response_from_error(error),
2220            }
2221        } else {
2222            let _ = cancel_session(namespace_id);
2223            IpcResponse::Ok {
2224                message: format!("unsubscribed: {namespace}"),
2225            }
2226        }
2227    }
2228
2229    async fn handle_subscribe_public(&self, ticket: String) -> IpcResponse {
2230        let context = match &self.archive_context {
2231            Some(ctx) => ctx.clone(),
2232            None => {
2233                return IpcResponse::Error {
2234                    message: "daemon subscribe-public IPC is unavailable: server has no node context".to_owned(),
2235                };
2236            }
2237        };
2238        let blob_ticket = match ticket.parse::<iroh_blobs::ticket::BlobTicket>() {
2239            Ok(t) => t,
2240            Err(error) => {
2241                return IpcResponse::Error {
2242                    message: format!("invalid blob ticket: {error}"),
2243                };
2244            }
2245        };
2246        let manager = FolderManager::new(&context.node);
2247        match manager.subscribe_public(&blob_ticket).await {
2248            Ok(hash) => {
2249                let provider = Some(blob_ticket.addr().clone());
2250                let size = match context.node.blob_store().get(hash).await {
2251                    Ok(bytes) => u64::try_from(bytes.len()).unwrap_or_default(),
2252                    Err(error) => return response_from_error(error),
2253                };
2254                let subscription = PublicSubscription::new(hash, provider, size);
2255                let namespace = subscription.namespace_id();
2256                self.daemon_handle
2257                    .folder_registry
2258                    .write()
2259                    .await
2260                    .add_subscription(subscription);
2261                if let Some(ref node_db) = self.node_db
2262                    && let Err(error) = node_db.save_subscription(&hash, size)
2263                {
2264                    tracing::warn!(%hash, %error, "failed to persist subscription");
2265                }
2266                IpcResponse::Ok {
2267                    message: format!("subscribed: {namespace}\nhash: {hash}\nsize: {size}"),
2268                }
2269            }
2270            Err(error) => response_from_error(error),
2271        }
2272    }
2273
2274    async fn handle_create_folder(&self, path: PathBuf, mode: String, indexing: bool) -> IpcResponse {
2275        let context = match &self.archive_context {
2276            Some(ctx) => ctx.clone(),
2277            None => {
2278                return IpcResponse::Error {
2279                    message: "daemon create-folder IPC is unavailable: server has no node context".to_owned(),
2280                };
2281            }
2282        };
2283        match std::fs::create_dir_all(&path) {
2284            Ok(()) => {}
2285            Err(error) => {
2286                return IpcResponse::Error {
2287                    message: format!("failed to create directory: {error}"),
2288                };
2289            }
2290        }
2291        let sync_mode = match SyncMode::from_str(&mode) {
2292            Ok(m) => m,
2293            Err(error) => {
2294                return IpcResponse::Error {
2295                    message: format!("invalid sync mode: {error}"),
2296                };
2297            }
2298        };
2299        // `folders create` is idempotent per path: re-running it for a path that
2300        // is already registered would otherwise mint a fresh namespace each time,
2301        // leaving a previously shared URL pointing at a namespace the sender no
2302        // longer imports into. Reuse the existing registration instead.
2303        let existing = {
2304            let registry = self.daemon_handle.folder_registry.read().await;
2305            registry
2306                .statuses()
2307                .into_iter()
2308                .filter(|status| status.kind == "folder" && !status.path.as_os_str().is_empty())
2309                .find(|status| same_absolute_path(&status.path, &path))
2310                .map(|status| status.namespace)
2311        };
2312        if let Some(namespace) = existing {
2313            return IpcResponse::Ok {
2314                message: format!("namespace: {namespace}"),
2315            };
2316        }
2317        let manager = FolderManager::new(&context.node);
2318        match manager.create(sync_mode).await {
2319            Ok(folder) => {
2320                let namespace = folder.namespace_id().to_string();
2321                if indexing
2322                    && let Some(indexing_service) = &context.indexing
2323                    && let Err(error) = indexing_service.enable_folder(&folder).await
2324                {
2325                    tracing::warn!(%error, %namespace, "failed to enable indexing for new folder");
2326                }
2327                self.register_folder(folder.namespace_id(), &namespace, &path, sync_mode)
2328                    .await;
2329                // A created folder is two-way by default: the owner should also
2330                // receive remote changes. Enable live sync so the reconciler can
2331                // materialize peers' edits into the mount.
2332                if let Some(ref node_db) = self.node_db {
2333                    let mut config = match node_db.load_app_config() {
2334                        Ok(config) => config,
2335                        Err(error) => return response_from_error(error),
2336                    };
2337                    config.set_subscribe(
2338                        &namespace,
2339                        true,
2340                        &SubscribeFilters::new(false, true, None, None, None, None),
2341                    );
2342                    if let Err(error) = node_db.save_app_config(&config) {
2343                        return response_from_error(error);
2344                    }
2345                }
2346                match folder.ticket(true).await {
2347                    Ok(_ticket) => IpcResponse::Ok {
2348                        message: format!("namespace: {namespace}"),
2349                    },
2350                    Err(error) => response_from_error(error),
2351                }
2352            }
2353            Err(error) => response_from_error(error),
2354        }
2355    }
2356
2357    async fn handle_stats_files(&self, folder_path: PathBuf) -> IpcResponse {
2358        let context = match &self.archive_context {
2359            Some(ctx) => ctx.clone(),
2360            None => {
2361                return IpcResponse::Error {
2362                    message: "daemon stats-files IPC is unavailable: server has no node context".to_owned(),
2363                };
2364            }
2365        };
2366        let manager = FolderManager::new(&context.node);
2367        let folder = match resolve_folder_for_daemon(
2368            &manager,
2369            &*self.daemon_handle.folder_registry.read().await,
2370            &folder_path,
2371        )
2372        .await
2373        {
2374            Ok(f) => f,
2375            Err(error) => return error,
2376        };
2377        let entries = match folder.content_entries().await {
2378            Ok(e) => e,
2379            Err(error) => return response_from_error(error),
2380        };
2381        let mut collector = FileStatsCollector::new();
2382        for entry in entries {
2383            collector.add_entry_bytes_with_time(entry.key(), entry.content_len(), Some(entry.timestamp()));
2384        }
2385        IpcResponse::FileStats(Box::new(collector.report()))
2386    }
2387
2388    async fn handle_list_entries(&self, namespace: &str, enrich: bool) -> IpcResponse {
2389        let context = match &self.archive_context {
2390            Some(ctx) => ctx.clone(),
2391            None => {
2392                return IpcResponse::Error {
2393                    message: "daemon list-entries IPC is unavailable: server has no node context".to_owned(),
2394                };
2395            }
2396        };
2397        let namespace_id = match iroh_docs::NamespaceId::from_str(namespace) {
2398            Ok(id) => id,
2399            Err(error) => {
2400                return IpcResponse::Error {
2401                    message: format!("invalid namespace: {error}"),
2402                };
2403            }
2404        };
2405        let manager = FolderManager::new(&context.node);
2406        let folder = match manager.get(namespace_id).await {
2407            Ok(folder) => folder,
2408            Err(error) => return response_from_error(error),
2409        };
2410        let mount_root = self.daemon_handle.folder_registry.read().await.path_for(namespace);
2411        let entries = match folder.list_entries().await {
2412            Ok(entries) => entries,
2413            Err(error) => return response_from_error(error),
2414        };
2415        let mut rows = Vec::with_capacity(entries.len());
2416        for entry in entries {
2417            let local = match folder.has_local(entry.hash).await {
2418                Ok(local) => local,
2419                Err(error) => return response_from_error(error),
2420            };
2421            let (size, modified) = if enrich
2422                && local
2423                && let Some(root) = mount_root.as_deref()
2424                && let Ok(metadata) = std::fs::metadata(root.join(&entry.path))
2425            {
2426                (
2427                    metadata.len(),
2428                    metadata
2429                        .modified()
2430                        .ok()
2431                        .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
2432                        .map(|duration| duration.as_secs()),
2433                )
2434            } else {
2435                (entry.size, None)
2436            };
2437            rows.push(EntryRow {
2438                path: entry.path,
2439                hash: entry.hash,
2440                size,
2441                local,
2442                modified,
2443            });
2444        }
2445        IpcResponse::Entries(rows)
2446    }
2447
2448    async fn handle_enrich_sort(&self, path: PathBuf) -> IpcResponse {
2449        let Some(context) = self.archive_context.clone() else {
2450            return IpcResponse::EnrichData(HashMap::new());
2451        };
2452        let manager = FolderManager::new(&context.node);
2453        let Ok(folder) =
2454            resolve_folder_for_daemon(&manager, &*self.daemon_handle.folder_registry.read().await, &path).await
2455        else {
2456            return IpcResponse::EnrichData(HashMap::new());
2457        };
2458        let Ok(entries) = context.node.docs_engine().list_latest(folder.doc()).await else {
2459            return IpcResponse::EnrichData(HashMap::new());
2460        };
2461        let mut path_map: Vec<(String, iroh_blobs::Hash)> = Vec::new();
2462        for entry in entries {
2463            if entry.key().starts_with(b"sys/") {
2464                continue;
2465            }
2466            let Ok(path_str) = String::from_utf8(entry.key().to_vec()) else {
2467                continue;
2468            };
2469            let hash = entry.content_hash();
2470            path_map.push((path_str, hash));
2471        }
2472
2473        let peers_per_hash: HashMap<iroh_blobs::Hash, usize> = HashMap::new();
2474
2475        let result: HashMap<String, usize> = path_map
2476            .into_iter()
2477            .map(|(path_str, hash)| {
2478                let count = peers_per_hash.get(&hash).copied().unwrap_or(0);
2479                (path_str, count)
2480            })
2481            .collect();
2482        IpcResponse::EnrichData(result)
2483    }
2484
2485    /// Read-only aggregation of which peers joined a folder and which of its
2486    /// blobs each peer can serve. No new state: the peer set is drawn from the
2487    /// network manager's membership records and the per-blob counts from the
2488    /// (currently empty) `EnrichSort` peer map.
2489    async fn handle_peer_availability(&self, folder_selection: String) -> IpcResponse {
2490        let Some(context) = self.archive_context.clone() else {
2491            return IpcResponse::Error {
2492                message: "daemon peer-availability IPC is unavailable: server has no node context".to_owned(),
2493            };
2494        };
2495        let manager = FolderManager::new(&context.node);
2496        let folder = match resolve_folder_for_daemon(
2497            &manager,
2498            &*self.daemon_handle.folder_registry.read().await,
2499            Path::new(&folder_selection),
2500        )
2501        .await
2502        {
2503            Ok(folder) => folder,
2504            Err(error) => return error,
2505        };
2506        let namespace_id = folder.namespace_id();
2507        let namespace_str = namespace_id.to_string();
2508
2509        // Inbound peers: members of the networks that contain this folder.
2510        let mut peers: Vec<PeerInfo> = Vec::new();
2511        if let Some(net_mgr_ref) = self.network_manager.as_ref() {
2512            let member_devices = {
2513                let guard = net_mgr_ref.read().await;
2514                let local_node = *guard.local_node();
2515                let mut member_devices: Vec<String> = Vec::new();
2516                if let Ok(network_ids) = guard.networks_for_folder(&namespace_id) {
2517                    for network_id in network_ids {
2518                        let Ok(net_id) = network_id.parse::<crate::net::NetworkId>() else {
2519                            continue;
2520                        };
2521                        let Some(network) = guard.get(&net_id) else {
2522                            continue;
2523                        };
2524                        for member in &network.members {
2525                            if *member == local_node {
2526                                continue;
2527                            }
2528                            let device_id = crate::node::identity::DeviceId::from_node_id(*member).to_syncthing();
2529                            if !member_devices.contains(&device_id) {
2530                                member_devices.push(device_id);
2531                            }
2532                        }
2533                    }
2534                }
2535                drop(guard);
2536                member_devices
2537            };
2538            for device_id in member_devices {
2539                peers.push(PeerInfo {
2540                    device_id,
2541                    name: None,
2542                    connection: Some("member".to_owned()),
2543                });
2544            }
2545        }
2546        peers.sort_by(|left, right| left.device_id.cmp(&right.device_id));
2547
2548        // Live gossip neighbors mark peers that are currently connected.
2549        if let Ok(live_peers) = context.node.topic_tracker().find_peers(namespace_id).await {
2550            let live: std::collections::HashSet<String> = live_peers
2551                .into_iter()
2552                .map(|peer| crate::node::identity::DeviceId::from_node_id(peer.id).to_syncthing())
2553                .collect();
2554            for peer in &mut peers {
2555                if live.contains(&peer.device_id) {
2556                    peer.connection = Some("online".to_owned());
2557                }
2558            }
2559        }
2560
2561        // Per-blob counts reuse the empty `EnrichSort` map path: a blob's peers
2562        // are not tracked per-blob yet, so `peer_count` is a point-in-time 0.
2563        let Ok(entries) = context.node.docs_engine().list_latest(folder.doc()).await else {
2564            return IpcResponse::PeerAvailability(Box::new(PeerAvailabilityReport {
2565                folder: namespace_str,
2566                peers,
2567                per_blob: Vec::new(),
2568            }));
2569        };
2570        let mut per_blob = Vec::with_capacity(entries.len());
2571        for entry in entries {
2572            if entry.key().starts_with(b"sys/") {
2573                continue;
2574            }
2575            let Ok(path) = String::from_utf8(entry.key().to_vec()) else {
2576                continue;
2577            };
2578            let hash = entry.content_hash();
2579            let peer_count = 0;
2580            let pct_seeded = if peers.is_empty() {
2581                0.0
2582            } else {
2583                f64::from(i32::try_from(peer_count).unwrap_or(0))
2584                    .mul_add(f64::from(i32::try_from(peers.len()).unwrap_or(1)).recip(), 0.0)
2585                    .mul_add(100.0, 0.0)
2586            };
2587            per_blob.push(BlobPeerAvailability {
2588                path,
2589                hash: hash.to_string(),
2590                peer_count,
2591                peers: Vec::new(),
2592                pct_seeded,
2593            });
2594        }
2595        IpcResponse::PeerAvailability(Box::new(PeerAvailabilityReport {
2596            folder: namespace_str,
2597            peers,
2598            per_blob,
2599        }))
2600    }
2601
2602    async fn handle_verify_integrity(
2603        &self,
2604        path: PathBuf,
2605        hash: Vec<String>,
2606        path_filter: Option<String>,
2607        glob_filter: Option<String>,
2608        fix: bool,
2609        from: Vec<String>,
2610    ) -> IpcResponse {
2611        let context = match &self.archive_context {
2612            Some(ctx) => ctx.clone(),
2613            None => {
2614                return IpcResponse::Error {
2615                    message: "daemon verify IPC is unavailable: server has no node context".to_owned(),
2616                };
2617            }
2618        };
2619        let manager = FolderManager::new(&context.node);
2620        let folder =
2621            match resolve_folder_for_daemon(&manager, &*self.daemon_handle.folder_registry.read().await, &path).await {
2622                Ok(f) => f,
2623                Err(error) => return error,
2624            };
2625        let checker = IntegrityChecker::new(context.node.blob_store().clone(), context.node.docs_engine().clone());
2626
2627        let filter = build_ipc_verify_filter(&hash, path_filter.as_ref(), glob_filter.as_ref());
2628
2629        match checker.verify_folder_filtered(&folder, filter.as_ref()).await {
2630            Ok(result) => {
2631                let mut message = format!(
2632                    "total: {}, verified: {}, corrupted: {}, missing: {}",
2633                    result.total,
2634                    result.verified,
2635                    result.corrupted.len(),
2636                    result.missing.len(),
2637                );
2638                if fix {
2639                    let namespace_id = folder.namespace_id();
2640                    let repair = self
2641                        .run_daemon_repair(&context, namespace_id, &result.corrupted, &from)
2642                        .await;
2643                    let _ = write!(
2644                        message,
2645                        ", repair: attempted {}, repaired {}",
2646                        repair.attempted, repair.repaired,
2647                    );
2648                }
2649                let valid = result.is_valid();
2650                let _ = write!(message, ", valid: {valid}");
2651                IpcResponse::Ok { message }
2652            }
2653            Err(error) => response_from_error(error),
2654        }
2655    }
2656
2657    async fn run_daemon_repair(
2658        &self,
2659        context: &ArchiveContext,
2660        namespace_id: iroh_docs::NamespaceId,
2661        corrupted: &[crate::verify::CorruptionInfo],
2662        tickets: &[String],
2663    ) -> crate::verify::RepairResult {
2664        let mut result = crate::verify::RepairResult::default();
2665        if corrupted.is_empty() {
2666            return result;
2667        }
2668
2669        let provider_tickets: Vec<(iroh_blobs::Hash, iroh_blobs::ticket::BlobTicket)> = tickets
2670            .iter()
2671            .filter_map(|t| {
2672                let ticket: iroh_blobs::ticket::BlobTicket = t.parse().ok()?;
2673                Some((ticket.hash(), ticket))
2674            })
2675            .collect();
2676
2677        let namespace_peers = context
2678            .node
2679            .topic_tracker()
2680            .find_peers(namespace_id)
2681            .await
2682            .unwrap_or_default();
2683
2684        for item in corrupted {
2685            result.attempted = result.attempted.saturating_add(1);
2686            let hash = item.expected_hash;
2687
2688            // Try --from tickets first, then namespace peers.
2689            let candidate_tickets: Vec<iroh_blobs::ticket::BlobTicket> = provider_tickets
2690                .iter()
2691                .filter(|(ticket_hash, _)| *ticket_hash == hash)
2692                .map(|(_, ticket)| ticket.clone())
2693                .collect();
2694            let repaired = crate::node::blob_store::try_fetch_first_working(&candidate_tickets, |ticket| {
2695                context.node.blob_store().force_fetch(context.node.endpoint(), ticket)
2696            })
2697            .await
2698            .is_ok()
2699                || crate::node::blob_store::try_fetch_first_working(&namespace_peers, |peer| async {
2700                    context
2701                        .node
2702                        .blob_store()
2703                        .force_fetch_from_peer(context.node.endpoint(), peer, hash)
2704                        .await
2705                })
2706                .await
2707                .is_ok();
2708            if repaired {
2709                result.repaired = result.repaired.saturating_add(1);
2710            } else {
2711                result.failed.push(crate::verify::RepairOutcome {
2712                    path: item.path.clone(),
2713                    hash,
2714                    success: false,
2715                    error: Some("no alternative providers available".to_owned()),
2716                });
2717            }
2718        }
2719        result
2720    }
2721
2722    async fn handle_unsubscribe_command(&self, namespace: &str) -> IpcResponse {
2723        if !namespace.starts_with("blob:") {
2724            return IpcResponse::Error {
2725                message: "unsubscribe applies only to public blob subscriptions; use `leave` for folders".to_owned(),
2726            };
2727        }
2728        self.daemon_handle
2729            .folder_registry
2730            .write()
2731            .await
2732            .remove_subscription(namespace);
2733        if let Some(ref manager) = self.folder_manager
2734            && let Some(hash_str) = namespace.strip_prefix("blob:")
2735            && let Ok(hash) = hash_str.parse::<iroh_blobs::Hash>()
2736        {
2737            manager.drop_subscription(&hash).await;
2738        }
2739        if let Some(ref node_db) = self.node_db
2740            && let Some(hash_str) = namespace.strip_prefix("blob:")
2741            && let Ok(hash) = hash_str.parse::<iroh_blobs::Hash>()
2742            && let Err(error) = node_db.remove_subscription(&hash)
2743        {
2744            tracing::warn!(%hash, %error, "failed to remove subscription from database");
2745        }
2746        IpcResponse::Ok {
2747            message: format!("unsubscribed: {namespace}"),
2748        }
2749    }
2750
2751    async fn handle_share_group(&self, cmd: IpcCommand) -> IpcResponse {
2752        if let IpcCommand::Share {
2753            namespace,
2754            blob,
2755            writable,
2756            pin,
2757            persist,
2758            addr_info,
2759        } = cmd
2760        {
2761            let options = ShareOptions {
2762                writable,
2763                pin,
2764                persist,
2765                addr_info,
2766            };
2767            return self.handle_share(namespace, blob, options).await;
2768        }
2769        if matches!(cmd, IpcCommand::ShareList) {
2770            return self.handle_share_list();
2771        }
2772        if let IpcCommand::Unshare {
2773            namespace,
2774            blob,
2775            writable,
2776        } = cmd
2777        {
2778            return self.handle_unshare(namespace, blob, writable).await;
2779        }
2780        IpcResponse::Error {
2781            message: format!("unhandled share command: {cmd:?}"),
2782        }
2783    }
2784
2785    async fn handle_snapshot_group(&self, cmd: IpcCommand) -> IpcResponse {
2786        if let IpcCommand::SnapshotCreate {
2787            path,
2788            description,
2789            threads,
2790        } = cmd
2791        {
2792            return self.handle_snapshot_create(path, description, threads).await;
2793        }
2794        if let IpcCommand::SnapshotList { path } = cmd {
2795            return self.handle_snapshot_list(path).await;
2796        }
2797        if let IpcCommand::SnapshotDelete { id } = cmd {
2798            return self.handle_snapshot_delete(id).await;
2799        }
2800        if let IpcCommand::SnapshotDiff { first, second, .. } = cmd {
2801            return self.handle_snapshot_diff(first, second).await;
2802        }
2803        if let IpcCommand::SnapshotRestore { path, snapshot } = cmd {
2804            return self.handle_snapshot_restore(path, snapshot).await;
2805        }
2806        IpcResponse::Error {
2807            message: format!("unhandled snapshot command: {cmd:?}"),
2808        }
2809    }
2810
2811    async fn handle_share(&self, namespace: String, blob: Option<String>, options: ShareOptions) -> IpcResponse {
2812        let context = match &self.archive_context {
2813            Some(ctx) => ctx.clone(),
2814            None => {
2815                return IpcResponse::Error {
2816                    message: "daemon share IPC is unavailable: server has no node context".to_owned(),
2817                };
2818            }
2819        };
2820        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
2821            Ok(id) => id,
2822            Err(error) => {
2823                return IpcResponse::Error {
2824                    message: format!("invalid namespace: {error}"),
2825                };
2826            }
2827        };
2828        let manager = FolderManager::new(&context.node);
2829        let folder = match manager.get(namespace_id).await {
2830            Ok(f) => f,
2831            Err(error) => return response_from_error(error),
2832        };
2833        if let Some(hash_str) = blob {
2834            let hash = match hash_str.parse::<iroh_blobs::Hash>() {
2835                Ok(h) => h,
2836                Err(error) => {
2837                    return IpcResponse::Error {
2838                        message: format!("invalid blob hash: {error}"),
2839                    };
2840                }
2841            };
2842            return match folder.publish_blob(context.node.endpoint().addr(), hash).await {
2843                Ok(ticket) => IpcResponse::Ok {
2844                    message: ticket.to_string(),
2845                },
2846                Err(error) => response_from_error(error),
2847            };
2848        }
2849        let result = match share_folder(&folder, options, |ns, access, ticket| {
2850            if let Some(node_db) = &self.node_db {
2851                node_db.add_share(&ns.to_string(), access, &ticket.to_string())?;
2852            }
2853            Ok(())
2854        })
2855        .await
2856        {
2857            Ok(result) => result,
2858            Err(error) => return response_from_error(error),
2859        };
2860        IpcResponse::Ok { message: result.url }
2861    }
2862
2863    fn handle_share_list(&self) -> IpcResponse {
2864        let Some(node_db) = &self.node_db else {
2865            return IpcResponse::Error {
2866                message: "daemon share-list IPC is unavailable: no node database".to_owned(),
2867            };
2868        };
2869        match node_db.list_shares() {
2870            Ok(shares) => {
2871                if shares.is_empty() {
2872                    return IpcResponse::Ok {
2873                        message: "no shares".to_owned(),
2874                    };
2875                }
2876                let mut lines = Vec::new();
2877                for (namespace, access, ticket) in shares {
2878                    lines.push(format!("namespace: {namespace}\naccess: {access}\nticket: {ticket}"));
2879                }
2880                IpcResponse::Ok {
2881                    message: lines.join("\n\n"),
2882                }
2883            }
2884            Err(error) => response_from_error(error),
2885        }
2886    }
2887
2888    async fn handle_unshare(&self, namespace: String, blob: Option<String>, writable: bool) -> IpcResponse {
2889        let context = match &self.archive_context {
2890            Some(ctx) => ctx.clone(),
2891            None => {
2892                return IpcResponse::Error {
2893                    message: "daemon unshare IPC is unavailable: server has no node context".to_owned(),
2894                };
2895            }
2896        };
2897        let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
2898            Ok(id) => id,
2899            Err(error) => {
2900                return IpcResponse::Error {
2901                    message: format!("invalid namespace: {error}"),
2902                };
2903            }
2904        };
2905        let manager = FolderManager::new(&context.node);
2906        if let Some(hash_str) = blob {
2907            let hash = match hash_str.parse::<iroh_blobs::Hash>() {
2908                Ok(h) => h,
2909                Err(error) => {
2910                    return IpcResponse::Error {
2911                        message: format!("invalid blob hash: {error}"),
2912                    };
2913                }
2914            };
2915            let folder = match manager.get(namespace_id).await {
2916                Ok(f) => f,
2917                Err(error) => return response_from_error(error),
2918            };
2919            return match folder.unpublish_blob(hash).await {
2920                Ok(()) => IpcResponse::Ok {
2921                    message: format!("unshared: {namespace_id} (blob {hash_str})"),
2922                },
2923                Err(error) => response_from_error(error),
2924            };
2925        }
2926        let access = if writable { "write" } else { "read" };
2927        if let Some(node_db) = &self.node_db
2928            && let Err(error) = node_db.remove_share(&namespace_id.to_string(), access)
2929        {
2930            return response_from_error(error);
2931        }
2932        if let Ok(folder) = manager.get(namespace_id).await {
2933            let _ = folder.unpin_all_content().await;
2934        }
2935        IpcResponse::Ok {
2936            message: format!("unshared: {namespace_id} ({access})"),
2937        }
2938    }
2939
2940    async fn handle_snapshot_create(&self, path: PathBuf, description: Option<String>, threads: usize) -> IpcResponse {
2941        let context = match &self.archive_context {
2942            Some(ctx) => ctx.clone(),
2943            None => {
2944                return IpcResponse::Error {
2945                    message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
2946                };
2947            }
2948        };
2949        let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
2950        let result = if path.exists() {
2951            snapshots.create_from_path(&path, threads, description).await
2952        } else {
2953            let manager = FolderManager::new(&context.node);
2954            let folder =
2955                match resolve_folder_for_daemon(&manager, &*self.daemon_handle.folder_registry.read().await, &path)
2956                    .await
2957                {
2958                    Ok(f) => f,
2959                    Err(error) => return error,
2960                };
2961            snapshots.create_for_folder(&folder, description).await
2962        };
2963        match result {
2964            Ok(snapshot) => IpcResponse::Ok {
2965                message: snapshot.id.to_string(),
2966            },
2967            Err(error) => response_from_error(error),
2968        }
2969    }
2970
2971    async fn handle_snapshot_list(&self, path: PathBuf) -> IpcResponse {
2972        let context = match &self.archive_context {
2973            Some(ctx) => ctx.clone(),
2974            None => {
2975                return IpcResponse::Error {
2976                    message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
2977                };
2978            }
2979        };
2980        let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
2981        let namespace = path.to_string_lossy().parse::<iroh_docs::NamespaceId>().ok();
2982        match snapshots.list().await {
2983            Ok(all) => {
2984                let rows = all
2985                    .into_iter()
2986                    .filter(|s| namespace.is_none_or(|id| s.namespace_id == Some(id)))
2987                    .map(|s| SnapshotInfo {
2988                        id: s.id.to_string(),
2989                        created_at: s.created_at,
2990                        total_size: s.total_size,
2991                        file_count: s.file_count,
2992                        description: s.description,
2993                    })
2994                    .collect();
2995                IpcResponse::SnapshotList(rows)
2996            }
2997            Err(error) => response_from_error(error),
2998        }
2999    }
3000
3001    async fn handle_snapshot_delete(&self, id: String) -> IpcResponse {
3002        let context = match &self.archive_context {
3003            Some(ctx) => ctx.clone(),
3004            None => {
3005                return IpcResponse::Error {
3006                    message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
3007                };
3008            }
3009        };
3010        let hash = match id.parse::<iroh_blobs::Hash>() {
3011            Ok(h) => h,
3012            Err(error) => {
3013                return IpcResponse::Error {
3014                    message: format!("invalid snapshot id: {error}"),
3015                };
3016            }
3017        };
3018        let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
3019        match snapshots.delete(hash).await {
3020            Ok(()) => IpcResponse::Ok {
3021                message: format!("deleted: {id}"),
3022            },
3023            Err(error) => response_from_error(error),
3024        }
3025    }
3026
3027    async fn handle_snapshot_diff(&self, first: String, second: String) -> IpcResponse {
3028        let context = match &self.archive_context {
3029            Some(ctx) => ctx.clone(),
3030            None => {
3031                return IpcResponse::Error {
3032                    message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
3033                };
3034            }
3035        };
3036        let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
3037        let (Ok(left_id), Ok(right_id)) = (first.parse::<iroh_blobs::Hash>(), second.parse::<iroh_blobs::Hash>())
3038        else {
3039            return IpcResponse::Error {
3040                message: "invalid snapshot id".to_owned(),
3041            };
3042        };
3043        let (left, right) = match (snapshots.load(left_id).await, snapshots.load(right_id).await) {
3044            (Ok(left), Ok(right)) => (left, right),
3045            (Err(error), _) | (_, Err(error)) => return response_from_error(error),
3046        };
3047        match left.diff(&right) {
3048            Ok(diff) => IpcResponse::SnapshotDiff(Box::new(SnapshotDiffReport {
3049                added: diff.added.iter().map(|e| e.path.display().to_string()).collect(),
3050                removed: diff.removed.iter().map(|e| e.path.display().to_string()).collect(),
3051                modified: diff
3052                    .modified
3053                    .iter()
3054                    .map(|(old, new)| {
3055                        (
3056                            old.path.display().to_string(),
3057                            old.hash.to_string(),
3058                            new.hash.to_string(),
3059                        )
3060                    })
3061                    .collect(),
3062            })),
3063            Err(error) => response_from_error(error),
3064        }
3065    }
3066
3067    async fn handle_snapshot_restore(&self, path: PathBuf, snapshot: String) -> IpcResponse {
3068        let context = match &self.archive_context {
3069            Some(ctx) => ctx.clone(),
3070            None => {
3071                return IpcResponse::Error {
3072                    message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
3073                };
3074            }
3075        };
3076        let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
3077        let id = match snapshot.parse::<iroh_blobs::Hash>() {
3078            Ok(id) => id,
3079            Err(error) => {
3080                return IpcResponse::Error {
3081                    message: format!("invalid snapshot id: {error}"),
3082                };
3083            }
3084        };
3085        let loaded = match snapshots.load(id).await {
3086            Ok(found) => found,
3087            Err(error) => return response_from_error(error),
3088        };
3089        let result = if let Ok(namespace) = path.to_string_lossy().parse::<iroh_docs::NamespaceId>() {
3090            let manager = FolderManager::new(&context.node);
3091            match manager.get(namespace).await {
3092                Ok(folder) => snapshots
3093                    .restore_for_folder(&folder, &loaded)
3094                    .await
3095                    .map(|()| namespace.to_string()),
3096                Err(error) => Err(error),
3097            }
3098        } else {
3099            snapshots
3100                .restore_to_path(&loaded, &path)
3101                .await
3102                .map(|paths| format!("restored {} files", paths.len()))
3103        };
3104        match result {
3105            Ok(message) => IpcResponse::Ok { message },
3106            Err(error) => response_from_error(error),
3107        }
3108    }
3109
3110    async fn handle_network_invite(&self, network_id: String, device: String) -> IpcResponse {
3111        let Some(ref net_mgr) = self.network_manager else {
3112            return IpcResponse::Error {
3113                message: "network manager not available in IPC server".to_owned(),
3114            };
3115        };
3116        let net_id = match network_id.parse::<crate::net::NetworkId>() {
3117            Ok(id) => id,
3118            Err(e) => return response_from_error(e),
3119        };
3120        let peer = match device.parse::<iroh::PublicKey>() {
3121            Ok(pk) => pk,
3122            Err(e) => {
3123                return IpcResponse::Error {
3124                    message: format!("invalid device ID: {e}"),
3125                };
3126            }
3127        };
3128        let result = net_mgr.write().await.invite(net_id, peer);
3129        match result {
3130            Ok(ticket) => {
3131                if let Some(context) = &self.archive_context {
3132                    let manager = net_mgr.write().await;
3133                    if let Some(network) = manager.get(&net_id).cloned()
3134                        && network.doc_ticket.is_some()
3135                        && let Err(error) = crate::net::membership::refresh_with_node(&context.node, &network).await
3136                    {
3137                        tracing::warn!(%error, "failed to refresh network membership doc");
3138                    }
3139                }
3140                IpcResponse::Ok {
3141                    message: ticket.to_string(),
3142                }
3143            }
3144            Err(e) => response_from_error(e),
3145        }
3146    }
3147
3148    async fn handle_network_kick(&self, network_id: String, device: String) -> IpcResponse {
3149        let Some(ref net_mgr) = self.network_manager else {
3150            return IpcResponse::Error {
3151                message: "network manager not available in IPC server".to_owned(),
3152            };
3153        };
3154        let net_id = match network_id.parse::<crate::net::NetworkId>() {
3155            Ok(id) => id,
3156            Err(e) => return response_from_error(e),
3157        };
3158        let peer = match device.parse::<iroh::PublicKey>() {
3159            Ok(pk) => pk,
3160            Err(e) => {
3161                return IpcResponse::Error {
3162                    message: format!("invalid device ID: {e}"),
3163                };
3164            }
3165        };
3166        let result = net_mgr.write().await.kick(net_id, &peer);
3167        match result {
3168            Ok(()) => {
3169                if let Some(context) = &self.archive_context {
3170                    let manager = net_mgr.write().await;
3171                    if let Some(network) = manager.get(&net_id).cloned()
3172                        && network.doc_ticket.is_some()
3173                        && let Err(error) = crate::net::membership::refresh_with_node(&context.node, &network).await
3174                    {
3175                        tracing::warn!(%error, "failed to refresh network membership doc");
3176                    }
3177                }
3178                IpcResponse::Ok {
3179                    message: "member kicked".to_owned(),
3180                }
3181            }
3182            Err(e) => response_from_error(e),
3183        }
3184    }
3185
3186    async fn handle_network_leave(&self, network_id: String) -> IpcResponse {
3187        let Some(ref net_mgr) = self.network_manager else {
3188            return IpcResponse::Error {
3189                message: "network manager not available in IPC server".to_owned(),
3190            };
3191        };
3192        let net_id = match network_id.parse::<crate::net::NetworkId>() {
3193            Ok(id) => id,
3194            Err(e) => return response_from_error(e),
3195        };
3196        let result = net_mgr.write().await.leave(net_id);
3197        match result {
3198            Ok(()) => IpcResponse::Ok {
3199                message: "left network".to_owned(),
3200            },
3201            Err(e) => response_from_error(e),
3202        }
3203    }
3204
3205    async fn handle_network_create(
3206        &self,
3207        name: String,
3208        label: String,
3209        invite_only: bool,
3210        doc_ticket: Option<String>,
3211    ) -> IpcResponse {
3212        let Some(ref net_mgr) = self.network_manager else {
3213            return IpcResponse::Error {
3214                message: "network manager not available in IPC server".to_owned(),
3215            };
3216        };
3217        let options = crate::net::NetworkOptions {
3218            label,
3219            invite_only,
3220            ..crate::net::NetworkOptions::default()
3221        };
3222        let created = net_mgr.write().await.create_with_doc_ticket(&name, options, doc_ticket);
3223        let id = match created {
3224            Ok(id) => id,
3225            Err(e) => return response_from_error(e),
3226        };
3227        if let Some(context) = &self.archive_context {
3228            let mut manager = net_mgr.write().await;
3229            if let Some(network) = manager.get(&id).cloned()
3230                && let Err(error) =
3231                    crate::net::membership::provision_with_node(&context.node, &network, &mut manager).await
3232            {
3233                tracing::warn!(%error, "network created without a membership doc (doc_ticket)");
3234            }
3235        }
3236        IpcResponse::NetworkCreated {
3237            name,
3238            id: id.to_string(),
3239        }
3240    }
3241
3242    async fn handle_network_join(&self, ticket: String) -> IpcResponse {
3243        let Some(ref net_mgr) = self.network_manager else {
3244            return IpcResponse::Error {
3245                message: "network manager not available in IPC server".to_owned(),
3246            };
3247        };
3248        let parsed = match ticket.parse::<crate::net::NetworkTicket>() {
3249            Ok(t) => t,
3250            Err(e) => return response_from_error(e),
3251        };
3252        let result = net_mgr.write().await.join(parsed);
3253        match result {
3254            Ok(id) => IpcResponse::Ok {
3255                message: format!("joined network {id}"),
3256            },
3257            Err(e) => response_from_error(e),
3258        }
3259    }
3260
3261    async fn handle_collection_publish(
3262        &self,
3263        path: PathBuf,
3264        namespace: String,
3265        sequence: u64,
3266        _bootstrap: Vec<String>,
3267        manifest_bytes: Option<Vec<u8>>,
3268    ) -> IpcResponse {
3269        let context = match &self.archive_context {
3270            Some(ctx) => ctx.clone(),
3271            None => {
3272                return IpcResponse::Error {
3273                    message: "daemon collection-publish IPC is unavailable: server has no node context".to_owned(),
3274                };
3275            }
3276        };
3277        let manifest = match manifest_bytes {
3278            Some(bytes) => match CollectionManifest::from_bytes(bytes) {
3279                Ok(m) => m,
3280                Err(error) => return response_from_error(error),
3281            },
3282            None => {
3283                return IpcResponse::Error {
3284                    message: "collection publish requires manifest_bytes; run `package init` first".to_owned(),
3285                };
3286            }
3287        };
3288        for entry in &manifest.entries {
3289            let file_path = path.join(&entry.logical_path);
3290            match context.node.blob_store().add_file(&file_path).await {
3291                Ok(hash) => {
3292                    if hash != entry.content_id {
3293                        return IpcResponse::Error {
3294                            message: format!(
3295                                "collection content changed while publishing: {}",
3296                                entry.logical_path.display()
3297                            ),
3298                        };
3299                    }
3300                }
3301                Err(error) => return response_from_error(error),
3302            }
3303        }
3304        let namespace_id = match namespace.parse::<iroh_docs::NamespaceId>() {
3305            Ok(id) => id,
3306            Err(error) => {
3307                return IpcResponse::Error {
3308                    message: format!("invalid namespace: {error}"),
3309                };
3310            }
3311        };
3312        let manager = FolderManager::new(&context.node);
3313        let folder = match manager.get(namespace_id).await {
3314            Ok(f) => f,
3315            Err(error) => return response_from_error(error),
3316        };
3317        let store = CollectionStore::for_node(&context.node, &folder);
3318        let head = match store.publish(&manifest, sequence).await {
3319            Ok(h) => h,
3320            Err(error) => return response_from_error(error),
3321        };
3322        let ticket = context.node.blob_store().ticket(context.node.endpoint(), head.manifest);
3323        IpcResponse::Ok {
3324            message: format!(
3325                "manifest: {}\nmanifest_ticket: {}\nsequence: {}",
3326                head.manifest, ticket, head.sequence,
3327            ),
3328        }
3329    }
3330
3331    async fn handle_package_group(&self, cmd: IpcCommand) -> IpcResponse {
3332        if let IpcCommand::PackageInfo { ticket, hash, node_id } = cmd {
3333            return self.handle_package_info(ticket, hash, node_id).await;
3334        }
3335        if let IpcCommand::PackageInstall { ticket } = cmd {
3336            return self.handle_package_install(ticket).await;
3337        }
3338        IpcResponse::Error {
3339            message: format!("unhandled package command: {cmd:?}"),
3340        }
3341    }
3342
3343    async fn handle_package_info(
3344        &self,
3345        ticket: Option<String>,
3346        hash: Option<String>,
3347        node_id: Option<String>,
3348    ) -> IpcResponse {
3349        let context = match &self.archive_context {
3350            Some(ctx) => ctx.clone(),
3351            None => {
3352                return IpcResponse::Error {
3353                    message: "daemon package IPC is unavailable: server has no node context".to_owned(),
3354                };
3355            }
3356        };
3357        let manifest = match self.fetch_package_manifest(&context, ticket, hash, node_id).await {
3358            Ok(manifest) => manifest,
3359            Err(error) => return response_from_error(error),
3360        };
3361        IpcResponse::PackageInfo(Box::new(PackageInfoResult::new(
3362            manifest.collection_id.to_string(),
3363            manifest.package.as_ref().map(|package| package.name.clone()),
3364            manifest.version.clone(),
3365            manifest.parent.map(|parent| parent.to_string()),
3366            manifest.entries.len(),
3367        )))
3368    }
3369
3370    async fn handle_package_install(&self, ticket: String) -> IpcResponse {
3371        let context = match &self.archive_context {
3372            Some(ctx) => ctx.clone(),
3373            None => {
3374                return IpcResponse::Error {
3375                    message: "daemon package IPC is unavailable: server has no node context".to_owned(),
3376                };
3377            }
3378        };
3379        let Some(node_db) = self.node_db.clone() else {
3380            return IpcResponse::Error {
3381                message: "daemon package install is unavailable: node database is missing".to_owned(),
3382            };
3383        };
3384        let blob_ticket = match ticket.parse::<iroh_blobs::ticket::BlobTicket>() {
3385            Ok(parsed_ticket) => parsed_ticket,
3386            Err(error) => {
3387                return IpcResponse::Error {
3388                    message: format!("invalid blob ticket: {error}"),
3389                };
3390            }
3391        };
3392        let data_dir = self.daemon_handle.state.read().await.data_dir.clone();
3393        let packages = crate::folder::PackageManager::new(data_dir.join("packages"), node_db);
3394        match packages
3395            .install_from_ticket(&blob_ticket, context.node.endpoint(), context.node.blob_store())
3396            .await
3397        {
3398            Ok(manifest) => IpcResponse::PackageInstall {
3399                collection_id: manifest.collection_id.to_string(),
3400                version: manifest.version,
3401            },
3402            Err(error) => response_from_error(error),
3403        }
3404    }
3405
3406    async fn fetch_package_manifest(
3407        &self,
3408        context: &ArchiveContext,
3409        ticket: Option<String>,
3410        hash: Option<String>,
3411        node_id: Option<String>,
3412    ) -> Result<CollectionManifest> {
3413        if let Some(ticket_text) = ticket {
3414            let blob_ticket = ticket_text
3415                .parse::<iroh_blobs::ticket::BlobTicket>()
3416                .map_err(|error| SyncwebError::InvalidTicket(error.to_string()))?;
3417            if !context.node.blob_store().has(blob_ticket.hash()).await? {
3418                context
3419                    .node
3420                    .blob_store()
3421                    .fetch(context.node.endpoint(), &blob_ticket)
3422                    .await?;
3423            }
3424            let manifest = CollectionManifest::from_bytes(context.node.blob_store().get(blob_ticket.hash()).await?)?;
3425            if manifest.blob_id()? != blob_ticket.hash() {
3426                return Err(SyncwebError::InvalidTicket(
3427                    "manifest ticket hash does not match manifest content".to_owned(),
3428                ));
3429            }
3430            return Ok(manifest);
3431        }
3432        if let (Some(hash_str), Some(node_id_str)) = (hash, node_id) {
3433            let blob_hash = hash_str
3434                .parse::<iroh_blobs::Hash>()
3435                .map_err(|error| SyncwebError::InvalidTicket(error.to_string()))?;
3436            let peer_id = node_id_str
3437                .parse::<iroh::PublicKey>()
3438                .map_err(|error| SyncwebError::InvalidTicket(error.to_string()))?;
3439            let blob_ticket = crate::node::blob_store::raw_blob_ticket(iroh::EndpointAddr::new(peer_id), blob_hash);
3440            if !context.node.blob_store().has(blob_hash).await? {
3441                context
3442                    .node
3443                    .blob_store()
3444                    .fetch(context.node.endpoint(), &blob_ticket)
3445                    .await?;
3446            }
3447            return CollectionManifest::from_bytes(context.node.blob_store().get(blob_hash).await?);
3448        }
3449        Err(SyncwebError::InvalidTicket(
3450            "provide a blob ticket or hash with --node-id".to_owned(),
3451        ))
3452    }
3453
3454    async fn handle_indexing_enable(&self, namespace: String) -> IpcResponse {
3455        let context = match &self.archive_context {
3456            Some(ctx) => ctx.clone(),
3457            None => {
3458                return IpcResponse::Error {
3459                    message: "daemon indexing IPC is unavailable: server has no node context".to_owned(),
3460                };
3461            }
3462        };
3463        let Some(indexing) = context.indexing.clone() else {
3464            return IpcResponse::Error {
3465                message: "daemon indexing IPC is unavailable: indexing service is disabled".to_owned(),
3466            };
3467        };
3468        let namespace_id = match namespace.parse::<iroh_docs::NamespaceId>() {
3469            Ok(id) => id,
3470            Err(error) => {
3471                return IpcResponse::Error {
3472                    message: format!("invalid namespace: {error}"),
3473                };
3474            }
3475        };
3476        let manager = FolderManager::new(&context.node);
3477        let folder = match manager.get(namespace_id).await {
3478            Ok(folder) => folder,
3479            Err(error) => return response_from_error(error),
3480        };
3481        match indexing.enable_folder(&folder).await {
3482            Ok(handle) => IpcResponse::Ok {
3483                message: format!("enabled: {}", handle.namespace_id()),
3484            },
3485            Err(error) => response_from_error(error),
3486        }
3487    }
3488
3489    async fn handle_indexing_publish(&self, namespace: String, catalog: String, tags: Vec<String>) -> IpcResponse {
3490        let context = match &self.archive_context {
3491            Some(ctx) => ctx.clone(),
3492            None => {
3493                return IpcResponse::Error {
3494                    message: "daemon indexing IPC is unavailable: server has no node context".to_owned(),
3495                };
3496            }
3497        };
3498        let Some(indexing) = context.indexing.clone() else {
3499            return IpcResponse::Error {
3500                message: "daemon indexing IPC is unavailable: indexing service is disabled".to_owned(),
3501            };
3502        };
3503        let namespace_id = match namespace.parse::<iroh_docs::NamespaceId>() {
3504            Ok(id) => id,
3505            Err(error) => {
3506                return IpcResponse::Error {
3507                    message: format!("invalid namespace: {error}"),
3508                };
3509            }
3510        };
3511        let manager = FolderManager::new(&context.node);
3512        let folder = match manager.get(namespace_id).await {
3513            Ok(folder) => folder,
3514            Err(error) => return response_from_error(error),
3515        };
3516        let publish_result = async {
3517            indexing.enable_folder(&folder).await?;
3518            let author = context.node.docs_engine().author().await?;
3519            let catalog_service =
3520                indexing.catalog_service(context.node.docs_engine(), context.node.blob_store(), author);
3521            let catalog_handle = catalog_service.get_or_create_catalog(&catalog).await?;
3522            let published = catalog_service
3523                .publish_folder_with_metadata(&catalog_handle, &folder, namespace, &tags)
3524                .await?;
3525            let ticket = catalog_service.ticket(&catalog_handle, false).await?;
3526            Ok::<_, SyncwebError>((published, catalog_handle.namespace_id().to_string(), ticket.to_string()))
3527        }
3528        .await;
3529        match publish_result {
3530            Ok((published, catalog_namespace, ticket)) => IpcResponse::Ok {
3531                message: format!(
3532                    "published: {published}\ncatalog: {catalog}\nnamespace: {catalog_namespace}\nticket: {ticket}"
3533                ),
3534            },
3535            Err(error) => response_from_error(error),
3536        }
3537    }
3538
3539    #[cfg(unix)]
3540    async fn handle_connection(&self, stream: tokio::net::UnixStream) -> Result<()> {
3541        use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
3542        let (read_half, mut write_half) = stream.into_split();
3543        let mut line = Vec::new();
3544        BufReader::new(read_half).read_until(b'\n', &mut line).await?;
3545        let response = match serde_json::from_slice::<IpcRequest>(line.trim_ascii()) {
3546            Ok(request) => Box::pin(self.handle_request(request)).await,
3547            Err(error) => IpcResponse::Error {
3548                message: format!("invalid daemon request: {error}"),
3549            },
3550        };
3551        let bytes = encode_ipc_response(&response)?;
3552        write_half.write_all(&bytes).await?;
3553        Ok(())
3554    }
3555
3556    async fn latest_collection_head(node: &IrohNode, doc: &iroh_docs::api::Doc) -> Result<CollectionHead> {
3557        let entries = node.docs_engine().list_latest(doc).await?;
3558        let head_entry = entries
3559            .iter()
3560            .find(|entry| entry.key().starts_with(b"collections/") && entry.key().ends_with(b"/head"))
3561            .ok_or_else(|| SyncwebError::InvalidConfig("folder has no published collection head".to_owned()))?;
3562        let bytes = node.blob_store().get(head_entry.content_hash()).await?;
3563        let head = serde_json::from_slice(&bytes)
3564            .map_err(|error| SyncwebError::operation("failed to deserialize collection head", error))?;
3565        Ok(head)
3566    }
3567
3568    async fn collection_manifests(
3569        node: &IrohNode,
3570        doc: &iroh_docs::api::Doc,
3571        head: CollectionHead,
3572    ) -> Result<Vec<CollectionManifest>> {
3573        let prefix = format!("collections/{}/manifests/", head.collection_id);
3574        let entries = node.docs_engine().list_latest(doc).await?;
3575        let mut manifests = Vec::new();
3576        for entry in entries {
3577            if !entry.key().starts_with(prefix.as_bytes()) {
3578                continue;
3579            }
3580            let bytes = node.blob_store().get(entry.content_hash()).await?;
3581            let manifest = CollectionManifest::from_bytes(bytes)?;
3582            manifests.push(manifest);
3583        }
3584        if manifests.is_empty() {
3585            return Err(SyncwebError::InvalidConfig(
3586                "folder has no published collection manifests".to_owned(),
3587            ));
3588        }
3589        Ok(manifests)
3590    }
3591}
3592
3593fn response_from_error(error: impl std::fmt::Display) -> IpcResponse {
3594    IpcResponse::Error {
3595        message: error.to_string(),
3596    }
3597}
3598
3599/// Whether two folder mount paths refer to the same directory.
3600///
3601/// Mount paths are stored exactly as the user typed them (`./shared` vs
3602/// `shared`), so compare canonicalized absolute forms and fall back to a plain
3603/// equality when a path does not exist on disk yet.
3604fn same_absolute_path(left: &Path, right: &Path) -> bool {
3605    let canonical = |path: &Path| {
3606        std::fs::canonicalize(path).unwrap_or_else(|_| {
3607            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
3608            if path.is_absolute() {
3609                path.to_path_buf()
3610            } else {
3611                cwd.join(path)
3612            }
3613        })
3614    };
3615    canonical(left) == canonical(right)
3616}
3617
3618fn build_ipc_verify_filter(
3619    hash: &[String],
3620    path_filter: Option<&String>,
3621    glob_filter: Option<&String>,
3622) -> Option<crate::verify::VerifyFilter> {
3623    let has_filter = !hash.is_empty() || path_filter.is_some() || glob_filter.is_some();
3624    if !has_filter {
3625        return None;
3626    }
3627    let mut filter = crate::verify::VerifyFilter::new();
3628    if !hash.is_empty() {
3629        let hashes: Vec<iroh_blobs::Hash> = hash.iter().filter_map(|h| h.parse::<iroh_blobs::Hash>().ok()).collect();
3630        if !hashes.is_empty() {
3631            filter = filter.with_hashes(hashes);
3632        }
3633    }
3634    if let Some(p) = path_filter {
3635        filter = filter.with_path(std::path::PathBuf::from(p));
3636    }
3637    if let Some(g) = glob_filter {
3638        filter.glob = Some(g.clone());
3639    }
3640    Some(filter)
3641}
3642
3643async fn resolve_folder_for_daemon(
3644    manager: &FolderManager,
3645    registry: &FolderRegistry,
3646    selector: &Path,
3647) -> std::result::Result<crate::folder::SyncwebFolder, IpcResponse> {
3648    // A selector that names a managed mount path must resolve through the
3649    // folder registry: `FolderManager` only knows namespaces, so a path
3650    // selector fails whenever more than one folder is managed.
3651    if selector.to_string_lossy().parse::<iroh_docs::NamespaceId>().is_err()
3652        && let Some(namespace) = registry.namespace_for_path(selector)
3653        && let Ok(namespace_id) = namespace.parse::<iroh_docs::NamespaceId>()
3654    {
3655        return manager.get(namespace_id).await.map_err(|error| IpcResponse::Error {
3656            message: error.to_string(),
3657        });
3658    }
3659    manager.resolve(selector).await.map_err(|error| IpcResponse::Error {
3660        message: error.to_string(),
3661    })
3662}
3663
3664/// A client for sending requests to the daemon.
3665#[derive(Clone, Debug)]
3666pub struct IpcClient {
3667    socket_path: PathBuf,
3668}
3669
3670impl IpcClient {
3671    #[must_use]
3672    pub fn new(data_dir: &Path) -> Self {
3673        Self::from_socket_path(daemon_socket_path(data_dir))
3674    }
3675
3676    #[must_use]
3677    pub const fn from_socket_path(socket_path: PathBuf) -> Self {
3678        Self { socket_path }
3679    }
3680
3681    #[must_use]
3682    pub fn socket_path(&self) -> &Path {
3683        &self.socket_path
3684    }
3685
3686    /// Send one newline-delimited JSON request and await its response.
3687    ///
3688    /// # Errors
3689    ///
3690    /// Returns an error when the socket is unavailable, the operation times
3691    /// out, or either JSON message is malformed.
3692    pub async fn send(&self, request: IpcRequest) -> Result<IpcResponse> {
3693        #[cfg(unix)]
3694        {
3695            let mut transport = UnixSocketTransport::new(self.socket_path.clone());
3696            transport.send_request(&request).await?;
3697            transport.recv_response().await
3698        }
3699        #[cfg(not(unix))]
3700        {
3701            let _ = request;
3702            Err(SyncwebError::operation(
3703                "daemon IPC is unavailable",
3704                "Unix sockets are not supported on this platform",
3705            ))
3706        }
3707    }
3708
3709    async fn request(&self, command: IpcCommand, operation: &str) -> Result<IpcResponse> {
3710        let response = self.send(IpcRequest::new(command)).await?;
3711        if let IpcResponse::Error { message } = response {
3712            Err(SyncwebError::operation(
3713                format!("daemon {operation} request failed"),
3714                message,
3715            ))
3716        } else {
3717            Ok(response)
3718        }
3719    }
3720
3721    fn unexpected_response(operation: &str, response: &IpcResponse) -> SyncwebError {
3722        SyncwebError::operation(
3723            format!("daemon {operation} request returned an unexpected response"),
3724            format!("{response:?}"),
3725        )
3726    }
3727
3728    fn expect_message(operation: &str, response: IpcResponse) -> Result<String> {
3729        if let IpcResponse::Ok { message } = response {
3730            Ok(message)
3731        } else {
3732            Err(Self::unexpected_response(operation, &response))
3733        }
3734    }
3735
3736    /// Request the daemon status.
3737    ///
3738    /// # Errors
3739    ///
3740    /// Returns an error if the daemon request fails or returns an unexpected response.
3741    pub async fn status(&self) -> Result<DaemonStatus> {
3742        let response = self.request(IpcCommand::Status, "status").await?;
3743        if let IpcResponse::Status(status) = response {
3744            Ok(status)
3745        } else {
3746            Err(Self::unexpected_response("status", &response))
3747        }
3748    }
3749
3750    /// List all folders and public subscriptions managed by the daemon.
3751    ///
3752    /// # Errors
3753    ///
3754    /// Returns an error if the daemon request fails or returns an unexpected response.
3755    pub async fn list_folders(&self) -> Result<Vec<FolderStatus>> {
3756        let response = self.request(IpcCommand::ListFolders, "list folders").await?;
3757        if let IpcResponse::FolderList(folders) = response {
3758            Ok(folders)
3759        } else {
3760            Err(Self::unexpected_response("list folders", &response))
3761        }
3762    }
3763
3764    /// Register a folder with the daemon.
3765    ///
3766    /// # Errors
3767    ///
3768    /// Returns an error if the daemon request fails or returns an unexpected response.
3769    pub async fn add_folder(&self, namespace: String, path: PathBuf) -> Result<String> {
3770        Self::expect_message(
3771            "add folder",
3772            self.request(IpcCommand::AddFolder { namespace, path }, "add folder")
3773                .await?,
3774        )
3775    }
3776
3777    /// Register a folder with the daemon for continuous watching, including
3778    /// the filesystem exclude globs and optional per-folder filter override.
3779    ///
3780    /// # Errors
3781    ///
3782    /// Returns an error if the daemon request fails or returns an unexpected response.
3783    pub async fn watch_start(
3784        &self,
3785        namespace: String,
3786        path: PathBuf,
3787        exclude: Vec<String>,
3788        filter: Option<FilterConfig>,
3789    ) -> Result<String> {
3790        Self::expect_message(
3791            "watch start",
3792            self.request(
3793                IpcCommand::WatchStart {
3794                    namespace,
3795                    path,
3796                    exclude,
3797                    filter,
3798                },
3799                "watch start",
3800            )
3801            .await?,
3802        )
3803    }
3804
3805    /// Ask the daemon to trigger synchronization.
3806    ///
3807    /// # Errors
3808    ///
3809    /// Returns an error if the daemon request fails or returns an unexpected response.
3810    pub async fn trigger_sync(&self, namespace: Option<String>) -> Result<String> {
3811        Self::expect_message(
3812            "trigger sync",
3813            self.request(IpcCommand::TriggerSync { namespace }, "trigger sync")
3814                .await?,
3815        )
3816    }
3817
3818    /// Set the daemon log level.
3819    ///
3820    /// # Errors
3821    ///
3822    /// Returns an error if the daemon request fails or returns an unexpected response.
3823    pub async fn set_log_level(&self, level: String) -> Result<String> {
3824        Self::expect_message(
3825            "set log level",
3826            self.request(IpcCommand::SetLogLevel { level }, "set log level").await?,
3827        )
3828    }
3829
3830    /// Ask the daemon to reload its configuration.
3831    ///
3832    /// # Errors
3833    ///
3834    /// Returns an error if the daemon request fails or returns an unexpected response.
3835    pub async fn reload_config(&self) -> Result<String> {
3836        Self::expect_message(
3837            "reload config",
3838            self.request(IpcCommand::ReloadConfig, "reload config").await?,
3839        )
3840    }
3841
3842    /// Ask the daemon to shut down.
3843    ///
3844    /// # Errors
3845    ///
3846    /// Returns an error if the daemon request fails or returns an unexpected response.
3847    pub async fn shutdown(&self, force: bool) -> Result<String> {
3848        Self::expect_message(
3849            "shutdown",
3850            self.request(IpcCommand::Shutdown { force }, "shutdown").await?,
3851        )
3852    }
3853
3854    /// Download selected folder content and return the transferred byte count.
3855    ///
3856    /// # Errors
3857    ///
3858    /// Returns an error if the daemon request fails or returns an unexpected response.
3859    pub async fn download(&self, namespace: String, strategy: FetchStrategy) -> Result<u64> {
3860        let response = self
3861            .request(IpcCommand::Download { namespace, strategy }, "download")
3862            .await?;
3863        if let IpcResponse::DownloadComplete { bytes_transferred } = response {
3864            Ok(bytes_transferred)
3865        } else {
3866            Err(Self::unexpected_response("download", &response))
3867        }
3868    }
3869
3870    /// Materialize pending transfer jobs and return completed and failed counts.
3871    ///
3872    /// # Errors
3873    ///
3874    /// Returns an error if the daemon request fails or returns an unexpected response.
3875    pub async fn materialize_transfers(&self, namespace: Option<String>) -> Result<(u64, u64)> {
3876        let response = self
3877            .request(IpcCommand::MaterializeTransfers { namespace }, "materialize transfers")
3878            .await?;
3879        if let IpcResponse::TransferJobsProcessed { completed, failed } = response {
3880            Ok((completed, failed))
3881        } else {
3882            Err(Self::unexpected_response("materialize transfers", &response))
3883        }
3884    }
3885
3886    /// Import files into a managed folder and return the imported entry count.
3887    ///
3888    /// # Errors
3889    ///
3890    /// Returns an error if the daemon request fails or returns an unexpected response.
3891    pub async fn import_files(&self, namespace: Option<String>, path: PathBuf) -> Result<u64> {
3892        let response = self
3893            .request(IpcCommand::ImportFiles { namespace, path }, "import files")
3894            .await?;
3895        if let IpcResponse::ImportFilesComplete { entries } = response {
3896            Ok(entries)
3897        } else {
3898            Err(Self::unexpected_response("import files", &response))
3899        }
3900    }
3901
3902    /// One-shot filtered scan/import for `watch --once`. Returns the imported
3903    /// entry count and the folder-relative paths rejected by the filter.
3904    ///
3905    /// # Errors
3906    ///
3907    /// Returns an error when the daemon request fails or returns an unexpected
3908    /// response.
3909    pub async fn watch_once(
3910        &self,
3911        namespace: Option<String>,
3912        path: PathBuf,
3913        filter: Option<FilterConfig>,
3914        exclude: Vec<String>,
3915    ) -> Result<(u64, Vec<String>)> {
3916        let response = self
3917            .request(
3918                IpcCommand::WatchOnce {
3919                    namespace,
3920                    path,
3921                    filter,
3922                    exclude,
3923                },
3924                "watch once",
3925            )
3926            .await?;
3927        if let IpcResponse::WatchOnceComplete { imported, rejected } = response {
3928            Ok((imported, rejected))
3929        } else {
3930            Err(Self::unexpected_response("watch once", &response))
3931        }
3932    }
3933
3934    /// Fetch and pin a content blob through the daemon and return its hash.
3935    ///
3936    /// # Errors
3937    ///
3938    /// Returns an error when the daemon request fails or returns an unexpected
3939    /// response.
3940    pub async fn fetch_blob(&self, hash: String, tickets: Vec<String>, export_path: Option<PathBuf>) -> Result<String> {
3941        let response = self
3942            .request(
3943                IpcCommand::FetchBlob {
3944                    hash,
3945                    tickets,
3946                    export_path,
3947                },
3948                "fetch blob",
3949            )
3950            .await?;
3951        if let IpcResponse::FetchBlobComplete { hash: fetched } = response {
3952            Ok(fetched)
3953        } else {
3954            Err(Self::unexpected_response("fetch blob", &response))
3955        }
3956    }
3957
3958    /// Publish a stable link into a managed folder through the daemon.
3959    ///
3960    /// # Errors
3961    ///
3962    /// Returns an error when the daemon request fails or returns an unexpected
3963    /// response.
3964    pub async fn link_publish(&self, namespace: String, link: String) -> Result<String> {
3965        Self::expect_message(
3966            "link publish",
3967            self.request(IpcCommand::LinkPublish { namespace, link }, "link publish")
3968                .await?,
3969        )
3970    }
3971
3972    /// Import an archive and return its resulting collection.
3973    ///
3974    /// # Errors
3975    ///
3976    /// Returns an error if the daemon request fails or returns an unexpected response.
3977    pub async fn import_archive(
3978        &self,
3979        input: PathBuf,
3980        target: PathBuf,
3981        filter: Option<FilterConfig>,
3982    ) -> Result<DropImportResult> {
3983        let response = self
3984            .request(IpcCommand::ImportArchive { input, target, filter }, "import archive")
3985            .await?;
3986        if let IpcResponse::ImportComplete(result) = response {
3987            Ok(*result)
3988        } else {
3989            Err(Self::unexpected_response("import archive", &response))
3990        }
3991    }
3992
3993    /// Export a managed folder as an archive.
3994    ///
3995    /// # Errors
3996    ///
3997    /// Returns an error if the daemon request fails or returns an unexpected response.
3998    pub async fn export_archive(
3999        &self,
4000        namespace: String,
4001        version: Option<String>,
4002        output: PathBuf,
4003    ) -> Result<DropExportResult> {
4004        let response = self
4005            .request(
4006                IpcCommand::ExportArchive {
4007                    namespace,
4008                    version,
4009                    output,
4010                },
4011                "export archive",
4012            )
4013            .await?;
4014        if let IpcResponse::ExportComplete(result) = response {
4015            Ok(*result)
4016        } else {
4017            Err(Self::unexpected_response("export archive", &response))
4018        }
4019    }
4020
4021    /// Export a collection found in a daemon-managed folder as an archive.
4022    ///
4023    /// # Errors
4024    ///
4025    /// Returns an error when the daemon request fails or returns an unexpected
4026    /// response.
4027    pub async fn export_collection_archive(
4028        &self,
4029        collection: String,
4030        version: Option<String>,
4031        output: PathBuf,
4032        filter: Option<FilterConfig>,
4033    ) -> Result<DropExportResult> {
4034        let response = self
4035            .request(
4036                IpcCommand::ExportCollectionArchive {
4037                    collection,
4038                    version,
4039                    output,
4040                    filter,
4041                },
4042                "export collection archive",
4043            )
4044            .await?;
4045        if let IpcResponse::ExportComplete(result) = response {
4046            Ok(*result)
4047        } else {
4048            Err(Self::unexpected_response("export collection archive", &response))
4049        }
4050    }
4051
4052    /// Join a folder from a ticket.
4053    ///
4054    /// # Errors
4055    ///
4056    /// Returns an error if the daemon request fails or returns an unexpected response.
4057    pub async fn join(
4058        &self,
4059        ticket: String,
4060        path: PathBuf,
4061        mode: SyncMode,
4062        filters: SubscribeFilters,
4063        options: JoinOptions,
4064    ) -> Result<String> {
4065        Self::expect_message(
4066            "join",
4067            self.request(
4068                IpcCommand::Join {
4069                    ticket,
4070                    path,
4071                    mode,
4072                    subscribe: options.subscribe,
4073                    filters,
4074                    download: options.download,
4075                    indexing: options.indexing,
4076                    metadata_only: options.metadata_only,
4077                },
4078                "join",
4079            )
4080            .await?,
4081        )
4082    }
4083
4084    /// Enable or disable subscription for a managed folder.
4085    ///
4086    /// # Errors
4087    ///
4088    /// Returns an error if the daemon request fails or returns an unexpected response.
4089    pub async fn set_subscribe(
4090        &self,
4091        namespace: String,
4092        enabled: bool,
4093        filters: Option<SubscribeFilters>,
4094    ) -> Result<String> {
4095        Self::expect_message(
4096            "set subscribe",
4097            self.request(
4098                IpcCommand::SetSubscribe {
4099                    namespace,
4100                    enabled,
4101                    filters,
4102                },
4103                "set subscribe",
4104            )
4105            .await?,
4106        )
4107    }
4108
4109    /// Subscribe to a public blob ticket.
4110    ///
4111    /// # Errors
4112    ///
4113    /// Returns an error if the daemon request fails or returns an unexpected response.
4114    pub async fn subscribe_public(&self, ticket: String) -> Result<String> {
4115        Self::expect_message(
4116            "subscribe public",
4117            self.request(IpcCommand::SubscribePublic { ticket }, "subscribe public")
4118                .await?,
4119        )
4120    }
4121
4122    /// Create a managed folder and return the daemon's creation message.
4123    ///
4124    /// # Errors
4125    ///
4126    /// Returns an error if the daemon request fails or returns an unexpected response.
4127    pub async fn create_folder(&self, path: PathBuf, mode: String, indexing: bool) -> Result<String> {
4128        Self::expect_message(
4129            "create folder",
4130            self.request(IpcCommand::CreateFolder { path, mode, indexing }, "create folder")
4131                .await?,
4132        )
4133    }
4134
4135    /// Collect file statistics for a managed folder.
4136    ///
4137    /// # Errors
4138    ///
4139    /// Returns an error if the daemon request fails or returns an unexpected response.
4140    pub async fn stats_files(&self, folder: PathBuf) -> Result<FileStatsReport> {
4141        let response = self.request(IpcCommand::StatsFiles { folder }, "stats files").await?;
4142        if let IpcResponse::FileStats(report) = response {
4143            Ok(*report)
4144        } else {
4145            Err(Self::unexpected_response("stats files", &response))
4146        }
4147    }
4148
4149    /// List the metadata entries in a managed folder.
4150    ///
4151    /// # Errors
4152    ///
4153    /// Returns an error if the daemon request fails or returns an unexpected response.
4154    pub async fn list_entries(&self, folder: String, enrich: bool) -> Result<Vec<EntryRow>> {
4155        let response = self
4156            .request(IpcCommand::ListEntries { folder, enrich }, "list entries")
4157            .await?;
4158        if let IpcResponse::Entries(entries) = response {
4159            Ok(entries)
4160        } else {
4161            Err(Self::unexpected_response("list entries", &response))
4162        }
4163    }
4164
4165    /// Verify the integrity of a managed folder.
4166    ///
4167    /// # Errors
4168    ///
4169    /// Returns an error if the daemon request fails or returns an unexpected response.
4170    pub async fn verify_integrity(
4171        &self,
4172        path: PathBuf,
4173        hash: Vec<String>,
4174        path_filter: Option<String>,
4175        glob_filter: Option<String>,
4176        fix: bool,
4177        from: Vec<String>,
4178    ) -> Result<String> {
4179        Self::expect_message(
4180            "verify integrity",
4181            self.request(
4182                IpcCommand::VerifyIntegrity {
4183                    path,
4184                    hash,
4185                    path_filter,
4186                    glob_filter,
4187                    fix,
4188                    from,
4189                },
4190                "verify integrity",
4191            )
4192            .await?,
4193        )
4194    }
4195
4196    /// Remove a public blob subscription.
4197    ///
4198    /// # Errors
4199    ///
4200    /// Returns an error if the daemon request fails or returns an unexpected response.
4201    pub async fn unsubscribe(&self, namespace: String) -> Result<String> {
4202        Self::expect_message(
4203            "unsubscribe",
4204            self.request(IpcCommand::Unsubscribe { namespace }, "unsubscribe")
4205                .await?,
4206        )
4207    }
4208
4209    /// Leave a managed folder.
4210    ///
4211    /// # Errors
4212    ///
4213    /// Returns an error if the daemon request fails or returns an unexpected response.
4214    pub async fn leave_folder(&self, namespace: String, delete_files: bool) -> Result<String> {
4215        Self::expect_message(
4216            "leave folder",
4217            self.request(
4218                IpcCommand::LeaveFolder {
4219                    namespace,
4220                    delete_files,
4221                },
4222                "leave folder",
4223            )
4224            .await?,
4225        )
4226    }
4227
4228    /// Share a folder or one of its blobs and return the resulting message.
4229    ///
4230    /// # Errors
4231    ///
4232    /// Returns an error if the daemon request fails or returns an unexpected response.
4233    pub async fn share(&self, namespace: String, blob: Option<String>, options: ShareOptions) -> Result<String> {
4234        Self::expect_message(
4235            "share",
4236            self.request(
4237                IpcCommand::Share {
4238                    namespace,
4239                    blob,
4240                    writable: options.writable,
4241                    pin: options.pin,
4242                    persist: options.persist,
4243                    addr_info: options.addr_info,
4244                },
4245                "share",
4246            )
4247            .await?,
4248        )
4249    }
4250
4251    /// List persisted shares.
4252    ///
4253    /// # Errors
4254    ///
4255    /// Returns an error if the daemon request fails or returns an unexpected response.
4256    pub async fn share_list(&self) -> Result<String> {
4257        Self::expect_message("share list", self.request(IpcCommand::ShareList, "share list").await?)
4258    }
4259
4260    /// Remove a folder share or one of its blob shares.
4261    ///
4262    /// # Errors
4263    ///
4264    /// Returns an error if the daemon request fails or returns an unexpected response.
4265    pub async fn unshare(&self, namespace: String, blob: Option<String>, writable: bool) -> Result<String> {
4266        Self::expect_message(
4267            "unshare",
4268            self.request(
4269                IpcCommand::Unshare {
4270                    namespace,
4271                    blob,
4272                    writable,
4273                },
4274                "unshare",
4275            )
4276            .await?,
4277        )
4278    }
4279
4280    /// Create a snapshot and return its identifier message.
4281    ///
4282    /// # Errors
4283    ///
4284    /// Returns an error if the daemon request fails or returns an unexpected response.
4285    pub async fn snapshot_create(&self, path: PathBuf, description: Option<String>, threads: usize) -> Result<String> {
4286        Self::expect_message(
4287            "snapshot create",
4288            self.request(
4289                IpcCommand::SnapshotCreate {
4290                    path,
4291                    description,
4292                    threads,
4293                },
4294                "snapshot create",
4295            )
4296            .await?,
4297        )
4298    }
4299
4300    /// List snapshots for a folder.
4301    ///
4302    /// # Errors
4303    ///
4304    /// Returns an error if the daemon request fails or returns an unexpected response.
4305    pub async fn snapshot_list(&self, path: PathBuf) -> Result<Vec<SnapshotInfo>> {
4306        let response = self.request(IpcCommand::SnapshotList { path }, "snapshot list").await?;
4307        if let IpcResponse::SnapshotList(snapshots) = response {
4308            Ok(snapshots)
4309        } else {
4310            Err(Self::unexpected_response("snapshot list", &response))
4311        }
4312    }
4313
4314    /// Delete a snapshot.
4315    ///
4316    /// # Errors
4317    ///
4318    /// Returns an error if the daemon request fails or returns an unexpected response.
4319    pub async fn snapshot_delete(&self, id: String) -> Result<String> {
4320        Self::expect_message(
4321            "snapshot delete",
4322            self.request(IpcCommand::SnapshotDelete { id }, "snapshot delete")
4323                .await?,
4324        )
4325    }
4326
4327    /// Compare two snapshots.
4328    ///
4329    /// # Errors
4330    ///
4331    /// Returns an error if the daemon request fails or returns an unexpected response.
4332    pub async fn snapshot_diff(&self, path: PathBuf, first: String, second: String) -> Result<SnapshotDiffReport> {
4333        let response = self
4334            .request(IpcCommand::SnapshotDiff { path, first, second }, "snapshot diff")
4335            .await?;
4336        if let IpcResponse::SnapshotDiff(report) = response {
4337            Ok(*report)
4338        } else {
4339            Err(Self::unexpected_response("snapshot diff", &response))
4340        }
4341    }
4342
4343    /// Restore a snapshot.
4344    ///
4345    /// # Errors
4346    ///
4347    /// Returns an error if the daemon request fails or returns an unexpected response.
4348    pub async fn snapshot_restore(&self, path: PathBuf, snapshot: String) -> Result<String> {
4349        Self::expect_message(
4350            "snapshot restore",
4351            self.request(IpcCommand::SnapshotRestore { path, snapshot }, "snapshot restore")
4352                .await?,
4353        )
4354    }
4355
4356    /// Publish a collection and return the daemon's result message.
4357    ///
4358    /// # Errors
4359    ///
4360    /// Returns an error if the daemon request fails or returns an unexpected response.
4361    pub async fn collection_publish(
4362        &self,
4363        path: PathBuf,
4364        namespace: String,
4365        sequence: u64,
4366        bootstrap: Vec<String>,
4367        manifest_bytes: Option<Vec<u8>>,
4368    ) -> Result<String> {
4369        Self::expect_message(
4370            "collection publish",
4371            self.request(
4372                IpcCommand::CollectionPublish {
4373                    path,
4374                    namespace,
4375                    sequence,
4376                    bootstrap,
4377                    manifest_bytes,
4378                },
4379                "collection publish",
4380            )
4381            .await?,
4382        )
4383    }
4384
4385    /// Return peer enrichment counts for a folder.
4386    ///
4387    /// # Errors
4388    ///
4389    /// Returns an error if the daemon request fails or returns an unexpected response.
4390    pub async fn enrich_sort(&self, path: PathBuf) -> Result<HashMap<String, usize>> {
4391        let response = self.request(IpcCommand::EnrichSort { path }, "enrich sort").await?;
4392        if let IpcResponse::EnrichData(data) = response {
4393            Ok(data)
4394        } else {
4395            Err(Self::unexpected_response("enrich sort", &response))
4396        }
4397    }
4398
4399    /// Invite a device to a network.
4400    ///
4401    /// # Errors
4402    ///
4403    /// Returns an error if the daemon request fails or returns an unexpected response.
4404    pub async fn network_invite(&self, network_id: String, device: String) -> Result<String> {
4405        Self::expect_message(
4406            "network invite",
4407            self.request(IpcCommand::NetworkInvite { network_id, device }, "network invite")
4408                .await?,
4409        )
4410    }
4411
4412    /// Remove a device from a network.
4413    ///
4414    /// # Errors
4415    ///
4416    /// Returns an error if the daemon request fails or returns an unexpected response.
4417    pub async fn network_kick(&self, network_id: String, device: String) -> Result<String> {
4418        Self::expect_message(
4419            "network kick",
4420            self.request(IpcCommand::NetworkKick { network_id, device }, "network kick")
4421                .await?,
4422        )
4423    }
4424
4425    /// Leave a network.
4426    ///
4427    /// # Errors
4428    ///
4429    /// Returns an error if the daemon request fails or returns an unexpected response.
4430    pub async fn network_leave(&self, network_id: String) -> Result<String> {
4431        Self::expect_message(
4432            "network leave",
4433            self.request(IpcCommand::NetworkLeave { network_id }, "network leave")
4434                .await?,
4435        )
4436    }
4437
4438    /// Create a network and return its structured identity.
4439    ///
4440    /// # Errors
4441    ///
4442    /// Returns an error if the daemon request fails or returns an unexpected response.
4443    pub async fn network_create(
4444        &self,
4445        name: String,
4446        label: String,
4447        invite_only: bool,
4448        doc_ticket: Option<String>,
4449    ) -> Result<(String, String)> {
4450        let response = self
4451            .request(
4452                IpcCommand::NetworkCreate {
4453                    name,
4454                    label,
4455                    invite_only,
4456                    doc_ticket,
4457                },
4458                "network create",
4459            )
4460            .await?;
4461        if let IpcResponse::NetworkCreated { name: created_name, id } = response {
4462            Ok((created_name, id))
4463        } else {
4464            Err(Self::unexpected_response("network create", &response))
4465        }
4466    }
4467
4468    /// Join a network from an invite ticket.
4469    ///
4470    /// # Errors
4471    ///
4472    /// Returns an error if the daemon request fails or returns an unexpected response.
4473    pub async fn network_join(&self, ticket: String) -> Result<String> {
4474        Self::expect_message(
4475            "network join",
4476            self.request(IpcCommand::NetworkJoin { ticket }, "network join").await?,
4477        )
4478    }
4479
4480    /// Report peer availability for a folder.
4481    ///
4482    /// # Errors
4483    ///
4484    /// Returns an error if the daemon request fails or returns an unexpected response.
4485    pub async fn peer_availability(&self, folder: String) -> Result<PeerAvailabilityReport> {
4486        let response = self
4487            .request(IpcCommand::PeerAvailability { folder }, "peer availability")
4488            .await?;
4489        if let IpcResponse::PeerAvailability(report) = response {
4490            Ok(*report)
4491        } else {
4492            Err(Self::unexpected_response("peer availability", &response))
4493        }
4494    }
4495
4496    /// Inspect a package manifest.
4497    ///
4498    /// # Errors
4499    ///
4500    /// Returns an error if the daemon request fails or returns an unexpected response.
4501    pub async fn package_info(
4502        &self,
4503        ticket: Option<String>,
4504        hash: Option<String>,
4505        node_id: Option<String>,
4506    ) -> Result<PackageInfoResult> {
4507        let response = self
4508            .request(IpcCommand::PackageInfo { ticket, hash, node_id }, "package info")
4509            .await?;
4510        if let IpcResponse::PackageInfo(info) = response {
4511            Ok(*info)
4512        } else {
4513            Err(Self::unexpected_response("package info", &response))
4514        }
4515    }
4516
4517    /// Install a package and return its collection identity.
4518    ///
4519    /// # Errors
4520    ///
4521    /// Returns an error if the daemon request fails or returns an unexpected response.
4522    pub async fn package_install(&self, ticket: String) -> Result<(String, String)> {
4523        let response = self
4524            .request(IpcCommand::PackageInstall { ticket }, "package install")
4525            .await?;
4526        if let IpcResponse::PackageInstall { collection_id, version } = response {
4527            Ok((collection_id, version))
4528        } else {
4529            Err(Self::unexpected_response("package install", &response))
4530        }
4531    }
4532
4533    /// Enable indexing for a folder.
4534    ///
4535    /// # Errors
4536    ///
4537    /// Returns an error if the daemon request fails or returns an unexpected response.
4538    pub async fn indexing_enable(&self, namespace: String) -> Result<String> {
4539        Self::expect_message(
4540            "indexing enable",
4541            self.request(IpcCommand::IndexingEnable { namespace }, "indexing enable")
4542                .await?,
4543        )
4544    }
4545
4546    /// Publish an indexing catalog and return the daemon's result message.
4547    ///
4548    /// # Errors
4549    ///
4550    /// Returns an error if the daemon request fails or returns an unexpected response.
4551    pub async fn indexing_publish(&self, namespace: String, catalog: String, tags: Vec<String>) -> Result<String> {
4552        Self::expect_message(
4553            "indexing publish",
4554            self.request(
4555                IpcCommand::IndexingPublish {
4556                    namespace,
4557                    catalog,
4558                    tags,
4559                },
4560                "indexing publish",
4561            )
4562            .await?,
4563        )
4564    }
4565
4566    /// Perform a bounded synchronous status probe for routing decisions.
4567    ///
4568    /// # Errors
4569    ///
4570    /// Returns an error when the daemon does not answer a status request.
4571    pub fn status_sync(&self) -> Result<DaemonStatus> {
4572        #[cfg(unix)]
4573        {
4574            use std::{
4575                io::{BufRead, Write},
4576                os::unix::net::UnixStream,
4577            };
4578
4579            let stream = UnixStream::connect(&self.socket_path).map_err(|error| {
4580                SyncwebError::operation(
4581                    format!("failed to connect to daemon socket at {}", self.socket_path.display()),
4582                    error,
4583                )
4584            })?;
4585            stream.set_read_timeout(Some(IPC_TIMEOUT))?;
4586            stream.set_write_timeout(Some(IPC_TIMEOUT))?;
4587            let mut writer = stream.try_clone()?;
4588            let request = serde_json::to_vec(&IpcRequest::new(IpcCommand::Status))
4589                .map_err(|error| SyncwebError::operation("failed to serialize IPC request", error))?;
4590            writer.write_all(&request)?;
4591            writer.write_all(b"\n")?;
4592            let mut line = String::new();
4593            std::io::BufReader::new(stream).read_line(&mut line)?;
4594            match serde_json::from_str::<IpcResponse>(&line)
4595                .map_err(|error| SyncwebError::operation("failed to deserialize IPC response", error))?
4596            {
4597                IpcResponse::Status(status) => Ok(status),
4598                IpcResponse::Error { message } => Err(SyncwebError::operation("daemon status request failed", message)),
4599                IpcResponse::Ok { .. }
4600                | IpcResponse::FolderList(_)
4601                | IpcResponse::DownloadComplete { .. }
4602                | IpcResponse::ImportFilesComplete { .. }
4603                | IpcResponse::WatchOnceComplete { .. }
4604                | IpcResponse::FetchBlobComplete { .. }
4605                | IpcResponse::ImportComplete(_)
4606                | IpcResponse::ExportComplete(_)
4607                | IpcResponse::EnrichData(_)
4608                | IpcResponse::FileStats(_)
4609                | IpcResponse::Entries(_)
4610                | IpcResponse::PeerAvailability(_)
4611                | IpcResponse::SnapshotList(_)
4612                | IpcResponse::SnapshotDiff(_)
4613                | IpcResponse::TransferJobsProcessed { .. }
4614                | IpcResponse::PackageInfo(_)
4615                | IpcResponse::PackageInstall { .. }
4616                | IpcResponse::NetworkCreated { .. } => Err(SyncwebError::operation(
4617                    "daemon status request returned an unexpected response",
4618                    "unexpected response",
4619                )),
4620            }
4621        }
4622        #[cfg(not(unix))]
4623        {
4624            Err(SyncwebError::operation(
4625                "daemon IPC is unavailable",
4626                "Unix sockets are not supported on this platform",
4627            ))
4628        }
4629    }
4630}
4631
4632#[cfg(unix)]
4633fn set_owner_only_permissions(path: &Path) -> Result<()> {
4634    use std::os::unix::fs::PermissionsExt;
4635    let mut permissions = std::fs::metadata(path)?.permissions();
4636    permissions.set_mode(0o600);
4637    Ok(std::fs::set_permissions(path, permissions)?)
4638}
4639
4640#[cfg(test)]
4641mod tests {
4642    use std::{path::PathBuf, sync::Arc};
4643
4644    #[cfg(unix)]
4645    use std::{os::unix::fs::PermissionsExt, time::Duration};
4646
4647    use super::*;
4648    use crate::daemon::{DaemonState, DaemonStatus};
4649
4650    fn socket_path() -> PathBuf {
4651        std::env::temp_dir().join(format!("syncweb-ipc-{}.sock", uuid::Uuid::new_v4()))
4652    }
4653
4654    fn state() -> DaemonState {
4655        DaemonState::new(
4656            std::process::id(),
4657            "node",
4658            1,
4659            std::env::temp_dir(),
4660            DaemonStatus::Running,
4661        )
4662    }
4663
4664    #[test]
4665    fn request_round_trips_as_json() {
4666        let request = IpcRequest::new(IpcCommand::Download {
4667            namespace: "namespace".to_owned(),
4668            strategy: FetchStrategy::default(),
4669        });
4670        let encoded = serde_json::to_vec(&request).expect("serialize request");
4671        let decoded: IpcRequest = serde_json::from_slice(&encoded).expect("deserialize request");
4672        assert!(matches!(decoded.command, IpcCommand::Download { .. }));
4673    }
4674
4675    #[test]
4676    fn watch_config_round_trips_as_json() {
4677        let watch = IpcRequest::new(IpcCommand::WatchStart {
4678            namespace: "namespace".to_owned(),
4679            path: PathBuf::from("/tmp/folder"),
4680            exclude: vec!["*.tmp".to_owned()],
4681            filter: None,
4682        });
4683        let watch_encoded = serde_json::to_vec(&watch).expect("serialize watch start request");
4684        let watch_decoded: IpcRequest =
4685            serde_json::from_slice(&watch_encoded).expect("deserialize watch start request");
4686        assert!(matches!(
4687            watch_decoded.command,
4688            IpcCommand::WatchStart { exclude, .. } if exclude == ["*.tmp".to_owned()]
4689        ));
4690
4691        let once = IpcRequest::new(IpcCommand::WatchOnce {
4692            namespace: Some("namespace".to_owned()),
4693            path: PathBuf::from("/tmp/folder"),
4694            filter: None,
4695            exclude: vec!["*.tmp".to_owned()],
4696        });
4697        let once_encoded = serde_json::to_vec(&once).expect("serialize watch once request");
4698        let once_decoded: IpcRequest = serde_json::from_slice(&once_encoded).expect("deserialize watch once request");
4699        assert!(matches!(
4700            once_decoded.command,
4701            IpcCommand::WatchOnce { exclude, .. } if exclude == ["*.tmp".to_owned()]
4702        ));
4703
4704        let fetch = IpcRequest::new(IpcCommand::FetchBlob {
4705            hash: "hash".to_owned(),
4706            tickets: vec!["ticket".to_owned()],
4707            export_path: Some(PathBuf::from("/tmp/out.bin")),
4708        });
4709        let fetch_encoded = serde_json::to_vec(&fetch).expect("serialize fetch request");
4710        let fetch_decoded: IpcRequest = serde_json::from_slice(&fetch_encoded).expect("deserialize fetch request");
4711        assert!(matches!(fetch_decoded.command, IpcCommand::FetchBlob { .. }));
4712
4713        let publish = IpcRequest::new(IpcCommand::LinkPublish {
4714            namespace: "namespace".to_owned(),
4715            link: "syncweb://content/hash".to_owned(),
4716        });
4717        let publish_encoded = serde_json::to_vec(&publish).expect("serialize publish request");
4718        let publish_decoded: IpcRequest =
4719            serde_json::from_slice(&publish_encoded).expect("deserialize publish request");
4720        assert!(matches!(publish_decoded.command, IpcCommand::LinkPublish { .. }));
4721    }
4722
4723    #[test]
4724    fn folder_registry_upserts_watch_config() {
4725        let namespace = iroh_docs::NamespaceSecret::from_bytes(&[9; 32]).id();
4726        let mut registry = FolderRegistry::new();
4727        registry.add_or_update_watch(
4728            FolderEntry::new(namespace, PathBuf::from("/tmp/folder"))
4729                .with_watch(WatchConfig::new(vec!["*.tmp".to_owned()], None)),
4730        );
4731        let watch = registry.watch_for(&namespace.to_string());
4732        assert_eq!(watch.exclude, ["*.tmp".to_owned()]);
4733
4734        registry.add_or_update_watch(
4735            FolderEntry::new(namespace, PathBuf::from("/tmp/folder"))
4736                .with_watch(WatchConfig::new(vec!["*.bak".to_owned()], None)),
4737        );
4738        let updated = registry.watch_for(&namespace.to_string());
4739        assert_eq!(updated.exclude, ["*.bak".to_owned()]);
4740    }
4741
4742    #[tokio::test]
4743    async fn handle_request_updates_registry_and_trigger() {
4744        let (sync_trigger, mut sync_receiver) = mpsc::unbounded_channel();
4745        let handle = DaemonHandle::with_channels(
4746            Arc::new(RwLock::new(state())),
4747            Arc::new(RwLock::new(FolderRegistry::new())),
4748            broadcast::channel(4).0,
4749            sync_trigger,
4750        );
4751        let server = IpcServer::new(socket_path(), handle);
4752        let namespace = iroh_docs::NamespaceSecret::from_bytes(&[7; 32]).id().to_string();
4753
4754        assert!(matches!(
4755            server
4756                .handle_request(IpcRequest::new(IpcCommand::AddFolder {
4757                    namespace: namespace.clone(),
4758                    path: PathBuf::from("/tmp/folder"),
4759                }))
4760                .await,
4761            IpcResponse::Ok { .. }
4762        ));
4763        assert!(matches!(
4764            server
4765                .handle_request(IpcRequest::new(IpcCommand::ListFolders))
4766                .await,
4767            IpcResponse::FolderList(folders)
4768                if folders.len() == 1 && folders.first().is_some_and(|folder| folder.namespace == namespace)
4769        ));
4770        assert!(matches!(
4771            server
4772                .handle_request(IpcRequest::new(IpcCommand::TriggerSync {
4773                    namespace: Some(namespace.clone()),
4774                }))
4775                .await,
4776            IpcResponse::Ok { .. }
4777        ));
4778        assert_eq!(sync_receiver.recv().await, Some(Some(namespace)));
4779    }
4780
4781    #[cfg(unix)]
4782    #[tokio::test]
4783    async fn client_round_trips_with_server() {
4784        let path = socket_path();
4785        let handle = DaemonHandle::new(state());
4786        let mut shutdown_receiver = handle.shutdown_sender.subscribe();
4787        let server = IpcServer::new(path.clone(), handle);
4788        let server_task = tokio::spawn(async move { server.serve().await });
4789
4790        tokio::time::timeout(Duration::from_secs(1), async {
4791            while !path.exists() {
4792                tokio::time::sleep(Duration::from_millis(5)).await;
4793            }
4794        })
4795        .await
4796        .expect("server socket should appear");
4797
4798        let client = IpcClient::from_socket_path(path.clone());
4799        assert!(matches!(
4800            client
4801                .send(IpcRequest::new(IpcCommand::Status))
4802                .await
4803                .expect("status response"),
4804            IpcResponse::Status(DaemonStatus::Running)
4805        ));
4806        assert!(matches!(
4807            client
4808                .send(IpcRequest::new(IpcCommand::Shutdown { force: false }))
4809                .await
4810                .expect("shutdown response"),
4811            IpcResponse::Ok { .. }
4812        ));
4813        shutdown_receiver.recv().await.expect("shutdown broadcast");
4814        server_task
4815            .await
4816            .expect("server task should join")
4817            .expect("server should stop cleanly");
4818        assert!(!path.exists());
4819    }
4820
4821    #[cfg(unix)]
4822    #[tokio::test]
4823    async fn socket_is_owner_only() {
4824        let path = socket_path();
4825        let listener = IpcListener::new(path.clone()).bind().expect("bind socket");
4826        assert_eq!(
4827            std::fs::metadata(&path).expect("socket metadata").permissions().mode() & 0o777,
4828            0o600
4829        );
4830        drop(listener);
4831        std::fs::remove_file(path).expect("remove socket");
4832    }
4833
4834    #[tokio::test]
4835    #[cfg(unix)]
4836    async fn client_reports_missing_server() {
4837        let path = socket_path();
4838        let error = IpcClient::from_socket_path(path)
4839            .send(IpcRequest::new(IpcCommand::Status))
4840            .await
4841            .expect_err("missing server should fail");
4842        assert!(error.to_string().contains("failed to connect to daemon socket"));
4843    }
4844
4845    #[test]
4846    fn new_commands_round_trip_as_json() {
4847        let req1 = IpcRequest::new(IpcCommand::Unsubscribe {
4848            namespace: "blob:baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
4849        });
4850        let enc1 = serde_json::to_vec(&req1).expect("serialize");
4851        let dec1: IpcRequest = serde_json::from_slice(&enc1).expect("deserialize");
4852        assert!(matches!(dec1.command, IpcCommand::Unsubscribe { .. }));
4853
4854        let req2 = IpcRequest::new(IpcCommand::LeaveFolder {
4855            namespace: "ns".to_owned(),
4856            delete_files: false,
4857        });
4858        let enc2 = serde_json::to_vec(&req2).expect("serialize");
4859        let dec2: IpcRequest = serde_json::from_slice(&enc2).expect("deserialize");
4860        assert!(matches!(
4861            dec2.command,
4862            IpcCommand::LeaveFolder {
4863                delete_files: false,
4864                ..
4865            }
4866        ));
4867
4868        let req3 = IpcRequest::new(IpcCommand::Share {
4869            namespace: "ns".to_owned(),
4870            blob: Some("baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned()),
4871            writable: false,
4872            pin: false,
4873            persist: false,
4874            addr_info: default_addr_info(),
4875        });
4876        let enc3 = serde_json::to_vec(&req3).expect("serialize");
4877        let dec3: IpcRequest = serde_json::from_slice(&enc3).expect("deserialize");
4878        assert!(matches!(dec3.command, IpcCommand::Share { .. }));
4879
4880        let req4 = IpcRequest::new(IpcCommand::SnapshotCreate {
4881            path: PathBuf::from("."),
4882            description: Some("test".to_owned()),
4883            threads: 0,
4884        });
4885        let enc4 = serde_json::to_vec(&req4).expect("serialize");
4886        let dec4: IpcRequest = serde_json::from_slice(&enc4).expect("deserialize");
4887        assert!(matches!(dec4.command, IpcCommand::SnapshotCreate { .. }));
4888
4889        let req5 = IpcRequest::new(IpcCommand::SnapshotList {
4890            path: PathBuf::from("."),
4891        });
4892        let enc5 = serde_json::to_vec(&req5).expect("serialize");
4893        let dec5: IpcRequest = serde_json::from_slice(&enc5).expect("deserialize");
4894        assert!(matches!(dec5.command, IpcCommand::SnapshotList { .. }));
4895
4896        let req6 = IpcRequest::new(IpcCommand::SnapshotDelete {
4897            id: "baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
4898        });
4899        let enc6 = serde_json::to_vec(&req6).expect("serialize");
4900        let dec6: IpcRequest = serde_json::from_slice(&enc6).expect("deserialize");
4901        assert!(matches!(dec6.command, IpcCommand::SnapshotDelete { .. }));
4902
4903        let req7 = IpcRequest::new(IpcCommand::CollectionPublish {
4904            path: PathBuf::from("."),
4905            namespace: "ns".to_owned(),
4906            sequence: 1,
4907            bootstrap: vec![],
4908            manifest_bytes: None,
4909        });
4910        let enc7 = serde_json::to_vec(&req7).expect("serialize");
4911        let dec7: IpcRequest = serde_json::from_slice(&enc7).expect("deserialize");
4912        assert!(matches!(dec7.command, IpcCommand::CollectionPublish { .. }));
4913
4914        let req8 = IpcRequest::new(IpcCommand::SubscribePublic {
4915            ticket: "blob_ticket".to_owned(),
4916        });
4917        let enc8 = serde_json::to_vec(&req8).expect("serialize");
4918        let dec8: IpcRequest = serde_json::from_slice(&enc8).expect("deserialize");
4919        assert!(matches!(dec8.command, IpcCommand::SubscribePublic { .. }));
4920    }
4921
4922    #[tokio::test]
4923    async fn test_ipc_unsubscribe_rejects_folder_namespaces() {
4924        let handle = DaemonHandle::new(state());
4925        let server = IpcServer::new(socket_path(), handle);
4926        let namespace = iroh_docs::NamespaceSecret::from_bytes(&[7; 32]).id().to_string();
4927        let response = server
4928            .handle_request(IpcRequest::new(IpcCommand::Unsubscribe {
4929                namespace: namespace.clone(),
4930            }))
4931            .await;
4932        assert!(matches!(
4933            response,
4934            IpcResponse::Error { message } if message.contains("use `leave` for folders")
4935        ));
4936    }
4937
4938    #[tokio::test]
4939    async fn test_ipc_unsubscribe_invalid_namespace() {
4940        let handle = DaemonHandle::new(state());
4941        let server = IpcServer::new(socket_path(), handle);
4942        let response = server
4943            .handle_request(IpcRequest::new(IpcCommand::Unsubscribe {
4944                namespace: "not-a-namespace".to_owned(),
4945            }))
4946            .await;
4947        assert!(matches!(
4948            response,
4949            IpcResponse::Error { message } if message.contains("use `leave` for folders")
4950        ));
4951    }
4952
4953    #[tokio::test]
4954    async fn test_ipc_leave_folder_no_context() {
4955        let handle = DaemonHandle::new(state());
4956        let server = IpcServer::new(socket_path(), handle);
4957        let response = server
4958            .handle_request(IpcRequest::new(IpcCommand::LeaveFolder {
4959                namespace: "ns".to_owned(),
4960                delete_files: false,
4961            }))
4962            .await;
4963        assert!(matches!(
4964            response,
4965            IpcResponse::Error { message } if message.contains("no node context")
4966        ));
4967    }
4968
4969    #[tokio::test]
4970    async fn test_ipc_share_no_context() {
4971        let handle = DaemonHandle::new(state());
4972        let server = IpcServer::new(socket_path(), handle);
4973        let response = server
4974            .handle_request(IpcRequest::new(IpcCommand::Share {
4975                namespace: "ns".to_owned(),
4976                blob: Some("baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned()),
4977                writable: false,
4978                pin: false,
4979                persist: false,
4980                addr_info: default_addr_info(),
4981            }))
4982            .await;
4983        assert!(matches!(
4984            response,
4985            IpcResponse::Error { message } if message.contains("no node context")
4986        ));
4987    }
4988
4989    #[tokio::test]
4990    async fn test_ipc_snapshot_create_no_context() {
4991        let handle = DaemonHandle::new(state());
4992        let server = IpcServer::new(socket_path(), handle);
4993        let response = server
4994            .handle_request(IpcRequest::new(IpcCommand::SnapshotCreate {
4995                path: PathBuf::from("."),
4996                description: None,
4997                threads: 0,
4998            }))
4999            .await;
5000        assert!(matches!(
5001            response,
5002            IpcResponse::Error { message } if message.contains("no node context")
5003        ));
5004    }
5005
5006    #[tokio::test]
5007    async fn test_ipc_snapshot_list_no_context() {
5008        let handle = DaemonHandle::new(state());
5009        let server = IpcServer::new(socket_path(), handle);
5010        let response = server
5011            .handle_request(IpcRequest::new(IpcCommand::SnapshotList {
5012                path: PathBuf::from("."),
5013            }))
5014            .await;
5015        assert!(matches!(
5016            response,
5017            IpcResponse::Error { message } if message.contains("no node context")
5018        ));
5019    }
5020
5021    #[tokio::test]
5022    async fn test_ipc_snapshot_delete_no_context() {
5023        let handle = DaemonHandle::new(state());
5024        let server = IpcServer::new(socket_path(), handle);
5025        let response = server
5026            .handle_request(IpcRequest::new(IpcCommand::SnapshotDelete {
5027                id: "baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
5028            }))
5029            .await;
5030        assert!(matches!(
5031            response,
5032            IpcResponse::Error { message } if message.contains("no node context")
5033        ));
5034    }
5035
5036    #[tokio::test]
5037    async fn test_ipc_collection_publish_no_context() {
5038        let handle = DaemonHandle::new(state());
5039        let server = IpcServer::new(socket_path(), handle);
5040        let response = server
5041            .handle_request(IpcRequest::new(IpcCommand::CollectionPublish {
5042                path: PathBuf::from("."),
5043                namespace: "ns".to_owned(),
5044                sequence: 1,
5045                bootstrap: vec![],
5046                manifest_bytes: None,
5047            }))
5048            .await;
5049        assert!(matches!(
5050            response,
5051            IpcResponse::Error { message } if message.contains("no node context")
5052        ));
5053    }
5054
5055    #[tokio::test]
5056    async fn test_ipc_create_folder_no_context() {
5057        let handle = DaemonHandle::new(state());
5058        let server = IpcServer::new(socket_path(), handle);
5059        let response = server
5060            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
5061                path: PathBuf::from("."),
5062                mode: "sendreceive".to_owned(),
5063                indexing: false,
5064            }))
5065            .await;
5066        assert!(matches!(
5067            response,
5068            IpcResponse::Error { message } if message.contains("no node context")
5069        ));
5070    }
5071
5072    #[tokio::test]
5073    async fn test_ipc_create_folder_no_context_with_invalid_mode() {
5074        let handle = DaemonHandle::new(state());
5075        let server = IpcServer::new(socket_path(), handle);
5076        let response = server
5077            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
5078                path: PathBuf::from("/tmp/test-create-folder"),
5079                mode: "invalid".to_owned(),
5080                indexing: false,
5081            }))
5082            .await;
5083        assert!(matches!(
5084            response,
5085            IpcResponse::Error { message } if message.contains("no node context")
5086        ));
5087    }
5088
5089    #[tokio::test]
5090    async fn test_ipc_stats_files_no_context() {
5091        let handle = DaemonHandle::new(state());
5092        let server = IpcServer::new(socket_path(), handle);
5093        let response = server
5094            .handle_request(IpcRequest::new(IpcCommand::StatsFiles {
5095                folder: PathBuf::from("."),
5096            }))
5097            .await;
5098        assert!(matches!(
5099            response,
5100            IpcResponse::Error { message } if message.contains("no node context")
5101        ));
5102    }
5103
5104    #[tokio::test]
5105    async fn test_ipc_materialize_transfers_no_context() {
5106        let handle = DaemonHandle::new(state());
5107        let server = IpcServer::new(socket_path(), handle);
5108        let response = server
5109            .handle_request(IpcRequest::new(IpcCommand::MaterializeTransfers { namespace: None }))
5110            .await;
5111        assert!(matches!(
5112            response,
5113            IpcResponse::Error { message } if message.contains("node context")
5114        ));
5115    }
5116
5117    #[tokio::test]
5118    async fn test_ipc_verify_integrity_no_context() {
5119        let handle = DaemonHandle::new(state());
5120        let server = IpcServer::new(socket_path(), handle);
5121        let response = server
5122            .handle_request(IpcRequest::new(IpcCommand::VerifyIntegrity {
5123                path: PathBuf::from("."),
5124                hash: Vec::new(),
5125                path_filter: None,
5126                glob_filter: None,
5127                fix: false,
5128                from: Vec::new(),
5129            }))
5130            .await;
5131        assert!(matches!(
5132            response,
5133            IpcResponse::Error { message } if message.contains("no node context")
5134        ));
5135    }
5136
5137    #[tokio::test]
5138    async fn test_ipc_join_no_context() {
5139        let handle = DaemonHandle::new(state());
5140        let server = IpcServer::new(socket_path(), handle);
5141        let response = server
5142            .handle_request(IpcRequest::new(IpcCommand::Join {
5143                ticket: "ticket".to_owned(),
5144                path: PathBuf::from("/tmp"),
5145                mode: SyncMode::SendReceive,
5146                subscribe: false,
5147                filters: SubscribeFilters::default(),
5148                download: false,
5149                indexing: false,
5150                metadata_only: false,
5151            }))
5152            .await;
5153        assert!(matches!(
5154            response,
5155            IpcResponse::Error { message } if message.contains("no node context")
5156        ));
5157    }
5158
5159    #[tokio::test]
5160    async fn test_ipc_unshare_no_context() {
5161        let handle = DaemonHandle::new(state());
5162        let server = IpcServer::new(socket_path(), handle);
5163        let response = server
5164            .handle_request(IpcRequest::new(IpcCommand::Unshare {
5165                namespace: "ns".to_owned(),
5166                blob: None,
5167                writable: false,
5168            }))
5169            .await;
5170        assert!(matches!(
5171            response,
5172            IpcResponse::Error { message } if message.contains("no node context")
5173        ));
5174    }
5175
5176    #[tokio::test]
5177    async fn test_ipc_subscribe_no_context() {
5178        let handle = DaemonHandle::new(state());
5179        let server = IpcServer::new(socket_path(), handle);
5180        let response = server
5181            .handle_request(IpcRequest::new(IpcCommand::SetSubscribe {
5182                namespace: "ns".to_owned(),
5183                enabled: true,
5184                filters: Some(SubscribeFilters {
5185                    ingest_only: true,
5186                    ..Default::default()
5187                }),
5188            }))
5189            .await;
5190        assert!(matches!(
5191            response,
5192            IpcResponse::Error { message } if message.contains("no node context")
5193        ));
5194    }
5195
5196    struct IpcTestFixture {
5197        server: IpcServer,
5198        node: Arc<IrohNode>,
5199        directory: PathBuf,
5200    }
5201
5202    async fn setup_ipc_test() -> IpcTestFixture {
5203        use crate::node::identity::IdentityManager;
5204        use crate::node::iroh_node::RelayMode;
5205        use std::collections::HashSet;
5206        let directory = std::env::temp_dir().join(format!("syncweb-ipc-test-{}", uuid::Uuid::new_v4()));
5207        std::fs::create_dir_all(&directory).expect("test directory should be created");
5208
5209        let identity = IdentityManager::new(directory.join("identity.key")).expect("test identity should open");
5210        let node = Arc::new(
5211            IrohNode::new(
5212                identity,
5213                directory.join("data"),
5214                RelayMode::Default,
5215                Arc::new(std::sync::RwLock::new(HashSet::new())),
5216                crate::node::iroh_node::DiscoveryConfig::disabled(),
5217            )
5218            .await
5219            .expect("test node should start"),
5220        );
5221        let pool = Arc::new(ManagedPool::new("syncweb-test", 1).expect("test pool should start"));
5222
5223        let daemon_state = DaemonState::new(
5224            std::process::id(),
5225            node.endpoint().id().to_string(),
5226            1,
5227            &directory,
5228            DaemonStatus::Running,
5229        );
5230        let handle = DaemonHandle::new(daemon_state);
5231        let server =
5232            IpcServer::with_archive_context(socket_path(), directory.clone(), handle, node.clone(), pool, None);
5233
5234        IpcTestFixture {
5235            server,
5236            node,
5237            directory,
5238        }
5239    }
5240
5241    async fn cleanup_ipc_test(fixture: IpcTestFixture) {
5242        let _ = fixture.node.stop().await;
5243        let _ = std::fs::remove_dir_all(&fixture.directory);
5244    }
5245
5246    #[tokio::test]
5247    async fn test_ipc_create_folder_creates_and_returns_message() {
5248        let fixture = setup_ipc_test().await;
5249        let test_dir = fixture.directory.join("create-folder-test");
5250        let response = fixture
5251            .server
5252            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
5253                path: test_dir.clone(),
5254                mode: "sendreceive".to_owned(),
5255                indexing: false,
5256            }))
5257            .await;
5258        assert!(matches!(response, IpcResponse::Ok { .. }));
5259        if let IpcResponse::Ok { message } = response {
5260            assert!(message.contains("namespace:"));
5261            assert!(!message.contains("ticket:"));
5262        }
5263        cleanup_ipc_test(fixture).await;
5264        let _ = std::fs::remove_dir_all(&test_dir);
5265    }
5266
5267    #[tokio::test]
5268    async fn test_ipc_create_folder_invalid_mode() {
5269        let fixture = setup_ipc_test().await;
5270        let test_dir = fixture.directory.join("create-folder-invalid");
5271        let response = fixture
5272            .server
5273            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
5274                path: test_dir.clone(),
5275                mode: "invalid-mode".to_owned(),
5276                indexing: false,
5277            }))
5278            .await;
5279        assert!(matches!(response, IpcResponse::Error { .. }));
5280        if let IpcResponse::Error { message } = response {
5281            assert!(message.contains("invalid sync mode"));
5282        }
5283        cleanup_ipc_test(fixture).await;
5284    }
5285
5286    #[tokio::test]
5287    async fn test_ipc_create_folder_duplicate_namespace() {
5288        let fixture = setup_ipc_test().await;
5289        let test_dir1 = fixture.directory.join("create-folder-dup-1");
5290        let test_dir2 = fixture.directory.join("create-folder-dup-2");
5291
5292        let response1 = fixture
5293            .server
5294            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
5295                path: test_dir1.clone(),
5296                mode: "sendreceive".to_owned(),
5297                indexing: false,
5298            }))
5299            .await;
5300        assert!(matches!(response1, IpcResponse::Ok { .. }));
5301        let namespace = if let IpcResponse::Ok { message } = &response1 {
5302            message
5303                .lines()
5304                .find(|line| line.starts_with("namespace:"))
5305                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
5306        } else {
5307            None
5308        };
5309
5310        if let Some(ref ns) = namespace {
5311            let response2 = fixture
5312                .server
5313                .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
5314                    path: test_dir2.clone(),
5315                    mode: "sendreceive".to_owned(),
5316                    indexing: false,
5317                }))
5318                .await;
5319            assert!(matches!(response2, IpcResponse::Ok { .. }));
5320            let ns2 = if let IpcResponse::Ok { message } = &response2 {
5321                message
5322                    .lines()
5323                    .find(|line| line.starts_with("namespace:"))
5324                    .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
5325            } else {
5326                None
5327            };
5328            assert_ne!(Some(ns), ns2.as_ref(), "each create should produce a unique namespace");
5329        }
5330
5331        cleanup_ipc_test(fixture).await;
5332        let _ = std::fs::remove_dir_all(&test_dir1);
5333        let _ = std::fs::remove_dir_all(&test_dir2);
5334    }
5335
5336    #[tokio::test]
5337    async fn test_ipc_stats_files_returns_report() {
5338        let fixture = setup_ipc_test().await;
5339        let test_dir = fixture.directory.join("stats-files-test");
5340        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
5341
5342        let response1 = fixture
5343            .server
5344            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
5345                path: test_dir.clone(),
5346                mode: "sendreceive".to_owned(),
5347                indexing: false,
5348            }))
5349            .await;
5350        let namespace = if let IpcResponse::Ok { message } = &response1 {
5351            message
5352                .lines()
5353                .find(|line| line.starts_with("namespace:"))
5354                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
5355        } else {
5356            None
5357        };
5358
5359        if let Some(ns) = namespace {
5360            let response2 = fixture
5361                .server
5362                .handle_request(IpcRequest::new(IpcCommand::StatsFiles {
5363                    folder: PathBuf::from(&ns),
5364                }))
5365                .await;
5366            assert!(matches!(response2, IpcResponse::FileStats(_)));
5367        }
5368
5369        cleanup_ipc_test(fixture).await;
5370        let _ = std::fs::remove_dir_all(&test_dir);
5371    }
5372
5373    #[tokio::test]
5374    async fn test_ipc_stats_files_unknown_folder() {
5375        let fixture = setup_ipc_test().await;
5376        let response = fixture
5377            .server
5378            .handle_request(IpcRequest::new(IpcCommand::StatsFiles {
5379                folder: PathBuf::from("/nonexistent/path/that/does/not/exist"),
5380            }))
5381            .await;
5382        assert!(matches!(response, IpcResponse::Error { .. }));
5383        cleanup_ipc_test(fixture).await;
5384    }
5385
5386    #[tokio::test]
5387    async fn test_ipc_verify_integrity_returns_result() {
5388        let fixture = setup_ipc_test().await;
5389        let test_dir = fixture.directory.join("verify-test");
5390        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
5391
5392        let response1 = fixture
5393            .server
5394            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
5395                path: test_dir.clone(),
5396                mode: "sendreceive".to_owned(),
5397                indexing: false,
5398            }))
5399            .await;
5400        let namespace = if let IpcResponse::Ok { message } = &response1 {
5401            message
5402                .lines()
5403                .find(|line| line.starts_with("namespace:"))
5404                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
5405        } else {
5406            None
5407        };
5408
5409        if let Some(ns) = namespace {
5410            let response2 = fixture
5411                .server
5412                .handle_request(IpcRequest::new(IpcCommand::VerifyIntegrity {
5413                    path: PathBuf::from(&ns),
5414                    hash: Vec::new(),
5415                    path_filter: None,
5416                    glob_filter: None,
5417                    fix: false,
5418                    from: Vec::new(),
5419                }))
5420                .await;
5421            assert!(matches!(response2, IpcResponse::Ok { .. }));
5422            if let IpcResponse::Ok { message } = response2 {
5423                assert!(message.contains("total:"));
5424                assert!(message.contains("verified:"));
5425                assert!(message.contains("corrupted:"));
5426                assert!(message.contains("missing:"));
5427                assert!(message.contains("valid:"));
5428            }
5429        }
5430
5431        cleanup_ipc_test(fixture).await;
5432        let _ = std::fs::remove_dir_all(&test_dir);
5433    }
5434
5435    #[tokio::test]
5436    async fn test_ipc_verify_integrity_unknown_folder() {
5437        let fixture = setup_ipc_test().await;
5438        let response = fixture
5439            .server
5440            .handle_request(IpcRequest::new(IpcCommand::VerifyIntegrity {
5441                path: PathBuf::from("/nonexistent/path/that/does/not/exist"),
5442                hash: Vec::new(),
5443                path_filter: None,
5444                glob_filter: None,
5445                fix: false,
5446                from: Vec::new(),
5447            }))
5448            .await;
5449        assert!(matches!(response, IpcResponse::Error { .. }));
5450        cleanup_ipc_test(fixture).await;
5451    }
5452
5453    #[tokio::test]
5454    async fn test_ipc_join_folder_invalid_ticket() {
5455        let fixture = setup_ipc_test().await;
5456        let test_dir = fixture.directory.join("join-invalid");
5457        let response = fixture
5458            .server
5459            .handle_request(IpcRequest::new(IpcCommand::Join {
5460                ticket: "not-a-valid-ticket".to_owned(),
5461                path: test_dir.clone(),
5462                mode: SyncMode::SendReceive,
5463                subscribe: false,
5464                filters: SubscribeFilters::default(),
5465                download: false,
5466                indexing: false,
5467                metadata_only: false,
5468            }))
5469            .await;
5470        assert!(matches!(response, IpcResponse::Error { .. }));
5471        cleanup_ipc_test(fixture).await;
5472    }
5473
5474    #[tokio::test]
5475    async fn test_ipc_share_folder_ticket() {
5476        let fixture = setup_ipc_test().await;
5477        let test_dir = fixture.directory.join("share-test");
5478        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
5479
5480        let response1 = fixture
5481            .server
5482            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
5483                path: test_dir.clone(),
5484                mode: "sendreceive".to_owned(),
5485                indexing: false,
5486            }))
5487            .await;
5488        let namespace = if let IpcResponse::Ok { message } = &response1 {
5489            message
5490                .lines()
5491                .find(|line| line.starts_with("namespace:"))
5492                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
5493        } else {
5494            None
5495        };
5496
5497        if let Some(ns) = namespace {
5498            let response2 = fixture
5499                .server
5500                .handle_request(IpcRequest::new(IpcCommand::Share {
5501                    namespace: ns.clone(),
5502                    blob: None,
5503                    writable: false,
5504                    pin: false,
5505                    persist: false,
5506                    addr_info: default_addr_info(),
5507                }))
5508                .await;
5509            assert!(matches!(response2, IpcResponse::Ok { .. }));
5510            if let IpcResponse::Ok { message } = response2 {
5511                assert!(
5512                    crate::uri::is_folder_url(&message),
5513                    "share should emit a URL: {message}"
5514                );
5515            }
5516        }
5517
5518        cleanup_ipc_test(fixture).await;
5519        let _ = std::fs::remove_dir_all(&test_dir);
5520    }
5521
5522    #[tokio::test]
5523    async fn test_ipc_share_invalid_namespace() {
5524        let fixture = setup_ipc_test().await;
5525        let response = fixture
5526            .server
5527            .handle_request(IpcRequest::new(IpcCommand::Share {
5528                namespace: "not-a-namespace".to_owned(),
5529                blob: None,
5530                writable: false,
5531                pin: false,
5532                persist: false,
5533                addr_info: default_addr_info(),
5534            }))
5535            .await;
5536        assert!(matches!(response, IpcResponse::Error { .. }));
5537        if let IpcResponse::Error { message } = response {
5538            assert!(message.contains("invalid namespace"));
5539        }
5540        cleanup_ipc_test(fixture).await;
5541    }
5542
5543    #[tokio::test]
5544    async fn test_ipc_subscribe_returns_ok() {
5545        let fixture = setup_ipc_test().await;
5546        let test_dir = fixture.directory.join("subscribe-test");
5547        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
5548
5549        let response1 = fixture
5550            .server
5551            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
5552                path: test_dir.clone(),
5553                mode: "sendreceive".to_owned(),
5554                indexing: false,
5555            }))
5556            .await;
5557        let namespace = if let IpcResponse::Ok { message } = &response1 {
5558            message
5559                .lines()
5560                .find(|line| line.starts_with("namespace:"))
5561                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
5562        } else {
5563            None
5564        };
5565
5566        if let Some(ns) = namespace {
5567            let response = fixture
5568                .server
5569                .handle_request(IpcRequest::new(IpcCommand::SetSubscribe {
5570                    namespace: ns.clone(),
5571                    enabled: true,
5572                    filters: Some(SubscribeFilters {
5573                        ingest_only: true,
5574                        ..Default::default()
5575                    }),
5576                }))
5577                .await;
5578            assert!(matches!(response, IpcResponse::Ok { .. }));
5579            if let IpcResponse::Ok { message } = response {
5580                assert!(message.contains("subscribed:"));
5581            }
5582        }
5583
5584        cleanup_ipc_test(fixture).await;
5585        let _ = std::fs::remove_dir_all(&test_dir);
5586    }
5587
5588    #[tokio::test]
5589    async fn test_ipc_subscribe_with_params() {
5590        let fixture = setup_ipc_test().await;
5591        let test_dir = fixture.directory.join("subscribe-params-test");
5592        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
5593
5594        let response1 = fixture
5595            .server
5596            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
5597                path: test_dir.clone(),
5598                mode: "sendreceive".to_owned(),
5599                indexing: false,
5600            }))
5601            .await;
5602        let namespace = if let IpcResponse::Ok { message } = &response1 {
5603            message
5604                .lines()
5605                .find(|line| line.starts_with("namespace:"))
5606                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
5607        } else {
5608            None
5609        };
5610
5611        if let Some(ns) = namespace {
5612            let filters = SubscribeFilters {
5613                ingest_only: true,
5614                ..Default::default()
5615            };
5616            let response2 = fixture
5617                .server
5618                .handle_request(IpcRequest::new(IpcCommand::SetSubscribe {
5619                    namespace: ns.clone(),
5620                    enabled: true,
5621                    filters: Some(filters),
5622                }))
5623                .await;
5624            assert!(matches!(response2, IpcResponse::Ok { .. }));
5625        }
5626
5627        cleanup_ipc_test(fixture).await;
5628        let _ = std::fs::remove_dir_all(&test_dir);
5629    }
5630
5631    #[tokio::test]
5632    async fn test_ipc_leave_folder_removes_from_registry() {
5633        let fixture = setup_ipc_test().await;
5634        let test_dir = fixture.directory.join("leave-test");
5635        std::fs::create_dir_all(&test_dir).expect("test dir should be created");
5636
5637        let response = fixture
5638            .server
5639            .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
5640                path: test_dir.clone(),
5641                mode: "sendreceive".to_owned(),
5642                indexing: false,
5643            }))
5644            .await;
5645        let namespace = if let IpcResponse::Ok { message } = &response {
5646            message
5647                .lines()
5648                .find(|line| line.starts_with("namespace:"))
5649                .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
5650        } else {
5651            None
5652        };
5653
5654        if let Some(ref ns) = namespace {
5655            let statuses1 = fixture.server.daemon_handle.folder_registry.read().await.statuses();
5656            assert!(statuses1.iter().any(|s| s.namespace == *ns));
5657
5658            let response2 = fixture
5659                .server
5660                .handle_request(IpcRequest::new(IpcCommand::LeaveFolder {
5661                    namespace: ns.clone(),
5662                    delete_files: false,
5663                }))
5664                .await;
5665            assert!(matches!(response2, IpcResponse::Ok { .. }));
5666
5667            let statuses2 = fixture.server.daemon_handle.folder_registry.read().await.statuses();
5668            assert!(!statuses2.iter().any(|s| s.namespace == *ns));
5669        }
5670
5671        cleanup_ipc_test(fixture).await;
5672        let _ = std::fs::remove_dir_all(&test_dir);
5673    }
5674
5675    #[tokio::test]
5676    async fn test_ipc_leave_folder_nonexistent() {
5677        let fixture = setup_ipc_test().await;
5678        let fake_ns = iroh_docs::NamespaceSecret::from_bytes(&[99; 32]).id().to_string();
5679        let _response = fixture
5680            .server
5681            .handle_request(IpcRequest::new(IpcCommand::LeaveFolder {
5682                namespace: fake_ns.clone(),
5683                delete_files: false,
5684            }))
5685            .await;
5686        let registry = fixture.server.daemon_handle.folder_registry.read().await;
5687        let statuses = registry.statuses();
5688        assert!(!statuses.iter().any(|s| s.namespace == fake_ns));
5689        drop(registry);
5690        cleanup_ipc_test(fixture).await;
5691    }
5692
5693    #[tokio::test]
5694    async fn test_ipc_unshare_invalid_hash() {
5695        let fixture = setup_ipc_test().await;
5696        let fake_ns = iroh_docs::NamespaceSecret::from_bytes(&[88; 32]).id().to_string();
5697        let response = fixture
5698            .server
5699            .handle_request(IpcRequest::new(IpcCommand::Unshare {
5700                namespace: fake_ns,
5701                blob: Some("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".to_owned()),
5702                writable: false,
5703            }))
5704            .await;
5705        assert!(matches!(response, IpcResponse::Error { .. }));
5706        if let IpcResponse::Error { message } = response {
5707            assert!(message.contains("invalid blob hash"));
5708        }
5709        cleanup_ipc_test(fixture).await;
5710    }
5711
5712    #[tokio::test]
5713    async fn test_ipc_snapshot_list_empty() {
5714        let fixture = setup_ipc_test().await;
5715        let response = fixture
5716            .server
5717            .handle_request(IpcRequest::new(IpcCommand::SnapshotList {
5718                path: PathBuf::from("."),
5719            }))
5720            .await;
5721        assert!(matches!(response, IpcResponse::SnapshotList(snapshots) if snapshots.is_empty()));
5722        cleanup_ipc_test(fixture).await;
5723    }
5724
5725    #[tokio::test]
5726    async fn test_ipc_snapshot_delete_invalid_id() {
5727        let fixture = setup_ipc_test().await;
5728        let response = fixture
5729            .server
5730            .handle_request(IpcRequest::new(IpcCommand::SnapshotDelete {
5731                id: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".to_owned(),
5732            }))
5733            .await;
5734        assert!(matches!(response, IpcResponse::Error { .. }));
5735        if let IpcResponse::Error { message } = response {
5736            assert!(message.contains("invalid snapshot id"));
5737        }
5738        cleanup_ipc_test(fixture).await;
5739    }
5740}