bitcoin_peers_connection/
user_agent.rs

1//! User agent validation and utilities for bitcoin p2p protocol.
2//!
3//! This module provides a validated `UserAgent` type and utilities for creating
4//! properly formatted user agents following Bitcoin Core conventions.
5
6use std::fmt;
7use std::str::FromStr;
8
9/// Errors that can occur during user agent validation.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum UserAgentError {
12    /// The user agent format is invalid (must be `/name:version/`).
13    InvalidFormat,
14    /// The name component is missing or empty.
15    MissingName,
16    /// The version component is missing or empty.
17    MissingVersion,
18}
19
20impl fmt::Display for UserAgentError {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            UserAgentError::InvalidFormat => {
24                write!(f, "User agent must follow format '/name:version/'")
25            }
26            UserAgentError::MissingName => {
27                write!(f, "User agent name component cannot be empty")
28            }
29            UserAgentError::MissingVersion => {
30                write!(f, "User agent version component cannot be empty")
31            }
32        }
33    }
34}
35
36impl std::error::Error for UserAgentError {}
37
38/// A validated Bitcoin Core-style user agent string.
39///
40/// This type ensures that user agent strings follow the Bitcoin Core convention
41/// of `/name:version/` and are valid at compile time rather than runtime.
42///
43/// # Example
44///
45/// ```
46/// use bitcoin_peers_connection::UserAgent;
47/// use std::str::FromStr;
48///
49/// // Valid user agent
50/// let user_agent = UserAgent::from_str("/bitcoin-peers:0.1.0/").unwrap();
51/// assert_eq!(user_agent.as_str(), "/bitcoin-peers:0.1.0/");
52///
53/// // Create with validation
54/// let user_agent = UserAgent::new("/bitcoin-peers:0.1.0/").unwrap();
55///
56/// // Create from components
57/// let user_agent = UserAgent::from_name_version("bitcoin-peers", "0.1.0");
58/// assert_eq!(user_agent.as_str(), "/bitcoin-peers:0.1.0/");
59/// ```
60#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61pub struct UserAgent(String);
62
63impl UserAgent {
64    /// Creates a new validated user agent from a string.
65    ///
66    /// # Arguments
67    ///
68    /// * `user_agent` - The user agent string to validate
69    ///
70    /// # Returns
71    ///
72    /// * `Ok(UserAgent)` - If the user agent is valid
73    /// * `Err(UserAgentError)` - If the user agent format is invalid
74    ///
75    /// # Example
76    ///
77    /// ```
78    /// use bitcoin_peers_connection::UserAgent;
79    ///
80    /// let user_agent = UserAgent::new("/bitcoin-peers:0.1.0/").unwrap();
81    /// assert_eq!(user_agent.as_str(), "/bitcoin-peers:0.1.0/");
82    /// ```
83    pub fn new<S: AsRef<str>>(user_agent: S) -> Result<Self, UserAgentError> {
84        let user_agent = user_agent.as_ref();
85        validate_bitcoin_core_format(user_agent)?;
86        Ok(UserAgent(user_agent.to_string()))
87    }
88
89    /// Creates a user agent from name and version components.
90    ///
91    /// This constructor cannot fail since it creates the string in the correct format.
92    ///
93    /// # Arguments
94    ///
95    /// * `name` - The application name
96    /// * `version` - The application version
97    ///
98    /// # Returns
99    ///
100    /// A validated `UserAgent`
101    ///
102    /// # Example
103    ///
104    /// ```
105    /// use bitcoin_peers_connection::UserAgent;
106    ///
107    /// let user_agent = UserAgent::from_name_version("bitcoin-peers", "0.1.0");
108    /// assert_eq!(user_agent.as_str(), "/bitcoin-peers:0.1.0/");
109    /// ```
110    pub fn from_name_version<S1: AsRef<str>, S2: AsRef<str>>(name: S1, version: S2) -> Self {
111        let formatted = bitcoin_core_format(name.as_ref(), version.as_ref());
112        UserAgent(formatted)
113    }
114
115    /// Returns the user agent string.
116    ///
117    /// # Example
118    ///
119    /// ```
120    /// use bitcoin_peers_connection::UserAgent;
121    ///
122    /// let user_agent = UserAgent::from_name_version("bitcoin-peers", "0.1.0");
123    /// assert_eq!(user_agent.as_str(), "/bitcoin-peers:0.1.0/");
124    /// ```
125    pub fn as_str(&self) -> &str {
126        &self.0
127    }
128}
129
130impl fmt::Display for UserAgent {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        write!(f, "{}", self.0)
133    }
134}
135
136impl FromStr for UserAgent {
137    type Err = UserAgentError;
138
139    fn from_str(s: &str) -> Result<Self, Self::Err> {
140        UserAgent::new(s)
141    }
142}
143
144impl AsRef<str> for UserAgent {
145    fn as_ref(&self) -> &str {
146        &self.0
147    }
148}
149
150fn validate_bitcoin_core_format(user_agent: &str) -> Result<(), UserAgentError> {
151    // Check basic format: must start and end with '/' and contain ':'.
152    if !user_agent.starts_with('/') || !user_agent.ends_with('/') {
153        return Err(UserAgentError::InvalidFormat);
154    }
155
156    if !user_agent.contains(':') {
157        return Err(UserAgentError::InvalidFormat);
158    }
159
160    // Extract the content between the slashes.
161    let contents = &user_agent[1..user_agent.len() - 1];
162    let parts: Vec<&str> = contents.split(':').collect();
163
164    if parts.len() != 2 {
165        return Err(UserAgentError::InvalidFormat);
166    }
167
168    if parts[0].is_empty() {
169        return Err(UserAgentError::MissingName);
170    }
171
172    if parts[1].is_empty() {
173        return Err(UserAgentError::MissingVersion);
174    }
175
176    Ok(())
177}
178
179fn bitcoin_core_format(name: &str, version: &str) -> String {
180    format!("/{name}:{version}/")
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_validate_valid_user_agents() {
189        assert!(validate_bitcoin_core_format("/bitcoin-peers:0.1.0/").is_ok());
190        assert!(validate_bitcoin_core_format("/Bitcoin Core:26.0.0/").is_ok());
191        assert!(validate_bitcoin_core_format("/Satoshi:0.21.0/").is_ok());
192        assert!(validate_bitcoin_core_format("/my-app:1.2.3-beta/").is_ok());
193    }
194
195    #[test]
196    fn test_validate_invalid_format() {
197        // Missing leading slash
198        assert_eq!(
199            validate_bitcoin_core_format("bitcoin-peers:0.1.0/"),
200            Err(UserAgentError::InvalidFormat)
201        );
202
203        // Missing trailing slash
204        assert_eq!(
205            validate_bitcoin_core_format("/bitcoin-peers:0.1.0"),
206            Err(UserAgentError::InvalidFormat)
207        );
208
209        // Missing both slashes
210        assert_eq!(
211            validate_bitcoin_core_format("bitcoin-peers:0.1.0"),
212            Err(UserAgentError::InvalidFormat)
213        );
214
215        // Missing colon
216        assert_eq!(
217            validate_bitcoin_core_format("/bitcoin-peers/"),
218            Err(UserAgentError::InvalidFormat)
219        );
220
221        // Multiple colons
222        assert_eq!(
223            validate_bitcoin_core_format("/bitcoin:peers:0.1.0/"),
224            Err(UserAgentError::InvalidFormat)
225        );
226    }
227
228    #[test]
229    fn test_validate_missing_name() {
230        assert_eq!(
231            validate_bitcoin_core_format("/:0.1.0/"),
232            Err(UserAgentError::MissingName)
233        );
234    }
235
236    #[test]
237    fn test_validate_missing_version() {
238        assert_eq!(
239            validate_bitcoin_core_format("/bitcoin-peers:/"),
240            Err(UserAgentError::MissingVersion)
241        );
242    }
243
244    #[test]
245    fn test_bitcoin_core_format() {
246        assert_eq!(
247            bitcoin_core_format("bitcoin-peers", "0.1.0"),
248            "/bitcoin-peers:0.1.0/"
249        );
250        assert_eq!(
251            bitcoin_core_format("Bitcoin Core", "26.0.0"),
252            "/Bitcoin Core:26.0.0/"
253        );
254        assert_eq!(bitcoin_core_format("test", "1.0"), "/test:1.0/");
255    }
256
257    #[test]
258    fn test_error_display() {
259        assert_eq!(
260            UserAgentError::InvalidFormat.to_string(),
261            "User agent must follow format '/name:version/'"
262        );
263        assert_eq!(
264            UserAgentError::MissingName.to_string(),
265            "User agent name component cannot be empty"
266        );
267        assert_eq!(
268            UserAgentError::MissingVersion.to_string(),
269            "User agent version component cannot be empty"
270        );
271    }
272
273    #[test]
274    fn test_roundtrip() {
275        let name = "bitcoin-peers";
276        let version = "0.1.0";
277        let user_agent = bitcoin_core_format(name, version);
278        assert!(validate_bitcoin_core_format(&user_agent).is_ok());
279
280        let validated = UserAgent::new(&user_agent).unwrap();
281        assert_eq!(validated.as_str(), "/bitcoin-peers:0.1.0/");
282    }
283
284    #[test]
285    fn test_user_agent_new_valid() {
286        let user_agent = UserAgent::new("/bitcoin-peers:0.1.0/").unwrap();
287        assert_eq!(user_agent.as_str(), "/bitcoin-peers:0.1.0/");
288    }
289
290    #[test]
291    fn test_user_agent_new_invalid() {
292        assert!(UserAgent::new("invalid").is_err());
293        assert!(UserAgent::new("/invalid/").is_err());
294        assert!(UserAgent::new("/:version/").is_err());
295        assert!(UserAgent::new("/name:/").is_err());
296    }
297
298    #[test]
299    fn test_user_agent_from_name_version() {
300        let user_agent = UserAgent::from_name_version("bitcoin-peers", "0.1.0");
301        assert_eq!(user_agent.as_str(), "/bitcoin-peers:0.1.0/");
302    }
303
304    #[test]
305    fn test_user_agent_from_str() {
306        let user_agent: UserAgent = "/bitcoin-peers:0.1.0/".parse().unwrap();
307        assert_eq!(user_agent.as_str(), "/bitcoin-peers:0.1.0/");
308    }
309
310    #[test]
311    fn test_user_agent_display() {
312        let user_agent = UserAgent::from_name_version("bitcoin-peers", "0.1.0");
313        assert_eq!(format!("{user_agent}"), "/bitcoin-peers:0.1.0/");
314    }
315
316    #[test]
317    fn test_user_agent_equality() {
318        let ua1 = UserAgent::from_name_version("bitcoin-peers", "0.1.0");
319        let ua2 = UserAgent::new("/bitcoin-peers:0.1.0/").unwrap();
320        assert_eq!(ua1, ua2);
321    }
322
323    #[test]
324    fn test_user_agent_as_ref() {
325        let user_agent = UserAgent::from_name_version("bitcoin-peers", "0.1.0");
326        let s: &str = user_agent.as_ref();
327        assert_eq!(s, "/bitcoin-peers:0.1.0/");
328    }
329}