Skip to content

Commit e430085

Browse files
committed
fix(http): address second-round code review findings
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<Client, String> 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.
1 parent e359fd2 commit e430085

7 files changed

Lines changed: 208 additions & 40 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -999,11 +999,11 @@ jobs:
999999
# Force the registry-fetch path: install a pinned pnpm globally so
10001000
# vp downloads it (and therefore traverses sfw) rather than reusing
10011001
# whatever's preinstalled on the runner.
1002-
sfw ${{ matrix.vp_bin }} i -g pnpm@9.15.0
1002+
sfw "${{ matrix.vp_bin }}" i -g pnpm@9.15.0
10031003
# Then exercise `vp install` inside a real repo, also through sfw.
10041004
git clone --depth 1 https://github.com/vitejs/vite.git "$RUNNER_TEMP/vite"
10051005
cd "$RUNNER_TEMP/vite"
1006-
sfw ${{ matrix.vp_bin }} install --no-frozen-lockfile
1006+
sfw "${{ matrix.vp_bin }}" install --no-frozen-lockfile
10071007
10081008
done:
10091009
runs-on: ubuntu-latest

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_error/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ serde_yml = { workspace = true }
2121
thiserror = { workspace = true }
2222
tokio = { workspace = true }
2323
vite_path = { workspace = true }
24+
vite_shared = { workspace = true }
2425
vite_str = { workspace = true }
2526
vite_workspace = { workspace = true }
2627
wax = { workspace = true }

