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