489 lines
15 KiB
Rust
489 lines
15 KiB
Rust
use std::{
|
|
collections::HashMap,
|
|
fs::{self, File},
|
|
io::{self, Read, Write},
|
|
net::IpAddr,
|
|
path::{Path, PathBuf},
|
|
sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
mpsc::{self, RecvTimeoutError},
|
|
},
|
|
thread,
|
|
time::Duration,
|
|
};
|
|
|
|
use anyhow::{Context, Result, anyhow, bail, ensure};
|
|
use semver::Version;
|
|
use serde::Deserialize;
|
|
use sha2::{Digest, Sha256};
|
|
use ureq::{
|
|
Agent, Body,
|
|
http::{Response, Uri, header::LOCATION},
|
|
unversioned::{
|
|
resolver::DefaultResolver,
|
|
transport::{
|
|
Buffers, ConnectionDetails, Connector, DefaultConnector, NextTimeout, Transport,
|
|
},
|
|
},
|
|
};
|
|
|
|
const MANIFEST_LIMIT: u64 = 1 << 20;
|
|
const DOWNLOAD_LIMIT: u64 = 4 << 30;
|
|
const MANIFEST_TIMEOUT: Duration = Duration::from_secs(30);
|
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
|
const MAX_REDIRECTS: usize = 5;
|
|
const PACKAGE_MANAGED: [&str; 5] = ["/usr", "/opt", "/app", "/snap", "/nix/store"];
|
|
|
|
/// Longest a download may go without a byte.
|
|
pub const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Release {
|
|
pub version: Version,
|
|
pub url: String,
|
|
pub sha256: String,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
pub enum Check {
|
|
Current,
|
|
Available(Release),
|
|
}
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
pub enum InstallPolicy {
|
|
SelfManaged,
|
|
PackageManager,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct Manifest {
|
|
version: Version,
|
|
assets: HashMap<String, Asset>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct Asset {
|
|
url: String,
|
|
sha256: String,
|
|
}
|
|
|
|
/// The manifest key of this build, such as `macos-aarch64`.
|
|
pub fn platform() -> String {
|
|
format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
|
|
}
|
|
|
|
pub fn install_policy() -> Result<InstallPolicy> {
|
|
let exe = std::env::current_exe().context("cannot find the running executable")?;
|
|
Ok(policy_for(std::env::consts::OS, &exe))
|
|
}
|
|
|
|
fn policy_for(os: &str, exe: &Path) -> InstallPolicy {
|
|
let managed = PACKAGE_MANAGED.iter().any(|prefix| exe.starts_with(prefix));
|
|
if os == "linux" && managed {
|
|
InstallPolicy::PackageManager
|
|
} else {
|
|
InstallPolicy::SelfManaged
|
|
}
|
|
}
|
|
|
|
/// Fetches the manifest and compares it with `current`. Never installs.
|
|
pub fn check(current: &Version, manifest_url: &str) -> Result<Check> {
|
|
let host = secure_host(manifest_url)?;
|
|
log::info!("update check: asking {host}");
|
|
let result = fetch_manifest(current, manifest_url);
|
|
match &result {
|
|
Ok(Check::Current) => log::info!("update check: {host} has nothing newer than {current}"),
|
|
Ok(Check::Available(release)) => {
|
|
log::info!("update check: {host} offers {}", release.version)
|
|
}
|
|
Err(error) => log::warn!("update check: {host} failed: {error:#}"),
|
|
}
|
|
result
|
|
}
|
|
|
|
fn fetch_manifest(current: &Version, manifest_url: &str) -> Result<Check> {
|
|
let text = fetch(&manifest_agent(), manifest_url)?
|
|
.body_mut()
|
|
.with_config()
|
|
.limit(MANIFEST_LIMIT)
|
|
.read_to_string()
|
|
.context("cannot read the update manifest")?;
|
|
let manifest: Manifest =
|
|
serde_json::from_str(&text).context("the update manifest is not valid")?;
|
|
let key = platform();
|
|
let Some(asset) = manifest.assets.get(&key) else {
|
|
bail!("release {} has no build for {key}", manifest.version);
|
|
};
|
|
secure_host(&asset.url)?;
|
|
if manifest.version <= *current {
|
|
return Ok(Check::Current);
|
|
}
|
|
Ok(Check::Available(Release {
|
|
version: manifest.version,
|
|
url: asset.url.clone(),
|
|
sha256: asset.sha256.to_lowercase(),
|
|
}))
|
|
}
|
|
|
|
/// Downloads into `dir`, calling `progress(done, total)`. `Ok(None)` means cancelled.
|
|
pub fn download(
|
|
release: &Release,
|
|
dir: &Path,
|
|
cancel: &AtomicBool,
|
|
idle: Duration,
|
|
progress: &mut dyn FnMut(u64, Option<u64>),
|
|
) -> Result<Option<PathBuf>> {
|
|
let host = secure_host(&release.url)?;
|
|
let name = file_name(&release.url)?;
|
|
let part = dir.join(format!("{name}.part"));
|
|
let target = dir.join(name);
|
|
log::info!("update download: {} from {host}", release.version);
|
|
|
|
let outcome = stream(release, &part, cancel, idle, progress);
|
|
match outcome {
|
|
Ok(true) => {
|
|
fs::rename(&part, &target)
|
|
.with_context(|| format!("cannot move {} into place", part.display()))?;
|
|
log::info!(
|
|
"update download: {} saved to {}",
|
|
release.version,
|
|
target.display()
|
|
);
|
|
Ok(Some(target))
|
|
}
|
|
Ok(false) => {
|
|
log::info!("update download: {} cancelled", release.version);
|
|
remove_part(&part)?;
|
|
Ok(None)
|
|
}
|
|
Err(error) => {
|
|
log::warn!("update download: {} failed: {error:#}", release.version);
|
|
if let Err(cleanup) = remove_part(&part) {
|
|
log::warn!("update download: {cleanup:#}");
|
|
}
|
|
Err(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Checks a user's file against its published hash.
|
|
pub fn verify_file(path: &Path, sha256: &str) -> Result<()> {
|
|
let mut file = File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
|
|
let mut hasher = Sha256::new();
|
|
io::copy(&mut file, &mut hasher).with_context(|| format!("cannot read {}", path.display()))?;
|
|
expect_hash(hasher, sha256)
|
|
}
|
|
|
|
fn stream(
|
|
release: &Release,
|
|
part: &Path,
|
|
cancel: &AtomicBool,
|
|
idle: Duration,
|
|
progress: &mut dyn FnMut(u64, Option<u64>),
|
|
) -> Result<bool> {
|
|
let response = fetch(&download_agent(idle), &release.url)?;
|
|
let total = response.body().content_length();
|
|
let (_, body) = response.into_parts();
|
|
let (sender, receiver) = mpsc::sync_channel::<io::Result<Vec<u8>>>(4);
|
|
thread::spawn(move || {
|
|
let mut reader = body.into_with_config().limit(DOWNLOAD_LIMIT).reader();
|
|
loop {
|
|
let mut chunk = vec![0u8; 64 * 1024];
|
|
match reader.read(&mut chunk) {
|
|
Ok(read) => {
|
|
chunk.truncate(read);
|
|
if sender.send(Ok(chunk)).is_err() || read == 0 {
|
|
return;
|
|
}
|
|
}
|
|
Err(error) => {
|
|
let _ = sender.send(Err(error));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let mut file =
|
|
File::create(part).with_context(|| format!("cannot create {}", part.display()))?;
|
|
let mut hasher = Sha256::new();
|
|
let mut done = 0u64;
|
|
loop {
|
|
if cancel.load(Ordering::Relaxed) {
|
|
return Ok(false);
|
|
}
|
|
match receiver.recv_timeout(Duration::from_millis(100)) {
|
|
Ok(Ok(chunk)) if chunk.is_empty() => break,
|
|
Ok(Ok(chunk)) => {
|
|
hasher.update(&chunk);
|
|
file.write_all(&chunk)
|
|
.with_context(|| format!("cannot write {}", part.display()))?;
|
|
done += chunk.len() as u64;
|
|
progress(done, total);
|
|
}
|
|
Ok(Err(error)) if is_timeout(&error) => {
|
|
bail!("the download stalled: no data for {idle:?}")
|
|
}
|
|
Ok(Err(error)) => return Err(anyhow!(error).context("the download was interrupted")),
|
|
Err(RecvTimeoutError::Timeout) => {}
|
|
Err(RecvTimeoutError::Disconnected) => bail!("the download thread ended early"),
|
|
}
|
|
}
|
|
file.sync_all()
|
|
.with_context(|| format!("cannot flush {}", part.display()))?;
|
|
expect_hash(hasher, &release.sha256)?;
|
|
Ok(true)
|
|
}
|
|
|
|
fn is_timeout(error: &io::Error) -> bool {
|
|
let from_ureq = error
|
|
.get_ref()
|
|
.and_then(|inner| inner.downcast_ref::<ureq::Error>())
|
|
.is_some_and(|inner| matches!(inner, ureq::Error::Timeout(_)));
|
|
from_ureq
|
|
|| matches!(
|
|
error.kind(),
|
|
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
|
|
)
|
|
}
|
|
|
|
fn remove_part(part: &Path) -> Result<()> {
|
|
match fs::remove_file(part) {
|
|
Ok(()) => Ok(()),
|
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
|
Err(error) => Err(error).with_context(|| format!("cannot remove {}", part.display())),
|
|
}
|
|
}
|
|
|
|
fn expect_hash(hasher: Sha256, expected: &str) -> Result<()> {
|
|
let actual: String = hasher
|
|
.finalize()
|
|
.iter()
|
|
.map(|byte| format!("{byte:02x}"))
|
|
.collect();
|
|
ensure!(
|
|
actual.eq_ignore_ascii_case(expected),
|
|
"the file has sha256 {actual}, the release says {expected}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn file_name(url: &str) -> Result<&str> {
|
|
let url = url.split_once(['?', '#']).map_or(url, |(head, _)| head);
|
|
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
|
|
let path = rest.split_once('/').map_or("", |(_, path)| path);
|
|
let name = path.rsplit_once('/').map_or(path, |(_, name)| name);
|
|
ensure!(
|
|
!name.is_empty() && name != "." && name != ".." && !name.contains(['\\', ':']),
|
|
"{url} names no safe file"
|
|
);
|
|
Ok(name)
|
|
}
|
|
|
|
/// Accepts https, or http to this machine. Returns the host.
|
|
fn secure_host(url: &str) -> Result<String> {
|
|
let uri: Uri = url.parse().with_context(|| format!("{url} is not a URL"))?;
|
|
let authority = uri
|
|
.authority()
|
|
.with_context(|| format!("{url} has no host"))?;
|
|
ensure!(
|
|
!authority.as_str().contains('@'),
|
|
"{url} carries credentials"
|
|
);
|
|
let host = authority.host();
|
|
let local = host == "localhost"
|
|
|| host
|
|
.trim_matches(['[', ']'])
|
|
.parse::<IpAddr>()
|
|
.is_ok_and(|ip| ip.is_loopback());
|
|
ensure!(
|
|
uri.scheme_str() == Some("https") || (uri.scheme_str() == Some("http") && local),
|
|
"{url} is not https"
|
|
);
|
|
Ok(host.to_string())
|
|
}
|
|
|
|
/// GETs `url`, following redirects only to places `secure_host` accepts.
|
|
pub(crate) fn fetch(agent: &Agent, url: &str) -> Result<Response<Body>> {
|
|
let mut url = url.to_string();
|
|
for _ in 0..=MAX_REDIRECTS {
|
|
secure_host(&url)?;
|
|
let response = agent
|
|
.get(&url)
|
|
.call()
|
|
.with_context(|| format!("cannot fetch {url}"))?;
|
|
if !response.status().is_redirection() {
|
|
return Ok(response);
|
|
}
|
|
let location = response
|
|
.headers()
|
|
.get(LOCATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
.with_context(|| format!("{url} redirects without a location"))?;
|
|
url = if location.starts_with('/') {
|
|
let uri: Uri = url.parse()?;
|
|
format!(
|
|
"{}://{}{location}",
|
|
uri.scheme_str().unwrap_or("https"),
|
|
uri.authority().map_or("", |a| a.as_str())
|
|
)
|
|
} else {
|
|
location.to_string()
|
|
};
|
|
}
|
|
bail!("too many redirects")
|
|
}
|
|
|
|
pub(crate) fn manifest_agent() -> Agent {
|
|
Agent::config_builder()
|
|
.timeout_global(Some(MANIFEST_TIMEOUT))
|
|
.max_redirects(0)
|
|
.max_redirects_will_error(false)
|
|
.build()
|
|
.into()
|
|
}
|
|
|
|
/// No total timeout; a silent socket times out.
|
|
fn download_agent(idle: Duration) -> Agent {
|
|
let config = Agent::config_builder()
|
|
.timeout_connect(Some(CONNECT_TIMEOUT))
|
|
.timeout_recv_response(Some(CONNECT_TIMEOUT))
|
|
.max_redirects(0)
|
|
.max_redirects_will_error(false)
|
|
.build();
|
|
Agent::with_parts(
|
|
config,
|
|
DefaultConnector::new().chain(IdleConnector(idle)),
|
|
DefaultResolver::default(),
|
|
)
|
|
}
|
|
|
|
/// Caps socket waits at `idle`; a stall frees the socket.
|
|
#[derive(Debug)]
|
|
struct IdleConnector(Duration);
|
|
|
|
impl<In: Transport> Connector<In> for IdleConnector {
|
|
type Out = Idle<In>;
|
|
|
|
fn connect(
|
|
&self,
|
|
_: &ConnectionDetails,
|
|
chained: Option<In>,
|
|
) -> Result<Option<Idle<In>>, ureq::Error> {
|
|
Ok(chained.map(|inner| Idle {
|
|
inner,
|
|
idle: self.0,
|
|
}))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Idle<T> {
|
|
inner: T,
|
|
idle: Duration,
|
|
}
|
|
|
|
impl<T> Idle<T> {
|
|
fn capped(&self, mut timeout: NextTimeout) -> NextTimeout {
|
|
timeout.after = timeout.after.min(self.idle.into());
|
|
timeout
|
|
}
|
|
}
|
|
|
|
impl<T: Transport> Transport for Idle<T> {
|
|
fn buffers(&mut self) -> &mut dyn Buffers {
|
|
self.inner.buffers()
|
|
}
|
|
|
|
fn transmit_output(&mut self, amount: usize, timeout: NextTimeout) -> Result<(), ureq::Error> {
|
|
let timeout = self.capped(timeout);
|
|
self.inner.transmit_output(amount, timeout)
|
|
}
|
|
|
|
fn await_input(&mut self, timeout: NextTimeout) -> Result<bool, ureq::Error> {
|
|
let timeout = self.capped(timeout);
|
|
self.inner.await_input(timeout)
|
|
}
|
|
|
|
fn is_open(&mut self) -> bool {
|
|
self.inner.is_open()
|
|
}
|
|
|
|
fn is_tls(&self) -> bool {
|
|
self.inner.is_tls()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::path::Path;
|
|
|
|
use super::{InstallPolicy, file_name, policy_for, secure_host};
|
|
|
|
#[test]
|
|
fn only_a_linux_package_install_defers_to_the_package_manager() {
|
|
let usr = Path::new("/usr/bin/ely-doc");
|
|
assert_eq!(policy_for("linux", usr), InstallPolicy::PackageManager);
|
|
assert_eq!(
|
|
policy_for("linux", Path::new("/app/bin/ely-doc")),
|
|
InstallPolicy::PackageManager
|
|
);
|
|
assert_eq!(
|
|
policy_for("linux", Path::new("/home/me/Apps/ely-doc")),
|
|
InstallPolicy::SelfManaged
|
|
);
|
|
assert_eq!(
|
|
policy_for("linux", Path::new("/tmp/.mount_ElyDoc/usr/bin/ely-doc")),
|
|
InstallPolicy::SelfManaged
|
|
);
|
|
assert_eq!(policy_for("macos", usr), InstallPolicy::SelfManaged);
|
|
assert_eq!(
|
|
policy_for("windows", Path::new("C:/Program Files/Ely")),
|
|
InstallPolicy::SelfManaged
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plain_http_is_for_this_machine_only() {
|
|
for ok in [
|
|
"https://example.com/a.json",
|
|
"http://127.0.0.1:8080/a",
|
|
"http://localhost/a",
|
|
"http://[::1]:9/a",
|
|
] {
|
|
secure_host(ok).unwrap();
|
|
}
|
|
for bad in [
|
|
"http://example.com/a",
|
|
"http://127.0.0.1.evil.com/a",
|
|
"http://localhost.evil.com/a",
|
|
"http://127.0.0.1:1@192.168.1.5:80/a",
|
|
"https://user:pw@example.com/a",
|
|
"ftp://x/y",
|
|
"file:///etc/passwd",
|
|
"/relative",
|
|
] {
|
|
assert!(secure_host(bad).is_err(), "{bad}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_download_names_one_file() {
|
|
assert_eq!(
|
|
file_name("https://h/p/ElyDoc-0.2.0.dmg?sig=1#x").unwrap(),
|
|
"ElyDoc-0.2.0.dmg"
|
|
);
|
|
for bad in [
|
|
"https://h/p/",
|
|
"https://h/..",
|
|
"https://h/.",
|
|
"https://h",
|
|
"https://h/..\\evil.exe",
|
|
"https://h/c:evil",
|
|
] {
|
|
assert!(file_name(bad).is_err(), "{bad}");
|
|
}
|
|
}
|
|
}
|