1use serde::{Deserialize, Serialize};
3use std::{
4 io::{Read, Write},
5 net::TcpStream,
6 path::Path,
7};
8
9const DEFAULT_SOCKET: &str = "/run/rofl-appd.sock";
10const DEFAULT_HTTP_PORT: u16 = 80;
11const HTTP_SCHEME: &str = "http://";
12const HTTPS_SCHEME: &str = "https://";
13const LOCALHOST_HOST: &str = "localhost";
14
15#[derive(Clone)]
16pub struct RoflClient {
17 transport: Transport,
18}
19
20#[derive(Clone)]
21enum Transport {
22 UnixSocket { socket_path: String },
23 Http(HttpTransport),
24}
25
26#[derive(Clone)]
27struct HttpTransport {
28 connect_target: String,
29 host_header: String,
30 base_path: String,
31}
32
33impl RoflClient {
34 pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
35 Self::with_socket_path(DEFAULT_SOCKET)
36 }
37
38 pub fn with_socket_path<P: AsRef<Path>>(
39 socket_path: P,
40 ) -> Result<Self, Box<dyn std::error::Error>> {
41 let socket_path = socket_path.as_ref().to_string_lossy().to_string();
42 if !Path::new(&socket_path).exists() {
43 return Err(format!("Socket not found at: {socket_path}").into());
44 }
45 Ok(Self {
46 transport: Transport::UnixSocket { socket_path },
47 })
48 }
49
50 pub fn with_url(url: &str) -> Result<Self, Box<dyn std::error::Error>> {
51 if let Some(url) = url.strip_prefix(HTTP_SCHEME) {
52 let transport = HttpTransport::parse(url)?;
53 return Ok(Self {
54 transport: Transport::Http(transport),
55 });
56 }
57 if url.starts_with(HTTPS_SCHEME) {
58 return Err(
59 "HTTPS transport is not supported by oasis-rofl-client; use http:// or a Unix socket path"
60 .into(),
61 );
62 }
63
64 Self::with_socket_path(url)
65 }
66
67 async fn blocking<T, F>(&self, f: F) -> Result<T, Box<dyn std::error::Error>>
68 where
69 T: Send + 'static,
70 F: FnOnce(Transport) -> std::io::Result<T> + Send + 'static,
71 {
72 let transport = self.transport.clone();
73 tokio::task::spawn_blocking(move || f(transport))
74 .await
75 .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?
76 .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })
77 }
78
79 pub async fn get_app_id(&self) -> Result<String, Box<dyn std::error::Error>> {
81 self.blocking(|transport| {
82 let body = transport.request("GET", "/rofl/v1/app/id", None, None)?;
83 let s = String::from_utf8(body)
84 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
85 Ok(s.trim().to_string())
86 })
87 .await
88 }
89
90 pub async fn generate_key(
92 &self,
93 key_id: &str,
94 kind: KeyKind,
95 ) -> Result<String, Box<dyn std::error::Error>> {
96 let req = serde_json::to_vec(&KeyGenerationRequest {
97 key_id: key_id.to_string(),
98 kind: kind.to_string(),
99 })?;
100 self.blocking(move |transport| {
101 let body = transport.request(
102 "POST",
103 "/rofl/v1/keys/generate",
104 Some(&req),
105 Some("application/json"),
106 )?;
107 let resp: KeyGenerationResponse = serde_json::from_slice(&body)
108 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
109 Ok(resp.key)
110 })
111 .await
112 }
113
114 pub async fn sign_submit(
116 &self,
117 tx: Tx,
118 encrypt: Option<bool>,
119 ) -> Result<String, Box<dyn std::error::Error>> {
120 let req = serde_json::to_vec(&SignSubmitRequest { tx, encrypt })?;
121 self.blocking(move |transport| {
122 let body = transport.request(
123 "POST",
124 "/rofl/v1/tx/sign-submit",
125 Some(&req),
126 Some("application/json"),
127 )?;
128 let resp: SignSubmitResponse = serde_json::from_slice(&body)
129 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
130 Ok(resp.data)
131 })
132 .await
133 }
134
135 pub async fn get_metadata(
137 &self,
138 ) -> Result<std::collections::HashMap<String, String>, Box<dyn std::error::Error>> {
139 self.blocking(|transport| {
140 let body = transport.request("GET", "/rofl/v1/metadata", None, None)?;
141 let resp: std::collections::HashMap<String, String> = serde_json::from_slice(&body)
142 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
143 Ok(resp)
144 })
145 .await
146 }
147
148 pub async fn set_metadata(
150 &self,
151 metadata: &std::collections::HashMap<String, String>,
152 ) -> Result<(), Box<dyn std::error::Error>> {
153 let req = serde_json::to_vec(metadata)?;
154 self.blocking(move |transport| {
155 transport.request(
156 "POST",
157 "/rofl/v1/metadata",
158 Some(&req),
159 Some("application/json"),
160 )?;
161 Ok(())
162 })
163 .await
164 }
165
166 pub async fn query(
168 &self,
169 method: &str,
170 args: &[u8],
171 ) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
172 let payload = serde_json::json!({
173 "method": method,
174 "args": hex::encode(args),
175 });
176 let req = serde_json::to_vec(&payload)?;
177 self.blocking(move |transport| {
178 let body = transport.request(
179 "POST",
180 "/rofl/v1/query",
181 Some(&req),
182 Some("application/json"),
183 )?;
184 let resp: serde_json::Value = serde_json::from_slice(&body)
185 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
186 let data_hex = resp.get("data").and_then(|v| v.as_str()).ok_or_else(|| {
187 std::io::Error::new(std::io::ErrorKind::InvalidData, "Missing 'data' field")
188 })?;
189 let data = hex::decode(data_hex)
190 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
191 Ok(data)
192 })
193 .await
194 }
195
196 pub async fn sign_submit_eth(
198 &self,
199 gas_limit: u64,
200 to: &str,
201 value: &str,
202 data_hex: &str,
203 encrypt: Option<bool>,
204 ) -> Result<String, Box<dyn std::error::Error>> {
205 let eth = EthCall {
206 gas_limit,
207 to: to.to_string(),
208 value: value.to_string(),
209 data: data_hex.to_string(),
210 };
211 self.sign_submit(Tx::Eth(eth), encrypt).await
212 }
213}
214
215impl Transport {
216 fn request(
217 &self,
218 method: &str,
219 path: &str,
220 body: Option<&[u8]>,
221 content_type: Option<&str>,
222 ) -> std::io::Result<Vec<u8>> {
223 match self {
224 Self::UnixSocket { socket_path } => {
225 unix_socket_request(socket_path, method, path, body, content_type)
226 }
227 Self::Http(http) => {
228 let stream = TcpStream::connect(&http.connect_target)?;
229 let request_path = http.request_path(path)?;
230 http_request(
231 stream,
232 &http.host_header,
233 method,
234 &request_path,
235 body,
236 content_type,
237 )
238 }
239 }
240 }
241}
242
243impl HttpTransport {
244 fn parse(url: &str) -> Result<Self, Box<dyn std::error::Error>> {
245 if url.is_empty() {
246 return Err("HTTP URL must include a host".into());
247 }
248
249 let authority_end = url.find(['/', '?', '#']).unwrap_or(url.len());
250 let authority = &url[..authority_end];
251 let suffix = &url[authority_end..];
252
253 if authority.is_empty() {
254 return Err("HTTP URL must include a host".into());
255 }
256 if authority.chars().any(char::is_whitespace) {
257 return Err("HTTP URL must not contain whitespace in the authority".into());
258 }
259 if authority.contains('@') {
260 return Err("HTTP URL must not include user info".into());
261 }
262 if suffix.starts_with('?') || suffix.starts_with('#') {
263 return Err("HTTP URL must not include a query string or fragment".into());
264 }
265 if suffix.contains('?') || suffix.contains('#') {
266 return Err("HTTP URL base path must not include a query string or fragment".into());
267 }
268
269 let connect_target = normalize_connect_target(authority)?;
270 let base_path = normalize_base_path(suffix)?;
271
272 Ok(Self {
273 connect_target,
274 host_header: authority.to_string(),
275 base_path,
276 })
277 }
278
279 fn request_path(&self, path: &str) -> std::io::Result<String> {
280 if !path.starts_with('/') {
281 return Err(std::io::Error::new(
282 std::io::ErrorKind::InvalidInput,
283 "ROFL endpoint path must start with '/'",
284 ));
285 }
286
287 if self.base_path.is_empty() {
288 return Ok(path.to_string());
289 }
290
291 Ok(format!("{}{}", self.base_path, path))
292 }
293}
294
295fn normalize_connect_target(authority: &str) -> Result<String, Box<dyn std::error::Error>> {
296 if authority.starts_with('[') {
297 let Some(end) = authority.find(']') else {
298 return Err("HTTP URL contains an invalid IPv6 host".into());
299 };
300 if end == 1 {
301 return Err("HTTP URL must include a host".into());
302 }
303 let suffix = &authority[end + 1..];
304 return Ok(match suffix {
305 "" => format!("{authority}:{DEFAULT_HTTP_PORT}"),
306 _ if suffix.starts_with(':') => {
307 validate_port(&suffix[1..])?;
308 authority.to_string()
309 }
310 _ => return Err("HTTP URL contains an invalid IPv6 host".into()),
311 });
312 }
313
314 let colon_count = authority.matches(':').count();
315 match colon_count {
316 0 => Ok(format!("{authority}:{DEFAULT_HTTP_PORT}")),
317 1 => {
318 let Some((host, port)) = authority.split_once(':') else {
319 return Err("HTTP URL contains an invalid authority".into());
320 };
321 if host.is_empty() {
322 return Err("HTTP URL must include a host".into());
323 }
324 validate_port(port)?;
325 Ok(authority.to_string())
326 }
327 _ => Err("HTTP URL contains an invalid host; IPv6 addresses must be bracketed".into()),
328 }
329}
330
331fn validate_port(port: &str) -> Result<(), Box<dyn std::error::Error>> {
332 if port.is_empty() {
333 return Err("HTTP URL must include a numeric port after ':'".into());
334 }
335 port.parse::<u16>()
336 .map(|_| ())
337 .map_err(|_| "HTTP URL contains an invalid port".into())
338}
339
340fn normalize_base_path(path: &str) -> Result<String, Box<dyn std::error::Error>> {
341 if path.is_empty() || path == "/" {
342 return Ok(String::new());
343 }
344 if !path.starts_with('/') {
345 return Err("HTTP URL base path must start with '/'".into());
346 }
347
348 Ok(path.trim_end_matches('/').to_string())
349}
350
351#[cfg(unix)]
352fn unix_socket_request(
353 socket_path: &str,
354 method: &str,
355 path: &str,
356 body: Option<&[u8]>,
357 content_type: Option<&str>,
358) -> std::io::Result<Vec<u8>> {
359 use std::os::unix::net::UnixStream;
360
361 let stream = UnixStream::connect(socket_path)?;
362 http_request(stream, LOCALHOST_HOST, method, path, body, content_type)
363}
364
365#[cfg(not(unix))]
366fn unix_socket_request(
367 _socket_path: &str,
368 _method: &str,
369 _path: &str,
370 _body: Option<&[u8]>,
371 _content_type: Option<&str>,
372) -> std::io::Result<Vec<u8>> {
373 Err(std::io::Error::other(
374 "Unix domain socket transport is not supported on this platform; use HTTP transport instead",
375 ))
376}
377
378fn http_request<S: Read + Write>(
380 mut stream: S,
381 host: &str,
382 method: &str,
383 path: &str,
384 body: Option<&[u8]>,
385 content_type: Option<&str>,
386) -> std::io::Result<Vec<u8>> {
387 use std::io::{Error, ErrorKind};
388
389 let mut req = Vec::new();
390 req.extend_from_slice(format!("{method} {path} HTTP/1.1\r\n").as_bytes());
391 req.extend_from_slice(format!("Host: {host}\r\n").as_bytes());
392 req.extend_from_slice(b"Connection: close\r\n");
393 if let Some(ct) = content_type {
394 req.extend_from_slice(format!("Content-Type: {ct}\r\n").as_bytes());
395 }
396 if let Some(b) = body {
397 req.extend_from_slice(format!("Content-Length: {}\r\n", b.len()).as_bytes());
398 }
399 req.extend_from_slice(b"\r\n");
400 if let Some(b) = body {
401 req.extend_from_slice(b);
402 }
403
404 stream.write_all(&req)?;
405 stream.flush()?;
406
407 let mut resp = Vec::new();
408 let mut buf = [0u8; 8192];
409 loop {
410 let n = stream.read(&mut buf)?;
411 if n == 0 {
412 break;
413 }
414 resp.extend_from_slice(&buf[..n]);
415 }
416
417 let header_end = resp
418 .windows(4)
419 .position(|w| w == b"\r\n\r\n")
420 .ok_or_else(|| {
421 Error::new(
422 ErrorKind::InvalidData,
423 "Invalid HTTP response: no header/body delimiter",
424 )
425 })?;
426 let (head, body_bytes) = resp.split_at(header_end + 4);
427
428 let mut lines = head.split(|&b| b == b'\n');
429 let status_line = lines
430 .next()
431 .ok_or_else(|| Error::new(ErrorKind::InvalidData, "Invalid HTTP response: empty"))?;
432 let status_str = String::from_utf8(status_line.to_vec())
433 .map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
434 let code: u16 = status_str
435 .split_whitespace()
436 .nth(1)
437 .ok_or_else(|| Error::new(ErrorKind::InvalidData, "Invalid HTTP status line"))?
438 .parse()
439 .map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
440
441 if !(200..300).contains(&code) {
442 let msg = String::from_utf8_lossy(body_bytes).to_string();
443 return Err(Error::other(format!("HTTP {code} error: {msg}")));
444 }
445
446 Ok(body_bytes.to_vec())
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451#[serde(rename_all = "kebab-case")]
452pub enum KeyKind {
453 Raw256,
454 Raw384,
455 Ed25519,
456 Secp256k1,
457}
458
459impl std::fmt::Display for KeyKind {
460 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461 match self {
462 KeyKind::Raw256 => write!(f, "raw-256"),
463 KeyKind::Raw384 => write!(f, "raw-384"),
464 KeyKind::Ed25519 => write!(f, "ed25519"),
465 KeyKind::Secp256k1 => write!(f, "secp256k1"),
466 }
467 }
468}
469
470#[derive(Debug, Serialize)]
471struct KeyGenerationRequest {
472 key_id: String,
473 kind: String,
474}
475
476#[derive(Debug, Deserialize)]
477struct KeyGenerationResponse {
478 key: String,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
484#[serde(tag = "kind", content = "data")]
485pub enum Tx {
486 #[serde(rename = "eth")]
487 Eth(EthCall),
488 #[serde(rename = "std")]
489 Std(String), }
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct EthCall {
494 pub gas_limit: u64,
495 pub to: String,
496 pub value: String,
497 pub data: String, }
499
500#[derive(Debug, Serialize)]
501struct SignSubmitRequest {
502 pub tx: Tx,
503 #[serde(skip_serializing_if = "Option::is_none")]
504 pub encrypt: Option<bool>,
505}
506
507#[derive(Debug, Deserialize)]
508struct SignSubmitResponse {
509 pub data: String, }
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use std::{
516 io::{Read, Write},
517 net::TcpListener,
518 thread,
519 };
520
521 #[cfg(unix)]
522 use std::os::unix::net::UnixListener;
523 #[cfg(unix)]
524 use tempfile::TempDir;
525
526 #[cfg(unix)]
527 fn setup_mock_uds_server(responses: Vec<(String, String)>) -> (TempDir, String) {
528 let temp_dir = TempDir::new().unwrap();
529 let socket_path = temp_dir.path().join("test.sock");
530 let socket_path_str = socket_path.to_string_lossy().to_string();
531
532 let listener = UnixListener::bind(&socket_path).unwrap();
533
534 thread::spawn(move || serve_requests(listener, responses));
535
536 thread::sleep(std::time::Duration::from_millis(100));
537
538 (temp_dir, socket_path_str)
539 }
540
541 fn setup_mock_tcp_server(responses: Vec<(String, String)>) -> String {
542 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
543 let address = listener.local_addr().unwrap();
544
545 thread::spawn(move || serve_requests(listener, responses));
546
547 thread::sleep(std::time::Duration::from_millis(100));
548
549 format!("http://{address}")
550 }
551
552 fn serve_requests<L>(listener: L, responses: Vec<(String, String)>)
553 where
554 L: AcceptOnce,
555 {
556 for (expected_path, response) in responses {
557 if let Some(mut stream) = listener.accept_one() {
558 let mut buf = vec![0u8; 4096];
559 let n = stream.read(&mut buf).unwrap();
560 let request = String::from_utf8_lossy(&buf[..n]);
561 assert!(request.contains(&expected_path));
562
563 let http_response = format!(
564 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
565 response.len(),
566 response
567 );
568 stream.write_all(http_response.as_bytes()).unwrap();
569 }
570 }
571 }
572
573 trait AcceptOnce {
574 type Stream: Read + Write;
575
576 fn accept_one(&self) -> Option<Self::Stream>;
577 }
578
579 #[cfg(unix)]
580 impl AcceptOnce for UnixListener {
581 type Stream = std::os::unix::net::UnixStream;
582
583 fn accept_one(&self) -> Option<Self::Stream> {
584 self.accept().ok().map(|(stream, _)| stream)
585 }
586 }
587
588 impl AcceptOnce for TcpListener {
589 type Stream = std::net::TcpStream;
590
591 fn accept_one(&self) -> Option<Self::Stream> {
592 self.accept().ok().map(|(stream, _)| stream)
593 }
594 }
595
596 #[cfg(unix)]
597 #[tokio::test]
598 async fn test_get_app_id() {
599 let (_temp_dir, socket_path) = setup_mock_uds_server(vec![(
600 "/rofl/v1/app/id".to_string(),
601 "oasis1qr677rv0dcnh7ys4yanlynysvnjtk9gnsyhvm5wj".to_string(),
602 )]);
603
604 let client = RoflClient::with_socket_path(&socket_path).unwrap();
605 let app_id = client.get_app_id().await.unwrap();
606
607 assert_eq!(app_id, "oasis1qr677rv0dcnh7ys4yanlynysvnjtk9gnsyhvm5wj");
608 }
609
610 #[cfg(unix)]
611 #[tokio::test]
612 async fn test_generate_key() {
613 let response = r#"{"key":"0x123456789abcdef"}"#;
614 let (_temp_dir, socket_path) = setup_mock_uds_server(vec![(
615 "/rofl/v1/keys/generate".to_string(),
616 response.to_string(),
617 )]);
618
619 let client = RoflClient::with_socket_path(&socket_path).unwrap();
620 let key = client
621 .generate_key("test-key-id", KeyKind::Secp256k1)
622 .await
623 .unwrap();
624
625 assert_eq!(key, "0x123456789abcdef");
626 }
627
628 #[cfg(unix)]
629 #[tokio::test]
630 async fn test_get_metadata() {
631 let response = r#"{"key1":"value1","key2":"value2"}"#;
632 let (_temp_dir, socket_path) = setup_mock_uds_server(vec![(
633 "/rofl/v1/metadata".to_string(),
634 response.to_string(),
635 )]);
636
637 let client = RoflClient::with_socket_path(&socket_path).unwrap();
638 let metadata = client.get_metadata().await.unwrap();
639
640 assert_eq!(metadata.get("key1").unwrap(), "value1");
641 assert_eq!(metadata.get("key2").unwrap(), "value2");
642 }
643
644 #[cfg(unix)]
645 #[tokio::test]
646 async fn test_set_metadata() {
647 let (_temp_dir, socket_path) =
648 setup_mock_uds_server(vec![("/rofl/v1/metadata".to_string(), "".to_string())]);
649
650 let client = RoflClient::with_socket_path(&socket_path).unwrap();
651 let mut metadata = std::collections::HashMap::new();
652 metadata.insert("new_key".to_string(), "new_value".to_string());
653
654 client.set_metadata(&metadata).await.unwrap();
655 }
656
657 #[cfg(unix)]
658 #[tokio::test]
659 async fn test_query() {
660 let response = r#"{"data":"48656c6c6f"}"#;
661 let (_temp_dir, socket_path) =
662 setup_mock_uds_server(vec![("/rofl/v1/query".to_string(), response.to_string())]);
663
664 let client = RoflClient::with_socket_path(&socket_path).unwrap();
665 let args = b"\xa1\x64test\x65value";
666 let result = client.query("test.Method", args).await.unwrap();
667
668 assert_eq!(result, b"Hello");
669 }
670
671 #[tokio::test]
672 async fn test_http_transport_get_app_id() {
673 let url = setup_mock_tcp_server(vec![(
674 "/rofl/v1/app/id".to_string(),
675 "oasis1qr677rv0dcnh7ys4yanlynysvnjtk9gnsyhvm5wj".to_string(),
676 )]);
677
678 let client = RoflClient::with_url(&url).unwrap();
679 let app_id = client.get_app_id().await.unwrap();
680
681 assert_eq!(app_id, "oasis1qr677rv0dcnh7ys4yanlynysvnjtk9gnsyhvm5wj");
682 }
683
684 #[tokio::test]
685 async fn test_http_transport_preserves_base_path() {
686 let base_url = setup_mock_tcp_server(vec![(
687 "/prefix/rofl/v1/keys/generate".to_string(),
688 r#"{"key":"0x123456789abcdef"}"#.to_string(),
689 )]);
690
691 let client = RoflClient::with_url(&format!("{base_url}/prefix/")).unwrap();
692 let key = client
693 .generate_key("test-key-id", KeyKind::Secp256k1)
694 .await
695 .unwrap();
696
697 assert_eq!(key, "0x123456789abcdef");
698 }
699
700 #[tokio::test]
701 async fn test_http_transport_uses_default_port() {
702 let transport = HttpTransport::parse("example.com").unwrap();
703 assert_eq!(transport.connect_target, "example.com:80");
704 assert_eq!(transport.host_header, "example.com");
705 assert_eq!(transport.base_path, "");
706 }
707
708 #[tokio::test]
709 async fn test_with_url_rejects_https() {
710 let err = match RoflClient::with_url("https://localhost:8549") {
711 Ok(_) => panic!("expected https URL to be rejected"),
712 Err(err) => err,
713 };
714 assert!(err.to_string().contains("HTTPS transport is not supported"));
715 }
716
717 #[tokio::test]
718 async fn test_with_url_rejects_query_and_fragment() {
719 let err = match RoflClient::with_url("http://localhost:8549/prefix?test=true") {
720 Ok(_) => panic!("expected URL with query string to be rejected"),
721 Err(err) => err,
722 };
723 assert!(err
724 .to_string()
725 .contains("must not include a query string or fragment"));
726
727 let err = match RoflClient::with_url("http://localhost:8549#fragment") {
728 Ok(_) => panic!("expected URL with fragment to be rejected"),
729 Err(err) => err,
730 };
731 assert!(err
732 .to_string()
733 .contains("must not include a query string or fragment"));
734 }
735
736 #[tokio::test]
737 async fn test_http_url_without_host_is_rejected() {
738 let err = match RoflClient::with_url("http://") {
739 Ok(_) => panic!("expected URL without host to be rejected"),
740 Err(err) => err,
741 };
742 assert!(err.to_string().contains("must include a host"));
743 }
744
745 #[tokio::test]
746 async fn test_with_url_rejects_invalid_authority() {
747 let err = match RoflClient::with_url("http://localhost:") {
748 Ok(_) => panic!("expected URL with empty port to be rejected"),
749 Err(err) => err,
750 };
751 assert!(err
752 .to_string()
753 .contains("must include a numeric port after ':'"));
754
755 let err = match RoflClient::with_url("http://:8549") {
756 Ok(_) => panic!("expected URL with empty host to be rejected"),
757 Err(err) => err,
758 };
759 assert!(err.to_string().contains("must include a host"));
760
761 let err = match RoflClient::with_url("http://localhost:abc") {
762 Ok(_) => panic!("expected URL with non-numeric port to be rejected"),
763 Err(err) => err,
764 };
765 assert!(err.to_string().contains("contains an invalid port"));
766 }
767
768 #[tokio::test]
769 async fn test_bad_socket_path() {
770 let result = RoflClient::with_socket_path("/non/existent/socket.sock");
771 assert!(result.is_err());
772 }
773}