Skip to content
Merged
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
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,61 @@ jobs:
RUST_BACKTRACE=1 pnpm test
"

# Regression test for #2278: Debian slim images ship no ca-certificates
# package, and every vp command that touched the network aborted there
# (0.1.23 to 0.2.6) or failed with a handled error (#2273). The global vp
# is built inside a bookworm container so the binary's glibc floor matches
# the runtime image, then runs in node:24-bookworm-slim (the image from the
# report) where HTTPS must work through the bundled Mozilla roots fallback.
certless-image-test:
needs: detect-changes
if: needs.detect-changes.outputs.code-changed == 'true'
name: Cert-less image E2E test
runs-on: namespace-profile-linux-x64-default
timeout-minutes: 30
steps:
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
- uses: ./.github/actions/clone

- 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: certless-image-test

# Mounting the host rustup and cargo homes reuses the pinned nightly
# that setup-rust installed and the restored registry cache, so the
# container downloads nothing on a warm run (rustup dist binaries
# target glibc 2.17 and run on bookworm's 2.36). `rustup show` still
# installs the toolchain if the mount misses it.
- name: Build the global vp in a bookworm container
run: |
docker run --rm \
-v "${{ github.workspace }}:/work" \
-v "$HOME/.rustup:/usr/local/rustup" \
-v "$HOME/.cargo/registry:/usr/local/cargo/registry" \
-v "$HOME/.cargo/git:/usr/local/cargo/git" \
-w /work rust:1-bookworm bash -c '
set -e
apt-get update -qq && apt-get install -y -qq --no-install-recommends cmake python3
rustup show
cargo build -p vite_global_cli
'

- name: Run vp in node:24-bookworm-slim without a CA store
run: |
docker run --rm \
-v "${{ github.workspace }}/target/debug/vp:/usr/local/bin/vp:ro" \
node:24-bookworm-slim bash -c '
set -ex
# The image must actually lack the CA bundle, or this job stops
# guarding anything.
test ! -e /etc/ssl/certs/ca-certificates.crt
vp --version
# HTTPS to nodejs.org through the shared client; exits nonzero
# without the bundled-roots fallback.
vp env list-remote --lts
'

install-e2e-test:
name: Local CLI `vp install` E2E test
needs:
Expand Down Expand Up @@ -1367,6 +1422,7 @@ jobs:
- lint
- cli-e2e-test
- cli-e2e-test-musl
- certless-image-test
- cli-snapshot-test
- cli-snapshot-test-windows
# Skipped on unlabeled PRs; counted on push-to-main and labeled PRs.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
"css-module-lexer",
"html5gum",
"prettyplease",
"proc-macro2",

Check warning on line 11 in Cargo.toml

View workflow job for this annotation

GitHub Actions / Lint

shear/redundant_ignore

redundant ignore `proc-macro2` (remove from ignored list)
"quote",

Check warning on line 12 in Cargo.toml

View workflow job for this annotation

GitHub Actions / Lint

shear/redundant_ignore

redundant ignore `quote` (remove from ignored list)
"string_cache",
"syn",

Check warning on line 14 in Cargo.toml

View workflow job for this annotation

GitHub Actions / Lint

shear/redundant_ignore

redundant ignore `syn` (remove from ignored list)
]

