crates/veilvoice-gui/src/verify.rs
what this file is for · veilvoice-gui · 1651 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
//! The verify tab: drop a download on the window and be told what it is.
//!
//! # Why this exists here rather than in the verifier
//!
//! The ask was drag-and-drop verification. There were two ways to have it:
//! link `eframe` into `veilvoice-verify`, or move the checking out of that
//! binary so both front ends call the same code.
//!
//! The portable verifier is the one program in this project whose *smallness*
//! is a feature: it is what somebody downloads before they trust anything
//! else here, and a 1.5 MB single file is part of why it is checkable at all.
//! Putting a GUI toolkit in it would have cost that for a convenience the
//! desktop application was already the right place for. So the arithmetic
//! moved to `veilvoice_verify::check` and this is a second caller, not a second
//! implementation.
//!
//! # Three files, and the tab says so before it is given any
//!
//! Verifying needs the download, the `SHA256SUMS` and the `SHA256SUMS.asc`.
//! Dropping one file on a window and getting a verdict would be a lie, and an
//! interface that discovers the other two are missing *after* the drop teaches
//! people that verification is fiddly rather than that it needs three things.
//! All three slots are visible from the start, and a drop fills whichever one
//! the file's name says it is.
//!
//! # Roadmap item 97: one press, three answers
//!
//! The tab used to answer one question, and it was not the question somebody
//! actually has. "Is this zip the published one" is a step; "is the program I
//! am about to run the published one" is the thing they want to know, and it
//! was being left to the command line.
//!
//! So one press now checks the archive against the signed hash list, then every
//! file extracted out of that archive against the signed contents list the
//! release publishes beside it, and then runs the GnuPG on this machine over
//! the same signature and shows what it said. Three answers, one button, and
//! each one drawn separately so a pass on one is never mistaken for a pass on
//! another.
//!
//! Two things are deliberately **not** failures. A release that published no
//! contents list -- everything before v0.1.15 -- simply has no such row, rather
//! than a warning about a file that was never meant to be there. And a GnuPG
//! that cannot run on this machine is drawn in the quiet colour: it is a fact
//! about the computer and says nothing whatever about the download.
//!
//! # Nothing here downloads anything
//!
//! Not even the key: it is compiled in, and its fingerprint is checked against
//! a constant a reader can compare with the README. `veilvoice-verify` is
//! still the tool for fetching a release, because it is the one that can be
//! checked before it is run.
//!
//! # In plain words
//!
//! Drop a download on the window and be told whether it is genuine.
//!
//! It checks the signature over the list of hashes first, and only then compares
//! your file against that list. That order matters: a list of hashes that has not
//! been checked is just some numbers somebody sent you.
//!
//! Drop the downloaded archive and the hash list and signature sitting beside it
//! are picked up on their own. Nothing is downloaded and nothing leaves the
//! machine.
//!
//! One press checks the zip, then every file you unzipped out of it, and then
//! asks your own GnuPG the same question and shows you its answer.
use crate::layout::column;
use crate::theme::palette as p;
use eframe::egui::{self, RichText, Ui};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use veilvoice_verify::check::{Checked, Error};
use veilvoice_verify::gnupg::backend::{self, Choice as Checker, Survey};
/// Which of the three files a dropped path is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Slot {
Download,
Sums,
Signature,
}
/// Work out what a dropped file is from its name.
///
/// By name rather than by content: the signature and the list are both text,
/// the download is anything, and asking the user "which of these is which"
/// after they have dropped three files is worse than getting it right for the
/// names this project actually publishes. A wrong guess is visible and one
/// click to correct, because every slot also has its own button.
fn slot_for(path: &Path) -> Slot {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if name.ends_with(".asc") || name.ends_with(".sig") {
Slot::Signature
} else if name.contains("sha256sums") || name == "sha256sum.txt" {
Slot::Sums
} else {
Slot::Download
}
}
/// Everything one press of **check** found out.
///
/// **Roadmap item 97.** The tab used to answer one question -- is this archive the
/// published one -- and it now answers three, because the other two are what
/// somebody actually wants to know and both were being left to the command
/// line. The extra work is done on the same worker thread and reported in the
/// same place, so there is still one button.
#[derive(Debug, Default)]
pub struct Report {
/// The archive against the signed hash list. The question this tab has
/// always answered.
pub file: Option<Result<Checked, Error>>,
/// Every file extracted out of that archive, against the signed contents
/// list, where the release published one.
pub contents: Option<Contents>,
/// The same signature, through the GnuPG on this machine.
pub gnupg: Option<Gnupg>,
}
/// What the extracted folder turned out to hold.
#[derive(Debug, Default)]
pub struct Contents {
/// The folder that was looked at.
pub folder: String,
/// How many files the release published for this archive.
pub total: usize,
/// How many of them are on disk, unchanged.
pub good: usize,
/// Everything wrong, one line each, ready to draw.
pub problems: Vec<String>,
/// Why nothing could be checked, when that is the answer.
pub unusable: Option<String>,
}
/// What this machine's GnuPG said.
#[derive(Debug, Default)]
pub struct Gnupg {
/// Where GnuPG was found, or why it was not asked.
pub found: String,
/// What was done to the keyring and how to undo it.
pub note: Vec<String>,
/// GnuPG's answer in one line.
pub verdict: String,
/// Whether that answer agrees with this program's own.
pub agrees: bool,
/// Whether GnuPG gave an answer at all. A GnuPG that could not run says
/// nothing about the download and must not be drawn as a failure.
pub answered: bool,
/// What GnuPG printed, for when the answer needs its evidence.
pub said: Vec<String>,
}
/// The tab's state.
#[derive(Default)]
pub struct Verify {
/// The file being checked.
pub download: Option<PathBuf>,
/// The signed list of hashes.
pub sums: Option<PathBuf>,
/// The detached signature over that list.
pub signature: Option<PathBuf>,
job: Option<mpsc::Receiver<Report>>,
report: Option<Report>,
/// Set while files are over the window, so the drop target can light up.
hovering: bool,
/// The file picker, while it is open.
choosing: crate::dialog::Pending,
/// Which slot the open picker was started for.
///
/// Remembered because the answer arrives frames later, by which time the
/// loop that drew the button is over. Without it a chosen file lands in
/// whichever slot happened to be drawn when the dialog closed.
choosing_for: Option<Slot>,
/// Which OpenPGP implementation the reader chose, if any.
///
/// `None` is not "decide for me": it is the ordinary state, and it means
/// the check built into this binary. See [`veilvoice_verify::gnupg::backend`].
checker: Option<Checker>,
/// What is installed. `None` means the first look has not come back yet.
///
/// This used to stay `None` until somebody pressed "look again", and the
/// reason was sound as far as it went: on Windows, finding out whether
/// GnuPG is inside WSL means starting WSL, and starting a Linux
/// distribution because a window opened is not a thing to do on the
/// thread that draws.
///
/// The answer was the wrong half of it, though. Somebody who opens the
/// Verify tab is asking whether their download is genuine, and being told
/// "nothing has been looked for yet" is the program declining to answer a
/// question it could have answered before being asked. The look happens at
/// launch now, on a thread, so the expensive half costs the window
/// nothing: `survey_job` carries the result back and is polled with
/// `try_recv`, never waited on.
survey: Option<Survey>,
/// The first look, running on its own thread.
survey_job: Option<mpsc::Receiver<Survey>>,
/// When the commands were last copied, so the button can say it happened.
copied: Option<f64>,
}
/// How long the copy button says "copied", in seconds.
///
/// Long enough to read, short enough that it is clearly about the press that
/// just happened rather than a state the button is in.
const COPIED_FOR: f64 = 2.5;
/// Where every release, and the files published beside it, actually are.
///
/// The verify tab asks for a hash list and a signature and used to say
/// nothing about where a person gets one.
const RELEASES_PAGE: &str = "https://github.com/tilas01/veilvoice/releases/latest";
/// The signing key, in the repository as well as in each release, so it can be
/// fetched from somewhere other than the release being checked.
const SIGNING_KEY_IN_REPO: &str =
"https://github.com/tilas01/veilvoice/blob/main/website/assets/veilvoice-signing-key.asc";
/// The width the slot labels are given, so the file names beside them start
/// level. Wide enough for `SHA256SUMS.asc`, which is the longest.
const SLOT_LABEL_WIDTH: f32 = 132.0;
/// The width the file name is given, so the three `choose…` buttons land at
/// one x whatever is in the slots. Wide enough for
/// `the signature over that list`, the longest of the three placeholders; a
/// name longer than this pushes its own button right, and a row where the
/// name is the interesting part is the right place to spend the space.
const SLOT_NAME_WIDTH: f32 = 226.0;
impl Verify {
/// Whether a check is running, so the app keeps repainting.
pub fn is_busy(&self) -> bool {
self.job.is_some()
}
/// Whether the window has to keep drawing for this panel's sake.
///
/// Busy *or* hovering. An idle egui window repaints only when something
/// asks it to, and dragging a file over it is not by itself something that
/// does -- so without this the drop target never lit up and the dropped
/// file did not appear until the mouse moved for some other reason.
pub fn wants_repaint(&self) -> bool {
self.job.is_some() || self.hovering
}
/// Take the worker's answer if it has one. Never waits.
pub fn drain(&mut self) {
let Some(rx) = &self.job else { return };
match rx.try_recv() {
Ok(answer) => {
self.report = Some(answer);
self.job = None;
}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => {
self.report = Some(Report {
file: Some(Err(Error::Io(
"the check stopped without answering".to_string(),
))),
..Report::default()
});
self.job = None;
}
}
}
/// Put a dropped or chosen file into the slot its name says it belongs in.
pub fn accept(&mut self, path: PathBuf) {
let slot = slot_for(&path);
match slot {
Slot::Download => self.download = Some(path.clone()),
Slot::Sums => self.sums = Some(path.clone()),
Slot::Signature => self.signature = Some(path.clone()),
}
// A release is downloaded as three files into one folder, and the other
// two are almost always sitting beside the one that was just dropped.
// Asking somebody to find them by hand three times is asking them to
// give up on verifying, which is the outcome this panel exists to
// prevent.
self.fill_from_beside(&path);
// A new file makes the last answer stale, and a stale verdict beside a
// different file is the worst thing this panel could show.
self.report = None;
}
/// Fill the empty slots from the folder this file came from.
///
/// **Only the empty ones.** A slot somebody chose themselves is never
/// replaced: they said which file they meant, and a guess from a name
/// overriding that is how the wrong thing gets verified and reported as
/// right.
///
/// Only exact names are taken -- `SHA256SUMS` and `SHA256SUMS.asc`, as the
/// release publishes them. Anything looser starts matching files that
/// happen to be nearby, and a hash list is not something to be clever
/// about finding.
fn fill_from_beside(&mut self, path: &std::path::Path) {
let Some(folder) = path.parent() else {
return;
};
if self.sums.is_none() {
let candidate = folder.join("SHA256SUMS");
if candidate.is_file() {
self.sums = Some(candidate);
}
}
if self.signature.is_none() {
let candidate = folder.join("SHA256SUMS.asc");
if candidate.is_file() {
self.signature = Some(candidate);
}
}
}
/// Which slots were filled in by looking rather than by being chosen.
///
/// Shown beside them, because a file that appeared without being asked for
/// is a file somebody should be able to see and change.
pub fn found_beside(&self, path: &std::path::Path) -> Vec<&'static str> {
let Some(folder) = path.parent() else {
return Vec::new();
};
let mut out = Vec::new();
if self.sums.as_deref() == Some(folder.join("SHA256SUMS").as_path()) {
out.push("SHA256SUMS");
}
if self.signature.as_deref() == Some(folder.join("SHA256SUMS.asc").as_path()) {
out.push("SHA256SUMS.asc");
}
out
}
/// Read what the window was given this frame.
///
/// Called from `update` before the tab is drawn. egui reports hovering and
/// dropping through the same input state, so both are taken here and the
/// tab merely renders the result.
pub fn take_dropped(&mut self, ctx: &egui::Context) {
let (hovering, dropped) = ctx.input(|i| {
(
!i.raw.hovered_files.is_empty(),
i.raw
.dropped_files
.iter()
.map(|f| f.path().to_path_buf())
.collect::<Vec<_>>(),
)
});
self.hovering = hovering;
for path in dropped {
self.accept(path);
}
}
/// The whole tab.
pub fn tab(&mut self, ui: &mut Ui) {
// The application scrolls every tab in one place; a second scroller
// here would trap the wheel in whichever the pointer was over.
self.body(ui);
}
fn body(&mut self, ui: &mut Ui) {
ui.heading(RichText::new("Verify a download").color(p::blue()));
ui.add_space(4.0);
ui.label(
RichText::new(
"Drop the downloaded archive on this window and the hash list and \
signature beside it are picked up automatically. Nothing is \
downloaded and nothing leaves this machine.",
)
.color(p::muted()),
);
ui.add_space(4.0);
ui.label(
RichText::new(
"One press checks the signature, the archive, every file you \
extracted out of it, and then all of it again through your own \
GnuPG if you have one.",
)
.color(p::muted())
.small(),
);
// What was filled in without being asked for. Shown rather than left to
// be noticed: a file that appeared on its own is a file somebody should
// be able to see, and change if it is not the one they meant.
if let Some(download) = self.download.clone() {
let found = self.found_beside(&download);
if !found.is_empty() {
ui.add_space(4.0);
ui.label(
RichText::new(format!(
" found beside it: {}. Change either below if that is not \
what you meant",
found.join(" and ")
))
.small()
.color(p::muted()),
);
}
}
ui.add_space(12.0);
self.drop_target(ui);
ui.add_space(12.0);
self.slot_row(
ui,
Slot::Download,
"the download",
"the file you downloaded",
);
self.slot_row(ui, Slot::Sums, "SHA256SUMS", "the list of hashes");
self.slot_row(
ui,
Slot::Signature,
"SHA256SUMS.asc",
"the signature over that list",
);
self.gnupg_section(ui);
// Outside `gnupg_section`, which returns early when the hash list and
// the signature have not been chosen yet. Which program does the
// checking is a decision somebody makes *before* they check anything,
// so putting the chooser behind that return showed it only once it no
// longer mattered.
self.checker_section(ui);
ui.add_space(12.0);
let ready = self.download.is_some() && self.sums.is_some() && self.signature.is_some();
ui.horizontal(|ui| {
let busy = self.is_busy();
if ui
.add_enabled(ready && !busy, egui::Button::new("check"))
.clicked()
{
self.start();
}
if busy {
ui.spinner();
ui.label(RichText::new("checking…").color(p::muted()).small());
} else if !ready {
ui.label(
RichText::new("all three files are needed")
.color(p::muted())
.small(),
);
}
});
ui.add_space(10.0);
self.verdict(ui);
ui.add_space(14.0);
ui.label(
RichText::new("What a pass proves")
.color(p::yellow())
.small(),
);
ui.label(RichText::new(veilvoice_verify::check::SCOPE).color(p::muted()));
ui.add_space(8.0);
ui.label(
RichText::new(format!(
"key fingerprint {}",
veilvoice_verify::check::FINGERPRINT
))
.color(p::muted())
.small(),
);
ui.label(
RichText::new(
"Compare that against the fingerprint in README.md and on the website. A \
program telling you its own key is correct has proved nothing.",
)
.color(p::muted())
.small(),
);
// Where the three files come from.
//
// The tab asks for a hash list and a signature and, until this was
// here, never said where a person gets one. They are published beside
// every archive, and the answer is one line.
ui.add_space(8.0);
ui.label(
RichText::new("Where these files come from")
.color(p::blue())
.small(),
);
ui.label(
RichText::new(
"`SHA256SUMS` and `SHA256SUMS.asc` are published beside every archive \
on the releases page, and so is the signing key, as \
`veilvoice-signing-key.asc`. The same key is in this repository. \
Download all three from the same release you are checking.",
)
.color(p::muted())
.small(),
);
ui.horizontal(|ui| {
ui.hyperlink_to("the releases page", RELEASES_PAGE);
ui.label(RichText::new("and").color(p::muted()).small());
ui.hyperlink_to("the signing key in the repository", SIGNING_KEY_IN_REPO);
});
}
/// The rectangle that lights up while files are over the window.
fn drop_target(&self, ui: &mut Ui) {
let (rect, _) = ui.allocate_exact_size(
egui::vec2(ui.available_width().min(520.0), 74.0),
egui::Sense::hover(),
);
let (edge, ink, words) = if self.hovering {
(p::blue(), p::blue(), "let go")
} else {
(p::border(), p::muted(), "drop files here")
};
ui.painter().rect_stroke(
rect,
9.0,
egui::Stroke::new(1.5, edge),
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
words,
egui::FontId::monospace(15.0),
ink,
);
}
/// One file slot: what it is, what is in it, and a way to change it.
///
/// The two labels are given fixed-width columns so that the three rows
/// line up and, with them, the three `choose…` buttons beside them.
///
/// This used to pad the label with `{label:<16}` and hope. Trailing spaces
/// line nothing up in a proportional font, which is the same habit the
/// alignment work already took out of this application once: `the
/// download`, `SHA256SUMS` and `SHA256SUMS.asc` are three different widths
/// on screen whatever they are padded to, so the second column started in
/// three different places and every button sat somewhere else again.
fn slot_row(&mut self, ui: &mut Ui, slot: Slot, label: &str, what: &str) {
ui.horizontal(|ui| {
column(ui, SLOT_LABEL_WIDTH, |ui| {
ui.label(RichText::new(label).color(p::muted()).small());
});
let current = match slot {
Slot::Download => &self.download,
Slot::Sums => &self.sums,
Slot::Signature => &self.signature,
};
let text = match current {
Some(path) => path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string()),
None => what.to_string(),
};
column(ui, SLOT_NAME_WIDTH, |ui| {
ui.label(RichText::new(text).color(if current.is_some() {
p::fg()
} else {
p::muted()
}));
});
if ui
.add_enabled(
!self.choosing.is_open(),
egui::Button::new("choose…").small(),
)
.clicked()
{
// Which slot was asked for is remembered here, because the
// answer arrives some frames later and by then this loop is
// over. Without it the file would land in whichever slot
// happened to be drawn when the dialog closed.
self.choosing_for = Some(slot);
self.choosing.start(crate::dialog::Ask::open());
}
});
// Straight into the slot that was asked for, not through `accept`: the
// user said which one by pressing that button, and a guess from the
// file name would override what they just told us.
if let Some(path) = self.choosing.taken() {
match self.choosing_for.take() {
Some(Slot::Download) => self.download = Some(path),
Some(Slot::Sums) => self.sums = Some(path),
Some(Slot::Signature) => self.signature = Some(path),
None => {}
}
self.report = None;
}
}
/// Roadmap item 90. The same check, with a GnuPG this project did not write.
///
/// Here rather than on its own tab because this is where somebody is
/// already asking "is this download genuine", and the honest answer to that
/// question includes "and here is how to ask something other than me".
///
/// The commands come from [`veilvoice_verify::gnupg::commands`], which the
/// portable verifier also uses, so the window and the command line cannot
/// drift into printing two different recipes.
fn gnupg_section(&mut self, ui: &mut Ui) {
ui.add_space(16.0);
ui.separator();
ui.label(
RichText::new("The same check, typed by you")
.color(p::blue())
.small(),
);
ui.label(
RichText::new(
"Pressing check above already runs your GnuPG and shows what it said. \
These are the same commands for you to run yourself, which is the part \
no program can do for you: the one telling you a download is genuine \
came out of that download.",
)
.color(p::muted())
.small(),
);
let (Some(sums), Some(signature)) = (self.sums.clone(), self.signature.clone()) else {
ui.label(
RichText::new(
"choose a hash list and a signature above, and the commands appear here",
)
.color(p::muted())
.small(),
);
return;
};
match veilvoice_verify::gnupg::on_path() {
Some(gpg) => ui.label(
RichText::new(format!("GnuPG found at {}", gpg.display()))
.color(p::green())
.small(),
),
None => ui.label(
RichText::new(
"GnuPG is not on your PATH. These are the commands if you install it.",
)
.color(p::muted())
.small(),
),
};
// The signing key beside the hash list, when the release shipped one.
let key = sums.with_file_name("veilvoice-signing-key.asc");
let key = key.is_file().then_some(key);
let commands = veilvoice_verify::gnupg::commands(&sums, &signature, key.as_deref());
ui.add_space(4.0);
let backend = backend::resolve(
self.checker,
self.survey.as_ref().unwrap_or(&Survey::default()),
);
for line in &commands {
ui.label(
RichText::new(backend.spell(line))
.color(p::fg())
.monospace(),
);
}
ui.add_space(4.0);
// The copy button answers. A button that does nothing visible when
// pressed leaves somebody pressing it again to find out whether it
// worked, and a clipboard is not somewhere they can easily look.
let now = ui.input(|i| i.time);
let just_copied = self.copied.is_some_and(|at| now - at < COPIED_FOR);
ui.horizontal(|ui| {
if ui.button("copy these commands").clicked() {
let script: Vec<String> = commands.iter().map(|line| backend.spell(line)).collect();
ui.ctx().copy_text(script.join("\n"));
self.copied = Some(now);
}
if just_copied {
ui.label(
RichText::new(format!(
"copied, {} lines, ready to paste into a terminal",
commands.len()
))
.color(p::green())
.small(),
);
}
});
if just_copied {
// Keep drawing until the message has had its time, or it would sit
// there until something else caused a repaint.
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(250));
}
ui.label(
RichText::new(
"Worth doing. VeilVoice checked the signature with a key built into \
itself, and this program came out of the same download you are \
checking. GnuPG, and the fingerprint on the website, are the \
independent answer.",
)
.color(p::muted())
.small(),
);
}
/// Which implementation checks the signature, and the choice behind it.
///
/// Three of them, and the difference is the whole point: the built-in
/// check is made by a program that came out of the download it is
/// checking, and the others are not. See [`veilvoice_verify::gnupg::backend`] for
/// why an installed GnuPG is still not used until it is chosen.
/// Look for GnuPG, on a thread, and never twice at once.
///
/// On a thread because on Windows the WSL half starts a Linux
/// distribution, which takes as long as it takes. The window must not
/// wait for that, so it does not: the result comes back through a channel
/// that is only ever polled.
fn start_survey(&mut self) {
if self.survey_job.is_some() {
return;
}
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut fresh = backend::look();
// Only Windows has WSL, and `look` only reports one there, so this
// is a no-op everywhere else rather than a platform check.
if let Some(wsl) = fresh.wsl.as_mut() {
wsl.gpg = backend::look_in_wsl(&wsl.program);
}
let _ = tx.send(fresh);
});
self.survey_job = Some(rx);
}
/// Take the result if it has arrived, without ever waiting for it.
///
/// `try_recv` rather than `recv`: this runs on the thread that draws, and
/// blocking it on a WSL start is the whole thing being avoided.
fn poll_survey(&mut self) {
let Some(rx) = &self.survey_job else { return };
match rx.try_recv() {
Ok(fresh) => {
self.survey = Some(fresh);
self.survey_job = None;
}
Err(mpsc::TryRecvError::Disconnected) => self.survey_job = None,
Err(mpsc::TryRecvError::Empty) => {}
}
}
fn checker_section(&mut self, ui: &mut Ui) {
// The first look starts itself. Somebody who opens this tab is asking
// whether their download is genuine, and a program that could have
// found GnuPG before being asked should have.
self.poll_survey();
if self.survey.is_none() && self.survey_job.is_none() {
self.start_survey();
}
ui.add_space(12.0);
ui.label(
RichText::new("Which program does the checking")
.color(p::blue())
.small(),
);
let survey = self.survey.clone().unwrap_or_default();
let backend = backend::resolve(self.checker, &survey);
ui.label(
RichText::new(backend.plainly())
.color(if backend.is_second_opinion() {
p::green()
} else {
p::muted()
})
.small(),
);
ui.horizontal(|ui| {
for choice in Checker::ALL {
let usable = survey.supports(*choice) || *choice == Checker::BuiltIn;
let chosen = self.checker == Some(*choice);
let label = match choice {
Checker::BuiltIn => "VeilVoice's own check",
Checker::Native => "GnuPG on this machine",
Checker::Wsl => "GnuPG inside WSL",
};
if ui
.add_enabled(usable, egui::Button::selectable(chosen, label))
.clicked()
{
self.checker = Some(*choice);
}
}
if ui.button("look again").clicked() {
self.start_survey();
}
});
if self.survey.is_none() {
ui.label(
RichText::new(
"Looking for GnuPG on this machine, and on Windows inside \
WSL as well. This finishes on its own.",
)
.color(p::muted())
.small(),
);
return;
}
// What to do when the one they want is not here.
if !survey.supports(Checker::Native) {
if let Some(companion) = veilvoice_setup::companions::ALL
.iter()
.find(|c| c.key == "gnupg")
{
match companion.offer() {
veilvoice_setup::companions::Offer::Command { argv, via, .. } => {
ui.label(
RichText::new(format!(
"GnuPG is not on this machine. To install it, via {via}:"
))
.color(p::muted())
.small(),
);
self.copyable_command(ui, &argv.join(" "));
}
other => {
ui.label(
RichText::new(format!(
"GnuPG is not on this machine. {}",
match other {
veilvoice_setup::companions::Offer::NoKnownRoute(why) => why,
veilvoice_setup::companions::Offer::Page(page) =>
format!("It can be downloaded from {page}."),
_ => "Install it the way you install anything else \
on this system."
.to_string(),
}
))
.color(p::muted())
.small(),
);
}
}
}
}
if let Some(wsl) = survey.wsl.as_ref() {
if wsl.gpg.is_none() {
ui.label(
RichText::new("WSL is here and GnuPG inside it is not. To install it there:")
.color(p::muted())
.small(),
);
self.copyable_command(ui, &backend::install_in_wsl().join(" "));
ui.label(
RichText::new(
"That needs root inside the distribution. VeilVoice does not \
ask for a password and will not run it: run it in a terminal, \
where you can see what you are approving.",
)
.color(p::muted())
.small(),
);
}
}
}
/// One command, shown as it would be typed, with a button that copies it
/// and says it did.
fn copyable_command(&mut self, ui: &mut Ui, command: &str) {
let now = ui.input(|i| i.time);
ui.horizontal(|ui| {
ui.label(RichText::new(command).color(p::fg()).monospace());
if ui.small_button("copy").clicked() {
ui.ctx().copy_text(command.to_string());
self.copied = Some(now);
}
});
}
/// The answer, in the colour it deserves.
fn verdict(&self, ui: &mut Ui) {
let Some(report) = &self.report else {
ui.label(RichText::new("Not checked.").color(p::muted()).small());
return;
};
self.archive_verdict(ui, report);
self.contents_verdict(ui, report);
self.gnupg_verdict(ui, report);
}
/// Roadmap item 97. Every extracted file against the signed contents list.
fn contents_verdict(&self, ui: &mut Ui, report: &Report) {
let Some(contents) = &report.contents else {
return;
};
ui.add_space(10.0);
ui.label(
RichText::new(format!("Everything in {}", contents.folder))
.color(p::blue())
.small(),
);
if let Some(why) = &contents.unusable {
ui.label(RichText::new(why.clone()).color(p::red()));
ui.label(
RichText::new(
"Nothing in that folder was checked. Do not treat what is in it as \
verified.",
)
.color(p::muted())
.small(),
);
return;
}
for line in &contents.problems {
ui.label(RichText::new(line.clone()).color(p::red()).monospace());
}
if contents.problems.is_empty() {
ui.label(
RichText::new(format!(
"all {} files match the signed list, and there is nothing else in \
the folder",
contents.total
))
.color(p::green()),
);
} else {
ui.label(
RichText::new(format!(
"{} of {} files are as published. Extract the checked archive again \
and use what comes out of it.",
contents.good, contents.total
))
.color(p::red()),
);
}
}
/// Roadmap item 97. What this machine's own GnuPG made of the same signature.
fn gnupg_verdict(&self, ui: &mut Ui, report: &Report) {
let Some(gnupg) = &report.gnupg else {
return;
};
ui.add_space(10.0);
ui.label(
RichText::new("Checked again with your own GnuPG")
.color(p::blue())
.small(),
);
ui.label(RichText::new(gnupg.found.clone()).color(p::muted()).small());
for line in &gnupg.note {
ui.label(RichText::new(line.clone()).color(p::muted()).small());
}
if !gnupg.verdict.is_empty() {
// A GnuPG that could not run is drawn in the quiet colour, never
// the loud one. It is a fact about this machine and says nothing
// whatever about the download.
let colour = if !gnupg.answered {
p::muted()
} else if gnupg.agrees {
p::green()
} else {
p::red()
};
ui.label(RichText::new(gnupg.verdict.clone()).color(colour));
}
for line in &gnupg.said {
ui.label(
RichText::new(line.clone())
.color(p::muted())
.small()
.monospace(),
);
}
}
/// The archive against the signed hash list.
fn archive_verdict(&self, ui: &mut Ui, report: &Report) {
match &report.file {
None => {
ui.label(RichText::new("Not checked.").color(p::muted()).small());
}
Some(Ok(checked)) if checked.matched => {
ui.label(
RichText::new(format!("{} is the file this key published.", checked.name))
.color(p::green()),
);
ui.label(
RichText::new(format!("sha256 {}", checked.actual))
.color(p::muted())
.small(),
);
}
Some(Ok(checked)) => {
// The signature was good and the hash was not. Said in the
// strongest words this panel has, because it is the one outcome
// that means something is actually wrong with the file.
ui.label(
RichText::new(format!(
"{} does NOT match the signed list. Do not run it.",
checked.name
))
.color(p::red()),
);
ui.label(
RichText::new(format!("expected {}", checked.expected))
.color(p::muted())
.small(),
);
ui.label(
RichText::new(format!("actual {}", checked.actual))
.color(p::muted())
.small(),
);
}
Some(Err(error)) => {
ui.label(RichText::new(error.to_string()).color(p::red()));
if matches!(error, Error::NotListed(_)) {
ui.label(
RichText::new(
"That usually means the SHA256SUMS is from a different release \
than the file.",
)
.color(p::muted())
.small(),
);
}
}
}
}
/// Run the check on a thread of its own.
///
/// Hashing a release archive is tens of megabytes of reading, and OpenPGP
/// verification is not free either. `update()` may start work; it may never
/// wait for it.
fn start(&mut self) {
let (Some(download), Some(sums), Some(signature)) = (
self.download.clone(),
self.sums.clone(),
self.signature.clone(),
) else {
return;
};
let (tx, rx) = mpsc::channel();
self.job = Some(rx);
self.report = None;
std::thread::spawn(move || {
let _ = tx.send(examine(&download, &sums, &signature));
});
}
}
/// The whole check, off the drawing thread.
///
/// A free function rather than a method so it cannot reach the tab's state:
/// everything it needs is in its three arguments and everything it found is in
/// what it returns, which is the only shape that is safe to run on a thread
/// while the window carries on drawing.
fn examine(download: &Path, sums_path: &Path, signature_path: &Path) -> Report {
let read = |path: &Path| {
std::fs::read_to_string(path)
.map_err(|e| Error::Io(format!("cannot read {}: {e}", path.display())))
};
let (sums, signature) = match (read(sums_path), read(signature_path)) {
(Ok(sums), Ok(signature)) => (sums, signature),
(Err(why), _) | (_, Err(why)) => {
return Report {
file: Some(Err(why)),
..Report::default()
}
}
};
let file = veilvoice_verify::check::check_file(download, &sums, &signature);
// The rest only when the archive itself is the published one. Reporting on
// an extracted folder after the archive failed would be answering a
// question nobody should still be asking.
let go_on = matches!(&file, Ok(checked) if checked.matched);
Report {
contents: go_on
.then(|| examine_contents(download, sums_path, &sums, &signature))
.flatten(),
gnupg: Some(examine_gnupg(sums_path, signature_path)),
file: Some(file),
}
}
/// Roadmap item 97. Every file in the extracted folder, against the signed list.
///
/// The order is the one the whole project keeps: `CONTENTS.sha256` is checked
/// against the signed hash list **before** it is parsed, because it decides
/// which paths get read and what they are compared against.
fn examine_contents(
download: &Path,
sums_path: &Path,
sums: &str,
signature: &str,
) -> Option<Contents> {
use veilvoice_verify::check::contents;
let list = sums_path.with_file_name(contents::CONTENTS);
if !list.is_file() {
// Every release before v0.1.15 is in this position, and it is not a
// failure. Nothing is drawn rather than a row saying so, because a
// panel that reports the absence of an optional file teaches people to
// worry about it.
return None;
}
let folder = download
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let unusable = |why: String| {
Some(Contents {
folder: folder.clone(),
unusable: Some(why),
..Contents::default()
})
};
match veilvoice_verify::check::check_file(&list, sums, signature) {
Err(why) => return unusable(format!("{why}")),
Ok(checked) if !checked.matched => {
return unusable(
"CONTENTS.sha256 is not the list this release signed: the hashes do not \
agree."
.to_string(),
)
}
Ok(_) => {}
}
let text = match std::fs::read_to_string(&list) {
Ok(text) => text,
Err(e) => return unusable(format!("cannot read {}: {e}", list.display())),
};
let all = match contents::parse(&text) {
Ok(all) => all,
Err(why) => return unusable(format!("{why}")),
};
let name = download.file_name()?.to_string_lossy().into_owned();
let section = contents::for_archive(&all, &name)?;
let root = download.parent().unwrap_or(Path::new("."));
let outcomes = contents::check(root, section);
let sweep = contents::extras(root, section);
let good = outcomes.iter().filter(|o| o.is_good()).count();
let mut problems = Vec::new();
for outcome in &outcomes {
match &outcome.verdict {
contents::Verdict::Matches => {}
contents::Verdict::Differs { .. } => {
problems.push(format!("CHANGED {}", outcome.path))
}
contents::Verdict::Missing => problems.push(format!("MISSING {}", outcome.path)),
contents::Verdict::Unreadable(why) => {
problems.push(format!("UNREADABLE {}: {why}", outcome.path))
}
// F-99. A link or a directory standing where a file should be is
// not the published file, whatever it points at.
contents::Verdict::NotAFile(what) => {
problems.push(format!("{what} WHERE A FILE SHOULD BE {}", outcome.path))
}
}
}
for extra in &sweep.extras {
problems.push(format!(
"NOT PART OF THE RELEASE {}",
extra.strip_prefix(root).unwrap_or(extra).display()
));
}
// F-98. Unknown is not empty. A folder this could not open must not be
// drawn as one it looked in and found nothing.
for shut in &sweep.unreadable {
problems.push(format!(
"COULD NOT LOOK INSIDE {}",
shut.strip_prefix(root).unwrap_or(shut).display()
));
}
Some(Contents {
folder: section.roots().into_iter().next().unwrap_or(folder),
total: outcomes.len(),
good,
problems,
unusable: None,
})
}
/// Roadmap item 97. The same signature, through the GnuPG this machine already has.
fn examine_gnupg(sums: &Path, signature: &Path) -> Gnupg {
let gpg = match veilvoice_verify::gnupg::Gnupg::found() {
Err(why) => {
return Gnupg {
found: format!("{why}. The commands below are what to run if you install it."),
..Gnupg::default()
}
}
Ok(gpg) => gpg,
};
let mut report = Gnupg {
found: format!("GnuPG found at {}", gpg.program().display()),
..Gnupg::default()
};
match gpg.import(
veilvoice_verify::check::PUBLIC_KEY,
veilvoice_verify::check::FINGERPRINT,
) {
// Not drawn as a failure. GnuPG being unusable on this machine says
// nothing about the download.
Err(why) => {
report.verdict = format!("GnuPG could not be asked: {why}");
return report;
}
Ok(import) => report.note = import.note(),
}
match gpg.verify(signature, sums, veilvoice_verify::check::FINGERPRINT) {
Err(why) => report.verdict = format!("GnuPG could not be asked: {why}"),
Ok(run) => {
report.answered = true;
report.agrees = run.outcome.is_good();
report.verdict = run.outcome.plainly();
if !report.agrees {
report.said = run.said.lines().map(str::to_string).collect();
}
}
}
report
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_dropped_file_lands_in_the_slot_its_name_says() {
assert_eq!(slot_for(Path::new("SHA256SUMS")), Slot::Sums);
assert_eq!(slot_for(Path::new("sha256sums")), Slot::Sums);
assert_eq!(slot_for(Path::new("SHA256SUMS.asc")), Slot::Signature);
assert_eq!(slot_for(Path::new("release.sig")), Slot::Signature);
assert_eq!(
slot_for(Path::new("veilvoice-0.1.12-windows.zip")),
Slot::Download
);
// `.asc` wins over the word "sha256sums", because the signature file is
// named after the thing it signs and would otherwise land in the wrong
// slot every single time.
assert_eq!(slot_for(Path::new("SHA256SUMS.asc")), Slot::Signature);
}
/// **The three files arrive together.** A release is downloaded as an
/// archive plus `SHA256SUMS` plus `SHA256SUMS.asc`, into one folder.
/// Making somebody find each of them by hand is making them give up.
#[test]
fn dropping_the_archive_finds_the_hash_list_and_signature_beside_it() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("veilvoice-v0.1.13-windows-x86_64.zip");
std::fs::write(&archive, b"pretend").unwrap();
std::fs::write(dir.path().join("SHA256SUMS"), b"list").unwrap();
std::fs::write(dir.path().join("SHA256SUMS.asc"), b"sig").unwrap();
let mut verify = Verify::default();
verify.accept(archive.clone());
assert_eq!(verify.download.as_deref(), Some(archive.as_path()));
assert_eq!(
verify.sums.as_deref(),
Some(dir.path().join("SHA256SUMS").as_path())
);
assert_eq!(
verify.signature.as_deref(),
Some(dir.path().join("SHA256SUMS.asc").as_path())
);
assert_eq!(
verify.found_beside(&archive),
vec!["SHA256SUMS", "SHA256SUMS.asc"]
);
}
/// A slot somebody chose is never replaced by a guess. They said which
/// file they meant, and overriding that is how the wrong thing gets
/// verified and reported as right.
#[test]
fn a_file_that_was_chosen_is_never_replaced_by_one_found_nearby() {
let dir = tempfile::tempdir().unwrap();
let elsewhere = tempfile::tempdir().unwrap();
let archive = dir.path().join("release.zip");
std::fs::write(&archive, b"pretend").unwrap();
std::fs::write(dir.path().join("SHA256SUMS"), b"the one beside it").unwrap();
let chosen = elsewhere.path().join("SHA256SUMS");
std::fs::write(&chosen, b"the one they picked").unwrap();
let mut verify = Verify {
sums: Some(chosen.clone()),
..Verify::default()
};
verify.accept(archive);
assert_eq!(
verify.sums.as_deref(),
Some(chosen.as_path()),
"the chosen file must survive"
);
}
/// Only the exact published names. Anything looser starts matching files
/// that merely happen to be nearby, and a hash list is not something to be
/// clever about finding.
#[test]
fn only_the_exact_names_are_taken_from_the_folder() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("release.zip");
std::fs::write(&archive, b"pretend").unwrap();
for decoy in ["SHA256SUMS.old", "sha256sums.txt", "SHA256SUMS.asc.bak"] {
std::fs::write(dir.path().join(decoy), b"no").unwrap();
}
let mut verify = Verify::default();
verify.accept(archive.clone());
assert_eq!(verify.sums, None, "no decoy may be taken");
assert_eq!(verify.signature, None);
assert!(verify.found_beside(&archive).is_empty());
}
#[test]
fn accepting_files_fills_the_three_slots() {
let mut verify = Verify::default();
verify.accept(PathBuf::from("veilvoice.zip"));
verify.accept(PathBuf::from("SHA256SUMS"));
verify.accept(PathBuf::from("SHA256SUMS.asc"));
assert_eq!(verify.download, Some(PathBuf::from("veilvoice.zip")));
assert_eq!(verify.sums, Some(PathBuf::from("SHA256SUMS")));
assert_eq!(verify.signature, Some(PathBuf::from("SHA256SUMS.asc")));
}
/// A new file makes the last answer stale, and a stale verdict shown beside
/// a different file is the worst thing this panel could do.
#[test]
fn a_new_file_clears_the_previous_answer() {
let mut verify = Verify {
report: Some(Report {
file: Some(Err(Error::BadSignature)),
..Report::default()
}),
..Verify::default()
};
verify.accept(PathBuf::from("veilvoice.zip"));
assert!(verify.report.is_none());
}
/// The window has to keep drawing while a file is over it, not only while
/// a check is running. This is the assertion behind that.
#[test]
fn hovering_keeps_the_window_awake() {
let mut verify = Verify::default();
assert!(!verify.wants_repaint(), "an idle panel asks for nothing");
verify.hovering = true;
assert!(verify.wants_repaint(), "a file over the window is a reason");
assert!(!verify.is_busy(), "hovering is not a running check");
}
#[test]
fn a_check_cannot_start_without_all_three() {
let mut verify = Verify::default();
verify.start();
assert!(!verify.is_busy());
verify.accept(PathBuf::from("veilvoice.zip"));
verify.start();
assert!(!verify.is_busy());
verify.accept(PathBuf::from("SHA256SUMS"));
verify.start();
assert!(!verify.is_busy(), "still no signature");
}
#[test]
fn a_worker_that_dies_without_answering_is_reported() {
let (tx, rx) = mpsc::channel();
drop(tx);
let mut verify = Verify {
job: Some(rx),
..Verify::default()
};
verify.drain();
assert!(!verify.is_busy());
match verify.report.as_ref().and_then(|r| r.file.as_ref()) {
Some(Err(Error::Io(why))) => assert!(why.contains("without answering"), "{why}"),
other => panic!("expected a reported failure, got {other:?}"),
}
}
/// The signing key this tab points at is a file that exists here.
///
/// A link to a key is worth exactly as much as the key being at the other
/// end of it. The path is the one `veilvoice_verify::check` compiles the key in
/// from, so if the file moves, both this link and that include break
/// together rather than the link rotting quietly.
#[test]
fn the_signing_key_link_points_at_the_key_that_is_compiled_in() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../website/assets/veilvoice-signing-key.asc"
);
assert!(
std::path::Path::new(path).is_file(),
"the signing key is not at {path}, so the link on the verify tab \
points at nothing"
);
assert!(
SIGNING_KEY_IN_REPO.ends_with("website/assets/veilvoice-signing-key.asc"),
"the link and the file have parted company: {SIGNING_KEY_IN_REPO}"
);
}
/// The GnuPG section is drawn once, not once for each file slot.
///
/// It used to be the last statement of `slot_row`, and `slot_row` is
/// called three times: for the download, the hash list and the signature.
/// So the verify tab carried three copies of the heading "The same check,
/// typed by you", three copies of the paragraph under it and three copies
/// of "choose a hash list and a signature above", interleaved with the
/// three file rows. It shipped in every screenshot of that tab.
///
/// Nothing caught it because every piece of it was correct: the section
/// draws what it should, and `slot_row` draws what it should. Only the
/// place one is called from was wrong, and no test looked at that.
///
/// This reads the call site rather than the drawing, because where it is
/// called from is exactly what was wrong.
#[test]
fn the_gnupg_section_is_drawn_once() {
// Only the code, not the tests: this file is read by this test, and
// the name written in the assertion below is itself a match. The
// first version counted two calls and one of them was its own.
let source = include_str!("verify.rs").replace("\r\n", "\n");
let source = source.split("\n#[cfg(test)]").next().unwrap();
let calls = source.matches("self.gnupg_section(ui)").count();
assert_eq!(
calls, 1,
"the GnuPG section is called {calls} times; it belongs once, in \
`body`, after the three slots rather than inside each of them"
);
let row = source
.split("fn slot_row(")
.nth(1)
.and_then(|rest| rest.split("\n fn ").next())
.expect("slot_row exists");
assert!(
!row.contains("gnupg_section"),
"`slot_row` draws the GnuPG section, so it is drawn once per file \
slot and the tab carries three copies of it"
);
}
/// The slot labels are given a column rather than padded with spaces.
///
/// `format!("{label:<16}")` lines nothing up in a proportional font, so
/// the three file names started in three different places and the three
/// `choose…` buttons beside them did too. The same habit was taken out of
/// `security.rs` once already, which is why the fix now lives in
/// `layout::column` and both call it.
#[test]
fn the_slot_rows_use_a_column_and_not_padding() {
let source = include_str!("verify.rs").replace("\r\n", "\n");
let source = source.split("\n#[cfg(test)]").next().unwrap();
let row = source
.split("fn slot_row(")
.nth(1)
.and_then(|rest| rest.split("\n fn ").next())
.expect("slot_row exists");
assert!(
!row.contains(":<"),
"a slot label is padded to a width with spaces, which aligns \
nothing in a proportional font"
);
assert_eq!(
row.matches("column(ui,").count(),
2,
"both the label and the file name need a fixed column, or the \
`choose…` buttons do not line up with each other"
);
}
/// Roadmap item 90. The window and the command line must print the same GnuPG
/// recipe, which is why the body of it lives in `veilvoice_verify::check` and both
/// call it rather than each keeping a copy.
#[test]
fn the_window_prints_the_shared_gnupg_commands() {
let source = include_str!("verify.rs").replace("\r\n", "\n");
let start = source
.find("fn gnupg_section(")
.expect("the section exists");
let end = source[start..]
.find("\n /// The answer, in the colour")
.map(|at| start + at)
.unwrap_or(source.len());
let body = &source[start..end];
assert!(
body.contains("veilvoice_verify::gnupg::commands"),
"the window builds its own commands, which will drift from the \
verifier's"
);
for forbidden in ["Command::new", "process::Command"] {
assert!(
!body.contains(forbidden),
"the window runs {forbidden:?}: the independent check is \
independent because the person runs it"
);
}
}
/// The whole thing, end to end, against a file this test makes: the
/// signature check has to fail before any hash is believed, even when the
/// hash is perfect.
#[test]
fn a_perfect_hash_under_a_bad_signature_still_fails() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("release.zip");
std::fs::write(&file, b"contents").unwrap();
let sums = format!(
"{} release.zip\n",
veilvoice_verify::check::sha256_bytes(b"contents")
);
let error = veilvoice_verify::check::check_file(&file, &sums, "not a signature")
.expect_err("a bad signature must stop the check");
assert!(matches!(error, Error::Malformed(_)), "{error:?}");
}
/// **Roadmap item 97.** A release with no contents list produces no row about
/// one. Everything published before v0.1.15 is in that position, and a
/// panel that reported the absence of an optional file would teach people
/// to worry about it.
#[test]
fn a_release_without_a_contents_list_draws_nothing_about_one() {
let room = std::env::temp_dir().join(format!(
"veilvoice-verify-tab-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&room).unwrap();
let sums = room.join("SHA256SUMS");
std::fs::write(&sums, b"nothing\n").unwrap();
assert!(
examine_contents(
&room.join("veilvoice-v0.1.15-linux-x86_64.tar.gz"),
&sums,
"",
""
)
.is_none(),
"no CONTENTS.sha256 beside it means no contents row"
);
std::fs::remove_dir_all(&room).ok();
}
/// **Roadmap item 97.** A contents list that is not the signed one is refused,
/// and nothing in the folder is reported on. Parsing it first and checking
/// afterwards would be letting a downloaded text file choose which paths
/// get read.
#[test]
fn a_contents_list_that_is_not_the_signed_one_checks_nothing() {
let room = std::env::temp_dir().join(format!(
"veilvoice-verify-tab-bad-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&room).unwrap();
let sums = room.join("SHA256SUMS");
std::fs::write(&sums, b"nothing\n").unwrap();
std::fs::write(room.join("CONTENTS.sha256"), b"# a.tar.gz\n").unwrap();
let report = examine_contents(
&room.join("veilvoice-v0.1.15-linux-x86_64.tar.gz"),
&sums,
"nothing\n",
"not a signature",
)
.expect("a list that is there is reported on");
assert!(report.unusable.is_some(), "{report:?}");
assert_eq!(report.total, 0, "nothing was checked");
std::fs::remove_dir_all(&room).ok();
}
/// **Roadmap item 97.** A GnuPG that cannot run is never drawn as a failure of
/// the download. The distinction is the one this is most tempted to get
/// wrong, and getting it wrong tells somebody not to run a sound release.
#[test]
fn a_gnupg_that_gave_no_answer_is_not_a_disagreement() {
let quiet = Gnupg {
found: "GnuPG is not on your PATH.".to_string(),
..Gnupg::default()
};
assert!(!quiet.answered, "nothing was asked");
assert!(!quiet.agrees, "and nothing agreed");
// The panel colours on `answered` first, so a GnuPG that said nothing
// is muted rather than red. Asserted here because the drawing itself
// needs a window and this is the decision inside it.
let source = include_str!("verify.rs");
let body = source
.split("fn gnupg_verdict(")
.nth(1)
.expect("the GnuPG row has to be findable");
let body = body.split("\n /// ").next().unwrap();
let muted = body.find("!gnupg.answered").expect("answered is checked");
let red = body.find("p::red()").expect("red exists");
assert!(muted < red, "the not-answered case must be decided first");
}
/// **Roadmap item 97.** The extracted folder is only looked at once the archive
/// itself has passed. Reporting on a folder after the archive failed would
/// be answering a question nobody should still be asking.
#[test]
fn nothing_is_reported_about_a_folder_when_the_archive_failed() {
let source = include_str!("verify.rs");
let body = source
.split("fn examine(")
.nth(1)
.expect("the worker has to be findable");
let body = body.split("\nfn ").next().unwrap();
assert!(
body.contains("let go_on = matches!(&file, Ok(checked) if checked.matched)"),
"the contents check must be gated on the archive passing"
);
assert!(
body.contains("go_on\n .then("),
"and the gate must be the thing that decides whether it runs"
);
}
}