Skip to content

Commit d611940

Browse files
committed
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.
1 parent 533a865 commit d611940

6 files changed

Lines changed: 212 additions & 32 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -906,12 +906,6 @@ jobs:
906906
target: x86_64-pc-windows-msvc
907907
sfw_asset: sfw-free-windows-x86_64.exe
908908
runs-on: ${{ matrix.os }}
909-
# TODO(SocketDev/sfw-free#30, SocketDev/sfw-free#43): drop `VP_INSECURE_TLS`
910-
# once sfw ships the EKU fix. Until then the cert sfw issues is rejected by
911-
# rustls/native-tls; the proxy + CA-injection plumbing is still exercised
912-
# via HTTPS_PROXY + SSL_CERT_FILE, only certificate *validity* is skipped.
913-
env:
914-
VP_INSECURE_TLS: '1'
915909
steps:
916910
- uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
917911
- uses: ./.github/actions/clone
@@ -954,7 +948,10 @@ jobs:
954948
run: |
955949
set -euo pipefail
956950
mkdir -p "$RUNNER_TEMP/sfw-bin"
957-
curl --fail --location --retry 3 --retry-delay 2 \
951+
# `--remove-on-error` (curl 7.83+) prevents leaving a zero-byte file
952+
# on failure so the next step's `sfw --version` fails clearly rather
953+
# than with "exec format error" on an empty binary.
954+
curl --fail --location --remove-on-error --retry 3 --retry-delay 2 \
958955
--output "$RUNNER_TEMP/sfw-bin/sfw${{ runner.os == 'Windows' && '.exe' || '' }}" \
959956
"https://github.com/SocketDev/sfw-free/releases/latest/download/${{ matrix.sfw_asset }}"
960957
if [[ "${{ runner.os }}" != "Windows" ]]; then
@@ -966,6 +963,14 @@ jobs:
966963
run: sfw --version
967964

968965
- name: Run `sfw vp install` against a real repo
966+
# TODO(SocketDev/sfw-free#30, SocketDev/sfw-free#43): drop `VP_INSECURE_TLS`
967+
# once sfw ships the EKU fix. Until then the cert sfw issues is rejected
968+
# by rustls/native-tls; the proxy + CA-injection plumbing is still
969+
# exercised via HTTPS_PROXY + SSL_CERT_FILE, only certificate *validity*
970+
# is skipped. Scoped to this step so unrelated build/setup steps in this
971+
# job never see an HTTP client with cert verification disabled.
972+
env:
973+
VP_INSECURE_TLS: '1'
969974
run: |
970975
set -euo pipefail
971976
# Force the registry-fetch path: install a pinned pnpm globally so

Cargo.lock

