Skip to main content

wire/
relay_client.rs

1//! HTTP client for `wire-relay-server`.
2//!
3//! Sync wrapper around `reqwest::blocking` so CLI commands stay synchronous —
4//! the only async surface in the crate is `relay_server::serve`. Async clients
5//! land in v0.2 if a long-running daemon needs them.
6
7use anyhow::{Context, Result, anyhow};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11#[derive(Clone)]
12pub struct RelayClient {
13    base_url: String,
14    client: reqwest::blocking::Client,
15}
16
17#[derive(Debug, Serialize, Deserialize)]
18pub struct AllocateResponse {
19    pub slot_id: String,
20    pub slot_token: String,
21}
22
23#[derive(Debug, Deserialize)]
24pub struct PostEventResponse {
25    pub event_id: Option<String>,
26    pub status: String,
27}
28
29/// Env var: when set to a truthy value (`1`, `true`, `yes`), every TLS
30/// verification check on every wire HTTPS client is disabled. Intended
31/// as an emergency-only operator override for environments behind a
32/// TLS-intercepting middlebox (corporate proxy, AV product like Avast
33/// re-signing certs with its own root, captive portal). Prints a loud
34/// stderr banner on every send when active. **Do not set this in
35/// production.** Documented in THREAT_MODEL.md + README.
36pub const INSECURE_SKIP_TLS_ENV: &str = "WIRE_INSECURE_SKIP_TLS_VERIFY";
37
38fn insecure_skip_tls_verify() -> bool {
39    matches!(
40        std::env::var(INSECURE_SKIP_TLS_ENV)
41            .unwrap_or_default()
42            .to_ascii_lowercase()
43            .as_str(),
44        "1" | "true" | "yes" | "on"
45    )
46}
47
48/// v0.5.13: emit the loud-fail banner exactly once per process so we
49/// don't spam a hundred lines per `wire push`. Per-process `OnceLock`
50/// guards the emission. The banner goes to stderr; never stdout (we
51/// must not corrupt the `--json` machine-readable contract).
52fn maybe_emit_insecure_banner() {
53    static ONCE: std::sync::OnceLock<()> = std::sync::OnceLock::new();
54    if insecure_skip_tls_verify() {
55        ONCE.get_or_init(|| {
56            eprintln!(
57                "\x1b[1;31mwire: WARNING\x1b[0m {INSECURE_SKIP_TLS_ENV}=1 is set; TLS verification is DISABLED for all relay traffic. \
58                 MITM attacks against the relay path are undetectable in this mode. Unset to restore default trust validation."
59            );
60        });
61    }
62}
63
64/// Centralized builder for blocking HTTPS clients across wire. Loads
65/// the OS native trust store (rustls-tls-native-roots) so corporate
66/// proxies, AV cert-resign products, and on-prem CAs validate. Honors
67/// the [`INSECURE_SKIP_TLS_ENV`] escape hatch for the corporate-proxy
68/// emergency case.
69pub fn build_blocking_client(
70    timeout: Option<std::time::Duration>,
71) -> Result<reqwest::blocking::Client> {
72    let mut b = reqwest::blocking::Client::builder();
73    if let Some(t) = timeout {
74        b = b.timeout(t);
75    }
76    if insecure_skip_tls_verify() {
77        maybe_emit_insecure_banner();
78        b = b.danger_accept_invalid_certs(true);
79    }
80    b.build()
81        .with_context(|| "constructing reqwest blocking client")
82}
83
84/// Flatten an `anyhow::Error` source chain into a single human-readable
85/// transport-error line for the `reason` field in `wire push --json` and
86/// for stderr surfaces. Classifies the topmost cause (`TLS error`,
87/// `DNS error`, `connect timeout`, `read timeout`, `HTTP error`) so a
88/// silent failure no longer leaks past the user as a bare URL.
89///
90/// v0.5.13 rule 1 of the network-resilience doctrine — see issue #6.
91pub fn format_transport_error(err: &anyhow::Error) -> String {
92    let mut parts: Vec<String> = err.chain().map(|c| c.to_string()).collect();
93    // Heuristic classification — search the chain for the lowest-level
94    // descriptor and prefix the message so the reader sees the kind
95    // even when the topmost context is just the URL.
96    let lower = parts
97        .iter()
98        .map(|p| p.to_ascii_lowercase())
99        .collect::<Vec<_>>();
100    let class = if lower.iter().any(|p| {
101        p.contains("invalid peer certificate")
102            || p.contains("certificate verification")
103            || p.contains("unknownissuer")
104            || p.contains("certificate is not valid")
105            || p.contains("tls handshake")
106    }) {
107        Some("TLS error")
108    } else if lower.iter().any(|p| {
109        p.contains("dns error")
110            || p.contains("nodename nor servname")
111            || p.contains("failed to lookup address")
112    }) {
113        Some("DNS error")
114    } else if lower
115        .iter()
116        .any(|p| p.contains("operation timed out") || p.contains("deadline has elapsed"))
117    {
118        Some("timeout")
119    } else if lower
120        .iter()
121        .any(|p| p.contains("connection refused") || p.contains("connection reset"))
122    {
123        Some("connect error")
124    } else {
125        None
126    };
127    if let Some(c) = class {
128        parts.insert(0, c.to_string());
129    }
130    parts.join(": ")
131}
132
133/// v0.7.0-alpha.17: minimal blocking HTTP/1.1 client over Unix Domain
134/// Socket. Used by callers that detect a `unix://` scheme on a relay
135/// endpoint URL and route around reqwest (which has no UDS support).
136///
137/// Connects to `socket_path`, writes a single HTTP/1.1 request, parses
138/// status + Content-Length + body. Closes the connection (no keep-
139/// alive). Sufficient for wire's request shape: single POST or GET per
140/// call, JSON in + JSON out, small payloads.
141///
142/// Returns `(status_code, body_bytes)`. Caller decodes body per the
143/// endpoint's content type.
144#[cfg(unix)]
145pub fn uds_request(
146    socket_path: &std::path::Path,
147    method: &str,
148    request_target: &str,
149    headers: &[(&str, &str)],
150    body: &[u8],
151) -> Result<(u16, Vec<u8>)> {
152    use std::io::{Read, Write};
153    use std::os::unix::net::UnixStream;
154    let mut stream =
155        UnixStream::connect(socket_path).with_context(|| format!("connect UDS {socket_path:?}"))?;
156    stream.set_read_timeout(Some(std::time::Duration::from_secs(30)))?;
157    stream.set_write_timeout(Some(std::time::Duration::from_secs(30)))?;
158    let mut req = String::with_capacity(256 + headers.len() * 32 + body.len());
159    req.push_str(method);
160    req.push(' ');
161    req.push_str(request_target);
162    req.push_str(" HTTP/1.1\r\n");
163    req.push_str("Host: localhost\r\n");
164    req.push_str("Connection: close\r\n");
165    req.push_str(&format!("Content-Length: {}\r\n", body.len()));
166    for (k, v) in headers {
167        req.push_str(k);
168        req.push_str(": ");
169        req.push_str(v);
170        req.push_str("\r\n");
171    }
172    req.push_str("\r\n");
173    stream.write_all(req.as_bytes())?;
174    if !body.is_empty() {
175        stream.write_all(body)?;
176    }
177    stream.flush()?;
178    let mut raw = Vec::new();
179    stream.read_to_end(&mut raw)?;
180    // Parse HTTP/1.1 response: status line + headers + \r\n\r\n + body.
181    let split = raw
182        .windows(4)
183        .position(|w| w == b"\r\n\r\n")
184        .ok_or_else(|| anyhow!("UDS response missing header/body delimiter"))?;
185    let head = std::str::from_utf8(&raw[..split])
186        .map_err(|e| anyhow!("UDS response head not UTF-8: {e}"))?;
187    let body = raw[split + 4..].to_vec();
188    let status_line = head.lines().next().unwrap_or("");
189    // "HTTP/1.1 200 OK"
190    let status: u16 = status_line
191        .split_whitespace()
192        .nth(1)
193        .and_then(|s| s.parse().ok())
194        .ok_or_else(|| anyhow!("UDS response missing status code: {status_line:?}"))?;
195    Ok((status, body))
196}
197
198/// v0.7.0-alpha.19: scheme-aware POST helper that dispatches to either
199/// reqwest (for `http(s)://...`) OR the hand-rolled `uds_request` (for
200/// `unix:///path/to/sock`). Lets the daemon + cmd_send walk a peer's
201/// pinned endpoints uniformly without each call site having to detect
202/// scheme + branch.
203///
204/// Used by the routing layer to send signed events to a peer's slot
205/// regardless of which transport scope the peer is reachable on. UDS
206/// path uses the alpha.17 client; TCP path uses the existing
207/// RelayClient::post_event flow.
208pub fn post_event_to_endpoint(
209    endpoint: &crate::endpoints::Endpoint,
210    event: &Value,
211) -> Result<PostEventResponse> {
212    #[cfg(unix)]
213    if let Some(socket_path) = endpoint.relay_url.strip_prefix("unix://") {
214        let body = serde_json::json!({"event": event}).to_string();
215        let auth_header = format!("Bearer {}", endpoint.slot_token);
216        let (status, body) = uds_request(
217            std::path::Path::new(socket_path),
218            "POST",
219            &format!("/v1/events/{}", endpoint.slot_id),
220            &[
221                ("Content-Type", "application/json"),
222                ("Authorization", &auth_header),
223            ],
224            body.as_bytes(),
225        )?;
226        if !(200..300).contains(&status) {
227            return Err(anyhow!(
228                "post_event (uds {socket_path}) failed: {status}: {}",
229                String::from_utf8_lossy(&body)
230            ));
231        }
232        return Ok(serde_json::from_slice(&body)?);
233    }
234    let client = RelayClient::new(&endpoint.relay_url);
235    client.post_event(&endpoint.slot_id, &endpoint.slot_token, event)
236}
237
238impl RelayClient {
239    pub fn new(base_url: &str) -> Self {
240        let client = build_blocking_client(Some(std::time::Duration::from_secs(30)))
241            .expect("reqwest client construction is infallible with rustls + native roots");
242        Self {
243            base_url: base_url.trim_end_matches('/').to_string(),
244            client,
245        }
246    }
247
248    /// Allocate a fresh slot. Returns `(slot_id, slot_token)` — caller MUST
249    /// persist `slot_token` somewhere safe (mode 0600 file); it grants both
250    /// read and write access to the slot.
251    pub fn allocate_slot(&self, handle_hint: Option<&str>) -> Result<AllocateResponse> {
252        let body = serde_json::json!({"handle": handle_hint});
253        let resp = self
254            .client
255            .post(format!("{}/v1/slot/allocate", self.base_url))
256            .json(&body)
257            .send()
258            .with_context(|| format!("POST {}/v1/slot/allocate", self.base_url))?;
259        let status = resp.status();
260        if !status.is_success() {
261            let detail = resp.text().unwrap_or_default();
262            return Err(anyhow!("allocate failed: {status}: {detail}"));
263        }
264        Ok(resp.json()?)
265    }
266
267    /// POST a signed event to a slot. Caller passes the slot's bearer token
268    /// (the relay model in v0.1 is "shared slot token between paired peers" —
269    /// see iter 9 SPAKE2 for how this token gets exchanged).
270    pub fn post_event(
271        &self,
272        slot_id: &str,
273        slot_token: &str,
274        event: &Value,
275    ) -> Result<PostEventResponse> {
276        let body = serde_json::json!({"event": event});
277        let resp = self
278            .client
279            .post(format!("{}/v1/events/{slot_id}", self.base_url))
280            .bearer_auth(slot_token)
281            .json(&body)
282            .send()
283            .with_context(|| format!("POST {}/v1/events/{slot_id}", self.base_url))?;
284        let status = resp.status();
285        if !status.is_success() {
286            let detail = resp.text().unwrap_or_default();
287            return Err(anyhow!("post_event failed: {status}: {detail}"));
288        }
289        Ok(resp.json()?)
290    }
291
292    /// GET events from a slot. `since` is an event_id cursor (exclusive); pass
293    /// `None` for the full slot snapshot. `limit` defaults to 100, max 1000.
294    pub fn list_events(
295        &self,
296        slot_id: &str,
297        slot_token: &str,
298        since: Option<&str>,
299        limit: Option<usize>,
300    ) -> Result<Vec<Value>> {
301        let mut url = format!("{}/v1/events/{slot_id}", self.base_url);
302        let mut sep = '?';
303        if let Some(s) = since {
304            url.push(sep);
305            url.push_str(&format!("since={s}"));
306            sep = '&';
307        }
308        if let Some(n) = limit {
309            url.push(sep);
310            url.push_str(&format!("limit={n}"));
311        }
312        let resp = self
313            .client
314            .get(&url)
315            .bearer_auth(slot_token)
316            .send()
317            .with_context(|| format!("GET {url}"))?;
318        let status = resp.status();
319        if !status.is_success() {
320            let detail = resp.text().unwrap_or_default();
321            return Err(anyhow!("list_events failed: {status}: {detail}"));
322        }
323        Ok(resp.json()?)
324    }
325
326    /// R4 — probe slot attentiveness. Returns `(event_count, last_pull_at_unix)`
327    /// — the relay's view of the slot's owner's most recent poll. `None` for
328    /// `last_pull_at_unix` means the slot has not been pulled since relay
329    /// restart. Best-effort: any HTTP failure returns `Ok((0, None))` so the
330    /// caller's pre-flight check degrades to "no signal" rather than abort.
331    pub fn slot_state(&self, slot_id: &str, slot_token: &str) -> Result<(usize, Option<u64>)> {
332        let url = format!("{}/v1/slot/{slot_id}/state", self.base_url);
333        let resp = match self.client.get(&url).bearer_auth(slot_token).send() {
334            Ok(r) => r,
335            Err(_) => return Ok((0, None)),
336        };
337        if !resp.status().is_success() {
338            return Ok((0, None));
339        }
340        let v: Value = resp.json().unwrap_or(Value::Null);
341        let count = v.get("event_count").and_then(Value::as_u64).unwrap_or(0) as usize;
342        let last = v.get("last_pull_at_unix").and_then(Value::as_u64);
343        Ok((count, last))
344    }
345
346    pub fn responder_health_set(
347        &self,
348        slot_id: &str,
349        slot_token: &str,
350        record: &Value,
351    ) -> Result<Value> {
352        let resp = self
353            .client
354            .post(format!(
355                "{}/v1/slot/{slot_id}/responder-health",
356                self.base_url
357            ))
358            .bearer_auth(slot_token)
359            .json(record)
360            .send()
361            .with_context(|| {
362                format!("POST {}/v1/slot/{slot_id}/responder-health", self.base_url)
363            })?;
364        let status = resp.status();
365        if !status.is_success() {
366            let detail = resp.text().unwrap_or_default();
367            return Err(anyhow!("responder_health_set failed: {status}: {detail}"));
368        }
369        Ok(resp.json()?)
370    }
371
372    pub fn responder_health_get(&self, slot_id: &str, slot_token: &str) -> Result<Value> {
373        let resp = self
374            .client
375            .get(format!("{}/v1/slot/{slot_id}/state", self.base_url))
376            .bearer_auth(slot_token)
377            .send()
378            .with_context(|| format!("GET {}/v1/slot/{slot_id}/state", self.base_url))?;
379        let status = resp.status();
380        if !status.is_success() {
381            let detail = resp.text().unwrap_or_default();
382            return Err(anyhow!("responder_health_get failed: {status}: {detail}"));
383        }
384        let state: Value = resp.json()?;
385        Ok(state
386            .get("responder_health")
387            .cloned()
388            .unwrap_or(Value::Null))
389    }
390
391    pub fn healthz(&self) -> Result<bool> {
392        let resp = self
393            .client
394            .get(format!("{}/healthz", self.base_url))
395            .send()?;
396        Ok(resp.status().is_success())
397    }
398
399    /// Healthz pre-flight that surfaces the underlying reqwest error in its
400    /// own message. Use at every "is the relay reachable before we mutate
401    /// state" site. The three possible failure modes (network error, 5xx
402    /// from a reachable host, healthy) each get a distinct diagnostic line.
403    pub fn check_healthz(&self) -> anyhow::Result<()> {
404        match self.healthz() {
405            Ok(true) => Ok(()),
406            Ok(false) => anyhow::bail!(
407                "phyllis: silent line — {}/healthz returned non-200.\n\
408                 the host is reachable but the relay isn't returning ok. test:\n  \
409                 curl -v {}/healthz",
410                self.base_url,
411                self.base_url
412            ),
413            Err(e) => anyhow::bail!(
414                "phyllis: silent line — couldn't reach {}/healthz: {e:#}.\n\
415                 test reachability from this machine:\n  curl -v {}/healthz\n\
416                 if curl also fails, a sandbox / proxy / firewall is the usual cause.\n\
417                 (OpenShell sandbox? run `curl -fsSL https://wireup.net/openshell-policy.sh | bash -s <sandbox-name>` on the host first.)",
418                self.base_url,
419                self.base_url
420            ),
421        }
422    }
423
424    /// Open or join a pair-slot. Returns the relay-assigned `pair_id`.
425    /// `role` must be `"host"` or `"guest"`. The host calls first; the guest
426    /// uses the same `code_hash` and finds the existing slot.
427    pub fn pair_open(&self, code_hash: &str, msg_b64: &str, role: &str) -> Result<String> {
428        let body = serde_json::json!({"code_hash": code_hash, "msg": msg_b64, "role": role});
429        let resp = self
430            .client
431            .post(format!("{}/v1/pair", self.base_url))
432            .json(&body)
433            .send()?;
434        let status = resp.status();
435        if !status.is_success() {
436            let detail = resp.text().unwrap_or_default();
437            return Err(anyhow!("pair_open failed: {status}: {detail}"));
438        }
439        let v: Value = resp.json()?;
440        v.get("pair_id")
441            .and_then(Value::as_str)
442            .map(str::to_string)
443            .ok_or_else(|| anyhow!("pair_open response missing pair_id"))
444    }
445
446    /// Forget the pair-slot at this code_hash on the relay. Either side can call;
447    /// knowledge of the code is the only auth. Idempotent — succeeds even if the
448    /// slot doesn't exist. Use after a client crash mid-handshake so the host
449    /// doesn't stay locked out until TTL.
450    pub fn pair_abandon(&self, code_hash: &str) -> Result<()> {
451        let body = serde_json::json!({"code_hash": code_hash});
452        let resp = self
453            .client
454            .post(format!("{}/v1/pair/abandon", self.base_url))
455            .json(&body)
456            .send()?;
457        let status = resp.status();
458        if !status.is_success() {
459            let detail = resp.text().unwrap_or_default();
460            return Err(anyhow!("pair_abandon failed: {status}: {detail}"));
461        }
462        Ok(())
463    }
464
465    /// Read peer's SPAKE2 message + (eventually) sealed bootstrap from a pair-slot.
466    pub fn pair_get(
467        &self,
468        pair_id: &str,
469        as_role: &str,
470    ) -> Result<(Option<String>, Option<String>)> {
471        let resp = self
472            .client
473            .get(format!(
474                "{}/v1/pair/{pair_id}?as_role={as_role}",
475                self.base_url
476            ))
477            .send()?;
478        let status = resp.status();
479        if !status.is_success() {
480            let detail = resp.text().unwrap_or_default();
481            return Err(anyhow!("pair_get failed: {status}: {detail}"));
482        }
483        let v: Value = resp.json()?;
484        let peer_msg = v
485            .get("peer_msg")
486            .and_then(Value::as_str)
487            .map(str::to_string);
488        let peer_bootstrap = v
489            .get("peer_bootstrap")
490            .and_then(Value::as_str)
491            .map(str::to_string);
492        Ok((peer_msg, peer_bootstrap))
493    }
494
495    /// POST a sealed bootstrap payload to the pair-slot.
496    pub fn pair_bootstrap(&self, pair_id: &str, role: &str, sealed_b64: &str) -> Result<()> {
497        let body = serde_json::json!({"role": role, "sealed": sealed_b64});
498        let resp = self
499            .client
500            .post(format!("{}/v1/pair/{pair_id}/bootstrap", self.base_url))
501            .json(&body)
502            .send()?;
503        if !resp.status().is_success() {
504            let s = resp.status();
505            let detail = resp.text().unwrap_or_default();
506            return Err(anyhow!("pair_bootstrap failed: {s}: {detail}"));
507        }
508        Ok(())
509    }
510
511    /// Claim a `nick@<this-relay-domain>` handle (v0.5). Caller must hold
512    /// the bearer token for `slot_id`. FCFS on nick; same-DID re-claims OK.
513    ///
514    /// Back-compat wrapper around `handle_claim_v2` that omits the
515    /// `discoverable` field (relay defaults to discoverable on absence).
516    pub fn handle_claim(
517        &self,
518        nick: &str,
519        slot_id: &str,
520        slot_token: &str,
521        relay_url: Option<&str>,
522        card: &Value,
523    ) -> Result<Value> {
524        self.handle_claim_v2(nick, slot_id, slot_token, relay_url, card, None)
525    }
526
527    /// v0.5.19 (#9.1) variant accepting the optional `discoverable`
528    /// flag. `None` = relay default (= true, back-compat).
529    /// `Some(false)` = opt out of `/v1/handles` bulk listing while
530    /// keeping direct `.well-known/wire/agent` resolution working.
531    /// Relays older than v0.5.19 ignore the field — safe to always send.
532    pub fn handle_claim_v2(
533        &self,
534        nick: &str,
535        slot_id: &str,
536        slot_token: &str,
537        relay_url: Option<&str>,
538        card: &Value,
539        discoverable: Option<bool>,
540    ) -> Result<Value> {
541        let mut body = serde_json::json!({
542            "nick": nick,
543            "slot_id": slot_id,
544            "relay_url": relay_url,
545            "card": card,
546        });
547        if let Some(d) = discoverable {
548            body["discoverable"] = serde_json::json!(d);
549        }
550        let resp = self
551            .client
552            .post(format!("{}/v1/handle/claim", self.base_url))
553            .bearer_auth(slot_token)
554            .json(&body)
555            .send()
556            .with_context(|| format!("POST {}/v1/handle/claim", self.base_url))?;
557        let status = resp.status();
558        if !status.is_success() {
559            let detail = resp.text().unwrap_or_default();
560            return Err(anyhow!("handle_claim failed: {status}: {detail}"));
561        }
562        Ok(resp.json()?)
563    }
564
565    /// POST an intro (zero-paste pair-drop) event to a known nick's slot
566    /// without holding that slot's bearer token. Relay validates the event
567    /// is kind=1100 with an embedded signed agent-card; otherwise refuses.
568    pub fn handle_intro(&self, nick: &str, event: &Value) -> Result<Value> {
569        let body = serde_json::json!({"event": event});
570        let resp = self
571            .client
572            .post(format!("{}/v1/handle/intro/{nick}", self.base_url))
573            .json(&body)
574            .send()
575            .with_context(|| format!("POST {}/v1/handle/intro/{nick}", self.base_url))?;
576        let status = resp.status();
577        if !status.is_success() {
578            let detail = resp.text().unwrap_or_default();
579            return Err(anyhow!("handle_intro failed: {status}: {detail}"));
580        }
581        Ok(resp.json()?)
582    }
583
584    /// Resolve a handle on this relay via A2A v1.0 `.well-known/agent-card.json?handle=<nick>`.
585    /// Returns the parsed AgentCard JSON. Wire-served relays embed wire-native
586    /// fields (DID, slot_id, profile, raw card) under `extensions[0].params`.
587    /// Foreign A2A agents return their A2A card without wire ext — useful for
588    /// `wire whois` even when full mailbox pairing isn't possible.
589    pub fn well_known_agent_card_a2a(&self, handle: &str) -> Result<Value> {
590        let resp = self
591            .client
592            .get(format!("{}/.well-known/agent-card.json", self.base_url))
593            .query(&[("handle", handle)])
594            .send()
595            .with_context(|| {
596                format!(
597                    "GET {}/.well-known/agent-card.json?handle={handle}",
598                    self.base_url
599                )
600            })?;
601        let status = resp.status();
602        if !status.is_success() {
603            let detail = resp.text().unwrap_or_default();
604            return Err(anyhow!(
605                "well_known_agent_card_a2a failed: {status}: {detail}"
606            ));
607        }
608        Ok(resp.json()?)
609    }
610
611    /// Resolve a handle on this relay via `.well-known/wire/agent?handle=<nick>`.
612    /// Caller passes either the full `nick@domain` or just `<nick>` — the
613    /// server only uses the local part.
614    pub fn well_known_agent(&self, handle: &str) -> Result<Value> {
615        let resp = self
616            .client
617            .get(format!("{}/.well-known/wire/agent", self.base_url))
618            .query(&[("handle", handle)])
619            .send()
620            .with_context(|| {
621                format!(
622                    "GET {}/.well-known/wire/agent?handle={handle}",
623                    self.base_url
624                )
625            })?;
626        let status = resp.status();
627        if !status.is_success() {
628            let detail = resp.text().unwrap_or_default();
629            return Err(anyhow!("well_known_agent failed: {status}: {detail}"));
630        }
631        Ok(resp.json()?)
632    }
633}
634
635#[cfg(all(test, unix))]
636mod uds_tests {
637    use super::*;
638    use std::io::{Read, Write};
639    use std::os::unix::net::UnixListener;
640    use std::thread;
641
642    /// Spawn a one-shot UDS HTTP/1.1 server that returns a canned
643    /// response. Returns the socket path; cleanup is via drop of the
644    /// tempdir the caller manages.
645    fn spawn_canned_uds_server(socket_path: std::path::PathBuf, status: u16, body: &'static str) {
646        let listener = UnixListener::bind(&socket_path).expect("bind canned UDS");
647        thread::spawn(move || {
648            let (mut stream, _) = listener.accept().expect("accept canned UDS");
649            let mut req_buf = [0u8; 4096];
650            let _ = stream.read(&mut req_buf);
651            let body_bytes = body.as_bytes();
652            let status_text = match status {
653                200 => "OK",
654                201 => "Created",
655                400 => "Bad Request",
656                _ => "Status",
657            };
658            let resp = format!(
659                "HTTP/1.1 {status} {status_text}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
660                body_bytes.len()
661            );
662            let _ = stream.write_all(resp.as_bytes());
663        });
664    }
665
666    #[test]
667    fn uds_request_round_trips_200_with_body() {
668        let tmpdir = std::env::temp_dir().join(format!("wire-uds-test-{}", rand::random::<u32>()));
669        std::fs::create_dir_all(&tmpdir).unwrap();
670        let sock = tmpdir.join("rt.sock");
671        let _ = std::fs::remove_file(&sock);
672        spawn_canned_uds_server(sock.clone(), 200, r#"{"ok":true}"#);
673        // Give the server a moment to bind.
674        std::thread::sleep(std::time::Duration::from_millis(50));
675        let (status, body) = uds_request(
676            &sock,
677            "POST",
678            "/v1/test",
679            &[("Content-Type", "application/json")],
680            b"{}",
681        )
682        .expect("uds_request succeeds");
683        assert_eq!(status, 200);
684        assert_eq!(body, br#"{"ok":true}"#);
685    }
686
687    #[test]
688    fn uds_request_surfaces_non_2xx_status() {
689        let tmpdir = std::env::temp_dir().join(format!("wire-uds-test-{}", rand::random::<u32>()));
690        std::fs::create_dir_all(&tmpdir).unwrap();
691        let sock = tmpdir.join("err.sock");
692        let _ = std::fs::remove_file(&sock);
693        spawn_canned_uds_server(sock.clone(), 400, r#"{"error":"bad"}"#);
694        std::thread::sleep(std::time::Duration::from_millis(50));
695        let (status, body) = uds_request(&sock, "GET", "/v1/test", &[], b"")
696            .expect("uds_request succeeds even on 4xx");
697        assert_eq!(status, 400);
698        assert_eq!(body, br#"{"error":"bad"}"#);
699    }
700
701    #[test]
702    fn uds_request_fails_on_nonexistent_socket() {
703        let nope = std::path::Path::new("/tmp/wire-uds-nonexistent-socket-aaa.sock");
704        let _ = std::fs::remove_file(nope);
705        let err = uds_request(nope, "GET", "/", &[], b"").unwrap_err();
706        let msg = format!("{err:#}");
707        assert!(
708            msg.contains("connect UDS"),
709            "expected connect error, got: {msg}"
710        );
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn url_normalization_trims_trailing_slash() {
720        let c = RelayClient::new("http://example.com/");
721        assert_eq!(c.base_url, "http://example.com");
722        let c = RelayClient::new("http://example.com");
723        assert_eq!(c.base_url, "http://example.com");
724    }
725
726    #[test]
727    fn format_transport_error_classifies_tls() {
728        // Simulate the Avast/corp-proxy class from issue #6: reqwest wraps
729        // a rustls UnknownIssuer inside a hyper error inside a context URL.
730        let inner = anyhow!("invalid peer certificate: UnknownIssuer");
731        let middle: anyhow::Error = inner.context("hyper send");
732        let top = middle.context("POST https://relay.example/v1/events/abc");
733        let formatted = format_transport_error(&top);
734        assert!(
735            formatted.starts_with("TLS error:"),
736            "expected TLS class prefix, got: {formatted}"
737        );
738        assert!(
739            formatted.contains("UnknownIssuer"),
740            "lost root cause: {formatted}"
741        );
742        assert!(
743            formatted.contains("POST https://relay.example"),
744            "lost context URL: {formatted}"
745        );
746    }
747
748    #[test]
749    fn format_transport_error_classifies_timeout() {
750        let inner = anyhow!("operation timed out");
751        let top = inner.context("POST https://relay.example/v1/events/abc");
752        let formatted = format_transport_error(&top);
753        assert!(formatted.starts_with("timeout:"), "got: {formatted}");
754    }
755
756    #[test]
757    fn format_transport_error_classifies_dns() {
758        let inner = anyhow!("dns error: failed to lookup address");
759        let top = inner.context("POST https://relay.example/v1/events/abc");
760        let formatted = format_transport_error(&top);
761        assert!(formatted.starts_with("DNS error:"), "got: {formatted}");
762    }
763
764    #[test]
765    fn format_transport_error_falls_back_to_chain_join() {
766        // Unknown class → no prefix, just the joined chain. Behavior MUST
767        // still surface every cause (this is the loud-fail invariant).
768        let inner = anyhow!("Refused to connect for non-standard reason xyz");
769        let top = inner.context("POST https://relay.example/v1/events/abc");
770        let formatted = format_transport_error(&top);
771        assert!(formatted.contains("Refused to connect"));
772        assert!(formatted.contains("POST https://relay.example"));
773    }
774
775    #[test]
776    fn insecure_env_recognizes_truthy_values_and_default_off() {
777        // Process-global env var → must be one test, not two (otherwise
778        // parallel cargo-test threads race). Single test owns the var's
779        // lifecycle from "unset" through truthy values back to "unset".
780        use std::sync::{Mutex, OnceLock};
781        static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
782        let _lock = GUARD.get_or_init(|| Mutex::new(())).lock().unwrap();
783
784        // SAFETY: env mutation here is serialized by the GUARD mutex;
785        // other tests in this module do not touch INSECURE_SKIP_TLS_ENV.
786        unsafe {
787            std::env::remove_var(INSECURE_SKIP_TLS_ENV);
788        }
789        assert!(!insecure_skip_tls_verify(), "default must be secure");
790
791        for v in ["1", "true", "yes", "on", "TRUE", "Yes"] {
792            unsafe {
793                std::env::set_var(INSECURE_SKIP_TLS_ENV, v);
794            }
795            assert!(insecure_skip_tls_verify(), "value {v:?} should be truthy");
796        }
797        // Falsy / unset round-trip back to secure.
798        for v in ["0", "false", "no", "off", ""] {
799            unsafe {
800                std::env::set_var(INSECURE_SKIP_TLS_ENV, v);
801            }
802            assert!(
803                !insecure_skip_tls_verify(),
804                "value {v:?} must not enable insecure mode"
805            );
806        }
807        unsafe {
808            std::env::remove_var(INSECURE_SKIP_TLS_ENV);
809        }
810    }
811}