Skip to content

Commit

Permalink
Move to source env strategy
Browse files Browse the repository at this point in the history
Prior to this commit, Rustup used a strategy of modifying PATH
directly in a shell's run-commands (rc) files. This moves all
shell support in Rustup over to a new strategy for changing PATH.
A set of "env" scripts (only one right now) are sourced from shell
rcs to add Rustup's PATH idempotently (only once).

PATH should no longer be modified except through those scripts!
  • Loading branch information
workingjubilee committed Jul 16, 2020
1 parent c300af2 commit 850adef
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 111 deletions.
12 changes: 5 additions & 7 deletions src/cli/self_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ pub fn install(
let install_res: Result<utils::ExitCode> = (|| {
install_bins()?;
if !opts.no_modify_path {
do_add_to_path(&get_add_path_methods())?;
do_add_to_path()?;
}
utils::create_rustup_home()?;
maybe_install_rust(
Expand All @@ -336,9 +336,6 @@ pub fn install(
quiet,
)?;

#[cfg(unix)]
write_env()?;

Ok(utils::ExitCode(0))
})();

Expand Down Expand Up @@ -515,9 +512,10 @@ fn pre_install_msg(no_modify_path: bool) -> Result<String> {

if !no_modify_path {
if cfg!(unix) {
let shells = shell::get_available_shells();
let rcfiles = shells
.filter_map(|sh| sh.rcfile().map(|rc| format!(" {}", rc.display())))
// Brittle code warning: some duplication in unix::do_add_to_path
let rcfiles = shell::get_available_shells()
.flat_map(|sh| sh.update_rcs().into_iter())
.map(|rc| format!(" {}", rc.display()))
.collect::<Vec<_>>();
let plural = if rcfiles.len() > 1 { "s" } else { "" };
let rcfiles = rcfiles.join("\n");
Expand Down
155 changes: 130 additions & 25 deletions src/cli/self_update/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,51 @@
//! so handling them is relatively consistent. But only relatively.
//! POSIX postdates Unix by 20 years, and each "Unix-like" shell develops
//! unique quirks over time.
//!
//!
//! Windowing Managers, Desktop Environments, GUI Terminals, and PATHs
//!
//! Duplicating paths in PATH can cause performance issues when the OS searches
//! the same place multiple times. Traditionally, Unix configurations have
//! resolved this by setting up PATHs in the shell's login profile.
//!
//! This has its own issues. Login profiles are only intended to run once, but
//! changing the PATH is common enough that people may run it twice. Desktop
//! environments often choose to NOT start login shells in GUI terminals. Thus,
//! a trend has emerged to place PATH updates in other run-commands (rc) files,
//! leaving Rustup with few assumptions to build on for fulfilling its promise
//! to set up PATH appropriately.
//!
//! Rustup addresses this by:
//! 1) using a shell script that updates PATH if the path is not in PATH
//! 2) sourcing this script in any known and appropriate rc file

// TODO: Nushell, PowerShell
// Cross-platform non-POSIX shells were not assessed for integration yet.

use super::canonical_cargo_home;
use super::*;
use crate::process;
use std::path::PathBuf;

pub type Shell = Box<dyn UnixShell>;

#[derive(Debug, PartialEq)]
pub struct ShellScript {
content: &'static str,
name: &'static str,
}

impl ShellScript {
pub fn write(&self) -> Result<()> {
let cargo_bin = format!("{}/bin", canonical_cargo_home()?);
let env_name = utils::cargo_home()?.join(self.name);
let env_file = self.content.replace("{cargo_bin}", &cargo_bin);
utils::write_file(self.name, &env_name, &env_file)?;
Ok(())
}
}

#[allow(dead_code)] // For some reason.
const POSIX_ENV: &str = include_str!("env");

macro_rules! support_shells {
( $($shell:ident,)* ) => {
fn enumerate_shells() -> Vec<Shell> {
Expand All @@ -22,6 +57,9 @@ macro_rules! support_shells {
}
}

// TODO: Tcsh (BSD)
// TODO?: Make a decision on Ion Shell, Power Shell, Nushell
// Cross-platform non-POSIX shells have not been assessed for integration yet
support_shells! {
Posix,
Bash,
Expand All @@ -33,17 +71,27 @@ pub fn get_available_shells() -> impl Iterator<Item = Shell> {
}

pub trait UnixShell {
// Detects if a shell "exists". Users have multiple shells, so an "eager"
// heuristic should be used, assuming shells exist if any traces do.
fn does_exist(&self) -> bool;

fn rcfile(&self) -> Option<PathBuf>;
// Gives all rcfiles of a given shell that rustup is concerned with.
// Used primarily in checking rcfiles for cleanup.
fn rcfiles(&self) -> Vec<PathBuf>;

// Gives rcs that should be written to.
fn update_rcs(&self) -> Vec<PathBuf>;

// Writes the relevant env file.
fn env_script(&self) -> ShellScript {
ShellScript {
name: "env",
content: POSIX_ENV,
}
}

fn export_string(&self) -> Result<String> {
// The path is *prepended* in case there are system-installed
// rustc's that need to be overridden.
Ok(format!(
r#"export PATH="{}/bin:$PATH""#,
canonical_cargo_home()?
))
fn source_string(&self) -> Result<String> {
Ok(format!(r#"source "{}/env""#, canonical_cargo_home()?))
}
}

Expand All @@ -53,42 +101,99 @@ impl UnixShell for Posix {
true
}

fn rcfile(&self) -> Option<PathBuf> {
utils::home_dir().map(|dir| dir.join(".profile"))
fn rcfiles(&self) -> Vec<PathBuf> {
match utils::home_dir() {
Some(dir) => vec![dir.join(".profile")],
_ => vec![],
}
}

fn update_rcs(&self) -> Vec<PathBuf> {
// Write to .profile even if it doesn't exist.
self.rcfiles()
}
}

struct Bash;

// Bash is the source of many complications because it loads ONLY 1 rc,
// either a login profile or .bashrc, not both.
impl Bash {
// Bash will load only one of these, the first one that exists, when
// a login shell starts. BUT not all shells start inside a login shell!
fn profiles() -> Vec<PathBuf> {
[".bash_profile", ".bash_login", ".profile"]
.iter()
.filter_map(|rc| utils::home_dir().map(|dir| dir.join(rc)))
.collect()
}

// Bash will load only .bashrc on the start of most GUI terminals.
fn rc() -> Option<PathBuf> {
utils::home_dir().map(|dir| dir.join(".bashrc"))
}
}
impl UnixShell for Bash {
fn does_exist(&self) -> bool {
self.rcfile().map_or(false, |rc| rc.is_file())
matches!(process().var("SHELL"), Ok(sh) if sh.contains("bash"))
|| self.rcfiles().iter().any(|rc| rc.is_file())
|| matches!(utils::find_cmd(&["bash"]), Some(_))
}

fn rcfile(&self) -> Option<PathBuf> {
// .bashrc is normative, in spite of a few weird Mac versions.
utils::home_dir().map(|dir| dir.join(".bashrc"))
fn rcfiles(&self) -> Vec<PathBuf> {
// .bashrc first so it gets skipped in update_rcs
let mut profiles = Bash::profiles();
if let Some(rc) = Bash::rc() {
profiles.push(rc);
}
profiles
}

fn update_rcs(&self) -> Vec<PathBuf> {
Bash::profiles()
.into_iter()
.filter(|rc| rc.is_file())
// bash only reads one "login profile" so pick the one that exists
.take(1)
// Always pick .bashrc as well for GUI terminals
.chain(Bash::rc().filter(|rc| rc.is_file()))
.collect()
}
}

struct Zsh;
impl UnixShell for Zsh {
fn does_exist(&self) -> bool {
self.rcfile().map_or(false, |rc| rc.is_file())
matches!(process().var("SHELL"), Ok(sh) if sh.contains("zsh"))
|| matches!(process().var("ZDOTDIR"), Ok(dir) if dir.len() > 0)
|| self.rcfiles().iter().any(|rc| rc.is_file())
|| matches!(utils::find_cmd(&["zsh"]), Some(_))
}

fn rcfile(&self) -> Option<PathBuf> {
fn rcfiles(&self) -> Vec<PathBuf> {
// FIXME: if zsh exists but is not in the process tree of the shell
// on install, $ZDOTDIR may not be loaded and give the wrong result.
let zdotdir = match process().var("ZDOTDIR") {
Ok(dir) => Some(PathBuf::from(dir)),
_ => utils::home_dir(),
};

// .zshenv is preferred for path mods but not all zshers use it,
// zsh always loads .zshrc on interactive, unlike bash's weirdness.
zdotdir.map(|dir| match dir.join(".zshenv") {
rc if rc.is_file() => rc,
_ => dir.join(".zshrc"),
})
// Don't bother with .zprofile/.zlogin because .zshrc will always be
// modified and always be sourced.
[".zshenv", ".zshrc"]
.iter()
.filter_map(|rc| zdotdir.as_ref().map(|dir| dir.join(rc)))
.collect()
}

fn update_rcs(&self) -> Vec<PathBuf> {
// .zshenv is preferred for path mods but not all zshers prefer it,
// zsh always loads .zshrc on interactive, unlike bash's xor-logic.
// So picking one will always work for this.
self.rcfiles()
.into_iter()
.filter(|rc| rc.is_file())
.take(1)
.collect()
}
}
107 changes: 34 additions & 73 deletions src/cli/self_update/unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@ use std::path::{Path, PathBuf};
use std::process::Command;

use super::super::errors::*;
use super::path_update::PathUpdateMethod;
use super::{canonical_cargo_home, install_bins};
use super::install_bins;
use super::shell;
use crate::process;
use crate::utils::utils;
use crate::utils::Notification;

use super::shell;
use super::shell::Shell;

// If the user is trying to install with sudo, on some systems this will
// result in writing root-owned files to the user's home directory, because
// sudo is configured not to change $HOME. Don't let that bogosity happen.
Expand Down Expand Up @@ -61,61 +58,49 @@ pub fn complete_windows_uninstall() -> Result<utils::ExitCode> {
}

pub fn do_remove_from_path() -> Result<()> {
for rcpath in get_remove_path_methods().filter_map(|sh| sh.rcfile()) {
let file = utils::read_file("rcfile", &rcpath)?;
let addition = format!("\n{}\n", shell_export_string()?);

let file_bytes = file.into_bytes();
let addition_bytes = addition.into_bytes();

let idx = file_bytes
.windows(addition_bytes.len())
.position(|w| w == &*addition_bytes);
if let Some(i) = idx {
let mut new_file_bytes = file_bytes[..i].to_vec();
new_file_bytes.extend(&file_bytes[i + addition_bytes.len()..]);
let new_file = String::from_utf8(new_file_bytes).unwrap();
utils::write_file("rcfile", &rcpath, &new_file)?;
} else {
// Weird case. rcfile no longer needs to be modified?
for sh in shell::get_available_shells() {
let source_cmd = format!("\n{}\n", sh.source_string()?);
let source_bytes = source_cmd.into_bytes();

// Check more files for cleanup than normally are updated.
for rc in sh.rcfiles().iter().filter(|rc| rc.is_file()) {
let file = utils::read_file("rcfile", &rc)?;
let file_bytes = file.into_bytes();
if let Some(idx) = file_bytes
.windows(source_bytes.len())
.position(|w| w == source_bytes.as_slice())
{
// Here we rewrite the file without the offending line.
let mut new_bytes = file_bytes[..idx].to_vec();
new_bytes.extend(&file_bytes[idx + source_bytes.len()..]);
let new_file = String::from_utf8(new_bytes).unwrap();
utils::write_file("rcfile", &rc, &new_file)?;
}
}
}

Ok(())
}

pub fn write_env() -> Result<()> {
let env_file = utils::cargo_home()?.join("env");
let env_str = format!("{}\n", shell_export_string()?);
utils::write_file("env", &env_file, &env_str)?;
Ok(())
}
pub fn do_add_to_path() -> Result<()> {
let mut scripts = vec![];

pub fn shell_export_string() -> Result<String> {
let path = format!("{}/bin", canonical_cargo_home()?);
// The path is *prepended* in case there are system-installed
// rustc's that need to be overridden.
Ok(format!(r#"export PATH="{}:$PATH""#, path))
}

pub fn do_add_to_path(methods: &[PathUpdateMethod]) -> Result<()> {
for method in methods {
if let PathUpdateMethod::RcFile(ref rcpath) = *method {
let file = if rcpath.exists() {
utils::read_file("rcfile", rcpath)?
} else {
String::new()
};
let addition = format!("\n{}", shell_export_string()?);
if !file.contains(&addition) {
utils::append_file("rcfile", rcpath, &addition).chain_err(|| {
for sh in shell::get_available_shells() {
let source_cmd = format!("\n{}", sh.source_string()?);
for rc in sh.update_rcs() {
if !rc.is_file() || !utils::read_file("rcfile", &rc)?.contains(&source_cmd) {
utils::append_file("rcfile", &rc, &source_cmd).chain_err(|| {
ErrorKind::WritingShellProfile {
path: rcpath.to_path_buf(),
path: rc.to_path_buf(),
}
})?;
let script = sh.env_script();
// Only write scripts once.
if !scripts.contains(&script) {
script.write()?;
scripts.push(script);
}
}
} else {
unreachable!()
}
}

Expand Down Expand Up @@ -152,27 +137,3 @@ pub fn self_replace() -> Result<utils::ExitCode> {

Ok(utils::ExitCode(0))
}

/// Decide which rcfiles we're going to update, so we
/// can tell the user before they confirm.
pub fn get_add_path_methods() -> Vec<PathUpdateMethod> {
shell::get_available_shells()
.filter_map(|sh| sh.rcfile())
.map(PathUpdateMethod::RcFile)
.collect()
}

/// Decide which rcfiles we're going to update, so we
/// can tell the user before they confirm.
fn get_remove_path_methods() -> impl Iterator<Item = Shell> {
shell::get_available_shells().filter(|sh| {
if let (Some(rc), Ok(export)) = (sh.rcfile(), sh.export_string()) {
rc.is_file()
&& utils::read_file("rcfile", &rc)
.unwrap_or_default()
.contains(&export)
} else {
false
}
})
}
Loading

0 comments on commit 850adef

Please sign in to comment.