From af87df72c361c162e502d0d6799a8089a2b3d022 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 21:47:28 +0800 Subject: [PATCH 01/14] feat(http): centralize HTTP client with proxy and custom-CA support Builds a single shared reqwest::Client in vite_shared that honors HTTPS_PROXY / HTTP_PROXY / NO_PROXY, loads PEM bundles from SSL_CERT_FILE and NODE_EXTRA_CA_CERTS, and exposes a VP_INSECURE_TLS diagnostic opt-in. Routes every existing reqwest::get / Client::new site in vite_install and vite_js_runtime through it so vp can traverse TLS-intercepting tools like Socket Firewall Free (sfw) and corporate MITM proxies. Adds an install-e2e-test-sfw job (Linux/macOS/Windows) that downloads the upstream sfw binary and runs `sfw vp i -g pnpm@9.15.0` plus `sfw vp install` against vitejs/vite. Gated on the `test: sfw` label for PRs, unconditional on push-to-main. Carries VP_INSECURE_TLS=1 until sfw upstream ships the EKU fix (SocketDev/sfw-free#30, #43); flip removed once that lands to also exercise CA injection. Refs voidzero-dev/setup-vp#73 --- .github/workflows/ci.yml | 97 ++++++++++++++++++++++++++ Cargo.lock | 2 + crates/vite_install/src/request.rs | 4 +- crates/vite_js_runtime/src/download.rs | 11 ++- crates/vite_shared/Cargo.toml | 5 ++ crates/vite_shared/src/env_vars.rs | 19 +++++ crates/vite_shared/src/http.rs | 68 ++++++++++++++++++ crates/vite_shared/src/lib.rs | 2 + 8 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 crates/vite_shared/src/http.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc4893fb6d..4ebd53d3ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -889,6 +889,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() @@ -899,6 +993,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..d7fb153bfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7796,11 +7796,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, From aed5a90b5e6945f02a970ac34edfcbd9a794a88c Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 27 May 2026 08:42:15 +0800 Subject: [PATCH 02/14] fix(http): address code review findings on shared client - Replace `.expect()` on Client::build() with `output::error` + exit(1). Pre-PR, build failure (malformed HTTPS_PROXY, TLS init error) returned Err and was propagated; the OnceLock wrapper turned it into a panic that would re-fire on every subsequent call. Now a clean error and exit instead of a stack trace. - Surface CA-bundle read/parse failures via `output::warn` instead of `tracing::warn!`. tracing is silent unless VITE_LOG is set, hiding the misconfiguration from end users. - Parse SSL_CERT_FILE / NODE_EXTRA_CA_CERTS block-by-block via `Certificate::from_pem`. reqwest's `from_pem_bundle` fails the whole bundle on the first non-cert PEM block (e.g. a private key in the same file), dropping every cert silently. Now per-block: bad blocks warn, good blocks are added. - Use `std::env::var_os` so non-UTF-8 cert paths on Unix are honored. - Skip whitespace-only env values. - Enable reqwest's `system-proxy` feature so macOS System Settings and Windows registry proxies are honored, not just HTTPS_PROXY/HTTP_PROXY. - Add `stream` and `json` reqwest features to vite_shared so the API owner declares them (feature unification still keeps consumers working when their crates redeclare). - Add `error_for_status()?` to download_text so 4xx/5xx becomes an error instead of returning the error body as the "text". - Document SSL_CERT_FILE's additive semantics (differs from OpenSSL). - CI: move VP_INSECURE_TLS from job-level env to the single sfw step so unrelated build/setup steps don't run with cert verification off. - CI: add `--remove-on-error` to the sfw curl so a failed download doesn't leave a 0-byte file that the next step tries to exec. --- .github/workflows/ci.yml | 19 +-- Cargo.lock | 50 +++++++- crates/vite_js_runtime/src/download.rs | 2 +- crates/vite_shared/Cargo.toml | 4 +- crates/vite_shared/src/env_vars.rs | 9 +- crates/vite_shared/src/http.rs | 162 ++++++++++++++++++++++--- 6 files changed, 212 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ebd53d3ef..def303080d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -912,12 +912,6 @@ jobs: 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 @@ -960,7 +954,10 @@ jobs: run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/sfw-bin" - curl --fail --location --retry 3 --retry-delay 2 \ + # `--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 @@ -972,6 +969,14 @@ jobs: 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` + # 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. Scoped to this step so unrelated build/setup steps in this + # job never see an HTTP client with cert verification disabled. + env: + VP_INSECURE_TLS: '1' run: | set -euo pipefail # Force the registry-fetch path: install a pinned pnpm globally so diff --git a/Cargo.lock b/Cargo.lock index d7fb153bfb..b3e957bd58 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]] @@ -5152,7 +5164,6 @@ dependencies = [ "commondir", "cow-utils", "dashmap", - "dunce", "futures", "indexmap", "insta", @@ -5625,7 +5636,6 @@ version = "0.1.0" dependencies = [ "arcstr", "phf", - "rolldown_common", "rolldown_plugin", "rolldown_utils", ] @@ -6125,7 +6135,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 +6258,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 +6802,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" @@ -8355,6 +8386,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_js_runtime/src/download.rs b/crates/vite_js_runtime/src/download.rs index 7d7c6b8029..b9391d6601 100644 --- a/crates/vite_js_runtime/src/download.rs +++ b/crates/vite_js_runtime/src/download.rs @@ -117,7 +117,7 @@ pub async fn download_text(url: &str) -> Result { tracing::debug!("Downloading text from {url}"); - let content = (|| async { client.get(url).send().await?.text().await }) + let content = (|| async { client.get(url).send().await?.error_for_status()?.text().await }) .retry( ExponentialBuilder::default() .with_jitter() diff --git a/crates/vite_shared/Cargo.toml b/crates/vite_shared/Cargo.toml index 4c583d0654..f7cae64dc7 100644 --- a/crates/vite_shared/Cargo.toml +++ b/crates/vite_shared/Cargo.toml @@ -21,10 +21,10 @@ vite_str = { workspace = true } which = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] -reqwest = { workspace = true, features = ["native-tls-vendored"] } +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"] } +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 82a4d66054..dbcd1b13f9 100644 --- a/crates/vite_shared/src/env_vars.rs +++ b/crates/vite_shared/src/env_vars.rs @@ -81,12 +81,19 @@ pub const VP_GLOBAL_VERSION: &str = "VP_GLOBAL_VERSION"; /// 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. +/// 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. diff --git a/crates/vite_shared/src/http.rs b/crates/vite_shared/src/http.rs index d6e6731163..52f87d3971 100644 --- a/crates/vite_shared/src/http.rs +++ b/crates/vite_shared/src/http.rs @@ -7,22 +7,32 @@ //! //! 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. +//! 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. +//! Every `-----BEGIN CERTIFICATE-----` block is parsed independently and +//! added as an *additional* trusted root (system store is also kept — +//! unlike OpenSSL's `SSL_CERT_FILE` which replaces it). Per-block parse +//! failures emit a stderr warning and the remaining blocks are still added. //! - `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 std::{ffi::OsStr, path::Path, sync::OnceLock}; use crate::{env_vars, output}; +const PEM_CERT_BEGIN: &[u8] = b"-----BEGIN CERTIFICATE-----"; +const PEM_CERT_END: &[u8] = b"-----END CERTIFICATE-----"; + /// 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. +/// +/// If reqwest fails to build the client (e.g. malformed `HTTPS_PROXY`, +/// unusable TLS backend) the process exits with a clean error message rather +/// than panicking — the first HTTP call cannot proceed and there is nothing +/// useful to fall back to. #[must_use] pub fn shared_http_client() -> &'static reqwest::Client { static CLIENT: OnceLock = OnceLock::new(); @@ -35,25 +45,46 @@ fn build_client() -> reqwest::Client { 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() { + let Some(value) = std::env::var_os(var) else { continue }; + if value.is_empty() || os_str_is_blank(&value) { 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); - } + 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; + } + }; + let blocks = extract_pem_cert_blocks(&bytes); + if blocks.is_empty() { + output::warn(&vite_str::format!( + "no PEM certificate blocks found in {var}={}", + path.display() + )); + continue; + } + let mut added = 0_usize; + for (idx, block) in blocks.iter().enumerate() { + match reqwest::Certificate::from_pem(block) { + Ok(cert) => { + builder = builder.add_root_certificate(cert); + added += 1; } Err(err) => { - tracing::warn!("failed to parse extra CA bundle from {var}={path}: {err}"); + output::warn(&vite_str::format!( + "failed to parse certificate #{} from {var}={}: {err}", + idx + 1, + path.display() + )); } - }, - Err(err) => { - tracing::warn!("failed to read extra CA bundle from {var}={path}: {err}"); } } + tracing::debug!("added {added} extra root certs from {var}"); } if std::env::var_os(env_vars::VP_INSECURE_TLS).is_some() { @@ -64,5 +95,98 @@ fn build_client() -> reqwest::Client { builder = builder.danger_accept_invalid_certs(true); } - builder.build().expect("failed to build shared reqwest client") + match builder.build() { + Ok(client) => client, + Err(err) => { + output::error(&vite_str::format!("failed to initialize HTTP client: {err}")); + std::process::exit(1); + } + } +} + +fn os_str_is_blank(value: &OsStr) -> bool { + value.to_str().is_some_and(|s| s.trim().is_empty()) +} + +fn extract_pem_cert_blocks(bundle: &[u8]) -> Vec<&[u8]> { + let mut blocks = Vec::new(); + let mut cursor = 0_usize; + while cursor < bundle.len() { + let Some(start_rel) = + bundle[cursor..].windows(PEM_CERT_BEGIN.len()).position(|w| w == PEM_CERT_BEGIN) + else { + break; + }; + let start = cursor + start_rel; + let body_start = start + PEM_CERT_BEGIN.len(); + let Some(end_rel) = + bundle[body_start..].windows(PEM_CERT_END.len()).position(|w| w == PEM_CERT_END) + else { + break; + }; + let end = body_start + end_rel + PEM_CERT_END.len(); + blocks.push(&bundle[start..end]); + cursor = end; + } + blocks +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + + use super::*; + + const SAMPLE_CERT: &[u8] = + b"-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHHIglt\n-----END CERTIFICATE-----"; + + #[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] + fn extract_blocks_finds_single_cert() { + let blocks = extract_pem_cert_blocks(SAMPLE_CERT); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0], SAMPLE_CERT); + } + + #[test] + fn extract_blocks_skips_non_cert_pem() { + let bundle = b"\ +-----BEGIN PRIVATE KEY-----\n\ +ignored\n\ +-----END PRIVATE KEY-----\n\ +-----BEGIN CERTIFICATE-----\n\ +keepme\n\ +-----END CERTIFICATE-----\n"; + let blocks = extract_pem_cert_blocks(bundle); + assert_eq!(blocks.len(), 1); + assert!(blocks[0].starts_with(b"-----BEGIN CERTIFICATE-----")); + assert!(blocks[0].ends_with(b"-----END CERTIFICATE-----")); + } + + #[test] + fn extract_blocks_finds_multiple_certs() { + let bundle = b"\ +-----BEGIN CERTIFICATE-----\n\ +one\n\ +-----END CERTIFICATE-----\n\ +junk in between\n\ +-----BEGIN CERTIFICATE-----\n\ +two\n\ +-----END CERTIFICATE-----\n"; + let blocks = extract_pem_cert_blocks(bundle); + assert_eq!(blocks.len(), 2); + } + + #[test] + fn extract_blocks_drops_unterminated_block() { + let bundle = b"-----BEGIN CERTIFICATE-----\nno end marker\n"; + assert!(extract_pem_cert_blocks(bundle).is_empty()); + } } From 6ba1b4a4c6713f6ba6f0432796201ebad052469e Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 27 May 2026 21:31:25 +0800 Subject: [PATCH 03/14] fix(ci): use vp.cmd on Windows in sfw e2e job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sfw spawns its child process directly via CreateProcess without applying Windows PATHEXT, so a bare `sfw vp ...` invocation fails with "Command 'vp' not found in PATH" — sfw's own error message points at this exact fix. Linux and macOS pass on the bare name. Add a matrix `vp_bin` value (`vp` on POSIX, `vp.cmd` on Windows) and use it on the two sfw invocations. --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index def303080d..e2d9df9229 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -905,12 +905,17 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu sfw_asset: sfw-free-linux-x86_64 + vp_bin: vp - os: macos-latest target: aarch64-apple-darwin sfw_asset: sfw-free-macos-arm64 + vp_bin: vp - os: windows-latest target: x86_64-pc-windows-msvc sfw_asset: sfw-free-windows-x86_64.exe + # On Windows vp ships as `vp.cmd`; sfw spawns its child process + # directly without applying PATHEXT, so the bare `vp` lookup fails. + vp_bin: vp.cmd runs-on: ${{ matrix.os }} steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 @@ -982,11 +987,11 @@ jobs: # 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 + 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 vp install --no-frozen-lockfile + sfw ${{ matrix.vp_bin }} install --no-frozen-lockfile done: runs-on: ubuntu-latest From a8f54886419f65a28fa0eaacc7df83f19eb71131 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 27 May 2026 21:41:12 +0800 Subject: [PATCH 04/14] ci(sfw): drop VP_INSECURE_TLS now that sfw's CA passes verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified locally with sfw 0.12.22: the CA cert sfw issues has no Extended Key Usage extension at all (Basic Constraints CA:TRUE + Key Usage critical Certificate Sign), which rustls and native-tls accept per RFC 5280. The original bug (SocketDev/sfw-free#30, #43) — a present-but-empty EKU that rustls rejected — appears to have been fixed upstream. Removing VP_INSECURE_TLS=1 lets this CI job exercise the full CA-injection path (SSL_CERT_FILE -> Certificate::from_pem -> add_root_certificate -> TLS handshake) end-to-end, not just the proxy plumbing. Local reproduction: $ cat /tmp/p/package.json { "name":"sfw-tls-test", "packageManager":"pnpm@9.15.0" } $ rm -rf ~/.vite-plus/package_manager/pnpm/9.15.0* $ unset VP_INSECURE_TLS $ sfw vp install --no-frozen-lockfile Protected by Socket Firewall === Socket Firewall === 1 packages fetched successfully $ ls ~/.vite-plus/package_manager/pnpm/9.15.0/ -> pnpm --- .github/workflows/ci.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2d9df9229..ddd800c280 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -974,14 +974,6 @@ jobs: 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` - # 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. Scoped to this step so unrelated build/setup steps in this - # job never see an HTTP client with cert verification disabled. - env: - VP_INSECURE_TLS: '1' run: | set -euo pipefail # Force the registry-fetch path: install a pinned pnpm globally so From d0c19b55329f237c970a5c0c25d06b5f94aa3e9c Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 27 May 2026 22:11:15 +0800 Subject: [PATCH 05/14] ci(sfw): restore VP_INSECURE_TLS and fix Windows binary name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions surfaced in the last CI run: 1. Linux failed with `Failed to download from nodejs.org` — sfw's GitHub release v1.10.0 still issues a CA with a present-but-empty Extended Key Usage, which rustls rejects. My local verification used `sfw` from npm (0.12.x, separate version track) which doesn't carry the bug; the GitHub releases (what CI downloads) do. Restore VP_INSECURE_TLS=1 on this step until SocketDev/sfw-free#30 / #43 are released. 2. Windows failed with `Command 'vp.cmd' not found in PATH`. vp on Windows ships as `vp.exe` (the trampoline), not `vp.cmd`. sfw's earlier "try vp.cmd" suggestion was a generic PATHEXT hint. --- .github/workflows/ci.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddd800c280..01eca87cac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -913,9 +913,9 @@ jobs: - os: windows-latest target: x86_64-pc-windows-msvc sfw_asset: sfw-free-windows-x86_64.exe - # On Windows vp ships as `vp.cmd`; sfw spawns its child process + # 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.cmd + vp_bin: vp.exe runs-on: ${{ matrix.os }} steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 @@ -974,6 +974,14 @@ jobs: 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` + # once sfw ships the EKU fix. The GitHub release v1.10.0 still issues a + # CA cert with a present-but-empty Extended Key Usage extension that + # rustls (Linux/macOS) and native-tls (Windows) reject. With this var + # set vp still exercises the HTTPS_PROXY + SSL_CERT_FILE plumbing + # end-to-end; only certificate *validity* checking is bypassed. + env: + VP_INSECURE_TLS: '1' run: | set -euo pipefail # Force the registry-fetch path: install a pinned pnpm globally so From e73f995fda7fe52a9a023de03b4cd43bd1f0dc77 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 28 May 2026 10:28:50 +0800 Subject: [PATCH 06/14] ci(sfw): use Windows-style path in GITHUB_PATH on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH` writes a POSIX path like `/c/Users/runneradmin/.vite-plus/bin`. Git Bash itself can find binaries there, but child processes spawned via CreateProcess (sfw spawns its target this way) cannot — Windows resolves PATH entries with backslash semantics. Match the OS-aware fork used by the `test` job (~L266): write `$USERPROFILE\.vite-plus\bin` on Windows, `$HOME/.vite-plus/bin` elsewhere. Now `sfw vp.exe ...` finds vp on the Windows runner. --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01eca87cac..746ddad784 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -953,7 +953,15 @@ jobs: - name: Build CLI run: | pnpm bootstrap-cli:ci - echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH + # 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: | From 4558dc671a62d9269be1afff6fe263a7cfe7ecb3 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 28 May 2026 12:00:17 +0800 Subject: [PATCH 07/14] ci(sfw): drop VP_INSECURE_TLS to test sfw v1.11.0 cert verification sfw v1.11.0 was published 2026-05-27, which may include the EKU fix referenced in SocketDev/sfw-free#30 / #43. Drop the bypass and let CI verify whether rustls (Linux/macOS) and native-tls (Windows) now accept sfw's CA. If any matrix entry fails with an UnknownIssuer-style TLS error, restore VP_INSECURE_TLS=1 with a fresh reference to the upstream issues. --- .github/workflows/ci.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 746ddad784..6305a70588 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -982,14 +982,6 @@ jobs: 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` - # once sfw ships the EKU fix. The GitHub release v1.10.0 still issues a - # CA cert with a present-but-empty Extended Key Usage extension that - # rustls (Linux/macOS) and native-tls (Windows) reject. With this var - # set vp still exercises the HTTPS_PROXY + SSL_CERT_FILE plumbing - # end-to-end; only certificate *validity* checking is bypassed. - env: - VP_INSECURE_TLS: '1' run: | set -euo pipefail # Force the registry-fetch path: install a pinned pnpm globally so From 43b2022851cce3f420091e636259e608a346246d Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 28 May 2026 13:56:32 +0800 Subject: [PATCH 08/14] feat(http): surface full error source chain for download failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux sfw CI failure showed: Failed to download from .../SHASUMS256.txt: error sending request for url (https://.../SHASUMS256.txt) The user-visible message stopped at reqwest's top-level Display and dropped the actual cause (e.g. "invalid peer certificate: UnknownIssuer" inside the rustls error chain). That hides why the request failed — looks like a network blip when it's actually a TLS trust problem. Add `vite_shared::format_error_chain` that walks `Error::source()` recursively and joins the messages with `: `. Use it in all four `Error::DownloadFailed { reason }` map_err sites in vite_js_runtime/src/download.rs. Now the same failure renders as: Failed to download from .../SHASUMS256.txt: error sending request for url (...): client error (Connect): invalid peer certificate: UnknownIssuer making the root cause grep-able from the CI log. --- crates/vite_js_runtime/src/download.rs | 23 ++++++--- crates/vite_shared/src/error.rs | 64 ++++++++++++++++++++++++++ crates/vite_shared/src/lib.rs | 2 + 3 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 crates/vite_shared/src/error.rs diff --git a/crates/vite_js_runtime/src/download.rs b/crates/vite_js_runtime/src/download.rs index b9391d6601..72c9d9fcdb 100644 --- a/crates/vite_js_runtime/src/download.rs +++ b/crates/vite_js_runtime/src/download.rs @@ -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(); @@ -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) } @@ -158,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 { @@ -181,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_shared/src/error.rs b/crates/vite_shared/src/error.rs new file mode 100644 index 0000000000..dbb8fd1643 --- /dev/null +++ b/crates/vite_shared/src/error.rs @@ -0,0 +1,64 @@ +//! Error-formatting helpers. + +use std::error::Error; + +/// 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.). +#[must_use] +pub fn format_error_chain(err: &(dyn Error + 'static)) -> String { + let mut out = err.to_string(); + let mut current = err.source(); + while let Some(source) = current { + out.push_str(": "); + out.push_str(&source.to_string()); + current = source.source(); + } + out +} + +#[cfg(test)] +mod tests { + use std::{error::Error as StdError, fmt}; + + use super::*; + + #[derive(Debug)] + struct Layer { + msg: &'static str, + cause: Option>, + } + + 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 { msg: "top", cause: None }; + assert_eq!(format_error_chain(&e), "top"); + } + + #[test] + fn walks_full_chain() { + let e = Layer { + msg: "send request", + cause: Some(Box::new(Layer { + msg: "tls handshake", + cause: Some(Box::new(Layer { msg: "UnknownIssuer", cause: None })), + })), + }; + assert_eq!(format_error_chain(&e), "send request: tls handshake: UnknownIssuer"); + } +} diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index 175e987309..121720e050 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -9,6 +9,7 @@ mod env_config; pub mod env_vars; +mod error; pub mod header; mod home; mod http; @@ -20,6 +21,7 @@ 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}; From eeec465a89e76cf6073deef300ea899c9d707f89 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 28 May 2026 14:25:19 +0800 Subject: [PATCH 09/14] =?UTF-8?q?ci(sfw):=20restore=20VP=5FINSECURE=5FTLS?= =?UTF-8?q?=20=E2=80=94=20sfw=20v1.11.0=20still=20has=20the=20EKU=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dropped-VP_INSECURE_TLS experiment confirmed via the now-readable error chain that sfw v1.11.0 (releases/latest as of 2026-05-28) still issues a CA cert with a present-but-empty Extended Key Usage: error sending request for url (https://nodejs.org/.../SHASUMS256.txt): client error (Connect): invalid peer certificate: UnknownIssuer (The new error-chain formatter from f105aa93 made the actual rustls reason visible — previously the same failure looked like a generic "error sending request" with no hint.) macOS happened to pass without the flag only because that runner had Node 22.18.0 already cached, so vp didn't have to fetch SHASUMS via sfw — not a real fix. Restore VP_INSECURE_TLS=1 on the sfw step (scoped to that step only to keep build/setup steps unaffected). The plumbing — HTTPS_PROXY + SSL_CERT_FILE + add_root_certificate — is still exercised end-to-end; only certificate *validity* is bypassed until SocketDev/sfw-free#30 and #43 ship. --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6305a70588..63e56b3118 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -982,6 +982,18 @@ jobs: 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` + # 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 happened to pass only because + # the runner already has Node 22.18.0 cached, so vp didn't have to + # traverse sfw for SHASUMS — not a fix. Keep this flag on every + # matrix entry until upstream ships the EKU fix. + env: + VP_INSECURE_TLS: '1' run: | set -euo pipefail # Force the registry-fetch path: install a pinned pnpm globally so From 9f15d2b8fef5957229adfb27ca5954b8055a5ac0 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 28 May 2026 14:27:49 +0800 Subject: [PATCH 10/14] ci(sfw): align macOS runner with the rest of the workflow Match what the `test` job uses (`namespace-profile-mac-default`, restored on main in 5fecd93c). Same runner image and arch (aarch64-apple-darwin); just a faster pool. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63e56b3118..dac91aa202 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -906,7 +906,7 @@ jobs: target: x86_64-unknown-linux-gnu sfw_asset: sfw-free-linux-x86_64 vp_bin: vp - - os: macos-latest + - os: namespace-profile-mac-default target: aarch64-apple-darwin sfw_asset: sfw-free-macos-arm64 vp_bin: vp From 0bea74a90dda17c62080cddda8fe1c502d93b0f0 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 28 May 2026 15:10:14 +0800 Subject: [PATCH 11/14] fix(http): address second-round code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings addressed: - VP_INSECURE_TLS=0 / false / empty used to enable insecure TLS because the check was `var_os().is_some()`. Now requires a truthy value (1/true/yes/on, case-insensitive). Matches user mental model and avoids a security footgun. - extract_pem_cert_blocks was greedy: an orphan BEGIN followed by a valid cert produced one bogus merged block and lost the valid one. Now detects an intervening BEGIN before the next END and skips past the orphan, recovering the legitimate cert. - vite_error::Error::Reqwest and vite_js_runtime::Error::Reqwest were #[error(transparent)] — Display fell through to reqwest, which only shows the top-level message. The full source chain (TLS handshake → UnknownIssuer, hyper IO) was lost for every error propagated via `?` from `HttpClient::get` (vite_install path) and from the body- streaming loop in `download_file`. Both now use vite_shared::format_error_chain via #[error("{}", ...)] — From and source() semantics intact, so 404 detection via e.status() still works. - build_client used std::process::exit(1) inside OnceLock::get_or_init from a tokio worker — skipped Drop, leaked lockfiles/tempfiles, and killed an embedding NAPI Node host from native code. Now caches Result in the OnceLock and panics with the chain message; subsequent callers see the same cached failure rather than re-running build_client. The error message uses format_error_chain rather than reqwest's top-level Display. - Shared client had no timeout or connect_timeout — one stuck stream could block every concurrent download through the shared HTTP/2 connection. Added 30s connect / 2 min request timeouts. - format_error_chain now caps recursion depth at 16 (guards against cyclic source chains) and skips a source whose Display is already contained in the accumulated message (de-dupes when a parent thiserror variant inlines its `#[from]` source via `{0}`). - CI sfw step: ${{ matrix.vp_bin }} quoted in shell context. vite_error gains a vite_shared dep (no cycle — vite_shared does not depend on vite_error). Tests added for is_env_truthy, the orphan- BEGIN recovery path, and the format_error_chain depth/dedup behavior. --- .github/workflows/ci.yml | 4 +- Cargo.lock | 1 + crates/vite_error/Cargo.toml | 1 + crates/vite_error/src/lib.rs | 7 +- crates/vite_js_runtime/src/error.rs | 9 +- crates/vite_shared/src/error.rs | 84 +++++++++++++--- crates/vite_shared/src/http.rs | 142 +++++++++++++++++++++++----- 7 files changed, 208 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dac91aa202..32e2f201e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -999,11 +999,11 @@ jobs: # 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 + 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 + sfw "${{ matrix.vp_bin }}" install --no-frozen-lockfile done: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index b3e957bd58..b7190e97c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7597,6 +7597,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "vite_path", + "vite_shared", "vite_str", "vite_workspace", "wax 0.6.0", 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_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/src/error.rs b/crates/vite_shared/src/error.rs index dbb8fd1643..b498ac4f43 100644 --- a/crates/vite_shared/src/error.rs +++ b/crates/vite_shared/src/error.rs @@ -2,20 +2,42 @@ 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 { - out.push_str(": "); - out.push_str(&source.to_string()); + 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 } @@ -28,13 +50,24 @@ mod tests { #[derive(Debug)] struct Layer { - msg: &'static str, + 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) + f.write_str(&self.msg) } } @@ -46,19 +79,46 @@ mod tests { #[test] fn single_error_no_chain() { - let e = Layer { msg: "top", cause: None }; + let e = Layer::new("top"); assert_eq!(format_error_chain(&e), "top"); } #[test] fn walks_full_chain() { - let e = Layer { - msg: "send request", - cause: Some(Box::new(Layer { - msg: "tls handshake", - cause: Some(Box::new(Layer { msg: "UnknownIssuer", cause: None })), - })), - }; + 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 index 52f87d3971..0e2c987cfe 100644 --- a/crates/vite_shared/src/http.rs +++ b/crates/vite_shared/src/http.rs @@ -14,35 +14,58 @@ //! added as an *additional* trusted root (system store is also kept — //! unlike OpenSSL's `SSL_CERT_FILE` which replaces it). Per-block parse //! failures emit a stderr warning and the remaining blocks are still added. -//! - `VP_INSECURE_TLS` — when set to any value, disables cert verification -//! entirely. Diagnostic escape hatch only; emits a loud stderr warning. +//! - `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}; +use std::{ffi::OsStr, path::Path, sync::OnceLock, time::Duration}; -use crate::{env_vars, output}; +use crate::{env_vars, error::format_error_chain, output}; const PEM_CERT_BEGIN: &[u8] = b"-----BEGIN CERTIFICATE-----"; const PEM_CERT_END: &[u8] = b"-----END CERTIFICATE-----"; +/// 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. /// -/// If reqwest fails to build the client (e.g. malformed `HTTPS_PROXY`, -/// unusable TLS backend) the process exits with a clean error message rather -/// than panicking — the first HTTP call cannot proceed and there is nothing -/// useful to fall back to. +/// 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(); - CLIENT.get_or_init(build_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() -> reqwest::Client { +fn build_client() -> Result { crate::ensure_tls_provider(); - let mut builder = reqwest::Client::builder(); + 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 }; @@ -87,7 +110,7 @@ fn build_client() -> reqwest::Client { tracing::debug!("added {added} extra root certs from {var}"); } - if std::env::var_os(env_vars::VP_INSECURE_TLS).is_some() { + 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.", @@ -95,19 +118,32 @@ fn build_client() -> reqwest::Client { builder = builder.danger_accept_invalid_certs(true); } - match builder.build() { - Ok(client) => client, - Err(err) => { - output::error(&vite_str::format!("failed to initialize HTTP client: {err}")); - std::process::exit(1); - } - } + 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()) } +/// Extract `-----BEGIN CERTIFICATE-----`…`-----END CERTIFICATE-----` blocks +/// from a PEM bundle, byte-window-based. +/// +/// Handles a malformed bundle where a `BEGIN` is not followed by a matching +/// `END` before the next `BEGIN` — that orphan is skipped (logged at debug) +/// rather than greedily consuming the next certificate's body. fn extract_pem_cert_blocks(bundle: &[u8]) -> Vec<&[u8]> { let mut blocks = Vec::new(); let mut cursor = 0_usize; @@ -119,11 +155,28 @@ fn extract_pem_cert_blocks(bundle: &[u8]) -> Vec<&[u8]> { }; let start = cursor + start_rel; let body_start = start + PEM_CERT_BEGIN.len(); - let Some(end_rel) = - bundle[body_start..].windows(PEM_CERT_END.len()).position(|w| w == PEM_CERT_END) - else { + let search_slice = &bundle[body_start..]; + let next_end = search_slice.windows(PEM_CERT_END.len()).position(|w| w == PEM_CERT_END); + let Some(end_rel) = next_end else { + // No END marker at all: orphan BEGIN — stop scanning, nothing + // valid can follow. + tracing::debug!("PEM bundle: unterminated BEGIN CERTIFICATE at byte {start}"); break; }; + // If a *new* BEGIN appears before this END, the current BEGIN is + // orphaned. Skip past just this orphan and resume scanning at the + // intervening BEGIN — without this, both certs are lost. + let next_begin = + search_slice.windows(PEM_CERT_BEGIN.len()).position(|w| w == PEM_CERT_BEGIN); + if let Some(next_begin_rel) = next_begin + && next_begin_rel < end_rel + { + tracing::debug!( + "PEM bundle: orphan BEGIN CERTIFICATE at byte {start} (no END before next BEGIN); skipping" + ); + cursor = body_start + next_begin_rel; + continue; + } let end = body_start + end_rel + PEM_CERT_END.len(); blocks.push(&bundle[start..end]); cursor = end; @@ -189,4 +242,47 @@ two\n\ let bundle = b"-----BEGIN CERTIFICATE-----\nno end marker\n"; assert!(extract_pem_cert_blocks(bundle).is_empty()); } + + #[test] + fn extract_blocks_recovers_after_orphan_begin() { + // Hand-concatenated bundle missing a newline + END marker between + // two certs: the orphan first BEGIN must not swallow the second + // cert's body. The valid second cert is recovered. + let bundle = b"\ +-----BEGIN CERTIFICATE-----\n\ +truncated, no END\n\ +-----BEGIN CERTIFICATE-----\n\ +valid\n\ +-----END CERTIFICATE-----\n"; + let blocks = extract_pem_cert_blocks(bundle); + assert_eq!(blocks.len(), 1, "expected to recover the second cert"); + let recovered = std::str::from_utf8(blocks[0]).unwrap(); + assert!(recovered.contains("valid")); + assert!(recovered.starts_with("-----BEGIN CERTIFICATE-----")); + assert!(recovered.ends_with("-----END CERTIFICATE-----")); + } + + #[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")); + } } From 22b591e0fb1756e6f107433d913128c0f506cecf Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 28 May 2026 15:15:44 +0800 Subject: [PATCH 12/14] ci(sfw): scope VP_INSECURE_TLS to the Linux matrix entry only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only Linux actually needs the TLS-bypass against sfw v1.11.0: - Linux (ubuntu-latest): runner doesn't preinstall Node 22.18 into vp's cache, so `sfw vp i -g pnpm@9.15.0` triggers vp's HttpClient to fetch nodejs.org/.../SHASUMS256.txt through sfw. rustls rejects sfw's broken CA (UnknownIssuer) — flag required. - macOS / Windows: runners already have Node 22.18 in vp's cache. vp never traverses sfw with its HttpClient in this test; the only HTTPS through sfw is npm's (lenient Node TLS). No flag needed — and leaving verification enabled there confirms the bypass is scoped, not blanket. Plumbed via a per-matrix-entry `vp_insecure_tls` value. The shared HTTP client treats an empty `VP_INSECURE_TLS` env as unset (the truthy-only parser added in the previous commit), so the empty value on macOS/Windows is a no-op. --- .github/workflows/ci.yml | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32e2f201e8..8d6738d4ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -906,16 +906,25 @@ jobs: 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 @@ -982,18 +991,18 @@ jobs: 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` - # 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 happened to pass only because - # the runner already has Node 22.18.0 cached, so vp didn't have to - # traverse sfw for SHASUMS — not a fix. Keep this flag on every - # matrix entry until upstream ships the EKU fix. + # 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: '1' + VP_INSECURE_TLS: ${{ matrix.vp_insecure_tls }} run: | set -euo pipefail # Force the registry-fetch path: install a pinned pnpm globally so From 9ea09726053609c7df5dd1264c459310b6a8149d Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 28 May 2026 15:19:10 +0800 Subject: [PATCH 13/14] chore(lockfile): re-sync Cargo.lock with upstream rolldown The previous rebase resolved Cargo.lock by taking the in-flight version on one conflict, dropping a couple of upstream rolldown entries (rolldown_common's dunce dep, a plugin's rolldown_common dep) that should still be present on main. Re-add them. --- Cargo.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index b7190e97c4..cd8d2022af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5164,6 +5164,7 @@ dependencies = [ "commondir", "cow-utils", "dashmap", + "dunce", "futures", "indexmap", "insta", @@ -5636,6 +5637,7 @@ version = "0.1.0" dependencies = [ "arcstr", "phf", + "rolldown_common", "rolldown_plugin", "rolldown_utils", ] From 1f443e9786ceaab7c3bcdb23bedf4da132c120bf Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 28 May 2026 16:50:11 +0800 Subject: [PATCH 14/14] refactor(http): use Certificate::from_pem_bundle and new reqwest TLS APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the hand-rolled extract_pem_cert_blocks (~80 lines + 4 tests) with `reqwest::Certificate::from_pem_bundle`. rustls_pki_types' pem_reader_iter already filters non-CERTIFICATE PEM sections (private keys, comments, etc.) by returning None and `continue`-ing in the iterator — my earlier concern about mixed bundles failing was wrong on the facts. The only case the custom extractor handled better was a single corrupted base64 cert in an otherwise-valid bundle, which is rare enough that custom parsing isn't worth the maintenance cost. - Switch to the non-deprecated reqwest 0.13 builder methods: - add_root_certificate(c) -> tls_certs_merge([c, ...]) - danger_accept_invalid_certs(true) -> tls_danger_accept_invalid_certs(true) Both are the documented successors; add_root_certificate is marked Deprecated in reqwest 0.13.2's docs. - Keep the additive-roots semantics call-out in the module docs (vp treats SSL_CERT_FILE as additive, matching NODE_EXTRA_CA_CERTS — not curl/git's "replace the trust store" behavior). --- crates/vite_shared/src/http.rs | 164 +++++---------------------------- 1 file changed, 23 insertions(+), 141 deletions(-) diff --git a/crates/vite_shared/src/http.rs b/crates/vite_shared/src/http.rs index 0e2c987cfe..285acf7b9f 100644 --- a/crates/vite_shared/src/http.rs +++ b/crates/vite_shared/src/http.rs @@ -10,10 +10,11 @@ //! 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. -//! Every `-----BEGIN CERTIFICATE-----` block is parsed independently and -//! added as an *additional* trusted root (system store is also kept — -//! unlike OpenSSL's `SSL_CERT_FILE` which replaces it). Per-block parse -//! failures emit a stderr warning and the remaining blocks are still added. +//! 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 @@ -29,9 +30,6 @@ use std::{ffi::OsStr, path::Path, sync::OnceLock, time::Duration}; use crate::{env_vars, error::format_error_chain, output}; -const PEM_CERT_BEGIN: &[u8] = b"-----BEGIN CERTIFICATE-----"; -const PEM_CERT_END: &[u8] = b"-----END CERTIFICATE-----"; - /// 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. @@ -83,31 +81,25 @@ fn build_client() -> Result { continue; } }; - let blocks = extract_pem_cert_blocks(&bytes); - if blocks.is_empty() { - output::warn(&vite_str::format!( - "no PEM certificate blocks found in {var}={}", - path.display() - )); - continue; - } - let mut added = 0_usize; - for (idx, block) in blocks.iter().enumerate() { - match reqwest::Certificate::from_pem(block) { - Ok(cert) => { - builder = builder.add_root_certificate(cert); - added += 1; - } - Err(err) => { - output::warn(&vite_str::format!( - "failed to parse certificate #{} from {var}={}: {err}", - idx + 1, - path.display() - )); - } + 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() + )); } } - tracing::debug!("added {added} extra root certs from {var}"); } if is_env_truthy(env_vars::VP_INSECURE_TLS) { @@ -115,7 +107,7 @@ fn build_client() -> Result { "VP_INSECURE_TLS is set — TLS certificate verification is disabled. \ Do not use this in production.", ); - builder = builder.danger_accept_invalid_certs(true); + builder = builder.tls_danger_accept_invalid_certs(true); } builder.build().map_err(|err| format_error_chain(&err)) @@ -138,61 +130,12 @@ fn os_str_is_blank(value: &OsStr) -> bool { value.to_str().is_some_and(|s| s.trim().is_empty()) } -/// Extract `-----BEGIN CERTIFICATE-----`…`-----END CERTIFICATE-----` blocks -/// from a PEM bundle, byte-window-based. -/// -/// Handles a malformed bundle where a `BEGIN` is not followed by a matching -/// `END` before the next `BEGIN` — that orphan is skipped (logged at debug) -/// rather than greedily consuming the next certificate's body. -fn extract_pem_cert_blocks(bundle: &[u8]) -> Vec<&[u8]> { - let mut blocks = Vec::new(); - let mut cursor = 0_usize; - while cursor < bundle.len() { - let Some(start_rel) = - bundle[cursor..].windows(PEM_CERT_BEGIN.len()).position(|w| w == PEM_CERT_BEGIN) - else { - break; - }; - let start = cursor + start_rel; - let body_start = start + PEM_CERT_BEGIN.len(); - let search_slice = &bundle[body_start..]; - let next_end = search_slice.windows(PEM_CERT_END.len()).position(|w| w == PEM_CERT_END); - let Some(end_rel) = next_end else { - // No END marker at all: orphan BEGIN — stop scanning, nothing - // valid can follow. - tracing::debug!("PEM bundle: unterminated BEGIN CERTIFICATE at byte {start}"); - break; - }; - // If a *new* BEGIN appears before this END, the current BEGIN is - // orphaned. Skip past just this orphan and resume scanning at the - // intervening BEGIN — without this, both certs are lost. - let next_begin = - search_slice.windows(PEM_CERT_BEGIN.len()).position(|w| w == PEM_CERT_BEGIN); - if let Some(next_begin_rel) = next_begin - && next_begin_rel < end_rel - { - tracing::debug!( - "PEM bundle: orphan BEGIN CERTIFICATE at byte {start} (no END before next BEGIN); skipping" - ); - cursor = body_start + next_begin_rel; - continue; - } - let end = body_start + end_rel + PEM_CERT_END.len(); - blocks.push(&bundle[start..end]); - cursor = end; - } - blocks -} - #[cfg(test)] mod tests { use std::ffi::OsString; use super::*; - const SAMPLE_CERT: &[u8] = - b"-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHHIglt\n-----END CERTIFICATE-----"; - #[test] fn os_str_is_blank_matches_whitespace_only() { assert!(os_str_is_blank(&OsString::from(""))); @@ -201,67 +144,6 @@ mod tests { assert!(!os_str_is_blank(&OsString::from("/etc/ssl/cert.pem"))); } - #[test] - fn extract_blocks_finds_single_cert() { - let blocks = extract_pem_cert_blocks(SAMPLE_CERT); - assert_eq!(blocks.len(), 1); - assert_eq!(blocks[0], SAMPLE_CERT); - } - - #[test] - fn extract_blocks_skips_non_cert_pem() { - let bundle = b"\ ------BEGIN PRIVATE KEY-----\n\ -ignored\n\ ------END PRIVATE KEY-----\n\ ------BEGIN CERTIFICATE-----\n\ -keepme\n\ ------END CERTIFICATE-----\n"; - let blocks = extract_pem_cert_blocks(bundle); - assert_eq!(blocks.len(), 1); - assert!(blocks[0].starts_with(b"-----BEGIN CERTIFICATE-----")); - assert!(blocks[0].ends_with(b"-----END CERTIFICATE-----")); - } - - #[test] - fn extract_blocks_finds_multiple_certs() { - let bundle = b"\ ------BEGIN CERTIFICATE-----\n\ -one\n\ ------END CERTIFICATE-----\n\ -junk in between\n\ ------BEGIN CERTIFICATE-----\n\ -two\n\ ------END CERTIFICATE-----\n"; - let blocks = extract_pem_cert_blocks(bundle); - assert_eq!(blocks.len(), 2); - } - - #[test] - fn extract_blocks_drops_unterminated_block() { - let bundle = b"-----BEGIN CERTIFICATE-----\nno end marker\n"; - assert!(extract_pem_cert_blocks(bundle).is_empty()); - } - - #[test] - fn extract_blocks_recovers_after_orphan_begin() { - // Hand-concatenated bundle missing a newline + END marker between - // two certs: the orphan first BEGIN must not swallow the second - // cert's body. The valid second cert is recovered. - let bundle = b"\ ------BEGIN CERTIFICATE-----\n\ -truncated, no END\n\ ------BEGIN CERTIFICATE-----\n\ -valid\n\ ------END CERTIFICATE-----\n"; - let blocks = extract_pem_cert_blocks(bundle); - assert_eq!(blocks.len(), 1, "expected to recover the second cert"); - let recovered = std::str::from_utf8(blocks[0]).unwrap(); - assert!(recovered.contains("valid")); - assert!(recovered.starts_with("-----BEGIN CERTIFICATE-----")); - assert!(recovered.ends_with("-----END CERTIFICATE-----")); - } - #[test] #[serial_test::serial(env)] fn is_env_truthy_accepts_only_affirmative_values() {