[workspace.package]
Expand Down Expand Up @@ -304,6 +304,7 @@
vite_task = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "5c1d02c750ac21c6f4cf0528062590a145e87fd1" }
vite_workspace = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "5c1d02c750ac21c6f4cf0528062590a145e87fd1" }
walkdir = "2.5.0"
webpki-root-certs = "1.0.9"
which = "8.0.0"
winreg = "0.56.0"
xxhash-rust = "0.8.15"
Expand Down
1 change: 1 addition & 0 deletions crates/vite_shared/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ reqwest = { workspace = true, features = ["native-tls-vendored", "stream", "json
[target.'cfg(not(target_os = "windows"))'.dependencies]
reqwest = { workspace = true, features = ["rustls-no-provider", "stream", "json", "system-proxy"] }
rustls = { workspace = true }
webpki-root-certs = { workspace = true }

[dev-dependencies]
serial_test = { workspace = true }
Expand Down
181 changes: 143 additions & 38 deletions crates/vite_shared/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
//! (including `0`, `false`, `no`, `off`, empty string) leaves verification
//! enabled.
//!
//! If the client cannot be built because the trust store is empty (Debian
//! slim and distroless images ship no `ca-certificates` package), the build
//! is retried once with the Mozilla root store compiled into vp, the same
//! list Node ships. Like Node, the fallback is silent apart from a
//! debug-level trace. A populated system store is used as-is; the bundled
//! roots are never merged over it.
//!
//! 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
Expand All @@ -35,16 +42,15 @@ use crate::{env_vars, error::format_error_chain, output};
/// The process-wide HTTP client could not be built.
///
/// Building the client is where TLS and proxy configuration is first resolved,
/// so every failure here is an environment condition the user can fix: no
/// system CA bundle, an unusable proxy setting, an unparsable extra CA bundle.
/// `cause` carries the full `source()` chain, flattened by
/// so every failure here is an environment condition the user can fix: an
/// unusable proxy setting or an unparsable extra CA bundle. An empty system
/// CA store no longer reaches this error; the bundled-roots fallback handles
/// it. `cause` carries the full `source()` chain, flattened by
/// [`crate::format_error_chain`] because `reqwest::Error` is not `Clone`.
#[derive(Debug, Clone, thiserror::Error)]
#[error(
"could not initialize the HTTP client: {cause}\nCheck that a system CA bundle is installed \
(Debian/Ubuntu: `apt-get install -y ca-certificates`, Alpine: `apk add ca-certificates`) — \
minimal container images often ship none — and that HTTPS_PROXY / HTTP_PROXY and any bundle \
named by SSL_CERT_FILE or NODE_EXTRA_CA_CERTS are valid."
"could not initialize the HTTP client: {cause}\nCheck that HTTPS_PROXY / HTTP_PROXY and any \
bundle named by SSL_CERT_FILE or NODE_EXTRA_CA_CERTS are valid."
)]
pub struct HttpClientError {
cause: Str,
Expand All @@ -65,9 +71,9 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
/// The client is built on first call and reused thereafter. See module docs
/// for the env vars it honors.
///
/// Returns [`HttpClientError`] if the client cannot be built (no system CA
/// bundle, unusable `HTTPS_PROXY`, …). The outcome is cached, so later calls
/// report the same error without retrying.
/// Returns [`HttpClientError`] if the client cannot be built (unusable
/// `HTTPS_PROXY`, bad `SSL_CERT_FILE`, …). The outcome is cached, so later
/// calls report the same error without retrying.
pub fn shared_http_client() -> Result<&'static reqwest::Client, HttpClientError> {
static CLIENT: OnceLock<Result<reqwest::Client, HttpClientError>> = OnceLock::new();
CLIENT.get_or_init(build_client).as_ref().map_err(Clone::clone)
Expand All @@ -76,9 +82,27 @@ pub fn shared_http_client() -> Result<&'static reqwest::Client, HttpClientError>
fn build_client() -> Result<reqwest::Client, HttpClientError> {
crate::ensure_tls_provider();

let mut builder =
reqwest::Client::builder().timeout(REQUEST_TIMEOUT).connect_timeout(CONNECT_TIMEOUT);
let extra_certs = extra_certs_from_env();

let insecure = is_env_truthy(env_vars::VP_INSECURE_TLS);
if insecure {
output::warn(
"VP_INSECURE_TLS is set — TLS certificate verification is disabled. \
Do not use this in production.",
);
}

match build_with(extra_certs.clone(), insecure) {
Ok(client) => Ok(client),
Err(err) => build_with_bundled_roots(extra_certs, insecure)
.ok_or_else(|| HttpClientError { cause: format_error_chain(&err).into() }),
}
}

/// Extra root certs named by `SSL_CERT_FILE` / `NODE_EXTRA_CA_CERTS`.
/// An unreadable or unparsable bundle warns and is skipped.
fn extra_certs_from_env() -> Vec<reqwest::Certificate> {
let mut extra_certs = Vec::new();
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) {
Expand All @@ -103,9 +127,8 @@ fn build_client() -> Result<reqwest::Client, HttpClientError> {
));
}
Ok(certs) => {
let n = certs.len();
builder = builder.tls_certs_merge(certs);
tracing::debug!("added {n} extra root certs from {var}");
tracing::debug!("added {} extra root certs from {var}", certs.len());
extra_certs.extend(certs);
}
Err(err) => {
output::warn(&vite_str::format!(
Expand All @@ -115,16 +138,60 @@ fn build_client() -> Result<reqwest::Client, HttpClientError> {
}
}
}
extra_certs
}

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.",
);
fn build_with(
extra_certs: Vec<reqwest::Certificate>,
insecure: bool,
) -> Result<reqwest::Client, reqwest::Error> {
let mut builder =
reqwest::Client::builder().timeout(REQUEST_TIMEOUT).connect_timeout(CONNECT_TIMEOUT);
if !extra_certs.is_empty() {
builder = builder.tls_certs_merge(extra_certs);
}
if insecure {
builder = builder.tls_danger_accept_invalid_certs(true);
}
builder.build()
}

builder.build().map_err(|err| HttpClientError { cause: format_error_chain(&err).into() })
/// Retry the build with the Mozilla root store compiled into vp merged in.
///
/// Succeeds exactly when the first build failed only because the trust store
/// is empty: rustls-platform-verifier tolerates an empty system store once
/// extra roots are present. Any other failure (malformed proxy, bad
/// `SSL_CERT_FILE`) is still present on the retry, so the caller reports the
/// original error.
#[cfg(not(target_os = "windows"))]
fn build_with_bundled_roots(
mut extra_certs: Vec<reqwest::Certificate>,
insecure: bool,
) -> Option<reqwest::Client> {
extra_certs.extend(bundled_root_certs());
let client = build_with(extra_certs, insecure).ok()?;
tracing::debug!(
"system CA store is empty; using the bundled Mozilla root certificates for TLS \
verification"
);
Some(client)
}

