diff --git a/Cargo.lock b/Cargo.lock index b950bd276..4a67229de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -444,6 +444,14 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "export" +version = "0.1.0" +source = "git+https://github.com/DeterminateSystems/export#cf859216f9b4b9e27ef0aa0bcb2f52ca8a4e1c02" +dependencies = [ + "thiserror", +] + [[package]] name = "eyre" version = "0.6.8" @@ -963,6 +971,7 @@ dependencies = [ "color-eyre", "dirs", "dyn-clone", + "export", "eyre", "glob", "indexmap 2.1.0", diff --git a/Cargo.toml b/Cargo.toml index e30e0351e..162aa43f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ which = "4.4.0" sysctl = "0.5.4" walkdir = "2.3.3" indexmap = { version = "2.0.2", features = ["serde"] } +export = { git = "https://github.com/DeterminateSystems/export" } [dev-dependencies] eyre = { version = "0.6.8", default-features = false, features = [ "track-caller" ] } diff --git a/src/action/common/configure_shell_profile.rs b/src/action/common/configure_shell_profile.rs index d6fc90017..6d80568d1 100644 --- a/src/action/common/configure_shell_profile.rs +++ b/src/action/common/configure_shell_profile.rs @@ -9,8 +9,8 @@ use std::path::{Path, PathBuf}; use tokio::task::JoinSet; use tracing::{span, Instrument, Span}; -const PROFILE_NIX_FILE_SHELL: &str = "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh"; -const PROFILE_NIX_FILE_FISH: &str = "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.fish"; +pub(crate) const PROFILE_NIX_FILE_SHELL: &str = "/nix/nix-installer.d/profile.sh"; +pub(crate) const PROFILE_NIX_FILE_FISH: &str = "/nix/nix-installer.d/profile.fish"; /** Configure any detected shell profiles to include Nix support @@ -31,14 +31,47 @@ impl ConfigureShellProfile { let mut create_directories = Vec::default(); let shell_buf = format!( - "\n\ - # Nix\n\ - if [ -e '{PROFILE_NIX_FILE_SHELL}' ]; then\n\ - {inde}. '{PROFILE_NIX_FILE_SHELL}'\n\ - fi\n\ - # End Nix\n - \n", - inde = " ", // indent + r#" + +# Begin Nix +if [ -f '{PROFILE_NIX_FILE_SHELL}' ]; then + . '{PROFILE_NIX_FILE_SHELL}' +fi +# End Nix + +"# + ); + + create_directories.push( + CreateDirectory::plan("/nix/nix-installer.d", None, None, 0o0755, false) + .await + .map_err(Self::error)?, + ); + + create_or_insert_files.push( + CreateOrInsertIntoFile::plan( + PROFILE_NIX_FILE_SHELL, + None, + None, + 0o644, + include_str!("./profiles/profile.sh").to_string(), + create_or_insert_into_file::Position::Beginning, + ) + .await + .map_err(Self::error)?, + ); + + create_or_insert_files.push( + CreateOrInsertIntoFile::plan( + PROFILE_NIX_FILE_FISH, + None, + None, + 0o644, + include_str!("./profiles/profile.fish").to_string(), + create_or_insert_into_file::Position::Beginning, + ) + .await + .map_err(Self::error)?, ); for profile_target in locations.bash.iter().chain(locations.zsh.iter()) { @@ -67,14 +100,15 @@ impl ConfigureShellProfile { } let fish_buf = format!( - "\n\ - # Nix\n\ - if test -e '{PROFILE_NIX_FILE_FISH}'\n\ - {inde}. '{PROFILE_NIX_FILE_FISH}'\n\ - end\n\ - # End Nix\n\ - \n", - inde = " ", // indent + r#" + +# Begin Nix +if [ -f {PROFILE_NIX_FILE_FISH} ]; then + . {PROFILE_NIX_FILE_FISH} +fi +# End Nix + +"# ); for fish_prefix in &locations.fish.confd_prefixes { diff --git a/src/action/common/profiles/profile.fish b/src/action/common/profiles/profile.fish new file mode 100644 index 000000000..d4d1640f7 --- /dev/null +++ b/src/action/common/profiles/profile.fish @@ -0,0 +1,3 @@ +if [ -f /nix/nix-installer ] && [ -x /nix/nix-installer ] && not set -q __ETC_PROFILE_NIX_SOURCED; + eval "$(/nix/nix-installer export --format fish)" +end diff --git a/src/action/common/profiles/profile.sh b/src/action/common/profiles/profile.sh new file mode 100644 index 000000000..a433481bf --- /dev/null +++ b/src/action/common/profiles/profile.sh @@ -0,0 +1,5 @@ +# shellcheck shell=sh + +if [ -f /nix/nix-installer ] && [ -x /nix/nix-installer ] && [ -z "${__ETC_PROFILE_NIX_SOURCED:-}" ]; then + eval "$(/nix/nix-installer export --format sh)" +fi diff --git a/src/action/macos/configure_remote_building.rs b/src/action/macos/configure_remote_building.rs index 3d9e03690..38e6d37e6 100644 --- a/src/action/macos/configure_remote_building.rs +++ b/src/action/macos/configure_remote_building.rs @@ -1,10 +1,10 @@ -use crate::action::base::{create_or_insert_into_file, CreateOrInsertIntoFile}; -use crate::action::{Action, ActionDescription, ActionError, ActionTag, StatefulAction}; - use std::path::Path; + use tracing::{span, Instrument, Span}; -const PROFILE_NIX_FILE_SHELL: &str = "/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh"; +use crate::action::base::{create_or_insert_into_file, CreateOrInsertIntoFile}; +use crate::action::common::configure_shell_profile::PROFILE_NIX_FILE_SHELL; +use crate::action::{Action, ActionDescription, ActionError, ActionTag, StatefulAction}; /** Configure macOS's zshenv to load the Nix environment when ForceCommand is used. @@ -20,12 +20,14 @@ impl ConfigureRemoteBuilding { pub async fn plan() -> Result, ActionError> { let shell_buf = format!( r#" + # Set up Nix only on SSH connections # See: https://github.com/DeterminateSystems/nix-installer/pull/714 -if [ -e '{PROFILE_NIX_FILE_SHELL}' ] && [ -n "${{SSH_CONNECTION}}" ] && [ "${{SHLVL}}" -eq 1 ]; then +if [ -n "${{SSH_CONNECTION}}" ] && [ "${{SHLVL}}" -eq 1 ] && [ -f '{PROFILE_NIX_FILE_SHELL}' ]; then . '{PROFILE_NIX_FILE_SHELL}' fi # End Nix + "# ); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 37ded7302..9c0df7741 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -49,6 +49,7 @@ impl CommandExecute for NixInstallerCli { NixInstallerSubcommand::Install(install) => install.execute().await, NixInstallerSubcommand::Repair(restore_shell) => restore_shell.execute().await, NixInstallerSubcommand::Uninstall(revert) => revert.execute().await, + NixInstallerSubcommand::Export(export) => export.execute().await, } } } diff --git a/src/cli/subcommand/export.rs b/src/cli/subcommand/export.rs new file mode 100644 index 000000000..d87c8baaf --- /dev/null +++ b/src/cli/subcommand/export.rs @@ -0,0 +1,270 @@ +use std::collections::HashMap; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::io::{stdout, Write}; +use std::os::unix::ffi::OsStrExt; +use std::path::PathBuf; +use std::process::ExitCode; + +use crate::cli::CommandExecute; +use clap::Parser; + +const LOCAL_STATE_DIR: &str = "/nix/var"; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("The HOME environment variable is not set.")] + HomeNotSet, + + #[error("__ETC_PROFILE_NIX_SOURCED is set, indicating the relevant environment variables have already been set.")] + AlreadyRun, + + #[error("Some of the paths from Nix for XDG_DATA_DIR are not valid, due to an illegal character, like a colon.")] + InvalidXdgDataDirs(Vec), + + #[error("Some of the paths from Nix for PATH are not valid, due to an illegal character, like a colon.")] + InvalidPathDirs(Vec), + + #[error("Some of the paths from Nix for MANPATH are not valid, due to an illegal character, like a colon.")] + InvalidManPathDirs(Vec), +} + +/** +Emit all the environment variables that should be set to use Nix. + +Safety note: environment variables and values can contain any bytes except +for a null byte. This includes newlines and spaces, which requires careful +handling. + +In `space-newline-separated` mode, `nix-installer` guarantees it will: + + * only emit keys that are alphanumeric with underscores, + * only emit values without newlines + +and will refuse to emit any output to stdout if the variables and values +would violate these safety rules. + +In `null-separated` mode, `nix-installer` emits data in this format: + + KEYNAME\0VALUE\0KEYNAME\0VALUE\0 + +*/ +#[derive(Debug, Parser)] +#[command(args_conflicts_with_subcommands = true)] +pub struct Export { + #[clap(long)] + format: ExportFormat, + + #[clap(long)] + sample_output: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, clap::ValueEnum)] +enum ExportFormat { + Fish, + Sh, +} + +#[async_trait::async_trait] +impl CommandExecute for Export { + #[tracing::instrument(level = "trace", skip_all)] + async fn execute(self) -> eyre::Result { + let env: HashMap = match self.sample_output { + Some(filename) => { + // Note: not tokio File b/c I don't think serde_json has fancy async support? + let file = std::fs::File::open(filename)?; + let intermediate: HashMap = serde_json::from_reader(file)?; + intermediate + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect() + }, + None => { + match calculate_environment() { + e @ Err(Error::AlreadyRun) => { + tracing::debug!("Ignored error: {:?}", e); + return Ok(ExitCode::SUCCESS); + }, + Err(e) => { + tracing::warn!("Error setting up the environment for Nix: {:?}", e); + // Don't return an Err, because we don't want to suggest bug reports for predictable problems. + return Ok(ExitCode::FAILURE); + }, + Ok(env) => env, + } + }, + }; + + let mut export_env: HashMap = HashMap::new(); + for (k, v) in env.into_iter() { + export_env.insert(k.try_into()?, v); + } + + stdout().write_all( + export::escape( + match self.format { + ExportFormat::Fish => export::Encoding::Fish, + ExportFormat::Sh => export::Encoding::PosixShell, + }, + export_env, + )? + .as_bytes(), + )?; + + Ok(ExitCode::SUCCESS) + } +} + +fn nonempty_var_os(key: &str) -> Option { + env::var_os(key).filter(|val| !val.is_empty()) +} + +fn env_path(key: &str) -> Option> { + let path = env::var_os(key)?; + + if path.is_empty() { + return Some(vec![]); + } + + Some(env::split_paths(&path).collect()) +} + +pub fn calculate_environment() -> Result, Error> { + let mut envs: HashMap = HashMap::new(); + + // Don't export variables twice. + // @PORT-NOTE nix-profile-daemon.sh.in and nix-profile-daemon.fish.in implemented + // this behavior, but it was not implemented in nix-profile.sh.in and nix-profile.fish.in + // even though I believe it is desirable in both cases. + if nonempty_var_os("__ETC_PROFILE_NIX_SOURCED") == Some("1".into()) { + return Err(Error::AlreadyRun); + } + + // @PORT-NOTE nix-profile.sh.in and nix-profile.fish.in check HOME and USER are set, + // but not nix-profile-daemon.sh.in and nix-profile-daemon.fish.in. + // The -daemon variants appear to just assume the values are set, which is probably + // not safe, so we check it in all cases. + let home = if let Some(home) = nonempty_var_os("HOME") { + PathBuf::from(home) + } else { + return Err(Error::HomeNotSet); + }; + + envs.insert("__ETC_PROFILE_NIX_SOURCED".into(), "1".into()); + + let nix_link: PathBuf = { + let legacy_location = home.join(".nix-profile"); + let xdg_location = nonempty_var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local/state")) + .join("nix/profile"); + + if xdg_location.is_symlink() { + // In the future we'll prefer the legacy location, but + // evidently this is the intended order preference: + // https://github.com/NixOS/nix/commit/2b801d6e3c3a3be6feb6fa2d9a0b009fa9261b45 + xdg_location + } else { + legacy_location + } + }; + + let nix_profiles = &[ + PathBuf::from(LOCAL_STATE_DIR).join("nix/profiles/default"), + nix_link.clone(), + ]; + envs.insert( + "NIX_PROFILES".into(), + nix_profiles + .iter() + .map(|path| path.as_os_str()) + .collect::>() + .join(OsStr::new(" ")), + ); + + { + let mut xdg_data_dirs: Vec = env_path("XDG_DATA_DIRS").unwrap_or_else(|| { + vec![ + PathBuf::from("/usr/local/share"), + PathBuf::from("/usr/share"), + ] + }); + + xdg_data_dirs.extend(vec![ + nix_link.join("share"), + PathBuf::from(LOCAL_STATE_DIR).join("nix/profiles/default/share"), + ]); + + if let Ok(dirs) = env::join_paths(&xdg_data_dirs) { + envs.insert("XDG_DATA_DIRS".into(), dirs); + } else { + return Err(Error::InvalidXdgDataDirs(xdg_data_dirs)); + } + } + + if nonempty_var_os("NIX_SSL_CERT_FILE").is_none() { + let mut candidate_locations = vec![ + PathBuf::from("/etc/ssl/certs/ca-certificates.crt"), // NixOS, Ubuntu, Debian, Gentoo, Arch + PathBuf::from("/etc/ssl/ca-bundle.pem"), // openSUSE Tumbleweed + PathBuf::from("/etc/ssl/certs/ca-bundle.crt"), // Old NixOS + PathBuf::from("/etc/pki/tls/certs/ca-bundle.crt"), // Fedora, CentOS + ]; + + // Add the various profiles, preferring the last profile, ie: most global profile (matches upstream behavior) + for profile in nix_profiles.iter().rev() { + candidate_locations.extend([ + profile.join("etc/ssl/certs/ca-bundle.crt"), // fall back to cacert in Nix profile + profile.join("etc/ca-bundle.crt"), // old cacert in Nix profile + ]); + } + + if let Some(cert) = candidate_locations.iter().find(|path| path.is_file()) { + envs.insert("NIX_SSL_CERT_FILE".into(), cert.into()); + } else { + tracing::warn!( + "Could not identify any SSL certificates out of these candidates: {:?}", + candidate_locations + ) + } + }; + + { + let mut path = vec![ + nix_link.join("bin"), + // Note: This is typically only used in single-user installs, but I chose to do it in both for simplicity. + // If there is good reason, we can make it fancier. + PathBuf::from(LOCAL_STATE_DIR).join("nix/profiles/default/bin"), + ]; + + if let Some(old_path) = env_path("PATH") { + path.extend(old_path); + } + + if let Ok(dirs) = env::join_paths(&path) { + envs.insert("PATH".into(), dirs); + } else { + return Err(Error::InvalidPathDirs(path)); + } + } + + if let Some(old_path) = env_path("MANPATH") { + let mut path = vec![ + nix_link.join("share/man"), + // Note: This is typically only used in single-user installs, but I chose to do it in both for simplicity. + // If there is good reason, we can make it fancier. + PathBuf::from(LOCAL_STATE_DIR).join("nix/profiles/default/share/man"), + ]; + + path.extend(old_path); + + if let Ok(dirs) = env::join_paths(&path) { + envs.insert("MANPATH".into(), dirs); + } else { + return Err(Error::InvalidManPathDirs(path)); + } + } + + tracing::debug!("Calculated environment: {:#?}", envs); + + Ok(envs) +} diff --git a/src/cli/subcommand/install.rs b/src/cli/subcommand/install.rs index a72c05b4a..a874be741 100644 --- a/src/cli/subcommand/install.rs +++ b/src/cli/subcommand/install.rs @@ -1,18 +1,20 @@ use std::{ - os::unix::prelude::PermissionsExt, path::{Path, PathBuf}, process::ExitCode, }; use crate::{ - action::ActionState, + action::{ + common::configure_shell_profile::PROFILE_NIX_FILE_FISH, + common::configure_shell_profile::PROFILE_NIX_FILE_SHELL, ActionState, + }, cli::{ ensure_root, interaction::{self, PromptChoice}, signal_channel, CommandExecute, }, error::HasExpectedErrors, - plan::RECEIPT_LOCATION, + plan::{copy_self_to_nix_dir, RECEIPT_LOCATION}, planner::Planner, settings::CommonSettings, BuiltinPlanner, InstallPlan, NixInstallerError, @@ -313,9 +315,8 @@ impl CommandExecute for Install { } }, Ok(_) => { - copy_self_to_nix_dir() - .await - .wrap_err("Copying `nix-installer` to `/nix/nix-installer`")?; + let load_fish = format!(". {}", PROFILE_NIX_FILE_FISH); + let load_shell = format!(". {}", PROFILE_NIX_FILE_SHELL); println!( "\ {success}\n\ @@ -323,10 +324,12 @@ impl CommandExecute for Install { ", success = "Nix was installed successfully!".green().bold(), shell_reminder = match std::env::var("SHELL") { - Ok(val) if val.contains("fish") => - ". /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.fish".bold(), - Ok(_) | Err(_) => - ". /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh".bold(), + Ok(val) if val.contains("fish") => { + load_fish.bold() + }, + Ok(_) | Err(_) => { + load_shell.bold() + }, }, ); }, @@ -335,11 +338,3 @@ impl CommandExecute for Install { Ok(ExitCode::SUCCESS) } } - -#[tracing::instrument(level = "debug")] -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?; - Ok(()) -} diff --git a/src/cli/subcommand/mod.rs b/src/cli/subcommand/mod.rs index ce8f42435..5431a90bc 100644 --- a/src/cli/subcommand/mod.rs +++ b/src/cli/subcommand/mod.rs @@ -8,6 +8,8 @@ mod uninstall; use uninstall::Uninstall; mod self_test; use self_test::SelfTest; +mod export; +pub use export::Export; #[allow(clippy::large_enum_variant)] #[derive(Debug, clap::Subcommand)] @@ -17,4 +19,5 @@ pub enum NixInstallerSubcommand { Uninstall(Uninstall), SelfTest(SelfTest), Plan(Plan), + Export(Export), } diff --git a/src/plan.rs b/src/plan.rs index 6543f0cbc..3e4ac6c39 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -1,13 +1,15 @@ +use std::os::unix::prelude::PermissionsExt; use std::{path::PathBuf, str::FromStr}; +use owo_colors::OwoColorize; +use semver::{Version, VersionReq}; +use tokio::sync::broadcast::Receiver; + use crate::{ action::{Action, ActionDescription, StatefulAction}, planner::{BuiltinPlanner, Planner}, NixInstallerError, }; -use owo_colors::OwoColorize; -use semver::{Version, VersionReq}; -use tokio::sync::broadcast::Receiver; pub const RECEIPT_LOCATION: &str = "/nix/receipt.json"; @@ -211,6 +213,7 @@ impl InstallPlan { } write_receipt(self.clone()).await?; + copy_self_to_nix_dir().await.ok(); if let Err(err) = crate::self_test::self_test() .await @@ -425,6 +428,14 @@ async fn write_receipt(plan: InstallPlan) -> Result<(), NixInstallerError> { Result::<(), NixInstallerError>::Ok(()) } +#[tracing::instrument(level = "debug")] +pub(crate) 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?; + Ok(()) +} + pub fn current_version() -> Result { let nix_installer_version_str = env!("CARGO_PKG_VERSION"); Version::from_str(nix_installer_version_str).map_err(|e| {