crates/veilvoice-verify/src/deps.rs

what this file is for · veilvoice-verify · 652 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 this machine needs before it can build VeilVoice, and who ships it.
//!
//! # The rule, which predates this module
//!
//! Installing a build dependency means running somebody else's package
//! manager, as root, on somebody else's machine. The companion setup already
//! makes that trade and it gets the same rule here: **detect what is there,
//! say what each thing is and who ships it, and install only on an explicit
//! yes.** Never silently, never ticked by default, and never as a side effect
//! of asking a question.
//!
//! What it will not do is add a network client to VeilVoice. It shells out to
//! the tool the platform already has, exactly as the verifier does for
//! downloads, so the claim that this project's dependency graph contains no
//! HTTP client is unchanged and still checkable with `cargo tree`.
//!
//! # Why there is a table at all
//!
//! Almost all of VeilVoice is pure Rust and needs nothing but a compiler. The
//! exceptions are real and they are the reason a build fails on a fresh
//! machine with a message about a missing header rather than about a missing
//! package:
//!
//! * **Linux**: `cpal` reaches ALSA through `alsa-sys`, which is a `-sys`
//!   crate: it compiles against ALSA's C headers and asks `pkg-config` where
//!   they are. Neither ships with a base install of most distributions.
//! * **macOS**: CoreAudio comes from Apple's SDK, which arrives with the
//!   Xcode command line tools. Apple's licence does not permit redistributing
//!   it, which is also why this tool cannot build a macOS binary anywhere else.
//! * **Windows**: the MSVC toolchain needs a linker, which comes with the
//!   Visual Studio build tools.
//!
//! Everything else -- the engine, the container format, the app lock, the
//! website generators -- has no system dependency at all.
//!
//! # What "detected" means, and what it does not
//!
//! [`Need::detect`] answers from what is on `PATH` and from `pkg-config`. That
//! is a real answer for a linker or a compiler and a *good* answer for a
//! library, but it is not a build. A machine can pass every probe here and
//! still fail to compile, and this module says so rather than promising
//! otherwise: the build in roadmap item 55 is the only thing that actually knows.
//!
//! # In plain words
//!
//! Before you can build this program yourself, your computer needs a few
//! pieces: a Rust compiler, and -- on Linux and macOS -- one or two things
//! from your operating system that VeilVoice's sound handling is built on top
//! of. This works out which of those you already have and which you do not,
//! tells you exactly what each one is and who makes it, and offers to install
//! the missing ones **only if you say yes**.
//!
//! It will never install anything on its own, and it does not download
//! anything itself -- it asks the software installer your system already came
//! with, so you can see exactly what is being run.

use std::process::Command;


/// Whether a dependency is here, missing, or unanswerable.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Presence {
    /// Found, with whatever the probe could say about it.
    Present(String),
    /// Looked for and not found.
    Missing,
    /// Not needed on this operating system.
    NotOnThisPlatform,
    /// The probe itself could not run.
    ///
    /// Deliberately not [`Presence::Missing`]. "I looked and it is not there"
    /// and "I could not look" lead to different actions, and reporting the
    /// second as the first is how a tool offers to install something that is
    /// already installed.
    Unknown(String),
}


impl Presence {

    /// Whether this counts as satisfied.
    pub fn is_satisfied(&self) -> bool {
        matches!(self, Self::Present(_) | Self::NotOnThisPlatform)
    }



    /// One line, for a report.
    pub fn describe(&self) -> String {
        match self {
            Self::Present(detail) if detail.is_empty() => "found".to_string(),
            Self::Present(detail) => format!("found -- {detail}"),
            Self::Missing => "MISSING".to_string(),
            Self::NotOnThisPlatform => "not needed on this platform".to_string(),
            Self::Unknown(why) => format!("could not tell -- {why}"),
        }
    }

}


/// What could be done about a missing dependency, on this machine.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Route {
    /// A command that would install it. **Never run without an explicit yes.**
    Run {
        /// The program.
        program: String,
        /// Its arguments.
        args: Vec<String>,
        /// Who ships what this installs.
        vendor: &'static str,
    },
    /// Something a person has to do themselves, with the reason.
    ///
    /// Downloading Apple's or Microsoft's tooling means accepting a licence,
    /// and a licence has to be accepted by the person it binds rather than by
    /// a program acting on their behalf.
    Yourself(String),
    /// Not needed here.
    NotOnThisPlatform,
    /// Nothing is known. Said rather than guessed.
    Unknown(String),
}


impl Route {

