Skip to main content

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}