crates/veilvoice-verify/src/discover.rs

what this file is for · veilvoice-verify · 460 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
//! Finding a release to check, without being told where it is.
//!
//! # Why this exists
//!
//! Verifying a download used to require naming three files and knowing a tag.
//! That is fine for somebody who has already read the instructions and is
//! wrong for everybody else, and "everybody else" is precisely the population
//! a verifier exists to serve. Somebody who has just downloaded an archive and
//! wants to know whether it is the real one should be able to run this and be
//! told.
//!
//! So: look in the obvious places, in an obvious order, and say what was found.
//! Nothing here downloads, nothing here guesses at a hash, and nothing here
//! reports "verified" on the strength of a filename.
//!
//! # Where it looks, and why in that order
//!
//! 1. The directory given, if one was.
//! 2. The current working directory, where somebody who has just `cd`-ed to
//!    their downloads will be.
//! 3. The directory the running binary is in, where somebody who unpacked the
//!    archive and double-clicked the verifier inside it will be.
//! 4. The usual download directories for the platform.
//!
//! Each is searched one level deep only. A recursive walk of a home directory
//! is slow, surprising, and would let a verifier wander into places nobody
//! asked it to look at.
//!
//! # What counts as a release archive
//!
//! A file whose name starts `veilvoice-` and ends in one of the archive
//! extensions this project publishes. That is a **filename** test and it proves
//! nothing at all: it is how candidates are found, never how they are judged.
//! Every candidate still has to survive the signature and the hash, and a file
//! that merely looks the part fails exactly as loudly as one that does not.
//!
//! # In plain words
//!
//! Looks for a downloaded release to check, so you can double-click the verifier
//! and have it work.
//!
//! It looks in the folder it is in, the current folder, one level up from each
//! of those, and your Downloads and Desktop. If it finds nothing it says exactly where it looked, rather than
//! reporting a failure that leaves you guessing.

use std::path::{Path, PathBuf};


/// The extensions this project publishes releases as.
const ARCHIVES: &[&str] = &[".zip", ".tar.gz", ".tgz", ".tar.xz"];



/// The names of the two files a signed release carries beside its archives.
pub const SUMS: &str = "SHA256SUMS";


/// The detached signature over [`SUMS`].
pub const SUMS_SIG: &str = "SHA256SUMS.asc";


/// The list of what is inside each archive, itself covered by [`SUMS`].
///
/// **Roadmap item 97.** Optional, and its absence is not a failure: releases before
/// v0.1.15 do not carry one, and a verifier that refused them would be refusing
/// files it can check perfectly well.
pub const CONTENTS: &str = crate::check::contents::CONTENTS;



/// What was found in one directory.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Found {
    /// The directory looked in.
    pub directory: PathBuf,
    /// Release archives, sorted by name so two runs agree.
    pub archives: Vec<PathBuf>,
    /// The hash list, if it is there.
    pub sums: Option<PathBuf>,
    /// The signature over the hash list, if it is there.
    pub signature: Option<PathBuf>,
    /// The list of what is inside each archive, if the release published one.
    pub contents: Option<PathBuf>,
}


impl Found {

    /// Whether this directory holds everything needed to verify offline.
    pub fn is_complete(&self) -> bool {
        !self.archives.is_empty() && self.sums.is_some() && self.signature.is_some()
    }



    /// Whether anything at all turned up.
    pub fn is_empty(&self) -> bool {
        self.archives.is_empty() && self.sums.is_none() && self.signature.is_none()
    }



    /// What is missing, in words, for a message to the user.
    pub fn missing(&self) -> Vec<&'static str> {
        let mut gaps = Vec::new();
        if self.archives.is_empty() {
            gaps.push("no release archive");
        }
        if self.sums.is_none() {
            gaps.push("no SHA256SUMS");
        }
        if self.signature.is_none() {
            gaps.push("no SHA256SUMS.asc");
        }
        gaps
    }

}


/// Whether a filename looks like one of this project's release archives.
///
/// A filename test, and therefore evidence of nothing. See the module note.
pub fn looks_like_archive(name: &str) -> bool {
    let lower = name.to_ascii_lowercase();
    lower.starts_with("veilvoice-") && ARCHIVES.iter().any(|end| lower.ends_with(end))
}



