upd-cli 0.1.5__tar.gz → 0.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. {upd_cli-0.1.5 → upd_cli-0.1.6}/CHANGELOG.md +21 -0
  2. {upd_cli-0.1.5 → upd_cli-0.1.6}/Cargo.lock +1 -1
  3. {upd_cli-0.1.5 → upd_cli-0.1.6}/Cargo.toml +1 -1
  4. {upd_cli-0.1.5 → upd_cli-0.1.6}/PKG-INFO +1 -1
  5. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/audit/mod.rs +13 -8
  6. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/cli.rs +31 -0
  7. upd_cli-0.1.6/src/http.rs +516 -0
  8. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/lib.rs +1 -0
  9. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/main.rs +39 -6
  10. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/registry/crates_io.rs +17 -15
  11. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/registry/github_releases.rs +9 -7
  12. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/registry/go_proxy.rs +12 -10
  13. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/registry/mod.rs +2 -2
  14. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/registry/npm.rs +12 -9
  15. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/registry/nuget.rs +9 -7
  16. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/registry/pypi.rs +13 -11
  17. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/registry/rubygems.rs +9 -7
  18. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/registry/terraform.rs +18 -14
  19. {upd_cli-0.1.5 → upd_cli-0.1.6}/.mise.toml +0 -0
  20. {upd_cli-0.1.5 → upd_cli-0.1.6}/.pre-commit-config.yaml +0 -0
  21. {upd_cli-0.1.5 → upd_cli-0.1.6}/.pre-commit-hooks.yaml +0 -0
  22. {upd_cli-0.1.5 → upd_cli-0.1.6}/.rumdl.toml +0 -0
  23. {upd_cli-0.1.5 → upd_cli-0.1.6}/LICENSE +0 -0
  24. {upd_cli-0.1.5 → upd_cli-0.1.6}/Makefile +0 -0
  25. {upd_cli-0.1.5 → upd_cli-0.1.6}/README.md +0 -0
  26. {upd_cli-0.1.5 → upd_cli-0.1.6}/assets/logo-wide.svg +0 -0
  27. {upd_cli-0.1.5 → upd_cli-0.1.6}/assets/logo.svg +0 -0
  28. {upd_cli-0.1.5 → upd_cli-0.1.6}/pyproject.toml +0 -0
  29. {upd_cli-0.1.5 → upd_cli-0.1.6}/python/upd_cli/__init__.py +0 -0
  30. {upd_cli-0.1.5 → upd_cli-0.1.6}/python/upd_cli/__main__.py +0 -0
  31. {upd_cli-0.1.5 → upd_cli-0.1.6}/python/upd_cli/py.typed +0 -0
  32. {upd_cli-0.1.5 → upd_cli-0.1.6}/rust-toolchain.toml +0 -0
  33. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/align.rs +0 -0
  34. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/audit/cache.rs +0 -0
  35. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/audit/cvss.rs +0 -0
  36. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/cache.rs +0 -0
  37. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/config.rs +0 -0
  38. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/cooldown.rs +0 -0
  39. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/interactive.rs +0 -0
  40. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/lockfile.rs +0 -0
  41. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/output.rs +0 -0
  42. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/registry/mock.rs +0 -0
  43. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/registry/utils.rs +0 -0
  44. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/cargo_toml.rs +0 -0
  45. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/csproj.rs +0 -0
  46. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/gemfile.rs +0 -0
  47. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/github_actions.rs +0 -0
  48. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/go_mod.rs +0 -0
  49. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/mise.rs +0 -0
  50. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/mod.rs +0 -0
  51. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/npm_range.rs +0 -0
  52. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/package_json.rs +0 -0
  53. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/pre_commit.rs +0 -0
  54. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/pyproject.rs +0 -0
  55. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/requirements.rs +0 -0
  56. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/updater/terraform.rs +0 -0
  57. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/version/compare.rs +0 -0
  58. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/version/mod.rs +0 -0
  59. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/version/pep440.rs +0 -0
  60. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/version/semver_util.rs +0 -0
  61. {upd_cli-0.1.5 → upd_cli-0.1.6}/src/version/tag.rs +0 -0
  62. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/audit_offline.rs +0 -0
  63. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/audit_sarif.rs +0 -0
  64. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/audit_severity.rs +0 -0
  65. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/bump_filter.rs +0 -0
  66. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/cooldown_e2e.rs +0 -0
  67. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/discovery_no_ignore.rs +0 -0
  68. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/exit_codes.rs +0 -0
  69. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/fix_audit.rs +0 -0
  70. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/format_json.rs +0 -0
  71. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/help_text.rs +0 -0
  72. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/interactive_tty.rs +0 -0
  73. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/invalid_positional.rs +0 -0
  74. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/no_args_scope.rs +0 -0
  75. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/output_streams.rs +0 -0
  76. {upd_cli-0.1.5 → upd_cli-0.1.6}/tests/package_filter.rs +0 -0
  77. {upd_cli-0.1.5 → upd_cli-0.1.6}/vership.toml +0 -0
