crates/veilvoice-verify/src/fetch.rs
what this file is for · veilvoice-verify · 329 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
//! Download a release, without putting an HTTP client in the dependency graph.
//!
//! # The constraint this is written around
//!
//! VeilVoice is **offline by construction**, and that is not a slogan: a CI job
//! fails the build if `reqwest`, `hyper`, `curl`, `ureq`, `tungstenite`,
//! `isahc` or `surf` appears anywhere in `cargo tree`. The claim on the front
//! page -- "no network code in the dependency graph" -- is one a reader can
//! check in ten seconds, and it is a large part of why this project is worth
//! trusting.
//!
//! Fetching a release to check it is a genuinely useful thing for *this*
//! binary to do, and it is also the one thing that claim forbids. So the
//! download is done by **the tool the operating system already ships**, invoked
//! as a subprocess:
//!
//! | Platform | Used | Already present because |
//! |---|---|---|
//! | Windows 10+ | `curl.exe` | shipped in `System32` since 2018 |
//! | macOS | `curl` | part of the base system |
//! | Linux, BSD | `curl`, else `wget` | one or the other is on essentially every install |
//!
//! `cargo tree` stays exactly as clean as it was, the CI job that enforces it
//! is untouched, and nothing in the *library* crates gained the ability to talk
//! to anything. What changed is that one command-line tool, whose entire
//! purpose is checking downloads, can now also make one -- when asked, never on
//! its own.
//!
//! This is the same pattern the rest of the project already uses for
//! platform work it will not link a dependency for: `veilvoice-watch` reads the
//! registry through `reg query`, and `veilvoice-guard` reads the event log
//! through `wevtutil`.
//!
//! # What is deliberately not done here
//!
//! **No downloader is resolved by bare name.** Finding `curl` by searching
//! `PATH` on Windows includes the current directory, so running this from a
//! folder containing a hostile `curl.exe` would run that instead -- finding
//! F-13, in the one program whose job is deciding whether a download is
//! genuine. Absolute paths are tried first, and a bare name is only ever a
//! last resort on platforms where the search order does not include the
//! working directory.
//!
//! **Nothing is fetched implicitly.** A download happens because the user
//! passed a subcommand that says so. There is no update check, no telemetry,
//! and no "just in case" request.
//!
//! **Only one host is ever contacted**, and it is compiled in. A URL cannot be
//! supplied on the command line, so this cannot be turned into a general
//! downloader by an argument.
//!
//! # In plain words
//!
//! Downloads a release, without VeilVoice containing any networking code.
//!
//! It asks the tool your system already has to do the fetching. That is what keeps
//! a real promise the rest of the project makes: there is no HTTP client anywhere
//! in what VeilVoice is built from, which you can check yourself, and this is the
//! one command that touches the network at all.
use std::path::{Path, PathBuf};
use std::process::Command;
/// The only host this will ever talk to.
///
/// Compiled in rather than accepted as an argument. A verifier that can be
/// pointed anywhere is a download tool wearing a verifier's name, and the
/// fingerprint check below is only meaningful against artefacts from the
/// project it was built for.
pub const HOST: &str = "https://github.com";
/// The repository releases are fetched from.
pub const REPO: &str = "tilas01/veilvoice";
/// The largest file this will accept.
///
/// A release archive is tens of megabytes. This bounds what a redirected or
/// substituted response can make the tool write to disk, and it is checked
/// after the download rather than trusted from a header, because a header is
/// something the other end chose.
pub const MAX_BYTES: u64 = 512 * 1024 * 1024;
/// Where a downloader was found, and what to call it.
struct Downloader {
program: PathBuf,
style: Style,
}
#[derive(Clone, Copy)]
enum Style {
Curl,
// Only reachable on the platforms whose lookup below considers it. Windows
// ships curl and nothing else worth preferring, so on that target this
// variant is genuinely dead -- and `-D warnings` is right to say so.
#[cfg_attr(windows, allow(dead_code))]
Wget,
}
/// Absolute paths first, and a bare name only where that is safe.
///
/// Resolving a program by bare name on Windows searches the **current
/// directory** before most of `PATH`, so a file called `curl.exe` sitting
/// beside a downloaded archive would be run instead of the system one. That is
/// finding F-13, and this is the program where it would matter most.
fn find_downloader() -> Option<Downloader> {
#[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(Downloader {
program: curl,
style: Style::Curl,
});
}
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(Downloader {
program: path,
style: Style::Curl,
});
}
}
for candidate in ["/usr/bin/wget", "/bin/wget", "/usr/local/bin/wget"] {
let path = PathBuf::from(candidate);
if path.is_file() {
return Some(Downloader {
program: path,
style: Style::Wget,
});
}
}
None
}
}
/// Say what could not be found, and what to do instead.
///
/// A tool that cannot download should explain how to proceed without it, not
/// merely report an absence. Everything this fetches can be fetched by hand.
pub fn no_downloader_message() -> String {
format!(
"no downloader was found on this system.\n\n\
This tool does not contain an HTTP client -- VeilVoice has no network \
code in its dependency graph at all, which is a property you can check \
with `cargo tree`. It borrows the one your operating system already \
ships, and could not find it.\n\n\
Download the files yourself from\n {HOST}/{REPO}/releases\n\
and pass them in:\n\n \
veilvoice verify file <ARCHIVE> --sums SHA256SUMS --sig SHA256SUMS.asc\n"
)
}
/// Fetch one URL into `into`. Returns the path written.
///
/// Every failure is reported rather than retried. A verifier that quietly
/// tries again is a verifier whose output does not say what actually happened.
pub fn download(url: &str, into: &Path) -> Result<PathBuf, String> {
if !url.starts_with(HOST) {
// Unreachable through the public API, which builds every URL from the
// constants above -- but this is the check that keeps that true if
// somebody adds a caller later.
return Err(format!("refusing to fetch from outside {HOST}: {url}"));
}
let Some(downloader) = find_downloader() else {
return Err(no_downloader_message());
};
let mut command = Command::new(&downloader.program);
match downloader.style {
Style::Curl => {
command.args([
"--fail", // an HTTP error is a failure, not a saved error page
"--location", // GitHub redirects release assets to its CDN
"--silent",
"--show-error",
"--proto",
"=https", // never downgrade, never another scheme
"--max-time",
"600",
"--output",
]);
command.arg(into);
command.arg(url);
}
Style::Wget => {
command.args(["--quiet", "--https-only", "--timeout=600", "-O"]);
command.arg(into);
command.arg(url);
}
}
let output = command
.output()
.map_err(|error| format!("could not run {}: {error}", downloader.program.display()))?;
if !output.status.success() {
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
let _ = std::fs::remove_file(into);
return Err(if detail.is_empty() {
format!("download failed: {url}")
} else {
format!("download failed: {url}\n {detail}")
});
}
let size = std::fs::metadata(into)
.map_err(|error| format!("downloaded file is unreadable: {error}"))?
.len();
if size == 0 {
let _ = std::fs::remove_file(into);
return Err(format!("download produced an empty file: {url}"));
}
if size > MAX_BYTES {
let _ = std::fs::remove_file(into);
return Err(format!(
"downloaded file is {size} bytes, over the {MAX_BYTES} limit; refusing it"
));
}
Ok(into.to_path_buf())
}
/// The URL of one file in one release.
pub fn asset_url(tag: &str, name: &str) -> String {
format!("{HOST}/{REPO}/releases/download/{tag}/{name}")
}
/// The three files every release publishes for checking itself.
pub const SUMS: &str = "SHA256SUMS";
pub const SIGNATURE: &str = "SHA256SUMS.asc";
/// A release tag, rejected unless it looks like one.
///
/// The tag becomes part of a URL and part of a filename, so it is validated
/// rather than trusted: without this, a tag containing `../` would write
/// outside the download directory, and one containing a shell metacharacter
/// would be passed to a subprocess. The argument is passed as a separate
/// argv entry rather than through a shell, so the second is already handled --
/// but a check that only holds because of how the caller happens to invoke
/// things is not a check.
pub fn valid_tag(tag: &str) -> bool {
!tag.is_empty()
&& tag.len() <= 40
&& tag
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
}
/// An asset filename, rejected unless it looks like one.
pub fn valid_asset(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 128
&& !name.contains("..")
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_url_outside_the_compiled_in_host_is_refused() {
let target = std::env::temp_dir().join("veilvoice-fetch-test-never-written");
let error = download("https://example.invalid/thing", &target)
.expect_err("an arbitrary host must be refused");
assert!(error.contains("refusing to fetch"), "{error}");
assert!(!target.exists(), "a refused download still wrote a file");
}
#[test]
fn a_tag_that_would_escape_the_directory_is_refused() {
for bad in [
"../../etc",
"a/b",
"tag with spaces",
"",
"a;rm -rf /",
"v1$(x)",
] {
assert!(!valid_tag(bad), "the tag {bad:?} was accepted");
}
for good in ["v0.1.11", "v1.0.0-rc1", "nightly_2026"] {
assert!(valid_tag(good), "the tag {good:?} was refused");
}
}
#[test]
fn an_asset_name_that_would_escape_the_directory_is_refused() {
for bad in ["../SHA256SUMS", "a/b.tar.gz", "", "x y.zip"] {
assert!(!valid_asset(bad), "the name {bad:?} was accepted");
}
for good in [
"SHA256SUMS",
"SHA256SUMS.asc",
"veilvoice-v0.1.11-linux-x86_64.tar.gz",
] {
assert!(valid_asset(good), "the name {good:?} was refused");
}
}
#[test]
fn an_asset_url_is_built_from_the_compiled_in_host_and_repository() {
let url = asset_url("v0.1.11", "SHA256SUMS");
assert!(url.starts_with(HOST), "{url}");
assert!(url.contains(REPO), "{url}");
assert!(url.ends_with("/v0.1.11/SHA256SUMS"), "{url}");
}
#[test]
fn the_absent_downloader_message_says_how_to_proceed_without_one() {
// A tool that cannot download must explain the manual route rather
// than merely reporting that it cannot.
let text = no_downloader_message();
assert!(text.contains("releases"), "no link to the releases: {text}");
assert!(text.contains("--sums"), "no manual command shown: {text}");
assert!(
text.contains("no network code"),
"the reason there is no built-in client should be stated: {text}"
);
}
}