/// Look in one directory, one level deep.
pub fn look_in(directory: &Path) -> Found {
    let mut found = Found {
        directory: directory.to_path_buf(),
        archives: Vec::new(),
        sums: None,
        signature: None,
        contents: None,
    };
    let Ok(entries) = std::fs::read_dir(directory) else {
        return found;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_file() {
            continue;
        }
        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
            continue;
        };
        if name.eq_ignore_ascii_case(SUMS) {
            found.sums = Some(path);
        } else if name.eq_ignore_ascii_case(SUMS_SIG) {
            found.signature = Some(path);
        } else if name.eq_ignore_ascii_case(CONTENTS) {
            found.contents = Some(path);
        } else if looks_like_archive(name) {
            found.archives.push(path);
        }
    }
    // Sorted so two runs over the same directory report in the same order, and
    // so a listing shown to a user is stable between them.
    found.archives.sort();
    found
}



/// Every place worth looking, in order, without duplicates.
pub fn places(explicit: Option<&Path>) -> Vec<PathBuf> {
    let mut places: Vec<PathBuf> = Vec::new();
    let mut add = |path: Option<PathBuf>| {
        if let Some(path) = path {
            if !places.contains(&path) {
                places.push(path);
            }
        }
    };

    let here = std::env::current_dir().ok();
    // Where somebody who unpacked the archive and ran the verifier inside it
    // will be. `current_exe` can fail on a deleted or moved binary, which is
    // not worth an error -- it simply means one fewer place to look.
    let beside_the_program = std::env::current_exe()
        .ok()
        .and_then(|exe| exe.parent().map(Path::to_path_buf));

    add(explicit.map(Path::to_path_buf));
    add(here.clone());
    add(beside_the_program.clone());

    // F-107. One level up from each of those, which is where the download
    // actually is.
    //
    // Every archive tool unpacks `veilvoice-vX.Y.Z-linux-x86_64.tar.gz` into a
    // folder of that name, beside the archive. So somebody who downloads a
    // release, extracts it, opens the new folder and runs the program in it is
    // standing in a directory that holds the binaries and none of the things
    // they are checked against: `SHA256SUMS`, its signature and the archive are
    // all one level above. That is the single most likely way this program is
    // ever run, and it was the one arrangement that reported "no VeilVoice
    // release was found to check".
    //
    // One level only. Two would start reading directories that have nothing to
    // do with the download, and a verifier that goes wandering through a
    // stranger's filesystem looking for something to check is a worse thing
    // than one that asks for a directory.
    // The directory named on the command line gets the same treatment as the
    // one we are standing in. Somebody who types the extracted folder's name
    // means the same thing as somebody who walks into it.
    add(explicit.and_then(Path::parent).map(Path::to_path_buf));
    add(here
        .as_deref()
        .and_then(Path::parent)
        .map(Path::to_path_buf));
    add(beside_the_program
        .as_deref()
        .and_then(Path::parent)
        .map(Path::to_path_buf));

    for home in [
        std::env::var_os("USERPROFILE").map(PathBuf::from),
        std::env::var_os("HOME").map(PathBuf::from),
    ]
    .into_iter()
    .flatten()
    {
        add(Some(home.join("Downloads")));
        add(Some(home.join("Desktop")));
    }
    places
}



/// Look everywhere worth looking and return the first directory that holds a
/// complete, checkable set, or, failing that, everything that turned up.
///
/// "Complete" means an archive, a hash list and a signature in one place, which
/// is what an offline check needs. A directory with an archive and no hash list
/// is reported rather than used: it is exactly the situation where somebody
/// needs to be told what else to download, and silently reaching for a hash
/// list from a *different* directory would be checking one release against
/// another's list.
pub fn search(explicit: Option<&Path>) -> (Option<Found>, Vec<Found>) {
    let mut all = Vec::new();
    let mut complete = None;
    for place in places(explicit) {
        let found = look_in(&place);
        if found.is_empty() {
            continue;
        }
        if complete.is_none() && found.is_complete() {
            complete = Some(found.clone());
        }
        all.push(found);
    }
    (complete, all)
}


#[cfg(test)]
mod tests {
    use super::*;

