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