diff --git a/Cargo.toml b/Cargo.toml index 4efa1efee1..5082a5ebaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,9 @@ otel = [ # Exports code dependent on private interfaces for the integration test suite test = ["dep:snapbox", "dep:walkdir", "clap-cargo/testing_colors"] +# Run the tests that require containers. +test-with-containers = ["test"] + # Sorted by alphabetic order [dependencies] anstream = "0.6.20" diff --git a/doc/user-guide/src/environment-variables.md b/doc/user-guide/src/environment-variables.md index bb9dc3d512..b29cc79562 100644 --- a/doc/user-guide/src/environment-variables.md +++ b/doc/user-guide/src/environment-variables.md @@ -28,6 +28,16 @@ - `RUSTUP_VERSION` (default: none). Overrides the rustup version (e.g. `1.27.1`) to be downloaded when executing `rustup-init.sh` or `rustup self update`. +- `RUSTUP_AUTHORIZATION_HEADER` (default: none). The value to an `Authorization` HTTP + header that should be added to all requests made by rustup. This is meant for use when + using an alternate rustup distribution server (through the `RUSTUP_DIST_SERVER` + environment variable) which requires authentication such as basic username:password + credentials or a bearer token. + +- `RUSTUP_PROXY_AUTHORIZATION_HEADER` (default: none). This is like the `RUSTUP_AUTHORIZATION_HEADER` except + this will add a `Proxy-Authorization` HTTP header. This is for authenticating to forward + proxies (via the `HTTP_PROXY` or `HTTPS_PROXY`) environment variables. + - `RUSTUP_IO_THREADS` *unstable* (default: reported cpu count, max 8). Sets the number of threads to perform close IO in. Set to `1` to force single-threaded IO for troubleshooting, or an arbitrary number to override diff --git a/src/download/mod.rs b/src/download/mod.rs index 37ae9b32d4..7f7e7e6016 100644 --- a/src/download/mod.rs +++ b/src/download/mod.rs @@ -207,7 +207,14 @@ async fn download_file_( }; let res = backend - .download_to_path(url, path, resume_from_partial, Some(callback), timeout) + .download_to_path( + url, + path, + resume_from_partial, + Some(callback), + timeout, + process, + ) .await; // The notification should only be sent if the download was successful (i.e. didn't timeout) @@ -221,6 +228,19 @@ async fn download_file_( res } +#[cfg(any( + feature = "curl-backend", + feature = "reqwest-rustls-tls", + feature = "reqwest-native-tls" +))] +const RUSTUP_AUTHORIZATION_HEADER_ENV_VAR: &str = "RUSTUP_AUTHORIZATION_HEADER"; +#[cfg(any( + feature = "curl-backend", + feature = "reqwest-rustls-tls", + feature = "reqwest-native-tls" +))] +const RUSTUP_PROXY_AUTHORIZATION_HEADER_ENV_VAR: &str = "RUSTUP_PROXY_AUTHORIZATION_HEADER"; + /// User agent header value for HTTP request. /// See: https://github.com/rust-lang/rustup/issues/2860. #[cfg(feature = "curl-backend")] @@ -253,9 +273,10 @@ impl Backend { resume_from_partial: bool, callback: Option>, timeout: Duration, + process: &Process, ) -> anyhow::Result<()> { let Err(err) = self - .download_impl(url, path, resume_from_partial, callback, timeout) + .download_impl(url, path, resume_from_partial, callback, timeout, process) .await else { return Ok(()); @@ -278,6 +299,7 @@ impl Backend { resume_from_partial: bool, callback: Option>, timeout: Duration, + process: &Process, ) -> anyhow::Result<()> { use std::cell::RefCell; use std::fs::OpenOptions; @@ -337,17 +359,23 @@ impl Backend { let file = RefCell::new(file); // TODO: the sync callback will stall the async runtime if IO calls block, which is OS dependent. Rearrange. - self.download(url, resume_from, timeout, &|event| { - if let Event::DownloadDataReceived(data) = event { - file.borrow_mut() - .write_all(data) - .context("unable to write download to disk")?; - } - match callback { - Some(cb) => cb(event), - None => Ok(()), - } - }) + self.download( + url, + resume_from, + timeout, + &|event| { + if let Event::DownloadDataReceived(data) = event { + file.borrow_mut() + .write_all(data) + .context("unable to write download to disk")?; + } + match callback { + Some(cb) => cb(event), + None => Ok(()), + } + }, + process, + ) .await?; file.borrow_mut() @@ -371,12 +399,16 @@ impl Backend { resume_from: u64, timeout: Duration, callback: DownloadCallback<'_>, + process: &Process, ) -> anyhow::Result<()> { match self { #[cfg(feature = "curl-backend")] - Self::Curl => curl::download(url, resume_from, callback, timeout), + Self::Curl => curl::download(url, resume_from, callback, timeout, process), #[cfg(any(feature = "reqwest-rustls-tls", feature = "reqwest-native-tls"))] - Self::Reqwest(tls) => tls.download(url, resume_from, callback, timeout).await, + Self::Reqwest(tls) => { + tls.download(url, resume_from, callback, timeout, process) + .await + } } } } @@ -398,12 +430,13 @@ impl TlsBackend { resume_from: u64, callback: DownloadCallback<'_>, timeout: Duration, + process: &Process, ) -> anyhow::Result<()> { let client = match self { #[cfg(feature = "reqwest-rustls-tls")] - Self::Rustls => reqwest_be::rustls_client(timeout)?, + Self::Rustls => reqwest_be::rustls_client(timeout, process)?, #[cfg(feature = "reqwest-native-tls")] - Self::NativeTls => reqwest_be::native_tls_client(timeout)?, + Self::NativeTls => reqwest_be::native_tls_client(timeout, process)?, }; reqwest_be::download(url, resume_from, callback, client).await @@ -430,16 +463,41 @@ mod curl { use std::time::Duration; use anyhow::{Context, Result}; - use curl::easy::Easy; + use curl::easy::{Easy, List}; + use tracing::debug; use url::Url; - use super::{DownloadError, Event}; + use super::{ + DownloadError, Event, Process, RUSTUP_AUTHORIZATION_HEADER_ENV_VAR, + RUSTUP_PROXY_AUTHORIZATION_HEADER_ENV_VAR, + }; + + macro_rules! add_header_for_curl_easy_handle { + ($handle:ident, $process:ident, $env_var:ident, $header_name:literal, $header_list:ident) => { + if let Some(rustup_header_value) = $process.var_opt($env_var).map_err(|error| { + anyhow::anyhow!( + "Internal error getting `{}` environment variable: {}", + $env_var, + anyhow::format_err!(error) + ) + })? { + let list = $header_list.get_or_insert(List::new()); + list.append(format!("{}: {}", $header_name, rustup_header_value).as_str()) + .map_err(|_| { + // The error could contain sensitive data so give a generic error instead. + anyhow::anyhow!("Failed to add `{}` HTTP header.", $header_name) + })?; + debug!("Adding `{}` header.", $header_name); + } + }; + } pub(super) fn download( url: &Url, resume_from: u64, callback: &dyn Fn(Event<'_>) -> Result<()>, timeout: Duration, + process: &Process, ) -> Result<()> { // Fetch either a cached libcurl handle (which will preserve open // connections) or create a new one if it isn't listed. @@ -453,6 +511,27 @@ mod curl { handle.url(url.as_ref())?; handle.follow_location(true)?; handle.useragent(super::CURL_USER_AGENT)?; + let mut header_list: Option = None; + add_header_for_curl_easy_handle!( + handle, + process, + RUSTUP_AUTHORIZATION_HEADER_ENV_VAR, + "Authorization", + header_list + ); + add_header_for_curl_easy_handle!( + handle, + process, + RUSTUP_PROXY_AUTHORIZATION_HEADER_ENV_VAR, + "Proxy-Authorization", + header_list + ); + if let Some(list) = header_list { + handle.http_headers(list).map_err(|_| { + // The error could contain sensitive data so give a generic error instead. + anyhow::anyhow!("Failed to add headers to curl easy handle.") + })?; + } if resume_from > 0 { handle.resume_from(resume_from)?; @@ -557,7 +636,36 @@ mod reqwest_be { use tokio_stream::StreamExt; use url::Url; - use super::{DownloadError, Event}; + use super::{ + DownloadError, Event, Process, RUSTUP_AUTHORIZATION_HEADER_ENV_VAR, + RUSTUP_PROXY_AUTHORIZATION_HEADER_ENV_VAR, debug, + }; + + macro_rules! add_header_for_client_builder { + ($client_builder:ident, $process:ident, $env_var:ident, $header_name:path) => { + if let Some(rustup_header_value) = $process.var_opt($env_var).map_err(|_| { + // The error could contain sensitive data so give a generic error instead. + DownloadError::Message(format!( + "Internal error getting `{}` environment variable", + $env_var + )) + })? { + let mut headers = header::HeaderMap::new(); + let mut auth_value = + header::HeaderValue::from_str(&rustup_header_value).map_err(|_| { + // The error could contain sensitive data so give a generic error instead. + DownloadError::Message(format!( + "The `{}` environment variable set to an invalid HTTP header value.", + $env_var + )) + })?; + auth_value.set_sensitive(true); + headers.insert($header_name, auth_value); + $client_builder = $client_builder.default_headers(headers); + debug!("Added `{}` header.", $header_name); + } + }; + } pub(super) async fn download( url: &Url, @@ -592,18 +700,34 @@ mod reqwest_be { Ok(()) } - fn client_generic() -> ClientBuilder { - Client::builder() + fn client_generic(process: &Process) -> Result { + let mut client_builder = Client::builder() // HACK: set `pool_max_idle_per_host` to `0` to avoid an issue in the underlying // `hyper` library that causes the `reqwest` client to hang in some cases. // See for more details. .pool_max_idle_per_host(0) .gzip(false) - .proxy(Proxy::custom(env_proxy)) + .proxy(Proxy::custom(env_proxy)); + add_header_for_client_builder!( + client_builder, + process, + RUSTUP_AUTHORIZATION_HEADER_ENV_VAR, + header::AUTHORIZATION + ); + add_header_for_client_builder!( + client_builder, + process, + RUSTUP_PROXY_AUTHORIZATION_HEADER_ENV_VAR, + header::PROXY_AUTHORIZATION + ); + Ok(client_builder) } #[cfg(feature = "reqwest-rustls-tls")] - pub(super) fn rustls_client(timeout: Duration) -> Result<&'static Client, DownloadError> { + pub(super) fn rustls_client( + timeout: Duration, + process: &Process, + ) -> Result<&'static Client, DownloadError> { // If the client is already initialized, the passed timeout is ignored. if let Some(client) = CLIENT_RUSTLS_TLS.get() { return Ok(client); @@ -627,7 +751,7 @@ mod reqwest_be { .with_no_client_auth(); tls_config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - let client = client_generic() + let client = client_generic(process)? .read_timeout(timeout) .use_preconfigured_tls(tls_config) .user_agent(super::REQWEST_RUSTLS_TLS_USER_AGENT) @@ -644,13 +768,16 @@ mod reqwest_be { static CLIENT_RUSTLS_TLS: OnceLock = OnceLock::new(); #[cfg(feature = "reqwest-native-tls")] - pub(super) fn native_tls_client(timeout: Duration) -> Result<&'static Client, DownloadError> { + pub(super) fn native_tls_client( + timeout: Duration, + process: &Process, + ) -> Result<&'static Client, DownloadError> { // If the client is already initialized, the passed timeout is ignored. if let Some(client) = CLIENT_NATIVE_TLS.get() { return Ok(client); } - let client = client_generic() + let client = client_generic(process)? .read_timeout(timeout) .user_agent(super::REQWEST_DEFAULT_TLS_USER_AGENT) .build() diff --git a/src/download/tests.rs b/src/download/tests.rs index dd95c67f02..a9df8de3fc 100644 --- a/src/download/tests.rs +++ b/src/download/tests.rs @@ -26,6 +26,9 @@ mod curl { use super::{scrub_env, serve_file, tmp_dir, write_file}; use crate::download::{Backend, Event}; + #[cfg(feature = "test")] + use crate::process::TestProcess; + #[tokio::test] async fn partially_downloaded_file_gets_resumed_from_byte_offset() { let tmpdir = tmp_dir(); @@ -43,6 +46,7 @@ mod curl { true, None, Duration::from_secs(180), + &TestProcess::default().process, ) .await .expect("Test download failed"); @@ -91,6 +95,7 @@ mod curl { Ok(()) }), Duration::from_secs(180), + &TestProcess::default().process, ) .await .expect("Test download failed"); @@ -120,6 +125,9 @@ mod reqwest { use super::{scrub_env, serve_file, tmp_dir, write_file}; use crate::download::{Backend, Event, TlsBackend}; + #[cfg(feature = "test")] + use crate::process::TestProcess; + // Tests for correctly retrieving the proxy (host, port) tuple from $https_proxy #[tokio::test] async fn read_basic_proxy_params() { @@ -199,6 +207,7 @@ mod reqwest { true, None, Duration::from_secs(180), + &TestProcess::default().process, ) .await .expect("Test download failed"); @@ -247,6 +256,7 @@ mod reqwest { Ok(()) }), Duration::from_secs(180), + &TestProcess::default().process, ) .await .expect("Test download failed"); diff --git a/src/test.rs b/src/test.rs index 99e57e275b..e1ff847d92 100644 --- a/src/test.rs +++ b/src/test.rs @@ -29,7 +29,7 @@ pub use crate::cli::self_update::{RegistryGuard, RegistryValueId, USER_PATH, get mod clitools; pub use clitools::{ Assert, CliTestContext, Config, SanitizedOutput, Scenario, SelfUpdateTestContext, - output_release_file, print_command, print_indented, + TestContainer, TestContainerContext, output_release_file, print_command, print_indented, }; pub(crate) mod dist; pub use dist::DistContext; diff --git a/src/test/clitools.rs b/src/test/clitools.rs index 17bcf923b8..08f3e4ec55 100644 --- a/src/test/clitools.rs +++ b/src/test/clitools.rs @@ -12,7 +12,7 @@ use std::{ mem, ops::{Deref, DerefMut}, path::{Path, PathBuf}, - process::Command, + process::{Command, Stdio}, string::FromUtf8Error, sync::{Arc, LazyLock, RwLock, RwLockWriteGuard}, time::Instant, @@ -1253,3 +1253,292 @@ where } inner(original.as_ref(), link.as_ref()) } + +const DEFAULT_RUSTUP_TEST_NETWORK_NAME: &str = "rustup-test-network"; + +// This is the simplest ferron config that will send all output to stderr and serve the rustup test distribution files +// and require basic authentication. The only valid credentials are 'test:123?45>6'. +const RUSTUP_TEST_DIST_SERVER_CONFIG: &str = ":8080 {\n\ + log \"/dev/stderr\"\n\ + error_log \"/dev/stderr\"\n\ + status 401 users=\"test\"\n\ + user \"test\" \"$argon2id$v=19$m=19456,t=2,p=1$emTillHaS3OqFuvITdXxzg$G00heP8QSXk5H/ruTiLt302Xk3uETfU5QO8hBIwUq08\"\n\ + root \"/mnt/rustup-test-temp-dir\"\n\ +}"; + +// This is used for ensuring one thread ensures the test network is created. +static CONTAINER_NETWORK_CREATE_LOCK: LazyLock> = + LazyLock::new(|| tokio::sync::Mutex::new(())); + +#[derive(Debug)] +pub enum TestContainer { + DistServer, + ForwardProxy, +} + +impl std::fmt::Display for TestContainer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TestContainer::DistServer => { + write!(f, "rustup-test-dist-server") + } + TestContainer::ForwardProxy => { + write!(f, "rustup-test-forward-proxy") + } + } + } +} + +pub struct TestContainerContext { + container: TestContainer, + docker_program: std::ffi::OsString, + leave_container_running: bool, + cli_test_context: CliTestContext, +} + +impl Drop for TestContainerContext { + fn drop(&mut self) { + self.cleanup_container().unwrap(); + } +} + +impl TestContainerContext { + pub async fn new(process: &process::Process, container: TestContainer) -> Self { + let default_docker_program = format!("docker{EXE_SUFFIX}"); + let docker_program = process + .var_os("RUSTUP_TEST_DOCKER_PROGRAM") + .unwrap_or(default_docker_program.into()); + let leave_container_running = process + .var_opt("RUSTUP_TEST_LEAVE_CONTAINERS_RUNNING") + .unwrap() + .map(|var| var.eq("true")) + .unwrap_or_default(); + let cli_test_context = match container { + TestContainer::DistServer => { + let mut cli_test_context = CliTestContext::new(Scenario::SimpleV2).await; + if leave_container_running { + cli_test_context.config.test_dist_dir.disable_cleanup(true); + } + cli_test_context + } + TestContainer::ForwardProxy => CliTestContext::new(Scenario::None).await, + }; + Self { + container, + docker_program, + leave_container_running, + cli_test_context, + } + } + + pub async fn run(&mut self) -> anyhow::Result<()> { + if !self.is_running() { + self.ensure_network_created().await.unwrap(); + match self.container { + TestContainer::DistServer => { + let temp_dir_string = self + .cli_test_context + .config + .test_dist_dir + .path() + .to_string_lossy() + .into_owned(); + tokio::fs::write( + self.cli_test_context + .config + .test_dist_dir + .path() + .join("rustup-test-dist-server.kdl"), + RUSTUP_TEST_DIST_SERVER_CONFIG, + ) + .await + .unwrap(); + let mut command = Command::new(&self.docker_program); + let exit_status = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args([ + "run", + "--detach", + "--name", + format!("{}", self.container).as_str(), + "--net", + DEFAULT_RUSTUP_TEST_NETWORK_NAME, + "--publish", + "8080:8080", + "--volume", + format!("{temp_dir_string}:/mnt/rustup-test-temp-dir").as_str(), + "ferronserver/ferron:2", + "ferron", + "--config", + "/mnt/rustup-test-temp-dir/rustup-test-dist-server.kdl", + ]) + .spawn() + .unwrap() + .wait() + .unwrap(); + if !exit_status.success() { + let msg = format!( + "A problem occurred attempting to start the {} container.", + self.container + ); + tracing::error!("{msg}"); + Err(anyhow::anyhow!(msg)) + } else { + Ok(()) + } + } + TestContainer::ForwardProxy => { + let mut command = Command::new(&self.docker_program); + let exit_status = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args([ + "run", + "--detach", + "--name", + format!("{}", self.container).as_str(), + "--net", + DEFAULT_RUSTUP_TEST_NETWORK_NAME, + "--publish", + "9080:9080", + "mitmproxy/mitmproxy", + "mitmdump", + "--mode", + "regular@0.0.0.0:9080", + "--proxyauth", + "test:123?45>6", + ]) + .spawn() + .unwrap() + .wait() + .unwrap(); + if !exit_status.success() { + let msg = format!( + "A problem occurred attempting to start the {} container.", + self.container + ); + tracing::error!("{msg}"); + Err(anyhow::anyhow!(msg)) + } else { + Ok(()) + } + } + } + } else { + Ok(()) + } + } + + async fn ensure_network_created(&self) -> anyhow::Result<()> { + let _guard = (*CONTAINER_NETWORK_CREATE_LOCK).lock().await; + let mut command = Command::new(&self.docker_program); + let output = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .args([ + "network", + "ls", + "--format", + "json", + "--filter", + format!("name={DEFAULT_RUSTUP_TEST_NETWORK_NAME}").as_str(), + ]) + .output() + .unwrap(); + if output.stdout.is_empty() { + let mut command = Command::new(&self.docker_program); + let exit_status = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["network", "create", DEFAULT_RUSTUP_TEST_NETWORK_NAME]) + .spawn() + .unwrap() + .wait() + .unwrap(); + if !exit_status.success() { + let msg = format!( + "A problem occurred attempting to ensure '{DEFAULT_RUSTUP_TEST_NETWORK_NAME}' network is created." + ); + tracing::error!("{msg}"); + Err(anyhow::anyhow!(msg)) + } else { + Ok(()) + } + } else { + Ok(()) + } + } + + fn is_running(&self) -> bool { + let mut command = Command::new(&self.docker_program); + let output = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .args([ + "ps", + "--format", + "json", + "--filter", + format!("name={}", self.container).as_str(), + ]) + .output() + .unwrap(); + !output.stdout.is_empty() + } + + pub fn cleanup_container(&self) -> anyhow::Result<()> { + if !self.leave_container_running { + if self.is_running() { + let mut command = Command::new(&self.docker_program); + let exit_status = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["stop", format!("{}", self.container).as_str()]) + .spawn() + .unwrap() + .wait() + .unwrap(); + if !exit_status.success() { + let msg = format!( + "A problem occurred attempting to stop the {} container.", + self.container + ); + tracing::error!("{msg}"); + Err(anyhow::anyhow!(msg)) + } else { + let mut command = Command::new(&self.docker_program); + let exit_status = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["rm", format!("{}", self.container).as_str()]) + .spawn() + .unwrap() + .wait() + .unwrap(); + if !exit_status.success() { + let msg = format!( + "A problem occurred attempting to remove the {} container.", + self.container + ); + tracing::error!("{msg}"); + Err(anyhow::anyhow!(msg)) + } else { + Ok(()) + } + } + } else { + Ok(()) + } + } else { + Ok(()) + } + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000000..25e7d3918b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,89 @@ +# Test containers + +Test containers are for validating functionality when needing to use a distribution server and/or forward proxy that require +authentication. For any of the test servers, the only valid credentials will be the username:password creds 'test:123?45>6'. +The tests requiring containers will be ran when the 'test-with-containers' feature is enabled. By default, the containers are +started with the 'docker' program on non-Windows machines and 'docker.exe' on Windows machines. The program can be changed +to a different program or a full path to a program using the `RUSTUP_TEST_DOCKER_PROGRAM` environment variable. As an example, +if you need to use podman, run the following. + +```sh +export RUSTUP_TEST_DOCKER_PROGRAM="podman" +``` + +## Manual startup of test containers. + +The test containers can be started and left running by running any of the container tests and setting the +`RUSTUP_TEST_LEAVE_CONTAINERS_RUNNING` environment variable to the case-sensitive string `true`. You can also +run the following to quickly start the containers. + +```sh +export RUSTUP_TEST_LEAVE_CONTAINERS_RUNNING="true" +cargo test --features test-with-containers -- suite::cli_test_with_containers::tests::test_start_containers +``` + +Or in powershell... + +```powershell +$env:RUSTUP_TEST_LEAVE_CONTAINERS_RUNNING = "true" +cargo test --features test-with-containers -- suite::cli_test_with_containers::tests::test_start_containers +``` + +Note however the automated tests should not be ran while the `RUSTUP_TEST_LEAVE_CONTAINERS_RUNNING` environment +variable is set to true. Otherwise, the many deliberate use of missing or incorrect credentials will trigger the +test distribution server to stop accepting requests for some time. + +Use the docker command to determine what host port is being used for each of the containers. For example... + +```sh +docker ps --format json --filter "name=rustup-test-dist-server" | jq '.["Ports"]' +docker ps --format json --filter "name=rustup-test-forward-proxy" | jq '.["Ports"]' +``` + +When using the test containers, set the following environment variables. + +```sh +export RUSTUP_DIST_SERVER="http://localhost:8080" +export RUSTUP_UPDATE_ROOT="${RUSTUP_DIST_SERVER}/rustup" +export RUSTUP_AUTHORIZATION_HEADER="Basic $(printf 'test:123?45>6' | base64)" +export RUSTUP_HOME="${HOME}/test-rustup-init" +export CARGO_HOME="${RUSTUP_HOME}" +``` + +In powershell, the equivalent commmands are... + +```powershell +$env:RUSTUP_DIST_SERVER = "http://localhost:8080" +$env:RUSTUP_UPDATE_ROOT = "${env:RUSTUP_DIST_SERVER}/rustup" +$env:RUSTUP_AUTHORIZATION_HEADER = "Basic $([Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes('test:123?45>6')))" +$env:RUSTUP_HOME = "${env:USERPROFILE}/test-rustup-init" +$env:CARGO_HOME = "${env:RUSTUP_HOME}" +``` + +The 'RUSTUP_LOG' environment variable should also be set so you can see what server is being used for web requests. + +For testing support to set the `Proxy-Authorization` header, set these environment variables. + +```sh +export ALL_PROXY="http://127.0.0.1:9080" +export RUSTUP_PROXY_AUTHORIZATION_HEADER="Basic $(printf 'test:123?45>6' | base64)" +export RUSTUP_DIST_SERVER="http://rustup-test-dist-server:8080" +export RUSTUP_UPDATE_ROOT="${RUSTUP_DIST_SERVER}/rustup" +``` + +In powershell, equivalent commands are... + +```powershell +$env:ALL_PROXY = "http://127.0.0.1:9080" +$env:RUSTUP_PROXY_AUTHORIZATION_HEADER = "Basic $([Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes('test:123?45>6')))" +$env:RUSTUP_DIST_SERVER = "http://rustup-test-dist-server:8080" +$env:RUSTUP_UPDATE_ROOT = "${env:RUSTUP_DIST_SERVER}/rustup" +``` + +To stop and remove the test containers, run the following. + +```sh +docker stop rustup-test-dist-server && docker rm rustup-test-dist-server +docker stop rustup-test-forward-proxy && docker rm rustup-test-forward-proxy +``` + diff --git a/tests/suite/cli_test_with_containers.rs b/tests/suite/cli_test_with_containers.rs new file mode 100644 index 0000000000..b6aca09dd2 --- /dev/null +++ b/tests/suite/cli_test_with_containers.rs @@ -0,0 +1,532 @@ +#[cfg(feature = "test-with-containers")] +mod tests { + use std::{ + env::consts::EXE_SUFFIX, + process::{Command, Stdio}, + }; + + use rustup::{ + process::Process, + test::{CliTestContext, Scenario, TestContainer, TestContainerContext}, + }; + use tokio::sync::{Mutex, OnceCell}; + + // TODO: Figure out how to programmatically determine the published ports in running containers and then + // use that to set the 'RUSTUP_DIST_SERVER' and 'ALL_PROXY' environment variables. + const RUSTUP_TEST_DIST_SERVER: &str = "http://localhost:8080"; + const RUSTUP_TEST_FORWARD_PROXY: &str = "http://localhost:9080"; + + struct TestContainerContexts { + dist_server_container_context: TestContainerContext, + forward_proxy_container_context: TestContainerContext, + } + + impl TestContainerContexts { + async fn new() -> Self { + let process = Process::os(); + Self { + dist_server_container_context: TestContainerContext::new( + &process, + TestContainer::DistServer, + ) + .await, + forward_proxy_container_context: TestContainerContext::new( + &process, + TestContainer::ForwardProxy, + ) + .await, + } + } + + async fn start_containers(&mut self) { + self.dist_server_container_context.run().await.unwrap(); + self.forward_proxy_container_context.run().await.unwrap(); + } + + async fn stop_containers(&mut self) { + self.dist_server_container_context + .cleanup_container() + .unwrap(); + self.forward_proxy_container_context + .cleanup_container() + .unwrap(); + } + } + + static TEST_CONTAINER_CONTEXTS_ONCE: OnceCell> = + OnceCell::const_new(); + + macro_rules! start_containers { + () => {{ + let mut test_container_contexts_guard = TEST_CONTAINER_CONTEXTS_ONCE + .get_or_init(|| async { Mutex::new(TestContainerContexts::new().await) }) + .await + .lock() + .await; + test_container_contexts_guard.start_containers().await; + test_container_contexts_guard + }}; + } + + macro_rules! stop_containers { + ($guard:ident) => {{ + $guard.stop_containers().await; + }}; + } + + #[tokio::test] + async fn test_start_containers() { + let mut guard = start_containers!(); + stop_containers!(guard); + } + + #[tokio::test] + async fn test_dist_server_require_basic_auth_missing_creds() { + let mut guard = start_containers!(); + let cli_test_context = CliTestContext::new(Scenario::None).await; + let rustup_init_path = cli_test_context + .config + .exedir + .join(format!("rustup-init{EXE_SUFFIX}")); + let mut command = Command::new(rustup_init_path); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["-y", "--no-modify-path"]) + .env( + "RUSTUP_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env( + "CARGO_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env("RUSTUP_DIST_SERVER", RUSTUP_TEST_DIST_SERVER) + .env( + "RUSTUP_UPDATE_ROOT", + format!("{RUSTUP_TEST_DIST_SERVER}/rustup").as_str(), + ); + let mut child = command.spawn().unwrap(); + let exit_status = child.wait().unwrap(); + assert!(!exit_status.success()); + stop_containers!(guard); + } + + #[tokio::test] + async fn test_dist_server_require_basic_auth_incorrect_creds() { + let mut guard = start_containers!(); + let cli_test_context = CliTestContext::new(Scenario::None).await; + let rustup_init_path = cli_test_context + .config + .exedir + .join(format!("rustup-init{EXE_SUFFIX}")); + let mut command = Command::new(rustup_init_path); + // Basic creds derived from 'test:123?45>67'. + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["-y", "--no-modify-path"]) + .env( + "RUSTUP_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env( + "CARGO_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env("RUSTUP_DIST_SERVER", RUSTUP_TEST_DIST_SERVER) + .env( + "RUSTUP_UPDATE_ROOT", + format!("{RUSTUP_TEST_DIST_SERVER}/rustup").as_str(), + ) + .env("RUSTUP_AUTHORIZATION_HEADER", "Basic dGVzdDoxMjM/NDU+Njc="); + let mut child = command.spawn().unwrap(); + let exit_status = child.wait().unwrap(); + assert!(!exit_status.success()); + stop_containers!(guard); + } + + #[tokio::test] + async fn test_dist_server_require_basic_auth() { + let mut guard = start_containers!(); + let cli_test_context = CliTestContext::new(Scenario::None).await; + let rustup_init_path = cli_test_context + .config + .exedir + .join(format!("rustup-init{EXE_SUFFIX}")); + let mut command = Command::new(rustup_init_path); + // Basic creds derived from 'test:123?45>6'. + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["-y", "--no-modify-path"]) + .env( + "RUSTUP_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env( + "CARGO_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env("RUSTUP_DIST_SERVER", RUSTUP_TEST_DIST_SERVER) + .env( + "RUSTUP_UPDATE_ROOT", + format!("{RUSTUP_TEST_DIST_SERVER}/rustup").as_str(), + ) + .env("RUSTUP_AUTHORIZATION_HEADER", "Basic dGVzdDoxMjM/NDU+Ng=="); + let mut child = command.spawn().unwrap(); + let exit_status = child.wait().unwrap(); + assert!(exit_status.success()); + stop_containers!(guard); + } + + #[tokio::test] + async fn test_forward_proxy_require_basic_auth_missing_creds() { + let mut guard = start_containers!(); + let cli_test_context = CliTestContext::new(Scenario::None).await; + let rustup_init_path = cli_test_context + .config + .exedir + .join(format!("rustup-init{EXE_SUFFIX}")); + // Inside of the container, the test dist server needs to be reached using its hostname. + let rust_test_dist_server = format!("http://{}:8080", TestContainer::DistServer); + let mut command = Command::new(rustup_init_path); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["-y", "--no-modify-path"]) + .env( + "RUSTUP_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env( + "CARGO_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env("RUSTUP_DIST_SERVER", &rust_test_dist_server) + .env( + "RUSTUP_UPDATE_ROOT", + format!("{rust_test_dist_server}/rustup").as_str(), + ) + .env("RUSTUP_AUTHORIZATION_HEADER", "Basic dGVzdDoxMjM/NDU+Ng==") + .env("ALL_PROXY", RUSTUP_TEST_FORWARD_PROXY); + let mut child = command.spawn().unwrap(); + let exit_status = child.wait().unwrap(); + assert!(!exit_status.success()); + stop_containers!(guard); + } + + #[tokio::test] + async fn test_forward_proxy_require_basic_auth_incorrect_creds() { + let mut guard = start_containers!(); + let cli_test_context = CliTestContext::new(Scenario::None).await; + let rustup_init_path = cli_test_context + .config + .exedir + .join(format!("rustup-init{EXE_SUFFIX}")); + // Inside of the container, the test dist server needs to be reached using its hostname. + let rust_test_dist_server = format!("http://{}:8080", TestContainer::DistServer); + let mut command = Command::new(rustup_init_path); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["-y", "--no-modify-path"]) + .env( + "RUSTUP_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env( + "CARGO_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env("RUSTUP_DIST_SERVER", &rust_test_dist_server) + .env( + "RUSTUP_UPDATE_ROOT", + format!("{rust_test_dist_server}/rustup").as_str(), + ) + .env("RUSTUP_AUTHORIZATION_HEADER", "Basic dGVzdDoxMjM/NDU+Ng==") + .env("ALL_PROXY", RUSTUP_TEST_FORWARD_PROXY) + .env( + "RUSTUP_PROXY_AUTHORIZATION_HEADER", + "Basic dGVzdDoxMjM/NDU+Njc=", + ); + let mut child = command.spawn().unwrap(); + let exit_status = child.wait().unwrap(); + assert!(!exit_status.success()); + stop_containers!(guard); + } + + #[tokio::test] + async fn test_forward_proxy_require_basic_auth() { + let mut guard = start_containers!(); + let cli_test_context = CliTestContext::new(Scenario::None).await; + let rustup_init_path = cli_test_context + .config + .exedir + .join(format!("rustup-init{EXE_SUFFIX}")); + // Inside of the container, the test dist server needs to be reached using its hostname. + let rust_test_dist_server = format!("http://{}:8080", TestContainer::DistServer); + let mut command = Command::new(rustup_init_path); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["-y", "--no-modify-path"]) + .env( + "RUSTUP_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env( + "CARGO_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env("RUSTUP_DIST_SERVER", &rust_test_dist_server) + .env( + "RUSTUP_UPDATE_ROOT", + format!("{rust_test_dist_server}/rustup").as_str(), + ) + .env("RUSTUP_AUTHORIZATION_HEADER", "Basic dGVzdDoxMjM/NDU+Ng==") + .env("ALL_PROXY", RUSTUP_TEST_FORWARD_PROXY) + .env( + "RUSTUP_PROXY_AUTHORIZATION_HEADER", + "Basic dGVzdDoxMjM/NDU+Ng==", + ); + let mut child = command.spawn().unwrap(); + let exit_status = child.wait().unwrap(); + assert!(exit_status.success()); + stop_containers!(guard); + } + + #[tokio::test] + async fn test_dist_server_require_basic_auth_missing_creds_with_curl() { + let mut guard = start_containers!(); + let cli_test_context = CliTestContext::new(Scenario::None).await; + let rustup_init_path = cli_test_context + .config + .exedir + .join(format!("rustup-init{EXE_SUFFIX}")); + let mut command = Command::new(rustup_init_path); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["-y", "--no-modify-path"]) + .env( + "RUSTUP_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env( + "CARGO_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env("RUSTUP_DIST_SERVER", RUSTUP_TEST_DIST_SERVER) + .env( + "RUSTUP_UPDATE_ROOT", + format!("{RUSTUP_TEST_DIST_SERVER}/rustup").as_str(), + ) + .env("RUSTUP_USE_CURL", "1"); + let mut child = command.spawn().unwrap(); + let exit_status = child.wait().unwrap(); + assert!(!exit_status.success()); + stop_containers!(guard); + } + + #[tokio::test] + async fn test_dist_server_require_basic_auth_incorrect_creds_with_curl() { + let mut guard = start_containers!(); + let cli_test_context = CliTestContext::new(Scenario::None).await; + let rustup_init_path = cli_test_context + .config + .exedir + .join(format!("rustup-init{EXE_SUFFIX}")); + let mut command = Command::new(rustup_init_path); + // Basic creds derived from 'test:123?45>67'. + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["-y", "--no-modify-path"]) + .env( + "RUSTUP_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env( + "CARGO_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env("RUSTUP_DIST_SERVER", RUSTUP_TEST_DIST_SERVER) + .env( + "RUSTUP_UPDATE_ROOT", + format!("{RUSTUP_TEST_DIST_SERVER}/rustup").as_str(), + ) + .env("RUSTUP_AUTHORIZATION_HEADER", "Basic dGVzdDoxMjM/NDU+Njc=") + .env("RUSTUP_USE_CURL", "1"); + let mut child = command.spawn().unwrap(); + let exit_status = child.wait().unwrap(); + assert!(!exit_status.success()); + stop_containers!(guard); + } + + #[tokio::test] + async fn test_dist_server_require_basic_auth_with_curl() { + let mut guard = start_containers!(); + let cli_test_context = CliTestContext::new(Scenario::None).await; + let rustup_init_path = cli_test_context + .config + .exedir + .join(format!("rustup-init{EXE_SUFFIX}")); + let mut command = Command::new(rustup_init_path); + // Basic creds derived from 'test:123?45>6'. + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["-y", "--no-modify-path"]) + .env( + "RUSTUP_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env( + "CARGO_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env("RUSTUP_DIST_SERVER", RUSTUP_TEST_DIST_SERVER) + .env( + "RUSTUP_UPDATE_ROOT", + format!("{RUSTUP_TEST_DIST_SERVER}/rustup").as_str(), + ) + .env("RUSTUP_AUTHORIZATION_HEADER", "Basic dGVzdDoxMjM/NDU+Ng==") + .env("RUSTUP_USE_CURL", "1"); + let mut child = command.spawn().unwrap(); + let exit_status = child.wait().unwrap(); + assert!(exit_status.success()); + stop_containers!(guard); + } + + #[tokio::test] + async fn test_forward_proxy_require_basic_auth_missing_creds_with_curl() { + let mut guard = start_containers!(); + let cli_test_context = CliTestContext::new(Scenario::None).await; + let rustup_init_path = cli_test_context + .config + .exedir + .join(format!("rustup-init{EXE_SUFFIX}")); + // Inside of the container, the test dist server needs to be reached using its hostname. + let rust_test_dist_server = format!("http://{}:8080", TestContainer::DistServer); + let mut command = Command::new(rustup_init_path); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["-y", "--no-modify-path"]) + .env( + "RUSTUP_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env( + "CARGO_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env("RUSTUP_DIST_SERVER", &rust_test_dist_server) + .env( + "RUSTUP_UPDATE_ROOT", + format!("{rust_test_dist_server}/rustup").as_str(), + ) + .env("RUSTUP_AUTHORIZATION_HEADER", "Basic dGVzdDoxMjM/NDU+Ng==") + .env("ALL_PROXY", RUSTUP_TEST_FORWARD_PROXY) + .env("RUSTUP_USE_CURL", "1"); + let mut child = command.spawn().unwrap(); + let exit_status = child.wait().unwrap(); + assert!(!exit_status.success()); + stop_containers!(guard); + } + + #[tokio::test] + async fn test_forward_proxy_require_basic_auth_incorrect_creds_with_curl() { + let mut guard = start_containers!(); + let cli_test_context = CliTestContext::new(Scenario::None).await; + let rustup_init_path = cli_test_context + .config + .exedir + .join(format!("rustup-init{EXE_SUFFIX}")); + // Inside of the container, the test dist server needs to be reached using its hostname. + let rust_test_dist_server = format!("http://{}:8080", TestContainer::DistServer); + let mut command = Command::new(rustup_init_path); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["-y", "--no-modify-path"]) + .env( + "RUSTUP_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env( + "CARGO_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env("RUSTUP_DIST_SERVER", &rust_test_dist_server) + .env( + "RUSTUP_UPDATE_ROOT", + format!("{rust_test_dist_server}/rustup").as_str(), + ) + .env("RUSTUP_AUTHORIZATION_HEADER", "Basic dGVzdDoxMjM/NDU+Ng==") + .env("ALL_PROXY", RUSTUP_TEST_FORWARD_PROXY) + .env( + "RUSTUP_PROXY_AUTHORIZATION_HEADER", + "Basic dGVzdDoxMjM/NDU+Njc=", + ) + .env("RUSTUP_USE_CURL", "1"); + let mut child = command.spawn().unwrap(); + let exit_status = child.wait().unwrap(); + assert!(!exit_status.success()); + stop_containers!(guard); + } + + #[tokio::test] + async fn test_forward_proxy_require_basic_auth_with_curl() { + let mut guard = start_containers!(); + let cli_test_context = CliTestContext::new(Scenario::None).await; + let rustup_init_path = cli_test_context + .config + .exedir + .join(format!("rustup-init{EXE_SUFFIX}")); + // Inside of the container, the test dist server needs to be reached using its hostname. + let rust_test_dist_server = format!("http://{}:8080", TestContainer::DistServer); + let mut command = Command::new(rustup_init_path); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(["-y", "--no-modify-path"]) + .env( + "RUSTUP_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env( + "CARGO_HOME", + cli_test_context.config.rustupdir.to_string().as_str(), + ) + .env("RUSTUP_DIST_SERVER", &rust_test_dist_server) + .env( + "RUSTUP_UPDATE_ROOT", + format!("{rust_test_dist_server}/rustup").as_str(), + ) + .env("RUSTUP_AUTHORIZATION_HEADER", "Basic dGVzdDoxMjM/NDU+Ng==") + .env("ALL_PROXY", RUSTUP_TEST_FORWARD_PROXY) + .env( + "RUSTUP_PROXY_AUTHORIZATION_HEADER", + "Basic dGVzdDoxMjM/NDU+Ng==", + ) + .env("RUSTUP_USE_CURL", "1"); + let mut child = command.spawn().unwrap(); + let exit_status = child.wait().unwrap(); + assert!(exit_status.success()); + stop_containers!(guard); + } +} diff --git a/tests/suite/mod.rs b/tests/suite/mod.rs index 296102d67a..1d9a029ba7 100644 --- a/tests/suite/mod.rs +++ b/tests/suite/mod.rs @@ -6,6 +6,7 @@ mod cli_rustup; mod cli_rustup_init_ui; mod cli_rustup_ui; mod cli_self_upd; +mod cli_test_with_containers; mod cli_v1; mod cli_v2; mod dist_install;