crates/veilvoice-setup/src/update.rs
what this file is for · veilvoice-setup · 581 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! Ask, **only when told to**, whether a newer VeilVoice release exists.
//!
//! # What this is, and the claim it changes
//!
//! Until this crate existed, VeilVoice's front page said *"no telemetry, no
//! update check"*. Half of that is unchanged and half of it is not, and the
//! wording moved in the same commit as the code rather than afterwards:
//!
//! * **No telemetry.** Unchanged, and nothing here sends anything about you.
//! The request is a plain `GET` of a public URL that anybody can open in a
//! browser; it carries no identifier, no configuration and no counter.
//! * **No *automatic* update check.** Nothing runs on a timer, at startup, or
//! in the background. [`check`] runs because a person pressed a button in
//! this run of the program, and it does nothing else ever.
//!
//! An update checker that runs by itself is a beacon: it tells a server that
//! this machine has VeilVoice on it, roughly how often it is used, and from
//! which address. That is the thing being refused. A button somebody presses,
//! once, when they want to know, is a different act with different consequences,
//! and it is the only one on offer.
//!
//! # There is still no HTTP client in the dependency graph
//!
//! This crate has **no dependencies**. It runs the transfer tool the operating
//! system already ships, exactly as `veilvoice-verify` has fetched releases
//! since it existed, and reads its output. `cargo tree` shows no `reqwest`, no
//! `hyper`, no `ureq`; the CI job that fails the build if one appears is
//! unchanged and still passes.
//!
//! The tool is found at an **absolute path**, never by bare name. Resolving a
//! program by name on Windows searches the current directory before most of
//! `PATH`, so a file called `curl.exe` sitting beside the program would be run
//! instead of the system one. That is finding F-13, and it does not get to
//! happen twice.
//!
//! # What it will not do
//!
//! It does not download a release, it does not install anything, and it does
//! not restart the program. It reports a version string and leaves every
//! decision to the reader. Downloading a release and checking its signature is
//! `veilvoice-verify`'s job, and that is a separate, deliberate act too.
//!
//! An update checker that could install its own answer is an update checker
//! that can be made to install somebody else's.
//!
//! # What a "newer version" is worth here
//!
//! The answer comes from a public web page over TLS. That is enough to say
//! *"there is probably something newer, go and look"* and it is **not** enough
//! to act on: a name in a document is not a signature. Nothing in this crate
//! verifies anything, and [`Report::caveat`] says so in the words the user
//! sees rather than only in this comment.
//!
//! # In plain words
//!
//! This is the "check for updates" button, and nothing else.
//!
//! It runs when you press it and at no other time. There is no timer and nothing
//! in the background, because a program that checks by itself is telling somebody
//! else's computer that yours exists and how often you use it.
//!
//! It reads a public page anybody can open, tells you the newest version number,
//! and stops there. It does not download anything and it does not install
//! anything.
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
/// The repository asked about.
pub const REPO: &str = "tilas01/veilvoice";
/// The page fetched. A plain URL a person can open themselves and compare.
///
/// Deliberately the human-readable redirect rather than an API endpoint: it is
/// checkable by eye, it needs no token, and it is not rate limited per address
/// in the way the API is. The redirect's target carries the tag.
pub const LATEST_URL: &str = "https://github.com/tilas01/veilvoice/releases/latest";
/// Where releases are listed, for somebody doing this by hand.
pub const RELEASES_URL: &str = "https://github.com/tilas01/veilvoice/releases";
/// How long the transfer tool is given before it is given up on.
///
/// Short on purpose. This runs because somebody pressed a button and is
/// waiting; a check that hangs for a minute on a captive portal is worse than
/// one that says it could not reach anything.
pub const TIMEOUT: Duration = Duration::from_secs(10);
/// How this build's version compares with the newest published one.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
/// The newest published release is this one.
UpToDate,
/// Something newer exists. Carries the published version.
Newer(String),
/// This build is ahead of anything published, meaning an unreleased `main`.
Ahead(String),
/// A version string came back that this build cannot compare.
///
/// Reported rather than guessed at. Two version strings that do not parse
/// are two strings, and pretending to order them is how a checker tells
/// somebody they are out of date when they are not.
Unreadable(String),
}
/// What a check found.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Report {
/// The version this build reports.
pub current: String,
/// The newest version the page named.
pub latest: String,
/// How the two compare.
pub verdict: Verdict,
}
impl Report {
/// What this answer is worth, in the words the user should see.
///
/// Carried on the report rather than written into whichever front end
/// happens to be showing it, so a second front end cannot show the answer
/// without the caveat.
pub fn caveat(&self) -> &'static str {
"This is a version number read off a public page. It is not a signature \
and nothing here has verified anything. Download a release and check it \
with `veilvoice verify` before you run it."
}
}
/// Why a check could not be completed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Error {
/// The system ships no transfer tool this crate knows how to drive.
NoTransferTool,
/// The tool ran and failed. Carries what it said.
Failed(String),
/// The response arrived and held no version this crate could find.
NoVersionFound,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoTransferTool => write!(
f,
"no transfer tool was found on this system. VeilVoice contains no HTTP \
client -- it borrows the one your operating system ships, and could not \
find it. Open {RELEASES_URL} yourself instead."
),
Self::Failed(why) => write!(f, "the check could not be completed: {why}"),
Self::NoVersionFound => write!(
f,
"the reply held no version number this build could read. Open \
{RELEASES_URL} and look."
),
}
}
}
impl std::error::Error for Error {}
/// The version this build was compiled as.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Ask whether anything newer than `current` has been published.
///
/// **Runs a subprocess and waits.** Never call it from a thread that paints:
/// a network round trip on the UI thread is the freeze the user reports.
pub fn check(current: &str) -> Result<Report, Error> {
let tool = find_tool().ok_or(Error::NoTransferTool)?;
let body = fetch(&tool)?;
let latest = tag_in(&body).ok_or(Error::NoVersionFound)?;
Ok(report(current, &latest))
}
/// Compare two version strings and build the report.
///
/// Split out from [`check`] so the comparison is testable without a network,
/// a subprocess, or a machine that has either.
pub fn report(current: &str, latest: &str) -> Report {
let verdict = match (parse(current), parse(latest)) {
(Some(here), Some(there)) => {
if there > here {
Verdict::Newer(latest.to_string())
} else if here > there {
Verdict::Ahead(latest.to_string())
} else {
Verdict::UpToDate
}
}
_ => Verdict::Unreadable(latest.to_string()),
};
Report {
current: current.to_string(),
latest: latest.to_string(),
verdict,
}
}
/// `1.2.3` or `v1.2.3` as three numbers.
///
/// Anything else is `None` rather than a guess. A pre-release suffix makes the
/// string unreadable on purpose: ordering `1.0.0-rc1` against `1.0.0` correctly
/// needs the whole of semantic versioning's precedence rules, and a checker
/// that gets it subtly wrong tells people to downgrade.
fn parse(version: &str) -> Option<(u64, u64, u64)> {
let version = version.trim().trim_start_matches('v');
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))
}
/// The tag in whatever the transfer tool printed.
///
/// With curl this is the one-line redirect target and the tag is the whole
/// point of it. With wget it is the page body, which names the same tag. One
/// scanner for both, because the shape being looked for is identical and a
/// second code path is a second thing to get wrong.
///
/// Scanned rather than parsed as HTML: parsing a document to read one substring
/// is a dependency and an attack surface for a job a search does exactly as
/// well.
///
/// # Every match, not the first one
///
/// The first version of this took the first `/releases/tag/` it found. Run
/// against the real page, it returned nothing: GitHub's release page contains
/// an **empty** `/releases/tag/` before any real one -- a template link with no
/// tag after it -- so the first match yielded an empty string and the check
/// reported "no version number" against a page that plainly had one. Found by
/// running it, not by reading it.
///
/// Bounded on both ends: at most 32 characters, and only characters a version
/// tag is made of. A page that came back as something else entirely produces no
/// match rather than a run of somebody's markup shown to the user as a version.
pub fn tag_in(body: &str) -> Option<String> {
const MARKER: &str = "/releases/tag/";
let mut from = 0usize;
while let Some(found) = body[from..].find(MARKER) {
let start = from + found + MARKER.len();
let tag: String = body[start..]
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
.take(32)
.collect();
if !tag.is_empty() {
return Some(tag);
}
from = start;
}
None
}
/// This platform's bit bucket, for a reply whose body is not wanted.
#[cfg(windows)]
const NULL_DEVICE: &str = "NUL";
/// This platform's bit bucket, for a reply whose body is not wanted.
#[cfg(not(windows))]
const NULL_DEVICE: &str = "/dev/null";
/// Where a transfer tool was found, and how to drive it.
struct Tool {
program: PathBuf,
wget: bool,
}
/// Absolute paths only. See the module note on finding F-13.
fn find_tool() -> Option<Tool> {
#[cfg(windows)]
{
let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into());
let curl = PathBuf::from(format!(r"{root}\System32\curl.exe"));
if curl.is_file() {
return Some(Tool {
program: curl,
wget: false,
});
}
None
}
#[cfg(not(windows))]
{
for candidate in ["/usr/bin/curl", "/bin/curl", "/usr/local/bin/curl"] {
let path = PathBuf::from(candidate);
if path.is_file() {
return Some(Tool {
program: path,
wget: false,
});
}
}
for candidate in ["/usr/bin/wget", "/bin/wget", "/usr/local/bin/wget"] {
let path = PathBuf::from(candidate);
if path.is_file() {
return Some(Tool {
program: path,
wget: true,
});
}
}
None
}
}
/// Run the tool and hand back what it printed.
fn fetch(tool: &Tool) -> Result<String, Error> {
let seconds = TIMEOUT.as_secs().to_string();
let mut command = Command::new(&tool.program);
if tool.wget {
command.args([
"--quiet",
"--max-redirect=5",
"--timeout",
&seconds,
"--tries=1",
"-O",
"-",
LATEST_URL,
]);
} else {
// The body is thrown away and only the **final URL** is printed. The
// redirect target of `/releases/latest` *is* the tag -- one line
// instead of two hundred kilobytes of a page, nothing of the reply
// reaching the scanner, and no way for markup to be mistaken for a
// version. Measured on the real page: 205,538 bytes against 54.
command.args([
"-L",
"--max-redirs",
"5",
"--silent",
"--show-error",
"--max-time",
&seconds,
// `--proto =https` refuses to be redirected onto a plain-text or a
// file scheme. Without it a redirect chain decides the protocol.
"--proto",
"=https",
"-o",
NULL_DEVICE,
"-w",
"%{url_effective}",
LATEST_URL,
]);
}
let output = command.output().map_err(|e| Error::Failed(e.to_string()))?;
if !output.status.success() {
let said = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(Error::Failed(if said.is_empty() {
format!("{} exited with {}", tool.program.display(), output.status)
} else {
said
}));
}
// Bounded before it is looked at. A reply is a public release page; a
// reply the size of a film is something else, and reading all of it into
// a string to search for one substring is the wrong thing to do with it.
const MAX: usize = 4 * 1024 * 1024;
let body = &output.stdout[..output.stdout.len().min(MAX)];
Ok(String::from_utf8_lossy(body).into_owned())
}
/// What this crate does and does not do, in one paragraph, for a front end to
/// show beside the button.
pub const SCOPE: &str = "\
This check happens because you pressed the button, and at no other time. There \
is no timer, no check at startup, and nothing runs in the background. VeilVoice \
contains no HTTP client: this borrows the transfer tool your operating system \
already ships and reads a public page anybody can open. It sends nothing about \
you or this machine. It does not download a release, install anything, or \
restart the program -- it reports a version number, and every decision after \
that is yours. A version number on a page is not a signature: check a download \
with `veilvoice verify` before running it.";
#[cfg(test)]
mod tests {
/// The guide describes the tool this actually looks for.
///
/// Written after the guide said "PowerShell's web request on Windows",
/// which this has never used: it looks for `System32\\curl.exe` by
/// absolute path. That was a sentence about the program, written beside
/// the program, wrong on the day it was written -- which is the shape of
/// finding after finding in this repository, and is why the two are
/// compared here rather than trusted to agree.
#[test]
fn the_guide_names_the_tool_this_looks_for() {
let guide = include_str!("../../../docs/USER_GUIDE.md").replace("\r\n", "\n");
let source = include_str!("update.rs");
let looking = source
.split("fn find_tool()")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("find_tool exists");
// Every absolute path the program will actually try.
let mut paths: Vec<&str> = looking
.match_indices('"')
.map(|(at, _)| &looking[at + 1..])
.filter_map(|rest| rest.split('"').next())
.filter(|candidate| candidate.contains('/') || candidate.contains('\\'))
.collect();
paths.sort();
paths.dedup();
assert!(!paths.is_empty(), "find_tool names no paths");
for path in paths {
// The guide writes Windows paths with the environment variable
// spelled out, so compare on the part that identifies the tool.
let needle = path.rsplit(['/', '\\']).next().unwrap_or(path);
assert!(
guide.contains(needle),
"the update check looks for {path:?} and docs/USER_GUIDE.md \
never mentions {needle:?}"
);
}
assert!(
!guide.contains("PowerShell's web request"),
"the guide credits PowerShell for the update check; this has never \
used it"
);
}
use super::*;
#[test]
fn a_higher_published_version_is_newer() {
let report = report("0.1.12", "0.2.0");
assert_eq!(report.verdict, Verdict::Newer("0.2.0".into()));
assert_eq!(report.current, "0.1.12");
}
#[test]
fn the_same_version_is_up_to_date() {
assert_eq!(report("0.1.12", "v0.1.12").verdict, Verdict::UpToDate);
}
/// An unreleased build is ahead, and is told so rather than told it is
/// current. Saying "up to date" to somebody running `main` hides the one
/// fact that matters about what they are running.
#[test]
fn a_build_ahead_of_the_newest_release_is_told_so() {
assert_eq!(
report("0.2.0", "0.1.12").verdict,
Verdict::Ahead("0.1.12".into())
);
}
/// Ordering is refused rather than guessed. A checker that gets
/// pre-release precedence subtly wrong tells people to downgrade.
#[test]
fn a_version_that_cannot_be_compared_is_refused_rather_than_ordered() {
assert_eq!(
report("0.1.12", "0.2.0-rc1").verdict,
Verdict::Unreadable("0.2.0-rc1".into())
);
assert_eq!(
report("0.1.12", "nightly").verdict,
Verdict::Unreadable("nightly".into())
);
assert!(parse("1.2").is_none(), "three numbers or nothing");
assert!(parse("1.2.3.4").is_none(), "three numbers or nothing");
}
#[test]
fn the_leading_v_is_optional_on_both_sides() {
assert_eq!(parse("v1.2.3"), parse("1.2.3"));
assert_eq!(
report("v1.0.0", "1.0.1").verdict,
Verdict::Newer("1.0.1".into())
);
}
#[test]
fn the_tag_is_read_out_of_a_release_page() {
let body = r#"<a href="/tilas01/veilvoice/releases/tag/v0.1.12">v0.1.12</a>"#;
assert_eq!(tag_in(body).as_deref(), Some("v0.1.12"));
}
/// A page that is not a release page yields nothing, rather than a
/// fragment of somebody's markup shown to the user as a version.
/// The real page carries an empty `/releases/tag/` before any real one.
/// Taking the first match returned nothing against a page that plainly had
/// a version on it, which is how this was found: by running it.
#[test]
fn an_empty_tag_link_before_a_real_one_is_stepped_over() {
let body = r#"<a href="/tilas01/veilvoice/releases/tag/"></a>
<a href="/tilas01/veilvoice/releases/tag/v0.1.12">v0.1.12</a>"#;
assert_eq!(tag_in(body).as_deref(), Some("v0.1.12"));
}
/// curl is asked for the redirect target rather than the page, so what the
/// scanner sees is one line. This is the shape it has to handle.
#[test]
fn the_redirect_target_alone_is_enough() {
let url = "https://github.com/tilas01/veilvoice/releases/tag/v0.1.12";
assert_eq!(tag_in(url).as_deref(), Some("v0.1.12"));
}
#[test]
fn a_page_with_no_tag_yields_nothing() {
assert_eq!(tag_in("<html><body>hello</body></html>"), None);
assert_eq!(tag_in(""), None);
assert_eq!(tag_in("/releases/tag/"), None);
assert_eq!(tag_in("/releases/tag/<script>"), None);
}
/// Whatever comes back, what is shown is short and made of characters a
/// version number is made of.
#[test]
fn a_hostile_tag_is_bounded_in_length_and_alphabet() {
let long = format!("/releases/tag/{}", "a".repeat(500));
let tag = tag_in(&long).expect("a tag is found");
assert_eq!(tag.len(), 32, "bounded to 32 characters");
let markup = "/releases/tag/v1.0.0\"><img src=x onerror=alert(1)>";
assert_eq!(tag_in(markup).as_deref(), Some("v1.0.0"));
}
/// The URL asked about is this project's own, over TLS, and is a page a
/// person can open and compare by hand.
#[test]
fn the_url_is_this_project_over_tls() {
assert!(LATEST_URL.starts_with("https://"));
assert!(LATEST_URL.contains(REPO));
assert!(RELEASES_URL.starts_with("https://"));
assert!(RELEASES_URL.contains(REPO));
}
/// The scope note has to state the limits, not only the capability. This
/// is the wording the front page's claim now depends on.
#[test]
fn the_scope_note_states_what_it_does_not_do() {
let scope = SCOPE.to_lowercase();
for phrase in [
"you pressed the button",
"no timer",
"no http client",
"sends nothing about you",
"does not download",
"not a signature",
] {
assert!(scope.contains(phrase), "the scope note must say {phrase:?}");
}
}
/// Every report carries the caveat, so no front end can show the answer
/// without it.
#[test]
fn a_report_carries_what_the_answer_is_worth() {
let caveat = report("0.1.12", "0.2.0").caveat().to_lowercase();
assert!(caveat.contains("not a signature"));
assert!(caveat.contains("veilvoice verify"));
}
/// Errors explain what to do instead rather than only that something
/// failed. A tool that cannot reach the network still has a user who wants
/// to know whether there is an update.
#[test]
fn every_failure_says_what_to_do_instead() {
for error in [
Error::NoTransferTool,
Error::NoVersionFound,
Error::Failed("connection refused".into()),
] {
let text = error.to_string();
assert!(!text.is_empty());
}
assert!(Error::NoTransferTool.to_string().contains(RELEASES_URL));
assert!(Error::NoVersionFound.to_string().contains(RELEASES_URL));
assert!(Error::Failed("refused".into())
.to_string()
.contains("refused"));
}
}