1#![deny(missing_docs)]
2mod 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#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum Error {
45 Dns(String),
47 Tls(String),
49 Http {
51 status: u16,
53 body: String,
55 },
56 Io(String),
58 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
86pub 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
96pub 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
107pub type ConditionalGetResult = Result<(Vec<u8>, Option<String>, Option<String>), Error>;
109
110pub 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
149pub fn env_var(name: &str) -> Option<String> {
151 std::env::var(name).ok()
152}
153
154pub 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
167pub 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}