diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64ab076e1f..f1163eec80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -883,6 +883,100 @@ jobs: echo "" done + install-e2e-test-sfw: + name: Local CLI `vp install` E2E test (Socket Firewall Free) + needs: + - download-previous-rolldown-binaries + # Run if: not a PR (push-to-main / workflow_dispatch), OR PR has 'test: sfw' label. + # Heavy job (3 OSes × real registry traffic) — gated to avoid running on every PR. + if: >- + github.event_name != 'pull_request' || + contains(github.event.pull_request.labels.*.name, 'test: sfw') + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + sfw_asset: sfw-free-linux-x86_64 + - os: macos-latest + target: aarch64-apple-darwin + sfw_asset: sfw-free-macos-arm64 + - os: windows-latest + target: x86_64-pc-windows-msvc + sfw_asset: sfw-free-windows-x86_64.exe + runs-on: ${{ matrix.os }} + # TODO(SocketDev/sfw-free#30, SocketDev/sfw-free#43): drop `VP_INSECURE_TLS` + # once sfw ships the EKU fix. Until then the cert sfw issues is rejected by + # rustls/native-tls; the proxy + CA-injection plumbing is still exercised + # via HTTPS_PROXY + SSL_CERT_FILE, only certificate *validity* is skipped. + env: + VP_INSECURE_TLS: '1' + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - uses: ./.github/actions/clone + + - name: Setup Dev Drive + if: runner.os == 'Windows' + uses: samypr100/setup-dev-drive@30f0f98ae5636b2b6501e181dfb3631b9974818d # v4.0.0 + with: + drive-size: 12GB + drive-format: ReFS + env-mapping: | + CARGO_HOME,{{ DEV_DRIVE }}/.cargo + RUSTUP_HOME,{{ DEV_DRIVE }}/.rustup + + - uses: oxc-project/setup-rust@68c3199c5339f965e6e163924c3c450773eba42b # main (pending v1.0.17 — Swatinem/rust-cache v2.9.1 for node24) + with: + save-cache: ${{ github.ref_name == 'main' }} + cache-key: install-e2e-test-sfw-${{ matrix.os }} + target-dir: ${{ runner.os == 'Windows' && format('{0}/target', env.DEV_DRIVE) || '' }} + + - uses: oxc-project/setup-node@ab97f03642370d79a7e96dd286bd02a1be40e0ba # v1.3.0 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: rolldown-binaries + path: ./rolldown/packages/rolldown/src + merge-multiple: true + + - name: Build with upstream + uses: ./.github/actions/build-upstream + with: + target: ${{ matrix.target }} + + - name: Build CLI + run: | + pnpm bootstrap-cli:ci + echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH + + - name: Download sfw + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/sfw-bin" + curl --fail --location --retry 3 --retry-delay 2 \ + --output "$RUNNER_TEMP/sfw-bin/sfw${{ runner.os == 'Windows' && '.exe' || '' }}" \ + "https://github.com/SocketDev/sfw-free/releases/latest/download/${{ matrix.sfw_asset }}" + if [[ "${{ runner.os }}" != "Windows" ]]; then + chmod +x "$RUNNER_TEMP/sfw-bin/sfw" + fi + echo "$RUNNER_TEMP/sfw-bin" >> "$GITHUB_PATH" + + - name: Verify sfw on PATH + run: sfw --version + + - name: Run `sfw vp install` against a real repo + run: | + set -euo pipefail + # Force the registry-fetch path: install a pinned pnpm globally so + # vp downloads it (and therefore traverses sfw) rather than reusing + # whatever's preinstalled on the runner. + sfw vp i -g pnpm@9.15.0 + # Then exercise `vp install` inside a real repo, also through sfw. + git clone --depth 1 https://github.com/vitejs/vite.git "$RUNNER_TEMP/vite" + cd "$RUNNER_TEMP/vite" + sfw vp install --no-frozen-lockfile + done: runs-on: ubuntu-latest if: always() @@ -893,6 +987,9 @@ jobs: - cli-e2e-test - cli-e2e-test-musl - cli-snap-test + # Skipped on unlabeled PRs; counted on push-to-main and labeled PRs. + # `contains(needs.*.result, 'failure')` ignores "skipped" results. + - install-e2e-test-sfw steps: - run: exit 1 # Thank you, next https://github.com/vercel/next.js/blob/canary/.github/workflows/build_and_test.yml#L379 diff --git a/Cargo.lock b/Cargo.lock index 41e9e34fea..b2fe920404 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7730,11 +7730,13 @@ dependencies = [ "directories", "nix 0.30.1", "owo-colors", + "reqwest", "rustls", "serde", "serde_json", "serial_test", "supports-color 3.0.2", + "tracing", "tracing-subscriber", "vite_path", "vite_str", diff --git a/crates/vite_install/src/request.rs b/crates/vite_install/src/request.rs index c4b2e706af..43549fee52 100644 --- a/crates/vite_install/src/request.rs +++ b/crates/vite_install/src/request.rs @@ -59,9 +59,9 @@ impl HttpClient { } async fn get(&self, url: &str) -> Result { - vite_shared::ensure_tls_provider(); + let client = vite_shared::shared_http_client(); - let response = (|| async { reqwest::get(url).await?.error_for_status() }) + let response = (|| async { client.get(url).send().await?.error_for_status() }) .retry( ExponentialBuilder::default() .with_jitter() diff --git a/crates/vite_js_runtime/src/download.rs b/crates/vite_js_runtime/src/download.rs index 2a7216d67e..7d7c6b8029 100644 --- a/crates/vite_js_runtime/src/download.rs +++ b/crates/vite_js_runtime/src/download.rs @@ -37,11 +37,11 @@ pub async fn download_file( target_path: &AbsolutePath, message: &str, ) -> Result<(), Error> { - vite_shared::ensure_tls_provider(); + let client = vite_shared::shared_http_client(); tracing::debug!("Downloading {url} to {target_path:?}"); - let response = (|| async { reqwest::get(url).await?.error_for_status() }) + let response = (|| async { client.get(url).send().await?.error_for_status() }) .retry( ExponentialBuilder::default() .with_jitter() @@ -113,11 +113,11 @@ pub async fn download_file( /// Download text content from a URL with retry logic #[expect(clippy::disallowed_types, reason = "HTTP response body is a String")] pub async fn download_text(url: &str) -> Result { - vite_shared::ensure_tls_provider(); + let client = vite_shared::shared_http_client(); tracing::debug!("Downloading text from {url}"); - let content = (|| async { reqwest::get(url).await?.text().await }) + let content = (|| async { client.get(url).send().await?.text().await }) .retry( ExponentialBuilder::default() .with_jitter() @@ -138,12 +138,11 @@ pub async fn fetch_with_cache_headers( url: &str, if_none_match: Option<&str>, ) -> Result { - vite_shared::ensure_tls_provider(); + let client = vite_shared::shared_http_client(); tracing::debug!("Fetching with cache headers from {url}"); let response = (|| async { - let client = reqwest::Client::new(); let mut request = client.get(url); if let Some(etag) = if_none_match { diff --git a/crates/vite_shared/Cargo.toml b/crates/vite_shared/Cargo.toml index ff814d571b..4c583d0654 100644 --- a/crates/vite_shared/Cargo.toml +++ b/crates/vite_shared/Cargo.toml @@ -14,12 +14,17 @@ owo-colors = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } supports-color = "3" +tracing = { workspace = true } tracing-subscriber = { workspace = true } vite_path = { workspace = true } vite_str = { workspace = true } which = { workspace = true } +[target.'cfg(target_os = "windows")'.dependencies] +reqwest = { workspace = true, features = ["native-tls-vendored"] } + [target.'cfg(not(target_os = "windows"))'.dependencies] +reqwest = { workspace = true, features = ["rustls-no-provider"] } rustls = { workspace = true } [dev-dependencies] diff --git a/crates/vite_shared/src/env_vars.rs b/crates/vite_shared/src/env_vars.rs index 40fa82ee7e..82a4d66054 100644 --- a/crates/vite_shared/src/env_vars.rs +++ b/crates/vite_shared/src/env_vars.rs @@ -76,6 +76,25 @@ pub const VP_CLI_BIN: &str = "VP_CLI_BIN"; /// Global CLI version, passed from Rust binary to JS for --version display. pub const VP_GLOBAL_VERSION: &str = "VP_GLOBAL_VERSION"; +// ── HTTP client TLS / CA configuration ────────────────────────────────── + +/// Path to a PEM bundle of extra CA certificates to trust for HTTPS. +/// +/// Industry-standard env var also set by tools like Socket Firewall Free. +pub const SSL_CERT_FILE: &str = "SSL_CERT_FILE"; + +/// Path to a PEM bundle of extra CA certificates to trust for HTTPS. +/// +/// Node.js convention; honored alongside `SSL_CERT_FILE` for setups that only +/// configure the Node-flavored variable. +pub const NODE_EXTRA_CA_CERTS: &str = "NODE_EXTRA_CA_CERTS"; + +/// Disable HTTPS certificate verification in vp's shared HTTP client. +/// +/// Diagnostic escape hatch only. Setting this to any value triggers a loud +/// startup warning. Do not use in production. +pub const VP_INSECURE_TLS: &str = "VP_INSECURE_TLS"; + // ── Testing / Development ─────────────────────────────────────────────── /// Override the trampoline binary path for tests. diff --git a/crates/vite_shared/src/http.rs b/crates/vite_shared/src/http.rs new file mode 100644 index 0000000000..d6e6731163 --- /dev/null +++ b/crates/vite_shared/src/http.rs @@ -0,0 +1,68 @@ +//! Process-wide shared `reqwest::Client`. +//! +//! Built once, lazily, and reused for every HTTP call vp makes. The single +//! instance lets us configure proxy honoring and custom-CA injection in one +//! place so HTTPS-intercepting tools like Socket Firewall Free (sfw) and +//! corporate MITM proxies work without per-call setup. +//! +//! Configuration sources (all read at first call): +//! - `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` — honored automatically by +//! reqwest; no explicit wiring needed. +//! - `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS` — each may point to a PEM bundle +//! (one or more concatenated certs). Every cert is added as a trusted root. +//! A read/parse failure logs a warning and is otherwise ignored so a +//! malformed env var never blocks startup. +//! - `VP_INSECURE_TLS` — when set to any value, disables cert verification +//! entirely. Diagnostic escape hatch only; emits a loud stderr warning. + +use std::sync::OnceLock; + +use crate::{env_vars, output}; + +/// Get the process-wide `reqwest::Client`. +/// +/// The client is built on first call and reused thereafter. See module docs +/// for the env vars it honors. +#[must_use] +pub fn shared_http_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(build_client) +} + +fn build_client() -> reqwest::Client { + crate::ensure_tls_provider(); + + let mut builder = reqwest::Client::builder(); + + for var in [env_vars::SSL_CERT_FILE, env_vars::NODE_EXTRA_CA_CERTS] { + let Ok(path) = std::env::var(var) else { continue }; + if path.is_empty() { + continue; + } + match std::fs::read(&path) { + Ok(bytes) => match reqwest::Certificate::from_pem_bundle(&bytes) { + Ok(certs) => { + for cert in certs { + builder = builder.add_root_certificate(cert); + } + } + Err(err) => { + tracing::warn!("failed to parse extra CA bundle from {var}={path}: {err}"); + } + }, + Err(err) => { + tracing::warn!("failed to read extra CA bundle from {var}={path}: {err}"); + } + } + } + + if std::env::var_os(env_vars::VP_INSECURE_TLS).is_some() { + output::warn( + "VP_INSECURE_TLS is set — TLS certificate verification is disabled. \ + Do not use this in production.", + ); + builder = builder.danger_accept_invalid_certs(true); + } + + builder.build().expect("failed to build shared reqwest client") +} diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index 5e742e4fb7..175e987309 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -11,6 +11,7 @@ mod env_config; pub mod env_vars; pub mod header; mod home; +mod http; pub mod output; mod package_json; mod path_env; @@ -20,6 +21,7 @@ mod tracing; pub use env_config::{EnvConfig, TestEnvGuard}; pub use home::get_vp_home; +pub use http::shared_http_client; pub use package_json::{DevEngines, Engines, PackageJson, RuntimeEngine, RuntimeEngineConfig}; pub use path_env::{ PrependOptions, PrependResult, format_path_prepended, format_path_with_prepend,