Skip to content

Commit be990ac

Browse files
authored
Merge pull request #413 from blyxxyz/better-rustls-errors
Improve rustls errors for invalid certificates
2 parents 24afadb + 3002033 commit be990ac

File tree

6 files changed

+140
-47
lines changed

6 files changed

+140
-47
lines changed

Cargo.lock

+7-6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ serde_urlencoded = "0.7.0"
4848
supports-hyperlinks = "3.0.0"
4949
termcolor = "1.1.2"
5050
time = "0.3.16"
51+
humantime = "2.2.0"
5152
unicode-width = "0.1.9"
5253
url = "2.2.2"
5354
ruzstd = { version = "0.7", default-features = false, features = ["std"]}
@@ -56,7 +57,7 @@ log = "0.4.21"
5657

5758
# Enable logging in transitive dependencies.
5859
# The rustls version number should be kept in sync with hyper/reqwest.
59-
rustls = { version = "0.23.14", optional = true, default-features = false, features = ["logging"] }
60+
rustls = { version = "0.23.25", optional = true, default-features = false, features = ["logging"] }
6061
tracing = { version = "0.1.41", default-features = false, features = ["log"] }
6162
reqwest_cookie_store = { version = "0.8.0", features = ["serde"] }
6263

src/error_reporting.rs

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
use std::process::ExitCode;
2+
3+
pub(crate) fn additional_messages(err: &anyhow::Error, native_tls: bool) -> Vec<String> {
4+
let mut msgs = Vec::new();
5+
6+
#[cfg(feature = "rustls")]
7+
msgs.extend(format_rustls_error(err));
8+
9+
if native_tls && err.root_cause().to_string() == "invalid minimum TLS version for backend" {
10+
msgs.push("Try running without the --native-tls flag.".into());
11+
}
12+
13+
msgs
14+
}
15+
16+
/// Format certificate expired/not valid yet messages. By default these print
17+
/// human-unfriendly Unix timestamps.
18+
///
19+
/// Other rustls error messages (e.g. wrong host) are readable enough.
20+
#[cfg(feature = "rustls")]
21+
fn format_rustls_error(err: &anyhow::Error) -> Option<String> {
22+
use humantime::format_duration;
23+
use rustls::pki_types::UnixTime;
24+
use rustls::CertificateError;
25+
use time::OffsetDateTime;
26+
27+
// Multiple layers of io::Error for some reason?
28+
// This may be fragile
29+
let err = err.root_cause().downcast_ref::<std::io::Error>()?;
30+
let err = err.get_ref()?.downcast_ref::<std::io::Error>()?;
31+
let err = err.get_ref()?.downcast_ref::<rustls::Error>()?;
32+
let rustls::Error::InvalidCertificate(err) = err else {
33+
return None;
34+
};
35+
36+
fn conv_time(unix_time: &UnixTime) -> Option<OffsetDateTime> {
37+
OffsetDateTime::from_unix_timestamp(unix_time.as_secs() as i64).ok()
38+
}
39+
40+
match err {
41+
CertificateError::ExpiredContext { time, not_after } => {
42+
let time = conv_time(time)?;
43+
let not_after = conv_time(not_after)?;
44+
let diff = format_duration((time - not_after).try_into().ok()?);
45+
Some(format!(
46+
"Certificate not valid after {not_after} ({diff} ago).",
47+
))
48+
}
49+
CertificateError::NotValidYetContext { time, not_before } => {
50+
let time = conv_time(time)?;
51+
let not_before = conv_time(not_before)?;
52+
let diff = format_duration((not_before - time).try_into().ok()?);
53+
Some(format!(
54+
"Certificate not valid before {not_before} ({diff} from now).",
55+
))
56+
}
57+
_ => None,
58+
}
59+
}
60+
61+
pub(crate) fn exit_code(err: &anyhow::Error) -> ExitCode {
62+
if let Some(err) = err.downcast_ref::<reqwest::Error>() {
63+
if err.is_timeout() {
64+
return ExitCode::from(2);
65+
}
66+
}
67+
68+
if err
69+
.downcast_ref::<crate::redirect::TooManyRedirects>()
70+
.is_some()
71+
{
72+
return ExitCode::from(6);
73+
}
74+
75+
ExitCode::FAILURE
76+
}

src/main.rs