    fn touch(dir: &Path, name: &str) -> PathBuf {
        let path = dir.join(name);
        std::fs::write(&path, b"x").unwrap();
        path
    }

    /// F-107. The download is one level up from the folder you extracted.
    ///
    /// Reproduces the arrangement every archive tool produces and every user
    /// therefore has: the archive, `SHA256SUMS` and its signature in one
    /// directory, and the unpacked folder beside them. Standing in that folder
    /// and asking for a check has to find the release, because it is the most
    /// likely way this program is ever run, and it was the one arrangement
    /// that answered "no VeilVoice release was found to check".
    #[test]
    fn a_release_one_level_up_from_the_extracted_folder_is_found() {
        let download = tempfile::tempdir().unwrap();
        touch(download.path(), "veilvoice-v0.1.15-linux-x86_64.tar.gz");
        touch(download.path(), SUMS);
        touch(download.path(), SUMS_SIG);

        // What `tar` leaves behind: a folder named after the archive, holding
        // the binaries and none of the things they are checked against.
        let extracted = download.path().join("veilvoice-v0.1.15-linux-x86_64");
        std::fs::create_dir(&extracted).unwrap();
        touch(&extracted, "veilvoice");
        touch(&extracted, "veilvoice-gui");

        // Asked about the extracted folder, which is where the user is.
        let places = places(Some(&extracted));
        assert!(
            places.iter().any(|p| p == download.path()),
            "the download directory {:?} is not searched from inside {:?}; \
             searched: {places:?}",
            download.path(),
            extracted
        );

        let (complete, _) = search(Some(&extracted));
        let complete = complete.expect(
            "a complete set sits one level up, and standing in the extracted \
             folder is how this program is normally run",
        );
        assert_eq!(complete.directory, download.path());
    }

    /// The search stops one level up, and does not go wandering.
    ///
    /// A verifier that climbed until it found something to check would read
    /// directories that have nothing to do with the download. One level is the
    /// extract-into-a-subfolder case; anything past it is somebody else's
    /// filesystem.
    #[test]
    fn the_search_does_not_climb_past_one_level() {
        let root = tempfile::tempdir().unwrap();
        touch(root.path(), "veilvoice-v0.1.15-linux-x86_64.tar.gz");
        touch(root.path(), SUMS);
        touch(root.path(), SUMS_SIG);

        let deep = root.path().join("one").join("two");
        std::fs::create_dir_all(&deep).unwrap();

        let places = places(Some(&deep));
        assert!(
            !places.iter().any(|p| p == root.path()),
            "the search reached {:?} from two levels down; searched: {places:?}",
            root.path()
        );
    }

    #[test]
    fn the_published_archive_names_are_recognised() {
        for name in [
            "veilvoice-v0.1.12-windows-x86_64.zip",
            "veilvoice-v0.1.12-linux-x86_64.tar.gz",
            "veilvoice-v0.1.11-openbsd-x86_64.tar.gz",
            "VeilVoice-v0.1.12-macos-aarch64.tar.xz",
        ] {
            assert!(looks_like_archive(name), "{name}");
        }
    }

    #[test]
    fn anything_else_is_not_a_candidate() {
        for name in [
            "",
            "notveilvoice.zip",
            "veilvoice.txt",
            "veilvoice-v0.1.12.exe",
            "SHA256SUMS",
            "readme-veilvoice-v1.zip",
        ] {
            assert!(!looks_like_archive(name), "{name}");
        }
    }

    #[test]
    fn a_complete_directory_is_recognised() {
        let dir = tempfile::tempdir().unwrap();
        touch(dir.path(), "veilvoice-v0.1.12-windows-x86_64.zip");
        touch(dir.path(), SUMS);
        touch(dir.path(), SUMS_SIG);
        let found = look_in(dir.path());
        assert!(found.is_complete());
        assert!(!found.is_empty());
        assert!(found.missing().is_empty());
        assert_eq!(found.archives.len(), 1);
    }