    /// The command line, for showing before asking.
    ///
    /// Shown in full, every time, before the question. A yes to a command
    /// nobody read is not consent to anything.
    pub fn command_line(&self) -> Option<String> {
        match self {
            Self::Run { program, args, .. } => Some(format!("{program} {}", args.join(" "))),
            _ => None,
        }
    }

}


/// One thing a build needs.
#[derive(Clone, Copy, Debug)]
pub struct Need {
    /// Stable identifier.
    pub key: &'static str,
    /// What it is called.
    pub name: &'static str,
    /// What it is, for somebody who has not met it.
    pub what: &'static str,
    /// Which part of VeilVoice needs it, and why.
    pub why: &'static str,
    /// Whether the build fails without it, or only a feature.
    pub required: bool,
}



/// Everything a build can need, on every platform.
///
/// Kept whole rather than compiled down to this platform's subset:
/// [`for_this_platform`] filters, and somebody reading the source should be
/// able to see what a build needs elsewhere without owning that machine.
pub const ALL: &[Need] = &[
    Need {
        key: "rust",
        name: "The Rust toolchain",
        what: "The compiler and `cargo`, at the version pinned in \
               `rust-toolchain.toml`.",
        why: "Everything. The pinned version is not a preference: a fixed \
              compiler is a prerequisite for a build that comes out the same \
              bytes twice.",
        required: true,
    },

    Need {
        key: "cc",
        name: "A C compiler and linker",
        what: "On Linux, `cc` from GCC or Clang. On Windows, the Visual Studio \
               build tools. On macOS, the Xcode command line tools.",
        why: "Rust does not ship a linker. Every Rust program on every platform \
              needs the system one.",
        required: true,
    },
    Need {
        key: "pkg-config",
        name: "pkg-config",
        what: "The tool a build script asks where a system library's headers \
               are.",
        why: "`alsa-sys` uses it to find ALSA. Without it the build stops with \
              a message about ALSA rather than about pkg-config, which is why \
              it is listed separately.",
        required: true,
    },
    Need {
        key: "alsa",
        name: "ALSA development headers",
        what: "The header files for Linux's sound layer -- `libasound2-dev` on \
               Debian and Ubuntu, `alsa-lib-devel` on Fedora, `alsa-lib` on \
               Arch.",
        why: "Live capture and playback go through `cpal`, which reaches ALSA \
              through a `-sys` crate that compiles against these headers. \
              Everything that is not live mode builds without them.",
        required: false,
    },
];

impl Need {

    /// Look for it. Changes nothing.
    pub fn detect(&self) -> Presence {
        match self.key {
            "rust" => match program_version("cargo", &["--version"]) {
                Some(line) => Presence::Present(line),
                None => Presence::Missing,
            },
            "cc" => detect_linker(),
            "pkg-config" => {
                if !cfg!(target_os = "linux") {
                    return Presence::NotOnThisPlatform;
                }
                match program_version("pkg-config", &["--version"]) {
                    Some(line) => Presence::Present(format!("version {line}")),
                    None => Presence::Missing,
                }
            }
            "alsa" => detect_alsa(),
            other => Presence::Unknown(format!("no probe is written for {other}")),
        }
    }



    /// What could be done about it here.
    pub fn route(&self) -> Route {
        match self.key {
            // rustup's own installer, and not run by this program. It writes to
            // the home directory, alters the shell profile and downloads a
            // toolchain, and somebody should type that themselves knowing all
            // three.
            "rust" => Route::Yourself(
                "Install it from https://rustup.rs, which is how the Rust project \
                 ships it. This program does not run that installer for you: it \
                 downloads a compiler, writes to your home directory and edits \
                 your shell profile, and all three are yours to agree to."
                    .to_string(),
            ),

            "cc" => {
                if cfg!(target_os = "linux") {
                    linux_package("build-essential", "gcc", "base-devel")
                } else if cfg!(target_os = "macos") {
                    Route::Run {
                        program: "xcode-select".to_string(),
                        args: vec!["--install".to_string()],
                        vendor: "Apple",
                    }
                } else if cfg!(windows) {
                    Route::Yourself(
                        "Install the Visual Studio Build Tools from Microsoft, with \
                         the \"Desktop development with C++\" workload. It is \
                         several gigabytes and it carries Microsoft's licence, which \
                         is yours to accept rather than this program's."
                            .to_string(),
                    )
                } else {
                    Route::Unknown(
                        "no route is written for this operating system; install a C \
                         compiler the way you install anything else here"
                            .to_string(),
                    )
                }
            }

            "pkg-config" => {
                if cfg!(target_os = "linux") {
                    linux_package("pkg-config", "pkgconf-pkg-config", "pkgconf")
                } else {
                    Route::NotOnThisPlatform
                }
            }

            "alsa" => {
                if cfg!(target_os = "linux") {
                    linux_package("libasound2-dev", "alsa-lib-devel", "alsa-lib")
                } else {
                    Route::NotOnThisPlatform
                }
            }

            other => Route::Unknown(format!("no route is written for {other}")),
        }
    }

}


