Skip to content

Commit b40be8b

Browse files
committed
Auto merge of #12310 - weihanglo:asymmetric-challenge, r=Eh2406
feat(crates-io): expose HTTP headers and Error type ### What does this PR try to resolve? This is part of #11521. [RFC 3231] mentions the authentication process could have an additional **challenge-response mechanism** to prevent potential replay attacks. The challenge usually comes from HTTP `www-authenticate` header as a opaque string. When a client gets a 401/403 response with such a challenge, it may attach the `challenge` to the payload and request again to anwser the challenge. ``` ➡️ cargo requests ⬅️ server responds with `www-authenticate` containing some opaque challenge string ➡️ cargo automatically requests again without any user perception ⬅️ server responds ok ``` However, `crates-io` crate doesn't expose HTTP headers. There is no access to `www-authenticate` header. This PR make it expose HTTP headers and the custom `Error` type, so `cargo` can access and do further on the authentication process. [RFC 3231]: https://rust-lang.github.io/rfcs/3231-cargo-asymmetric-tokens.html#the-authentication-process
2 parents 00b8da6 + 31b500c commit b40be8b

File tree

5 files changed

+87
-95
lines changed

5 files changed

+87
-95
lines changed

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ cargo-util = { version = "0.2.5", path = "crates/cargo-util" }
2727
cargo_metadata = "0.14.0"
2828
clap = "4.2.0"
2929
core-foundation = { version = "0.9.0", features = ["mac_os_10_7_support"] }
30-
crates-io = { version = "0.37.0", path = "crates/crates-io" }
30+
crates-io = { version = "0.38.0", path = "crates/crates-io" }
3131
criterion = { version = "0.5.1", features = ["html_reports"] }
3232
curl = "0.4.44"
3333
curl-sys = "0.4.63"
@@ -87,6 +87,7 @@ syn = { version = "2.0.14", features = ["extra-traits", "full"] }
8787
tar = { version = "0.4.38", default-features = false }
8888
tempfile = "3.1.0"
8989
termcolor = "1.1.2"
90+
thiserror = "1.0.40"
9091
time = { version = "0.3", features = ["parsing", "formatting"] }
9192
toml = "0.7.0"
9293
toml_edit = "0.19.0"

crates/crates-io/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "crates-io"
3-
version = "0.37.0"
3+
version = "0.38.0"
44
edition.workspace = true
55
license.workspace = true
66
repository = "https://github.com/rust-lang/cargo"
@@ -13,9 +13,9 @@ name = "crates_io"
1313
path = "lib.rs"
1414

1515
[dependencies]
16-
anyhow.workspace = true
1716
curl.workspace = true
1817
percent-encoding.workspace = true
1918
serde = { workspace = true, features = ["derive"] }
2019
serde_json.workspace = true
20+
thiserror.workspace = true
2121
url.workspace = true

crates/crates-io/lib.rs

Lines changed: 79 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
#![allow(clippy::all)]
22

33
use std::collections::BTreeMap;
4-
use std::fmt;
54
use std::fs::File;
65
use std::io::prelude::*;
76
use std::io::{Cursor, SeekFrom};
87
use std::time::Instant;
98

10-
use anyhow::{bail, format_err, Context, Result};
119
use curl::easy::{Easy, List};
1210
use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
1311
use serde::{Deserialize, Serialize};
1412
use url::Url;
1513

