aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock3
-rw-r--r--crates/mozart-core/Cargo.toml2
-rw-r--r--crates/mozart-core/src/http.rs198
-rw-r--r--crates/mozart-registry/Cargo.toml1
-rw-r--r--crates/mozart-registry/src/downloader.rs4
-rw-r--r--crates/mozart-registry/src/packagist.rs12
-rw-r--r--crates/mozart-vcs/src/driver/bitbucket.rs2
-rw-r--r--crates/mozart-vcs/src/driver/forgejo.rs2
-rw-r--r--crates/mozart-vcs/src/driver/github.rs2
-rw-r--r--crates/mozart-vcs/src/driver/gitlab.rs2
-rw-r--r--crates/mozart/src/commands.rs6
-rw-r--r--crates/mozart/src/commands/config_helpers.rs47
-rw-r--r--crates/mozart/src/commands/diagnose.rs6
-rw-r--r--crates/mozart/src/commands/self_update.rs6
14 files changed, 267 insertions, 26 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 5b06248..6fda00e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1166,9 +1166,11 @@ dependencies = [
"mozart-console-macros",
"mozart-spdx-licenses",
"regex",
+ "reqwest",
"serde",
"serde_json",
"tempfile",
+ "tracing",
]
[[package]]
@@ -1200,7 +1202,6 @@ dependencies = [
"mozart-test-harness",
"mozart-vcs",
"regex",
- "reqwest",
"serde",
"serde_json",
"sha1",
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}"))?;