Skip to main content

decdn_cli/commands/
probe.rs

1//! `decdn probe` — send a `cdn/probe/v1` content-availability query to a
2//! running node and print the signed response.
3
4use decdn_common::cli;
5
6use decdn_client::probe::probe_once;
7
8/// Parse a user-supplied BLAKE3 hash (64 hex chars, optional `0x` prefix —
9/// the same form `cache.pinned_hashes` accepts) into raw bytes.
10fn parse_hash(s: &str) -> anyhow::Result<[u8; 32]> {
11    let hex = s.strip_prefix("0x").unwrap_or(s);
12    let h = blake3::Hash::from_hex(hex).map_err(|e| {
13        anyhow::anyhow!("invalid --hash {s:?}: expected 64 hex chars (BLAKE3 digest): {e}")
14    })?;
15    Ok(*h.as_bytes())
16}
17
18/// Send a `cdn/probe/v1` content-availability request to a running node and
19/// print the signed response.
20///
21/// The transport path lives in [`probe_once`]; response correlation and the
22/// mandatory `slash_sig` check (ADR 014 §1) are applied here on the returned
23/// response.
24pub async fn probe(
25    args: &cli::ProbeArgs,
26    config_path: Option<&std::path::Path>,
27) -> anyhow::Result<()> {
28    use std::str::FromStr;
29    use std::time::{Duration, SystemTime, UNIX_EPOCH};
30
31    use iroh::{EndpointAddr, PublicKey};
32
33    use decdn_client::endpoint as client_endpoint;
34
35    let node_id = PublicKey::from_str(&args.node_id)
36        .map_err(|e| anyhow::anyhow!("invalid --node-id {:?}: {e}", args.node_id))?;
37    let hash = parse_hash(&args.hash)?;
38
39    // Relays come from `network.relay_urls` in config; `--relay-url` overrides
40    // (#935). Discovery (#936): `[network.discovery]` composes operator
41    // resolution legs, else `presets::N0`, so a node-id can be dialed without an
42    // explicit `--addr` when discovery is configured.
43    let relays = client_endpoint::resolve_relays(args.relay_url.as_deref(), config_path)?;
44    let discovery = client_endpoint::client_discovery(config_path)?;
45
46    // No reachability pre-check: the endpoint is discovery-enabled, so a node-id
47    // resolves via `[network.discovery]` / `presets::N0` (plus its default
48    // relays) even without `--addr` or configured relays. A direct `--addr`
49    // still pins the address when given.
50    let endpoint = client_endpoint::client_endpoint(&relays, &discovery).await?;
51
52    let mut target = EndpointAddr::new(node_id);
53    if let Some(addr) = args.addr {
54        target = target.with_ip_addr(addr);
55    }
56    // Attach a relay hint so a node dialed without --addr is reachable via relay.
57    if let Some(url) = relays.first() {
58        target = target.with_relay_url(url.clone());
59    }
60
61    let timeout = Duration::from_millis(args.timeout_ms);
62    // ADR 005: `timestamp_us` is the requester-generated microsecond
63    // timestamp echoed back; it serves response correlation. RTT is measured
64    // from the local monotonic clock (`Instant`), which is strictly better
65    // than `receive_time - timestamp_us` for a single-host CLI and avoids
66    // cross-host clock-skew artefacts.
67    let timestamp_us = u64::try_from(
68        SystemTime::now()
69            .duration_since(UNIX_EPOCH)
70            .map_or(0, |d| d.as_micros()),
71    )
72    .unwrap_or(u64::MAX);
73
74    // Transport and RTT measurement live in `probe_once`.
75    let result = probe_once(&endpoint, target, hash, timestamp_us, timeout).await;
76    endpoint.close().await;
77    let (resp, resp_ext, rtt) = result?;
78
79    // Correlation: the node echoes both the queried hash and the
80    // requester timestamp (ADR 005). A mismatch means a stale/confused
81    // response — reject it.
82    if resp.body.timestamp_us != timestamp_us {
83        anyhow::bail!(
84            "timestamp mismatch: sent {timestamp_us}, received {}",
85            resp.body.timestamp_us
86        );
87    }
88    if resp.body.hash != hash {
89        anyhow::bail!("hash mismatch: response is for a different blob");
90    }
91
92    // ADR 014 §1 / #252: `slash_sig` is mandatory and non-empty, and a
93    // `rate_per_mb` of 0 MUST be rejected on receive. Route through
94    // `ProbeResponse::validate()` so these requester obligations share one
95    // definition with the protocol layer rather than open-coded checks that
96    // can drift. The two live obligations on this path are the `slash_sig`
97    // length and the `rate_per_mb != 0` rule (#252) — the `MAX_RATE_PER_MB`
98    // upper bound is already enforced at decode time by `deserialize_rate_per_mb`,
99    // but zero is a valid wire value the decoder accepts and the requester must
100    // not. Full attribution (recover signer, confirm NodeId↔address via
101    // `CapacityBond`) is the on-chain `SlashJudge`'s job — the CLI has no
102    // registry client, so it enforces presence/shape only.
103    resp.validate()
104        .map_err(|e| anyhow::anyhow!("rejecting probe response: {e}"))?;
105
106    let mut stdout = std::io::stdout().lock();
107    write_probe_response(&mut stdout, &resp, &resp_ext, rtt.ms, args.json)
108        .map_err(|e| anyhow::anyhow!("failed to write probe response: {e}"))?;
109    Ok(())
110}
111
112/// Render a successful probe response to `w` in pretty or JSON form.
113///
114/// Public-in-crate so unit tests can capture the output into a buffer and
115/// assert on the wire contract — in particular the `--json` shape (key set,
116/// `rtt_ms` quantization, `slash_sig` hex encoding).
117pub(crate) fn write_probe_response(
118    w: &mut impl std::io::Write,
119    resp: &decdn_protocol::message::ProbeResponse,
120    resp_ext: &decdn_protocol::ProbeResponseExt,
121    rtt_ms: f64,
122    json: bool,
123) -> std::io::Result<()> {
124    use std::fmt::Write as _;
125
126    let hash_hex = {
127        let mut s = String::with_capacity(64);
128        for b in resp.body.hash {
129            let _ = write!(s, "{b:02x}");
130        }
131        s
132    };
133    let slash_sig_hex = {
134        let mut s = String::with_capacity(resp.slash_sig.len() * 2);
135        for b in &resp.slash_sig {
136            let _ = write!(s, "{b:02x}");
137        }
138        s
139    };
140
141    if json {
142        // Quantize `rtt_ms` to ms precision before serialization (see the
143        // CHANGELOG BREAKING note — the #421 manual `{:.3}` format is gone;
144        // serde_json drops insignificant trailing zeros).
145        let rtt_ms_quantized = (rtt_ms * 1000.0).round() / 1000.0;
146        let output = serde_json::json!({
147            "hash": hash_hex,
148            "has_blob": resp.body.has_blob,
149            "rate_per_mb": resp.body.rate_per_mb,
150            "total_bytes": resp_ext.total_bytes,
151            "timestamp_us": resp.body.timestamp_us,
152            "rtt_ms": rtt_ms_quantized,
153            "slash_sig": slash_sig_hex,
154        });
155        writeln!(w, "{output}")
156    } else {
157        writeln!(w, "hash:          {hash_hex}")?;
158        writeln!(w, "has_blob:      {}", resp.body.has_blob)?;
159        writeln!(w, "rate_per_mb:   {} (base units)", resp.body.rate_per_mb)?;
160        match resp_ext.total_bytes {
161            Some(n) => writeln!(w, "total_bytes:   {n}")?,
162            None => writeln!(w, "total_bytes:   (unknown)")?,
163        }
164        writeln!(w, "timestamp_us:  {} (echoed ok)", resp.body.timestamp_us)?;
165        writeln!(w, "rtt:           {rtt_ms:.3} ms")?;
166        writeln!(w, "slash_sig:     {slash_sig_hex}")
167    }
168}
169
170#[cfg(test)]
171#[allow(
172    clippy::unwrap_used,
173    clippy::expect_used,
174    clippy::indexing_slicing,
175    clippy::panic
176)]
177mod tests;