diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b64380a..259db50 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -43,6 +43,7 @@ impl CommandExecute for NixInstallerCli { match subcommand { NixInstallerSubcommand::Plan(plan) => plan.execute().await, + NixInstallerSubcommand::SelfTest(self_test) => self_test.execute().await, NixInstallerSubcommand::Install(install) => install.execute().await, NixInstallerSubcommand::Uninstall(revert) => revert.execute().await, } diff --git a/src/cli/subcommand/install.rs b/src/cli/subcommand/install.rs index 8133cab..9b87a1f 100644 --- a/src/cli/subcommand/install.rs +++ b/src/cli/subcommand/install.rs @@ -227,7 +227,7 @@ impl CommandExecute for Install { Err(err) => { if !no_confirm { // Attempt to copy self to the store if possible, but since the install failed, this might not work, that's ok. - copy_self_to_nix_store().await.ok(); + copy_self_to_nix_dir().await.ok(); let mut was_expected = false; if let Some(expected) = err.expected() { @@ -301,7 +301,7 @@ impl CommandExecute for Install { } }, Ok(_) => { - copy_self_to_nix_store() + copy_self_to_nix_dir() .await .wrap_err("Copying `nix-installer` to `/nix/nix-installer`")?; println!( @@ -335,7 +335,7 @@ impl CommandExecute for Install { } #[tracing::instrument(level = "debug")] -async fn copy_self_to_nix_store() -> Result<(), std::io::Error> { +async fn copy_self_to_nix_dir() -> Result<(), std::io::Error> { let path = std::env::current_exe()?; tokio::fs::copy(path, "/nix/nix-installer").await?; tokio::fs::set_permissions("/nix/nix-installer", PermissionsExt::from_mode(0o0755)).await?; diff --git a/src/cli/subcommand/mod.rs b/src/cli/subcommand/mod.rs index 3429fcd..466bbfc 100644 --- a/src/cli/subcommand/mod.rs +++ b/src/cli/subcommand/mod.rs @@ -4,10 +4,13 @@ mod install; use install::Install; mod uninstall; use uninstall::Uninstall; +mod self_test; +use self_test::SelfTest; #[derive(Debug, clap::Subcommand)] pub enum NixInstallerSubcommand { Plan(Plan), Install(Install), Uninstall(Uninstall), + SelfTest(SelfTest), } diff --git a/src/cli/subcommand/self_test.rs b/src/cli/subcommand/self_test.rs new file mode 100644 index 0000000..e768cc3 --- /dev/null +++ b/src/cli/subcommand/self_test.rs @@ -0,0 +1,26 @@ +use std::process::ExitCode; + +use clap::Parser; + +use crate::cli::CommandExecute; + +/// Run a self test of Nix to ensure that the install worked. +#[derive(Debug, Parser)] +pub struct SelfTest {} + +#[async_trait::async_trait] +impl CommandExecute for SelfTest { + #[tracing::instrument(level = "debug", skip_all, fields())] + async fn execute(self) -> eyre::Result { + crate::self_test::self_test().await?; + + tracing::info!( + shells = ?crate::self_test::Shell::discover() + .iter() + .map(|v| v.executable()) + .collect::>(), + "Successfully tested Nix install in all discovered shells." + ); + Ok(ExitCode::SUCCESS) + } +} diff --git a/src/error.rs b/src/error.rs index 30c714f..3c8e641 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,7 +2,10 @@ use std::{error::Error, path::PathBuf}; use semver::Version; -use crate::{action::ActionError, planner::PlannerError, settings::InstallSettingsError}; +use crate::{ + action::ActionError, planner::PlannerError, self_test::SelfTestError, + settings::InstallSettingsError, +}; /// An error occurring during a call defined in this crate #[non_exhaustive] @@ -11,6 +14,13 @@ pub enum NixInstallerError { /// An error originating from an [`Action`](crate::action::Action) #[error("Error executing action")] Action(#[source] ActionError), + /// An error originating from a [`self_test`](crate::self_test) + #[error("Self test")] + SelfTest( + #[source] + #[from] + SelfTestError, + ), /// An error originating from an [`Action`](crate::action::Action) while reverting #[error("Error reverting\n{}", .0.iter().map(|err| { if let Some(source) = err.source() { @@ -90,6 +100,7 @@ impl HasExpectedErrors for NixInstallerError { match self { NixInstallerError::Action(action_error) => action_error.kind().expected(), NixInstallerError::ActionRevert(_) => None, + NixInstallerError::SelfTest(_) => None, NixInstallerError::RecordingReceipt(_, _) => None, NixInstallerError::CopyingSelf(_) => None, NixInstallerError::SerializingReceipt(_) => None, @@ -113,6 +124,7 @@ impl crate::diagnostics::ErrorDiagnostic for NixInstallerError { fn diagnostic(&self) -> String { let static_str: &'static str = (self).into(); let context = match self { + Self::SelfTest(self_test) => vec![self_test.diagnostic().to_string()], Self::Action(action_error) => vec![action_error.diagnostic().to_string()], Self::ActionRevert(action_errors) => action_errors .iter() diff --git a/src/lib.rs b/src/lib.rs index 4f5feda..2ecd8e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,6 +78,7 @@ mod error; mod os; mod plan; pub mod planner; +pub mod self_test; pub mod settings; use std::{ffi::OsStr, path::Path, process::Output}; diff --git a/src/plan.rs b/src/plan.rs index 5cef0f3..43123dd 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -196,6 +196,26 @@ impl InstallPlan { } write_receipt(self.clone()).await?; + + if let Err(err) = crate::self_test::self_test() + .await + .map_err(NixInstallerError::SelfTest) + { + #[cfg(feature = "diagnostics")] + if let Some(diagnostic_data) = &self.diagnostic_data { + diagnostic_data + .clone() + .failure(&err) + .send( + crate::diagnostics::DiagnosticAction::Install, + crate::diagnostics::DiagnosticStatus::Failure, + ) + .await?; + } + + return Err(err); + } + #[cfg(feature = "diagnostics")] if let Some(diagnostic_data) = &self.diagnostic_data { diagnostic_data diff --git a/src/self_test.rs b/src/self_test.rs new file mode 100644 index 0000000..b0d36d0 --- /dev/null +++ b/src/self_test.rs @@ -0,0 +1,160 @@ +use std::{process::Output, time::SystemTime}; + +use tokio::process::Command; +use which::which; + +#[non_exhaustive] +#[derive(thiserror::Error, Debug, strum::IntoStaticStr)] +pub enum SelfTestError { + #[error("Shell `{shell}` failed self-test with command `{command}`, stderr:\n{}", String::from_utf8_lossy(&output.stderr))] + ShellFailed { + shell: Shell, + command: String, + output: Output, + }, + /// Failed to execute command + #[error("Failed to execute command `{command}`", + command = .command, + )] + Command { + shell: Shell, + command: String, + #[source] + error: std::io::Error, + }, + #[error(transparent)] + SystemTime(#[from] std::time::SystemTimeError), +} + +#[cfg(feature = "diagnostics")] +impl crate::diagnostics::ErrorDiagnostic for SelfTestError { + fn diagnostic(&self) -> String { + let static_str: &'static str = (self).into(); + let context = match self { + Self::ShellFailed { shell, .. } => vec![shell.to_string()], + Self::Command { shell, .. } => vec![shell.to_string()], + Self::SystemTime(_) => vec![], + }; + return format!( + "{}({})", + static_str, + context + .iter() + .map(|v| format!("\"{v}\"")) + .collect::>() + .join(", ") + ); + } +} + +#[derive(Clone, Copy, Debug)] +pub enum Shell { + Sh, + Bash, + Fish, + Zsh, +} + +impl std::fmt::Display for Shell { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.executable()) + } +} + +impl Shell { + pub fn all() -> &'static [Shell] { + &[Shell::Sh, Shell::Bash, Shell::Fish, Shell::Zsh] + } + pub fn executable(&self) -> &'static str { + match &self { + Shell::Sh => "sh", + Shell::Bash => "bash", + Shell::Fish => "fish", + Shell::Zsh => "zsh", + } + } + + #[tracing::instrument(skip_all)] + pub async fn self_test(&self) -> Result<(), SelfTestError> { + let executable = self.executable(); + let mut command = match &self { + // On Mac, `bash -ic nix` won't work, but `bash -lc nix` will. + Shell::Sh | Shell::Bash => { + let mut command = Command::new(executable); + command.arg("-lc"); + command + }, + Shell::Zsh | Shell::Fish => { + let mut command = Command::new(executable); + command.arg("-ic"); + command + }, + }; + + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + const SYSTEM: &str = "x86_64-linux"; + #[cfg(all(target_os = "linux", target_arch = "x86"))] + const SYSTEM: &str = "x86-linux"; + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + const SYSTEM: &str = "aarch64-linux"; + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + const SYSTEM: &str = "x86_64-darwin"; + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + const SYSTEM: &str = "aarch64-darwin"; + + let timestamp_millis = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_millis(); + + command.arg(format!( + r#"nix build --no-link --expr 'derivation {{ name = "self-test-{executable}-{timestamp_millis}"; system = "{SYSTEM}"; builder = "/bin/sh"; args = ["-c" "echo hello > \$out"]; }}'"# + )); + let command_str = format!("{:?}", command.as_std()); + + tracing::debug!( + command = command_str, + "Testing Nix install via `{executable}`" + ); + let output = command + .output() + .await + .map_err(|error| SelfTestError::Command { + shell: *self, + command: command_str.clone(), + error, + })?; + + if output.status.success() { + Ok(()) + } else { + Err(SelfTestError::ShellFailed { + shell: *self, + command: command_str, + output, + }) + } + } + + #[tracing::instrument(skip_all)] + pub fn discover() -> Vec { + let mut found_shells = vec![]; + for shell in Self::all() { + if which(shell.executable()).is_ok() { + tracing::debug!("Discovered `{shell}`"); + found_shells.push(*shell) + } + } + found_shells + } +} + +#[tracing::instrument(skip_all)] +pub async fn self_test() -> Result<(), SelfTestError> { + let shells = Shell::discover(); + + for shell in shells { + shell.self_test().await?; + } + + Ok(()) +} diff --git a/tests/plan.rs b/tests/plan.rs index 934d110..e34ad0f 100644 --- a/tests/plan.rs +++ b/tests/plan.rs @@ -25,11 +25,11 @@ fn plan_compat_steam_deck() -> eyre::Result<()> { Ok(()) } -// // Ensure existing plans still parse -// // If this breaks and you need to update the fixture, disable these tests, bump `nix_installer` to a new version, and update the plans. -// #[cfg(target_os = "macos")] -// #[test] -// fn plan_compat_macos() -> eyre::Result<()> { -// let _: InstallPlan = serde_json::from_str(MACOS)?; -// Ok(()) -// } +// Ensure existing plans still parse +// If this breaks and you need to update the fixture, disable these tests, bump `nix_installer` to a new version, and update the plans. +#[cfg(target_os = "macos")] +#[test] +fn plan_compat_macos() -> eyre::Result<()> { + let _: InstallPlan = serde_json::from_str(MACOS)?; + Ok(()) +}