/// What this platform needs, in the order to report them.
pub fn for_this_platform() -> Vec<&'static Need> {
    ALL.iter()
        .filter(|need| need.detect() != Presence::NotOnThisPlatform)
        .collect()
}



/// A package under the three names the major families give it.
///
/// The package manager on `PATH` decides which name is used. A machine with
/// none of them gets [`Route::Yourself`] with all three names in it, because a
/// reader on a distribution nobody here has heard of can still translate.
fn linux_package(debian: &'static str, fedora: &'static str, arch: &'static str) -> Route {
    for (manager, vendor, args) in [
        (
            "apt-get",
            "Debian, Ubuntu and derivatives",
            vec!["install", debian],
        ),
        ("dnf", "Fedora and Red Hat", vec!["install", fedora]),
        ("pacman", "Arch and derivatives", vec!["-S", arch]),
        ("zypper", "openSUSE", vec!["install", fedora]),
        ("apk", "Alpine", vec!["add", debian]),
    ] {
        if which(manager).is_some() {
            return Route::Run {
                program: "sudo".to_string(),
                args: std::iter::once(manager.to_string())
                    .chain(args.into_iter().map(str::to_string))
                    .collect(),
                vendor,
            };
        }
    }
    Route::Yourself(format!(
        "No package manager this program recognises is on PATH. The package is \
         called {debian} on Debian and Ubuntu, {fedora} on Fedora, and {arch} on \
         Arch; install the equivalent for your system."
    ))
}



/// Whether a program is on `PATH`, and where.
///
/// Asks the system's own resolver rather than walking `PATH` here: the rules
/// differ per platform (`PATHEXT` on Windows, for one) and reimplementing them
/// is how a probe reports something as missing that is sitting right there.
fn which(program: &str) -> Option<String> {
    // The finder itself is named absolutely. Asking `PATH` where something on
    // `PATH` lives, by way of a program found on `PATH`, is a circle with an
    // obvious way in.
    let output = if cfg!(windows) {
        let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
        Command::new(format!(r"{root}\System32\where.exe"))
            .arg(program)
            .output()
            .ok()?
    } else {
        // `command -v` is a shell builtin, so it needs a shell.
        Command::new("/bin/sh")
            .args(["-c", &format!("command -v {program}")])
            .output()
            .ok()?
    };
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8_lossy(&output.stdout);
    text.lines().next().map(|line| line.trim().to_string())
}



/// The first line a program prints when asked its version.
fn program_version(program: &str, args: &[&str]) -> Option<String> {
    let output = Command::new(program).args(args).output().ok()?;
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8_lossy(&output.stdout);
    let line = text.lines().next()?.trim().to_string();
    Some(line)
}



/// A linker, by whichever name this platform calls it.
fn detect_linker() -> Presence {
    if cfg!(windows) {
        // F-68. This looked for `link` on PATH and reported whatever it found.
        // On the machine it was first run on that was Git for Windows'
        // `usr/bin/link.exe` -- GNU coreutils' hardlink utility, which shares a
        // name with Microsoft's linker and has nothing whatever to do with
        // building Rust. The report said the linker was present. A build on
        // that machine would have stopped with a linker error.
        //
        // There is no honest probe here. `link.exe` is only on PATH inside a
        // Developer Command Prompt, cargo finds MSVC through the registry
        // instead, and any `link` that *is* on PATH is more likely to be
        // something else. So this says it cannot tell, which is true, and the
        // build says the rest.
        return Presence::Unknown(
            "Rust finds Microsoft's linker through the registry rather than PATH, \
             so there is nothing here to look at. A `link.exe` on PATH is usually \
             something else -- Git for Windows ships one. Only a build can say."
                .to_string(),
        );
    }
    for name in ["cc", "clang", "gcc"] {
        if let Some(path) = which(name) {
            return Presence::Present(path);
        }
    }
    Presence::Missing
}



