diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-04 13:59:49 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-04 13:59:49 +0900 |
| commit | e6e4f6f6319b39ba3020f96d070b637054c04b6a (patch) | |
| tree | 216423fc61716825f1eded1163e17264dbff3ea1 /crates/mozart-core | |
| parent | 30b222e7324e933f1e1bde5c5e34d588e36adbaa (diff) | |
| download | php-mozart-e6e4f6f6319b39ba3020f96d070b637054c04b6a.tar.gz php-mozart-e6e4f6f6319b39ba3020f96d070b637054c04b6a.tar.zst php-mozart-e6e4f6f6319b39ba3020f96d070b637054c04b6a.zip | |
feat(http): honor config.cafile and config.capath
Composer's config.cafile/config.capath were accepted by the config
command but ignored by every HTTP request. Centralize reqwest client
construction in mozart_core::http, pre-load the configured CA bundle
at startup, and route every callsite (registry, vcs drivers, diagnose,
self-update) through the shared builder so user-supplied roots are
actually used during HTTPS verification.
Diffstat (limited to 'crates/mozart-core')
| -rw-r--r-- | crates/mozart-core/Cargo.toml | 2 | ||||
| -rw-r--r-- | crates/mozart-core/src/http.rs | 198 |
2 files changed, 200 insertions, 0 deletions
diff --git a/crates/mozart-core/Cargo.toml b/crates/mozart-core/Cargo.toml index fb114ff..afdde29 100644 --- a/crates/mozart-core/Cargo.toml +++ b/crates/mozart-core/Cargo.toml @@ -10,8 +10,10 @@ anyhow.workspace = true colored.workspace = true dialoguer.workspace = true regex.workspace = true +reqwest.workspace = true serde.workspace = true serde_json.workspace = true +tracing.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/mozart-core/src/http.rs b/crates/mozart-core/src/http.rs index ebd28f9..7d3de8e 100644 --- a/crates/mozart-core/src/http.rs +++ b/crates/mozart-core/src/http.rs @@ -1,3 +1,8 @@ +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use anyhow::{Context, Result, anyhow, bail}; + /// Returns the common User-Agent string for all HTTP requests. /// /// Format: `Mozart/<version> (<os>; <arch>)` @@ -9,3 +14,196 @@ pub fn user_agent() -> String { std::env::consts::ARCH, ) } + +/// TLS verification options, mirroring Composer's `config.cafile` and +/// `config.capath`. +#[derive(Debug, Default, Clone)] +pub struct TlsOptions { + pub cafile: Option<PathBuf>, + pub capath: Option<PathBuf>, +} + +/// Pre-parsed root certificates, loaded once from `cafile`/`capath` and shared +/// across every reqwest client built via [`client_builder`]. +static EXTRA_ROOT_CERTS: OnceLock<Vec<reqwest::Certificate>> = OnceLock::new(); + +/// Initialize the process-wide TLS options. +/// +/// Reads `cafile` and `capath` (if set), parses every certificate up-front, +/// and stores the parsed [`reqwest::Certificate`] list in a global so that +/// subsequent [`client_builder`] calls are infallible. +/// +/// May be called at most once; subsequent calls are silently ignored. This +/// matches the lifetime of the binary's HTTP configuration: load on startup, +/// reuse for the rest of the process. +pub fn init_tls_options(opts: &TlsOptions) -> Result<()> { + if EXTRA_ROOT_CERTS.get().is_some() { + return Ok(()); + } + let mut certs = Vec::new(); + if let Some(ref cafile) = opts.cafile { + certs.extend(load_cafile(cafile)?); + } + if let Some(ref capath) = opts.capath { + certs.extend(load_capath(capath)?); + } + let _ = EXTRA_ROOT_CERTS.set(certs); + Ok(()) +} + +fn load_cafile(path: &Path) -> Result<Vec<reqwest::Certificate>> { + let pem = std::fs::read(path).with_context(|| { + format!( + "The configured cafile {} could not be read.", + path.display() + ) + })?; + let certs = reqwest::Certificate::from_pem_bundle(&pem) + .with_context(|| format!("The configured cafile {} was not valid.", path.display()))?; + if certs.is_empty() { + bail!( + "The configured cafile {} did not contain any certificates.", + path.display() + ); + } + Ok(certs) +} + +fn load_capath(path: &Path) -> Result<Vec<reqwest::Certificate>> { + let metadata = std::fs::metadata(path).with_context(|| { + format!( + "The configured capath {} could not be accessed.", + path.display() + ) + })?; + if !metadata.is_dir() { + return Err(anyhow!( + "The configured capath {} is not a directory.", + path.display() + )); + } + let mut out = Vec::new(); + let entries = std::fs::read_dir(path).with_context(|| { + format!( + "The configured capath {} could not be read.", + path.display() + ) + })?; + for entry in entries { + let entry = + entry.with_context(|| format!("Failed to enumerate capath {}", path.display()))?; + let entry_path = entry.path(); + if !entry_path.is_file() { + continue; + } + let Ok(pem) = std::fs::read(&entry_path) else { + continue; + }; + match reqwest::Certificate::from_pem_bundle(&pem) { + Ok(parsed) => out.extend(parsed), + Err(e) => { + tracing::debug!( + path = %entry_path.display(), + error = %e, + "skipping non-PEM file in capath" + ); + } + } + } + Ok(out) +} + +/// Returns a [`reqwest::ClientBuilder`] preconfigured with Mozart's User-Agent +/// and any extra root certificates registered via [`init_tls_options`]. +pub fn client_builder() -> reqwest::ClientBuilder { + let mut b = reqwest::Client::builder().user_agent(user_agent()); + if let Some(certs) = EXTRA_ROOT_CERTS.get() { + for cert in certs { + b = b.add_root_certificate(cert.clone()); + } + } + b +} + +/// Build a default [`reqwest::Client`] with Mozart's User-Agent and any +/// configured root certificates. Panics on build failure, matching +/// [`reqwest::Client::new`] semantics. +pub fn default_client() -> reqwest::Client { + client_builder() + .build() + .expect("failed to build default HTTP client") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + // A self-signed PEM cert generated for testing only. + // + // $ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \ + // -keyout key.pem -out cert.pem -days 365 -nodes \ + // -subj "/CN=localhost" \ + // -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1" + const TEST_PEM: &[u8] = b"\ +-----BEGIN CERTIFICATE----- +MIIBpjCCAUygAwIBAgIUF1tLFV2l2URaYf1oYgEMs89bv8owCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUwNDA0NTU1OVoXDTI3MDUwNDA0 +NTU1OVowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEAFZrTfAdhntykKL3WTL/hGHnBQhxv1205XRWnXzMwWSaow9R+VIEKZRw +kwrKKPM04RlpiwqCbJOV/IutFvQHvqN8MHowHQYDVR0OBBYEFLryrLkUMiRWV9yF +Dj7paTV/36+/MB8GA1UdIwQYMBaAFLryrLkUMiRWV9yFDj7paTV/36+/MA8GA1Ud +EwEB/wQFMAMBAf8wJwYDVR0RBCAwHoIJbG9jYWxob3N0ggsqLmxvY2FsaG9zdIcE +fwAAATAKBggqhkjOPQQDAgNIADBFAiEAhgdXBmYJYqipYwiDM1SKiXDg2bwN9YLu +zbjOBz0kJ14CIA+tqV3c2sYRJhqwLu7phihPef38zcG70ADcz5o2VQnk +-----END CERTIFICATE----- +"; + + #[test] + fn user_agent_includes_version() { + let ua = user_agent(); + assert!(ua.starts_with("Mozart/")); + } + + #[test] + fn load_cafile_parses_pem_bundle() { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(TEST_PEM).unwrap(); + let certs = load_cafile(f.path()).expect("valid PEM should parse"); + assert_eq!(certs.len(), 1); + } + + #[test] + fn load_cafile_missing_file_errors() { + let err = load_cafile(Path::new("/nonexistent/path/to/cafile.pem")).unwrap_err(); + assert!(err.to_string().contains("could not be read")); + } + + #[test] + fn load_cafile_invalid_pem_errors() { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(b"this is not a PEM file\n").unwrap(); + let err = load_cafile(f.path()).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("not valid") || msg.contains("did not contain"), + "unexpected error message: {msg}" + ); + } + + #[test] + fn load_capath_reads_pem_files_and_skips_others() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("ca.pem"), TEST_PEM).unwrap(); + std::fs::write(dir.path().join("README.txt"), b"not a cert").unwrap(); + let certs = load_capath(dir.path()).expect("should succeed"); + assert_eq!(certs.len(), 1); + } + + #[test] + fn load_capath_rejects_file_path() { + let f = tempfile::NamedTempFile::new().unwrap(); + let err = load_capath(f.path()).unwrap_err(); + assert!(err.to_string().contains("not a directory")); + } +} |
