diff --git a/CHANGELOG.md b/CHANGELOG.md index 562e0c95..f6e83d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,34 @@ functionality, under the "Removed" section. ### Changed -- `nh os info` now hides empty columns. -- `nh os info` now support `--fields` to select which field(s) to display; also - add a per-generation "Closure Size" coloumn. - ([#375](https://github.com/nix-community/nh/issues/375)) +- `--elevation-program` flag was renamed to `--elevation-strategy` with support + for `'none'` (no elevation) and `'passwordless'` (for remote hosts with + `NOPASSWD` configured) values. The old flag name remains available as an alias + for backward compatibility. It may be removed at a later version. + ([#434](https://github.com/nix-community/nh/issues/434)) + - Multi-program remote elevation support: `sudo`, `doas`, `run0`, and `pkexec` + are now supported with correct flags for each program + - Environment variable `NH_ELEVATION_PROGRAM` is still supported for backward + compatibility (falls back to `NH_ELEVATION_STRATEGY` if set) +- Platform commands (`nh os`, `nh home`, `nh darwin`) now support SSH-based + remote builds via `--build-host`. The flag now uses proper remote build + semantics: derivations are copied to the remote host via `nix-copy-closure`, + built remotely, and results are transferred back. This matches `nixos-rebuild` + behavior, and is significantly more robust than the previous implementation + where `--build-host` would use Nix's `--builders` flag inefficiently. + ([#428](https://github.com/nix-community/nh/issues/428), + [#497](https://github.com/nix-community/nh/pull/497)) + - A new `--no-validate` flag skips pre-activation system validation checks. + Can also be set via the `NH_NO_VALIDATE` environment variable. + - Added `NH_REMOTE_CLEANUP` environment variable. When set, NH will attempt to + terminate remote Nix processes on interrupt (Ctrl+C). Opt-in due to + fragility. +- Shell argument splitting now uses `shlex` for proper quote handling in complex + command arguments. +- `nh os info` now supports `--fields` to select which field(s) to display + ([#375](https://github.com/nix-community/nh/issues/375)). + - Empty columns are now hidden by default to avoid visual clutter. + - A new, per-generation "Closure Size" column has been added - `nh os switch` and `nh os boot` now support the `--install-bootloader` flag, which will explicitly set `NIXOS_INSTALL_BOOTLOADER` for `switch-to-configuration`. Bootloader behaviour was previously supported by @@ -48,7 +72,7 @@ functionality, under the "Removed" section. variable. - `nh search` displays a link to the `package.nix` file on the nixpkgs GitHub, and also fixes the existing links so that they no longer brokenly point to a - non-existent file path on nix flake systems. + non-existent file path on Nix flake systems. ### Fixed @@ -77,6 +101,8 @@ functionality, under the "Removed" section. the installable such as (`./flake.nix#myHost`) in the past and lead to confusing behaviour for those unfamiliar. Such arguments are now normalized with a warning if NH can parse them. +- Password caching now works across all remote operations. +- Empty password validation prevents invalid credential caching. ### Removed diff --git a/Cargo.lock b/Cargo.lock index d3d8f124..7cb150f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,7 +432,7 @@ dependencies = [ "mio", "parking_lot", "rustix", - "signal-hook", + "signal-hook 0.3.18", "signal-hook-mio", "winapi", ] @@ -1429,6 +1429,8 @@ dependencies = [ "serde", "serde_json", "serial_test", + "shlex", + "signal-hook 0.4.1", "subprocess", "supports-hyperlinks", "system-configuration", @@ -1437,6 +1439,7 @@ dependencies = [ "thiserror 2.0.17", "tracing", "tracing-subscriber", + "urlencoding", "which", "yansi", ] @@ -2229,6 +2232,16 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a37d01603c37b5466f808de79f845c7116049b0579adb70a6b7d47c1fa3a952" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-mio" version = "0.2.4" @@ -2237,7 +2250,7 @@ checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", "mio", - "signal-hook", + "signal-hook 0.3.18", ] [[package]] @@ -2776,6 +2789,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index 2c6cdf05..f3a5d9d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,8 @@ secrecy = { features = [ "serde" ], version = "0.10.3" } semver = "1.0.27" serde = { features = [ "derive" ], version = "1.0.228" } serde_json = "1.0.145" +shlex = "1.3.0" +signal-hook = "0.4.1" subprocess = "0.2.9" supports-hyperlinks = "3.1.0" tempfile = "3.23.0" @@ -54,6 +56,7 @@ textwrap = { features = [ "terminal_size" ], version = "0.16.2" } thiserror = "2.0.17" tracing = "0.1.41" tracing-subscriber = { features = [ "env-filter", "registry", "std" ], version = "0.3.20" } +urlencoding = "2.1.3" which = "8.0.0" yansi = "1.0.1" diff --git a/README.md b/docs/README.md similarity index 82% rename from README.md rename to docs/README.md index 0b9fb696..731c9f21 100644 --- a/README.md +++ b/docs/README.md @@ -61,6 +61,12 @@ To get started with NH, skip to the [Usage](#usage) section. with explicit targeting. - **Extensible & Futureproof**: Designed for seamless, rapid addition of new subcommands and flags. + - **NH is a reimplementation of the CLIs you all know and love**, but with a + focus on safety and correctness. The language and design choices allow new + feature additions to be trivial and (almost) zero-cost. +- **Excellent Documentation**: Everything you can do with NH is documented. + Everything NH _does_ is documented. The user-facing and developer-facing + documentation is, and will always remain, up to date. ### Design @@ -111,7 +117,7 @@ the package is outdated. The latest, tagged version is available in Nixpkgs as **NH stable**. This is recommended for most users, as tagged releases will usually undergo more -testing.This repository also provides the latest development version of NH, +testing. This repository also provides the latest development version of NH, which you can get from the flake outputs. ```sh @@ -142,16 +148,15 @@ set the following configuration: > configurations via channels or manual dependency pinning and the such. Please > consider the new API mature, but somewhat experimental as it is a new > addition. Remember to report any bugs! +> +> - For flakes, the command is `nh os switch /path/to/flake` +> - For a classical configuration: +> - `nh os switch -f ''`, or +> - `nh os switch -f '' -- -I nixos-config=/path/to/configuration.nix` +> if using a different location than the default. -- For flakes, the command is `nh os switch /path/to/flake` -- For a classical configuration: - - `nh os switch -f ''`, or - - `nh os switch -f '' -- -I - nixos-config=/path/to/configuration.nix` - if using a different location than the default. - -You might want to check `nh os --help` for other values and the defaults from -environment variables. +You might want to check `nh os --help` or `man 1 nh` for other values and the +defaults from environment variables. #### Specialisations support @@ -199,32 +204,47 @@ One of the features and the core principles of NH is to provide a clean, uniform and intuitive CLI for its users. The `nh` command offers several subcommands, all with their extensive CLI flags for extensive configuration. +> [!TIP] +> NH supports various flags, [environment variables](#environment-variables) and +> setup options to provide the best possible user experience. See the `--help` +> page for individual subcommands, or `man 1 nh` for more information on each +> subcommand with examples. You may also use the relevant platform module, such +> as the NixOS module available in Nixpkgs, to customize it for your system as +> described in the installation section. + Under the `nh` command, there are two types of commands that you'll be interested in: ### Global Subcommands Global subcommands implement functionality around core Nix commands. As it -stands, we provide a **better search** and **better garbage collection**. +stands, we provide a **better search** and **better garbage collection** +experience, done so with two subcommands provided out of the box. + +#### `nh search` -- `nh search` - a super-fast package searching tool (powered by an Elasticsearch - client) for Nix packages in supported Nixpkgs branches. +We provide a super-fast package searching tool (powered by an Elasticsearch +client) for Nix packages in supported Nixpkgs branches, available as +`nh search`. -

+

nh search showcase

-- `nh clean` - a re-implementation of `nix-collect-garbage` that also collects - gcroots. +#### `nh clean` -

+Reimplementation of `nix-collect-garbage` that also collects gcroots with +various options for fine-graining what is kept, and additional context before +the cleanup process to let you know what is to be cleaned. + +

nh clean showcase

@@ -234,27 +254,37 @@ stands, we provide a **better search** and **better garbage collection**. Platform specific subcommands are those that implement CLI utilities for **NixOS**, **Home Manager** and **Nix-Darwin**. -- `nh os` - reimplements `nixos-rebuild`[^1] with the addition of - - build-tree displays. - - diff of changes. - - confirmation. +#### `nh os` + +The `nh os` subcommand reimplements the Python script, `nixos-rebuild-ng`, [^1] +from ground up _with the addition of_: + +- Build-tree displays via **nix-output-monitor** (nom). +- Pretty diffs of changes via **dix** +- Confirmation + +and other additional changes to make the UI more intuitive, from supporting +environment variables to additional safeguards. Is this all? No, more is to +come. -

+

nh os switch showcase