/// Windows uses native-tls over SChannel, whose store is never empty; the
/// bundled roots stay out of the binary there.
#[cfg(target_os = "windows")]
fn build_with_bundled_roots(
_extra_certs: Vec<reqwest::Certificate>,
_insecure: bool,
) -> Option<reqwest::Client> {
None
}

#[cfg(not(target_os = "windows"))]
fn bundled_root_certs() -> impl Iterator<Item = reqwest::Certificate> {
webpki_root_certs::TLS_SERVER_ROOT_CERTS
.iter()
.filter_map(|der| reqwest::Certificate::from_der(der.as_ref()).ok())
}

/// Returns `true` only for clearly affirmative env-var values
Expand Down Expand Up @@ -156,31 +223,38 @@ mod tests {
const PEM_WITH_INVALID_DER: &[u8] =
b"-----BEGIN CERTIFICATE-----\nbm90IGEgY2VydGlmaWNhdGU=\n-----END CERTIFICATE-----\n";

/// Callers already treat "no HTTP client" as a handled outcome, but a panic
/// never reaches them: `[profile.release]` sets `panic = "abort"`, so this
/// is a SIGABRT, not a recoverable unwind. Reported in the wild as
/// "No CA certificates were loaded from the system" in an image with no CA
/// bundle. Unwinds here only because the `test` profile unwinds.
#[test]
#[serial_test::serial(env)]
fn client_build_failure_reaches_the_caller_instead_of_panicking() {
/// Writes the invalid-DER fixture, points `SSL_CERT_FILE` at it around
/// `f`, and cleans up. Callers must hold the `serial(env)` lock.
fn with_invalid_ssl_cert_file<T>(tag: &str, f: impl FnOnce() -> T) -> T {
let bundle = std::env::temp_dir()
.join(vite_str::format!("vp-invalid-ca-{}.pem", std::process::id()).as_str());
.join(vite_str::format!("vp-invalid-ca-{tag}-{}.pem", std::process::id()).as_str());
std::fs::write(&bundle, PEM_WITH_INVALID_DER).expect("write CA bundle fixture");
// SAFETY: env access in this module's tests is serialized.
unsafe {
std::env::set_var(env_vars::SSL_CERT_FILE, &bundle);
}

// Discard the value: only *how* the failure is reported is under test.
let outcome = std::panic::catch_unwind(|| {
let _ = shared_http_client();
});

let result = f();
unsafe {
std::env::remove_var(env_vars::SSL_CERT_FILE);
}
let _ = std::fs::remove_file(&bundle);
result
}

/// Callers already treat "no HTTP client" as a handled outcome, but a panic
/// never reaches them: `[profile.release]` sets `panic = "abort"`, so this
/// is a SIGABRT, not a recoverable unwind. Reported in the wild as
/// "No CA certificates were loaded from the system" in an image with no CA
/// bundle. Unwinds here only because the `test` profile unwinds.
#[test]
#[serial_test::serial(env)]
fn client_build_failure_reaches_the_caller_instead_of_panicking() {
// Discard the value: only *how* the failure is reported is under test.
let outcome = with_invalid_ssl_cert_file("panic", || {
std::panic::catch_unwind(|| {
let _ = shared_http_client();
})
});

assert!(
outcome.is_ok(),
Expand All @@ -189,12 +263,43 @@ mod tests {
);
}

/// Every bundled root has to survive the DER conversion, or the fallback
/// silently ships a smaller trust store than intended.
#[cfg(not(target_os = "windows"))]
#[test]
fn bundled_roots_all_convert_to_reqwest_certificates() {
let total = webpki_root_certs::TLS_SERVER_ROOT_CERTS.len();
assert!(total > 0);
assert_eq!(bundled_root_certs().count(), total);
}

/// The bundled-roots retry must not paper over failures the bundle cannot
/// fix: a bad `SSL_CERT_FILE` poisons the retry as well, and the original
/// error surfaces. Exercises `build_client` directly because
/// `shared_http_client` caches its first outcome process-wide.
///
/// Non-Windows only, like the fallback itself: native-tls rejects the bad
/// PEM at parse time (skipped with a warning), so the build succeeds and
/// there is no failure for a retry to mask.
#[cfg(not(target_os = "windows"))]
#[test]
#[serial_test::serial(env)]
fn bundled_roots_fallback_does_not_mask_other_build_failures() {
let result = with_invalid_ssl_cert_file("fallback", build_client);

assert!(
result.is_err(),
"a bad extra CA bundle has to fail the build even with the bundled-roots retry in \
place"
);
}

#[test]
fn error_reports_the_cause_and_a_remedy() {
let cause = "No CA certificates were loaded from the system";
let cause = "builder error: proxy scheme is not supported";
let message = HttpClientError { cause: cause.into() }.to_string();
assert!(message.contains(cause), "{message}");
assert!(message.contains("ca-certificates"), "{message}");
assert!(message.contains("HTTPS_PROXY"), "{message}");
assert!(message.contains(env_vars::NODE_EXTRA_CA_CERTS), "{message}");
}

Expand Down
Loading