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}
335
336/// Result of [`Command::Has`].
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338#[serde(rename_all = "camelCase")]
339pub struct HasResult {
340 /// Some bytes for this CID are in the store.
341 pub present: bool,
342 /// The content is fully downloaded.
343 pub complete: bool,
344 /// Logical size known so far, in bytes.
345 pub bytes: u64,
346}
347
348/// Terminal result of [`Command::Export`].
349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
350#[serde(rename_all = "camelCase")]
351pub struct ExportReceipt {
352 /// Echo of the exported CID.
353 pub cid: Cid,
354 /// Where the bytes were written.
355 pub dest: PathBuf,
356 /// Logical size exported, in bytes.
357 pub bytes: u64,
358}
359
360/// Terminal result of [`Command::Fetch`].
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
362#[serde(rename_all = "camelCase")]
363pub struct FetchReceipt {
364 /// Echo of the requested repository.
365 pub rid: RepoId,
366 /// Echo of the fetched CID.
367 pub cid: Cid,
368 /// Logical size now complete in the store, in bytes.
369 pub bytes: u64,
370 /// `true` if the bytes were already local; no network was used.
371 pub from_cache: bool,
372 /// `true` if a `seeded/{rid}/{release}/{cid}` tag is now set.
373 pub seeded: bool,
374 /// Endpoint id the node serves on, as a canonical `radiroh://<base32>`
375 /// URL. Present so the caller can write the `add_location` COB after a
376 /// seeding fetch. Mirrors [`SeedReceipt::endpoint_id`].
377 pub endpoint_id: EndpointId,
378}
379
380/// Terminal result of [`Command::Download`].
381#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
382#[serde(rename_all = "camelCase")]
383pub struct DownloadReceipt {
384 /// Echo of the requested repository.
385 pub rid: RepoId,
386 /// Echo of the downloaded CID.
387 pub cid: Cid,
388 /// Where the bytes were written.
389 pub dest: PathBuf,
390 /// Logical size exported, in bytes.
391 pub bytes: u64,
392 /// `true` if the bytes were already local; no network was used.
393 pub from_cache: bool,
394 /// `true` if a `seeded/{rid}/{release}/{cid}` tag is now set.
395 pub seeded: bool,
396 /// Endpoint id the node serves on, as a canonical `radiroh://<base32>`
397 /// URL. Present so the caller can write the `add_location` COB after a
398 /// seeding download. Mirrors [`SeedReceipt::endpoint_id`].
399 pub endpoint_id: EndpointId,
400}
401
402/// Successful result of [`Command::Status`]. See the design doc for the
403/// per-field source recipes; in v1 only the obvious fields are wired and
404/// the rest default to zero.
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
406#[serde(rename_all = "camelCase")]
407pub struct Status {
408 /// Endpoint id the node is serving on, as a canonical `radiroh://<base32>` URL.
409 pub endpoint_id: EndpointId,
410 /// Unix timestamp (seconds) when the node bound its socket.
411 pub started_at_unix: i64,
412 /// Aggregated tag-level stats.
413 pub seeded: SeededStats,
414 /// Connection counters derived from iroh's metrics.
415 pub connections: ConnectionStats,
416 /// Bytes-on-the-wire counters from iroh's socket metrics.
417 pub traffic: TrafficStats,
418 /// Home-relay connectivity and measured latency.
419 pub relay: RelayStats,
420 /// Pkarr key and the pkarr relays the node publishes to. `None` when the
421 /// node predates the field.
422 #[serde(default)]
423 pub pkarr: Option<PkarrStats>,
424 /// Soft warnings rendered as advice to the user.
425 pub warnings: Warnings,
426}
427
428/// Aggregated `seeded/{rid}/{release}/{cid}` tag stats across all repos.
429#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
430#[serde(rename_all = "camelCase")]
431pub struct SeededStats {
432 /// Number of distinct seeded blobs. A blob tagged in several releases
433 /// counts once.
434 pub count: usize,
435 /// Sum of logical sizes across distinct seeded blobs.
436 pub bytes_logical: u64,
437}
438
439/// QUIC connection counters from iroh's socket metrics.
440#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
441#[serde(rename_all = "camelCase")]
442pub struct ConnectionStats {
443 /// Currently open connections (`opened_total - closed_total`).
444 pub active: u32,
445 /// Lifetime opened-handshaked count (excludes 0-RTT).
446 pub opened_total: u64,
447 /// Lifetime closed count.
448 pub closed_total: u64,
449 /// Lifetime count of direct (non-relayed) connections.
450 pub direct_total: u64,
451 /// Lifetime count of holepunch attempts (client-side increments only).
452 pub holepunch_attempts: u64,
453 /// Path counter: direct.
454 pub paths_direct: u64,
455 /// Path counter: relayed.
456 pub paths_relayed: u64,
457}
458
459/// Bytes-on-the-wire counters from iroh's socket metrics. See the design
460/// doc for the disco-vs-data semantics — `out_bytes` includes disco
461/// frames, `in_bytes` excludes them.
462#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
463#[serde(rename_all = "camelCase")]
464pub struct TrafficStats {
465 /// Bytes sent across ipv4/ipv6/relay (includes disco).
466 pub out_bytes: u64,
467 /// Bytes received across ipv4/ipv6/relay/custom (data only).
468 pub in_bytes: u64,
469}
470
471/// Home-relay connectivity. The relay is how peers that can't holepunch a
472/// direct path reach this node, so a disconnected relay means reduced
473/// reachability even while the local socket is bound.
474#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
475#[serde(rename_all = "camelCase")]
476pub struct RelayStats {
477 /// Per-home-relay status. Empty before a relay is selected, or when
478 /// relays are disabled.
479 pub relays: Vec<RelayHealth>,
480 /// URL of the lowest-latency relay net_report would prefer, if a report
481 /// has landed yet.
482 pub preferred: Option<String>,
483 /// A QAD (UDP) round trip completed over IPv4 — i.e. direct UDP works.
484 pub udp_v4: bool,
485 /// A QAD (UDP) round trip completed over IPv6.
486 pub udp_v6: bool,
487}
488
489/// Connection status and measured latency of a single home relay.
490#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
491#[serde(rename_all = "camelCase")]
492pub struct RelayHealth {
493 /// Relay URL.
494 pub url: String,
495 /// `true` when the endpoint currently holds a connection to the relay.
496 pub connected: bool,
497 /// Lowest round-trip latency measured by net_report, in milliseconds.
498 /// `None` until a probe lands.
499 pub latency_ms: Option<u64>,
500 /// Most recent connection error when disconnected; `None` when connected
501 /// or before any failure was observed.
502 pub last_error: Option<String>,
503}
504
505/// Pkarr identity of the node. Peers resolve the node's home relay through
506/// these pkarr relays.
507#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
508#[serde(rename_all = "camelCase")]
509pub struct PkarrStats {
510 /// The node's key as a `pk:<z32>` URI.
511 pub uri: String,
512 /// Pkarr relays (HTTP servers, not iroh relays) the node publishes to
513 /// and resolves from.
514 pub relays: Vec<Url>,
515}
516
517/// Soft warnings surfaced in `Status`, rendered as advice to the user.
518#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
519#[serde(rename_all = "camelCase")]
520pub struct Warnings {
521 /// Set when no home relay is connected; peers that can't holepunch may
522 /// be unable to reach this node.
523 pub relay_unreachable: bool,
524}
525
526#[cfg(test)]
527mod tests {
528 use std::path::PathBuf;
529 use std::str::FromStr;
530
531 use serde_json::json;
532
533 use super::*;
534
535 /// Real RepoId used as a wire-snapshot fixture.
536 const SAMPLE_RID: &str = "rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip";
537
538 fn sample_rid() -> RepoId {
539 RepoId::from_str(SAMPLE_RID).unwrap()
540 }
541
542 /// Release Oid (a COB ObjectId's git hash) used as a wire-snapshot fixture.
543 const SAMPLE_RELEASE: &str = "0123456789abcdef0123456789abcdef01234567";
544
545 fn sample_release() -> Oid {
546 Oid::from_str(SAMPLE_RELEASE).unwrap()
547 }
548
549 /// Real Blake3/raw Cid for wire snapshots — built from a pinned
550 /// preimage so the multibase string stays stable across runs.
551 fn sample_cid() -> Cid {
552 let digest = blake3::hash(b"protocol-cid-sample");
553 let mh =
554 cid::multihash::Multihash::<64>::wrap(crate::cid::HASH_CODE_BLAKE3, digest.as_bytes())
555 .unwrap();
556 Cid::from(cid::Cid::new_v1(crate::cid::RAW_CODEC, mh))
557 }
558
559 /// Catch-all check that every top-level type round-trips through
560 /// JSON and the encoding stays bit-for-bit stable. If you add a
561 /// field or change a tag, expect to update the literals here.
562 #[test]
563 fn wire_snapshot_command_status() {
564 let cmd = Command::Status;
565 let s = serde_json::to_string(&cmd).unwrap();
566 assert_eq!(s, r#"{"command":"status"}"#);
567 let back: Command = serde_json::from_str(&s).unwrap();
568 assert_eq!(back, cmd);
569 }
570
571 #[test]
572 fn wire_snapshot_command_alive() {
573 let cmd = Command::Alive;
574 let s = serde_json::to_string(&cmd).unwrap();
575 assert_eq!(s, r#"{"command":"alive"}"#);
576 let back: Command = serde_json::from_str(&s).unwrap();
577 assert_eq!(back, cmd);
578 }
579
580 #[test]
581 fn wire_snapshot_command_seed() {
582 let cid = sample_cid();
583 let cmd = Command::Seed {
584 rid: sample_rid(),
585 release: sample_release(),
586 cid,
587 path: PathBuf::from("/tmp/a"),
588 kind: ArtifactKind::Blob,
589 mode: ImportMode::Copy,
590 };
591 let s = serde_json::to_value(&cmd).unwrap();
592 assert_eq!(
593 s,
594 json!({
595 "command": "seed",
596 "rid": SAMPLE_RID,
597 "release": SAMPLE_RELEASE,
598 "cid": cid.to_string(),
599 "path": "/tmp/a",
600 "kind": "blob",
601 "mode": "copy",
602 })
603 );
604 }
605
606 #[test]
607 fn wire_snapshot_command_unseed_and_lookups() {
608 let cid = sample_cid();
609 // `release: None` (stop seeding the CID everywhere) omits the field.
610 let unseed = Command::Unseed {
611 rid: sample_rid(),
612 release: None,
613 cid,
614 };
615 assert_eq!(
616 serde_json::to_value(&unseed).unwrap(),
617 json!({"command":"unseed", "rid": SAMPLE_RID, "cid": cid.to_string()})
618 );
619
620 // `release: Some(..)` (one release) carries the id.
621 let unseed_one = Command::Unseed {
622 rid: sample_rid(),
623 release: Some(sample_release()),
624 cid,
625 };
626 assert_eq!(
627 serde_json::to_value(&unseed_one).unwrap(),
628 json!({"command":"unseed", "rid": SAMPLE_RID, "release": SAMPLE_RELEASE, "cid": cid.to_string()})
629 );
630
631 let is_seeding = Command::IsSeeding {
632 rid: sample_rid(),
633 cid,
634 };
635 assert_eq!(
636 serde_json::to_value(&is_seeding).unwrap(),
637 json!({"command":"isSeeding", "rid": SAMPLE_RID, "cid": cid.to_string()})
638 );
639
640 let list = Command::ListSeeded { rid: sample_rid() };
641 assert_eq!(
642 serde_json::to_value(&list).unwrap(),
643 json!({"command":"listSeeded", "rid": SAMPLE_RID})
644 );
645
646 let shutdown = Command::Shutdown;
647 assert_eq!(
648 serde_json::to_value(&shutdown).unwrap(),
649 json!({"command":"shutdown"})
650 );
651 }
652
653 #[test]
654 fn wire_snapshot_command_result_ok_and_err() {
655 let ok: CommandResult<u32> = CommandResult::Okay(7);
656 assert_eq!(serde_json::to_value(&ok).unwrap(), json!({"okay": 7}));
657
658 let err: CommandResult<u32> = CommandResult::Error(CommandError {
659 code: ErrorCode::CidMismatch,
660 message: "expected != actual".into(),
661 });
662 assert_eq!(
663 serde_json::to_value(&err).unwrap(),
664 json!({"error": {"code": "cidMismatch", "message": "expected != actual"}})
665 );
666 }
667
668 #[test]
669 fn wire_snapshot_receipts() {
670 let endpoint_id = sample_endpoint_id();
671 let cid = sample_cid();
672 let seed = SeedReceipt {
673 rid: sample_rid(),
674 cid,
675 endpoint_id,
676 bytes: 42,
677 was_new: true,
678 };
679 assert_eq!(
680 serde_json::to_value(&seed).unwrap(),
681 json!({
682 "rid": SAMPLE_RID,
683 "cid": cid.to_string(),
684 // Serialized as the canonical radiroh:// URL form.
685 "endpointId": endpoint_id.to_string(),
686 "bytes": 42,
687 "wasNew": true,
688 })
689 );
690 // Round-trips back to the same typed value.
691 let back: SeedReceipt =
692 serde_json::from_value(serde_json::to_value(&seed).unwrap()).unwrap();
693 assert_eq!(back, seed);
694
695 let unseed = UnseedReceipt {
696 rid: sample_rid(),
697 cid,
698 was_removed: false,
699 };
700 assert_eq!(
701 serde_json::to_value(&unseed).unwrap(),
702 json!({"rid": SAMPLE_RID, "cid": cid.to_string(), "wasRemoved": false})
703 );
704
705 let entry = SeededEntry {
706 cid,
707 bytes: 1024,
708 complete: Some(true),
709 };
710 assert_eq!(
711 serde_json::to_value(&entry).unwrap(),
712 json!({"cid": cid.to_string(), "bytes": 1024, "complete": true})
713 );
714 }
715
716 /// Fixed endpoint id for wire snapshots; derived from a pinned secret.
717 fn sample_endpoint_id() -> EndpointId {
718 iroh_base::SecretKey::from_bytes(&[7u8; 32]).public().into()
719 }
720
721 #[test]
722 fn wire_snapshot_status_zeroed() {
723 let endpoint_id = sample_endpoint_id();
724 let st = Status {
725 endpoint_id,
726 started_at_unix: 0,
727 seeded: SeededStats::default(),
728 connections: ConnectionStats::default(),
729 traffic: TrafficStats::default(),
730 relay: RelayStats::default(),
731 pkarr: Some(PkarrStats {
732 uri: endpoint_id.to_pkarr_uri(),
733 relays: vec![Url::parse("https://dns.example.org/pkarr").unwrap()],
734 }),
735 warnings: Warnings::default(),
736 };
737 assert_eq!(
738 serde_json::to_value(&st).unwrap(),
739 json!({
740 "endpointId": endpoint_id.to_string(),
741 "startedAtUnix": 0,
742 "seeded": {"count": 0, "bytesLogical": 0},
743 "connections": {
744 "active": 0,
745 "openedTotal": 0,
746 "closedTotal": 0,
747 "directTotal": 0,
748 "holepunchAttempts": 0,
749 "pathsDirect": 0,
750 "pathsRelayed": 0,
751 },
752 "traffic": {"outBytes": 0, "inBytes": 0},
753 "relay": {
754 "relays": [],
755 "preferred": null,
756 "udpV4": false,
757 "udpV6": false,
758 },
759 "pkarr": {
760 "uri": endpoint_id.to_pkarr_uri(),
761 "relays": ["https://dns.example.org/pkarr"],
762 },
763 "warnings": {"relayUnreachable": false},
764 })
765 );
766 }
767
768 #[test]
769 fn status_without_pkarr_decodes() {
770 // A node that predates the `pkarr` field must still decode.
771 let endpoint_id = sample_endpoint_id();
772 let st: Status = serde_json::from_value(json!({
773 "endpointId": endpoint_id.to_string(),
774 "startedAtUnix": 0,
775 "seeded": {"count": 0, "bytesLogical": 0},
776 "connections": {
777 "active": 0,
778 "openedTotal": 0,
779 "closedTotal": 0,
780 "directTotal": 0,
781 "holepunchAttempts": 0,
782 "pathsDirect": 0,
783 "pathsRelayed": 0,
784 },
785 "traffic": {"outBytes": 0, "inBytes": 0},
786 "relay": {"relays": [], "preferred": null, "udpV4": false, "udpV6": false},
787 "warnings": {"relayUnreachable": false},
788 }))
789 .unwrap();
790 assert_eq!(st.pkarr, None);
791 }
792
793 #[test]
794 fn wire_snapshot_command_has_export_fetch_download() {
795 let cid = sample_cid();
796 let endpoint_id = sample_endpoint_id();
797
798 let has = Command::Has { cid };
799 assert_eq!(
800 serde_json::to_value(&has).unwrap(),
801 json!({"command": "has", "cid": cid.to_string()})
802 );
803
804 let export = Command::Export {
805 cid,
806 dest: PathBuf::from("/tmp/out"),
807 };
808 assert_eq!(
809 serde_json::to_value(&export).unwrap(),
810 json!({"command": "export", "cid": cid.to_string(), "dest": "/tmp/out"})
811 );
812
813 // Fetch is store-only: no `dest` field on the wire. `seed: Some(..)`
814 // carries the release to seed under.
815 let fetch = Command::Fetch {
816 rid: sample_rid(),
817 cid,
818 locations: vec![
819 FetchLocation::Iroh(endpoint_id),
820 FetchLocation::Url(Url::parse("https://e.x/f").unwrap()),
821 ],
822 seed: Some(sample_release()),
823 };
824 assert_eq!(
825 serde_json::to_value(&fetch).unwrap(),
826 json!({
827 "command": "fetch",
828 "rid": SAMPLE_RID,
829 "cid": cid.to_string(),
830 "locations": [
831 {"iroh": endpoint_id.to_string()},
832 {"url": "https://e.x/f"},
833 ],
834 "seed": SAMPLE_RELEASE,
835 })
836 );
837 let back: Command = serde_json::from_value(serde_json::to_value(&fetch).unwrap()).unwrap();
838 assert_eq!(back, fetch);
839
840 // Download adds `dest`; `seed: None` omits the field entirely.
841 let download = Command::Download {
842 rid: sample_rid(),
843 cid,
844 locations: vec![FetchLocation::Iroh(endpoint_id)],
845 dest: PathBuf::from("/tmp/out"),
846 seed: None,
847 };
848 assert_eq!(
849 serde_json::to_value(&download).unwrap(),
850 json!({
851 "command": "download",
852 "rid": SAMPLE_RID,
853 "cid": cid.to_string(),
854 "locations": [{"iroh": endpoint_id.to_string()}],
855 "dest": "/tmp/out",
856 })
857 );
858 let back: Command =
859 serde_json::from_value(serde_json::to_value(&download).unwrap()).unwrap();
860 assert_eq!(back, download);
861 }
862
863 #[test]
864 fn wire_snapshot_stream_event() {
865 // Terminal tags match CommandResult; `progress` is the new frame.
866 let progress: StreamEvent<u32> = StreamEvent::Progress(FetchProgress::Connecting);
867 assert_eq!(
868 serde_json::to_value(&progress).unwrap(),
869 json!({"progress": {"kind": "connecting"}})
870 );
871
872 let ok: StreamEvent<u32> = StreamEvent::Okay(7);
873 assert_eq!(serde_json::to_value(&ok).unwrap(), json!({"okay": 7}));
874
875 let err: StreamEvent<u32> = StreamEvent::Error(CommandError {
876 code: ErrorCode::AllFailed,
877 message: "no locations".into(),
878 });
879 assert_eq!(
880 serde_json::to_value(&err).unwrap(),
881 json!({"error": {"code": "allFailed", "message": "no locations"}})
882 );
883 }
884
885 #[test]
886 fn wire_snapshot_fetch_progress() {
887 let endpoint_id = sample_endpoint_id();
888 let cases = [
889 (FetchProgress::Connecting, json!({"kind": "connecting"})),
890 (
891 FetchProgress::TryingLocation { endpoint_id },
892 json!({"kind": "tryingLocation", "endpointId": endpoint_id.to_string()}),
893 ),
894 (
895 FetchProgress::LocationFailed { endpoint_id },
896 json!({"kind": "locationFailed", "endpointId": endpoint_id.to_string()}),
897 ),
898 (
899 FetchProgress::Downloading {
900 offset: 65536,
901 total: Some(1048576),
902 },
903 json!({"kind": "downloading", "offset": 65536, "total": 1048576}),
904 ),
905 (
906 FetchProgress::Exporting {
907 offset: 10,
908 total: None,
909 entry: Some("a/b.txt".into()),
910 },
911 json!({"kind": "exporting", "offset": 10, "total": null, "entry": "a/b.txt"}),
912 ),
913 ];
914 for (value, expected) in cases {
915 assert_eq!(serde_json::to_value(&value).unwrap(), expected);
916 let back: FetchProgress =
917 serde_json::from_value(serde_json::to_value(&value).unwrap()).unwrap();
918 assert_eq!(back, value);
919 }
920 }
921
922 #[test]
923 fn wire_snapshot_fetch_results() {
924 let cid = sample_cid();
925 let endpoint_id = sample_endpoint_id();
926
927 let has = HasResult {
928 present: true,
929 complete: false,
930 bytes: 1024,
931 };
932 assert_eq!(
933 serde_json::to_value(&has).unwrap(),
934 json!({"present": true, "complete": false, "bytes": 1024})
935 );
936
937 let export = ExportReceipt {
938 cid,
939 dest: PathBuf::from("/tmp/out"),
940 bytes: 2048,
941 };
942 assert_eq!(
943 serde_json::to_value(&export).unwrap(),
944 json!({"cid": cid.to_string(), "dest": "/tmp/out", "bytes": 2048})
945 );
946
947 // Fetch receipt has no `dest`; `bytes` is the logical store size.
948 let fetch = FetchReceipt {
949 rid: sample_rid(),
950 cid,
951 bytes: 4096,
952 from_cache: false,
953 seeded: true,
954 endpoint_id,
955 };
956 assert_eq!(
957 serde_json::to_value(&fetch).unwrap(),
958 json!({
959 "rid": SAMPLE_RID,
960 "cid": cid.to_string(),
961 "bytes": 4096,
962 "fromCache": false,
963 "seeded": true,
964 "endpointId": endpoint_id.to_string(),
965 })
966 );
967 let back: FetchReceipt =
968 serde_json::from_value(serde_json::to_value(&fetch).unwrap()).unwrap();
969 assert_eq!(back, fetch);
970
971 // Download receipt adds `dest`.
972 let download = DownloadReceipt {
973 rid: sample_rid(),
974 cid,
975 dest: PathBuf::from("/tmp/out"),
976 bytes: 4096,
977 from_cache: true,
978 seeded: false,
979 endpoint_id,
980 };
981 assert_eq!(
982 serde_json::to_value(&download).unwrap(),
983 json!({
984 "rid": SAMPLE_RID,
985 "cid": cid.to_string(),
986 "dest": "/tmp/out",
987 "bytes": 4096,
988 "fromCache": true,
989 "seeded": false,
990 "endpointId": endpoint_id.to_string(),
991 })
992 );
993 let back: DownloadReceipt =
994 serde_json::from_value(serde_json::to_value(&download).unwrap()).unwrap();
995 assert_eq!(back, download);
996 }
997}