From ba6a95852399e412f856d558378660dcaa8e7e36 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Tue, 20 Oct 2026 15:18:45 +0100 Subject: [PATCH 1/6] Allow library callers to select a guest toolchain file Select installed Cargo and Rust tools consistently for guest preparation and compilation without changing the host process environment or installing components. Document the API and cover newer-host/older-guest builds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 63d06f38-d356-4418-aa31-2473e0f87584 Signed-off-by: Simon Davies --- .github/workflows/ci.yml | 18 ++++ Cargo.lock | 55 ++++++++++++ Cargo.toml | 1 + README.md | 45 ++++++++++ src/cargo_cmd.rs | 179 ++++++++++++++++++++++++++++++++++++++- src/cli.rs | 8 +- src/command.rs | 59 ++++++++++++- src/lib.rs | 6 +- src/sysroot.rs | 72 +++++++++++++--- src/toolchain.rs | 12 +-- tests/toolchain_file.rs | 129 ++++++++++++++++++++++++++++ 11 files changed, 550 insertions(+), 34 deletions(-) create mode 100644 tests/toolchain_file.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54bf321..1c26677 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,6 +146,24 @@ jobs: exit 1 fi + library-toolchain-selection: + name: Select older guest toolchain on ${{ matrix.os }} + strategy: + matrix: + os: ["ubuntu-latest", "windows-latest"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: "1.99.1" + rustflags: "" + - uses: Swatinem/rust-cache@v2 + - name: Install guest toolchain separately + run: rustup toolchain install 1.94.1 --profile minimal --component rust-src + - name: Build guest through the library with a newer host + run: cargo +1.99.1 test --lib --test toolchain_file -- --include-ignored + spelling: name: Spell check with typos runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 85545e8..1bbee1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,6 +93,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "toml", "which", ] @@ -495,6 +496,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "strsim" version = "0.11.1" @@ -536,6 +546,45 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -637,6 +686,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 8bba0cb..68edc13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ semver = { version = "1.0", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tempfile = "3" +toml = "1.0" which = { version = "8", features = ["regex"] } quote = "1.0" proc-macro2 = "1.0" \ No newline at end of file diff --git a/README.md b/README.md index 3b89ace..606d416 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,51 @@ Your binary will be built for the `x86_64-hyperlight-none` target (or `aarch64-h There's no need for any extra configuration, the command will take care of everything. +## Selecting a guest toolchain from Rust + +When calling cargo-hyperlight as a library (for example from a host `build.rs`), +select the guest's installed toolchain explicitly: + +```rust,no_run +fn main() -> anyhow::Result<()> { + cargo_hyperlight::cargo()? + .toolchain_file("guest/rust-toolchain.toml")? + .env_clear_cargo() + .current_dir("guest") + .arg("build") + .status() +} +``` + +For example, a newer host compiler can build a guest pinned to Rust 1.94.1: + +```toml +[toolchain] +channel = "1.94.1" +components = ["rust-src"] +``` + +Install the toolchain and `rust-src` separately before building: + +```sh +rustup toolchain install 1.94.1 --profile minimal --component rust-src +``` + +`toolchain_file` reads the TOML file immediately and returns an error if it is +invalid, unreadable, or names an unavailable toolchain. Relative file paths resolve +against the calling process's current directory, independently of `current_dir`. +The selection overrides `CARGO`, `RUSTC`, `RUSTDOC`, and `RUSTUP_TOOLCHAIN` for +preparation and the final guest command, including after environment clearing. +It does not change the parent process environment or install anything. The file's +`components`, `targets`, and `profile` settings do not trigger downloads; missing +`rust-src` is reported as an error. Path-based toolchains are not supported. + +Without explicit selection, existing toolchain discovery and automatic `rust-src` +installation are unchanged. Merely setting the guest working directory is not +enough in a host build script: Cargo normally supplies its own absolute executable +path. Stable Rust 1.95 and newer reject the custom target specifications used by +Hyperlight, so use Rust 1.94.x or a supported nightly for the guest. + ## Building C Guests cargo-hyperlight also supports building C guest binaries. To get the compiler and linker flags needed to build a C guest, use: diff --git a/src/cargo_cmd.rs b/src/cargo_cmd.rs index 920d364..37ab686 100644 --- a/src/cargo_cmd.rs +++ b/src/cargo_cmd.rs @@ -4,7 +4,7 @@ use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail, ensure}; pub trait CargoCmd { fn manifest_path(&mut self, path: &Option>) -> &mut Self; @@ -54,8 +54,104 @@ pub fn find_cargo() -> Result { }) } -pub fn cargo_cmd() -> Result { - Ok(find_cargo()?.command()) +pub fn cargo_cmd(env: &HashMap) -> Result { + let cargo = match env.get(OsStr::new("CARGO")) { + Some(cargo) => PathBuf::from(cargo), + None => find_cargo()?.path, + }; + let mut cmd = Command::new(cargo); + cmd.env_clear().envs(env); + Ok(cmd) +} + +#[derive(Clone)] +pub(crate) struct GuestToolchain { + pub cargo: PathBuf, + rustc: PathBuf, + rustdoc: PathBuf, + channel: String, +} + +impl GuestToolchain { + pub fn from_file(path: &Path) -> Result { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read toolchain file {}", path.display()))?; + let channel = parse_toolchain(&contents) + .with_context(|| format!("Invalid toolchain file {}", path.display()))?; + let rustup = which::which("rustup").context("Toolchain selection requires rustup")?; + let binary = |name: &str| -> Result { + let output = Command::new(&rustup) + .env("RUSTUP_AUTO_INSTALL", "0") + .env("RUSTUP_TOOLCHAIN", &channel) + .args(["which", "--toolchain", &channel, name]) + .checked_output() + .with_context(|| { + format!("Toolchain {channel:?} must already be installed with {name}") + })?; + let path = PathBuf::from(String::from_utf8(output.stdout)?.trim()); + ensure!( + path.is_file(), + "Missing {name} executable: {}", + path.display() + ); + Ok(path) + }; + Ok(Self { + cargo: binary("cargo")?, + rustc: binary("rustc")?, + rustdoc: binary("rustdoc")?, + channel, + }) + } + + pub fn apply_env(&self, env: &mut HashMap) { + for (key, value) in [ + ("CARGO", self.cargo.as_os_str()), + ("RUSTC", self.rustc.as_os_str()), + ("RUSTDOC", self.rustdoc.as_os_str()), + ("RUSTUP_TOOLCHAIN", OsStr::new(&self.channel)), + ("RUSTUP_AUTO_INSTALL", OsStr::new("0")), + ] { + // Windows environment names are case-insensitive. + env.retain(|k, _| { + if cfg!(windows) { + !k.eq_ignore_ascii_case(OsStr::new(key)) + } else { + k != key + } + }); + env.insert(key.into(), value.into()); + } + } +} + +fn parse_toolchain(contents: &str) -> Result { + #[derive(serde::Deserialize)] + struct File { + toolchain: Toolchain, + } + #[derive(serde::Deserialize)] + struct Toolchain { + channel: String, + path: Option, + #[serde(rename = "components")] + _components: Option>, + #[serde(rename = "targets")] + _targets: Option>, + #[serde(rename = "profile")] + _profile: Option, + } + let file: File = toml::from_str(contents).context("Expected [toolchain] with a channel")?; + ensure!( + file.toolchain.path.is_none(), + "Path toolchains are not supported" + ); + let channel = file.toolchain.channel; + ensure!( + !channel.is_empty() && channel.trim() == channel && !channel.starts_with('-'), + "Toolchain channel must be a nonempty rustup toolchain name" + ); + Ok(channel) } pub struct CheckedOutput { @@ -269,3 +365,80 @@ pub fn merge_env( base } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_toolchain_channel() { + assert_eq!( + parse_toolchain( + "# guest compiler\n[toolchain]\nchannel = '1.94.1'\n\ + components = ['rust-src']\nprofile = 'minimal'\n" + ) + .unwrap(), + "1.94.1" + ); + } + + #[test] + fn reject_invalid_toolchain_files() { + for contents in [ + "", + "1.94.1", + "[toolchain", + "[toolchain]\ncomponents = ['rust-src']", + "[toolchain]\nchannel = 1941", + "[toolchain]\nchannel = ''", + "[toolchain]\nchannel = ' 1.94.1 '", + "[toolchain]\nchannel = '--help'", + "[toolchain]\nchannel = '1.94.1'\npath = '/tmp/toolchain'", + "[toolchain]\nchannel = '1.94.1'\ncomponents = 123", + ] { + assert!(parse_toolchain(contents).is_err(), "{contents}"); + } + } + + #[test] + fn effective_cargo_overrides_process_cargo() { + let env = HashMap::from([ + ("CARGO".into(), "guest-cargo".into()), + ("RUSTC".into(), "guest-rustc".into()), + ("RUSTUP_TOOLCHAIN".into(), "guest".into()), + ]); + let command = cargo_cmd(&env).unwrap(); + assert_eq!(command.get_program(), "guest-cargo"); + assert_eq!( + merge_env(std::iter::empty::<(&str, &str)>(), command.get_envs()), + env + ); + } + + #[test] + fn selected_toolchain_replaces_host_environment() { + let toolchain = GuestToolchain { + cargo: "guest-cargo".into(), + rustc: "guest-rustc".into(), + rustdoc: "guest-rustdoc".into(), + channel: "1.94.1".into(), + }; + let mut env = HashMap::from([ + ("CARGO".into(), "host-cargo".into()), + ("RUSTC".into(), "host-rustc".into()), + ("RUSTDOC".into(), "host-rustdoc".into()), + ("RUSTUP_TOOLCHAIN".into(), "host".into()), + ]); + toolchain.apply_env(&mut env); + assert_eq!( + env, + HashMap::from([ + ("CARGO".into(), "guest-cargo".into()), + ("RUSTC".into(), "guest-rustc".into()), + ("RUSTDOC".into(), "guest-rustdoc".into()), + ("RUSTUP_TOOLCHAIN".into(), "1.94.1".into()), + ("RUSTUP_AUTO_INSTALL".into(), "0".into()), + ]) + ); + } +} diff --git a/src/cli.rs b/src/cli.rs index 96200a7..19f8da6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -300,9 +300,7 @@ fn resolve_target_dir( env: &HashMap, cwd: &PathBuf, ) -> Result { - let output = cargo_cmd()? - .env_clear() - .envs(env.iter()) + let output = cargo_cmd(env)? .current_dir(cwd) .arg("metadata") .manifest_path(manifest_path) @@ -318,9 +316,7 @@ fn resolve_target_dir( } fn resolve_target(env: &HashMap, cwd: &PathBuf) -> Result { - let output = cargo_cmd()? - .env_clear() - .envs(env.iter()) + let output = cargo_cmd(env)? .current_dir(cwd) .arg("config") .arg("get") diff --git a/src/command.rs b/src/command.rs index 0ca73e4..cea61e6 100644 --- a/src/command.rs +++ b/src/command.rs @@ -9,7 +9,7 @@ use anyhow::{Context, Result}; use os_str_bytes::OsStrBytesExt; use crate::CargoCommandExt; -use crate::cargo_cmd::{CargoBinary, CargoCmd as _, find_cargo, merge_env}; +use crate::cargo_cmd::{CargoBinary, CargoCmd as _, GuestToolchain, find_cargo, merge_env}; use crate::cli::{Args, Warning}; /// A process builder for cargo commands, providing a similar API to `std::process::Command`. @@ -50,6 +50,7 @@ use crate::cli::{Args, Warning}; #[derive(Clone)] pub struct Command { cargo: CargoBinary, + toolchain: Option, /// Arguments to pass to the cargo program args: Vec, /// Environment variable mappings to set for the child process @@ -123,6 +124,7 @@ impl Command { let cargo = find_cargo()?; Ok(Self { cargo, + toolchain: None, args: Vec::new(), envs: BTreeMap::new(), inherit_envs: true, @@ -131,6 +133,50 @@ impl Command { }) } + /// Selects the installed Rust toolchain named in a `rust-toolchain.toml` file. + /// + /// Relative paths resolve against the process's current directory when this + /// method is called, not the directory set by [`Self::current_dir`]. The file + /// is read immediately and must contain `[toolchain]` with a `channel`. + /// Path-based toolchains are not supported. + /// + /// Both Cargo and rustc are selected for all preparation and guest commands, + /// overriding inherited or explicit `CARGO`, `RUSTC`, `RUSTDOC`, and + /// `RUSTUP_TOOLCHAIN` values, even after [`Self::env_clear`]. + /// No process-global environment is changed. + /// Rustup's automatic installation is disabled for the selected command. + /// + /// Selection does not install toolchains or components. Install the toolchain + /// and its `rust-src` component separately before calling [`Self::status`]. + /// Other file settings such as `components`, `targets`, and `profile` do not + /// trigger installation. + /// + /// # Errors + /// + /// Returns an error for an unreadable or invalid file, missing rustup, or + /// unavailable Cargo, rustc, or rustdoc. A failed selection leaves the + /// previous configuration unchanged. + /// + /// # Examples + /// + /// ```no_run + /// # fn build() -> anyhow::Result<()> { + /// cargo_hyperlight::cargo()? + /// .toolchain_file("guest/rust-toolchain.toml")? + /// .env_clear_cargo() + /// .current_dir("guest") + /// .arg("build") + /// .status()?; + /// # Ok(()) + /// # } + /// ``` + pub fn toolchain_file(&mut self, path: impl AsRef) -> Result<&mut Self> { + let toolchain = GuestToolchain::from_file(path.as_ref())?; + self.cargo.path = toolchain.cargo.clone(); + self.toolchain = Some(toolchain); + Ok(self) + } + /// Adds an argument to pass to the cargo program. /// /// Only one argument can be passed per use. So instead of: @@ -523,7 +569,11 @@ impl Command { } fn resolve_env(&self) -> HashMap { - merge_env(self.base_env(), self.get_envs()) + let mut env = merge_env(self.base_env(), self.get_envs()); + if let Some(toolchain) = &self.toolchain { + toolchain.apply_env(&mut env); + } + env } fn command(&self) -> StdCommand { @@ -579,6 +629,9 @@ impl Command { None => command.env_remove(k), }; } + if self.toolchain.is_some() { + command.env_clear().envs(self.resolve_env()); + } command } @@ -655,7 +708,7 @@ impl Command { pub fn status(&self) -> anyhow::Result<()> { let mut args = self.build_args(); - args.prepare_sysroot() + args.prepare_sysroot_with_install(self.toolchain.is_none()) .context("Failed to prepare sysroot")?; self.command() diff --git a/src/lib.rs b/src/lib.rs index 440c33f..e3e98c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -121,8 +121,12 @@ impl CargoCommandExt for std::process::Command { impl Args { pub fn prepare_sysroot(&mut self) -> Result<()> { + self.prepare_sysroot_with_install(true) + } + + pub(crate) fn prepare_sysroot_with_install(&mut self, install_rust_src: bool) -> Result<()> { // Build sysroot - sysroot::build(self)?; + sysroot::build(self, install_rust_src)?; // Build toolchain toolchain::prepare(self)?; diff --git a/src/sysroot.rs b/src/sysroot.rs index 53bdd03..bbc5873 100644 --- a/src/sysroot.rs +++ b/src/sysroot.rs @@ -1,6 +1,6 @@ use std::ffi::OsStr; use std::ops::Not as _; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Output, Stdio}; use anyhow::{Context, Result, bail, ensure}; @@ -19,9 +19,7 @@ pub(crate) const UNSTABLE_TARGET_SPEC_FLAG: &str = "-Zunstable-options"; /// Queries the version of the cargo release in use. fn cargo_version(args: &Args) -> Result { - let output = cargo_cmd()? - .env_clear() - .envs(args.env.iter()) + let output = cargo_cmd(&args.env)? .current_dir(&args.current_dir) .arg("version") .arg("--verbose") @@ -121,7 +119,7 @@ pub(crate) struct CargoBuildMessage { pub(crate) filenames: Vec, } -pub fn build(args: &mut Args) -> Result<()> { +pub fn build(args: &mut Args, install_rust_src: bool) -> Result<()> { let cargo_version = cargo_version(args)?; let target_spec = match args.target.as_str() { @@ -195,8 +193,11 @@ Supported values are: std::fs::write(crate_dir.join("lib.rs"), LIB_RS).context("Failed to write lib.rs")?; // if we are using rustup, ensure that the rust-src component is installed - if let Some(rustup_toolchain) = std::env::var_os("RUSTUP_TOOLCHAIN") { + if install_rust_src && let Some(rustup_toolchain) = args.env.get(OsStr::new("RUSTUP_TOOLCHAIN")) + { std::process::Command::new("rustup") + .env_clear() + .envs(&args.env) .arg("--quiet") .arg("component") .arg("add") @@ -206,11 +207,27 @@ Supported values are: .checked_output() .context("Failed to get Rust's std lib sources")?; } + if !install_rust_src { + let rustc = args + .env + .get(OsStr::new("RUSTC")) + .context("Missing selected rustc")?; + let output = std::process::Command::new(rustc) + .env_clear() + .envs(&args.env) + .arg("--print=sysroot") + .checked_output()?; + let sysroot = PathBuf::from(String::from_utf8(output.stdout)?.trim()); + require_rust_src( + &sysroot, + args.env + .get(OsStr::new("RUSTUP_TOOLCHAIN")) + .context("Missing selected toolchain")?, + )?; + } // Build the sysroot - let output = cargo_cmd()? - .env_clear() - .envs(args.env.iter()) + let output = cargo_cmd(&args.env)? .current_dir(&args.current_dir) .arg("build") .target(&args.target) @@ -287,9 +304,7 @@ Supported values are: } fn get_spec(args: &Args, triplet: impl AsRef) -> Result> { - let output = cargo_cmd()? - .env_clear() - .envs(args.env.iter()) + let output = cargo_cmd(&args.env)? .current_dir(&args.current_dir) .arg("rustc") .arg("--target") @@ -306,3 +321,36 @@ fn get_spec(args: &Args, triplet: impl AsRef) -> Result> serde_json::from_slice(&output.stdout).context("Failed to parse target spec JSON") } + +fn require_rust_src(sysroot: &Path, toolchain: &OsStr) -> Result<()> { + ensure!( + sysroot + .join("lib/rustlib/src/rust/library/Cargo.toml") + .is_file(), + "Selected toolchain is missing rust-src; install it separately with \ + `rustup component add rust-src --toolchain {}`", + toolchain.to_string_lossy() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selected_toolchain_requires_preinstalled_sources() { + let dir = tempfile::tempdir().unwrap(); + let error = require_rust_src(dir.path(), OsStr::new("1.94.1")).unwrap_err(); + assert!( + error + .to_string() + .contains("rustup component add rust-src --toolchain 1.94.1") + ); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + let sources = dir.path().join("lib/rustlib/src/rust/library"); + std::fs::create_dir_all(&sources).unwrap(); + std::fs::write(sources.join("Cargo.toml"), "").unwrap(); + require_rust_src(dir.path(), OsStr::new("1.94.1")).unwrap(); + } +} diff --git a/src/toolchain.rs b/src/toolchain.rs index f688861..57ce26a 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -61,9 +61,7 @@ fn find_package_dir(metadata: &CargoMetadata, name: &str) -> Result Result { - let metadata = cargo_cmd()? - .env_clear() - .envs(args.env.iter()) + let metadata = cargo_cmd(&args.env)? .current_dir(&args.current_dir) .arg("metadata") .manifest_path(&args.manifest_path) @@ -93,9 +91,7 @@ fn copy_includes(src_dirs: impl Iterator>, dst_dir: &Path) -> fn build_guest_capi(args: &Args, capi_dir: &Path) -> Result<()> { use crate::CargoCommandExt; - let output = cargo_cmd()? - .env_clear() - .envs(args.env.iter()) + let output = cargo_cmd(&args.env)? .arg("build") .manifest_path(&Some(capi_dir.join("Cargo.toml"))) .target_dir(args.build_dir()) @@ -186,9 +182,7 @@ fn build_wrappers(args: &Args) -> Result<()> { .to_string(), )?; - let output = cargo_cmd()? - .env_clear() - .envs(args.env.iter()) + let output = cargo_cmd(&args.env)? .current_dir(&args.current_dir) .arg("build") .target(&args.host) diff --git a/tests/toolchain_file.rs b/tests/toolchain_file.rs new file mode 100644 index 0000000..392654e --- /dev/null +++ b/tests/toolchain_file.rs @@ -0,0 +1,129 @@ +use std::path::Path; +use std::process::Command; + +#[test] +fn invalid_selection_preserves_default_program() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("rust-toolchain.toml"); + let mut command = cargo_hyperlight::cargo().unwrap(); + let original = command.get_program().to_owned(); + + let error = command.toolchain_file(&file).unwrap_err(); + assert!(format!("{error:#}").contains("Failed to read toolchain file")); + assert_eq!(command.get_program(), original); + + std::fs::write(&file, "[toolchain]\nchannel = 123").unwrap(); + let error = command.toolchain_file(&file).unwrap_err(); + assert!(format!("{error:#}").contains("Invalid toolchain file")); + assert_eq!(command.get_program(), original); +} + +#[test] +fn unavailable_toolchain_does_not_fall_back() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("rust-toolchain.toml"); + let mut command = cargo_hyperlight::cargo().unwrap(); + let original = command.get_program().to_owned(); + for channel in ["cargo-hyperlight-missing-toolchain", "1.94.999"] { + std::fs::write(&file, format!("[toolchain]\nchannel = '{channel}'")).unwrap(); + let error = command.toolchain_file(&file).unwrap_err(); + let error = format!("{error:#}"); + assert!(error.contains("must already be installed"), "{error}"); + assert!(!error.contains("syncing channel updates"), "{error}"); + assert_eq!(command.get_program(), original); + } +} + +/// Run with a newer host: cargo +1.99.1 test --test toolchain_file -- --include-ignored +#[test] +#[ignore = "requires a host compiler newer than 1.94, Rust 1.94.1 with rust-src, and guest build dependencies"] +fn newer_host_builds_guest_with_older_toolchain() { + let host = Command::new(env!("CARGO")) + .args(["--version"]) + .output() + .unwrap(); + assert!(host.status.success()); + let version = String::from_utf8(host.stdout).unwrap(); + let version = semver::Version::parse(version.split_whitespace().nth(1).unwrap()).unwrap(); + assert!(version >= semver::Version::new(1, 95, 0), "{version}"); + + let dir = tempfile::tempdir().unwrap(); + let guest = dir.path().join("guest"); + std::fs::create_dir_all(guest.join("src")).unwrap(); + let example = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join("guest"); + std::fs::copy(example.join("Cargo.toml"), guest.join("Cargo.toml")).unwrap(); + for entry in std::fs::read_dir(example.join("src")).unwrap() { + let entry = entry.unwrap(); + std::fs::copy(entry.path(), guest.join("src").join(entry.file_name())).unwrap(); + } + let build_script = include_str!("../examples/guest/build.rs") + .replace("fn main() {", "fn main() {\n verify_toolchain();"); + std::fs::write( + guest.join("build.rs"), + [ + build_script.as_str(), + r#" +fn verify_toolchain() { + for key in ["RUSTC", "CARGO"] { + let output = std::process::Command::new(std::env::var_os(key).unwrap()) + .arg("--version").output().unwrap(); + assert!(output.status.success()); + let version = String::from_utf8(output.stdout).unwrap(); + assert!(version.contains("1.94.1"), "{key}: {version}"); + } +} +"#, + ] + .concat(), + ) + .unwrap(); + std::fs::write( + guest.join("rust-toolchain.toml"), + "[toolchain]\nchannel = '1.94.1'\ncomponents = ['rust-src']\n", + ) + .unwrap(); + + let mut command = cargo_hyperlight::cargo().unwrap(); + command + .current_dir(&guest) + .toolchain_file(guest.join("rust-toolchain.toml")) + .unwrap() + .env_clear_cargo() + .env("CARGO_TARGET_DIR", dir.path().join("target")) + .env("CARGO", env!("CARGO")) + .env("RUSTC", "nonexistent-host-rustc") + .env("RUSTDOC", "nonexistent-host-rustdoc") + .env("RUSTUP_TOOLCHAIN", "nonexistent-host-toolchain") + .arg("build"); + let args = command.build_args(); + let output = Command::new(command.get_program()) + .arg("--version") + .output() + .unwrap(); + assert!(String::from_utf8(output.stdout).unwrap().contains("1.94.1")); + command.status().unwrap(); + assert!( + args.target_dir + .join(&args.target) + .join("debug") + .join("guest") + .is_file() + ); + assert!( + args.wrapper_dir() + .join(format!("clang{}", std::env::consts::EXE_SUFFIX)) + .is_file() + ); + + let mut cleared = command.clone(); + cleared.env_clear(); + let args = cleared.build_args(); + assert_eq!( + args.env + .get(std::ffi::OsStr::new("RUSTUP_TOOLCHAIN")) + .unwrap(), + "1.94.1" + ); +} From 9b0f347c6a66e6801639877bc6c5026bbbaf3d7a Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Tue, 20 Oct 2026 15:24:40 +0100 Subject: [PATCH 2/6] Update README.md Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 63d06f38-d356-4418-aa31-2473e0f87584 Signed-off-by: Simon Davies --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 606d416..d358ced 100644 --- a/README.md +++ b/README.md @@ -126,12 +126,6 @@ It does not change the parent process environment or install anything. The file' `components`, `targets`, and `profile` settings do not trigger downloads; missing `rust-src` is reported as an error. Path-based toolchains are not supported. -Without explicit selection, existing toolchain discovery and automatic `rust-src` -installation are unchanged. Merely setting the guest working directory is not -enough in a host build script: Cargo normally supplies its own absolute executable -path. Stable Rust 1.95 and newer reject the custom target specifications used by -Hyperlight, so use Rust 1.94.x or a supported nightly for the guest. - ## Building C Guests cargo-hyperlight also supports building C guest binaries. To get the compiler and linker flags needed to build a C guest, use: From 1e2dad3772201793419db21b31febb7c00358234 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Tue, 20 Oct 2026 16:32:33 +0100 Subject: [PATCH 3/6] Fix Cargo selection and inherited environment diagnostics Use the same Cargo executable for preparation and the guest command, with explicit toolchain selection taking precedence over CARGO overrides. Keep inherited credentials out of printable command overrides while preserving environment inheritance and clearing behavior. Add regression tests for executable selection and credential-free debug and failure diagnostics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 63d06f38-d356-4418-aa31-2473e0f87584 Signed-off-by: Simon Davies --- src/cargo_cmd.rs | 20 +++----- src/command.rs | 124 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 16 deletions(-) diff --git a/src/cargo_cmd.rs b/src/cargo_cmd.rs index 37ab686..acfcdf8 100644 --- a/src/cargo_cmd.rs +++ b/src/cargo_cmd.rs @@ -32,16 +32,6 @@ pub struct CargoBinary { pub rustup_toolchain: Option, } -impl CargoBinary { - pub fn command(&self) -> Command { - let mut cmd = Command::new(&self.path); - if let Some(rustup_toolchain) = &self.rustup_toolchain { - cmd.env("RUSTUP_TOOLCHAIN", rustup_toolchain); - } - cmd - } -} - pub fn find_cargo() -> Result { let cargo = match env::var_os("CARGO") { Some(cargo) => PathBuf::from(cargo), @@ -104,14 +94,18 @@ impl GuestToolchain { }) } - pub fn apply_env(&self, env: &mut HashMap) { - for (key, value) in [ + pub fn env_overrides(&self) -> [(&str, &OsStr); 5] { + [ ("CARGO", self.cargo.as_os_str()), ("RUSTC", self.rustc.as_os_str()), ("RUSTDOC", self.rustdoc.as_os_str()), ("RUSTUP_TOOLCHAIN", OsStr::new(&self.channel)), ("RUSTUP_AUTO_INSTALL", OsStr::new("0")), - ] { + ] + } + + pub fn apply_env(&self, env: &mut HashMap) { + for (key, value) in self.env_overrides() { // Windows environment names are case-insensitive. env.retain(|k, _| { if cfg!(windows) { diff --git a/src/command.rs b/src/command.rs index cea61e6..fc687ea 100644 --- a/src/command.rs +++ b/src/command.rs @@ -570,6 +570,7 @@ impl Command { fn resolve_env(&self) -> HashMap { let mut env = merge_env(self.base_env(), self.get_envs()); + env.insert("CARGO".into(), self.get_program().to_owned()); if let Some(toolchain) = &self.toolchain { toolchain.apply_env(&mut env); } @@ -577,7 +578,7 @@ impl Command { } fn command(&self) -> StdCommand { - let mut command = self.cargo.command(); + let mut command = StdCommand::new(self.get_program()); // Filter out --target and --target-dir from forwarded args since // populate_from_args sets them via env vars with the resolved values let args: Vec<_> = self.get_args().map(|a| a.to_owned()).collect(); @@ -629,14 +630,19 @@ impl Command { None => command.env_remove(k), }; } - if self.toolchain.is_some() { - command.env_clear().envs(self.resolve_env()); + if let Some(toolchain) = &self.toolchain { + command.envs(toolchain.env_overrides()); } command } /// Returns the path to the cargo program that will be executed. /// + /// Explicit toolchain selection takes precedence over a `CARGO` environment + /// override. Otherwise an explicit `CARGO` value selects the executable for + /// preparation and the final command; clearing or removing it restores the + /// executable captured when the builder was created. + /// /// # Examples /// /// ```no_run @@ -646,6 +652,11 @@ impl Command { /// println!("Program: {:?}", command.get_program()); /// ``` pub fn get_program(&self) -> &OsStr { + if self.toolchain.is_none() + && let Some(Some(cargo)) = self.envs.get(OsStr::new("CARGO")) + { + return cargo; + } self.cargo.path.as_os_str() } @@ -747,3 +758,110 @@ fn should_preserve_cargo_env(key: &OsStr) -> bool { || key.starts_with("CARGO_ALIAS_") || key.starts_with("CARGO_TERM_") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cargo_override_is_used_for_preparation_and_final_command() { + let mut command = Command::new().unwrap(); + let captured = command.get_program().to_owned(); + command.env("CARGO", "other-cargo"); + let prepared = crate::cargo_cmd::cargo_cmd(&command.resolve_env()).unwrap(); + assert_eq!(prepared.get_program(), "other-cargo"); + assert_eq!(command.command().get_program(), prepared.get_program()); + assert_eq!(command.get_program(), prepared.get_program()); + + command.env_remove("CARGO"); + let prepared = crate::cargo_cmd::cargo_cmd(&command.resolve_env()).unwrap(); + assert_eq!(prepared.get_program(), captured); + assert_eq!(command.command().get_program(), captured); + command.env("CARGO", "other-cargo").env_clear(); + assert_eq!(command.command().get_program(), captured); + assert_eq!( + crate::cargo_cmd::cargo_cmd(&command.resolve_env()) + .unwrap() + .get_program(), + captured + ); + } + + #[test] + fn selected_toolchain_does_not_print_inherited_credentials() { + const KEY: &str = "CARGO_REGISTRIES_HYPERLIGHT_TEST_TOKEN"; + const VALUE: &str = "dummy-inherited-credential-for-regression"; + if env::var_os(KEY).is_none() { + let output = StdCommand::new(env::current_exe().unwrap()) + .args([ + "--exact", + "command::tests::selected_toolchain_does_not_print_inherited_credentials", + ]) + .env(KEY, VALUE) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("rust-toolchain.toml"); + let channel = env::var("RUSTUP_TOOLCHAIN").unwrap(); + std::fs::write(&file, format!("[toolchain]\nchannel = {channel:?}")).unwrap(); + let mut command = Command::new().unwrap(); + command + .toolchain_file(file) + .unwrap() + .env("CARGO", "unavailable-host-cargo") + .env("RUSTC", "unavailable-host-rustc") + .env("VISIBLE_TEST_SETTING", "visible") + .env_remove("REMOVED_TEST_SETTING") + .arg("--target") + .arg("x86_64-hyperlight-none") + .arg("--target-dir") + .arg(dir.path()); + + let program = command.get_program().to_owned(); + assert_ne!(program, "unavailable-host-cargo"); + for clear_cargo in [false, true] { + if clear_cargo { + command.env_clear_cargo(); + } + assert_eq!(command.resolve_env().get(OsStr::new(KEY)).unwrap(), VALUE); + let child = command.command(); + assert_eq!(child.get_program(), program); + assert!(!child.get_envs().any(|(key, _)| key == KEY)); + assert!( + child + .get_envs() + .any(|(key, value)| key == "VISIBLE_TEST_SETTING" + && value == Some(OsStr::new("visible"))) + ); + assert!( + child + .get_envs() + .any(|(key, value)| key == "REMOVED_TEST_SETTING" && value.is_none()) + ); + let debug = format!("{command:?}"); + assert!(!debug.contains(KEY)); + assert!(!debug.contains(VALUE)); + let error = command + .command() + .arg("--invalid-cargo-hyperlight-test-flag") + .checked_output() + .err() + .unwrap(); + let diagnostic = format!("{error:#}"); + assert!(!diagnostic.contains(KEY)); + assert!(!diagnostic.contains(VALUE)); + } + command.env_clear(); + assert!(!command.resolve_env().contains_key(OsStr::new(KEY))); + assert_eq!(command.command().get_program(), program); + assert!(!command.command().get_envs().any(|(key, _)| key == KEY)); + } +} From d153d07292d5ad6337ca18be661df5757d202963 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 2 Nov 2026 21:07:11 +0000 Subject: [PATCH 4/6] Install toolchain basd on RUSTUP_AUTO_INSTALL value Signed-off-by: Simon Davies --- README.md | 34 ++++++--- justfile | 6 +- src/cargo_cmd.rs | 155 +++++++++++++++++++++++++++++++++------- src/command.rs | 49 ++++++++----- src/lib.rs | 6 +- src/sysroot.rs | 109 +++++++++++++--------------- tests/toolchain_file.rs | 152 ++++++++++++++++++++++++++++++++++++--- 7 files changed, 386 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index d358ced..cfb1c7e 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ There's no need for any extra configuration, the command will take care of every ## Selecting a guest toolchain from Rust When calling cargo-hyperlight as a library (for example from a host `build.rs`), -select the guest's installed toolchain explicitly: +select the guest's toolchain explicitly: ```rust,no_run fn main() -> anyhow::Result<()> { @@ -111,20 +111,32 @@ channel = "1.94.1" components = ["rust-src"] ``` -Install the toolchain and `rust-src` separately before building: - -```sh -rustup toolchain install 1.94.1 --profile minimal --component rust-src -``` - `toolchain_file` reads the TOML file immediately and returns an error if it is -invalid, unreadable, or names an unavailable toolchain. Relative file paths resolve +invalid, unreadable, or rustup cannot provision the toolchain. Relative file paths resolve against the calling process's current directory, independently of `current_dir`. The selection overrides `CARGO`, `RUSTC`, `RUSTDOC`, and `RUSTUP_TOOLCHAIN` for preparation and the final guest command, including after environment clearing. -It does not change the parent process environment or install anything. The file's -`components`, `targets`, and `profile` settings do not trigger downloads; missing -`rust-src` is reported as an error. Path-based toolchains are not supported. +Rustup installs the selected toolchain using the file's `components`, `targets`, +and `profile` settings. Sysroot preparation also installs `rust-src` when needed. +The parent process environment is unchanged. Select a Rust version using `channel`; +selecting a local compiler directory using `[toolchain].path` is not supported. + +To disable toolchain and component installation, set `RUSTUP_AUTO_INSTALL=0` in +your shell. Library calls inherit this setting: + +```sh +RUSTUP_AUTO_INSTALL=0 cargo build +``` + +For library usage, you can also call `.env("RUSTUP_AUTO_INSTALL", "0")` **before** +`.toolchain_file(...)`. Explicit settings override the inherited value. The file +is processed during the `toolchain_file` call, so settings made afterwards cannot +prevent installation that has already happened. + +With installation disabled, the toolchain file still selects the Rust version. +The requested toolchain, required components (including `rust-src`), and targets +must already be installed; missing prerequisites cause an error. Sysroot +preparation uses the same environment setting to decide whether to install `rust-src`. ## Building C Guests diff --git a/justfile b/justfile index f97432a..f0e6082 100644 --- a/justfile +++ b/justfile @@ -34,7 +34,11 @@ test-new: test-clang-parser: cargo test --test clang_parser -test: test-new test-clang-parser +test-toolchain: build-guest + cargo test --lib + cargo test --test toolchain_file + +test: test-new test-clang-parser test-toolchain build-c-sysroot: cargo hyperlight build-c-sysroot --manifest-path examples/c/fetch-capi/Cargo.toml --c-sysroot-dir examples/c/sysroot diff --git a/src/cargo_cmd.rs b/src/cargo_cmd.rs index acfcdf8..775a6db 100644 --- a/src/cargo_cmd.rs +++ b/src/cargo_cmd.rs @@ -63,21 +63,29 @@ pub(crate) struct GuestToolchain { } impl GuestToolchain { - pub fn from_file(path: &Path) -> Result { + pub fn from_file(path: &Path, auto_install: Option<&OsStr>) -> Result { let contents = std::fs::read_to_string(path) .with_context(|| format!("Failed to read toolchain file {}", path.display()))?; let channel = parse_toolchain(&contents) .with_context(|| format!("Invalid toolchain file {}", path.display()))?; let rustup = which::which("rustup").context("Toolchain selection requires rustup")?; + if rustup_auto_install_enabled(auto_install) { + channel + .install_command(&rustup) + .env_remove("RUSTUP_AUTO_INSTALL") + .envs(auto_install.map(|value| ("RUSTUP_AUTO_INSTALL", value))) + .checked_output() + .context("Failed to install guest toolchain and components")?; + } + let channel = channel.channel; let binary = |name: &str| -> Result { let output = Command::new(&rustup) - .env("RUSTUP_AUTO_INSTALL", "0") + .env_remove("RUSTUP_AUTO_INSTALL") + .envs(auto_install.map(|value| ("RUSTUP_AUTO_INSTALL", value))) .env("RUSTUP_TOOLCHAIN", &channel) .args(["which", "--toolchain", &channel, name]) .checked_output() - .with_context(|| { - format!("Toolchain {channel:?} must already be installed with {name}") - })?; + .with_context(|| format!("Failed to locate {name} for toolchain {channel:?}"))?; let path = PathBuf::from(String::from_utf8(output.stdout)?.trim()); ensure!( path.is_file(), @@ -94,13 +102,12 @@ impl GuestToolchain { }) } - pub fn env_overrides(&self) -> [(&str, &OsStr); 5] { + pub fn env_overrides(&self) -> [(&str, &OsStr); 4] { [ ("CARGO", self.cargo.as_os_str()), ("RUSTC", self.rustc.as_os_str()), ("RUSTDOC", self.rustdoc.as_os_str()), ("RUSTUP_TOOLCHAIN", OsStr::new(&self.channel)), - ("RUSTUP_AUTO_INSTALL", OsStr::new("0")), ] } @@ -119,33 +126,54 @@ impl GuestToolchain { } } -fn parse_toolchain(contents: &str) -> Result { +pub(crate) fn rustup_auto_install_enabled(value: Option<&OsStr>) -> bool { + value != Some(OsStr::new("0")) +} + +#[derive(serde::Deserialize)] +struct Toolchain { + channel: String, + path: Option, + #[serde(default)] + components: Vec, + #[serde(default)] + targets: Vec, + profile: Option, +} + +impl Toolchain { + fn install_command(&self, rustup: &Path) -> Command { + let mut command = Command::new(rustup); + command.args(["toolchain", "install", &self.channel]); + if let Some(profile) = &self.profile { + command.arg("--profile").arg(profile); + } + for component in &self.components { + command.arg("--component").arg(component); + } + for target in &self.targets { + command.arg("--target").arg(target); + } + command + } +} + +fn parse_toolchain(contents: &str) -> Result { #[derive(serde::Deserialize)] struct File { toolchain: Toolchain, } - #[derive(serde::Deserialize)] - struct Toolchain { - channel: String, - path: Option, - #[serde(rename = "components")] - _components: Option>, - #[serde(rename = "targets")] - _targets: Option>, - #[serde(rename = "profile")] - _profile: Option, - } let file: File = toml::from_str(contents).context("Expected [toolchain] with a channel")?; ensure!( file.toolchain.path.is_none(), "Path toolchains are not supported" ); - let channel = file.toolchain.channel; + let channel = &file.toolchain.channel; ensure!( !channel.is_empty() && channel.trim() == channel && !channel.starts_with('-'), "Toolchain channel must be a nonempty rustup toolchain name" ); - Ok(channel) + Ok(file.toolchain) } pub struct CheckedOutput { @@ -310,9 +338,30 @@ impl CargoCmd for Command { } } +pub(crate) fn env_keys_equal(a: &OsStr, b: &OsStr) -> bool { + #[cfg(windows)] + { + return a.eq_ignore_ascii_case(b); + } + + #[cfg(not(windows))] + { + a == b + } +} + +pub(crate) fn get_env_value<'a>( + env: &'a HashMap, + key: &str, +) -> Option<&'a OsStr> { + env.iter().find_map(|(name, value)| { + env_keys_equal(name, OsStr::new(key)).then_some(value.as_os_str()) + }) +} + fn get_env(cmd: &Command, key: &str) -> Option { let mut envs = cmd.get_envs(); - match envs.find(|(k, _)| *k == key) { + match envs.find(|(k, _)| env_keys_equal(k, OsStr::new(key))) { Some((_, v)) => v.map(ToOwned::to_owned), None => std::env::var_os(key), } @@ -350,10 +399,16 @@ pub fn merge_env( .collect::>(); for (k, v) in envs { + let k = k.as_ref(); if let Some(v) = v { - base.insert(k.as_ref().to_owned(), v.as_ref().to_owned()); + if cfg!(windows) { + base.retain(|name, _| !env_keys_equal(name, k)); + } + base.insert(k.to_owned(), v.as_ref().to_owned()); + } else if cfg!(windows) { + base.retain(|name, _| !env_keys_equal(name, k)); } else { - base.remove(k.as_ref()); + base.remove(k); } } @@ -364,6 +419,27 @@ pub fn merge_env( mod tests { use super::*; + #[test] + fn environment_keys_follow_platform_case_rules() { + let upper = OsStr::new("RUSTUP_AUTO_INSTALL"); + let lower = OsStr::new("rustup_auto_install"); + + #[cfg(windows)] + assert!(env_keys_equal(upper, lower)); + + #[cfg(not(windows))] + assert!(!env_keys_equal(upper, lower)); + } + + #[test] + fn rustup_auto_install_requires_explicit_opt_out() { + assert!(rustup_auto_install_enabled(None)); + for value in ["1", "", "false"] { + assert!(rustup_auto_install_enabled(Some(OsStr::new(value)))); + } + assert!(!rustup_auto_install_enabled(Some(OsStr::new("0")))); + } + #[test] fn parse_toolchain_channel() { assert_eq!( @@ -371,11 +447,39 @@ mod tests { "# guest compiler\n[toolchain]\nchannel = '1.94.1'\n\ components = ['rust-src']\nprofile = 'minimal'\n" ) - .unwrap(), + .unwrap() + .channel, "1.94.1" ); } + #[test] + fn install_honors_toolchain_settings() { + let toolchain = parse_toolchain( + "[toolchain]\nchannel = '1.94.1'\ncomponents = ['rust-src', 'clippy']\n\ + targets = ['x86_64-unknown-none']\nprofile = 'minimal'\n", + ) + .unwrap(); + let command = toolchain.install_command(Path::new("rustup")); + let args: Vec<_> = command.get_args().collect(); + assert_eq!( + args, + [ + "toolchain", + "install", + "1.94.1", + "--profile", + "minimal", + "--component", + "rust-src", + "--component", + "clippy", + "--target", + "x86_64-unknown-none", + ] + ); + } + #[test] fn reject_invalid_toolchain_files() { for contents in [ @@ -431,7 +535,6 @@ mod tests { ("RUSTC".into(), "guest-rustc".into()), ("RUSTDOC".into(), "guest-rustdoc".into()), ("RUSTUP_TOOLCHAIN".into(), "1.94.1".into()), - ("RUSTUP_AUTO_INSTALL".into(), "0".into()), ]) ); } diff --git a/src/command.rs b/src/command.rs index fc687ea..f689956 100644 --- a/src/command.rs +++ b/src/command.rs @@ -9,7 +9,10 @@ use anyhow::{Context, Result}; use os_str_bytes::OsStrBytesExt; use crate::CargoCommandExt; -use crate::cargo_cmd::{CargoBinary, CargoCmd as _, GuestToolchain, find_cargo, merge_env}; +use crate::cargo_cmd::{ + CargoBinary, CargoCmd as _, GuestToolchain, env_keys_equal, find_cargo, get_env_value, + merge_env, +}; use crate::cli::{Args, Warning}; /// A process builder for cargo commands, providing a similar API to `std::process::Command`. @@ -133,29 +136,33 @@ impl Command { }) } - /// Selects the installed Rust toolchain named in a `rust-toolchain.toml` file. + /// Selects the Rust toolchain named in a `rust-toolchain.toml` file. /// /// Relative paths resolve against the process's current directory when this /// method is called, not the directory set by [`Self::current_dir`]. The file /// is read immediately and must contain `[toolchain]` with a `channel`. - /// Path-based toolchains are not supported. + /// Selecting a local compiler directory with `[toolchain].path` is not supported. /// /// Both Cargo and rustc are selected for all preparation and guest commands, /// overriding inherited or explicit `CARGO`, `RUSTC`, `RUSTDOC`, and /// `RUSTUP_TOOLCHAIN` values, even after [`Self::env_clear`]. /// No process-global environment is changed. - /// Rustup's automatic installation is disabled for the selected command. - /// - /// Selection does not install toolchains or components. Install the toolchain - /// and its `rust-src` component separately before calling [`Self::status`]. - /// Other file settings such as `components`, `targets`, and `profile` do not - /// trigger installation. + /// Rustup installs the selected toolchain using the file's `components`, + /// `targets`, and `profile` settings. Sysroot preparation also ensures that + /// the `rust-src` component is installed. + /// Set `RUSTUP_AUTO_INSTALL=0` in the parent environment or call + /// `.env("RUSTUP_AUTO_INSTALL", "0")` before this method to use an installed + /// toolchain without provisioning it. Earlier environment overrides and + /// removals are honored; changes made after this call cannot undo provisioning. + /// Sysroot preparation skips installing `rust-src` when the guest command's + /// environment contains `RUSTUP_AUTO_INSTALL=0`. The required components and + /// targets must already be installed; missing prerequisites cause an error. /// /// # Errors /// - /// Returns an error for an unreadable or invalid file, missing rustup, or - /// unavailable Cargo, rustc, or rustdoc. A failed selection leaves the - /// previous configuration unchanged. + /// Returns an error for an unreadable or invalid file, missing rustup, failed + /// installation, or unavailable Cargo, rustc, or rustdoc. A failed selection + /// leaves the previous configuration unchanged. /// /// # Examples /// @@ -171,7 +178,9 @@ impl Command { /// # } /// ``` pub fn toolchain_file(&mut self, path: impl AsRef) -> Result<&mut Self> { - let toolchain = GuestToolchain::from_file(path.as_ref())?; + let env = self.resolve_env(); + let auto_install = get_env_value(&env, "RUSTUP_AUTO_INSTALL"); + let toolchain = GuestToolchain::from_file(path.as_ref(), auto_install)?; self.cargo.path = toolchain.cargo.clone(); self.toolchain = Some(toolchain); Ok(self) @@ -301,8 +310,12 @@ impl Command { /// [`env_clear`]: Command::env_clear /// [`env_remove`]: Command::env_remove pub fn env(&mut self, key: impl AsRef, value: impl AsRef) -> &mut Self { + let key = key.as_ref(); + if cfg!(windows) { + self.envs.retain(|name, _| !env_keys_equal(name, key)); + } self.envs - .insert(key.as_ref().to_owned(), Some(value.as_ref().to_owned())); + .insert(key.to_owned(), Some(value.as_ref().to_owned())); self } @@ -410,7 +423,11 @@ impl Command { /// .exec(); /// ``` pub fn env_remove(&mut self, key: impl AsRef) -> &mut Self { - self.envs.insert(key.as_ref().to_owned(), None); + let key = key.as_ref(); + if cfg!(windows) { + self.envs.retain(|name, _| !env_keys_equal(name, key)); + } + self.envs.insert(key.to_owned(), None); self } @@ -719,7 +736,7 @@ impl Command { pub fn status(&self) -> anyhow::Result<()> { let mut args = self.build_args(); - args.prepare_sysroot_with_install(self.toolchain.is_none()) + args.prepare_sysroot() .context("Failed to prepare sysroot")?; self.command() diff --git a/src/lib.rs b/src/lib.rs index e3e98c1..440c33f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -121,12 +121,8 @@ impl CargoCommandExt for std::process::Command { impl Args { pub fn prepare_sysroot(&mut self) -> Result<()> { - self.prepare_sysroot_with_install(true) - } - - pub(crate) fn prepare_sysroot_with_install(&mut self, install_rust_src: bool) -> Result<()> { // Build sysroot - sysroot::build(self, install_rust_src)?; + sysroot::build(self)?; // Build toolchain toolchain::prepare(self)?; diff --git a/src/sysroot.rs b/src/sysroot.rs index bbc5873..b8d5f06 100644 --- a/src/sysroot.rs +++ b/src/sysroot.rs @@ -1,12 +1,12 @@ use std::ffi::OsStr; use std::ops::Not as _; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::{Output, Stdio}; use anyhow::{Context, Result, bail, ensure}; use serde_json::{Map, Value, json}; -use crate::cargo_cmd::{CargoCmd, cargo_cmd}; +use crate::cargo_cmd::{CargoCmd, cargo_cmd, get_env_value, rustup_auto_install_enabled}; use crate::cli::Args; const CARGO_TOML: &str = include_str!("dummy/_Cargo.toml"); @@ -17,6 +17,20 @@ const LIB_RS: &str = include_str!("dummy/_lib.rs"); /// nightly channel and only when this flag is passed. pub(crate) const UNSTABLE_TARGET_SPEC_FLAG: &str = "-Zunstable-options"; +fn rust_src_install_command(args: &Args) -> Option { + let toolchain = args.env.get(OsStr::new("RUSTUP_TOOLCHAIN"))?; + if !rustup_auto_install_enabled(get_env_value(&args.env, "RUSTUP_AUTO_INSTALL")) { + return None; + } + let mut command = std::process::Command::new("rustup"); + command + .env_clear() + .envs(&args.env) + .args(["--quiet", "component", "add", "rust-src", "--toolchain"]) + .arg(toolchain); + Some(command) +} + /// Queries the version of the cargo release in use. fn cargo_version(args: &Args) -> Result { let output = cargo_cmd(&args.env)? @@ -119,7 +133,7 @@ pub(crate) struct CargoBuildMessage { pub(crate) filenames: Vec, } -pub fn build(args: &mut Args, install_rust_src: bool) -> Result<()> { +pub fn build(args: &mut Args) -> Result<()> { let cargo_version = cargo_version(args)?; let target_spec = match args.target.as_str() { @@ -192,39 +206,12 @@ Supported values are: .context("Failed to write Cargo.toml")?; std::fs::write(crate_dir.join("lib.rs"), LIB_RS).context("Failed to write lib.rs")?; - // if we are using rustup, ensure that the rust-src component is installed - if install_rust_src && let Some(rustup_toolchain) = args.env.get(OsStr::new("RUSTUP_TOOLCHAIN")) - { - std::process::Command::new("rustup") - .env_clear() - .envs(&args.env) - .arg("--quiet") - .arg("component") - .arg("add") - .arg("rust-src") - .arg("--toolchain") - .arg(rustup_toolchain) + // Install rust-src only when rustup provisioning is enabled. + if let Some(mut command) = rust_src_install_command(args) { + command .checked_output() .context("Failed to get Rust's std lib sources")?; } - if !install_rust_src { - let rustc = args - .env - .get(OsStr::new("RUSTC")) - .context("Missing selected rustc")?; - let output = std::process::Command::new(rustc) - .env_clear() - .envs(&args.env) - .arg("--print=sysroot") - .checked_output()?; - let sysroot = PathBuf::from(String::from_utf8(output.stdout)?.trim()); - require_rust_src( - &sysroot, - args.env - .get(OsStr::new("RUSTUP_TOOLCHAIN")) - .context("Missing selected toolchain")?, - )?; - } // Build the sysroot let output = cargo_cmd(&args.env)? @@ -322,35 +309,41 @@ fn get_spec(args: &Args, triplet: impl AsRef) -> Result> serde_json::from_slice(&output.stdout).context("Failed to parse target spec JSON") } -fn require_rust_src(sysroot: &Path, toolchain: &OsStr) -> Result<()> { - ensure!( - sysroot - .join("lib/rustlib/src/rust/library/Cargo.toml") - .is_file(), - "Selected toolchain is missing rust-src; install it separately with \ - `rustup component add rust-src --toolchain {}`", - toolchain.to_string_lossy() - ); - Ok(()) -} - #[cfg(test)] mod tests { use super::*; #[test] - fn selected_toolchain_requires_preinstalled_sources() { - let dir = tempfile::tempdir().unwrap(); - let error = require_rust_src(dir.path(), OsStr::new("1.94.1")).unwrap_err(); - assert!( - error - .to_string() - .contains("rustup component add rust-src --toolchain 1.94.1") - ); - assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); - let sources = dir.path().join("lib/rustlib/src/rust/library"); - std::fs::create_dir_all(&sources).unwrap(); - std::fs::write(sources.join("Cargo.toml"), "").unwrap(); - require_rust_src(dir.path(), OsStr::new("1.94.1")).unwrap(); + fn rust_src_provisioning_uses_guest_environment() { + let mut command = crate::cargo().unwrap(); + command + .env_clear() + .env("RUSTUP_TOOLCHAIN", "guest-toolchain") + .env("RUSTUP_AUTO_INSTALL", "0"); + assert!(rust_src_install_command(&command.build_args()).is_none()); + + for value in [Some("1"), None] { + if let Some(value) = value { + command.env("RUSTUP_AUTO_INSTALL", value); + } else { + command.env_remove("RUSTUP_AUTO_INSTALL"); + } + let install = rust_src_install_command(&command.build_args()).unwrap(); + assert_eq!(install.get_program(), "rustup"); + assert_eq!( + install.get_args().collect::>(), + [ + "--quiet", + "component", + "add", + "rust-src", + "--toolchain", + "guest-toolchain" + ] + ); + } + + command.env_remove("RUSTUP_TOOLCHAIN"); + assert!(rust_src_install_command(&command.build_args()).is_none()); } } diff --git a/tests/toolchain_file.rs b/tests/toolchain_file.rs index 392654e..28b27ed 100644 --- a/tests/toolchain_file.rs +++ b/tests/toolchain_file.rs @@ -24,19 +24,155 @@ fn unavailable_toolchain_does_not_fall_back() { let file = dir.path().join("rust-toolchain.toml"); let mut command = cargo_hyperlight::cargo().unwrap(); let original = command.get_program().to_owned(); - for channel in ["cargo-hyperlight-missing-toolchain", "1.94.999"] { - std::fs::write(&file, format!("[toolchain]\nchannel = '{channel}'")).unwrap(); - let error = command.toolchain_file(&file).unwrap_err(); - let error = format!("{error:#}"); - assert!(error.contains("must already be installed"), "{error}"); - assert!(!error.contains("syncing channel updates"), "{error}"); - assert_eq!(command.get_program(), original); + std::fs::write( + &file, + "[toolchain]\nchannel = 'cargo-hyperlight-missing-toolchain'", + ) + .unwrap(); + let error = command.toolchain_file(&file).unwrap_err(); + let error = format!("{error:#}"); + assert!( + error.contains("Failed to install guest toolchain") + || error.contains("Failed to locate cargo"), + "{error}" + ); + assert_eq!(command.get_program(), original); +} + +#[cfg(unix)] +#[test] +fn toolchain_provisioning_honors_auto_install() { + use std::os::unix::fs::PermissionsExt; + + const FILE_ENV: &str = "CARGO_HYPERLIGHT_TEST_TOOLCHAIN_FILE"; + const MISSING_ENV: &str = "CARGO_HYPERLIGHT_TEST_TOOLCHAIN_MISSING"; + const MODE_ENV: &str = "CARGO_HYPERLIGHT_TEST_ENV_MODE"; + if let Some(file) = std::env::var_os(FILE_ENV) { + let inherited = std::env::var_os("RUSTUP_AUTO_INSTALL"); + let mut command = cargo_hyperlight::cargo().unwrap(); + match std::env::var(MODE_ENV).unwrap().as_str() { + "inherit" => {} + "0" => { + command.env("RUSTUP_AUTO_INSTALL", "0"); + } + "1" => { + command.env("RUSTUP_AUTO_INSTALL", "1"); + } + "remove" => { + command.env_remove("RUSTUP_AUTO_INSTALL"); + } + "clear" => { + command.env_clear(); + } + "clear_cargo" => { + command.env_clear_cargo(); + } + mode => panic!("Unknown test environment mode: {mode}"), + } + let original = command.get_program().to_owned(); + let result = command.toolchain_file(file); + if std::env::var_os(MISSING_ENV).as_deref() == Some(std::ffi::OsStr::new("1")) { + let error = format!("{:#}", result.unwrap_err()); + assert!(error.contains("Failed to locate cargo"), "{error}"); + assert_eq!(command.get_program(), original); + } else { + result.unwrap(); + assert_eq!(command.get_program(), std::env::current_exe().unwrap()); + } + assert_eq!(std::env::var_os("RUSTUP_AUTO_INSTALL"), inherited); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("rust-toolchain.toml"); + let log = dir.path().join("rustup.log"); + let rustup = dir.path().join("rustup"); + std::fs::write( + &file, + "[toolchain]\nchannel = 'guest-test'\ncomponents = ['rust-src']\n", + ) + .unwrap(); + std::fs::write( + &rustup, + r#"#!/bin/sh +printf '%s\n' "$*" >> "$CARGO_HYPERLIGHT_TEST_RUSTUP_LOG" + printf 'auto-install=%s\n' "${RUSTUP_AUTO_INSTALL-unset}" >> "$CARGO_HYPERLIGHT_TEST_RUSTUP_LOG" +case "$1" in + toolchain) exit 0 ;; + which) + if [ "$CARGO_HYPERLIGHT_TEST_TOOLCHAIN_MISSING" = 1 ]; then + exit 1 + fi + printf '%s\n' "$CARGO_HYPERLIGHT_TEST_BINARY" + ;; + *) exit 1 ;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&rustup, std::fs::Permissions::from_mode(0o755)).unwrap(); + + for (auto_install, mode, effective) in [ + (None, "inherit", None), + (Some("1"), "inherit", Some("1")), + (Some("0"), "inherit", Some("0")), + (None, "0", Some("0")), + (Some("1"), "0", Some("0")), + (Some("0"), "1", Some("1")), + (Some("0"), "remove", None), + (Some("0"), "clear", None), + (Some("0"), "clear_cargo", Some("0")), + ] { + for missing in [false, true] { + std::fs::write(&log, "").unwrap(); + let mut child = Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "toolchain_provisioning_honors_auto_install", + "--nocapture", + ]) + .env("PATH", dir.path()) + .env("CARGO", env!("CARGO")) + .env(FILE_ENV, &file) + .env(MISSING_ENV, if missing { "1" } else { "0" }) + .env(MODE_ENV, mode) + .env("CARGO_HYPERLIGHT_TEST_RUSTUP_LOG", &log) + .env( + "CARGO_HYPERLIGHT_TEST_BINARY", + std::env::current_exe().unwrap(), + ) + .env_remove("RUSTUP_AUTO_INSTALL"); + if let Some(value) = auto_install { + child.env("RUSTUP_AUTO_INSTALL", value); + } + let output = child.output().unwrap(); + assert!(output.status.success(), "{output:?}"); + let calls = std::fs::read_to_string(&log).unwrap(); + assert_eq!( + calls.contains("toolchain install guest-test --component rust-src\n"), + effective != Some("0"), + "{calls}" + ); + let expected_env = format!("auto-install={}", effective.unwrap_or("unset")); + assert!( + calls + .lines() + .filter(|line| line.starts_with("auto-install=")) + .all(|line| line == expected_env), + "{calls}" + ); + assert!( + calls.contains("which --toolchain guest-test cargo\n"), + "{calls}" + ); + } } } /// Run with a newer host: cargo +1.99.1 test --test toolchain_file -- --include-ignored #[test] -#[ignore = "requires a host compiler newer than 1.94, Rust 1.94.1 with rust-src, and guest build dependencies"] +#[ignore = "requires a host compiler newer than 1.94, rustup downloads, and guest build dependencies"] fn newer_host_builds_guest_with_older_toolchain() { let host = Command::new(env!("CARGO")) .args(["--version"]) From 5993b956fdcfd091a02ad3c3592baff67e0a2b9f Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Tue, 3 Nov 2026 09:47:35 +0000 Subject: [PATCH 5/6] fix clippy Signed-off-by: Simon Davies --- src/cargo_cmd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cargo_cmd.rs b/src/cargo_cmd.rs index 775a6db..4f914ae 100644 --- a/src/cargo_cmd.rs +++ b/src/cargo_cmd.rs @@ -341,7 +341,7 @@ impl CargoCmd for Command { pub(crate) fn env_keys_equal(a: &OsStr, b: &OsStr) -> bool { #[cfg(windows)] { - return a.eq_ignore_ascii_case(b); + a.eq_ignore_ascii_case(b) } #[cfg(not(windows))] From 8ef36a6ef539e9084e18dfcc0ca35512b69d2bf4 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 9 Nov 2026 19:19:33 +0000 Subject: [PATCH 6/6] Fixes from feedback Signed-off-by: Simon Davies --- README.md | 5 ++- src/cargo_cmd.rs | 98 +++++++++++++++++++++++++++++++---------- src/command.rs | 45 ++++++++++++++++--- src/sysroot.rs | 80 +++++++++++++++++++++++++++++++-- tests/toolchain_file.rs | 82 +++++++++++++++++++++++++++++----- 5 files changed, 265 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index cfb1c7e..1c9718d 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,10 @@ against the calling process's current directory, independently of `current_dir`. The selection overrides `CARGO`, `RUSTC`, `RUSTDOC`, and `RUSTUP_TOOLCHAIN` for preparation and the final guest command, including after environment clearing. Rustup installs the selected toolchain using the file's `components`, `targets`, -and `profile` settings. Sysroot preparation also installs `rust-src` when needed. +and `profile` settings without updating installed toolchains or Rustup itself. +Selection uses the builder's resolved environment, including any `PATH` and +`RUSTUP_HOME` settings made before `toolchain_file`. +Sysroot preparation also installs `rust-src` when needed. The parent process environment is unchanged. Select a Rust version using `channel`; selecting a local compiler directory using `[toolchain].path` is not supported. diff --git a/src/cargo_cmd.rs b/src/cargo_cmd.rs index 4f914ae..0f80aab 100644 --- a/src/cargo_cmd.rs +++ b/src/cargo_cmd.rs @@ -45,7 +45,7 @@ pub fn find_cargo() -> Result { } pub fn cargo_cmd(env: &HashMap) -> Result { - let cargo = match env.get(OsStr::new("CARGO")) { + let cargo = match get_env_value(env, "CARGO") { Some(cargo) => PathBuf::from(cargo), None => find_cargo()?.path, }; @@ -63,25 +63,26 @@ pub(crate) struct GuestToolchain { } impl GuestToolchain { - pub fn from_file(path: &Path, auto_install: Option<&OsStr>) -> Result { + pub fn from_file(path: &Path, env: &HashMap) -> Result { let contents = std::fs::read_to_string(path) .with_context(|| format!("Failed to read toolchain file {}", path.display()))?; let channel = parse_toolchain(&contents) .with_context(|| format!("Invalid toolchain file {}", path.display()))?; - let rustup = which::which("rustup").context("Toolchain selection requires rustup")?; - if rustup_auto_install_enabled(auto_install) { + let rustup = which::which_in("rustup", get_env_value(env, "PATH"), env::current_dir()?) + .context("Toolchain selection requires rustup")?; + if rustup_auto_install_enabled(get_env_value(env, "RUSTUP_AUTO_INSTALL")) { channel .install_command(&rustup) - .env_remove("RUSTUP_AUTO_INSTALL") - .envs(auto_install.map(|value| ("RUSTUP_AUTO_INSTALL", value))) + .env_clear() + .envs(env) .checked_output() .context("Failed to install guest toolchain and components")?; } let channel = channel.channel; let binary = |name: &str| -> Result { let output = Command::new(&rustup) - .env_remove("RUSTUP_AUTO_INSTALL") - .envs(auto_install.map(|value| ("RUSTUP_AUTO_INSTALL", value))) + .env_clear() + .envs(env) .env("RUSTUP_TOOLCHAIN", &channel) .args(["which", "--toolchain", &channel, name]) .checked_output() @@ -113,14 +114,7 @@ impl GuestToolchain { pub fn apply_env(&self, env: &mut HashMap) { for (key, value) in self.env_overrides() { - // Windows environment names are case-insensitive. - env.retain(|k, _| { - if cfg!(windows) { - !k.eq_ignore_ascii_case(OsStr::new(key)) - } else { - k != key - } - }); + env.retain(|k, _| !env_keys_equal(k, OsStr::new(key))); env.insert(key.into(), value.into()); } } @@ -144,7 +138,13 @@ struct Toolchain { impl Toolchain { fn install_command(&self, rustup: &Path) -> Command { let mut command = Command::new(rustup); - command.args(["toolchain", "install", &self.channel]); + command.args([ + "toolchain", + "install", + &self.channel, + "--no-update", + "--no-self-update", + ]); if let Some(profile) = &self.profile { command.arg("--profile").arg(profile); } @@ -310,17 +310,20 @@ impl CargoCmd for Command { } fn checked_output(&mut self) -> Result { - let output = self.output(); - - let Ok(output) = output else { - bail!("Failed to execute command:\n{self:?}"); - }; + let command = format!( + "{:?} {:?}", + self.get_program(), + self.get_args().collect::>() + ); + let output = self + .output() + .with_context(|| format!("Failed to execute command:\n{command}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); if let Some(code) = output.status.code() { - bail!("Command exited with code {code}:\n{self:?}\n{stderr}"); + bail!("Command exited with code {code}:\n{command}\n{stderr}"); } else { - bail!("Command terminated by signal:\n{self:?}\n{stderr}"); + bail!("Command terminated by signal:\n{command}\n{stderr}"); } } @@ -419,6 +422,44 @@ pub fn merge_env( mod tests { use super::*; + #[test] + fn command_failures_do_not_print_environment() { + const KEY: &str = "CARGO_REGISTRIES_HYPERLIGHT_TEST_TOKEN"; + const VALUE: &str = "dummy-credential-for-command-failure"; + for program in [ + std::env::current_exe().unwrap(), + PathBuf::from("cargo-hyperlight-nonexistent-executable"), + ] { + let error = Command::new(&program) + .arg("--invalid-cargo-hyperlight-test-flag") + .env(KEY, VALUE) + .checked_output() + .err() + .unwrap(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains(&format!("{program:?}")), "{diagnostic}"); + assert!( + diagnostic.contains("--invalid-cargo-hyperlight-test-flag"), + "{diagnostic}" + ); + assert!(!diagnostic.contains(KEY), "{diagnostic}"); + assert!(!diagnostic.contains(VALUE), "{diagnostic}"); + if program.is_file() { + assert!( + diagnostic.contains("Command exited with code"), + "{diagnostic}" + ); + assert!(diagnostic.contains("Unrecognized option"), "{diagnostic}"); + } else { + assert!( + diagnostic.contains("Failed to execute command"), + "{diagnostic}" + ); + assert!(error.source().is_some(), "{diagnostic}"); + } + } + } + #[test] fn environment_keys_follow_platform_case_rules() { let upper = OsStr::new("RUSTUP_AUTO_INSTALL"); @@ -468,6 +509,8 @@ mod tests { "toolchain", "install", "1.94.1", + "--no-update", + "--no-self-update", "--profile", "minimal", "--component", @@ -513,6 +556,13 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn effective_cargo_lookup_is_case_insensitive() { + let env = HashMap::from([("cargo".into(), "guest-cargo".into())]); + assert_eq!(cargo_cmd(&env).unwrap().get_program(), "guest-cargo"); + } + #[test] fn selected_toolchain_replaces_host_environment() { let toolchain = GuestToolchain { diff --git a/src/command.rs b/src/command.rs index f689956..549ddba 100644 --- a/src/command.rs +++ b/src/command.rs @@ -10,8 +10,7 @@ use os_str_bytes::OsStrBytesExt; use crate::CargoCommandExt; use crate::cargo_cmd::{ - CargoBinary, CargoCmd as _, GuestToolchain, env_keys_equal, find_cargo, get_env_value, - merge_env, + CargoBinary, CargoCmd as _, GuestToolchain, env_keys_equal, find_cargo, merge_env, }; use crate::cli::{Args, Warning}; @@ -148,7 +147,9 @@ impl Command { /// `RUSTUP_TOOLCHAIN` values, even after [`Self::env_clear`]. /// No process-global environment is changed. /// Rustup installs the selected toolchain using the file's `components`, - /// `targets`, and `profile` settings. Sysroot preparation also ensures that + /// `targets`, and `profile` settings without updating installed toolchains + /// or Rustup itself. Selection uses the resolved environment, including + /// earlier `PATH` and `RUSTUP_HOME` overrides. Sysroot preparation also ensures that /// the `rust-src` component is installed. /// Set `RUSTUP_AUTO_INSTALL=0` in the parent environment or call /// `.env("RUSTUP_AUTO_INSTALL", "0")` before this method to use an installed @@ -179,8 +180,7 @@ impl Command { /// ``` pub fn toolchain_file(&mut self, path: impl AsRef) -> Result<&mut Self> { let env = self.resolve_env(); - let auto_install = get_env_value(&env, "RUSTUP_AUTO_INSTALL"); - let toolchain = GuestToolchain::from_file(path.as_ref(), auto_install)?; + let toolchain = GuestToolchain::from_file(path.as_ref(), &env)?; self.cargo.path = toolchain.cargo.clone(); self.toolchain = Some(toolchain); Ok(self) @@ -587,6 +587,7 @@ impl Command { fn resolve_env(&self) -> HashMap { let mut env = merge_env(self.base_env(), self.get_envs()); + env.retain(|name, _| !env_keys_equal(name, OsStr::new("CARGO"))); env.insert("CARGO".into(), self.get_program().to_owned()); if let Some(toolchain) = &self.toolchain { toolchain.apply_env(&mut env); @@ -670,7 +671,10 @@ impl Command { /// ``` pub fn get_program(&self) -> &OsStr { if self.toolchain.is_none() - && let Some(Some(cargo)) = self.envs.get(OsStr::new("CARGO")) + && let Some((_, Some(cargo))) = self + .envs + .iter() + .find(|(name, _)| env_keys_equal(name, OsStr::new("CARGO"))) { return cargo; } @@ -804,6 +808,34 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn cargo_override_uses_windows_case_rules() { + let mut command = Command::new().unwrap(); + let captured = command.get_program().to_owned(); + command + .env("CARGO", "first-cargo") + .env("cargo", "guest-cargo"); + let env = command.resolve_env(); + assert_eq!(command.get_program(), "guest-cargo"); + assert_eq!(command.command().get_program(), "guest-cargo"); + assert_eq!( + crate::cargo_cmd::cargo_cmd(&env).unwrap().get_program(), + "guest-cargo" + ); + assert_eq!( + env.keys() + .filter(|name| env_keys_equal(name, OsStr::new("CARGO"))) + .count(), + 1 + ); + command.env_remove("CaRgO"); + assert_eq!(command.get_program(), captured); + assert_eq!(command.command().get_program(), captured); + command.env("cargo", "guest-cargo").env_clear(); + assert_eq!(command.get_program(), captured); + } + #[test] fn selected_toolchain_does_not_print_inherited_credentials() { const KEY: &str = "CARGO_REGISTRIES_HYPERLIGHT_TEST_TOKEN"; @@ -831,6 +863,7 @@ mod tests { std::fs::write(&file, format!("[toolchain]\nchannel = {channel:?}")).unwrap(); let mut command = Command::new().unwrap(); command + .env("RUSTUP_AUTO_INSTALL", "0") .toolchain_file(file) .unwrap() .env("CARGO", "unavailable-host-cargo") diff --git a/src/sysroot.rs b/src/sysroot.rs index b8d5f06..30f7b08 100644 --- a/src/sysroot.rs +++ b/src/sysroot.rs @@ -1,4 +1,3 @@ -use std::ffi::OsStr; use std::ops::Not as _; use std::path::PathBuf; use std::process::{Output, Stdio}; @@ -18,7 +17,7 @@ const LIB_RS: &str = include_str!("dummy/_lib.rs"); pub(crate) const UNSTABLE_TARGET_SPEC_FLAG: &str = "-Zunstable-options"; fn rust_src_install_command(args: &Args) -> Option { - let toolchain = args.env.get(OsStr::new("RUSTUP_TOOLCHAIN"))?; + let toolchain = get_env_value(&args.env, "RUSTUP_TOOLCHAIN")?; if !rustup_auto_install_enabled(get_env_value(&args.env, "RUSTUP_AUTO_INSTALL")) { return None; } @@ -54,7 +53,7 @@ fn cargo_version(args: &Args) -> Result { /// Asks rustc to print the configuration of the hyperlight target, which forces /// it to load the custom target specification from the sysroot. fn probe_target_spec(args: &Args, extra_flags: &[&str]) -> Result { - let rustc = match args.env.get(OsStr::new("RUSTC")) { + let rustc = match get_env_value(&args.env, "RUSTC") { Some(rustc) => PathBuf::from(rustc), None => which::which("rustc").context("Failed to find rustc")?, }; @@ -313,6 +312,81 @@ fn get_spec(args: &Args, triplet: impl AsRef) -> Result> mod tests { use super::*; + #[cfg(unix)] + #[test] + fn rust_src_install_failure_does_not_print_inherited_credentials() { + use std::os::unix::fs::PermissionsExt; + + const KEY: &str = "CARGO_REGISTRIES_HYPERLIGHT_TEST_TOKEN"; + const VALUE: &str = "dummy-inherited-credential-for-install-failure"; + if std::env::var_os(KEY).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "sysroot::tests::rust_src_install_failure_does_not_print_inherited_credentials", + ]) + .env(KEY, VALUE) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let rustup = dir.path().join("rustup"); + std::fs::write( + &rustup, + "#!/bin/sh\necho 'dummy rust-src install failure' >&2\nexit 42\n", + ) + .unwrap(); + std::fs::set_permissions(&rustup, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut command = crate::cargo().unwrap(); + command + .env("PATH", dir.path()) + .env("RUSTUP_TOOLCHAIN", "guest-toolchain") + .env("RUSTUP_AUTO_INSTALL", "1"); + let args = command.build_args(); + assert_eq!( + get_env_value(&args.env, KEY), + Some(std::ffi::OsStr::new(VALUE)) + ); + let error = rust_src_install_command(&args) + .unwrap() + .checked_output() + .err() + .unwrap(); + let diagnostic = format!("{error:#}"); + for expected in [ + "rustup", + "rust-src", + "guest-toolchain", + "code 42", + "dummy rust-src install failure", + ] { + assert!(diagnostic.contains(expected), "{diagnostic}"); + } + assert!(!diagnostic.contains(KEY), "{diagnostic}"); + assert!(!diagnostic.contains(VALUE), "{diagnostic}"); + } + + #[cfg(windows)] + #[test] + fn rust_src_toolchain_lookup_is_case_insensitive() { + let mut command = crate::cargo().unwrap(); + command + .env("rustup_toolchain", "guest-toolchain") + .env("rustup_auto_install", "1"); + let install = rust_src_install_command(&command.build_args()).unwrap(); + assert_eq!( + install.get_args().last(), + Some(std::ffi::OsStr::new("guest-toolchain")) + ); + } + #[test] fn rust_src_provisioning_uses_guest_environment() { let mut command = crate::cargo().unwrap(); diff --git a/tests/toolchain_file.rs b/tests/toolchain_file.rs index 28b27ed..ac5a433 100644 --- a/tests/toolchain_file.rs +++ b/tests/toolchain_file.rs @@ -45,7 +45,7 @@ fn toolchain_provisioning_honors_auto_install() { use std::os::unix::fs::PermissionsExt; const FILE_ENV: &str = "CARGO_HYPERLIGHT_TEST_TOOLCHAIN_FILE"; - const MISSING_ENV: &str = "CARGO_HYPERLIGHT_TEST_TOOLCHAIN_MISSING"; + const MISSING_ENV: &str = "HYPERLIGHT_TEST_TOOLCHAIN_MISSING"; const MODE_ENV: &str = "CARGO_HYPERLIGHT_TEST_ENV_MODE"; if let Some(file) = std::env::var_os(FILE_ENV) { let inherited = std::env::var_os("RUSTUP_AUTO_INSTALL"); @@ -63,6 +63,14 @@ fn toolchain_provisioning_honors_auto_install() { } "clear" => { command.env_clear(); + for key in [ + "PATH", + "HYPERLIGHT_TEST_RUSTUP_LOG", + "HYPERLIGHT_TEST_BINARY", + MISSING_ENV, + ] { + command.env(key, std::env::var_os(key).unwrap()); + } } "clear_cargo" => { command.env_clear_cargo(); @@ -95,15 +103,15 @@ fn toolchain_provisioning_honors_auto_install() { std::fs::write( &rustup, r#"#!/bin/sh -printf '%s\n' "$*" >> "$CARGO_HYPERLIGHT_TEST_RUSTUP_LOG" - printf 'auto-install=%s\n' "${RUSTUP_AUTO_INSTALL-unset}" >> "$CARGO_HYPERLIGHT_TEST_RUSTUP_LOG" +printf '%s\n' "$*" >> "$HYPERLIGHT_TEST_RUSTUP_LOG" + printf 'auto-install=%s\n' "${RUSTUP_AUTO_INSTALL-unset}" >> "$HYPERLIGHT_TEST_RUSTUP_LOG" case "$1" in toolchain) exit 0 ;; which) - if [ "$CARGO_HYPERLIGHT_TEST_TOOLCHAIN_MISSING" = 1 ]; then + if [ "$HYPERLIGHT_TEST_TOOLCHAIN_MISSING" = 1 ]; then exit 1 fi - printf '%s\n' "$CARGO_HYPERLIGHT_TEST_BINARY" + printf '%s\n' "$HYPERLIGHT_TEST_BINARY" ;; *) exit 1 ;; esac @@ -137,11 +145,8 @@ esac .env(FILE_ENV, &file) .env(MISSING_ENV, if missing { "1" } else { "0" }) .env(MODE_ENV, mode) - .env("CARGO_HYPERLIGHT_TEST_RUSTUP_LOG", &log) - .env( - "CARGO_HYPERLIGHT_TEST_BINARY", - std::env::current_exe().unwrap(), - ) + .env("HYPERLIGHT_TEST_RUSTUP_LOG", &log) + .env("HYPERLIGHT_TEST_BINARY", std::env::current_exe().unwrap()) .env_remove("RUSTUP_AUTO_INSTALL"); if let Some(value) = auto_install { child.env("RUSTUP_AUTO_INSTALL", value); @@ -150,7 +155,7 @@ esac assert!(output.status.success(), "{output:?}"); let calls = std::fs::read_to_string(&log).unwrap(); assert_eq!( - calls.contains("toolchain install guest-test --component rust-src\n"), + calls.contains("toolchain install guest-test --no-update --no-self-update --component rust-src\n"), effective != Some("0"), "{calls}" ); @@ -170,6 +175,61 @@ esac } } +#[cfg(unix)] +#[test] +fn toolchain_selection_uses_builder_environment() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("rust-toolchain.toml"); + let log = dir.path().join("rustup.log"); + let rustup = dir.path().join("rustup"); + let home = dir.path().join("rustup-home"); + std::fs::write(&file, "[toolchain]\nchannel = 'guest-test'\n").unwrap(); + std::fs::write( + &rustup, + r#"#!/bin/sh +printf '%s|%s|%s\n' "$*" "$RUSTUP_HOME" "${CARGO_HYPERLIGHT_REMOVED-unset}" >> "$HYPERLIGHT_TEST_RUSTUP_LOG" +case "$1" in + toolchain) exit 0 ;; + which) printf '%s\n' "$HYPERLIGHT_TEST_BINARY" ;; + *) exit 1 ;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&rustup, std::fs::Permissions::from_mode(0o755)).unwrap(); + + for clear in [false, true] { + std::fs::write(&log, "").unwrap(); + let mut command = cargo_hyperlight::cargo().unwrap(); + if clear { + command.env_clear(); + } + command + .env("PATH", dir.path()) + .env("RUSTUP_HOME", &home) + .env("RUSTUP_AUTO_INSTALL", "1") + .env("HYPERLIGHT_TEST_RUSTUP_LOG", &log) + .env("HYPERLIGHT_TEST_BINARY", std::env::current_exe().unwrap()) + .env("CARGO_HYPERLIGHT_REMOVED", "remove-me") + .env_remove("CARGO_HYPERLIGHT_REMOVED") + .toolchain_file(&file) + .unwrap(); + assert_eq!(command.get_program(), std::env::current_exe().unwrap()); + let calls = std::fs::read_to_string(&log).unwrap(); + let expected = [ + "toolchain install guest-test --no-update --no-self-update", + "which --toolchain guest-test cargo", + "which --toolchain guest-test rustc", + "which --toolchain guest-test rustdoc", + ] + .map(|args| format!("{args}|{}|unset\n", home.display())) + .concat(); + assert_eq!(calls, expected); + } +} + /// Run with a newer host: cargo +1.99.1 test --test toolchain_file -- --include-ignored #[test] #[ignore = "requires a host compiler newer than 1.94, rustup downloads, and guest build dependencies"]