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) 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        });
889    }
890    ok_json(out)
891}
892
893async fn build_status(ctx: &NodeCtx) -> Result<Status, ShareError> {
894    let store = &ctx.store;
895    let endpoint = &ctx.endpoint;
896    let metrics = endpoint.metrics();
897    // A blob shared across releases carries one tag per release; collapse to
898    // distinct blobs so the count and byte total reflect what's on disk.
899    let distinct: std::collections::HashMap<iroh_blobs::Hash, Cid> = seeder::all_seeded(store)
900        .await?
901        .into_iter()
902        .map(|(_rid, _release, cid, hash)| (hash, cid))
903        .collect();
904    let count = distinct.len();
905    let mut bytes_logical = 0u64;
906    for (hash, cid) in &distinct {
907        bytes_logical =
908            bytes_logical.saturating_add(seeder::artifact_size_for(store, cid, *hash).await);
909    }
910    // Socket counters are byte totals (sends include disco frames; recv
911    // counts data only) and connection/path tallies — see the field docs
912    // on `ConnectionStats`/`TrafficStats` for the disco-vs-data split.
913    let s = &metrics.socket;
914    let opened_total = s.num_conns_opened.get();
915    let closed_total = s.num_conns_closed.get();
916    let connections = radicle_artifact_core::protocol::ConnectionStats {
917        active: opened_total.saturating_sub(closed_total) as u32,
918        opened_total,
919        closed_total,
920        direct_total: s.num_conns_direct.get(),
921        holepunch_attempts: s.holepunch_attempts.get(),
922        paths_direct: s.paths_direct.get(),
923        paths_relayed: s.paths_relay.get(),
924    };
925    let traffic = radicle_artifact_core::protocol::TrafficStats {
926        out_bytes: s
927            .send_ipv4
928            .get()
929            .saturating_add(s.send_ipv6.get())
930            .saturating_add(s.send_relay.get()),
931        in_bytes: s
932            .recv_data_ipv4
933            .get()
934            .saturating_add(s.recv_data_ipv6.get())
935            .saturating_add(s.recv_data_relay.get())
936            .saturating_add(s.recv_data_custom.get()),
937    };
938    let relay = relay_stats(endpoint);
939    // A bound socket with no connected home relay is reachable only by peers
940    // that can holepunch a direct path; flag it as advice.
941    let relay_unreachable = !relay.relays.iter().any(|r| r.connected);
942    Ok(Status {
943        endpoint_id: ctx.endpoint_id,
944        started_at_unix: ctx.started_at_unix,
945        seeded: radicle_artifact_core::protocol::SeededStats {
946            count,
947            bytes_logical,
948        },
949        connections,
950        traffic,
951        relay,
952        pkarr: Some(radicle_artifact_core::protocol::PkarrStats {
953            uri: ctx.endpoint_id.to_pkarr_uri(),
954            relays: ctx.pkarr_urls.clone(),
955        }),
956        warnings: radicle_artifact_core::protocol::Warnings { relay_unreachable },
957    })
958}
959
960/// Snapshot home-relay connectivity and latency from the live endpoint.
961///
962/// `home_relay_status()` gives connection state per relay; `net_report()`
963/// (best-effort, may be empty before the first probe lands) supplies the
964/// preferred relay, UDP reachability, and per-relay round-trip latency.
965fn relay_stats(endpoint: &iroh::Endpoint) -> radicle_artifact_core::protocol::RelayStats {
966    use iroh::Watcher;
967
968    let report = endpoint.net_report().get();
969    // Lowest measured latency per relay URL across probe types (v4/v6/https).
970    let mut latency_ms: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
971    if let Some(r) = &report {
972        for (_probe, url, dur) in r.relay_latency.iter() {
973            let ms = dur.as_millis() as u64;
974            latency_ms
975                .entry(url.to_string())
976                .and_modify(|m| *m = (*m).min(ms))
977                .or_insert(ms);
978        }
979    }
980
981    let relays = endpoint
982        .home_relay_status()
983        .get()
984        .into_iter()
985        .map(|s| {
986            let url = s.url().to_string();
987            radicle_artifact_core::protocol::RelayHealth {
988                latency_ms: latency_ms.get(&url).copied(),
989                connected: s.is_connected(),
990                last_error: s.last_error().map(|e| e.to_string()),
991                url,
992            }
993        })
994        .collect();
995
996    radicle_artifact_core::protocol::RelayStats {
997        relays,
998        preferred: report
999            .as_ref()
1000            .and_then(|r| r.preferred_relay.as_ref().map(|u| u.to_string())),
1001        udp_v4: report.as_ref().map(|r| r.udp_v4).unwrap_or(false),
1002        udp_v6: report.as_ref().map(|r| r.udp_v6).unwrap_or(false),
1003    }
1004}
1005
1006fn ok_json<T: serde::Serialize>(v: T) -> String {
1007    serde_json::to_string(&CommandResult::Okay(v))
1008        .unwrap_or_else(|e| format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#))
1009}
1010
1011fn err_json<T>(code: ErrorCode, message: String) -> String
1012where
1013    T: serde::Serialize,
1014{
1015    serde_json::to_string(&CommandResult::<T>::Error(CommandError { code, message }))
1016        .unwrap_or_else(|e| format!(r#"{{"error":{{"code":"internal","message":"encode: {e}"}}}}"#))
1017}
1018
1019fn err_from_share<T>(e: ShareError) -> String
1020where
1021    T: serde::Serialize,
1022{
1023    let code = share_error_to_code(&e);
1024    err_json::<T>(code, e.to_string())
1025}
1026
1027fn share_error_to_code(e: &ShareError) -> ErrorCode {
1028    match e {
1029        ShareError::CidMismatch { .. } => ErrorCode::CidMismatch,
1030        ShareError::Io(_) => ErrorCode::Io,
1031        ShareError::Iroh(_) => ErrorCode::Iroh,
1032        _ => ErrorCode::Internal,
1033    }
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038    use std::fs;
1039
1040    use cid::multihash::Multihash;
1041
1042    use super::*;
1043    use radicle_artifact_core::cid::{
1044        self as cid_utils, ArtifactKind, HASH_CODE_BLAKE3, RAW_CODEC,
1045    };
1046
1047    /// Build a fake but well-formed blob CID over `data` so the
1048    /// `tag_seeded` path picks `HashAndFormat::raw`.
1049    fn fake_blob_cid(data: &[u8]) -> Cid {
1050        let digest = blake3::hash(data);
1051        let mh = Multihash::<64>::wrap(HASH_CODE_BLAKE3, digest.as_bytes()).unwrap();
1052        Cid::from(cid::Cid::new_v1(RAW_CODEC, mh))
1053    }
1054
1055    fn rid_a() -> RepoId {
1056        RepoId::from_str("rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip").unwrap()
1057    }
1058
1059    fn release_a() -> Oid {
1060        Oid::from_str("0123456789abcdef0123456789abcdef01234567").unwrap()
1061    }
1062
1063    /// Spawn a node under `home`, wait for its control socket to appear, and
1064    /// return the socket path plus the join handle for asserting clean exit.
1065    async fn start_node(
1066        home: &Path,
1067        secret: iroh::SecretKey,
1068    ) -> (PathBuf, tokio::task::JoinHandle<Result<(), NodeError>>) {
1069        let home_path = home.to_path_buf();
1070        let handle = tokio::spawn(async move { run(&home_path, secret).await });
1071        let socket = home.join(ARTIFACTS_DIR).join("control.sock");
1072        for _ in 0..200 {
1073            if socket.exists() {
1074                break;
1075            }
1076            tokio::time::sleep(Duration::from_millis(50)).await;
1077        }
1078        assert!(socket.exists(), "control socket never appeared");
1079        (socket, handle)
1080    }
1081
1082    /// End-to-end client↔node round-trip covering Status, Seed (new +
1083    /// duplicate + CID mismatch), IsSeeding, ListSeeded, Unseed (new +
1084    /// duplicate), and Shutdown.
1085    #[test]
1086    fn node_round_trip() {
1087        let rt = tokio::runtime::Builder::new_multi_thread()
1088            .enable_all()
1089            .build()
1090            .unwrap();
1091        rt.block_on(async {
1092            let home = tempfile::tempdir().unwrap();
1093
1094            // Compute a real blob CID from a real file so the import
1095            // verification passes.
1096            let blob_path = home.path().join("payload.bin");
1097            let payload = b"hello rad-artifact";
1098            fs::write(&blob_path, payload).unwrap();
1099            let real_cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1100            let rid = rid_a();
1101
1102            // Pin the secret so the test is reproducible.
1103            let secret = iroh::SecretKey::from_bytes(&[1u8; 32]);
1104            let expected_endpoint_id = EndpointId::from(secret.public());
1105
1106            // Run the node on a tokio task; capture the join handle so
1107            // we can assert clean exit.
1108            let home_path = home.path().to_path_buf();
1109            let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1110
1111            // Wait for the socket to appear (bootstrap involves iroh
1112            // endpoint bind, which is async).
1113            let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1114            for _ in 0..200 {
1115                if socket.exists() {
1116                    break;
1117                }
1118                tokio::time::sleep(Duration::from_millis(50)).await;
1119            }
1120            assert!(socket.exists(), "control socket never appeared");
1121
1122            let client = Client::new(socket.clone());
1123
1124            // is_running succeeds.
1125            assert!(client.is_running().await);
1126
1127            // Status round-trips and reports the expected endpoint id.
1128            let status = client.status().await.unwrap();
1129            assert_eq!(status.endpoint_id, expected_endpoint_id);
1130            assert_eq!(status.seeded.count, 0);
1131
1132            // Seed (new).
1133            let receipt = client
1134                .seed(
1135                    rid,
1136                    release_a(),
1137                    real_cid,
1138                    &blob_path,
1139                    ArtifactKind::Blob,
1140                    ImportMode::Copy,
1141                )
1142                .await
1143                .unwrap();
1144            assert!(receipt.was_new);
1145            assert_eq!(receipt.endpoint_id, expected_endpoint_id);
1146            assert_eq!(receipt.bytes, payload.len() as u64);
1147
1148            // Seed again — idempotent: was_new false.
1149            let receipt2 = client
1150                .seed(
1151                    rid,
1152                    release_a(),
1153                    real_cid,
1154                    &blob_path,
1155                    ArtifactKind::Blob,
1156                    ImportMode::Copy,
1157                )
1158                .await
1159                .unwrap();
1160            assert!(!receipt2.was_new);
1161
1162            // Seed with a tampered path that does not match the CID.
1163            let bad_path = home.path().join("tampered.bin");
1164            fs::write(&bad_path, b"different bytes").unwrap();
1165            let bad_cid = fake_blob_cid(b"not the right preimage");
1166            let err = client
1167                .seed(
1168                    rid,
1169                    release_a(),
1170                    bad_cid,
1171                    &bad_path,
1172                    ArtifactKind::Blob,
1173                    ImportMode::Copy,
1174                )
1175                .await
1176                .expect_err("CID mismatch must error");
1177            match err {
1178                radicle_artifact_client::ClientError::Remote(CommandError { code, .. }) => {
1179                    assert_eq!(code, ErrorCode::CidMismatch);
1180                }
1181                other => panic!("expected CidMismatch error, got {other:?}"),
1182            }
1183
1184            // IsSeeding reflects the tag we set above.
1185            assert!(client.is_seeding(rid, real_cid).await.unwrap());
1186
1187            // ListSeeded returns exactly the one entry.
1188            let entries = client.list_seeded(rid).await.unwrap();
1189            assert_eq!(entries.len(), 1);
1190            assert_eq!(entries[0].cid, real_cid);
1191            assert_eq!(entries[0].bytes, payload.len() as u64);
1192
1193            // Status now reports one seeded artifact.
1194            let status = client.status().await.unwrap();
1195            assert_eq!(status.seeded.count, 1);
1196            assert_eq!(status.seeded.bytes_logical, payload.len() as u64);
1197
1198            // Unseed once removes the tag; second call is idempotent.
1199            let r1 = client
1200                .unseed(rid, Some(release_a()), real_cid)
1201                .await
1202                .unwrap();
1203            assert!(r1.was_removed);
1204            let r2 = client
1205                .unseed(rid, Some(release_a()), real_cid)
1206                .await
1207                .unwrap();
1208            assert!(!r2.was_removed);
1209            assert!(!client.is_seeding(rid, real_cid).await.unwrap());
1210
1211            // Shutdown — the node acks then exits cleanly.
1212            client.shutdown().await.unwrap();
1213            tokio::time::timeout(Duration::from_secs(10), node_handle)
1214                .await
1215                .expect("node did not exit within 10s")
1216                .expect("join error")
1217                .expect("node returned error");
1218
1219            // Socket file is gone after shutdown.
1220            assert!(!socket.exists());
1221        });
1222    }
1223
1224    /// Malformed `rid`/`cid` on the wire must surface as `InvalidRequest`
1225    /// with the offending field named in the message — so callers don't
1226    /// have to grep for "invalid command JSON" to recognize bad input.
1227    #[test]
1228    fn invalid_typed_fields_surface_as_invalid_request() {
1229        let rt = tokio::runtime::Builder::new_multi_thread()
1230            .enable_all()
1231            .build()
1232            .unwrap();
1233        rt.block_on(async {
1234            let home = tempfile::tempdir().unwrap();
1235            let secret = iroh::SecretKey::from_bytes(&[4u8; 32]);
1236            let home_path = home.path().to_path_buf();
1237            let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1238
1239            let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1240            for _ in 0..200 {
1241                if socket.exists() {
1242                    break;
1243                }
1244                tokio::time::sleep(Duration::from_millis(50)).await;
1245            }
1246            assert!(socket.exists());
1247
1248            // The typed Client can't construct these frames since the
1249            // fields are now RepoId / Cid, so we hand-roll the wire.
1250            for (frame, expected_field) in [
1251                (
1252                    br#"{"command":"listSeeded","rid":"not-a-real-rid"}"#.as_slice(),
1253                    "rid",
1254                ),
1255                (
1256                    br#"{"command":"isSeeding","rid":"rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip","cid":"not-a-real-cid"}"#.as_slice(),
1257                    "cid",
1258                ),
1259            ] {
1260                let mut stream = tokio::net::UnixStream::connect(&socket).await.unwrap();
1261                tokio::io::AsyncWriteExt::write_all(&mut stream, frame)
1262                    .await
1263                    .unwrap();
1264                tokio::io::AsyncWriteExt::write_all(&mut stream, b"\n")
1265                    .await
1266                    .unwrap();
1267                let mut buf = String::new();
1268                tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1269                    .await
1270                    .unwrap();
1271                let parsed: CommandResult<serde_json::Value> =
1272                    serde_json::from_str(buf.trim()).unwrap();
1273                match parsed {
1274                    CommandResult::Error(CommandError { code, message }) => {
1275                        assert_eq!(code, ErrorCode::InvalidRequest);
1276                        assert!(
1277                            message.contains(expected_field),
1278                            "message should name {expected_field}: {message}"
1279                        );
1280                    }
1281                    CommandResult::Okay(_) => panic!("expected error, got ok"),
1282                }
1283            }
1284
1285            // Clean shutdown.
1286            let client = Client::new(socket);
1287            client.shutdown().await.unwrap();
1288            node_handle.await.unwrap().unwrap();
1289        });
1290    }
1291
1292    /// A second `run` on the same home while the first is alive must
1293    /// fail with `AlreadyRunning`, not silently steal the socket.
1294    #[test]
1295    fn double_start_errors() {
1296        let rt = tokio::runtime::Builder::new_multi_thread()
1297            .enable_all()
1298            .build()
1299            .unwrap();
1300        rt.block_on(async {
1301            let home = tempfile::tempdir().unwrap();
1302            let secret = iroh::SecretKey::from_bytes(&[2u8; 32]);
1303            let home_path = home.path().to_path_buf();
1304            let first = tokio::spawn(async move { run(&home_path, secret).await });
1305
1306            let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1307            for _ in 0..200 {
1308                if socket.exists() {
1309                    break;
1310                }
1311                tokio::time::sleep(Duration::from_millis(50)).await;
1312            }
1313            assert!(socket.exists());
1314
1315            // Same home, different secret to keep keys distinct.
1316            let secret2 = iroh::SecretKey::from_bytes(&[3u8; 32]);
1317            let err = run(home.path(), secret2).await.expect_err("must fail");
1318            assert!(matches!(err, NodeError::AlreadyRunning(_)));
1319
1320            // Tear down the first one so the test cleans up.
1321            let client = Client::new(socket);
1322            client.shutdown().await.unwrap();
1323            first.await.unwrap().unwrap();
1324        });
1325    }
1326
1327    /// Send a one-shot command on a fresh connection and decode the reply.
1328    async fn oneshot<T: serde::de::DeserializeOwned>(
1329        socket: &Path,
1330        cmd: &Command,
1331    ) -> CommandResult<T> {
1332        let mut stream = UnixStream::connect(socket).await.unwrap();
1333        let mut line = serde_json::to_string(cmd).unwrap();
1334        line.push('\n');
1335        tokio::io::AsyncWriteExt::write_all(&mut stream, line.as_bytes())
1336            .await
1337            .unwrap();
1338        let mut buf = String::new();
1339        tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1340            .await
1341            .unwrap();
1342        serde_json::from_str(buf.trim()).unwrap()
1343    }
1344
1345    /// Send a streaming command; return (progress frame count, terminal frame).
1346    async fn streaming<T: serde::de::DeserializeOwned>(
1347        socket: &Path,
1348        cmd: &Command,
1349    ) -> (usize, StreamEvent<T>) {
1350        let mut stream = UnixStream::connect(socket).await.unwrap();
1351        let mut line = serde_json::to_string(cmd).unwrap();
1352        line.push('\n');
1353        tokio::io::AsyncWriteExt::write_all(&mut stream, line.as_bytes())
1354            .await
1355            .unwrap();
1356        let mut buf = String::new();
1357        tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1358            .await
1359            .unwrap();
1360        let mut progress = 0usize;
1361        let mut terminal = None;
1362        for l in buf.lines().filter(|l| !l.trim().is_empty()) {
1363            match serde_json::from_str::<StreamEvent<T>>(l).unwrap() {
1364                StreamEvent::Progress(_) => progress += 1,
1365                term => terminal = Some(term),
1366            }
1367        }
1368        (progress, terminal.expect("a terminal frame"))
1369    }
1370
1371    /// `Has` reflects store presence; `Export` streams local bytes to disk
1372    /// and reports `NotLocal` for content the store doesn't hold.
1373    #[test]
1374    fn has_and_export_round_trip() {
1375        let rt = tokio::runtime::Builder::new_multi_thread()
1376            .enable_all()
1377            .build()
1378            .unwrap();
1379        rt.block_on(async {
1380            let home = tempfile::tempdir().unwrap();
1381            let payload = b"hello has/export";
1382            let blob_path = home.path().join("payload.bin");
1383            fs::write(&blob_path, payload).unwrap();
1384            let cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1385            let rid = rid_a();
1386
1387            let secret = iroh::SecretKey::from_bytes(&[5u8; 32]);
1388            let home_path = home.path().to_path_buf();
1389            let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1390
1391            let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1392            for _ in 0..200 {
1393                if socket.exists() {
1394                    break;
1395                }
1396                tokio::time::sleep(Duration::from_millis(50)).await;
1397            }
1398            assert!(socket.exists(), "control socket never appeared");
1399
1400            // Seed the blob so its bytes live in the store.
1401            let client = Client::new(socket.clone());
1402            client
1403                .seed(
1404                    rid,
1405                    release_a(),
1406                    cid,
1407                    &blob_path,
1408                    ArtifactKind::Blob,
1409                    ImportMode::Copy,
1410                )
1411                .await
1412                .unwrap();
1413
1414            // Has: present and complete with the right size.
1415            match oneshot::<HasResult>(&socket, &Command::Has { cid }).await {
1416                CommandResult::Okay(h) => {
1417                    assert!(h.present);
1418                    assert!(h.complete);
1419                    assert_eq!(h.bytes, payload.len() as u64);
1420                }
1421                CommandResult::Error(e) => panic!("has errored: {e:?}"),
1422            }
1423
1424            // Has on content the store doesn't hold: absent.
1425            let unknown = fake_blob_cid(b"never stored");
1426            match oneshot::<HasResult>(&socket, &Command::Has { cid: unknown }).await {
1427                CommandResult::Okay(h) => {
1428                    assert!(!h.present);
1429                    assert!(!h.complete);
1430                }
1431                CommandResult::Error(e) => panic!("has errored: {e:?}"),
1432            }
1433
1434            // Export streams the bytes to disk; the file matches the payload.
1435            let dest = home.path().join("exported.bin");
1436            let (_progress, term) = streaming::<ExportReceipt>(
1437                &socket,
1438                &Command::Export {
1439                    cid,
1440                    dest: dest.clone(),
1441                },
1442            )
1443            .await;
1444            match term {
1445                StreamEvent::Okay(r) => {
1446                    assert_eq!(r.bytes, payload.len() as u64);
1447                    assert_eq!(r.dest, dest);
1448                }
1449                other => panic!("expected okay, got {other:?}"),
1450            }
1451            assert_eq!(fs::read(&dest).unwrap(), payload);
1452
1453            // Export of absent content reports NotLocal.
1454            let (_p, term) = streaming::<ExportReceipt>(
1455                &socket,
1456                &Command::Export {
1457                    cid: unknown,
1458                    dest: home.path().join("nope.bin"),
1459                },
1460            )
1461            .await;
1462            match term {
1463                StreamEvent::Error(e) => assert_eq!(e.code, ErrorCode::NotLocal),
1464                other => panic!("expected NotLocal error, got {other:?}"),
1465            }
1466
1467            client.shutdown().await.unwrap();
1468            node_handle.await.unwrap().unwrap();
1469        });
1470    }
1471
1472    /// `Fetch` (store-only) and `Download` (to disk) fast paths over
1473    /// already-local bytes: `Fetch` writes no file and reports the logical
1474    /// store size; `Download` writes the file; `Fetch --seed` tags a second
1475    /// repo by shared hash; an empty location set on absent content reports
1476    /// `NoLocations`. (The networked download path needs two endpoints and is
1477    /// exercised via the shared core, not here.)
1478    #[test]
1479    fn fetch_and_download_fast_path_and_no_locations() {
1480        let rt = tokio::runtime::Builder::new_multi_thread()
1481            .enable_all()
1482            .build()
1483            .unwrap();
1484        rt.block_on(async {
1485            let home = tempfile::tempdir().unwrap();
1486            let payload = b"hello fetch fast-path";
1487            let blob_path = home.path().join("payload.bin");
1488            fs::write(&blob_path, payload).unwrap();
1489            let cid = cid_utils::compute_blob_cid(&blob_path).unwrap();
1490            let rid = rid_a();
1491
1492            let secret = iroh::SecretKey::from_bytes(&[6u8; 32]);
1493            let home_path = home.path().to_path_buf();
1494            let node_handle = tokio::spawn(async move { run(&home_path, secret).await });
1495
1496            let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1497            for _ in 0..200 {
1498                if socket.exists() {
1499                    break;
1500                }
1501                tokio::time::sleep(Duration::from_millis(50)).await;
1502            }
1503            assert!(socket.exists(), "control socket never appeared");
1504
1505            // Seed so the bytes live in the store.
1506            let client = Client::new(socket.clone());
1507            client
1508                .seed(
1509                    rid,
1510                    release_a(),
1511                    cid,
1512                    &blob_path,
1513                    ArtifactKind::Blob,
1514                    ImportMode::Copy,
1515                )
1516                .await
1517                .unwrap();
1518
1519            // Fetch fast path: complete locally, no locations, no seed, no
1520            // disk write. Reports the logical store size.
1521            let (_p, term) = streaming::<FetchReceipt>(
1522                &socket,
1523                &Command::Fetch {
1524                    rid,
1525                    cid,
1526                    locations: vec![],
1527                    seed: None,
1528                },
1529            )
1530            .await;
1531            match term {
1532                StreamEvent::Okay(r) => {
1533                    assert!(r.from_cache);
1534                    assert!(!r.seeded);
1535                    assert_eq!(r.bytes, payload.len() as u64);
1536                }
1537                other => panic!("expected okay, got {other:?}"),
1538            }
1539
1540            // Download fast path: same bytes, but exported to disk.
1541            let dest = home.path().join("downloaded.bin");
1542            let (_p, term) = streaming::<DownloadReceipt>(
1543                &socket,
1544                &Command::Download {
1545                    rid,
1546                    cid,
1547                    locations: vec![],
1548                    dest: dest.clone(),
1549                    seed: None,
1550                },
1551            )
1552            .await;
1553            match term {
1554                StreamEvent::Okay(r) => {
1555                    assert!(r.from_cache);
1556                    assert!(!r.seeded);
1557                    assert_eq!(r.bytes, payload.len() as u64);
1558                    assert_eq!(r.dest, dest);
1559                }
1560                other => panic!("expected okay, got {other:?}"),
1561            }
1562            assert_eq!(fs::read(&dest).unwrap(), payload);
1563
1564            // Fetch with seed under a second repo: the bytes are shared by
1565            // hash, so a (rid2, release, cid) tag is set without re-downloading.
1566            let rid2 = RepoId::from_str("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5").unwrap();
1567            let (_p, term) = streaming::<FetchReceipt>(
1568                &socket,
1569                &Command::Fetch {
1570                    rid: rid2,
1571                    cid,
1572                    locations: vec![],
1573                    seed: Some(release_a()),
1574                },
1575            )
1576            .await;
1577            match term {
1578                StreamEvent::Okay(r) => {
1579                    assert!(r.from_cache);
1580                    assert!(r.seeded);
1581                }
1582                other => panic!("expected okay, got {other:?}"),
1583            }
1584            assert!(client.is_seeding(rid2, cid).await.unwrap());
1585
1586            // Absent content with no locations to try: NoLocations.
1587            let unknown = fake_blob_cid(b"absent");
1588            let (_p, term) = streaming::<FetchReceipt>(
1589                &socket,
1590                &Command::Fetch {
1591                    rid,
1592                    cid: unknown,
1593                    locations: vec![],
1594                    seed: None,
1595                },
1596            )
1597            .await;
1598            match term {
1599                StreamEvent::Error(e) => assert_eq!(e.code, ErrorCode::NoLocations),
1600                other => panic!("expected NoLocations error, got {other:?}"),
1601            }
1602
1603            client.shutdown().await.unwrap();
1604            node_handle.await.unwrap().unwrap();
1605        });
1606    }
1607
1608    /// A client that vanishes mid-stream must drop the in-flight `op`, not
1609    /// leave it running. Driven directly over an in-memory pipe: the op parks
1610    /// forever, so the only way `run_stream` returns is the disconnect arm
1611    /// seeing EOF on the read half — at which point the op future is dropped.
1612    #[test]
1613    fn run_stream_aborts_on_client_disconnect() {
1614        use std::sync::atomic::AtomicBool;
1615
1616        let rt = tokio::runtime::Builder::new_multi_thread()
1617            .enable_all()
1618            .build()
1619            .unwrap();
1620        rt.block_on(async {
1621            // Duplex pipe standing in for the connection: we keep the client
1622            // end, run_stream reads/writes the server end.
1623            let (client_end, server_end) = tokio::io::duplex(64);
1624            let (mut srv_read, mut srv_write) = tokio::io::split(server_end);
1625
1626            // Guard whose Drop flips a flag, so we can prove the op future was
1627            // dropped (aborted) rather than allowed to complete.
1628            struct DropFlag(Arc<AtomicBool>);
1629            impl Drop for DropFlag {
1630                fn drop(&mut self) {
1631                    self.0.store(true, Ordering::SeqCst);
1632                }
1633            }
1634            let aborted = Arc::new(AtomicBool::new(false));
1635            let completed = Arc::new(AtomicBool::new(false));
1636            let aborted_op = aborted.clone();
1637            let completed_op = completed.clone();
1638
1639            let op = async move |_tx: mpsc::UnboundedSender<FetchProgress>| {
1640                let _guard = DropFlag(aborted_op);
1641                // Never resolves on its own; only a drop ends it.
1642                std::future::pending::<()>().await;
1643                completed_op.store(true, Ordering::SeqCst);
1644                Ok::<(), (ErrorCode, String)>(())
1645            };
1646
1647            let server =
1648                tokio::spawn(
1649                    async move { run_stream::<()>(&mut srv_read, &mut srv_write, op).await },
1650                );
1651
1652            // Let run_stream enter its select loop (op parked) before the
1653            // client disconnects, so we exercise a genuinely in-flight op.
1654            tokio::time::sleep(Duration::from_millis(50)).await;
1655            drop(client_end); // EOF on the server read half
1656
1657            let res = tokio::time::timeout(Duration::from_secs(5), server)
1658                .await
1659                .expect("run_stream did not return after disconnect")
1660                .expect("join error");
1661            assert!(res.is_ok());
1662            assert!(
1663                aborted.load(Ordering::SeqCst),
1664                "op future was not dropped on disconnect"
1665            );
1666            assert!(
1667                !completed.load(Ordering::SeqCst),
1668                "op should have been aborted, not completed"
1669            );
1670        });
1671    }
1672
1673    /// A leftover socket file with no live owner must be reclaimed: `run`
1674    /// probes it, finds nothing answering, unlinks it, and binds its own.
1675    #[test]
1676    fn stale_socket_is_reclaimed() {
1677        let rt = tokio::runtime::Builder::new_multi_thread()
1678            .enable_all()
1679            .build()
1680            .unwrap();
1681        rt.block_on(async {
1682            let home = tempfile::tempdir().unwrap();
1683            let socket = home.path().join(ARTIFACTS_DIR).join("control.sock");
1684            fs::create_dir_all(socket.parent().unwrap()).unwrap();
1685            // Bind then drop: the socket file lingers but nothing listens.
1686            drop(UnixListener::bind(&socket).unwrap());
1687            assert!(socket.exists());
1688
1689            let secret = iroh::SecretKey::from_bytes(&[7u8; 32]);
1690            let home_path = home.path().to_path_buf();
1691            let handle = tokio::spawn(async move { run(&home_path, secret).await });
1692
1693            // Poll is_running (not just file existence) since the stale file
1694            // is replaced in place — only a live answer means we're up.
1695            let client = Client::new(socket.clone());
1696            let mut ready = false;
1697            for _ in 0..200 {
1698                if client.is_running().await {
1699                    ready = true;
1700                    break;
1701                }
1702                tokio::time::sleep(Duration::from_millis(50)).await;
1703            }
1704            assert!(ready, "node never came up after reclaiming stale socket");
1705
1706            client.shutdown().await.unwrap();
1707            handle.await.unwrap().unwrap();
1708        });
1709    }
1710
1711    /// `Seed` against a path that doesn't exist must report `PathNotFound`
1712    /// before any import work.
1713    #[test]
1714    fn seed_missing_path_errors() {
1715        let rt = tokio::runtime::Builder::new_multi_thread()
1716            .enable_all()
1717            .build()
1718            .unwrap();
1719        rt.block_on(async {
1720            let home = tempfile::tempdir().unwrap();
1721            let secret = iroh::SecretKey::from_bytes(&[8u8; 32]);
1722            let (socket, handle) = start_node(home.path(), secret).await;
1723
1724            let client = Client::new(socket.clone());
1725            let missing = home.path().join("does-not-exist.bin");
1726            let err = client
1727                .seed(
1728                    rid_a(),
1729                    release_a(),
1730                    fake_blob_cid(b"whatever"),
1731                    &missing,
1732                    ArtifactKind::Blob,
1733                    ImportMode::Copy,
1734                )
1735                .await
1736                .expect_err("missing path must error");
1737            match err {
1738                radicle_artifact_client::ClientError::Remote(CommandError { code, .. }) => {
1739                    assert_eq!(code, ErrorCode::PathNotFound);
1740                }
1741                other => panic!("expected PathNotFound, got {other:?}"),
1742            }
1743
1744            client.shutdown().await.unwrap();
1745            handle.await.unwrap().unwrap();
1746        });
1747    }
1748
1749    /// Bare malformed JSON (not just a bad typed field) must surface as
1750    /// `InvalidRequest` naming the JSON parse failure.
1751    #[test]
1752    fn malformed_json_surfaces_as_invalid_request() {
1753        let rt = tokio::runtime::Builder::new_multi_thread()
1754            .enable_all()
1755            .build()
1756            .unwrap();
1757        rt.block_on(async {
1758            let home = tempfile::tempdir().unwrap();
1759            let secret = iroh::SecretKey::from_bytes(&[9u8; 32]);
1760            let (socket, handle) = start_node(home.path(), secret).await;
1761
1762            let mut stream = UnixStream::connect(&socket).await.unwrap();
1763            tokio::io::AsyncWriteExt::write_all(&mut stream, b"this is not json\n")
1764                .await
1765                .unwrap();
1766            let mut buf = String::new();
1767            tokio::io::AsyncReadExt::read_to_string(&mut stream, &mut buf)
1768                .await
1769                .unwrap();
1770            let parsed: CommandResult<serde_json::Value> =
1771                serde_json::from_str(buf.trim()).unwrap();
1772            match parsed {
1773                CommandResult::Error(CommandError { code, message }) => {
1774                    assert_eq!(code, ErrorCode::InvalidRequest);
1775                    assert!(
1776                        message.contains("invalid command JSON"),
1777                        "message should name the JSON failure: {message}"
1778                    );
1779                }
1780                CommandResult::Okay(_) => panic!("expected error, got ok"),
1781            }
1782
1783            let client = Client::new(socket);
1784            client.shutdown().await.unwrap();
1785            handle.await.unwrap().unwrap();
1786        });
1787    }
1788}