1use std::{
2 collections::HashMap,
3 path::{Path, PathBuf},
4 str::FromStr,
5 sync::{
6 Arc,
7 atomic::{AtomicBool, Ordering},
8 },
9 time::Duration,
10};
11
12use n0_future::StreamExt;
13use serde::{Deserialize, Serialize};
14use tokio::sync::{RwLock, broadcast, mpsc};
15
16use crate::{
17 error::{Result, SyncwebError},
18 filter::{FilterConfig, FilterEngine},
19 folder::{
20 CollectionHead, CollectionManifest, CollectionStore, DropExportOptions, DropExportResult, DropExporter,
21 DropImportOptions, DropImportResult, DropImporter, FolderManager, PackageAnnouncement, PackageCatalog,
22 SyncMode,
23 },
24 fs::Importer,
25 node::{gossip_service::GossipService, iroh_node::IrohNode},
26 snapshot::SnapshotStore,
27 sync::{
28 ActiveSession, FetchCandidate, FetchStrategy, HealthReport, SubscribeParams, SyncEngine, SyncEvent,
29 cancel_session,
30 },
31 verify::IntegrityChecker,
32};
33
34use super::{
35 ManagedPool,
36 state::{DaemonStatus, daemon_socket_path},
37};
38
39const IPC_TIMEOUT: Duration = Duration::from_millis(500); #[derive(Clone, Debug, Deserialize, Serialize)]
43#[non_exhaustive]
44pub struct IpcRequest {
45 pub command: IpcCommand,
46}
47
48impl IpcRequest {
49 #[must_use]
50 pub const fn new(command: IpcCommand) -> Self {
51 Self { command }
52 }
53}
54
55#[derive(Clone, Debug, Deserialize, Serialize)]
57#[serde(tag = "command", rename_all = "snake_case")]
58#[non_exhaustive]
59pub enum IpcCommand {
60 Status,
61 ListFolders,
62 AddFolder {
63 namespace: String,
64 path: PathBuf,
65 },
66 RemoveFolder {
67 namespace: String,
68 },
69 TriggerSync {
70 namespace: Option<String>,
71 },
72 SetLogLevel {
73 level: String,
74 },
75 ReloadConfig,
76 Shutdown {
77 force: bool,
78 },
79 Download {
80 namespace: String,
81 strategy: FetchStrategy,
82 },
83 ImportFiles {
84 namespace: Option<String>,
85 path: PathBuf,
86 },
87 ImportArchive {
88 input: PathBuf,
89 target: PathBuf,
90 filter: Option<FilterConfig>,
91 },
92 ExportArchive {
93 namespace: String,
94 version: Option<String>,
95 output: PathBuf,
96 },
97 Join {
98 ticket: String,
99 path: PathBuf,
100 mode: SyncMode,
101 },
102 Publish {
103 namespace: String,
104 blob: Option<String>,
105 },
106 Subscribe {
107 namespace: String,
108 params: SubscribeParams,
109 },
110 CreateFolder {
111 path: PathBuf,
112 mode: String,
113 },
114 HealthCheck {
115 path: PathBuf,
116 },
117 VerifyIntegrity {
118 path: PathBuf,
119 },
120 Unsubscribe {
121 namespace: String,
122 },
123 LeaveFolder {
124 namespace: String,
125 },
126 Unpublish {
127 namespace: String,
128 blob: String,
129 },
130 SnapshotCreate {
131 path: PathBuf,
132 description: Option<String>,
133 threads: usize,
134 },
135 SnapshotList {
136 path: PathBuf,
137 },
138 SnapshotDelete {
139 id: String,
140 },
141 CollectionPublish {
142 path: PathBuf,
143 namespace: String,
144 sequence: u64,
145 bootstrap: Vec<String>,
146 },
147}
148
149#[derive(Clone, Debug, Deserialize, Serialize)]
151#[serde(tag = "response", content = "data", rename_all = "snake_case")]
152#[non_exhaustive]
153pub enum IpcResponse {
154 Ok { message: String },
155 Status(DaemonStatus),
156 FolderList(Vec<FolderStatus>),
157 DownloadComplete { bytes_transferred: u64 },
158 ImportFilesComplete { entries: u64 },
159 ImportComplete(Box<DropImportResult>),
160 ExportComplete(Box<DropExportResult>),
161 Error { message: String },
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
166#[non_exhaustive]
167pub struct FolderStatus {
168 pub namespace: String,
169 pub path: PathBuf,
170 pub session_active: bool,
171 pub last_sync_at: Option<u64>,
172 pub sync_count: u64,
173 pub entries_synced: u64,
174 pub errors: Vec<String>,
175}
176
177#[non_exhaustive]
179pub struct FolderEntry {
180 pub namespace: iroh_docs::NamespaceId,
181 pub path: PathBuf,
182 pub session: Option<ActiveSession>,
183 pub last_sync_at: Option<u64>,
184 pub sync_count: u64,
185 pub entries_synced: u64,
186 pub errors: Vec<String>,
187}
188
189impl FolderEntry {
190 #[must_use]
191 pub const fn new(namespace: iroh_docs::NamespaceId, path: PathBuf) -> Self {
192 Self {
193 namespace,
194 path,
195 session: None,
196 last_sync_at: None,
197 sync_count: 0,
198 entries_synced: 0,
199 errors: Vec::new(),
200 }
201 }
202
203 #[must_use]
204 pub fn status(&self) -> FolderStatus {
205 FolderStatus {
206 namespace: self.namespace.to_string(),
207 path: self.path.clone(),
208 session_active: self.session.is_some() || crate::sync::is_active(self.namespace),
209 last_sync_at: self.last_sync_at,
210 sync_count: self.sync_count,
211 entries_synced: self.entries_synced,
212 errors: self.errors.clone(),
213 }
214 }
215}
216
217#[derive(Default)]
219pub struct FolderRegistry {
220 folders: HashMap<String, FolderEntry>,
221}
222
223impl FolderRegistry {
224 #[must_use]
225 pub fn new() -> Self {
226 Self::default()
227 }
228
229 pub fn add(&mut self, entry: FolderEntry) -> Result<()> {
235 let key = entry.namespace.to_string();
236 if self.folders.contains_key(&key) {
237 return Err(SyncwebError::FolderAlreadyManaged);
238 }
239 self.folders.insert(key, entry);
240 Ok(())
241 }
242
243 pub fn add_or_update(&mut self, entry: FolderEntry) -> Result<()> {
250 let key = entry.namespace.to_string();
251 if let Some(existing) = self.folders.get_mut(&key) {
252 if existing.path.as_os_str().is_empty() && !entry.path.as_os_str().is_empty() {
253 existing.path = entry.path;
254 return Ok(());
255 }
256 return Err(SyncwebError::FolderAlreadyManaged);
257 }
258 self.folders.insert(key, entry);
259 Ok(())
260 }
261
262 pub fn remove(&mut self, namespace: &iroh_docs::NamespaceId) -> Option<FolderEntry> {
263 self.folders.remove(&namespace.to_string())
264 }
265
266 #[must_use]
267 pub fn statuses(&self) -> Vec<FolderStatus> {
268 let mut statuses: Vec<_> = self.folders.values().map(FolderEntry::status).collect();
269 statuses.sort_by(|left, right| left.namespace.cmp(&right.namespace));
270 statuses
271 }
272
273 #[must_use]
274 pub fn len(&self) -> usize {
275 self.folders.len()
276 }
277
278 #[must_use]
279 pub fn is_empty(&self) -> bool {
280 self.folders.is_empty()
281 }
282
283 pub fn record_import(&mut self, namespace: iroh_docs::NamespaceId, entries: u64, timestamp: u64) {
284 if let Some(folder) = self.folders.get_mut(&namespace.to_string()) {
285 folder.entries_synced = folder.entries_synced.saturating_add(entries);
286 folder.sync_count = folder.sync_count.saturating_add(1);
287 folder.last_sync_at = Some(timestamp);
288 folder.errors.clear();
289 }
290 }
291
292 pub fn record_error(&mut self, namespace: iroh_docs::NamespaceId, error: impl Into<String>) {
293 if let Some(folder) = self.folders.get_mut(&namespace.to_string()) {
294 folder.errors.push(error.into());
295 if folder.errors.len() > 16 {
296 let remove_count = folder.errors.len().saturating_sub(16);
297 folder.errors.drain(..remove_count);
298 }
299 }
300 }
301}
302
303#[derive(Clone)]
305#[non_exhaustive]
306pub struct DaemonHandle {
307 pub state: Arc<RwLock<super::state::DaemonState>>,
308 pub folder_registry: Arc<RwLock<FolderRegistry>>,
309 pub shutdown_sender: broadcast::Sender<()>,
310 pub sync_trigger: mpsc::UnboundedSender<Option<String>>,
311 pub reload_requested: Arc<AtomicBool>,
312}
313
314impl DaemonHandle {
315 #[must_use]
317 pub fn new(state: super::state::DaemonState) -> Self {
318 let (shutdown_sender, _) = broadcast::channel(16);
319 let (sync_trigger, _) = mpsc::unbounded_channel();
320 Self::with_channels(
321 Arc::new(RwLock::new(state)),
322 Arc::new(RwLock::new(FolderRegistry::new())),
323 shutdown_sender,
324 sync_trigger,
325 )
326 }
327
328 #[must_use]
329 pub fn with_channels(
330 state: Arc<RwLock<super::state::DaemonState>>,
331 folder_registry: Arc<RwLock<FolderRegistry>>,
332 shutdown_sender: broadcast::Sender<()>,
333 sync_trigger: mpsc::UnboundedSender<Option<String>>,
334 ) -> Self {
335 Self::with_channels_and_reload(
336 state,
337 folder_registry,
338 shutdown_sender,
339 sync_trigger,
340 Arc::new(AtomicBool::new(false)),
341 )
342 }
343
344 #[must_use]
345 pub const fn with_channels_and_reload(
346 state: Arc<RwLock<super::state::DaemonState>>,
347 folder_registry: Arc<RwLock<FolderRegistry>>,
348 shutdown_sender: broadcast::Sender<()>,
349 sync_trigger: mpsc::UnboundedSender<Option<String>>,
350 reload_requested: Arc<AtomicBool>,
351 ) -> Self {
352 Self {
353 state,
354 folder_registry,
355 shutdown_sender,
356 sync_trigger,
357 reload_requested,
358 }
359 }
360
361 pub async fn set_status(&self, status: DaemonStatus) {
363 self.state.write().await.status = status;
364 }
365}
366
367#[derive(Clone, Debug)]
369pub struct IpcListener {
370 socket_path: PathBuf,
371}
372
373impl IpcListener {
374 #[must_use]
375 pub const fn new(socket_path: PathBuf) -> Self {
376 Self { socket_path }
377 }
378
379 #[must_use]
380 pub fn for_data_dir(data_dir: &Path) -> Self {
381 Self::new(daemon_socket_path(data_dir))
382 }
383
384 #[must_use]
385 pub fn socket_path(&self) -> &Path {
386 &self.socket_path
387 }
388
389 #[cfg(unix)]
395 pub fn bind(&self) -> Result<tokio::net::UnixListener> {
396 if let Some(parent) = self.socket_path.parent() {
397 std::fs::create_dir_all(parent)?;
398 }
399 if self.socket_path.exists() {
400 match std::os::unix::net::UnixStream::connect(&self.socket_path) {
401 Ok(_) => {
402 return Err(std::io::Error::new(
403 std::io::ErrorKind::AddrInUse,
404 "daemon IPC socket is already in use",
405 )
406 .into());
407 }
408 Err(_) => std::fs::remove_file(&self.socket_path)?,
409 }
410 }
411 let listener = tokio::net::UnixListener::bind(&self.socket_path)?;
412 set_owner_only_permissions(&self.socket_path)?;
413 Ok(listener)
414 }
415}
416
417#[derive(Clone)]
419pub struct IpcServer {
420 listener: IpcListener,
421 daemon_handle: DaemonHandle,
422 archive_context: Option<Arc<ArchiveContext>>,
423}
424
425#[derive(Clone)]
426struct ArchiveContext {
427 node: Arc<IrohNode>,
428 pool: Arc<ManagedPool>,
429}
430
431impl IpcServer {
432 #[must_use]
433 pub const fn new(socket_path: PathBuf, daemon_handle: DaemonHandle) -> Self {
434 Self {
435 listener: IpcListener::new(socket_path),
436 daemon_handle,
437 archive_context: None,
438 }
439 }
440
441 #[must_use]
443 pub fn with_archive_context(
444 socket_path: PathBuf,
445 daemon_handle: DaemonHandle,
446 node: Arc<IrohNode>,
447 pool: Arc<ManagedPool>,
448 ) -> Self {
449 Self {
450 listener: IpcListener::new(socket_path),
451 daemon_handle,
452 archive_context: Some(Arc::new(ArchiveContext { node, pool })),
453 }
454 }
455
456 pub async fn serve(&self) -> Result<()> {
462 #[cfg(unix)]
463 {
464 let listener = self.listener.bind()?;
465 let mut shutdown = self.daemon_handle.shutdown_sender.subscribe();
466 let result = loop {
467 tokio::select! {
468 shutdown_result = shutdown.recv() => {
469 match shutdown_result {
470 Ok(()) | Err(broadcast::error::RecvError::Closed) => break Ok(()),
471 Err(broadcast::error::RecvError::Lagged(_)) => {}
472 }
473 }
474 accepted = listener.accept() => {
475 let (stream, _) = accepted?;
476 if let Err(error) = self.handle_connection(stream).await {
477 tracing::error!(%error, "daemon IPC connection failed");
478 return Err(error);
479 }
480 }
481 }
482 };
483 if let Err(error) = std::fs::remove_file(self.listener.socket_path())
484 && self.listener.socket_path().exists()
485 {
486 return Err(error.into());
487 }
488 result
489 }
490 #[cfg(not(unix))]
491 {
492 Err(SyncwebError::operation(
493 "daemon IPC is unavailable",
494 "Unix sockets are not supported on this platform",
495 ))
496 }
497 }
498
499 pub async fn handle_request(&self, request: IpcRequest) -> IpcResponse {
501 match request.command {
502 IpcCommand::Status => IpcResponse::Status(self.daemon_handle.state.read().await.status),
503 IpcCommand::ListFolders => {
504 let folders = self.daemon_handle.folder_registry.read().await.statuses();
505 IpcResponse::FolderList(folders)
506 }
507 IpcCommand::AddFolder { namespace, path } => self.handle_add_folder(namespace, path).await,
508 IpcCommand::RemoveFolder { namespace } => self.handle_remove_folder(namespace).await,
509 IpcCommand::TriggerSync { namespace } => self.handle_trigger_sync(namespace),
510 IpcCommand::SetLogLevel { level } => IpcResponse::Ok {
511 message: format!("log level set to {level}"),
512 },
513 IpcCommand::ReloadConfig => self.handle_reload_config(),
514 IpcCommand::Shutdown { force } => self.handle_shutdown(force),
515 IpcCommand::ImportArchive { input, target, filter } => {
516 match self.handle_import_archive(input, target, filter).await {
517 Ok(result) => IpcResponse::ImportComplete(Box::new(result)),
518 Err(error) => response_from_error(error),
519 }
520 }
521 IpcCommand::ImportFiles { namespace, path } => self.handle_import_files_response(namespace, path).await,
522 IpcCommand::ExportArchive {
523 namespace,
524 version,
525 output,
526 } => match self.handle_export_archive(namespace, version, output).await {
527 Ok(result) => IpcResponse::ExportComplete(Box::new(result)),
528 Err(error) => response_from_error(error),
529 },
530 IpcCommand::Download { namespace, strategy } => self.handle_download_response(namespace, strategy).await,
531 IpcCommand::Join { ticket, path, mode } => self.handle_join(ticket, path, mode).await,
532 IpcCommand::Publish { namespace, blob } => self.handle_publish(namespace, blob).await,
533 IpcCommand::Subscribe { namespace, params } => self.handle_subscribe(namespace, params).await,
534 IpcCommand::CreateFolder { path, mode } => self.handle_create_folder(path, mode).await,
535 IpcCommand::HealthCheck { path } => self.handle_health_check(path).await,
536 IpcCommand::VerifyIntegrity { path } => self.handle_verify_integrity(path).await,
537 IpcCommand::Unsubscribe { namespace } => self.handle_unsubscribe(&namespace),
538 IpcCommand::LeaveFolder { namespace } => self.handle_leave_folder(namespace).await,
539 IpcCommand::Unpublish { namespace, blob } => self.handle_unpublish(namespace, blob).await,
540 IpcCommand::SnapshotCreate {
541 path,
542 description,
543 threads,
544 } => self.handle_snapshot_create(path, description, threads).await,
545 IpcCommand::SnapshotList { path } => self.handle_snapshot_list(path).await,
546 IpcCommand::SnapshotDelete { id } => self.handle_snapshot_delete(id).await,
547 IpcCommand::CollectionPublish {
548 path,
549 namespace,
550 sequence,
551 bootstrap,
552 } => {
553 self.handle_collection_publish(path, namespace, sequence, bootstrap)
554 .await
555 }
556 }
557 }
558
559 async fn handle_add_folder(&self, namespace: String, path: PathBuf) -> IpcResponse {
560 match iroh_docs::NamespaceId::from_str(&namespace) {
561 Ok(namespace_id) => {
562 let mut registry = self.daemon_handle.folder_registry.write().await;
563 match registry.add_or_update(FolderEntry::new(namespace_id, path)) {
564 Ok(()) => IpcResponse::Ok {
565 message: "folder added".to_owned(),
566 },
567 Err(error) => response_from_error(error),
568 }
569 }
570 Err(error) => IpcResponse::Error {
571 message: format!("invalid folder namespace: {error}"),
572 },
573 }
574 }
575
576 async fn handle_remove_folder(&self, namespace: String) -> IpcResponse {
577 match iroh_docs::NamespaceId::from_str(&namespace) {
578 Ok(namespace_id) => {
579 let removed = self.daemon_handle.folder_registry.write().await.remove(&namespace_id);
580 if removed.is_some() {
581 let _ = cancel_session(namespace_id);
582 IpcResponse::Ok {
583 message: "folder removed".to_owned(),
584 }
585 } else {
586 IpcResponse::Error {
587 message: format!("folder not found: {namespace}"),
588 }
589 }
590 }
591 Err(error) => IpcResponse::Error {
592 message: format!("invalid folder namespace: {error}"),
593 },
594 }
595 }
596
597 fn handle_trigger_sync(&self, namespace: Option<String>) -> IpcResponse {
598 match self.daemon_handle.sync_trigger.send(namespace) {
599 Ok(()) => IpcResponse::Ok {
600 message: "synchronization requested".to_owned(),
601 },
602 Err(error) => response_from_error(error),
603 }
604 }
605
606 fn handle_reload_config(&self) -> IpcResponse {
607 self.daemon_handle.reload_requested.store(true, Ordering::Release);
608 if self.daemon_handle.sync_trigger.send(None).is_err() {
609 tracing::debug!("daemon reload wake-up channel is not connected");
610 }
611 IpcResponse::Ok {
612 message: "configuration reload requested".to_owned(),
613 }
614 }
615
616 fn handle_shutdown(&self, force: bool) -> IpcResponse {
617 if let Err(error) = self.daemon_handle.shutdown_sender.send(()) {
618 return response_from_error(error);
619 }
620 IpcResponse::Ok {
621 message: if force {
622 "forced shutdown requested".to_owned()
623 } else {
624 "shutdown requested".to_owned()
625 },
626 }
627 }
628
629 async fn handle_download_response(&self, namespace: String, strategy: FetchStrategy) -> IpcResponse {
630 match self.handle_download(namespace, strategy).await {
631 Ok(bytes_transferred) => IpcResponse::DownloadComplete { bytes_transferred },
632 Err(error) => response_from_error(error),
633 }
634 }
635
636 async fn handle_import_files_response(&self, namespace: Option<String>, path: PathBuf) -> IpcResponse {
637 match self.handle_import_files(namespace, path).await {
638 Ok(entries) => IpcResponse::ImportFilesComplete { entries },
639 Err(error) => response_from_error(error),
640 }
641 }
642
643 async fn handle_download(&self, namespace: String, strategy: FetchStrategy) -> Result<u64> {
644 let context = self.archive_context.clone().ok_or_else(|| {
645 SyncwebError::operation("daemon download IPC is unavailable", "server has no node context")
646 })?;
647 let namespace_id = iroh_docs::NamespaceId::from_str(&namespace)
648 .map_err(|error| SyncwebError::operation("invalid download namespace", error))?;
649 let sync = SyncEngine::new(
650 FolderManager::new(&context.node),
651 context.node.blob_store().clone(),
652 context.node.docs_engine().clone(),
653 context.node.gossip_service().clone(),
654 );
655 let mut intent = sync.fetch(namespace_id, strategy).await?;
656 let mut bytes_transferred = 0_u64;
657 while let Some(event) = intent.next().await {
658 match event {
659 SyncEvent::Stats(stats) => {
660 bytes_transferred = bytes_transferred.max(stats.bytes_transferred);
661 }
662 SyncEvent::Failed(message) => {
663 return Err(SyncwebError::operation("daemon download failed", message));
664 }
665 SyncEvent::Finished => break,
666 SyncEvent::Started
667 | SyncEvent::Progress { .. }
668 | SyncEvent::Paused
669 | SyncEvent::Resumed
670 | SyncEvent::Cancelled => {}
671 }
672 }
673 Ok(bytes_transferred)
674 }
675
676 async fn handle_import_files(&self, namespace: Option<String>, path: PathBuf) -> Result<u64> {
677 let context = self.archive_context.clone().ok_or_else(|| {
678 SyncwebError::operation("daemon filesystem import is unavailable", "server has no node context")
679 })?;
680 let namespace_id = if let Some(value) = namespace {
681 iroh_docs::NamespaceId::from_str(&value)
682 .map_err(|error| SyncwebError::operation("invalid import namespace", error))?
683 } else {
684 let folders = self.daemon_handle.folder_registry.read().await.statuses();
685 let [folder] = folders.as_slice() else {
686 return Err(SyncwebError::operation(
687 "cannot infer import namespace",
688 "specify a folder when more than one folder is managed",
689 ));
690 };
691 iroh_docs::NamespaceId::from_str(&folder.namespace)
692 .map_err(|error| SyncwebError::operation("invalid managed folder namespace", error))?
693 };
694 let folder = FolderManager::new(&context.node).get(namespace_id).await?;
695 let root = if path.is_dir() {
696 path.clone()
697 } else {
698 path.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf)
699 };
700 let importer = Importer::new(
701 context.node.blob_store().clone(),
702 context.node.docs_engine().clone(),
703 folder.doc().clone(),
704 folder.author(),
705 )
706 .with_root(root);
707 let entries = importer.import_path(path).await?;
708 u64::try_from(entries.len()).map_err(|error| SyncwebError::operation("import entry count overflowed", error))
709 }
710
711 async fn handle_import_archive(
712 &self,
713 input: PathBuf,
714 target: PathBuf,
715 filter: Option<FilterConfig>,
716 ) -> Result<DropImportResult> {
717 let context = self.archive_context.clone().ok_or_else(|| {
718 SyncwebError::operation("daemon archive IPC is unavailable", "server has no node context")
719 })?;
720 let filter_engine = filter.map(FilterEngine::new).transpose()?;
721 let options = filter_engine.map_or_else(DropImportOptions::default, |value| {
722 DropImportOptions::default().with_filter(value)
723 });
724 let importer = DropImporter::new(context.node.blob_store().clone());
725 let mut result = importer
726 .import_archive(&input, options, Some(context.pool.as_ref()))
727 .await?;
728 importer.materialize(&result, &target).await?;
729 let folder = FolderManager::new(&context.node).create(SyncMode::SendReceive).await?;
730 let store = CollectionStore::new(
731 folder.doc().clone(),
732 folder.author(),
733 context.node.blob_store().clone(),
734 context.node.docs_engine().clone(),
735 );
736 store.publish(&result.collection_manifest, 1).await?;
737 result.namespace_id = Some(folder.namespace_id());
738 Ok(result)
739 }
740
741 async fn handle_export_archive(
742 &self,
743 namespace: String,
744 version: Option<String>,
745 output: PathBuf,
746 ) -> Result<DropExportResult> {
747 let context = self.archive_context.clone().ok_or_else(|| {
748 SyncwebError::operation("daemon archive IPC is unavailable", "server has no node context")
749 })?;
750 let namespace_id = iroh_docs::NamespaceId::from_str(&namespace)
751 .map_err(|error| SyncwebError::operation("invalid export namespace", error))?;
752 let folder = FolderManager::new(&context.node).get(namespace_id).await?;
753 let head = Self::latest_collection_head(&context.node, folder.doc()).await?;
754 let manifests = Self::collection_manifests(&context.node, folder.doc(), head).await?;
755 let options = version.map_or_else(DropExportOptions::default, |value| {
756 DropExportOptions::default().with_version(value)
757 });
758 DropExporter::new(context.node.blob_store().clone())
759 .export_drop_with_options(&manifests, output, options, Some(context.pool.as_ref()))
760 .await
761 }
762
763 async fn handle_join(&self, ticket: String, path: PathBuf, mode: SyncMode) -> IpcResponse {
764 let context = match &self.archive_context {
765 Some(ctx) => ctx.clone(),
766 None => {
767 return IpcResponse::Error {
768 message: "daemon join IPC is unavailable: server has no node context".to_owned(),
769 };
770 }
771 };
772 match tokio::fs::create_dir_all(&path).await {
773 Ok(()) => {}
774 Err(error) => {
775 return IpcResponse::Error {
776 message: format!("failed to create folder path: {error}"),
777 };
778 }
779 }
780 let manager = FolderManager::new(&context.node);
781 match manager.join(ticket, mode).await {
782 Ok(folder) => {
783 let namespace = folder.namespace_id().to_string();
784 IpcResponse::Ok {
785 message: format!("joined: {namespace}"),
786 }
787 }
788 Err(error) => response_from_error(error),
789 }
790 }
791
792 async fn handle_publish(&self, namespace: String, blob: Option<String>) -> IpcResponse {
793 let context = match &self.archive_context {
794 Some(ctx) => ctx.clone(),
795 None => {
796 return IpcResponse::Error {
797 message: "daemon publish IPC is unavailable: server has no node context".to_owned(),
798 };
799 }
800 };
801 let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
802 Ok(id) => id,
803 Err(error) => {
804 return IpcResponse::Error {
805 message: format!("invalid namespace: {error}"),
806 };
807 }
808 };
809 let manager = FolderManager::new(&context.node);
810 let folder = match manager.get(namespace_id).await {
811 Ok(f) => f,
812 Err(error) => return response_from_error(error),
813 };
814 match blob {
815 Some(blob_hash) => {
816 let hash = match blob_hash.parse::<iroh_blobs::Hash>() {
817 Ok(h) => h,
818 Err(error) => {
819 return IpcResponse::Error {
820 message: format!("invalid blob hash: {error}"),
821 };
822 }
823 };
824 match folder.publish_blob(context.node.endpoint().addr(), hash).await {
825 Ok(ticket) => IpcResponse::Ok {
826 message: format!("blob_ticket: {ticket}"),
827 },
828 Err(error) => response_from_error(error),
829 }
830 }
831 None => match folder.ticket(context.node.endpoint().addr(), false).await {
832 Ok(ticket) => IpcResponse::Ok {
833 message: format!("ticket: {ticket}"),
834 },
835 Err(error) => response_from_error(error),
836 },
837 }
838 }
839
840 async fn handle_subscribe(&self, namespace: String, params: SubscribeParams) -> IpcResponse {
841 let context = match &self.archive_context {
842 Some(ctx) => ctx.clone(),
843 None => {
844 return IpcResponse::Error {
845 message: "daemon subscribe IPC is unavailable: server has no node context".to_owned(),
846 };
847 }
848 };
849 let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
850 Ok(id) => id,
851 Err(error) => {
852 return IpcResponse::Error {
853 message: format!("invalid namespace: {error}"),
854 };
855 }
856 };
857 let manager = FolderManager::new(&context.node);
858 let sync = SyncEngine::new(
859 manager,
860 context.node.blob_store().clone(),
861 context.node.docs_engine().clone(),
862 context.node.gossip_service().clone(),
863 );
864 match sync.subscribe(namespace_id, params).await {
865 Ok(_intent) => IpcResponse::Ok {
866 message: format!("subscribed: {namespace}"),
867 },
868 Err(error) => response_from_error(error),
869 }
870 }
871
872 async fn handle_create_folder(&self, path: PathBuf, mode: String) -> IpcResponse {
873 let context = match &self.archive_context {
874 Some(ctx) => ctx.clone(),
875 None => {
876 return IpcResponse::Error {
877 message: "daemon create-folder IPC is unavailable: server has no node context".to_owned(),
878 };
879 }
880 };
881 match std::fs::create_dir_all(&path) {
882 Ok(()) => {}
883 Err(error) => {
884 return IpcResponse::Error {
885 message: format!("failed to create directory: {error}"),
886 };
887 }
888 }
889 let sync_mode = match SyncMode::from_str(&mode) {
890 Ok(m) => m,
891 Err(error) => {
892 return IpcResponse::Error {
893 message: format!("invalid sync mode: {error}"),
894 };
895 }
896 };
897 let manager = FolderManager::new(&context.node);
898 match manager.create(sync_mode).await {
899 Ok(folder) => {
900 let namespace = folder.namespace_id().to_string();
901 let namespace_id = folder.namespace_id();
902 if self
903 .daemon_handle
904 .folder_registry
905 .write()
906 .await
907 .add(FolderEntry::new(namespace_id, path))
908 .is_err()
909 {
910 tracing::warn!(%namespace, "folder already in daemon registry");
911 }
912 match folder.ticket(context.node.endpoint().addr(), true).await {
913 Ok(ticket) => IpcResponse::Ok {
914 message: format!("namespace: {namespace}\nticket: {ticket}"),
915 },
916 Err(error) => response_from_error(error),
917 }
918 }
919 Err(error) => response_from_error(error),
920 }
921 }
922
923 async fn handle_health_check(&self, path: PathBuf) -> IpcResponse {
924 let context = match &self.archive_context {
925 Some(ctx) => ctx.clone(),
926 None => {
927 return IpcResponse::Error {
928 message: "daemon health IPC is unavailable: server has no node context".to_owned(),
929 };
930 }
931 };
932 let manager = FolderManager::new(&context.node);
933 let folder = match resolve_folder_for_daemon(&manager, &path).await {
934 Ok(f) => f,
935 Err(error) => return error,
936 };
937 let entries = match context.node.docs_engine().list_latest(folder.doc()).await {
938 Ok(e) => e,
939 Err(error) => return response_from_error(error),
940 };
941 let mut candidates = Vec::new();
942 for entry in entries {
943 if entry.key().starts_with(b"sys/") {
944 continue;
945 }
946 let path_str = match String::from_utf8(entry.key().to_vec()) {
947 Ok(s) => s,
948 Err(error) => {
949 return IpcResponse::Error {
950 message: format!("folder entry path is not UTF-8: {error}"),
951 };
952 }
953 };
954 let local = match folder.has_local(entry.content_hash()).await {
955 Ok(l) => l,
956 Err(error) => return response_from_error(error),
957 };
958 candidates.push(FetchCandidate::new(
959 path_str,
960 entry.content_hash(),
961 entry.content_len(),
962 0,
963 local,
964 ));
965 }
966 let report = HealthReport::from_candidates(&candidates, 4);
967 IpcResponse::Ok {
968 message: format!(
969 "total: {}, well-seeded: {}, under-seeded: {}, unseeded: {}",
970 report.total, report.well_seeded, report.under_seeded, report.unseeded,
971 ),
972 }
973 }
974
975 async fn handle_verify_integrity(&self, path: PathBuf) -> IpcResponse {
976 let context = match &self.archive_context {
977 Some(ctx) => ctx.clone(),
978 None => {
979 return IpcResponse::Error {
980 message: "daemon verify IPC is unavailable: server has no node context".to_owned(),
981 };
982 }
983 };
984 let manager = FolderManager::new(&context.node);
985 let folder = match resolve_folder_for_daemon(&manager, &path).await {
986 Ok(f) => f,
987 Err(error) => return error,
988 };
989 let checker = IntegrityChecker::new(context.node.blob_store().clone(), context.node.docs_engine().clone());
990 match checker.verify_folder(&folder).await {
991 Ok(result) => {
992 let valid = result.is_valid();
993 IpcResponse::Ok {
994 message: format!(
995 "total: {}, verified: {}, corrupted: {}, missing: {}, valid: {valid}",
996 result.total,
997 result.verified,
998 result.corrupted.len(),
999 result.missing.len(),
1000 ),
1001 }
1002 }
1003 Err(error) => response_from_error(error),
1004 }
1005 }
1006
1007 #[allow(clippy::unused_self)]
1008 fn handle_unsubscribe(&self, namespace: &str) -> IpcResponse {
1009 match iroh_docs::NamespaceId::from_str(namespace) {
1010 Ok(namespace_id) => {
1011 if cancel_session(namespace_id) {
1012 IpcResponse::Ok {
1013 message: format!("unsubscribed: {namespace}"),
1014 }
1015 } else {
1016 IpcResponse::Error {
1017 message: format!("no active session for {namespace}"),
1018 }
1019 }
1020 }
1021 Err(error) => IpcResponse::Error {
1022 message: format!("invalid namespace: {error}"),
1023 },
1024 }
1025 }
1026
1027 async fn handle_leave_folder(&self, namespace: String) -> IpcResponse {
1028 let context = match &self.archive_context {
1029 Some(ctx) => ctx.clone(),
1030 None => {
1031 return IpcResponse::Error {
1032 message: "daemon leave-folder IPC is unavailable: server has no node context".to_owned(),
1033 };
1034 }
1035 };
1036 let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
1037 Ok(id) => id,
1038 Err(error) => {
1039 return IpcResponse::Error {
1040 message: format!("invalid namespace: {error}"),
1041 };
1042 }
1043 };
1044 let _ = cancel_session(namespace_id);
1045 let manager = FolderManager::new(&context.node);
1046 match manager.drop(namespace_id).await {
1047 Ok(()) => {
1048 let _ = self.daemon_handle.folder_registry.write().await.remove(&namespace_id);
1049 IpcResponse::Ok {
1050 message: format!("left: {namespace}"),
1051 }
1052 }
1053 Err(error) => response_from_error(error),
1054 }
1055 }
1056
1057 async fn handle_unpublish(&self, namespace: String, blob: String) -> IpcResponse {
1058 let context = match &self.archive_context {
1059 Some(ctx) => ctx.clone(),
1060 None => {
1061 return IpcResponse::Error {
1062 message: "daemon unpublish IPC is unavailable: server has no node context".to_owned(),
1063 };
1064 }
1065 };
1066 let namespace_id = match iroh_docs::NamespaceId::from_str(&namespace) {
1067 Ok(id) => id,
1068 Err(error) => {
1069 return IpcResponse::Error {
1070 message: format!("invalid namespace: {error}"),
1071 };
1072 }
1073 };
1074 let hash = match blob.parse::<iroh_blobs::Hash>() {
1075 Ok(h) => h,
1076 Err(error) => {
1077 return IpcResponse::Error {
1078 message: format!("invalid blob hash: {error}"),
1079 };
1080 }
1081 };
1082 let manager = FolderManager::new(&context.node);
1083 let folder = match manager.get(namespace_id).await {
1084 Ok(f) => f,
1085 Err(error) => return response_from_error(error),
1086 };
1087 match folder.unpublish_blob(hash).await {
1088 Ok(()) => IpcResponse::Ok {
1089 message: format!("unpublished: {blob}"),
1090 },
1091 Err(error) => response_from_error(error),
1092 }
1093 }
1094
1095 async fn handle_snapshot_create(&self, path: PathBuf, description: Option<String>, threads: usize) -> IpcResponse {
1096 let context = match &self.archive_context {
1097 Some(ctx) => ctx.clone(),
1098 None => {
1099 return IpcResponse::Error {
1100 message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
1101 };
1102 }
1103 };
1104 let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
1105 let result = if path.exists() {
1106 snapshots.create_from_path(&path, threads, description).await
1107 } else {
1108 let manager = FolderManager::new(&context.node);
1109 let folder = match resolve_folder_for_daemon(&manager, &path).await {
1110 Ok(f) => f,
1111 Err(error) => return error,
1112 };
1113 snapshots.create_for_folder(&folder, description).await
1114 };
1115 match result {
1116 Ok(snapshot) => IpcResponse::Ok {
1117 message: format!(
1118 "snapshot: {}\nroot_hash: {}\nfiles: {}\nsize: {}",
1119 snapshot.id, snapshot.root_hash, snapshot.file_count, snapshot.total_size,
1120 ),
1121 },
1122 Err(error) => response_from_error(error),
1123 }
1124 }
1125
1126 async fn handle_snapshot_list(&self, path: PathBuf) -> IpcResponse {
1127 let context = match &self.archive_context {
1128 Some(ctx) => ctx.clone(),
1129 None => {
1130 return IpcResponse::Error {
1131 message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
1132 };
1133 }
1134 };
1135 let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
1136 let namespace = path.to_string_lossy().parse::<iroh_docs::NamespaceId>().ok();
1137 match snapshots.list().await {
1138 Ok(all) => {
1139 let count = all
1140 .into_iter()
1141 .filter(|s| namespace.is_none_or(|id| s.namespace_id == Some(id)))
1142 .count();
1143 IpcResponse::Ok {
1144 message: format!("snapshots: {count}"),
1145 }
1146 }
1147 Err(error) => response_from_error(error),
1148 }
1149 }
1150
1151 async fn handle_snapshot_delete(&self, id: String) -> IpcResponse {
1152 let context = match &self.archive_context {
1153 Some(ctx) => ctx.clone(),
1154 None => {
1155 return IpcResponse::Error {
1156 message: "daemon snapshot IPC is unavailable: server has no node context".to_owned(),
1157 };
1158 }
1159 };
1160 let hash = match id.parse::<iroh_blobs::Hash>() {
1161 Ok(h) => h,
1162 Err(error) => {
1163 return IpcResponse::Error {
1164 message: format!("invalid snapshot id: {error}"),
1165 };
1166 }
1167 };
1168 let snapshots = SnapshotStore::with_docs(context.node.blob_store().clone(), context.node.docs_engine().clone());
1169 match snapshots.delete(hash).await {
1170 Ok(()) => IpcResponse::Ok {
1171 message: format!("deleted: {id}"),
1172 },
1173 Err(error) => response_from_error(error),
1174 }
1175 }
1176
1177 async fn handle_collection_publish(
1178 &self,
1179 path: PathBuf,
1180 namespace: String,
1181 sequence: u64,
1182 bootstrap: Vec<String>,
1183 ) -> IpcResponse {
1184 let context = match &self.archive_context {
1185 Some(ctx) => ctx.clone(),
1186 None => {
1187 return IpcResponse::Error {
1188 message: "daemon collection-publish IPC is unavailable: server has no node context".to_owned(),
1189 };
1190 }
1191 };
1192 let manifest_path = path.join(".syncweb-collection.json");
1193 let manifest_bytes = match tokio::fs::read(&manifest_path).await {
1194 Ok(bytes) => bytes,
1195 Err(error) => {
1196 return IpcResponse::Error {
1197 message: format!("failed to read collection manifest: {error}"),
1198 };
1199 }
1200 };
1201 let manifest = match CollectionManifest::from_bytes(manifest_bytes) {
1202 Ok(m) => m,
1203 Err(error) => return response_from_error(error),
1204 };
1205 for entry in &manifest.entries {
1206 let file_path = path.join(&entry.logical_path);
1207 match context.node.blob_store().add_file(&file_path).await {
1208 Ok(hash) => {
1209 if hash != entry.content_id {
1210 return IpcResponse::Error {
1211 message: format!(
1212 "collection content changed while publishing: {}",
1213 entry.logical_path.display()
1214 ),
1215 };
1216 }
1217 }
1218 Err(error) => return response_from_error(error),
1219 }
1220 }
1221 let namespace_id = match namespace.parse::<iroh_docs::NamespaceId>() {
1222 Ok(id) => id,
1223 Err(error) => {
1224 return IpcResponse::Error {
1225 message: format!("invalid namespace: {error}"),
1226 };
1227 }
1228 };
1229 let manager = FolderManager::new(&context.node);
1230 let folder = match manager.get(namespace_id).await {
1231 Ok(f) => f,
1232 Err(error) => return response_from_error(error),
1233 };
1234 let store = CollectionStore::new(
1235 folder.doc().clone(),
1236 folder.author(),
1237 context.node.blob_store().clone(),
1238 context.node.docs_engine().clone(),
1239 );
1240 let head = match store.publish(&manifest, sequence).await {
1241 Ok(h) => h,
1242 Err(error) => return response_from_error(error),
1243 };
1244 let name = manifest
1245 .package
1246 .as_ref()
1247 .map_or_else(|| manifest.collection_id.to_string(), |profile| profile.name.clone());
1248 let ticket = context.node.blob_store().ticket(context.node.endpoint(), head.manifest);
1249 let announcement = match PackageAnnouncement::new(
1250 manifest.collection_id,
1251 name,
1252 manifest.version.clone(),
1253 head.sequence,
1254 head.manifest,
1255 ticket.to_string(),
1256 context.node.endpoint().id(),
1257 ) {
1258 Ok(a) => a,
1259 Err(error) => return response_from_error(error),
1260 };
1261 let bootstrap_nodes: Vec<_> = bootstrap
1262 .into_iter()
1263 .filter_map(|b| b.parse::<iroh::PublicKey>().ok())
1264 .collect();
1265 let catalog = PackageCatalog::new(context.node.gossip_service());
1266 let topic = if bootstrap_nodes.is_empty() {
1267 match catalog.subscribe(bootstrap_nodes).await {
1268 Ok(t) => t,
1269 Err(error) => return response_from_error(error),
1270 }
1271 } else {
1272 match catalog.subscribe_and_join(bootstrap_nodes).await {
1273 Ok(t) => t,
1274 Err(error) => return response_from_error(error),
1275 }
1276 };
1277 let (sender, _receiver) = GossipService::split(topic);
1278 if let Err(error) = catalog.announce(&sender, &announcement).await {
1279 return response_from_error(error);
1280 }
1281 IpcResponse::Ok {
1282 message: format!(
1283 "manifest: {}\nmanifest_ticket: {}\nsequence: {}",
1284 head.manifest, announcement.manifest_ticket, head.sequence,
1285 ),
1286 }
1287 }
1288
1289 #[cfg(unix)]
1290 async fn handle_connection(&self, stream: tokio::net::UnixStream) -> Result<()> {
1291 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1292 let (read_half, mut write_half) = stream.into_split();
1293 let mut line = Vec::new();
1294 BufReader::new(read_half).read_until(b'\n', &mut line).await?;
1295 let response = match serde_json::from_slice::<IpcRequest>(line.trim_ascii()) {
1296 Ok(request) => self.handle_request(request).await,
1297 Err(error) => IpcResponse::Error {
1298 message: format!("invalid daemon request: {error}"),
1299 },
1300 };
1301 let mut bytes = serde_json::to_vec(&response)
1302 .map_err(|error| SyncwebError::operation("failed to serialize IPC response", error))?;
1303 bytes.push(b'\n');
1304 write_half.write_all(&bytes).await?;
1305 Ok(())
1306 }
1307
1308 async fn latest_collection_head(node: &IrohNode, doc: &iroh_docs::api::Doc) -> Result<CollectionHead> {
1309 let entries = node.docs_engine().list_latest(doc).await?;
1310 let head_entry = entries
1311 .iter()
1312 .find(|entry| entry.key().starts_with(b"collections/") && entry.key().ends_with(b"/head"))
1313 .ok_or_else(|| SyncwebError::InvalidConfig("folder has no published collection head".to_owned()))?;
1314 let bytes = node.blob_store().get(head_entry.content_hash()).await?;
1315 let head = serde_json::from_slice(&bytes)
1316 .map_err(|error| SyncwebError::operation("failed to deserialize collection head", error))?;
1317 Ok(head)
1318 }
1319
1320 async fn collection_manifests(
1321 node: &IrohNode,
1322 doc: &iroh_docs::api::Doc,
1323 head: CollectionHead,
1324 ) -> Result<Vec<CollectionManifest>> {
1325 let prefix = format!("collections/{}/manifests/", head.collection_id);
1326 let entries = node.docs_engine().list_latest(doc).await?;
1327 let mut manifests = Vec::new();
1328 for entry in entries {
1329 if !entry.key().starts_with(prefix.as_bytes()) {
1330 continue;
1331 }
1332 let bytes = node.blob_store().get(entry.content_hash()).await?;
1333 let manifest = CollectionManifest::from_bytes(bytes)?;
1334 manifests.push(manifest);
1335 }
1336 if manifests.is_empty() {
1337 return Err(SyncwebError::InvalidConfig(
1338 "folder has no published collection manifests".to_owned(),
1339 ));
1340 }
1341 Ok(manifests)
1342 }
1343}
1344
1345fn response_from_error(error: impl std::fmt::Display) -> IpcResponse {
1346 IpcResponse::Error {
1347 message: error.to_string(),
1348 }
1349}
1350
1351async fn resolve_folder_for_daemon(
1352 manager: &FolderManager,
1353 selector: &Path,
1354) -> std::result::Result<crate::folder::SyncwebFolder, IpcResponse> {
1355 if let Ok(namespace) = selector.to_string_lossy().parse::<iroh_docs::NamespaceId>() {
1356 return manager.get(namespace).await.map_err(|error| IpcResponse::Error {
1357 message: format!("folder not found: {error}"),
1358 });
1359 }
1360 let folders = manager.list().await.map_err(|error| IpcResponse::Error {
1361 message: format!("failed to list folders: {error}"),
1362 })?;
1363 match folders.as_slice() {
1364 [folder] => Ok(folder.clone()),
1365 [] => Err(IpcResponse::Error {
1366 message: "no synchronized folders are available".to_owned(),
1367 }),
1368 _ => Err(IpcResponse::Error {
1369 message: "folder path is not a namespace ID and more than one synchronized folder is available".to_owned(),
1370 }),
1371 }
1372}
1373
1374#[derive(Clone, Debug)]
1376pub struct IpcClient {
1377 socket_path: PathBuf,
1378}
1379
1380impl IpcClient {
1381 #[must_use]
1382 pub fn new(data_dir: &Path) -> Self {
1383 Self::from_socket_path(daemon_socket_path(data_dir))
1384 }
1385
1386 #[must_use]
1387 pub const fn from_socket_path(socket_path: PathBuf) -> Self {
1388 Self { socket_path }
1389 }
1390
1391 #[must_use]
1392 pub fn socket_path(&self) -> &Path {
1393 &self.socket_path
1394 }
1395
1396 pub async fn send(&self, request: IpcRequest) -> Result<IpcResponse> {
1403 #[cfg(unix)]
1404 {
1405 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1406 use tokio::time::timeout;
1407 let mut stream = timeout(IPC_TIMEOUT, tokio::net::UnixStream::connect(&self.socket_path))
1408 .await
1409 .map_err(|error| SyncwebError::operation("daemon IPC connection timed out", error))?
1410 .map_err(|error| SyncwebError::operation("daemon IPC connection failed", error))?;
1411 let mut message = serde_json::to_vec(&request)
1412 .map_err(|error| SyncwebError::operation("failed to serialize IPC request", error))?;
1413 message.push(b'\n');
1414 timeout(IPC_TIMEOUT, stream.write_all(&message))
1415 .await
1416 .map_err(|error| SyncwebError::operation("daemon IPC write timed out", error))?
1417 .map_err(|error| SyncwebError::operation("daemon IPC write failed", error))?;
1418 let mut response = Vec::new();
1419 let mut reader = BufReader::new(stream);
1420 timeout(IPC_TIMEOUT, reader.read_until(b'\n', &mut response))
1421 .await
1422 .map_err(|error| SyncwebError::operation("daemon IPC read timed out", error))?
1423 .map_err(|error| SyncwebError::operation("daemon IPC read failed", error))?;
1424 if response.is_empty() {
1425 return Err(SyncwebError::operation(
1426 "daemon IPC returned no response",
1427 "connection closed",
1428 ));
1429 }
1430 serde_json::from_slice(response.trim_ascii())
1431 .map_err(|error| SyncwebError::operation("failed to deserialize IPC response", error))
1432 }
1433 #[cfg(not(unix))]
1434 {
1435 let _ = request;
1436 Err(SyncwebError::operation(
1437 "daemon IPC is unavailable",
1438 "Unix sockets are not supported on this platform",
1439 ))
1440 }
1441 }
1442
1443 pub fn status_sync(&self) -> Result<DaemonStatus> {
1449 #[cfg(unix)]
1450 {
1451 use std::{
1452 io::{BufRead, Write},
1453 os::unix::net::UnixStream,
1454 };
1455
1456 let stream = UnixStream::connect(&self.socket_path)?;
1457 stream.set_read_timeout(Some(IPC_TIMEOUT))?;
1458 stream.set_write_timeout(Some(IPC_TIMEOUT))?;
1459 let mut writer = stream.try_clone()?;
1460 let request = serde_json::to_vec(&IpcRequest::new(IpcCommand::Status))
1461 .map_err(|error| SyncwebError::operation("failed to serialize IPC request", error))?;
1462 writer.write_all(&request)?;
1463 writer.write_all(b"\n")?;
1464 let mut line = String::new();
1465 std::io::BufReader::new(stream).read_line(&mut line)?;
1466 match serde_json::from_str::<IpcResponse>(&line)
1467 .map_err(|error| SyncwebError::operation("failed to deserialize IPC response", error))?
1468 {
1469 IpcResponse::Status(status) => Ok(status),
1470 IpcResponse::Error { message } => Err(SyncwebError::operation("daemon status request failed", message)),
1471 IpcResponse::Ok { .. }
1472 | IpcResponse::FolderList(_)
1473 | IpcResponse::DownloadComplete { .. }
1474 | IpcResponse::ImportFilesComplete { .. }
1475 | IpcResponse::ImportComplete(_)
1476 | IpcResponse::ExportComplete(_) => Err(SyncwebError::operation(
1477 "daemon status request returned an unexpected response",
1478 "unexpected response",
1479 )),
1480 }
1481 }
1482 #[cfg(not(unix))]
1483 {
1484 Err(SyncwebError::operation(
1485 "daemon IPC is unavailable",
1486 "Unix sockets are not supported on this platform",
1487 ))
1488 }
1489 }
1490}
1491
1492fn set_owner_only_permissions(path: &Path) -> Result<()> {
1493 #[cfg(unix)]
1494 {
1495 use std::os::unix::fs::PermissionsExt;
1496 let mut permissions = std::fs::metadata(path)?.permissions();
1497 permissions.set_mode(0o600);
1498 std::fs::set_permissions(path, permissions)?;
1499 }
1500 let _ = path;
1501 Ok(())
1502}
1503
1504#[cfg(test)]
1505mod tests {
1506 use std::{path::PathBuf, sync::Arc, time::Duration};
1507
1508 #[cfg(unix)]
1509 use std::os::unix::fs::PermissionsExt;
1510
1511 use super::*;
1512 use crate::daemon::{DaemonState, DaemonStatus};
1513
1514 fn socket_path() -> PathBuf {
1515 std::env::temp_dir().join(format!("syncweb-ipc-{}.sock", uuid::Uuid::new_v4()))
1516 }
1517
1518 fn state() -> DaemonState {
1519 DaemonState::new(
1520 std::process::id(),
1521 "node",
1522 1,
1523 std::env::temp_dir(),
1524 DaemonStatus::Running,
1525 )
1526 }
1527
1528 #[test]
1529 fn request_round_trips_as_json() {
1530 let request = IpcRequest::new(IpcCommand::Download {
1531 namespace: "namespace".to_owned(),
1532 strategy: FetchStrategy::default(),
1533 });
1534 let encoded = serde_json::to_vec(&request).expect("serialize request");
1535 let decoded: IpcRequest = serde_json::from_slice(&encoded).expect("deserialize request");
1536 assert!(matches!(decoded.command, IpcCommand::Download { .. }));
1537 }
1538
1539 #[tokio::test]
1540 async fn handle_request_updates_registry_and_trigger() {
1541 let (sync_trigger, mut sync_receiver) = mpsc::unbounded_channel();
1542 let handle = DaemonHandle::with_channels(
1543 Arc::new(RwLock::new(state())),
1544 Arc::new(RwLock::new(FolderRegistry::new())),
1545 broadcast::channel(4).0,
1546 sync_trigger,
1547 );
1548 let server = IpcServer::new(socket_path(), handle);
1549 let namespace = iroh_docs::NamespaceSecret::from_bytes(&[7; 32]).id().to_string();
1550
1551 assert!(matches!(
1552 server
1553 .handle_request(IpcRequest::new(IpcCommand::AddFolder {
1554 namespace: namespace.clone(),
1555 path: PathBuf::from("/tmp/folder"),
1556 }))
1557 .await,
1558 IpcResponse::Ok { .. }
1559 ));
1560 assert!(matches!(
1561 server
1562 .handle_request(IpcRequest::new(IpcCommand::ListFolders))
1563 .await,
1564 IpcResponse::FolderList(folders)
1565 if folders.len() == 1 && folders.first().is_some_and(|folder| folder.namespace == namespace)
1566 ));
1567 assert!(matches!(
1568 server
1569 .handle_request(IpcRequest::new(IpcCommand::TriggerSync {
1570 namespace: Some(namespace.clone()),
1571 }))
1572 .await,
1573 IpcResponse::Ok { .. }
1574 ));
1575 assert_eq!(sync_receiver.recv().await, Some(Some(namespace)));
1576 }
1577
1578 #[cfg(unix)]
1579 #[tokio::test]
1580 async fn client_round_trips_with_server() {
1581 let path = socket_path();
1582 let handle = DaemonHandle::new(state());
1583 let mut shutdown_receiver = handle.shutdown_sender.subscribe();
1584 let server = IpcServer::new(path.clone(), handle);
1585 let server_task = tokio::spawn(async move { server.serve().await });
1586
1587 tokio::time::timeout(Duration::from_secs(1), async {
1588 while !path.exists() {
1589 tokio::time::sleep(Duration::from_millis(5)).await;
1590 }
1591 })
1592 .await
1593 .expect("server socket should appear");
1594
1595 let client = IpcClient::from_socket_path(path.clone());
1596 assert!(matches!(
1597 client
1598 .send(IpcRequest::new(IpcCommand::Status))
1599 .await
1600 .expect("status response"),
1601 IpcResponse::Status(DaemonStatus::Running)
1602 ));
1603 assert!(matches!(
1604 client
1605 .send(IpcRequest::new(IpcCommand::Shutdown { force: false }))
1606 .await
1607 .expect("shutdown response"),
1608 IpcResponse::Ok { .. }
1609 ));
1610 shutdown_receiver.recv().await.expect("shutdown broadcast");
1611 server_task
1612 .await
1613 .expect("server task should join")
1614 .expect("server should stop cleanly");
1615 assert!(!path.exists());
1616 }
1617
1618 #[cfg(unix)]
1619 #[tokio::test]
1620 async fn socket_is_owner_only() {
1621 let path = socket_path();
1622 let listener = IpcListener::new(path.clone()).bind().expect("bind socket");
1623 assert_eq!(
1624 std::fs::metadata(&path).expect("socket metadata").permissions().mode() & 0o777,
1625 0o600
1626 );
1627 drop(listener);
1628 std::fs::remove_file(path).expect("remove socket");
1629 }
1630
1631 #[tokio::test]
1632 async fn client_reports_missing_server() {
1633 let path = socket_path();
1634 let error = IpcClient::from_socket_path(path)
1635 .send(IpcRequest::new(IpcCommand::Status))
1636 .await
1637 .expect_err("missing server should fail");
1638 assert!(error.to_string().contains("daemon IPC connection failed"));
1639 }
1640
1641 #[test]
1642 fn new_commands_round_trip_as_json() {
1643 let req1 = IpcRequest::new(IpcCommand::Unsubscribe {
1644 namespace: "ns".to_owned(),
1645 });
1646 let enc1 = serde_json::to_vec(&req1).expect("serialize");
1647 let dec1: IpcRequest = serde_json::from_slice(&enc1).expect("deserialize");
1648 assert!(matches!(dec1.command, IpcCommand::Unsubscribe { .. }));
1649
1650 let req2 = IpcRequest::new(IpcCommand::LeaveFolder {
1651 namespace: "ns".to_owned(),
1652 });
1653 let enc2 = serde_json::to_vec(&req2).expect("serialize");
1654 let dec2: IpcRequest = serde_json::from_slice(&enc2).expect("deserialize");
1655 assert!(matches!(dec2.command, IpcCommand::LeaveFolder { .. }));
1656
1657 let req3 = IpcRequest::new(IpcCommand::Unpublish {
1658 namespace: "ns".to_owned(),
1659 blob: "baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
1660 });
1661 let enc3 = serde_json::to_vec(&req3).expect("serialize");
1662 let dec3: IpcRequest = serde_json::from_slice(&enc3).expect("deserialize");
1663 assert!(matches!(dec3.command, IpcCommand::Unpublish { .. }));
1664
1665 let req4 = IpcRequest::new(IpcCommand::SnapshotCreate {
1666 path: PathBuf::from("."),
1667 description: Some("test".to_owned()),
1668 threads: 0,
1669 });
1670 let enc4 = serde_json::to_vec(&req4).expect("serialize");
1671 let dec4: IpcRequest = serde_json::from_slice(&enc4).expect("deserialize");
1672 assert!(matches!(dec4.command, IpcCommand::SnapshotCreate { .. }));
1673
1674 let req5 = IpcRequest::new(IpcCommand::SnapshotList {
1675 path: PathBuf::from("."),
1676 });
1677 let enc5 = serde_json::to_vec(&req5).expect("serialize");
1678 let dec5: IpcRequest = serde_json::from_slice(&enc5).expect("deserialize");
1679 assert!(matches!(dec5.command, IpcCommand::SnapshotList { .. }));
1680
1681 let req6 = IpcRequest::new(IpcCommand::SnapshotDelete {
1682 id: "baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
1683 });
1684 let enc6 = serde_json::to_vec(&req6).expect("serialize");
1685 let dec6: IpcRequest = serde_json::from_slice(&enc6).expect("deserialize");
1686 assert!(matches!(dec6.command, IpcCommand::SnapshotDelete { .. }));
1687
1688 let req7 = IpcRequest::new(IpcCommand::CollectionPublish {
1689 path: PathBuf::from("."),
1690 namespace: "ns".to_owned(),
1691 sequence: 1,
1692 bootstrap: vec![],
1693 });
1694 let enc7 = serde_json::to_vec(&req7).expect("serialize");
1695 let dec7: IpcRequest = serde_json::from_slice(&enc7).expect("deserialize");
1696 assert!(matches!(dec7.command, IpcCommand::CollectionPublish { .. }));
1697 }
1698
1699 #[tokio::test]
1700 async fn test_ipc_unsubscribe_no_active_session() {
1701 let handle = DaemonHandle::new(state());
1702 let server = IpcServer::new(socket_path(), handle);
1703 let namespace = iroh_docs::NamespaceSecret::from_bytes(&[7; 32]).id().to_string();
1704 let response = server
1705 .handle_request(IpcRequest::new(IpcCommand::Unsubscribe {
1706 namespace: namespace.clone(),
1707 }))
1708 .await;
1709 assert!(matches!(
1710 response,
1711 IpcResponse::Error { message } if message.contains("no active session")
1712 ));
1713 }
1714
1715 #[tokio::test]
1716 async fn test_ipc_unsubscribe_invalid_namespace() {
1717 let handle = DaemonHandle::new(state());
1718 let server = IpcServer::new(socket_path(), handle);
1719 let response = server
1720 .handle_request(IpcRequest::new(IpcCommand::Unsubscribe {
1721 namespace: "not-a-namespace".to_owned(),
1722 }))
1723 .await;
1724 assert!(matches!(
1725 response,
1726 IpcResponse::Error { message } if message.contains("invalid namespace")
1727 ));
1728 }
1729
1730 #[tokio::test]
1731 async fn test_ipc_leave_folder_no_context() {
1732 let handle = DaemonHandle::new(state());
1733 let server = IpcServer::new(socket_path(), handle);
1734 let response = server
1735 .handle_request(IpcRequest::new(IpcCommand::LeaveFolder {
1736 namespace: "ns".to_owned(),
1737 }))
1738 .await;
1739 assert!(matches!(
1740 response,
1741 IpcResponse::Error { message } if message.contains("no node context")
1742 ));
1743 }
1744
1745 #[tokio::test]
1746 async fn test_ipc_unpublish_no_context() {
1747 let handle = DaemonHandle::new(state());
1748 let server = IpcServer::new(socket_path(), handle);
1749 let response = server
1750 .handle_request(IpcRequest::new(IpcCommand::Unpublish {
1751 namespace: "ns".to_owned(),
1752 blob: "baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
1753 }))
1754 .await;
1755 assert!(matches!(
1756 response,
1757 IpcResponse::Error { message } if message.contains("no node context")
1758 ));
1759 }
1760
1761 #[tokio::test]
1762 async fn test_ipc_snapshot_create_no_context() {
1763 let handle = DaemonHandle::new(state());
1764 let server = IpcServer::new(socket_path(), handle);
1765 let response = server
1766 .handle_request(IpcRequest::new(IpcCommand::SnapshotCreate {
1767 path: PathBuf::from("."),
1768 description: None,
1769 threads: 0,
1770 }))
1771 .await;
1772 assert!(matches!(
1773 response,
1774 IpcResponse::Error { message } if message.contains("no node context")
1775 ));
1776 }
1777
1778 #[tokio::test]
1779 async fn test_ipc_snapshot_list_no_context() {
1780 let handle = DaemonHandle::new(state());
1781 let server = IpcServer::new(socket_path(), handle);
1782 let response = server
1783 .handle_request(IpcRequest::new(IpcCommand::SnapshotList {
1784 path: PathBuf::from("."),
1785 }))
1786 .await;
1787 assert!(matches!(
1788 response,
1789 IpcResponse::Error { message } if message.contains("no node context")
1790 ));
1791 }
1792
1793 #[tokio::test]
1794 async fn test_ipc_snapshot_delete_no_context() {
1795 let handle = DaemonHandle::new(state());
1796 let server = IpcServer::new(socket_path(), handle);
1797 let response = server
1798 .handle_request(IpcRequest::new(IpcCommand::SnapshotDelete {
1799 id: "baead9a5c1f7b3d2e4f60897c5a1b3d8e2f40796a8c0b5d3e7f10829c4a6b0d".to_owned(),
1800 }))
1801 .await;
1802 assert!(matches!(
1803 response,
1804 IpcResponse::Error { message } if message.contains("no node context")
1805 ));
1806 }
1807
1808 #[tokio::test]
1809 async fn test_ipc_collection_publish_no_context() {
1810 let handle = DaemonHandle::new(state());
1811 let server = IpcServer::new(socket_path(), handle);
1812 let response = server
1813 .handle_request(IpcRequest::new(IpcCommand::CollectionPublish {
1814 path: PathBuf::from("."),
1815 namespace: "ns".to_owned(),
1816 sequence: 1,
1817 bootstrap: vec![],
1818 }))
1819 .await;
1820 assert!(matches!(
1821 response,
1822 IpcResponse::Error { message } if message.contains("no node context")
1823 ));
1824 }
1825
1826 #[tokio::test]
1827 async fn test_ipc_create_folder_no_context() {
1828 let handle = DaemonHandle::new(state());
1829 let server = IpcServer::new(socket_path(), handle);
1830 let response = server
1831 .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
1832 path: PathBuf::from("."),
1833 mode: "sendreceive".to_owned(),
1834 }))
1835 .await;
1836 assert!(matches!(
1837 response,
1838 IpcResponse::Error { message } if message.contains("no node context")
1839 ));
1840 }
1841
1842 #[tokio::test]
1843 async fn test_ipc_create_folder_no_context_with_invalid_mode() {
1844 let handle = DaemonHandle::new(state());
1845 let server = IpcServer::new(socket_path(), handle);
1846 let response = server
1847 .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
1848 path: PathBuf::from("/tmp/test-create-folder"),
1849 mode: "invalid".to_owned(),
1850 }))
1851 .await;
1852 assert!(matches!(
1853 response,
1854 IpcResponse::Error { message } if message.contains("no node context")
1855 ));
1856 }
1857
1858 #[tokio::test]
1859 async fn test_ipc_health_check_no_context() {
1860 let handle = DaemonHandle::new(state());
1861 let server = IpcServer::new(socket_path(), handle);
1862 let response = server
1863 .handle_request(IpcRequest::new(IpcCommand::HealthCheck {
1864 path: PathBuf::from("."),
1865 }))
1866 .await;
1867 assert!(matches!(
1868 response,
1869 IpcResponse::Error { message } if message.contains("no node context")
1870 ));
1871 }
1872
1873 #[tokio::test]
1874 async fn test_ipc_verify_integrity_no_context() {
1875 let handle = DaemonHandle::new(state());
1876 let server = IpcServer::new(socket_path(), handle);
1877 let response = server
1878 .handle_request(IpcRequest::new(IpcCommand::VerifyIntegrity {
1879 path: PathBuf::from("."),
1880 }))
1881 .await;
1882 assert!(matches!(
1883 response,
1884 IpcResponse::Error { message } if message.contains("no node context")
1885 ));
1886 }
1887
1888 #[tokio::test]
1889 async fn test_ipc_join_no_context() {
1890 let handle = DaemonHandle::new(state());
1891 let server = IpcServer::new(socket_path(), handle);
1892 let response = server
1893 .handle_request(IpcRequest::new(IpcCommand::Join {
1894 ticket: "ticket".to_owned(),
1895 path: PathBuf::from("/tmp"),
1896 mode: SyncMode::SendReceive,
1897 }))
1898 .await;
1899 assert!(matches!(
1900 response,
1901 IpcResponse::Error { message } if message.contains("no node context")
1902 ));
1903 }
1904
1905 #[tokio::test]
1906 async fn test_ipc_publish_no_context() {
1907 let handle = DaemonHandle::new(state());
1908 let server = IpcServer::new(socket_path(), handle);
1909 let response = server
1910 .handle_request(IpcRequest::new(IpcCommand::Publish {
1911 namespace: "ns".to_owned(),
1912 blob: None,
1913 }))
1914 .await;
1915 assert!(matches!(
1916 response,
1917 IpcResponse::Error { message } if message.contains("no node context")
1918 ));
1919 }
1920
1921 #[tokio::test]
1922 async fn test_ipc_subscribe_no_context() {
1923 let handle = DaemonHandle::new(state());
1924 let server = IpcServer::new(socket_path(), handle);
1925 let response = server
1926 .handle_request(IpcRequest::new(IpcCommand::Subscribe {
1927 namespace: "ns".to_owned(),
1928 params: SubscribeParams::ingest_only(),
1929 }))
1930 .await;
1931 assert!(matches!(
1932 response,
1933 IpcResponse::Error { message } if message.contains("no node context")
1934 ));
1935 }
1936
1937 struct IpcTestFixture {
1938 server: IpcServer,
1939 node: Arc<IrohNode>,
1940 directory: PathBuf,
1941 }
1942
1943 async fn setup_ipc_test() -> IpcTestFixture {
1944 use crate::node::identity::IdentityManager;
1945 use crate::node::iroh_node::RelayMode;
1946
1947 let directory = std::env::temp_dir().join(format!("syncweb-ipc-test-{}", uuid::Uuid::new_v4()));
1948 std::fs::create_dir_all(&directory).expect("test directory should be created");
1949
1950 let identity = IdentityManager::new(directory.join("identity.key")).expect("test identity should open");
1951 let node = Arc::new(
1952 IrohNode::new(identity, directory.join("data"), RelayMode::Default)
1953 .await
1954 .expect("test node should start"),
1955 );
1956 let pool = Arc::new(ManagedPool::new("syncweb-test", 1).expect("test pool should start"));
1957
1958 let daemon_state = DaemonState::new(
1959 std::process::id(),
1960 node.endpoint().id().to_string(),
1961 1,
1962 &directory,
1963 DaemonStatus::Running,
1964 );
1965 let handle = DaemonHandle::new(daemon_state);
1966 let server = IpcServer::with_archive_context(socket_path(), handle, node.clone(), pool);
1967
1968 IpcTestFixture {
1969 server,
1970 node,
1971 directory,
1972 }
1973 }
1974
1975 async fn cleanup_ipc_test(fixture: IpcTestFixture) {
1976 let _ = fixture.node.stop().await;
1977 let _ = std::fs::remove_dir_all(&fixture.directory);
1978 }
1979
1980 #[tokio::test]
1981 async fn test_ipc_create_folder_creates_and_returns_message() {
1982 let fixture = setup_ipc_test().await;
1983 let test_dir = fixture.directory.join("create-folder-test");
1984 let response = fixture
1985 .server
1986 .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
1987 path: test_dir.clone(),
1988 mode: "sendreceive".to_owned(),
1989 }))
1990 .await;
1991 assert!(matches!(response, IpcResponse::Ok { .. }));
1992 if let IpcResponse::Ok { message } = response {
1993 assert!(message.contains("namespace:"));
1994 assert!(message.contains("ticket:"));
1995 }
1996 cleanup_ipc_test(fixture).await;
1997 let _ = std::fs::remove_dir_all(&test_dir);
1998 }
1999
2000 #[tokio::test]
2001 async fn test_ipc_create_folder_invalid_mode() {
2002 let fixture = setup_ipc_test().await;
2003 let test_dir = fixture.directory.join("create-folder-invalid");
2004 let response = fixture
2005 .server
2006 .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2007 path: test_dir.clone(),
2008 mode: "invalid-mode".to_owned(),
2009 }))
2010 .await;
2011 assert!(matches!(response, IpcResponse::Error { .. }));
2012 if let IpcResponse::Error { message } = response {
2013 assert!(message.contains("invalid sync mode"));
2014 }
2015 cleanup_ipc_test(fixture).await;
2016 }
2017
2018 #[tokio::test]
2019 async fn test_ipc_create_folder_duplicate_namespace() {
2020 let fixture = setup_ipc_test().await;
2021 let test_dir1 = fixture.directory.join("create-folder-dup-1");
2022 let test_dir2 = fixture.directory.join("create-folder-dup-2");
2023
2024 let response1 = fixture
2025 .server
2026 .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2027 path: test_dir1.clone(),
2028 mode: "sendreceive".to_owned(),
2029 }))
2030 .await;
2031 assert!(matches!(response1, IpcResponse::Ok { .. }));
2032 let namespace = if let IpcResponse::Ok { message } = &response1 {
2033 message
2034 .lines()
2035 .find(|line| line.starts_with("namespace:"))
2036 .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2037 } else {
2038 None
2039 };
2040
2041 if let Some(ref ns) = namespace {
2042 let response2 = fixture
2043 .server
2044 .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2045 path: test_dir2.clone(),
2046 mode: "sendreceive".to_owned(),
2047 }))
2048 .await;
2049 assert!(matches!(response2, IpcResponse::Ok { .. }));
2050 let ns2 = if let IpcResponse::Ok { message } = &response2 {
2051 message
2052 .lines()
2053 .find(|line| line.starts_with("namespace:"))
2054 .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2055 } else {
2056 None
2057 };
2058 assert_ne!(Some(ns), ns2.as_ref(), "each create should produce a unique namespace");
2059 }
2060
2061 cleanup_ipc_test(fixture).await;
2062 let _ = std::fs::remove_dir_all(&test_dir1);
2063 let _ = std::fs::remove_dir_all(&test_dir2);
2064 }
2065
2066 #[tokio::test]
2067 async fn test_ipc_health_check_returns_report() {
2068 let fixture = setup_ipc_test().await;
2069 let test_dir = fixture.directory.join("health-test");
2070 std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2071
2072 let response1 = fixture
2073 .server
2074 .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2075 path: test_dir.clone(),
2076 mode: "sendreceive".to_owned(),
2077 }))
2078 .await;
2079 let namespace = if let IpcResponse::Ok { message } = &response1 {
2080 message
2081 .lines()
2082 .find(|line| line.starts_with("namespace:"))
2083 .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2084 } else {
2085 None
2086 };
2087
2088 if let Some(ns) = namespace {
2089 let response2 = fixture
2090 .server
2091 .handle_request(IpcRequest::new(IpcCommand::HealthCheck {
2092 path: PathBuf::from(&ns),
2093 }))
2094 .await;
2095 assert!(matches!(response2, IpcResponse::Ok { .. }));
2096 if let IpcResponse::Ok { message } = response2 {
2097 assert!(message.contains("total:"));
2098 assert!(message.contains("well-seeded:"));
2099 }
2100 }
2101
2102 cleanup_ipc_test(fixture).await;
2103 let _ = std::fs::remove_dir_all(&test_dir);
2104 }
2105
2106 #[tokio::test]
2107 async fn test_ipc_health_check_unknown_folder() {
2108 let fixture = setup_ipc_test().await;
2109 let response = fixture
2110 .server
2111 .handle_request(IpcRequest::new(IpcCommand::HealthCheck {
2112 path: PathBuf::from("/nonexistent/path/that/does/not/exist"),
2113 }))
2114 .await;
2115 assert!(matches!(response, IpcResponse::Error { .. }));
2116 cleanup_ipc_test(fixture).await;
2117 }
2118
2119 #[tokio::test]
2120 async fn test_ipc_verify_integrity_returns_result() {
2121 let fixture = setup_ipc_test().await;
2122 let test_dir = fixture.directory.join("verify-test");
2123 std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2124
2125 let response1 = fixture
2126 .server
2127 .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2128 path: test_dir.clone(),
2129 mode: "sendreceive".to_owned(),
2130 }))
2131 .await;
2132 let namespace = if let IpcResponse::Ok { message } = &response1 {
2133 message
2134 .lines()
2135 .find(|line| line.starts_with("namespace:"))
2136 .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2137 } else {
2138 None
2139 };
2140
2141 if let Some(ns) = namespace {
2142 let response2 = fixture
2143 .server
2144 .handle_request(IpcRequest::new(IpcCommand::VerifyIntegrity {
2145 path: PathBuf::from(&ns),
2146 }))
2147 .await;
2148 assert!(matches!(response2, IpcResponse::Ok { .. }));
2149 if let IpcResponse::Ok { message } = response2 {
2150 assert!(message.contains("total:"));
2151 assert!(message.contains("verified:"));
2152 assert!(message.contains("corrupted:"));
2153 assert!(message.contains("missing:"));
2154 assert!(message.contains("valid:"));
2155 }
2156 }
2157
2158 cleanup_ipc_test(fixture).await;
2159 let _ = std::fs::remove_dir_all(&test_dir);
2160 }
2161
2162 #[tokio::test]
2163 async fn test_ipc_verify_integrity_unknown_folder() {
2164 let fixture = setup_ipc_test().await;
2165 let response = fixture
2166 .server
2167 .handle_request(IpcRequest::new(IpcCommand::VerifyIntegrity {
2168 path: PathBuf::from("/nonexistent/path/that/does/not/exist"),
2169 }))
2170 .await;
2171 assert!(matches!(response, IpcResponse::Error { .. }));
2172 cleanup_ipc_test(fixture).await;
2173 }
2174
2175 #[tokio::test]
2176 async fn test_ipc_join_folder_invalid_ticket() {
2177 let fixture = setup_ipc_test().await;
2178 let test_dir = fixture.directory.join("join-invalid");
2179 let response = fixture
2180 .server
2181 .handle_request(IpcRequest::new(IpcCommand::Join {
2182 ticket: "not-a-valid-ticket".to_owned(),
2183 path: test_dir.clone(),
2184 mode: SyncMode::SendReceive,
2185 }))
2186 .await;
2187 assert!(matches!(response, IpcResponse::Error { .. }));
2188 cleanup_ipc_test(fixture).await;
2189 }
2190
2191 #[tokio::test]
2192 async fn test_ipc_publish_folder_ticket() {
2193 let fixture = setup_ipc_test().await;
2194 let test_dir = fixture.directory.join("publish-test");
2195 std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2196
2197 let response1 = fixture
2198 .server
2199 .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2200 path: test_dir.clone(),
2201 mode: "sendreceive".to_owned(),
2202 }))
2203 .await;
2204 let namespace = if let IpcResponse::Ok { message } = &response1 {
2205 message
2206 .lines()
2207 .find(|line| line.starts_with("namespace:"))
2208 .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2209 } else {
2210 None
2211 };
2212
2213 if let Some(ns) = namespace {
2214 let response2 = fixture
2215 .server
2216 .handle_request(IpcRequest::new(IpcCommand::Publish {
2217 namespace: ns.clone(),
2218 blob: None,
2219 }))
2220 .await;
2221 assert!(matches!(response2, IpcResponse::Ok { .. }));
2222 if let IpcResponse::Ok { message } = response2 {
2223 assert!(message.contains("ticket:"));
2224 }
2225 }
2226
2227 cleanup_ipc_test(fixture).await;
2228 let _ = std::fs::remove_dir_all(&test_dir);
2229 }
2230
2231 #[tokio::test]
2232 async fn test_ipc_publish_invalid_namespace() {
2233 let fixture = setup_ipc_test().await;
2234 let response = fixture
2235 .server
2236 .handle_request(IpcRequest::new(IpcCommand::Publish {
2237 namespace: "not-a-namespace".to_owned(),
2238 blob: None,
2239 }))
2240 .await;
2241 assert!(matches!(response, IpcResponse::Error { .. }));
2242 if let IpcResponse::Error { message } = response {
2243 assert!(message.contains("invalid namespace"));
2244 }
2245 cleanup_ipc_test(fixture).await;
2246 }
2247
2248 #[tokio::test]
2249 async fn test_ipc_subscribe_returns_ok() {
2250 let fixture = setup_ipc_test().await;
2251 let test_dir = fixture.directory.join("subscribe-test");
2252 std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2253
2254 let response1 = fixture
2255 .server
2256 .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2257 path: test_dir.clone(),
2258 mode: "sendreceive".to_owned(),
2259 }))
2260 .await;
2261 let namespace = if let IpcResponse::Ok { message } = &response1 {
2262 message
2263 .lines()
2264 .find(|line| line.starts_with("namespace:"))
2265 .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2266 } else {
2267 None
2268 };
2269
2270 if let Some(ns) = namespace {
2271 let response = fixture
2272 .server
2273 .handle_request(IpcRequest::new(IpcCommand::Subscribe {
2274 namespace: ns.clone(),
2275 params: SubscribeParams::ingest_only(),
2276 }))
2277 .await;
2278 assert!(matches!(response, IpcResponse::Ok { .. }));
2279 if let IpcResponse::Ok { message } = response {
2280 assert!(message.contains("subscribed:"));
2281 }
2282 }
2283
2284 cleanup_ipc_test(fixture).await;
2285 let _ = std::fs::remove_dir_all(&test_dir);
2286 }
2287
2288 #[tokio::test]
2289 async fn test_ipc_subscribe_with_params() {
2290 let fixture = setup_ipc_test().await;
2291 let test_dir = fixture.directory.join("subscribe-params-test");
2292 std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2293
2294 let response1 = fixture
2295 .server
2296 .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2297 path: test_dir.clone(),
2298 mode: "sendreceive".to_owned(),
2299 }))
2300 .await;
2301 let namespace = if let IpcResponse::Ok { message } = &response1 {
2302 message
2303 .lines()
2304 .find(|line| line.starts_with("namespace:"))
2305 .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2306 } else {
2307 None
2308 };
2309
2310 if let Some(ns) = namespace {
2311 let params = SubscribeParams {
2312 ingest_only: true,
2313 ..SubscribeParams::default()
2314 };
2315 let response2 = fixture
2316 .server
2317 .handle_request(IpcRequest::new(IpcCommand::Subscribe {
2318 namespace: ns.clone(),
2319 params,
2320 }))
2321 .await;
2322 assert!(matches!(response2, IpcResponse::Ok { .. }));
2323 }
2324
2325 cleanup_ipc_test(fixture).await;
2326 let _ = std::fs::remove_dir_all(&test_dir);
2327 }
2328
2329 #[tokio::test]
2330 async fn test_ipc_leave_folder_removes_from_registry() {
2331 let fixture = setup_ipc_test().await;
2332 let test_dir = fixture.directory.join("leave-test");
2333 std::fs::create_dir_all(&test_dir).expect("test dir should be created");
2334
2335 let response = fixture
2336 .server
2337 .handle_request(IpcRequest::new(IpcCommand::CreateFolder {
2338 path: test_dir.clone(),
2339 mode: "sendreceive".to_owned(),
2340 }))
2341 .await;
2342 let namespace = if let IpcResponse::Ok { message } = &response {
2343 message
2344 .lines()
2345 .find(|line| line.starts_with("namespace:"))
2346 .and_then(|line| line.strip_prefix("namespace:").map(str::trim).map(str::to_owned))
2347 } else {
2348 None
2349 };
2350
2351 if let Some(ref ns) = namespace {
2352 let statuses1 = fixture.server.daemon_handle.folder_registry.read().await.statuses();
2353 assert!(statuses1.iter().any(|s| s.namespace == *ns));
2354
2355 let response2 = fixture
2356 .server
2357 .handle_request(IpcRequest::new(IpcCommand::LeaveFolder { namespace: ns.clone() }))
2358 .await;
2359 assert!(matches!(response2, IpcResponse::Ok { .. }));
2360
2361 let statuses2 = fixture.server.daemon_handle.folder_registry.read().await.statuses();
2362 assert!(!statuses2.iter().any(|s| s.namespace == *ns));
2363 }
2364
2365 cleanup_ipc_test(fixture).await;
2366 let _ = std::fs::remove_dir_all(&test_dir);
2367 }
2368
2369 #[tokio::test]
2370 async fn test_ipc_leave_folder_nonexistent() {
2371 let fixture = setup_ipc_test().await;
2372 let fake_ns = iroh_docs::NamespaceSecret::from_bytes(&[99; 32]).id().to_string();
2373 let _response = fixture
2374 .server
2375 .handle_request(IpcRequest::new(IpcCommand::LeaveFolder {
2376 namespace: fake_ns.clone(),
2377 }))
2378 .await;
2379 let registry = fixture.server.daemon_handle.folder_registry.read().await;
2380 let statuses = registry.statuses();
2381 assert!(!statuses.iter().any(|s| s.namespace == fake_ns));
2382 drop(registry);
2383 cleanup_ipc_test(fixture).await;
2384 }
2385
2386 #[tokio::test]
2387 async fn test_ipc_unpublish_invalid_hash() {
2388 let fixture = setup_ipc_test().await;
2389 let fake_ns = iroh_docs::NamespaceSecret::from_bytes(&[88; 32]).id().to_string();
2390 let response = fixture
2391 .server
2392 .handle_request(IpcRequest::new(IpcCommand::Unpublish {
2393 namespace: fake_ns,
2394 blob: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".to_owned(),
2395 }))
2396 .await;
2397 assert!(matches!(response, IpcResponse::Error { .. }));
2398 if let IpcResponse::Error { message } = response {
2399 assert!(message.contains("invalid blob hash"));
2400 }
2401 cleanup_ipc_test(fixture).await;
2402 }
2403
2404 #[tokio::test]
2405 async fn test_ipc_snapshot_list_empty() {
2406 let fixture = setup_ipc_test().await;
2407 let response = fixture
2408 .server
2409 .handle_request(IpcRequest::new(IpcCommand::SnapshotList {
2410 path: PathBuf::from("."),
2411 }))
2412 .await;
2413 assert!(matches!(response, IpcResponse::Ok { .. }));
2414 if let IpcResponse::Ok { message } = response {
2415 assert!(message.contains("snapshots:"));
2416 }
2417 cleanup_ipc_test(fixture).await;
2418 }
2419
2420 #[tokio::test]
2421 async fn test_ipc_snapshot_delete_invalid_id() {
2422 let fixture = setup_ipc_test().await;
2423 let response = fixture
2424 .server
2425 .handle_request(IpcRequest::new(IpcCommand::SnapshotDelete {
2426 id: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".to_owned(),
2427 }))
2428 .await;
2429 assert!(matches!(response, IpcResponse::Error { .. }));
2430 if let IpcResponse::Error { message } = response {
2431 assert!(message.contains("invalid snapshot id"));
2432 }
2433 cleanup_ipc_test(fixture).await;
2434 }
2435}