diff --git a/CHANGELOG.md b/CHANGELOG.md index cd5059bc..f6a52e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ # NH Changelog +## Unreleased + +### Added + +- Nh now checks if the current Nix implementation has necessary experimental + features enabled. In mainline Nix (CppNix, etc.) we check for `nix-command` + and `flakes` being set. In Lix, we also use `repl-flake` as it is still + provided as an experimental feature. + +- Nh will now check if you are using the latest stable, or "recommended," + version of Nix (or Lix.) This check has been placed to make it clear we do not + support legacy/vulnerable versions of Nix, and encourage users to update if + they have not yet done so. + ## 4.0.3 ### Added diff --git a/package.nix b/package.nix index ffd9ff47..d38671b3 100644 --- a/package.nix +++ b/package.nix @@ -39,11 +39,12 @@ rustPlatform.buildRustPackage { buildInputs = lib.optionals stdenv.isDarwin [ darwin.apple_sdk.frameworks.SystemConfiguration ]; - preFixup = '' + postInstall = '' mkdir completions - $out/bin/nh completions bash > completions/nh.bash - $out/bin/nh completions zsh > completions/nh.zsh - $out/bin/nh completions fish > completions/nh.fish + + for shell in bash zsh fish; do + NH_NO_CHECKS=1 $out/bin/nh completions $shell > completions/nh.$shell + done installShellCompletion completions/* ''; @@ -55,9 +56,7 @@ rustPlatform.buildRustPackage { cargoLock.lockFile = ./Cargo.lock; - env = { - NH_REV = rev; - }; + env.NH_REV = rev; meta = { description = "Yet another nix cli helper"; @@ -66,6 +65,7 @@ rustPlatform.buildRustPackage { mainProgram = "nh"; maintainers = with lib.maintainers; [ drupol + NotAShelf viperML ]; }; diff --git a/src/checks.rs b/src/checks.rs new file mode 100644 index 00000000..bf805f06 --- /dev/null +++ b/src/checks.rs @@ -0,0 +1,154 @@ +use std::{cmp::Ordering, env}; + +use color_eyre::{eyre, Result}; +use semver::Version; +use tracing::warn; + +use crate::util; + +/// Verifies if the installed Nix version meets requirements +/// +/// # Returns +/// +/// * `Result<()>` - Ok if version requirements are met, error otherwise +pub fn check_nix_version() -> Result<()> { + if env::var("NH_NO_CHECKS").is_ok() { + return Ok(()); + } + + let version = util::get_nix_version()?; + let is_lix_binary = util::is_lix()?; + + // XXX: Both Nix and Lix follow semantic versioning (semver). Update the + // versions below once latest stable for either of those packages change. + // TODO: Set up a CI to automatically update those in the future. + const MIN_LIX_VERSION: &str = "2.91.1"; + const MIN_NIX_VERSION: &str = "2.24.14"; + + // Minimum supported versions. Those should generally correspond to + // latest package versions in the stable branch. + // + // Q: Why are you doing this? + // A: First of all to make sure we do not make baseless assumptions + // about the user's system; we should only work around APIs that we + // are fully aware of, and not try to work around every edge case. + // Also, nh should be responsible for nudging the user to use the + // relevant versions of the software it wraps, so that we do not have + // to try and support too many versions. NixOS stable and unstable + // will ALWAYS be supported, but outdated versions will not. If your + // Nix fork uses a different versioning scheme, please open an issue. + let min_version = if is_lix_binary { + MIN_LIX_VERSION + } else { + MIN_NIX_VERSION + }; + + let current = Version::parse(&version)?; + let required = Version::parse(min_version)?; + + match current.cmp(&required) { + Ordering::Less => { + let binary_name = if is_lix_binary { "Lix" } else { "Nix" }; + warn!( + "Warning: {} version {} is older than the recommended minimum version {}. You may encounter issues.", + binary_name, + version, + min_version + ); + Ok(()) + } + _ => Ok(()), + } +} + +/// Verifies if the required experimental features are enabled +/// +/// # Returns +/// +/// * `Result<()>` - Ok if all required features are enabled, error otherwise +pub fn check_nix_features() -> Result<()> { + if env::var("NH_NO_CHECKS").is_ok() { + return Ok(()); + } + + let mut required_features = vec!["nix-command", "flakes"]; + + // Lix still uses repl-flake, which is removed in the latest version of Nix. + if util::is_lix()? { + required_features.push("repl-flake"); + } + + tracing::debug!("Required Nix features: {}", required_features.join(", ")); + + // Get currently enabled features + match util::get_nix_experimental_features() { + Ok(enabled_features) => { + let features_vec: Vec<_> = enabled_features.into_iter().collect(); + tracing::debug!("Enabled Nix features: {}", features_vec.join(", ")); + } + Err(e) => { + tracing::warn!("Failed to get enabled Nix features: {}", e); + } + } + + let missing_features = util::get_missing_experimental_features(&required_features)?; + + if !missing_features.is_empty() { + tracing::warn!( + "Missing required Nix features: {}", + missing_features.join(", ") + ); + return Err(eyre::eyre!( + "Missing required experimental features. Please enable: {}", + missing_features.join(", ") + )); + } + + tracing::debug!("All required Nix features are enabled"); + Ok(()) +} + +/// Handles environment variable setup and returns if a warning should be shown +/// +/// # Returns +/// +/// * `Result` - True if a warning should be shown about the FLAKE +/// variable, false otherwise +pub fn setup_environment() -> Result { + let mut do_warn = false; + + if let Ok(f) = std::env::var("FLAKE") { + // Set NH_FLAKE if it's not already set + if std::env::var("NH_FLAKE").is_err() { + std::env::set_var("NH_FLAKE", f); + + // Only warn if FLAKE is set and we're using it to set NH_FLAKE + // AND none of the command-specific env vars are set + if std::env::var("NH_OS_FLAKE").is_err() + && std::env::var("NH_HOME_FLAKE").is_err() + && std::env::var("NH_DARWIN_FLAKE").is_err() + { + do_warn = true; + } + } + } + + Ok(do_warn) +} + +/// Consolidate all necessary checks for Nix functionality into a single +/// function. This will be executed in the main function, but can be executed +/// before critical commands to double-check if necessary. +/// +/// # Returns +/// +/// * `Result<()>` - Ok if all checks pass, error otherwise +pub fn verify_nix_environment() -> Result<()> { + if env::var("NH_NO_CHECKS").is_ok() { + return Ok(()); + } + + check_nix_version()?; + check_nix_features()?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 88c145c0..cd13a3ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod checks; mod clean; mod commands; mod completion; @@ -20,29 +21,20 @@ const NH_VERSION: &str = env!("CARGO_PKG_VERSION"); const NH_REV: Option<&str> = option_env!("NH_REV"); fn main() -> Result<()> { - let mut do_warn = false; - if let Ok(f) = std::env::var("FLAKE") { - // Set NH_FLAKE if it's not already set - if std::env::var("NH_FLAKE").is_err() { - std::env::set_var("NH_FLAKE", f); - - // Only warn if FLAKE is set and we're using it to set NH_FLAKE - // AND none of the command-specific env vars are set - if std::env::var("NH_OS_FLAKE").is_err() - && std::env::var("NH_HOME_FLAKE").is_err() - && std::env::var("NH_DARWIN_FLAKE").is_err() - { - do_warn = true; - } - } - } - let args = ::parse(); + + // Set up logging crate::logging::setup_logging(args.verbose)?; tracing::debug!("{args:#?}"); tracing::debug!(%NH_VERSION, ?NH_REV); - if do_warn { + // Verify the Nix environment before running commands + checks::verify_nix_environment()?; + + // Once we assert required Nix features, validate NH environment checks + // For now, this is just NH_* variables being set. More checks may be + // added to setup_environment in the future. + if checks::setup_environment()? { tracing::warn!( "nh {NH_VERSION} now uses NH_FLAKE instead of FLAKE, please modify your configuration" ); @@ -51,6 +43,7 @@ fn main() -> Result<()> { args.command.run() } +/// Self-elevates the current process by re-executing it with sudo fn self_elevate() -> ! { use std::os::unix::process::CommandExt; diff --git a/src/util.rs b/src/util.rs index 7ab460e0..1765087c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,47 +1,29 @@ -extern crate semver; - +use std::collections::HashSet; use std::path::{Path, PathBuf}; -use std::process::Command; use std::str; use color_eyre::{eyre, Result}; -use semver::Version; use tempfile::TempDir; -/// Compares two semantic versions and returns their order. -/// -/// This function takes two version strings, parses them into `semver::Version` objects, and compares them. -/// It returns an `Ordering` indicating whether the current version is less than, equal to, or -/// greater than the target version. -/// -/// # Arguments -/// -/// * `current` - A string slice representing the current version. -/// * `target` - A string slice representing the target version to compare against. -/// -/// # Returns -/// -/// * `Result` - The comparison result. -pub fn compare_semver(current: &str, target: &str) -> Result { - let current = Version::parse(current)?; - let target = Version::parse(target)?; - - Ok(current.cmp(&target)) -} +use crate::commands::Command; /// Retrieves the installed Nix version as a string. /// -/// This function executes the `nix --version` command, parses the output to extract the version string, -/// and returns it. If the version string cannot be found or parsed, it returns an error. +/// This function executes the `nix --version` command, parses the output to +/// extract the version string, and returns it. If the version string cannot be +/// found or parsed, it returns an error. /// /// # Returns /// -/// * `Result` - The Nix version string or an error if the version cannot be retrieved. +/// * `Result` - The Nix version string or an error if the version +/// cannot be retrieved. pub fn get_nix_version() -> Result { - let output = Command::new("nix").arg("--version").output()?; + let output = Command::new("nix") + .arg("--version") + .run_capture()? + .ok_or_else(|| eyre::eyre!("No output from command"))?; - let output_str = str::from_utf8(&output.stdout)?; - let version_str = output_str + let version_str = output .lines() .next() .ok_or_else(|| eyre::eyre!("No version string found"))?; @@ -59,6 +41,21 @@ pub fn get_nix_version() -> Result { Err(eyre::eyre!("Failed to extract version")) } +/// Determines if the Nix binary is actually Lix +/// +/// # Returns +/// +/// * `Result` - True if the binary is Lix, false if it's standard Nix +pub fn is_lix() -> Result { + let output = Command::new("nix") + .arg("--version") + .run_capture()? + .ok_or_else(|| eyre::eyre!("No output from command"))?; + + Ok(output.to_lowercase().contains("lix")) +} + +/// Represents an object that may be a temporary path pub trait MaybeTempPath: std::fmt::Debug { fn get_path(&self) -> &Path; } @@ -75,6 +72,11 @@ impl MaybeTempPath for (PathBuf, TempDir) { } } +/// Gets the hostname of the current system +/// +/// # Returns +/// +/// * `Result` - The hostname as a string or an error pub fn get_hostname() -> Result { #[cfg(not(target_os = "macos"))] { @@ -102,3 +104,52 @@ pub fn get_hostname() -> Result { Ok(name.to_string()) } } + +/// Retrieves all enabled experimental features in Nix. +/// +/// This function executes the `nix config show experimental-features` command +/// and returns a `HashSet` of the enabled features. +/// +/// # Returns +/// +/// * `Result>` - A `HashSet` of enabled experimental features +/// or an error. +pub fn get_nix_experimental_features() -> Result> { + let output = Command::new("nix") + .args(["config", "show", "experimental-features"]) + .run_capture()?; + + // If running with dry=true, output might be None + let output_str = match output { + Some(output) => output, + None => return Ok(HashSet::new()), + }; + + let enabled_features: HashSet = + output_str.split_whitespace().map(String::from).collect(); + + Ok(enabled_features) +} + +/// Gets the missing experimental features from a required list. +/// +/// # Arguments +/// +/// * `required_features` - A slice of string slices representing the features +/// required. +/// +/// # Returns +/// +/// * `Result>` - A vector of missing experimental features or an +/// error. +pub fn get_missing_experimental_features(required_features: &[&str]) -> Result> { + let enabled_features = get_nix_experimental_features()?; + + let missing_features: Vec = required_features + .iter() + .filter(|&feature| !enabled_features.contains(*feature)) + .map(|&s| s.to_string()) + .collect(); + + Ok(missing_features) +}