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