Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
'';
Expand All @@ -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";
Expand All @@ -66,6 +65,7 @@ rustPlatform.buildRustPackage {
mainProgram = "nh";
maintainers = with lib.maintainers; [
drupol
NotAShelf
viperML
];
};
Expand Down
152 changes: 152 additions & 0 deletions src/checks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
use std::{cmp::Ordering, env};

use color_eyre::{eyre, Result};
use semver::Version;

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" };
Err(eyre::eyre!(
"{} version {} is too old. Minimum required version is {}",
binary_name,
version,
min_version
))
}
_ => 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<bool>` - True if a warning should be shown about the FLAKE
/// variable, false otherwise
pub fn setup_environment() -> Result<bool> {
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(())
}
22 changes: 6 additions & 16 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod checks;
mod clean;
mod commands;
mod completion;
Expand All @@ -20,22 +21,7 @@ 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 do_warn = checks::setup_environment()?;

let args = <crate::interface::Main as clap::Parser>::parse();
crate::logging::setup_logging(args.verbose)?;
Expand All @@ -48,9 +34,13 @@ fn main() -> Result<()> {
);
}

// Verify the Nix environment before running commands
checks::verify_nix_environment()?;

args.command.run()
}

/// Self-elevates the current process by re-executing it with sudo
fn self_elevate() -> ! {
use std::os::unix::process::CommandExt;

Expand Down
111 changes: 81 additions & 30 deletions src/util.rs
Original file line number Diff line number Diff line change
@@ -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<std::cmp::Ordering>` - The comparison result.
pub fn compare_semver(current: &str, target: &str) -> Result<std::cmp::Ordering> {
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<String>` - The Nix version string or an error if the version cannot be retrieved.
/// * `Result<String>` - The Nix version string or an error if the version
/// cannot be retrieved.
pub fn get_nix_version() -> Result<String> {
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"))?;
Expand All @@ -59,6 +41,21 @@ pub fn get_nix_version() -> Result<String> {
Err(eyre::eyre!("Failed to extract version"))
}

/// Determines if the Nix binary is actually Lix
///
/// # Returns
///
/// * `Result<bool>` - True if the binary is Lix, false if it's standard Nix
pub fn is_lix() -> Result<bool> {
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;
}
Expand All @@ -75,6 +72,11 @@ impl MaybeTempPath for (PathBuf, TempDir) {
}
}

/// Gets the hostname of the current system
///
/// # Returns
///
/// * `Result<String>` - The hostname as a string or an error
pub fn get_hostname() -> Result<String> {
#[cfg(not(target_os = "macos"))]
{
Expand Down Expand Up @@ -102,3 +104,52 @@ pub fn get_hostname() -> Result<String> {
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<HashSet<String>>` - A `HashSet` of enabled experimental features
/// or an error.
pub fn get_nix_experimental_features() -> Result<HashSet<String>> {
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<String> =
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<Vec<String>>` - A vector of missing experimental features or an
/// error.
pub fn get_missing_experimental_features(required_features: &[&str]) -> Result<Vec<String>> {
let enabled_features = get_nix_experimental_features()?;

let missing_features: Vec<String> = required_features
.iter()
.filter(|&feature| !enabled_features.contains(*feature))
.map(|&s| s.to_string())
.collect();

Ok(missing_features)
}