Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,100 @@ jobs:
echo ""
done

install-e2e-test-sfw:
name: Local CLI `vp install` E2E test (Socket Firewall Free)
needs:
- download-previous-rolldown-binaries
# Run if: not a PR (push-to-main / workflow_dispatch), OR PR has 'test: sfw' label.
# Heavy job (3 OSes × real registry traffic) — gated to avoid running on every PR.
if: >-
github.event_name != 'pull_request' ||
contains(github.event.pull_request.labels.*.name, 'test: sfw')
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
sfw_asset: sfw-free-linux-x86_64
- os: macos-latest
target: aarch64-apple-darwin
sfw_asset: sfw-free-macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
sfw_asset: sfw-free-windows-x86_64.exe
runs-on: ${{ matrix.os }}
# TODO(SocketDev/sfw-free#30, SocketDev/sfw-free#43): drop `VP_INSECURE_TLS`
# once sfw ships the EKU fix. Until then the cert sfw issues is rejected by
# rustls/native-tls; the proxy + CA-injection plumbing is still exercised
# via HTTPS_PROXY + SSL_CERT_FILE, only certificate *validity* is skipped.
env:
VP_INSECURE_TLS: '1'
steps:
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
- uses: ./.github/actions/clone

- name: Setup Dev Drive
if: runner.os == 'Windows'
uses: samypr100/setup-dev-drive@30f0f98ae5636b2b6501e181dfb3631b9974818d # v4.0.0
with:
drive-size: 12GB
drive-format: ReFS
env-mapping: |
CARGO_HOME,{{ DEV_DRIVE }}/.cargo
RUSTUP_HOME,{{ DEV_DRIVE }}/.rustup

- uses: oxc-project/setup-rust@68c3199c5339f965e6e163924c3c450773eba42b # main (pending v1.0.17 — Swatinem/rust-cache v2.9.1 for node24)
with:
save-cache: ${{ github.ref_name == 'main' }}
cache-key: install-e2e-test-sfw-${{ matrix.os }}
target-dir: ${{ runner.os == 'Windows' && format('{0}/target', env.DEV_DRIVE) || '' }}

- uses: oxc-project/setup-node@ab97f03642370d79a7e96dd286bd02a1be40e0ba # v1.3.0

- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: rolldown-binaries
path: ./rolldown/packages/rolldown/src
merge-multiple: true

- name: Build with upstream
uses: ./.github/actions/build-upstream
with:
target: ${{ matrix.target }}

- name: Build CLI
run: |
pnpm bootstrap-cli:ci
echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH

- name: Download sfw
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/sfw-bin"
curl --fail --location --retry 3 --retry-delay 2 \
--output "$RUNNER_TEMP/sfw-bin/sfw${{ runner.os == 'Windows' && '.exe' || '' }}" \
"https://github.com/SocketDev/sfw-free/releases/latest/download/${{ matrix.sfw_asset }}"
if [[ "${{ runner.os }}" != "Windows" ]]; then
chmod +x "$RUNNER_TEMP/sfw-bin/sfw"
fi
echo "$RUNNER_TEMP/sfw-bin" >> "$GITHUB_PATH"

- name: Verify sfw on PATH
run: sfw --version

- name: Run `sfw vp install` against a real repo
run: |
set -euo pipefail
# Force the registry-fetch path: install a pinned pnpm globally so
# vp downloads it (and therefore traverses sfw) rather than reusing
# whatever's preinstalled on the runner.
sfw vp i -g pnpm@9.15.0
# Then exercise `vp install` inside a real repo, also through sfw.
git clone --depth 1 https://github.com/vitejs/vite.git "$RUNNER_TEMP/vite"
cd "$RUNNER_TEMP/vite"
sfw vp install --no-frozen-lockfile

done:
runs-on: ubuntu-latest
if: always()
Expand All @@ -893,6 +987,9 @@ jobs:
- cli-e2e-test
- cli-e2e-test-musl
- cli-snap-test
# Skipped on unlabeled PRs; counted on push-to-main and labeled PRs.
# `contains(needs.*.result, 'failure')` ignores "skipped" results.
- install-e2e-test-sfw
steps:
- run: exit 1
# Thank you, next https://github.com/vercel/next.js/blob/canary/.github/workflows/build_and_test.yml#L379
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions crates/vite_install/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ impl HttpClient {
}

async fn get(&self, url: &str) -> Result<Response, Error> {
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()
Expand Down
11 changes: 5 additions & 6 deletions crates/vite_js_runtime/src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<String, Error> {
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()
Expand All @@ -138,12 +138,11 @@ pub async fn fetch_with_cache_headers(
url: &str,
if_none_match: Option<&str>,
) -> Result<CachedFetchResponse, Error> {
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 {
Expand Down
5 changes: 5 additions & 0 deletions crates/vite_shared/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
19 changes: 19 additions & 0 deletions crates/vite_shared/src/env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions crates/vite_shared/src/http.rs
Original file line number Diff line number Diff line change
@@ -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<reqwest::Client> = 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")
}
2 changes: 2 additions & 0 deletions crates/vite_shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Loading