dig_dht/error.rs
1//! [`DhtError`] — the crate's error type.
2//!
3//! Every variant that carries text carries a [`SafeText`], never a `String` (#1675). A `String`
4//! parameter accepts anything, so it invites a caller to embed whatever a peer sent; the safety of
5//! the crate then rests on every present and future caller remembering a convention that is written
6//! down nowhere in the type. `SafeText` cannot be built from a runtime `String` without saying the
7//! text is untrusted, so raw peer text is unrepresentable in a `DhtError` rather than merely
8//! discouraged.
9//!
10//! The constructors below make the provenance of every message explicit at the call site:
11//! [`DhtError::transport`] for text this crate wrote, [`DhtError::transport_from_untrusted`] for
12//! text that came from, or was derived from, a remote party.
13
14use thiserror::Error;
15
16use dig_nat::SafeText;
17
18/// An error from a DHT operation.
19#[derive(Debug, Error)]
20pub enum DhtError {
21 /// A transport-level failure talking to a peer (connect failed, stream error, timeout). Carries
22 /// the underlying reason as text — the DHT treats a transport failure to one peer as that peer
23 /// being unreachable and continues the lookup with others.
24 #[error("transport error: {0}")]
25 Transport(SafeText),
26
27 /// A peer's response could not be parsed / did not match the expected shape for the request.
28 ///
29 /// The reason describes the failure WITHOUT quoting the response, because the response is
30 /// exactly the thing the peer chose — see [`SafeText::describing_json_error`].
31 #[error("malformed response: {0}")]
32 MalformedResponse(SafeText),
33
34 /// A hex `peer_id` / content key / root supplied to the API was not valid 64-char hex.
35 #[error("invalid hex identifier: {0}")]
36 InvalidHex(SafeText),
37
38 /// The lookup could not proceed because the routing table + bootstrap set were empty — there is
39 /// no one to ask. Bootstrap the node with at least one reachable peer first.
40 #[error("no peers to query (routing table + bootstrap set are empty)")]
41 NoPeers,
42
43 /// The RPC timed out waiting for a peer response.
44 #[error("rpc timed out")]
45 Timeout,
46}
47
48impl DhtError {
49 /// Build a [`DhtError::Transport`] from text THIS CRATE wrote.
50 ///
51 /// Accepts a `&'static str` (a source literal) or an already-vetted [`SafeText`]. It deliberately
52 /// does NOT accept a `String` or an arbitrary `Display`: those are how a peer's own bytes used to
53 /// get in. For a message derived from a remote party, say so with
54 /// [`Self::transport_from_untrusted`].
55 ///
56 /// ```
57 /// use dig_dht::DhtError;
58 ///
59 /// let err = DhtError::transport("connection refused");
60 /// assert_eq!(err.to_string(), "transport error: connection refused");
61 /// ```
62 ///
63 /// A raw `String` — the shape that let peer text in — no longer compiles:
64 ///
65 /// ```compile_fail
66 /// use dig_dht::DhtError;
67 ///
68 /// let from_the_wire: String = std::env::var("PEER_SAID").unwrap_or_default();
69 /// let err = DhtError::transport(from_the_wire);
70 /// ```
71 pub fn transport(text: impl Into<SafeText>) -> Self {
72 DhtError::Transport(text.into())
73 }
74
75 /// Build a [`DhtError::Transport`] from text of REMOTE origin, neutralizing it on the way in.
76 ///
77 /// Use this for an `io::Error`, a TLS error, or any message whose content a peer could have
78 /// influenced. The name is the documentation: a reader of the call site can see that untrusted
79 /// text is entering an error, which is precisely what a bare `String` parameter concealed.
80 pub fn transport_from_untrusted(reason: impl std::fmt::Display) -> Self {
81 DhtError::Transport(SafeText::from_untrusted(reason.to_string()))
82 }
83
84 /// Build a [`DhtError::MalformedResponse`] describing a `serde_json` decode failure on a peer's
85 /// reply, without quoting the reply.
86 pub fn malformed_response(error: &serde_json::Error) -> Self {
87 DhtError::MalformedResponse(SafeText::describing_json_error(error))
88 }
89
90 /// Build a [`DhtError::InvalidHex`] naming WHICH argument was not canonical 64-hex.
91 ///
92 /// The offending value is NOT echoed. A non-canonical identifier is by definition not one of our
93 /// own, so quoting it back would put a stranger's bytes in the message; the caller already knows
94 /// what it passed, so naming the argument is the diagnosis that actually helps.
95 pub fn invalid_hex(which: &'static str) -> Self {
96 DhtError::InvalidHex(SafeText::from_static(which))
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn transport_helper_formats() {
106 let e = DhtError::transport("connection refused");
107 assert!(e.to_string().contains("connection refused"));
108 assert!(matches!(e, DhtError::Transport(_)));
109 }
110
111 /// The untrusted door sanitizes; the trusted door has nothing to sanitize.
112 #[test]
113 fn untrusted_transport_text_cannot_forge_a_log_line() {
114 let e = DhtError::transport_from_untrusted("refused\n2026-07-31 ERROR forged");
115
116 let rendered = e.to_string();
117 assert!(!rendered.contains('\n'), "got: {rendered:?}");
118 assert!(
119 rendered.contains("refused") && rendered.contains("forged"),
120 "escaped, not deleted: {rendered}"
121 );
122 }
123
124 #[test]
125 fn a_malformed_response_does_not_quote_the_response() {
126 let json_err =
127 serde_json::from_str::<u64>(r#""what-the-peer-sent""#).expect_err("not a u64");
128
129 let rendered = DhtError::malformed_response(&json_err).to_string();
130
131 assert!(!rendered.contains("what-the-peer-sent"));
132 assert!(rendered.contains("malformed response"));
133 assert!(rendered.contains("line 1"), "still locatable: {rendered}");
134 }
135
136 #[test]
137 fn invalid_hex_names_the_argument_rather_than_echoing_it() {
138 let rendered = DhtError::invalid_hex("peer_id").to_string();
139
140 assert!(rendered.contains("peer_id"));
141 assert!(rendered.contains("invalid hex"));
142 }
143
144 #[test]
145 fn error_messages_are_descriptive() {
146 assert!(DhtError::NoPeers.to_string().contains("no peers"));
147 assert!(DhtError::Timeout.to_string().contains("timed out"));
148 assert!(DhtError::MalformedResponse(SafeText::from_static("x"))
149 .to_string()
150 .contains("malformed"));
151 }
152}