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 | |
| 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')
| -rw-r--r-- | crates/mozart-core/Cargo.toml | 2 | ||||
| -rw-r--r-- | crates/mozart-core/src/http.rs | 198 | ||||
| -rw-r--r-- | crates/mozart-registry/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/mozart-registry/src/downloader.rs | 4 | ||||
| -rw-r--r-- | crates/mozart-registry/src/packagist.rs | 12 | ||||
| -rw-r--r-- | crates/mozart-vcs/src/driver/bitbucket.rs | 2 | ||||
| -rw-r--r-- | crates/mozart-vcs/src/driver/forgejo.rs | 2 | ||||
| -rw-r--r-- | crates/mozart-vcs/src/driver/github.rs | 2 | ||||
| -rw-r--r-- | crates/mozart-vcs/src/driver/gitlab.rs | 2 | ||||
| -rw-r--r-- | crates/mozart/src/commands.rs | 6 | ||||
| -rw-r--r-- | crates/mozart/src/commands/config_helpers.rs | 47 | ||||
| -rw-r--r-- | crates/mozart/src/commands/diagnose.rs | 6 | ||||
| -rw-r--r-- | crates/mozart/src/commands/self_update.rs | 6 |
13 files changed, 265 insertions, 25 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")); + } +} diff --git a/crates/mozart-registry/Cargo.toml b/crates/mozart-registry/Cargo.toml index 6816e8d..ceaaed0 100644 --- a/crates/mozart-registry/Cargo.toml +++ b/crates/mozart-registry/Cargo.toml @@ -17,7 +17,6 @@ flate2.workspace = true indexmap.workspace = true md5.workspace = true regex.workspace = true -reqwest.workspace = true serde.workspace = true serde_json.workspace = true sha1.workspace = true diff --git a/crates/mozart-registry/src/downloader.rs b/crates/mozart-registry/src/downloader.rs index c13ebdc..3cb991b 100644 --- a/crates/mozart-registry/src/downloader.rs +++ b/crates/mozart-registry/src/downloader.rs @@ -109,9 +109,7 @@ pub async fn download_dist( } } - let client = reqwest::Client::builder() - .user_agent(mozart_core::http::user_agent()) - .build()?; + let client = mozart_core::http::client_builder().build()?; let response = client.get(url).send().await?; tracing::debug!(status = %response.status(), "received response"); diff --git a/crates/mozart-registry/src/packagist.rs b/crates/mozart-registry/src/packagist.rs index 6b24589..504aa34 100644 --- a/crates/mozart-registry/src/packagist.rs +++ b/crates/mozart-registry/src/packagist.rs @@ -248,9 +248,7 @@ pub async fn fetch_package_versions( // Cache miss — fetch from Packagist let url = format!("https://repo.packagist.org/p2/{package_name}.json"); tracing::debug!(%url, "fetching package metadata"); - let client = reqwest::Client::builder() - .user_agent(mozart_core::http::user_agent()) - .build()?; + let client = mozart_core::http::client_builder().build()?; let response = client.get(&url).send().await?; tracing::debug!(status = %response.status(), "received response"); @@ -324,9 +322,7 @@ pub async fn search_packages( query: &str, package_type: Option<&str>, ) -> anyhow::Result<(Vec<SearchResult>, u64)> { - let client = reqwest::Client::builder() - .user_agent(mozart_core::http::user_agent()) - .build()?; + let client = mozart_core::http::client_builder().build()?; let mut all_results: Vec<SearchResult> = Vec::new(); let mut page = 1usize; @@ -440,9 +436,7 @@ pub struct SecurityAdvisoriesResponse { pub async fn fetch_security_advisories( package_names: &[&str], ) -> anyhow::Result<BTreeMap<String, Vec<SecurityAdvisory>>> { - let client = reqwest::Client::builder() - .user_agent(mozart_core::http::user_agent()) - .build()?; + let client = mozart_core::http::client_builder().build()?; let mut all_advisories: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); diff --git a/crates/mozart-vcs/src/driver/bitbucket.rs b/crates/mozart-vcs/src/driver/bitbucket.rs index 77704fa..0e67bc8 100644 --- a/crates/mozart-vcs/src/driver/bitbucket.rs +++ b/crates/mozart-vcs/src/driver/bitbucket.rs @@ -37,7 +37,7 @@ impl BitbucketDriver { branches: None, info_cache: IndexMap::new(), git_driver: None, - http_client: Client::new(), + http_client: mozart_core::http::default_client(), config, api_failed: false, vcs_type: "git".to_string(), diff --git a/crates/mozart-vcs/src/driver/forgejo.rs b/crates/mozart-vcs/src/driver/forgejo.rs index 488e165..665c177 100644 --- a/crates/mozart-vcs/src/driver/forgejo.rs +++ b/crates/mozart-vcs/src/driver/forgejo.rs @@ -42,7 +42,7 @@ impl ForgejoDriver { branches: None, info_cache: IndexMap::new(), git_driver: None, - http_client: Client::new(), + http_client: mozart_core::http::default_client(), config, api_failed: false, } diff --git a/crates/mozart-vcs/src/driver/github.rs b/crates/mozart-vcs/src/driver/github.rs index 9c11389..e968c3e 100644 --- a/crates/mozart-vcs/src/driver/github.rs +++ b/crates/mozart-vcs/src/driver/github.rs @@ -40,7 +40,7 @@ impl GitHubDriver { repo_data: None, info_cache: IndexMap::new(), git_driver: None, - http_client: Client::new(), + http_client: mozart_core::http::default_client(), config, api_failed: false, } diff --git a/crates/mozart-vcs/src/driver/gitlab.rs b/crates/mozart-vcs/src/driver/gitlab.rs index c1afbcb..937251a 100644 --- a/crates/mozart-vcs/src/driver/gitlab.rs +++ b/crates/mozart-vcs/src/driver/gitlab.rs @@ -44,7 +44,7 @@ impl GitLabDriver { branches: None, info_cache: IndexMap::new(), git_driver: None, - http_client: Client::new(), + http_client: mozart_core::http::default_client(), config, api_failed: false, } diff --git a/crates/mozart/src/commands.rs b/crates/mozart/src/commands.rs index c648754..504e38d 100644 --- a/crates/mozart/src/commands.rs +++ b/crates/mozart/src/commands.rs @@ -254,6 +254,12 @@ pub async fn execute(cli: &Cli) -> anyhow::Result<()> { cli.no_ansi, cli.no_interaction, ); + + // Initialize HTTPS root certificates from `config.cafile` / `config.capath` + // before any command makes a network request. + let tls_opts = config_helpers::load_tls_options(cli); + mozart_core::http::init_tls_options(&tls_opts)?; + let command = cli.command.as_ref().expect("command must be set"); match command { Commands::About(args) => about::execute(args, cli, &console).await, diff --git a/crates/mozart/src/commands/config_helpers.rs b/crates/mozart/src/commands/config_helpers.rs index 9b60129..422db4d 100644 --- a/crates/mozart/src/commands/config_helpers.rs +++ b/crates/mozart/src/commands/config_helpers.rs @@ -68,6 +68,53 @@ pub(crate) fn working_dir(cli: &super::Cli) -> anyhow::Result<PathBuf> { } } +/// Read TLS-related options (`config.cafile`, `config.capath`) from the merged +/// global + local config. Local values override global. Relative paths are +/// resolved against the directory of the config file that defined them. +pub(crate) fn load_tls_options(cli: &super::Cli) -> mozart_core::http::TlsOptions { + let mut opts = mozart_core::http::TlsOptions::default(); + + let home = composer_home(); + apply_tls_from_file(&home.join("config.json"), &home, &mut opts); + + if let Ok(wd) = working_dir(cli) { + apply_tls_from_file(&wd.join("composer.json"), &wd, &mut opts); + } + + opts +} + +fn apply_tls_from_file(path: &Path, base_dir: &Path, opts: &mut mozart_core::http::TlsOptions) { + let Ok(content) = std::fs::read_to_string(path) else { + return; + }; + let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else { + return; + }; + let Some(cfg) = json.get("config").and_then(|v| v.as_object()) else { + return; + }; + if let Some(s) = cfg.get("cafile").and_then(|v| v.as_str()) + && !s.is_empty() + { + opts.cafile = Some(resolve_relative(s, base_dir)); + } + if let Some(s) = cfg.get("capath").and_then(|v| v.as_str()) + && !s.is_empty() + { + opts.capath = Some(resolve_relative(s, base_dir)); + } +} + +fn resolve_relative(path: &str, base: &Path) -> PathBuf { + let p = Path::new(path); + if p.is_absolute() { + p.to_path_buf() + } else { + base.join(p) + } +} + /// Read a JSON file as `serde_json::Value`. /// If the file does not exist, return a default skeleton: /// `{"config": {}}` for global files, `{}` for local. diff --git a/crates/mozart/src/commands/diagnose.rs b/crates/mozart/src/commands/diagnose.rs index bb6e886..2320ddc 100644 --- a/crates/mozart/src/commands/diagnose.rs +++ b/crates/mozart/src/commands/diagnose.rs @@ -92,9 +92,8 @@ async fn check_http_connectivity(url: &str) -> CheckResult { return CheckResult::Skip("COMPOSER_DISABLE_NETWORK is set".to_string()); } - let client = match reqwest::Client::builder() + let client = match mozart_core::http::client_builder() .timeout(std::time::Duration::from_secs(10)) - .user_agent(mozart_core::http::user_agent()) .build() { Ok(c) => c, @@ -120,9 +119,8 @@ async fn check_github_api() -> CheckResult { return CheckResult::Skip("COMPOSER_DISABLE_NETWORK is set".to_string()); } - let client = match reqwest::Client::builder() + let client = match mozart_core::http::client_builder() .timeout(std::time::Duration::from_secs(10)) - .user_agent(mozart_core::http::user_agent()) .build() { Ok(c) => c, diff --git a/crates/mozart/src/commands/self_update.rs b/crates/mozart/src/commands/self_update.rs index afc77f3..2c7c59b 100644 --- a/crates/mozart/src/commands/self_update.rs +++ b/crates/mozart/src/commands/self_update.rs @@ -150,9 +150,8 @@ fn version_from_backup(path: &Path) -> String { async fn fetch_releases(include_prerelease: bool) -> anyhow::Result<Vec<GitHubRelease>> { let url = format!("{GITHUB_API_BASE}/{GITHUB_REPO}/releases"); - let client = reqwest::Client::builder() + let client = mozart_core::http::client_builder() .timeout(std::time::Duration::from_secs(30)) - .user_agent(mozart_core::http::user_agent()) .build() .map_err(|e| anyhow::anyhow!("Could not build HTTP client: {e}"))?; @@ -230,9 +229,8 @@ async fn download_asset( show_progress: bool, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - let client = reqwest::Client::builder() + let client = mozart_core::http::client_builder() .timeout(std::time::Duration::from_secs(300)) - .user_agent(mozart_core::http::user_agent()) .build() .map_err(|e| anyhow::anyhow!("Could not build HTTP client: {e}"))?; |
