bitcoin_peers_connection/
user_agent.rs1use std::fmt;
7use std::str::FromStr;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum UserAgentError {
12 InvalidFormat,
14 MissingName,
16 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61pub struct UserAgent(String);
62
63impl UserAgent {
64 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 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 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 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 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 assert_eq!(
199 validate_bitcoin_core_format("bitcoin-peers:0.1.0/"),
200 Err(UserAgentError::InvalidFormat)
201 );
202
203 assert_eq!(
205 validate_bitcoin_core_format("/bitcoin-peers:0.1.0"),
206 Err(UserAgentError::InvalidFormat)
207 );
208
209 assert_eq!(
211 validate_bitcoin_core_format("bitcoin-peers:0.1.0"),
212 Err(UserAgentError::InvalidFormat)
213 );
214
215 assert_eq!(
217 validate_bitcoin_core_format("/bitcoin-peers/"),
218 Err(UserAgentError::InvalidFormat)
219 );
220
221 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}