diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74d756a..63104c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: - uses: actions/checkout@v4 - name: Build run: cargo build + - name: Test + run: cargo test test-windows: runs-on: windows-latest @@ -29,3 +31,5 @@ jobs: - uses: actions/checkout@v4 - name: Build run: cargo build + - name: Test + run: cargo test diff --git a/CHANGELOG.md b/CHANGELOG.md index 8299bb5..d771563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- Show an actionable hint when `bootit` is installed to a user-local directory (e.g. via `cargo install`), where `sudo bootit` cannot find the binary. + ## [0.1.0] - 2026-02-05 ### Added diff --git a/README.md b/README.md index b1fc1f5..70f7b1c 100644 --- a/README.md +++ b/README.md @@ -37,5 +37,34 @@ it windows # No sudo required! cargo install bootit ``` +## Troubleshooting + +### `sudo: bootit: command not found` after `cargo install` + +`cargo install` puts `bootit` in `~/.cargo/bin`, which is on your user PATH +but not on sudo's `secure_path` (the list of directories sudo searches when +you run `sudo `). As a result, the first command fails and the +second one cannot even find the binary: + +```bash +$ bootit scan +Error: This program must be run as root (try: sudo bootit ...) + +$ sudo bootit scan +sudo: bootit: command not found +``` + +Run it with the full path instead: + +```bash +sudo "$(which bootit)" scan +``` + +or link it into a system directory once so plain `sudo bootit` works: + +```bash +sudo ln -s "$(which bootit)" /usr/local/bin/bootit +``` + ## Contributing Contributions are welcome! Feel free to open issues or submit pull requests on the GitHub repository diff --git a/src/util.rs b/src/util.rs index 98e4cf1..bfea1dd 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use miette::miette; use which::which; @@ -8,8 +8,15 @@ pub fn check_privileges() -> miette::Result<()> { { let euid = unsafe { libc::geteuid() }; if euid != 0 { + let hint = sudo_hint(); + if hint.is_empty() { + return Err(miette!( + "This program must be run as root (try: sudo bootit ...)" + )); + } return Err(miette!( - "This program must be run as root (try: sudo bootit ...)" + help = hint, + "This program must be run as root (try: sudo bootit ...)", )); } } @@ -26,6 +33,44 @@ pub fn check_privileges() -> miette::Result<()> { Ok(()) } +/// Builds a hint for the non-root error message. +/// +/// `cargo install` places binaries in `~/.cargo/bin`, which is on the user's +/// PATH but not on sudo's `secure_path`. In that case `sudo bootit ...` fails +/// with "command not found" before the program even runs, so the generic +/// "try: sudo bootit ..." advice in the error message is misleading. +/// +/// Returns an empty string when the binary lives in a system directory that +/// sudo can already find, so the error message stays short. +#[cfg(unix)] +fn sudo_hint() -> String { + let exe = std::env::current_exe().ok(); + let home = std::env::var_os("HOME").map(PathBuf::from); + match (exe, home) { + (Some(exe), Some(home)) => sudo_hint_for(&exe, &home), + _ => String::new(), + } +} + +#[cfg(unix)] +fn sudo_hint_for(exe: &Path, home: &Path) -> String { + let installed_to_user_dir = + exe.starts_with(home) || exe.to_string_lossy().contains(".cargo/bin"); + if !installed_to_user_dir { + return String::new(); + } + + format!( + "bootit is installed in a user-local directory ({}) which sudo does not search, so \ + `sudo bootit ...` will fail with \"command not found\".\n\ + Run it with the full path:\n sudo {} ...\n\ + or link it into a system directory once:\n sudo ln -s {} /usr/local/bin/bootit", + exe.display(), + exe.display(), + exe.display(), + ) +} + #[cfg(windows)] fn is_elevated() -> bool { use std::ptr::null_mut; @@ -67,3 +112,36 @@ pub fn find_it() -> miette::Result { Err(miette!("Could not find 'it' in PATH. Please install it.")) } } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn no_hint_when_installed_in_system_dir() { + let hint = sudo_hint_for(Path::new("/usr/local/bin/bootit"), Path::new("/home/user")); + assert_eq!(hint, ""); + } + + #[test] + fn hint_for_cargo_install_under_home() { + let hint = sudo_hint_for( + Path::new("/home/user/.cargo/bin/bootit"), + Path::new("/home/user"), + ); + assert!(hint.contains("/home/user/.cargo/bin/bootit")); + assert!(hint.contains("sudo /home/user/.cargo/bin/bootit ...")); + assert!(hint.contains("ln -s")); + } + + #[test] + fn hint_for_cargo_install_under_other_home() { + // Installed via cargo but for a different user than the one running it. + let hint = sudo_hint_for( + Path::new("/home/other/.cargo/bin/bootit"), + Path::new("/home/user"), + ); + assert!(hint.contains("ln -s")); + } +}