Skip to main content

vzglyd_sidecar/
lib.rs

1#![deny(missing_docs)]
2//! # `vzglyd_sidecar`
3//!
4//! Networking and IPC utilities for [VZGLYD](https://github.com/vzglyd/vzglyd) slide sidecars.
5//!
6//! A sidecar is a companion `wasm32-wasip1` program that fetches external data and pushes it
7//! into a paired slide over the VZGLYD host channel.
8//!
9//! ## Typical Structure
10//!
11//! ```no_run
12//! use vzglyd_sidecar::{https_get_text, poll_loop};
13//!
14//! fn main() {
15//!     poll_loop(60, || {
16//!         let body = https_get_text("api.example.com", "/data")?;
17//!         Ok(body.into_bytes())
18//!     });
19//! }
20//! ```
21//!
22//! This crate is primarily intended for the `wasm32-wasip1` target used by VZGLYD sidecars.
23
24mod channel;
25mod dns;
26mod http;
27mod poll;
28mod socket;
29mod tls;
30
31use std::cell::RefCell;
32use std::fmt;
33
34pub use channel::{channel_active, channel_poll, channel_push, info_log, sleep_secs};
35pub use poll::poll_loop;
36use std::time::Duration;
37
38thread_local! {
39    static DNS_RESOLVER: RefCell<dns::DnsResolver> = RefCell::new(dns::DnsResolver::new());
40}
41
42/// Errors returned by network and channel helpers.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum Error {
45    /// DNS resolution failed.
46    Dns(String),
47    /// TLS handshake or certificate validation failed.
48    Tls(String),
49    /// The server responded with an HTTP error status.
50    Http {
51        /// HTTP status code returned by the server.
52        status: u16,
53        /// Response body returned with the error status.
54        body: String,
55    },
56    /// General I/O error.
57    Io(String),
58    /// The operation timed out.
59    Timeout,
60}
61
62impl fmt::Display for Error {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            Self::Dns(message) => write!(f, "DNS error: {message}"),
66            Self::Tls(message) => write!(f, "TLS error: {message}"),
67            Self::Http { status, body } => write!(f, "HTTP {status}: {body}"),
68            Self::Io(message) => write!(f, "I/O error: {message}"),
69            Self::Timeout => f.write_str("operation timed out"),
70        }
71    }
72}
73
74impl std::error::Error for Error {}
75
76impl From<std::io::Error> for Error {
77    fn from(error: std::io::Error) -> Self {
78        if error.kind() == std::io::ErrorKind::TimedOut {
79            Self::Timeout
80        } else {
81            Self::Io(error.to_string())
82        }
83    }
84}
85
86/// Perform an HTTPS `GET` request and return the raw response body.
87///
88/// # Errors
89///
90/// Returns [`Error`] if DNS resolution, socket connection, TLS setup, or the HTTP request fails.
91pub fn https_get(host: &str, path: &str) -> Result<Vec<u8>, Error> {
92    let response = perform_get(host, path, &[])?;
93    http::successful_body(response)
94}
95
96/// Perform an HTTPS `GET` request and decode the body as UTF-8 text.
97///
98/// # Errors
99///
100/// Returns [`Error`] if the request fails or the response body is not valid UTF-8.
101pub fn https_get_text(host: &str, path: &str) -> Result<String, Error> {
102    let body = https_get(host, path)?;
103    String::from_utf8(body)
104        .map_err(|error| Error::Io(format!("HTTP body was not valid UTF-8: {error}")))
105}
106
107/// Body, ETag, and Last-Modified returned by a conditional GET.
108pub type ConditionalGetResult = Result<(Vec<u8>, Option<String>, Option<String>), Error>;
109
110/// Perform a conditional HTTPS `GET` request using `ETag` and `Last-Modified` hints.
111///
112/// When the server responds with `304 Not Modified`, the returned body is empty and the cached
113/// validators are preserved.
114///
115/// # Errors
116///
117/// Returns [`Error`] if DNS resolution, socket connection, TLS setup, or the HTTP request fails.
118pub fn https_get_conditional(
119    host: &str,
120    path: &str,
121    etag: Option<&str>,
122    last_modified: Option<&str>,
123) -> ConditionalGetResult {
124    let mut headers = Vec::new();
125    if let Some(etag) = etag {
126        headers.push(("If-None-Match".to_string(), etag.to_string()));
127    }
128    if let Some(last_modified) = last_modified {
129        headers.push(("If-Modified-Since".to_string(), last_modified.to_string()));
130    }
131
132    let response = perform_get(host, path, &headers)?;
133    if response.status_code == 304 {
134        return Ok((
135            Vec::new(),
136            response.etag.or_else(|| etag.map(ToOwned::to_owned)),
137            response
138                .last_modified
139                .or_else(|| last_modified.map(ToOwned::to_owned)),
140        ));
141    }
142
143    let etag = response.etag.clone();
144    let last_modified = response.last_modified.clone();
145    let body = http::successful_body(response)?;
146    Ok((body, etag, last_modified))
147}
148
149/// Read an environment variable from the sidecar process.
150pub fn env_var(name: &str) -> Option<String> {
151    std::env::var(name).ok()
152}
153
154/// Attempt a TCP connection and return the time taken to establish it.
155///
156/// This is primarily useful for health-check sidecars that want to measure reachability or
157/// approximate latency.
158///
159/// # Errors
160///
161/// Returns [`Error`] if DNS resolution fails or no socket can be connected before the timeout.
162pub fn tcp_connect(host: &str, port: u16, timeout_ms: u32) -> Result<Duration, Error> {
163    let addrs = DNS_RESOLVER.with(|resolver| resolver.borrow_mut().resolve(host))?;
164    socket::connect_any(&addrs, port, Duration::from_millis(u64::from(timeout_ms)))
165}
166
167/// Split an HTTPS URL into `(host, path)` for use with [`https_get`] helpers.
168///
169/// # Errors
170///
171/// Returns [`Error`] if the URL is not HTTPS or does not contain a valid host.
172pub fn split_https_url(url: &str) -> Result<(String, String), Error> {
173    let rest = url
174        .strip_prefix("https://")
175        .ok_or_else(|| Error::Io(format!("unsupported URL scheme in '{url}'")))?;
176
177    if rest.is_empty() {
178        return Err(Error::Io("HTTPS URL is missing a host".to_string()));
179    }
180
181    let (host, path) = if let Some((host, remainder)) = rest.split_once('/') {
182        (host, format!("/{}", remainder))
183    } else if let Some((host, query)) = rest.split_once('?') {
184        (host, format!("/?{query}"))
185    } else {
186        (rest, "/".to_string())
187    };
188
189    if host.is_empty() {
190        return Err(Error::Io(format!("HTTPS URL is missing a host in '{url}'")));
191    }
192
193    Ok((host.to_string(), path))
194}
195
196fn perform_get(
197    host: &str,
198    path: &str,
199    headers: &[(String, String)],
200) -> Result<http::HttpResponse, Error> {
201    let addrs = DNS_RESOLVER.with(|resolver| resolver.borrow_mut().resolve(host))?;
202    http::https_get_with_candidates(host, path, headers, &addrs)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    #[ignore = "requires outbound network"]
211    fn https_get_and_https_get_text_fetch_public_https_payloads() {
212        let text_body = https_get_text("api.coinbase.com", "/v2/prices/BTC-USD/spot")
213            .expect("fetch coinbase text payload");
214        assert!(text_body.contains("\"amount\""));
215
216        let bytes_body =
217            https_get("api.coinbase.com", "/v2/prices/BTC-USD/spot").expect("fetch bytes payload");
218        let bytes_text =
219            std::str::from_utf8(&bytes_body).expect("coinbase payload should be UTF-8");
220        assert!(bytes_text.contains("\"amount\""));
221    }
222
223    #[test]
224    #[ignore = "requires outbound network"]
225    fn https_get_conditional_reuses_etag_for_not_modified_responses() {
226        let (body, etag, last_modified) = https_get_conditional("api.github.com", "/", None, None)
227            .expect("fetch github metadata");
228        assert!(!body.is_empty());
229        assert!(etag.is_some() || last_modified.is_some());
230
231        let (cached_body, cached_etag, cached_last_modified) = https_get_conditional(
232            "api.github.com",
233            "/",
234            etag.as_deref(),
235            last_modified.as_deref(),
236        )
237        .expect("perform conditional github request");
238        assert!(cached_body.is_empty(), "expected 304 body to be empty");
239        assert!(cached_etag.is_some() || cached_last_modified.is_some());
240    }
241
242    #[test]
243    fn split_https_url_handles_plain_and_query_urls() {
244        assert_eq!(
245            split_https_url("https://calendar.google.com/calendar/ical/test/basic.ics").unwrap(),
246            (
247                "calendar.google.com".to_string(),
248                "/calendar/ical/test/basic.ics".to_string()
249            )
250        );
251        assert_eq!(
252            split_https_url("https://example.com?foo=bar").unwrap(),
253            ("example.com".to_string(), "/?foo=bar".to_string())
254        );
255    }
256}