1use std::io;
14use std::os::unix::fs::PermissionsExt;
15use std::path::{Path, PathBuf};
16use std::str::FromStr;
17use std::sync::atomic::{AtomicUsize, Ordering};
18use std::sync::Arc;
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20
21use iroh_blobs::api::downloader::Downloader;
22use iroh_blobs::store::fs::FsStore;
23use iroh_blobs::HashAndFormat;
24use radicle::git::Oid;
25use radicle::identity::RepoId;
26use radicle_artifact_core::cid::Cid;
27use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
28use tokio::net::{UnixListener, UnixStream};
29use tokio::signal::unix::{signal, SignalKind};
30use tokio::sync::{broadcast, mpsc};
31use url::Url;
32
33use crate::seeder;
34use crate::{fetch, Error as ShareError};
35use radicle_artifact_client::tokio::Client;
36use radicle_artifact_core::cid::{self as cid_utils, ArtifactKind};
37use radicle_artifact_core::keys::EndpointId;
38use radicle_artifact_core::protocol::{
39 Command, CommandError, CommandResult, DownloadReceipt, ErrorCode, ExportReceipt, FetchLocation,
40 FetchProgress, FetchReceipt, HasResult, ImportMode, SeedReceipt, SeededEntry, Status,
41 StreamEvent, UnseedReceipt,
42};
43use radicle_artifact_core::ARTIFACTS_DIR;
44
45const DRAIN_TIMEOUT: Duration = Duration::from_secs(300);
50
51const READ_TIMEOUT: Duration = Duration::from_secs(30);
55
56const ROUTER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
59
60#[derive(Debug, thiserror::Error)]
62pub enum NodeError {
63 #[error("another rad-artifact node is already running at {0}")]
66 AlreadyRunning(PathBuf),
67 #[error(transparent)]
69 Share(#[from] ShareError),
70 #[error("node I/O error: {0}")]
72 Io(#[from] io::Error),
73}
74
75struct NodeCtx {
82 store: FsStore,
84 downloader: Downloader,
86 endpoint: iroh::Endpoint,
89 endpoint_id: EndpointId,
91 started_at_unix: i64,
93}
94
95pub async fn run(home: &Path, secret: iroh::SecretKey) -> Result<(), NodeError> {
112 let socket_path = home.join(ARTIFACTS_DIR).join("control.sock");
113 if let Some(parent) = socket_path.parent() {
115 std::fs::create_dir_all(parent)?;
116 }
117
118 if socket_path.exists() {
122 let probe = Client::new(socket_path.clone());
123 if probe.is_running().await {
124 return Err(NodeError::AlreadyRunning(socket_path));
125 }
126 std::fs::remove_file(&socket_path)?;
127 }
128
129 let seeder = seeder::bootstrap(home, secret).await?;
130
131 let listener = UnixListener::bind(&socket_path)?;
132 std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600))?;
133
134 let started_at_unix = SystemTime::now()
135 .duration_since(UNIX_EPOCH)
136 .map(|d| d.as_secs() as i64)
137 .unwrap_or(0);
138
139 let endpoint_id = EndpointId::from(seeder.router.endpoint().id());
140
141 tracing::info!(
142 endpoint_id = %endpoint_id,
143 socket = %socket_path.display(),
144 "rad-artifact node ready"
145 );
146
147 let downloader = Downloader::new_with_opts(
150 seeder.blobs.as_ref(),
151 seeder.router.endpoint(),
152 fetch::pool_options(),
153 );
154 let ctx = Arc::new(NodeCtx {
155 store: seeder.blobs.clone(),
156 downloader,
157 endpoint: seeder.router.endpoint().clone(),
158 endpoint_id,
159 started_at_unix,
160 });
161
162 let (shutdown_tx, mut shutdown_rx) = broadcast::channel::<()>(8);
167 spawn_signal_handler(shutdown_tx.clone());
168
169 let in_flight = Arc::new(AtomicUsize::new(0));
170
171 loop {
172 tokio::select! {
173 biased;
174 res = shutdown_rx.recv() => {
175 if res.is_ok() { break; }
177 }
178 accept = listener.accept() => {
179 let (stream, _addr) = match accept {
180 Ok(v) => v,
181 Err(e) => {
182 tracing::warn!("accept error: {e}");
183 continue;
184 }
185 };
186 let ctx = ctx.clone();
187 let shutdown_tx = shutdown_tx.clone();
188 let in_flight = in_flight.clone();
189 in_flight.fetch_add(1, Ordering::SeqCst);
190 tokio::spawn(async move {
191 if let Err(e) = handle_connection(stream, &ctx, &shutdown_tx).await {
192 tracing::warn!("handler error: {e}");
193 }
194 in_flight.fetch_sub(1, Ordering::SeqCst);
195 });
196 }
197 }
198 }
199
200 drop(listener);
202 let _ = std::fs::remove_file(&socket_path);
203
204 let drain_deadline = Instant::now() + DRAIN_TIMEOUT;
206 while in_flight.load(Ordering::SeqCst) > 0 && Instant::now() < drain_deadline {
207 tokio::time::sleep(Duration::from_millis(50)).await;
208 }
209
210 let _ = tokio::time::timeout(ROUTER_SHUTDOWN_TIMEOUT, seeder.router.shutdown()).await;
211 tracing::info!("rad-artifact node stopped");
212 Ok(())
213}
214
215fn spawn_signal_handler(shutdown_tx: broadcast::Sender<()>) {
217 tokio::spawn(async move {
218 let Ok(mut term) = signal(SignalKind::terminate()) else {
219 return;
220 };
221 let Ok(mut int) = signal(SignalKind::interrupt()) else {
222 return;
223 };
224 tokio::select! {
225 _ = term.recv() => {}
226 _ = int.recv() => {}
227 }
228 let _ = shutdown_tx.send(());
229 });
230}
231
232async fn handle_connection(
235 stream: UnixStream,
236 ctx: &NodeCtx,
237 shutdown_tx: &broadcast::Sender<()>,
238) -> io::Result<()> {
239 let (read, mut write) = stream.into_split();
240 let mut reader = BufReader::new(read);
241 let mut line = String::new();
242 let n = match tokio::time::timeout(READ_TIMEOUT, reader.read_line(&mut line)).await {
245 Ok(res) => res?,
246 Err(_) => return Ok(()),
247 };
248 if n == 0 {
249 return Ok(());
250 }
251
252 match parse_command(line.trim_end()) {
253 Ok(Command::Export { cid, dest }) => {
256 stream_export(ctx, &mut reader, &mut write, cid, dest).await
257 }
258 Ok(Command::Fetch {
259 rid,
260 cid,
261 locations,
262 seed,
263 }) => stream_fetch(ctx, &mut reader, &mut write, rid, cid, locations, seed).await,
264 Ok(Command::Download {
265 rid,
266 cid,
267 locations,
268 dest,
269 seed,
270 }) => {
271 stream_download(
272 ctx,
273 &mut reader,
274 &mut write,
275 rid,
276 cid,
277 locations,
278 dest,
279 seed,
280 )
281 .await
282 }
283 Ok(cmd) => write_line(&mut write, dispatch(cmd, ctx, shutdown_tx).await).await,
285 Err((code, msg)) => write_line(&mut write, err_json::<()>(code, msg)).await,
286 }
287}
288
289async fn write_line(
291 write: &mut (impl AsyncWriteExt + Unpin),
292 mut response: String,
293) -> io::Result<()> {
294 response.push('\n');
295 write.write_all(response.as_bytes()).await?;
296 write.flush().await
297}
298
299fn parse_command(line: &str) -> Result<Command, (ErrorCode, String)> {
303 let value: serde_json::Value = serde_json::from_str(line).map_err(|e| {
304 (
305 ErrorCode::InvalidRequest,
306 format!("invalid command JSON: {e}"),
307 )
308 })?;
309 if let Some(rid_s) = value.get("rid").and_then(|v| v.as_str()) {
310 if let Err(e) = RepoId::from_str(rid_s) {
311 return Err((
312 ErrorCode::InvalidRequest,
313 format!("invalid rid {rid_s:?}: {e}"),
314 ));
315 }
316 }
317 if let Some(cid_s) = value.get("cid").and_then(|v| v.as_str()) {
318 if let Err(e) = Cid::from_str(cid_s) {
319 return Err((
320 ErrorCode::InvalidRequest,
321 format!("invalid cid {cid_s:?}: {e}"),
322 ));
323 }
324 }
325 serde_json::from_value(value)
326 .map_err(|e| (ErrorCode::InvalidRequest, format!("invalid command: {e}")))
327}
328
329async fn dispatch(cmd: Command, ctx: &NodeCtx, shutdown_tx: &broadcast::Sender<()>) -> String {
333 let store = &ctx.store;
334 match cmd {
335 Command::Alive => ok_json(()),
337 Command::Status => {
338 match build_status(store, &ctx.endpoint, ctx.endpoint_id, ctx.started_at_unix).await {
339 Ok(status) => ok_json(status),
340 Err(e) => err_from_share::<Status>(e),
341 }
342 }
343 Command::Seed {
344 rid,
345 release,
346 cid,
347 path,
348 kind,
349 mode,
350 } => seed_response(store, rid, release, cid, &path, kind, mode, ctx.endpoint_id).await,
351 Command::Unseed { rid, release, cid } => unseed_response(store, rid, release, cid).await,
352 Command::IsSeeding { rid, cid } => is_seeding_response(store, &rid, &cid).await,
353 Command::ListSeeded { rid } => list_seeded_response(store, rid).await,
354 Command::Has { cid } => has_response(store, &cid).await,
355 Command::Export { .. } | Command::Fetch { .. } | Command::Download { .. } => {
357 unreachable!("streaming commands are handled before dispatch")
358 }
359 Command::Shutdown => {
360 let resp = ok_json(());
363 let _ = shutdown_tx.send(());
364 resp
365 }
366 }
367}
368
369fn hash_and_format(cid: &Cid) -> Result<HashAndFormat, (ErrorCode, String)> {
371 let hash = cid_utils::cid_to_blake3_hash(cid)
372 .map_err(|e| (ErrorCode::InvalidRequest, e.to_string()))?;
373 match cid_utils::artifact_kind(cid) {
374 Ok(ArtifactKind::Blob) => Ok(HashAndFormat::raw(hash.into())),
375 Ok(ArtifactKind::Collection) => Ok(HashAndFormat::hash_seq(hash.into())),
376 Err(e) => Err((ErrorCode::InvalidRequest, e.to_string())),
377 }
378}
379
380async fn has_response(store: &FsStore, cid: &Cid) -> String {
383 let haf = match hash_and_format(cid) {
384 Ok(h) => h,
385 Err((code, msg)) => return err_json::<HasResult>(code, msg),
386 };
387 match store.remote().local(haf).await {
388 Ok(info) => {
389 let bytes = info.local_bytes();
390 ok_json(HasResult {
391 present: bytes > 0,
392 complete: info.is_complete(),
393 bytes,
394 })
395 }
396 Err(e) => err_json::<HasResult>(ErrorCode::Iroh, format!("local lookup: {e}")),
397 }
398}
399
400async fn write_frame<T: serde::Serialize>(
402 write: &mut (impl AsyncWriteExt + Unpin),
403 event: &StreamEvent<T>,
404) -> io::Result<()> {
405 let mut line = serde_json::to_string(event).unwrap_or_else(|e| {
406 format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#)
407 });
408 line.push('\n');
409 write.write_all(line.as_bytes()).await?;
410 write.flush().await
411}
412
413async fn stream_error<T: serde::Serialize>(
415 write: &mut (impl AsyncWriteExt + Unpin),
416 code: ErrorCode,
417 message: String,
418) -> io::Result<()> {
419 write_frame::<T>(write, &StreamEvent::Error(CommandError { code, message })).await
420}
421
422async fn run_stream<T: serde::Serialize>(
431 read: &mut (impl AsyncReadExt + Unpin),
432 write: &mut (impl AsyncWriteExt + Unpin),
433 op: impl AsyncFnOnce(mpsc::UnboundedSender<FetchProgress>) -> Result<T, (ErrorCode, String)>,
434) -> io::Result<()> {
435 let (tx, mut rx) = mpsc::unbounded_channel::<FetchProgress>();
436 let fut = op(tx);
437 tokio::pin!(fut);
438 let mut probe = [0u8; 1];
439 loop {
440 tokio::select! {
441 biased;
442 res = &mut fut => {
443 while let Ok(p) = rx.try_recv() {
445 write_frame(write, &StreamEvent::<T>::Progress(p)).await?;
446 }
447 let event = match res {
448 Ok(payload) => StreamEvent::Okay(payload),
449 Err((code, message)) => StreamEvent::Error(CommandError { code, message }),
450 };
451 return write_frame(write, &event).await;
452 }
453 Some(p) = rx.recv() => {
454 write_frame(write, &StreamEvent::<T>::Progress(p)).await?;
455 }
456 _ = read.read(&mut probe) => {
461 return Ok(());
462 }
463 }
464 }
465}
466
467async fn stream_export(
470 ctx: &NodeCtx,
471 read: &mut (impl AsyncReadExt + Unpin),
472 write: &mut (impl AsyncWriteExt + Unpin),
473 cid: Cid,
474 dest: PathBuf,
475) -> io::Result<()> {
476 let kind = match cid_utils::artifact_kind(&cid) {
477 Ok(k) => k,
478 Err(e) => {
479 return stream_error::<ExportReceipt>(write, ErrorCode::InvalidRequest, e.to_string())
480 .await
481 }
482 };
483 let haf = match hash_and_format(&cid) {
484 Ok(h) => h,
485 Err((code, msg)) => return stream_error::<ExportReceipt>(write, code, msg).await,
486 };
487 let hash = haf.hash;
488
489 match ctx.store.blobs().has(hash).await {
491 Ok(true) => {}
492 Ok(false) => {
493 return stream_error::<ExportReceipt>(
494 write,
495 ErrorCode::NotLocal,
496 format!("content for {cid} is not complete in the store"),
497 )
498 .await
499 }
500 Err(e) => {
501 return stream_error::<ExportReceipt>(
502 write,
503 ErrorCode::Iroh,
504 format!("local lookup: {e}"),
505 )
506 .await
507 }
508 }
509
510 run_stream(read, write, async move |tx| {
511 let on_progress = move |p| {
512 let _ = tx.send(p);
513 };
514 let bytes = match kind {
515 ArtifactKind::Blob => fetch::export_blob_to(&ctx.store, hash, &dest, on_progress).await,
516 ArtifactKind::Collection => {
517 fetch::export_collection_to(&ctx.store, hash, &dest, on_progress).await
518 }
519 }
520 .map_err(|e| (share_error_to_code(&e), e.to_string()))?;
521 Ok(ExportReceipt { cid, dest, bytes })
522 })
523 .await
524}
525
526fn partition(locations: &[FetchLocation]) -> (Vec<EndpointId>, Vec<Url>) {
528 let mut iroh = Vec::new();
529 let mut urls = Vec::new();
530 for loc in locations {
531 match loc {
532 FetchLocation::Iroh(id) => iroh.push(*id),
533 FetchLocation::Url(u) => urls.push(u.clone()),
534 }
535 }
536 (iroh, urls)
537}
538
539async fn export_to_dest(
541 store: &FsStore,
542 hash: iroh_blobs::Hash,
543 kind: ArtifactKind,
544 dest: &Path,
545 on_progress: impl FnMut(FetchProgress),
546) -> Result<u64, (ErrorCode, String)> {
547 match kind {
548 ArtifactKind::Blob => fetch::export_blob_to(store, hash, dest, on_progress).await,
549 ArtifactKind::Collection => {
550 fetch::export_collection_to(store, hash, dest, on_progress).await
551 }
552 }
553 .map_err(|e| (share_error_to_code(&e), e.to_string()))
554}
555
556struct Fetched {
562 kind: ArtifactKind,
564 hash: iroh_blobs::Hash,
566 from_cache: bool,
568 _tt: iroh_blobs::api::TempTag,
570}
571
572async fn fetch_into_store(
582 ctx: &NodeCtx,
583 cid: &Cid,
584 locations: &[FetchLocation],
585 on_progress: &mut impl FnMut(FetchProgress),
586) -> Result<Fetched, (ErrorCode, String)> {
587 let kind =
588 cid_utils::artifact_kind(cid).map_err(|e| (ErrorCode::InvalidRequest, e.to_string()))?;
589 let haf = hash_and_format(cid)?;
590 let hash = haf.hash;
591 let store = &ctx.store;
592
593 let tt = store
600 .tags()
601 .temp_tag(haf)
602 .await
603 .map_err(|e| (ErrorCode::Iroh, format!("temp tag: {e}")))?;
604
605 let already = store
607 .blobs()
608 .has(hash)
609 .await
610 .map_err(|e| (ErrorCode::Iroh, format!("local lookup: {e}")))?;
611 if already {
612 return Ok(Fetched {
613 kind,
614 hash,
615 from_cache: true,
616 _tt: tt,
617 });
618 }
619
620 on_progress(FetchProgress::Connecting);
621
622 let (iroh_ids, urls) = partition(locations);
623 let no_locations = iroh_ids.is_empty() && urls.is_empty();
624 let mut errors: Vec<String> = Vec::new();
625 let mut got = false;
626
627 if !iroh_ids.is_empty() {
628 match fetch::download_iroh_to_store(
629 &ctx.downloader,
630 store,
631 haf,
632 iroh_ids,
633 &mut *on_progress,
634 )
635 .await
636 {
637 Ok(()) => got = true,
638 Err(errs) => errors.extend(errs.into_iter().map(|e| e.to_string())),
639 }
640 }
641 if !got {
642 match kind {
643 ArtifactKind::Blob => {
646 for url in &urls {
647 match fetch::http_to_store(store, url, cid, &mut *on_progress).await {
648 Ok(_) => break,
649 Err(e) => errors.push(e.to_string()),
650 }
651 }
652 }
653 ArtifactKind::Collection => {
654 for url in &urls {
655 errors.push(format!("HTTP fetch unsupported for collection: {url}"));
656 }
657 }
658 }
659 }
660
661 let complete = store
664 .remote()
665 .local(haf)
666 .await
667 .map(|i| i.is_complete())
668 .unwrap_or(false);
669 if !complete {
670 drop(tt); let code = if no_locations {
672 ErrorCode::NoLocations
673 } else {
674 ErrorCode::AllFailed
675 };
676 let msg = if errors.is_empty() {
677 "no locations succeeded".to_string()
678 } else {
679 errors.join("; ")
680 };
681 return Err((code, msg));
682 }
683
684 Ok(Fetched {
685 kind,
686 hash,
687 from_cache: false,
688 _tt: tt,
689 })
690}
691
692async fn stream_fetch(
701 ctx: &NodeCtx,
702 read: &mut (impl AsyncReadExt + Unpin),
703 write: &mut (impl AsyncWriteExt + Unpin),
704 rid: RepoId,
705 cid: Cid,
706 locations: Vec<FetchLocation>,
707 seed: Option<Oid>,
708) -> io::Result<()> {
709 let endpoint_id = ctx.endpoint_id;
710
711 run_stream(read, write, async move |tx| {
712 let mut on_progress = move |p| {
713 let _ = tx.send(p);
714 };
715 let store = &ctx.store;
716
717 let fetched = fetch_into_store(ctx, &cid, &locations, &mut on_progress).await?;
718 let seeded = tag_if_seeding(store, &rid, seed.as_ref(), &cid, fetched.hash)
719 .await
720 .map_err(|e| (share_error_to_code(&e), e.to_string()))?;
721
722 let bytes = seeder::artifact_size_for(store, &cid, fetched.hash).await;
724 Ok(FetchReceipt {
725 rid,
726 cid,
727 bytes,
728 from_cache: fetched.from_cache,
729 seeded,
730 endpoint_id,
731 })
732 })
734 .await
735}
736
737#[allow(clippy::too_many_arguments)]
745async fn stream_download(
746 ctx: &NodeCtx,
747 read: &mut (impl AsyncReadExt + Unpin),
748 write: &mut (impl AsyncWriteExt + Unpin),
749 rid: RepoId,
750 cid: Cid,
751 locations: Vec<FetchLocation>,
752 dest: PathBuf,
753 seed: Option<Oid>,
754) -> io::Result<()> {
755 let endpoint_id = ctx.endpoint_id;
756
757 run_stream(read, write, async move |tx| {
758 let mut on_progress = move |p| {
759 let _ = tx.send(p);
760 };
761 let store = &ctx.store;
762
763 let fetched = fetch_into_store(ctx, &cid, &locations, &mut on_progress).await?;
764
765 let bytes =
767 export_to_dest(store, fetched.hash, fetched.kind, &dest, &mut on_progress).await?;
768 let seeded = tag_if_seeding(store, &rid, seed.as_ref(), &cid, fetched.hash)
769 .await
770 .map_err(|e| (share_error_to_code(&e), e.to_string()))?;
771
772 Ok(DownloadReceipt {
773 rid,
774 cid,
775 dest,
776 bytes,
777 from_cache: fetched.from_cache,
778 seeded,
779 endpoint_id,
780 })
781 })
783 .await
784}
785
786async fn tag_if_seeding(
793 store: &iroh_blobs::api::Store,
794 rid: &RepoId,
795 seed: Option<&Oid>,
796 cid: &Cid,
797 hash: iroh_blobs::Hash,
798) -> Result<bool, ShareError> {
799 let Some(release) = seed else {
800 return Ok(false);
801 };
802 seeder::tag_seeded(store, rid, release, cid, hash).await?;
803 Ok(true)
804}
805
806#[allow(clippy::too_many_arguments)]
807async fn seed_response(
808 store: &FsStore,
809 rid: RepoId,
810 release: Oid,
811 cid: Cid,
812 path: &Path,
813 kind: ArtifactKind,
814 mode: ImportMode,
815 endpoint_id: EndpointId,
816) -> String {
817 if !path.exists() {
818 return err_json::<SeedReceipt>(
819 ErrorCode::PathNotFound,
820 format!("path not found: {}", path.display()),
821 );
822 }
823
824 let was_already = match seeder::is_seeded(store, &rid, &release, &cid).await {
825 Ok(v) => v,
826 Err(e) => return err_from_share::<SeedReceipt>(e),
827 };
828 let hash = match seeder::seed_artifact(store, &rid, &release, &cid, path, kind, mode).await {
829 Ok(hash) => hash,
830 Err(e) => return err_from_share::<SeedReceipt>(e),
831 };
832 let bytes = seeder::artifact_size_for(store, &cid, hash).await;
833 let receipt = SeedReceipt {
834 rid,
835 cid,
836 endpoint_id,
837 bytes,
838 was_new: !was_already,
839 };
840 ok_json(receipt)
841}
842
843async fn unseed_response(store: &FsStore, rid: RepoId, release: Option<Oid>, cid: Cid) -> String {
848 let was_removed = match &release {
849 Some(release) => match seeder::untag_seeded(store, &rid, release, &cid).await {
850 Ok(removed) => removed,
851 Err(e) => return err_from_share::<UnseedReceipt>(e),
852 },
853 None => match seeder::untag_all(store, &rid, &cid).await {
854 Ok(removed) => removed > 0,
855 Err(e) => return err_from_share::<UnseedReceipt>(e),
856 },
857 };
858 ok_json(UnseedReceipt {
859 rid,
860 cid,
861 was_removed,
862 })
863}
864
865async fn is_seeding_response(store: &FsStore, rid: &RepoId, cid: &Cid) -> String {
866 match seeder::is_seeded_any(store, rid, cid).await {
867 Ok(v) => ok_json(v),
868 Err(e) => err_from_share::<bool>(e),
869 }
870}
871
872async fn list_seeded_response(store: &FsStore, rid: RepoId) -> String {
873 let cids = match seeder::seeded_cids(store, &rid).await {
874 Ok(v) => v,
875 Err(e) => return err_from_share::<Vec<SeededEntry>>(e),
876 };
877 let mut out = Vec::with_capacity(cids.len());
878 for (cid, hash) in cids {
879 let bytes = seeder::artifact_size_for(store, &cid, hash).await;
880 let complete = seeder::artifact_is_complete(store, &cid, hash).await;
883 out.push(SeededEntry {
884 cid,
885 bytes,
886 complete,
887 });
888 }
889 ok_json(out)
890}
891
892async fn build_status(
893 store: &FsStore,
894 endpoint: &iroh::Endpoint,
895 endpoint_id: EndpointId,
896 started_at_unix: i64,
897) -> Result<Status, ShareError> {
898 let metrics = endpoint.metrics();
899 let distinct: std::collections::HashMap<iroh_blobs::Hash, Cid> = seeder::all_seeded(store)
902 .await?
903 .into_iter()
904 .map(|(_rid, _release, cid, hash)| (hash, cid))
905 .collect();
906 let count = distinct.len();
907 let mut bytes_logical = 0u64;
908 for (hash, cid) in &distinct {
909 bytes_logical =
910 bytes_logical.saturating_add(seeder::artifact_size_for(store, cid, *hash).await);
911 }
912 let s = &metrics.socket;
916 let opened_total = s.num_conns_opened.get();
917 let closed_total = s.num_conns_closed.get();
918 let connections = radicle_artifact_core::protocol::ConnectionStats {
919 active: opened_total.saturating_sub(closed_total) as u32,
920 opened_total,
921 closed_total,
922 direct_total: s.num_conns_direct.get(),
923 holepunch_attempts: s.holepunch_attempts.get(),
924 paths_direct: s.paths_direct.get(),
925 paths_relayed: s.paths_relay.get(),
926 };
927 let traffic = radicle_artifact_core::protocol::TrafficStats {
928 out_bytes: s
929 .send_ipv4
930 .get()
931 .saturating_add(s.send_ipv6.get())
932 .saturating_add(s.send_relay.get()),
933 in_bytes: s
934 .recv_data_ipv4
935 .get()
936 .saturating_add(s.recv_data_ipv6.get())
937 .saturating_add(s.recv_data_relay.get())
938 .saturating_add(s.recv_data_custom.get()),
939 };
940 let relay = relay_stats(endpoint);
941 let relay_unreachable = !relay.relays.iter().any(|r| r.connected);
944 Ok(Status {
945 endpoint_id,
946 started_at_unix,
947 seeded: radicle_artifact_core::protocol::SeededStats {
948 count,
949 bytes_logical,
950 },
951 connections,
952 traffic,
953 relay,
954 warnings: radicle_artifact_core::protocol::Warnings { relay_unreachable },
955 })
956}
957
958fn relay_stats(endpoint: &iroh::Endpoint) -> radicle_artifact_core::protocol::RelayStats {
964 use iroh::Watcher;
965
966 let report = endpoint.net_report().get();
967 let mut latency_ms: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
969 if let Some(r) = &report {
970 for (_probe, url, dur) in r.relay_latency.iter() {
971 let ms = dur.as_millis() as u64;
972 latency_ms
973 .entry(url.to_string())
974 .and_modify(|m| *m = (*m).min(ms))
975 .or_insert(ms);
976 }
977 }
978
979 let relays = endpoint
980 .home_relay_status()
981 .get()
982 .into_iter()
983 .map(|s| {
984 let url = s.url().to_string();
985 radicle_artifact_core::protocol::RelayHealth {
986 latency_ms: latency_ms.get(&url).copied(),
987 connected: s.is_connected(),
988 last_error: s.last_error().map(|e| e.to_string()),
989 url,
990 }
991 })
992 .collect();
993
994 radicle_artifact_core::protocol::RelayStats {
995 relays,
996 preferred: report
997 .as_ref()
998 .and_then(|r| r.preferred_relay.as_ref().map(|u| u.to_string())),
999 udp_v4: report.as_ref().map(|r| r.udp_v4).unwrap_or(false),
1000 udp_v6: report.as_ref().map(|r| r.udp_v6).unwrap_or(false),
1001 }
1002}
1003
1004fn ok_json<T: serde::Serialize>(v: T) -> String {
1005 serde_json::to_string(&CommandResult::Okay(v))
1006 .unwrap_or_else(|e| format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#))
1007}
1008
1009fn err_json<T>(code: ErrorCode, message: String) -> String
1010where
1011 T: serde::Serialize,
1012{
1013 serde_json::to_string(&CommandResult::<T>::Error(CommandError { code, message }))
1014 .unwrap_or_else(|e| format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#))
1015}
1016
1017fn err_from_share<T>(e: ShareError) -> String
1018where
1019 T: serde::Serialize,
1020{
1021 let code = share_error_to_code(&e);
1022 err_json::<T>(code, e.to_string())
1023}
1024
1025fn share_error_to_code(e: &ShareError) -> ErrorCode {
1026 match e {
1027 ShareError::CidMismatch { .. } => ErrorCode::CidMismatch,
1028 ShareError::Io(_) => ErrorCode::Io,
1029 ShareError::Iroh(_) => ErrorCode::Iroh,
1030 _ => ErrorCode::Internal,
1031 }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use std::fs;
1037
1038 use cid::multihash::Multihash;
1039
1040 use super::*;
1041 use radicle_artifact_core::cid::{
1042 self as cid_utils, ArtifactKind, HASH_CODE_BLAKE3, RAW_CODEC,
1043 };
1044
1045 fn fake_blob_cid(data: &[u8]) -> Cid {
1048 let digest = blake3::hash(data);
1049 let mh = Multihash::<64>::wrap(HASH_CODE_BLAKE3, digest.as_bytes()).unwrap();
1050 Cid::from(cid::Cid::new_v1(RAW_CODEC, mh))
1051 }
1052
1053 fn rid_a() -> RepoId {
1054 RepoId::from_str("rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip").unwrap()
1055 }
1056
1057 fn release_a() -> Oid {
1058 Oid::from_str("0123456789abcdef0123456789abcdef01234567").unwrap()
1059 }
1060
1061 async fn start_node(
1064 home: &Path,
1065 secret: iroh::SecretKey,
1066 ) -> (PathBuf, tokio::task::JoinHandle<Result<(), NodeError>>) {
1067 let home_path = home.to_path_buf();
1068 let handle = tokio::spawn(async move { run(&home_path, secret).await });
1069 let socket = home.join(ARTIFACTS_DIR).join("control.sock");
1070 for _ in 0..200 {
1071 if socket.exists() {
1072 break;
1073 }
1074 tokio::time::sleep(Duration::from_millis(50)).await;
1075 }
1076 assert!(socket.exists(), "control socket never appeared");
1077 (socket, handle)
1078 }
1079
1080 #[test]
1084 fn node_round_trip() {
1085 let rt = tokio::runtime::Builder::new_multi_thread()
1086 .enable_all()
1087 .build()
1088 .unwrap();
1089 rt.block_on(async {
1090 let home = tempfile::tempdir().unwrap();
1091
1092 let blob_path = home.path().join("payload.bin");
1095 let payload = b"hello rad-artifact";
1096 fs::write(&blob_path, payload).unwrap();
1097 let real_cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1098 let rid = rid_a();
1099
1100 let secret = iroh::SecretKey::from_bytes(&[1u8; 32]);
1102 let expected_endpoint_id = EndpointId::from(secret.public());
1103
1104 let home_path = home.path().to_path_buf();
1107 let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1108
1109 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1112 for _ in 0..200 {
1113 if socket.exists() {
1114 break;
1115 }
1116 tokio::time::sleep(Duration::from_millis(50)).await;
1117 }
1118 assert!(socket.exists(), "control socket never appeared");
1119
1120 let client = Client::new(socket.clone());
1121
1122 assert!(client.is_running().await);
1124
1125 let status = client.status().await.unwrap();
1127 assert_eq!(status.endpoint_id, expected_endpoint_id);
1128 assert_eq!(status.seeded.count, 0);
1129
1130 let receipt = client
1132 .seed(
1133 rid,
1134 release_a(),
1135 real_cid,
1136 &blob_path,
1137 ArtifactKind::Blob,
1138 ImportMode::Copy,
1139 )
1140 .await
1141 .unwrap();
1142 assert!(receipt.was_new);
1143 assert_eq!(receipt.endpoint_id, expected_endpoint_id);
1144 assert_eq!(receipt.bytes, payload.len() as u64);
1145
1146 let receipt2 = client
1148 .seed(
1149 rid,
1150 release_a(),
1151 real_cid,
1152 &blob_path,
1153 ArtifactKind::Blob,
1154 ImportMode::Copy,
1155 )
1156 .await
1157 .unwrap();
1158 assert!(!receipt2.was_new);
1159
1160 let bad_path = home.path().join("tampered.bin");
1162 fs::write(&bad_path, b"different bytes").unwrap();
1163 let bad_cid = fake_blob_cid(b"not the right preimage");
1164 let err = client
1165 .seed(
1166 rid,
1167 release_a(),
1168 bad_cid,
1169 &bad_path,
1170 ArtifactKind::Blob,
1171 ImportMode::Copy,
1172 )
1173 .await
1174 .expect_err("CID mismatch must error");
1175 match err {
1176 radicle_artifact_client::ClientError::Remote(CommandError { code, .. }) => {
1177 assert_eq!(code, ErrorCode::CidMismatch);
1178 }
1179 other => panic!("expected CidMismatch error, got {other:?}"),
1180 }
1181
1182 assert!(client.is_seeding(rid, real_cid).await.unwrap());
1184
1185 let entries = client.list_seeded(rid).await.unwrap();
1187 assert_eq!(entries.len(), 1);
1188 assert_eq!(entries[0].cid, real_cid);
1189 assert_eq!(entries[0].bytes, payload.len() as u64);
1190
1191 let status = client.status().await.unwrap();
1193 assert_eq!(status.seeded.count, 1);
1194 assert_eq!(status.seeded.bytes_logical, payload.len() as u64);
1195
1196 let r1 = client
1198 .unseed(rid, Some(release_a()), real_cid)
1199 .await
1200 .unwrap();
1201 assert!(r1.was_removed);
1202 let r2 = client
1203 .unseed(rid, Some(release_a()), real_cid)
1204 .await
1205 .unwrap();
1206 assert!(!r2.was_removed);
1207 assert!(!client.is_seeding(rid, real_cid).await.unwrap());
1208
1209 client.shutdown().await.unwrap();
1211 tokio::time::timeout(Duration::from_secs(10), node_handle)
1212 .await
1213 .expect("node did not exit within 10s")
1214 .expect("join error")
1215 .expect("node returned error");
1216
1217 assert!(!socket.exists());
1219 });
1220 }
1221
1222 #[test]
1226 fn invalid_typed_fields_surface_as_invalid_request() {
1227 let rt = tokio::runtime::Builder::new_multi_thread()
1228 .enable_all()
1229 .build()
1230 .unwrap();
1231 rt.block_on(async {
1232 let home = tempfile::tempdir().unwrap();
1233 let secret = iroh::SecretKey::from_bytes(&[4u8; 32]);
1234 let home_path = home.path().to_path_buf();
1235 let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1236
1237 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1238 for _ in 0..200 {
1239 if socket.exists() {
1240 break;
1241 }
1242 tokio::time::sleep(Duration::from_millis(50)).await;
1243 }
1244 assert!(socket.exists());
1245
1246 for (frame, expected_field) in [
1249 (
1250 br#"{"command":"listSeeded","rid":"not-a-real-rid"}"#.as_slice(),
1251 "rid",
1252 ),
1253 (
1254 br#"{"command":"isSeeding","rid":"rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip","cid":"not-a-real-cid"}"#.as_slice(),
1255 "cid",
1256 ),
1257 ] {
1258 let mut stream = tokio::net::UnixStream::connect(&socket).await.unwrap();
1259 tokio::io::AsyncWriteExt::write_all(&mut stream, frame)
1260 .await
1261 .unwrap();
1262 tokio::io::AsyncWriteExt::write_all(&mut stream, b"\n")
1263 .await
1264 .unwrap();
1265 let mut buf = String::new();
1266 tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1267 .await
1268 .unwrap();
1269 let parsed: CommandResult<serde_json::Value> =
1270 serde_json::from_str(buf.trim()).unwrap();
1271 match parsed {
1272 CommandResult::Error(CommandError { code, message }) => {
1273 assert_eq!(code, ErrorCode::InvalidRequest);
1274 assert!(
1275 message.contains(expected_field),
1276 "message should name {expected_field}: {message}"
1277 );
1278 }
1279 CommandResult::Okay(_) => panic!("expected error, got ok"),
1280 }
1281 }
1282
1283 let client = Client::new(socket);
1285 client.shutdown().await.unwrap();
1286 node_handle.await.unwrap().unwrap();
1287 });
1288 }
1289
1290 #[test]
1293 fn double_start_errors() {
1294 let rt = tokio::runtime::Builder::new_multi_thread()
1295 .enable_all()
1296 .build()
1297 .unwrap();
1298 rt.block_on(async {
1299 let home = tempfile::tempdir().unwrap();
1300 let secret = iroh::SecretKey::from_bytes(&[2u8; 32]);
1301 let home_path = home.path().to_path_buf();
1302 let first = tokio::spawn(async move { run(&home_path, secret).await });
1303
1304 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1305 for _ in 0..200 {
1306 if socket.exists() {
1307 break;
1308 }
1309 tokio::time::sleep(Duration::from_millis(50)).await;
1310 }
1311 assert!(socket.exists());
1312
1313 let secret2 = iroh::SecretKey::from_bytes(&[3u8; 32]);
1315 let err = run(home.path(), secret2).await.expect_err("must fail");
1316 assert!(matches!(err, NodeError::AlreadyRunning(_)));
1317
1318 let client = Client::new(socket);
1320 client.shutdown().await.unwrap();
1321 first.await.unwrap().unwrap();
1322 });
1323 }
1324
1325 async fn oneshot<T: serde::de::DeserializeOwned>(
1327 socket: &Path,
1328 cmd: &Command,
1329 ) -> CommandResult<T> {
1330 let mut stream = UnixStream::connect(socket).await.unwrap();
1331 let mut line = serde_json::to_string(cmd).unwrap();
1332 line.push('\n');
1333 tokio::io::AsyncWriteExt::write_all(&mut stream, line.as_bytes())
1334 .await
1335 .unwrap();
1336 let mut buf = String::new();
1337 tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1338 .await
1339 .unwrap();
1340 serde_json::from_str(buf.trim()).unwrap()
1341 }
1342
1343 async fn streaming<T: serde::de::DeserializeOwned>(
1345 socket: &Path,
1346 cmd: &Command,
1347 ) -> (usize, StreamEvent<T>) {
1348 let mut stream = UnixStream::connect(socket).await.unwrap();
1349 let mut line = serde_json::to_string(cmd).unwrap();
1350 line.push('\n');
1351 tokio::io::AsyncWriteExt::write_all(&mut stream, line.as_bytes())
1352 .await
1353 .unwrap();
1354 let mut buf = String::new();
1355 tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1356 .await
1357 .unwrap();
1358 let mut progress = 0usize;
1359 let mut terminal = None;
1360 for l in buf.lines().filter(|l| !l.trim().is_empty()) {
1361 match serde_json::from_str::<StreamEvent<T>>(l).unwrap() {
1362 StreamEvent::Progress(_) => progress += 1,
1363 term => terminal = Some(term),
1364 }
1365 }
1366 (progress, terminal.expect("a terminal frame"))
1367 }
1368
1369 #[test]
1372 fn has_and_export_round_trip() {
1373 let rt = tokio::runtime::Builder::new_multi_thread()
1374 .enable_all()
1375 .build()
1376 .unwrap();
1377 rt.block_on(async {
1378 let home = tempfile::tempdir().unwrap();
1379 let payload = b"hello has/export";
1380 let blob_path = home.path().join("payload.bin");
1381 fs::write(&blob_path, payload).unwrap();
1382 let cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1383 let rid = rid_a();
1384
1385 let secret = iroh::SecretKey::from_bytes(&[5u8; 32]);
1386 let home_path = home.path().to_path_buf();
1387 let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1388
1389 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1390 for _ in 0..200 {
1391 if socket.exists() {
1392 break;
1393 }
1394 tokio::time::sleep(Duration::from_millis(50)).await;
1395 }
1396 assert!(socket.exists(), "control socket never appeared");
1397
1398 let client = Client::new(socket.clone());
1400 client
1401 .seed(
1402 rid,
1403 release_a(),
1404 cid,
1405 &blob_path,
1406 ArtifactKind::Blob,
1407 ImportMode::Copy,
1408 )
1409 .await
1410 .unwrap();
1411
1412 match oneshot::<HasResult>(&socket, &Command::Has { cid }).await {
1414 CommandResult::Okay(h) => {
1415 assert!(h.present);
1416 assert!(h.complete);
1417 assert_eq!(h.bytes, payload.len() as u64);
1418 }
1419 CommandResult::Error(e) => panic!("has errored: {e:?}"),
1420 }
1421
1422 let unknown = fake_blob_cid(b"never stored");
1424 match oneshot::<HasResult>(&socket, &Command::Has { cid: unknown }).await {
1425 CommandResult::Okay(h) => {
1426 assert!(!h.present);
1427 assert!(!h.complete);
1428 }
1429 CommandResult::Error(e) => panic!("has errored: {e:?}"),
1430 }
1431
1432 let dest = home.path().join("exported.bin");
1434 let (_progress, term) = streaming::<ExportReceipt>(
1435 &socket,
1436 &Command::Export {
1437 cid,
1438 dest: dest.clone(),
1439 },
1440 )
1441 .await;
1442 match term {
1443 StreamEvent::Okay(r) => {
1444 assert_eq!(r.bytes, payload.len() as u64);
1445 assert_eq!(r.dest, dest);
1446 }
1447 other => panic!("expected okay, got {other:?}"),
1448 }
1449 assert_eq!(fs::read(&dest).unwrap(), payload);
1450
1451 let (_p, term) = streaming::<ExportReceipt>(
1453 &socket,
1454 &Command::Export {
1455 cid: unknown,
1456 dest: home.path().join("nope.bin"),
1457 },
1458 )
1459 .await;
1460 match term {
1461 StreamEvent::Error(e) => assert_eq!(e.code, ErrorCode::NotLocal),
1462 other => panic!("expected NotLocal error, got {other:?}"),
1463 }
1464
1465 client.shutdown().await.unwrap();
1466 node_handle.await.unwrap().unwrap();
1467 });
1468 }
1469
1470 #[test]
1477 fn fetch_and_download_fast_path_and_no_locations() {
1478 let rt = tokio::runtime::Builder::new_multi_thread()
1479 .enable_all()
1480 .build()
1481 .unwrap();
1482 rt.block_on(async {
1483 let home = tempfile::tempdir().unwrap();
1484 let payload = b"hello fetch fast-path";
1485 let blob_path = home.path().join("payload.bin");
1486 fs::write(&blob_path, payload).unwrap();
1487 let cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1488 let rid = rid_a();
1489
1490 let secret = iroh::SecretKey::from_bytes(&[6u8; 32]);
1491 let home_path = home.path().to_path_buf();
1492 let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1493
1494 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1495 for _ in 0..200 {
1496 if socket.exists() {
1497 break;
1498 }
1499 tokio::time::sleep(Duration::from_millis(50)).await;
1500 }
1501 assert!(socket.exists(), "control socket never appeared");
1502
1503 let client = Client::new(socket.clone());
1505 client
1506 .seed(
1507 rid,
1508 release_a(),
1509 cid,
1510 &blob_path,
1511 ArtifactKind::Blob,
1512 ImportMode::Copy,
1513 )
1514 .await
1515 .unwrap();
1516
1517 let (_p, term) = streaming::<FetchReceipt>(
1520 &socket,
1521 &Command::Fetch {
1522 rid,
1523 cid,
1524 locations: vec![],
1525 seed: None,
1526 },
1527 )
1528 .await;
1529 match term {
1530 StreamEvent::Okay(r) => {
1531 assert!(r.from_cache);
1532 assert!(!r.seeded);
1533 assert_eq!(r.bytes, payload.len() as u64);
1534 }
1535 other => panic!("expected okay, got {other:?}"),
1536 }
1537
1538 let dest = home.path().join("downloaded.bin");
1540 let (_p, term) = streaming::<DownloadReceipt>(
1541 &socket,
1542 &Command::Download {
1543 rid,
1544 cid,
1545 locations: vec![],
1546 dest: dest.clone(),
1547 seed: None,
1548 },
1549 )
1550 .await;
1551 match term {
1552 StreamEvent::Okay(r) => {
1553 assert!(r.from_cache);
1554 assert!(!r.seeded);
1555 assert_eq!(r.bytes, payload.len() as u64);
1556 assert_eq!(r.dest, dest);
1557 }
1558 other => panic!("expected okay, got {other:?}"),
1559 }
1560 assert_eq!(fs::read(&dest).unwrap(), payload);
1561
1562 let rid2 = RepoId::from_str("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5").unwrap();
1565 let (_p, term) = streaming::<FetchReceipt>(
1566 &socket,
1567 &Command::Fetch {
1568 rid: rid2,
1569 cid,
1570 locations: vec![],
1571 seed: Some(release_a()),
1572 },
1573 )
1574 .await;
1575 match term {
1576 StreamEvent::Okay(r) => {
1577 assert!(r.from_cache);
1578 assert!(r.seeded);
1579 }
1580 other => panic!("expected okay, got {other:?}"),
1581 }
1582 assert!(client.is_seeding(rid2, cid).await.unwrap());
1583
1584 let unknown = fake_blob_cid(b"absent");
1586 let (_p, term) = streaming::<FetchReceipt>(
1587 &socket,
1588 &Command::Fetch {
1589 rid,
1590 cid: unknown,
1591 locations: vec![],
1592 seed: None,
1593 },
1594 )
1595 .await;
1596 match term {
1597 StreamEvent::Error(e) => assert_eq!(e.code, ErrorCode::NoLocations),
1598 other => panic!("expected NoLocations error, got {other:?}"),
1599 }
1600
1601 client.shutdown().await.unwrap();
1602 node_handle.await.unwrap().unwrap();
1603 });
1604 }
1605
1606 #[test]
1611 fn run_stream_aborts_on_client_disconnect() {
1612 use std::sync::atomic::AtomicBool;
1613
1614 let rt = tokio::runtime::Builder::new_multi_thread()
1615 .enable_all()
1616 .build()
1617 .unwrap();
1618 rt.block_on(async {
1619 let (client_end, server_end) = tokio::io::duplex(64);
1622 let (mut srv_read, mut srv_write) = tokio::io::split(server_end);
1623
1624 struct DropFlag(Arc<AtomicBool>);
1627 impl Drop for DropFlag {
1628 fn drop(&mut self) {
1629 self.0.store(true, Ordering::SeqCst);
1630 }
1631 }
1632 let aborted = Arc::new(AtomicBool::new(false));
1633 let completed = Arc::new(AtomicBool::new(false));
1634 let aborted_op = aborted.clone();
1635 let completed_op = completed.clone();
1636
1637 let op = async move |_tx: mpsc::UnboundedSender<FetchProgress>| {
1638 let _guard = DropFlag(aborted_op);
1639 std::future::pending::<()>().await;
1641 completed_op.store(true, Ordering::SeqCst);
1642 Ok::<(), (ErrorCode, String)>(())
1643 };
1644
1645 let server =
1646 tokio::spawn(
1647 async move { run_stream::<()>(&mut srv_read, &mut srv_write, op).await },
1648 );
1649
1650 tokio::time::sleep(Duration::from_millis(50)).await;
1653 drop(client_end); let res = tokio::time::timeout(Duration::from_secs(5), server)
1656 .await
1657 .expect("run_stream did not return after disconnect")
1658 .expect("join error");
1659 assert!(res.is_ok());
1660 assert!(
1661 aborted.load(Ordering::SeqCst),
1662 "op future was not dropped on disconnect"
1663 );
1664 assert!(
1665 !completed.load(Ordering::SeqCst),
1666 "op should have been aborted, not completed"
1667 );
1668 });
1669 }
1670
1671 #[test]
1674 fn stale_socket_is_reclaimed() {
1675 let rt = tokio::runtime::Builder::new_multi_thread()
1676 .enable_all()
1677 .build()
1678 .unwrap();
1679 rt.block_on(async {
1680 let home = tempfile::tempdir().unwrap();
1681 let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1682 fs::create_dir_all(socket.parent().unwrap()).unwrap();
1683 drop(UnixListener::bind(&socket).unwrap());
1685 assert!(socket.exists());
1686
1687 let secret = iroh::SecretKey::from_bytes(&[7u8; 32]);
1688 let home_path = home.path().to_path_buf();
1689 let handle = tokio::spawn(async move { run(&home_path, secret).await });
1690
1691 let client = Client::new(socket.clone());
1694 let mut ready = false;
1695 for _ in 0..200 {
1696 if client.is_running().await {
1697 ready = true;
1698 break;
1699 }
1700 tokio::time::sleep(Duration::from_millis(50)).await;
1701 }
1702 assert!(ready, "node never came up after reclaiming stale socket");
1703
1704 client.shutdown().await.unwrap();
1705 handle.await.unwrap().unwrap();
1706 });
1707 }
1708
1709 #[test]
1712 fn seed_missing_path_errors() {
1713 let rt = tokio::runtime::Builder::new_multi_thread()
1714 .enable_all()
1715 .build()
1716 .unwrap();
1717 rt.block_on(async {
1718 let home = tempfile::tempdir().unwrap();
1719 let secret = iroh::SecretKey::from_bytes(&[8u8; 32]);
1720 let (socket, handle) = start_node(home.path(), secret).await;
1721
1722 let client = Client::new(socket.clone());
1723 let missing = home.path().join("does-not-exist.bin");
1724 let err = client
1725 .seed(
1726 rid_a(),
1727 release_a(),
1728 fake_blob_cid(b"whatever"),
1729 &missing,
1730 ArtifactKind::Blob,
1731 ImportMode::Copy,
1732 )
1733 .await
1734 .expect_err("missing path must error");
1735 match err {
1736 radicle_artifact_client::ClientError::Remote(CommandError { code, .. }) => {
1737 assert_eq!(code, ErrorCode::PathNotFound);
1738 }
1739 other => panic!("expected PathNotFound, got {other:?}"),
1740 }
1741
1742 client.shutdown().await.unwrap();
1743 handle.await.unwrap().unwrap();
1744 });
1745 }
1746
1747 #[test]
1750 fn malformed_json_surfaces_as_invalid_request() {
1751 let rt = tokio::runtime::Builder::new_multi_thread()
1752 .enable_all()
1753 .build()
1754 .unwrap();
1755 rt.block_on(async {
1756 let home = tempfile::tempdir().unwrap();
1757 let secret = iroh::SecretKey::from_bytes(&[9u8; 32]);
1758 let (socket, handle) = start_node(home.path(), secret).await;
1759
1760 let mut stream = UnixStream::connect(&socket).await.unwrap();
1761 tokio::io::AsyncWriteExt::write_all(&mut stream, b"this is not json\n")
1762 .await
1763 .unwrap();
1764 let mut buf = String::new();
1765 tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1766 .await
1767 .unwrap();
1768 let parsed: CommandResult<serde_json::Value> =
1769 serde_json::from_str(buf.trim()).unwrap();
1770 match parsed {
1771 CommandResult::Error(CommandError { code, message }) => {
1772 assert_eq!(code, ErrorCode::InvalidRequest);
1773 assert!(
1774 message.contains("invalid command JSON"),
1775 "message should name the JSON failure: {message}"
1776 );
1777 }
1778 CommandResult::Okay(_) => panic!("expected error, got ok"),
1779 }
1780
1781 let client = Client::new(socket);
1782 client.shutdown().await.unwrap();
1783 handle.await.unwrap().unwrap();
1784 });
1785 }
1786}