Skip to main content

radicle_artifact_node/
node.rs

1//! Long-running `rad-artifact` node: owns the seeder, exposes a
2//! control socket, drives graceful shutdown on signals.
3//!
4//! [`run`] bootstraps the [`Seeder`](crate::seeder::Seeder), binds
5//! `<home>/artifacts/control.sock`, accepts one [`Command`] per
6//! connection and writes back one [`CommandResult`]. Parent-side
7//! helpers (detached spawn, passphrase resolution, log rotation,
8//! liveness polling) live in the `radicle-artifact` crate's `lifecycle` module.
9//!
10//! - tags survive shutdown — restart resumes seeding what was previously
11//!   tagged
12
13use 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
45/// How long shutdown waits for in-flight handlers before forcing the
46/// router down anyway. Sized to outlast a large collection import so a
47/// shutdown mid-seed doesn't abort the write; a genuinely stuck handler
48/// still can't pin us past this bound.
49const DRAIN_TIMEOUT: Duration = Duration::from_secs(300);
50
51/// How long a connection may sit without sending its command line before
52/// we drop it. Bounds idle/half-open clients so they can't pin a handler
53/// task and fd indefinitely.
54const READ_TIMEOUT: Duration = Duration::from_secs(30);
55
56/// How long [`iroh::protocol::Router::shutdown`] gets before we give up
57/// and return.
58const ROUTER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
59
60/// Failure modes for [`run`].
61#[derive(Debug, thiserror::Error)]
62pub enum NodeError {
63    /// Another node is already bound to the control socket — bail out
64    /// instead of stealing it.
65    #[error("another rad-artifact node is already running at {0}")]
66    AlreadyRunning(PathBuf),
67    /// Seeder bootstrap or runtime error.
68    #[error(transparent)]
69    Share(#[from] ShareError),
70    /// Local I/O (mkdir, bind, chmod) failure.
71    #[error("node I/O error: {0}")]
72    Io(#[from] io::Error),
73}
74
75/// Shared per-node state passed to every connection handler.
76///
77/// Built once at startup and wrapped in an `Arc` so each spawned handler
78/// gets a cheap clone. Holds the single `FsStore` and the single
79/// [`Downloader`] (built on the seeder endpoint, pooling connections
80/// across all fetches), plus identity/uptime fields for `Status`.
81struct NodeCtx {
82    /// The persistent blob store.
83    store: FsStore,
84    /// Downloader bound to the seeder endpoint, reused across fetches.
85    downloader: Downloader,
86    /// The seeder endpoint, kept for its live connection/traffic metrics
87    /// (a clone shares the underlying counters with the router's endpoint).
88    endpoint: iroh::Endpoint,
89    /// Endpoint id the node serves on.
90    endpoint_id: EndpointId,
91    /// Unix timestamp (seconds) when the node bound its socket.
92    started_at_unix: i64,
93    /// Pkarr relays the endpoint publishes to.
94    pkarr_urls: Vec<url::Url>,
95}
96
97/// Run the node in the foreground until it receives a shutdown signal
98/// or a [`Command::Shutdown`].
99///
100/// On entry the function:
101/// 1. bootstraps the seeder under `<home>/artifacts/`
102/// 2. probes the control socket — if a live owner answers, returns
103///    [`NodeError::AlreadyRunning`]; if the socket file exists but
104///    nothing answers, unlinks it
105/// 3. binds the socket with 0600 perms
106/// 4. installs SIGTERM/SIGINT handlers and runs the accept loop
107///
108/// On exit the function drains in-flight handlers (up to
109/// `DRAIN_TIMEOUT`) and shuts the iroh router down (capped by
110/// `ROUTER_SHUTDOWN_TIMEOUT`). The socket file is unlinked. Seeded
111/// tags are intentionally left in place so a restart resumes the prior
112/// set.
113pub async fn run(home: &Path, secret: iroh::SecretKey) -> Result<(), NodeError> {
114    let socket_path = home.join(ARTIFACTS_DIR).join("control.sock");
115    // ensure the parent directory exists before we probe / bind
116    if let Some(parent) = socket_path.parent() {
117        std::fs::create_dir_all(parent)?;
118    }
119
120    // Probe the socket BEFORE opening the FsStore: a live owner means
121    // we'd block on its single-writer lock, so cheap-fail here with a
122    // friendly error. A stale file is unlinked so bind() can succeed.
123    if socket_path.exists() {
124        let probe = Client::new(socket_path.clone());
125        if probe.is_running().await {
126            return Err(NodeError::AlreadyRunning(socket_path));
127        }
128        std::fs::remove_file(&socket_path)?;
129    }
130
131    let seeder = seeder::bootstrap(home, secret).await?;
132
133    let listener = UnixListener::bind(&socket_path)?;
134    std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600))?;
135
136    let started_at_unix = SystemTime::now()
137        .duration_since(UNIX_EPOCH)
138        .map(|d| d.as_secs() as i64)
139        .unwrap_or(0);
140
141    let endpoint_id = EndpointId::from(seeder.router.endpoint().id());
142
143    tracing::info!(
144        endpoint_id = %endpoint_id,
145        socket = %socket_path.display(),
146        "rad-artifact node ready"
147    );
148
149    // One Downloader on the seeder endpoint, reused across all fetches so
150    // the connection pool is shared (no per-fetch endpoint bind).
151    let downloader = Downloader::new_with_opts(
152        seeder.blobs.as_ref(),
153        seeder.router.endpoint(),
154        fetch::pool_options(),
155    );
156    let ctx = Arc::new(NodeCtx {
157        store: seeder.blobs.clone(),
158        downloader,
159        endpoint: seeder.router.endpoint().clone(),
160        endpoint_id,
161        started_at_unix,
162        pkarr_urls: seeder.pkarr_urls.clone(),
163    });
164
165    // Subscribe before installing the signal handler: a signal that
166    // arrives during startup must land in a live receiver, otherwise the
167    // broadcast (which doesn't buffer for absent receivers) drops it and
168    // the accept loop never sees the shutdown.
169    let (shutdown_tx, mut shutdown_rx) = broadcast::channel::<()>(8);
170    spawn_signal_handler(shutdown_tx.clone());
171
172    let in_flight = Arc::new(AtomicUsize::new(0));
173
174    loop {
175        tokio::select! {
176            biased;
177            res = shutdown_rx.recv() => {
178                // any send (signal or Command::Shutdown) -> stop accepting
179                if res.is_ok() { break; }
180            }
181            accept = listener.accept() => {
182                let (stream, _addr) = match accept {
183                    Ok(v) => v,
184                    Err(e) => {
185                        tracing::warn!("accept error: {e}");
186                        continue;
187                    }
188                };
189                let ctx = ctx.clone();
190                let shutdown_tx = shutdown_tx.clone();
191                let in_flight = in_flight.clone();
192                in_flight.fetch_add(1, Ordering::SeqCst);
193                tokio::spawn(async move {
194                    if let Err(e) = handle_connection(stream, &ctx, &shutdown_tx).await {
195                        tracing::warn!("handler error: {e}");
196                    }
197                    in_flight.fetch_sub(1, Ordering::SeqCst);
198                });
199            }
200        }
201    }
202
203    // stop accepting new connections (file inode still alive until unlink)
204    drop(listener);
205    let _ = std::fs::remove_file(&socket_path);
206
207    // wait for in-flight handlers; bounded so a stuck handler can't pin us
208    let drain_deadline = Instant::now() + DRAIN_TIMEOUT;
209    while in_flight.load(Ordering::SeqCst) > 0 && Instant::now() < drain_deadline {
210        tokio::time::sleep(Duration::from_millis(50)).await;
211    }
212
213    let _ = tokio::time::timeout(ROUTER_SHUTDOWN_TIMEOUT, seeder.router.shutdown()).await;
214    tracing::info!("rad-artifact node stopped");
215    Ok(())
216}
217
218/// Watch SIGTERM/SIGINT in the background and broadcast a shutdown signal.
219fn spawn_signal_handler(shutdown_tx: broadcast::Sender<()>) {
220    tokio::spawn(async move {
221        let Ok(mut term) = signal(SignalKind::terminate()) else {
222            return;
223        };
224        let Ok(mut int) = signal(SignalKind::interrupt()) else {
225            return;
226        };
227        tokio::select! {
228            _ = term.recv() => {}
229            _ = int.recv() => {}
230        }
231        let _ = shutdown_tx.send(());
232    });
233}
234
235/// Read one command, then either write one response (one-shot commands)
236/// or stream frames (`Fetch`/`Export`), and close.
237async fn handle_connection(
238    stream: UnixStream,
239    ctx: &NodeCtx,
240    shutdown_tx: &broadcast::Sender<()>,
241) -> io::Result<()> {
242    let (read, mut write) = stream.into_split();
243    let mut reader = BufReader::new(read);
244    let mut line = String::new();
245    // Bound the wait for the command line so a connected-but-silent
246    // client can't pin this handler (and its fd) forever.
247    let n = match tokio::time::timeout(READ_TIMEOUT, reader.read_line(&mut line)).await {
248        Ok(res) => res?,
249        Err(_) => return Ok(()),
250    };
251    if n == 0 {
252        return Ok(());
253    }
254
255    match parse_command(line.trim_end()) {
256        // Streaming commands write their own frames directly. They also get
257        // the read half to watch for client disconnect during silent phases.
258        Ok(Command::Export { cid, dest }) => {
259            stream_export(ctx, &mut reader, &mut write, cid, dest).await
260        }
261        Ok(Command::Fetch {
262            rid,
263            cid,
264            locations,
265            seed,
266        }) => stream_fetch(ctx, &mut reader, &mut write, rid, cid, locations, seed).await,
267        Ok(Command::Download {
268            rid,
269            cid,
270            locations,
271            dest,
272            seed,
273        }) => {
274            stream_download(
275                ctx,
276                &mut reader,
277                &mut write,
278                rid,
279                cid,
280                locations,
281                dest,
282                seed,
283            )
284            .await
285        }
286        // One-shot commands return a single JSON line.
287        Ok(cmd) => write_line(&mut write, dispatch(cmd, ctx, shutdown_tx).await).await,
288        Err((code, msg)) => write_line(&mut write, err_json::<()>(code, msg)).await,
289    }
290}
291
292/// Write a single JSON response line (with trailing newline) and flush.
293async fn write_line(
294    write: &mut (impl AsyncWriteExt + Unpin),
295    mut response: String,
296) -> io::Result<()> {
297    response.push('\n');
298    write.write_all(response.as_bytes()).await?;
299    write.flush().await
300}
301
302/// Two-step wire decode: catch malformed `rid` (and other typed fields)
303/// with a message that names the field, instead of leaking the
304/// underlying serde error verbatim (`Unknown base code: n` etc.).
305fn parse_command(line: &str) -> Result<Command, (ErrorCode, String)> {
306    let value: serde_json::Value = serde_json::from_str(line).map_err(|e| {
307        (
308            ErrorCode::InvalidRequest,
309            format!("invalid command JSON: {e}"),
310        )
311    })?;
312    if let Some(rid_s) = value.get("rid").and_then(|v| v.as_str()) {
313        if let Err(e) = RepoId::from_str(rid_s) {
314            return Err((
315                ErrorCode::InvalidRequest,
316                format!("invalid rid {rid_s:?}: {e}"),
317            ));
318        }
319    }
320    if let Some(cid_s) = value.get("cid").and_then(|v| v.as_str()) {
321        if let Err(e) = Cid::from_str(cid_s) {
322            return Err((
323                ErrorCode::InvalidRequest,
324                format!("invalid cid {cid_s:?}: {e}"),
325            ));
326        }
327    }
328    serde_json::from_value(value)
329        .map_err(|e| (ErrorCode::InvalidRequest, format!("invalid command: {e}")))
330}
331
332/// Dispatch a one-shot command, returning the JSON line to send back
333/// (without trailing newline). Streaming commands (`Fetch`, `Export`) are
334/// handled in [`handle_connection`] before reaching here.
335async fn dispatch(cmd: Command, ctx: &NodeCtx, shutdown_tx: &broadcast::Sender<()>) -> String {
336    let store = &ctx.store;
337    match cmd {
338        // Cheap liveness probe: touch no state, just ack.
339        Command::Alive => ok_json(()),
340        Command::Status => match build_status(ctx).await {
341            Ok(status) => ok_json(status),
342            Err(e) => err_from_share::<Status>(e),
343        },
344        Command::Seed {
345            rid,
346            release,
347            cid,
348            path,
349            kind,
350            mode,
351        } => seed_response(store, rid, release, cid, &path, kind, mode, ctx.endpoint_id).await,
352        Command::Unseed { rid, release, cid } => unseed_response(store, rid, release, cid).await,
353        Command::IsSeeding { rid, cid } => is_seeding_response(store, &rid, &cid).await,
354        Command::ListSeeded { rid } => list_seeded_response(store, rid).await,
355        Command::Has { cid } => has_response(store, &cid).await,
356        // Intercepted in handle_connection; never reaches dispatch.
357        Command::Export { .. } | Command::Fetch { .. } | Command::Download { .. } => {
358            unreachable!("streaming commands are handled before dispatch")
359        }
360        Command::Shutdown => {
361            // ack first, then broadcast so the loop tears down after the
362            // response makes it to the wire
363            let resp = ok_json(());
364            let _ = shutdown_tx.send(());
365            resp
366        }
367    }
368}
369
370/// Resolve a CID to the iroh hash+format pair, or a wire error.
371fn hash_and_format(cid: &Cid) -> Result<HashAndFormat, (ErrorCode, String)> {
372    let hash = cid_utils::cid_to_blake3_hash(cid)
373        .map_err(|e| (ErrorCode::InvalidRequest, e.to_string()))?;
374    match cid_utils::artifact_kind(cid) {
375        Ok(ArtifactKind::Blob) => Ok(HashAndFormat::raw(hash.into())),
376        Ok(ArtifactKind::Collection) => Ok(HashAndFormat::hash_seq(hash.into())),
377        Err(e) => Err((ErrorCode::InvalidRequest, e.to_string())),
378    }
379}
380
381/// `Has`: report local presence/completeness for a CID. Hash-keyed, so it
382/// answers regardless of which repo (if any) tagged the content.
383async fn has_response(store: &FsStore, cid: &Cid) -> String {
384    let haf = match hash_and_format(cid) {
385        Ok(h) => h,
386        Err((code, msg)) => return err_json::<HasResult>(code, msg),
387    };
388    match store.remote().local(haf).await {
389        Ok(info) => {
390            let bytes = info.local_bytes();
391            ok_json(HasResult {
392                present: bytes > 0,
393                complete: info.is_complete(),
394                bytes,
395            })
396        }
397        Err(e) => err_json::<HasResult>(ErrorCode::Iroh, format!("local lookup: {e}")),
398    }
399}
400
401/// Serialize and write one stream frame as a JSON line, then flush.
402async fn write_frame<T: serde::Serialize>(
403    write: &mut (impl AsyncWriteExt + Unpin),
404    event: &StreamEvent<T>,
405) -> io::Result<()> {
406    let mut line = serde_json::to_string(event).unwrap_or_else(|e| {
407        format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#)
408    });
409    line.push('\n');
410    write.write_all(line.as_bytes()).await?;
411    write.flush().await
412}
413
414/// Shorthand for writing a terminal error frame.
415async fn stream_error<T: serde::Serialize>(
416    write: &mut (impl AsyncWriteExt + Unpin),
417    code: ErrorCode,
418    message: String,
419) -> io::Result<()> {
420    write_frame::<T>(write, &StreamEvent::Error(CommandError { code, message })).await
421}
422
423/// Drive a streaming operation: forward `FetchProgress` frames as they
424/// arrive on the channel, then write the terminal okay/error frame.
425///
426/// Disconnect-to-abort works two ways, so a vanished client never leaves
427/// `op` running: a failed frame write propagates its error, and `read` is
428/// polled for EOF (or any unexpected inbound byte) even during silent
429/// phases that emit no frames (a long export or HTTP body). Either path
430/// returns, dropping `op` and aborting the in-flight download/export.
431async fn run_stream<T: serde::Serialize>(
432    read: &mut (impl AsyncReadExt + Unpin),
433    write: &mut (impl AsyncWriteExt + Unpin),
434    op: impl AsyncFnOnce(mpsc::UnboundedSender<FetchProgress>) -> Result<T, (ErrorCode, String)>,
435) -> io::Result<()> {
436    let (tx, mut rx) = mpsc::unbounded_channel::<FetchProgress>();
437    let fut = op(tx);
438    tokio::pin!(fut);
439    let mut probe = [0u8; 1];
440    loop {
441        tokio::select! {
442            biased;
443            res = &mut fut => {
444                // Flush any progress buffered before completion.
445                while let Ok(p) = rx.try_recv() {
446                    write_frame(write, &StreamEvent::<T>::Progress(p)).await?;
447                }
448                let event = match res {
449                    Ok(payload) => StreamEvent::Okay(payload),
450                    Err((code, message)) => StreamEvent::Error(CommandError { code, message }),
451                };
452                return write_frame(write, &event).await;
453            }
454            Some(p) = rx.recv() => {
455                write_frame(write, &StreamEvent::<T>::Progress(p)).await?;
456            }
457            // The client should stay silent until the terminal frame; a read
458            // readiness means it closed (EOF) or went away. Stop and drop
459            // `op`. read() is cancel-safe here: with no inbound bytes it just
460            // stays pending, so being dropped by another arm loses nothing.
461            _ = read.read(&mut probe) => {
462                return Ok(());
463            }
464        }
465    }
466}
467
468/// `Export`: stream already-local bytes to `dest`. Errors with `NotLocal`
469/// if the content isn't complete in the store.
470async fn stream_export(
471    ctx: &NodeCtx,
472    read: &mut (impl AsyncReadExt + Unpin),
473    write: &mut (impl AsyncWriteExt + Unpin),
474    cid: Cid,
475    dest: PathBuf,
476) -> io::Result<()> {
477    let kind = match cid_utils::artifact_kind(&cid) {
478        Ok(k) => k,
479        Err(e) => {
480            return stream_error::<ExportReceipt>(write, ErrorCode::InvalidRequest, e.to_string())
481                .await
482        }
483    };
484    let haf = match hash_and_format(&cid) {
485        Ok(h) => h,
486        Err((code, msg)) => return stream_error::<ExportReceipt>(write, code, msg).await,
487    };
488    let hash = haf.hash;
489
490    // Export needs the bytes already complete locally.
491    match ctx.store.blobs().has(hash).await {
492        Ok(true) => {}
493        Ok(false) => {
494            return stream_error::<ExportReceipt>(
495                write,
496                ErrorCode::NotLocal,
497                format!("content for {cid} is not complete in the store"),
498            )
499            .await
500        }
501        Err(e) => {
502            return stream_error::<ExportReceipt>(
503                write,
504                ErrorCode::Iroh,
505                format!("local lookup: {e}"),
506            )
507            .await
508        }
509    }
510
511    run_stream(read, write, async move |tx| {
512        let on_progress = move |p| {
513            let _ = tx.send(p);
514        };
515        let bytes = match kind {
516            ArtifactKind::Blob => fetch::export_blob_to(&ctx.store, hash, &dest, on_progress).await,
517            ArtifactKind::Collection => {
518                fetch::export_collection_to(&ctx.store, hash, &dest, on_progress).await
519            }
520        }
521        .map_err(|e| (share_error_to_code(&e), e.to_string()))?;
522        Ok(ExportReceipt { cid, dest, bytes })
523    })
524    .await
525}
526
527/// Split resolved locations into iroh providers and HTTP URLs.
528fn partition(locations: &[FetchLocation]) -> (Vec<EndpointId>, Vec<Url>) {
529    let mut iroh = Vec::new();
530    let mut urls = Vec::new();
531    for loc in locations {
532        match loc {
533            FetchLocation::Iroh(id) => iroh.push(*id),
534            FetchLocation::Url(u) => urls.push(u.clone()),
535        }
536    }
537    (iroh, urls)
538}
539
540/// Export `hash` to `dest` per `kind`, mapping errors to a wire code.
541async fn export_to_dest(
542    store: &FsStore,
543    hash: iroh_blobs::Hash,
544    kind: ArtifactKind,
545    dest: &Path,
546    on_progress: impl FnMut(FetchProgress),
547) -> Result<u64, (ErrorCode, String)> {
548    match kind {
549        ArtifactKind::Blob => fetch::export_blob_to(store, hash, dest, on_progress).await,
550        ArtifactKind::Collection => {
551            fetch::export_collection_to(store, hash, dest, on_progress).await
552        }
553    }
554    .map_err(|e| (share_error_to_code(&e), e.to_string()))
555}
556
557/// An Artifact fetched complete into the store: the bytes are present and
558/// held alive by a [Temp Tag](iroh_blobs::api::TempTag) until this value
559/// drops. The caller keeps it until any Seeded Tag is set, so there's no GC
560/// window between releasing the transient protection and the persistent tag
561/// landing; an error or client disconnect drops it and GC reclaims the bytes.
562struct Fetched {
563    /// Artifact kind derived from the CID (for export dispatch).
564    kind: ArtifactKind,
565    /// BLAKE3 hash of the content.
566    hash: iroh_blobs::Hash,
567    /// `true` if the bytes were already complete locally (no network used).
568    from_cache: bool,
569    /// Transient GC protection; released on drop, never read directly.
570    _tt: iroh_blobs::api::TempTag,
571}
572
573/// Fetch `cid` into the store: a no-op if the bytes are already complete
574/// locally, otherwise download from `locations` (iroh providers batched into
575/// one multi-provider download, HTTP URLs tried in sequence as a blob-only
576/// fallback) and re-check completeness against the store. The kind and hash
577/// are derived from `cid`.
578///
579/// Returns the complete, protected bytes — hold the result until any Seeded
580/// Tag is set, then let it drop. On failure the temp tag is dropped here so
581/// GC reclaims any partial.
582async fn fetch_into_store(
583    ctx: &NodeCtx,
584    cid: &Cid,
585    locations: &[FetchLocation],
586    on_progress: &mut impl FnMut(FetchProgress),
587) -> Result<Fetched, (ErrorCode, String)> {
588    let kind =
589        cid_utils::artifact_kind(cid).map_err(|e| (ErrorCode::InvalidRequest, e.to_string()))?;
590    let haf = hash_and_format(cid)?;
591    let hash = haf.hash;
592    let store = &ctx.store;
593
594    // Tag the CID to prevent GC before doing anything else. This covers both paths:
595    // cached bytes that exist only as an untagged GC cache (a prior non-seed
596    // fetch) can't be GCed between the completeness check and export/seed
597    // on the fast path, and in-flight bytes are protected on the download
598    // path. If the returned `Fetched` is dropped (disconnect) the temp tag
599    // drops with it.
600    let tt = store
601        .tags()
602        .temp_tag(haf)
603        .await
604        .map_err(|e| (ErrorCode::Iroh, format!("temp tag: {e}")))?;
605
606    // Fast path: bytes already complete locally.
607    let already = store
608        .blobs()
609        .has(hash)
610        .await
611        .map_err(|e| (ErrorCode::Iroh, format!("local lookup: {e}")))?;
612    if already {
613        return Ok(Fetched {
614            kind,
615            hash,
616            from_cache: true,
617            _tt: tt,
618        });
619    }
620
621    on_progress(FetchProgress::Connecting);
622
623    let (iroh_ids, urls) = partition(locations);
624    let no_locations = iroh_ids.is_empty() && urls.is_empty();
625    let mut errors: Vec<String> = Vec::new();
626    let mut got = false;
627
628    if !iroh_ids.is_empty() {
629        match fetch::download_iroh_to_store(
630            &ctx.downloader,
631            store,
632            haf,
633            iroh_ids,
634            &mut *on_progress,
635        )
636        .await
637        {
638            Ok(()) => got = true,
639            Err(errs) => errors.extend(errs.into_iter().map(|e| e.to_string())),
640        }
641    }
642    if !got {
643        match kind {
644            // HTTP is a blob-only fallback; stop at the first success
645            // (completeness is re-checked against the store below).
646            ArtifactKind::Blob => {
647                for url in &urls {
648                    match fetch::http_to_store(store, url, cid, &mut *on_progress).await {
649                        Ok(_) => break,
650                        Err(e) => errors.push(e.to_string()),
651                    }
652                }
653            }
654            ArtifactKind::Collection => {
655                for url in &urls {
656                    errors.push(format!("HTTP fetch unsupported for collection: {url}"));
657                }
658            }
659        }
660    }
661
662    // Trust the store: complete means we have it, whatever individual
663    // providers reported.
664    let complete = store
665        .remote()
666        .local(haf)
667        .await
668        .map(|i| i.is_complete())
669        .unwrap_or(false);
670    if !complete {
671        drop(tt); // release protection so GC reclaims the partial
672        let code = if no_locations {
673            ErrorCode::NoLocations
674        } else {
675            ErrorCode::AllFailed
676        };
677        let msg = if errors.is_empty() {
678            "no locations succeeded".to_string()
679        } else {
680            errors.join("; ")
681        };
682        return Err((code, msg));
683    }
684
685    Ok(Fetched {
686        kind,
687        hash,
688        from_cache: false,
689        _tt: tt,
690    })
691}
692
693/// `Fetch`: pull the artifact complete into the store (no-op if already
694/// local, else download from `locations`), tagging as seeded if requested.
695/// Writes nothing to disk — see [`stream_download`].
696///
697/// Abort-safe: the [`Fetched`] temp tag protects the content until the Seeded
698/// Tag is set and the value drops at the end of the closure; a disconnect
699/// mid-stream drops this future via [`run_stream`], releasing the tag so GC
700/// reclaims any partial.
701async fn stream_fetch(
702    ctx: &NodeCtx,
703    read: &mut (impl AsyncReadExt + Unpin),
704    write: &mut (impl AsyncWriteExt + Unpin),
705    rid: RepoId,
706    cid: Cid,
707    locations: Vec<FetchLocation>,
708    seed: Option<Oid>,
709) -> io::Result<()> {
710    let endpoint_id = ctx.endpoint_id;
711
712    run_stream(read, write, async move |tx| {
713        let mut on_progress = move |p| {
714            let _ = tx.send(p);
715        };
716        let store = &ctx.store;
717
718        let fetched = fetch_into_store(ctx, &cid, &locations, &mut on_progress).await?;
719        let seeded = tag_if_seeding(store, &rid, seed.as_ref(), &cid, fetched.hash)
720            .await
721            .map_err(|e| (share_error_to_code(&e), e.to_string()))?;
722
723        // Logical size now complete in the store; no disk write.
724        let bytes = seeder::artifact_size_for(store, &cid, fetched.hash).await;
725        Ok(FetchReceipt {
726            rid,
727            cid,
728            bytes,
729            from_cache: fetched.from_cache,
730            seeded,
731            endpoint_id,
732        })
733        // `fetched` (and its temp tag) drops here, after any Seeded Tag is set.
734    })
735    .await
736}
737
738/// `Download`: [`Fetch`](stream_fetch) into the store, then export to `dest`.
739/// Fast-path export if already local; tags as seeded if requested.
740///
741/// Abort-safe identically to [`stream_fetch`]: the [`Fetched`] temp tag
742/// protects the content across the download AND the export (the export runs
743/// before the Seeded Tag is set), so the bytes can't be reclaimed mid-export;
744/// a disconnect drops this future and the protection.
745#[allow(clippy::too_many_arguments)]
746async fn stream_download(
747    ctx: &NodeCtx,
748    read: &mut (impl AsyncReadExt + Unpin),
749    write: &mut (impl AsyncWriteExt + Unpin),
750    rid: RepoId,
751    cid: Cid,
752    locations: Vec<FetchLocation>,
753    dest: PathBuf,
754    seed: Option<Oid>,
755) -> io::Result<()> {
756    let endpoint_id = ctx.endpoint_id;
757
758    run_stream(read, write, async move |tx| {
759        let mut on_progress = move |p| {
760            let _ = tx.send(p);
761        };
762        let store = &ctx.store;
763
764        let fetched = fetch_into_store(ctx, &cid, &locations, &mut on_progress).await?;
765
766        // Export inside the protected window, before the Seeded Tag is set.
767        let bytes =
768            export_to_dest(store, fetched.hash, fetched.kind, &dest, &mut on_progress).await?;
769        let seeded = tag_if_seeding(store, &rid, seed.as_ref(), &cid, fetched.hash)
770            .await
771            .map_err(|e| (share_error_to_code(&e), e.to_string()))?;
772
773        Ok(DownloadReceipt {
774            rid,
775            cid,
776            dest,
777            bytes,
778            from_cache: fetched.from_cache,
779            seeded,
780            endpoint_id,
781        })
782        // `fetched` (and its temp tag) drops here, after any Seeded Tag is set.
783    })
784    .await
785}
786
787/// Tag `(rid, release, cid)` as seeded when a fetch/download asked to seed
788/// under `release`, returning whether a tag was set.
789///
790/// `seed: None` leaves the bytes untagged; the caller already has them in the
791/// store. Pairing seed intent with its release in one `Option` means there's
792/// no unscoped-seed case to handle here.
793async fn tag_if_seeding(
794    store: &iroh_blobs::api::Store,
795    rid: &RepoId,
796    seed: Option<&Oid>,
797    cid: &Cid,
798    hash: iroh_blobs::Hash,
799) -> Result<bool, ShareError> {
800    let Some(release) = seed else {
801        return Ok(false);
802    };
803    seeder::tag_seeded(store, rid, release, cid, hash).await?;
804    Ok(true)
805}
806
807#[allow(clippy::too_many_arguments)]
808async fn seed_response(
809    store: &FsStore,
810    rid: RepoId,
811    release: Oid,
812    cid: Cid,
813    path: &Path,
814    kind: ArtifactKind,
815    mode: ImportMode,
816    endpoint_id: EndpointId,
817) -> String {
818    if !path.exists() {
819        return err_json::<SeedReceipt>(
820            ErrorCode::PathNotFound,
821            format!("path not found: {}", path.display()),
822        );
823    }
824
825    let was_already = match seeder::is_seeded(store, &rid, &release, &cid).await {
826        Ok(v) => v,
827        Err(e) => return err_from_share::<SeedReceipt>(e),
828    };
829    let hash = match seeder::seed_artifact(store, &rid, &release, &cid, path, kind, mode).await {
830        Ok(hash) => hash,
831        Err(e) => return err_from_share::<SeedReceipt>(e),
832    };
833    let bytes = seeder::artifact_size_for(store, &cid, hash).await;
834    let receipt = SeedReceipt {
835        rid,
836        cid,
837        endpoint_id,
838        bytes,
839        was_new: !was_already,
840    };
841    ok_json(receipt)
842}
843
844/// `release: Some(id)` drops that one release's tag; `None` stops seeding the
845/// CID across every release of `rid`. `was_removed` reports whether the
846/// removal actually dropped a tag, read from the delete itself so a
847/// concurrent unseed can't make it lie.
848async fn unseed_response(store: &FsStore, rid: RepoId, release: Option<Oid>, cid: Cid) -> String {
849    let was_removed = match &release {
850        Some(release) => match seeder::untag_seeded(store, &rid, release, &cid).await {
851            Ok(removed) => removed,
852            Err(e) => return err_from_share::<UnseedReceipt>(e),
853        },
854        None => match seeder::untag_all(store, &rid, &cid).await {
855            Ok(removed) => removed > 0,
856            Err(e) => return err_from_share::<UnseedReceipt>(e),
857        },
858    };
859    ok_json(UnseedReceipt {
860        rid,
861        cid,
862        was_removed,
863    })
864}
865
866async fn is_seeding_response(store: &FsStore, rid: &RepoId, cid: &Cid) -> String {
867    match seeder::is_seeded_any(store, rid, cid).await {
868        Ok(v) => ok_json(v),
869        Err(e) => err_from_share::<bool>(e),
870    }
871}
872
873async fn list_seeded_response(store: &FsStore, rid: RepoId) -> String {
874    let cids = match seeder::seeded_cids(store, &rid).await {
875        Ok(v) => v,
876        Err(e) => return err_from_share::<Vec<SeededEntry>>(e),
877    };
878    let mut out = Vec::with_capacity(cids.len());
879    for (cid, (hash, releases)) in cids {
880        let bytes = seeder::artifact_size_for(store, &cid, hash).await;
881        // The tag says we mean to seed this; ask the store whether the
882        // bytes are still there to back it up.
883        let complete = seeder::artifact_is_complete(store, &cid, hash).await;
884        out.push(SeededEntry {
885            cid,
886            bytes,
887            complete,
888            releases,
889        });
890    }
891    ok_json(out)
892}
893
894async fn build_status(ctx: &NodeCtx) -> Result<Status, ShareError> {
895    let store = &ctx.store;
896    let endpoint = &ctx.endpoint;
897    let metrics = endpoint.metrics();
898    // A blob shared across releases carries one tag per release; collapse to
899    // distinct blobs so the count and byte total reflect what's on disk.
900    let distinct: std::collections::HashMap<iroh_blobs::Hash, Cid> = seeder::all_seeded(store)
901        .await?
902        .into_iter()
903        .map(|(_rid, _release, cid, hash)| (hash, cid))
904        .collect();
905    let count = distinct.len();
906    let mut bytes_logical = 0u64;
907    for (hash, cid) in &distinct {
908        bytes_logical =
909            bytes_logical.saturating_add(seeder::artifact_size_for(store, cid, *hash).await);
910    }
911    // Socket counters are byte totals (sends include disco frames; recv
912    // counts data only) and connection/path tallies — see the field docs
913    // on `ConnectionStats`/`TrafficStats` for the disco-vs-data split.
914    let s = &metrics.socket;
915    let opened_total = s.num_conns_opened.get();
916    let closed_total = s.num_conns_closed.get();
917    let connections = radicle_artifact_core::protocol::ConnectionStats {
918        active: opened_total.saturating_sub(closed_total) as u32,
919        opened_total,
920        closed_total,
921        direct_total: s.num_conns_direct.get(),
922        holepunch_attempts: s.holepunch_attempts.get(),
923        paths_direct: s.paths_direct.get(),
924        paths_relayed: s.paths_relay.get(),
925    };
926    let traffic = radicle_artifact_core::protocol::TrafficStats {
927        out_bytes: s
928            .send_ipv4
929            .get()
930            .saturating_add(s.send_ipv6.get())
931            .saturating_add(s.send_relay.get()),
932        in_bytes: s
933            .recv_data_ipv4
934            .get()
935            .saturating_add(s.recv_data_ipv6.get())
936            .saturating_add(s.recv_data_relay.get())
937            .saturating_add(s.recv_data_custom.get()),
938    };
939    let relay = relay_stats(endpoint);
940    // A bound socket with no connected home relay is reachable only by peers
941    // that can holepunch a direct path; flag it as advice.
942    let relay_unreachable = !relay.relays.iter().any(|r| r.connected);
943    Ok(Status {
944        endpoint_id: ctx.endpoint_id,
945        version: Some(env!("CARGO_PKG_VERSION").to_owned()),
946        started_at_unix: ctx.started_at_unix,
947        seeded: radicle_artifact_core::protocol::SeededStats {
948            count,
949            bytes_logical,
950        },
951        connections,
952        traffic,
953        relay,
954        pkarr: Some(radicle_artifact_core::protocol::PkarrStats {
955            uri: ctx.endpoint_id.to_pkarr_uri(),
956            relays: ctx.pkarr_urls.clone(),
957        }),
958        warnings: radicle_artifact_core::protocol::Warnings { relay_unreachable },
959    })
960}
961
962/// Snapshot home-relay connectivity and latency from the live endpoint.
963///
964/// `home_relay_status()` gives connection state per relay; `net_report()`
965/// (best-effort, may be empty before the first probe lands) supplies the
966/// preferred relay, UDP reachability, and per-relay round-trip latency.
967fn relay_stats(endpoint: &iroh::Endpoint) -> radicle_artifact_core::protocol::RelayStats {
968    use iroh::Watcher;
969
970    let report = endpoint.net_report().get();
971    // Lowest measured latency per relay URL across probe types (v4/v6/https).
972    let mut latency_ms: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
973    if let Some(r) = &report {
974        for (_probe, url, dur) in r.relay_latency.iter() {
975            let ms = dur.as_millis() as u64;
976            latency_ms
977                .entry(url.to_string())
978                .and_modify(|m| *m = (*m).min(ms))
979                .or_insert(ms);
980        }
981    }
982
983    let relays = endpoint
984        .home_relay_status()
985        .get()
986        .into_iter()
987        .map(|s| {
988            let url = s.url().to_string();
989            radicle_artifact_core::protocol::RelayHealth {
990                latency_ms: latency_ms.get(&url).copied(),
991                connected: s.is_connected(),
992                last_error: s.last_error().map(|e| e.to_string()),
993                url,
994            }
995        })
996        .collect();
997
998    radicle_artifact_core::protocol::RelayStats {
999        relays,
1000        preferred: report
1001            .as_ref()
1002            .and_then(|r| r.preferred_relay.as_ref().map(|u| u.to_string())),
1003        udp_v4: report.as_ref().map(|r| r.udp_v4).unwrap_or(false),
1004        udp_v6: report.as_ref().map(|r| r.udp_v6).unwrap_or(false),
1005    }
1006}
1007
1008fn ok_json<T: serde::Serialize>(v: T) -> String {
1009    serde_json::to_string(&CommandResult::Okay(v))
1010        .unwrap_or_else(|e| format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#))
1011}
1012
1013fn err_json<T>(code: ErrorCode, message: String) -> String
1014where
1015    T: serde::Serialize,
1016{
1017    serde_json::to_string(&CommandResult::<T>::Error(CommandError { code, message }))
1018        .unwrap_or_else(|e| format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#))
1019}
1020
1021fn err_from_share<T>(e: ShareError) -> String
1022where
1023    T: serde::Serialize,
1024{
1025    let code = share_error_to_code(&e);
1026    err_json::<T>(code, e.to_string())
1027}
1028
1029fn share_error_to_code(e: &ShareError) -> ErrorCode {
1030    match e {
1031        ShareError::CidMismatch { .. } => ErrorCode::CidMismatch,
1032        ShareError::Io(_) => ErrorCode::Io,
1033        ShareError::Iroh(_) => ErrorCode::Iroh,
1034        _ => ErrorCode::Internal,
1035    }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use std::fs;
1041
1042    use cid::multihash::Multihash;
1043
1044    use super::*;
1045    use radicle_artifact_core::cid::{
1046        self as cid_utils, ArtifactKind, HASH_CODE_BLAKE3, RAW_CODEC,
1047    };
1048
1049    /// Build a fake but well-formed blob CID over `data` so the
1050    /// `tag_seeded` path picks `HashAndFormat::raw`.
1051    fn fake_blob_cid(data: &[u8]) -> Cid {
1052        let digest = blake3::hash(data);
1053        let mh = Multihash::<64>::wrap(HASH_CODE_BLAKE3, digest.as_bytes()).unwrap();
1054        Cid::from(cid::Cid::new_v1(RAW_CODEC, mh))
1055    }
1056
1057    fn rid_a() -> RepoId {
1058        RepoId::from_str("rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip").unwrap()
1059    }
1060
1061    fn release_a() -> Oid {
1062        Oid::from_str("0123456789abcdef0123456789abcdef01234567").unwrap()
1063    }
1064
1065    /// Spawn a node under `home`, wait for its control socket to appear, and
1066    /// return the socket path plus the join handle for asserting clean exit.
1067    async fn start_node(
1068        home: &Path,
1069        secret: iroh::SecretKey,
1070    ) -> (PathBuf, tokio::task::JoinHandle<Result<(), NodeError>>) {
1071        let home_path = home.to_path_buf();
1072        let handle = tokio::spawn(async move { run(&home_path, secret).await });
1073        let socket = home.join(ARTIFACTS_DIR).join("control.sock");
1074        for _ in 0..200 {
1075            if socket.exists() {
1076                break;
1077            }
1078            tokio::time::sleep(Duration::from_millis(50)).await;
1079        }
1080        assert!(socket.exists(), "control socket never appeared");
1081        (socket, handle)
1082    }
1083
1084    /// End-to-end client↔node round-trip covering Status, Seed (new +
1085    /// duplicate + CID mismatch), IsSeeding, ListSeeded, Unseed (new +
1086    /// duplicate), and Shutdown.
1087    #[test]
1088    fn node_round_trip() {
1089        let rt = tokio::runtime::Builder::new_multi_thread()
1090            .enable_all()
1091            .build()
1092            .unwrap();
1093        rt.block_on(async {
1094            let home = tempfile::tempdir().unwrap();
1095
1096            // Compute a real blob CID from a real file so the import
1097            // verification passes.
1098            let blob_path = home.path().join("payload.bin");
1099            let payload = b"hello rad-artifact";
1100            fs::write(&blob_path, payload).unwrap();
1101            let real_cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1102            let rid = rid_a();
1103
1104            // Pin the secret so the test is reproducible.
1105            let secret = iroh::SecretKey::from_bytes(&[1u8; 32]);
1106            let expected_endpoint_id = EndpointId::from(secret.public());
1107
1108            // Run the node on a tokio task; capture the join handle so
1109            // we can assert clean exit.
1110            let home_path = home.path().to_path_buf();
1111            let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1112
1113            // Wait for the socket to appear (bootstrap involves iroh
1114            // endpoint bind, which is async).
1115            let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1116            for _ in 0..200 {
1117                if socket.exists() {
1118                    break;
1119                }
1120                tokio::time::sleep(Duration::from_millis(50)).await;
1121            }
1122            assert!(socket.exists(), "control socket never appeared");
1123
1124            let client = Client::new(socket.clone());
1125
1126            // is_running succeeds.
1127            assert!(client.is_running().await);
1128
1129            // Status round-trips and reports the expected endpoint id.
1130            let status = client.status().await.unwrap();
1131            assert_eq!(status.endpoint_id, expected_endpoint_id);
1132            assert_eq!(status.seeded.count, 0);
1133
1134            // Seed (new).
1135            let receipt = client
1136                .seed(
1137                    rid,
1138                    release_a(),
1139                    real_cid,
1140                    &blob_path,
1141                    ArtifactKind::Blob,
1142                    ImportMode::Copy,
1143                )
1144                .await
1145                .unwrap();
1146            assert!(receipt.was_new);
1147            assert_eq!(receipt.endpoint_id, expected_endpoint_id);
1148            assert_eq!(receipt.bytes, payload.len() as u64);
1149
1150            // Seed again — idempotent: was_new false.
1151            let receipt2 = client
1152                .seed(
1153                    rid,
1154                    release_a(),
1155                    real_cid,
1156                    &blob_path,
1157                    ArtifactKind::Blob,
1158                    ImportMode::Copy,
1159                )
1160                .await
1161                .unwrap();
1162            assert!(!receipt2.was_new);
1163
1164            // Seed with a tampered path that does not match the CID.
1165            let bad_path = home.path().join("tampered.bin");
1166            fs::write(&bad_path, b"different bytes").unwrap();
1167            let bad_cid = fake_blob_cid(b"not the right preimage");
1168            let err = client
1169                .seed(
1170                    rid,
1171                    release_a(),
1172                    bad_cid,
1173                    &bad_path,
1174                    ArtifactKind::Blob,
1175                    ImportMode::Copy,
1176                )
1177                .await
1178                .expect_err("CID mismatch must error");
1179            match err {
1180                radicle_artifact_client::ClientError::Remote(CommandError { code, .. }) => {
1181                    assert_eq!(code, ErrorCode::CidMismatch);
1182                }
1183                other => panic!("expected CidMismatch error, got {other:?}"),
1184            }
1185
1186            // IsSeeding reflects the tag we set above.
1187            assert!(client.is_seeding(rid, real_cid).await.unwrap());
1188
1189            // ListSeeded returns exactly the one entry.
1190            let entries = client.list_seeded(rid).await.unwrap();
1191            assert_eq!(entries.len(), 1);
1192            assert_eq!(entries[0].cid, real_cid);
1193            assert_eq!(entries[0].bytes, payload.len() as u64);
1194            assert_eq!(entries[0].releases, vec![release_a()]);
1195
1196            // Status now reports one seeded artifact.
1197            let status = client.status().await.unwrap();
1198            assert_eq!(status.seeded.count, 1);
1199            assert_eq!(status.seeded.bytes_logical, payload.len() as u64);
1200
1201            // Unseed once removes the tag; second call is idempotent.
1202            let r1 = client
1203                .unseed(rid, Some(release_a()), real_cid)
1204                .await
1205                .unwrap();
1206            assert!(r1.was_removed);
1207            let r2 = client
1208                .unseed(rid, Some(release_a()), real_cid)
1209                .await
1210                .unwrap();
1211            assert!(!r2.was_removed);
1212            assert!(!client.is_seeding(rid, real_cid).await.unwrap());
1213
1214            // Shutdown — the node acks then exits cleanly.
1215            client.shutdown().await.unwrap();
1216            tokio::time::timeout(Duration::from_secs(10), node_handle)
1217                .await
1218                .expect("node did not exit within 10s")
1219                .expect("join error")
1220                .expect("node returned error");
1221
1222            // Socket file is gone after shutdown.
1223            assert!(!socket.exists());
1224        });
1225    }
1226
1227    /// Malformed `rid`/`cid` on the wire must surface as `InvalidRequest`
1228    /// with the offending field named in the message — so callers don't
1229    /// have to grep for "invalid command JSON" to recognize bad input.
1230    #[test]
1231    fn invalid_typed_fields_surface_as_invalid_request() {
1232        let rt = tokio::runtime::Builder::new_multi_thread()
1233            .enable_all()
1234            .build()
1235            .unwrap();
1236        rt.block_on(async {
1237            let home = tempfile::tempdir().unwrap();
1238            let secret = iroh::SecretKey::from_bytes(&[4u8; 32]);
1239            let home_path = home.path().to_path_buf();
1240            let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1241
1242            let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1243            for _ in 0..200 {
1244                if socket.exists() {
1245                    break;
1246                }
1247                tokio::time::sleep(Duration::from_millis(50)).await;
1248            }
1249            assert!(socket.exists());
1250
1251            // The typed Client can't construct these frames since the
1252            // fields are now RepoId / Cid, so we hand-roll the wire.
1253            for (frame, expected_field) in [
1254                (
1255                    br#"{"command":"listSeeded","rid":"not-a-real-rid"}"#.as_slice(),
1256                    "rid",
1257                ),
1258                (
1259                    br#"{"command":"isSeeding","rid":"rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip","cid":"not-a-real-cid"}"#.as_slice(),
1260                    "cid",
1261                ),
1262            ] {
1263                let mut stream = tokio::net::UnixStream::connect(&socket).await.unwrap();
1264                tokio::io::AsyncWriteExt::write_all(&mut stream, frame)
1265                    .await
1266                    .unwrap();
1267                tokio::io::AsyncWriteExt::write_all(&mut stream, b"\n")
1268                    .await
1269                    .unwrap();
1270                let mut buf = String::new();
1271                tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1272                    .await
1273                    .unwrap();
1274                let parsed: CommandResult<serde_json::Value> =
1275                    serde_json::from_str(buf.trim()).unwrap();
1276                match parsed {
1277                    CommandResult::Error(CommandError { code, message }) => {
1278                        assert_eq!(code, ErrorCode::InvalidRequest);
1279                        assert!(
1280                            message.contains(expected_field),
1281                            "message should name {expected_field}: {message}"
1282                        );
1283                    }
1284                    CommandResult::Okay(_) => panic!("expected error, got ok"),
1285                }
1286            }
1287
1288            // Clean shutdown.
1289            let client = Client::new(socket);
1290            client.shutdown().await.unwrap();
1291            node_handle.await.unwrap().unwrap();
1292        });
1293    }
1294
1295    /// A second `run` on the same home while the first is alive must
1296    /// fail with `AlreadyRunning`, not silently steal the socket.
1297    #[test]
1298    fn double_start_errors() {
1299        let rt = tokio::runtime::Builder::new_multi_thread()
1300            .enable_all()
1301            .build()
1302            .unwrap();
1303        rt.block_on(async {
1304            let home = tempfile::tempdir().unwrap();
1305            let secret = iroh::SecretKey::from_bytes(&[2u8; 32]);
1306            let home_path = home.path().to_path_buf();
1307            let first = tokio::spawn(async move { run(&home_path, secret).await });
1308
1309            let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1310            for _ in 0..200 {
1311                if socket.exists() {
1312                    break;
1313                }
1314                tokio::time::sleep(Duration::from_millis(50)).await;
1315            }
1316            assert!(socket.exists());
1317
1318            // Same home, different secret to keep keys distinct.
1319            let secret2 = iroh::SecretKey::from_bytes(&[3u8; 32]);
1320            let err = run(home.path(), secret2).await.expect_err("must fail");
1321            assert!(matches!(err, NodeError::AlreadyRunning(_)));
1322
1323            // Tear down the first one so the test cleans up.
1324            let client = Client::new(socket);
1325            client.shutdown().await.unwrap();
1326            first.await.unwrap().unwrap();
1327        });
1328    }
1329
1330    /// Send a one-shot command on a fresh connection and decode the reply.
1331    async fn oneshot<T: serde::de::DeserializeOwned>(
1332        socket: &Path,
1333        cmd: &Command,
1334    ) -> CommandResult<T> {
1335        let mut stream = UnixStream::connect(socket).await.unwrap();
1336        let mut line = serde_json::to_string(cmd).unwrap();
1337        line.push('\n');
1338        tokio::io::AsyncWriteExt::write_all(&mut stream, line.as_bytes())
1339            .await
1340            .unwrap();
1341        let mut buf = String::new();
1342        tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1343            .await
1344            .unwrap();
1345        serde_json::from_str(buf.trim()).unwrap()
1346    }
1347
1348    /// Send a streaming command; return (progress frame count, terminal frame).
1349    async fn streaming<T: serde::de::DeserializeOwned>(
1350        socket: &Path,
1351        cmd: &Command,
1352    ) -> (usize, StreamEvent<T>) {
1353        let mut stream = UnixStream::connect(socket).await.unwrap();
1354        let mut line = serde_json::to_string(cmd).unwrap();
1355        line.push('\n');
1356        tokio::io::AsyncWriteExt::write_all(&mut stream, line.as_bytes())
1357            .await
1358            .unwrap();
1359        let mut buf = String::new();
1360        tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1361            .await
1362            .unwrap();
1363        let mut progress = 0usize;
1364        let mut terminal = None;
1365        for l in buf.lines().filter(|l| !l.trim().is_empty()) {
1366            match serde_json::from_str::<StreamEvent<T>>(l).unwrap() {
1367                StreamEvent::Progress(_) => progress += 1,
1368                term => terminal = Some(term),
1369            }
1370        }
1371        (progress, terminal.expect("a terminal frame"))
1372    }
1373
1374    /// `Has` reflects store presence; `Export` streams local bytes to disk
1375    /// and reports `NotLocal` for content the store doesn't hold.
1376    #[test]
1377    fn has_and_export_round_trip() {
1378        let rt = tokio::runtime::Builder::new_multi_thread()
1379            .enable_all()
1380            .build()
1381            .unwrap();
1382        rt.block_on(async {
1383            let home = tempfile::tempdir().unwrap();
1384            let payload = b"hello has/export";
1385            let blob_path = home.path().join("payload.bin");
1386            fs::write(&blob_path, payload).unwrap();
1387            let cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1388            let rid = rid_a();
1389
1390            let secret = iroh::SecretKey::from_bytes(&[5u8; 32]);
1391            let home_path = home.path().to_path_buf();
1392            let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1393
1394            let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1395            for _ in 0..200 {
1396                if socket.exists() {
1397                    break;
1398                }
1399                tokio::time::sleep(Duration::from_millis(50)).await;
1400            }
1401            assert!(socket.exists(), "control socket never appeared");
1402
1403            // Seed the blob so its bytes live in the store.
1404            let client = Client::new(socket.clone());
1405            client
1406                .seed(
1407                    rid,
1408                    release_a(),
1409                    cid,
1410                    &blob_path,
1411                    ArtifactKind::Blob,
1412                    ImportMode::Copy,
1413                )
1414                .await
1415                .unwrap();
1416
1417            // Has: present and complete with the right size.
1418            match oneshot::<HasResult>(&socket, &Command::Has { cid }).await {
1419                CommandResult::Okay(h) => {
1420                    assert!(h.present);
1421                    assert!(h.complete);
1422                    assert_eq!(h.bytes, payload.len() as u64);
1423                }
1424                CommandResult::Error(e) => panic!("has errored: {e:?}"),
1425            }
1426
1427            // Has on content the store doesn't hold: absent.
1428            let unknown = fake_blob_cid(b"never stored");
1429            match oneshot::<HasResult>(&socket, &Command::Has { cid: unknown }).await {
1430                CommandResult::Okay(h) => {
1431                    assert!(!h.present);
1432                    assert!(!h.complete);
1433                }
1434                CommandResult::Error(e) => panic!("has errored: {e:?}"),
1435            }
1436
1437            // Export streams the bytes to disk; the file matches the payload.
1438            let dest = home.path().join("exported.bin");
1439            let (_progress, term) = streaming::<ExportReceipt>(
1440                &socket,
1441                &Command::Export {
1442                    cid,
1443                    dest: dest.clone(),
1444                },
1445            )
1446            .await;
1447            match term {
1448                StreamEvent::Okay(r) => {
1449                    assert_eq!(r.bytes, payload.len() as u64);
1450                    assert_eq!(r.dest, dest);
1451                }
1452                other => panic!("expected okay, got {other:?}"),
1453            }
1454            assert_eq!(fs::read(&dest).unwrap(), payload);
1455
1456            // Export of absent content reports NotLocal.
1457            let (_p, term) = streaming::<ExportReceipt>(
1458                &socket,
1459                &Command::Export {
1460                    cid: unknown,
1461                    dest: home.path().join("nope.bin"),
1462                },
1463            )
1464            .await;
1465            match term {
1466                StreamEvent::Error(e) => assert_eq!(e.code, ErrorCode::NotLocal),
1467                other => panic!("expected NotLocal error, got {other:?}"),
1468            }
1469
1470            client.shutdown().await.unwrap();
1471            node_handle.await.unwrap().unwrap();
1472        });
1473    }
1474
1475    /// `Fetch` (store-only) and `Download` (to disk) fast paths over
1476    /// already-local bytes: `Fetch` writes no file and reports the logical
1477    /// store size; `Download` writes the file; `Fetch --seed` tags a second
1478    /// repo by shared hash; an empty location set on absent content reports
1479    /// `NoLocations`. (The networked download path needs two endpoints and is
1480    /// exercised via the shared core, not here.)
1481    #[test]
1482    fn fetch_and_download_fast_path_and_no_locations() {
1483        let rt = tokio::runtime::Builder::new_multi_thread()
1484            .enable_all()
1485            .build()
1486            .unwrap();
1487        rt.block_on(async {
1488            let home = tempfile::tempdir().unwrap();
1489            let payload = b"hello fetch fast-path";
1490            let blob_path = home.path().join("payload.bin");
1491            fs::write(&blob_path, payload).unwrap();
1492            let cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1493            let rid = rid_a();
1494
1495            let secret = iroh::SecretKey::from_bytes(&[6u8; 32]);
1496            let home_path = home.path().to_path_buf();
1497            let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1498
1499            let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1500            for _ in 0..200 {
1501                if socket.exists() {
1502                    break;
1503                }
1504                tokio::time::sleep(Duration::from_millis(50)).await;
1505            }
1506            assert!(socket.exists(), "control socket never appeared");
1507
1508            // Seed so the bytes live in the store.
1509            let client = Client::new(socket.clone());
1510            client
1511                .seed(
1512                    rid,
1513                    release_a(),
1514                    cid,
1515                    &blob_path,
1516                    ArtifactKind::Blob,
1517                    ImportMode::Copy,
1518                )
1519                .await
1520                .unwrap();
1521
1522            // Fetch fast path: complete locally, no locations, no seed, no
1523            // disk write. Reports the logical store size.
1524            let (_p, term) = streaming::<FetchReceipt>(
1525                &socket,
1526                &Command::Fetch {
1527                    rid,
1528                    cid,
1529                    locations: vec![],
1530                    seed: None,
1531                },
1532            )
1533            .await;
1534            match term {
1535                StreamEvent::Okay(r) => {
1536                    assert!(r.from_cache);
1537                    assert!(!r.seeded);
1538                    assert_eq!(r.bytes, payload.len() as u64);
1539                }
1540                other => panic!("expected okay, got {other:?}"),
1541            }
1542
1543            // Download fast path: same bytes, but exported to disk.
1544            let dest = home.path().join("downloaded.bin");
1545            let (_p, term) = streaming::<DownloadReceipt>(
1546                &socket,
1547                &Command::Download {
1548                    rid,
1549                    cid,
1550                    locations: vec![],
1551                    dest: dest.clone(),
1552                    seed: None,
1553                },
1554            )
1555            .await;
1556            match term {
1557                StreamEvent::Okay(r) => {
1558                    assert!(r.from_cache);
1559                    assert!(!r.seeded);
1560                    assert_eq!(r.bytes, payload.len() as u64);
1561                    assert_eq!(r.dest, dest);
1562                }
1563                other => panic!("expected okay, got {other:?}"),
1564            }
1565            assert_eq!(fs::read(&dest).unwrap(), payload);
1566
1567            // Fetch with seed under a second repo: the bytes are shared by
1568            // hash, so a (rid2, release, cid) tag is set without re-downloading.
1569            let rid2 = RepoId::from_str("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5").unwrap();
1570            let (_p, term) = streaming::<FetchReceipt>(
1571                &socket,
1572                &Command::Fetch {
1573                    rid: rid2,
1574                    cid,
1575                    locations: vec![],
1576                    seed: Some(release_a()),
1577                },
1578            )
1579            .await;
1580            match term {
1581                StreamEvent::Okay(r) => {
1582                    assert!(r.from_cache);
1583                    assert!(r.seeded);
1584                }
1585                other => panic!("expected okay, got {other:?}"),
1586            }
1587            assert!(client.is_seeding(rid2, cid).await.unwrap());
1588
1589            // Absent content with no locations to try: NoLocations.
1590            let unknown = fake_blob_cid(b"absent");
1591            let (_p, term) = streaming::<FetchReceipt>(
1592                &socket,
1593                &Command::Fetch {
1594                    rid,
1595                    cid: unknown,
1596                    locations: vec![],
1597                    seed: None,
1598                },
1599            )
1600            .await;
1601            match term {
1602                StreamEvent::Error(e) => assert_eq!(e.code, ErrorCode::NoLocations),
1603                other => panic!("expected NoLocations error, got {other:?}"),
1604            }
1605
1606            client.shutdown().await.unwrap();
1607            node_handle.await.unwrap().unwrap();
1608        });
1609    }
1610
1611    /// A client that vanishes mid-stream must drop the in-flight `op`, not
1612    /// leave it running. Driven directly over an in-memory pipe: the op parks
1613    /// forever, so the only way `run_stream` returns is the disconnect arm
1614    /// seeing EOF on the read half — at which point the op future is dropped.
1615    #[test]
1616    fn run_stream_aborts_on_client_disconnect() {
1617        use std::sync::atomic::AtomicBool;
1618
1619        let rt = tokio::runtime::Builder::new_multi_thread()
1620            .enable_all()
1621            .build()
1622            .unwrap();
1623        rt.block_on(async {
1624            // Duplex pipe standing in for the connection: we keep the client
1625            // end, run_stream reads/writes the server end.
1626            let (client_end, server_end) = tokio::io::duplex(64);
1627            let (mut srv_read, mut srv_write) = tokio::io::split(server_end);
1628
1629            // Guard whose Drop flips a flag, so we can prove the op future was
1630            // dropped (aborted) rather than allowed to complete.
1631            struct DropFlag(Arc<AtomicBool>);
1632            impl Drop for DropFlag {
1633                fn drop(&mut self) {
1634                    self.0.store(true, Ordering::SeqCst);
1635                }
1636            }
1637            let aborted = Arc::new(AtomicBool::new(false));
1638            let completed = Arc::new(AtomicBool::new(false));
1639            let aborted_op = aborted.clone();
1640            let completed_op = completed.clone();
1641
1642            let op = async move |_tx: mpsc::UnboundedSender<FetchProgress>| {
1643                let _guard = DropFlag(aborted_op);
1644                // Never resolves on its own; only a drop ends it.
1645                std::future::pending::<()>().await;
1646                completed_op.store(true, Ordering::SeqCst);
1647                Ok::<(), (ErrorCode, String)>(())
1648            };
1649
1650            let server =
1651                tokio::spawn(
1652                    async move { run_stream::<()>(&mut srv_read, &mut srv_write, op).await },
1653                );
1654
1655            // Let run_stream enter its select loop (op parked) before the
1656            // client disconnects, so we exercise a genuinely in-flight op.
1657            tokio::time::sleep(Duration::from_millis(50)).await;
1658            drop(client_end); // EOF on the server read half
1659
1660            let res = tokio::time::timeout(Duration::from_secs(5), server)
1661                .await
1662                .expect("run_stream did not return after disconnect")
1663                .expect("join error");
1664            assert!(res.is_ok());
1665            assert!(
1666                aborted.load(Ordering::SeqCst),
1667                "op future was not dropped on disconnect"
1668            );
1669            assert!(
1670                !completed.load(Ordering::SeqCst),
1671                "op should have been aborted, not completed"
1672            );
1673        });
1674    }
1675
1676    /// A leftover socket file with no live owner must be reclaimed: `run`
1677    /// probes it, finds nothing answering, unlinks it, and binds its own.
1678    #[test]
1679    fn stale_socket_is_reclaimed() {
1680        let rt = tokio::runtime::Builder::new_multi_thread()
1681            .enable_all()
1682            .build()
1683            .unwrap();
1684        rt.block_on(async {
1685            let home = tempfile::tempdir().unwrap();
1686            let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1687            fs::create_dir_all(socket.parent().unwrap()).unwrap();
1688            // Bind then drop: the socket file lingers but nothing listens.
1689            drop(UnixListener::bind(&socket).unwrap());
1690            assert!(socket.exists());
1691
1692            let secret = iroh::SecretKey::from_bytes(&[7u8; 32]);
1693            let home_path = home.path().to_path_buf();
1694            let handle = tokio::spawn(async move { run(&home_path, secret).await });
1695
1696            // Poll is_running (not just file existence) since the stale file
1697            // is replaced in place — only a live answer means we're up.
1698            let client = Client::new(socket.clone());
1699            let mut ready = false;
1700            for _ in 0..200 {
1701                if client.is_running().await {
1702                    ready = true;
1703                    break;
1704                }
1705                tokio::time::sleep(Duration::from_millis(50)).await;
1706            }
1707            assert!(ready, "node never came up after reclaiming stale socket");
1708
1709            client.shutdown().await.unwrap();
1710            handle.await.unwrap().unwrap();
1711        });
1712    }
1713
1714    /// `Seed` against a path that doesn't exist must report `PathNotFound`
1715    /// before any import work.
1716    #[test]
1717    fn seed_missing_path_errors() {
1718        let rt = tokio::runtime::Builder::new_multi_thread()
1719            .enable_all()
1720            .build()
1721            .unwrap();
1722        rt.block_on(async {
1723            let home = tempfile::tempdir().unwrap();
1724            let secret = iroh::SecretKey::from_bytes(&[8u8; 32]);
1725            let (socket, handle) = start_node(home.path(), secret).await;
1726
1727            let client = Client::new(socket.clone());
1728            let missing = home.path().join("does-not-exist.bin");
1729            let err = client
1730                .seed(
1731                    rid_a(),
1732                    release_a(),
1733                    fake_blob_cid(b"whatever"),
1734                    &missing,
1735                    ArtifactKind::Blob,
1736                    ImportMode::Copy,
1737                )
1738                .await
1739                .expect_err("missing path must error");
1740            match err {
1741                radicle_artifact_client::ClientError::Remote(CommandError { code, .. }) => {
1742                    assert_eq!(code, ErrorCode::PathNotFound);
1743                }
1744                other => panic!("expected PathNotFound, got {other:?}"),
1745            }
1746
1747            client.shutdown().await.unwrap();
1748            handle.await.unwrap().unwrap();
1749        });
1750    }
1751
1752    /// Bare malformed JSON (not just a bad typed field) must surface as
1753    /// `InvalidRequest` naming the JSON parse failure.
1754    #[test]
1755    fn malformed_json_surfaces_as_invalid_request() {
1756        let rt = tokio::runtime::Builder::new_multi_thread()
1757            .enable_all()
1758            .build()
1759            .unwrap();
1760        rt.block_on(async {
1761            let home = tempfile::tempdir().unwrap();
1762            let secret = iroh::SecretKey::from_bytes(&[9u8; 32]);
1763            let (socket, handle) = start_node(home.path(), secret).await;
1764
1765            let mut stream = UnixStream::connect(&socket).await.unwrap();
1766            tokio::io::AsyncWriteExt::write_all(&mut stream, b"this is not json\n")
1767                .await
1768                .unwrap();
1769            let mut buf = String::new();
1770            tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1771                .await
1772                .unwrap();
1773            let parsed: CommandResult<serde_json::Value> =
1774                serde_json::from_str(buf.trim()).unwrap();
1775            match parsed {
1776                CommandResult::Error(CommandError { code, message }) => {
1777                    assert_eq!(code, ErrorCode::InvalidRequest);
1778                    assert!(
1779                        message.contains("invalid command JSON"),
1780                        "message should name the JSON failure: {message}"
1781                    );
1782                }
1783                CommandResult::Okay(_) => panic!("expected error, got ok"),
1784            }
1785
1786            let client = Client::new(socket);
1787            client.shutdown().await.unwrap();
1788            handle.await.unwrap().unwrap();
1789        });
1790    }
1791}