use std::path::PathBuf;
use radicle::git::Oid;
use radicle::identity::RepoId;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::cid::{ArtifactKind, Cid};
use crate::keys::EndpointId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ImportMode {
Copy,
Reference,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "command", rename_all = "kebab-case")]
pub enum Command {
Alive,
Status,
Seed {
rid: RepoId,
release: Oid,
cid: Cid,
path: PathBuf,
kind: ArtifactKind,
mode: ImportMode,
},
Unseed {
rid: RepoId,
#[serde(default, skip_serializing_if = "Option::is_none")]
release: Option<Oid>,
cid: Cid,
},
IsSeeding {
rid: RepoId,
cid: Cid,
},
ListSeeded {
rid: RepoId,
},
Has {
cid: Cid,
},
Export {
cid: Cid,
dest: PathBuf,
},
Fetch {
rid: RepoId,
cid: Cid,
locations: Vec<FetchLocation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
seed: Option<Oid>,
},
Download {
rid: RepoId,
cid: Cid,
locations: Vec<FetchLocation>,
dest: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
seed: Option<Oid>,
},
Shutdown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum FetchLocation {
Url(Url),
Iroh(EndpointId),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CommandResult<T> {
Okay(T),
Error(CommandError),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum StreamEvent<T> {
Progress(FetchProgress),
Okay(T),
Error(CommandError),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub enum FetchProgress {
Connecting,
TryingLocation {
endpoint_id: EndpointId,
},
LocationFailed {
endpoint_id: EndpointId,
},
Downloading {
offset: u64,
total: Option<u64>,
},
Exporting {
offset: u64,
total: Option<u64>,
entry: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandError {
pub code: ErrorCode,
pub message: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ErrorCode {
CidMismatch,
PathNotFound,
NotSeeding,
Io,
Iroh,
InvalidRequest,
NotLocal,
NoLocations,
AllFailed,
Internal,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SeedReceipt {
pub rid: RepoId,
pub cid: Cid,
pub endpoint_id: EndpointId,
pub bytes: u64,
pub was_new: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UnseedReceipt {
pub rid: RepoId,
pub cid: Cid,
pub was_removed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SeededEntry {
pub cid: Cid,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HasResult {
pub present: bool,
pub complete: bool,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExportReceipt {
pub cid: Cid,
pub dest: PathBuf,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FetchReceipt {
pub rid: RepoId,
pub cid: Cid,
pub bytes: u64,
pub from_cache: bool,
pub seeded: bool,
pub endpoint_id: EndpointId,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DownloadReceipt {
pub rid: RepoId,
pub cid: Cid,
pub dest: PathBuf,
pub bytes: u64,
pub from_cache: bool,
pub seeded: bool,
pub endpoint_id: EndpointId,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Status {
pub endpoint_id: EndpointId,
pub started_at_unix: i64,
pub seeded: SeededStats,
pub connections: ConnectionStats,
pub traffic: TrafficStats,
pub relay: RelayStats,
pub warnings: Warnings,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SeededStats {
pub count: usize,
pub bytes_logical: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ConnectionStats {
pub active: u32,
pub opened_total: u64,
pub closed_total: u64,
pub direct_total: u64,
pub holepunch_attempts: u64,
pub paths_direct: u64,
pub paths_relayed: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrafficStats {
pub out_bytes: u64,
pub in_bytes: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct RelayStats {
pub relays: Vec<RelayHealth>,
pub preferred: Option<String>,
pub udp_v4: bool,
pub udp_v6: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RelayHealth {
pub url: String,
pub connected: bool,
pub latency_ms: Option<u64>,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Warnings {
pub relay_unreachable: bool,
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::str::FromStr;
use serde_json::json;
use super::*;
const SAMPLE_RID: &str = "rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip";
fn sample_rid() -> RepoId {
RepoId::from_str(SAMPLE_RID).unwrap()
}
const SAMPLE_RELEASE: &str = "0123456789abcdef0123456789abcdef01234567";
fn sample_release() -> Oid {
Oid::from_str(SAMPLE_RELEASE).unwrap()
}
fn sample_cid() -> Cid {
let digest = blake3::hash(b"protocol-cid-sample");
let mh =
cid::multihash::Multihash::<64>::wrap(crate::cid::HASH_CODE_BLAKE3, digest.as_bytes())
.unwrap();
Cid::from(cid::Cid::new_v1(crate::cid::RAW_CODEC, mh))
}
#[test]
fn wire_snapshot_command_status() {
let cmd = Command::Status;
let s = serde_json::to_string(&cmd).unwrap();
assert_eq!(s, r#"{"command":"status"}"#);
let back: Command = serde_json::from_str(&s).unwrap();
assert_eq!(back, cmd);
}
#[test]
fn wire_snapshot_command_alive() {
let cmd = Command::Alive;
let s = serde_json::to_string(&cmd).unwrap();
assert_eq!(s, r#"{"command":"alive"}"#);
let back: Command = serde_json::from_str(&s).unwrap();
assert_eq!(back, cmd);
}
#[test]
fn wire_snapshot_command_seed() {
let cid = sample_cid();
let cmd = Command::Seed {
rid: sample_rid(),
release: sample_release(),
cid,
path: PathBuf::from("/tmp/a"),
kind: ArtifactKind::Blob,
mode: ImportMode::Copy,
};
let s = serde_json::to_value(&cmd).unwrap();
assert_eq!(
s,
json!({
"command": "seed",
"rid": SAMPLE_RID,
"release": SAMPLE_RELEASE,
"cid": cid.to_string(),
"path": "/tmp/a",
"kind": "blob",
"mode": "copy",
})
);
}
#[test]
fn wire_snapshot_command_unseed_and_lookups() {
let cid = sample_cid();
let unseed = Command::Unseed {
rid: sample_rid(),
release: None,
cid,
};
assert_eq!(
serde_json::to_value(&unseed).unwrap(),
json!({"command":"unseed", "rid": SAMPLE_RID, "cid": cid.to_string()})
);
let unseed_one = Command::Unseed {
rid: sample_rid(),
release: Some(sample_release()),
cid,
};
assert_eq!(
serde_json::to_value(&unseed_one).unwrap(),
json!({"command":"unseed", "rid": SAMPLE_RID, "release": SAMPLE_RELEASE, "cid": cid.to_string()})
);
let is_seeding = Command::IsSeeding {
rid: sample_rid(),
cid,
};
assert_eq!(
serde_json::to_value(&is_seeding).unwrap(),
json!({"command":"is-seeding", "rid": SAMPLE_RID, "cid": cid.to_string()})
);
let list = Command::ListSeeded { rid: sample_rid() };
assert_eq!(
serde_json::to_value(&list).unwrap(),
json!({"command":"list-seeded", "rid": SAMPLE_RID})
);
let shutdown = Command::Shutdown;
assert_eq!(
serde_json::to_value(&shutdown).unwrap(),
json!({"command":"shutdown"})
);
}
#[test]
fn wire_snapshot_command_result_ok_and_err() {
let ok: CommandResult<u32> = CommandResult::Okay(7);
assert_eq!(serde_json::to_value(&ok).unwrap(), json!({"okay": 7}));
let err: CommandResult<u32> = CommandResult::Error(CommandError {
code: ErrorCode::CidMismatch,
message: "expected != actual".into(),
});
assert_eq!(
serde_json::to_value(&err).unwrap(),
json!({"error": {"code": "cid-mismatch", "message": "expected != actual"}})
);
}
#[test]
fn wire_snapshot_receipts() {
let endpoint_id = sample_endpoint_id();
let cid = sample_cid();
let seed = SeedReceipt {
rid: sample_rid(),
cid,
endpoint_id,
bytes: 42,
was_new: true,
};
assert_eq!(
serde_json::to_value(&seed).unwrap(),
json!({
"rid": SAMPLE_RID,
"cid": cid.to_string(),
"endpoint_id": endpoint_id.to_string(),
"bytes": 42,
"was_new": true,
})
);
let back: SeedReceipt =
serde_json::from_value(serde_json::to_value(&seed).unwrap()).unwrap();
assert_eq!(back, seed);
let unseed = UnseedReceipt {
rid: sample_rid(),
cid,
was_removed: false,
};
assert_eq!(
serde_json::to_value(&unseed).unwrap(),
json!({"rid": SAMPLE_RID, "cid": cid.to_string(), "was_removed": false})
);
let entry = SeededEntry { cid, bytes: 1024 };
assert_eq!(
serde_json::to_value(&entry).unwrap(),
json!({"cid": cid.to_string(), "bytes": 1024})
);
}
fn sample_endpoint_id() -> EndpointId {
iroh_base::SecretKey::from_bytes(&[7u8; 32]).public().into()
}
#[test]
fn wire_snapshot_status_zeroed() {
let endpoint_id = sample_endpoint_id();
let st = Status {
endpoint_id,
started_at_unix: 0,
seeded: SeededStats::default(),
connections: ConnectionStats::default(),
traffic: TrafficStats::default(),
relay: RelayStats::default(),
warnings: Warnings::default(),
};
assert_eq!(
serde_json::to_value(&st).unwrap(),
json!({
"endpoint_id": endpoint_id.to_string(),
"started_at_unix": 0,
"seeded": {"count": 0, "bytes_logical": 0},
"connections": {
"active": 0,
"opened_total": 0,
"closed_total": 0,
"direct_total": 0,
"holepunch_attempts": 0,
"paths_direct": 0,
"paths_relayed": 0,
},
"traffic": {"out_bytes": 0, "in_bytes": 0},
"relay": {
"relays": [],
"preferred": null,
"udp_v4": false,
"udp_v6": false,
},
"warnings": {"relay_unreachable": false},
})
);
}
#[test]
fn wire_snapshot_command_has_export_fetch_download() {
let cid = sample_cid();
let endpoint_id = sample_endpoint_id();
let has = Command::Has { cid };
assert_eq!(
serde_json::to_value(&has).unwrap(),
json!({"command": "has", "cid": cid.to_string()})
);
let export = Command::Export {
cid,
dest: PathBuf::from("/tmp/out"),
};
assert_eq!(
serde_json::to_value(&export).unwrap(),
json!({"command": "export", "cid": cid.to_string(), "dest": "/tmp/out"})
);
let fetch = Command::Fetch {
rid: sample_rid(),
cid,
locations: vec![
FetchLocation::Iroh(endpoint_id),
FetchLocation::Url(Url::parse("https://e.x/f").unwrap()),
],
seed: Some(sample_release()),
};
assert_eq!(
serde_json::to_value(&fetch).unwrap(),
json!({
"command": "fetch",
"rid": SAMPLE_RID,
"cid": cid.to_string(),
"locations": [
{"iroh": endpoint_id.to_string()},
{"url": "https://e.x/f"},
],
"seed": SAMPLE_RELEASE,
})
);
let back: Command = serde_json::from_value(serde_json::to_value(&fetch).unwrap()).unwrap();
assert_eq!(back, fetch);
let download = Command::Download {
rid: sample_rid(),
cid,
locations: vec![FetchLocation::Iroh(endpoint_id)],
dest: PathBuf::from("/tmp/out"),
seed: None,
};
assert_eq!(
serde_json::to_value(&download).unwrap(),
json!({
"command": "download",
"rid": SAMPLE_RID,
"cid": cid.to_string(),
"locations": [{"iroh": endpoint_id.to_string()}],
"dest": "/tmp/out",
})
);
let back: Command =
serde_json::from_value(serde_json::to_value(&download).unwrap()).unwrap();
assert_eq!(back, download);
}
#[test]
fn wire_snapshot_stream_event() {
let progress: StreamEvent<u32> = StreamEvent::Progress(FetchProgress::Connecting);
assert_eq!(
serde_json::to_value(&progress).unwrap(),
json!({"progress": {"kind": "connecting"}})
);
let ok: StreamEvent<u32> = StreamEvent::Okay(7);
assert_eq!(serde_json::to_value(&ok).unwrap(), json!({"okay": 7}));
let err: StreamEvent<u32> = StreamEvent::Error(CommandError {
code: ErrorCode::AllFailed,
message: "no locations".into(),
});
assert_eq!(
serde_json::to_value(&err).unwrap(),
json!({"error": {"code": "all-failed", "message": "no locations"}})
);
}
#[test]
fn wire_snapshot_fetch_progress() {
let endpoint_id = sample_endpoint_id();
let cases = [
(FetchProgress::Connecting, json!({"kind": "connecting"})),
(
FetchProgress::TryingLocation { endpoint_id },
json!({"kind": "trying-location", "endpoint_id": endpoint_id.to_string()}),
),
(
FetchProgress::LocationFailed { endpoint_id },
json!({"kind": "location-failed", "endpoint_id": endpoint_id.to_string()}),
),
(
FetchProgress::Downloading {
offset: 65536,
total: Some(1048576),
},
json!({"kind": "downloading", "offset": 65536, "total": 1048576}),
),
(
FetchProgress::Exporting {
offset: 10,
total: None,
entry: Some("a/b.txt".into()),
},
json!({"kind": "exporting", "offset": 10, "total": null, "entry": "a/b.txt"}),
),
];
for (value, expected) in cases {
assert_eq!(serde_json::to_value(&value).unwrap(), expected);
let back: FetchProgress =
serde_json::from_value(serde_json::to_value(&value).unwrap()).unwrap();
assert_eq!(back, value);
}
}
#[test]
fn wire_snapshot_fetch_results() {
let cid = sample_cid();
let endpoint_id = sample_endpoint_id();
let has = HasResult {
present: true,
complete: false,
bytes: 1024,
};
assert_eq!(
serde_json::to_value(&has).unwrap(),
json!({"present": true, "complete": false, "bytes": 1024})
);
let export = ExportReceipt {
cid,
dest: PathBuf::from("/tmp/out"),
bytes: 2048,
};
assert_eq!(
serde_json::to_value(&export).unwrap(),
json!({"cid": cid.to_string(), "dest": "/tmp/out", "bytes": 2048})
);
let fetch = FetchReceipt {
rid: sample_rid(),
cid,
bytes: 4096,
from_cache: false,
seeded: true,
endpoint_id,
};
assert_eq!(
serde_json::to_value(&fetch).unwrap(),
json!({
"rid": SAMPLE_RID,
"cid": cid.to_string(),
"bytes": 4096,
"from_cache": false,
"seeded": true,
"endpoint_id": endpoint_id.to_string(),
})
);
let back: FetchReceipt =
serde_json::from_value(serde_json::to_value(&fetch).unwrap()).unwrap();
assert_eq!(back, fetch);
let download = DownloadReceipt {
rid: sample_rid(),
cid,
dest: PathBuf::from("/tmp/out"),
bytes: 4096,
from_cache: true,
seeded: false,
endpoint_id,
};
assert_eq!(
serde_json::to_value(&download).unwrap(),
json!({
"rid": SAMPLE_RID,
"cid": cid.to_string(),
"dest": "/tmp/out",
"bytes": 4096,
"from_cache": true,
"seeded": false,
"endpoint_id": endpoint_id.to_string(),
})
);
let back: DownloadReceipt =
serde_json::from_value(serde_json::to_value(&download).unwrap()).unwrap();
assert_eq!(back, download);
}
}