+23-30
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod buffer;
44
mod cli;
55
mod decoder;
66
mod download;
7+
mod error_reporting;
78
mod formatting;
89
mod generation;
910
mod middleware;
@@ -22,7 +23,7 @@ use std::fs::File;
2223
use std::io::{self, IsTerminal, Read, Write as _};
2324
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
2425
use std::path::PathBuf;
25-
use std::process;
26+
use std::process::ExitCode;
2627
use std::str::FromStr;
2728
use std::sync::Arc;
2829

@@ -61,7 +62,7 @@ fn get_user_agent() -> &'static str {
6162
}
6263
}
6364

64-
fn main() {
65+
fn main() -> ExitCode {
6566
let args = Cli::parse();
6667

6768
if args.debug {
@@ -77,39 +78,30 @@ fn main() {
7778
let bin_name = args.bin_name.clone();
7879

7980
match run(args) {
80-
Ok(exit_code) => {
81-
process::exit(exit_code);
82-
}
81+
Ok(exit_code) => exit_code,
8382
Err(err) => {
8483
log::debug!("{err:#?}");
8584
eprintln!("{bin_name}: error: {err:?}");
86-
let msg = err.root_cause().to_string();
87-
if native_tls && msg == "invalid minimum TLS version for backend" {
85+
86+
for message in error_reporting::additional_messages(&err, native_tls) {
8887
eprintln!();
89-
eprintln!("Try running without the --native-tls flag.");
90-
}
91-
if let Some(err) = err.downcast_ref::<reqwest::Error>() {
92-
if err.is_timeout() {
93-
process::exit(2);
94-
}
88+
eprintln!("{message}");
9589
}
96-
if msg.starts_with("Too many redirects") {
97-
process::exit(6);
98-
}
99-
process::exit(1);
90+
91+
error_reporting::exit_code(&err)
10092
}
10193
}
10294
}
10395

104-
fn run(args: Cli) -> Result<i32> {
96+
fn run(args: Cli) -> Result<ExitCode> {
10597
if let Some(generate) = args.generate {
10698
generation::generate(&args.bin_name, generate);
107-
return Ok(0);
99+
return Ok(ExitCode::SUCCESS);
108100
}
109101

110102
if args.curl {
111103
to_curl::print_curl_translation(args)?;
112-
return Ok(0);
104+
return Ok(ExitCode::SUCCESS);
113105
}
114106

115107
let (mut headers, headers_to_unset) = args.request_items.headers()?;
@@ -189,7 +181,7 @@ fn run(args: Cli) -> Result<i32> {
189181
return Err(anyhow!("This binary was built without native-tls support"));
190182
}
191183

192-
let mut exit_code: i32 = 0;
184+
let mut failure_code = None;
193185
let mut resume: Option<u64> = None;
194186
let mut auth = None;
195187
let mut save_auth_in_session = true;
@@ -622,16 +614,17 @@ fn run(args: Cli) -> Result<i32> {
622614

623615
let status = response.status();
624616
if args.check_status.unwrap_or(!args.httpie_compat_mode) {
625-
exit_code = match status.as_u16() {
626-
300..=399 if !args.follow => 3,
627-
400..=499 => 4,
628-
500..=599 => 5,
629-
_ => 0,
630-
};
617+
match status.as_u16() {
618+
300..=399 if !args.follow => failure_code = Some(ExitCode::from(3)),
619+
400..=499 => failure_code = Some(ExitCode::from(4)),
620+
500..=599 => failure_code = Some(ExitCode::from(5)),
621+
_ => (),
622+
}
623+
631624
// Print this if the status code isn't otherwise ending up in the terminal.
632625
// HTTPie looks at --quiet, since --quiet always suppresses the response
633626
// headers even if you pass --print=h. But --print takes precedence for us.
634-
if exit_code != 0 && (is_output_redirected || !print.response_headers) {
627+
if failure_code.is_some() && (is_output_redirected || !print.response_headers) {
635628
log::warn!("HTTP {} {}", status.as_u16(), reason_phrase(&response));
636629
}
637630
}
@@ -640,7 +633,7 @@ fn run(args: Cli) -> Result<i32> {
640633
printer.print_response_headers(&response)?;
641634
}
642635
if args.download {
643-
if exit_code == 0 {
636+
if failure_code.is_none() {
644637
download_file(
645638
response,
646639
args.output,
@@ -670,7 +663,7 @@ fn run(args: Cli) -> Result<i32> {
670663
.with_context(|| format!("couldn't persist session {}", s.path.display()))?;
671664
}
672665

673-
Ok(exit_code)
666+
Ok(failure_code.unwrap_or(ExitCode::SUCCESS))
674667
}
675668

676669
/// Configure backtraces for standard panics and anyhow using `$RUST_BACKTRACE`.

src/redirect.rs

+22-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use anyhow::{anyhow, Result};
1+
use anyhow::Result;
22
use reqwest::blocking::{Request, Response};
33
use reqwest::header::{
44
HeaderMap, AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, COOKIE, LOCATION,
@@ -31,10 +31,10 @@ impl Middleware for RedirectFollower {
3131
if remaining_redirects > 0 {
3232
remaining_redirects -= 1;
3333
} else {
34-
return Err(anyhow!(
35-
"Too many redirects (--max-redirects={})",
36-
self.max_redirects
37-
));
34+
return Err(TooManyRedirects {
35+
max_redirects: self.max_redirects,
36+
}
37+
.into());
3838
}
3939
log::info!("Following redirect to {}", next_request.url());
4040
log::trace!("Remaining redirects: {}", remaining_redirects);
@@ -48,6 +48,23 @@ impl Middleware for RedirectFollower {
4848
}
4949
}
5050

51+
#[derive(Debug)]
52+
pub(crate) struct TooManyRedirects {
53+
max_redirects: usize,
54+
}
55+
56+
impl std::fmt::Display for TooManyRedirects {
57+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58+
write!(
59+
f,
60+
"Too many redirects (--max-redirects={})",
61+
self.max_redirects,
62+
)
63+
}
64+
}
65+
66+
impl std::error::Error for TooManyRedirects {}
67+
5168
// See https://github.com/seanmonstar/reqwest/blob/bbeb1ede4e8098481c3de6f2cafb8ecca1db4ede/src/async_impl/client.rs#L1500-L1607
5269
fn get_next_request(mut request: Request, response: &Response) -> Option<Request> {
5370
let get_next_url = |request: &Request| {

tests/cli.rs

+10-5
Original file line numberDiff line numberDiff line change
@@ -1138,7 +1138,6 @@ fn proxy_multiple_valid_proxies() {
11381138

11391139
// temporarily disabled for builds not using rustls
11401140
#[cfg(all(feature = "online-tests", feature = "rustls"))]
1141-
#[ignore = "endpoint is randomly timing out"]
11421141
#[test]
11431142
fn verify_default_yes() {
11441143
use predicates::boolean::PredicateBooleanExt;
@@ -1154,7 +1153,6 @@ fn verify_default_yes() {
11541153

11551154
// temporarily disabled for builds not using rustls
11561155
#[cfg(all(feature = "online-tests", feature = "rustls"))]
1157-
#[ignore = "endpoint is randomly timing out"]
11581156
#[test]
11591157
fn verify_explicit_yes() {
11601158
use predicates::boolean::PredicateBooleanExt;
@@ -1169,7 +1167,6 @@ fn verify_explicit_yes() {
11691167
}
11701168

11711169
#[cfg(feature = "online-tests")]
1172-
#[ignore = "endpoint is randomly timing out"]
11731170
#[test]
11741171
fn verify_no() {
11751172
get_command()
@@ -1181,7 +1178,6 @@ fn verify_no() {
11811178
}
11821179

11831180
#[cfg(all(feature = "rustls", feature = "online-tests"))]
1184-
#[ignore = "endpoint is randomly timing out"]
11851181
#[test]
11861182
fn verify_valid_file() {
11871183
get_command()
@@ -1197,7 +1193,6 @@ fn verify_valid_file() {
11971193
// This test may fail if https://github.com/seanmonstar/reqwest/issues/1260 is fixed
11981194
// If that happens make sure to remove the warning, not just this test
11991195
#[cfg(all(feature = "native-tls", feature = "online-tests"))]
1200-
#[ignore = "endpoint is randomly timing out"]
12011196
#[test]
12021197
fn verify_valid_file_native_tls() {
12031198
get_command()
@@ -1218,6 +1213,16 @@ fn cert_without_key() {
12181213
.stderr(predicates::str::is_empty());
12191214
}
12201215

1216+
#[cfg(all(feature = "rustls", feature = "online-tests"))]
1217+
#[test]
1218+
fn formatted_certificate_expired_message() {
1219+
get_command()
1220+
.arg("https://expired.badssl.com")
1221+
.assert()
1222+
.failure()
1223+
.stderr(contains("Certificate not valid after 2015-04-12"));
1224+
}
1225+
12211226
#[test]
12221227
fn override_dns_resolution() {
12231228
let server = server::http(|req| async move {

0 commit comments

Comments
 (0)