14+
pub type Result<T> = std::result::Result<T, Error>;
15+
1616
pub struct Registry {
1717
/// The base URL for issuing API requests.
1818
host: String,
@@ -125,67 +125,62 @@ struct Crates {
125125
meta: TotalCrates,
126126
}
127127

128-
#[derive(Debug)]
129-
pub enum ResponseError {
130-
Curl(curl::Error),
128+
/// Error returned when interacting with a registry.
129+
#[derive(Debug, thiserror::Error)]
130+
pub enum Error {
131+
/// Error from libcurl.
132+
#[error(transparent)]
133+
Curl(#[from] curl::Error),
134+
135+
/// Error from seriailzing the request payload and deserialzing the
136+
/// response body (like response body didn't match expected structure).
137+
#[error(transparent)]
138+
Json(#[from] serde_json::Error),
139+
140+
/// Error from IO. Mostly from reading the tarball to upload.
141+
#[error("failed to seek tarball")]
142+
Io(#[from] std::io::Error),
143+
144+
/// Response body was not valid utf8.
145+
#[error("invalid response body from server")]
146+
Utf8(#[from] std::string::FromUtf8Error),
147+
148+
/// Error from API response containing JSON field `errors.details`.
149+
#[error(
150+
"the remote server responded with an error{}: {}",
151+
status(*code),
152+
errors.join(", "),
153+
)]
131154
Api {
132155
code: u32,
156+
headers: Vec<String>,
133157
errors: Vec<String>,
134158
},
159+
160+
/// Error from API response which didn't have pre-programmed `errors.details`.
161+
#[error(
162+
"failed to get a 200 OK response, got {code}\nheaders:\n\t{}\nbody:\n{body}",
163+
headers.join("\n\t"),
164+
)]
135165
Code {
136166
code: u32,
137167
headers: Vec<String>,
138168
body: String,
139169
},
140-
Other(anyhow::Error),
141-
}
142-
143-
impl std::error::Error for ResponseError {
144-
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
145-
match self {
146-
ResponseError::Curl(..) => None,
147-
ResponseError::Api { .. } => None,
148-
ResponseError::Code { .. } => None,
149-
ResponseError::Other(e) => Some(e.as_ref()),
150-
}
151-
}
152-
}
153170

154-
impl fmt::Display for ResponseError {
155-
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
156-
match self {
157-
ResponseError::Curl(e) => write!(f, "{}", e),
158-
ResponseError::Api { code, errors } => {
159-
f.write_str("the remote server responded with an error")?;
160-
if *code != 200 {
161-
write!(f, " (status {} {})", code, reason(*code))?;
162-
};
163-
write!(f, ": {}", errors.join(", "))
164-
}
165-
ResponseError::Code {
166-
code,
167-
headers,
168-
body,
169-
} => write!(
170-
f,
171-
"failed to get a 200 OK response, got {}\n\
172-
headers:\n\
173-
\t{}\n\
174-
body:\n\
175-
{}",
176-
code,
177-
headers.join("\n\t"),
178-
body
179-
),
180-
ResponseError::Other(..) => write!(f, "invalid response from server"),
181-
}
182-
}
183-
}
184-
185-
impl From<curl::Error> for ResponseError {
186-
fn from(error: curl::Error) -> Self {
187-
ResponseError::Curl(error)
188-
}
171+
/// Reason why the token was invalid.
172+
#[error("{0}")]
173+
InvalidToken(&'static str),
174+
175+
/// Server was unavailable and timeouted. Happened when uploading a way
176+
/// too large tarball to crates.io.
177+
#[error(
178+
"Request timed out after 30 seconds. If you're trying to \
179+
upload a crate it may be too large. If the crate is under \
180+
10MB in size, you can email [email protected] for assistance.\n\
181+
Total size was {0}."
182+
)]
183+
Timeout(u64),
189184
}
190185

