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