    /// The case somebody actually hits: the archive downloaded, the hash list
    /// forgotten. They need to be told which, not left with a failure.
    #[test]
    fn a_directory_missing_the_hash_list_says_which_part_is_missing() {
        let dir = tempfile::tempdir().unwrap();
        touch(dir.path(), "veilvoice-v0.1.12-windows-x86_64.zip");
        let found = look_in(dir.path());
        assert!(!found.is_complete());
        assert!(!found.is_empty());
        let missing = found.missing();
        assert!(missing.contains(&"no SHA256SUMS"), "{missing:?}");
        assert!(missing.contains(&"no SHA256SUMS.asc"), "{missing:?}");
        assert!(!missing.contains(&"no release archive"), "{missing:?}");
    }

    #[test]
    fn several_archives_come_back_in_a_stable_order() {
        let dir = tempfile::tempdir().unwrap();
        touch(dir.path(), "veilvoice-v0.1.12-windows-x86_64.zip");
        touch(dir.path(), "veilvoice-v0.1.12-linux-x86_64.tar.gz");
        touch(dir.path(), "veilvoice-v0.1.12-macos-aarch64.tar.gz");
        let first = look_in(dir.path());
        let second = look_in(dir.path());
        assert_eq!(first.archives.len(), 3);
        assert_eq!(first.archives, second.archives);
    }

    #[test]
    fn an_empty_or_missing_directory_finds_nothing_rather_than_failing() {
        let dir = tempfile::tempdir().unwrap();
        assert!(look_in(dir.path()).is_empty());
        assert!(look_in(&dir.path().join("not-here")).is_empty());
    }

    /// A directory of unrelated files must not produce candidates.
    #[test]
    fn unrelated_files_are_not_candidates() {
        let dir = tempfile::tempdir().unwrap();
        touch(dir.path(), "holiday.zip");
        touch(dir.path(), "notes.txt");
        assert!(look_in(dir.path()).is_empty());
    }

    /// A directory is only ever searched one level deep. A recursive walk of a
    /// home directory is slow, surprising, and lets a verifier wander into
    /// places nobody asked about.
    #[test]
    fn the_search_does_not_descend() {
        let dir = tempfile::tempdir().unwrap();
        let deeper = dir.path().join("deeper");
        std::fs::create_dir_all(&deeper).unwrap();
        touch(&deeper, "veilvoice-v0.1.12-windows-x86_64.zip");
        assert!(look_in(dir.path()).is_empty());
    }

    #[test]
    fn the_places_list_starts_where_it_was_told_and_has_no_duplicates() {
        let dir = tempfile::tempdir().unwrap();
        let places = places(Some(dir.path()));
        assert_eq!(places[0], dir.path());
        let mut sorted = places.clone();
        sorted.sort();
        sorted.dedup();
        assert_eq!(sorted.len(), places.len(), "a place is listed twice");
    }

    /// The whole point of the search: it must find a complete set without
    /// being told where anything is.
    #[test]
    fn a_complete_set_is_found_when_it_is_pointed_at() {
        let dir = tempfile::tempdir().unwrap();
        touch(dir.path(), "veilvoice-v0.1.12-windows-x86_64.zip");
        touch(dir.path(), SUMS);
        touch(dir.path(), SUMS_SIG);
        let (complete, all) = search(Some(dir.path()));
        let complete = complete.expect("the set should have been found");
        assert_eq!(complete.directory, dir.path());
        assert!(!all.is_empty());
    }

    /// An incomplete directory must be reported rather than silently paired
    /// with a hash list from somewhere else -- which would be checking one
    /// release against another's list.
    #[test]
    fn an_incomplete_directory_is_reported_and_not_completed_from_elsewhere() {
        let dir = tempfile::tempdir().unwrap();
        touch(dir.path(), "veilvoice-v0.1.12-windows-x86_64.zip");
        let (complete, all) = search(Some(dir.path()));
        let ours = all
            .iter()
            .find(|found| found.directory == dir.path())
            .expect("the directory should be reported");
        assert!(!ours.is_complete());
        // `complete` may be Some from a real Downloads directory on the machine
        // this runs on, but it must never be *this* directory.
        if let Some(complete) = complete {
            assert_ne!(complete.directory, dir.path());
        }
    }

    /// Searching the real machine must not panic whatever is on it.
    #[test]
    fn searching_this_machine_does_not_panic() {
        let (_, all) = search(None);
        for found in all {
            assert!(found.directory.is_absolute() || found.directory.exists());
        }
    }
}