191186
impl Registry {
@@ -221,10 +216,9 @@ impl Registry {
221216
}
222217

223218
fn token(&self) -> Result<&str> {
224-
let token = match self.token.as_ref() {
225-
Some(s) => s,
226-
None => bail!("no upload token found, please run `cargo login`"),
227-
};
219+
let token = self.token.as_ref().ok_or_else(|| {
220+
Error::InvalidToken("no upload token found, please run `cargo login`")
221+
})?;
228222
check_token(token)?;
229223
Ok(token)
230224
}
@@ -270,12 +264,8 @@ impl Registry {
270264
// This checks the length using seeking instead of metadata, because
271265
// on some filesystems, getting the metadata will fail because
272266
// the file was renamed in ops::package.
273-
let tarball_len = tarball
274-
.seek(SeekFrom::End(0))
275-
.with_context(|| "failed to seek tarball")?;
276-
tarball
277-
.seek(SeekFrom::Start(0))
278-
.with_context(|| "failed to seek tarball")?;
267+
let tarball_len = tarball.seek(SeekFrom::End(0))?;
268+
tarball.seek(SeekFrom::Start(0))?;
279269
let header = {
280270
let mut w = Vec::new();
281271
w.extend(&(json.len() as u32).to_le_bytes());
@@ -300,18 +290,12 @@ impl Registry {
300290
let body = self
301291
.handle(&mut |buf| body.read(buf).unwrap_or(0))
302292
.map_err(|e| match e {
303-
ResponseError::Code { code, .. }
293+
Error::Code { code, .. }
304294
if code == 503
305295
&& started.elapsed().as_secs() >= 29
306296
&& self.host_is_crates_io() =>
307297
{
308-
format_err!(
309-
"Request timed out after 30 seconds. If you're trying to \
310-
upload a crate it may be too large. If the crate is under \
311-
10MB in size, you can email [email protected] for assistance.\n\
312-
Total size was {}.",
313-
tarball_len
314-
)
298+
Error::Timeout(tarball_len)
315299
}
316300
_ => e.into(),
317301
})?;
@@ -410,10 +394,7 @@ impl Registry {
410394
}
411395
}
412396

413-
fn handle(
414-
&mut self,
415-
read: &mut dyn FnMut(&mut [u8]) -> usize,
416-
) -> std::result::Result<String, ResponseError> {
397+
fn handle(&mut self, read: &mut dyn FnMut(&mut [u8]) -> usize) -> Result<String> {
417398
let mut headers = Vec::new();
418399
let mut body = Vec::new();
419400
{
@@ -427,28 +408,29 @@ impl Registry {
427408
// Headers contain trailing \r\n, trim them to make it easier
428409
// to work with.
429410
let s = String::from_utf8_lossy(data).trim().to_string();
411+
// Don't let server sneak extra lines anywhere.
412+
if s.contains('\n') {
413+
return true;
414+
}
430415
headers.push(s);
431416
true
432417
})?;
433418
handle.perform()?;
434419
}
435420

436-
let body = match String::from_utf8(body) {
437-
Ok(body) => body,
438-
Err(..) => {
439-
return Err(ResponseError::Other(format_err!(
440-
"response body was not valid utf-8"
441-
)))
442-
}
443-
};
421+
let body = String::from_utf8(body)?;
444422
let errors = serde_json::from_str::<ApiErrorList>(&body)
445423
.ok()
446424
.map(|s| s.errors.into_iter().map(|s| s.detail).collect::<Vec<_>>());
447425

448426
match (self.handle.response_code()?, errors) {
449427
(0, None) | (200, None) => Ok(body),
450-
(code, Some(errors)) => Err(ResponseError::Api { code, errors }),
451-
(code, None) => Err(ResponseError::Code {
428+
(code, Some(errors)) => Err(Error::Api {
429+
code,
430+
headers,
431+
errors,
432+
}),
433+
(code, None) => Err(Error::Code {
452434
code,
453435
headers,
454436
body,
@@ -457,6 +439,15 @@ impl Registry {
457439
}
458440
}
459441

442+
fn status(code: u32) -> String {
443+
if code == 200 {
444+
String::new()
445+
} else {
446+
let reason = reason(code);
447+
format!(" (status {code} {reason})")
448+
}
449+
}
450+
460451
fn reason(code: u32) -> &'static str {
461452
// Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
462453
match code {
@@ -520,7 +511,7 @@ pub fn is_url_crates_io(url: &str) -> bool {
520511
/// registries only create tokens in that format so that is as less restricted as possible.
521512
pub fn check_token(token: &str) -> Result<()> {
522513
if token.is_empty() {
523-
bail!("please provide a non-empty token");
514+
return Err(Error::InvalidToken("please provide a non-empty token"));
524515
}
525516
if token.bytes().all(|b| {
526517
// This is essentially the US-ASCII limitation of
@@ -531,9 +522,9 @@ pub fn check_token(token: &str) -> Result<()> {
531522
}) {
532523
Ok(())
533524
} else {
534-
Err(anyhow::anyhow!(
525+
Err(Error::InvalidToken(
535526
"token contains invalid characters.\nOnly printable ISO-8859-1 characters \
536-
are allowed as it is sent in a HTTPS header."
527+
are allowed as it is sent in a HTTPS header.",
537528
))
538529
}
539530
}

tests/testsuite/publish.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2023,10 +2023,10 @@ fn api_other_error() {
20232023
[ERROR] failed to publish to registry at http://127.0.0.1:[..]/
20242024
20252025
Caused by:
2026-
invalid response from server
2026+
invalid response body from server
20272027
20282028
Caused by:
2029-
response body was not valid utf-8
2029+
invalid utf-8 sequence of [..]
20302030
",
20312031
)
20322032
.run();

0 commit comments

Comments
 (0)