From 86dbe3f2ad62d5caf3040e66a3f77b1b1beb7b51 Mon Sep 17 00:00:00 2001 From: Andres Mejia Sanchez Date: Mon, 31 Aug 2026 12:04:34 -0400 Subject: [PATCH] Support setting an "Authorization" and a "Proxy-Authorization" HTTP request headers. The "Authorization" request header is used to authenticate to rustup distribution mirrors that require authentication. The "Proxy-Authorization" request header is used to authenticate to proxies. This change adds the RUSTUP_AUTHORIZATION_HEADER and RUSTUP_PROXY_AUTHORIZATION_HEADER environment variables, which set the "Authorization" and "Proxy-Authorization" request headers on the requests that rustup makes when downloading. As part of this change, "rustup-init.sh" was also modified to support distribution servers and proxies running on the localhost using HTTP. This not only provides the benefit of being able to use the mock programs introduced below to run tests, it also provides additional support for custom servers and proxies which run on the localhost and forward requests to corporate servers that may require more elaborate forms of authentication, such as the usage of cookies or the usage of mTLS. To exercise this behavior, this change introduces the "rustup-mock-server" and "rustup-mock-proxy" test binaries. The former serves a mock distribution tree and can require basic authentication; the latter is a forward proxy that forwards requests to the former and can require its own basic authentication. Both programs listen on an OS-assigned localhost port and record the listening address (and, for the server, the directory being served) in a data file, so that the tests can discover them and run in parallel. Integration tests start these programs and verify that unauthenticated requests are rejected (401 from the server, 407 from the proxy) and that the "rustup-init" binary and the "rustup-init.sh" script can install a toolchain in the direct, proxied, and authenticated cases, presenting credentials via the new environment variables. --- Cargo.lock | 12 + Cargo.toml | 18 +- doc/dev-guide/src/SUMMARY.md | 1 + doc/dev-guide/src/index.md | 6 + doc/dev-guide/src/testing.md | 340 +++++++++++ doc/user-guide/src/environment-variables.md | 8 + rustup-init.sh | 140 ++++- src/bin/rustup-mock-proxy.rs | 343 +++++++++++ src/bin/rustup-mock-server.rs | 249 ++++++++ src/download/mod.rs | 24 +- src/download/tests.rs | 447 +++++++++++---- src/test.rs | 60 +- src/test/dist.rs | 16 +- src/test/mock_data.rs | 280 +++++++++ tests/suite/init_sh.rs | 272 +++++++++ tests/suite/mod.rs | 2 + tests/suite/proxy.rs | 600 ++++++++++++++++++++ 17 files changed, 2655 insertions(+), 163 deletions(-) create mode 100644 doc/dev-guide/src/testing.md create mode 100644 src/bin/rustup-mock-proxy.rs create mode 100644 src/bin/rustup-mock-server.rs create mode 100644 src/test/mock_data.rs create mode 100644 tests/suite/init_sh.rs create mode 100644 tests/suite/proxy.rs diff --git a/Cargo.lock b/Cargo.lock index 79a6e31f10..ee313a1cb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2047,6 +2047,7 @@ dependencies = [ "anstream", "anstyle", "anyhow", + "base64", "cc", "chrono", "clap", @@ -2268,6 +2269,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2572,6 +2583,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 2e4af2652f..ac6152e837 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,9 @@ name = "rustup" version = "1.30.0" edition = "2024" +# `cargo run` without `--bin` runs the main binary, as it did before the +# `rustup-mock-server` and `rustup-mock-proxy` binaries were added. +default-run = "rustup-init" license = "MIT OR Apache-2.0" description = "Manage multiple rust installations with ease" homepage = "https://github.com/rust-lang/rustup" @@ -43,6 +46,7 @@ test = ["dep:snapbox", "dep:walkdir", "clap-cargo/testing_colors"] anstream = "1" anstyle = "1.0.11" anyhow = "1.0.69" +base64 = "0.22" cc = "1" chrono = { version = "0.4", default-features = false, features = ["std"] } clap = { version = "4", features = ["derive", "wrap_help", "string"] } @@ -59,7 +63,7 @@ futures-util = "0.3.31" git-testament = "0.2" home = "0.5.4" http-body-util = "0.1.0" -hyper = { version = "1.0", default-features = false, features = ["server", "http1"] } +hyper = { version = "1.0", default-features = false, features = ["client", "http1", "server"] } hyper-util = { version = "0.1.1", features = ["tokio"] } indicatif = "0.18" itertools = "0.15" @@ -93,7 +97,7 @@ tar = "0.4.26" tempfile = "3.8" thiserror = "2" threadpool = "1" -tokio = { version = "1.26.0", default-features = false, features = ["macros", "rt-multi-thread", "sync"] } +tokio = { version = "1.26.0", default-features = false, features = ["macros", "rt-multi-thread", "signal", "sync"] } tokio-retry = "0.3.0" tokio-stream = "0.1.14" toml = "1.0" @@ -177,3 +181,13 @@ opt-level = 0 [package.metadata.cargo-all-features] # Building with no web backend will error. always_include_features = ["reqwest-rustls-tls"] + +[[bin]] +name = "rustup-mock-server" +path = "src/bin/rustup-mock-server.rs" +required-features = ["test"] + +[[bin]] +name = "rustup-mock-proxy" +path = "src/bin/rustup-mock-proxy.rs" +required-features = ["test"] diff --git a/doc/dev-guide/src/SUMMARY.md b/doc/dev-guide/src/SUMMARY.md index d95e347b52..c95fcda487 100644 --- a/doc/dev-guide/src/SUMMARY.md +++ b/doc/dev-guide/src/SUMMARY.md @@ -8,3 +8,4 @@ - [Release process](release-process.md) - [Tips and tricks](tips-and-tricks.md) - [Tracing](tracing.md) +- [Testing](testing.md) diff --git a/doc/dev-guide/src/index.md b/doc/dev-guide/src/index.md index a842025f36..a6e9c34042 100644 --- a/doc/dev-guide/src/index.md +++ b/doc/dev-guide/src/index.md @@ -31,6 +31,12 @@ affecting any existing installation. Remember to keep those two environment vari set when running your compiled `rustup-init` or the toolchains it installs, but _unset_ when rebuilding `rustup` itself. +To develop entirely offline, without network access to the real distribution +server, the `rustup-mock-server` program serves a mock distribution tree that +rustup can install from. See the +["Using with rustup" section of the testing documentation](testing.md#using-with-rustup) +for an example. + If you wish to install your new build to try out longer term in your home directory then you can run `cargo dev-install` which is an alias in `.cargo/config` which runs `cargo run -- --no-modify-path -y` to install your build into your homedir. diff --git a/doc/dev-guide/src/testing.md b/doc/dev-guide/src/testing.md new file mode 100644 index 0000000000..ae7a7760c8 --- /dev/null +++ b/doc/dev-guide/src/testing.md @@ -0,0 +1,340 @@ +# Testing + +This guide explains how to test rustup features, including the mock distribution server +and proxy server for testing the `RUSTUP_AUTHORIZATION_HEADER` and +`RUSTUP_PROXY_AUTHORIZATION_HEADER` environment variables. + +## Test Suite + +The integration tests in `tests/suite/proxy.rs` and +`tests/suite/init_sh.rs` are the test suite for the `rustup-init` binary +and the `rustup-init.sh` script. They start `rustup-mock-server` and +`rustup-mock-proxy` on OS-assigned ports (discovered through their data +files), first without authentication and then with basic authentication, and +verify: + +- that the distribution server is reachable directly and through the proxy, +- that unauthenticated requests are rejected (401 from the server, 407 from + the proxy) and that authenticated requests succeed, +- that the `rustup-init` binary can install a toolchain in all of those cases, + using `RUSTUP_AUTHORIZATION_HEADER` and + `RUSTUP_PROXY_AUTHORIZATION_HEADER` where applicable, and +- that the `rustup-init.sh` script can install a toolchain, forcing the use + of `curl` or of `wget` by running it with a restricted `PATH`. + +The tests run in parallel (each test uses its own server and proxy on +OS-assigned localhost ports) and can be run individually: + +```bash +cargo test --features test --test test_bonanza proxy:: +cargo test --features test --test test_bonanza init_sh:: +``` + +The `rustup-init.sh` tests run only on Unix: on Windows, +`rustup-init.exe` is downloaded and run directly, so the script is not used +there (see the [installation documentation](https://rust-lang.github.io/rustup/installation/other.html)). +They also require `sh`, `curl`, and `wget` in `PATH`. + +## Building the Test Programs + +All test-related programs require the `test` feature to be enabled. Build them as follows: + +```bash +# Build all test programs +cargo build --features test + +# Build individual test binaries +cargo build --features test --bin rustup-mock-server +cargo build --features test --bin rustup-mock-proxy +``` + +## Data Files + +Both `rustup-mock-server` and `rustup-mock-proxy` write a data file once +their listener is bound, and remove it when they exit (after a normal exit or +SIGINT/SIGTERM). The presence of the file is therefore a readiness signal: +tests wait for it before talking to the program, and read it to learn the +address and port the program is listening on (important when the port is +OS-assigned, which is the default). + +The file contains one `key=value` pair per line: + +| Key | Description | +| ------------ | -------------------------------------------------------- | +| `addr` | The address the program is listening on | +| `port` | The port the program is listening on | +| `pid` | The process id of the program | +| `credential` | The basic test credential in use (only when `--basic-test-credential` was given) | +| `directory` | The directory being served (`rustup-mock-server` only) | + +The location of the data file is set with the `--data-file` option. When it +is not given, the default locations are +`${HOME}/.local/share/rustup-mock-server.data` and +`${HOME}/.local/share/rustup-mock-proxy.data` on Unix, and +`%LOCALAPPDATA%\rustup-mock-server.data` and +`%LOCALAPPDATA%\rustup-mock-proxy.data` on Windows. + +A shell one-liner to read the port from a data file: + +```bash +PORT=$(grep '^port=' "${HOME}/.local/share/rustup-mock-server.data" | cut -d= -f2) +``` + +## Mock Distribution Server (`rustup-mock-server`) + +The `rustup-mock-server` program creates a mock distribution server for testing rustup functionality. It serves files over HTTP from a directory structure that mimics the rustup distribution server format. + +### Running the Mock Server + +Basic usage: + +```bash +# Start the mock server (it picks a free port and records it in its data +# file, ${HOME}/.local/share/rustup-mock-server.data by default) +./target/debug/rustup-mock-server + +# Use a specific port +./target/debug/rustup-mock-server --port 8080 + +# Bind to a specific address +./target/debug/rustup-mock-server --addr 0.0.0.0 + +# Specify a directory to serve from +./target/debug/rustup-mock-server --directory /path/to/mock/dist + +# Use Basic authentication +./target/debug/rustup-mock-server --basic-test-credential "testuser:testpass" + +# Write the data file somewhere else +./target/debug/rustup-mock-server --data-file /tmp/my-mock-server.data +``` + +### Command Line Options + +| Option | Description | +|--------|-------------| +| `--addr ` | Address to bind to (default: "127.0.0.1") | +| `--port ` | Port to bind to; 0 lets the OS assign a free port (default: 0) | +| `--directory ` | Directory to serve files from. If not specified, a temporary directory will be created | +| `--basic-test-credential ` | Basic auth credentials in the form "username:password" | +| `--data-file ` | Where to write the data file (default: ${HOME}/.local/share/rustup-mock-server.data, or %LOCALAPPDATA%\rustup-mock-server.data on Windows) | + +### Environment Variables + +The server uses the `RUSTUP_LOG` environment variable for logging configuration: + +```bash +# Enable debug logging +RUSTUP_LOG="debug" ./target/debug/rustup-mock-server +``` + +### Using with rustup + +To test rustup against the mock server: + +```bash +# Create a directory for rustup +export RUSTUP_HOME="$(mktemp -d)" +export CARGO_HOME="${RUSTUP_HOME}" + +# Start the mock server in the background +./target/debug/rustup-mock-server & +MOCK_PID=$! + +# Wait for the server to start (its data file appears once it is listening) +DATA_FILE="${HOME}/.local/share/rustup-mock-server.data" +while ! grep -q '^port=' "$DATA_FILE" 2>/dev/null; do sleep 1; done +PORT=$(grep '^port=' "$DATA_FILE" | cut -d= -f2) + +# Point rustup at the mock server +export RUSTUP_DIST_SERVER="http://127.0.0.1:${PORT}" +export RUSTUP_UPDATE_ROOT="${RUSTUP_DIST_SERVER}/rustup" + +# Initialize RUSTUP_HOME directory +./target/debug/rustup-init --no-modify-path + +# Run rustup commands +./target/debug/rustup --default stable + +# Clean up (the server removes its data file on exit) +kill $MOCK_PID +rm -rf "$RUSTUP_HOME" +``` + +## Forward Proxy (`rustup-mock-proxy`) + +The `rustup-mock-proxy` program creates a forward proxy for testing the `RUSTUP_PROXY_AUTHORIZATION_HEADER` environment variable. + +### Running the Proxy + +Basic usage: + +```bash +# Start the proxy (it picks a free port and records it in its data file, +# ${HOME}/.local/share/rustup-mock-proxy.data by default) +./target/debug/rustup-mock-proxy + +# Use a specific port +./target/debug/rustup-mock-proxy --port 8081 + +# Bind to a different address +./target/debug/rustup-mock-proxy --addr 0.0.0.0 + +# Use Basic authentication +./target/debug/rustup-mock-proxy --basic-test-credential "proxyuser:proxypass" + +# Write the data file somewhere else +./target/debug/rustup-mock-proxy --data-file /tmp/my-mock-proxy.data +``` + +### Command Line Options + +| Option | Description | +|--------|-------------| +| `--addr ` | Address to bind to (default: "127.0.0.1") | +| `--port ` | Port to bind to; 0 lets the OS assign a free port (default: 0) | +| `--basic-test-credential ` | Basic auth credentials in the form "username:password" for the `Proxy-Authorization` header | +| `--data-file ` | Where to write the data file (default: ${HOME}/.local/share/rustup-mock-proxy.data, or %LOCALAPPDATA%\rustup-mock-proxy.data on Windows) | + +### Using with rustup + +To test rustup with the proxy, run the mock server as the distribution server and +route rustup's downloads through the proxy: + +```bash +# Create a directory for rustup +export RUSTUP_HOME="$(mktemp -d)" +export CARGO_HOME="${RUSTUP_HOME}" + +# Start the mock server and the proxy in the background +./target/debug/rustup-mock-server & +MOCK_PID=$! +./target/debug/rustup-mock-proxy --basic-test-credential "proxyuser:proxypass" & +PROXY_PID=$! + +SERVER_DATA_FILE="${HOME}/.local/share/rustup-mock-server.data" +PROXY_DATA_FILE="${HOME}/.local/share/rustup-mock-proxy.data" + +# Wait for the programs to start (their data files appear once they are +# listening) +while ! grep -q '^port=' "$SERVER_DATA_FILE" 2>/dev/null; do sleep 1; done +while ! grep -q '^port=' "$PROXY_DATA_FILE" 2>/dev/null; do sleep 1; done +SERVER_PORT=$(grep '^port=' "$SERVER_DATA_FILE" | cut -d= -f2) +PROXY_PORT=$(grep '^port=' "$PROXY_DATA_FILE" | cut -d= -f2) + +# Set the proxy credentials +export RUSTUP_PROXY_AUTHORIZATION_HEADER="Basic $(echo -n 'proxyuser:proxypass' | base64)" + +# Point rustup at the mock server through the proxy +export RUSTUP_DIST_SERVER="http://127.0.0.1:${SERVER_PORT}" +export RUSTUP_UPDATE_ROOT="${RUSTUP_DIST_SERVER}/rustup" +export http_proxy="http://127.0.0.1:${PROXY_PORT}" +export https_proxy="http://127.0.0.1:${PROXY_PORT}" + +# Initialize RUSTUP_HOME directory +./target/debug/rustup-init --no-modify-path + +# Run rustup commands through the proxy +./target/debug/rustup --default stable + +# Clean up (the programs remove their data files on exit) +kill $MOCK_PID $PROXY_PID +rm -rf "$RUSTUP_HOME" +``` + +## Testing Authorization Headers + +The `RUSTUP_AUTHORIZATION_HEADER` and `RUSTUP_PROXY_AUTHORIZATION_HEADER` environment variables allow you to set HTTP headers for downloads. + +### Testing with the Mock Server + +```bash +# Create a directory for rustup +export RUSTUP_HOME="$(mktemp -d)" +export CARGO_HOME="${RUSTUP_HOME}" + +# Start the mock server with basic auth +./target/debug/rustup-mock-server --basic-test-credential "testuser:testpass" & +MOCK_PID=$! + +# Wait for the server to start (its data file appears once it is listening) +DATA_FILE="${HOME}/.local/share/rustup-mock-server.data" +while ! grep -q '^port=' "$DATA_FILE" 2>/dev/null; do sleep 1; done +PORT=$(grep '^port=' "$DATA_FILE" | cut -d= -f2) + +# Set the authorization header +export RUSTUP_AUTHORIZATION_HEADER="Basic $(echo -n 'testuser:testpass' | base64)" + +# Point rustup at the mock server +export RUSTUP_DIST_SERVER="http://127.0.0.1:${PORT}" +export RUSTUP_UPDATE_ROOT="${RUSTUP_DIST_SERVER}/rustup" + +# Initialize RUSTUP_HOME directory +./target/debug/rustup-init --no-modify-path + +# Run rustup commands that require authentication +./target/debug/rustup --default stable + +# Clean up (the server removes its data file on exit) +kill $MOCK_PID +rm -rf "$RUSTUP_HOME" +``` + +### Testing with the Proxy + +To test the `RUSTUP_PROXY_AUTHORIZATION_HEADER` against a proxy that requires +authentication, run both the mock server and the proxy with credentials: + +```bash +# Create a directory for rustup +export RUSTUP_HOME="$(mktemp -d)" +export CARGO_HOME="${RUSTUP_HOME}" + +# Start the mock server and the proxy with basic auth +./target/debug/rustup-mock-server --basic-test-credential "testuser:testpass" & +MOCK_PID=$! +./target/debug/rustup-mock-proxy --basic-test-credential "proxyuser:proxypass" & +PROXY_PID=$! + +SERVER_DATA_FILE="${HOME}/.local/share/rustup-mock-server.data" +PROXY_DATA_FILE="${HOME}/.local/share/rustup-mock-proxy.data" + +# Wait for the programs to start (their data files appear once they are +# listening) +while ! grep -q '^port=' "$SERVER_DATA_FILE" 2>/dev/null; do sleep 1; done +while ! grep -q '^port=' "$PROXY_DATA_FILE" 2>/dev/null; do sleep 1; done +SERVER_PORT=$(grep '^port=' "$SERVER_DATA_FILE" | cut -d= -f2) +PROXY_PORT=$(grep '^port=' "$PROXY_DATA_FILE" | cut -d= -f2) + +# Set the authorization headers +export RUSTUP_AUTHORIZATION_HEADER="Basic $(echo -n 'testuser:testpass' | base64)" +export RUSTUP_PROXY_AUTHORIZATION_HEADER="Basic $(echo -n 'proxyuser:proxypass' | base64)" + +# Point rustup at the mock server through the proxy +export RUSTUP_DIST_SERVER="http://127.0.0.1:${SERVER_PORT}" +export RUSTUP_UPDATE_ROOT="${RUSTUP_DIST_SERVER}/rustup" +export http_proxy="http://127.0.0.1:${PROXY_PORT}" +export https_proxy="http://127.0.0.1:${PROXY_PORT}" + +# Initialize RUSTUP_HOME directory +./target/debug/rustup-init --no-modify-path + +# Run rustup commands through the proxy +./target/debug/rustup --default stable + +# Clean up (the programs remove their data files on exit) +kill $MOCK_PID $PROXY_PID +rm -rf "$RUSTUP_HOME" +``` + +## Unit and Integration Tests + +Unit tests for the authorization headers are located in `src/download/tests.rs`. These +tests verify the HTTP header functionality with a real HTTP server using the `hyper` +server infrastructure. + +Integration tests for the mock server and proxy are located in +`tests/suite/proxy.rs`. They cover the same scenarios as the test scripts and run with +`cargo test --features test`. Each test starts the mock programs with +`--data-file` pointing into a test-specific temporary directory, and reads the +listening address from the data file (see the "Data Files" section above). diff --git a/doc/user-guide/src/environment-variables.md b/doc/user-guide/src/environment-variables.md index 7e836b034a..51d72497f9 100644 --- a/doc/user-guide/src/environment-variables.md +++ b/doc/user-guide/src/environment-variables.md @@ -72,6 +72,14 @@ - `RUSTUP_CONCURRENT_DOWNLOADS` _unstable_ (default: 2). Controls the number of downloads made concurrently. +- `RUSTUP_AUTHORIZATION_HEADER` (default: none). Sets the `Authorization` HTTP request header + that will be included in all downloads from the rustup distribution server. Useful for + authenticated downloads when using a private package index. + +- `RUSTUP_PROXY_AUTHORIZATION_HEADER` (default: none). Sets the `Proxy-Authorization` HTTP request header + that will be included in all downloads from the rustup distribution server. Useful for + authenticated proxy connections. + - `RUSTUP_TOOLCHAIN_SOURCE` _unstable_. Set by rustup to tell proxied tools how `RUSTUP_TOOLCHAIN` was determined. Non-rustup tools should not set this environment variable, except insofar as to mirror an earlier invocation from rustup. [directive syntax]: https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives diff --git a/rustup-init.sh b/rustup-init.sh index d1e43f5378..4a7920248f 100755 --- a/rustup-init.sh +++ b/rustup-init.sh @@ -657,8 +657,50 @@ ignore() { "$@" } +# Returns success if $1 is an http:// URL that points at the local machine: +# the host must be `localhost`, an IPv4 loopback address (127.0.0.0/8), or +# the IPv6 loopback address (`::1`). Such URLs may be downloaded over plain +# HTTP; every other URL must use https. +is_local_http_url() { + local _url="$1" + case "$_url" in + http://*) + ;; + *) + return 1 + ;; + esac + # Strip the path, the optional port, and IPv6 brackets to get the bare host. + local _host="${_url#http://}" + _host="${_host%%/*}" + case "$_host" in + \[*\]*) + # IPv6 literal host: [addr] or [addr]:port + _host="${_host%%\]*}" + _host="${_host#?}" + ;; + *) + _host="${_host%%:*}" + ;; + esac + case "$_host" in + localhost | 127.* | ::1) + return 0 + ;; + *) + return 1 + ;; + esac +} + # This wraps curl or wget. Try curl first, if not installed, # use wget instead. +# +# If RUSTUP_AUTHORIZATION_HEADER is set it is sent as the value of the +# `Authorization` header, and if RUSTUP_PROXY_AUTHORIZATION_HEADER is set it +# is sent as the value of the `Proxy-Authorization` header (to the proxy). +# http:// URLs pointing at the local machine (see is_local_http_url) are +# downloaded over plain HTTP; every other URL enforces https. downloader() { # zsh does not split words by default, Required for curl retry arguments below. is_zsh && setopt local_options shwordsplit @@ -668,6 +710,7 @@ downloader() { local _err local _status local _retry + local _url if check_cmd curl; then # Check if we have a broken snap curl # https://github.com/boukendesho/curl-snap/issues/1 @@ -691,57 +734,104 @@ downloader() { _dld='curl or wget' # to be used in error message of need_cmd fi - if [ "$1" = --check ]; then + _url="$1" + + if [ "$_url" = --check ]; then need_cmd "$_dld" - elif [ "$_dld" = curl ]; then + return 0 + fi + + local _output + local _arch + _output="$2" + _arch="$3" + + if [ "$_dld" = curl ]; then + # Build the complete list of curl arguments in the positional + # parameters; some values (cipher suites, header values) contain + # spaces and cannot be carried through a single variable in POSIX sh. check_curl_for_retry_support _retry="$RETVAL" - get_ciphersuites_for_curl - _ciphersuites="$RETVAL" - if [ -n "$_ciphersuites" ]; then - # shellcheck disable=SC2086 - _err=$(curl $_retry --proto '=https' --tlsv1.2 --ciphers "$_ciphersuites" --silent --show-error --fail --location "$1" --output "$2" 2>&1) - _status=$? + # shellcheck disable=SC2086 # _retry is intentionally split into words + set -- $_retry + if is_local_http_url "$_url"; then + # Plain HTTP to the local machine: no TLS enforcement needed. + : else - warn "Not enforcing strong cipher suites for TLS, this is potentially less secure" - if ! check_help_for "$3" curl --proto --tlsv1.2; then - warn "Not enforcing TLS v1.2, this is potentially less secure" - # shellcheck disable=SC2086 - _err=$(curl $_retry --silent --show-error --fail --location "$1" --output "$2" 2>&1) - _status=$? + get_ciphersuites_for_curl + _ciphersuites="$RETVAL" + if [ -n "$_ciphersuites" ]; then + set -- "$@" --proto '=https' --tlsv1.2 --ciphers "$_ciphersuites" else - # shellcheck disable=SC2086 - _err=$(curl $_retry --proto '=https' --tlsv1.2 --silent --show-error --fail --location "$1" --output "$2" 2>&1) - _status=$? + warn "Not enforcing strong cipher suites for TLS, this is potentially less secure" + if check_help_for "$_arch" curl --proto --tlsv1.2; then + set -- "$@" --proto '=https' --tlsv1.2 + else + warn "Not enforcing TLS v1.2, this is potentially less secure" + fi fi fi + if [ -n "${RUSTUP_AUTHORIZATION_HEADER-}" ]; then + set -- "$@" --header "Authorization: ${RUSTUP_AUTHORIZATION_HEADER}" + fi + if [ -n "${RUSTUP_PROXY_AUTHORIZATION_HEADER-}" ]; then + set -- "$@" --proxy-header "Proxy-Authorization: ${RUSTUP_PROXY_AUTHORIZATION_HEADER}" + fi + set -- "$@" --silent --show-error --fail --location "$_url" --output "$_output" + _err=$(curl "$@" 2>&1) + _status=$? if [ -n "$_err" ]; then warn "$_err" if echo "$_err" | grep -q 404$; then - err "installer for platform '$3' not found, this may be unsupported" + err "installer for platform '$_arch' not found, this may be unsupported" exit 1 fi fi return $_status elif [ "$_dld" = wget ]; then + # Build the complete list of wget arguments in the positional + # parameters (see the curl branch above for why). + # + # wget has no option to send headers only to the proxy, so + # Proxy-Authorization is added with --header as well; it is included + # in the request the proxy receives. + local _has_headers + _has_headers=no + set -- + if [ -n "${RUSTUP_AUTHORIZATION_HEADER-}" ]; then + set -- "$@" --header "Authorization: ${RUSTUP_AUTHORIZATION_HEADER}" + _has_headers=yes + fi + if [ -n "${RUSTUP_PROXY_AUTHORIZATION_HEADER-}" ]; then + set -- "$@" --header "Proxy-Authorization: ${RUSTUP_PROXY_AUTHORIZATION_HEADER}" + _has_headers=yes + fi if [ "$(wget -V 2>&1|head -2|tail -1|cut -f1 -d" ")" = "BusyBox" ]; then warn "using the BusyBox version of wget. Not enforcing strong cipher suites for TLS or TLS v1.2, this is potentially less secure" - _err=$(wget "$1" -O "$2" 2>&1) + if [ "$_has_headers" = yes ]; then + warn "BusyBox wget does not support custom headers, so RUSTUP_*_AUTHORIZATION_HEADER will not be sent" + set -- + fi + _err=$(wget "$@" "$_url" -O "$_output" 2>&1) _status=$? else get_ciphersuites_for_wget _ciphersuites="$RETVAL" - if [ -n "$_ciphersuites" ]; then - _err=$(wget --https-only --secure-protocol=TLSv1_2 --ciphers "$_ciphersuites" "$1" -O "$2" 2>&1) + if is_local_http_url "$_url"; then + # Plain HTTP to the local machine: no TLS enforcement needed. + _err=$(wget "$@" "$_url" -O "$_output" 2>&1) + _status=$? + elif [ -n "$_ciphersuites" ]; then + _err=$(wget "$@" --https-only --secure-protocol=TLSv1_2 --ciphers "$_ciphersuites" "$_url" -O "$_output" 2>&1) _status=$? else warn "Not enforcing strong cipher suites for TLS, this is potentially less secure" - if ! check_help_for "$3" wget --https-only --secure-protocol; then + if ! check_help_for "$_arch" wget --https-only --secure-protocol; then warn "Not enforcing TLS v1.2, this is potentially less secure" - _err=$(wget "$1" -O "$2" 2>&1) + _err=$(wget "$@" "$_url" -O "$_output" 2>&1) _status=$? else - _err=$(wget --https-only --secure-protocol=TLSv1_2 "$1" -O "$2" 2>&1) + _err=$(wget "$@" --https-only --secure-protocol=TLSv1_2 "$_url" -O "$_output" 2>&1) _status=$? fi fi @@ -749,7 +839,7 @@ downloader() { if [ -n "$_err" ]; then warn "$_err" if echo "$_err" | grep -q ' 404 Not Found$'; then - err "installer for platform '$3' not found, this may be unsupported" + err "installer for platform '$_arch' not found, this may be unsupported" exit 1 fi fi diff --git a/src/bin/rustup-mock-proxy.rs b/src/bin/rustup-mock-proxy.rs new file mode 100644 index 0000000000..f128749deb --- /dev/null +++ b/src/bin/rustup-mock-proxy.rs @@ -0,0 +1,343 @@ +//! Forward proxy for testing rustup proxy authorization +//! +//! This program creates a forward proxy for testing the `RUSTUP_PROXY_AUTHORIZATION_HEADER` +//! environment variable in rustup. + +use std::env; +use std::net::SocketAddr; +use std::path::PathBuf; + +use base64::Engine; +use clap::Parser; +use http_body_util::{BodyExt, Full}; +use hyper::body::Bytes; +use hyper::body::Incoming; +use hyper::header::PROXY_AUTHORIZATION; +use hyper::server::conn::http1 as server_http1; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode}; +use hyper_util::rt::TokioIo; +use rustup::test::{MockDataFile, shutdown_signal}; +use tokio::net::{TcpListener, TcpStream}; +use tracing::{error, info, warn}; + +/// Forward proxy for testing rustup proxy authorization +#[derive(Parser, Debug)] +#[command(name = "rustup-mock-proxy")] +#[command(author, version, about, long_about = None)] +struct Opt { + /// Address to bind to + #[arg(short, long, default_value = "127.0.0.1")] + addr: String, + + /// Port to bind to; 0 lets the OS assign a free port (the default) + #[arg(short, long, default_value = "0")] + port: u16, + + /// Basic auth credentials in the form "username:password" + #[arg(long)] + basic_test_credential: Option, + + /// Where to write the data file (default: + /// ${HOME}/.local/share/rustup-mock-proxy.data on Unix, + /// %LOCALAPPDATA%\rustup-mock-proxy.data on Windows) + #[arg(long)] + data_file: Option, +} + +#[tokio::main] +async fn main() { + // Initialize logging from RUSTUP_LOG environment variable + let log_level = env::var("RUSTUP_LOG").unwrap_or_else(|_| "info".to_string()); + + // Set up tracing subscriber with the log level + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new(log_level.clone())) + .init(); + + let opt = Opt::parse(); + + // Parse basic auth credentials if provided + let credentials: Option<(String, String)> = opt.basic_test_credential.as_ref().and_then(|s| { + let parts: Vec<&str> = s.splitn(2, ':').collect(); + if parts.len() == 2 { + Some((parts[0].to_string(), parts[1].to_string())) + } else { + warn!("Invalid basic auth format, expected 'username:password'"); + None + } + }); + let credential = credentials + .as_ref() + .map(|(user, pass)| format!("{user}:{pass}")); + + let data_file = MockDataFile::new( + opt.data_file + .clone() + .unwrap_or_else(|| MockDataFile::default_path("rustup-mock-proxy")), + ); + + let addr: SocketAddr = format!("{}:{}", opt.addr, opt.port) + .parse() + .expect("Invalid address:port combination"); + + // Create the server + let listener = match TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + error!("Failed to bind to {}: {}", addr, e); + std::process::exit(1); + } + }; + + // Record the listening address and port (and the rest of the runtime + // configuration) in the data file. This is written after the listener is + // bound, so its presence means the proxy is ready. + let local_addr = listener + .local_addr() + .expect("bound listener has no local address"); + if let Err(e) = MockDataFile::write( + data_file.path(), + &local_addr.ip().to_string(), + local_addr.port(), + std::process::id(), + credential.as_deref(), + None, + ) { + error!("Failed to write data file {:?}: {}", data_file.path(), e); + std::process::exit(1); + } + + info!("Forward proxy listening on {}", local_addr); + info!("Data file written to {:?}", data_file.path()); + info!("Forward proxy ready to accept connections"); + + tokio::select! { + _ = shutdown_signal() => info!("Shutting down"), + _ = serve(listener, credentials) => {} + } + + // `data_file` is dropped here, removing the data file. +} + +/// Accepts connections until the process is terminated. +async fn serve(listener: TcpListener, credentials: Option<(String, String)>) { + loop { + let (stream, remote_addr) = match listener.accept().await { + Ok(s) => s, + Err(e) => { + error!("Failed to accept connection: {}", e); + continue; + } + }; + + info!("Proxy connection from {}", remote_addr); + + let creds = credentials.clone(); + let io = TokioIo::new(stream); + + tokio::spawn(async move { + if let Err(e) = serve_connection(io, creds).await { + error!("Connection error: {}", e); + } + }); + } +} + +async fn serve_connection( + io: TokioIo, + credentials: Option<(String, String)>, +) -> Result<(), hyper::Error> { + let mut builder = server_http1::Builder::new(); + builder.preserve_header_case(true); + builder.title_case_headers(true); + + builder + .serve_connection( + io, + service_fn(move |req| { + let creds = credentials.clone(); + async move { handle_proxy_request(req, creds).await } + }), + ) + .with_upgrades() + .await +} + +async fn handle_proxy_request( + req: Request, + credentials: Option<(String, String)>, +) -> Result>, hyper::Error> { + info!("Proxy request: {} {}", req.method(), req.uri()); + + // Handle basic authentication if configured + if let Some((username, password)) = &credentials { + let auth_header = req.headers().get(PROXY_AUTHORIZATION); + + match auth_header { + Some(header) => { + let auth_str = header.to_str().unwrap_or(""); + let expected = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("{}:{}", username, password).as_bytes()) + ); + if auth_str != expected { + info!("Authentication failed for request to {}", req.uri()); + return Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Full::new(Bytes::from("Unauthorized"))) + .unwrap()); + } + } + None => { + info!( + "Missing proxy authentication header for request to {}", + req.uri() + ); + return Ok(Response::builder() + .status(StatusCode::PROXY_AUTHENTICATION_REQUIRED) + .body(Full::new(Bytes::from("Proxy requires authentication"))) + .unwrap()); + } + } + } + + // Handle CONNECT requests for tunneling (used for HTTPS) + if req.method() == Method::CONNECT { + handle_connect_request(req).await + } else { + // For HTTP requests, forward directly to the target + forward_http_request(req).await + } +} + +async fn forward_http_request( + req: Request, +) -> Result>, hyper::Error> { + // Extract the target from the URI + let Some(target) = req.uri().authority().map(|a| a.as_str().to_string()) else { + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Full::new(Bytes::from("Bad request - no authority"))) + .unwrap()); + }; + + info!("Forwarding request to {}", target); + + let stream = match TcpStream::connect(&target).await { + Ok(stream) => stream, + Err(e) => { + warn!("Failed to connect to {}: {}", target, e); + return Ok(Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Full::new(Bytes::from(format!("Connection failed: {e}")))) + .unwrap()); + } + }; + + // Forward the request through a hyper HTTP/1.1 client connection. The + // absolute-form URI is sent unchanged, as a forward proxy requires. + let (mut sender, conn) = match hyper::client::conn::http1::Builder::new() + .handshake(TokioIo::new(stream)) + .await + { + Ok(handshake) => handshake, + Err(e) => { + warn!("Failed to start client connection to {}: {}", target, e); + return Ok(Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Full::new(Bytes::from(format!("Handshake failed: {e}")))) + .unwrap()); + } + }; + let conn_target = target.clone(); + tokio::spawn(async move { + if let Err(e) = conn.await { + warn!( + "Client connection to {} closed with error: {}", + conn_target, e + ); + } + }); + + let response = match sender.send_request(req).await { + Ok(response) => response, + Err(e) => { + warn!("Failed to send request to {}: {}", target, e); + return Ok(Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Full::new(Bytes::from(format!("Send error: {e}")))) + .unwrap()); + } + }; + + let (parts, body) = response.into_parts(); + let bytes = match body.collect().await { + Ok(collected) => collected.to_bytes(), + Err(e) => { + warn!("Failed to read response from {}: {}", target, e); + return Ok(Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Full::new(Bytes::new())) + .unwrap()); + } + }; + + info!("Received response: {} bytes", bytes.len()); + Ok(Response::from_parts(parts, Full::new(bytes))) +} + +async fn handle_connect_request( + req: Request, +) -> Result>, hyper::Error> { + // Extract the target address from the CONNECT request + let target = req.uri().authority().map(|auth| auth.to_string()); + + match target { + Some(addr) => { + info!("CONNECT tunnel request to {}", addr); + + // Upgrade the connection and trigger the tunnel + let upgraded = hyper::upgrade::on(req).await?; + + // Handle the tunnel + tokio::spawn(async move { + if let Err(e) = tunnel(upgraded, &addr).await { + warn!("Tunnel error: {}", e); + } + }); + + Ok(Response::builder() + .status(StatusCode::OK) + .body(Full::new(Bytes::new())) + .unwrap()) + } + None => { + warn!("CONNECT request without valid authority"); + let mut resp = Response::new(Full::new(Bytes::new())); + *resp.status_mut() = StatusCode::BAD_REQUEST; + Ok(resp) + } + } +} + +// Create a bidirectional tunnel between the client and target server +async fn tunnel(upgraded: hyper::upgrade::Upgraded, target: &str) -> std::io::Result<()> { + info!("Establishing tunnel to {}", target); + + // Connect to the target server + let mut server = TcpStream::connect(target).await?; + let mut upgraded = TokioIo::new(upgraded); + + // Proxy data between client and server using copy_bidirectional + let (from_client, from_server) = + tokio::io::copy_bidirectional(&mut upgraded, &mut server).await?; + + info!( + "Tunnel complete: client wrote {} bytes, received {} bytes", + from_client, from_server + ); + + Ok(()) +} diff --git a/src/bin/rustup-mock-server.rs b/src/bin/rustup-mock-server.rs new file mode 100644 index 0000000000..e9be16e758 --- /dev/null +++ b/src/bin/rustup-mock-server.rs @@ -0,0 +1,249 @@ +//! Mock distribution server for testing rustup +//! +//! This program creates a mock distribution server for testing rustup functionality. +//! It serves files over HTTP from a directory structure that mimics the rustup distribution server format. + +use std::env; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; + +use base64::Engine; +use clap::Parser; +use http_body_util::Full; +use hyper::body::Bytes; +use hyper::body::Incoming; +use hyper::header::AUTHORIZATION; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Request, Response, StatusCode}; +use rustup::test::{MockDataFile, create_mock_dist_server, shutdown_signal}; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tracing::{error, info, warn}; + +/// Mock distribution server for testing rustup +#[derive(Parser, Debug)] +#[command(name = "rustup-mock-server")] +#[command(author, version, about, long_about = None)] +struct Opt { + /// Address to bind to + #[arg(short, long, default_value = "127.0.0.1")] + addr: String, + + /// Port to bind to; 0 lets the OS assign a free port (the default) + #[arg(short, long, default_value = "0")] + port: u16, + + /// Directory to serve (a temporary directory will be created if not specified) + #[arg(short, long)] + directory: Option, + + /// Basic auth credentials in the form "username:password" + #[arg(long)] + basic_test_credential: Option, + + /// Where to write the data file (default: + /// ${HOME}/.local/share/rustup-mock-server.data on Unix, + /// %LOCALAPPDATA%\rustup-mock-server.data on Windows) + #[arg(long)] + data_file: Option, +} + +#[tokio::main] +async fn main() { + // Initialize logging from RUSTUP_LOG environment variable + let log_level = env::var("RUSTUP_LOG").unwrap_or_else(|_| "info".to_string()); + + // Set up tracing subscriber with the log level + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::new(log_level.clone())) + .init(); + + let opt = Opt::parse(); + + let (directory, _temp_dir) = if let Some(dir) = opt.directory { + (dir, None) + } else { + let temp = TempDir::new().expect("Failed to create temporary directory"); + (temp.path().to_path_buf(), Some(temp)) + }; + + info!("Creating mock distribution server at {:?}", directory); + + // Create the mock distribution server directory structure using the test module's infrastructure + if let Err(e) = create_mock_dist_server(&directory) { + error!("Failed to setup mock distribution server: {}", e); + std::process::exit(1); + } + + info!("Mock distribution server created at {:?}", directory); + + // Parse basic auth credentials if provided + let credentials: Option<(String, String)> = opt.basic_test_credential.as_ref().and_then(|s| { + let parts: Vec<&str> = s.splitn(2, ':').collect(); + if parts.len() == 2 { + Some((parts[0].to_string(), parts[1].to_string())) + } else { + warn!("Invalid basic auth format, expected 'username:password'"); + None + } + }); + let credential = credentials + .as_ref() + .map(|(user, pass)| format!("{user}:{pass}")); + + let data_file = MockDataFile::new( + opt.data_file + .clone() + .unwrap_or_else(|| MockDataFile::default_path("rustup-mock-server")), + ); + + let addr: SocketAddr = format!("{}:{}", opt.addr, opt.port) + .parse() + .expect("Invalid address:port combination"); + + // Create the server + let listener = match TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + error!("Failed to bind to {}: {}", addr, e); + std::process::exit(1); + } + }; + + // Record the listening address and port (and the rest of the runtime + // configuration) in the data file. This is written after the listener is + // bound, so its presence means the server is ready. + let local_addr = listener + .local_addr() + .expect("bound listener has no local address"); + if let Err(e) = MockDataFile::write( + data_file.path(), + &local_addr.ip().to_string(), + local_addr.port(), + std::process::id(), + credential.as_deref(), + Some(&directory), + ) { + error!("Failed to write data file {:?}: {}", data_file.path(), e); + std::process::exit(1); + } + + info!("Mock server listening on {}", local_addr); + info!("Data file written to {:?}", data_file.path()); + info!("Server ready to accept connections"); + + let server_state = Arc::new(Mutex::new(ServerState { + dist_dir: directory, + credentials, + })); + + tokio::select! { + _ = shutdown_signal() => info!("Shutting down"), + _ = serve(listener, server_state) => {} + } + + // `data_file` is dropped here, removing the data file. +} + +/// Accepts connections until the process is terminated. +async fn serve(listener: TcpListener, server_state: Arc>) { + loop { + let (stream, remote_addr) = match listener.accept().await { + Ok(s) => s, + Err(e) => { + error!("Failed to accept connection: {}", e); + continue; + } + }; + + info!("Connection from {}", remote_addr); + + let server_state = server_state.clone(); + let io = hyper_util::rt::TokioIo::new(stream); + + tokio::spawn(async move { + if let Err(e) = http1::Builder::new() + .serve_connection( + io, + service_fn(move |req| { + let state = server_state.clone(); + async move { handle_request(req, state).await } + }), + ) + .await + { + error!("Connection error: {}", e); + } + }); + } +} + +#[derive(Debug, Clone)] +struct ServerState { + dist_dir: PathBuf, + credentials: Option<(String, String)>, +} + +async fn handle_request( + req: Request, + state: Arc>, +) -> Result>, hyper::Error> { + let state = state.lock().unwrap(); + + // Handle basic authentication if configured + if let Some((username, password)) = &state.credentials { + if let Some(auth_header) = req.headers().get(AUTHORIZATION) { + let auth_str = auth_header.to_str().unwrap_or(""); + let expected = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("{}:{}", username, password).as_bytes()) + ); + if auth_str != expected { + warn!("Authentication failed"); + return Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Full::new(Bytes::from("Unauthorized"))) + .unwrap()); + } + } else { + warn!("Missing authentication header"); + return Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Full::new(Bytes::from("Authentication required"))) + .unwrap()); + } + } + + let path = req.uri().path(); + let safe_path = path.trim_start_matches('/'); + + info!("Request for: {}", path); + + let file_path = state.dist_dir.join(safe_path); + + if !file_path.exists() { + warn!("File not found: {:?}", file_path); + return Ok(Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from("Not Found"))) + .unwrap()); + } + + // For simplicity, return a basic response + let body = match std::fs::read(&file_path) { + Ok(contents) => Full::new(Bytes::from(contents)), + Err(e) => { + error!("Failed to read file {:?}: {}", file_path, e); + return Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Full::new(Bytes::from("Internal Error"))) + .unwrap()); + } + }; + + Ok(Response::new(body)) +} diff --git a/src/download/mod.rs b/src/download/mod.rs index 8871a936f5..c6d91933b5 100644 --- a/src/download/mod.rs +++ b/src/download/mod.rs @@ -30,10 +30,12 @@ use crate::{dist::download::DownloadStatus, errors::RustupError, process::Proces #[cfg(test)] mod tests; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct DownloadOptions { tls: Tls, timeout: Duration, + authorization_header: Option, + proxy_authorization_header: Option, } impl DownloadOptions { @@ -44,7 +46,7 @@ impl DownloadOptions { hasher: None, status: None, resume: false, - options: *self, + options: self.clone(), } } } @@ -98,7 +100,15 @@ impl TryFrom<&Process> for DownloadOptions { Err(_) => 180, }); - Ok(Self { tls, timeout }) + let authorization_header = process.var_opt("RUSTUP_AUTHORIZATION_HEADER")?; + let proxy_authorization_header = process.var_opt("RUSTUP_PROXY_AUTHORIZATION_HEADER")?; + + Ok(Self { + tls, + timeout, + authorization_header, + proxy_authorization_header, + }) } } @@ -287,6 +297,14 @@ impl<'a> Download<'a> { } let mut req = client.get(url.as_str()); + + if let Some(header) = &self.options.authorization_header { + req = req.header(header::AUTHORIZATION, header); + } + if let Some(header) = &self.options.proxy_authorization_header { + req = req.header(header::PROXY_AUTHORIZATION, header); + } + if resume_from != 0 { req = req.header(header::RANGE, format!("bytes={resume_from}-")); } diff --git a/src/download/tests.rs b/src/download/tests.rs index 0957b3718b..d49d62ba85 100644 --- a/src/download/tests.rs +++ b/src/download/tests.rs @@ -15,6 +15,220 @@ use hyper::server::conn::http1; use hyper::service::service_fn; use tempfile::TempDir; +pub fn tmp_dir() -> TempDir { + tempfile::Builder::new() + .prefix("rustup-download-test-") + .tempdir() + .expect("creating tempdir for test") +} + +pub fn write_file(path: &Path, contents: &str) { + let mut file = fs::OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(path) + .expect("writing test data"); + + io::Write::write_all(&mut file, contents.as_bytes()).expect("writing test data"); + + file.sync_data().expect("writing test data"); +} + +// A dead simple hyper server implementation. +// For more info, see: +// https://hyper.rs/guides/1/server/hello-world/ +async fn run_server( + addr_tx: Sender, + addr: SocketAddr, + contents: Vec, + honor_range: bool, +) { + let svc = service_fn(move |req: Request| { + let contents = contents.clone(); + async move { + let res = serve_contents(req, contents, honor_range); + Ok::<_, Infallible>(res) + } + }); + + let listener = tokio::net::TcpListener::bind(&addr) + .await + .expect("can not bind"); + + let addr = listener.local_addr().unwrap(); + addr_tx.send(addr).unwrap(); + + loop { + let (stream, _) = listener + .accept() + .await + .expect("could not accept connection"); + let io = hyper_util::rt::TokioIo::new(stream); + + let svc = svc.clone(); + tokio::spawn(async move { + if let Err(err) = http1::Builder::new().serve_connection(io, svc).await { + eprintln!("failed to serve connection: {err:?}"); + } + }); + } +} + +pub fn serve_file(contents: Vec, honor_range: bool) -> SocketAddr { + let addr: SocketAddr = ([127, 0, 0, 1], 0).into(); + let (addr_tx, addr_rx) = channel(); + + thread::spawn(move || { + let server = run_server(addr_tx, addr, contents, honor_range); + let rt = tokio::runtime::Runtime::new().expect("could not creating Runtime"); + rt.block_on(server); + }); + + let addr = addr_rx.recv(); + addr.unwrap() +} + +fn serve_contents( + req: Request, + contents: Vec, + honor_range: bool, +) -> hyper::Response> { + let mut range_header = None; + let (status, body) = if honor_range && let Some(range) = req.headers().get(hyper::header::RANGE) + { + // extract range "bytes={start}-" + let range = range.to_str().expect("unexpected Range header"); + assert!(range.starts_with("bytes=")); + let range = range.trim_start_matches("bytes="); + assert!(range.ends_with('-')); + let range = range.trim_end_matches('-'); + assert_eq!(range.split('-').count(), 1); + let start: u64 = range.parse().expect("unexpected Range header"); + + range_header = Some(format!("bytes {}-{len}/{len}", start, len = contents.len())); + ( + hyper::StatusCode::PARTIAL_CONTENT, + contents[start as usize..].to_vec(), + ) + } else { + (hyper::StatusCode::OK, contents) + }; + + let mut res = hyper::Response::builder() + .status(status) + .header(hyper::header::CONTENT_LENGTH, body.len()) + .body(Full::new(Bytes::from(body))) + .unwrap(); + if let Some(range) = range_header { + res.headers_mut() + .insert(hyper::header::CONTENT_RANGE, range.parse().unwrap()); + } + res +} + +/// Clear proxy-related environment variables +/// +/// Every test using a proxy-sensitive URL should call this and hold the returned guard, +/// regardless of whether the test is going to set its own proxy environment variables. +async fn scrub_env() -> tokio::sync::MutexGuard<'static, ()> { + static SERIALISE_TESTS: LazyLock> = + LazyLock::new(|| tokio::sync::Mutex::new(())); + + let guard = SERIALISE_TESTS.lock().await; + + // SAFETY: We are clearing environment variables when `SERIALISE_TESTS` is locked, and those + // environment variables in question are only relevant in tests that continue to hold this + // mutex guard. + unsafe { + remove_var("http_proxy"); + remove_var("HTTP_PROXY"); + remove_var("https_proxy"); + remove_var("HTTPS_PROXY"); + remove_var("ftp_proxy"); + remove_var("FTP_PROXY"); + remove_var("all_proxy"); + remove_var("ALL_PROXY"); + remove_var("no_proxy"); + remove_var("NO_PROXY"); + } + + guard +} + +/// A server that verifies the given request headers match the expected values. +fn serve_file_with_header_verification(headers: Vec<(&str, &str)>) -> SocketAddr { + let addr: SocketAddr = ([127, 0, 0, 1], 0).into(); + let (addr_tx, addr_rx) = channel(); + let headers: Vec<(String, String)> = headers + .into_iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect(); + + thread::spawn(move || { + let contents = b"test content for header verification".to_vec(); + + let svc = service_fn(move |req: Request| { + let contents = contents.clone(); + let headers = headers.clone(); + async move { + let res = serve_contents_with_header_verification(req, contents, &headers); + Ok::<_, Infallible>(res) + } + }); + + let rt = tokio::runtime::Runtime::new().expect("could not create Runtime"); + rt.block_on(async { + let listener = tokio::net::TcpListener::bind(addr) + .await + .expect("can not bind"); + let local_addr = listener.local_addr().unwrap(); + addr_tx.send(local_addr).unwrap(); + + loop { + let (stream, _) = listener + .accept() + .await + .expect("could not accept connection"); + let io = hyper_util::rt::TokioIo::new(stream); + let svc_ref = svc.clone(); + + if let Err(err) = http1::Builder::new().serve_connection(io, svc_ref).await { + eprintln!("failed to serve connection: {err:?}"); + } + } + }); + }); + + addr_rx.recv().unwrap() +} + +fn serve_contents_with_header_verification( + req: Request, + contents: Vec, + headers: &[(String, String)], +) -> hyper::Response> { + // Verify all headers are present and have the expected values + for (header_name, expected_value) in headers { + let actual_value = req + .headers() + .get(header_name.as_str()) + .map(|v| v.to_str().unwrap_or("")); + if actual_value != Some(expected_value.as_str()) { + return hyper::Response::builder() + .status(hyper::StatusCode::UNAUTHORIZED) + .body(Full::new(Bytes::from("Unauthorized"))) + .unwrap(); + } + } + + hyper::Response::builder() + .status(hyper::StatusCode::OK) + .header(hyper::header::CONTENT_LENGTH, contents.len()) + .body(Full::new(Bytes::from(contents))) + .unwrap() +} + #[cfg(any(feature = "reqwest-rustls-tls", feature = "reqwest-native-tls"))] mod reqwest { use std::env::set_var; @@ -28,12 +242,14 @@ mod reqwest { use reqwest::{Client, Proxy}; use url::Url; - use super::{scrub_env, serve_file, tmp_dir, write_file}; + use super::{scrub_env, serve_file, serve_file_with_header_verification, tmp_dir, write_file}; use crate::download::{DownloadOptions, Tls}; const OPTIONS: DownloadOptions = DownloadOptions { tls: DOWNLOAD_BACKEND, timeout: Duration::from_secs(180), + authorization_header: None, + proxy_authorization_header: None, }; #[cfg(feature = "reqwest-rustls-tls")] @@ -158,6 +374,8 @@ mod reqwest { DownloadOptions { tls: DOWNLOAD_BACKEND, timeout: Duration::from_secs(1), + authorization_header: None, + proxy_authorization_header: None, } .start(&from_url, &target_path) .with_resume() @@ -168,145 +386,132 @@ mod reqwest { assert!(target_path.exists(), "partial file should not be deleted"); assert_eq!(std::fs::read_to_string(&target_path).unwrap(), "123"); } -} -pub fn tmp_dir() -> TempDir { - tempfile::Builder::new() - .prefix("rustup-download-test-") - .tempdir() - .expect("creating tempdir for test") -} + #[tokio::test] + async fn authorization_header() { + let _guard = scrub_env().await; + let tmpdir = tmp_dir(); + let target_path = tmpdir.path().join("downloaded"); -pub fn write_file(path: &Path, contents: &str) { - let mut file = fs::OpenOptions::new() - .write(true) - .truncate(true) - .create(true) - .open(path) - .expect("writing test data"); + // Bearer token: ghp_1234567890abcdef is a typical format for GitHub Personal Access Tokens, + // which are commonly used as Bearer tokens in CI/CD environments. + let bearer_token = "Bearer ghp_1234567890abcdef"; + let addr = serve_file_with_header_verification(vec![("Authorization", bearer_token)]); + let from_url = format!("http://{addr}").parse().unwrap(); - io::Write::write_all(&mut file, contents.as_bytes()).expect("writing test data"); + let options = DownloadOptions { + tls: DOWNLOAD_BACKEND, + timeout: Duration::from_secs(180), + authorization_header: Some(bearer_token.to_string()), + proxy_authorization_header: None, + }; - file.sync_data().expect("writing test data"); -} + options + .start(&from_url, &target_path) + .download() + .await + .expect("Test download failed"); -// A dead simple hyper server implementation. -// For more info, see: -// https://hyper.rs/guides/1/server/hello-world/ -async fn run_server( - addr_tx: Sender, - addr: SocketAddr, - contents: Vec, - honor_range: bool, -) { - let svc = service_fn(move |req: Request| { - let contents = contents.clone(); - async move { - let res = serve_contents(req, contents, honor_range); - Ok::<_, Infallible>(res) - } - }); + assert!(target_path.exists()); + assert_eq!( + std::fs::read_to_string(&target_path).unwrap().trim(), + "test content for header verification" + ); + } - let listener = tokio::net::TcpListener::bind(&addr) - .await - .expect("can not bind"); + #[tokio::test] + async fn proxy_authorization_header() { + let _guard = scrub_env().await; + let tmpdir = tmp_dir(); + let target_path = tmpdir.path().join("downloaded"); - let addr = listener.local_addr().unwrap(); - addr_tx.send(addr).unwrap(); + // Basic auth value for username 'test' and password '123?45>6': + // Shell command: echo -n 'test:123?45>6' | base64 + // Result: dGVzdDoxMjM/NDU+Ng== + // The password contains special characters that are common in passwords (? and >). + let basic_auth = "Basic dGVzdDoxMjM/NDU+Ng=="; + let addr = serve_file_with_header_verification(vec![("Proxy-Authorization", basic_auth)]); + let from_url = format!("http://{addr}").parse().unwrap(); - loop { - let (stream, _) = listener - .accept() + let options = DownloadOptions { + tls: DOWNLOAD_BACKEND, + timeout: Duration::from_secs(180), + authorization_header: None, + proxy_authorization_header: Some(basic_auth.to_string()), + }; + + options + .start(&from_url, &target_path) + .download() .await - .expect("could not accept connection"); - let io = hyper_util::rt::TokioIo::new(stream); + .expect("Test download failed"); - let svc = svc.clone(); - tokio::spawn(async move { - if let Err(err) = http1::Builder::new().serve_connection(io, svc).await { - eprintln!("failed to serve connection: {err:?}"); - } - }); + assert!(target_path.exists()); + assert_eq!( + std::fs::read_to_string(&target_path).unwrap().trim(), + "test content for header verification" + ); } -} - -pub fn serve_file(contents: Vec, honor_range: bool) -> SocketAddr { - let addr = ([127, 0, 0, 1], 0).into(); - let (addr_tx, addr_rx) = channel(); - thread::spawn(move || { - let server = run_server(addr_tx, addr, contents, honor_range); - let rt = tokio::runtime::Runtime::new().expect("could not creating Runtime"); - rt.block_on(server); - }); + #[tokio::test] + async fn both_authorization_headers() { + let _guard = scrub_env().await; + let tmpdir = tmp_dir(); + let target_path = tmpdir.path().join("downloaded"); - let addr = addr_rx.recv(); - addr.unwrap() -} + // Both Authorization and Proxy-Authorization headers + let addr = serve_file_with_header_verification(vec![ + ("Authorization", "Bearer combined-token"), + ("Proxy-Authorization", "Basic dGVzdDoxMjM/NDU+Ng=="), + ]); + let from_url = format!("http://{addr}").parse().unwrap(); -fn serve_contents( - req: Request, - contents: Vec, - honor_range: bool, -) -> hyper::Response> { - let mut range_header = None; - let (status, body) = if honor_range && let Some(range) = req.headers().get(hyper::header::RANGE) - { - // extract range "bytes={start}-" - let range = range.to_str().expect("unexpected Range header"); - assert!(range.starts_with("bytes=")); - let range = range.trim_start_matches("bytes="); - assert!(range.ends_with('-')); - let range = range.trim_end_matches('-'); - assert_eq!(range.split('-').count(), 1); - let start: u64 = range.parse().expect("unexpected Range header"); + let options = DownloadOptions { + tls: DOWNLOAD_BACKEND, + timeout: Duration::from_secs(180), + authorization_header: Some("Bearer combined-token".to_string()), + proxy_authorization_header: Some("Basic dGVzdDoxMjM/NDU+Ng==".to_string()), + }; - range_header = Some(format!("bytes {}-{len}/{len}", start, len = contents.len())); - ( - hyper::StatusCode::PARTIAL_CONTENT, - contents[start as usize..].to_vec(), - ) - } else { - (hyper::StatusCode::OK, contents) - }; + options + .start(&from_url, &target_path) + .download() + .await + .expect("Test download failed"); - let mut res = hyper::Response::builder() - .status(status) - .header(hyper::header::CONTENT_LENGTH, body.len()) - .body(Full::new(Bytes::from(body))) - .unwrap(); - if let Some(range) = range_header { - res.headers_mut() - .insert(hyper::header::CONTENT_RANGE, range.parse().unwrap()); + assert!(target_path.exists()); + assert_eq!( + std::fs::read_to_string(&target_path).unwrap().trim(), + "test content for header verification" + ); } - res -} -/// Clear proxy-related environment variables -/// -/// Every test using a proxy-sensitive URL should call this and hold the returned guard, -/// regardless of whether the test is going to set its own proxy environment variables. -async fn scrub_env() -> tokio::sync::MutexGuard<'static, ()> { - static SERIALISE_TESTS: LazyLock> = - LazyLock::new(|| tokio::sync::Mutex::new(())); + #[tokio::test] + async fn no_authorization_headers() { + let _guard = scrub_env().await; + let tmpdir = tmp_dir(); + let target_path = tmpdir.path().join("downloaded"); + // Use the standard serve_file which doesn't check for any headers + let addr = serve_file(b"test content for no headers".to_vec(), false); + let from_url = format!("http://{addr}").parse().unwrap(); - let guard = SERIALISE_TESTS.lock().await; + let options = DownloadOptions { + tls: DOWNLOAD_BACKEND, + timeout: Duration::from_secs(180), + authorization_header: None, + proxy_authorization_header: None, + }; - // SAFETY: We are clearing environment variables when `SERIALISE_TESTS` is locked, and those - // environment variables in question are only relevant in tests that continue to hold this - // mutex guard. - unsafe { - remove_var("http_proxy"); - remove_var("HTTP_PROXY"); - remove_var("https_proxy"); - remove_var("HTTPS_PROXY"); - remove_var("ftp_proxy"); - remove_var("FTP_PROXY"); - remove_var("all_proxy"); - remove_var("ALL_PROXY"); - remove_var("no_proxy"); - remove_var("NO_PROXY"); - } + options + .start(&from_url, &target_path) + .download() + .await + .expect("Test download failed"); - guard + assert!(target_path.exists()); + assert_eq!( + std::fs::read_to_string(&target_path).unwrap().trim(), + "test content for no headers" + ); + } } diff --git a/src/test.rs b/src/test.rs index ebb178fd04..f49d56d979 100644 --- a/src/test.rs +++ b/src/test.rs @@ -2,7 +2,8 @@ clippy::box_default, clippy::print_stdout, clippy::print_stderr, - clippy::dbg_macro + clippy::dbg_macro, + unused_imports )] //! Test support module; public to permit use from integration tests. @@ -16,7 +17,7 @@ use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::process::Command; -#[cfg(test)] +#[cfg(any(test, feature = "test"))] use anyhow::Result; use sha2::{Digest, Sha256}; @@ -36,10 +37,12 @@ pub use clitools::{ Assert, CliTestContext, Config, ParkedChild, SanitizedOutput, Scenario, SelfUpdateTestContext, output_release_file, print_command, print_indented, }; -pub(super) mod dist; +pub mod dist; pub use dist::DistContext; -pub(super) mod mock; +pub mod mock; pub use mock::{MockComponentBuilder, MockFile, MockInstallerBuilder}; +mod mock_data; +pub use mock_data::MockDataFile; pub fn checkpoint_path(test_root: &Path, name: &str) -> PathBuf { test_root.join(format!("rustup-checkpoint-{name}")) @@ -338,3 +341,52 @@ pub static MULTI_ARCH1: &str = "i686-unknown-linux-gnu"; static MULTI_ARCH1: &str = "x86_64-unknown-linux-gnu"; pub const CHECKPOINT_ENV: &str = "RUSTUP_TEST_CHECKPOINT"; + +/// Create a mock distribution server directory structure at the specified path. +/// This function is used by the rustup-mock-server binary to set up the server. +#[cfg(feature = "test")] +#[allow(unused_qualifications)] +pub fn create_mock_dist_server(path: &Path) -> anyhow::Result<()> { + use dist::MockChannel; + use dist::{MockDistServer, MockManifestVersion}; + + let server = MockDistServer { + path: path.to_path_buf(), + channels: vec![MockChannel::new( + "stable", + "2025-01-01", + "1.30.0", + "hash-12345", + dist::RlsStatus::Available, + true, + false, + )], + }; + + server.write(&[MockManifestVersion::V2], false, true); + Ok(()) +} + +/// Resolves when the process receives a termination signal: SIGINT anywhere, +/// plus SIGTERM on unix. +/// +/// The mock programs select on this so they can remove their data file and +/// exit cleanly. +#[cfg(feature = "test")] +pub async fn shutdown_signal() { + #[cfg(unix)] + { + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to register a SIGTERM handler"); + tokio::select! { + _ = sigterm.recv() => tracing::info!("SIGTERM received"), + _ = tokio::signal::ctrl_c() => tracing::info!("SIGINT received"), + } + } + #[cfg(not(unix))] + { + if tokio::signal::ctrl_c().await.is_err() { + tracing::error!("failed to listen for SIGINT"); + } + } +} diff --git a/src/test/dist.rs b/src/test/dist.rs index 833836d47c..3667afe7b9 100644 --- a/src/test/dist.rs +++ b/src/test/dist.rs @@ -264,7 +264,7 @@ pub fn change_channel_date(dist_server: &Url, channel: &str, date: &str) { // A mock Rust v2 distribution server. Create it and run `write` // to write its structure to a directory. #[derive(Debug)] -pub(crate) struct MockDistServer { +pub struct MockDistServer { // The local path to the dist server root pub path: PathBuf, pub channels: Vec, @@ -272,7 +272,7 @@ pub(crate) struct MockDistServer { // A Rust distribution channel #[derive(Debug)] -pub(crate) struct MockChannel { +pub struct MockChannel { // e.g. "nightly" pub name: String, // YYYY-MM-DD @@ -282,7 +282,7 @@ pub(crate) struct MockChannel { } impl MockChannel { - pub(super) fn new( + pub fn new( channel: &str, date: &str, version: &str, @@ -533,7 +533,7 @@ impl MockChannelContent { } #[derive(Copy, Clone, Eq, PartialEq)] -pub(super) enum RlsStatus { +pub enum RlsStatus { Available, Renamed, Unavailable, @@ -550,7 +550,7 @@ impl RlsStatus { // A single rust-installer package #[derive(Debug, Hash, Eq, PartialEq)] -pub(crate) struct MockPackage { +pub struct MockPackage { // rust, rustc, rust-std-$tuple, rust-doc, etc. pub name: &'static str, pub version: String, @@ -558,7 +558,7 @@ pub(crate) struct MockPackage { } #[derive(Debug, Hash, Eq, PartialEq, Clone)] -pub(crate) struct MockTargetedPackage { +pub struct MockTargetedPackage { // Target tuple pub target: String, // Whether the file actually exists (could be due to build failure) @@ -569,7 +569,7 @@ pub(crate) struct MockTargetedPackage { } #[derive(Debug, PartialEq, Eq, Hash, Clone)] -pub(crate) struct MockComponent { +pub struct MockComponent { pub name: String, pub target: String, pub is_extension: bool, @@ -582,7 +582,7 @@ struct MockHashes { pub zst: Option, } -pub(crate) enum MockManifestVersion { +pub enum MockManifestVersion { V1, V2, } diff --git a/src/test/mock_data.rs b/src/test/mock_data.rs new file mode 100644 index 0000000000..8b90f56953 --- /dev/null +++ b/src/test/mock_data.rs @@ -0,0 +1,280 @@ +//! Data files for the mock distribution server and forward proxy. +//! +//! Both programs write a small `key=value` data file describing how to reach +//! them, so tests can discover the (possibly OS-assigned) port and any other +//! runtime configuration without parsing program output. + +use std::collections::BTreeMap; +use std::env; +use std::ffi::OsStr; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// A data file describing a running mock distribution server or forward +/// proxy. +/// +/// The file contains one `key=value` pair per line: +/// +/// | Key | Description | +/// | ------------ | ------------------------------------------------------- | +/// | `addr` | The address the process is listening on | +/// | `port` | The port the process is listening on | +/// | `pid` | The process id of the process | +/// | `credential` | The basic test credential in use (only if configured) | +/// | `directory` | The directory being served (mock server only) | +/// +/// The file is written after the process has bound its listener and removed +/// when the process exits. +pub struct MockDataFile { + path: PathBuf, +} + +impl MockDataFile { + /// The default data file location for a mock program. + /// + /// On Unix this is `${HOME}/.local/share/.data`, falling back to + /// the system temporary directory when `HOME` is not set. On Windows it + /// is `%LOCALAPPDATA%\.data`, falling back to + /// `C:\Users\%USER%\AppData\Local` and then to the system temporary + /// directory. + pub fn default_path(name: &str) -> PathBuf { + Self::default_dir().join(format!("{name}.data")) + } + + /// The directory data files are created in by default. + fn default_dir() -> PathBuf { + #[cfg(not(windows))] + { + Self::unix_default_dir(env::var_os("HOME").as_deref()) + } + + #[cfg(windows)] + { + Self::windows_default_dir( + env::var_os("LOCALAPPDATA").as_deref(), + env::var_os("USER").as_deref(), + ) + } + } + + /// The Unix default data directory: `${HOME}/.local/share`, or the + /// system temporary directory when `HOME` is not set. + #[cfg_attr(windows, allow(dead_code))] + fn unix_default_dir(home: Option<&OsStr>) -> PathBuf { + match home { + Some(home) => Path::new(home).join(".local").join("share"), + None => env::temp_dir(), + } + } + + /// The Windows default data directory: `LOCALAPPDATA`, falling back to + /// `C:\Users\${USER}\AppData\Local` and then to the system temporary + /// directory. + #[cfg_attr(not(windows), allow(dead_code))] + fn windows_default_dir(localappdata: Option<&OsStr>, user: Option<&OsStr>) -> PathBuf { + if let Some(localappdata) = localappdata { + return PathBuf::from(localappdata); + } + if let Some(user) = user { + return Path::new(r"C:\Users") + .join(user) + .join("AppData") + .join("Local"); + } + env::temp_dir() + } + + /// Atomically writes the data file for a listening process, replacing any + /// existing file at `path`. + /// + /// `credential` is recorded only when the process uses basic test + /// credentials, and `directory` (mock server only) records the served + /// directory. + pub fn write( + path: &Path, + addr: &str, + port: u16, + pid: u32, + credential: Option<&str>, + directory: Option<&Path>, + ) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut content = String::new(); + content.push_str(&format!("addr={addr}\nport={port}\npid={pid}\n")); + if let Some(credential) = credential { + content.push_str(&format!("credential={credential}\n")); + } + if let Some(directory) = directory { + content.push_str(&format!("directory={}\n", directory.display())); + } + + // Write to a sibling temporary file and rename, so readers never + // observe a partially written file. + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("data"); + let tmp = path.with_file_name(format!(".{file_name}.tmp")); + fs::write(&tmp, content)?; + fs::rename(&tmp, path) + } + + /// Wraps a data file at `path`; the file is removed when the + /// `MockDataFile` is dropped. + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + /// The location of the data file. + pub fn path(&self) -> &Path { + &self.path + } + + /// Returns the value stored under `key`, if the file can be read and the + /// key is present. + pub fn get(&self, key: &str) -> Option { + Self::parse(&fs::read_to_string(&self.path).ok()?) + .into_iter() + .find_map(|(name, value)| (name == key).then_some(value)) + } + + /// Parses data file content into key/value pairs. + pub fn parse(content: &str) -> BTreeMap { + content + .lines() + .filter_map(|line| line.split_once('=')) + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect() + } + + /// Removes the data file, ignoring errors (e.g. it was already removed). + pub fn remove(&self) { + let _ = fs::remove_file(&self.path); + } +} + +impl Drop for MockDataFile { + fn drop(&mut self) { + self.remove(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn data_file_round_trip() { + let tmp = tempfile::Builder::new() + .prefix("mock-data-") + .tempdir() + .unwrap(); + let path = tmp.path().join("mock-server.data"); + let data = MockDataFile::new(path.clone()); + + MockDataFile::write( + &path, + "127.0.0.1", + 43211, + 4242, + Some("testuser:testpass"), + Some(tmp.path()), + ) + .unwrap(); + + assert_eq!(data.get("addr").as_deref(), Some("127.0.0.1")); + assert_eq!(data.get("port").as_deref(), Some("43211")); + assert_eq!(data.get("pid").as_deref(), Some("4242")); + assert_eq!(data.get("credential").as_deref(), Some("testuser:testpass")); + assert_eq!( + data.get("directory").as_deref(), + Some(tmp.path().display().to_string().as_str()) + ); + assert_eq!(data.get("missing"), None); + + data.remove(); + assert!(!path.exists()); + } + + #[test] + fn data_file_omits_unset_optionals() { + let tmp = tempfile::Builder::new() + .prefix("mock-data-") + .tempdir() + .unwrap(); + let path = tmp.path().join("mock-proxy.data"); + let data = MockDataFile::new(path.clone()); + + MockDataFile::write(&path, "127.0.0.1", 43211, 4242, None, None).unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + assert!(!content.contains("credential=")); + assert!(!content.contains("directory=")); + assert_eq!(data.get("port").as_deref(), Some("43211")); + + data.remove(); + assert!(!path.exists()); + } + + #[test] + fn default_path_uses_platform_default_dir() { + let dir = MockDataFile::default_dir(); + let path = MockDataFile::default_path("rustup-mock-server"); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("rustup-mock-server.data") + ); + assert_eq!(path.parent(), Some(dir.as_path())); + } + + #[test] + fn unix_default_dir_lives_under_local_share() { + let dir = MockDataFile::unix_default_dir(Some(OsStr::new("/home/tester"))); + + // Compare the final components rather than a rendered string, so the + // assertion holds on every platform (path separators differ). + let mut components = dir.components().rev().take(2).collect::>(); + components.reverse(); + let names = components + .iter() + .map(|component| component.as_os_str().to_str().unwrap()) + .collect::>(); + assert_eq!(names, [".local", "share"]); + + // Without `HOME` the directory is the system temporary directory. + assert_eq!(MockDataFile::unix_default_dir(None), env::temp_dir()); + } + + #[test] + fn windows_default_dir_uses_localappdata() { + // `LOCALAPPDATA` is used as-is when set. + assert_eq!( + MockDataFile::windows_default_dir( + Some(OsStr::new(r"C:\Users\tester\AppData\Local")), + None + ), + PathBuf::from(r"C:\Users\tester\AppData\Local") + ); + + // Without `LOCALAPPDATA` the directory is built from `USER`. + let dir = MockDataFile::windows_default_dir(None, Some(OsStr::new("tester"))); + assert!(dir.to_string_lossy().contains(r"C:\Users")); + let mut components = dir.components().rev().take(3).collect::>(); + components.reverse(); + let names = components + .iter() + .map(|component| component.as_os_str().to_str().unwrap()) + .collect::>(); + assert_eq!(names, ["tester", "AppData", "Local"]); + + // Without either variable the system temporary directory is used. + assert_eq!( + MockDataFile::windows_default_dir(None, None), + env::temp_dir() + ); + } +} diff --git a/tests/suite/init_sh.rs b/tests/suite/init_sh.rs new file mode 100644 index 0000000000..7119e3718c --- /dev/null +++ b/tests/suite/init_sh.rs @@ -0,0 +1,272 @@ +//! Integration tests for the `rustup-init.sh` installer script. +//! +//! `rustup-init.sh` downloads the `rustup-init` binary from a distribution +//! root and runs it, so the mock dist tree is seeded with a copy of the +//! binary built from this repo at `dist//rustup-init`, where `` +//! is the triple the script itself detects for the host. +//! +//! Each test boots a fresh mock dist server (and proxy where relevant) on an +//! OS-assigned port, so the tests can run in parallel. The script is forced +//! to use a specific downloader (`curl` or `wget`) by running it with a +//! `PATH` that contains only the commands it needs. +//! +//! These tests only run on Unix: on Windows, `rustup-init.exe` is +//! downloaded and run directly, and the script is not used (see +//! ). + +#![cfg(all(feature = "test", not(windows)))] + +use std::env; +use std::fs; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use crate::suite::proxy::{ + MockProxy, MockServer, PROXY_PASSWORD, PROXY_USER, SERVER_PASSWORD, SERVER_USER, basic_auth, +}; + +const RUSTUP_INIT_SH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/rustup-init.sh"); + +/// The commands `rustup-init.sh` needs to run: the `need_cmd()` checks in +/// `main()` (`uname`, `mktemp`, `chmod`, `mkdir`, `rm`, `rmdir`), the +/// architecture detection helpers (`head`, `tail`, `cut`, `base64`), `grep` +/// (downloader detection and error handling), and `cat` (`--help`). +const INIT_SH_COMMANDS: &[&str] = &[ + "uname", "mktemp", "chmod", "mkdir", "rm", "rmdir", "head", "tail", "cut", "base64", "grep", + "cat", +]; + +/// Finds `name` in the current `PATH` (an existing, executable file). +fn find_in_path(name: &str) -> Option { + let path = env::var_os("PATH")?; + for entry in env::split_paths(&path) { + let candidate = entry.join(name); + let Ok(metadata) = fs::metadata(&candidate) else { + continue; + }; + if metadata.is_file() && (metadata.permissions().mode() & 0o111) != 0 { + return Some(candidate); + } + } + None +} + +/// Creates a directory of symlinks holding only the commands +/// `rustup-init.sh` needs, with `downloader` as the sole downloader, so the +/// script is forced to use it. +fn make_restricted_path(downloader: &str) -> tempfile::TempDir { + let dir = tempfile::Builder::new() + .prefix("restricted-bin-") + .tempdir() + .unwrap(); + for name in INIT_SH_COMMANDS.iter().chain(std::iter::once(&downloader)) { + let target = find_in_path(name).unwrap_or_else(|| { + panic!("{name} not found in PATH; it is required by the rustup-init.sh tests") + }); + symlink(&target, dir.path().join(name)).unwrap(); + } + dir +} + +/// The architecture triple `rustup-init.sh` detects for the host, as the +/// script reports it through its `RUSTUP_INIT_SH_PRINT` mode. +fn detect_host_arch() -> String { + let output = Command::new("sh") + .arg(RUSTUP_INIT_SH) + .env("RUSTUP_INIT_SH_PRINT", "arch") + .output() + .expect("failed to run rustup-init.sh in print mode"); + assert!( + output.status.success(), + "rustup-init.sh architecture detection failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("non-UTF-8 architecture from rustup-init.sh") + .trim() + .to_string() +} + +/// Seeds `dist//rustup-init` in the tree served by `server` with the +/// `rustup-init` binary built from this repo, so `rustup-init.sh` can +/// download and run it. +fn seed_rustup_init_bin(server: &MockServer) { + let arch = detect_host_arch(); + let target = server + .directory() + .join("dist") + .join(&arch) + .join("rustup-init"); + fs::create_dir_all(target.parent().expect("dist path has a parent")).unwrap(); + fs::copy(env!("CARGO_BIN_EXE_rustup-init"), &target).unwrap(); +} + +/// Starts a mock server and seeds its dist tree with the `rustup-init` +/// binary. `credentials` is in `user:password` form, or `None` for a server +/// that does not require authentication. +fn start_seeded_server(credentials: Option<&str>) -> MockServer { + let server = MockServer::start(credentials); + seed_rustup_init_bin(&server); + server +} + +/// Runs `rustup-init.sh -y --no-modify-path` against `dist_root` (through +/// `proxy` when given) in a fresh `RUSTUP_HOME`/`CARGO_HOME`, forcing +/// `downloader` as the script's only available downloader. +/// +/// Variables that could leak in from the surrounding environment are removed +/// first, then `extra_env` is applied (the authorization header variables +/// for the authenticated scenarios). +fn run_rustup_init_sh( + downloader: &str, + dist_root: &str, + proxy: Option<&str>, + extra_env: &[(&str, &str)], +) -> (tempfile::TempDir, Output) { + let restricted_bin = make_restricted_path(downloader); + let home = tempfile::Builder::new() + .prefix("rustup-home-") + .tempdir() + .unwrap(); + + // Resolve the shell before the PATH is restricted: the kernel looks up a + // bare command name through the child's PATH. + let sh = find_in_path("sh") + .expect("sh not found in PATH; it is required by the rustup-init.sh tests"); + let mut cmd = Command::new(&sh); + cmd.arg(RUSTUP_INIT_SH); + cmd.arg("-y"); + cmd.arg("--no-modify-path"); + for var in [ + "http_proxy", + "https_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "all_proxy", + "ALL_PROXY", + "RUSTUP_HOME", + "CARGO_HOME", + "RUSTUP_DIST_SERVER", + "RUSTUP_UPDATE_ROOT", + "RUSTUP_AUTHORIZATION_HEADER", + "RUSTUP_PROXY_AUTHORIZATION_HEADER", + ] { + cmd.env_remove(var); + } + cmd.env("PATH", restricted_bin.path()); + // An empty http_proxy disables proxying. + cmd.env("http_proxy", proxy.unwrap_or("")); + cmd.env("https_proxy", proxy.unwrap_or("")); + cmd.env("RUSTUP_HOME", home.path()); + cmd.env("CARGO_HOME", home.path()); + cmd.env("RUSTUP_UPDATE_ROOT", dist_root); + cmd.env("RUSTUP_DIST_SERVER", dist_root); + for (key, value) in extra_env { + cmd.env(key, value); + } + let output = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("failed to run rustup-init.sh"); + (home, output) +} + +/// Fails the test unless `rustup-init.sh` succeeded, installed a `rustup` +/// binary into `home`, and that binary runs. +fn expect_rustup_installed(home: &Path, output: &Output) { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let rustup_bin = home + .join("bin") + .join(format!("rustup{}", env::consts::EXE_SUFFIX)); + + if !output.status.success() { + panic!( + "rustup-init.sh exited with {}\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}", + output.status + ); + } + if !rustup_bin.is_file() { + panic!( + "rustup-init.sh succeeded but {} was not installed\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}", + rustup_bin.display() + ); + } + let version = Command::new(&rustup_bin) + .arg("--version") + .env("RUSTUP_HOME", home) + .env("CARGO_HOME", home) + .output() + .expect("failed to run the installed rustup"); + if !version.status.success() { + panic!( + "installed rustup --version failed with {}\n--- stdout ---\n{}\n--- stderr ---\n{}", + version.status, + String::from_utf8_lossy(&version.stdout), + String::from_utf8_lossy(&version.stderr), + ); + } +} + +/// `rustup-init.sh` installs rustup when only `curl` is available as a +/// downloader. +#[test] +fn rustup_init_sh_installs_with_curl() { + let server = start_seeded_server(None); + let (home, output) = run_rustup_init_sh("curl", &server.root_url(), None, &[]); + expect_rustup_installed(home.path(), &output); +} + +/// `rustup-init.sh` installs rustup when only `wget` is available as a +/// downloader. +#[test] +fn rustup_init_sh_installs_with_wget() { + let server = start_seeded_server(None); + let (home, output) = run_rustup_init_sh("wget", &server.root_url(), None, &[]); + expect_rustup_installed(home.path(), &output); +} + +/// `rustup-init.sh` installs rustup from an authenticated mock server, +/// presenting the server credentials via `RUSTUP_AUTHORIZATION_HEADER`. +#[test] +fn rustup_init_sh_installs_from_authenticated_server() { + let credentials = format!("{SERVER_USER}:{SERVER_PASSWORD}"); + let server = start_seeded_server(Some(&credentials)); + let authorization = basic_auth(SERVER_USER, SERVER_PASSWORD); + let (home, output) = run_rustup_init_sh( + "curl", + &server.root_url(), + None, + &[("RUSTUP_AUTHORIZATION_HEADER", authorization.as_str())], + ); + expect_rustup_installed(home.path(), &output); +} + +/// `rustup-init.sh` installs rustup through an authenticated proxy, +/// presenting both the server credentials +/// (`RUSTUP_AUTHORIZATION_HEADER`) and the proxy credentials +/// (`RUSTUP_PROXY_AUTHORIZATION_HEADER`). +#[test] +fn rustup_init_sh_installs_through_authenticated_proxy() { + let server_credentials = format!("{SERVER_USER}:{SERVER_PASSWORD}"); + let proxy_credentials = format!("{PROXY_USER}:{PROXY_PASSWORD}"); + let server = start_seeded_server(Some(&server_credentials)); + let proxy = MockProxy::start(Some(&proxy_credentials)); + let authorization = basic_auth(SERVER_USER, SERVER_PASSWORD); + let proxy_authorization = basic_auth(PROXY_USER, PROXY_PASSWORD); + let (home, output) = run_rustup_init_sh( + "wget", + &server.root_url(), + Some(&proxy.root_url()), + &[ + ("RUSTUP_AUTHORIZATION_HEADER", authorization.as_str()), + ( + "RUSTUP_PROXY_AUTHORIZATION_HEADER", + proxy_authorization.as_str(), + ), + ], + ); + expect_rustup_installed(home.path(), &output); +} diff --git a/tests/suite/mod.rs b/tests/suite/mod.rs index 0fab5f63df..7ebef65ad0 100644 --- a/tests/suite/mod.rs +++ b/tests/suite/mod.rs @@ -10,5 +10,7 @@ mod cli_self_upd; mod cli_v1; mod cli_v2; mod dist_install; +mod init_sh; mod known_target_tuples; +mod proxy; mod static_roots; diff --git a/tests/suite/proxy.rs b/tests/suite/proxy.rs new file mode 100644 index 0000000000..5aa6ab2076 --- /dev/null +++ b/tests/suite/proxy.rs @@ -0,0 +1,600 @@ +//! Integration tests for `rustup-mock-server` and `rustup-mock-proxy`. +//! +//! Each test boots a fresh mock dist server (and proxy where relevant) on an +//! OS-assigned port, so the tests can run in parallel. The listening address +//! of each program is read from its data file (see `rustup::test::MockDataFile`). + +#![cfg(feature = "test")] + +use std::collections::BTreeMap; +use std::env::consts::EXE_SUFFIX; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +use base64::Engine; + +use rustup::test::MockDataFile; + +const MOCK_SERVER: &str = env!("CARGO_BIN_EXE_rustup-mock-server"); +const MOCK_PROXY: &str = env!("CARGO_BIN_EXE_rustup-mock-proxy"); +const RUSTUP_INIT: &str = env!("CARGO_BIN_EXE_rustup-init"); + +pub(crate) const SERVER_USER: &str = "testuser"; +pub(crate) const SERVER_PASSWORD: &str = "testpass"; +pub(crate) const PROXY_USER: &str = "proxyuser"; +pub(crate) const PROXY_PASSWORD: &str = "proxypass"; +const CHANNEL_MANIFEST: &str = "dist/channel-rust-stable.toml"; + +/// A running `rustup-mock-server` serving a fresh mock dist tree. +/// +/// The server populates its own temporary directory with the mock dist tree; +/// the test's temp directory holds the server's log and data file. The server +/// process is killed and its temp directory removed on drop. +pub(crate) struct MockServer { + /// Keeps the temp dir (and the log and data file it holds) alive. + #[allow(dead_code)] + tmp: tempfile::TempDir, + log: PathBuf, + child: Child, + addr: SocketAddr, + // Only used from the `init_sh` tests, which do not run on Windows. + #[cfg_attr(windows, allow(dead_code))] + directory: PathBuf, +} + +impl MockServer { + /// Starts a server bound to an OS-assigned `127.0.0.1` port. + /// + /// The server populates its own temporary directory with the mock dist + /// tree. The listening address is read from the server's data file. + /// `credentials` is in `user:password` form, or `None` for a server that + /// does not require authentication. + pub(crate) fn start(credentials: Option<&str>) -> Self { + let tmp = tempfile::Builder::new() + .prefix("mock-server-") + .tempdir() + .unwrap(); + let data_file = tmp.path().join("mock-server.data"); + let log = tmp.path().join("mock-server.log"); + let data_file_arg = data_file.to_str().unwrap().to_string(); + let mut child = spawn( + MOCK_SERVER, + &["--data-file", data_file_arg.as_str()], + credentials, + &log, + ); + let data = wait_for_data_file(&data_file, Duration::from_secs(30)).unwrap_or_else(|e| { + kill(&mut child); + dump_logs(&[&log]); + panic!("mock server did not become ready: {e}"); + }); + let addr = data_addr(&data); + let directory = data + .get("directory") + .map(PathBuf::from) + .unwrap_or_else(|| panic!("mock server data file has no directory: {data:?}")); + + Self { + tmp, + log, + child, + addr, + directory, + } + } + + /// The root URL of the server, for `RUSTUP_DIST_SERVER`. + pub(crate) fn root_url(&self) -> String { + format!("http://{}", self.addr) + } + + /// The directory the server is serving, from its data file. + #[cfg_attr(windows, allow(dead_code))] + pub(crate) fn directory(&self) -> &Path { + &self.directory + } + + /// The URL of `path` on the server. + fn url(&self, path: &str) -> String { + format!("{}/{}", self.root_url(), path.trim_start_matches('/')) + } +} + +impl Drop for MockServer { + fn drop(&mut self) { + kill(&mut self.child); + } +} + +/// A running `rustup-mock-proxy` bound to an OS-assigned `127.0.0.1` port. +/// +/// The proxy process is killed and its temp directory removed on drop. +pub(crate) struct MockProxy { + /// Keeps the temp dir (and the log and data file it holds) alive. + #[allow(dead_code)] + tmp: tempfile::TempDir, + log: PathBuf, + child: Child, + addr: SocketAddr, +} + +impl MockProxy { + /// Starts a proxy bound to an OS-assigned `127.0.0.1` port. + /// + /// The listening address is read from the proxy's data file. + /// `credentials` is in `user:password` form, or `None` to allow + /// unauthenticated requests. + pub(crate) fn start(credentials: Option<&str>) -> Self { + let tmp = tempfile::Builder::new() + .prefix("mock-proxy-") + .tempdir() + .unwrap(); + let data_file = tmp.path().join("mock-proxy.data"); + let log = tmp.path().join("mock-proxy.log"); + let data_file_arg = data_file.to_str().unwrap().to_string(); + let mut child = spawn( + MOCK_PROXY, + &["--data-file", data_file_arg.as_str()], + credentials, + &log, + ); + let data = wait_for_data_file(&data_file, Duration::from_secs(30)).unwrap_or_else(|e| { + kill(&mut child); + dump_logs(&[&log]); + panic!("mock proxy did not become ready: {e}"); + }); + let addr = data_addr(&data); + + Self { + tmp, + log, + child, + addr, + } + } + + /// The proxy URL, for `http_proxy`/`https_proxy`. + pub(crate) fn root_url(&self) -> String { + format!("http://{}", self.addr) + } +} + +impl Drop for MockProxy { + fn drop(&mut self) { + kill(&mut self.child); + } +} + +/// Spawns one of the mock binaries, appending its output to `log`. +fn spawn(bin: &str, args: &[&str], credentials: Option<&str>, log: &Path) -> Child { + let stdout = File::create(log).unwrap(); + let stderr = OpenOptions::new().append(true).open(log).unwrap(); + + let mut cmd = Command::new(bin); + cmd.args(args); + if let Some(credentials) = credentials { + cmd.args(["--basic-test-credential", credentials]); + } + cmd.stdout(stdout).stderr(stderr); + cmd.spawn().expect("failed to spawn mock binary") +} + +/// Kills `child`, ignoring errors if it already exited. +fn kill(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +/// Blocks until `path` holds a complete mock data file, returning the +/// key/value pairs it records, or times out. +/// +/// Both mock programs write the data file after binding their listener, so a +/// complete file means the service is ready. +fn wait_for_data_file(path: &Path, timeout: Duration) -> anyhow::Result> { + let deadline = Instant::now() + timeout; + loop { + if let Ok(content) = fs::read_to_string(path) { + let data = MockDataFile::parse(&content); + if data.contains_key("addr") && data.contains_key("port") { + return Ok(data); + } + } + anyhow::ensure!( + Instant::now() < deadline, + "data file {path:?} not ready after {timeout:?}" + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// The listening address recorded in a complete mock data file. +fn data_addr(data: &BTreeMap) -> SocketAddr { + format!( + "{}:{}", + data.get("addr").expect("data file has an addr"), + data.get("port").expect("data file has a port") + ) + .parse() + .expect("data file holds a valid address") +} + +/// The result of a raw HTTP GET. +struct HttpResponse { + status: u16, + body: Vec, +} + +/// Performs a minimal HTTP/1.1 GET against `url`. +/// +/// If `proxy` is `Some`, the request is sent to that forward proxy in +/// absolute-URI form, like a proxying HTTP client would do. The request +/// always carries `Connection: close` and the response is read until EOF, so +/// there is no keep-alive state to manage. +fn http_get( + url: &str, + proxy: Option, + headers: &[(&str, &str)], +) -> anyhow::Result { + let (host, port, path) = parse_url(url)?; + let (connect_addr, target) = if let Some(proxy) = proxy { + (proxy, url.to_string()) + } else { + let host_addr = format!("{host}:{port}").parse::()?; + (host_addr, path) + }; + + let mut stream = TcpStream::connect(connect_addr)?; + stream.set_read_timeout(Some(Duration::from_secs(30)))?; + stream.set_write_timeout(Some(Duration::from_secs(30)))?; + + let mut request = + format!("GET {target} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n"); + for (name, value) in headers { + request.push_str(name); + request.push_str(": "); + request.push_str(value); + request.push_str("\r\n"); + } + request.push_str("\r\n"); + stream.write_all(request.as_bytes())?; + + let mut response = Vec::new(); + stream.read_to_end(&mut response)?; + + let head_end = response + .windows(4) + .position(|w| w == b"\r\n\r\n") + .ok_or_else(|| anyhow::anyhow!("no end of headers in response from {connect_addr}"))?; + let head = std::str::from_utf8(&response[..head_end])?; + let body = response[head_end + 4..].to_vec(); + + let status = head + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|code| code.parse::().ok()) + .ok_or_else(|| anyhow::anyhow!("unparseable status line in {head:?}"))?; + + Ok(HttpResponse { status, body }) +} + +/// Parses `http://host:port/path` into its components. +fn parse_url(url: &str) -> anyhow::Result<(String, u16, String)> { + let rest = url + .strip_prefix("http://") + .ok_or_else(|| anyhow::anyhow!("expected an http:// URL, got {url:?}"))?; + let (host_port, path) = rest.split_once('/').unwrap_or((rest, "")); + let (host, port) = host_port + .rsplit_once(':') + .ok_or_else(|| anyhow::anyhow!("missing port in {url:?}"))?; + let port = port.parse()?; + Ok((host.to_string(), port, format!("/{path}"))) +} + +/// Builds a `Basic` authorization header value for `user:password`. +pub(crate) fn basic_auth(user: &str, password: &str) -> String { + let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}")); + format!("Basic {encoded}") +} + +/// Runs `rustup-init --no-modify-path` in a fresh `RUSTUP_HOME`/`CARGO_HOME`, +/// feeding it `1` (proceed with the standard installation) on stdin. +/// +/// Variables that could leak in from the surrounding environment are removed +/// first, then `extra_env` is applied. +fn run_rustup_init(extra_env: &[(&str, &str)]) -> anyhow::Result<(tempfile::TempDir, Output)> { + let home = tempfile::Builder::new().prefix("rustup-home-").tempdir()?; + + let mut cmd = Command::new(RUSTUP_INIT); + cmd.arg("-y"); + cmd.arg("--no-modify-path"); + for var in [ + "http_proxy", + "https_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "all_proxy", + "ALL_PROXY", + "RUSTUP_HOME", + "CARGO_HOME", + "RUSTUP_DIST_SERVER", + "RUSTUP_UPDATE_ROOT", + "RUSTUP_AUTHORIZATION_HEADER", + "RUSTUP_PROXY_AUTHORIZATION_HEADER", + ] { + cmd.env_remove(var); + } + cmd.env("RUSTUP_HOME", home.path()); + cmd.env("CARGO_HOME", home.path()); + // The test environment may have a real (non-rustup) Rust on PATH (the CI + // build images ship one at /rustc-sysroot/bin), which would make + // rustup-init prompt and abort. Skip the check, as the clitools test + // infrastructure does (src/test/clitools.rs). + cmd.env("RUSTUP_INIT_SKIP_PATH_CHECK", "yes"); + // Likewise skip the MSVC check on Windows: it would prompt for a Visual + // C++ installation on machines without the MSVC build tools (see + // src/cli/self_update/windows.rs). + cmd.env("RUSTUP_INIT_SKIP_MSVC_CHECK", "yes"); + for (key, value) in extra_env { + cmd.env(key, value); + } + + // `-y` answers every prompt, including the one Windows shows after a + // successful install ("Press the Enter key to continue"). It is the + // same unattended invocation the other rustup-init tests use (e.g. + // tests/suite/cli_exact.rs). + let output = cmd + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output()?; + Ok((home, output)) +} + +/// Prints the contents of `logs` to stderr, to help diagnose failures. +fn dump_logs

(logs: &[P]) +where + P: AsRef, +{ + for log in logs { + let log = log.as_ref(); + if let Ok(contents) = fs::read_to_string(log) { + eprintln!("--- {log:?} ---\n{contents}"); + } + } +} + +/// Runs `http_get`, dumping `logs` and panicking if the request itself fails. +fn get_or_panic( + url: &str, + proxy: Option, + headers: &[(&str, &str)], + logs: &[&PathBuf], +) -> HttpResponse { + match http_get(url, proxy, headers) { + Ok(response) => response, + Err(error) => { + dump_logs(logs); + panic!("request to {url} failed: {error}"); + } + } +} + +/// Fails the test unless the response has `status` and, when `needle` is +/// `Some`, a body containing it. Dumps `logs` on failure. +fn expect_response(response: &HttpResponse, status: u16, needle: Option<&str>, logs: &[&PathBuf]) { + let body = String::from_utf8_lossy(&response.body); + let matched = response.status == status && needle.is_none_or(|needle| body.contains(needle)); + if !matched { + dump_logs(logs); + let expected = match needle { + Some(needle) => format!("status {status} with body containing {needle:?}"), + None => format!("status {status}"), + }; + panic!( + "expected {expected}, got status {} with body: {body}", + response.status + ); + } +} + +/// Fails the test unless `rustup-init` succeeded and installed a `rustup` +/// binary into `home`. Dumps `logs` and the captured output on failure. +fn expect_rustup_installed(home: &Path, output: &Output, logs: &[&PathBuf]) { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let rustup_bin = home.join("bin").join(format!("rustup{EXE_SUFFIX}")); + + let failure = if !output.status.success() { + Some(format!("rustup-init exited with {}", output.status)) + } else if !rustup_bin.exists() { + Some(format!( + "rustup-init succeeded but {} was not installed", + rustup_bin.display() + )) + } else { + None + }; + + if let Some(failure) = failure { + dump_logs(logs); + panic!( + "{failure}\n--- rustup-init stdout ---\n{stdout}\n--- rustup-init stderr ---\n{stderr}" + ); + } +} + +// === No authentication === + +/// The mock server serves dist files directly. +#[test] +fn mock_server_serves_dist_directly() { + let server = MockServer::start(None); + let response = get_or_panic(&server.url(CHANNEL_MANIFEST), None, &[], &[&server.log]); + expect_response(&response, 200, Some("manifest-version"), &[&server.log]); +} + +/// The proxy forwards requests to the mock server. +#[test] +fn proxy_forwards_to_mock_server() { + let server = MockServer::start(None); + let proxy = MockProxy::start(None); + let response = get_or_panic( + &server.url(CHANNEL_MANIFEST), + Some(proxy.addr), + &[], + &[&proxy.log, &server.log], + ); + expect_response( + &response, + 200, + Some("manifest-version"), + &[&proxy.log, &server.log], + ); +} + +/// `rustup-init` installs a toolchain straight from the mock server. +#[test] +fn rustup_init_installs_from_mock_server() { + let server = MockServer::start(None); + let dist_server = server.root_url(); + let update_root = format!("{dist_server}/rustup"); + let (home, output) = run_rustup_init(&[ + ("RUSTUP_DIST_SERVER", dist_server.as_str()), + ("RUSTUP_UPDATE_ROOT", update_root.as_str()), + ]) + .expect("failed to spawn rustup-init"); + expect_rustup_installed(home.path(), &output, &[&server.log]); +} + +/// `rustup-init` installs a toolchain through the proxy. +#[test] +fn rustup_init_installs_through_proxy() { + let server = MockServer::start(None); + let proxy = MockProxy::start(None); + let dist_server = server.root_url(); + let update_root = format!("{dist_server}/rustup"); + let proxy_url = proxy.root_url(); + let (home, output) = run_rustup_init(&[ + ("RUSTUP_DIST_SERVER", dist_server.as_str()), + ("RUSTUP_UPDATE_ROOT", update_root.as_str()), + ("http_proxy", proxy_url.as_str()), + ("https_proxy", proxy_url.as_str()), + ]) + .expect("failed to spawn rustup-init"); + expect_rustup_installed(home.path(), &output, &[&proxy.log, &server.log]); +} + +// === Basic authentication === + +/// Unauthenticated requests to the mock server are rejected with 401. +#[test] +fn mock_server_rejects_unauthenticated_requests() { + let server = MockServer::start(Some(&format!("{SERVER_USER}:{SERVER_PASSWORD}"))); + let response = get_or_panic(&server.url(CHANNEL_MANIFEST), None, &[], &[&server.log]); + expect_response(&response, 401, None, &[&server.log]); +} + +/// The mock server serves dist files to clients presenting the right +/// credentials. +#[test] +fn mock_server_serves_authenticated_requests() { + let server = MockServer::start(Some(&format!("{SERVER_USER}:{SERVER_PASSWORD}"))); + let authorization = basic_auth(SERVER_USER, SERVER_PASSWORD); + let response = get_or_panic( + &server.url(CHANNEL_MANIFEST), + None, + &[("Authorization", &authorization)], + &[&server.log], + ); + expect_response(&response, 200, Some("manifest-version"), &[&server.log]); +} + +/// The proxy demands its own credentials even when the target's credentials +/// are presented. +#[test] +fn proxy_rejects_requests_without_proxy_auth() { + let server = MockServer::start(Some(&format!("{SERVER_USER}:{SERVER_PASSWORD}"))); + let proxy = MockProxy::start(Some(&format!("{PROXY_USER}:{PROXY_PASSWORD}"))); + let authorization = basic_auth(SERVER_USER, SERVER_PASSWORD); + let response = get_or_panic( + &server.url(CHANNEL_MANIFEST), + Some(proxy.addr), + &[("Authorization", &authorization)], + &[&proxy.log, &server.log], + ); + expect_response(&response, 407, None, &[&proxy.log, &server.log]); +} + +/// With both the proxy and the target authenticated, the proxy forwards the +/// request. +#[test] +fn proxy_forwards_fully_authenticated_requests() { + let server = MockServer::start(Some(&format!("{SERVER_USER}:{SERVER_PASSWORD}"))); + let proxy = MockProxy::start(Some(&format!("{PROXY_USER}:{PROXY_PASSWORD}"))); + let authorization = basic_auth(SERVER_USER, SERVER_PASSWORD); + let proxy_authorization = basic_auth(PROXY_USER, PROXY_PASSWORD); + let response = get_or_panic( + &server.url(CHANNEL_MANIFEST), + Some(proxy.addr), + &[ + ("Authorization", &authorization), + ("Proxy-Authorization", &proxy_authorization), + ], + &[&proxy.log, &server.log], + ); + expect_response( + &response, + 200, + Some("manifest-version"), + &[&proxy.log, &server.log], + ); +} + +/// `rustup-init` installs a toolchain from an authenticated mock server, +/// presenting its credentials via `RUSTUP_AUTHORIZATION_HEADER`. +#[test] +fn rustup_init_installs_from_authenticated_mock_server() { + let server = MockServer::start(Some(&format!("{SERVER_USER}:{SERVER_PASSWORD}"))); + let dist_server = server.root_url(); + let update_root = format!("{dist_server}/rustup"); + let authorization = basic_auth(SERVER_USER, SERVER_PASSWORD); + let (home, output) = run_rustup_init(&[ + ("RUSTUP_DIST_SERVER", dist_server.as_str()), + ("RUSTUP_UPDATE_ROOT", update_root.as_str()), + ("RUSTUP_AUTHORIZATION_HEADER", authorization.as_str()), + ]) + .expect("failed to spawn rustup-init"); + expect_rustup_installed(home.path(), &output, &[&server.log]); +} + +/// `rustup-init` installs a toolchain through an authenticated proxy, +/// presenting both the target credentials (`RUSTUP_AUTHORIZATION_HEADER`) +/// and the proxy credentials (`RUSTUP_PROXY_AUTHORIZATION_HEADER`). +#[test] +fn rustup_init_installs_through_authenticated_proxy() { + let server = MockServer::start(Some(&format!("{SERVER_USER}:{SERVER_PASSWORD}"))); + let proxy = MockProxy::start(Some(&format!("{PROXY_USER}:{PROXY_PASSWORD}"))); + let dist_server = server.root_url(); + let update_root = format!("{dist_server}/rustup"); + let proxy_url = proxy.root_url(); + let authorization = basic_auth(SERVER_USER, SERVER_PASSWORD); + let proxy_authorization = basic_auth(PROXY_USER, PROXY_PASSWORD); + let (home, output) = run_rustup_init(&[ + ("RUSTUP_DIST_SERVER", dist_server.as_str()), + ("RUSTUP_UPDATE_ROOT", update_root.as_str()), + ("http_proxy", proxy_url.as_str()), + ("https_proxy", proxy_url.as_str()), + ("RUSTUP_AUTHORIZATION_HEADER", authorization.as_str()), + ( + "RUSTUP_PROXY_AUTHORIZATION_HEADER", + proxy_authorization.as_str(), + ), + ]) + .expect("failed to spawn rustup-init"); + expect_rustup_installed(home.path(), &output, &[&proxy.log, &server.log]); +}