/// ALSA's headers, through the tool the build script itself uses.
fn detect_alsa() -> Presence {
    if !cfg!(target_os = "linux") {
        return Presence::NotOnThisPlatform;
    }
    if which("pkg-config").is_none() {
        return Presence::Unknown(
            "pkg-config is not installed, so there is no way to ask where ALSA is".to_string(),
        );
    }
    // Exactly what `alsa-sys`'s build script asks, so the answer here and the
    // answer during a build come from the same source.
    match Command::new("pkg-config")
        .args(["--modversion", "alsa"])
        .output()
    {
        Ok(output) if output.status.success() => {
            let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
            Presence::Present(format!("ALSA {version}"))
        }
        Ok(_) => Presence::Missing,
        Err(error) => Presence::Unknown(format!("pkg-config would not run: {error}")),
    }
}



/// What is missing, split by whether a build stops without it.
///
/// Returned rather than printed so the caller decides how loudly to say it --
/// and so the two are never conflated. A missing optional dependency means a
/// build that succeeds with less in it, and reporting that as a failure would
/// send somebody installing things they do not need.
pub fn missing() -> (Vec<&'static Need>, Vec<&'static Need>) {
    let mut required = Vec::new();
    let mut optional = Vec::new();
    for need in for_this_platform() {
        // `Unknown` is not `Missing`. A probe that could not run is not
        // evidence of absence, and offering to install over the top of
        // something already there is the mistake that distinction prevents.
        if let Presence::Missing = need.detect() {
            if need.required {
                required.push(need);
            } else {
                optional.push(need);
            }
        }
    }
    (required, optional)
}


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

    /// The one with this identifier. Here rather than in the module above,
    /// because the tests are the only thing that looks a need up by name --
    /// everything else walks the whole list.
    fn by_key(key: &str) -> Option<&'static Need> {
        ALL.iter().find(|need| need.key == key)
    }

    #[test]
    fn every_entry_is_complete_and_uniquely_keyed() {
        let mut keys: Vec<&str> = ALL.iter().map(|need| need.key).collect();
        let count = keys.len();
        keys.sort_unstable();
        keys.dedup();
        assert_eq!(keys.len(), count, "two needs share a key");

        for need in ALL {
            assert!(!need.name.is_empty(), "{}", need.key);
            assert!(!need.what.is_empty(), "{}", need.key);
            assert!(!need.why.is_empty(), "{}", need.key);
            assert_eq!(by_key(need.key).map(|n| n.key), Some(need.key));
        }
        assert!(by_key("nothing-like-this").is_none());
    }

    /// Every need says *why* VeilVoice wants it. A list of packages with no
    /// reasons is a list somebody installs without reading, which is the exact
    /// habit this table exists to avoid feeding.
    #[test]
    fn every_need_explains_itself_rather_than_just_naming_a_package() {
        for need in ALL {
            assert!(
                need.why.len() > 40,
                "{}: the reason is too short to be a reason",
                need.key
            );
            assert!(
                need.what.len() > 30,
                "{}: say what it actually is",
                need.key
            );
        }
    }

    /// Detection must not panic, hang, or change anything, on any machine.
    #[test]
    fn looking_is_safe_wherever_this_runs() {
        for need in ALL {
            let presence = need.detect();
            assert!(!presence.describe().is_empty(), "{}", need.key);
            let route = need.route();
            match &route {
                Route::Run { program, args, .. } => {
                    assert!(!program.is_empty());
                    assert!(!args.is_empty());
                    assert!(route.command_line().is_some());
                }
                Route::Yourself(words) => assert!(words.len() > 20, "{}", need.key),
                Route::NotOnThisPlatform | Route::Unknown(_) => {}
            }
        }
    }

    /// A compiler is certainly here: this test is being compiled by one.
    #[test]
    fn the_toolchain_is_found_because_it_is_running_this_test() {
        let rust = by_key("rust").unwrap();
        match rust.detect() {
            Presence::Present(line) => assert!(line.contains("cargo"), "{line}"),
            // `cargo test` can be invoked where `cargo` itself is not on PATH.
            // That is a real answer and not a reason to fail somebody's build.
            other => panic!("cargo should be findable from a cargo test: {other:?}"),
        }
    }

    /// F-68. A probe must not answer from a program that merely shares a name
    /// with the one it is looking for.
    ///
    /// This reported "found" on a Windows machine because Git for Windows ships
    /// `usr/bin/link.exe`, GNU coreutils' hardlink tool. A build on that machine
    /// would have stopped with a linker error after the dependency check had
    /// said everything was fine.
    #[test]
    fn the_windows_linker_is_not_looked_for_by_name_on_path() {
        // A source-reading test, so the line endings have to be settled first.
        // F-72: these searched for "\n}\n" and passed on every machine
        // whose checkout uses LF. GitHub's Windows runners default to
        // core.autocrlf=true, so the file arrives with CRLF, the pattern
        // matches nothing, and three tests failed there and nowhere else --
        // including on the developer machine that had just run them.
        // Normalised here as well as pinned in .gitattributes: a test that
        // depends on a git setting is a test somebody will trip over.
        let source = include_str!("deps.rs").replace("\r\n", "\n");
        let start = source.find("fn detect_linker()").expect("the function");
        let end = source[start..].find("\n}\n").expect("its end") + start;
        let body = &source[start..end];
        assert!(
            !body.contains(concat!("which(", '"', "link", '"', ")")),
            "a `link` on PATH is usually not Microsoft's linker"
        );

        if cfg!(windows) {
            let presence = by_key("cc").unwrap().detect();
            assert!(
                matches!(presence, Presence::Unknown(_)),
                "on Windows only a build can decide: {presence:?}"
            );
            assert!(
                presence.describe().contains("Only a build can say"),
                "{}",
                presence.describe()
            );
        }
    }

    /// Nothing in this module runs a package manager. The routes are values;
    /// something else has to decide to run one, after asking.
    #[test]
    fn no_route_is_ever_taken_by_this_module() {
        let source = include_str!("deps.rs").replace("\r\n", "\n");
        let body = source.split("#[cfg(test)]").next().unwrap();
        for forbidden in [".status()", ".spawn()"] {
            assert!(
                !body.contains(forbidden),
                "this module only looks; {forbidden} runs something"
            );
        }
        // `.output()` is used, and only to ask a program its version. Every
        // use is named here so a new one has to be argued for.
        assert_eq!(
            body.matches(".output()").count(),
            4,
            "a new subprocess appeared in a module whose job is to look"
        );
    }

    /// An unanswerable probe must never be reported as an absence.
    #[test]
    fn could_not_tell_is_not_the_same_as_not_there() {
        assert!(!Presence::Missing.is_satisfied());
        assert!(!Presence::Unknown("no probe".into()).is_satisfied());
        assert!(Presence::NotOnThisPlatform.is_satisfied());
        assert!(Presence::Present(String::new()).is_satisfied());

        assert!(Presence::Unknown("x".into())
            .describe()
            .contains("could not tell"));
        assert_eq!(Presence::Missing.describe(), "MISSING");

        // And the split that acts on it: only a definite Missing counts.
        let (required, optional) = missing();
        for need in required.iter().chain(optional.iter()) {
            assert_eq!(need.detect(), Presence::Missing, "{}", need.key);
        }
    }

    /// Optional and required are kept apart, or somebody installs ALSA headers
    /// on a machine that will never run live mode.
    #[test]
    fn a_missing_optional_dependency_is_not_a_failed_build() {
        let alsa = by_key("alsa").unwrap();
        assert!(!alsa.required, "everything but live mode builds without it");
        assert!(alsa.why.contains("not live mode"), "{}", alsa.why);

        for key in ["rust", "cc"] {
            assert!(by_key(key).unwrap().required, "{key}");
        }
    }

    /// The Linux package name is given for all three families whatever happens,
    /// so a reader on a fourth can translate.
    #[test]
    fn a_machine_with_no_known_package_manager_still_gets_the_names() {
        // Exercised directly rather than through `route`, which depends on what
        // is installed on the machine running the test.
        let route = linux_package("libasound2-dev", "alsa-lib-devel", "alsa-lib");
        match route {
            Route::Run {
                program,
                args,
                vendor,
            } => {
                // A manager was found. It must be run through `sudo` and name
                // the package, and it must say whose packaging it is.
                assert_eq!(program, "sudo");
                assert!(args.len() >= 2, "{args:?}");
                assert!(!vendor.is_empty());
            }
            Route::Yourself(words) => {
                for name in ["libasound2-dev", "alsa-lib-devel", "alsa-lib"] {
                    assert!(words.contains(name), "{words}");
                }
            }
            other => panic!("a Linux package should not be {other:?}"),
        }
    }

    /// Nothing here downloads anything itself.
    #[test]
    fn nothing_in_this_module_speaks_http() {
        let source = include_str!("deps.rs").replace("\r\n", "\n");
        for word in ["http://", "https://reqwest", "TcpStream"] {
            assert!(
                !source.split("//!").last().unwrap_or("").contains(word),
                "{word} in a module that is supposed to shell out"
            );
        }
    }
}