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