@@ -15,6 +15,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
15
 
16
16
 
17
17
 
18
+
19
+ ## [0.1.6](https://github.com/rvben/upd/compare/v0.1.5...v0.1.6) - 2026-04-29
20
+
21
+ ### Added
22
+
23
+ - **http**: wire TLS init into networked subcommand entry points ([04f7b0d](https://github.com/rvben/upd/commit/04f7b0d6362f32ff2a6212386f310b481bfd7d56))
24
+ - **cli**: add --insecure global flag ([df1943f](https://github.com/rvben/upd/commit/df1943ff53369b0cc0b32401cbbedb1095f07230))
25
+ - **http**: attach TLS hint to send errors via wrap_send_err ([9b1fd2c](https://github.com/rvben/upd/commit/9b1fd2cef7e307a6d3328074a82353a772a5b44c))
26
+ - **http**: apply TLS options to ClientBuilder ([7d8f616](https://github.com/rvben/upd/commit/7d8f6161f5f7be31e829f89ba9aaeb332532229f))
27
+ - **http**: implement init() over pure helpers ([79556c6](https://github.com/rvben/upd/commit/79556c6d5a34cf5eda97e645b4d62c62ad706809))
28
+ - **http**: detect TLS trust failures in error chains ([e63be5d](https://github.com/rvben/upd/commit/e63be5d62cfa82f2a73f3e78e294c65868c53ba7))
29
+ - **http**: parse PEM CA bundle with multi-cert support ([07e3983](https://github.com/rvben/upd/commit/07e3983a9477dad042ea0f5b10ee8efa9382b384))
30
+ - **http**: resolve CA bundle path from env vars ([2bd42ec](https://github.com/rvben/upd/commit/2bd42ec7018094e1daf399af8fb2e7e288e9e206))
31
+ - **http**: scaffold TLS options module ([3f797cc](https://github.com/rvben/upd/commit/3f797cc658298b9b952e566b49d4998a4decfc64))
32
+
33
+ ### Fixed
34
+
35
+ - **http**: propagate --insecure global flag to self-update ([88b397e](https://github.com/rvben/upd/commit/88b397ee4a169eab94b56247050e8b1814c8ccf3))
36
+ - **http**: defer TLS init past offline and no-op early returns ([41cfb40](https://github.com/rvben/upd/commit/41cfb40f2f72e6090fc0f083c13371c3a4269347))
37
+ - **http**: skip CA bundle resolution when --insecure is set ([8d73e27](https://github.com/rvben/upd/commit/8d73e27aadaa5ac488bbbf7f556476cf9bfaa0e6))
38
+
18
39
  ## [0.1.5](https://github.com/rvben/upd/compare/v0.1.4...v0.1.5) - 2026-04-28
19
40
 
20
41
  ### Added
@@ -2024,7 +2024,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
2024
2024
 
2025
2025
  [[package]]
2026
2026
  name = "upd"
2027
- version = "0.1.5"
2027
+ version = "0.1.6"
2028
2028
  dependencies = [
2029
2029
  "anyhow",
2030
2030
  "async-trait",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "upd"
3
- version = "0.1.5"
3
+ version = "0.1.6"
4
4
  edition = "2024"
5
5
  rust-version = "1.95.0"
6
6
  description = "A fast dependency updater for Python, Node.js, Rust, Go, Ruby, Terraform, GitHub Actions, pre-commit, and Mise projects"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upd-cli
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -203,10 +203,11 @@ impl OsvClient {
203
203
  /// Create a client pointing at a custom base URL (used by tests).
204
204
  pub fn with_base_url(base_url: String) -> Self {
205
205
  Self {
206
- client: Client::builder()
207
- .timeout(Duration::from_secs(30))
208
- .build()
209
- .expect("Failed to create HTTP client. This usually indicates a TLS/SSL configuration issue on your system."),
206
+ client: crate::http::apply(
207
+ Client::builder().timeout(Duration::from_secs(30))
208
+ )
209
+ .build()
210
+ .expect("Failed to create HTTP client. This usually indicates a TLS/SSL configuration issue on your system."),
210
211
  base_url,
211
212
  }
212
213
  }
@@ -333,12 +334,14 @@ impl OsvClient {
333
334
 
334
335
  let request = OsvBatchRequest { queries };
335
336
 
337
+ let url = format!("{}/querybatch", self.base_url);
336
338
  let mut response = self
337
339
  .client
338
- .post(format!("{}/querybatch", self.base_url))
340
+ .post(&url)
339
341
  .json(&request)
340
342
  .send()
341
- .await?;
343
+ .await
344
+ .map_err(|e| crate::http::wrap_send_err(e, &url))?;
342
345
 
343
346
  if !response.status().is_success() {
344
347
  let status = response.status();
@@ -397,11 +400,13 @@ impl OsvClient {
397
400
 
398
401
  /// Fetch vulnerability details by ID
399
402
  async fn fetch_vuln_by_id(&self, id: &str) -> Result<Vulnerability> {
403
+ let url = format!("{}/vulns/{}", self.base_url, id);
400
404
  let response = self
401
405
  .client
402
- .get(format!("{}/vulns/{}", self.base_url, id))
406
+ .get(&url)
403
407
  .send()
404
- .await?;
408
+ .await
409
+ .map_err(|e| crate::http::wrap_send_err(e, &url))?;
405
410
 
406
411
  if !response.status().is_success() {
407
412
  anyhow::bail!(
@@ -192,6 +192,16 @@ pub struct Cli {
192
192
  /// Explicit file paths are always processed regardless of this flag.
193
193
  #[arg(long = "no-ignore", global = true)]
194
194
  pub no_ignore: bool,
195
+
196
+ /// Disable TLS certificate verification.
197
+ ///
198
+ /// Skips verification of server certificates for all HTTPS requests this run.
199
+ /// Hostname verification is also disabled on a best-effort basis (may not be
200
+ /// honored under every TLS backend). Use only as a last resort in environments
201
+ /// where the system trust store cannot be configured. Prefer setting
202
+ /// REQUESTS_CA_BUNDLE or SSL_CERT_FILE to your corporate CA bundle.
203
+ #[arg(long, global = true)]
204
+ pub insecure: bool,
195
205
  }
196
206
 
197
207
  #[derive(Subcommand)]
@@ -297,6 +307,7 @@ mod tests {
297
307
  assert!(cli.paths.is_empty());
298
308
  assert!(cli.command.is_none());
299
309
  assert!(cli.min_age.is_none());
310
+ assert!(!cli.insecure);
300
311
  }
301
312
 
302
313
  #[test]
@@ -874,4 +885,24 @@ mod tests {
874
885
  assert!(cli.no_ignore);
875
886
  assert!(matches!(cli.command, Some(Command::Align { .. })));
876
887
  }
888
+
889
+ #[test]
890
+ fn test_cli_parses_insecure() {
891
+ let cli = Cli::try_parse_from(["upd", "--insecure"]).unwrap();
892
+ assert!(cli.insecure);
893
+ }
894
+
895
+ #[test]
896
+ fn test_cli_insecure_default_false() {
897
+ let cli = Cli::try_parse_from(["upd"]).unwrap();
898
+ assert!(!cli.insecure);
899
+ }
900
+
901
+ #[test]
902
+ fn test_cli_insecure_is_global_across_subcommands() {
903
+ for sub in ["audit", "update", "align"] {
904
+ let cli = Cli::try_parse_from(["upd", sub, "--insecure"]).unwrap();
905
+ assert!(cli.insecure, "--insecure must be accepted under `{sub}`");
906
+ }
907
+ }
877
908
  }
@@ -0,0 +1,516 @@
1
+ //! TLS configuration for HTTP clients.
2
+ //!
3
+ //! This module owns a process-global `HttpOptions` (initialized once per networked
4
+ //! subcommand) describing extra CA certificates and an `--insecure` flag. Each
5
+ //! `Client::builder()` chain in the codebase calls [`apply`] to inherit those options.
6
+ //!
7
+ //! Pure helpers ([`resolve_ca_path`], [`parse_pem_bundle`], [`chain_indicates_tls_failure`])
8
+ //! contain the testable logic; [`init`] is a thin shell over them.
9
+
10
+ use anyhow::{Context, Result};
11
+ use reqwest::{Certificate, ClientBuilder};
12
+ use std::path::{Path, PathBuf};
13
+ use std::sync::OnceLock;
14
+
15
+ const CA_BUNDLE_ENV_VARS: &[&str] = &[
16
+ "UPD_CA_BUNDLE",
17
+ "REQUESTS_CA_BUNDLE",
18
+ "CURL_CA_BUNDLE",
19
+ "SSL_CERT_FILE",
20
+ ];
21
+
22
+ #[derive(Debug, Default)]
23
+ pub struct HttpOptions {
24
+ pub insecure: bool,
25
+ /// PEM-parsed certificates loaded once at startup from env-var-resolved paths.
26
+ pub extra_certs: Vec<Certificate>,
27
+ }
28
+
29
+ static HTTP_OPTIONS: OnceLock<HttpOptions> = OnceLock::new();
30
+ static DEFAULT_OPTIONS: OnceLock<HttpOptions> = OnceLock::new();
31
+
32
+ /// Resolve which CA-bundle env var holds the path we should load.
33
+ ///
34
+ /// Returns the first non-empty value among `UPD_CA_BUNDLE`, `REQUESTS_CA_BUNDLE`,
35
+ /// `CURL_CA_BUNDLE`, `SSL_CERT_FILE` (in that order), or `None` when none are set.
36
+ /// Empty strings are treated as unset.
37
+ pub(crate) fn resolve_ca_path<E>(env: E) -> Option<PathBuf>
38
+ where
39
+ E: Fn(&str) -> Option<String>,
40
+ {
41
+ CA_BUNDLE_ENV_VARS
42
+ .iter()
43
+ .filter_map(|name| env(name))
44
+ .find(|v| !v.is_empty())
45
+ .map(PathBuf::from)
46
+ }
47
+
48
+ /// Parse one or more PEM-encoded certificates from a byte buffer.
49
+ ///
50
+ /// Treats `from_pem_bundle` failures as fatal. An empty buffer (or a buffer with
51
+ /// no `BEGIN CERTIFICATE` markers) returns an `Err` so callers can surface a
52
+ /// meaningful diagnostic — silently producing an empty cert list would mask a
53
+ /// misconfigured CA bundle path.
54
+ pub(crate) fn parse_pem_bundle(bytes: &[u8]) -> Result<Vec<Certificate>> {
55
+ if bytes.is_empty() {
56
+ anyhow::bail!("CA bundle is empty (no PEM data)");
57
+ }
58
+ let certs =
59
+ Certificate::from_pem_bundle(bytes).context("failed to parse PEM certificate bundle")?;
60
+ if certs.is_empty() {
61
+ anyhow::bail!("no certificates found in PEM bundle");
62
+ }
63
+ Ok(certs)
64
+ }
65
+
66
+ const TLS_MARKERS: &[&str] = &[
67
+ "unknown issuer",
68
+ "unknownissuer",
69
+ "invalidcertificate",
70
+ "self-signed",
71
+ "self signed",
72
+ "certificate verify",
73
+ "certificateverification",
74
+ "tls handshake",
75
+ "certnotvalidforname",
76
+ "certificate not valid for name",
77
+ "certexpired",
78
+ "cert expired",
79
+ "certnotvalidyet",
80
+ "certrevoked",
81
+ "unknownrevocationstatus",
82
+ ];
83
+
84
+ /// Walk an error chain and return true if any source's `Display` contains a
85
+ /// known TLS-trust-failure marker (case-insensitive).
86
+ ///
87
+ /// Generic over `dyn std::error::Error` so it can be unit-tested with synthetic
88
+ /// error types — `reqwest::Error` has no public constructor for arbitrary chains.
89
+ pub(crate) fn chain_indicates_tls_failure(err: &(dyn std::error::Error + 'static)) -> bool {
90
+ let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
91
+ while let Some(e) = current {
92
+ let msg = e.to_string().to_lowercase();
93
+ if TLS_MARKERS.iter().any(|m| msg.contains(m)) {
94
+ return true;
95
+ }
96
+ current = e.source();
97
+ }
98
+ false
99
+ }
100
+
101
+ /// Compute the extra-certs vector that `init` would set, given injectable env
102
+ /// and file-read closures. Pulled out so the short-circuit and error paths can
103
+ /// be exercised hermetically without touching the process-global `OnceLock`.
104
+ fn compute_extra_certs<E, R>(insecure: bool, env: E, read: R) -> Result<Vec<Certificate>>
105
+ where
106
+ E: Fn(&str) -> Option<String>,
107
+ R: Fn(&Path) -> std::io::Result<Vec<u8>>,
108
+ {
109
+ if insecure {
110
+ return Ok(Vec::new());
111
+ }
112
+ let Some(p) = resolve_ca_path(env) else {
113
+ return Ok(Vec::new());
114
+ };
115
+ let bytes = read(&p).with_context(|| format!("failed to read CA bundle at {}", p.display()))?;
116
+ parse_pem_bundle(&bytes)
117
+ .with_context(|| format!("failed to parse CA bundle at {}", p.display()))
118
+ }
119
+
120
+ /// Initialize TLS options. Called from the entry point of every networked
121
+ /// subcommand (`run_update`, `run_align`, `run_audit`, `self_update`) before
122
+ /// any [`reqwest::Client`] is built.
123
+ ///
124
+ /// Reads CA paths from `UPD_CA_BUNDLE` → `REQUESTS_CA_BUNDLE` → `CURL_CA_BUNDLE` →
125
+ /// `SSL_CERT_FILE` (priority order; first non-empty wins). Returns `Err` with the
126
+ /// path in the message if the chosen file cannot be read or parsed.
127
+ ///
128
+ /// When `insecure` is true, env-var-resolved bundles are not read or parsed: the
129
+ /// user has opted out of verification entirely, so a stale or malformed bundle
130
+ /// path must not block the run.
131
+ ///
132
+ /// **Library callers:** clients constructed before `init` is called see
133
+ /// [`HttpOptions::default`] forever — only clients constructed after `init`
134
+ /// pick up the configured CAs and `--insecure` flag.
135
+ pub fn init(insecure: bool) -> Result<()> {
136
+ let extra_certs =
137
+ compute_extra_certs(insecure, |k| std::env::var(k).ok(), |p| std::fs::read(p))?;
138
+ // OnceLock::set is fallible if already set; that's fine — first init wins, later
139
+ // calls in the same process are silent no-ops (the value is already correct).
140
+ let _ = HTTP_OPTIONS.set(HttpOptions {
141
+ insecure,
142
+ extra_certs,
143
+ });
144
+ Ok(())
145
+ }
146
+
147
+ /// Returns the initialized options, or [`HttpOptions::default`] if `init` was
148
+ /// never called (the contract used by library tests).
149
+ pub fn options() -> &'static HttpOptions {
150
+ HTTP_OPTIONS
151
+ .get()
152
+ .unwrap_or_else(|| DEFAULT_OPTIONS.get_or_init(HttpOptions::default))
153
+ }
154
+
155
+ /// Apply the configured TLS options to a [`ClientBuilder`].
156
+ pub fn apply(mut builder: ClientBuilder) -> ClientBuilder {
157
+ let opts = options();
158
+ for cert in &opts.extra_certs {
159
+ builder = builder.add_root_certificate(cert.clone());
160
+ }
161
+ if opts.insecure {
162
+ builder = builder
163
+ .danger_accept_invalid_certs(true)
164
+ .danger_accept_invalid_hostnames(true);
165
+ }
166
+ builder
167
+ }
168
+
169
+ /// Build the user-facing TLS hint for a given URL.
170
+ fn tls_hint(url: &str) -> String {
171
+ let host = url::Url::parse(url)
172
+ .ok()
173
+ .and_then(|u| u.host_str().map(str::to_owned))
174
+ .unwrap_or_else(|| url.to_string());
175
+ format!(
176
+ "TLS certificate verification failed for {host}. \
177
+ If you're behind a corporate proxy, install your CA into the system trust store \
178
+ or set REQUESTS_CA_BUNDLE / SSL_CERT_FILE to your CA bundle path. \
179
+ As a last resort, pass --insecure to skip verification (not recommended)."
180
+ )
181
+ }
182
+
183
+ /// Map a [`reqwest::Error`] from `.send()` into an [`anyhow::Error`], attaching
184
+ /// a TLS-trust hint when the error chain indicates a certificate-verification
185
+ /// failure.
186
+ pub fn wrap_send_err(err: reqwest::Error, url: &str) -> anyhow::Error {
187
+ if chain_indicates_tls_failure(&err) {
188
+ let hint = tls_hint(url);
189
+ anyhow::Error::from(err).context(hint)
190
+ } else {
191
+ anyhow::Error::from(err)
192
+ }
193
+ }
194
+
195
+ #[cfg(test)]
196
+ mod tests {
197
+ use super::*;
198
+
199
+ fn fake_env<'a>(map: &'a [(&'a str, Option<&'a str>)]) -> impl Fn(&str) -> Option<String> + 'a {
200
+ move |k: &str| {
201
+ map.iter()
202
+ .find(|(name, _)| *name == k)
203
+ .and_then(|(_, v)| v.map(str::to_owned))
204
+ }
205
+ }
206
+
207
+ #[test]
208
+ fn test_resolve_ca_path_priority_order() {
209
+ // UPD_CA_BUNDLE wins
210
+ let env = fake_env(&[
211
+ ("UPD_CA_BUNDLE", Some("/upd")),
212
+ ("REQUESTS_CA_BUNDLE", Some("/requests")),
213
+ ("CURL_CA_BUNDLE", Some("/curl")),
214
+ ("SSL_CERT_FILE", Some("/ssl")),
215
+ ]);
216
+ assert_eq!(resolve_ca_path(&env), Some(PathBuf::from("/upd")));
217
+
218
+ // Without UPD_CA_BUNDLE, REQUESTS_CA_BUNDLE wins
219
+ let env = fake_env(&[
220
+ ("UPD_CA_BUNDLE", None),
221
+ ("REQUESTS_CA_BUNDLE", Some("/requests")),
222
+ ("CURL_CA_BUNDLE", Some("/curl")),
223
+ ("SSL_CERT_FILE", Some("/ssl")),
224
+ ]);
225
+ assert_eq!(resolve_ca_path(&env), Some(PathBuf::from("/requests")));
226
+
227
+ // Down to SSL_CERT_FILE
228
+ let env = fake_env(&[("SSL_CERT_FILE", Some("/ssl"))]);
229
+ assert_eq!(resolve_ca_path(&env), Some(PathBuf::from("/ssl")));
230
+ }
231
+
232
+ #[test]
233
+ fn test_resolve_ca_path_skips_empty_strings() {
234
+ let env = fake_env(&[
235
+ ("UPD_CA_BUNDLE", Some("")),
236
+ ("REQUESTS_CA_BUNDLE", Some("/requests")),
237
+ ]);
238
+ assert_eq!(resolve_ca_path(&env), Some(PathBuf::from("/requests")));
239
+ }
240
+
241
+ #[test]
242
+ fn test_resolve_ca_path_returns_none_when_all_unset() {
243
+ let env = fake_env(&[]);
244
+ assert_eq!(resolve_ca_path(&env), None);
245
+ }
246
+
247
+ const TEST_CERT_1: &[u8] = br#"-----BEGIN CERTIFICATE-----
248
+ MIIDCzCCAfOgAwIBAgIUKZ0KJZK5GE4JM+j0245augoh8OwwDQYJKoZIhvcNAQEL
249
+ BQAwFTETMBEGA1UEAwwKdXBkLXRlc3QtMTAeFw0yNjA0MjkwODI5MjhaFw0zNjA0
250
+ MjYwODI5MjhaMBUxEzARBgNVBAMMCnVwZC10ZXN0LTEwggEiMA0GCSqGSIb3DQEB
251
+ AQUAA4IBDwAwggEKAoIBAQDc44mX7cV5VLqtFGPGnEjjRLC+vO6Tk2SSO3eC9S3M
252
+ 6Ryy3sAl11ZeSLzBOhdE3rZC4+oZjiy7Ht8TJuz8XxMH4ASUrfdkO7CyJfbxE5FJ
253
+ FAb+ZbHE9K+SKiCiVPd367v05Tra2P31+k3qFYnqH9jklwj1RwdMNhNLNUDd0f7I
254
+ xiTEGnSEfNUy1opwAcwYdRIAYAWi9TQxLy6+JyVHaDSG9s05SbinKaRWb/5Elopc
255
+ QJZ0fa8caCW7t+6caQQNE4pbgwj9iLSstyo28MAiZcQ7vgOzxCvyxf/fhQbbuCjg
256
+ uCUlTb9QsToXjF6GrQ6ZY5ze0KslQIa1KHfEtt8C9PnlAgMBAAGjUzBRMB0GA1Ud
257
+ DgQWBBSMYjXeEDAT+plKMitN3kuxrtU+eTAfBgNVHSMEGDAWgBSMYjXeEDAT+plK
258
+ MitN3kuxrtU+eTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB3
259
+ k413BI7F3FEOsjaCaSqr+Cp+xaPytGFxwjB7y7lXk3Ep0sCwSO99QDze+hXAWY+L
260
+ 32JHgNXjvKccgfb+VP8Cv7XR5vNBmOm8RXfE90r3YwgPQo2GfAK3KAclFabil9ek
261
+ Dhjy52Y3Kw3S1ZfjizJbKlT0WvLLIaFVAHxOKCxwTNnYzu9j4rF2krE74yTdaPBm
262
+ bOIOTW/JL8Xlux+k0jV+BvLYK8/rlKgjUlCUlQFwKfQVVTagnh66jQbvtLqGT/1z
263
+ 5AQ1MkjwT9arAOD+xWKXd3F1e2uEFl8QTGSqCTv85gKFM2BAVS04295SUUfllbNd
264
+ wVzHoiFTA+cbUjjOxjGM
265
+ -----END CERTIFICATE-----
266
+ "#;
267
+ const TEST_CERT_2: &[u8] = br#"-----BEGIN CERTIFICATE-----
268
+ MIIDCzCCAfOgAwIBAgIUTRxLSgg2B1Nv53xT2+s2LrPEze4wDQYJKoZIhvcNAQEL
269
+ BQAwFTETMBEGA1UEAwwKdXBkLXRlc3QtMjAeFw0yNjA0MjkwODI5MjhaFw0zNjA0
270
+ MjYwODI5MjhaMBUxEzARBgNVBAMMCnVwZC10ZXN0LTIwggEiMA0GCSqGSIb3DQEB
271
+ AQUAA4IBDwAwggEKAoIBAQDaP51Ef0k52LrqC9PZ1kW/XlSqNXuTLgHeOPFPJmxQ
272
+ lVQ9/3dRAE3gnJUZmOsoZ5lgceNhBuPArrHJytDMUFJ5auRZhF1i7LOSRYF9B7KW
273
+ gQmlniAI9+rYBMTgc/2+PnnB3dGHcg23sVwtYWzTW6DZl6z28cpUXgLES7sSPi+0
274
+ hleTvfsEW2idjFcZO6sOFEeyPA3PJUA0YtWdKLANRp5kIEEqQO5Bln8MOEbl6r2O
275
+ vJOZnUTCoqP1Y2xExAj2gUw/+CWzAjC0rp4uPKUWP0ckHGlbCm7KmqxYLE6o0obg
276
+ c9vXxyeZ0FbZLc60e/mB3iakxJWa8DK1T9DH7gt5cXBfAgMBAAGjUzBRMB0GA1Ud
277
+ DgQWBBQLAmfP5m+XU+67B7/u6l89s6p7wTAfBgNVHSMEGDAWgBQLAmfP5m+XU+67
278
+ B7/u6l89s6p7wTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBW
279
+ L0eKTSQx7KJ4F5N7WDgdZvp3QtonBWIgQzRxgWEpS8oQyS0Jtr5esOmCSMbPnPNj
280
+ 2xSE001niyxlsgjd4/UQ3P9Kj8jHfElnNB/qjSGPWzTRb/T+2LLXWQZ3ptcN8p4O
281
+ JeCGZf6GyCJEu9ToiPddNZl0B9IELCUSBJ8QPbIVBRHCIbCDIe5bIt3gJKBnK+Vl
282
+ 8eUKTvF8cnnvw2Nr0umbxCoVjbmbyKpL/3ZsT70QF4eyQZ0JAmYQg7Ufx+pj09FX
283
+ j6KbS4KlY8roCKAlQEAxJ1qXTvl2/6QcWHvux1nue0KcBfHvFBAIHfNVUj7NslXX
284
+ uMhJbUlN9AYtL2pAGNPK
285
+ -----END CERTIFICATE-----
286
+ "#;
287
+
288
+ fn two_cert_bundle() -> Vec<u8> {
289
+ let mut out = Vec::with_capacity(TEST_CERT_1.len() + TEST_CERT_2.len());
290
+ out.extend_from_slice(TEST_CERT_1);
291
+ out.extend_from_slice(TEST_CERT_2);
292
+ out
293
+ }
294
+
295
+ #[test]
296
+ fn test_parse_pem_bundle_loads_multiple_certs() {
297
+ let bytes = two_cert_bundle();
298
+ let certs = parse_pem_bundle(&bytes).expect("two-cert bundle must parse");
299
+ assert_eq!(certs.len(), 2, "expected 2 certs, got {}", certs.len());
300
+ }
301
+
302
+ #[test]
303
+ fn test_parse_pem_bundle_rejects_garbage() {
304
+ let bytes = b"this is not a PEM file at all";
305
+ let err = parse_pem_bundle(bytes).expect_err("garbage must not parse");
306
+ let msg = err.to_string().to_lowercase();
307
+ assert!(
308
+ msg.contains("pem") || msg.contains("certificate"),
309
+ "expected PEM-related error, got: {msg}"
310
+ );
311
+ }
312
+
313
+ #[test]
314
+ fn test_parse_pem_bundle_handles_empty_input() {
315
+ let err = parse_pem_bundle(&[]).expect_err("empty input must not parse");
316
+ let msg = err.to_string().to_lowercase();
317
+ assert!(
318
+ msg.contains("empty") || msg.contains("no certificate") || msg.contains("pem"),
319
+ "expected empty/no-cert error, got: {msg}"
320
+ );
321
+ }
322
+
323
+ use std::error::Error as StdError;
324
+ use std::fmt;
325
+
326
+ #[derive(Debug)]
327
+ struct TestError {
328
+ msg: String,
329
+ source: Option<Box<dyn StdError + Send + Sync + 'static>>,
330
+ }
331
+
332
+ impl TestError {
333
+ fn new(msg: &str) -> Self {
334
+ Self {
335
+ msg: msg.to_string(),
336
+ source: None,
337
+ }
338
+ }
339
+ fn with_source(mut self, src: Box<dyn StdError + Send + Sync + 'static>) -> Self {
340
+ self.source = Some(src);
341
+ self
342
+ }
343
+ }
344
+
345
+ impl fmt::Display for TestError {
346
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347
+ f.write_str(&self.msg)
348
+ }
349
+ }
350
+
351
+ impl StdError for TestError {
352
+ fn source(&self) -> Option<&(dyn StdError + 'static)> {
353
+ self.source
354
+ .as_deref()
355
+ .map(|s| s as &(dyn StdError + 'static))
356
+ }
357
+ }
358
+
359
+ #[test]
360
+ fn test_chain_indicates_tls_failure_for_each_marker() {
361
+ let markers = [
362
+ "UnknownIssuer",
363
+ "unknown issuer",
364
+ "InvalidCertificate",
365
+ "invalidcertificate",
366
+ "self-signed",
367
+ "self signed",
368
+ "certificate verify failed",
369
+ "CertificateVerification",
370
+ "tls handshake failure",
371
+ "CertNotValidForName",
372
+ "certificate not valid for name",
373
+ "CertExpired",
374
+ "cert expired",
375
+ "CertNotValidYet",
376
+ "CertRevoked",
377
+ "UnknownRevocationStatus",
378
+ ];
379
+ for m in markers {
380
+ let e = TestError::new(m);
381
+ assert!(
382
+ chain_indicates_tls_failure(&e),
383
+ "expected marker {m:?} to indicate TLS failure"
384
+ );
385
+ }
386
+ }
387
+
388
+ #[test]
389
+ fn test_chain_indicates_tls_failure_passthrough() {
390
+ for msg in [
391
+ "connection refused",
392
+ "dns lookup failed",
393
+ "timed out",
394
+ "operation cancelled",
395
+ "broken pipe",
396
+ ] {
397
+ let e = TestError::new(msg);
398
+ assert!(
399
+ !chain_indicates_tls_failure(&e),
400
+ "non-TLS message {msg:?} must not match"
401
+ );
402
+ }
403
+ }
404
+
405
+ #[test]
406
+ fn test_chain_indicates_tls_failure_walks_sources() {
407
+ let inner = TestError::new("InvalidCertificate(UnknownIssuer)");
408
+ let outer = TestError::new("error sending request").with_source(Box::new(inner));
409
+ assert!(
410
+ chain_indicates_tls_failure(&outer),
411
+ "matcher must walk Error::source"
412
+ );
413
+ }
414
+
415
+ use serial_test::serial;
416
+
417
+ #[test]
418
+ #[serial]
419
+ fn test_init_smoke_default() {
420
+ // Note: `init` only sets the OnceLock the FIRST time across the test process.
421
+ // This test asserts the post-init state is sane regardless of whether it ran
422
+ // first; we don't assert the exact value of `insecure` since another #[serial]
423
+ // test in this process may have already initialized it.
424
+ let _ = init(false);
425
+ let opts = options();
426
+ let _ = opts.insecure;
427
+ let _ = &opts.extra_certs;
428
+ }
429
+
430
+ #[test]
431
+ #[serial]
432
+ fn test_options_default_when_uninitialized() {
433
+ // This test only meaningfully runs in a process where init() was never called.
434
+ // Under nextest's default per-test process model this is hermetic. Under
435
+ // `cargo test`'s shared process, we tolerate either default or initialized state.
436
+ let opts = options();
437
+ let _ = opts.insecure;
438
+ let _ = &opts.extra_certs;
439
+ }
440
+
441
+ #[test]
442
+ fn test_init_bad_path_via_helpers() {
443
+ // We can't safely invoke `init()` with a custom env in the test process
444
+ // because of the OnceLock. Instead, exercise the underlying composition:
445
+ // resolve → read → parse, which is exactly what `init` does internally.
446
+ let path = PathBuf::from("/this/path/definitely/does/not/exist/upd-ca.pem");
447
+ let read_err = std::fs::read(&path).expect_err("read of missing path must fail");
448
+ assert_eq!(read_err.kind(), std::io::ErrorKind::NotFound);
449
+ }
450
+
451
+ #[test]
452
+ fn test_compute_extra_certs_insecure_short_circuits() {
453
+ // With insecure=true, env vars and file reads must not be consulted at all.
454
+ // We assert this by passing closures that would panic if called.
455
+ let env =
456
+ |_: &str| -> Option<String> { panic!("env should not be read when insecure=true") };
457
+ let read = |_: &Path| -> std::io::Result<Vec<u8>> {
458
+ panic!("read should not be called when insecure=true")
459
+ };
460
+ let certs = compute_extra_certs(true, env, read).expect("insecure must succeed");
461
+ assert!(certs.is_empty(), "insecure mode must yield no extra certs");
462
+ }
463
+
464
+ #[test]
465
+ fn test_compute_extra_certs_insecure_ignores_broken_bundle() {
466
+ // Even with a stale env var pointing at a bogus path, insecure=true must succeed.
467
+ let env = fake_env(&[("UPD_CA_BUNDLE", Some("/nope/missing.pem"))]);
468
+ let read = |_: &Path| -> std::io::Result<Vec<u8>> {
469
+ Err(std::io::Error::from(std::io::ErrorKind::NotFound))
470
+ };
471
+ let certs = compute_extra_certs(true, env, read).expect("insecure must ignore bad bundle");
472
+ assert!(certs.is_empty());
473
+ }
474
+
475
+ #[test]
476
+ fn test_compute_extra_certs_secure_propagates_read_error_with_path() {
477
+ // With insecure=false, a read error must surface and include the resolved path.
478
+ let env = fake_env(&[("UPD_CA_BUNDLE", Some("/nope/missing.pem"))]);
479
+ let read = |_: &Path| -> std::io::Result<Vec<u8>> {
480
+ Err(std::io::Error::from(std::io::ErrorKind::NotFound))
481
+ };
482
+ let err = compute_extra_certs(false, env, read).expect_err("missing path must error");
483
+ let chain = format!("{err:#}");
484
+ assert!(
485
+ chain.contains("/nope/missing.pem"),
486
+ "error should mention path, got: {chain}"
487
+ );
488
+ assert!(
489
+ chain.to_lowercase().contains("failed to read"),
490
+ "error should describe read failure, got: {chain}"
491
+ );
492
+ }
493
+
494
+ #[test]
495
+ fn test_compute_extra_certs_no_env_returns_empty() {
496
+ let env = fake_env(&[]);
497
+ let read = |_: &Path| -> std::io::Result<Vec<u8>> {
498
+ panic!("read should not be called when no env var is set")
499
+ };
500
+ let certs = compute_extra_certs(false, env, read).expect("no env must succeed");
501
+ assert!(certs.is_empty());
502
+ }
503
+
504
+ #[test]
505
+ fn test_apply_with_default_options_builds() {
506
+ // Contract test: we can't introspect a ClientBuilder's TLS state, so the
507
+ // best we can do is assert that the chain compiles and produces a usable
508
+ // Client. With default options (no extra certs, not insecure), apply
509
+ // must be a no-op that doesn't break the builder.
510
+ let client = apply(reqwest::Client::builder()).build();
511
+ assert!(
512
+ client.is_ok(),
513
+ "apply(builder).build() must succeed: {client:?}"
514
+ );
515
+ }
516
+ }
@@ -4,6 +4,7 @@ pub mod cache;
4
4
  pub mod cli;
5
5
  pub mod config;
6
6
  pub mod cooldown;
7
+ pub mod http;
7
8
  pub mod interactive;
8
9
  pub mod lockfile;
9
10
  pub mod output;