Skip to main content

oasis_rofl_client/
lib.rs

1// rofl-client/rs/src/lib.rs
2use 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    // GET /rofl/v1/app/id
80    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    // POST /rofl/v1/keys/generate
91    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    // POST /rofl/v1/tx/sign-submit
115    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    // GET /rofl/v1/metadata
136    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    // POST /rofl/v1/metadata
149    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    // POST /rofl/v1/query
167    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    /// Convenience helper for ETH-style call
197    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
378// Blocking HTTP/1.1 request over any synchronous stream.
379fn 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// See https://github.com/oasisprotocol/oasis-sdk/blob/1ae8882b05d10a44398e52b5b8c56ab2086f81b3/rofl-appd/src/services/kms.rs#L59-L74
450#[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// -------------------- sign-submit types --------------------
482
483#[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), // CBOR-serialized hex-encoded Transaction
490}
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, // hex string without 0x prefix
498}
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, // CBOR-serialized hex-encoded call result
510}
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}