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#[async_trait::async_trait]
51pub trait IpcTransport: Send + Sync {
52 async fn send_request(&mut self, request: &IpcRequest) -> Result<()>;
54
55 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#[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#[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#[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#[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#[derive(Clone, Debug, Deserialize, Serialize)]
446#[non_exhaustive]
447pub struct SnapshotDiffReport {
448 pub added: Vec<String>,
449 pub removed: Vec<String>,
450 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#[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#[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
509const fn default_entry_enrich() -> bool {
512 true
513}
514
515const fn default_addr_info() -> iroh_docs::api::protocol::AddrInfoOptions {
518 iroh_docs::api::protocol::AddrInfoOptions::RelayAndAddresses
519}
520
521#[derive(Clone, Debug, Default, Deserialize, Serialize)]
524#[non_exhaustive]
525pub struct PeerAvailabilityReport {
526 pub folder: String,
528 #[serde(default)]
530 pub peers: Vec<PeerInfo>,
531 #[serde(default)]
534 pub per_blob: Vec<BlobPeerAvailability>,
535}
536
537#[derive(Clone, Debug, Deserialize, Serialize)]
539#[non_exhaustive]
540pub struct PeerInfo {
541 pub device_id: String,
543 #[serde(default)]
545 pub name: Option<String>,
546 #[serde(default)]
548 pub connection: Option<String>,
549}
550
551#[derive(Clone, Debug, Deserialize, Serialize)]
553#[non_exhaustive]
554pub struct BlobPeerAvailability {
555 pub path: String,
557 pub hash: String,
559 #[serde(default)]
561 pub peer_count: usize,
562 #[serde(default)]
564 pub peers: Vec<String>,
565 #[serde(default)]
567 pub pct_seeded: f64,
568}
569
570pub use crate::daemon::state::FolderStatusReport as FolderStatus;
572
573#[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 pub fn compiled_filter(&self) -> Result<Option<FilterEngine>> {
599 self.filter.clone().map(FilterEngine::new).transpose()
600 }
601}
602
603#[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 #[must_use]
638 pub fn with_watch(mut self, watch: WatchConfig) -> Self {
639 self.watch = watch;
640 self
641 }
642
643 #[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#[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 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 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 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 #[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 pub fn add_subscription(&mut self, subscription: PublicSubscription) {
756 let key = subscription.namespace_id();
757 self.subscriptions.insert(key, subscription);
758 }
759
760 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 #[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 #[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#[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 #[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 pub async fn set_status(&self, status: DaemonStatus) {
896 self.state.write().await.status = status;
897 }
898}
899
900#[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 #[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#[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#[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
3599fn 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 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn share_list(&self) -> Result<String> {
4257 Self::expect_message("share list", self.request(IpcCommand::ShareList, "share list").await?)
4258 }
4259
4260 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}