-- `nh home` - reimplements `home-manager`. -- `nh darwin` - reimplements `darwin-rebuild`. +#### `nh home` -> [!TIP] -> NH supports various flags, [environment variables](#environment-variables) and -> setups to provide the best possible user experience. See the `--help` page for -> individual subcommands, or `man 1 nh` for more information on each subcommand -> with examples. +The `nh home` subcommand reimplements the `home-manager` script, with the same +additions as `nh os`. + +#### `nh darwin` + +Last but not least, the `nh darwin` subcommand is a pure-rust reimplementation +of the `darwin-rebuild` script featuring the same additions as `nh os` and +`nh home`. [^1]: `nh os` does not yet provide full feature parity with `nixos-rebuild`. While a large collection of subcommands have been implemented, you might be @@ -343,6 +373,12 @@ the common variables that you may encounter or choose to employ are as follows: - Control whether `nom` (nix-output-monitor) should be enabled for the build processes. Equivalent of `--no-nom`. +- `NH_REMOTE_CLEANUP` + - Whether to initiate an attempt to clean up remote processes on interrupt via + pkill. This is implemented to match nixos-rebuild's behaviour, but due to + its fragile nature it has been made opt-in. Unless NH has been leaving + zombie processes on interrupt, there is generally no need to set this. + ### Notes - Any environment variables prefixed with `NH_` are explicitly propagated by NH @@ -352,6 +388,15 @@ the common variables that you may encounter or choose to employ are as follows: `FLAKE` and emit a warning recommending migration to `NH_FLAKE`. `FLAKE` will be removed in the future versions of NH. +## Frequently Asked Questions (FAQ) + +**Q**: Does NH wrap the CLIs that I typically use? + +**A**: No, all of the commands use Nix directly, and they **do not consume the +typical CLI utilities**. NH is slowly converting existing tools that are invoked +via shell to native Rust libraries to get safer integration and slightly better +performance. + ## Hacking Contributions are always welcome. To get started, just clone the repository and diff --git a/.github/nh_clean_screenshot.png b/docs/assets/nh_clean_screenshot.png similarity index 100% rename from .github/nh_clean_screenshot.png rename to docs/assets/nh_clean_screenshot.png diff --git a/.github/nh_search_screenshot.png b/docs/assets/nh_search_screenshot.png similarity index 100% rename from .github/nh_search_screenshot.png rename to docs/assets/nh_search_screenshot.png diff --git a/.github/nh_switch_screenshot.png b/docs/assets/nh_switch_screenshot.png similarity index 100% rename from .github/nh_switch_screenshot.png rename to docs/assets/nh_switch_screenshot.png diff --git a/docs/remote-build.md b/docs/remote-build.md new file mode 100644 index 00000000..fb248d45 --- /dev/null +++ b/docs/remote-build.md @@ -0,0 +1,233 @@ +# Remote Deployment + +NH supports remote deployments, as you might be familiar from `nixos-rebuild` or +similar tools, using the `--build-host` and `--target-host` options. + +## Overview + +Remote deployment has two independent concepts: + +- **`--build-host`**: Where the configuration is **built** (via + `nix-copy-closure` + `nix build`) +- **`--target-host`**: Where the result is **deployed** and activated + +You can use either, both, or neither. Derivation evaluation always happens +locally. + +| Flags used | Build location | Activation location | +| -------------------------------- | -------------- | ------------------- | +| none | localhost | localhost | +| `--build-host X` | X | localhost | +| `--target-host Y` | localhost | Y | +| `--build-host X --target-host Y` | X | Y | +| `--build-host Y --target-host Y` | Y | Y | + +## Basic Usage + +### Build Remotely, Deploy Locally + +```bash +nh os switch --build-host user@buildserver +``` + +This builds the configuration on `buildserver`, then copies the result back to +the local machine and activates it. + +### Build Locally, Deploy Remotely + +```bash +nh os switch --target-host user@production +``` + +This builds the configuration locally, copies it to `production`, and activates +it there. + +### Build on One Host, Deploy to Another + +```bash +nh os switch --build-host user@buildserver --target-host user@production +``` + +This builds on `buildserver`, then copies the result directly to `production` +and activates it there. + +### Build and Deploy to the Same Remote Host + +```bash +nh os switch --build-host user@production --target-host user@production +``` + +This builds on `production` and deploys to `production`. The implementation +avoids unnecessary data transfers by detecting when both hosts are the same. + +## Host Specification Format + +Hosts can be specified in several formats: + +- `hostname` - connects as the current user +- `user@hostname` - connects as the specified user +- `ssh://hostname` or `ssh://user@hostname` - URI format (scheme is stripped) +- `ssh-ng://hostname` or `ssh-ng://user@hostname` - Nix store URI format (scheme + is stripped) +- IPv6 addresses must use bracketed notation: `[2001:db8::1]` or + `user@[2001:db8::1]` + +> [!NOTE] +> Due to restrictions of Nix's SSH remote handling, ports cannot be specified in +> the host string. Use `NIX_SSHOPTS="-p 2222"` or configure ports in +> `~/.ssh/config` for the host you are building on/deploying to. + +## SSH Configuration + +### Authentication + +Remote deployment connects via SSH as the user running `nh`, not as `root`. This +means: + +- Your SSH keys and agent are used +- You can use keys with passphrases (via ssh-agent) +- You can use interactive authentication if needed + +This differs from `nix build --builders`, which connects via the `nix-daemon` +running as `root`. + +### Connection Multiplexing + +`nh` uses SSH's `ControlMaster` feature to share connections: + +```plaintext +ControlMaster=auto +ControlPath=/nh-ssh-%n +ControlPersist=60 +``` + +This reduces overhead when multiple SSH operations are performed. The first SSH +connection to a host creates a master connection, and subsequent operations to +the same host reuse it, avoiding repeated authentication. + +SSH control connections are automatically cleaned up when `nh` completes, +ensuring no lingering SSH processes remain. + +### Custom SSH Options + +Use the `NIX_SSHOPTS` environment variable to pass additional SSH options: + +```bash +NIX_SSHOPTS="-p 2222 -i ~/.ssh/custom_key" nh os switch --build-host user@host +``` + +Options in `NIX_SSHOPTS` are merged with the default options. For persistent +configuration, use `~/.ssh/config`: + +```plaintext +Host buildserver + HostName 192.168.1.100 + Port 2222 + User builder + IdentityFile ~/.ssh/builder_key +``` + +Then simply use: + +```bash +nh os switch --build-host buildserver +``` + +## Environment Variables + +### NH_REMOTE_CLEANUP + +When set, nh will attempt to terminate remote Nix processes when you press +Ctrl+C during a remote build. This uses `pkill` on the remote host to clean up +the build process. + +```bash +export NH_REMOTE_CLEANUP=1 +nh os switch --build-host user@buildserver +``` + +Valid values: `1`, `true`, `yes` (case-insensitive). + +This feature is **opt-in** because it is inherently fragile - remote process +cleanup depends on SSH still being functional and `pkill` being available. You +may still see zombie processes on the remote host if the connection drops before +cleanup can complete. + +### NH_NO_VALIDATE + +When set, skips pre-activation system validation checks. Useful when the target +host's store path isn't accessible from the local machine (e.g., building +remotely and deploying to a different target). + +```bash +export NH_NO_VALIDATE=1 +nh os switch --build-host user@buildserver --target-host user@production +``` + +## How Remote Builds Work + +When you use `--build-host`, `nh` follows this process: + +1. **Evaluate** the derivation path locally using + `nix eval --raw .drvPath` +2. **Copy derivation** to the build host using `nix-copy-closure --to` +3. **Build remotely** by running `nix build ^* --print-out-paths` on the + build host +4. **Copy result** back based on the deployment scenario (see below) + +### Copy Optimization + +To avoid unnecessary network transfers, `nh` optimizes copies based on your +configuration: + + + +| Scenario | Copy Path | +| -------------------------------------------- | -------------------------------------------------- | +| Build remote, no target | `build -> local` | +| Build remote, target = different host | `build -> target`, `build -> local` (for out-link) | +| Build remote, target = build host | `(nothing)` (already on target) | +| Build remote, target = build host + out-link | `build -> local` (only for out-link) | + + + +If `--build-host` and `--target-host` differ, NH will attempt a quick connection +from the build host to the target host to see if it can handle the copy directly +without relaying over localhost. This operation **will not fail the remote build +process**, and NH will simply relay over the orchestrator, i.e., the host you +have ran `nh os build` on. This is implemented as a minor convenience function, +and has zero negative effect over your builds. Instead, it may optimize the +number of connections when all hosts are connected over Tailscale, for example. + +When `--build-host` and `--target-host` point to the same machine, the result +stays on that machine unless you need a local out-link (symlink to the build +result). + +For security, you are _encouraged to be explicit_ in your hostnames and not +trust the DNS blindly. + +## Substitutes + +Use `--use-substitutes` to allow remote hosts to fetch pre-built binaries from +binary caches instead of building everything: + +```bash +nh os switch --build-host buildserver --use-substitutes +``` + +This passes: + +- `--use-substitutes` to `nix-copy-closure` +- `--substitute-on-destination` to `nix copy` (when copying between two remote + hosts) + +## Build Output + +### nix-output-monitor + +By default, build output is shown directly. While the NH package is wrapped with +nix-output-monitor, you will need `nix-output-monitor` available on the build +host if you want NH to be able to use it. + +If `nix-output-monitor` creates issues for whatever reason, you may disable it +with `--no-nom`. diff --git a/package.nix b/package.nix index fc25694d..7c7283c5 100644 --- a/package.nix +++ b/package.nix @@ -5,6 +5,7 @@ makeBinaryWrapper, installShellFiles, versionCheckHook, + sudo, use-nom ? true, nix-output-monitor ? null, rev ? "dirty", @@ -78,6 +79,32 @@ rustPlatform.buildRustPackage (finalAttrs: { versionCheckProgram = "${placeholder "out"}/bin/${finalAttrs.meta.mainProgram}"; versionCheckProgramArg = "--version"; + # pkgs.sudo is not available on the Darwin platform, and thus breaks build + # if added to nativeCheckInputs. We must manually disable the tests that + # *require* it, because they will fail when sudo is missing. + nativeCheckInputs = lib.optionals (!stdenv.hostPlatform.isDarwin) [ sudo ]; + checkFlags = lib.optionals stdenv.hostPlatform.isDarwin [ + # Tests that require sudo in PATH (not available on Darwin) + "--skip" + "test_build_sudo_cmd_basic" + "--skip" + "test_build_sudo_cmd_with_preserve_vars" + "--skip" + "test_build_sudo_cmd_with_preserve_vars_disabled" + "--skip" + "test_build_sudo_cmd_with_set_vars" + "--skip" + "test_build_sudo_cmd_force_no_stdin" + "--skip" + "test_build_sudo_cmd_with_remove_vars" + "--skip" + "test_build_sudo_cmd_with_askpass" + "--skip" + "test_build_sudo_cmd_env_added_once" + "--skip" + "test_elevation_strategy_passwordless_resolves" + ]; + # Besides the install check, we have a bunch of tests to run. Nextest is # the fastest way of running those since it's significantly faster than # `cargo test`, and has a nicer UI with CI-friendly characteristics. diff --git a/src/commands.rs b/src/commands.rs index 78f0ef44..9a49a39b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,7 +1,9 @@ use std::{ collections::HashMap, + convert::Infallible, ffi::{OsStr, OsString}, path::PathBuf, + str::FromStr, sync::{Mutex, OnceLock}, }; @@ -20,65 +22,54 @@ use crate::{installable::Installable, interface::NixBuildPassthroughArgs}; static PASSWORD_CACHE: OnceLock>> = OnceLock::new(); -fn get_cached_password(host: &str) -> Option { +/// Retrieves a cached password for the specified host. +/// +/// # Arguments +/// +/// * `host` - The host identifier (e.g., "user@hostname" or "hostname") to look +/// up in the cache +/// +/// # Returns +/// +/// * `Some(SecretString)` - If a password for the host exists in the cache +/// * `None` - If no password has been cached for this host +/// +/// # Errors +/// +/// Returns an error if the password cache lock is poisoned. +pub fn get_cached_password(host: &str) -> Result> { let cache = PASSWORD_CACHE.get_or_init(|| Mutex::new(HashMap::new())); let guard = cache .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - guard.get(host).cloned() + .map_err(|_| eyre::eyre!("Password cache lock poisoned"))?; + Ok(guard.get(host).cloned()) } -fn cache_password(host: &str, password: SecretString) { - let cache = PASSWORD_CACHE.get_or_init(|| Mutex::new(HashMap::new())); - let mut guard = cache - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - guard.insert(host.to_string(), password); -} - -/// Parse a command line string respecting quoted arguments. +/// Stores a password in the cache for the specified host. /// -/// Splits the command line by whitespace while preserving spaces within -/// single or double quoted strings. Quote characters are removed from -/// the resulting tokens. -fn parse_cmdline_with_quotes(cmdline: &str) -> Vec { - let mut parts = Vec::default(); - let mut current = String::new(); - let mut quoted = None; - - for c in cmdline.chars() { - match c { - // Opening quote - enter quoted mode - '\'' | '"' if quoted.is_none() => { - quoted = Some(c); - }, - // Closing quote - exit quoted mode - '\'' | '"' if quoted.is_some_and(|q| q == c) => { - quoted = None; - }, - // Different quote type while already quoted - treat as literal - '\'' | '"' => { - current.push(c); - }, - // Whitespace outside quotes - end of current token - s if s.is_whitespace() && quoted.is_none() => { - if !current.is_empty() { - parts.push(current.clone()); - current.clear(); - } - }, - // Any char, add to current token - _ => { - current.push(c); - }, - } - } +/// The password is stored as a `SecretString` to ensure secure memory +/// handling. Cached passwords persist for the lifetime of the program and can +/// be retrieved using [`get_cached_password`]. +/// +/// # Arguments +/// +/// * `host` - The host identifier (e.g., "user@hostname" or "hostname") to +/// associate with the password +/// * `password` - The password to cache, wrapped in a `SecretString` for secure +/// handling +/// +/// # Errors +/// +/// Returns an error if the password cache lock is poisoned. +pub fn cache_password(host: &str, password: SecretString) -> Result<()> { + let cache = PASSWORD_CACHE.get_or_init(|| Mutex::new(HashMap::new())); - if !current.is_empty() { - parts.push(current); - } + cache + .lock() + .map_err(|_| eyre::eyre!("Password cache lock poisoned"))? + .insert(host.to_string(), password); - parts + Ok(()) } fn ssh_wrap( @@ -115,35 +106,117 @@ pub enum EnvAction { Remove, } -/// Strategy for choosing a privilege elevation program. -/// - `Auto`: try supported programs in fallback order. -/// - `Prefer(PathBuf)`: try the specified program, then fallback. -/// - `Force(&'static str)`: use only the specified program, error if not -/// available. -#[allow(dead_code)] +/// Strategy argument for handling privilege elevation when running commands. +/// +/// Defines how `nh` should handle privilege elevation for commands +/// that require root access (e.g., `switch-to-configuration`) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ElevationStrategyArg { + /// No elevation - commands run without privilege escalation. + None, + + /// Automatically detect and use the first available elevation program + /// (tries doas -> sudo -> run0 -> pkexec in order). Uses askpass helper if + /// available. + Auto, + + /// Use elevation program but skip password prompting for remote hosts with + /// NOPASSWD configured. + Passwordless, + + /// Use the specified elevation program. + Program(PathBuf), +} + +impl FromStr for ElevationStrategyArg { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + match s { + "none" => Ok(Self::None), + "auto" => Ok(Self::Auto), + "passwordless" => Ok(Self::Passwordless), + _ => { + if let Some(rest) = s.strip_prefix("program:") { + Ok(Self::Program(PathBuf::from(rest))) + } else { + Ok(Self::Program(PathBuf::from(s))) + } + }, + } + } +} + +/// Strategy for handling privilege elevation at runtime. +/// +/// This enum defines how `nh` should handle privilege elevation for commands +/// that require root access (e.g., `switch-to-configuration`). #[derive(Debug, Clone, PartialEq, Eq)] pub enum ElevationStrategy { + /// Automatically detect and use the first available elevation program + /// (tries doas -> sudo -> run0 -> pkexec in order). Uses askpass helper if + /// available. Auto, + + /// Try the specified elevation program first, fall back to `Auto` if not + /// found. Corresponds to CLI argument that is a path. Prefer(PathBuf), + + /// Use only the specified program name. + #[allow(dead_code, reason = "In use")] Force(&'static str), + + /// Do not use any elevation program. Commands run without privilege + /// escalation. This will fail for commands requiring root unless the user is + /// already root or the system has other privilege mechanisms configured. + None, + + /// Use elevation program but skip password prompting. For remote hosts with + /// passwordless sudo (NOPASSWD in sudoers) or similar configurations. The + /// elevation command runs without `--stdin` or password input. + Passwordless, } impl ElevationStrategy { + /// Resolves the elevation strategy to an actual program path. + /// + /// Attempts to find an appropriate privilege elevation program based on the + /// strategy variant and system availability. + /// + /// # Returns + /// + /// Returns `Ok(PathBuf)` containing the path to the elevation program binary. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// - `None` variant: Always fails (elevation is disabled via + /// `--elevation-strategy=none`) + /// - `Force` variant: The specified program is not found in PATH + /// - Other variants: No suitable elevation programs are available on the + /// system pub fn resolve(&self) -> Result { match self { - Self::Auto => Self::choice(), + Self::Auto | Self::Passwordless => Self::choice(), Self::Prefer(program) => { which(program).or_else(|_| { - let auto = Self::choice()?; warn!( - "{} not found. Using {} instead", - program.to_string_lossy(), - auto.to_string_lossy() + ?program, + "Preferred elevation program not found, falling back to \ + auto-detection" ); - Ok(auto) + Self::choice() }) }, - Self::Force(program) => Ok(program.into()), + Self::Force(program_name) => { + which(program_name).context(format!( + "Forced elevation program '{program_name}' not found in PATH" + )) + }, + // Only reachable if resolve() is called directly. Safe since callers + // check is_some() before invoking resolve(). + Self::None => bail!("Elevation disabled via --elevation-strategy=none"), } } @@ -190,6 +263,7 @@ impl ElevationStrategy { } #[derive(Debug)] +#[allow(clippy::struct_field_names)] pub struct Command { dry: bool, message: Option, @@ -236,13 +310,6 @@ impl Command { self } - /// Set the SSH target for remote command execution. - #[must_use] - pub fn ssh(mut self, ssh: Option) -> Self { - self.ssh = ssh; - self - } - /// Add a single argument to the command. #[must_use] pub fn arg>(mut self, arg: S) -> Self { @@ -346,7 +413,7 @@ impl Command { if self.elevate.is_some() && cfg!(target_os = "macos") { self .env_vars - .insert("HOME".to_string(), EnvAction::Set("".to_string())); + .insert("HOME".to_string(), EnvAction::Set(String::new())); } // Preserve all variables in PRESERVE_ENV if present @@ -408,23 +475,29 @@ impl Command { /// /// Panics: If called when `self.elevate` is `None` fn build_sudo_cmd(&self) -> Result { - let elevation_program = self + let elevation_strategy = self .elevate .as_ref() - .ok_or_else(|| eyre::eyre!("Command not found for elevation"))? + .ok_or_else(|| eyre::eyre!("Command not found for elevation"))?; + + let elevation_program = elevation_strategy .resolve() .context("Failed to resolve elevation program")?; let mut cmd = Exec::cmd(&elevation_program); - // Use NH_SUDO_ASKPASS program for sudo if present + // Use NH_SUDO_ASKPASS program for sudo if present, but NOT for + // Passwordless variant (Passwordless expects NOPASSWD config without + // password input) let program_name = elevation_program .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| { eyre::eyre!("Failed to determine elevation program name") })?; - if program_name == "sudo" { + if program_name == "sudo" + && !matches!(elevation_strategy, ElevationStrategy::Passwordless) + { if let Ok(askpass) = std::env::var("NH_SUDO_ASKPASS") { cmd = cmd.env("SUDO_ASKPASS", askpass).arg("-A"); } @@ -444,10 +517,9 @@ impl Command { match action { EnvAction::Set(value) => Some(format!("{key}={value}")), EnvAction::Preserve if preserve_env => { - match std::env::var(key) { - Ok(value) => Some(format!("{key}={value}")), - Err(_) => None, - } + std::env::var(key) + .ok() + .map(|value| format!("{key}={value}")) }, _ => None, } @@ -490,10 +562,8 @@ impl Command { match action { EnvAction::Set(value) => Some(format!("{key}={value}")), EnvAction::Preserve if preserve_env => { - match std::env::var(key) { - Ok(value) => Some(format!("{key}={value}")), - Err(_) => None, - } + std::env::var(key) + .map_or(None, |value| Some(format!("{key}={value}"))) }, _ => None, } @@ -555,15 +625,14 @@ impl Command { /// Panics if the command result is unexpectedly None. #[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn run(&self) -> Result<()> { - // Prompt for sudo password if needed for remote deployment - // FIXME: this implementation only covers Sudo. I *think* doas and run0 are - // able to read from stdin, but needs to be tested and possibly - // mitigated. + // Prompt for elevation password if needed for remote deployment. + // Note: Only sudo supports stdin password input. For remote deployments + // with doas/run0, use --elevation-strategy=passwordless instead. let sudo_password = if self.ssh.is_some() && self.elevate.is_some() { let host = self.ssh.as_ref().ok_or_else(|| { eyre::eyre!("SSH host is None but elevation is required") })?; - if let Some(cached_password) = get_cached_password(host) { + if let Some(cached_password) = get_cached_password(host)? { Some(cached_password) } else { let password = @@ -571,8 +640,11 @@ impl Command { .without_confirmation() .prompt() .context("Failed to read sudo password")?; + if password.is_empty() { + bail!("Password cannot be empty"); + } let secret_password = SecretString::new(password.into()); - cache_password(host, secret_password.clone()); + cache_password(host, secret_password.clone())?; Some(secret_password) } } else { @@ -709,7 +781,6 @@ pub struct Build { installable: Installable, extra_args: Vec, nom: bool, - builder: Option, } impl Build { @@ -720,7 +791,6 @@ impl Build { installable, extra_args: vec![], nom: false, - builder: None, } } @@ -742,12 +812,6 @@ impl Build { self } - #[must_use] - pub fn builder(mut self, builder: Option) -> Self { - self.builder = builder; - self - } - #[must_use] pub fn extra_args(mut self, args: I) -> Self where @@ -781,16 +845,10 @@ impl Build { let base_command = Exec::cmd("nix") .arg("build") .args(&installable_args) - .args(&match &self.builder { - Some(host) => { - vec!["--builders".to_string(), format!("ssh://{host} - - - 100")] - }, - None => vec![], - }) .args(&self.extra_args); - let exit = if self.nom { - let cmd = { + if self.nom { + let pipeline = { base_command .args(&["--log-format", "internal-json", "--verbose"]) .stderr(Redirection::Merge) @@ -798,20 +856,41 @@ impl Build { | Exec::cmd("nom").args(&["--json"]) } .stdout(Redirection::None); - debug!(?cmd); - cmd.join() + debug!(?pipeline); + + // Use `popen()` to get access to individual processes so we can check + // Nix's exit status, not nom's. The pipeline's `join()` only returns + // the exit status of the last command (nom), which always succeeds + // even when Nix fails. + let mut processes = pipeline.popen()?; + + // Wait for all processes to finish + for proc in &mut processes { + proc.wait()?; + } + + // Check the exit status of the FIRST process (nix build) + // This is the one that matters. If Nix fails, we should fail as well + if let Some(nix_proc) = processes.first() { + if let Some(exit_status) = nix_proc.exit_status() { + match exit_status { + ExitStatus::Exited(0) => (), + other => bail!(ExitError(other)), + } + } + } } else { let cmd = base_command .stderr(Redirection::Merge) .stdout(Redirection::None); debug!(?cmd); - cmd.join() - }; + let exit = cmd.join(); - match exit? { - ExitStatus::Exited(0) => (), - other => bail!(ExitError(other)), + match exit? { + ExitStatus::Exited(0) => (), + other => bail!(ExitError(other)), + } } Ok(()) @@ -824,6 +903,12 @@ pub struct ExitError(ExitStatus); #[cfg(test)] mod tests { + #![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::unreachable, + reason = "Fine in tests" + )] use std::{env, ffi::OsString}; use serial_test::serial; @@ -842,7 +927,7 @@ mod tests { unsafe { env::set_var(key, value); } - EnvGuard { + Self { key: key.to_string(), original, } @@ -896,7 +981,6 @@ mod tests { .dry(true) .show_output(true) .elevate(Some(ElevationStrategy::Force("sudo"))) - .ssh(Some("host".to_string())) .message("test message") .arg("arg1") .args(["arg2", "arg3"]); @@ -904,7 +988,6 @@ mod tests { assert!(cmd.dry); assert!(cmd.show_output); assert_eq!(cmd.elevate, Some(ElevationStrategy::Force("sudo"))); - assert_eq!(cmd.ssh, Some("host".to_string())); assert_eq!(cmd.message, Some("test message".to_string())); assert_eq!(cmd.args, vec![ OsString::from("arg1"), @@ -1084,11 +1167,10 @@ mod tests { .build_sudo_cmd() .expect("build_sudo_cmd should succeed in test"); - // Platform-agnostic: 'sudo' may not be the first token if env vars are - // injected (e.g., NH_SUDO_ASKPASS). Accept any command line where - // 'sudo' is present as a token. + // Platform-agnostic: 'sudo' may be a full path or just the program name. + // Accept any command line where a token ends with 'sudo'. let cmdline = sudo_exec.to_cmdline_lossy(); - assert!(cmdline.split_whitespace().any(|tok| tok == "sudo")); + assert!(cmdline.split_whitespace().any(|tok| tok.ends_with("sudo"))); } #[test] @@ -1153,6 +1235,42 @@ mod tests { assert!(cmdline.contains("TEST_VAR=test_value")); } + #[test] + fn test_elevation_strategy_passwordless_resolves() { + let strategy = ElevationStrategy::Passwordless; + let result = strategy.resolve(); + + // Passwordless should resolve to an elevation program just like Auto + assert!(result.is_ok()); + let program = result.unwrap(); + assert!(!program.as_os_str().is_empty()); + } + + #[test] + fn test_elevation_strategy_arg_program_prefix_parsing() { + let parsed = "program:/path/to/bin".parse::(); + assert!(parsed.is_ok()); + match parsed.unwrap() { + ElevationStrategyArg::Program(path) => { + assert_eq!(path, PathBuf::from("/path/to/bin")); + }, + _ => unreachable!("Expected Program variant"), + } + } + + #[test] + fn test_build_sudo_cmd_force_no_stdin() { + let cmd = + Command::new("test").elevate(Some(ElevationStrategy::Force("sudo"))); + + let sudo_exec = + cmd.build_sudo_cmd().expect("build_sudo_cmd should succeed"); + let cmdline = sudo_exec.to_cmdline_lossy(); + + // Force("sudo") uses regular sudo without --stdin or --prompt flags + assert!(cmdline.contains("sudo")); + } + #[test] #[serial] fn test_build_sudo_cmd_with_remove_vars() { @@ -1250,7 +1368,6 @@ mod tests { assert_eq!(build.installable.to_args(), installable.to_args()); assert!(build.extra_args.is_empty()); assert!(!build.nom); - assert!(build.builder.is_none()); } #[test] @@ -1264,8 +1381,7 @@ mod tests { .message("Building package") .extra_arg("--verbose") .extra_args(["--option", "setting", "value"]) - .nom(true) - .builder(Some("build-host".to_string())); + .nom(true); assert_eq!(build.message, Some("Building package".to_string())); assert_eq!(build.extra_args, vec![ @@ -1275,7 +1391,6 @@ mod tests { OsString::from("value") ]); assert!(build.nom); - assert_eq!(build.builder, Some("build-host".to_string())); } #[test] @@ -1312,27 +1427,27 @@ mod tests { #[test] fn test_parse_cmdline_simple() { - let result = parse_cmdline_with_quotes("cmd arg1 arg2 arg3"); + let result = shlex::split("cmd arg1 arg2 arg3").unwrap_or_default(); assert_eq!(result, vec!["cmd", "arg1", "arg2", "arg3"]); } #[test] fn test_parse_cmdline_with_single_quotes() { - let result = parse_cmdline_with_quotes("cmd 'arg with spaces' arg2"); + let result = shlex::split("cmd 'arg with spaces' arg2").unwrap_or_default(); assert_eq!(result, vec!["cmd", "arg with spaces", "arg2"]); } #[test] fn test_parse_cmdline_with_double_quotes() { - let result = parse_cmdline_with_quotes(r#"cmd "arg with spaces" arg2"#); + let result = + shlex::split(r#"cmd "arg with spaces" arg2"#).unwrap_or_default(); assert_eq!(result, vec!["cmd", "arg with spaces", "arg2"]); } #[test] fn test_parse_cmdline_mixed_quotes() { - let result = parse_cmdline_with_quotes( - r#"cmd 'single quoted' "double quoted" normal"#, - ); + let result = shlex::split(r#"cmd 'single quoted' "double quoted" normal"#) + .unwrap_or_default(); assert_eq!(result, vec![ "cmd", "single quoted", @@ -1343,8 +1458,8 @@ mod tests { #[test] fn test_parse_cmdline_with_equals_in_quotes() { - let result = - parse_cmdline_with_quotes("sudo env 'PATH=/path/with spaces' /bin/cmd"); + let result = shlex::split("sudo env 'PATH=/path/with spaces' /bin/cmd") + .unwrap_or_default(); assert_eq!(result, vec![ "sudo", "env", @@ -1355,33 +1470,33 @@ mod tests { #[test] fn test_parse_cmdline_multiple_spaces() { - let result = parse_cmdline_with_quotes("cmd arg1 arg2"); + let result = shlex::split("cmd arg1 arg2").unwrap_or_default(); assert_eq!(result, vec!["cmd", "arg1", "arg2"]); } #[test] fn test_parse_cmdline_leading_trailing_spaces() { - let result = parse_cmdline_with_quotes(" cmd arg1 arg2 "); + let result = shlex::split(" cmd arg1 arg2 ").unwrap_or_default(); assert_eq!(result, vec!["cmd", "arg1", "arg2"]); } #[test] fn test_parse_cmdline_empty_string() { - let result = parse_cmdline_with_quotes(""); + let result = shlex::split("").unwrap_or_default(); assert_eq!(result, Vec::::default()); } #[test] fn test_parse_cmdline_only_spaces() { - let result = parse_cmdline_with_quotes(" "); + let result = shlex::split(" ").unwrap_or_default(); assert_eq!(result, Vec::::default()); } #[test] fn test_parse_cmdline_realistic_sudo() { let cmdline = - r#"/usr/bin/sudo env 'PATH=/path with spaces' /usr/bin/nh clean all"#; - let result = parse_cmdline_with_quotes(cmdline); + r"/usr/bin/sudo env 'PATH=/path with spaces' /usr/bin/nh clean all"; + let result = shlex::split(cmdline).unwrap_or_default(); assert_eq!(result, vec![ "/usr/bin/sudo", "env", @@ -1455,7 +1570,76 @@ mod tests { (EnvAction::Set(orig_val), EnvAction::Set(cloned_val)) => { assert_eq!(orig_val, cloned_val); }, + #[allow(clippy::unreachable, reason = "Should never happen")] _ => unreachable!("Clone should preserve variant and value"), } } + + #[test] + fn test_parse_cmdline_escaped_quotes() { + // shlex handles backslash escapes within double quotes + let result = + shlex::split(r#"cmd "arg with \"escaped\" quotes""#).unwrap_or_default(); + assert_eq!(result, vec!["cmd", r#"arg with "escaped" quotes"#]); + } + + #[test] + fn test_parse_cmdline_nested_quotes() { + // Single quotes inside double quotes are preserved literally + let result = shlex::split(r#"cmd "it's a test""#).unwrap_or_default(); + assert_eq!(result, vec!["cmd", "it's a test"]); + } + + #[test] + fn test_parse_cmdline_backslash_outside_quotes() { + // Backslash escapes space outside quotes + let result = shlex::split(r"cmd arg\ with\ space").unwrap_or_default(); + assert_eq!(result, vec!["cmd", "arg with space"]); + } + + #[test] + fn test_parse_cmdline_nix_store_paths() { + // Typical nix store paths should work + let result = shlex::split( + "/nix/store/abc123-foo/bin/cmd --flag /nix/store/def456-bar", + ) + .unwrap_or_default(); + assert_eq!(result, vec![ + "/nix/store/abc123-foo/bin/cmd", + "--flag", + "/nix/store/def456-bar" + ]); + } + + #[test] + fn test_parse_cmdline_env_vars_in_quotes() { + // Environment variable syntax should be preserved + let result = + shlex::split(r#"env "PATH=$HOME/bin:$PATH" cmd"#).unwrap_or_default(); + assert_eq!(result, vec!["env", "PATH=$HOME/bin:$PATH", "cmd"]); + } + + #[test] + fn test_parse_cmdline_unclosed_quote_returns_none() { + // shlex returns None for unclosed quotes, we return empty vec + let result = shlex::split("cmd 'unclosed").unwrap_or_default(); + assert_eq!(result, Vec::::default()); + } + + #[test] + fn test_parse_cmdline_complex_sudo_command() { + // Complex sudo command with multiple quoted args + let cmdline = r#"/usr/bin/sudo -E env 'HOME=/root' "PATH=/usr/bin" /usr/bin/nh os switch"#; + let result = shlex::split(cmdline).unwrap_or_default(); + assert_eq!(result, vec![ + "/usr/bin/sudo", + "-E", + "env", + "HOME=/root", + "PATH=/usr/bin", + "/usr/bin/nh", + "os", + "switch" + ]); + } } diff --git a/src/darwin.rs b/src/darwin.rs index 83fd9414..7fde98b3 100644 --- a/src/darwin.rs +++ b/src/darwin.rs @@ -1,4 +1,4 @@ -use std::{env, path::PathBuf}; +use std::{convert::Into, env, path::PathBuf}; use color_eyre::eyre::{Context, bail, eyre}; use tracing::{debug, info, warn}; @@ -15,6 +15,7 @@ use crate::{ DarwinSubcommand, DiffType, }, + remote::{self, RemoteBuildConfig, RemoteHost}, update::update, util::{get_hostname, print_dix_diff}, }; @@ -25,9 +26,23 @@ const CURRENT_PROFILE: &str = "/run/current-system"; impl DarwinArgs { /// Run the `darwin` subcommand. /// + /// # Parameters + /// + /// * `self` - The Darwin operation arguments + /// * `elevation` - The privilege elevation strategy (sudo/doas/none) + /// + /// # Returns + /// + /// Returns `Ok(())` if the operation succeeds. + /// /// # Errors /// - /// Returns an error if the operation fails. + /// Returns an error if: + /// + /// - Build or activation operations fail + /// - Remote operations encounter network or SSH issues + /// - Nix evaluation or building fails + /// - File system operations fail #[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn run(self, elevation: ElevationStrategy) -> Result<()> { use DarwinRebuildVariant::{Build, Switch}; @@ -108,15 +123,49 @@ impl DarwinRebuildArgs { let toplevel = toplevel_for(hostname, installable, "toplevel")?; - commands::Build::new(toplevel) - .extra_arg("--out-link") - .extra_arg(&out_path) - .extra_args(&self.extra_args) - .passthrough(&self.common.passthrough) - .message("Building Darwin configuration") - .nom(!self.common.no_nom) - .run() - .wrap_err("Failed to build Darwin configuration")?; + // If a build host is specified, use remote build semantics + if let Some(ref build_host_str) = self.build_host { + info!("Building Darwin configuration"); + + let build_host = RemoteHost::parse(build_host_str) + .wrap_err("Invalid build host specification")?; + + let config = RemoteBuildConfig { + build_host, + target_host: None, + use_nom: !self.common.no_nom, + use_substitutes: self.common.passthrough.use_substitutes, + extra_args: self + .extra_args + .iter() + .map(Into::into) + .chain( + self + .common + .passthrough + .generate_passthrough_args() + .into_iter() + .map(Into::into), + ) + .collect(), + }; + + // Initialize SSH control - guard will cleanup connections on drop + let _ssh_guard = remote::init_ssh_control(); + + remote::build_remote(&toplevel, &config, Some(&out_path)) + .wrap_err("Failed to build Darwin configuration")?; + } else { + commands::Build::new(toplevel) + .extra_arg("--out-link") + .extra_arg(&out_path) + .extra_args(&self.extra_args) + .passthrough(&self.common.passthrough) + .message("Building Darwin configuration") + .nom(!self.common.no_nom) + .run() + .wrap_err("Failed to build Darwin configuration")?; + } let target_profile = out_path.clone(); diff --git a/src/home.rs b/src/home.rs index 6211d619..1f860e8b 100644 --- a/src/home.rs +++ b/src/home.rs @@ -1,4 +1,4 @@ -use std::{env, ffi::OsString, path::PathBuf}; +use std::{convert::Into, env, ffi::OsString, path::PathBuf}; use color_eyre::{ Result, @@ -11,6 +11,7 @@ use crate::{ commands::Command, installable::Installable, interface::{self, DiffType, HomeRebuildArgs, HomeReplArgs, HomeSubcommand}, + remote::{self, RemoteBuildConfig, RemoteHost}, update::update, util::{get_hostname, print_dix_diff}, }; @@ -18,9 +19,22 @@ use crate::{ impl interface::HomeArgs { /// Run the `home` subcommand. /// + /// # Parameters + /// + /// * `self` - The Home Manager operation arguments + /// + /// # Returns + /// + /// Returns `Ok(())` if the operation succeeds. + /// /// # Errors /// - /// Returns an error if the operation fails. + /// Returns an error if: + /// + /// - Build or activation operations fail + /// - Remote operations encounter network or SSH issues + /// - Nix evaluation or building fails + /// - File system operations fail #[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn run(self) -> Result<()> { use HomeRebuildVariant::{Build, Switch}; @@ -95,15 +109,49 @@ impl HomeRebuildArgs { self.configuration.clone(), )?; - commands::Build::new(toplevel) - .extra_arg("--out-link") - .extra_arg(&out_path) - .extra_args(&self.extra_args) - .passthrough(&self.common.passthrough) - .message("Building Home-Manager configuration") - .nom(!self.common.no_nom) - .run() - .wrap_err("Failed to build Home-Manager configuration")?; + // If a build host is specified, use remote build semantics + if let Some(ref build_host_str) = self.build_host { + info!("Building Home-Manager configuration"); + + let build_host = RemoteHost::parse(build_host_str) + .wrap_err("Invalid build host specification")?; + + let config = RemoteBuildConfig { + build_host, + target_host: None, + use_nom: !self.common.no_nom, + use_substitutes: self.common.passthrough.use_substitutes, + extra_args: self + .extra_args + .iter() + .map(Into::into) + .chain( + self + .common + .passthrough + .generate_passthrough_args() + .into_iter() + .map(Into::into), + ) + .collect(), + }; + + // Initialize SSH control - guard will cleanup connections on drop + let _ssh_guard = remote::init_ssh_control(); + + remote::build_remote(&toplevel, &config, Some(&out_path)) + .wrap_err("Failed to build Home-Manager configuration")?; + } else { + commands::Build::new(toplevel) + .extra_arg("--out-link") + .extra_arg(&out_path) + .extra_args(&self.extra_args) + .passthrough(&self.common.passthrough) + .message("Building Home-Manager configuration") + .nom(!self.common.no_nom) + .run() + .wrap_err("Failed to build Home-Manager configuration")?; + } let prev_generation: Option = [ PathBuf::from("/nix/var/nix/profiles/per-user") @@ -122,12 +170,13 @@ impl HomeRebuildArgs { let spec_location = PathBuf::from(std::env::var("HOME")?) .join(".local/share/home-manager/specialisation"); - let current_specialisation = if let Some(s) = spec_location.to_str() { - std::fs::read_to_string(s).ok() - } else { - tracing::warn!("spec_location path is not valid UTF-8"); - None - }; + let current_specialisation = spec_location.to_str().map_or_else( + || { + tracing::warn!("spec_location path is not valid UTF-8"); + None + }, + |s| std::fs::read_to_string(s).ok(), + ); let target_specialisation = if self.no_specialisation { None @@ -390,6 +439,7 @@ where } }, Installable::Store { .. } => {}, + #[allow(clippy::unreachable, reason = "Should never happen")] Installable::Unspecified => { unreachable!( "Unspecified installable should have been resolved before calling \ diff --git a/src/interface.rs b/src/interface.rs index f4c722c5..8895df6f 100644 --- a/src/interface.rs +++ b/src/interface.rs @@ -51,9 +51,22 @@ pub struct Main { /// more detailed logs. pub verbosity: clap_verbosity_flag::Verbosity, - #[arg(short, long, global = true, env = "NH_ELEVATION_PROGRAM", value_hint = clap::ValueHint::CommandName)] - /// Choose what privilege elevation program should be used - pub elevation_program: Option, + #[arg( + short, + long, + global = true, + env = "NH_ELEVATION_STRATEGY", + value_hint = clap::ValueHint::CommandName, + alias = "elevation-program" + )] + /// Choose the privilege elevation strategy. + /// + /// Can be a path to an elevation program (e.g., /usr/bin/sudo), + /// or one of: 'none' (no elevation), + /// 'passwordless' (use elevation without password prompt for remote hosts + /// with NOPASSWD configured), or 'auto' (automatically detect available + /// elevation programs in order: doas, sudo, run0, pkexec) + pub elevation_strategy: Option, #[command(subcommand)] pub command: NHCommand, @@ -200,6 +213,7 @@ pub struct OsBuildVmArgs { } #[derive(Debug, Args)] +#[allow(clippy::struct_excessive_bools)] pub struct OsRebuildArgs { #[command(flatten)] pub common: CommonRebuildArgs, @@ -232,13 +246,17 @@ pub struct OsRebuildArgs { #[arg(short = 'R', long, env = "NH_BYPASS_ROOT_CHECK")] pub bypass_root_check: bool, - /// Deploy the configuration to a different host over ssh + /// Deploy the built configuration to a different host over SSH #[arg(long)] pub target_host: Option, - /// Build the configuration to a different host over ssh + /// Build the configuration on a different host over SSH #[arg(long)] pub build_host: Option, + + /// Skip pre-activation system validation checks + #[arg(long, env = "NH_NO_VALIDATE")] + pub no_validate: bool, } #[derive(Debug, Args)] @@ -543,6 +561,10 @@ pub struct HomeRebuildArgs { /// Show activation logs #[arg(long, env = "NH_SHOW_ACTIVATION_LOGS")] pub show_activation_logs: bool, + + /// Build the configuration on a different host over SSH + #[arg(long)] + pub build_host: Option, } impl HomeRebuildArgs { @@ -649,6 +671,10 @@ pub struct DarwinRebuildArgs { /// Show activation logs #[arg(long, env = "NH_SHOW_ACTIVATION_LOGS")] pub show_activation_logs: bool, + + /// Build the configuration on a different host over SSH + #[arg(long)] + pub build_host: Option, } impl DarwinRebuildArgs { diff --git a/src/lib.rs b/src/lib.rs index 8d2ad92e..7ecac771 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod interface; pub mod json; pub mod logging; pub mod nixos; +pub mod remote; pub mod search; pub mod update; pub mod util; diff --git a/src/main.rs b/src/main.rs index 5e442d09..103d80bc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,14 +9,17 @@ mod interface; mod json; mod logging; mod nixos; +mod remote; mod search; mod update; mod util; +use std::str::FromStr; + use color_eyre::Result; #[cfg(feature = "hotpath")] use hotpath; -use crate::commands::ElevationStrategy; +use crate::commands::{ElevationStrategy, ElevationStrategyArg}; pub const NH_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const NH_REV: Option<&str> = option_env!("NH_REV"); @@ -25,7 +28,34 @@ fn main() -> Result<()> { #[cfg(feature = "hotpath")] let _guard = hotpath::GuardBuilder::new("main").build(); - let args = ::parse(); + let mut args = ::parse(); + + // Backward compatibility: support NH_ELEVATION_PROGRAM env var if + // NH_ELEVATION_STRATEGY is not set. + // TODO: Remove this fallback in a future version + if args.elevation_strategy.is_none() { + if let Some(old_value) = std::env::var("NH_ELEVATION_PROGRAM") + .ok() + .filter(|v| !v.is_empty()) + { + tracing::warn!( + "NH_ELEVATION_PROGRAM is deprecated, use NH_ELEVATION_STRATEGY \ + instead. Falling back to NH_ELEVATION_PROGRAM for backward \ + compatibility. Accepted values: none, passwordless, program:" + ); + match ElevationStrategyArg::from_str(&old_value) { + Ok(strategy) => args.elevation_strategy = Some(strategy), + Err(e) => { + tracing::warn!( + "Failed to parse NH_ELEVATION_PROGRAM value '{}': {}. Falling \ + back to none.", + old_value, + e + ); + }, + } + } + } // Set up logging crate::logging::setup_logging(args.verbosity)?; @@ -40,9 +70,20 @@ fn main() -> Result<()> { // added to setup_environment in the future. checks::verify_variables()?; - let elevation = args - .elevation_program - .map_or(ElevationStrategy::Auto, ElevationStrategy::Prefer); + let elevation = + args + .elevation_strategy + .as_ref() + .map_or(ElevationStrategy::Auto, |arg| { + match arg { + ElevationStrategyArg::Auto => ElevationStrategy::Auto, + ElevationStrategyArg::None => ElevationStrategy::None, + ElevationStrategyArg::Passwordless => ElevationStrategy::Passwordless, + ElevationStrategyArg::Program(path) => { + ElevationStrategy::Prefer(path.clone()) + }, + } + }); args.command.run(elevation) } diff --git a/src/nixos.rs b/src/nixos.rs index 0fc627af..f638be21 100644 --- a/src/nixos.rs +++ b/src/nixos.rs @@ -1,4 +1,5 @@ use std::{ + convert::Into, env, fs, path::{Path, PathBuf}, @@ -22,6 +23,7 @@ use crate::{ OsRollbackArgs, OsSubcommand::{self}, }, + remote::{self, RemoteBuildConfig, RemoteHost}, update::update, util::{ensure_ssh_key_login, get_hostname, print_dix_diff}, }; @@ -31,7 +33,36 @@ const CURRENT_PROFILE: &str = "/run/current-system"; const SPEC_LOCATION: &str = "/etc/specialisation"; +/// Essential files that must exist in a valid NixOS system closure. Each tuple +/// contains the file path relative to the system profile and its description. +/// The descriptions are used on log messages or errors. +const ESSENTIAL_FILES: &[(&str, &str)] = &[ + ("bin/switch-to-configuration", "activation script"), + ("nixos-version", "system version identifier"), + ("init", "system init script"), + ("sw/bin", "system path"), +]; + impl interface::OsArgs { + /// Executes the NixOS subcommand. + /// + /// # Parameters + /// + /// * `self` - The NixOS operation arguments + /// * `elevation` - The privilege elevation strategy (sudo/doas/none) + /// + /// # Returns + /// + /// Returns `Ok(())` if the operation succeeds. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// - Build or activation operations fail + /// - Remote operations encounter network or SSH issues + /// - Nix evaluation or building fails + /// - File system operations fail #[cfg_attr(feature = "hotpath", hotpath::measure)] pub fn run(self, elevation: ElevationStrategy) -> Result<()> { use OsRebuildVariant::{Boot, Build, Switch, Test}; @@ -49,7 +80,7 @@ impl interface::OsArgs { if args.common.ask || args.common.dry { warn!("`--ask` and `--dry` have no effect for `nh os build`"); } - args.build_only(&Build, None, elevation) + args.build_only(&Build, None, &elevation) }, OsSubcommand::BuildVm(args) => args.build_vm(elevation), OsSubcommand::Repl(args) => args.run(), @@ -86,7 +117,7 @@ impl OsBuildVmArgs { // Show warning if no hostname was explicitly provided for VM builds if self.common.hostname.is_none() { - let (_, target_hostname) = self.common.setup_build_context()?; + let (_, target_hostname) = self.common.setup_build_context(&elevation)?; tracing::warn!( "Guessing system is {target_hostname} for a VM image. If this isn't \ intended, use --hostname to change." @@ -96,7 +127,7 @@ impl OsBuildVmArgs { self.common.build_only( &OsRebuildVariant::BuildVm, Some(&attr), - elevation, + &elevation, )?; // If --run flag is set, execute the VM @@ -118,7 +149,8 @@ impl OsRebuildActivateArgs { ) -> Result<()> { use OsRebuildVariant::{Build, BuildVm}; - let (elevate, target_hostname) = self.rebuild.setup_build_context()?; + let (elevate, target_hostname) = + self.rebuild.setup_build_context(&elevation)?; let (out_path, _tempdir_guard) = self.rebuild.determine_output_path(variant)?; @@ -132,9 +164,18 @@ impl OsRebuildActivateArgs { _ => "Building NixOS configuration", }; - self - .rebuild - .execute_build_command(toplevel, &out_path, message)?; + // Initialize SSH control early if we have remote hosts - guard will keep + // connections alive for both build and activation + let _ssh_guard = if self.rebuild.build_host.is_some() + || self.rebuild.target_host.is_some() + { + Some(remote::init_ssh_control()) + } else { + None + }; + + let actual_store_path = + self.rebuild.execute_build(toplevel, &out_path, message)?; let target_profile = self.rebuild.resolve_specialisation_and_profile(&out_path)?; @@ -158,6 +199,7 @@ impl OsRebuildActivateArgs { variant, &out_path, &target_profile, + actual_store_path.as_deref(), elevate, elevation, )?; @@ -170,6 +212,7 @@ impl OsRebuildActivateArgs { variant: &OsRebuildVariant, out_path: &Path, target_profile: &Path, + actual_store_path: Option<&Path>, elevate: bool, elevation: ElevationStrategy, ) -> Result<()> { @@ -186,89 +229,184 @@ impl OsRebuildActivateArgs { } if let Some(target_host) = &self.rebuild.target_host { - Command::new("nix") - .args([ - "copy", - "--to", - format!("ssh://{target_host}").as_str(), - match target_profile.to_str() { - Some(s) => s, - None => { - return Err(eyre!("target_profile path is not valid UTF-8")); - }, - }, - ]) - .message("Copying configuration to target") - .with_required_env() - .run()?; + // Only copy if the output path exists locally (i.e., was copied back from + // remote build) + if out_path.exists() { + let target = RemoteHost::parse(target_host) + .wrap_err("Invalid target host specification")?; + remote::copy_to_remote( + &target, + target_profile, + self.rebuild.common.passthrough.use_substitutes, + ) + .context("Failed to copy configuration to target host")?; + } } - let switch_to_configuration = target_profile - .canonicalize() - .context("Failed to resolve output path")? - .join("bin") - .join("switch-to-configuration") - .canonicalize() - .context("Failed to resolve switch-to-configuration path")?; + // Validate system closure before activation, unless bypassed. For remote + // builds, use the actual store path returned from the build. For local + // builds, canonicalize the target_profile. + let is_remote_build = self.rebuild.target_host.is_some(); + let resolved_profile: PathBuf = if let Some(store_path) = actual_store_path + { + // Remote build - use the actual store path from the build output + store_path.to_path_buf() + } else if is_remote_build && !out_path.exists() { + // Remote build with no local result and no store path captured + // (shouldn't happen, but fallback) + target_profile.to_path_buf() + } else { + // Local build - canonicalize the symlink to get the store path + target_profile + .canonicalize() + .context("Failed to resolve output path to actual store path")? + }; + + let should_skip = self.rebuild.no_validate; - if !switch_to_configuration.exists() { - return Err(missing_switch_to_configuration_error()); + if should_skip { + warn!( + "Skipping pre-activation validation (--no-validate or NH_NO_VALIDATE \ + set)" + ); + warn!( + "This may result in activation failures if the system closure is \ + incomplete" + ); + } else if let Some(target_host) = &self.rebuild.target_host { + // For remote activation, validate on the remote host using the resolved + // store path + validate_system_closure_remote( + &resolved_profile, + target_host, + self.rebuild.build_host.as_deref(), + )?; + } else { + // For local activation, validate locally + validate_system_closure(&resolved_profile)?; } + // Resolve switch-to-configuration path for activation commands. For + // remote-only builds where out_path doesn't exist locally, skip this + // since we'll execute these commands via SSH on the remote host + let switch_to_configuration_path = + resolved_profile.join("bin").join("switch-to-configuration"); + + let switch_to_configuration = if is_remote_build && !out_path.exists() { + // Remote build with no local result. Use uncanonicalized path for SSH + switch_to_configuration_path + } else { + switch_to_configuration_path + .canonicalize() + .context("Failed to resolve switch-to-configuration path")? + }; + let canonical_out_path = switch_to_configuration.to_str().ok_or_else(|| { eyre!("switch-to-configuration path contains invalid UTF-8") })?; if let Test | Switch = variant { - let variant_label = match variant { - Test => "test", - Switch => "switch", - _ => unreachable!(), - }; - - Command::new(canonical_out_path) - .arg("test") - .ssh(self.rebuild.target_host.clone()) - .message("Activating configuration") - .elevate(elevate.then_some(elevation.clone())) - .preserve_envs(["NIXOS_INSTALL_BOOTLOADER"]) - .with_required_env() - .show_output(self.show_activation_logs) - .run() - .wrap_err(format!("Activation ({variant_label}) failed"))?; + if let Some(target_host) = &self.rebuild.target_host { + let target = RemoteHost::parse(target_host) + .wrap_err("Invalid target host specification")?; + + let activation_type = match variant { + Test => remote::ActivationType::Test, + Switch => remote::ActivationType::Switch, + #[allow(clippy::unreachable, reason = "Should never happen.")] + _ => unreachable!(), + }; + + remote::activate_remote( + &target, + &resolved_profile, + &remote::ActivateRemoteConfig { + platform: remote::Platform::NixOS, + activation_type, + install_bootloader: false, + show_logs: self.show_activation_logs, + elevation: elevate.then_some(elevation.clone()), + }, + ) + .wrap_err(format!( + "Activation ({}) failed", + activation_type.as_str() + ))?; + } else { + Command::new(canonical_out_path) + .arg("test") + .message("Activating configuration") + .elevate(elevate.then_some(elevation.clone())) + .preserve_envs(["NIXOS_INSTALL_BOOTLOADER"]) + .with_required_env() + .show_output(self.show_activation_logs) + .run() + .wrap_err("Activation (test) failed")?; + } - debug!("Completed {variant:?} operation with output path: {out_path:?}"); + if let Some(store_path) = actual_store_path { + debug!( + "Completed {variant:?} operation with store path: {store_path:?}" + ); + } else { + debug!( + "Completed {variant:?} operation with local output path: \ + {out_path:?}" + ); + } } if let Boot | Switch = variant { - Command::new("nix") - .elevate(elevate.then_some(elevation.clone())) - .args(["build", "--no-link", "--profile", SYSTEM_PROFILE]) - .arg(canonical_out_path) - .ssh(self.rebuild.target_host.clone()) - .with_required_env() - .run() - .wrap_err("Failed to set system profile")?; - - let mut cmd = Command::new(switch_to_configuration) - .arg("boot") - .ssh(self.rebuild.target_host.clone()) - .elevate(elevate.then_some(elevation)) - .message("Adding configuration to bootloader") - .preserve_envs(["NIXOS_INSTALL_BOOTLOADER"]); + if let Some(target_host) = &self.rebuild.target_host { + let target = RemoteHost::parse(target_host) + .wrap_err("Invalid target host specification")?; + + remote::activate_remote( + &target, + &resolved_profile, + &remote::ActivateRemoteConfig { + platform: remote::Platform::NixOS, + activation_type: remote::ActivationType::Boot, + install_bootloader: self.rebuild.install_bootloader, + show_logs: false, + elevation: elevate.then_some(elevation), + }, + ) + .wrap_err("Bootloader activation failed")?; + } else { + Command::new("nix") + .elevate(elevate.then_some(elevation.clone())) + .args(["build", "--no-link", "--profile", SYSTEM_PROFILE]) + .arg(canonical_out_path) + .with_required_env() + .run() + .wrap_err("Failed to set system profile")?; + + let mut cmd = Command::new(switch_to_configuration) + .arg("boot") + .elevate(elevate.then_some(elevation)) + .message("Adding configuration to bootloader") + .preserve_envs(["NIXOS_INSTALL_BOOTLOADER"]); + + if self.rebuild.install_bootloader { + cmd = cmd.set_env("NIXOS_INSTALL_BOOTLOADER", "1"); + } - if self.rebuild.install_bootloader { - cmd = cmd.set_env("NIXOS_INSTALL_BOOTLOADER", "1"); + cmd + .with_required_env() + .run() + .wrap_err("Bootloader activation failed")?; } - - cmd - .with_required_env() - .run() - .wrap_err("Bootloader activation failed")?; } - debug!("Completed {variant:?} operation with output path: {out_path:?}"); + if let Some(store_path) = actual_store_path { + debug!("Completed {variant:?} operation with store path: {store_path:?}"); + } else { + debug!( + "Completed {variant:?} operation with local output path: {out_path:?}" + ); + } Ok(()) } } @@ -283,17 +421,21 @@ impl OsRebuildArgs { /// - Resolving the target hostname for the build. /// /// # Returns - /// A `Result` containing a tuple: + /// + /// `Result` containing a tuple: + /// /// - `bool`: `true` if elevation is required, `false` otherwise. /// - `String`: The resolved target hostname. - fn setup_build_context(&self) -> Result<(bool, String)> { + fn setup_build_context( + &self, + elevation: &ElevationStrategy, + ) -> Result<(bool, String)> { + // Only check SSH key login if remote hosts are involved if self.build_host.is_some() || self.target_host.is_some() { - // This can fail, we only care about prompting the user - // for ssh key login beforehand. - let _ = ensure_ssh_key_login(); + ensure_ssh_key_login()?; } - let elevate = has_elevation_status(self.bypass_root_check)?; + let elevate = has_elevation_status(self.bypass_root_check, elevation)?; if self.update_args.update_all || self.update_args.update_input.is_some() { update( @@ -344,22 +486,69 @@ impl OsRebuildArgs { ) } - fn execute_build_command( + fn execute_build( &self, toplevel: Installable, out_path: &Path, message: &str, - ) -> Result<()> { - commands::Build::new(toplevel) - .extra_arg("--out-link") - .extra_arg(out_path) - .extra_args(&self.extra_args) - .passthrough(&self.common.passthrough) - .builder(self.build_host.clone()) - .message(message) - .nom(!self.common.no_nom) - .run() - .wrap_err("Failed to build configuration") + ) -> Result> { + // If a build host is specified, use proper remote build semantics: + // + // 1. Evaluate derivation locally + // 2. Copy derivation to build host (user-initiated SSH) + // 3. Build on remote host + // 4. Copy result back (to localhost or target_host) + if let Some(ref build_host_str) = self.build_host { + info!("{message}"); + + let build_host = RemoteHost::parse(build_host_str) + .wrap_err("Invalid build host specification")?; + + let target_host = self + .target_host + .as_ref() + .map(|s| RemoteHost::parse(s)) + .transpose() + .wrap_err("Invalid target host specification")?; + + let config = RemoteBuildConfig { + build_host, + target_host, + use_nom: !self.common.no_nom, + use_substitutes: self.common.passthrough.use_substitutes, + extra_args: self + .extra_args + .iter() + .map(Into::into) + .chain( + self + .common + .passthrough + .generate_passthrough_args() + .into_iter() + .map(Into::into), + ) + .collect(), + }; + + let actual_store_path = + remote::build_remote(&toplevel, &config, Some(out_path))?; + + Ok(Some(actual_store_path)) + } else { + // Local build - use the existing path + commands::Build::new(toplevel) + .extra_arg("--out-link") + .extra_arg(out_path) + .extra_args(&self.extra_args) + .passthrough(&self.common.passthrough) + .message(message) + .nom(!self.common.no_nom) + .run() + .wrap_err("Failed to build configuration")?; + + Ok(None) // Local builds don't have separate store path + } } fn resolve_specialisation_and_profile( @@ -383,16 +572,23 @@ impl OsRebuildArgs { debug!("Output path: {out_path:?}"); debug!("Target profile path: {}", target_profile.display()); - debug!("Target profile exists: {}", target_profile.exists()); - if !target_profile - .try_exists() - .context("Failed to check if target profile exists")? - { - return Err(eyre!( - "Target profile path does not exist: {}", - target_profile.display() - )); + // If out_path doesn't exist locally, assume it's remote and skip existence + // check + if out_path.exists() { + debug!("Target profile exists: {}", target_profile.exists()); + + if !target_profile + .try_exists() + .context("Failed to check if target profile exists")? + { + return Err(eyre!( + "Target profile path does not exist: {}", + target_profile.display() + )); + } + } else { + debug!("Output path is remote, skipping local existence check"); } Ok(target_profile) @@ -435,11 +631,11 @@ impl OsRebuildArgs { self, variant: &OsRebuildVariant, final_attr: Option<&String>, - _elevation: ElevationStrategy, + elevation: &ElevationStrategy, ) -> Result<()> { use OsRebuildVariant::{Build, BuildVm}; - let (_, target_hostname) = self.setup_build_context()?; + let (_, target_hostname) = self.setup_build_context(elevation)?; let (out_path, _tempdir_guard) = self.determine_output_path(variant)?; @@ -451,7 +647,7 @@ impl OsRebuildArgs { _ => "Building NixOS configuration", }; - self.execute_build_command(toplevel, &out_path, message)?; + self.execute_build(toplevel, &out_path, message)?; let target_profile = self.resolve_specialisation_and_profile(&out_path)?; @@ -467,7 +663,7 @@ impl OsRebuildArgs { impl OsRollbackArgs { #[expect(clippy::too_many_lines)] fn rollback(&self, elevation: ElevationStrategy) -> Result<()> { - let elevate = has_elevation_status(self.bypass_root_check)?; + let elevate = has_elevation_status(self.bypass_root_check, &elevation)?; // Find previous generation or specific generation let target_generation = if let Some(gen_number) = self.to { @@ -749,6 +945,75 @@ fn run_vm(out_path: &Path) -> Result<()> { Ok(()) } +/// Validates that essential files exist in the system closure. +/// +/// Checks for a few critical files that must be present in a complete NixOS +/// system. This is essentially in-line with what nixos-rebuild-ng checks for. +/// +/// - bin/switch-to-configuration: activation script +/// - nixos-version: system version identifier +/// - init: system init script +/// - sw/bin: system path binaries +/// +/// # Returns +/// +/// `Ok(())` if all files exist, or an error listing missing files. +fn validate_system_closure(system_path: &Path) -> Result<()> { + let mut missing = Vec::new(); + for (file, description) in ESSENTIAL_FILES { + let path = system_path.join(file); + if !path.exists() { + missing.push(format!(" - {file} ({description})")); + } + } + + if !missing.is_empty() { + let missing_list = missing.join("\n"); + return Err(eyre!( + "System closure validation failed. Missing essential files:\n{}\n\nThis \ + typically happens when:\n1. 'system.switch.enable' is set to false in \ + your configuration\n2. The build was incomplete or corrupted\n3. \ + You're using an incomplete derivation\n\nTo fix this:\n1. Check if \ + 'system.switch.enable = false' is set and remove it\n2. Rebuild your \ + system configuration\n3. If the problem persists, verify your system \ + closure is complete\n\nSystem path checked: {}", + missing_list, + system_path.display() + )); + } + + Ok(()) +} + +/// Validates essential files on a remote host via SSH. +/// +/// Similar to [`validate_system_closure`] but executes checks on a remote host. +fn validate_system_closure_remote( + system_path: &Path, + target_host: &str, + build_host: Option<&str>, +) -> Result<()> { + let target = remote::RemoteHost::parse(target_host) + .wrap_err("Invalid target host specification")?; + + // Build context string for error messages + let context = build_host.map(|build| { + if build == target_host { + "also build host".to_string() + } else { + format!("built on '{build}'") + } + }); + + // Delegate to the generic remote validation function + remote::validate_closure_remote( + &target, + system_path, + ESSENTIAL_FILES, + context.as_deref(), + ) +} + /// Returns an error indicating that the 'switch-to-configuration' binary is /// missing, along with common reasons and solutions. fn missing_switch_to_configuration_error() -> color_eyre::eyre::Report { @@ -796,13 +1061,23 @@ fn get_nh_os_flake_env() -> Result> { /// `bypass_root_check` is true). /// /// # Arguments +/// /// * `bypass_root_check` - If true, bypasses the root check and assumes no /// elevation is needed. /// /// # Errors +/// /// Returns an error if `bypass_root_check` is false and the user is root, /// as `nh os` subcommands should not be run directly as root. -fn has_elevation_status(bypass_root_check: bool) -> Result { +fn has_elevation_status( + bypass_root_check: bool, + elevation: &commands::ElevationStrategy, +) -> Result { + // If elevation strategy is None, never elevate + if matches!(elevation, commands::ElevationStrategy::None) { + return Ok(false); + } + if bypass_root_check { warn!("Bypassing root check, now running nix as root"); Ok(false) diff --git a/src/remote.rs b/src/remote.rs new file mode 100644 index 00000000..362fca70 --- /dev/null +++ b/src/remote.rs @@ -0,0 +1,2467 @@ +use std::{ + env, + ffi::OsString, + io::Read, + path::{Path, PathBuf}, + sync::{ + Arc, + OnceLock, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use color_eyre::{ + Result, + eyre::{Context, bail, eyre}, +}; +use secrecy::{ExposeSecret, SecretString}; +use subprocess::{Exec, ExitStatus, Redirection}; +use tracing::{debug, error, info, warn}; + +use crate::{ + commands::{ElevationStrategy, cache_password, get_cached_password}, + installable::Installable, + util::NixVariant, +}; + +/// Global flag indicating whether a SIGINT (Ctrl+C) was received. +static INTERRUPTED: OnceLock> = OnceLock::new(); + +/// Get or initialize the interrupt flag. +/// +/// Returns a reference to the shared interrupt flag, initializing it on first +/// call. +fn get_interrupt_flag() -> &'static Arc { + INTERRUPTED.get_or_init(|| Arc::new(AtomicBool::new(false))) +} + +/// Cache for signal handler registration status. +static HANDLER_REGISTERED: OnceLock<()> = OnceLock::new(); + +/// Builds a remote command string with proper elevation handling. +/// +/// Constructs the command to execute on the remote host, wrapping it with +/// the appropriate elevation program (sudo/doas/etc) based on the strategy. +/// +/// # Arguments +/// * `strategy` - Optional elevation strategy to use +/// * `base_cmd` - The base command to execute +/// +/// # Returns +/// The complete command string to execute on the remote +/// +/// # Errors +/// Returns error if: +/// - Elevation program cannot be resolved +/// - Elevation program name cannot be determined +fn build_remote_command( + strategy: Option<&ElevationStrategy>, + base_cmd: &str, +) -> Result { + if let Some(strategy) = strategy { + if matches!(strategy, ElevationStrategy::None) { + return Ok(base_cmd.to_string()); + } + + let program = strategy.resolve()?; + let program_name = program + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| eyre!("Failed to determine elevation program name"))?; + + match (program_name, strategy) { + // sudo passwordless: use --non-interactive to fail if password required + ("sudo", ElevationStrategy::Passwordless) => { + Ok(format!( + "{} --non-interactive {}", + program.display(), + base_cmd + )) + }, + ("sudo", _) => { + Ok(format!( + "{} --prompt= --stdin {}", + program.display(), + base_cmd + )) + }, + // doas passwordless: use -n flag (non-interactive) + ("doas", ElevationStrategy::Passwordless) => { + Ok(format!("{} -n {}", program.display(), base_cmd)) + }, + ("doas", _) => { + bail!( + "doas does not support stdin password input for remote deployment. \ + Use --elevation-strategy=passwordless if remote has NOPASSWD \ + configured." + ) + }, + // run0 passwordless: use --no-ask-password flag + ("run0", ElevationStrategy::Passwordless) => { + Ok(format!( + "{} --no-ask-password {}", + program.display(), + base_cmd + )) + }, + ("run0", _) => { + bail!( + "run0 does not support stdin password input for remote deployment. \ + Use --elevation-strategy=passwordless if authentication is not \ + required." + ) + }, + // pkexec: no passwordless support + ("pkexec", _) => { + bail!( + "pkexec does not support non-interactive password input for remote \ + deployment. pkexec requires a polkit agent which is not available \ + over SSH." + ) + }, + // Unknown program: bail instead of guessing + (_, ElevationStrategy::Passwordless) => { + bail!( + "Unknown elevation program '{}' does not have known passwordless \ + support. Only sudo, doas, and run0 are supported with \ + --elevation-strategy=passwordless", + program_name + ) + }, + (..) => { + bail!( + "Unknown elevation program '{}' does not support stdin password \ + input for remote deployment. Only sudo supports password input \ + over SSH. Use --elevation-strategy=passwordless if remote has \ + passwordless elevation configured, or use a known elevation \ + program (sudo/doas/run0).", + program_name + ) + }, + } + } else { + Ok(base_cmd.to_string()) + } +} + +/// Register a SIGINT handler that sets the global interrupt flag. +/// +/// This function is idempotent - multiple calls are safe and will not +/// create multiple handlers. Uses `signal_hook::flag::register` which +/// is async-signal-safe. +/// +/// # Errors +/// +/// Returns an error if the signal handler cannot be registered. +fn register_interrupt_handler() -> Result<()> { + use signal_hook::{consts::SIGINT, flag}; + + if HANDLER_REGISTERED.get().is_some() { + return Ok(()); + } + + // Not registered yet, register it + flag::register(SIGINT, Arc::clone(get_interrupt_flag())) + .context("Failed to register SIGINT handler")?; + + // Mark as registered + // The race condition here is benign. Worst case, we register twice, but both + // handlers will set the same flag which is fine + let _ = HANDLER_REGISTERED.set(()); + + Ok(()) +} + +/// Guard that cleans up SSH `ControlMaster` sockets on drop. +/// +/// This ensures SSH control connections are properly closed when remote +/// operations complete, preventing lingering SSH processes. +#[must_use] +pub struct SshControlGuard { + control_dir: PathBuf, +} + +impl Drop for SshControlGuard { + fn drop(&mut self) { + cleanup_ssh_control_sockets(&self.control_dir); + } +} + +/// Clean up SSH `ControlMaster` sockets in the control directory. +/// +/// Iterates through all ssh-* control sockets and sends the "exit" command +/// to close the master connection. Errors are logged but not propagated. +fn cleanup_ssh_control_sockets(control_dir: &std::path::Path) { + debug!( + "Cleaning up SSH control sockets in {}", + control_dir.display() + ); + + // Read directory entries + let entries = match std::fs::read_dir(control_dir) { + Ok(entries) => entries, + Err(e) => { + // Directory might not exist if no SSH connections were made + debug!( + "Could not read SSH control directory {}: {}", + control_dir.display(), + e + ); + return; + }, + }; + + for entry in entries.flatten() { + let path = entry.path(); + + // Only process files starting with "ssh-" + if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { + if filename.starts_with("ssh-") { + debug!("Closing SSH control socket: {}", path.display()); + + // Run: ssh -o ControlPath= -O exit dummyhost + let result = Exec::cmd("ssh") + .args(&["-o", &format!("ControlPath={}", path.display())]) + .args(&["-O", "exit", "dummyhost"]) + .stdout(Redirection::Pipe) + .stderr(Redirection::Pipe) + .capture(); + + match result { + Ok(capture) => { + if !capture.exit_status.success() { + // This is normal if the connection was already closed + debug!( + "SSH control socket cleanup exited with status {:?} for {}", + capture.exit_status, + path.display() + ); + } + }, + Err(e) => { + tracing::warn!( + "Failed to close SSH control socket at {}: {}", + path.display(), + e + ); + }, + } + } + } + } +} + +/// Initialize SSH control socket management. +/// +/// Returns a guard that will clean up SSH `ControlMaster` connections when +/// dropped. The guard should be held for the duration of remote operations. +pub fn init_ssh_control() -> SshControlGuard { + let control_dir = get_ssh_control_dir().clone(); + SshControlGuard { control_dir } +} + +/// Cache for the SSH control socket directory. +static SSH_CONTROL_DIR: OnceLock = OnceLock::new(); + +/// Get or create the SSH control socket directory. +/// +/// This creates a temporary directory that persists for the lifetime of the +/// program, similar to nixos-rebuild-ng's tmpdir module. +fn get_ssh_control_dir() -> &'static PathBuf { + SSH_CONTROL_DIR.get_or_init(|| { + // Try to use XDG_RUNTIME_DIR first (usually /run/user/), fall back to + // /tmp + // XXX: I do not want to use the dirs crate just for this. + let base = env::var("XDG_RUNTIME_DIR") + .map_or_else(|_| PathBuf::from("/tmp"), PathBuf::from); + + let control_dir = base.join(format!("nh-ssh-{}", std::process::id())); + + // Create the directory if it doesn't exist + if let Err(e1) = std::fs::create_dir_all(&control_dir) { + debug!( + "Failed to create SSH control directory at {}: {e1}", + control_dir.display() + ); + + // Fall back to /tmp/nh-ssh- - try creating there instead + let fallback_dir = + PathBuf::from("/tmp").join(format!("nh-ssh-{}", std::process::id())); + + // As a last resort, if *all else* fails, we construct a unique + // subdirectory under /tmp with PID and full timestamp to preserve + // process isolation and avoid collisions between concurrent invocations + if let Err(e2) = std::fs::create_dir_all(&fallback_dir) { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| { + d.as_secs() * 1_000_000_000 + u64::from(d.subsec_nanos()) + }); + + let unique_dir = PathBuf::from("/tmp").join(format!( + "nh-ssh-{}-{}", + std::process::id(), + timestamp + )); + + if let Err(e3) = std::fs::create_dir_all(&unique_dir) { + error!( + "Failed to create SSH control directory after exhausting all \ + fallbacks. Errors: (1) {}: {e1}, (2) {}: {e2}, (3) {}: {e3}. SSH \ + operations will likely fail.", + control_dir.display(), + fallback_dir.display(), + unique_dir.display() + ); + + // Return the path anyway; SSH will fail with a clear error if + // directory creation is truly impossible, and at this point we + // are out of options. + return unique_dir; + } + + debug!( + "Created unique SSH control directory: {}", + unique_dir.display() + ); + return unique_dir; + } + return fallback_dir; + } + + control_dir + }) +} + +/// A parsed remote host specification. +/// +/// Handles various formats: +/// +/// - `hostname` +/// - `user@hostname` +/// - `ssh://[user@]hostname` (scheme stripped) +/// - `ssh-ng://[user@]hostname` (scheme stripped) +#[derive(Debug, Clone)] +pub struct RemoteHost { + /// The host string (may include user@) + host: String, +} + +impl RemoteHost { + /// Get the hostname part without the `user@` prefix. + /// + /// Used for hostname comparisons when determining if two `RemoteHost` + /// instances refer to the same physical host (e.g., detecting when + /// `build_host` == `target_host` regardless of different user credentials). + /// + /// Returns the bracketed IPv6 address as-is if present (e.g., + /// `[2001:db8::1]`). + /// + /// # Panics + /// + /// This function will never panic in practice because `rsplit('@').next()` + /// always returns at least one element (the original string if no '@' + /// exists). + #[must_use] + pub fn hostname(&self) -> &str { + #[allow(clippy::unwrap_used)] + self.host.rsplit('@').next().unwrap() + } + + /// Parse a host specification string. + /// + /// Accepts: + /// - `hostname` + /// - `user@hostname` + /// - `ssh://[user@]hostname` + /// - `ssh-ng://[user@]hostname` + /// + /// URI schemes are stripped since `--build-host` uses direct SSH. + /// + /// # Errors + /// + /// Returns an error if the host specification is invalid (empty hostname, + /// empty username, contains invalid characters like `:` or `/`). + pub fn parse(input: &str) -> Result { + // Strip URI schemes - we use direct SSH regardless + let host = input + .strip_prefix("ssh-ng://") + .or_else(|| input.strip_prefix("ssh://")) + .unwrap_or(input); + + if host.is_empty() { + bail!("Empty hostname in host specification"); + } + + // Validate: check for empty user in user@host format + if host.starts_with('@') { + bail!("Empty username in host specification: {input}"); + } + if host.ends_with('@') { + bail!("Empty hostname in host specification: {input}"); + } + + // Validate hostname doesn't contain invalid characters + // (after stripping any user@ prefix for the check) + let hostname_part = host.rsplit('@').next().unwrap_or(host); + if hostname_part.contains('/') { + bail!( + "Invalid hostname '{hostname_part}': contains '/'. Did you mean to \ + use a bare hostname?" + ); + } + + // Check for colons, but allow them in bracketed IPv6 addresses + if hostname_part.contains(':') { + // Check if this is a bracketed IPv6 address + let is_bracketed_ipv6 = + hostname_part.starts_with('[') && hostname_part.contains(']'); + + if !is_bracketed_ipv6 { + bail!( + "Invalid hostname '{}': contains ':'. Ports should be specified via \ + NIX_SSHOPTS=\"-p 2222\" or ~/.ssh/config", + hostname_part + ); + } + + // Validate bracket matching for IPv6 + if !hostname_part.ends_with(']') { + bail!( + "Invalid IPv6 address '{}': contains characters after closing \ + bracket", + hostname_part + ); + } + + let open_count = hostname_part.matches('[').count(); + let close_count = hostname_part.matches(']').count(); + if open_count != 1 || close_count != 1 { + bail!( + "Invalid IPv6 address '{}': mismatched brackets", + hostname_part + ); + } + } + + Ok(Self { + host: host.to_string(), + }) + } + + /// Get the SSH-compatible host string. + /// + /// Strips brackets from IPv6 addresses since SSH doesn't accept them. + /// Preserves zone IDs (`%eth0`) and `user@` prefix if present. + /// + /// Examples: + /// + /// - `[2001:db8::1]` -> `2001:db8::1` + /// - `user@[2001:db8::1]` -> `user@2001:db8::1` + /// - `[fe80::1%eth0]` -> `fe80::1%eth0` + /// - `host.example` -> `host.example` + #[must_use] + pub fn ssh_host(&self) -> String { + let hostname = self.hostname(); + + // Check for bracketed IPv6 address + if hostname.starts_with('[') && hostname.ends_with(']') { + let inner = &hostname[1..hostname.len() - 1]; + + // Validate it's actually a valid IPv6 address + // Split on '%' to validate only the address part (zone ID is + // SSH-specific) + let addr_part = inner.split('%').next().unwrap_or(inner); + if addr_part.parse::().is_ok() { + // Reconstruct with user@ prefix if present + if let Some(at_pos) = self.host.find('@') { + let user = &self.host[..at_pos]; + return format!("{user}@{inner}"); + } + return inner.to_string(); + } + } + + // Not IPv6 or not bracketed, return as-is + self.host.clone() + } +} + +impl std::fmt::Display for RemoteHost { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.host) + } +} + +/// Get the default SSH options for connection multiplexing. +/// Includes a `ControlPath` pointing to our control socket directory. +fn get_default_ssh_opts() -> Vec { + let control_dir = get_ssh_control_dir(); + let control_path = control_dir.join("ssh-%n"); + + vec![ + "-o".to_string(), + "ControlMaster=auto".to_string(), + "-o".to_string(), + format!("ControlPath={}", control_path.display()), + "-o".to_string(), + "ControlPersist=60".to_string(), + ] +} + +/// Shell-quote a string for safe passing through SSH to remote shell. +fn shell_quote(s: &str) -> String { + // Use shlex::try_quote for battle-tested quoting + // Returns Cow::Borrowed if no quoting needed, Cow::Owned if quoted + shlex::try_quote(s).map_or_else( + |_| format!("'{}'", s.replace('\'', r"'\''")), + std::borrow::Cow::into_owned, + ) +} + +/// Get SSH options from `NIX_SSHOPTS` plus our defaults. This includes +/// connection multiplexing options (`ControlMaster`, `ControlPath`, +/// `ControlPersist`) which enable efficient reuse of SSH connections. +pub fn get_ssh_opts() -> Vec { + let mut opts: Vec = Vec::new(); + + // User options first (from NIX_SSHOPTS) + if let Ok(sshopts) = env::var("NIX_SSHOPTS") { + if let Some(parsed) = shlex::split(&sshopts) { + opts.extend(parsed); + } else { + let truncated = sshopts.chars().take(60).collect::(); + let sshopts_display = if sshopts.len() > 60 { + format!("{truncated}...",) + } else { + truncated + }; + warn!( + "Failed to parse NIX_SSHOPTS, ignoring. Provide valid options or use \ + ~/.ssh/config. Value: {sshopts_display}", + ); + } + } + + // Then our defaults (including ControlPath) + opts.extend(get_default_ssh_opts()); + + opts +} + +/// Get `NIX_SSHOPTS` environment value with our defaults appended. +/// Used for `nix-copy-closure` which reads `NIX_SSHOPTS`. +/// +/// Note: `nix-copy-closure` splits `NIX_SSHOPTS` by whitespace without shell +/// parsing, so values containing spaces cannot be properly passed through +/// this mechanism. Users needing complex SSH options should use +/// `~/.ssh/config` instead. +fn get_nix_sshopts_env() -> String { + let user_opts = env::var("NIX_SSHOPTS").unwrap_or_default(); + let default_opts = get_default_ssh_opts(); + + if user_opts.is_empty() { + default_opts.join(" ") + } else { + // Append our defaults to user options + // NOTE: We preserve user options as-is since nix-copy-closure + // does simple whitespace splitting + format!("{} {}", user_opts, default_opts.join(" ")) + } +} + +/// Check if remote cleanup is enabled via environment variable. +/// +/// Returns `true` if `NH_REMOTE_CLEANUP` is set to a truthy value: +/// "1", "true", "yes" (case-insensitive). +/// +/// Returns `false` if unset, empty, or set to any other value. +fn should_cleanup_remote() -> bool { + env::var("NH_REMOTE_CLEANUP").is_ok_and(|val| { + let val = val.trim().to_lowercase(); + val == "1" || val == "true" || val == "yes" + }) +} + +/// Attempt to clean up a remote process using pkill. +/// +/// This is a best-effort (and opt-in) operation called when the user interrupts +/// a remote build. It tries to terminate the remote nix process via SSH and +/// pkill, but is inherently fragile due to the nature of remote building +/// semantics. +/// +/// # Arguments +/// +/// * `host` - The remote host where the process is running +/// * `remote_cmd` - The original command that was run remotely, used for pkill +/// matching +fn attempt_remote_cleanup(host: &RemoteHost, remote_cmd: &str) { + if !should_cleanup_remote() { + return; + } + + let ssh_opts = get_ssh_opts(); + let quoted_cmd = shell_quote(remote_cmd); // for safe passing through pkill's --full argument + + // Build the pkill command: + // pkill -INT --full '' will match the exact command line + let pkill_cmd = format!("pkill -INT --full {quoted_cmd}"); + + // Build SSH command with stderr capture for diagnostics + let mut ssh_cmd = Exec::cmd("ssh").stderr(Redirection::Pipe); + for opt in &ssh_opts { + ssh_cmd = ssh_cmd.arg(opt); + } + ssh_cmd = ssh_cmd.arg(host.ssh_host()).arg(&pkill_cmd); + + debug!("Attempting remote cleanup on '{host}': pkill -INT --full "); + + // Use popen with timeout to avoid hanging on unresponsive hosts + let mut process = match ssh_cmd.popen() { + Ok(p) => p, + Err(e) => { + info!("Failed to execute remote cleanup on '{host}': {e}"); + return; + }, + }; + + // Wait up to 5 seconds for cleanup to complete + let timeout = Duration::from_secs(5); + match process.wait_timeout(timeout) { + Ok(Some(_)) => { + // Process exited, check status below + }, + Ok(None) => { + // Timeout - kill the process and continue + let _ = process.kill(); + let _ = process.wait(); + info!("Remote cleanup on '{host}' timed out after 5 seconds"); + return; + }, + Err(e) => { + info!("Error waiting for remote cleanup on '{host}': {e}"); + return; + }, + } + + // Check exit status + if let Some(exit_status) = process.exit_status() { + if exit_status.success() { + info!("Cleaned up remote process on '{}'", host); + } else { + // Capture stderr for error diagnosis + let stderr = process.stderr.take().map_or_else(String::new, |mut e| { + let mut s = String::new(); + let _ = e.read_to_string(&mut s); + s + }); + let stderr_lower = stderr.to_lowercase(); + + if stderr.contains("No matching processes") + || stderr_lower.contains("0 processes") + { + debug!( + "No matching process found on '{host}' during cleanup (may have \ + already exited)" + ); + } else if stderr_lower.contains("not found") + || stderr_lower.contains("command not found") + { + info!("pkill not available on '{}', skipping remote cleanup", host); + } else if stderr_lower.contains("permission denied") + || stderr_lower.contains("operation not permitted") + { + info!( + "Permission denied for pkill on '{host}', skipping remote cleanup", + ); + } else { + info!("Remote cleanup on '{host}' returned non-zero exit status"); + } + } + } +} + +/// Get the flake experimental feature flags required for `nix` commands. +/// +/// Returns the flags needed for `--extra-experimental-features "nix-command +/// flakes"` based on the detected Nix variant: +/// +/// - Determinate Nix: No flags needed (features are stable) +/// - Nix/Lix: Returns `["--extra-experimental-features", "nix-command flakes"]` +/// +/// Technically this is inconsistent with our default behaviour, which is to +/// *warn* on missing features but since this is for *remote deployment* it is +/// safer to assist the user instead. Without those features, remote deployment +/// may never succeed. +fn get_flake_flags() -> Vec<&'static str> { + let variant = crate::util::get_nix_variant(); + match variant { + NixVariant::Determinate => vec![], + NixVariant::Nix | NixVariant::Lix => { + vec!["--extra-experimental-features", "nix-command flakes"] + }, + } +} + +/// Convert `OsString` arguments to UTF-8 Strings. +/// +/// Returns an error if any argument is not valid UTF-8. +fn convert_extra_args(extra_args: &[OsString]) -> Result> { + extra_args + .iter() + .map(|s| { + s.to_str() + .map(String::from) + .ok_or_else(|| eyre!("Extra argument is not valid UTF-8: {:?}", s)) + }) + .collect::>>() +} + +/// Run a command on a remote host via SSH. +fn run_remote_command( + host: &RemoteHost, + args: &[&str], + capture_stdout: bool, +) -> Result> { + let ssh_opts = get_ssh_opts(); + + debug!("Running remote command on {}: {}", host, args.join(" ")); + + let quoted_args: Vec = args.iter().map(|s| shell_quote(s)).collect(); + let remote_cmd = quoted_args.join(" "); + let mut cmd = Exec::cmd("ssh"); + for opt in &ssh_opts { + cmd = cmd.arg(opt); + } + cmd = cmd.arg(host.ssh_host()).arg(&remote_cmd); + + if capture_stdout { + cmd = cmd.stdout(Redirection::Pipe).stderr(Redirection::Pipe); + } + + let capture = cmd.capture().wrap_err_with(|| { + format!("Failed to execute command on remote host '{host}'") + })?; + + if !capture.exit_status.success() { + let stderr = capture.stderr_str(); + bail!( + "Remote command failed on '{}' (exit {:?}):\n{}", + host, + capture.exit_status, + stderr + ); + } + + if capture_stdout { + Ok(Some(capture.stdout_str().trim().to_string())) + } else { + Ok(None) + } +} + +/// Copy a Nix closure to a remote host. +fn copy_closure_to( + host: &RemoteHost, + path: &str, + use_substitutes: bool, +) -> Result<()> { + info!("Copying closure to build host '{}'", host); + + let mut cmd = Exec::cmd("nix-copy-closure") + .arg("--to") + .arg(host.ssh_host()); + + if use_substitutes { + cmd = cmd.arg("--use-substitutes"); + } + + cmd = cmd.arg(path).env("NIX_SSHOPTS", get_nix_sshopts_env()); + + debug!(?cmd, "nix-copy-closure --to"); + + let capture = cmd + .capture() + .wrap_err("Failed to copy closure to remote host")?; + + if !capture.exit_status.success() { + bail!( + "nix-copy-closure --to '{}' failed:\n{}", + host, + capture.stderr_str() + ); + } + + Ok(()) +} + +/// Validates that essential files exist in a closure on a remote host. +/// +/// Performs batched SSH checks using connection multiplexing. This is useful +/// for validating that a system closure contains all necessary files before +/// attempting activation. +/// +/// # Arguments +/// +/// * `host` - The remote host to check files on +/// * `closure_path` - The base path to the closure (e.g., +/// `/nix/store/xxx-nixos-system`) +/// * `essential_files` - Slice of (`relative_path`, `description`) tuples for +/// files to validate +/// * `context_info` - Optional context for error messages (e.g., "built on +/// 'host1'") +/// +/// # Returns +/// +/// Returns `Ok(())` if all files exist, or an error describing which files are +/// missing. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// - SSH connection to the remote host fails +/// - Any of the essential files are missing +/// - Path strings contain invalid UTF-8 +pub fn validate_closure_remote( + host: &RemoteHost, + closure_path: &Path, + essential_files: &[(&str, &str)], + context_info: Option<&str>, +) -> Result<()> { + // Build all test commands for batched execution + let test_commands: Vec = essential_files + .iter() + .map(|(file, _)| { + let remote_path = closure_path.join(file); + let path_str = remote_path.to_str().ok_or_else(|| { + eyre!("Path is not valid UTF-8: {}", remote_path.display()) + })?; + let quoted_path = shlex::try_quote(path_str).map_err(|_| { + eyre!("Failed to quote path for shell: {}", remote_path.display()) + })?; + Ok(format!("test -e {quoted_path}")) + }) + .collect::>>()?; + + // Join all tests with &&, so the command fails on first missing file + // We check each file individually afterward to identify which ones are + // missing + let batch_command = test_commands.join(" && "); + + // Get SSH options for connection multiplexing + let ssh_opts = get_ssh_opts(); + + // Execute batch check using SSH with proper connection multiplexing + let check_result = std::process::Command::new("ssh") + .args(&ssh_opts) + .arg(host.ssh_host()) + .arg(&batch_command) + .output(); + + // If batch check succeeds, all files exist + if let Ok(output) = &check_result { + if output.status.success() { + return Ok(()); + } + } + + // Batch check failed or errored. Identify which files are missing + let mut missing = Vec::new(); + for ((file, description), test_cmd) in + essential_files.iter().zip(&test_commands) + { + let check_result = std::process::Command::new("ssh") + .args(&ssh_opts) + .arg(host.ssh_host()) + .arg(test_cmd) + .output(); + + match check_result { + Ok(output) if !output.status.success() => { + missing.push(format!(" - {file} ({description})")); + }, + Err(e) => { + return Err(eyre!( + "Failed to check file existence on remote host {}: {}", + host, + e + )); + }, + _ => {}, // File exists + } + } + + if !missing.is_empty() { + let missing_list = missing.join("\n"); + + // Build context-aware error message + let host_context = context_info.map_or_else( + || format!("on remote host '{host}'"), + |ctx| format!("on remote host '{host}' ({ctx})"), + ); + + return Err(eyre!( + "Closure validation failed {}.\n\nMissing essential files in store path \ + '{}':\n{}\n\nThis typically happens when:\n1. Required system \ + components are disabled in your configuration\n2. The build was \ + incomplete or corrupted\n3. The Nix store path was not fully copied to \ + the target host\n\nTo fix this:\n1. Verify your configuration enables \ + all required components\n2. Ensure the complete closure was copied: \ + nix copy --to ssh://{} {}\n3. Rebuild your configuration if the \ + problem persists\n4. Use --no-validate to bypass this check if you're \ + certain the system is correctly configured", + host_context, + closure_path.display(), + missing_list, + host, + closure_path.display() + )); + } + + Ok(()) +} + +/// Copy a Nix closure from a remote host to localhost. +fn copy_closure_from( + host: &RemoteHost, + path: &str, + use_substitutes: bool, +) -> Result<()> { + info!("Copying result from build host '{host}'"); + + let mut cmd = Exec::cmd("nix-copy-closure") + .arg("--from") + .arg(host.ssh_host()); + + if use_substitutes { + cmd = cmd.arg("--use-substitutes"); + } + + cmd = cmd.arg(path).env("NIX_SSHOPTS", get_nix_sshopts_env()); + + debug!(?cmd, "nix-copy-closure --from"); + + let capture = cmd + .capture() + .wrap_err("Failed to copy closure from remote host")?; + + if !capture.exit_status.success() { + bail!( + "nix-copy-closure --from '{}' failed:\n{}", + host, + capture.stderr_str() + ); + } + + Ok(()) +} + +/// Copy a Nix closure from localhost to a remote host. +/// +/// Uses `nix copy --to ssh://host` to transfer a store path and its +/// dependencies from the local Nix store to a remote machine via SSH. +/// +/// When `use_substitutes` is enabled, the remote host will attempt to fetch +/// missing paths from configured binary caches instead of transferring them +/// over SSH, which can significantly improve performance and reduce bandwidth +/// usage. +/// +/// # Arguments +/// +/// * `host` - The remote host to copy the closure to. SSH connection +/// multiplexing and options from `NIX_SSHOPTS` are automatically applied. +/// * `path` - The store path to copy (e.g., `/nix/store/xxx-nixos-system`). All +/// dependencies (the complete closure) are copied automatically. +/// * `use_substitutes` - When `true`, adds `--substitute-on-destination` to +/// allow the remote host to fetch missing paths from binary caches instead of +/// transferring them over SSH. +/// +/// # Returns +/// +/// Returns `Ok(())` on success, or an error if the copy operation fails. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// - The SSH connection to the remote host fails +/// - The `nix copy` command fails (e.g., insufficient disk space on remote, +/// network issues, authentication failures) +/// - The path does not exist in the local store +pub fn copy_to_remote( + host: &RemoteHost, + path: &Path, + use_substitutes: bool, +) -> Result<()> { + info!("Copying closure to remote host '{}'", host); + + let flake_flags = get_flake_flags(); + let mut cmd = Exec::cmd("nix") + .args(&flake_flags) + .args(&["copy", "--to"]) + .arg(format!("ssh://{}", host.ssh_host())); + + if use_substitutes { + cmd = cmd.arg("--substitute-on-destination"); + } + + cmd = cmd.arg(path).env("NIX_SSHOPTS", get_nix_sshopts_env()); + + debug!(?cmd, "nix copy --to"); + + let capture = cmd + .capture() + .wrap_err("Failed to copy closure to remote host")?; + + if !capture.exit_status.success() { + bail!("nix copy --to '{}' failed:\n{}", host, capture.stderr_str()); + } + + Ok(()) +} + +/// Copy a Nix closure from one remote host to another. +/// Uses `nix copy --from ssh://source --to ssh://dest`. +fn copy_closure_between_remotes( + from_host: &RemoteHost, + to_host: &RemoteHost, + path: &str, + use_substitutes: bool, +) -> Result<()> { + info!("Copying closure from '{}' to '{}'", from_host, to_host); + + let flake_flags = get_flake_flags(); + let mut cmd = Exec::cmd("nix") + .args(&flake_flags) + .args(&["copy", "--from"]) + .arg(format!("ssh://{}", from_host.ssh_host())) + .arg("--to") + .arg(format!("ssh://{}", to_host.ssh_host())); + + if use_substitutes { + cmd = cmd.arg("--substitute-on-destination"); + } + + cmd = cmd.arg(path).env("NIX_SSHOPTS", get_nix_sshopts_env()); + + debug!(?cmd, "nix copy between remotes"); + + let capture = cmd + .capture() + .wrap_err("Failed to copy closure between remote hosts")?; + + if !capture.exit_status.success() { + bail!( + "nix copy from '{}' to '{}' failed:\n{}", + from_host, + to_host, + capture.stderr_str() + ); + } + + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Represents the type of activation to perform on a remote system. +/// +/// This determines which action the system's activation script will execute. +pub enum ActivationType { + /// Run the configuration in a test mode without activating + Test, + + /// Atomically switch to the new configuration + Switch, + + /// Make the new configuration the default boot option + Boot, +} + +impl ActivationType { + /// Get the string representation used by activation scripts. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Test => "test", + Self::Switch => "switch", + Self::Boot => "boot", + } + } +} + +/// Represents the target platform for remote operations. +/// +/// This enum allows the remote module to support multiple platforms while +/// keeping the implementation generic. Currently only NixOS is implemented. +/// Other platforms can be added in the future. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + /// NixOS system configuration + NixOS, + // TODO: Add Darwin and HomeManager support + // + // To add support for other platforms: + // + // 1. Add the platform variant to this enum + // 2. Implement platform-specific activation logic in a (private) function + // 3. Update `activate_remote()` to dispatch to the new platform handler + // Darwin, + // HomeManager, +} + +/// Configuration for remote activation operations. +#[derive(Debug)] +pub struct ActivateRemoteConfig { + /// The target platform for activation + pub platform: Platform, + + /// The type of activation to perform + pub activation_type: ActivationType, + + /// Whether to install the bootloader during activation + pub install_bootloader: bool, + + /// Whether to show output logs during activation + pub show_logs: bool, + + /// Elevation strategy for remote activation commands. + /// + /// - `None`: No elevation, run commands as the remote user + /// - `Some(strategy)`: Use the specified elevation strategy (sudo, doas, + /// etc.) + pub elevation: Option, +} + +/// Activate a system configuration on a remote host. +/// +/// Currently only supports NixOS. +/// +/// # Arguments +/// +/// * `host` - The remote host to activate on +/// * `system_profile` - The path to the NixOS system profile (e.g., +/// /nix/var/nix/profiles/system) +/// * `config` - Activation configuration options +/// +/// # Errors +/// +/// Returns an error if SSH connection fails or activation commands fail. +pub fn activate_remote( + host: &RemoteHost, + system_profile: &Path, + config: &ActivateRemoteConfig, +) -> Result<()> { + match config.platform { + Platform::NixOS => activate_nixos_remote(host, system_profile, config), + // TODO: + // Platform::Darwin => activate_darwin_remote(host, system_profile, config), + // Platform::HomeManager => activate_home_remote(host, system_profile, + // config), + } +} + +/// Activate a NixOS system configuration on a remote host. +/// +/// Handles the SSH commands required to activate a NixOS system. Supports +/// test, switch, and boot activation types. +/// +/// # Arguments +/// +/// * `host` - The remote host to activate on +/// * `system_profile` - The path to the NixOS system profile +/// * `config` - Activation configuration options +/// +/// # Errors +/// +/// Returns an error if SSH connection fails or activation commands fail. +fn activate_nixos_remote( + host: &RemoteHost, + system_profile: &Path, + config: &ActivateRemoteConfig, +) -> Result<()> { + let ssh_opts = get_ssh_opts(); + + // Prompt for password if elevation is needed + // Skip for None (no elevation) and Passwordless (remote has NOPASSWD + // configured) + let sudo_password = if let Some(ref strategy) = config.elevation { + if matches!( + strategy, + ElevationStrategy::None | ElevationStrategy::Passwordless + ) { + // None: no elevation program used + // Passwordless: elevation program used but no password needed + None + } else { + let host_str = host.ssh_host(); + if let Some(cached_password) = get_cached_password(&host_str)? { + Some(cached_password) + } else { + let password = + inquire::Password::new(&format!("[sudo] password for {host_str}:")) + .without_confirmation() + .prompt() + .context("Failed to read sudo password")?; + if password.is_empty() { + bail!("Password cannot be empty"); + } + let secret_password = SecretString::new(password.into()); + cache_password(&host_str, secret_password.clone())?; + Some(secret_password) + } + } + } else { + None + }; + + let switch_to_config = system_profile.join("bin/switch-to-configuration"); + + let switch_path_str = switch_to_config.to_str().ok_or_else(|| { + eyre!("switch-to-configuration path contains invalid UTF-8") + })?; + + match config.activation_type { + ActivationType::Test | ActivationType::Switch => { + let action = config.activation_type.as_str(); + + let mut ssh_cmd = Exec::cmd("ssh"); + for opt in &ssh_opts { + ssh_cmd = ssh_cmd.arg(opt); + } + // Add -T flag to disable pseudo-terminal allocation (needed for stdin) + ssh_cmd = ssh_cmd.arg("-T"); + ssh_cmd = ssh_cmd.arg(host.ssh_host()); + + // Build the remote command using helper function + let base_cmd = format!("{} {}", shell_quote(switch_path_str), action); + let remote_cmd = + build_remote_command(config.elevation.as_ref(), &base_cmd)?; + + ssh_cmd = ssh_cmd.arg(remote_cmd); + + // Pass password via stdin if elevation is needed + if let Some(ref password) = sudo_password { + ssh_cmd = + ssh_cmd.stdin(format!("{}\n", password.expose_secret()).as_str()); + } + + if config.show_logs { + ssh_cmd = ssh_cmd + .stdout(Redirection::Merge) + .stderr(Redirection::Merge); + } + + debug!(?ssh_cmd, "Activating NixOS configuration"); + + let capture = ssh_cmd + .capture() + .wrap_err("Failed to activate NixOS configuration")?; + + if !capture.exit_status.success() { + bail!( + "Activation ({}) failed on '{}':\n{}", + action, + host, + capture.stderr_str() + ); + } + }, + + ActivationType::Boot => { + let mut profile_ssh_cmd = Exec::cmd("ssh"); + for opt in &ssh_opts { + profile_ssh_cmd = profile_ssh_cmd.arg(opt); + } + // Add -T flag to disable pseudo-terminal allocation (needed for stdin) + profile_ssh_cmd = profile_ssh_cmd.arg("-T"); + profile_ssh_cmd = profile_ssh_cmd.arg(host.ssh_host()); + + // Build the remote command using helper function + let base_cmd = format!( + "nix build --no-link --profile {} {}", + NIXOS_SYSTEM_PROFILE, + shell_quote(&system_profile.to_string_lossy()) + ); + let profile_remote_cmd = + build_remote_command(config.elevation.as_ref(), &base_cmd)?; + + profile_ssh_cmd = profile_ssh_cmd.arg(profile_remote_cmd); + + // Pass password via stdin if elevation is needed + if let Some(ref password) = sudo_password { + profile_ssh_cmd = profile_ssh_cmd + .stdin(format!("{}\n", password.expose_secret()).as_str()); + } + + debug!(?profile_ssh_cmd, "Setting NixOS profile"); + + let profile_capture = profile_ssh_cmd + .capture() + .wrap_err("Failed to set NixOS profile")?; + + if !profile_capture.exit_status.success() { + bail!( + "Failed to set system profile on '{}':\n{}", + host, + profile_capture.stderr_str() + ); + } + + let mut boot_ssh_cmd = Exec::cmd("ssh"); + for opt in &ssh_opts { + boot_ssh_cmd = boot_ssh_cmd.arg(opt); + } + // Add -T flag to disable pseudo-terminal allocation (needed for stdin) + boot_ssh_cmd = boot_ssh_cmd.arg("-T"); + boot_ssh_cmd = boot_ssh_cmd.arg(host.ssh_host()); + + // Build the remote command using helper function + let boot_remote_cmd = if config.install_bootloader { + let base_cmd = format!( + "NIXOS_INSTALL_BOOTLOADER=1 {} boot", + shell_quote(switch_path_str) + ); + build_remote_command(config.elevation.as_ref(), &base_cmd)? + } else { + let base_cmd = format!("{} boot", shell_quote(switch_path_str)); + build_remote_command(config.elevation.as_ref(), &base_cmd)? + }; + + boot_ssh_cmd = boot_ssh_cmd.arg(boot_remote_cmd); + + // Pass password via stdin if elevation is needed + if let Some(ref password) = sudo_password { + boot_ssh_cmd = boot_ssh_cmd + .stdin(format!("{}\n", password.expose_secret()).as_str()); + } + + debug!(?boot_ssh_cmd, "Bootloader activation"); + + let boot_capture = boot_ssh_cmd + .capture() + .wrap_err("Bootloader activation failed")?; + + if !boot_capture.exit_status.success() { + bail!( + "Bootloader activation failed on '{}':\n{}", + host, + boot_capture.stderr_str() + ); + } + }, + } + + Ok(()) +} + +/// System profile path for NixOS. +/// Used by remote activation functions. +const NIXOS_SYSTEM_PROFILE: &str = "/nix/var/nix/profiles/system"; + +/// Evaluate a flake installable to get its derivation path. +/// Matches nixos-rebuild-ng: `nix eval --raw .drvPath` +fn eval_drv_path(installable: &Installable) -> Result { + // Build the installable with .drvPath appended + let drv_installable = match installable { + Installable::Flake { + reference, + attribute, + } => { + let mut drv_attr = attribute.clone(); + drv_attr.push("drvPath".to_string()); + Installable::Flake { + reference: reference.clone(), + attribute: drv_attr, + } + }, + Installable::File { path, attribute } => { + let mut drv_attr = attribute.clone(); + drv_attr.push("drvPath".to_string()); + Installable::File { + path: path.clone(), + attribute: drv_attr, + } + }, + Installable::Expression { + expression, + attribute, + } => { + let mut drv_attr = attribute.clone(); + drv_attr.push("drvPath".to_string()); + Installable::Expression { + expression: expression.clone(), + attribute: drv_attr, + } + }, + Installable::Store { path } => { + bail!( + "Cannot perform remote build with store path '{}'. Store paths are \ + already built.", + path.display() + ); + }, + Installable::Unspecified => { + bail!("Cannot evaluate unspecified installable"); + }, + }; + + let args = drv_installable.to_args(); + debug!("Evaluating drvPath: nix eval --raw {:?}", args); + + let flake_flags = get_flake_flags(); + let cmd = Exec::cmd("nix") + .args(&flake_flags) + .arg("eval") + .arg("--raw") + .args(&args) + .stdout(Redirection::Pipe) + .stderr(Redirection::Pipe); + + let capture = cmd.capture().wrap_err("Failed to run nix eval")?; + + if !capture.exit_status.success() { + bail!( + "Failed to evaluate derivation path:\n{}", + capture.stderr_str() + ); + } + + let drv_path = capture.stdout_str().trim().to_string(); + if drv_path.is_empty() { + bail!("nix eval returned empty derivation path"); + } + + debug!("Derivation path: {}", drv_path); + Ok(drv_path) +} + +/// Configuration for a remote build operation. +/// +/// # Host Interaction Semantics +/// +/// The behavior depends on which hosts are specified: +/// +/// | `build_host` | `target_host` | Behavior | +/// |--------------|---------------|----------| +/// | Some(H2) | None | Build on H2, copy result to localhost | +/// | Some(H2) | Some(H2) | Build on H2, no copy (build host = target) | +/// | Some(H2) | Some(H3) | Build on H2, try direct copy to H3; if that fails, relay through localhost | +/// +/// When `build_host` and `target_host` differ, the code attempts a direct +/// copy between remotes first. If this fails (common when the hosts can't +/// see each other), it falls back to relaying through localhost: +/// +/// - Direct: Host2 -> Host3 +/// - Fallback: Host2 -> Host1 (localhost) → Host3 +/// +/// If `out_link` is requested in `build_remote()`, the result is always +/// copied to localhost regardless of whether a direct copy succeeded, +/// because the symlink must point to a local store path. +#[derive(Debug, Clone)] +pub struct RemoteBuildConfig { + /// The host to build on + pub build_host: RemoteHost, + + /// Optional target host to copy the result to (instead of localhost). + /// When set, copies directly from `build_host` to `target_host`. + pub target_host: Option, + + /// Whether to use nix-output-monitor for build output + pub use_nom: bool, + + /// Whether to use substitutes when copying closures + pub use_substitutes: bool, + + /// Extra arguments to pass to the build command + pub extra_args: Vec, +} + +/// Perform a remote build of a flake installable. +/// +/// This implements the `build_remote_flake` workflow from nixos-rebuild-ng: +/// 1. Evaluate drvPath locally via `nix eval --raw` +/// 2. Copy the derivation to the build host via `nix-copy-closure` +/// 3. Build on remote host via `nix build ^* --print-out-paths` +/// 4. Copy the result back (to localhost or `target_host`) +/// +/// Returns the output path in the Nix store. +/// +/// # Errors +/// +/// Returns an error if any step fails (evaluation, copy, build). +pub fn build_remote( + installable: &Installable, + config: &RemoteBuildConfig, + out_link: Option<&std::path::Path>, +) -> Result { + let build_host = &config.build_host; + let use_substitutes = config.use_substitutes; + + // Step 1: Evaluate drvPath locally + info!("Evaluating derivation path"); + let drv_path = eval_drv_path(installable)?; + + // Step 2: Copy derivation to build host + copy_closure_to(build_host, &drv_path, use_substitutes)?; + + // Step 3: Build on remote + info!("Building on remote host '{}'", build_host); + let out_path = build_on_remote(build_host, &drv_path, config)?; + + // Step 4: Copy result to destination + // + // Optimizes copy paths based on hostname comparison: + // - When build_host != target_host: copy build -> target, then build -> local + // if needed + // - When build_host == target_host: skip redundant copies, only copy to local + // if out-link is needed + // - When target_host is None: always copy build -> local + let target_is_build_host = config + .target_host + .as_ref() + .is_some_and(|th| th.hostname() == build_host.hostname()); + + let need_local_copy = match &config.target_host { + None => true, + Some(_target_host) if target_is_build_host => { + debug!( + "Skipping copy from build host to target host (same host: {})", + build_host.hostname() + ); + + // When build_host == target_host and both are remote, the result is + // already where it needs to be. No need to copy to localhost even if + // out_link is requested, since the closure will be activated remotely. + // This is a little confusing, but frankly, respecting --out-link to + // create a local path while everything happens remotely is a bit + // more confusing. + false + }, + Some(target_host) => { + match copy_closure_between_remotes( + build_host, + target_host, + &out_path, + use_substitutes, + ) { + Ok(()) => { + debug!( + "Successfully copied closure directly from {} to {}", + build_host.hostname(), + target_host.hostname() + ); + out_link.is_some() + }, + Err(e) => { + warn!( + "Direct copy from {} to {} failed: {}. Will relay through \ + localhost.", + build_host.hostname(), + target_host.hostname(), + e + ); + true + }, + } + }, + }; + + if need_local_copy { + copy_closure_from(build_host, &out_path, use_substitutes)?; + } + + // Create local out-link if requested and the result is in local store + // When build_host == target_host (both remote), skip out-link creation + // since the closure is remote and won't be copied to localhost + if let Some(link) = out_link { + if need_local_copy { + debug!("Creating out-link: {} -> {}", link.display(), out_path); + // Remove existing symlink/file if present + let _ = std::fs::remove_file(link); + std::os::unix::fs::symlink(&out_path, link) + .wrap_err("Failed to create out-link")?; + } else { + debug!( + "Skipping out-link creation: result is on remote host and not copied \ + to localhost" + ); + } + } + + Ok(PathBuf::from(out_path)) +} + +/// Build a derivation on a remote host. +/// Returns the output path. +fn build_on_remote( + host: &RemoteHost, + drv_path: &str, + config: &RemoteBuildConfig, +) -> Result { + // Build command: nix build ^* --print-out-paths [extra_args...] + let drv_with_outputs = format!("{drv_path}^*"); + + if config.use_nom { + // Check that nom is available before attempting to use it + which::which("nom") + .wrap_err("nom (nix-output-monitor) is required but not found in PATH")?; + + // With nom: pipe through nix-output-monitor + build_on_remote_with_nom(host, &drv_with_outputs, config) + } else { + // Without nom: simple remote execution + build_on_remote_simple(host, &drv_with_outputs, config) + } +} + +/// Build the argument list for remote nix build commands. +/// Returns owned strings to avoid lifetime issues with `extra_args`. +fn build_nix_command( + drv_with_outputs: &str, + extra_flags: &[&str], + extra_args: &[OsString], +) -> Result> { + let flake_flags = get_flake_flags(); + let extra_args_strings = convert_extra_args(extra_args)?; + + let mut args = vec!["nix".to_string()]; + args.extend(flake_flags.iter().map(|s| (*s).to_string())); + args.push("build".to_string()); + args.push(drv_with_outputs.to_string()); + args.extend(extra_flags.iter().map(|s| (*s).to_string())); + args.extend(extra_args_strings); + + Ok(args) +} + +/// Build on remote without nom - just capture output. +fn build_on_remote_simple( + host: &RemoteHost, + drv_with_outputs: &str, + config: &RemoteBuildConfig, +) -> Result { + // Register interrupt handler at start + register_interrupt_handler()?; + + let ssh_opts = get_ssh_opts(); + + let args = build_nix_command( + drv_with_outputs, + &["--print-out-paths"], + &config.extra_args, + )?; + let arg_refs: Vec<&str> = + args.iter().map(std::string::String::as_str).collect(); + + // Build SSH command with stdout capture + // Quote all arguments for safe shell passing + let quoted_args: Vec = + arg_refs.iter().map(|s| shell_quote(s)).collect(); + let remote_cmd = quoted_args.join(" "); + + let mut ssh_cmd = Exec::cmd("ssh"); + for opt in &ssh_opts { + ssh_cmd = ssh_cmd.arg(opt); + } + ssh_cmd = ssh_cmd + .arg(host.ssh_host()) + .arg(&remote_cmd) + .stdout(Redirection::Pipe) + .stderr(Redirection::Pipe); + + // Execute with popen to get process handle + let mut process = ssh_cmd.popen()?; + + // Wait for completion with interrupt checking + let exit_status = loop { + match process.wait_timeout(std::time::Duration::from_millis(100))? { + Some(status) => break status, + None => { + // Check interrupt flag while waiting + if get_interrupt_flag().load(Ordering::Relaxed) { + debug!("Interrupt detected, killing SSH process"); + + let _ = process.kill(); + let _ = process.wait(); // reap zombie + + // Attempt remote cleanup if enabled + attempt_remote_cleanup(host, &remote_cmd); + + bail!("Operation interrupted by user"); + } + }, + } + }; + + // Check exit status + if !exit_status.success() { + let stderr = process + .stderr + .take() + .and_then(|mut e| { + let mut s = String::new(); + e.read_to_string(&mut s).ok().map(|_| s) + }) + .unwrap_or_else(|| String::from("(no stderr)")); + bail!("Remote command failed: {}", stderr); + } + + // Read stdout + let stdout = process + .stdout + .take() + .ok_or_else(|| eyre!("Failed to capture stdout"))?; + let mut reader = std::io::BufReader::new(stdout); + let mut output = String::new(); + reader.read_to_string(&mut output)?; + + // --print-out-paths may return multiple lines; take first + let out_path = output + .lines() + .next() + .ok_or_else(|| eyre!("Remote build returned empty output"))? + .trim() + .to_string(); + + debug!("Remote build output: {}", out_path); + Ok(out_path) +} + +/// Build on remote with nom - pipe through nix-output-monitor. +fn build_on_remote_with_nom( + host: &RemoteHost, + drv_with_outputs: &str, + config: &RemoteBuildConfig, +) -> Result { + // Register interrupt handler at start + register_interrupt_handler()?; + + let ssh_opts = get_ssh_opts(); + + // Build the remote command with JSON output for nom + let remote_args = build_nix_command( + drv_with_outputs, + &["--log-format", "internal-json", "--verbose"], + &config.extra_args, + )?; + let arg_refs: Vec<&str> = remote_args + .iter() + .map(std::string::String::as_str) + .collect(); + + // Build SSH command + // Quote all arguments for safe shell passing + let quoted_remote: Vec = + arg_refs.iter().map(|s| shell_quote(s)).collect(); + let remote_cmd = quoted_remote.join(" "); + + let mut ssh_cmd = Exec::cmd("ssh"); + for opt in &ssh_opts { + ssh_cmd = ssh_cmd.arg(opt); + } + ssh_cmd = ssh_cmd + .arg(host.ssh_host()) + .arg(&remote_cmd) + .stdout(Redirection::Pipe) + .stderr(Redirection::Merge); + + // Pipe through nom + let nom_cmd = Exec::cmd("nom").arg("--json"); + let pipeline = (ssh_cmd | nom_cmd).stdout(Redirection::None); + + debug!(?pipeline, "Running remote build with nom"); + + // Use popen() to get access to individual processes so we can check + // ssh's exit status, not nom's. The pipeline's join() only returns + // the exit status of the last command (nom), which always succeeds + // even when the remote nix command fails. + let mut processes = + pipeline.popen().wrap_err("Remote build with nom failed")?; + + // Use wait_timeout in a polling loop to check interrupt flag every 100ms + let poll_interval = Duration::from_millis(100); + + for proc in &mut processes { + #[allow( + clippy::needless_continue, + reason = "Better for explicitness and consistency" + )] + loop { + // Check interrupt flag before waiting + if get_interrupt_flag().load(Ordering::Relaxed) { + debug!("Interrupt detected during build with nom"); + // Kill remaining local processes. This will cause SSH to terminate + // the remote command automatically + for p in &mut processes { + let _ = p.kill(); + let _ = p.wait(); // reap zombie + } + + // Attempt remote cleanup if enabled + attempt_remote_cleanup(host, &remote_cmd); + + bail!("Operation interrupted by user"); + } + + // Poll process with timeout + match proc.wait_timeout(poll_interval)? { + Some(_) => { + // Process has exited, exit status is automatically cached in the + // Popen struct Move to next process + break; + }, + + None => { + // Timeout elapsed, process still running - loop continues + // and will check interrupt flag again + continue; + }, + } + } + } + + // Check the exit status of the FIRST process (ssh -> nix build) + // This is the one that matters. If the remote build fails, we should fail + // too + if let Some(ssh_proc) = processes.first() { + if let Some(exit_status) = ssh_proc.exit_status() { + match exit_status { + ExitStatus::Exited(0) => {}, + other => bail!("Remote build failed with exit status: {other:?}"), + } + } + } + + // nom consumed the output, so we need to query the output path separately + // Run nix build again with --print-out-paths (it will be a no-op since + // already built) + let query_args = + build_nix_command(drv_with_outputs, &["--print-out-paths"], &[])?; + let query_refs: Vec<&str> = + query_args.iter().map(std::string::String::as_str).collect(); + + let result = run_remote_command(host, &query_refs, true); + + // Check if interrupted during query + if get_interrupt_flag().load(Ordering::Relaxed) { + debug!("Interrupt detected during output path query"); + bail!("Operation interrupted by user"); + } + + let result = + result?.ok_or_else(|| eyre!("Failed to get output path after build"))?; + + let out_path = result + .lines() + .next() + .ok_or_else(|| eyre!("Output path query returned empty"))? + .trim() + .to_string(); + + debug!("Remote build output: {}", out_path); + Ok(out_path) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "Fine in tests" + )] + use proptest::prelude::*; + use serial_test::serial; + + use super::*; + + proptest! { + #[test] + fn hostname_always_returns_suffix_after_last_at(s in "\\PC*") { + let host = RemoteHost { host: s.clone() }; + let expected = s.rsplit('@').next().unwrap(); + prop_assert_eq!(host.hostname(), expected); + } + + #[test] + fn hostname_is_substring_of_host(s in "\\PC*") { + let host = RemoteHost { host: s.clone() }; + prop_assert!(s.contains(host.hostname())); + } + + #[test] + fn hostname_no_at_means_whole_string(s in "[^@]*") { + let host = RemoteHost { host: s.clone() }; + prop_assert_eq!(host.hostname(), s); + } + + #[test] + fn hostname_with_user(user in "[a-zA-Z0-9_]+", hostname in "[a-zA-Z0-9_.-]+") { + let full = format!("{user}@{hostname}"); + let host = RemoteHost { host: full }; + prop_assert_eq!(host.hostname(), hostname); + } + + #[test] + fn parse_valid_bare_hostname(hostname in "[a-zA-Z0-9_.-]+") { + let result = RemoteHost::parse(&hostname); + prop_assert!(result.is_ok()); + let host = result.unwrap(); + prop_assert_eq!(host.hostname(), hostname); + } + + #[test] + fn parse_valid_user_at_hostname(user in "[a-zA-Z0-9_]+", hostname in "[a-zA-Z0-9_.-]+") { + let full = format!("{user}@{hostname}"); + let result = RemoteHost::parse(&full); + prop_assert!(result.is_ok()); + let host = result.unwrap(); + prop_assert_eq!(host.hostname(), hostname); + } + } + + #[test] + fn test_parse_bare_hostname() { + let host = RemoteHost::parse("buildserver").expect("should parse"); + assert_eq!(host.to_string(), "buildserver"); + } + + #[test] + fn test_parse_user_at_hostname() { + let host = RemoteHost::parse("root@buildserver").expect("should parse"); + assert_eq!(host.to_string(), "root@buildserver"); + } + + #[test] + fn test_parse_ssh_uri_stripped() { + let host = RemoteHost::parse("ssh://buildserver").expect("should parse"); + assert_eq!(host.to_string(), "buildserver"); + } + + #[test] + fn test_parse_ssh_ng_uri_stripped() { + let host = RemoteHost::parse("ssh-ng://buildserver").expect("should parse"); + assert_eq!(host.to_string(), "buildserver"); + } + + #[test] + fn test_parse_ssh_uri_with_user() { + let host = + RemoteHost::parse("ssh://root@buildserver").expect("should parse"); + assert_eq!(host.to_string(), "root@buildserver"); + } + + #[test] + fn test_parse_ssh_ng_uri_with_user() { + let host = + RemoteHost::parse("ssh-ng://admin@buildserver").expect("should parse"); + assert_eq!(host.to_string(), "admin@buildserver"); + } + + #[test] + fn test_parse_empty_fails() { + assert!(RemoteHost::parse("").is_err()); + } + + #[test] + fn test_parse_empty_user_fails() { + assert!(RemoteHost::parse("@hostname").is_err()); + } + + #[test] + fn test_parse_empty_hostname_fails() { + assert!(RemoteHost::parse("user@").is_err()); + } + + #[test] + fn test_parse_port_rejected() { + let Err(err) = RemoteHost::parse("hostname:22") else { + panic!("expected error for port in hostname"); + }; + assert!(err.to_string().contains("NIX_SSHOPTS")); + } + + #[test] + fn test_parse_ipv6_bracketed() { + let host = RemoteHost::parse("[2001:db8::1]").expect("should parse IPv6"); + assert_eq!(host.to_string(), "[2001:db8::1]"); + assert_eq!(host.hostname(), "[2001:db8::1]"); + } + + #[test] + fn test_parse_ipv6_with_user() { + let host = RemoteHost::parse("root@[2001:db8::1]") + .expect("should parse IPv6 with user"); + assert_eq!(host.to_string(), "root@[2001:db8::1]"); + assert_eq!(host.hostname(), "[2001:db8::1]"); + } + + #[test] + fn test_parse_ipv6_with_zone_id() { + let host = + RemoteHost::parse("[fe80::1%eth0]").expect("should parse IPv6 with zone"); + assert_eq!(host.to_string(), "[fe80::1%eth0]"); + } + + #[test] + fn test_parse_ipv6_ssh_uri() { + let host = RemoteHost::parse("ssh://[2001:db8::1]") + .expect("should parse IPv6 SSH URI"); + assert_eq!(host.to_string(), "[2001:db8::1]"); + } + + #[test] + fn test_parse_ipv6_ssh_uri_with_user() { + let host = RemoteHost::parse("ssh://root@[2001:db8::1]") + .expect("should parse IPv6 SSH URI with user"); + assert_eq!(host.to_string(), "root@[2001:db8::1]"); + } + + #[test] + fn test_parse_ipv6_localhost() { + let host = RemoteHost::parse("[::1]").expect("should parse IPv6 localhost"); + assert_eq!(host.to_string(), "[::1]"); + } + + #[test] + fn test_parse_ipv6_compressed() { + let host = + RemoteHost::parse("[2001:db8::]").expect("should parse compressed IPv6"); + assert_eq!(host.to_string(), "[2001:db8::]"); + } + + #[test] + fn test_parse_ipv6_unbracketed_rejected() { + // Bare IPv6 without brackets should be rejected + let result = RemoteHost::parse("2001:db8::1"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("NIX_SSHOPTS")); + } + + #[test] + fn test_parse_ipv6_mismatched_brackets_rejected() { + assert!(RemoteHost::parse("[2001:db8::1").is_err()); + assert!(RemoteHost::parse("2001:db8::1]").is_err()); + } + + #[test] + fn test_parse_ipv6_extra_brackets_rejected() { + assert!(RemoteHost::parse("[[2001:db8::1]]").is_err()); + assert!(RemoteHost::parse("[2001:db8::[1]]").is_err()); + } + + #[test] + fn test_parse_ipv6_with_port_rejected() { + // IPv6 with port syntax should be rejected (use NIX_SSHOPTS) + let result = RemoteHost::parse("[2001:db8::1]:22"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_ipv6_chars_after_bracket_rejected() { + // Characters after closing bracket should be rejected + let result = RemoteHost::parse("[2001:db8::1]extra"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_ipv6_at_inside_brackets_rejected() { + // @ character inside brackets should be rejected (not valid IPv6) + // This ensures [@2001:db8::1] and [2001@db8::1] are both rejected + let result = RemoteHost::parse("[@2001:db8::1]"); + assert!(result.is_err(), "[@2001:db8::1] should be rejected"); + + let result2 = RemoteHost::parse("[2001@db8::1]"); + assert!(result2.is_err(), "[2001@db8::1] should be rejected"); + } + + #[test] + fn test_ssh_host_ipv6_strips_brackets() { + let host = RemoteHost::parse("[2001:db8::1]").expect("should parse IPv6"); + assert_eq!(host.ssh_host(), "2001:db8::1"); + } + + #[test] + fn test_ssh_host_ipv6_with_user() { + let host = RemoteHost::parse("user@[2001:db8::1]").expect("should parse"); + assert_eq!(host.ssh_host(), "user@2001:db8::1"); + } + + #[test] + fn test_ssh_host_ipv6_with_zone_id() { + let host = RemoteHost::parse("[fe80::1%eth0]").expect("should parse"); + assert_eq!(host.ssh_host(), "fe80::1%eth0"); + } + + #[test] + fn test_ssh_host_ipv6_with_zone_id_and_user() { + let host = RemoteHost::parse("user@[fe80::1%eth0]").expect("should parse"); + assert_eq!(host.ssh_host(), "user@fe80::1%eth0"); + } + + #[test] + fn test_ssh_host_ipv6_localhost() { + let host = RemoteHost::parse("[::1]").expect("should parse"); + assert_eq!(host.ssh_host(), "::1"); + } + + #[test] + fn test_ssh_host_non_ipv6_unchanged() { + let host = RemoteHost::parse("host.example").expect("should parse"); + assert_eq!(host.ssh_host(), "host.example"); + } + + #[test] + fn test_ssh_host_non_ipv6_with_user() { + let host = RemoteHost::parse("user@host.example").expect("should parse"); + assert_eq!(host.ssh_host(), "user@host.example"); + } + + #[test] + fn test_ssh_host_ssh_uri_ipv6() { + let host = RemoteHost::parse("ssh://[2001:db8::1]").expect("should parse"); + assert_eq!(host.ssh_host(), "2001:db8::1"); + } + + #[test] + fn test_ssh_host_ssh_uri_ipv6_with_user() { + let host = + RemoteHost::parse("ssh://root@[2001:db8::1]").expect("should parse"); + assert_eq!(host.ssh_host(), "root@2001:db8::1"); + } + + #[test] + fn test_shell_quote_simple() { + assert_eq!(shell_quote("simple"), "simple"); + assert_eq!( + shell_quote("/nix/store/abc123-foo"), + "/nix/store/abc123-foo" + ); + } + + #[test] + #[serial] + fn test_get_ssh_opts_default() { + // Clear env var for test + unsafe { + std::env::remove_var("NIX_SSHOPTS"); + } + let opts = get_ssh_opts(); + assert!(opts.contains(&"-o".to_string())); + assert!(opts.contains(&"ControlMaster=auto".to_string())); + assert!(opts.contains(&"ControlPersist=60".to_string())); + // Check that ControlPath is present (the exact path varies) + assert!(opts.iter().any(|o| o.starts_with("ControlPath="))); + } + + #[test] + #[serial] + fn test_get_ssh_opts_with_simple_nix_sshopts() { + unsafe { + std::env::set_var("NIX_SSHOPTS", "-p 2222 -i /path/to/key"); + } + let opts = get_ssh_opts(); + // User options should be included + assert!(opts.contains(&"-p".to_string())); + assert!(opts.contains(&"2222".to_string())); + assert!(opts.contains(&"-i".to_string())); + assert!(opts.contains(&"/path/to/key".to_string())); + // Default options should still be present + assert!(opts.contains(&"ControlMaster=auto".to_string())); + unsafe { + std::env::remove_var("NIX_SSHOPTS"); + } + } + + #[test] + #[serial] + fn test_get_ssh_opts_with_quoted_nix_sshopts() { + // Test that quoted paths with spaces are handled correctly + unsafe { + std::env::set_var("NIX_SSHOPTS", r#"-i "/path/with spaces/key""#); + } + let opts = get_ssh_opts(); + // The path should be parsed as a single argument without quotes + assert!(opts.contains(&"-i".to_string())); + assert!(opts.contains(&"/path/with spaces/key".to_string())); + // Default options should still be present + assert!(opts.contains(&"ControlMaster=auto".to_string())); + unsafe { + std::env::remove_var("NIX_SSHOPTS"); + } + } + + #[test] + #[serial] + fn test_get_ssh_opts_with_option_value_nix_sshopts() { + // Test -o with quoted value containing spaces + unsafe { + std::env::set_var( + "NIX_SSHOPTS", + r#"-o "ProxyCommand=ssh -W %h:%p jump""#, + ); + } + let opts = get_ssh_opts(); + assert!(opts.contains(&"-o".to_string())); + assert!(opts.contains(&"ProxyCommand=ssh -W %h:%p jump".to_string())); + unsafe { + std::env::remove_var("NIX_SSHOPTS"); + } + } + + #[test] + fn test_shell_quote_behavior() { + // Verify shell_quote adds quotes when needed + assert_eq!(shell_quote("simple"), "simple"); + assert_eq!(shell_quote("has space"), "'has space'"); + // shlex::try_quote uses double quotes when string contains single quote + assert_eq!(shell_quote("has'quote"), "\"has'quote\""); + } + + #[test] + fn test_shell_quote_roundtrip() { + // Test that quoting and then parsing gives back the original + let test_cases = vec![ + "simple", + "/nix/store/abc123-foo", + "has space", + "has'quote", + "has\"doublequote", + "$(dangerous)", + "path/with spaces/and'quotes", + ]; + + for original in test_cases { + let quoted = shell_quote(original); + // Parse the quoted string back - should give single element + let parsed = shlex::split("ed); + assert!( + parsed.is_some(), + "Failed to parse quoted string for: {original}" + ); + let parsed = parsed.expect("checked above"); + assert_eq!( + parsed.len(), + 1, + "Expected single element for: {original}, got: {parsed:?}" + ); + assert_eq!( + parsed[0], original, + "Roundtrip failed for: {original}, quoted as: {quoted}" + ); + } + } + + #[test] + fn test_shell_quote_nix_drv_output() { + // Test the drv^* syntax used by nix + let drv_path = "/nix/store/abc123.drv^*"; + let quoted = shell_quote(drv_path); + let parsed = shlex::split("ed).expect("should parse"); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0], drv_path); + } + + #[test] + fn test_shell_quote_preserves_equals() { + // Environment variable assignments should work + let env_var = "PATH=/usr/bin:/bin"; + let quoted = shell_quote(env_var); + let parsed = shlex::split("ed).expect("should parse"); + assert_eq!(parsed[0], env_var); + } + + #[test] + fn test_shell_quote_unicode() { + // Unicode should be preserved + let unicode = "path/with/émojis/🚀"; + let quoted = shell_quote(unicode); + let parsed = shlex::split("ed).expect("should parse"); + assert_eq!(parsed[0], unicode); + } + + #[test] + #[serial] + fn test_get_nix_sshopts_env_empty() { + unsafe { + std::env::remove_var("NIX_SSHOPTS"); + } + let result = get_nix_sshopts_env(); + // Should contain our defaults as space-separated values + assert!(result.contains("-o")); + assert!(result.contains("ControlMaster=auto")); + assert!(result.contains("ControlPersist=60")); + // Should contain ControlPath (exact path varies) + assert!(result.contains("ControlPath=")); + } + + #[test] + #[serial] + fn test_get_nix_sshopts_env_simple() { + unsafe { + std::env::set_var("NIX_SSHOPTS", "-p 2222"); + } + let result = get_nix_sshopts_env(); + // User options should come first + assert!(result.starts_with("-p 2222")); + // Defaults should be appended + assert!(result.contains("ControlMaster=auto")); + unsafe { + std::env::remove_var("NIX_SSHOPTS"); + } + } + + #[test] + #[serial] + fn test_get_nix_sshopts_env_preserves_user_opts() { + // User options are preserved as-is (nix-copy-closure does whitespace split) + unsafe { + std::env::set_var("NIX_SSHOPTS", "-i /path/to/key -p 22"); + } + let result = get_nix_sshopts_env(); + // User options preserved at start + assert!(result.starts_with("-i /path/to/key -p 22")); + // Our defaults appended + assert!(result.contains("ControlMaster=auto")); + unsafe { + std::env::remove_var("NIX_SSHOPTS"); + } + } + + #[test] + #[serial] + fn test_get_nix_sshopts_env_no_extra_quoting() { + // Verify we don't add shell quotes (nix-copy-closure doesn't parse them) + unsafe { + std::env::remove_var("NIX_SSHOPTS"); + } + let result = get_nix_sshopts_env(); + // Should NOT contain shell quote characters around our options + assert!(!result.contains("'ControlMaster")); + assert!(!result.contains("\"ControlMaster")); + // Values should be bare + assert!(result.contains("-o ControlMaster=auto")); + } + + #[test] + fn test_hostname_comparison_for_same_host() { + let host1 = RemoteHost::parse("user1@host.example").unwrap(); + let host2 = RemoteHost::parse("user2@host.example").unwrap(); + let host3 = RemoteHost::parse("host.example").unwrap(); + let host4 = RemoteHost::parse("other.host").unwrap(); + + assert_eq!(host1.hostname(), "host.example"); + assert_eq!(host2.hostname(), "host.example"); + assert_eq!(host3.hostname(), "host.example"); + assert_eq!(host4.hostname(), "other.host"); + + assert_eq!(host1.hostname(), host2.hostname()); + assert_eq!(host1.hostname(), host3.hostname()); + assert_ne!(host1.hostname(), host4.hostname()); + } + + #[test] + fn test_get_ssh_control_dir_creates_directory() { + let dir = get_ssh_control_dir(); + // The directory should exist in normal operation. In extreme edge cases + // (read-only /tmp), the function returns a path that may not exist, and + // SSH will fail with a clear error when attempting to use it. + assert!( + dir.exists(), + "Control dir should exist in normal operation: {}", + dir.display() + ); + + // Should contain our process-specific suffix + let dir_str = dir.to_string_lossy(); + assert!( + dir_str.contains("nh-ssh-"), + "Control dir should contain 'nh-ssh-': {dir_str}" + ); + } + + #[test] + fn test_init_ssh_control_returns_guard() { + // Verify that init_ssh_control() returns a guard + // and that the guard holds the correct control directory + let guard = init_ssh_control(); + let expected_dir = get_ssh_control_dir(); + + // Verify the guard holds the same directory + assert_eq!(guard.control_dir, *expected_dir); + } + + #[test] + fn test_ssh_control_guard_drop() { + // Verify that dropping the guard doesn't panic + // We can't easily test the actual cleanup without creating real SSH + // connections, but we can at least verify the Drop implementation runs + let guard = init_ssh_control(); + drop(guard); + // If this completes without panic, the Drop impl is at least safe + } + + proptest! { + #[test] + #[serial] + fn test_should_cleanup_remote_enabled_by_valid_values( + value in prop_oneof![ + Just("1"), + Just("true"), + Just("yes"), + Just("TRUE"), + Just("YES"), + Just("True"), + ] + ) { + unsafe { + std::env::set_var("NH_REMOTE_CLEANUP", value); + } + prop_assert!(should_cleanup_remote()); + unsafe { + std::env::remove_var("NH_REMOTE_CLEANUP"); + } + } + } + + #[test] + #[serial] + fn test_should_cleanup_remote_empty_disabled() { + // Empty value should NOT enable cleanup + unsafe { + std::env::set_var("NH_REMOTE_CLEANUP", ""); + } + assert!(!should_cleanup_remote()); + unsafe { + std::env::remove_var("NH_REMOTE_CLEANUP"); + } + } + + #[test] + #[serial] + fn test_should_cleanup_remote_arbitrary_value_disabled() { + // Arbitrary values should NOT enable cleanup + unsafe { + std::env::set_var("NH_REMOTE_CLEANUP", "maybe"); + } + assert!(!should_cleanup_remote()); + unsafe { + std::env::remove_var("NH_REMOTE_CLEANUP"); + } + } + + #[test] + fn test_attempt_remote_cleanup_does_nothing_when_disabled() { + // When should_cleanup_remote returns false, no SSH command should be + // executed. We can't easily verify no SSH was spawned, but we can verify + // the function doesn't panic or error when cleanup is disabled + let host = RemoteHost::parse("user@host.example").unwrap(); + let remote_cmd = "nix build /nix/store/abc.drv^* --print-out-paths"; + + // This should complete without error even when cleanup is disabled + attempt_remote_cleanup(&host, remote_cmd); + // If we reach here, the function handled the disabled case gracefully + } +} diff --git a/src/util/platform.rs b/src/util/platform.rs index 048db99c..606c2839 100644 --- a/src/util/platform.rs +++ b/src/util/platform.rs @@ -490,27 +490,6 @@ pub fn get_target_hostname( Ok((target_hostname, hostname_mismatch)) } -/// Common function to activate configurations in `NixOS` -pub fn activate_nixos_configuration( - target_profile: &Path, - variant: &str, - target_host: Option, - elevate: bool, - message: &str, -) -> Result<()> { - let switch_to_configuration = target_profile.join("bin").join("switch-to-configuration"); - let switch_to_configuration = switch_to_configuration.canonicalize().map_err(|e| { - color_eyre::eyre::eyre!("Failed to canonicalize switch-to-configuration path: {}", e) - })?; - - commands::Command::new(switch_to_configuration) - .arg(variant) - .ssh(target_host) - .message(message) - .elevate(elevate) - .run() -} - /// Configuration options for rebuilding workflows pub struct RebuildWorkflowConfig<'a> { /// The Nix installable representing the configuration diff --git a/xtask/src/man.rs b/xtask/src/man.rs index 92f9b9e3..c1a961ff 100644 --- a/xtask/src/man.rs +++ b/xtask/src/man.rs @@ -46,6 +46,122 @@ pub fn generate(out_dir: &str) -> Result<(), String> { .to_writer(&mut buffer) .map_err(|e| format!("Failed to write exit status section: {}", e))?; + // ENVIRONMENT section + let env_vars = [ + ( + "NH_NO_CHECKS", + "When set (any non-empty value), skips startup checks such as Nix \ + version and experimental feature validation. Useful for generating \ + completions or running in constrained build environments.", + ), + ( + "NH_FLAKE", + "Preferred path/reference to a directory containing your flake.nix used \ + by NH when running flake-based commands. Historically FLAKE was used; \ + NH will migrate FLAKE into NH_FLAKE if present.", + ), + ( + "NH_OS_FLAKE", + "Command-specific flake reference for nh os commands. Takes precedence \ + over NH_FLAKE.", + ), + ( + "NH_HOME_FLAKE", + "Command-specific flake reference for nh home commands. Takes \ + precedence over NH_FLAKE.", + ), + ( + "NH_DARWIN_FLAKE", + "Command-specific flake reference for nh darwin commands. Takes \ + precedence over NH_FLAKE.", + ), + ( + "NH_SUDO_ASKPASS", + "Path to a program used as SUDO_ASKPASS when NH self-elevates with sudo.", + ), + ( + "NH_PRESERVE_ENV", + "Controls whether environment variables marked for preservation are \ + passed to elevated commands. Set to \"0\" to disable, \"1\" to force. \ + If unset, defaults to enabled.", + ), + ( + "NH_SHOW_ACTIVATION_LOGS", + "Controls whether activation output is displayed. By default, \ + activation output is hidden. Setting to \"1\" shows full logs.", + ), + ( + "NH_LOG", + "Sets the tracing/log filter for NH. Uses tracing_subscriber format \ + (e.g., nh=trace).", + ), + ( + "NH_NOM", + "Control whether nix-output-monitor (nom) is enabled for build \ + processes. Equivalent of --no-nom.", + ), + ( + "NH_REMOTE_CLEANUP", + "Whether to clean up remote processes on interrupt via pkill. Opt-in \ + due to fragile behavior.", + ), + ( + "NIXOS_INSTALL_BOOTLOADER", + "Forwarded to switch-to-configuration. If true, forces bootloader \ + installation. Also available via --install-bootloader.", + ), + ( + "NIX_SSHOPTS", + "SSH options passed to Nix commands for remote builds.", + ), + ( + "NIX_CONFIG", + "Nix configuration options passed to all Nix commands.", + ), + ( + "NIX_REMOTE", + "Remote store configuration for Nix operations.", + ), + ( + "NIX_SSL_CERT_FILE", + "SSL certificate file for Nix HTTPS operations.", + ), + ("NIX_USER_CONF_FILES", "User configuration files for Nix."), + ]; + let mut sect = Roff::new(); + sect.control("SH", ["ENVIRONMENT"]); + for (var, desc) in env_vars { + sect.control("IP", [var]).text([roman(desc)]); + } + sect + .to_writer(&mut buffer) + .map_err(|e| format!("Failed to write environment section: {}", e))?; + + // FILES section + let files = [ + ( + "/nix/var/nix/profiles/system", + "System profile directory containing system generations.", + ), + ( + "/run/current-system", + "Symlink to the currently active system profile.", + ), + ( + "/etc/specialisation", + "NixOS specialisation detection. Contains the active specialisation \ + name.", + ), + ]; + let mut sect = Roff::new(); + sect.control("SH", ["FILES"]); + for (path, desc) in files { + sect.control("IP", [path]).text([roman(desc)]); + } + sect + .to_writer(&mut buffer) + .map_err(|e| format!("Failed to write files section: {}", e))?; + // EXAMPLES section let examples = [ (