crates/veilvoice-verify/src/extracted.rs
what this file is for · veilvoice-verify · 316 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
//! What came out of the archive, and the GnuPG somebody already has.
//!
//! **Roadmap item 91.** Two halves of the same request: check the extracted copy as
//! well as the archive, and offer the check through GnuPG for anybody who would
//! rather trust their own tools than this binary.
//!
//! # This used to be the honest limit, and it is not the limit any more
//!
//! Worth reading before the code, because the module changed shape around it.
//!
//! A release signs `SHA256SUMS`, and `SHA256SUMS` covers the **archives**. So
//! verifying `veilvoice-0.1.14-linux-x86_64.zip` proved that archive was the
//! one that was signed, and proved nothing at all about the folder sitting
//! beside it. The folder may predate the download, may have come out of a
//! different copy, may have been edited since. Nothing on disk records which
//! archive a directory was extracted from.
//!
//! That was written here as a limit that could not be lifted, and it could not
//! be lifted **from this side**. It was lifted from the other one. A release
//! now also publishes `CONTENTS.sha256`, listing every file inside every
//! archive with its SHA-256, staged before `SHA256SUMS` is computed so that the
//! signature covers it too. `crate::check::contents` reads it and `lib.rs`
//! checks the extracted folder against it, file by file, and reports anything
//! in that folder the release never published.
//!
//! The lesson is worth keeping beside the code: "no signed list covers loose
//! files" was a true statement about the release format, and it was being
//! treated as a fact about the world. Publishing one more file changed it.
//!
//! What is left here is the part no hash can answer. A file can be byte for
//! byte correct and still not start, because the tool that unpacked it dropped
//! the execute bit, and somebody in that position has a folder that looks
//! perfect and does nothing. That is what [`look_in`] and [`Program::runnable`]
//! are for, and they are still asked after every hash has matched.
//!
//! Releases published before v0.1.15 carry no contents list, and for those the
//! old report and the old caveat are exactly what is printed, because they were
//! honest then and still are.
//!
//! # GnuPG
//!
//! VeilVoice checks the signature itself, with the key compiled into this
//! binary, so that somebody with no GnuPG installed is not stuck. That is a
//! convenience and it has an obvious circularity: the program telling you the
//! download is genuine is a program from the same download.
//!
//! [`gnupg_commands`] is the answer to that. It prints the exact commands to
//! run with a GnuPG this project did not write, against a key fingerprint
//! published somewhere this project does not control. Anybody who wants the
//! independent check has it, spelled out, with nothing to work out.
//!
//! # In plain words
//!
//! Checks the folder you unzipped, as well as the zip.
//!
//! From v0.1.15 a release publishes a signed list of everything inside each
//! archive, so every file in that folder is checked against it, and anything in
//! there that was not part of the release is named. For older releases, which
//! carry no such list, it can only tell you the programs are there and that
//! your system will run them, and it says so rather than implying more.
use std::path::{Path, PathBuf};
/// The programs a release archive carries.
///
/// Two since 0.1.18, when the verifier stopped being an executable of its own
/// and became part of both of them. An archive is not missing anything by not
/// holding a third.
pub const PROGRAMS: &[&str] = &["veilvoice", "veilvoice-gui"];
/// One program found in an extracted directory.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Program {
/// Where it is.
pub path: PathBuf,
/// Whether the operating system will run it.
///
/// On Unix, the owner execute bit. On Windows there is no such bit and the
/// extension decides, so this is true for a file that is there at all and
/// the report says which platform's answer it is giving.
pub runnable: bool,
}
/// What an extracted directory turned out to hold.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Extracted {
/// The directory itself.
pub directory: PathBuf,
/// The programs found in it.
pub programs: Vec<Program>,
}
impl Extracted {
/// Whether anything was found at all.
pub fn is_empty(&self) -> bool {
self.programs.is_empty()
}
/// The programs the operating system will not run.
pub fn not_runnable(&self) -> Vec<&Program> {
self.programs.iter().filter(|p| !p.runnable).collect()
}
}
/// The directory an archive would extract into, by this project's naming.
///
/// `veilvoice-0.1.14-linux-x86_64.zip` extracts into
/// `veilvoice-0.1.14-linux-x86_64`. Returns `None` for a name that is not one
/// of ours rather than stripping whatever happens to be after the last dot.
pub fn directory_for(archive: &Path) -> Option<PathBuf> {
let name = archive.file_name()?.to_str()?;
let stem = [".tar.gz", ".tar.xz", ".tgz", ".zip"]
.iter()
.find_map(|suffix| name.strip_suffix(suffix))?;
if !stem.starts_with("veilvoice") {
return None;
}
Some(archive.with_file_name(stem))
}
/// Look in `directory` for the programs a release carries.
pub fn look_in(directory: &Path) -> Extracted {
let mut found = Extracted {
directory: directory.to_path_buf(),
programs: Vec::new(),
};
for program in PROGRAMS {
for name in [program.to_string(), format!("{program}.exe")] {
let path = directory.join(&name);
if path.is_file() {
found.programs.push(Program {
runnable: runnable(&path),
path,
});
break;
}
}
}
found
}
/// Whether the operating system will run this file.
fn runnable(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(path) {
// Any execute bit. Checking only the owner's would report a file
// as unrunnable for somebody running it as a different user in the
// same group, which is a real way to install these.
Ok(meta) => meta.permissions().mode() & 0o111 != 0,
Err(_) => false,
}
}
#[cfg(not(unix))]
{
// No execute bit exists. The extension decides, and the caller is told
// that is the answer being given.
path.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("exe"))
|| path.extension().is_none()
}
}
/// The commands that check this release with somebody else's GnuPG.
///
/// Roadmap item 90 moved the body out of this binary so the desktop application's
/// verify tab prints the same commands; roadmap item 97 moved it again, into
/// `crate::gnupg`, which also *runs* them. Re-exported here rather than
/// called through at every site, which keeps this module the one place the
/// verifier looks for anything about extracted releases and GnuPG.
pub use crate::gnupg::commands as gnupg_commands;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_archive_name_yields_the_folder_it_extracts_into() {
for (archive, folder) in [
(
"veilvoice-0.1.14-linux-x86_64.zip",
"veilvoice-0.1.14-linux-x86_64",
),
("veilvoice-0.1.14-macos.tar.gz", "veilvoice-0.1.14-macos"),
("veilvoice-0.1.14-win.tar.xz", "veilvoice-0.1.14-win"),
] {
assert_eq!(
directory_for(Path::new(archive)),
Some(PathBuf::from(folder)),
"{archive}"
);
}
}
/// Anything not ours produces nothing, rather than a folder name invented
/// by stripping whatever came after the last dot.
#[test]
fn a_name_that_is_not_ours_yields_nothing() {
for other in [
"holiday.zip",
"notes.tar.gz",
"veilvoice",
"somethingelse-1.0.zip",
] {
assert_eq!(directory_for(Path::new(other)), None, "{other}");
}
}
#[test]
fn an_empty_directory_holds_no_programs() {
let dir = tempfile::tempdir().unwrap();
let found = look_in(dir.path());
assert!(found.is_empty());
assert!(found.not_runnable().is_empty());
}
#[cfg(unix)]
#[test]
fn a_program_without_its_execute_bit_is_reported_as_such() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let runnable = dir.path().join("veilvoice");
let inert = dir.path().join("veilvoice-gui");
std::fs::write(&runnable, b"#!/bin/sh\n").unwrap();
std::fs::write(&inert, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(&runnable, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::set_permissions(&inert, std::fs::Permissions::from_mode(0o644)).unwrap();
let found = look_in(dir.path());
assert_eq!(found.programs.len(), 2);
let stuck = found.not_runnable();
assert_eq!(stuck.len(), 1, "one of the two is not runnable");
assert!(stuck[0].path.ends_with("veilvoice-gui"));
}
#[test]
fn the_gnupg_commands_check_the_signature_and_then_the_hashes() {
let lines = gnupg_commands(
Path::new("SHA256SUMS"),
Path::new("SHA256SUMS.asc"),
Some(Path::new("veilvoice-signing-key.asc")),
);
assert_eq!(lines.len(), 3);
assert!(lines[0].starts_with("gpg --import"));
assert!(lines[1].starts_with("gpg --verify"));
assert!(
lines[1].find("SHA256SUMS.asc").unwrap()
< lines[1].find(" SHA256SUMS").unwrap_or(usize::MAX)
|| lines[1].contains("SHA256SUMS.asc SHA256SUMS"),
"the signature comes first: {}",
lines[1]
);
assert!(lines[2].contains("sha256sum -c"));
// Without a key file, there is nothing to import.
let short = gnupg_commands(Path::new("SHA256SUMS"), Path::new("SHA256SUMS.asc"), None);
assert_eq!(short.len(), 2);
assert!(short[0].starts_with("gpg --verify"));
}
/// Roadmap item 91. The extracted report must never run when the archive itself
/// failed. "The archive is bad, and here are the programs in the folder
/// beside it" reads as reassurance, and there is none to give: an archive
/// that failed its signature says nothing good about anything unpacked
/// from it.
#[test]
fn a_failed_archive_stops_before_the_extracted_report() {
let source = include_str!("lib.rs").replace("\r\n", "\n");
let start = source
.find("fn command_auto(")
.expect("command_auto exists");
let end = source[start..]
.find("\n/// Roadmap item 91. What is in the folder")
.map(|at| start + at)
.unwrap_or(source.len());
let body = &source[start..end];
let failure = body
.find("if failures > 0 {")
.expect("the failure branch exists");
let report = body
.find("report_extracted(")
.expect("the extracted report is called");
assert!(
failure < report,
"the extracted report runs before the failure check"
);
assert!(
body[failure..report].contains("return worst;"),
"a failed archive falls through into the extracted report"
);
}
/// This module must not run GnuPG. A verifier that shells out to `gpg` and
/// reports what it said has not escaped the circularity it exists to
/// escape, because the thing running `gpg` is the binary under suspicion.
#[test]
fn it_prints_the_commands_rather_than_running_them() {
let source = include_str!("extracted.rs").replace("\r\n", "\n");
let shipped = source.split("#[cfg(test)]").next().unwrap_or("");
let body: String = shipped
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
for forbidden in ["Command::new", "process::Command", ".output()", ".status()"] {
assert!(
!body.contains(forbidden),
"this module calls {forbidden:?}: the independent check is \
independent because the person runs it"
);
}
}
}