Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/bun_core/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1330,13 +1330,18 @@ pub(crate) mod strings_impl {
debug_assert!(!b.is_empty());
debug_assert!(!a.is_empty());

// Miri has no shim for either libc call, and `bun_url`'s unit tests reach this.
#[cfg(miri)]
{
a.eq_ignore_ascii_case(&b[..a.len()])
}
// SAFETY: a.len() <= b.len() here; strncasecmp reads at most a.len() bytes from each.
#[cfg(not(windows))]
#[cfg(all(not(miri), not(windows)))]
unsafe {
libc::strncasecmp(a.as_ptr().cast(), b.as_ptr().cast(), a.len()) == 0
}
// Windows MSVC libc has no `strncasecmp`; `_strnicmp` is the equivalent.
#[cfg(windows)]
#[cfg(all(not(miri), windows))]
unsafe {
unsafe extern "C" {
fn _strnicmp(
Expand Down
5 changes: 3 additions & 2 deletions src/dotenv/env_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ impl Loader {
}

pub fn get_http_proxy_for(&self, url: &URL<'_>) -> Option<URL<'_>> {
let proxy = URL::parse(self.proxy_env_for_scheme(url.is_http())?);
// `http://DOMAIN\user:pass@proxy:8080` is a domain login, as curl reads it.
let proxy = URL::parse_single_reader(self.proxy_env_for_scheme(url.is_http())?);
Comment thread
robobun marked this conversation as resolved.
if self.is_no_proxy(url.hostname, url.get_port_auto()) {
return None;
}
Expand Down Expand Up @@ -372,7 +373,7 @@ impl Loader {
let value = self
.get_lower_then_upper(b"all_proxy", b"ALL_PROXY")
.filter(|p| !Self::is_emptyish(p))?;
let url = URL::parse(value);
let url = URL::parse_single_reader(value);
(url.protocol.is_empty() || url.has_http_like_protocol()).then_some(value)
}

Expand Down
3 changes: 2 additions & 1 deletion src/http/AsyncHTTP.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ fn make_client<'a>(
result_callback: noop_callback(),
if_modified_since: b"",
request_content_len_buf: [0u8; b"18446744073709551615".len()],
http_proxy,
// The client dials and authenticates a proxy from this one parse, whoever made the URL.
http_proxy: http_proxy.map(|proxy| URL::parse_single_reader(proxy.href)),
proxy_settings: None,
proxy_headers,
proxy_authorization: None,
Expand Down
2 changes: 1 addition & 1 deletion src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2707,7 +2707,7 @@ impl<'a> HTTPClient<'a> {
Some(href) => {
// SAFETY: self-borrow. `href` points into `self.proxy_settings`'s
// boxed storage, which lives as long as `self` (>= `'a`).
let proxy: URL<'a> = unsafe { URL::parse(href).erase_lifetime() };
let proxy: URL<'a> = unsafe { URL::parse_single_reader(href).erase_lifetime() };
self.proxy_authorization = async_http::basic_authorization(&proxy);
self.http_proxy = Some(proxy);
}
Expand Down
4 changes: 2 additions & 2 deletions src/install/NetworkTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,8 @@ fn count_auth(header_builder: &mut HeaderBuilder, scope: &npm::registry::Scope)
fn split_url_userinfo(url: &[u8]) -> Option<(&[u8], Box<[u8]>)> {
let authority_start = strings::index_of(url, b"://")? + b"://".len();
let rest = &url[authority_start..];
let authority = &rest[..strings::index_of_any(rest, b"/?#").unwrap_or(rest.len())];
let at = strings::last_index_of_char(authority, b'@')?;
// npm reads a tarball URL with `new URL()`, so the authority ends where that ends.
let at = URL::parse(url).userinfo_end(rest, bun_url::AuthorityEnd::LikeNewURL)?;
Comment thread
robobun marked this conversation as resolved.

let mut without_userinfo = Vec::with_capacity(url.len() - (at + 1));
without_userinfo.extend_from_slice(&url[..authority_start]);
Expand Down
158 changes: 131 additions & 27 deletions src/url/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,15 @@ pub use whatwg::{
file_url_from_string, href_from_string, join, origin_from_slice, path_from_file_url,
};

/// Where the authority ends, which is where the search for the `@` of the userinfo stops.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum AuthorityEnd {
/// `/`, `?`, `#`, and a `\` in a special scheme. For a string that something else reads too.
LikeNewURL,
/// `/`, `?` or `#`, so a `\` stays userinfo. Only for a string this parser alone reads.
SlashQueryOrHash,
}

// URL is a pure view struct — every field is a slice into `href` (or a
// literal default).
#[derive(Clone)]
Expand All @@ -203,6 +212,8 @@ pub struct URL<'a> {
pub(crate) search_params: Option<QueryStringMap>,
pub username: &'a [u8],
pub(crate) port_was_automatically_set: bool,
/// The rule `parse` used, so `href_without_userinfo` cuts the same bytes.
pub(crate) authority_end: AuthorityEnd,
}

impl<'a> Default for URL<'a> {
Expand All @@ -222,6 +233,7 @@ impl<'a> Default for URL<'a> {
search_params: None,
username: b"",
port_was_automatically_set: false,
authority_end: AuthorityEnd::LikeNewURL,
}
}
}
Expand Down Expand Up @@ -312,6 +324,7 @@ impl<'a> URL<'a> {
search_params: self.search_params,
username: d(self.username),
port_was_automatically_set: self.port_was_automatically_set,
authority_end: self.authority_end,
}
}

Expand Down Expand Up @@ -421,6 +434,39 @@ impl<'a> URL<'a> {
strings::eql_case_insensitive_ascii(self.protocol, b"http", true)
}

/// The schemes WHATWG calls special: a `\` ends the authority of these, as a `/` does.
fn has_special_scheme(&self) -> bool {
strings::eql_any_case_insensitive_ascii(
self.protocol,
&[b"http", b"https", b"ws", b"wss", b"ftp", b"file"],
)
}

fn backslash_ends_authority(&self, end: AuthorityEnd) -> bool {
end == AuthorityEnd::LikeNewURL && self.has_special_scheme()
}

/// The one definition of where an authority ends, for the userinfo, the host and the port.
fn ends_authority(byte: u8, backslash_ends_it: bool) -> bool {
matches!(byte, b'/' | b'?' | b'#') || (backslash_ends_it && byte == b'\\')
}

/// The last `@` of the authority of `after_scheme`, the text after `scheme://`.
pub fn userinfo_end(&self, after_scheme: &[u8], end: AuthorityEnd) -> Option<usize> {
let backslash_ends_it = self.backslash_ends_authority(end);
let mut last_at = None;
// One pass over the authority, which is short.
for (i, &byte) in after_scheme.iter().enumerate() {
if Self::ends_authority(byte, backslash_ends_it) {
break;
}
if byte == b'@' {
last_at = Some(i);
}
}
last_at
}

pub fn display_hostname(&self) -> &[u8] {
if !self.hostname.is_empty() {
self.hostname
Expand Down Expand Up @@ -481,13 +527,11 @@ impl<'a> URL<'a> {
if self.username.is_empty() && self.password.is_empty() {
return Cow::Borrowed(self.href);
}
// The userinfo ends at the last `@` of the authority, as `parse` reads it.
let Some(authority) = strings::index_of(self.href, b"://").map(|i| i + 3) else {
return Cow::Borrowed(self.href);
};
let rest = &self.href[authority..];
let end = strings::index_of_any(rest, b"/?#").unwrap_or(rest.len());
let Some(at) = strings::last_index_of_char(&rest[..end], b'@') else {
let Some(at) = self.userinfo_end(rest, self.authority_end) else {
return Cow::Borrowed(self.href);
};
let mut out = Vec::with_capacity(self.href.len() - at - 1);
Expand Down Expand Up @@ -646,12 +690,23 @@ impl<'a> URL<'a> {
}
}

/// Reads the authority as `new URL()` reads it. See [`URL::parse_single_reader`] for the other rule.
pub fn parse(base: &'a [u8]) -> URL<'a> {
Self::parse_with(base, AuthorityEnd::LikeNewURL)
}

/// `parse` for a string this parser alone reads, where a `\` before the `@` is userinfo.
pub fn parse_single_reader(base: &'a [u8]) -> URL<'a> {
Self::parse_with(base, AuthorityEnd::SlashQueryOrHash)
}

fn parse_with(base: &'a [u8], authority_end: AuthorityEnd) -> URL<'a> {
if base.is_empty() {
return URL::default();
}
let mut url = URL {
href: base,
authority_end,
..Default::default()
};
let mut offset: u32 = 0;
Expand Down Expand Up @@ -681,17 +736,7 @@ impl<'a> URL<'a> {
// what precedes the last `@` of the authority.
if offset > 0 {
let rest = &base[offset as usize..];
// One pass over the authority, which is short: the last
// `@` before the first `/`, `?` or `#` ends the userinfo.
let mut last_at = None;
for (i, &byte) in rest.iter().enumerate() {
match byte {
b'@' => last_at = Some(i),
b'/' | b'?' | b'#' => break,
_ => {}
}
}
if let Some(at) = last_at {
if let Some(at) = url.userinfo_end(rest, authority_end) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let userinfo = &rest[..at];
(url.username, url.password) =
strings::split_once_char(userinfo, b':').unwrap_or((userinfo, b""));
Expand Down Expand Up @@ -802,7 +847,12 @@ impl<'a> URL<'a> {
b':' => {
if i + 3 <= str.len() && str[i + 1] == b'/' && str[i + 2] == b'/' {
self.protocol = &str[0..i];
return Some(u32::try_from(i + 3).expect("int cast"));
// RFC 3986 §3.1: only behind `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` is there an authority.
let is_scheme = self.protocol.first().is_some_and(u8::is_ascii_alphabetic)
&& self.protocol.iter().all(|byte| {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'+' | b'-' | b'.')
});
return is_scheme.then(|| u32::try_from(i + 3).expect("int cast"));
}
}
_ => {}
Expand Down Expand Up @@ -843,6 +893,7 @@ impl<'a> URL<'a> {

pub(crate) fn parse_host(&mut self, str: &'a [u8]) -> Option<u32> {
let mut i: u32 = 0;
let backslash_ends_it = self.backslash_ends_authority(self.authority_end);

// reset it
self.host = b"";
Expand All @@ -866,12 +917,8 @@ impl<'a> URL<'a> {
} else {
colon_i
};
match str[i as usize] {
// alright, we found the slash or "?"
b'?' | b'/' => {
break;
}
_ => {}
if Self::ends_authority(str[i as usize], backslash_ends_it) {
break;
}
i += 1;
}
Expand Down Expand Up @@ -900,12 +947,8 @@ impl<'a> URL<'a> {
colon_i
};

match str[i as usize] {
// alright, we found the slash or "?"
b'?' | b'/' => {
break;
}
_ => {}
if Self::ends_authority(str[i as usize], backslash_ends_it) {
break;
}
i += 1;
}
Expand Down Expand Up @@ -1803,6 +1846,67 @@ mod tests {
assert_eq!(url.hash, b"#frag?x=2");
}

#[test]
fn the_authority_ends_where_new_url_ends_it() {
let url = URL::parse(br"http://u:p@first.example:8080\x@second.example/path");
assert_eq!((url.username, url.password), (&b"u"[..], &b"p"[..]));
assert_eq!(
(url.hostname, url.port),
(&b"first.example"[..], &b"8080"[..])
);

let url = URL::parse(b"HTTPS://u:p@first.example:8443#@second.example/");
assert_eq!((url.username, url.password), (&b"u"[..], &b"p"[..]));
assert_eq!(
(url.hostname, url.port),
(&b"first.example"[..], &b"8443"[..])
);

// In a scheme that is not special, a `\` is part of the userinfo, as for `new URL()`.
let url = URL::parse(br"socks5://u:p@first.example\x@second.example/");
assert_eq!(
(url.username, url.password),
(&b"u"[..], &br"p@first.example\x"[..])
);
assert_eq!(url.hostname, b"second.example");
}

#[test]
fn a_proxy_keeps_a_domain_login() {
let proxy = URL::parse_single_reader(br"http://DOMAIN\user:pass@proxy.example:8080");
assert_eq!(
(proxy.username, proxy.password),
(&br"DOMAIN\user"[..], &b"pass"[..])
);
assert_eq!(
(proxy.hostname, proxy.port),
(&b"proxy.example"[..], &b"8080"[..])
);
assert_eq!(
&*proxy.href_without_userinfo(),
b"http://proxy.example:8080"
);
}

#[test]
fn no_host_is_read_behind_a_second_scheme() {
let url = URL::parse(b"http:first.example://second.example/");
assert_eq!(url.protocol, b"http:first.example");
assert_eq!(url.hostname, b"http");

let url = URL::parse(b"blob:http://second.example/id");
assert_eq!(url.protocol, b"blob:http");
assert_eq!(url.hostname, b"blob");

let url = URL::parse(b"1http://second.example/");
assert_eq!(url.protocol, b"1http");
assert_eq!(url.hostname, b"1http");

let url = URL::parse(b"localhost:3000/api");
assert_eq!(url.protocol, b"");
assert_eq!((url.hostname, url.port), (&b"localhost"[..], &b"3000"[..]));
}

#[test]
fn join_normalizes_the_path() {
assert_eq!(
Expand Down
24 changes: 24 additions & 0 deletions test/cli/install/bun-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,30 @@ describe.concurrent("bun-install", () => {
});
});

it("sends the credentials to the host in front of a backslash, not the one behind it", async () => {
// `new URL("http://u:p@first\\x@second/pkg.tgz")` reads the host as `first` and the
// credentials as `u:p`, because a `\` ends the authority of an http URL. npm reads it the
// same way. The path of the request is not compared: Windows turns the `\` into a `/`.
const firstReceived: Received[] = [];
const secondReceived: Received[] = [];
await using first = serveTarball(firstReceived, basic("u:p"));
await using second = serveTarball(secondReceived, null);

const result = await install(
String.raw`http://u:p@127.0.0.1:${first.port}\x@127.0.0.1:${second.port}${tarballPath}`,
);

expect({
first: firstReceived.map(({ url, authorization }) => ({ host: new URL(url).host, authorization })),
secondReceived,
...result,
}).toEqual({
first: [{ host: `127.0.0.1:${first.port}`, authorization: basic("u:p") }],
secondReceived: [],
...installed,
});
});

it("keeps the credentials across a redirect within the host", async () => {
const received: Received[] = [];
await using server = serveTarball(received, basic("carol:s3cret"));
Expand Down
Loading
Loading