Skip to main content

radicle_artifact_core/
protocol.rs

1//! Wire protocol for the rad-artifact node control socket.
2//!
3//! One request per Unix-socket connection, each command a single line of
4//! JSON terminated by `\n`. Most commands are one-shot: the client writes
5//! one [`Command`] and reads one [`CommandResult<T>`]. The streaming
6//! commands ([`Command::Fetch`], [`Command::Download`], [`Command::Export`])
7//! instead emit a sequence of [`StreamEvent`] frames — zero or more
8//! `progress`, then one
9//! terminal `okay`/`error` whose tags match [`CommandResult`].
10//!
11//! The schema is additive-friendly on the wire (an unknown command tag
12//! is an error; unknown struct fields are ignored, new variants are new
13//! tags), but the Rust types are plain —
14//! the node crate constructs and exhaustively matches them, and all
15//! crates in this workspace version in lockstep.
16
17use std::path::PathBuf;
18
19use radicle::git::Oid;
20use radicle::identity::RepoId;
21use serde::{Deserialize, Serialize};
22use url::Url;
23
24use crate::cid::{ArtifactKind, Cid};
25use crate::keys::EndpointId;
26
27/// How imported bytes are placed in the store.
28///
29/// A serde-friendly, project-stable representation suitable for the wire
30/// protocol; the node maps it onto `iroh_blobs::api::blobs::ImportMode`.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub enum ImportMode {
34    /// Copy bytes into the store. The source file can be moved or
35    /// deleted afterwards without breaking seeding. Default for the
36    /// node.
37    Copy,
38    /// Reference the source file in place. No bytes are copied. The
39    /// caller is responsible for keeping the source path stable for as
40    /// long as they want to seed the artifact; if the file is moved or
41    /// deleted, fetches will fail.
42    Reference,
43}
44
45/// A control-socket request.
46///
47/// Serialized as JSON with an internal `"command"` tag, camelCase
48/// variant names. Unit variants (`Status`, `Shutdown`) serialize to
49/// `{"command":"status"}`.
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51#[serde(tag = "command", rename_all = "camelCase")]
52pub enum Command {
53    /// Cheap liveness probe: the node replies `{"okay":null}` and does no
54    /// work. Used to tell a live owner from a stale socket file. Distinct
55    /// from any network-level reachability check against a peer endpoint.
56    Alive,
57    /// Report node status.
58    Status,
59    /// Import bytes from `path`, verify against `cid`, register the
60    /// `seeded/{rid}/{release}/{cid}` tag.
61    Seed {
62        /// Repository the artifact belongs to.
63        rid: RepoId,
64        /// Release the seeded tag is scoped to. Lets a CID shared by several
65        /// releases be unseeded per release without dropping the others.
66        release: Oid,
67        /// Expected content identifier.
68        cid: Cid,
69        /// Path to the artifact on disk (file for blobs, directory for collections).
70        path: PathBuf,
71        /// Whether the artifact is a single blob or a collection.
72        kind: ArtifactKind,
73        /// Copy bytes into the store or reference them in place.
74        mode: ImportMode,
75    },
76    /// Remove seeded tags for `cid`. Idempotent. `release: Some(id)` drops
77    /// just that release's tag; `None` stops seeding the CID across every
78    /// release of `rid`.
79    Unseed {
80        /// Repository the artifact belongs to.
81        rid: RepoId,
82        /// Release to stop seeding, or `None` for all releases of `rid`.
83        #[serde(default, skip_serializing_if = "Option::is_none")]
84        release: Option<Oid>,
85        /// Content identifier to stop seeding.
86        cid: Cid,
87    },
88    /// Whether `(rid, cid)` is currently seeded under any release.
89    IsSeeding {
90        /// Repository the artifact belongs to.
91        rid: RepoId,
92        /// Content identifier to check.
93        cid: Cid,
94    },
95    /// List CIDs seeded under `rid`.
96    ListSeeded {
97        /// Repository to enumerate.
98        rid: RepoId,
99    },
100    /// Cheap predicate: is this CID's content present/complete in the
101    /// store? No network. One-shot, returns [`HasResult`]. Hash-keyed and
102    /// repo-agnostic.
103    Has {
104        /// Content identifier to look up.
105        cid: Cid,
106    },
107    /// Export already-local bytes to `dest`. No network. Streaming —
108    /// emits `exporting` progress, then [`ExportReceipt`]. Errors with
109    /// [`ErrorCode::NotLocal`] if the content isn't complete in the store.
110    Export {
111        /// Content identifier to export.
112        cid: Cid,
113        /// Destination path (file for blobs, directory for collections).
114        dest: PathBuf,
115    },
116    /// Fetch an artifact into the store: no-op if already complete locally,
117    /// else download from `locations`, optionally tag as seeded. Does not
118    /// write to disk — use [`Command::Download`] for that. Streaming —
119    /// emits progress, then [`FetchReceipt`].
120    Fetch {
121        /// Repository the artifact belongs to (for the seeded tag).
122        rid: RepoId,
123        /// Expected content identifier; the blob kind is derived from it.
124        cid: Cid,
125        /// Resolved providers/URLs to try. Iroh providers are batched into
126        /// one multi-provider download; URLs are tried in sequence.
127        locations: Vec<FetchLocation>,
128        /// Release to seed under once the fetch completes, tagged
129        /// `seeded/{rid}/{release}/{cid}` so the node serves it. `None`
130        /// fetches without seeding; pairing seed intent with its release
131        /// keeps an unscoped seed unrepresentable.
132        #[serde(default, skip_serializing_if = "Option::is_none")]
133        seed: Option<Oid>,
134    },
135    /// Download an artifact to disk: [`Command::Fetch`] into the store, then
136    /// export to `dest`. Fast-path export if already local. Optionally tags
137    /// as seeded. Streaming — emits progress, then [`DownloadReceipt`].
138    Download {
139        /// Repository the artifact belongs to (for the seeded tag).
140        rid: RepoId,
141        /// Expected content identifier; the blob kind is derived from it.
142        cid: Cid,
143        /// Resolved providers/URLs to try. Iroh providers are batched into
144        /// one multi-provider download; URLs are tried in sequence.
145        locations: Vec<FetchLocation>,
146        /// Destination path (file for blobs, directory for collections).
147        dest: PathBuf,
148        /// Release to seed under once the download completes, tagged
149        /// `seeded/{rid}/{release}/{cid}` so the node serves it. `None`
150        /// downloads without seeding; pairing seed intent with its release
151        /// keeps an unscoped seed unrepresentable.
152        #[serde(default, skip_serializing_if = "Option::is_none")]
153        seed: Option<Oid>,
154    },
155    /// Ask the node to shut down gracefully.
156    Shutdown,
157}
158
159/// A resolved place to fetch an artifact from.
160///
161/// Owned and serde-friendly, unlike the borrowed COB form (`(&Url, &Did)`).
162/// The caller resolves COB locations (including DID-derived bare
163/// `radiroh://` entries) into this concrete form; the node does no
164/// identity resolution of its own.
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
166#[serde(rename_all = "camelCase")]
167pub enum FetchLocation {
168    /// An HTTP(S) (or other-scheme) URL, serialized as the URL string.
169    Url(Url),
170    /// An iroh provider, serialized as the canonical `radiroh://<base32>` URL.
171    Iroh(EndpointId),
172}
173
174/// Top-level response envelope.
175///
176/// Externally tagged so the wire reads `{"okay": <T>}` or
177/// `{"error": <CommandError>}`. Type-stable across commands by parameter.
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
179#[serde(rename_all = "camelCase")]
180pub enum CommandResult<T> {
181    /// Success with command-specific payload.
182    Okay(T),
183    /// Failure with structured error.
184    Error(CommandError),
185}
186
187/// Frame of a streaming response ([`Command::Fetch`], [`Command::Download`],
188/// [`Command::Export`]).
189///
190/// Externally tagged: `{"progress": …}` (repeatable, non-terminal) then
191/// exactly one terminal `{"okay": <T>}` / `{"error": <CommandError>}`. The
192/// terminal tags deliberately match [`CommandResult`] so a generic reader
193/// recognizes them; `progress` is the new, non-terminal frame.
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
195#[serde(rename_all = "camelCase")]
196pub enum StreamEvent<T> {
197    /// Non-terminal progress update.
198    Progress(FetchProgress),
199    /// Terminal success with command-specific payload.
200    Okay(T),
201    /// Terminal failure.
202    Error(CommandError),
203}
204
205/// One progress frame for a streaming command.
206///
207/// An enum, not a struct, so Location-level events (which carry no byte
208/// offset) and byte-movement events are modeled distinctly. The variants
209/// map onto the iroh `DownloadProgressItem` kinds the download loop
210/// already produces. `Export` only ever emits [`FetchProgress::Exporting`].
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212#[serde(
213    rename_all = "camelCase",
214    rename_all_fields = "camelCase",
215    tag = "kind"
216)]
217pub enum FetchProgress {
218    /// Endpoint/relay setup, before any Location is tried.
219    Connecting,
220    /// Now attempting this Location.
221    TryingLocation {
222        /// Endpoint being tried.
223        endpoint_id: EndpointId,
224    },
225    /// This Location failed; moving on to the next.
226    LocationFailed {
227        /// Endpoint that failed.
228        endpoint_id: EndpointId,
229    },
230    /// Byte movement during download.
231    Downloading {
232        /// Bytes downloaded so far.
233        offset: u64,
234        /// Total size, if known.
235        total: Option<u64>,
236    },
237    /// Byte movement while writing the store out to disk.
238    Exporting {
239        /// Bytes exported so far.
240        offset: u64,
241        /// Total size, if known.
242        total: Option<u64>,
243        /// Collection member being exported; `None` for a single blob.
244        entry: Option<String>,
245    },
246}
247
248/// Structured failure: an [`ErrorCode`] plus a human-readable message.
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
250#[serde(rename_all = "camelCase")]
251pub struct CommandError {
252    /// Machine-readable category.
253    pub code: ErrorCode,
254    /// Free-form detail; not for parsing.
255    pub message: String,
256}
257
258/// Classifier for [`CommandError`]. `#[non_exhaustive]` so new codes are
259/// additive.
260#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
261#[serde(rename_all = "camelCase")]
262pub enum ErrorCode {
263    /// Content hash from `path` did not match the requested `cid`.
264    CidMismatch,
265    /// `path` does not exist or is not readable.
266    PathNotFound,
267    /// Tried to operate on a `(rid, cid)` that is not seeded.
268    NotSeeding,
269    /// Local I/O failure.
270    Io,
271    /// Iroh networking or store failure.
272    Iroh,
273    /// Wire-level decode failure: the command line was not valid JSON,
274    /// or a typed field (rid, cid, …) failed to parse. The accompanying
275    /// `message` surfaces the underlying serde error.
276    InvalidRequest,
277    /// `Export` (or a fetch fast path) needed local bytes that the store
278    /// does not hold completely.
279    NotLocal,
280    /// A `Fetch`/`Download` was given no usable locations to try.
281    NoLocations,
282    /// Every provider/URL a `Fetch`/`Download` tried failed; `message`
283    /// lists them.
284    AllFailed,
285    /// Bug or unhandled state inside the node.
286    Internal,
287}
288
289/// Successful result of [`Command::Seed`].
290#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
291#[serde(rename_all = "camelCase")]
292pub struct SeedReceipt {
293    /// Echo of the requested repository.
294    pub rid: RepoId,
295    /// Echo of the requested CID.
296    pub cid: Cid,
297    /// Endpoint id the node is serving on, as a canonical `radiroh://<base32>` URL.
298    pub endpoint_id: EndpointId,
299    /// Logical size of the imported artifact in bytes.
300    pub bytes: u64,
301    /// `true` if this call newly tagged the pair; `false` if it was
302    /// already tagged before the call.
303    pub was_new: bool,
304}
305
306/// Successful result of [`Command::Unseed`].
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
308#[serde(rename_all = "camelCase")]
309pub struct UnseedReceipt {
310    /// Echo of the requested repository.
311    pub rid: RepoId,
312    /// Echo of the requested CID.
313    pub cid: Cid,
314    /// `true` if a tag was removed; `false` if no tag existed.
315    pub was_removed: bool,
316}
317
318/// One entry returned by [`Command::ListSeeded`].
319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
320#[serde(rename_all = "camelCase")]
321pub struct SeededEntry {
322    /// Content identifier currently tagged under the requested rid.
323    pub cid: Cid,
324    /// Logical artifact size in bytes. Best-effort — zero if the iroh
325    /// status call temporarily fails.
326    pub bytes: u64,
327    /// Whether the store actually holds every byte. A seeded tag records
328    /// intent, so it can outlive the bytes it points at (a wiped store, a
329    /// restored backup); this reports what is on disk. `None` means the
330    /// lookup failed, or the node predates the field — treat it as
331    /// unknown, never as missing.
332    #[serde(default)]
333    pub complete: Option<bool>,
334    /// Releases that seed this CID. One CID can belong to several releases
335    /// of the repository. Empty when the node predates the field.
336    #[serde(default)]
337    pub releases: Vec<Oid>,
338}
339
340/// Result of [`Command::Has`].
341#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
342#[serde(rename_all = "camelCase")]
343pub struct HasResult {
344    /// Some bytes for this CID are in the store.
345    pub present: bool,
346    /// The content is fully downloaded.
347    pub complete: bool,
348    /// Logical size known so far, in bytes.
349    pub bytes: u64,
350}
351
352/// Terminal result of [`Command::Export`].
353#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
354#[serde(rename_all = "camelCase")]
355pub struct ExportReceipt {
356    /// Echo of the exported CID.
357    pub cid: Cid,
358    /// Where the bytes were written.
359    pub dest: PathBuf,
360    /// Logical size exported, in bytes.
361    pub bytes: u64,
362}
363
364/// Terminal result of [`Command::Fetch`].
365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
366#[serde(rename_all = "camelCase")]
367pub struct FetchReceipt {
368    /// Echo of the requested repository.
369    pub rid: RepoId,
370    /// Echo of the fetched CID.
371    pub cid: Cid,
372    /// Logical size now complete in the store, in bytes.
373    pub bytes: u64,
374    /// `true` if the bytes were already local; no network was used.
375    pub from_cache: bool,
376    /// `true` if a `seeded/{rid}/{release}/{cid}` tag is now set.
377    pub seeded: bool,
378    /// Endpoint id the node serves on, as a canonical `radiroh://<base32>`
379    /// URL. Present so the caller can write the `add_location` COB after a
380    /// seeding fetch. Mirrors [`SeedReceipt::endpoint_id`].
381    pub endpoint_id: EndpointId,
382}
383
384/// Terminal result of [`Command::Download`].
385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
386#[serde(rename_all = "camelCase")]
387pub struct DownloadReceipt {
388    /// Echo of the requested repository.
389    pub rid: RepoId,
390    /// Echo of the downloaded CID.
391    pub cid: Cid,
392    /// Where the bytes were written.
393    pub dest: PathBuf,
394    /// Logical size exported, in bytes.
395    pub bytes: u64,
396    /// `true` if the bytes were already local; no network was used.
397    pub from_cache: bool,
398    /// `true` if a `seeded/{rid}/{release}/{cid}` tag is now set.
399    pub seeded: bool,
400    /// Endpoint id the node serves on, as a canonical `radiroh://<base32>`
401    /// URL. Present so the caller can write the `add_location` COB after a
402    /// seeding download. Mirrors [`SeedReceipt::endpoint_id`].
403    pub endpoint_id: EndpointId,
404}
405
406/// Successful result of [`Command::Status`]. See the design doc for the
407/// per-field source recipes; in v1 only the obvious fields are wired and
408/// the rest default to zero.
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
410#[serde(rename_all = "camelCase")]
411pub struct Status {
412    /// Endpoint id the node is serving on, as a canonical `radiroh://<base32>` URL.
413    pub endpoint_id: EndpointId,
414    /// Version of the running `rad-artifact-node`. `None` when the node
415    /// predates the field.
416    #[serde(default)]
417    pub version: Option<String>,
418    /// Unix timestamp (seconds) when the node bound its socket.
419    pub started_at_unix: i64,
420    /// Aggregated tag-level stats.
421    pub seeded: SeededStats,
422    /// Connection counters derived from iroh's metrics.
423    pub connections: ConnectionStats,
424    /// Bytes-on-the-wire counters from iroh's socket metrics.
425    pub traffic: TrafficStats,
426    /// Home-relay connectivity and measured latency.
427    pub relay: RelayStats,
428    /// Pkarr key and the pkarr relays the node publishes to. `None` when the
429    /// node predates the field.
430    #[serde(default)]
431    pub pkarr: Option<PkarrStats>,
432    /// Soft warnings rendered as advice to the user.
433    pub warnings: Warnings,
434}
435
436/// Aggregated `seeded/{rid}/{release}/{cid}` tag stats across all repos.
437#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
438#[serde(rename_all = "camelCase")]
439pub struct SeededStats {
440    /// Number of distinct seeded blobs. A blob tagged in several releases
441    /// counts once.
442    pub count: usize,
443    /// Sum of logical sizes across distinct seeded blobs.
444    pub bytes_logical: u64,
445}
446
447/// QUIC connection counters from iroh's socket metrics.
448#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
449#[serde(rename_all = "camelCase")]
450pub struct ConnectionStats {
451    /// Currently open connections (`opened_total - closed_total`).
452    pub active: u32,
453    /// Lifetime opened-handshaked count (excludes 0-RTT).
454    pub opened_total: u64,
455    /// Lifetime closed count.
456    pub closed_total: u64,
457    /// Lifetime count of direct (non-relayed) connections.
458    pub direct_total: u64,
459    /// Lifetime count of holepunch attempts (client-side increments only).
460    pub holepunch_attempts: u64,
461    /// Path counter: direct.
462    pub paths_direct: u64,
463    /// Path counter: relayed.
464    pub paths_relayed: u64,
465}
466
467/// Bytes-on-the-wire counters from iroh's socket metrics. See the design
468/// doc for the disco-vs-data semantics — `out_bytes` includes disco
469/// frames, `in_bytes` excludes them.
470#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
471#[serde(rename_all = "camelCase")]
472pub struct TrafficStats {
473    /// Bytes sent across ipv4/ipv6/relay (includes disco).
474    pub out_bytes: u64,
475    /// Bytes received across ipv4/ipv6/relay/custom (data only).
476    pub in_bytes: u64,
477}
478
479/// Home-relay connectivity. The relay is how peers that can't holepunch a
480/// direct path reach this node, so a disconnected relay means reduced
481/// reachability even while the local socket is bound.
482#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
483#[serde(rename_all = "camelCase")]
484pub struct RelayStats {
485    /// Per-home-relay status. Empty before a relay is selected, or when
486    /// relays are disabled.
487    pub relays: Vec<RelayHealth>,
488    /// URL of the lowest-latency relay net_report would prefer, if a report
489    /// has landed yet.
490    pub preferred: Option<String>,
491    /// A QAD (UDP) round trip completed over IPv4 — i.e. direct UDP works.
492    pub udp_v4: bool,
493    /// A QAD (UDP) round trip completed over IPv6.
494    pub udp_v6: bool,
495}
496
497/// Connection status and measured latency of a single home relay.
498#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
499#[serde(rename_all = "camelCase")]
500pub struct RelayHealth {
501    /// Relay URL.
502    pub url: String,
503    /// `true` when the endpoint currently holds a connection to the relay.
504    pub connected: bool,
505    /// Lowest round-trip latency measured by net_report, in milliseconds.
506    /// `None` until a probe lands.
507    pub latency_ms: Option<u64>,
508    /// Most recent connection error when disconnected; `None` when connected
509    /// or before any failure was observed.
510    pub last_error: Option<String>,
511}
512
513/// Pkarr identity of the node. Peers resolve the node's home relay through
514/// these pkarr relays.
515#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
516#[serde(rename_all = "camelCase")]
517pub struct PkarrStats {
518    /// The node's key as a `pk:<z32>` URI.
519    pub uri: String,
520    /// Pkarr relays (HTTP servers, not iroh relays) the node publishes to
521    /// and resolves from.
522    pub relays: Vec<Url>,
523}
524
525/// Soft warnings surfaced in `Status`, rendered as advice to the user.
526#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
527#[serde(rename_all = "camelCase")]
528pub struct Warnings {
529    /// Set when no home relay is connected; peers that can't holepunch may
530    /// be unable to reach this node.
531    pub relay_unreachable: bool,
532}
533
534#[cfg(test)]
535mod tests {
536    use std::path::PathBuf;
537    use std::str::FromStr;
538
539    use serde_json::json;
540
541    use super::*;
542
543    /// Real RepoId used as a wire-snapshot fixture.
544    const SAMPLE_RID: &str = "rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip";
545
546    fn sample_rid() -> RepoId {
547        RepoId::from_str(SAMPLE_RID).unwrap()
548    }
549
550    /// Release Oid (a COB ObjectId's git hash) used as a wire-snapshot fixture.
551    const SAMPLE_RELEASE: &str = "0123456789abcdef0123456789abcdef01234567";
552
553    fn sample_release() -> Oid {
554        Oid::from_str(SAMPLE_RELEASE).unwrap()
555    }
556
557    /// Real Blake3/raw Cid for wire snapshots — built from a pinned
558    /// preimage so the multibase string stays stable across runs.
559    fn sample_cid() -> Cid {
560        let digest = blake3::hash(b"protocol-cid-sample");
561        let mh =
562            cid::multihash::Multihash::<64>::wrap(crate::cid::HASH_CODE_BLAKE3, digest.as_bytes())
563                .unwrap();
564        Cid::from(cid::Cid::new_v1(crate::cid::RAW_CODEC, mh))
565    }
566
567    /// Catch-all check that every top-level type round-trips through
568    /// JSON and the encoding stays bit-for-bit stable. If you add a
569    /// field or change a tag, expect to update the literals here.
570    #[test]
571    fn wire_snapshot_command_status() {
572        let cmd = Command::Status;
573        let s = serde_json::to_string(&cmd).unwrap();
574        assert_eq!(s, r#"{"command":"status"}"#);
575        let back: Command = serde_json::from_str(&s).unwrap();
576        assert_eq!(back, cmd);
577    }
578
579    #[test]
580    fn wire_snapshot_command_alive() {
581        let cmd = Command::Alive;
582        let s = serde_json::to_string(&cmd).unwrap();
583        assert_eq!(s, r#"{"command":"alive"}"#);
584        let back: Command = serde_json::from_str(&s).unwrap();
585        assert_eq!(back, cmd);
586    }
587
588    #[test]
589    fn wire_snapshot_command_seed() {
590        let cid = sample_cid();
591        let cmd = Command::Seed {
592            rid: sample_rid(),
593            release: sample_release(),
594            cid,
595            path: PathBuf::from("/tmp/a"),
596            kind: ArtifactKind::Blob,
597            mode: ImportMode::Copy,
598        };
599        let s = serde_json::to_value(&cmd).unwrap();
600        assert_eq!(
601            s,
602            json!({
603                "command": "seed",
604                "rid": SAMPLE_RID,
605                "release": SAMPLE_RELEASE,
606                "cid": cid.to_string(),
607                "path": "/tmp/a",
608                "kind": "blob",
609                "mode": "copy",
610            })
611        );
612    }
613
614    #[test]
615    fn wire_snapshot_command_unseed_and_lookups() {
616        let cid = sample_cid();
617        // `release: None` (stop seeding the CID everywhere) omits the field.
618        let unseed = Command::Unseed {
619            rid: sample_rid(),
620            release: None,
621            cid,
622        };
623        assert_eq!(
624            serde_json::to_value(&unseed).unwrap(),
625            json!({"command":"unseed", "rid": SAMPLE_RID, "cid": cid.to_string()})
626        );
627
628        // `release: Some(..)` (one release) carries the id.
629        let unseed_one = Command::Unseed {
630            rid: sample_rid(),
631            release: Some(sample_release()),
632            cid,
633        };
634        assert_eq!(
635            serde_json::to_value(&unseed_one).unwrap(),
636            json!({"command":"unseed", "rid": SAMPLE_RID, "release": SAMPLE_RELEASE, "cid": cid.to_string()})
637        );
638
639        let is_seeding = Command::IsSeeding {
640            rid: sample_rid(),
641            cid,
642        };
643        assert_eq!(
644            serde_json::to_value(&is_seeding).unwrap(),
645            json!({"command":"isSeeding", "rid": SAMPLE_RID, "cid": cid.to_string()})
646        );
647
648        let list = Command::ListSeeded { rid: sample_rid() };
649        assert_eq!(
650            serde_json::to_value(&list).unwrap(),
651            json!({"command":"listSeeded", "rid": SAMPLE_RID})
652        );
653
654        let shutdown = Command::Shutdown;
655        assert_eq!(
656            serde_json::to_value(&shutdown).unwrap(),
657            json!({"command":"shutdown"})
658        );
659    }
660
661    #[test]
662    fn wire_snapshot_command_result_ok_and_err() {
663        let ok: CommandResult<u32> = CommandResult::Okay(7);
664        assert_eq!(serde_json::to_value(&ok).unwrap(), json!({"okay": 7}));
665
666        let err: CommandResult<u32> = CommandResult::Error(CommandError {
667            code: ErrorCode::CidMismatch,
668            message: "expected != actual".into(),
669        });
670        assert_eq!(
671            serde_json::to_value(&err).unwrap(),
672            json!({"error": {"code": "cidMismatch", "message": "expected != actual"}})
673        );
674    }
675
676    #[test]
677    fn wire_snapshot_receipts() {
678        let endpoint_id = sample_endpoint_id();
679        let cid = sample_cid();
680        let seed = SeedReceipt {
681            rid: sample_rid(),
682            cid,
683            endpoint_id,
684            bytes: 42,
685            was_new: true,
686        };
687        assert_eq!(
688            serde_json::to_value(&seed).unwrap(),
689            json!({
690                "rid": SAMPLE_RID,
691                "cid": cid.to_string(),
692                // Serialized as the canonical radiroh:// URL form.
693                "endpointId": endpoint_id.to_string(),
694                "bytes": 42,
695                "wasNew": true,
696            })
697        );
698        // Round-trips back to the same typed value.
699        let back: SeedReceipt =
700            serde_json::from_value(serde_json::to_value(&seed).unwrap()).unwrap();
701        assert_eq!(back, seed);
702
703        let unseed = UnseedReceipt {
704            rid: sample_rid(),
705            cid,
706            was_removed: false,
707        };
708        assert_eq!(
709            serde_json::to_value(&unseed).unwrap(),
710            json!({"rid": SAMPLE_RID, "cid": cid.to_string(), "wasRemoved": false})
711        );
712
713        let entry = SeededEntry {
714            cid,
715            bytes: 1024,
716            complete: Some(true),
717            releases: vec![sample_release()],
718        };
719        assert_eq!(
720            serde_json::to_value(&entry).unwrap(),
721            json!({
722                "cid": cid.to_string(),
723                "bytes": 1024,
724                "complete": true,
725                "releases": [SAMPLE_RELEASE],
726            })
727        );
728
729        // An older node omits `releases`; it decodes as empty.
730        let old: SeededEntry = serde_json::from_value(
731            json!({"cid": cid.to_string(), "bytes": 1024, "complete": true}),
732        )
733        .unwrap();
734        assert!(old.releases.is_empty());
735    }
736
737    /// Fixed endpoint id for wire snapshots; derived from a pinned secret.
738    fn sample_endpoint_id() -> EndpointId {
739        iroh_base::SecretKey::from_bytes(&[7u8; 32]).public().into()
740    }
741
742    #[test]
743    fn wire_snapshot_status_zeroed() {
744        let endpoint_id = sample_endpoint_id();
745        let st = Status {
746            endpoint_id,
747            version: Some("0.0.0".into()),
748            started_at_unix: 0,
749            seeded: SeededStats::default(),
750            connections: ConnectionStats::default(),
751            traffic: TrafficStats::default(),
752            relay: RelayStats::default(),
753            pkarr: Some(PkarrStats {
754                uri: endpoint_id.to_pkarr_uri(),
755                relays: vec![Url::parse("https://dns.example.org/pkarr").unwrap()],
756            }),
757            warnings: Warnings::default(),
758        };
759        assert_eq!(
760            serde_json::to_value(&st).unwrap(),
761            json!({
762                "endpointId": endpoint_id.to_string(),
763                "version": "0.0.0",
764                "startedAtUnix": 0,
765                "seeded": {"count": 0, "bytesLogical": 0},
766                "connections": {
767                    "active": 0,
768                    "openedTotal": 0,
769                    "closedTotal": 0,
770                    "directTotal": 0,
771                    "holepunchAttempts": 0,
772                    "pathsDirect": 0,
773                    "pathsRelayed": 0,
774                },
775                "traffic": {"outBytes": 0, "inBytes": 0},
776                "relay": {
777                    "relays": [],
778                    "preferred": null,
779                    "udpV4": false,
780                    "udpV6": false,
781                },
782                "pkarr": {
783                    "uri": endpoint_id.to_pkarr_uri(),
784                    "relays": ["https://dns.example.org/pkarr"],
785                },
786                "warnings": {"relayUnreachable": false},
787            })
788        );
789    }
790
791    #[test]
792    fn status_without_optional_fields_decodes() {
793        // A node that predates the `pkarr` and `version` fields must still decode.
794        let endpoint_id = sample_endpoint_id();
795        let st: Status = serde_json::from_value(json!({
796            "endpointId": endpoint_id.to_string(),
797            "startedAtUnix": 0,
798            "seeded": {"count": 0, "bytesLogical": 0},
799            "connections": {
800                "active": 0,
801                "openedTotal": 0,
802                "closedTotal": 0,
803                "directTotal": 0,
804                "holepunchAttempts": 0,
805                "pathsDirect": 0,
806                "pathsRelayed": 0,
807            },
808            "traffic": {"outBytes": 0, "inBytes": 0},
809            "relay": {"relays": [], "preferred": null, "udpV4": false, "udpV6": false},
810            "warnings": {"relayUnreachable": false},
811        }))
812        .unwrap();
813        assert_eq!(st.pkarr, None);
814        assert_eq!(st.version, None);
815    }
816
817    #[test]
818    fn wire_snapshot_command_has_export_fetch_download() {
819        let cid = sample_cid();
820        let endpoint_id = sample_endpoint_id();
821
822        let has = Command::Has { cid };
823        assert_eq!(
824            serde_json::to_value(&has).unwrap(),
825            json!({"command": "has", "cid": cid.to_string()})
826        );
827
828        let export = Command::Export {
829            cid,
830            dest: PathBuf::from("/tmp/out"),
831        };
832        assert_eq!(
833            serde_json::to_value(&export).unwrap(),
834            json!({"command": "export", "cid": cid.to_string(), "dest": "/tmp/out"})
835        );
836
837        // Fetch is store-only: no `dest` field on the wire. `seed: Some(..)`
838        // carries the release to seed under.
839        let fetch = Command::Fetch {
840            rid: sample_rid(),
841            cid,
842            locations: vec![
843                FetchLocation::Iroh(endpoint_id),
844                FetchLocation::Url(Url::parse("https://e.x/f").unwrap()),
845            ],
846            seed: Some(sample_release()),
847        };
848        assert_eq!(
849            serde_json::to_value(&fetch).unwrap(),
850            json!({
851                "command": "fetch",
852                "rid": SAMPLE_RID,
853                "cid": cid.to_string(),
854                "locations": [
855                    {"iroh": endpoint_id.to_string()},
856                    {"url": "https://e.x/f"},
857                ],
858                "seed": SAMPLE_RELEASE,
859            })
860        );
861        let back: Command = serde_json::from_value(serde_json::to_value(&fetch).unwrap()).unwrap();
862        assert_eq!(back, fetch);
863
864        // Download adds `dest`; `seed: None` omits the field entirely.
865        let download = Command::Download {
866            rid: sample_rid(),
867            cid,
868            locations: vec![FetchLocation::Iroh(endpoint_id)],
869            dest: PathBuf::from("/tmp/out"),
870            seed: None,
871        };
872        assert_eq!(
873            serde_json::to_value(&download).unwrap(),
874            json!({
875                "command": "download",
876                "rid": SAMPLE_RID,
877                "cid": cid.to_string(),
878                "locations": [{"iroh": endpoint_id.to_string()}],
879                "dest": "/tmp/out",
880            })
881        );
882        let back: Command =
883            serde_json::from_value(serde_json::to_value(&download).unwrap()).unwrap();
884        assert_eq!(back, download);
885    }
886
887    #[test]
888    fn wire_snapshot_stream_event() {
889        // Terminal tags match CommandResult; `progress` is the new frame.
890        let progress: StreamEvent<u32> = StreamEvent::Progress(FetchProgress::Connecting);
891        assert_eq!(
892            serde_json::to_value(&progress).unwrap(),
893            json!({"progress": {"kind": "connecting"}})
894        );
895
896        let ok: StreamEvent<u32> = StreamEvent::Okay(7);
897        assert_eq!(serde_json::to_value(&ok).unwrap(), json!({"okay": 7}));
898
899        let err: StreamEvent<u32> = StreamEvent::Error(CommandError {
900            code: ErrorCode::AllFailed,
901            message: "no locations".into(),
902        });
903        assert_eq!(
904            serde_json::to_value(&err).unwrap(),
905            json!({"error": {"code": "allFailed", "message": "no locations"}})
906        );
907    }
908
909    #[test]
910    fn wire_snapshot_fetch_progress() {
911        let endpoint_id = sample_endpoint_id();
912        let cases = [
913            (FetchProgress::Connecting, json!({"kind": "connecting"})),
914            (
915                FetchProgress::TryingLocation { endpoint_id },
916                json!({"kind": "tryingLocation", "endpointId": endpoint_id.to_string()}),
917            ),
918            (
919                FetchProgress::LocationFailed { endpoint_id },
920                json!({"kind": "locationFailed", "endpointId": endpoint_id.to_string()}),
921            ),
922            (
923                FetchProgress::Downloading {
924                    offset: 65536,
925                    total: Some(1048576),
926                },
927                json!({"kind": "downloading", "offset": 65536, "total": 1048576}),
928            ),
929            (
930                FetchProgress::Exporting {
931                    offset: 10,
932                    total: None,
933                    entry: Some("a/b.txt".into()),
934                },
935                json!({"kind": "exporting", "offset": 10, "total": null, "entry": "a/b.txt"}),
936            ),
937        ];
938        for (value, expected) in cases {
939            assert_eq!(serde_json::to_value(&value).unwrap(), expected);
940            let back: FetchProgress =
941                serde_json::from_value(serde_json::to_value(&value).unwrap()).unwrap();
942            assert_eq!(back, value);
943        }
944    }
945
946    #[test]
947    fn wire_snapshot_fetch_results() {
948        let cid = sample_cid();
949        let endpoint_id = sample_endpoint_id();
950
951        let has = HasResult {
952            present: true,
953            complete: false,
954            bytes: 1024,
955        };
956        assert_eq!(
957            serde_json::to_value(&has).unwrap(),
958            json!({"present": true, "complete": false, "bytes": 1024})
959        );
960
961        let export = ExportReceipt {
962            cid,
963            dest: PathBuf::from("/tmp/out"),
964            bytes: 2048,
965        };
966        assert_eq!(
967            serde_json::to_value(&export).unwrap(),
968            json!({"cid": cid.to_string(), "dest": "/tmp/out", "bytes": 2048})
969        );
970
971        // Fetch receipt has no `dest`; `bytes` is the logical store size.
972        let fetch = FetchReceipt {
973            rid: sample_rid(),
974            cid,
975            bytes: 4096,
976            from_cache: false,
977            seeded: true,
978            endpoint_id,
979        };
980        assert_eq!(
981            serde_json::to_value(&fetch).unwrap(),
982            json!({
983                "rid": SAMPLE_RID,
984                "cid": cid.to_string(),
985                "bytes": 4096,
986                "fromCache": false,
987                "seeded": true,
988                "endpointId": endpoint_id.to_string(),
989            })
990        );
991        let back: FetchReceipt =
992            serde_json::from_value(serde_json::to_value(&fetch).unwrap()).unwrap();
993        assert_eq!(back, fetch);
994
995        // Download receipt adds `dest`.
996        let download = DownloadReceipt {
997            rid: sample_rid(),
998            cid,
999            dest: PathBuf::from("/tmp/out"),
1000            bytes: 4096,
1001            from_cache: true,
1002            seeded: false,
1003            endpoint_id,
1004        };
1005        assert_eq!(
1006            serde_json::to_value(&download).unwrap(),
1007            json!({
1008                "rid": SAMPLE_RID,
1009                "cid": cid.to_string(),
1010                "dest": "/tmp/out",
1011                "bytes": 4096,
1012                "fromCache": true,
1013                "seeded": false,
1014                "endpointId": endpoint_id.to_string(),
1015            })
1016        );
1017        let back: DownloadReceipt =
1018            serde_json::from_value(serde_json::to_value(&download).unwrap()).unwrap();
1019        assert_eq!(back, download);
1020    }
1021}