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 c9b6606..7290b57 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" @@ -525,6 +535,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" @@ -626,6 +675,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..d358ced 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,45 @@ 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. + ## 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..acfcdf8 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; @@ -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), @@ -54,8 +44,108 @@ 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 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) { + !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 +359,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..fc687ea 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,11 +569,16 @@ 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()); + env.insert("CARGO".into(), self.get_program().to_owned()); + if let Some(toolchain) = &self.toolchain { + toolchain.apply_env(&mut env); + } + env } 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(); @@ -579,11 +630,19 @@ impl Command { None => command.env_remove(k), }; } + 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 @@ -593,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() } @@ -655,7 +719,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() @@ -694,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)); + } +} 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" + ); +}