Summary
The ca_cert_file constructor argument is implemented by writing to the process-wide environment. The value outlives the client that set it, leaks into child processes, and silently changes which CA roots every subsequently-constructed client trusts.
Cause
src/lib.rs:194-197:
// Ca_cert_file. BEFORE!!! verify (fn load_ca_certs() reads env var HTTPR_CA_BUNDLE)
if let Some(ca_bundle_path) = &ca_cert_file {
std::env::set_var("HTTPR_CA_BUNDLE", ca_bundle_path);
}
load_ca_certs() (src/utils.rs:9) then reads that env var. The per-client argument is routed through global mutable state, and it is never cleared. Results are not cached, so every later client re-reads whatever path was left behind.
Reproduction
import os, subprocess, sys, httpr
try:
httpr.Client(ca_cert_file="/nonexistent/a.pem")
except Exception as e:
print("ctor raised:", type(e).__name__)
subprocess.run([sys.executable, "-c",
"import os; print('child sees:', os.environ.get('HTTPR_CA_BUNDLE'))"])
httpr.Client() # no ca_cert_file at all
Observed:
ctor raised: ConnectError
child sees: /nonexistent/a.pem <- leaked to the child process
ConnectError: Failed to read CA certificates from /nonexistent/a.pem <- from the *second*, unconfigured client
A plain httpr.Client() — which asked for nothing — now fails because of a path a different client set earlier.
Note os.environ in the parent still reads None: CPython caches os.environ at import, so Rust's setenv isn't visible there. The child process proves the real process environment was mutated.
With a valid CA path the failure is worse because it's silent: later clients quietly trust a different root set than the built-in one they'd expect.
Additional concern
std::env::set_var is not thread-safe. Concurrently reading the environment from another thread (any getenv, including from C libraries) while it runs is undefined behaviour — this is why it became unsafe in Rust edition 2024. The crate is on edition 2021, so it compiles today, but this is a latent hazard and a blocker for an edition bump.
Expected
ca_cert_file scopes to the client that specified it, like verify=/REQUESTS_CA_BUNDLE handling in httpx and requests. Reading HTTPR_CA_BUNDLE from the environment as a default is fine; writing to it is not.
Proposed fix
Change load_ca_certs() to take the path as a parameter rather than reading a global:
pub fn load_ca_certs(path: Option<&str>) -> Result<Vec<Certificate>>
In new(), resolve the effective path once as ca_cert_file.or_else(|| std::env::var("HTTPR_CA_BUNDLE").ok()) and pass it down — matching how proxy already falls back to HTTPR_PROXY at src/lib.rs:176 without writing back to the environment. Delete the set_var call.
The #[cfg(test)] tests in src/utils.rs that set the env var will need updating to pass the path directly.
Suggested tests
- Constructing a client with
ca_cert_file does not change the environment seen by a child process.
- Client A with
ca_cert_file=<path> followed by client B with none: B is unaffected.
Size
~1 hour.
Summary
The
ca_cert_fileconstructor argument is implemented by writing to the process-wide environment. The value outlives the client that set it, leaks into child processes, and silently changes which CA roots every subsequently-constructed client trusts.Cause
src/lib.rs:194-197:load_ca_certs()(src/utils.rs:9) then reads that env var. The per-client argument is routed through global mutable state, and it is never cleared. Results are not cached, so every later client re-reads whatever path was left behind.Reproduction
Observed:
A plain
httpr.Client()— which asked for nothing — now fails because of a path a different client set earlier.Note
os.environin the parent still readsNone: CPython cachesos.environat import, so Rust'ssetenvisn't visible there. The child process proves the real process environment was mutated.With a valid CA path the failure is worse because it's silent: later clients quietly trust a different root set than the built-in one they'd expect.
Additional concern
std::env::set_varis not thread-safe. Concurrently reading the environment from another thread (anygetenv, including from C libraries) while it runs is undefined behaviour — this is why it becameunsafein Rust edition 2024. The crate is on edition 2021, so it compiles today, but this is a latent hazard and a blocker for an edition bump.Expected
ca_cert_filescopes to the client that specified it, likeverify=/REQUESTS_CA_BUNDLEhandling inhttpxandrequests. ReadingHTTPR_CA_BUNDLEfrom the environment as a default is fine; writing to it is not.Proposed fix
Change
load_ca_certs()to take the path as a parameter rather than reading a global:In
new(), resolve the effective path once asca_cert_file.or_else(|| std::env::var("HTTPR_CA_BUNDLE").ok())and pass it down — matching howproxyalready falls back toHTTPR_PROXYatsrc/lib.rs:176without writing back to the environment. Delete theset_varcall.The
#[cfg(test)]tests insrc/utils.rsthat set the env var will need updating to pass the path directly.Suggested tests
ca_cert_filedoes not change the environment seen by a child process.ca_cert_file=<path>followed by client B with none: B is unaffected.Size
~1 hour.