use crate::config::Config;
use crate::paths;
use crate::units::parse_duration;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::os::unix::fs::OpenOptionsExt;
use std::time::Duration;
pub const NEVER: &str = "never";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Record {
pub last_checked: u64,
pub newest: Option<String>,
pub source: Option<String>,
pub error: Option<String>,
pub told: Option<String>,
}
#[derive(Debug)]
pub struct Answer {
pub newest: String,
pub source: String,
}
pub fn read_record() -> Record {
let Ok(dir) = paths::state_dir() else {
return Record::default();
};
read_record_in(&dir)
}
fn read_record_in(dir: &std::path::Path) -> Record {
let path = dir.join("update.json");
std::fs::read(path)
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
fn write_record_in(dir: &std::path::Path, record: &Record) -> Result<()> {
paths::ensure_dir(dir, 0o700)?;
let bytes = serde_json::to_vec_pretty(record)?;
crate::job::write_atomic(&dir.join("update.json"), &bytes, 0o600)
}
fn with_the_record(change: impl FnOnce(&mut Record)) -> Result<Record> {
with_the_record_in(&paths::state_dir()?, change)
}
fn with_the_record_in(dir: &std::path::Path, change: impl FnOnce(&mut Record)) -> Result<Record> {
use std::os::unix::io::AsRawFd;
paths::ensure_dir(dir, 0o700)?;
let path = dir.join("update.lock");
let lock = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.mode(0o600)
.open(&path)?;
let give_up = std::time::Instant::now() + Duration::from_secs(2);
loop {
if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
break;
}
let e = std::io::Error::last_os_error();
let busy = e.kind() == std::io::ErrorKind::WouldBlock;
if (busy || e.kind() == std::io::ErrorKind::Interrupted)
&& std::time::Instant::now() < give_up
{
let jitter = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() % 20)
.unwrap_or(0);
std::thread::sleep(Duration::from_millis(5 + u64::from(jitter)));
continue;
}
bail!("qex could not lock {}: {e}", path.display());
}
let mut record = read_record_in(dir);
change(&mut record);
let answer = write_record_in(dir, &record);
unsafe {
libc::flock(lock.as_raw_fd(), libc::LOCK_UN);
}
answer.map(|_| record)
}
pub fn interval(cfg: &Config) -> Result<Option<Duration>> {
let value = cfg.update.check.trim();
if value.eq_ignore_ascii_case(NEVER) {
return Ok(None);
}
match parse_duration(value) {
Ok(None) => Ok(None),
Ok(Some(d)) => Ok(Some(d)),
Err(e) => bail!("[update] check: {e}"),
}
}
pub fn ask(cfg: &Config) -> Result<Answer> {
let url = cfg.update.url.trim().to_string();
if url.is_empty() {
bail!("[update] url is empty, so qex has nothing to ask");
}
if !is_an_address(&url) {
bail!(
"[update] url must start with `https://`, `http://` or `file://`, and it holds \
`{url}`. An address that starts with a dash becomes an OPTION of the program \
that asks."
);
}
let limit = parse_duration(&cfg.update.timeout)
.map_err(|e| anyhow::anyhow!("[update] timeout: {e}"))?
.unwrap_or(Duration::from_secs(5));
let body = fetch(&url, limit)?;
let newest = tag_of(&body)?;
Ok(Answer {
newest,
source: url,
})
}
const MOST_BYTES: usize = 256 * 1024;
const MOST_WORDS: usize = 2 * 1024;
const SCHEMES: [&str; 3] = ["https://", "http://", "file://"];
pub fn is_an_address(url: &str) -> bool {
SCHEMES.iter().any(|scheme| url.starts_with(scheme))
}
fn fetch(url: &str, limit: Duration) -> Result<String> {
use std::io::Read;
use std::os::unix::io::AsRawFd;
let attempts: [(&str, Vec<String>); 2] = [
(
"curl",
vec![
"-fsSL".into(),
"--max-time".into(),
limit.as_secs_f64().ceil().max(1.0).to_string(),
"--max-filesize".into(),
MOST_BYTES.to_string(),
"-H".into(),
"Accept: application/vnd.github+json".into(),
url.into(),
],
),
(
"wget",
vec!["-q".into(), "-O".into(), "-".into(), url.into()],
),
];
let mut missing = Vec::new();
for (program, args) in attempts {
let mut command = std::process::Command::new(program);
command
.args(&args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
unsafe {
use std::os::unix::process::CommandExt;
command.pre_exec(|| {
libc::setpgid(0, 0);
#[cfg(target_os = "linux")]
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
Ok(())
});
}
let child = command.spawn();
let mut child = match child {
Ok(child) => child,
Err(_) => {
missing.push(program);
continue;
}
};
let mut said = child.stderr.take();
let (sender, complaints) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut tail: std::collections::VecDeque<u8> = std::collections::VecDeque::new();
if let Some(handle) = said.as_mut() {
let mut buffer = [0u8; 4096];
while let Ok(n) = handle.read(&mut buffer) {
if n == 0 {
break;
}
tail.extend(&buffer[..n]);
while tail.len() > MOST_WORDS {
tail.pop_front();
}
}
}
let text: Vec<u8> = tail.into_iter().collect();
sender
.send(String::from_utf8_lossy(&text).trim().to_string())
.ok();
});
let deadline = std::time::Instant::now() + limit;
let mut body = Vec::new();
let mut stop = Stop::Answered;
if let Some(out) = child.stdout.as_mut() {
let fd = out.as_raw_fd();
let mut buffer = [0u8; 8192];
loop {
let left = deadline.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
stop = Stop::TooSlow;
break;
}
let mut watch = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
let ms = left.as_millis().min(i32::MAX as u128) as i32;
match unsafe { libc::poll(&mut watch, 1, ms) } {
0 => {
stop = Stop::TooSlow;
break;
}
-1 => {
if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted
{
continue;
}
stop = Stop::Broken;
break;
}
_ => match out.read(&mut buffer) {
Ok(0) => break,
Ok(n) => {
body.extend_from_slice(&buffer[..n]);
if body.len() > MOST_BYTES {
stop = Stop::TooMuch;
break;
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => {
stop = Stop::Broken;
break;
}
},
}
}
}
if !matches!(stop, Stop::Answered) {
stop_the_group(&child);
}
let ended = wait_briefly(&mut child, Duration::from_secs(2));
let words = complaints
.recv_timeout(Duration::from_millis(200))
.unwrap_or_default();
match stop {
Stop::TooMuch => bail!(
"the answer of {url} passed {MOST_BYTES} bytes, and the answer of this service \
is one small object. qex stopped the read."
),
Stop::TooSlow => bail!(
"{program} did not answer for {url} in {} seconds, and qex stopped it.",
limit.as_secs_f64()
),
Stop::Broken => bail!("qex could not read the answer of {program} for {url}"),
Stop::Answered => {}
}
let Some(end) = ended else {
bail!("{program} did not stop for {url}, and qex could not wait for it");
};
if end.success() {
return Ok(String::from_utf8_lossy(&body).to_string());
}
let code = end.code().unwrap_or(-1);
bail!(
"{program} could not reach {url}: exit code {code}{}",
if words.is_empty() {
String::new()
} else {
format!(": {words}")
}
);
}
bail!(
"qex asks a web service with `curl` or `wget`, and this machine has neither ({}). \
Install one, or set `[update] check = \"never\"` in your config file.",
missing.join(" and ")
)
}
enum Stop {
Answered,
TooSlow,
TooMuch,
Broken,
}
fn stop_the_group(child: &std::process::Child) {
let pid = child.id() as i32;
if pid > 1 {
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
}
}
fn wait_briefly(
child: &mut std::process::Child,
limit: Duration,
) -> Option<std::process::ExitStatus> {
let deadline = std::time::Instant::now() + limit;
loop {
match child.try_wait() {
Ok(Some(end)) => return Some(end),
Ok(None) => {}
Err(_) => return None,
}
if std::time::Instant::now() >= deadline {
stop_the_group(child);
child.kill().ok();
return child.wait().ok();
}
std::thread::sleep(Duration::from_millis(10));
}
}
fn tag_of(body: &str) -> Result<String> {
#[derive(Deserialize)]
struct Release {
tag_name: Option<String>,
}
let release: Release = serde_json::from_str(body)
.context("the service gave an answer that qex could not read as JSON")?;
let tag = release
.tag_name
.filter(|t| !t.trim().is_empty())
.context("the answer of the service holds no `tag_name`")?;
Ok(tag.trim().trim_start_matches('v').to_string())
}
fn numbers_of(version: &str) -> Option<(u64, u64, u64)> {
let mut parts = version.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((major, minor, patch))
}
pub fn is_newer(mine: &str, newest: &str) -> bool {
match (numbers_of(mine), numbers_of(newest)) {
(Some(mine), Some(newest)) => newest > mine,
_ => false,
}
}
pub fn check_if_due(cfg: &Config) {
let Ok(Some(gap)) = interval(cfg) else {
return;
};
let record = read_record();
let now = crate::sys::now_secs();
if record.last_checked == 0 {
with_the_record(|r| r.last_checked = now).ok();
return;
}
if now.saturating_sub(record.last_checked) < gap.as_secs() {
return;
}
let answer = ask(cfg);
with_the_record(|r| {
r.last_checked = now;
match &answer {
Ok(answer) => {
r.newest = Some(answer.newest.clone());
r.source = Some(answer.source.clone());
r.error = None;
}
Err(e) => r.error = Some(format!("{e:#}")),
}
})
.ok();
}
pub fn note_for_a_command(cfg: &Config) -> Option<String> {
let dir = paths::state_dir().ok()?;
note_for_a_command_in(&dir, crate::version::VERSION, cfg)
}
fn note_for_a_command_in(dir: &std::path::Path, mine: &str, cfg: &Config) -> Option<String> {
interval(cfg).ok().flatten()?;
note(mine, &read_record_in(dir))?;
let mut line = None;
with_the_record_in(dir, |r| {
line = note(mine, r);
if line.is_some() {
r.told.clone_from(&r.newest);
}
})
.ok()?;
line
}
fn note(mine: &str, record: &Record) -> Option<String> {
if crate::version::is_development(mine) {
return None;
}
let newest = record.newest.as_deref()?;
if !is_newer(mine, newest) {
return None;
}
if record.told.as_deref() == Some(newest) {
return None;
}
Some(format!(
"qex: a newer qex exists: {newest}. This is {mine}. Run `qex version --check` for the \
detail, or set `[update] check = \"never\"` to stop this message."
))
}
pub struct Report {
pub mine: String,
pub newest: Option<String>,
pub source: Option<String>,
pub development: bool,
pub newer: bool,
pub error: Option<String>,
}
pub fn report(cfg: &Config) -> Report {
let mine = crate::version::VERSION.to_string();
let development = crate::version::is_development(&mine);
match ask(cfg) {
Ok(answer) => Report {
newer: !development && is_newer(&mine, &answer.newest),
newest: Some(answer.newest),
source: Some(answer.source),
mine,
development,
error: None,
},
Err(e) => Report {
mine,
newest: None,
source: Some(cfg.update.url.clone()),
development,
newer: false,
error: Some(format!("{e:#}")),
},
}
}
impl Report {
pub fn text(&self) -> String {
if let Some(error) = &self.error {
return format!(
"qex could not ask for the newest release: {error}\n\
The version that you have still operates. Nothing changed."
);
}
let newest = self.newest.clone().unwrap_or_default();
let source = self.source.clone().unwrap_or_default();
if self.development {
let mine = &self.mine;
return format!(
"This is a development build: {mine}.\n\
The newest release is {newest}, from {source}.\n\
A development build is neither newer nor older than a release. It holds the \
commit that you built."
);
}
if self.newer {
return format!(
"A newer release exists: {newest}, from {source}.\n\
Take it from https://github.com/stephenc/qex/releases/latest , or run \
`cargo install qex`.\n\
qex installs nothing by itself."
);
}
if numbers_of(&newest).is_none() {
return format!(
"The service named `{newest}`, from {source}. That is not a release number of \
the form X.Y.Z, so qex cannot say whether it is newer than this build."
);
}
format!("This is the newest release. The newest is {newest}, from {source}.")
}
pub fn json(&self) -> serde_json::Value {
serde_json::json!({
"version": self.mine,
"newest": self.newest,
"newer": self.newer,
"development": self.development,
"source": self.source,
"error": self.error,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_release_above_this_one_is_newer() {
assert!(is_newer("0.23.0", "0.24.0"));
assert!(is_newer("0.23.0", "1.0.0"));
assert!(is_newer("0.23.0", "0.23.1"));
assert!(!is_newer("0.23.0", "0.23.0"));
assert!(!is_newer("0.23.1", "0.23.0"));
assert!(is_newer("0.9.0", "0.23.0"));
assert!(!is_newer("0.23.0", "0.9.0"));
}
#[test]
fn a_development_build_is_neither_newer_nor_older() {
assert!(!is_newer("0.0.0-dev+g98513e2", "0.23.0"));
assert!(!is_newer("0.23.0", "0.0.0-dev+g98513e2"));
assert!(!is_newer("0.23.0", "0.24.0-rc1"));
}
#[test]
fn the_tag_of_a_release_loses_its_v() {
assert_eq!(tag_of(r#"{"tag_name":"v0.23.0"}"#).unwrap(), "0.23.0");
assert_eq!(tag_of(r#"{"tag_name":"0.23.0"}"#).unwrap(), "0.23.0");
assert!(tag_of(r#"{"tag_name":""}"#).is_err());
assert!(tag_of(r#"{"other":1}"#).is_err());
assert!(tag_of("not json").is_err());
}
fn record_of(newest: &str, told: Option<&str>) -> Record {
Record {
last_checked: 1,
newest: Some(newest.to_string()),
source: Some("a service".into()),
error: None,
told: told.map(|t| t.to_string()),
}
}
#[test]
fn the_line_arrives_one_time_for_each_release() {
let record = record_of("0.24.0", None);
let line = note("0.23.0", &record).expect("a newer release must give a line");
assert!(line.contains("0.24.0") && line.contains("0.23.0"), "{line}");
assert!(
line.contains("never"),
"the line must say how to stop it: {line}"
);
assert!(note("0.23.0", &record_of("0.24.0", Some("0.24.0"))).is_none());
assert!(note("0.23.0", &record_of("0.25.0", Some("0.24.0"))).is_some());
assert!(note("0.24.0", &record_of("0.24.0", None)).is_none());
assert!(note("0.25.0", &record_of("0.24.0", None)).is_none());
assert!(note("0.0.0-dev+g98513e2", &record_of("9.9.9", None)).is_none());
let mut empty = record_of("0.24.0", None);
empty.newest = None;
assert!(note("0.23.0", &empty).is_none());
}
fn a_directory(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("qx-upd-{name}-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_command_gives_the_line_one_time_and_remembers_it() {
let dir = a_directory("cmd");
let cfg = Config::default();
write_record_in(&dir, &record_of("0.24.0", None)).unwrap();
let first = note_for_a_command_in(&dir, "0.23.0", &cfg);
assert!(first.is_some(), "the first command must give the line");
assert_eq!(
read_record_in(&dir).told.as_deref(),
Some("0.24.0"),
"the record must remember the version that qex named"
);
assert!(note_for_a_command_in(&dir, "0.23.0", &cfg).is_none());
let record = read_record_in(&dir);
assert_eq!(record.newest.as_deref(), Some("0.24.0"));
assert_eq!(record.source.as_deref(), Some("a service"));
let mut quiet = Config::default();
quiet.update.check = "never".into();
write_record_in(&dir, &record_of("0.25.0", None)).unwrap();
assert!(note_for_a_command_in(&dir, "0.23.0", &quiet).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_command_with_nothing_to_say_writes_no_file() {
let dir = a_directory("quiet");
let cfg = Config::default();
assert!(note_for_a_command_in(&dir, "0.23.0", &cfg).is_none());
assert!(
!dir.join("update.json").exists(),
"a command with nothing to say must write no record"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_writer_keeps_the_field_of_the_other_writer() {
let dir = a_directory("both");
write_record_in(&dir, &record_of("0.24.0", Some("0.24.0"))).unwrap();
with_the_record_in(&dir, |r| {
r.newest = Some("0.25.0".into());
r.last_checked = 99;
})
.unwrap();
let record = read_record_in(&dir);
assert_eq!(
record.told.as_deref(),
Some("0.24.0"),
"the coordinator must keep the word of a command"
);
let line = note_for_a_command_in(&dir, "0.23.0", &Config::default());
assert!(line.unwrap().contains("0.25.0"));
let record = read_record_in(&dir);
assert_eq!(record.last_checked, 99, "a command must keep the time");
assert_eq!(record.told.as_deref(), Some("0.25.0"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_lock_file_that_qex_cannot_open_stops_the_write() {
let dir = a_directory("nolock");
write_record_in(&dir, &record_of("0.24.0", None)).unwrap();
std::fs::create_dir(dir.join("update.lock")).unwrap();
let answer = with_the_record_in(&dir, |r| r.told = Some("0.24.0".into()));
assert!(
answer.is_err(),
"a lock that qex cannot take must stop the write"
);
let record = read_record_in(&dir);
assert_eq!(record.newest.as_deref(), Some("0.24.0"));
assert!(record.told.is_none(), "nothing may reach the disk");
assert!(note_for_a_command_in(&dir, "0.23.0", &Config::default()).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn two_writers_at_once_lose_nothing() {
let dir = a_directory("race");
write_record_in(&dir, &Record::default()).unwrap();
let writers = 8;
let each = 25;
let done = std::sync::atomic::AtomicU64::new(0);
std::thread::scope(|scope| {
for _ in 0..writers {
scope.spawn(|| {
for _ in 0..each {
if with_the_record_in(&dir, |r| r.last_checked += 1).is_ok() {
done.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
});
}
});
let done = done.load(std::sync::atomic::Ordering::SeqCst);
assert!(done > 0, "no writer took the lock at all");
assert_eq!(
read_record_in(&dir).last_checked,
done,
"the record must hold every write that took the lock, and no less"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn only_an_address_reaches_the_program_that_asks() {
assert!(is_an_address("https://example.test/x"));
assert!(is_an_address("http://example.test/x"));
assert!(is_an_address("file:///tmp/x"));
assert!(!is_an_address("-K/tmp/curlrc"));
assert!(!is_an_address("--config=/tmp/curlrc"));
assert!(!is_an_address("example.test/x"));
assert!(!is_an_address(""));
let mut cfg = Config::default();
cfg.update.url = "-K/tmp/curlrc".into();
let e = format!("{:#}", ask(&cfg).expect_err("qex must refuse this"));
assert!(e.contains("must start with"), "got: {e}");
}
#[test]
fn a_service_that_never_answers_stops_at_the_limit() {
let dir = a_directory("slow");
let pipe = dir.join("pipe");
let name = std::ffi::CString::new(pipe.to_str().unwrap()).unwrap();
if unsafe { libc::mkfifo(name.as_ptr(), 0o600) } != 0 {
std::fs::remove_dir_all(&dir).ok();
return;
}
let mut cfg = Config::default();
cfg.update.url = format!("file://{}", pipe.display());
cfg.update.timeout = "2s".into();
let (sender, answer) = std::sync::mpsc::channel();
std::thread::spawn(move || {
sender.send(ask(&cfg).map(|a| a.newest)).ok();
});
let answer = answer
.recv_timeout(Duration::from_secs(15))
.expect("the limit of 2 seconds did not hold");
std::fs::remove_dir_all(&dir).ok();
let e = format!(
"{:#}",
answer.expect_err("a service that never answers is a fault")
);
assert!(
e.contains("did not answer"),
"the message must name the limit: {e}"
);
}
#[test]
fn an_answer_that_never_ends_stops_at_the_limit() {
let mut cfg = Config::default();
cfg.update.url = "file:///dev/zero".into();
cfg.update.timeout = "10s".into();
let started = std::time::Instant::now();
let e = format!("{:#}", ask(&cfg).expect_err("an endless answer is a fault"));
assert!(
e.contains("passed") || e.contains("could not reach"),
"got: {e}"
);
assert!(
started.elapsed() < Duration::from_secs(10),
"the read must stop at the limit, and it took {:?}",
started.elapsed()
);
}
#[test]
fn never_gives_no_interval() {
let mut cfg = Config::default();
cfg.update.check = "never".into();
assert!(interval(&cfg).unwrap().is_none());
cfg.update.check = "NEVER".into();
assert!(interval(&cfg).unwrap().is_none());
cfg.update.check = "0".into();
assert!(interval(&cfg).unwrap().is_none());
cfg.update.check = "7d".into();
assert_eq!(
interval(&cfg).unwrap(),
Some(Duration::from_secs(7 * 24 * 3600))
);
}
}