mod options;
mod policy;
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::Path;
use std::sync::mpsc::{self, RecvTimeoutError};
use std::thread;
use std::time::{Duration, Instant};
use ureq::Agent;
use ureq::tls::{RootCerts, TlsConfig};
pub use options::HttpOptions;
use policy::Transport;
pub(crate) use policy::{Metadata, ensure_secure};
use crate::cancel::{self, CancelToken};
use crate::error::{Error, Result};
pub(crate) const USER_AGENT: &str = concat!("gpui-updater/", env!("CARGO_PKG_VERSION"));
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const RECV_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
const BODY_STALL_TIMEOUT: Duration = Duration::from_secs(30);
const PRE_BODY_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_RETRIES: u32 = 5;
const RETRY_BASE_DELAY: Duration = Duration::from_millis(500);
const RETRY_MAX_DELAY: Duration = Duration::from_secs(4);
fn backoff(attempt: u32) -> Duration {
RETRY_BASE_DELAY
.saturating_mul(2u32.saturating_pow(attempt))
.min(RETRY_MAX_DELAY)
}
fn tls_config() -> TlsConfig {
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.build()
}
fn metadata_agent(http: &HttpOptions) -> Result<Agent> {
Ok(Agent::config_builder()
.user_agent(USER_AGENT)
.tls_config(tls_config())
.https_only(true)
.proxy(http.resolve_proxy()?)
.timeout_resolve(Some(CONNECT_TIMEOUT))
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_recv_response(Some(RECV_RESPONSE_TIMEOUT))
.build()
.into())
}
fn download_agent(stall: Duration, http: &HttpOptions) -> Result<Agent> {
Ok(Agent::config_builder()
.user_agent(USER_AGENT)
.tls_config(tls_config())
.https_only(true)
.proxy(http.resolve_proxy()?)
.timeout_resolve(Some(CONNECT_TIMEOUT))
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_recv_body(Some(stall))
.build()
.into())
}
fn build(
agent: &Agent,
url: &str,
headers: &[(String, String)],
transport: Transport,
) -> ureq::RequestBuilder<ureq::typestate::WithoutBody> {
let config = agent.get(url).config();
let mut req = match transport {
Transport::Https => config.https_only(true),
Transport::LoopbackHttp => config.https_only(false).max_redirects(0),
}
.build();
for (k, v) in headers {
req = req.header(k.as_str(), v.as_str());
}
req
}
fn http_err(e: ureq::Error) -> Error {
match e {
ureq::Error::RequireHttpsOnly(url) => Error::InsecureUrl(url),
other => Error::Http(other.to_string()),
}
}
fn finalize(url: &str, e: ureq::Error) -> Error {
match e {
ureq::Error::StatusCode(code) => Error::Http(format!("GET {url} -> {code}")),
other => http_err(other),
}
}
fn is_transient(error: &ureq::Error) -> bool {
use ureq::Error;
match error {
Error::StatusCode(code) => *code >= 500 || matches!(*code, 408 | 429),
Error::Io(_)
| Error::Timeout(_)
| Error::ConnectionFailed
| Error::HostNotFound
| Error::Protocol(_) => true,
_ => false,
}
}
fn retrying<T>(
mut attempt: impl FnMut() -> std::result::Result<T, ureq::Error>,
) -> std::result::Result<T, ureq::Error> {
let mut tries: u32 = 0;
loop {
match attempt() {
Ok(value) => return Ok(value),
Err(e) if tries < MAX_RETRIES && is_transient(&e) => {
thread::sleep(backoff(tries));
tries += 1;
}
Err(e) => return Err(e),
}
}
}
fn get_bytes(agent: &Agent, request: &Request<'_>) -> std::result::Result<Vec<u8>, ureq::Error> {
let mut resp = build(agent, request.url, &request.headers, request.transport).call()?;
let status = resp.status();
if !status.is_success() {
return Err(ureq::Error::StatusCode(status.as_u16()));
}
resp.body_mut()
.with_config()
.limit(request.kind.max_len())
.read_to_vec()
}
struct Request<'a> {
url: &'a str,
headers: Vec<(String, String)>,
transport: Transport,
kind: Metadata,
}
fn get_metadata(
url: &str,
headers: &[(&str, &str)],
kind: Metadata,
http: &HttpOptions,
) -> Result<Vec<u8>> {
let request = Request {
url,
headers: http.merge(headers),
transport: Transport::of(url)?,
kind,
};
let agent = metadata_agent(http)?;
retrying(|| get_bytes(&agent, &request)).map_err(|e| finalize(url, e))
}
pub(crate) fn get_json<T: serde::de::DeserializeOwned>(
url: &str,
headers: &[(&str, &str)],
http: &HttpOptions,
) -> Result<T> {
let body = get_metadata(url, headers, Metadata::Document, http)?;
serde_json::from_slice(&body).map_err(|e| Error::Parse(e.to_string()))
}
pub(crate) fn get_string(
url: &str,
headers: &[(&str, &str)],
kind: Metadata,
http: &HttpOptions,
) -> Result<String> {
let bytes = get_metadata(url, headers, kind, http)?;
String::from_utf8(bytes).map_err(|e| Error::Parse(e.to_string()))
}
#[derive(Clone, Copy)]
pub(crate) struct Fetch<'a> {
pub(crate) url: &'a str,
pub(crate) headers: &'a [(&'a str, &'a str)],
pub(crate) expected_len: Option<u64>,
pub(crate) max_len: u64,
pub(crate) cancel: Option<&'a CancelToken>,
pub(crate) http: &'a HttpOptions,
}
impl<'a> Fetch<'a> {
pub(crate) fn new(url: &'a str) -> Self {
Self {
url,
headers: &[],
expected_len: None,
max_len: u64::MAX,
cancel: None,
http: &options::DEFAULT,
}
}
pub(crate) fn http(self, http: &'a HttpOptions) -> Self {
Self { http, ..self }
}
pub(crate) fn cancel(self, cancel: &'a CancelToken) -> Self {
Self {
cancel: Some(cancel),
..self
}
}
fn check_cancelled(&self) -> Result<()> {
self.cancel.map_or(Ok(()), CancelToken::check)
}
fn pause(&self, duration: Duration) -> Result<()> {
if let Some(cancel) = self.cancel {
cancel.sleep(duration)
} else {
thread::sleep(duration);
Ok(())
}
}
pub(crate) fn expect_len(self, len: u64) -> Self {
Self {
expected_len: (len > 0).then_some(len),
..self
}
}
pub(crate) fn max_len(self, len: u64) -> Self {
Self {
max_len: len,
..self
}
}
fn limit(&self) -> u64 {
self.expected_len.unwrap_or(self.max_len)
}
}
#[derive(Clone, Copy)]
struct Transfer<'a> {
agent: &'a Agent,
pre_body: Duration,
}
pub(crate) fn download(
fetch: &Fetch<'_>,
dest: &Path,
progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
let agent = download_agent(BODY_STALL_TIMEOUT, fetch.http)?;
let transfer = Transfer {
agent: &agent,
pre_body: PRE_BODY_TIMEOUT,
};
download_with(transfer, fetch, dest, progress)
}
fn download_with(
transfer: Transfer<'_>,
fetch: &Fetch<'_>,
dest: &Path,
mut progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
if let Some(expected) = fetch.expected_len.filter(|&len| len > fetch.max_len) {
return Err(Error::Http(format!(
"GET {} -> {expected} bytes published, more than the {}-byte limit",
fetch.url, fetch.max_len
)));
}
let result = retry_download(transfer, fetch, dest, &mut progress);
if matches!(result, Err(Error::Cancelled)) {
let _ = std::fs::remove_file(dest);
}
result
}
#[derive(Debug, Default)]
struct Partial {
len: u64,
validator: Option<String>,
total: Option<u64>,
}
impl Partial {
fn resume_point(&self) -> Option<(u64, &str)> {
let validator = self.validator.as_deref()?;
(self.len > 0).then_some((self.len, validator))
}
}
fn retry_download(
transfer: Transfer<'_>,
fetch: &Fetch<'_>,
dest: &Path,
progress: &mut impl FnMut(u64, Option<u64>),
) -> Result<()> {
let mut partial = Partial::default();
let mut tries: u32 = 0;
loop {
fetch.check_cancelled()?;
let before = partial.len;
match download_once(transfer, fetch, dest, &mut partial, progress) {
Ok(()) => return Ok(()),
Err((Error::Cancelled, _)) => return Err(Error::Cancelled),
Err((_, transient)) if transient && tries < MAX_RETRIES => {
if partial.resume_point().is_some() {
if partial.len > before {
tries = 0;
}
progress(partial.len, partial.total);
} else {
partial = Partial::default();
progress(0, None);
}
fetch.pause(backoff(tries))?;
tries += 1;
}
Err((e, _)) => return Err(e),
}
}
}
fn call_with_deadline(
transfer: Transfer<'_>,
fetch: &Fetch<'_>,
transport: Transport,
resume: Option<(u64, &str)>,
) -> std::result::Result<ureq::http::Response<ureq::Body>, (Error, bool)> {
let (url, deadline) = (fetch.url, transfer.pre_body);
let range =
resume.map(|(offset, validator)| (format!("bytes={offset}-"), validator.to_string()));
let (tx, rx) = mpsc::channel();
let agent = transfer.agent.clone();
let owned_url = url.to_string();
let mut own = vec![("accept", "application/octet-stream")];
own.extend_from_slice(fetch.headers);
let owned_headers = fetch.http.merge(&own);
thread::spawn(move || {
let mut req = build(&agent, &owned_url, &owned_headers, transport);
if let Some((range, validator)) = &range {
req = req
.header("range", range.as_str())
.header("if-range", validator.as_str());
}
let _ = tx.send(req.call());
});
let started = Instant::now();
let received = loop {
let left = deadline.saturating_sub(started.elapsed());
match rx.recv_timeout(left.min(cancel::POLL)) {
Ok(received) => break Some(received),
Err(RecvTimeoutError::Disconnected) => break None,
Err(RecvTimeoutError::Timeout) => {
if fetch.check_cancelled().is_err() {
return Err((Error::Cancelled, false));
}
if left.is_zero() {
break None;
}
}
}
};
match received {
Some(Ok(resp)) => Ok(resp),
Some(Err(e)) => {
let transient = is_transient(&e);
Err((http_err(e), transient))
}
None => Err((
Error::Http(format!("GET {url} -> no response within {deadline:?}")),
true,
)),
}
}
fn download_once(
transfer: Transfer<'_>,
fetch: &Fetch<'_>,
dest: &Path,
partial: &mut Partial,
progress: &mut impl FnMut(u64, Option<u64>),
) -> std::result::Result<(), (Error, bool)> {
let url = fetch.url;
let transport = Transport::of(url).map_err(|e| (e, false))?;
let mut resp = call_with_deadline(transfer, fetch, transport, partial.resume_point())?;
let start = body_start(&resp, url, partial)?;
let announced = resp
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(|rest| start + rest);
if let (Some(expected), Some(announced)) = (fetch.expected_len, announced) {
if announced != expected {
return Err((
Error::Http(format!(
"GET {url} -> {announced} bytes, but {expected} were published"
)),
false,
));
}
}
if let Some(announced) = announced.filter(|&len| len > fetch.max_len) {
return Err((
Error::Http(format!(
"GET {url} -> {announced} bytes, more than the {}-byte limit",
fetch.max_len
)),
false,
));
}
let total = announced.or(fetch.expected_len);
let limit = fetch.limit();
let bound = if fetch.expected_len.is_some() {
"published"
} else {
"allowed"
};
match validator_of(resp.headers()) {
Some(validator) => partial.validator = Some(validator),
None if start == 0 => partial.validator = None,
None => {}
}
partial.len = start;
partial.total = total;
let mut file = open_at(dest, start).map_err(|e| (Error::Io(e), false))?;
let mut reader = resp.body_mut().as_reader();
let mut buf = vec![0u8; 64 * 1024];
let mut downloaded = start;
loop {
if fetch.check_cancelled().is_err() {
return Err((Error::Cancelled, false));
}
let n = reader.read(&mut buf).map_err(|e| (Error::Io(e), true))?;
if n == 0 {
break;
}
downloaded += n as u64;
if downloaded > limit {
return Err((
Error::Http(format!("GET {url} -> more than the {bound} {limit} bytes")),
false,
));
}
file.write_all(&buf[..n])
.map_err(|e| (Error::Io(e), false))?;
partial.len = downloaded;
progress(downloaded, total);
}
if downloaded < limit && fetch.expected_len.is_some() {
return Err((
Error::Http(format!(
"GET {url} -> body ended after {downloaded} of {limit} bytes"
)),
true,
));
}
file.flush().map_err(|e| (Error::Io(e), false))?;
Ok(())
}
fn body_start(
resp: &ureq::http::Response<ureq::Body>,
url: &str,
partial: &mut Partial,
) -> std::result::Result<u64, (Error, bool)> {
let code = resp.status().as_u16();
let asked = partial.resume_point().map(|(offset, _)| offset);
match (code, asked) {
(206, Some(offset)) => {
let range = resp
.headers()
.get("content-range")
.and_then(|v| v.to_str().ok())
.and_then(content_range);
match range {
Some((first, size))
if first == offset
&& size.is_none_or(|size| partial.total.is_none_or(|t| t == size)) =>
{
Ok(offset)
}
_ => {
*partial = Partial::default();
Err((
Error::Http(format!("GET {url} -> 206 for another range")),
true,
))
}
}
}
(206, None) => Err((
Error::Http(format!("GET {url} -> 206 without a range request")),
false,
)),
(416, Some(_)) => {
*partial = Partial::default();
Err((Error::Http(format!("GET {url} -> 416")), true))
}
(200..=299, _) => Ok(0),
(code, _) => {
let transient = code >= 500 || matches!(code, 408 | 429);
Err((Error::Http(format!("GET {url} -> {code}")), transient))
}
}
}
fn content_range(value: &str) -> Option<(u64, Option<u64>)> {
let rest = value.trim().strip_prefix("bytes ")?;
let (span, size) = rest.split_once('/')?;
let (first, last) = span.split_once('-')?;
let first: u64 = first.trim().parse().ok()?;
let last: u64 = last.trim().parse().ok()?;
let size = match size.trim() {
"*" => None,
size => Some(size.parse::<u64>().ok()?),
};
if last < first || size.is_some_and(|size| last >= size) {
return None;
}
Some((first, size))
}
fn validator_of(headers: &ureq::http::HeaderMap) -> Option<String> {
let get = |name: &str| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|v| !v.is_empty())
};
get("etag")
.filter(|tag| !tag.starts_with("W/"))
.or_else(|| get("last-modified"))
.map(str::to_string)
}
fn open_at(dest: &Path, start: u64) -> io::Result<File> {
if start == 0 {
return File::create(dest);
}
let mut file = OpenOptions::new().write(true).open(dest)?;
file.set_len(start)?;
file.seek(SeekFrom::Start(start))?;
Ok(file)
}
#[cfg(test)]
mod tests;