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