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 = "kebab-case")]
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, kebab-case
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 = "kebab-case")]
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 = "kebab-case")]
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 = "kebab-case")]
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 = "kebab-case")]
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(rename_all = "kebab-case", tag = "kind")]
213pub enum FetchProgress {
214    /// Endpoint/relay setup, before any Location is tried.
215    Connecting,
216    /// Now attempting this Location.
217    TryingLocation {
218        /// Endpoint being tried.
219        endpoint_id: EndpointId,
220    },
221    /// This Location failed; moving on to the next.
222    LocationFailed {
223        /// Endpoint that failed.
224        endpoint_id: EndpointId,
225    },
226    /// Byte movement during download.
227    Downloading {
228        /// Bytes downloaded so far.
229        offset: u64,
230        /// Total size, if known.
231        total: Option<u64>,
232    },
233    /// Byte movement while writing the store out to disk.
234    Exporting {
235        /// Bytes exported so far.
236        offset: u64,
237        /// Total size, if known.
238        total: Option<u64>,
239        /// Collection member being exported; `None` for a single blob.
240        entry: Option<String>,
241    },
242}
243
244/// Structured failure: an [`ErrorCode`] plus a human-readable message.
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
246pub struct CommandError {
247    /// Machine-readable category.
248    pub code: ErrorCode,
249    /// Free-form detail; not for parsing.
250    pub message: String,
251}
252
253/// Classifier for [`CommandError`]. `#[non_exhaustive]` so new codes are
254/// additive.
255#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
256#[serde(rename_all = "kebab-case")]
257pub enum ErrorCode {
258    /// Content hash from `path` did not match the requested `cid`.
259    CidMismatch,
260    /// `path` does not exist or is not readable.
261    PathNotFound,
262    /// Tried to operate on a `(rid, cid)` that is not seeded.
263    NotSeeding,
264    /// Local I/O failure.
265    Io,
266    /// Iroh networking or store failure.
267    Iroh,
268    /// Wire-level decode failure: the command line was not valid JSON,
269    /// or a typed field (rid, cid, …) failed to parse. The accompanying
270    /// `message` surfaces the underlying serde error.
271    InvalidRequest,
272    /// `Export` (or a fetch fast path) needed local bytes that the store
273    /// does not hold completely.
274    NotLocal,
275    /// A `Fetch`/`Download` was given no usable locations to try.
276    NoLocations,
277    /// Every provider/URL a `Fetch`/`Download` tried failed; `message`
278    /// lists them.
279    AllFailed,
280    /// Bug or unhandled state inside the node.
281    Internal,
282}
283
284/// Successful result of [`Command::Seed`].
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
286pub struct SeedReceipt {
287    /// Echo of the requested repository.
288    pub rid: RepoId,
289    /// Echo of the requested CID.
290    pub cid: Cid,
291    /// Endpoint id the node is serving on, as a canonical `radiroh://<base32>` URL.
292    pub endpoint_id: EndpointId,
293    /// Logical size of the imported artifact in bytes.
294    pub bytes: u64,
295    /// `true` if this call newly tagged the pair; `false` if it was
296    /// already tagged before the call.
297    pub was_new: bool,
298}
299
300/// Successful result of [`Command::Unseed`].
301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
302pub struct UnseedReceipt {
303    /// Echo of the requested repository.
304    pub rid: RepoId,
305    /// Echo of the requested CID.
306    pub cid: Cid,
307    /// `true` if a tag was removed; `false` if no tag existed.
308    pub was_removed: bool,
309}
310
311/// One entry returned by [`Command::ListSeeded`].
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
313pub struct SeededEntry {
314    /// Content identifier currently tagged under the requested rid.
315    pub cid: Cid,
316    /// Logical artifact size in bytes. Best-effort — zero if the iroh
317    /// status call temporarily fails.
318    pub bytes: u64,
319}
320
321/// Result of [`Command::Has`].
322#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
323pub struct HasResult {
324    /// Some bytes for this CID are in the store.
325    pub present: bool,
326    /// The content is fully downloaded.
327    pub complete: bool,
328    /// Logical size known so far, in bytes.
329    pub bytes: u64,
330}
331
332/// Terminal result of [`Command::Export`].
333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
334pub struct ExportReceipt {
335    /// Echo of the exported CID.
336    pub cid: Cid,
337    /// Where the bytes were written.
338    pub dest: PathBuf,
339    /// Logical size exported, in bytes.
340    pub bytes: u64,
341}
342
343/// Terminal result of [`Command::Fetch`].
344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
345pub struct FetchReceipt {
346    /// Echo of the requested repository.
347    pub rid: RepoId,
348    /// Echo of the fetched CID.
349    pub cid: Cid,
350    /// Logical size now complete in the store, in bytes.
351    pub bytes: u64,
352    /// `true` if the bytes were already local; no network was used.
353    pub from_cache: bool,
354    /// `true` if a `seeded/{rid}/{cid}` tag is now set.
355    pub seeded: bool,
356    /// Endpoint id the node serves on, as a canonical `radiroh://<base32>`
357    /// URL. Present so the caller can write the `add_location` COB after a
358    /// seeding fetch. Mirrors [`SeedReceipt::endpoint_id`].
359    pub endpoint_id: EndpointId,
360}
361
362/// Terminal result of [`Command::Download`].
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
364pub struct DownloadReceipt {
365    /// Echo of the requested repository.
366    pub rid: RepoId,
367    /// Echo of the downloaded CID.
368    pub cid: Cid,
369    /// Where the bytes were written.
370    pub dest: PathBuf,
371    /// Logical size exported, in bytes.
372    pub bytes: u64,
373    /// `true` if the bytes were already local; no network was used.
374    pub from_cache: bool,
375    /// `true` if a `seeded/{rid}/{cid}` tag is now set.
376    pub seeded: bool,
377    /// Endpoint id the node serves on, as a canonical `radiroh://<base32>`
378    /// URL. Present so the caller can write the `add_location` COB after a
379    /// seeding download. Mirrors [`SeedReceipt::endpoint_id`].
380    pub endpoint_id: EndpointId,
381}
382
383/// Successful result of [`Command::Status`]. See the design doc for the
384/// per-field source recipes; in v1 only the obvious fields are wired and
385/// the rest default to zero.
386#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
387pub struct Status {
388    /// Endpoint id the node is serving on, as a canonical `radiroh://<base32>` URL.
389    pub endpoint_id: EndpointId,
390    /// Unix timestamp (seconds) when the node bound its socket.
391    pub started_at_unix: i64,
392    /// Aggregated tag-level stats.
393    pub seeded: SeededStats,
394    /// Connection counters derived from iroh's metrics.
395    pub connections: ConnectionStats,
396    /// Bytes-on-the-wire counters from iroh's socket metrics.
397    pub traffic: TrafficStats,
398    /// Home-relay connectivity and measured latency.
399    pub relay: RelayStats,
400    /// Soft warnings rendered as advice to the user.
401    pub warnings: Warnings,
402}
403
404/// Aggregated `seeded/{rid}/{cid}` tag stats across all repos.
405#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
406pub struct SeededStats {
407    /// Number of tagged `(rid, cid)` pairs.
408    pub count: usize,
409    /// Sum of logical artifact sizes across all tags.
410    pub bytes_logical: u64,
411}
412
413/// QUIC connection counters from iroh's socket metrics.
414#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
415pub struct ConnectionStats {
416    /// Currently open connections (`opened_total - closed_total`).
417    pub active: u32,
418    /// Lifetime opened-handshaked count (excludes 0-RTT).
419    pub opened_total: u64,
420    /// Lifetime closed count.
421    pub closed_total: u64,
422    /// Lifetime count of direct (non-relayed) connections.
423    pub direct_total: u64,
424    /// Lifetime count of holepunch attempts (client-side increments only).
425    pub holepunch_attempts: u64,
426    /// Path counter: direct.
427    pub paths_direct: u64,
428    /// Path counter: relayed.
429    pub paths_relayed: u64,
430}
431
432/// Bytes-on-the-wire counters from iroh's socket metrics. See the design
433/// doc for the disco-vs-data semantics — `out_bytes` includes disco
434/// frames, `in_bytes` excludes them.
435#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
436pub struct TrafficStats {
437    /// Bytes sent across ipv4/ipv6/relay (includes disco).
438    pub out_bytes: u64,
439    /// Bytes received across ipv4/ipv6/relay/custom (data only).
440    pub in_bytes: u64,
441}
442
443/// Home-relay connectivity. The relay is how peers that can't holepunch a
444/// direct path reach this node, so a disconnected relay means reduced
445/// reachability even while the local socket is bound.
446#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
447pub struct RelayStats {
448    /// Per-home-relay status. Empty before a relay is selected, or when
449    /// relays are disabled.
450    pub relays: Vec<RelayHealth>,
451    /// URL of the lowest-latency relay net_report would prefer, if a report
452    /// has landed yet.
453    pub preferred: Option<String>,
454    /// A QAD (UDP) round trip completed over IPv4 — i.e. direct UDP works.
455    pub udp_v4: bool,
456    /// A QAD (UDP) round trip completed over IPv6.
457    pub udp_v6: bool,
458}
459
460/// Connection status and measured latency of a single home relay.
461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
462pub struct RelayHealth {
463    /// Relay URL.
464    pub url: String,
465    /// `true` when the endpoint currently holds a connection to the relay.
466    pub connected: bool,
467    /// Lowest round-trip latency measured by net_report, in milliseconds.
468    /// `None` until a probe lands.
469    pub latency_ms: Option<u64>,
470    /// Most recent connection error when disconnected; `None` when connected
471    /// or before any failure was observed.
472    pub last_error: Option<String>,
473}
474
475/// Soft warnings surfaced in `Status`, rendered as advice to the user.
476#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
477pub struct Warnings {
478    /// Set when no home relay is connected; peers that can't holepunch may
479    /// be unable to reach this node.
480    pub relay_unreachable: bool,
481}
482
483#[cfg(test)]
484mod tests {
485    use std::path::PathBuf;
486    use std::str::FromStr;
487
488    use serde_json::json;
489
490    use super::*;
491
492    /// Real RepoId used as a wire-snapshot fixture.
493    const SAMPLE_RID: &str = "rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip";
494
495    fn sample_rid() -> RepoId {
496        RepoId::from_str(SAMPLE_RID).unwrap()
497    }
498
499    /// Release Oid (a COB ObjectId's git hash) used as a wire-snapshot fixture.
500    const SAMPLE_RELEASE: &str = "0123456789abcdef0123456789abcdef01234567";
501
502    fn sample_release() -> Oid {
503        Oid::from_str(SAMPLE_RELEASE).unwrap()
504    }
505
506    /// Real Blake3/raw Cid for wire snapshots — built from a pinned
507    /// preimage so the multibase string stays stable across runs.
508    fn sample_cid() -> Cid {
509        let digest = blake3::hash(b"protocol-cid-sample");
510        let mh =
511            cid::multihash::Multihash::<64>::wrap(crate::cid::HASH_CODE_BLAKE3, digest.as_bytes())
512                .unwrap();
513        Cid::from(cid::Cid::new_v1(crate::cid::RAW_CODEC, mh))
514    }
515
516    /// Catch-all check that every top-level type round-trips through
517    /// JSON and the encoding stays bit-for-bit stable. If you add a
518    /// field or change a tag, expect to update the literals here.
519    #[test]
520    fn wire_snapshot_command_status() {
521        let cmd = Command::Status;
522        let s = serde_json::to_string(&cmd).unwrap();
523        assert_eq!(s, r#"{"command":"status"}"#);
524        let back: Command = serde_json::from_str(&s).unwrap();
525        assert_eq!(back, cmd);
526    }
527
528    #[test]
529    fn wire_snapshot_command_alive() {
530        let cmd = Command::Alive;
531        let s = serde_json::to_string(&cmd).unwrap();
532        assert_eq!(s, r#"{"command":"alive"}"#);
533        let back: Command = serde_json::from_str(&s).unwrap();
534        assert_eq!(back, cmd);
535    }
536
537    #[test]
538    fn wire_snapshot_command_seed() {
539        let cid = sample_cid();
540        let cmd = Command::Seed {
541            rid: sample_rid(),
542            release: sample_release(),
543            cid,
544            path: PathBuf::from("/tmp/a"),
545            kind: ArtifactKind::Blob,
546            mode: ImportMode::Copy,
547        };
548        let s = serde_json::to_value(&cmd).unwrap();
549        assert_eq!(
550            s,
551            json!({
552                "command": "seed",
553                "rid": SAMPLE_RID,
554                "release": SAMPLE_RELEASE,
555                "cid": cid.to_string(),
556                "path": "/tmp/a",
557                "kind": "blob",
558                "mode": "copy",
559            })
560        );
561    }
562
563    #[test]
564    fn wire_snapshot_command_unseed_and_lookups() {
565        let cid = sample_cid();
566        // `release: None` (stop seeding the CID everywhere) omits the field.
567        let unseed = Command::Unseed {
568            rid: sample_rid(),
569            release: None,
570            cid,
571        };
572        assert_eq!(
573            serde_json::to_value(&unseed).unwrap(),
574            json!({"command":"unseed", "rid": SAMPLE_RID, "cid": cid.to_string()})
575        );
576
577        // `release: Some(..)` (one release) carries the id.
578        let unseed_one = Command::Unseed {
579            rid: sample_rid(),
580            release: Some(sample_release()),
581            cid,
582        };
583        assert_eq!(
584            serde_json::to_value(&unseed_one).unwrap(),
585            json!({"command":"unseed", "rid": SAMPLE_RID, "release": SAMPLE_RELEASE, "cid": cid.to_string()})
586        );
587
588        let is_seeding = Command::IsSeeding {
589            rid: sample_rid(),
590            cid,
591        };
592        assert_eq!(
593            serde_json::to_value(&is_seeding).unwrap(),
594            json!({"command":"is-seeding", "rid": SAMPLE_RID, "cid": cid.to_string()})
595        );
596
597        let list = Command::ListSeeded { rid: sample_rid() };
598        assert_eq!(
599            serde_json::to_value(&list).unwrap(),
600            json!({"command":"list-seeded", "rid": SAMPLE_RID})
601        );
602
603        let shutdown = Command::Shutdown;
604        assert_eq!(
605            serde_json::to_value(&shutdown).unwrap(),
606            json!({"command":"shutdown"})
607        );
608    }
609
610    #[test]
611    fn wire_snapshot_command_result_ok_and_err() {
612        let ok: CommandResult<u32> = CommandResult::Okay(7);
613        assert_eq!(serde_json::to_value(&ok).unwrap(), json!({"okay": 7}));
614
615        let err: CommandResult<u32> = CommandResult::Error(CommandError {
616            code: ErrorCode::CidMismatch,
617            message: "expected != actual".into(),
618        });
619        assert_eq!(
620            serde_json::to_value(&err).unwrap(),
621            json!({"error": {"code": "cid-mismatch", "message": "expected != actual"}})
622        );
623    }
624
625    #[test]
626    fn wire_snapshot_receipts() {
627        let endpoint_id = sample_endpoint_id();
628        let cid = sample_cid();
629        let seed = SeedReceipt {
630            rid: sample_rid(),
631            cid,
632            endpoint_id,
633            bytes: 42,
634            was_new: true,
635        };
636        assert_eq!(
637            serde_json::to_value(&seed).unwrap(),
638            json!({
639                "rid": SAMPLE_RID,
640                "cid": cid.to_string(),
641                // Serialized as the canonical radiroh:// URL form.
642                "endpoint_id": endpoint_id.to_string(),
643                "bytes": 42,
644                "was_new": true,
645            })
646        );
647        // Round-trips back to the same typed value.
648        let back: SeedReceipt =
649            serde_json::from_value(serde_json::to_value(&seed).unwrap()).unwrap();
650        assert_eq!(back, seed);
651
652        let unseed = UnseedReceipt {
653            rid: sample_rid(),
654            cid,
655            was_removed: false,
656        };
657        assert_eq!(
658            serde_json::to_value(&unseed).unwrap(),
659            json!({"rid": SAMPLE_RID, "cid": cid.to_string(), "was_removed": false})
660        );
661
662        let entry = SeededEntry { cid, bytes: 1024 };
663        assert_eq!(
664            serde_json::to_value(&entry).unwrap(),
665            json!({"cid": cid.to_string(), "bytes": 1024})
666        );
667    }
668
669    /// Fixed endpoint id for wire snapshots; derived from a pinned secret.
670    fn sample_endpoint_id() -> EndpointId {
671        iroh_base::SecretKey::from_bytes(&[7u8; 32]).public().into()
672    }
673
674    #[test]
675    fn wire_snapshot_status_zeroed() {
676        let endpoint_id = sample_endpoint_id();
677        let st = Status {
678            endpoint_id,
679            started_at_unix: 0,
680            seeded: SeededStats::default(),
681            connections: ConnectionStats::default(),
682            traffic: TrafficStats::default(),
683            relay: RelayStats::default(),
684            warnings: Warnings::default(),
685        };
686        assert_eq!(
687            serde_json::to_value(&st).unwrap(),
688            json!({
689                "endpoint_id": endpoint_id.to_string(),
690                "started_at_unix": 0,
691                "seeded": {"count": 0, "bytes_logical": 0},
692                "connections": {
693                    "active": 0,
694                    "opened_total": 0,
695                    "closed_total": 0,
696                    "direct_total": 0,
697                    "holepunch_attempts": 0,
698                    "paths_direct": 0,
699                    "paths_relayed": 0,
700                },
701                "traffic": {"out_bytes": 0, "in_bytes": 0},
702                "relay": {
703                    "relays": [],
704                    "preferred": null,
705                    "udp_v4": false,
706                    "udp_v6": false,
707                },
708                "warnings": {"relay_unreachable": false},
709            })
710        );
711    }
712
713    #[test]
714    fn wire_snapshot_command_has_export_fetch_download() {
715        let cid = sample_cid();
716        let endpoint_id = sample_endpoint_id();
717
718        let has = Command::Has { cid };
719        assert_eq!(
720            serde_json::to_value(&has).unwrap(),
721            json!({"command": "has", "cid": cid.to_string()})
722        );
723
724        let export = Command::Export {
725            cid,
726            dest: PathBuf::from("/tmp/out"),
727        };
728        assert_eq!(
729            serde_json::to_value(&export).unwrap(),
730            json!({"command": "export", "cid": cid.to_string(), "dest": "/tmp/out"})
731        );
732
733        // Fetch is store-only: no `dest` field on the wire. `seed: Some(..)`
734        // carries the release to seed under.
735        let fetch = Command::Fetch {
736            rid: sample_rid(),
737            cid,
738            locations: vec![
739                FetchLocation::Iroh(endpoint_id),
740                FetchLocation::Url(Url::parse("https://e.x/f").unwrap()),
741            ],
742            seed: Some(sample_release()),
743        };
744        assert_eq!(
745            serde_json::to_value(&fetch).unwrap(),
746            json!({
747                "command": "fetch",
748                "rid": SAMPLE_RID,
749                "cid": cid.to_string(),
750                "locations": [
751                    {"iroh": endpoint_id.to_string()},
752                    {"url": "https://e.x/f"},
753                ],
754                "seed": SAMPLE_RELEASE,
755            })
756        );
757        let back: Command = serde_json::from_value(serde_json::to_value(&fetch).unwrap()).unwrap();
758        assert_eq!(back, fetch);
759
760        // Download adds `dest`; `seed: None` omits the field entirely.
761        let download = Command::Download {
762            rid: sample_rid(),
763            cid,
764            locations: vec![FetchLocation::Iroh(endpoint_id)],
765            dest: PathBuf::from("/tmp/out"),
766            seed: None,
767        };
768        assert_eq!(
769            serde_json::to_value(&download).unwrap(),
770            json!({
771                "command": "download",
772                "rid": SAMPLE_RID,
773                "cid": cid.to_string(),
774                "locations": [{"iroh": endpoint_id.to_string()}],
775                "dest": "/tmp/out",
776            })
777        );
778        let back: Command =
779            serde_json::from_value(serde_json::to_value(&download).unwrap()).unwrap();
780        assert_eq!(back, download);
781    }
782
783    #[test]
784    fn wire_snapshot_stream_event() {
785        // Terminal tags match CommandResult; `progress` is the new frame.
786        let progress: StreamEvent<u32> = StreamEvent::Progress(FetchProgress::Connecting);
787        assert_eq!(
788            serde_json::to_value(&progress).unwrap(),
789            json!({"progress": {"kind": "connecting"}})
790        );
791
792        let ok: StreamEvent<u32> = StreamEvent::Okay(7);
793        assert_eq!(serde_json::to_value(&ok).unwrap(), json!({"okay": 7}));
794
795        let err: StreamEvent<u32> = StreamEvent::Error(CommandError {
796            code: ErrorCode::AllFailed,
797            message: "no locations".into(),
798        });
799        assert_eq!(
800            serde_json::to_value(&err).unwrap(),
801            json!({"error": {"code": "all-failed", "message": "no locations"}})
802        );
803    }
804
805    #[test]
806    fn wire_snapshot_fetch_progress() {
807        let endpoint_id = sample_endpoint_id();
808        let cases = [
809            (FetchProgress::Connecting, json!({"kind": "connecting"})),
810            (
811                FetchProgress::TryingLocation { endpoint_id },
812                json!({"kind": "trying-location", "endpoint_id": endpoint_id.to_string()}),
813            ),
814            (
815                FetchProgress::LocationFailed { endpoint_id },
816                json!({"kind": "location-failed", "endpoint_id": endpoint_id.to_string()}),
817            ),
818            (
819                FetchProgress::Downloading {
820                    offset: 65536,
821                    total: Some(1048576),
822                },
823                json!({"kind": "downloading", "offset": 65536, "total": 1048576}),
824            ),
825            (
826                FetchProgress::Exporting {
827                    offset: 10,
828                    total: None,
829                    entry: Some("a/b.txt".into()),
830                },
831                json!({"kind": "exporting", "offset": 10, "total": null, "entry": "a/b.txt"}),
832            ),
833        ];
834        for (value, expected) in cases {
835            assert_eq!(serde_json::to_value(&value).unwrap(), expected);
836            let back: FetchProgress =
837                serde_json::from_value(serde_json::to_value(&value).unwrap()).unwrap();
838            assert_eq!(back, value);
839        }
840    }
841
842    #[test]
843    fn wire_snapshot_fetch_results() {
844        let cid = sample_cid();
845        let endpoint_id = sample_endpoint_id();
846
847        let has = HasResult {
848            present: true,
849            complete: false,
850            bytes: 1024,
851        };
852        assert_eq!(
853            serde_json::to_value(&has).unwrap(),
854            json!({"present": true, "complete": false, "bytes": 1024})
855        );
856
857        let export = ExportReceipt {
858            cid,
859            dest: PathBuf::from("/tmp/out"),
860            bytes: 2048,
861        };
862        assert_eq!(
863            serde_json::to_value(&export).unwrap(),
864            json!({"cid": cid.to_string(), "dest": "/tmp/out", "bytes": 2048})
865        );
866
867        // Fetch receipt has no `dest`; `bytes` is the logical store size.
868        let fetch = FetchReceipt {
869            rid: sample_rid(),
870            cid,
871            bytes: 4096,
872            from_cache: false,
873            seeded: true,
874            endpoint_id,
875        };
876        assert_eq!(
877            serde_json::to_value(&fetch).unwrap(),
878            json!({
879                "rid": SAMPLE_RID,
880                "cid": cid.to_string(),
881                "bytes": 4096,
882                "from_cache": false,
883                "seeded": true,
884                "endpoint_id": endpoint_id.to_string(),
885            })
886        );
887        let back: FetchReceipt =
888            serde_json::from_value(serde_json::to_value(&fetch).unwrap()).unwrap();
889        assert_eq!(back, fetch);
890
891        // Download receipt adds `dest`.
892        let download = DownloadReceipt {
893            rid: sample_rid(),
894            cid,
895            dest: PathBuf::from("/tmp/out"),
896            bytes: 4096,
897            from_cache: true,
898            seeded: false,
899            endpoint_id,
900        };
901        assert_eq!(
902            serde_json::to_value(&download).unwrap(),
903            json!({
904                "rid": SAMPLE_RID,
905                "cid": cid.to_string(),
906                "dest": "/tmp/out",
907                "bytes": 4096,
908                "from_cache": true,
909                "seeded": false,
910                "endpoint_id": endpoint_id.to_string(),
911            })
912        );
913        let back: DownloadReceipt =
914            serde_json::from_value(serde_json::to_value(&download).unwrap()).unwrap();
915        assert_eq!(back, download);
916    }
917}