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..f6b3d9843e --- /dev/null +++ b/tests/suite/proxy.rs @@ -0,0 +1,597 @@ +//! 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, + 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. + 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]); +}