diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc4893fb6d..8d6738d4ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -889,6 +889,131 @@ 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 + vp_bin: vp + # Only Linux needs the TLS-bypass: the ubuntu-latest runner + # doesn't preinstall Node 22.18 into vp's cache, so vp's HttpClient + # (rustls) fetches `nodejs.org/.../SHASUMS256.txt` through sfw and + # hits the upstream EKU bug. macOS/Windows runners ship Node in + # vp's cache already, so vp never calls its HttpClient through sfw + # in this test — leave the verification on there. + vp_insecure_tls: '1' + - os: namespace-profile-mac-default + target: aarch64-apple-darwin + sfw_asset: sfw-free-macos-arm64 + vp_bin: vp + vp_insecure_tls: '' + - os: windows-latest + target: x86_64-pc-windows-msvc + sfw_asset: sfw-free-windows-x86_64.exe + # On Windows vp ships as `vp.exe`; sfw spawns its child process + # directly without applying PATHEXT, so the bare `vp` lookup fails. + vp_bin: vp.exe + vp_insecure_tls: '' + runs-on: ${{ matrix.os }} + 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 + # Mirror the per-OS path form used by the `test` job (ci.yml ~L266): + # GITHUB_PATH on Windows needs a Windows-style path, otherwise child + # processes spawned by tools like sfw (which use CreateProcess and + # not bash's PATH munging) can't resolve `vp.exe`. + if [[ "$RUNNER_OS" == "Windows" ]]; then + echo "$USERPROFILE\.vite-plus\bin" >> $GITHUB_PATH + else + echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH + fi + + - name: Download sfw + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/sfw-bin" + # `--remove-on-error` (curl 7.83+) prevents leaving a zero-byte file + # on failure so the next step's `sfw --version` fails clearly rather + # than with "exec format error" on an empty binary. + curl --fail --location --remove-on-error --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 + # TODO(SocketDev/sfw-free#30, SocketDev/sfw-free#43): drop `vp_insecure_tls` + # from the Linux matrix entry once sfw ships the EKU fix. Verified + # against sfw v1.11.0 (releases/latest as of 2026-05-28): on Linux, + # vp's HTTPS request to nodejs.org through sfw still fails with + # "invalid peer certificate: UnknownIssuer" because sfw's CA carries a + # present-but-empty Extended Key Usage extension that rustls rejects. + # macOS/Windows runners cache Node 22.18 in vp's directory, so vp + # doesn't call its HttpClient through sfw — leaving TLS verification + # enabled there gives us coverage that the bypass *isn't* used on + # those platforms. + env: + VP_INSECURE_TLS: ${{ matrix.vp_insecure_tls }} + 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 "${{ matrix.vp_bin }}" 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 "${{ matrix.vp_bin }}" install --no-frozen-lockfile + done: runs-on: ubuntu-latest if: always() @@ -899,6 +1024,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 1baef9e905..cd8d2022af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1092,6 +1092,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -2496,9 +2506,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.3", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -6125,7 +6137,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -6248,7 +6260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -6792,6 +6804,27 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81c645a4de0d803ced6ef0388a2646aa1ef8467173b5d59a2c33c88de4ab76e7" +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tar" version = "0.4.45" @@ -7566,6 +7599,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "vite_path", + "vite_shared", "vite_str", "vite_workspace", "wax 0.6.0", @@ -7796,11 +7830,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", @@ -8353,6 +8389,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/crates/vite_error/Cargo.toml b/crates/vite_error/Cargo.toml index f8d1ca6499..0360c25005 100644 --- a/crates/vite_error/Cargo.toml +++ b/crates/vite_error/Cargo.toml @@ -21,6 +21,7 @@ serde_yml = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } vite_path = { workspace = true } +vite_shared = { workspace = true } vite_str = { workspace = true } vite_workspace = { workspace = true } wax = { workspace = true } diff --git a/crates/vite_error/src/lib.rs b/crates/vite_error/src/lib.rs index 240496dc69..b1411abd0a 100644 --- a/crates/vite_error/src/lib.rs +++ b/crates/vite_error/src/lib.rs @@ -106,7 +106,12 @@ pub enum Error { #[error(transparent)] Semver(#[from] semver::Error), - #[error(transparent)] + // `#[error("{}", ...)]` not `transparent`: surface the full `source()` + // chain (TLS handshake → UnknownIssuer, hyper IO errors, etc.) instead of + // just reqwest's top-level "error sending request for url (...)" message. + // Keeps `From` and `source()` semantics intact, so 404 + // detection via `e.status()` at call sites still works. + #[error("{}", vite_shared::format_error_chain(.0))] Reqwest(#[from] reqwest::Error), #[error(transparent)] 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..72c9d9fcdb 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() @@ -49,7 +49,10 @@ pub async fn download_file( .with_max_times(3), ) .await - .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?; + .map_err(|e| Error::DownloadFailed { + url: url.into(), + reason: vite_shared::format_error_chain(&e).into(), + })?; // Get Content-Length for progress bar let total_size = response.content_length(); @@ -113,11 +116,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?.error_for_status()?.text().await }) .retry( ExponentialBuilder::default() .with_jitter() @@ -125,7 +128,10 @@ pub async fn download_text(url: &str) -> Result { .with_max_times(3), ) .await - .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?; + .map_err(|e| Error::DownloadFailed { + url: url.into(), + reason: vite_shared::format_error_chain(&e).into(), + })?; Ok(content) } @@ -138,12 +144,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 { @@ -159,7 +164,10 @@ pub async fn fetch_with_cache_headers( .with_max_times(3), ) .await - .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?; + .map_err(|e| Error::DownloadFailed { + url: url.into(), + reason: vite_shared::format_error_chain(&e).into(), + })?; // Check for 304 Not Modified if response.status() == reqwest::StatusCode::NOT_MODIFIED { @@ -182,10 +190,10 @@ pub async fn fetch_with_cache_headers( .and_then(|v| v.to_str().ok()) .and_then(parse_max_age); - let body = response - .text() - .await - .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?; + let body = response.text().await.map_err(|e| Error::DownloadFailed { + url: url.into(), + reason: vite_shared::format_error_chain(&e).into(), + })?; Ok(CachedFetchResponse { body: Some(body), etag, max_age, not_modified: false }) } diff --git a/crates/vite_js_runtime/src/error.rs b/crates/vite_js_runtime/src/error.rs index 2f79c1f7d0..c1c528a3e7 100644 --- a/crates/vite_js_runtime/src/error.rs +++ b/crates/vite_js_runtime/src/error.rs @@ -58,8 +58,13 @@ pub enum Error { #[error(transparent)] Io(#[from] std::io::Error), - /// HTTP request error - #[error(transparent)] + /// HTTP request error. + /// + /// Surface the full `source()` chain (TLS handshake / connect / hyper + /// IO) rather than reqwest's top-level message only. Body-streaming + /// failures inside `download_file` propagate via `?` into this variant, + /// so the chain has to be exposed here — not at the call site. + #[error("{}", vite_shared::format_error_chain(.0))] Reqwest(#[from] reqwest::Error), /// Join error from tokio diff --git a/crates/vite_shared/Cargo.toml b/crates/vite_shared/Cargo.toml index ff814d571b..f7cae64dc7 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", "stream", "json", "system-proxy"] } + [target.'cfg(not(target_os = "windows"))'.dependencies] +reqwest = { workspace = true, features = ["rustls-no-provider", "stream", "json", "system-proxy"] } 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..dbcd1b13f9 100644 --- a/crates/vite_shared/src/env_vars.rs +++ b/crates/vite_shared/src/env_vars.rs @@ -76,6 +76,32 @@ 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. +/// +/// Note on semantics: vp treats this as **additive** to the system trust +/// store (matches Node.js's `NODE_EXTRA_CA_CERTS`), not as a replacement. +/// This differs from OpenSSL/curl/git, which use `SSL_CERT_FILE` as the +/// *sole* trusted bundle. Users who want strict isolation should also +/// restrict outbound traffic at the network layer. +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. Always additive to the system trust +/// store. +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/error.rs b/crates/vite_shared/src/error.rs new file mode 100644 index 0000000000..b498ac4f43 --- /dev/null +++ b/crates/vite_shared/src/error.rs @@ -0,0 +1,124 @@ +//! Error-formatting helpers. + +use std::error::Error; + +/// Maximum chain depth `format_error_chain` will walk. +/// +/// Guards against pathological / cyclic `source()` chains (rare but possible +/// when an error type holds itself via `Box` or `Arc`). +const MAX_CHAIN_DEPTH: usize = 16; + +/// Format an error and its full `source()` chain as `top: cause: deeper-cause`. +/// +/// Use this when stringifying an error into a field of a higher-level error +/// type — otherwise the Display impl of types like `reqwest::Error` only shows +/// the top-level message, hiding the actual cause (TLS handshake failure, +/// connection refused, etc.). +/// +/// Behavior notes: +/// - Walks at most [`MAX_CHAIN_DEPTH`] levels; further sources are summarized +/// as `: ...`. +/// - Skips a source whose Display is already contained in the accumulated +/// message — avoids duplicates when a parent thiserror variant inlines its +/// `#[from]` source via `{0}`. +#[must_use] +pub fn format_error_chain(err: &(dyn Error + 'static)) -> String { + let mut out = err.to_string(); + let mut current = err.source(); + let mut depth = 0_usize; + while let Some(source) = current { + if depth >= MAX_CHAIN_DEPTH { + out.push_str(": ..."); + break; + } + let part = source.to_string(); + if !part.is_empty() && !out.contains(&part) { + out.push_str(": "); + out.push_str(&part); + } + current = source.source(); + depth += 1; + } + out +} + +#[cfg(test)] +mod tests { + use std::{error::Error as StdError, fmt}; + + use super::*; + + #[derive(Debug)] + struct Layer { + msg: String, + cause: Option>, + } + + impl Layer { + fn new(msg: &str) -> Self { + Self { msg: msg.to_string(), cause: None } + } + + fn with_cause(mut self, cause: Layer) -> Self { + self.cause = Some(Box::new(cause)); + self + } + } + + impl fmt::Display for Layer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.msg) + } + } + + impl StdError for Layer { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.cause.as_deref().map(|c| c as &(dyn StdError + 'static)) + } + } + + #[test] + fn single_error_no_chain() { + let e = Layer::new("top"); + assert_eq!(format_error_chain(&e), "top"); + } + + #[test] + fn walks_full_chain() { + let e = Layer::new("send request") + .with_cause(Layer::new("tls handshake").with_cause(Layer::new("UnknownIssuer"))); + assert_eq!(format_error_chain(&e), "send request: tls handshake: UnknownIssuer"); + } + + #[test] + fn dedupes_source_already_in_parent() { + // thiserror's `#[error("Wrapped: {0}")]` style: parent already + // contains the inner message — don't print it twice. + let inner = Layer::new("TLS error: UnknownIssuer"); + let outer = Layer::new("Wrapped: TLS error: UnknownIssuer").with_cause(inner); + assert_eq!(format_error_chain(&outer), "Wrapped: TLS error: UnknownIssuer"); + } + + #[test] + fn dedupes_partial_overlap() { + // The source message appears as a substring of the parent — skip it. + let parent = Layer::new("top: foo bar").with_cause(Layer::new("foo bar")); + assert_eq!(format_error_chain(&parent), "top: foo bar"); + } + + #[test] + fn caps_at_max_depth() { + let mut chain = Layer::new("leaf"); + for i in 0..(MAX_CHAIN_DEPTH + 5) { + chain = Layer::new(&format!("level-{i}")).with_cause(chain); + } + let out = format_error_chain(&chain); + assert!(out.ends_with(": ..."), "expected truncation marker, got {out}"); + } + + #[test] + fn skips_empty_source_messages() { + let parent = Layer::new("top").with_cause(Layer::new("")); + assert_eq!(format_error_chain(&parent), "top"); + } +} diff --git a/crates/vite_shared/src/http.rs b/crates/vite_shared/src/http.rs new file mode 100644 index 0000000000..285acf7b9f --- /dev/null +++ b/crates/vite_shared/src/http.rs @@ -0,0 +1,170 @@ +//! 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. With the `system-proxy` feature enabled, macOS System Settings +//! proxies and Windows registry proxies are also picked up. +//! - `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS` — each may point to a PEM bundle. +//! Parsed via `reqwest::Certificate::from_pem_bundle` and merged into the +//! trust store as *additional* roots. Note: the system store is **kept** +//! (matches Node's `NODE_EXTRA_CA_CERTS` semantics, *not* OpenSSL/curl/git +//! which use `SSL_CERT_FILE` as the sole bundle). Users who need strict +//! isolation should enforce it at the network layer. +//! - `VP_INSECURE_TLS` — when set to a *truthy* value (`1`, `true`, `yes`, +//! `on`, case-insensitive), disables cert verification entirely. Diagnostic +//! escape hatch only; emits a loud stderr warning. Any other value +//! (including `0`, `false`, `no`, `off`, empty string) leaves verification +//! enabled. +//! +//! Note: env vars are read exactly once at the first HTTP call. In long-lived +//! processes (e.g. the NAPI binding embedded in Node), later +//! `process.env.SSL_CERT_FILE = ...` mutations do *not* re-configure the +//! client. + +use std::{ffi::OsStr, path::Path, sync::OnceLock, time::Duration}; + +use crate::{env_vars, error::format_error_chain, output}; + +/// Per-request total timeout. Long enough for slow tarball downloads on +/// constrained CI runners, short enough that a single stuck stream doesn't +/// silently hang a build. +const REQUEST_TIMEOUT: Duration = Duration::from_mins(2); + +/// TCP connect timeout. Distinct from the request timeout above — without +/// this, a black-holed proxy can stall every HTTP call for kernel-level +/// retries (multiple minutes). +const CONNECT_TIMEOUT: Duration = Duration::from_secs(30); + +/// 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. +/// +/// Panics on the *first* call if reqwest fails to build the client (malformed +/// `HTTPS_PROXY`, unusable TLS backend, etc.); subsequent calls in the same +/// process panic with the same message. Panic — not `process::exit` — so +/// destructors of in-flight work still run (lockfiles released, tempfiles +/// cleaned) and an embedding Node host (NAPI) keeps the process alive. +#[must_use] +pub fn shared_http_client() -> &'static reqwest::Client { + static CLIENT: OnceLock> = OnceLock::new(); + match CLIENT.get_or_init(build_client) { + Ok(client) => client, + Err(msg) => panic!("failed to initialize HTTP client: {msg}"), + } +} + +fn build_client() -> Result { + crate::ensure_tls_provider(); + + let mut builder = + reqwest::Client::builder().timeout(REQUEST_TIMEOUT).connect_timeout(CONNECT_TIMEOUT); + + for var in [env_vars::SSL_CERT_FILE, env_vars::NODE_EXTRA_CA_CERTS] { + let Some(value) = std::env::var_os(var) else { continue }; + if value.is_empty() || os_str_is_blank(&value) { + continue; + } + let path = Path::new(&value); + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(err) => { + output::warn(&vite_str::format!( + "failed to read CA bundle from {var}={}: {err}", + path.display() + )); + continue; + } + }; + match reqwest::Certificate::from_pem_bundle(&bytes) { + Ok(certs) if certs.is_empty() => { + output::warn(&vite_str::format!( + "no PEM certificate blocks found in {var}={}", + path.display() + )); + } + Ok(certs) => { + let n = certs.len(); + builder = builder.tls_certs_merge(certs); + tracing::debug!("added {n} extra root certs from {var}"); + } + Err(err) => { + output::warn(&vite_str::format!( + "failed to parse CA bundle from {var}={}: {err}", + path.display() + )); + } + } + } + + if is_env_truthy(env_vars::VP_INSECURE_TLS) { + output::warn( + "VP_INSECURE_TLS is set — TLS certificate verification is disabled. \ + Do not use this in production.", + ); + builder = builder.tls_danger_accept_invalid_certs(true); + } + + builder.build().map_err(|err| format_error_chain(&err)) +} + +/// Returns `true` only for clearly affirmative env-var values +/// (`1`, `true`, `yes`, `on`, case-insensitive). +/// +/// Avoids the footgun where `VP_INSECURE_TLS=0` or `VP_INSECURE_TLS=false` +/// is interpreted as "the variable is set, so feature on" — users naturally +/// expect those values to *disable* the flag. +fn is_env_truthy(var: &str) -> bool { + let Some(value) = std::env::var_os(var) else { return false }; + let Some(s) = value.to_str() else { return false }; + let trimmed = s.trim(); + ["1", "true", "yes", "on"].iter().any(|v| trimmed.eq_ignore_ascii_case(v)) +} + +fn os_str_is_blank(value: &OsStr) -> bool { + value.to_str().is_some_and(|s| s.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + + use super::*; + + #[test] + fn os_str_is_blank_matches_whitespace_only() { + assert!(os_str_is_blank(&OsString::from(""))); + assert!(os_str_is_blank(&OsString::from(" "))); + assert!(os_str_is_blank(&OsString::from("\t\n"))); + assert!(!os_str_is_blank(&OsString::from("/etc/ssl/cert.pem"))); + } + + #[test] + #[serial_test::serial(env)] + fn is_env_truthy_accepts_only_affirmative_values() { + // Use unique var names per case to avoid test-ordering interference + // when std::env is process-global. + for affirmative in ["1", "true", "TRUE", "True", "yes", "Yes", "on", "ON", " 1 "] { + // SAFETY: tests are run serially within this module for env vars. + unsafe { + std::env::set_var("VP_TEST_TRUTHY_VALUE", affirmative); + } + assert!(is_env_truthy("VP_TEST_TRUTHY_VALUE"), "should be truthy: {affirmative:?}"); + } + for negative in ["0", "false", "FALSE", "no", "off", "", " "] { + unsafe { + std::env::set_var("VP_TEST_TRUTHY_VALUE", negative); + } + assert!(!is_env_truthy("VP_TEST_TRUTHY_VALUE"), "should be falsy: {negative:?}"); + } + unsafe { + std::env::remove_var("VP_TEST_TRUTHY_VALUE"); + } + assert!(!is_env_truthy("VP_TEST_TRUTHY_VALUE")); + } +} diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index 5e742e4fb7..121720e050 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -9,8 +9,10 @@ mod env_config; pub mod env_vars; +mod error; pub mod header; mod home; +mod http; pub mod output; mod package_json; mod path_env; @@ -19,7 +21,9 @@ mod tls; mod tracing; pub use env_config::{EnvConfig, TestEnvGuard}; +pub use error::format_error_chain; 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,