Lines changed: 46 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_js_runtime/src/download.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ pub async fn download_text(url: &str) -> Result<String, Error> {
117117

118118
tracing::debug!("Downloading text from {url}");
119119

120-
let content = (|| async { client.get(url).send().await?.text().await })
120+
let content = (|| async { client.get(url).send().await?.error_for_status()?.text().await })
121121
.retry(
122122
ExponentialBuilder::default()
123123
.with_jitter()

crates/vite_shared/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ vite_str = { workspace = true }
2121
which = { workspace = true }
2222

2323
[target.'cfg(target_os = "windows")'.dependencies]
24-
reqwest = { workspace = true, features = ["native-tls-vendored"] }
24+
reqwest = { workspace = true, features = ["native-tls-vendored", "stream", "json", "system-proxy"] }
2525

2626
[target.'cfg(not(target_os = "windows"))'.dependencies]
27-
reqwest = { workspace = true, features = ["rustls-no-provider"] }
27+
reqwest = { workspace = true, features = ["rustls-no-provider", "stream", "json", "system-proxy"] }
2828
rustls = { workspace = true }
2929

3030
[dev-dependencies]

crates/vite_shared/src/env_vars.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,19 @@ pub const VP_GLOBAL_VERSION: &str = "VP_GLOBAL_VERSION";
8181
/// Path to a PEM bundle of extra CA certificates to trust for HTTPS.
8282
///
8383
/// Industry-standard env var also set by tools like Socket Firewall Free.
84+
///
85+
/// Note on semantics: vp treats this as **additive** to the system trust
86+
/// store (matches Node.js's `NODE_EXTRA_CA_CERTS`), not as a replacement.
87+
/// This differs from OpenSSL/curl/git, which use `SSL_CERT_FILE` as the
88+
/// *sole* trusted bundle. Users who want strict isolation should also
89+
/// restrict outbound traffic at the network layer.
8490
pub const SSL_CERT_FILE: &str = "SSL_CERT_FILE";
8591

8692
/// Path to a PEM bundle of extra CA certificates to trust for HTTPS.
8793
///
8894
/// Node.js convention; honored alongside `SSL_CERT_FILE` for setups that only
89-
/// configure the Node-flavored variable.
95+
/// configure the Node-flavored variable. Always additive to the system trust
96+
/// store.
9097
pub const NODE_EXTRA_CA_CERTS: &str = "NODE_EXTRA_CA_CERTS";
9198

9299
/// Disable HTTPS certificate verification in vp's shared HTTP client.

crates/vite_shared/src/http.rs

Lines changed: 143 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,32 @@
77
//!
88
//! Configuration sources (all read at first call):
99
//! - `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` — honored automatically by
10-
//! reqwest; no explicit wiring needed.
11-
//! - `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS` — each may point to a PEM bundle
12-
//! (one or more concatenated certs). Every cert is added as a trusted root.
13-
//! A read/parse failure logs a warning and is otherwise ignored so a
14-
//! malformed env var never blocks startup.
10+
//! reqwest. With the `system-proxy` feature enabled, macOS System Settings
11+
//! proxies and Windows registry proxies are also picked up.
12+
//! - `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS` — each may point to a PEM bundle.
13+
//! Every `-----BEGIN CERTIFICATE-----` block is parsed independently and
14+
//! added as an *additional* trusted root (system store is also kept —
15+
//! unlike OpenSSL's `SSL_CERT_FILE` which replaces it). Per-block parse
16+
//! failures emit a stderr warning and the remaining blocks are still added.
1517
//! - `VP_INSECURE_TLS` — when set to any value, disables cert verification
1618
//! entirely. Diagnostic escape hatch only; emits a loud stderr warning.
1719
18-
use std::sync::OnceLock;
20+
use std::{ffi::OsStr, path::Path, sync::OnceLock};
1921

2022
use crate::{env_vars, output};
2123

24+
const PEM_CERT_BEGIN: &[u8] = b"-----BEGIN CERTIFICATE-----";
25+
const PEM_CERT_END: &[u8] = b"-----END CERTIFICATE-----";
26+
2227
/// Get the process-wide `reqwest::Client`.
2328
///
2429
/// The client is built on first call and reused thereafter. See module docs
2530
/// for the env vars it honors.
31+
///
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.
2636
#[must_use]
2737
pub fn shared_http_client() -> &'static reqwest::Client {
2838
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
@@ -35,25 +45,46 @@ fn build_client() -> reqwest::Client {
3545
let mut builder = reqwest::Client::builder();
3646

3747
for var in [env_vars::SSL_CERT_FILE, env_vars::NODE_EXTRA_CA_CERTS] {
38-
let Ok(path) = std::env::var(var) else { continue };
39-
if path.is_empty() {
48+
let Some(value) = std::env::var_os(var) else { continue };
49+
if value.is_empty() || os_str_is_blank(&value) {
4050
continue;
4151
}
42-
match std::fs::read(&path) {
43-
Ok(bytes) => match reqwest::Certificate::from_pem_bundle(&bytes) {
44-
Ok(certs) => {
45-
for cert in certs {
46-
builder = builder.add_root_certificate(cert);
47-
}
52+
let path = Path::new(&value);
53+
let bytes = match std::fs::read(path) {
54+
Ok(bytes) => bytes,
55+
Err(err) => {
56+
output::warn(&vite_str::format!(
57+
"failed to read CA bundle from {var}={}: {err}",
58+
path.display()
59+
));
60+
continue;
61+
}
62+
};
63+
let blocks = extract_pem_cert_blocks(&bytes);
64+
if blocks.is_empty() {
65+
output::warn(&vite_str::format!(
66+
"no PEM certificate blocks found in {var}={}",
67+
path.display()
68+
));
69+
continue;
70+
}
71+
let mut added = 0_usize;
72+
for (idx, block) in blocks.iter().enumerate() {
73+
match reqwest::Certificate::from_pem(block) {
74+
Ok(cert) => {
75+
builder = builder.add_root_certificate(cert);
76+
added += 1;
4877
}
4978
Err(err) => {
50-
tracing::warn!("failed to parse extra CA bundle from {var}={path}: {err}");
79+
output::warn(&vite_str::format!(
80+
"failed to parse certificate #{} from {var}={}: {err}",
81+
idx + 1,
82+
path.display()
83+
));
5184
}
52-
},
53-
Err(err) => {
54-
tracing::warn!("failed to read extra CA bundle from {var}={path}: {err}");
5585
}
5686
}
87+
tracing::debug!("added {added} extra root certs from {var}");
5788
}
5889

5990
if std::env::var_os(env_vars::VP_INSECURE_TLS).is_some() {
@@ -64,5 +95,98 @@ fn build_client() -> reqwest::Client {
6495
builder = builder.danger_accept_invalid_certs(true);
6596
}
6697

67-
builder.build().expect("failed to build shared reqwest client")
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+
}
105+
}
106+
107+
fn os_str_is_blank(value: &OsStr) -> bool {
108+
value.to_str().is_some_and(|s| s.trim().is_empty())
109+
}
110+
111+
fn extract_pem_cert_blocks(bundle: &[u8]) -> Vec<&[u8]> {
112+
let mut blocks = Vec::new();
113+
let mut cursor = 0_usize;
114+
while cursor < bundle.len() {
115+
let Some(start_rel) =
116+
bundle[cursor..].windows(PEM_CERT_BEGIN.len()).position(|w| w == PEM_CERT_BEGIN)
117+
else {
118+
break;
119+
};
120+
let start = cursor + start_rel;
121+
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 {
125+
break;
126+
};
127+
let end = body_start + end_rel + PEM_CERT_END.len();
128+
blocks.push(&bundle[start..end]);
129+
cursor = end;
130+
}
131+
blocks
132+
}
133+
134+
#[cfg(test)]
135+
mod tests {
136+
use std::ffi::OsString;
137+
138+
use super::*;
139+
140+
const SAMPLE_CERT: &[u8] =
141+
b"-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHHIglt\n-----END CERTIFICATE-----";
142+
143+
#[test]
144+
fn os_str_is_blank_matches_whitespace_only() {
145+
assert!(os_str_is_blank(&OsString::from("")));
146+
assert!(os_str_is_blank(&OsString::from(" ")));
147+
assert!(os_str_is_blank(&OsString::from("\t\n")));
148+
assert!(!os_str_is_blank(&OsString::from("/etc/ssl/cert.pem")));
149+
}
150+
151+
#[test]
152+
fn extract_blocks_finds_single_cert() {
153+
let blocks = extract_pem_cert_blocks(SAMPLE_CERT);
154+
assert_eq!(blocks.len(), 1);
155+
assert_eq!(blocks[0], SAMPLE_CERT);
156+
}
157+
158+
#[test]
159+
fn extract_blocks_skips_non_cert_pem() {
160+
let bundle = b"\
161+
-----BEGIN PRIVATE KEY-----\n\
162+
ignored\n\
163+
-----END PRIVATE KEY-----\n\
164+
-----BEGIN CERTIFICATE-----\n\
165+
keepme\n\
166+
-----END CERTIFICATE-----\n";
167+
let blocks = extract_pem_cert_blocks(bundle);
168+
assert_eq!(blocks.len(), 1);
169+
assert!(blocks[0].starts_with(b"-----BEGIN CERTIFICATE-----"));
170+
assert!(blocks[0].ends_with(b"-----END CERTIFICATE-----"));
171+
}
172+
173+
#[test]
174+
fn extract_blocks_finds_multiple_certs() {
175+
let bundle = b"\
176+
-----BEGIN CERTIFICATE-----\n\
177+
one\n\
178+
-----END CERTIFICATE-----\n\
179+
junk in between\n\
180+
-----BEGIN CERTIFICATE-----\n\
181+
two\n\
182+
-----END CERTIFICATE-----\n";
183+
let blocks = extract_pem_cert_blocks(bundle);
184+
assert_eq!(blocks.len(), 2);
185+
}
186+
187+
#[test]
188+
fn extract_blocks_drops_unterminated_block() {
189+
let bundle = b"-----BEGIN CERTIFICATE-----\nno end marker\n";
190+
assert!(extract_pem_cert_blocks(bundle).is_empty());
191+
}
68192
}

0 commit comments

Comments
 (0)