crates/vite_error/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,12 @@ pub enum Error {
106106
#[error(transparent)]
107107
Semver(#[from] semver::Error),
108108

109-
#[error(transparent)]
109+
// `#[error("{}", ...)]` not `transparent`: surface the full `source()`
110+
// chain (TLS handshake → UnknownIssuer, hyper IO errors, etc.) instead of
111+
// just reqwest's top-level "error sending request for url (...)" message.
112+
// Keeps `From<reqwest::Error>` and `source()` semantics intact, so 404
113+
// detection via `e.status()` at call sites still works.
114+
#[error("{}", vite_shared::format_error_chain(.0))]
110115
Reqwest(#[from] reqwest::Error),
111116

112117
#[error(transparent)]

crates/vite_js_runtime/src/error.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,13 @@ pub enum Error {
5858
#[error(transparent)]
5959
Io(#[from] std::io::Error),
6060

61-
/// HTTP request error
62-
#[error(transparent)]
61+
/// HTTP request error.
62+
///
63+
/// Surface the full `source()` chain (TLS handshake / connect / hyper
64+
/// IO) rather than reqwest's top-level message only. Body-streaming
65+
/// failures inside `download_file` propagate via `?` into this variant,
66+
/// so the chain has to be exposed here — not at the call site.
67+
#[error("{}", vite_shared::format_error_chain(.0))]
6368
Reqwest(#[from] reqwest::Error),
6469

6570
/// Join error from tokio

crates/vite_shared/src/error.rs

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,42 @@
22
33
use std::error::Error;
44

5+
/// Maximum chain depth `format_error_chain` will walk.
6+
///
7+
/// Guards against pathological / cyclic `source()` chains (rare but possible
8+
/// when an error type holds itself via `Box<dyn Error>` or `Arc`).
9+
const MAX_CHAIN_DEPTH: usize = 16;
10+
511
/// Format an error and its full `source()` chain as `top: cause: deeper-cause`.
612
///
713
/// Use this when stringifying an error into a field of a higher-level error
814
/// type — otherwise the Display impl of types like `reqwest::Error` only shows
915
/// the top-level message, hiding the actual cause (TLS handshake failure,
1016
/// connection refused, etc.).
17+
///
18+
/// Behavior notes:
19+
/// - Walks at most [`MAX_CHAIN_DEPTH`] levels; further sources are summarized
20+
/// as `: ...`.
21+
/// - Skips a source whose Display is already contained in the accumulated
22+
/// message — avoids duplicates when a parent thiserror variant inlines its
23+
/// `#[from]` source via `{0}`.
1124
#[must_use]
1225
pub fn format_error_chain(err: &(dyn Error + 'static)) -> String {
1326
let mut out = err.to_string();
1427
let mut current = err.source();
28+
let mut depth = 0_usize;
1529
while let Some(source) = current {
16-
out.push_str(": ");
17-
out.push_str(&source.to_string());
30+
if depth >= MAX_CHAIN_DEPTH {
31+
out.push_str(": ...");
32+
break;
33+
}
34+
let part = source.to_string();
35+
if !part.is_empty() && !out.contains(&part) {
36+
out.push_str(": ");
37+
out.push_str(&part);
38+
}
1839
current = source.source();
40+
depth += 1;
1941
}
2042
out
2143
}
@@ -28,13 +50,24 @@ mod tests {
2850

2951
#[derive(Debug)]
3052
struct Layer {
31-
msg: &'static str,
53+
msg: String,
3254
cause: Option<Box<Layer>>,
3355
}
3456

57+
impl Layer {
58+
fn new(msg: &str) -> Self {
59+
Self { msg: msg.to_string(), cause: None }
60+
}
61+
62+
fn with_cause(mut self, cause: Layer) -> Self {
63+
self.cause = Some(Box::new(cause));
64+
self
65+
}
66+
}
67+
3568
impl fmt::Display for Layer {
3669
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37-
f.write_str(self.msg)
70+
f.write_str(&self.msg)
3871
}
3972
}
4073

@@ -46,19 +79,46 @@ mod tests {
4679

4780
#[test]
4881
fn single_error_no_chain() {
49-
let e = Layer { msg: "top", cause: None };
82+
let e = Layer::new("top");
5083
assert_eq!(format_error_chain(&e), "top");
5184
}
5285

5386
#[test]
5487
fn walks_full_chain() {
55-
let e = Layer {
56-
msg: "send request",
57-
cause: Some(Box::new(Layer {
58-
msg: "tls handshake",
59-
cause: Some(Box::new(Layer { msg: "UnknownIssuer", cause: None })),
60-
})),
61-
};
88+
let e = Layer::new("send request")
89+
.with_cause(Layer::new("tls handshake").with_cause(Layer::new("UnknownIssuer")));
6290
assert_eq!(format_error_chain(&e), "send request: tls handshake: UnknownIssuer");
6391
}
92+
93+
#[test]
94+
fn dedupes_source_already_in_parent() {
95+
// thiserror's `#[error("Wrapped: {0}")]` style: parent already
96+
// contains the inner message — don't print it twice.
97+
let inner = Layer::new("TLS error: UnknownIssuer");
98+
let outer = Layer::new("Wrapped: TLS error: UnknownIssuer").with_cause(inner);
99+
assert_eq!(format_error_chain(&outer), "Wrapped: TLS error: UnknownIssuer");
100+
}
101+
102+
#[test]
103+
fn dedupes_partial_overlap() {
104+
// The source message appears as a substring of the parent — skip it.
105+
let parent = Layer::new("top: foo bar").with_cause(Layer::new("foo bar"));
106+
assert_eq!(format_error_chain(&parent), "top: foo bar");
107+
}
108+
109+
#[test]
110+
fn caps_at_max_depth() {
111+
let mut chain = Layer::new("leaf");
112+
for i in 0..(MAX_CHAIN_DEPTH + 5) {
113+
chain = Layer::new(&format!("level-{i}")).with_cause(chain);
114+
}
115+
let out = format_error_chain(&chain);
116+
assert!(out.ends_with(": ..."), "expected truncation marker, got {out}");
117+
}
118+
119+
#[test]
120+
fn skips_empty_source_messages() {
121+
let parent = Layer::new("top").with_cause(Layer::new(""));
122+
assert_eq!(format_error_chain(&parent), "top");
123+
}
64124
}

crates/vite_shared/src/http.rs

Lines changed: 119 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,35 +14,58 @@
1414
//! added as an *additional* trusted root (system store is also kept —
1515
//! unlike OpenSSL's `SSL_CERT_FILE` which replaces it). Per-block parse
1616
//! failures emit a stderr warning and the remaining blocks are still added.
17-
//! - `VP_INSECURE_TLS` — when set to any value, disables cert verification
18-
//! entirely. Diagnostic escape hatch only; emits a loud stderr warning.
17+
//! - `VP_INSECURE_TLS` — when set to a *truthy* value (`1`, `true`, `yes`,
18+
//! `on`, case-insensitive), disables cert verification entirely. Diagnostic
19+
//! escape hatch only; emits a loud stderr warning. Any other value
20+
//! (including `0`, `false`, `no`, `off`, empty string) leaves verification
21+
//! enabled.
22+
//!
23+
//! Note: env vars are read exactly once at the first HTTP call. In long-lived
24+
//! processes (e.g. the NAPI binding embedded in Node), later
25+
//! `process.env.SSL_CERT_FILE = ...` mutations do *not* re-configure the
26+
//! client.
1927
20-
use std::{ffi::OsStr, path::Path, sync::OnceLock};
28+
use std::{ffi::OsStr, path::Path, sync::OnceLock, time::Duration};
2129

22-
use crate::{env_vars, output};
30+
use crate::{env_vars, error::format_error_chain, output};
2331

2432
const PEM_CERT_BEGIN: &[u8] = b"-----BEGIN CERTIFICATE-----";
2533
const PEM_CERT_END: &[u8] = b"-----END CERTIFICATE-----";
2634

35+
/// Per-request total timeout. Long enough for slow tarball downloads on
36+
/// constrained CI runners, short enough that a single stuck stream doesn't
37+
/// silently hang a build.
38+
const REQUEST_TIMEOUT: Duration = Duration::from_mins(2);
39+
40+
/// TCP connect timeout. Distinct from the request timeout above — without
41+
/// this, a black-holed proxy can stall every HTTP call for kernel-level
42+
/// retries (multiple minutes).
43+
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
44+
2745
/// Get the process-wide `reqwest::Client`.
2846
///
2947
/// The client is built on first call and reused thereafter. See module docs
3048
/// for the env vars it honors.
3149
///
32-
/// If reqwest fails to build the client (e.g. malformed `HTTPS_PROXY`,
33-
/// unusable TLS backend) the process exits with a clean error message rather
34-
/// than panicking — the first HTTP call cannot proceed and there is nothing
35-
/// useful to fall back to.
50+
/// Panics on the *first* call if reqwest fails to build the client (malformed
51+
/// `HTTPS_PROXY`, unusable TLS backend, etc.); subsequent calls in the same
52+
/// process panic with the same message. Panic — not `process::exit` — so
53+
/// destructors of in-flight work still run (lockfiles released, tempfiles
54+
/// cleaned) and an embedding Node host (NAPI) keeps the process alive.
3655
#[must_use]
3756
pub fn shared_http_client() -> &'static reqwest::Client {
38-
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
39-
CLIENT.get_or_init(build_client)
57+
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
58+
match CLIENT.get_or_init(build_client) {
59+
Ok(client) => client,
60+
Err(msg) => panic!("failed to initialize HTTP client: {msg}"),
61+
}
4062
}
4163

42-
fn build_client() -> reqwest::Client {
64+
fn build_client() -> Result<reqwest::Client, String> {
4365
crate::ensure_tls_provider();
4466

45-
let mut builder = reqwest::Client::builder();
67+
let mut builder =
68+
reqwest::Client::builder().timeout(REQUEST_TIMEOUT).connect_timeout(CONNECT_TIMEOUT);
4669

4770
for var in [env_vars::SSL_CERT_FILE, env_vars::NODE_EXTRA_CA_CERTS] {
4871
let Some(value) = std::env::var_os(var) else { continue };
@@ -87,27 +110,40 @@ fn build_client() -> reqwest::Client {
87110
tracing::debug!("added {added} extra root certs from {var}");
88111
}
89112

90-
if std::env::var_os(env_vars::VP_INSECURE_TLS).is_some() {
113+
if is_env_truthy(env_vars::VP_INSECURE_TLS) {
91114
output::warn(
92115
"VP_INSECURE_TLS is set — TLS certificate verification is disabled. \
93116
Do not use this in production.",
94117
);
95118
builder = builder.danger_accept_invalid_certs(true);
96119
}
97120

98-
match builder.build() {
99-
Ok(client) => client,
100-
Err(err) => {
101-
output::error(&vite_str::format!("failed to initialize HTTP client: {err}"));
102-
std::process::exit(1);
103-
}
104-
}
121+
builder.build().map_err(|err| format_error_chain(&err))
122+
}
123+
124+
/// Returns `true` only for clearly affirmative env-var values
125+
/// (`1`, `true`, `yes`, `on`, case-insensitive).
126+
///
127+
/// Avoids the footgun where `VP_INSECURE_TLS=0` or `VP_INSECURE_TLS=false`
128+
/// is interpreted as "the variable is set, so feature on" — users naturally
129+
/// expect those values to *disable* the flag.
130+
fn is_env_truthy(var: &str) -> bool {
131+
let Some(value) = std::env::var_os(var) else { return false };
132+
let Some(s) = value.to_str() else { return false };
133+
let trimmed = s.trim();
134+
["1", "true", "yes", "on"].iter().any(|v| trimmed.eq_ignore_ascii_case(v))
105135
}
106136

107137
fn os_str_is_blank(value: &OsStr) -> bool {
108138
value.to_str().is_some_and(|s| s.trim().is_empty())
109139
}
110140

141+
/// Extract `-----BEGIN CERTIFICATE-----`…`-----END CERTIFICATE-----` blocks
142+
/// from a PEM bundle, byte-window-based.
143+
///
144+
/// Handles a malformed bundle where a `BEGIN` is not followed by a matching
145+
/// `END` before the next `BEGIN` — that orphan is skipped (logged at debug)
146+
/// rather than greedily consuming the next certificate's body.
111147
fn extract_pem_cert_blocks(bundle: &[u8]) -> Vec<&[u8]> {
112148
let mut blocks = Vec::new();
113149
let mut cursor = 0_usize;
@@ -119,11 +155,28 @@ fn extract_pem_cert_blocks(bundle: &[u8]) -> Vec<&[u8]> {
119155
};
120156
let start = cursor + start_rel;
121157
let body_start = start + PEM_CERT_BEGIN.len();
122-
let Some(end_rel) =
123-
bundle[body_start..].windows(PEM_CERT_END.len()).position(|w| w == PEM_CERT_END)
124-
else {
158+
let search_slice = &bundle[body_start..];
159+
let next_end = search_slice.windows(PEM_CERT_END.len()).position(|w| w == PEM_CERT_END);
160+
let Some(end_rel) = next_end else {
161+
// No END marker at all: orphan BEGIN — stop scanning, nothing
162+
// valid can follow.
163+
tracing::debug!("PEM bundle: unterminated BEGIN CERTIFICATE at byte {start}");
125164
break;
126165
};
166+
// If a *new* BEGIN appears before this END, the current BEGIN is
167+
// orphaned. Skip past just this orphan and resume scanning at the
168+
// intervening BEGIN — without this, both certs are lost.
169+
let next_begin =
170+
search_slice.windows(PEM_CERT_BEGIN.len()).position(|w| w == PEM_CERT_BEGIN);
171+
if let Some(next_begin_rel) = next_begin
172+
&& next_begin_rel < end_rel
173+
{
174+
tracing::debug!(
175+
"PEM bundle: orphan BEGIN CERTIFICATE at byte {start} (no END before next BEGIN); skipping"
176+
);
177+
cursor = body_start + next_begin_rel;
178+
continue;
179+
}
127180
let end = body_start + end_rel + PEM_CERT_END.len();
128181
blocks.push(&bundle[start..end]);
129182
cursor = end;
@@ -189,4 +242,47 @@ two\n\
189242
let bundle = b"-----BEGIN CERTIFICATE-----\nno end marker\n";
190243
assert!(extract_pem_cert_blocks(bundle).is_empty());
191244
}
245+
246+
#[test]
247+
fn extract_blocks_recovers_after_orphan_begin() {
248+
// Hand-concatenated bundle missing a newline + END marker between
249+
// two certs: the orphan first BEGIN must not swallow the second
250+
// cert's body. The valid second cert is recovered.
251+
let bundle = b"\
252+
-----BEGIN CERTIFICATE-----\n\
253+
truncated, no END\n\
254+
-----BEGIN CERTIFICATE-----\n\
255+
valid\n\
256+
-----END CERTIFICATE-----\n";
257+
let blocks = extract_pem_cert_blocks(bundle);
258+
assert_eq!(blocks.len(), 1, "expected to recover the second cert");
259+
let recovered = std::str::from_utf8(blocks[0]).unwrap();
260+
assert!(recovered.contains("valid"));
261+
assert!(recovered.starts_with("-----BEGIN CERTIFICATE-----"));
262+
assert!(recovered.ends_with("-----END CERTIFICATE-----"));
263+
}
264+
265+
#[test]
266+
#[serial_test::serial(env)]
267+
fn is_env_truthy_accepts_only_affirmative_values() {
268+
// Use unique var names per case to avoid test-ordering interference
269+
// when std::env is process-global.
270+
for affirmative in ["1", "true", "TRUE", "True", "yes", "Yes", "on", "ON", " 1 "] {
271+
// SAFETY: tests are run serially within this module for env vars.
272+
unsafe {
273+
std::env::set_var("VP_TEST_TRUTHY_VALUE", affirmative);
274+
}
275+
assert!(is_env_truthy("VP_TEST_TRUTHY_VALUE"), "should be truthy: {affirmative:?}");
276+
}
277+
for negative in ["0", "false", "FALSE", "no", "off", "", " "] {
278+
unsafe {
279+
std::env::set_var("VP_TEST_TRUTHY_VALUE", negative);
280+
}
281+
assert!(!is_env_truthy("VP_TEST_TRUTHY_VALUE"), "should be falsy: {negative:?}");
282+
}
283+
unsafe {
284+
std::env::remove_var("VP_TEST_TRUTHY_VALUE");
285+
}
286+
assert!(!is_env_truthy("VP_TEST_TRUTHY_VALUE"));
287+
}
192288
}

0 commit comments

Comments
 (0)