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